diff --git a/.config/nextest.toml b/.config/nextest.toml new file mode 100644 index 0000000000..2a4cd2cb93 --- /dev/null +++ b/.config/nextest.toml @@ -0,0 +1,3 @@ +# Minimum cargo-nextest for this repository: GHSA-vc25-qg58-w57c is fixed in 0.9.150 and CI +# installs exactly that version, so an older nextest refuses to run (exit 92 REQUIRED_VERSION_NOT_MET). +nextest-version = "0.9.150" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9d168c7aa6..94a4c545fe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -82,7 +82,7 @@ jobs: 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.149,cargo-deny@0.20.2 + 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 diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml index f54b0538b9..ea61d84303 100644 --- a/.github/workflows/packaging-lint.yml +++ b/.github/workflows/packaging-lint.yml @@ -21,3 +21,27 @@ jobs: lint: # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. uses: storytold/craft-common-actions/.github/workflows/packaging-lint.yml@main + + windows-install-policy: + timeout-minutes: 10 + name: Windows installation policy (no app build) + runs-on: windows-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Install the pinned packaging toolchain + shell: pwsh + run: | + $tools = Join-Path $env:RUNNER_TEMP 'craft-install-policy-wix' + dotnet tool install --tool-path $tools wix --version 5.0.2 + if ($LASTEXITCODE -ne 0) { throw 'WiX installation failed.' } + $tools >> $env:GITHUB_PATH + $policy = Get-Content -Raw -LiteralPath packaging/windows/install-policy.json | ConvertFrom-Json + foreach ($extension in @($policy.extensions)) { + & (Join-Path $tools 'wix.exe') extension add -g "WixToolset.$extension.wixext/5.0.2" + if ($LASTEXITCODE -ne 0) { throw "WiX extension $extension installation failed." } + } + - name: Build and check non-executable installer fixtures + shell: pwsh + run: ./packaging/windows/test-install-policy.ps1 -OutputDirectory (Join-Path $env:RUNNER_TEMP 'craft-install-policy-tests') diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml index d470419d26..838af4524c 100644 --- a/.github/workflows/windows-arm64.yml +++ b/.github/workflows/windows-arm64.yml @@ -27,8 +27,9 @@ permissions: jobs: windows-arm64: - # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. - uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@main + uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@8bb4e5ab95443171104ad88113fe1ef9b0e5ce3f with: app-name: pdfcraft app-dir: PdfCraft + install-relative-path: Programs/ArtCraft/PdfCraft + install-scope: perUser diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index c5be9d7bc1..23ebcbbe82 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -8,34 +8,34 @@ Every asset PdfCraft includes, bundles or uses to build its published material, | Asset | Title | Author | Licence | Source | Used for | |---|---|---|---|---|---| -| `crates/ui-egui/src/i18n/el.tsv` | Greek interface translation catalog | Camthalion77 and contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft English UI labels; 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/mod.rs | -| `crates/ui-egui/src/i18n/zh-hans.tsv` | Simplified Chinese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 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/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; 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/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; 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/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; 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/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; 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/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; 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/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; 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/mod.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/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; 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/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; 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/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; 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/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; 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/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; 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/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; 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/mod.rs | -| `crates/ui-egui/src/i18n/kk.tsv` | Kazakh interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of the English UI labels into Kazakh, no proprietary localisation resources; 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/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; 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/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; 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/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; 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/mod.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/mod.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/mod.rs | -| `crates/ui-egui/src/i18n/tr.tsv` | Turkish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 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/mod.rs | -| `crates/ui-egui/src/i18n/lt.tsv` | Lithuanian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; original catalog reviewed by a native Lithuanian-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 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/mod.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/mod.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/mod.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/mod.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/mod.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/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 | @@ -286,17 +286,17 @@ Every asset PdfCraft includes, bundles or uses to build its published material, | `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/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/mod.rs | -| `crates/ui-egui/src/i18n/sl.tsv` | Slovene interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 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/mod.rs | +| `crates/ui-egui/src/i18n/vi.tsv` | Vietnamese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels; PhotoCraft and VectorCraft Vietnamese catalogs used only as translation memory when English source and semantic context matched; original catalog reviewed by a native Vietnamese-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/sl.tsv` | Slovene interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Slovene UI strings, compiled in by crates/ui-egui/src/i18n.rs | | `vendor/vello_common/assets/probe.png` | vello_common probe reference image (rendered from probe.rgba) | Linebender vello contributors | MIT OR Apache-2.0 | vello_common 0.0.8 (crates.io), vendored in vendor/vello_common; regenerated by its vello_sparse_tests regenerate_probe_reference binary | Reference picture of the vendored vello_common probe module, for people only; the build reads probe.rgba, not this file | -| `crates/ui-egui/src/i18n/nb.tsv` | Norwegian Bokmål interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Norwegian Bokmål UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `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/mod.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/mod.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/mod.rs | +| `crates/ui-egui/src/i18n/sq.tsv` | Albanian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-appearance labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Albanian UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/th.tsv` | Thai interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Thai UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/ca.tsv` | Catalan interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, written from their meaning; no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Catalan UI strings, compiled in by crates/ui-egui/src/i18n.rs | ## Compiled in through dependencies (6) @@ -335,8 +335,8 @@ Fonts are fetched by `cargo xtask demo-pdf` into `target/demo-fonts/`, OCR model | `NotoColorEmoji-Regular.ttf` | Noto Color Emoji | Google Inc. | OFL-1.1 | https://github.com/google/fonts/tree/9710da1eacb3be272583c3224dcb70f9da6eadbb/ofl/notocoloremoji | Showcase PDF (cargo xtask demo-pdf) | | `Inter[opsz,wght].ttf` | Inter (variable) | The Inter Project Authors (Rasmus Andersson) | OFL-1.1 | https://github.com/google/fonts/tree/9710da1eacb3be272583c3224dcb70f9da6eadbb/ofl/inter | Showcase PDF (cargo xtask demo-pdf) | | `Inter-Italic[opsz,wght].ttf` | Inter Italic (variable) | The Inter Project Authors (Rasmus Andersson) | OFL-1.1 | https://github.com/google/fonts/tree/9710da1eacb3be272583c3224dcb70f9da6eadbb/ofl/inter | Showcase PDF (cargo xtask demo-pdf) | -| `text-detection.rten` | Ocrs text detection model | Robert Knight (ocrs-models; trained on HierText, CC-BY-SA-4.0) | CC-BY-SA-4.0 | https://github.com/robertknight/ocrs-models (the models ocrs downloads; model card: https://huggingface.co/robertknight/ocrs, cc-by-sa-4.0) | Scan & OCR: finds words on page images (cargo xtask models) | -| `text-recognition.rten` | Ocrs text recognition model | Robert Knight (ocrs-models; trained on HierText, CC-BY-SA-4.0) | CC-BY-SA-4.0 | https://github.com/robertknight/ocrs-models (the models ocrs downloads; model card: https://huggingface.co/robertknight/ocrs, cc-by-sa-4.0) | Scan & OCR: reads the words it finds (cargo xtask models) | +| `text-detection.onnx` | Ocrs text detection model | Robert Knight (ocrs-models; trained on HierText, CC-BY-SA-4.0) | CC-BY-SA-4.0 | https://github.com/robertknight/ocrs/blob/0c826441362a701c55560fefc17bf356b6d1037e/ocrs/examples/download-models.sh (author-published ONNX models; model card: https://huggingface.co/robertknight/ocrs/blob/df0edd170279ab971b53e094c627255a87e1a503/README.md, cc-by-sa-4.0) | Scan & OCR: finds words on page images (cargo xtask models) | +| `text-recognition.onnx` | Ocrs text recognition model | Robert Knight (ocrs-models; trained on HierText, CC-BY-SA-4.0) | CC-BY-SA-4.0 | https://github.com/robertknight/ocrs/blob/0c826441362a701c55560fefc17bf356b6d1037e/ocrs/examples/download-models.sh (author-published ONNX models; model card: https://huggingface.co/robertknight/ocrs/blob/df0edd170279ab971b53e094c627255a87e1a503/README.md, cc-by-sa-4.0) | Scan & OCR: reads the words it finds (cargo xtask models) | ## Optional build inputs (1) diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index 8b2186d5b4..489247559d 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -6,7 +6,7 @@ source = "Contributor-original: clean-room translations of PdfCraft English UI l licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "Greek UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" +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`). @@ -30,7 +30,7 @@ source = "Contributor-original: clean-room translations of PdfCraft's English UI 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" +usage = "Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/zh-hant.tsv" @@ -40,7 +40,7 @@ source = "Contributor-original: clean-room translations of PdfCraft's English UI 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" +usage = "Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/cs.tsv" @@ -50,7 +50,7 @@ source = "Contributor-original: clean-room translations of PdfCraft's English UI 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" +usage = "Czech UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/pt-br.tsv" @@ -60,7 +60,7 @@ source = "Clean-room translations of PdfCraft's English UI labels, no proprietar 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" +usage = "Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/es.tsv" @@ -70,7 +70,7 @@ source = "Contributor-original: clean-room translations of PdfCraft's English UI 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" +usage = "Spanish UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/fr.tsv" @@ -80,7 +80,7 @@ source = "Contributor-original: clean-room translations of PdfCraft's English UI 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" +usage = "French UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/de.tsv" @@ -90,7 +90,7 @@ source = "Contributor-original: clean-room translations of PdfCraft's English UI 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" +usage = "German UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/lo.tsv" @@ -100,7 +100,7 @@ source = "Contributor-original: clean-room translations of PdfCraft English UI l licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "Lao UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" +usage = "Lao UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/te.tsv" @@ -110,7 +110,7 @@ source = "Contributor-original: clean-room translations of PdfCraft's English UI 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" +usage = "Telugu UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/it.tsv" @@ -120,7 +120,7 @@ source = "Contributor-original: clean-room translations of PdfCraft's English UI 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" +usage = "Italian UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/pl.tsv" @@ -130,7 +130,7 @@ source = "Contributor-original: clean-room translations of PdfCraft's English UI 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" +usage = "Polish UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/ru.tsv" @@ -140,7 +140,7 @@ source = "Contributor-original: clean-room translations of PdfCraft's English UI 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" +usage = "Russian UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/hu.tsv" @@ -150,7 +150,7 @@ source = "Contributor-original: clean-room translations of PdfCraft's English UI 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" +usage = "Hungarian UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/bg.tsv" @@ -160,7 +160,7 @@ source = "Contributor-original: clean-room translations of PdfCraft's English UI 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" +usage = "Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/kk.tsv" @@ -170,7 +170,7 @@ source = "Contributor-original: clean-room translations of the English UI labels licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "Kazakh UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" +usage = "Kazakh UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/ja.tsv" @@ -180,7 +180,7 @@ source = "Contributor-original: clean-room translations of PdfCraft's English UI 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" +usage = "Japanese UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/uk.tsv" @@ -190,7 +190,7 @@ source = "Contributor-original: clean-room translations of PdfCraft's English UI 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" +usage = "Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/ar.tsv" @@ -200,7 +200,7 @@ source = "Contributor-original: clean-room translations of PdfCraft's English UI 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" +usage = "Arabic UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/ckb.tsv" @@ -210,7 +210,7 @@ source = "Contributor-original: clean-room translations of PdfCraft's English UI 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/mod.rs" +usage = "Central Kurdish UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/fa.tsv" @@ -220,7 +220,7 @@ source = "Contributor-original: clean-room translations of PdfCraft's English UI licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "Persian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" +usage = "Persian UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/tr.tsv" @@ -230,7 +230,7 @@ source = "Contributor-original: clean-room translations of PdfCraft's English UI licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "Turkish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" +usage = "Turkish UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/lt.tsv" @@ -240,7 +240,7 @@ source = "Contributor-original: clean-room translations of PdfCraft's English UI licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" +usage = "Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/hi.tsv" @@ -250,7 +250,7 @@ source = "Contributor-original: clean-room translations of PdfCraft's English UI licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "Hindi UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" +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" @@ -259,7 +259,7 @@ source = "Contributor-original: clean-room translations of PdfCraft's English UI licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "Indonesian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" +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" @@ -268,7 +268,7 @@ source = "Contributor-original: clean-room translations of PdfCraft's English UI licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "Korean UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" +usage = "Korean UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/ro.tsv" @@ -278,7 +278,7 @@ source = "Contributor-original: clean-room translations of PdfCraft's English UI licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "Romanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" +usage = "Romanian UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] @@ -289,7 +289,7 @@ source = "Contributor-original: AI-assisted (Claude Code) clean-room translation licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "Finnish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" +usage = "Finnish UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "assets/icons/accessibility.svg" @@ -3356,30 +3356,30 @@ usage = "1x + 2x HiDPI 16-colour palette TIFF copied into the macOS DMG by packa sha256 = "f46881724a6621f3686a612a79dbdce4f7dbe9e2025610e44c9a094341334c2c" [[fetched]] -file = "text-detection.rten" -url = "https://ocrs-models.s3-accelerate.amazonaws.com/text-detection.rten" +file = "text-detection.onnx" +url = "https://ocrs-models.s3-accelerate.amazonaws.com/text-detection.onnx" title = "Ocrs text detection model" author = "Robert Knight (ocrs-models; trained on HierText, CC-BY-SA-4.0)" -source = "https://github.com/robertknight/ocrs-models (the models ocrs downloads; model card: https://huggingface.co/robertknight/ocrs, cc-by-sa-4.0)" +source = "https://github.com/robertknight/ocrs/blob/0c826441362a701c55560fefc17bf356b6d1037e/ocrs/examples/download-models.sh (author-published ONNX models; model card: https://huggingface.co/robertknight/ocrs/blob/df0edd170279ab971b53e094c627255a87e1a503/README.md, cc-by-sa-4.0)" licence = "CC-BY-SA-4.0" licence_url = "https://creativecommons.org/licenses/by-sa/4.0/legalcode.txt" licence_sha256 = "28a9529c7d0bb4dc51f4bf5c116a3d16ef247a052f7591466768ddf563fd1cf5" kind = "model" usage = "Scan & OCR: finds words on page images (cargo xtask models)" -sha256 = "f15cfb56bd02c4bf478a20343986504a1f01e1665c2b3a0ad66340f054b1b5ca" +sha256 = "a917b23dbd9524b465df7e922641b3ff2981623df4ded5a0234004ef2fee7cfe" [[fetched]] -file = "text-recognition.rten" -url = "https://ocrs-models.s3-accelerate.amazonaws.com/text-recognition.rten" +file = "text-recognition.onnx" +url = "https://ocrs-models.s3-accelerate.amazonaws.com/text-recognition.onnx" title = "Ocrs text recognition model" author = "Robert Knight (ocrs-models; trained on HierText, CC-BY-SA-4.0)" -source = "https://github.com/robertknight/ocrs-models (the models ocrs downloads; model card: https://huggingface.co/robertknight/ocrs, cc-by-sa-4.0)" +source = "https://github.com/robertknight/ocrs/blob/0c826441362a701c55560fefc17bf356b6d1037e/ocrs/examples/download-models.sh (author-published ONNX models; model card: https://huggingface.co/robertknight/ocrs/blob/df0edd170279ab971b53e094c627255a87e1a503/README.md, cc-by-sa-4.0)" licence = "CC-BY-SA-4.0" licence_url = "https://creativecommons.org/licenses/by-sa/4.0/legalcode.txt" licence_sha256 = "28a9529c7d0bb4dc51f4bf5c116a3d16ef247a052f7591466768ddf563fd1cf5" kind = "model" usage = "Scan & OCR: reads the words it finds (cargo xtask models)" -sha256 = "e484866d4cce403175bd8d00b128feb08ab42e208de30e42cd9889d8f1735a6e" +sha256 = "86c145c2edb96c8caed5b1ebb8f44d706408922211451c309c625157dd6061c5" [[build_input]] name = "craft-fonts" @@ -3422,7 +3422,7 @@ source = "Contributor-original: clean-room translations of PdfCraft's English UI licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" +usage = "Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/sl.tsv" @@ -3432,7 +3432,7 @@ source = "Contributor-original: clean-room translations of PdfCraft's English UI licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "Slovene UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" +usage = "Slovene UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] @@ -3454,7 +3454,7 @@ source = "Contributor-original: clean-room translations of PdfCraft's English UI 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/mod.rs" +usage = "Norwegian Bokmål UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "assets/icons/superscript.svg" @@ -3508,7 +3508,7 @@ source = "Contributor-original: clean-room translations of PdfCraft's English UI licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "Albanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" +usage = "Albanian UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/th.tsv" @@ -3518,7 +3518,7 @@ source = "Contributor-original: clean-room translations of PdfCraft's English UI licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "Thai UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" +usage = "Thai UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/ca.tsv" @@ -3528,4 +3528,4 @@ source = "Contributor-original: clean-room translations of PdfCraft's English UI licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "Catalan UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" +usage = "Catalan UI strings, compiled in by crates/ui-egui/src/i18n.rs" diff --git a/CLAUDE.md b/CLAUDE.md index f51a96d56e..a37cec4ca6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -39,6 +39,7 @@ PdfCraft is a clean-room, open-source, Rust-native PDF application targeting Ado - **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. diff --git a/Cargo.lock b/Cargo.lock index 3c713ba5cc..4f8e8ac8cd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1023,6 +1023,15 @@ dependencies = [ "x509-cert", ] +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror 2.0.21", +] + [[package]] name = "codespan-reporting" version = "0.13.1" @@ -1169,12 +1178,40 @@ dependencies = [ [[package]] name = "craft-docpath" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63" +source = "git+https://github.com/storytold/craft-libs?rev=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=edc41f68fa24c46651c3b5117d10f0ead75ec9cb#edc41f68fa24c46651c3b5117d10f0ead75ec9cb" +source = "git+https://github.com/storytold/craft-libs?rev=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545" dependencies = [ "egui", "serde", @@ -1184,7 +1221,7 @@ dependencies = [ [[package]] name = "craft-menu" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=edc41f68fa24c46651c3b5117d10f0ead75ec9cb#edc41f68fa24c46651c3b5117d10f0ead75ec9cb" +source = "git+https://github.com/storytold/craft-libs?rev=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545" dependencies = [ "craft-keymap", "craft-quit", @@ -1197,11 +1234,11 @@ dependencies = [ [[package]] name = "craft-print" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=42e30cd5b3f4b114270dd053fccbc33bedc47f68#42e30cd5b3f4b114270dd053fccbc33bedc47f68" +source = "git+https://github.com/storytold/craft-libs?rev=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545" dependencies = [ "craft-winprint-gdi", - "miniz_oxide 0.8.9", - "roxmltree", + "miniz_oxide 0.9.1", + "roxmltree 0.21.2", "tempfile", "winprint", "winsafe", @@ -1210,7 +1247,7 @@ dependencies = [ [[package]] name = "craft-quit" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=edc41f68fa24c46651c3b5117d10f0ead75ec9cb#edc41f68fa24c46651c3b5117d10f0ead75ec9cb" +source = "git+https://github.com/storytold/craft-libs?rev=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545" dependencies = [ "dispatch2", "egui", @@ -1221,20 +1258,28 @@ dependencies = [ [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=edc41f68fa24c46651c3b5117d10f0ead75ec9cb#edc41f68fa24c46651c3b5117d10f0ead75ec9cb" +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=495e5971bc024aa96e6ed318cb6e7cd3a0103184#495e5971bc024aa96e6ed318cb6e7cd3a0103184" +source = "git+https://github.com/storytold/craft-libs?rev=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545" dependencies = [ "chrono", "dunce", @@ -2073,6 +2118,17 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" +[[package]] +name = "fixed_decimal" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2214fb00f669ee96bee5bb29dfb1ddabe058b2bc426817efc79afa03c9bc55ae" +dependencies = [ + "displaydoc", + "smallvec", + "writeable", +] + [[package]] name = "fixedbitset" version = "0.5.7" @@ -2188,7 +2244,7 @@ version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646" dependencies = [ - "roxmltree", + "roxmltree 0.20.0", ] [[package]] @@ -2882,6 +2938,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" @@ -2890,11 +2963,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" @@ -2907,6 +3001,7 @@ dependencies = [ "icu_provider", "smallvec", "utf16_iter", + "utf8_iter", "write16", "zerovec", ] @@ -2917,6 +3012,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" @@ -2946,6 +3067,9 @@ checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" dependencies = [ "displaydoc", "icu_locale_core", + "postcard", + "serde", + "stable_deref_trait", "writeable", "yoke", "zerofrom", @@ -2953,6 +3077,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" @@ -3251,6 +3399,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" @@ -4340,6 +4491,7 @@ dependencies = [ "ocrs", "pdfcraft-fonts", "rten", + "tempfile", "thiserror 2.0.21", ] @@ -4391,7 +4543,7 @@ dependencies = [ "pdfcraft-content", "pdfcraft-cos", "pdfcraft-model", - "roxmltree", + "roxmltree 0.20.0", ] [[package]] @@ -4554,7 +4706,7 @@ dependencies = [ "pdfcraft-cos", "pdfcraft-fonts", "png 0.18.1", - "roxmltree", + "roxmltree 0.20.0", "sha2 0.11.1", "thiserror 2.0.21", ] @@ -4567,7 +4719,7 @@ dependencies = [ "pdfcraft-cos", "pdfcraft-forms", "pdfcraft-model", - "roxmltree", + "roxmltree 0.20.0", "thiserror 2.0.21", ] @@ -4888,12 +5040,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", ] @@ -5444,6 +5608,15 @@ version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" +[[package]] +name = "roxmltree" +version = "0.21.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9da7f59328b09405b181fee1018925fad50f157afb43c0c0f5c522c256dd5907" +dependencies = [ + "memchr", +] + [[package]] name = "rsa" version = "0.10.0-rc.18" @@ -5473,6 +5646,7 @@ dependencies = [ "rten-base", "rten-gemm", "rten-model-file", + "rten-onnx", "rten-parallel", "rten-shape-inference", "rten-simd", @@ -5522,6 +5696,12 @@ dependencies = [ "rten-base", ] +[[package]] +name = "rten-onnx" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96944374568990b8d1bbc5bae5957585866f9dd8256130460405730948bfd42e" + [[package]] name = "rten-parallel" version = "0.26.0" @@ -6515,7 +6695,10 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4" dependencies = [ + "js-sys", "libc", + "wasm-bindgen", + "web-sys", ] [[package]] @@ -6710,6 +6893,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" dependencies = [ "displaydoc", + "serde_core", "zerovec", ] @@ -7078,7 +7262,7 @@ dependencies = [ "kurbo 0.11.3", "log", "pico-args", - "roxmltree", + "roxmltree 0.20.0", "simplecss", "siphasher", "strict-num", @@ -8052,6 +8236,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" @@ -8376,8 +8563,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ "displaydoc", + "litemap", + "serde_core", "yoke", "zerofrom", + "zerovec", ] [[package]] @@ -8386,6 +8576,7 @@ version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" dependencies = [ + "serde", "yoke", "zerofrom", "zerovec-derive", diff --git a/Cargo.toml b/Cargo.toml index 8eb38b29af..f86ebaa15f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,17 +13,17 @@ 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 = "edc41f68fa24c46651c3b5117d10f0ead75ec9cb", 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 = "edc41f68fa24c46651c3b5117d10f0ead75ec9cb" } +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 = "edc41f68fa24c46651c3b5117d10f0ead75ec9cb" } -craft-keymap = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "edc41f68fa24c46651c3b5117d10f0ead75ec9cb", default-features = false } +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 = "42e30cd5b3f4b114270dd053fccbc33bedc47f68" } +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 = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" } -craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184", features = ["egui"] } +craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545" } +craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545", features = ["egui"] } # internal pdfcraft-geom = { path = "crates/geom", version = "0.14.0" } pdfcraft-filters = { path = "crates/filters", version = "0.14.0" } @@ -91,6 +91,9 @@ web-time = "1.1" unsafe_code = "forbid" [workspace.lints.clippy] +cognitive_complexity = "deny" +too_many_arguments = "deny" +wildcard_imports = "deny" dbg_macro = "warn" todo = "warn" diff --git a/README.md b/README.md index f319ffeadb..1766e26ebb 100644 --- a/README.md +++ b/README.md @@ -459,11 +459,13 @@ The portable zip runs from any folder, a USB stick included. Its `portable.txt` logs and crash recovery in a `PdfCraftData` folder next to `pdfcraft.exe`, so nothing is written to `%APPDATA%`; delete that file to use the normal per-user folders. -The MSI installs for all users and requires administrator privileges. It adds "Edit with PdfCraft" +The MSI installs for your account only by default, without administrator privileges. Choose +"Everyone on this computer" on its first page, or pass `ALLUSERS=1` from an elevated terminal, to install +for all users. It adds "Edit with PdfCraft" to the right-click menu of PDF files and "Create PDF with PdfCraft…" to the right-click menu of images (.png .jpg .jpeg .tif .tiff .gif .bmp .jp2 .j2k .jpx) in File Explorer (on Windows 11, under "Show more options"), without changing which app opens them by default. For unattended deployment -without a desktop shortcut, run from an elevated terminal: +without a desktop shortcut (add `ALLUSERS=1`, from an elevated terminal, for an all-users install): ```powershell msiexec /i "pdfcraft--windows-x64.msi" /qn /norestart INSTALLDESKTOPSHORTCUT=0 @@ -471,7 +473,7 @@ msiexec /i "pdfcraft--windows-x64.msi" /qn /norestart INSTALLDESKTOPSHORTCU | Property | `=0` leaves out | |---|---| -| `INSTALLDESKTOPSHORTCUT` | the all-users desktop shortcut | +| `INSTALLDESKTOPSHORTCUT` | the desktop shortcut | | `INSTALLPDFCONTEXTMENU` | "Edit with PdfCraft" on PDF files | | `INSTALLIMAGECONTEXTMENU` | "Create PDF with PdfCraft…" on images | diff --git a/ROADMAP.md b/ROADMAP.md index 165fccc5e8..f5de8044e1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,6 +2,7 @@ **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) @@ -199,6 +200,7 @@ 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. @@ -682,6 +684,7 @@ Newest first: the date, what moved, and the new overall estimate. | Date | Change | Summary | |---|---|---| +| 2026-10-27 | minor | M10: OCR stages SHA-256-verified ONNX models after the upstream RTEN URL brownout; discovery prefers a complete ONNX pair per directory, falls back to a complete RTEN pair, and never mixes formats or directories; language/web limits and estimates unchanged. | | 2026-10-23 | minor | M8, #3205 follow-up: phone-pattern redaction includes the +39 country code on labelled Italian geographic numbers with a slash; dates, protocol identifiers and whole-run guards remain. Estimates unchanged. | | 2026-10-23 | minor | M11: image downsampling measures physical resolution after page-local UserUnit scaling, including shared nested forms; synthetic measurement and optimization regressions. Estimates unchanged. | | 2026-10-23 | minor | M10: Word table cells use the grid columns they span, including cells after a merged cell; synthetic export regression and CLI reproduction. Estimates unchanged. | diff --git a/apps/pdfcraft-cli/src/check.rs b/apps/pdfcraft-cli/src/check.rs new file mode 100644 index 0000000000..62993ad6dc --- /dev/null +++ b/apps/pdfcraft-cli/src/check.rs @@ -0,0 +1,208 @@ +use super::commands::{parse_dpi, read}; +use super::options::{AUDIT_OPTIONS, CHECK_ONE_OPTIONS, CHECK_OPTIONS, flag, positional, validate_options}; +use super::{ + Arc, CliError, Command, Duration, Instant, PageRenderer, Path, PathBuf, RenderConfig, RenderRequest, RequestKind, Stdio, inspect, stdout_line, +}; + +/// Audit a document for faux redactions. Prints the findings as JSON; fails when any are +/// found so scripts and CI can gate on it, and when the audit itself cannot run. +pub(crate) fn audit(args: &[String]) -> Result<(), CliError> { + validate_options("audit", args, AUDIT_OPTIONS)?; + let path = *positional(args, AUDIT_OPTIONS).first().ok_or("audit: missing file")?; + let bytes = read(path)?; + let password = flag(args, "--password"); + let doc = pdfcraft_cos::Document::open_with_password(bytes.clone(), password).map_err(|e| format!("{path}: {e}"))?; + let findings = pdfcraft_audit::audit_redactions(&doc).map_err(|e| format!("{path}: {e}"))?; + // Rectangles in points from the top-left of the displayed page, as `doc_audit_redactions` + // reports them; the page geometry is read only when there is something to place. + let info = if findings.is_empty() { + None + } else { + let render_password = pdfcraft_engine::render_password_for(bytes.clone(), password); + Some(inspect(bytes, render_password.as_deref()).map_err(|e| format!("{path}: {e}"))?) + }; + let items: Vec = findings + .into_iter() + .map(|f| { + let rect = f.rect.map(|v| v as f32); + serde_json::json!({ + "page": f.page + 1, + "kind": f.kind.id(), + "rect": info.as_ref().map_or(rect, |i| pdfcraft_automation::view_rect(i, f.page, rect)), + "covered_text": f.covered_text, + }) + }) + .collect(); + println!("{}", serde_json::json!({ "file": path, "findings": items, "count": items.len() })); + if items.is_empty() { Ok(()) } else { Err(format!("audit: {} faux redaction(s) found", items.len()).into()) } +} + +/// Child-process body for `check`: prints one JSON line. +pub(crate) fn check_one(args: &[String]) -> Result<(), CliError> { + validate_options("check-one", args, CHECK_ONE_OPTIONS)?; + let path = *positional(args, CHECK_ONE_OPTIONS).first().ok_or("check-one: missing file")?; + let dpi = parse_dpi(flag(args, "--dpi").unwrap_or("36"))?; + let start = Instant::now(); + let bytes = read(path)?; + let (status, pages, failed, detail, warnings) = match inspect(bytes.clone(), None) { + Err(e) => (format!("open-error:{}", short(&e.to_string())), 0, 0, e.to_string(), 0), + Ok(info) => { + let mut r = PageRenderer::new(bytes, RenderConfig::default()); + let mut failed = Vec::new(); + for i in 0..info.pages.len().min(500) { + let p = r.render(RenderRequest { page: i, kind: RequestKind::Pixels, tile: None, scale: dpi / 72.0, tag: 0, preview: false }); + if let Some(e) = r.render(RenderRequest { page: i, kind: RequestKind::Text, tile: None, scale: 1.0, tag: 0, preview: false }).error { + failed.push(format!("p{} text: {e}", i + 1)); + } + if let Some(e) = p.error { + failed.push(format!("p{}: {e}", i + 1)); + } + } + let status = if failed.is_empty() { "ok".to_string() } else { "page-errors".to_string() }; + (status, info.pages.len(), failed.len(), failed.join(" | "), info.warnings.len()) + } + }; + // `--edit` (fuzzing): also run our own object layer end to end, with no panic safety net, + // so a panic in cos shows up as a crash of this process. + if args.iter().any(|a| a == "--edit") { + edit_round_trip(&std::fs::read(path).map_err(|e| e.to_string())?); + } + let line = serde_json::json!({ "file": path, "status": status, "pages": pages, "failed_pages": failed, "warnings": warnings, + "ms": start.elapsed().as_millis() as u64, "detail": detail.chars().take(400).collect::() }); + stdout_line(format_args!("{line}"))?; + Ok(()) +} + +/// Open with cos, touch every object, edit the catalog, save incrementally and in full (both +/// output styles), and reopen each result. Errors are fine; panics, hangs and aborts are bugs. +fn edit_round_trip(bytes: &[u8]) { + use pdfcraft_cos::{Document, Object, SaveOptions, write_full, write_incremental}; + let Ok(mut doc) = Document::open(Arc::new(bytes.to_vec())) else { return }; + for num in doc.object_numbers().into_iter().take(20_000) { + if let Ok(o) = doc.try_get(num) + && let Object::Stream(s) = &*o + { + let _ = s.decoded(); + } + } + if let Some(root) = doc.root() { + let _ = doc.update_dict(root, |d| d.set(b"PdfCraftFuzz".to_vec(), Object::Bool(true))); + } + let classic = SaveOptions { object_streams: false, ..SaveOptions::default() }; + for out in [write_incremental(&doc, &SaveOptions::default()), write_full(&doc, &SaveOptions::default()), write_full(&doc, &classic)] + .into_iter() + .flatten() + { + if let Ok(again) = Document::open(Arc::new(out)) { + let _ = again.root().map(|r| again.get(r)); + } + } +} + +fn short(s: &str) -> String { + s.split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()).take(4).collect::>().join("-").to_lowercase() +} + +fn collect(paths: &[&str]) -> Vec { + let mut out = Vec::new(); + let mut stack: Vec = paths.iter().map(PathBuf::from).collect(); + let mut visited = std::collections::HashSet::new(); + while let Some(p) = stack.pop() { + if p.is_dir() { + // Directory aliases and symlink cycles must not scan the same tree repeatedly. + let identity = match p.canonicalize() { + Ok(identity) => identity, + Err(error) => { + eprintln!("{}: could not resolve directory: {error}", p.display()); + continue; + } + }; + if !visited.insert(identity) { + continue; + } + if let Ok(rd) = std::fs::read_dir(&p) { + stack.extend(rd.flatten().map(|e| e.path())); + } + } else if p.extension().and_then(std::ffi::OsStr::to_str).is_some_and(|e| e.eq_ignore_ascii_case("pdf")) { + out.push(p); + } + } + out.sort(); + out +} + +pub(crate) fn check(args: &[String]) -> Result<(), CliError> { + validate_options("check", args, CHECK_OPTIONS)?; + // Every option is checked before the inputs are scanned, so a bad value is one message, + // not one problem per file: a zero timeout used to make every file time out, and a bad + // dpi used to reach each child, which printed nothing and was reported as a crash. + let timeout_value = flag(args, "--timeout").unwrap_or("20"); + let timeout = Duration::from_secs( + timeout_value + .parse::() + .ok() + .filter(|secs| *secs > 0) + .ok_or_else(|| format!("bad --timeout: expected a positive whole number of seconds, got {timeout_value:?}"))?, + ); + let dpi = flag(args, "--dpi").unwrap_or("36").to_string(); + parse_dpi(&dpi)?; + let files = collect(&positional(args, CHECK_OPTIONS)); + if files.is_empty() { + return Err("check: no PDF files found".into()); + } + let exe = std::env::current_exe().map_err(|e| e.to_string())?; + let mut results = Vec::new(); + let mut counts: std::collections::BTreeMap = Default::default(); + for (n, f) in files.iter().enumerate() { + let r = run_child(&exe, f, &dpi, timeout); + let status = r["status"].as_str().unwrap_or("?").to_string(); + *counts.entry(status.split(':').next().unwrap_or("?").to_string()).or_default() += 1; + if status != "ok" { + eprintln!( + "[{}/{}] {status:<28} {} {}", + n + 1, + files.len(), + f.display(), + r["detail"].as_str().unwrap_or("").chars().take(160).collect::() + ); + } + results.push(r); + } + eprintln!("\nchecked {} files: {:?}", files.len(), counts); + if let Some(out) = flag(args, "--json") { + std::fs::write(out, serde_json::to_string_pretty(&results).unwrap_or_default()).map_err(|e| e.to_string())?; + } + Ok(()) +} + +fn run_child(exe: &Path, file: &Path, dpi: &str, timeout: Duration) -> serde_json::Value { + let file_s = file.to_string_lossy().to_string(); + let child = Command::new(exe).args(["check-one", &file_s, "--dpi", dpi]).stdout(Stdio::piped()).stderr(Stdio::null()).spawn(); + let mut child = match child { + Ok(c) => c, + Err(e) => return serde_json::json!({ "file": file_s, "status": "harness-error", "detail": e.to_string() }), + }; + let start = Instant::now(); + loop { + match child.try_wait() { + Ok(Some(status)) => { + let mut out = String::new(); + if let Some(mut so) = child.stdout.take() { + use std::io::Read; + let _ = so.read_to_string(&mut out); + } + return match serde_json::from_str::(out.trim()) { + Ok(v) => v, + Err(_) => serde_json::json!({ "file": file_s, "status": "crash", "detail": format!("child exited with {status}") }), + }; + } + Ok(None) if start.elapsed() > timeout => { + let _ = child.kill(); + let _ = child.wait(); + return serde_json::json!({ "file": file_s, "status": "timeout", "detail": format!("exceeded {}s", timeout.as_secs()) }); + } + Ok(None) => std::thread::sleep(Duration::from_millis(20)), + Err(e) => return serde_json::json!({ "file": file_s, "status": "harness-error", "detail": e.to_string() }), + } + } +} diff --git a/apps/pdfcraft-cli/src/commands.rs b/apps/pdfcraft-cli/src/commands.rs new file mode 100644 index 0000000000..9d0b2057f0 --- /dev/null +++ b/apps/pdfcraft-cli/src/commands.rs @@ -0,0 +1,413 @@ +use super::options::{ + COMBINE_OPTIONS, EXTRACT_OPTIONS, INFO_OPTIONS, RENDER_OPTIONS, SPLIT_OPTIONS, TEXT_OPTIONS, can_be_path_value, flag, needs_value, positional, + validate_options, +}; +use super::{Arc, CliError, ImageFormat, PageRenderer, Path, PathBuf, RenderConfig, RenderRequest, RequestKind, Write, inspect, stdout_line}; + +pub(crate) fn read(path: &str) -> Result>, String> { + std::fs::read(path).map(Arc::new).map_err(|e| format!("{path}: {e}")) +} + +pub(crate) fn info(args: &[String]) -> Result<(), CliError> { + validate_options("info", args, INFO_OPTIONS)?; + let path = *positional(args, INFO_OPTIONS).first().ok_or("info: missing file")?; + let bytes = read(path)?; + let password = flag(args, "--password"); + // The renderer takes only the user password of R2–R4 files; `Session::open` takes either. + let render_password = pdfcraft_engine::render_password_for(bytes.clone(), password); + let mut info = inspect(bytes.clone(), render_password.as_deref()).map_err(|e| e.to_string())?; + let pdf_pages = info.pages.len(); + let acroform_fields = info.fields.len(); + // Dynamic XFA forms: say what laying the template out gives (what the app shows). + let xfa_layout = (info.xfa == Some(pdfcraft_render::Xfa::Dynamic)) + .then(|| { + let mut s = pdfcraft_engine::Session::new(); + let id = s.open("info.pdf", None, bytes.clone(), password).ok()?; + let d = s.get(id)?; + d.xfa.as_ref().map(|x| { + info = d.info.clone(); + // The in-memory layout is an appended revision, not the file being inspected. + info.file_size = bytes.len(); + serde_json::json!({ "pages": x.pages, "fields": x.fields, "warnings": x.warnings }) + }) + }) + .flatten(); + // What the document declares as a standard, read the way the Standards panel reads it, so + // `info` and the app agree. A file that declares nothing leaves these null and empty. + let declared = + pdfcraft_cos::Document::open_with_password(bytes.clone(), password).map(|doc| pdfcraft_preflight::declared(&doc)).unwrap_or_default(); + let json = serde_json::json!({ + "file": path, + "pdf_version": info.pdf_version, + "file_size": info.file_size, + "pages": info.pages.len(), + "pdf_pages": pdf_pages, "acroform_fields": acroform_fields, + "first_page_pt": info.pages.first().map(|p| [p.width, p.height]), + "title": info.title, "author": info.author, "producer": info.producer, "creator": info.creator, + "encrypted": info.encrypted, "tagged": info.tagged, "javascript": info.has_javascript, + "pdfa": declared.pdfa.as_ref().map(|(part, conformance)| format!("PDF/A-{part}{}", conformance.to_lowercase())), + "pdfua": declared.pdfua, + "output_intents": declared.output_intents, + "xfa": info.xfa.map(|x| match x { pdfcraft_render::Xfa::Static => "static", pdfcraft_render::Xfa::Dynamic => "dynamic" }), + "xfa_layout": xfa_layout, + "bookmarks": info.outline.len(), "annotations": info.annotations.len(), "fields": info.fields.len(), + "links": info.links.len(), "layers": info.layers.len(), "attachments": info.attachments.len(), + "transitions": info.transitions.len(), + "custom_properties": info.custom.len(), + "page_labels": info.pages.iter().take(8).map(|p| p.label.clone()).collect::>(), + "warnings": info.warnings, + }); + stdout_line(format_args!("{}", serde_json::to_string_pretty(&json).unwrap_or_default()))?; + Ok(()) +} + +pub(crate) fn text(args: &[String]) -> Result<(), CliError> { + validate_options("text", args, TEXT_OPTIONS)?; + let path = *positional(args, TEXT_OPTIONS).first().ok_or("text: missing file")?; + let mut selected_page = None; + // Validate every supplied value before reading, retaining the first valid selection. + let mut options = args.iter(); + while let Some(option) = options.next() { + if option == "--page" { + let page = options + .next() + .and_then(|value| value.parse::().ok()) + .filter(|page| *page > 0) + .ok_or("bad --page: expected a positive page number")?; + selected_page.get_or_insert(page - 1); + } else if option.starts_with("--") { + // Match positional(): an option's operand is not itself another option. + options.next(); + } + } + let password = flag(args, "--password"); + let mut bytes = read(path)?; + // The renderer takes only the user password of R2–R4 files; `Session::open` takes either. + // Worked out from the file as read: `bytes` may become the session's display copy below. + let render_password = pdfcraft_engine::render_password_for(bytes.clone(), password); + // Printing the text is copying it, so ask what `text_extract` asks (#1645): without the + // permissions password, a document that doesn't allow copying gives no text. + let mut session = pdfcraft_engine::Session::new(); + if let Some(doc) = session.open(path, None, bytes.clone(), password).ok().and_then(|id| session.get(id)) { + // The engine's hint is about editing in the app; on the command line the way in is + // `--password`. + doc.check_copying() + .map_err(|_| "text: the document's permissions don't allow copying its text; pass the owner password with --password to extract it")?; + if doc.xfa.is_some() { + bytes = doc.display.clone(); + } + } + let mut r = PageRenderer::new(bytes.clone(), RenderConfig { password: render_password.as_deref().map(Arc::from), ..Default::default() }); + // A document the renderer could not open (wrong or missing password, unparseable file) has + // no pages, and "extract every page" of nothing would print nothing and exit 0 (#132). Ask + // `inspect` why instead, so a protected file fails the same way `info` does. + if r.page_count() == 0 { + let info = inspect(bytes.clone(), render_password.as_deref()).map_err(|e| format!("text: {e}"))?; + if !info.pages.is_empty() { + return Err("text: the document could not be parsed".into()); + } + } + let pages: Vec = match selected_page { + Some(page) => vec![page], + None => (0..r.page_count()).collect(), + }; + let mut failed: Vec = Vec::new(); + for (n, p) in pages.iter().enumerate() { + let out = r.render(RenderRequest { page: *p, kind: RequestKind::Text, tile: None, scale: 1.0, tag: 0, preview: false }); + if let Some(e) = out.error { + let _ = writeln!(std::io::stderr().lock(), "page {}: {e}", p + 1); + failed.push(p + 1); + continue; + } + if n > 0 { + stdout_line(format_args!("\u{c}"))?; + } + stdout_line(format_args!("{}", out.text.map(|t| t.plain_text()).unwrap_or_default()))?; + } + if failed.is_empty() { + Ok(()) + } else { + Err(format!("text: page(s) {} could not be read", failed.iter().map(usize::to_string).collect::>().join(", ")).into()) + } +} + +/// The most pages one `A-B` range may name: a bound on what a typed range can allocate. +const MAX_RANGE: usize = 1_000_000; + +/// 1-based pages ("1,3,5-8": single pages and inclusive ranges, in the order given) → 0-based +/// indices (#1923). +fn page_list(s: &str) -> Result, String> { + let number = |t: &str, item: &str| t.trim().parse::().ok().filter(|n| *n > 0).ok_or_else(|| format!("bad page number {item:?}")); + let mut out = Vec::new(); + for item in s.split(',').map(str::trim) { + match item.split_once('-') { + Some((from, to)) => { + let (from, to) = (number(from, item)?, number(to, item)?); + if to < from { + return Err(format!("bad page range {item:?}: it ends before it starts")); + } + if to - from >= MAX_RANGE { + return Err(format!("bad page range {item:?}: more than {MAX_RANGE} pages")); + } + out.extend(from - 1..to); + } + None => out.push(number(item, item)? - 1), + } + } + Ok(out) +} + +/// The options `edit` understands, for its "unknown option" message. +const EDIT_OPTIONS: &str = "--out, --password, --full, --rotate, --delete, --move, --insert-blank, --title, --author"; + +/// Apply page and metadata edits through the engine and save (incrementally unless `--full`). +/// +/// Every argument is parsed before the file is opened: an option this command does not know +/// (a typo like `--rotat`) is an error, not something to skip — skipping it would write an +/// unchanged copy and exit 0, which looks like a successful edit (#133). +pub(crate) fn edit(args: &[String]) -> Result<(), CliError> { + use pdfcraft_engine::{Edit, Session}; + let mut path: Option<&str> = None; + let mut out: Option<&str> = None; + let mut password: Option<&str> = None; + let mut full = false; + let mut edits = Vec::new(); + let mut i = 0; + while let Some(arg) = args.get(i).map(String::as_str) { + // Options that take no value, and the input file. + match arg { + "--full" => { + full = true; + i += 1; + continue; + } + a if a.starts_with("--") => {} + a => { + if path.is_some() { + return Err(format!("edit: unexpected argument {a:?} (one input file, then options)").into()); + } + path = Some(a); + i += 1; + continue; + } + } + // `edit` parses its own options (they run in order), so `--out` is checked here in the + // same way: it wrote a PDF to a file called `--full`. + let value = args.get(i + 1).map(String::as_str); + if !can_be_path_value(arg, value) { + return Err(needs_value("edit", arg, value).into()); + } + let value = value.ok_or_else(|| format!("edit: {arg} needs a value"))?; + match arg { + "--out" => out = Some(value), + "--password" => password = Some(value), + "--rotate" => { + let (pages, deg) = value.split_once(':').ok_or("--rotate PAGES:DEGREES")?; + let degrees: i64 = deg.parse().map_err(|_| format!("bad --rotate degrees: expected a whole number of degrees, got {deg:?}"))?; + // The same rule `rotate_pages` enforces, checked here so a bad angle is named + // with its option before any file is opened. + if degrees % 90 != 0 { + return Err(format!("bad --rotate degrees: expected a multiple of 90 (such as 90, -90 or 180), got {degrees}").into()); + } + edits.push(Edit::RotatePages { pages: page_list(pages)?, degrees }); + } + "--delete" => edits.push(Edit::DeletePages { pages: page_list(value)? }), + "--move" => { + let (pages, to) = value.split_once(':').ok_or("--move PAGES:TO")?; + let target: usize = to + .parse() + .ok() + .filter(|target| *target > 0) + .ok_or_else(|| format!("bad --move target: expected a page number of at least 1, got {to:?}"))?; + edits.push(Edit::MovePages { pages: page_list(pages)?, to: target - 1 }); + } + "--insert-blank" => { + let position: usize = value + .parse() + .ok() + .filter(|position| *position > 0) + .ok_or_else(|| format!("bad --insert-blank position: expected a page position of at least 1, got {value:?}"))?; + edits.push(Edit::InsertBlankPage { at: position - 1, width: 612.0, height: 792.0 }); + } + "--title" => edits.push(Edit::SetInfo { key: "Title".into(), value: value.into() }), + "--author" => edits.push(Edit::SetInfo { key: "Author".into(), value: value.into() }), + other => return Err(format!("edit: unknown option {other:?} (expected one of: {EDIT_OPTIONS})").into()), + } + i += 2; + } + let path = path.ok_or("edit: missing file")?; + let out = out.ok_or("edit: missing --out")?; + let mut session = Session::new(); + let id = session.open(path, Some(path.to_string()), read(path)?, password).map_err(|e| e.to_string())?; + for e in edits { + // A destination past the end must fail like `page_move` / `page_insert_blank` do, not + // silently become an append (#1330). Checked per edit: earlier edits change the count. + let n = session.get(id).map_or(0, |d| d.info.pages.len()); + let destination = match &e { + Edit::MovePages { to, .. } => Some(("--move target", *to)), + Edit::InsertBlankPage { at, .. } => Some(("--insert-blank", *at)), + _ => None, + }; + if let Some((option, at)) = destination.filter(|(_, at)| *at > n) { + return Err(format!("{option} {} is past the end: it must be between 1 and {} (the document has {n} pages)", at + 1, n + 1).into()); + } + // Pages count from the document as the earlier options left it, so a page past the end is + // reported with its own option and the current page count (#1550). + let listed = match &e { + Edit::RotatePages { pages, .. } => Some(("--rotate", pages)), + Edit::DeletePages { pages } => Some(("--delete", pages)), + Edit::MovePages { pages, .. } => Some(("--move", pages)), + _ => None, + }; + if let Some((option, bad)) = listed.and_then(|(option, pages)| pages.iter().find(|p| **p >= n).map(|p| (option, *p))) { + return Err(format!("{option}: page {} does not exist; after the earlier options the document has {n} pages", bad + 1).into()); + } + session.apply(id, e).map_err(|e| e.to_string())?; + } + let bytes = if full { session.save_full_bytes(id) } else { session.save_bytes(id) }.map_err(|e| e.to_string())?; + // As the app's Save: the file must reopen with its pages before it replaces anything (#1698). + session.check_save(id, &bytes).map_err(|e| e.to_string())?; + write_pdf(Path::new(out), &bytes) +} + +/// Write a PDF a command produced, through a staging file beside the target (#2166): a write +/// that fails, or `--out` naming the input, never leaves a truncated file where a good one was. +fn write_pdf(out: &Path, bytes: &[u8]) -> Result<(), CliError> { + use pdfcraft_platform::staging::{StagingName, write_atomically}; + write_atomically(out, bytes, StagingName::SuffixThenTag).map_err(|e| format!("{}: {e}", out.display()).into()) +} + +fn file_stem(path: &str) -> String { + Path::new(path).file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_else(|| path.to_string()) +} + +pub(crate) fn combine(args: &[String]) -> Result<(), CliError> { + validate_options("combine", args, COMBINE_OPTIONS)?; + let out = flag(args, "--out").ok_or("combine: missing --out")?; + let inputs = positional(args, COMBINE_OPTIONS); + if inputs.len() < 2 { + return Err("combine: give at least two input files".into()); + } + let sources = inputs.iter().map(|p| Ok((file_stem(p), read(p)?))).collect::, String>>()?; + let bytes = pdfcraft_engine::Session::new().combine(&sources).map_err(|e| e.to_string())?; + write_pdf(Path::new(out), &bytes) +} + +pub(crate) fn extract(args: &[String]) -> Result<(), CliError> { + validate_options("extract", args, EXTRACT_OPTIONS)?; + let path = *positional(args, EXTRACT_OPTIONS).first().ok_or("extract: missing file")?; + let out = flag(args, "--out").ok_or("extract: missing --out")?; + let pages = page_list(flag(args, "--pages").ok_or("extract: missing --pages")?)?; + let mut session = pdfcraft_engine::Session::new(); + let id = session.open(path, None, read(path)?, flag(args, "--password")).map_err(|e| e.to_string())?; + let bytes = session.extract(id, &pages).map_err(|e| e.to_string())?; + write_pdf(Path::new(out), &bytes) +} + +pub(crate) fn split(args: &[String]) -> Result<(), CliError> { + validate_options("split", args, SPLIT_OPTIONS)?; + use pdfcraft_engine::SplitBy; + let path = *positional(args, SPLIT_OPTIONS).first().ok_or("split: missing file")?; + let by = match (flag(args, "--every"), flag(args, "--before")) { + (Some(n), None) => { + // A count the splitter cannot hold is refused here, before the file is opened, + // rather than silently clamped to 1 further down. + let count = n + .parse::() + .ok() + .filter(|count| *count > 0) + .ok_or_else(|| format!("bad --every: expected a positive whole number of pages, got {n:?}"))?; + SplitBy::PageCount(count) + } + (None, Some(list)) => SplitBy::Before(page_list(list)?), + _ => return Err("split: give either --every N or --before PAGES".into()), + }; + let dir = PathBuf::from(flag(args, "--out-dir").unwrap_or(".")); + let mut session = pdfcraft_engine::Session::new(); + let id = session.open(path, None, read(path)?, flag(args, "--password")).map_err(|e| e.to_string())?; + let stem = file_stem(path); + let parts = session.split(id, &by).map_err(|e| e.to_string())?; + // An output folder that does not exist yet is created (#249); one that can't be is reported + // as the folder's problem, not as the first part's. + std::fs::create_dir_all(&dir).map_err(|e| format!("split: --out-dir {}: {e}", dir.display()))?; + for (a, b, bytes) in parts { + let name = dir.join(if a == b { format!("{stem}-p{a}.pdf") } else { format!("{stem}-p{a}-{b}.pdf") }); + write_pdf(&name, &bytes)?; + stdout_line(format_args!("{}", name.display()))?; + } + Ok(()) +} + +/// A `--dpi` the renderer can be asked for: a positive, finite number. `0`, a negative value +/// or `NaN` parse as an `f32` but reach the renderer as a scale it refuses with its own +/// internal message (and in `check`, passed on as text, they would turn every file into a +/// child that printed nothing), so the CLI names the option and the value instead. +pub(crate) fn parse_dpi(value: &str) -> Result { + value + .parse::() + .ok() + .filter(|dpi| dpi.is_finite() && *dpi > 0.0) + .ok_or_else(|| format!("bad --dpi: expected a positive number of dots per inch, got {value:?}")) +} + +pub(crate) fn render(args: &[String]) -> Result<(), CliError> { + validate_options("render", args, RENDER_OPTIONS)?; + let path = *positional(args, RENDER_OPTIONS).first().ok_or("render: missing file")?; + let page: usize = + flag(args, "--page").unwrap_or("1").parse().ok().filter(|page| *page > 0).ok_or("bad --page: expected a positive page number")?; + let page_index = page - 1; + let dpi = parse_dpi(flag(args, "--dpi").unwrap_or("96"))?; + let out = flag(args, "--out").ok_or("render: missing --out (.png, .jpg, .tif or .pam)")?; + // The file is what its name says (#248): a `.png` used to get a netpbm PAM stream. + let format = match Path::new(out).extension().and_then(std::ffi::OsStr::to_str).map(str::to_ascii_lowercase).as_deref() { + Some("png") => Some(ImageFormat::Png), + Some("jpg" | "jpeg") => Some(ImageFormat::Jpeg { quality: 90 }), + Some("tif" | "tiff") => Some(ImageFormat::Tiff), + Some("pam") => None, + _ => return Err(format!("render: --out {out}: use a .png, .jpg, .tif or .pam name").into()), + }; + // The app renders a display copy that draws comments missing their /AP streams; the CLI + // must agree with it (and with independent viewers), while keeping the file it reads intact. + let bytes = read(path)?; + let password = flag(args, "--password"); + // The renderer takes only the user password of R2–R4 files; `Session::open` and the display + // copy take either. + let render_password = pdfcraft_engine::render_password_for(bytes.clone(), password); + let display = if inspect(bytes.clone(), render_password.as_deref()).is_ok_and(|info| info.xfa == Some(pdfcraft_render::Xfa::Dynamic)) { + let mut session = pdfcraft_engine::Session::new(); + let id = session.open(path, None, bytes, password).map_err(|e| e.to_string())?; + session.get(id).ok_or("render: the document could not be opened")?.display.clone() + } else { + pdfcraft_engine::display_bytes_for(bytes, password) + }; + let mut r = + PageRenderer::new(display, RenderConfig { password: render_password.as_deref().map(Arc::from), reject_oversize: true, ..Default::default() }); + let p = r.render(RenderRequest { page: page_index, kind: RequestKind::Pixels, tile: None, scale: dpi / 72.0, tag: 0, preview: false }); + if let Some(e) = p.error { + return Err(e.into()); + } + let bytes = match format { + // With `reject_oversize` the page is drawn at `--dpi` or not at all, so the file can say so. + Some(f) => pdfcraft_engine::export::encode_image(p.width, p.height, &p.rgba, f, f64::from(dpi))?, + None => { + // PAM (netpbm RGB_ALPHA): the raw premultiplied pixels, for tools that read it. + let mut pam = format!("P7\nWIDTH {}\nHEIGHT {}\nDEPTH 4\nMAXVAL 255\nTUPLTYPE RGB_ALPHA\nENDHDR\n", p.width, p.height).into_bytes(); + pam.extend_from_slice(&p.rgba); + pam + } + }; + std::fs::write(out, bytes).map_err(|e| format!("{out}: {e}"))?; + let _ = writeln!(std::io::stderr().lock(), "rendered page {page} at {dpi} dpi: {}×{} px in {} ms", p.width, p.height, p.millis); + for warning in &p.warnings { + match warning { + pdfcraft_render::RenderWarning::ContentTruncated => { + let _ = writeln!(std::io::stderr().lock(), "warning: page {page}: content past the page's safety budget was skipped"); + } + pdfcraft_render::RenderWarning::ColourFallback => { + let _ = + writeln!(std::io::stderr().lock(), "warning: page {page}: a colour that could not be computed was painted in a fallback colour"); + } + } + } + Ok(()) +} diff --git a/apps/pdfcraft-cli/src/main.rs b/apps/pdfcraft-cli/src/main.rs index 9ac3b21589..0c7fa9ba22 100644 --- a/apps/pdfcraft-cli/src/main.rs +++ b/apps/pdfcraft-cli/src/main.rs @@ -53,6 +53,20 @@ macro_rules! mcp_usage { }; } +mod check; +mod commands; +mod options; +mod run; +mod ui; + +use check::{audit, check, check_one}; +use commands::{combine, edit, extract, info, render, split, text}; +use options::{OptionSpec, flag, validate_options}; +#[cfg(feature = "mcp")] +use run::mcp; +use run::{automation, print_output, run, tools}; +use ui::ui; + /// The full usage, printed by `--help` (also after a command, e.g. `render --help`). const USAGE: &str = concat!( "\ @@ -181,1055 +195,3 @@ fn version() -> Result<(), CliError> { stdout_line(format_args!("Source: {}", pdfcraft_engine::links::GITHUB))?; Ok(()) } - -/// Audit a document for faux redactions. Prints the findings as JSON; fails when any are -/// found so scripts and CI can gate on it, and when the audit itself cannot run. -fn audit(args: &[String]) -> Result<(), CliError> { - validate_options("audit", args, AUDIT_OPTIONS)?; - let path = *positional(args, AUDIT_OPTIONS).first().ok_or("audit: missing file")?; - let bytes = read(path)?; - let password = flag(args, "--password"); - let doc = pdfcraft_cos::Document::open_with_password(bytes.clone(), password).map_err(|e| format!("{path}: {e}"))?; - let findings = pdfcraft_audit::audit_redactions(&doc).map_err(|e| format!("{path}: {e}"))?; - // Rectangles in points from the top-left of the displayed page, as `doc_audit_redactions` - // reports them; the page geometry is read only when there is something to place. - let info = if findings.is_empty() { - None - } else { - let render_password = pdfcraft_engine::render_password_for(bytes.clone(), password); - Some(inspect(bytes, render_password.as_deref()).map_err(|e| format!("{path}: {e}"))?) - }; - let items: Vec = findings - .into_iter() - .map(|f| { - let rect = f.rect.map(|v| v as f32); - serde_json::json!({ - "page": f.page + 1, - "kind": f.kind.id(), - "rect": info.as_ref().map_or(rect, |i| pdfcraft_automation::view_rect(i, f.page, rect)), - "covered_text": f.covered_text, - }) - }) - .collect(); - println!("{}", serde_json::json!({ "file": path, "findings": items, "count": items.len() })); - if items.is_empty() { Ok(()) } else { Err(format!("audit: {} faux redaction(s) found", items.len()).into()) } -} - -fn flag<'a>(args: &'a [String], name: &str) -> Option<&'a str> { - args.iter().position(|a| a == name).and_then(|i| args.get(i + 1)).map(String::as_str) -} - -#[derive(Clone, Copy)] -struct OptionSpec { - name: &'static str, - takes_value: bool, -} - -/// The options whose value is a file or folder. Only these refuse a value that is another -/// option: a file named `--password` is never what was meant, while a password can be anything -/// (`--password --page` is a password of `--page`, which tests/text.rs pins), and so can a title. -/// The way to name a file that does start with `-` is `./--name`, as `--root` already offered. -const PATH_VALUE_OPTIONS: [&str; 6] = ["--out", "--out-dir", "--json", "--root", "--control", "--script"]; - -/// Whether a token can be the value of a path option. Another option cannot: `--out --password` -/// used to write the PDF to a file called `--password` and exit 0 (photocraft#6944). -fn can_be_path_value(name: &str, value: Option<&str>) -> bool { - !PATH_VALUE_OPTIONS.contains(&name) || value.is_some_and(|v| !v.is_empty() && !v.starts_with("--")) -} - -/// The complaint about a path option whose value is missing or is another option. -fn needs_value(command: &str, name: &str, value: Option<&str>) -> String { - match value { - Some(v) if !v.is_empty() => { - format!("{command}: {name} needs a value and `{v}` is another option; write `./{v}` for a file of that name") - } - _ => format!("{command}: {name} needs a value"), - } -} - -/// Refuse unknown options and malformed confinement flags before any file is read. -fn validate_options(command: &str, args: &[String], specs: &[OptionSpec]) -> Result<(), CliError> { - if specs.iter().any(|spec| spec.name == "--root") { - // `flag` searches every token, including another option's value. Match that lookup so - // a malformed confinement flag cannot be hidden there and fall back to no root. - let mut root_seen = false; - for (i, _) in args.iter().enumerate().filter(|(_, arg)| arg.as_str() == "--root") { - if root_seen { - return Err(format!("{command}: --root may only be supplied once").into()); - } - root_seen = true; - if args.get(i + 1).is_none_or(|value| value.is_empty() || value.starts_with("--")) { - return Err(format!("{command}: --root needs a directory value").into()); - } - } - } - let mut i = 0; - while let Some(arg) = args.get(i) { - if let Some(name) = arg.strip_prefix("--") { - let Some(spec) = specs.iter().find(|spec| spec.name == arg) else { - return Err(format!("{command}: unknown option --{name}").into()); - }; - // A file's name is not another option, which also closes the gap the walk left for - // those: an unknown option in that value position was skipped along with it. Other - // values, missing or malformed, are left to the command, which reports them in its - // own terms (`text` and `extract` explain what a page value must be). - i += if spec.takes_value { - let value = args.get(i + 1).map(String::as_str); - if !can_be_path_value(spec.name, value) { - return Err(needs_value(command, spec.name, value).into()); - } - 2 - } else { - 1 - }; - } else { - i += 1; - } - } - Ok(()) -} - -const INFO_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--password", takes_value: true }]; -const AUDIT_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--password", takes_value: true }]; -const TEXT_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--page", takes_value: true }, OptionSpec { name: "--password", takes_value: true }]; -const COMBINE_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--out", takes_value: true }]; -const EXTRACT_OPTIONS: &[OptionSpec] = &[ - OptionSpec { name: "--out", takes_value: true }, - OptionSpec { name: "--pages", takes_value: true }, - OptionSpec { name: "--password", takes_value: true }, -]; -const SPLIT_OPTIONS: &[OptionSpec] = &[ - OptionSpec { name: "--every", takes_value: true }, - OptionSpec { name: "--before", takes_value: true }, - OptionSpec { name: "--out-dir", takes_value: true }, - OptionSpec { name: "--password", takes_value: true }, -]; -const RENDER_OPTIONS: &[OptionSpec] = &[ - OptionSpec { name: "--page", takes_value: true }, - OptionSpec { name: "--dpi", takes_value: true }, - OptionSpec { name: "--out", takes_value: true }, - OptionSpec { name: "--password", takes_value: true }, -]; -const CHECK_ONE_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--dpi", takes_value: true }, OptionSpec { name: "--edit", takes_value: false }]; -const CHECK_OPTIONS: &[OptionSpec] = &[ - OptionSpec { name: "--timeout", takes_value: true }, - OptionSpec { name: "--dpi", takes_value: true }, - OptionSpec { name: "--json", takes_value: true }, -]; -const RUN_OPTIONS: &[OptionSpec] = &[ - OptionSpec { name: "--root", takes_value: true }, - OptionSpec { name: "--script", takes_value: true }, - OptionSpec { name: "--out", takes_value: true }, - OptionSpec { name: "--report", takes_value: false }, -]; -const UI_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--control", takes_value: true }, OptionSpec { name: "--out", takes_value: true }]; - -#[cfg(feature = "mcp")] -const MCP_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--root", takes_value: true }, OptionSpec { name: "--compact", takes_value: false }]; - -/// The arguments that are not options or their values. Which tokens are values is the spec's -/// answer: skipping the one after every option made `check-one --edit file.pdf` report "missing -/// file", the file having been read as `--edit`'s value. -fn positional<'a>(args: &'a [String], specs: &[OptionSpec]) -> Vec<&'a str> { - let mut out = Vec::new(); - let mut i = 0; - while let Some(a) = args.get(i) { - match specs.iter().find(|spec| spec.name == a.as_str()) { - Some(spec) if spec.takes_value => i += 2, - // An option that takes no value, and an unknown one, which `validate_options` has - // already refused by the time this runs. - Some(_) => i += 1, - None if a.starts_with("--") => i += 1, - None => { - out.push(a.as_str()); - i += 1; - } - } - } - out -} - -fn read(path: &str) -> Result>, String> { - std::fs::read(path).map(Arc::new).map_err(|e| format!("{path}: {e}")) -} - -fn info(args: &[String]) -> Result<(), CliError> { - validate_options("info", args, INFO_OPTIONS)?; - let path = *positional(args, INFO_OPTIONS).first().ok_or("info: missing file")?; - let bytes = read(path)?; - let password = flag(args, "--password"); - // The renderer takes only the user password of R2–R4 files; `Session::open` takes either. - let render_password = pdfcraft_engine::render_password_for(bytes.clone(), password); - let mut info = inspect(bytes.clone(), render_password.as_deref()).map_err(|e| e.to_string())?; - let pdf_pages = info.pages.len(); - let acroform_fields = info.fields.len(); - // Dynamic XFA forms: say what laying the template out gives (what the app shows). - let xfa_layout = (info.xfa == Some(pdfcraft_render::Xfa::Dynamic)) - .then(|| { - let mut s = pdfcraft_engine::Session::new(); - let id = s.open("info.pdf", None, bytes.clone(), password).ok()?; - let d = s.get(id)?; - d.xfa.as_ref().map(|x| { - info = d.info.clone(); - // The in-memory layout is an appended revision, not the file being inspected. - info.file_size = bytes.len(); - serde_json::json!({ "pages": x.pages, "fields": x.fields, "warnings": x.warnings }) - }) - }) - .flatten(); - // What the document declares as a standard, read the way the Standards panel reads it, so - // `info` and the app agree. A file that declares nothing leaves these null and empty. - let declared = - pdfcraft_cos::Document::open_with_password(bytes.clone(), password).map(|doc| pdfcraft_preflight::declared(&doc)).unwrap_or_default(); - let json = serde_json::json!({ - "file": path, - "pdf_version": info.pdf_version, - "file_size": info.file_size, - "pages": info.pages.len(), - "pdf_pages": pdf_pages, "acroform_fields": acroform_fields, - "first_page_pt": info.pages.first().map(|p| [p.width, p.height]), - "title": info.title, "author": info.author, "producer": info.producer, "creator": info.creator, - "encrypted": info.encrypted, "tagged": info.tagged, "javascript": info.has_javascript, - "pdfa": declared.pdfa.as_ref().map(|(part, conformance)| format!("PDF/A-{part}{}", conformance.to_lowercase())), - "pdfua": declared.pdfua, - "output_intents": declared.output_intents, - "xfa": info.xfa.map(|x| match x { pdfcraft_render::Xfa::Static => "static", pdfcraft_render::Xfa::Dynamic => "dynamic" }), - "xfa_layout": xfa_layout, - "bookmarks": info.outline.len(), "annotations": info.annotations.len(), "fields": info.fields.len(), - "links": info.links.len(), "layers": info.layers.len(), "attachments": info.attachments.len(), - "transitions": info.transitions.len(), - "custom_properties": info.custom.len(), - "page_labels": info.pages.iter().take(8).map(|p| p.label.clone()).collect::>(), - "warnings": info.warnings, - }); - stdout_line(format_args!("{}", serde_json::to_string_pretty(&json).unwrap_or_default()))?; - Ok(()) -} - -fn text(args: &[String]) -> Result<(), CliError> { - validate_options("text", args, TEXT_OPTIONS)?; - let path = *positional(args, TEXT_OPTIONS).first().ok_or("text: missing file")?; - let mut selected_page = None; - // Validate every supplied value before reading, retaining the first valid selection. - let mut options = args.iter(); - while let Some(option) = options.next() { - if option == "--page" { - let page = options - .next() - .and_then(|value| value.parse::().ok()) - .filter(|page| *page > 0) - .ok_or("bad --page: expected a positive page number")?; - selected_page.get_or_insert(page - 1); - } else if option.starts_with("--") { - // Match positional(): an option's operand is not itself another option. - options.next(); - } - } - let password = flag(args, "--password"); - let mut bytes = read(path)?; - // The renderer takes only the user password of R2–R4 files; `Session::open` takes either. - // Worked out from the file as read: `bytes` may become the session's display copy below. - let render_password = pdfcraft_engine::render_password_for(bytes.clone(), password); - // Printing the text is copying it, so ask what `text_extract` asks (#1645): without the - // permissions password, a document that doesn't allow copying gives no text. - let mut session = pdfcraft_engine::Session::new(); - if let Some(doc) = session.open(path, None, bytes.clone(), password).ok().and_then(|id| session.get(id)) { - // The engine's hint is about editing in the app; on the command line the way in is - // `--password`. - doc.check_copying() - .map_err(|_| "text: the document's permissions don't allow copying its text; pass the owner password with --password to extract it")?; - if doc.xfa.is_some() { - bytes = doc.display.clone(); - } - } - let mut r = PageRenderer::new(bytes.clone(), RenderConfig { password: render_password.as_deref().map(Arc::from), ..Default::default() }); - // A document the renderer could not open (wrong or missing password, unparseable file) has - // no pages, and "extract every page" of nothing would print nothing and exit 0 (#132). Ask - // `inspect` why instead, so a protected file fails the same way `info` does. - if r.page_count() == 0 { - let info = inspect(bytes.clone(), render_password.as_deref()).map_err(|e| format!("text: {e}"))?; - if !info.pages.is_empty() { - return Err("text: the document could not be parsed".into()); - } - } - let pages: Vec = match selected_page { - Some(page) => vec![page], - None => (0..r.page_count()).collect(), - }; - let mut failed: Vec = Vec::new(); - for (n, p) in pages.iter().enumerate() { - let out = r.render(RenderRequest { page: *p, kind: RequestKind::Text, tile: None, scale: 1.0, tag: 0, preview: false }); - if let Some(e) = out.error { - let _ = writeln!(std::io::stderr().lock(), "page {}: {e}", p + 1); - failed.push(p + 1); - continue; - } - if n > 0 { - stdout_line(format_args!("\u{c}"))?; - } - stdout_line(format_args!("{}", out.text.map(|t| t.plain_text()).unwrap_or_default()))?; - } - if failed.is_empty() { - Ok(()) - } else { - Err(format!("text: page(s) {} could not be read", failed.iter().map(usize::to_string).collect::>().join(", ")).into()) - } -} - -/// The most pages one `A-B` range may name: a bound on what a typed range can allocate. -const MAX_RANGE: usize = 1_000_000; - -/// 1-based pages ("1,3,5-8": single pages and inclusive ranges, in the order given) → 0-based -/// indices (#1923). -fn page_list(s: &str) -> Result, String> { - let number = |t: &str, item: &str| t.trim().parse::().ok().filter(|n| *n > 0).ok_or_else(|| format!("bad page number {item:?}")); - let mut out = Vec::new(); - for item in s.split(',').map(str::trim) { - match item.split_once('-') { - Some((from, to)) => { - let (from, to) = (number(from, item)?, number(to, item)?); - if to < from { - return Err(format!("bad page range {item:?}: it ends before it starts")); - } - if to - from >= MAX_RANGE { - return Err(format!("bad page range {item:?}: more than {MAX_RANGE} pages")); - } - out.extend(from - 1..to); - } - None => out.push(number(item, item)? - 1), - } - } - Ok(out) -} - -/// The options `edit` understands, for its "unknown option" message. -const EDIT_OPTIONS: &str = "--out, --password, --full, --rotate, --delete, --move, --insert-blank, --title, --author"; - -/// Apply page and metadata edits through the engine and save (incrementally unless `--full`). -/// -/// Every argument is parsed before the file is opened: an option this command does not know -/// (a typo like `--rotat`) is an error, not something to skip — skipping it would write an -/// unchanged copy and exit 0, which looks like a successful edit (#133). -fn edit(args: &[String]) -> Result<(), CliError> { - use pdfcraft_engine::{Edit, Session}; - let mut path: Option<&str> = None; - let mut out: Option<&str> = None; - let mut password: Option<&str> = None; - let mut full = false; - let mut edits = Vec::new(); - let mut i = 0; - while let Some(arg) = args.get(i).map(String::as_str) { - // Options that take no value, and the input file. - match arg { - "--full" => { - full = true; - i += 1; - continue; - } - a if a.starts_with("--") => {} - a => { - if path.is_some() { - return Err(format!("edit: unexpected argument {a:?} (one input file, then options)").into()); - } - path = Some(a); - i += 1; - continue; - } - } - // `edit` parses its own options (they run in order), so `--out` is checked here in the - // same way: it wrote a PDF to a file called `--full`. - let value = args.get(i + 1).map(String::as_str); - if !can_be_path_value(arg, value) { - return Err(needs_value("edit", arg, value).into()); - } - let value = value.ok_or_else(|| format!("edit: {arg} needs a value"))?; - match arg { - "--out" => out = Some(value), - "--password" => password = Some(value), - "--rotate" => { - let (pages, deg) = value.split_once(':').ok_or("--rotate PAGES:DEGREES")?; - let degrees: i64 = deg.parse().map_err(|_| format!("bad --rotate degrees: expected a whole number of degrees, got {deg:?}"))?; - // The same rule `rotate_pages` enforces, checked here so a bad angle is named - // with its option before any file is opened. - if degrees % 90 != 0 { - return Err(format!("bad --rotate degrees: expected a multiple of 90 (such as 90, -90 or 180), got {degrees}").into()); - } - edits.push(Edit::RotatePages { pages: page_list(pages)?, degrees }); - } - "--delete" => edits.push(Edit::DeletePages { pages: page_list(value)? }), - "--move" => { - let (pages, to) = value.split_once(':').ok_or("--move PAGES:TO")?; - let target: usize = to - .parse() - .ok() - .filter(|target| *target > 0) - .ok_or_else(|| format!("bad --move target: expected a page number of at least 1, got {to:?}"))?; - edits.push(Edit::MovePages { pages: page_list(pages)?, to: target - 1 }); - } - "--insert-blank" => { - let position: usize = value - .parse() - .ok() - .filter(|position| *position > 0) - .ok_or_else(|| format!("bad --insert-blank position: expected a page position of at least 1, got {value:?}"))?; - edits.push(Edit::InsertBlankPage { at: position - 1, width: 612.0, height: 792.0 }); - } - "--title" => edits.push(Edit::SetInfo { key: "Title".into(), value: value.into() }), - "--author" => edits.push(Edit::SetInfo { key: "Author".into(), value: value.into() }), - other => return Err(format!("edit: unknown option {other:?} (expected one of: {EDIT_OPTIONS})").into()), - } - i += 2; - } - let path = path.ok_or("edit: missing file")?; - let out = out.ok_or("edit: missing --out")?; - let mut session = Session::new(); - let id = session.open(path, Some(path.to_string()), read(path)?, password).map_err(|e| e.to_string())?; - for e in edits { - // A destination past the end must fail like `page_move` / `page_insert_blank` do, not - // silently become an append (#1330). Checked per edit: earlier edits change the count. - let n = session.get(id).map_or(0, |d| d.info.pages.len()); - let destination = match &e { - Edit::MovePages { to, .. } => Some(("--move target", *to)), - Edit::InsertBlankPage { at, .. } => Some(("--insert-blank", *at)), - _ => None, - }; - if let Some((option, at)) = destination.filter(|(_, at)| *at > n) { - return Err(format!("{option} {} is past the end: it must be between 1 and {} (the document has {n} pages)", at + 1, n + 1).into()); - } - // Pages count from the document as the earlier options left it, so a page past the end is - // reported with its own option and the current page count (#1550). - let listed = match &e { - Edit::RotatePages { pages, .. } => Some(("--rotate", pages)), - Edit::DeletePages { pages } => Some(("--delete", pages)), - Edit::MovePages { pages, .. } => Some(("--move", pages)), - _ => None, - }; - if let Some((option, bad)) = listed.and_then(|(option, pages)| pages.iter().find(|p| **p >= n).map(|p| (option, *p))) { - return Err(format!("{option}: page {} does not exist; after the earlier options the document has {n} pages", bad + 1).into()); - } - session.apply(id, e).map_err(|e| e.to_string())?; - } - let bytes = if full { session.save_full_bytes(id) } else { session.save_bytes(id) }.map_err(|e| e.to_string())?; - // As the app's Save: the file must reopen with its pages before it replaces anything (#1698). - session.check_save(id, &bytes).map_err(|e| e.to_string())?; - write_pdf(Path::new(out), &bytes) -} - -/// Write a PDF a command produced, through a staging file beside the target (#2166): a write -/// that fails, or `--out` naming the input, never leaves a truncated file where a good one was. -fn write_pdf(out: &Path, bytes: &[u8]) -> Result<(), CliError> { - use pdfcraft_platform::staging::{StagingName, write_atomically}; - write_atomically(out, bytes, StagingName::SuffixThenTag).map_err(|e| format!("{}: {e}", out.display()).into()) -} - -fn file_stem(path: &str) -> String { - Path::new(path).file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_else(|| path.to_string()) -} - -fn combine(args: &[String]) -> Result<(), CliError> { - validate_options("combine", args, COMBINE_OPTIONS)?; - let out = flag(args, "--out").ok_or("combine: missing --out")?; - let inputs = positional(args, COMBINE_OPTIONS); - if inputs.len() < 2 { - return Err("combine: give at least two input files".into()); - } - let sources = inputs.iter().map(|p| Ok((file_stem(p), read(p)?))).collect::, String>>()?; - let bytes = pdfcraft_engine::Session::new().combine(&sources).map_err(|e| e.to_string())?; - write_pdf(Path::new(out), &bytes) -} - -fn extract(args: &[String]) -> Result<(), CliError> { - validate_options("extract", args, EXTRACT_OPTIONS)?; - let path = *positional(args, EXTRACT_OPTIONS).first().ok_or("extract: missing file")?; - let out = flag(args, "--out").ok_or("extract: missing --out")?; - let pages = page_list(flag(args, "--pages").ok_or("extract: missing --pages")?)?; - let mut session = pdfcraft_engine::Session::new(); - let id = session.open(path, None, read(path)?, flag(args, "--password")).map_err(|e| e.to_string())?; - let bytes = session.extract(id, &pages).map_err(|e| e.to_string())?; - write_pdf(Path::new(out), &bytes) -} - -fn split(args: &[String]) -> Result<(), CliError> { - validate_options("split", args, SPLIT_OPTIONS)?; - use pdfcraft_engine::SplitBy; - let path = *positional(args, SPLIT_OPTIONS).first().ok_or("split: missing file")?; - let by = match (flag(args, "--every"), flag(args, "--before")) { - (Some(n), None) => { - // A count the splitter cannot hold is refused here, before the file is opened, - // rather than silently clamped to 1 further down. - let count = n - .parse::() - .ok() - .filter(|count| *count > 0) - .ok_or_else(|| format!("bad --every: expected a positive whole number of pages, got {n:?}"))?; - SplitBy::PageCount(count) - } - (None, Some(list)) => SplitBy::Before(page_list(list)?), - _ => return Err("split: give either --every N or --before PAGES".into()), - }; - let dir = PathBuf::from(flag(args, "--out-dir").unwrap_or(".")); - let mut session = pdfcraft_engine::Session::new(); - let id = session.open(path, None, read(path)?, flag(args, "--password")).map_err(|e| e.to_string())?; - let stem = file_stem(path); - let parts = session.split(id, &by).map_err(|e| e.to_string())?; - // An output folder that does not exist yet is created (#249); one that can't be is reported - // as the folder's problem, not as the first part's. - std::fs::create_dir_all(&dir).map_err(|e| format!("split: --out-dir {}: {e}", dir.display()))?; - for (a, b, bytes) in parts { - let name = dir.join(if a == b { format!("{stem}-p{a}.pdf") } else { format!("{stem}-p{a}-{b}.pdf") }); - write_pdf(&name, &bytes)?; - stdout_line(format_args!("{}", name.display()))?; - } - Ok(()) -} - -/// A `--dpi` the renderer can be asked for: a positive, finite number. `0`, a negative value -/// or `NaN` parse as an `f32` but reach the renderer as a scale it refuses with its own -/// internal message (and in `check`, passed on as text, they would turn every file into a -/// child that printed nothing), so the CLI names the option and the value instead. -fn parse_dpi(value: &str) -> Result { - value - .parse::() - .ok() - .filter(|dpi| dpi.is_finite() && *dpi > 0.0) - .ok_or_else(|| format!("bad --dpi: expected a positive number of dots per inch, got {value:?}")) -} - -fn render(args: &[String]) -> Result<(), CliError> { - validate_options("render", args, RENDER_OPTIONS)?; - let path = *positional(args, RENDER_OPTIONS).first().ok_or("render: missing file")?; - let page: usize = - flag(args, "--page").unwrap_or("1").parse().ok().filter(|page| *page > 0).ok_or("bad --page: expected a positive page number")?; - let page_index = page - 1; - let dpi = parse_dpi(flag(args, "--dpi").unwrap_or("96"))?; - let out = flag(args, "--out").ok_or("render: missing --out (.png, .jpg, .tif or .pam)")?; - // The file is what its name says (#248): a `.png` used to get a netpbm PAM stream. - let format = match Path::new(out).extension().and_then(std::ffi::OsStr::to_str).map(str::to_ascii_lowercase).as_deref() { - Some("png") => Some(ImageFormat::Png), - Some("jpg" | "jpeg") => Some(ImageFormat::Jpeg { quality: 90 }), - Some("tif" | "tiff") => Some(ImageFormat::Tiff), - Some("pam") => None, - _ => return Err(format!("render: --out {out}: use a .png, .jpg, .tif or .pam name").into()), - }; - // The app renders a display copy that draws comments missing their /AP streams; the CLI - // must agree with it (and with independent viewers), while keeping the file it reads intact. - let bytes = read(path)?; - let password = flag(args, "--password"); - // The renderer takes only the user password of R2–R4 files; `Session::open` and the display - // copy take either. - let render_password = pdfcraft_engine::render_password_for(bytes.clone(), password); - let display = if inspect(bytes.clone(), render_password.as_deref()).is_ok_and(|info| info.xfa == Some(pdfcraft_render::Xfa::Dynamic)) { - let mut session = pdfcraft_engine::Session::new(); - let id = session.open(path, None, bytes, password).map_err(|e| e.to_string())?; - session.get(id).ok_or("render: the document could not be opened")?.display.clone() - } else { - pdfcraft_engine::display_bytes_for(bytes, password) - }; - let mut r = - PageRenderer::new(display, RenderConfig { password: render_password.as_deref().map(Arc::from), reject_oversize: true, ..Default::default() }); - let p = r.render(RenderRequest { page: page_index, kind: RequestKind::Pixels, tile: None, scale: dpi / 72.0, tag: 0, preview: false }); - if let Some(e) = p.error { - return Err(e.into()); - } - let bytes = match format { - // With `reject_oversize` the page is drawn at `--dpi` or not at all, so the file can say so. - Some(f) => pdfcraft_engine::export::encode_image(p.width, p.height, &p.rgba, f, f64::from(dpi))?, - None => { - // PAM (netpbm RGB_ALPHA): the raw premultiplied pixels, for tools that read it. - let mut pam = format!("P7\nWIDTH {}\nHEIGHT {}\nDEPTH 4\nMAXVAL 255\nTUPLTYPE RGB_ALPHA\nENDHDR\n", p.width, p.height).into_bytes(); - pam.extend_from_slice(&p.rgba); - pam - } - }; - std::fs::write(out, bytes).map_err(|e| format!("{out}: {e}"))?; - let _ = writeln!(std::io::stderr().lock(), "rendered page {page} at {dpi} dpi: {}×{} px in {} ms", p.width, p.height, p.millis); - for warning in &p.warnings { - match warning { - pdfcraft_render::RenderWarning::ContentTruncated => { - let _ = writeln!(std::io::stderr().lock(), "warning: page {page}: content past the page's safety budget was skipped"); - } - pdfcraft_render::RenderWarning::ColourFallback => { - let _ = - writeln!(std::io::stderr().lock(), "warning: page {page}: a colour that could not be computed was painted in a fallback colour"); - } - } - } - Ok(()) -} - -/// Child-process body for `check`: prints one JSON line. -fn check_one(args: &[String]) -> Result<(), CliError> { - validate_options("check-one", args, CHECK_ONE_OPTIONS)?; - let path = *positional(args, CHECK_ONE_OPTIONS).first().ok_or("check-one: missing file")?; - let dpi = parse_dpi(flag(args, "--dpi").unwrap_or("36"))?; - let start = Instant::now(); - let bytes = read(path)?; - let (status, pages, failed, detail, warnings) = match inspect(bytes.clone(), None) { - Err(e) => (format!("open-error:{}", short(&e.to_string())), 0, 0, e.to_string(), 0), - Ok(info) => { - let mut r = PageRenderer::new(bytes, RenderConfig::default()); - let mut failed = Vec::new(); - for i in 0..info.pages.len().min(500) { - let p = r.render(RenderRequest { page: i, kind: RequestKind::Pixels, tile: None, scale: dpi / 72.0, tag: 0, preview: false }); - if let Some(e) = r.render(RenderRequest { page: i, kind: RequestKind::Text, tile: None, scale: 1.0, tag: 0, preview: false }).error { - failed.push(format!("p{} text: {e}", i + 1)); - } - if let Some(e) = p.error { - failed.push(format!("p{}: {e}", i + 1)); - } - } - let status = if failed.is_empty() { "ok".to_string() } else { "page-errors".to_string() }; - (status, info.pages.len(), failed.len(), failed.join(" | "), info.warnings.len()) - } - }; - // `--edit` (fuzzing): also run our own object layer end to end, with no panic safety net, - // so a panic in cos shows up as a crash of this process. - if args.iter().any(|a| a == "--edit") { - edit_round_trip(&std::fs::read(path).map_err(|e| e.to_string())?); - } - let line = serde_json::json!({ "file": path, "status": status, "pages": pages, "failed_pages": failed, "warnings": warnings, - "ms": start.elapsed().as_millis() as u64, "detail": detail.chars().take(400).collect::() }); - stdout_line(format_args!("{line}"))?; - Ok(()) -} - -/// Open with cos, touch every object, edit the catalog, save incrementally and in full (both -/// output styles), and reopen each result. Errors are fine; panics, hangs and aborts are bugs. -fn edit_round_trip(bytes: &[u8]) { - use pdfcraft_cos::{Document, Object, SaveOptions, write_full, write_incremental}; - let Ok(mut doc) = Document::open(Arc::new(bytes.to_vec())) else { return }; - for num in doc.object_numbers().into_iter().take(20_000) { - if let Ok(o) = doc.try_get(num) - && let Object::Stream(s) = &*o - { - let _ = s.decoded(); - } - } - if let Some(root) = doc.root() { - let _ = doc.update_dict(root, |d| d.set(b"PdfCraftFuzz".to_vec(), Object::Bool(true))); - } - let classic = SaveOptions { object_streams: false, ..SaveOptions::default() }; - for out in [write_incremental(&doc, &SaveOptions::default()), write_full(&doc, &SaveOptions::default()), write_full(&doc, &classic)] - .into_iter() - .flatten() - { - if let Ok(again) = Document::open(Arc::new(out)) { - let _ = again.root().map(|r| again.get(r)); - } - } -} - -fn short(s: &str) -> String { - s.split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()).take(4).collect::>().join("-").to_lowercase() -} - -fn collect(paths: &[&str]) -> Vec { - let mut out = Vec::new(); - let mut stack: Vec = paths.iter().map(PathBuf::from).collect(); - let mut visited = std::collections::HashSet::new(); - while let Some(p) = stack.pop() { - if p.is_dir() { - // Directory aliases and symlink cycles must not scan the same tree repeatedly. - let identity = match p.canonicalize() { - Ok(identity) => identity, - Err(error) => { - eprintln!("{}: could not resolve directory: {error}", p.display()); - continue; - } - }; - if !visited.insert(identity) { - continue; - } - if let Ok(rd) = std::fs::read_dir(&p) { - stack.extend(rd.flatten().map(|e| e.path())); - } - } else if p.extension().and_then(std::ffi::OsStr::to_str).is_some_and(|e| e.eq_ignore_ascii_case("pdf")) { - out.push(p); - } - } - out.sort(); - out -} - -fn check(args: &[String]) -> Result<(), CliError> { - validate_options("check", args, CHECK_OPTIONS)?; - // Every option is checked before the inputs are scanned, so a bad value is one message, - // not one problem per file: a zero timeout used to make every file time out, and a bad - // dpi used to reach each child, which printed nothing and was reported as a crash. - let timeout_value = flag(args, "--timeout").unwrap_or("20"); - let timeout = Duration::from_secs( - timeout_value - .parse::() - .ok() - .filter(|secs| *secs > 0) - .ok_or_else(|| format!("bad --timeout: expected a positive whole number of seconds, got {timeout_value:?}"))?, - ); - let dpi = flag(args, "--dpi").unwrap_or("36").to_string(); - parse_dpi(&dpi)?; - let files = collect(&positional(args, CHECK_OPTIONS)); - if files.is_empty() { - return Err("check: no PDF files found".into()); - } - let exe = std::env::current_exe().map_err(|e| e.to_string())?; - let mut results = Vec::new(); - let mut counts: std::collections::BTreeMap = Default::default(); - for (n, f) in files.iter().enumerate() { - let r = run_child(&exe, f, &dpi, timeout); - let status = r["status"].as_str().unwrap_or("?").to_string(); - *counts.entry(status.split(':').next().unwrap_or("?").to_string()).or_default() += 1; - if status != "ok" { - eprintln!( - "[{}/{}] {status:<28} {} {}", - n + 1, - files.len(), - f.display(), - r["detail"].as_str().unwrap_or("").chars().take(160).collect::() - ); - } - results.push(r); - } - eprintln!("\nchecked {} files: {:?}", files.len(), counts); - if let Some(out) = flag(args, "--json") { - std::fs::write(out, serde_json::to_string_pretty(&results).unwrap_or_default()).map_err(|e| e.to_string())?; - } - Ok(()) -} - -fn run_child(exe: &Path, file: &Path, dpi: &str, timeout: Duration) -> serde_json::Value { - let file_s = file.to_string_lossy().to_string(); - let child = Command::new(exe).args(["check-one", &file_s, "--dpi", dpi]).stdout(Stdio::piped()).stderr(Stdio::null()).spawn(); - let mut child = match child { - Ok(c) => c, - Err(e) => return serde_json::json!({ "file": file_s, "status": "harness-error", "detail": e.to_string() }), - }; - let start = Instant::now(); - loop { - match child.try_wait() { - Ok(Some(status)) => { - let mut out = String::new(); - if let Some(mut so) = child.stdout.take() { - use std::io::Read; - let _ = so.read_to_string(&mut out); - } - return match serde_json::from_str::(out.trim()) { - Ok(v) => v, - Err(_) => serde_json::json!({ "file": file_s, "status": "crash", "detail": format!("child exited with {status}") }), - }; - } - Ok(None) if start.elapsed() > timeout => { - let _ = child.kill(); - let _ = child.wait(); - return serde_json::json!({ "file": file_s, "status": "timeout", "detail": format!("exceeded {}s", timeout.as_secs()) }); - } - Ok(None) => std::thread::sleep(Duration::from_millis(20)), - Err(e) => return serde_json::json!({ "file": file_s, "status": "harness-error", "detail": e.to_string() }), - } - } -} - -// ---- automation -------------------------------------------------------------------------------- - -fn automation(args: &[String]) -> Result { - let a = pdfcraft_automation::Automation::new(); - match flag(args, "--root") { - Some(root) => a.with_root(root).map_err(|e| format!("--root {root}: {e}")), - None => Ok(a), - } -} - -fn tools() -> Result<(), CliError> { - let list: Vec = pdfcraft_automation::tools() - .iter() - .map(|t| serde_json::json!({ "name": t.name, "description": t.description, "read_only": t.read_only, "command": t.command, "input_schema": t.input_schema })) - .collect(); - stdout_line(format_args!("{}", serde_json::to_string_pretty(&list).unwrap_or_default()))?; - Ok(()) -} - -/// Print a tool's result: JSON as JSON; images go to `--out` (or are summarised). The image is -/// written like a tool's own output, so `--root` confines it too. -fn print_output(auto: &pdfcraft_automation::Automation, content: Vec, out: Option<&str>) -> Result<(), CliError> { - for c in content { - match c { - pdfcraft_automation::Content::Json(v) => stdout_line(format_args!("{}", serde_json::to_string_pretty(&v).unwrap_or_default()))?, - pdfcraft_automation::Content::Png { data, width, height } => match out { - Some(path) => { - let written = auto.write_output(path, &data).map_err(|e| e.to_string())?; - stdout_line(format_args!("{}", serde_json::json!({ "image": written.to_string_lossy(), "width": width, "height": height })))?; - } - None => stdout_line(format_args!("{}", png_summary(&data, width, height)))?, - }, - } - } - Ok(()) -} - -/// What a PNG result is reported as when it isn't written to a file. -fn png_summary(data: &[u8], width: u32, height: u32) -> serde_json::Value { - serde_json::json!({ "image": "png", "width": width, "height": height, "bytes": data.len(), "hint": "pass --out file.png to save it" }) -} - -/// A tool's result as JSON values, for the `--report` of a script. Same content as -/// [`print_output`], collected instead of printed. -fn output_values( - auto: &pdfcraft_automation::Automation, - content: Vec, - out: Option<&str>, -) -> Result, String> { - let mut values = Vec::new(); - for c in content { - values.push(match c { - pdfcraft_automation::Content::Json(v) => v, - pdfcraft_automation::Content::Png { data, width, height } => match out { - Some(path) => { - let written = auto.write_output(path, &data).map_err(|e| e.to_string())?; - serde_json::json!({ "image": written.to_string_lossy(), "width": width, "height": height }) - } - None => png_summary(&data, width, height), - }, - }); - } - Ok(values) -} - -/// Run a script and print one JSON document: per step its number, tool and `ok`/`error`/`skipped` -/// with the result or the message. The first failing step ends the run, as without `--report`; -/// the steps after it are listed as skipped and the exit status is the same. -fn run_script_report(auto: &mut pdfcraft_automation::Automation, steps: &[serde_json::Value]) -> Result<(), CliError> { - let mut report = Vec::with_capacity(steps.len()); - let mut failure: Option = None; - for (i, step) in steps.iter().enumerate() { - let n = i + 1; - let tool = step["tool"].as_str(); - if failure.is_some() { - report.push(serde_json::json!({ "step": n, "tool": tool, "status": "skipped" })); - continue; - } - let outcome = match tool { - None => Err(format!("step {n}: missing \"tool\"")), - Some(t) => auto - .call(t, &step["args"]) - .map_err(|e| e.to_string()) - .and_then(|content| output_values(auto, content, step["out"].as_str())) - .map_err(|e| format!("step {n} ({t}): {e}")), - }; - match outcome { - Ok(result) => report.push(serde_json::json!({ "step": n, "tool": tool, "status": "ok", "result": result })), - Err(message) => { - report.push(serde_json::json!({ "step": n, "tool": tool, "status": "error", "error": message })); - failure = Some(message); - } - } - } - let document = serde_json::json!({ "ok": failure.is_none(), "steps": report }); - stdout_line(format_args!("{}", serde_json::to_string_pretty(&document).unwrap_or_default()))?; - failure.map_or(Ok(()), |message| Err(CliError::Message(message))) -} - -fn run(args: &[String]) -> Result<(), CliError> { - validate_options("run", args, RUN_OPTIONS)?; - let report = args.iter().any(|a| a == "--report"); - if report && flag(args, "--script").is_none() { - return Err("run: --report needs --script".to_string().into()); - } - let mut auto = automation(args)?; - if let Some(script) = flag(args, "--script") { - let text = std::fs::read_to_string(script).map_err(|e| format!("{script}: {e}"))?; - let steps: Vec = serde_json::from_str(&text).map_err(|e| format!("{script}: {e}"))?; - if report { - return run_script_report(&mut auto, &steps); - } - for (i, step) in steps.iter().enumerate() { - let tool = step["tool"].as_str().ok_or(format!("step {}: missing \"tool\"", i + 1))?; - let content = auto.call(tool, &step["args"]).map_err(|e| format!("step {} ({tool}): {e}", i + 1))?; - print_output(&auto, content, step["out"].as_str()).map_err(|e| match e { - CliError::Message(message) => CliError::Message(format!("step {} ({tool}): {message}", i + 1)), - stdout => stdout, - })?; - } - return Ok(()); - } - let tool = *positional(args, RUN_OPTIONS).first().ok_or("run: missing tool name (see `pdfcraft-cli tools`)")?; - let mut obj = serde_json::Map::new(); - for kv in positional(args, RUN_OPTIONS).iter().skip(1) { - let (k, v) = kv.split_once('=').ok_or(format!("run: expected key=value, got {kv:?}"))?; - let value = serde_json::from_str(v).unwrap_or_else(|_| serde_json::Value::String(v.to_string())); - obj.insert(k.to_string(), value); - } - let content = auto.call(tool, &serde_json::Value::Object(obj)).map_err(|e| e.to_string())?; - print_output(&auto, content, flag(args, "--out")) -} - -/// Take `--automation-read-root` / `--automation-write-root` (each `--flag DIR` or `--flag=DIR`, -/// once) out of `args`, and open the roots, which must be existing folders. Returns the arguments -/// that are left. A malformed root flag is an error, never a root silently left out. -#[cfg(feature = "mcp")] -fn automation_roots(command: &str, args: &[String]) -> Result<(Option>, Vec), String> { - let mut found = automation_args::AutomationArgs::new(None, None); - let mut left = Vec::new(); - let mut it = args.iter().cloned(); - while let Some(arg) = it.next() { - if !found.consume(&arg, &mut it).map_err(|e| format!("{command}: {e}"))? { - left.push(arg); - } - } - let roots = pdfcraft_automation::file_access::AutomationRoots::new(found.read_root.as_deref(), found.write_root.as_deref()) - .map_err(|e| format!("{command}: {e}"))?; - Ok((roots, left)) -} - -#[cfg(feature = "mcp")] -fn mcp(args: &[String]) -> Result<(), CliError> { - let (roots, args) = automation_roots("mcp", args)?; - let args = args.as_slice(); - validate_options("mcp", args, MCP_OPTIONS)?; - let compact = args.iter().any(|a| a == "--compact"); - let mut auto = automation(args)?; - if let Some(roots) = roots { - let show = |root: Option<&Path>| root.map_or_else(|| "none".to_string(), |r| r.display().to_string()); - let _ = writeln!( - std::io::stderr().lock(), - "pdfcraft-cli: files confined: read root {}, write root {}", - show(roots.read_root()), - show(roots.write_root()) - ); - auto = auto.with_automation_roots(Some(roots)); - } - let mut server = pdfcraft_automation::mcp::McpServer::new(auto).with_compact(compact); - let _ = writeln!( - std::io::stderr().lock(), - "pdfcraft-cli: MCP server on stdio (protocol {}{}); close stdin to stop", - pdfcraft_automation::mcp::PROTOCOL_VERSIONS[0], - if compact { ", compact tool list" } else { "" } - ); - server.serve(std::io::stdin().lock(), std::io::stdout().lock()).map_err(|e| CliError::Message(e.to_string())) -} - -// ---- UI control channel client ----------------------------------------------------------------- - -/// One request to a running app's control channel (`pdfcraft --control FILE`). -fn ui(args: &[String]) -> Result<(), CliError> { - validate_options("ui", args, UI_OPTIONS)?; - use std::io::{BufRead, BufReader, Write as _}; - let file = flag(args, "--control").ok_or("ui: missing --control FILE (start the app with `pdfcraft --control FILE`)")?; - let info = read_control_file(file)?; - let port = info["port"].as_u64().ok_or(format!("{file}: no port"))?; - // Refused rather than truncated, which would quietly reach some other port. - let port = u16::try_from(port).map_err(|_| format!("{file}: {port} is not a port number"))?; - let token = info["token"].as_str().ok_or(format!("{file}: no token"))?; - let pos = positional(args, UI_OPTIONS); - let method = *pos.first().ok_or("ui: missing method (state, inspect, click, drag, type, key, command, commands, set, open, screenshot)")?; - let method = if method.starts_with("ui.") { method.to_string() } else { format!("ui.{method}") }; - let mut params = serde_json::Map::new(); - for kv in pos.iter().skip(1) { - let (k, v) = kv.split_once('=').ok_or(format!("ui: expected key=value, got {kv:?}"))?; - params.insert(k.to_string(), serde_json::from_str(v).unwrap_or_else(|_| serde_json::Value::String(v.to_string()))); - } - let stream = - std::net::TcpStream::connect(("127.0.0.1", port)).map_err(|e| format!("can't reach the app on port {port}: {e} (is it still running?)"))?; - stream.set_read_timeout(Some(Duration::from_secs(40))).map_err(|e| e.to_string())?; - let mut write = stream.try_clone().map_err(|e| e.to_string())?; - let mut lines = BufReader::new(stream).lines(); - let mut rpc = |id: u64, method: &str, params: serde_json::Value| -> Result { - writeln!(write, "{}", serde_json::json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params })).map_err(|e| e.to_string())?; - let line = lines.next().ok_or("the app closed the connection")?.map_err(|e| e.to_string())?; - let reply: serde_json::Value = serde_json::from_str(&line).map_err(|e| e.to_string())?; - match reply.get("error") { - Some(e) => Err(e["message"].as_str().unwrap_or("error").to_string()), - None => Ok(reply["result"].clone()), - } - }; - rpc(1, "auth", serde_json::json!({ "token": token }))?; - let mut result = rpc(2, &method, serde_json::Value::Object(params))?; - if let (Some(out), Some(data)) = (flag(args, "--out"), result.get("png_base64").and_then(|d| d.as_str())) { - use base64::Engine as _; - let png = base64::engine::general_purpose::STANDARD.decode(data).map_err(|e| e.to_string())?; - std::fs::write(out, png).map_err(|e| format!("{out}: {e}"))?; - result["png_base64"] = serde_json::json!(format!("written to {out}")); - } - stdout_line(format_args!("{}", serde_json::to_string_pretty(&result).unwrap_or_default()))?; - Ok(()) -} - -/// Most a control file may hold. The app writes under 100 bytes. -const CONTROL_FILE_MAX: u64 = 64 * 1024; - -const CONTROL_FILE_ADVICE: &str = - "Start the app with a control file in a folder only you can write, such as `pdfcraft --control ~/.pdfcraft-control.json`"; - -/// Read the file written by `pdfcraft --control FILE`. -/// -/// It names the port that receives the token and every command, typed text included, so it must -/// be the user's own. In a shared folder such as `/tmp`, another local user can create the file -/// first; the app then can't write it, and the CLI would talk to the other user's listener. -/// Refused: a symbolic link, anything but a regular file, and on Unix a file owned by someone -/// else or open to other users (the app writes it with mode 600). -fn read_control_file(file: &str) -> Result { - use std::io::Read as _; - let at_path = std::fs::symlink_metadata(file).map_err(|e| format!("{file}: {e}"))?; - if at_path.file_type().is_symlink() { - return Err(format!("{file}: is a symbolic link, which could point at another user's file. {CONTROL_FILE_ADVICE}")); - } - if !at_path.is_file() { - return Err(format!("{file}: not a regular file. {CONTROL_FILE_ADVICE}")); - } - let f = std::fs::File::open(file).map_err(|e| format!("{file}: {e}"))?; - #[cfg(unix)] - { - use std::os::unix::fs::MetadataExt; - // Checked on the open file, so nothing swapped in after `symlink_metadata` gets through. - let opened = f.metadata().map_err(|e| format!("{file}: {e}"))?; - if (opened.dev(), opened.ino()) != (at_path.dev(), at_path.ino()) { - return Err(format!("{file}: was replaced while being opened. {CONTROL_FILE_ADVICE}")); - } - if let Some(problem) = control_file_problem(opened.uid(), opened.mode(), current_uid()) { - return Err(format!("{file}: {problem}. {CONTROL_FILE_ADVICE}")); - } - } - let mut text = String::new(); - f.take(CONTROL_FILE_MAX.saturating_add(1)).read_to_string(&mut text).map_err(|e| format!("{file}: {e}"))?; - if u64::try_from(text.len()).unwrap_or(u64::MAX) > CONTROL_FILE_MAX { - return Err(format!("{file}: too large for a control file (over {} KiB)", CONTROL_FILE_MAX / 1024)); - } - serde_json::from_str(&text).map_err(|e| format!("{file}: {e}")) -} - -/// Why user `me` can't trust a control file with this owner and mode, if they can't. -#[cfg(any(test, unix))] -fn control_file_problem(owner: u32, mode: u32, me: u32) -> Option { - if owner != me { - return Some(format!("owned by another user (uid {owner}; you are uid {me}), who may be the one listening for your commands")); - } - let permissions = mode & 0o7777; - (permissions & 0o077 != 0).then(|| format!("other users have access to it (mode {permissions:o}; the app writes it with mode 600)")) -} - -/// The effective user id: the owner of the files this process creates, and so of a control file -/// the app wrote for this user. std has no `geteuid` and this crate forbids `unsafe`; rustix's is -/// safe and can't fail. -#[cfg(unix)] -fn current_uid() -> u32 { - rustix::process::geteuid().as_raw() -} - -#[cfg(test)] -mod tests { - use super::control_file_problem; - - #[test] - fn a_control_file_must_be_yours_and_private() { - // Regular file type bits (0o100000) don't matter, only the owner and the permission bits. - assert_eq!(control_file_problem(1000, 0o100600, 1000), None); - assert_eq!(control_file_problem(1000, 0o600, 1000), None); - assert_eq!(control_file_problem(1000, 0o400, 1000), None); - let other = control_file_problem(1001, 0o600, 1000).unwrap_or_default(); - assert!(other.contains("owned by another user") && other.contains("1001"), "{other}"); - // Root gets no exception: a file a user planted is still theirs. - assert!(control_file_problem(1000, 0o600, 0).is_some_and(|p| p.contains("owned by another user"))); - for mode in [0o644, 0o640, 0o604, 0o660, 0o606, 0o620, 0o602, 0o610, 0o601, 0o100666] { - let open = control_file_problem(1000, mode, 1000).unwrap_or_default(); - assert!(open.contains("other users"), "{mode:o}: {open}"); - } - assert!(control_file_problem(1000, 0o100644, 1000).unwrap_or_default().contains("mode 644")); - } -} diff --git a/apps/pdfcraft-cli/src/options.rs b/apps/pdfcraft-cli/src/options.rs new file mode 100644 index 0000000000..1ddbfabcd5 --- /dev/null +++ b/apps/pdfcraft-cli/src/options.rs @@ -0,0 +1,148 @@ +use super::CliError; + +pub(crate) fn flag<'a>(args: &'a [String], name: &str) -> Option<&'a str> { + args.iter().position(|a| a == name).and_then(|i| args.get(i + 1)).map(String::as_str) +} + +#[derive(Clone, Copy)] +pub(crate) struct OptionSpec { + pub(crate) name: &'static str, + pub(crate) takes_value: bool, +} + +/// The options whose value is a file or folder. Only these refuse a value that is another +/// option: a file named `--password` is never what was meant, while a password can be anything +/// (`--password --page` is a password of `--page`, which tests/text.rs pins), and so can a title. +/// The way to name a file that does start with `-` is `./--name`, as `--root` already offered. +const PATH_VALUE_OPTIONS: [&str; 6] = ["--out", "--out-dir", "--json", "--root", "--control", "--script"]; + +/// Whether a token can be the value of a path option. Another option cannot: `--out --password` +/// used to write the PDF to a file called `--password` and exit 0 (photocraft#6944). +pub(crate) fn can_be_path_value(name: &str, value: Option<&str>) -> bool { + !PATH_VALUE_OPTIONS.contains(&name) || value.is_some_and(|v| !v.is_empty() && !v.starts_with("--")) +} + +/// The complaint about a path option whose value is missing or is another option. +pub(crate) fn needs_value(command: &str, name: &str, value: Option<&str>) -> String { + match value { + Some(v) if !v.is_empty() => { + format!("{command}: {name} needs a value and `{v}` is another option; write `./{v}` for a file of that name") + } + _ => format!("{command}: {name} needs a value"), + } +} + +/// Refuse unknown options and malformed confinement flags before any file is read. +pub(crate) fn validate_options(command: &str, args: &[String], specs: &[OptionSpec]) -> Result<(), CliError> { + if specs.iter().any(|spec| spec.name == "--root") { + // `flag` searches every token, including another option's value. Match that lookup so + // a malformed confinement flag cannot be hidden there and fall back to no root. + let mut root_seen = false; + for (i, _) in args.iter().enumerate().filter(|(_, arg)| arg.as_str() == "--root") { + if root_seen { + return Err(format!("{command}: --root may only be supplied once").into()); + } + root_seen = true; + if args.get(i + 1).is_none_or(|value| value.is_empty() || value.starts_with("--")) { + return Err(format!("{command}: --root needs a directory value").into()); + } + } + } + let mut i = 0; + while let Some(arg) = args.get(i) { + if let Some(name) = arg.strip_prefix("--") { + let Some(spec) = specs.iter().find(|spec| spec.name == arg) else { + return Err(format!("{command}: unknown option --{name}").into()); + }; + // A file's name is not another option, which also closes the gap the walk left for + // those: an unknown option in that value position was skipped along with it. Other + // values, missing or malformed, are left to the command, which reports them in its + // own terms (`text` and `extract` explain what a page value must be). + i += if spec.takes_value { + let value = args.get(i + 1).map(String::as_str); + if !can_be_path_value(spec.name, value) { + return Err(needs_value(command, spec.name, value).into()); + } + 2 + } else { + 1 + }; + } else { + i += 1; + } + } + Ok(()) +} + +pub(crate) const INFO_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--password", takes_value: true }]; + +pub(crate) const AUDIT_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--password", takes_value: true }]; + +pub(crate) const TEXT_OPTIONS: &[OptionSpec] = + &[OptionSpec { name: "--page", takes_value: true }, OptionSpec { name: "--password", takes_value: true }]; + +pub(crate) const COMBINE_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--out", takes_value: true }]; + +pub(crate) const EXTRACT_OPTIONS: &[OptionSpec] = &[ + OptionSpec { name: "--out", takes_value: true }, + OptionSpec { name: "--pages", takes_value: true }, + OptionSpec { name: "--password", takes_value: true }, +]; + +pub(crate) const SPLIT_OPTIONS: &[OptionSpec] = &[ + OptionSpec { name: "--every", takes_value: true }, + OptionSpec { name: "--before", takes_value: true }, + OptionSpec { name: "--out-dir", takes_value: true }, + OptionSpec { name: "--password", takes_value: true }, +]; + +pub(crate) const RENDER_OPTIONS: &[OptionSpec] = &[ + OptionSpec { name: "--page", takes_value: true }, + OptionSpec { name: "--dpi", takes_value: true }, + OptionSpec { name: "--out", takes_value: true }, + OptionSpec { name: "--password", takes_value: true }, +]; + +pub(crate) const CHECK_ONE_OPTIONS: &[OptionSpec] = + &[OptionSpec { name: "--dpi", takes_value: true }, OptionSpec { name: "--edit", takes_value: false }]; + +pub(crate) const CHECK_OPTIONS: &[OptionSpec] = &[ + OptionSpec { name: "--timeout", takes_value: true }, + OptionSpec { name: "--dpi", takes_value: true }, + OptionSpec { name: "--json", takes_value: true }, +]; + +pub(crate) const RUN_OPTIONS: &[OptionSpec] = &[ + OptionSpec { name: "--root", takes_value: true }, + OptionSpec { name: "--script", takes_value: true }, + OptionSpec { name: "--out", takes_value: true }, + OptionSpec { name: "--report", takes_value: false }, +]; + +pub(crate) const UI_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--control", takes_value: true }, OptionSpec { name: "--out", takes_value: true }]; + +#[cfg(feature = "mcp")] +pub(crate) const MCP_OPTIONS: &[OptionSpec] = + &[OptionSpec { name: "--root", takes_value: true }, OptionSpec { name: "--compact", takes_value: false }]; + +/// The arguments that are not options or their values. Which tokens are values is the spec's +/// answer: skipping the one after every option made `check-one --edit file.pdf` report "missing +/// file", the file having been read as `--edit`'s value. +pub(crate) fn positional<'a>(args: &'a [String], specs: &[OptionSpec]) -> Vec<&'a str> { + let mut out = Vec::new(); + let mut i = 0; + while let Some(a) = args.get(i) { + match specs.iter().find(|spec| spec.name == a.as_str()) { + Some(spec) if spec.takes_value => i += 2, + // An option that takes no value, and an unknown one, which `validate_options` has + // already refused by the time this runs. + Some(_) => i += 1, + None if a.starts_with("--") => i += 1, + None => { + out.push(a.as_str()); + i += 1; + } + } + } + out +} diff --git a/apps/pdfcraft-cli/src/run.rs b/apps/pdfcraft-cli/src/run.rs new file mode 100644 index 0000000000..0726087d9d --- /dev/null +++ b/apps/pdfcraft-cli/src/run.rs @@ -0,0 +1,188 @@ +#[cfg(feature = "mcp")] +use super::options::MCP_OPTIONS; +use super::options::{RUN_OPTIONS, flag, positional, validate_options}; +#[cfg(feature = "mcp")] +use super::{Arc, Path, Write, automation_args}; +use super::{CliError, stdout_line}; + +// ---- automation -------------------------------------------------------------------------------- + +pub(crate) fn automation(args: &[String]) -> Result { + let a = pdfcraft_automation::Automation::new(); + match flag(args, "--root") { + Some(root) => a.with_root(root).map_err(|e| format!("--root {root}: {e}")), + None => Ok(a), + } +} + +pub(crate) fn tools() -> Result<(), CliError> { + let list: Vec = pdfcraft_automation::tools() + .iter() + .map(|t| serde_json::json!({ "name": t.name, "description": t.description, "read_only": t.read_only, "command": t.command, "input_schema": t.input_schema })) + .collect(); + stdout_line(format_args!("{}", serde_json::to_string_pretty(&list).unwrap_or_default()))?; + Ok(()) +} + +/// Print a tool's result: JSON as JSON; images go to `--out` (or are summarised). The image is +/// written like a tool's own output, so `--root` confines it too. +pub(crate) fn print_output( + auto: &pdfcraft_automation::Automation, + content: Vec, + out: Option<&str>, +) -> Result<(), CliError> { + for c in content { + match c { + pdfcraft_automation::Content::Json(v) => stdout_line(format_args!("{}", serde_json::to_string_pretty(&v).unwrap_or_default()))?, + pdfcraft_automation::Content::Png { data, width, height } => match out { + Some(path) => { + let written = auto.write_output(path, &data).map_err(|e| e.to_string())?; + stdout_line(format_args!("{}", serde_json::json!({ "image": written.to_string_lossy(), "width": width, "height": height })))?; + } + None => stdout_line(format_args!("{}", png_summary(&data, width, height)))?, + }, + } + } + Ok(()) +} + +/// What a PNG result is reported as when it isn't written to a file. +fn png_summary(data: &[u8], width: u32, height: u32) -> serde_json::Value { + serde_json::json!({ "image": "png", "width": width, "height": height, "bytes": data.len(), "hint": "pass --out file.png to save it" }) +} + +/// A tool's result as JSON values, for the `--report` of a script. Same content as +/// [`print_output`], collected instead of printed. +fn output_values( + auto: &pdfcraft_automation::Automation, + content: Vec, + out: Option<&str>, +) -> Result, String> { + let mut values = Vec::new(); + for c in content { + values.push(match c { + pdfcraft_automation::Content::Json(v) => v, + pdfcraft_automation::Content::Png { data, width, height } => match out { + Some(path) => { + let written = auto.write_output(path, &data).map_err(|e| e.to_string())?; + serde_json::json!({ "image": written.to_string_lossy(), "width": width, "height": height }) + } + None => png_summary(&data, width, height), + }, + }); + } + Ok(values) +} + +/// Run a script and print one JSON document: per step its number, tool and `ok`/`error`/`skipped` +/// with the result or the message. The first failing step ends the run, as without `--report`; +/// the steps after it are listed as skipped and the exit status is the same. +fn run_script_report(auto: &mut pdfcraft_automation::Automation, steps: &[serde_json::Value]) -> Result<(), CliError> { + let mut report = Vec::with_capacity(steps.len()); + let mut failure: Option = None; + for (i, step) in steps.iter().enumerate() { + let n = i + 1; + let tool = step["tool"].as_str(); + if failure.is_some() { + report.push(serde_json::json!({ "step": n, "tool": tool, "status": "skipped" })); + continue; + } + let outcome = match tool { + None => Err(format!("step {n}: missing \"tool\"")), + Some(t) => auto + .call(t, &step["args"]) + .map_err(|e| e.to_string()) + .and_then(|content| output_values(auto, content, step["out"].as_str())) + .map_err(|e| format!("step {n} ({t}): {e}")), + }; + match outcome { + Ok(result) => report.push(serde_json::json!({ "step": n, "tool": tool, "status": "ok", "result": result })), + Err(message) => { + report.push(serde_json::json!({ "step": n, "tool": tool, "status": "error", "error": message })); + failure = Some(message); + } + } + } + let document = serde_json::json!({ "ok": failure.is_none(), "steps": report }); + stdout_line(format_args!("{}", serde_json::to_string_pretty(&document).unwrap_or_default()))?; + failure.map_or(Ok(()), |message| Err(CliError::Message(message))) +} + +pub(crate) fn run(args: &[String]) -> Result<(), CliError> { + validate_options("run", args, RUN_OPTIONS)?; + let report = args.iter().any(|a| a == "--report"); + if report && flag(args, "--script").is_none() { + return Err("run: --report needs --script".to_string().into()); + } + let mut auto = automation(args)?; + if let Some(script) = flag(args, "--script") { + let text = std::fs::read_to_string(script).map_err(|e| format!("{script}: {e}"))?; + let steps: Vec = serde_json::from_str(&text).map_err(|e| format!("{script}: {e}"))?; + if report { + return run_script_report(&mut auto, &steps); + } + for (i, step) in steps.iter().enumerate() { + let tool = step["tool"].as_str().ok_or(format!("step {}: missing \"tool\"", i + 1))?; + let content = auto.call(tool, &step["args"]).map_err(|e| format!("step {} ({tool}): {e}", i + 1))?; + print_output(&auto, content, step["out"].as_str()).map_err(|e| match e { + CliError::Message(message) => CliError::Message(format!("step {} ({tool}): {message}", i + 1)), + stdout => stdout, + })?; + } + return Ok(()); + } + let tool = *positional(args, RUN_OPTIONS).first().ok_or("run: missing tool name (see `pdfcraft-cli tools`)")?; + let mut obj = serde_json::Map::new(); + for kv in positional(args, RUN_OPTIONS).iter().skip(1) { + let (k, v) = kv.split_once('=').ok_or(format!("run: expected key=value, got {kv:?}"))?; + let value = serde_json::from_str(v).unwrap_or_else(|_| serde_json::Value::String(v.to_string())); + obj.insert(k.to_string(), value); + } + let content = auto.call(tool, &serde_json::Value::Object(obj)).map_err(|e| e.to_string())?; + print_output(&auto, content, flag(args, "--out")) +} + +/// Take `--automation-read-root` / `--automation-write-root` (each `--flag DIR` or `--flag=DIR`, +/// once) out of `args`, and open the roots, which must be existing folders. Returns the arguments +/// that are left. A malformed root flag is an error, never a root silently left out. +#[cfg(feature = "mcp")] +fn automation_roots(command: &str, args: &[String]) -> Result<(Option>, Vec), String> { + let mut found = automation_args::AutomationArgs::new(None, None); + let mut left = Vec::new(); + let mut it = args.iter().cloned(); + while let Some(arg) = it.next() { + if !found.consume(&arg, &mut it).map_err(|e| format!("{command}: {e}"))? { + left.push(arg); + } + } + let roots = pdfcraft_automation::file_access::AutomationRoots::new(found.read_root.as_deref(), found.write_root.as_deref()) + .map_err(|e| format!("{command}: {e}"))?; + Ok((roots, left)) +} + +#[cfg(feature = "mcp")] +pub(crate) fn mcp(args: &[String]) -> Result<(), CliError> { + let (roots, args) = automation_roots("mcp", args)?; + let args = args.as_slice(); + validate_options("mcp", args, MCP_OPTIONS)?; + let compact = args.iter().any(|a| a == "--compact"); + let mut auto = automation(args)?; + if let Some(roots) = roots { + let show = |root: Option<&Path>| root.map_or_else(|| "none".to_string(), |r| r.display().to_string()); + let _ = writeln!( + std::io::stderr().lock(), + "pdfcraft-cli: files confined: read root {}, write root {}", + show(roots.read_root()), + show(roots.write_root()) + ); + auto = auto.with_automation_roots(Some(roots)); + } + let mut server = pdfcraft_automation::mcp::McpServer::new(auto).with_compact(compact); + let _ = writeln!( + std::io::stderr().lock(), + "pdfcraft-cli: MCP server on stdio (protocol {}{}); close stdin to stop", + pdfcraft_automation::mcp::PROTOCOL_VERSIONS[0], + if compact { ", compact tool list" } else { "" } + ); + server.serve(std::io::stdin().lock(), std::io::stdout().lock()).map_err(|e| CliError::Message(e.to_string())) +} diff --git a/apps/pdfcraft-cli/src/ui.rs b/apps/pdfcraft-cli/src/ui.rs new file mode 100644 index 0000000000..dc419171b0 --- /dev/null +++ b/apps/pdfcraft-cli/src/ui.rs @@ -0,0 +1,131 @@ +use super::options::{UI_OPTIONS, flag, positional, validate_options}; +use super::{CliError, Duration, stdout_line}; + +// ---- UI control channel client ----------------------------------------------------------------- + +/// One request to a running app's control channel (`pdfcraft --control FILE`). +pub(crate) fn ui(args: &[String]) -> Result<(), CliError> { + validate_options("ui", args, UI_OPTIONS)?; + use std::io::{BufRead, BufReader, Write as _}; + let file = flag(args, "--control").ok_or("ui: missing --control FILE (start the app with `pdfcraft --control FILE`)")?; + let info = read_control_file(file)?; + let port = info["port"].as_u64().ok_or(format!("{file}: no port"))?; + // Refused rather than truncated, which would quietly reach some other port. + let port = u16::try_from(port).map_err(|_| format!("{file}: {port} is not a port number"))?; + let token = info["token"].as_str().ok_or(format!("{file}: no token"))?; + let pos = positional(args, UI_OPTIONS); + let method = *pos.first().ok_or("ui: missing method (state, inspect, click, drag, type, key, command, commands, set, open, screenshot)")?; + let method = if method.starts_with("ui.") { method.to_string() } else { format!("ui.{method}") }; + let mut params = serde_json::Map::new(); + for kv in pos.iter().skip(1) { + let (k, v) = kv.split_once('=').ok_or(format!("ui: expected key=value, got {kv:?}"))?; + params.insert(k.to_string(), serde_json::from_str(v).unwrap_or_else(|_| serde_json::Value::String(v.to_string()))); + } + let stream = + std::net::TcpStream::connect(("127.0.0.1", port)).map_err(|e| format!("can't reach the app on port {port}: {e} (is it still running?)"))?; + stream.set_read_timeout(Some(Duration::from_secs(40))).map_err(|e| e.to_string())?; + let mut write = stream.try_clone().map_err(|e| e.to_string())?; + let mut lines = BufReader::new(stream).lines(); + let mut rpc = |id: u64, method: &str, params: serde_json::Value| -> Result { + writeln!(write, "{}", serde_json::json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params })).map_err(|e| e.to_string())?; + let line = lines.next().ok_or("the app closed the connection")?.map_err(|e| e.to_string())?; + let reply: serde_json::Value = serde_json::from_str(&line).map_err(|e| e.to_string())?; + match reply.get("error") { + Some(e) => Err(e["message"].as_str().unwrap_or("error").to_string()), + None => Ok(reply["result"].clone()), + } + }; + rpc(1, "auth", serde_json::json!({ "token": token }))?; + let mut result = rpc(2, &method, serde_json::Value::Object(params))?; + if let (Some(out), Some(data)) = (flag(args, "--out"), result.get("png_base64").and_then(|d| d.as_str())) { + use base64::Engine as _; + let png = base64::engine::general_purpose::STANDARD.decode(data).map_err(|e| e.to_string())?; + std::fs::write(out, png).map_err(|e| format!("{out}: {e}"))?; + result["png_base64"] = serde_json::json!(format!("written to {out}")); + } + stdout_line(format_args!("{}", serde_json::to_string_pretty(&result).unwrap_or_default()))?; + Ok(()) +} + +/// Most a control file may hold. The app writes under 100 bytes. +const CONTROL_FILE_MAX: u64 = 64 * 1024; + +const CONTROL_FILE_ADVICE: &str = + "Start the app with a control file in a folder only you can write, such as `pdfcraft --control ~/.pdfcraft-control.json`"; + +/// Read the file written by `pdfcraft --control FILE`. +/// +/// It names the port that receives the token and every command, typed text included, so it must +/// be the user's own. In a shared folder such as `/tmp`, another local user can create the file +/// first; the app then can't write it, and the CLI would talk to the other user's listener. +/// Refused: a symbolic link, anything but a regular file, and on Unix a file owned by someone +/// else or open to other users (the app writes it with mode 600). +fn read_control_file(file: &str) -> Result { + use std::io::Read as _; + let at_path = std::fs::symlink_metadata(file).map_err(|e| format!("{file}: {e}"))?; + if at_path.file_type().is_symlink() { + return Err(format!("{file}: is a symbolic link, which could point at another user's file. {CONTROL_FILE_ADVICE}")); + } + if !at_path.is_file() { + return Err(format!("{file}: not a regular file. {CONTROL_FILE_ADVICE}")); + } + let f = std::fs::File::open(file).map_err(|e| format!("{file}: {e}"))?; + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + // Checked on the open file, so nothing swapped in after `symlink_metadata` gets through. + let opened = f.metadata().map_err(|e| format!("{file}: {e}"))?; + if (opened.dev(), opened.ino()) != (at_path.dev(), at_path.ino()) { + return Err(format!("{file}: was replaced while being opened. {CONTROL_FILE_ADVICE}")); + } + if let Some(problem) = control_file_problem(opened.uid(), opened.mode(), current_uid()) { + return Err(format!("{file}: {problem}. {CONTROL_FILE_ADVICE}")); + } + } + let mut text = String::new(); + f.take(CONTROL_FILE_MAX.saturating_add(1)).read_to_string(&mut text).map_err(|e| format!("{file}: {e}"))?; + if u64::try_from(text.len()).unwrap_or(u64::MAX) > CONTROL_FILE_MAX { + return Err(format!("{file}: too large for a control file (over {} KiB)", CONTROL_FILE_MAX / 1024)); + } + serde_json::from_str(&text).map_err(|e| format!("{file}: {e}")) +} + +/// Why user `me` can't trust a control file with this owner and mode, if they can't. +#[cfg(any(test, unix))] +fn control_file_problem(owner: u32, mode: u32, me: u32) -> Option { + if owner != me { + return Some(format!("owned by another user (uid {owner}; you are uid {me}), who may be the one listening for your commands")); + } + let permissions = mode & 0o7777; + (permissions & 0o077 != 0).then(|| format!("other users have access to it (mode {permissions:o}; the app writes it with mode 600)")) +} + +/// The effective user id: the owner of the files this process creates, and so of a control file +/// the app wrote for this user. std has no `geteuid` and this crate forbids `unsafe`; rustix's is +/// safe and can't fail. +#[cfg(unix)] +fn current_uid() -> u32 { + rustix::process::geteuid().as_raw() +} + +#[cfg(test)] +mod tests { + use super::control_file_problem; + + #[test] + fn a_control_file_must_be_yours_and_private() { + // Regular file type bits (0o100000) don't matter, only the owner and the permission bits. + assert_eq!(control_file_problem(1000, 0o100600, 1000), None); + assert_eq!(control_file_problem(1000, 0o600, 1000), None); + assert_eq!(control_file_problem(1000, 0o400, 1000), None); + let other = control_file_problem(1001, 0o600, 1000).unwrap_or_default(); + assert!(other.contains("owned by another user") && other.contains("1001"), "{other}"); + // Root gets no exception: a file a user planted is still theirs. + assert!(control_file_problem(1000, 0o600, 0).is_some_and(|p| p.contains("owned by another user"))); + for mode in [0o644, 0o640, 0o604, 0o660, 0o606, 0o620, 0o602, 0o610, 0o601, 0o100666] { + let open = control_file_problem(1000, mode, 1000).unwrap_or_default(); + assert!(open.contains("other users"), "{mode:o}: {open}"); + } + assert!(control_file_problem(1000, 0o100644, 1000).unwrap_or_default().contains("mode 644")); + } +} diff --git a/apps/pdfcraft/src/main.rs b/apps/pdfcraft/src/main.rs index ee8ae152cc..f8e4e5b0d2 100644 --- a/apps/pdfcraft/src/main.rs +++ b/apps/pdfcraft/src/main.rs @@ -802,705 +802,4 @@ fn pick_adapter(adapters: &[(u32, u32, eframe::wgpu::DeviceType)], displays: &[D } #[cfg(test)] -mod tests { - /// The lockfile package block for `name`, or a panic naming the missing package. - fn locked_package(root: &std::path::Path, name: &str) -> String { - let lock = std::fs::read_to_string(root.join("Cargo.lock")).expect("Cargo.lock"); - lock.split("[[package]]") - .find(|p| p.contains(&format!("\nname = \"{name}\"\n"))) - .map(str::to_owned) - .unwrap_or_else(|| panic!("{name} is not in Cargo.lock")) - } - - /// Interim graph check for the window stack while the reviewed fork revision is not yet - /// published. The old `vendor/winit` / `vendor/egui-wgpu` path patches were removed from the - /// published manifest; local integration supplies the reviewed worktrees through an external - /// Cargo config patch. A git source must name the public storytold fork; a path source is the - /// local integration graph. Behaviour (surface fitting, error scopes, scroll phases) is tested - /// directly elsewhere; the retained vendor copies keep provenance until the fork revision is - /// pinned. No public revision is invented here. - fn assert_window_stack_graph(root: &std::path::Path, name: &str, version: &str) -> String { - let package = locked_package(root, name); - assert!(package.contains(&format!("\nversion = \"{version}\"\n")), "{name} must resolve to {version}: {package}"); - if let Some(source) = package.lines().find_map(|line| line.strip_prefix("source = ")) { - assert!(source.contains("github.com/storytold/"), "{name} must come from the reviewed storytold fork once published, not {source}"); - assert!( - source.split(|c: char| !c.is_ascii_hexdigit()).any(|part| part.len() == 40), - "{name} must pin the fork's 40-character revision, not {source}" - ); - } - // A path patch has no `source =` in Cargo.lock. The local integration config is the only - // allowed path source; a machine-local path must never be in the published manifest, - // which the root-manifest assertion below checks. A path entry carries no remote identity - // to verify, so this test does not claim the resolved code is the published fork. - let manifest = std::fs::read_to_string(root.join("Cargo.toml")).expect("root Cargo.toml"); - assert!(!manifest.contains(&format!("{name} = {{ path = \"vendor/")), "vendor/{name} must not be a published path patch"); - package - } - - use pdfcraft_ui_egui::control::Endpoint; - - fn endpoint() -> std::io::Result { - Ok(Endpoint { port: 4321, token: "0123456789abcdef".into() }) - } - - /// Tests that open a real GPU device take this first, so only one of them is inside the - /// graphics libraries at a time. The test threads otherwise run them together, and on - /// FreeBSD's CI machine the test process then died with SIGSEGV, in about half the runs: - /// the first device was still being set up when the second test began its own. The UI - /// tests take turns the same way (`crates/ui-egui/tests/it/main.rs`). Hold it for the whole - /// test (declared before the instance, so it's released after the device is dropped). - static GPU: std::sync::Mutex<()> = std::sync::Mutex::new(()); - - fn gpu() -> std::sync::MutexGuard<'static, ()> { - // A test that panicked while holding it leaves nothing to clean up. - GPU.lock().unwrap_or_else(std::sync::PoisonError::into_inner) - } - - #[test] - fn a_control_channel_that_cant_be_published_stops_the_app() { - let dir = std::env::temp_dir().join(format!("pdfcraft-app-control-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - let path = |name: &str| dir.join(name).to_str().unwrap().to_owned(); - - // A folder in the way stands in for a file another user created first: the write fails. - std::fs::create_dir(dir.join("taken.json")).unwrap(); - let err = super::open_control_channel(&path("taken.json"), endpoint()).unwrap_err(); - assert!(err.contains("--control") && err.contains("taken.json") && err.contains("only you can write"), "{err}"); - // No listener: nothing to publish. - let err = super::open_control_channel(&path("pc.json"), Err(std::io::Error::other("no loopback"))).unwrap_err(); - assert!(err.contains("no loopback"), "{err}"); - assert!(!dir.join("pc.json").exists()); - - super::open_control_channel(&path("pc.json"), endpoint()).unwrap(); - let written: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(dir.join("pc.json")).unwrap()).unwrap(); - assert_eq!((written["port"].as_u64(), written["token"].as_str()), (Some(4321), Some("0123456789abcdef"))); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn path_from_arg_decodes_file_uris() { - assert_eq!(super::path_from_arg("file:///home/alice/report.pdf".to_string()), "/home/alice/report.pdf"); - assert_eq!(super::path_from_arg("file:///home/alice/my%20report.pdf".to_string()), "/home/alice/my report.pdf"); - assert_eq!(super::path_from_arg("file://localhost/tmp/a%C3%A9.pdf".to_string()), "/tmp/aé.pdf"); - // Malformed or truncated escapes are kept as written; bytes that aren't UTF-8 keep the URI. - assert_eq!(super::path_from_arg("file:///tmp/100%.pdf".to_string()), "/tmp/100%.pdf"); - assert_eq!(super::path_from_arg("file:///tmp/a%2".to_string()), "/tmp/a%2"); - assert_eq!(super::path_from_arg("file:///tmp/%zz%".to_string()), "/tmp/%zz%"); - assert_eq!(super::path_from_arg("file:///tmp/%FF.pdf".to_string()), "file:///tmp/%FF.pdf"); - } - - #[test] - fn unquoted_paths_with_spaces_are_rejoined() { - let have = ["P:\\Archive\\00042A26 Contract.PDF", "C:\\a b c.pdf", "C:\\a.pdf", "C:\\x y.pdf"]; - let exists = |p: &str| have.contains(&p); - let args = |a: &[&str]| a.iter().map(|s| s.to_string()).collect::>(); - // The case an office program produced: one path, split at its space. - assert_eq!( - super::rejoin_split_paths(args(&["P:\\Archive\\00042A26", "Contract.PDF"]), exists), - args(&["P:\\Archive\\00042A26 Contract.PDF"]) - ); - // The longest run wins; files that exist on their own are left alone. - assert_eq!(super::rejoin_split_paths(args(&["C:\\a", "b", "c.pdf", "C:\\a.pdf"]), exists), args(&["C:\\a b c.pdf", "C:\\a.pdf"])); - assert_eq!(super::rejoin_split_paths(args(&["C:\\a.pdf", "C:\\x", "y.pdf"]), exists), args(&["C:\\a.pdf", "C:\\x y.pdf"])); - // Nothing to join: kept as given, so the missing file is still reported by name. - assert_eq!(super::rejoin_split_paths(args(&["missing.pdf", "other"]), exists), args(&["missing.pdf", "other"])); - assert!(super::rejoin_split_paths(Vec::new(), exists).is_empty()); - } - - #[test] - fn path_from_arg_leaves_plain_paths_and_other_schemes_alone() { - assert_eq!(super::path_from_arg("report.pdf".to_string()), "report.pdf"); - assert_eq!(super::path_from_arg("/home/alice/report.pdf".to_string()), "/home/alice/report.pdf"); - assert_eq!(super::path_from_arg("https://example.com/report.pdf".to_string()), "https://example.com/report.pdf"); - assert_eq!(super::path_from_arg("file://server/share/a.pdf".to_string()), "file://server/share/a.pdf"); - assert_eq!(super::path_from_arg("file://".to_string()), "file://"); - } - - #[test] - fn opengl_is_the_fallback_unless_a_renderer_was_chosen() { - use super::{RendererChoice, native_options, renderer_choice, retry_with_gl}; - assert_eq!(renderer_choice(None), RendererChoice::Auto); - assert_eq!(renderer_choice(Some("")), RendererChoice::Auto); - assert_eq!(renderer_choice(Some("vulkan")), RendererChoice::Auto); - assert_eq!(renderer_choice(Some(" WGPU ")), RendererChoice::Wgpu); - for gl in ["gl", "OpenGL", "glow"] { - assert_eq!(renderer_choice(Some(gl)), RendererChoice::Gl); - } - // Retried with OpenGL only when wgpu failed to start by default, never after the app ran. - assert!(retry_with_gl(RendererChoice::Auto, false)); - assert!(!retry_with_gl(RendererChoice::Auto, true)); - assert!(!retry_with_gl(RendererChoice::Wgpu, false)); - assert!(!retry_with_gl(RendererChoice::Gl, false)); - // Each run states its renderer rather than relying on eframe's default. - assert_eq!(native_options(false, false, eframe::Renderer::Wgpu).renderer, eframe::Renderer::Wgpu); - assert_eq!(native_options(false, false, eframe::Renderer::Glow).renderer, eframe::Renderer::Glow); - } - - #[test] - fn the_desktop_frame_is_turned_off_only_when_the_app_draws_one() { - use super::native_options; - assert_eq!(native_options(false, false, eframe::Renderer::Glow).viewport.decorations, None); - assert_eq!(native_options(false, true, eframe::Renderer::Glow).viewport.decorations, Some(false)); - } - - #[test] - fn a_software_renderer_is_shown_to_the_user() { - use eframe::wgpu::DeviceType; - // Issue #519: WARP or llvmpipe is announced in the app, not just in pdfcraft.log. - let mut app = super::PdfCraftApp::new(); - super::notify_software_renderer(&mut app, DeviceType::Cpu); - let notice = app.toast.as_ref().map(|(m, _)| m.as_str()).unwrap_or_default(); - assert!(notice.contains("without a graphics processor"), "{notice:?}"); - for gpu in [DeviceType::IntegratedGpu, DeviceType::DiscreteGpu, DeviceType::VirtualGpu, DeviceType::Other] { - let mut app = super::PdfCraftApp::new(); - super::notify_software_renderer(&mut app, gpu); - assert!(app.toast.is_none(), "{gpu:?}: {:?}", app.toast); - } - } - - #[test] - fn gpu_setup_avoids_vulkan_and_stretched_resizes_on_windows_and_prefers_low_power() { - let mut native = eframe::NativeOptions::default(); - super::configure_gpu(&mut native); - let eframe::egui_wgpu::WgpuSetup::CreateNew(setup) = &native.wgpu_options.wgpu_setup else { - panic!("default setup creates its own instance") - }; - if std::env::var_os("WGPU_POWER_PREF").is_none() { - assert_eq!(setup.power_preference, eframe::wgpu::PowerPreference::LowPower); - } - if std::env::var_os("WGPU_VALIDATION_INDIRECT_CALL").is_none() { - let flags = setup.instance_descriptor.flags; - assert!(!flags.contains(eframe::wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL), "Intel Metal can't compile its shader: {flags:?}"); - } - if cfg!(target_os = "windows") && std::env::var_os("WGPU_BACKEND").is_none() { - let backends = setup.instance_descriptor.backends; - assert!(backends.contains(eframe::wgpu::Backends::DX12), "{backends:?}"); - assert!(!backends.contains(eframe::wgpu::Backends::VULKAN), "issue #37: {backends:?}"); - } - use eframe::wgpu::Dx12SwapchainKind::{DxgiFromHwnd, DxgiFromVisual}; - assert_eq!(super::dx12_presentation(None), DxgiFromVisual); - assert_eq!(super::dx12_presentation(Some(DxgiFromHwnd)), DxgiFromHwnd, "WGPU_DX12_PRESENTATION_SYSTEM still decides"); - assert_eq!(super::dx12_presentation(Some(DxgiFromVisual)), DxgiFromVisual); - let presentation = setup.instance_descriptor.backend_options.dx12.presentation_system; - if cfg!(target_os = "windows") { - // A composition visual unless WGPU_DX12_PRESENTATION_SYSTEM asks otherwise. - let asked = eframe::wgpu::Dx12SwapchainKind::from_env(); - assert_eq!(presentation, asked.unwrap_or(DxgiFromVisual), "issue #2738: resizing stretches a window swap chain"); - } else { - assert_eq!(presentation, eframe::wgpu::Dx12SwapchainKind::default().with_env(), "only Windows changes it"); - } - } - - #[test] - fn restored_hidpi_window_fits_the_adapters_surface_limit() { - // The Outlook launch crash: 3440 x 1369 saved points at 250% DPI requested this surface. - let (width, height) = (8600, 3423); - let required = eframe::wgpu::Limits::default(); - assert!(width > required.max_texture_dimension_2d); - let supported = eframe::wgpu::Limits { max_texture_dimension_2d: 16384, ..required.clone() }; - let enabled = super::surface_texture_limits(required, &supported); - assert!(width <= enabled.max_texture_dimension_2d); - assert!(height <= enabled.max_texture_dimension_2d); - } - - #[test] - fn surface_limits_preserve_other_requirements_and_never_overrequest() { - for required in [eframe::wgpu::Limits::default(), eframe::wgpu::Limits::downlevel_webgl2_defaults()] { - for extent in [4096, 8192, 16384] { - let supported = eframe::wgpu::Limits { - max_texture_dimension_2d: extent, - max_bind_groups: 8, - min_uniform_buffer_offset_alignment: 128, - min_storage_buffer_offset_alignment: 128, - ..required.clone() - }; - let enabled = super::surface_texture_limits(required.clone(), &supported); - let mut expected = required.clone(); - expected.max_texture_dimension_2d = extent; - assert_eq!(enabled, expected); - assert_eq!(enabled.max_texture_dimension_2d, supported.max_texture_dimension_2d); - assert!(enabled.check_limits(&supported)); - } - } - } - - #[test] - fn surface_limits_fit_v3d_shader_variable_limit() { - // Issue #2383: V3D supports 15 variables, below the default request of 16. - let required = eframe::wgpu::Limits::default(); - let supported = eframe::wgpu::Limits { max_texture_dimension_2d: 7680, max_inter_stage_shader_variables: 15, ..required.clone() }; - let enabled = super::surface_texture_limits(required.clone(), &supported); - assert!(enabled.check_limits(&supported), "unsupported device limits: {enabled:?}"); - let expected = eframe::wgpu::Limits { max_texture_dimension_2d: 7680, max_inter_stage_shader_variables: 15, ..required }; - assert_eq!(enabled, expected); - } - - #[test] - fn surface_limits_fit_downlevel_maxima_and_minimum_alignments() { - let required = eframe::wgpu::Limits::downlevel_webgl2_defaults(); - let supported = eframe::wgpu::Limits { - max_texture_dimension_2d: 2048, - max_bind_groups: 2, - max_vertex_attributes: 8, - min_uniform_buffer_offset_alignment: 512, - min_storage_buffer_offset_alignment: 512, - ..required.clone() - }; - let enabled = super::surface_texture_limits(required, &supported); - assert!(enabled.check_limits(&supported), "unsupported device limits: {enabled:?}"); - // Larger minimum alignments are weaker capabilities, unlike maximum limits. - assert_eq!(enabled, supported); - } - - #[test] - fn a_window_larger_than_the_gpu_limit_gets_a_surface_within_it() { - use eframe::egui_wgpu::winit::surface_fit; - // Issue #577: 3440 x 1369 points restored at 250% asked an 8192-pixel device for this. - let (width, height, scale) = surface_fit(8600, 3423, 8192); - assert_eq!((width, height), (8192, 3260)); - // Both sides shrink alike and egui draws at that factor: the whole window is drawn. - assert!((8600.0 * scale - 8192.0).abs() < 0.01, "{scale}"); - assert!((3423.0 * scale - height as f32).abs() < 1.0, "{scale}"); - // Within the limit nothing changes, up to and including the limit itself. - assert_eq!(surface_fit(8600, 3423, 16384), (8600, 3423, 1.0)); - assert_eq!(surface_fit(8192, 8192, 8192), (8192, 8192, 1.0)); - assert_eq!(surface_fit(0, 0, 8192), (0, 0, 1.0)); - // One side over the limit: the window as it would be stretched across monitors. - assert_eq!(surface_fit(8193, 600, 8192).0, 8192); - assert_eq!(surface_fit(600, 8193, 8192).1, 8192); - } - - #[test] - fn surface_fit_never_empties_or_overflows_a_surface() { - use eframe::egui_wgpu::winit::surface_fit; - for (w, h, max) in [ - (1, u32::MAX, 8192), - (u32::MAX, 1, 2048), - (u32::MAX, u32::MAX, 16384), - (0, u32::MAX, 8192), - (u32::MAX, 0, 8192), - (5, 7, 0), - (40_000, 3, 1), - ] { - let (fw, fh, scale) = surface_fit(w, h, max); - assert!(fw <= max.max(1) && fh <= max.max(1), "{w} x {h} in {max}: {fw} x {fh}"); - // A side that wasn't zero stays non-zero (`Surface::configure` rejects an empty one), - // and a zero side stays zero (egui-wgpu skips configuring it). - assert_eq!((fw == 0, fh == 0), (w == 0, h == 0), "{w} x {h} in {max}: {fw} x {fh}"); - assert!(scale.is_finite() && scale > 0.0 && scale <= 1.0, "{w} x {h} in {max}: {scale}"); - } - // The longer side lands exactly on the limit (rounding never leaves it a pixel short), - // and the shorter side keeps the window's proportions to within a pixel. - for max in [2048, 8192, 16384] { - for long in (max + 1..=max * 5).step_by(997).chain([max * 2, max * 4, u32::MAX]) { - for short in [1, 3, 600, max / 3, max - 1, max, long - 1, long] { - let (fw, fh, scale) = surface_fit(long, short, max); - assert_eq!(fw, max, "{long} x {short} in {max}"); - let expected = f64::from(short) * f64::from(max) / f64::from(long); - assert!((f64::from(fh) - expected).abs() <= 1.0, "{long} x {short} in {max}: {fh}"); - assert_eq!(surface_fit(short, long, max), (fh, fw, scale), "transposed"); - } - } - } - } - - /// Regression test: the lockfile resolves egui-wgpu 0.37.0 through an allowed source, the root manifest - /// does not path-patch the vendor copy, and that copy stays as provenance for the surface-fit patch. - #[test] - fn vendor_provenance_egui_wgpu_surface_fit_identity() { - // Issue #577: egui-wgpu configured a window's surface at the window's size, and - // `Surface::configure` panics when that's beyond the device's `max_texture_dimension_2d` - // (emilk/egui#8361). The reviewed egui 0.37 worktree carries E2 (`surface_fit`); the - // behavioural test above exercises it. The old 0.36 vendor copy is retained for - // provenance only and is no longer a published path patch, so reading it would not prove - // what the app runs. - let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); - assert_window_stack_graph(&root, "egui-wgpu", "0.37.0"); - assert!(root.join("vendor/egui-wgpu/src/winit.rs").exists(), "the vendor copy and its provenance stay until the fork revision is pinned"); - } - - #[test] - fn a_gpu_error_while_a_window_is_set_up_is_returned_not_a_panic() { - // Issue #519: on a 2015 Intel GPU, wgpu's GL backend couldn't configure the window's - // surface (`GpuWaitTimeout`), and wgpu's default error handler panicked before the app was - // created, so PdfCraft never got to retry with OpenGL. vendor/egui-wgpu configures a new - // window's surface inside `catch_errors`. A real device on PdfCraft's own GPU settings, - // and an error wgpu raises before anything reaches the driver: a texture one pixel wider - // than the device allows. - use eframe::wgpu; - let _gpu = gpu(); - let native = super::native_options(false, false, eframe::Renderer::Wgpu); - let eframe::egui_wgpu::WgpuSetup::CreateNew(setup) = &native.wgpu_options.wgpu_setup else { - panic!("default setup creates its own instance") - }; - let instance = pollster::block_on(native.wgpu_options.wgpu_setup.new_instance()); - let options = wgpu::RequestAdapterOptions { power_preference: setup.power_preference, ..Default::default() }; - // CI runners have a software adapter (WARP, llvmpipe); a machine without any skips. - let Ok(adapter) = pollster::block_on(instance.request_adapter(&options)) else { - eprintln!("skipping: no GPU adapter on this machine"); - return; - }; - let Ok((device, _queue)) = pollster::block_on(adapter.request_device(&(setup.device_descriptor)(&adapter))) else { - eprintln!("skipping: the adapter gives no device"); - return; - }; - let texture = |width: u32| { - device.create_texture(&wgpu::TextureDescriptor { - label: Some("issue-519"), - size: wgpu::Extent3d { width, height: 1, depth_or_array_layers: 1 }, - mip_level_count: 1, - sample_count: 1, - dimension: wgpu::TextureDimension::D2, - format: wgpu::TextureFormat::Rgba8Unorm, - usage: wgpu::TextureUsages::TEXTURE_BINDING, - view_formats: &[], - }) - }; - let too_wide = device.limits().max_texture_dimension_2d.saturating_add(1); - let caught = pollster::block_on(eframe::egui_wgpu::winit::catch_errors(&device, || texture(too_wide))); - assert!(matches!(caught, Err(wgpu::Error::Validation { .. })), "{caught:?}"); - // The device stays usable, and nothing is left behind to catch later errors by mistake. - let fine = pollster::block_on(eframe::egui_wgpu::winit::catch_errors(&device, || texture(16))); - assert!(fine.is_ok(), "{fine:?}"); - assert_eq!(pollster::block_on(eframe::egui_wgpu::winit::catch_errors(&device, || 7)).ok(), Some(7)); - // Nested: the inner call keeps its own error, and the outer one still catches what follows. - let caught = pollster::block_on(eframe::egui_wgpu::winit::catch_errors(&device, || { - let inner = pollster::block_on(eframe::egui_wgpu::winit::catch_errors(&device, || texture(too_wide))); - assert!(inner.is_err(), "inner: {inner:?}"); - texture(too_wide) - })); - assert!(matches!(caught, Err(wgpu::Error::Validation { .. })), "outer: {caught:?}"); - } - - /// Regression test: the lockfile resolves egui-wgpu 0.37.0 through an allowed source, the root manifest - /// does not path-patch the vendor copy, and that copy stays as provenance for the catch-errors patch. - #[test] - fn vendor_provenance_egui_wgpu_catch_errors_identity() { - // Issue #519: E3 (`catch_errors`) is tested above against a real device. The reviewed 0.37 - // worktree carries it around the first configure of every new window's surface, and its - // error reaches eframe as `WgpuError` (eframe then returns `Error::Wgpu`, which `main` - // retries with OpenGL). Without it a failed configure panics before the app is created. - // `catch_errors` is exercised against a real device in the test above and below; the - // graph check makes sure the app runs the reviewed 0.37 worktree (the retained vendor - // copy is not read as proof of behaviour). - let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); - assert_window_stack_graph(&root, "egui-wgpu", "0.37.0"); - assert!(root.join("vendor/egui-wgpu/src/winit.rs").exists(), "the vendor copy and its provenance stay until the fork revision is pinned"); - } - - #[test] - fn pipeline_creation_error_is_returned_and_device_remains_usable() { - use eframe::{egui_wgpu, wgpu}; - let _gpu = gpu(); - let mut native = super::native_options(false, false, eframe::Renderer::Wgpu); - let egui_wgpu::WgpuSetup::CreateNew(setup) = &native.wgpu_options.wgpu_setup else { panic!("default setup creates its own instance") }; - let instance = pollster::block_on(native.wgpu_options.wgpu_setup.new_instance()); - let options = wgpu::RequestAdapterOptions { power_preference: setup.power_preference, ..Default::default() }; - let Ok(adapter) = pollster::block_on(instance.request_adapter(&options)) else { - assert!(std::env::var_os("PDFCRAFT_TEST_REQUIRE_GPU").is_none(), "a GPU adapter is required for this run"); - eprintln!("skipping pipeline regression: no GPU adapter"); - return; - }; - let (device, queue) = pollster::block_on(adapter.request_device(&(setup.device_descriptor)(&adapter))) - .expect("the selected adapter must provide a test device"); - native.wgpu_options.wgpu_setup = - egui_wgpu::WgpuSetup::Existing(egui_wgpu::WgpuSetupExisting { instance: instance.clone(), adapter, device, queue }); - // Three samples is invalid on every backend, so this reproduces a pipeline error - // without depending on a particular driver. The unpatched constructor panics. - for samples in [3, 1, 3, 1] { - let options = egui_wgpu::RendererOptions { msaa_samples: samples, ..Default::default() }; - let result = pollster::block_on(egui_wgpu::RenderState::create(&native.wgpu_options, &instance, None, options)); - if samples == 3 { - let error = result.err().expect("invalid pipeline must return an error"); - assert!(error.to_string().contains("egui_pipeline"), "{error}"); - } else { - assert!(result.is_ok(), "the device must remain usable: {:?}", result.err()); - } - } - } - - /// Regression test: winit resolves to 0.30.13 without a published vendor path patch, the retained copy - /// still carries the W1 Windows 11 monitor-scale fix, and its upstream base is recorded. - #[test] - fn winit_carries_the_windows_11_monitor_scale_fix() { - // Issue #324: winit 0.30.13 as released nudges a window dragged onto a monitor with another - // scale factor back onto the one it is leaving, so on Windows 11 it ends up on the wrong - // monitor, at the wrong size and scale. vendor/winit carries the fix from winit master. A - // dependency bump that resolves winit from crates.io again, or a re-vendored copy without - // the patch, would silently bring the bug back: re-apply the patch, or drop the copy once a - // winit 0.30 release has the fix (vendor/README.md). - // W1 (and W2–W13) were audited against the reviewed winit worktree: the vendor rows in - // `vendor/README.md` match the fork's PATCHES.md entries W1–W13. The vendor source is no - // longer a published path patch, so this checks the actual graph, not the old copy. - let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); - assert_window_stack_graph(&root, "winit", "0.30.13"); - let event_loop = std::fs::read_to_string(root.join("vendor/winit/src/platform_impl/windows/event_loop.rs")).unwrap(); - // The audited W1 hunk itself, not merely the file's presence: Windows 11 applies the - // rect Windows suggests, while Windows 10 keeps the old adjustment. - for marker in [ - "PdfCraft patch: from Windows 11 (build 22000) on, apply the rect Windows suggests", - "if !WIN10_BUILD_VERSION.is_some_and(|build| build < 22000) {", - "new_outer_rect = suggested_rect;", - ] { - assert!(event_loop.contains(marker), "vendor/winit lost the W1 monitor-scale patch: {marker}"); - } - let provenance = std::fs::read_to_string(root.join("vendor/winit/.cargo_vcs_info.json")).unwrap(); - assert!(provenance.contains("e9809ef54b18499bb4f2cac945719ecc2a61061b"), "vendor/winit keeps its upstream base provenance"); - } - - const NVIDIA: (u32, u32) = (0x10de, 0x2684); - const NVIDIA_3090: (u32, u32) = (0x10de, 0x2204); - const AMD_IGPU: (u32, u32) = (0x1002, 0x164e); - - fn adapters() -> Vec<(u32, u32, eframe::wgpu::DeviceType)> { - use eframe::wgpu::DeviceType; - vec![(NVIDIA.0, NVIDIA.1, DeviceType::DiscreteGpu), (AMD_IGPU.0, AMD_IGPU.1, DeviceType::IntegratedGpu), (0, 0, DeviceType::Cpu)] - } - - const INTEL_IGPU: (u32, u32) = (0x8086, 0xa780); - - fn monitor(pci: (u32, u32)) -> super::DisplayGpu { - super::DisplayGpu { pci, primary: false } - } - - fn panel(pci: (u32, u32)) -> super::DisplayGpu { - super::DisplayGpu { pci, primary: true } - } - - #[test] - fn pick_adapter_prefers_the_gpu_driving_the_monitors() { - // A desktop whose monitors are all on the discrete GPU: the integrated one shows black. - assert_eq!(super::pick_adapter(&adapters(), &[monitor(NVIDIA)], None), Some(0)); - // Issue #378: the same on Windows, where the Ryzen integrated GPU without a monitor crashed - // the display driver. Windows also says which display is the primary one. - assert_eq!(super::pick_adapter(&adapters(), &[panel(NVIDIA)], None), Some(0)); - } - - #[test] - fn pick_adapter_prefers_the_gpu_driving_the_primary_display() { - // A Windows desktop with a monitor on each GPU: the window opens on the primary display. - assert_eq!(super::pick_adapter(&adapters(), &[monitor(AMD_IGPU), panel(NVIDIA)], None), Some(0)); - assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU), monitor(NVIDIA)], None), Some(1)); - } - - #[test] - fn pci_ids_come_from_windows_device_ids() { - assert_eq!(super::pci_ids("PCI\\VEN_10DE&DEV_2204&SUBSYS_40421458&REV_A1"), Some(NVIDIA_3090)); - assert_eq!(super::pci_ids("PCI\\VEN_1002&DEV_164E&SUBSYS_88771043&REV_C1"), Some(AMD_IGPU)); - assert_eq!(super::pci_ids("pci\\ven_1002&dev_164e"), Some(AMD_IGPU)); - // Remote Desktop and other non-PCI display devices, and malformed ids. - assert_eq!(super::pci_ids("ROOT\\BasicDisplay\\0000"), None); - assert_eq!(super::pci_ids("PCI\\VEN_10DE&SUBSYS_40421458"), None); - assert_eq!(super::pci_ids("PCI\\VEN_10DE&DEV_ZZZZ"), None); - assert_eq!(super::pci_ids("VEN_&DEV_"), None); - assert_eq!(super::pci_ids(""), None); - } - - #[cfg(target_os = "windows")] - #[test] - fn windows_display_gpus_lists_pci_gpus_with_a_monitor() { - // Whatever this machine has: every entry is a PCI GPU, at most one drives the primary - // display, and no GPU is listed twice. (A headless CI runner may list none.) - let gpus = super::windows_display_gpus(); - assert!(gpus.iter().filter(|g| g.primary).count() <= 1, "{gpus:?}"); - for (i, g) in gpus.iter().enumerate() { - assert!(g.pci.0 != 0, "{gpus:?}"); - assert!(!gpus[..i].iter().any(|h| h.pci == g.pci), "{gpus:?}"); - } - } - - #[test] - fn pick_adapter_keeps_the_integrated_gpu_on_hybrid_laptops() { - // Issue #8: the panel is on the integrated GPU, an external monitor on the discrete one. - assert_eq!(super::pick_adapter(&adapters(), &[monitor(NVIDIA), panel(AMD_IGPU)], None), Some(1)); - assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU), monitor(NVIDIA)], None), Some(1)); - assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU)], None), Some(1)); - assert_eq!(super::pick_adapter(&adapters(), &[monitor(AMD_IGPU)], None), Some(1)); - } - - #[test] - fn pick_adapter_prefers_the_discrete_gpu_on_desktops_with_a_monitor_on_each() { - // Issue #445: one monitor on the Intel iGPU, one on the NVIDIA card, no built-in panel. - use eframe::wgpu::DeviceType; - let desktop = - [(INTEL_IGPU.0, INTEL_IGPU.1, DeviceType::IntegratedGpu), (NVIDIA.0, NVIDIA.1, DeviceType::DiscreteGpu), (0, 0, DeviceType::Cpu)]; - assert_eq!(super::pick_adapter(&desktop, &[monitor(INTEL_IGPU), monitor(NVIDIA)], None), Some(1)); - assert_eq!(super::pick_adapter(&desktop, &[monitor(NVIDIA), monitor(INTEL_IGPU)], None), Some(1)); - // A GPU driving no display still loses to one that does. - assert_eq!(super::pick_adapter(&adapters(), &[monitor(AMD_IGPU), monitor(INTEL_IGPU)], None), Some(1)); - } - - #[test] - fn pick_adapter_prefers_the_gpu_the_compositor_draws_with() { - // A hybrid laptop whose Wayland compositor runs on the discrete GPU (Hyprland with - // `AQ_DRM_DEVICES` naming it first): the integrated GPU drives the built-in panel, yet the - // compositor shows nothing it draws, and reports no error either. - assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU)], Some(NVIDIA)), Some(0)); - assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU), monitor(NVIDIA)], Some(NVIDIA)), Some(0)); - // The usual hybrid laptop, with the compositor on the integrated GPU: as before (#8). - assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU), monitor(NVIDIA)], Some(AMD_IGPU)), Some(1)); - // No GPU drives a display as far as sysfs says (a container), but the compositor knows. - assert_eq!(super::pick_adapter(&adapters(), &[], Some(NVIDIA)), Some(0)); - // A compositor GPU no adapter matches (a virtual one) leaves the displays in charge. - assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU)], Some((0x1af4, 0x1050))), Some(1)); - } - - #[test] - fn pick_adapter_falls_back_to_low_power_without_a_match() { - assert_eq!(super::pick_adapter(&adapters(), &[monitor((0x8086, 0x1234))], None), Some(1)); - assert_eq!(super::pick_adapter(&adapters(), &[monitor((0x8086, 0x1234)), monitor((0x8086, 0x5678))], None), Some(1)); - assert_eq!(super::pick_adapter(&[], &[monitor(NVIDIA)], None), None); - } - - #[test] - fn internal_panels_are_edp_lvds_and_dsi_connectors() { - for name in ["eDP-1", "LVDS-1", "DSI-1"] { - assert!(super::is_internal_panel(name), "{name}"); - } - for name in ["DP-3", "HDMI-A-1", "DVI-D-1", "VGA-1", "Writeback-1", ""] { - assert!(!super::is_internal_panel(name), "{name}"); - } - } - - #[cfg(target_os = "linux")] - #[test] - fn linux_display_gpus_reads_connected_connectors() -> std::io::Result<()> { - let dir = std::env::temp_dir().join(format!("pdfcraft-drm-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - let card = |name: &str, (vendor, device): (u32, u32)| -> std::io::Result<()> { - std::fs::create_dir_all(dir.join(name).join("device"))?; - std::fs::write(dir.join(name).join("device/vendor"), format!("{vendor:#06x}\n"))?; - std::fs::write(dir.join(name).join("device/device"), format!("{device:#06x}\n")) - }; - let connector = |name: &str, status: &str| -> std::io::Result<()> { - std::fs::create_dir_all(dir.join(name))?; - std::fs::write(dir.join(name).join("status"), format!("{status}\n")) - }; - card("card1", NVIDIA)?; - card("card2", AMD_IGPU)?; - connector("card1-DP-3", "connected")?; - connector("card1-DP-4", "connected")?; - connector("card2-HDMI-A-1", "disconnected")?; - connector("card2-Writeback-1", "unknown")?; - let desktop = super::linux_display_gpus(&dir); - // Issue #445: a monitor on the integrated GPU too, still no built-in panel. - connector("card2-HDMI-A-1", "connected")?; - let mut both = super::linux_display_gpus(&dir); - // A hybrid laptop: the integrated GPU drives the built-in panel as well. - connector("card2-eDP-1", "connected")?; - let mut laptop = super::linux_display_gpus(&dir); - std::fs::remove_dir_all(&dir)?; - assert_eq!(desktop, vec![monitor(NVIDIA)]); - both.sort_by_key(|g| g.pci); - assert_eq!(both, vec![monitor(AMD_IGPU), monitor(NVIDIA)]); - laptop.sort_by_key(|g| g.pci); - assert_eq!(laptop, vec![panel(AMD_IGPU), monitor(NVIDIA)]); - assert!(super::linux_display_gpus(std::path::Path::new("/nonexistent/drm")).is_empty()); - Ok(()) - } - - /// A fresh folder per call: tests run in parallel. - fn scratch() -> std::path::PathBuf { - static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); - let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - let d = std::env::temp_dir().join(format!("pdfcraft-control-file-{}-{n}", std::process::id())); - let _ = std::fs::remove_dir_all(&d); - std::fs::create_dir_all(&d).unwrap(); - d - } - - /// The control file's port and token, and every name in its folder. - fn written(dir: &std::path::Path) -> (serde_json::Value, Vec) { - let json = serde_json::from_str(&std::fs::read_to_string(dir.join("ctl.json")).unwrap()).unwrap(); - let mut names: Vec = std::fs::read_dir(dir).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect(); - names.sort(); - (json, names) - } - - #[test] - fn the_control_file_replaces_a_hard_link_instead_of_writing_through_it() { - // The file used to be opened with create + truncate, so a link planted at the path had its - // target truncated and overwritten with the token. - let dir = scratch(); - std::fs::write(dir.join("victim.txt"), "keep me").unwrap(); - std::fs::hard_link(dir.join("victim.txt"), dir.join("ctl.json")).unwrap(); - super::write_control_file(&dir.join("ctl.json").to_string_lossy(), 4242, "t0ken").unwrap(); - assert_eq!(std::fs::read_to_string(dir.join("victim.txt")).unwrap(), "keep me"); - let (json, names) = written(&dir); - assert_eq!((json["port"].as_u64(), json["token"].as_str()), (Some(4242), Some("t0ken"))); - assert_eq!(names, ["ctl.json", "victim.txt"], "no temporary file left behind"); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn the_control_file_replaces_a_symlink_instead_of_writing_through_it() { - let dir = scratch(); - let (link, target) = (dir.join("ctl.json"), dir.join("victim.txt")); - std::fs::write(&target, "keep me").unwrap(); - #[cfg(unix)] - let made = std::os::unix::fs::symlink(&target, &link); - #[cfg(windows)] - let made = std::os::windows::fs::symlink_file(&target, &link); - if let Err(e) = made { - // Windows needs Developer Mode (or admin) for symlinks. - eprintln!("skipped: can't create a symlink here: {e}"); - let _ = std::fs::remove_dir_all(&dir); - return; - } - super::write_control_file(&link.to_string_lossy(), 4242, "t0ken").unwrap(); - assert_eq!(std::fs::read_to_string(&target).unwrap(), "keep me"); - assert!(!std::fs::symlink_metadata(&link).unwrap().file_type().is_symlink()); - assert_eq!(written(&dir).0["token"].as_str(), Some("t0ken")); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn the_control_file_replaces_the_one_a_previous_run_left() { - let dir = scratch(); - std::fs::write(dir.join("ctl.json"), r#"{"port":1,"token":"old"}"#).unwrap(); - super::write_control_file(&dir.join("ctl.json").to_string_lossy(), 4242, "t0ken").unwrap(); - let (json, names) = written(&dir); - assert_eq!(json["token"].as_str(), Some("t0ken")); - assert_eq!(names, ["ctl.json"]); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let mode = std::fs::metadata(dir.join("ctl.json")).unwrap().permissions().mode(); - assert_eq!(mode & 0o077, 0, "only the owner can read the token: {mode:o}"); - } - let _ = std::fs::remove_dir_all(&dir); - } - - /// X11 startup notification and docks match a window to its launcher by `StartupWMClass`, - /// so it has to be the class the window carries: the app id, on Wayland and X11 alike. - #[test] - fn the_desktop_entry_names_the_window_class() { - let entry = include_str!("../../../packaging/linux/ai.storyteller.pdfcraft.desktop"); - let class = entry.lines().find_map(|line| line.strip_prefix("StartupWMClass=")); - assert_eq!(class, Some(super::APP_ID)); - } - - // Regression test for #246: `eprintln!` panics when standard error is a broken pipe (a GUI - // launch on Windows), and a panic inside the panic hook aborts the process. The start-up - // path and the hook must write to standard error without it. - #[test] - fn start_up_and_the_panic_hook_never_use_eprintln() { - let source = include_str!("main.rs"); - // Everything before the top-level test module is product code. - let product = source.split("\n#[cfg(test)]\nmod tests {").next().unwrap_or(source); - assert!(product.len() < source.len(), "found the end of the product code"); - let offenders: Vec<_> = product - .lines() - .enumerate() - .filter(|(_, line)| !line.trim_start().starts_with("//") && line.contains("eprintln!")) - .map(|(n, line)| format!("main.rs:{}: {}", n + 1, line.trim())) - .collect(); - assert!( - offenders.is_empty(), - "`eprintln!` panics on a broken standard error pipe; use `log::warn!` or `write_fmt` on `stderr()` and ignore the error:\n{}", - offenders.join("\n") - ); - } -} +mod tests; diff --git a/apps/pdfcraft/src/tests.rs b/apps/pdfcraft/src/tests.rs new file mode 100644 index 0000000000..c6e6f36518 --- /dev/null +++ b/apps/pdfcraft/src/tests.rs @@ -0,0 +1,686 @@ +/// The lockfile package block for `name`, or a panic naming the missing package. +fn locked_package(root: &std::path::Path, name: &str) -> String { + let lock = std::fs::read_to_string(root.join("Cargo.lock")).expect("Cargo.lock"); + lock.split("[[package]]") + .find(|p| p.contains(&format!("\nname = \"{name}\"\n"))) + .map(str::to_owned) + .unwrap_or_else(|| panic!("{name} is not in Cargo.lock")) +} + +/// Interim graph check for the window stack while the reviewed fork revision is not yet +/// published. The old `vendor/winit` / `vendor/egui-wgpu` path patches were removed from the +/// published manifest; local integration supplies the reviewed worktrees through an external +/// Cargo config patch. A git source must name the public storytold fork; a path source is the +/// local integration graph. Behaviour (surface fitting, error scopes, scroll phases) is tested +/// directly elsewhere; the retained vendor copies keep provenance until the fork revision is +/// pinned. No public revision is invented here. +fn assert_window_stack_graph(root: &std::path::Path, name: &str, version: &str) -> String { + let package = locked_package(root, name); + assert!(package.contains(&format!("\nversion = \"{version}\"\n")), "{name} must resolve to {version}: {package}"); + if let Some(source) = package.lines().find_map(|line| line.strip_prefix("source = ")) { + assert!(source.contains("github.com/storytold/"), "{name} must come from the reviewed storytold fork once published, not {source}"); + assert!( + source.split(|c: char| !c.is_ascii_hexdigit()).any(|part| part.len() == 40), + "{name} must pin the fork's 40-character revision, not {source}" + ); + } + // A path patch has no `source =` in Cargo.lock. The local integration config is the only + // allowed path source; a machine-local path must never be in the published manifest, + // which the root-manifest assertion below checks. A path entry carries no remote identity + // to verify, so this test does not claim the resolved code is the published fork. + let manifest = std::fs::read_to_string(root.join("Cargo.toml")).expect("root Cargo.toml"); + assert!(!manifest.contains(&format!("{name} = {{ path = \"vendor/")), "vendor/{name} must not be a published path patch"); + package +} + +use pdfcraft_ui_egui::control::Endpoint; + +fn endpoint() -> std::io::Result { + Ok(Endpoint { port: 4321, token: "0123456789abcdef".into() }) +} + +/// Tests that open a real GPU device take this first, so only one of them is inside the +/// graphics libraries at a time. The test threads otherwise run them together, and on +/// FreeBSD's CI machine the test process then died with SIGSEGV, in about half the runs: +/// the first device was still being set up when the second test began its own. The UI +/// tests take turns the same way (`crates/ui-egui/tests/it/main.rs`). Hold it for the whole +/// test (declared before the instance, so it's released after the device is dropped). +static GPU: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +fn gpu() -> std::sync::MutexGuard<'static, ()> { + // A test that panicked while holding it leaves nothing to clean up. + GPU.lock().unwrap_or_else(std::sync::PoisonError::into_inner) +} + +#[test] +fn a_control_channel_that_cant_be_published_stops_the_app() { + let dir = std::env::temp_dir().join(format!("pdfcraft-app-control-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = |name: &str| dir.join(name).to_str().unwrap().to_owned(); + + // A folder in the way stands in for a file another user created first: the write fails. + std::fs::create_dir(dir.join("taken.json")).unwrap(); + let err = super::open_control_channel(&path("taken.json"), endpoint()).unwrap_err(); + assert!(err.contains("--control") && err.contains("taken.json") && err.contains("only you can write"), "{err}"); + // No listener: nothing to publish. + let err = super::open_control_channel(&path("pc.json"), Err(std::io::Error::other("no loopback"))).unwrap_err(); + assert!(err.contains("no loopback"), "{err}"); + assert!(!dir.join("pc.json").exists()); + + super::open_control_channel(&path("pc.json"), endpoint()).unwrap(); + let written: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(dir.join("pc.json")).unwrap()).unwrap(); + assert_eq!((written["port"].as_u64(), written["token"].as_str()), (Some(4321), Some("0123456789abcdef"))); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn path_from_arg_decodes_file_uris() { + assert_eq!(super::path_from_arg("file:///home/alice/report.pdf".to_string()), "/home/alice/report.pdf"); + assert_eq!(super::path_from_arg("file:///home/alice/my%20report.pdf".to_string()), "/home/alice/my report.pdf"); + assert_eq!(super::path_from_arg("file://localhost/tmp/a%C3%A9.pdf".to_string()), "/tmp/aé.pdf"); + // Malformed or truncated escapes are kept as written; bytes that aren't UTF-8 keep the URI. + assert_eq!(super::path_from_arg("file:///tmp/100%.pdf".to_string()), "/tmp/100%.pdf"); + assert_eq!(super::path_from_arg("file:///tmp/a%2".to_string()), "/tmp/a%2"); + assert_eq!(super::path_from_arg("file:///tmp/%zz%".to_string()), "/tmp/%zz%"); + assert_eq!(super::path_from_arg("file:///tmp/%FF.pdf".to_string()), "file:///tmp/%FF.pdf"); +} + +#[test] +fn unquoted_paths_with_spaces_are_rejoined() { + let have = ["P:\\Archive\\00042A26 Contract.PDF", "C:\\a b c.pdf", "C:\\a.pdf", "C:\\x y.pdf"]; + let exists = |p: &str| have.contains(&p); + let args = |a: &[&str]| a.iter().map(|s| s.to_string()).collect::>(); + // The case an office program produced: one path, split at its space. + assert_eq!(super::rejoin_split_paths(args(&["P:\\Archive\\00042A26", "Contract.PDF"]), exists), args(&["P:\\Archive\\00042A26 Contract.PDF"])); + // The longest run wins; files that exist on their own are left alone. + assert_eq!(super::rejoin_split_paths(args(&["C:\\a", "b", "c.pdf", "C:\\a.pdf"]), exists), args(&["C:\\a b c.pdf", "C:\\a.pdf"])); + assert_eq!(super::rejoin_split_paths(args(&["C:\\a.pdf", "C:\\x", "y.pdf"]), exists), args(&["C:\\a.pdf", "C:\\x y.pdf"])); + // Nothing to join: kept as given, so the missing file is still reported by name. + assert_eq!(super::rejoin_split_paths(args(&["missing.pdf", "other"]), exists), args(&["missing.pdf", "other"])); + assert!(super::rejoin_split_paths(Vec::new(), exists).is_empty()); +} + +#[test] +fn path_from_arg_leaves_plain_paths_and_other_schemes_alone() { + assert_eq!(super::path_from_arg("report.pdf".to_string()), "report.pdf"); + assert_eq!(super::path_from_arg("/home/alice/report.pdf".to_string()), "/home/alice/report.pdf"); + assert_eq!(super::path_from_arg("https://example.com/report.pdf".to_string()), "https://example.com/report.pdf"); + assert_eq!(super::path_from_arg("file://server/share/a.pdf".to_string()), "file://server/share/a.pdf"); + assert_eq!(super::path_from_arg("file://".to_string()), "file://"); +} + +#[test] +fn opengl_is_the_fallback_unless_a_renderer_was_chosen() { + use super::{RendererChoice, native_options, renderer_choice, retry_with_gl}; + assert_eq!(renderer_choice(None), RendererChoice::Auto); + assert_eq!(renderer_choice(Some("")), RendererChoice::Auto); + assert_eq!(renderer_choice(Some("vulkan")), RendererChoice::Auto); + assert_eq!(renderer_choice(Some(" WGPU ")), RendererChoice::Wgpu); + for gl in ["gl", "OpenGL", "glow"] { + assert_eq!(renderer_choice(Some(gl)), RendererChoice::Gl); + } + // Retried with OpenGL only when wgpu failed to start by default, never after the app ran. + assert!(retry_with_gl(RendererChoice::Auto, false)); + assert!(!retry_with_gl(RendererChoice::Auto, true)); + assert!(!retry_with_gl(RendererChoice::Wgpu, false)); + assert!(!retry_with_gl(RendererChoice::Gl, false)); + // Each run states its renderer rather than relying on eframe's default. + assert_eq!(native_options(false, false, eframe::Renderer::Wgpu).renderer, eframe::Renderer::Wgpu); + assert_eq!(native_options(false, false, eframe::Renderer::Glow).renderer, eframe::Renderer::Glow); +} + +#[test] +fn the_desktop_frame_is_turned_off_only_when_the_app_draws_one() { + use super::native_options; + assert_eq!(native_options(false, false, eframe::Renderer::Glow).viewport.decorations, None); + assert_eq!(native_options(false, true, eframe::Renderer::Glow).viewport.decorations, Some(false)); +} + +#[test] +fn a_software_renderer_is_shown_to_the_user() { + use eframe::wgpu::DeviceType; + // Issue #519: WARP or llvmpipe is announced in the app, not just in pdfcraft.log. + let mut app = super::PdfCraftApp::new(); + super::notify_software_renderer(&mut app, DeviceType::Cpu); + let notice = app.toast.as_ref().map(|(m, _)| m.as_str()).unwrap_or_default(); + assert!(notice.contains("without a graphics processor"), "{notice:?}"); + for gpu in [DeviceType::IntegratedGpu, DeviceType::DiscreteGpu, DeviceType::VirtualGpu, DeviceType::Other] { + let mut app = super::PdfCraftApp::new(); + super::notify_software_renderer(&mut app, gpu); + assert!(app.toast.is_none(), "{gpu:?}: {:?}", app.toast); + } +} + +#[test] +fn gpu_setup_avoids_vulkan_and_stretched_resizes_on_windows_and_prefers_low_power() { + let mut native = eframe::NativeOptions::default(); + super::configure_gpu(&mut native); + let eframe::egui_wgpu::WgpuSetup::CreateNew(setup) = &native.wgpu_options.wgpu_setup else { panic!("default setup creates its own instance") }; + if std::env::var_os("WGPU_POWER_PREF").is_none() { + assert_eq!(setup.power_preference, eframe::wgpu::PowerPreference::LowPower); + } + if std::env::var_os("WGPU_VALIDATION_INDIRECT_CALL").is_none() { + let flags = setup.instance_descriptor.flags; + assert!(!flags.contains(eframe::wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL), "Intel Metal can't compile its shader: {flags:?}"); + } + if cfg!(target_os = "windows") && std::env::var_os("WGPU_BACKEND").is_none() { + let backends = setup.instance_descriptor.backends; + assert!(backends.contains(eframe::wgpu::Backends::DX12), "{backends:?}"); + assert!(!backends.contains(eframe::wgpu::Backends::VULKAN), "issue #37: {backends:?}"); + } + use eframe::wgpu::Dx12SwapchainKind::{DxgiFromHwnd, DxgiFromVisual}; + assert_eq!(super::dx12_presentation(None), DxgiFromVisual); + assert_eq!(super::dx12_presentation(Some(DxgiFromHwnd)), DxgiFromHwnd, "WGPU_DX12_PRESENTATION_SYSTEM still decides"); + assert_eq!(super::dx12_presentation(Some(DxgiFromVisual)), DxgiFromVisual); + let presentation = setup.instance_descriptor.backend_options.dx12.presentation_system; + if cfg!(target_os = "windows") { + // A composition visual unless WGPU_DX12_PRESENTATION_SYSTEM asks otherwise. + let asked = eframe::wgpu::Dx12SwapchainKind::from_env(); + assert_eq!(presentation, asked.unwrap_or(DxgiFromVisual), "issue #2738: resizing stretches a window swap chain"); + } else { + assert_eq!(presentation, eframe::wgpu::Dx12SwapchainKind::default().with_env(), "only Windows changes it"); + } +} + +#[test] +fn restored_hidpi_window_fits_the_adapters_surface_limit() { + // The Outlook launch crash: 3440 x 1369 saved points at 250% DPI requested this surface. + let (width, height) = (8600, 3423); + let required = eframe::wgpu::Limits::default(); + assert!(width > required.max_texture_dimension_2d); + let supported = eframe::wgpu::Limits { max_texture_dimension_2d: 16384, ..required.clone() }; + let enabled = super::surface_texture_limits(required, &supported); + assert!(width <= enabled.max_texture_dimension_2d); + assert!(height <= enabled.max_texture_dimension_2d); +} + +#[test] +fn surface_limits_preserve_other_requirements_and_never_overrequest() { + for required in [eframe::wgpu::Limits::default(), eframe::wgpu::Limits::downlevel_webgl2_defaults()] { + for extent in [4096, 8192, 16384] { + let supported = eframe::wgpu::Limits { + max_texture_dimension_2d: extent, + max_bind_groups: 8, + min_uniform_buffer_offset_alignment: 128, + min_storage_buffer_offset_alignment: 128, + ..required.clone() + }; + let enabled = super::surface_texture_limits(required.clone(), &supported); + let mut expected = required.clone(); + expected.max_texture_dimension_2d = extent; + assert_eq!(enabled, expected); + assert_eq!(enabled.max_texture_dimension_2d, supported.max_texture_dimension_2d); + assert!(enabled.check_limits(&supported)); + } + } +} + +#[test] +fn surface_limits_fit_v3d_shader_variable_limit() { + // Issue #2383: V3D supports 15 variables, below the default request of 16. + let required = eframe::wgpu::Limits::default(); + let supported = eframe::wgpu::Limits { max_texture_dimension_2d: 7680, max_inter_stage_shader_variables: 15, ..required.clone() }; + let enabled = super::surface_texture_limits(required.clone(), &supported); + assert!(enabled.check_limits(&supported), "unsupported device limits: {enabled:?}"); + let expected = eframe::wgpu::Limits { max_texture_dimension_2d: 7680, max_inter_stage_shader_variables: 15, ..required }; + assert_eq!(enabled, expected); +} + +#[test] +fn surface_limits_fit_downlevel_maxima_and_minimum_alignments() { + let required = eframe::wgpu::Limits::downlevel_webgl2_defaults(); + let supported = eframe::wgpu::Limits { + max_texture_dimension_2d: 2048, + max_bind_groups: 2, + max_vertex_attributes: 8, + min_uniform_buffer_offset_alignment: 512, + min_storage_buffer_offset_alignment: 512, + ..required.clone() + }; + let enabled = super::surface_texture_limits(required, &supported); + assert!(enabled.check_limits(&supported), "unsupported device limits: {enabled:?}"); + // Larger minimum alignments are weaker capabilities, unlike maximum limits. + assert_eq!(enabled, supported); +} + +#[test] +fn a_window_larger_than_the_gpu_limit_gets_a_surface_within_it() { + use eframe::egui_wgpu::winit::surface_fit; + // Issue #577: 3440 x 1369 points restored at 250% asked an 8192-pixel device for this. + let (width, height, scale) = surface_fit(8600, 3423, 8192); + assert_eq!((width, height), (8192, 3260)); + // Both sides shrink alike and egui draws at that factor: the whole window is drawn. + assert!((8600.0 * scale - 8192.0).abs() < 0.01, "{scale}"); + assert!((3423.0 * scale - height as f32).abs() < 1.0, "{scale}"); + // Within the limit nothing changes, up to and including the limit itself. + assert_eq!(surface_fit(8600, 3423, 16384), (8600, 3423, 1.0)); + assert_eq!(surface_fit(8192, 8192, 8192), (8192, 8192, 1.0)); + assert_eq!(surface_fit(0, 0, 8192), (0, 0, 1.0)); + // One side over the limit: the window as it would be stretched across monitors. + assert_eq!(surface_fit(8193, 600, 8192).0, 8192); + assert_eq!(surface_fit(600, 8193, 8192).1, 8192); +} + +#[test] +fn surface_fit_never_empties_or_overflows_a_surface() { + use eframe::egui_wgpu::winit::surface_fit; + for (w, h, max) in + [(1, u32::MAX, 8192), (u32::MAX, 1, 2048), (u32::MAX, u32::MAX, 16384), (0, u32::MAX, 8192), (u32::MAX, 0, 8192), (5, 7, 0), (40_000, 3, 1)] + { + let (fw, fh, scale) = surface_fit(w, h, max); + assert!(fw <= max.max(1) && fh <= max.max(1), "{w} x {h} in {max}: {fw} x {fh}"); + // A side that wasn't zero stays non-zero (`Surface::configure` rejects an empty one), + // and a zero side stays zero (egui-wgpu skips configuring it). + assert_eq!((fw == 0, fh == 0), (w == 0, h == 0), "{w} x {h} in {max}: {fw} x {fh}"); + assert!(scale.is_finite() && scale > 0.0 && scale <= 1.0, "{w} x {h} in {max}: {scale}"); + } + // The longer side lands exactly on the limit (rounding never leaves it a pixel short), + // and the shorter side keeps the window's proportions to within a pixel. + for max in [2048, 8192, 16384] { + for long in (max + 1..=max * 5).step_by(997).chain([max * 2, max * 4, u32::MAX]) { + for short in [1, 3, 600, max / 3, max - 1, max, long - 1, long] { + let (fw, fh, scale) = surface_fit(long, short, max); + assert_eq!(fw, max, "{long} x {short} in {max}"); + let expected = f64::from(short) * f64::from(max) / f64::from(long); + assert!((f64::from(fh) - expected).abs() <= 1.0, "{long} x {short} in {max}: {fh}"); + assert_eq!(surface_fit(short, long, max), (fh, fw, scale), "transposed"); + } + } + } +} + +/// Regression test: the lockfile resolves egui-wgpu 0.37.0 through an allowed source, the root manifest +/// does not path-patch the vendor copy, and that copy stays as provenance for the surface-fit patch. +#[test] +fn vendor_provenance_egui_wgpu_surface_fit_identity() { + // Issue #577: egui-wgpu configured a window's surface at the window's size, and + // `Surface::configure` panics when that's beyond the device's `max_texture_dimension_2d` + // (emilk/egui#8361). The reviewed egui 0.37 worktree carries E2 (`surface_fit`); the + // behavioural test above exercises it. The old 0.36 vendor copy is retained for + // provenance only and is no longer a published path patch, so reading it would not prove + // what the app runs. + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + assert_window_stack_graph(&root, "egui-wgpu", "0.37.0"); + assert!(root.join("vendor/egui-wgpu/src/winit.rs").exists(), "the vendor copy and its provenance stay until the fork revision is pinned"); +} + +#[test] +fn a_gpu_error_while_a_window_is_set_up_is_returned_not_a_panic() { + // Issue #519: on a 2015 Intel GPU, wgpu's GL backend couldn't configure the window's + // surface (`GpuWaitTimeout`), and wgpu's default error handler panicked before the app was + // created, so PdfCraft never got to retry with OpenGL. vendor/egui-wgpu configures a new + // window's surface inside `catch_errors`. A real device on PdfCraft's own GPU settings, + // and an error wgpu raises before anything reaches the driver: a texture one pixel wider + // than the device allows. + use eframe::wgpu; + let _gpu = gpu(); + let native = super::native_options(false, false, eframe::Renderer::Wgpu); + let eframe::egui_wgpu::WgpuSetup::CreateNew(setup) = &native.wgpu_options.wgpu_setup else { panic!("default setup creates its own instance") }; + let instance = pollster::block_on(native.wgpu_options.wgpu_setup.new_instance()); + let options = wgpu::RequestAdapterOptions { power_preference: setup.power_preference, ..Default::default() }; + // CI runners have a software adapter (WARP, llvmpipe); a machine without any skips. + let Ok(adapter) = pollster::block_on(instance.request_adapter(&options)) else { + eprintln!("skipping: no GPU adapter on this machine"); + return; + }; + let Ok((device, _queue)) = pollster::block_on(adapter.request_device(&(setup.device_descriptor)(&adapter))) else { + eprintln!("skipping: the adapter gives no device"); + return; + }; + let texture = |width: u32| { + device.create_texture(&wgpu::TextureDescriptor { + label: Some("issue-519"), + size: wgpu::Extent3d { width, height: 1, depth_or_array_layers: 1 }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8Unorm, + usage: wgpu::TextureUsages::TEXTURE_BINDING, + view_formats: &[], + }) + }; + let too_wide = device.limits().max_texture_dimension_2d.saturating_add(1); + let caught = pollster::block_on(eframe::egui_wgpu::winit::catch_errors(&device, || texture(too_wide))); + assert!(matches!(caught, Err(wgpu::Error::Validation { .. })), "{caught:?}"); + // The device stays usable, and nothing is left behind to catch later errors by mistake. + let fine = pollster::block_on(eframe::egui_wgpu::winit::catch_errors(&device, || texture(16))); + assert!(fine.is_ok(), "{fine:?}"); + assert_eq!(pollster::block_on(eframe::egui_wgpu::winit::catch_errors(&device, || 7)).ok(), Some(7)); + // Nested: the inner call keeps its own error, and the outer one still catches what follows. + let caught = pollster::block_on(eframe::egui_wgpu::winit::catch_errors(&device, || { + let inner = pollster::block_on(eframe::egui_wgpu::winit::catch_errors(&device, || texture(too_wide))); + assert!(inner.is_err(), "inner: {inner:?}"); + texture(too_wide) + })); + assert!(matches!(caught, Err(wgpu::Error::Validation { .. })), "outer: {caught:?}"); +} + +/// Regression test: the lockfile resolves egui-wgpu 0.37.0 through an allowed source, the root manifest +/// does not path-patch the vendor copy, and that copy stays as provenance for the catch-errors patch. +#[test] +fn vendor_provenance_egui_wgpu_catch_errors_identity() { + // Issue #519: E3 (`catch_errors`) is tested above against a real device. The reviewed 0.37 + // worktree carries it around the first configure of every new window's surface, and its + // error reaches eframe as `WgpuError` (eframe then returns `Error::Wgpu`, which `main` + // retries with OpenGL). Without it a failed configure panics before the app is created. + // `catch_errors` is exercised against a real device in the test above and below; the + // graph check makes sure the app runs the reviewed 0.37 worktree (the retained vendor + // copy is not read as proof of behaviour). + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + assert_window_stack_graph(&root, "egui-wgpu", "0.37.0"); + assert!(root.join("vendor/egui-wgpu/src/winit.rs").exists(), "the vendor copy and its provenance stay until the fork revision is pinned"); +} + +#[test] +fn pipeline_creation_error_is_returned_and_device_remains_usable() { + use eframe::{egui_wgpu, wgpu}; + let _gpu = gpu(); + let mut native = super::native_options(false, false, eframe::Renderer::Wgpu); + let egui_wgpu::WgpuSetup::CreateNew(setup) = &native.wgpu_options.wgpu_setup else { panic!("default setup creates its own instance") }; + let instance = pollster::block_on(native.wgpu_options.wgpu_setup.new_instance()); + let options = wgpu::RequestAdapterOptions { power_preference: setup.power_preference, ..Default::default() }; + let Ok(adapter) = pollster::block_on(instance.request_adapter(&options)) else { + assert!(std::env::var_os("PDFCRAFT_TEST_REQUIRE_GPU").is_none(), "a GPU adapter is required for this run"); + eprintln!("skipping pipeline regression: no GPU adapter"); + return; + }; + let (device, queue) = + pollster::block_on(adapter.request_device(&(setup.device_descriptor)(&adapter))).expect("the selected adapter must provide a test device"); + native.wgpu_options.wgpu_setup = + egui_wgpu::WgpuSetup::Existing(egui_wgpu::WgpuSetupExisting { instance: instance.clone(), adapter, device, queue }); + // Three samples is invalid on every backend, so this reproduces a pipeline error + // without depending on a particular driver. The unpatched constructor panics. + for samples in [3, 1, 3, 1] { + let options = egui_wgpu::RendererOptions { msaa_samples: samples, ..Default::default() }; + let result = pollster::block_on(egui_wgpu::RenderState::create(&native.wgpu_options, &instance, None, options)); + if samples == 3 { + let error = result.err().expect("invalid pipeline must return an error"); + assert!(error.to_string().contains("egui_pipeline"), "{error}"); + } else { + assert!(result.is_ok(), "the device must remain usable: {:?}", result.err()); + } + } +} + +/// Regression test: winit resolves to 0.30.13 without a published vendor path patch, the retained copy +/// still carries the W1 Windows 11 monitor-scale fix, and its upstream base is recorded. +#[test] +fn winit_carries_the_windows_11_monitor_scale_fix() { + // Issue #324: winit 0.30.13 as released nudges a window dragged onto a monitor with another + // scale factor back onto the one it is leaving, so on Windows 11 it ends up on the wrong + // monitor, at the wrong size and scale. vendor/winit carries the fix from winit master. A + // dependency bump that resolves winit from crates.io again, or a re-vendored copy without + // the patch, would silently bring the bug back: re-apply the patch, or drop the copy once a + // winit 0.30 release has the fix (vendor/README.md). + // W1 (and W2–W13) were audited against the reviewed winit worktree: the vendor rows in + // `vendor/README.md` match the fork's PATCHES.md entries W1–W13. The vendor source is no + // longer a published path patch, so this checks the actual graph, not the old copy. + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + assert_window_stack_graph(&root, "winit", "0.30.13"); + let event_loop = std::fs::read_to_string(root.join("vendor/winit/src/platform_impl/windows/event_loop.rs")).unwrap(); + // The audited W1 hunk itself, not merely the file's presence: Windows 11 applies the + // rect Windows suggests, while Windows 10 keeps the old adjustment. + for marker in [ + "PdfCraft patch: from Windows 11 (build 22000) on, apply the rect Windows suggests", + "if !WIN10_BUILD_VERSION.is_some_and(|build| build < 22000) {", + "new_outer_rect = suggested_rect;", + ] { + assert!(event_loop.contains(marker), "vendor/winit lost the W1 monitor-scale patch: {marker}"); + } + let provenance = std::fs::read_to_string(root.join("vendor/winit/.cargo_vcs_info.json")).unwrap(); + assert!(provenance.contains("e9809ef54b18499bb4f2cac945719ecc2a61061b"), "vendor/winit keeps its upstream base provenance"); +} + +const NVIDIA: (u32, u32) = (0x10de, 0x2684); +const NVIDIA_3090: (u32, u32) = (0x10de, 0x2204); +const AMD_IGPU: (u32, u32) = (0x1002, 0x164e); + +fn adapters() -> Vec<(u32, u32, eframe::wgpu::DeviceType)> { + use eframe::wgpu::DeviceType; + vec![(NVIDIA.0, NVIDIA.1, DeviceType::DiscreteGpu), (AMD_IGPU.0, AMD_IGPU.1, DeviceType::IntegratedGpu), (0, 0, DeviceType::Cpu)] +} + +const INTEL_IGPU: (u32, u32) = (0x8086, 0xa780); + +fn monitor(pci: (u32, u32)) -> super::DisplayGpu { + super::DisplayGpu { pci, primary: false } +} + +fn panel(pci: (u32, u32)) -> super::DisplayGpu { + super::DisplayGpu { pci, primary: true } +} + +#[test] +fn pick_adapter_prefers_the_gpu_driving_the_monitors() { + // A desktop whose monitors are all on the discrete GPU: the integrated one shows black. + assert_eq!(super::pick_adapter(&adapters(), &[monitor(NVIDIA)], None), Some(0)); + // Issue #378: the same on Windows, where the Ryzen integrated GPU without a monitor crashed + // the display driver. Windows also says which display is the primary one. + assert_eq!(super::pick_adapter(&adapters(), &[panel(NVIDIA)], None), Some(0)); +} + +#[test] +fn pick_adapter_prefers_the_gpu_driving_the_primary_display() { + // A Windows desktop with a monitor on each GPU: the window opens on the primary display. + assert_eq!(super::pick_adapter(&adapters(), &[monitor(AMD_IGPU), panel(NVIDIA)], None), Some(0)); + assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU), monitor(NVIDIA)], None), Some(1)); +} + +#[test] +fn pci_ids_come_from_windows_device_ids() { + assert_eq!(super::pci_ids("PCI\\VEN_10DE&DEV_2204&SUBSYS_40421458&REV_A1"), Some(NVIDIA_3090)); + assert_eq!(super::pci_ids("PCI\\VEN_1002&DEV_164E&SUBSYS_88771043&REV_C1"), Some(AMD_IGPU)); + assert_eq!(super::pci_ids("pci\\ven_1002&dev_164e"), Some(AMD_IGPU)); + // Remote Desktop and other non-PCI display devices, and malformed ids. + assert_eq!(super::pci_ids("ROOT\\BasicDisplay\\0000"), None); + assert_eq!(super::pci_ids("PCI\\VEN_10DE&SUBSYS_40421458"), None); + assert_eq!(super::pci_ids("PCI\\VEN_10DE&DEV_ZZZZ"), None); + assert_eq!(super::pci_ids("VEN_&DEV_"), None); + assert_eq!(super::pci_ids(""), None); +} + +#[cfg(target_os = "windows")] +#[test] +fn windows_display_gpus_lists_pci_gpus_with_a_monitor() { + // Whatever this machine has: every entry is a PCI GPU, at most one drives the primary + // display, and no GPU is listed twice. (A headless CI runner may list none.) + let gpus = super::windows_display_gpus(); + assert!(gpus.iter().filter(|g| g.primary).count() <= 1, "{gpus:?}"); + for (i, g) in gpus.iter().enumerate() { + assert!(g.pci.0 != 0, "{gpus:?}"); + assert!(!gpus[..i].iter().any(|h| h.pci == g.pci), "{gpus:?}"); + } +} + +#[test] +fn pick_adapter_keeps_the_integrated_gpu_on_hybrid_laptops() { + // Issue #8: the panel is on the integrated GPU, an external monitor on the discrete one. + assert_eq!(super::pick_adapter(&adapters(), &[monitor(NVIDIA), panel(AMD_IGPU)], None), Some(1)); + assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU), monitor(NVIDIA)], None), Some(1)); + assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU)], None), Some(1)); + assert_eq!(super::pick_adapter(&adapters(), &[monitor(AMD_IGPU)], None), Some(1)); +} + +#[test] +fn pick_adapter_prefers_the_discrete_gpu_on_desktops_with_a_monitor_on_each() { + // Issue #445: one monitor on the Intel iGPU, one on the NVIDIA card, no built-in panel. + use eframe::wgpu::DeviceType; + let desktop = [(INTEL_IGPU.0, INTEL_IGPU.1, DeviceType::IntegratedGpu), (NVIDIA.0, NVIDIA.1, DeviceType::DiscreteGpu), (0, 0, DeviceType::Cpu)]; + assert_eq!(super::pick_adapter(&desktop, &[monitor(INTEL_IGPU), monitor(NVIDIA)], None), Some(1)); + assert_eq!(super::pick_adapter(&desktop, &[monitor(NVIDIA), monitor(INTEL_IGPU)], None), Some(1)); + // A GPU driving no display still loses to one that does. + assert_eq!(super::pick_adapter(&adapters(), &[monitor(AMD_IGPU), monitor(INTEL_IGPU)], None), Some(1)); +} + +#[test] +fn pick_adapter_prefers_the_gpu_the_compositor_draws_with() { + // A hybrid laptop whose Wayland compositor runs on the discrete GPU (Hyprland with + // `AQ_DRM_DEVICES` naming it first): the integrated GPU drives the built-in panel, yet the + // compositor shows nothing it draws, and reports no error either. + assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU)], Some(NVIDIA)), Some(0)); + assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU), monitor(NVIDIA)], Some(NVIDIA)), Some(0)); + // The usual hybrid laptop, with the compositor on the integrated GPU: as before (#8). + assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU), monitor(NVIDIA)], Some(AMD_IGPU)), Some(1)); + // No GPU drives a display as far as sysfs says (a container), but the compositor knows. + assert_eq!(super::pick_adapter(&adapters(), &[], Some(NVIDIA)), Some(0)); + // A compositor GPU no adapter matches (a virtual one) leaves the displays in charge. + assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU)], Some((0x1af4, 0x1050))), Some(1)); +} + +#[test] +fn pick_adapter_falls_back_to_low_power_without_a_match() { + assert_eq!(super::pick_adapter(&adapters(), &[monitor((0x8086, 0x1234))], None), Some(1)); + assert_eq!(super::pick_adapter(&adapters(), &[monitor((0x8086, 0x1234)), monitor((0x8086, 0x5678))], None), Some(1)); + assert_eq!(super::pick_adapter(&[], &[monitor(NVIDIA)], None), None); +} + +#[test] +fn internal_panels_are_edp_lvds_and_dsi_connectors() { + for name in ["eDP-1", "LVDS-1", "DSI-1"] { + assert!(super::is_internal_panel(name), "{name}"); + } + for name in ["DP-3", "HDMI-A-1", "DVI-D-1", "VGA-1", "Writeback-1", ""] { + assert!(!super::is_internal_panel(name), "{name}"); + } +} + +#[cfg(target_os = "linux")] +#[test] +fn linux_display_gpus_reads_connected_connectors() -> std::io::Result<()> { + let dir = std::env::temp_dir().join(format!("pdfcraft-drm-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let card = |name: &str, (vendor, device): (u32, u32)| -> std::io::Result<()> { + std::fs::create_dir_all(dir.join(name).join("device"))?; + std::fs::write(dir.join(name).join("device/vendor"), format!("{vendor:#06x}\n"))?; + std::fs::write(dir.join(name).join("device/device"), format!("{device:#06x}\n")) + }; + let connector = |name: &str, status: &str| -> std::io::Result<()> { + std::fs::create_dir_all(dir.join(name))?; + std::fs::write(dir.join(name).join("status"), format!("{status}\n")) + }; + card("card1", NVIDIA)?; + card("card2", AMD_IGPU)?; + connector("card1-DP-3", "connected")?; + connector("card1-DP-4", "connected")?; + connector("card2-HDMI-A-1", "disconnected")?; + connector("card2-Writeback-1", "unknown")?; + let desktop = super::linux_display_gpus(&dir); + // Issue #445: a monitor on the integrated GPU too, still no built-in panel. + connector("card2-HDMI-A-1", "connected")?; + let mut both = super::linux_display_gpus(&dir); + // A hybrid laptop: the integrated GPU drives the built-in panel as well. + connector("card2-eDP-1", "connected")?; + let mut laptop = super::linux_display_gpus(&dir); + std::fs::remove_dir_all(&dir)?; + assert_eq!(desktop, vec![monitor(NVIDIA)]); + both.sort_by_key(|g| g.pci); + assert_eq!(both, vec![monitor(AMD_IGPU), monitor(NVIDIA)]); + laptop.sort_by_key(|g| g.pci); + assert_eq!(laptop, vec![panel(AMD_IGPU), monitor(NVIDIA)]); + assert!(super::linux_display_gpus(std::path::Path::new("/nonexistent/drm")).is_empty()); + Ok(()) +} + +/// A fresh folder per call: tests run in parallel. +fn scratch() -> std::path::PathBuf { + static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); + let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let d = std::env::temp_dir().join(format!("pdfcraft-control-file-{}-{n}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d +} + +/// The control file's port and token, and every name in its folder. +fn written(dir: &std::path::Path) -> (serde_json::Value, Vec) { + let json = serde_json::from_str(&std::fs::read_to_string(dir.join("ctl.json")).unwrap()).unwrap(); + let mut names: Vec = std::fs::read_dir(dir).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect(); + names.sort(); + (json, names) +} + +#[test] +fn the_control_file_replaces_a_hard_link_instead_of_writing_through_it() { + // The file used to be opened with create + truncate, so a link planted at the path had its + // target truncated and overwritten with the token. + let dir = scratch(); + std::fs::write(dir.join("victim.txt"), "keep me").unwrap(); + std::fs::hard_link(dir.join("victim.txt"), dir.join("ctl.json")).unwrap(); + super::write_control_file(&dir.join("ctl.json").to_string_lossy(), 4242, "t0ken").unwrap(); + assert_eq!(std::fs::read_to_string(dir.join("victim.txt")).unwrap(), "keep me"); + let (json, names) = written(&dir); + assert_eq!((json["port"].as_u64(), json["token"].as_str()), (Some(4242), Some("t0ken"))); + assert_eq!(names, ["ctl.json", "victim.txt"], "no temporary file left behind"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn the_control_file_replaces_a_symlink_instead_of_writing_through_it() { + let dir = scratch(); + let (link, target) = (dir.join("ctl.json"), dir.join("victim.txt")); + std::fs::write(&target, "keep me").unwrap(); + #[cfg(unix)] + let made = std::os::unix::fs::symlink(&target, &link); + #[cfg(windows)] + let made = std::os::windows::fs::symlink_file(&target, &link); + if let Err(e) = made { + // Windows needs Developer Mode (or admin) for symlinks. + eprintln!("skipped: can't create a symlink here: {e}"); + let _ = std::fs::remove_dir_all(&dir); + return; + } + super::write_control_file(&link.to_string_lossy(), 4242, "t0ken").unwrap(); + assert_eq!(std::fs::read_to_string(&target).unwrap(), "keep me"); + assert!(!std::fs::symlink_metadata(&link).unwrap().file_type().is_symlink()); + assert_eq!(written(&dir).0["token"].as_str(), Some("t0ken")); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn the_control_file_replaces_the_one_a_previous_run_left() { + let dir = scratch(); + std::fs::write(dir.join("ctl.json"), r#"{"port":1,"token":"old"}"#).unwrap(); + super::write_control_file(&dir.join("ctl.json").to_string_lossy(), 4242, "t0ken").unwrap(); + let (json, names) = written(&dir); + assert_eq!(json["token"].as_str(), Some("t0ken")); + assert_eq!(names, ["ctl.json"]); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(dir.join("ctl.json")).unwrap().permissions().mode(); + assert_eq!(mode & 0o077, 0, "only the owner can read the token: {mode:o}"); + } + let _ = std::fs::remove_dir_all(&dir); +} + +/// X11 startup notification and docks match a window to its launcher by `StartupWMClass`, +/// so it has to be the class the window carries: the app id, on Wayland and X11 alike. +#[test] +fn the_desktop_entry_names_the_window_class() { + let entry = include_str!("../../../packaging/linux/ai.storyteller.pdfcraft.desktop"); + let class = entry.lines().find_map(|line| line.strip_prefix("StartupWMClass=")); + assert_eq!(class, Some(super::APP_ID)); +} + +// Regression test for #246: `eprintln!` panics when standard error is a broken pipe (a GUI +// launch on Windows), and a panic inside the panic hook aborts the process. The start-up +// path and the hook must write to standard error without it. +#[test] +fn start_up_and_the_panic_hook_never_use_eprintln() { + let source = include_str!("main.rs"); + // Everything before the top-level test module is product code. + let product = source.split("\n#[cfg(test)]\nmod tests;").next().unwrap_or(source); + assert!(product.len() < source.len(), "found the end of the product code"); + let offenders: Vec<_> = product + .lines() + .enumerate() + .filter(|(_, line)| !line.trim_start().starts_with("//") && line.contains("eprintln!")) + .map(|(n, line)| format!("main.rs:{}: {}", n + 1, line.trim())) + .collect(); + assert!( + offenders.is_empty(), + "`eprintln!` panics on a broken standard error pipe; use `log::warn!` or `write_fmt` on `stderr()` and ignore the error:\n{}", + offenders.join("\n") + ); +} diff --git a/clippy.toml b/clippy.toml index 7f2fd53057..31e54da30f 100644 --- a/clippy.toml +++ b/clippy.toml @@ -13,3 +13,8 @@ disallowed-methods = [ { path = "egui::Ui::radio_value", reason = "use radio_value from crates/ui-egui/src/widgets.rs, which is outlined and filled" }, { path = "egui::RadioButton::new", reason = "use radio from crates/ui-egui/src/widgets.rs, which is outlined and filled" }, ] +cognitive-complexity-threshold = 30 + +disallowed-types = [ + { path = "egui::ComboBox", reason = "use craft_ui::ComboBox: egui's combo box menu can't grow back while open (egui#5225)" }, +] diff --git a/crates/annot/README.md b/crates/annot/README.md index 8108201835..999baec20a 100644 --- a/crates/annot/README.md +++ b/crates/annot/README.md @@ -30,7 +30,7 @@ let index = add_annotation(&mut doc, &NewAnnotation { page, shape, style, conten add_reply(&mut doc, page, index, "Agreed", "Ada", &meta)?; set_review_state(&mut doc, page, index, ReviewState::Accepted, "Ada", &meta)?; move_annotation(&mut doc, page, index, dx, dy, &meta)?; -set_style(&mut doc, page, index, Some(rgb), Some(0.5), Some(2.0), &meta)?; +set_style(&mut doc, page, index, StyleChange::new(Some(rgb), Some(0.5), Some(2.0), None), &meta)?; delete_annotation(&mut doc, page, index)?; signature_image(&doc, page, index)?; // embedded image XObject of Fill & Sign signature/initials ``` diff --git a/crates/annot/src/appearance.rs b/crates/annot/src/appearance.rs index 530438db6d..abc746c0dd 100644 --- a/crates/annot/src/appearance.rs +++ b/crates/annot/src/appearance.rs @@ -16,6 +16,11 @@ pub use pdfcraft_fonts::{helvetica_width as text_width, wrap}; use crate::{NOTE_SIZE, Rgb, n}; +mod symbols; + +pub(crate) use symbols::display_stamp; +use symbols::{CAPTION_SIZE, attach_icon, ellipse, line_caption, line_layout, note_icon, stamp}; + /// Length of an arrowhead for a line of width `w`. pub fn arrow_size(w: f64) -> f64 { 6.0 + 3.0 * w.max(0.0) @@ -368,16 +373,60 @@ fn caret_layout(d: &Dict, rect: [f64; 4]) -> ([f64; 4], Option<(f64, f64, f64)>) (caret, Some((caret[2] + gap * scale, caret[1] + size * 0.18, size))) } +#[derive(Clone, Copy)] +struct AppearanceContext<'a> { + dict: &'a Dict, + subtype: &'a [u8], + rect: [f64; 4], + cloudy: bool, + opacity: f64, + stroke: Option, + width: f64, +} + +#[derive(Default)] +struct AppearanceDrawing { + resources: Dict, + content: String, + tail: Vec, +} + +impl AppearanceDrawing { + fn new(context: &AppearanceContext<'_>) -> Self { + let mut drawing = Self::default(); + if let Some(gs) = ext_gstate(context.opacity, context.subtype == b"Highlight") { + drawing.resources.set(b"ExtGState".to_vec(), Object::Dict(gs)); + drawing.content.push_str("/GS0 gs\n"); + } + drawing + } +} + /// Draw the normal appearance of an annotation, or `None` if this subtype/variant isn't supported. /// Text the standard fonts can't draw comes out as `?`: [`undrawable_text`] says which, and /// [`crate::set_appearance`] marks the annotation with it (`pdfcraft_fonts::UNDRAWN_MARK`). pub fn build(d: &Dict) -> Option { + let context = appearance_context(d)?; + let drawing = AppearanceDrawing::new(&context); + match context.subtype { + b"Text" => return draw_icon(&context, "Note", [1.0, 0.82, 0.0], drawing), + b"FileAttachment" => return draw_icon(&context, "PushPin", [0.0, 0.47, 0.84], drawing), + b"Stamp" => return draw_stamp(&context, drawing), + b"FreeText" => return draw_free_text(&context, drawing), + _ => {} + } + let mut drawing = drawing; + draw_body(&context, &mut drawing)?; + finish_appearance(&context, drawing) +} + +fn appearance_context(d: &Dict) -> Option> { // Imported measurement appearances may include leaders, captions and formatting that // this builder cannot reproduce. Preserve them rather than silently losing detail. if d.contains(b"Measure") && !d.contains(b"PCMeasureValue") { return None; } - let subtype = d.name(b"Subtype")?.to_vec(); + let subtype = d.name(b"Subtype")?; let rect = nums(d, b"Rect").filter(|r| r.len() == 4)?; let rect = [rect[0].min(rect[2]), rect[1].min(rect[3]), rect[0].max(rect[2]), rect[1].max(rect[3])]; // Cloudy borders (§12.5.4) are drawn for polygons only. @@ -385,1053 +434,576 @@ pub fn build(d: &Dict) -> Option { if cloudy && subtype != b"Polygon" { return None; } - let opacity = d.get(b"CA").and_then(|o| o.as_f64()).unwrap_or(1.0).clamp(0.0, 1.0); - let stroke = color(d, b"C")?; - let w = border_width(d); - let mut res = Dict::new(); - let mut c = String::new(); - // Content after `c` that isn't UTF-8 (WinAnsi text). - let mut tail: Vec = Vec::new(); - let markup = matches!(subtype.as_slice(), b"Highlight" | b"Underline" | b"StrikeOut" | b"Squiggly"); - if let Some(gs) = ext_gstate(opacity, subtype == b"Highlight") { - res.set(b"ExtGState".to_vec(), Object::Dict(gs)); - c.push_str("/GS0 gs\n"); - } - match subtype.as_slice() { - b"Text" => { - let col = stroke.unwrap_or([1.0, 0.82, 0.0]); - let icon = d.name(b"Name").map(|n| String::from_utf8_lossy(n).into_owned()).unwrap_or_else(|| "Note".into()); - c.push_str(¬e_icon(&icon, col)); - return Some(form([0.0, 0.0, NOTE_SIZE, NOTE_SIZE], c.as_bytes(), res)); - } - b"FileAttachment" => { - let col = stroke.unwrap_or([0.0, 0.47, 0.84]); - let icon = d.name(b"Name").map(|n| String::from_utf8_lossy(n).into_owned()).unwrap_or_else(|| "PushPin".into()); - c.push_str(&attach_icon(&icon, col)); - return Some(form([0.0, 0.0, NOTE_SIZE, NOTE_SIZE], c.as_bytes(), res)); - } - _ if markup => { - let q = nums(d, b"QuadPoints").filter(|q| !q.is_empty() && q.len() % 8 == 0)?; - let col = stroke?; - for quad in q.as_chunks::<8>().0.iter() { - let p = |i: usize| (quad[2 * i], quad[2 * i + 1]); - let (p1, p2, p3, p4) = (p(0), p(1), p(2), p(3)); - let h = (p1.0 - p3.0).hypot(p1.1 - p3.1); - // A point a fraction `t` of the way from the bottom edge to the top edge. - let at = |bottom: (f64, f64), top: (f64, f64), t: f64| (bottom.0 + (top.0 - bottom.0) * t, bottom.1 + (top.1 - bottom.1) * t); - match subtype.as_slice() { - b"Highlight" => { - c.push_str(&rg(col)); - c.push_str(&format!( - "{} {} m {} {} l {} {} l {} {} l h f\n", - n(p1.0), - n(p1.1), - n(p2.0), - n(p2.1), - n(p4.0), - n(p4.1), - n(p3.0), - n(p3.1) - )); - } - b"Underline" | b"StrikeOut" => { - let lw = (h * 0.07).clamp(0.5, 3.0); - let t = if subtype == b"Underline" { 0.08 } else { 0.42 }; - let (a, b) = (at(p3, p1, t), at(p4, p2, t)); - c.push_str(&format!("{}{} w\n{} {} m {} {} l S\n", rg_stroke(col), n(lw), n(a.0), n(a.1), n(b.0), n(b.1))); - } - _ => { - // Squiggly: a zigzag along the bottom of the quad. - let lw = (h * 0.05).clamp(0.5, 2.0); - let amp = (h * 0.06).max(0.75); - let len = (p4.0 - p3.0).hypot(p4.1 - p3.1); - let step = (h / 4.0).max(1.5); - let steps = ((len / step).ceil() as usize).clamp(1, 10_000); - c.push_str(&format!("{}{} w 1 j\n", rg_stroke(col), n(lw))); - for i in 0..=steps { - let t = i as f64 / steps as f64; - let base = (p3.0 + (p4.0 - p3.0) * t, p3.1 + (p4.1 - p3.1) * t); - let up = if i % 2 == 0 { amp * 2.0 } else { 0.0 }; - let (ux, uy) = if h > 0.0 { ((p1.0 - p3.0) / h, (p1.1 - p3.1) / h) } else { (0.0, 1.0) }; - let (x, y) = (base.0 + ux * up, base.1 + uy * up); - c.push_str(&format!("{} {} {}\n", n(x), n(y), if i == 0 { "m" } else { "l" })); - } - c.push_str("S\n"); - } - } - } - } - b"Redact" => { - // While marked: the outline of each area (the fill comes when applied). - let q = nums(d, b"QuadPoints").filter(|q| !q.is_empty() && q.len() % 8 == 0)?; - let col = stroke.unwrap_or([0.89, 0.13, 0.13]); - c.push_str(&format!("{}1 w\n", rg_stroke(col))); - for quad in q.as_chunks::<8>().0.iter() { - c.push_str(&format!( - "{} {} m {} {} l {} {} l {} {} l h S\n", - n(quad[0]), - n(quad[1]), - n(quad[2]), - n(quad[3]), - n(quad[6]), - n(quad[7]), - n(quad[4]), - n(quad[5]) - )); - } - } - b"Square" | b"Circle" => { - let fill = interior_color(d, None); - if stroke.is_none() && fill.is_none() { - return Some(form(rect, c.as_bytes(), res)); - } - let inset = w / 2.0; - let [x0, y0, x1, y1] = [rect[0] + inset, rect[1] + inset, rect[2] - inset, rect[3] - inset]; - if let Some(f) = fill { - c.push_str(&rg(f)); - } - if let Some(s) = stroke { - c.push_str(&format!("{}{} w\n{}", rg_stroke(s), n(w), dash(d))); - } - if subtype == b"Square" { - c.push_str(&format!("{} {} {} {} re\n", n(x0), n(y0), n(x1 - x0), n(y1 - y0))); - } else { - c.push_str(&ellipse(x0, y0, x1, y1)); - } - c.push_str(match (fill.is_some(), stroke.is_some() && w > 0.0) { - (true, true) => "B\n", - (true, false) => "f\n", - (false, true) => "S\n", - (false, false) => "n\n", - }); - } - b"Line" => { - let l = nums(d, b"L").filter(|l| l.len() == 4 && l.iter().all(|x| x.is_finite()))?; - let col = stroke?; - let ends: Vec> = match d.get(b"LE") { - None => vec![b"None".to_vec(), b"None".to_vec()], - Some(o) => o.as_array()?.iter().map(|e| e.as_name().map(<[u8]>::to_vec)).collect::>()?, - }; - if ends.len() != 2 || ends.iter().any(|e| !known_ending(e)) { - return None; - } - let fill = interior_color(d, Some(col)).unwrap_or(col); - let layout = line_layout(d, [(l[0], l[1]), (l[2], l[3])], w); - let [p, q] = layout.line; - c.push_str(&format!("{}{}{} w 1 J 1 j\n{}", rg_stroke(col), rg(fill), n(w), dash(d))); - for [s, e] in layout.leaders.iter().chain(&layout.strokes).chain(&layout.tick) { - c.push_str(&format!("{} {} m {} {} l S\n", n(s.0), n(s.1), n(e.0), n(e.1))); - } - c.push_str("[] 0 d\n"); - for (end, (tip, from)) in ends.iter().zip([(p, q), (q, p)]) { - if !line_end(&mut c, end, tip, from, w) { - return None; - } - } - if let Some((text, m)) = layout.caption { - // The caption is black, in the standard font the measurement label uses. - tail.extend( - format!("q 0 g BT /Helv {} Tf {} {} {} {} {} {} Tm ", n(CAPTION_SIZE), n(m[0]), n(m[1]), n(m[2]), n(m[3]), n(m[4]), n(m[5])) - .bytes(), - ); - // WinAnsi bytes go into the stream as-is, not through UTF-8. - tail.extend(literal(&win_ansi(&text))); - tail.extend_from_slice(b" Tj ET Q\n"); - res.set(b"Font".to_vec(), helvetica_resource()); - } - } - b"Polygon" | b"PolyLine" => { - let v = nums(d, b"Vertices").filter(|v| v.len() >= 4 && v.len() % 2 == 0 && v.iter().all(|x| x.is_finite()))?; - let pts: Vec<(f64, f64)> = v.as_chunks::<2>().0.iter().map(|p| (p[0], p[1])).collect(); - let col = stroke; - let closed = subtype == b"Polygon"; - let fill = if closed { interior_color(d, None) } else { None }; - let ends = if subtype == b"PolyLine" { - match d.get(b"LE") { - None => None, - Some(o) => { - let names: Vec> = - o.as_array().filter(|a| a.len() == 2)?.iter().map(|e| e.as_name().map(<[u8]>::to_vec)).collect::>()?; - if names.iter().any(|e| !known_ending(e)) { - return None; - } - Some(names) - } - } - } else { - None - }; - if let Some(f) = fill { - c.push_str(&rg(f)); - } - if let Some(s) = col { - c.push_str(&format!("{}{} w 1 J 1 j\n{}", rg_stroke(s), n(w), dash(d))); - } - if cloudy { - let intensity = d.get(b"BE").and_then(|b| b.as_dict()).and_then(|b| b.get(b"I")).and_then(|i| i.as_f64()).unwrap_or(1.0); - c.push_str(&cloud_path(&pts, cloud_radius(w) * intensity.clamp(0.5, 2.0))); - } else { - for (i, p) in pts.iter().enumerate() { - c.push_str(&format!("{} {} {}\n", n(p.0), n(p.1), if i == 0 { "m" } else { "l" })); - } - if closed { - c.push_str("h\n"); - } - } - c.push_str(match (fill.is_some(), col.is_some() && w > 0.0) { - (true, true) => "B\n", - (true, false) => "f\n", - (false, true) => "S\n", - (false, false) => "n\n", - }); - if let Some(ends) = ends { - let col = col?; - let (Some(start), Some(end)) = (ends.first(), ends.get(1)) else { return None }; - let (Some(&tip0), Some(&from0)) = (pts.first(), pts.get(1)) else { return None }; - let (Some(&tip1), Some(&from1)) = (pts.last(), pts.get(pts.len().saturating_sub(2))) else { return None }; - let fill = interior_color(d, Some(col)).unwrap_or(col); - c.push_str(&format!("[] 0 d\n{}", rg(fill))); - if !line_end(&mut c, start, tip0, from0, w) || !line_end(&mut c, end, tip1, from1, w) { - return None; - } - } - } - b"Caret" => { - // A filled caret: two curved flanks meeting at the top centre. - let col = stroke.unwrap_or([0.0, 0.47, 0.84]); - let (caret, pilcrow) = caret_layout(d, rect); - let [x0, y0, x1, y1] = caret; - let (cx, h) = ((x0 + x1) / 2.0, y1 - y0); - c.push_str(&format!( - "{}{} {} m {} {} {} {} {} {} c {} {} {} {} {} {} c h f\n", - rg(col), - n(x0), - n(y0), - n(cx - (x1 - x0) * 0.1), - n(y0 + h * 0.2), - n(cx), - n(y1 - h * 0.25), - n(cx), - n(y1), - n(cx), - n(y1 - h * 0.25), - n(cx + (x1 - x0) * 0.1), - n(y0 + h * 0.2), - n(x1), - n(y0) - )); - if let Some((x, y, size)) = pilcrow { - // `/Sy /P`: a new paragraph symbol beside the caret (Table 183), in its colour. - // 0xB6 is the pilcrow in WinAnsiEncoding. - let mut out = c.into_bytes(); - out.extend(format!("BT /Helv {} Tf {} {} Td (", n(size), n(x), n(y)).bytes()); - out.push(0xB6); - out.extend_from_slice(b") Tj ET\n"); - res.set(b"Font".to_vec(), helvetica_resource()); - return Some(form(rect, &out, res)); - } - } - b"Ink" => { - let col = stroke?; - let list = d.get(b"InkList")?.as_array()?; - // `/BS` gives an ink stroke its width and dash pattern (Table 182). - c.push_str(&format!("{}{} w 1 J 1 j\n{}", rg_stroke(col), n(w), dash(d))); - for s in list { - let pts: Vec = s.as_array()?.iter().map(|o| o.as_f64()).collect::>()?; - let pts: Vec<(f64, f64)> = pts.as_chunks::<2>().0.iter().map(|p| (p[0], p[1])).collect(); - let Some(first) = pts.first() else { continue }; - c.push_str(&format!("{} {} m\n", n(first.0), n(first.1))); - match pts.as_slice() { - [_] => c.push_str(&format!("{} {} l\n", n(first.0 + 0.01), n(first.1))), - [_, end] => c.push_str(&format!("{} {} l\n", n(end.0), n(end.1))), - _ => c.push_str(&smooth_curve(&pts)), - } - c.push_str("S\n"); - } - } - b"Stamp" => { - // A typed signature: filled outlines normalised to the rectangle. - if let Some(outline) = d.get(b"PCOutline").and_then(|o| o.as_array()) { - let [x0, y0, x1, y1] = rect; - let (w, h) = (x1 - x0, y1 - y0); - c.push_str(&rg(stroke.unwrap_or([0.0; 3]))); - for contour in outline { - let v: Vec = contour.as_array().map(|a| a.iter().filter_map(Object::as_f64).collect()).unwrap_or_default(); - for (i, p) in v.as_chunks::<2>().0.iter().enumerate() { - c.push_str(&format!("{} {} {}\n", n(x0 + p[0] * w), n(y0 + p[1] * h), if i == 0 { "m" } else { "l" })); - } - if v.len() >= 6 { - c.push_str("h\n"); - } - } - c.push_str("f\n"); - return Some(form(rect, c.as_bytes(), res)); - } - // A custom stamp: its picture (an image, or a form mapped to /PCPictureSize) fills - // the rectangle. An image signature added to a turned page (`/PCPictureRotate`) gets - // the standard counter-rotation as its form `/Matrix`: viewers fit the turned - // bounding box to `/Rect` (ISO 32000-2 §12.5.5), so it reads upright as displayed. - if let Some(pic) = d.get(b"PCPicture").and_then(Object::as_ref) { - let [x0, y0, x1, y1] = rect; - let (w, h) = (x1 - x0, y1 - y0); - let image = matches!(d.get(b"PCPictureImage"), Some(Object::Bool(true))); - let place = if image { - format!("{} 0 0 {} {} {} cm", n(w), n(h), n(x0), n(y0)) - } else { - let size = nums(d, b"PCPictureSize").filter(|s| s.len() == 2 && s[0] > 0.0 && s[1] > 0.0)?; - format!("{} 0 0 {} {} {} cm", n(w / size[0]), n(h / size[1]), n(x0), n(y0)) - }; - c.push_str(&format!("q {place} /Pic Do Q\n")); - let mut xo = Dict::new(); - xo.set(b"Pic".to_vec(), Object::Ref(pic)); - res.set(b"XObject".to_vec(), Object::Dict(xo)); - let mut stream = form(rect, c.as_bytes(), res); - let turn = picture_turn(d); - if image && turn != 0 { - // The page's view matrix without its offset: display axes in user space. - let m = pdfcraft_model::view_matrix_for(turn, [0.0; 4]); - let matrix = [m[0], m[1], m[2], m[3], 0.0, 0.0]; - stream.dict.set(b"Matrix".to_vec(), Object::Array(matrix.iter().map(|x| Object::Real(*x)).collect())); - } - return Some(stream); - } - // Only PdfCraft's own Fill & Sign marks are drawn here. - let name = d.name(b"Name")?; - let col = stroke.unwrap_or([0.0; 3]); - // A mark or stamp placed on a turned page is drawn upright in its displayed box, then - // mapped into `/Rect` the way the page maps display space into user space. Viewers - // fit that box to `/Rect`; the page rotation then shows it the right way up. - let turn = stamp_turn(d); - let draw = if turn == 0 { - rect - } else { - let (rw, rh) = (rect[2] - rect[0], rect[3] - rect[1]); - let (dw, dh) = if matches!(turn, 90 | 270) { (rh, rw) } else { (rw, rh) }; - if dw < 1.0 || dh < 1.0 { - return None; - } - [0.0, 0.0, dw, dh] - }; - let turned = |mut stream: Stream| { - if turn != 0 { - let m = pdfcraft_model::view_matrix_for(turn, rect); - stream.dict.set(b"Matrix".to_vec(), Object::Array(m.iter().copied().map(Object::Real).collect())); - } - stream - }; - let [x0, y0, x1, y1] = draw; - let (w, h) = (x1 - x0, y1 - y0); - let lw = w.min(h) * 0.12; - match name { - b"PCCheck" => c.push_str(&format!( - "{}{} w 1 J 1 j\n{} {} m {} {} l {} {} l S\n", - rg_stroke(col), - n(lw), - n(x0 + w * 0.15), - n(y0 + h * 0.5), - n(x0 + w * 0.4), - n(y0 + h * 0.2), - n(x0 + w * 0.88), - n(y0 + h * 0.85) - )), - b"PCCross" => c.push_str(&format!( - "{}{} w 1 J\n{} {} m {} {} l {} {} m {} {} l S\n", - rg_stroke(col), - n(lw), - n(x0 + w * 0.18), - n(y0 + h * 0.18), - n(x1 - w * 0.18), - n(y1 - h * 0.18), - n(x0 + w * 0.18), - n(y1 - h * 0.18), - n(x1 - w * 0.18), - n(y0 + h * 0.18) - )), - b"PCDot" => { - let r = w.min(h) * 0.3; - c.push_str(&rg(col)); - c.push_str(&ellipse(x0 + w / 2.0 - r, y0 + h / 2.0 - r, x0 + w / 2.0 + r, y0 + h / 2.0 + r)); - c.push_str("f\n"); - } - b"PCLine" => c.push_str(&format!( - "{}{} w 1 J\n{} {} m {} {} l S\n", - rg_stroke(col), - n(h.clamp(0.5, 2.0)), - n(x0), - n(y0 + h / 2.0), - n(x1), - n(y0 + h / 2.0) - )), - other => { - // Only stamps PdfCraft made: others keep their own artwork. - if !matches!(d.get(b"PCStamp"), Some(Object::Bool(true))) { - return None; - } - let kind = crate::StampKind::from_name(other)?; - let by = d.get(b"PCByLine").and_then(|o| o.as_string()).map(PdfString::to_text); - let sign_here = kind.group() == crate::StampGroup::SignHere; - return Some(turned(stamp(kind.label(), sign_here, draw, stroke.unwrap_or(kind.color()), by.as_deref(), opacity, res))); - } - } - if turn != 0 { - return Some(turned(form(draw, c.as_bytes(), res))); - } - } - b"FreeText" => { - let (text_color, size) = parse_da(d); - let bg = stroke; - let bw = if d.contains(b"BS") || d.contains(b"Border") { border_width(d) } else { 0.0 }; - // A callout: the leader line (arrowhead at its first point), and the text box inside - // `/Rect` by `/RD`. - // Text added to a turned page is drawn upright in its displayed box, which the form - // `/Matrix` then maps into `/Rect` (as for stamps). Callouts keep user-space axes. - let turn = if d.contains(b"CL") { 0 } else { text_turn(d) }; - let (rect, matrix) = if turn == 0 { - (rect, None) - } else { - let (rw, rh) = (rect[2] - rect[0], rect[3] - rect[1]); - let (dw, dh) = if matches!(turn, 90 | 270) { (rh, rw) } else { (rw, rh) }; - if dw < 1.0 || dh < 1.0 { - return None; - } - ([0.0, 0.0, dw, dh], Some(pdfcraft_model::view_matrix_for(turn, rect))) - }; - let full = rect; - let mut rect = rect; - if d.contains(b"CL") { - let cl = nums(d, b"CL").filter(|l| l.len() == 4 || l.len() == 6)?; - let rd = nums(d, b"RD").filter(|r| r.len() == 4 && r.iter().all(|x| *x >= 0.0))?; - rect = [full[0] + rd[0], full[1] + rd[1], full[2] - rd[2], full[3] - rd[3]]; - if rect[2] - rect[0] < 1.0 || rect[3] - rect[1] < 1.0 { - return None; - } - let end = match d.get(b"LE") { - None => b"None".to_vec(), - Some(o) => o.as_name()?.to_vec(), - }; - if !known_ending(&end) { - return None; - } - let lw = bw.max(0.5); - let pts: Vec<(f64, f64)> = cl.as_chunks::<2>().0.iter().map(|p| (p[0], p[1])).collect(); - if pts.len() < 2 || pts.iter().any(|p| !p.0.is_finite() || !p.1.is_finite()) { - return None; - } - c.push_str(&format!("{}{}{} w 1 J 1 j\n", rg_stroke(text_color), rg(bg.unwrap_or([1.0; 3])), n(lw))); - for (i, p) in pts.iter().enumerate() { - c.push_str(&format!("{} {} {}\n", n(p.0), n(p.1), if i == 0 { "m" } else { "l" })); - } - c.push_str("S\n"); - if !line_end(&mut c, &end, pts[0], pts[1], lw) { - return None; - } - } - if let Some(bg) = bg { - c.push_str(&format!("{}{} {} {} {} re f\n", rg(bg), n(rect[0]), n(rect[1]), n(rect[2] - rect[0]), n(rect[3] - rect[1]))); - } - if bw > 0.0 { - let h = bw / 2.0; - c.push_str(&format!( - "{}{} w\n{}{} {} {} {} re S\n[] 0 d\n", - rg_stroke(text_color), - n(bw), - dash(d), - n(rect[0] + h), - n(rect[1] + h), - n(rect[2] - rect[0] - bw), - n(rect[3] - rect[1] - bw) - )); - } - let text = d.get(b"Contents").and_then(|o| o.as_string()).map(PdfString::to_text).unwrap_or_default(); - let pad = 2.0 + bw; - let width = (rect[2] - rect[0] - 2.0 * pad).max(1.0); - let q = d.int(b"Q").unwrap_or(0); - c.push_str(&format!( - "{} {} {} {} re W n\nBT\n/Helv {} Tf\n{}", - n(rect[0]), - n(rect[1]), - n(rect[2] - rect[0]), - n(rect[3] - rect[1]), - n(size), - rg(text_color) - )); - let mut out = c.into_bytes(); - let mut y = rect[3] - pad - size * 0.9; - for line in wrap(&text, size, width) { - if y < rect[1] - size { - break; - } - let lw = text_width(&line, size); - let x = match q { - 1 => rect[0] + pad + (width - lw) / 2.0, - 2 => rect[2] - pad - lw, - _ => rect[0] + pad, - }; - out.extend(format!("1 0 0 1 {} {} Tm ", n(x), n(y)).bytes()); - out.extend(literal(&win_ansi(&line))); - out.extend_from_slice(b" Tj\n"); - y -= size * 1.2; - } - out.extend_from_slice(b"ET\n"); - res.set(b"Font".to_vec(), helvetica_resource()); - let mut stream = form(full, &out, res); - if let Some(m) = matrix { - stream.dict.set(b"Matrix".to_vec(), Object::Array(m.iter().copied().map(Object::Real).collect())); - } - return Some(stream); - } - _ => return None, + Some(AppearanceContext { + dict: d, + subtype, + rect, + cloudy, + opacity: d.get(b"CA").and_then(|o| o.as_f64()).unwrap_or(1.0).clamp(0.0, 1.0), + stroke: color(d, b"C")?, + width: border_width(d), + }) +} + +fn draw_body(context: &AppearanceContext<'_>, drawing: &mut AppearanceDrawing) -> Option<()> { + match context.subtype { + b"Highlight" | b"Underline" | b"StrikeOut" | b"Squiggly" => draw_markup(context, drawing), + b"Redact" => draw_redaction(context, drawing), + b"Square" | b"Circle" => draw_box(context, drawing), + b"Line" => draw_line(context, drawing), + b"Polygon" | b"PolyLine" => draw_polygon(context, drawing), + b"Caret" => draw_caret(context, drawing), + b"Ink" => draw_ink(context, drawing), + _ => None, } +} + +fn finish_appearance(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawing) -> Option { // PdfCraft measurement captions are kept separate from the comment's free-form text. // A restyle regenerates the path and its value together. - let mut out = c.into_bytes(); - out.extend(tail); - if let Some(value) = d.get(b"PCMeasureValue").and_then(Object::as_string) { + let mut out = drawing.content.into_bytes(); + out.extend(drawing.tail); + if let Some(value) = context.dict.get(b"PCMeasureValue").and_then(Object::as_string) { let value = value.to_text(); - if value.chars().count() <= 256 && matches!(subtype.as_slice(), b"Line" | b"PolyLine" | b"Polygon") { + if value.chars().count() <= 256 && matches!(context.subtype, b"Line" | b"PolyLine" | b"Polygon") { let size = 10.0; - let x = (rect[0] + rect[2] - text_width(&value, size)) * 0.5; - let y = rect[3] - 12.0; - let col = stroke.unwrap_or([0.0, 0.47, 0.84]); + let x = (context.rect[0] + context.rect[2] - text_width(&value, size)) * 0.5; + let y = context.rect[3] - 12.0; + let col = context.stroke.unwrap_or([0.0, 0.47, 0.84]); out.extend(format!("{}BT /Helv {} Tf {} {} Td ", rg(col), n(size), n(x), n(y)).bytes()); // WinAnsi bytes (e.g. 0xB2 for "²") go into the stream as-is, not through UTF-8. out.extend(literal(&win_ansi(&value))); out.extend_from_slice(b" Tj ET\n"); - res.set(b"Font".to_vec(), helvetica_resource()); + drawing.resources.set(b"Font".to_vec(), helvetica_resource()); + } + } + Some(form(context.rect, &out, drawing.resources)) +} + +fn draw_markup(context: &AppearanceContext<'_>, drawing: &mut AppearanceDrawing) -> Option<()> { + let d = context.dict; + let subtype = context.subtype; + let stroke = context.stroke; + let c = &mut drawing.content; + let q = nums(d, b"QuadPoints").filter(|q| !q.is_empty() && q.len() % 8 == 0)?; + let col = stroke?; + for quad in q.as_chunks::<8>().0.iter() { + let p = |i: usize| (quad[2 * i], quad[2 * i + 1]); + let (p1, p2, p3, p4) = (p(0), p(1), p(2), p(3)); + let h = (p1.0 - p3.0).hypot(p1.1 - p3.1); + // A point a fraction `t` of the way from the bottom edge to the top edge. + let at = |bottom: (f64, f64), top: (f64, f64), t: f64| (bottom.0 + (top.0 - bottom.0) * t, bottom.1 + (top.1 - bottom.1) * t); + match subtype { + b"Highlight" => { + c.push_str(&rg(col)); + c.push_str(&format!("{} {} m {} {} l {} {} l {} {} l h f\n", n(p1.0), n(p1.1), n(p2.0), n(p2.1), n(p4.0), n(p4.1), n(p3.0), n(p3.1))); + } + b"Underline" | b"StrikeOut" => { + let lw = (h * 0.07).clamp(0.5, 3.0); + let t = if subtype == b"Underline" { 0.08 } else { 0.42 }; + let (a, b) = (at(p3, p1, t), at(p4, p2, t)); + c.push_str(&format!("{}{} w\n{} {} m {} {} l S\n", rg_stroke(col), n(lw), n(a.0), n(a.1), n(b.0), n(b.1))); + } + _ => { + // Squiggly: a zigzag along the bottom of the quad. + let lw = (h * 0.05).clamp(0.5, 2.0); + let amp = (h * 0.06).max(0.75); + let len = (p4.0 - p3.0).hypot(p4.1 - p3.1); + let step = (h / 4.0).max(1.5); + let steps = ((len / step).ceil() as usize).clamp(1, 10_000); + c.push_str(&format!("{}{} w 1 j\n", rg_stroke(col), n(lw))); + for i in 0..=steps { + let t = i as f64 / steps as f64; + let base = (p3.0 + (p4.0 - p3.0) * t, p3.1 + (p4.1 - p3.1) * t); + let up = if i % 2 == 0 { amp * 2.0 } else { 0.0 }; + let (ux, uy) = if h > 0.0 { ((p1.0 - p3.0) / h, (p1.1 - p3.1) / h) } else { (0.0, 1.0) }; + let (x, y) = (base.0 + ux * up, base.1 + uy * up); + c.push_str(&format!("{} {} {}\n", n(x), n(y), if i == 0 { "m" } else { "l" })); + } + c.push_str("S\n"); + } } } - Some(form(rect, &out, res)) + Some(()) } -/// The label and colour of a standard rubber stamp name (ISO 32000-2 Table 184), for a stamp -/// another program wrote without an appearance. Names are matched byte for byte, as PDF names -/// are compared (§7.3.5). Eight are stamps of PdfCraft's palette; the other six take a label of -/// their own and the palette's colour for their meaning. -fn standard_stamp(name: &[u8]) -> Option<(&'static str, Rgb)> { - use crate::StampKind::*; - let palette = [Approved, NotApproved, NotForPublicRelease, Confidential, Final, ForComment, Draft, ForPublicRelease]; - if let Some(kind) = palette.into_iter().find(|k| k.name().as_bytes() == name) { - return Some((kind.label(), kind.color())); +fn draw_redaction(context: &AppearanceContext<'_>, drawing: &mut AppearanceDrawing) -> Option<()> { + let d = context.dict; + let stroke = context.stroke; + let c = &mut drawing.content; + // While marked: the outline of each area (the fill comes when applied). + let q = nums(d, b"QuadPoints").filter(|q| !q.is_empty() && q.len() % 8 == 0)?; + let col = stroke.unwrap_or([0.89, 0.13, 0.13]); + c.push_str(&format!("{}1 w\n", rg_stroke(col))); + for quad in q.as_chunks::<8>().0.iter() { + c.push_str(&format!( + "{} {} m {} {} l {} {} l {} {} l h S\n", + n(quad[0]), + n(quad[1]), + n(quad[2]), + n(quad[3]), + n(quad[6]), + n(quad[7]), + n(quad[4]), + n(quad[5]) + )); } - let (label, like) = match name { - b"Experimental" => ("EXPERIMENTAL", Draft), - b"AsIs" => ("AS IS", Draft), - b"Expired" => ("EXPIRED", NotApproved), - b"Sold" => ("SOLD", Approved), - b"Departmental" => ("DEPARTMENTAL", Draft), - b"TopSecret" => ("TOP SECRET", Confidential), - _ => return None, - }; - Some((label, like.color())) + Some(()) } -/// The neutral grey of a stamp whose name has no drawing. -const PLACEHOLDER_GREY: Rgb = [0.45, 0.45, 0.45]; - -/// The display appearance of a rubber stamp another program wrote without one: the stamp its -/// `/Name` names (Draft when there is none, Table 184's default) in its `/C` colour or the -/// stamp's own, or for any other name a thin grey frame with both diagonals, so the stamp stays -/// visible and can be selected. The frame is grey whatever the `/C`: it stands in for a stamp -/// that cannot be drawn, so it takes none of that stamp's colour. The name is never drawn as text. -/// -/// For display only ([`crate::with_missing_appearances`]): [`build`] declines these stamps, so -/// edits (restyling, flattening) never write this drawing into the file. `None` for any other -/// annotation, one of PdfCraft's own stamps, a dictionary with an `/AP` (a stamp's own artwork is -/// never replaced; the display copy passes the dictionary without an `/AP` that has no normal -/// appearance), or a `/Rect` that isn't finite or is under a point wide or high. -pub(crate) fn display_stamp(d: &Dict) -> Option { - let own = matches!(d.get(b"PCStamp"), Some(Object::Bool(true))) || d.contains(b"PCPicture") || d.contains(b"PCOutline"); - if d.name(b"Subtype") != Some(b"Stamp") || own || d.contains(b"AP") { - return None; +fn draw_box(context: &AppearanceContext<'_>, drawing: &mut AppearanceDrawing) -> Option<()> { + let d = context.dict; + let subtype = context.subtype; + let rect = context.rect; + let stroke = context.stroke; + let w = context.width; + let c = &mut drawing.content; + let fill = interior_color(d, None); + if stroke.is_none() && fill.is_none() { + return Some(()); } - let rect = nums(d, b"Rect")?; - let [a, b, c, e] = rect.as_slice() else { return None }; - let rect = [a.min(*c), b.min(*e), a.max(*c), b.max(*e)]; - if !rect.iter().all(|v| v.is_finite()) || rect[2] - rect[0] < 1.0 || rect[3] - rect[1] < 1.0 { - return None; + let inset = w / 2.0; + let [x0, y0, x1, y1] = [rect[0] + inset, rect[1] + inset, rect[2] - inset, rect[3] - inset]; + if let Some(f) = fill { + c.push_str(&rg(f)); } - let opacity = d.get(b"CA").and_then(|o| o.as_f64()).unwrap_or(1.0).clamp(0.0, 1.0); - // A malformed `/C` falls back to the stamp's own colour rather than hiding the stamp. - let stroke = color(d, b"C").flatten(); - let mut res = Dict::new(); - if let Some(gs) = ext_gstate(opacity, false) { - res.set(b"ExtGState".to_vec(), Object::Dict(gs)); - } - // A stamp whose intent (`/IT`, PDF 2.0) is an image or a snapshot has no `/Name` to default. - let stamp_intent = d.name(b"IT").is_none_or(|it| it == b"Stamp"); - let name = d.name(b"Name").or(stamp_intent.then_some(b"Draft".as_slice())); - if let Some((label, col)) = name.and_then(standard_stamp) { - return Some(stamp(label, false, rect, stroke.unwrap_or(col), None, opacity, res)); - } - let [x0, y0, x1, y1] = rect; - let (a0, b0, a1, b1) = (x0 + 0.5, y0 + 0.5, x1 - 0.5, y1 - 0.5); - let mut c = String::new(); - if opacity < 1.0 { - c.push_str("/GS0 gs\n"); + if let Some(s) = stroke { + c.push_str(&format!("{}{} w\n{}", rg_stroke(s), n(w), dash(d))); } - c.push_str(&format!( - "{}1 w\n{} {} {} {} re\n{} {} m {} {} l\n{} {} m {} {} l\nS\n", - // Grey whatever the `/C`: the placeholder is not the stamp, so it takes none of its colour. - rg_stroke(PLACEHOLDER_GREY), - n(a0), - n(b0), - n(a1 - a0), - n(b1 - b0), - n(a0), - n(b0), - n(a1), - n(b1), - n(a0), - n(b1), - n(a1), - n(b0) - )); - Some(form(rect, c.as_bytes(), res)) -} - -/// Size of a line's caption, as of a measurement's label. -const CAPTION_SIZE: f64 = 10.0; - -/// Characters of a line's caption drawn at most, as for a measurement's label. -const MAX_CAPTION_CHARS: usize = 256; - -/// Leader lengths and caption offsets (Table 178) are held within this many points (200 inches, -/// the largest page side ISO 32000-2 Annex C allows), so an absurd value can't overflow. -const MAX_LINE_EXTENT: f64 = 14_400.0; - -/// A Table 178 length or offset: finite and within [`MAX_LINE_EXTENT`], 0 when absent or not one. -fn line_length(v: Option) -> f64 { - v.filter(|v| v.is_finite()).map_or(0.0, |v| v.clamp(-MAX_LINE_EXTENT, MAX_LINE_EXTENT)) -} - -/// The caption a Line with `/Cap true` shows (ISO 32000-2 Table 178): its `/Contents`, else the -/// plain text of its `/RC`, on one line and cut to [`MAX_CAPTION_CHARS`]. `None` without a -/// caption, and for PdfCraft's own measurements, whose label (`/PCMeasureValue`) is the caption. -fn line_caption(d: &Dict) -> Option { - if !matches!(d.get(b"Cap"), Some(Object::Bool(true))) || d.contains(b"PCMeasureValue") { + if subtype == b"Square" { + c.push_str(&format!("{} {} {} {} re\n", n(x0), n(y0), n(x1 - x0), n(y1 - y0))); + } else { + c.push_str(&ellipse(x0, y0, x1, y1)); + } + c.push_str(match (fill.is_some(), stroke.is_some() && w > 0.0) { + (true, true) => "B\n", + (true, false) => "f\n", + (false, true) => "S\n", + (false, false) => "n\n", + }); + Some(()) +} + +fn draw_line(context: &AppearanceContext<'_>, drawing: &mut AppearanceDrawing) -> Option<()> { + let d = context.dict; + let stroke = context.stroke; + let w = context.width; + let c = &mut drawing.content; + let res = &mut drawing.resources; + let tail = &mut drawing.tail; + let l = nums(d, b"L").filter(|l| l.len() == 4 && l.iter().all(|x| x.is_finite()))?; + let col = stroke?; + let ends: Vec> = match d.get(b"LE") { + None => vec![b"None".to_vec(), b"None".to_vec()], + Some(o) => o.as_array()?.iter().map(|e| e.as_name().map(<[u8]>::to_vec)).collect::>()?, + }; + if ends.len() != 2 || ends.iter().any(|e| !known_ending(e)) { return None; } - let text = |key: &[u8]| d.get(key).and_then(Object::as_string).map(PdfString::to_text); - let raw = text(b"Contents").filter(|t| !t.trim().is_empty()).or_else(|| text(b"RC").map(|rc| rich_plain(&rc)))?; - let words: Vec<&str> = raw.split_whitespace().collect(); - let caption: String = words.join(" ").chars().filter(|c| !c.is_control()).take(MAX_CAPTION_CHARS).collect(); - (!caption.is_empty()).then_some(caption) -} - -/// The text of rich text (§12.7.4.3, XHTML): its markup dropped, the five XML entities and -/// numeric character references read, block ends read as spaces. -fn rich_plain(rc: &str) -> String { - let mut out = String::new(); - let mut tag: Option = None; - // Rich text past this is not caption material; it bounds the work. - for ch in rc.chars().take(MAX_CAPTION_CHARS * 64) { - match (&mut tag, ch) { - (None, '<') => tag = Some(String::new()), - (Some(name), '>') => { - let name = - name.trim_start_matches('/').split(|c: char| c.is_whitespace() || c == '/').next().unwrap_or_default().to_ascii_lowercase(); - if matches!(name.as_str(), "p" | "br" | "div" | "li") { - out.push(' '); - } - tag = None; - } - (Some(name), c) => { - if name.len() < 16 { - name.push(c); - } - } - (None, c) => out.push(c), + let fill = interior_color(d, Some(col)).unwrap_or(col); + let layout = line_layout(d, [(l[0], l[1]), (l[2], l[3])], w); + let [p, q] = layout.line; + c.push_str(&format!("{}{}{} w 1 J 1 j\n{}", rg_stroke(col), rg(fill), n(w), dash(d))); + for [s, e] in layout.leaders.iter().chain(&layout.strokes).chain(&layout.tick) { + c.push_str(&format!("{} {} m {} {} l S\n", n(s.0), n(s.1), n(e.0), n(e.1))); + } + c.push_str("[] 0 d\n"); + for (end, (tip, from)) in ends.iter().zip([(p, q), (q, p)]) { + if !line_end(c, end, tip, from, w) { + return None; } } - let mut text = String::new(); - let mut rest = out.as_str(); - while let Some(at) = rest.find('&') { - text.push_str(rest.get(..at).unwrap_or_default()); - let after = rest.get(at + 1..).unwrap_or_default(); - let entity = after.find(';').filter(|&end| end <= 10).and_then(|end| Some((after.get(..end)?, end))); - let decoded = entity.and_then(|(name, end)| { - let c = match name { - "amp" => '&', - "lt" => '<', - "gt" => '>', - "quot" => '"', - "apos" => '\'', - _ => { - let num = name.strip_prefix('#')?; - let code = match num.strip_prefix(['x', 'X']) { - Some(hex) => u32::from_str_radix(hex, 16).ok()?, - None => num.parse().ok()?, - }; - char::from_u32(code)? + if let Some((text, m)) = layout.caption { + // The caption is black, in the standard font the measurement label uses. + tail.extend( + format!("q 0 g BT /Helv {} Tf {} {} {} {} {} {} Tm ", n(CAPTION_SIZE), n(m[0]), n(m[1]), n(m[2]), n(m[3]), n(m[4]), n(m[5])).bytes(), + ); + // WinAnsi bytes go into the stream as-is, not through UTF-8. + tail.extend(literal(&win_ansi(&text))); + tail.extend_from_slice(b" Tj ET Q\n"); + res.set(b"Font".to_vec(), helvetica_resource()); + } + Some(()) +} + +fn draw_polygon(context: &AppearanceContext<'_>, drawing: &mut AppearanceDrawing) -> Option<()> { + let d = context.dict; + let subtype = context.subtype; + let stroke = context.stroke; + let w = context.width; + let cloudy = context.cloudy; + let c = &mut drawing.content; + let v = nums(d, b"Vertices").filter(|v| v.len() >= 4 && v.len() % 2 == 0 && v.iter().all(|x| x.is_finite()))?; + let pts: Vec<(f64, f64)> = v.as_chunks::<2>().0.iter().map(|p| (p[0], p[1])).collect(); + let col = stroke; + let closed = subtype == b"Polygon"; + let fill = if closed { interior_color(d, None) } else { None }; + let ends = if subtype == b"PolyLine" { + match d.get(b"LE") { + None => None, + Some(o) => { + let names: Vec> = + o.as_array().filter(|a| a.len() == 2)?.iter().map(|e| e.as_name().map(<[u8]>::to_vec)).collect::>()?; + if names.iter().any(|e| !known_ending(e)) { + return None; } - }; - Some((c, end)) - }); - match decoded { - Some((c, end)) => { - text.push(c); - rest = after.get(end + 1..).unwrap_or_default(); - } - None => { - text.push('&'); - rest = after; + Some(names) } } + } else { + None + }; + if let Some(f) = fill { + c.push_str(&rg(f)); } - text.push_str(rest); - text -} - -/// Where a Line annotation's parts go (ISO 32000-2 §12.5.6.7, Table 178). -#[derive(Debug, Default)] -struct LineLayout { - /// The line itself, start to end: `/L`, or `/L` moved out by its leader lines. - line: [(f64, f64); 2], - /// Strokes along the line: the whole line, or the two pieces either side of an inline caption. - strokes: Vec<[(f64, f64); 2]>, - /// The leader lines, from `/L` (past the `/LLO` gap) to the line and on by `/LLE`. - leaders: Vec<[(f64, f64); 2]>, - /// A short stroke from the line to a caption `/CO` moved off it. - tick: Option<[(f64, f64); 2]>, - /// The caption and its text matrix. - caption: Option<(String, [f64; 6])>, -} - -/// Lay out a Line annotation from `/L` = `[a b]`, line width `w`, and its leader and caption -/// entries. -/// -/// Leader lines: a positive `/LL` puts the line on the left of travel from `a` to `b` (above `/L` -/// for a line drawn left to right), so the leader lines, followed from the line to `/L`, turn -/// clockwise from the direction of travel; a negative one puts it on the right. The line sits -/// `|LL| + LLO` from `/L`. `/LLE` and `/LLO` are non-negative: a negative one counts as 0. -/// -/// Caption (`/Cap true`): centred on the line's midpoint and turned with it so it reads left to -/// right (bottom to top for a vertical line). `/CP /Inline` (the default) centres it on the line, -/// which is broken around it; `/Top` sets it on top of the unbroken line. `/CO [h v]` moves it `h` -/// along the line and `v` across it (positive: up in reading direction); a caption moved off an -/// inline line gets a tick from the line to it. -/// -/// A zero-length or overflowing `/L` has no direction: it gets neither leader lines nor caption. -/// Every point is finite; if anything derived from the entries is not, they are left out. -fn line_layout(d: &Dict, [a, b]: [(f64, f64); 2], w: f64) -> LineLayout { - let plain = LineLayout { line: [a, b], strokes: vec![[a, b]], ..LineLayout::default() }; - let (dx, dy) = (b.0 - a.0, b.1 - a.1); - let len = dx.hypot(dy); - if !len.is_finite() || len <= 0.0 { - return plain; - } - let (ux, uy) = (dx / len, dy / len); - let mut out = LineLayout { line: [a, b], ..LineLayout::default() }; - let ll = line_length(d.get(b"LL").and_then(Object::as_f64)); - if ll != 0.0 { - // The unit normal from `/L` towards the line: left of travel for a positive /LL. - let s = ll.signum(); - let (nx, ny) = (-uy * s, ux * s); - let llo = line_length(d.get(b"LLO").and_then(Object::as_f64)).max(0.0); - let lle = line_length(d.get(b"LLE").and_then(Object::as_f64)).max(0.0); - let at = |p: (f64, f64), k: f64| (p.0 + nx * k, p.1 + ny * k); - let off = ll.abs() + llo; - out.line = [at(a, off), at(b, off)]; - out.leaders = vec![[at(a, llo), at(a, off + lle)], [at(b, llo), at(b, off + lle)]]; + if let Some(s) = col { + c.push_str(&format!("{}{} w 1 J 1 j\n{}", rg_stroke(s), n(w), dash(d))); } - let [p, _] = out.line; - let along = |t: f64| (p.0 + ux * t, p.1 + uy * t); - let mut gap = None; - if let Some(text) = line_caption(d) { - let size = CAPTION_SIZE; - let width = text_width(&text, size); - // Reading direction, and "up" across it. - let (tx, ty) = if ux < 0.0 || (ux == 0.0 && uy < 0.0) { (-ux, -uy) } else { (ux, uy) }; - let (vx, vy) = (-ty, tx); - let co = nums(d, b"CO").filter(|c| c.len() == 2).unwrap_or_default(); - let h = line_length(co.first().copied()); - let v = line_length(co.get(1).copied()); - let top = d.name(b"CP") == Some(b"Top"); - // The caption's centre, as a distance along the line from `p`. - let centre = len / 2.0 + h * (tx * ux + ty * uy); - let mid = along(centre); - // Baseline across the line: on top of it, or with the text centred on it. - let base = if top { w.max(0.0) / 2.0 + 2.0 + v } else { v - size * 0.35 }; - let origin = (mid.0 - tx * width / 2.0 + vx * base, mid.1 - ty * width / 2.0 + vy * base); - out.caption = Some((text, [tx, ty, -ty, tx, origin.0, origin.1])); - if !top && v.abs() < size / 2.0 { - let half = width / 2.0 + 2.0; - gap = Some(((centre - half).clamp(0.0, len), (centre + half).clamp(0.0, len))); - } else if !top { - let reach = v - v.signum() * size * 0.4; - out.tick = Some([mid, (mid.0 + vx * reach, mid.1 + vy * reach)]); + if cloudy { + let intensity = d.get(b"BE").and_then(|b| b.as_dict()).and_then(|b| b.get(b"I")).and_then(|i| i.as_f64()).unwrap_or(1.0); + c.push_str(&cloud_path(&pts, cloud_radius(w) * intensity.clamp(0.5, 2.0))); + } else { + for (i, p) in pts.iter().enumerate() { + c.push_str(&format!("{} {} {}\n", n(p.0), n(p.1), if i == 0 { "m" } else { "l" })); + } + if closed { + c.push_str("h\n"); + } + } + c.push_str(match (fill.is_some(), col.is_some() && w > 0.0) { + (true, true) => "B\n", + (true, false) => "f\n", + (false, true) => "S\n", + (false, false) => "n\n", + }); + if let Some(ends) = ends { + let col = col?; + let (Some(start), Some(end)) = (ends.first(), ends.get(1)) else { return None }; + let (Some(&tip0), Some(&from0)) = (pts.first(), pts.get(1)) else { return None }; + let (Some(&tip1), Some(&from1)) = (pts.last(), pts.get(pts.len().saturating_sub(2))) else { return None }; + let fill = interior_color(d, Some(col)).unwrap_or(col); + c.push_str(&format!("[] 0 d\n{}", rg(fill))); + if !line_end(c, start, tip0, from0, w) || !line_end(c, end, tip1, from1, w) { + return None; + } + } + Some(()) +} + +fn draw_caret(context: &AppearanceContext<'_>, drawing: &mut AppearanceDrawing) -> Option<()> { + let d = context.dict; + let rect = context.rect; + let stroke = context.stroke; + let c = &mut drawing.content; + // A filled caret: two curved flanks meeting at the top centre. + let col = stroke.unwrap_or([0.0, 0.47, 0.84]); + let (caret, pilcrow) = caret_layout(d, rect); + let [x0, y0, x1, y1] = caret; + let (cx, h) = ((x0 + x1) / 2.0, y1 - y0); + c.push_str(&format!( + "{}{} {} m {} {} {} {} {} {} c {} {} {} {} {} {} c h f\n", + rg(col), + n(x0), + n(y0), + n(cx - (x1 - x0) * 0.1), + n(y0 + h * 0.2), + n(cx), + n(y1 - h * 0.25), + n(cx), + n(y1), + n(cx), + n(y1 - h * 0.25), + n(cx + (x1 - x0) * 0.1), + n(y0 + h * 0.2), + n(x1), + n(y0) + )); + if let Some((x, y, size)) = pilcrow { + // `/Sy /P`: a new paragraph symbol beside the caret (Table 183), in its colour. + // 0xB6 is the pilcrow in WinAnsiEncoding. + drawing.tail.extend(format!("BT /Helv {} Tf {} {} Td (", n(size), n(x), n(y)).bytes()); + drawing.tail.push(0xB6); + drawing.tail.extend_from_slice(b") Tj ET\n"); + drawing.resources.set(b"Font".to_vec(), helvetica_resource()); + } + Some(()) +} + +fn draw_ink(context: &AppearanceContext<'_>, drawing: &mut AppearanceDrawing) -> Option<()> { + let d = context.dict; + let stroke = context.stroke; + let w = context.width; + let c = &mut drawing.content; + let col = stroke?; + let list = d.get(b"InkList")?.as_array()?; + // `/BS` gives an ink stroke its width and dash pattern (Table 182). + c.push_str(&format!("{}{} w 1 J 1 j\n{}", rg_stroke(col), n(w), dash(d))); + for s in list { + let pts: Vec = s.as_array()?.iter().map(|o| o.as_f64()).collect::>()?; + let pts: Vec<(f64, f64)> = pts.as_chunks::<2>().0.iter().map(|p| (p[0], p[1])).collect(); + let Some(first) = pts.first() else { continue }; + c.push_str(&format!("{} {} m\n", n(first.0), n(first.1))); + match pts.as_slice() { + [_] => c.push_str(&format!("{} {} l\n", n(first.0 + 0.01), n(first.1))), + [_, end] => c.push_str(&format!("{} {} l\n", n(end.0), n(end.1))), + _ => c.push_str(&smooth_curve(&pts)), + } + c.push_str("S\n"); + } + Some(()) +} + +fn draw_stamp(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawing) -> Option { + let d = context.dict; + let rect = context.rect; + let stroke = context.stroke; + let opacity = context.opacity; + let c = &mut drawing.content; + let mut res = std::mem::take(&mut drawing.resources); + // A typed signature: filled outlines normalised to the rectangle. + if let Some(outline) = d.get(b"PCOutline").and_then(|o| o.as_array()) { + let [x0, y0, x1, y1] = rect; + let (w, h) = (x1 - x0, y1 - y0); + c.push_str(&rg(stroke.unwrap_or([0.0; 3]))); + for contour in outline { + let v: Vec = contour.as_array().map(|a| a.iter().filter_map(Object::as_f64).collect()).unwrap_or_default(); + for (i, p) in v.as_chunks::<2>().0.iter().enumerate() { + c.push_str(&format!("{} {} {}\n", n(x0 + p[0] * w), n(y0 + p[1] * h), if i == 0 { "m" } else { "l" })); + } + if v.len() >= 6 { + c.push_str("h\n"); + } + } + c.push_str("f\n"); + return Some(form(rect, c.as_bytes(), res)); + } + // A custom stamp: its picture (an image, or a form mapped to /PCPictureSize) fills + // the rectangle. An image signature added to a turned page (`/PCPictureRotate`) gets + // the standard counter-rotation as its form `/Matrix`: viewers fit the turned + // bounding box to `/Rect` (ISO 32000-2 §12.5.5), so it reads upright as displayed. + if let Some(pic) = d.get(b"PCPicture").and_then(Object::as_ref) { + let [x0, y0, x1, y1] = rect; + let (w, h) = (x1 - x0, y1 - y0); + let image = matches!(d.get(b"PCPictureImage"), Some(Object::Bool(true))); + let place = if image { + format!("{} 0 0 {} {} {} cm", n(w), n(h), n(x0), n(y0)) + } else { + let size = nums(d, b"PCPictureSize").filter(|s| s.len() == 2 && s[0] > 0.0 && s[1] > 0.0)?; + format!("{} 0 0 {} {} {} cm", n(w / size[0]), n(h / size[1]), n(x0), n(y0)) + }; + c.push_str(&format!("q {place} /Pic Do Q\n")); + let mut xo = Dict::new(); + xo.set(b"Pic".to_vec(), Object::Ref(pic)); + res.set(b"XObject".to_vec(), Object::Dict(xo)); + let mut stream = form(rect, c.as_bytes(), res); + let turn = picture_turn(d); + if image && turn != 0 { + // The page's view matrix without its offset: display axes in user space. + let m = pdfcraft_model::view_matrix_for(turn, [0.0; 4]); + let matrix = [m[0], m[1], m[2], m[3], 0.0, 0.0]; + stream.dict.set(b"Matrix".to_vec(), Object::Array(matrix.iter().map(|x| Object::Real(*x)).collect())); + } + return Some(stream); + } + // Only PdfCraft's own Fill & Sign marks are drawn here. + let name = d.name(b"Name")?; + let col = stroke.unwrap_or([0.0; 3]); + // A mark or stamp placed on a turned page is drawn upright in its displayed box, then + // mapped into `/Rect` the way the page maps display space into user space. Viewers + // fit that box to `/Rect`; the page rotation then shows it the right way up. + let turn = stamp_turn(d); + let draw = if turn == 0 { + rect + } else { + let (rw, rh) = (rect[2] - rect[0], rect[3] - rect[1]); + let (dw, dh) = if matches!(turn, 90 | 270) { (rh, rw) } else { (rw, rh) }; + if dw < 1.0 || dh < 1.0 { + return None; } - } - out.strokes = match gap { - Some((g0, g1)) => [(0.0, g0), (g1, len)].into_iter().filter(|(s, e)| e > s).map(|(s, e)| [along(s), along(e)]).collect(), - None => vec![out.line], + [0.0, 0.0, dw, dh] }; - let finite = |q: &(f64, f64)| q.0.is_finite() && q.1.is_finite(); - let all_finite = - out.line.iter().chain(out.strokes.iter().flatten()).chain(out.leaders.iter().flatten()).chain(out.tick.iter().flatten()).all(finite) - && out.caption.as_ref().is_none_or(|(_, m)| m.iter().all(|x| x.is_finite())); - if all_finite { out } else { plain } -} - -/// A rubber stamp: a rounded frame (a pointed tag for sign-here stamps) with the label in bold -/// capitals, and the dynamic stamps' "By … at …" line. -fn stamp(label: &str, sign_here: bool, rect: [f64; 4], col: Rgb, by: Option<&str>, opacity: f64, mut res: Dict) -> Stream { - let [x0, y0, x1, y1] = rect; - let h = y1 - y0; - let mut c = String::new(); - if opacity < 1.0 { - c.push_str("/GS0 gs\n"); - } - let lw = (h * 0.07).clamp(1.0, 3.0); - let inset = lw / 2.0 + 0.5; - if sign_here { - // A tag pointing left, filled, with white text. - let tip = h * 0.45; - c.push_str(&format!( - "{}{}{} w 1 j\n{} {} m {} {} l {} {} l {} {} l {} {} l h B\n", - rg(col), + let turned = |mut stream: Stream| { + if turn != 0 { + let m = pdfcraft_model::view_matrix_for(turn, rect); + stream.dict.set(b"Matrix".to_vec(), Object::Array(m.iter().copied().map(Object::Real).collect())); + } + stream + }; + let [x0, y0, x1, y1] = draw; + let (w, h) = (x1 - x0, y1 - y0); + let lw = w.min(h) * 0.12; + match name { + b"PCCheck" => c.push_str(&format!( + "{}{} w 1 J 1 j\n{} {} m {} {} l {} {} l S\n", rg_stroke(col), n(lw), - n(x0 + inset), + n(x0 + w * 0.15), + n(y0 + h * 0.5), + n(x0 + w * 0.4), + n(y0 + h * 0.2), + n(x0 + w * 0.88), + n(y0 + h * 0.85) + )), + b"PCCross" => c.push_str(&format!( + "{}{} w 1 J\n{} {} m {} {} l {} {} m {} {} l S\n", + rg_stroke(col), + n(lw), + n(x0 + w * 0.18), + n(y0 + h * 0.18), + n(x1 - w * 0.18), + n(y1 - h * 0.18), + n(x0 + w * 0.18), + n(y1 - h * 0.18), + n(x1 - w * 0.18), + n(y0 + h * 0.18) + )), + b"PCDot" => { + let r = w.min(h) * 0.3; + c.push_str(&rg(col)); + c.push_str(&ellipse(x0 + w / 2.0 - r, y0 + h / 2.0 - r, x0 + w / 2.0 + r, y0 + h / 2.0 + r)); + c.push_str("f\n"); + } + b"PCLine" => c.push_str(&format!( + "{}{} w 1 J\n{} {} m {} {} l S\n", + rg_stroke(col), + n(h.clamp(0.5, 2.0)), + n(x0), n(y0 + h / 2.0), - n(x0 + tip), - n(y1 - inset), - n(x1 - inset), - n(y1 - inset), - n(x1 - inset), - n(y0 + inset), - n(x0 + tip), - n(y0 + inset) - )); + n(x1), + n(y0 + h / 2.0) + )), + other => { + // Only stamps PdfCraft made: others keep their own artwork. + if !matches!(d.get(b"PCStamp"), Some(Object::Bool(true))) { + return None; + } + let kind = crate::StampKind::from_name(other)?; + let by = d.get(b"PCByLine").and_then(|o| o.as_string()).map(PdfString::to_text); + let sign_here = kind.group() == crate::StampGroup::SignHere; + return Some(turned(stamp(kind.label(), sign_here, draw, stroke.unwrap_or(kind.color()), by.as_deref(), opacity, res))); + } + } + if turn != 0 { + return Some(turned(form(draw, c.as_bytes(), res))); + } + Some(form(rect, c.as_bytes(), res)) +} + +fn draw_free_text(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawing) -> Option { + let d = context.dict; + let rect = context.rect; + let stroke = context.stroke; + let mut c = std::mem::take(&mut drawing.content); + let mut res = std::mem::take(&mut drawing.resources); + let _tail = std::mem::take(&mut drawing.tail); + let (text_color, size) = parse_da(d); + let bg = stroke; + let bw = if d.contains(b"BS") || d.contains(b"Border") { border_width(d) } else { 0.0 }; + // A callout: the leader line (arrowhead at its first point), and the text box inside + // `/Rect` by `/RD`. + // Text added to a turned page is drawn upright in its displayed box, which the form + // `/Matrix` then maps into `/Rect` (as for stamps). Callouts keep user-space axes. + let turn = if d.contains(b"CL") { 0 } else { text_turn(d) }; + let (rect, matrix) = if turn == 0 { + (rect, None) } else { - // A rounded frame with a light tint. - let r = (h * 0.2).min(8.0); - let k = 0.552_284_75 * r; - let (a0, b0, a1, b1) = (x0 + inset, y0 + inset, x1 - inset, y1 - inset); - let tint = col.map(|v| 1.0 - (1.0 - v) * 0.12); + let (rw, rh) = (rect[2] - rect[0], rect[3] - rect[1]); + let (dw, dh) = if matches!(turn, 90 | 270) { (rh, rw) } else { (rw, rh) }; + if dw < 1.0 || dh < 1.0 { + return None; + } + ([0.0, 0.0, dw, dh], Some(pdfcraft_model::view_matrix_for(turn, rect))) + }; + let full = rect; + let mut rect = rect; + if d.contains(b"CL") { + let cl = nums(d, b"CL").filter(|l| l.len() == 4 || l.len() == 6)?; + let rd = nums(d, b"RD").filter(|r| r.len() == 4 && r.iter().all(|x| *x >= 0.0))?; + rect = [full[0] + rd[0], full[1] + rd[1], full[2] - rd[2], full[3] - rd[3]]; + if rect[2] - rect[0] < 1.0 || rect[3] - rect[1] < 1.0 { + return None; + } + let end = match d.get(b"LE") { + None => b"None".to_vec(), + Some(o) => o.as_name()?.to_vec(), + }; + if !known_ending(&end) { + return None; + } + let lw = bw.max(0.5); + let pts: Vec<(f64, f64)> = cl.as_chunks::<2>().0.iter().map(|p| (p[0], p[1])).collect(); + if pts.len() < 2 || pts.iter().any(|p| !p.0.is_finite() || !p.1.is_finite()) { + return None; + } + c.push_str(&format!("{}{}{} w 1 J 1 j\n", rg_stroke(text_color), rg(bg.unwrap_or([1.0; 3])), n(lw))); + for (i, p) in pts.iter().enumerate() { + c.push_str(&format!("{} {} {}\n", n(p.0), n(p.1), if i == 0 { "m" } else { "l" })); + } + c.push_str("S\n"); + if !line_end(&mut c, &end, pts[0], pts[1], lw) { + return None; + } + } + if let Some(bg) = bg { + c.push_str(&format!("{}{} {} {} {} re f\n", rg(bg), n(rect[0]), n(rect[1]), n(rect[2] - rect[0]), n(rect[3] - rect[1]))); + } + if bw > 0.0 { + let h = bw / 2.0; c.push_str(&format!( - "{}{}{} w\n{} {} m {} {} l {} {} {} {} {} {} c {} {} l {} {} {} {} {} {} c {} {} l {} {} {} {} {} {} c {} {} l {} {} {} {} {} {} c h B\n", - rg(tint), - rg_stroke(col), - n(lw), - n(a0 + r), - n(b0), - n(a1 - r), - n(b0), - n(a1 - r + k), - n(b0), - n(a1), - n(b0 + r - k), - n(a1), - n(b0 + r), - n(a1), - n(b1 - r), - n(a1), - n(b1 - r + k), - n(a1 - r + k), - n(b1), - n(a1 - r), - n(b1), - n(a0 + r), - n(b1), - n(a0 + r - k), - n(b1), - n(a0), - n(b1 - r + k), - n(a0), - n(b1 - r), - n(a0), - n(b0 + r), - n(a0), - n(b0 + r - k), - n(a0 + r - k), - n(b0), - n(a0 + r), - n(b0) + "{}{} w\n{}{} {} {} {} re S\n[] 0 d\n", + rg_stroke(text_color), + n(bw), + dash(d), + n(rect[0] + h), + n(rect[1] + h), + n(rect[2] - rect[0] - bw), + n(rect[3] - rect[1] - bw) )); } - let (title_h, by_h) = if by.is_some() { (h * 0.5, h * 0.22) } else { (h * 0.52, 0.0) }; - let text_area = if sign_here { (x0 + h * 0.45, x1 - lw * 2.0) } else { (x0 + lw * 2.0, x1 - lw * 2.0) }; - let mut size = title_h; - let tw = text_width(label, size) * 1.12; - if tw > text_area.1 - text_area.0 - 4.0 { - size *= (text_area.1 - text_area.0 - 4.0) / tw; - } - let tw = text_width(label, size) * 1.12; - let text_col = if sign_here { [1.0, 1.0, 1.0] } else { col }; - let ty = if by.is_some() { y0 + h * 0.48 } else { y0 + (h - size * 0.72) / 2.0 }; + let text = d.get(b"Contents").and_then(|o| o.as_string()).map(PdfString::to_text).unwrap_or_default(); + let pad = 2.0 + bw; + let width = (rect[2] - rect[0] - 2.0 * pad).max(1.0); + let q = d.int(b"Q").unwrap_or(0); + c.push_str(&format!( + "{} {} {} {} re W n\nBT\n/Helv {} Tf\n{}", + n(rect[0]), + n(rect[1]), + n(rect[2] - rect[0]), + n(rect[3] - rect[1]), + n(size), + rg(text_color) + )); let mut out = c.into_bytes(); - out.extend( - format!("BT\n{}/HelvB {} Tf\n1 0 0 1 {} {} Tm ", rg(text_col), n(size), n(text_area.0 + (text_area.1 - text_area.0 - tw) / 2.0), n(ty)) - .bytes(), - ); - out.extend(literal(&win_ansi(label))); - out.extend_from_slice(b" Tj\n"); - if let Some(b) = by { - let mut s = by_h; - let bw = text_width(b, s); - if bw > text_area.1 - text_area.0 - 4.0 { - s *= (text_area.1 - text_area.0 - 4.0) / bw; - } - let bw = text_width(b, s); - out.extend( - format!("/Helv {} Tf\n1 0 0 1 {} {} Tm ", n(s), n(text_area.0 + (text_area.1 - text_area.0 - bw) / 2.0), n(y0 + h * 0.18)).bytes(), - ); - out.extend(literal(&win_ansi(b))); + let mut y = rect[3] - pad - size * 0.9; + for line in wrap(&text, size, width) { + if y < rect[1] - size { + break; + } + let lw = text_width(&line, size); + let x = match q { + 1 => rect[0] + pad + (width - lw) / 2.0, + 2 => rect[2] - pad - lw, + _ => rect[0] + pad, + }; + out.extend(format!("1 0 0 1 {} {} Tm ", n(x), n(y)).bytes()); + out.extend(literal(&win_ansi(&line))); out.extend_from_slice(b" Tj\n"); + y -= size * 1.2; } out.extend_from_slice(b"ET\n"); - let font = |base: &str| { - let mut f = Dict::new(); - f.set(b"Type".to_vec(), Object::name("Font")); - f.set(b"Subtype".to_vec(), Object::name("Type1")); - f.set(b"BaseFont".to_vec(), Object::name(base)); - f.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding")); - Object::Dict(f) - }; - let mut fonts = Dict::new(); - fonts.set(b"HelvB".to_vec(), font("Helvetica-Bold")); - fonts.set(b"Helv".to_vec(), font("Helvetica")); - res.set(b"Font".to_vec(), Object::Dict(fonts)); - form(rect, &out, res) -} - -/// An ellipse inscribed in a rectangle, as four Bézier arcs. -fn ellipse(x0: f64, y0: f64, x1: f64, y1: f64) -> String { - let k = 0.552_284_75; - let (cx, cy, rx, ry) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0, (x1 - x0) / 2.0, (y1 - y0) / 2.0); - let (ox, oy) = (rx * k, ry * k); - let mut s = format!("{} {} m\n", n(cx + rx), n(cy)); - for [a, b, c, d, e, f] in [ - [cx + rx, cy + oy, cx + ox, cy + ry, cx, cy + ry], - [cx - ox, cy + ry, cx - rx, cy + oy, cx - rx, cy], - [cx - rx, cy - oy, cx - ox, cy - ry, cx, cy - ry], - [cx + ox, cy - ry, cx + rx, cy - oy, cx + rx, cy], - ] { - s.push_str(&format!("{} {} {} {} {} {} c\n", n(a), n(b), n(c), n(d), n(e), n(f))); + res.set(b"Font".to_vec(), helvetica_resource()); + let mut stream = form(full, &out, res); + if let Some(m) = matrix { + stream.dict.set(b"Matrix".to_vec(), Object::Array(m.iter().copied().map(Object::Real).collect())); } - s.push_str("h\n"); - s + Some(stream) } -/// PdfCraft's note icons, drawn in a 20 × 20 box: a speech bubble for `/Comment`, a page -/// with a folded corner for everything else, both filled with the note colour. -/// File attachment icons in a 20 × 20 box: PdfCraft's own drawings. -fn attach_icon(name: &str, col: Rgb) -> String { - let mut s = format!("{}{}1.2 w 1 j 1 J\n", rg(col), rg_stroke(col)); - match name { - "Paperclip" => s.push_str("8 4 m 8 15 l 8 18 13 18 13 15 c 13 6 l 13 3 10 3 10 6 c 10 14 l S\n"), - "Graph" => s.push_str("3 3 m 3 17 l 3 3 m 17 3 l S\n5 3 3 6 re 9 3 3 10 re 13 3 3 13 re f\n"), - "Tag" => s.push_str("3 10 m 8 15 l 17 15 l 17 5 l 8 5 l h S\n7.3 10 m 7.3 10.7 6.7 11.3 6 11.3 c 5.3 11.3 4.7 10.7 4.7 10 c 4.7 9.3 5.3 8.7 6 8.7 c 6.7 8.7 7.3 9.3 7.3 10 c f\n"), - _ => { - // Push pin: a round head on a short needle. - s.push_str("10 2 m 10 9 l S\n6 9 m 14 9 l 14 11 l 12 12 l 12 16 l 13.5 17 l 13.5 18.5 l 6.5 18.5 l 6.5 17 l 8 16 l 8 12 l 6 11 l h f\n"); - } - } - s -} - -/// A closed circle path (four Bézier quarters) centred on (`x`, `y`). -fn circle_path(x: f64, y: f64, r: f64) -> String { - let k = r * 0.5523; - let p = |v: f64| n(v); - format!( - "{} {} m {} {} {} {} {} {} c {} {} {} {} {} {} c {} {} {} {} {} {} c {} {} {} {} {} {} c h\n", - p(x + r), - p(y), - p(x + r), - p(y + k), - p(x + k), - p(y + r), - p(x), - p(y + r), - p(x - k), - p(y + r), - p(x - r), - p(y + k), - p(x - r), - p(y), - p(x - r), - p(y - k), - p(x - k), - p(y - r), - p(x), - p(y - r), - p(x + k), - p(y - r), - p(x + r), - p(y - k), - p(x + r), - p(y) - ) -} - -/// A note's icon in a 20 × 20 box: filled in `col`, outlined in dark grey. Each name of -/// [`crate::NoteIcon`] has its own shape, so the icon a note names is the one it shows (ISO -/// 32000-2 §12.5.6.4, Table 175); a name PdfCraft doesn't know draws as `Note`. -fn note_icon(name: &str, col: Rgb) -> String { - let mut s = format!("{}0.25 0.25 0.25 RG 0.8 w 1 j 1 J\n", rg(col)); - match name { - "Comment" => { - s.push_str("3 18.5 m 17 18.5 l 18.5 18.5 18.5 17 18.5 17 c 18.5 8 l 18.5 6.5 17 6.5 17 6.5 c 9.5 6.5 l 5 2 l 5.5 6.5 l 3 6.5 l 1.5 6.5 1.5 8 1.5 8 c 1.5 17 l 1.5 18.5 3 18.5 3 18.5 c h B\n"); - s.push_str("4.5 15 m 15.5 15 l 4.5 12.5 m 15.5 12.5 l 4.5 10 m 11.5 10 l S\n"); - } - // A key lying flat: a round bow with a hole, a shaft and two bits. - "Key" => { - s.push_str(&circle_path(5.5, 10.0, 4.5)); - s.push_str(&circle_path(5.0, 10.0, 1.5)); - s.push_str( - "9.5 11.2 m 18.5 11.2 l 18.5 8.8 l 17 8.8 l 17 6 l 15 6 l 15 8.8 l 13.5 8.8 l 13.5 6.8 l 11.5 6.8 l 11.5 8.8 l 9.5 8.8 l h B*\n", - ); - } - // A disc with a question mark. - "Help" => { - s.push_str(&circle_path(10.0, 10.0, 8.5)); - s.push_str("B\n1.6 w 7 12.8 m 7 15.6 13 15.6 13 12.8 c 13 10.6 10 10.8 10 8.2 c S\n10 5 m 10 5.1 l S\n"); - } - // An insertion caret. - "Insert" => s.push_str("1.5 3 m 10 18 l 18.5 3 l 14.5 3 l 10 11 l 5.5 3 l h B\n"), - // A caret over a bar: a new paragraph goes here. - "NewParagraph" => s.push_str("3.5 9 m 10 18.5 l 16.5 9 l h B\n3 2 14 4 re B\n"), - // A pilcrow: a filled bowl and two stems. - "Paragraph" => { - s.push_str("11 18.5 m 7 18.5 4 16.3 4 13.2 c 4 10.1 7 8 11 8 c h B\n"); - s.push_str("11 18.5 m 16 18.5 l 16 1.5 l 14 1.5 l 14 16.5 l 13 16.5 l 13 1.5 l 11 1.5 l h B\n"); - } - // A tick. - "Check" => s.push_str("1.5 10.5 m 4.5 13.5 l 8 9.5 l 16 18.5 l 18.5 15.5 l 8 3.5 l h B\n"), - // A ring. - "Circle" => { - s.push_str(&circle_path(10.0, 10.0, 8.5)); - s.push_str(&circle_path(10.0, 10.0, 5.0)); - s.push_str("B*\n"); - } - // A thick X. - "Cross" => s.push_str("4 1.5 m 10 7.5 l 16 1.5 l 18.5 4 l 12.5 10 l 18.5 16 l 16 18.5 l 10 12.5 l 4 18.5 l 1.5 16 l 7.5 10 l 1.5 4 l h B\n"), - // A ring with sighting lines through it. - "CrossHairs" => { - s.push_str(&circle_path(10.0, 10.0, 7.0)); - s.push_str(&circle_path(10.0, 10.0, 4.5)); - s.push_str("B*\n1.2 w 10 0.5 m 10 19.5 l 0.5 10 m 19.5 10 l S\n"); - } - // A five-pointed star. - "Star" => { - let points: Vec = (0..10) - .map(|i| { - let r = if i % 2 == 0 { 9.5 } else { 3.9 }; - let a = std::f64::consts::FRAC_PI_2 + f64::from(i) * std::f64::consts::PI / 5.0; - format!("{} {}", n(10.0 + r * a.cos()), n(9.5 + r * a.sin())) - }) - .collect(); - s.push_str(&format!("{} m {} l h B\n", points[0], points[1..].join(" l "))); - } - // Block arrows. - "RightArrow" => s.push_str("1.5 7 m 11 7 l 11 2.5 l 18.5 10 l 11 17.5 l 11 13 l 1.5 13 l h B\n"), - "UpArrow" => s.push_str("7 1.5 m 7 11 l 2.5 11 l 10 18.5 l 17.5 11 l 13 11 l 13 1.5 l h B\n"), - _ => { - s.push_str("3 19 m 13 19 l 17 15 l 17 1 l 3 1 l h B\n13 19 m 13 15 l 17 15 l S\n"); - s.push_str("5.5 12 m 14.5 12 l 5.5 9 m 14.5 9 l 5.5 6 m 11.5 6 l S\n"); - } - } - s +fn draw_icon(context: &AppearanceContext<'_>, name: &str, fallback: Rgb, mut drawing: AppearanceDrawing) -> Option { + let d = context.dict; + let col = context.stroke.unwrap_or(fallback); + let icon = d.name(b"Name").map(|n| String::from_utf8_lossy(n).into_owned()).unwrap_or_else(|| name.into()); + let content = if name == "Note" { note_icon(&icon, col) } else { attach_icon(&icon, col) }; + drawing.content.push_str(&content); + Some(form([0.0, 0.0, NOTE_SIZE, NOTE_SIZE], drawing.content.as_bytes(), drawing.resources)) } diff --git a/crates/annot/src/appearance/symbols.rs b/crates/annot/src/appearance/symbols.rs new file mode 100644 index 0000000000..3b33662524 --- /dev/null +++ b/crates/annot/src/appearance/symbols.rs @@ -0,0 +1,534 @@ +use super::{Dict, Object, PdfString, Rgb, Stream, color, ext_gstate, form, literal, n, nums, rg, rg_stroke, text_width, win_ansi}; + +/// The label and colour of a standard rubber stamp name (ISO 32000-2 Table 184), for a stamp +/// another program wrote without an appearance. Names are matched byte for byte, as PDF names +/// are compared (§7.3.5). Eight are stamps of PdfCraft's palette; the other six take a label of +/// their own and the palette's colour for their meaning. +fn standard_stamp(name: &[u8]) -> Option<(&'static str, Rgb)> { + use crate::StampKind::*; + let palette = [Approved, NotApproved, NotForPublicRelease, Confidential, Final, ForComment, Draft, ForPublicRelease]; + if let Some(kind) = palette.into_iter().find(|k| k.name().as_bytes() == name) { + return Some((kind.label(), kind.color())); + } + let (label, like) = match name { + b"Experimental" => ("EXPERIMENTAL", Draft), + b"AsIs" => ("AS IS", Draft), + b"Expired" => ("EXPIRED", NotApproved), + b"Sold" => ("SOLD", Approved), + b"Departmental" => ("DEPARTMENTAL", Draft), + b"TopSecret" => ("TOP SECRET", Confidential), + _ => return None, + }; + Some((label, like.color())) +} + +/// The neutral grey of a stamp whose name has no drawing. +const PLACEHOLDER_GREY: Rgb = [0.45, 0.45, 0.45]; + +/// The display appearance of a rubber stamp another program wrote without one: the stamp its +/// `/Name` names (Draft when there is none, Table 184's default) in its `/C` colour or the +/// stamp's own, or for any other name a thin grey frame with both diagonals, so the stamp stays +/// visible and can be selected. The frame is grey whatever the `/C`: it stands in for a stamp +/// that cannot be drawn, so it takes none of that stamp's colour. The name is never drawn as text. +/// +/// For display only ([`crate::with_missing_appearances`]): [`build`] declines these stamps, so +/// edits (restyling, flattening) never write this drawing into the file. `None` for any other +/// annotation, one of PdfCraft's own stamps, a dictionary with an `/AP` (a stamp's own artwork is +/// never replaced; the display copy passes the dictionary without an `/AP` that has no normal +/// appearance), or a `/Rect` that isn't finite or is under a point wide or high. +pub(crate) fn display_stamp(d: &Dict) -> Option { + let own = matches!(d.get(b"PCStamp"), Some(Object::Bool(true))) || d.contains(b"PCPicture") || d.contains(b"PCOutline"); + if d.name(b"Subtype") != Some(b"Stamp") || own || d.contains(b"AP") { + return None; + } + let rect = nums(d, b"Rect")?; + let [a, b, c, e] = rect.as_slice() else { return None }; + let rect = [a.min(*c), b.min(*e), a.max(*c), b.max(*e)]; + if !rect.iter().all(|v| v.is_finite()) || rect[2] - rect[0] < 1.0 || rect[3] - rect[1] < 1.0 { + return None; + } + let opacity = d.get(b"CA").and_then(|o| o.as_f64()).unwrap_or(1.0).clamp(0.0, 1.0); + // A malformed `/C` falls back to the stamp's own colour rather than hiding the stamp. + let stroke = color(d, b"C").flatten(); + let mut res = Dict::new(); + if let Some(gs) = ext_gstate(opacity, false) { + res.set(b"ExtGState".to_vec(), Object::Dict(gs)); + } + // A stamp whose intent (`/IT`, PDF 2.0) is an image or a snapshot has no `/Name` to default. + let stamp_intent = d.name(b"IT").is_none_or(|it| it == b"Stamp"); + let name = d.name(b"Name").or(stamp_intent.then_some(b"Draft".as_slice())); + if let Some((label, col)) = name.and_then(standard_stamp) { + return Some(stamp(label, false, rect, stroke.unwrap_or(col), None, opacity, res)); + } + let [x0, y0, x1, y1] = rect; + let (a0, b0, a1, b1) = (x0 + 0.5, y0 + 0.5, x1 - 0.5, y1 - 0.5); + let mut c = String::new(); + if opacity < 1.0 { + c.push_str("/GS0 gs\n"); + } + c.push_str(&format!( + "{}1 w\n{} {} {} {} re\n{} {} m {} {} l\n{} {} m {} {} l\nS\n", + // Grey whatever the `/C`: the placeholder is not the stamp, so it takes none of its colour. + rg_stroke(PLACEHOLDER_GREY), + n(a0), + n(b0), + n(a1 - a0), + n(b1 - b0), + n(a0), + n(b0), + n(a1), + n(b1), + n(a0), + n(b1), + n(a1), + n(b0) + )); + Some(form(rect, c.as_bytes(), res)) +} + +/// Size of a line's caption, as of a measurement's label. +pub(super) const CAPTION_SIZE: f64 = 10.0; + +/// Characters of a line's caption drawn at most, as for a measurement's label. +const MAX_CAPTION_CHARS: usize = 256; + +/// Leader lengths and caption offsets (Table 178) are held within this many points (200 inches, +/// the largest page side ISO 32000-2 Annex C allows), so an absurd value can't overflow. +const MAX_LINE_EXTENT: f64 = 14_400.0; + +/// A Table 178 length or offset: finite and within [`MAX_LINE_EXTENT`], 0 when absent or not one. +fn line_length(v: Option) -> f64 { + v.filter(|v| v.is_finite()).map_or(0.0, |v| v.clamp(-MAX_LINE_EXTENT, MAX_LINE_EXTENT)) +} + +/// The caption a Line with `/Cap true` shows (ISO 32000-2 Table 178): its `/Contents`, else the +/// plain text of its `/RC`, on one line and cut to [`MAX_CAPTION_CHARS`]. `None` without a +/// caption, and for PdfCraft's own measurements, whose label (`/PCMeasureValue`) is the caption. +pub(super) fn line_caption(d: &Dict) -> Option { + if !matches!(d.get(b"Cap"), Some(Object::Bool(true))) || d.contains(b"PCMeasureValue") { + return None; + } + let text = |key: &[u8]| d.get(key).and_then(Object::as_string).map(PdfString::to_text); + let raw = text(b"Contents").filter(|t| !t.trim().is_empty()).or_else(|| text(b"RC").map(|rc| rich_plain(&rc)))?; + let words: Vec<&str> = raw.split_whitespace().collect(); + let caption: String = words.join(" ").chars().filter(|c| !c.is_control()).take(MAX_CAPTION_CHARS).collect(); + (!caption.is_empty()).then_some(caption) +} + +/// The text of rich text (§12.7.4.3, XHTML): its markup dropped, the five XML entities and +/// numeric character references read, block ends read as spaces. +fn rich_plain(rc: &str) -> String { + let mut out = String::new(); + let mut tag: Option = None; + // Rich text past this is not caption material; it bounds the work. + for ch in rc.chars().take(MAX_CAPTION_CHARS * 64) { + match (&mut tag, ch) { + (None, '<') => tag = Some(String::new()), + (Some(name), '>') => { + let name = + name.trim_start_matches('/').split(|c: char| c.is_whitespace() || c == '/').next().unwrap_or_default().to_ascii_lowercase(); + if matches!(name.as_str(), "p" | "br" | "div" | "li") { + out.push(' '); + } + tag = None; + } + (Some(name), c) => { + if name.len() < 16 { + name.push(c); + } + } + (None, c) => out.push(c), + } + } + let mut text = String::new(); + let mut rest = out.as_str(); + while let Some(at) = rest.find('&') { + text.push_str(rest.get(..at).unwrap_or_default()); + let after = rest.get(at + 1..).unwrap_or_default(); + let entity = after.find(';').filter(|&end| end <= 10).and_then(|end| Some((after.get(..end)?, end))); + let decoded = entity.and_then(|(name, end)| { + let c = match name { + "amp" => '&', + "lt" => '<', + "gt" => '>', + "quot" => '"', + "apos" => '\'', + _ => { + let num = name.strip_prefix('#')?; + let code = match num.strip_prefix(['x', 'X']) { + Some(hex) => u32::from_str_radix(hex, 16).ok()?, + None => num.parse().ok()?, + }; + char::from_u32(code)? + } + }; + Some((c, end)) + }); + match decoded { + Some((c, end)) => { + text.push(c); + rest = after.get(end + 1..).unwrap_or_default(); + } + None => { + text.push('&'); + rest = after; + } + } + } + text.push_str(rest); + text +} + +/// Where a Line annotation's parts go (ISO 32000-2 §12.5.6.7, Table 178). +#[derive(Debug, Default)] +pub(super) struct LineLayout { + /// The line itself, start to end: `/L`, or `/L` moved out by its leader lines. + pub(super) line: [(f64, f64); 2], + /// Strokes along the line: the whole line, or the two pieces either side of an inline caption. + pub(super) strokes: Vec<[(f64, f64); 2]>, + /// The leader lines, from `/L` (past the `/LLO` gap) to the line and on by `/LLE`. + pub(super) leaders: Vec<[(f64, f64); 2]>, + /// A short stroke from the line to a caption `/CO` moved off it. + pub(super) tick: Option<[(f64, f64); 2]>, + /// The caption and its text matrix. + pub(super) caption: Option<(String, [f64; 6])>, +} + +/// Lay out a Line annotation from `/L` = `[a b]`, line width `w`, and its leader and caption +/// entries. +/// +/// Leader lines: a positive `/LL` puts the line on the left of travel from `a` to `b` (above `/L` +/// for a line drawn left to right), so the leader lines, followed from the line to `/L`, turn +/// clockwise from the direction of travel; a negative one puts it on the right. The line sits +/// `|LL| + LLO` from `/L`. `/LLE` and `/LLO` are non-negative: a negative one counts as 0. +/// +/// Caption (`/Cap true`): centred on the line's midpoint and turned with it so it reads left to +/// right (bottom to top for a vertical line). `/CP /Inline` (the default) centres it on the line, +/// which is broken around it; `/Top` sets it on top of the unbroken line. `/CO [h v]` moves it `h` +/// along the line and `v` across it (positive: up in reading direction); a caption moved off an +/// inline line gets a tick from the line to it. +/// +/// A zero-length or overflowing `/L` has no direction: it gets neither leader lines nor caption. +/// Every point is finite; if anything derived from the entries is not, they are left out. +pub(super) fn line_layout(d: &Dict, [a, b]: [(f64, f64); 2], w: f64) -> LineLayout { + let plain = LineLayout { line: [a, b], strokes: vec![[a, b]], ..LineLayout::default() }; + let (dx, dy) = (b.0 - a.0, b.1 - a.1); + let len = dx.hypot(dy); + if !len.is_finite() || len <= 0.0 { + return plain; + } + let (ux, uy) = (dx / len, dy / len); + let mut out = LineLayout { line: [a, b], ..LineLayout::default() }; + let ll = line_length(d.get(b"LL").and_then(Object::as_f64)); + if ll != 0.0 { + // The unit normal from `/L` towards the line: left of travel for a positive /LL. + let s = ll.signum(); + let (nx, ny) = (-uy * s, ux * s); + let llo = line_length(d.get(b"LLO").and_then(Object::as_f64)).max(0.0); + let lle = line_length(d.get(b"LLE").and_then(Object::as_f64)).max(0.0); + let at = |p: (f64, f64), k: f64| (p.0 + nx * k, p.1 + ny * k); + let off = ll.abs() + llo; + out.line = [at(a, off), at(b, off)]; + out.leaders = vec![[at(a, llo), at(a, off + lle)], [at(b, llo), at(b, off + lle)]]; + } + let [p, _] = out.line; + let along = |t: f64| (p.0 + ux * t, p.1 + uy * t); + let mut gap = None; + if let Some(text) = line_caption(d) { + let size = CAPTION_SIZE; + let width = text_width(&text, size); + // Reading direction, and "up" across it. + let (tx, ty) = if ux < 0.0 || (ux == 0.0 && uy < 0.0) { (-ux, -uy) } else { (ux, uy) }; + let (vx, vy) = (-ty, tx); + let co = nums(d, b"CO").filter(|c| c.len() == 2).unwrap_or_default(); + let h = line_length(co.first().copied()); + let v = line_length(co.get(1).copied()); + let top = d.name(b"CP") == Some(b"Top"); + // The caption's centre, as a distance along the line from `p`. + let centre = len / 2.0 + h * (tx * ux + ty * uy); + let mid = along(centre); + // Baseline across the line: on top of it, or with the text centred on it. + let base = if top { w.max(0.0) / 2.0 + 2.0 + v } else { v - size * 0.35 }; + let origin = (mid.0 - tx * width / 2.0 + vx * base, mid.1 - ty * width / 2.0 + vy * base); + out.caption = Some((text, [tx, ty, -ty, tx, origin.0, origin.1])); + if !top && v.abs() < size / 2.0 { + let half = width / 2.0 + 2.0; + gap = Some(((centre - half).clamp(0.0, len), (centre + half).clamp(0.0, len))); + } else if !top { + let reach = v - v.signum() * size * 0.4; + out.tick = Some([mid, (mid.0 + vx * reach, mid.1 + vy * reach)]); + } + } + out.strokes = match gap { + Some((g0, g1)) => [(0.0, g0), (g1, len)].into_iter().filter(|(s, e)| e > s).map(|(s, e)| [along(s), along(e)]).collect(), + None => vec![out.line], + }; + let finite = |q: &(f64, f64)| q.0.is_finite() && q.1.is_finite(); + let all_finite = + out.line.iter().chain(out.strokes.iter().flatten()).chain(out.leaders.iter().flatten()).chain(out.tick.iter().flatten()).all(finite) + && out.caption.as_ref().is_none_or(|(_, m)| m.iter().all(|x| x.is_finite())); + if all_finite { out } else { plain } +} + +/// A rubber stamp: a rounded frame (a pointed tag for sign-here stamps) with the label in bold +/// capitals, and the dynamic stamps' "By … at …" line. +pub(super) fn stamp(label: &str, sign_here: bool, rect: [f64; 4], col: Rgb, by: Option<&str>, opacity: f64, mut res: Dict) -> Stream { + let [x0, y0, x1, y1] = rect; + let h = y1 - y0; + let mut c = String::new(); + if opacity < 1.0 { + c.push_str("/GS0 gs\n"); + } + let lw = (h * 0.07).clamp(1.0, 3.0); + let inset = lw / 2.0 + 0.5; + if sign_here { + // A tag pointing left, filled, with white text. + let tip = h * 0.45; + c.push_str(&format!( + "{}{}{} w 1 j\n{} {} m {} {} l {} {} l {} {} l {} {} l h B\n", + rg(col), + rg_stroke(col), + n(lw), + n(x0 + inset), + n(y0 + h / 2.0), + n(x0 + tip), + n(y1 - inset), + n(x1 - inset), + n(y1 - inset), + n(x1 - inset), + n(y0 + inset), + n(x0 + tip), + n(y0 + inset) + )); + } else { + // A rounded frame with a light tint. + let r = (h * 0.2).min(8.0); + let k = 0.552_284_75 * r; + let (a0, b0, a1, b1) = (x0 + inset, y0 + inset, x1 - inset, y1 - inset); + let tint = col.map(|v| 1.0 - (1.0 - v) * 0.12); + c.push_str(&format!( + "{}{}{} w\n{} {} m {} {} l {} {} {} {} {} {} c {} {} l {} {} {} {} {} {} c {} {} l {} {} {} {} {} {} c {} {} l {} {} {} {} {} {} c h B\n", + rg(tint), + rg_stroke(col), + n(lw), + n(a0 + r), + n(b0), + n(a1 - r), + n(b0), + n(a1 - r + k), + n(b0), + n(a1), + n(b0 + r - k), + n(a1), + n(b0 + r), + n(a1), + n(b1 - r), + n(a1), + n(b1 - r + k), + n(a1 - r + k), + n(b1), + n(a1 - r), + n(b1), + n(a0 + r), + n(b1), + n(a0 + r - k), + n(b1), + n(a0), + n(b1 - r + k), + n(a0), + n(b1 - r), + n(a0), + n(b0 + r), + n(a0), + n(b0 + r - k), + n(a0 + r - k), + n(b0), + n(a0 + r), + n(b0) + )); + } + let (title_h, by_h) = if by.is_some() { (h * 0.5, h * 0.22) } else { (h * 0.52, 0.0) }; + let text_area = if sign_here { (x0 + h * 0.45, x1 - lw * 2.0) } else { (x0 + lw * 2.0, x1 - lw * 2.0) }; + let mut size = title_h; + let tw = text_width(label, size) * 1.12; + if tw > text_area.1 - text_area.0 - 4.0 { + size *= (text_area.1 - text_area.0 - 4.0) / tw; + } + let tw = text_width(label, size) * 1.12; + let text_col = if sign_here { [1.0, 1.0, 1.0] } else { col }; + let ty = if by.is_some() { y0 + h * 0.48 } else { y0 + (h - size * 0.72) / 2.0 }; + let mut out = c.into_bytes(); + out.extend( + format!("BT\n{}/HelvB {} Tf\n1 0 0 1 {} {} Tm ", rg(text_col), n(size), n(text_area.0 + (text_area.1 - text_area.0 - tw) / 2.0), n(ty)) + .bytes(), + ); + out.extend(literal(&win_ansi(label))); + out.extend_from_slice(b" Tj\n"); + if let Some(b) = by { + let mut s = by_h; + let bw = text_width(b, s); + if bw > text_area.1 - text_area.0 - 4.0 { + s *= (text_area.1 - text_area.0 - 4.0) / bw; + } + let bw = text_width(b, s); + out.extend( + format!("/Helv {} Tf\n1 0 0 1 {} {} Tm ", n(s), n(text_area.0 + (text_area.1 - text_area.0 - bw) / 2.0), n(y0 + h * 0.18)).bytes(), + ); + out.extend(literal(&win_ansi(b))); + out.extend_from_slice(b" Tj\n"); + } + out.extend_from_slice(b"ET\n"); + let font = |base: &str| { + let mut f = Dict::new(); + f.set(b"Type".to_vec(), Object::name("Font")); + f.set(b"Subtype".to_vec(), Object::name("Type1")); + f.set(b"BaseFont".to_vec(), Object::name(base)); + f.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding")); + Object::Dict(f) + }; + let mut fonts = Dict::new(); + fonts.set(b"HelvB".to_vec(), font("Helvetica-Bold")); + fonts.set(b"Helv".to_vec(), font("Helvetica")); + res.set(b"Font".to_vec(), Object::Dict(fonts)); + form(rect, &out, res) +} + +/// An ellipse inscribed in a rectangle, as four Bézier arcs. +pub(super) fn ellipse(x0: f64, y0: f64, x1: f64, y1: f64) -> String { + let k = 0.552_284_75; + let (cx, cy, rx, ry) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0, (x1 - x0) / 2.0, (y1 - y0) / 2.0); + let (ox, oy) = (rx * k, ry * k); + let mut s = format!("{} {} m\n", n(cx + rx), n(cy)); + for [a, b, c, d, e, f] in [ + [cx + rx, cy + oy, cx + ox, cy + ry, cx, cy + ry], + [cx - ox, cy + ry, cx - rx, cy + oy, cx - rx, cy], + [cx - rx, cy - oy, cx - ox, cy - ry, cx, cy - ry], + [cx + ox, cy - ry, cx + rx, cy - oy, cx + rx, cy], + ] { + s.push_str(&format!("{} {} {} {} {} {} c\n", n(a), n(b), n(c), n(d), n(e), n(f))); + } + s.push_str("h\n"); + s +} + +/// PdfCraft's note icons, drawn in a 20 × 20 box: a speech bubble for `/Comment`, a page +/// with a folded corner for everything else, both filled with the note colour. +/// File attachment icons in a 20 × 20 box: PdfCraft's own drawings. +pub(super) fn attach_icon(name: &str, col: Rgb) -> String { + let mut s = format!("{}{}1.2 w 1 j 1 J\n", rg(col), rg_stroke(col)); + match name { + "Paperclip" => s.push_str("8 4 m 8 15 l 8 18 13 18 13 15 c 13 6 l 13 3 10 3 10 6 c 10 14 l S\n"), + "Graph" => s.push_str("3 3 m 3 17 l 3 3 m 17 3 l S\n5 3 3 6 re 9 3 3 10 re 13 3 3 13 re f\n"), + "Tag" => s.push_str("3 10 m 8 15 l 17 15 l 17 5 l 8 5 l h S\n7.3 10 m 7.3 10.7 6.7 11.3 6 11.3 c 5.3 11.3 4.7 10.7 4.7 10 c 4.7 9.3 5.3 8.7 6 8.7 c 6.7 8.7 7.3 9.3 7.3 10 c f\n"), + _ => { + // Push pin: a round head on a short needle. + s.push_str("10 2 m 10 9 l S\n6 9 m 14 9 l 14 11 l 12 12 l 12 16 l 13.5 17 l 13.5 18.5 l 6.5 18.5 l 6.5 17 l 8 16 l 8 12 l 6 11 l h f\n"); + } + } + s +} + +/// A closed circle path (four Bézier quarters) centred on (`x`, `y`). +fn circle_path(x: f64, y: f64, r: f64) -> String { + let k = r * 0.5523; + let p = |v: f64| n(v); + format!( + "{} {} m {} {} {} {} {} {} c {} {} {} {} {} {} c {} {} {} {} {} {} c {} {} {} {} {} {} c h\n", + p(x + r), + p(y), + p(x + r), + p(y + k), + p(x + k), + p(y + r), + p(x), + p(y + r), + p(x - k), + p(y + r), + p(x - r), + p(y + k), + p(x - r), + p(y), + p(x - r), + p(y - k), + p(x - k), + p(y - r), + p(x), + p(y - r), + p(x + k), + p(y - r), + p(x + r), + p(y - k), + p(x + r), + p(y) + ) +} + +/// A note's icon in a 20 × 20 box: filled in `col`, outlined in dark grey. Each name of +/// [`crate::NoteIcon`] has its own shape, so the icon a note names is the one it shows (ISO +/// 32000-2 §12.5.6.4, Table 175); a name PdfCraft doesn't know draws as `Note`. +pub(super) fn note_icon(name: &str, col: Rgb) -> String { + let mut s = format!("{}0.25 0.25 0.25 RG 0.8 w 1 j 1 J\n", rg(col)); + match name { + "Comment" => { + s.push_str("3 18.5 m 17 18.5 l 18.5 18.5 18.5 17 18.5 17 c 18.5 8 l 18.5 6.5 17 6.5 17 6.5 c 9.5 6.5 l 5 2 l 5.5 6.5 l 3 6.5 l 1.5 6.5 1.5 8 1.5 8 c 1.5 17 l 1.5 18.5 3 18.5 3 18.5 c h B\n"); + s.push_str("4.5 15 m 15.5 15 l 4.5 12.5 m 15.5 12.5 l 4.5 10 m 11.5 10 l S\n"); + } + // A key lying flat: a round bow with a hole, a shaft and two bits. + "Key" => { + s.push_str(&circle_path(5.5, 10.0, 4.5)); + s.push_str(&circle_path(5.0, 10.0, 1.5)); + s.push_str( + "9.5 11.2 m 18.5 11.2 l 18.5 8.8 l 17 8.8 l 17 6 l 15 6 l 15 8.8 l 13.5 8.8 l 13.5 6.8 l 11.5 6.8 l 11.5 8.8 l 9.5 8.8 l h B*\n", + ); + } + // A disc with a question mark. + "Help" => { + s.push_str(&circle_path(10.0, 10.0, 8.5)); + s.push_str("B\n1.6 w 7 12.8 m 7 15.6 13 15.6 13 12.8 c 13 10.6 10 10.8 10 8.2 c S\n10 5 m 10 5.1 l S\n"); + } + // An insertion caret. + "Insert" => s.push_str("1.5 3 m 10 18 l 18.5 3 l 14.5 3 l 10 11 l 5.5 3 l h B\n"), + // A caret over a bar: a new paragraph goes here. + "NewParagraph" => s.push_str("3.5 9 m 10 18.5 l 16.5 9 l h B\n3 2 14 4 re B\n"), + // A pilcrow: a filled bowl and two stems. + "Paragraph" => { + s.push_str("11 18.5 m 7 18.5 4 16.3 4 13.2 c 4 10.1 7 8 11 8 c h B\n"); + s.push_str("11 18.5 m 16 18.5 l 16 1.5 l 14 1.5 l 14 16.5 l 13 16.5 l 13 1.5 l 11 1.5 l h B\n"); + } + // A tick. + "Check" => s.push_str("1.5 10.5 m 4.5 13.5 l 8 9.5 l 16 18.5 l 18.5 15.5 l 8 3.5 l h B\n"), + // A ring. + "Circle" => { + s.push_str(&circle_path(10.0, 10.0, 8.5)); + s.push_str(&circle_path(10.0, 10.0, 5.0)); + s.push_str("B*\n"); + } + // A thick X. + "Cross" => s.push_str("4 1.5 m 10 7.5 l 16 1.5 l 18.5 4 l 12.5 10 l 18.5 16 l 16 18.5 l 10 12.5 l 4 18.5 l 1.5 16 l 7.5 10 l 1.5 4 l h B\n"), + // A ring with sighting lines through it. + "CrossHairs" => { + s.push_str(&circle_path(10.0, 10.0, 7.0)); + s.push_str(&circle_path(10.0, 10.0, 4.5)); + s.push_str("B*\n1.2 w 10 0.5 m 10 19.5 l 0.5 10 m 19.5 10 l S\n"); + } + // A five-pointed star. + "Star" => { + let points: Vec = (0..10) + .map(|i| { + let r = if i % 2 == 0 { 9.5 } else { 3.9 }; + let a = std::f64::consts::FRAC_PI_2 + f64::from(i) * std::f64::consts::PI / 5.0; + format!("{} {}", n(10.0 + r * a.cos()), n(9.5 + r * a.sin())) + }) + .collect(); + s.push_str(&format!("{} m {} l h B\n", points[0], points[1..].join(" l "))); + } + // Block arrows. + "RightArrow" => s.push_str("1.5 7 m 11 7 l 11 2.5 l 18.5 10 l 11 17.5 l 11 13 l 1.5 13 l h B\n"), + "UpArrow" => s.push_str("7 1.5 m 7 11 l 2.5 11 l 10 18.5 l 17.5 11 l 13 11 l 13 1.5 l h B\n"), + _ => { + s.push_str("3 19 m 13 19 l 17 15 l 17 1 l 3 1 l h B\n13 19 m 13 15 l 17 15 l S\n"); + s.push_str("5.5 12 m 14.5 12 l 5.5 9 m 14.5 9 l 5.5 6 m 11.5 6 l S\n"); + } + } + s +} diff --git a/crates/annot/src/create.rs b/crates/annot/src/create.rs new file mode 100644 index 0000000000..a6db5b940b --- /dev/null +++ b/crates/annot/src/create.rs @@ -0,0 +1,485 @@ +use super::draw::draw_appearance; +use super::lookup::{annot_dict, annot_ref, annots, fill_sign_shape, page_ref, set_annots}; +use super::modify::{n, rich_text}; +use super::types::{ + FLAG_NO_ROTATE, FLAG_NO_ZOOM, FLAG_PRINT, LineEnding, Markup, Meta, NOTE_SIZE, NewAnnotation, OverlayLook, Rgb, Shape, StampGroup, Style, +}; +use super::{AnnotError, Dict, Document, ObjRef, Object, PdfString, appearance}; + +pub(crate) fn num_array(v: &[f64]) -> Object { + Object::Array(v.iter().map(|x| Object::Real(*x)).collect()) +} + +pub(crate) fn rgb(c: Rgb) -> Object { + num_array(&c.map(|x| x.clamp(0.0, 1.0))) +} + +pub(crate) fn finite(v: &[f64]) -> bool { + v.iter().all(|x| x.is_finite() && x.abs() < 1e7) +} + +pub(crate) fn normalize(r: [f64; 4]) -> [f64; 4] { + [r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])] +} + +pub(crate) fn bounds(points: impl Iterator) -> Option<[f64; 4]> { + points.fold(None, |acc, [x, y]| match acc { + None => Some([x, y, x, y]), + Some([a, b, c, d]) => Some([a.min(x), b.min(y), c.max(x), d.max(y)]), + }) +} + +pub(crate) fn grow(r: [f64; 4], by: f64) -> [f64; 4] { + [r[0] - by, r[1] - by, r[2] + by, r[3] + by] +} + +pub(crate) fn ending_draws(ending: LineEnding) -> bool { + ending != LineEnding::None +} + +pub(crate) fn ending_pair(start: LineEnding, end: LineEnding) -> Object { + Object::Array(vec![Object::name(start.name()), Object::name(end.name())]) +} + +/// Validate and compute `/Rect` for a new comment. +pub(crate) fn rect_for(shape: &Shape, style: &Style) -> Result<[f64; 4], AnnotError> { + let bad = |what: &str| AnnotError::Invalid(format!("invalid {what}")); + let half = style.width.max(0.0) / 2.0; + let r = match shape { + Shape::Note { at, .. } | Shape::Attachment { at, .. } => { + if !finite(at) { + return Err(bad("position")); + } + [at[0], at[1] - NOTE_SIZE, at[0] + NOTE_SIZE, at[1]] + } + Shape::TextMarkup { quads, .. } | Shape::Redact { quads, .. } => { + if quads.is_empty() || !quads.iter().all(|q| finite(q)) { + return Err(bad("text area (no quadrilaterals)")); + } + bounds(quads.iter().flat_map(|q| q.as_chunks::<2>().0.iter().map(|p| [p[0], p[1]]).collect::>())) + .ok_or_else(|| bad("text area"))? + } + Shape::Rectangle { rect } + | Shape::Oval { rect } + | Shape::TextBox { rect, .. } + | Shape::Typewriter { rect, .. } + | Shape::Stamp { rect, .. } + | Shape::TypedSignature { rect, .. } + | Shape::Mark { rect, .. } => { + let r = normalize(*rect); + if !finite(rect) || r[2] - r[0] < 1.0 || r[3] - r[1] < 1.0 { + return Err(bad("rectangle (too small)")); + } + r + } + Shape::CustomStamp { rect, .. } => { + let r = normalize(*rect); + // Image signatures may be very thin; a positive PDF appearance box still works. + if !finite(rect) || r[2] <= r[0] || r[3] <= r[1] { + return Err(bad("rectangle (empty)")); + } + r + } + Shape::Line { from, to, start, end } => { + if !finite(from) || !finite(to) || (from[0] - to[0]).hypot(from[1] - to[1]) < 1.0 { + return Err(bad("line (too short)")); + } + let pad = half + if ending_draws(*start) || ending_draws(*end) { appearance::arrow_size(style.width) } else { 0.0 }; + grow(bounds([*from, *to].into_iter()).unwrap_or_default(), pad + 1.0) + } + Shape::Ink { strokes } | Shape::Signature { strokes } => { + if strokes.iter().all(Vec::is_empty) || !strokes.iter().flatten().all(|p| finite(p)) { + return Err(bad("drawing (no points)")); + } + // Strokes of three or more points are drawn as curves, which stay within their + // points and control points. + let controls = strokes.iter().filter(|s| s.len() > 2).flat_map(|s| { + let pts: Vec<(f64, f64)> = s.iter().map(|p| (p[0], p[1])).collect(); + appearance::smooth_segments(&pts).into_iter().flat_map(|[a, b, _]| [[a.0, a.1], [b.0, b.1]]) + }); + grow(bounds(strokes.iter().flatten().copied().chain(controls)).unwrap_or_default(), half + 1.0) + } + Shape::Polygon { vertices, cloud } => { + let b = bounds(vertices.iter().copied()) + .filter(|b| vertices.len() >= 3 && vertices.iter().all(|p| finite(p)) && b[2] - b[0] >= 1.0 && b[3] - b[1] >= 1.0); + let b = b.ok_or_else(|| bad("polygon (it needs three points)"))?; + grow(b, half + 1.0 + if *cloud { 1.5 * appearance::cloud_radius(style.width) } else { 0.0 }) + } + Shape::PolyLine { vertices, start, end } => { + let b = bounds(vertices.iter().copied()) + .filter(|b| vertices.len() >= 2 && vertices.iter().all(|p| finite(p)) && (b[2] - b[0]).max(b[3] - b[1]) >= 1.0); + let extra = if ending_draws(*start) || ending_draws(*end) { appearance::arrow_size(style.width) } else { 0.0 }; + grow(b.ok_or_else(|| bad("connected lines (they need two points)"))?, half + 1.0 + extra) + } + Shape::Callout { rect, knee, point, .. } => { + let r = normalize(*rect); + if !finite(rect) || !finite(knee) || !finite(point) || r[2] - r[0] < 1.0 || r[3] - r[1] < 1.0 { + return Err(bad("callout")); + } + let pad = half + appearance::arrow_size(style.width) + 1.0; + let lead = grow(bounds([*knee, *point].into_iter()).unwrap_or_default(), pad); + [r[0].min(lead[0]), r[1].min(lead[1]), r[2].max(lead[2]), r[3].max(lead[3])] + } + Shape::Caret { rect } => { + let r = normalize(*rect); + if !finite(rect) || r[2] - r[0] < 1.0 || r[3] - r[1] < 1.0 { + return Err(bad("caret")); + } + r + } + }; + Ok(r) +} + +pub(crate) fn base_dict(subtype: &str, rect: [f64; 4], page: ObjRef, contents: &str, author: &str, meta: &Meta) -> Dict { + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("Annot")); + d.set(b"Subtype".to_vec(), Object::name(subtype)); + d.set(b"Rect".to_vec(), num_array(&rect)); + d.set(b"Contents".to_vec(), PdfString::text(contents)); + if !author.is_empty() { + d.set(b"T".to_vec(), PdfString::text(author)); + } + if let Some(date) = &meta.date { + d.set(b"M".to_vec(), PdfString::literal(date.as_bytes().to_vec())); + d.set(b"CreationDate".to_vec(), PdfString::literal(date.as_bytes().to_vec())); + } + if !meta.id.is_empty() { + d.set(b"NM".to_vec(), PdfString::text(&meta.id)); + } + d.set(b"P".to_vec(), Object::Ref(page)); + d.set(b"F".to_vec(), Object::Int(FLAG_PRINT)); + d +} + +/// Acrobat's `/Subj` for each tool (shown as the comment type in other viewers). +fn subject(shape: &Shape) -> &'static str { + match shape { + Shape::Note { .. } => "Sticky Note", + Shape::TextMarkup { kind: Markup::Highlight, .. } => "Highlight", + Shape::TextMarkup { kind: Markup::Underline, .. } => "Underline", + Shape::TextMarkup { kind: Markup::StrikeOut, .. } => "Strikethrough", + Shape::TextMarkup { kind: Markup::Squiggly, .. } => "Squiggly", + Shape::Rectangle { .. } => "Rectangle", + Shape::Oval { .. } => "Oval", + Shape::Line { start: LineEnding::None, end: LineEnding::OpenArrow, .. } => "Arrow", + Shape::Line { .. } => "Line", + Shape::Ink { .. } => "Pencil", + Shape::TextBox { .. } => "Text Box", + Shape::Typewriter { .. } => "Typewriter", + Shape::Mark { mark, .. } => mark.label(), + Shape::Signature { .. } => "Signature", + Shape::Redact { .. } => "Redact", + Shape::Stamp { stamp, .. } => match stamp.group() { + StampGroup::Dynamic => "Dynamic stamp", + StampGroup::SignHere => "Sign Here", + StampGroup::StandardBusiness => "Stamp", + }, + Shape::CustomStamp { .. } => "Stamp", + Shape::TypedSignature { .. } => "Signature", + Shape::Polygon { cloud: true, .. } => "Cloud", + Shape::Polygon { .. } => "Polygon", + Shape::PolyLine { .. } => "Polygonal Line", + Shape::Callout { .. } => "Callout", + Shape::Caret { .. } => "Inserted Text", + Shape::Attachment { .. } => "File Attachment", + } +} + +/// Where a callout's leader line meets its text box: the middle of the side facing `knee`. +pub fn callout_attach(rect: [f64; 4], knee: [f64; 2]) -> [f64; 2] { + let [x0, y0, x1, y1] = normalize(rect); + let (cx, cy) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0); + if knee[0] < x0 { + [x0, cy] + } else if knee[0] > x1 { + [x1, cy] + } else if knee[1] > y1 { + [cx, y1] + } else { + [cx, y0] + } +} + +/// Add a comment; returns its index in the page's `/Annots`. +pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> Result { + let page = page_ref(doc, new.page)?; + let style = &new.style; + validate_style(style)?; + let rect = rect_for(&new.shape, style)?; + let mut d = base_dict(new.shape.subtype(), rect, page, &new.contents, &new.author, meta); + d.set(b"Subj".to_vec(), PdfString::text(subject(&new.shape))); + let opacity = style.opacity.clamp(0.0, 1.0); + set_non_opaque_style(&mut d, opacity); + let border = |d: &mut Dict| { + let mut bs = Dict::new(); + bs.set(b"W".to_vec(), Object::Real(style.width.max(0.0))); + bs.set(b"S".to_vec(), Object::name("S")); + d.set(b"BS".to_vec(), Object::Dict(bs)); + }; + let mut popup = None; + match &new.shape { + Shape::Note { icon, .. } => { + d.set(b"C".to_vec(), rgb(style.color)); + d.set(b"Name".to_vec(), Object::name(icon.name())); + d.set(b"F".to_vec(), Object::Int(FLAG_PRINT | FLAG_NO_ZOOM | FLAG_NO_ROTATE)); + d.set(b"Open".to_vec(), Object::Bool(false)); + popup = Some([rect[2] + 10.0, rect[3] - 120.0, rect[2] + 210.0, rect[3]]); + } + Shape::TextMarkup { quads, .. } => { + d.set(b"C".to_vec(), rgb(style.color)); + d.set(b"QuadPoints".to_vec(), num_array(&quads.concat())); + } + Shape::Redact { quads, overlay, look } => set_redaction_properties(&mut d, style, quads, overlay, look), + Shape::Rectangle { .. } | Shape::Oval { .. } => { + d.set(b"C".to_vec(), rgb(style.color)); + if let Some(f) = style.fill { + d.set(b"IC".to_vec(), rgb(f)); + } + border(&mut d); + } + Shape::Line { from, to, start, end } => { + d.set(b"C".to_vec(), rgb(style.color)); + d.set(b"L".to_vec(), num_array(&[from[0], from[1], to[0], to[1]])); + if ending_draws(*start) || ending_draws(*end) { + d.set(b"LE".to_vec(), ending_pair(*start, *end)); + } + border(&mut d); + } + Shape::Mark { mark, .. } => { + d.set(b"C".to_vec(), rgb(style.color)); + d.set(b"Name".to_vec(), Object::name(mark.name())); + border(&mut d); + // Drawn upright for the page as displayed, as a stamp is (#2392). + let turn = pdfcraft_model::pages(doc).get(new.page).map_or(0, |p| p.rotation(doc)); + if matches!(turn, 90 | 180 | 270) { + d.set(b"PCStampRotate".to_vec(), Object::Int(turn)); + } + } + Shape::Stamp { stamp, by, .. } => { + d.set(b"C".to_vec(), rgb(style.color)); + d.set(b"Name".to_vec(), Object::name(stamp.name())); + // Marks the stamp as drawn by PdfCraft: other stamps with standard names keep + // their own artwork. + d.set(b"PCStamp".to_vec(), Object::Bool(true)); + if let Some(b) = by { + d.set(b"PCByLine".to_vec(), PdfString::text(b)); + } + // The label is drawn for the page as displayed. A later page rotation is a new edit. + let turn = pdfcraft_model::pages(doc).get(new.page).map_or(0, |p| p.rotation(doc)); + if matches!(turn, 90 | 180 | 270) { + d.set(b"PCStampRotate".to_vec(), Object::Int(turn)); + } + } + Shape::TypedSignature { contours, .. } => { + d.set(b"C".to_vec(), rgb(style.color)); + d.set(b"Name".to_vec(), Object::name("PCTypedSignature")); + let list = contours.iter().filter(|c| c.len() > 2).map(|c| num_array(&c.iter().flat_map(|p| [p[0], p[1]]).collect::>())).collect(); + d.set(b"PCOutline".to_vec(), Object::Array(list)); + } + Shape::CustomStamp { name, picture, image, size, .. } => { + let clean: String = name.chars().filter(char::is_ascii_alphanumeric).collect(); + d.set(b"Name".to_vec(), Object::name(&format!("PCCustom{clean}"))); + d.set(b"PCPicture".to_vec(), Object::Ref(*picture)); + d.set(b"PCPictureImage".to_vec(), Object::Bool(*image)); + d.set(b"PCPictureSize".to_vec(), num_array(&[size.0, size.1])); + // An image signature's rectangle is in user space; on a turned page its appearance is + // turned back (see `appearance::build`), so it reads upright as displayed. + if *image && matches!(clean.as_str(), "Signature" | "Initials") { + let turn = pdfcraft_model::pages(doc).get(new.page).map_or(0, |p| p.rotation(doc)); + if turn != 0 { + d.set(b"PCPictureRotate".to_vec(), Object::Int(turn)); + } + } + } + Shape::Ink { strokes } | Shape::Signature { strokes } => { + d.set(b"C".to_vec(), rgb(style.color)); + let list = strokes.iter().filter(|s| !s.is_empty()).map(|s| num_array(&s.concat())).collect(); + d.set(b"InkList".to_vec(), Object::Array(list)); + border(&mut d); + } + Shape::Polygon { vertices, cloud } => { + d.set(b"C".to_vec(), rgb(style.color)); + if let Some(f) = style.fill { + d.set(b"IC".to_vec(), rgb(f)); + } + d.set(b"Vertices".to_vec(), num_array(&vertices.concat())); + if *cloud { + d.set(b"IT".to_vec(), Object::name("PolygonCloud")); + let mut be = Dict::new(); + be.set(b"S".to_vec(), Object::name("C")); + be.set(b"I".to_vec(), Object::Int(1)); + d.set(b"BE".to_vec(), Object::Dict(be)); + } + border(&mut d); + } + Shape::PolyLine { vertices, start, end } => { + d.set(b"C".to_vec(), rgb(style.color)); + d.set(b"Vertices".to_vec(), num_array(&vertices.concat())); + if ending_draws(*start) || ending_draws(*end) { + d.set(b"LE".to_vec(), ending_pair(*start, *end)); + } + border(&mut d); + } + Shape::Caret { .. } => { + d.set(b"C".to_vec(), rgb(style.color)); + d.set(b"Sy".to_vec(), Object::name("None")); + } + Shape::Attachment { icon, file, data, .. } => { + if file.trim().is_empty() { + return Err(AnnotError::Invalid("the attached file needs a name".into())); + } + d.set(b"C".to_vec(), rgb(style.color)); + d.set(b"Name".to_vec(), Object::name(icon.name())); + d.set(b"F".to_vec(), Object::Int(FLAG_PRINT | FLAG_NO_ZOOM | FLAG_NO_ROTATE)); + // The file: an embedded file stream inside a file specification (§7.11.4). + let mut params = Dict::new(); + params.set(b"Size".to_vec(), Object::Int(data.len() as i64)); + if let Some(date) = &meta.date { + params.set(b"ModDate".to_vec(), PdfString::literal(date.as_bytes().to_vec())); + } + let mut ef = Dict::new(); + ef.set(b"Type".to_vec(), Object::name("EmbeddedFile")); + ef.set(b"Params".to_vec(), Object::Dict(params)); + let ef = doc.add(Object::Stream(pdfcraft_cos::Stream::flate(ef, data))); + let mut efd = Dict::new(); + efd.set(b"F".to_vec(), Object::Ref(ef)); + efd.set(b"UF".to_vec(), Object::Ref(ef)); + let mut fs = Dict::new(); + fs.set(b"Type".to_vec(), Object::name("Filespec")); + fs.set(b"F".to_vec(), PdfString::text(file.trim())); + fs.set(b"UF".to_vec(), PdfString::text(file.trim())); + fs.set(b"EF".to_vec(), Object::Dict(efd)); + d.set(b"FS".to_vec(), Object::Ref(doc.add(Object::Dict(fs)))); + if new.contents.is_empty() { + d.set(b"Contents".to_vec(), PdfString::text(file.trim())); + } + } + Shape::Callout { rect: tb, knee, point, ending, .. } => { + let tb = normalize(*tb); + let attach = callout_attach(tb, *knee); + d.set(b"IT".to_vec(), Object::name("FreeTextCallout")); + d.set(b"CL".to_vec(), num_array(&[point[0], point[1], knee[0], knee[1], attach[0], attach[1]])); + d.set(b"LE".to_vec(), Object::name(ending.name())); + // The text box inside /Rect (§12.5.6.6 /RD). + d.set(b"RD".to_vec(), num_array(&[tb[0] - rect[0], tb[1] - rect[1], rect[2] - tb[2], rect[3] - tb[3]])); + } + _ => {} + } + match &new.shape { + Shape::TextBox { font_size, .. } | Shape::Typewriter { font_size, .. } | Shape::Callout { font_size, .. } => { + if matches!(new.shape, Shape::Typewriter { .. }) { + d.set(b"IT".to_vec(), Object::name("FreeTextTypeWriter")); + } + let size = if font_size.is_finite() && *font_size > 0.0 { font_size.clamp(1.0, 400.0) } else { 12.0 }; + let [r, g, b] = style.color.map(|x| x.clamp(0.0, 1.0)); + d.set(b"DA".to_vec(), PdfString::literal(format!("{} {} {} rg /Helv {} Tf", n(r), n(g), n(b), n(size)).into_bytes())); + d.set(b"Q".to_vec(), Object::Int(0)); + if let Some(f) = style.fill { + d.set(b"C".to_vec(), rgb(f)); + } + border(&mut d); + rich_text(&mut d); + // The text is laid out for the page as displayed, like a stamp's label. + if !matches!(new.shape, Shape::Callout { .. }) { + let turn = pdfcraft_model::pages(doc).get(new.page).map_or(0, |p| p.rotation(doc)); + if matches!(turn, 90 | 180 | 270) { + d.set(b"PCTextRotate".to_vec(), Object::Int(turn)); + } + } + } + _ => {} + } + if fill_sign_shape(&new.shape) { + // Survives a later subject edit, so flatten-on-save still finds the mark. + d.set(b"PCFillSign".to_vec(), Object::Bool(true)); + } + // A redaction's overlay text becomes page content when it is applied, where `?` would be there + // for good: refused before anything is added. A comment's own text is stored as typed and + // drawn with `?` for what the standard fonts can't draw (marked, see `draw_appearance`). + if d.name(b"Subtype") == Some(b"Redact") + && let Some((what, c)) = appearance::undrawable_text(&d) + { + return Err(AnnotError::Undrawable(pdfcraft_fonts::undrawable_message(what, c))); + } + let r = doc.add(Object::Dict(d.clone())); + draw_appearance(doc, r)?; + let mut list = annots(doc, page); + let index = list.len(); + list.push(Object::Ref(r)); + add_popup(doc, r, popup, &mut list)?; + set_annots(doc, page, list)?; + Ok(index) +} + +fn validate_style(style: &Style) -> Result<(), AnnotError> { + if !finite(&style.color) || !style.opacity.is_finite() || !style.width.is_finite() { + return Err(AnnotError::Invalid("invalid style".into())); + } + Ok(()) +} + +fn set_redaction_properties(dict: &mut Dict, style: &Style, quads: &[[f64; 8]], overlay: &str, look: &OverlayLook) { + dict.set(b"C".to_vec(), rgb(style.color)); + dict.set(b"IC".to_vec(), rgb(style.fill.unwrap_or([0.0, 0.0, 0.0]))); + dict.set(b"QuadPoints".to_vec(), num_array(&quads.concat())); + if !overlay.is_empty() { + dict.set(b"OverlayText".to_vec(), PdfString::text(overlay)); + let [r, g, b] = look.color.map(|x| x.clamp(0.0, 1.0)); + let size = if look.size > 0.0 { look.size.min(400.0) } else { 0.0 }; + dict.set(b"DA".to_vec(), PdfString::literal(format!("{} {} {} rg /{} {} Tf", n(r), n(g), n(b), look.font.resource(), n(size)).into_bytes())); + dict.set(b"Q".to_vec(), Object::Int(look.align.min(2) as i64)); + if look.repeat { + dict.set(b"Repeat".to_vec(), Object::Bool(true)); + } + } +} + +fn set_non_opaque_style(dict: &mut Dict, opacity: f64) { + if opacity < 1.0 { + dict.set(b"CA".to_vec(), Object::Real(opacity)); + } +} + +fn add_popup(doc: &mut Document, parent: ObjRef, rect: Option<[f64; 4]>, annots: &mut Vec) -> Result<(), AnnotError> { + let Some(rect) = rect else { return Ok(()) }; + let mut popup = Dict::new(); + popup.set(b"Type".to_vec(), Object::name("Annot")); + popup.set(b"Subtype".to_vec(), Object::name("Popup")); + popup.set(b"Rect".to_vec(), num_array(&rect)); + popup.set(b"Parent".to_vec(), Object::Ref(parent)); + popup.set(b"Open".to_vec(), Object::Bool(false)); + popup.set(b"F".to_vec(), Object::Int(FLAG_PRINT | FLAG_NO_ZOOM | FLAG_NO_ROTATE)); + let popup_ref = doc.add(Object::Dict(popup)); + doc.update_dict(parent, |dict| dict.set(b"Popup".to_vec(), Object::Ref(popup_ref)))?; + annots.push(Object::Ref(popup_ref)); + Ok(()) +} + +/// Counterrotate a newly placed picture stamp's appearance into displayed-page axes. +/// The picture may be an image or a PDF page. The normal appearance's transformed bounding +/// box is fitted to `/Rect` by PDF viewers. +pub fn orient_image_stamp(doc: &mut Document, page: usize, index: usize, rotation: i64) -> Result<(), AnnotError> { + let matrix = match rotation { + 90 => [0.0, 1.0, -1.0, 0.0, 0.0, 0.0], + 180 => [-1.0, 0.0, 0.0, -1.0, 0.0, 0.0], + 270 => [0.0, -1.0, 1.0, 0.0, 0.0, 0.0], + _ => return Ok(()), + }; + let (_, r) = annot_ref(doc, page, index)?; + let d = annot_dict(doc, r); + if d.name(b"Subtype") != Some(b"Stamp") || d.get(b"PCPicture").is_none() { + return Err(AnnotError::Invalid("only a picture stamp can be oriented".into())); + } + let normal = d + .get(b"AP") + .map(|ap| doc.resolve(ap)) + .and_then(|ap| ap.as_dict().and_then(|ap| ap.reference(b"N"))) + .ok_or_else(|| AnnotError::Invalid("the stamp has no normal appearance".into()))?; + let Object::Stream(mut stream) = doc.get(normal).as_ref().clone() else { + return Err(AnnotError::Invalid("the stamp's normal appearance is not a stream".into())); + }; + stream.dict.set(b"Matrix".to_vec(), num_array(&matrix)); + doc.set(normal, Object::Stream(stream)); + Ok(()) +} diff --git a/crates/annot/src/draw.rs b/crates/annot/src/draw.rs new file mode 100644 index 0000000000..5e698332da --- /dev/null +++ b/crates/annot/src/draw.rs @@ -0,0 +1,198 @@ +use super::create::{finite, num_array}; +use super::lookup::{annot_dict, annots, page_refs}; +use super::modify::refuse_foreign_text; +use super::types::NOTE_SIZE; +use super::{AnnotError, Dict, Document, ObjRef, Object, Stream, appearance}; + +/// (Re)generate `/AP /N` for the annotation `r` from its dictionary, for an edit that keeps its +/// text (a restyle, a new size or colour). +/// +/// A comment whose text the standard fonts can't draw, with an appearance another program drew +/// (with a font that has those characters), keeps that appearance: redrawing it would put `?` in +/// their place, so the edit is refused (`AnnotError::Undrawable`). One PdfCraft drew with `?` +/// (marked `pdfcraft_fonts::UNDRAWN_MARK`) is redrawn. +pub fn set_appearance(doc: &mut Document, r: ObjRef) -> Result<(), AnnotError> { + refuse_foreign_text(doc, &annot_dict(doc, r))?; + draw_appearance(doc, r) +} + +/// Put `before` back when redrawing fails, so a refused edit does not keep the new rectangle or style. +pub(crate) fn redraw_or_restore(doc: &mut Document, r: ObjRef, before: Dict) -> Result<(), AnnotError> { + if let Err(e) = set_appearance(doc, r) { + doc.set(r, Object::Dict(before)); + return Err(e); + } + Ok(()) +} + +/// Draw `r`'s normal appearance from its dictionary, whatever its text: characters the standard +/// fonts can't draw come out as `?`, and the annotation is marked (`pdfcraft_fonts::UNDRAWN_MARK`, +/// cleared when all of it can be drawn) so the edit is reported and flattening won't make that +/// `?` permanent. For new text (a new comment, changed contents), as Acrobat keeps text its font +/// can't show, and for display-only copies. +pub(crate) fn draw_appearance(doc: &mut Document, r: ObjRef) -> Result<(), AnnotError> { + let d = annot_dict(doc, r); + let subtype = String::from_utf8_lossy(d.name(b"Subtype").unwrap_or_default()).into_owned(); + let Some(stream) = build_appearance(doc, &d) else { return Err(AnnotError::Unsupported(subtype)) }; + apply_appearance(doc, r, &d, stream) +} + +/// Draw the appearance for an annotation dictionary: the expensive half of [`set_appearance`]. +/// Pure from the dictionary and the objects it points at, so [`AppearanceCache`] can reuse it. +fn build_appearance(doc: &Document, d: &Dict) -> Option { + let mut stream = appearance::build(d)?; + // Picture-stamp restyling must retain the placement's page-axis correction. + if d.get(b"PCPicture").is_some() { + let matrix = d.get(b"AP").and_then(|ap| { + let ap = doc.resolve(ap); + let normal = doc.resolve(ap.as_dict()?.get(b"N")?); + let Object::Stream(normal) = normal.as_ref() else { return None }; + normal.dict.get(b"Matrix").cloned() + }); + if let Some(matrix) = matrix { + stream.dict.set(b"Matrix".to_vec(), matrix); + } + } + Some(stream) +} + +/// Store a built appearance on the annotation: the cheap half of [`set_appearance`]. +fn apply_appearance(doc: &mut Document, r: ObjRef, d: &Dict, stream: Stream) -> Result<(), AnnotError> { + // Characters the standard fonts can't draw came out as `?`: mark the annotation with the first. + let missing = appearance::undrawable_text(d).map(|(_, c)| c); + let ap = doc.add(Object::Stream(stream)); + // A copy (a shared /AP is left alone) that keeps unknown entries; the old down and rollover + // appearances would show the previous look on press or hover, so they go with the old /N. + let mut apd = d.get(b"AP").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().cloned()).unwrap_or_default(); + apd.remove(b"D"); + apd.remove(b"R"); + apd.set(b"N".to_vec(), Object::Ref(ap)); + // A note icon has a fixed size (`NOTE_SIZE`); viewers draw it at the upper-left corner of `/Rect`. + // The appearance's box is mapped onto `/Rect`, so any other `/Rect` (a large one, or one with + // no width, as LaTeX `pdfcomment` writes) would stretch the icon or shrink it to nothing. + let icon_rect = matches!(d.name(b"Subtype"), Some(b"Text" | b"FileAttachment")) + .then(|| d.get(b"Rect")?.as_array()?.iter().map(|o| o.as_f64()).collect::>>()) + .flatten() + .filter(|q| q.len() == 4 && finite(q)) + .map(|q| { + let (x, top) = (q[0].min(q[2]), q[1].max(q[3])); + [x, top - NOTE_SIZE, x + NOTE_SIZE, top] + }); + doc.update_dict(r, |d| { + d.set(b"AP".to_vec(), Object::Dict(apd)); + if let Some(rect) = icon_rect { + d.set(b"Rect".to_vec(), num_array(&rect)); + } + d.remove(b"AS"); + pdfcraft_fonts::set_undrawn_mark(d, missing); + })?; + Ok(()) +} + +/// Display appearances built for annotations without any, reused while the annotation +/// dictionaries they were drawn from are unchanged. +/// +/// A content edit (moving a paragraph, retyping text, …) leaves every annotation dictionary +/// alone, but the display copy is rebuilt from the edited document on every refresh: without +/// this cache each refresh redrew every appearance (27k on one CAD file, ~0.7 s per edit +/// commit). Entries are keyed by annotation reference; object numbers are never reused within +/// a document lineage, so an entry stays valid until an annotation edit runs. Callers clear +/// the cache on any refresh that may change annotations (comments, structural and full +/// refreshes) and keep it for content-only ones. +#[derive(Clone, Debug, Default)] +pub struct AppearanceCache { + entries: std::collections::HashMap, + bytes: usize, + hits: u64, + builds: u64, +} + +impl AppearanceCache { + /// Cached appearance data restarts past this instead of growing without bound. + const MAX_BYTES: usize = 64 << 20; + + /// Forget every entry (an annotation edit may have changed the dictionaries). + pub fn clear(&mut self) { + *self = Self::default(); + } + + fn get(&mut self, r: ObjRef) -> Option { + let stream = self.entries.get(&r)?.clone(); + self.hits += 1; + Some(stream) + } + + fn insert(&mut self, r: ObjRef, stream: &Stream) { + self.builds += 1; + if self.entries.len() >= 1_000_000 { + self.clear(); + } + if self.bytes.saturating_add(stream.raw.len()) <= Self::MAX_BYTES { + self.bytes += stream.raw.len(); + self.entries.insert(r, stream.clone()); + } + // Else over budget: keep serving the entries already cached instead of growing. + } + + /// Cache hits so far (reused appearances); for tests. + pub fn hits(&self) -> u64 { + self.hits + } + + /// Appearances built so far; for tests. + pub fn builds(&self) -> u64 { + self.builds + } +} + +/// A copy of `doc` in which every annotation without a normal appearance (`/AP /N`) has the one +/// [`appearance::build`] draws from its dictionary, for displaying the document (`None` when no +/// annotation needs one). Many files carry comments without appearances (FreeText, Ink, notes, +/// stamps, …) that viewers draw from the dictionary. `doc` is not changed: saving writes the file +/// as it was. Links, form fields and pop-ups keep their own handling. +pub fn with_missing_appearances(doc: &Document) -> Option { + with_missing_appearances_cached(doc, &mut AppearanceCache::default()) +} + +/// A copy of `doc` with [`AppearanceCache`] reuse across refreshes: annotations whose +/// dictionary is unchanged since the cached build keep their stored appearance instead of +/// redrawing it. See [`AppearanceCache`] for when entries stay valid. +pub fn with_missing_appearances_cached(doc: &Document, cache: &mut AppearanceCache) -> Option { + let mut copy: Option = None; + // An annotation listed more than once, on one page or several, is drawn once. `has_normal` + // reads `doc`, which never gets the appearance, so every repeat used to add another stream to + // the copy, and the display bytes grew with the number of listings. + let mut seen = std::collections::HashSet::new(); + for page in page_refs(doc).ok()? { + for r in annots(doc, page).iter().filter_map(Object::as_ref) { + if !seen.insert(r) { + continue; + } + let d = annot_dict(doc, r); + if matches!(d.name(b"Subtype"), None | Some(b"Link" | b"Widget" | b"Popup")) { + continue; + } + let has_normal = d.get(b"AP").is_some_and(|ap| doc.resolve(ap).as_dict().is_some_and(|ap| ap.contains(b"N"))); + if has_normal { + continue; + } + if let Some(stream) = cache.get(r) { + // Fails only when `r` isn't a dictionary, which the cached build ruled out. + apply_appearance(copy.get_or_insert_with(|| doc.clone()), r, &d, stream).ok(); + continue; + } + // Another program's stamp gets a drawing for display only (ISO 32000-2 Table 184). Its + // `/AP`, if any, has no `/N` to keep here. + let other_stamp = || { + let mut bare = d.clone(); + bare.remove(b"AP"); + appearance::display_stamp(&bare) + }; + let Some(stream) = build_appearance(doc, &d).or_else(other_stamp) else { continue }; + cache.insert(r, &stream); + // Fails only when `r` isn't a dictionary, which the build just ruled out. + apply_appearance(copy.get_or_insert_with(|| doc.clone()), r, &d, stream).ok(); + } + } + copy +} diff --git a/crates/annot/src/info.rs b/crates/annot/src/info.rs new file mode 100644 index 0000000000..b56e0de20d --- /dev/null +++ b/crates/annot/src/info.rs @@ -0,0 +1,276 @@ +use super::create::finite; +use super::draw::set_appearance; +use super::lookup::{annot_dict, annot_ref, annotation_flags, annots, drawn_upright, is_fill_sign, page_ref, page_refs}; +use super::modify::{fillable, touch, unlocked}; +use super::types::{FLAG_LOCKED, LineEnding, Meta, NoteIcon, Rgb}; +use super::{AnnotError, Dict, Document, ObjRef, Object, PdfString, appearance}; + +// ── reading ───────────────────────────────────────────────────────────────────────────────── + +/// One comment as the viewer lists it (the same fields and rules as +/// `pdfcraft_render::Annotation`), read straight from the object graph. The engine uses it to +/// refresh the comment list after a comment edit without re-inspecting the whole document. +#[derive(Clone, Debug, PartialEq)] +pub struct Summary { + pub page: usize, + pub index: usize, + pub subtype: String, + pub author: Option, + pub contents: Option, + /// `/M` as written (a PDF date string). + pub modified: Option, + pub name: Option, + pub in_reply_to: Option, + /// The page and `/Annots` position `/IRT` points at, by reference (`/NM` need not be unique). + pub parent: Option<(usize, usize)>, + /// `/RT /Group`: grouped with the `/IRT` annotation (Replace Text's caret), not a reply to it. + pub grouped: bool, + pub rect: [f32; 4], + pub color: Option<[f32; 3]>, + pub state: Option, + pub quads: Vec<[f32; 8]>, + pub locked: bool, + pub intent: Option, + /// A Fill & Sign mark ([`is_fill_sign`]). + pub fill_sign: bool, + /// The NoRotate flag (`/F` bit 5), as `pdfcraft_render::Annotation::no_rotate`. + pub no_rotate: bool, + /// A line's two ends (`/L`), or the corners of connected lines, a polygon or a cloud + /// (`/Vertices`), in user space; empty for other comments. + pub points: Vec<[f32; 2]>, +} + +pub(crate) fn text_value(doc: &Document, d: &Dict, key: &[u8]) -> Option { + let o = doc.resolve(d.get(key)?); + let s = match &*o { + Object::String(s) => s.to_text(), + Object::Name(n) => String::from_utf8_lossy(n).into_owned(), + _ => return None, + }; + let s = s.trim_matches('\0').trim().to_string(); + (!s.is_empty()).then_some(s) +} + +/// Whether an annotation subtype is a comment. Links, form widgets, pop-ups and the non-markup +/// multimedia and print-production annotations (ISO 32000-2 §12.5.6: Screen, Movie, RichMedia, +/// 3D, PrinterMark, TrapNet, Watermark) are not. +pub fn is_comment_subtype(subtype: &str) -> bool { + !matches!(subtype, "Link" | "Widget" | "Popup" | "Screen" | "Movie" | "RichMedia" | "3D" | "PrinterMark" | "TrapNet" | "Watermark") +} + +/// Where a reference to a reference (`5 0 obj 6 0 R endobj`) ends, as `pdfcraft_render::inspect` +/// follows it, within a few steps (a cycle stops there). +fn final_ref(doc: &Document, mut r: ObjRef) -> ObjRef { + for _ in 0..8 { + match &*doc.get(r) { + Object::Ref(next) => r = *next, + _ => break, + } + } + r +} + +/// Every comment (see [`is_comment_subtype`]), ordered by page and then top edge. +pub fn summaries(doc: &Document) -> Vec { + let mut out = Vec::new(); + let Ok(pages) = page_refs(doc) else { return out }; + let lists: Vec> = pages.iter().map(|p| annots(doc, *p)).collect(); + // Where each annotation is first listed, so a reply's `/IRT` finds its parent by reference. + let mut slots: std::collections::HashMap = std::collections::HashMap::new(); + for (page, list) in lists.iter().enumerate() { + for (index, entry) in list.iter().enumerate() { + if let Some(r) = entry.as_ref() { + slots.entry(final_ref(doc, r)).or_insert((page, index)); + } + } + } + for (page, list) in lists.iter().enumerate() { + for (index, entry) in list.iter().enumerate() { + let obj = entry.as_ref().map_or_else(|| doc.resolve(entry), |r| doc.get(final_ref(doc, r))); + let Some(d) = obj.as_dict() else { continue }; + let Some(subtype) = d.name(b"Subtype").map(|s| String::from_utf8_lossy(s).into_owned()) else { continue }; + if !is_comment_subtype(&subtype) { + continue; + } + let nums = |k: &[u8]| -> Vec { + d.get(k) + .map(|o| doc.resolve(o)) + .and_then(|o| o.as_array().map(|a| a.iter().map(|x| doc.resolve(x).as_f64().unwrap_or(0.0) as f32).collect())) + .unwrap_or_default() + }; + let r = nums(b"Rect"); + let rect = if r.len() == 4 { [r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])] } else { [0.0; 4] }; + let c = nums(b"C"); + let mut color = (c.len() == 3).then(|| [c[0], c[1], c[2]]); + if subtype == "FreeText" + && let Some(da) = text_value(doc, d, b"DA") + { + let t: Vec<&str> = da.split_whitespace().collect(); + if let Some(i) = t.iter().position(|x| *x == "rg") + && i >= 3 + { + let f = |k: usize| t[k].parse::().unwrap_or(0.0); + color = Some([f(i - 3), f(i - 2), f(i - 1)]); + } + } + let quads = nums(b"QuadPoints").as_chunks::<8>().0.to_vec(); + // Keep in step with `pdfcraft_render::inspect`. + let points = match subtype.as_str() { + "Line" => Some(nums(b"L")).filter(|l| l.len() == 4).map(|l| l.as_chunks::<2>().0.to_vec()).unwrap_or_default(), + "PolyLine" | "Polygon" => nums(b"Vertices").as_chunks::<2>().0.to_vec(), + _ => Vec::new(), + }; + let irt = d.get(b"IRT").map(|o| o.as_ref().map_or_else(|| doc.resolve(o), |r| doc.get(final_ref(doc, r)))); + let in_reply_to = irt.and_then(|o| o.as_dict().cloned()).and_then(|p| text_value(doc, &p, b"NM")); + let parent = d.get(b"IRT").and_then(Object::as_ref).and_then(|r| slots.get(&final_ref(doc, r)).copied()); + out.push(Summary { + page, + index, + subtype, + author: text_value(doc, d, b"T"), + // Acrobat ends lines with CR, which the interface would draw as nothing (keep in + // step with `pdfcraft_render::inspect`). + contents: text_value(doc, d, b"Contents").map(|s| s.replace("\r\n", "\n").replace('\r', "\n")), + modified: text_value(doc, d, b"M"), + name: text_value(doc, d, b"NM"), + in_reply_to, + parent, + grouped: d.name(b"RT") == Some(b"Group"), + rect, + color, + state: text_value(doc, d, b"State"), + quads, + locked: annotation_flags(doc, d) & FLAG_LOCKED != 0, + intent: d.get(b"IT").and_then(|o| doc.resolve(o).as_name().map(|n| String::from_utf8_lossy(n).into_owned())), + fill_sign: is_fill_sign(doc, d), + no_rotate: drawn_upright(doc, d), + points, + }); + } + } + out.sort_by(|a, b| a.page.cmp(&b.page).then(b.rect[3].total_cmp(&a.rect[3]))); + out +} + +/// Change a comment's author (`/T`), subject (`/Subj`) and, for notes, icon (`/Name`, redrawn). +/// `None` leaves a value as it is. +pub fn set_info( + doc: &mut Document, + page: usize, + index: usize, + author: Option<&str>, + subject: Option<&str>, + icon: Option, + meta: &Meta, +) -> Result<(), AnnotError> { + let (_, r) = annot_ref(doc, page, index)?; + unlocked(doc, r)?; + let is_note = annot_dict(doc, r).name(b"Subtype") == Some(b"Text"); + if icon.is_some() && !is_note { + return Err(AnnotError::Invalid("only sticky notes have an icon".into())); + } + doc.update_dict(r, |d| { + if let Some(a) = author { + d.set(b"T".to_vec(), PdfString::text(a)); + } + if let Some(s) = subject { + d.set(b"Subj".to_vec(), PdfString::text(s)); + } + if let Some(i) = icon { + d.set(b"Name".to_vec(), Object::name(i.name())); + } + touch(d, meta); + })?; + if icon.is_some() { + set_appearance(doc, r)?; + } + Ok(()) +} + +/// What Comment properties shows for one comment. +#[derive(Clone, Debug, PartialEq)] +pub struct Props { + pub subtype: String, + pub author: String, + pub subject: String, + pub color: Option, + pub opacity: f64, + /// Border / line width, for shapes, lines, drawings and text boxes. + pub width: Option, + /// Notes only. + pub icon: Option, + /// `/M`, as written. + pub modified: Option, + /// The appearance can be redrawn (so colour, opacity and width can change). + pub restylable: bool, + /// The Locked flag. + pub locked: bool, + /// `/LE`: two names for a line or polyline (`None` when unset), one for a callout. + pub endings: Option>, + /// Whether the comment has a fill control (see [`fillable`]). + pub fillable: bool, + /// The interior colour (`None`: no fill). + pub fill: Option, + /// Text boxes, typewriter text and callouts: the `/DA` font size in points. + pub font_size: Option, +} + +/// The current properties of the comment at `(page, index)`. +pub fn props(doc: &Document, page: usize, index: usize) -> Option { + let p = page_ref(doc, page).ok()?; + let entry = annots(doc, p).get(index).cloned()?; + let obj = doc.resolve(&entry); + let d = obj.as_dict()?; + let subtype = String::from_utf8_lossy(d.name(b"Subtype")?).into_owned(); + let c: Vec = d.get(b"C").and_then(|o| o.as_array().map(|a| a.iter().filter_map(|x| x.as_f64()).collect())).unwrap_or_default(); + let color = if subtype == "FreeText" { Some(appearance::parse_da(d).0) } else { (c.len() == 3).then(|| [c[0], c[1], c[2]]) }; + let width = matches!(subtype.as_str(), "Square" | "Circle" | "Line" | "Ink" | "Polygon" | "PolyLine" | "FreeText").then(|| { + if subtype == "FreeText" { + if !d.contains(b"BS") && !d.contains(b"Border") { 0.0 } else { appearance::border_width(d) } + } else { + d.get(b"BS").and_then(|b| b.as_dict()).and_then(|b| b.get(b"W")).and_then(|w| w.as_f64()).unwrap_or(1.0) + } + }); + Some(Props { + author: text_value(doc, d, b"T").unwrap_or_default(), + subject: text_value(doc, d, b"Subj").unwrap_or_default(), + color, + opacity: d.get(b"CA").and_then(|o| o.as_f64()).unwrap_or(1.0), + width, + icon: (subtype == "Text").then(|| d.name(b"Name").and_then(|n| NoteIcon::from_name(&String::from_utf8_lossy(n))).unwrap_or(NoteIcon::Note)), + modified: text_value(doc, d, b"M"), + restylable: appearance::build(d).is_some(), + locked: annotation_flags(doc, d) & FLAG_LOCKED != 0, + fillable: fillable(&subtype), + fill: fill_of(d), + font_size: (subtype == "FreeText").then(|| appearance::parse_da(d).1), + subtype, + endings: line_endings_of(d), + }) +} + +/// A shape’s `/IC` or FreeText `/C` as RGB. Grey and CMYK fills are converted. +fn fill_of(d: &Dict) -> Option { + let key: &[u8] = if d.name(b"Subtype") == Some(b"FreeText") { b"C" } else { b"IC" }; + let c: Vec = d.get(key)?.as_array()?.iter().filter_map(|x| x.as_f64()).collect(); + let rgb = match c.as_slice() { + [g] => [*g; 3], + [r, g, b] => [*r, *g, *b], + [c, m, y, k] => [(1.0 - c) * (1.0 - k), (1.0 - m) * (1.0 - k), (1.0 - y) * (1.0 - k)], + _ => return None, + }; + finite(&rgb).then(|| rgb.map(|x| x.clamp(0.0, 1.0))) +} + +/// `/LE` for a line, polyline or callout. Unknown names yield `None` so the control stays hidden. +pub(crate) fn line_endings_of(d: &Dict) -> Option> { + let parse = |name: &[u8]| std::str::from_utf8(name).ok().and_then(LineEnding::parse); + match d.name(b"Subtype")? { + b"Line" | b"PolyLine" => match d.get(b"LE") { + None => Some(vec![LineEnding::None, LineEnding::None]), + Some(o) => o.as_array().filter(|a| a.len() == 2)?.iter().map(|e| parse(e.as_name()?)).collect(), + }, + b"FreeText" if d.contains(b"CL") => Some(vec![parse(d.name(b"LE").unwrap_or(b"None"))?]), + _ => None, + } +} diff --git a/crates/annot/src/lib.rs b/crates/annot/src/lib.rs index 6c9fc5dce8..e9e0359ce0 100644 --- a/crates/annot/src/lib.rs +++ b/crates/annot/src/lib.rs @@ -18,6 +18,32 @@ use std::collections::{HashMap, HashSet}; use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream}; +mod create; +mod draw; +mod info; +mod lookup; +mod modify; +mod types; + +#[cfg(test)] +use create::num_array; +pub use create::{add_annotation, callout_attach, orient_image_stamp}; +pub use draw::{AppearanceCache, set_appearance, with_missing_appearances, with_missing_appearances_cached}; +pub use info::{Props, Summary, is_comment_subtype, props, set_info, summaries}; +#[cfg(test)] +use lookup::{ANNOTS_ENTRIES_COPIED, PageWalk, annot_dict, annot_ref}; +use lookup::{annots, page_ref}; +pub use lookup::{has_visible_fill_sign, is_fill_sign, isolate_annotation, page_refs, picture_rotation, set_annots, signature_image}; +pub(crate) use modify::n; +pub use modify::{ + add_reply, add_text_replacement, delete_annotation, erase_ink, fillable, goes_with, move_annotation, set_contents, set_fill, set_font_size, + set_locked, set_marked, set_points, set_rect, set_review_state, set_style, with_replies_and_popups, +}; +pub use types::{ + AttachIcon, FillMark, LineEnding, Markup, Meta, NOTE_SIZE, NewAnnotation, NoteIcon, OverlayFont, OverlayLook, ReviewState, Rgb, Shape, + StampGroup, StampKind, Style, StyleChange, TextReplacement, rect_quad, +}; + pub mod appearance; pub mod links; @@ -45,2643 +71,3 @@ pub enum AnnotError { #[error("{0}")] Cos(#[from] pdfcraft_cos::CosError), } - -pub type Rgb = [f64; 3]; - -/// Text markup kinds (§12.5.6.10). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum Markup { - Highlight, - Underline, - StrikeOut, - Squiggly, -} - -impl Markup { - fn subtype(self) -> &'static str { - match self { - Markup::Highlight => "Highlight", - Markup::Underline => "Underline", - Markup::StrikeOut => "StrikeOut", - Markup::Squiggly => "Squiggly", - } - } -} - -/// Text-annotation icon names (`/Name`). The first seven are the standard names ISO 32000-2 -/// §12.5.6.4, Table 175 asks every viewer to draw; the rest are further names in common use, which -/// the same table allows ("Additional names may be supported as well"). -#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] -pub enum NoteIcon { - #[default] - Comment, - Note, - Help, - Insert, - Key, - NewParagraph, - Paragraph, - Check, - Circle, - Cross, - CrossHairs, - Star, - RightArrow, - UpArrow, -} - -impl NoteIcon { - pub fn name(self) -> &'static str { - match self { - NoteIcon::Comment => "Comment", - NoteIcon::Note => "Note", - NoteIcon::Help => "Help", - NoteIcon::Insert => "Insert", - NoteIcon::Key => "Key", - NoteIcon::NewParagraph => "NewParagraph", - NoteIcon::Paragraph => "Paragraph", - NoteIcon::Check => "Check", - NoteIcon::Circle => "Circle", - NoteIcon::Cross => "Cross", - NoteIcon::CrossHairs => "CrossHairs", - NoteIcon::Star => "Star", - NoteIcon::RightArrow => "RightArrow", - NoteIcon::UpArrow => "UpArrow", - } - } - - /// Every icon, the standard names first. - pub const ALL: [NoteIcon; 14] = [ - Self::Comment, - Self::Note, - Self::Help, - Self::Insert, - Self::Key, - Self::NewParagraph, - Self::Paragraph, - Self::Check, - Self::Circle, - Self::Cross, - Self::CrossHairs, - Self::Star, - Self::RightArrow, - Self::UpArrow, - ]; - - pub fn from_name(n: &str) -> Option { - Self::ALL.into_iter().find(|i| i.name() == n) - } -} - -/// Fill & Sign marks (Acrobat's ✓, ✕, ●, ─). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum FillMark { - Check, - Cross, - Dot, - Line, -} - -impl FillMark { - /// The `/Name` of the stamp PdfCraft draws for it. - pub fn name(self) -> &'static str { - match self { - FillMark::Check => "PCCheck", - FillMark::Cross => "PCCross", - FillMark::Dot => "PCDot", - FillMark::Line => "PCLine", - } - } - - pub fn label(self) -> &'static str { - match self { - FillMark::Check => "Checkmark", - FillMark::Cross => "Cross", - FillMark::Dot => "Dot", - FillMark::Line => "Line", - } - } - - /// The user-space box of the mark placed at `at` on a page turned by `rotation`: centred - /// there and sized in displayed axes, so a line runs across the page as displayed (#2392). - pub fn rect_at(self, at: [f64; 2], rotation: i64) -> [f64; 4] { - let (w, h) = if self == FillMark::Line { (36.0, 4.0) } else { (12.0, 12.0) }; - let (w, h) = if matches!(rotation.rem_euclid(360), 90 | 270) { (h, w) } else { (w, h) }; - [at[0] - w / 2.0, at[1] - h / 2.0, at[0] + w / 2.0, at[1] + h / 2.0] - } -} - -/// The stamps of Acrobat's stamp palette (drawn in PdfCraft's own style). -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -pub enum StampKind { - // Standard business. - Approved, - Completed, - Confidential, - Draft, - Final, - ForComment, - ForPublicRelease, - InformationOnly, - NotApproved, - NotForPublicRelease, - PreliminaryResults, - Void, - // Sign here. - Accepted, - InitialHere, - Rejected, - SignHere, - Witness, - // Dynamic (with a "By … at …" line). - DynApproved, - DynConfidential, - DynReceived, - DynReviewed, - DynRevised, -} - -/// The palette's sections. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum StampGroup { - Dynamic, - SignHere, - StandardBusiness, -} - -impl StampKind { - pub const ALL: [StampKind; 22] = [ - StampKind::DynApproved, - StampKind::DynConfidential, - StampKind::DynReceived, - StampKind::DynReviewed, - StampKind::DynRevised, - StampKind::Accepted, - StampKind::InitialHere, - StampKind::Rejected, - StampKind::SignHere, - StampKind::Witness, - StampKind::Approved, - StampKind::Completed, - StampKind::Confidential, - StampKind::Draft, - StampKind::Final, - StampKind::ForComment, - StampKind::ForPublicRelease, - StampKind::InformationOnly, - StampKind::NotApproved, - StampKind::NotForPublicRelease, - StampKind::PreliminaryResults, - StampKind::Void, - ]; - - pub fn group(self) -> StampGroup { - use StampKind::*; - match self { - DynApproved | DynConfidential | DynReceived | DynReviewed | DynRevised => StampGroup::Dynamic, - Accepted | InitialHere | Rejected | SignHere | Witness => StampGroup::SignHere, - _ => StampGroup::StandardBusiness, - } - } - - /// The text on the stamp. - pub fn label(self) -> &'static str { - use StampKind::*; - match self { - Approved | DynApproved => "APPROVED", - Completed => "COMPLETED", - Confidential | DynConfidential => "CONFIDENTIAL", - Draft => "DRAFT", - Final => "FINAL", - ForComment => "FOR COMMENT", - ForPublicRelease => "FOR PUBLIC RELEASE", - InformationOnly => "INFORMATION ONLY", - NotApproved => "NOT APPROVED", - NotForPublicRelease => "NOT FOR PUBLIC RELEASE", - PreliminaryResults => "PRELIMINARY RESULTS", - Void => "VOID", - Accepted => "ACCEPTED", - InitialHere => "INITIAL HERE", - Rejected => "REJECTED", - SignHere => "SIGN HERE", - Witness => "WITNESS", - DynReceived => "RECEIVED", - DynReviewed => "REVIEWED", - DynRevised => "REVISED", - } - } - - /// `/Name`: the standard stamp names of ISO 32000-2 Table 184 where one exists. - pub fn name(self) -> &'static str { - use StampKind::*; - match self { - Approved => "Approved", - Confidential => "Confidential", - Draft => "Draft", - Final => "Final", - ForComment => "ForComment", - ForPublicRelease => "ForPublicRelease", - NotApproved => "NotApproved", - NotForPublicRelease => "NotForPublicRelease", - Completed => "PCCompleted", - InformationOnly => "PCInformationOnly", - PreliminaryResults => "PCPreliminaryResults", - Void => "PCVoid", - Accepted => "PCAccepted", - InitialHere => "PCInitialHere", - Rejected => "PCRejected", - SignHere => "PCSignHere", - Witness => "PCWitness", - DynApproved => "PCDynApproved", - DynConfidential => "PCDynConfidential", - DynReceived => "PCDynReceived", - DynReviewed => "PCDynReviewed", - DynRevised => "PCDynRevised", - } - } - - pub fn from_name(n: &[u8]) -> Option { - Self::ALL.into_iter().find(|k| k.name().as_bytes() == n) - } - - /// The stamp's colour: green for approval, red for refusal and restriction, blue otherwise. - pub fn color(self) -> Rgb { - use StampKind::*; - match self { - Approved | Completed | Final | Accepted | DynApproved | DynReceived | DynReviewed => [0.13, 0.55, 0.13], - NotApproved | Rejected | Void | Confidential | NotForPublicRelease | DynConfidential => [0.80, 0.10, 0.10], - SignHere | InitialHere | Witness => [0.85, 0.35, 0.05], - _ => [0.10, 0.30, 0.70], - } - } - - /// The stamp's size (points) for its label, as placed with a click. - pub fn size(self) -> (f64, f64) { - let w = appearance::text_width(self.label(), 16.0) * 1.12 + 24.0; - let h = if self.group() == StampGroup::Dynamic { 42.0 } else { 30.0 }; - let w = if self.group() == StampGroup::SignHere { w + 14.0 } else { w }; - (w.max(80.0), h) - } -} - -/// A line ending (`/LE`): the ten styles in ISO 32000-2 Table 217. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum LineEnding { - None, - Square, - Circle, - Diamond, - OpenArrow, - ClosedArrow, - Butt, - ROpenArrow, - RClosedArrow, - Slash, -} - -impl LineEnding { - pub const ALL: [Self; 10] = [ - Self::None, - Self::Square, - Self::Circle, - Self::Diamond, - Self::OpenArrow, - Self::ClosedArrow, - Self::Butt, - Self::ROpenArrow, - Self::RClosedArrow, - Self::Slash, - ]; - - pub fn name(self) -> &'static str { - match self { - Self::None => "None", - Self::Square => "Square", - Self::Circle => "Circle", - Self::Diamond => "Diamond", - Self::OpenArrow => "OpenArrow", - Self::ClosedArrow => "ClosedArrow", - Self::Butt => "Butt", - Self::ROpenArrow => "ROpenArrow", - Self::RClosedArrow => "RClosedArrow", - Self::Slash => "Slash", - } - } - - pub fn parse(name: &str) -> Option { - Self::ALL.into_iter().find(|ending| ending.name() == name) - } -} - -/// Geometry of a new comment, in PDF user space of its page. -#[derive(Clone, Debug, PartialEq)] -pub enum Shape { - /// A sticky note whose icon's top-left corner is at `at`. - Note { - at: [f64; 2], - icon: NoteIcon, - }, - /// Text markup over quadrilaterals: `[x1 y1 x2 y2 x3 y3 x4 y4]` = top-left, top-right, - /// bottom-left, bottom-right (the order Acrobat writes, §12.5.6.10). - TextMarkup { - kind: Markup, - quads: Vec<[f64; 8]>, - }, - Rectangle { - rect: [f64; 4], - }, - Oval { - rect: [f64; 4], - }, - /// A line. An open arrow at `to` is `start: None`, `end: OpenArrow` (the Arrow tool). - Line { - from: [f64; 2], - to: [f64; 2], - start: LineEnding, - end: LineEnding, - }, - /// Freehand strokes (Draw tool). - Ink { - strokes: Vec>, - }, - /// A text box (FreeText) showing the comment's contents. - TextBox { - rect: [f64; 4], - font_size: f64, - }, - /// Fill & Sign text typed onto the page (FreeText, typewriter intent, no border). - Typewriter { - rect: [f64; 4], - font_size: f64, - }, - /// A Fill & Sign mark in `rect`. - Mark { - rect: [f64; 4], - mark: FillMark, - }, - /// A drawn signature (ink strokes). - Signature { - strokes: Vec>, - }, - /// A rubber stamp from the stamp palette; `by` is the dynamic stamps' second line. - Stamp { - rect: [f64; 4], - stamp: StampKind, - by: Option, - }, - /// A typed signature or initials: filled outlines (each contour as points normalised to - /// `rect`, 0–1, y up), drawn with the non-zero winding rule. - TypedSignature { - rect: [f64; 4], - contours: Vec>, - }, - /// A custom stamp: a picture already in the document (an image XObject, or a form XObject - /// whose `/Matrix` maps it to `size` points) filling `rect`, named `name`. - CustomStamp { - rect: [f64; 4], - name: String, - picture: ObjRef, - image: bool, - size: (f64, f64), - }, - /// A redaction mark (§12.5.6.23) over quadrilaterals (text) or one rectangle as a quad - /// (areas, pages). `overlay` is the text shown on the box once applied, drawn with `look`. - Redact { - quads: Vec<[f64; 8]>, - overlay: String, - look: OverlayLook, - }, - /// A closed polygon (Polygon tool); with `cloud`, Acrobat's Cloud tool: the same polygon - /// with a cloudy border (`/BE /S /C`, `/IT /PolygonCloud`). - Polygon { - vertices: Vec<[f64; 2]>, - cloud: bool, - }, - /// Connected lines (Polygonal Line tool). `start` is the first vertex, `end` the last. - PolyLine { - vertices: Vec<[f64; 2]>, - start: LineEnding, - end: LineEnding, - }, - /// A text callout: a text box at `rect` with a leader line from `point` (arrowhead) via - /// `knee` to the box (FreeText, `/IT /FreeTextCallout`, `/CL`). - Callout { - rect: [f64; 4], - knee: [f64; 2], - point: [f64; 2], - font_size: f64, - ending: LineEnding, - }, - /// Insert text: a caret in `rect` (its point at the top centre). - Caret { - rect: [f64; 4], - }, - /// Attach a file as a comment: an icon whose top-left corner is at `at`, holding `data` - /// (embedded) under the name `file`. - Attachment { - at: [f64; 2], - icon: AttachIcon, - file: String, - data: Vec, - }, -} - -/// The standard file attachment icons (§12.5.6.15). -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub enum AttachIcon { - #[default] - PushPin, - Paperclip, - Graph, - Tag, -} - -impl AttachIcon { - pub const ALL: [AttachIcon; 4] = [AttachIcon::PushPin, AttachIcon::Paperclip, AttachIcon::Graph, AttachIcon::Tag]; - pub fn name(self) -> &'static str { - match self { - AttachIcon::PushPin => "PushPin", - AttachIcon::Paperclip => "Paperclip", - AttachIcon::Graph => "Graph", - AttachIcon::Tag => "Tag", - } - } - pub fn from_name(n: &str) -> Option { - Self::ALL.into_iter().find(|i| i.name().eq_ignore_ascii_case(n)) - } -} - -/// A rectangle `[x0 y0 x1 y1]` as a quad in Acrobat's order (top-left, top-right, bottom-left, -/// bottom-right). -pub fn rect_quad(r: [f64; 4]) -> [f64; 8] { - let [x0, y0, x1, y1] = normalize(r); - [x0, y1, x1, y1, x0, y0, x1, y0] -} - -impl Shape { - pub fn subtype(&self) -> &'static str { - match self { - Shape::Note { .. } => "Text", - Shape::TextMarkup { kind, .. } => kind.subtype(), - Shape::Rectangle { .. } => "Square", - Shape::Oval { .. } => "Circle", - Shape::Line { .. } => "Line", - Shape::Ink { .. } | Shape::Signature { .. } => "Ink", - Shape::TextBox { .. } | Shape::Typewriter { .. } | Shape::Callout { .. } => "FreeText", - Shape::Mark { .. } | Shape::Stamp { .. } | Shape::CustomStamp { .. } | Shape::TypedSignature { .. } => "Stamp", - Shape::Redact { .. } => "Redact", - Shape::Polygon { .. } => "Polygon", - Shape::PolyLine { .. } => "PolyLine", - Shape::Caret { .. } => "Caret", - Shape::Attachment { .. } => "FileAttachment", - } - } -} - -/// The standard fonts of redaction overlay text. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub enum OverlayFont { - #[default] - Helvetica, - Times, - Courier, -} - -impl OverlayFont { - pub const ALL: [OverlayFont; 3] = [OverlayFont::Helvetica, OverlayFont::Times, OverlayFont::Courier]; - - pub fn name(self) -> &'static str { - match self { - OverlayFont::Helvetica => "Helvetica", - OverlayFont::Times => "Times Roman", - OverlayFont::Courier => "Courier", - } - } - - /// The `/DA` font resource name (Acrobat's form font names). - pub fn resource(self) -> &'static str { - match self { - OverlayFont::Helvetica => "Helv", - OverlayFont::Times => "TiRo", - OverlayFont::Courier => "Cour", - } - } - - pub fn from_resource(n: &str) -> OverlayFont { - match n { - "TiRo" | "Times-Roman" | "TimesRoman" => OverlayFont::Times, - "Cour" | "Courier" => OverlayFont::Courier, - _ => OverlayFont::Helvetica, - } - } -} - -/// How redaction overlay text is drawn (Redaction Properties ▸ Appearance). -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct OverlayLook { - pub font: OverlayFont, - /// Points; 0 = auto-size to fit the area. - pub size: f64, - pub color: Rgb, - /// 0 left, 1 centre, 2 right. - pub align: u8, - /// Repeat the text to fill the area. - pub repeat: bool, -} - -impl Default for OverlayLook { - fn default() -> Self { - Self { font: OverlayFont::Helvetica, size: 0.0, color: [1.0, 0.0, 0.0], align: 1, repeat: false } - } -} - -/// How a comment looks. -#[derive(Clone, Debug, PartialEq)] -pub struct Style { - /// Stroke colour (text colour for text boxes, icon colour for notes). - pub color: Rgb, - /// 0–1 (`/CA`). - pub opacity: f64, - /// Border / stroke width in points (0 = none). - pub width: f64, - /// Interior fill for rectangles and ovals (`/IC`), background for text boxes. - pub fill: Option, -} - -impl Default for Style { - fn default() -> Self { - Self { color: [1.0, 0.82, 0.0], opacity: 1.0, width: 1.0, fill: None } - } -} - -impl Style { - /// The default look of each commenting tool. - pub fn default_for(shape: &Shape) -> Self { - let (color, width) = match shape { - Shape::Note { .. } => ([1.0, 0.82, 0.0], 1.0), - Shape::TextMarkup { kind: Markup::Highlight, .. } => ([1.0, 0.94, 0.0], 1.0), - Shape::TextMarkup { kind: Markup::Underline, .. } => ([0.0, 0.47, 0.84], 1.0), - Shape::TextMarkup { kind: Markup::StrikeOut, .. } => ([0.89, 0.13, 0.13], 1.0), - Shape::TextMarkup { kind: Markup::Squiggly, .. } => ([0.18, 0.62, 0.36], 1.0), - Shape::Rectangle { .. } | Shape::Oval { .. } | Shape::Line { .. } | Shape::Polygon { .. } | Shape::PolyLine { .. } => { - ([0.89, 0.13, 0.13], 2.0) - } - Shape::Callout { .. } => ([0.0, 0.0, 0.0], 1.0), - Shape::Caret { .. } | Shape::Attachment { .. } => ([0.0, 0.47, 0.84], 1.0), - Shape::Ink { .. } => ([0.0, 0.4, 0.87], 2.0), - Shape::TextBox { .. } | Shape::Typewriter { .. } => ([0.0, 0.0, 0.0], 0.0), - Shape::Mark { .. } => ([0.0, 0.0, 0.0], 1.5), - Shape::Signature { .. } => ([0.0, 0.0, 0.0], 1.5), - Shape::Stamp { stamp, .. } => (stamp.color(), 2.0), - Shape::CustomStamp { .. } => ([0.0, 0.0, 0.0], 0.0), - Shape::TypedSignature { .. } => ([0.0, 0.0, 0.0], 0.0), - // Red outline while marked; a black box once applied. - Shape::Redact { .. } => return Self { color: [0.89, 0.13, 0.13], opacity: 1.0, width: 1.0, fill: Some([0.0, 0.0, 0.0]) }, - }; - Self { color, opacity: 1.0, width, fill: None } - } -} - -/// A comment to add. -#[derive(Clone, Debug, PartialEq)] -pub struct NewAnnotation { - /// 0-based page index. - pub page: usize, - pub shape: Shape, - pub style: Style, - /// The comment text (`/Contents`); the text shown by a text box. - pub contents: String, - /// `/T` (shown as the comment's author). - pub author: String, -} - -/// Values stamped onto what an edit creates or changes, supplied by the caller so edits stay -/// deterministic: a PDF date (`D:…`) and a unique id for `/NM`. -#[derive(Clone, Debug, PartialEq, Default)] -pub struct Meta { - pub date: Option, - pub id: String, -} - -/// Review states a reply can set (§12.5.6.3, Table 172; Acrobat's "Set status"). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum ReviewState { - None, - Accepted, - Rejected, - Cancelled, - Completed, -} - -impl ReviewState { - pub fn name(self) -> &'static str { - match self { - ReviewState::None => "None", - ReviewState::Accepted => "Accepted", - ReviewState::Rejected => "Rejected", - ReviewState::Cancelled => "Cancelled", - ReviewState::Completed => "Completed", - } - } - - pub fn from_name(n: &str) -> Option { - [Self::None, Self::Accepted, Self::Rejected, Self::Cancelled, Self::Completed].into_iter().find(|s| s.name().eq_ignore_ascii_case(n)) - } -} - -// ── pages and /Annots ─────────────────────────────────────────────────────────────────────── - -/// The page object of each leaf page, in order. -pub fn page_refs(doc: &Document) -> Result, AnnotError> { - let root = doc.root().ok_or(AnnotError::NoPageTree)?; - let pages = doc.get(root).as_dict().and_then(|d| d.reference(b"Pages")).ok_or(AnnotError::NoPageTree)?; - Ok(PageWalk::new(doc, pages).collect()) -} - -/// Depth-first walk over the leaf pages under `top`. It keeps one cursor per open `/Kids` array, -/// never one entry per child, so a child listed many times or shared between nodes is skipped by -/// the seen check without being queued first (`pdfcraft_model::pages` walks the same way). -/// Cycles end at the seen check, a `/Kids` array object shared by several nodes is read once, -/// and at most a million nodes and four million `/Kids` entries are read. -struct PageWalk<'a> { - doc: &'a Document, - seen: std::collections::HashSet, - pending: Vec<(std::sync::Arc, usize)>, - /// `/Kids` entries read so far. - examined: usize, -} - -impl<'a> PageWalk<'a> { - fn new(doc: &'a Document, top: ObjRef) -> Self { - let top = std::sync::Arc::new(Object::Array(vec![Object::Ref(top)])); - Self { doc, seen: Default::default(), pending: vec![(top, 0)], examined: 0 } - } -} - -impl Iterator for PageWalk<'_> { - type Item = ObjRef; - - fn next(&mut self) -> Option { - while let Some((kids, next)) = self.pending.last_mut() { - let Some(kid) = kids.as_array().and_then(|a| a.get(*next)) else { - self.pending.pop(); - continue; - }; - let node = kid.as_ref(); - *next += 1; - self.examined += 1; - let Some(node) = node else { continue }; - if self.seen.len() >= 1_000_000 || self.examined > 4_000_000 { - self.pending.clear(); - return None; - } - if !self.seen.insert(node) { - continue; - } - let obj = self.doc.get(node); - let Some(d) = obj.as_dict() else { continue }; - match d.get(b"Kids") { - // A `/Kids` array object shared by several nodes is read under the first: under - // the others it would only list nodes already seen. - Some(Object::Ref(array)) if d.name(b"Type") != Some(b"Page") => { - if self.seen.insert(*array) { - self.pending.push((self.doc.get(*array), 0)); - } - } - Some(kids) if d.name(b"Type") != Some(b"Page") => self.pending.push((self.doc.resolve(kids), 0)), - _ => return Some(node), - } - } - None - } -} - -fn page_ref(doc: &Document, page: usize) -> Result { - page_refs(doc)?.get(page).copied().ok_or(AnnotError::NoSuchPage(page)) -} - -/// The entries of a page's `/Annots` (references or inline dictionaries). -fn annots(doc: &Document, page: ObjRef) -> Vec { - let obj = doc.get(page); - let Some(a) = obj.as_dict().and_then(|d| d.get(b"Annots")) else { return Vec::new() }; - let list = doc.resolve(a).as_array().cloned().unwrap_or_default(); - #[cfg(test)] - ANNOTS_ENTRIES_COPIED.with(|n| n.set(n.get() + list.len())); - list -} - -#[cfg(test)] -thread_local! { - /// Entries copied out of pages' `/Annots` arrays. - static ANNOTS_ENTRIES_COPIED: std::cell::Cell = const { std::cell::Cell::new(0) }; -} - -/// Replace a page's `/Annots`, writing into the shared array object when it is indirect. An empty -/// list removes a direct `/Annots`. -pub fn set_annots(doc: &mut Document, page: ObjRef, list: Vec) -> Result<(), pdfcraft_cos::CosError> { - let existing = doc.get(page).as_dict().and_then(|d| d.reference(b"Annots")); - match existing { - Some(r) if doc.get(r).as_array().is_some() => doc.set(r, Object::Array(list)), - _ => doc.update_dict(page, |d| { - if list.is_empty() { - d.remove(b"Annots"); - } else { - d.set(b"Annots".to_vec(), Object::Array(list)); - } - })?, - } - Ok(()) -} - -/// The annotation at `(page, index)` as an indirect object (inline dictionaries are promoted). -fn annot_ref(doc: &mut Document, page: usize, index: usize) -> Result<(ObjRef, ObjRef), AnnotError> { - let p = page_ref(doc, page)?; - let mut list = annots(doc, p); - let entry = list.get(index).cloned().ok_or(AnnotError::NoSuchAnnotation { page, index })?; - let r = match entry { - Object::Ref(r) if doc.get(r).as_dict().is_some() => r, - Object::Dict(d) => { - let r = doc.add(Object::Dict(d)); - list[index] = Object::Ref(r); - set_annots(doc, p, list)?; - r - } - _ => return Err(AnnotError::NoSuchAnnotation { page, index }), - }; - Ok((p, r)) -} - -fn annot_dict(doc: &Document, r: ObjRef) -> Dict { - doc.get(r).as_dict().cloned().unwrap_or_default() -} - -/// A Fill & Sign item: typed text, a check/cross/dot/line, or a typed, drawn or image signature. -/// -/// New items carry `/PCFillSign`. Older files are recognised from the dictionaries PdfCraft -/// already writes (`/IT /FreeTextTypeWriter`, stamp `/Name`, drawn ink whose subject is -/// "Signature"). -pub fn is_fill_sign(doc: &Document, d: &Dict) -> bool { - if matches!(d.get(b"PCFillSign").map(|o| doc.resolve(o)).as_deref(), Some(Object::Bool(true))) { - return true; - } - let subtype = d.name(b"Subtype").unwrap_or_default(); - if subtype == b"FreeText" && d.name(b"IT") == Some(b"FreeTextTypeWriter") { - return true; - } - if subtype == b"Stamp" { - if matches!(d.name(b"Name"), Some(b"PCCheck" | b"PCCross" | b"PCDot" | b"PCLine" | b"PCTypedSignature")) { - return true; - } - if matches!(d.name(b"Name"), Some(b"PCCustomSignature" | b"PCCustomInitials")) - && matches!(d.get(b"PCPictureImage").map(|o| doc.resolve(o)).as_deref(), Some(Object::Bool(true))) - { - return true; - } - } - subtype == b"Ink" && text_value(doc, d, b"Subj").as_deref() == Some("Signature") -} - -fn annotation_flags(doc: &Document, d: &Dict) -> i64 { - d.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0) -} - -/// Whether the renderer keeps the annotation upright on a turned page: the NoRotate flag, with -/// `/F` read as the renderer reads it (a real truncated, a value that isn't a `u32` no flags). -fn drawn_upright(doc: &Document, d: &Dict) -> bool { - let f = match d.get(b"F").map(|f| doc.resolve(f)).as_deref() { - Some(Object::Int(i)) => *i, - Some(Object::Real(r)) => *r as i64, - _ => 0, - }; - u32::try_from(f).is_ok_and(|f| i64::from(f) & FLAG_NO_ROTATE != 0) -} - -/// Whether any page has a Fill & Sign annotation that flatten would bake in (not hidden). -pub fn has_visible_fill_sign(doc: &Document) -> bool { - let Ok(pages) = page_refs(doc) else { return false }; - for page in pages { - for entry in annots(doc, page) { - let obj = doc.resolve(&entry); - let Some(d) = obj.as_dict() else { continue }; - let flags = annotation_flags(doc, d); - if flags & (FLAG_HIDDEN | FLAG_NO_VIEW) != 0 { - continue; - } - if is_fill_sign(doc, d) { - return true; - } - } - } - false -} - -fn fill_sign_shape(shape: &Shape) -> bool { - match shape { - Shape::Typewriter { .. } | Shape::Mark { .. } | Shape::Signature { .. } | Shape::TypedSignature { .. } => true, - Shape::CustomStamp { name, image: true, .. } => name == "Signature" || name == "Initials", - _ => false, - } -} - -/// The embedded image of a Fill & Sign image signature or initials (0-based target). -/// Other stamps have appearances that can't be represented by this image alone. -pub fn signature_image(doc: &Document, page: usize, index: usize) -> Result, AnnotError> { - let p = page_ref(doc, page)?; - let list = annots(doc, p); - let entry = list.get(index).ok_or(AnnotError::NoSuchAnnotation { page, index })?; - let obj = doc.resolve(entry); - let Some(d) = obj.as_dict() else { return Ok(None) }; - Ok((d.name(b"Subtype") == Some(b"Stamp") - && matches!(d.name(b"Name"), Some(b"PCCustomSignature" | b"PCCustomInitials")) - && matches!(d.get(b"PCPictureImage"), Some(Object::Bool(true)))) - .then(|| d.reference(b"PCPicture")) - .flatten()) -} - -/// The page `/Rotate` the picture of the image signature at `(page, index)` is drawn turned back -/// by, so it reads upright on a page shown that way: 0 for one added to an unturned page, or by -/// another app. The picture appears turned by the page's current rotation less this. -pub fn picture_rotation(doc: &Document, page: usize, index: usize) -> Result { - let p = page_ref(doc, page)?; - let list = annots(doc, p); - let entry = list.get(index).ok_or(AnnotError::NoSuchAnnotation { page, index })?; - Ok(doc.resolve(entry).as_dict().map_or(0, appearance::picture_turn)) -} - -/// Leave page `page` of (a copy of) a document showing only its annotation at `index`, without -/// the page's content or other annotations, so a render draws that annotation alone: to lay it -/// over the page while it is dragged. `false`, with `doc` unchanged, when the annotation has no -/// normal appearance (`/AP /N`) for that render to draw. -pub fn isolate_annotation(doc: &mut Document, page: usize, index: usize) -> Result { - let p = page_ref(doc, page)?; - let entry = annots(doc, p).get(index).cloned().ok_or(AnnotError::NoSuchAnnotation { page, index })?; - let has_normal = { - let obj = doc.resolve(&entry); - obj.as_dict().and_then(|d| d.get(b"AP")).is_some_and(|ap| doc.resolve(ap).as_dict().is_some_and(|ap| ap.contains(b"N"))) - }; - if !has_normal { - return Ok(false); - } - set_annots(doc, p, vec![entry])?; - doc.update_dict(p, |d| { - d.remove(b"Contents"); - })?; - Ok(true) -} - -// ── building ──────────────────────────────────────────────────────────────────────────────── - -/// Annotation flags (§12.5.3). -const FLAG_PRINT: i64 = 4; -const FLAG_NO_ZOOM: i64 = 8; -const FLAG_NO_ROTATE: i64 = 16; -const FLAG_HIDDEN: i64 = 2; -const FLAG_NO_VIEW: i64 = 32; -const FLAG_LOCKED: i64 = 128; - -/// Size of a note icon (points, unscaled by zoom). -pub const NOTE_SIZE: f64 = 20.0; - -fn num_array(v: &[f64]) -> Object { - Object::Array(v.iter().map(|x| Object::Real(*x)).collect()) -} - -fn rgb(c: Rgb) -> Object { - num_array(&c.map(|x| x.clamp(0.0, 1.0))) -} - -fn finite(v: &[f64]) -> bool { - v.iter().all(|x| x.is_finite() && x.abs() < 1e7) -} - -fn normalize(r: [f64; 4]) -> [f64; 4] { - [r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])] -} - -fn bounds(points: impl Iterator) -> Option<[f64; 4]> { - points.fold(None, |acc, [x, y]| match acc { - None => Some([x, y, x, y]), - Some([a, b, c, d]) => Some([a.min(x), b.min(y), c.max(x), d.max(y)]), - }) -} - -fn grow(r: [f64; 4], by: f64) -> [f64; 4] { - [r[0] - by, r[1] - by, r[2] + by, r[3] + by] -} - -fn ending_draws(ending: LineEnding) -> bool { - ending != LineEnding::None -} - -fn ending_pair(start: LineEnding, end: LineEnding) -> Object { - Object::Array(vec![Object::name(start.name()), Object::name(end.name())]) -} - -/// Validate and compute `/Rect` for a new comment. -fn rect_for(shape: &Shape, style: &Style) -> Result<[f64; 4], AnnotError> { - let bad = |what: &str| AnnotError::Invalid(format!("invalid {what}")); - let half = style.width.max(0.0) / 2.0; - let r = match shape { - Shape::Note { at, .. } | Shape::Attachment { at, .. } => { - if !finite(at) { - return Err(bad("position")); - } - [at[0], at[1] - NOTE_SIZE, at[0] + NOTE_SIZE, at[1]] - } - Shape::TextMarkup { quads, .. } | Shape::Redact { quads, .. } => { - if quads.is_empty() || !quads.iter().all(|q| finite(q)) { - return Err(bad("text area (no quadrilaterals)")); - } - bounds(quads.iter().flat_map(|q| q.as_chunks::<2>().0.iter().map(|p| [p[0], p[1]]).collect::>())) - .ok_or_else(|| bad("text area"))? - } - Shape::Rectangle { rect } - | Shape::Oval { rect } - | Shape::TextBox { rect, .. } - | Shape::Typewriter { rect, .. } - | Shape::Stamp { rect, .. } - | Shape::TypedSignature { rect, .. } - | Shape::Mark { rect, .. } => { - let r = normalize(*rect); - if !finite(rect) || r[2] - r[0] < 1.0 || r[3] - r[1] < 1.0 { - return Err(bad("rectangle (too small)")); - } - r - } - Shape::CustomStamp { rect, .. } => { - let r = normalize(*rect); - // Image signatures may be very thin; a positive PDF appearance box still works. - if !finite(rect) || r[2] <= r[0] || r[3] <= r[1] { - return Err(bad("rectangle (empty)")); - } - r - } - Shape::Line { from, to, start, end } => { - if !finite(from) || !finite(to) || (from[0] - to[0]).hypot(from[1] - to[1]) < 1.0 { - return Err(bad("line (too short)")); - } - let pad = half + if ending_draws(*start) || ending_draws(*end) { appearance::arrow_size(style.width) } else { 0.0 }; - grow(bounds([*from, *to].into_iter()).unwrap_or_default(), pad + 1.0) - } - Shape::Ink { strokes } | Shape::Signature { strokes } => { - if strokes.iter().all(Vec::is_empty) || !strokes.iter().flatten().all(|p| finite(p)) { - return Err(bad("drawing (no points)")); - } - // Strokes of three or more points are drawn as curves, which stay within their - // points and control points. - let controls = strokes.iter().filter(|s| s.len() > 2).flat_map(|s| { - let pts: Vec<(f64, f64)> = s.iter().map(|p| (p[0], p[1])).collect(); - appearance::smooth_segments(&pts).into_iter().flat_map(|[a, b, _]| [[a.0, a.1], [b.0, b.1]]) - }); - grow(bounds(strokes.iter().flatten().copied().chain(controls)).unwrap_or_default(), half + 1.0) - } - Shape::Polygon { vertices, cloud } => { - let b = bounds(vertices.iter().copied()) - .filter(|b| vertices.len() >= 3 && vertices.iter().all(|p| finite(p)) && b[2] - b[0] >= 1.0 && b[3] - b[1] >= 1.0); - let b = b.ok_or_else(|| bad("polygon (it needs three points)"))?; - grow(b, half + 1.0 + if *cloud { 1.5 * appearance::cloud_radius(style.width) } else { 0.0 }) - } - Shape::PolyLine { vertices, start, end } => { - let b = bounds(vertices.iter().copied()) - .filter(|b| vertices.len() >= 2 && vertices.iter().all(|p| finite(p)) && (b[2] - b[0]).max(b[3] - b[1]) >= 1.0); - let extra = if ending_draws(*start) || ending_draws(*end) { appearance::arrow_size(style.width) } else { 0.0 }; - grow(b.ok_or_else(|| bad("connected lines (they need two points)"))?, half + 1.0 + extra) - } - Shape::Callout { rect, knee, point, .. } => { - let r = normalize(*rect); - if !finite(rect) || !finite(knee) || !finite(point) || r[2] - r[0] < 1.0 || r[3] - r[1] < 1.0 { - return Err(bad("callout")); - } - let pad = half + appearance::arrow_size(style.width) + 1.0; - let lead = grow(bounds([*knee, *point].into_iter()).unwrap_or_default(), pad); - [r[0].min(lead[0]), r[1].min(lead[1]), r[2].max(lead[2]), r[3].max(lead[3])] - } - Shape::Caret { rect } => { - let r = normalize(*rect); - if !finite(rect) || r[2] - r[0] < 1.0 || r[3] - r[1] < 1.0 { - return Err(bad("caret")); - } - r - } - }; - Ok(r) -} - -fn base_dict(subtype: &str, rect: [f64; 4], page: ObjRef, contents: &str, author: &str, meta: &Meta) -> Dict { - let mut d = Dict::new(); - d.set(b"Type".to_vec(), Object::name("Annot")); - d.set(b"Subtype".to_vec(), Object::name(subtype)); - d.set(b"Rect".to_vec(), num_array(&rect)); - d.set(b"Contents".to_vec(), PdfString::text(contents)); - if !author.is_empty() { - d.set(b"T".to_vec(), PdfString::text(author)); - } - if let Some(date) = &meta.date { - d.set(b"M".to_vec(), PdfString::literal(date.as_bytes().to_vec())); - d.set(b"CreationDate".to_vec(), PdfString::literal(date.as_bytes().to_vec())); - } - if !meta.id.is_empty() { - d.set(b"NM".to_vec(), PdfString::text(&meta.id)); - } - d.set(b"P".to_vec(), Object::Ref(page)); - d.set(b"F".to_vec(), Object::Int(FLAG_PRINT)); - d -} - -/// Acrobat's `/Subj` for each tool (shown as the comment type in other viewers). -fn subject(shape: &Shape) -> &'static str { - match shape { - Shape::Note { .. } => "Sticky Note", - Shape::TextMarkup { kind: Markup::Highlight, .. } => "Highlight", - Shape::TextMarkup { kind: Markup::Underline, .. } => "Underline", - Shape::TextMarkup { kind: Markup::StrikeOut, .. } => "Strikethrough", - Shape::TextMarkup { kind: Markup::Squiggly, .. } => "Squiggly", - Shape::Rectangle { .. } => "Rectangle", - Shape::Oval { .. } => "Oval", - Shape::Line { start: LineEnding::None, end: LineEnding::OpenArrow, .. } => "Arrow", - Shape::Line { .. } => "Line", - Shape::Ink { .. } => "Pencil", - Shape::TextBox { .. } => "Text Box", - Shape::Typewriter { .. } => "Typewriter", - Shape::Mark { mark, .. } => mark.label(), - Shape::Signature { .. } => "Signature", - Shape::Redact { .. } => "Redact", - Shape::Stamp { stamp, .. } => match stamp.group() { - StampGroup::Dynamic => "Dynamic stamp", - StampGroup::SignHere => "Sign Here", - StampGroup::StandardBusiness => "Stamp", - }, - Shape::CustomStamp { .. } => "Stamp", - Shape::TypedSignature { .. } => "Signature", - Shape::Polygon { cloud: true, .. } => "Cloud", - Shape::Polygon { .. } => "Polygon", - Shape::PolyLine { .. } => "Polygonal Line", - Shape::Callout { .. } => "Callout", - Shape::Caret { .. } => "Inserted Text", - Shape::Attachment { .. } => "File Attachment", - } -} - -/// Where a callout's leader line meets its text box: the middle of the side facing `knee`. -pub fn callout_attach(rect: [f64; 4], knee: [f64; 2]) -> [f64; 2] { - let [x0, y0, x1, y1] = normalize(rect); - let (cx, cy) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0); - if knee[0] < x0 { - [x0, cy] - } else if knee[0] > x1 { - [x1, cy] - } else if knee[1] > y1 { - [cx, y1] - } else { - [cx, y0] - } -} - -/// Add a comment; returns its index in the page's `/Annots`. -pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> Result { - let page = page_ref(doc, new.page)?; - let style = &new.style; - if !finite(&style.color) || !style.opacity.is_finite() || !style.width.is_finite() { - return Err(AnnotError::Invalid("invalid style".into())); - } - let rect = rect_for(&new.shape, style)?; - let mut d = base_dict(new.shape.subtype(), rect, page, &new.contents, &new.author, meta); - d.set(b"Subj".to_vec(), PdfString::text(subject(&new.shape))); - let opacity = style.opacity.clamp(0.0, 1.0); - if opacity < 1.0 { - d.set(b"CA".to_vec(), Object::Real(opacity)); - } - let border = |d: &mut Dict| { - let mut bs = Dict::new(); - bs.set(b"W".to_vec(), Object::Real(style.width.max(0.0))); - bs.set(b"S".to_vec(), Object::name("S")); - d.set(b"BS".to_vec(), Object::Dict(bs)); - }; - let mut popup = None; - match &new.shape { - Shape::Note { icon, .. } => { - d.set(b"C".to_vec(), rgb(style.color)); - d.set(b"Name".to_vec(), Object::name(icon.name())); - d.set(b"F".to_vec(), Object::Int(FLAG_PRINT | FLAG_NO_ZOOM | FLAG_NO_ROTATE)); - d.set(b"Open".to_vec(), Object::Bool(false)); - popup = Some([rect[2] + 10.0, rect[3] - 120.0, rect[2] + 210.0, rect[3]]); - } - Shape::TextMarkup { quads, .. } => { - d.set(b"C".to_vec(), rgb(style.color)); - d.set(b"QuadPoints".to_vec(), num_array(&quads.concat())); - } - Shape::Redact { quads, overlay, look } => { - d.set(b"C".to_vec(), rgb(style.color)); - d.set(b"IC".to_vec(), rgb(style.fill.unwrap_or([0.0, 0.0, 0.0]))); - d.set(b"QuadPoints".to_vec(), num_array(&quads.concat())); - if !overlay.is_empty() { - d.set(b"OverlayText".to_vec(), PdfString::text(overlay)); - let [r, g, b] = look.color.map(|x| x.clamp(0.0, 1.0)); - let size = if look.size > 0.0 { look.size.min(400.0) } else { 0.0 }; - d.set( - b"DA".to_vec(), - PdfString::literal(format!("{} {} {} rg /{} {} Tf", n(r), n(g), n(b), look.font.resource(), n(size)).into_bytes()), - ); - d.set(b"Q".to_vec(), Object::Int(look.align.min(2) as i64)); - if look.repeat { - d.set(b"Repeat".to_vec(), Object::Bool(true)); - } - } - } - Shape::Rectangle { .. } | Shape::Oval { .. } => { - d.set(b"C".to_vec(), rgb(style.color)); - if let Some(f) = style.fill { - d.set(b"IC".to_vec(), rgb(f)); - } - border(&mut d); - } - Shape::Line { from, to, start, end } => { - d.set(b"C".to_vec(), rgb(style.color)); - d.set(b"L".to_vec(), num_array(&[from[0], from[1], to[0], to[1]])); - if ending_draws(*start) || ending_draws(*end) { - d.set(b"LE".to_vec(), ending_pair(*start, *end)); - } - border(&mut d); - } - Shape::Mark { mark, .. } => { - d.set(b"C".to_vec(), rgb(style.color)); - d.set(b"Name".to_vec(), Object::name(mark.name())); - border(&mut d); - // Drawn upright for the page as displayed, as a stamp is (#2392). - let turn = pdfcraft_model::pages(doc).get(new.page).map_or(0, |p| p.rotation(doc)); - if matches!(turn, 90 | 180 | 270) { - d.set(b"PCStampRotate".to_vec(), Object::Int(turn)); - } - } - Shape::Stamp { stamp, by, .. } => { - d.set(b"C".to_vec(), rgb(style.color)); - d.set(b"Name".to_vec(), Object::name(stamp.name())); - // Marks the stamp as drawn by PdfCraft: other stamps with standard names keep - // their own artwork. - d.set(b"PCStamp".to_vec(), Object::Bool(true)); - if let Some(b) = by { - d.set(b"PCByLine".to_vec(), PdfString::text(b)); - } - // The label is drawn for the page as displayed. A later page rotation is a new edit. - let turn = pdfcraft_model::pages(doc).get(new.page).map_or(0, |p| p.rotation(doc)); - if matches!(turn, 90 | 180 | 270) { - d.set(b"PCStampRotate".to_vec(), Object::Int(turn)); - } - } - Shape::TypedSignature { contours, .. } => { - d.set(b"C".to_vec(), rgb(style.color)); - d.set(b"Name".to_vec(), Object::name("PCTypedSignature")); - let list = contours.iter().filter(|c| c.len() > 2).map(|c| num_array(&c.iter().flat_map(|p| [p[0], p[1]]).collect::>())).collect(); - d.set(b"PCOutline".to_vec(), Object::Array(list)); - } - Shape::CustomStamp { name, picture, image, size, .. } => { - let clean: String = name.chars().filter(char::is_ascii_alphanumeric).collect(); - d.set(b"Name".to_vec(), Object::name(&format!("PCCustom{clean}"))); - d.set(b"PCPicture".to_vec(), Object::Ref(*picture)); - d.set(b"PCPictureImage".to_vec(), Object::Bool(*image)); - d.set(b"PCPictureSize".to_vec(), num_array(&[size.0, size.1])); - // An image signature's rectangle is in user space; on a turned page its appearance is - // turned back (see `appearance::build`), so it reads upright as displayed. - if *image && matches!(clean.as_str(), "Signature" | "Initials") { - let turn = pdfcraft_model::pages(doc).get(new.page).map_or(0, |p| p.rotation(doc)); - if turn != 0 { - d.set(b"PCPictureRotate".to_vec(), Object::Int(turn)); - } - } - } - Shape::Ink { strokes } | Shape::Signature { strokes } => { - d.set(b"C".to_vec(), rgb(style.color)); - let list = strokes.iter().filter(|s| !s.is_empty()).map(|s| num_array(&s.concat())).collect(); - d.set(b"InkList".to_vec(), Object::Array(list)); - border(&mut d); - } - Shape::Polygon { vertices, cloud } => { - d.set(b"C".to_vec(), rgb(style.color)); - if let Some(f) = style.fill { - d.set(b"IC".to_vec(), rgb(f)); - } - d.set(b"Vertices".to_vec(), num_array(&vertices.concat())); - if *cloud { - d.set(b"IT".to_vec(), Object::name("PolygonCloud")); - let mut be = Dict::new(); - be.set(b"S".to_vec(), Object::name("C")); - be.set(b"I".to_vec(), Object::Int(1)); - d.set(b"BE".to_vec(), Object::Dict(be)); - } - border(&mut d); - } - Shape::PolyLine { vertices, start, end } => { - d.set(b"C".to_vec(), rgb(style.color)); - d.set(b"Vertices".to_vec(), num_array(&vertices.concat())); - if ending_draws(*start) || ending_draws(*end) { - d.set(b"LE".to_vec(), ending_pair(*start, *end)); - } - border(&mut d); - } - Shape::Caret { .. } => { - d.set(b"C".to_vec(), rgb(style.color)); - d.set(b"Sy".to_vec(), Object::name("None")); - } - Shape::Attachment { icon, file, data, .. } => { - if file.trim().is_empty() { - return Err(AnnotError::Invalid("the attached file needs a name".into())); - } - d.set(b"C".to_vec(), rgb(style.color)); - d.set(b"Name".to_vec(), Object::name(icon.name())); - d.set(b"F".to_vec(), Object::Int(FLAG_PRINT | FLAG_NO_ZOOM | FLAG_NO_ROTATE)); - // The file: an embedded file stream inside a file specification (§7.11.4). - let mut params = Dict::new(); - params.set(b"Size".to_vec(), Object::Int(data.len() as i64)); - if let Some(date) = &meta.date { - params.set(b"ModDate".to_vec(), PdfString::literal(date.as_bytes().to_vec())); - } - let mut ef = Dict::new(); - ef.set(b"Type".to_vec(), Object::name("EmbeddedFile")); - ef.set(b"Params".to_vec(), Object::Dict(params)); - let ef = doc.add(Object::Stream(pdfcraft_cos::Stream::flate(ef, data))); - let mut efd = Dict::new(); - efd.set(b"F".to_vec(), Object::Ref(ef)); - efd.set(b"UF".to_vec(), Object::Ref(ef)); - let mut fs = Dict::new(); - fs.set(b"Type".to_vec(), Object::name("Filespec")); - fs.set(b"F".to_vec(), PdfString::text(file.trim())); - fs.set(b"UF".to_vec(), PdfString::text(file.trim())); - fs.set(b"EF".to_vec(), Object::Dict(efd)); - d.set(b"FS".to_vec(), Object::Ref(doc.add(Object::Dict(fs)))); - if new.contents.is_empty() { - d.set(b"Contents".to_vec(), PdfString::text(file.trim())); - } - } - Shape::Callout { rect: tb, knee, point, ending, .. } => { - let tb = normalize(*tb); - let attach = callout_attach(tb, *knee); - d.set(b"IT".to_vec(), Object::name("FreeTextCallout")); - d.set(b"CL".to_vec(), num_array(&[point[0], point[1], knee[0], knee[1], attach[0], attach[1]])); - d.set(b"LE".to_vec(), Object::name(ending.name())); - // The text box inside /Rect (§12.5.6.6 /RD). - d.set(b"RD".to_vec(), num_array(&[tb[0] - rect[0], tb[1] - rect[1], rect[2] - tb[2], rect[3] - tb[3]])); - } - _ => {} - } - match &new.shape { - Shape::TextBox { font_size, .. } | Shape::Typewriter { font_size, .. } | Shape::Callout { font_size, .. } => { - if matches!(new.shape, Shape::Typewriter { .. }) { - d.set(b"IT".to_vec(), Object::name("FreeTextTypeWriter")); - } - let size = if font_size.is_finite() && *font_size > 0.0 { font_size.clamp(1.0, 400.0) } else { 12.0 }; - let [r, g, b] = style.color.map(|x| x.clamp(0.0, 1.0)); - d.set(b"DA".to_vec(), PdfString::literal(format!("{} {} {} rg /Helv {} Tf", n(r), n(g), n(b), n(size)).into_bytes())); - d.set(b"Q".to_vec(), Object::Int(0)); - if let Some(f) = style.fill { - d.set(b"C".to_vec(), rgb(f)); - } - border(&mut d); - rich_text(&mut d); - // The text is laid out for the page as displayed, like a stamp's label. - if !matches!(new.shape, Shape::Callout { .. }) { - let turn = pdfcraft_model::pages(doc).get(new.page).map_or(0, |p| p.rotation(doc)); - if matches!(turn, 90 | 180 | 270) { - d.set(b"PCTextRotate".to_vec(), Object::Int(turn)); - } - } - } - _ => {} - } - if fill_sign_shape(&new.shape) { - // Survives a later subject edit, so flatten-on-save still finds the mark. - d.set(b"PCFillSign".to_vec(), Object::Bool(true)); - } - // A redaction's overlay text becomes page content when it is applied, where `?` would be there - // for good: refused before anything is added. A comment's own text is stored as typed and - // drawn with `?` for what the standard fonts can't draw (marked, see `draw_appearance`). - if d.name(b"Subtype") == Some(b"Redact") - && let Some((what, c)) = appearance::undrawable_text(&d) - { - return Err(AnnotError::Undrawable(pdfcraft_fonts::undrawable_message(what, c))); - } - let r = doc.add(Object::Dict(d.clone())); - draw_appearance(doc, r)?; - let mut list = annots(doc, page); - let index = list.len(); - list.push(Object::Ref(r)); - if let Some(pr) = popup { - let mut p = Dict::new(); - p.set(b"Type".to_vec(), Object::name("Annot")); - p.set(b"Subtype".to_vec(), Object::name("Popup")); - p.set(b"Rect".to_vec(), num_array(&pr)); - p.set(b"Parent".to_vec(), Object::Ref(r)); - p.set(b"Open".to_vec(), Object::Bool(false)); - p.set(b"F".to_vec(), Object::Int(FLAG_PRINT | FLAG_NO_ZOOM | FLAG_NO_ROTATE)); - let pref = doc.add(Object::Dict(p)); - doc.update_dict(r, |d| d.set(b"Popup".to_vec(), Object::Ref(pref)))?; - list.push(Object::Ref(pref)); - } - set_annots(doc, page, list)?; - Ok(index) -} - -/// Counterrotate a newly placed picture stamp's appearance into displayed-page axes. -/// The picture may be an image or a PDF page. The normal appearance's transformed bounding -/// box is fitted to `/Rect` by PDF viewers. -pub fn orient_image_stamp(doc: &mut Document, page: usize, index: usize, rotation: i64) -> Result<(), AnnotError> { - let matrix = match rotation { - 90 => [0.0, 1.0, -1.0, 0.0, 0.0, 0.0], - 180 => [-1.0, 0.0, 0.0, -1.0, 0.0, 0.0], - 270 => [0.0, -1.0, 1.0, 0.0, 0.0, 0.0], - _ => return Ok(()), - }; - let (_, r) = annot_ref(doc, page, index)?; - let d = annot_dict(doc, r); - if d.name(b"Subtype") != Some(b"Stamp") || d.get(b"PCPicture").is_none() { - return Err(AnnotError::Invalid("only a picture stamp can be oriented".into())); - } - let normal = d - .get(b"AP") - .map(|ap| doc.resolve(ap)) - .and_then(|ap| ap.as_dict().and_then(|ap| ap.reference(b"N"))) - .ok_or_else(|| AnnotError::Invalid("the stamp has no normal appearance".into()))?; - let Object::Stream(mut stream) = doc.get(normal).as_ref().clone() else { - return Err(AnnotError::Invalid("the stamp's normal appearance is not a stream".into())); - }; - stream.dict.set(b"Matrix".to_vec(), num_array(&matrix)); - doc.set(normal, Object::Stream(stream)); - Ok(()) -} - -/// A comment another program drew, whose text the standard fonts can't draw. Redrawing it would -/// replace those characters with `?`, so the edit is refused before the dictionary changes. -fn refuse_foreign_text(doc: &Document, d: &Dict) -> Result<(), AnnotError> { - let drawn_elsewhere = - d.get(b"AP").is_some_and(|ap| doc.resolve(ap).as_dict().is_some_and(|ap| ap.contains(b"N"))) && pdfcraft_fonts::undrawn_mark(d).is_none(); - if drawn_elsewhere && let Some((what, c)) = appearance::undrawable_text(d) { - return Err(AnnotError::Undrawable(format!( - "PdfCraft can't redraw this comment: the standard fonts can't draw \"{}\" (U+{:04X}) in {what}, which would then show as ? (the comment was left as it is)", - c.escape_debug(), - u32::from(c) - ))); - } - Ok(()) -} - -/// (Re)generate `/AP /N` for the annotation `r` from its dictionary, for an edit that keeps its -/// text (a restyle, a new size or colour). -/// -/// A comment whose text the standard fonts can't draw, with an appearance another program drew -/// (with a font that has those characters), keeps that appearance: redrawing it would put `?` in -/// their place, so the edit is refused (`AnnotError::Undrawable`). One PdfCraft drew with `?` -/// (marked `pdfcraft_fonts::UNDRAWN_MARK`) is redrawn. -pub fn set_appearance(doc: &mut Document, r: ObjRef) -> Result<(), AnnotError> { - refuse_foreign_text(doc, &annot_dict(doc, r))?; - draw_appearance(doc, r) -} - -/// Put `before` back when redrawing fails, so a refused edit does not keep the new rectangle or style. -fn redraw_or_restore(doc: &mut Document, r: ObjRef, before: Dict) -> Result<(), AnnotError> { - if let Err(e) = set_appearance(doc, r) { - doc.set(r, Object::Dict(before)); - return Err(e); - } - Ok(()) -} - -/// Draw `r`'s normal appearance from its dictionary, whatever its text: characters the standard -/// fonts can't draw come out as `?`, and the annotation is marked (`pdfcraft_fonts::UNDRAWN_MARK`, -/// cleared when all of it can be drawn) so the edit is reported and flattening won't make that -/// `?` permanent. For new text (a new comment, changed contents), as Acrobat keeps text its font -/// can't show, and for display-only copies. -fn draw_appearance(doc: &mut Document, r: ObjRef) -> Result<(), AnnotError> { - let d = annot_dict(doc, r); - let subtype = String::from_utf8_lossy(d.name(b"Subtype").unwrap_or_default()).into_owned(); - let Some(stream) = build_appearance(doc, &d) else { return Err(AnnotError::Unsupported(subtype)) }; - apply_appearance(doc, r, &d, stream) -} - -/// Draw the appearance for an annotation dictionary: the expensive half of [`set_appearance`]. -/// Pure from the dictionary and the objects it points at, so [`AppearanceCache`] can reuse it. -fn build_appearance(doc: &Document, d: &Dict) -> Option { - let mut stream = appearance::build(d)?; - // Picture-stamp restyling must retain the placement's page-axis correction. - if d.get(b"PCPicture").is_some() { - let matrix = d.get(b"AP").and_then(|ap| { - let ap = doc.resolve(ap); - let normal = doc.resolve(ap.as_dict()?.get(b"N")?); - let Object::Stream(normal) = normal.as_ref() else { return None }; - normal.dict.get(b"Matrix").cloned() - }); - if let Some(matrix) = matrix { - stream.dict.set(b"Matrix".to_vec(), matrix); - } - } - Some(stream) -} - -/// Store a built appearance on the annotation: the cheap half of [`set_appearance`]. -fn apply_appearance(doc: &mut Document, r: ObjRef, d: &Dict, stream: Stream) -> Result<(), AnnotError> { - // Characters the standard fonts can't draw came out as `?`: mark the annotation with the first. - let missing = appearance::undrawable_text(d).map(|(_, c)| c); - let ap = doc.add(Object::Stream(stream)); - // A copy (a shared /AP is left alone) that keeps unknown entries; the old down and rollover - // appearances would show the previous look on press or hover, so they go with the old /N. - let mut apd = d.get(b"AP").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().cloned()).unwrap_or_default(); - apd.remove(b"D"); - apd.remove(b"R"); - apd.set(b"N".to_vec(), Object::Ref(ap)); - // A note icon has a fixed size (`NOTE_SIZE`); viewers draw it at the upper-left corner of `/Rect`. - // The appearance's box is mapped onto `/Rect`, so any other `/Rect` (a large one, or one with - // no width, as LaTeX `pdfcomment` writes) would stretch the icon or shrink it to nothing. - let icon_rect = matches!(d.name(b"Subtype"), Some(b"Text" | b"FileAttachment")) - .then(|| d.get(b"Rect")?.as_array()?.iter().map(|o| o.as_f64()).collect::>>()) - .flatten() - .filter(|q| q.len() == 4 && finite(q)) - .map(|q| { - let (x, top) = (q[0].min(q[2]), q[1].max(q[3])); - [x, top - NOTE_SIZE, x + NOTE_SIZE, top] - }); - doc.update_dict(r, |d| { - d.set(b"AP".to_vec(), Object::Dict(apd)); - if let Some(rect) = icon_rect { - d.set(b"Rect".to_vec(), num_array(&rect)); - } - d.remove(b"AS"); - pdfcraft_fonts::set_undrawn_mark(d, missing); - })?; - Ok(()) -} - -/// Display appearances built for annotations without any, reused while the annotation -/// dictionaries they were drawn from are unchanged. -/// -/// A content edit (moving a paragraph, retyping text, …) leaves every annotation dictionary -/// alone, but the display copy is rebuilt from the edited document on every refresh: without -/// this cache each refresh redrew every appearance (27k on one CAD file, ~0.7 s per edit -/// commit). Entries are keyed by annotation reference; object numbers are never reused within -/// a document lineage, so an entry stays valid until an annotation edit runs. Callers clear -/// the cache on any refresh that may change annotations (comments, structural and full -/// refreshes) and keep it for content-only ones. -#[derive(Clone, Debug, Default)] -pub struct AppearanceCache { - entries: std::collections::HashMap, - bytes: usize, - hits: u64, - builds: u64, -} - -impl AppearanceCache { - /// Cached appearance data restarts past this instead of growing without bound. - const MAX_BYTES: usize = 64 << 20; - - /// Forget every entry (an annotation edit may have changed the dictionaries). - pub fn clear(&mut self) { - *self = Self::default(); - } - - fn get(&mut self, r: ObjRef) -> Option { - let stream = self.entries.get(&r)?.clone(); - self.hits += 1; - Some(stream) - } - - fn insert(&mut self, r: ObjRef, stream: &Stream) { - self.builds += 1; - if self.entries.len() >= 1_000_000 { - self.clear(); - } - if self.bytes.saturating_add(stream.raw.len()) <= Self::MAX_BYTES { - self.bytes += stream.raw.len(); - self.entries.insert(r, stream.clone()); - } - // Else over budget: keep serving the entries already cached instead of growing. - } - - /// Cache hits so far (reused appearances); for tests. - pub fn hits(&self) -> u64 { - self.hits - } - - /// Appearances built so far; for tests. - pub fn builds(&self) -> u64 { - self.builds - } -} - -/// A copy of `doc` in which every annotation without a normal appearance (`/AP /N`) has the one -/// [`appearance::build`] draws from its dictionary, for displaying the document (`None` when no -/// annotation needs one). Many files carry comments without appearances (FreeText, Ink, notes, -/// stamps, …) that viewers draw from the dictionary. `doc` is not changed: saving writes the file -/// as it was. Links, form fields and pop-ups keep their own handling. -pub fn with_missing_appearances(doc: &Document) -> Option { - with_missing_appearances_cached(doc, &mut AppearanceCache::default()) -} - -/// A copy of `doc` with [`AppearanceCache`] reuse across refreshes: annotations whose -/// dictionary is unchanged since the cached build keep their stored appearance instead of -/// redrawing it. See [`AppearanceCache`] for when entries stay valid. -pub fn with_missing_appearances_cached(doc: &Document, cache: &mut AppearanceCache) -> Option { - let mut copy: Option = None; - // An annotation listed more than once, on one page or several, is drawn once. `has_normal` - // reads `doc`, which never gets the appearance, so every repeat used to add another stream to - // the copy, and the display bytes grew with the number of listings. - let mut seen = std::collections::HashSet::new(); - for page in page_refs(doc).ok()? { - for r in annots(doc, page).iter().filter_map(Object::as_ref) { - if !seen.insert(r) { - continue; - } - let d = annot_dict(doc, r); - if matches!(d.name(b"Subtype"), None | Some(b"Link" | b"Widget" | b"Popup")) { - continue; - } - let has_normal = d.get(b"AP").is_some_and(|ap| doc.resolve(ap).as_dict().is_some_and(|ap| ap.contains(b"N"))); - if has_normal { - continue; - } - if let Some(stream) = cache.get(r) { - // Fails only when `r` isn't a dictionary, which the cached build ruled out. - apply_appearance(copy.get_or_insert_with(|| doc.clone()), r, &d, stream).ok(); - continue; - } - // Another program's stamp gets a drawing for display only (ISO 32000-2 Table 184). Its - // `/AP`, if any, has no `/N` to keep here. - let other_stamp = || { - let mut bare = d.clone(); - bare.remove(b"AP"); - appearance::display_stamp(&bare) - }; - let Some(stream) = build_appearance(doc, &d).or_else(other_stamp) else { continue }; - cache.insert(r, &stream); - // Fails only when `r` isn't a dictionary, which the build just ruled out. - apply_appearance(copy.get_or_insert_with(|| doc.clone()), r, &d, stream).ok(); - } - } - copy -} - -/// Format a number for content streams and DA strings. -pub(crate) fn n(v: f64) -> String { - let s = format!("{:.3}", if v.abs() < 5e-4 { 0.0 } else { v }); - let s = s.trim_end_matches('0').trim_end_matches('.'); - if s == "-0" { "0".into() } else { s.into() } -} - -// ── edits ─────────────────────────────────────────────────────────────────────────────────── - -/// A text box's rich text (§12.7.4.3): `/DS` (default style) and `/RC` (XHTML) written from its -/// plain `/Contents`, `/DA` and `/Q`, so viewers that edit rich text show the same thing. -fn rich_text(d: &mut Dict) { - let text = d.get(b"Contents").and_then(|c| c.as_string()).map(PdfString::to_text).unwrap_or_default(); - let (col, size) = appearance::parse_da(d); - let hex = format!( - "#{:02X}{:02X}{:02X}", - (col[0].clamp(0.0, 1.0) * 255.0).round() as u8, - (col[1].clamp(0.0, 1.0) * 255.0).round() as u8, - (col[2].clamp(0.0, 1.0) * 255.0).round() as u8 - ); - let align = match d.get(b"Q").and_then(Object::as_int) { - Some(1) => "center", - Some(2) => "right", - _ => "left", - }; - let esc = |s: &str| s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """); - let body: String = text.split('\n').map(|line| format!("

{}

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

{}

", esc(line.trim_end_matches('\r')))).collect(); + let style = format!( + "font-size:{}pt;text-align:{align};color:{hex};font-weight:normal;font-style:normal;font-family:Helvetica;font-stretch:normal", + n(size) + ); + let rc = format!( + "{body}" + ); + d.set(b"DS".to_vec(), PdfString::text(&format!("font: Helvetica {}pt; text-align:{align}; color:{hex}", n(size)))); + d.set(b"RC".to_vec(), PdfString::text(&rc)); +} + +pub(crate) fn touch(d: &mut Dict, meta: &Meta) { + if let Some(date) = &meta.date { + d.set(b"M".to_vec(), PdfString::literal(date.as_bytes().to_vec())); + } +} + +/// Delete a comment together with its pop-up and its replies (and theirs), as Acrobat does. +pub fn delete_annotation(doc: &mut Document, page: usize, index: usize) -> Result<(), AnnotError> { + let p = page_ref(doc, page)?; + let list = annots(doc, p); + let target = list.get(index).cloned().ok_or(AnnotError::NoSuchAnnotation { page, index })?; + if let Some(r) = target.as_ref() { + unlocked(doc, r)?; + } + // An inline comment has no reference to collect; the pop-up it names still goes. + let seed = target.as_ref().or_else(|| target.as_dict().and_then(|d| d.reference(b"Popup"))); + let doomed = with_replies_and_popups(doc, &list, seed); + let kept: Vec = list.into_iter().enumerate().filter(|(i, e)| *i != index && !goes_with(e, &doomed)).map(|(_, e)| e).collect(); + Ok(set_annots(doc, p, kept)?) +} + +/// Whether the `/Annots` entry `entry` goes with `going` (from [`with_replies_and_popups`]): it is +/// one of them, or it is written inline (a file can, though the standard asks for references) and +/// replies to (`/IRT`) or pops up for (`/Parent`) one of them. An inline entry has no reference +/// for the walk to collect, so it is matched here. +pub fn goes_with(entry: &Object, going: &HashSet) -> bool { + match entry.as_ref() { + Some(r) => going.contains(&r), + None => entry.as_dict().is_some_and(|d| [d.reference(b"IRT"), d.reference(b"Parent")].into_iter().flatten().any(|r| going.contains(&r))), + } +} + +/// `doomed` and what goes with it, as Acrobat deletes a comment: the entries of `list` (one or +/// more pages' `/Annots`) that reply to (`/IRT`) or pop up for (`/Parent`) anything going, their +/// own replies, and the pop-ups going annotations name (`/Popup`). An inline reply has no +/// reference to collect, but the pop-up it names is. Each annotation is visited once, so a long or +/// cyclic chain costs no more than its length. Filter a list with [`goes_with`], which also +/// matches inline entries. +pub fn with_replies_and_popups(doc: &Document, list: &[Object], doomed: impl IntoIterator) -> HashSet { + let mut hanging: HashMap> = HashMap::new(); + for e in list { + let obj = doc.resolve(e); + let Some(d) = obj.as_dict() else { continue }; + // What goes with the annotation this entry hangs off: the entry, or for an inline one the + // pop-up it names. + let Some(goes) = e.as_ref().or_else(|| d.reference(b"Popup")) else { continue }; + for on in [d.reference(b"IRT"), d.reference(b"Parent")].into_iter().flatten() { + hanging.entry(on).or_default().push(goes); + } + } + let mut going = HashSet::new(); + let mut todo: Vec = doomed.into_iter().collect(); + while let Some(r) = todo.pop() { + if !going.insert(r) { + continue; + } + todo.extend(hanging.get(&r).into_iter().flatten()); + todo.extend(doc.get(r).as_dict().and_then(|d| d.reference(b"Popup"))); + } + going +} + +/// Change a comment's text. Text boxes are redrawn to show it, and their rectangle follows the +/// new text: the wrap width and top edge stay, the height fits the wrapped lines. +pub fn set_contents(doc: &mut Document, page: usize, index: usize, text: &str, meta: &Meta) -> Result<(), AnnotError> { + let (_, r) = annot_ref(doc, page, index)?; + let free_text = annot_dict(doc, r).name(b"Subtype") == Some(b"FreeText"); + doc.update_dict(r, |d| { + d.set(b"Contents".to_vec(), PdfString::text(text)); + // The rich-text version follows the new plain text (text boxes), or would contradict it. + if free_text { + rich_text(d); + } else { + d.remove(b"RC"); + } + touch(d, meta); + })?; + if free_text { + // The box grows and shrinks with the text instead of clipping it. A locked box keeps its + // text editable (as in Acrobat), so the re-fit ignores the lock. New text is drawn even + // where the standard fonts can't draw some of it (marked and reported, see + // `draw_appearance`). + match fitted_box(doc, r, text) { + Some(rect) => apply_text_box(doc, r, rect, meta, true)?, + None => draw_appearance(doc, r)?, + } + } + Ok(()) +} + +/// The rectangle a FreeText annotation's text box needs for `text`: the current wrap width and +/// top edge stay and the height fits the wrapped lines, as at creation. `None` keeps the old +/// rectangle (the box can't be measured, so only the appearance is redrawn). +fn fitted_box(doc: &Document, r: ObjRef, text: &str) -> Option<[f64; 4]> { + let d = annot_dict(doc, r); + let nums = |key: &[u8]| -> Option> { d.get(key)?.as_array()?.iter().map(|o| o.as_f64()).collect() }; + let (_, size) = appearance::parse_da(&d); + let pad = 2.0 + border_width_of(&d); + let rect = nums(b"Rect").filter(|v| v.len() == 4 && v.iter().all(|x| x.is_finite()))?; + let rect = [rect[0], rect[1], rect[2], rect[3]]; + // A callout re-fits its text box (`/Rect` inset by `/RD`); the leader line keeps its place. + let tb = if d.contains(b"CL") { + let rd = nums(b"RD").filter(|v| v.len() == 4 && v.iter().all(|x| *x >= 0.0))?; + [rect[0] + rd[0], rect[1] + rd[1], rect[2] - rd[2], rect[3] - rd[3]] + } else { + rect + }; + let (x0, top) = (tb[0].min(tb[2]), tb[1].max(tb[3])); + let w = tb[2] - tb[0]; + let w = if w.is_finite() && w >= 1.0 { + w + } else { + // No usable old width: fall back to the creation-time width (at most 300 pt). + let longest = text.lines().map(|l| appearance::text_width(l, size)).fold(0.0, f64::max); + (longest + 2.0 * pad + 4.0).clamp(40.0, 300.0) + }; + let lines = appearance::wrap(text, size, (w - 2.0 * pad).max(1.0)).len().max(1) as f64; + Some([x0, top - lines * size * 1.2 - 2.0 * pad - 2.0, x0 + w, top]) +} + +/// Reply to a comment; returns the reply's index in the page's `/Annots`. +/// +/// A reply is a text annotation with `/IRT` pointing at its parent (§12.5.6.2). It gets the +/// parent's rectangle and an empty appearance, so it shows in comment lists but never paints a +/// second icon on the page. +pub fn add_reply(doc: &mut Document, page: usize, index: usize, text: &str, author: &str, meta: &Meta) -> Result { + reply(doc, page, index, text, author, meta, None) +} + +/// Set a comment's review status (Acrobat: "Set status ▸ Accepted"…). Like Acrobat this adds a +/// state reply (`/State`, `/StateModel /Review`) by `author`; the latest one wins. +pub fn set_review_state(doc: &mut Document, page: usize, index: usize, state: ReviewState, author: &str, meta: &Meta) -> Result { + let text = format!("{} set by {}", state.name(), if author.is_empty() { "unknown" } else { author }); + reply(doc, page, index, &text, author, meta, Some(state)) +} + +fn reply( + doc: &mut Document, + page: usize, + index: usize, + text: &str, + author: &str, + meta: &Meta, + state: Option, +) -> Result { + let (p, parent) = annot_ref(doc, page, index)?; + let pd = annot_dict(doc, parent); + if pd.name(b"Subtype") == Some(b"Popup") { + return Err(AnnotError::Invalid("pop-ups can't be replied to".into())); + } + let rect = pd.get(b"Rect").cloned().unwrap_or_else(|| num_array(&[0.0, 0.0, 0.0, 0.0])); + let mut d = base_dict("Text", [0.0; 4], p, text, author, meta); + d.set(b"Rect".to_vec(), rect); + d.set(b"IRT".to_vec(), Object::Ref(parent)); + d.set(b"F".to_vec(), Object::Int(FLAG_PRINT | FLAG_NO_ZOOM | FLAG_NO_ROTATE)); + d.set(b"Name".to_vec(), Object::name("Comment")); + if let Some(c) = pd.get(b"C") { + d.set(b"C".to_vec(), c.clone()); + } + if let Some(s) = state { + d.set(b"State".to_vec(), PdfString::text(s.name())); + d.set(b"StateModel".to_vec(), PdfString::text("Review")); + d.set(b"Subj".to_vec(), PdfString::text("Status")); + } + // An empty form: nothing is drawn for the reply itself. + let mut fd = Dict::new(); + fd.set(b"Type".to_vec(), Object::name("XObject")); + fd.set(b"Subtype".to_vec(), Object::name("Form")); + fd.set(b"BBox".to_vec(), num_array(&[0.0, 0.0, 0.0, 0.0])); + let ap = doc.add(Object::Stream(pdfcraft_cos::Stream::from_raw(fd, Vec::new()))); + let mut apd = Dict::new(); + apd.set(b"N".to_vec(), Object::Ref(ap)); + d.set(b"AP".to_vec(), Object::Dict(apd)); + let r = doc.add(Object::Dict(d)); + let mut list = annots(doc, p); + list.push(Object::Ref(r)); + let i = list.len() - 1; + set_annots(doc, p, list)?; + Ok(i) +} + +fn flags(doc: &Document, r: ObjRef) -> i64 { + doc.get(r).as_dict().map(|d| annotation_flags(doc, d)).unwrap_or(0) +} + +/// Refuse to change a locked comment (Acrobat: Properties ▸ Locked). +pub(crate) fn unlocked(doc: &Document, r: ObjRef) -> Result<(), AnnotError> { + if flags(doc, r) & FLAG_LOCKED != 0 { Err(AnnotError::Invalid("the comment is locked".into())) } else { Ok(()) } +} + +/// Lock or unlock a comment (the Locked flag). A locked comment can't be moved, resized, +/// restyled or deleted; its text and replies stay editable, as in Acrobat. +pub fn set_locked(doc: &mut Document, page: usize, index: usize, locked: bool) -> Result<(), AnnotError> { + let (_, r) = annot_ref(doc, page, index)?; + let f = if locked { flags(doc, r) | FLAG_LOCKED } else { flags(doc, r) & !FLAG_LOCKED }; + doc.update_dict(r, |d| d.set(b"F".to_vec(), Object::Int(f)))?; + Ok(()) +} + +/// Mark or unmark a comment with a checkmark. Like Acrobat this adds a hidden state reply with +/// `/StateModel /Marked` by `author`; the latest one wins. It's private bookkeeping, not a status. +pub fn set_marked(doc: &mut Document, page: usize, index: usize, marked: bool, author: &str, meta: &Meta) -> Result { + let (p, parent) = annot_ref(doc, page, index)?; + let pd = annot_dict(doc, parent); + if pd.name(b"Subtype") == Some(b"Popup") { + return Err(AnnotError::Invalid("pop-ups can't be marked".into())); + } + let state = if marked { "Marked" } else { "Unmarked" }; + let text = format!("{state} set by {}", if author.is_empty() { "unknown" } else { author }); + let mut d = base_dict("Text", [0.0; 4], p, &text, author, meta); + d.set(b"Rect".to_vec(), pd.get(b"Rect").cloned().unwrap_or_else(|| num_array(&[0.0; 4]))); + d.set(b"IRT".to_vec(), Object::Ref(parent)); + d.set(b"F".to_vec(), Object::Int(FLAG_HIDDEN | FLAG_PRINT | FLAG_NO_ZOOM | FLAG_NO_ROTATE)); + d.set(b"State".to_vec(), PdfString::text(state)); + d.set(b"StateModel".to_vec(), PdfString::text("Marked")); + let r = doc.add(Object::Dict(d)); + let mut list = annots(doc, p); + list.push(Object::Ref(r)); + let i = list.len() - 1; + set_annots(doc, p, list)?; + Ok(i) +} + +/// Move a comment (and its pop-up) by `(dx, dy)` points. The appearance moves with `/Rect`. +pub fn move_annotation(doc: &mut Document, page: usize, index: usize, dx: f64, dy: f64, meta: &Meta) -> Result<(), AnnotError> { + if !finite(&[dx, dy]) { + return Err(AnnotError::Invalid("invalid offset".into())); + } + let (_, r) = annot_ref(doc, page, index)?; + unlocked(doc, r)?; + let shift = |o: &Object, every: bool| -> Option { + let a = o.as_array()?; + Some(Object::Array( + a.iter() + .enumerate() + .map(|(i, v)| match v.as_f64() { + Some(x) if every || i < 4 => Object::Real(x + if i % 2 == 0 { dx } else { dy }), + _ => v.clone(), + }) + .collect(), + )) + }; + let popup = annot_dict(doc, r).reference(b"Popup"); + doc.update_dict(r, |d| { + for (k, every) in [(&b"Rect"[..], false), (b"QuadPoints", true), (b"L", false), (b"CL", true), (b"Vertices", true)] { + if let Some(v) = d.get(k).and_then(|o| shift(o, every)) { + d.set(k.to_vec(), v); + } + } + if let Some(Object::Array(list)) = d.get(b"InkList").cloned() { + let moved = list.iter().map(|s| shift(s, true).unwrap_or_else(|| s.clone())).collect(); + d.set(b"InkList".to_vec(), Object::Array(moved)); + } + touch(d, meta); + })?; + if let Some(p) = popup + && doc.get(p).as_dict().is_some() + { + doc.update_dict(p, |d| { + if let Some(v) = d.get(b"Rect").and_then(|o| shift(o, false)) { + d.set(b"Rect".to_vec(), v); + } + })?; + } + Ok(()) +} + +/// Resize a rectangle, oval, text box, stamp or drawing to `rect`. Stamps keep their appearance, +/// which PDF viewers scale from its bounding box into the new rectangle; drawings scale their +/// strokes and get a new one. +pub fn set_rect(doc: &mut Document, page: usize, index: usize, rect: [f64; 4], meta: &Meta) -> Result<(), AnnotError> { + let (_, r) = annot_ref(doc, page, index)?; + unlocked(doc, r)?; + let d = annot_dict(doc, r); + let subtype = String::from_utf8_lossy(d.name(b"Subtype").unwrap_or_default()).into_owned(); + if subtype == "Stamp" { + let rect = normalize(rect); + if !finite(&rect) || rect[2] <= rect[0] || rect[3] <= rect[1] { + return Err(AnnotError::Invalid("invalid rectangle (too small)".into())); + } + doc.update_dict(r, |d| { + d.set(b"Rect".to_vec(), num_array(&rect)); + touch(d, meta); + })?; + return Ok(()); + } + if subtype == "Ink" { + return resize_ink(doc, r, normalize(rect), meta); + } + if !matches!(subtype.as_str(), "Square" | "Circle" | "FreeText") { + return Err(AnnotError::Invalid(format!("{subtype} comments can't be resized"))); + } + apply_text_box(doc, r, rect, meta, false) +} + +/// At most this many points reshape a comment (a drawn polygon has a handful). +const MAX_POINTS: usize = 100_000; + +/// Reshape a line, connected lines, a polygon or a cloud: its points in user space (a line's two +/// ends, the others' corners), as dragging the comment's handles sets them. `/Rect` follows the +/// points with room for the line's width and endings, as when the comment was drawn, and the +/// appearance is redrawn. A measurement keeps the points it measured. +pub fn set_points(doc: &mut Document, page: usize, index: usize, points: &[[f64; 2]], meta: &Meta) -> Result<(), AnnotError> { + let (_, r) = annot_ref(doc, page, index)?; + unlocked(doc, r)?; + let d = annot_dict(doc, r); + if d.contains(b"Measure") { + return Err(AnnotError::Invalid("a measurement's points are where it measured".into())); + } + if points.len() > MAX_POINTS { + return Err(AnnotError::Invalid(format!("at most {MAX_POINTS} points"))); + } + let style = Style { width: border_width_of(&d), ..Style::default() }; + let ends = line_endings_of(&d).unwrap_or_default(); + let (start, end) = (ends.first().copied().unwrap_or(LineEnding::None), ends.get(1).copied().unwrap_or(LineEnding::None)); + let subtype = String::from_utf8_lossy(d.name(b"Subtype").unwrap_or_default()).into_owned(); + let (shape, key): (Shape, &[u8]) = match (subtype.as_str(), points) { + ("Line", [from, to]) => (Shape::Line { from: *from, to: *to, start, end }, b"L"), + ("Line", _) => return Err(AnnotError::Invalid("a line has two ends".into())), + ("PolyLine", _) => (Shape::PolyLine { vertices: points.to_vec(), start, end }, b"Vertices"), + ("Polygon", _) => { + let cloud = d.get(b"BE").and_then(|b| b.as_dict()).is_some_and(|b| b.name(b"S") == Some(b"C")); + (Shape::Polygon { vertices: points.to_vec(), cloud }, b"Vertices") + } + (other, _) => return Err(AnnotError::Invalid(format!("{other} comments have no points to move"))), + }; + // Checks the points too: finite, and far enough apart to draw. + let rect = rect_for(&shape, &style)?; + let before = d; + doc.update_dict(r, |d| { + d.set(key.to_vec(), num_array(&points.concat())); + d.set(b"Rect".to_vec(), num_array(&rect)); + touch(d, meta); + })?; + redraw_or_restore(doc, r, before) +} + +/// Scale a drawing's strokes into `rect`. The margin between the strokes and the old `/Rect` (half +/// the line width and a little more) stays the same on each side, so the line isn't clipped. +fn resize_ink(doc: &mut Document, r: ObjRef, rect: [f64; 4], meta: &Meta) -> Result<(), AnnotError> { + let d = annot_dict(doc, r); + let strokes: Vec> = d + .get(b"InkList") + .map(|l| doc.resolve(l)) + .and_then(|l| l.as_array().cloned()) + .unwrap_or_default() + .iter() + .map(|s| { + let v: Vec = doc.resolve(s).as_array().map(|a| a.iter().filter_map(|x| doc.resolve(x).as_f64()).collect()).unwrap_or_default(); + v.as_chunks::<2>().0.iter().map(|p| [p[0], p[1]]).collect() + }) + .collect(); + let Some(inner) = bounds(strokes.iter().flatten().copied()) else { + return Err(AnnotError::Invalid("the drawing has no strokes".into())); + }; + let old = d.get(b"Rect").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default(); + let old: Vec = old.iter().filter_map(|v| doc.resolve(v).as_f64()).collect(); + let old = match old[..] { + [a, b, c, e] => normalize([a, b, c, e]), + _ => inner, + }; + let margin = [inner[0] - old[0], inner[1] - old[1], old[2] - inner[2], old[3] - inner[3]].map(|m| m.max(0.0)); + let target = [rect[0] + margin[0], rect[1] + margin[1], rect[2] - margin[2], rect[3] - margin[3]]; + if !finite(&rect) || target[2] < target[0] || target[3] < target[1] || rect[2] - rect[0] < 1.0 || rect[3] - rect[1] < 1.0 { + return Err(AnnotError::Invalid("invalid rectangle (too small)".into())); + } + // An axis with no extent (a straight stroke) is centred rather than scaled. + let axis = |v: f64, a0: f64, a1: f64, b0: f64, b1: f64| if a1 > a0 { b0 + (v - a0) / (a1 - a0) * (b1 - b0) } else { (b0 + b1) / 2.0 }; + let scaled: Vec> = strokes + .iter() + .map(|s| { + s.iter().map(|p| [axis(p[0], inner[0], inner[2], target[0], target[2]), axis(p[1], inner[1], inner[3], target[1], target[3])]).collect() + }) + .collect(); + let before = annot_dict(doc, r); + doc.update_dict(r, |d| { + d.set(b"InkList".to_vec(), Object::Array(scaled.iter().map(|s| num_array(&s.concat())).collect())); + d.set(b"Rect".to_vec(), num_array(&rect)); + touch(d, meta); + })?; + redraw_or_restore(doc, r, before) +} + +/// Store `rect` as a FreeText annotation's text box (or a square's/oval's rectangle): a callout's +/// leader line re-attaches and `/Rect` grows to hold it, and the appearance is redrawn +/// (`new_text`: for changed contents, see [`set_appearance`] and `draw_appearance`). +fn apply_text_box(doc: &mut Document, r: ObjRef, rect: [f64; 4], meta: &Meta, new_text: bool) -> Result<(), AnnotError> { + let rect = normalize(rect); + if !finite(&rect) || rect[2] - rect[0] < 1.0 || rect[3] - rect[1] < 1.0 { + return Err(AnnotError::Invalid("invalid rectangle (too small)".into())); + } + let d = annot_dict(doc, r); + if !new_text { + refuse_foreign_text(doc, &d)?; + } + // A callout's rectangle is its text box: the leader line re-attaches and `/Rect` grows to hold it. + let callout = d.get(b"CL").and_then(|o| o.as_array()).map(|a| a.iter().filter_map(|x| x.as_f64()).collect::>()).filter(|l| l.len() == 6); + let (outer, cl) = match callout { + Some(mut l) => { + let attach = callout_attach(rect, [l[2], l[3]]); + (l[4], l[5]) = (attach[0], attach[1]); + let pad = border_width_of(&d) / 2.0 + appearance::arrow_size(border_width_of(&d)) + 1.0; + let lead = grow(bounds([[l[0], l[1]], [l[2], l[3]]].into_iter()).unwrap_or_default(), pad); + ([rect[0].min(lead[0]), rect[1].min(lead[1]), rect[2].max(lead[2]), rect[3].max(lead[3])], Some(l)) + } + None => (rect, None), + }; + doc.update_dict(r, |d| { + d.set(b"Rect".to_vec(), num_array(&outer)); + match &cl { + Some(l) => { + d.set(b"CL".to_vec(), num_array(l)); + d.set(b"RD".to_vec(), num_array(&[rect[0] - outer[0], rect[1] - outer[1], outer[2] - rect[2], outer[3] - rect[3]])); + } + None => { + d.remove(b"RD"); + } + } + touch(d, meta); + })?; + let before = d; + if new_text { + if let Err(e) = draw_appearance(doc, r) { + doc.set(r, Object::Dict(before)); + return Err(e); + } + Ok(()) + } else { + redraw_or_restore(doc, r, before) + } +} + +fn border_width_of(d: &Dict) -> f64 { + d.get(b"BS").and_then(|b| b.as_dict()).and_then(|b| b.get(b"W")).and_then(|w| w.as_f64()).unwrap_or(1.0).max(0.0) +} + +/// Change a comment's colour, opacity, line width and/or line endings, and redraw it. +pub fn set_style(doc: &mut Document, page: usize, index: usize, change: StyleChange, meta: &Meta) -> Result<(), AnnotError> { + let StyleChange { color, opacity, width, endings } = change; + let (_, r) = annot_ref(doc, page, index)?; + unlocked(doc, r)?; + let d = annot_dict(doc, r); + let subtype = String::from_utf8_lossy(d.name(b"Subtype").unwrap_or_default()).into_owned(); + // Check before changing anything: a stale appearance would contradict the new style. + if appearance::build(&d).is_none() { + return Err(AnnotError::Unsupported(subtype.clone())); + } + if color.is_some_and(|c| !finite(&c)) || opacity.is_some_and(|o| !o.is_finite()) || width.is_some_and(|w| !w.is_finite()) { + return Err(AnnotError::Invalid("invalid style".into())); + } + if let Some(ends) = &endings { + let callout = subtype == "FreeText" && d.contains(b"CL"); + let pair = matches!(subtype.as_str(), "Line" | "PolyLine"); + if (callout && ends.len() != 1) || (pair && ends.len() != 2) || (!callout && !pair) { + return Err(AnnotError::Invalid("this comment has no line endings to change".into())); + } + } + refuse_foreign_text(doc, &d)?; + let free_text = subtype == "FreeText"; + let before = d; + doc.update_dict(r, |d| { + if let Some(c) = color { + if free_text { + let (_, size) = appearance::parse_da(d); + let [r, g, b] = c.map(|x| x.clamp(0.0, 1.0)); + d.set(b"DA".to_vec(), PdfString::literal(format!("{} {} {} rg /Helv {} Tf", n(r), n(g), n(b), n(size)).into_bytes())); + rich_text(d); + } else { + d.set(b"C".to_vec(), rgb(c)); + } + } + if let Some(o) = opacity { + let o = o.clamp(0.0, 1.0); + if o < 1.0 { + d.set(b"CA".to_vec(), Object::Real(o)); + } else { + d.remove(b"CA"); + } + } + if let Some(w) = width { + let mut bs = d.get(b"BS").and_then(|b| b.as_dict()).cloned().unwrap_or_default(); + bs.set(b"W".to_vec(), Object::Real(w.max(0.0))); + d.set(b"BS".to_vec(), Object::Dict(bs)); + d.remove(b"Border"); + } + if let Some(ends) = endings { + if ends.len() == 1 { + if let Some(ending) = ends.first() { + d.set(b"LE".to_vec(), Object::name(ending.name())); + } + } else if let (Some(start), Some(end)) = (ends.first(), ends.get(1)) { + d.set(b"LE".to_vec(), ending_pair(*start, *end)); + } + } + ensure_ending_room(d); + touch(d, meta); + })?; + redraw_or_restore(doc, r, before) +} + +/// Change the font size of a text box, typewriter text or callout (`/DA` keeps its colour; the +/// rich-text strings follow) and redraw it. The box grows to hold the wrapped text at the new +/// size and never shrinks, so a box the user drew large stays large. Sizes are 1 to 400 pt. +pub fn set_font_size(doc: &mut Document, page: usize, index: usize, size: f64, meta: &Meta) -> Result<(), AnnotError> { + let (_, r) = annot_ref(doc, page, index)?; + unlocked(doc, r)?; + let d = annot_dict(doc, r); + if d.name(b"Subtype") != Some(b"FreeText") { + return Err(AnnotError::Invalid("only text boxes, typewriter text and callouts have a font size".into())); + } + if !size.is_finite() || !(1.0..=400.0).contains(&size) { + return Err(AnnotError::Invalid("font size must be 1\u{2013}400 pt".into())); + } + refuse_foreign_text(doc, &d)?; + let before = d; + doc.update_dict(r, |d| { + let (col, _) = appearance::parse_da(d); + d.set(b"DA".to_vec(), PdfString::literal(format!("{} {} {} rg /Helv {} Tf", n(col[0]), n(col[1]), n(col[2]), n(size)).into_bytes())); + rich_text(d); + touch(d, meta); + })?; + let text = text_value(doc, &annot_dict(doc, r), b"Contents").unwrap_or_default(); + let result = match fitted_box(doc, r, &text) { + Some(fit) => { + let cur = annot_dict(doc, r); + let nums: Vec = + cur.get(b"Rect").and_then(|o| o.as_array()).map(|a| a.iter().filter_map(|x| x.as_f64()).collect()).unwrap_or_default(); + // Keep the top and sides; only the bottom edge moves, and only downwards. + match four(&nums).map(normalize) { + Some(old) if !cur.contains(b"CL") => apply_text_box(doc, r, [fit[0], fit[1].min(old[1]), fit[2], fit[3]], meta, false), + _ => apply_text_box(doc, r, fit, meta, false), + } + } + None => redraw_or_restore(doc, r, before.clone()), + }; + if result.is_err() { + doc.set(r, Object::Dict(before)); + } + result +} + +/// Whether a comment has a fill: `/IC` for shapes, `/C` for FreeText (§12.5.6). +pub fn fillable(subtype: &str) -> bool { + matches!(subtype, "Square" | "Circle" | "Polygon" | "FreeText") +} + +/// Fill a shape or FreeText comment with `fill`, or remove its fill (`None`), and +/// redraw its appearance (Acrobat: Properties ▸ Fill Color). +pub fn set_fill(doc: &mut Document, page: usize, index: usize, fill: Option, meta: &Meta) -> Result<(), AnnotError> { + let (_, r) = annot_ref(doc, page, index)?; + unlocked(doc, r)?; + let d = annot_dict(doc, r); + let subtype = String::from_utf8_lossy(d.name(b"Subtype").unwrap_or_default()).into_owned(); + if !fillable(&subtype) { + return Err(AnnotError::Invalid("only rectangles, ovals, polygons and text boxes can be filled".into())); + } + // Check before changing anything: a stale appearance would contradict the new fill. + if appearance::build(&d).is_none() { + return Err(AnnotError::Unsupported(subtype)); + } + if fill.is_some_and(|c| !finite(&c)) { + return Err(AnnotError::Invalid("invalid fill colour".into())); + } + refuse_foreign_text(doc, &d)?; + let before = d; + doc.update_dict(r, |d| { + let key: &[u8] = if subtype == "FreeText" { b"C" } else { b"IC" }; + match fill { + Some(c) => d.set(key.to_vec(), rgb(c)), + None => { + d.remove(key); + } + } + touch(d, meta); + })?; + redraw_or_restore(doc, r, before) +} + +/// Grow a line or polyline's rectangle so a non-`None` ending is not clipped. Idempotent. +fn ensure_ending_room(d: &mut Dict) { + let subtype = d.name(b"Subtype").unwrap_or_default(); + if !matches!(subtype, b"Line" | b"PolyLine") { + return; + } + if !line_endings_of(d).is_some_and(|ends| ends.iter().any(|e| ending_draws(*e))) { + return; + } + let key: &[u8] = if subtype == b"Line" { b"L" } else { b"Vertices" }; + let Some(pts) = pair_points(d, key) else { return }; + let Some(bounds) = bounds(pts.iter().copied()) else { return }; + let w = border_width_of(d); + let need = grow(bounds, w / 2.0 + appearance::arrow_size(w) + 1.0); + let vals: Vec = d.get(b"Rect").and_then(|o| o.as_array()).map(|a| a.iter().filter_map(|x| x.as_f64()).collect()).unwrap_or_default(); + let Some(rect) = four(&vals) else { return }; + let union = [rect[0].min(need[0]), rect[1].min(need[1]), rect[2].max(need[2]), rect[3].max(need[3])]; + if union != rect { + d.set(b"Rect".to_vec(), num_array(&union)); + } +} + +fn four(v: &[f64]) -> Option<[f64; 4]> { + Some([*v.first()?, *v.get(1)?, *v.get(2)?, *v.get(3)?]) +} + +fn pair_points(d: &Dict, key: &[u8]) -> Option> { + let v: Vec = d.get(key)?.as_array()?.iter().filter_map(|o| o.as_f64()).collect(); + if v.len() < 4 || !v.len().is_multiple_of(2) || v.iter().any(|x| !x.is_finite()) { + return None; + } + Some(v.as_chunks::<2>().0.to_vec()) +} + +/// Replace Text (Acrobat's proposal): strike out `quads` and add a caret at the end of the +/// struck text holding `replacement`, grouped with the strikeout (`/IRT` + `/RT /Group`) so they +/// move, list and delete as one. Returns the strikeout's index. +/// +/// The two annotations need their own `/NM`: XFDF names a reply by the `/NM` of what it replies +/// to, so one id for both writes `inreplyto` equal to the caret's own `name`, and a reader that +/// resolves names (Acrobat, and our own importer) loses the pair. `caret_meta` is a second id +/// from the caller, so the edit stays deterministic. +pub fn add_text_replacement(doc: &mut Document, replacement: TextReplacement<'_>) -> Result { + let TextReplacement { page, quads, replacement, author, strike, caret, strike_meta: meta, caret_meta } = replacement; + let last = *quads.last().ok_or_else(|| AnnotError::Invalid("select the text to replace first".into()))?; + let s = add_annotation( + doc, + &NewAnnotation { + page, + shape: Shape::TextMarkup { kind: Markup::StrikeOut, quads: quads.to_vec() }, + style: strike.clone(), + contents: String::new(), + author: author.into(), + }, + meta, + )?; + let (_, sr) = annot_ref(doc, page, s)?; + doc.update_dict(sr, |d| d.set(b"IT".to_vec(), Object::name("StrikeOutTextEdit")))?; + // The caret sits on the baseline at the end of the last line, its point at the text. + let xs = [last[0], last[2], last[4], last[6]]; + let ys = [last[1], last[3], last[5], last[7]]; + let x = xs.iter().copied().fold(f64::MIN, f64::max); + let (y0, y1) = (ys.iter().copied().fold(f64::MAX, f64::min), ys.iter().copied().fold(f64::MIN, f64::max)); + let h = ((y1 - y0) * 0.6).clamp(4.0, 24.0); + let rect = [x - h / 2.0, y0 - h * 0.6, x + h / 2.0, y0 + h * 0.4]; + let c = add_annotation( + doc, + &NewAnnotation { page, shape: Shape::Caret { rect }, style: caret.clone(), contents: replacement.into(), author: author.into() }, + caret_meta, + )?; + let (_, cr) = annot_ref(doc, page, c)?; + doc.update_dict(cr, |d| { + d.set(b"IRT".to_vec(), Object::Ref(sr)); + d.set(b"RT".to_vec(), Object::name("Group")); + d.set(b"Subj".to_vec(), PdfString::text("Replace Text")); + })?; + Ok(s) +} + +/// The Eraser (Draw tools): remove the parts of drawing `(page, index)` within `radius` points +/// of `path`, splitting strokes where they are cut. A drawing with nothing left is deleted. +/// Returns whether anything was erased. +pub fn erase_ink(doc: &mut Document, page: usize, index: usize, path: &[[f64; 2]], radius: f64, meta: &Meta) -> Result { + let (_, r) = annot_ref(doc, page, index)?; + unlocked(doc, r)?; + let d = annot_dict(doc, r); + if d.name(b"Subtype") != Some(b"Ink") { + return Err(AnnotError::Invalid("only drawings can be erased".into())); + } + if path.is_empty() || !path.iter().all(|p| finite(p)) || !radius.is_finite() || radius <= 0.0 { + return Err(AnnotError::Invalid("invalid eraser path".into())); + } + let strokes: Vec> = d + .get(b"InkList") + .map(|l| doc.resolve(l)) + .and_then(|l| l.as_array().cloned()) + .unwrap_or_default() + .iter() + .map(|s| { + let v: Vec = doc.resolve(s).as_array().map(|a| a.iter().filter_map(|x| doc.resolve(x).as_f64()).collect()).unwrap_or_default(); + v.as_chunks::<2>().0.iter().map(|p| [p[0], p[1]]).collect() + }) + .collect(); + // Distance from a point to the eraser's path (segments, or a single point). + let near = |p: [f64; 2]| -> bool { + if path.len() == 1 { + return (p[0] - path[0][0]).hypot(p[1] - path[0][1]) <= radius; + } + path.windows(2).any(|w| { + let (a, b) = (w[0], w[1]); + let (dx, dy) = (b[0] - a[0], b[1] - a[1]); + let len2 = dx * dx + dy * dy; + let t = if len2 > 0.0 { (((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / len2).clamp(0.0, 1.0) } else { 0.0 }; + (p[0] - (a[0] + t * dx)).hypot(p[1] - (a[1] + t * dy)) <= radius + }) + }; + let mut kept: Vec> = Vec::new(); + let mut erased = false; + for s in &strokes { + // Densify so long segments are cut where the eraser crosses them. + let mut pts: Vec<[f64; 2]> = Vec::new(); + for w in s.windows(2) { + let n = ((w[1][0] - w[0][0]).hypot(w[1][1] - w[0][1]) / (radius / 2.0)).ceil().clamp(1.0, 2000.0) as usize; + for k in 0..n { + let t = k as f64 / n as f64; + pts.push([w[0][0] + (w[1][0] - w[0][0]) * t, w[0][1] + (w[1][1] - w[0][1]) * t]); + } + } + if let Some(last) = s.last() { + pts.push(*last); + } + let mut run: Vec<[f64; 2]> = Vec::new(); + for p in pts { + if near(p) { + erased = true; + if run.len() >= 2 { + kept.push(std::mem::take(&mut run)); + } + run.clear(); + } else { + run.push(p); + } + } + if run.len() >= 2 { + kept.push(run); + } + } + if !erased { + return Ok(false); + } + if kept.is_empty() { + delete_annotation(doc, page, index)?; + return Ok(true); + } + let width = d.get(b"BS").and_then(|b| b.as_dict()).and_then(|b| b.get(b"W")).and_then(|w| w.as_f64()).unwrap_or(1.0); + let rect = grow(bounds(kept.iter().flatten().copied()).unwrap_or_default(), width / 2.0 + 1.0); + doc.update_dict(r, |d| { + d.set(b"InkList".to_vec(), Object::Array(kept.iter().map(|s| num_array(&s.concat())).collect())); + d.set(b"Rect".to_vec(), num_array(&rect)); + touch(d, meta); + })?; + set_appearance(doc, r)?; + Ok(true) +} diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index b9e47e5fb9..eb8d1a0f15 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -303,7 +303,7 @@ fn move_shifts_all_geometry_and_the_popup() { fn restyle_and_resize_redraw_the_appearance() { let mut doc = fixture(); let r = add_annotation(&mut doc, &new(0, Shape::Rectangle { rect: [10.0, 10.0, 110.0, 60.0] }), &meta("r")).unwrap(); - set_style(&mut doc, 0, r, Some([1.0, 0.0, 0.0]), Some(0.5), Some(4.0), None, &meta("")).unwrap(); + set_style(&mut doc, 0, r, StyleChange::new(Some([1.0, 0.0, 0.0]), Some(0.5), Some(4.0), None), &meta("")).unwrap(); set_rect(&mut doc, 0, r, [0.0, 0.0, 200.0, 100.0], &meta("")).unwrap(); let doc2 = reopen(&doc); let d = &list(&doc2, 0)[r]; @@ -313,7 +313,10 @@ fn restyle_and_resize_redraw_the_appearance() { assert_eq!(d.get(b"CA").and_then(|o| o.as_f64()), Some(0.5)); // A stamp's appearance can't be regenerated, so restyling it changes nothing. let before = list(&doc, 1)[0].clone(); - assert_eq!(set_style(&mut doc, 1, 0, Some([0.0; 3]), None, None, None, &meta("")), Err(AnnotError::Unsupported("Stamp".into()))); + assert_eq!( + set_style(&mut doc, 1, 0, StyleChange::new(Some([0.0; 3]), None, None, None), &meta("")), + Err(AnnotError::Unsupported("Stamp".into())) + ); assert_eq!(list(&doc, 1)[0], before); // Lines can't be resized as rectangles. let l = add_annotation( @@ -374,7 +377,7 @@ fn text_box_text_and_colour_changes_are_drawn() { let mut doc = fixture(); let t = add_annotation(&mut doc, &new(0, Shape::TextBox { rect: [0.0, 0.0, 300.0, 100.0], font_size: 12.0 }), &meta("t")).unwrap(); set_contents(&mut doc, 0, t, "Caf\u{e9} (draft) \u{2014} 100%", &meta("")).unwrap(); - set_style(&mut doc, 0, t, Some([1.0, 0.0, 0.0]), None, None, None, &meta("")).unwrap(); + set_style(&mut doc, 0, t, StyleChange::new(Some([1.0, 0.0, 0.0]), None, None, None), &meta("")).unwrap(); let doc = reopen(&doc); let d = &list(&doc, 0)[t]; assert_eq!(text(d, b"Contents"), "Caf\u{e9} (draft) \u{2014} 100%"); @@ -556,8 +559,8 @@ fn fill_and_sign_items_are_drawn() { assert_eq!(text(&all[s], b"Subj"), "Signature"); // Other stamps still can't be restyled; ours can. let mut doc = doc; - set_style(&mut doc, 1, marks[0], Some([0.0, 0.0, 1.0]), None, None, None, &meta("")).unwrap(); - assert!(set_style(&mut doc, 1, 0, Some([0.0; 3]), None, None, None, &meta("")).is_err()); + set_style(&mut doc, 1, marks[0], StyleChange::new(Some([0.0, 0.0, 1.0]), None, None, None), &meta("")).unwrap(); + assert!(set_style(&mut doc, 1, 0, StyleChange::new(Some([0.0; 3]), None, None, None), &meta("")).is_err()); } #[test] @@ -844,7 +847,20 @@ fn replace_text_groups_a_strikeout_and_a_caret() { let quads = [[100.0, 720.0, 200.0, 720.0, 100.0, 706.0, 200.0, 706.0]]; let red = Style { color: [0.89, 0.13, 0.13], opacity: 1.0, width: 1.0, fill: None }; let blue = Style { color: [0.0, 0.47, 0.84], opacity: 1.0, width: 1.0, fill: None }; - let i = add_text_replacement(&mut doc, 0, &quads, "new words", "Ada", &red, &blue, &meta("r"), &meta("r-caret")).unwrap(); + let i = add_text_replacement( + &mut doc, + TextReplacement { + page: 0, + quads: &quads, + replacement: "new words", + author: "Ada", + strike: &red, + caret: &blue, + strike_meta: &meta("r"), + caret_meta: &meta("r-caret"), + }, + ) + .unwrap(); let doc = reopen(&doc); let a = list(&doc, 0); let strike = &a[i]; @@ -866,7 +882,22 @@ fn replace_text_groups_a_strikeout_and_a_caret() { let mut doc = doc; delete_annotation(&mut doc, 0, i).unwrap(); assert!(!list(&doc, 0).iter().any(|d| d.name(b"Subtype") == Some(b"Caret"))); - assert!(add_text_replacement(&mut doc, 0, &[], "x", "Ada", &red, &blue, &meta("r"), &meta("r-caret")).is_err()); + assert!( + add_text_replacement( + &mut doc, + TextReplacement { + page: 0, + quads: &[], + replacement: "x", + author: "Ada", + strike: &red, + caret: &blue, + strike_meta: &meta("r"), + caret_meta: &meta("r-caret") + } + ) + .is_err() + ); } #[test] @@ -1177,7 +1208,7 @@ fn every_line_ending_is_drawn() { ) .unwrap(); let before = rect(&list(&doc, 0).into_iter().find(|d| d.name(b"Subtype") == Some(b"Line")).expect("line")); - set_style(&mut doc, 0, index, None, None, None, Some(&[LineEnding::None, LineEnding::Square]), &meta("style")).unwrap(); + set_style(&mut doc, 0, index, StyleChange::new(None, None, None, Some(vec![LineEnding::None, LineEnding::Square])), &meta("style")).unwrap(); let mut doc = reopen(&doc); let line = list(&doc, 0).into_iter().find(|d| d.name(b"Subtype") == Some(b"Line")).expect("line"); let after = rect(&line); @@ -1245,7 +1276,7 @@ fn redraw_keeps_shared_annotation_appearances_and_drops_stale_alternates() { let other_dict = list(&doc, 0)[other].clone(); let source = other_dict.get(b"AP").unwrap(); let before = doc.resolve(source); - set_style(&mut doc, 0, first, Some([1.0, 0.0, 0.0]), None, None, None, &meta("")).unwrap(); + set_style(&mut doc, 0, first, StyleChange::new(Some([1.0, 0.0, 0.0]), None, None, None), &meta("")).unwrap(); assert_eq!(doc.resolve(source), before, "a shared AP must not change"); for full in [false, true] { let bytes = if full { @@ -1367,7 +1398,7 @@ fn comment_text_the_standard_fonts_cannot_draw_is_kept_and_marked() { .unwrap(); assert_eq!(mark(&doc, s), Some('张')); // Our own `?` appearance is redrawn on a restyle; Western European text clears the mark. - set_style(&mut doc, 0, t, Some([1.0, 0.0, 0.0]), None, None, None, &meta("")).unwrap(); + set_style(&mut doc, 0, t, StyleChange::new(Some([1.0, 0.0, 0.0]), None, None, None), &meta("")).unwrap(); assert_eq!(mark(&doc, t), Some('签')); set_contents(&mut doc, 0, t, "Zürich —\nGenève", &meta("")).unwrap(); assert_eq!(mark(&doc, t), None); @@ -1395,7 +1426,7 @@ fn comment_text_the_standard_fonts_cannot_draw_is_kept_and_marked() { set_annots(&mut doc, page, on_page).unwrap(); let k = list(&doc, 0).len() - 1; let before = list(&doc, 0)[k].clone(); - let err = set_style(&mut doc, 0, k, Some([1.0, 0.0, 0.0]), Some(0.4), Some(6.0), None, &meta("")).unwrap_err(); + let err = set_style(&mut doc, 0, k, StyleChange::new(Some([1.0, 0.0, 0.0]), Some(0.4), Some(6.0), None), &meta("")).unwrap_err(); assert!(matches!(&err, AnnotError::Undrawable(m) if m.contains("can't redraw") && m.contains("\"欢\" (U+6B22)")), "{err:?}"); assert_eq!(list(&doc, 0)[k], before, "a refused restyle keeps the colour, opacity, width and appearance"); let err = set_rect(&mut doc, 0, k, [40.0, 40.0, 180.0, 90.0], &meta("")).unwrap_err(); @@ -1476,7 +1507,7 @@ fn a_comment_listed_many_times_is_drawn_once_for_display() { fn text_box_font_size_changes_the_da_and_grows_the_box() { let mut doc = fixture(); let t = add_annotation(&mut doc, &new(0, Shape::TextBox { rect: [100.0, 680.0, 220.0, 692.0], font_size: 12.0 }), &meta("t")).unwrap(); - set_style(&mut doc, 0, t, Some([1.0, 0.0, 0.0]), None, None, None, &meta("")).unwrap(); + set_style(&mut doc, 0, t, StyleChange::new(Some([1.0, 0.0, 0.0]), None, None, None), &meta("")).unwrap(); set_contents(&mut doc, 0, t, "one two three four five six seven eight nine ten eleven twelve", &meta("")).unwrap(); let height = |doc: &Document| { let rect: Vec = list(doc, 0)[t].get(b"Rect").unwrap().as_array().unwrap().iter().map(|x| x.as_f64().unwrap()).collect(); @@ -1522,7 +1553,7 @@ fn text_boxes_and_callouts_can_change_fill_and_border() { let index = add_annotation(&mut doc, &new(0, shape), &meta("test")).unwrap(); assert!(props(&doc, 0, index).unwrap().fillable); set_fill(&mut doc, 0, index, Some([1.0, 1.0, 1.0]), &meta("test")).unwrap(); - set_style(&mut doc, 0, index, None, None, Some(3.0), None, &meta("test")).unwrap(); + set_style(&mut doc, 0, index, StyleChange::new(None, None, Some(3.0), None), &meta("test")).unwrap(); let saved = reopen(&doc); let properties = props(&saved, 0, index).unwrap(); assert_eq!(properties.fill, Some([1.0; 3])); @@ -1791,7 +1822,7 @@ fn a_star_note_keeps_its_star_when_recoloured() { b"<< /Type /Annot /Subtype /Text /Rect [100 100 120 120] /Name /Star /C [1 0.82 0] /F 4 >>", ]); assert_eq!(props(&doc, 0, 0).and_then(|p| p.icon), Some(NoteIcon::Star)); - set_style(&mut doc, 0, 0, Some([0.0, 0.0, 1.0]), None, None, None, &meta("")).unwrap(); + set_style(&mut doc, 0, 0, StyleChange::new(Some([0.0, 0.0, 1.0]), None, None, None), &meta("")).unwrap(); let doc = reopen(&doc); let d = &list(&doc, 0)[0]; assert_eq!(d.name(b"Name"), Some(&b"Star"[..])); diff --git a/crates/annot/src/types.rs b/crates/annot/src/types.rs new file mode 100644 index 0000000000..bd3409b5ae --- /dev/null +++ b/crates/annot/src/types.rs @@ -0,0 +1,690 @@ +use super::create::normalize; +use super::{ObjRef, appearance}; + +pub type Rgb = [f64; 3]; + +/// Text markup kinds (§12.5.6.10). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Markup { + Highlight, + Underline, + StrikeOut, + Squiggly, +} + +impl Markup { + fn subtype(self) -> &'static str { + match self { + Markup::Highlight => "Highlight", + Markup::Underline => "Underline", + Markup::StrikeOut => "StrikeOut", + Markup::Squiggly => "Squiggly", + } + } +} + +/// Text-annotation icon names (`/Name`). The first seven are the standard names ISO 32000-2 +/// §12.5.6.4, Table 175 asks every viewer to draw; the rest are further names in common use, which +/// the same table allows ("Additional names may be supported as well"). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum NoteIcon { + #[default] + Comment, + Note, + Help, + Insert, + Key, + NewParagraph, + Paragraph, + Check, + Circle, + Cross, + CrossHairs, + Star, + RightArrow, + UpArrow, +} + +impl NoteIcon { + pub fn name(self) -> &'static str { + match self { + NoteIcon::Comment => "Comment", + NoteIcon::Note => "Note", + NoteIcon::Help => "Help", + NoteIcon::Insert => "Insert", + NoteIcon::Key => "Key", + NoteIcon::NewParagraph => "NewParagraph", + NoteIcon::Paragraph => "Paragraph", + NoteIcon::Check => "Check", + NoteIcon::Circle => "Circle", + NoteIcon::Cross => "Cross", + NoteIcon::CrossHairs => "CrossHairs", + NoteIcon::Star => "Star", + NoteIcon::RightArrow => "RightArrow", + NoteIcon::UpArrow => "UpArrow", + } + } + + /// Every icon, the standard names first. + pub const ALL: [NoteIcon; 14] = [ + Self::Comment, + Self::Note, + Self::Help, + Self::Insert, + Self::Key, + Self::NewParagraph, + Self::Paragraph, + Self::Check, + Self::Circle, + Self::Cross, + Self::CrossHairs, + Self::Star, + Self::RightArrow, + Self::UpArrow, + ]; + + pub fn from_name(n: &str) -> Option { + Self::ALL.into_iter().find(|i| i.name() == n) + } +} + +/// Fill & Sign marks (Acrobat's ✓, ✕, ●, ─). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum FillMark { + Check, + Cross, + Dot, + Line, +} + +impl FillMark { + /// The `/Name` of the stamp PdfCraft draws for it. + pub fn name(self) -> &'static str { + match self { + FillMark::Check => "PCCheck", + FillMark::Cross => "PCCross", + FillMark::Dot => "PCDot", + FillMark::Line => "PCLine", + } + } + + pub fn label(self) -> &'static str { + match self { + FillMark::Check => "Checkmark", + FillMark::Cross => "Cross", + FillMark::Dot => "Dot", + FillMark::Line => "Line", + } + } + + /// The user-space box of the mark placed at `at` on a page turned by `rotation`: centred + /// there and sized in displayed axes, so a line runs across the page as displayed (#2392). + pub fn rect_at(self, at: [f64; 2], rotation: i64) -> [f64; 4] { + let (w, h) = if self == FillMark::Line { (36.0, 4.0) } else { (12.0, 12.0) }; + let (w, h) = if matches!(rotation.rem_euclid(360), 90 | 270) { (h, w) } else { (w, h) }; + [at[0] - w / 2.0, at[1] - h / 2.0, at[0] + w / 2.0, at[1] + h / 2.0] + } +} + +/// The stamps of Acrobat's stamp palette (drawn in PdfCraft's own style). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum StampKind { + // Standard business. + Approved, + Completed, + Confidential, + Draft, + Final, + ForComment, + ForPublicRelease, + InformationOnly, + NotApproved, + NotForPublicRelease, + PreliminaryResults, + Void, + // Sign here. + Accepted, + InitialHere, + Rejected, + SignHere, + Witness, + // Dynamic (with a "By … at …" line). + DynApproved, + DynConfidential, + DynReceived, + DynReviewed, + DynRevised, +} + +/// The palette's sections. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum StampGroup { + Dynamic, + SignHere, + StandardBusiness, +} + +impl StampKind { + pub const ALL: [StampKind; 22] = [ + StampKind::DynApproved, + StampKind::DynConfidential, + StampKind::DynReceived, + StampKind::DynReviewed, + StampKind::DynRevised, + StampKind::Accepted, + StampKind::InitialHere, + StampKind::Rejected, + StampKind::SignHere, + StampKind::Witness, + StampKind::Approved, + StampKind::Completed, + StampKind::Confidential, + StampKind::Draft, + StampKind::Final, + StampKind::ForComment, + StampKind::ForPublicRelease, + StampKind::InformationOnly, + StampKind::NotApproved, + StampKind::NotForPublicRelease, + StampKind::PreliminaryResults, + StampKind::Void, + ]; + + pub fn group(self) -> StampGroup { + use StampKind::*; + match self { + DynApproved | DynConfidential | DynReceived | DynReviewed | DynRevised => StampGroup::Dynamic, + Accepted | InitialHere | Rejected | SignHere | Witness => StampGroup::SignHere, + _ => StampGroup::StandardBusiness, + } + } + + /// The text on the stamp. + pub fn label(self) -> &'static str { + use StampKind::*; + match self { + Approved | DynApproved => "APPROVED", + Completed => "COMPLETED", + Confidential | DynConfidential => "CONFIDENTIAL", + Draft => "DRAFT", + Final => "FINAL", + ForComment => "FOR COMMENT", + ForPublicRelease => "FOR PUBLIC RELEASE", + InformationOnly => "INFORMATION ONLY", + NotApproved => "NOT APPROVED", + NotForPublicRelease => "NOT FOR PUBLIC RELEASE", + PreliminaryResults => "PRELIMINARY RESULTS", + Void => "VOID", + Accepted => "ACCEPTED", + InitialHere => "INITIAL HERE", + Rejected => "REJECTED", + SignHere => "SIGN HERE", + Witness => "WITNESS", + DynReceived => "RECEIVED", + DynReviewed => "REVIEWED", + DynRevised => "REVISED", + } + } + + /// `/Name`: the standard stamp names of ISO 32000-2 Table 184 where one exists. + pub fn name(self) -> &'static str { + use StampKind::*; + match self { + Approved => "Approved", + Confidential => "Confidential", + Draft => "Draft", + Final => "Final", + ForComment => "ForComment", + ForPublicRelease => "ForPublicRelease", + NotApproved => "NotApproved", + NotForPublicRelease => "NotForPublicRelease", + Completed => "PCCompleted", + InformationOnly => "PCInformationOnly", + PreliminaryResults => "PCPreliminaryResults", + Void => "PCVoid", + Accepted => "PCAccepted", + InitialHere => "PCInitialHere", + Rejected => "PCRejected", + SignHere => "PCSignHere", + Witness => "PCWitness", + DynApproved => "PCDynApproved", + DynConfidential => "PCDynConfidential", + DynReceived => "PCDynReceived", + DynReviewed => "PCDynReviewed", + DynRevised => "PCDynRevised", + } + } + + pub fn from_name(n: &[u8]) -> Option { + Self::ALL.into_iter().find(|k| k.name().as_bytes() == n) + } + + /// The stamp's colour: green for approval, red for refusal and restriction, blue otherwise. + pub fn color(self) -> Rgb { + use StampKind::*; + match self { + Approved | Completed | Final | Accepted | DynApproved | DynReceived | DynReviewed => [0.13, 0.55, 0.13], + NotApproved | Rejected | Void | Confidential | NotForPublicRelease | DynConfidential => [0.80, 0.10, 0.10], + SignHere | InitialHere | Witness => [0.85, 0.35, 0.05], + _ => [0.10, 0.30, 0.70], + } + } + + /// The stamp's size (points) for its label, as placed with a click. + pub fn size(self) -> (f64, f64) { + let w = appearance::text_width(self.label(), 16.0) * 1.12 + 24.0; + let h = if self.group() == StampGroup::Dynamic { 42.0 } else { 30.0 }; + let w = if self.group() == StampGroup::SignHere { w + 14.0 } else { w }; + (w.max(80.0), h) + } +} + +/// A line ending (`/LE`): the ten styles in ISO 32000-2 Table 217. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum LineEnding { + None, + Square, + Circle, + Diamond, + OpenArrow, + ClosedArrow, + Butt, + ROpenArrow, + RClosedArrow, + Slash, +} + +impl LineEnding { + pub const ALL: [Self; 10] = [ + Self::None, + Self::Square, + Self::Circle, + Self::Diamond, + Self::OpenArrow, + Self::ClosedArrow, + Self::Butt, + Self::ROpenArrow, + Self::RClosedArrow, + Self::Slash, + ]; + + pub fn name(self) -> &'static str { + match self { + Self::None => "None", + Self::Square => "Square", + Self::Circle => "Circle", + Self::Diamond => "Diamond", + Self::OpenArrow => "OpenArrow", + Self::ClosedArrow => "ClosedArrow", + Self::Butt => "Butt", + Self::ROpenArrow => "ROpenArrow", + Self::RClosedArrow => "RClosedArrow", + Self::Slash => "Slash", + } + } + + pub fn parse(name: &str) -> Option { + Self::ALL.into_iter().find(|ending| ending.name() == name) + } +} + +/// Geometry of a new comment, in PDF user space of its page. +#[derive(Clone, Debug, PartialEq)] +pub enum Shape { + /// A sticky note whose icon's top-left corner is at `at`. + Note { + at: [f64; 2], + icon: NoteIcon, + }, + /// Text markup over quadrilaterals: `[x1 y1 x2 y2 x3 y3 x4 y4]` = top-left, top-right, + /// bottom-left, bottom-right (the order Acrobat writes, §12.5.6.10). + TextMarkup { + kind: Markup, + quads: Vec<[f64; 8]>, + }, + Rectangle { + rect: [f64; 4], + }, + Oval { + rect: [f64; 4], + }, + /// A line. An open arrow at `to` is `start: None`, `end: OpenArrow` (the Arrow tool). + Line { + from: [f64; 2], + to: [f64; 2], + start: LineEnding, + end: LineEnding, + }, + /// Freehand strokes (Draw tool). + Ink { + strokes: Vec>, + }, + /// A text box (FreeText) showing the comment's contents. + TextBox { + rect: [f64; 4], + font_size: f64, + }, + /// Fill & Sign text typed onto the page (FreeText, typewriter intent, no border). + Typewriter { + rect: [f64; 4], + font_size: f64, + }, + /// A Fill & Sign mark in `rect`. + Mark { + rect: [f64; 4], + mark: FillMark, + }, + /// A drawn signature (ink strokes). + Signature { + strokes: Vec>, + }, + /// A rubber stamp from the stamp palette; `by` is the dynamic stamps' second line. + Stamp { + rect: [f64; 4], + stamp: StampKind, + by: Option, + }, + /// A typed signature or initials: filled outlines (each contour as points normalised to + /// `rect`, 0–1, y up), drawn with the non-zero winding rule. + TypedSignature { + rect: [f64; 4], + contours: Vec>, + }, + /// A custom stamp: a picture already in the document (an image XObject, or a form XObject + /// whose `/Matrix` maps it to `size` points) filling `rect`, named `name`. + CustomStamp { + rect: [f64; 4], + name: String, + picture: ObjRef, + image: bool, + size: (f64, f64), + }, + /// A redaction mark (§12.5.6.23) over quadrilaterals (text) or one rectangle as a quad + /// (areas, pages). `overlay` is the text shown on the box once applied, drawn with `look`. + Redact { + quads: Vec<[f64; 8]>, + overlay: String, + look: OverlayLook, + }, + /// A closed polygon (Polygon tool); with `cloud`, Acrobat's Cloud tool: the same polygon + /// with a cloudy border (`/BE /S /C`, `/IT /PolygonCloud`). + Polygon { + vertices: Vec<[f64; 2]>, + cloud: bool, + }, + /// Connected lines (Polygonal Line tool). `start` is the first vertex, `end` the last. + PolyLine { + vertices: Vec<[f64; 2]>, + start: LineEnding, + end: LineEnding, + }, + /// A text callout: a text box at `rect` with a leader line from `point` (arrowhead) via + /// `knee` to the box (FreeText, `/IT /FreeTextCallout`, `/CL`). + Callout { + rect: [f64; 4], + knee: [f64; 2], + point: [f64; 2], + font_size: f64, + ending: LineEnding, + }, + /// Insert text: a caret in `rect` (its point at the top centre). + Caret { + rect: [f64; 4], + }, + /// Attach a file as a comment: an icon whose top-left corner is at `at`, holding `data` + /// (embedded) under the name `file`. + Attachment { + at: [f64; 2], + icon: AttachIcon, + file: String, + data: Vec, + }, +} + +/// The standard file attachment icons (§12.5.6.15). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum AttachIcon { + #[default] + PushPin, + Paperclip, + Graph, + Tag, +} + +impl AttachIcon { + pub const ALL: [AttachIcon; 4] = [AttachIcon::PushPin, AttachIcon::Paperclip, AttachIcon::Graph, AttachIcon::Tag]; + pub fn name(self) -> &'static str { + match self { + AttachIcon::PushPin => "PushPin", + AttachIcon::Paperclip => "Paperclip", + AttachIcon::Graph => "Graph", + AttachIcon::Tag => "Tag", + } + } + pub fn from_name(n: &str) -> Option { + Self::ALL.into_iter().find(|i| i.name().eq_ignore_ascii_case(n)) + } +} + +/// A rectangle `[x0 y0 x1 y1]` as a quad in Acrobat's order (top-left, top-right, bottom-left, +/// bottom-right). +pub fn rect_quad(r: [f64; 4]) -> [f64; 8] { + let [x0, y0, x1, y1] = normalize(r); + [x0, y1, x1, y1, x0, y0, x1, y0] +} + +impl Shape { + pub fn subtype(&self) -> &'static str { + match self { + Shape::Note { .. } => "Text", + Shape::TextMarkup { kind, .. } => kind.subtype(), + Shape::Rectangle { .. } => "Square", + Shape::Oval { .. } => "Circle", + Shape::Line { .. } => "Line", + Shape::Ink { .. } | Shape::Signature { .. } => "Ink", + Shape::TextBox { .. } | Shape::Typewriter { .. } | Shape::Callout { .. } => "FreeText", + Shape::Mark { .. } | Shape::Stamp { .. } | Shape::CustomStamp { .. } | Shape::TypedSignature { .. } => "Stamp", + Shape::Redact { .. } => "Redact", + Shape::Polygon { .. } => "Polygon", + Shape::PolyLine { .. } => "PolyLine", + Shape::Caret { .. } => "Caret", + Shape::Attachment { .. } => "FileAttachment", + } + } +} + +/// The standard fonts of redaction overlay text. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum OverlayFont { + #[default] + Helvetica, + Times, + Courier, +} + +impl OverlayFont { + pub const ALL: [OverlayFont; 3] = [OverlayFont::Helvetica, OverlayFont::Times, OverlayFont::Courier]; + + pub fn name(self) -> &'static str { + match self { + OverlayFont::Helvetica => "Helvetica", + OverlayFont::Times => "Times Roman", + OverlayFont::Courier => "Courier", + } + } + + /// The `/DA` font resource name (Acrobat's form font names). + pub fn resource(self) -> &'static str { + match self { + OverlayFont::Helvetica => "Helv", + OverlayFont::Times => "TiRo", + OverlayFont::Courier => "Cour", + } + } + + pub fn from_resource(n: &str) -> OverlayFont { + match n { + "TiRo" | "Times-Roman" | "TimesRoman" => OverlayFont::Times, + "Cour" | "Courier" => OverlayFont::Courier, + _ => OverlayFont::Helvetica, + } + } +} + +/// How redaction overlay text is drawn (Redaction Properties ▸ Appearance). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct OverlayLook { + pub font: OverlayFont, + /// Points; 0 = auto-size to fit the area. + pub size: f64, + pub color: Rgb, + /// 0 left, 1 centre, 2 right. + pub align: u8, + /// Repeat the text to fill the area. + pub repeat: bool, +} + +impl Default for OverlayLook { + fn default() -> Self { + Self { font: OverlayFont::Helvetica, size: 0.0, color: [1.0, 0.0, 0.0], align: 1, repeat: false } + } +} + +/// How a comment looks. +#[derive(Clone, Debug, PartialEq)] +pub struct Style { + /// Stroke colour (text colour for text boxes, icon colour for notes). + pub color: Rgb, + /// 0–1 (`/CA`). + pub opacity: f64, + /// Border / stroke width in points (0 = none). + pub width: f64, + /// Interior fill for rectangles and ovals (`/IC`), background for text boxes. + pub fill: Option, +} + +/// The style properties changed by one annotation edit. `None` leaves a property unchanged. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct StyleChange { + pub color: Option, + pub opacity: Option, + pub width: Option, + pub endings: Option>, +} + +impl StyleChange { + pub fn new(color: Option, opacity: Option, width: Option, endings: Option>) -> Self { + Self { color, opacity, width, endings } + } +} + +/// Inputs for replacing selected text with a grouped strikeout and caret annotation. +pub struct TextReplacement<'a> { + pub page: usize, + pub quads: &'a [[f64; 8]], + pub replacement: &'a str, + pub author: &'a str, + pub strike: &'a Style, + pub caret: &'a Style, + pub strike_meta: &'a Meta, + pub caret_meta: &'a Meta, +} + +impl Default for Style { + fn default() -> Self { + Self { color: [1.0, 0.82, 0.0], opacity: 1.0, width: 1.0, fill: None } + } +} + +impl Style { + /// The default look of each commenting tool. + pub fn default_for(shape: &Shape) -> Self { + let (color, width) = match shape { + Shape::Note { .. } => ([1.0, 0.82, 0.0], 1.0), + Shape::TextMarkup { kind: Markup::Highlight, .. } => ([1.0, 0.94, 0.0], 1.0), + Shape::TextMarkup { kind: Markup::Underline, .. } => ([0.0, 0.47, 0.84], 1.0), + Shape::TextMarkup { kind: Markup::StrikeOut, .. } => ([0.89, 0.13, 0.13], 1.0), + Shape::TextMarkup { kind: Markup::Squiggly, .. } => ([0.18, 0.62, 0.36], 1.0), + Shape::Rectangle { .. } | Shape::Oval { .. } | Shape::Line { .. } | Shape::Polygon { .. } | Shape::PolyLine { .. } => { + ([0.89, 0.13, 0.13], 2.0) + } + Shape::Callout { .. } => ([0.0, 0.0, 0.0], 1.0), + Shape::Caret { .. } | Shape::Attachment { .. } => ([0.0, 0.47, 0.84], 1.0), + Shape::Ink { .. } => ([0.0, 0.4, 0.87], 2.0), + Shape::TextBox { .. } | Shape::Typewriter { .. } => ([0.0, 0.0, 0.0], 0.0), + Shape::Mark { .. } => ([0.0, 0.0, 0.0], 1.5), + Shape::Signature { .. } => ([0.0, 0.0, 0.0], 1.5), + Shape::Stamp { stamp, .. } => (stamp.color(), 2.0), + Shape::CustomStamp { .. } => ([0.0, 0.0, 0.0], 0.0), + Shape::TypedSignature { .. } => ([0.0, 0.0, 0.0], 0.0), + // Red outline while marked; a black box once applied. + Shape::Redact { .. } => return Self { color: [0.89, 0.13, 0.13], opacity: 1.0, width: 1.0, fill: Some([0.0, 0.0, 0.0]) }, + }; + Self { color, opacity: 1.0, width, fill: None } + } +} + +/// A comment to add. +#[derive(Clone, Debug, PartialEq)] +pub struct NewAnnotation { + /// 0-based page index. + pub page: usize, + pub shape: Shape, + pub style: Style, + /// The comment text (`/Contents`); the text shown by a text box. + pub contents: String, + /// `/T` (shown as the comment's author). + pub author: String, +} + +/// Values stamped onto what an edit creates or changes, supplied by the caller so edits stay +/// deterministic: a PDF date (`D:…`) and a unique id for `/NM`. +#[derive(Clone, Debug, PartialEq, Default)] +pub struct Meta { + pub date: Option, + pub id: String, +} + +/// Review states a reply can set (§12.5.6.3, Table 172; Acrobat's "Set status"). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ReviewState { + None, + Accepted, + Rejected, + Cancelled, + Completed, +} + +impl ReviewState { + pub fn name(self) -> &'static str { + match self { + ReviewState::None => "None", + ReviewState::Accepted => "Accepted", + ReviewState::Rejected => "Rejected", + ReviewState::Cancelled => "Cancelled", + ReviewState::Completed => "Completed", + } + } + + pub fn from_name(n: &str) -> Option { + [Self::None, Self::Accepted, Self::Rejected, Self::Cancelled, Self::Completed].into_iter().find(|s| s.name().eq_ignore_ascii_case(n)) + } +} + +// ── building ──────────────────────────────────────────────────────────────────────────────── + +/// Annotation flags (§12.5.3). +pub(crate) const FLAG_PRINT: i64 = 4; + +pub(crate) const FLAG_NO_ZOOM: i64 = 8; + +pub(crate) const FLAG_NO_ROTATE: i64 = 16; + +pub(crate) const FLAG_HIDDEN: i64 = 2; + +pub(crate) const FLAG_NO_VIEW: i64 = 32; + +pub(crate) const FLAG_LOCKED: i64 = 128; + +/// Size of a note icon (points, unscaled by zoom). +pub const NOTE_SIZE: f64 = 20.0; diff --git a/crates/automation/README.md b/crates/automation/README.md index cdaaa13e64..d17084aa30 100644 --- a/crates/automation/README.md +++ b/crates/automation/README.md @@ -112,13 +112,13 @@ The release packages ship `pdfcraft-cli` alongside the desktop app, so no build | Install | CLI | |---|---| -| Windows (MSI) | `C:\Program Files\PdfCraft\pdfcraft-cli.exe` by default (wherever you installed it otherwise), not on `PATH` | +| Windows (MSI) | `%LOCALAPPDATA%\Programs\ArtCraft\PdfCraft\pdfcraft-cli.exe` by default (`C:\Program Files\ArtCraft\PdfCraft\pdfcraft-cli.exe` for an all-users install, under `Program Files (x86)` instead for the 32-bit build, or wherever you installed it otherwise), not on `PATH` | | Linux (deb, rpm) | `/usr/bin/pdfcraft-cli` | | macOS | the separate `pdfcraft-cli--macos-.zip` release asset (the `.app` holds only the desktop app) | ```sh -# Windows, default install folder -claude mcp add pdfcraft -- "C:\Program Files\PdfCraft\pdfcraft-cli.exe" mcp --root +# Windows (PowerShell), default install folder +claude mcp add pdfcraft -- "$env:LOCALAPPDATA\Programs\ArtCraft\PdfCraft\pdfcraft-cli.exe" mcp --root # Linux, or macOS with the CLI unzipped onto PATH claude mcp add pdfcraft -- pdfcraft-cli mcp --root ``` diff --git a/crates/automation/src/a11y.rs b/crates/automation/src/a11y.rs index 20243dcdae..1f0c9cb8a5 100644 --- a/crates/automation/src/a11y.rs +++ b/crates/automation/src/a11y.rs @@ -8,6 +8,10 @@ use std::collections::BTreeSet; use crate::{Args, Automation, Result, ToolError, child, failed, unused, write_atomic}; +mod documents; +mod form_tools; +mod ocr; + fn status_id(s: Status) -> &'static str { match s { Status::Passed => "passed", @@ -574,511 +578,6 @@ impl Automation { } } -impl Automation { - /// Recognition settings from the arguments: `dpi` (clamped to 72–600), `language` (refused - /// unless the models read it) and `skip_text_pages`, each left at its default when absent. - pub(crate) fn ocr_settings(&self, a: &Args) -> Result { - let mut settings = pdfcraft_engine::ocr::OcrSettings::default(); - if let Some(d) = a.opt_num("dpi")? { - settings.dpi = d.clamp(72.0, 600.0) as f32; - } - if let Some(l) = a.opt_str("language")? { - if !pdfcraft_engine::ocr::LANGUAGES.iter().any(|x| x.0 == l) { - return Err(ToolError::InvalidArgs(format!("unsupported language {l:?}"))); - } - settings.language = l.into(); - } - if let Some(s) = a.opt_bool("skip_text_pages")? { - settings.skip_text_pages = s; - } - Ok(settings) - } - - /// Recognizes text in each of `paths` and writes the result under the same file name in - /// `folder`, reporting per file its output, word count and skipped pages, or its error. A - /// path outside the root refuses the whole call before any work starts. - pub(crate) fn ocr_recognize_files(&mut self, a: &Args) -> Result { - let settings = self.ocr_settings(a)?; - let folder = self.resolve(a.str("folder")?, true)?; - // A path that leaves the root refuses the whole call before any work starts, like the - // other tools (the write check passes missing files, which stay per-file errors below). - let paths = a.strs("paths")?; - for p in &paths { - self.resolve_for(p, crate::file_access::Access::Read, true)?; - } - // Two inputs that would write the same output file overwrite each other while both report - // success, so the whole batch is refused here: before the folder is created, anything is - // read, or the OCR engines load. - let targets: Vec = paths.iter().map(|p| child(&folder, &input_file_name(p))).collect(); - let shared = shared_file_names(&targets); - if !shared.is_empty() { - return Err(ToolError::InvalidArgs(format!( - "more than one input would write to the same output file, so each would replace the one before it. Colliding file names: {}. Give each input a different file name, or run them in separate batches with different folders.", - shared.join(", ") - ))); - } - std::fs::create_dir_all(&folder).map_err(|e| failed(e.to_string()))?; - let ocr = pdfcraft_engine::ocr::engine().map_err(failed)?; - let mut out = Vec::new(); - for p in paths { - let name = input_file_name(p); - let result = - self.resolve(p, false).map_err(|e| e.to_string()).and_then(|src| crate::read_input_bytes(&src).map_err(|e| e.to_string())).and_then( - |bytes| pdfcraft_engine::ocr::recognize_file(&name, std::sync::Arc::new(bytes), None, settings.clone(), &ocr, |_, _| true), - ); - out.push(match result { - Ok(r) => { - let target = child(&folder, &name); - write_atomic(&target, &r.bytes)?; - let skipped: Vec = r.pages.iter().filter(|p| p.skipped.is_some()).map(|p| p.page + 1).collect(); - json!({ "path": p, "output": target.to_string_lossy(), "words": r.words(), "skipped_pages": skipped }) - } - Err(e) => json!({ "path": p, "error": e }), - }); - } - Ok(json!({ "files": out })) - } - - /// Recognizes text on the open document's `pages` (every page when absent) and adds it, - /// reporting the total word count and, per page, why it was skipped or what it found. - pub(crate) fn ocr_recognize(&mut self, a: &Args) -> Result { - let settings = self.ocr_settings(a)?; - let pages = if a.opt_ints("pages")?.is_some() { self.pages(a, "pages")? } else { Vec::new() }; - let doc = self.doc(a)?; - // Recognizing needs only permission to change the document; the words it found are - // returned only when copying is allowed too. - let (id, copy) = (doc.id, doc.allows_copying()); - let found = self.session.recognize_text(id, &pages, settings).map_err(failed)?; - let list: Vec = found - .iter() - .map(|p| match &p.skipped { - Some(why) => json!({ "page": p.page + 1, "skipped": why }), - None if copy => json!({ "page": p.page + 1, "words": p.words.len(), "already_on_page": p.already_on_page, "text": p.text() }), - None => json!({ "page": p.page + 1, "words": p.words.len(), "already_on_page": p.already_on_page }), - }) - .collect(); - Ok(json!({ "words": found.iter().map(|p| p.words.len()).sum::(), "pages": list })) - } - - /// Whether text recognition's models are installed, the directories searched for them (in - /// order), and the languages it reads, each as a code and a name. - pub(crate) fn ocr_status(&self) -> Result { - use pdfcraft_engine::ocr; - let dirs: Vec = ocr::Models::search_dirs().iter().map(|d| d.to_string_lossy().into_owned()).collect(); - let langs: Vec = ocr::LANGUAGES.iter().map(|(c, n)| json!({ "code": c, "name": n })).collect(); - Ok(json!({ "available": ocr::available(), "search_dirs": dirs, "languages": langs })) - } -} - -/// The file name an input is written under: its own file name, or `document.pdf` when it has none. -fn input_file_name(path: &str) -> String { - std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "document.pdf".into()) -} - -/// The output file names that two or more of `targets` share, each spelling listed once, in input -/// order. Names are compared without regard to case on Windows and macOS and exactly elsewhere; on -/// Windows a trailing dot or space is not part of the name (`scan.pdf.` is `scan.pdf`). Unicode -/// normalization forms (NFC and NFD spellings of one name) are not compared, so such inputs are -/// not caught. The folder is the same for every target, so only the names are compared. -fn shared_file_names(targets: &[std::path::PathBuf]) -> Vec { - let mut groups: std::collections::BTreeMap)> = std::collections::BTreeMap::new(); - for target in targets { - let name = target.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); - let key = if cfg!(windows) { - name.trim_end_matches(['.', ' ']).to_lowercase() - } else if cfg!(target_os = "macos") { - // Case only; NFC and NFD spellings of one name stay distinct here (a known limit). - name.to_lowercase() - } else { - name.clone() - }; - let (count, spellings) = groups.entry(key).or_insert_with(|| (0, Vec::new())); - *count += 1; - if !spellings.contains(&name) { - spellings.push(name); - } - } - groups.into_values().filter(|(count, _)| *count > 1).flat_map(|(_, spellings)| spellings).collect() -} - -/// A script's request to the viewer as the JSON that running a script reports; a side panel -/// goes by its fixed id. -fn request_json(r: &pdfcraft_engine::js::Request) -> Value { - use pdfcraft_engine::js::Request as R; - match r { - R::Reset(n) => json!({ "reset": n }), - R::Print => json!({ "print": true }), - R::GoToPage(p) => json!({ "page": p + 1 }), - R::LaunchUrl(u) => json!({ "url": u }), - R::Submit(u) => json!({ "submit": u }), - R::Focus(f) => json!({ "focus": f }), - R::Beep => json!({ "beep": true }), - R::SaveAs => json!({ "save_as": true }), - R::ShowPanel(p) => json!({ "panel": p.id() }), - } -} - -impl Automation { - pub(crate) fn js_run(&mut self, a: &Args) -> Result { - let id = self.doc(a)?.id; - let o = self.session.run_javascript(id, a.str("script")?, a.opt_str("field")?).map_err(failed)?; - let reqs: Vec = o.requests.iter().map(request_json).collect(); - Ok( - json!({ "alerts": o.alerts, "console": o.console, "requests": reqs, "error": o.error, "document_errors": o.doc_errors, "result": o.result }), - ) - } - - pub(crate) fn js_document_scripts(&self, a: &Args) -> Result { - let list: Vec = self.doc(a)?.document_scripts().into_iter().map(|(n, s)| json!({ "name": n, "script": s })).collect(); - Ok(json!({ "scripts": list })) - } - - pub(crate) fn js_set_document_script(&mut self, a: &Args) -> Result { - let edit = pdfcraft_engine::Edit::SetDocumentScript { name: a.str("name")?.into(), script: a.opt_str("script")?.map(str::to_string) }; - let id = self.doc(a)?.id; - self.session.apply(id, edit).map_err(failed)?; - self.js_document_scripts(a) - } - - pub(crate) fn form_set_script(&mut self, a: &Args) -> Result { - let edit = pdfcraft_engine::Edit::SetFieldScript { - name: a.str("field")?.into(), - event: a.str("event")?.into(), - script: a.opt_str("script")?.map(str::to_string), - }; - let id = self.doc(a)?.id; - self.session.apply(id, edit).map_err(failed)?; - let out = self.session.take_js_output(id); - Ok(json!({ "field": a.str("field")?, "event": a.str("event")?, "console": out.console, "errors": out.errors })) - } - - pub(crate) fn form_merge_data(&mut self, a: &Args) -> Result { - let mut files = Vec::new(); - for p in a.strs("paths")? { - let path = self.resolve(p, false)?; - files.push((p.to_string(), crate::read_input_bytes(&path).map_err(|e| failed(format!("{p}: {e}")))?)); - } - let csv = pdfcraft_engine::merge_data_files(&files).map_err(failed)?; - let target = self.resolve(a.str("path")?, true)?; - write_atomic(&target, csv.as_bytes())?; - let columns = csv.lines().next().map_or(0, |h| h.split(',').count()); - Ok(json!({ "path": target.to_string_lossy(), "rows": files.len(), "columns": columns })) - } - - pub(crate) fn doc_export_office(&self, a: &Args) -> Result { - let path = self.resolve(a.str("path")?, true)?; - let ext = path.extension().map(|e| e.to_string_lossy().into_owned()).unwrap_or_default(); - let format = pdfcraft_engine::compare::OfficeFormat::from_extension(&ext) - .ok_or_else(|| ToolError::InvalidArgs(format!("unsupported extension {ext:?} (docx, pptx, html or rtf)")))?; - let export = self.doc_to_copy(a)?.export_office_checked(format); - write_atomic(&path, &export.bytes)?; - // Text left out to keep a hostile page bounded is said, not silent (#3120). - let truncated: Vec = export.truncated_pages.iter().map(|p| p.saturating_add(1)).collect(); - let mut out = json!({ "path": path.to_string_lossy(), "bytes": export.bytes.len(), "format": format.extension(), - "truncated": !truncated.is_empty(), "truncated_pages": truncated }); - if !export.truncated_pages.is_empty() { - use pdfcraft_engine::compare::{MAX_FORM_DEPTH, MAX_FORM_VISITS}; - out["warnings"] = json!([format!( - "forms nested more than {MAX_FORM_DEPTH} deep, or past the first {MAX_FORM_VISITS} forms of a page (forms the page draws itself count too), were left out on the pages in truncated_pages: their text and images are missing from the export, although the viewer may still show them" - )]); - } - Ok(out) - } - - fn pdfa_level(&self, a: &Args) -> Result { - let l = a.opt_str("level")?.unwrap_or("2b"); - pdfcraft_engine::pdfa::Level::from_id(l).ok_or_else(|| ToolError::InvalidArgs(format!("unknown PDF/A level {l:?} (2b or 3b)"))) - } - - pub(crate) fn pdfa_verify(&self, a: &Args) -> Result { - let level = self.pdfa_level(a)?; - let doc = self.doc(a)?; - let issues: Vec = doc - .pdfa_verify(level) - .iter() - .map(|i| json!({ "clause": i.clause, "message": i.message, "page": i.page.map(|p| p + 1), "fixable": i.fixable })) - .collect(); - let d = doc.standards(); - Ok(json!({ - "level": level.label(), - "compliant": issues.is_empty(), - "issues": issues, - "declared": { "pdfa": d.pdfa.map(|(p, c)| format!("PDF/A-{p}{}", c.to_lowercase())), "pdfua": d.pdfua, "output_intents": d.output_intents }, - })) - } - - pub(crate) fn print_readiness(&self, a: &Args) -> Result { - let report = self.doc(a)?.print_readiness(); - let findings: Vec = - report.findings.iter().map(|f| json!({ "id": f.id, "severity": f.severity, "message": f.message, "page": f.page })).collect(); - let pages: Vec = report - .pages - .iter() - .map(|p| { - json!({ - "page": p.page, - "media_mm": p.media_mm, - "crop_mm": p.crop_mm, - "trim_mm": p.trim_mm, - "bleed_mm": p.bleed_mm, - "trim_set": p.trim_set, - "bleed_set": p.bleed_set, - "bleed_margin_mm": p.bleed_margin_mm, - }) - }) - .collect(); - let images: Vec = report - .images - .iter() - .map(|image| { - json!({ - "page": image.page, - "name": image.name, - "ppi": image.ppi, - "pixels": image.pixels, - "size_mm": image.size_mm, - }) - }) - .collect(); - let pdfa: Vec = report - .pdfa_issues - .iter() - .map(|issue| json!({ "clause": issue.clause, "message": issue.message, "page": issue.page.map(|p| p + 1) })) - .collect(); - Ok(json!({ - "text": report.text(), - "findings": findings, - "pages": pages, - "images": images, - "output_intents": report.output_intents, - "fonts_not_embedded": report.fonts_not_embedded, - "pdfa_issues": pdfa, - "resolution_unknown": report.resolution_unknown, - })) - } - - pub(crate) fn pdfa_convert(&mut self, a: &Args) -> Result { - let level = self.pdfa_level(a)?; - let id = self.doc(a)?.id; - self.session.apply(id, pdfcraft_engine::Edit::ConvertPdfA { level }).map_err(failed)?; - self.pdfa_verify(a) - } - - pub(crate) fn action_list(&self) -> Result { - use pdfcraft_engine::actions::{Step, builtin}; - let step_json = |s: &Step| json!({ "step": s.id(), "arg": s.arg() }); - let actions: Vec = builtin() - .iter() - .map(|a| json!({ "name": a.name, "description": a.description, "steps": a.steps.iter().map(step_json).collect::>() })) - .collect(); - let steps: Vec = Step::all().iter().map(|s| json!({ "step": s.id(), "label": s.label(), "takes_arg": s.arg().is_some() })).collect(); - Ok(json!({ "actions": actions, "steps": steps })) - } - - pub(crate) fn action_run(&mut self, a: &Args) -> Result { - use pdfcraft_engine::actions::{Action, Step, builtin, run_on}; - let action = if let Some(name) = a.opt_str("action")? { - builtin() - .into_iter() - .find(|x| x.name.eq_ignore_ascii_case(name)) - .ok_or_else(|| ToolError::InvalidArgs(format!("no built-in action {name:?}")))? - } else { - let items = a.get("steps").and_then(Value::as_array).ok_or_else(|| ToolError::InvalidArgs("give action or steps".into()))?; - let steps = items - .iter() - .map(|it| { - let id = it["step"].as_str().unwrap_or(""); - Step::from_id(id, it["arg"].as_str().unwrap_or("")).ok_or_else(|| ToolError::InvalidArgs(format!("unknown step {id:?}"))) - }) - .collect::>>()?; - Action { name: "Custom".into(), description: String::new(), steps, builtin: false } - }; - let folder = self.resolve(a.str("folder")?, true)?; - std::fs::create_dir_all(&folder).map_err(|e| failed(e.to_string()))?; - let mut out = Vec::new(); - for p in a.strs("paths")? { - let name = std::path::Path::new(p).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "document.pdf".into()); - let result = self - .resolve(p, false) - .map_err(|e| e.to_string()) - .and_then(|src| crate::read_input_bytes(&src).map_err(|e| e.to_string())) - .and_then(|bytes| run_on(&action, &name, std::sync::Arc::new(bytes), |_, _| {})); - out.push(match result { - Ok(r) => { - let target = child(&folder, &name); - let target = if target.exists() { - let stem = - std::path::Path::new(&name).file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_else(|| "document".into()); - unused(&folder, &stem) - } else { - target - }; - if target.exists() { - return Err(failed(format!("{}: no unused output name available", target.display()))); - } - write_atomic(&target, &r.bytes)?; - json!({ "path": p, "output": target.to_string_lossy(), "log": r.log }) - } - Err(e) => json!({ "path": p, "error": e }), - }); - } - Ok(json!({ "action": action.name, "files": out })) - } - - fn compare_ids(&self, a: &Args) -> Result<(pdfcraft_engine::DocId, pdfcraft_engine::DocId)> { - let new = self.doc(a)?.id; - let other = a.int("other")?; - let old = u64::try_from(other) - .ok() - .and_then(|o| self.session.get(pdfcraft_engine::DocId(o))) - .map(|d| d.id) - .ok_or_else(|| ToolError::InvalidArgs(format!("no open document {other}")))?; - Ok((old, new)) - } - - pub(crate) fn doc_compare(&self, a: &Args) -> Result { - use pdfcraft_engine::compare::Kind; - let (old, new) = self.compare_ids(a)?; - let c = self.session.compare(old, new).map_err(failed)?; - let limit = a.opt_int("limit")?.unwrap_or(500).max(1) as usize; - let r2 = |r: &[f64; 4]| r.map(|v| (v * 100.0).round() / 100.0); - let side = - |s: &pdfcraft_engine::compare::Side| json!({ "text": s.text, "page": s.page + 1, "rects": s.rects.iter().map(r2).collect::>() }); - let list: Vec = c - .changes - .iter() - .take(limit) - .map(|ch| json!({ "kind": ch.kind.label().to_lowercase(), "old": side(&ch.old), "new": side(&ch.new) })) - .collect(); - let visual: Vec = if a.opt_bool("visual")?.unwrap_or(false) { - self.session.compare_visual(old, new, 72.0).map_err(failed)?.iter().map(|(p, r)| json!({ "page": p + 1, "rect": r2(r) })).collect() - } else { - Vec::new() - }; - Ok(json!({ - "visual": visual, - "identical": c.identical(), - "replaced": c.count(Kind::Replaced), - "inserted": c.count(Kind::Inserted), - "deleted": c.count(Kind::Deleted), - "old_words": c.old_words, - "new_words": c.new_words, - "changes": list, - })) - } - - pub(crate) fn doc_compare_report(&self, a: &Args) -> Result { - let (old, new) = self.compare_ids(a)?; - let bytes = self.session.compare_report(old, new).map_err(failed)?; - let path = self.resolve(a.str("path")?, true)?; - write_atomic(&path, &bytes)?; - Ok(json!({ "path": path.to_string_lossy(), "bytes": bytes.len() })) - } - - pub(crate) fn doc_compare_mark(&mut self, a: &Args) -> Result { - let (old, new) = self.compare_ids(a)?; - let n = self.session.mark_differences(old, new).map_err(failed)?; - Ok(json!({ "comments": n })) - } - - pub(crate) fn form_detect_fields(&mut self, a: &Args) -> Result { - let pages = if a.opt_ints("pages")?.is_some() { self.pages(a, "pages")? } else { Vec::new() }; - let id = self.doc(a)?.id; - let found = self.session.detect_fields(id, &pages); - let list: Vec = found - .iter() - .map(|(p, c)| { - let (kind, comb) = match c.kind { - pdfcraft_engine::detect::Kind::Text => ("text", None), - pdfcraft_engine::detect::Kind::CheckBox => ("checkbox", None), - pdfcraft_engine::detect::Kind::Comb { cells } => ("text", Some(cells)), - }; - let mut v = json!({ "page": p + 1, "kind": kind, "name": c.name, "rect": c.rect.map(|v| (v * 100.0).round() / 100.0) }); - if let Some(cells) = comb { - v["comb"] = json!(cells); - } - v - }) - .collect(); - if a.opt_bool("add")?.unwrap_or(true) && !found.is_empty() { - self.session.auto_detect_fields(id, &pages).map_err(failed)?; - } - Ok(json!({ "fields": list })) - } - - pub(crate) fn form_actions(&self, a: &Args) -> Result { - use pdfcraft_engine::FieldAction as A; - let list: Vec = self - .doc(a)? - .field_actions(a.str("field")?) - .iter() - .map(|(t, act)| { - let mut v = match act { - A::JavaScript(j) => json!({ "javascript": j }), - A::Uri(u) => json!({ "url": u }), - A::Reset(f) => json!({ "reset": f }), - A::Named(n) => json!({ "menu": n }), - A::GoTo(p) => json!({ "page": p + 1 }), - A::ShowHide { fields, hide: true } => json!({ "hide": fields }), - A::ShowHide { fields, hide: false } => json!({ "show": fields }), - A::Submit(u) => json!({ "submit": u }), - A::Other(s) => json!({ "other": s }), - }; - v["trigger"] = json!(t.id()); - v - }) - .collect(); - Ok(json!({ "field": a.str("field")?, "actions": list })) - } - - pub(crate) fn form_set_actions(&mut self, a: &Args) -> Result { - use pdfcraft_engine::{FieldAction as A, FieldTrigger as T}; - let items = a.get("actions").and_then(Value::as_array).ok_or_else(|| ToolError::InvalidArgs("actions must be an array".into()))?; - let bad = |m: String| ToolError::InvalidArgs(m); - let names = |v: &Value| -> Vec { - v.as_array().map(|x| x.iter().filter_map(|s| s.as_str().map(str::to_string)).collect()).unwrap_or_default() - }; - let mut acts = Vec::new(); - for it in items { - let tid = it["trigger"].as_str().unwrap_or(""); - let t = T::from_id(tid).ok_or_else(|| bad(format!("unknown trigger {tid:?}")))?; - let act = if let Some(j) = it["javascript"].as_str() { - A::JavaScript(j.into()) - } else if let Some(u) = it["url"].as_str() { - A::Uri(u.into()) - } else if it.get("reset").is_some() { - A::Reset(names(&it["reset"])) - } else if let Some(m) = it["menu"].as_str() { - A::Named(m.into()) - } else if let Some(p) = it["page"].as_u64() { - A::GoTo((p.max(1) - 1) as usize) - } else if it.get("show").is_some() { - A::ShowHide { fields: names(&it["show"]), hide: false } - } else if it.get("hide").is_some() { - A::ShowHide { fields: names(&it["hide"]), hide: true } - } else if let Some(u) = it["submit"].as_str() { - A::Submit(u.into()) - } else { - return Err(bad(format!("{tid}: give one action (javascript, url, reset, menu, page, show, hide or submit)"))); - }; - acts.push((t, act)); - } - let name = a.str("field")?.to_string(); - let edit = - pdfcraft_engine::Edit::SetFieldProps { name, props: Box::new(pdfcraft_engine::FieldProps { actions: Some(acts), ..Default::default() }) }; - let id = self.doc(a)?.id; - self.session.apply(id, edit).map_err(failed)?; - self.form_actions(a) - } - - pub(crate) fn js_enabled(&mut self, a: &Args) -> Result { - if let Some(on) = a.opt_bool("enabled")? { - self.session.set_javascript(on); - } - Ok(json!({ "enabled": self.session.javascript() })) - } -} - #[cfg(test)] mod review_html_tests { use super::*; @@ -1098,21 +597,3 @@ mod review_html_tests { 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 { + 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( @@ -241,7 +252,8 @@ impl Session { }; // XFA notes survive the document being re-read after edits. let xfa_warnings: Vec = if info.xfa.is_some() { info.warnings.clone() } else { Vec::new() }; - let mut document = Self::build_document(&context.trust, name, path, bytes, info, cos, render_password, password, xfa); + 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); @@ -254,18 +266,8 @@ impl Session { } /// The last step of opening: build the document record and register it. - #[allow(clippy::too_many_arguments)] - fn build_document( - trust: &Arc, - name: String, - path: Option, - bytes: Arc>, - mut info: DocInfo, - cos: Result, Box>, - render_password: Option, - password: Option<&str>, - xfa: Option, - ) -> Document { + 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)) => { diff --git a/crates/engine/src/reexports.rs b/crates/engine/src/reexports.rs deleted file mode 100644 index 208a8e450b..0000000000 --- a/crates/engine/src/reexports.rs +++ /dev/null @@ -1,53 +0,0 @@ -//! What the engine re-exports from the other pdfcraft crates, so frontends need only this one. - -pub use pdfcraft_organize::{BoxSpec, FieldConflicts, PageBox, SplitBy, Transition, TransitionSpec, mime_for_name, split_ranges}; - -pub use pdfcraft_organize::LabelStyle; -pub use pdfcraft_organize::Standard; -pub use pdfcraft_organize::check_custom_key; -pub use pdfcraft_organize::view::{InitialView, Layout as InitialLayout, Magnification, Navigation}; - -pub use pdfcraft_cos::Algorithm; -pub use pdfcraft_create::{CONVERTIBLE, CreateError, ImageResolution, SourceKind, decode_text, source_kind}; -pub use pdfcraft_edit::added::{ - CHAR_SPACING_LIMIT as ADDED_CHAR_SPACING_LIMIT, paste_place as added_paste_place, paste_rect as added_paste_rect, text_rect as added_text_rect, - text_width as added_text_width, -}; -pub use pdfcraft_edit::{ - Added, AddedImage, AddedText, Align as TextAlign, Background, Content as AddedContent, Family as FontFamily, HeaderFooter, MarkKind, Watermark, -}; -pub use pdfcraft_forms::{ - BorderStyle, CalculatedField, CalculationIssue, CalculationOrder, CheckStyle, Field as FormField, FieldAction, FieldChange, FieldFont, - FieldKind as FormFieldKind, FieldProps, FieldValue, Look as FieldLook, LookPatch as FieldLookPatch, NewField, TabOrder, Trigger as FieldTrigger, - Widget as FormWidget, af as form_scripts, appearance as form_appearance, appearance::auto_text_size_by as field_auto_text_size_by, - calculations_changed as form_calculations_changed, flags as field_flags, -}; - -pub use pdfcraft_a11y as a11y; -pub use pdfcraft_edit::{BlockKey, BlockStyle, PageImage, RangeStyle, TextBlock, TextLine, block_overlapping, first_undrawable}; -pub use pdfcraft_measure as measure; -pub use pdfcraft_xfa::Report as XfaLayout; - -pub use pdfcraft_edit::{EditableObject, MAX_MOVE_OBJECTS, ObjectKind, ObjectTarget}; - -pub use pdfcraft_fonts::{MAX_SIGNATURE_CHARS, ScriptOutline, script_missing, script_outline, undrawable_message}; - -/// Comment geometry helpers (text-box line breaking) for frontends. -pub use pdfcraft_annot::appearance as annot_text; -pub use pdfcraft_annot::links::{Highlight as LinkHighlight, LinkAction, LinkItem, LinkStyle}; -pub use pdfcraft_annot::{ - AppearanceCache, AttachIcon, FillMark, LineEnding, Markup, NOTE_SIZE, NewAnnotation, NoteIcon, OverlayFont, OverlayLook, Props as CommentProps, - ReviewState, Rgb, Shape, StampGroup, StampKind, Style, rect_quad, -}; -pub use pdfcraft_forms::detect; -pub use pdfcraft_optimize as optimize; -pub use pdfcraft_preflight as pdfa; -pub use pdfcraft_print as print; -pub use pdfcraft_redact::codes::{CODE_SETS as REDACTION_CODE_SETS, CodeSet as RedactionCodeSet}; -pub use pdfcraft_redact::patterns::{ - MAX_WORDS as REDACT_MAX_WORDS, PATTERNS as REDACT_PATTERNS, Pattern as RedactPattern, find as find_pattern, word_list as redact_word_list, -}; -pub use pdfcraft_redact::sanitize::{HIDDEN, Hidden}; -pub use pdfcraft_sign as sign; -pub use pdfcraft_sign::{SignOptions, SignatureInfo, Status as SignatureStatus, TrustStore}; -pub use pdfcraft_xfdf::Format as DataFormat; diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index c3e34d90bd..1051903bae 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -18,6 +18,11 @@ use crate::{ writable, xfa, xfa_sync_datasets, }; +mod editing; +mod files; +mod save; +mod view; + #[derive(Default)] pub struct Session { pub(crate) docs: Vec, @@ -41,1550 +46,3 @@ pub struct Session { /// [`Session::with_xfa_time_scale`]. pub(crate) xfa_time_scale: Option, } - -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. - 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) - } - - /// Apply an edit. On success the previous state is undoable and the view data is refreshed. - pub fn apply(&mut self, id: DocId, edit: Edit) -> Result<(), EditError> { - let now = self.now(); - let today = self.today(); - let js_off = self.js_off; - let xfa_time_scale = self.xfa_time_scale.unwrap_or(1); - let doc = self.doc_mut(id)?; - doc.edit_notices.clear(); - let name = doc.name.clone(); - let is_xfa = doc.info.xfa.is_some(); - let mut scope = scope_of(&edit); - let mut cx = EditCtx::new(now, doc.generation ^ (id.0 << 48)); - if !js_off { - cx.xfa = doc.xfa_template.clone(); - } - cx.xfa_time_scale = xfa_time_scale; - cx.pages = doc.info.pages.len(); - cx.today = today; - let reason = doc.read_only_reason.clone().unwrap_or_default(); - let signed = doc.is_signed(); - let editor = doc.editor.as_mut().ok_or(EditError::ReadOnly(reason))?; - if let Some(p) = editor.cos.permissions() { - check_permission(&edit, &p)?; - } - // Rewriting an OCR word (invisible text over a scan) also hides the scanned word: cover it - // in the paper's colour and draw the new text in the ink's. - let edit = match edit { - Edit::EditTextBlock { page, block, text, mut style } => { - // Only invisible text is sampled; the blocks are usually cached from opening it. - if style.cover.is_none() - && let Some(info) = doc.info.pages.get(page) - && let Some(b) = editor.text_blocks(page).ok().and_then(|b| b.get(block).filter(|b| b.invisible).cloned()) - && let Some((paper, ink)) = guard(|| scan_colours::sample(&editor.cos, info, page, &b)).ok().flatten() - { - style.cover = Some(paper); - style.color = style.color.or(Some(ink)); - } - Edit::EditTextBlock { page, block, text, style } - } - other => other, - }; - // Nothing to bake: leave undo history and the dirty flag alone. - if matches!(&edit, Edit::FlattenFillSign) && !pdfcraft_annot::has_visible_fill_sign(&editor.cos) { - return Ok(()); - } - let mut next = editor.cos.clone(); - if !js_off && uses_scripts(&edit) { - cx.js = Some(js::JsRunner::new(&next, &name)); - } - cx.renamed_fields.clear(); - cx.redaction_overlays.clear(); - // `next` is a copy: if the edit fails or crashes, the document is unchanged. - guard(|| run_edit(&mut next, &edit, &mut cx)) - .unwrap_or_else(|m| Err(EditError::Invalid(format!("{} failed unexpectedly ({m}); the document was not changed", edit.label()))))?; - // Redaction removed what its own interpreter places under the marks. The marks were drawn - // over the page as the renderer shows it, so the renderer has the last word. - if let Edit::ApplyRedactions { pages } = &edit { - let marks: Vec = - pdfcraft_redact::marks(&editor.cos).into_iter().filter(|m| pages.as_ref().is_none_or(|p| p.contains(&m.page))).collect(); - guard(|| shown_under_redactions(&next, &marks, &cx.redaction_overlays, &doc.info.pages, doc.password.as_deref())) - .unwrap_or_else(|m| Err(EditError::Invalid(format!("{} failed unexpectedly ({m}); the document was not changed", edit.label()))))?; - } - // Renamed fields are said, not slipped in: a repair-log note (with the caveat that - // scripts were left alone) and a count in the undo step's label. - let renamed_note = |renamed: &[(String, String)]| { - (!renamed.is_empty()).then(|| { - let shown: Vec = renamed.iter().take(5).map(|(old, new)| format!("{old} → {new}")).collect(); - let more = renamed.len().saturating_sub(shown.len()); - let more = if more > 0 { format!(", and {more} more") } else { String::new() }; - format!( - "inserted pages renamed {} form field{} to keep their names separate ({}{more}); scripts that refer to the old names were not rewritten", - renamed.len(), - if renamed.len() == 1 { "" } else { "s" }, - shown.join(", ") - ) - }) - }; - if let Some(note) = renamed_note(&cx.renamed_fields) { - next.note_repair(note); - } - // Only Insert from File renames fields; its label says so in a wording the interface - // translates (the count lives in the repair note). - let label = match (&edit, cx.renamed_fields.len()) { - (_, 0) => edit.label(), - (Edit::InsertPagesFrom { name, .. }, n) => Label::with( - if n == 1 { "Insert pages from {name} — 1 field renamed" } else { "Insert pages from {name} — fields renamed" }, - name.as_str(), - ), - _ => edit.label(), - }; - // An XFA form's scripts answer the change: exit and validate scripts of the field, then - // every calculation (and a reset recalculates). They read the data, so it is brought up - // to date first. - if let Some(tpl) = cx.xfa.clone() { - if is_xfa && matches!(scope_of(&edit), Scope::Form | Scope::Full | Scope::Content) { - let datasets = &mut cx.xfa_datasets; - let notes = guard(|| xfa_sync_datasets(&mut next, Some(&tpl), datasets)) - .unwrap_or_else(|m| Err(format!("writing the XFA data failed unexpectedly ({m})"))) - .map_err(EditError::Write)?; - cx.xfa_out.errors.extend(notes); - } - fn collect(e: &Edit, changed: &mut Vec, reset: &mut bool) { - match e { - Edit::SetFieldValue { name, .. } => changed.push(name.clone()), - Edit::ResetForm { .. } => *reset = true, - Edit::Batch { edits, .. } => edits.iter().for_each(|e| collect(e, changed, reset)), - _ => {} - } - } - let mut changed: Vec = Vec::new(); - let mut reset = false; - collect(&edit, &mut changed, &mut reset); - if !changed.is_empty() || reset { - let mut run = xfa::XfaRun { - page_count: cx.pages, - out: &mut cx.xfa_out, - datasets: cx.xfa_datasets, - ran_away: false, - time_scale: cx.xfa_time_scale, - }; - // Every changed field's scripts, then the calculations once (after a reset, - // only those). - let ran = guard(|| xfa::on_changes(&mut next, &tpl, &changed, &mut run)) - .unwrap_or_else(|m| Err(format!("the form's scripts failed unexpectedly ({m})"))); - cx.xfa_datasets = run.datasets; - cx.xfa_ran_away |= run.ran_away; - if let Err(e) = ran { - cx.xfa_out.errors.push(e); - } - } - } - // XFA forms keep their values in the datasets packet too, for Adobe's viewers. - let mut xfa_notes = Vec::new(); - if is_xfa && matches!(scope_of(&edit), Scope::Form | Scope::Full | Scope::Content) { - let (tpl, datasets) = (cx.xfa.as_deref(), &mut cx.xfa_datasets); - xfa_notes = guard(|| xfa_sync_datasets(&mut next, tpl, datasets)) - .unwrap_or_else(|m| Err(format!("writing the XFA data failed unexpectedly ({m})"))) - .map_err(EditError::Write)?; - // Rebuilding from stale data would discard accepted widget values. - if xfa_notes.is_empty() { - let reflow = guard(|| xfa::relayout_changed_text(&mut next, tpl, &doc.form)) - .unwrap_or_else(|m| Err(format!("laying out the XFA text failed unexpectedly ({m})"))) - .map_err(EditError::Write)?; - if let Some(warnings) = reflow { - xfa_notes.extend(warnings); - // Layout replaces pages and widgets, even if the page count is unchanged. - // Undo/redo must rebuild these too instead of refreshing only field values. - scope = Scope::Full; - } - } - } - // Signed documents are only ever saved incrementally: an edit that needs a full rewrite - // (applying redactions, changing security, sanitizing) would invalidate the signatures. - // So does any edit to a file rebuilt from object headers: it has no chain to append to. - let rewrites = |c: &pdfcraft_cos::Document| c.full_save_required() || c.encryption_changed(); - if signed && ((rewrites(&next) && !rewrites(&editor.cos)) || (next.is_modified() && next.revisions().is_empty())) { - return Err(EditError::SignedRewrite(edit.label().to_string())); - } - // 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()); - } - Ok(()) - } - - pub fn undo(&mut self, id: DocId) -> Result { - let doc = self.doc_mut(id)?; - let editor = doc.editor.as_mut().ok_or(EditError::NothingToUndo)?; - let (label, prev, keys, scope) = editor.undo.pop().ok_or(EditError::NothingToUndo)?; - let current = std::mem::replace(&mut editor.cos, prev); - let current_keys = std::mem::replace(&mut editor.keys, keys); - editor.redo.push((label.clone(), current, current_keys, scope)); - Self::adopt_keys(doc); - let refreshed = guard(|| Self::refresh_scoped(doc, scope)).unwrap_or_else(|m| Err(EditError::Reopen(m))); - if let Err(e) = refreshed { - if let Some(ed) = doc.editor.as_mut() - && let Some((label, current, current_keys, scope)) = ed.redo.pop() - { - let prev = std::mem::replace(&mut ed.cos, current); - let keys = std::mem::replace(&mut ed.keys, current_keys); - ed.undo.push((label, prev, keys, scope)); - } - Self::adopt_keys(doc); - if !matches!(guard(|| Self::refresh(doc)), Ok(Ok(()))) { - Self::show_released(doc); - } - return Err(e); - } - doc.edit_notices.clear(); - Self::sync_dirty(doc); - doc.generation += 1; - Ok(label) - } - - pub fn redo(&mut self, id: DocId) -> Result { - let doc = self.doc_mut(id)?; - let editor = doc.editor.as_mut().ok_or(EditError::NothingToRedo)?; - let (label, next, keys, scope) = editor.redo.pop().ok_or(EditError::NothingToRedo)?; - let current = std::mem::replace(&mut editor.cos, next); - let current_keys = std::mem::replace(&mut editor.keys, keys); - editor.undo.push((label.clone(), current, current_keys, scope)); - Self::adopt_keys(doc); - let refreshed = guard(|| Self::refresh_scoped(doc, scope)).unwrap_or_else(|m| Err(EditError::Reopen(m))); - if let Err(e) = refreshed { - if let Some(ed) = doc.editor.as_mut() - && let Some((label, current, current_keys, scope)) = ed.undo.pop() - { - let next = std::mem::replace(&mut ed.cos, current); - let keys = std::mem::replace(&mut ed.keys, current_keys); - ed.redo.push((label, next, keys, scope)); - } - Self::adopt_keys(doc); - if !matches!(guard(|| Self::refresh(doc)), Ok(Ok(()))) { - Self::show_released(doc); - } - return Err(e); - } - doc.edit_notices.clear(); - Self::sync_dirty(doc); - doc.generation += 1; - Ok(label) - } - - /// Unsaved when the working graph is not the last opened or saved file. Undo back to that - /// file is clean; undo past a later save is not, because the restored snapshot's own base - /// is an earlier revision. - fn sync_dirty(doc: &mut Document) { - doc.dirty = doc.editor.as_ref().is_some_and(|e| e.unsaved_file || e.cos.is_modified() || !Arc::ptr_eq(e.cos.bytes(), &e.saved)); - } - - /// Use the current state's password for viewing (protection edits change it). - fn adopt_keys(doc: &mut Document) { - if let Some(e) = doc.editor.as_ref() { - doc.password = e.keys.render.clone(); - doc.config.password = e.keys.render.as_deref().map(Arc::from); - } - } - - /// Rebuild the working bytes and the view data that `scope` may have changed. Metadata edits - /// keep the renderer; other scopes also replace it. - /// Comment and form edits skip the full re-inspection (seconds on very large files): the - /// comment list and field values are re-read from the object graph instead. - fn refresh_scoped(doc: &mut Document, scope: Scope) -> Result<(), EditError> { - if scope == Scope::Full || doc.info.encrypted != doc.editor.as_ref().is_some_and(|e| e.cos.output_handler().is_some()) { - return Self::refresh(doc); - } - if scope == Scope::Content { - return Self::refresh_content(doc, None); - } - if scope == Scope::Links { - return Self::refresh_links(doc); - } - // Metadata edits keep the renderer, which holds the working file anyway. - if scope != Scope::Metadata { - Self::release_working(doc); - } - let Some(editor) = doc.editor.as_mut() else { return Ok(()) }; - // Comment edits, and form edits (a script may change annotation properties), may change - // annotation dictionaries, invalidating cached appearances. - if matches!(scope, Scope::Comments | Scope::Form) { - editor.appearances.clear(); - } - let bytes = if editor.cos.is_modified() { - Arc::new(write_incremental(&editor.cos, &SaveOptions::default()).map_err(|e| EditError::Write(e.to_string()))?) - } else { - editor.cos.bytes().clone() - }; - if scope == Scope::Metadata { - 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. - 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(()) - } - - /// 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(()) - } - - /// The page count of another PDF (Replace Pages, Insert Pages dialogs). - pub fn page_count_of(&self, name: &str, bytes: &Arc>) -> Result { - Ok(pdfcraft_organize::page_count(&open_source(name, bytes)?)?) - } - - /// A new blank document (Create ▸ Blank page). - pub fn create_blank(&self, width: f64, height: f64, pages: usize) -> Result>, EditError> { - self.write_new(&pdfcraft_create::blank(width, height, pages)?) - } - - /// A new document with one page per image (PNG, JPEG). - pub fn create_from_images(&self, images: &[(String, Vec)]) -> Result>, EditError> { - self.write_new(&pdfcraft_create::from_images(images)?) - } - - /// Create image pages at embedded resolution or a fixed dpi, without resampling. - pub fn create_from_images_with_resolution(&self, images: &[(String, Vec)], resolution: ImageResolution) -> Result>, EditError> { - self.write_new(&pdfcraft_create::from_images_with_resolution(images, resolution)?) - } - - /// A new document from plain text (US Letter, 11 pt Helvetica). Text Helvetica can't draw is - /// refused, naming the line and the character (never set as `?`). - pub fn create_from_text(&self, title: &str, text: &str) -> Result>, EditError> { - self.write_new(&pdfcraft_create::from_text(title, text, pdfcraft_create::LETTER, 11.0)?) - } - - /// [`Self::create_from_text`] for a report PdfCraft writes from documents (comment summaries, - /// compare reports), which can quote any language: characters Helvetica can't draw are written - /// as `[U+XXXX]`, and the report's first line says so, rather than the report being refused. - pub(crate) fn create_report(&self, title: &str, text: &str) -> Result>, EditError> { - let (doc, _escaped) = pdfcraft_create::from_text_escaped(title, text, pdfcraft_create::LETTER, 11.0)?; - self.write_new(&doc) - } - - /// Convert a file Create understands (an image or plain text) to PDF bytes; a PDF is checked - /// (it must open and allow copying pages) and returned as it is. - pub fn convert_to_pdf(&self, name: &str, bytes: &Arc>) -> Result<(SourceKind, Arc>), EditError> { - let Some(kind) = source_kind(name, bytes) else { - return Err(EditError::Source(format!("{name}: this file type can't be converted; use a PDF, an image or a .txt file"))); - }; - let title = name.rsplit_once('.').map_or(name, |(s, _)| s); - // Image decoders read untrusted bytes: a panic in one must not take the app down. - let created = guard(|| match kind { - SourceKind::Pdf => open_source(name, bytes).map(|_| bytes.clone()), - SourceKind::Image => self.create_from_images(&[(name.to_string(), bytes.to_vec())]), - SourceKind::Text => self.create_from_text(title, &decode_text(bytes)), - }) - .map_err(|_| EditError::Source(format!("{name}: the file could not be read")))?; - Ok((kind, created?)) - } - - /// A file for Combine as PDF bytes: an image or text file is converted; a PDF is returned as - /// it is, unchecked (Combine reads it itself, with its password), as is a file named .pdf - /// without a `%PDF-` header, which the reader may still open. - pub fn as_pdf(&self, name: &str, bytes: &Arc>) -> Result>, EditError> { - let pdf = match source_kind(name, bytes) { - Some(SourceKind::Pdf) => true, - Some(SourceKind::Image | SourceKind::Text) => false, - None => name.to_ascii_lowercase().ends_with(".pdf"), - }; - if pdf { Ok(bytes.clone()) } else { self.convert_to_pdf(name, bytes).map(|(_, pdf)| pdf) } - } - - /// Reduce File Size: Acrobat's defaults (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)) - } - - /// 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) - } - - 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())) - } - - /// 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 - } - } - - /// 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) - } - - /// 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(()) - } - - 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/session/editing.rs b/crates/engine/src/session/editing.rs new file mode 100644 index 0000000000..f8d5648a2d --- /dev/null +++ b/crates/engine/src/session/editing.rs @@ -0,0 +1,528 @@ +#[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> { + 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()); + } + Ok(()) + } + + pub fn undo(&mut self, id: DocId) -> Result { + let doc = self.doc_mut(id)?; + let editor = doc.editor.as_mut().ok_or(EditError::NothingToUndo)?; + let (label, prev, keys, scope) = editor.undo.pop().ok_or(EditError::NothingToUndo)?; + let current = std::mem::replace(&mut editor.cos, prev); + let current_keys = std::mem::replace(&mut editor.keys, keys); + editor.redo.push((label.clone(), current, current_keys, scope)); + Self::adopt_keys(doc); + let refreshed = guard(|| Self::refresh_scoped(doc, scope)).unwrap_or_else(|m| Err(EditError::Reopen(m))); + if let Err(e) = refreshed { + if let Some(ed) = doc.editor.as_mut() + && let Some((label, current, current_keys, scope)) = ed.redo.pop() + { + let prev = std::mem::replace(&mut ed.cos, current); + let keys = std::mem::replace(&mut ed.keys, current_keys); + ed.undo.push((label, prev, keys, scope)); + } + Self::adopt_keys(doc); + if !matches!(guard(|| Self::refresh(doc)), Ok(Ok(()))) { + Self::show_released(doc); + } + return Err(e); + } + doc.edit_notices.clear(); + Self::sync_dirty(doc); + doc.generation += 1; + Ok(label) + } + + pub fn redo(&mut self, id: DocId) -> Result { + let doc = self.doc_mut(id)?; + let editor = doc.editor.as_mut().ok_or(EditError::NothingToRedo)?; + let (label, next, keys, scope) = editor.redo.pop().ok_or(EditError::NothingToRedo)?; + let current = std::mem::replace(&mut editor.cos, next); + let current_keys = std::mem::replace(&mut editor.keys, keys); + editor.undo.push((label.clone(), current, current_keys, scope)); + Self::adopt_keys(doc); + let refreshed = guard(|| Self::refresh_scoped(doc, scope)).unwrap_or_else(|m| Err(EditError::Reopen(m))); + if let Err(e) = refreshed { + if let Some(ed) = doc.editor.as_mut() + && let Some((label, current, current_keys, scope)) = ed.undo.pop() + { + let next = std::mem::replace(&mut ed.cos, current); + let keys = std::mem::replace(&mut ed.keys, current_keys); + ed.redo.push((label, next, keys, scope)); + } + Self::adopt_keys(doc); + if !matches!(guard(|| Self::refresh(doc)), Ok(Ok(()))) { + Self::show_released(doc); + } + return Err(e); + } + doc.edit_notices.clear(); + Self::sync_dirty(doc); + doc.generation += 1; + Ok(label) + } + + /// Unsaved when the working graph is not the last opened or saved file. Undo back to that + /// file is clean; undo past a later save is not, because the restored snapshot's own base + /// is an earlier revision. + pub(super) fn sync_dirty(doc: &mut Document) { + doc.dirty = doc.editor.as_ref().is_some_and(|e| e.unsaved_file || e.cos.is_modified() || !Arc::ptr_eq(e.cos.bytes(), &e.saved)); + } + + /// Use the current state's password for viewing (protection edits change it). + pub(super) fn adopt_keys(doc: &mut Document) { + if let Some(e) = doc.editor.as_ref() { + doc.password = e.keys.render.clone(); + doc.config.password = e.keys.render.as_deref().map(Arc::from); + } + } + + /// Rebuild the working bytes and the view data that `scope` may have changed. Metadata edits + /// keep the renderer; other scopes also replace it. + /// Comment and form edits skip the full re-inspection (seconds on very large files): the + /// comment list and field values are re-read from the object graph instead. + fn refresh_scoped(doc: &mut Document, scope: Scope) -> Result<(), EditError> { + if scope == Scope::Full || doc.info.encrypted != doc.editor.as_ref().is_some_and(|e| e.cos.output_handler().is_some()) { + return Self::refresh(doc); + } + if scope == Scope::Content { + return Self::refresh_content(doc, None); + } + if scope == Scope::Links { + return Self::refresh_links(doc); + } + // Metadata edits keep the renderer, which holds the working file anyway. + if scope != Scope::Metadata { + Self::release_working(doc); + } + let Some(editor) = doc.editor.as_mut() else { return Ok(()) }; + // Comment edits, and form edits (a script may change annotation properties), may change + // annotation dictionaries, invalidating cached appearances. + if matches!(scope, Scope::Comments | Scope::Form) { + editor.appearances.clear(); + } + let bytes = if editor.cos.is_modified() { + Arc::new(write_incremental(&editor.cos, &SaveOptions::default()).map_err(|e| EditError::Write(e.to_string()))?) + } else { + editor.cos.bytes().clone() + }; + if scope == Scope::Metadata { + for (field, value) in info_fields(&mut doc.info, &editor.cos) { + *field = value; + } + doc.info.custom = pdfcraft_organize::custom_info(&editor.cos); + doc.info.file_size = bytes.len(); + #[cfg(test)] + check_refresh_panic(); + if !doc.signatures.is_empty() { + let signatures = signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache); + doc.signatures = Arc::new(signatures); + } + doc.bytes = bytes; + return Ok(()); + } + let mut form = pdfcraft_forms::fields(&editor.cos); + xfa::mark_script_buttons(&editor.cos, &mut form); + let form = Arc::new(form); + match scope { + Scope::Metadata => return Ok(()), + Scope::Comments => { + doc.info.annotations = comment_list(&editor.cos); + // Link indexes are positions in /Annots, so comment insertion/deletion can move them. + doc.links = OnceLock::new(); + } + Scope::Form => { + // PERF: keystroke path, index once. A linear find per field is O(F^2); + // forms reach 10k fields. + let mut by_name = std::collections::HashMap::with_capacity(form.len()); + for x in form.iter() { + // First match wins (do not simplify to collect: it keeps the last). + by_name.entry(x.name.as_str()).or_insert(x); + } + for f in &mut doc.info.fields { + if let Some(ff) = by_name.get(f.name.as_str()) { + f.value = match ff.kind { + pdfcraft_forms::FieldKind::CheckBox | pdfcraft_forms::FieldKind::Radio => { + Some(ff.value.first().cloned().unwrap_or_else(|| "Off".into())) + } + _ if ff.value.is_empty() => None, + _ => Some(ff.value.join(", ")), + }; + } + } + } + Scope::Links => return Self::refresh_links(doc), + // Handled above; kept as a full refresh so the match stays total. + Scope::Full => return Self::refresh(doc), + // Handled above (content-only refresh); the arm keeps the match total. + Scope::Content => return Self::refresh_content(doc, None), + } + doc.info.file_size = bytes.len(); + doc.form = form; + let signatures = if doc.signatures.is_empty() { Vec::new() } else { signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache) }; + doc.display = display_bytes(Some(editor), &bytes); + doc.signatures = Arc::new(signatures); + doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), 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..1e8cd9b08a --- /dev/null +++ b/crates/engine/src/session/files.rs @@ -0,0 +1,401 @@ +use super::{ + Arc, CombineSource, Combined, DataFormat, DocId, EditError, Hidden, ImageResolution, LOW_RESOLUTION_PRINT_DPI, OptimizeReport, SaveOptions, + Session, SourceKind, SplitPart, decode_text, guard, open_source, open_source_with, optimize, print, print_raster, set_default_layer_state, + source_kind, writable, write_full, +}; + +impl Session { + /// The page count of another PDF (Replace Pages, Insert Pages dialogs). + pub fn page_count_of(&self, name: &str, bytes: &Arc>) -> Result { + Ok(pdfcraft_organize::page_count(&open_source(name, bytes)?)?) + } + + /// A new blank document (Create ▸ Blank page). + pub fn create_blank(&self, width: f64, height: f64, pages: usize) -> Result>, EditError> { + self.write_new(&pdfcraft_create::blank(width, height, pages)?) + } + + /// A new document with one page per image (PNG, JPEG). + pub fn create_from_images(&self, images: &[(String, Vec)]) -> Result>, EditError> { + self.write_new(&pdfcraft_create::from_images(images)?) + } + + /// Create image pages at embedded resolution or a fixed dpi, without resampling. + pub fn create_from_images_with_resolution(&self, images: &[(String, Vec)], resolution: ImageResolution) -> Result>, EditError> { + self.write_new(&pdfcraft_create::from_images_with_resolution(images, resolution)?) + } + + /// A new document from plain text (US Letter, 11 pt Helvetica). Text Helvetica can't draw is + /// refused, naming the line and the character (never set as `?`). + pub fn create_from_text(&self, title: &str, text: &str) -> Result>, EditError> { + self.write_new(&pdfcraft_create::from_text(title, text, pdfcraft_create::LETTER, 11.0)?) + } + + /// [`Self::create_from_text`] for a report PdfCraft writes from documents (comment summaries, + /// compare reports), which can quote any language: characters Helvetica can't draw are written + /// as `[U+XXXX]`, and the report's first line says so, rather than the report being refused. + pub(crate) fn create_report(&self, title: &str, text: &str) -> Result>, EditError> { + let (doc, _escaped) = pdfcraft_create::from_text_escaped(title, text, pdfcraft_create::LETTER, 11.0)?; + self.write_new(&doc) + } + + /// Convert a file Create understands (an image or plain text) to PDF bytes; a PDF is checked + /// (it must open and allow copying pages) and returned as it is. + pub fn convert_to_pdf(&self, name: &str, bytes: &Arc>) -> Result<(SourceKind, Arc>), EditError> { + let Some(kind) = source_kind(name, bytes) else { + return Err(EditError::Source(format!("{name}: this file type can't be converted; use a PDF, an image or a .txt file"))); + }; + let title = name.rsplit_once('.').map_or(name, |(s, _)| s); + // Image decoders read untrusted bytes: a panic in one must not take the app down. + let created = guard(|| match kind { + SourceKind::Pdf => open_source(name, bytes).map(|_| bytes.clone()), + SourceKind::Image => self.create_from_images(&[(name.to_string(), bytes.to_vec())]), + SourceKind::Text => self.create_from_text(title, &decode_text(bytes)), + }) + .map_err(|_| EditError::Source(format!("{name}: the file could not be read")))?; + Ok((kind, created?)) + } + + /// A file for Combine as PDF bytes: an image or text file is converted; a PDF is returned as + /// it is, unchecked (Combine reads it itself, with its password), as is a file named .pdf + /// without a `%PDF-` header, which the reader may still open. + pub fn as_pdf(&self, name: &str, bytes: &Arc>) -> Result>, EditError> { + let pdf = match source_kind(name, bytes) { + Some(SourceKind::Pdf) => true, + Some(SourceKind::Image | SourceKind::Text) => false, + None => name.to_ascii_lowercase().ends_with(".pdf"), + }; + if pdf { Ok(bytes.clone()) } else { self.convert_to_pdf(name, bytes).map(|(_, pdf)| pdf) } + } + + /// Reduce File Size: Acrobat's defaults (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_scan.rs b/crates/engine/src/signature_scan.rs index ba6ca269ab..4742754b01 100644 --- a/crates/engine/src/signature_scan.rs +++ b/crates/engine/src/signature_scan.rs @@ -602,303 +602,4 @@ fn render(area: &RgbImage, alpha: &[u8], bounds: Region) -> Result, Scan } #[cfg(test)] -mod tests { - use super::*; - - /// A gray "photo": `paper(x, y)` brightness with dark ink rectangles `[x, y, w, h]`. - fn photo(width: usize, height: usize, paper: impl Fn(usize, usize) -> u8, ink: &[[usize; 4]], ink_value: u8) -> Vec { - let mut out = vec![0; width * height]; - for y in 0..height { - for x in 0..width { - let inside = ink.iter().any(|r| x >= r[0] && x < r[0] + r[2] && y >= r[1] && y < r[1] + r[3]); - out[y * width + x] = if inside { ink_value } else { paper(x, y) }; - } - } - out - } - - fn rgb(gray: &[u8], width: usize, height: usize) -> RgbImage { - RgbImage::from_fn(width as u32, height as u32, |x, y| { - let v = gray[y as usize * width + x as usize]; - image::Rgb([v, v, v]) - }) - } - - fn jpeg(image: &RgbImage) -> Vec { - let mut out = Cursor::new(Vec::new()); - image.write_to(&mut out, ImageFormat::Jpeg).unwrap(); - out.into_inner() - } - - /// Photos keep the image signature limits (#1124): a 4 MiB file, at most 32 megapixels and - /// 8192 pixels per side decoded, and a working copy of at most 4 megapixels. - #[test] - fn photos_keep_the_image_signature_limits() { - let png = |image: &RgbImage| { - let mut out = Cursor::new(Vec::new()); - image.write_to(&mut out, ImageFormat::Png).unwrap(); - out.into_inner() - }; - assert!(matches!(SignaturePhoto::from_bytes(&vec![0; MAX_PHOTO_BYTES + 1]), Err(ScanError::Size))); - assert!(SignaturePhoto::read(std::io::repeat(0)).is_err()); - assert!(matches!(SignaturePhoto::from_bytes(&png(&RgbImage::new(8193, 1))), Err(ScanError::Size))); - assert!(matches!(SignaturePhoto::from_bytes(b"GIF89a\x02\0\x02\0"), Err(ScanError::Format))); - let [w, h] = SignaturePhoto::from_bytes(&png(&RgbImage::from_pixel(8000, 4000, image::Rgb([220, 220, 215])))).unwrap().size(); - assert!(w <= WORKING_MAX_SIDE && u64::from(w) * u64::from(h) <= MAX_OUTPUT_PIXELS, "{w} x {h}"); - assert!((f64::from(w) / f64::from(h) - 2.0).abs() < 0.01, "the aspect ratio is kept: {w} x {h}"); - } - - fn region(r: [usize; 4]) -> Region { - Region { x: r[0], y: r[1], width: r[2], height: r[3] } - } - - fn opts() -> Options { - Options { sensitivity: DEFAULT_SENSITIVITY, despeckle: true, background_radius: None } - } - - #[test] - fn box_blur_averages_and_keeps_a_constant_signal() { - let mut values = vec![0.0, 0.0, 9.0, 0.0, 0.0]; - box_blur(&mut values, 5, 1, 1); - assert_eq!(values, vec![0.0, 3.0, 3.0, 3.0, 0.0]); - let mut flat = vec![7.0; 12]; - box_blur(&mut flat, 4, 3, 2); - assert!(flat.iter().all(|v| (v - 7.0).abs() < 1e-4)); - } - - #[test] - fn square_filters_match_a_naive_window_with_repeated_edges() { - let (w, h) = (13, 7); - let src: Vec = (0..w * h).map(|i| ((i * 37 + 11) % 251) as u8).collect(); - for radius in [1, 2, 5] { - for max in [true, false] { - let fast = square_filter(&src, w, h, radius, max); - for y in 0..h { - for x in 0..w { - let mut want = if max { 0 } else { 255 }; - for dy in -(radius as i64)..=radius as i64 { - for dx in -(radius as i64)..=radius as i64 { - let sx = (x as i64 + dx).clamp(0, w as i64 - 1) as usize; - let sy = (y as i64 + dy).clamp(0, h as i64 - 1) as usize; - let v = src[sy * w + sx]; - want = if max { want.max(v) } else { want.min(v) }; - } - } - assert_eq!(fast[y * w + x], want, "radius {radius} max {max} at {x},{y}"); - } - } - } - } - } - - #[test] - fn components_are_eight_connected() { - let mask = [true, false, false, false, false, true, false, true, false, false, false, false]; - let (labels, count) = label_components(&mask, 4, 3); - assert_eq!(count, 2); - assert_eq!(labels[0], labels[5]); - assert_ne!(labels[7], labels[0]); - assert_eq!(labels[1], 0); - } - - #[test] - fn sensitivity_lowers_the_threshold() { - assert!(threshold(0) > threshold(50)); - assert!(threshold(50) > threshold(100)); - assert_eq!(threshold(200), threshold(100)); - } - - #[test] - fn ink_survives_a_shadow_gradient_and_the_shadow_does_not() { - let (w, h) = (160, 80); - let stroke = [20, 30, 120, 6]; - let gray = photo(w, h, |x, _| (235 - 115 * x / (w - 1)) as u8, &[stroke], 30); - let ink = extract_ink(&gray, w, h, opts()); - assert_eq!(ink.alpha[10 * w + 150], 0); - assert_eq!(ink.alpha[33 * w + 30], 255); - assert_eq!(ink.alpha[33 * w + 130], 255); - assert_eq!(ink.bounds, Some(region(stroke))); - } - - #[test] - fn a_sharp_shadow_edge_is_not_ink() { - let (w, h) = (200, 120); - let stroke = [30, 52, 140, 4]; - let gray = photo(w, h, |_, y| if y < 60 { 230 } else { 120 }, &[stroke], 30); - let ink = extract_ink(&gray, w, h, opts()); - for y in 58..63 { - assert_eq!(ink.alpha[y * w + 10], 0); - assert_eq!(ink.alpha[y * w + 190], 0); - } - assert_eq!(ink.bounds, Some(region(stroke))); - } - - #[test] - fn a_table_at_the_edge_of_the_frame_is_not_ink() { - let (w, h) = (300, 120); - let stroke = [60, 60, 200, 4]; - let gray = photo(w, h, |x, y| if x < 12 || y < 10 { 70 } else { 225 }, &[stroke], 30); - let ink = extract_ink(&gray, w, h, opts()); - assert_eq!(ink.alpha[5 * w + 150], 0); - assert_eq!(ink.alpha[60 * w + 5], 0); - assert_eq!(ink.alpha[11 * w + 150], 0); - assert_eq!(ink.bounds, Some(region(stroke))); - } - - #[test] - fn thick_strokes_stay_solid_when_the_window_is_sized_on_the_photo() { - let (w, h) = (600, 200); - let stroke = [50, 90, 500, 18]; - let gray = photo(w, h, |_, _| 225, &[stroke], 30); - let middle = 99 * w + 300; - // Sized on the tight frame alone, the window is narrower than the stroke. - assert_eq!(extract_ink(&gray, w, h, opts()).alpha[middle], 0); - let ink = extract_ink(&gray, w, h, Options { background_radius: Some((4.0 * w as f32 * BACKGROUND_RADIUS_RATIO) as usize), ..opts() }); - assert_eq!(ink.bounds, Some(region(stroke))); - assert!((90..108).all(|y| ink.alpha[y * w + 300] == 255)); - } - - #[test] - fn faint_stroke_ends_touching_ink_are_kept_and_isolated_ones_are_not() { - let (w, h) = (200, 60); - let mut gray = photo(w, h, |_, _| 220, &[[20, 28, 80, 4]], 40); - for y in 28..32 { - for x in (100..180).chain(10..15) { - gray[y * w + x] = 175; - } - } - let ink = extract_ink(&gray, w, h, opts()); - assert!(ink.alpha[30 * w + 150] > 0); - assert_eq!(ink.alpha[30 * w + 12], 0); - assert_eq!(ink.bounds, Some(region([20, 28, 160, 4]))); - } - - #[test] - fn dust_is_removed_unless_despeckling_is_off() { - let (w, h) = (200, 100); - let gray = photo(w, h, |_, _| 230, &[[20, 40, 150, 5], [190, 5, 1, 1]], 20); - assert!(extract_ink(&gray, w, h, Options { despeckle: false, ..opts() }).alpha[5 * w + 190] > 0); - let ink = extract_ink(&gray, w, h, opts()); - assert_eq!(ink.alpha[5 * w + 190], 0); - assert_eq!(ink.bounds, Some(region([20, 40, 150, 5]))); - } - - #[test] - fn faint_strokes_need_a_higher_sensitivity() { - let (w, h) = (120, 60); - let gray = photo(w, h, |_, _| 220, &[[10, 25, 100, 4]], 165); - assert!(extract_ink(&gray, w, h, Options { sensitivity: 0, ..opts() }).bounds.is_none()); - assert!(extract_ink(&gray, w, h, Options { sensitivity: 100, ..opts() }).bounds.is_some()); - } - - #[test] - fn blank_paper_has_no_ink() { - let gray = photo(64, 64, |x, y| (200 + (x * 7 + y * 13) % 20) as u8, &[], 0); - assert!(extract_ink(&gray, 64, 64, opts()).bounds.is_none()); - assert!(detect_region(&gray, 64, 64).is_none()); - } - - #[test] - fn detection_frames_all_words_and_ignores_dust_and_the_table_edge() { - let (w, h) = (400, 200); - let gray = photo(w, h, |_, y| if y >= 185 { 30 } else { 225 }, &[[40, 95, 90, 5], [230, 92, 30, 5], [300, 20, 1, 1]], 30); - let r = detect_region(&gray, w, h).unwrap(); - assert!(r.x <= 40 && r.x + r.width >= 260, "{r:?}"); - assert!(r.y > 20 && r.y + r.height < 185, "{r:?}"); - } - - #[test] - fn detection_frames_the_signature_not_the_sheet_on_a_dark_table() { - let (w, h) = (400, 300); - let sheet = |x: usize, y: usize| if (60..340).contains(&x) && (50..250).contains(&y) { 220 } else { 60 }; - let gray = photo(w, h, sheet, &[[120, 150, 140, 4], [150, 135, 4, 20]], 30); - let r = detect_region(&gray, w, h).unwrap(); - assert!(r.x > 80 && r.x + r.width < 320, "{r:?}"); - assert!(r.y > 70 && r.y + r.height < 230, "{r:?}"); - } - - #[test] - fn rects_turn_with_the_photo_and_back() { - let rect = PhotoRect { x: 10, y: 20, width: 30, height: 40 }; - let turned = rect.turned(Turn::Clockwise, 100, 80); - assert_eq!(turned, PhotoRect { x: 20, y: 10, width: 40, height: 30 }); - assert_eq!(turned.turned(Turn::CounterClockwise, 80, 100), rect); - assert_eq!(PhotoRect { x: 500, y: 0, width: 0, height: 900 }.clamped(100, 50), PhotoRect { x: 99, y: 0, width: 1, height: 50 }); - assert_eq!(PhotoRect { x: 0, y: 0, width: u32::MAX, height: 1 }.turned(Turn::CounterClockwise, 10, 10).y, 0); - } - - #[test] - fn a_photo_is_scanned_into_a_trimmed_transparent_signature_in_its_ink_colour() { - let (w, h) = (600, 400); - let mut image = rgb(&photo(w, h, |x, _| (230 - 60 * x / w) as u8, &[], 0), w, h); - for (x, y, p) in image.enumerate_pixels_mut() { - if (150..450).contains(&x) && (200..206).contains(&y) || (200..206).contains(&x) && (170..206).contains(&y) { - *p = image::Rgb([30, 50, 140]); - } - } - let mut photo = SignaturePhoto::from_bytes(&jpeg(&image)).unwrap(); - let rect = photo.auto_frame(); - assert!(rect.x <= 150 && rect.x + rect.width >= 450 && rect.y <= 170 && rect.y + rect.height >= 206, "{rect:?}"); - let signature = photo.scan(rect, DEFAULT_SENSITIVITY, SCAN_MAX_SIDE).unwrap(); - let [sw, sh] = signature.size(); - assert!((300..340).contains(&sw) && (36..60).contains(&sh), "{sw}x{sh}"); - let pixels = signature.rgba().as_chunks::<4>().0; - assert_eq!(pixels[0][3], 0, "the margin is transparent"); - let solid: Vec<_> = pixels.iter().filter(|p| p[3] == 255).collect(); - assert!(!solid.is_empty()); - assert!(solid.iter().all(|p| p[2] > p[0] + 40), "blue ink stays blue: {:?}", solid[0]); - let quick = photo.scan(rect, DEFAULT_SENSITIVITY, PREVIEW_MAX_SIDE).unwrap(); - assert!(quick.size()[0] <= sw); - } - - #[test] - fn a_sideways_signature_is_turned_upright() { - let (w, h) = (400, 600); - let gray = photo(w, h, |_, _| 225, &[[200, 120, 5, 300], [185, 300, 25, 4]], 30); - let mut photo = SignaturePhoto::from_bytes(&jpeg(&rgb(&gray, w, h))).unwrap(); - let rect = photo.auto_frame(); - assert_eq!(photo.size(), [600, 400]); - assert!(rect.width > rect.height * 2, "{rect:?}"); - } - - #[test] - fn an_empty_frame_reports_that_no_signature_was_found() { - let photo = SignaturePhoto::from_bytes(&jpeg(&RgbImage::from_pixel(200, 100, image::Rgb([220, 220, 220])))).unwrap(); - let mut copy = photo.clone(); - assert_eq!(copy.auto_frame(), PhotoRect { x: 28, y: 35, width: 142, height: 30 }); - assert!(matches!(photo.scan(PhotoRect { x: 0, y: 0, width: 200, height: 100 }, 50, SCAN_MAX_SIDE), Err(ScanError::NoInk))); - } - - #[test] - fn large_photos_are_downscaled_and_invalid_ones_rejected() { - let photo = SignaturePhoto::from_bytes(&jpeg(&RgbImage::from_pixel(5000, 100, image::Rgb([200, 200, 200])))).unwrap(); - let [w, h] = photo.size(); - assert!((4000..=WORKING_MAX_SIDE).contains(&w) && (81..=82).contains(&h), "{w} x {h}"); - assert!(photo.thumbnail(512).width() <= 512); - assert!(matches!(SignaturePhoto::from_bytes(b"not a photo"), Err(ScanError::Format))); - assert!(matches!(SignaturePhoto::from_bytes(b"GIF89a\x02\0\x02\0"), Err(ScanError::Format))); - assert!(SignaturePhoto::from_bytes(b"\xff\xd8\xff\xe0truncated").is_err()); - assert!(matches!(SignaturePhoto::from_bytes(&vec![0; MAX_PHOTO_BYTES + 1]), Err(ScanError::Size))); - assert!(SignaturePhoto::read(std::io::repeat(0)).is_err()); - // A header claiming a huge image is rejected before decoding. - let mut png = Vec::new(); - png.extend_from_slice(b"\x89PNG\r\n\x1a\n\0\0\0\x0dIHDR"); - png.extend_from_slice(&20_000_u32.to_be_bytes()); - png.extend_from_slice(&20_000_u32.to_be_bytes()); - png.extend_from_slice(&[8, 2, 0, 0, 0, 0, 0, 0, 0]); - assert!(SignaturePhoto::from_bytes(&png).is_err()); - } - - #[test] - fn degenerate_buffers_do_not_panic() { - assert!(extract_ink(&[], 0, 0, opts()).bounds.is_none()); - assert!(extract_ink(&[0; 3], 2, 2, opts()).bounds.is_none()); - assert!(extract_ink(&[0], 1, 1, opts()).bounds.is_none()); - assert!(detect_region(&[255; 4], 2, 2).is_none()); - let mut empty: Vec = Vec::new(); - box_blur(&mut empty, 0, 0, 3); - assert!(square_filter(&[], 0, 0, 2, true).is_empty()); - let photo = SignaturePhoto::from_bytes(&jpeg(&RgbImage::from_pixel(3, 2, image::Rgb([10, 10, 10])))).unwrap(); - let _ = photo.scan(PhotoRect { x: 9, y: 9, width: 0, height: 0 }, 255, 0); - } -} +mod tests; diff --git a/crates/engine/src/signature_scan/tests.rs b/crates/engine/src/signature_scan/tests.rs new file mode 100644 index 0000000000..c13200b1f1 --- /dev/null +++ b/crates/engine/src/signature_scan/tests.rs @@ -0,0 +1,298 @@ +use super::*; + +/// A gray "photo": `paper(x, y)` brightness with dark ink rectangles `[x, y, w, h]`. +fn photo(width: usize, height: usize, paper: impl Fn(usize, usize) -> u8, ink: &[[usize; 4]], ink_value: u8) -> Vec { + let mut out = vec![0; width * height]; + for y in 0..height { + for x in 0..width { + let inside = ink.iter().any(|r| x >= r[0] && x < r[0] + r[2] && y >= r[1] && y < r[1] + r[3]); + out[y * width + x] = if inside { ink_value } else { paper(x, y) }; + } + } + out +} + +fn rgb(gray: &[u8], width: usize, height: usize) -> RgbImage { + RgbImage::from_fn(width as u32, height as u32, |x, y| { + let v = gray[y as usize * width + x as usize]; + image::Rgb([v, v, v]) + }) +} + +fn jpeg(image: &RgbImage) -> Vec { + let mut out = Cursor::new(Vec::new()); + image.write_to(&mut out, ImageFormat::Jpeg).unwrap(); + out.into_inner() +} + +/// Photos keep the image signature limits (#1124): a 4 MiB file, at most 32 megapixels and +/// 8192 pixels per side decoded, and a working copy of at most 4 megapixels. +#[test] +fn photos_keep_the_image_signature_limits() { + let png = |image: &RgbImage| { + let mut out = Cursor::new(Vec::new()); + image.write_to(&mut out, ImageFormat::Png).unwrap(); + out.into_inner() + }; + assert!(matches!(SignaturePhoto::from_bytes(&vec![0; MAX_PHOTO_BYTES + 1]), Err(ScanError::Size))); + assert!(SignaturePhoto::read(std::io::repeat(0)).is_err()); + assert!(matches!(SignaturePhoto::from_bytes(&png(&RgbImage::new(8193, 1))), Err(ScanError::Size))); + assert!(matches!(SignaturePhoto::from_bytes(b"GIF89a\x02\0\x02\0"), Err(ScanError::Format))); + let [w, h] = SignaturePhoto::from_bytes(&png(&RgbImage::from_pixel(8000, 4000, image::Rgb([220, 220, 215])))).unwrap().size(); + assert!(w <= WORKING_MAX_SIDE && u64::from(w) * u64::from(h) <= MAX_OUTPUT_PIXELS, "{w} x {h}"); + assert!((f64::from(w) / f64::from(h) - 2.0).abs() < 0.01, "the aspect ratio is kept: {w} x {h}"); +} + +fn region(r: [usize; 4]) -> Region { + Region { x: r[0], y: r[1], width: r[2], height: r[3] } +} + +fn opts() -> Options { + Options { sensitivity: DEFAULT_SENSITIVITY, despeckle: true, background_radius: None } +} + +#[test] +fn box_blur_averages_and_keeps_a_constant_signal() { + let mut values = vec![0.0, 0.0, 9.0, 0.0, 0.0]; + box_blur(&mut values, 5, 1, 1); + assert_eq!(values, vec![0.0, 3.0, 3.0, 3.0, 0.0]); + let mut flat = vec![7.0; 12]; + box_blur(&mut flat, 4, 3, 2); + assert!(flat.iter().all(|v| (v - 7.0).abs() < 1e-4)); +} + +#[test] +fn square_filters_match_a_naive_window_with_repeated_edges() { + let (w, h) = (13, 7); + let src: Vec = (0..w * h).map(|i| ((i * 37 + 11) % 251) as u8).collect(); + for radius in [1, 2, 5] { + for max in [true, false] { + let fast = square_filter(&src, w, h, radius, max); + for y in 0..h { + for x in 0..w { + let mut want = if max { 0 } else { 255 }; + for dy in -(radius as i64)..=radius as i64 { + for dx in -(radius as i64)..=radius as i64 { + let sx = (x as i64 + dx).clamp(0, w as i64 - 1) as usize; + let sy = (y as i64 + dy).clamp(0, h as i64 - 1) as usize; + let v = src[sy * w + sx]; + want = if max { want.max(v) } else { want.min(v) }; + } + } + assert_eq!(fast[y * w + x], want, "radius {radius} max {max} at {x},{y}"); + } + } + } + } +} + +#[test] +fn components_are_eight_connected() { + let mask = [true, false, false, false, false, true, false, true, false, false, false, false]; + let (labels, count) = label_components(&mask, 4, 3); + assert_eq!(count, 2); + assert_eq!(labels[0], labels[5]); + assert_ne!(labels[7], labels[0]); + assert_eq!(labels[1], 0); +} + +#[test] +fn sensitivity_lowers_the_threshold() { + assert!(threshold(0) > threshold(50)); + assert!(threshold(50) > threshold(100)); + assert_eq!(threshold(200), threshold(100)); +} + +#[test] +fn ink_survives_a_shadow_gradient_and_the_shadow_does_not() { + let (w, h) = (160, 80); + let stroke = [20, 30, 120, 6]; + let gray = photo(w, h, |x, _| (235 - 115 * x / (w - 1)) as u8, &[stroke], 30); + let ink = extract_ink(&gray, w, h, opts()); + assert_eq!(ink.alpha[10 * w + 150], 0); + assert_eq!(ink.alpha[33 * w + 30], 255); + assert_eq!(ink.alpha[33 * w + 130], 255); + assert_eq!(ink.bounds, Some(region(stroke))); +} + +#[test] +fn a_sharp_shadow_edge_is_not_ink() { + let (w, h) = (200, 120); + let stroke = [30, 52, 140, 4]; + let gray = photo(w, h, |_, y| if y < 60 { 230 } else { 120 }, &[stroke], 30); + let ink = extract_ink(&gray, w, h, opts()); + for y in 58..63 { + assert_eq!(ink.alpha[y * w + 10], 0); + assert_eq!(ink.alpha[y * w + 190], 0); + } + assert_eq!(ink.bounds, Some(region(stroke))); +} + +#[test] +fn a_table_at_the_edge_of_the_frame_is_not_ink() { + let (w, h) = (300, 120); + let stroke = [60, 60, 200, 4]; + let gray = photo(w, h, |x, y| if x < 12 || y < 10 { 70 } else { 225 }, &[stroke], 30); + let ink = extract_ink(&gray, w, h, opts()); + assert_eq!(ink.alpha[5 * w + 150], 0); + assert_eq!(ink.alpha[60 * w + 5], 0); + assert_eq!(ink.alpha[11 * w + 150], 0); + assert_eq!(ink.bounds, Some(region(stroke))); +} + +#[test] +fn thick_strokes_stay_solid_when_the_window_is_sized_on_the_photo() { + let (w, h) = (600, 200); + let stroke = [50, 90, 500, 18]; + let gray = photo(w, h, |_, _| 225, &[stroke], 30); + let middle = 99 * w + 300; + // Sized on the tight frame alone, the window is narrower than the stroke. + assert_eq!(extract_ink(&gray, w, h, opts()).alpha[middle], 0); + let ink = extract_ink(&gray, w, h, Options { background_radius: Some((4.0 * w as f32 * BACKGROUND_RADIUS_RATIO) as usize), ..opts() }); + assert_eq!(ink.bounds, Some(region(stroke))); + assert!((90..108).all(|y| ink.alpha[y * w + 300] == 255)); +} + +#[test] +fn faint_stroke_ends_touching_ink_are_kept_and_isolated_ones_are_not() { + let (w, h) = (200, 60); + let mut gray = photo(w, h, |_, _| 220, &[[20, 28, 80, 4]], 40); + for y in 28..32 { + for x in (100..180).chain(10..15) { + gray[y * w + x] = 175; + } + } + let ink = extract_ink(&gray, w, h, opts()); + assert!(ink.alpha[30 * w + 150] > 0); + assert_eq!(ink.alpha[30 * w + 12], 0); + assert_eq!(ink.bounds, Some(region([20, 28, 160, 4]))); +} + +#[test] +fn dust_is_removed_unless_despeckling_is_off() { + let (w, h) = (200, 100); + let gray = photo(w, h, |_, _| 230, &[[20, 40, 150, 5], [190, 5, 1, 1]], 20); + assert!(extract_ink(&gray, w, h, Options { despeckle: false, ..opts() }).alpha[5 * w + 190] > 0); + let ink = extract_ink(&gray, w, h, opts()); + assert_eq!(ink.alpha[5 * w + 190], 0); + assert_eq!(ink.bounds, Some(region([20, 40, 150, 5]))); +} + +#[test] +fn faint_strokes_need_a_higher_sensitivity() { + let (w, h) = (120, 60); + let gray = photo(w, h, |_, _| 220, &[[10, 25, 100, 4]], 165); + assert!(extract_ink(&gray, w, h, Options { sensitivity: 0, ..opts() }).bounds.is_none()); + assert!(extract_ink(&gray, w, h, Options { sensitivity: 100, ..opts() }).bounds.is_some()); +} + +#[test] +fn blank_paper_has_no_ink() { + let gray = photo(64, 64, |x, y| (200 + (x * 7 + y * 13) % 20) as u8, &[], 0); + assert!(extract_ink(&gray, 64, 64, opts()).bounds.is_none()); + assert!(detect_region(&gray, 64, 64).is_none()); +} + +#[test] +fn detection_frames_all_words_and_ignores_dust_and_the_table_edge() { + let (w, h) = (400, 200); + let gray = photo(w, h, |_, y| if y >= 185 { 30 } else { 225 }, &[[40, 95, 90, 5], [230, 92, 30, 5], [300, 20, 1, 1]], 30); + let r = detect_region(&gray, w, h).unwrap(); + assert!(r.x <= 40 && r.x + r.width >= 260, "{r:?}"); + assert!(r.y > 20 && r.y + r.height < 185, "{r:?}"); +} + +#[test] +fn detection_frames_the_signature_not_the_sheet_on_a_dark_table() { + let (w, h) = (400, 300); + let sheet = |x: usize, y: usize| if (60..340).contains(&x) && (50..250).contains(&y) { 220 } else { 60 }; + let gray = photo(w, h, sheet, &[[120, 150, 140, 4], [150, 135, 4, 20]], 30); + let r = detect_region(&gray, w, h).unwrap(); + assert!(r.x > 80 && r.x + r.width < 320, "{r:?}"); + assert!(r.y > 70 && r.y + r.height < 230, "{r:?}"); +} + +#[test] +fn rects_turn_with_the_photo_and_back() { + let rect = PhotoRect { x: 10, y: 20, width: 30, height: 40 }; + let turned = rect.turned(Turn::Clockwise, 100, 80); + assert_eq!(turned, PhotoRect { x: 20, y: 10, width: 40, height: 30 }); + assert_eq!(turned.turned(Turn::CounterClockwise, 80, 100), rect); + assert_eq!(PhotoRect { x: 500, y: 0, width: 0, height: 900 }.clamped(100, 50), PhotoRect { x: 99, y: 0, width: 1, height: 50 }); + assert_eq!(PhotoRect { x: 0, y: 0, width: u32::MAX, height: 1 }.turned(Turn::CounterClockwise, 10, 10).y, 0); +} + +#[test] +fn a_photo_is_scanned_into_a_trimmed_transparent_signature_in_its_ink_colour() { + let (w, h) = (600, 400); + let mut image = rgb(&photo(w, h, |x, _| (230 - 60 * x / w) as u8, &[], 0), w, h); + for (x, y, p) in image.enumerate_pixels_mut() { + if (150..450).contains(&x) && (200..206).contains(&y) || (200..206).contains(&x) && (170..206).contains(&y) { + *p = image::Rgb([30, 50, 140]); + } + } + let mut photo = SignaturePhoto::from_bytes(&jpeg(&image)).unwrap(); + let rect = photo.auto_frame(); + assert!(rect.x <= 150 && rect.x + rect.width >= 450 && rect.y <= 170 && rect.y + rect.height >= 206, "{rect:?}"); + let signature = photo.scan(rect, DEFAULT_SENSITIVITY, SCAN_MAX_SIDE).unwrap(); + let [sw, sh] = signature.size(); + assert!((300..340).contains(&sw) && (36..60).contains(&sh), "{sw}x{sh}"); + let pixels = signature.rgba().as_chunks::<4>().0; + assert_eq!(pixels[0][3], 0, "the margin is transparent"); + let solid: Vec<_> = pixels.iter().filter(|p| p[3] == 255).collect(); + assert!(!solid.is_empty()); + assert!(solid.iter().all(|p| p[2] > p[0] + 40), "blue ink stays blue: {:?}", solid[0]); + let quick = photo.scan(rect, DEFAULT_SENSITIVITY, PREVIEW_MAX_SIDE).unwrap(); + assert!(quick.size()[0] <= sw); +} + +#[test] +fn a_sideways_signature_is_turned_upright() { + let (w, h) = (400, 600); + let gray = photo(w, h, |_, _| 225, &[[200, 120, 5, 300], [185, 300, 25, 4]], 30); + let mut photo = SignaturePhoto::from_bytes(&jpeg(&rgb(&gray, w, h))).unwrap(); + let rect = photo.auto_frame(); + assert_eq!(photo.size(), [600, 400]); + assert!(rect.width > rect.height * 2, "{rect:?}"); +} + +#[test] +fn an_empty_frame_reports_that_no_signature_was_found() { + let photo = SignaturePhoto::from_bytes(&jpeg(&RgbImage::from_pixel(200, 100, image::Rgb([220, 220, 220])))).unwrap(); + let mut copy = photo.clone(); + assert_eq!(copy.auto_frame(), PhotoRect { x: 28, y: 35, width: 142, height: 30 }); + assert!(matches!(photo.scan(PhotoRect { x: 0, y: 0, width: 200, height: 100 }, 50, SCAN_MAX_SIDE), Err(ScanError::NoInk))); +} + +#[test] +fn large_photos_are_downscaled_and_invalid_ones_rejected() { + let photo = SignaturePhoto::from_bytes(&jpeg(&RgbImage::from_pixel(5000, 100, image::Rgb([200, 200, 200])))).unwrap(); + let [w, h] = photo.size(); + assert!((4000..=WORKING_MAX_SIDE).contains(&w) && (81..=82).contains(&h), "{w} x {h}"); + assert!(photo.thumbnail(512).width() <= 512); + assert!(matches!(SignaturePhoto::from_bytes(b"not a photo"), Err(ScanError::Format))); + assert!(matches!(SignaturePhoto::from_bytes(b"GIF89a\x02\0\x02\0"), Err(ScanError::Format))); + assert!(SignaturePhoto::from_bytes(b"\xff\xd8\xff\xe0truncated").is_err()); + assert!(matches!(SignaturePhoto::from_bytes(&vec![0; MAX_PHOTO_BYTES + 1]), Err(ScanError::Size))); + assert!(SignaturePhoto::read(std::io::repeat(0)).is_err()); + // A header claiming a huge image is rejected before decoding. + let mut png = Vec::new(); + png.extend_from_slice(b"\x89PNG\r\n\x1a\n\0\0\0\x0dIHDR"); + png.extend_from_slice(&20_000_u32.to_be_bytes()); + png.extend_from_slice(&20_000_u32.to_be_bytes()); + png.extend_from_slice(&[8, 2, 0, 0, 0, 0, 0, 0, 0]); + assert!(SignaturePhoto::from_bytes(&png).is_err()); +} + +#[test] +fn degenerate_buffers_do_not_panic() { + assert!(extract_ink(&[], 0, 0, opts()).bounds.is_none()); + assert!(extract_ink(&[0; 3], 2, 2, opts()).bounds.is_none()); + assert!(extract_ink(&[0], 1, 1, opts()).bounds.is_none()); + assert!(detect_region(&[255; 4], 2, 2).is_none()); + let mut empty: Vec = Vec::new(); + box_blur(&mut empty, 0, 0, 3); + assert!(square_filter(&[], 0, 0, 2, true).is_empty()); + let photo = SignaturePhoto::from_bytes(&jpeg(&RgbImage::from_pixel(3, 2, image::Rgb([10, 10, 10])))).unwrap(); + let _ = photo.scan(PhotoRect { x: 9, y: 9, width: 0, height: 0 }, 255, 0); +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index c622af21b4..14a61b2633 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -3068,7 +3068,12 @@ fn secondary_indexes_load_once_and_follow_edit_scopes() { assert!(doc.links().is_empty()); assert_eq!(secondary_scan_counts(), (1, 1), "repeated reads reuse the link list"); } + metadata_and_comment_edits_keep_the_indexes(&mut session, id); + link_edits_rebuild_only_the_link_list(&mut session, id); + content_and_marker_edits_rebuild_the_markers(&mut session, id); +} +fn metadata_and_comment_edits_keep_the_indexes(session: &mut Session, id: DocId) { session.apply(id, Edit::SetInfo { key: "Title".into(), value: "Changed".into() }).unwrap(); assert!(session.get(id).unwrap().links().is_empty()); assert!(session.get(id).unwrap().marks().is_empty()); @@ -3077,7 +3082,9 @@ fn secondary_indexes_load_once_and_follow_edit_scopes() { session.apply(id, rect_comment(0, [20.0, 20.0, 60.0, 60.0])).unwrap(); assert!(session.get(id).unwrap().links().is_empty()); assert_eq!(secondary_scan_counts(), (1, 2), "comment edits invalidate the annotation-position link index"); +} +fn link_edits_rebuild_only_the_link_list(session: &mut Session, id: DocId) { session .apply( id, @@ -3101,7 +3108,9 @@ fn secondary_indexes_load_once_and_follow_edit_scopes() { session.apply(id, Edit::DeleteAnnotation { page: 0, index: 0 }).unwrap(); assert_eq!(session.get(id).unwrap().links()[0].index, 0, "deleting an earlier annotation shifts link indexes"); assert_eq!(secondary_scan_counts(), (1, 6), "comment deletion rebuilds annotation-position indexes"); +} +fn content_and_marker_edits_rebuild_the_markers(session: &mut Session, id: DocId) { session .apply(id, Edit::AddText { page: 0, text: AddedText { rect: [20.0, 250.0, 100.0, 280.0], text: "Added".into(), ..Default::default() } }) .unwrap(); @@ -3410,6 +3419,70 @@ fn pages_export_as_png_jpeg_and_tiff() { assert_eq!((compression, predictor), (5, 1), "LZW, without a predictor"); } +/// Export ▸ Image draws several pages at once: each file is the one exporting that page alone +/// writes, handed over in the order asked for, whatever the number of pages drawn at once. +#[test] +fn pages_exported_at_once_are_the_files_each_page_exports_alone_in_order() { + use crate::export::{Exporter, ImageFormat}; + let (s, id) = session_with(12); + let pages = [3, 0, 11, 5, 5, 7, 1, 2, 10, 9, 8, 6, 4]; + for format in [ImageFormat::Png, ImageFormat::Jpeg { quality: 85 }, ImageFormat::Tiff] { + let alone: Vec<(usize, Vec)> = pages.iter().map(|&p| (p, Exporter::new(s.get(id).unwrap()).image(p, 96.0, format).unwrap())).collect(); + for workers in [1, 2, 4, 8] { + let mut got = Vec::new(); + let mut ex = Exporter::new(s.get(id).unwrap()); + ex.images_on(workers, &pages, 96.0, format, |p, img| { + got.push((p, img)); + Ok(()) + }) + .unwrap(); + assert!(got == alone, "{} with {workers} workers", format.label()); + } + let mut got = Vec::new(); + Exporter::new(s.get(id).unwrap()) + .images(&pages, 96.0, format, |p, img| { + got.push((p, img)); + Ok(()) + }) + .unwrap(); + assert!(got == alone, "{}", format.label()); + } +} + +/// A page that can't be exported, or a file that can't be written, stops the export there with +/// its error: every page before it is handed over, none after it. +#[test] +fn exporting_pages_at_once_stops_at_the_first_error() { + use crate::export::{Exporter, ImageFormat}; + let (s, id) = session_with(6); + for workers in [1, 4] { + let mut ex = Exporter::new(s.get(id).unwrap()); + let mut got = Vec::new(); + let result = ex.images_on(workers, &[0, 1, 99, 2, 3], 72.0, ImageFormat::Png, |p, _| { + got.push(p); + Ok(()) + }); + assert_eq!(result, Err("page 100 does not exist".to_string()), "{workers} workers"); + assert_eq!(got, [0, 1], "{workers} workers"); + + let mut got = Vec::new(); + let result = ex.images_on(workers, &[0, 1, 2, 3, 4, 5], 72.0, ImageFormat::Png, |p, _| { + got.push(p); + if p == 2 { Err("disk full".to_string()) } else { Ok(()) } + }); + assert_eq!(result, Err("disk full".to_string()), "{workers} workers"); + assert_eq!(got, [0, 1, 2], "{workers} workers"); + // The exporter is still usable afterwards. + assert!(ex.image(5, 72.0, ImageFormat::Png).is_ok()); + } + // A panicking sink stops the workers rather than leaving them waiting for it. + let mut ex = Exporter::new(s.get(id).unwrap()); + let panicked = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + ex.images_on(4, &[0, 1, 2, 3, 4, 5], 72.0, ImageFormat::Png, |p, _| if p == 1 { panic!("sink") } else { Ok(()) }) + })); + assert!(panicked.is_err()); +} + /// A TIFF's resolution unit (2 = inch) and its horizontal and vertical resolution. fn tiff_resolution(bytes: &[u8]) -> (u32, f64, f64) { use tiff::{decoder::ifd::Value, tags::Tag}; @@ -8042,7 +8115,12 @@ fn undo_and_redo_roll_back_on_a_refresh_panic_or_error() { // Redo now succeeds cleanly. assert_eq!(s.redo(id).unwrap().as_str(), "Rotate page"); assert_eq!(s.get(id).unwrap().info.pages[0].rotation, 90); + rolls_back_metadata_scope(&mut s, id); + rolls_back_protection_keys(&mut s, id); +} +/// Undo/redo of a metadata edit rolled back after a refresh panic. +fn rolls_back_metadata_scope(s: &mut Session, id: DocId) { // 5. Metadata scope: a panic after `doc.info.title` was written rolls back `info.title` and keeps `edit_notices`. s.apply(id, Edit::SetInfo { key: "Title".into(), value: "Draft".into() }).unwrap(); let notice = TextNotice { field: None, page: 0, ch: '☺', redrawn_elsewhere: false }; @@ -8060,7 +8138,10 @@ fn undo_and_redo_roll_back_on_a_refresh_panic_or_error() { assert_eq!(s.get(id).unwrap().info.title, None); assert_eq!(s.redo(id).unwrap().as_str(), "Change Title"); assert_eq!(s.get(id).unwrap().info.title.as_deref(), Some("Draft")); +} +/// Undo/redo of a protection edit rolled back after a refresh panic. +fn rolls_back_protection_keys(s: &mut Session, id: DocId) { // 6. Protection keys: undo/redo rollback restores `editor.keys` and `doc.password` before re-inspecting. let protect = Protection { open_password: Some("u".into()), permissions_password: Some("o".into()), ..Protection::default() }; s.apply(id, Edit::Protect(protect)).unwrap(); @@ -8656,69 +8737,69 @@ fn pdf_with_annots(annots: &[&[u8]]) -> Vec { bytes } -/// ISO 32000-2 Table 224 (`/NeedAppearances`) and Table 192 (`/MK /CA`): a form that leaves its -/// appearances to the viewer shows its check boxes and radio buttons drawn from `/MK`, over the -/// filled squares the file carries: a checked box its check mark (ZapfDingbats `4`), an unchecked -/// one its empty border, a radio button its dot (`l`), on screen and printed as an image. Without -/// `/NeedAppearances` the file's own squares show. Saving writes the file as it was. -#[test] -fn need_appearances_buttons_are_displayed_from_their_captions() { - let pdf = |need: bool| { - let flag = if need { "/NeedAppearances true " } else { "" }; - format!( - "%PDF-1.7\n\ - 1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >> endobj\n\ - 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\ - 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 400 200] /Annots [6 0 R 7 0 R 8 0 R] >> endobj\n\ - 4 0 obj << /Fields [6 0 R 7 0 R 8 0 R] {flag}/DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> endobj\n\ - 5 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> endobj\n\ - 6 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /T (on) /V /Yes /AS /Yes /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (4) >> /Rect [20 100 60 140] /F 4 /P 3 0 R /AP << /N << /Yes 9 0 R /Off 9 0 R >> >> >> endobj\n\ - 7 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /T (off) /V /Off /AS /Off /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (4) >> /Rect [100 100 140 140] /F 4 /P 3 0 R /AP << /N << /Yes 9 0 R /Off 9 0 R >> >> >> endobj\n\ - 8 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /Ff 32768 /T (radio) /V /B /AS /B /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (l) >> /Rect [180 100 220 140] /F 4 /P 3 0 R /AP << /N << /B 9 0 R /Off 9 0 R >> >> >> endobj\n\ - 9 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 40 40] /Length 18 >> stream\n0 g 4 4 32 32 re f\nendstream endobj\n\ - trailer << /Root 1 0 R >>\n%%EOF" - ) - .into_bytes() - }; - let render = |bytes: Arc>| { - let mut r = pdfcraft_render::PageRenderer::new(bytes, pdfcraft_render::RenderConfig::default()); - r.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() }) - }; - // Dark pixels of the page (drawn at scale 1, 400 x 200) in the user-space box x0..x1, y0..y1. - let ink = |page: &pdfcraft_render::RenderedPage, (x0, y0, x1, y1): (u32, u32, u32, u32)| { - let (px, _) = page.rgba.as_chunks::<4>(); - (200 - y1..200 - y0) - .flat_map(|y| (x0..x1).map(move |x| (x, y))) - .filter(|&(x, y)| px.get((y * page.width + x) as usize).is_some_and(|p| p[0] < 128 && p[1] < 128 && p[2] < 128)) - .count() - }; - // In a widget whose lower-left corner is (x, y): its whole inside, the top-left of its inside - // (where a check mark leaves the box empty), and the lower-left corner (outside a dot). - let inside = |x: u32, y: u32| (x + 6, y + 6, x + 34, y + 34); - let top_left = |x: u32, y: u32| (x + 6, y + 26, x + 14, y + 34); - let corner = |x: u32, y: u32| (x + 5, y + 5, x + 9, y + 9); - let mut s = Session::new(); - let id = s.open("form.pdf", None, Arc::new(pdf(true)), None).unwrap(); - let shown = render(s.get(id).unwrap().display.clone()); - let raster = pdfcraft_print::Raster { dpi: 72.0, grayscale: false }; - let pictures = crate::print_raster::rasterize(s.get(id).unwrap(), &[0], &raster, pdfcraft_print::Content::Document).unwrap(); - let printed = render(Arc::new(pictures)); - for (page, how) in [(&shown, "shown"), (&printed, "printed")] { - let (check, check_corner) = (ink(page, inside(20, 100)), ink(page, top_left(20, 100))); - assert!(check > 100 && check < 600, "{how}: a check mark, not the 28 x 28 square: {check}"); - assert_eq!(check_corner, 0, "{how}: the check mark leaves the top left empty"); - assert_eq!(ink(page, inside(100, 100)), 0, "{how}: the unchecked box is empty"); - assert!(ink(page, (100, 100, 140, 102)) > 30, "{how}: and keeps its border"); - let dot = ink(page, (188, 108, 212, 132)); - assert!(dot > 300 && ink(page, corner(180, 100)) == 0, "{how}: the radio button's dot: {dot}"); - } - let saved = s.save_bytes(id).unwrap(); - assert!(saved.windows(18).any(|w| w == b"0 g 4 4 32 32 re f"), "the file keeps its own appearances"); - - // Without /NeedAppearances the file's filled squares are shown. - let id = s.open("plain.pdf", None, Arc::new(pdf(false)), None).unwrap(); - let shown = render(s.get(id).unwrap().display.clone()); - for x in [20, 100, 180] { - assert!(ink(&shown, inside(x, 100)) > 700, "the file's square at {x}"); - } -} +/// ISO 32000-2 Table 224 (`/NeedAppearances`) and Table 192 (`/MK /CA`): a form that leaves its +/// appearances to the viewer shows its check boxes and radio buttons drawn from `/MK`, over the +/// filled squares the file carries: a checked box its check mark (ZapfDingbats `4`), an unchecked +/// one its empty border, a radio button its dot (`l`), on screen and printed as an image. Without +/// `/NeedAppearances` the file's own squares show. Saving writes the file as it was. +#[test] +fn need_appearances_buttons_are_displayed_from_their_captions() { + let pdf = |need: bool| { + let flag = if need { "/NeedAppearances true " } else { "" }; + format!( + "%PDF-1.7\n\ + 1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >> endobj\n\ + 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\ + 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 400 200] /Annots [6 0 R 7 0 R 8 0 R] >> endobj\n\ + 4 0 obj << /Fields [6 0 R 7 0 R 8 0 R] {flag}/DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> endobj\n\ + 5 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> endobj\n\ + 6 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /T (on) /V /Yes /AS /Yes /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (4) >> /Rect [20 100 60 140] /F 4 /P 3 0 R /AP << /N << /Yes 9 0 R /Off 9 0 R >> >> >> endobj\n\ + 7 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /T (off) /V /Off /AS /Off /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (4) >> /Rect [100 100 140 140] /F 4 /P 3 0 R /AP << /N << /Yes 9 0 R /Off 9 0 R >> >> >> endobj\n\ + 8 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /Ff 32768 /T (radio) /V /B /AS /B /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (l) >> /Rect [180 100 220 140] /F 4 /P 3 0 R /AP << /N << /B 9 0 R /Off 9 0 R >> >> >> endobj\n\ + 9 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 40 40] /Length 18 >> stream\n0 g 4 4 32 32 re f\nendstream endobj\n\ + trailer << /Root 1 0 R >>\n%%EOF" + ) + .into_bytes() + }; + let render = |bytes: Arc>| { + let mut r = pdfcraft_render::PageRenderer::new(bytes, pdfcraft_render::RenderConfig::default()); + r.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() }) + }; + // Dark pixels of the page (drawn at scale 1, 400 x 200) in the user-space box x0..x1, y0..y1. + let ink = |page: &pdfcraft_render::RenderedPage, (x0, y0, x1, y1): (u32, u32, u32, u32)| { + let (px, _) = page.rgba.as_chunks::<4>(); + (200 - y1..200 - y0) + .flat_map(|y| (x0..x1).map(move |x| (x, y))) + .filter(|&(x, y)| px.get((y * page.width + x) as usize).is_some_and(|p| p[0] < 128 && p[1] < 128 && p[2] < 128)) + .count() + }; + // In a widget whose lower-left corner is (x, y): its whole inside, the top-left of its inside + // (where a check mark leaves the box empty), and the lower-left corner (outside a dot). + let inside = |x: u32, y: u32| (x + 6, y + 6, x + 34, y + 34); + let top_left = |x: u32, y: u32| (x + 6, y + 26, x + 14, y + 34); + let corner = |x: u32, y: u32| (x + 5, y + 5, x + 9, y + 9); + let mut s = Session::new(); + let id = s.open("form.pdf", None, Arc::new(pdf(true)), None).unwrap(); + let shown = render(s.get(id).unwrap().display.clone()); + let raster = pdfcraft_print::Raster { dpi: 72.0, grayscale: false }; + let pictures = crate::print_raster::rasterize(s.get(id).unwrap(), &[0], &raster, pdfcraft_print::Content::Document).unwrap(); + let printed = render(Arc::new(pictures)); + for (page, how) in [(&shown, "shown"), (&printed, "printed")] { + let (check, check_corner) = (ink(page, inside(20, 100)), ink(page, top_left(20, 100))); + assert!(check > 100 && check < 600, "{how}: a check mark, not the 28 x 28 square: {check}"); + assert_eq!(check_corner, 0, "{how}: the check mark leaves the top left empty"); + assert_eq!(ink(page, inside(100, 100)), 0, "{how}: the unchecked box is empty"); + assert!(ink(page, (100, 100, 140, 102)) > 30, "{how}: and keeps its border"); + let dot = ink(page, (188, 108, 212, 132)); + assert!(dot > 300 && ink(page, corner(180, 100)) == 0, "{how}: the radio button's dot: {dot}"); + } + let saved = s.save_bytes(id).unwrap(); + assert!(saved.windows(18).any(|w| w == b"0 g 4 4 32 32 re f"), "the file keeps its own appearances"); + + // Without /NeedAppearances the file's filled squares are shown. + let id = s.open("plain.pdf", None, Arc::new(pdf(false)), None).unwrap(); + let shown = render(s.get(id).unwrap().display.clone()); + for x in [20, 100, 180] { + assert!(ink(&shown, inside(x, 100)) > 700, "the file's square at {x}"); + } +} diff --git a/crates/export/src/as_docx.rs b/crates/export/src/as_docx.rs new file mode 100644 index 0000000000..ab7451dc0e --- /dev/null +++ b/crates/export/src/as_docx.rs @@ -0,0 +1,364 @@ +use super::Zip; +use super::as_html::esc; +use super::layout::{Image, Item, Page, Table, body_size, items}; + +/// A colour's 8-bit sRGB components (NaN and out-of-range values clamp; NaN reads as 0). +pub(crate) fn rgb8(c: [f64; 3]) -> [u8; 3] { + // The value is clamped to 0..=255 before the cast, so it cannot truncate. + c.map(|v| if v.is_finite() { (v.clamp(0.0, 1.0) * 255.0).round() as u8 } else { 0 }) +} + +/// The colour as `RRGGBB`, or `None` for black (the default every format already uses). +pub(crate) fn hex(c: [f64; 3]) -> Option { + let [r, g, b] = rgb8(c); + (r | g | b != 0).then(|| format!("{r:02X}{g:02X}{b:02X}")) +} + +/// A formatted text run (shared by paragraphs and table cells). +fn run_xml(text: &str, size: f64, bold: bool, italic: bool, color: [f64; 3]) -> String { + let mut rpr = String::new(); + if bold { + rpr.push_str(""); + } + if italic { + rpr.push_str(""); + } + if let Some(h) = hex(color) { + rpr.push_str(&format!("")); + } + let half_points = if size.is_finite() { (size * 2.0).round().clamp(2.0, 3276.0) as i64 } else { 24 }; + rpr.push_str(&format!("")); + format!("{rpr}{}", esc(text)) +} + +/// One Word table: explicit absent borders (alignment alone cannot prove PDF rules), a grid sized +/// from the detected column edges, `gridSpan` for spanning cells, empty cells for holes. +pub(crate) fn docx_table(t: &Table) -> String { + let mut edges = t.cols.clone(); + edges.push(t.rect[2]); + let widths: Vec = edges.windows(2).map(|w| ((w[1] - w[0]).max(20.0) * 20.0).round().clamp(60.0, 31680.0) as i64).collect(); + let mut s = String::from(""); + for edge in ["top", "left", "bottom", "right", "insideH", "insideV"] { + s.push_str(&format!("")); + } + s.push_str(""); + for w in &widths { + s.push_str(&format!("")); + } + s.push_str(""); + for row in &t.rows { + s.push_str(""); + let mut column = 0usize; + for c in row { + let span = if c.span > 1 { format!("", c.span) } else { String::new() }; + let w: i64 = widths.iter().skip(column).take(c.span).sum(); + s.push_str(&format!( + "{span}{}", + run_xml(&c.text, c.size, c.bold, c.italic, c.color) + )); + column = column.saturating_add(c.span); + } + // Pad the grid so every row covers all columns (Word rejects short rows). + for _ in column..t.cols.len() { + s.push_str(""); + } + s.push_str(""); + } + s.push_str(""); + s +} + +/// Pages with rotated text, or text that actually overlaps an image or an oversized +/// (watermark-like) text block, cannot be put in paragraph flow without changing their layout. +/// Keep those pages as editable, absolutely positioned text boxes. Every other page, including +/// one with a logo or a big title that sits clear of the text, flows as paragraphs and tables. +fn positioned(page: &Page) -> bool { + if page.blocks.iter().any(|b| b.rotation.abs() > 0.1) { + return true; + } + let body = body_size(std::slice::from_ref(page)).max(1.0); + let overlays = page + .images + .iter() + .map(|im| (im.rect, None)) + .chain(page.blocks.iter().enumerate().filter(|(_, b)| b.size >= 2.0 * body).map(|(i, b)| (b.rect, Some(i)))); + // Bound the pairwise test on hostile pages (many overlays times many blocks): past the + // budget, keep the default flowing layout. + let mut budget: usize = 1_000_000; + for (r, own) in overlays { + for (i, b) in page.blocks.iter().enumerate() { + if budget == 0 { + return false; + } + budget -= 1; + if Some(i) != own && overlaps(r, b.rect) { + return true; + } + } + } + false +} + +/// Whether two [x0, y0, x1, y1] boxes share more than a hairline (0.5 pt each way); boxes that +/// only touch, or hostile non-finite coordinates, do not count. +fn overlaps(a: [f64; 4], b: [f64; 4]) -> bool { + let w = a[2].min(b[2]) - a[0].max(b[0]); + let h = a[3].min(b[3]) - a[1].max(b[1]); + w.is_finite() && h.is_finite() && w > 0.5 && h > 0.5 +} + +/// Bound every value written into a shape's style, including hostile PDF coordinates. +fn layout_number(n: f64) -> f64 { + if n.is_finite() { n.clamp(-1584.0, 1584.0) } else { 0.0 } +} + +/// Most images a Word export embeds, and most image bytes (the zip writer has 32-bit sizes and +/// a 16-bit entry count; a hostile PDF must not be able to overflow them). +pub(crate) const MAX_MEDIA: usize = 1000; + +const MAX_MEDIA_BYTES: usize = 512 << 20; + +/// Whether `im` can still be embedded: it has data, and the package stays within its limits. +fn room_for(media: &[(String, &Image)], im: &Image) -> bool { + !im.bytes.is_empty() + && media.len() < MAX_MEDIA + && media.iter().map(|(_, m)| m.bytes.len()).fold(im.bytes.len(), usize::saturating_add) <= MAX_MEDIA_BYTES +} + +/// Points as EMU (12700 per point); non-finite values are 0. +fn emu(pt: f64) -> i64 { + if pt.is_finite() { (pt * 12700.0).round() as i64 } else { 0 } +} + +/// The `a:graphic` of one picture (DrawingML), rotated by `rotation` degrees when given. +fn picture_xml(n: usize, name: &str, (cx, cy): (i64, i64), rotation: Option) -> String { + let rot = rotation.map_or(String::new(), |r| format!(" rot=\"{}\"", (r * 60000.0).round() as i64)); + format!( + "\ +\ +\ +\ +\ +" + ) +} + +fn positioned_page<'a>(page: &'a Page, media: &mut Vec<(String, &'a Image)>, shape_id: &mut usize) -> String { + // One 1 pt paragraph anchors the whole page. Shapes never reserve body space, so even a + // page-sized watermark cannot push the invoice footer onto another page. + let mut s = String::from(""); + let body = body_size(std::slice::from_ref(page)).max(1.0); + for b in &page.blocks { + *shape_id = shape_id.saturating_add(1); + let rotation = layout_number(-b.rotation).rem_euclid(360.0); + let (sin, cos) = rotation.to_radians().sin_cos(); + let (bw, bh) = (layout_number(b.rect[2] - b.rect[0]).max(1.0), layout_number(b.rect[3] - b.rect[1]).max(1.0)); + let h = if b.rotation.abs() > 0.1 { layout_number(b.size).max(1.0) } else { bh }; + // Recover the unrotated width from the axis-aligned box. Use the larger axis so + // quarter turns and 45-degree watermarks do not divide by (nearly) zero. + let w = if b.rotation.abs() <= 0.1 { + bw + } else if cos.abs() >= sin.abs() { + (bw - h * sin.abs()) / cos.abs() + } else { + (bh - h * cos.abs()) / sin.abs() + }; + let w = layout_number(w).max(1.0); + let (x, y) = (layout_number(b.rect[0] + (bw - w) / 2.0), layout_number(page.height - b.rect[3] + (bh - h) / 2.0)); + let z = if b.size >= 2.0 * body { -2 } else { 1 }; + let style = format!( + "position:absolute;margin-left:{x:.3}pt;margin-top:{y:.3}pt;width:{w:.3}pt;height:{h:.3}pt;rotation:{rotation:.3};z-index:{z};mso-position-horizontal-relative:page;mso-position-vertical-relative:page" + ); + s.push_str(&format!( + "{}", + shape_id, run_xml(&b.text, b.size, b.bold, b.italic, b.color) + )); + } + for im in &page.images { + if !room_for(media, im) { + continue; + } + *shape_id = shape_id.saturating_add(1); + let n = media.len().saturating_add(1); + let (w, h) = (layout_number(im.size[0]).max(1.0), layout_number(im.size[1]).max(1.0)); + let (x, y) = (layout_number((im.rect[0] + im.rect[2] - w) / 2.0), layout_number(page.height - (im.rect[1] + im.rect[3] + h) / 2.0)); + let rotation = layout_number(-im.rotation).rem_euclid(360.0); + let name = format!("image{n}.{}", im.ext); + let pic = picture_xml(n, &name, (emu(w), emu(h)), Some(rotation)); + // A DrawingML anchor, the way Word itself stores a picture placed behind text: a VML + // rectangle with an image does not show the picture. + s.push_str(&format!( + "\ +{}{}\ +", + n.saturating_add(1), + emu(x), + emu(y), + emu(w), + emu(h), + )); + s.push_str(&pic); + s.push_str(""); + media.push((name, im)); + } + s.push_str(""); + s +} + +fn word_page_size(page: &Page) -> (i64, i64) { + let twips = |pt: f64| if pt.is_finite() { (pt * 20.0).round().clamp(144.0, 31680.0) as i64 } else { 12240 }; + (twips(page.width), twips(page.height)) +} + +fn section(page: &Page) -> String { + let (pw, ph) = word_page_size(page); + let (mx, my) = ((pw / 8).min(1440), (ph / 8).min(1440)); + format!( + "", + my / 2, + my / 2 + ) +} + +/// The page colour of a Word document: Word has one background for the whole document, so it +/// is written only when every page has the same, non-white background (#2992). +fn word_background(pages: &[Page]) -> Option { + let first = rgb8(pages.first()?.background?); + let same = pages.iter().all(|p| p.background.map(rgb8) == Some(first)); + (same && first != [255, 255, 255]).then(|| format!("{:02X}{:02X}{:02X}", first[0], first[1], first[2])) +} + +/// A Word document (.docx, Office Open XML). Ordinary pages flow as paragraphs and tables; +/// pages with rotated text, or text that overlaps artwork, use floating shapes to retain their layout. +pub fn docx(pages: &[Page], title: &str) -> Vec { + let mut body = String::new(); + let mut media: Vec<(String, &Image)> = Vec::new(); + // Word needs a paragraph between adjacent tables and after the last one in the body. + let mut after_table = false; + let mut shape_id = 0usize; + for (page_index, page) in pages.iter().enumerate() { + let (pw, _) = word_page_size(page); + let text_w = (pw - 2 * (pw / 8).min(1440)) as f64 / 20.0; + if positioned(page) { + body.push_str(&positioned_page(page, &mut media, &mut shape_id)); + after_table = false; + } else { + for it in items(std::slice::from_ref(page)) { + match &it { + Item::Para(runs, lvl) => { + let style = match lvl { + 1 => "", + 2 => "", + _ => "", + }; + body.push_str(""); + body.push_str(style); + for r in runs { + body.push_str(&run_xml(&r.text, r.block.size, r.block.bold, r.block.italic, r.block.color)); + } + body.push_str(""); + } + Item::Table(t) => { + if after_table { + body.push_str(""); + } + body.push_str(&docx_table(t)); + } + Item::Img(im) => { + if room_for(&media, im) { + let n = media.len() + 1; + let name = format!("image{n}.{}", im.ext); + // Size on the page, at most the text width. + let (w, h) = ((im.rect[2] - im.rect[0]).max(1.0), (im.rect[3] - im.rect[1]).max(1.0)); + let k = (text_w / w).min(1.0); + let (cx, cy) = (emu(w * k), emu(h * k)); + body.push_str(&format!("")); + body.push_str(&picture_xml(n, &name, (cx, cy), None)); + body.push_str(""); + media.push((name, im)); + } + } + Item::PageBreak => body.push_str(""), + } + after_table = matches!(it, Item::Table(_)); + } + } + // Section breaks carry this source page's size and create exactly one next page. + // The last section must be a direct child of the body (OOXML 17.6.17). + if page_index + 1 < pages.len() { + body.push_str(&format!( + "{}", + section(page) + )); + } else { + if after_table || page.blocks.is_empty() && page.images.is_empty() { + body.push_str(""); + } + body.push_str(§ion(page)); + } + after_table = false; + } + // Word shows a document background only when its settings say so (`settings.xml` below). + let page_colour = word_background(pages); + let background = page_colour.as_deref().map(|c| format!("")).unwrap_or_default(); + let doc = format!( + "\n\ +{background}{body}\ +" + ); + let mut types = String::from( + "\n\ +\ +\ +\ +\ +", + ); + if page_colour.is_some() { + types.push_str( + "", + ); + } + types.push_str(""); + let rels = "\n\ +\ +"; + let mut doc_rels = String::from( + "\n\ +", + ); + for (i, (name, _)) in media.iter().enumerate() { + doc_rels.push_str(&format!( + "", + i + 1 + )); + } + if page_colour.is_some() { + doc_rels.push_str(""); + } + doc_rels.push_str(""); + let styles = "\n\ +\ +\ +\ +"; + let core = format!( + "\n{}", + esc(title) + ); + let mut z = Zip::default(); + z.add("[Content_Types].xml", types.as_bytes(), true); + z.add("_rels/.rels", rels.as_bytes(), true); + z.add("docProps/core.xml", core.as_bytes(), true); + z.add("word/document.xml", doc.as_bytes(), true); + z.add("word/styles.xml", styles.as_bytes(), true); + z.add("word/_rels/document.xml.rels", doc_rels.as_bytes(), true); + if page_colour.is_some() { + z.add("word/settings.xml", b"\n", true); + } + for (name, im) in &media { + z.add(&format!("word/media/{name}"), &im.bytes, false); + } + z.finish() +} diff --git a/crates/export/src/as_html.rs b/crates/export/src/as_html.rs new file mode 100644 index 0000000000..7aeea595a8 --- /dev/null +++ b/crates/export/src/as_html.rs @@ -0,0 +1,103 @@ +use super::as_docx::{hex, rgb8}; +use super::layout::{Item, Page, items}; + +/// Whether a character may appear in the output. XML 1.0 forbids the C0 controls other than tab, +/// line feed and carriage return, and U+FFFE/U+FFFF. Text extracted from PDFs often holds them +/// (fonts without a usable `/ToUnicode`), and Word refuses a whole document over one (#72). +pub(crate) fn allowed(c: char) -> bool { + matches!(c, '\t' | '\n' | '\r') || !(c.is_ascii_control() || c == '\u{FFFE}' || c == '\u{FFFF}') +} + +pub(crate) fn esc(s: &str) -> String { + let mut o = String::with_capacity(s.len()); + for c in s.chars().filter(|c| allowed(*c)) { + match c { + '&' => o.push_str("&"), + '<' => o.push_str("<"), + '>' => o.push_str(">"), + '"' => o.push_str("""), + c => o.push(c), + } + } + o +} + +pub(crate) fn base64(data: &[u8]) -> String { + const A: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut s = String::with_capacity(data.len().div_ceil(3) * 4); + for c in data.chunks(3) { + let n = (c[0] as u32) << 16 | (*c.get(1).unwrap_or(&0) as u32) << 8 | *c.get(2).unwrap_or(&0) as u32; + s.push(A[(n >> 18) as usize & 63] as char); + s.push(A[(n >> 12) as usize & 63] as char); + s.push(if c.len() > 1 { A[(n >> 6) as usize & 63] as char } else { '=' }); + s.push(if c.len() > 2 { A[n as usize & 63] as char } else { '=' }); + } + s +} + +/// One HTML file: headings and paragraphs, tables as real `` elements, images inline, a +/// rule between pages. +pub fn html(pages: &[Page], title: &str) -> String { + let mut s = format!( + "\n\n\n\n{}\n\n\n\n", + esc(title) + ); + for it in items(pages) { + match it { + Item::Para(runs, lvl) => { + let Some(first) = runs.first().map(|r| r.block) else { continue }; + let mut t = String::new(); + for r in &runs { + let mut run = esc(&r.text); + if r.block.italic { + run = format!("{run}"); + } + if r.block.bold && lvl == 0 { + run = format!("{run}"); + } + // The paragraph has its first run's colour; a run in another colour says so. + let colour = rgb8(r.block.color); + if colour != rgb8(first.color) { + let [red, green, blue] = colour; + run = format!("{run}"); + } + t.push_str(&run); + } + let style = hex(first.color).map_or(String::new(), |h| format!(" style=\"color:#{h}\"")); + match lvl { + 0 => s.push_str(&format!("{t}

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

\"\"

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

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

\"\"

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

Annual Report

", "

Overview

", "

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


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

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

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

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

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

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

Before Chevron after.

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

Before

") && h.contains("

SIDE

"), "{h}"); - } - /// A block that starts where the line above ends is the next line, not a run of it. - #[test] - fn a_block_on_the_next_line_starts_a_paragraph() { - let blocks = vec![ - block("The first line ends here", 72.0, 700.0, 190.0, 712.0), - Block { italic: true, ..block("and the next starts there.", 190.5, 690.0, 320.0, 702.0) }, - ]; - let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None }; - let xml = part(&docx(std::slice::from_ref(&p), "Lines"), "word/document.xml"); - assert_eq!(word_paragraphs(&xml), vec![runs(&[("The first line ends here", false)]), runs(&[("and the next starts there.", true)])], "{xml}"); - } - - /// A superscript beside a table cell stays in the table: table detection consumes it before - /// paragraphs are formed, and a sentence after the table still keeps its italic run. - #[test] - fn table_cells_are_not_runs_of_a_paragraph() { - let mut blocks = vec![block("PLACE", 100.0, 700.0, 140.0, 712.0), block("PRIZE (euros)", 300.0, 700.0, 380.0, 712.0)]; - for (n, (row, sup)) in [("1", "st"), ("2", "nd"), ("3", "rd")].iter().enumerate() { - let y = 680.0 - 20.0 * n as f64; - blocks.push(block(row, 100.0, y, 106.0, y + 12.0)); - blocks.push(Block { size: 8.0, ..block(sup, 106.0, y + 5.0, 113.0, y + 13.0) }); - blocks.push(block(&format!("{},000", 30 - 5 * n), 300.0, y, 340.0, y + 12.0)); - } - blocks.extend(italic_sentence().into_iter().map(|b| Block { rect: [b.rect[0], b.rect[1] - 200.0, b.rect[2], b.rect[3] - 200.0], ..b })); - let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None }; - let xml = part(&docx(std::slice::from_ref(&p), "Prizes"), "word/document.xml"); - assert_eq!(xml.matches("").count(), 1, "{xml}"); - for cell in ["st", "nd", "rd", "30,000"] { - assert_eq!(xml.matches(&format!("preserve\">{cell}")).count(), 1, "{cell}: {xml}"); - } - // The cells are all in the table: the only flowing paragraph is the sentence. - assert_eq!(word_paragraphs(&xml), vec![runs(&[("Before ", false), ("Chevron ", true), ("after.", false)])], "{xml}"); - } +use as_html::{allowed, base64}; +pub use as_rtf::rtf; +pub use layout::{Block, Cell, Image, Page, Table}; +#[cfg(test)] +use layout::{LOOKUP_STEPS, MAX_COLS, body_size}; +pub(crate) use layout::{Run, paragraphs, tables}; - /// A bold lead-in followed by body text on its line is body text, not a heading; a heading - /// with a smaller footnote marker after it stays a heading. - #[test] - fn a_paragraph_is_a_heading_only_when_its_text_is() { - let blocks = vec![ - Block { size: 24.0, ..block("Annual Report", 72.0, 740.0, 250.0, 764.0) }, - Block { size: 14.0, ..block("1", 251.0, 752.0, 258.0, 766.0) }, - Block { bold: true, ..block("Summary", 72.0, 700.0, 125.0, 712.0) }, - block("the year closed with more members than it opened with, and fewer debts.", 128.0, 700.0, 540.0, 712.0), - ]; - let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None }; - let xml = part(&docx(std::slice::from_ref(&p), "Report"), "word/document.xml"); - assert_eq!(xml.matches("").count(), 1, "{xml}"); - assert!(!xml.contains("Heading2"), "the lead-in made its paragraph a heading: {xml}"); - assert_eq!(word_paragraphs(&xml).len(), 2, "{xml}"); - } -} +#[cfg(test)] +mod tests; diff --git a/crates/export/src/pptx.rs b/crates/export/src/pptx.rs index 2edc0e25c9..49765000d3 100644 --- a/crates/export/src/pptx.rs +++ b/crates/export/src/pptx.rs @@ -358,398 +358,4 @@ xmlns:a=\"http://schemas.openxmlformats.org/drawingml/2006/main\" type=\"blank\" } #[cfg(test)] -mod tests { - use super::*; - use crate::Block; - use crate::tests::part; - - fn sample_page() -> Page { - Page { - width: 720.0, - height: 540.0, - blocks: vec![ - Block { - text: "Q3 Business Update".into(), - rect: [72.0, 460.0, 500.0, 500.0], - size: 28.0, - bold: true, - italic: false, - color: [0.1, 0.2, 0.3], - rotation: 0.0, - space_around: [false; 2], - }, - Block { - text: "Revenue grew 25% year over year across all regions.".into(), - rect: [72.0, 380.0, 500.0, 410.0], - size: 14.0, - bold: false, - italic: false, - color: [0.0; 3], - rotation: 0.0, - space_around: [false; 2], - }, - Block { - text: "Operational expenses remained flat.".into(), - rect: [72.0, 340.0, 500.0, 370.0], - size: 14.0, - bold: false, - italic: true, - color: [0.0; 3], - rotation: 0.0, - space_around: [false; 2], - }, - ], - images: vec![Image { - ext: "png", - bytes: b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR\0\0\0\x02\0\0\0\x03".as_slice().into(), - rect: [72.0, 150.0, 300.0, 300.0], - size: [228.0, 150.0], - rotation: 0.0, - }], - background: None, - } - } - - #[test] - pub fn pptx_is_a_presentationml_package() { - let p = sample_page(); - let bytes = pptx(&[p], "Executive Deck"); - assert!(bytes.starts_with(b"PK")); - for part_name in [ - "[Content_Types].xml", - "_rels/.rels", - "docProps/core.xml", - "ppt/presentation.xml", - "ppt/_rels/presentation.xml.rels", - "ppt/slideMasters/slideMaster1.xml", - "ppt/slideMasters/_rels/slideMaster1.xml.rels", - "ppt/slideLayouts/slideLayout1.xml", - "ppt/slideLayouts/_rels/slideLayout1.xml.rels", - "ppt/theme/theme1.xml", - "ppt/slides/slide1.xml", - "ppt/slides/_rels/slide1.xml.rels", - "ppt/media/image1.png", - ] { - assert!(bytes.windows(part_name.len()).any(|w| w == part_name.as_bytes()), "missing {part_name}"); - } - } - - #[test] - pub fn pptx_slide_has_title_body_and_images_in_order() { - let p = sample_page(); - let bytes = pptx(&[p], "Deck"); - let slide_xml = part(&bytes, "ppt/slides/slide1.xml"); - assert!(slide_xml.contains("name=\"Title 2\"")); - assert!(slide_xml.contains("txBox=\"1\"")); - assert!(slide_xml.contains("Q3 Business Update")); - assert!(slide_xml.contains("name=\"TextBox 3\"")); - assert!(slide_xml.contains("Revenue grew 25%")); - assert!(slide_xml.contains("")); - assert!(slide_xml.contains("")); - assert!(!slide_xml.contains("")); - assert!(slide_xml.contains("i=\"1\"")); - } - - /// Every entry of the package in order: (name, uncompressed bytes), read from the local - /// headers the way an unzipper walks them, so a broken structure fails here. - fn entries(zip: &[u8]) -> Vec<(String, Vec)> { - let at = |i: usize, n: usize| zip[i..i + n].iter().rev().fold(0usize, |v, b| v << 8 | *b as usize); - let mut out = Vec::new(); - let mut i = 0; - while zip[i..].starts_with(b"PK\x03\x04") { - let (method, size, raw, name_len, extra) = (at(i + 8, 2), at(i + 18, 4), at(i + 22, 4), at(i + 26, 2), at(i + 28, 2)); - let data = i + 30 + name_len + extra; - let name = String::from_utf8(zip[i + 30..i + 30 + name_len].to_vec()).expect("UTF-8 name"); - let body = &zip[data..data + size]; - let bytes = match method { - 0 => body.to_vec(), - 8 => { - let mut v = Vec::new(); - std::io::Read::read_to_end(&mut flate2::read::DeflateDecoder::new(body), &mut v).expect("inflates"); - v - } - m => panic!("{name}: unexpected method {m}"), - }; - assert_eq!(bytes.len(), raw, "{name}: uncompressed size"); - out.push((name, bytes)); - i = data + size; - } - assert!(zip[i..].starts_with(b"PK\x01\x02"), "the central directory follows the entries"); - out - } - - /// Checks that every start tag is closed by the matching end tag (text is escaped, so a - /// `<` always starts markup). Catches a typo such as `…`. - fn assert_tags_balance(name: &str, xml: &str) { - let mut stack: Vec<&str> = Vec::new(); - for chunk in xml.split('<').skip(1) { - let tag = chunk.split('>').next().unwrap_or_default(); - if tag.starts_with('?') { - continue; - } - if let Some(close) = tag.strip_prefix('/') { - assert_eq!(stack.pop(), Some(close.trim()), "{name}: closes the wrong element"); - } else if !tag.ends_with('/') { - stack.push(tag.split_whitespace().next().unwrap_or_default()); - } - } - assert!(stack.is_empty(), "{name}: unclosed {stack:?}"); - } - - #[test] - fn pptx_package_parts_are_complete_and_consistent() { - let cell = |t: &str, x: f64, y: f64| Block { - text: t.into(), - rect: [x, y, x + 100.0, y + 14.0], - size: 12.0, - bold: false, - italic: false, - color: [0.0; 3], - rotation: 0.0, - space_around: [false; 2], - }; - let table_page = Page { - width: 612.0, - height: 792.0, - blocks: vec![ - cell("Metric", 72.0, 400.0), - cell("Value", 200.0, 400.0), - cell("Growth", 72.0, 360.0), - cell("+9%", 200.0, 360.0), - cell("Target", 72.0, 320.0), - cell("100%", 200.0, 320.0), - ], - images: Vec::new(), - background: None, - }; - let bytes = pptx(&[sample_page(), table_page], "Two"); - let parts = entries(&bytes); - assert_eq!(parts.first().map(|p| p.0.as_str()), Some("[Content_Types].xml"), "content types come first"); - let names: std::collections::HashSet<&str> = parts.iter().map(|p| p.0.as_str()).collect(); - assert_eq!(names.len(), parts.len(), "no duplicate parts"); - for required in [ - "[Content_Types].xml", - "_rels/.rels", - "ppt/presentation.xml", - "ppt/_rels/presentation.xml.rels", - "ppt/slideMasters/slideMaster1.xml", - "ppt/slideMasters/_rels/slideMaster1.xml.rels", - "ppt/slideLayouts/slideLayout1.xml", - "ppt/slideLayouts/_rels/slideLayout1.xml.rels", - "ppt/theme/theme1.xml", - "ppt/slides/slide1.xml", - "ppt/slides/_rels/slide1.xml.rels", - "ppt/slides/slide2.xml", - "ppt/slides/_rels/slide2.xml.rels", - "ppt/media/image1.png", - ] { - assert!(names.contains(required), "missing {required}"); - } - let text = |n: &str| String::from_utf8(parts.iter().find(|p| p.0 == n).map(|p| p.1.clone()).unwrap_or_default()).expect("UTF-8 XML"); - for (name, body) in &parts { - if name.ends_with(".xml") || name.ends_with(".rels") { - assert!(body.starts_with(b""), "the second slide carries the table"); - // Text boxes, not placeholders: the blank layout defines none to inherit from. - for slide in ["ppt/slides/slide1.xml", "ppt/slides/slide2.xml"] { - assert!(!text(slide).contains(" = base.split('/').filter(|s| !s.is_empty()).collect(); - for seg in target.split('/') { - if seg == ".." { - path.pop(); - } else { - path.push(seg); - } - } - let resolved = path.join("/"); - assert!(names.contains(resolved.as_str()), "{rels}: target {target} is missing"); - } - } - // The theme's font scheme carries the schema-required latin, ea and cs faces. - let theme = text("ppt/theme/theme1.xml"); - for font in ["majorFont", "minorFont"] { - let inner = theme.split(&format!("")).nth(1).and_then(|r| r.split(&format!("")).next()).unwrap_or_default(); - assert!(inner.contains(""), "the slide is still written"); - assert!(!slide.contains("y=\"-"), "positions never wrap negative"); - assert!(!slide.contains("")); - let slide_xml = part(&bytes, "ppt/slides/slide1.xml"); - assert!(slide_xml.contains("")); - } - - #[test] - fn pptx_slide_dimensions_and_clamping() { - // Known dimensions: 720x540 pt (10x7.5 in) = 9144000x6858000 EMU - let p = Page { width: 720.0, height: 540.0, ..Page::default() }; - let bytes = pptx(&[p], "Known Dimensions"); - let pres_xml = part(&bytes, "ppt/presentation.xml"); - assert!(pres_xml.contains(""), "{pres_xml}"); - - // Tiny page (10x10 pt) clamps to PowerPoint minimum of 1 inch (914400 EMU) - let tiny = Page { width: 10.0, height: 10.0, ..Page::default() }; - let tiny_bytes = pptx(&[tiny], "Tiny"); - let tiny_pres = part(&tiny_bytes, "ppt/presentation.xml"); - assert!(tiny_pres.contains(""), "{tiny_pres}"); - - // Poster page (10000x10000 pt) clamps to PowerPoint maximum of 56 inches (51206400 EMU) - let poster = Page { width: 10_000.0, height: 10_000.0, ..Page::default() }; - let poster_bytes = pptx(&[poster], "Poster"); - let poster_pres = part(&poster_bytes, "ppt/presentation.xml"); - assert!(poster_pres.contains(""), "{poster_pres}"); - - // Non-finite dimensions fall back to default - let nan_page = Page { width: f64::NAN, height: -1.0, ..Page::default() }; - let nan_bytes = pptx(&[nan_page], "NaN"); - let nan_pres = part(&nan_bytes, "ppt/presentation.xml"); - assert!(nan_pres.contains(""), "{nan_pres}"); - } - - #[test] - fn pptx_table_cell_with_largest_font_stays_in_table_and_not_in_title() { - let cell = |t: &str, x: f64, y: f64, w: f64, sz: f64| Block { - text: t.into(), - rect: [x, y, x + w, y + 14.0], - size: sz, - bold: false, - italic: false, - color: [0.0; 3], - rotation: 0.0, - space_around: [false; 2], - }; - let p = Page { - width: 720.0, - height: 540.0, - blocks: vec![ - // Heading outside the table (short width, does not span table columns) - Block { - text: "Monthly Summary".into(), - rect: [50.0, 480.0, 150.0, 500.0], - size: 20.0, - bold: true, - italic: false, - color: [0.0; 3], - rotation: 0.0, - space_around: [false; 2], - }, - // Table where one cell has a very large number (e.g. 36 pt callout in the table) - cell("Metric", 72.0, 400.0, 100.0, 12.0), - cell("Value", 200.0, 400.0, 100.0, 12.0), - cell("Growth", 72.0, 360.0, 100.0, 12.0), - cell("+99%", 200.0, 360.0, 100.0, 36.0), - cell("Target", 72.0, 320.0, 100.0, 12.0), - cell("100%", 200.0, 320.0, 100.0, 12.0), - ], - images: Vec::new(), - background: None, - }; - let bytes = pptx(&[p], "Table Check"); - let slide_xml = part(&bytes, "ppt/slides/slide1.xml"); - // Title should be the block outside the table ("Monthly Summary"), not the "+99%" cell - assert!(slide_xml.contains("Monthly Summary")); - assert!(slide_xml.contains("name=\"Title 2\"")); - // The table must contain both the cell and explicit borders - assert!(slide_xml.contains("")); - assert!(slide_xml.contains("+99%")); - assert!(slide_xml.contains("")); - } - - #[test] - fn pptx_opens_in_libreoffice() { - let p = sample_page(); - let bytes = pptx(&[p], "LibreOffice Check"); - let dir = std::env::temp_dir().join(format!("pdfcraft-pptx-lo-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - let _ = std::fs::create_dir_all(&dir); - let pptx_path = dir.join("test.pptx"); - std::fs::write(&pptx_path, &bytes).unwrap(); - - for cmd in ["libreoffice", "soffice"] { - let status = std::process::Command::new(cmd).args(["--headless", "--convert-to", "pdf", "--outdir"]).arg(&dir).arg(&pptx_path).status(); - if status.is_ok_and(|st| st.success()) { - assert!(dir.join("test.pdf").is_file(), "LibreOffice produced test.pdf"); - break; - } - } - let _ = std::fs::remove_dir_all(&dir); - } -} +mod tests; diff --git a/crates/export/src/pptx/tests.rs b/crates/export/src/pptx/tests.rs new file mode 100644 index 0000000000..2e1c1bd046 --- /dev/null +++ b/crates/export/src/pptx/tests.rs @@ -0,0 +1,393 @@ +use super::*; +use crate::Block; +use crate::tests::part; + +fn sample_page() -> Page { + Page { + width: 720.0, + height: 540.0, + blocks: vec![ + Block { + text: "Q3 Business Update".into(), + rect: [72.0, 460.0, 500.0, 500.0], + size: 28.0, + bold: true, + italic: false, + color: [0.1, 0.2, 0.3], + rotation: 0.0, + space_around: [false; 2], + }, + Block { + text: "Revenue grew 25% year over year across all regions.".into(), + rect: [72.0, 380.0, 500.0, 410.0], + size: 14.0, + bold: false, + italic: false, + color: [0.0; 3], + rotation: 0.0, + space_around: [false; 2], + }, + Block { + text: "Operational expenses remained flat.".into(), + rect: [72.0, 340.0, 500.0, 370.0], + size: 14.0, + bold: false, + italic: true, + color: [0.0; 3], + rotation: 0.0, + space_around: [false; 2], + }, + ], + images: vec![Image { + ext: "png", + bytes: b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR\0\0\0\x02\0\0\0\x03".as_slice().into(), + rect: [72.0, 150.0, 300.0, 300.0], + size: [228.0, 150.0], + rotation: 0.0, + }], + background: None, + } +} + +#[test] +pub fn pptx_is_a_presentationml_package() { + let p = sample_page(); + let bytes = pptx(&[p], "Executive Deck"); + assert!(bytes.starts_with(b"PK")); + for part_name in [ + "[Content_Types].xml", + "_rels/.rels", + "docProps/core.xml", + "ppt/presentation.xml", + "ppt/_rels/presentation.xml.rels", + "ppt/slideMasters/slideMaster1.xml", + "ppt/slideMasters/_rels/slideMaster1.xml.rels", + "ppt/slideLayouts/slideLayout1.xml", + "ppt/slideLayouts/_rels/slideLayout1.xml.rels", + "ppt/theme/theme1.xml", + "ppt/slides/slide1.xml", + "ppt/slides/_rels/slide1.xml.rels", + "ppt/media/image1.png", + ] { + assert!(bytes.windows(part_name.len()).any(|w| w == part_name.as_bytes()), "missing {part_name}"); + } +} + +#[test] +pub fn pptx_slide_has_title_body_and_images_in_order() { + let p = sample_page(); + let bytes = pptx(&[p], "Deck"); + let slide_xml = part(&bytes, "ppt/slides/slide1.xml"); + assert!(slide_xml.contains("name=\"Title 2\"")); + assert!(slide_xml.contains("txBox=\"1\"")); + assert!(slide_xml.contains("Q3 Business Update")); + assert!(slide_xml.contains("name=\"TextBox 3\"")); + assert!(slide_xml.contains("Revenue grew 25%")); + assert!(slide_xml.contains("")); + assert!(slide_xml.contains("")); + assert!(!slide_xml.contains("")); + assert!(slide_xml.contains("i=\"1\"")); +} + +/// Every entry of the package in order: (name, uncompressed bytes), read from the local +/// headers the way an unzipper walks them, so a broken structure fails here. +fn entries(zip: &[u8]) -> Vec<(String, Vec)> { + let at = |i: usize, n: usize| zip[i..i + n].iter().rev().fold(0usize, |v, b| v << 8 | *b as usize); + let mut out = Vec::new(); + let mut i = 0; + while zip[i..].starts_with(b"PK\x03\x04") { + let (method, size, raw, name_len, extra) = (at(i + 8, 2), at(i + 18, 4), at(i + 22, 4), at(i + 26, 2), at(i + 28, 2)); + let data = i + 30 + name_len + extra; + let name = String::from_utf8(zip[i + 30..i + 30 + name_len].to_vec()).expect("UTF-8 name"); + let body = &zip[data..data + size]; + let bytes = match method { + 0 => body.to_vec(), + 8 => { + let mut v = Vec::new(); + std::io::Read::read_to_end(&mut flate2::read::DeflateDecoder::new(body), &mut v).expect("inflates"); + v + } + m => panic!("{name}: unexpected method {m}"), + }; + assert_eq!(bytes.len(), raw, "{name}: uncompressed size"); + out.push((name, bytes)); + i = data + size; + } + assert!(zip[i..].starts_with(b"PK\x01\x02"), "the central directory follows the entries"); + out +} + +/// Checks that every start tag is closed by the matching end tag (text is escaped, so a +/// `<` always starts markup). Catches a typo such as `…`. +fn assert_tags_balance(name: &str, xml: &str) { + let mut stack: Vec<&str> = Vec::new(); + for chunk in xml.split('<').skip(1) { + let tag = chunk.split('>').next().unwrap_or_default(); + if tag.starts_with('?') { + continue; + } + if let Some(close) = tag.strip_prefix('/') { + assert_eq!(stack.pop(), Some(close.trim()), "{name}: closes the wrong element"); + } else if !tag.ends_with('/') { + stack.push(tag.split_whitespace().next().unwrap_or_default()); + } + } + assert!(stack.is_empty(), "{name}: unclosed {stack:?}"); +} + +#[test] +fn pptx_package_parts_are_complete_and_consistent() { + let cell = |t: &str, x: f64, y: f64| Block { + text: t.into(), + rect: [x, y, x + 100.0, y + 14.0], + size: 12.0, + bold: false, + italic: false, + color: [0.0; 3], + rotation: 0.0, + space_around: [false; 2], + }; + let table_page = Page { + width: 612.0, + height: 792.0, + blocks: vec![ + cell("Metric", 72.0, 400.0), + cell("Value", 200.0, 400.0), + cell("Growth", 72.0, 360.0), + cell("+9%", 200.0, 360.0), + cell("Target", 72.0, 320.0), + cell("100%", 200.0, 320.0), + ], + images: Vec::new(), + background: None, + }; + let bytes = pptx(&[sample_page(), table_page], "Two"); + let parts = entries(&bytes); + assert_eq!(parts.first().map(|p| p.0.as_str()), Some("[Content_Types].xml"), "content types come first"); + let names: std::collections::HashSet<&str> = parts.iter().map(|p| p.0.as_str()).collect(); + assert_eq!(names.len(), parts.len(), "no duplicate parts"); + for required in [ + "[Content_Types].xml", + "_rels/.rels", + "ppt/presentation.xml", + "ppt/_rels/presentation.xml.rels", + "ppt/slideMasters/slideMaster1.xml", + "ppt/slideMasters/_rels/slideMaster1.xml.rels", + "ppt/slideLayouts/slideLayout1.xml", + "ppt/slideLayouts/_rels/slideLayout1.xml.rels", + "ppt/theme/theme1.xml", + "ppt/slides/slide1.xml", + "ppt/slides/_rels/slide1.xml.rels", + "ppt/slides/slide2.xml", + "ppt/slides/_rels/slide2.xml.rels", + "ppt/media/image1.png", + ] { + assert!(names.contains(required), "missing {required}"); + } + let text = |n: &str| String::from_utf8(parts.iter().find(|p| p.0 == n).map(|p| p.1.clone()).unwrap_or_default()).expect("UTF-8 XML"); + for (name, body) in &parts { + if name.ends_with(".xml") || name.ends_with(".rels") { + assert!(body.starts_with(b""), "the second slide carries the table"); + // Text boxes, not placeholders: the blank layout defines none to inherit from. + for slide in ["ppt/slides/slide1.xml", "ppt/slides/slide2.xml"] { + assert!(!text(slide).contains(" = base.split('/').filter(|s| !s.is_empty()).collect(); + for seg in target.split('/') { + if seg == ".." { + path.pop(); + } else { + path.push(seg); + } + } + let resolved = path.join("/"); + assert!(names.contains(resolved.as_str()), "{rels}: target {target} is missing"); + } + } + // The theme's font scheme carries the schema-required latin, ea and cs faces. + let theme = text("ppt/theme/theme1.xml"); + for font in ["majorFont", "minorFont"] { + let inner = theme.split(&format!("")).nth(1).and_then(|r| r.split(&format!("")).next()).unwrap_or_default(); + assert!(inner.contains(""), "the slide is still written"); + assert!(!slide.contains("y=\"-"), "positions never wrap negative"); + assert!(!slide.contains("")); + let slide_xml = part(&bytes, "ppt/slides/slide1.xml"); + assert!(slide_xml.contains("")); +} + +#[test] +fn pptx_slide_dimensions_and_clamping() { + // Known dimensions: 720x540 pt (10x7.5 in) = 9144000x6858000 EMU + let p = Page { width: 720.0, height: 540.0, ..Page::default() }; + let bytes = pptx(&[p], "Known Dimensions"); + let pres_xml = part(&bytes, "ppt/presentation.xml"); + assert!(pres_xml.contains(""), "{pres_xml}"); + + // Tiny page (10x10 pt) clamps to PowerPoint minimum of 1 inch (914400 EMU) + let tiny = Page { width: 10.0, height: 10.0, ..Page::default() }; + let tiny_bytes = pptx(&[tiny], "Tiny"); + let tiny_pres = part(&tiny_bytes, "ppt/presentation.xml"); + assert!(tiny_pres.contains(""), "{tiny_pres}"); + + // Poster page (10000x10000 pt) clamps to PowerPoint maximum of 56 inches (51206400 EMU) + let poster = Page { width: 10_000.0, height: 10_000.0, ..Page::default() }; + let poster_bytes = pptx(&[poster], "Poster"); + let poster_pres = part(&poster_bytes, "ppt/presentation.xml"); + assert!(poster_pres.contains(""), "{poster_pres}"); + + // Non-finite dimensions fall back to default + let nan_page = Page { width: f64::NAN, height: -1.0, ..Page::default() }; + let nan_bytes = pptx(&[nan_page], "NaN"); + let nan_pres = part(&nan_bytes, "ppt/presentation.xml"); + assert!(nan_pres.contains(""), "{nan_pres}"); +} + +#[test] +fn pptx_table_cell_with_largest_font_stays_in_table_and_not_in_title() { + let cell = |t: &str, x: f64, y: f64, w: f64, sz: f64| Block { + text: t.into(), + rect: [x, y, x + w, y + 14.0], + size: sz, + bold: false, + italic: false, + color: [0.0; 3], + rotation: 0.0, + space_around: [false; 2], + }; + let p = Page { + width: 720.0, + height: 540.0, + blocks: vec![ + // Heading outside the table (short width, does not span table columns) + Block { + text: "Monthly Summary".into(), + rect: [50.0, 480.0, 150.0, 500.0], + size: 20.0, + bold: true, + italic: false, + color: [0.0; 3], + rotation: 0.0, + space_around: [false; 2], + }, + // Table where one cell has a very large number (e.g. 36 pt callout in the table) + cell("Metric", 72.0, 400.0, 100.0, 12.0), + cell("Value", 200.0, 400.0, 100.0, 12.0), + cell("Growth", 72.0, 360.0, 100.0, 12.0), + cell("+99%", 200.0, 360.0, 100.0, 36.0), + cell("Target", 72.0, 320.0, 100.0, 12.0), + cell("100%", 200.0, 320.0, 100.0, 12.0), + ], + images: Vec::new(), + background: None, + }; + let bytes = pptx(&[p], "Table Check"); + let slide_xml = part(&bytes, "ppt/slides/slide1.xml"); + // Title should be the block outside the table ("Monthly Summary"), not the "+99%" cell + assert!(slide_xml.contains("Monthly Summary")); + assert!(slide_xml.contains("name=\"Title 2\"")); + // The table must contain both the cell and explicit borders + assert!(slide_xml.contains("")); + assert!(slide_xml.contains("+99%")); + assert!(slide_xml.contains("")); +} + +#[test] +fn pptx_opens_in_libreoffice() { + let p = sample_page(); + let bytes = pptx(&[p], "LibreOffice Check"); + let dir = std::env::temp_dir().join(format!("pdfcraft-pptx-lo-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let _ = std::fs::create_dir_all(&dir); + let pptx_path = dir.join("test.pptx"); + std::fs::write(&pptx_path, &bytes).unwrap(); + + for cmd in ["libreoffice", "soffice"] { + let status = std::process::Command::new(cmd).args(["--headless", "--convert-to", "pdf", "--outdir"]).arg(&dir).arg(&pptx_path).status(); + if status.is_ok_and(|st| st.success()) { + assert!(dir.join("test.pdf").is_file(), "LibreOffice produced test.pdf"); + break; + } + } + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/export/src/tests.rs b/crates/export/src/tests.rs new file mode 100644 index 0000000000..d735eb4c20 --- /dev/null +++ b/crates/export/src/tests.rs @@ -0,0 +1,771 @@ +use super::*; + +/// A page of `n` one-word blocks, each in its own size and colour. +fn varied_page(n: usize) -> Page { + let blocks = (0..n) + .map(|i| Block { + text: "word".into(), + rect: [72.0, i as f64, 100.0, i as f64 + 1.0], + size: 4.0 + i as f64 * 0.5, + bold: false, + italic: false, + color: [(i % 256) as f64 / 255.0, (i / 256 % 256) as f64 / 255.0, 0.5], + rotation: 0.0, + space_around: [false; 2], + }) + .collect(); + Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None } +} + +fn lookup_steps(f: impl FnOnce()) -> usize { + LOOKUP_STEPS.with(|c| c.set(0)); + f(); + LOOKUP_STEPS.with(|c| c.get()) +} + +#[test] +fn many_distinct_sizes_are_counted_in_constant_steps_each() { + let pages = [varied_page(3000)]; + let steps = lookup_steps(|| { + body_size(&pages); + }); + assert!(steps <= 2 * 3000, "{steps} steps"); +} + +#[test] +fn many_distinct_colours_take_constant_steps_each() { + let pages = [varied_page(3000)]; + let mut out = String::new(); + let steps = lookup_steps(|| out = rtf(&pages)); + assert!(steps <= 4 * 3000, "{steps} steps"); + assert!(out.contains("\\cf3000 ") || out.contains("\\cf3000\\"), "the last colour has the last slot"); +} + +fn page() -> Page { + let b = |t: &str, y: f64, size: f64, bold: bool| Block { + text: t.into(), + rect: [72.0, y, 500.0, y + size], + size, + bold, + italic: false, + color: [0.0; 3], + rotation: 0.0, + space_around: [false; 2], + }; + Page { + width: 612.0, + height: 792.0, + blocks: vec![ + b("Body text after the picture, which is long enough to be the main text size of the page.", 400.0, 11.0, false), + b("Annual Report", 700.0, 24.0, true), + b("Overview", 650.0, 14.0, true), + b("Some text & more of it so that eleven points is the body size here.", 620.0, 11.0, false), + ], + images: vec![Image { + ext: "png", + bytes: b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR\0\0\0\x02\0\0\0\x03".as_slice().into(), + rect: [72.0, 450.0, 272.0, 600.0], + size: [200.0, 150.0], + rotation: 0.0, + }], + background: None, + } +} + +#[test] +fn html_has_headings_paragraphs_and_images_in_order() { + let h = html(&[page(), page()], "Report"); + let order: Vec = ["

Annual Report

", "

Overview

", "

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


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

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

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

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

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

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

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

Before Chevron after.

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

Before

") && h.contains("

SIDE

"), "{h}"); +} +/// A block that starts where the line above ends is the next line, not a run of it. +#[test] +fn a_block_on_the_next_line_starts_a_paragraph() { + let blocks = vec![ + block("The first line ends here", 72.0, 700.0, 190.0, 712.0), + Block { italic: true, ..block("and the next starts there.", 190.5, 690.0, 320.0, 702.0) }, + ]; + let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None }; + let xml = part(&docx(std::slice::from_ref(&p), "Lines"), "word/document.xml"); + assert_eq!(word_paragraphs(&xml), vec![runs(&[("The first line ends here", false)]), runs(&[("and the next starts there.", true)])], "{xml}"); +} + +/// A superscript beside a table cell stays in the table: table detection consumes it before +/// paragraphs are formed, and a sentence after the table still keeps its italic run. +#[test] +fn table_cells_are_not_runs_of_a_paragraph() { + let mut blocks = vec![block("PLACE", 100.0, 700.0, 140.0, 712.0), block("PRIZE (euros)", 300.0, 700.0, 380.0, 712.0)]; + for (n, (row, sup)) in [("1", "st"), ("2", "nd"), ("3", "rd")].iter().enumerate() { + let y = 680.0 - 20.0 * n as f64; + blocks.push(block(row, 100.0, y, 106.0, y + 12.0)); + blocks.push(Block { size: 8.0, ..block(sup, 106.0, y + 5.0, 113.0, y + 13.0) }); + blocks.push(block(&format!("{},000", 30 - 5 * n), 300.0, y, 340.0, y + 12.0)); + } + blocks.extend(italic_sentence().into_iter().map(|b| Block { rect: [b.rect[0], b.rect[1] - 200.0, b.rect[2], b.rect[3] - 200.0], ..b })); + let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None }; + let xml = part(&docx(std::slice::from_ref(&p), "Prizes"), "word/document.xml"); + assert_eq!(xml.matches("").count(), 1, "{xml}"); + for cell in ["st", "nd", "rd", "30,000"] { + assert_eq!(xml.matches(&format!("preserve\">{cell}")).count(), 1, "{cell}: {xml}"); + } + // The cells are all in the table: the only flowing paragraph is the sentence. + assert_eq!(word_paragraphs(&xml), vec![runs(&[("Before ", false), ("Chevron ", true), ("after.", false)])], "{xml}"); +} + +/// A bold lead-in followed by body text on its line is body text, not a heading; a heading +/// with a smaller footnote marker after it stays a heading. +#[test] +fn a_paragraph_is_a_heading_only_when_its_text_is() { + let blocks = vec![ + Block { size: 24.0, ..block("Annual Report", 72.0, 740.0, 250.0, 764.0) }, + Block { size: 14.0, ..block("1", 251.0, 752.0, 258.0, 766.0) }, + Block { bold: true, ..block("Summary", 72.0, 700.0, 125.0, 712.0) }, + block("the year closed with more members than it opened with, and fewer debts.", 128.0, 700.0, 540.0, 712.0), + ]; + let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None }; + let xml = part(&docx(std::slice::from_ref(&p), "Report"), "word/document.xml"); + assert_eq!(xml.matches("").count(), 1, "{xml}"); + assert!(!xml.contains("Heading2"), "the lead-in made its paragraph a heading: {xml}"); + assert_eq!(word_paragraphs(&xml).len(), 2, "{xml}"); +} diff --git a/crates/fonts/src/pdf.rs b/crates/fonts/src/pdf.rs index 6e7ac7a8c2..240b1c4d55 100644 --- a/crates/fonts/src/pdf.rs +++ b/crates/fonts/src/pdf.rs @@ -1330,818 +1330,7 @@ fn identity_to_unicode() -> Vec { } #[cfg(test)] -mod tests { - use pdfcraft_cos::{Dict, Document, Object, Stream}; - - use super::*; - - /// Span records read while `f` runs. - fn span_reads(f: impl FnOnce()) -> usize { - SPAN_READS.with(|c| c.set(0)); - f(); - SPAN_READS.with(|c| c.get()) - } - - /// A composite font with `encoding` as its CMap, `w` as its widths and `to_unicode`. - fn composite(doc: &mut Document, encoding: &str, w: Vec, to_unicode: &str) -> Dict { - let mut desc = Dict::new(); - desc.set(b"Subtype".to_vec(), Object::name("CIDFontType2")); - desc.set(b"W".to_vec(), Object::Array(w)); - let enc = Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), encoding.as_bytes())))); - let tu = Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), to_unicode.as_bytes())))); - font( - doc, - vec![ - ("Subtype", Object::name("Type0")), - ("Encoding", enc), - ("DescendantFonts", Object::Array(vec![Object::Dict(desc)])), - ("ToUnicode", tu), - ], - ) - } - - /// Many `/W` ranges that leave a code out cost a glyph of it a binary search, not a scan of them all. - #[test] - fn widths_of_a_code_no_range_covers_take_few_steps() { - let mut doc = Document::new_empty(); - let w: Vec = (1..=10_000i64).flat_map(|k| [Object::Int(2 * k), Object::Int(2 * k), Object::Int(500)]).collect(); - let f = composite(&mut doc, "", w, ""); - let m = Metrics::from_dict(&doc, &f); - assert_eq!(m.width(4), 0.5); - let reads = span_reads(|| { - for _ in 0..1000 { - assert_eq!(m.width(3), 1.0); - } - }); - assert!(reads <= 1000 * 64, "{reads} span reads"); - } - - /// Where spans overlap, the first-declared one wins, as a scan in declaration order finds it. - #[test] - fn overlapping_spans_resolve_to_the_first_declared() { - let spans = Spans::new(vec![(0, 10, 'a'), (5, 20, 'b'), (3, 3, 'c'), (30, 25, 'd'), (u32::MAX, u32::MAX, 'e')]); - let found = |code| spans.find(code).map(|s| s.2); - let want = [(0, Some('a')), (3, Some('a')), (10, Some('a')), (11, Some('b')), (20, Some('b')), (21, None), (27, None), (u32::MAX, Some('e'))]; - for (code, value) in want { - assert_eq!(found(code), value, "code {code}"); - } - } - - /// Checking the glyph of every explicit ToUnicode entry looks its code up once in the CID map. - #[test] - fn many_unicode_entries_look_up_their_cids_in_few_steps() { - let n = 2000; - let cidchars: String = (0..n).map(|k| format!("<{:04X}> {} ", 0x8000 + k, k + 1)).collect(); - let encoding = format!("1 begincodespacerange <0000> endcodespacerange {n} begincidchar {cidchars} endcidchar"); - let bfchars: String = (0..n).map(|k| format!("<{:04X}> <{:04X}> ", 0x1000 + k, 0x4E00 + k)).collect(); - let to_unicode = format!("{n} beginbfchar {bfchars} endbfchar"); - let mut doc = Document::new_empty(); - let f = composite(&mut doc, &encoding, Vec::new(), &to_unicode); - let reads = span_reads(|| { - Metrics::from_dict(&doc, &f); - }); - assert!(reads <= n * 64, "{reads} span reads"); - } - - /// Every glyph of a sequential range builds its text from the destination, so a long one is cut - /// to the CMap limit once, when it is read. - #[test] - fn unicode_destinations_are_cut_to_the_cmap_limit() { - let long = "0041".repeat(4096); - let cmap = format!("2 beginbfrange <0010> <0020> <{long}> <0030> <0030> [<{long}>] endbfrange 1 beginbfchar <0040> <{long}> endbfchar"); - let mut u = Unicode::default(); - parse_to_unicode(cmap.as_bytes(), &mut u); - for code in [0x10, 0x20, 0x30, 0x40] { - let text = u.text(code).unwrap_or_default(); - assert!(!text.is_empty() && text.len() <= MAX_DESTINATION_UNITS * 3, "code {code:#x}: {} bytes", text.len()); - } - } - - /// A font whose ranges cover a few low codes keeps tables that size, not a fixed 64k-entry one. - #[test] - fn low_code_tables_end_with_the_last_covered_code() { - // One range over codes 0x20..=0x21, then nothing. - let cover = [(0x20u64, Some(0usize)), (0x22, None)]; - let owner = super::owners_below_64k(&cover); - assert_eq!(owner.len(), 0x22); - assert_eq!((owner[0x1F], owner[0x20], owner[0x21]), (u32::MAX, 0, 0)); - assert!(super::owners_below_64k(&[(0x2_0000, Some(0))]).is_empty(), "no code below 65 536"); - let bits = super::bit_set_below_64k([3u32, 130, 0x2_0000].into_iter()); - assert_eq!(bits.len(), 3); - assert_eq!((bits[0], bits[2]), (1 << 3, 1 << 2)); - assert!(super::bit_set_below_64k([0x1_0000u32].into_iter()).is_empty()); - } - - fn font(doc: &mut Document, entries: Vec<(&str, Object)>) -> Dict { - let mut d = Dict::new(); - d.set(b"Type".to_vec(), Object::name("Font")); - for (k, v) in entries { - d.set(k.as_bytes().to_vec(), v); - } - let _ = doc; - d - } - - #[test] - fn embedded_editor_fonts_are_validated_without_installing_or_exporting() { - use pdfcraft_cos::Stream; - let mut doc = Document::new_empty(); - let bytes = include_bytes!("../../../assets/fonts/Inter-Regular.ttf"); - let program = doc.add(Object::Stream(Stream::flate(Dict::new(), bytes))); - let mut descriptor = Dict::new(); - descriptor.set(b"FontFile2".to_vec(), Object::Ref(program)); - let simple = font(&mut doc, vec![("FontDescriptor", Object::Dict(descriptor.clone()))]); - assert_eq!(embedded_sfnt(&doc, &simple).unwrap(), bytes); - let composite = font(&mut doc, vec![("DescendantFonts", Object::Array(vec![Object::Dict(simple.clone())]))]); - assert_eq!(embedded_sfnt(&doc, &composite).unwrap(), bytes); - let broken = doc.add(Object::Stream(Stream::flate(Dict::new(), b"broken font"))); - descriptor.set(b"FontFile2".to_vec(), Object::Ref(broken)); - let broken = font(&mut doc, vec![("FontDescriptor", Object::Dict(descriptor))]); - assert!(embedded_sfnt(&doc, &broken).is_none()); - assert!(embedded_sfnt(&doc, &Dict::new()).is_none()); - } - - /// #2130: with no `/Widths`, an embedded program's advances win over a guess from the name - /// ("Mono" would mean Courier, 600 units for every glyph). - #[test] - fn a_font_without_widths_measures_with_its_embedded_program() { - use pdfcraft_cos::Stream; - use skrifa::MetadataProvider as _; - let mut doc = Document::new_empty(); - let bytes = include_bytes!("../../../assets/fonts/Inter-Regular.ttf"); - let face = skrifa::FontRef::new(bytes).unwrap(); - let (size, location) = (skrifa::instance::Size::unscaled(), skrifa::instance::LocationRef::default()); - let upem = f64::from(face.metrics(size, location).units_per_em); - let advance = |c: char| f64::from(face.glyph_metrics(size, location).advance_width(face.charmap().map(c).unwrap()).unwrap()) / upem; - let program = doc.add(Object::Stream(Stream::flate(Dict::new(), bytes))); - let mut descriptor = Dict::new(); - descriptor.set(b"FontFile2".to_vec(), Object::Ref(program)); - let f = font( - &mut doc, - vec![ - ("Subtype", Object::name("TrueType")), - ("BaseFont", Object::name("HouseMono")), - ("Encoding", Object::name("WinAnsiEncoding")), - ("FontDescriptor", Object::Dict(descriptor)), - ], - ); - let m = Metrics::from_dict(&doc, &f); - for c in ['i', 'l', 'W', ' '] { - assert!((m.width(u32::from(c)) - advance(c)).abs() < 1e-9, "{c}: {} vs {}", m.width(u32::from(c)), advance(c)); - } - assert!(m.width(u32::from('i')) < 0.4, "not Courier's 0.6"); - } - - #[test] - fn mac_subset_cmap_uses_pdf_unicode_without_changing_source_program() { - use skrifa::{MetadataProvider as _, raw::TableProvider as _}; - let original = include_bytes!("../../../assets/fonts/Inter-Regular.ttf"); - let face = skrifa::FontRef::new(original).unwrap(); - let expected = face.charmap().map('Ω').unwrap(); - let mut subset = original.to_vec(); - let count = u16::from_be_bytes(subset[4..6].try_into().unwrap()) as usize; - let entry = (0..count).map(|i| 12 + i * 16).find(|i| &subset[*i..*i + 4] == b"cmap").unwrap(); - let mut cmap = vec![0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 12]; - cmap.extend_from_slice(&[0, 6, 0, 12, 0, 0, 0, 65, 0, 1]); - cmap.extend_from_slice(&u16::try_from(expected.to_u32()).unwrap().to_be_bytes()); - let offset = subset.len() as u32; - subset[entry + 8..entry + 12].copy_from_slice(&offset.to_be_bytes()); - subset[entry + 12..entry + 16].copy_from_slice(&(cmap.len() as u32).to_be_bytes()); - subset.extend_from_slice(&cmap); - let face = skrifa::FontRef::new(&subset).unwrap(); - assert!(!face.charmap().has_map(), "egui cannot directly use Mac format 6"); - assert_eq!(face.cmap().unwrap().map_codepoint(65_u32), Some(expected)); - let mut doc = Document::new_empty(); - let program = doc.add(Object::Stream(Stream::flate(Dict::new(), &subset))); - let mut descriptor = Dict::new(); - descriptor.set(b"FontFile2".to_vec(), Object::Ref(program)); - let unicode = doc.add(Object::Stream(Stream::flate(Dict::new(), b"1 beginbfchar <41> <03A9> endbfchar"))); - let source = font(&mut doc, vec![("FontDescriptor", Object::Dict(descriptor)), ("ToUnicode", Object::Ref(unicode))]); - let editor = embedded_sfnt(&doc, &source).unwrap(); - let face = skrifa::FontRef::new(&editor).unwrap(); - assert_eq!(face.charmap().map('Ω'), Some(expected), "Unicode selects the original glyph"); - let Object::Stream(source) = doc.resolve(&Object::Ref(program)).as_ref().clone() else { - panic!("source stream"); - }; - assert_eq!(source.decoded().unwrap(), subset, "only an in-memory editor copy is adapted"); - let mut broken = vec![0; 16]; - assert!(append_editor_cmap(&mut broken, &std::collections::BTreeMap::from([(65, 1)])).is_none()); - } - - fn metrics_with_flags(flags: Option) -> Metrics { - let mut doc = Document::new_empty(); - let mut descriptor = Dict::new(); - if let Some(flags) = flags { - descriptor.set(b"Flags".to_vec(), flags); - } - let font = font(&mut doc, vec![("Subtype", Object::name("TrueType")), ("FontDescriptor", Object::Dict(descriptor))]); - Metrics::from_dict(&doc, &font) - } - - #[test] - fn font_descriptor_flags_stay_within_the_u32_domain() { - let valid = metrics_with_flags(Some(Object::Int(64 | 262_144))); - assert!(valid.italic && valid.bold); - - for flags in [Some(Object::Int(-1)), Some(Object::Int(4_294_967_296)), Some(Object::Bool(true)), None] { - let metrics = metrics_with_flags(flags); - assert!(!metrics.italic && !metrics.bold); - } - } - - #[test] - fn type3_descriptor_names_the_font_without_changing_its_metrics() { - let mut doc = Document::new_empty(); - let mut f = font( - &mut doc, - vec![ - ("Subtype", Object::name("Type3")), - ( - "FontMatrix", - Object::Array(vec![Object::Real(0.001), Object::Int(0), Object::Int(0), Object::Real(0.001), Object::Int(0), Object::Int(0)]), - ), - ("FontBBox", Object::Array(vec![Object::Int(0), Object::Int(-300), Object::Int(1000), Object::Int(1000)])), - ], - ); - let before = Metrics::from_dict(&doc, &f); - let mut descriptor = Dict::new(); - descriptor.set(b"Type".to_vec(), Object::name("FontDescriptor")); - descriptor.set(b"FontName".to_vec(), Object::name("ExampleMincho-Regular")); - descriptor.set(b"Flags".to_vec(), Object::Int(6)); - descriptor.set(b"ItalicAngle".to_vec(), Object::Int(0)); - let reference = doc.add(Object::Dict(descriptor)); - f.set(b"FontDescriptor".to_vec(), Object::Ref(reference)); - let after = Metrics::from_dict(&doc, &f); - assert_eq!(after.base_font, "ExampleMincho-Regular"); - assert_eq!((after.ascent, after.descent, after.scale), (before.ascent, before.descent, before.scale)); - assert!(!after.bold && !after.italic && !after.subset); - f.set(b"BaseFont".to_vec(), Object::name("ExplicitName")); - assert_eq!(Metrics::from_dict(&doc, &f).base_font, "ExplicitName", "an explicit BaseFont takes precedence"); - } - - #[test] - fn simple_fonts_decode_and_encode_through_their_encoding() { - let mut doc = Document::new_empty(); - let f = font( - &mut doc, - vec![("Subtype", Object::name("Type1")), ("BaseFont", Object::name("Helvetica")), ("Encoding", Object::name("WinAnsiEncoding"))], - ); - let m = Metrics::from_dict(&doc, &f); - assert_eq!(m.decode(b"Caf\xe9 \x80 \x93x\x94"), "Café € “x”"); - assert_eq!(m.encode("Café €").as_deref(), Some(&b"Caf\xe9 \x80"[..])); - assert_eq!(m.encode("Ω"), None, "not in WinAnsi"); - // Differences rename codes by glyph name. - let mut enc = Dict::new(); - enc.set(b"Differences".to_vec(), Object::Array(vec![Object::Int(65), Object::name("eacute"), Object::name("uni03A9")])); - let f = font(&mut doc, vec![("Subtype", Object::name("Type1")), ("BaseFont", Object::name("Custom")), ("Encoding", Object::Dict(enc))]); - let m = Metrics::from_dict(&doc, &f); - assert_eq!(m.decode(b"AB"), "éΩ"); - assert_eq!(m.encode("Ω").as_deref(), Some(&b"B"[..])); - } - - /// A standard-14 font with no `/Widths` is measured by the glyph each code draws, not by the - /// glyph WinAnsi gives that code (#2894); `/Widths`, when present, still go by code. - #[test] - fn standard_fonts_without_widths_measure_the_glyph_their_differences_name() { - let mut doc = Document::new_empty(); - let mut enc = Dict::new(); - enc.set(b"BaseEncoding".to_vec(), Object::name("WinAnsiEncoding")); - let diffs = - vec![Object::Int(65), Object::name("W"), Object::Int(87), Object::name("A"), Object::name("uni03A9"), Object::name("not.a.glyph")]; - enc.set(b"Differences".to_vec(), Object::Array(diffs)); - let f = font( - &mut doc, - vec![("Subtype", Object::name("Type1")), ("BaseFont", Object::name("Helvetica")), ("Encoding", Object::Dict(enc.clone()))], - ); - let m = Metrics::from_dict(&doc, &f); - assert_eq!((m.width(65), m.width(87)), (0.944, 0.667), "A and W swap their widths"); - // Omega has no WinAnsi code and the last name reads as nothing: both keep their code's width. - assert_eq!((m.width(88), m.width(89)), (0.667, 0.667)); - let f = font( - &mut doc, - vec![ - ("Subtype", Object::name("Type1")), - ("BaseFont", Object::name("Helvetica")), - ("FirstChar", Object::Int(87)), - ("Widths", Object::Array(vec![Object::Int(500)])), - ("Encoding", Object::Dict(enc)), - ], - ); - assert_eq!(Metrics::from_dict(&doc, &f).width(87), 0.5, "/Widths go by code"); - } - - #[test] - fn subset_fonts_only_encode_the_glyphs_they_have() { - let mut doc = Document::new_empty(); - // Widths for a, b only (97, 98); c (99) is 0. - let widths = Object::Array(vec![Object::Int(500), Object::Int(520), Object::Int(0)]); - let f = font( - &mut doc, - vec![ - ("Subtype", Object::name("TrueType")), - ("BaseFont", Object::name("ABCDEF+Arial")), - ("FirstChar", Object::Int(97)), - ("Widths", widths), - ("Encoding", Object::name("WinAnsiEncoding")), - ], - ); - let m = Metrics::from_dict(&doc, &f); - assert!(m.subset); - assert_eq!(m.encode("ab").as_deref(), Some(&b"ab"[..])); - assert_eq!(m.encode("abc"), None); - } - - #[test] - fn cmap_streams_decode_within_a_size_cap() { - // A valid CMap padded past the cap (a small Flate stream of whitespace) is not decoded whole. - let to_unicode = b"1 begincodespacerange <0000> endcodespacerange 1 beginbfrange <0024> <0026> <0041> endbfrange"; - let encoding = b"1 begincodespacerange <0000> endcodespacerange 1 begincidrange <0000> 0 endcidrange"; - for padded in [false, true] { - let mut doc = Document::new_empty(); - let mut stream = |cmap: &[u8]| { - let mut data = cmap.to_vec(); - if padded { - data.resize(MAX_CMAP_BYTES + 1, b' '); - } - Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), &data)))) - }; - let (tu, enc) = (stream(to_unicode), stream(encoding)); - let mut desc = Dict::new(); - desc.set(b"Subtype".to_vec(), Object::name("CIDFontType2")); - let f = font( - &mut doc, - vec![ - ("Subtype", Object::name("Type0")), - ("Encoding", enc), - ("DescendantFonts", Object::Array(vec![Object::Dict(desc)])), - ("ToUnicode", tu), - ], - ); - let m = Metrics::from_dict(&doc, &f); - assert_eq!(m.decode(&[0, 0x24]) == "A", !padded, "ToUnicode, padded: {padded}"); - assert_eq!(matches!(m.codes, Codes::Ranges(_)), !padded, "Encoding, padded: {padded}"); - } - } - - #[test] - fn composite_fonts_use_to_unicode() { - let mut doc = Document::new_empty(); - let cmap = b"/CIDInit /ProcSet findresource begin 1 begincodespacerange <0000> endcodespacerange \ - 2 beginbfchar <0003> <0020> <0010> <00E9> endbfchar 1 beginbfrange <0024> <0026> <0041> endbfrange \ - 1 beginbfrange <0030> <0031> [<0048> <0069>] endbfrange end"; - let tu = doc.add(Object::Stream(Stream::from_raw(Dict::new(), cmap.to_vec()))); - let mut desc = Dict::new(); - desc.set(b"Subtype".to_vec(), Object::name("CIDFontType2")); - desc.set( - b"W".to_vec(), - Object::Array(vec![ - Object::Int(3), - Object::Array(vec![Object::Int(250)]), - Object::Int(16), - Object::Int(16), - Object::Int(500), - Object::Int(36), - Object::Int(49), - Object::Int(600), - ]), - ); - let f = font( - &mut doc, - vec![ - ("Subtype", Object::name("Type0")), - ("BaseFont", Object::name("QWERTY+Noto")), - ("Encoding", Object::name("Identity-H")), - ("DescendantFonts", Object::Array(vec![Object::Dict(desc)])), - ("ToUnicode", Object::Ref(tu)), - ], - ); - let m = Metrics::from_dict(&doc, &f); - assert_eq!(m.decode(&[0, 0x24, 0, 0x25, 0, 3, 0, 0x30, 0, 0x31, 0, 0x10]), "AB Hié"); - assert_eq!(m.encode("Hi A").as_deref(), Some(&[0, 0x30, 0, 0x31, 0, 3, 0, 0x24][..])); - assert_eq!(m.encode("Z"), None); - } - - /// Word's Type 0 subsets map CID 0 as well as the real space glyph to U+0020 and give CID 0 a - /// width; CID 0 is `.notdef`, so a typed space takes the real one (#3223). - #[test] - fn composite_fonts_never_encode_with_cid_0() { - let mut doc = Document::new_empty(); - let cmap = b"/CIDInit /ProcSet findresource begin 1 begincodespacerange <0000> endcodespacerange \ - 1 beginbfrange <0000> <0002> [<0020> <004D> <00E1>] endbfrange \ - 1 beginbfrange <0005> <0007> [<0065> <0072> <0020>] endbfrange end"; - let tu = doc.add(Object::Stream(Stream::from_raw(Dict::new(), cmap.to_vec()))); - let mut desc = Dict::new(); - desc.set(b"Subtype".to_vec(), Object::name("CIDFontType2")); - let widths = [889, 444, 389, 278, 444, 333, 250].map(Object::Int).to_vec(); - desc.set(b"W".to_vec(), Object::Array(vec![Object::Int(0), Object::Array(vec![Object::Int(778)]), Object::Int(1), Object::Array(widths)])); - let f = font( - &mut doc, - vec![ - ("Subtype", Object::name("Type0")), - ("BaseFont", Object::name("BCDEEE+TimesNewRomanPSMT")), - ("Encoding", Object::name("Identity-H")), - ("DescendantFonts", Object::Array(vec![Object::Dict(desc)])), - ("ToUnicode", Object::Ref(tu)), - ], - ); - let m = Metrics::from_dict(&doc, &f); - assert_eq!(m.encode("Me er").as_deref(), Some(&[0, 1, 0, 5, 0, 7, 0, 5, 0, 6][..])); - assert_eq!(m.decode(&[0, 1, 0, 7, 0, 2]), "M á"); - } - - /// WSCAD writes `/ToUnicode /Identity-H` (a name, not a CMap stream) on composite fonts whose - /// codes are UTF-16 code units: the codes read as the same Unicode, as in Acrobat. Controls - /// and lone surrogates map to nothing, and a simple font's name is not a ToUnicode map. - #[test] - fn identity_to_unicode_names_read_codes_as_unicode() { - let doc = Document::new_empty(); - for name in ["Identity-H", "Identity-V"] { - let f = font( - &mut Document::new_empty(), - vec![ - ("Subtype", Object::name("Type0")), - ("BaseFont", Object::name("RobotoMono-Regular")), - ("Encoding", Object::name("Identity-H")), - ("DescendantFonts", Object::Array(vec![Object::Dict(Dict::new())])), - ("ToUnicode", Object::name(name)), - ], - ); - let m = Metrics::from_dict(&doc, &f); - assert_eq!(m.decode(&[0, b'Z', 0, 0xFC, 0, b'r', 0x20, 0xAC, 0x4E, 0x2D]), "Zür€中", "{name}"); - assert_eq!(m.text_of(0x0001), None); - assert_eq!(m.text_of(0x007F), None, "DEL is a control"); - assert_eq!(m.text_of(0x0085), None, "a C1 control"); - assert_eq!(m.text_of(0x00A0).as_deref(), Some("\u{A0}"), "the range resumes after C1"); - assert_eq!(m.text_of(0x007E).as_deref(), Some("~")); - assert_eq!(m.text_of(0xD800), None); - assert_eq!(m.text_of(0xFFFD).as_deref(), Some("\u{FFFD}")); - } - let simple = font( - &mut Document::new_empty(), - vec![("Subtype", Object::name("TrueType")), ("BaseFont", Object::name("Arial")), ("ToUnicode", Object::name("Identity-H"))], - ); - let m = Metrics::from_dict(&doc, &simple); - assert_eq!(m.text_of(0x41).as_deref(), Some("A"), "the encoding still applies"); - } - - /// Chrome's subsets leave the glyphs of `/DW` width out of `/W` (#1443): "s" (CID 0x56) is - /// still in the font, but nothing is when the font gives no default width to point at. - #[test] - fn composite_subset_glyphs_of_the_default_width_are_present() { - let metrics = |dw: bool| { - let mut doc = Document::new_empty(); - let cmap = b"/CIDInit /ProcSet findresource begin 1 begincodespacerange <0000> endcodespacerange \ - 1 beginbfrange <0055> <0057> <0072> endbfrange end"; - let tu = doc.add(Object::Stream(Stream::from_raw(Dict::new(), cmap.to_vec()))); - let mut desc = Dict::new(); - desc.set(b"Subtype".to_vec(), Object::name("CIDFontType2")); - if dw { - desc.set(b"DW".to_vec(), Object::Int(500)); - } - desc.set( - b"W".to_vec(), - Object::Array(vec![ - Object::Int(0x55), - Object::Array(vec![Object::Int(333)]), - Object::Int(0x57), - Object::Array(vec![Object::Int(277)]), - ]), - ); - let f = font( - &mut doc, - vec![ - ("Subtype", Object::name("Type0")), - ("BaseFont", Object::name("AAAAAA+LiberationSans")), - ("Encoding", Object::name("Identity-H")), - ("DescendantFonts", Object::Array(vec![Object::Dict(desc)])), - ("ToUnicode", Object::Ref(tu)), - ], - ); - Metrics::from_dict(&doc, &f) - }; - let chrome = metrics(true); - assert_eq!(chrome.encode("rst").as_deref(), Some(&[0, 0x55, 0, 0x56, 0, 0x57][..])); - assert_eq!(chrome.width(0x56), 0.5); - assert_eq!(chrome.encode("x"), None, "a character the ToUnicode map doesn't give"); - assert_eq!(metrics(false).encode("s"), None); - } - - /// A `/W` run that starts at the last CID stops there instead of overflowing onto CID 0. - #[test] - fn composite_width_run_past_the_last_cid_is_dropped() { - let widths = |first: i64| { - let mut doc = Document::new_empty(); - let mut desc = Dict::new(); - desc.set(b"Subtype".to_vec(), Object::name("CIDFontType2")); - desc.set(b"DW".to_vec(), Object::Int(1000)); - desc.set(b"W".to_vec(), Object::Array(vec![Object::Int(first), Object::Array(vec![Object::Int(100), Object::Int(200)])])); - let f = font( - &mut doc, - vec![ - ("Subtype", Object::name("Type0")), - ("Encoding", Object::name("Identity-H")), - ("DescendantFonts", Object::Array(vec![Object::Dict(desc)])), - ], - ); - Metrics::from_dict(&doc, &f) - }; - let ordinary = widths(0); - assert_eq!((ordinary.width(0), ordinary.width(1)), (0.1, 0.2)); - let last = widths(i64::from(u32::MAX)); - assert_eq!((last.width(0), last.width(u32::MAX)), (1.0, 0.1)); - } - - #[test] - fn glyph_names() { - assert_eq!(glyph_unicode("quotedblleft"), Some('“')); - assert_eq!(glyph_unicode("uni20AC"), Some('€')); - assert_eq!(glyph_unicode("u1F600"), Some('😀')); - assert_eq!(glyph_unicode("nonsense"), None); - } - - fn to_unicode(cmap: &[u8]) -> HashMap { - let mut u = Unicode::default(); - parse_to_unicode(cmap, &mut u); - let codes: Vec = u.explicit.keys().copied().chain(u.ranges.iter().flat_map(|r| r.lo..=r.hi)).collect(); - codes.into_iter().filter_map(|c| u.text(c).map(|t| (c, t.into_owned()))).collect() - } - - #[test] - fn cmap_tokens_need_no_space_between_them() { - // Hex strings, brackets and operators may touch; comments and literal strings are skipped. - let cmap = b"/CIDSystemInfo << /Registry (Adobe (x) \\) beginbfchar) >> def % beginbfchar <0001> <0058>\n\ - 2 beginbfchar<0003><0020><0010><00 E9>endbfchar \ - 1 beginbfrange<0024><0026><0041>endbfrange 1 beginbfrange<0030><0031>[<0048><0069>]endbfrange"; - let map = to_unicode(cmap); - let mut got: Vec<_> = map.iter().map(|(c, u)| (*c, u.as_str())).collect(); - got.sort_unstable(); - assert_eq!(got, [(3, " "), (0x10, "é"), (0x24, "A"), (0x25, "B"), (0x26, "C"), (0x30, "H"), (0x31, "i")]); - let (spaces, cids) = parse_cmap(b"1 begincodespacerange<00>endcodespacerange 1 begincidrange<20><7E>1 endcidrange"); - assert_eq!(spaces, [(1, vec![0], vec![0xff])]); - assert_eq!(cids, [(0x20, 0x7e, 1)]); - } - - #[test] - fn malformed_cmaps_never_panic() { - // Non-ASCII bytes inside hex strings once split a UTF-8 character and panicked. - for cmap in [ - "1 beginbfchar <0é0> <0041> <0042> endbfchar".as_bytes(), - "1 beginbfrange <0é0> <00é> <0041> endbfrange".as_bytes(), - b"1 beginbfchar <0001> <004", - b"1 beginbfchar <123> <0041> <0002> <0003> endbfchar", - b"1 beginbfrange <0001> [<0041>] endbfrange", - b"1 beginbfrange <0001> <0002> [<0041> (", - b"1 beginbfrange <0001>", - b"\xff\xfe beginbfchar \x80<\xc3\xa9> <0041> endbfchar %", - ] { - to_unicode(cmap); - parse_cmap(cmap); - } - // Code counters at the top of the range stop instead of overflowing. - let map = to_unicode(b"1 beginbfrange [<0041> <0042> <0043>] endbfrange"); - assert_eq!(map.len(), 2); - assert_eq!(map.get(&u32::MAX).map(String::as_str), Some("B")); - // A malformed array entry keeps its place, so the next one maps the next code. - let map = to_unicode(b"1 beginbfrange <0001> <0003> [<0041> <0043>] endbfrange"); - assert_eq!((map.get(&2), map.get(&3).map(String::as_str)), (None, Some("C"))); - let mut m = Metrics::fallback(); - m.cid_map = Spans::new(parse_cmap(b"1 begincidrange <0000> 4294967295 endcidrange").1); - assert_eq!(m.cid(5), u32::MAX); - let mut doc = Document::new_empty(); - let mut enc = Dict::new(); - enc.set(b"Differences".to_vec(), Object::Array(vec![Object::Int(i64::MAX), Object::name("A"), Object::name("B")])); - let f = font(&mut doc, vec![("Subtype", Object::name("Type1")), ("Encoding", Object::Dict(enc))]); - assert_eq!(unicode_map(&doc, &f, false).text(u32::MAX).as_deref(), Some("B")); - } - - #[test] - fn sequential_ranges_past_the_entry_cap_still_map() { - // 17 ranges of 65 536 codes: 1 114 112 codes from 0.4 KB of CMap, more than MAX_CMAP_ENTRIES - // (the first 16 ranges use up the whole cap). Each range maps its codes upward from 'A', so - // the last code of the last range, 0x100019, is 'A' + 0x19 = 'Z'. - let mut cmap = b"17 beginbfrange ".to_vec(); - for k in 0..17u32 { - cmap.extend(format!("<{:08X}> <{:08X}> <0041> ", k << 16, (k << 16) | 0xffff).bytes()); - } - cmap.extend(b"endbfrange"); - let mut doc = Document::new_empty(); - let tu = doc.add(Object::Stream(Stream::from_raw(Dict::new(), cmap))); - let f = font( - &mut doc, - vec![ - ("Subtype", Object::name("Type0")), - ("BaseFont", Object::name("Wide")), - ("Encoding", Object::name("Identity-H")), - ("ToUnicode", Object::Ref(tu)), - ], - ); - let m = Metrics::from_dict(&doc, &f); - assert_eq!(m.text_of(0x0010_0019).as_deref(), Some("Z")); - } - - #[test] - fn a_cmap_adds_at_most_max_entries() { - // 200 ranges of 65 536 codes would be 13 million codes from 4 KB of stream: 200 records. - let mut cmap = b"200 beginbfrange ".to_vec(); - for k in 0..200u32 { - cmap.extend(format!("<{:08X}> <{:08X}> <0041> ", k << 16, (k << 16) | 0xffff).bytes()); - } - cmap.extend(b"endbfrange"); - let mut u = Unicode::default(); - parse_to_unicode(&cmap, &mut u); - assert_eq!((u.ranges.len(), u.explicit.len()), (200, 0)); - // Explicit entries are what the budget counts: 250 of the 300 here are kept. - let mut many = b"300 beginbfchar ".to_vec(); - for k in 0..300u32 { - many.extend(format!("<{k:04X}> <{:04X}> ", 0x4E00 + k).bytes()); - } - many.extend(b"endbfchar"); - let mut u = Unicode::default(); - parse_to_unicode_within(&many, &mut u, 250); - assert_eq!((u.explicit.len(), u.text(249).is_some(), u.text(250).is_some()), (250, true, false)); - let mut many = b"begincidrange ".to_vec(); - for k in 0..MAX_CMAP_RANGES + 10 { - many.extend(format!("<{k:08X}> <{k:08X}> 1 ").bytes()); - } - assert_eq!(parse_cmap(&many).1.len(), MAX_CMAP_RANGES); - } - - #[test] - fn a_range_is_one_record_however_many_codes() { - let mut u = Unicode::default(); - parse_to_unicode(b"1 beginbfrange <0000> <0041> endbfrange", &mut u); - assert_eq!((u.ranges.len(), u.explicit.len()), (1, 0)); - assert_eq!(u.text(0xFFFF).as_deref(), Some("@"), "0x41 + 0xFFFF wraps to U+0040"); - } - - #[test] - fn sequential_ranges_count_against_the_budget() { - // Range k maps codes k and k + 1 and costs two, so a budget of 250 keeps ranges 0 to 124. - let mut cmap = Vec::new(); - for k in 0..300u32 { - cmap.extend(format!("1 beginbfrange <{k:08X}> <{:08X}> <0041> endbfrange ", k + 1).bytes()); - } - let mut u = Unicode::default(); - parse_to_unicode_within(&cmap, &mut u, 250); - assert_eq!((u.ranges.len(), u.text(125).is_some(), u.text(126).is_none()), (125, true, true)); - } - - #[test] - fn explicit_and_range_mappings_override_the_encoding() { - let mut doc = Document::new_empty(); - let cmap = b"1 beginbfchar <41> <0058> endbfchar 1 beginbfrange <42> <43> <0059> endbfrange"; - let tu = doc.add(Object::Stream(Stream::from_raw(Dict::new(), cmap.to_vec()))); - let f = font( - &mut doc, - vec![ - ("Subtype", Object::name("Type1")), - ("BaseFont", Object::name("Custom")), - ("Encoding", Object::name("WinAnsiEncoding")), - ("ToUnicode", Object::Ref(tu)), - ], - ); - let m = Metrics::from_dict(&doc, &f); - let shown: Vec> = (0x41..=0x44).map(|c| m.text_of(c).map(|t| t.into_owned())).collect(); - assert_eq!(shown, [Some("X".to_string()), Some("Y".to_string()), Some("Z".to_string()), Some("D".to_string())]); - } - - /// Deterministic input for the differential test below. - struct Gen(u64); - - impl Gen { - fn below(&mut self, n: u32) -> u32 { - self.0 = self.0.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1_442_695_040_888_963_407); - ((self.0 >> 33) % u64::from(n)) as u32 - } - - /// A destination: a unit from a pool of edge cases, a surrogate pair, or a lone high surrogate. - fn units(&mut self) -> Vec { - const POOL: [u16; 12] = [0x41, 0x42, 0xE9, 0x0, 0xFFFD, 0xFFFE, 0xFFFF, 0x100, 0x3042, 0xD800, 0xDC00, 0xDFFF]; - match self.below(6) { - 0 => vec![0xD835, 0xDC00 + self.below(0x400) as u16], - 1 => vec![0xD800, 0x41], - _ => vec![POOL[self.below(POOL.len() as u32) as usize]], - } - } - } - - fn hex(units: &[u16]) -> String { - units.iter().map(|u| format!("{u:04X}")).collect() - } - - #[test] - fn lookups_match_the_expanded_mappings() { - // Random ToUnicode CMaps; `expected` is what expanding their entries in order gives. - let mut g = Gen(0x2545_f491_4f6c_dd1d); - for _ in 0..300 { - let mut cmap = String::from("1 begincodespacerange <0000> endcodespacerange "); - let mut expected: HashMap = HashMap::new(); - for _ in 0..1 + g.below(12) { - match g.below(3) { - 0 => { - let (code, u) = (g.below(300), g.units()); - cmap += &format!("1 beginbfchar <{code:04X}> <{}> endbfchar ", hex(&u)); - expected.insert(code, String::from_utf16_lossy(&u)); - } - 1 => { - let (lo, width, u) = (g.below(300), g.below(13), g.units()); - cmap += &format!("1 beginbfrange <{lo:04X}> <{:04X}> <{}> endbfrange ", lo + width, hex(&u)); - for k in 0..=width { - let mut v = u.clone(); - if let Some(last) = v.last_mut() { - *last = last.wrapping_add(k as u16); - } - expected.insert(lo + k, String::from_utf16_lossy(&v)); - } - } - _ => { - let (lo, width) = (g.below(300), g.below(5)); - let dests: Vec> = (0..=width).map(|_| g.units()).collect(); - let list: Vec = dests.iter().map(|d| format!("<{}>", hex(d))).collect(); - cmap += &format!("1 beginbfrange <{lo:04X}> <{:04X}> [{}] endbfrange ", lo + width, list.join(" ")); - for (k, d) in dests.iter().enumerate() { - expected.insert(lo + k as u32, String::from_utf16_lossy(d)); - } - } - } - } - let mut doc = Document::new_empty(); - let tu = doc.add(Object::Stream(Stream::from_raw(Dict::new(), cmap.clone().into_bytes()))); - let f = font( - &mut doc, - vec![ - ("Subtype", Object::name("Type0")), - ("BaseFont", Object::name("Gen")), - ("Encoding", Object::name("Identity-H")), - ("ToUnicode", Object::Ref(tu)), - ], - ); - let m = Metrics::from_dict(&doc, &f); - for code in 0..=320u32 { - let got = m.text_of(code).map(|t| t.into_owned()); - assert_eq!(got, expected.get(&code).cloned(), "code {code:#06x} in {cmap}"); - } - let mut reverse: HashMap = HashMap::new(); - // Code 0 is CID 0, `.notdef`, which no character is encoded with. - for (&code, text) in expected.iter().filter(|&(&code, _)| code != 0) { - if let Some(c) = only_char(text) { - keep(&mut reverse, c, code); - } - } - let mut chars: Vec = expected.values().flat_map(|t| t.chars()).chain(['A', '\u{FFFD}', '\u{0}', '\u{1D400}']).collect(); - chars.sort_unstable(); - chars.dedup(); - for c in chars { - let want = reverse.get(&c).map(|code| code.to_be_bytes()[2..].to_vec()); - assert_eq!(m.encode(&c.to_string()), want, "char {c:?} in {cmap}"); - } - } - } - - #[test] - fn fffd_lookups_skip_overridden_surrogates() { - // Each record maps every code to itself, so its surrogate offsets decode to U+FFFD. The last - // record overrides the surrogates with letters, so each lookup walks past them to U+FFFD itself. - let mut cmap = "1 beginbfrange <0000> <0000> endbfrange ".repeat(300); - cmap.push_str("1 beginbfrange <0041> endbfrange"); - let mut doc = Document::new_empty(); - let tu = doc.add(Object::Stream(Stream::from_raw(Dict::new(), cmap.into_bytes()))); - let f = font( - &mut doc, - vec![ - ("Subtype", Object::name("Type0")), - ("BaseFont", Object::name("Gen")), - ("Encoding", Object::name("Identity-H")), - ("ToUnicode", Object::Ref(tu)), - ], - ); - let m = Metrics::from_dict(&doc, &f); - assert_eq!(m.encode("\u{FFFD}"), Some(vec![0xFF, 0xFD])); - } - - #[test] - fn hostile_ranges_stay_bounded() { - // A range wider than 65 536 codes is ignored, a reversed one maps nothing, and a maximal - // one-code range maps just its own code. - let mut u = Unicode::default(); - let cmap = b"1 beginbfrange <00000000> <0041> endbfrange \ - 1 beginbfrange <0005> <0001> <0041> endbfrange \ - 1 beginbfrange endbfrange"; - parse_to_unicode(cmap, &mut u); - assert_eq!((u.ranges.len(), u.text(u32::MAX).as_deref()), (0, Some("\u{FFFF}"))); - } - - #[test] - fn low_codes_resolve_to_the_covering_range() { - let mut u = Unicode::default(); - let cmap = b"1 beginbfrange <0005> <0007> <0041> endbfrange 1 beginbfrange <0064> <0065> <0042> endbfrange \ - 1 beginbfrange <0000FFFE> <00010001> <0043> endbfrange"; - parse_to_unicode(cmap, &mut u); - let covered: Vec = (0..0x70).filter(|&c| u.covering(c).is_some()).collect(); - assert_eq!(covered, [5, 6, 7, 100, 101]); - assert_eq!((u.covering(0xFFFF).is_some(), u.covering(0x1_0001).is_some(), u.covering(0x1_0002).is_none()), (true, true, true)); - } -} +mod tests; #[cfg(test)] mod encoding_tests { diff --git a/crates/fonts/src/pdf/tests.rs b/crates/fonts/src/pdf/tests.rs new file mode 100644 index 0000000000..c2acef191c --- /dev/null +++ b/crates/fonts/src/pdf/tests.rs @@ -0,0 +1,797 @@ +use pdfcraft_cos::{Dict, Document, Object, Stream}; + +use super::*; + +/// Span records read while `f` runs. +fn span_reads(f: impl FnOnce()) -> usize { + SPAN_READS.with(|c| c.set(0)); + f(); + SPAN_READS.with(|c| c.get()) +} + +/// A composite font with `encoding` as its CMap, `w` as its widths and `to_unicode`. +fn composite(doc: &mut Document, encoding: &str, w: Vec, to_unicode: &str) -> Dict { + let mut desc = Dict::new(); + desc.set(b"Subtype".to_vec(), Object::name("CIDFontType2")); + desc.set(b"W".to_vec(), Object::Array(w)); + let enc = Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), encoding.as_bytes())))); + let tu = Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), to_unicode.as_bytes())))); + font( + doc, + vec![("Subtype", Object::name("Type0")), ("Encoding", enc), ("DescendantFonts", Object::Array(vec![Object::Dict(desc)])), ("ToUnicode", tu)], + ) +} + +/// Many `/W` ranges that leave a code out cost a glyph of it a binary search, not a scan of them all. +#[test] +fn widths_of_a_code_no_range_covers_take_few_steps() { + let mut doc = Document::new_empty(); + let w: Vec = (1..=10_000i64).flat_map(|k| [Object::Int(2 * k), Object::Int(2 * k), Object::Int(500)]).collect(); + let f = composite(&mut doc, "", w, ""); + let m = Metrics::from_dict(&doc, &f); + assert_eq!(m.width(4), 0.5); + let reads = span_reads(|| { + for _ in 0..1000 { + assert_eq!(m.width(3), 1.0); + } + }); + assert!(reads <= 1000 * 64, "{reads} span reads"); +} + +/// Where spans overlap, the first-declared one wins, as a scan in declaration order finds it. +#[test] +fn overlapping_spans_resolve_to_the_first_declared() { + let spans = Spans::new(vec![(0, 10, 'a'), (5, 20, 'b'), (3, 3, 'c'), (30, 25, 'd'), (u32::MAX, u32::MAX, 'e')]); + let found = |code| spans.find(code).map(|s| s.2); + let want = [(0, Some('a')), (3, Some('a')), (10, Some('a')), (11, Some('b')), (20, Some('b')), (21, None), (27, None), (u32::MAX, Some('e'))]; + for (code, value) in want { + assert_eq!(found(code), value, "code {code}"); + } +} + +/// Checking the glyph of every explicit ToUnicode entry looks its code up once in the CID map. +#[test] +fn many_unicode_entries_look_up_their_cids_in_few_steps() { + let n = 2000; + let cidchars: String = (0..n).map(|k| format!("<{:04X}> {} ", 0x8000 + k, k + 1)).collect(); + let encoding = format!("1 begincodespacerange <0000> endcodespacerange {n} begincidchar {cidchars} endcidchar"); + let bfchars: String = (0..n).map(|k| format!("<{:04X}> <{:04X}> ", 0x1000 + k, 0x4E00 + k)).collect(); + let to_unicode = format!("{n} beginbfchar {bfchars} endbfchar"); + let mut doc = Document::new_empty(); + let f = composite(&mut doc, &encoding, Vec::new(), &to_unicode); + let reads = span_reads(|| { + Metrics::from_dict(&doc, &f); + }); + assert!(reads <= n * 64, "{reads} span reads"); +} + +/// Every glyph of a sequential range builds its text from the destination, so a long one is cut +/// to the CMap limit once, when it is read. +#[test] +fn unicode_destinations_are_cut_to_the_cmap_limit() { + let long = "0041".repeat(4096); + let cmap = format!("2 beginbfrange <0010> <0020> <{long}> <0030> <0030> [<{long}>] endbfrange 1 beginbfchar <0040> <{long}> endbfchar"); + let mut u = Unicode::default(); + parse_to_unicode(cmap.as_bytes(), &mut u); + for code in [0x10, 0x20, 0x30, 0x40] { + let text = u.text(code).unwrap_or_default(); + assert!(!text.is_empty() && text.len() <= MAX_DESTINATION_UNITS * 3, "code {code:#x}: {} bytes", text.len()); + } +} + +/// A font whose ranges cover a few low codes keeps tables that size, not a fixed 64k-entry one. +#[test] +fn low_code_tables_end_with_the_last_covered_code() { + // One range over codes 0x20..=0x21, then nothing. + let cover = [(0x20u64, Some(0usize)), (0x22, None)]; + let owner = super::owners_below_64k(&cover); + assert_eq!(owner.len(), 0x22); + assert_eq!((owner[0x1F], owner[0x20], owner[0x21]), (u32::MAX, 0, 0)); + assert!(super::owners_below_64k(&[(0x2_0000, Some(0))]).is_empty(), "no code below 65 536"); + let bits = super::bit_set_below_64k([3u32, 130, 0x2_0000].into_iter()); + assert_eq!(bits.len(), 3); + assert_eq!((bits[0], bits[2]), (1 << 3, 1 << 2)); + assert!(super::bit_set_below_64k([0x1_0000u32].into_iter()).is_empty()); +} + +fn font(doc: &mut Document, entries: Vec<(&str, Object)>) -> Dict { + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("Font")); + for (k, v) in entries { + d.set(k.as_bytes().to_vec(), v); + } + let _ = doc; + d +} + +#[test] +fn embedded_editor_fonts_are_validated_without_installing_or_exporting() { + use pdfcraft_cos::Stream; + let mut doc = Document::new_empty(); + let bytes = include_bytes!("../../../../assets/fonts/Inter-Regular.ttf"); + let program = doc.add(Object::Stream(Stream::flate(Dict::new(), bytes))); + let mut descriptor = Dict::new(); + descriptor.set(b"FontFile2".to_vec(), Object::Ref(program)); + let simple = font(&mut doc, vec![("FontDescriptor", Object::Dict(descriptor.clone()))]); + assert_eq!(embedded_sfnt(&doc, &simple).unwrap(), bytes); + let composite = font(&mut doc, vec![("DescendantFonts", Object::Array(vec![Object::Dict(simple.clone())]))]); + assert_eq!(embedded_sfnt(&doc, &composite).unwrap(), bytes); + let broken = doc.add(Object::Stream(Stream::flate(Dict::new(), b"broken font"))); + descriptor.set(b"FontFile2".to_vec(), Object::Ref(broken)); + let broken = font(&mut doc, vec![("FontDescriptor", Object::Dict(descriptor))]); + assert!(embedded_sfnt(&doc, &broken).is_none()); + assert!(embedded_sfnt(&doc, &Dict::new()).is_none()); +} + +/// #2130: with no `/Widths`, an embedded program's advances win over a guess from the name +/// ("Mono" would mean Courier, 600 units for every glyph). +#[test] +fn a_font_without_widths_measures_with_its_embedded_program() { + use pdfcraft_cos::Stream; + use skrifa::MetadataProvider as _; + let mut doc = Document::new_empty(); + let bytes = include_bytes!("../../../../assets/fonts/Inter-Regular.ttf"); + let face = skrifa::FontRef::new(bytes).unwrap(); + let (size, location) = (skrifa::instance::Size::unscaled(), skrifa::instance::LocationRef::default()); + let upem = f64::from(face.metrics(size, location).units_per_em); + let advance = |c: char| f64::from(face.glyph_metrics(size, location).advance_width(face.charmap().map(c).unwrap()).unwrap()) / upem; + let program = doc.add(Object::Stream(Stream::flate(Dict::new(), bytes))); + let mut descriptor = Dict::new(); + descriptor.set(b"FontFile2".to_vec(), Object::Ref(program)); + let f = font( + &mut doc, + vec![ + ("Subtype", Object::name("TrueType")), + ("BaseFont", Object::name("HouseMono")), + ("Encoding", Object::name("WinAnsiEncoding")), + ("FontDescriptor", Object::Dict(descriptor)), + ], + ); + let m = Metrics::from_dict(&doc, &f); + for c in ['i', 'l', 'W', ' '] { + assert!((m.width(u32::from(c)) - advance(c)).abs() < 1e-9, "{c}: {} vs {}", m.width(u32::from(c)), advance(c)); + } + assert!(m.width(u32::from('i')) < 0.4, "not Courier's 0.6"); +} + +#[test] +fn mac_subset_cmap_uses_pdf_unicode_without_changing_source_program() { + use skrifa::{MetadataProvider as _, raw::TableProvider as _}; + let original = include_bytes!("../../../../assets/fonts/Inter-Regular.ttf"); + let face = skrifa::FontRef::new(original).unwrap(); + let expected = face.charmap().map('Ω').unwrap(); + let mut subset = original.to_vec(); + let count = u16::from_be_bytes(subset[4..6].try_into().unwrap()) as usize; + let entry = (0..count).map(|i| 12 + i * 16).find(|i| &subset[*i..*i + 4] == b"cmap").unwrap(); + let mut cmap = vec![0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 12]; + cmap.extend_from_slice(&[0, 6, 0, 12, 0, 0, 0, 65, 0, 1]); + cmap.extend_from_slice(&u16::try_from(expected.to_u32()).unwrap().to_be_bytes()); + let offset = subset.len() as u32; + subset[entry + 8..entry + 12].copy_from_slice(&offset.to_be_bytes()); + subset[entry + 12..entry + 16].copy_from_slice(&(cmap.len() as u32).to_be_bytes()); + subset.extend_from_slice(&cmap); + let face = skrifa::FontRef::new(&subset).unwrap(); + assert!(!face.charmap().has_map(), "egui cannot directly use Mac format 6"); + assert_eq!(face.cmap().unwrap().map_codepoint(65_u32), Some(expected)); + let mut doc = Document::new_empty(); + let program = doc.add(Object::Stream(Stream::flate(Dict::new(), &subset))); + let mut descriptor = Dict::new(); + descriptor.set(b"FontFile2".to_vec(), Object::Ref(program)); + let unicode = doc.add(Object::Stream(Stream::flate(Dict::new(), b"1 beginbfchar <41> <03A9> endbfchar"))); + let source = font(&mut doc, vec![("FontDescriptor", Object::Dict(descriptor)), ("ToUnicode", Object::Ref(unicode))]); + let editor = embedded_sfnt(&doc, &source).unwrap(); + let face = skrifa::FontRef::new(&editor).unwrap(); + assert_eq!(face.charmap().map('Ω'), Some(expected), "Unicode selects the original glyph"); + let Object::Stream(source) = doc.resolve(&Object::Ref(program)).as_ref().clone() else { + panic!("source stream"); + }; + assert_eq!(source.decoded().unwrap(), subset, "only an in-memory editor copy is adapted"); + let mut broken = vec![0; 16]; + assert!(append_editor_cmap(&mut broken, &std::collections::BTreeMap::from([(65, 1)])).is_none()); +} + +fn metrics_with_flags(flags: Option) -> Metrics { + let mut doc = Document::new_empty(); + let mut descriptor = Dict::new(); + if let Some(flags) = flags { + descriptor.set(b"Flags".to_vec(), flags); + } + let font = font(&mut doc, vec![("Subtype", Object::name("TrueType")), ("FontDescriptor", Object::Dict(descriptor))]); + Metrics::from_dict(&doc, &font) +} + +#[test] +fn font_descriptor_flags_stay_within_the_u32_domain() { + let valid = metrics_with_flags(Some(Object::Int(64 | 262_144))); + assert!(valid.italic && valid.bold); + + for flags in [Some(Object::Int(-1)), Some(Object::Int(4_294_967_296)), Some(Object::Bool(true)), None] { + let metrics = metrics_with_flags(flags); + assert!(!metrics.italic && !metrics.bold); + } +} + +#[test] +fn type3_descriptor_names_the_font_without_changing_its_metrics() { + let mut doc = Document::new_empty(); + let mut f = font( + &mut doc, + vec![ + ("Subtype", Object::name("Type3")), + ( + "FontMatrix", + Object::Array(vec![Object::Real(0.001), Object::Int(0), Object::Int(0), Object::Real(0.001), Object::Int(0), Object::Int(0)]), + ), + ("FontBBox", Object::Array(vec![Object::Int(0), Object::Int(-300), Object::Int(1000), Object::Int(1000)])), + ], + ); + let before = Metrics::from_dict(&doc, &f); + let mut descriptor = Dict::new(); + descriptor.set(b"Type".to_vec(), Object::name("FontDescriptor")); + descriptor.set(b"FontName".to_vec(), Object::name("ExampleMincho-Regular")); + descriptor.set(b"Flags".to_vec(), Object::Int(6)); + descriptor.set(b"ItalicAngle".to_vec(), Object::Int(0)); + let reference = doc.add(Object::Dict(descriptor)); + f.set(b"FontDescriptor".to_vec(), Object::Ref(reference)); + let after = Metrics::from_dict(&doc, &f); + assert_eq!(after.base_font, "ExampleMincho-Regular"); + assert_eq!((after.ascent, after.descent, after.scale), (before.ascent, before.descent, before.scale)); + assert!(!after.bold && !after.italic && !after.subset); + f.set(b"BaseFont".to_vec(), Object::name("ExplicitName")); + assert_eq!(Metrics::from_dict(&doc, &f).base_font, "ExplicitName", "an explicit BaseFont takes precedence"); +} + +#[test] +fn simple_fonts_decode_and_encode_through_their_encoding() { + let mut doc = Document::new_empty(); + let f = font( + &mut doc, + vec![("Subtype", Object::name("Type1")), ("BaseFont", Object::name("Helvetica")), ("Encoding", Object::name("WinAnsiEncoding"))], + ); + let m = Metrics::from_dict(&doc, &f); + assert_eq!(m.decode(b"Caf\xe9 \x80 \x93x\x94"), "Café € “x”"); + assert_eq!(m.encode("Café €").as_deref(), Some(&b"Caf\xe9 \x80"[..])); + assert_eq!(m.encode("Ω"), None, "not in WinAnsi"); + // Differences rename codes by glyph name. + let mut enc = Dict::new(); + enc.set(b"Differences".to_vec(), Object::Array(vec![Object::Int(65), Object::name("eacute"), Object::name("uni03A9")])); + let f = font(&mut doc, vec![("Subtype", Object::name("Type1")), ("BaseFont", Object::name("Custom")), ("Encoding", Object::Dict(enc))]); + let m = Metrics::from_dict(&doc, &f); + assert_eq!(m.decode(b"AB"), "éΩ"); + assert_eq!(m.encode("Ω").as_deref(), Some(&b"B"[..])); +} + +/// A standard-14 font with no `/Widths` is measured by the glyph each code draws, not by the +/// glyph WinAnsi gives that code (#2894); `/Widths`, when present, still go by code. +#[test] +fn standard_fonts_without_widths_measure_the_glyph_their_differences_name() { + let mut doc = Document::new_empty(); + let mut enc = Dict::new(); + enc.set(b"BaseEncoding".to_vec(), Object::name("WinAnsiEncoding")); + let diffs = vec![Object::Int(65), Object::name("W"), Object::Int(87), Object::name("A"), Object::name("uni03A9"), Object::name("not.a.glyph")]; + enc.set(b"Differences".to_vec(), Object::Array(diffs)); + let f = + font(&mut doc, vec![("Subtype", Object::name("Type1")), ("BaseFont", Object::name("Helvetica")), ("Encoding", Object::Dict(enc.clone()))]); + let m = Metrics::from_dict(&doc, &f); + assert_eq!((m.width(65), m.width(87)), (0.944, 0.667), "A and W swap their widths"); + // Omega has no WinAnsi code and the last name reads as nothing: both keep their code's width. + assert_eq!((m.width(88), m.width(89)), (0.667, 0.667)); + let f = font( + &mut doc, + vec![ + ("Subtype", Object::name("Type1")), + ("BaseFont", Object::name("Helvetica")), + ("FirstChar", Object::Int(87)), + ("Widths", Object::Array(vec![Object::Int(500)])), + ("Encoding", Object::Dict(enc)), + ], + ); + assert_eq!(Metrics::from_dict(&doc, &f).width(87), 0.5, "/Widths go by code"); +} + +#[test] +fn subset_fonts_only_encode_the_glyphs_they_have() { + let mut doc = Document::new_empty(); + // Widths for a, b only (97, 98); c (99) is 0. + let widths = Object::Array(vec![Object::Int(500), Object::Int(520), Object::Int(0)]); + let f = font( + &mut doc, + vec![ + ("Subtype", Object::name("TrueType")), + ("BaseFont", Object::name("ABCDEF+Arial")), + ("FirstChar", Object::Int(97)), + ("Widths", widths), + ("Encoding", Object::name("WinAnsiEncoding")), + ], + ); + let m = Metrics::from_dict(&doc, &f); + assert!(m.subset); + assert_eq!(m.encode("ab").as_deref(), Some(&b"ab"[..])); + assert_eq!(m.encode("abc"), None); +} + +#[test] +fn cmap_streams_decode_within_a_size_cap() { + // A valid CMap padded past the cap (a small Flate stream of whitespace) is not decoded whole. + let to_unicode = b"1 begincodespacerange <0000> endcodespacerange 1 beginbfrange <0024> <0026> <0041> endbfrange"; + let encoding = b"1 begincodespacerange <0000> endcodespacerange 1 begincidrange <0000> 0 endcidrange"; + for padded in [false, true] { + let mut doc = Document::new_empty(); + let mut stream = |cmap: &[u8]| { + let mut data = cmap.to_vec(); + if padded { + data.resize(MAX_CMAP_BYTES + 1, b' '); + } + Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), &data)))) + }; + let (tu, enc) = (stream(to_unicode), stream(encoding)); + let mut desc = Dict::new(); + desc.set(b"Subtype".to_vec(), Object::name("CIDFontType2")); + let f = font( + &mut doc, + vec![ + ("Subtype", Object::name("Type0")), + ("Encoding", enc), + ("DescendantFonts", Object::Array(vec![Object::Dict(desc)])), + ("ToUnicode", tu), + ], + ); + let m = Metrics::from_dict(&doc, &f); + assert_eq!(m.decode(&[0, 0x24]) == "A", !padded, "ToUnicode, padded: {padded}"); + assert_eq!(matches!(m.codes, Codes::Ranges(_)), !padded, "Encoding, padded: {padded}"); + } +} + +#[test] +fn composite_fonts_use_to_unicode() { + let mut doc = Document::new_empty(); + let cmap = b"/CIDInit /ProcSet findresource begin 1 begincodespacerange <0000> endcodespacerange \ + 2 beginbfchar <0003> <0020> <0010> <00E9> endbfchar 1 beginbfrange <0024> <0026> <0041> endbfrange \ + 1 beginbfrange <0030> <0031> [<0048> <0069>] endbfrange end"; + let tu = doc.add(Object::Stream(Stream::from_raw(Dict::new(), cmap.to_vec()))); + let mut desc = Dict::new(); + desc.set(b"Subtype".to_vec(), Object::name("CIDFontType2")); + desc.set( + b"W".to_vec(), + Object::Array(vec![ + Object::Int(3), + Object::Array(vec![Object::Int(250)]), + Object::Int(16), + Object::Int(16), + Object::Int(500), + Object::Int(36), + Object::Int(49), + Object::Int(600), + ]), + ); + let f = font( + &mut doc, + vec![ + ("Subtype", Object::name("Type0")), + ("BaseFont", Object::name("QWERTY+Noto")), + ("Encoding", Object::name("Identity-H")), + ("DescendantFonts", Object::Array(vec![Object::Dict(desc)])), + ("ToUnicode", Object::Ref(tu)), + ], + ); + let m = Metrics::from_dict(&doc, &f); + assert_eq!(m.decode(&[0, 0x24, 0, 0x25, 0, 3, 0, 0x30, 0, 0x31, 0, 0x10]), "AB Hié"); + assert_eq!(m.encode("Hi A").as_deref(), Some(&[0, 0x30, 0, 0x31, 0, 3, 0, 0x24][..])); + assert_eq!(m.encode("Z"), None); +} + +/// Word's Type 0 subsets map CID 0 as well as the real space glyph to U+0020 and give CID 0 a +/// width; CID 0 is `.notdef`, so a typed space takes the real one (#3223). +#[test] +fn composite_fonts_never_encode_with_cid_0() { + let mut doc = Document::new_empty(); + let cmap = b"/CIDInit /ProcSet findresource begin 1 begincodespacerange <0000> endcodespacerange \ + 1 beginbfrange <0000> <0002> [<0020> <004D> <00E1>] endbfrange \ + 1 beginbfrange <0005> <0007> [<0065> <0072> <0020>] endbfrange end"; + let tu = doc.add(Object::Stream(Stream::from_raw(Dict::new(), cmap.to_vec()))); + let mut desc = Dict::new(); + desc.set(b"Subtype".to_vec(), Object::name("CIDFontType2")); + let widths = [889, 444, 389, 278, 444, 333, 250].map(Object::Int).to_vec(); + desc.set(b"W".to_vec(), Object::Array(vec![Object::Int(0), Object::Array(vec![Object::Int(778)]), Object::Int(1), Object::Array(widths)])); + let f = font( + &mut doc, + vec![ + ("Subtype", Object::name("Type0")), + ("BaseFont", Object::name("BCDEEE+TimesNewRomanPSMT")), + ("Encoding", Object::name("Identity-H")), + ("DescendantFonts", Object::Array(vec![Object::Dict(desc)])), + ("ToUnicode", Object::Ref(tu)), + ], + ); + let m = Metrics::from_dict(&doc, &f); + assert_eq!(m.encode("Me er").as_deref(), Some(&[0, 1, 0, 5, 0, 7, 0, 5, 0, 6][..])); + assert_eq!(m.decode(&[0, 1, 0, 7, 0, 2]), "M á"); +} + +/// WSCAD writes `/ToUnicode /Identity-H` (a name, not a CMap stream) on composite fonts whose +/// codes are UTF-16 code units: the codes read as the same Unicode, as in Acrobat. Controls +/// and lone surrogates map to nothing, and a simple font's name is not a ToUnicode map. +#[test] +fn identity_to_unicode_names_read_codes_as_unicode() { + let doc = Document::new_empty(); + for name in ["Identity-H", "Identity-V"] { + let f = font( + &mut Document::new_empty(), + vec![ + ("Subtype", Object::name("Type0")), + ("BaseFont", Object::name("RobotoMono-Regular")), + ("Encoding", Object::name("Identity-H")), + ("DescendantFonts", Object::Array(vec![Object::Dict(Dict::new())])), + ("ToUnicode", Object::name(name)), + ], + ); + let m = Metrics::from_dict(&doc, &f); + assert_eq!(m.decode(&[0, b'Z', 0, 0xFC, 0, b'r', 0x20, 0xAC, 0x4E, 0x2D]), "Zür€中", "{name}"); + assert_eq!(m.text_of(0x0001), None); + assert_eq!(m.text_of(0x007F), None, "DEL is a control"); + assert_eq!(m.text_of(0x0085), None, "a C1 control"); + assert_eq!(m.text_of(0x00A0).as_deref(), Some("\u{A0}"), "the range resumes after C1"); + assert_eq!(m.text_of(0x007E).as_deref(), Some("~")); + assert_eq!(m.text_of(0xD800), None); + assert_eq!(m.text_of(0xFFFD).as_deref(), Some("\u{FFFD}")); + } + let simple = font( + &mut Document::new_empty(), + vec![("Subtype", Object::name("TrueType")), ("BaseFont", Object::name("Arial")), ("ToUnicode", Object::name("Identity-H"))], + ); + let m = Metrics::from_dict(&doc, &simple); + assert_eq!(m.text_of(0x41).as_deref(), Some("A"), "the encoding still applies"); +} + +/// Chrome's subsets leave the glyphs of `/DW` width out of `/W` (#1443): "s" (CID 0x56) is +/// still in the font, but nothing is when the font gives no default width to point at. +#[test] +fn composite_subset_glyphs_of_the_default_width_are_present() { + let metrics = |dw: bool| { + let mut doc = Document::new_empty(); + let cmap = b"/CIDInit /ProcSet findresource begin 1 begincodespacerange <0000> endcodespacerange \ + 1 beginbfrange <0055> <0057> <0072> endbfrange end"; + let tu = doc.add(Object::Stream(Stream::from_raw(Dict::new(), cmap.to_vec()))); + let mut desc = Dict::new(); + desc.set(b"Subtype".to_vec(), Object::name("CIDFontType2")); + if dw { + desc.set(b"DW".to_vec(), Object::Int(500)); + } + desc.set( + b"W".to_vec(), + Object::Array(vec![Object::Int(0x55), Object::Array(vec![Object::Int(333)]), Object::Int(0x57), Object::Array(vec![Object::Int(277)])]), + ); + let f = font( + &mut doc, + vec![ + ("Subtype", Object::name("Type0")), + ("BaseFont", Object::name("AAAAAA+LiberationSans")), + ("Encoding", Object::name("Identity-H")), + ("DescendantFonts", Object::Array(vec![Object::Dict(desc)])), + ("ToUnicode", Object::Ref(tu)), + ], + ); + Metrics::from_dict(&doc, &f) + }; + let chrome = metrics(true); + assert_eq!(chrome.encode("rst").as_deref(), Some(&[0, 0x55, 0, 0x56, 0, 0x57][..])); + assert_eq!(chrome.width(0x56), 0.5); + assert_eq!(chrome.encode("x"), None, "a character the ToUnicode map doesn't give"); + assert_eq!(metrics(false).encode("s"), None); +} + +/// A `/W` run that starts at the last CID stops there instead of overflowing onto CID 0. +#[test] +fn composite_width_run_past_the_last_cid_is_dropped() { + let widths = |first: i64| { + let mut doc = Document::new_empty(); + let mut desc = Dict::new(); + desc.set(b"Subtype".to_vec(), Object::name("CIDFontType2")); + desc.set(b"DW".to_vec(), Object::Int(1000)); + desc.set(b"W".to_vec(), Object::Array(vec![Object::Int(first), Object::Array(vec![Object::Int(100), Object::Int(200)])])); + let f = font( + &mut doc, + vec![ + ("Subtype", Object::name("Type0")), + ("Encoding", Object::name("Identity-H")), + ("DescendantFonts", Object::Array(vec![Object::Dict(desc)])), + ], + ); + Metrics::from_dict(&doc, &f) + }; + let ordinary = widths(0); + assert_eq!((ordinary.width(0), ordinary.width(1)), (0.1, 0.2)); + let last = widths(i64::from(u32::MAX)); + assert_eq!((last.width(0), last.width(u32::MAX)), (1.0, 0.1)); +} + +#[test] +fn glyph_names() { + assert_eq!(glyph_unicode("quotedblleft"), Some('“')); + assert_eq!(glyph_unicode("uni20AC"), Some('€')); + assert_eq!(glyph_unicode("u1F600"), Some('😀')); + assert_eq!(glyph_unicode("nonsense"), None); +} + +fn to_unicode(cmap: &[u8]) -> HashMap { + let mut u = Unicode::default(); + parse_to_unicode(cmap, &mut u); + let codes: Vec = u.explicit.keys().copied().chain(u.ranges.iter().flat_map(|r| r.lo..=r.hi)).collect(); + codes.into_iter().filter_map(|c| u.text(c).map(|t| (c, t.into_owned()))).collect() +} + +#[test] +fn cmap_tokens_need_no_space_between_them() { + // Hex strings, brackets and operators may touch; comments and literal strings are skipped. + let cmap = b"/CIDSystemInfo << /Registry (Adobe (x) \\) beginbfchar) >> def % beginbfchar <0001> <0058>\n\ + 2 beginbfchar<0003><0020><0010><00 E9>endbfchar \ + 1 beginbfrange<0024><0026><0041>endbfrange 1 beginbfrange<0030><0031>[<0048><0069>]endbfrange"; + let map = to_unicode(cmap); + let mut got: Vec<_> = map.iter().map(|(c, u)| (*c, u.as_str())).collect(); + got.sort_unstable(); + assert_eq!(got, [(3, " "), (0x10, "é"), (0x24, "A"), (0x25, "B"), (0x26, "C"), (0x30, "H"), (0x31, "i")]); + let (spaces, cids) = parse_cmap(b"1 begincodespacerange<00>endcodespacerange 1 begincidrange<20><7E>1 endcidrange"); + assert_eq!(spaces, [(1, vec![0], vec![0xff])]); + assert_eq!(cids, [(0x20, 0x7e, 1)]); +} + +#[test] +fn malformed_cmaps_never_panic() { + // Non-ASCII bytes inside hex strings once split a UTF-8 character and panicked. + for cmap in [ + "1 beginbfchar <0é0> <0041> <0042> endbfchar".as_bytes(), + "1 beginbfrange <0é0> <00é> <0041> endbfrange".as_bytes(), + b"1 beginbfchar <0001> <004", + b"1 beginbfchar <123> <0041> <0002> <0003> endbfchar", + b"1 beginbfrange <0001> [<0041>] endbfrange", + b"1 beginbfrange <0001> <0002> [<0041> (", + b"1 beginbfrange <0001>", + b"\xff\xfe beginbfchar \x80<\xc3\xa9> <0041> endbfchar %", + ] { + to_unicode(cmap); + parse_cmap(cmap); + } + // Code counters at the top of the range stop instead of overflowing. + let map = to_unicode(b"1 beginbfrange [<0041> <0042> <0043>] endbfrange"); + assert_eq!(map.len(), 2); + assert_eq!(map.get(&u32::MAX).map(String::as_str), Some("B")); + // A malformed array entry keeps its place, so the next one maps the next code. + let map = to_unicode(b"1 beginbfrange <0001> <0003> [<0041> <0043>] endbfrange"); + assert_eq!((map.get(&2), map.get(&3).map(String::as_str)), (None, Some("C"))); + let mut m = Metrics::fallback(); + m.cid_map = Spans::new(parse_cmap(b"1 begincidrange <0000> 4294967295 endcidrange").1); + assert_eq!(m.cid(5), u32::MAX); + let mut doc = Document::new_empty(); + let mut enc = Dict::new(); + enc.set(b"Differences".to_vec(), Object::Array(vec![Object::Int(i64::MAX), Object::name("A"), Object::name("B")])); + let f = font(&mut doc, vec![("Subtype", Object::name("Type1")), ("Encoding", Object::Dict(enc))]); + assert_eq!(unicode_map(&doc, &f, false).text(u32::MAX).as_deref(), Some("B")); +} + +#[test] +fn sequential_ranges_past_the_entry_cap_still_map() { + // 17 ranges of 65 536 codes: 1 114 112 codes from 0.4 KB of CMap, more than MAX_CMAP_ENTRIES + // (the first 16 ranges use up the whole cap). Each range maps its codes upward from 'A', so + // the last code of the last range, 0x100019, is 'A' + 0x19 = 'Z'. + let mut cmap = b"17 beginbfrange ".to_vec(); + for k in 0..17u32 { + cmap.extend(format!("<{:08X}> <{:08X}> <0041> ", k << 16, (k << 16) | 0xffff).bytes()); + } + cmap.extend(b"endbfrange"); + let mut doc = Document::new_empty(); + let tu = doc.add(Object::Stream(Stream::from_raw(Dict::new(), cmap))); + let f = font( + &mut doc, + vec![ + ("Subtype", Object::name("Type0")), + ("BaseFont", Object::name("Wide")), + ("Encoding", Object::name("Identity-H")), + ("ToUnicode", Object::Ref(tu)), + ], + ); + let m = Metrics::from_dict(&doc, &f); + assert_eq!(m.text_of(0x0010_0019).as_deref(), Some("Z")); +} + +#[test] +fn a_cmap_adds_at_most_max_entries() { + // 200 ranges of 65 536 codes would be 13 million codes from 4 KB of stream: 200 records. + let mut cmap = b"200 beginbfrange ".to_vec(); + for k in 0..200u32 { + cmap.extend(format!("<{:08X}> <{:08X}> <0041> ", k << 16, (k << 16) | 0xffff).bytes()); + } + cmap.extend(b"endbfrange"); + let mut u = Unicode::default(); + parse_to_unicode(&cmap, &mut u); + assert_eq!((u.ranges.len(), u.explicit.len()), (200, 0)); + // Explicit entries are what the budget counts: 250 of the 300 here are kept. + let mut many = b"300 beginbfchar ".to_vec(); + for k in 0..300u32 { + many.extend(format!("<{k:04X}> <{:04X}> ", 0x4E00 + k).bytes()); + } + many.extend(b"endbfchar"); + let mut u = Unicode::default(); + parse_to_unicode_within(&many, &mut u, 250); + assert_eq!((u.explicit.len(), u.text(249).is_some(), u.text(250).is_some()), (250, true, false)); + let mut many = b"begincidrange ".to_vec(); + for k in 0..MAX_CMAP_RANGES + 10 { + many.extend(format!("<{k:08X}> <{k:08X}> 1 ").bytes()); + } + assert_eq!(parse_cmap(&many).1.len(), MAX_CMAP_RANGES); +} + +#[test] +fn a_range_is_one_record_however_many_codes() { + let mut u = Unicode::default(); + parse_to_unicode(b"1 beginbfrange <0000> <0041> endbfrange", &mut u); + assert_eq!((u.ranges.len(), u.explicit.len()), (1, 0)); + assert_eq!(u.text(0xFFFF).as_deref(), Some("@"), "0x41 + 0xFFFF wraps to U+0040"); +} + +#[test] +fn sequential_ranges_count_against_the_budget() { + // Range k maps codes k and k + 1 and costs two, so a budget of 250 keeps ranges 0 to 124. + let mut cmap = Vec::new(); + for k in 0..300u32 { + cmap.extend(format!("1 beginbfrange <{k:08X}> <{:08X}> <0041> endbfrange ", k + 1).bytes()); + } + let mut u = Unicode::default(); + parse_to_unicode_within(&cmap, &mut u, 250); + assert_eq!((u.ranges.len(), u.text(125).is_some(), u.text(126).is_none()), (125, true, true)); +} + +#[test] +fn explicit_and_range_mappings_override_the_encoding() { + let mut doc = Document::new_empty(); + let cmap = b"1 beginbfchar <41> <0058> endbfchar 1 beginbfrange <42> <43> <0059> endbfrange"; + let tu = doc.add(Object::Stream(Stream::from_raw(Dict::new(), cmap.to_vec()))); + let f = font( + &mut doc, + vec![ + ("Subtype", Object::name("Type1")), + ("BaseFont", Object::name("Custom")), + ("Encoding", Object::name("WinAnsiEncoding")), + ("ToUnicode", Object::Ref(tu)), + ], + ); + let m = Metrics::from_dict(&doc, &f); + let shown: Vec> = (0x41..=0x44).map(|c| m.text_of(c).map(|t| t.into_owned())).collect(); + assert_eq!(shown, [Some("X".to_string()), Some("Y".to_string()), Some("Z".to_string()), Some("D".to_string())]); +} + +/// Deterministic input for the differential test below. +struct Gen(u64); + +impl Gen { + fn below(&mut self, n: u32) -> u32 { + self.0 = self.0.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1_442_695_040_888_963_407); + ((self.0 >> 33) % u64::from(n)) as u32 + } + + /// A destination: a unit from a pool of edge cases, a surrogate pair, or a lone high surrogate. + fn units(&mut self) -> Vec { + const POOL: [u16; 12] = [0x41, 0x42, 0xE9, 0x0, 0xFFFD, 0xFFFE, 0xFFFF, 0x100, 0x3042, 0xD800, 0xDC00, 0xDFFF]; + match self.below(6) { + 0 => vec![0xD835, 0xDC00 + self.below(0x400) as u16], + 1 => vec![0xD800, 0x41], + _ => vec![POOL[self.below(POOL.len() as u32) as usize]], + } + } +} + +fn hex(units: &[u16]) -> String { + units.iter().map(|u| format!("{u:04X}")).collect() +} + +#[test] +fn lookups_match_the_expanded_mappings() { + // Random ToUnicode CMaps; `expected` is what expanding their entries in order gives. + let mut g = Gen(0x2545_f491_4f6c_dd1d); + for _ in 0..300 { + let mut cmap = String::from("1 begincodespacerange <0000> endcodespacerange "); + let mut expected: HashMap = HashMap::new(); + for _ in 0..1 + g.below(12) { + match g.below(3) { + 0 => { + let (code, u) = (g.below(300), g.units()); + cmap += &format!("1 beginbfchar <{code:04X}> <{}> endbfchar ", hex(&u)); + expected.insert(code, String::from_utf16_lossy(&u)); + } + 1 => { + let (lo, width, u) = (g.below(300), g.below(13), g.units()); + cmap += &format!("1 beginbfrange <{lo:04X}> <{:04X}> <{}> endbfrange ", lo + width, hex(&u)); + for k in 0..=width { + let mut v = u.clone(); + if let Some(last) = v.last_mut() { + *last = last.wrapping_add(k as u16); + } + expected.insert(lo + k, String::from_utf16_lossy(&v)); + } + } + _ => { + let (lo, width) = (g.below(300), g.below(5)); + let dests: Vec> = (0..=width).map(|_| g.units()).collect(); + let list: Vec = dests.iter().map(|d| format!("<{}>", hex(d))).collect(); + cmap += &format!("1 beginbfrange <{lo:04X}> <{:04X}> [{}] endbfrange ", lo + width, list.join(" ")); + for (k, d) in dests.iter().enumerate() { + expected.insert(lo + k as u32, String::from_utf16_lossy(d)); + } + } + } + } + let mut doc = Document::new_empty(); + let tu = doc.add(Object::Stream(Stream::from_raw(Dict::new(), cmap.clone().into_bytes()))); + let f = font( + &mut doc, + vec![ + ("Subtype", Object::name("Type0")), + ("BaseFont", Object::name("Gen")), + ("Encoding", Object::name("Identity-H")), + ("ToUnicode", Object::Ref(tu)), + ], + ); + let m = Metrics::from_dict(&doc, &f); + for code in 0..=320u32 { + let got = m.text_of(code).map(|t| t.into_owned()); + assert_eq!(got, expected.get(&code).cloned(), "code {code:#06x} in {cmap}"); + } + let mut reverse: HashMap = HashMap::new(); + // Code 0 is CID 0, `.notdef`, which no character is encoded with. + for (&code, text) in expected.iter().filter(|&(&code, _)| code != 0) { + if let Some(c) = only_char(text) { + keep(&mut reverse, c, code); + } + } + let mut chars: Vec = expected.values().flat_map(|t| t.chars()).chain(['A', '\u{FFFD}', '\u{0}', '\u{1D400}']).collect(); + chars.sort_unstable(); + chars.dedup(); + for c in chars { + let want = reverse.get(&c).map(|code| code.to_be_bytes()[2..].to_vec()); + assert_eq!(m.encode(&c.to_string()), want, "char {c:?} in {cmap}"); + } + } +} + +#[test] +fn fffd_lookups_skip_overridden_surrogates() { + // Each record maps every code to itself, so its surrogate offsets decode to U+FFFD. The last + // record overrides the surrogates with letters, so each lookup walks past them to U+FFFD itself. + let mut cmap = "1 beginbfrange <0000> <0000> endbfrange ".repeat(300); + cmap.push_str("1 beginbfrange <0041> endbfrange"); + let mut doc = Document::new_empty(); + let tu = doc.add(Object::Stream(Stream::from_raw(Dict::new(), cmap.into_bytes()))); + let f = font( + &mut doc, + vec![ + ("Subtype", Object::name("Type0")), + ("BaseFont", Object::name("Gen")), + ("Encoding", Object::name("Identity-H")), + ("ToUnicode", Object::Ref(tu)), + ], + ); + let m = Metrics::from_dict(&doc, &f); + assert_eq!(m.encode("\u{FFFD}"), Some(vec![0xFF, 0xFD])); +} + +#[test] +fn hostile_ranges_stay_bounded() { + // A range wider than 65 536 codes is ignored, a reversed one maps nothing, and a maximal + // one-code range maps just its own code. + let mut u = Unicode::default(); + let cmap = b"1 beginbfrange <00000000> <0041> endbfrange \ + 1 beginbfrange <0005> <0001> <0041> endbfrange \ + 1 beginbfrange endbfrange"; + parse_to_unicode(cmap, &mut u); + assert_eq!((u.ranges.len(), u.text(u32::MAX).as_deref()), (0, Some("\u{FFFF}"))); +} + +#[test] +fn low_codes_resolve_to_the_covering_range() { + let mut u = Unicode::default(); + let cmap = b"1 beginbfrange <0005> <0007> <0041> endbfrange 1 beginbfrange <0064> <0065> <0042> endbfrange \ + 1 beginbfrange <0000FFFE> <00010001> <0043> endbfrange"; + parse_to_unicode(cmap, &mut u); + let covered: Vec = (0..0x70).filter(|&c| u.covering(c).is_some()).collect(); + assert_eq!(covered, [5, 6, 7, 100, 101]); + assert_eq!((u.covering(0xFFFF).is_some(), u.covering(0x1_0001).is_some(), u.covering(0x1_0002).is_none()), (true, true, true)); +} diff --git a/crates/forms/src/actions.rs b/crates/forms/src/actions.rs index 5eb57c6c1b..5ebe3269cc 100644 --- a/crates/forms/src/actions.rs +++ b/crates/forms/src/actions.rs @@ -262,17 +262,21 @@ fn write(doc: &mut Document, a: &FieldAction, pages: &[pdfcraft_cos::ObjRef]) -> /// Rewrite one trigger of a widget to `new` (`None` clears it), given the `old` value read from /// the widget. `aa` and `a_entry` are the widget's `/AA` dict and `/A` entry, updated in place. -#[allow(clippy::too_many_arguments)] +struct TriggerUpdate<'a> { + trigger: Trigger, + replacement: Option<&'a FieldAction>, + previous: Option, +} + fn replace_trigger( doc: &mut Document, pages: &[pdfcraft_cos::ObjRef], scripts: &mut pdfcraft_cos::DecodeBudget, - t: Trigger, - new: Option<&FieldAction>, - old: Option, + update: TriggerUpdate<'_>, aa: &mut Dict, a_entry: &mut Option, ) -> Result<(), FormError> { + let TriggerUpdate { trigger: t, replacement: new, previous: old } = update; let old_action = old.as_ref().and_then(|o| read(doc, o, pages, scripts)); if new.is_some() && new == old_action.as_ref() { return Ok(()); @@ -322,7 +326,7 @@ pub fn set_field_actions(doc: &mut Document, name: &str, actions: &[(Trigger, Fi None => wd.get(b"A").cloned().or_else(|| aa.get(b"U").cloned()), Some(k) => aa.get(k).cloned(), }; - replace_trigger(doc, &pages, &mut scripts, t, new, old, &mut aa, &mut a_entry)?; + replace_trigger(doc, &pages, &mut scripts, TriggerUpdate { trigger: t, replacement: new, previous: old }, &mut aa, &mut a_entry)?; } doc.update_dict(w.obj, |d| { match &a_entry { diff --git a/crates/forms/src/af.rs b/crates/forms/src/af.rs index 3119481cff..ee4aa18e3c 100644 --- a/crates/forms/src/af.rs +++ b/crates/forms/src/af.rs @@ -13,6 +13,17 @@ use std::collections::VecDeque; use std::fmt::Write as _; use std::ops::Range; +mod calc; +mod format; +mod script; + +#[cfg(test)] +use calc::{Notation, eval_notation, notation_name, notation_tokens, unique}; +pub use calc::{calculate, calculation_reads, notation_js, rename_in_calculation}; +pub use format::{DATE_PRESETS, DateTime, TIME_PRESETS, format_date, format_value, js_number, keystroke, make_number, parse_date, validate}; +use script::{Arg, call}; +pub use script::{calculate_js, format_js, parse_calculate, parse_format, parse_validate, validate_js}; + /// The Format tab. #[derive(Clone, Debug, PartialEq, Default)] pub enum Format { @@ -112,1699 +123,8 @@ impl Actions { } } -// ── parsing scripts ───────────────────────────────────────────────────────────────────────── - -#[derive(Clone, Debug, PartialEq)] -enum Arg { - Num(f64), - Str(String), - Bool(bool), - List(Vec), -} - -impl Arg { - fn num(&self) -> Option { - match self { - Arg::Num(n) => Some(*n), - Arg::Bool(b) => Some(f64::from(u8::from(*b))), - Arg::Str(s) => s.trim().parse().ok(), - Arg::List(_) => None, - } - } - - fn string(&self) -> Option { - match self { - Arg::Str(s) => Some(s.clone()), - Arg::Num(n) => Some(n.to_string()), - _ => None, - } - } - - fn truthy(&self) -> bool { - match self { - Arg::Bool(b) => *b, - Arg::Num(n) => *n != 0.0, - Arg::Str(s) => !s.is_empty(), - Arg::List(l) => !l.is_empty(), - } - } -} - -/// How deeply script arguments and field notation may nest; deeper input is rejected rather -/// than recursed into (a hostile file could otherwise overflow the stack). -const MAX_NESTING: usize = 64; - -fn hex4(bytes: &[u8]) -> Option { - if bytes.len() != 4 { - return None; - } - bytes.iter().try_fold(0u16, |value, byte| { - let digit = match byte { - b'0'..=b'9' => byte - b'0', - b'a'..=b'f' => byte - b'a' + 10, - b'A'..=b'F' => byte - b'A' + 10, - _ => return None, - }; - value.checked_mul(16)?.checked_add(u16::from(digit)) - }) -} - -struct Parser<'a> { - s: &'a [u8], - i: usize, - depth: usize, -} - -impl Parser<'_> { - fn ws(&mut self) { - while self.i < self.s.len() && (self.s[self.i].is_ascii_whitespace()) { - self.i += 1; - } - } - - fn string(&mut self) -> Option { - let q = self.s[self.i]; - self.i += 1; - let mut out = Vec::new(); - while self.i < self.s.len() { - let c = self.s[self.i]; - self.i += 1; - match c { - b'\\' if self.i < self.s.len() => { - let e = self.s[self.i]; - self.i += 1; - match e { - b'n' => out.push(b'\n'), - b'r' => out.push(b'\r'), - b't' => out.push(b'\t'), - b'b' => out.push(0x08), - b'f' => out.push(0x0C), - b'v' => out.push(0x0B), - b'0' => out.push(0), - b'x' => { - let digits = self.s.get(self.i..self.i.checked_add(2)?)?; - let code = hex4(&[b'0', b'0', digits[0], digits[1]])?; - self.i += 2; - let ch = char::from_u32(u32::from(code)).unwrap_or('\u{FFFD}'); - let mut encoded = [0u8; 4]; - out.extend_from_slice(ch.encode_utf8(&mut encoded).as_bytes()); - } - b'u' => { - let code = hex4(self.s.get(self.i..self.i.checked_add(4)?)?)?; - self.i += 4; - let code = if (0xD800..=0xDBFF).contains(&code) && self.s.get(self.i..self.i.checked_add(6)?)?.starts_with(b"\\u") { - let low = hex4(self.s.get(self.i.checked_add(2)?..self.i.checked_add(6)?)?)?; - if (0xDC00..=0xDFFF).contains(&low) { - self.i += 6; - 0x10000 + ((u32::from(code) - 0xD800) << 10) + (u32::from(low) - 0xDC00) - } else { - u32::from(code) - } - } else { - u32::from(code) - }; - let ch = char::from_u32(code).unwrap_or('\u{FFFD}'); - let mut encoded = [0u8; 4]; - out.extend_from_slice(ch.encode_utf8(&mut encoded).as_bytes()); - } - other => out.push(other), - } - } - c if c == q => return Some(String::from_utf8_lossy(&out).into_owned()), - c => out.push(c), - } - } - None - } - - fn arg(&mut self) -> Option { - self.ws(); - let c = *self.s.get(self.i)?; - match c { - b'"' | b'\'' => self.string().map(Arg::Str), - b'[' => { - self.i += 1; - self.nested(b']').map(Arg::List) - } - _ if self.s[self.i..].starts_with(b"new Array") => { - self.i += b"new Array".len(); - self.ws(); - if self.s.get(self.i) != Some(&b'(') { - return None; - } - self.i += 1; - self.nested(b')').map(Arg::List) - } - _ if self.s[self.i..].starts_with(b"true") => { - self.i += 4; - Some(Arg::Bool(true)) - } - _ if self.s[self.i..].starts_with(b"false") => { - self.i += 5; - Some(Arg::Bool(false)) - } - _ => { - let start = self.i; - while self.i < self.s.len() && (self.s[self.i].is_ascii_digit() || matches!(self.s[self.i], b'.' | b'-' | b'+' | b'e' | b'E')) { - self.i += 1; - } - std::str::from_utf8(&self.s[start..self.i]).ok()?.parse().ok().map(Arg::Num) - } - } - } - - /// A list inside another, at most [`MAX_NESTING`] deep. - fn nested(&mut self, close: u8) -> Option> { - if self.depth >= MAX_NESTING { - return None; - } - self.depth += 1; - let out = self.list(close); - self.depth -= 1; - out - } - - fn list(&mut self, close: u8) -> Option> { - let mut out = Vec::new(); - loop { - self.ws(); - if self.s.get(self.i) == Some(&close) { - self.i += 1; - return Some(out); - } - out.push(self.arg()?); - self.ws(); - match self.s.get(self.i) { - Some(b',') => self.i += 1, - Some(c) if *c == close => { - self.i += 1; - return Some(out); - } - _ => return None, - } - } - } -} - -/// The first call to `name(` in `js` and its arguments. -fn call(js: &str, name: &str) -> Option> { - let at = js.find(&format!("{name}("))?; - // Not part of a longer identifier (AFDate_Format vs AFDate_FormatEx). - if js[..at].chars().last().is_some_and(|c| c.is_alphanumeric() || c == '_') { - return None; - } - let mut p = Parser { s: js.as_bytes(), i: at + name.len() + 1, depth: 0 }; - p.list(b')') -} - -/// Acrobat's AFDate_Format(n) / AFTime_Format(n) presets. -pub const DATE_PRESETS: [&str; 14] = [ - "m/d", - "m/d/yy", - "mm/dd/yy", - "mm/yy", - "d-mmm", - "d-mmm-yy", - "dd-mmm-yy", - "yy-mm-dd", - "mmm-yy", - "mmmm-yy", - "mmm d, yyyy", - "mmmm d, yyyy", - "m/d/yy h:MM tt", - "m/d/yy HH:MM", -]; -pub const TIME_PRESETS: [&str; 4] = ["HH:MM", "h:MM tt", "HH:MM:ss", "h:MM:ss tt"]; - -pub fn parse_format(js: &str) -> Option { - let a = |args: &[Arg], i: usize| args.get(i).and_then(Arg::num).unwrap_or(0.0).clamp(0.0, 255.0) as u8; - if let Some(args) = call(js, "AFNumber_Format") { - return Some(Format::Number { - decimals: a(&args, 0), - sep: a(&args, 1), - neg: a(&args, 2), - currency: args.get(4).and_then(Arg::string).unwrap_or_default(), - prepend: args.get(5).is_none_or(Arg::truthy), - }); - } - if let Some(args) = call(js, "AFPercent_Format") { - return Some(Format::Percent { decimals: a(&args, 0), sep: a(&args, 1) }); - } - if let Some(args) = call(js, "AFDate_FormatEx") { - return Some(Format::Date(args.first().and_then(Arg::string)?)); - } - if let Some(args) = call(js, "AFDate_Format") { - return Some(Format::Date(DATE_PRESETS.get(a(&args, 0) as usize)?.to_string())); - } - if let Some(args) = call(js, "AFTime_FormatEx") { - return Some(Format::Time(args.first().and_then(Arg::string)?)); - } - if let Some(args) = call(js, "AFTime_Format") { - return Some(Format::Time(TIME_PRESETS.get(a(&args, 0) as usize)?.to_string())); - } - if let Some(args) = call(js, "AFSpecial_Format") { - return Some(Format::Special(a(&args, 0).min(3))); - } - if let Some(args) = call(js, "AFSpecial_KeystrokeEx") { - return Some(Format::Mask(args.first().and_then(Arg::string)?)); - } - None -} - -pub fn parse_validate(js: &str) -> Option { - let args = call(js, "AFRange_Validate")?; - let flag = |i: usize| args.get(i).is_some_and(Arg::truthy); - let num = |i: usize| args.get(i).and_then(Arg::num); - Some(Validate::Range { min: if flag(0) { num(1) } else { None }, max: if flag(2) { num(3) } else { None } }) -} - -pub fn parse_calculate(js: &str) -> Option { - // Simplified field notation: Acrobat keeps the expression in a comment. A script is read for - // its comments only when it spells the marker. - if js.contains("BVCALC") - && let Some(range) = notation_range(js, &js_tokens(js).1) - { - return Some(Calculate::Notation(js.get(range)?.trim().to_string())); - } - let args = call(js, "AFSimple_Calculate")?; - let op = match args.first().and_then(Arg::string)?.as_str() { - "SUM" => CalcOp::Sum, - "PRD" => CalcOp::Product, - "AVG" => CalcOp::Average, - "MIN" => CalcOp::Minimum, - "MAX" => CalcOp::Maximum, - _ => return None, - }; - let fields = match args.get(1)? { - Arg::List(l) => l.iter().filter_map(Arg::string).collect(), - Arg::Str(s) => s.split(',').map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).collect(), - _ => return None, - }; - Some(Calculate::Simple { op, fields }) -} - -// ── writing scripts (what Acrobat's tabs generate) ───────────────────────────────────────── - -fn js_str(s: &str) -> String { - let mut o = String::from("\""); - for c in s.chars() { - match c { - '"' | '\\' => { - o.push('\\'); - o.push(c); - } - '\n' => o.push_str("\\n"), - '\r' => o.push_str("\\r"), - '\t' => o.push_str("\\t"), - c if c.is_ascii() && !c.is_control() => o.push(c), - c => { - let mut units = [0u16; 2]; - for unit in c.encode_utf16(&mut units) { - o.push_str(&format!("\\u{unit:04X}")); - } - } - } - } - o.push('"'); - o -} - -/// The (format, keystroke) scripts for a format. -pub fn format_js(f: &Format) -> Option<(String, String)> { - Some(match f { - Format::None => return None, - Format::Number { decimals, sep, neg, currency, prepend } => { - let args = format!("{decimals}, {sep}, {neg}, 0, {}, {prepend}", js_str(currency)); - (format!("AFNumber_Format({args});"), format!("AFNumber_Keystroke({args});")) - } - Format::Percent { decimals, sep } => (format!("AFPercent_Format({decimals}, {sep});"), format!("AFPercent_Keystroke({decimals}, {sep});")), - Format::Date(f) => (format!("AFDate_FormatEx({});", js_str(f)), format!("AFDate_KeystrokeEx({});", js_str(f))), - Format::Time(f) => (format!("AFTime_FormatEx({});", js_str(f)), format!("AFTime_KeystrokeEx({});", js_str(f))), - Format::Special(n) => (format!("AFSpecial_Format({n});"), format!("AFSpecial_Keystroke({n});")), - Format::Mask(m) => (String::new(), format!("AFSpecial_KeystrokeEx({});", js_str(m))), - }) -} - -pub fn validate_js(v: &Validate) -> Option { - match v { - Validate::None => None, - Validate::Range { min, max } => { - Some(format!("AFRange_Validate({}, {}, {}, {});", min.is_some(), min.unwrap_or(0.0), max.is_some(), max.unwrap_or(0.0))) - } - } -} - -pub fn calculate_js(c: &Calculate) -> Option { - match c { - Calculate::None => None, - Calculate::Simple { op, fields } => Some(format!( - "AFSimple_Calculate({}, new Array({}));", - js_str(op.code()), - fields.iter().map(|f| js_str(f)).collect::>().join(", ") - )), - // Acrobat's own translation follows the comment, so a viewer that runs the script instead - // of reading the comment computes the same value (a placeholder here showed 0). - Calculate::Notation(expr) => Some(format!("/** BVCALC {expr} EVCALC **/ event.value = {};", notation_js(expr))), - } -} - -// ── renaming fields in calculations ───────────────────────────────────────────────────────── - -/// A JavaScript token, as far as finding the field names a calculation reads needs. -#[derive(Clone, Debug, PartialEq)] -enum Js { - Ident, - /// A string literal, its escapes undone. - Str(String), - Punct(char), - /// A number, a regular expression or a template literal. - Other, -} - -/// A [`Js`] token with its byte range in the script. -type Token = (Range, Js); - -/// Words after which `/` starts a regular expression instead of dividing. -const BEFORE_REGEX: [&str; 13] = ["return", "typeof", "case", "do", "else", "in", "of", "new", "delete", "void", "throw", "instanceof", "yield"]; - -/// Words whose condition, in parentheses, comes before a statement, so a `/` after it starts a -/// regular expression. The `while` that ends a `do` loop counts too: the loop ends at its `)`, so -/// a `/` after it starts the next statement, and can't divide. -const BEFORE_CONDITION: [&str; 4] = ["if", "while", "for", "with"]; - -/// Whether a line ends at `s[j]`: JavaScript ends one at `\n`, `\r`, U+2028 and U+2029. -fn line_ends_at(s: &[u8], j: usize) -> bool { - matches!(s.get(j..), Some([b'\n' | b'\r', ..] | [0xE2, 0x80, 0xA8 | 0xA9, ..])) -} - -/// JavaScript's tokens with their byte ranges, and apart from them the text of each comment -/// (inside its `//`, or its `/*` and `*/`): enough of the language to tell code from strings, -/// comments and regular expressions. The tokens stop where a string, comment or template literal -/// never ends; a comment that never ends runs to the end of the script. -fn js_tokens(js: &str) -> (Vec, Vec>) { - let s = js.as_bytes(); - // Whether `s[j]` is part of a word. Bytes past ASCII are parts of identifiers, except - // U+2028 and U+2029, which end a line: a range never ends inside a character. - let word = |j: usize| s.get(j).is_some_and(|&b| b.is_ascii_alphanumeric() || b == b'_' || b == b'$' || !b.is_ascii()) && !line_ends_at(s, j); - let mut out: Vec = Vec::new(); - let mut comments = Vec::new(); - let mut i = 0; - // Where a regular expression that never ended stopped being read: every `/` before it - // divides, so a line of them is read once, not once for each. - let mut divides_until = 0; - // For each `(` still open, whether it opened the condition of `if`, `while`, `for` or - // `with`; and whether the last token is the `)` that closed one. - let mut conditions: Vec = Vec::new(); - let mut after_condition = false; - 'scan: while let Some(&c) = s.get(i) { - let start = i; - let token = match c { - _ if c.is_ascii_whitespace() => { - i += 1; - continue; - } - // U+2028 or U+2029 between tokens ends a line, and is skipped whole: read a byte at a - // time, the rest of it would be read from inside the character. - 0xE2 if line_ends_at(s, i) => { - i += 3; - continue; - } - b'/' if s.get(i + 1) == Some(&b'/') => { - i = (i + 2..s.len()).find(|&j| line_ends_at(s, j)).unwrap_or(s.len()); - comments.push(start + 2..i); - continue; - } - b'/' if s.get(i + 1) == Some(&b'*') => match js.get(i + 2..).and_then(|rest| rest.find("*/")) { - Some(n) => { - comments.push(i + 2..i + 2 + n); - i += n + 4; - continue; - } - None => { - comments.push(i + 2..s.len()); - break 'scan; - } - }, - b'"' | b'\'' => { - let mut p = Parser { s, i, depth: 0 }; - let Some(text) = p.string() else { break 'scan }; - i = p.i; - Js::Str(text) - } - b'`' => { - let mut j = i + 1; - loop { - match s.get(j) { - None => break 'scan, - Some(b'\\') => j += 2, - Some(b'`') => break, - Some(_) => j += 1, - } - } - i = j + 1; - Js::Other - } - b'/' if i >= divides_until && regex_may_follow(js, &out, after_condition) => { - // A regular expression ends at a `/` outside a class; a line break first means it - // was a division after all. - let (mut j, mut class) = (i + 1, false); - let end = loop { - match s.get(j) { - None => break None, - Some(_) if line_ends_at(s, j) => break None, - Some(b'\\') => j += 2, - Some(b'/') if !class => break Some(j), - Some(b) => { - class = match b { - b'[' => true, - b']' => false, - _ => class, - }; - j += 1; - } - } - }; - match end { - Some(e) => { - i = e + 1; - while word(i) { - i += 1; - } - Js::Other - } - None => { - divides_until = j; - i += 1; - Js::Punct('/') - } - } - } - _ if c.is_ascii_digit() => { - while word(i) || s.get(i) == Some(&b'.') { - i += 1; - } - Js::Other - } - _ if word(i) => { - while word(i) { - i += 1; - } - Js::Ident - } - _ => { - i += 1; - Js::Punct(char::from(c)) - } - }; - after_condition = match token { - Js::Punct('(') => { - conditions.push(opens_condition(js, &out)); - false - } - Js::Punct(')') => conditions.pop().unwrap_or(false), - _ => false, - }; - out.push((start..i, token)); - } - (out, comments) -} - -/// Whether a `/` after `tokens` starts a regular expression: after an operator, an opening -/// bracket, a keyword such as `return` or the condition of `if`, `while`, `for` or `with`, not -/// after a value. `after_condition` says the last token is the `)` that ends such a condition. -fn regex_may_follow(js: &str, tokens: &[Token], after_condition: bool) -> bool { - match tokens.last() { - None => true, - Some((range, Js::Ident)) => js.get(range.clone()).is_some_and(|w| BEFORE_REGEX.contains(&w)), - Some((_, Js::Punct(')'))) => after_condition, - Some((_, Js::Punct(c))) => !matches!(c, ']' | '}'), - Some((_, Js::Str(_) | Js::Other)) => false, - } -} - -/// Whether a `(` after `tokens` opens the condition of `if`, `while`, `for` or `with`, and not -/// the arguments of a method that has one of those names (`o.if(x)`). -fn opens_condition(js: &str, tokens: &[Token]) -> bool { - let mut back = tokens.iter().rev(); - let Some((range, Js::Ident)) = back.next() else { return false }; - js.get(range.clone()).is_some_and(|w| BEFORE_CONDITION.contains(&w)) && !matches!(back.next(), Some((_, Js::Punct('.')))) -} - -/// Where a Calculate script keeps its simplified field notation: between `BVCALC` and the -/// `EVCALC` after it, in the first of `comments` that holds both, as Acrobat writes it -/// (`/** BVCALC a + b EVCALC **/`). A string or code that spells the markers is not the notation. -fn notation_range(js: &str, comments: &[Range]) -> Option> { - comments.iter().find_map(|comment| { - let text = js.get(comment.clone())?; - let a = text.find("BVCALC")? + 6; - let b = a + text.get(a..)?.find("EVCALC")?; - Some(comment.start + a..comment.start + b) - }) -} - -/// A place where a Calculate script names a field. -#[derive(Clone, Debug, PartialEq)] -enum Site { - /// A string literal holding one name: `getField("a")`, or an item of an - /// `AFSimple_Calculate` list. - Literal(String), - /// An `AFSimple_Calculate` list written as one string of comma-separated names. - Commas(String), - /// A name in the simplified field notation of a `BVCALC` comment. - Notation(String), -} - -impl Site { - fn names(&self) -> Vec<&str> { - match self { - Site::Literal(name) | Site::Notation(name) => vec![name.as_str()], - Site::Commas(list) => list.split(',').map(str::trim).filter(|n| !n.is_empty()).collect(), - } - } - - /// The site written again with field `old` named `new`, or `None` when it doesn't name `old`. - fn renamed(&self, old: &str, new: &str) -> Option { - match self { - Site::Literal(name) => (name == old).then(|| js_str(new)), - Site::Notation(name) => (name == old).then(|| notation_name(new)), - Site::Commas(list) => { - let parts: Vec<&str> = list.split(',').collect(); - parts.iter().any(|p| p.trim() == old).then(|| { - // The spaces around each name stay as they were. - let parts: Vec = parts.iter().map(|p| if p.trim() == old { p.replacen(old, new, 1) } else { (*p).to_owned() }).collect(); - js_str(&parts.join(",")) - }) - } - } - } -} - -/// Where a Calculate script names fields, in order: the simplified field notation in its comment -/// (see [`notation_range`]; [`parse_calculate`] reads it there too), the field list of -/// `AFSimple_Calculate`, and every `getField` whose whole argument is one string literal. -/// A name built at run time (`getField("row" + i)`) is not a site. -fn calculation_sites(js: &str) -> Vec<(Range, Site)> { - let mut sites = Vec::new(); - let (tokens, comments) = js_tokens(js); - if let Some(at) = notation_range(js, &comments) - && let Some(expr) = js.get(at.clone()) - { - for (range, token) in notation_tokens(expr) { - if let Notation::Name(name) = token { - sites.push((at.start + range.start..at.start + range.end, Site::Notation(name))); - } - } - } - let ident = |k: usize, word: &str| tokens.get(k).is_some_and(|(range, t)| *t == Js::Ident && js.get(range.clone()) == Some(word)); - let punct = |k: usize, c: char| tokens.get(k).is_some_and(|(_, t)| *t == Js::Punct(c)); - let string = |k: usize| match tokens.get(k) { - Some((range, Js::Str(text))) => Some((range.clone(), text.clone())), - _ => None, - }; - for k in 0..tokens.len() { - if ident(k, "getField") - && punct(k + 1, '(') - && punct(k + 3, ')') - && let Some((range, name)) = string(k + 2) - { - sites.push((range, Site::Literal(name))); - } - if !(ident(k, "AFSimple_Calculate") && punct(k + 1, '(') && string(k + 2).is_some() && punct(k + 3, ',')) { - continue; - } - if let Some((range, list)) = string(k + 4) { - sites.push((range, Site::Commas(list))); - continue; - } - // `new Array("a", "b")` or `["a", "b"]`: its items, up to the first that isn't a string. - // Nothing else is read, so no token is read for two calls. - let first = if punct(k + 4, '[') { - k + 5 - } else if ident(k + 4, "new") && ident(k + 5, "Array") && punct(k + 6, '(') { - k + 7 - } else { - continue; - }; - for (range, token) in tokens.iter().skip(first) { - match token { - Js::Str(name) => sites.push((range.clone(), Site::Literal(name.clone()))), - Js::Punct(',') => {} - _ => break, - } - } - } - sites.sort_by_key(|(range, _)| range.start); - sites -} - -/// A Calculate script with field `old` named `new` everywhere it reads it (see -/// [`calculation_sites`]), the rest kept as written; `None` when it doesn't read `old`. Also -/// `None` when the new name would read as more than a name there (one holding `EVCALC` would end -/// the notation early), so a script is never left meaning something else. -pub fn rename_in_calculation(js: &str, old: &str, new: &str) -> Option { - let sites = calculation_sites(js); - let edits: Vec<(Range, String)> = sites.iter().filter_map(|(range, site)| Some((range.clone(), site.renamed(old, new)?))).collect(); - if edits.is_empty() { - return None; - } - let out = splice(js, edits); - let expected: Vec<&str> = sites.iter().flat_map(|(_, site)| site.names()).map(|n| if n == old { new } else { n }).collect(); - let written = calculation_sites(&out); - let got: Vec<&str> = written.iter().flat_map(|(_, site)| site.names()).collect(); - (got == expected).then_some(out) -} - -/// The fields a Calculate script reads by name (see [`calculation_sites`]), each once, in the -/// order it first names them. -pub fn calculation_reads(js: &str) -> Vec { - let sites = calculation_sites(js); - unique(sites.iter().flat_map(|(_, site)| site.names()).map(str::to_owned)) -} - -impl Calculate { - /// The fields it reads, each once, in the order it first names them. A name also stands for - /// the fields under it (`row` for `row.0` and `row.1`), as [`calculate`] reads them. - pub fn reads(&self) -> Vec { - match self { - Calculate::None => Vec::new(), - Calculate::Simple { fields, .. } => unique(fields.iter().cloned()), - Calculate::Notation(expr) => unique(notation_tokens(expr).into_iter().filter_map(|(_, token)| match token { - Notation::Name(name) => Some(name), - _ => None, - })), - } - } -} - -/// `names` without repeats, in order. -fn unique(names: impl IntoIterator) -> Vec { - let mut seen = std::collections::HashSet::new(); - names.into_iter().filter(|n| seen.insert(n.clone())).collect() -} - -// ── running them ──────────────────────────────────────────────────────────────────────────── - -/// AFMakeNumber: a number from typed text (currency symbols, group separators, spaces and a -/// trailing % ignored; a comma decimal separator accepted). -pub fn make_number(s: &str) -> Option { - let t: String = s.trim().chars().filter(|c| !c.is_whitespace() && !matches!(c, '$' | '€' | '£' | '¥' | '\'' | '%')).collect(); - if t.is_empty() { - return None; - } - let neg = t.starts_with('(') && t.ends_with(')'); - let t = t.trim_start_matches('(').trim_end_matches(')'); - // "1.234,56" or "1234,56": comma is the decimal separator when it comes last. - let normalized = match (t.rfind(','), t.rfind('.')) { - (Some(c), Some(d)) if c > d => t.replace('.', "").replace(',', "."), - (Some(c), None) if t.len() - c - 1 != 3 => t.replace(',', "."), - _ => t.replace(',', ""), - }; - let v: f64 = normalized.parse().ok()?; - v.is_finite().then_some(if neg { -v } else { v }) -} - -/// A number as JavaScript prints it (integers without a fraction). -pub fn js_number(v: f64) -> String { - if v.fract() == 0.0 && v.abs() < 1e15 { format!("{}", v as i64) } else { format!("{v}") } -} - -fn group(int: &str, sep: char) -> String { - let mut out = String::new(); - for (i, c) in int.chars().enumerate() { - if i > 0 && (int.len() - i).is_multiple_of(3) { - out.push(sep); - } - out.push(c); - } - out -} - -/// A number with `decimals` places in separator style `sep` (0 "1,234.56", 1 "1234.56", -/// 2 "1.234,56", 3 "1234,56", 4 "1'234.56"). -fn number_text(v: f64, decimals: u8, sep: u8) -> String { - // Round half away from zero, as JavaScript's toFixed does for these values. - let p = 10f64.powi(i32::from(decimals)); - let scaled = v.abs() * p; - // Past 2^53 every value is already whole, and scaling could overflow to infinity. - let r = if scaled.is_finite() { scaled.round() / p } else { v.abs() }; - let s = format!("{:.*}", decimals as usize, r); - let (int, frac) = s.split_once('.').map_or((s.as_str(), ""), |(a, b)| (a, b)); - let (g, d) = match sep { - 1 => (None, '.'), - 2 => (Some('.'), ','), - 3 => (None, ','), - 4 => (Some('\''), '.'), - _ => (Some(','), '.'), - }; - let mut out = match g { - Some(c) => group(int, c), - None => int.to_string(), - }; - if !frac.is_empty() { - out.push(d); - out.push_str(frac); - } - out -} - -const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]; -const DAYS: [&str; 7] = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; - -/// A date and time read from text. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct DateTime { - pub y: i32, - pub m: u32, - pub d: u32, - pub hh: u32, - pub mm: u32, - pub ss: u32, -} - -fn days_in(y: i32, m: u32) -> u32 { - match m { - 2 if (y % 4 == 0 && y % 100 != 0) || y % 400 == 0 => 29, - 2 => 28, - 4 | 6 | 9 | 11 => 30, - _ => 31, - } -} - -fn weekday(y: i32, m: u32, d: u32) -> usize { - // Sakamoto's method, in i64 so no year or day can overflow it. - const T: [i64; 12] = [0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4]; - let y = i64::from(y) - i64::from(m < 3); - let t = T.get((m as usize).wrapping_sub(1)).copied().unwrap_or(0); - (y + y / 4 - y / 100 + y / 400 + t + i64::from(d)).rem_euclid(7) as usize -} - -/// One field of a date format, in the order the format has them. -struct Part { - /// `'y'`, `'m'`, `'d'`, `'H'`, `'M'` or `'s'`; a run of literal digits (the fixed year in - /// "dd.mm.26") is `'#'`. - kind: char, - /// The value of a `'#'` run; 0 for the others. - literal: u32, - /// The digits the field takes when typed run together with the next one: 4 for "yyyy", 2 for - /// "yy", "mm", "dd", "HH", "MM" and "ss". 0 when the width varies ("m", "d") or the field is - /// a name ("mmm"). - width: usize, - /// The next field follows with nothing between them ("yyyymmdd"), so one run of typed digits - /// can hold both. - joined: bool, -} - -/// The fields of a format (year, month, day, hours, minutes, seconds and literal digits), in -/// order. -fn order(fmt: &str) -> Vec { - let mut out: Vec = Vec::new(); - // Weekday names (ddd, dddd) are shown, not typed: leave them out. - let mut cleaned = fmt.replace("dddd", " "); - cleaned = cleaned.replace("ddd", " "); - let mut prev = ' '; - // The letter of the last field's current run and its length so far. - let mut run: Option<(char, usize)> = None; - let fixed = |k: char, n: usize| match (k, n) { - ('y', 2 | 4) | ('m' | 'd' | 'H' | 'M' | 's', 2) => n, - _ => 0, - }; - for c in cleaned.chars() { - let continues = run.is_some_and(|(r, _)| r == c); - if let Some((r, n)) = run - && !continues - { - // The run ended; it was the last field's width. - if let Some(last) = out.last_mut().filter(|p| p.kind != '#') { - last.width = fixed(if r == 'h' { 'H' } else { r }, n); - } - run = None; - } - if let Some(digit) = c.to_digit(10) { - match out.last_mut() { - Some(p) if p.kind == '#' && prev.is_ascii_digit() => p.literal = p.literal.saturating_mul(10).saturating_add(digit), - _ => out.push(Part { kind: '#', literal: digit, width: 0, joined: false }), - } - prev = c; - continue; - } - let k = match c { - 'y' => 'y', - 'm' => 'm', - 'd' => 'd', - 'H' | 'h' => 'H', - 'M' => 'M', - 's' => 's', - _ => { - prev = c; - continue; - } - }; - if continues { - run = run.map(|(r, n)| (r, n.saturating_add(1))); - } else { - // A field starting right where the last one's letters stopped is joined to it. - let adjacent = matches!(prev, 'y' | 'm' | 'd' | 'H' | 'h' | 'M' | 's'); - if out.last().map(|p| p.kind) != Some(k) { - if let Some(last) = out.last_mut().filter(|p| p.kind != '#') { - last.joined = adjacent; - } - out.push(Part { kind: k, literal: 0, width: 0, joined: false }); - } - run = Some((c, 1)); - } - prev = c; - } - if let (Some((r, n)), Some(last)) = (run, out.last_mut().filter(|p| p.kind != '#')) { - last.width = fixed(if r == 'h' { 'H' } else { r }, n); - } - out -} - -/// Parse typed text against a date/time format (AFDate_KeystrokeEx's leniency: any -/// separators, month names, two-digit years). `None` when it isn't a valid date. -pub fn parse_date(text: &str, fmt: &str) -> Option { - let t = text.trim(); - if t.is_empty() { - return None; - } - let lower = t.to_lowercase(); - // A bare trailing "p" ("5:30p") only counts when it follows a digit, not a word like "Sep". - let bare_p = lower.strip_suffix('p').is_some_and(|rest| rest.trim_end().ends_with(|c: char| c.is_ascii_digit())); - let pm = lower.contains("pm") || bare_p; - let bare_a = lower.strip_suffix('a').is_some_and(|rest| rest.trim_end().ends_with(|c: char| c.is_ascii_digit())); - let am = lower.contains("am") || bare_a; - // Tokens: runs of digits (kept as text, so a run can be split between fields typed without - // separators) and month names. - let mut nums: VecDeque = VecDeque::new(); - let mut month_name: Option = None; - let mut cur = String::new(); - let flush = |cur: &mut String, nums: &mut VecDeque, month: &mut Option| { - if cur.is_empty() { - return; - } - if cur.starts_with(|c: char| c.is_ascii_digit()) { - nums.push_back(std::mem::take(cur)); - } else if cur.len() >= 3 - && let Some(i) = MONTHS.iter().position(|m| m.to_lowercase().starts_with(&cur.to_lowercase())) - { - *month = Some(i as u32 + 1); - } - cur.clear(); - }; - for c in t.chars() { - // Digits and letters form separate tokens ("5Mar" is 5 and Mar). - let same_kind = cur.is_empty() || cur.chars().next().is_some_and(|x| x.is_ascii_digit()) == c.is_ascii_digit(); - if c.is_alphanumeric() && same_kind { - cur.push(c); - } else { - flush(&mut cur, &mut nums, &mut month_name); - if c.is_alphanumeric() { - cur.push(c); - } - } - } - flush(&mut cur, &mut nums, &mut month_name); - let mut dt = DateTime { y: 0, m: 1, d: 1, hh: 0, mm: 0, ss: 0 }; - // The next number for a field. A field joined to the next one in the format ("yyyymmdd") - // takes only its own width from a longer run of digits and leaves the rest for the next. - // A run typed on its own is split only when fewer runs were typed than the fields of its kind - // still to fill (`need`, this one included: date fields for a date field, time fields for a - // time field, since times may be left out), and only when its rest can fill the next field - // (`next_width`): with separators each run is one field's number, so "2026-10-18" keeps its - // four-digit year in a yymmdd or yymmddHHMM field, and "2026 10 018" is the 18th. What is left - // of a split run (`tail`: the front of `nums` is one) goes on being split along the fields - // written side by side, and must fill its last field exactly, so "202610018" leaves "018" for - // dd and is refused. `None` when there is no number left, it doesn't fit a u32, or such a - // rest has the wrong width. - let next_number = |nums: &mut VecDeque, tail: &mut bool, part: &Part, need: usize, next_width: usize| -> Option { - let run = nums.pop_front()?; - let was_tail = std::mem::replace(tail, false); - let short = nums.len() < need.saturating_sub(1); - let digits = match (run.get(..part.width), run.get(part.width..)) { - (Some(head), Some(rest)) if part.joined && part.width > 0 && !rest.is_empty() && (was_tail || (short && rest.len() >= next_width)) => { - nums.push_front(rest.to_string()); - *tail = true; - head - } - _ if was_tail && part.width > 0 && run.len() != part.width => return None, - _ => run.as_str(), - }; - digits.parse::().ok() - }; - let parts = order(fmt); - let takes_number = |p: &Part| matches!(p.kind, 'y' | 'd' | 'H' | 'M' | 's') || (p.kind == 'm' && month_name.is_none()); - let is_time = |p: &Part| matches!(p.kind, 'H' | 'M' | 's'); - // The fields of each kind still to fill: [date, time]. - let mut wanted = [parts.iter().filter(|p| takes_number(p) && !is_time(p)).count(), parts.iter().filter(|p| is_time(p)).count()]; - let mut tail = false; - let mut have_y = false; - for (i, part) in parts.iter().enumerate() { - let kind = usize::from(is_time(part)); - let need = wanted.get(kind).copied().unwrap_or(0); - let next_width = parts.get(i + 1).map_or(0, |p| p.width); - if takes_number(part) - && let Some(left) = wanted.get_mut(kind) - { - *left = left.saturating_sub(1); - } - let take = |nums: &mut VecDeque, tail: &mut bool| next_number(nums, tail, part, need, next_width); - match part.kind { - // Literal digits in the format may be typed or left out ("15.05.26" or "15.05" - // for "dd.mm.26"); a different number there is left over and rejected below. - '#' if nums.front().and_then(|n| n.parse::().ok()) == Some(part.literal) => { - nums.pop_front(); - tail = false; - } - 'm' => { - dt.m = match month_name { - Some(m) => m, - None => take(&mut nums, &mut tail)?, - } - } - 'd' => dt.d = take(&mut nums, &mut tail)?, - 'y' => { - let y = i32::try_from(take(&mut nums, &mut tail)?).ok()?; - dt.y = if y < 100 { if y < 50 { 2000 + y } else { 1900 + y } } else { y }; - have_y = true; - } - // Times may be left out; a number that is there must be readable. - 'H' => dt.hh = if nums.is_empty() { 0 } else { take(&mut nums, &mut tail)? }, - 'M' => dt.mm = if nums.is_empty() { 0 } else { take(&mut nums, &mut tail)? }, - 's' => dt.ss = if nums.is_empty() { 0 } else { take(&mut nums, &mut tail)? }, - _ => {} - } - } - if !nums.is_empty() { - return None; - } - if !have_y { - dt.y = 2000; - } - if pm && dt.hh < 12 { - dt.hh += 12; - } - if am && dt.hh == 12 { - dt.hh = 0; - } - let has_date = fmt.contains(['y', 'm', 'd']); - if has_date && (!(1..=12).contains(&dt.m) || dt.d == 0 || dt.d > days_in(dt.y, dt.m)) { - return None; - } - if dt.hh > 23 || dt.mm > 59 || dt.ss > 59 { - return None; - } - Some(dt) -} - -/// Format a date/time with Acrobat's date format letters. -pub fn format_date(dt: DateTime, fmt: &str) -> String { - let chars: Vec = fmt.chars().collect(); - let mut out = String::new(); - let mut i = 0; - while i < chars.len() { - let c = chars[i]; - let mut n = 1; - while i + n < chars.len() && chars[i + n] == c { - n += 1; - } - let h12 = if dt.hh.is_multiple_of(12) { 12 } else { dt.hh % 12 }; - match (c, n) { - ('y', 4..) => { - let _ = write!(out, "{:04}", dt.y); - } - ('y', _) => { - let _ = write!(out, "{:02}", dt.y.rem_euclid(100)); - } - ('m', 3..) => match MONTHS.get((dt.m as usize).wrapping_sub(1)) { - Some(name) if n >= 4 => out.push_str(name), - Some(name) => out.push_str(name.get(..3).unwrap_or(name)), - None => { - let _ = write!(out, "{}", dt.m); - } - }, - ('m', 2) => { - let _ = write!(out, "{:02}", dt.m); - } - ('m', _) => { - let _ = write!(out, "{}", dt.m); - } - ('d', 3..) => { - let name = DAYS.get(weekday(dt.y, dt.m, dt.d)).copied().unwrap_or_default(); - out.push_str(if n >= 4 { name } else { name.get(..3).unwrap_or(name) }); - } - ('d', 2) => { - let _ = write!(out, "{:02}", dt.d); - } - ('d', _) => { - let _ = write!(out, "{}", dt.d); - } - ('H', 2) => { - let _ = write!(out, "{:02}", dt.hh); - } - ('H', _) => { - let _ = write!(out, "{}", dt.hh); - } - ('h', 2) => { - let _ = write!(out, "{h12:02}"); - } - ('h', _) => { - let _ = write!(out, "{h12}"); - } - ('M', 2) => { - let _ = write!(out, "{:02}", dt.mm); - } - ('M', _) => { - let _ = write!(out, "{}", dt.mm); - } - ('s', 2) => { - let _ = write!(out, "{:02}", dt.ss); - } - ('s', _) => { - let _ = write!(out, "{}", dt.ss); - } - ('t', 2) => out.push_str(if dt.hh < 12 { "am" } else { "pm" }), - ('t', _) => out.push(if dt.hh < 12 { 'a' } else { 'p' }), - (c, n) => out.extend(std::iter::repeat_n(c, n)), - } - i += n; - } - out -} - -const SPECIAL_MASKS: [&str; 4] = ["99999", "99999-9999", "(999) 999-9999", "999-99-9999"]; - -fn mask_fits(mask: &str, text: &str) -> bool { - let t: Vec = text.chars().collect(); - let m: Vec = mask.chars().collect(); - t.len() == m.len() - && m.iter().zip(&t).all(|(mc, tc)| match mc { - '9' => tc.is_ascii_digit(), - 'A' => tc.is_alphabetic(), - 'O' => tc.is_alphanumeric(), - 'X' => true, - other => other == tc, - }) -} - -/// Fill a mask from the typed characters that fit its slots. -fn apply_mask(mask: &str, text: &str) -> Option { - let mut src = text.chars().filter(|c| c.is_alphanumeric()); - let mut out = String::new(); - for mc in mask.chars() { - match mc { - '9' | 'A' | 'O' | 'X' => { - let c = src.next()?; - let ok = match mc { - '9' => c.is_ascii_digit(), - 'A' => c.is_alphabetic(), - _ => true, - }; - if !ok { - return None; - } - out.push(c); - } - other => out.push(other), - } - } - src.next().is_none().then_some(out) -} - -/// The text shown for a value (the Format event). -pub fn format_value(f: &Format, value: &str) -> String { - if value.is_empty() { - return String::new(); - } - match f { - Format::None => value.to_string(), - Format::Number { decimals, sep, neg, currency, prepend } => { - let Some(v) = make_number(value) else { return value.to_string() }; - let body = number_text(v, *decimals, *sep); - let body = match (currency.is_empty(), *prepend) { - (true, _) => body, - (false, true) => format!("{currency}{body}"), - (false, false) => format!("{body}{currency}"), - }; - match (v < 0.0, neg) { - (false, _) => body, - // Styles 2 and 3 use parentheses (3 is also red, which the appearance can't - // know here); 0 and 1 a minus sign. - (true, 2 | 3) => format!("({body})"), - (true, _) => format!("-{body}"), - } - } - Format::Percent { decimals, sep } => match make_number(value) { - Some(v) => format!( - "{}%", - if v < 0.0 { format!("-{}", number_text(v * 100.0, *decimals, *sep)) } else { number_text(v * 100.0, *decimals, *sep) } - ), - None => value.to_string(), - }, - Format::Date(fmt) | Format::Time(fmt) => parse_date(value, fmt).map_or_else(|| value.to_string(), |dt| format_date(dt, fmt)), - Format::Special(n) => apply_mask(SPECIAL_MASKS[(*n).min(3) as usize], value).unwrap_or_else(|| value.to_string()), - Format::Mask(m) => apply_mask(m, value).unwrap_or_else(|| value.to_string()), - } -} - -/// The Keystroke event on commit: does the value fit the format? Returns the value to store -/// (numbers are normalised), or Acrobat's message. -pub fn keystroke(f: &Format, field: &str, value: &str) -> Result { - let bad = || format!("The value entered does not match the format of the field [ {field} ]"); - // Blank input clears a formatted field; without a format, whitespace is text like any other. - if *f != Format::None && value.trim().is_empty() { - return Ok(String::new()); - } - match f { - Format::None => Ok(value.to_string()), - Format::Number { .. } => make_number(value).map(js_number).ok_or_else(bad), - Format::Percent { .. } => { - // Typed "12.5%" or "12.5" both mean 0.125 once stored? Acrobat stores the number - // as typed and shows it ×100: keep the typed number. - make_number(value).map(js_number).ok_or_else(bad) - } - Format::Date(fmt) | Format::Time(fmt) => parse_date(value, fmt) - .map(|_| value.trim().to_string()) - .ok_or_else(|| format!("Invalid date/time: please ensure that the date/time exists. Field [ {field} ] should match format {fmt}")), - Format::Special(n) => { - let m = SPECIAL_MASKS[(*n).min(3) as usize]; - apply_mask(m, value).filter(|s| mask_fits(m, s)).map(|_| value.trim().to_string()).ok_or_else(bad) - } - Format::Mask(m) => apply_mask(m, value).map(|_| value.trim().to_string()).ok_or_else(bad), - } -} - -/// The Validate event: `Err` with Acrobat's message when the value is out of range. -pub fn validate(v: &Validate, value: &str) -> Result<(), String> { - let Validate::Range { min, max } = v else { return Ok(()) }; - if value.trim().is_empty() { - return Ok(()); - } - let Some(x) = make_number(value) else { return Ok(()) }; - let fmt = |n: f64| js_number(n); - let ok = min.is_none_or(|m| x >= m) && max.is_none_or(|m| x <= m); - if ok { - return Ok(()); - } - Err(match (min, max) { - (Some(a), Some(b)) => format!("Invalid value: must be greater than or equal to {} and less than or equal to {}.", fmt(*a), fmt(*b)), - (Some(a), None) => format!("Invalid value: must be greater than or equal to {}.", fmt(*a)), - (None, Some(b)) => format!("Invalid value: must be less than or equal to {}.", fmt(*b)), - (None, None) => String::new(), - }) -} - -/// A token of simplified field notation. -#[derive(Clone, Debug, PartialEq)] -enum Notation { - /// A number, as written. - Number, - /// `+ - * / ( )`. - Op(char), - /// A field name, its `\` escapes undone. - Name(String), -} - -/// Simplified field notation's tokens, each with its byte range in `src`. Whitespace separates -/// them, and `\` escapes a space or an operator inside a name, as Acrobat writes them. -fn notation_tokens(src: &str) -> Vec<(Range, Notation)> { - let breaks = |c: char| c.is_whitespace() || "+-*/()".contains(c); - let mut out = Vec::new(); - let mut cs = src.char_indices().peekable(); - while let Some(&(start, c)) = cs.peek() { - let mut end = start + c.len_utf8(); - if c.is_whitespace() { - cs.next(); - } else if breaks(c) { - cs.next(); - out.push((start..end, Notation::Op(c))); - } else if c.is_ascii_digit() || c == '.' { - while let Some((i, d)) = cs.next_if(|(_, d)| d.is_ascii_digit() || *d == '.') { - end = i + d.len_utf8(); - } - out.push((start..end, Notation::Number)); - } else { - let mut name = String::new(); - while let Some((i, n)) = cs.next_if(|(_, n)| !breaks(*n)) { - end = i + n.len_utf8(); - if n == '\\' - && let Some((j, next)) = cs.next() - { - end = j + next.len_utf8(); - name.push(next); - } else { - name.push(n); - } - } - out.push((start..end, Notation::Name(name))); - } - } - out -} - -/// A field name as simplified field notation writes it: `\` before a space, an operator or a -/// `\`, and before a first character that would start a number. -fn notation_name(name: &str) -> String { - let mut out = String::new(); - for (i, c) in name.chars().enumerate() { - if c.is_whitespace() || "+-*/()\\".contains(c) || (i == 0 && (c.is_ascii_digit() || c == '.')) { - out.push('\\'); - } - out.push(c); - } - out -} - -/// `src` with each range replaced. The ranges come in order and don't overlap; one that does -/// is left out rather than written over another. -fn splice(src: &str, edits: impl IntoIterator, String)>) -> String { - let mut out = String::with_capacity(src.len()); - let mut done = 0; - for (range, with) in edits { - let Some(kept) = src.get(done..range.start).filter(|_| range.start <= range.end) else { continue }; - out.push_str(kept); - out.push_str(&with); - done = range.end; - } - out.push_str(src.get(done..).unwrap_or_default()); - out -} - -/// Simplified field notation as the JavaScript Acrobat writes after its `BVCALC` comment: -/// each field name becomes `AFMakeNumber(this.getField("name").value)`, the rest stays. -pub fn notation_js(expr: &str) -> String { - let names = notation_tokens(expr).into_iter().filter_map(|(range, t)| match t { - Notation::Name(name) => Some((range, format!("AFMakeNumber(this.getField({}).value)", js_str(&name)))), - _ => None, - }); - splice(expr, names) -} - -/// Evaluate simplified field notation: + - * / and parentheses over numbers and field names. -fn eval_notation(src: &str, value_of: &dyn Fn(&str) -> f64) -> Option { - #[derive(Debug, Clone, PartialEq)] - enum T { - N(f64), - Op(char), - } - let toks = notation_tokens(src) - .into_iter() - .map(|(range, t)| match t { - Notation::Number => src.get(range)?.parse().ok().map(T::N), - Notation::Op(c) => Some(T::Op(c)), - Notation::Name(name) => Some(T::N(value_of(&name))), - }) - .collect::>>()?; - // Recursive descent, `d` levels deep (at most MAX_NESTING). - fn expr(t: &[T], i: &mut usize, d: usize) -> Option { - let mut v = term(t, i, d)?; - while let Some(T::Op(o @ ('+' | '-'))) = t.get(*i) { - *i += 1; - let r = term(t, i, d)?; - v = if *o == '+' { v + r } else { v - r }; - } - Some(v) - } - fn term(t: &[T], i: &mut usize, d: usize) -> Option { - let mut v = factor(t, i, d)?; - while let Some(T::Op(o @ ('*' | '/'))) = t.get(*i) { - *i += 1; - let r = factor(t, i, d)?; - v = if *o == '*' { v * r } else { v / r }; - } - Some(v) - } - fn factor(t: &[T], i: &mut usize, d: usize) -> Option { - if d > MAX_NESTING { - return None; - } - match t.get(*i)? { - T::N(n) => { - *i += 1; - Some(*n) - } - T::Op('-') => { - *i += 1; - factor(t, i, d + 1).map(|v| -v) - } - T::Op('(') => { - *i += 1; - let v = expr(t, i, d + 1)?; - (t.get(*i) == Some(&T::Op(')'))).then(|| *i += 1)?; - Some(v) - } - _ => None, - } - } - let mut i = 0; - let v = expr(&toks, &mut i, 0)?; - (i == toks.len() && v.is_finite()).then_some(v) -} - -/// The Calculate event: the new value, given the current values of the other fields -/// (`values(name)` returns every field named `name` or below it, for groups). -pub fn calculate(c: &Calculate, values: &dyn Fn(&str) -> Vec) -> Option { - let num = |s: &String| make_number(s).unwrap_or(0.0); - match c { - Calculate::None => None, - Calculate::Simple { op, fields } => { - let xs: Vec = fields.iter().flat_map(|f| values(f)).map(|v| num(&v)).collect(); - let v = match op { - CalcOp::Sum => xs.iter().sum(), - CalcOp::Product => xs.iter().product(), - CalcOp::Average => { - if xs.is_empty() { - 0.0 - } else { - xs.iter().sum::() / xs.len() as f64 - } - } - CalcOp::Minimum => xs.iter().copied().fold(f64::INFINITY, f64::min), - CalcOp::Maximum => xs.iter().copied().fold(f64::NEG_INFINITY, f64::max), - }; - Some(if v.is_finite() { js_number(v) } else { "0".into() }) - } - Calculate::Notation(expr) => { - let v = eval_notation(expr, &|name| values(name).first().map(num).unwrap_or(0.0))?; - Some(js_number(v)) - } - } -} - #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn javascript_escapes_are_decoded() { - assert_eq!( - parse_format("AFNumber_Format(2, 0, 0, 0, \"\\x24\", true);"), - Some(Format::Number { decimals: 2, sep: 0, neg: 0, currency: "$".into(), prepend: true }) - ); - assert_eq!(parse_format("AFDate_FormatEx(\"\\b\\f\\v\\0\\q\");"), Some(Format::Date("\u{8}\u{c}\u{b}\0q".into()))); - } - - #[test] - fn scripts_are_recognised_and_written_back() { - assert_eq!( - parse_format("AFNumber_Format(2, 0, 0, 0, \"$\", true);"), - Some(Format::Number { decimals: 2, sep: 0, neg: 0, currency: "$".into(), prepend: true }) - ); - assert_eq!(parse_format("AFPercent_Format(1, 0);"), Some(Format::Percent { decimals: 1, sep: 0 })); - assert_eq!(parse_format("AFDate_FormatEx(\"dd-mmm-yyyy\");"), Some(Format::Date("dd-mmm-yyyy".into()))); - assert_eq!(parse_format("AFDate_Format(2);"), Some(Format::Date("mm/dd/yy".into()))); - assert_eq!(parse_format("AFTime_Format(1);"), Some(Format::Time("h:MM tt".into()))); - assert_eq!(parse_format("AFSpecial_Format(2);"), Some(Format::Special(2))); - assert_eq!(parse_format("AFSpecial_KeystrokeEx(\"AA-9999\");"), Some(Format::Mask("AA-9999".into()))); - assert_eq!(parse_format("app.alert('hi')"), None); - assert_eq!(parse_validate("AFRange_Validate(true, 0, true, 100);"), Some(Validate::Range { min: Some(0.0), max: Some(100.0) })); - assert_eq!(parse_validate("AFRange_Validate(false, 0, true, 10);"), Some(Validate::Range { min: None, max: Some(10.0) })); - assert_eq!( - parse_calculate("AFSimple_Calculate(\"SUM\", new Array (\"a\", \"b.c\"));"), - Some(Calculate::Simple { op: CalcOp::Sum, fields: vec!["a".into(), "b.c".into()] }) - ); - assert_eq!(parse_calculate("AFSimple_Calculate('AVG', ['x']);"), Some(Calculate::Simple { op: CalcOp::Average, fields: vec!["x".into()] })); - assert_eq!( - parse_calculate("/** BVCALC Price * Qty EVCALC **/ event.value = AFMakeNumber(getField(\"Price\").value) * …"), - Some(Calculate::Notation("Price * Qty".into())) - ); - // Round trips through what the tabs write. - for f in [ - Format::Number { decimals: 2, sep: 2, neg: 2, currency: "€".into(), prepend: false }, - Format::Percent { decimals: 0, sep: 0 }, - Format::Date("mmmm d, yyyy".into()), - Format::Special(3), - ] { - let (fj, _) = format_js(&f).unwrap(); - assert_eq!(parse_format(&fj), Some(f)); - } - let euro = format_js(&Format::Number { decimals: 2, sep: 2, neg: 0, currency: "€".into(), prepend: true }).unwrap().0; - assert!(euro.contains("\\u20AC"), "{euro}"); - assert!(!euro.contains('€'), "{euro}"); - let pound = format_js(&Format::Number { decimals: 2, sep: 0, neg: 0, currency: "£".into(), prepend: true }).unwrap().0; - assert!(pound.contains("\\u00A3"), "{pound}"); - assert!(!pound.contains('£'), "{pound}"); - let c = Calculate::Simple { op: CalcOp::Product, fields: vec!["q".into(), "p".into()] }; - assert_eq!(parse_calculate(&calculate_js(&c).unwrap()), Some(c)); - let c = Calculate::Notation("a + b".into()); - assert_eq!(parse_calculate(&calculate_js(&c).unwrap()), Some(c)); - // The JavaScript after the comment is Acrobat's translation, so viewers that run it - // instead of reading the comment compute the same value (not 0). - assert_eq!( - calculate_js(&Calculate::Notation("(Unit\\ Price * Qty) / 2".into())).unwrap(), - "/** BVCALC (Unit\\ Price * Qty) / 2 EVCALC **/ event.value = (AFMakeNumber(this.getField(\"Unit Price\").value) * AFMakeNumber(this.getField(\"Qty\").value)) / 2;" - ); - } - - #[test] - fn a_rename_rewrites_only_where_a_calculation_reads_the_field() { - let renamed = |js: &str| rename_in_calculation(js, "a", "qty"); - // The field lists Acrobat and other tools write for AFSimple_Calculate. - assert_eq!( - renamed("AFSimple_Calculate(\"SUM\", new Array (\"a\", \"b\"));").as_deref(), - Some("AFSimple_Calculate(\"SUM\", new Array (\"qty\", \"b\"));") - ); - assert_eq!(renamed("AFSimple_Calculate('AVG', ['b', 'a']);").as_deref(), Some("AFSimple_Calculate('AVG', ['b', \"qty\"]);")); - assert_eq!(renamed("AFSimple_Calculate(\"SUM\", \"a, b,a\");").as_deref(), Some("AFSimple_Calculate(\"SUM\", \"qty, b,qty\");")); - assert_eq!(renamed("AFSimple_Calculate(\"SUM\", new Array(\"ab\", \"b.a\"));"), None, "other names that contain it"); - // Notation: the expression in the comment and Acrobat's translation after it. - let notation = |expr: &str| calculate_js(&Calculate::Notation(expr.into())).unwrap(); - assert_eq!(rename_in_calculation(¬ation("(a + ab) * a / 2"), "a", "Unit Price"), Some(notation("(Unit\\ Price + ab) * Unit\\ Price / 2"))); - assert_eq!(rename_in_calculation(¬ation("Unit\\ Price * 2"), "Unit Price", "price"), Some(notation("price * 2"))); - // Scripts: a getField whose argument is one string literal. Comments, strings, template - // literals, regular expressions and names built at run time are left as written. - let script = "// getField(\"a\")\nvar s = \"getField('a')\", t = `getField(\"a\")`; /* getField(\"a\") */\n\ - if (/\"/.test(s)) event.value = this.getField('a').value / 2 * getField(\"a\" + 1).value + getField ( \"ab\" ).value;"; - assert_eq!(renamed(script), Some(script.replacen("this.getField('a')", "this.getField(\"qty\")", 1))); - assert_eq!(renamed("event.value = getField(\"b\").value;"), None); - // A new name is escaped as the script needs it. - assert_eq!( - rename_in_calculation("event.value = getField(\"a\").value;", "a", "say \"é\"").as_deref(), - Some("event.value = getField(\"say \\\"\\u00E9\\\"\").value;") - ); - // A name that would end the notation early is not written in. - assert_eq!(rename_in_calculation(¬ation("a * 2"), "a", "qtyEVCALC"), None); - // Unfinished scripts end their tokens, not the program. - for js in ["getField(\"a", "/* getField(\"a\")", "`getField(\"a\")", "x = /[/", "getField('a\\u12"] { - assert_eq!(renamed(js), None, "{js}"); - } - } - - /// The notation is read only from a comment, where Acrobat writes it: a string or code that - /// spells the markers is left alone, by the Calculate tab and by a rename. - #[test] - fn notation_is_read_only_from_a_comment() { - let string = "var s = \"BVCALC a EVCALC\"; event.value = getField(\"b\").value;"; - assert_eq!(parse_calculate(string), None); - assert_eq!(rename_in_calculation(string, "a", "qty"), None); - // A comment after such a string holds the notation. - let both = "var s = 'BVCALC x EVCALC'; /** BVCALC a + 1 EVCALC **/ event.value = 1;"; - assert_eq!(parse_calculate(both), Some(Calculate::Notation("a + 1".into()))); - assert_eq!(rename_in_calculation(both, "x", "y"), None); - assert_eq!(rename_in_calculation(both, "a", "qty"), Some(both.replace("a + 1", "qty + 1"))); - // A line comment is a comment too, and an unfinished one still holds it. - assert_eq!(parse_calculate("// BVCALC a EVCALC\nevent.value = 1;"), Some(Calculate::Notation("a".into()))); - assert_eq!(parse_calculate("/** BVCALC a EVCALC"), Some(Calculate::Notation("a".into()))); - } - - /// A line comment ends at any of JavaScript's line terminators, not only `\n`, so with `\r` line - /// endings the code after one is still code; and a `/` read as starting a regular expression - /// divides after all when the line ends first. - #[test] - fn a_line_comment_ends_at_any_line_terminator() { - for end in ["\n", "\r", "\r\n", "\u{2028}", "\u{2029}"] { - let string = format!("// header{end}var s = \"BVCALC a EVCALC\"; event.value = getField(\"b\").value;"); - assert_eq!(parse_calculate(&string), None, "{end:?}"); - assert_eq!(rename_in_calculation(&string, "a", "qty"), None, "{end:?}"); - let script = format!("// note{end}getField(\"a\").value;"); - assert_eq!(rename_in_calculation(&script, "a", "qty"), Some(script.replace("\"a\"", "\"qty\"")), "{end:?}"); - let divides = format!("var n = i++ / 2;{end}event.value = getField(\"a\").value / 4;"); - assert_eq!(rename_in_calculation(÷s, "a", "qty"), Some(divides.replace("\"a\"", "\"qty\"")), "{end:?}"); - } - } - - /// U+2028 and U+2029 end a word, a number or a regular expression's flags, though as bytes - /// past ASCII they could be part of one, so a `getField` right after one is still a call. - #[test] - fn a_token_ends_at_a_line_separator() { - for end in ["\u{2028}", "\u{2029}"] { - let scripts = ["x", "1", "/r/g"].map(|token| format!("var t = {token}{end}getField(\"a\").value;")); - let renamed = scripts.clone().map(|js| rename_in_calculation(&js, "a", "qty")); - assert_eq!(renamed, scripts.map(|js| Some(js.replace("\"a\"", "\"qty\""))), "{end:?}"); - } - } - - /// A `/` after the condition of `if`, `while`, `for` or `with` starts a regular expression, - /// as after an operator; after a call's parentheses it still divides. - #[test] - fn a_regex_after_a_condition_is_not_read_as_code() { - let renamed = |js: &str| rename_in_calculation(js, "a", "qty"); - assert_eq!(renamed("if (flag) /getField(\"a\")/.test(x); event.value = 1;"), None); - // A quote inside it doesn't start a string that hides the rest of the script. - let quote = "while (i--) /\"/.test(s); event.value = getField(\"a\").value;"; - assert_eq!(renamed(quote), Some(quote.replace("\"a\"", "\"qty\""))); - // The same after the `while` that ends a `do` loop. - let after_do = "do i++; while (i < n) /\"/.test(s); event.value = getField(\"a\").value;"; - assert_eq!(renamed(after_do), Some(after_do.replace("\"a\"", "\"qty\""))); - // After a call, or a method that happens to be called `if`, the `/` divides. - let divides = "event.value = Math.abs(getField(\"b\").value) / 2 + getField(\"a\").value / 3 + o.if(x) / 4 + getField(\"a\").value / 5;"; - assert_eq!(renamed(divides), Some(divides.replace("\"a\"", "\"qty\""))); - } - - /// Scripts are read in one pass: a line of unfinished regular expressions, or calls nested in - /// one another's field lists, don't make a rename read the rest of the script again for each. - #[test] - fn hostile_scripts_are_read_in_one_pass() { - let regexes = format!("x = {} getField(\"a\")", "/[".repeat(500_000)); - assert!(rename_in_calculation(®exes, "a", "b").is_some_and(|js| js.ends_with("getField(\"b\")"))); - let nested = format!("{}\"a\"", "AFSimple_Calculate(\"SUM\", new Array(".repeat(100_000)); - assert_eq!(rename_in_calculation(&nested, "a", "b"), Some(format!("{}\"b\"", "AFSimple_Calculate(\"SUM\", new Array(".repeat(100_000)))); - } - - /// Notation is read through one lexer: field names with escapes, numbers and operators. - #[test] - fn notation_is_read_by_one_lexer() { - let tokens: Vec = notation_tokens("(Unit\\ Price+1.5)*qty_2\\").into_iter().map(|(_, t)| t).collect(); - assert_eq!( - tokens, - [ - Notation::Op('('), - Notation::Name("Unit Price".into()), - Notation::Op('+'), - Notation::Number, - Notation::Op(')'), - Notation::Op('*'), - Notation::Name("qty_2\\".into()) - ] - ); - assert_eq!(notation_js("2 * a\\ b"), "2 * AFMakeNumber(this.getField(\"a b\").value)"); - let values = |name: &str| if name == "Unit Price" { 4.0 } else { 0.5 }; - assert_eq!(eval_notation("(Unit\\ Price + 1.5) * qty", &values), Some(2.75)); - assert_eq!(eval_notation("1.2.3 + a", &values), None, "a number that isn't one"); - assert_eq!(notation_name("2nd + (x)\\"), "\\2nd\\ \\+\\ \\(x\\)\\\\"); - } - - proptest::proptest! { - /// Any field name written in notation reads back as that one name. - #[test] - fn any_name_written_in_notation_reads_back(name in "\\PC{1,12}") { - let written = notation_name(&name); - proptest::prop_assert_eq!(notation_tokens(&written), vec![(0..written.len(), Notation::Name(name))]); - } - - /// Renaming a field in a formula and back gives the formula as it was. - #[test] - fn a_rename_round_trips(old in "\\PC{1,8}", new in "\\PC{1,8}", others in proptest::collection::vec("\\PC{1,8}", 0..4), sum in proptest::bool::ANY) { - proptest::prop_assume!(old != new && !others.contains(&new) && ![&old, &new].into_iter().chain(&others).any(|n| n.contains("VCALC"))); - let names: Vec = std::iter::once(old.clone()).chain(others).collect(); - let js = if sum { - calculate_js(&Calculate::Simple { op: CalcOp::Sum, fields: names }).unwrap() - } else { - calculate_js(&Calculate::Notation(names.iter().map(|n| notation_name(n)).collect::>().join(" * "))).unwrap() - }; - let renamed = rename_in_calculation(&js, &old, &new); - proptest::prop_assert!(renamed.is_some(), "{js}"); - let back = renamed.as_deref().and_then(|r| rename_in_calculation(r, &new, &old)); - proptest::prop_assert_eq!(back, Some(js)); - } - - /// What a formula reads is the same whether it is read from the tab's settings or from - /// the script they are written as. - #[test] - fn a_formula_reads_the_same_fields_as_its_script(names in proptest::collection::vec("\\PC{1,8}", 1..5), sum in proptest::bool::ANY) { - proptest::prop_assume!(!names.iter().any(|n| n.contains("VCALC"))); - let c = if sum { - Calculate::Simple { op: CalcOp::Product, fields: names.clone() } - } else { - Calculate::Notation(names.iter().map(|n| notation_name(n)).collect::>().join(" + ")) - }; - let js = calculate_js(&c).unwrap(); - proptest::prop_assert_eq!(calculation_reads(&js), c.reads()); - proptest::prop_assert_eq!(c.reads(), unique(names)); - } - } - - #[test] - fn a_calculation_reads_each_named_field_once() { - assert_eq!(Calculate::Simple { op: CalcOp::Sum, fields: vec!["a".into(), "b".into(), "a".into()] }.reads(), ["a", "b"]); - assert_eq!(Calculate::Notation("(Unit\\ Price + 2) * qty / qty".into()).reads(), ["Unit Price", "qty"]); - assert_eq!(Calculate::None.reads(), Vec::::new()); - let script = "var t = getField(\"sub\").value; // getField(\"x\")\nevent.value = t + getField(\"tax\").value + getField(\"row\" + 1).value;"; - assert_eq!(calculation_reads(script), ["sub", "tax"], "names built at run time aren't known"); - assert_eq!(calculation_reads("AFSimple_Calculate(\"SUM\", \"a, b\");"), ["a", "b"]); - } - - #[test] - fn numbers_format_like_acrobat() { - let n = |d, s, ng, c: &str, p| Format::Number { decimals: d, sep: s, neg: ng, currency: c.into(), prepend: p }; - assert_eq!(format_value(&n(2, 0, 0, "$", true), "1234.5"), "$1,234.50"); - assert_eq!(format_value(&n(0, 1, 0, "", true), "1234567"), "1234567"); - assert_eq!(format_value(&n(2, 2, 0, " €", false), "1234.5"), "1.234,50 €"); - assert_eq!(format_value(&n(2, 0, 2, "", true), "-12"), "(12.00)"); - assert_eq!(format_value(&n(1, 0, 0, "", true), "-0.25"), "-0.3"); - assert_eq!(format_value(&Format::Percent { decimals: 1, sep: 0 }, "0.125"), "12.5%"); - assert_eq!(make_number("$1,234.50"), Some(1234.5)); - assert_eq!(make_number("1.234,5"), Some(1234.5)); - assert_eq!(make_number("(7)"), Some(-7.0)); - assert_eq!(make_number("abc"), None); - assert_eq!(keystroke(&n(2, 0, 0, "$", true), "Total", "$1,000"), Ok("1000".into())); - assert_eq!( - keystroke(&n(2, 0, 0, "$", true), "Total", "ten"), - Err("The value entered does not match the format of the field [ Total ]".into()) - ); - // Blank input clears a formatted field (#1323); without a format it is text like any other. - assert_eq!(keystroke(&n(2, 0, 0, "$", true), "Total", " \t"), Ok(String::new())); - assert_eq!(keystroke(&Format::None, "Name", " \t"), Ok(" \t".into())); - } - - #[test] - fn dates_times_and_masks() { - let d = Format::Date("mm/dd/yyyy".into()); - assert_eq!(format_value(&d, "1/2/24"), "01/02/2024"); - assert_eq!(format_value(&Format::Date("mmmm d, yyyy".into()), "10/1/2026"), "October 1, 2026"); - assert_eq!(format_value(&Format::Date("dddd, mmm d, yyyy".into()), "10/1/2026"), "Thursday, Oct 1, 2026"); - assert_eq!(format_value(&Format::Date("dd-mmm-yy".into()), "5-Mar-21"), "05-Mar-21", "month names are read"); - assert!(keystroke(&d, "Due", "2/30/2024").is_err(), "no 30 February"); - assert!(keystroke(&d, "Due", "13/1/2024").is_err()); - // Literal digits in the format (a leave form fixing the year as "dd.mm.26"). - let fixed = Format::Date("dd.mm.26".into()); - assert_eq!(keystroke(&fixed, "Von", "15.05.26"), Ok("15.05.26".into())); - assert_eq!(format_value(&fixed, "15.05.26"), "15.05.26"); - assert_eq!(format_value(&fixed, "3.1"), "03.01.26", "the literal may be left out"); - assert!(keystroke(&fixed, "Von", "15.05.27").is_err(), "a different number there is not the literal"); - assert!(keystroke(&fixed, "Von", "32.05.26").is_err()); - assert_eq!(format_value(&Format::Time("h:MM tt".into()), "14:05"), "2:05 pm"); - assert_eq!(format_value(&Format::Time("HH:MM".into()), "2:05 pm"), "14:05"); - assert_eq!(format_value(&Format::Special(2), "5551234567"), "(555) 123-4567"); - assert_eq!(format_value(&Format::Special(3), "123-45-6789"), "123-45-6789"); - assert!(keystroke(&Format::Special(0), "Zip", "1234").is_err()); - assert_eq!(format_value(&Format::Mask("AA-9999".into()), "ab1234"), "ab-1234"); - assert!(keystroke(&Format::Mask("AA-9999".into()), "Code", "a11234").is_err()); - } - - #[test] - fn validation_and_calculation() { - let r = Validate::Range { min: Some(0.0), max: Some(100.0) }; - assert!(validate(&r, "50").is_ok() && validate(&r, "").is_ok()); - assert_eq!(validate(&r, "120"), Err("Invalid value: must be greater than or equal to 0 and less than or equal to 100.".into())); - let vals = |name: &str| -> Vec { - match name { - "a" => vec!["2".into()], - "b" => vec!["$3.50".into()], - "row" => vec!["1".into(), "2".into(), "3".into()], - "Unit Price" => vec!["4".into()], - _ => vec![], - } - }; - let simple = |op, f: &[&str]| calculate(&Calculate::Simple { op, fields: f.iter().map(|s| s.to_string()).collect() }, &vals); - assert_eq!(simple(CalcOp::Sum, &["a", "b"]), Some("5.5".into())); - assert_eq!(simple(CalcOp::Product, &["a", "b"]), Some("7".into())); - assert_eq!(simple(CalcOp::Average, &["row"]), Some("2".into()), "groups count every child"); - assert_eq!(simple(CalcOp::Maximum, &["a", "row"]), Some("3".into())); - assert_eq!(simple(CalcOp::Sum, &["missing"]), Some("0".into())); - assert_eq!(calculate(&Calculate::Notation("(a + b) * 2 - Unit\\ Price / 4".into()), &vals), Some("10".into())); - assert_eq!(calculate(&Calculate::Notation("a +".into()), &vals), None); - } -} +mod tests; // ── push buttons ──────────────────────────────────────────────────────────────────────────── diff --git a/crates/forms/src/af/calc.rs b/crates/forms/src/af/calc.rs new file mode 100644 index 0000000000..b3a1524a4f --- /dev/null +++ b/crates/forms/src/af/calc.rs @@ -0,0 +1,514 @@ +use super::format::{js_number, make_number}; +use super::script::{MAX_NESTING, Parser, js_str}; +use super::*; + +// ── renaming fields in calculations ───────────────────────────────────────────────────────── + +/// A JavaScript token, as far as finding the field names a calculation reads needs. +#[derive(Clone, Debug, PartialEq)] +pub(super) enum Js { + Ident, + /// A string literal, its escapes undone. + Str(String), + Punct(char), + /// A number, a regular expression or a template literal. + Other, +} + +/// A [`Js`] token with its byte range in the script. +type Token = (Range, Js); + +/// Words after which `/` starts a regular expression instead of dividing. +const BEFORE_REGEX: [&str; 13] = ["return", "typeof", "case", "do", "else", "in", "of", "new", "delete", "void", "throw", "instanceof", "yield"]; + +/// Words whose condition, in parentheses, comes before a statement, so a `/` after it starts a +/// regular expression. The `while` that ends a `do` loop counts too: the loop ends at its `)`, so +/// a `/` after it starts the next statement, and can't divide. +const BEFORE_CONDITION: [&str; 4] = ["if", "while", "for", "with"]; + +/// Whether a line ends at `s[j]`: JavaScript ends one at `\n`, `\r`, U+2028 and U+2029. +fn line_ends_at(s: &[u8], j: usize) -> bool { + matches!(s.get(j..), Some([b'\n' | b'\r', ..] | [0xE2, 0x80, 0xA8 | 0xA9, ..])) +} + +/// JavaScript's tokens with their byte ranges, and apart from them the text of each comment +/// (inside its `//`, or its `/*` and `*/`): enough of the language to tell code from strings, +/// comments and regular expressions. The tokens stop where a string, comment or template literal +/// never ends; a comment that never ends runs to the end of the script. +pub(super) fn js_tokens(js: &str) -> (Vec, Vec>) { + let s = js.as_bytes(); + // Whether `s[j]` is part of a word. Bytes past ASCII are parts of identifiers, except + // U+2028 and U+2029, which end a line: a range never ends inside a character. + let word = |j: usize| s.get(j).is_some_and(|&b| b.is_ascii_alphanumeric() || b == b'_' || b == b'$' || !b.is_ascii()) && !line_ends_at(s, j); + let mut out: Vec = Vec::new(); + let mut comments = Vec::new(); + let mut i = 0; + // Where a regular expression that never ended stopped being read: every `/` before it + // divides, so a line of them is read once, not once for each. + let mut divides_until = 0; + // For each `(` still open, whether it opened the condition of `if`, `while`, `for` or + // `with`; and whether the last token is the `)` that closed one. + let mut conditions: Vec = Vec::new(); + let mut after_condition = false; + 'scan: while let Some(&c) = s.get(i) { + let start = i; + let token = match c { + _ if c.is_ascii_whitespace() => { + i += 1; + continue; + } + // U+2028 or U+2029 between tokens ends a line, and is skipped whole: read a byte at a + // time, the rest of it would be read from inside the character. + 0xE2 if line_ends_at(s, i) => { + i += 3; + continue; + } + b'/' if s.get(i + 1) == Some(&b'/') => { + i = (i + 2..s.len()).find(|&j| line_ends_at(s, j)).unwrap_or(s.len()); + comments.push(start + 2..i); + continue; + } + b'/' if s.get(i + 1) == Some(&b'*') => match js.get(i + 2..).and_then(|rest| rest.find("*/")) { + Some(n) => { + comments.push(i + 2..i + 2 + n); + i += n + 4; + continue; + } + None => { + comments.push(i + 2..s.len()); + break 'scan; + } + }, + b'"' | b'\'' => { + let mut p = Parser { s, i, depth: 0 }; + let Some(text) = p.string() else { break 'scan }; + i = p.i; + Js::Str(text) + } + b'`' => { + let mut j = i + 1; + loop { + match s.get(j) { + None => break 'scan, + Some(b'\\') => j += 2, + Some(b'`') => break, + Some(_) => j += 1, + } + } + i = j + 1; + Js::Other + } + b'/' if i >= divides_until && regex_may_follow(js, &out, after_condition) => { + // A regular expression ends at a `/` outside a class; a line break first means it + // was a division after all. + let (mut j, mut class) = (i + 1, false); + let end = loop { + match s.get(j) { + None => break None, + Some(_) if line_ends_at(s, j) => break None, + Some(b'\\') => j += 2, + Some(b'/') if !class => break Some(j), + Some(b) => { + class = match b { + b'[' => true, + b']' => false, + _ => class, + }; + j += 1; + } + } + }; + match end { + Some(e) => { + i = e + 1; + while word(i) { + i += 1; + } + Js::Other + } + None => { + divides_until = j; + i += 1; + Js::Punct('/') + } + } + } + _ if c.is_ascii_digit() => { + while word(i) || s.get(i) == Some(&b'.') { + i += 1; + } + Js::Other + } + _ if word(i) => { + while word(i) { + i += 1; + } + Js::Ident + } + _ => { + i += 1; + Js::Punct(char::from(c)) + } + }; + after_condition = match token { + Js::Punct('(') => { + conditions.push(opens_condition(js, &out)); + false + } + Js::Punct(')') => conditions.pop().unwrap_or(false), + _ => false, + }; + out.push((start..i, token)); + } + (out, comments) +} + +/// Whether a `/` after `tokens` starts a regular expression: after an operator, an opening +/// bracket, a keyword such as `return` or the condition of `if`, `while`, `for` or `with`, not +/// after a value. `after_condition` says the last token is the `)` that ends such a condition. +fn regex_may_follow(js: &str, tokens: &[Token], after_condition: bool) -> bool { + match tokens.last() { + None => true, + Some((range, Js::Ident)) => js.get(range.clone()).is_some_and(|w| BEFORE_REGEX.contains(&w)), + Some((_, Js::Punct(')'))) => after_condition, + Some((_, Js::Punct(c))) => !matches!(c, ']' | '}'), + Some((_, Js::Str(_) | Js::Other)) => false, + } +} + +/// Whether a `(` after `tokens` opens the condition of `if`, `while`, `for` or `with`, and not +/// the arguments of a method that has one of those names (`o.if(x)`). +fn opens_condition(js: &str, tokens: &[Token]) -> bool { + let mut back = tokens.iter().rev(); + let Some((range, Js::Ident)) = back.next() else { return false }; + js.get(range.clone()).is_some_and(|w| BEFORE_CONDITION.contains(&w)) && !matches!(back.next(), Some((_, Js::Punct('.')))) +} + +/// Where a Calculate script keeps its simplified field notation: between `BVCALC` and the +/// `EVCALC` after it, in the first of `comments` that holds both, as Acrobat writes it +/// (`/** BVCALC a + b EVCALC **/`). A string or code that spells the markers is not the notation. +pub(super) fn notation_range(js: &str, comments: &[Range]) -> Option> { + comments.iter().find_map(|comment| { + let text = js.get(comment.clone())?; + let a = text.find("BVCALC")? + 6; + let b = a + text.get(a..)?.find("EVCALC")?; + Some(comment.start + a..comment.start + b) + }) +} + +/// A place where a Calculate script names a field. +#[derive(Clone, Debug, PartialEq)] +enum Site { + /// A string literal holding one name: `getField("a")`, or an item of an + /// `AFSimple_Calculate` list. + Literal(String), + /// An `AFSimple_Calculate` list written as one string of comma-separated names. + Commas(String), + /// A name in the simplified field notation of a `BVCALC` comment. + Notation(String), +} + +impl Site { + fn names(&self) -> Vec<&str> { + match self { + Site::Literal(name) | Site::Notation(name) => vec![name.as_str()], + Site::Commas(list) => list.split(',').map(str::trim).filter(|n| !n.is_empty()).collect(), + } + } + + /// The site written again with field `old` named `new`, or `None` when it doesn't name `old`. + fn renamed(&self, old: &str, new: &str) -> Option { + match self { + Site::Literal(name) => (name == old).then(|| js_str(new)), + Site::Notation(name) => (name == old).then(|| notation_name(new)), + Site::Commas(list) => { + let parts: Vec<&str> = list.split(',').collect(); + parts.iter().any(|p| p.trim() == old).then(|| { + // The spaces around each name stay as they were. + let parts: Vec = parts.iter().map(|p| if p.trim() == old { p.replacen(old, new, 1) } else { (*p).to_owned() }).collect(); + js_str(&parts.join(",")) + }) + } + } + } +} + +/// Where a Calculate script names fields, in order: the simplified field notation in its comment +/// (see [`notation_range`]; [`parse_calculate`] reads it there too), the field list of +/// `AFSimple_Calculate`, and every `getField` whose whole argument is one string literal. +/// A name built at run time (`getField("row" + i)`) is not a site. +fn calculation_sites(js: &str) -> Vec<(Range, Site)> { + let mut sites = Vec::new(); + let (tokens, comments) = js_tokens(js); + if let Some(at) = notation_range(js, &comments) + && let Some(expr) = js.get(at.clone()) + { + for (range, token) in notation_tokens(expr) { + if let Notation::Name(name) = token { + sites.push((at.start + range.start..at.start + range.end, Site::Notation(name))); + } + } + } + let ident = |k: usize, word: &str| tokens.get(k).is_some_and(|(range, t)| *t == Js::Ident && js.get(range.clone()) == Some(word)); + let punct = |k: usize, c: char| tokens.get(k).is_some_and(|(_, t)| *t == Js::Punct(c)); + let string = |k: usize| match tokens.get(k) { + Some((range, Js::Str(text))) => Some((range.clone(), text.clone())), + _ => None, + }; + for k in 0..tokens.len() { + if ident(k, "getField") + && punct(k + 1, '(') + && punct(k + 3, ')') + && let Some((range, name)) = string(k + 2) + { + sites.push((range, Site::Literal(name))); + } + if !(ident(k, "AFSimple_Calculate") && punct(k + 1, '(') && string(k + 2).is_some() && punct(k + 3, ',')) { + continue; + } + if let Some((range, list)) = string(k + 4) { + sites.push((range, Site::Commas(list))); + continue; + } + // `new Array("a", "b")` or `["a", "b"]`: its items, up to the first that isn't a string. + // Nothing else is read, so no token is read for two calls. + let first = if punct(k + 4, '[') { + k + 5 + } else if ident(k + 4, "new") && ident(k + 5, "Array") && punct(k + 6, '(') { + k + 7 + } else { + continue; + }; + for (range, token) in tokens.iter().skip(first) { + match token { + Js::Str(name) => sites.push((range.clone(), Site::Literal(name.clone()))), + Js::Punct(',') => {} + _ => break, + } + } + } + sites.sort_by_key(|(range, _)| range.start); + sites +} + +/// A Calculate script with field `old` named `new` everywhere it reads it (see +/// [`calculation_sites`]), the rest kept as written; `None` when it doesn't read `old`. Also +/// `None` when the new name would read as more than a name there (one holding `EVCALC` would end +/// the notation early), so a script is never left meaning something else. +pub fn rename_in_calculation(js: &str, old: &str, new: &str) -> Option { + let sites = calculation_sites(js); + let edits: Vec<(Range, String)> = sites.iter().filter_map(|(range, site)| Some((range.clone(), site.renamed(old, new)?))).collect(); + if edits.is_empty() { + return None; + } + let out = splice(js, edits); + let expected: Vec<&str> = sites.iter().flat_map(|(_, site)| site.names()).map(|n| if n == old { new } else { n }).collect(); + let written = calculation_sites(&out); + let got: Vec<&str> = written.iter().flat_map(|(_, site)| site.names()).collect(); + (got == expected).then_some(out) +} + +/// The fields a Calculate script reads by name (see [`calculation_sites`]), each once, in the +/// order it first names them. +pub fn calculation_reads(js: &str) -> Vec { + let sites = calculation_sites(js); + unique(sites.iter().flat_map(|(_, site)| site.names()).map(str::to_owned)) +} + +impl Calculate { + /// The fields it reads, each once, in the order it first names them. A name also stands for + /// the fields under it (`row` for `row.0` and `row.1`), as [`calculate`] reads them. + pub fn reads(&self) -> Vec { + match self { + Calculate::None => Vec::new(), + Calculate::Simple { fields, .. } => unique(fields.iter().cloned()), + Calculate::Notation(expr) => unique(notation_tokens(expr).into_iter().filter_map(|(_, token)| match token { + Notation::Name(name) => Some(name), + _ => None, + })), + } + } +} + +/// `names` without repeats, in order. +pub(super) fn unique(names: impl IntoIterator) -> Vec { + let mut seen = std::collections::HashSet::new(); + names.into_iter().filter(|n| seen.insert(n.clone())).collect() +} + +/// A token of simplified field notation. +#[derive(Clone, Debug, PartialEq)] +pub(super) enum Notation { + /// A number, as written. + Number, + /// `+ - * / ( )`. + Op(char), + /// A field name, its `\` escapes undone. + Name(String), +} + +/// Simplified field notation's tokens, each with its byte range in `src`. Whitespace separates +/// them, and `\` escapes a space or an operator inside a name, as Acrobat writes them. +pub(super) fn notation_tokens(src: &str) -> Vec<(Range, Notation)> { + let breaks = |c: char| c.is_whitespace() || "+-*/()".contains(c); + let mut out = Vec::new(); + let mut cs = src.char_indices().peekable(); + while let Some(&(start, c)) = cs.peek() { + let mut end = start + c.len_utf8(); + if c.is_whitespace() { + cs.next(); + } else if breaks(c) { + cs.next(); + out.push((start..end, Notation::Op(c))); + } else if c.is_ascii_digit() || c == '.' { + while let Some((i, d)) = cs.next_if(|(_, d)| d.is_ascii_digit() || *d == '.') { + end = i + d.len_utf8(); + } + out.push((start..end, Notation::Number)); + } else { + let mut name = String::new(); + while let Some((i, n)) = cs.next_if(|(_, n)| !breaks(*n)) { + end = i + n.len_utf8(); + if n == '\\' + && let Some((j, next)) = cs.next() + { + end = j + next.len_utf8(); + name.push(next); + } else { + name.push(n); + } + } + out.push((start..end, Notation::Name(name))); + } + } + out +} + +/// A field name as simplified field notation writes it: `\` before a space, an operator or a +/// `\`, and before a first character that would start a number. +pub(super) fn notation_name(name: &str) -> String { + let mut out = String::new(); + for (i, c) in name.chars().enumerate() { + if c.is_whitespace() || "+-*/()\\".contains(c) || (i == 0 && (c.is_ascii_digit() || c == '.')) { + out.push('\\'); + } + out.push(c); + } + out +} + +/// `src` with each range replaced. The ranges come in order and don't overlap; one that does +/// is left out rather than written over another. +fn splice(src: &str, edits: impl IntoIterator, String)>) -> String { + let mut out = String::with_capacity(src.len()); + let mut done = 0; + for (range, with) in edits { + let Some(kept) = src.get(done..range.start).filter(|_| range.start <= range.end) else { continue }; + out.push_str(kept); + out.push_str(&with); + done = range.end; + } + out.push_str(src.get(done..).unwrap_or_default()); + out +} + +/// Simplified field notation as the JavaScript Acrobat writes after its `BVCALC` comment: +/// each field name becomes `AFMakeNumber(this.getField("name").value)`, the rest stays. +pub fn notation_js(expr: &str) -> String { + let names = notation_tokens(expr).into_iter().filter_map(|(range, t)| match t { + Notation::Name(name) => Some((range, format!("AFMakeNumber(this.getField({}).value)", js_str(&name)))), + _ => None, + }); + splice(expr, names) +} + +/// Evaluate simplified field notation: + - * / and parentheses over numbers and field names. +pub(super) fn eval_notation(src: &str, value_of: &dyn Fn(&str) -> f64) -> Option { + #[derive(Debug, Clone, PartialEq)] + enum T { + N(f64), + Op(char), + } + let toks = notation_tokens(src) + .into_iter() + .map(|(range, t)| match t { + Notation::Number => src.get(range)?.parse().ok().map(T::N), + Notation::Op(c) => Some(T::Op(c)), + Notation::Name(name) => Some(T::N(value_of(&name))), + }) + .collect::>>()?; + // Recursive descent, `d` levels deep (at most MAX_NESTING). + fn expr(t: &[T], i: &mut usize, d: usize) -> Option { + let mut v = term(t, i, d)?; + while let Some(T::Op(o @ ('+' | '-'))) = t.get(*i) { + *i += 1; + let r = term(t, i, d)?; + v = if *o == '+' { v + r } else { v - r }; + } + Some(v) + } + fn term(t: &[T], i: &mut usize, d: usize) -> Option { + let mut v = factor(t, i, d)?; + while let Some(T::Op(o @ ('*' | '/'))) = t.get(*i) { + *i += 1; + let r = factor(t, i, d)?; + v = if *o == '*' { v * r } else { v / r }; + } + Some(v) + } + fn factor(t: &[T], i: &mut usize, d: usize) -> Option { + if d > MAX_NESTING { + return None; + } + match t.get(*i)? { + T::N(n) => { + *i += 1; + Some(*n) + } + T::Op('-') => { + *i += 1; + factor(t, i, d + 1).map(|v| -v) + } + T::Op('(') => { + *i += 1; + let v = expr(t, i, d + 1)?; + (t.get(*i) == Some(&T::Op(')'))).then(|| *i += 1)?; + Some(v) + } + _ => None, + } + } + let mut i = 0; + let v = expr(&toks, &mut i, 0)?; + (i == toks.len() && v.is_finite()).then_some(v) +} + +/// The Calculate event: the new value, given the current values of the other fields +/// (`values(name)` returns every field named `name` or below it, for groups). +pub fn calculate(c: &Calculate, values: &dyn Fn(&str) -> Vec) -> Option { + let num = |s: &String| make_number(s).unwrap_or(0.0); + match c { + Calculate::None => None, + Calculate::Simple { op, fields } => { + let xs: Vec = fields.iter().flat_map(|f| values(f)).map(|v| num(&v)).collect(); + let v = match op { + CalcOp::Sum => xs.iter().sum(), + CalcOp::Product => xs.iter().product(), + CalcOp::Average => { + if xs.is_empty() { + 0.0 + } else { + xs.iter().sum::() / xs.len() as f64 + } + } + CalcOp::Minimum => xs.iter().copied().fold(f64::INFINITY, f64::min), + CalcOp::Maximum => xs.iter().copied().fold(f64::NEG_INFINITY, f64::max), + }; + Some(if v.is_finite() { js_number(v) } else { "0".into() }) + } + Calculate::Notation(expr) => { + let v = eval_notation(expr, &|name| values(name).first().map(num).unwrap_or(0.0))?; + Some(js_number(v)) + } + } +} diff --git a/crates/forms/src/af/format.rs b/crates/forms/src/af/format.rs new file mode 100644 index 0000000000..df4720bec7 --- /dev/null +++ b/crates/forms/src/af/format.rs @@ -0,0 +1,533 @@ +use super::*; + +/// Acrobat's AFDate_Format(n) / AFTime_Format(n) presets. +pub const DATE_PRESETS: [&str; 14] = [ + "m/d", + "m/d/yy", + "mm/dd/yy", + "mm/yy", + "d-mmm", + "d-mmm-yy", + "dd-mmm-yy", + "yy-mm-dd", + "mmm-yy", + "mmmm-yy", + "mmm d, yyyy", + "mmmm d, yyyy", + "m/d/yy h:MM tt", + "m/d/yy HH:MM", +]; + +pub const TIME_PRESETS: [&str; 4] = ["HH:MM", "h:MM tt", "HH:MM:ss", "h:MM:ss tt"]; + +// ── running them ──────────────────────────────────────────────────────────────────────────── + +/// AFMakeNumber: a number from typed text (currency symbols, group separators, spaces and a +/// trailing % ignored; a comma decimal separator accepted). +pub fn make_number(s: &str) -> Option { + let t: String = s.trim().chars().filter(|c| !c.is_whitespace() && !matches!(c, '$' | '€' | '£' | '¥' | '\'' | '%')).collect(); + if t.is_empty() { + return None; + } + let neg = t.starts_with('(') && t.ends_with(')'); + let t = t.trim_start_matches('(').trim_end_matches(')'); + // "1.234,56" or "1234,56": comma is the decimal separator when it comes last. + let normalized = match (t.rfind(','), t.rfind('.')) { + (Some(c), Some(d)) if c > d => t.replace('.', "").replace(',', "."), + (Some(c), None) if t.len() - c - 1 != 3 => t.replace(',', "."), + _ => t.replace(',', ""), + }; + let v: f64 = normalized.parse().ok()?; + v.is_finite().then_some(if neg { -v } else { v }) +} + +/// A number as JavaScript prints it (integers without a fraction). +pub fn js_number(v: f64) -> String { + if v.fract() == 0.0 && v.abs() < 1e15 { format!("{}", v as i64) } else { format!("{v}") } +} + +fn group(int: &str, sep: char) -> String { + let mut out = String::new(); + for (i, c) in int.chars().enumerate() { + if i > 0 && (int.len() - i).is_multiple_of(3) { + out.push(sep); + } + out.push(c); + } + out +} + +/// A number with `decimals` places in separator style `sep` (0 "1,234.56", 1 "1234.56", +/// 2 "1.234,56", 3 "1234,56", 4 "1'234.56"). +fn number_text(v: f64, decimals: u8, sep: u8) -> String { + // Round half away from zero, as JavaScript's toFixed does for these values. + let p = 10f64.powi(i32::from(decimals)); + let scaled = v.abs() * p; + // Past 2^53 every value is already whole, and scaling could overflow to infinity. + let r = if scaled.is_finite() { scaled.round() / p } else { v.abs() }; + let s = format!("{:.*}", decimals as usize, r); + let (int, frac) = s.split_once('.').map_or((s.as_str(), ""), |(a, b)| (a, b)); + let (g, d) = match sep { + 1 => (None, '.'), + 2 => (Some('.'), ','), + 3 => (None, ','), + 4 => (Some('\''), '.'), + _ => (Some(','), '.'), + }; + let mut out = match g { + Some(c) => group(int, c), + None => int.to_string(), + }; + if !frac.is_empty() { + out.push(d); + out.push_str(frac); + } + out +} + +const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]; + +const DAYS: [&str; 7] = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; + +/// A date and time read from text. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct DateTime { + pub y: i32, + pub m: u32, + pub d: u32, + pub hh: u32, + pub mm: u32, + pub ss: u32, +} + +fn days_in(y: i32, m: u32) -> u32 { + match m { + 2 if (y % 4 == 0 && y % 100 != 0) || y % 400 == 0 => 29, + 2 => 28, + 4 | 6 | 9 | 11 => 30, + _ => 31, + } +} + +fn weekday(y: i32, m: u32, d: u32) -> usize { + // Sakamoto's method, in i64 so no year or day can overflow it. + const T: [i64; 12] = [0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4]; + let y = i64::from(y) - i64::from(m < 3); + let t = T.get((m as usize).wrapping_sub(1)).copied().unwrap_or(0); + (y + y / 4 - y / 100 + y / 400 + t + i64::from(d)).rem_euclid(7) as usize +} + +/// One field of a date format, in the order the format has them. +struct Part { + /// `'y'`, `'m'`, `'d'`, `'H'`, `'M'` or `'s'`; a run of literal digits (the fixed year in + /// "dd.mm.26") is `'#'`. + kind: char, + /// The value of a `'#'` run; 0 for the others. + literal: u32, + /// The digits the field takes when typed run together with the next one: 4 for "yyyy", 2 for + /// "yy", "mm", "dd", "HH", "MM" and "ss". 0 when the width varies ("m", "d") or the field is + /// a name ("mmm"). + width: usize, + /// The next field follows with nothing between them ("yyyymmdd"), so one run of typed digits + /// can hold both. + joined: bool, +} + +/// The fields of a format (year, month, day, hours, minutes, seconds and literal digits), in +/// order. +fn order(fmt: &str) -> Vec { + let mut out: Vec = Vec::new(); + // Weekday names (ddd, dddd) are shown, not typed: leave them out. + let mut cleaned = fmt.replace("dddd", " "); + cleaned = cleaned.replace("ddd", " "); + let mut prev = ' '; + // The letter of the last field's current run and its length so far. + let mut run: Option<(char, usize)> = None; + let fixed = |k: char, n: usize| match (k, n) { + ('y', 2 | 4) | ('m' | 'd' | 'H' | 'M' | 's', 2) => n, + _ => 0, + }; + for c in cleaned.chars() { + let continues = run.is_some_and(|(r, _)| r == c); + if let Some((r, n)) = run + && !continues + { + // The run ended; it was the last field's width. + if let Some(last) = out.last_mut().filter(|p| p.kind != '#') { + last.width = fixed(if r == 'h' { 'H' } else { r }, n); + } + run = None; + } + if let Some(digit) = c.to_digit(10) { + match out.last_mut() { + Some(p) if p.kind == '#' && prev.is_ascii_digit() => p.literal = p.literal.saturating_mul(10).saturating_add(digit), + _ => out.push(Part { kind: '#', literal: digit, width: 0, joined: false }), + } + prev = c; + continue; + } + let k = match c { + 'y' => 'y', + 'm' => 'm', + 'd' => 'd', + 'H' | 'h' => 'H', + 'M' => 'M', + 's' => 's', + _ => { + prev = c; + continue; + } + }; + if continues { + run = run.map(|(r, n)| (r, n.saturating_add(1))); + } else { + // A field starting right where the last one's letters stopped is joined to it. + let adjacent = matches!(prev, 'y' | 'm' | 'd' | 'H' | 'h' | 'M' | 's'); + if out.last().map(|p| p.kind) != Some(k) { + if let Some(last) = out.last_mut().filter(|p| p.kind != '#') { + last.joined = adjacent; + } + out.push(Part { kind: k, literal: 0, width: 0, joined: false }); + } + run = Some((c, 1)); + } + prev = c; + } + if let (Some((r, n)), Some(last)) = (run, out.last_mut().filter(|p| p.kind != '#')) { + last.width = fixed(if r == 'h' { 'H' } else { r }, n); + } + out +} + +/// Parse typed text against a date/time format (AFDate_KeystrokeEx's leniency: any +/// separators, month names, two-digit years). `None` when it isn't a valid date. +pub fn parse_date(text: &str, fmt: &str) -> Option { + let t = text.trim(); + if t.is_empty() { + return None; + } + let lower = t.to_lowercase(); + // A bare trailing "p" ("5:30p") only counts when it follows a digit, not a word like "Sep". + let bare_p = lower.strip_suffix('p').is_some_and(|rest| rest.trim_end().ends_with(|c: char| c.is_ascii_digit())); + let pm = lower.contains("pm") || bare_p; + let bare_a = lower.strip_suffix('a').is_some_and(|rest| rest.trim_end().ends_with(|c: char| c.is_ascii_digit())); + let am = lower.contains("am") || bare_a; + // Tokens: runs of digits (kept as text, so a run can be split between fields typed without + // separators) and month names. + let mut nums: VecDeque = VecDeque::new(); + let mut month_name: Option = None; + let mut cur = String::new(); + let flush = |cur: &mut String, nums: &mut VecDeque, month: &mut Option| { + if cur.is_empty() { + return; + } + if cur.starts_with(|c: char| c.is_ascii_digit()) { + nums.push_back(std::mem::take(cur)); + } else if cur.len() >= 3 + && let Some(i) = MONTHS.iter().position(|m| m.to_lowercase().starts_with(&cur.to_lowercase())) + { + *month = Some(i as u32 + 1); + } + cur.clear(); + }; + for c in t.chars() { + // Digits and letters form separate tokens ("5Mar" is 5 and Mar). + let same_kind = cur.is_empty() || cur.chars().next().is_some_and(|x| x.is_ascii_digit()) == c.is_ascii_digit(); + if c.is_alphanumeric() && same_kind { + cur.push(c); + } else { + flush(&mut cur, &mut nums, &mut month_name); + if c.is_alphanumeric() { + cur.push(c); + } + } + } + flush(&mut cur, &mut nums, &mut month_name); + let mut dt = DateTime { y: 0, m: 1, d: 1, hh: 0, mm: 0, ss: 0 }; + // The next number for a field. A field joined to the next one in the format ("yyyymmdd") + // takes only its own width from a longer run of digits and leaves the rest for the next. + // A run typed on its own is split only when fewer runs were typed than the fields of its kind + // still to fill (`need`, this one included: date fields for a date field, time fields for a + // time field, since times may be left out), and only when its rest can fill the next field + // (`next_width`): with separators each run is one field's number, so "2026-10-18" keeps its + // four-digit year in a yymmdd or yymmddHHMM field, and "2026 10 018" is the 18th. What is left + // of a split run (`tail`: the front of `nums` is one) goes on being split along the fields + // written side by side, and must fill its last field exactly, so "202610018" leaves "018" for + // dd and is refused. `None` when there is no number left, it doesn't fit a u32, or such a + // rest has the wrong width. + let next_number = |nums: &mut VecDeque, tail: &mut bool, part: &Part, need: usize, next_width: usize| -> Option { + let run = nums.pop_front()?; + let was_tail = std::mem::replace(tail, false); + let short = nums.len() < need.saturating_sub(1); + let digits = match (run.get(..part.width), run.get(part.width..)) { + (Some(head), Some(rest)) if part.joined && part.width > 0 && !rest.is_empty() && (was_tail || (short && rest.len() >= next_width)) => { + nums.push_front(rest.to_string()); + *tail = true; + head + } + _ if was_tail && part.width > 0 && run.len() != part.width => return None, + _ => run.as_str(), + }; + digits.parse::().ok() + }; + let parts = order(fmt); + let takes_number = |p: &Part| matches!(p.kind, 'y' | 'd' | 'H' | 'M' | 's') || (p.kind == 'm' && month_name.is_none()); + let is_time = |p: &Part| matches!(p.kind, 'H' | 'M' | 's'); + // The fields of each kind still to fill: [date, time]. + let mut wanted = [parts.iter().filter(|p| takes_number(p) && !is_time(p)).count(), parts.iter().filter(|p| is_time(p)).count()]; + let mut tail = false; + let mut have_y = false; + for (i, part) in parts.iter().enumerate() { + let kind = usize::from(is_time(part)); + let need = wanted.get(kind).copied().unwrap_or(0); + let next_width = parts.get(i + 1).map_or(0, |p| p.width); + if takes_number(part) + && let Some(left) = wanted.get_mut(kind) + { + *left = left.saturating_sub(1); + } + let take = |nums: &mut VecDeque, tail: &mut bool| next_number(nums, tail, part, need, next_width); + match part.kind { + // Literal digits in the format may be typed or left out ("15.05.26" or "15.05" + // for "dd.mm.26"); a different number there is left over and rejected below. + '#' if nums.front().and_then(|n| n.parse::().ok()) == Some(part.literal) => { + nums.pop_front(); + tail = false; + } + 'm' => { + dt.m = match month_name { + Some(m) => m, + None => take(&mut nums, &mut tail)?, + } + } + 'd' => dt.d = take(&mut nums, &mut tail)?, + 'y' => { + let y = i32::try_from(take(&mut nums, &mut tail)?).ok()?; + dt.y = if y < 100 { if y < 50 { 2000 + y } else { 1900 + y } } else { y }; + have_y = true; + } + // Times may be left out; a number that is there must be readable. + 'H' => dt.hh = if nums.is_empty() { 0 } else { take(&mut nums, &mut tail)? }, + 'M' => dt.mm = if nums.is_empty() { 0 } else { take(&mut nums, &mut tail)? }, + 's' => dt.ss = if nums.is_empty() { 0 } else { take(&mut nums, &mut tail)? }, + _ => {} + } + } + if !nums.is_empty() { + return None; + } + if !have_y { + dt.y = 2000; + } + if pm && dt.hh < 12 { + dt.hh += 12; + } + if am && dt.hh == 12 { + dt.hh = 0; + } + let has_date = fmt.contains(['y', 'm', 'd']); + if has_date && (!(1..=12).contains(&dt.m) || dt.d == 0 || dt.d > days_in(dt.y, dt.m)) { + return None; + } + if dt.hh > 23 || dt.mm > 59 || dt.ss > 59 { + return None; + } + Some(dt) +} + +/// Format a date/time with Acrobat's date format letters. +pub fn format_date(dt: DateTime, fmt: &str) -> String { + let chars: Vec = fmt.chars().collect(); + let mut out = String::new(); + let mut i = 0; + while i < chars.len() { + let c = chars[i]; + let mut n = 1; + while i + n < chars.len() && chars[i + n] == c { + n += 1; + } + let h12 = if dt.hh.is_multiple_of(12) { 12 } else { dt.hh % 12 }; + match (c, n) { + ('y', 4..) => { + let _ = write!(out, "{:04}", dt.y); + } + ('y', _) => { + let _ = write!(out, "{:02}", dt.y.rem_euclid(100)); + } + ('m', 3..) => match MONTHS.get((dt.m as usize).wrapping_sub(1)) { + Some(name) if n >= 4 => out.push_str(name), + Some(name) => out.push_str(name.get(..3).unwrap_or(name)), + None => { + let _ = write!(out, "{}", dt.m); + } + }, + ('m', 2) => { + let _ = write!(out, "{:02}", dt.m); + } + ('m', _) => { + let _ = write!(out, "{}", dt.m); + } + ('d', 3..) => { + let name = DAYS.get(weekday(dt.y, dt.m, dt.d)).copied().unwrap_or_default(); + out.push_str(if n >= 4 { name } else { name.get(..3).unwrap_or(name) }); + } + ('d', 2) => { + let _ = write!(out, "{:02}", dt.d); + } + ('d', _) => { + let _ = write!(out, "{}", dt.d); + } + ('H', 2) => { + let _ = write!(out, "{:02}", dt.hh); + } + ('H', _) => { + let _ = write!(out, "{}", dt.hh); + } + ('h', 2) => { + let _ = write!(out, "{h12:02}"); + } + ('h', _) => { + let _ = write!(out, "{h12}"); + } + ('M', 2) => { + let _ = write!(out, "{:02}", dt.mm); + } + ('M', _) => { + let _ = write!(out, "{}", dt.mm); + } + ('s', 2) => { + let _ = write!(out, "{:02}", dt.ss); + } + ('s', _) => { + let _ = write!(out, "{}", dt.ss); + } + ('t', 2) => out.push_str(if dt.hh < 12 { "am" } else { "pm" }), + ('t', _) => out.push(if dt.hh < 12 { 'a' } else { 'p' }), + (c, n) => out.extend(std::iter::repeat_n(c, n)), + } + i += n; + } + out +} + +const SPECIAL_MASKS: [&str; 4] = ["99999", "99999-9999", "(999) 999-9999", "999-99-9999"]; + +fn mask_fits(mask: &str, text: &str) -> bool { + let t: Vec = text.chars().collect(); + let m: Vec = mask.chars().collect(); + t.len() == m.len() + && m.iter().zip(&t).all(|(mc, tc)| match mc { + '9' => tc.is_ascii_digit(), + 'A' => tc.is_alphabetic(), + 'O' => tc.is_alphanumeric(), + 'X' => true, + other => other == tc, + }) +} + +/// Fill a mask from the typed characters that fit its slots. +fn apply_mask(mask: &str, text: &str) -> Option { + let mut src = text.chars().filter(|c| c.is_alphanumeric()); + let mut out = String::new(); + for mc in mask.chars() { + match mc { + '9' | 'A' | 'O' | 'X' => { + let c = src.next()?; + let ok = match mc { + '9' => c.is_ascii_digit(), + 'A' => c.is_alphabetic(), + _ => true, + }; + if !ok { + return None; + } + out.push(c); + } + other => out.push(other), + } + } + src.next().is_none().then_some(out) +} + +/// The text shown for a value (the Format event). +pub fn format_value(f: &Format, value: &str) -> String { + if value.is_empty() { + return String::new(); + } + match f { + Format::None => value.to_string(), + Format::Number { decimals, sep, neg, currency, prepend } => { + let Some(v) = make_number(value) else { return value.to_string() }; + let body = number_text(v, *decimals, *sep); + let body = match (currency.is_empty(), *prepend) { + (true, _) => body, + (false, true) => format!("{currency}{body}"), + (false, false) => format!("{body}{currency}"), + }; + match (v < 0.0, neg) { + (false, _) => body, + // Styles 2 and 3 use parentheses (3 is also red, which the appearance can't + // know here); 0 and 1 a minus sign. + (true, 2 | 3) => format!("({body})"), + (true, _) => format!("-{body}"), + } + } + Format::Percent { decimals, sep } => match make_number(value) { + Some(v) => format!( + "{}%", + if v < 0.0 { format!("-{}", number_text(v * 100.0, *decimals, *sep)) } else { number_text(v * 100.0, *decimals, *sep) } + ), + None => value.to_string(), + }, + Format::Date(fmt) | Format::Time(fmt) => parse_date(value, fmt).map_or_else(|| value.to_string(), |dt| format_date(dt, fmt)), + Format::Special(n) => apply_mask(SPECIAL_MASKS[(*n).min(3) as usize], value).unwrap_or_else(|| value.to_string()), + Format::Mask(m) => apply_mask(m, value).unwrap_or_else(|| value.to_string()), + } +} + +/// The Keystroke event on commit: does the value fit the format? Returns the value to store +/// (numbers are normalised), or Acrobat's message. +pub fn keystroke(f: &Format, field: &str, value: &str) -> Result { + let bad = || format!("The value entered does not match the format of the field [ {field} ]"); + // Blank input clears a formatted field; without a format, whitespace is text like any other. + if *f != Format::None && value.trim().is_empty() { + return Ok(String::new()); + } + match f { + Format::None => Ok(value.to_string()), + Format::Number { .. } => make_number(value).map(js_number).ok_or_else(bad), + Format::Percent { .. } => { + // Typed "12.5%" or "12.5" both mean 0.125 once stored? Acrobat stores the number + // as typed and shows it ×100: keep the typed number. + make_number(value).map(js_number).ok_or_else(bad) + } + Format::Date(fmt) | Format::Time(fmt) => parse_date(value, fmt) + .map(|_| value.trim().to_string()) + .ok_or_else(|| format!("Invalid date/time: please ensure that the date/time exists. Field [ {field} ] should match format {fmt}")), + Format::Special(n) => { + let m = SPECIAL_MASKS[(*n).min(3) as usize]; + apply_mask(m, value).filter(|s| mask_fits(m, s)).map(|_| value.trim().to_string()).ok_or_else(bad) + } + Format::Mask(m) => apply_mask(m, value).map(|_| value.trim().to_string()).ok_or_else(bad), + } +} + +/// The Validate event: `Err` with Acrobat's message when the value is out of range. +pub fn validate(v: &Validate, value: &str) -> Result<(), String> { + let Validate::Range { min, max } = v else { return Ok(()) }; + if value.trim().is_empty() { + return Ok(()); + } + let Some(x) = make_number(value) else { return Ok(()) }; + let fmt = |n: f64| js_number(n); + let ok = min.is_none_or(|m| x >= m) && max.is_none_or(|m| x <= m); + if ok { + return Ok(()); + } + Err(match (min, max) { + (Some(a), Some(b)) => format!("Invalid value: must be greater than or equal to {} and less than or equal to {}.", fmt(*a), fmt(*b)), + (Some(a), None) => format!("Invalid value: must be greater than or equal to {}.", fmt(*a)), + (None, Some(b)) => format!("Invalid value: must be less than or equal to {}.", fmt(*b)), + (None, None) => String::new(), + }) +} diff --git a/crates/forms/src/af/script.rs b/crates/forms/src/af/script.rs new file mode 100644 index 0000000000..9aed297b45 --- /dev/null +++ b/crates/forms/src/af/script.rs @@ -0,0 +1,340 @@ +use super::calc::{js_tokens, notation_js, notation_range}; +use super::format::{DATE_PRESETS, TIME_PRESETS}; +use super::{CalcOp, Calculate, Format, Validate}; + +// ── parsing scripts ───────────────────────────────────────────────────────────────────────── + +#[derive(Clone, Debug, PartialEq)] +pub(super) enum Arg { + Num(f64), + Str(String), + Bool(bool), + List(Vec), +} + +impl Arg { + fn num(&self) -> Option { + match self { + Arg::Num(n) => Some(*n), + Arg::Bool(b) => Some(f64::from(u8::from(*b))), + Arg::Str(s) => s.trim().parse().ok(), + Arg::List(_) => None, + } + } + + pub(super) fn string(&self) -> Option { + match self { + Arg::Str(s) => Some(s.clone()), + Arg::Num(n) => Some(n.to_string()), + _ => None, + } + } + + fn truthy(&self) -> bool { + match self { + Arg::Bool(b) => *b, + Arg::Num(n) => *n != 0.0, + Arg::Str(s) => !s.is_empty(), + Arg::List(l) => !l.is_empty(), + } + } +} + +/// How deeply script arguments and field notation may nest; deeper input is rejected rather +/// than recursed into (a hostile file could otherwise overflow the stack). +pub(super) const MAX_NESTING: usize = 64; + +fn hex4(bytes: &[u8]) -> Option { + if bytes.len() != 4 { + return None; + } + bytes.iter().try_fold(0u16, |value, byte| { + let digit = match byte { + b'0'..=b'9' => byte - b'0', + b'a'..=b'f' => byte - b'a' + 10, + b'A'..=b'F' => byte - b'A' + 10, + _ => return None, + }; + value.checked_mul(16)?.checked_add(u16::from(digit)) + }) +} + +pub(super) struct Parser<'a> { + pub(super) s: &'a [u8], + pub(super) i: usize, + pub(super) depth: usize, +} + +impl Parser<'_> { + fn ws(&mut self) { + while self.i < self.s.len() && (self.s[self.i].is_ascii_whitespace()) { + self.i += 1; + } + } + + pub(super) fn string(&mut self) -> Option { + let q = self.s[self.i]; + self.i += 1; + let mut out = Vec::new(); + while self.i < self.s.len() { + let c = self.s[self.i]; + self.i += 1; + match c { + b'\\' if self.i < self.s.len() => { + let e = self.s[self.i]; + self.i += 1; + match e { + b'n' => out.push(b'\n'), + b'r' => out.push(b'\r'), + b't' => out.push(b'\t'), + b'b' => out.push(0x08), + b'f' => out.push(0x0C), + b'v' => out.push(0x0B), + b'0' => out.push(0), + b'x' => { + let digits = self.s.get(self.i..self.i.checked_add(2)?)?; + let code = hex4(&[b'0', b'0', digits[0], digits[1]])?; + self.i += 2; + let ch = char::from_u32(u32::from(code)).unwrap_or('\u{FFFD}'); + let mut encoded = [0u8; 4]; + out.extend_from_slice(ch.encode_utf8(&mut encoded).as_bytes()); + } + b'u' => { + let code = hex4(self.s.get(self.i..self.i.checked_add(4)?)?)?; + self.i += 4; + let code = if (0xD800..=0xDBFF).contains(&code) && self.s.get(self.i..self.i.checked_add(6)?)?.starts_with(b"\\u") { + let low = hex4(self.s.get(self.i.checked_add(2)?..self.i.checked_add(6)?)?)?; + if (0xDC00..=0xDFFF).contains(&low) { + self.i += 6; + 0x10000 + ((u32::from(code) - 0xD800) << 10) + (u32::from(low) - 0xDC00) + } else { + u32::from(code) + } + } else { + u32::from(code) + }; + let ch = char::from_u32(code).unwrap_or('\u{FFFD}'); + let mut encoded = [0u8; 4]; + out.extend_from_slice(ch.encode_utf8(&mut encoded).as_bytes()); + } + other => out.push(other), + } + } + c if c == q => return Some(String::from_utf8_lossy(&out).into_owned()), + c => out.push(c), + } + } + None + } + + fn arg(&mut self) -> Option { + self.ws(); + let c = *self.s.get(self.i)?; + match c { + b'"' | b'\'' => self.string().map(Arg::Str), + b'[' => { + self.i += 1; + self.nested(b']').map(Arg::List) + } + _ if self.s[self.i..].starts_with(b"new Array") => { + self.i += b"new Array".len(); + self.ws(); + if self.s.get(self.i) != Some(&b'(') { + return None; + } + self.i += 1; + self.nested(b')').map(Arg::List) + } + _ if self.s[self.i..].starts_with(b"true") => { + self.i += 4; + Some(Arg::Bool(true)) + } + _ if self.s[self.i..].starts_with(b"false") => { + self.i += 5; + Some(Arg::Bool(false)) + } + _ => { + let start = self.i; + while self.i < self.s.len() && (self.s[self.i].is_ascii_digit() || matches!(self.s[self.i], b'.' | b'-' | b'+' | b'e' | b'E')) { + self.i += 1; + } + std::str::from_utf8(&self.s[start..self.i]).ok()?.parse().ok().map(Arg::Num) + } + } + } + + /// A list inside another, at most [`MAX_NESTING`] deep. + fn nested(&mut self, close: u8) -> Option> { + if self.depth >= MAX_NESTING { + return None; + } + self.depth += 1; + let out = self.list(close); + self.depth -= 1; + out + } + + fn list(&mut self, close: u8) -> Option> { + let mut out = Vec::new(); + loop { + self.ws(); + if self.s.get(self.i) == Some(&close) { + self.i += 1; + return Some(out); + } + out.push(self.arg()?); + self.ws(); + match self.s.get(self.i) { + Some(b',') => self.i += 1, + Some(c) if *c == close => { + self.i += 1; + return Some(out); + } + _ => return None, + } + } + } +} + +/// The first call to `name(` in `js` and its arguments. +pub(super) fn call(js: &str, name: &str) -> Option> { + let at = js.find(&format!("{name}("))?; + // Not part of a longer identifier (AFDate_Format vs AFDate_FormatEx). + if js[..at].chars().last().is_some_and(|c| c.is_alphanumeric() || c == '_') { + return None; + } + let mut p = Parser { s: js.as_bytes(), i: at + name.len() + 1, depth: 0 }; + p.list(b')') +} + +pub fn parse_format(js: &str) -> Option { + let a = |args: &[Arg], i: usize| args.get(i).and_then(Arg::num).unwrap_or(0.0).clamp(0.0, 255.0) as u8; + if let Some(args) = call(js, "AFNumber_Format") { + return Some(Format::Number { + decimals: a(&args, 0), + sep: a(&args, 1), + neg: a(&args, 2), + currency: args.get(4).and_then(Arg::string).unwrap_or_default(), + prepend: args.get(5).is_none_or(Arg::truthy), + }); + } + if let Some(args) = call(js, "AFPercent_Format") { + return Some(Format::Percent { decimals: a(&args, 0), sep: a(&args, 1) }); + } + if let Some(args) = call(js, "AFDate_FormatEx") { + return Some(Format::Date(args.first().and_then(Arg::string)?)); + } + if let Some(args) = call(js, "AFDate_Format") { + return Some(Format::Date(DATE_PRESETS.get(a(&args, 0) as usize)?.to_string())); + } + if let Some(args) = call(js, "AFTime_FormatEx") { + return Some(Format::Time(args.first().and_then(Arg::string)?)); + } + if let Some(args) = call(js, "AFTime_Format") { + return Some(Format::Time(TIME_PRESETS.get(a(&args, 0) as usize)?.to_string())); + } + if let Some(args) = call(js, "AFSpecial_Format") { + return Some(Format::Special(a(&args, 0).min(3))); + } + if let Some(args) = call(js, "AFSpecial_KeystrokeEx") { + return Some(Format::Mask(args.first().and_then(Arg::string)?)); + } + None +} + +pub fn parse_validate(js: &str) -> Option { + let args = call(js, "AFRange_Validate")?; + let flag = |i: usize| args.get(i).is_some_and(Arg::truthy); + let num = |i: usize| args.get(i).and_then(Arg::num); + Some(Validate::Range { min: if flag(0) { num(1) } else { None }, max: if flag(2) { num(3) } else { None } }) +} + +pub fn parse_calculate(js: &str) -> Option { + // Simplified field notation: Acrobat keeps the expression in a comment. A script is read for + // its comments only when it spells the marker. + if js.contains("BVCALC") + && let Some(range) = notation_range(js, &js_tokens(js).1) + { + return Some(Calculate::Notation(js.get(range)?.trim().to_string())); + } + let args = call(js, "AFSimple_Calculate")?; + let op = match args.first().and_then(Arg::string)?.as_str() { + "SUM" => CalcOp::Sum, + "PRD" => CalcOp::Product, + "AVG" => CalcOp::Average, + "MIN" => CalcOp::Minimum, + "MAX" => CalcOp::Maximum, + _ => return None, + }; + let fields = match args.get(1)? { + Arg::List(l) => l.iter().filter_map(Arg::string).collect(), + Arg::Str(s) => s.split(',').map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).collect(), + _ => return None, + }; + Some(Calculate::Simple { op, fields }) +} + +// ── writing scripts (what Acrobat's tabs generate) ───────────────────────────────────────── + +pub(super) fn js_str(s: &str) -> String { + let mut o = String::from("\""); + for c in s.chars() { + match c { + '"' | '\\' => { + o.push('\\'); + o.push(c); + } + '\n' => o.push_str("\\n"), + '\r' => o.push_str("\\r"), + '\t' => o.push_str("\\t"), + c if c.is_ascii() && !c.is_control() => o.push(c), + c => { + let mut units = [0u16; 2]; + for unit in c.encode_utf16(&mut units) { + o.push_str(&format!("\\u{unit:04X}")); + } + } + } + } + o.push('"'); + o +} + +/// The (format, keystroke) scripts for a format. +pub fn format_js(f: &Format) -> Option<(String, String)> { + Some(match f { + Format::None => return None, + Format::Number { decimals, sep, neg, currency, prepend } => { + let args = format!("{decimals}, {sep}, {neg}, 0, {}, {prepend}", js_str(currency)); + (format!("AFNumber_Format({args});"), format!("AFNumber_Keystroke({args});")) + } + Format::Percent { decimals, sep } => (format!("AFPercent_Format({decimals}, {sep});"), format!("AFPercent_Keystroke({decimals}, {sep});")), + Format::Date(f) => (format!("AFDate_FormatEx({});", js_str(f)), format!("AFDate_KeystrokeEx({});", js_str(f))), + Format::Time(f) => (format!("AFTime_FormatEx({});", js_str(f)), format!("AFTime_KeystrokeEx({});", js_str(f))), + Format::Special(n) => (format!("AFSpecial_Format({n});"), format!("AFSpecial_Keystroke({n});")), + Format::Mask(m) => (String::new(), format!("AFSpecial_KeystrokeEx({});", js_str(m))), + }) +} + +pub fn validate_js(v: &Validate) -> Option { + match v { + Validate::None => None, + Validate::Range { min, max } => { + Some(format!("AFRange_Validate({}, {}, {}, {});", min.is_some(), min.unwrap_or(0.0), max.is_some(), max.unwrap_or(0.0))) + } + } +} + +pub fn calculate_js(c: &Calculate) -> Option { + match c { + Calculate::None => None, + Calculate::Simple { op, fields } => Some(format!( + "AFSimple_Calculate({}, new Array({}));", + js_str(op.code()), + fields.iter().map(|f| js_str(f)).collect::>().join(", ") + )), + // Acrobat's own translation follows the comment, so a viewer that runs the script instead + // of reading the comment computes the same value (a placeholder here showed 0). + Calculate::Notation(expr) => Some(format!("/** BVCALC {expr} EVCALC **/ event.value = {};", notation_js(expr))), + } +} diff --git a/crates/forms/src/af/tests.rs b/crates/forms/src/af/tests.rs new file mode 100644 index 0000000000..ff25e06968 --- /dev/null +++ b/crates/forms/src/af/tests.rs @@ -0,0 +1,309 @@ +use super::*; + +#[test] +fn javascript_escapes_are_decoded() { + assert_eq!( + parse_format("AFNumber_Format(2, 0, 0, 0, \"\\x24\", true);"), + Some(Format::Number { decimals: 2, sep: 0, neg: 0, currency: "$".into(), prepend: true }) + ); + assert_eq!(parse_format("AFDate_FormatEx(\"\\b\\f\\v\\0\\q\");"), Some(Format::Date("\u{8}\u{c}\u{b}\0q".into()))); +} + +#[test] +fn scripts_are_recognised_and_written_back() { + assert_eq!( + parse_format("AFNumber_Format(2, 0, 0, 0, \"$\", true);"), + Some(Format::Number { decimals: 2, sep: 0, neg: 0, currency: "$".into(), prepend: true }) + ); + assert_eq!(parse_format("AFPercent_Format(1, 0);"), Some(Format::Percent { decimals: 1, sep: 0 })); + assert_eq!(parse_format("AFDate_FormatEx(\"dd-mmm-yyyy\");"), Some(Format::Date("dd-mmm-yyyy".into()))); + assert_eq!(parse_format("AFDate_Format(2);"), Some(Format::Date("mm/dd/yy".into()))); + assert_eq!(parse_format("AFTime_Format(1);"), Some(Format::Time("h:MM tt".into()))); + assert_eq!(parse_format("AFSpecial_Format(2);"), Some(Format::Special(2))); + assert_eq!(parse_format("AFSpecial_KeystrokeEx(\"AA-9999\");"), Some(Format::Mask("AA-9999".into()))); + assert_eq!(parse_format("app.alert('hi')"), None); + assert_eq!(parse_validate("AFRange_Validate(true, 0, true, 100);"), Some(Validate::Range { min: Some(0.0), max: Some(100.0) })); + assert_eq!(parse_validate("AFRange_Validate(false, 0, true, 10);"), Some(Validate::Range { min: None, max: Some(10.0) })); + assert_eq!( + parse_calculate("AFSimple_Calculate(\"SUM\", new Array (\"a\", \"b.c\"));"), + Some(Calculate::Simple { op: CalcOp::Sum, fields: vec!["a".into(), "b.c".into()] }) + ); + assert_eq!(parse_calculate("AFSimple_Calculate('AVG', ['x']);"), Some(Calculate::Simple { op: CalcOp::Average, fields: vec!["x".into()] })); + assert_eq!( + parse_calculate("/** BVCALC Price * Qty EVCALC **/ event.value = AFMakeNumber(getField(\"Price\").value) * …"), + Some(Calculate::Notation("Price * Qty".into())) + ); + // Round trips through what the tabs write. + for f in [ + Format::Number { decimals: 2, sep: 2, neg: 2, currency: "€".into(), prepend: false }, + Format::Percent { decimals: 0, sep: 0 }, + Format::Date("mmmm d, yyyy".into()), + Format::Special(3), + ] { + let (fj, _) = format_js(&f).unwrap(); + assert_eq!(parse_format(&fj), Some(f)); + } + let euro = format_js(&Format::Number { decimals: 2, sep: 2, neg: 0, currency: "€".into(), prepend: true }).unwrap().0; + assert!(euro.contains("\\u20AC"), "{euro}"); + assert!(!euro.contains('€'), "{euro}"); + let pound = format_js(&Format::Number { decimals: 2, sep: 0, neg: 0, currency: "£".into(), prepend: true }).unwrap().0; + assert!(pound.contains("\\u00A3"), "{pound}"); + assert!(!pound.contains('£'), "{pound}"); + let c = Calculate::Simple { op: CalcOp::Product, fields: vec!["q".into(), "p".into()] }; + assert_eq!(parse_calculate(&calculate_js(&c).unwrap()), Some(c)); + let c = Calculate::Notation("a + b".into()); + assert_eq!(parse_calculate(&calculate_js(&c).unwrap()), Some(c)); + // The JavaScript after the comment is Acrobat's translation, so viewers that run it + // instead of reading the comment compute the same value (not 0). + assert_eq!( + calculate_js(&Calculate::Notation("(Unit\\ Price * Qty) / 2".into())).unwrap(), + "/** BVCALC (Unit\\ Price * Qty) / 2 EVCALC **/ event.value = (AFMakeNumber(this.getField(\"Unit Price\").value) * AFMakeNumber(this.getField(\"Qty\").value)) / 2;" + ); +} + +#[test] +fn a_rename_rewrites_only_where_a_calculation_reads_the_field() { + let renamed = |js: &str| rename_in_calculation(js, "a", "qty"); + // The field lists Acrobat and other tools write for AFSimple_Calculate. + assert_eq!( + renamed("AFSimple_Calculate(\"SUM\", new Array (\"a\", \"b\"));").as_deref(), + Some("AFSimple_Calculate(\"SUM\", new Array (\"qty\", \"b\"));") + ); + assert_eq!(renamed("AFSimple_Calculate('AVG', ['b', 'a']);").as_deref(), Some("AFSimple_Calculate('AVG', ['b', \"qty\"]);")); + assert_eq!(renamed("AFSimple_Calculate(\"SUM\", \"a, b,a\");").as_deref(), Some("AFSimple_Calculate(\"SUM\", \"qty, b,qty\");")); + assert_eq!(renamed("AFSimple_Calculate(\"SUM\", new Array(\"ab\", \"b.a\"));"), None, "other names that contain it"); + // Notation: the expression in the comment and Acrobat's translation after it. + let notation = |expr: &str| calculate_js(&Calculate::Notation(expr.into())).unwrap(); + assert_eq!(rename_in_calculation(¬ation("(a + ab) * a / 2"), "a", "Unit Price"), Some(notation("(Unit\\ Price + ab) * Unit\\ Price / 2"))); + assert_eq!(rename_in_calculation(¬ation("Unit\\ Price * 2"), "Unit Price", "price"), Some(notation("price * 2"))); + // Scripts: a getField whose argument is one string literal. Comments, strings, template + // literals, regular expressions and names built at run time are left as written. + let script = "// getField(\"a\")\nvar s = \"getField('a')\", t = `getField(\"a\")`; /* getField(\"a\") */\n\ + if (/\"/.test(s)) event.value = this.getField('a').value / 2 * getField(\"a\" + 1).value + getField ( \"ab\" ).value;"; + assert_eq!(renamed(script), Some(script.replacen("this.getField('a')", "this.getField(\"qty\")", 1))); + assert_eq!(renamed("event.value = getField(\"b\").value;"), None); + // A new name is escaped as the script needs it. + assert_eq!( + rename_in_calculation("event.value = getField(\"a\").value;", "a", "say \"é\"").as_deref(), + Some("event.value = getField(\"say \\\"\\u00E9\\\"\").value;") + ); + // A name that would end the notation early is not written in. + assert_eq!(rename_in_calculation(¬ation("a * 2"), "a", "qtyEVCALC"), None); + // Unfinished scripts end their tokens, not the program. + for js in ["getField(\"a", "/* getField(\"a\")", "`getField(\"a\")", "x = /[/", "getField('a\\u12"] { + assert_eq!(renamed(js), None, "{js}"); + } +} + +/// The notation is read only from a comment, where Acrobat writes it: a string or code that +/// spells the markers is left alone, by the Calculate tab and by a rename. +#[test] +fn notation_is_read_only_from_a_comment() { + let string = "var s = \"BVCALC a EVCALC\"; event.value = getField(\"b\").value;"; + assert_eq!(parse_calculate(string), None); + assert_eq!(rename_in_calculation(string, "a", "qty"), None); + // A comment after such a string holds the notation. + let both = "var s = 'BVCALC x EVCALC'; /** BVCALC a + 1 EVCALC **/ event.value = 1;"; + assert_eq!(parse_calculate(both), Some(Calculate::Notation("a + 1".into()))); + assert_eq!(rename_in_calculation(both, "x", "y"), None); + assert_eq!(rename_in_calculation(both, "a", "qty"), Some(both.replace("a + 1", "qty + 1"))); + // A line comment is a comment too, and an unfinished one still holds it. + assert_eq!(parse_calculate("// BVCALC a EVCALC\nevent.value = 1;"), Some(Calculate::Notation("a".into()))); + assert_eq!(parse_calculate("/** BVCALC a EVCALC"), Some(Calculate::Notation("a".into()))); +} + +/// A line comment ends at any of JavaScript's line terminators, not only `\n`, so with `\r` line +/// endings the code after one is still code; and a `/` read as starting a regular expression +/// divides after all when the line ends first. +#[test] +fn a_line_comment_ends_at_any_line_terminator() { + for end in ["\n", "\r", "\r\n", "\u{2028}", "\u{2029}"] { + let string = format!("// header{end}var s = \"BVCALC a EVCALC\"; event.value = getField(\"b\").value;"); + assert_eq!(parse_calculate(&string), None, "{end:?}"); + assert_eq!(rename_in_calculation(&string, "a", "qty"), None, "{end:?}"); + let script = format!("// note{end}getField(\"a\").value;"); + assert_eq!(rename_in_calculation(&script, "a", "qty"), Some(script.replace("\"a\"", "\"qty\"")), "{end:?}"); + let divides = format!("var n = i++ / 2;{end}event.value = getField(\"a\").value / 4;"); + assert_eq!(rename_in_calculation(÷s, "a", "qty"), Some(divides.replace("\"a\"", "\"qty\"")), "{end:?}"); + } +} + +/// U+2028 and U+2029 end a word, a number or a regular expression's flags, though as bytes +/// past ASCII they could be part of one, so a `getField` right after one is still a call. +#[test] +fn a_token_ends_at_a_line_separator() { + for end in ["\u{2028}", "\u{2029}"] { + let scripts = ["x", "1", "/r/g"].map(|token| format!("var t = {token}{end}getField(\"a\").value;")); + let renamed = scripts.clone().map(|js| rename_in_calculation(&js, "a", "qty")); + assert_eq!(renamed, scripts.map(|js| Some(js.replace("\"a\"", "\"qty\""))), "{end:?}"); + } +} + +/// A `/` after the condition of `if`, `while`, `for` or `with` starts a regular expression, +/// as after an operator; after a call's parentheses it still divides. +#[test] +fn a_regex_after_a_condition_is_not_read_as_code() { + let renamed = |js: &str| rename_in_calculation(js, "a", "qty"); + assert_eq!(renamed("if (flag) /getField(\"a\")/.test(x); event.value = 1;"), None); + // A quote inside it doesn't start a string that hides the rest of the script. + let quote = "while (i--) /\"/.test(s); event.value = getField(\"a\").value;"; + assert_eq!(renamed(quote), Some(quote.replace("\"a\"", "\"qty\""))); + // The same after the `while` that ends a `do` loop. + let after_do = "do i++; while (i < n) /\"/.test(s); event.value = getField(\"a\").value;"; + assert_eq!(renamed(after_do), Some(after_do.replace("\"a\"", "\"qty\""))); + // After a call, or a method that happens to be called `if`, the `/` divides. + let divides = "event.value = Math.abs(getField(\"b\").value) / 2 + getField(\"a\").value / 3 + o.if(x) / 4 + getField(\"a\").value / 5;"; + assert_eq!(renamed(divides), Some(divides.replace("\"a\"", "\"qty\""))); +} + +/// Scripts are read in one pass: a line of unfinished regular expressions, or calls nested in +/// one another's field lists, don't make a rename read the rest of the script again for each. +#[test] +fn hostile_scripts_are_read_in_one_pass() { + let regexes = format!("x = {} getField(\"a\")", "/[".repeat(500_000)); + assert!(rename_in_calculation(®exes, "a", "b").is_some_and(|js| js.ends_with("getField(\"b\")"))); + let nested = format!("{}\"a\"", "AFSimple_Calculate(\"SUM\", new Array(".repeat(100_000)); + assert_eq!(rename_in_calculation(&nested, "a", "b"), Some(format!("{}\"b\"", "AFSimple_Calculate(\"SUM\", new Array(".repeat(100_000)))); +} + +/// Notation is read through one lexer: field names with escapes, numbers and operators. +#[test] +fn notation_is_read_by_one_lexer() { + let tokens: Vec = notation_tokens("(Unit\\ Price+1.5)*qty_2\\").into_iter().map(|(_, t)| t).collect(); + assert_eq!( + tokens, + [ + Notation::Op('('), + Notation::Name("Unit Price".into()), + Notation::Op('+'), + Notation::Number, + Notation::Op(')'), + Notation::Op('*'), + Notation::Name("qty_2\\".into()) + ] + ); + assert_eq!(notation_js("2 * a\\ b"), "2 * AFMakeNumber(this.getField(\"a b\").value)"); + let values = |name: &str| if name == "Unit Price" { 4.0 } else { 0.5 }; + assert_eq!(eval_notation("(Unit\\ Price + 1.5) * qty", &values), Some(2.75)); + assert_eq!(eval_notation("1.2.3 + a", &values), None, "a number that isn't one"); + assert_eq!(notation_name("2nd + (x)\\"), "\\2nd\\ \\+\\ \\(x\\)\\\\"); +} + +proptest::proptest! { + /// Any field name written in notation reads back as that one name. + #[test] + fn any_name_written_in_notation_reads_back(name in "\\PC{1,12}") { + let written = notation_name(&name); + proptest::prop_assert_eq!(notation_tokens(&written), vec![(0..written.len(), Notation::Name(name))]); + } + + /// Renaming a field in a formula and back gives the formula as it was. + #[test] + fn a_rename_round_trips(old in "\\PC{1,8}", new in "\\PC{1,8}", others in proptest::collection::vec("\\PC{1,8}", 0..4), sum in proptest::bool::ANY) { + proptest::prop_assume!(old != new && !others.contains(&new) && ![&old, &new].into_iter().chain(&others).any(|n| n.contains("VCALC"))); + let names: Vec = std::iter::once(old.clone()).chain(others).collect(); + let js = if sum { + calculate_js(&Calculate::Simple { op: CalcOp::Sum, fields: names }).unwrap() + } else { + calculate_js(&Calculate::Notation(names.iter().map(|n| notation_name(n)).collect::>().join(" * "))).unwrap() + }; + let renamed = rename_in_calculation(&js, &old, &new); + proptest::prop_assert!(renamed.is_some(), "{js}"); + let back = renamed.as_deref().and_then(|r| rename_in_calculation(r, &new, &old)); + proptest::prop_assert_eq!(back, Some(js)); + } + + /// What a formula reads is the same whether it is read from the tab's settings or from + /// the script they are written as. + #[test] + fn a_formula_reads_the_same_fields_as_its_script(names in proptest::collection::vec("\\PC{1,8}", 1..5), sum in proptest::bool::ANY) { + proptest::prop_assume!(!names.iter().any(|n| n.contains("VCALC"))); + let c = if sum { + Calculate::Simple { op: CalcOp::Product, fields: names.clone() } + } else { + Calculate::Notation(names.iter().map(|n| notation_name(n)).collect::>().join(" + ")) + }; + let js = calculate_js(&c).unwrap(); + proptest::prop_assert_eq!(calculation_reads(&js), c.reads()); + proptest::prop_assert_eq!(c.reads(), unique(names)); + } +} + +#[test] +fn a_calculation_reads_each_named_field_once() { + assert_eq!(Calculate::Simple { op: CalcOp::Sum, fields: vec!["a".into(), "b".into(), "a".into()] }.reads(), ["a", "b"]); + assert_eq!(Calculate::Notation("(Unit\\ Price + 2) * qty / qty".into()).reads(), ["Unit Price", "qty"]); + assert_eq!(Calculate::None.reads(), Vec::::new()); + let script = "var t = getField(\"sub\").value; // getField(\"x\")\nevent.value = t + getField(\"tax\").value + getField(\"row\" + 1).value;"; + assert_eq!(calculation_reads(script), ["sub", "tax"], "names built at run time aren't known"); + assert_eq!(calculation_reads("AFSimple_Calculate(\"SUM\", \"a, b\");"), ["a", "b"]); +} + +#[test] +fn numbers_format_like_acrobat() { + let n = |d, s, ng, c: &str, p| Format::Number { decimals: d, sep: s, neg: ng, currency: c.into(), prepend: p }; + assert_eq!(format_value(&n(2, 0, 0, "$", true), "1234.5"), "$1,234.50"); + assert_eq!(format_value(&n(0, 1, 0, "", true), "1234567"), "1234567"); + assert_eq!(format_value(&n(2, 2, 0, " €", false), "1234.5"), "1.234,50 €"); + assert_eq!(format_value(&n(2, 0, 2, "", true), "-12"), "(12.00)"); + assert_eq!(format_value(&n(1, 0, 0, "", true), "-0.25"), "-0.3"); + assert_eq!(format_value(&Format::Percent { decimals: 1, sep: 0 }, "0.125"), "12.5%"); + assert_eq!(make_number("$1,234.50"), Some(1234.5)); + assert_eq!(make_number("1.234,5"), Some(1234.5)); + assert_eq!(make_number("(7)"), Some(-7.0)); + assert_eq!(make_number("abc"), None); + assert_eq!(keystroke(&n(2, 0, 0, "$", true), "Total", "$1,000"), Ok("1000".into())); + assert_eq!(keystroke(&n(2, 0, 0, "$", true), "Total", "ten"), Err("The value entered does not match the format of the field [ Total ]".into())); + // Blank input clears a formatted field (#1323); without a format it is text like any other. + assert_eq!(keystroke(&n(2, 0, 0, "$", true), "Total", " \t"), Ok(String::new())); + assert_eq!(keystroke(&Format::None, "Name", " \t"), Ok(" \t".into())); +} + +#[test] +fn dates_times_and_masks() { + let d = Format::Date("mm/dd/yyyy".into()); + assert_eq!(format_value(&d, "1/2/24"), "01/02/2024"); + assert_eq!(format_value(&Format::Date("mmmm d, yyyy".into()), "10/1/2026"), "October 1, 2026"); + assert_eq!(format_value(&Format::Date("dddd, mmm d, yyyy".into()), "10/1/2026"), "Thursday, Oct 1, 2026"); + assert_eq!(format_value(&Format::Date("dd-mmm-yy".into()), "5-Mar-21"), "05-Mar-21", "month names are read"); + assert!(keystroke(&d, "Due", "2/30/2024").is_err(), "no 30 February"); + assert!(keystroke(&d, "Due", "13/1/2024").is_err()); + // Literal digits in the format (a leave form fixing the year as "dd.mm.26"). + let fixed = Format::Date("dd.mm.26".into()); + assert_eq!(keystroke(&fixed, "Von", "15.05.26"), Ok("15.05.26".into())); + assert_eq!(format_value(&fixed, "15.05.26"), "15.05.26"); + assert_eq!(format_value(&fixed, "3.1"), "03.01.26", "the literal may be left out"); + assert!(keystroke(&fixed, "Von", "15.05.27").is_err(), "a different number there is not the literal"); + assert!(keystroke(&fixed, "Von", "32.05.26").is_err()); + assert_eq!(format_value(&Format::Time("h:MM tt".into()), "14:05"), "2:05 pm"); + assert_eq!(format_value(&Format::Time("HH:MM".into()), "2:05 pm"), "14:05"); + assert_eq!(format_value(&Format::Special(2), "5551234567"), "(555) 123-4567"); + assert_eq!(format_value(&Format::Special(3), "123-45-6789"), "123-45-6789"); + assert!(keystroke(&Format::Special(0), "Zip", "1234").is_err()); + assert_eq!(format_value(&Format::Mask("AA-9999".into()), "ab1234"), "ab-1234"); + assert!(keystroke(&Format::Mask("AA-9999".into()), "Code", "a11234").is_err()); +} + +#[test] +fn validation_and_calculation() { + let r = Validate::Range { min: Some(0.0), max: Some(100.0) }; + assert!(validate(&r, "50").is_ok() && validate(&r, "").is_ok()); + assert_eq!(validate(&r, "120"), Err("Invalid value: must be greater than or equal to 0 and less than or equal to 100.".into())); + let vals = |name: &str| -> Vec { + match name { + "a" => vec!["2".into()], + "b" => vec!["$3.50".into()], + "row" => vec!["1".into(), "2".into(), "3".into()], + "Unit Price" => vec!["4".into()], + _ => vec![], + } + }; + let simple = |op, f: &[&str]| calculate(&Calculate::Simple { op, fields: f.iter().map(|s| s.to_string()).collect() }, &vals); + assert_eq!(simple(CalcOp::Sum, &["a", "b"]), Some("5.5".into())); + assert_eq!(simple(CalcOp::Product, &["a", "b"]), Some("7".into())); + assert_eq!(simple(CalcOp::Average, &["row"]), Some("2".into()), "groups count every child"); + assert_eq!(simple(CalcOp::Maximum, &["a", "row"]), Some("3".into())); + assert_eq!(simple(CalcOp::Sum, &["missing"]), Some("0".into())); + assert_eq!(calculate(&Calculate::Notation("(a + b) * 2 - Unit\\ Price / 4".into()), &vals), Some("10".into())); + assert_eq!(calculate(&Calculate::Notation("a +".into()), &vals), None); +} diff --git a/crates/forms/src/appearances.rs b/crates/forms/src/appearances.rs new file mode 100644 index 0000000000..64569fa3ca --- /dev/null +++ b/crates/forms/src/appearances.rs @@ -0,0 +1,237 @@ +use super::read::{acroform, fields}; +use super::values::merged_appearance; +use super::{Dict, Document, FieldKind, ObjRef, Object, Widget, appearance, name_bytes}; + +/// Whether the form asks viewers to draw its fields' appearances themselves (`/NeedAppearances`). +pub fn need_appearances(doc: &Document) -> bool { + acroform(doc).is_some_and(|af| matches!(af.get(b"NeedAppearances").map(|o| doc.resolve(o)).as_deref(), Some(Object::Bool(true)))) +} + +/// A copy of `doc` in which form fields are drawn the way the file leaves to the viewer, for +/// displaying the document (`None` when no widget needs it). Text and choice fields are drawn from +/// their values: every one when the form sets `/NeedAppearances` (ISO 32000-2 Table 224: the +/// writer left the appearances to the viewer), and otherwise each widget without a normal +/// appearance (`/AP /N`), as typing the value would draw it (#3470). Check boxes and radio buttons +/// with no normal appearance for their state (`/AS`) are drawn from their `/MK` background, border +/// and caption, and with `/NeedAppearances` every one that has an `/MK` is, over its own +/// appearance; push buttons with none are drawn from their `/MK` and `/BS` (Table 192). `doc` is +/// not changed: saving writes the file as it was. +pub fn with_field_appearances(doc: &Document) -> Option { + with_field_appearances_cached(doc, &mut FieldAppearanceCache::default()) +} + +/// [`with_field_appearances`], reusing the appearances `cache` drew for widgets whose inputs are +/// the same objects as then (see [`FieldAppearanceCache`]). +pub fn with_field_appearances_cached(doc: &Document, cache: &mut FieldAppearanceCache) -> Option { + let all = need_appearances(doc); + let mut copy: Option = None; + for f in fields(doc) { + for w in &f.widgets { + let widget = doc.get(w.obj); + let normal = widget + .as_dict() + .and_then(|d| d.get(b"AP")) + .and_then(|ap| doc.resolve(ap).as_dict().and_then(|ap| ap.get(b"N").map(|n| doc.resolve(n)))); + // A check box or radio button shows the appearance of its state (12.7.5.2.3): `/AS`, + // else the value when it names this widget's on state (any check box value when the + // widget names none), else Off. An Off state may go without one (it shows nothing); + // an on state needs one. + let state = match f.kind { + FieldKind::CheckBox | FieldKind::Radio => { + let mine = |v: &&String| w.on_state.as_ref().map_or(f.kind == FieldKind::CheckBox, |on| on == *v); + let value = f.value.first().filter(|v| !v.is_empty() && v.as_str() != "Off").filter(mine); + Some(w.state.clone().or_else(|| value.cloned()).unwrap_or_else(|| "Off".into())) + } + _ => None, + }; + // With /NeedAppearances a check box or radio button that describes its look in /MK is + // drawn from it (Table 192), its own appearance or not; one without /MK keeps its own. + let described = || widget.as_dict().and_then(|d| d.get(b"MK")).is_some_and(|mk| doc.resolve(mk).as_dict().is_some()); + let missing = match f.kind { + FieldKind::Text | FieldKind::Combo | FieldKind::List => normal.is_none() || all, + FieldKind::PushButton => normal.is_none(), + FieldKind::CheckBox | FieldKind::Radio => match (normal.as_deref(), state.as_deref()) { + _ if all && described() => true, + (None, _) => true, + (Some(Object::Dict(states)), Some(s)) => s != "Off" && !states.contains(&name_bytes(s)), + _ => false, + }, + FieldKind::Signature => false, + }; + if !missing { + continue; + } + let inputs = appearance_inputs(doc, w); + let stream = match cache.get(w.obj, &inputs) { + Some(stream) => stream, + None => { + let stream = match f.kind { + FieldKind::CheckBox | FieldKind::Radio => appearance::button_state_shown(doc, &f, w, state.as_deref() != Some("Off")), + FieldKind::PushButton => appearance::push_button_shown(doc, &f, w), + _ => appearance::field_appearance_drawn(doc, &f, w, &f.value, true).0, + }; + cache.insert(w.obj, inputs, &stream); + stream + } + }; + let target = copy.get_or_insert_with(|| doc.clone()); + let ap = target.add(Object::Stream(stream)); + let mut fresh = Dict::new(); + match &state { + // The drawn look under its state's name, and that state as the widget's own. + Some(s) => { + let mut states = Dict::new(); + states.set(name_bytes(s), Object::Ref(ap)); + fresh.set(b"N".to_vec(), Object::Dict(states)); + } + None => fresh.set(b"N".to_vec(), Object::Ref(ap)), + } + let apd = merged_appearance(target, w.obj, &fresh); + // Fails only when the widget isn't a dictionary; that widget then keeps its own look. + target + .update_dict(w.obj, |d| { + d.set(b"AP".to_vec(), Object::Dict(apd)); + if let Some(s) = &state { + d.set(b"AS".to_vec(), Object::Name(name_bytes(s))); + } + }) + .ok(); + } + } + copy +} + +/// The objects a widget's drawn appearance is built from, as the document holds them: the widget +/// and the fields above it (value, `/DA`, `/Q`, flags, options, `/MK`, formatting actions), the +/// form dictionary (`/DA`) and the fonts of its `/DR`, each with the objects its entries refer to +/// (an indirect `/MK` or `/V`). Not the page: a field's rotation is its own `/MK /R`. +fn appearance_inputs(doc: &Document, w: &Widget) -> Vec> { + // The objects a dictionary's entries refer to. The tree links and the drawn appearance itself + // are not inputs, and `/Kids` and `/Fields` can be long. + let referred = |d: &Dict, inputs: &mut Vec>| { + for (k, v) in d.iter() { + if !matches!(&k[..], b"Kids" | b"Fields" | b"P" | b"Parent" | b"AP") + && let Some(r) = v.as_ref() + { + inputs.push(doc.get(r)); + } + } + }; + let mut inputs = Vec::new(); + let mut seen = std::collections::HashSet::new(); + let mut node = Some(w.obj); + // The /Parent chain; the seen-set and the cap end a cycle or an absurd depth. + while let Some(r) = node.filter(|r| seen.insert(*r) && seen.len() <= 64) { + let obj = doc.get(r); + if let Some(d) = obj.as_dict() { + node = d.reference(b"Parent"); + referred(d, &mut inputs); + } else { + node = None; + } + inputs.push(obj); + } + let Some(root) = doc.root() else { return inputs }; + let catalog = doc.get(root); + let form = match catalog.as_dict().and_then(|c| c.reference(b"AcroForm")) { + Some(r) => doc.get(r), + None => catalog, + }; + if let Some(f) = form.as_dict() { + referred(f, &mut inputs); + // The fonts of /DR. An indirect /DR or /Font is listed through the entries that refer to + // it; a direct one is part of the object that holds it. `held` only finds them: a copy + // of a direct object is a new one each time, never an input. + let held = |o: &Object| o.as_ref().map_or_else(|| std::sync::Arc::new(o.clone()), |r| doc.get(r)); + if let Some(dr) = f.get(b"DR").map(held) + && let Some(dr) = dr.as_dict() + { + referred(dr, &mut inputs); + if let Some(font) = dr.get(b"Font").map(held) + && let Some(fonts) = font.as_dict() + { + referred(fonts, &mut inputs); + } + } + } + inputs.push(form); + inputs +} + +/// Field appearances drawn for the display copy ([`with_field_appearances_cached`]), so a refresh +/// redraws only the widgets whose inputs changed: a form that leaves every appearance to the +/// viewer otherwise redrew all of its fields on each keystroke in any one of them (#3470). An +/// entry holds the input objects it was drawn from and is used only while each is still the same +/// object (`Arc::ptr_eq`); the document replaces an object it edits, so a changed input is a +/// miss, never a stale hit. +#[derive(Clone, Debug, Default)] +pub struct FieldAppearanceCache { + entries: std::collections::HashMap>, pdfcraft_cos::Stream)>, + bytes: usize, + hits: u64, + builds: u64, +} + +impl FieldAppearanceCache { + /// Cached appearance data restarts past this instead of growing without bound. + const MAX_BYTES: usize = 64 << 20; + + /// Forget every entry. + pub fn clear(&mut self) { + *self = Self::default(); + } + + fn get(&mut self, w: ObjRef, inputs: &[std::sync::Arc]) -> Option { + let (from, stream) = self.entries.get(&w)?; + if from.len() != inputs.len() || !from.iter().zip(inputs).all(|(a, b)| std::sync::Arc::ptr_eq(a, b)) { + return None; + } + self.hits += 1; + Some(stream.clone()) + } + + fn insert(&mut self, w: ObjRef, inputs: Vec>, stream: &pdfcraft_cos::Stream) { + self.builds += 1; + if self.bytes.saturating_add(stream.raw.len()) > Self::MAX_BYTES { + self.clear(); + } + self.bytes = self.bytes.saturating_add(stream.raw.len()); + if let Some((_, old)) = self.entries.insert(w, (inputs, stream.clone())) { + self.bytes = self.bytes.saturating_sub(old.raw.len()); + } + } + + /// Cache hits so far (reused appearances); for tests. + pub fn hits(&self) -> u64 { + self.hits + } + + /// Appearances drawn so far; for tests. + pub fn builds(&self) -> u64 { + self.builds + } +} + +/// Whether the document's XMP metadata declares PDF/A conformance (`pdfaid:part`). +pub fn declares_pdfa(doc: &Document) -> bool { + let Some(root) = doc.root() else { return false }; + let Some(m) = doc.get(root).as_dict().and_then(|c| c.get(b"Metadata").cloned()) else { return false }; + let m = doc.resolve(&m); + match &*m { + Object::Stream(s) => s.decoded().is_ok_and(|b| b.windows(11).any(|w| w == b"pdfaid:part")), + _ => false, + } +} + +/// Every field with a widget whose appearance PdfCraft drew with `?` for a character its font +/// can't hold (`pdfcraft_fonts::UNDRAWN_MARK`): the field's name and that character, once per +/// field. +pub fn undrawn_fields(doc: &Document) -> Vec<(String, char)> { + fields(doc) + .into_iter() + .filter_map(|f| { + let c = f.widgets.iter().find_map(|w| doc.get(w.obj).as_dict().and_then(pdfcraft_fonts::undrawn_mark))?; + Some((f.name, c)) + }) + .collect() +} diff --git a/crates/forms/src/author.rs b/crates/forms/src/author.rs index 215bd095fc..ce89fad806 100644 --- a/crates/forms/src/author.rs +++ b/crates/forms/src/author.rs @@ -10,6 +10,17 @@ use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream}; use crate::{Field, FieldKind, FormError, Widget, appearance, fields, flags, page_refs}; +mod edit; +mod lifecycle; +mod look; +mod tree; + +pub use edit::set_props; +pub use lifecycle::{add_field, delete_field, delete_field_object, duplicate_field}; +pub(crate) use look::button_appearance; +pub use look::{BorderStyle, CheckStyle, FieldFont, Look, LookPatch, check_style, look, redraw_field, set_button_icon}; +pub(crate) use tree::{ensure_form, form_fonts_as_objects}; + /// The smallest width and height of a field's widget, in points: a smaller one is refused. pub const MIN_FIELD_SIDE: f64 = 4.0; @@ -113,1599 +124,3 @@ fn invalid(m: impl Into) -> Result { fn rgb(c: [f64; 3]) -> Object { Object::Array(c.iter().map(|v| Object::Real(*v)).collect()) } - -/// The AcroForm dictionary's reference, creating the form (with `/Helv` in `/DR`) if needed. -pub(crate) fn ensure_form(doc: &mut Document) -> Result { - let root = doc.root().ok_or(FormError::NoForm)?; - let existing = doc.get(root).as_dict().and_then(|d| d.get(b"AcroForm").cloned()); - let r = match existing { - Some(Object::Ref(r)) if doc.get(r).as_dict().is_some() => r, - other => { - let d = other.and_then(|o| o.as_dict().cloned()).unwrap_or_default(); - let r = doc.add(Object::Dict(d)); - doc.update_dict(root, |c| c.set(b"AcroForm".to_vec(), Object::Ref(r)))?; - r - } - }; - let mut af = doc.get(r).as_dict().cloned().unwrap_or_default(); - if !af.contains(b"Fields") { - af.set(b"Fields".to_vec(), Object::Array(Vec::new())); - } - if !af.contains(b"DA") { - af.set(b"DA".to_vec(), PdfString::literal(b"/Helv 0 Tf 0 g".to_vec())); - } - let (dr, mut fonts) = form_fonts(doc, &af); - let mut added = inline_fonts_as_objects(doc, &mut fonts); - // Acrobat's resource names for the standard fonts its Appearance tab offers. - for (name, base) in [("Helv", "Helvetica"), ("TiRo", "Times-Roman"), ("Cour", "Courier")] { - if fonts.contains(name.as_bytes()) { - continue; - } - let mut f = Dict::new(); - f.set(b"Type".to_vec(), Object::name("Font")); - f.set(b"Subtype".to_vec(), Object::name("Type1")); - f.set(b"BaseFont".to_vec(), Object::name(base)); - f.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding")); - let r = doc.add(Object::Dict(f)); - fonts.set(name.as_bytes().to_vec(), Object::Ref(r)); - added = true; - } - if added { - store_form_fonts(doc, &mut af, dr, fonts); - } - doc.set(r, Object::Dict(af)); - Ok(r) -} - -/// The form dictionary's `/DR` and its `/Font` dictionary, resolved (empty where missing). -fn form_fonts(doc: &Document, af: &Dict) -> (Dict, Dict) { - let dr = af.get(b"DR").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default(); - let fonts = dr.get(b"Font").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default(); - (dr, fonts) -} - -/// Each /DR font its own object: Acrobat, redrawing a text field after it is typed in, shows -/// nothing when the font is a dictionary written inline in /DR (#3441). Fonts written inline -/// (by an earlier save, or another writer) become objects saying the same; a font that is an -/// object already, or an entry that isn't a dictionary, is left alone. Whether any changed. -fn inline_fonts_as_objects(doc: &mut Document, fonts: &mut Dict) -> bool { - let inline: Vec<(Vec, Dict)> = fonts.iter().filter_map(|(k, v)| Some((k.clone(), v.as_dict()?.clone()))).collect(); - let changed = !inline.is_empty(); - for (name, font) in inline { - let r = doc.add(Object::Dict(font)); - fonts.set(name, Object::Ref(r)); - } - changed -} - -/// Write `fonts` back as the `/DR /Font` of the form dictionary `af`. An indirect `/DR` or -/// `/Font` stays the object it is. -fn store_form_fonts(doc: &mut Document, af: &mut Dict, mut dr: Dict, fonts: Dict) { - match dr.get(b"Font").and_then(Object::as_ref) { - Some(fr) => doc.set(fr, Object::Dict(fonts)), - None => dr.set(b"Font".to_vec(), Object::Dict(fonts)), - } - match af.get(b"DR").and_then(Object::as_ref) { - Some(dr_ref) => doc.set(dr_ref, Object::Dict(dr)), - None => af.set(b"DR".to_vec(), Object::Dict(dr)), - } -} - -/// Filling a field: the form's inline `/DR` fonts become objects (see -/// [`inline_fonts_as_objects`]), so a form an earlier version of PdfCraft made works in Acrobat -/// once it has been filled here too (#3441). Only the font storage changes; nothing is added. -/// Not in a form a signature covers: there the file stays as the signer saw it, apart from the -/// value itself. -pub(crate) fn form_fonts_as_objects(doc: &mut Document, all: &[crate::Field]) -> Result<(), FormError> { - let signed = all.iter().any(|f| f.kind == crate::FieldKind::Signature && holds_value(doc, f.obj)); - if signed { - return Ok(()); - } - let Some(root) = doc.root() else { return Ok(()) }; - let Some(entry) = doc.get(root).as_dict().and_then(|c| c.get(b"AcroForm").cloned()) else { return Ok(()) }; - match entry { - Object::Ref(r) => { - let Some(mut af) = doc.get(r).as_dict().cloned() else { return Ok(()) }; - let (dr, mut fonts) = form_fonts(doc, &af); - if inline_fonts_as_objects(doc, &mut fonts) { - store_form_fonts(doc, &mut af, dr, fonts); - doc.set(r, Object::Dict(af)); - } - } - // A form dictionary written directly in the catalog (the catalog's /AcroForm need not be - // an indirect reference) is written back there. - Object::Dict(mut af) => { - let (dr, mut fonts) = form_fonts(doc, &af); - if inline_fonts_as_objects(doc, &mut fonts) { - store_form_fonts(doc, &mut af, dr, fonts); - doc.update_dict(root, |c| c.set(b"AcroForm".to_vec(), Object::Dict(af)))?; - } - } - _ => {} - } - Ok(()) -} - -/// Whether the field `r`, or the nearest ancestor that has one, holds a `/V` (the entry is -/// inheritable, ISO 32000-2 §12.7.4). The `/Parent` chain is followed at most 64 deep, as deep -/// as the field walk goes, so a cyclic chain ends. -fn holds_value(doc: &Document, r: ObjRef) -> bool { - let mut at = r; - for _ in 0..=64 { - let obj = doc.get(at); - let Some(d) = obj.as_dict() else { return false }; - if d.contains(b"V") { - return true; - } - match d.reference(b"Parent") { - Some(p) => at = p, - None => return false, - } - } - false -} - -/// The Appearance tab: borders, colours and text. -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct Look { - pub border: Option<[f64; 3]>, - pub fill: Option<[f64; 3]>, - /// Line thickness in points (Acrobat: thin 1, medium 2, thick 3). - pub width: f64, - pub style: BorderStyle, - pub text: [f64; 3], - pub font: FieldFont, -} - -/// A partial Appearance-tab edit. Outer `None` keeps a property; `Some(None)` removes a -/// border or fill colour. The patch is applied to each widget's own dictionaries. -#[derive(Clone, Copy, Debug, Default, PartialEq)] -pub struct LookPatch { - pub border: Option>, - pub fill: Option>, - pub width: Option, - pub style: Option, - pub text: Option<[f64; 3]>, - pub font: Option, -} - -impl LookPatch { - fn validate(&self) -> Result<(), FormError> { - for c in self.border.flatten().into_iter().chain(self.fill.flatten()).chain(self.text) { - if !c.iter().all(|v| v.is_finite() && (0.0..=1.0).contains(v)) { - return invalid("appearance colours must be finite RGB values between 0 and 1"); - } - } - if self.width.is_some_and(|v| !v.is_finite() || !(0.0..=12.0).contains(&v)) { - return invalid("border width must be between 0 and 12 points"); - } - Ok(()) - } -} - -/// Rewrite only the selected operators in place: the last `Tf` (font and size) and the last -/// fill colour (`g`, `rg` or `k`), appending one when the string has none. Everything else (a -/// custom font resource, colour space or other graphics state) is kept, and repeated edits don't -/// grow the string. -fn patch_da(da: &str, size: Option, patch: &LookPatch) -> String { - if patch.font.is_none() && size.is_none() && patch.text.is_none() { - return da.to_string(); - } - let mut toks: Vec = da.split_whitespace().map(str::to_string).collect(); - let numeric = |s: &str| s.parse::().is_ok(); - if patch.font.is_some() || size.is_some() { - let old = appearance::parse_da(da); - let font = patch.font.map_or_else(|| old.font.clone(), |f| f.resource().to_string()); - // `parse_da` reads sizes up to 300 points; keep the same bound when writing. - let size = appearance::fmt(size.unwrap_or(old.size).clamp(0.0, 300.0)); - let at = toks.iter().enumerate().rev().find(|(i, t)| t.as_str() == "Tf" && *i >= 2).map(|(i, _)| i); - match at { - Some(i) => { - if let Some(slot) = toks.get_mut(i - 2) { - *slot = format!("/{font}"); - } - if let Some(slot) = toks.get_mut(i - 1) { - *slot = size; - } - } - None => toks.extend([format!("/{font}"), size, "Tf".into()]), - } - } - if let Some(c) = patch.text { - let rgb = [appearance::fmt(c[0]), appearance::fmt(c[1]), appearance::fmt(c[2]), "rg".into()]; - let at = toks.iter().enumerate().rev().find_map(|(i, t)| { - let n = match t.as_str() { - "g" => 1, - "rg" => 3, - "k" => 4, - _ => return None, - }; - let start = i.checked_sub(n)?; - toks.get(start..i).is_some_and(|ops| ops.iter().all(|o| numeric(o))).then_some((start, i)) - }); - match at { - Some((start, end)) => { - toks.splice(start..=end, rgb); - } - None => toks.extend(rgb), - } - } - toks.join(" ") -} - -fn patch_widget_look(doc: &mut Document, w: &Widget, patch: &LookPatch) -> Result<(), FormError> { - let touches_mk = patch.border.is_some() || patch.fill.is_some(); - let touches_bs = patch.width.is_some() || patch.style.is_some(); - // A size- or font-only edit changes /DA, not the widget: leave it out of the save. - if !touches_mk && !touches_bs { - return Ok(()); - } - let wd = doc.get(w.obj).as_dict().cloned().ok_or_else(|| FormError::Invalid("the widget is not a dictionary".into()))?; - if touches_mk { - let indirect = wd.get(b"MK").and_then(Object::as_ref); - let mut mk = wd.get(b"MK").map(|m| doc.resolve(m)).and_then(|m| m.as_dict().cloned()).unwrap_or_default(); - for (key, change) in [(b"BC".as_slice(), patch.border), (b"BG".as_slice(), patch.fill)] { - if let Some(change) = change { - match change { - Some(c) => mk.set(key.to_vec(), rgb(c)), - None => { - mk.remove(key); - } - } - } - } - // A shared indirect /MK is updated where it lives, not copied into each widget. - match indirect { - Some(r) => doc.set(r, Object::Dict(mk)), - None => doc.update_dict(w.obj, |d| d.set(b"MK".to_vec(), Object::Dict(mk)))?, - } - } - if touches_bs { - let indirect = wd.get(b"BS").and_then(Object::as_ref); - let mut bs = wd.get(b"BS").map(|b| doc.resolve(b)).and_then(|b| b.as_dict().cloned()).unwrap_or_default(); - if let Some(width) = patch.width { - bs.set(b"W".to_vec(), Object::Real(width)); - } - if let Some(style) = patch.style { - bs.set(b"S".to_vec(), Object::name(style.code())); - if style == BorderStyle::Dashed && !bs.contains(b"D") { - bs.set(b"D".to_vec(), Object::Array(vec![Object::Int(3)])); - } - } - match indirect { - Some(r) => doc.set(r, Object::Dict(bs)), - None => doc.update_dict(w.obj, |d| d.set(b"BS".to_vec(), Object::Dict(bs)))?, - } - } - Ok(()) -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum BorderStyle { - Solid, - Dashed, - Beveled, - Inset, - Underline, -} - -impl BorderStyle { - pub const ALL: [BorderStyle; 5] = [BorderStyle::Solid, BorderStyle::Dashed, BorderStyle::Beveled, BorderStyle::Inset, BorderStyle::Underline]; - - fn code(self) -> &'static str { - match self { - BorderStyle::Solid => "S", - BorderStyle::Dashed => "D", - BorderStyle::Beveled => "B", - BorderStyle::Inset => "I", - BorderStyle::Underline => "U", - } - } - - pub fn label(self) -> &'static str { - match self { - BorderStyle::Solid => "Solid", - BorderStyle::Dashed => "Dashed", - BorderStyle::Beveled => "Beveled", - BorderStyle::Inset => "Inset", - BorderStyle::Underline => "Underline", - } - } -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum FieldFont { - Helvetica, - Times, - Courier, -} - -impl FieldFont { - pub const ALL: [FieldFont; 3] = [FieldFont::Helvetica, FieldFont::Times, FieldFont::Courier]; - - fn resource(self) -> &'static str { - match self { - FieldFont::Helvetica => "Helv", - FieldFont::Times => "TiRo", - FieldFont::Courier => "Cour", - } - } - - pub fn label(self) -> &'static str { - match self { - FieldFont::Helvetica => "Helvetica", - FieldFont::Times => "Times Roman", - FieldFont::Courier => "Courier", - } - } -} - -/// The mark a check box or radio button shows when on (Field Properties ▸ Options ▸ style). -/// Stored as `/MK /CA`, the ZapfDingbats character Acrobat uses for it; PdfCraft draws the -/// mark as paths, so no symbol font is needed. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum CheckStyle { - Check, - Circle, - Cross, - Diamond, - Square, - Star, -} - -impl CheckStyle { - pub const ALL: [CheckStyle; 6] = - [CheckStyle::Check, CheckStyle::Circle, CheckStyle::Cross, CheckStyle::Diamond, CheckStyle::Square, CheckStyle::Star]; - - fn code(self) -> &'static str { - match self { - CheckStyle::Check => "4", - CheckStyle::Circle => "l", - CheckStyle::Cross => "8", - CheckStyle::Diamond => "u", - CheckStyle::Square => "n", - CheckStyle::Star => "H", - } - } - - pub fn label(self) -> &'static str { - match self { - CheckStyle::Check => "Check", - CheckStyle::Circle => "Circle", - CheckStyle::Cross => "Cross", - CheckStyle::Diamond => "Diamond", - CheckStyle::Square => "Square", - CheckStyle::Star => "Star", - } - } - - /// A widget's style: its `/MK /CA`, else a check for check boxes and a circle for radios. - pub(crate) fn of_widget(doc: &Document, wd: &Dict, kind: FieldKind) -> Self { - let mk = wd.get(b"MK").and_then(|m| doc.resolve(m).as_dict().cloned()).unwrap_or_default(); - let ca = mk.get(b"CA").and_then(|c| doc.resolve(c).as_string().map(|s| s.to_text())); - CheckStyle::ALL.into_iter().find(|s| ca.as_deref() == Some(s.code())).unwrap_or(if kind == FieldKind::Radio { - CheckStyle::Circle - } else { - CheckStyle::Check - }) - } -} - -/// A check box's or radio button's style (from its first widget). -pub fn check_style(doc: &Document, f: &Field) -> CheckStyle { - let wd = f.widgets.first().and_then(|w| doc.get(w.obj).as_dict().cloned()).unwrap_or_default(); - CheckStyle::of_widget(doc, &wd, f.kind) -} - -fn rgb_of(doc: &Document, o: Option<&Object>) -> Option<[f64; 3]> { - let v: Vec = doc.resolve(o?).as_array()?.iter().filter_map(Object::as_f64).collect(); - match v.len() { - 1 => Some([v[0]; 3]), - 3 => Some([v[0], v[1], v[2]]), - 4 => Some([(1.0 - v[0]) * (1.0 - v[3]), (1.0 - v[1]) * (1.0 - v[3]), (1.0 - v[2]) * (1.0 - v[3])]), - _ => None, - } -} - -/// A field's current look (from its first widget and its default appearance). -pub fn look(doc: &Document, f: &Field) -> Look { - let wd = f.widgets.first().and_then(|w| doc.get(w.obj).as_dict().cloned()).unwrap_or_default(); - let mk = wd.get(b"MK").and_then(|m| doc.resolve(m).as_dict().cloned()).unwrap_or_default(); - let bs = wd.get(b"BS").and_then(|b| doc.resolve(b).as_dict().cloned()).unwrap_or_default(); - let da = wd.get(b"DA").and_then(|o| doc.resolve(o).as_string().map(|s| s.to_text())).unwrap_or_else(|| f.da.clone()); - let parsed = appearance::parse_da(&da); - let nums: Vec = parsed.color.split_whitespace().filter_map(|t| t.parse().ok()).collect(); - let text = match nums.len() { - 1 => [nums[0]; 3], - 3 => [nums[0], nums[1], nums[2]], - 4 => [(1.0 - nums[0]) * (1.0 - nums[3]), (1.0 - nums[1]) * (1.0 - nums[3]), (1.0 - nums[2]) * (1.0 - nums[3])], - _ => [0.0; 3], - }; - Look { - border: rgb_of(doc, mk.get(b"BC")), - fill: rgb_of(doc, mk.get(b"BG")), - width: bs.get(b"W").and_then(|w| doc.resolve(w).as_f64()).unwrap_or(1.0), - style: match bs.name(b"S") { - Some(b"D") => BorderStyle::Dashed, - Some(b"B") => BorderStyle::Beveled, - Some(b"I") => BorderStyle::Inset, - Some(b"U") => BorderStyle::Underline, - _ => BorderStyle::Solid, - }, - text, - font: match parsed.font.as_str() { - "TiRo" | "Times-Roman" => FieldFont::Times, - "Cour" | "Courier" => FieldFont::Courier, - _ => FieldFont::Helvetica, - }, - } -} - -/// A `/DA` string with the given font, size and colour. -fn da_string(font: FieldFont, size: f64, c: [f64; 3]) -> String { - let f = crate::appearance::fmt; - format!("/{} {} Tf {} {} {} rg", font.resource(), f(size.clamp(0.0, 100.0)), f(c[0]), f(c[1]), f(c[2])) -} - -fn add_to_page(doc: &mut Document, page: ObjRef, widget: ObjRef) -> Result<(), FormError> { - let existing = doc.get(page).as_dict().and_then(|d| d.get(b"Annots").cloned()); - match existing.as_ref().and_then(|o| o.as_ref()).filter(|r| doc.get(*r).as_array().is_some()) { - Some(r) => { - let mut a = doc.get(r).as_array().cloned().unwrap_or_default(); - a.push(Object::Ref(widget)); - doc.set(r, Object::Array(a)); - } - None => { - let mut a = existing.and_then(|o| o.as_array().cloned()).unwrap_or_default(); - a.push(Object::Ref(widget)); - doc.update_dict(page, |d| d.set(b"Annots".to_vec(), Object::Array(a)))?; - } - } - Ok(()) -} - -fn add_to_fields(doc: &mut Document, af: ObjRef, field: ObjRef) -> Result<(), FormError> { - let mut d = doc.get(af).as_dict().cloned().unwrap_or_default(); - let mut list = d.get(b"Fields").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default(); - list.push(Object::Ref(field)); - d.set(b"Fields".to_vec(), Object::Array(list)); - doc.set(af, Object::Dict(d)); - Ok(()) -} - -/// The most parts a dotted field name may have ("a.b.c" has three). -const MAX_NAME_PARTS: usize = 32; - -/// The field attributes a field inherits from its parents (ISO 32000-2 §12.7.4.1). -const INHERITED_KEYS: [&[u8]; 7] = [b"FT", b"Ff", b"V", b"DV", b"DA", b"Q", b"MaxLen"]; - -/// The parts of a full field name: `a.b` is the field `b` under the parent field `a`, since a -/// field's own (partial) name can't contain a period (ISO 32000-2 §12.7.4.2) (#2696). -fn name_parts(name: &str) -> Result, FormError> { - let parts: Vec<&str> = name.split('.').take(MAX_NAME_PARTS.saturating_add(1)).collect(); - if parts.len() > MAX_NAME_PARTS { - return invalid(format!("field names can have at most {MAX_NAME_PARTS} parts")); - } - if parts.iter().any(|p| p.trim().is_empty()) { - return invalid("field names can't be empty, or have an empty part between periods"); - } - Ok(parts) -} - -/// Why the full name `name` can't be used beside the field named `other`, if it can't: the -/// same name, or one inside the other (a field that has a value can't also hold fields). -fn clash(name: &str, other: &str) -> Option { - if name == other { - return Some(format!("a field named {name:?} already exists")); - } - let inside = |a: &str, b: &str| a.strip_prefix(b).is_some_and(|rest| rest.starts_with('.')); - (inside(name, other) || inside(other, name)).then(|| format!("{name:?} can't be used beside the field {other:?}")) -} - -/// The array `key` of `holder` (`/Fields` of the form, `/Kids` of a field). -fn list_of(doc: &Document, holder: ObjRef, key: &[u8]) -> Vec { - let d = doc.get(holder).as_dict().cloned().unwrap_or_default(); - d.get(key).map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default() -} - -/// Add `field` to `parent`'s `/Kids`, or to the form's `/Fields` at the top. -fn link(doc: &mut Document, af: ObjRef, parent: Option, field: ObjRef) -> Result<(), FormError> { - let Some(p) = parent else { return add_to_fields(doc, af, field) }; - let mut kids = list_of(doc, p, b"Kids"); - kids.push(Object::Ref(field)); - doc.update_dict(p, |d| d.set(b"Kids".to_vec(), Object::Array(kids)))?; - Ok(()) -} - -/// The node named `name` (its own `/T`) directly under `parent`, or at the top of the form. -fn child_named(doc: &Document, af: ObjRef, parent: Option, name: &str) -> Option { - let list = match parent { - Some(p) => list_of(doc, p, b"Kids"), - None => list_of(doc, af, b"Fields"), - }; - list.iter() - .filter_map(|o| o.as_ref()) - .find(|r| doc.get(*r).as_dict().and_then(|d| d.get(b"T").cloned()).and_then(|t| crate::text_of(&doc.resolve(&t))).as_deref() == Some(name)) -} - -/// The values a parent field passes to the fields under it that can't be turned off by giving -/// the field a neutral one of its own, as `/Ff` and `/Q` can. -const PASSED_VALUES: [&[u8]; 3] = [b"V", b"DV", b"MaxLen"]; - -/// Whether `field` may go in the field tree under the full name `parts`, changing nothing. The -/// field list holds only terminal fields, so a parent left empty by a move isn't in it: a node -/// of the same name there is found here. -/// `inherited` is what the field will hold as its own before it moves (see [`inherited`]). -fn check_place(doc: &Document, af: ObjRef, field: ObjRef, parts: &[&str], inherited: &Dict) -> Result<(), FormError> { - let Some((own, parents)) = parts.split_last() else { return invalid("field names can't be empty") }; - let mut owned = doc.get(field).as_dict().cloned().unwrap_or_default(); - for (k, v) in inherited.iter() { - owned.set(k.to_vec(), v.clone()); - } - let mut at: Option = None; - let mut whole_path = true; - for part in parents { - let Some(r) = child_named(doc, af, at, part) else { - whole_path = false; - break; - }; - // A value, reset value or length limit held by a parent would pass to this field. - let held = doc.get(r).as_dict().is_some_and(|d| PASSED_VALUES.iter().any(|k| d.contains(k) && !owned.contains(k))); - if held { - return invalid(format!("{part:?} holds a value for the fields under it, so {:?} can't be put there", parts.join("."))); - } - at = Some(r); - } - // A field being moved is still in the tree here, but under another full name (a move to its - // own name changes nothing and doesn't get here), so a node found is another. - if whole_path && child_named(doc, af, at, own).is_some() { - return invalid(format!("a field named {:?} already exists", parts.join("."))); - } - Ok(()) -} - -/// Put `field` in the field tree under the full name `parts`: its own `/T` is the last part, -/// and the parts before it name parent fields, reused where the tree has them and made where -/// not (Acrobat makes `a.b` a field `b` under a field `a`). -fn attach(doc: &mut Document, af: ObjRef, field: ObjRef, parts: &[&str]) -> Result<(), FormError> { - let Some((own, parents)) = parts.split_last() else { return invalid("field names can't be empty") }; - check_place(doc, af, field, parts, &Dict::new())?; - let mut parent: Option = None; - let mut inherits = false; - for part in parents { - let next = match child_named(doc, af, parent, part) { - Some(r) => { - inherits |= doc.get(r).as_dict().is_some_and(|d| d.contains(b"Ff") || d.contains(b"Q")); - r - } - None => { - let mut d = Dict::new(); - d.set(b"T".to_vec(), PdfString::text(part)); - d.set(b"Kids".to_vec(), Object::Array(Vec::new())); - if let Some(p) = parent { - d.set(b"Parent".to_vec(), Object::Ref(p)); - } - let r = doc.add(Object::Dict(d)); - link(doc, af, parent, r)?; - r - } - }; - parent = Some(next); - } - doc.update_dict(field, |d| { - d.set(b"T".to_vec(), PdfString::text(own)); - match parent { - Some(p) => d.set(b"Parent".to_vec(), Object::Ref(p)), - None => { - d.remove(b"Parent"); - } - } - // An existing parent's flags and alignment would otherwise pass to this field. - if inherits { - for k in [&b"Ff"[..], b"Q"] { - if !d.contains(k) { - d.set(k.to_vec(), Object::Int(0)); - } - } - } - })?; - link(doc, af, parent, field) -} - -/// What `field` inherits from its parents and doesn't hold itself, changing nothing. -fn inherited(doc: &Document, field: ObjRef) -> Dict { - let own = doc.get(field).as_dict().cloned().unwrap_or_default(); - let mut add = Dict::new(); - let mut seen = std::collections::HashSet::new(); - let mut parent = own.reference(b"Parent"); - while let Some(p) = parent { - if seen.len() > 64 || !seen.insert(p) { - break; - } - let Some(d) = doc.get(p).as_dict().cloned() else { break }; - for k in INHERITED_KEYS { - if let Some(v) = d.get(k).filter(|_| !own.contains(k) && !add.contains(k)) { - add.set(k.to_vec(), v.clone()); - } - } - parent = d.reference(b"Parent"); - } - add -} - -/// Copy onto `field` what it inherits (`add`, from [`inherited`]), so it keeps it when it moves. -fn own_inherited(doc: &mut Document, field: ObjRef, add: &Dict) -> Result<(), FormError> { - if add.iter().next().is_some() { - doc.update_dict(field, |d| { - for (k, v) in add.iter() { - d.set(k.to_vec(), v.clone()); - } - })?; - } - Ok(()) -} - -/// Take `field` out of the field tree: from its parent's `/Kids`, or from `/Fields`. -fn detach(doc: &mut Document, field: ObjRef) -> Result<(), FormError> { - let parent = doc.get(field).as_dict().and_then(|d| d.reference(b"Parent")); - let remove_from = |doc: &mut Document, holder: ObjRef, key: &[u8]| -> Result<(), FormError> { - let mut d = doc.get(holder).as_dict().cloned().unwrap_or_default(); - let list = d.get(key).map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default(); - let kept: Vec = list.into_iter().filter(|o| o.as_ref() != Some(field)).collect(); - d.set(key.to_vec(), Object::Array(kept)); - doc.set(holder, Object::Dict(d)); - Ok(()) - }; - match parent { - Some(p) => remove_from(doc, p, b"Kids")?, - None => { - let root = doc.root().ok_or(FormError::NoForm)?; - match doc.get(root).as_dict().and_then(|d| d.get(b"AcroForm").cloned()) { - Some(Object::Ref(af)) => remove_from(doc, af, b"Fields")?, - // A form dictionary held directly in the catalog. - Some(Object::Dict(mut af)) => { - let list = af.get(b"Fields").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default(); - let kept: Vec = list.into_iter().filter(|o| o.as_ref() != Some(field)).collect(); - af.set(b"Fields".to_vec(), Object::Array(kept)); - doc.update_dict(root, |d| d.set(b"AcroForm".to_vec(), Object::Dict(af)))?; - } - _ => {} - } - } - } - Ok(()) -} - -/// The first free "N" name: not a field's, and not held by a node at the top of the field -/// tree (`held`), such as a parent field left empty by a move, which isn't in `existing`. -fn free_name(existing: &[Field], held: &[String], base: &str) -> String { - (1..=u64::MAX) - .map(|n| format!("{base}{n}")) - .find(|c| !held.contains(c) && !existing.iter().any(|f| f.name == *c || f.name.starts_with(&format!("{c}.")))) - .unwrap_or_else(|| base.to_string()) -} - -/// The own names (`/T`) of the nodes in the form's `/Fields`, changing nothing. -fn top_names(doc: &Document) -> Vec { - let Some(root) = doc.root() else { return Vec::new() }; - let Some(af) = doc.get(root).as_dict().and_then(|d| d.get(b"AcroForm").map(|o| doc.resolve(o))).and_then(|o| o.as_dict().cloned()) else { - return Vec::new(); - }; - let list = af.get(b"Fields").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default(); - list.iter().filter_map(|o| doc.resolve(o).as_dict().and_then(|d| d.get(b"T").cloned())).filter_map(|t| crate::text_of(&doc.resolve(&t))).collect() -} - -fn widget_dict(page: ObjRef, rect: [f64; 4]) -> Dict { - let mut d = Dict::new(); - d.set(b"Type".to_vec(), Object::name("Annot")); - d.set(b"Subtype".to_vec(), Object::name("Widget")); - d.set(b"Rect".to_vec(), Object::Array(rect.iter().map(|v| Object::Real(*v)).collect())); - d.set(b"P".to_vec(), Object::Ref(page)); - d.set(b"F".to_vec(), Object::Int(4)); - let mut mk = Dict::new(); - mk.set(b"BC".to_vec(), rgb([0.55, 0.55, 0.55])); - mk.set(b"BG".to_vec(), rgb([1.0, 1.0, 1.0])); - d.set(b"MK".to_vec(), Object::Dict(mk)); - let mut bs = Dict::new(); - bs.set(b"W".to_vec(), Object::Int(1)); - bs.set(b"S".to_vec(), Object::name("S")); - d.set(b"BS".to_vec(), Object::Dict(bs)); - d -} - -/// Add a field on `page` (0-based) in `rect` (user space). Returns its full name. -pub fn add_field(doc: &mut Document, page: usize, rect: [f64; 4], kind: &NewField, name: Option<&str>) -> Result { - let pages = page_refs(doc); - let page_ref = *pages.get(page).ok_or_else(|| FormError::Invalid(format!("page {} does not exist", page + 1)))?; - let rect = [rect[0].min(rect[2]), rect[1].min(rect[3]), rect[0].max(rect[2]), rect[1].max(rect[3])]; - if !rect.iter().all(|v| v.is_finite()) || rect[2] - rect[0] < MIN_FIELD_SIDE || rect[3] - rect[1] < MIN_FIELD_SIDE { - return invalid("the field is too small"); - } - let existing = fields(doc); - // A radio button names its group: a new group takes that name. - let name = match (name, kind) { - (None, NewField::Radio { group: Some(g), .. }) => Some(g.as_str()), - _ => name, - }; - let name = match name.map(str::trim) { - Some(n) if !n.is_empty() => n.to_string(), - _ => free_name(&existing, &top_names(doc), kind.base_name()), - }; - let parts = name_parts(&name)?; - let radio_group = match kind { - NewField::Radio { group, .. } => existing.iter().find(|f| f.kind == FieldKind::Radio && Some(&f.name) == group.as_ref()).cloned(), - _ => None, - }; - if radio_group.is_none() - && let Some(why) = existing.iter().find_map(|f| clash(&name, &f.name)) - { - return invalid(why); - } - let af = ensure_form(doc)?; - let mut w = widget_dict(page_ref, rect); - let field_name = match kind { - NewField::Radio { group: Some(g), .. } if radio_group.is_some() => g.clone(), - _ => name.clone(), - }; - match kind { - NewField::Text { multiline } => { - w.set(b"FT".to_vec(), Object::name("Tx")); - // Auto size (0): a fixed 12 pt is clipped in a box shorter than that (#1888). - w.set(b"DA".to_vec(), PdfString::literal(b"/Helv 0 Tf 0 g".to_vec())); - if *multiline { - w.set(b"Ff".to_vec(), Object::Int(flags::MULTILINE as i64)); - } - } - NewField::Date => { - w.set(b"FT".to_vec(), Object::name("Tx")); - w.set(b"DA".to_vec(), PdfString::literal(b"/Helv 0 Tf 0 g".to_vec())); - // Acrobat's format and keystroke actions for mm/dd/yyyy (run by its JavaScript engine). - let js = |s: &str| { - let mut a = Dict::new(); - a.set(b"S".to_vec(), Object::name("JavaScript")); - a.set(b"JS".to_vec(), PdfString::literal(s.as_bytes().to_vec())); - Object::Dict(a) - }; - let mut aa = Dict::new(); - aa.set(b"F".to_vec(), js("AFDate_FormatEx(\"mm/dd/yyyy\");")); - aa.set(b"K".to_vec(), js("AFDate_KeystrokeEx(\"mm/dd/yyyy\");")); - w.set(b"AA".to_vec(), Object::Dict(aa)); - } - NewField::CheckBox => { - w.set(b"FT".to_vec(), Object::name("Btn")); - w.set(b"V".to_vec(), Object::name("Off")); - w.set(b"AS".to_vec(), Object::name("Off")); - w.set(b"DA".to_vec(), PdfString::literal(b"/ZaDb 0 Tf 0 g".to_vec())); - } - NewField::Radio { export, .. } => { - w.set(b"AS".to_vec(), Object::name("Off")); - if export.trim().is_empty() || export == "Off" { - return invalid("a radio button needs an export value other than Off"); - } - } - NewField::Combo { options, editable } | NewField::List { options, multi: editable } => { - w.set(b"FT".to_vec(), Object::name("Ch")); - w.set(b"DA".to_vec(), PdfString::literal(b"/Helv 12 Tf 0 g".to_vec())); - let combo = matches!(kind, NewField::Combo { .. }); - let mut ff = 0; - if combo { - ff |= flags::COMBO; - if *editable { - ff |= flags::EDIT; - } - } else if *editable { - ff |= flags::MULTI_SELECT; - } - w.set(b"Ff".to_vec(), Object::Int(ff as i64)); - w.set(b"Opt".to_vec(), Object::Array(options.iter().map(|o| Object::String(PdfString::text(o))).collect())); - } - NewField::Button { caption } => { - w.set(b"FT".to_vec(), Object::name("Btn")); - w.set(b"Ff".to_vec(), Object::Int(flags::PUSH_BUTTON as i64)); - w.set(b"DA".to_vec(), PdfString::literal(b"/Helv 0 Tf 0 g".to_vec())); - let mut mk = w.get(b"MK").and_then(|m| m.as_dict()).cloned().unwrap_or_default(); - mk.set(b"BG".to_vec(), rgb([0.86, 0.86, 0.86])); - mk.set(b"CA".to_vec(), PdfString::text(caption)); - w.set(b"MK".to_vec(), Object::Dict(mk)); - } - NewField::Image => { - w.set(b"FT".to_vec(), Object::name("Btn")); - w.set(b"Ff".to_vec(), Object::Int(flags::PUSH_BUTTON as i64)); - w.set(b"DA".to_vec(), PdfString::literal(b"/Helv 0 Tf 0 g".to_vec())); - let mut mk = w.get(b"MK").and_then(|m| m.as_dict()).cloned().unwrap_or_default(); - // Icon only, no border or fill: the picture is the field. - mk.remove(b"BC"); - mk.remove(b"BG"); - mk.set(b"TP".to_vec(), Object::Int(1)); - w.set(b"MK".to_vec(), Object::Dict(mk)); - let mut a = Dict::new(); - a.set(b"S".to_vec(), Object::name("JavaScript")); - a.set(b"JS".to_vec(), PdfString::literal(b"event.target.buttonImportIcon();".to_vec())); - w.set(b"A".to_vec(), Object::Dict(a)); - } - NewField::Signature => { - w.set(b"FT".to_vec(), Object::name("Sig")); - } - } - let widget = doc.add(Object::Dict(w)); - match kind { - NewField::Radio { export, .. } => { - let group = match &radio_group { - Some(g) => g.obj, - None => { - let mut g = Dict::new(); - g.set(b"FT".to_vec(), Object::name("Btn")); - g.set(b"Ff".to_vec(), Object::Int((flags::RADIO | flags::NO_TOGGLE_TO_OFF) as i64)); - g.set(b"V".to_vec(), Object::name("Off")); - g.set(b"DA".to_vec(), PdfString::literal(b"/ZaDb 0 Tf 0 g".to_vec())); - g.set(b"Kids".to_vec(), Object::Array(Vec::new())); - let g = doc.add(Object::Dict(g)); - attach(doc, af, g, &parts)?; - g - } - }; - doc.update_dict(widget, |d| d.set(b"Parent".to_vec(), Object::Ref(group)))?; - doc.update_dict(group, |d| { - let mut kids = d.get(b"Kids").and_then(|k| k.as_array().cloned()).unwrap_or_default(); - kids.push(Object::Ref(widget)); - d.set(b"Kids".to_vec(), Object::Array(kids)); - })?; - let w = Widget { - obj: widget, - page: Some(page), - rect, - on_state: Some(export.clone()), - state: Some("Off".into()), - tab: usize::MAX, - locked: false, - hidden: false, - read_only: false, - rotation: 0, - caption: None, - list: None, - round: true, - }; - let ap = appearance::check_box_states(doc, &w, FieldKind::Radio, export, "/ZaDb 0 Tf 0 g"); - doc.update_dict(widget, |d| d.set(b"AP".to_vec(), Object::Dict(ap)))?; - } - _ => attach(doc, af, widget, &parts)?, - } - add_to_page(doc, page_ref, widget)?; - redraw_field(doc, &field_name)?; - Ok(field_name) -} - -/// Regenerate every widget appearance of a field from its current value and settings. A value or -/// button caption its font can't hold is drawn with `?` for those characters, the widget is marked -/// (`pdfcraft_fonts::UNDRAWN_MARK`) and other viewers are asked to redraw the form (see -/// `ask_viewers_to_redraw`), as when a value is set. -pub fn redraw_field(doc: &mut Document, name: &str) -> Result<(), FormError> { - redraw_widgets(doc, name, None) -} - -/// `redraw_field` for the widget at index `only` alone (so a caption-only edit leaves the field's -/// other widgets' appearances as they are), or for every widget when `only` is `None`. Fails with -/// `FormError::NoSuchField` when no field is named `name`. -fn redraw_widgets(doc: &mut Document, name: &str, only: Option) -> Result<(), FormError> { - let f = fields(doc).into_iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?; - let mut undrawn = false; - for (index, w) in f.widgets.iter().enumerate() { - if only.is_some_and(|only| only != index) { - continue; - } - let mut missing = None; - let ap = match f.kind { - FieldKind::Text | FieldKind::Combo | FieldKind::List => { - let (s, m) = appearance::field_appearance_drawn(doc, &f, w, &f.value, true); - missing = m; - let r = doc.add(Object::Stream(s)); - let mut d = Dict::new(); - d.set(b"N".to_vec(), Object::Ref(r)); - d - } - FieldKind::CheckBox | FieldKind::Radio => { - let on = w.on_state.clone().unwrap_or_else(|| "Yes".into()); - appearance::check_box_states(doc, w, f.kind, &on, &f.da) - } - FieldKind::PushButton => { - let (s, m) = button_appearance(doc, w, &f.da); - missing = m; - let r = doc.add(Object::Stream(s)); - let mut d = Dict::new(); - d.set(b"N".to_vec(), Object::Ref(r)); - d - } - FieldKind::Signature => { - let s = empty_box(doc, w); - let r = doc.add(Object::Stream(s)); - let mut d = Dict::new(); - d.set(b"N".to_vec(), Object::Ref(r)); - d - } - }; - undrawn |= missing.is_some(); - let existing = crate::merged_appearance(doc, w.obj, &ap); - doc.update_dict(w.obj, |d| { - d.set(b"AP".to_vec(), Object::Dict(existing)); - pdfcraft_fonts::set_undrawn_mark(d, missing); - })?; - } - if undrawn { - crate::ask_viewers_to_redraw(doc)?; - } - Ok(()) -} - -fn frame_only(doc: &Document, w: &Widget) -> (String, crate::appearance::Placement) { - let (page_w, page_h) = ((w.rect[2] - w.rect[0]).max(1.0), (w.rect[3] - w.rect[1]).max(1.0)); - let wobj = doc.get(w.obj); - let wd = wobj.as_dict().cloned().unwrap_or_default(); - let place = crate::appearance::placement(crate::appearance::mk_rotation(doc, &wd), page_w, page_h); - let (width, height) = (place.layout_w, place.layout_h); - let mk = wd.get(b"MK").and_then(|m| m.as_dict()).cloned().unwrap_or_default(); - let col = |k: &[u8]| -> Option { - let v: Vec = mk.get(k)?.as_array()?.iter().filter_map(|x| x.as_f64()).collect(); - (v.len() == 3).then(|| format!("{} {} {}", crate::appearance::fmt(v[0]), crate::appearance::fmt(v[1]), crate::appearance::fmt(v[2]))) - }; - let mut c = String::new(); - if let Some(bg) = col(b"BG") { - c.push_str(&format!("{bg} rg 0 0 {width:.3} {height:.3} re f\n")); - } - if let Some(bc) = col(b"BC") { - c.push_str(&format!("{bc} RG 1 w 0.5 0.5 {:.3} {:.3} re S\n", width - 1.0, height - 1.0)); - } - (c, place) -} - -fn form_stream(place: crate::appearance::Placement, content: Vec, resources: Dict) -> Stream { - let mut d = Dict::new(); - d.set(b"Type".to_vec(), Object::name("XObject")); - d.set(b"Subtype".to_vec(), Object::name("Form")); - crate::appearance::set_form_box(&mut d, place); - d.set(b"Resources".to_vec(), Object::Dict(resources)); - Stream::flate(d, &content) -} - -/// A push button: background, border, its icon (`/MK /I`, scaled to fit and centred) and its -/// centred caption (unless the layout is icon only, `/TP 1`). The caption uses the font, size and -/// color its `/DA` asks for (the widget's, else `field_da`, the field's own or inherited one). -/// Also returns the first caption character the font can't draw (drawn as `?`), for the caller to -/// mark and report. -pub(crate) fn button_appearance(doc: &Document, w: &Widget, field_da: &str) -> (Stream, Option) { - let (mut c, place) = frame_only(doc, w); - let (width, height) = (place.layout_w, place.layout_h); - let wobj = doc.get(w.obj); - let mk = wobj.as_dict().and_then(|d| d.get(b"MK")).map(|m| doc.resolve(m)).and_then(|m| m.as_dict().cloned()).unwrap_or_default(); - let caption = mk.get(b"CA").map(|c| doc.resolve(c)).and_then(|c| c.as_string().map(|s| s.to_text())).unwrap_or_default(); - let icon_only = mk.get(b"TP").and_then(|t| doc.resolve(t).as_int()) == Some(1); - let mut content = std::mem::take(&mut c).into_bytes(); - let mut xobjects = Dict::new(); - if let Some(icon) = mk.get(b"I").and_then(Object::as_ref) - && let Object::Stream(s) = &*doc.get(icon) - { - let bb: Vec = s.dict.get(b"BBox").and_then(|b| b.as_array().map(|a| a.iter().filter_map(Object::as_f64).collect())).unwrap_or_default(); - if let [x0, y0, x1, y1] = bb[..] - && x1 > x0 - && y1 > y0 - { - // Proportional, as large as fits (Acrobat's default icon placement), centred. - let k = ((width - 2.0) / (x1 - x0)).min((height - 2.0) / (y1 - y0)).max(0.0); - let (dx, dy) = ((width - (x1 - x0) * k) / 2.0 - x0 * k, (height - (y1 - y0) * k) / 2.0 - y0 * k); - content.extend(format!("q {k:.6} 0 0 {k:.6} {dx:.3} {dy:.3} cm /Icon Do Q\n").bytes()); - xobjects.set(b"Icon".to_vec(), Object::Ref(icon)); - } - } - let mut missing = None; - let mut fonts = Dict::new(); - if !icon_only && !caption.is_empty() { - let da = wobj.as_dict().and_then(|d| d.get(b"DA")).and_then(|o| doc.resolve(o).as_string().map(|s| s.to_text())); - let auto = ((height - 4.0) * 0.6).clamp(4.0, 14.0); - let (ops, name, font, m) = appearance::button_caption(doc, da.as_deref().unwrap_or(field_da), &caption, width, height, auto); - content.extend(ops); - fonts.set(name.into_bytes(), font); - missing = m; - } - let mut res = Dict::new(); - res.set(b"Font".to_vec(), Object::Dict(fonts)); - if !xobjects.is_empty() { - res.set(b"XObject".to_vec(), Object::Dict(xobjects)); - } - (form_stream(place, content, res), missing) -} - -/// Give a push button (an image field) the picture `image` (an image XObject of `px` pixels): -/// it becomes the button's icon (`/MK /I`) and its appearance. -pub fn set_button_icon(doc: &mut Document, name: &str, image: ObjRef, px: (u32, u32)) -> Result<(), FormError> { - let f = fields(doc).into_iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?; - if f.kind != FieldKind::PushButton { - return invalid(format!("{name} is not a button or image field")); - } - // The icon form draws the image in a box of its pixel size (keeping its aspect ratio). - let (w, h) = (px.0.max(1) as f64, px.1.max(1) as f64); - let mut xo = Dict::new(); - xo.set(b"Im0".to_vec(), Object::Ref(image)); - let mut res = Dict::new(); - res.set(b"XObject".to_vec(), Object::Dict(xo)); - let icon = - doc.add(Object::Stream(form_stream(crate::appearance::placement(0, w, h), format!("q {w} 0 0 {h} 0 0 cm /Im0 Do Q").into_bytes(), res))); - for wd in &f.widgets { - let mut mk = - doc.get(wd.obj).as_dict().and_then(|d| d.get(b"MK").map(|m| doc.resolve(m))).and_then(|m| m.as_dict().cloned()).unwrap_or_default(); - mk.set(b"I".to_vec(), Object::Ref(icon)); - if !mk.contains(b"TP") { - mk.set(b"TP".to_vec(), Object::Int(1)); - } - doc.update_dict(wd.obj, |d| d.set(b"MK".to_vec(), Object::Dict(mk)))?; - } - redraw_field(doc, name) -} - -fn empty_box(doc: &Document, w: &Widget) -> Stream { - let (c, place) = frame_only(doc, w); - form_stream(place, c.into_bytes(), Dict::new()) -} - -/// Swap width and height, keeping the center. `None` when that is not a usable widget. -fn swapped_around_center(r: [f64; 4]) -> Option<[f64; 4]> { - if !r.iter().all(|v| v.is_finite()) { - return None; - } - let (x0, y0, x1, y1) = (r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])); - let (cx, cy) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0); - let (nw, nh) = (y1 - y0, x1 - x0); - let out = [cx - nw / 2.0, cy - nh / 2.0, cx + nw / 2.0, cy + nh / 2.0]; - (out.iter().all(|v| v.is_finite()) && out[2] - out[0] >= MIN_FIELD_SIDE && out[3] - out[1] >= MIN_FIELD_SIDE).then_some(out) -} - -/// Change a field's properties (General and Options tabs) and redraw it. -pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result { - if props.font_size.is_some_and(|size| !size.is_finite() || size < 0.0) { - return invalid("font size must be a finite, non-negative number"); - } - if let Some(patch) = &props.appearance { - if props.look.is_some() { - return invalid("give an appearance patch or a complete look, not both"); - } - patch.validate()?; - } - let all = fields(doc); - let f = all.iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?.clone(); - // Refuse a wrong type/index before any other requested property is written. - if let Some((wi, _)) = &props.caption { - if f.kind != FieldKind::PushButton { - return invalid("only push buttons have a caption"); - } - if f.widgets.get(*wi).is_none() { - return invalid(format!("{name} has no widget {}", wi.saturating_add(1))); - } - } - // Validated before anything is written, so a refused rotation leaves the document unchanged. - // `Some(Some(rect))`: the widget's axis changes and it gets the swapped rectangle. - let mut rotated_rect: Option> = None; - if let Some((wi, rot)) = props.rotation { - if !matches!(rot, 0 | 90 | 180 | 270) { - return invalid("rotation must be 0, 90, 180 or 270 degrees"); - } - let Some(w) = f.widgets.get(wi) else { - return invalid(format!("{name} has no widget {}", wi.saturating_add(1))); - }; - let axis_change = (w.rotation % 180 == 0) != (rot % 180 == 0); - rotated_rect = Some(None); - if axis_change && props.rect.is_none() { - let Some(rect) = swapped_around_center(w.rect) else { return invalid("the field is too small") }; - rotated_rect = Some(Some(rect)); - } - } - // A locked field only takes unlocking (Acrobat greys its properties out). - if f.locked() && props.locked != Some(false) && *props != (FieldProps { locked: props.locked, ..FieldProps::default() }) { - return invalid(format!("{name} is locked: unlock it first")); - } - if let Some(lock) = props.locked { - for w in &f.widgets { - let flags = doc.get(w.obj).as_dict().and_then(|d| d.get(b"F").and_then(|v| doc.resolve(v).as_int())).unwrap_or(0); - let v = if lock { flags | 128 } else { flags & !128 }; - doc.update_dict(w.obj, |d| d.set(b"F".to_vec(), Object::Int(v)))?; - } - if *props == (FieldProps { locked: props.locked, ..FieldProps::default() }) { - return Ok(name.to_string()); - } - } - if let Some(acts) = &props.actions { - crate::set_field_actions(doc, name, acts)?; - } - let mut new_name = name.to_string(); - if let Some(n) = props.name.as_deref().map(str::trim).filter(|n| *n != f.name) { - // A full name under the same parents only renames the field's own part (the editor - // shows nested fields by their full name): it isn't moved, so it keeps its place and - // goes on inheriting from its parents. - let n = match (n.rsplit_once('.'), f.name.rsplit_once('.')) { - (Some((parents, own)), Some((current, _))) if parents == current => own, - _ => n, - }; - if n.contains('.') { - // A full name: the field moves under the parent fields it names (#2696). - let parts = name_parts(n)?; - if let Some(why) = all.iter().filter(|x| x.obj != f.obj).find_map(|x| clash(n, &x.name)) { - return invalid(why); - } - let af = ensure_form(doc)?; - // Checked before anything changes, so a refused move leaves the field as it was. - let add = inherited(doc, f.obj); - check_place(doc, af, f.obj, &parts, &add)?; - // From here a failure (a write that `check_place` couldn't foresee) would leave the - // field half moved in `doc`. The engine runs every edit on a copy of the document and - // keeps it only when the edit succeeds (`Session::apply`), so the open document never - // sees that. - own_inherited(doc, f.obj, &add)?; - detach(doc, f.obj)?; - attach(doc, af, f.obj, &parts)?; - new_name = n.to_string(); - } else { - if n.is_empty() { - return invalid("field names can't be empty"); - } - // Rename the terminal part (the field's own /T). The parents' part stays, so the name - // that must be free is the full one: `MASTER.a` renamed to `b` takes `MASTER.b` (ISO - // 32000-2 §12.7.4.2). The field itself is no clash when it keeps its own name. - let prefix = f.name.rsplit_once('.').map(|(p, _)| format!("{p}.")).unwrap_or_default(); - new_name = format!("{prefix}{n}"); - if let Some(why) = all.iter().filter(|x| x.obj != f.obj).find_map(|x| clash(&new_name, &x.name)) { - return invalid(why); - } - // A node of that name beside it that isn't a terminal field, such as a parent - // field left empty by a move. - let af = ensure_form(doc)?; - let parent = doc.get(f.obj).as_dict().and_then(|d| d.reference(b"Parent")); - if child_named(doc, af, parent, n).is_some_and(|r| r != f.obj) { - return invalid(format!("a field named {new_name:?} already exists")); - } - doc.update_dict(f.obj, |d| d.set(b"T".to_vec(), PdfString::text(n)))?; - } - // Calculations read the field by its new name, unless another field keeps the old one. - if all.iter().filter(|x| x.name == f.name).count() == 1 { - rename_in_calculations(doc, &all, &f.name, &new_name)?; - } - } - // The new default appearance when the size or the look changes. - let size = props.font_size.unwrap_or_else(|| crate::appearance::parse_da(&f.da).size); - let new_da = match &props.look { - Some(l) => da_string(l.font, size, l.text), - None => patch_da(&f.da, props.font_size, &props.appearance.unwrap_or_default()), - }; - if props.look.is_some() || props.appearance.is_some_and(|p| p.font.is_some()) { - ensure_form(doc)?; - } - let mut ff = f.flags; - let mut set_flag = |flag: u32, on: Option| { - if let Some(on) = on { - if on { - ff |= flag; - } else { - ff &= !flag; - } - } - }; - set_flag(flags::READ_ONLY, props.read_only); - set_flag(flags::REQUIRED, props.required); - if f.kind == FieldKind::Text { - set_flag(flags::MULTILINE, props.multiline); - } - for (flag, on) in &props.flags { - set_flag(*flag, Some(*on)); - } - // A comb spreads a fixed number of characters across the field: it needs a limit and - // excludes multi-line, password, file selection and scrolling (ISO 32000-2 Table 229). - if f.kind == FieldKind::Text && ff & flags::COMB != 0 { - let limit = match props.max_len { - Some(m) => m, - None => f.max_len, - }; - if limit.is_none_or(|m| m == 0) { - return invalid("a comb of characters needs a character limit"); - } - ff &= !(flags::MULTILINE | flags::PASSWORD | flags::FILE_SELECT); - } - if let Some(q) = props.quadding - && !(0..=2).contains(&q) - { - return invalid("alignment is 0 (left), 1 (centre) or 2 (right)"); - } - if props.options.as_ref().is_some_and(Vec::is_empty) && matches!(f.kind, FieldKind::Combo | FieldKind::List) { - return invalid("a list needs at least one option"); - } - if props.check_style.is_some() && !matches!(f.kind, FieldKind::CheckBox | FieldKind::Radio) { - return invalid("only check boxes and radio buttons have a check style"); - } - doc.update_dict(f.obj, |d| { - d.set(b"Ff".to_vec(), Object::Int(ff as i64)); - if let Some(t) = &props.tooltip { - if t.is_empty() { - d.remove(b"TU"); - } else { - d.set(b"TU".to_vec(), PdfString::text(t)); - } - } - if let Some(m) = props.max_len { - match m { - Some(m) => d.set(b"MaxLen".to_vec(), Object::Int(m as i64)), - None => { - d.remove(b"MaxLen"); - } - } - } - if let Some(opts) = &props.options { - d.set(b"Opt".to_vec(), Object::Array(opts.iter().map(|o| Object::String(PdfString::text(o))).collect())); - } - // "Sort items": the list itself is kept in order (export values stay with their items). - if ff & flags::SORT != 0 && matches!(f.kind, FieldKind::Combo | FieldKind::List) { - let mut items: Vec<(String, String)> = match &props.options { - Some(o) => o.iter().map(|x| (x.clone(), x.clone())).collect(), - None => f.options.clone(), - }; - items.sort_by_key(|(_, display)| display.to_lowercase()); - let opt = items - .iter() - .map(|(e, disp)| { - if e == disp { - Object::String(PdfString::text(disp)) - } else { - Object::Array(vec![Object::String(PdfString::text(e)), Object::String(PdfString::text(disp))]) - } - }) - .collect(); - d.set(b"Opt".to_vec(), Object::Array(opt)); - } - if let Some(q) = props.quadding { - d.set(b"Q".to_vec(), Object::Int(q)); - } - match &props.default_value { - // Buttons name their default state; text and choices hold a string. - Some(Some(v)) if matches!(f.kind, FieldKind::CheckBox | FieldKind::Radio) => d.set(b"DV".to_vec(), Object::Name(crate::name_bytes(v))), - Some(Some(v)) => d.set(b"DV".to_vec(), PdfString::text(v)), - Some(None) => { - d.remove(b"DV"); - } - None => {} - } - if props.font_size.is_some() || props.look.is_some() || props.appearance.is_some_and(|p| p.font.is_some() || p.text.is_some()) { - d.set(b"DA".to_vec(), PdfString::literal(new_da.clone().into_bytes())); - } - })?; - if props.appearance.is_some() || (props.font_size.is_some() && props.look.is_none()) { - let patch = &props.appearance.unwrap_or_default(); - for w in &f.widgets { - patch_widget_look(doc, w, patch)?; - // A widget's own /DA overrides its field's /DA. Patch that override separately - // rather than replacing its font or colour with the first widget's defaults. - if w.obj != f.obj && (props.font_size.is_some() || patch.font.is_some() || patch.text.is_some()) { - let da = doc - .get(w.obj) - .as_dict() - .and_then(|d| d.get(b"DA").cloned()) - .map(|v| doc.resolve(&v)) - .and_then(|v| v.as_string().map(|s| s.to_text())); - if let Some(da) = da { - let da = patch_da(&da, props.font_size, patch); - doc.update_dict(w.obj, |d| d.set(b"DA".to_vec(), PdfString::literal(da.into_bytes())))?; - } - } - } - } - if let Some(l) = &props.look { - let arr = |c: [f64; 3]| Object::Array(c.iter().map(|v| Object::Real(v.clamp(0.0, 1.0))).collect()); - for w in &f.widgets { - // Resolved first: an indirect /MK keeps its mark, rotation and other entries. - let mut mk = doc.get(w.obj).as_dict().and_then(|d| d.get(b"MK").map(|m| doc.resolve(m).as_dict().cloned())).flatten().unwrap_or_default(); - doc.update_dict(w.obj, |d| { - match l.border { - Some(c) => mk.set(b"BC".to_vec(), arr(c)), - None => { - mk.remove(b"BC"); - } - } - match l.fill { - Some(c) => mk.set(b"BG".to_vec(), arr(c)), - None => { - mk.remove(b"BG"); - } - } - d.set(b"MK".to_vec(), Object::Dict(mk)); - let mut bs = Dict::new(); - bs.set(b"W".to_vec(), Object::Real(l.width.clamp(0.0, 12.0))); - bs.set(b"S".to_vec(), Object::name(l.style.code())); - if l.style == BorderStyle::Dashed { - bs.set(b"D".to_vec(), Object::Array(vec![Object::Int(3)])); - } - d.set(b"BS".to_vec(), Object::Dict(bs)); - })?; - } - } - if let Some(style) = props.check_style { - for w in &f.widgets { - // Resolved first: an indirect /MK keeps its colours. - let mut mk = doc.get(w.obj).as_dict().and_then(|d| d.get(b"MK").map(|m| doc.resolve(m).as_dict().cloned())).flatten().unwrap_or_default(); - mk.set(b"CA".to_vec(), PdfString::text(style.code())); - doc.update_dict(w.obj, |d| d.set(b"MK".to_vec(), Object::Dict(mk)))?; - } - } - if let Some((wi, r)) = props.rect { - let w = f.widgets.get(wi).ok_or_else(|| FormError::Invalid(format!("{name} has no widget {}", wi + 1)))?; - let r = [r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])]; - if !r.iter().all(|v| v.is_finite()) || r[2] - r[0] < MIN_FIELD_SIDE || r[3] - r[1] < MIN_FIELD_SIDE { - return invalid("the field is too small"); - } - doc.update_dict(w.obj, |d| d.set(b"Rect".to_vec(), Object::Array(r.iter().map(|v| Object::Real(*v)).collect())))?; - } - if props.format.is_some() || props.validate.is_some() || props.calculate.is_some() { - if !matches!(f.kind, FieldKind::Text | FieldKind::Combo) { - return invalid("only text fields and drop-down lists have format, validate and calculate scripts"); - } - let mut aa = doc - .get(f.obj) - .as_dict() - .and_then(|d| d.get(b"AA").cloned()) - .map(|a| doc.resolve(&a)) - .and_then(|a| a.as_dict().cloned()) - .unwrap_or_default(); - let action = |js: &str| -> Object { - let mut a = Dict::new(); - a.set(b"S".to_vec(), Object::name("JavaScript")); - a.set(b"JS".to_vec(), PdfString::literal(js.as_bytes().to_vec())); - Object::Dict(a) - }; - if let Some(fm) = &props.format { - aa.remove(b"F"); - aa.remove(b"K"); - if let Some((fjs, kjs)) = crate::af::format_js(fm) { - if !fjs.is_empty() { - aa.set(b"F".to_vec(), action(&fjs)); - } - aa.set(b"K".to_vec(), action(&kjs)); - } - } - if let Some(v) = &props.validate { - aa.remove(b"V"); - if let Some(js) = crate::af::validate_js(v) { - aa.set(b"V".to_vec(), action(&js)); - } - } - if let Some(c) = &props.calculate { - aa.remove(b"C"); - if let Some(js) = crate::af::calculate_js(c) { - aa.set(b"C".to_vec(), action(&js)); - } - crate::calc_order::keep_listed(doc, f.obj, *c != crate::af::Calculate::None)?; - } - doc.update_dict(f.obj, |d| { - if aa.is_empty() { - d.remove(b"AA"); - } else { - d.set(b"AA".to_vec(), Object::Dict(aa)); - } - })?; - } - // A complete look replaces widget /DA overrides. Partial changes were patched above - // against each override, including size-only changes that keep its font and colour. - if props.look.is_some() { - for w in &f.widgets { - if w.obj != f.obj { - doc.update_dict(w.obj, |d| { - if d.contains(b"DA") { - d.set(b"DA".to_vec(), PdfString::literal(new_da.clone().into_bytes())); - } - })?; - } - } - } - if let (Some((wi, rot)), Some(swapped)) = (props.rotation, rotated_rect) - && let Some(w) = f.widgets.get(wi) - { - if let Some(rect) = swapped { - doc.update_dict(w.obj, |d| d.set(b"Rect".to_vec(), Object::Array(rect.iter().map(|v| Object::Real(*v)).collect())))?; - } - let mut mk = doc.get(w.obj).as_dict().and_then(|d| d.get(b"MK").map(|m| doc.resolve(m).as_dict().cloned())).flatten().unwrap_or_default(); - if rot == 0 { - mk.remove(b"R"); - } else { - mk.set(b"R".to_vec(), Object::Int(rot)); - } - doc.update_dict(w.obj, |d| { - if mk.is_empty() { - d.remove(b"MK"); - } else { - d.set(b"MK".to_vec(), Object::Dict(mk)); - } - })?; - } - if let Some((wi, caption)) = &props.caption - && let Some(w) = f.widgets.get(*wi) - { - // Copy an indirect/shared MK before patching it; colours, icons, rotation and - // unknown entries belong to this widget, and other owners keep their old label. - let mut mk = doc.get(w.obj).as_dict().and_then(|d| d.get(b"MK").map(|m| doc.resolve(m).as_dict().cloned())).flatten().unwrap_or_default(); - mk.set(b"CA".to_vec(), PdfString::text(caption)); - doc.update_dict(w.obj, |d| d.set(b"MK".to_vec(), Object::Dict(mk)))?; - } - // A label-only edit must not replace another widget's custom appearances. - let only = props.caption.as_ref().filter(|_| *props == (FieldProps { caption: props.caption.clone(), ..Default::default() })).map(|(wi, _)| *wi); - redraw_widgets(doc, &new_name, only)?; - if props.calculate.is_some() { - crate::recalculate(doc)?; - } - Ok(new_name) -} - -/// Name field `old` `new` in each field's Calculate script that reads it -/// ([`crate::af::rename_in_calculation`]). -fn rename_in_calculations(doc: &mut Document, all: &[Field], old: &str, new: &str) -> Result<(), FormError> { - let aa_of = - |doc: &Document, r: ObjRef| doc.get(r).as_dict().and_then(|d| d.get(b"AA").map(|a| doc.resolve(a))).and_then(|a| a.as_dict().cloned()); - for f in all { - // The script is the field's, or its only widget's when the field has none, as - // `actions_of` reads it. - let only_widget = || match f.widgets.as_slice() { - [w] => aa_of(doc, w.obj).map(|aa| (w.obj, aa)), - _ => None, - }; - let Some((owner, mut aa)) = aa_of(doc, f.obj).map(|aa| (f.obj, aa)).or_else(only_widget) else { continue }; - let Some(mut action) = aa.get(b"C").and_then(|a| doc.resolve(a).as_dict().cloned()) else { continue }; - let js = match action.get(b"JS").map(|js| doc.resolve(js)).as_deref() { - Some(Object::String(s)) => s.to_text(), - // A stream that isn't UTF-8 reads with replacement characters, which must not be - // written back in place of what it holds. - Some(Object::Stream(s)) => match s.decoded().ok().and_then(|b| String::from_utf8(b).ok()) { - Some(js) => js, - None => continue, - }, - _ => continue, - }; - let Some(js) = crate::af::rename_in_calculation(&js, old, new) else { continue }; - action.set(b"JS".to_vec(), PdfString::text(&js)); - aa.set(b"C".to_vec(), Object::Dict(action)); - doc.update_dict(owner, |d| d.set(b"AA".to_vec(), Object::Dict(aa)))?; - } - Ok(()) -} - -/// Delete a field: its widgets leave their pages and the field leaves the form. -pub fn delete_field(doc: &mut Document, name: &str) -> Result<(), FormError> { - let f = fields(doc).into_iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?; - delete_found_field(doc, f) -} - -/// [`delete_field`] for the field object `obj`, when separate fields share one name. -pub fn delete_field_object(doc: &mut Document, obj: ObjRef) -> Result<(), FormError> { - let f = fields(doc).into_iter().find(|f| f.obj == obj).ok_or_else(|| FormError::NoSuchField(format!("{obj:?}")))?; - delete_found_field(doc, f) -} - -fn delete_found_field(doc: &mut Document, f: Field) -> Result<(), FormError> { - let widgets: std::collections::HashSet = f.widgets.iter().map(|w| w.obj).collect(); - for p in page_refs(doc) { - let Some(a) = doc.get(p).as_dict().and_then(|d| d.get(b"Annots").cloned()) else { continue }; - let list = doc.resolve(&a).as_array().cloned().unwrap_or_default(); - let kept: Vec = list.iter().filter(|o| !o.as_ref().is_some_and(|r| widgets.contains(&r))).cloned().collect(); - if kept.len() != list.len() { - match a.as_ref() { - Some(r) if doc.get(r).as_array().is_some() => doc.set(r, Object::Array(kept)), - _ => doc.update_dict(p, |d| d.set(b"Annots".to_vec(), Object::Array(kept)))?, - } - } - } - detach(doc, f.obj)?; - // Out of the calculation order too: a `/CO` entry would keep the field, its value and its - // widgets reachable for a full save. - drop_from_calculation_order(doc, |r| r == f.obj || widgets.contains(&r)) -} - -/// Remove the `/CO` entries `gone` matches, whether the form and the array are held directly or -/// by reference. -fn drop_from_calculation_order(doc: &mut Document, gone: impl Fn(ObjRef) -> bool) -> Result<(), FormError> { - let Some(root) = doc.root() else { return Ok(()) }; - let Some(af_obj) = doc.get(root).as_dict().and_then(|d| d.get(b"AcroForm").cloned()) else { return Ok(()) }; - let Some(mut af) = doc.resolve(&af_obj).as_dict().cloned() else { return Ok(()) }; - let Some(co_obj) = af.get(b"CO").cloned() else { return Ok(()) }; - let co = doc.resolve(&co_obj).as_array().cloned().unwrap_or_default(); - let kept: Vec = co.iter().filter(|o| !o.as_ref().is_some_and(&gone)).cloned().collect(); - if kept.len() == co.len() { - return Ok(()); - } - match (co_obj.as_ref(), af_obj.as_ref()) { - (Some(r), _) if doc.get(r).as_array().is_some() => doc.set(r, Object::Array(kept)), - (_, Some(af_ref)) => doc.update_dict(af_ref, |d| d.set(b"CO".to_vec(), Object::Array(kept)))?, - (_, None) => { - af.set(b"CO".to_vec(), Object::Array(kept)); - doc.update_dict(root, |d| d.set(b"AcroForm".to_vec(), Object::Dict(af)))?; - } - } - Ok(()) -} - -/// Widget-only keys: they stay with each widget when a merged field is split. -const WIDGET_KEYS: [&[u8]; 12] = [b"Type", b"Subtype", b"Rect", b"P", b"AP", b"AS", b"MK", b"F", b"BS", b"Border", b"H", b"StructParent"]; - -/// Duplicate a field onto other pages (Acrobat: right-click a field ▸ Duplicate): each page -/// gets a widget at the same place, belonging to the same field, so they share one value. -/// A field that is its own widget is first split into a field and a widget kid. Returns how -/// many widgets were added (pages that already have one are skipped). -pub fn duplicate_field(doc: &mut Document, name: &str, pages: &[usize]) -> Result { - let f = fields(doc).into_iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?; - let model = f.widgets.first().cloned().ok_or_else(|| FormError::Invalid(format!("{name} has no widget")))?; - let refs = page_refs(doc); - for p in pages { - if *p >= refs.len() { - return invalid(format!("page {} does not exist", p + 1)); - } - } - let have: std::collections::HashSet = f.widgets.iter().filter_map(|w| w.page).collect(); - let targets: Vec = pages.iter().copied().filter(|p| !have.contains(p)).collect(); - if targets.is_empty() { - return Ok(0); - } - // A merged field/widget becomes a field with one widget kid. - let mut model_ref = model.obj; - if model.obj == f.obj { - let d = doc.get(f.obj).as_dict().cloned().unwrap_or_default(); - let mut kid = Dict::new(); - for k in WIDGET_KEYS { - if let Some(v) = d.get(k) { - kid.set(k.to_vec(), v.clone()); - } - } - kid.set(b"Parent".to_vec(), Object::Ref(f.obj)); - let kr = doc.add(Object::Dict(kid)); - doc.update_dict(f.obj, |d| { - for k in WIDGET_KEYS { - d.remove(k); - } - d.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(kr)])); - })?; - // The page lists the widget now, not the field. - if let Some(p) = model.page { - let pr = refs[p]; - let annots = doc.get(pr).as_dict().and_then(|d| d.get(b"Annots").cloned()); - let swap = - |a: Vec| -> Vec { a.into_iter().map(|o| if o.as_ref() == Some(f.obj) { Object::Ref(kr) } else { o }).collect() }; - match annots { - Some(Object::Ref(ar)) => { - let a = doc.get(ar).as_array().cloned().unwrap_or_default(); - doc.set(ar, Object::Array(swap(a))); - } - Some(Object::Array(a)) => doc.update_dict(pr, |d| d.set(b"Annots".to_vec(), Object::Array(swap(a))))?, - _ => {} - } - } - model_ref = kr; - } - let template = doc.get(model_ref).as_dict().cloned().unwrap_or_default(); - let mut added = Vec::new(); - for p in &targets { - let pr = refs[*p]; - let mut w = template.clone(); - w.set(b"P".to_vec(), Object::Ref(pr)); - w.set(b"Parent".to_vec(), Object::Ref(f.obj)); - w.remove(b"StructParent"); - let wr = doc.add(Object::Dict(w)); - let annots = doc.get(pr).as_dict().and_then(|d| d.get(b"Annots").cloned()); - match annots { - Some(Object::Ref(ar)) => { - let mut a = doc.get(ar).as_array().cloned().unwrap_or_default(); - a.push(Object::Ref(wr)); - doc.set(ar, Object::Array(a)); - } - other => { - let mut a = other.and_then(|o| o.as_array().cloned()).unwrap_or_default(); - a.push(Object::Ref(wr)); - doc.update_dict(pr, |d| d.set(b"Annots".to_vec(), Object::Array(a)))?; - } - } - added.push(wr); - } - doc.update_dict(f.obj, |d| { - let mut kids = d.get(b"Kids").and_then(|k| k.as_array().cloned()).unwrap_or_default(); - kids.extend(added.iter().map(|r| Object::Ref(*r))); - d.set(b"Kids".to_vec(), Object::Array(kids)); - })?; - redraw_field(doc, name)?; - Ok(added.len()) -} diff --git a/crates/forms/src/author/edit.rs b/crates/forms/src/author/edit.rs new file mode 100644 index 0000000000..d61cfe28f2 --- /dev/null +++ b/crates/forms/src/author/edit.rs @@ -0,0 +1,446 @@ +use super::look::{BorderStyle, da_string, patch_da, patch_widget_look, redraw_widgets}; +use super::tree::{attach, check_place, child_named, clash, detach, ensure_form, inherited, name_parts, own_inherited}; +use super::{Dict, Document, Field, FieldKind, FieldProps, FormError, MIN_FIELD_SIDE, ObjRef, Object, PdfString, fields, flags, invalid}; + +/// Swap width and height, keeping the center. `None` when that is not a usable widget. +fn swapped_around_center(r: [f64; 4]) -> Option<[f64; 4]> { + if !r.iter().all(|v| v.is_finite()) { + return None; + } + let (x0, y0, x1, y1) = (r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])); + let (cx, cy) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0); + let (nw, nh) = (y1 - y0, x1 - x0); + let out = [cx - nw / 2.0, cy - nh / 2.0, cx + nw / 2.0, cy + nh / 2.0]; + (out.iter().all(|v| v.is_finite()) && out[2] - out[0] >= MIN_FIELD_SIDE && out[3] - out[1] >= MIN_FIELD_SIDE).then_some(out) +} + +/// Change a field's properties (General and Options tabs) and redraw it. +pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result { + if props.font_size.is_some_and(|size| !size.is_finite() || size < 0.0) { + return invalid("font size must be a finite, non-negative number"); + } + if let Some(patch) = &props.appearance { + if props.look.is_some() { + return invalid("give an appearance patch or a complete look, not both"); + } + patch.validate()?; + } + let all = fields(doc); + let f = all.iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?.clone(); + // Refuse a wrong type/index before any other requested property is written. + if let Some((wi, _)) = &props.caption { + if f.kind != FieldKind::PushButton { + return invalid("only push buttons have a caption"); + } + if f.widgets.get(*wi).is_none() { + return invalid(format!("{name} has no widget {}", wi.saturating_add(1))); + } + } + // Validated before anything is written, so a refused rotation leaves the document unchanged. + // `Some(Some(rect))`: the widget's axis changes and it gets the swapped rectangle. + let mut rotated_rect: Option> = None; + if let Some((wi, rot)) = props.rotation { + if !matches!(rot, 0 | 90 | 180 | 270) { + return invalid("rotation must be 0, 90, 180 or 270 degrees"); + } + let Some(w) = f.widgets.get(wi) else { + return invalid(format!("{name} has no widget {}", wi.saturating_add(1))); + }; + let axis_change = (w.rotation % 180 == 0) != (rot % 180 == 0); + rotated_rect = Some(None); + if axis_change && props.rect.is_none() { + let Some(rect) = swapped_around_center(w.rect) else { return invalid("the field is too small") }; + rotated_rect = Some(Some(rect)); + } + } + // A locked field only takes unlocking (Acrobat greys its properties out). + if f.locked() && props.locked != Some(false) && *props != (FieldProps { locked: props.locked, ..FieldProps::default() }) { + return invalid(format!("{name} is locked: unlock it first")); + } + if let Some(lock) = props.locked { + for w in &f.widgets { + let flags = doc.get(w.obj).as_dict().and_then(|d| d.get(b"F").and_then(|v| doc.resolve(v).as_int())).unwrap_or(0); + let v = if lock { flags | 128 } else { flags & !128 }; + doc.update_dict(w.obj, |d| d.set(b"F".to_vec(), Object::Int(v)))?; + } + if *props == (FieldProps { locked: props.locked, ..FieldProps::default() }) { + return Ok(name.to_string()); + } + } + if let Some(acts) = &props.actions { + crate::set_field_actions(doc, name, acts)?; + } + let new_name = rename_field(doc, &all, &f, name, props)?; + // The new default appearance when the size or the look changes. + let size = props.font_size.unwrap_or_else(|| crate::appearance::parse_da(&f.da).size); + let new_da = match &props.look { + Some(l) => da_string(l.font, size, l.text), + None => patch_da(&f.da, props.font_size, &props.appearance.unwrap_or_default()), + }; + if props.look.is_some() || props.appearance.is_some_and(|p| p.font.is_some()) { + ensure_form(doc)?; + } + let mut ff = f.flags; + let mut set_flag = |flag: u32, on: Option| { + if let Some(on) = on { + if on { + ff |= flag; + } else { + ff &= !flag; + } + } + }; + set_flag(flags::READ_ONLY, props.read_only); + set_flag(flags::REQUIRED, props.required); + if f.kind == FieldKind::Text { + set_flag(flags::MULTILINE, props.multiline); + } + for (flag, on) in &props.flags { + set_flag(*flag, Some(*on)); + } + // A comb spreads a fixed number of characters across the field: it needs a limit and + // excludes multi-line, password, file selection and scrolling (ISO 32000-2 Table 229). + if f.kind == FieldKind::Text && ff & flags::COMB != 0 { + let limit = match props.max_len { + Some(m) => m, + None => f.max_len, + }; + if limit.is_none_or(|m| m == 0) { + return invalid("a comb of characters needs a character limit"); + } + ff &= !(flags::MULTILINE | flags::PASSWORD | flags::FILE_SELECT); + } + if let Some(q) = props.quadding + && !(0..=2).contains(&q) + { + return invalid("alignment is 0 (left), 1 (centre) or 2 (right)"); + } + if props.options.as_ref().is_some_and(Vec::is_empty) && matches!(f.kind, FieldKind::Combo | FieldKind::List) { + return invalid("a list needs at least one option"); + } + if props.check_style.is_some() && !matches!(f.kind, FieldKind::CheckBox | FieldKind::Radio) { + return invalid("only check boxes and radio buttons have a check style"); + } + doc.update_dict(f.obj, |d| { + d.set(b"Ff".to_vec(), Object::Int(ff as i64)); + if let Some(t) = &props.tooltip { + if t.is_empty() { + d.remove(b"TU"); + } else { + d.set(b"TU".to_vec(), PdfString::text(t)); + } + } + if let Some(m) = props.max_len { + match m { + Some(m) => d.set(b"MaxLen".to_vec(), Object::Int(m as i64)), + None => { + d.remove(b"MaxLen"); + } + } + } + if let Some(opts) = &props.options { + d.set(b"Opt".to_vec(), Object::Array(opts.iter().map(|o| Object::String(PdfString::text(o))).collect())); + } + // "Sort items": the list itself is kept in order (export values stay with their items). + if ff & flags::SORT != 0 && matches!(f.kind, FieldKind::Combo | FieldKind::List) { + let mut items: Vec<(String, String)> = match &props.options { + Some(o) => o.iter().map(|x| (x.clone(), x.clone())).collect(), + None => f.options.clone(), + }; + items.sort_by_key(|(_, display)| display.to_lowercase()); + let opt = items + .iter() + .map(|(e, disp)| { + if e == disp { + Object::String(PdfString::text(disp)) + } else { + Object::Array(vec![Object::String(PdfString::text(e)), Object::String(PdfString::text(disp))]) + } + }) + .collect(); + d.set(b"Opt".to_vec(), Object::Array(opt)); + } + if let Some(q) = props.quadding { + d.set(b"Q".to_vec(), Object::Int(q)); + } + match &props.default_value { + // Buttons name their default state; text and choices hold a string. + Some(Some(v)) if matches!(f.kind, FieldKind::CheckBox | FieldKind::Radio) => d.set(b"DV".to_vec(), Object::Name(crate::name_bytes(v))), + Some(Some(v)) => d.set(b"DV".to_vec(), PdfString::text(v)), + Some(None) => { + d.remove(b"DV"); + } + None => {} + } + if props.font_size.is_some() || props.look.is_some() || props.appearance.is_some_and(|p| p.font.is_some() || p.text.is_some()) { + d.set(b"DA".to_vec(), PdfString::literal(new_da.clone().into_bytes())); + } + })?; + if props.appearance.is_some() || (props.font_size.is_some() && props.look.is_none()) { + let patch = &props.appearance.unwrap_or_default(); + for w in &f.widgets { + patch_widget_look(doc, w, patch)?; + // A widget's own /DA overrides its field's /DA. Patch that override separately + // rather than replacing its font or colour with the first widget's defaults. + if w.obj != f.obj && (props.font_size.is_some() || patch.font.is_some() || patch.text.is_some()) { + let da = doc + .get(w.obj) + .as_dict() + .and_then(|d| d.get(b"DA").cloned()) + .map(|v| doc.resolve(&v)) + .and_then(|v| v.as_string().map(|s| s.to_text())); + if let Some(da) = da { + let da = patch_da(&da, props.font_size, patch); + doc.update_dict(w.obj, |d| d.set(b"DA".to_vec(), PdfString::literal(da.into_bytes())))?; + } + } + } + } + apply_widget_style_props(doc, &f, name, props)?; + if props.format.is_some() || props.validate.is_some() || props.calculate.is_some() { + if !matches!(f.kind, FieldKind::Text | FieldKind::Combo) { + return invalid("only text fields and drop-down lists have format, validate and calculate scripts"); + } + let mut aa = doc + .get(f.obj) + .as_dict() + .and_then(|d| d.get(b"AA").cloned()) + .map(|a| doc.resolve(&a)) + .and_then(|a| a.as_dict().cloned()) + .unwrap_or_default(); + let action = |js: &str| -> Object { + let mut a = Dict::new(); + a.set(b"S".to_vec(), Object::name("JavaScript")); + a.set(b"JS".to_vec(), PdfString::literal(js.as_bytes().to_vec())); + Object::Dict(a) + }; + if let Some(fm) = &props.format { + aa.remove(b"F"); + aa.remove(b"K"); + if let Some((fjs, kjs)) = crate::af::format_js(fm) { + if !fjs.is_empty() { + aa.set(b"F".to_vec(), action(&fjs)); + } + aa.set(b"K".to_vec(), action(&kjs)); + } + } + if let Some(v) = &props.validate { + aa.remove(b"V"); + if let Some(js) = crate::af::validate_js(v) { + aa.set(b"V".to_vec(), action(&js)); + } + } + if let Some(c) = &props.calculate { + aa.remove(b"C"); + if let Some(js) = crate::af::calculate_js(c) { + aa.set(b"C".to_vec(), action(&js)); + } + crate::calc_order::keep_listed(doc, f.obj, *c != crate::af::Calculate::None)?; + } + doc.update_dict(f.obj, |d| { + if aa.is_empty() { + d.remove(b"AA"); + } else { + d.set(b"AA".to_vec(), Object::Dict(aa)); + } + })?; + } + let only = apply_late_widget_props(doc, &f, props, &new_da, rotated_rect)?; + redraw_widgets(doc, &new_name, only)?; + if props.calculate.is_some() { + crate::recalculate(doc)?; + } + Ok(new_name) +} + +fn rename_field(doc: &mut Document, all: &[Field], f: &Field, name: &str, props: &FieldProps) -> Result { + let mut new_name = name.to_string(); + if let Some(n) = props.name.as_deref().map(str::trim).filter(|n| *n != f.name) { + // A full name under the same parents only renames the field's own part (the editor + // shows nested fields by their full name): it isn't moved, so it keeps its place and + // goes on inheriting from its parents. + let n = match (n.rsplit_once('.'), f.name.rsplit_once('.')) { + (Some((parents, own)), Some((current, _))) if parents == current => own, + _ => n, + }; + if n.contains('.') { + // A full name: the field moves under the parent fields it names (#2696). + let parts = name_parts(n)?; + if let Some(why) = all.iter().filter(|x| x.obj != f.obj).find_map(|x| clash(n, &x.name)) { + return invalid(why); + } + let af = ensure_form(doc)?; + // Checked before anything changes, so a refused move leaves the field as it was. + let add = inherited(doc, f.obj); + check_place(doc, af, f.obj, &parts, &add)?; + // From here a failure (a write that `check_place` couldn't foresee) would leave the + // field half moved in `doc`. The engine runs every edit on a copy of the document and + // keeps it only when the edit succeeds (`Session::apply`), so the open document never + // sees that. + own_inherited(doc, f.obj, &add)?; + detach(doc, f.obj)?; + attach(doc, af, f.obj, &parts)?; + new_name = n.to_string(); + } else { + if n.is_empty() { + return invalid("field names can't be empty"); + } + // Rename the terminal part (the field's own /T). The parents' part stays, so the name + // that must be free is the full one: `MASTER.a` renamed to `b` takes `MASTER.b` (ISO + // 32000-2 §12.7.4.2). The field itself is no clash when it keeps its own name. + let prefix = f.name.rsplit_once('.').map(|(p, _)| format!("{p}.")).unwrap_or_default(); + new_name = format!("{prefix}{n}"); + if let Some(why) = all.iter().filter(|x| x.obj != f.obj).find_map(|x| clash(&new_name, &x.name)) { + return invalid(why); + } + // A node of that name beside it that isn't a terminal field, such as a parent + // field left empty by a move. + let af = ensure_form(doc)?; + let parent = doc.get(f.obj).as_dict().and_then(|d| d.reference(b"Parent")); + if child_named(doc, af, parent, n).is_some_and(|r| r != f.obj) { + return invalid(format!("a field named {new_name:?} already exists")); + } + doc.update_dict(f.obj, |d| d.set(b"T".to_vec(), PdfString::text(n)))?; + } + // Calculations read the field by its new name, unless another field keeps the old one. + if all.iter().filter(|x| x.name == f.name).count() == 1 { + rename_in_calculations(doc, all, &f.name, &new_name)?; + } + } + Ok(new_name) +} + +fn apply_widget_style_props(doc: &mut Document, f: &Field, name: &str, props: &FieldProps) -> Result<(), FormError> { + if let Some(l) = &props.look { + let arr = |c: [f64; 3]| Object::Array(c.iter().map(|v| Object::Real(v.clamp(0.0, 1.0))).collect()); + for w in &f.widgets { + // Resolved first: an indirect /MK keeps its mark, rotation and other entries. + let mut mk = doc.get(w.obj).as_dict().and_then(|d| d.get(b"MK").map(|m| doc.resolve(m).as_dict().cloned())).flatten().unwrap_or_default(); + doc.update_dict(w.obj, |d| { + match l.border { + Some(c) => mk.set(b"BC".to_vec(), arr(c)), + None => { + mk.remove(b"BC"); + } + } + match l.fill { + Some(c) => mk.set(b"BG".to_vec(), arr(c)), + None => { + mk.remove(b"BG"); + } + } + d.set(b"MK".to_vec(), Object::Dict(mk)); + let mut bs = Dict::new(); + bs.set(b"W".to_vec(), Object::Real(l.width.clamp(0.0, 12.0))); + bs.set(b"S".to_vec(), Object::name(l.style.code())); + if l.style == BorderStyle::Dashed { + bs.set(b"D".to_vec(), Object::Array(vec![Object::Int(3)])); + } + d.set(b"BS".to_vec(), Object::Dict(bs)); + })?; + } + } + if let Some(style) = props.check_style { + for w in &f.widgets { + // Resolved first: an indirect /MK keeps its colours. + let mut mk = doc.get(w.obj).as_dict().and_then(|d| d.get(b"MK").map(|m| doc.resolve(m).as_dict().cloned())).flatten().unwrap_or_default(); + mk.set(b"CA".to_vec(), PdfString::text(style.code())); + doc.update_dict(w.obj, |d| d.set(b"MK".to_vec(), Object::Dict(mk)))?; + } + } + if let Some((wi, r)) = props.rect { + let w = f.widgets.get(wi).ok_or_else(|| FormError::Invalid(format!("{name} has no widget {}", wi + 1)))?; + let r = [r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])]; + if !r.iter().all(|v| v.is_finite()) || r[2] - r[0] < MIN_FIELD_SIDE || r[3] - r[1] < MIN_FIELD_SIDE { + return invalid("the field is too small"); + } + doc.update_dict(w.obj, |d| d.set(b"Rect".to_vec(), Object::Array(r.iter().map(|v| Object::Real(*v)).collect())))?; + } + Ok(()) +} + +fn apply_late_widget_props( + doc: &mut Document, + f: &Field, + props: &FieldProps, + new_da: &str, + rotated_rect: Option>, +) -> Result, FormError> { + // A complete look replaces widget /DA overrides. Partial changes were patched above + // against each override, including size-only changes that keep its font and colour. + if props.look.is_some() { + for w in &f.widgets { + if w.obj != f.obj { + doc.update_dict(w.obj, |d| { + if d.contains(b"DA") { + d.set(b"DA".to_vec(), PdfString::literal(new_da.to_owned().into_bytes())); + } + })?; + } + } + } + if let (Some((wi, rot)), Some(swapped)) = (props.rotation, rotated_rect) + && let Some(w) = f.widgets.get(wi) + { + if let Some(rect) = swapped { + doc.update_dict(w.obj, |d| d.set(b"Rect".to_vec(), Object::Array(rect.iter().map(|v| Object::Real(*v)).collect())))?; + } + let mut mk = doc.get(w.obj).as_dict().and_then(|d| d.get(b"MK").map(|m| doc.resolve(m).as_dict().cloned())).flatten().unwrap_or_default(); + if rot == 0 { + mk.remove(b"R"); + } else { + mk.set(b"R".to_vec(), Object::Int(rot)); + } + doc.update_dict(w.obj, |d| { + if mk.is_empty() { + d.remove(b"MK"); + } else { + d.set(b"MK".to_vec(), Object::Dict(mk)); + } + })?; + } + if let Some((wi, caption)) = &props.caption + && let Some(w) = f.widgets.get(*wi) + { + // Copy an indirect/shared MK before patching it; colours, icons, rotation and + // unknown entries belong to this widget, and other owners keep their old label. + let mut mk = doc.get(w.obj).as_dict().and_then(|d| d.get(b"MK").map(|m| doc.resolve(m).as_dict().cloned())).flatten().unwrap_or_default(); + mk.set(b"CA".to_vec(), PdfString::text(caption)); + doc.update_dict(w.obj, |d| d.set(b"MK".to_vec(), Object::Dict(mk)))?; + } + // A label-only edit must not replace another widget's custom appearances. + let only = props.caption.as_ref().filter(|_| *props == (FieldProps { caption: props.caption.clone(), ..Default::default() })).map(|(wi, _)| *wi); + Ok(only) +} + +/// Name field `old` `new` in each field's Calculate script that reads it +/// ([`crate::af::rename_in_calculation`]). +fn rename_in_calculations(doc: &mut Document, all: &[Field], old: &str, new: &str) -> Result<(), FormError> { + let aa_of = + |doc: &Document, r: ObjRef| doc.get(r).as_dict().and_then(|d| d.get(b"AA").map(|a| doc.resolve(a))).and_then(|a| a.as_dict().cloned()); + for f in all { + // The script is the field's, or its only widget's when the field has none, as + // `actions_of` reads it. + let only_widget = || match f.widgets.as_slice() { + [w] => aa_of(doc, w.obj).map(|aa| (w.obj, aa)), + _ => None, + }; + let Some((owner, mut aa)) = aa_of(doc, f.obj).map(|aa| (f.obj, aa)).or_else(only_widget) else { continue }; + let Some(mut action) = aa.get(b"C").and_then(|a| doc.resolve(a).as_dict().cloned()) else { continue }; + let js = match action.get(b"JS").map(|js| doc.resolve(js)).as_deref() { + Some(Object::String(s)) => s.to_text(), + // A stream that isn't UTF-8 reads with replacement characters, which must not be + // written back in place of what it holds. + Some(Object::Stream(s)) => match s.decoded().ok().and_then(|b| String::from_utf8(b).ok()) { + Some(js) => js, + None => continue, + }, + _ => continue, + }; + let Some(js) = crate::af::rename_in_calculation(&js, old, new) else { continue }; + action.set(b"JS".to_vec(), PdfString::text(&js)); + aa.set(b"C".to_vec(), Object::Dict(action)); + doc.update_dict(owner, |d| d.set(b"AA".to_vec(), Object::Dict(aa)))?; + } + Ok(()) +} diff --git a/crates/forms/src/author/lifecycle.rs b/crates/forms/src/author/lifecycle.rs new file mode 100644 index 0000000000..7f7e3d9e41 --- /dev/null +++ b/crates/forms/src/author/lifecycle.rs @@ -0,0 +1,329 @@ +use super::look::redraw_field; +use super::tree::{add_to_page, attach, clash, detach, ensure_form, free_name, name_parts, top_names}; +use super::{ + Dict, Document, Field, FieldKind, FormError, MIN_FIELD_SIDE, NewField, ObjRef, Object, PdfString, Widget, appearance, fields, flags, invalid, + page_refs, rgb, +}; + +fn widget_dict(page: ObjRef, rect: [f64; 4]) -> Dict { + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("Annot")); + d.set(b"Subtype".to_vec(), Object::name("Widget")); + d.set(b"Rect".to_vec(), Object::Array(rect.iter().map(|v| Object::Real(*v)).collect())); + d.set(b"P".to_vec(), Object::Ref(page)); + d.set(b"F".to_vec(), Object::Int(4)); + let mut mk = Dict::new(); + mk.set(b"BC".to_vec(), rgb([0.55, 0.55, 0.55])); + mk.set(b"BG".to_vec(), rgb([1.0, 1.0, 1.0])); + d.set(b"MK".to_vec(), Object::Dict(mk)); + let mut bs = Dict::new(); + bs.set(b"W".to_vec(), Object::Int(1)); + bs.set(b"S".to_vec(), Object::name("S")); + d.set(b"BS".to_vec(), Object::Dict(bs)); + d +} + +/// Add a field on `page` (0-based) in `rect` (user space). Returns its full name. +pub fn add_field(doc: &mut Document, page: usize, rect: [f64; 4], kind: &NewField, name: Option<&str>) -> Result { + let pages = page_refs(doc); + let page_ref = *pages.get(page).ok_or_else(|| FormError::Invalid(format!("page {} does not exist", page + 1)))?; + let rect = [rect[0].min(rect[2]), rect[1].min(rect[3]), rect[0].max(rect[2]), rect[1].max(rect[3])]; + if !rect.iter().all(|v| v.is_finite()) || rect[2] - rect[0] < MIN_FIELD_SIDE || rect[3] - rect[1] < MIN_FIELD_SIDE { + return invalid("the field is too small"); + } + let existing = fields(doc); + // A radio button names its group: a new group takes that name. + let name = match (name, kind) { + (None, NewField::Radio { group: Some(g), .. }) => Some(g.as_str()), + _ => name, + }; + let name = match name.map(str::trim) { + Some(n) if !n.is_empty() => n.to_string(), + _ => free_name(&existing, &top_names(doc), kind.base_name()), + }; + let parts = name_parts(&name)?; + let radio_group = match kind { + NewField::Radio { group, .. } => existing.iter().find(|f| f.kind == FieldKind::Radio && Some(&f.name) == group.as_ref()).cloned(), + _ => None, + }; + if radio_group.is_none() + && let Some(why) = existing.iter().find_map(|f| clash(&name, &f.name)) + { + return invalid(why); + } + let af = ensure_form(doc)?; + let mut w = widget_dict(page_ref, rect); + let field_name = match kind { + NewField::Radio { group: Some(g), .. } if radio_group.is_some() => g.clone(), + _ => name.clone(), + }; + match kind { + NewField::Text { multiline } => { + w.set(b"FT".to_vec(), Object::name("Tx")); + // Auto size (0): a fixed 12 pt is clipped in a box shorter than that (#1888). + w.set(b"DA".to_vec(), PdfString::literal(b"/Helv 0 Tf 0 g".to_vec())); + if *multiline { + w.set(b"Ff".to_vec(), Object::Int(flags::MULTILINE as i64)); + } + } + NewField::Date => { + w.set(b"FT".to_vec(), Object::name("Tx")); + w.set(b"DA".to_vec(), PdfString::literal(b"/Helv 0 Tf 0 g".to_vec())); + // Acrobat's format and keystroke actions for mm/dd/yyyy (run by its JavaScript engine). + let js = |s: &str| { + let mut a = Dict::new(); + a.set(b"S".to_vec(), Object::name("JavaScript")); + a.set(b"JS".to_vec(), PdfString::literal(s.as_bytes().to_vec())); + Object::Dict(a) + }; + let mut aa = Dict::new(); + aa.set(b"F".to_vec(), js("AFDate_FormatEx(\"mm/dd/yyyy\");")); + aa.set(b"K".to_vec(), js("AFDate_KeystrokeEx(\"mm/dd/yyyy\");")); + w.set(b"AA".to_vec(), Object::Dict(aa)); + } + NewField::CheckBox => { + w.set(b"FT".to_vec(), Object::name("Btn")); + w.set(b"V".to_vec(), Object::name("Off")); + w.set(b"AS".to_vec(), Object::name("Off")); + w.set(b"DA".to_vec(), PdfString::literal(b"/ZaDb 0 Tf 0 g".to_vec())); + } + NewField::Radio { export, .. } => { + w.set(b"AS".to_vec(), Object::name("Off")); + if export.trim().is_empty() || export == "Off" { + return invalid("a radio button needs an export value other than Off"); + } + } + NewField::Combo { options, editable } | NewField::List { options, multi: editable } => { + w.set(b"FT".to_vec(), Object::name("Ch")); + w.set(b"DA".to_vec(), PdfString::literal(b"/Helv 12 Tf 0 g".to_vec())); + let combo = matches!(kind, NewField::Combo { .. }); + let mut ff = 0; + if combo { + ff |= flags::COMBO; + if *editable { + ff |= flags::EDIT; + } + } else if *editable { + ff |= flags::MULTI_SELECT; + } + w.set(b"Ff".to_vec(), Object::Int(ff as i64)); + w.set(b"Opt".to_vec(), Object::Array(options.iter().map(|o| Object::String(PdfString::text(o))).collect())); + } + NewField::Button { caption } => { + w.set(b"FT".to_vec(), Object::name("Btn")); + w.set(b"Ff".to_vec(), Object::Int(flags::PUSH_BUTTON as i64)); + w.set(b"DA".to_vec(), PdfString::literal(b"/Helv 0 Tf 0 g".to_vec())); + let mut mk = w.get(b"MK").and_then(|m| m.as_dict()).cloned().unwrap_or_default(); + mk.set(b"BG".to_vec(), rgb([0.86, 0.86, 0.86])); + mk.set(b"CA".to_vec(), PdfString::text(caption)); + w.set(b"MK".to_vec(), Object::Dict(mk)); + } + NewField::Image => { + w.set(b"FT".to_vec(), Object::name("Btn")); + w.set(b"Ff".to_vec(), Object::Int(flags::PUSH_BUTTON as i64)); + w.set(b"DA".to_vec(), PdfString::literal(b"/Helv 0 Tf 0 g".to_vec())); + let mut mk = w.get(b"MK").and_then(|m| m.as_dict()).cloned().unwrap_or_default(); + // Icon only, no border or fill: the picture is the field. + mk.remove(b"BC"); + mk.remove(b"BG"); + mk.set(b"TP".to_vec(), Object::Int(1)); + w.set(b"MK".to_vec(), Object::Dict(mk)); + let mut a = Dict::new(); + a.set(b"S".to_vec(), Object::name("JavaScript")); + a.set(b"JS".to_vec(), PdfString::literal(b"event.target.buttonImportIcon();".to_vec())); + w.set(b"A".to_vec(), Object::Dict(a)); + } + NewField::Signature => { + w.set(b"FT".to_vec(), Object::name("Sig")); + } + } + let widget = doc.add(Object::Dict(w)); + match kind { + NewField::Radio { export, .. } => { + let group = match &radio_group { + Some(g) => g.obj, + None => { + let mut g = Dict::new(); + g.set(b"FT".to_vec(), Object::name("Btn")); + g.set(b"Ff".to_vec(), Object::Int((flags::RADIO | flags::NO_TOGGLE_TO_OFF) as i64)); + g.set(b"V".to_vec(), Object::name("Off")); + g.set(b"DA".to_vec(), PdfString::literal(b"/ZaDb 0 Tf 0 g".to_vec())); + g.set(b"Kids".to_vec(), Object::Array(Vec::new())); + let g = doc.add(Object::Dict(g)); + attach(doc, af, g, &parts)?; + g + } + }; + doc.update_dict(widget, |d| d.set(b"Parent".to_vec(), Object::Ref(group)))?; + doc.update_dict(group, |d| { + let mut kids = d.get(b"Kids").and_then(|k| k.as_array().cloned()).unwrap_or_default(); + kids.push(Object::Ref(widget)); + d.set(b"Kids".to_vec(), Object::Array(kids)); + })?; + let w = Widget { + obj: widget, + page: Some(page), + rect, + on_state: Some(export.clone()), + state: Some("Off".into()), + tab: usize::MAX, + locked: false, + hidden: false, + read_only: false, + rotation: 0, + caption: None, + list: None, + round: true, + }; + let ap = appearance::check_box_states(doc, &w, FieldKind::Radio, export, "/ZaDb 0 Tf 0 g"); + doc.update_dict(widget, |d| d.set(b"AP".to_vec(), Object::Dict(ap)))?; + } + _ => attach(doc, af, widget, &parts)?, + } + add_to_page(doc, page_ref, widget)?; + redraw_field(doc, &field_name)?; + Ok(field_name) +} + +/// Delete a field: its widgets leave their pages and the field leaves the form. +pub fn delete_field(doc: &mut Document, name: &str) -> Result<(), FormError> { + let f = fields(doc).into_iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?; + delete_found_field(doc, f) +} + +/// [`delete_field`] for the field object `obj`, when separate fields share one name. +pub fn delete_field_object(doc: &mut Document, obj: ObjRef) -> Result<(), FormError> { + let f = fields(doc).into_iter().find(|f| f.obj == obj).ok_or_else(|| FormError::NoSuchField(format!("{obj:?}")))?; + delete_found_field(doc, f) +} + +fn delete_found_field(doc: &mut Document, f: Field) -> Result<(), FormError> { + let widgets: std::collections::HashSet = f.widgets.iter().map(|w| w.obj).collect(); + for p in page_refs(doc) { + let Some(a) = doc.get(p).as_dict().and_then(|d| d.get(b"Annots").cloned()) else { continue }; + let list = doc.resolve(&a).as_array().cloned().unwrap_or_default(); + let kept: Vec = list.iter().filter(|o| !o.as_ref().is_some_and(|r| widgets.contains(&r))).cloned().collect(); + if kept.len() != list.len() { + match a.as_ref() { + Some(r) if doc.get(r).as_array().is_some() => doc.set(r, Object::Array(kept)), + _ => doc.update_dict(p, |d| d.set(b"Annots".to_vec(), Object::Array(kept)))?, + } + } + } + detach(doc, f.obj)?; + // Out of the calculation order too: a `/CO` entry would keep the field, its value and its + // widgets reachable for a full save. + drop_from_calculation_order(doc, |r| r == f.obj || widgets.contains(&r)) +} + +/// Remove the `/CO` entries `gone` matches, whether the form and the array are held directly or +/// by reference. +fn drop_from_calculation_order(doc: &mut Document, gone: impl Fn(ObjRef) -> bool) -> Result<(), FormError> { + let Some(root) = doc.root() else { return Ok(()) }; + let Some(af_obj) = doc.get(root).as_dict().and_then(|d| d.get(b"AcroForm").cloned()) else { return Ok(()) }; + let Some(mut af) = doc.resolve(&af_obj).as_dict().cloned() else { return Ok(()) }; + let Some(co_obj) = af.get(b"CO").cloned() else { return Ok(()) }; + let co = doc.resolve(&co_obj).as_array().cloned().unwrap_or_default(); + let kept: Vec = co.iter().filter(|o| !o.as_ref().is_some_and(&gone)).cloned().collect(); + if kept.len() == co.len() { + return Ok(()); + } + match (co_obj.as_ref(), af_obj.as_ref()) { + (Some(r), _) if doc.get(r).as_array().is_some() => doc.set(r, Object::Array(kept)), + (_, Some(af_ref)) => doc.update_dict(af_ref, |d| d.set(b"CO".to_vec(), Object::Array(kept)))?, + (_, None) => { + af.set(b"CO".to_vec(), Object::Array(kept)); + doc.update_dict(root, |d| d.set(b"AcroForm".to_vec(), Object::Dict(af)))?; + } + } + Ok(()) +} + +/// Widget-only keys: they stay with each widget when a merged field is split. +const WIDGET_KEYS: [&[u8]; 12] = [b"Type", b"Subtype", b"Rect", b"P", b"AP", b"AS", b"MK", b"F", b"BS", b"Border", b"H", b"StructParent"]; + +/// Duplicate a field onto other pages (Acrobat: right-click a field ▸ Duplicate): each page +/// gets a widget at the same place, belonging to the same field, so they share one value. +/// A field that is its own widget is first split into a field and a widget kid. Returns how +/// many widgets were added (pages that already have one are skipped). +pub fn duplicate_field(doc: &mut Document, name: &str, pages: &[usize]) -> Result { + let f = fields(doc).into_iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?; + let model = f.widgets.first().cloned().ok_or_else(|| FormError::Invalid(format!("{name} has no widget")))?; + let refs = page_refs(doc); + for p in pages { + if *p >= refs.len() { + return invalid(format!("page {} does not exist", p + 1)); + } + } + let have: std::collections::HashSet = f.widgets.iter().filter_map(|w| w.page).collect(); + let targets: Vec = pages.iter().copied().filter(|p| !have.contains(p)).collect(); + if targets.is_empty() { + return Ok(0); + } + // A merged field/widget becomes a field with one widget kid. + let mut model_ref = model.obj; + if model.obj == f.obj { + let d = doc.get(f.obj).as_dict().cloned().unwrap_or_default(); + let mut kid = Dict::new(); + for k in WIDGET_KEYS { + if let Some(v) = d.get(k) { + kid.set(k.to_vec(), v.clone()); + } + } + kid.set(b"Parent".to_vec(), Object::Ref(f.obj)); + let kr = doc.add(Object::Dict(kid)); + doc.update_dict(f.obj, |d| { + for k in WIDGET_KEYS { + d.remove(k); + } + d.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(kr)])); + })?; + // The page lists the widget now, not the field. + if let Some(p) = model.page { + let pr = refs[p]; + let annots = doc.get(pr).as_dict().and_then(|d| d.get(b"Annots").cloned()); + let swap = + |a: Vec| -> Vec { a.into_iter().map(|o| if o.as_ref() == Some(f.obj) { Object::Ref(kr) } else { o }).collect() }; + match annots { + Some(Object::Ref(ar)) => { + let a = doc.get(ar).as_array().cloned().unwrap_or_default(); + doc.set(ar, Object::Array(swap(a))); + } + Some(Object::Array(a)) => doc.update_dict(pr, |d| d.set(b"Annots".to_vec(), Object::Array(swap(a))))?, + _ => {} + } + } + model_ref = kr; + } + let template = doc.get(model_ref).as_dict().cloned().unwrap_or_default(); + let mut added = Vec::new(); + for p in &targets { + let pr = refs[*p]; + let mut w = template.clone(); + w.set(b"P".to_vec(), Object::Ref(pr)); + w.set(b"Parent".to_vec(), Object::Ref(f.obj)); + w.remove(b"StructParent"); + let wr = doc.add(Object::Dict(w)); + let annots = doc.get(pr).as_dict().and_then(|d| d.get(b"Annots").cloned()); + match annots { + Some(Object::Ref(ar)) => { + let mut a = doc.get(ar).as_array().cloned().unwrap_or_default(); + a.push(Object::Ref(wr)); + doc.set(ar, Object::Array(a)); + } + other => { + let mut a = other.and_then(|o| o.as_array().cloned()).unwrap_or_default(); + a.push(Object::Ref(wr)); + doc.update_dict(pr, |d| d.set(b"Annots".to_vec(), Object::Array(a)))?; + } + } + added.push(wr); + } + doc.update_dict(f.obj, |d| { + let mut kids = d.get(b"Kids").and_then(|k| k.as_array().cloned()).unwrap_or_default(); + kids.extend(added.iter().map(|r| Object::Ref(*r))); + d.set(b"Kids".to_vec(), Object::Array(kids)); + })?; + redraw_field(doc, name)?; + Ok(added.len()) +} diff --git a/crates/forms/src/author/look.rs b/crates/forms/src/author/look.rs new file mode 100644 index 0000000000..23661ed70a --- /dev/null +++ b/crates/forms/src/author/look.rs @@ -0,0 +1,473 @@ +use super::{Dict, Document, Field, FieldKind, FormError, ObjRef, Object, Stream, Widget, appearance, fields, invalid, rgb}; + +/// The Appearance tab: borders, colours and text. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Look { + pub border: Option<[f64; 3]>, + pub fill: Option<[f64; 3]>, + /// Line thickness in points (Acrobat: thin 1, medium 2, thick 3). + pub width: f64, + pub style: BorderStyle, + pub text: [f64; 3], + pub font: FieldFont, +} + +/// A partial Appearance-tab edit. Outer `None` keeps a property; `Some(None)` removes a +/// border or fill colour. The patch is applied to each widget's own dictionaries. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct LookPatch { + pub border: Option>, + pub fill: Option>, + pub width: Option, + pub style: Option, + pub text: Option<[f64; 3]>, + pub font: Option, +} + +impl LookPatch { + pub(super) fn validate(&self) -> Result<(), FormError> { + for c in self.border.flatten().into_iter().chain(self.fill.flatten()).chain(self.text) { + if !c.iter().all(|v| v.is_finite() && (0.0..=1.0).contains(v)) { + return invalid("appearance colours must be finite RGB values between 0 and 1"); + } + } + if self.width.is_some_and(|v| !v.is_finite() || !(0.0..=12.0).contains(&v)) { + return invalid("border width must be between 0 and 12 points"); + } + Ok(()) + } +} + +/// Rewrite only the selected operators in place: the last `Tf` (font and size) and the last +/// fill colour (`g`, `rg` or `k`), appending one when the string has none. Everything else (a +/// custom font resource, colour space or other graphics state) is kept, and repeated edits don't +/// grow the string. +pub(super) fn patch_da(da: &str, size: Option, patch: &LookPatch) -> String { + if patch.font.is_none() && size.is_none() && patch.text.is_none() { + return da.to_string(); + } + let mut toks: Vec = da.split_whitespace().map(str::to_string).collect(); + let numeric = |s: &str| s.parse::().is_ok(); + if patch.font.is_some() || size.is_some() { + let old = appearance::parse_da(da); + let font = patch.font.map_or_else(|| old.font.clone(), |f| f.resource().to_string()); + // `parse_da` reads sizes up to 300 points; keep the same bound when writing. + let size = appearance::fmt(size.unwrap_or(old.size).clamp(0.0, 300.0)); + let at = toks.iter().enumerate().rev().find(|(i, t)| t.as_str() == "Tf" && *i >= 2).map(|(i, _)| i); + match at { + Some(i) => { + if let Some(slot) = toks.get_mut(i - 2) { + *slot = format!("/{font}"); + } + if let Some(slot) = toks.get_mut(i - 1) { + *slot = size; + } + } + None => toks.extend([format!("/{font}"), size, "Tf".into()]), + } + } + if let Some(c) = patch.text { + let rgb = [appearance::fmt(c[0]), appearance::fmt(c[1]), appearance::fmt(c[2]), "rg".into()]; + let at = toks.iter().enumerate().rev().find_map(|(i, t)| { + let n = match t.as_str() { + "g" => 1, + "rg" => 3, + "k" => 4, + _ => return None, + }; + let start = i.checked_sub(n)?; + toks.get(start..i).is_some_and(|ops| ops.iter().all(|o| numeric(o))).then_some((start, i)) + }); + match at { + Some((start, end)) => { + toks.splice(start..=end, rgb); + } + None => toks.extend(rgb), + } + } + toks.join(" ") +} + +pub(super) fn patch_widget_look(doc: &mut Document, w: &Widget, patch: &LookPatch) -> Result<(), FormError> { + let touches_mk = patch.border.is_some() || patch.fill.is_some(); + let touches_bs = patch.width.is_some() || patch.style.is_some(); + // A size- or font-only edit changes /DA, not the widget: leave it out of the save. + if !touches_mk && !touches_bs { + return Ok(()); + } + let wd = doc.get(w.obj).as_dict().cloned().ok_or_else(|| FormError::Invalid("the widget is not a dictionary".into()))?; + if touches_mk { + let indirect = wd.get(b"MK").and_then(Object::as_ref); + let mut mk = wd.get(b"MK").map(|m| doc.resolve(m)).and_then(|m| m.as_dict().cloned()).unwrap_or_default(); + for (key, change) in [(b"BC".as_slice(), patch.border), (b"BG".as_slice(), patch.fill)] { + if let Some(change) = change { + match change { + Some(c) => mk.set(key.to_vec(), rgb(c)), + None => { + mk.remove(key); + } + } + } + } + // A shared indirect /MK is updated where it lives, not copied into each widget. + match indirect { + Some(r) => doc.set(r, Object::Dict(mk)), + None => doc.update_dict(w.obj, |d| d.set(b"MK".to_vec(), Object::Dict(mk)))?, + } + } + if touches_bs { + let indirect = wd.get(b"BS").and_then(Object::as_ref); + let mut bs = wd.get(b"BS").map(|b| doc.resolve(b)).and_then(|b| b.as_dict().cloned()).unwrap_or_default(); + if let Some(width) = patch.width { + bs.set(b"W".to_vec(), Object::Real(width)); + } + if let Some(style) = patch.style { + bs.set(b"S".to_vec(), Object::name(style.code())); + if style == BorderStyle::Dashed && !bs.contains(b"D") { + bs.set(b"D".to_vec(), Object::Array(vec![Object::Int(3)])); + } + } + match indirect { + Some(r) => doc.set(r, Object::Dict(bs)), + None => doc.update_dict(w.obj, |d| d.set(b"BS".to_vec(), Object::Dict(bs)))?, + } + } + Ok(()) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum BorderStyle { + Solid, + Dashed, + Beveled, + Inset, + Underline, +} + +impl BorderStyle { + pub const ALL: [BorderStyle; 5] = [BorderStyle::Solid, BorderStyle::Dashed, BorderStyle::Beveled, BorderStyle::Inset, BorderStyle::Underline]; + + pub(super) fn code(self) -> &'static str { + match self { + BorderStyle::Solid => "S", + BorderStyle::Dashed => "D", + BorderStyle::Beveled => "B", + BorderStyle::Inset => "I", + BorderStyle::Underline => "U", + } + } + + pub fn label(self) -> &'static str { + match self { + BorderStyle::Solid => "Solid", + BorderStyle::Dashed => "Dashed", + BorderStyle::Beveled => "Beveled", + BorderStyle::Inset => "Inset", + BorderStyle::Underline => "Underline", + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum FieldFont { + Helvetica, + Times, + Courier, +} + +impl FieldFont { + pub const ALL: [FieldFont; 3] = [FieldFont::Helvetica, FieldFont::Times, FieldFont::Courier]; + + fn resource(self) -> &'static str { + match self { + FieldFont::Helvetica => "Helv", + FieldFont::Times => "TiRo", + FieldFont::Courier => "Cour", + } + } + + pub fn label(self) -> &'static str { + match self { + FieldFont::Helvetica => "Helvetica", + FieldFont::Times => "Times Roman", + FieldFont::Courier => "Courier", + } + } +} + +/// The mark a check box or radio button shows when on (Field Properties ▸ Options ▸ style). +/// Stored as `/MK /CA`, the ZapfDingbats character Acrobat uses for it; PdfCraft draws the +/// mark as paths, so no symbol font is needed. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CheckStyle { + Check, + Circle, + Cross, + Diamond, + Square, + Star, +} + +impl CheckStyle { + pub const ALL: [CheckStyle; 6] = + [CheckStyle::Check, CheckStyle::Circle, CheckStyle::Cross, CheckStyle::Diamond, CheckStyle::Square, CheckStyle::Star]; + + pub(super) fn code(self) -> &'static str { + match self { + CheckStyle::Check => "4", + CheckStyle::Circle => "l", + CheckStyle::Cross => "8", + CheckStyle::Diamond => "u", + CheckStyle::Square => "n", + CheckStyle::Star => "H", + } + } + + pub fn label(self) -> &'static str { + match self { + CheckStyle::Check => "Check", + CheckStyle::Circle => "Circle", + CheckStyle::Cross => "Cross", + CheckStyle::Diamond => "Diamond", + CheckStyle::Square => "Square", + CheckStyle::Star => "Star", + } + } + + /// A widget's style: its `/MK /CA`, else a check for check boxes and a circle for radios. + pub(crate) fn of_widget(doc: &Document, wd: &Dict, kind: FieldKind) -> Self { + let mk = wd.get(b"MK").and_then(|m| doc.resolve(m).as_dict().cloned()).unwrap_or_default(); + let ca = mk.get(b"CA").and_then(|c| doc.resolve(c).as_string().map(|s| s.to_text())); + CheckStyle::ALL.into_iter().find(|s| ca.as_deref() == Some(s.code())).unwrap_or(if kind == FieldKind::Radio { + CheckStyle::Circle + } else { + CheckStyle::Check + }) + } +} + +/// A check box's or radio button's style (from its first widget). +pub fn check_style(doc: &Document, f: &Field) -> CheckStyle { + let wd = f.widgets.first().and_then(|w| doc.get(w.obj).as_dict().cloned()).unwrap_or_default(); + CheckStyle::of_widget(doc, &wd, f.kind) +} + +fn rgb_of(doc: &Document, o: Option<&Object>) -> Option<[f64; 3]> { + let v: Vec = doc.resolve(o?).as_array()?.iter().filter_map(Object::as_f64).collect(); + match v.len() { + 1 => Some([v[0]; 3]), + 3 => Some([v[0], v[1], v[2]]), + 4 => Some([(1.0 - v[0]) * (1.0 - v[3]), (1.0 - v[1]) * (1.0 - v[3]), (1.0 - v[2]) * (1.0 - v[3])]), + _ => None, + } +} + +/// A field's current look (from its first widget and its default appearance). +pub fn look(doc: &Document, f: &Field) -> Look { + let wd = f.widgets.first().and_then(|w| doc.get(w.obj).as_dict().cloned()).unwrap_or_default(); + let mk = wd.get(b"MK").and_then(|m| doc.resolve(m).as_dict().cloned()).unwrap_or_default(); + let bs = wd.get(b"BS").and_then(|b| doc.resolve(b).as_dict().cloned()).unwrap_or_default(); + let da = wd.get(b"DA").and_then(|o| doc.resolve(o).as_string().map(|s| s.to_text())).unwrap_or_else(|| f.da.clone()); + let parsed = appearance::parse_da(&da); + let nums: Vec = parsed.color.split_whitespace().filter_map(|t| t.parse().ok()).collect(); + let text = match nums.len() { + 1 => [nums[0]; 3], + 3 => [nums[0], nums[1], nums[2]], + 4 => [(1.0 - nums[0]) * (1.0 - nums[3]), (1.0 - nums[1]) * (1.0 - nums[3]), (1.0 - nums[2]) * (1.0 - nums[3])], + _ => [0.0; 3], + }; + Look { + border: rgb_of(doc, mk.get(b"BC")), + fill: rgb_of(doc, mk.get(b"BG")), + width: bs.get(b"W").and_then(|w| doc.resolve(w).as_f64()).unwrap_or(1.0), + style: match bs.name(b"S") { + Some(b"D") => BorderStyle::Dashed, + Some(b"B") => BorderStyle::Beveled, + Some(b"I") => BorderStyle::Inset, + Some(b"U") => BorderStyle::Underline, + _ => BorderStyle::Solid, + }, + text, + font: match parsed.font.as_str() { + "TiRo" | "Times-Roman" => FieldFont::Times, + "Cour" | "Courier" => FieldFont::Courier, + _ => FieldFont::Helvetica, + }, + } +} + +/// A `/DA` string with the given font, size and colour. +pub(super) fn da_string(font: FieldFont, size: f64, c: [f64; 3]) -> String { + let f = crate::appearance::fmt; + format!("/{} {} Tf {} {} {} rg", font.resource(), f(size.clamp(0.0, 100.0)), f(c[0]), f(c[1]), f(c[2])) +} + +/// Regenerate every widget appearance of a field from its current value and settings. A value or +/// button caption its font can't hold is drawn with `?` for those characters, the widget is marked +/// (`pdfcraft_fonts::UNDRAWN_MARK`) and other viewers are asked to redraw the form (see +/// `ask_viewers_to_redraw`), as when a value is set. +pub fn redraw_field(doc: &mut Document, name: &str) -> Result<(), FormError> { + redraw_widgets(doc, name, None) +} + +/// `redraw_field` for the widget at index `only` alone (so a caption-only edit leaves the field's +/// other widgets' appearances as they are), or for every widget when `only` is `None`. Fails with +/// `FormError::NoSuchField` when no field is named `name`. +pub(super) fn redraw_widgets(doc: &mut Document, name: &str, only: Option) -> Result<(), FormError> { + let f = fields(doc).into_iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?; + let mut undrawn = false; + for (index, w) in f.widgets.iter().enumerate() { + if only.is_some_and(|only| only != index) { + continue; + } + let mut missing = None; + let ap = match f.kind { + FieldKind::Text | FieldKind::Combo | FieldKind::List => { + let (s, m) = appearance::field_appearance_drawn(doc, &f, w, &f.value, true); + missing = m; + let r = doc.add(Object::Stream(s)); + let mut d = Dict::new(); + d.set(b"N".to_vec(), Object::Ref(r)); + d + } + FieldKind::CheckBox | FieldKind::Radio => { + let on = w.on_state.clone().unwrap_or_else(|| "Yes".into()); + appearance::check_box_states(doc, w, f.kind, &on, &f.da) + } + FieldKind::PushButton => { + let (s, m) = button_appearance(doc, w, &f.da); + missing = m; + let r = doc.add(Object::Stream(s)); + let mut d = Dict::new(); + d.set(b"N".to_vec(), Object::Ref(r)); + d + } + FieldKind::Signature => { + let s = empty_box(doc, w); + let r = doc.add(Object::Stream(s)); + let mut d = Dict::new(); + d.set(b"N".to_vec(), Object::Ref(r)); + d + } + }; + undrawn |= missing.is_some(); + let existing = crate::merged_appearance(doc, w.obj, &ap); + doc.update_dict(w.obj, |d| { + d.set(b"AP".to_vec(), Object::Dict(existing)); + pdfcraft_fonts::set_undrawn_mark(d, missing); + })?; + } + if undrawn { + crate::ask_viewers_to_redraw(doc)?; + } + Ok(()) +} + +fn frame_only(doc: &Document, w: &Widget) -> (String, crate::appearance::Placement) { + let (page_w, page_h) = ((w.rect[2] - w.rect[0]).max(1.0), (w.rect[3] - w.rect[1]).max(1.0)); + let wobj = doc.get(w.obj); + let wd = wobj.as_dict().cloned().unwrap_or_default(); + let place = crate::appearance::placement(crate::appearance::mk_rotation(doc, &wd), page_w, page_h); + let (width, height) = (place.layout_w, place.layout_h); + let mk = wd.get(b"MK").and_then(|m| m.as_dict()).cloned().unwrap_or_default(); + let col = |k: &[u8]| -> Option { + let v: Vec = mk.get(k)?.as_array()?.iter().filter_map(|x| x.as_f64()).collect(); + (v.len() == 3).then(|| format!("{} {} {}", crate::appearance::fmt(v[0]), crate::appearance::fmt(v[1]), crate::appearance::fmt(v[2]))) + }; + let mut c = String::new(); + if let Some(bg) = col(b"BG") { + c.push_str(&format!("{bg} rg 0 0 {width:.3} {height:.3} re f\n")); + } + if let Some(bc) = col(b"BC") { + c.push_str(&format!("{bc} RG 1 w 0.5 0.5 {:.3} {:.3} re S\n", width - 1.0, height - 1.0)); + } + (c, place) +} + +fn form_stream(place: crate::appearance::Placement, content: Vec, resources: Dict) -> Stream { + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("XObject")); + d.set(b"Subtype".to_vec(), Object::name("Form")); + crate::appearance::set_form_box(&mut d, place); + d.set(b"Resources".to_vec(), Object::Dict(resources)); + Stream::flate(d, &content) +} + +/// A push button: background, border, its icon (`/MK /I`, scaled to fit and centred) and its +/// centred caption (unless the layout is icon only, `/TP 1`). The caption uses the font, size and +/// color its `/DA` asks for (the widget's, else `field_da`, the field's own or inherited one). +/// Also returns the first caption character the font can't draw (drawn as `?`), for the caller to +/// mark and report. +pub(crate) fn button_appearance(doc: &Document, w: &Widget, field_da: &str) -> (Stream, Option) { + let (mut c, place) = frame_only(doc, w); + let (width, height) = (place.layout_w, place.layout_h); + let wobj = doc.get(w.obj); + let mk = wobj.as_dict().and_then(|d| d.get(b"MK")).map(|m| doc.resolve(m)).and_then(|m| m.as_dict().cloned()).unwrap_or_default(); + let caption = mk.get(b"CA").map(|c| doc.resolve(c)).and_then(|c| c.as_string().map(|s| s.to_text())).unwrap_or_default(); + let icon_only = mk.get(b"TP").and_then(|t| doc.resolve(t).as_int()) == Some(1); + let mut content = std::mem::take(&mut c).into_bytes(); + let mut xobjects = Dict::new(); + if let Some(icon) = mk.get(b"I").and_then(Object::as_ref) + && let Object::Stream(s) = &*doc.get(icon) + { + let bb: Vec = s.dict.get(b"BBox").and_then(|b| b.as_array().map(|a| a.iter().filter_map(Object::as_f64).collect())).unwrap_or_default(); + if let [x0, y0, x1, y1] = bb[..] + && x1 > x0 + && y1 > y0 + { + // Proportional, as large as fits (Acrobat's default icon placement), centred. + let k = ((width - 2.0) / (x1 - x0)).min((height - 2.0) / (y1 - y0)).max(0.0); + let (dx, dy) = ((width - (x1 - x0) * k) / 2.0 - x0 * k, (height - (y1 - y0) * k) / 2.0 - y0 * k); + content.extend(format!("q {k:.6} 0 0 {k:.6} {dx:.3} {dy:.3} cm /Icon Do Q\n").bytes()); + xobjects.set(b"Icon".to_vec(), Object::Ref(icon)); + } + } + let mut missing = None; + let mut fonts = Dict::new(); + if !icon_only && !caption.is_empty() { + let da = wobj.as_dict().and_then(|d| d.get(b"DA")).and_then(|o| doc.resolve(o).as_string().map(|s| s.to_text())); + let auto = ((height - 4.0) * 0.6).clamp(4.0, 14.0); + let (ops, name, font, m) = appearance::button_caption(doc, da.as_deref().unwrap_or(field_da), &caption, width, height, auto); + content.extend(ops); + fonts.set(name.into_bytes(), font); + missing = m; + } + let mut res = Dict::new(); + res.set(b"Font".to_vec(), Object::Dict(fonts)); + if !xobjects.is_empty() { + res.set(b"XObject".to_vec(), Object::Dict(xobjects)); + } + (form_stream(place, content, res), missing) +} + +/// Give a push button (an image field) the picture `image` (an image XObject of `px` pixels): +/// it becomes the button's icon (`/MK /I`) and its appearance. +pub fn set_button_icon(doc: &mut Document, name: &str, image: ObjRef, px: (u32, u32)) -> Result<(), FormError> { + let f = fields(doc).into_iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?; + if f.kind != FieldKind::PushButton { + return invalid(format!("{name} is not a button or image field")); + } + // The icon form draws the image in a box of its pixel size (keeping its aspect ratio). + let (w, h) = (px.0.max(1) as f64, px.1.max(1) as f64); + let mut xo = Dict::new(); + xo.set(b"Im0".to_vec(), Object::Ref(image)); + let mut res = Dict::new(); + res.set(b"XObject".to_vec(), Object::Dict(xo)); + let icon = + doc.add(Object::Stream(form_stream(crate::appearance::placement(0, w, h), format!("q {w} 0 0 {h} 0 0 cm /Im0 Do Q").into_bytes(), res))); + for wd in &f.widgets { + let mut mk = + doc.get(wd.obj).as_dict().and_then(|d| d.get(b"MK").map(|m| doc.resolve(m))).and_then(|m| m.as_dict().cloned()).unwrap_or_default(); + mk.set(b"I".to_vec(), Object::Ref(icon)); + if !mk.contains(b"TP") { + mk.set(b"TP".to_vec(), Object::Int(1)); + } + doc.update_dict(wd.obj, |d| d.set(b"MK".to_vec(), Object::Dict(mk)))?; + } + redraw_field(doc, name) +} + +fn empty_box(doc: &Document, w: &Widget) -> Stream { + let (c, place) = frame_only(doc, w); + form_stream(place, c.into_bytes(), Dict::new()) +} diff --git a/crates/forms/src/author/tree.rs b/crates/forms/src/author/tree.rs new file mode 100644 index 0000000000..d8ca67ffb7 --- /dev/null +++ b/crates/forms/src/author/tree.rs @@ -0,0 +1,380 @@ +use super::{Dict, Document, Field, FormError, ObjRef, Object, PdfString, invalid}; + +/// The AcroForm dictionary's reference, creating the form (with `/Helv` in `/DR`) if needed. +pub(crate) fn ensure_form(doc: &mut Document) -> Result { + let root = doc.root().ok_or(FormError::NoForm)?; + let existing = doc.get(root).as_dict().and_then(|d| d.get(b"AcroForm").cloned()); + let r = match existing { + Some(Object::Ref(r)) if doc.get(r).as_dict().is_some() => r, + other => { + let d = other.and_then(|o| o.as_dict().cloned()).unwrap_or_default(); + let r = doc.add(Object::Dict(d)); + doc.update_dict(root, |c| c.set(b"AcroForm".to_vec(), Object::Ref(r)))?; + r + } + }; + let mut af = doc.get(r).as_dict().cloned().unwrap_or_default(); + if !af.contains(b"Fields") { + af.set(b"Fields".to_vec(), Object::Array(Vec::new())); + } + if !af.contains(b"DA") { + af.set(b"DA".to_vec(), PdfString::literal(b"/Helv 0 Tf 0 g".to_vec())); + } + let (dr, mut fonts) = form_fonts(doc, &af); + let mut added = inline_fonts_as_objects(doc, &mut fonts); + // Acrobat's resource names for the standard fonts its Appearance tab offers. + for (name, base) in [("Helv", "Helvetica"), ("TiRo", "Times-Roman"), ("Cour", "Courier")] { + if fonts.contains(name.as_bytes()) { + continue; + } + let mut f = Dict::new(); + f.set(b"Type".to_vec(), Object::name("Font")); + f.set(b"Subtype".to_vec(), Object::name("Type1")); + f.set(b"BaseFont".to_vec(), Object::name(base)); + f.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding")); + let r = doc.add(Object::Dict(f)); + fonts.set(name.as_bytes().to_vec(), Object::Ref(r)); + added = true; + } + if added { + store_form_fonts(doc, &mut af, dr, fonts); + } + doc.set(r, Object::Dict(af)); + Ok(r) +} + +/// The form dictionary's `/DR` and its `/Font` dictionary, resolved (empty where missing). +fn form_fonts(doc: &Document, af: &Dict) -> (Dict, Dict) { + let dr = af.get(b"DR").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default(); + let fonts = dr.get(b"Font").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default(); + (dr, fonts) +} + +/// Each /DR font its own object: Acrobat, redrawing a text field after it is typed in, shows +/// nothing when the font is a dictionary written inline in /DR (#3441). Fonts written inline +/// (by an earlier save, or another writer) become objects saying the same; a font that is an +/// object already, or an entry that isn't a dictionary, is left alone. Whether any changed. +fn inline_fonts_as_objects(doc: &mut Document, fonts: &mut Dict) -> bool { + let inline: Vec<(Vec, Dict)> = fonts.iter().filter_map(|(k, v)| Some((k.clone(), v.as_dict()?.clone()))).collect(); + let changed = !inline.is_empty(); + for (name, font) in inline { + let r = doc.add(Object::Dict(font)); + fonts.set(name, Object::Ref(r)); + } + changed +} + +/// Write `fonts` back as the `/DR /Font` of the form dictionary `af`. An indirect `/DR` or +/// `/Font` stays the object it is. +fn store_form_fonts(doc: &mut Document, af: &mut Dict, mut dr: Dict, fonts: Dict) { + match dr.get(b"Font").and_then(Object::as_ref) { + Some(fr) => doc.set(fr, Object::Dict(fonts)), + None => dr.set(b"Font".to_vec(), Object::Dict(fonts)), + } + match af.get(b"DR").and_then(Object::as_ref) { + Some(dr_ref) => doc.set(dr_ref, Object::Dict(dr)), + None => af.set(b"DR".to_vec(), Object::Dict(dr)), + } +} + +/// Filling a field: the form's inline `/DR` fonts become objects (see +/// [`inline_fonts_as_objects`]), so a form an earlier version of PdfCraft made works in Acrobat +/// once it has been filled here too (#3441). Only the font storage changes; nothing is added. +/// Not in a form a signature covers: there the file stays as the signer saw it, apart from the +/// value itself. +pub(crate) fn form_fonts_as_objects(doc: &mut Document, all: &[crate::Field]) -> Result<(), FormError> { + let signed = all.iter().any(|f| f.kind == crate::FieldKind::Signature && holds_value(doc, f.obj)); + if signed { + return Ok(()); + } + let Some(root) = doc.root() else { return Ok(()) }; + let Some(entry) = doc.get(root).as_dict().and_then(|c| c.get(b"AcroForm").cloned()) else { return Ok(()) }; + match entry { + Object::Ref(r) => { + let Some(mut af) = doc.get(r).as_dict().cloned() else { return Ok(()) }; + let (dr, mut fonts) = form_fonts(doc, &af); + if inline_fonts_as_objects(doc, &mut fonts) { + store_form_fonts(doc, &mut af, dr, fonts); + doc.set(r, Object::Dict(af)); + } + } + // A form dictionary written directly in the catalog (the catalog's /AcroForm need not be + // an indirect reference) is written back there. + Object::Dict(mut af) => { + let (dr, mut fonts) = form_fonts(doc, &af); + if inline_fonts_as_objects(doc, &mut fonts) { + store_form_fonts(doc, &mut af, dr, fonts); + doc.update_dict(root, |c| c.set(b"AcroForm".to_vec(), Object::Dict(af)))?; + } + } + _ => {} + } + Ok(()) +} + +/// Whether the field `r`, or the nearest ancestor that has one, holds a `/V` (the entry is +/// inheritable, ISO 32000-2 §12.7.4). The `/Parent` chain is followed at most 64 deep, as deep +/// as the field walk goes, so a cyclic chain ends. +fn holds_value(doc: &Document, r: ObjRef) -> bool { + let mut at = r; + for _ in 0..=64 { + let obj = doc.get(at); + let Some(d) = obj.as_dict() else { return false }; + if d.contains(b"V") { + return true; + } + match d.reference(b"Parent") { + Some(p) => at = p, + None => return false, + } + } + false +} + +pub(super) fn add_to_page(doc: &mut Document, page: ObjRef, widget: ObjRef) -> Result<(), FormError> { + let existing = doc.get(page).as_dict().and_then(|d| d.get(b"Annots").cloned()); + match existing.as_ref().and_then(|o| o.as_ref()).filter(|r| doc.get(*r).as_array().is_some()) { + Some(r) => { + let mut a = doc.get(r).as_array().cloned().unwrap_or_default(); + a.push(Object::Ref(widget)); + doc.set(r, Object::Array(a)); + } + None => { + let mut a = existing.and_then(|o| o.as_array().cloned()).unwrap_or_default(); + a.push(Object::Ref(widget)); + doc.update_dict(page, |d| d.set(b"Annots".to_vec(), Object::Array(a)))?; + } + } + Ok(()) +} + +fn add_to_fields(doc: &mut Document, af: ObjRef, field: ObjRef) -> Result<(), FormError> { + let mut d = doc.get(af).as_dict().cloned().unwrap_or_default(); + let mut list = d.get(b"Fields").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default(); + list.push(Object::Ref(field)); + d.set(b"Fields".to_vec(), Object::Array(list)); + doc.set(af, Object::Dict(d)); + Ok(()) +} + +/// The most parts a dotted field name may have ("a.b.c" has three). +const MAX_NAME_PARTS: usize = 32; + +/// The field attributes a field inherits from its parents (ISO 32000-2 §12.7.4.1). +const INHERITED_KEYS: [&[u8]; 7] = [b"FT", b"Ff", b"V", b"DV", b"DA", b"Q", b"MaxLen"]; + +/// The parts of a full field name: `a.b` is the field `b` under the parent field `a`, since a +/// field's own (partial) name can't contain a period (ISO 32000-2 §12.7.4.2) (#2696). +pub(super) fn name_parts(name: &str) -> Result, FormError> { + let parts: Vec<&str> = name.split('.').take(MAX_NAME_PARTS.saturating_add(1)).collect(); + if parts.len() > MAX_NAME_PARTS { + return invalid(format!("field names can have at most {MAX_NAME_PARTS} parts")); + } + if parts.iter().any(|p| p.trim().is_empty()) { + return invalid("field names can't be empty, or have an empty part between periods"); + } + Ok(parts) +} + +/// Why the full name `name` can't be used beside the field named `other`, if it can't: the +/// same name, or one inside the other (a field that has a value can't also hold fields). +pub(super) fn clash(name: &str, other: &str) -> Option { + if name == other { + return Some(format!("a field named {name:?} already exists")); + } + let inside = |a: &str, b: &str| a.strip_prefix(b).is_some_and(|rest| rest.starts_with('.')); + (inside(name, other) || inside(other, name)).then(|| format!("{name:?} can't be used beside the field {other:?}")) +} + +/// The array `key` of `holder` (`/Fields` of the form, `/Kids` of a field). +fn list_of(doc: &Document, holder: ObjRef, key: &[u8]) -> Vec { + let d = doc.get(holder).as_dict().cloned().unwrap_or_default(); + d.get(key).map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default() +} + +/// Add `field` to `parent`'s `/Kids`, or to the form's `/Fields` at the top. +fn link(doc: &mut Document, af: ObjRef, parent: Option, field: ObjRef) -> Result<(), FormError> { + let Some(p) = parent else { return add_to_fields(doc, af, field) }; + let mut kids = list_of(doc, p, b"Kids"); + kids.push(Object::Ref(field)); + doc.update_dict(p, |d| d.set(b"Kids".to_vec(), Object::Array(kids)))?; + Ok(()) +} + +/// The node named `name` (its own `/T`) directly under `parent`, or at the top of the form. +pub(super) fn child_named(doc: &Document, af: ObjRef, parent: Option, name: &str) -> Option { + let list = match parent { + Some(p) => list_of(doc, p, b"Kids"), + None => list_of(doc, af, b"Fields"), + }; + list.iter() + .filter_map(|o| o.as_ref()) + .find(|r| doc.get(*r).as_dict().and_then(|d| d.get(b"T").cloned()).and_then(|t| crate::text_of(&doc.resolve(&t))).as_deref() == Some(name)) +} + +/// The values a parent field passes to the fields under it that can't be turned off by giving +/// the field a neutral one of its own, as `/Ff` and `/Q` can. +const PASSED_VALUES: [&[u8]; 3] = [b"V", b"DV", b"MaxLen"]; + +/// Whether `field` may go in the field tree under the full name `parts`, changing nothing. The +/// field list holds only terminal fields, so a parent left empty by a move isn't in it: a node +/// of the same name there is found here. +/// `inherited` is what the field will hold as its own before it moves (see [`inherited`]). +pub(super) fn check_place(doc: &Document, af: ObjRef, field: ObjRef, parts: &[&str], inherited: &Dict) -> Result<(), FormError> { + let Some((own, parents)) = parts.split_last() else { return invalid("field names can't be empty") }; + let mut owned = doc.get(field).as_dict().cloned().unwrap_or_default(); + for (k, v) in inherited.iter() { + owned.set(k.to_vec(), v.clone()); + } + let mut at: Option = None; + let mut whole_path = true; + for part in parents { + let Some(r) = child_named(doc, af, at, part) else { + whole_path = false; + break; + }; + // A value, reset value or length limit held by a parent would pass to this field. + let held = doc.get(r).as_dict().is_some_and(|d| PASSED_VALUES.iter().any(|k| d.contains(k) && !owned.contains(k))); + if held { + return invalid(format!("{part:?} holds a value for the fields under it, so {:?} can't be put there", parts.join("."))); + } + at = Some(r); + } + // A field being moved is still in the tree here, but under another full name (a move to its + // own name changes nothing and doesn't get here), so a node found is another. + if whole_path && child_named(doc, af, at, own).is_some() { + return invalid(format!("a field named {:?} already exists", parts.join("."))); + } + Ok(()) +} + +/// Put `field` in the field tree under the full name `parts`: its own `/T` is the last part, +/// and the parts before it name parent fields, reused where the tree has them and made where +/// not (Acrobat makes `a.b` a field `b` under a field `a`). +pub(super) fn attach(doc: &mut Document, af: ObjRef, field: ObjRef, parts: &[&str]) -> Result<(), FormError> { + let Some((own, parents)) = parts.split_last() else { return invalid("field names can't be empty") }; + check_place(doc, af, field, parts, &Dict::new())?; + let mut parent: Option = None; + let mut inherits = false; + for part in parents { + let next = match child_named(doc, af, parent, part) { + Some(r) => { + inherits |= doc.get(r).as_dict().is_some_and(|d| d.contains(b"Ff") || d.contains(b"Q")); + r + } + None => { + let mut d = Dict::new(); + d.set(b"T".to_vec(), PdfString::text(part)); + d.set(b"Kids".to_vec(), Object::Array(Vec::new())); + if let Some(p) = parent { + d.set(b"Parent".to_vec(), Object::Ref(p)); + } + let r = doc.add(Object::Dict(d)); + link(doc, af, parent, r)?; + r + } + }; + parent = Some(next); + } + doc.update_dict(field, |d| { + d.set(b"T".to_vec(), PdfString::text(own)); + match parent { + Some(p) => d.set(b"Parent".to_vec(), Object::Ref(p)), + None => { + d.remove(b"Parent"); + } + } + // An existing parent's flags and alignment would otherwise pass to this field. + if inherits { + for k in [&b"Ff"[..], b"Q"] { + if !d.contains(k) { + d.set(k.to_vec(), Object::Int(0)); + } + } + } + })?; + link(doc, af, parent, field) +} + +/// What `field` inherits from its parents and doesn't hold itself, changing nothing. +pub(super) fn inherited(doc: &Document, field: ObjRef) -> Dict { + let own = doc.get(field).as_dict().cloned().unwrap_or_default(); + let mut add = Dict::new(); + let mut seen = std::collections::HashSet::new(); + let mut parent = own.reference(b"Parent"); + while let Some(p) = parent { + if seen.len() > 64 || !seen.insert(p) { + break; + } + let Some(d) = doc.get(p).as_dict().cloned() else { break }; + for k in INHERITED_KEYS { + if let Some(v) = d.get(k).filter(|_| !own.contains(k) && !add.contains(k)) { + add.set(k.to_vec(), v.clone()); + } + } + parent = d.reference(b"Parent"); + } + add +} + +/// Copy onto `field` what it inherits (`add`, from [`inherited`]), so it keeps it when it moves. +pub(super) fn own_inherited(doc: &mut Document, field: ObjRef, add: &Dict) -> Result<(), FormError> { + if add.iter().next().is_some() { + doc.update_dict(field, |d| { + for (k, v) in add.iter() { + d.set(k.to_vec(), v.clone()); + } + })?; + } + Ok(()) +} + +/// Take `field` out of the field tree: from its parent's `/Kids`, or from `/Fields`. +pub(super) fn detach(doc: &mut Document, field: ObjRef) -> Result<(), FormError> { + let parent = doc.get(field).as_dict().and_then(|d| d.reference(b"Parent")); + let remove_from = |doc: &mut Document, holder: ObjRef, key: &[u8]| -> Result<(), FormError> { + let mut d = doc.get(holder).as_dict().cloned().unwrap_or_default(); + let list = d.get(key).map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default(); + let kept: Vec = list.into_iter().filter(|o| o.as_ref() != Some(field)).collect(); + d.set(key.to_vec(), Object::Array(kept)); + doc.set(holder, Object::Dict(d)); + Ok(()) + }; + match parent { + Some(p) => remove_from(doc, p, b"Kids")?, + None => { + let root = doc.root().ok_or(FormError::NoForm)?; + match doc.get(root).as_dict().and_then(|d| d.get(b"AcroForm").cloned()) { + Some(Object::Ref(af)) => remove_from(doc, af, b"Fields")?, + // A form dictionary held directly in the catalog. + Some(Object::Dict(mut af)) => { + let list = af.get(b"Fields").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default(); + let kept: Vec = list.into_iter().filter(|o| o.as_ref() != Some(field)).collect(); + af.set(b"Fields".to_vec(), Object::Array(kept)); + doc.update_dict(root, |d| d.set(b"AcroForm".to_vec(), Object::Dict(af)))?; + } + _ => {} + } + } + } + Ok(()) +} + +/// The first free "N" name: not a field's, and not held by a node at the top of the field +/// tree (`held`), such as a parent field left empty by a move, which isn't in `existing`. +pub(super) fn free_name(existing: &[Field], held: &[String], base: &str) -> String { + (1..=u64::MAX) + .map(|n| format!("{base}{n}")) + .find(|c| !held.contains(c) && !existing.iter().any(|f| f.name == *c || f.name.starts_with(&format!("{c}.")))) + .unwrap_or_else(|| base.to_string()) +} + +/// The own names (`/T`) of the nodes in the form's `/Fields`, changing nothing. +pub(super) fn top_names(doc: &Document) -> Vec { + let Some(root) = doc.root() else { return Vec::new() }; + let Some(af) = doc.get(root).as_dict().and_then(|d| d.get(b"AcroForm").map(|o| doc.resolve(o))).and_then(|o| o.as_dict().cloned()) else { + return Vec::new(); + }; + let list = af.get(b"Fields").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default(); + list.iter().filter_map(|o| doc.resolve(o).as_dict().and_then(|d| d.get(b"T").cloned())).filter_map(|t| crate::text_of(&doc.resolve(&t))).collect() +} diff --git a/crates/forms/src/lib.rs b/crates/forms/src/lib.rs index 39cebd8fdd..6d8cf9c99d 100644 --- a/crates/forms/src/lib.rs +++ b/crates/forms/src/lib.rs @@ -35,6 +35,27 @@ pub use scripting::{ set_field_script, }; +mod appearances; +mod read; +mod tab_order; +mod values; + +pub use appearances::{FieldAppearanceCache, declares_pdfa, need_appearances, undrawn_fields, with_field_appearances, with_field_appearances_cached}; +pub(crate) use read::page_refs; +pub use read::{Fields, acroform, adopted_page_fields, fields, fields_with_adopted}; +#[cfg(test)] +use read::{MAX_SCRIPT_BYTES, PageWalk, enumerate, form_reads}; +use read::{script, script_budget}; +pub use tab_order::{TabOrder, move_in_tab_order, set_tab_order}; +#[cfg(test)] +use values::has_password_flag; +use values::write_value; +pub(crate) use values::{ask_viewers_to_redraw, merged_appearance}; +pub use values::{ + calculations_changed, clear_password_values, has_password_values, password_values, recalculate, recalculate_with, reset, restore_password_values, + set_value, set_value_with, set_values, +}; + #[cfg(test)] mod tests; @@ -267,1592 +288,6 @@ fn name_obj(text: &str) -> Object { Object::Name(name_bytes(text)) } -/// At most this many `/State` entries of a set-layer-visibility action are read. -const MAX_LAYER_STATE: usize = 1024; - -/// A push button's mouse-up action (`/A`, or `/AA /U`, on the field or its widget). -fn button_action(doc: &Document, d: &Dict, widgets: &[ObjRef], scripts: &mut DecodeBudget) -> Option { - // A button PdfCraft generated for an XFA image field (`/PCXfaImage true`, no PDF action): - // clicking it picks a picture, as `buttonImportIcon()` does. - if matches!(d.get(b"PCXfaImage").map(|o| doc.resolve(o)).as_deref(), Some(Object::Bool(true))) { - return Some(af::ButtonAction::ImportIcon); - } - let pick = |dict: &Dict| -> Option { - dict.get(b"A").cloned().or_else(|| dict.get(b"AA").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().and_then(|aa| aa.get(b"U").cloned()))) - }; - let action = pick(d).or_else(|| widgets.iter().find_map(|w| doc.get(*w).as_dict().and_then(pick)))?; - let a = doc.resolve(&action); - let a = a.as_dict()?; - let s = |k: &[u8]| a.get(k).and_then(|v| text_of(&doc.resolve(v))); - Some(match a.name(b"S")? { - b"ResetForm" => { - let fields = a - .get(b"Fields") - .map(|f| doc.resolve(f)) - .and_then(|f| f.as_array().cloned()) - .unwrap_or_default() - .iter() - .filter_map(|o| match o { - Object::Ref(r) => doc.get(*r).as_dict().and_then(|fd| fd.get(b"T").and_then(|t| text_of(&doc.resolve(t)))), - other => text_of(&doc.resolve(other)), - }) - .collect(); - let exclude = a.get(b"Flags").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0) & 1 != 0; - af::ButtonAction::Reset { fields, exclude } - } - b"Named" => af::ButtonAction::Named(String::from_utf8_lossy(a.name(b"N")?).into_owned()), - b"URI" => af::ButtonAction::Uri(s(b"URI")?), - b"SubmitForm" => af::ButtonAction::Submit( - a.get(b"F") - .map(|f| doc.resolve(f)) - .and_then(|f| f.as_dict().and_then(|fd| fd.get(b"F").and_then(|x| text_of(&doc.resolve(x)))).or_else(|| text_of(&f))) - .unwrap_or_default(), - ), - b"GoTo" => { - let dest = doc.resolve(a.get(b"D")?); - let target = dest.as_array()?.first()?.as_ref()?; - af::ButtonAction::GoTo(page_refs(doc).iter().position(|p| *p == target)?) - } - b"Hide" => { - // /T: a field name, an annotation (or field) reference, or an array of them. - let target = |o: &Object| match o { - Object::Ref(r) => qualified_name(doc, *r).or_else(|| text_of(&doc.resolve(o))), - other => text_of(other), - }; - let fields = match a.get(b"T") { - Some(t) => match &*doc.resolve(t) { - Object::Array(items) => items.iter().filter_map(target).collect(), - _ => target(t).into_iter().collect(), - }, - None => Vec::new(), - }; - let hide = !a.get(b"H").is_some_and(|h| matches!(&*doc.resolve(h), Object::Bool(false))); - af::ButtonAction::ShowHide { fields, hide } - } - b"SetOCGState" => { - // /State: ON, OFF or Toggle, each followed by the groups it applies to. - let mut changes = Vec::new(); - if let Some(state) = a.get(b"State") { - let mut op = None; - for item in doc.resolve(state).as_array().into_iter().flatten().take(MAX_LAYER_STATE) { - match item { - Object::Name(n) => { - op = match n.as_slice() { - b"ON" => Some(af::LayerOp::On), - b"OFF" => Some(af::LayerOp::Off), - b"Toggle" => Some(af::LayerOp::Toggle), - _ => None, - } - } - Object::Ref(r) => changes.extend(op.map(|op| (op, (r.num, r.generation)))), - _ => {} - } - } - } - let preserve_rb = !a.get(b"PreserveRB").is_some_and(|p| matches!(&*doc.resolve(p), Object::Bool(false))); - af::ButtonAction::SetLayers { changes, preserve_rb } - } - b"JavaScript" => af::button_script(&script(doc, &action, scripts)?), - _ => return None, - }) -} - -/// The fully qualified name of the field that `r` (a field or one of its widgets) belongs to, -/// from the partial names (`/T`) up its `/Parent` chain. -fn qualified_name(doc: &Document, r: ObjRef) -> Option { - let mut parts = Vec::new(); - let mut seen = std::collections::HashSet::new(); - let mut cur = Some(r); - while let Some(c) = cur { - if seen.len() > 64 || !seen.insert(c) { - break; - } - let obj = doc.get(c); - let d = obj.as_dict()?; - if let Some(t) = d.get(b"T").and_then(|o| text_of(&doc.resolve(o))) { - parts.push(t); - } - cur = d.get(b"Parent").and_then(|p| p.as_ref()); - } - parts.reverse(); - (!parts.is_empty()).then(|| parts.join(".")) -} - -/// JavaScript streams one reading of the form (its fields, or its document scripts) may decode -/// in all, however many actions name the same stream. -const MAX_SCRIPT_BYTES: usize = 64 << 20; - -/// A budget for the script streams of one reading of the form. -fn script_budget() -> DecodeBudget { - DecodeBudget::new(MAX_SCRIPT_BYTES, 0) -} - -/// The JavaScript of an action dictionary (`/JS` string or stream). A stream is decoded against -/// `budget`, and is `None` once it is spent. -fn script(doc: &Document, action: &Object, budget: &mut DecodeBudget) -> Option { - let a = doc.resolve(action); - let js = a.as_dict()?.get(b"JS")?.clone(); - match &*doc.resolve(&js) { - Object::String(s) => Some(s.to_text()), - Object::Stream(s) => budget.decode(s).ok().map(|b| String::from_utf8_lossy(&b).into_owned()), - _ => None, - } -} - -/// A field's format/keystroke, validate and calculate scripts (field dictionary, or its only -/// widget's when the field has none). -fn actions_of(doc: &Document, d: &Dict, widgets: &[ObjRef], scripts: &mut DecodeBudget) -> af::Actions { - let aa = d.get(b"AA").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().cloned()).or_else(|| { - (widgets.len() == 1) - .then(|| doc.get(widgets[0])) - .and_then(|w| w.as_dict().and_then(|wd| wd.get(b"AA").map(|a| doc.resolve(a)))) - .and_then(|a| a.as_dict().cloned()) - }); - let mut out = af::Actions::default(); - let Some(aa) = aa else { return out }; - let mut js = |k: &[u8]| aa.get(k).and_then(|a| script(doc, a, scripts)); - if let Some(f) = js(b"F") { - match af::parse_format(&f) { - Some(fm) => out.format = fm, - None if !f.trim().is_empty() => { - out.unsupported.push("format"); - out.scripts.format = Some(f); - } - None => {} - } - } - if let Some(k) = js(b"K") - && out.format == af::Format::None - { - // A mask has only a keystroke script. - match af::parse_format(&k) { - Some(fm @ af::Format::Mask(_)) => out.format = fm, - Some(_) => {} - None if !k.trim().is_empty() => { - out.unsupported.push("keystroke"); - out.scripts.keystroke = Some(k); - } - None => {} - } - } - if let Some(v) = js(b"V") { - match af::parse_validate(&v) { - Some(vv) => out.validate = vv, - None if !v.trim().is_empty() => { - out.unsupported.push("validate"); - out.scripts.validate = Some(v); - } - None => {} - } - } - if let Some(c) = js(b"C") { - match af::parse_calculate(&c) { - Some(cc) => out.calculate = cc, - None if !c.trim().is_empty() => { - out.unsupported.push("calculate"); - out.scripts.calculate = Some(c); - } - None => {} - } - } - out -} - -fn values_of(doc: &Document, o: Option<&Object>) -> Vec { - let Some(o) = o else { return Vec::new() }; - match &*doc.resolve(o) { - Object::Array(a) => a.iter().filter_map(|x| text_of(&doc.resolve(x))).collect(), - Object::Stream(s) => s.decoded().ok().map(|b| PdfString::literal(b).to_text()).into_iter().collect(), - other => text_of(other).filter(|s| s != "Off" && !s.is_empty()).into_iter().collect(), - } -} - -fn nums(doc: &Document, o: Option<&Object>) -> Option> { - let o = doc.resolve(o?); - o.as_array()?.iter().map(|x| doc.resolve(x).as_f64()).collect() -} - -/// Leaf page objects in order (a small walker; `organize` and `annot` have their own). -pub(crate) fn page_refs(doc: &Document) -> Vec { - let Some(root) = doc.root() else { return Vec::new() }; - let Some(pages) = doc.get(root).as_dict().and_then(|d| d.reference(b"Pages")) else { return Vec::new() }; - PageWalk::new(doc, pages).collect() -} - -/// Depth-first walk over the leaf pages under `top`. It keeps one cursor per open `/Kids` array, -/// never one entry per child, so a child listed many times or shared between nodes is skipped by -/// the seen check without being queued first (`pdfcraft_model::pages` walks the same way). -/// Cycles end at the seen check and at most a million nodes are visited. -struct PageWalk<'a> { - doc: &'a Document, - seen: std::collections::HashSet, - pending: Vec<(std::sync::Arc, usize)>, -} - -impl<'a> PageWalk<'a> { - fn new(doc: &'a Document, top: ObjRef) -> Self { - let top = std::sync::Arc::new(Object::Array(vec![Object::Ref(top)])); - Self { doc, seen: Default::default(), pending: vec![(top, 0)] } - } -} - -impl Iterator for PageWalk<'_> { - type Item = ObjRef; - - fn next(&mut self) -> Option { - while let Some((kids, next)) = self.pending.last_mut() { - let Some(kid) = kids.as_array().and_then(|a| a.get(*next)) else { - self.pending.pop(); - continue; - }; - let node = kid.as_ref(); - *next += 1; - let Some(node) = node else { continue }; - if self.seen.len() >= 1_000_000 { - self.pending.clear(); - return None; - } - if !self.seen.insert(node) { - continue; - } - let obj = self.doc.get(node); - let Some(d) = obj.as_dict() else { continue }; - match d.get(b"Kids").map(|k| self.doc.resolve(k)) { - Some(kids) if d.name(b"Type") != Some(b"Page") => self.pending.push((kids, 0)), - _ => return Some(node), - } - } - None - } -} - -/// The AcroForm dictionary. -pub fn acroform(doc: &Document) -> Option { - let root = doc.root()?; - let cat = doc.get(root); - let af = cat.as_dict()?.get(b"AcroForm")?.clone(); - doc.resolve(&af).as_dict().cloned() -} - -#[derive(Clone, Default)] -struct Inherited { - name: String, - ft: Option>, - ff: Option, - v: Option, - dv: Option, - da: Option, - q: Option, - max_len: Option, -} - -/// Every terminal field: the `/Fields` tree in order, then the fields reachable only through the -/// page annotations (see [`adopt_page_fields`]). -pub fn fields(doc: &Document) -> Vec { - fields_with_adopted(doc).items -} - -/// How many of [`fields`] were reachable only through the page annotations: the form's `/Fields` -/// list names none or only some of them, so the leniency that adopts them is worth recording. -pub fn adopted_page_fields(doc: &Document) -> usize { - fields_with_adopted(doc).adopted_page_fields -} - -/// The terminal fields and page-only fields adopted while reading a form. -#[derive(Clone, Debug, PartialEq)] -pub struct Fields { - /// Terminal fields in tree order, followed by adopted page-only fields. - pub items: Vec, - /// How many fields were reachable only through page annotations. - pub adopted_page_fields: usize, -} - -/// Read all terminal fields and the page-only adoption count in one enumeration. -pub fn fields_with_adopted(doc: &Document) -> Fields { - let (items, adopted_page_fields) = enumerate(doc); - Fields { items, adopted_page_fields } -} - -#[cfg(test)] -thread_local! { - /// Whole-form readings ([`enumerate`]) on this thread. - static FORM_READS: std::cell::Cell = const { std::cell::Cell::new(0) }; -} - -/// How many times this thread has read the whole form. -#[cfg(test)] -fn form_reads() -> usize { - FORM_READS.with(std::cell::Cell::get) -} - -fn enumerate(doc: &Document) -> (Vec, usize) { - #[cfg(test)] - FORM_READS.with(|n| n.set(n.get() + 1)); - let af = acroform(doc); - let mut page_of = std::collections::HashMap::new(); - let mut annot_index = std::collections::HashMap::new(); - let pages = page_refs(doc); - for (i, p) in pages.iter().enumerate() { - if let Some(a) = doc.get(*p).as_dict().and_then(|d| d.get(b"Annots").cloned()) { - for (k, e) in doc.resolve(&a).as_array().into_iter().flatten().enumerate() { - if let Some(r) = e.as_ref() { - page_of.entry(r).or_insert(i); - annot_index.entry(r).or_insert(k); - } - } - } - } - let base = match &af { - Some(af) => Inherited { - da: af.get(b"DA").and_then(|o| text_of(&doc.resolve(o))), - q: af.get(b"Q").and_then(|o| doc.resolve(o).as_int()), - ..Default::default() - }, - None => Inherited::default(), - }; - let mut out = Vec::new(); - let mut seen = Seen { fields: std::collections::HashSet::new(), widgets: std::collections::HashSet::new(), scripts: script_budget() }; - if let Some(af) = &af { - for f in af.get(b"Fields").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default() { - if let Some(r) = f.as_ref() { - walk(doc, r, &base, &page_of, &mut seen, &mut out, 0); - } - } - } - let listed = out.len(); - adopt_page_fields(doc, &pages, &base, &page_of, &mut seen, &mut out); - let adopted = out.len() - listed; - rank_tabs(doc, &pages, &annot_index, &mut out); - (out, adopted) -} - -/// Widget annotations no `/Fields` entry reaches are adopted as fields, as Acrobat and the -/// browsers do: some writers list only some of the fields, or none at all. Each widget is adopted -/// through its topmost unlisted `/Parent`, so a field split across several widgets stays one -/// field. Tree-listed fields keep their order first; the rest follow in page-annotation order. -fn adopt_page_fields( - doc: &Document, - pages: &[ObjRef], - base: &Inherited, - page_of: &std::collections::HashMap, - seen: &mut Seen, - out: &mut Vec, -) { - for p in pages { - let Some(a) = doc.get(*p).as_dict().and_then(|d| d.get(b"Annots").cloned()) else { continue }; - let annots = doc.resolve(&a); - let Some(annots) = annots.as_array() else { continue }; - for e in annots { - let Some(r) = e.as_ref() else { continue }; - if seen.fields.contains(&r) || seen.widgets.contains(&r) { - continue; - } - let obj = doc.get(r); - let Some(d) = obj.as_dict() else { continue }; - if d.name(b"Subtype") != Some(b"Widget") { - continue; - } - if !d.contains(b"FT") && !d.contains(b"T") && !d.contains(b"Parent") { - continue; - } - if let Some(root) = unlisted_root(doc, r, &seen.fields) { - walk(doc, root, base, page_of, seen, out, 0); - } - } - } -} - -/// The topmost field above `r` (itself without a `/Parent`), or `None` when `r` or an ancestor is -/// already reachable from the `/Fields` tree, or a `/Parent` loop hides the top. -fn unlisted_root(doc: &Document, r: ObjRef, seen: &std::collections::HashSet) -> Option { - let mut top = r; - let mut visited = std::collections::HashSet::from([r]); - loop { - let parent = doc.get(top).as_dict().and_then(|d| d.reference(b"Parent")); - let Some(p) = parent else { - return if seen.contains(&top) { None } else { Some(top) }; - }; - if !visited.insert(p) || visited.len() > 64 { - return None; - } - if seen.contains(&p) { - return None; - } - top = p; - } -} - -/// A page's tab order (`/Tabs`). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum TabOrder { - /// Rows, top to bottom, left to right within a row (`/R`). - Row, - /// Columns, left to right, top to bottom within a column (`/C`). - Column, - /// The document structure (`/S`; annotation order when the file has none). - Structure, - /// Unspecified: annotation order. - Annotations, -} - -impl TabOrder { - fn of(name: Option<&[u8]>) -> TabOrder { - match name { - Some(b"R") => TabOrder::Row, - Some(b"C") => TabOrder::Column, - Some(b"S") => TabOrder::Structure, - _ => TabOrder::Annotations, - } - } -} - -/// A widget on a page for tab ordering: (page, field, widget, rect, annotation index). -type TabItem = (usize, usize, usize, [f64; 4], usize); - -/// Every widget's place in the tab order. -fn rank_tabs(doc: &Document, pages: &[ObjRef], annot_index: &std::collections::HashMap, out: &mut [Field]) { - let mut items: Vec = Vec::new(); - for (fi, f) in out.iter().enumerate() { - for (wi, w) in f.widgets.iter().enumerate() { - if let Some(p) = w.page { - items.push((p, fi, wi, w.rect, annot_index.get(&w.obj).copied().unwrap_or(usize::MAX))); - } - } - } - let mut ordered: Vec<(usize, usize)> = Vec::with_capacity(items.len()); - for (pi, p) in pages.iter().enumerate() { - let mut on: Vec<_> = items.iter().filter(|i| i.0 == pi).collect(); - if on.is_empty() { - continue; - } - let tabs = TabOrder::of(doc.get(*p).as_dict().and_then(|d| d.name(b"Tabs"))); - match tabs { - TabOrder::Row | TabOrder::Column => { - let row = tabs == TabOrder::Row; - // Primary axis: rows by top edge (down the page), columns by left edge. - on.sort_by(|a, b| if row { b.3[3].total_cmp(&a.3[3]) } else { a.3[0].total_cmp(&b.3[0]) }); - let mut groups: Vec> = Vec::new(); - for it in on { - let fits = groups.last().is_some_and(|g| { - let first = g[0]; - if row { - (first.3[3] - it.3[3]).abs() < ((first.3[3] - first.3[1]) / 2.0).max(2.0) - } else { - (it.3[0] - first.3[0]).abs() < ((first.3[2] - first.3[0]) / 2.0).max(2.0) - } - }); - if fits && let Some(g) = groups.last_mut() { - g.push(it); - } else { - groups.push(vec![it]); - } - } - for mut g in groups { - g.sort_by(|a, b| if row { a.3[0].total_cmp(&b.3[0]) } else { b.3[3].total_cmp(&a.3[3]) }); - ordered.extend(g.iter().map(|i| (i.1, i.2))); - } - } - TabOrder::Structure | TabOrder::Annotations => { - on.sort_by_key(|i| i.4); - ordered.extend(on.iter().map(|i| (i.1, i.2))); - } - } - } - for f in out.iter_mut() { - for w in &mut f.widgets { - w.tab = usize::MAX; - } - } - for (rank, (fi, wi)) in ordered.into_iter().enumerate() { - out[fi].widgets[wi].tab = rank; - } -} - -/// Set the tab order of pages (0-based). -pub fn set_tab_order(doc: &mut Document, pages: &[usize], order: TabOrder) -> Result<(), FormError> { - let refs = page_refs(doc); - for &p in pages { - let r = *refs.get(p).ok_or_else(|| FormError::Invalid(format!("page {} does not exist", p + 1)))?; - doc.update_dict(r, |d| match order { - TabOrder::Row => d.set(b"Tabs".to_vec(), Object::name("R")), - TabOrder::Column => d.set(b"Tabs".to_vec(), Object::name("C")), - TabOrder::Structure => d.set(b"Tabs".to_vec(), Object::name("S")), - TabOrder::Annotations => { - d.remove(b"Tabs"); - } - })?; - } - Ok(()) -} - -/// What one reading of the form's fields has used: the field and widget dictionaries read, and -/// the script budget their actions share. -struct Seen { - fields: std::collections::HashSet, - /// A widget belongs to the first field that lists it, however often it is listed. - widgets: std::collections::HashSet, - scripts: DecodeBudget, -} - -/// Read the field at `r` and the fields under it into `out`, with what they inherit from -/// `parent`: kids with a name are fields of their own, kids without are this field's widgets. -fn walk( - doc: &Document, - r: ObjRef, - parent: &Inherited, - page_of: &std::collections::HashMap, - seen: &mut Seen, - out: &mut Vec, - depth: usize, -) { - if depth > 64 || !seen.fields.insert(r) { - return; - } - let obj = doc.get(r); - let Some(d) = obj.as_dict() else { return }; - let mut inh = parent.clone(); - if let Some(t) = d.get(b"T").and_then(|o| text_of(&doc.resolve(o))) { - inh.name = if inh.name.is_empty() { t } else { format!("{}.{t}", inh.name) }; - } - if let Some(ft) = d.name(b"FT") { - inh.ft = Some(ft.to_vec()); - } - if let Some(ff) = field_flags(doc, d) { - inh.ff = Some(ff); - } - for (k, slot) in [(&b"V"[..], &mut inh.v), (b"DV", &mut inh.dv)] { - if let Some(v) = d.get(k) { - *slot = Some(v.clone()); - } - } - if let Some(da) = d.get(b"DA").and_then(|o| text_of(&doc.resolve(o))) { - inh.da = Some(da); - } - if let Some(q) = d.get(b"Q").and_then(|o| doc.resolve(o).as_int()) { - inh.q = Some(q); - } - if let Some(m) = d.get(b"MaxLen").and_then(|o| doc.resolve(o).as_int()) { - // Compatibility: some writers emit zero for an unlimited text field. Keep /MaxLen - // untouched in the object graph, but expose only positive limits to both UI and API. - // An explicit zero also clears an inherited limit rather than inheriting it again. - inh.max_len = usize::try_from(m).ok().filter(|limit| *limit > 0); - } - let kids: Vec = - d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_default().iter().filter_map(|k| k.as_ref()).collect(); - // Kids with /T are fields; kids without are this field's widgets. - let field_kids: Vec = kids.iter().copied().filter(|k| doc.get(*k).as_dict().is_some_and(|kd| kd.contains(b"T"))).collect(); - if !field_kids.is_empty() { - for k in field_kids { - walk(doc, k, &inh, page_of, seen, out, depth + 1); - } - return; - } - // A field of no kind this reads is skipped before it claims widgets or scripts, which a later - // field listing them then reads. - let ff = inh.ff.unwrap_or(0); - let kind = match inh.ft.as_deref() { - Some(b"Tx") => FieldKind::Text, - Some(b"Btn") if ff & flags::PUSH_BUTTON != 0 => FieldKind::PushButton, - Some(b"Btn") if ff & flags::RADIO != 0 => FieldKind::Radio, - Some(b"Btn") => FieldKind::CheckBox, - Some(b"Ch") if ff & flags::COMBO != 0 => FieldKind::Combo, - Some(b"Ch") => FieldKind::List, - Some(b"Sig") => FieldKind::Signature, - _ => return, - }; - let widget_refs: Vec = if d.contains(b"Rect") { vec![r] } else { kids }; - let widget_refs: Vec = widget_refs.into_iter().filter(|w| seen.widgets.insert(*w)).collect(); - let actions = actions_of(doc, d, &widget_refs, &mut seen.scripts); - let button = (inh.ft.as_deref() == Some(b"Btn") && inh.ff.unwrap_or(0) & flags::PUSH_BUTTON != 0) - .then(|| button_action(doc, d, &widget_refs, &mut seen.scripts)) - .flatten(); - let widgets = widget_refs - .into_iter() - .filter_map(|w| { - let wo = doc.get(w); - let wd = wo.as_dict()?; - let rect = nums(doc, wd.get(b"Rect")).filter(|r| r.len() == 4).unwrap_or_else(|| vec![0.0; 4]); - let annot_flags = wd.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0); - let own_ff = if w == r { 0 } else { field_flags(doc, wd).unwrap_or(0) }; - let on_state = wd - .get(b"AP") - .map(|ap| doc.resolve(ap)) - .and_then(|ap| ap.as_dict().and_then(|a| a.get(b"N").cloned())) - .and_then(|n| doc.resolve(&n).as_dict().cloned()) - .and_then(|n| n.iter().map(|(k, _)| name_text(k)).find(|k| k != "Off")); - Some(Widget { - obj: w, - page: page_of.get(&w).copied(), - rect: [rect[0].min(rect[2]), rect[1].min(rect[3]), rect[0].max(rect[2]), rect[1].max(rect[3])], - on_state: on_state.filter(|_| matches!(kind, FieldKind::CheckBox | FieldKind::Radio)), - state: wd.name(b"AS").map(name_text), - tab: usize::MAX, - locked: annot_flags & 128 != 0, - hidden: annot_flags & (2 | 32) != 0, - read_only: (ff | own_ff) & flags::READ_ONLY != 0, - rotation: appearance::mk_rotation(doc, wd), - caption: (kind == FieldKind::PushButton) - .then(|| { - let mk = doc.resolve(wd.get(b"MK")?); - let value = doc.resolve(mk.as_dict()?.get(b"CA")?); - value.as_string().map(|s| s.to_text()) - }) - .flatten(), - list: (kind == FieldKind::List).then(|| appearance::list_rows_of(doc, inh.da.as_deref().unwrap_or("/Helv 0 Tf 0 g"), Some(d), wd)), - round: kind == FieldKind::Radio || (kind == FieldKind::CheckBox && appearance::is_round(doc, wd)), - }) - }) - .collect(); - let options = d - .get(b"Opt") - .map(|o| doc.resolve(o)) - .and_then(|o| o.as_array().cloned()) - .unwrap_or_default() - .iter() - .filter_map(|o| match &*doc.resolve(o) { - Object::Array(pair) if pair.len() >= 2 => Some((text_of(&doc.resolve(&pair[0]))?, text_of(&doc.resolve(&pair[1]))?)), - other => text_of(other).map(|t| (t.clone(), t)), - }) - .collect(); - out.push(Field { - name: inh.name.clone(), - obj: r, - kind, - value: values_of(doc, inh.v.as_ref()), - default: values_of(doc, inh.dv.as_ref()), - flags: ff, - max_len: inh.max_len, - options, - da: inh.da.clone().unwrap_or_else(|| "/Helv 0 Tf 0 g".into()), - quadding: inh.q.unwrap_or(0), - tooltip: d.get(b"TU").and_then(|o| text_of(&doc.resolve(o))), - widgets, - actions, - button, - }); -} - -fn field_flags(doc: &Document, dict: &Dict) -> Option { - dict.get(b"Ff").and_then(|value| doc.resolve(value).as_int()).and_then(|value| u32::try_from(value).ok()) -} - -// ── writing ───────────────────────────────────────────────────────────────────────────────── - -/// Fill the field `name`. -pub fn set_value(doc: &mut Document, name: &str, value: &FieldValue) -> Result<(), FormError> { - set_value_with(doc, name, value, &mut NoScripts) -} - -/// [`set_value`], running the fields' JavaScript through `scripts`. -pub fn set_value_with(doc: &mut Document, name: &str, value: &FieldValue, scripts: &mut dyn Scripts) -> Result<(), FormError> { - let all = fields(doc); - if all.is_empty() { - return Err(FormError::NoForm); - } - let named: Vec = all.iter().enumerate().filter(|(_, f)| f.name == name).map(|(i, _)| i).collect(); - fill(doc, &all, &named, name, value, scripts)?; - recalculate_with(doc, scripts)?; - Ok(()) -} - -/// Fill several fields, as [`set_value`] fills each in turn, but reading the form once and -/// recalculating once, after the last value. Filling fields one by one reads the whole form twice -/// per field, which takes minutes on a form with thousands of fields. -/// -/// Returns each value's result, in order. The outer `Err` is the recalculation failing, after -/// which, as with [`set_value`], no value counts as filled (though each was written). -pub fn set_values(doc: &mut Document, values: &[(String, FieldValue)]) -> Result>, FormError> { - let mut scripts = NoScripts; - let mut form = NamedFields::read(doc); - // The field objects this batch wrote since the form was last read. - let mut written = std::collections::HashSet::new(); - let mut results = Vec::with_capacity(values.len()); - let mut filled = false; - for (name, value) in values { - if form.all.is_empty() { - results.push(Err(FormError::NoForm)); - continue; - } - // A field written earlier in the batch (named twice, or a twin's name) is read again, - // so this write starts from what the earlier one left, as it would one by one. - if form.objs(name).any(|o| written.contains(&o)) { - form = NamedFields::read(doc); - written.clear(); - } - let named = form.named(name); - let result = fill(doc, &form.all, named, name, value, &mut scripts); - written.extend(form.objs(name)); - filled |= result.is_ok(); - results.push(result); - } - if filled { - recalculate_with(doc, &mut scripts)?; - } - Ok(results) -} - -/// The form's fields, with the positions of each fully qualified name in them (in field order). -struct NamedFields { - all: Vec, - by_name: std::collections::HashMap>, -} - -impl NamedFields { - fn read(doc: &Document) -> Self { - let all = fields(doc); - let mut by_name: std::collections::HashMap> = std::collections::HashMap::new(); - for (i, f) in all.iter().enumerate() { - by_name.entry(f.name.clone()).or_default().push(i); - } - NamedFields { all, by_name } - } - - fn named(&self, name: &str) -> &[usize] { - self.by_name.get(name).map_or(&[], Vec::as_slice) - } - - /// The field objects named `name`. - fn objs<'a>(&'a self, name: &str) -> impl Iterator + 'a { - self.named(name).iter().filter_map(|&i| self.all.get(i)).map(|f| f.obj) - } -} - -/// Write `value` into the field `name`, without recalculating: `named` are the positions in -/// `all` (the form as read before this write) of the fields with that name, in field order. -fn fill(doc: &mut Document, all: &[Field], named: &[usize], name: &str, value: &FieldValue, scripts: &mut dyn Scripts) -> Result<(), FormError> { - let f = named.first().and_then(|&i| all.get(i)).ok_or_else(|| FormError::NoSuchField(name.into()))?; - if f.read_only() { - return Err(FormError::ReadOnly(name.into())); - } - write_value(doc, f, value, scripts)?; - author::form_fonts_as_objects(doc, all)?; - // Fields with the same fully qualified name are one field (§12.7.3.1). Writers that repeat a - // name as separate field dictionaries, instead of widgets under one parent, get the value - // the first one took after its scripts, without running them again. - let twins: Vec<&Field> = named.iter().filter_map(|&i| all.get(i)).filter(|t| t.obj != f.obj && t.kind == f.kind).collect(); - if !twins.is_empty() { - let taken = fields(doc).into_iter().find(|x| x.obj == f.obj).map(|x| x.value).unwrap_or_default(); - for t in twins { - share_value(doc, f.obj, t, &taken, scripts)?; - } - } - Ok(()) -} - -/// Give `twin` the value `source` holds: its `/V` (and a choice's `/I`), drawn in the twin's own -/// widgets. -fn share_value(doc: &mut Document, source: ObjRef, twin: &Field, value: &[String], scripts: &mut dyn Scripts) -> Result<(), FormError> { - match twin.kind { - FieldKind::CheckBox => { - let state = twin.widgets.iter().find_map(|w| w.on_state.clone()).unwrap_or_else(|| "Yes".into()); - set_states(doc, twin, (!value.is_empty()).then_some(state.as_str())) - } - FieldKind::Radio => { - let choice = value.first().filter(|c| twin.widgets.iter().any(|w| w.on_state.as_deref() == Some(c.as_str()))); - set_states(doc, twin, choice.map(String::as_str)) - } - FieldKind::Text | FieldKind::Combo | FieldKind::List => { - let src = doc.get(source).as_dict().cloned().unwrap_or_default(); - doc.update_dict(twin.obj, |d| { - for key in [&b"V"[..], b"I"] { - match src.get(key) { - Some(v) => d.set(key.to_vec(), v.clone()), - None => { - d.remove(key); - } - } - } - d.remove(b"RV"); - })?; - redraw(doc, twin, value, scripts) - } - FieldKind::PushButton | FieldKind::Signature => Ok(()), - } -} - -/// Run every field's Calculate script in the form's calculation order (`/CO`, then the other -/// calculated fields), as Acrobat does after any value changes. Returns how many changed. -pub fn recalculate(doc: &mut Document) -> Result { - recalculate_with(doc, &mut NoScripts) -} - -/// [`recalculate`], running custom Calculate (and Format) scripts through `scripts`. -pub fn recalculate_with(doc: &mut Document, scripts: &mut dyn Scripts) -> Result { - let mut all = fields(doc); - if !all.iter().any(calc_order::is_calculated) { - return Ok(0); - } - // By object: two fields can share a name in a malformed form, and each calculates. - let order: Vec = calc_order::run_order(doc, &all).0.into_iter().filter_map(|(i, _)| all.get(i)).map(|f| f.obj).collect(); - let mut changed = 0; - for obj in order { - let Some(f) = all.iter().find(|f| f.obj == obj).cloned() else { continue }; - let lookup = |n: &str| -> Vec { - let prefix = format!("{n}."); - all.iter().filter(|x| x.name == n || x.name.starts_with(&prefix)).flat_map(|x| x.value.first().cloned()).collect() - }; - let current = f.value.first().cloned().unwrap_or_default(); - let v = if let Some(js) = &f.actions.scripts.calculate { - let r = scripts.run(FieldEvent::Calculate, js, &f, ¤t, &all); - scripting::apply_changes(doc, &r.changes, &f.name)?; - if !r.changes.is_empty() { - all = fields(doc); - } - if !r.rc { - continue; - } - r.value - } else { - let Some(v) = af::calculate(&f.actions.calculate, &lookup) else { continue }; - v - }; - if current != v && matches!(f.kind, FieldKind::Text | FieldKind::Combo) { - doc.update_dict(f.obj, |d| d.set(b"V".to_vec(), PdfString::text(&v)))?; - redraw(doc, &f, std::slice::from_ref(&v), scripts)?; - if let Some(target) = all.iter_mut().find(|x| x.obj == obj) { - target.value = vec![v]; - } - changed += 1; - } - } - Ok(changed) -} - -/// The fields whose calculation differs between two readings of a form, matched by field object -/// and named as in `after`: the formulas an edit rewrote, such as a rename's. -pub fn calculations_changed(before: &[Field], after: &[Field]) -> Vec { - let before: std::collections::HashMap = before.iter().map(|f| (f.obj, &f.actions)).collect(); - after - .iter() - .filter(|f| before.get(&f.obj).is_some_and(|b| b.calculate != f.actions.calculate || b.scripts.calculate != f.actions.scripts.calculate)) - .map(|f| f.name.clone()) - .collect() -} - fn invalid(m: impl Into) -> Result { Err(FormError::Invalid(m.into())) } - -fn write_value(doc: &mut Document, f: &Field, value: &FieldValue, scripts: &mut dyn Scripts) -> Result<(), FormError> { - match (f.kind, value) { - (FieldKind::Text, FieldValue::Text(t)) => { - if let Some(max) = f.max_len - && t.chars().count() > max - { - return invalid(format!("{:?} takes at most {max} characters", f.name)); - } - // Keystroke (on commit) and Validate, as Acrobat runs them before accepting a value. - let t = &af::keystroke(&f.actions.format, &f.name, t).map_err(FormError::Format)?; - af::validate(&f.actions.validate, t).map_err(FormError::Invalid)?; - let t = &scripting::accept(doc, f, t, scripts)?; - doc.update_dict(f.obj, |d| { - if t.is_empty() { - d.remove(b"V"); - } else { - d.set(b"V".to_vec(), PdfString::text(t)); - } - d.remove(b"RV"); - })?; - redraw(doc, f, std::slice::from_ref(t), scripts) - } - (FieldKind::CheckBox, v) => { - let state = f.widgets.iter().find_map(|w| w.on_state.clone()).unwrap_or_else(|| "Yes".into()); - let on = match v { - FieldValue::Check(on) => *on, - // The on-state name or a yes/no word also work (agents, FDF-style data). - FieldValue::Text(t) | FieldValue::Radio(Some(t)) => { - let t = t.trim(); - if t == state || f.state_for(t).is_some() || ["yes", "true", "on", "1", "x", "checked"].contains(&t.to_lowercase().as_str()) { - true - } else if t.is_empty() || ["no", "false", "off", "0", "unchecked"].contains(&t.to_lowercase().as_str()) { - false - } else { - return invalid(format!("{:?} is a check box: use true or false", f.name)); - } - } - FieldValue::Radio(None) => false, - FieldValue::Choice(_) => return invalid(format!("{:?} is a check box: use true or false", f.name)), - }; - set_states(doc, f, on.then_some(state.as_str())) - } - (FieldKind::Radio, v) => { - let choice = match v { - FieldValue::Radio(c) => c.clone(), - FieldValue::Text(t) if t.is_empty() => None, - FieldValue::Text(t) => Some(t.clone()), - FieldValue::Check(false) => None, - _ => return invalid(format!("{:?} is a radio group: choose one of its options", f.name)), - }; - // An export value from /Opt selects its widget's on state. - let choice = choice.map(|c| f.state_for(&c).map(str::to_owned).unwrap_or(c)); - if let Some(c) = &choice - && !f.widgets.iter().any(|w| w.on_state.as_deref() == Some(c.as_str())) - { - let opts: Vec<&str> = (0..f.widgets.len()).filter_map(|i| f.export_of(i)).collect(); - return invalid(format!("{:?} has no option {c:?} (options: {})", f.name, opts.join(", "))); - } - if choice.is_none() && f.has(flags::NO_TOGGLE_TO_OFF) && !f.value.is_empty() { - return invalid(format!("{:?} must keep one option selected", f.name)); - } - set_states(doc, f, choice.as_deref()) - } - (FieldKind::Combo | FieldKind::List, FieldValue::Choice(_) | FieldValue::Text(_)) => { - let vals: Vec = match value { - FieldValue::Choice(v) => v.clone(), - FieldValue::Text(t) if t.is_empty() => Vec::new(), - FieldValue::Text(t) => vec![t.clone()], - other => return invalid(format!("{:?} can't take {other:?}", f.name)), - }; - if vals.len() > 1 && !(f.kind == FieldKind::List && f.has(flags::MULTI_SELECT)) { - return invalid(format!("{:?} takes a single value", f.name)); - } - // Accept export values or display texts; free text only in editable combo boxes. - let mut exports = Vec::new(); - for v in &vals { - match f.options.iter().find(|(e, d)| e == v || d == v) { - Some((e, _)) => exports.push(e.clone()), - None if f.kind == FieldKind::Combo && f.has(flags::EDIT) => exports.push(v.clone()), - None if f.options.is_empty() => exports.push(v.clone()), - None => { - let opts: Vec<&str> = f.options.iter().map(|(_, d)| d.as_str()).collect(); - return invalid(format!("{:?} has no option {v:?} (options: {})", f.name, opts.join(", "))); - } - } - } - let mut indices: Vec = - exports.iter().filter_map(|e| f.options.iter().position(|(x, _)| x == e)).map(|i| Object::Int(i as i64)).collect(); - // /I lists option indices in ascending order; /V keeps the caller's value order. - // Every entry above is an integer, so this sorts by its numeric option index. - indices.sort_unstable_by_key(Object::as_int); - // A list box scrolls (`/TI`) so the picked options are among the rows it draws, as - // in other viewers: a pick below the last visible row would otherwise not show (#1524). - let top = match (f.kind, f.widgets.first()) { - (FieldKind::List, Some(w)) => w.list.map(|rows| { - let height = if matches!(w.rotation, 90 | 270) { w.rect[2] - w.rect[0] } else { w.rect[3] - w.rect[1] }; - let picked: Vec = indices.iter().filter_map(|i| i.as_int()).filter_map(|i| usize::try_from(i).ok()).collect(); - (rows.top, appearance::ListRows::top_showing(rows.top, &picked, rows.visible(height), f.options.len())) - }), - _ => None, - }; - doc.update_dict(f.obj, |d| { - match exports.as_slice() { - [] => { - d.remove(b"V"); - } - [one] => d.set(b"V".to_vec(), PdfString::text(one)), - many => d.set(b"V".to_vec(), Object::Array(many.iter().map(|e| Object::String(PdfString::text(e))).collect())), - } - if indices.is_empty() { - d.remove(b"I"); - } else { - d.set(b"I".to_vec(), Object::Array(indices)); - } - if let Some((old, new)) = top - && old != new - { - d.set(b"TI".to_vec(), Object::Int(i64::try_from(new).unwrap_or(0))); - } - })?; - // A widget with its own `/TI` (read before the field's) scrolls too. - if let Some((old, new)) = top - && old != new - { - for w in f.widgets.iter().filter(|w| w.obj != f.obj) { - if doc.get(w.obj).as_dict().is_some_and(|d| d.contains(b"TI")) { - doc.update_dict(w.obj, |d| d.set(b"TI".to_vec(), Object::Int(i64::try_from(new).unwrap_or(0))))?; - } - } - } - redraw(doc, f, &exports, scripts) - } - (FieldKind::PushButton, _) => invalid(format!("{:?} is a button; it has no value", f.name)), - (FieldKind::Signature, _) => invalid(format!("{:?} is a signature field; sign it with Fill & Sign or a digital ID", f.name)), - (kind, v) => invalid(format!("{:?} is a {kind:?} field and can't take {v:?}", f.name)), - } -} - -/// Check boxes and radio buttons: `/V` on the field, `/AS` on each widget. -fn set_states(doc: &mut Document, f: &Field, on: Option<&str>) -> Result<(), FormError> { - let v = on.unwrap_or("Off"); - doc.update_dict(f.obj, |d| d.set(b"V".to_vec(), name_obj(v)))?; - for w in &f.widgets { - let state = match (on, w.on_state.as_deref()) { - (Some(c), Some(s)) if c == s => s, - _ => "Off", - }; - // A widget without appearances for its states gets PdfCraft's own. - let has_ap = doc.get(w.obj).as_dict().and_then(|d| d.get(b"AP").cloned()).is_some(); - if !has_ap { - let on_name = w.on_state.clone().unwrap_or_else(|| on.unwrap_or("Yes").to_string()); - let ap = appearance::check_box_states(doc, w, f.kind, &on_name, &f.da); - doc.update_dict(w.obj, |d| d.set(b"AP".to_vec(), Object::Dict(ap)))?; - } - doc.update_dict(w.obj, |d| d.set(b"AS".to_vec(), name_obj(state)))?; - } - Ok(()) -} - -/// Copy an existing appearance dictionary before replacing its normal appearance. -fn appearance_dict(doc: &Document, widget: ObjRef) -> Dict { - doc.get(widget).as_dict().and_then(|d| d.get(b"AP").map(|a| doc.resolve(a))).and_then(|a| a.as_dict().cloned()).unwrap_or_default() -} - -/// `widget`'s appearance dictionary (a copy, so a shared one is left alone) with the freshly -/// drawn entries of `fresh` (its `/N`). Other entries are kept, but the old down and rollover -/// appearances (`/D`, `/R`) would show the old value, caption or colour on press or hover, so -/// they go — except, for check boxes and radio buttons (whose `/N` is a dictionary of states), -/// the states the new `/N` still draws under the same names. -pub(crate) fn merged_appearance(doc: &Document, widget: ObjRef, fresh: &Dict) -> Dict { - let mut apd = appearance_dict(doc, widget); - // Only a dictionary of states counts (`as_dict` would also see a stream's own dictionary). - let states_of = |o: &Object| match &*doc.resolve(o) { - Object::Dict(d) => Some(d.clone()), - _ => None, - }; - let states: Option>> = fresh.get(b"N").and_then(states_of).map(|d| d.iter().map(|(k, _)| k.clone()).collect()); - for key in [&b"D"[..], &b"R"[..]] { - if fresh.contains(key) { - continue; - } - let kept = states.as_ref().and_then(|names| { - let old = apd.get(key).and_then(states_of)?; - let mut d = Dict::new(); - for (k, v) in old.iter().filter(|(k, _)| names.contains(k)) { - d.set(k.clone(), v.clone()); - } - (!d.is_empty()).then_some(d) - }); - match kept { - Some(d) => apd.set(key.to_vec(), Object::Dict(d)), - None => { - apd.remove(key); - } - } - } - for (k, v) in fresh.iter() { - apd.set(k.clone(), v.clone()); - } - apd -} - -/// Regenerate the normal appearance of every widget of a text or choice field. -fn redraw(doc: &mut Document, f: &Field, values: &[String], scripts: &mut dyn Scripts) -> Result<(), FormError> { - let shown = match values { - [one] if matches!(f.kind, FieldKind::Text | FieldKind::Combo) => scripting::formatted(doc, f, one, scripts), - _ => None, - }; - let in_place = f.kind == FieldKind::Text && f.has(flags::PASSWORD); - let mut undrawn = false; - for w in &f.widgets { - let (stream, missing) = match &shown { - Some(s) => appearance::field_appearance_drawn(doc, f, w, std::slice::from_ref(s), false), - None => appearance::field_appearance_drawn(doc, f, w, values, true), - }; - undrawn |= missing.is_some(); - let existing_ap = in_place - .then(|| { - doc.get(w.obj) - .as_dict() - .and_then(|d| d.get(b"AP").map(|a| doc.resolve(a))) - .and_then(|a| a.as_dict().and_then(|a| a.get(b"N").cloned())) - .and_then(|n| n.as_ref()) - .filter(|r| doc.is_edited(r.num) && matches!(&*doc.get(*r), Object::Stream(_))) - }) - .flatten(); - let ap = match existing_ap { - Some(r) => { - doc.set(r, Object::Stream(stream)); - r - } - None => doc.add(Object::Stream(stream)), - }; - let mut fresh = Dict::new(); - fresh.set(b"N".to_vec(), Object::Ref(ap)); - let apd = merged_appearance(doc, w.obj, &fresh); - doc.update_dict(w.obj, |d| { - d.set(b"AP".to_vec(), Object::Dict(apd)); - d.remove(b"AS"); - pdfcraft_fonts::set_undrawn_mark(d, missing); - })?; - } - if undrawn { - ask_viewers_to_redraw(doc)?; - } - Ok(()) -} - -/// A value its widget's font can't show is stored as typed and drawn with `?` (as Acrobat keeps -/// such a value): `/NeedAppearances` then asks other viewers to draw the fields from their values -/// with fonts that have those characters (the interactive form dictionary, §12.7.3). Not in a -/// document that declares PDF/A, which forbids it: there the widget's mark -/// (`pdfcraft_fonts::UNDRAWN_MARK`) is all, and the edit's report says the `?` shows everywhere. -pub(crate) fn ask_viewers_to_redraw(doc: &mut Document) -> Result<(), FormError> { - if declares_pdfa(doc) || need_appearances(doc) { - return Ok(()); - } - let Some(root) = doc.root() else { return Ok(()) }; - let entry = doc.get(root).as_dict().and_then(|c| c.get(b"AcroForm").cloned()); - match entry { - Some(Object::Ref(r)) => doc.update_dict(r, |d| d.set(b"NeedAppearances".to_vec(), Object::Bool(true)))?, - Some(Object::Dict(mut af)) => { - af.set(b"NeedAppearances".to_vec(), Object::Bool(true)); - doc.update_dict(root, |c| c.set(b"AcroForm".to_vec(), Object::Dict(af)))?; - } - _ => {} - } - Ok(()) -} - -/// Text fields with the Password flag (inherited or their own) whose value (`/V`, inherited or -/// their own) is not empty and not their default value (`/DV`, which the form's author wrote and -/// a save restores), or one of whose separate widgets carries a `/V`. -fn password_fields(doc: &Document) -> Vec { - if !has_password_flag(doc) { - return Vec::new(); - } - let on_widget = |f: &Field| f.widgets.iter().any(|w| w.obj != f.obj && doc.get(w.obj).as_dict().is_some_and(|d| d.get(b"V").is_some())); - let typed = |f: &Field| f.value.iter().any(|v| !v.is_empty()) && f.value.first() != f.default.first(); - fields(doc).into_iter().filter(|f| f.kind == FieldKind::Text && f.has(flags::PASSWORD) && (typed(f) || on_widget(f))).collect() -} - -/// Whether any field dictionary sets the Password flag (#3943 review). A field that inherits the -/// flag has an ancestor that sets it, so that ancestor is found instead; a kid clearing the flag -/// again only makes the answer cautious. It reads only `/Ff`, `/Kids` and `/Parent`, so a save -/// or recovery snapshot of a form without such a field doesn't build every [`Field`]. Like -/// [`fields`], it looks at the form's field tree and at the widgets on the pages, which may be -/// missing from `/Fields`. Each dictionary is visited once and at most 64 levels deep. -fn has_password_flag(doc: &Document) -> bool { - let sets_it = |r: ObjRef| doc.get(r).as_dict().and_then(|d| field_flags(doc, d)).is_some_and(|ff| ff & flags::PASSWORD != 0); - let top = acroform(doc).and_then(|af| af.get(b"Fields").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned())).unwrap_or_default(); - let mut stack: Vec<(Object, usize)> = top.into_iter().map(|o| (o, 0)).collect(); - let mut seen = std::collections::HashSet::new(); - while let Some((o, depth)) = stack.pop() { - let Object::Ref(r) = o else { continue }; - if depth > 64 || !seen.insert(r) { - continue; - } - if sets_it(r) { - return true; - } - let kids = doc.get(r).as_dict().and_then(|d| d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned())).unwrap_or_default(); - stack.extend(kids.into_iter().map(|k| (k, depth + 1))); - } - // Widgets on the pages, and the fields above them. - page_refs(doc).into_iter().any(|p| { - let annots = - doc.get(p).as_dict().and_then(|d| d.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned())).unwrap_or_default(); - annots.iter().filter_map(Object::as_ref).filter(|r| !seen.contains(r)).any(|r| field_chain(doc, r).into_iter().any(sets_it)) - }) -} - -/// Whether any text field with the Password flag holds a value in `doc`. -pub fn has_password_values(doc: &Document) -> bool { - !password_fields(doc).is_empty() -} - -/// The typed values of the text fields with the Password flag, as (name, text) pairs, for -/// [`restore_password_values`] after the document was rebased on a file written without them. -pub fn password_values(doc: &Document) -> Vec<(String, String)> { - password_fields(doc).into_iter().filter_map(|f| f.value.first().map(|v| (f.name.clone(), v.clone()))).collect() -} - -/// Put values from [`password_values`] back into the open document, in memory only. A field -/// that is gone or read-only keeps what it has. -pub fn restore_password_values(doc: &mut Document, values: &[(String, String)]) -> Result<(), FormError> { - for (name, v) in values { - match set_value(doc, name, &FieldValue::Text(v.clone())) { - Ok(()) | Err(FormError::NoSuchField(_) | FormError::ReadOnly(_)) => {} - Err(e) => return Err(e), - } - } - Ok(()) -} - -/// `r` and its field ancestors (`/Parent`), nearest first, at most 64 deep and without repeats. -fn field_chain(doc: &Document, r: ObjRef) -> Vec { - let mut chain = vec![r]; - let mut cur = r; - while chain.len() < 64 { - let Some(Object::Ref(parent)) = doc.get(cur).as_dict().and_then(|d| d.get(b"Parent").cloned()) else { break }; - if chain.contains(&parent) { - break; - } - chain.push(parent); - cur = parent; - } - chain -} - -/// Remove the values of the text fields with the Password flag, for a copy of the document about -/// to be written to a file (#3926). ISO 32000-2 §12.7.5.3, Table 231, Password flag, note: "it is -/// imperative that PDF processors never store the value of the text field in the PDF file if -/// this flag is set". As Acrobat saves such a field (#3943 review, black-box test): a field with -/// a default value (`/DV`, which the form's author wrote) gets it back as its value, one without -/// is left empty, and its appearance is drawn again from that value, so the masking characters -/// no longer show how long the typed password was. `/DV` and the field's scripts stay. -/// -/// A value can sit on the field, on one of its widgets, or on an ancestor it inherits from -/// (`/V` is inheritable). Each is removed; an ancestor's only when every field inheriting from it -/// is such a password field. Where an ancestor's value is shared with other fields and stays, the -/// password field gets an empty value of its own so it no longer inherits it. Returns how many -/// fields were cleared. -pub fn clear_password_values(doc: &mut Document) -> Result { - let targets = password_fields(doc); - if targets.is_empty() { - return Ok(0); - } - let is_password = |f: &Field| f.kind == FieldKind::Text && f.has(flags::PASSWORD); - // Each field's ancestors, found once: only values change below, never `/Parent` (#3943 - // review: walking them again for every ancestor of every target was quadratic). - let chains: Vec<(bool, Vec)> = fields(doc).iter().map(|g| (is_password(g), field_chain(doc, g.obj))).collect(); - for f in &targets { - let chain = field_chain(doc, f.obj); - for w in &f.widgets { - if w.obj != f.obj { - doc.update_dict(w.obj, |d| { - d.remove(b"V"); - })?; - } - } - doc.update_dict(f.obj, |d| { - d.remove(b"V"); - })?; - let mut shared_value_kept = false; - for a in chain.iter().skip(1) { - if doc.get(*a).as_dict().is_none_or(|d| d.get(b"V").is_none()) { - continue; - } - let exclusive = chains.iter().filter(|(_, chain)| chain.contains(a)).all(|(password, _)| *password); - if exclusive { - doc.update_dict(*a, |d| { - d.remove(b"V"); - })?; - } else { - shared_value_kept = true; - } - } - let restored: Vec = f.default.first().cloned().into_iter().collect(); - match restored.first() { - Some(dv) => doc.update_dict(f.obj, |d| d.set(b"V".to_vec(), PdfString::text(dv)))?, - None if shared_value_kept => doc.update_dict(f.obj, |d| d.set(b"V".to_vec(), PdfString::text("")))?, - None => {} - } - redraw(doc, f, &restored, &mut NoScripts)?; - } - Ok(targets.len()) -} - -/// Whether the form asks viewers to draw its fields' appearances themselves (`/NeedAppearances`). -pub fn need_appearances(doc: &Document) -> bool { - acroform(doc).is_some_and(|af| matches!(af.get(b"NeedAppearances").map(|o| doc.resolve(o)).as_deref(), Some(Object::Bool(true)))) -} - -/// A copy of `doc` in which form fields are drawn the way the file leaves to the viewer, for -/// displaying the document (`None` when no widget needs it). Text and choice fields are drawn from -/// their values: every one when the form sets `/NeedAppearances` (ISO 32000-2 Table 224: the -/// writer left the appearances to the viewer), and otherwise each widget without a normal -/// appearance (`/AP /N`), as typing the value would draw it (#3470). Check boxes and radio buttons -/// with no normal appearance for their state (`/AS`) are drawn from their `/MK` background, border -/// and caption, and with `/NeedAppearances` every one that has an `/MK` is, over its own -/// appearance; push buttons with none are drawn from their `/MK` and `/BS` (Table 192). `doc` is -/// not changed: saving writes the file as it was. -pub fn with_field_appearances(doc: &Document) -> Option { - with_field_appearances_cached(doc, &mut FieldAppearanceCache::default()) -} - -/// [`with_field_appearances`], reusing the appearances `cache` drew for widgets whose inputs are -/// the same objects as then (see [`FieldAppearanceCache`]). -pub fn with_field_appearances_cached(doc: &Document, cache: &mut FieldAppearanceCache) -> Option { - let all = need_appearances(doc); - let mut copy: Option = None; - for f in fields(doc) { - for w in &f.widgets { - let widget = doc.get(w.obj); - let normal = widget - .as_dict() - .and_then(|d| d.get(b"AP")) - .and_then(|ap| doc.resolve(ap).as_dict().and_then(|ap| ap.get(b"N").map(|n| doc.resolve(n)))); - // A check box or radio button shows the appearance of its state (12.7.5.2.3): `/AS`, - // else the value when it names this widget's on state (any check box value when the - // widget names none), else Off. An Off state may go without one (it shows nothing); - // an on state needs one. - let state = match f.kind { - FieldKind::CheckBox | FieldKind::Radio => { - let mine = |v: &&String| w.on_state.as_ref().map_or(f.kind == FieldKind::CheckBox, |on| on == *v); - let value = f.value.first().filter(|v| !v.is_empty() && v.as_str() != "Off").filter(mine); - Some(w.state.clone().or_else(|| value.cloned()).unwrap_or_else(|| "Off".into())) - } - _ => None, - }; - // With /NeedAppearances a check box or radio button that describes its look in /MK is - // drawn from it (Table 192), its own appearance or not; one without /MK keeps its own. - let described = || widget.as_dict().and_then(|d| d.get(b"MK")).is_some_and(|mk| doc.resolve(mk).as_dict().is_some()); - let missing = match f.kind { - FieldKind::Text | FieldKind::Combo | FieldKind::List => normal.is_none() || all, - FieldKind::PushButton => normal.is_none(), - FieldKind::CheckBox | FieldKind::Radio => match (normal.as_deref(), state.as_deref()) { - _ if all && described() => true, - (None, _) => true, - (Some(Object::Dict(states)), Some(s)) => s != "Off" && !states.contains(&name_bytes(s)), - _ => false, - }, - FieldKind::Signature => false, - }; - if !missing { - continue; - } - let inputs = appearance_inputs(doc, w); - let stream = match cache.get(w.obj, &inputs) { - Some(stream) => stream, - None => { - let stream = match f.kind { - FieldKind::CheckBox | FieldKind::Radio => appearance::button_state_shown(doc, &f, w, state.as_deref() != Some("Off")), - FieldKind::PushButton => appearance::push_button_shown(doc, &f, w), - _ => appearance::field_appearance_drawn(doc, &f, w, &f.value, true).0, - }; - cache.insert(w.obj, inputs, &stream); - stream - } - }; - let target = copy.get_or_insert_with(|| doc.clone()); - let ap = target.add(Object::Stream(stream)); - let mut fresh = Dict::new(); - match &state { - // The drawn look under its state's name, and that state as the widget's own. - Some(s) => { - let mut states = Dict::new(); - states.set(name_bytes(s), Object::Ref(ap)); - fresh.set(b"N".to_vec(), Object::Dict(states)); - } - None => fresh.set(b"N".to_vec(), Object::Ref(ap)), - } - let apd = merged_appearance(target, w.obj, &fresh); - // Fails only when the widget isn't a dictionary; that widget then keeps its own look. - target - .update_dict(w.obj, |d| { - d.set(b"AP".to_vec(), Object::Dict(apd)); - if let Some(s) = &state { - d.set(b"AS".to_vec(), Object::Name(name_bytes(s))); - } - }) - .ok(); - } - } - copy -} - -/// The objects a widget's drawn appearance is built from, as the document holds them: the widget -/// and the fields above it (value, `/DA`, `/Q`, flags, options, `/MK`, formatting actions), the -/// form dictionary (`/DA`) and the fonts of its `/DR`, each with the objects its entries refer to -/// (an indirect `/MK` or `/V`). Not the page: a field's rotation is its own `/MK /R`. -fn appearance_inputs(doc: &Document, w: &Widget) -> Vec> { - // The objects a dictionary's entries refer to. The tree links and the drawn appearance itself - // are not inputs, and `/Kids` and `/Fields` can be long. - let referred = |d: &Dict, inputs: &mut Vec>| { - for (k, v) in d.iter() { - if !matches!(&k[..], b"Kids" | b"Fields" | b"P" | b"Parent" | b"AP") - && let Some(r) = v.as_ref() - { - inputs.push(doc.get(r)); - } - } - }; - let mut inputs = Vec::new(); - let mut seen = std::collections::HashSet::new(); - let mut node = Some(w.obj); - // The /Parent chain; the seen-set and the cap end a cycle or an absurd depth. - while let Some(r) = node.filter(|r| seen.insert(*r) && seen.len() <= 64) { - let obj = doc.get(r); - if let Some(d) = obj.as_dict() { - node = d.reference(b"Parent"); - referred(d, &mut inputs); - } else { - node = None; - } - inputs.push(obj); - } - let Some(root) = doc.root() else { return inputs }; - let catalog = doc.get(root); - let form = match catalog.as_dict().and_then(|c| c.reference(b"AcroForm")) { - Some(r) => doc.get(r), - None => catalog, - }; - if let Some(f) = form.as_dict() { - referred(f, &mut inputs); - // The fonts of /DR. An indirect /DR or /Font is listed through the entries that refer to - // it; a direct one is part of the object that holds it. `held` only finds them: a copy - // of a direct object is a new one each time, never an input. - let held = |o: &Object| o.as_ref().map_or_else(|| std::sync::Arc::new(o.clone()), |r| doc.get(r)); - if let Some(dr) = f.get(b"DR").map(held) - && let Some(dr) = dr.as_dict() - { - referred(dr, &mut inputs); - if let Some(font) = dr.get(b"Font").map(held) - && let Some(fonts) = font.as_dict() - { - referred(fonts, &mut inputs); - } - } - } - inputs.push(form); - inputs -} - -/// Field appearances drawn for the display copy ([`with_field_appearances_cached`]), so a refresh -/// redraws only the widgets whose inputs changed: a form that leaves every appearance to the -/// viewer otherwise redrew all of its fields on each keystroke in any one of them (#3470). An -/// entry holds the input objects it was drawn from and is used only while each is still the same -/// object (`Arc::ptr_eq`); the document replaces an object it edits, so a changed input is a -/// miss, never a stale hit. -#[derive(Clone, Debug, Default)] -pub struct FieldAppearanceCache { - entries: std::collections::HashMap>, pdfcraft_cos::Stream)>, - bytes: usize, - hits: u64, - builds: u64, -} - -impl FieldAppearanceCache { - /// Cached appearance data restarts past this instead of growing without bound. - const MAX_BYTES: usize = 64 << 20; - - /// Forget every entry. - pub fn clear(&mut self) { - *self = Self::default(); - } - - fn get(&mut self, w: ObjRef, inputs: &[std::sync::Arc]) -> Option { - let (from, stream) = self.entries.get(&w)?; - if from.len() != inputs.len() || !from.iter().zip(inputs).all(|(a, b)| std::sync::Arc::ptr_eq(a, b)) { - return None; - } - self.hits += 1; - Some(stream.clone()) - } - - fn insert(&mut self, w: ObjRef, inputs: Vec>, stream: &pdfcraft_cos::Stream) { - self.builds += 1; - if self.bytes.saturating_add(stream.raw.len()) > Self::MAX_BYTES { - self.clear(); - } - self.bytes = self.bytes.saturating_add(stream.raw.len()); - if let Some((_, old)) = self.entries.insert(w, (inputs, stream.clone())) { - self.bytes = self.bytes.saturating_sub(old.raw.len()); - } - } - - /// Cache hits so far (reused appearances); for tests. - pub fn hits(&self) -> u64 { - self.hits - } - - /// Appearances drawn so far; for tests. - pub fn builds(&self) -> u64 { - self.builds - } -} - -/// Whether the document's XMP metadata declares PDF/A conformance (`pdfaid:part`). -pub fn declares_pdfa(doc: &Document) -> bool { - let Some(root) = doc.root() else { return false }; - let Some(m) = doc.get(root).as_dict().and_then(|c| c.get(b"Metadata").cloned()) else { return false }; - let m = doc.resolve(&m); - match &*m { - Object::Stream(s) => s.decoded().is_ok_and(|b| b.windows(11).any(|w| w == b"pdfaid:part")), - _ => false, - } -} - -/// Every field with a widget whose appearance PdfCraft drew with `?` for a character its font -/// can't hold (`pdfcraft_fonts::UNDRAWN_MARK`): the field's name and that character, once per -/// field. -pub fn undrawn_fields(doc: &Document) -> Vec<(String, char)> { - fields(doc) - .into_iter() - .filter_map(|f| { - let c = f.widgets.iter().find_map(|w| doc.get(w.obj).as_dict().and_then(pdfcraft_fonts::undrawn_mark))?; - Some((f.name, c)) - }) - .collect() -} - -/// Acrobat's Clear form: every field (or the named ones) back to its default value. -pub fn reset(doc: &mut Document, names: Option<&[String]>) -> Result { - let all = fields(doc); - if all.is_empty() { - return Err(FormError::NoForm); - } - if let Some(n) = names - && let Some(missing) = n.iter().find(|n| !all.iter().any(|f| &f.name == *n)) - { - return Err(FormError::NoSuchField(missing.clone())); - } - let mut changed = 0; - for f in all.iter().filter(|f| names.is_none_or(|n| n.contains(&f.name))) { - if f.value == f.default { - continue; - } - let v = match f.kind { - FieldKind::Text => FieldValue::Text(f.default.first().cloned().unwrap_or_default()), - FieldKind::CheckBox => FieldValue::Check(!f.default.is_empty()), - FieldKind::Radio => FieldValue::Radio(f.default.first().cloned()), - FieldKind::Combo | FieldKind::List => FieldValue::Choice(f.default.clone()), - FieldKind::PushButton | FieldKind::Signature => continue, - }; - // A reset is not blocked by NoToggleToOff: go through the writer directly. - let mut tmp = f.clone(); - tmp.flags &= !flags::NO_TOGGLE_TO_OFF; - write_value(doc, &tmp, &v, &mut NoScripts)?; - changed += 1; - } - recalculate(doc)?; - Ok(changed) -} - -/// Order tabs manually (Acrobat: Fields ▸ Tab Order ▸ Order Tabs Manually, then drag a field): -/// move field `name` one place earlier or later in its page's tab order. The page switches to -/// annotation order (`/Tabs` removed) and its widgets are reordered in `/Annots`; other -/// annotations keep their places. -pub fn move_in_tab_order(doc: &mut Document, name: &str, earlier: bool) -> Result<(), FormError> { - let all = fields(doc); - let f = all.iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?; - let page = f.widgets.iter().filter_map(|w| w.page).min().ok_or_else(|| FormError::Invalid(format!("{name} is not on a page")))?; - // The page's fields in their current tab order (a field counts once, at its first widget). - let mut order: Vec<(usize, &str)> = - all.iter().filter_map(|g| g.widgets.iter().filter(|w| w.page == Some(page)).map(|w| w.tab).min().map(|t| (t, g.name.as_str()))).collect(); - order.sort(); - let names: Vec<&str> = order.iter().map(|(_, n)| *n).collect(); - let Some(i) = names.iter().position(|n| *n == name) else { return invalid(format!("{name} is not on a page")) }; - let j = if earlier { i.checked_sub(1) } else { Some(i + 1).filter(|j| *j < names.len()) }; - let Some(j) = j else { return Ok(()) }; - let mut names = names.into_iter().map(str::to_string).collect::>(); - names.swap(i, j); - // The widgets of this page in the new order. - let mut widgets: Vec = Vec::new(); - for n in &names { - if let Some(g) = all.iter().find(|g| &g.name == n) { - widgets.extend(g.widgets.iter().filter(|w| w.page == Some(page)).map(|w| w.obj)); - } - } - let pr = *page_refs(doc).get(page).ok_or_else(|| FormError::Invalid("no such page".into()))?; - let annots_obj = - doc.get(pr).as_dict().and_then(|d| d.get(b"Annots").cloned()).ok_or_else(|| FormError::Invalid("the page has no annotations".into()))?; - let list = doc.resolve(&annots_obj).as_array().cloned().unwrap_or_default(); - let mut next = widgets.into_iter(); - let set: std::collections::HashSet = all.iter().flat_map(|g| g.widgets.iter().filter(|w| w.page == Some(page)).map(|w| w.obj)).collect(); - let new_list: Vec = list - .iter() - .map(|o| match o.as_ref() { - Some(r) if set.contains(&r) => next.next().map(Object::Ref).unwrap_or_else(|| o.clone()), - _ => o.clone(), - }) - .collect(); - match annots_obj { - Object::Ref(ar) => doc.set(ar, Object::Array(new_list)), - _ => doc.update_dict(pr, |d| d.set(b"Annots".to_vec(), Object::Array(new_list)))?, - } - doc.update_dict(pr, |d| { - d.remove(b"Tabs"); - })?; - Ok(()) -} diff --git a/crates/forms/src/read.rs b/crates/forms/src/read.rs new file mode 100644 index 0000000000..e689c0e813 --- /dev/null +++ b/crates/forms/src/read.rs @@ -0,0 +1,568 @@ +use super::tab_order::rank_tabs; +use super::{DecodeBudget, Dict, Document, Field, FieldKind, ObjRef, Object, PdfString, Widget, af, appearance, flags, name_text, text_of}; + +/// At most this many `/State` entries of a set-layer-visibility action are read. +const MAX_LAYER_STATE: usize = 1024; + +/// A push button's mouse-up action (`/A`, or `/AA /U`, on the field or its widget). +fn button_action(doc: &Document, d: &Dict, widgets: &[ObjRef], scripts: &mut DecodeBudget) -> Option { + // A button PdfCraft generated for an XFA image field (`/PCXfaImage true`, no PDF action): + // clicking it picks a picture, as `buttonImportIcon()` does. + if matches!(d.get(b"PCXfaImage").map(|o| doc.resolve(o)).as_deref(), Some(Object::Bool(true))) { + return Some(af::ButtonAction::ImportIcon); + } + let pick = |dict: &Dict| -> Option { + dict.get(b"A").cloned().or_else(|| dict.get(b"AA").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().and_then(|aa| aa.get(b"U").cloned()))) + }; + let action = pick(d).or_else(|| widgets.iter().find_map(|w| doc.get(*w).as_dict().and_then(pick)))?; + let a = doc.resolve(&action); + let a = a.as_dict()?; + let s = |k: &[u8]| a.get(k).and_then(|v| text_of(&doc.resolve(v))); + Some(match a.name(b"S")? { + b"ResetForm" => { + let fields = a + .get(b"Fields") + .map(|f| doc.resolve(f)) + .and_then(|f| f.as_array().cloned()) + .unwrap_or_default() + .iter() + .filter_map(|o| match o { + Object::Ref(r) => doc.get(*r).as_dict().and_then(|fd| fd.get(b"T").and_then(|t| text_of(&doc.resolve(t)))), + other => text_of(&doc.resolve(other)), + }) + .collect(); + let exclude = a.get(b"Flags").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0) & 1 != 0; + af::ButtonAction::Reset { fields, exclude } + } + b"Named" => af::ButtonAction::Named(String::from_utf8_lossy(a.name(b"N")?).into_owned()), + b"URI" => af::ButtonAction::Uri(s(b"URI")?), + b"SubmitForm" => af::ButtonAction::Submit( + a.get(b"F") + .map(|f| doc.resolve(f)) + .and_then(|f| f.as_dict().and_then(|fd| fd.get(b"F").and_then(|x| text_of(&doc.resolve(x)))).or_else(|| text_of(&f))) + .unwrap_or_default(), + ), + b"GoTo" => { + let dest = doc.resolve(a.get(b"D")?); + let target = dest.as_array()?.first()?.as_ref()?; + af::ButtonAction::GoTo(page_refs(doc).iter().position(|p| *p == target)?) + } + b"Hide" => { + // /T: a field name, an annotation (or field) reference, or an array of them. + let target = |o: &Object| match o { + Object::Ref(r) => qualified_name(doc, *r).or_else(|| text_of(&doc.resolve(o))), + other => text_of(other), + }; + let fields = match a.get(b"T") { + Some(t) => match &*doc.resolve(t) { + Object::Array(items) => items.iter().filter_map(target).collect(), + _ => target(t).into_iter().collect(), + }, + None => Vec::new(), + }; + let hide = !a.get(b"H").is_some_and(|h| matches!(&*doc.resolve(h), Object::Bool(false))); + af::ButtonAction::ShowHide { fields, hide } + } + b"SetOCGState" => { + // /State: ON, OFF or Toggle, each followed by the groups it applies to. + let mut changes = Vec::new(); + if let Some(state) = a.get(b"State") { + let mut op = None; + for item in doc.resolve(state).as_array().into_iter().flatten().take(MAX_LAYER_STATE) { + match item { + Object::Name(n) => { + op = match n.as_slice() { + b"ON" => Some(af::LayerOp::On), + b"OFF" => Some(af::LayerOp::Off), + b"Toggle" => Some(af::LayerOp::Toggle), + _ => None, + } + } + Object::Ref(r) => changes.extend(op.map(|op| (op, (r.num, r.generation)))), + _ => {} + } + } + } + let preserve_rb = !a.get(b"PreserveRB").is_some_and(|p| matches!(&*doc.resolve(p), Object::Bool(false))); + af::ButtonAction::SetLayers { changes, preserve_rb } + } + b"JavaScript" => af::button_script(&script(doc, &action, scripts)?), + _ => return None, + }) +} + +/// The fully qualified name of the field that `r` (a field or one of its widgets) belongs to, +/// from the partial names (`/T`) up its `/Parent` chain. +fn qualified_name(doc: &Document, r: ObjRef) -> Option { + let mut parts = Vec::new(); + let mut seen = std::collections::HashSet::new(); + let mut cur = Some(r); + while let Some(c) = cur { + if seen.len() > 64 || !seen.insert(c) { + break; + } + let obj = doc.get(c); + let d = obj.as_dict()?; + if let Some(t) = d.get(b"T").and_then(|o| text_of(&doc.resolve(o))) { + parts.push(t); + } + cur = d.get(b"Parent").and_then(|p| p.as_ref()); + } + parts.reverse(); + (!parts.is_empty()).then(|| parts.join(".")) +} + +/// JavaScript streams one reading of the form (its fields, or its document scripts) may decode +/// in all, however many actions name the same stream. +pub(crate) const MAX_SCRIPT_BYTES: usize = 64 << 20; + +/// A budget for the script streams of one reading of the form. +pub(crate) fn script_budget() -> DecodeBudget { + DecodeBudget::new(MAX_SCRIPT_BYTES, 0) +} + +/// The JavaScript of an action dictionary (`/JS` string or stream). A stream is decoded against +/// `budget`, and is `None` once it is spent. +pub(crate) fn script(doc: &Document, action: &Object, budget: &mut DecodeBudget) -> Option { + let a = doc.resolve(action); + let js = a.as_dict()?.get(b"JS")?.clone(); + match &*doc.resolve(&js) { + Object::String(s) => Some(s.to_text()), + Object::Stream(s) => budget.decode(s).ok().map(|b| String::from_utf8_lossy(&b).into_owned()), + _ => None, + } +} + +/// A field's format/keystroke, validate and calculate scripts (field dictionary, or its only +/// widget's when the field has none). +fn actions_of(doc: &Document, d: &Dict, widgets: &[ObjRef], scripts: &mut DecodeBudget) -> af::Actions { + let aa = d.get(b"AA").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().cloned()).or_else(|| { + (widgets.len() == 1) + .then(|| doc.get(widgets[0])) + .and_then(|w| w.as_dict().and_then(|wd| wd.get(b"AA").map(|a| doc.resolve(a)))) + .and_then(|a| a.as_dict().cloned()) + }); + let mut out = af::Actions::default(); + let Some(aa) = aa else { return out }; + let mut js = |k: &[u8]| aa.get(k).and_then(|a| script(doc, a, scripts)); + if let Some(f) = js(b"F") { + match af::parse_format(&f) { + Some(fm) => out.format = fm, + None if !f.trim().is_empty() => { + out.unsupported.push("format"); + out.scripts.format = Some(f); + } + None => {} + } + } + if let Some(k) = js(b"K") + && out.format == af::Format::None + { + // A mask has only a keystroke script. + match af::parse_format(&k) { + Some(fm @ af::Format::Mask(_)) => out.format = fm, + Some(_) => {} + None if !k.trim().is_empty() => { + out.unsupported.push("keystroke"); + out.scripts.keystroke = Some(k); + } + None => {} + } + } + if let Some(v) = js(b"V") { + match af::parse_validate(&v) { + Some(vv) => out.validate = vv, + None if !v.trim().is_empty() => { + out.unsupported.push("validate"); + out.scripts.validate = Some(v); + } + None => {} + } + } + if let Some(c) = js(b"C") { + match af::parse_calculate(&c) { + Some(cc) => out.calculate = cc, + None if !c.trim().is_empty() => { + out.unsupported.push("calculate"); + out.scripts.calculate = Some(c); + } + None => {} + } + } + out +} + +fn values_of(doc: &Document, o: Option<&Object>) -> Vec { + let Some(o) = o else { return Vec::new() }; + match &*doc.resolve(o) { + Object::Array(a) => a.iter().filter_map(|x| text_of(&doc.resolve(x))).collect(), + Object::Stream(s) => s.decoded().ok().map(|b| PdfString::literal(b).to_text()).into_iter().collect(), + other => text_of(other).filter(|s| s != "Off" && !s.is_empty()).into_iter().collect(), + } +} + +fn nums(doc: &Document, o: Option<&Object>) -> Option> { + let o = doc.resolve(o?); + o.as_array()?.iter().map(|x| doc.resolve(x).as_f64()).collect() +} + +/// Leaf page objects in order (a small walker; `organize` and `annot` have their own). +pub(crate) fn page_refs(doc: &Document) -> Vec { + let Some(root) = doc.root() else { return Vec::new() }; + let Some(pages) = doc.get(root).as_dict().and_then(|d| d.reference(b"Pages")) else { return Vec::new() }; + PageWalk::new(doc, pages).collect() +} + +/// Depth-first walk over the leaf pages under `top`. It keeps one cursor per open `/Kids` array, +/// never one entry per child, so a child listed many times or shared between nodes is skipped by +/// the seen check without being queued first (`pdfcraft_model::pages` walks the same way). +/// Cycles end at the seen check and at most a million nodes are visited. +pub(crate) struct PageWalk<'a> { + doc: &'a Document, + seen: std::collections::HashSet, + pub(crate) pending: Vec<(std::sync::Arc, usize)>, +} + +impl<'a> PageWalk<'a> { + pub(crate) fn new(doc: &'a Document, top: ObjRef) -> Self { + let top = std::sync::Arc::new(Object::Array(vec![Object::Ref(top)])); + Self { doc, seen: Default::default(), pending: vec![(top, 0)] } + } +} + +impl Iterator for PageWalk<'_> { + type Item = ObjRef; + + fn next(&mut self) -> Option { + while let Some((kids, next)) = self.pending.last_mut() { + let Some(kid) = kids.as_array().and_then(|a| a.get(*next)) else { + self.pending.pop(); + continue; + }; + let node = kid.as_ref(); + *next += 1; + let Some(node) = node else { continue }; + if self.seen.len() >= 1_000_000 { + self.pending.clear(); + return None; + } + if !self.seen.insert(node) { + continue; + } + let obj = self.doc.get(node); + let Some(d) = obj.as_dict() else { continue }; + match d.get(b"Kids").map(|k| self.doc.resolve(k)) { + Some(kids) if d.name(b"Type") != Some(b"Page") => self.pending.push((kids, 0)), + _ => return Some(node), + } + } + None + } +} + +/// The AcroForm dictionary. +pub fn acroform(doc: &Document) -> Option { + let root = doc.root()?; + let cat = doc.get(root); + let af = cat.as_dict()?.get(b"AcroForm")?.clone(); + doc.resolve(&af).as_dict().cloned() +} + +#[derive(Clone, Default)] +struct Inherited { + name: String, + ft: Option>, + ff: Option, + v: Option, + dv: Option, + da: Option, + q: Option, + max_len: Option, +} + +/// Every terminal field: the `/Fields` tree in order, then the fields reachable only through the +/// page annotations (see [`adopt_page_fields`]). +pub fn fields(doc: &Document) -> Vec { + fields_with_adopted(doc).items +} + +/// How many of [`fields`] were reachable only through the page annotations: the form's `/Fields` +/// list names none or only some of them, so the leniency that adopts them is worth recording. +pub fn adopted_page_fields(doc: &Document) -> usize { + fields_with_adopted(doc).adopted_page_fields +} + +/// The terminal fields and page-only fields adopted while reading a form. +#[derive(Clone, Debug, PartialEq)] +pub struct Fields { + /// Terminal fields in tree order, followed by adopted page-only fields. + pub items: Vec, + /// How many fields were reachable only through page annotations. + pub adopted_page_fields: usize, +} + +/// Read all terminal fields and the page-only adoption count in one enumeration. +pub fn fields_with_adopted(doc: &Document) -> Fields { + let (items, adopted_page_fields) = enumerate(doc); + Fields { items, adopted_page_fields } +} + +#[cfg(test)] +thread_local! { + /// Whole-form readings ([`enumerate`]) on this thread. + static FORM_READS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// How many times this thread has read the whole form. +#[cfg(test)] +pub(crate) fn form_reads() -> usize { + FORM_READS.with(std::cell::Cell::get) +} + +pub(crate) fn enumerate(doc: &Document) -> (Vec, usize) { + #[cfg(test)] + FORM_READS.with(|n| n.set(n.get() + 1)); + let af = acroform(doc); + let mut page_of = std::collections::HashMap::new(); + let mut annot_index = std::collections::HashMap::new(); + let pages = page_refs(doc); + for (i, p) in pages.iter().enumerate() { + if let Some(a) = doc.get(*p).as_dict().and_then(|d| d.get(b"Annots").cloned()) { + for (k, e) in doc.resolve(&a).as_array().into_iter().flatten().enumerate() { + if let Some(r) = e.as_ref() { + page_of.entry(r).or_insert(i); + annot_index.entry(r).or_insert(k); + } + } + } + } + let base = match &af { + Some(af) => Inherited { + da: af.get(b"DA").and_then(|o| text_of(&doc.resolve(o))), + q: af.get(b"Q").and_then(|o| doc.resolve(o).as_int()), + ..Default::default() + }, + None => Inherited::default(), + }; + let mut out = Vec::new(); + let mut seen = Seen { fields: std::collections::HashSet::new(), widgets: std::collections::HashSet::new(), scripts: script_budget() }; + if let Some(af) = &af { + for f in af.get(b"Fields").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default() { + if let Some(r) = f.as_ref() { + walk(doc, r, &base, &page_of, &mut seen, &mut out, 0); + } + } + } + let listed = out.len(); + adopt_page_fields(doc, &pages, &base, &page_of, &mut seen, &mut out); + let adopted = out.len() - listed; + rank_tabs(doc, &pages, &annot_index, &mut out); + (out, adopted) +} + +/// Widget annotations no `/Fields` entry reaches are adopted as fields, as Acrobat and the +/// browsers do: some writers list only some of the fields, or none at all. Each widget is adopted +/// through its topmost unlisted `/Parent`, so a field split across several widgets stays one +/// field. Tree-listed fields keep their order first; the rest follow in page-annotation order. +fn adopt_page_fields( + doc: &Document, + pages: &[ObjRef], + base: &Inherited, + page_of: &std::collections::HashMap, + seen: &mut Seen, + out: &mut Vec, +) { + for p in pages { + let Some(a) = doc.get(*p).as_dict().and_then(|d| d.get(b"Annots").cloned()) else { continue }; + let annots = doc.resolve(&a); + let Some(annots) = annots.as_array() else { continue }; + for e in annots { + let Some(r) = e.as_ref() else { continue }; + if seen.fields.contains(&r) || seen.widgets.contains(&r) { + continue; + } + let obj = doc.get(r); + let Some(d) = obj.as_dict() else { continue }; + if d.name(b"Subtype") != Some(b"Widget") { + continue; + } + if !d.contains(b"FT") && !d.contains(b"T") && !d.contains(b"Parent") { + continue; + } + if let Some(root) = unlisted_root(doc, r, &seen.fields) { + walk(doc, root, base, page_of, seen, out, 0); + } + } + } +} + +/// The topmost field above `r` (itself without a `/Parent`), or `None` when `r` or an ancestor is +/// already reachable from the `/Fields` tree, or a `/Parent` loop hides the top. +fn unlisted_root(doc: &Document, r: ObjRef, seen: &std::collections::HashSet) -> Option { + let mut top = r; + let mut visited = std::collections::HashSet::from([r]); + loop { + let parent = doc.get(top).as_dict().and_then(|d| d.reference(b"Parent")); + let Some(p) = parent else { + return if seen.contains(&top) { None } else { Some(top) }; + }; + if !visited.insert(p) || visited.len() > 64 { + return None; + } + if seen.contains(&p) { + return None; + } + top = p; + } +} + +/// What one reading of the form's fields has used: the field and widget dictionaries read, and +/// the script budget their actions share. +struct Seen { + fields: std::collections::HashSet, + /// A widget belongs to the first field that lists it, however often it is listed. + widgets: std::collections::HashSet, + scripts: DecodeBudget, +} + +/// Read the field at `r` and the fields under it into `out`, with what they inherit from +/// `parent`: kids with a name are fields of their own, kids without are this field's widgets. +fn walk( + doc: &Document, + r: ObjRef, + parent: &Inherited, + page_of: &std::collections::HashMap, + seen: &mut Seen, + out: &mut Vec, + depth: usize, +) { + if depth > 64 || !seen.fields.insert(r) { + return; + } + let obj = doc.get(r); + let Some(d) = obj.as_dict() else { return }; + let mut inh = parent.clone(); + if let Some(t) = d.get(b"T").and_then(|o| text_of(&doc.resolve(o))) { + inh.name = if inh.name.is_empty() { t } else { format!("{}.{t}", inh.name) }; + } + if let Some(ft) = d.name(b"FT") { + inh.ft = Some(ft.to_vec()); + } + if let Some(ff) = field_flags(doc, d) { + inh.ff = Some(ff); + } + for (k, slot) in [(&b"V"[..], &mut inh.v), (b"DV", &mut inh.dv)] { + if let Some(v) = d.get(k) { + *slot = Some(v.clone()); + } + } + if let Some(da) = d.get(b"DA").and_then(|o| text_of(&doc.resolve(o))) { + inh.da = Some(da); + } + if let Some(q) = d.get(b"Q").and_then(|o| doc.resolve(o).as_int()) { + inh.q = Some(q); + } + if let Some(m) = d.get(b"MaxLen").and_then(|o| doc.resolve(o).as_int()) { + // Compatibility: some writers emit zero for an unlimited text field. Keep /MaxLen + // untouched in the object graph, but expose only positive limits to both UI and API. + // An explicit zero also clears an inherited limit rather than inheriting it again. + inh.max_len = usize::try_from(m).ok().filter(|limit| *limit > 0); + } + let kids: Vec = + d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_default().iter().filter_map(|k| k.as_ref()).collect(); + // Kids with /T are fields; kids without are this field's widgets. + let field_kids: Vec = kids.iter().copied().filter(|k| doc.get(*k).as_dict().is_some_and(|kd| kd.contains(b"T"))).collect(); + if !field_kids.is_empty() { + for k in field_kids { + walk(doc, k, &inh, page_of, seen, out, depth + 1); + } + return; + } + // A field of no kind this reads is skipped before it claims widgets or scripts, which a later + // field listing them then reads. + let ff = inh.ff.unwrap_or(0); + let kind = match inh.ft.as_deref() { + Some(b"Tx") => FieldKind::Text, + Some(b"Btn") if ff & flags::PUSH_BUTTON != 0 => FieldKind::PushButton, + Some(b"Btn") if ff & flags::RADIO != 0 => FieldKind::Radio, + Some(b"Btn") => FieldKind::CheckBox, + Some(b"Ch") if ff & flags::COMBO != 0 => FieldKind::Combo, + Some(b"Ch") => FieldKind::List, + Some(b"Sig") => FieldKind::Signature, + _ => return, + }; + let widget_refs: Vec = if d.contains(b"Rect") { vec![r] } else { kids }; + let widget_refs: Vec = widget_refs.into_iter().filter(|w| seen.widgets.insert(*w)).collect(); + let actions = actions_of(doc, d, &widget_refs, &mut seen.scripts); + let button = (inh.ft.as_deref() == Some(b"Btn") && inh.ff.unwrap_or(0) & flags::PUSH_BUTTON != 0) + .then(|| button_action(doc, d, &widget_refs, &mut seen.scripts)) + .flatten(); + let widgets = widget_refs + .into_iter() + .filter_map(|w| { + let wo = doc.get(w); + let wd = wo.as_dict()?; + let rect = nums(doc, wd.get(b"Rect")).filter(|r| r.len() == 4).unwrap_or_else(|| vec![0.0; 4]); + let annot_flags = wd.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0); + let own_ff = if w == r { 0 } else { field_flags(doc, wd).unwrap_or(0) }; + let on_state = wd + .get(b"AP") + .map(|ap| doc.resolve(ap)) + .and_then(|ap| ap.as_dict().and_then(|a| a.get(b"N").cloned())) + .and_then(|n| doc.resolve(&n).as_dict().cloned()) + .and_then(|n| n.iter().map(|(k, _)| name_text(k)).find(|k| k != "Off")); + Some(Widget { + obj: w, + page: page_of.get(&w).copied(), + rect: [rect[0].min(rect[2]), rect[1].min(rect[3]), rect[0].max(rect[2]), rect[1].max(rect[3])], + on_state: on_state.filter(|_| matches!(kind, FieldKind::CheckBox | FieldKind::Radio)), + state: wd.name(b"AS").map(name_text), + tab: usize::MAX, + locked: annot_flags & 128 != 0, + hidden: annot_flags & (2 | 32) != 0, + read_only: (ff | own_ff) & flags::READ_ONLY != 0, + rotation: appearance::mk_rotation(doc, wd), + caption: (kind == FieldKind::PushButton) + .then(|| { + let mk = doc.resolve(wd.get(b"MK")?); + let value = doc.resolve(mk.as_dict()?.get(b"CA")?); + value.as_string().map(|s| s.to_text()) + }) + .flatten(), + list: (kind == FieldKind::List).then(|| appearance::list_rows_of(doc, inh.da.as_deref().unwrap_or("/Helv 0 Tf 0 g"), Some(d), wd)), + round: kind == FieldKind::Radio || (kind == FieldKind::CheckBox && appearance::is_round(doc, wd)), + }) + }) + .collect(); + let options = d + .get(b"Opt") + .map(|o| doc.resolve(o)) + .and_then(|o| o.as_array().cloned()) + .unwrap_or_default() + .iter() + .filter_map(|o| match &*doc.resolve(o) { + Object::Array(pair) if pair.len() >= 2 => Some((text_of(&doc.resolve(&pair[0]))?, text_of(&doc.resolve(&pair[1]))?)), + other => text_of(other).map(|t| (t.clone(), t)), + }) + .collect(); + out.push(Field { + name: inh.name.clone(), + obj: r, + kind, + value: values_of(doc, inh.v.as_ref()), + default: values_of(doc, inh.dv.as_ref()), + flags: ff, + max_len: inh.max_len, + options, + da: inh.da.clone().unwrap_or_else(|| "/Helv 0 Tf 0 g".into()), + quadding: inh.q.unwrap_or(0), + tooltip: d.get(b"TU").and_then(|o| text_of(&doc.resolve(o))), + widgets, + actions, + button, + }); +} + +pub(crate) fn field_flags(doc: &Document, dict: &Dict) -> Option { + dict.get(b"Ff").and_then(|value| doc.resolve(value).as_int()).and_then(|value| u32::try_from(value).ok()) +} diff --git a/crates/forms/src/tab_order.rs b/crates/forms/src/tab_order.rs new file mode 100644 index 0000000000..77234ee094 --- /dev/null +++ b/crates/forms/src/tab_order.rs @@ -0,0 +1,153 @@ +use super::read::{fields, page_refs}; +use super::{Document, Field, FormError, ObjRef, Object, invalid}; + +/// A page's tab order (`/Tabs`). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TabOrder { + /// Rows, top to bottom, left to right within a row (`/R`). + Row, + /// Columns, left to right, top to bottom within a column (`/C`). + Column, + /// The document structure (`/S`; annotation order when the file has none). + Structure, + /// Unspecified: annotation order. + Annotations, +} + +impl TabOrder { + fn of(name: Option<&[u8]>) -> TabOrder { + match name { + Some(b"R") => TabOrder::Row, + Some(b"C") => TabOrder::Column, + Some(b"S") => TabOrder::Structure, + _ => TabOrder::Annotations, + } + } +} + +/// A widget on a page for tab ordering: (page, field, widget, rect, annotation index). +type TabItem = (usize, usize, usize, [f64; 4], usize); + +/// Every widget's place in the tab order. +pub(crate) fn rank_tabs(doc: &Document, pages: &[ObjRef], annot_index: &std::collections::HashMap, out: &mut [Field]) { + let mut items: Vec = Vec::new(); + for (fi, f) in out.iter().enumerate() { + for (wi, w) in f.widgets.iter().enumerate() { + if let Some(p) = w.page { + items.push((p, fi, wi, w.rect, annot_index.get(&w.obj).copied().unwrap_or(usize::MAX))); + } + } + } + let mut ordered: Vec<(usize, usize)> = Vec::with_capacity(items.len()); + for (pi, p) in pages.iter().enumerate() { + let mut on: Vec<_> = items.iter().filter(|i| i.0 == pi).collect(); + if on.is_empty() { + continue; + } + let tabs = TabOrder::of(doc.get(*p).as_dict().and_then(|d| d.name(b"Tabs"))); + match tabs { + TabOrder::Row | TabOrder::Column => { + let row = tabs == TabOrder::Row; + // Primary axis: rows by top edge (down the page), columns by left edge. + on.sort_by(|a, b| if row { b.3[3].total_cmp(&a.3[3]) } else { a.3[0].total_cmp(&b.3[0]) }); + let mut groups: Vec> = Vec::new(); + for it in on { + let fits = groups.last().is_some_and(|g| { + let first = g[0]; + if row { + (first.3[3] - it.3[3]).abs() < ((first.3[3] - first.3[1]) / 2.0).max(2.0) + } else { + (it.3[0] - first.3[0]).abs() < ((first.3[2] - first.3[0]) / 2.0).max(2.0) + } + }); + if fits && let Some(g) = groups.last_mut() { + g.push(it); + } else { + groups.push(vec![it]); + } + } + for mut g in groups { + g.sort_by(|a, b| if row { a.3[0].total_cmp(&b.3[0]) } else { b.3[3].total_cmp(&a.3[3]) }); + ordered.extend(g.iter().map(|i| (i.1, i.2))); + } + } + TabOrder::Structure | TabOrder::Annotations => { + on.sort_by_key(|i| i.4); + ordered.extend(on.iter().map(|i| (i.1, i.2))); + } + } + } + for f in out.iter_mut() { + for w in &mut f.widgets { + w.tab = usize::MAX; + } + } + for (rank, (fi, wi)) in ordered.into_iter().enumerate() { + out[fi].widgets[wi].tab = rank; + } +} + +/// Set the tab order of pages (0-based). +pub fn set_tab_order(doc: &mut Document, pages: &[usize], order: TabOrder) -> Result<(), FormError> { + let refs = page_refs(doc); + for &p in pages { + let r = *refs.get(p).ok_or_else(|| FormError::Invalid(format!("page {} does not exist", p + 1)))?; + doc.update_dict(r, |d| match order { + TabOrder::Row => d.set(b"Tabs".to_vec(), Object::name("R")), + TabOrder::Column => d.set(b"Tabs".to_vec(), Object::name("C")), + TabOrder::Structure => d.set(b"Tabs".to_vec(), Object::name("S")), + TabOrder::Annotations => { + d.remove(b"Tabs"); + } + })?; + } + Ok(()) +} + +/// Order tabs manually (Acrobat: Fields ▸ Tab Order ▸ Order Tabs Manually, then drag a field): +/// move field `name` one place earlier or later in its page's tab order. The page switches to +/// annotation order (`/Tabs` removed) and its widgets are reordered in `/Annots`; other +/// annotations keep their places. +pub fn move_in_tab_order(doc: &mut Document, name: &str, earlier: bool) -> Result<(), FormError> { + let all = fields(doc); + let f = all.iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?; + let page = f.widgets.iter().filter_map(|w| w.page).min().ok_or_else(|| FormError::Invalid(format!("{name} is not on a page")))?; + // The page's fields in their current tab order (a field counts once, at its first widget). + let mut order: Vec<(usize, &str)> = + all.iter().filter_map(|g| g.widgets.iter().filter(|w| w.page == Some(page)).map(|w| w.tab).min().map(|t| (t, g.name.as_str()))).collect(); + order.sort(); + let names: Vec<&str> = order.iter().map(|(_, n)| *n).collect(); + let Some(i) = names.iter().position(|n| *n == name) else { return invalid(format!("{name} is not on a page")) }; + let j = if earlier { i.checked_sub(1) } else { Some(i + 1).filter(|j| *j < names.len()) }; + let Some(j) = j else { return Ok(()) }; + let mut names = names.into_iter().map(str::to_string).collect::>(); + names.swap(i, j); + // The widgets of this page in the new order. + let mut widgets: Vec = Vec::new(); + for n in &names { + if let Some(g) = all.iter().find(|g| &g.name == n) { + widgets.extend(g.widgets.iter().filter(|w| w.page == Some(page)).map(|w| w.obj)); + } + } + let pr = *page_refs(doc).get(page).ok_or_else(|| FormError::Invalid("no such page".into()))?; + let annots_obj = + doc.get(pr).as_dict().and_then(|d| d.get(b"Annots").cloned()).ok_or_else(|| FormError::Invalid("the page has no annotations".into()))?; + let list = doc.resolve(&annots_obj).as_array().cloned().unwrap_or_default(); + let mut next = widgets.into_iter(); + let set: std::collections::HashSet = all.iter().flat_map(|g| g.widgets.iter().filter(|w| w.page == Some(page)).map(|w| w.obj)).collect(); + let new_list: Vec = list + .iter() + .map(|o| match o.as_ref() { + Some(r) if set.contains(&r) => next.next().map(Object::Ref).unwrap_or_else(|| o.clone()), + _ => o.clone(), + }) + .collect(); + match annots_obj { + Object::Ref(ar) => doc.set(ar, Object::Array(new_list)), + _ => doc.update_dict(pr, |d| d.set(b"Annots".to_vec(), Object::Array(new_list)))?, + } + doc.update_dict(pr, |d| { + d.remove(b"Tabs"); + })?; + Ok(()) +} diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index 61dab131e0..d8199f15d3 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -3601,13 +3601,17 @@ fn dotted_field_names_are_made_with_parent_fields() { }) .unwrap(); + invalid_dotted_field_names_are_rejected(&mut doc, &text); +} + +fn invalid_dotted_field_names_are_rejected(doc: &mut Document, text: &NewField) { for bad in ["MASTER", "MASTER.street1.x", "MASTER..x", ".x", "x.", "MASTER. "] { - assert!(add_field(&mut doc, 0, [300.0, 200.0, 450.0, 220.0], &text, Some(bad)).is_err(), "{bad:?}"); - assert!(set_props(&mut doc, "name", &FieldProps { name: Some(bad.into()), ..FieldProps::default() }).is_err(), "{bad:?}"); + assert!(add_field(doc, 0, [300.0, 200.0, 450.0, 220.0], text, Some(bad)).is_err(), "{bad:?}"); + assert!(set_props(doc, "name", &FieldProps { name: Some(bad.into()), ..FieldProps::default() }).is_err(), "{bad:?}"); } let deep = vec!["p"; 33].join("."); - assert!(add_field(&mut doc, 0, [300.0, 200.0, 450.0, 220.0], &text, Some(&deep)).is_err(), "too deep"); - assert!(set_props(&mut doc, "name", &FieldProps { name: Some("MASTER.street1".into()), ..FieldProps::default() }).is_err(), "taken"); + assert!(add_field(doc, 0, [300.0, 200.0, 450.0, 220.0], text, Some(&deep)).is_err(), "too deep"); + assert!(set_props(doc, "name", &FieldProps { name: Some("MASTER.street1".into()), ..FieldProps::default() }).is_err(), "taken"); } /// Review on #2957: a parent left empty by a move keeps its name from the next default name @@ -3801,90 +3805,17 @@ fn existing_form_fonts_are_kept_and_an_unchanged_resource_dictionary_is_not_rewr /// - Helv, TiRo and Cour are there. #[test] fn every_shape_of_form_fonts_ends_as_objects() { - let font = |base: &str| format!("<< /Type /Font /Subtype /Type1 /BaseFont /{base} >>"); let mut cases = 0; for dr_shape in ["missing", "inline", "object"] { for font_shape in ["missing", "inline", "object", "number"] { for helv in ["missing", "inline", "object", "dangling", "null", "number"] { for mine in ["missing", "inline", "object"] { - if dr_shape == "missing" && (font_shape != "missing" || helv != "missing" || mine != "missing") { - continue; - } - if matches!(font_shape, "missing" | "number") && (helv != "missing" || mine != "missing") { + let case = FontShapeCase::new(dr_shape, font_shape, helv, mine); + if !case.is_valid() { continue; } cases += 1; - let what = format!("/DR {dr_shape}, /Font {font_shape}, /Helv {helv}, /Mine {mine}"); - let mut entries = String::new(); - match helv { - "inline" => entries += &format!("/Helv {} ", font("Arial")), - "object" => entries += "/Helv 7 0 R ", - "dangling" => entries += "/Helv 99 0 R ", - "null" => entries += "/Helv null ", - "number" => entries += "/Helv 3 ", - _ => {} - } - match mine { - "inline" => entries += &format!("/Mine {} ", font("Symbol")), - "object" => entries += "/Mine 8 0 R ", - _ => {} - } - let fonts_value = match font_shape { - "inline" => format!("<< {entries}>>"), - "object" => "6 0 R".into(), - "number" => "7".into(), - _ => String::new(), - }; - let dr_dict = if fonts_value.is_empty() { "<< >>".to_string() } else { format!("<< /Font {fonts_value} >>") }; - let dr_value = match dr_shape { - "inline" => dr_dict.clone(), - "object" => "5 0 R".into(), - _ => String::new(), - }; - let af = if dr_value.is_empty() { "<< /Fields [] >>".to_string() } else { format!("<< /Fields [] /DR {dr_value} >>") }; - let mut doc = document(&[ - "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), - "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), - "<< /Type /Page /Parent 2 0 R >>".into(), - af, - if dr_shape == "object" { dr_dict } else { "null".into() }, - if font_shape == "object" { format!("<< {entries}>>") } else { "null".into() }, - font("Helvetica-Oblique"), - font("ZapfDingbats"), - ]); - add_field(&mut doc, 0, [40.0, 600.0, 300.0, 630.0], &NewField::Text { multiline: false }, Some("name")) - .unwrap_or_else(|e| panic!("{what}: {e}")); - let doc = reopen(&doc); - let af = acroform(&doc).unwrap(); - let dr = doc.resolve(af.get(b"DR").unwrap_or_else(|| panic!("{what}: no /DR"))); - let fonts = dr_fonts(&doc); - for (name, value) in fonts.iter() { - assert!(value.as_dict().is_none(), "{what}: {} is inline", String::from_utf8_lossy(name)); - } - for name in ["Helv", "TiRo", "Cour"] { - assert!(fonts.contains(name.as_bytes()), "{what}: no {name}"); - } - let base = - |name: &[u8]| fonts.reference(name).and_then(|r| doc.get(r).as_dict().and_then(|f| f.name(b"BaseFont").map(<[u8]>::to_vec))); - match helv { - "inline" => assert_eq!(base(b"Helv").as_deref(), Some(&b"Arial"[..]), "{what}"), - "object" => assert_eq!(fonts.reference(b"Helv"), Some(pdfcraft_cos::ObjRef::new(7, 0)), "{what}"), - "dangling" => assert_eq!(fonts.reference(b"Helv"), Some(pdfcraft_cos::ObjRef::new(99, 0)), "{what}"), - "null" => assert_eq!(fonts.get(b"Helv"), Some(&Object::Null), "{what}"), - "number" => assert_eq!(fonts.get(b"Helv"), Some(&Object::Int(3)), "{what}"), - _ => assert_eq!(base(b"Helv").as_deref(), Some(&b"Helvetica"[..]), "{what}"), - } - match mine { - "inline" => assert_eq!(base(b"Mine").as_deref(), Some(&b"Symbol"[..]), "{what}"), - "object" => assert_eq!(fonts.reference(b"Mine"), Some(pdfcraft_cos::ObjRef::new(8, 0)), "{what}"), - _ => assert!(!fonts.contains(b"Mine"), "{what}"), - } - if dr_shape == "object" { - assert_eq!(af.reference(b"DR"), Some(pdfcraft_cos::ObjRef::new(5, 0)), "{what}: /DR stays its object"); - } - if font_shape == "object" { - assert_eq!(dr.as_dict().unwrap().reference(b"Font"), Some(pdfcraft_cos::ObjRef::new(6, 0)), "{what}: /Font stays its object"); - } + assert_font_shape_case(&case); } } } @@ -3892,6 +3823,118 @@ fn every_shape_of_form_fonts_ends_as_objects() { assert_eq!(cases, 1 + 2 * (2 + 2 * 6 * 3), "every shape was tried"); } +struct FontShapeCase<'a> { + dr: &'a str, + fonts: &'a str, + helv: &'a str, + mine: &'a str, + description: String, +} + +impl<'a> FontShapeCase<'a> { + fn new(dr: &'a str, fonts: &'a str, helv: &'a str, mine: &'a str) -> Self { + Self { dr, fonts, helv, mine, description: format!("/DR {dr}, /Font {fonts}, /Helv {helv}, /Mine {mine}") } + } + + fn is_valid(&self) -> bool { + let only_default_dr = self.dr == "missing" && self.fonts == "missing" && self.helv == "missing" && self.mine == "missing"; + let invalid_fonts = matches!(self.fonts, "missing" | "number") && (self.helv != "missing" || self.mine != "missing"); + !(self.dr == "missing" && !only_default_dr || invalid_fonts) + } +} + +fn assert_font_shape_case(case: &FontShapeCase<'_>) { + let mut doc = font_shape_document(case); + add_field(&mut doc, 0, [40.0, 600.0, 300.0, 630.0], &NewField::Text { multiline: false }, Some("name")) + .unwrap_or_else(|e| panic!("{}: {e}", case.description)); + let doc = reopen(&doc); + let acro = acroform(&doc).unwrap(); + let dr = doc.resolve(acro.get(b"DR").unwrap_or_else(|| panic!("{}: no /DR", case.description))); + let fonts = dr_fonts(&doc); + assert_font_entries_are_references(&fonts, &case.description); + assert_required_form_fonts(&fonts, &case.description); + assert_requested_font_shapes(&doc, &acro, &dr, &fonts, case); +} + +fn font_shape_document(case: &FontShapeCase<'_>) -> Document { + let mut entries = String::new(); + match case.helv { + "inline" => entries += &format!("/Helv {} ", form_font("Arial")), + "object" => entries += "/Helv 7 0 R ", + "dangling" => entries += "/Helv 99 0 R ", + "null" => entries += "/Helv null ", + "number" => entries += "/Helv 3 ", + _ => {} + } + match case.mine { + "inline" => entries += &format!("/Mine {} ", form_font("Symbol")), + "object" => entries += "/Mine 8 0 R ", + _ => {} + } + let fonts_value = match case.fonts { + "inline" => format!("<< {entries}>>"), + "object" => "6 0 R".into(), + "number" => "7".into(), + _ => String::new(), + }; + let dr_dict = if fonts_value.is_empty() { "<< >>".to_string() } else { format!("<< /Font {fonts_value} >>") }; + let dr_value = match case.dr { + "inline" => dr_dict.clone(), + "object" => "5 0 R".into(), + _ => String::new(), + }; + let acroform = if dr_value.is_empty() { "<< /Fields [] >>".to_string() } else { format!("<< /Fields [] /DR {dr_value} >>") }; + document(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R >>".into(), + acroform, + if case.dr == "object" { dr_dict } else { "null".into() }, + if case.fonts == "object" { format!("<< {entries}>>") } else { "null".into() }, + form_font("Helvetica-Oblique"), + form_font("ZapfDingbats"), + ]) +} + +fn form_font(base: &str) -> String { + format!("<< /Type /Font /Subtype /Type1 /BaseFont /{base} >>") +} + +fn assert_font_entries_are_references(fonts: &Dict, description: &str) { + for (name, value) in fonts.iter() { + assert!(value.as_dict().is_none(), "{description}: {} is inline", String::from_utf8_lossy(name)); + } +} + +fn assert_required_form_fonts(fonts: &Dict, description: &str) { + for name in ["Helv", "TiRo", "Cour"] { + assert!(fonts.contains(name.as_bytes()), "{description}: no {name}"); + } +} + +fn assert_requested_font_shapes(doc: &Document, acro: &Dict, dr: &Object, fonts: &Dict, case: &FontShapeCase<'_>) { + let base = |name: &[u8]| fonts.reference(name).and_then(|r| doc.get(r).as_dict().and_then(|f| f.name(b"BaseFont").map(<[u8]>::to_vec))); + match case.helv { + "inline" => assert_eq!(base(b"Helv").as_deref(), Some(&b"Arial"[..]), "{}", case.description), + "object" => assert_eq!(fonts.reference(b"Helv"), Some(pdfcraft_cos::ObjRef::new(7, 0)), "{}", case.description), + "dangling" => assert_eq!(fonts.reference(b"Helv"), Some(pdfcraft_cos::ObjRef::new(99, 0)), "{}", case.description), + "null" => assert_eq!(fonts.get(b"Helv"), Some(&Object::Null), "{}", case.description), + "number" => assert_eq!(fonts.get(b"Helv"), Some(&Object::Int(3)), "{}", case.description), + _ => assert_eq!(base(b"Helv").as_deref(), Some(&b"Helvetica"[..]), "{}", case.description), + } + match case.mine { + "inline" => assert_eq!(base(b"Mine").as_deref(), Some(&b"Symbol"[..]), "{}", case.description), + "object" => assert_eq!(fonts.reference(b"Mine"), Some(pdfcraft_cos::ObjRef::new(8, 0)), "{}", case.description), + _ => assert!(!fonts.contains(b"Mine"), "{}", case.description), + } + if case.dr == "object" { + assert_eq!(acro.reference(b"DR"), Some(pdfcraft_cos::ObjRef::new(5, 0)), "{}: /DR stays its object", case.description); + } + if case.fonts == "object" { + assert_eq!(dr.as_dict().unwrap().reference(b"Font"), Some(pdfcraft_cos::ObjRef::new(6, 0)), "{}: /Font stays its object", case.description); + } +} + /// #3441 (review follow-up): filling a field converts the form's inline `/DR` fonts, so a form /// an earlier PdfCraft made works in Acrobat once it has been filled here. Nothing is added. A /// form a signature covers keeps `/DR` as the signer saw it, and a form whose fonts are objects diff --git a/crates/forms/src/values.rs b/crates/forms/src/values.rs new file mode 100644 index 0000000000..37df5502d1 --- /dev/null +++ b/crates/forms/src/values.rs @@ -0,0 +1,641 @@ +use super::appearances::{declares_pdfa, need_appearances}; +use super::read::{acroform, field_flags, fields, page_refs}; +use super::{ + Dict, Document, Field, FieldEvent, FieldKind, FieldValue, FormError, NoScripts, ObjRef, Object, PdfString, Scripts, af, appearance, author, + calc_order, flags, invalid, name_obj, scripting, +}; + +// ── writing ───────────────────────────────────────────────────────────────────────────────── + +/// Fill the field `name`. +pub fn set_value(doc: &mut Document, name: &str, value: &FieldValue) -> Result<(), FormError> { + set_value_with(doc, name, value, &mut NoScripts) +} + +/// [`set_value`], running the fields' JavaScript through `scripts`. +pub fn set_value_with(doc: &mut Document, name: &str, value: &FieldValue, scripts: &mut dyn Scripts) -> Result<(), FormError> { + let all = fields(doc); + if all.is_empty() { + return Err(FormError::NoForm); + } + let named: Vec = all.iter().enumerate().filter(|(_, f)| f.name == name).map(|(i, _)| i).collect(); + fill(doc, &all, &named, name, value, scripts)?; + recalculate_with(doc, scripts)?; + Ok(()) +} + +/// Fill several fields, as [`set_value`] fills each in turn, but reading the form once and +/// recalculating once, after the last value. Filling fields one by one reads the whole form twice +/// per field, which takes minutes on a form with thousands of fields. +/// +/// Returns each value's result, in order. The outer `Err` is the recalculation failing, after +/// which, as with [`set_value`], no value counts as filled (though each was written). +pub fn set_values(doc: &mut Document, values: &[(String, FieldValue)]) -> Result>, FormError> { + let mut scripts = NoScripts; + let mut form = NamedFields::read(doc); + // The field objects this batch wrote since the form was last read. + let mut written = std::collections::HashSet::new(); + let mut results = Vec::with_capacity(values.len()); + let mut filled = false; + for (name, value) in values { + if form.all.is_empty() { + results.push(Err(FormError::NoForm)); + continue; + } + // A field written earlier in the batch (named twice, or a twin's name) is read again, + // so this write starts from what the earlier one left, as it would one by one. + if form.objs(name).any(|o| written.contains(&o)) { + form = NamedFields::read(doc); + written.clear(); + } + let named = form.named(name); + let result = fill(doc, &form.all, named, name, value, &mut scripts); + written.extend(form.objs(name)); + filled |= result.is_ok(); + results.push(result); + } + if filled { + recalculate_with(doc, &mut scripts)?; + } + Ok(results) +} + +/// The form's fields, with the positions of each fully qualified name in them (in field order). +struct NamedFields { + all: Vec, + by_name: std::collections::HashMap>, +} + +impl NamedFields { + fn read(doc: &Document) -> Self { + let all = fields(doc); + let mut by_name: std::collections::HashMap> = std::collections::HashMap::new(); + for (i, f) in all.iter().enumerate() { + by_name.entry(f.name.clone()).or_default().push(i); + } + NamedFields { all, by_name } + } + + fn named(&self, name: &str) -> &[usize] { + self.by_name.get(name).map_or(&[], Vec::as_slice) + } + + /// The field objects named `name`. + fn objs<'a>(&'a self, name: &str) -> impl Iterator + 'a { + self.named(name).iter().filter_map(|&i| self.all.get(i)).map(|f| f.obj) + } +} + +/// Write `value` into the field `name`, without recalculating: `named` are the positions in +/// `all` (the form as read before this write) of the fields with that name, in field order. +fn fill(doc: &mut Document, all: &[Field], named: &[usize], name: &str, value: &FieldValue, scripts: &mut dyn Scripts) -> Result<(), FormError> { + let f = named.first().and_then(|&i| all.get(i)).ok_or_else(|| FormError::NoSuchField(name.into()))?; + if f.read_only() { + return Err(FormError::ReadOnly(name.into())); + } + write_value(doc, f, value, scripts)?; + author::form_fonts_as_objects(doc, all)?; + // Fields with the same fully qualified name are one field (§12.7.3.1). Writers that repeat a + // name as separate field dictionaries, instead of widgets under one parent, get the value + // the first one took after its scripts, without running them again. + let twins: Vec<&Field> = named.iter().filter_map(|&i| all.get(i)).filter(|t| t.obj != f.obj && t.kind == f.kind).collect(); + if !twins.is_empty() { + let taken = fields(doc).into_iter().find(|x| x.obj == f.obj).map(|x| x.value).unwrap_or_default(); + for t in twins { + share_value(doc, f.obj, t, &taken, scripts)?; + } + } + Ok(()) +} + +/// Give `twin` the value `source` holds: its `/V` (and a choice's `/I`), drawn in the twin's own +/// widgets. +fn share_value(doc: &mut Document, source: ObjRef, twin: &Field, value: &[String], scripts: &mut dyn Scripts) -> Result<(), FormError> { + match twin.kind { + FieldKind::CheckBox => { + let state = twin.widgets.iter().find_map(|w| w.on_state.clone()).unwrap_or_else(|| "Yes".into()); + set_states(doc, twin, (!value.is_empty()).then_some(state.as_str())) + } + FieldKind::Radio => { + let choice = value.first().filter(|c| twin.widgets.iter().any(|w| w.on_state.as_deref() == Some(c.as_str()))); + set_states(doc, twin, choice.map(String::as_str)) + } + FieldKind::Text | FieldKind::Combo | FieldKind::List => { + let src = doc.get(source).as_dict().cloned().unwrap_or_default(); + doc.update_dict(twin.obj, |d| { + for key in [&b"V"[..], b"I"] { + match src.get(key) { + Some(v) => d.set(key.to_vec(), v.clone()), + None => { + d.remove(key); + } + } + } + d.remove(b"RV"); + })?; + redraw(doc, twin, value, scripts) + } + FieldKind::PushButton | FieldKind::Signature => Ok(()), + } +} + +/// Run every field's Calculate script in the form's calculation order (`/CO`, then the other +/// calculated fields), as Acrobat does after any value changes. Returns how many changed. +pub fn recalculate(doc: &mut Document) -> Result { + recalculate_with(doc, &mut NoScripts) +} + +/// [`recalculate`], running custom Calculate (and Format) scripts through `scripts`. +pub fn recalculate_with(doc: &mut Document, scripts: &mut dyn Scripts) -> Result { + let mut all = fields(doc); + if !all.iter().any(calc_order::is_calculated) { + return Ok(0); + } + // By object: two fields can share a name in a malformed form, and each calculates. + let order: Vec = calc_order::run_order(doc, &all).0.into_iter().filter_map(|(i, _)| all.get(i)).map(|f| f.obj).collect(); + let mut changed = 0; + for obj in order { + let Some(f) = all.iter().find(|f| f.obj == obj).cloned() else { continue }; + let lookup = |n: &str| -> Vec { + let prefix = format!("{n}."); + all.iter().filter(|x| x.name == n || x.name.starts_with(&prefix)).flat_map(|x| x.value.first().cloned()).collect() + }; + let current = f.value.first().cloned().unwrap_or_default(); + let v = if let Some(js) = &f.actions.scripts.calculate { + let r = scripts.run(FieldEvent::Calculate, js, &f, ¤t, &all); + scripting::apply_changes(doc, &r.changes, &f.name)?; + if !r.changes.is_empty() { + all = fields(doc); + } + if !r.rc { + continue; + } + r.value + } else { + let Some(v) = af::calculate(&f.actions.calculate, &lookup) else { continue }; + v + }; + if current != v && matches!(f.kind, FieldKind::Text | FieldKind::Combo) { + doc.update_dict(f.obj, |d| d.set(b"V".to_vec(), PdfString::text(&v)))?; + redraw(doc, &f, std::slice::from_ref(&v), scripts)?; + if let Some(target) = all.iter_mut().find(|x| x.obj == obj) { + target.value = vec![v]; + } + changed += 1; + } + } + Ok(changed) +} + +/// The fields whose calculation differs between two readings of a form, matched by field object +/// and named as in `after`: the formulas an edit rewrote, such as a rename's. +pub fn calculations_changed(before: &[Field], after: &[Field]) -> Vec { + let before: std::collections::HashMap = before.iter().map(|f| (f.obj, &f.actions)).collect(); + after + .iter() + .filter(|f| before.get(&f.obj).is_some_and(|b| b.calculate != f.actions.calculate || b.scripts.calculate != f.actions.scripts.calculate)) + .map(|f| f.name.clone()) + .collect() +} + +pub(crate) fn write_value(doc: &mut Document, f: &Field, value: &FieldValue, scripts: &mut dyn Scripts) -> Result<(), FormError> { + match (f.kind, value) { + (FieldKind::Text, FieldValue::Text(t)) => { + if let Some(max) = f.max_len + && t.chars().count() > max + { + return invalid(format!("{:?} takes at most {max} characters", f.name)); + } + // Keystroke (on commit) and Validate, as Acrobat runs them before accepting a value. + let t = &af::keystroke(&f.actions.format, &f.name, t).map_err(FormError::Format)?; + af::validate(&f.actions.validate, t).map_err(FormError::Invalid)?; + let t = &scripting::accept(doc, f, t, scripts)?; + doc.update_dict(f.obj, |d| { + if t.is_empty() { + d.remove(b"V"); + } else { + d.set(b"V".to_vec(), PdfString::text(t)); + } + d.remove(b"RV"); + })?; + redraw(doc, f, std::slice::from_ref(t), scripts) + } + (FieldKind::CheckBox, v) => { + let state = f.widgets.iter().find_map(|w| w.on_state.clone()).unwrap_or_else(|| "Yes".into()); + let on = match v { + FieldValue::Check(on) => *on, + // The on-state name or a yes/no word also work (agents, FDF-style data). + FieldValue::Text(t) | FieldValue::Radio(Some(t)) => { + let t = t.trim(); + if t == state || f.state_for(t).is_some() || ["yes", "true", "on", "1", "x", "checked"].contains(&t.to_lowercase().as_str()) { + true + } else if t.is_empty() || ["no", "false", "off", "0", "unchecked"].contains(&t.to_lowercase().as_str()) { + false + } else { + return invalid(format!("{:?} is a check box: use true or false", f.name)); + } + } + FieldValue::Radio(None) => false, + FieldValue::Choice(_) => return invalid(format!("{:?} is a check box: use true or false", f.name)), + }; + set_states(doc, f, on.then_some(state.as_str())) + } + (FieldKind::Radio, v) => { + let choice = match v { + FieldValue::Radio(c) => c.clone(), + FieldValue::Text(t) if t.is_empty() => None, + FieldValue::Text(t) => Some(t.clone()), + FieldValue::Check(false) => None, + _ => return invalid(format!("{:?} is a radio group: choose one of its options", f.name)), + }; + // An export value from /Opt selects its widget's on state. + let choice = choice.map(|c| f.state_for(&c).map(str::to_owned).unwrap_or(c)); + if let Some(c) = &choice + && !f.widgets.iter().any(|w| w.on_state.as_deref() == Some(c.as_str())) + { + let opts: Vec<&str> = (0..f.widgets.len()).filter_map(|i| f.export_of(i)).collect(); + return invalid(format!("{:?} has no option {c:?} (options: {})", f.name, opts.join(", "))); + } + if choice.is_none() && f.has(flags::NO_TOGGLE_TO_OFF) && !f.value.is_empty() { + return invalid(format!("{:?} must keep one option selected", f.name)); + } + set_states(doc, f, choice.as_deref()) + } + (FieldKind::Combo | FieldKind::List, FieldValue::Choice(_) | FieldValue::Text(_)) => { + let vals: Vec = match value { + FieldValue::Choice(v) => v.clone(), + FieldValue::Text(t) if t.is_empty() => Vec::new(), + FieldValue::Text(t) => vec![t.clone()], + other => return invalid(format!("{:?} can't take {other:?}", f.name)), + }; + if vals.len() > 1 && !(f.kind == FieldKind::List && f.has(flags::MULTI_SELECT)) { + return invalid(format!("{:?} takes a single value", f.name)); + } + // Accept export values or display texts; free text only in editable combo boxes. + let mut exports = Vec::new(); + for v in &vals { + match f.options.iter().find(|(e, d)| e == v || d == v) { + Some((e, _)) => exports.push(e.clone()), + None if f.kind == FieldKind::Combo && f.has(flags::EDIT) => exports.push(v.clone()), + None if f.options.is_empty() => exports.push(v.clone()), + None => { + let opts: Vec<&str> = f.options.iter().map(|(_, d)| d.as_str()).collect(); + return invalid(format!("{:?} has no option {v:?} (options: {})", f.name, opts.join(", "))); + } + } + } + let mut indices: Vec = + exports.iter().filter_map(|e| f.options.iter().position(|(x, _)| x == e)).map(|i| Object::Int(i as i64)).collect(); + // /I lists option indices in ascending order; /V keeps the caller's value order. + // Every entry above is an integer, so this sorts by its numeric option index. + indices.sort_unstable_by_key(Object::as_int); + // A list box scrolls (`/TI`) so the picked options are among the rows it draws, as + // in other viewers: a pick below the last visible row would otherwise not show (#1524). + let top = match (f.kind, f.widgets.first()) { + (FieldKind::List, Some(w)) => w.list.map(|rows| { + let height = if matches!(w.rotation, 90 | 270) { w.rect[2] - w.rect[0] } else { w.rect[3] - w.rect[1] }; + let picked: Vec = indices.iter().filter_map(|i| i.as_int()).filter_map(|i| usize::try_from(i).ok()).collect(); + (rows.top, appearance::ListRows::top_showing(rows.top, &picked, rows.visible(height), f.options.len())) + }), + _ => None, + }; + doc.update_dict(f.obj, |d| { + match exports.as_slice() { + [] => { + d.remove(b"V"); + } + [one] => d.set(b"V".to_vec(), PdfString::text(one)), + many => d.set(b"V".to_vec(), Object::Array(many.iter().map(|e| Object::String(PdfString::text(e))).collect())), + } + if indices.is_empty() { + d.remove(b"I"); + } else { + d.set(b"I".to_vec(), Object::Array(indices)); + } + if let Some((old, new)) = top + && old != new + { + d.set(b"TI".to_vec(), Object::Int(i64::try_from(new).unwrap_or(0))); + } + })?; + // A widget with its own `/TI` (read before the field's) scrolls too. + if let Some((old, new)) = top + && old != new + { + for w in f.widgets.iter().filter(|w| w.obj != f.obj) { + if doc.get(w.obj).as_dict().is_some_and(|d| d.contains(b"TI")) { + doc.update_dict(w.obj, |d| d.set(b"TI".to_vec(), Object::Int(i64::try_from(new).unwrap_or(0))))?; + } + } + } + redraw(doc, f, &exports, scripts) + } + (FieldKind::PushButton, _) => invalid(format!("{:?} is a button; it has no value", f.name)), + (FieldKind::Signature, _) => invalid(format!("{:?} is a signature field; sign it with Fill & Sign or a digital ID", f.name)), + (kind, v) => invalid(format!("{:?} is a {kind:?} field and can't take {v:?}", f.name)), + } +} + +/// Check boxes and radio buttons: `/V` on the field, `/AS` on each widget. +fn set_states(doc: &mut Document, f: &Field, on: Option<&str>) -> Result<(), FormError> { + let v = on.unwrap_or("Off"); + doc.update_dict(f.obj, |d| d.set(b"V".to_vec(), name_obj(v)))?; + for w in &f.widgets { + let state = match (on, w.on_state.as_deref()) { + (Some(c), Some(s)) if c == s => s, + _ => "Off", + }; + // A widget without appearances for its states gets PdfCraft's own. + let has_ap = doc.get(w.obj).as_dict().and_then(|d| d.get(b"AP").cloned()).is_some(); + if !has_ap { + let on_name = w.on_state.clone().unwrap_or_else(|| on.unwrap_or("Yes").to_string()); + let ap = appearance::check_box_states(doc, w, f.kind, &on_name, &f.da); + doc.update_dict(w.obj, |d| d.set(b"AP".to_vec(), Object::Dict(ap)))?; + } + doc.update_dict(w.obj, |d| d.set(b"AS".to_vec(), name_obj(state)))?; + } + Ok(()) +} + +/// Copy an existing appearance dictionary before replacing its normal appearance. +fn appearance_dict(doc: &Document, widget: ObjRef) -> Dict { + doc.get(widget).as_dict().and_then(|d| d.get(b"AP").map(|a| doc.resolve(a))).and_then(|a| a.as_dict().cloned()).unwrap_or_default() +} + +/// `widget`'s appearance dictionary (a copy, so a shared one is left alone) with the freshly +/// drawn entries of `fresh` (its `/N`). Other entries are kept, but the old down and rollover +/// appearances (`/D`, `/R`) would show the old value, caption or colour on press or hover, so +/// they go — except, for check boxes and radio buttons (whose `/N` is a dictionary of states), +/// the states the new `/N` still draws under the same names. +pub(crate) fn merged_appearance(doc: &Document, widget: ObjRef, fresh: &Dict) -> Dict { + let mut apd = appearance_dict(doc, widget); + // Only a dictionary of states counts (`as_dict` would also see a stream's own dictionary). + let states_of = |o: &Object| match &*doc.resolve(o) { + Object::Dict(d) => Some(d.clone()), + _ => None, + }; + let states: Option>> = fresh.get(b"N").and_then(states_of).map(|d| d.iter().map(|(k, _)| k.clone()).collect()); + for key in [&b"D"[..], &b"R"[..]] { + if fresh.contains(key) { + continue; + } + let kept = states.as_ref().and_then(|names| { + let old = apd.get(key).and_then(states_of)?; + let mut d = Dict::new(); + for (k, v) in old.iter().filter(|(k, _)| names.contains(k)) { + d.set(k.clone(), v.clone()); + } + (!d.is_empty()).then_some(d) + }); + match kept { + Some(d) => apd.set(key.to_vec(), Object::Dict(d)), + None => { + apd.remove(key); + } + } + } + for (k, v) in fresh.iter() { + apd.set(k.clone(), v.clone()); + } + apd +} + +/// Regenerate the normal appearance of every widget of a text or choice field. +fn redraw(doc: &mut Document, f: &Field, values: &[String], scripts: &mut dyn Scripts) -> Result<(), FormError> { + let shown = match values { + [one] if matches!(f.kind, FieldKind::Text | FieldKind::Combo) => scripting::formatted(doc, f, one, scripts), + _ => None, + }; + let in_place = f.kind == FieldKind::Text && f.has(flags::PASSWORD); + let mut undrawn = false; + for w in &f.widgets { + let (stream, missing) = match &shown { + Some(s) => appearance::field_appearance_drawn(doc, f, w, std::slice::from_ref(s), false), + None => appearance::field_appearance_drawn(doc, f, w, values, true), + }; + undrawn |= missing.is_some(); + let existing_ap = in_place + .then(|| { + doc.get(w.obj) + .as_dict() + .and_then(|d| d.get(b"AP").map(|a| doc.resolve(a))) + .and_then(|a| a.as_dict().and_then(|a| a.get(b"N").cloned())) + .and_then(|n| n.as_ref()) + .filter(|r| doc.is_edited(r.num) && matches!(&*doc.get(*r), Object::Stream(_))) + }) + .flatten(); + let ap = match existing_ap { + Some(r) => { + doc.set(r, Object::Stream(stream)); + r + } + None => doc.add(Object::Stream(stream)), + }; + let mut fresh = Dict::new(); + fresh.set(b"N".to_vec(), Object::Ref(ap)); + let apd = merged_appearance(doc, w.obj, &fresh); + doc.update_dict(w.obj, |d| { + d.set(b"AP".to_vec(), Object::Dict(apd)); + d.remove(b"AS"); + pdfcraft_fonts::set_undrawn_mark(d, missing); + })?; + } + if undrawn { + ask_viewers_to_redraw(doc)?; + } + Ok(()) +} + +/// A value its widget's font can't show is stored as typed and drawn with `?` (as Acrobat keeps +/// such a value): `/NeedAppearances` then asks other viewers to draw the fields from their values +/// with fonts that have those characters (the interactive form dictionary, §12.7.3). Not in a +/// document that declares PDF/A, which forbids it: there the widget's mark +/// (`pdfcraft_fonts::UNDRAWN_MARK`) is all, and the edit's report says the `?` shows everywhere. +pub(crate) fn ask_viewers_to_redraw(doc: &mut Document) -> Result<(), FormError> { + if declares_pdfa(doc) || need_appearances(doc) { + return Ok(()); + } + let Some(root) = doc.root() else { return Ok(()) }; + let entry = doc.get(root).as_dict().and_then(|c| c.get(b"AcroForm").cloned()); + match entry { + Some(Object::Ref(r)) => doc.update_dict(r, |d| d.set(b"NeedAppearances".to_vec(), Object::Bool(true)))?, + Some(Object::Dict(mut af)) => { + af.set(b"NeedAppearances".to_vec(), Object::Bool(true)); + doc.update_dict(root, |c| c.set(b"AcroForm".to_vec(), Object::Dict(af)))?; + } + _ => {} + } + Ok(()) +} + +/// Text fields with the Password flag (inherited or their own) whose value (`/V`, inherited or +/// their own) is not empty and not their default value (`/DV`, which the form's author wrote and +/// a save restores), or one of whose separate widgets carries a `/V`. +fn password_fields(doc: &Document) -> Vec { + if !has_password_flag(doc) { + return Vec::new(); + } + let on_widget = |f: &Field| f.widgets.iter().any(|w| w.obj != f.obj && doc.get(w.obj).as_dict().is_some_and(|d| d.get(b"V").is_some())); + let typed = |f: &Field| f.value.iter().any(|v| !v.is_empty()) && f.value.first() != f.default.first(); + fields(doc).into_iter().filter(|f| f.kind == FieldKind::Text && f.has(flags::PASSWORD) && (typed(f) || on_widget(f))).collect() +} + +/// Whether any field dictionary sets the Password flag (#3943 review). A field that inherits the +/// flag has an ancestor that sets it, so that ancestor is found instead; a kid clearing the flag +/// again only makes the answer cautious. It reads only `/Ff`, `/Kids` and `/Parent`, so a save +/// or recovery snapshot of a form without such a field doesn't build every [`Field`]. Like +/// [`fields`], it looks at the form's field tree and at the widgets on the pages, which may be +/// missing from `/Fields`. Each dictionary is visited once and at most 64 levels deep. +pub(crate) fn has_password_flag(doc: &Document) -> bool { + let sets_it = |r: ObjRef| doc.get(r).as_dict().and_then(|d| field_flags(doc, d)).is_some_and(|ff| ff & flags::PASSWORD != 0); + let top = acroform(doc).and_then(|af| af.get(b"Fields").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned())).unwrap_or_default(); + let mut stack: Vec<(Object, usize)> = top.into_iter().map(|o| (o, 0)).collect(); + let mut seen = std::collections::HashSet::new(); + while let Some((o, depth)) = stack.pop() { + let Object::Ref(r) = o else { continue }; + if depth > 64 || !seen.insert(r) { + continue; + } + if sets_it(r) { + return true; + } + let kids = doc.get(r).as_dict().and_then(|d| d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned())).unwrap_or_default(); + stack.extend(kids.into_iter().map(|k| (k, depth + 1))); + } + // Widgets on the pages, and the fields above them. + page_refs(doc).into_iter().any(|p| { + let annots = + doc.get(p).as_dict().and_then(|d| d.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned())).unwrap_or_default(); + annots.iter().filter_map(Object::as_ref).filter(|r| !seen.contains(r)).any(|r| field_chain(doc, r).into_iter().any(sets_it)) + }) +} + +/// Whether any text field with the Password flag holds a value in `doc`. +pub fn has_password_values(doc: &Document) -> bool { + !password_fields(doc).is_empty() +} + +/// The typed values of the text fields with the Password flag, as (name, text) pairs, for +/// [`restore_password_values`] after the document was rebased on a file written without them. +pub fn password_values(doc: &Document) -> Vec<(String, String)> { + password_fields(doc).into_iter().filter_map(|f| f.value.first().map(|v| (f.name.clone(), v.clone()))).collect() +} + +/// Put values from [`password_values`] back into the open document, in memory only. A field +/// that is gone or read-only keeps what it has. +pub fn restore_password_values(doc: &mut Document, values: &[(String, String)]) -> Result<(), FormError> { + for (name, v) in values { + match set_value(doc, name, &FieldValue::Text(v.clone())) { + Ok(()) | Err(FormError::NoSuchField(_) | FormError::ReadOnly(_)) => {} + Err(e) => return Err(e), + } + } + Ok(()) +} + +/// `r` and its field ancestors (`/Parent`), nearest first, at most 64 deep and without repeats. +fn field_chain(doc: &Document, r: ObjRef) -> Vec { + let mut chain = vec![r]; + let mut cur = r; + while chain.len() < 64 { + let Some(Object::Ref(parent)) = doc.get(cur).as_dict().and_then(|d| d.get(b"Parent").cloned()) else { break }; + if chain.contains(&parent) { + break; + } + chain.push(parent); + cur = parent; + } + chain +} + +/// Remove the values of the text fields with the Password flag, for a copy of the document about +/// to be written to a file (#3926). ISO 32000-2 §12.7.5.3, Table 231, Password flag, note: "it is +/// imperative that PDF processors never store the value of the text field in the PDF file if +/// this flag is set". As Acrobat saves such a field (#3943 review, black-box test): a field with +/// a default value (`/DV`, which the form's author wrote) gets it back as its value, one without +/// is left empty, and its appearance is drawn again from that value, so the masking characters +/// no longer show how long the typed password was. `/DV` and the field's scripts stay. +/// +/// A value can sit on the field, on one of its widgets, or on an ancestor it inherits from +/// (`/V` is inheritable). Each is removed; an ancestor's only when every field inheriting from it +/// is such a password field. Where an ancestor's value is shared with other fields and stays, the +/// password field gets an empty value of its own so it no longer inherits it. Returns how many +/// fields were cleared. +pub fn clear_password_values(doc: &mut Document) -> Result { + let targets = password_fields(doc); + if targets.is_empty() { + return Ok(0); + } + let is_password = |f: &Field| f.kind == FieldKind::Text && f.has(flags::PASSWORD); + // Each field's ancestors, found once: only values change below, never `/Parent` (#3943 + // review: walking them again for every ancestor of every target was quadratic). + let chains: Vec<(bool, Vec)> = fields(doc).iter().map(|g| (is_password(g), field_chain(doc, g.obj))).collect(); + for f in &targets { + let chain = field_chain(doc, f.obj); + for w in &f.widgets { + if w.obj != f.obj { + doc.update_dict(w.obj, |d| { + d.remove(b"V"); + })?; + } + } + doc.update_dict(f.obj, |d| { + d.remove(b"V"); + })?; + let mut shared_value_kept = false; + for a in chain.iter().skip(1) { + if doc.get(*a).as_dict().is_none_or(|d| d.get(b"V").is_none()) { + continue; + } + let exclusive = chains.iter().filter(|(_, chain)| chain.contains(a)).all(|(password, _)| *password); + if exclusive { + doc.update_dict(*a, |d| { + d.remove(b"V"); + })?; + } else { + shared_value_kept = true; + } + } + let restored: Vec = f.default.first().cloned().into_iter().collect(); + match restored.first() { + Some(dv) => doc.update_dict(f.obj, |d| d.set(b"V".to_vec(), PdfString::text(dv)))?, + None if shared_value_kept => doc.update_dict(f.obj, |d| d.set(b"V".to_vec(), PdfString::text("")))?, + None => {} + } + redraw(doc, f, &restored, &mut NoScripts)?; + } + Ok(targets.len()) +} + +/// Acrobat's Clear form: every field (or the named ones) back to its default value. +pub fn reset(doc: &mut Document, names: Option<&[String]>) -> Result { + let all = fields(doc); + if all.is_empty() { + return Err(FormError::NoForm); + } + if let Some(n) = names + && let Some(missing) = n.iter().find(|n| !all.iter().any(|f| &f.name == *n)) + { + return Err(FormError::NoSuchField(missing.clone())); + } + let mut changed = 0; + for f in all.iter().filter(|f| names.is_none_or(|n| n.contains(&f.name))) { + if f.value == f.default { + continue; + } + let v = match f.kind { + FieldKind::Text => FieldValue::Text(f.default.first().cloned().unwrap_or_default()), + FieldKind::CheckBox => FieldValue::Check(!f.default.is_empty()), + FieldKind::Radio => FieldValue::Radio(f.default.first().cloned()), + FieldKind::Combo | FieldKind::List => FieldValue::Choice(f.default.clone()), + FieldKind::PushButton | FieldKind::Signature => continue, + }; + // A reset is not blocked by NoToggleToOff: go through the writer directly. + let mut tmp = f.clone(); + tmp.flags &= !flags::NO_TOGGLE_TO_OFF; + write_value(doc, &tmp, &v, &mut NoScripts)?; + changed += 1; + } + recalculate(doc)?; + Ok(changed) +} diff --git a/crates/js/src/document.rs b/crates/js/src/document.rs new file mode 100644 index 0000000000..e977457e2b --- /dev/null +++ b/crates/js/src/document.rs @@ -0,0 +1,224 @@ +use super::host::{arg, colour, error, function, host, read, s, set_value, strings, text, value_of, write}; +use super::model::{DISPLAY_HIDDEN, DISPLAY_VISIBLE, FieldType, Request}; +use super::{Attribute, Context, JsArray, JsObject, JsResult, JsString, JsValue, NativeFunction, ObjectInitializer, js_string}; + +macro_rules! getter { + ($ctx:expr, $name:expr, |$f:ident, $c:ident| $body:expr) => {{ + fn get(_: &JsValue, _: &[JsValue], name: &JsString, $c: &mut Context) -> JsResult { + let $f = read($c, name)?; + Ok($body) + } + function($ctx, NativeFunction::from_copy_closure_with_captures(get, $name.clone())) + }}; +} + +pub(crate) fn field_object(ctx: &mut Context, name: &str) -> JsObject { + let n = JsString::from(name); + let value_get = getter!(ctx, n, |f, c| value_of(&f, c)); + let value_set = { + fn set(_: &JsValue, args: &[JsValue], name: &JsString, c: &mut Context) -> JsResult { + let vals = strings(&arg(args, 0), c)?; + write(c, name, |f| set_value(f, vals))?; + Ok(JsValue::undefined()) + } + function(ctx, NativeFunction::from_copy_closure_with_captures(set, n.clone())) + }; + let as_string = getter!(ctx, n, |f, _c| s(&match f.kind { + FieldType::CheckBox | FieldType::RadioButton => f.value.first().cloned().unwrap_or_else(|| "Off".into()), + _ => f.value.join(","), + })); + let default = getter!(ctx, n, |f, _c| s(&f.default.first().cloned().unwrap_or_default())); + let kind = getter!(ctx, n, |f, _c| s(f.kind.name())); + let fname = getter!(ctx, n, |f, _c| s(&f.name)); + let readonly_get = getter!(ctx, n, |f, _c| JsValue::from(f.readonly)); + let readonly_set = { + fn set(_: &JsValue, args: &[JsValue], name: &JsString, c: &mut Context) -> JsResult { + let on = arg(args, 0).to_boolean(); + write(c, name, |f| f.readonly = on)?; + Ok(JsValue::undefined()) + } + function(ctx, NativeFunction::from_copy_closure_with_captures(set, n.clone())) + }; + let required_get = getter!(ctx, n, |f, _c| JsValue::from(f.required)); + let required_set = { + fn set(_: &JsValue, args: &[JsValue], name: &JsString, c: &mut Context) -> JsResult { + let on = arg(args, 0).to_boolean(); + write(c, name, |f| f.required = on)?; + Ok(JsValue::undefined()) + } + function(ctx, NativeFunction::from_copy_closure_with_captures(set, n.clone())) + }; + let display_get = getter!(ctx, n, |f, _c| JsValue::from(f.display)); + let display_set = { + fn set(_: &JsValue, args: &[JsValue], name: &JsString, c: &mut Context) -> JsResult { + let d = arg(args, 0).to_number(c)? as i32; + write(c, name, |f| f.display = d.clamp(0, 3))?; + Ok(JsValue::undefined()) + } + function(ctx, NativeFunction::from_copy_closure_with_captures(set, n.clone())) + }; + let hidden_get = getter!(ctx, n, |f, _c| JsValue::from(f.display == DISPLAY_HIDDEN)); + let hidden_set = { + fn set(_: &JsValue, args: &[JsValue], name: &JsString, c: &mut Context) -> JsResult { + let on = arg(args, 0).to_boolean(); + write(c, name, |f| f.display = if on { DISPLAY_HIDDEN } else { DISPLAY_VISIBLE })?; + Ok(JsValue::undefined()) + } + function(ctx, NativeFunction::from_copy_closure_with_captures(set, n.clone())) + }; + let num_items = getter!(ctx, n, |f, _c| JsValue::from(f.options.len() as f64)); + let char_limit = getter!(ctx, n, |f, _c| JsValue::from(f.char_limit.unwrap_or(0) as f64)); + let export_values = getter!(ctx, n, |f, c| { + let vals: Vec = f.options.iter().map(|o| s(&o.0)).collect(); + JsArray::from_iter(vals, c).into() + }); + let indices = getter!(ctx, n, |f, c| { + let idx: Vec = f.options.iter().enumerate().filter(|(_, o)| f.value.contains(&o.0)).map(|(i, _)| JsValue::from(i as f64)).collect(); + if idx.len() == 1 { idx[0].clone() } else { JsArray::from_iter(idx, c).into() } + }); + let text_color_get = getter!(ctx, n, |f, c| colour(&f.text_color, c)); + let text_color_set = { + fn set(_: &JsValue, args: &[JsValue], name: &JsString, c: &mut Context) -> JsResult { + let v = strings(&arg(args, 0), c)?; + write(c, name, |f| f.text_color = Some(v))?; + Ok(JsValue::undefined()) + } + function(ctx, NativeFunction::from_copy_closure_with_captures(set, n.clone())) + }; + let fill_color_get = getter!(ctx, n, |f, c| colour(&f.fill_color, c)); + let fill_color_set = { + fn set(_: &JsValue, args: &[JsValue], name: &JsString, c: &mut Context) -> JsResult { + let v = strings(&arg(args, 0), c)?; + write(c, name, |f| f.fill_color = Some(v))?; + Ok(JsValue::undefined()) + } + function(ctx, NativeFunction::from_copy_closure_with_captures(set, n.clone())) + }; + + fn get_item_at(_: &JsValue, args: &[JsValue], name: &JsString, c: &mut Context) -> JsResult { + let f = read(c, name)?; + let i = arg(args, 0).to_number(c)?; + // bExportValue defaults to true. + let export = args.get(1).is_none_or(JsValue::to_boolean); + let i = if i < 0.0 { f.options.len().saturating_sub(1) } else { i as usize }; + let o = f.options.get(i).ok_or_else(|| error(format!("{} has no item {i}", f.name)))?; + Ok(s(if export { &o.0 } else { &o.1 })) + } + fn is_box_checked(_: &JsValue, args: &[JsValue], name: &JsString, c: &mut Context) -> JsResult { + let f = read(c, name)?; + let i = arg(args, 0).to_number(c)? as usize; + let on = f.options.get(i).is_some_and(|o| f.value.first() == Some(&o.0)); + Ok(JsValue::from(on)) + } + fn check_this_box(_: &JsValue, args: &[JsValue], name: &JsString, c: &mut Context) -> JsResult { + let f = read(c, name)?; + let i = arg(args, 0).to_number(c)? as usize; + let on = args.get(1).is_none_or(JsValue::to_boolean); + let export = f.options.get(i).map(|o| o.0.clone()).ok_or_else(|| error(format!("{} has no widget {i}", f.name)))?; + write(c, name, |f| { + if on { + f.value = vec![export]; + } else if f.value.first() == Some(&export) { + f.value.clear(); + } + })?; + Ok(JsValue::undefined()) + } + fn set_focus(_: &JsValue, _: &[JsValue], name: &JsString, c: &mut Context) -> JsResult { + host(c)?.borrow_mut().request(Request::Focus(name.to_std_string_escaped())); + Ok(JsValue::undefined()) + } + + let rw = Attribute::CONFIGURABLE | Attribute::ENUMERABLE; + let get_item_at = NativeFunction::from_copy_closure_with_captures(get_item_at, n.clone()); + let is_box_checked = NativeFunction::from_copy_closure_with_captures(is_box_checked, n.clone()); + let check_this_box = NativeFunction::from_copy_closure_with_captures(check_this_box, n.clone()); + let set_focus = NativeFunction::from_copy_closure_with_captures(set_focus, n.clone()); + ObjectInitializer::new(ctx) + .accessor(js_string!("value"), Some(value_get), Some(value_set), rw) + .accessor(js_string!("valueAsString"), Some(as_string), None, rw) + .accessor(js_string!("defaultValue"), Some(default), None, rw) + .accessor(js_string!("type"), Some(kind), None, rw) + .accessor(js_string!("name"), Some(fname), None, rw) + .accessor(js_string!("readonly"), Some(readonly_get), Some(readonly_set), rw) + .accessor(js_string!("required"), Some(required_get), Some(required_set), rw) + .accessor(js_string!("display"), Some(display_get), Some(display_set), rw) + .accessor(js_string!("hidden"), Some(hidden_get), Some(hidden_set), rw) + .accessor(js_string!("numItems"), Some(num_items), None, rw) + .accessor(js_string!("charLimit"), Some(char_limit), None, rw) + .accessor(js_string!("exportValues"), Some(export_values), None, rw) + .accessor(js_string!("currentValueIndices"), Some(indices), None, rw) + .accessor(js_string!("textColor"), Some(text_color_get), Some(text_color_set), rw) + .accessor(js_string!("fillColor"), Some(fill_color_get), Some(fill_color_set), rw) + .function(get_item_at, js_string!("getItemAt"), 2) + .function(is_box_checked, js_string!("isBoxChecked"), 1) + .function(check_this_box, js_string!("checkThisBox"), 2) + .function(set_focus, js_string!("setFocus"), 0) + .build() +} + +// ── the document (global object) ──────────────────────────────────────────────────────────── + +pub(crate) fn get_field(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let name = text(&arg(args, 0), ctx)?; + let exists = host(ctx)?.borrow().fields.iter().any(|f| f.name == name); + if exists { + return Ok(field_object(ctx, &name).into()); + } + // A parent name (e.g. "total" for "total.0"): its first kid stands in, as for groups. + let prefix = format!("{name}."); + let kid = host(ctx)?.borrow().fields.iter().find(|f| f.name.starts_with(&prefix)).map(|f| f.name.clone()); + Ok(kid.map_or(JsValue::null(), |k| field_object(ctx, &k).into())) +} + +pub(crate) fn get_nth_field_name(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let i = arg(args, 0).to_number(ctx)?; + let name = host(ctx)?.borrow().fields.get(i.max(0.0) as usize).map(|f| f.name.clone()); + Ok(name.map_or(JsValue::null(), |n| s(&n))) +} + +pub(crate) fn reset_form(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let names = strings(&arg(args, 0), ctx)?; + host(ctx)?.borrow_mut().request(Request::Reset(names)); + Ok(JsValue::undefined()) +} + +pub(crate) fn print(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { + host(ctx)?.borrow_mut().request(Request::Print); + Ok(JsValue::undefined()) +} + +pub(crate) fn submit_form(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let a = arg(args, 0); + let url = match a.as_object() { + Some(o) if !o.is_array() => { + let u = o.get(js_string!("cURL"), ctx)?; + text(&u, ctx)? + } + _ => text(&a, ctx)?, + }; + host(ctx)?.borrow_mut().request(Request::Submit(url)); + Ok(JsValue::undefined()) +} + +pub(crate) fn noop(_: &JsValue, _: &[JsValue], _: &mut Context) -> JsResult { + Ok(JsValue::undefined()) +} + +pub(crate) fn num_fields(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { + Ok(JsValue::from(host(ctx)?.borrow().fields.len() as f64)) +} + +pub(crate) fn page_num_get(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { + Ok(JsValue::from(host(ctx)?.borrow().doc.page as f64)) +} + +pub(crate) fn page_num_set(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let n = arg(args, 0).to_number(ctx)?; + let mut h = host(ctx)?.borrow_mut(); + let last = h.doc.num_pages.saturating_sub(1); + let p = (n.max(0.0) as usize).min(last); + h.doc.page = p; + h.request(Request::GoToPage(p)); + Ok(JsValue::undefined()) +} diff --git a/crates/js/src/format.rs b/crates/js/src/format.rs new file mode 100644 index 0000000000..7f207b1916 --- /dev/null +++ b/crates/js/src/format.rs @@ -0,0 +1,620 @@ +use super::host::{arg, error, s, text}; +use super::run::{MAX_PRINTF_OUTPUT_BYTES, MAX_PRINTF_WIDTH_PRECISION}; +use super::{Context, JsDate, JsResult, JsString, JsValue, formcalc}; + +/// `util.printf(cFormat, …)`: `%[,n][+ 0#][width][.prec](d|f|s|x)`, where `,0` groups +/// thousands with commas (the default), `,1` doesn't, `,2` uses `.` and `,`, `,3` uses `.` +/// without grouping. +pub(crate) fn printf(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let fmt = text(&arg(args, 0), ctx)?; + let mut out = String::new(); + let check_output = |len: usize| -> JsResult<()> { + if len > MAX_PRINTF_OUTPUT_BYTES { + return Err(error(format!("util.printf output exceeds {MAX_PRINTF_OUTPUT_BYTES} bytes"))); + } + Ok(()) + }; + let mut next = 1; + let mut chars = fmt.chars().peekable(); + while let Some(c) = chars.next() { + if c != '%' { + check_output(out.len().saturating_add(c.len_utf8()))?; + out.push(c); + continue; + } + if chars.peek() == Some(&'%') { + chars.next(); + check_output(out.len().saturating_add(1))?; + out.push('%'); + continue; + } + let mut sep = 1u8; + if chars.peek() == Some(&',') { + chars.next(); + sep = chars.next().and_then(|d| d.to_digit(10)).unwrap_or(0) as u8; + } + let (mut plus, mut space, mut zero) = (false, false, false); + while let Some(&f) = chars.peek() { + match f { + '+' => plus = true, + ' ' => space = true, + '0' => zero = true, + '#' => {} + _ => break, + } + chars.next(); + } + let mut width = 0usize; + while let Some(d) = chars.peek().and_then(|d| d.to_digit(10)) { + width = width.saturating_mul(10).saturating_add(d as usize); + chars.next(); + } + let mut prec = None; + if chars.peek() == Some(&'.') { + chars.next(); + let mut p = 0usize; + while let Some(d) = chars.peek().and_then(|d| d.to_digit(10)) { + p = p.saturating_mul(10).saturating_add(d as usize); + chars.next(); + } + prec = Some(p); + } + if width > MAX_PRINTF_WIDTH_PRECISION || prec.is_some_and(|p| p > MAX_PRINTF_WIDTH_PRECISION) { + return Err(error(format!("util.printf width or precision exceeds {MAX_PRINTF_WIDTH_PRECISION}"))); + } + let conv = chars.next().unwrap_or('s'); + let v = arg(args, next); + next += 1; + let body = match conv { + 'd' | 'f' => { + let n = v.to_number(ctx)?; + let p = if conv == 'd' { 0 } else { prec.unwrap_or(6) }; + let n = if conv == 'd' { n.trunc() } else { n }; + let mut t = group(&format!("{:.*}", p, n.abs()), sep); + if n < 0.0 { + t.insert(0, '-'); + } else if plus { + t.insert(0, '+'); + } else if space { + t.insert(0, ' '); + } + t + } + 'x' => format!("{:X}", v.to_number(ctx)? as i64), + _ => { + let t = text(&v, ctx)?; + match prec { + Some(p) => t.chars().take(p).collect(), + None => t, + } + } + }; + let pad = width.saturating_sub(body.chars().count()); + check_output(out.len().saturating_add(pad).saturating_add(body.len()))?; + if zero && matches!(conv, 'd' | 'f') { + let (sign, digits) = if body.starts_with(['-', '+', ' ']) { body.split_at(1) } else { ("", body.as_str()) }; + out.push_str(sign); + out.push_str(&"0".repeat(pad)); + out.push_str(digits); + } else { + out.push_str(&" ".repeat(pad)); + out.push_str(&body); + } + } + Ok(s(&out)) +} + +/// Group the integer part of a plain decimal number per `util.printf`'s `,n` flag. +fn group(t: &str, sep: u8) -> String { + let (int, frac) = t.split_once('.').map_or((t, None), |(a, b)| (a, Some(b))); + let (thousands, point) = match sep { + 0 => (Some(','), '.'), + 2 => (Some('.'), ','), + 3 => (None, ','), + _ => (None, '.'), + }; + let mut g = String::new(); + for (i, ch) in int.chars().enumerate() { + if let Some(th) = thousands + && i > 0 + && (int.len() - i).is_multiple_of(3) + { + g.push(th); + } + g.push(ch); + } + if let Some(f) = frac { + g.push(point); + g.push_str(f); + } + g +} + +const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]; + +const DAYS: [&str; 7] = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; + +/// `util.printd(cFormat, oDate)`: yyyy yy mmmm mmm mm m dddd ddd dd d HH H hh h MM M ss s tt, +/// and the numeric formats 0 (D:yyyymmddHHMMss), 1 (yyyy.mm.dd HH:MM:ss) and 2 (m/d/yy h:MM:ss tt). +pub(crate) fn printd(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let f = arg(args, 0); + let fmt = if f.is_number() { + match f.to_number(ctx)? as i32 { + 0 => "D:yyyymmddHHMMss".to_string(), + 1 => "yyyy.mm.dd HH:MM:ss".to_string(), + _ => "m/d/yy h:MM:ss tt".to_string(), + } + } else { + text(&f, ctx)? + }; + let d = arg(args, 1); + let Some(date) = d.as_object() else { return Err(error("util.printd needs a Date")) }; + let mut part = |m: &str| -> JsResult { + let func = date.get(JsString::from(m), ctx)?; + let func = func.as_callable().ok_or_else(|| error("util.printd needs a Date"))?; + let v = func.call(&d, &[], ctx)?.to_number(ctx)?; + if v.is_nan() { + return Err(error("invalid date")); + } + Ok(v as i64) + }; + let (y, mo, day, wd, h, mi, sec) = + (part("getFullYear")?, part("getMonth")?, part("getDate")?, part("getDay")?, part("getHours")?, part("getMinutes")?, part("getSeconds")?); + let mut out = String::new(); + let b = fmt.as_bytes(); + let mut i = 0; + while i < b.len() { + let c = b[i]; + // Only date letters read as runs; a literal is copied one character at a time, so + // measuring its run would rescan a long literal stretch at every position. + let run = if b"ymdHhMst".contains(&c) { b[i..].iter().take_while(|x| **x == c).count() } else { 1 }; + #[cfg(test)] + PRINTD_SCANNED.fetch_add(run as u64, std::sync::atomic::Ordering::Relaxed); + let push_num = |out: &mut String, v: i64, n: usize| { + if n >= 2 { + out.push_str(&format!("{v:02}")); + } else { + out.push_str(&v.to_string()); + } + }; + match c { + b'y' => { + if run >= 4 { + out.push_str(&format!("{y:04}")); + } else { + out.push_str(&format!("{:02}", y % 100)); + } + } + b'm' => match run { + r if r >= 4 => out.push_str(MONTHS[mo as usize % 12]), + 3 => out.push_str(&MONTHS[mo as usize % 12][..3]), + n => push_num(&mut out, mo + 1, n), + }, + b'd' => match run { + r if r >= 4 => out.push_str(DAYS[wd as usize % 7]), + 3 => out.push_str(&DAYS[wd as usize % 7][..3]), + n => push_num(&mut out, day, n), + }, + b'H' => push_num(&mut out, h, run), + b'h' => push_num(&mut out, if h % 12 == 0 { 12 } else { h % 12 }, run), + b'M' => push_num(&mut out, mi, run), + b's' => push_num(&mut out, sec, run), + b't' => { + let ampm = if h < 12 { "am" } else { "pm" }; + out.push_str(if run >= 2 { ampm } else { &m[..1] }); + } + b'\\' if i + 1 < b.len() => { + // The escaped character may be multi-byte; copy it whole. + let ch = fmt[i + 1..].chars().next().unwrap_or(' '); + out.push(ch); + i += 1 + ch.len_utf8(); + continue; + } + _ => { + // Copy the rest of a multi-byte character too. + let ch = fmt[i..].chars().next().unwrap_or(' '); + out.push(ch); + i += ch.len_utf8(); + continue; + } + } + i += run; + } + Ok(s(&out)) +} + +/// A field of a `util.scand` format. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum DateUnit { + /// `yyyy`: four digits. + Year, + /// `yy`: two digits, read as 1950–2049. + ShortYear, + Month, + /// `mmm`, `mmmm`: a month's name or its three-letter abbreviation. + MonthName, + Day, + /// `ddd`, `dddd`: a weekday's name, read and not checked. + Weekday, + Hour, + /// `h`, `hh`: 0–12, with `tt` for the afternoon. + Hour12, + Minute, + Second, + /// `t`, `tt`: am or pm. + AmPm, +} + +impl DateUnit { + /// A year, month or day: a format needs one of these for `util.scand` to read anything. + fn is_date(self) -> bool { + matches!(self, Self::Year | Self::ShortYear | Self::Month | Self::MonthName | Self::Day) + } + + /// Read from a run of digits, not from letters. + fn is_numeric(self) -> bool { + !matches!(self, Self::MonthName | Self::Weekday | Self::AmPm) + } +} + +/// A piece of a `util.scand` format. +#[derive(Debug, PartialEq, Eq)] +enum ScanPart { + /// A field, the digits it takes when typed together with the next one (`width`), and whether + /// the next one follows it with nothing between them in the format (`joined`). + Field { unit: DateUnit, width: usize, joined: bool }, + /// Letters the format writes as they are, which the text must have there. + Word(String), +} + +/// The fields and literal words of a `util.scand` format, in order. A numeric field is marked +/// `joined` when another numeric field follows it directly; anything but letters and digits +/// outside a field is left out. +fn scan_parts(fmt: &str) -> Vec { + let chars: Vec = fmt.chars().collect(); + let mut parts: Vec = Vec::new(); + // The last part is a field written right before this point, or letters still being collected. + let (mut after_field, mut in_word) = (false, false); + let mut i = 0; + while let Some(&c) = chars.get(i) { + // Counted for field letters only, so a long run of anything else is read once. + let run = if "ymdHhMst".contains(c) { chars.get(i..).unwrap_or_default().iter().take_while(|x| **x == c).count() } else { 1 }; + let field = match (c, run) { + ('y', 4..) => Some((DateUnit::Year, 4)), + ('y', _) => Some((DateUnit::ShortYear, 2)), + ('m', 3..) => Some((DateUnit::MonthName, 0)), + ('m', n) => Some((DateUnit::Month, n)), + ('d', 3..) => Some((DateUnit::Weekday, 0)), + ('d', n) => Some((DateUnit::Day, n)), + ('H', n) => Some((DateUnit::Hour, n.min(2))), + ('h', n) => Some((DateUnit::Hour12, n.min(2))), + ('M', n) => Some((DateUnit::Minute, n.min(2))), + ('s', n) => Some((DateUnit::Second, n.min(2))), + ('t', _) => Some((DateUnit::AmPm, 0)), + _ => None, + }; + if let Some((unit, width)) = field { + if after_field + && unit.is_numeric() + && let Some(ScanPart::Field { unit: last, joined, .. }) = parts.last_mut() + && last.is_numeric() + { + *joined = true; + } + parts.push(ScanPart::Field { unit, width, joined: false }); + (after_field, in_word) = (true, false); + i += run; + continue; + } + // `\` writes the next character as it is. + let (literal, step) = if c == '\\' { (chars.get(i + 1).copied(), 2) } else { (Some(c), 1) }; + i += step; + after_field = false; + match literal { + Some(l) if l.is_alphanumeric() => match parts.last_mut() { + Some(ScanPart::Word(w)) if in_word => w.push(l), + _ => { + parts.push(ScanPart::Word(l.to_string())); + in_word = true; + } + }, + _ => in_word = false, + } + } + parts +} + +/// A run of digits or of letters in the text `util.scand` reads. +enum ScanToken<'a> { + Digits(&'a str), + Letters(&'a str), +} + +/// The text's runs of digits and of letters, in order; anything else only separates them, and +/// may not begin or end the text. `None` when it does. +fn scan_tokens(text: &str) -> Option>> { + // Some(true) for a digit, Some(false) for a letter, None for a separator. + let kind = |c: char| (c.is_ascii_digit() || c.is_alphabetic()).then_some(c.is_ascii_digit()); + kind(text.chars().next()?)?; + kind(text.chars().next_back()?)?; + let mut out = Vec::new(); + let mut start: Option<(usize, bool)> = None; + for (at, c) in text.char_indices().chain(std::iter::once((text.len(), ' '))) { + let k = kind(c); + if let Some((from, digits)) = start + && k != Some(digits) + { + let piece = text.get(from..at)?; + out.push(if digits { ScanToken::Digits(piece) } else { ScanToken::Letters(piece) }); + start = None; + } + if let Some(digits) = k + && start.is_none() + { + start = Some((at, digits)); + } + } + Some(out) +} + +/// What `util.scand` reads. +#[derive(Debug, PartialEq, Eq)] +enum Scanned { + /// The text is empty: today, at noon. + Today, + /// A date and time: year, month (1–12), day, hours, minutes, seconds. + At(i32, u32, u32, u32, u32, u32), +} + +/// The fields `util.scand` has read so far. +#[derive(Default)] +struct ScanValues { + y: Option, + mo: Option, + d: Option, + h: Option, + mi: Option, + sec: Option, + pm: Option, + /// The hour was read from `h` or `hh`. + twelve: bool, +} + +impl ScanValues { + /// Records `v` as the value of `unit`: a `yy` year as 1950–2049, and an `h` or `hh` hour + /// marked as 12-hour. Month names, weekdays and am/pm are not read here. + fn set(&mut self, unit: DateUnit, v: u32) { + match unit { + DateUnit::Year => self.y = i32::try_from(v).ok(), + DateUnit::ShortYear => self.y = i32::try_from(v).ok().map(|v| if v < 50 { 2000 + v } else { 1900 + v }), + DateUnit::Month => self.mo = Some(v), + DateUnit::Day => self.d = Some(v), + DateUnit::Hour => self.h = Some(v), + DateUnit::Hour12 => { + self.h = Some(v); + self.twelve = true; + } + DateUnit::Minute => self.mi = Some(v), + DateUnit::Second => self.sec = Some(v), + DateUnit::MonthName | DateUnit::Weekday | DateUnit::AmPm => {} + } + } +} + +/// Reads `text` by a `util.scand` format, as another viewer was observed to (black-box): +/// +/// - Fields may be separated by anything but digits and letters, whether or not the format +/// separates them, but nothing may come before the first or after the last. +/// - Digits typed together fill the format's fields written together with exactly as many +/// digits as each has letters (`yyyymd`: 4, 1 and 1). A run of digits for one field alone may +/// have one or two, except a four-digit year. +/// - `yy` is 1950–2049; month names may be written in full or as three letters, in any case. +/// - Impossible dates are refused, but a month or day of 0 is read as 1. +/// - A format with no year, month or day reads nothing; an empty text is today, at noon. +/// +/// Not observed: a year left out is `this_year`, and a month or day left out is 1. +fn scan_date(fmt: &str, text: &str, this_year: i32) -> Option { + let parts = scan_parts(fmt); + if !parts.iter().any(|p| matches!(p, ScanPart::Field { unit, .. } if unit.is_date())) { + return None; + } + if text.is_empty() { + return Some(Scanned::Today); + } + let tokens = scan_tokens(text)?; + let mut v = ScanValues::default(); + let (mut p, mut t) = (0, 0); + while let Some(part) = parts.get(p) { + let token = tokens.get(t)?; + t += 1; + match (part, token) { + (ScanPart::Word(w), ScanToken::Letters(l) | ScanToken::Digits(l)) => { + if w.to_lowercase() != l.to_lowercase() { + return None; + } + p += 1; + } + (ScanPart::Field { unit: DateUnit::MonthName, .. }, ScanToken::Letters(l)) => { + v.mo = Some(named(&MONTHS, l)?.saturating_add(1)); + p += 1; + } + (ScanPart::Field { unit: DateUnit::Weekday, .. }, ScanToken::Letters(l)) => { + named(&DAYS, l)?; + p += 1; + } + (ScanPart::Field { unit: DateUnit::AmPm, .. }, ScanToken::Letters(l)) => { + v.pm = Some(match l.to_lowercase().as_str() { + "am" | "a" => false, + "pm" | "p" => true, + _ => return None, + }); + p += 1; + } + (ScanPart::Field { unit, width, joined }, ScanToken::Digits(run)) if unit.is_numeric() => { + // The fields written together from here, no more than the run's digits can fill, + // and the fewest of them the run fills. + let mut group = vec![(*unit, *width)]; + let (mut more, mut widths) = (*joined, *width); + while more + && widths < run.len() + && let Some(ScanPart::Field { unit, width, joined }) = parts.get(p + group.len()) + { + group.push((*unit, *width)); + widths = widths.saturating_add(*width); + more = *joined; + } + let mut sum = 0; + let fills = group.iter().position(|(_, w)| { + sum += w; + sum == run.len() + }); + if let Some(last) = fills { + let mut at = 0; + for (unit, w) in group.iter().take(last + 1) { + v.set(*unit, run.get(at..at + w)?.parse().ok()?); + at += w; + } + p += last + 1; + } else if *unit != DateUnit::Year && run.len() <= 2 { + v.set(*unit, run.parse().ok()?); + p += 1; + } else { + return None; + } + } + _ => return None, + } + } + if t != tokens.len() { + return None; + } + let y = v.y.unwrap_or(this_year); + let mo = v.mo.unwrap_or(1).max(1); + let d = v.d.unwrap_or(1).max(1); + if mo > 12 || i64::from(d) > i64::from(formcalc::days_in(i64::from(y), mo)) { + return None; + } + let mut h = v.h.unwrap_or(0); + if v.twelve && h > 12 { + return None; + } + match v.pm { + Some(true) if h < 12 => h += 12, + Some(false) if h == 12 => h = 0, + _ => {} + } + let (mi, sec) = (v.mi.unwrap_or(0), v.sec.unwrap_or(0)); + (h <= 23 && mi <= 59 && sec <= 59).then_some(Scanned::At(y, mo, d, h, mi, sec)) +} + +/// The index of the name `word` is, in full or as its first three letters, in any case. +fn named(names: &[&str], word: &str) -> Option { + let word = word.to_lowercase(); + let i = names.iter().position(|n| { + let n = n.to_lowercase(); + word == n || n.get(..3) == Some(word.as_str()) + })?; + u32::try_from(i).ok() +} + +/// `util.scand(cFormat, cDate)`: the date `cDate` gives by a `util.printd` format, as a local +/// `Date`, or `null` when it doesn't read as one (see [`scan_date`]). Only text is read. +pub(crate) fn scand(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let Some(typed) = arg(args, 1).as_string().map(|t| t.to_std_string_escaped()) else { + return Ok(JsValue::null()); + }; + let fmt = text(&arg(args, 0), ctx)?; + let date = JsDate::new(ctx); + let this_year = date.get_full_year(ctx)?.to_number(ctx)?; + let this_year = if this_year.is_finite() { this_year as i32 } else { 2000 }; + match scan_date(&fmt, &typed, this_year) { + None => return Ok(JsValue::null()), + Some(Scanned::Today) => { + date.set_hours(&[12.into(), 0.into(), 0.into(), 0.into()], ctx)?; + } + Some(Scanned::At(y, mo, d, h, mi, sec)) => { + // setFullYear, not the constructor, so years 0–99 stay as they are. + date.set_full_year(&[y.into(), (mo.saturating_sub(1)).into(), d.into()], ctx)?; + date.set_hours(&[h.into(), mi.into(), sec.into(), 0.into()], ctx)?; + } + } + Ok(date.into()) +} + +/// `util.printx(cFormat, cSource)`: `?` any character, `X` letter or digit, `A` letter, `9` +/// digit, `*` the rest, `\` escape, `>` / `<` / `=` upper / lower / as is. +pub(crate) fn printx(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let fmt: Vec = text(&arg(args, 0), ctx)?.chars().collect(); + let src: Vec = text(&arg(args, 1), ctx)?.chars().collect(); + let (mut out, mut j, mut case) = (String::new(), 0, 0); + let mut i = 0; + let push = |out: &mut String, c: char, case: i32| match case { + 1 => out.extend(c.to_uppercase()), + -1 => out.extend(c.to_lowercase()), + _ => out.push(c), + }; + while i < fmt.len() { + let f = fmt[i]; + i += 1; + let take = |j: &mut usize, ok: &dyn Fn(char) -> bool| -> Option { + while *j < src.len() { + let c = src[*j]; + *j += 1; + if ok(c) { + return Some(c); + } + } + None + }; + match f { + '?' => { + if let Some(c) = take(&mut j, &|_| true) { + push(&mut out, c, case) + } + } + 'X' => { + if let Some(c) = take(&mut j, &|c| c.is_alphanumeric()) { + push(&mut out, c, case) + } + } + 'A' => { + if let Some(c) = take(&mut j, &|c| c.is_alphabetic()) { + push(&mut out, c, case) + } + } + '9' => { + if let Some(c) = take(&mut j, &|c| c.is_ascii_digit()) { + push(&mut out, c, case) + } + } + '*' => { + for c in src[j.min(src.len())..].iter().copied() { + push(&mut out, c, case); + } + j = src.len(); + } + '\\' if i < fmt.len() => { + out.push(fmt[i]); + i += 1; + } + '>' => case = 1, + '<' => case = -1, + '=' => case = 0, + c => out.push(c), + } + } + Ok(s(&out)) +} + +// ── stack safety ──────────────────────────────────────────────────────────────────────────── +// +// boa's parser and compiler recurse once per nesting level and have no limit of their own: about +// a hundred nested parentheses overflow a 2 MiB stack, which aborts the whole app. Scripts come +// from documents, so `run` refuses nesting no real form needs and runs the engine on a thread +// with a large stack (address space, committed only as it is used). Together these bound the +// stack a script can use: brackets and prefix operators by the limits below, and every other +// construct (a few KiB per level, several bytes of source each) by the length limit. + +/// Bytes `util.printd` has scanned for runs of a format character, for tests that bound its work. +#[cfg(test)] +pub(crate) static PRINTD_SCANNED: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); diff --git a/crates/js/src/formcalc.rs b/crates/js/src/formcalc.rs index 39ecdbde5a..5c80177021 100644 --- a/crates/js/src/formcalc.rs +++ b/crates/js/src/formcalc.rs @@ -18,6 +18,27 @@ use crate::xfa::{ reset_names, resolve_som, run_within, }; +mod access; +mod ast; +mod builtins; +mod dates; +mod eval; +mod lexer; +mod parser; +mod value; +mod words; + +use ast::Stmt; +pub(crate) use dates::days_in; +#[cfg(test)] +use dates::{civil_from_days, days_from_civil, format_date, parse_date}; +use parser::parse; +use value::Val; +#[cfg(test)] +use value::{fixed, num_text, parse_num}; +#[cfg(test)] +use words::word_num; + /// Deepest nesting the parser follows. const MAX_PARSE_DEPTH: usize = 64; /// Most statements and expressions evaluated in one run (loops included). @@ -47,722 +68,6 @@ fn is_limit(e: &str) -> bool { #[cfg(not(target_arch = "wasm32"))] const CLOCK_EVERY: u64 = 256; -// ── lexer ─────────────────────────────────────────────────────────────────────────────────── - -#[derive(Clone, Debug, PartialEq)] -enum Tok { - Num(f64), - Str(String), - Ident(String), - Op(&'static str), - Newline, - Eof, -} - -#[derive(Clone, Debug, PartialEq)] -struct Token { - tok: Tok, - line: usize, -} - -const OPS: [&str; 20] = ["<=", ">=", "==", "<>", "..", "(", ")", "[", "]", ".", ",", "=", "<", ">", "+", "-", "*", "/", "&", "|"]; - -fn lex(src: &str) -> Result, String> { - let chars: Vec = src.chars().collect(); - let mut out = Vec::new(); - let mut i = 0; - let mut line = 1; - let mut depth = 0usize; - while i < chars.len() { - let c = chars[i]; - match c { - '\n' => { - line += 1; - i += 1; - // Newlines inside brackets continue the expression. - if depth == 0 { - out.push(Token { tok: Tok::Newline, line }); - } - } - ' ' | '\t' | '\r' => i += 1, - ';' => { - while i < chars.len() && chars[i] != '\n' { - i += 1; - } - } - '/' if chars.get(i + 1) == Some(&'/') => { - while i < chars.len() && chars[i] != '\n' { - i += 1; - } - } - '"' => { - let mut s = String::new(); - i += 1; - loop { - match chars.get(i) { - None => return Err(format!("line {line}: unterminated string")), - Some('"') if chars.get(i + 1) == Some(&'"') => { - s.push('"'); - i += 2; - } - Some('"') => { - i += 1; - break; - } - Some(&ch) => { - if ch == '\n' { - line += 1; - } - s.push(ch); - i += 1; - } - } - if s.len() > MAX_STRING { - return Err(format!("line {line}: a string is too long")); - } - } - out.push(Token { tok: Tok::Str(s), line }); - } - c if c.is_ascii_digit() || (c == '.' && chars.get(i + 1).is_some_and(char::is_ascii_digit)) => { - let start = i; - while i < chars.len() && (chars[i].is_ascii_digit() || (chars[i] == '.' && chars.get(i + 1) != Some(&'.'))) { - i += 1; - } - if i < chars.len() && (chars[i] == 'e' || chars[i] == 'E') { - let mut j = i + 1; - if j < chars.len() && (chars[j] == '+' || chars[j] == '-') { - j += 1; - } - if j < chars.len() && chars[j].is_ascii_digit() { - i = j; - while i < chars.len() && chars[i].is_ascii_digit() { - i += 1; - } - } - } - let text: String = chars[start..i].iter().collect(); - let n: f64 = text.parse().map_err(|_| format!("line {line}: bad number {text}"))?; - out.push(Token { tok: Tok::Num(n), line }); - } - '!' => { - out.push(Token { tok: Tok::Ident("!".into()), line }); - i += 1; - } - c if c.is_alphabetic() || c == '_' || c == '$' => { - let start = i; - i += 1; - while i < chars.len() && (chars[i].is_alphanumeric() || chars[i] == '_' || chars[i] == '$') { - i += 1; - } - let text: String = chars[start..i].iter().collect(); - out.push(Token { tok: Tok::Ident(text), line }); - } - _ => { - let rest: String = chars[i..chars.len().min(i + 2)].iter().collect(); - let op = OPS.iter().find(|o| rest.starts_with(*o)).copied().ok_or_else(|| format!("line {line}: unexpected character {c:?}"))?; - match op { - "(" | "[" => depth += 1, - ")" | "]" => depth = depth.saturating_sub(1), - _ => {} - } - out.push(Token { tok: Tok::Op(op), line }); - i += op.len(); - } - } - } - out.push(Token { tok: Tok::Newline, line }); - out.push(Token { tok: Tok::Eof, line }); - Ok(out) -} - -// ── syntax ────────────────────────────────────────────────────────────────────────────────── - -#[derive(Clone, Debug, PartialEq)] -enum Expr { - Num(f64), - Str(String), - Null, - Ident(String), - /// `base.name` - Member(Box, String), - /// `base[n]`, `base[*]`, `base[-n]` - Index(Box, Idx), - /// `base..name` - Descend(Box, String), - Call(Box, Vec), - Neg(Box), - Not(Box), - Bin(&'static str, Box, Box), -} - -#[derive(Clone, Debug, PartialEq)] -enum Idx { - /// `[*]`: every instance. - All, - /// `[n]`: the n-th instance (from 0). - Abs(Box), - /// `[-n]` / `[+n]`: n instances before or after the one the script runs in. - Rel(i64), -} - -#[derive(Clone, Debug, PartialEq)] -enum Stmt { - Var(String, Option), - Assign(Expr, Expr), - Expr(Expr), - If(Vec<(Expr, Vec)>, Option>), - While(Expr, Vec), - For { var: String, from: Expr, to: Expr, up: bool, step: Option, body: Vec }, - Foreach { var: String, list: Vec, body: Vec }, - Func { name: String, f: Rc }, - Return(Option), - Break, - Continue, - Exit, - Throw(Expr), -} - -struct Parser { - toks: Vec, - at: usize, - depth: usize, - /// Operators and accessors chained so far in the expression being parsed. - chain: usize, -} - -fn kw(t: &Tok, word: &str) -> bool { - matches!(t, Tok::Ident(s) if s.eq_ignore_ascii_case(word)) -} - -impl Parser { - fn peek(&self) -> &Tok { - self.toks.get(self.at).map_or(&Tok::Eof, |t| &t.tok) - } - - fn line(&self) -> usize { - self.toks.get(self.at).map_or(0, |t| t.line) - } - - fn next(&mut self) -> Tok { - let t = self.peek().clone(); - self.at += 1; - t - } - - fn is_op(&self, op: &str) -> bool { - matches!(self.peek(), Tok::Op(o) if *o == op) - } - - fn eat_op(&mut self, op: &str) -> bool { - if self.is_op(op) { - self.at += 1; - true - } else { - false - } - } - - fn expect_op(&mut self, op: &str) -> Result<(), String> { - if self.eat_op(op) { Ok(()) } else { Err(format!("line {}: expected {op}", self.line())) } - } - - fn eat_kw(&mut self, word: &str) -> bool { - if kw(self.peek(), word) { - self.at += 1; - true - } else { - false - } - } - - fn expect_kw(&mut self, word: &str) -> Result<(), String> { - if self.eat_kw(word) { Ok(()) } else { Err(format!("line {}: expected {word}", self.line())) } - } - - fn skip_newlines(&mut self) { - while matches!(self.peek(), Tok::Newline) { - self.at += 1; - } - } - - fn enter(&mut self) -> Result<(), String> { - self.depth += 1; - if self.depth > MAX_PARSE_DEPTH { - return Err("the script is nested too deeply".into()); - } - Ok(()) - } - - fn leave(&mut self) { - self.depth = self.depth.saturating_sub(1); - } - - /// One more link in an operator or accessor chain. - fn link(&mut self) -> Result<(), String> { - self.chain += 1; - if self.chain > MAX_CHAIN { - return Err(format!("line {}: an expression is too long", self.line())); - } - Ok(()) - } - - /// Statements up to one of `enders` (not consumed). - fn block(&mut self, enders: &[&str]) -> Result, String> { - self.enter()?; - let mut out = Vec::new(); - loop { - self.skip_newlines(); - match self.peek() { - Tok::Eof => break, - Tok::Ident(w) if enders.iter().any(|e| w.eq_ignore_ascii_case(e)) => break, - _ => out.push(self.statement()?), - } - } - self.leave(); - Ok(out) - } - - fn statement(&mut self) -> Result { - let line = self.line(); - match self.peek().clone() { - Tok::Ident(w) if w.eq_ignore_ascii_case("var") => { - self.at += 1; - let Tok::Ident(name) = self.next() else { return Err(format!("line {line}: expected a name after var")) }; - let init = if self.eat_op("=") { Some(self.top_expr()?) } else { None }; - Ok(Stmt::Var(name, init)) - } - Tok::Ident(w) if w.eq_ignore_ascii_case("if") => { - self.at += 1; - let mut arms = Vec::new(); - let cond = self.top_expr()?; - self.expect_kw("then")?; - let body = self.block(&["elseif", "else", "endif"])?; - arms.push((cond, body)); - let mut otherwise = None; - loop { - if self.eat_kw("elseif") { - let c = self.top_expr()?; - self.expect_kw("then")?; - let b = self.block(&["elseif", "else", "endif"])?; - arms.push((c, b)); - } else if self.eat_kw("else") { - otherwise = Some(self.block(&["endif"])?); - } else { - self.expect_kw("endif")?; - break; - } - } - Ok(Stmt::If(arms, otherwise)) - } - Tok::Ident(w) if w.eq_ignore_ascii_case("while") => { - self.at += 1; - let cond = self.top_expr()?; - self.expect_kw("do")?; - let body = self.block(&["endwhile"])?; - self.expect_kw("endwhile")?; - Ok(Stmt::While(cond, body)) - } - Tok::Ident(w) if w.eq_ignore_ascii_case("for") => { - self.at += 1; - let Tok::Ident(var) = self.next() else { return Err(format!("line {line}: expected a variable after for")) }; - self.expect_op("=")?; - let from = self.top_expr()?; - let up = if self.eat_kw("upto") { - true - } else if self.eat_kw("downto") { - false - } else { - return Err(format!("line {line}: expected upto or downto")); - }; - let to = self.top_expr()?; - let step = if self.eat_kw("step") { Some(self.top_expr()?) } else { None }; - self.expect_kw("do")?; - let body = self.block(&["endfor"])?; - self.expect_kw("endfor")?; - Ok(Stmt::For { var, from, to, up, step, body }) - } - Tok::Ident(w) if w.eq_ignore_ascii_case("foreach") => { - self.at += 1; - let Tok::Ident(var) = self.next() else { return Err(format!("line {line}: expected a variable after foreach")) }; - self.expect_kw("in")?; - self.expect_op("(")?; - let mut list = Vec::new(); - if !self.is_op(")") { - loop { - list.push(self.top_expr()?); - if !self.eat_op(",") { - break; - } - } - } - self.expect_op(")")?; - self.expect_kw("do")?; - let body = self.block(&["endfor"])?; - self.expect_kw("endfor")?; - Ok(Stmt::Foreach { var, list, body }) - } - Tok::Ident(w) if w.eq_ignore_ascii_case("func") => { - self.at += 1; - let Tok::Ident(name) = self.next() else { return Err(format!("line {line}: expected a name after func")) }; - self.expect_op("(")?; - let mut params = Vec::new(); - if !self.is_op(")") { - loop { - let Tok::Ident(p) = self.next() else { return Err(format!("line {line}: expected a parameter name")) }; - params.push(p); - if !self.eat_op(",") { - break; - } - } - } - self.expect_op(")")?; - self.expect_kw("do")?; - let body = self.block(&["endfunc"])?; - self.expect_kw("endfunc")?; - Ok(Stmt::Func { name, f: Rc::new(Func { params, body }) }) - } - Tok::Ident(w) if w.eq_ignore_ascii_case("return") => { - self.at += 1; - let v = if matches!(self.peek(), Tok::Newline | Tok::Eof) { None } else { Some(self.top_expr()?) }; - Ok(Stmt::Return(v)) - } - Tok::Ident(w) if w.eq_ignore_ascii_case("break") => { - self.at += 1; - Ok(Stmt::Break) - } - Tok::Ident(w) if w.eq_ignore_ascii_case("continue") => { - self.at += 1; - Ok(Stmt::Continue) - } - Tok::Ident(w) if w.eq_ignore_ascii_case("exit") => { - self.at += 1; - Ok(Stmt::Exit) - } - Tok::Ident(w) if w.eq_ignore_ascii_case("throw") => { - self.at += 1; - Ok(Stmt::Throw(self.top_expr()?)) - } - Tok::Ident(w) if w.eq_ignore_ascii_case("do") => { - // `do … end`: a block as one statement. - self.at += 1; - let body = self.block(&["end"])?; - self.expect_kw("end")?; - Ok(Stmt::If(vec![(Expr::Num(1.0), body)], None)) - } - _ => { - let e = self.top_expr()?; - if self.eat_op("=") { - let v = self.top_expr()?; - return Ok(Stmt::Assign(e, v)); - } - Ok(Stmt::Expr(e)) - } - } - } - - /// An expression that starts a tree of its own (a condition, a bound, a statement's - /// expression): its operator chain is counted from zero, whatever came before it. - fn top_expr(&mut self) -> Result { - self.chain = 0; - self.expr() - } - - fn expr(&mut self) -> Result { - self.enter()?; - let e = self.or_expr(); - self.leave(); - e - } - - fn or_expr(&mut self) -> Result { - let mut l = self.and_expr()?; - loop { - if self.eat_op("|") || self.eat_kw("or") { - self.link()?; - let r = self.and_expr()?; - l = Expr::Bin("|", Box::new(l), Box::new(r)); - } else { - return Ok(l); - } - } - } - - fn and_expr(&mut self) -> Result { - let mut l = self.cmp_expr()?; - loop { - if self.eat_op("&") || self.eat_kw("and") { - self.link()?; - let r = self.cmp_expr()?; - l = Expr::Bin("&", Box::new(l), Box::new(r)); - } else { - return Ok(l); - } - } - } - - fn cmp_expr(&mut self) -> Result { - let mut l = self.add_expr()?; - loop { - let op = match self.peek() { - Tok::Op("==") => "==", - Tok::Op("<>") => "<>", - Tok::Op("<=") => "<=", - Tok::Op(">=") => ">=", - Tok::Op("<") => "<", - Tok::Op(">") => ">", - Tok::Ident(w) if w.eq_ignore_ascii_case("eq") => "==", - Tok::Ident(w) if w.eq_ignore_ascii_case("ne") => "<>", - Tok::Ident(w) if w.eq_ignore_ascii_case("le") => "<=", - Tok::Ident(w) if w.eq_ignore_ascii_case("ge") => ">=", - Tok::Ident(w) if w.eq_ignore_ascii_case("lt") => "<", - Tok::Ident(w) if w.eq_ignore_ascii_case("gt") => ">", - _ => return Ok(l), - }; - self.at += 1; - self.link()?; - let r = self.add_expr()?; - l = Expr::Bin(op, Box::new(l), Box::new(r)); - } - } - - fn add_expr(&mut self) -> Result { - let mut l = self.mul_expr()?; - loop { - let op = match self.peek() { - Tok::Op("+") => "+", - Tok::Op("-") => "-", - _ => return Ok(l), - }; - self.at += 1; - self.link()?; - let r = self.mul_expr()?; - l = Expr::Bin(op, Box::new(l), Box::new(r)); - } - } - - fn mul_expr(&mut self) -> Result { - let mut l = self.unary()?; - loop { - let op = match self.peek() { - Tok::Op("*") => "*", - Tok::Op("/") => "/", - _ => return Ok(l), - }; - self.at += 1; - self.link()?; - let r = self.unary()?; - l = Expr::Bin(op, Box::new(l), Box::new(r)); - } - } - - fn unary(&mut self) -> Result { - self.enter()?; - let e = if self.eat_op("-") { - Ok(Expr::Neg(Box::new(self.unary()?))) - } else if self.eat_op("+") { - self.unary() - } else if self.eat_kw("not") || matches!(self.peek(), Tok::Ident(w) if w == "!") { - if matches!(self.peek(), Tok::Ident(w) if w == "!") { - self.at += 1; - } - Ok(Expr::Not(Box::new(self.unary()?))) - } else { - self.postfix() - }; - self.leave(); - e - } - - fn postfix(&mut self) -> Result { - let mut e = self.primary()?; - loop { - if matches!(self.peek(), Tok::Op("." | ".." | "[" | "(")) { - self.link()?; - } - if self.eat_op(".") { - let Tok::Ident(name) = self.next() else { return Err(format!("line {}: expected a name after .", self.line())) }; - e = Expr::Member(Box::new(e), name); - } else if self.eat_op("..") { - let Tok::Ident(name) = self.next() else { return Err(format!("line {}: expected a name after ..", self.line())) }; - e = Expr::Descend(Box::new(e), name); - } else if self.eat_op("[") { - let sign = match self.peek() { - Tok::Op("-") => Some(-1), - Tok::Op("+") => Some(1), - _ => None, - }; - let relative = sign.and_then(|sign| match (self.toks.get(self.at + 1).map(|t| &t.tok), self.toks.get(self.at + 2).map(|t| &t.tok)) { - (Some(Tok::Num(n)), Some(Tok::Op("]"))) if n.is_finite() && *n >= 0.0 && *n < 1e6 => Some(sign * (*n as i64)), - _ => None, - }); - let idx = if let Some(k) = relative { - self.at += 2; - Idx::Rel(k) - } else if self.eat_op("*") { - Idx::All - } else { - Idx::Abs(Box::new(self.expr()?)) - }; - self.expect_op("]")?; - e = Expr::Index(Box::new(e), idx); - } else if self.eat_op("(") { - let mut args = Vec::new(); - self.skip_newlines(); - if !self.is_op(")") { - loop { - args.push(self.expr()?); - self.skip_newlines(); - if !self.eat_op(",") { - break; - } - self.skip_newlines(); - } - } - self.expect_op(")")?; - e = Expr::Call(Box::new(e), args); - } else { - return Ok(e); - } - } - } - - fn primary(&mut self) -> Result { - let line = self.line(); - match self.next() { - Tok::Num(n) => Ok(Expr::Num(n)), - Tok::Str(s) => Ok(Expr::Str(s)), - Tok::Op("(") => { - self.skip_newlines(); - let e = self.expr()?; - self.skip_newlines(); - self.expect_op(")")?; - Ok(e) - } - Tok::Ident(w) if w.eq_ignore_ascii_case("null") => Ok(Expr::Null), - Tok::Ident(w) if w.eq_ignore_ascii_case("nan") => Ok(Expr::Num(f64::NAN)), - Tok::Ident(w) if w.eq_ignore_ascii_case("infinity") => Ok(Expr::Num(f64::INFINITY)), - Tok::Ident(w) if w.eq_ignore_ascii_case("true") => Ok(Expr::Num(1.0)), - Tok::Ident(w) if w.eq_ignore_ascii_case("false") => Ok(Expr::Num(0.0)), - Tok::Ident(w) => Ok(Expr::Ident(w)), - t => Err(format!("line {line}: unexpected {}", describe(&t))), - } - } -} - -fn describe(t: &Tok) -> String { - match t { - Tok::Num(n) => format!("number {n}"), - Tok::Str(s) => format!("string {s:?}"), - Tok::Ident(s) => s.clone(), - Tok::Op(o) => format!("{o:?}"), - Tok::Newline => "end of line".into(), - Tok::Eof => "end of script".into(), - } -} - -fn parse(src: &str) -> Result, String> { - let toks = lex(src)?; - let mut p = Parser { toks, at: 0, depth: 0, chain: 0 }; - let body = p.block(&[])?; - match p.peek() { - Tok::Eof => Ok(body), - t => Err(format!("line {}: unexpected {}", p.line(), describe(t))), - } -} - -// ── values ────────────────────────────────────────────────────────────────────────────────── - -#[derive(Clone, Debug, PartialEq)] -enum Val { - Null, - Num(f64), - Str(String), - /// A form object, by index in the host. - Node(usize), - /// Several objects or values (`Row[*]`, `Row[*].Amount`). - List(Vec), - Host, - Layout, - Event, - /// `xfa` itself (`xfa.form`, `xfa.host`, …). - Xfa, - /// The instance manager of `name` under `parent`. - Im(usize, String), - /// A property object whose contents are not modelled (`$.border.fill…`): reads give - /// another sink, writes change nothing. - Sink, -} - -/// A number as FormCalc prints it: integers without a fraction, otherwise up to 15 digits. -fn num_text(n: f64) -> String { - if !n.is_finite() { - return if n.is_nan() { - "NaN".into() - } else if n > 0.0 { - "Infinity".into() - } else { - "-Infinity".into() - }; - } - if n.fract() == 0.0 && n.abs() < 1e15 { - return format!("{}", n as i64); - } - let s = format!("{n:.12}"); - let s = s.trim_end_matches('0').trim_end_matches('.'); - if s.is_empty() || s == "-0" { "0".into() } else { s.to_string() } -} - -/// `v` with `decimals` places, rounding exact halves away from zero (Rust's formatter rounds them to even). -fn fixed(v: f64, decimals: usize) -> String { - let k = 10f64.powi(decimals.min(20) as i32); - let scaled = (v * k).round() / k; - // A small negative value rounds to -0.0, which would print as "-0". - let scaled = if scaled == 0.0 { 0.0 } else { scaled }; - format!("{:.decimals$}", if scaled.is_finite() { scaled } else { v }) -} - -/// The number a string holds: its leading numeric part (`12abc` → 12), `None` for none. -fn parse_num(s: &str) -> Option { - let t = s.trim(); - if t.is_empty() { - return None; - } - let mut end = 0; - let b = t.as_bytes(); - let mut seen_digit = false; - let mut seen_dot = false; - let mut seen_e = false; - let mut i = 0; - if i < b.len() && (b[i] == b'-' || b[i] == b'+') { - i += 1; - } - while i < b.len() { - let c = b[i]; - if c.is_ascii_digit() { - seen_digit = true; - end = i + 1; - } else if c == b'.' && !seen_dot && !seen_e { - seen_dot = true; - } else if (c == b'e' || c == b'E') && seen_digit && !seen_e { - seen_e = true; - if i + 1 < b.len() && (b[i + 1] == b'-' || b[i + 1] == b'+') { - i += 1; - } - } else { - break; - } - i += 1; - } - if !seen_digit { - return None; - } - t.get(..end).and_then(|p| p.parse::().ok()).filter(|v| v.is_finite()) -} - -fn is_numeric_text(s: &str) -> bool { - s.trim().parse::().is_ok_and(f64::is_finite) -} - // ── the evaluator ─────────────────────────────────────────────────────────────────────────── enum Flow { @@ -799,1760 +104,6 @@ struct Interp<'h> { type R = Result; -impl Interp<'_> { - fn step(&mut self) -> R<()> { - self.charge(1) - } - - /// `n` units of work done (a step per statement or expression, more for work on long - /// strings and lists). - fn charge(&mut self, n: u64) -> R<()> { - let before = self.steps; - self.steps = self.steps.saturating_add(n); - if self.steps > MAX_STEPS { - return Err(RAN_TOO_LONG.into()); - } - #[cfg(not(target_arch = "wasm32"))] - if self.steps / CLOCK_EVERY != before / CLOCK_EVERY - && let Some(d) = self.deadline - && std::time::Instant::now() > d - { - return Err(RAN_TOO_LONG.into()); - } - #[cfg(target_arch = "wasm32")] - let _ = before; - Ok(()) - } - - /// `more` nodes into `out`, each once, never past [`MAX_LIST`]. - fn gather(&mut self, out: &mut Vec, seen: &mut HashSet, more: impl IntoIterator) -> R<()> { - for i in more { - if seen.insert(i) { - out.push(i); - if out.len() > MAX_LIST { - return Err(LIST_TOO_LONG.into()); - } - } - } - self.charge(1) - } - - /// The nodes of `v`, each once. - fn nodes_list(&self, v: &Val) -> Vec { - let mut seen = HashSet::new(); - self.nodes_of(v).into_iter().filter(|i| seen.insert(*i)).collect() - } - - fn node(&self, i: usize) -> Option<&HNode> { - self.h.nodes.get(i).filter(|n| !n.gone) - } - - // ── coercions - - /// A node's value as a FormCalc value. - fn node_value(&self, i: usize) -> Val { - let Some(n) = self.node(i) else { return Val::Null }; - if n.value.is_empty() { - return Val::Null; - } - if n.numeric - && let Some(v) = parse_num(&n.value) - { - return Val::Num(v); - } - Val::Str(n.value.clone()) - } - - /// What copying `v` (or the values it stands for) costs, in steps: one per 256 bytes, so - /// copying long strings over and over runs out of steps even where there is no clock - /// (wasm). A form object counts its value; a list its items too. - fn cost(&self, v: &Val) -> u64 { - let mut bytes = 0usize; - let mut todo = vec![v]; - while let Some(v) = todo.pop() { - match v { - Val::Str(s) => bytes = bytes.saturating_add(s.len()), - Val::Node(i) => bytes = bytes.saturating_add(self.node(*i).map_or(0, |n| n.value.len())), - Val::List(items) => { - bytes = bytes.saturating_add(items.len().saturating_mul(16)); - todo.extend(items.iter()); - } - _ => {} - } - } - (bytes / 256) as u64 - } - - /// [`Self::deref`], charged for the copy. - fn take(&mut self, v: Val) -> R { - self.charge(self.cost(&v))?; - Ok(self.deref(v)) - } - - /// Objects become their values; lists of objects become lists of values. - fn deref(&self, v: Val) -> Val { - match v { - Val::Node(i) => self.node_value(i), - Val::List(items) => Val::List(items.into_iter().map(|x| self.deref(x)).collect()), - other => other, - } - } - - fn to_num(&self, v: &Val) -> f64 { - match v { - Val::Null => 0.0, - Val::Num(n) => *n, - Val::Str(s) => parse_num(s).unwrap_or(0.0), - Val::Node(i) => self.to_num(&self.node_value(*i)), - Val::List(items) => items.first().map_or(0.0, |x| self.to_num(x)), - _ => 0.0, - } - } - - fn to_str(&self, v: &Val) -> String { - match v { - Val::Null => String::new(), - Val::Num(n) => num_text(*n), - Val::Str(s) => s.clone(), - Val::Node(i) => self.to_str(&self.node_value(*i)), - Val::List(items) => items.first().map_or(String::new(), |x| self.to_str(x)), - Val::Host => "host".into(), - Val::Layout => "layout".into(), - Val::Event => "event".into(), - Val::Xfa => "xfa".into(), - Val::Im(..) => "instanceManager".into(), - Val::Sink => String::new(), - } - } - - fn truthy(&self, v: &Val) -> bool { - match v { - Val::Null => false, - Val::Num(n) => *n != 0.0 && !n.is_nan(), - Val::Str(s) => parse_num(s).is_some_and(|n| n != 0.0), - Val::Node(i) => { - let d = self.node_value(*i); - self.truthy(&d) - } - Val::List(items) => !items.is_empty(), - _ => true, - } - } - - fn is_null(&self, v: &Val) -> bool { - match v { - Val::Null => true, - Val::Node(i) => self.node(*i).is_none_or(|n| n.value.is_empty()), - Val::List(items) => items.is_empty(), - _ => false, - } - } - - // ── scopes - - fn lookup(&self, name: &str) -> Option { - self.scopes.iter().rev().find_map(|s| s.get(name).cloned()) - } - - fn set_var(&mut self, name: &str, v: Val) -> bool { - for s in self.scopes.iter_mut().rev() { - if let Some(slot) = s.get_mut(name) { - *slot = v; - return true; - } - } - false - } - - fn declare(&mut self, name: &str, v: Val) { - if let Some(s) = self.scopes.last_mut() { - s.insert(name.to_string(), v); - } - } - - // ── accessors - - /// The objects an identifier names from the current node: `$`, the special objects, an - /// instance manager (`_name`), a variable, or a form object by name (children first, then - /// the containing subforms). - fn ident(&self, name: &str) -> R { - if let Some(v) = self.lookup(name) { - return Ok(v); - } - let lower = name.to_ascii_lowercase(); - Ok(match lower.as_str() { - "$" | "this" => Val::Node(self.h.current), - "$host" => Val::Host, - "$layout" => Val::Layout, - "$event" => Val::Event, - "$form" | "$data" | "$dataset" | "$datasets" => Val::Node(0), - "$record" => Val::Node(if self.node(1).is_some() { 1 } else { 0 }), - "$template" | "$xfa" => Val::Xfa, - "xfa" => Val::Xfa, - _ => { - if let Some(child) = name.strip_prefix('_') { - // The instance manager of `child`, looked for like a name. - let found = resolve_som(self.h, self.h.current, child); - let Some(&first) = found.first() else { return Err(format!("unknown instance manager _{child}")) }; - let parent = self.node(first).and_then(|n| n.parent).ok_or_else(|| format!("unknown instance manager _{child}"))?; - return Ok(Val::Im(parent, child.to_string())); - } - let found = resolve_som(self.h, self.h.current, name); - match found.as_slice() { - [] => return Err(format!("unknown name {name}")), - [one] => Val::Node(*one), - many => Val::List(many.iter().map(|i| Val::Node(*i)).collect()), - } - } - }) - } - - /// Every object a value stands for. - fn nodes_of(&self, v: &Val) -> Vec { - match v { - Val::Node(i) => vec![*i], - Val::List(items) => items.iter().flat_map(|x| self.nodes_of(x)).collect(), - _ => Vec::new(), - } - } - - fn member(&mut self, base: Val, name: &str) -> R { - match base { - Val::Node(i) => self.node_member(i, name), - Val::List(items) => { - let mut out = Vec::new(); - let mut seen = HashSet::new(); - for it in items { - match self.member(it, name)? { - Val::List(more) => { - for m in more { - self.list_push(&mut out, &mut seen, m)?; - } - } - Val::Null => {} - v => self.list_push(&mut out, &mut seen, v)?, - } - } - Ok(match out.len() { - 0 => Val::Null, - 1 => out.remove(0), - _ => Val::List(out), - }) - } - Val::Host => Ok(match name.to_ascii_lowercase().as_str() { - "numpages" => Val::Num(self.h.doc.page_count as f64), - "currentpage" => Val::Num(self.h.doc.page as f64), - "name" => Val::Str("Acrobat".into()), - "apptype" => Val::Str("Exchange-Pro".into()), - "version" => Val::Str("11".into()), - "variation" => Val::Str("Full".into()), - "language" => Val::Str("en".into()), - "platform" => Val::Str(crate::platform().into()), - "title" => Val::Str(self.h.doc.file_name.clone()), - "calculationsenabled" | "validationsenabled" => Val::Num(1.0), - _ => Val::Sink, - }), - Val::Layout => Ok(Val::Sink), - Val::Event => Ok(match name.to_ascii_lowercase().as_str() { - "name" => Val::Str(self.event.activity.clone()), - "newtext" | "fulltext" => Val::Str(self.event.new_text.clone()), - "prevtext" => Val::Str(self.event.prev_text.clone()), - "change" => Val::Str(String::new()), - "target" => Val::Node(self.h.current), - "selstart" | "selend" | "cancelaction" | "reenter" | "shift" | "modifier" | "keydown" | "commitkey" => Val::Num(0.0), - _ => Val::Sink, - }), - Val::Xfa => Ok(match name.to_ascii_lowercase().as_str() { - "form" | "datasets" | "data" => Val::Node(0), - "record" => Val::Node(if self.node(1).is_some() { 1 } else { 0 }), - "host" => Val::Host, - "layout" => Val::Layout, - "event" => Val::Event, - _ => Val::Sink, - }), - Val::Im(parent, child) => Ok(match name.to_ascii_lowercase().as_str() { - "count" => Val::Num(instances(self.h, parent, &child).len() as f64), - "min" => Val::Num(instances(self.h, parent, &child).first().and_then(|i| self.node(*i)).map_or(0.0, |n| n.occur_min as f64)), - "max" => Val::Num( - instances(self.h, parent, &child).first().and_then(|i| self.node(*i)).and_then(|n| n.occur_max).map_or(-1.0, |m| m as f64), - ), - "classname" => Val::Str("instanceManager".into()), - _ => Val::Sink, - }), - Val::Sink => Ok(Val::Sink), - Val::Null => Err(format!("no object to take {name} from")), - Val::Num(_) | Val::Str(_) => Err(format!("{} has no property {name}", self.to_str(&base))), - } - } - - /// `v` onto `out`: a node only once, never past [`MAX_LIST`]. - fn list_push(&mut self, out: &mut Vec, seen: &mut HashSet, v: Val) -> R<()> { - if let Val::Node(i) = v - && !seen.insert(i) - { - return Ok(()); - } - out.push(v); - if out.len() > MAX_LIST { - return Err(LIST_TOO_LONG.into()); - } - self.charge(1) - } - - /// Property `name` of form object `i` as FormCalc reads it: its value, attributes, - /// relatives and children by name. - fn node_member(&mut self, i: usize, name: &str) -> R { - let Some(n) = self.node(i) else { return Ok(Val::Null) }; - Ok(match name { - "formattedValue" => match &n.formatted { - Some(f) => Val::Str(f.clone()), - None => self.node_value(i), - }, - "rawValue" | "value" => self.node_value(i), - "name" => Val::Str(n.name.clone()), - "somExpression" => Val::Str(n.som.clone()), - "className" => Val::Str(n.kind.class_name().into()), - "index" | "instanceIndex" => Val::Num(n.index as f64), - "presence" => Val::Str(n.presence.clone()), - "access" => Val::Str(n.access.clone()), - "isNull" => Val::Num(if n.value.is_empty() { 1.0 } else { 0.0 }), - "mandatory" => Val::Str("disabled".into()), - "parent" => n.parent.map_or(Val::Null, Val::Node), - "nodes" => Val::List(n.children.iter().copied().filter(|c| self.node(*c).is_some()).map(Val::Node).collect()), - "all" => match n.parent { - Some(p) => Val::List(instances(self.h, p, &n.name).into_iter().map(Val::Node).collect()), - None => Val::Node(i), - }, - "instanceManager" => match n.parent { - Some(p) => Val::Im(p, n.name.clone()), - None => Val::Null, - }, - "border" | "ui" | "font" | "caption" | "fillColor" | "borderColor" | "fontColor" | "margin" | "para" | "assist" | "validate" - | "format" | "items" | "bind" | "keep" | "h" | "w" | "x" | "y" | "minH" | "maxH" | "minW" | "maxW" | "layout" | "model" - | "oneOfChild" | "locale" | "relevant" | "colSpan" | "dataNode" | "defaultValue" | "editValue" | "selectedIndex" | "event" - | "calculate" | "traversal" | "extras" | "desc" => Val::Sink, - other => { - if let Some(child) = other.strip_prefix('_') { - return Ok(if child_named(self.h, i, child).is_some() { Val::Im(i, child.to_string()) } else { Val::Null }); - } - let found = instances(self.h, i, other); - match found.as_slice() { - [] => Val::Null, - [one] => Val::Node(*one), - many => Val::List(many.iter().map(|k| Val::Node(*k)).collect()), - } - } - }) - } - - /// `base[n]` / `base[*]`: all the instances the last step named, then one or all. - fn index(&mut self, base: &Expr, idx: &Idx) -> R { - let all = self.all_of(base)?; - match idx { - Idx::All => Ok(Val::List(all.into_iter().map(Val::Node).collect())), - Idx::Abs(e) => { - let v = self.eval(e)?; - let n = self.to_num(&v); - if !n.is_finite() || n < 0.0 { - return Ok(Val::Null); - } - Ok(all.get(n as usize).map_or(Val::Null, |i| Val::Node(*i))) - } - Idx::Rel(k) => { - // From the instance the script runs in (`row[-1]` in a row is the row above), - // else from the first. - let here = all.iter().position(|&i| self.encloses(i, self.h.current)).unwrap_or(0); - let at = (here as i64).saturating_add(*k); - Ok(usize::try_from(at).ok().and_then(|at| all.get(at)).map_or(Val::Null, |i| Val::Node(*i))) - } - } - } - - /// Whether `node` is `inner` or one of its containers. - fn encloses(&self, node: usize, inner: usize) -> bool { - let mut at = Some(inner); - let mut guard = 0; - while let Some(i) = at - && guard < MAX_PARSE_DEPTH - { - if i == node { - return true; - } - at = self.h.nodes.get(i).and_then(|n| n.parent); - guard += 1; - } - false - } - - /// Every instance an accessor names (`Row` → all rows, not just the first), each once. - fn all_of(&mut self, e: &Expr) -> R> { - let mut out = Vec::new(); - let mut seen = HashSet::new(); - match e { - Expr::Ident(name) => { - if self.lookup(name).is_some() || name.starts_with('$') || name.eq_ignore_ascii_case("xfa") { - let v = self.eval(e)?; - return Ok(self.nodes_list(&v)); - } - // The name resolves to its first instance; `row[*]` means every sibling of it. - for first in resolve_som(self.h, self.h.current, name) { - match self.node(first).and_then(|n| n.parent.map(|p| (p, n.name.clone()))) { - Some((p, n)) => self.gather(&mut out, &mut seen, instances(self.h, p, &n))?, - None => self.gather(&mut out, &mut seen, [first])?, - } - } - } - Expr::Member(base, name) => { - let b = self.eval(base)?; - for i in self.nodes_list(&b) { - self.gather(&mut out, &mut seen, instances(self.h, i, name))?; - } - if out.is_empty() { - let v = self.member(b, name)?; - out = self.nodes_list(&v); - } - } - Expr::Descend(base, name) => { - let b = self.eval(base)?; - for i in self.nodes_list(&b) { - self.gather(&mut out, &mut seen, descendants_named(self.h, i, name, 0))?; - } - } - other => { - let v = self.eval(other)?; - out = self.nodes_list(&v); - } - } - Ok(out) - } - - // ── expressions - - fn eval(&mut self, e: &Expr) -> R { - self.step()?; - self.depth += 1; - if self.depth > self.max_depth { - self.depth -= 1; - return Err(TOO_DEEP.into()); - } - let r = self.eval_inner(e); - self.depth -= 1; - r - } - - fn eval_inner(&mut self, e: &Expr) -> R { - Ok(match e { - Expr::Num(n) => Val::Num(*n), - Expr::Str(s) => { - self.charge((s.len() / 256) as u64)?; - Val::Str(s.clone()) - } - Expr::Null => Val::Null, - Expr::Ident(name) => { - let v = self.ident(name)?; - self.charge(self.cost(&v))?; - v - } - Expr::Member(base, name) => { - let b = self.eval(base)?; - self.member(b, name)? - } - Expr::Index(base, idx) => self.index(base, idx)?, - Expr::Descend(base, name) => { - let b = self.eval(base)?; - let mut out = Vec::new(); - let mut seen = HashSet::new(); - for i in self.nodes_list(&b) { - self.gather(&mut out, &mut seen, descendants_named(self.h, i, name, 0))?; - } - match out.as_slice() { - [] => Val::Null, - [one] => Val::Node(*one), - many => Val::List(many.iter().map(|k| Val::Node(*k)).collect()), - } - } - Expr::Call(callee, args) => self.call(callee, args)?, - Expr::Neg(x) => { - let v = self.eval(x)?; - if self.is_null(&v) { Val::Null } else { Val::Num(-self.to_num(&v)) } - } - Expr::Not(x) => { - let v = self.eval(x)?; - Val::Num(if self.truthy(&v) { 0.0 } else { 1.0 }) - } - Expr::Bin(..) => { - // The parser leans left (`a + b + c` is `(a + b) + c`), so a chain is walked - // from its leftmost operand and folded without recursing per operator. - let mut links = Vec::new(); - let mut leftmost = e; - while let Expr::Bin(op, l, r) = leftmost { - links.push((*op, r.as_ref())); - leftmost = l; - } - let mut acc = self.eval(leftmost)?; - for (op, r) in links.into_iter().rev() { - self.step()?; - acc = match op { - "&" => { - if !self.truthy(&acc) { - // Not evaluated: the result is settled. - Val::Num(0.0) - } else { - let b = self.eval(r)?; - Val::Num(if self.truthy(&b) { 1.0 } else { 0.0 }) - } - } - "|" => { - if self.truthy(&acc) { - Val::Num(1.0) - } else { - let b = self.eval(r)?; - Val::Num(if self.truthy(&b) { 1.0 } else { 0.0 }) - } - } - _ => { - let b = self.eval(r)?; - // Comparing (and dereferencing) long strings is work too. - self.charge(self.cost(&acc).saturating_add(self.cost(&b)))?; - self.binary(op, acc, b)? - } - }; - } - acc - } - }) - } - - fn binary(&self, op: &str, a: Val, b: Val) -> R { - let (a, b) = (self.deref(a), self.deref(b)); - // Null with an arithmetic operator is null only when both sides are null. - match op { - "+" | "-" | "*" | "/" => { - if self.is_null(&a) && self.is_null(&b) { - return Ok(Val::Null); - } - let (x, y) = (self.to_num(&a), self.to_num(&b)); - let v = match op { - "+" => x + y, - "-" => x - y, - "*" => x * y, - _ => { - if y == 0.0 { - return Err("division by zero".into()); - } - x / y - } - }; - Ok(Val::Num(v)) - } - _ => { - let ord = match (&a, &b) { - (Val::Null, Val::Null) => Some(std::cmp::Ordering::Equal), - (Val::Null, _) | (_, Val::Null) => None, - (Val::Str(s), Val::Str(t)) if !(is_numeric_text(s) && is_numeric_text(t)) => Some(s.cmp(t)), - (Val::Str(s), Val::Num(_)) if !is_numeric_text(s) => Some(s.as_str().cmp(num_text(self.to_num(&b)).as_str())), - (Val::Num(_), Val::Str(t)) if !is_numeric_text(t) => Some(num_text(self.to_num(&a)).as_str().cmp(t.as_str())), - _ => self.to_num(&a).partial_cmp(&self.to_num(&b)), - }; - let r = match (op, ord) { - ("==", Some(o)) => o.is_eq(), - ("<>", Some(o)) => !o.is_eq(), - ("<>", None) => true, - ("<", Some(o)) => o.is_lt(), - ("<=", Some(o)) => o.is_le(), - (">", Some(o)) => o.is_gt(), - (">=", Some(o)) => o.is_ge(), - _ => false, - }; - Ok(Val::Num(if r { 1.0 } else { 0.0 })) - } - } - } - - // ── calls - - fn call(&mut self, callee: &Expr, args: &[Expr]) -> R { - match callee { - Expr::Ident(name) => { - if let Some(f) = self.funcs.get(&name.to_ascii_lowercase()).cloned() { - let mut vals = Vec::new(); - for a in args { - let v = self.eval(a)?; - vals.push(self.take(v)?); - } - return self.call_user(&f, vals); - } - self.builtin(name, args) - } - Expr::Member(base, method) => { - let b = self.eval(base)?; - self.method(b, method, args) - } - _ => Err("not a function".into()), - } - } - - fn call_user(&mut self, f: &Func, vals: Vec) -> R { - if self.scopes.len() > self.max_depth { - return Err(CALLS_TOO_DEEP.into()); - } - let mut scope = HashMap::new(); - for (i, p) in f.params.iter().enumerate() { - scope.insert(p.clone(), vals.get(i).cloned().unwrap_or(Val::Null)); - } - self.scopes.push(scope); - let r = self.block(&f.body); - self.scopes.pop(); - match r? { - (Flow::Return(v), _) => Ok(v), - (Flow::Exit, last) => { - self.exited = true; - Ok(last) - } - (_, last) => Ok(last), - } - } - - fn arg(&mut self, args: &[Expr], i: usize) -> R { - match args.get(i) { - Some(e) => { - let v = self.eval(e)?; - self.take(v) - } - None => Ok(Val::Null), - } - } - - fn arg_num(&mut self, args: &[Expr], i: usize) -> R { - let v = self.arg(args, i)?; - Ok(self.to_num(&v)) - } - - fn arg_str(&mut self, args: &[Expr], i: usize) -> R { - let v = self.arg(args, i)?; - Ok(self.to_str(&v)) - } - - /// Every value the arguments hold, lists flattened, nulls left out. - fn arg_values(&mut self, args: &[Expr]) -> R> { - self.arg_values_keeping(args, false) - } - - /// [`Self::arg_values`]; `objects` keeps form objects (fields, subforms) as themselves, so - /// `Count(row[*])` counts rows rather than filled-in values. - fn arg_values_keeping(&mut self, args: &[Expr], objects: bool) -> R> { - let mut out = Vec::new(); - for a in args { - let v = self.eval(a)?; - let v = if objects { v } else { self.take(v)? }; - match v { - Val::List(items) => out.extend(items.into_iter().filter(|x| !matches!(x, Val::Null))), - Val::Null => {} - other => out.push(other), - } - if out.len() > MAX_LIST { - return Err(LIST_TOO_LONG.into()); - } - } - Ok(out) - } - - fn method(&mut self, base: Val, method: &str, args: &[Expr]) -> R { - let m = method.to_ascii_lowercase(); - match base { - Val::Host => match m.as_str() { - "messagebox" => { - let t = self.arg_str(args, 0)?; - self.h.push(XfaEffect::MessageBox(t)); - Ok(Val::Num(1.0)) - } - "resetdata" => { - let list = self.arg_str(args, 0)?; - self.h.push(XfaEffect::ResetData(reset_names(&list))); - Ok(Val::Null) - } - "print" => { - self.h.push(XfaEffect::Print); - Ok(Val::Null) - } - "beep" => { - self.h.push(XfaEffect::Beep); - Ok(Val::Null) - } - "gotourl" => { - let u = self.arg_str(args, 0)?; - self.h.push(XfaEffect::LaunchUrl(u)); - Ok(Val::Null) - } - "setfocus" => { - let target = match args.first() { - Some(e) => { - let raw = self.eval(e)?; - match self.nodes_of(&raw).first() { - Some(i) => self.node(*i).map(|n| n.som.clone()).unwrap_or_default(), - None => { - let s = self.to_str(&raw); - let found = resolve_som(self.h, self.h.current, &s); - found.first().and_then(|i| self.node(*i)).map(|n| n.som.clone()).unwrap_or(s) - } - } - } - None => String::new(), - }; - self.h.push(XfaEffect::SetFocus(target)); - Ok(Val::Null) - } - _ => Ok(Val::Null), - }, - Val::Layout => match m.as_str() { - "page" | "abspage" => Ok(Val::Num(self.h.doc.page as f64 + if m == "page" { 1.0 } else { 0.0 })), - "pagecount" | "abspagecount" => Ok(Val::Num(self.h.doc.page_count as f64)), - "relayout" | "relayoutpagearea" => { - self.h.push(XfaEffect::Relayout); - Ok(Val::Null) - } - _ => Ok(Val::Num(0.0)), - }, - Val::Xfa => match m.as_str() { - "resolvenode" | "resolvenodes" => { - let s = self.arg_str(args, 0)?; - let found = resolve_som(self.h, self.h.current, &s); - Ok(self.set_val(found, m == "resolvenodes")) - } - "recalculate" => { - self.h.push(XfaEffect::Recalculate); - Ok(Val::Null) - } - "remerge" => { - self.h.push(XfaEffect::Relayout); - Ok(Val::Null) - } - _ => Ok(Val::Null), - }, - Val::Node(i) => match m.as_str() { - "resolvenode" | "resolvenodes" => { - let s = self.arg_str(args, 0)?; - let found = resolve_som(self.h, i, &s); - Ok(self.set_val(found, m == "resolvenodes")) - } - "recalculate" => { - self.h.push(XfaEffect::Recalculate); - Ok(Val::Null) - } - "remerge" => { - self.h.push(XfaEffect::Relayout); - Ok(Val::Null) - } - _ => Ok(Val::Null), - }, - Val::Im(parent, name) => match m.as_str() { - "addinstance" | "insertinstance" => Ok(self.h.add_instance(parent, &name).map_or(Val::Null, Val::Node)), - "removeinstance" => { - let i = self.arg_num(args, 0)?; - if i.is_finite() && i >= 0.0 { - self.h.remove_instance(parent, &name, i as usize); - } - Ok(Val::Null) - } - "setinstances" => { - let want = self.arg_num(args, 0)?; - if want.is_finite() && want >= 0.0 { - self.h.set_instances(parent, &name, want as usize); - } - Ok(Val::Null) - } - _ => Ok(Val::Null), - }, - Val::Event | Val::Sink | Val::List(_) => Ok(Val::Null), - Val::Null => Err(format!("no object to call {method} on")), - Val::Num(_) | Val::Str(_) => Err(format!("{} has no method {method}", self.to_str(&base))), - } - } - - fn set_val(&self, found: Vec, many: bool) -> Val { - if many { - return Val::List(found.into_iter().map(Val::Node).collect()); - } - match found.as_slice() { - [] => Val::Null, - [one] => Val::Node(*one), - all => Val::List(all.iter().map(|i| Val::Node(*i)).collect()), - } - } - - // ── assignment - - fn assign(&mut self, target: &Expr, v: Val) -> R<()> { - let v = self.deref(v); - match target { - Expr::Ident(name) => { - if self.set_var(name, v.clone()) { - return Ok(()); - } - let t = self.ident(name)?; - self.assign_to(t, "rawValue", v) - } - Expr::Member(base, prop) => { - let b = self.eval(base)?; - self.assign_to(b, prop, v) - } - Expr::Index(..) | Expr::Descend(..) => { - let t = self.eval(target)?; - self.assign_to(t, "rawValue", v) - } - _ => Err("cannot assign to that".into()), - } - } - - fn assign_to(&mut self, target: Val, prop: &str, v: Val) -> R<()> { - match target { - Val::Node(i) => { - let Some(n) = self.node(i) else { return Ok(()) }; - let som = n.som.clone(); - match prop { - "rawValue" | "value" | "formattedValue" => { - let text = self.to_str(&v); - if !self.h.set_value(i, text) { - return Err(format!("{} is not a field", if som.is_empty() { "the form" } else { som.as_str() })); - } - } - "presence" => { - let p = self.to_str(&v); - if matches!(p.as_str(), "visible" | "invisible" | "hidden" | "inactive") { - self.h.set_state(i, XfaEffect::SetPresence { som, presence: p }); - } - } - "access" => { - let a = self.to_str(&v); - if matches!(a.as_str(), "open" | "readOnly" | "protected" | "nonInteractive") { - self.h.set_state(i, XfaEffect::SetAccess { som, access: a }); - } - } - other => { - // `page1.qty = 7`: the child object named by the last step takes the value. - if let Some(child) = other.strip_prefix('_') { - if child_named(self.h, i, child).is_some() { - return self.assign_to(Val::Im(i, child.to_string()), "count", v); - } - return Ok(()); - } - let kids = instances(self.h, i, other); - if kids.is_empty() { - return Err(format!("{som} has no {other} to assign to")); - } - for k in kids { - // Every destination copies the value: charge each copy. - self.charge(self.cost(&v))?; - self.assign_to(Val::Node(k), "rawValue", v.clone())?; - } - } - } - Ok(()) - } - Val::List(items) => { - for it in items { - self.charge(self.cost(&v))?; - self.assign_to(it, prop, v.clone())?; - } - Ok(()) - } - Val::Im(parent, name) if prop.eq_ignore_ascii_case("count") => { - let want = self.to_num(&v); - if want.is_finite() && want >= 0.0 { - self.h.set_instances(parent, &name, want as usize); - } - Ok(()) - } - _ => Ok(()), - } - } - - // ── statements - - /// Run a block; returns how it ended and the value of its last expression statement. - fn block(&mut self, body: &[Stmt]) -> R<(Flow, Val)> { - // Nesting of statements counts like nesting of expressions: both are stack. - self.depth += 1; - if self.depth > self.max_depth { - self.depth -= 1; - return Err(TOO_DEEP.into()); - } - let r = self.block_inner(body); - self.depth -= 1; - r - } - - fn block_inner(&mut self, body: &[Stmt]) -> R<(Flow, Val)> { - let mut last = Val::Null; - for s in body { - self.step()?; - match self.exec(s)? { - (Flow::Next, v) => { - if let Some(v) = v { - last = v; - } - if self.exited { - return Ok((Flow::Exit, last)); - } - } - (flow, _) => return Ok((flow, last)), - } - } - Ok((Flow::Next, last)) - } - - fn exec(&mut self, s: &Stmt) -> R<(Flow, Option)> { - match s { - Stmt::Var(name, init) => { - let v = match init { - Some(e) => { - let v = self.eval(e)?; - self.take(v)? - } - None => Val::Null, - }; - self.declare(name, v.clone()); - Ok((Flow::Next, Some(v))) - } - Stmt::Assign(target, e) => { - let v = self.eval(e)?; - let v = self.take(v)?; - self.assign(target, v.clone())?; - Ok((Flow::Next, Some(v))) - } - Stmt::Expr(e) => { - let v = self.eval(e)?; - let v = self.take(v)?; - Ok((Flow::Next, Some(v))) - } - Stmt::If(arms, otherwise) => { - for (cond, body) in arms { - let c = self.eval(cond)?; - if self.truthy(&c) { - let (f, v) = self.block(body)?; - return Ok((f, Some(v))); - } - } - if let Some(body) = otherwise { - let (f, v) = self.block(body)?; - return Ok((f, Some(v))); - } - Ok((Flow::Next, None)) - } - Stmt::While(cond, body) => { - loop { - let c = self.eval(cond)?; - if !self.truthy(&c) { - break; - } - self.iterate()?; - match self.block(body)? { - (Flow::Break, _) => break, - (Flow::Return(v), _) => return Ok((Flow::Return(v), None)), - (Flow::Exit, _) => return Ok((Flow::Exit, None)), - _ => {} - } - } - Ok((Flow::Next, None)) - } - Stmt::For { var, from, to, up, step, body } => { - let start = { - let v = self.eval(from)?; - self.to_num(&v) - }; - let end = { - let v = self.eval(to)?; - self.to_num(&v) - }; - let by = match step { - Some(e) => { - let v = self.eval(e)?; - self.to_num(&v).abs() - } - None => 1.0, - }; - if by.is_nan() || by <= 0.0 || !start.is_finite() || !end.is_finite() { - return Err("bad for loop bounds".into()); - } - self.declare(var, Val::Num(start)); - let mut i = start; - while if *up { i <= end } else { i >= end } { - self.iterate()?; - self.set_var(var, Val::Num(i)); - match self.block(body)? { - (Flow::Break, _) => break, - (Flow::Return(v), _) => return Ok((Flow::Return(v), None)), - (Flow::Exit, _) => return Ok((Flow::Exit, None)), - _ => {} - } - i = if *up { i + by } else { i - by }; - } - Ok((Flow::Next, None)) - } - Stmt::Foreach { var, list, body } => { - let items = self.arg_values_keeping(list, true)?; - self.declare(var, Val::Null); - for it in items { - self.iterate()?; - self.set_var(var, it); - match self.block(body)? { - (Flow::Break, _) => break, - (Flow::Return(v), _) => return Ok((Flow::Return(v), None)), - (Flow::Exit, _) => return Ok((Flow::Exit, None)), - _ => {} - } - } - Ok((Flow::Next, None)) - } - Stmt::Func { name, f } => { - self.funcs.insert(name.to_ascii_lowercase(), Rc::clone(f)); - Ok((Flow::Next, None)) - } - Stmt::Return(e) => { - let v = match e { - Some(e) => { - let v = self.eval(e)?; - self.take(v)? - } - None => Val::Null, - }; - Ok((Flow::Return(v), None)) - } - Stmt::Break => Ok((Flow::Break, None)), - Stmt::Continue => Ok((Flow::Continue, None)), - Stmt::Exit => Ok((Flow::Exit, None)), - Stmt::Throw(e) => { - let v = self.eval(e)?; - self.charge(self.cost(&v))?; - // The thrown value becomes the error message the caller keeps: cut it as - // messages are. - Err(clip(self.to_str(&v))) - } - } - } - - fn iterate(&mut self) -> R<()> { - if self.loops_left == 0 { - return Err(LOOPED_TOO_MUCH.into()); - } - self.loops_left -= 1; - Ok(()) - } -} - -// ── built-in functions ────────────────────────────────────────────────────────────────────── - -/// `NaiveDate::num_days_from_ce` of 1900-01-01 minus one: a civil date's day count rebased so -/// that 1900-01-01 is FormCalc day 1 (and 1970-01-01 is day 25_568). -const FORMCALC_EPOCH_FROM_CE: i64 = 693_595; - -/// Days in one 400-year Gregorian cycle; the calendar repeats after it. -const DAYS_PER_400_YEARS: i64 = 146_097; - -/// Days from 1900-01-01 (day 1, as FormCalc counts) to a civil date. -/// -/// chrono's `NaiveDate` stops near ±262143, but FormCalc accepts day numbers up to `MAX_DAYS` -/// (years past 270000). The calendar repeats every 400 years, so chrono converts the date -/// within its cycle and whole cycles are added back; the result matches the proleptic Gregorian -/// count for every year. Callers pass dates `parse_date`/`civil_from_days` already validated; -/// anything else reads as 0. -fn days_from_civil(y: i64, m: u32, d: u32) -> i64 { - use chrono::Datelike; - let (cycles, year) = (i128::from(y.div_euclid(400)), y.rem_euclid(400)); - i32::try_from(year) - .ok() - .and_then(|year| chrono::NaiveDate::from_ymd_opt(year, m, d)) - .and_then(|date| { - let days = i128::from(date.num_days_from_ce()) - i128::from(FORMCALC_EPOCH_FROM_CE) + cycles * i128::from(DAYS_PER_400_YEARS); - i64::try_from(days).ok() - }) - .unwrap_or(0) -} - -/// The civil date (year, month, day) at FormCalc day number `n` (day 1 is 1900-01-01), for any -/// `n`: chrono converts the day within its 400-year cycle and the cycles are added back as years. -fn civil_from_days(n: i64) -> (i64, u32, u32) { - use chrono::Datelike; - // Days since CE day 0 (0000-12-31), in i128 so no `n` overflows. - let from_ce = i128::from(n) + i128::from(FORMCALC_EPOCH_FROM_CE); - let (cycles, rest) = (from_ce.div_euclid(i128::from(DAYS_PER_400_YEARS)), from_ce.rem_euclid(i128::from(DAYS_PER_400_YEARS))); - // `rest` is 0..146_097, so CE day `rest` lies in years 0..=400 (day 0 is 0000-12-31). - let date = i32::try_from(rest).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt); - match date { - Some(date) => { - let y = i128::from(date.year()) + cycles * 400; - (i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), date.month(), date.day()) - } - // Unreachable: every `rest` in 0..146_097 is a valid CE day. - None => (1900, 1, 1), - } -} - -/// Day numbers (and years) beyond this are refused rather than computed. -const MAX_DAYS: f64 = 1e8; - -const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]; - -/// Parse `text` as a date by `picture` (`YYYY-MM-DD`, `MM/DD/YYYY`, `MMM D, YYYY`, …). -fn parse_date(text: &str, picture: &str) -> Option<(i64, u32, u32)> { - let pic: Vec = picture.chars().collect(); - let txt: Vec = text.trim().chars().collect(); - let (mut y, mut m, mut d) = (None, None, None); - let (mut i, mut j) = (0, 0); - while i < pic.len() { - let c = pic[i].to_ascii_uppercase(); - if c == 'Y' || c == 'M' || c == 'D' { - let mut run = 1; - while i + run < pic.len() && pic[i + run].to_ascii_uppercase() == c { - run += 1; - } - i += run; - if c == 'M' && run >= 3 { - // Month name. - let rest: String = txt.get(j..).unwrap_or(&[]).iter().collect(); - let lower = rest.to_ascii_lowercase(); - let found = MONTHS - .iter() - .position(|name| lower.starts_with(&name.to_ascii_lowercase()) || lower.starts_with(&name[..3].to_ascii_lowercase())); - let k = found?; - m = Some(k as u32 + 1); - let full = MONTHS[k].to_ascii_lowercase(); - j += if lower.starts_with(&full) { full.chars().count() } else { 3 }; - continue; - } - let width = if run == 1 { 2 } else { run }; - let mut digits = String::new(); - while j < txt.len() && txt[j].is_ascii_digit() && digits.len() < width { - digits.push(txt[j]); - j += 1; - } - if digits.is_empty() { - return None; - } - let v: i64 = digits.parse().ok()?; - match c { - 'Y' => y = Some(if run <= 2 { if v < 50 { 2000 + v } else { 1900 + v } } else { v }), - 'M' => m = Some(v as u32), - _ => d = Some(v as u32), - } - } else { - if txt.get(j) != Some(&pic[i]) { - return None; - } - i += 1; - j += 1; - } - } - let (y, m, d) = (y?, m.unwrap_or(1), d.unwrap_or(1)); - ((-200_000..=200_000).contains(&y) && (1..=12).contains(&m) && (1..=days_in(y, m)).contains(&d)).then_some((y, m, d)) -} - -/// Number of days in month `m` (1-12) of year `y`, leap years included. -pub(crate) fn days_in(y: i64, m: u32) -> u32 { - // The length of the month from chrono (the first of the next month, less the first of this - // one). An invalid month keeps the old 31-day default. - // Month lengths repeat every 400 years, so the year within its cycle is enough (and always - // in chrono's range, whatever `y` is). - let y = y.rem_euclid(400); - let first = |y: i64, m: u32| i32::try_from(y).ok().and_then(|y| chrono::NaiveDate::from_ymd_opt(y, m, 1)); - let next = if m == 12 { first(y + 1, 1) } else { first(y, m + 1) }; - match (first(y, m), next) { - (Some(a), Some(b)) => (b - a).num_days() as u32, - _ => 31, - } -} - -const WEEKDAYS: [&str; 7] = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; - -fn format_date(y: i64, m: u32, d: u32, picture: &str) -> String { - let pic: Vec = picture.chars().collect(); - let mut out = String::new(); - let mut i = 0; - while i < pic.len() { - let c = pic[i].to_ascii_uppercase(); - if c == 'Y' || c == 'M' || c == 'D' { - let mut run = 1; - while i + run < pic.len() && pic[i + run].to_ascii_uppercase() == c { - run += 1; - } - i += run; - match (c, run) { - ('Y', 1 | 2) => out.push_str(&format!("{:02}", y.rem_euclid(100))), - ('Y', _) => out.push_str(&format!("{y:04}")), - ('M', 1) => out.push_str(&m.to_string()), - ('M', 2) => out.push_str(&format!("{m:02}")), - ('M', 3) => out.push_str(MONTHS.get((m as usize).saturating_sub(1)).map_or("", |n| &n[..3])), - ('M', _) => out.push_str(MONTHS.get((m as usize).saturating_sub(1)).copied().unwrap_or("")), - ('D', 1) => out.push_str(&d.to_string()), - (_, _) => out.push_str(&format!("{d:02}")), - } - } else if pic[i] == 'E' { - let mut run = 1; - while i + run < pic.len() && pic[i + run] == 'E' { - run += 1; - } - i += run; - // Day 1 (1900-01-01) was a Monday, so the serial modulo 7 indexes from Sunday. - let weekday = days_from_civil(y, m, d).rem_euclid(7) as usize; - match run { - 1 | 2 => out.push_str(&(weekday + 1).to_string()), - 3 => out.push_str(WEEKDAYS.get(weekday).map_or("", |n| &n[..3])), - _ => out.push_str(WEEKDAYS.get(weekday).copied().unwrap_or("")), - } - } else { - out.push(pic[i]); - i += 1; - } - } - out -} - -const ONES: [&str; 20] = [ - "Zero", - "One", - "Two", - "Three", - "Four", - "Five", - "Six", - "Seven", - "Eight", - "Nine", - "Ten", - "Eleven", - "Twelve", - "Thirteen", - "Fourteen", - "Fifteen", - "Sixteen", - "Seventeen", - "Eighteen", - "Nineteen", -]; -const TENS: [&str; 10] = ["", "", "Twenty", "Thirty", "Forty", "Fifty", "Sixty", "Seventy", "Eighty", "Ninety"]; - -fn words_below_1000(n: u64) -> String { - let mut parts = Vec::new(); - if n >= 100 { - parts.push(format!("{} Hundred", ONES[(n / 100) as usize])); - } - let r = n % 100; - if r >= 20 { - parts.push(if r.is_multiple_of(10) { - TENS[(r / 10) as usize].to_string() - } else { - format!("{}-{}", TENS[(r / 10) as usize], ONES[(r % 10) as usize]) - }); - } else if r > 0 || n == 0 { - parts.push(ONES[r as usize].to_string()); - } - parts.join(" ") -} - -/// `WordNum`: an integer in English words (up to the billions). -fn word_num(n: f64, cents: bool) -> String { - if !n.is_finite() || !(0.0..1e12).contains(&n) { - return String::new(); - } - let cents_total = (n * 100.0).round() as u64; - let whole = if cents { cents_total / 100 } else { n.trunc() as u64 }; - if whole >= 1_000_000_000_000 { - // Rounding to cents carried past the supported range. - return String::new(); - } - let mut parts = Vec::new(); - let groups = [(1_000_000_000u64, "Billion"), (1_000_000, "Million"), (1_000, "Thousand")]; - let mut rest = whole; - for (unit, name) in groups { - if rest >= unit { - parts.push(format!("{} {name}", words_below_1000(rest / unit))); - rest %= unit; - } - } - if rest > 0 || parts.is_empty() { - parts.push(words_below_1000(rest)); - } - let mut out = parts.join(" "); - if cents { - let c = cents_total % 100; - let dollars = if whole == 1 { "Dollar" } else { "Dollars" }; - let cent = if c == 1 { "Cent" } else { "Cents" }; - out.push_str(&format!(" {dollars} And {c:02} {cent}")); - } - out -} - -impl Interp<'_> { - /// A built-in function: its result is never a string past [`MAX_STRING`], and work on - /// long strings is charged to the step budget. - fn builtin(&mut self, name: &str, args: &[Expr]) -> R { - let v = self.builtin_inner(name, args)?; - if let Val::Str(s) = &v { - if s.len() > MAX_STRING { - return Err(STRING_TOO_LONG.into()); - } - self.charge((s.len() / 256) as u64)?; - } - Ok(v) - } - - fn builtin_inner(&mut self, name: &str, args: &[Expr]) -> R { - let lower = name.to_ascii_lowercase(); - let n = |v: f64| Ok(Val::Num(v)); - Ok(match lower.as_str() { - // ── arithmetic - "abs" => return n(self.arg_num(args, 0)?.abs()), - "ceil" => return n(self.arg_num(args, 0)?.ceil()), - "floor" => return n(self.arg_num(args, 0)?.floor()), - "mod" => { - let (a, b) = (self.arg_num(args, 0)?, self.arg_num(args, 1)?); - if b == 0.0 { - return Err("division by zero".into()); - } - Val::Num(a - b * (a / b).trunc()) - } - "round" => { - let a = self.arg_num(args, 0)?; - let places = if args.len() > 1 { self.arg_num(args, 1)?.clamp(0.0, 12.0) as i32 } else { 0 }; - let k = 10f64.powi(places); - let scaled = a * k; - if scaled.is_finite() { Val::Num(scaled.round() / k) } else { Val::Num(a) } - } - "count" => { - // Non-null values and objects: an empty field doesn't count, a subform does. - let vals = self.arg_values_keeping(args, true)?; - let counts = |v: &Val| match v { - Val::Node(i) => self.node(*i).is_some_and(|n| !matches!(n.kind, XfaKind::Field | XfaKind::ExclGroup) || !n.value.is_empty()), - other => !self.is_null(other), - }; - Val::Num(vals.iter().filter(|v| counts(v)).count() as f64) - } - "sum" | "avg" | "max" | "min" => { - let vals = self.arg_values(args)?; - let nums: Vec = vals.iter().map(|v| self.to_num(v)).collect(); - match lower.as_str() { - "sum" => Val::Num(nums.iter().sum()), - "avg" => { - if nums.is_empty() { - Val::Null - } else { - Val::Num(nums.iter().sum::() / nums.len() as f64) - } - } - "max" => nums.iter().copied().fold(None, |m: Option, x| Some(m.map_or(x, |m| m.max(x)))).map_or(Val::Null, Val::Num), - _ => nums.iter().copied().fold(None, |m: Option, x| Some(m.map_or(x, |m| m.min(x)))).map_or(Val::Null, Val::Num), - } - } - // ── logical - "choose" => { - let i = self.arg_num(args, 0)?; - if !i.is_finite() || i < 1.0 { - return Ok(Val::Null); - } - self.arg(args, i as usize)? - } - "exists" => { - let Some(e) = args.first() else { return Ok(Val::Num(0.0)) }; - // A name that resolves to nothing is the answer "no"; a limit the lookup ran - // into (time, steps, depth, size) still stops the script. - match self.all_of(e) { - Ok(v) => Val::Num(if v.is_empty() { 0.0 } else { 1.0 }), - Err(e) if is_limit(&e) => return Err(e), - Err(_) => Val::Num(0.0), - } - } - "hasvalue" => { - let v = self.arg(args, 0)?; - Val::Num(if self.is_null(&v) || matches!(&v, Val::Str(s) if s.trim().is_empty()) { 0.0 } else { 1.0 }) - } - "oneof" => { - let v = self.arg(args, 0)?; - let rest = self.arg_values(args.get(1..).unwrap_or_default())?; - let mut hit = false; - for x in &rest { - // Each comparison copies and compares `v`: charged per item. - self.charge(self.cost(&v).saturating_add(self.cost(x)).saturating_add(1))?; - if self.binary("==", v.clone(), x.clone()).is_ok_and(|r| self.truthy(&r)) { - hit = true; - break; - } - } - Val::Num(if hit { 1.0 } else { 0.0 }) - } - "within" => { - let v = self.arg(args, 0)?; - let (lo, hi) = (self.arg(args, 1)?, self.arg(args, 2)?); - let ok = self.binary(">=", v.clone(), lo).is_ok_and(|r| self.truthy(&r)) && self.binary("<=", v, hi).is_ok_and(|r| self.truthy(&r)); - Val::Num(if ok { 1.0 } else { 0.0 }) - } - // ── strings - "at" => { - let (s, sub) = (self.arg_str(args, 0)?, self.arg_str(args, 1)?); - Val::Num(if sub.is_empty() { 0.0 } else { s.find(&sub).map_or(0.0, |b| s[..b].chars().count() as f64 + 1.0) }) - } - "concat" => { - let mut out = String::new(); - for i in 0..args.len() { - out.push_str(&self.arg_str(args, i)?); - if out.len() > MAX_STRING { - return Err(STRING_TOO_LONG.into()); - } - } - Val::Str(out) - } - "left" => { - let (s, k) = (self.arg_str(args, 0)?, self.arg_num(args, 1)?.max(0.0) as usize); - Val::Str(s.chars().take(k).collect()) - } - "right" => { - let (s, k) = (self.arg_str(args, 0)?, self.arg_num(args, 1)?.max(0.0) as usize); - let len = s.chars().count(); - Val::Str(s.chars().skip(len.saturating_sub(k)).collect()) - } - "len" => Val::Num(self.arg_str(args, 0)?.chars().count() as f64), - "lower" => Val::Str(self.arg_str(args, 0)?.to_lowercase()), - "upper" => Val::Str(self.arg_str(args, 0)?.to_uppercase()), - "ltrim" => Val::Str(self.arg_str(args, 0)?.trim_start().to_string()), - "rtrim" => Val::Str(self.arg_str(args, 0)?.trim_end().to_string()), - "space" => { - let k = self.arg_num(args, 0)?.clamp(0.0, MAX_STRING as f64) as usize; - Val::Str(" ".repeat(k)) - } - "str" => { - let v = self.arg_num(args, 0)?; - let width = if args.len() > 1 { self.arg_num(args, 1)?.clamp(0.0, 1000.0) as usize } else { 10 }; - let prec = if args.len() > 2 { self.arg_num(args, 2)?.clamp(0.0, 20.0) as usize } else { 0 }; - let text = fixed(v, prec); - Val::Str(if text.len() > width { "*".repeat(width) } else { format!("{text:>width$}") }) - } - "substr" => { - let s = self.arg_str(args, 0)?; - let start = self.arg_num(args, 1)?.max(1.0) as usize; - let len = self.arg_num(args, 2)?.max(0.0) as usize; - Val::Str(s.chars().skip(start - 1).take(len).collect()) - } - "stuff" => { - let s = self.arg_str(args, 0)?; - let start = self.arg_num(args, 1)?.max(1.0) as usize; - let del = self.arg_num(args, 2)?.max(0.0) as usize; - let ins = if args.len() > 3 { self.arg_str(args, 3)? } else { String::new() }; - let chars: Vec = s.chars().collect(); - let a = start.saturating_sub(1).min(chars.len()); - let b = a.saturating_add(del).min(chars.len()); - let out: String = chars[..a].iter().chain(ins.chars().collect::>().iter()).chain(chars[b..].iter()).collect(); - if out.len() > MAX_STRING { - return Err(STRING_TOO_LONG.into()); - } - Val::Str(out) - } - "replace" => { - let (s, old) = (self.arg_str(args, 0)?, self.arg_str(args, 1)?); - let new = if args.len() > 2 { self.arg_str(args, 2)? } else { String::new() }; - if old.is_empty() { - return Ok(Val::Str(s)); - } - let grown = s.matches(old.as_str()).count().saturating_mul(new.len()).saturating_add(s.len()); - if grown > MAX_STRING { - return Err(STRING_TOO_LONG.into()); - } - Val::Str(s.replace(&old, &new)) - } - "wordnum" => { - let v = self.arg_num(args, 0)?; - let kind = if args.len() > 1 { self.arg_num(args, 1)? } else { 0.0 }; - Val::Str(word_num(v, kind == 2.0)) - } - "uuid" => Val::Str(format!("{:032x}", (self.steps as u128).wrapping_mul(0x9E37_79B9_7F4A_7C15_F39C_C060_5CED_C835))), - "encode" | "decode" => { - let s = self.arg_str(args, 0)?; - let kind = if args.len() > 1 { self.arg_str(args, 1)?.to_ascii_lowercase() } else { "url".into() }; - Val::Str(match (lower.as_str(), kind.as_str()) { - ("encode", "html" | "xml") => s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """), - ("decode", "html" | "xml") => s.replace("<", "<").replace(">", ">").replace(""", "\"").replace("&", "&"), - ("encode", _) => s - .bytes() - .map(|b| if b.is_ascii_alphanumeric() || b"-_.~".contains(&b) { (b as char).to_string() } else { format!("%{b:02X}") }) - .collect(), - (_, _) => { - let mut out = Vec::new(); - let b = s.as_bytes(); - let mut i = 0; - while i < b.len() { - if b[i] == b'%' - && let Some(h) = b - .get(i + 1..i + 3) - .filter(|h| h.iter().all(u8::is_ascii_hexdigit)) - .and_then(|h| std::str::from_utf8(h).ok()) - .and_then(|h| u8::from_str_radix(h, 16).ok()) - { - out.push(h); - i += 3; - } else { - out.push(b[i]); - i += 1; - } - } - String::from_utf8_lossy(&out).into_owned() - } - }) - } - "format" => { - // Number pictures: `9` digit, `z` optional digit, `,` separator, `.` point, `$`. - let pic = self.arg_str(args, 0)?; - let v = self.arg(args, 1)?; - if pic.to_ascii_uppercase().contains("YY") || pic.to_ascii_uppercase().contains("DD") { - let text = self.to_str(&v); - let days = parse_num(&text).filter(|d| is_numeric_text(&text) && d.abs() < MAX_DAYS).map(|d| d as i64); - return Ok(match days { - Some(d) => { - let (y, m, dd) = civil_from_days(d); - Val::Str(format_date(y, m, dd, &pic)) - } - None => parse_date(&text, "YYYY-MM-DD").map_or(Val::Str(text), |(y, m, d)| Val::Str(format_date(y, m, d, &pic))), - }); - } - let num = self.to_num(&v); - let decimals = pic.split_once('.').map_or(0, |(_, f)| f.chars().filter(|c| matches!(c, '9' | 'z' | 'Z')).count()).min(20); - // `9` in the integer part forces a digit; `z` does not. - let min_int = pic.split('.').next().map_or(0, |i| i.chars().filter(|c| *c == '9').count()).min(20); - let width = min_int + if decimals > 0 { decimals + 1 } else { 0 }; - let grouped = pic.contains(','); - let mut text = format!("{:0>width$}", fixed(num.abs(), decimals)); - if grouped { - let (int, frac) = text.split_once('.').map_or((text.as_str(), ""), |(a, b)| (a, b)); - let mut g = String::new(); - for (k, c) in int.chars().enumerate() { - if k > 0 && (int.len() - k) % 3 == 0 { - g.push(','); - } - g.push(c); - } - text = if frac.is_empty() { g } else { format!("{g}.{frac}") }; - } - let nonzero = text.chars().any(|c| ('1'..='9').contains(&c)); - if pic.contains('$') { - text = format!("${text}"); - } - if num < 0.0 && nonzero { - text = format!("-{text}"); - } - Val::Str(text) - } - "parse" => { - let pic = self.arg_str(args, 0)?; - let s = self.arg_str(args, 1)?; - if pic.to_ascii_uppercase().contains("YY") { - // The canonical form of a date is ISO. - return Ok(parse_date(&s, &pic).map_or(Val::Null, |(y, m, d)| Val::Str(format_date(y, m, d, "YYYY-MM-DD")))); - } - let cleaned: String = s.chars().filter(|c| c.is_ascii_digit() || *c == '.' || *c == '-').collect(); - parse_num(&cleaned).map_or(Val::Null, Val::Num) - } - // ── dates and times (days since 1900-01-01 = 1; milliseconds since midnight) - "date" => { - #[cfg(not(target_arch = "wasm32"))] - { - let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or(0); - Val::Num((secs.div_euclid(86_400) + 25_568) as f64) - } - #[cfg(target_arch = "wasm32")] - { - Val::Num(25_568.0) - } - } - "time" => { - #[cfg(not(target_arch = "wasm32"))] - { - let ms = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as i64).unwrap_or(0); - Val::Num(ms.rem_euclid(86_400_000) as f64) - } - #[cfg(target_arch = "wasm32")] - { - Val::Num(0.0) - } - } - "date2num" | "isodate2num" => { - let s = self.arg_str(args, 0)?; - let pic = if lower == "isodate2num" || args.len() < 2 { "YYYY-MM-DD".to_string() } else { self.arg_str(args, 1)? }; - let parsed = parse_date(&s, &pic).or_else(|| parse_date(&s, "YYYYMMDD")).or_else(|| parse_date(&s, "YYYY-MM-DD")); - parsed.map_or(Val::Num(0.0), |(y, m, d)| Val::Num(days_from_civil(y, m, d) as f64)) - } - "num2date" => { - let days = self.arg_num(args, 0)?; - let pic = if args.len() > 1 { self.arg_str(args, 1)? } else { "MMM D, YYYY".into() }; - if !days.is_finite() || days.abs() >= MAX_DAYS { - return Ok(Val::Null); - } - let (y, m, d) = civil_from_days(days as i64); - Val::Str(format_date(y, m, d, &pic)) - } - "datefmt" | "localdatefmt" => { - let style = if args.is_empty() { 0.0 } else { self.arg_num(args, 0)? }; - Val::Str( - match style as i32 { - 1 => "M/D/YY", - 2 => "MMM D, YYYY", - 3 => "MMMM D, YYYY", - 4 => "EEEE, MMMM D, YYYY", - _ => "MMM D, YYYY", - } - .into(), - ) - } - "timefmt" | "localtimefmt" => Val::Str("HH:MM:SS".into()), - "num2time" | "num2gmtime" => { - let ms = self.arg_num(args, 0)?; - if !ms.is_finite() || ms < 0.0 { - return Ok(Val::Null); - } - let s = (ms / 1000.0) as u64; - Val::Str(format!("{:02}:{:02}:{:02}", (s / 3600) % 24, (s / 60) % 60, s % 60)) - } - "time2num" | "isotime2num" => { - let s = self.arg_str(args, 0)?; - // ISO 8601 allows a leading "T", a trailing "Z" and fractional seconds. - let t = s.trim(); - let t = t.strip_prefix(['T', 't']).unwrap_or(t); - let t = t.strip_suffix(['Z', 'z']).unwrap_or(t); - let mut parts = t.split(':'); - let mut whole = || parts.next().and_then(|p| p.trim().parse::().ok()).unwrap_or(0).min(1_000_000); - let (h, m) = (whole(), whole()); - let sec = - parts.next().and_then(|p| p.trim().parse::().ok()).filter(|v| v.is_finite() && *v >= 0.0).unwrap_or(0.0).min(1_000_000.0); - Val::Num(((h * 3600 + m * 60) * 1000) as f64 + (sec * 1000.0).round()) - } - // ── financial - "fv" => { - let (p, r, k) = (self.arg_num(args, 0)?, self.arg_num(args, 1)?, self.arg_num(args, 2)?); - Val::Num(if r == 0.0 { p * k } else { p * (((1.0 + r).powf(k) - 1.0) / r) }) - } - "pv" => { - let (p, r, k) = (self.arg_num(args, 0)?, self.arg_num(args, 1)?, self.arg_num(args, 2)?); - Val::Num(if r == 0.0 { p * k } else { p * ((1.0 - (1.0 + r).powf(-k)) / r) }) - } - "pmt" => { - let (pv, r, k) = (self.arg_num(args, 0)?, self.arg_num(args, 1)?, self.arg_num(args, 2)?); - if k <= 0.0 { - return Err("Pmt needs a positive number of periods".into()); - } - Val::Num(if r == 0.0 { pv / k } else { pv * r / (1.0 - (1.0 + r).powf(-k)) }) - } - "term" => { - let (p, r, fv) = (self.arg_num(args, 0)?, self.arg_num(args, 1)?, self.arg_num(args, 2)?); - if p <= 0.0 || r <= 0.0 || fv <= 0.0 { - return Err("Term needs positive arguments".into()); - } - Val::Num(((fv * r / p) + 1.0).ln() / (1.0 + r).ln()) - } - "cterm" => { - let (r, fv, pv) = (self.arg_num(args, 0)?, self.arg_num(args, 1)?, self.arg_num(args, 2)?); - if r <= 0.0 || fv <= 0.0 || pv <= 0.0 { - return Err("CTerm needs positive arguments".into()); - } - Val::Num((fv / pv).ln() / (1.0 + r).ln()) - } - "rate" => { - let (fv, pv, k) = (self.arg_num(args, 0)?, self.arg_num(args, 1)?, self.arg_num(args, 2)?); - if fv <= 0.0 || pv <= 0.0 || k <= 0.0 { - return Err("Rate needs positive arguments".into()); - } - Val::Num((fv / pv).powf(1.0 / k) - 1.0) - } - "npv" => { - let r = self.arg_num(args, 0)?; - let flows = self.arg_values(args.get(1..).unwrap_or_default())?; - Val::Num(flows.iter().enumerate().map(|(i, v)| self.to_num(v) / (1.0 + r).powi(i as i32 + 1)).sum()) - } - "ipmt" | "ppmt" => { - let (pv, r, pmt, first, count) = ( - self.arg_num(args, 0)?, - self.arg_num(args, 1)?, - self.arg_num(args, 2)?, - self.arg_num(args, 3)?.max(1.0), - self.arg_num(args, 4)?.max(1.0), - ); - let (mut balance, mut interest, mut principal) = (pv, 0.0, 0.0); - for period in 1..=((first + count - 1.0).min(10_000.0) as u32) { - self.step()?; - let i = balance * r; - let p = (pmt - i).min(balance); - if period >= first as u32 { - interest += i; - principal += p; - } - balance -= p; - if balance <= 0.0 { - break; - } - } - Val::Num(if lower == "ipmt" { interest } else { principal }) - } - "apr" => { - let (principal, payment, periods) = (self.arg_num(args, 0)?, self.arg_num(args, 1)?, self.arg_num(args, 2)?); - if principal <= 0.0 || payment <= 0.0 || periods <= 0.0 { - return Err("Apr needs positive arguments".into()); - } - // Bisection on the monthly rate. - let (mut lo, mut hi) = (0.0f64, 1.0f64); - for _ in 0..200 { - let mid = (lo + hi) / 2.0; - let pmt = if mid == 0.0 { principal / periods } else { principal * mid / (1.0 - (1.0 + mid).powf(-periods)) }; - if pmt > payment { - hi = mid; - } else { - lo = mid; - } - } - Val::Num((lo + hi) / 2.0 * 12.0) - } - // ── units and references - "unittype" => { - let s = self.arg_str(args, 0)?; - let unit: String = s.trim().chars().skip_while(|c| c.is_ascii_digit() || *c == '.' || *c == '-' || *c == '+').collect(); - Val::Str( - match unit.trim().to_ascii_lowercase().as_str() { - "in" | "inch" | "inches" => "in", - "mm" | "millimeter" | "millimeters" => "mm", - "cm" | "centimeter" | "centimeters" => "cm", - "pt" | "point" | "points" => "pt", - "pc" | "pica" => "pc", - _ => "in", - } - .into(), - ) - } - "unitvalue" => { - let s = self.arg_str(args, 0)?; - let to = if args.len() > 1 { self.arg_str(args, 1)? } else { String::new() }; - let v = parse_num(&s).unwrap_or(0.0); - let unit: String = s.trim().chars().skip_while(|c| c.is_ascii_digit() || *c == '.' || *c == '-' || *c == '+').collect(); - let per_in = |u: &str| match u.trim().to_ascii_lowercase().as_str() { - "mm" | "millimeter" | "millimeters" => 25.4, - "cm" | "centimeter" | "centimeters" => 2.54, - "pt" | "point" | "points" => 72.0, - "pc" | "pica" => 6.0, - _ => 1.0, - }; - let target = if to.trim().is_empty() { unit.clone() } else { to }; - Val::Num(v / per_in(&unit) * per_in(&target)) - } - "ref" => { - let Some(e) = args.first() else { return Ok(Val::Null) }; - self.eval(e)? - } - "eval" => { - let src = self.arg_str(args, 0)?; - self.charge((src.len() / 16) as u64)?; - let body = parse(&src)?; - let (_, v) = self.block(&body)?; - v - } - "get" | "post" | "put" => return Err(format!("{name}: network access is not available to form scripts")), - _ => return Err(format!("unknown function {name}")), - }) - } -} - // ── entry points ──────────────────────────────────────────────────────────────────────────── /// Run a FormCalc `script` for `event` on the form `root`, as [`crate::xfa::run_xfa`] runs a diff --git a/crates/js/src/formcalc/access.rs b/crates/js/src/formcalc/access.rs new file mode 100644 index 0000000000..bea902c752 --- /dev/null +++ b/crates/js/src/formcalc/access.rs @@ -0,0 +1,436 @@ +#[cfg(not(target_arch = "wasm32"))] +use super::CLOCK_EVERY; +use super::ast::{Expr, Idx}; +use super::value::{Val, num_text, parse_num}; +use super::{ + HNode, HashSet, Interp, LIST_TOO_LONG, MAX_LIST, MAX_PARSE_DEPTH, MAX_STEPS, R, RAN_TOO_LONG, child_named, descendants_named, instances, + resolve_som, +}; + +impl Interp<'_> { + pub(super) fn step(&mut self) -> R<()> { + self.charge(1) + } + + /// `n` units of work done (a step per statement or expression, more for work on long + /// strings and lists). + pub(super) fn charge(&mut self, n: u64) -> R<()> { + let before = self.steps; + self.steps = self.steps.saturating_add(n); + if self.steps > MAX_STEPS { + return Err(RAN_TOO_LONG.into()); + } + #[cfg(not(target_arch = "wasm32"))] + if self.steps / CLOCK_EVERY != before / CLOCK_EVERY + && let Some(d) = self.deadline + && std::time::Instant::now() > d + { + return Err(RAN_TOO_LONG.into()); + } + #[cfg(target_arch = "wasm32")] + let _ = before; + Ok(()) + } + + /// `more` nodes into `out`, each once, never past [`MAX_LIST`]. + pub(super) fn gather(&mut self, out: &mut Vec, seen: &mut HashSet, more: impl IntoIterator) -> R<()> { + for i in more { + if seen.insert(i) { + out.push(i); + if out.len() > MAX_LIST { + return Err(LIST_TOO_LONG.into()); + } + } + } + self.charge(1) + } + + /// The nodes of `v`, each once. + pub(super) fn nodes_list(&self, v: &Val) -> Vec { + let mut seen = HashSet::new(); + self.nodes_of(v).into_iter().filter(|i| seen.insert(*i)).collect() + } + + pub(super) fn node(&self, i: usize) -> Option<&HNode> { + self.h.nodes.get(i).filter(|n| !n.gone) + } + + // ── coercions + + /// A node's value as a FormCalc value. + fn node_value(&self, i: usize) -> Val { + let Some(n) = self.node(i) else { return Val::Null }; + if n.value.is_empty() { + return Val::Null; + } + if n.numeric + && let Some(v) = parse_num(&n.value) + { + return Val::Num(v); + } + Val::Str(n.value.clone()) + } + + /// What copying `v` (or the values it stands for) costs, in steps: one per 256 bytes, so + /// copying long strings over and over runs out of steps even where there is no clock + /// (wasm). A form object counts its value; a list its items too. + pub(super) fn cost(&self, v: &Val) -> u64 { + let mut bytes = 0usize; + let mut todo = vec![v]; + while let Some(v) = todo.pop() { + match v { + Val::Str(s) => bytes = bytes.saturating_add(s.len()), + Val::Node(i) => bytes = bytes.saturating_add(self.node(*i).map_or(0, |n| n.value.len())), + Val::List(items) => { + bytes = bytes.saturating_add(items.len().saturating_mul(16)); + todo.extend(items.iter()); + } + _ => {} + } + } + (bytes / 256) as u64 + } + + /// [`Self::deref`], charged for the copy. + pub(super) fn take(&mut self, v: Val) -> R { + self.charge(self.cost(&v))?; + Ok(self.deref(v)) + } + + /// Objects become their values; lists of objects become lists of values. + pub(super) fn deref(&self, v: Val) -> Val { + match v { + Val::Node(i) => self.node_value(i), + Val::List(items) => Val::List(items.into_iter().map(|x| self.deref(x)).collect()), + other => other, + } + } + + pub(super) fn to_num(&self, v: &Val) -> f64 { + match v { + Val::Null => 0.0, + Val::Num(n) => *n, + Val::Str(s) => parse_num(s).unwrap_or(0.0), + Val::Node(i) => self.to_num(&self.node_value(*i)), + Val::List(items) => items.first().map_or(0.0, |x| self.to_num(x)), + _ => 0.0, + } + } + + pub(super) fn to_str(&self, v: &Val) -> String { + match v { + Val::Null => String::new(), + Val::Num(n) => num_text(*n), + Val::Str(s) => s.clone(), + Val::Node(i) => self.to_str(&self.node_value(*i)), + Val::List(items) => items.first().map_or(String::new(), |x| self.to_str(x)), + Val::Host => "host".into(), + Val::Layout => "layout".into(), + Val::Event => "event".into(), + Val::Xfa => "xfa".into(), + Val::Im(..) => "instanceManager".into(), + Val::Sink => String::new(), + } + } + + pub(super) fn truthy(&self, v: &Val) -> bool { + match v { + Val::Null => false, + Val::Num(n) => *n != 0.0 && !n.is_nan(), + Val::Str(s) => parse_num(s).is_some_and(|n| n != 0.0), + Val::Node(i) => { + let d = self.node_value(*i); + self.truthy(&d) + } + Val::List(items) => !items.is_empty(), + _ => true, + } + } + + pub(super) fn is_null(&self, v: &Val) -> bool { + match v { + Val::Null => true, + Val::Node(i) => self.node(*i).is_none_or(|n| n.value.is_empty()), + Val::List(items) => items.is_empty(), + _ => false, + } + } + + // ── scopes + + fn lookup(&self, name: &str) -> Option { + self.scopes.iter().rev().find_map(|s| s.get(name).cloned()) + } + + pub(super) fn set_var(&mut self, name: &str, v: Val) -> bool { + for s in self.scopes.iter_mut().rev() { + if let Some(slot) = s.get_mut(name) { + *slot = v; + return true; + } + } + false + } + + pub(super) fn declare(&mut self, name: &str, v: Val) { + if let Some(s) = self.scopes.last_mut() { + s.insert(name.to_string(), v); + } + } + + // ── accessors + + /// The objects an identifier names from the current node: `$`, the special objects, an + /// instance manager (`_name`), a variable, or a form object by name (children first, then + /// the containing subforms). + pub(super) fn ident(&self, name: &str) -> R { + if let Some(v) = self.lookup(name) { + return Ok(v); + } + let lower = name.to_ascii_lowercase(); + Ok(match lower.as_str() { + "$" | "this" => Val::Node(self.h.current), + "$host" => Val::Host, + "$layout" => Val::Layout, + "$event" => Val::Event, + "$form" | "$data" | "$dataset" | "$datasets" => Val::Node(0), + "$record" => Val::Node(if self.node(1).is_some() { 1 } else { 0 }), + "$template" | "$xfa" => Val::Xfa, + "xfa" => Val::Xfa, + _ => { + if let Some(child) = name.strip_prefix('_') { + // The instance manager of `child`, looked for like a name. + let found = resolve_som(self.h, self.h.current, child); + let Some(&first) = found.first() else { return Err(format!("unknown instance manager _{child}")) }; + let parent = self.node(first).and_then(|n| n.parent).ok_or_else(|| format!("unknown instance manager _{child}"))?; + return Ok(Val::Im(parent, child.to_string())); + } + let found = resolve_som(self.h, self.h.current, name); + match found.as_slice() { + [] => return Err(format!("unknown name {name}")), + [one] => Val::Node(*one), + many => Val::List(many.iter().map(|i| Val::Node(*i)).collect()), + } + } + }) + } + + /// Every object a value stands for. + pub(super) fn nodes_of(&self, v: &Val) -> Vec { + match v { + Val::Node(i) => vec![*i], + Val::List(items) => items.iter().flat_map(|x| self.nodes_of(x)).collect(), + _ => Vec::new(), + } + } + + pub(super) fn member(&mut self, base: Val, name: &str) -> R { + match base { + Val::Node(i) => self.node_member(i, name), + Val::List(items) => { + let mut out = Vec::new(); + let mut seen = HashSet::new(); + for it in items { + match self.member(it, name)? { + Val::List(more) => { + for m in more { + self.list_push(&mut out, &mut seen, m)?; + } + } + Val::Null => {} + v => self.list_push(&mut out, &mut seen, v)?, + } + } + Ok(match out.len() { + 0 => Val::Null, + 1 => out.remove(0), + _ => Val::List(out), + }) + } + Val::Host => Ok(match name.to_ascii_lowercase().as_str() { + "numpages" => Val::Num(self.h.doc.page_count as f64), + "currentpage" => Val::Num(self.h.doc.page as f64), + "name" => Val::Str("Acrobat".into()), + "apptype" => Val::Str("Exchange-Pro".into()), + "version" => Val::Str("11".into()), + "variation" => Val::Str("Full".into()), + "language" => Val::Str("en".into()), + "platform" => Val::Str(crate::platform().into()), + "title" => Val::Str(self.h.doc.file_name.clone()), + "calculationsenabled" | "validationsenabled" => Val::Num(1.0), + _ => Val::Sink, + }), + Val::Layout => Ok(Val::Sink), + Val::Event => Ok(match name.to_ascii_lowercase().as_str() { + "name" => Val::Str(self.event.activity.clone()), + "newtext" | "fulltext" => Val::Str(self.event.new_text.clone()), + "prevtext" => Val::Str(self.event.prev_text.clone()), + "change" => Val::Str(String::new()), + "target" => Val::Node(self.h.current), + "selstart" | "selend" | "cancelaction" | "reenter" | "shift" | "modifier" | "keydown" | "commitkey" => Val::Num(0.0), + _ => Val::Sink, + }), + Val::Xfa => Ok(match name.to_ascii_lowercase().as_str() { + "form" | "datasets" | "data" => Val::Node(0), + "record" => Val::Node(if self.node(1).is_some() { 1 } else { 0 }), + "host" => Val::Host, + "layout" => Val::Layout, + "event" => Val::Event, + _ => Val::Sink, + }), + Val::Im(parent, child) => Ok(match name.to_ascii_lowercase().as_str() { + "count" => Val::Num(instances(self.h, parent, &child).len() as f64), + "min" => Val::Num(instances(self.h, parent, &child).first().and_then(|i| self.node(*i)).map_or(0.0, |n| n.occur_min as f64)), + "max" => Val::Num( + instances(self.h, parent, &child).first().and_then(|i| self.node(*i)).and_then(|n| n.occur_max).map_or(-1.0, |m| m as f64), + ), + "classname" => Val::Str("instanceManager".into()), + _ => Val::Sink, + }), + Val::Sink => Ok(Val::Sink), + Val::Null => Err(format!("no object to take {name} from")), + Val::Num(_) | Val::Str(_) => Err(format!("{} has no property {name}", self.to_str(&base))), + } + } + + /// `v` onto `out`: a node only once, never past [`MAX_LIST`]. + fn list_push(&mut self, out: &mut Vec, seen: &mut HashSet, v: Val) -> R<()> { + if let Val::Node(i) = v + && !seen.insert(i) + { + return Ok(()); + } + out.push(v); + if out.len() > MAX_LIST { + return Err(LIST_TOO_LONG.into()); + } + self.charge(1) + } + + /// Property `name` of form object `i` as FormCalc reads it: its value, attributes, + /// relatives and children by name. + fn node_member(&mut self, i: usize, name: &str) -> R { + let Some(n) = self.node(i) else { return Ok(Val::Null) }; + Ok(match name { + "formattedValue" => match &n.formatted { + Some(f) => Val::Str(f.clone()), + None => self.node_value(i), + }, + "rawValue" | "value" => self.node_value(i), + "name" => Val::Str(n.name.clone()), + "somExpression" => Val::Str(n.som.clone()), + "className" => Val::Str(n.kind.class_name().into()), + "index" | "instanceIndex" => Val::Num(n.index as f64), + "presence" => Val::Str(n.presence.clone()), + "access" => Val::Str(n.access.clone()), + "isNull" => Val::Num(if n.value.is_empty() { 1.0 } else { 0.0 }), + "mandatory" => Val::Str("disabled".into()), + "parent" => n.parent.map_or(Val::Null, Val::Node), + "nodes" => Val::List(n.children.iter().copied().filter(|c| self.node(*c).is_some()).map(Val::Node).collect()), + "all" => match n.parent { + Some(p) => Val::List(instances(self.h, p, &n.name).into_iter().map(Val::Node).collect()), + None => Val::Node(i), + }, + "instanceManager" => match n.parent { + Some(p) => Val::Im(p, n.name.clone()), + None => Val::Null, + }, + "border" | "ui" | "font" | "caption" | "fillColor" | "borderColor" | "fontColor" | "margin" | "para" | "assist" | "validate" + | "format" | "items" | "bind" | "keep" | "h" | "w" | "x" | "y" | "minH" | "maxH" | "minW" | "maxW" | "layout" | "model" + | "oneOfChild" | "locale" | "relevant" | "colSpan" | "dataNode" | "defaultValue" | "editValue" | "selectedIndex" | "event" + | "calculate" | "traversal" | "extras" | "desc" => Val::Sink, + other => { + if let Some(child) = other.strip_prefix('_') { + return Ok(if child_named(self.h, i, child).is_some() { Val::Im(i, child.to_string()) } else { Val::Null }); + } + let found = instances(self.h, i, other); + match found.as_slice() { + [] => Val::Null, + [one] => Val::Node(*one), + many => Val::List(many.iter().map(|k| Val::Node(*k)).collect()), + } + } + }) + } + + /// `base[n]` / `base[*]`: all the instances the last step named, then one or all. + pub(super) fn index(&mut self, base: &Expr, idx: &Idx) -> R { + let all = self.all_of(base)?; + match idx { + Idx::All => Ok(Val::List(all.into_iter().map(Val::Node).collect())), + Idx::Abs(e) => { + let v = self.eval(e)?; + let n = self.to_num(&v); + if !n.is_finite() || n < 0.0 { + return Ok(Val::Null); + } + Ok(all.get(n as usize).map_or(Val::Null, |i| Val::Node(*i))) + } + Idx::Rel(k) => { + // From the instance the script runs in (`row[-1]` in a row is the row above), + // else from the first. + let here = all.iter().position(|&i| self.encloses(i, self.h.current)).unwrap_or(0); + let at = (here as i64).saturating_add(*k); + Ok(usize::try_from(at).ok().and_then(|at| all.get(at)).map_or(Val::Null, |i| Val::Node(*i))) + } + } + } + + /// Whether `node` is `inner` or one of its containers. + fn encloses(&self, node: usize, inner: usize) -> bool { + let mut at = Some(inner); + let mut guard = 0; + while let Some(i) = at + && guard < MAX_PARSE_DEPTH + { + if i == node { + return true; + } + at = self.h.nodes.get(i).and_then(|n| n.parent); + guard += 1; + } + false + } + + /// Every instance an accessor names (`Row` → all rows, not just the first), each once. + pub(super) fn all_of(&mut self, e: &Expr) -> R> { + let mut out = Vec::new(); + let mut seen = HashSet::new(); + match e { + Expr::Ident(name) => { + if self.lookup(name).is_some() || name.starts_with('$') || name.eq_ignore_ascii_case("xfa") { + let v = self.eval(e)?; + return Ok(self.nodes_list(&v)); + } + // The name resolves to its first instance; `row[*]` means every sibling of it. + for first in resolve_som(self.h, self.h.current, name) { + match self.node(first).and_then(|n| n.parent.map(|p| (p, n.name.clone()))) { + Some((p, n)) => self.gather(&mut out, &mut seen, instances(self.h, p, &n))?, + None => self.gather(&mut out, &mut seen, [first])?, + } + } + } + Expr::Member(base, name) => { + let b = self.eval(base)?; + for i in self.nodes_list(&b) { + self.gather(&mut out, &mut seen, instances(self.h, i, name))?; + } + if out.is_empty() { + let v = self.member(b, name)?; + out = self.nodes_list(&v); + } + } + Expr::Descend(base, name) => { + let b = self.eval(base)?; + for i in self.nodes_list(&b) { + self.gather(&mut out, &mut seen, descendants_named(self.h, i, name, 0))?; + } + } + other => { + let v = self.eval(other)?; + out = self.nodes_list(&v); + } + } + Ok(out) + } +} diff --git a/crates/js/src/formcalc/ast.rs b/crates/js/src/formcalc/ast.rs new file mode 100644 index 0000000000..ab453a8061 --- /dev/null +++ b/crates/js/src/formcalc/ast.rs @@ -0,0 +1,48 @@ +use super::{Func, Rc}; + +// ── syntax ────────────────────────────────────────────────────────────────────────────────── + +#[derive(Clone, Debug, PartialEq)] +pub(super) enum Expr { + Num(f64), + Str(String), + Null, + Ident(String), + /// `base.name` + Member(Box, String), + /// `base[n]`, `base[*]`, `base[-n]` + Index(Box, Idx), + /// `base..name` + Descend(Box, String), + Call(Box, Vec), + Neg(Box), + Not(Box), + Bin(&'static str, Box, Box), +} + +#[derive(Clone, Debug, PartialEq)] +pub(super) enum Idx { + /// `[*]`: every instance. + All, + /// `[n]`: the n-th instance (from 0). + Abs(Box), + /// `[-n]` / `[+n]`: n instances before or after the one the script runs in. + Rel(i64), +} + +#[derive(Clone, Debug, PartialEq)] +pub(super) enum Stmt { + Var(String, Option), + Assign(Expr, Expr), + Expr(Expr), + If(Vec<(Expr, Vec)>, Option>), + While(Expr, Vec), + For { var: String, from: Expr, to: Expr, up: bool, step: Option, body: Vec }, + Foreach { var: String, list: Vec, body: Vec }, + Func { name: String, f: Rc }, + Return(Option), + Break, + Continue, + Exit, + Throw(Expr), +} diff --git a/crates/js/src/formcalc/builtins.rs b/crates/js/src/formcalc/builtins.rs new file mode 100644 index 0000000000..c3b7c7f1f2 --- /dev/null +++ b/crates/js/src/formcalc/builtins.rs @@ -0,0 +1,504 @@ +use super::ast::Expr; +use super::dates::{MAX_DAYS, civil_from_days, days_from_civil, format_date, parse_date}; +use super::parser::parse; +use super::value::{Val, fixed, is_numeric_text, parse_num}; +use super::words::word_num; +use super::{Interp, MAX_STRING, R, STRING_TOO_LONG, XfaKind, is_limit}; + +impl Interp<'_> { + /// A built-in function: its result is never a string past [`MAX_STRING`], and work on + /// long strings is charged to the step budget. + pub(super) fn builtin(&mut self, name: &str, args: &[Expr]) -> R { + let v = self.builtin_inner(name, args)?; + if let Val::Str(s) = &v { + if s.len() > MAX_STRING { + return Err(STRING_TOO_LONG.into()); + } + self.charge((s.len() / 256) as u64)?; + } + Ok(v) + } + + fn builtin_inner(&mut self, name: &str, args: &[Expr]) -> R { + let lower = name.to_ascii_lowercase(); + let n = |v: f64| Ok(Val::Num(v)); + if let Some(value) = self.date_builtin(&lower, args)? { + return Ok(value); + } + if let Some(value) = self.financial_builtin(&lower, args)? { + return Ok(value); + } + if let Some(value) = self.unit_builtin(&lower, args)? { + return Ok(value); + } + Ok(match lower.as_str() { + // ── arithmetic + "abs" => return n(self.arg_num(args, 0)?.abs()), + "ceil" => return n(self.arg_num(args, 0)?.ceil()), + "floor" => return n(self.arg_num(args, 0)?.floor()), + "mod" => { + let (a, b) = (self.arg_num(args, 0)?, self.arg_num(args, 1)?); + if b == 0.0 { + return Err("division by zero".into()); + } + Val::Num(a - b * (a / b).trunc()) + } + "round" => { + let a = self.arg_num(args, 0)?; + let places = if args.len() > 1 { self.arg_num(args, 1)?.clamp(0.0, 12.0) as i32 } else { 0 }; + let k = 10f64.powi(places); + let scaled = a * k; + if scaled.is_finite() { Val::Num(scaled.round() / k) } else { Val::Num(a) } + } + "count" => { + // Non-null values and objects: an empty field doesn't count, a subform does. + let vals = self.arg_values_keeping(args, true)?; + let counts = |v: &Val| match v { + Val::Node(i) => self.node(*i).is_some_and(|n| !matches!(n.kind, XfaKind::Field | XfaKind::ExclGroup) || !n.value.is_empty()), + other => !self.is_null(other), + }; + Val::Num(vals.iter().filter(|v| counts(v)).count() as f64) + } + "sum" | "avg" | "max" | "min" => { + let vals = self.arg_values(args)?; + let nums: Vec = vals.iter().map(|v| self.to_num(v)).collect(); + match lower.as_str() { + "sum" => Val::Num(nums.iter().sum()), + "avg" => { + if nums.is_empty() { + Val::Null + } else { + Val::Num(nums.iter().sum::() / nums.len() as f64) + } + } + "max" => nums.iter().copied().fold(None, |m: Option, x| Some(m.map_or(x, |m| m.max(x)))).map_or(Val::Null, Val::Num), + _ => nums.iter().copied().fold(None, |m: Option, x| Some(m.map_or(x, |m| m.min(x)))).map_or(Val::Null, Val::Num), + } + } + // ── logical + "choose" => { + let i = self.arg_num(args, 0)?; + if !i.is_finite() || i < 1.0 { + return Ok(Val::Null); + } + self.arg(args, i as usize)? + } + "exists" => { + let Some(e) = args.first() else { return Ok(Val::Num(0.0)) }; + // A name that resolves to nothing is the answer "no"; a limit the lookup ran + // into (time, steps, depth, size) still stops the script. + match self.all_of(e) { + Ok(v) => Val::Num(if v.is_empty() { 0.0 } else { 1.0 }), + Err(e) if is_limit(&e) => return Err(e), + Err(_) => Val::Num(0.0), + } + } + "hasvalue" => { + let v = self.arg(args, 0)?; + Val::Num(if self.is_null(&v) || matches!(&v, Val::Str(s) if s.trim().is_empty()) { 0.0 } else { 1.0 }) + } + "oneof" => { + let v = self.arg(args, 0)?; + let rest = self.arg_values(args.get(1..).unwrap_or_default())?; + let mut hit = false; + for x in &rest { + // Each comparison copies and compares `v`: charged per item. + self.charge(self.cost(&v).saturating_add(self.cost(x)).saturating_add(1))?; + if self.binary("==", v.clone(), x.clone()).is_ok_and(|r| self.truthy(&r)) { + hit = true; + break; + } + } + Val::Num(if hit { 1.0 } else { 0.0 }) + } + "within" => { + let v = self.arg(args, 0)?; + let (lo, hi) = (self.arg(args, 1)?, self.arg(args, 2)?); + let ok = self.binary(">=", v.clone(), lo).is_ok_and(|r| self.truthy(&r)) && self.binary("<=", v, hi).is_ok_and(|r| self.truthy(&r)); + Val::Num(if ok { 1.0 } else { 0.0 }) + } + // ── strings + "at" => { + let (s, sub) = (self.arg_str(args, 0)?, self.arg_str(args, 1)?); + Val::Num(if sub.is_empty() { 0.0 } else { s.find(&sub).map_or(0.0, |b| s[..b].chars().count() as f64 + 1.0) }) + } + "concat" => { + let mut out = String::new(); + for i in 0..args.len() { + out.push_str(&self.arg_str(args, i)?); + if out.len() > MAX_STRING { + return Err(STRING_TOO_LONG.into()); + } + } + Val::Str(out) + } + "left" => { + let (s, k) = (self.arg_str(args, 0)?, self.arg_num(args, 1)?.max(0.0) as usize); + Val::Str(s.chars().take(k).collect()) + } + "right" => { + let (s, k) = (self.arg_str(args, 0)?, self.arg_num(args, 1)?.max(0.0) as usize); + let len = s.chars().count(); + Val::Str(s.chars().skip(len.saturating_sub(k)).collect()) + } + "len" => Val::Num(self.arg_str(args, 0)?.chars().count() as f64), + "lower" => Val::Str(self.arg_str(args, 0)?.to_lowercase()), + "upper" => Val::Str(self.arg_str(args, 0)?.to_uppercase()), + "ltrim" => Val::Str(self.arg_str(args, 0)?.trim_start().to_string()), + "rtrim" => Val::Str(self.arg_str(args, 0)?.trim_end().to_string()), + "space" => { + let k = self.arg_num(args, 0)?.clamp(0.0, MAX_STRING as f64) as usize; + Val::Str(" ".repeat(k)) + } + "str" => { + let v = self.arg_num(args, 0)?; + let width = if args.len() > 1 { self.arg_num(args, 1)?.clamp(0.0, 1000.0) as usize } else { 10 }; + let prec = if args.len() > 2 { self.arg_num(args, 2)?.clamp(0.0, 20.0) as usize } else { 0 }; + let text = fixed(v, prec); + Val::Str(if text.len() > width { "*".repeat(width) } else { format!("{text:>width$}") }) + } + "substr" => { + let s = self.arg_str(args, 0)?; + let start = self.arg_num(args, 1)?.max(1.0) as usize; + let len = self.arg_num(args, 2)?.max(0.0) as usize; + Val::Str(s.chars().skip(start - 1).take(len).collect()) + } + "stuff" => { + let s = self.arg_str(args, 0)?; + let start = self.arg_num(args, 1)?.max(1.0) as usize; + let del = self.arg_num(args, 2)?.max(0.0) as usize; + let ins = if args.len() > 3 { self.arg_str(args, 3)? } else { String::new() }; + let chars: Vec = s.chars().collect(); + let a = start.saturating_sub(1).min(chars.len()); + let b = a.saturating_add(del).min(chars.len()); + let out: String = chars[..a].iter().chain(ins.chars().collect::>().iter()).chain(chars[b..].iter()).collect(); + if out.len() > MAX_STRING { + return Err(STRING_TOO_LONG.into()); + } + Val::Str(out) + } + "replace" => { + let (s, old) = (self.arg_str(args, 0)?, self.arg_str(args, 1)?); + let new = if args.len() > 2 { self.arg_str(args, 2)? } else { String::new() }; + if old.is_empty() { + return Ok(Val::Str(s)); + } + let grown = s.matches(old.as_str()).count().saturating_mul(new.len()).saturating_add(s.len()); + if grown > MAX_STRING { + return Err(STRING_TOO_LONG.into()); + } + Val::Str(s.replace(&old, &new)) + } + "wordnum" => { + let v = self.arg_num(args, 0)?; + let kind = if args.len() > 1 { self.arg_num(args, 1)? } else { 0.0 }; + Val::Str(word_num(v, kind == 2.0)) + } + "uuid" => Val::Str(format!("{:032x}", (self.steps as u128).wrapping_mul(0x9E37_79B9_7F4A_7C15_F39C_C060_5CED_C835))), + "encode" | "decode" => { + let s = self.arg_str(args, 0)?; + let kind = if args.len() > 1 { self.arg_str(args, 1)?.to_ascii_lowercase() } else { "url".into() }; + Val::Str(match (lower.as_str(), kind.as_str()) { + ("encode", "html" | "xml") => s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """), + ("decode", "html" | "xml") => s.replace("<", "<").replace(">", ">").replace(""", "\"").replace("&", "&"), + ("encode", _) => s + .bytes() + .map(|b| if b.is_ascii_alphanumeric() || b"-_.~".contains(&b) { (b as char).to_string() } else { format!("%{b:02X}") }) + .collect(), + (_, _) => { + let mut out = Vec::new(); + let b = s.as_bytes(); + let mut i = 0; + while i < b.len() { + if b[i] == b'%' + && let Some(h) = b + .get(i + 1..i + 3) + .filter(|h| h.iter().all(u8::is_ascii_hexdigit)) + .and_then(|h| std::str::from_utf8(h).ok()) + .and_then(|h| u8::from_str_radix(h, 16).ok()) + { + out.push(h); + i += 3; + } else { + out.push(b[i]); + i += 1; + } + } + String::from_utf8_lossy(&out).into_owned() + } + }) + } + "format" => { + // Number pictures: `9` digit, `z` optional digit, `,` separator, `.` point, `$`. + let pic = self.arg_str(args, 0)?; + let v = self.arg(args, 1)?; + if pic.to_ascii_uppercase().contains("YY") || pic.to_ascii_uppercase().contains("DD") { + let text = self.to_str(&v); + let days = parse_num(&text).filter(|d| is_numeric_text(&text) && d.abs() < MAX_DAYS).map(|d| d as i64); + return Ok(match days { + Some(d) => { + let (y, m, dd) = civil_from_days(d); + Val::Str(format_date(y, m, dd, &pic)) + } + None => parse_date(&text, "YYYY-MM-DD").map_or(Val::Str(text), |(y, m, d)| Val::Str(format_date(y, m, d, &pic))), + }); + } + let num = self.to_num(&v); + let decimals = pic.split_once('.').map_or(0, |(_, f)| f.chars().filter(|c| matches!(c, '9' | 'z' | 'Z')).count()).min(20); + // `9` in the integer part forces a digit; `z` does not. + let min_int = pic.split('.').next().map_or(0, |i| i.chars().filter(|c| *c == '9').count()).min(20); + let width = min_int + if decimals > 0 { decimals + 1 } else { 0 }; + let grouped = pic.contains(','); + let mut text = format!("{:0>width$}", fixed(num.abs(), decimals)); + if grouped { + let (int, frac) = text.split_once('.').map_or((text.as_str(), ""), |(a, b)| (a, b)); + let mut g = String::new(); + for (k, c) in int.chars().enumerate() { + if k > 0 && (int.len() - k) % 3 == 0 { + g.push(','); + } + g.push(c); + } + text = if frac.is_empty() { g } else { format!("{g}.{frac}") }; + } + let nonzero = text.chars().any(|c| ('1'..='9').contains(&c)); + if pic.contains('$') { + text = format!("${text}"); + } + if num < 0.0 && nonzero { + text = format!("-{text}"); + } + Val::Str(text) + } + "parse" => { + let pic = self.arg_str(args, 0)?; + let s = self.arg_str(args, 1)?; + if pic.to_ascii_uppercase().contains("YY") { + // The canonical form of a date is ISO. + return Ok(parse_date(&s, &pic).map_or(Val::Null, |(y, m, d)| Val::Str(format_date(y, m, d, "YYYY-MM-DD")))); + } + let cleaned: String = s.chars().filter(|c| c.is_ascii_digit() || *c == '.' || *c == '-').collect(); + parse_num(&cleaned).map_or(Val::Null, Val::Num) + } + "ref" => { + let Some(e) = args.first() else { return Ok(Val::Null) }; + self.eval(e)? + } + "eval" => { + let src = self.arg_str(args, 0)?; + self.charge((src.len() / 16) as u64)?; + let body = parse(&src)?; + let (_, v) = self.block(&body)?; + v + } + "get" | "post" | "put" => return Err(format!("{name}: network access is not available to form scripts")), + _ => return Err(format!("unknown function {name}")), + }) + } + + fn date_builtin(&mut self, lower: &str, args: &[Expr]) -> R> { + let value = match lower { + "date" => { + #[cfg(not(target_arch = "wasm32"))] + { + let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or(0); + Val::Num((secs.div_euclid(86_400) + 25_568) as f64) + } + #[cfg(target_arch = "wasm32")] + { + Val::Num(25_568.0) + } + } + "time" => { + #[cfg(not(target_arch = "wasm32"))] + { + let ms = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as i64).unwrap_or(0); + Val::Num(ms.rem_euclid(86_400_000) as f64) + } + #[cfg(target_arch = "wasm32")] + { + Val::Num(0.0) + } + } + "date2num" | "isodate2num" => { + let s = self.arg_str(args, 0)?; + let pic = if lower == "isodate2num" || args.len() < 2 { "YYYY-MM-DD".to_string() } else { self.arg_str(args, 1)? }; + let parsed = parse_date(&s, &pic).or_else(|| parse_date(&s, "YYYYMMDD")).or_else(|| parse_date(&s, "YYYY-MM-DD")); + parsed.map_or(Val::Num(0.0), |(y, m, d)| Val::Num(days_from_civil(y, m, d) as f64)) + } + "num2date" => { + let days = self.arg_num(args, 0)?; + let pic = if args.len() > 1 { self.arg_str(args, 1)? } else { "MMM D, YYYY".into() }; + if !days.is_finite() || days.abs() >= MAX_DAYS { + return Ok(Some(Val::Null)); + } + let (y, m, d) = civil_from_days(days as i64); + Val::Str(format_date(y, m, d, &pic)) + } + "datefmt" | "localdatefmt" => { + let style = if args.is_empty() { 0.0 } else { self.arg_num(args, 0)? }; + Val::Str( + match style as i32 { + 1 => "M/D/YY", + 2 => "MMM D, YYYY", + 3 => "MMMM D, YYYY", + 4 => "EEEE, MMMM D, YYYY", + _ => "MMM D, YYYY", + } + .into(), + ) + } + "timefmt" | "localtimefmt" => Val::Str("HH:MM:SS".into()), + "num2time" | "num2gmtime" => { + let ms = self.arg_num(args, 0)?; + if !ms.is_finite() || ms < 0.0 { + return Ok(Some(Val::Null)); + } + let s = (ms / 1000.0) as u64; + Val::Str(format!("{:02}:{:02}:{:02}", (s / 3600) % 24, (s / 60) % 60, s % 60)) + } + "time2num" | "isotime2num" => { + let s = self.arg_str(args, 0)?; + // ISO 8601 allows a leading "T", a trailing "Z" and fractional seconds. + let t = s.trim(); + let t = t.strip_prefix(['T', 't']).unwrap_or(t); + let t = t.strip_suffix(['Z', 'z']).unwrap_or(t); + let mut parts = t.split(':'); + let mut whole = || parts.next().and_then(|p| p.trim().parse::().ok()).unwrap_or(0).min(1_000_000); + let (h, m) = (whole(), whole()); + let sec = + parts.next().and_then(|p| p.trim().parse::().ok()).filter(|v| v.is_finite() && *v >= 0.0).unwrap_or(0.0).min(1_000_000.0); + Val::Num(((h * 3600 + m * 60) * 1000) as f64 + (sec * 1000.0).round()) + } + _ => return Ok(None), + }; + Ok(Some(value)) + } + + fn financial_builtin(&mut self, lower: &str, args: &[Expr]) -> R> { + let value = match lower { + "fv" => { + let (p, r, k) = (self.arg_num(args, 0)?, self.arg_num(args, 1)?, self.arg_num(args, 2)?); + Val::Num(if r == 0.0 { p * k } else { p * (((1.0 + r).powf(k) - 1.0) / r) }) + } + "pv" => { + let (p, r, k) = (self.arg_num(args, 0)?, self.arg_num(args, 1)?, self.arg_num(args, 2)?); + Val::Num(if r == 0.0 { p * k } else { p * ((1.0 - (1.0 + r).powf(-k)) / r) }) + } + "pmt" => { + let (pv, r, k) = (self.arg_num(args, 0)?, self.arg_num(args, 1)?, self.arg_num(args, 2)?); + if k <= 0.0 { + return Err("Pmt needs a positive number of periods".into()); + } + Val::Num(if r == 0.0 { pv / k } else { pv * r / (1.0 - (1.0 + r).powf(-k)) }) + } + "term" => { + let (p, r, fv) = (self.arg_num(args, 0)?, self.arg_num(args, 1)?, self.arg_num(args, 2)?); + if p <= 0.0 || r <= 0.0 || fv <= 0.0 { + return Err("Term needs positive arguments".into()); + } + Val::Num(((fv * r / p) + 1.0).ln() / (1.0 + r).ln()) + } + "cterm" => { + let (r, fv, pv) = (self.arg_num(args, 0)?, self.arg_num(args, 1)?, self.arg_num(args, 2)?); + if r <= 0.0 || fv <= 0.0 || pv <= 0.0 { + return Err("CTerm needs positive arguments".into()); + } + Val::Num((fv / pv).ln() / (1.0 + r).ln()) + } + "rate" => { + let (fv, pv, k) = (self.arg_num(args, 0)?, self.arg_num(args, 1)?, self.arg_num(args, 2)?); + if fv <= 0.0 || pv <= 0.0 || k <= 0.0 { + return Err("Rate needs positive arguments".into()); + } + Val::Num((fv / pv).powf(1.0 / k) - 1.0) + } + "npv" => { + let r = self.arg_num(args, 0)?; + let flows = self.arg_values(args.get(1..).unwrap_or_default())?; + Val::Num(flows.iter().enumerate().map(|(i, v)| self.to_num(v) / (1.0 + r).powi(i as i32 + 1)).sum()) + } + "ipmt" | "ppmt" => { + let (pv, r, pmt, first, count) = ( + self.arg_num(args, 0)?, + self.arg_num(args, 1)?, + self.arg_num(args, 2)?, + self.arg_num(args, 3)?.max(1.0), + self.arg_num(args, 4)?.max(1.0), + ); + let (mut balance, mut interest, mut principal) = (pv, 0.0, 0.0); + for period in 1..=((first + count - 1.0).min(10_000.0) as u32) { + self.step()?; + let i = balance * r; + let p = (pmt - i).min(balance); + if period >= first as u32 { + interest += i; + principal += p; + } + balance -= p; + if balance <= 0.0 { + break; + } + } + Val::Num(if lower == "ipmt" { interest } else { principal }) + } + "apr" => { + let (principal, payment, periods) = (self.arg_num(args, 0)?, self.arg_num(args, 1)?, self.arg_num(args, 2)?); + if principal <= 0.0 || payment <= 0.0 || periods <= 0.0 { + return Err("Apr needs positive arguments".into()); + } + // Bisection on the monthly rate. + let (mut lo, mut hi) = (0.0f64, 1.0f64); + for _ in 0..200 { + let mid = (lo + hi) / 2.0; + let pmt = if mid == 0.0 { principal / periods } else { principal * mid / (1.0 - (1.0 + mid).powf(-periods)) }; + if pmt > payment { + hi = mid; + } else { + lo = mid; + } + } + Val::Num((lo + hi) / 2.0 * 12.0) + } + _ => return Ok(None), + }; + Ok(Some(value)) + } + + fn unit_builtin(&mut self, lower: &str, args: &[Expr]) -> R> { + let value = match lower { + "unittype" => { + let s = self.arg_str(args, 0)?; + let unit: String = s.trim().chars().skip_while(|c| c.is_ascii_digit() || *c == '.' || *c == '-' || *c == '+').collect(); + Val::Str( + match unit.trim().to_ascii_lowercase().as_str() { + "in" | "inch" | "inches" => "in", + "mm" | "millimeter" | "millimeters" => "mm", + "cm" | "centimeter" | "centimeters" => "cm", + "pt" | "point" | "points" => "pt", + "pc" | "pica" => "pc", + _ => "in", + } + .into(), + ) + } + "unitvalue" => { + let s = self.arg_str(args, 0)?; + let to = if args.len() > 1 { self.arg_str(args, 1)? } else { String::new() }; + let v = parse_num(&s).unwrap_or(0.0); + let unit: String = s.trim().chars().skip_while(|c| c.is_ascii_digit() || *c == '.' || *c == '-' || *c == '+').collect(); + let per_in = |u: &str| match u.trim().to_ascii_lowercase().as_str() { + "mm" | "millimeter" | "millimeters" => 25.4, + "cm" | "centimeter" | "centimeters" => 2.54, + "pt" | "point" | "points" => 72.0, + "pc" | "pica" => 6.0, + _ => 1.0, + }; + let target = if to.trim().is_empty() { unit.clone() } else { to }; + Val::Num(v / per_in(&unit) * per_in(&target)) + } + _ => return Ok(None), + }; + Ok(Some(value)) + } +} diff --git a/crates/js/src/formcalc/dates.rs b/crates/js/src/formcalc/dates.rs new file mode 100644 index 0000000000..bfb6383cdd --- /dev/null +++ b/crates/js/src/formcalc/dates.rs @@ -0,0 +1,166 @@ +// ── built-in functions ────────────────────────────────────────────────────────────────────── + +/// `NaiveDate::num_days_from_ce` of 1900-01-01 minus one: a civil date's day count rebased so +/// that 1900-01-01 is FormCalc day 1 (and 1970-01-01 is day 25_568). +const FORMCALC_EPOCH_FROM_CE: i64 = 693_595; + +/// Days in one 400-year Gregorian cycle; the calendar repeats after it. +const DAYS_PER_400_YEARS: i64 = 146_097; + +/// Days from 1900-01-01 (day 1, as FormCalc counts) to a civil date. +/// +/// chrono's `NaiveDate` stops near ±262143, but FormCalc accepts day numbers up to `MAX_DAYS` +/// (years past 270000). The calendar repeats every 400 years, so chrono converts the date +/// within its cycle and whole cycles are added back; the result matches the proleptic Gregorian +/// count for every year. Callers pass dates `parse_date`/`civil_from_days` already validated; +/// anything else reads as 0. +pub(super) fn days_from_civil(y: i64, m: u32, d: u32) -> i64 { + use chrono::Datelike; + let (cycles, year) = (i128::from(y.div_euclid(400)), y.rem_euclid(400)); + i32::try_from(year) + .ok() + .and_then(|year| chrono::NaiveDate::from_ymd_opt(year, m, d)) + .and_then(|date| { + let days = i128::from(date.num_days_from_ce()) - i128::from(FORMCALC_EPOCH_FROM_CE) + cycles * i128::from(DAYS_PER_400_YEARS); + i64::try_from(days).ok() + }) + .unwrap_or(0) +} + +/// The civil date (year, month, day) at FormCalc day number `n` (day 1 is 1900-01-01), for any +/// `n`: chrono converts the day within its 400-year cycle and the cycles are added back as years. +pub(super) fn civil_from_days(n: i64) -> (i64, u32, u32) { + use chrono::Datelike; + // Days since CE day 0 (0000-12-31), in i128 so no `n` overflows. + let from_ce = i128::from(n) + i128::from(FORMCALC_EPOCH_FROM_CE); + let (cycles, rest) = (from_ce.div_euclid(i128::from(DAYS_PER_400_YEARS)), from_ce.rem_euclid(i128::from(DAYS_PER_400_YEARS))); + // `rest` is 0..146_097, so CE day `rest` lies in years 0..=400 (day 0 is 0000-12-31). + let date = i32::try_from(rest).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt); + match date { + Some(date) => { + let y = i128::from(date.year()) + cycles * 400; + (i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), date.month(), date.day()) + } + // Unreachable: every `rest` in 0..146_097 is a valid CE day. + None => (1900, 1, 1), + } +} + +/// Day numbers (and years) beyond this are refused rather than computed. +pub(super) const MAX_DAYS: f64 = 1e8; + +const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]; + +/// Parse `text` as a date by `picture` (`YYYY-MM-DD`, `MM/DD/YYYY`, `MMM D, YYYY`, …). +pub(super) fn parse_date(text: &str, picture: &str) -> Option<(i64, u32, u32)> { + let pic: Vec = picture.chars().collect(); + let txt: Vec = text.trim().chars().collect(); + let (mut y, mut m, mut d) = (None, None, None); + let (mut i, mut j) = (0, 0); + while i < pic.len() { + let c = pic[i].to_ascii_uppercase(); + if c == 'Y' || c == 'M' || c == 'D' { + let mut run = 1; + while i + run < pic.len() && pic[i + run].to_ascii_uppercase() == c { + run += 1; + } + i += run; + if c == 'M' && run >= 3 { + // Month name. + let rest: String = txt.get(j..).unwrap_or(&[]).iter().collect(); + let lower = rest.to_ascii_lowercase(); + let found = MONTHS + .iter() + .position(|name| lower.starts_with(&name.to_ascii_lowercase()) || lower.starts_with(&name[..3].to_ascii_lowercase())); + let k = found?; + m = Some(k as u32 + 1); + let full = MONTHS[k].to_ascii_lowercase(); + j += if lower.starts_with(&full) { full.chars().count() } else { 3 }; + continue; + } + let width = if run == 1 { 2 } else { run }; + let mut digits = String::new(); + while j < txt.len() && txt[j].is_ascii_digit() && digits.len() < width { + digits.push(txt[j]); + j += 1; + } + if digits.is_empty() { + return None; + } + let v: i64 = digits.parse().ok()?; + match c { + 'Y' => y = Some(if run <= 2 { if v < 50 { 2000 + v } else { 1900 + v } } else { v }), + 'M' => m = Some(v as u32), + _ => d = Some(v as u32), + } + } else { + if txt.get(j) != Some(&pic[i]) { + return None; + } + i += 1; + j += 1; + } + } + let (y, m, d) = (y?, m.unwrap_or(1), d.unwrap_or(1)); + ((-200_000..=200_000).contains(&y) && (1..=12).contains(&m) && (1..=days_in(y, m)).contains(&d)).then_some((y, m, d)) +} + +/// Number of days in month `m` (1-12) of year `y`, leap years included. +pub(crate) fn days_in(y: i64, m: u32) -> u32 { + // The length of the month from chrono (the first of the next month, less the first of this + // one). An invalid month keeps the old 31-day default. + // Month lengths repeat every 400 years, so the year within its cycle is enough (and always + // in chrono's range, whatever `y` is). + let y = y.rem_euclid(400); + let first = |y: i64, m: u32| i32::try_from(y).ok().and_then(|y| chrono::NaiveDate::from_ymd_opt(y, m, 1)); + let next = if m == 12 { first(y + 1, 1) } else { first(y, m + 1) }; + match (first(y, m), next) { + (Some(a), Some(b)) => (b - a).num_days() as u32, + _ => 31, + } +} + +const WEEKDAYS: [&str; 7] = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; + +pub(super) fn format_date(y: i64, m: u32, d: u32, picture: &str) -> String { + let pic: Vec = picture.chars().collect(); + let mut out = String::new(); + let mut i = 0; + while i < pic.len() { + let c = pic[i].to_ascii_uppercase(); + if c == 'Y' || c == 'M' || c == 'D' { + let mut run = 1; + while i + run < pic.len() && pic[i + run].to_ascii_uppercase() == c { + run += 1; + } + i += run; + match (c, run) { + ('Y', 1 | 2) => out.push_str(&format!("{:02}", y.rem_euclid(100))), + ('Y', _) => out.push_str(&format!("{y:04}")), + ('M', 1) => out.push_str(&m.to_string()), + ('M', 2) => out.push_str(&format!("{m:02}")), + ('M', 3) => out.push_str(MONTHS.get((m as usize).saturating_sub(1)).map_or("", |n| &n[..3])), + ('M', _) => out.push_str(MONTHS.get((m as usize).saturating_sub(1)).copied().unwrap_or("")), + ('D', 1) => out.push_str(&d.to_string()), + (_, _) => out.push_str(&format!("{d:02}")), + } + } else if pic[i] == 'E' { + let mut run = 1; + while i + run < pic.len() && pic[i + run] == 'E' { + run += 1; + } + i += run; + // Day 1 (1900-01-01) was a Monday, so the serial modulo 7 indexes from Sunday. + let weekday = days_from_civil(y, m, d).rem_euclid(7) as usize; + match run { + 1 | 2 => out.push_str(&(weekday + 1).to_string()), + 3 => out.push_str(WEEKDAYS.get(weekday).map_or("", |n| &n[..3])), + _ => out.push_str(WEEKDAYS.get(weekday).copied().unwrap_or("")), + } + } else { + out.push(pic[i]); + i += 1; + } + } + out +} diff --git a/crates/js/src/formcalc/eval.rs b/crates/js/src/formcalc/eval.rs new file mode 100644 index 0000000000..a3218a78f6 --- /dev/null +++ b/crates/js/src/formcalc/eval.rs @@ -0,0 +1,621 @@ +use super::ast::{Expr, Stmt}; +use super::value::{Val, is_numeric_text, num_text}; +use super::{ + CALLS_TOO_DEEP, Flow, Func, HashMap, HashSet, Interp, LIST_TOO_LONG, LOOPED_TOO_MUCH, MAX_LIST, R, Rc, TOO_DEEP, XfaEffect, child_named, clip, + descendants_named, instances, reset_names, resolve_som, +}; + +impl Interp<'_> { + // ── expressions + + pub(super) fn eval(&mut self, e: &Expr) -> R { + self.step()?; + self.depth += 1; + if self.depth > self.max_depth { + self.depth -= 1; + return Err(TOO_DEEP.into()); + } + let r = self.eval_inner(e); + self.depth -= 1; + r + } + + fn eval_inner(&mut self, e: &Expr) -> R { + Ok(match e { + Expr::Num(n) => Val::Num(*n), + Expr::Str(s) => { + self.charge((s.len() / 256) as u64)?; + Val::Str(s.clone()) + } + Expr::Null => Val::Null, + Expr::Ident(name) => { + let v = self.ident(name)?; + self.charge(self.cost(&v))?; + v + } + Expr::Member(base, name) => { + let b = self.eval(base)?; + self.member(b, name)? + } + Expr::Index(base, idx) => self.index(base, idx)?, + Expr::Descend(base, name) => { + let b = self.eval(base)?; + let mut out = Vec::new(); + let mut seen = HashSet::new(); + for i in self.nodes_list(&b) { + self.gather(&mut out, &mut seen, descendants_named(self.h, i, name, 0))?; + } + match out.as_slice() { + [] => Val::Null, + [one] => Val::Node(*one), + many => Val::List(many.iter().map(|k| Val::Node(*k)).collect()), + } + } + Expr::Call(callee, args) => self.call(callee, args)?, + Expr::Neg(x) => { + let v = self.eval(x)?; + if self.is_null(&v) { Val::Null } else { Val::Num(-self.to_num(&v)) } + } + Expr::Not(x) => { + let v = self.eval(x)?; + Val::Num(if self.truthy(&v) { 0.0 } else { 1.0 }) + } + Expr::Bin(..) => { + // The parser leans left (`a + b + c` is `(a + b) + c`), so a chain is walked + // from its leftmost operand and folded without recursing per operator. + let mut links = Vec::new(); + let mut leftmost = e; + while let Expr::Bin(op, l, r) = leftmost { + links.push((*op, r.as_ref())); + leftmost = l; + } + let mut acc = self.eval(leftmost)?; + for (op, r) in links.into_iter().rev() { + self.step()?; + acc = match op { + "&" => { + if !self.truthy(&acc) { + // Not evaluated: the result is settled. + Val::Num(0.0) + } else { + let b = self.eval(r)?; + Val::Num(if self.truthy(&b) { 1.0 } else { 0.0 }) + } + } + "|" => { + if self.truthy(&acc) { + Val::Num(1.0) + } else { + let b = self.eval(r)?; + Val::Num(if self.truthy(&b) { 1.0 } else { 0.0 }) + } + } + _ => { + let b = self.eval(r)?; + // Comparing (and dereferencing) long strings is work too. + self.charge(self.cost(&acc).saturating_add(self.cost(&b)))?; + self.binary(op, acc, b)? + } + }; + } + acc + } + }) + } + + pub(super) fn binary(&self, op: &str, a: Val, b: Val) -> R { + let (a, b) = (self.deref(a), self.deref(b)); + // Null with an arithmetic operator is null only when both sides are null. + match op { + "+" | "-" | "*" | "/" => { + if self.is_null(&a) && self.is_null(&b) { + return Ok(Val::Null); + } + let (x, y) = (self.to_num(&a), self.to_num(&b)); + let v = match op { + "+" => x + y, + "-" => x - y, + "*" => x * y, + _ => { + if y == 0.0 { + return Err("division by zero".into()); + } + x / y + } + }; + Ok(Val::Num(v)) + } + _ => { + let ord = match (&a, &b) { + (Val::Null, Val::Null) => Some(std::cmp::Ordering::Equal), + (Val::Null, _) | (_, Val::Null) => None, + (Val::Str(s), Val::Str(t)) if !(is_numeric_text(s) && is_numeric_text(t)) => Some(s.cmp(t)), + (Val::Str(s), Val::Num(_)) if !is_numeric_text(s) => Some(s.as_str().cmp(num_text(self.to_num(&b)).as_str())), + (Val::Num(_), Val::Str(t)) if !is_numeric_text(t) => Some(num_text(self.to_num(&a)).as_str().cmp(t.as_str())), + _ => self.to_num(&a).partial_cmp(&self.to_num(&b)), + }; + let r = match (op, ord) { + ("==", Some(o)) => o.is_eq(), + ("<>", Some(o)) => !o.is_eq(), + ("<>", None) => true, + ("<", Some(o)) => o.is_lt(), + ("<=", Some(o)) => o.is_le(), + (">", Some(o)) => o.is_gt(), + (">=", Some(o)) => o.is_ge(), + _ => false, + }; + Ok(Val::Num(if r { 1.0 } else { 0.0 })) + } + } + } + + // ── calls + + fn call(&mut self, callee: &Expr, args: &[Expr]) -> R { + match callee { + Expr::Ident(name) => { + if let Some(f) = self.funcs.get(&name.to_ascii_lowercase()).cloned() { + let mut vals = Vec::new(); + for a in args { + let v = self.eval(a)?; + vals.push(self.take(v)?); + } + return self.call_user(&f, vals); + } + self.builtin(name, args) + } + Expr::Member(base, method) => { + let b = self.eval(base)?; + self.method(b, method, args) + } + _ => Err("not a function".into()), + } + } + + fn call_user(&mut self, f: &Func, vals: Vec) -> R { + if self.scopes.len() > self.max_depth { + return Err(CALLS_TOO_DEEP.into()); + } + let mut scope = HashMap::new(); + for (i, p) in f.params.iter().enumerate() { + scope.insert(p.clone(), vals.get(i).cloned().unwrap_or(Val::Null)); + } + self.scopes.push(scope); + let r = self.block(&f.body); + self.scopes.pop(); + match r? { + (Flow::Return(v), _) => Ok(v), + (Flow::Exit, last) => { + self.exited = true; + Ok(last) + } + (_, last) => Ok(last), + } + } + + pub(super) fn arg(&mut self, args: &[Expr], i: usize) -> R { + match args.get(i) { + Some(e) => { + let v = self.eval(e)?; + self.take(v) + } + None => Ok(Val::Null), + } + } + + pub(super) fn arg_num(&mut self, args: &[Expr], i: usize) -> R { + let v = self.arg(args, i)?; + Ok(self.to_num(&v)) + } + + pub(super) fn arg_str(&mut self, args: &[Expr], i: usize) -> R { + let v = self.arg(args, i)?; + Ok(self.to_str(&v)) + } + + /// Every value the arguments hold, lists flattened, nulls left out. + pub(super) fn arg_values(&mut self, args: &[Expr]) -> R> { + self.arg_values_keeping(args, false) + } + + /// [`Self::arg_values`]; `objects` keeps form objects (fields, subforms) as themselves, so + /// `Count(row[*])` counts rows rather than filled-in values. + pub(super) fn arg_values_keeping(&mut self, args: &[Expr], objects: bool) -> R> { + let mut out = Vec::new(); + for a in args { + let v = self.eval(a)?; + let v = if objects { v } else { self.take(v)? }; + match v { + Val::List(items) => out.extend(items.into_iter().filter(|x| !matches!(x, Val::Null))), + Val::Null => {} + other => out.push(other), + } + if out.len() > MAX_LIST { + return Err(LIST_TOO_LONG.into()); + } + } + Ok(out) + } + + fn method(&mut self, base: Val, method: &str, args: &[Expr]) -> R { + let m = method.to_ascii_lowercase(); + match base { + Val::Host => match m.as_str() { + "messagebox" => { + let t = self.arg_str(args, 0)?; + self.h.push(XfaEffect::MessageBox(t)); + Ok(Val::Num(1.0)) + } + "resetdata" => { + let list = self.arg_str(args, 0)?; + self.h.push(XfaEffect::ResetData(reset_names(&list))); + Ok(Val::Null) + } + "print" => { + self.h.push(XfaEffect::Print); + Ok(Val::Null) + } + "beep" => { + self.h.push(XfaEffect::Beep); + Ok(Val::Null) + } + "gotourl" => { + let u = self.arg_str(args, 0)?; + self.h.push(XfaEffect::LaunchUrl(u)); + Ok(Val::Null) + } + "setfocus" => { + let target = match args.first() { + Some(e) => { + let raw = self.eval(e)?; + match self.nodes_of(&raw).first() { + Some(i) => self.node(*i).map(|n| n.som.clone()).unwrap_or_default(), + None => { + let s = self.to_str(&raw); + let found = resolve_som(self.h, self.h.current, &s); + found.first().and_then(|i| self.node(*i)).map(|n| n.som.clone()).unwrap_or(s) + } + } + } + None => String::new(), + }; + self.h.push(XfaEffect::SetFocus(target)); + Ok(Val::Null) + } + _ => Ok(Val::Null), + }, + Val::Layout => match m.as_str() { + "page" | "abspage" => Ok(Val::Num(self.h.doc.page as f64 + if m == "page" { 1.0 } else { 0.0 })), + "pagecount" | "abspagecount" => Ok(Val::Num(self.h.doc.page_count as f64)), + "relayout" | "relayoutpagearea" => { + self.h.push(XfaEffect::Relayout); + Ok(Val::Null) + } + _ => Ok(Val::Num(0.0)), + }, + Val::Xfa => match m.as_str() { + "resolvenode" | "resolvenodes" => { + let s = self.arg_str(args, 0)?; + let found = resolve_som(self.h, self.h.current, &s); + Ok(self.set_val(found, m == "resolvenodes")) + } + "recalculate" => { + self.h.push(XfaEffect::Recalculate); + Ok(Val::Null) + } + "remerge" => { + self.h.push(XfaEffect::Relayout); + Ok(Val::Null) + } + _ => Ok(Val::Null), + }, + Val::Node(i) => match m.as_str() { + "resolvenode" | "resolvenodes" => { + let s = self.arg_str(args, 0)?; + let found = resolve_som(self.h, i, &s); + Ok(self.set_val(found, m == "resolvenodes")) + } + "recalculate" => { + self.h.push(XfaEffect::Recalculate); + Ok(Val::Null) + } + "remerge" => { + self.h.push(XfaEffect::Relayout); + Ok(Val::Null) + } + _ => Ok(Val::Null), + }, + Val::Im(parent, name) => match m.as_str() { + "addinstance" | "insertinstance" => Ok(self.h.add_instance(parent, &name).map_or(Val::Null, Val::Node)), + "removeinstance" => { + let i = self.arg_num(args, 0)?; + if i.is_finite() && i >= 0.0 { + self.h.remove_instance(parent, &name, i as usize); + } + Ok(Val::Null) + } + "setinstances" => { + let want = self.arg_num(args, 0)?; + if want.is_finite() && want >= 0.0 { + self.h.set_instances(parent, &name, want as usize); + } + Ok(Val::Null) + } + _ => Ok(Val::Null), + }, + Val::Event | Val::Sink | Val::List(_) => Ok(Val::Null), + Val::Null => Err(format!("no object to call {method} on")), + Val::Num(_) | Val::Str(_) => Err(format!("{} has no method {method}", self.to_str(&base))), + } + } + + fn set_val(&self, found: Vec, many: bool) -> Val { + if many { + return Val::List(found.into_iter().map(Val::Node).collect()); + } + match found.as_slice() { + [] => Val::Null, + [one] => Val::Node(*one), + all => Val::List(all.iter().map(|i| Val::Node(*i)).collect()), + } + } + + // ── assignment + + fn assign(&mut self, target: &Expr, v: Val) -> R<()> { + let v = self.deref(v); + match target { + Expr::Ident(name) => { + if self.set_var(name, v.clone()) { + return Ok(()); + } + let t = self.ident(name)?; + self.assign_to(t, "rawValue", v) + } + Expr::Member(base, prop) => { + let b = self.eval(base)?; + self.assign_to(b, prop, v) + } + Expr::Index(..) | Expr::Descend(..) => { + let t = self.eval(target)?; + self.assign_to(t, "rawValue", v) + } + _ => Err("cannot assign to that".into()), + } + } + + fn assign_to(&mut self, target: Val, prop: &str, v: Val) -> R<()> { + match target { + Val::Node(i) => { + let Some(n) = self.node(i) else { return Ok(()) }; + let som = n.som.clone(); + match prop { + "rawValue" | "value" | "formattedValue" => { + let text = self.to_str(&v); + if !self.h.set_value(i, text) { + return Err(format!("{} is not a field", if som.is_empty() { "the form" } else { som.as_str() })); + } + } + "presence" => { + let p = self.to_str(&v); + if matches!(p.as_str(), "visible" | "invisible" | "hidden" | "inactive") { + self.h.set_state(i, XfaEffect::SetPresence { som, presence: p }); + } + } + "access" => { + let a = self.to_str(&v); + if matches!(a.as_str(), "open" | "readOnly" | "protected" | "nonInteractive") { + self.h.set_state(i, XfaEffect::SetAccess { som, access: a }); + } + } + other => { + // `page1.qty = 7`: the child object named by the last step takes the value. + if let Some(child) = other.strip_prefix('_') { + if child_named(self.h, i, child).is_some() { + return self.assign_to(Val::Im(i, child.to_string()), "count", v); + } + return Ok(()); + } + let kids = instances(self.h, i, other); + if kids.is_empty() { + return Err(format!("{som} has no {other} to assign to")); + } + for k in kids { + // Every destination copies the value: charge each copy. + self.charge(self.cost(&v))?; + self.assign_to(Val::Node(k), "rawValue", v.clone())?; + } + } + } + Ok(()) + } + Val::List(items) => { + for it in items { + self.charge(self.cost(&v))?; + self.assign_to(it, prop, v.clone())?; + } + Ok(()) + } + Val::Im(parent, name) if prop.eq_ignore_ascii_case("count") => { + let want = self.to_num(&v); + if want.is_finite() && want >= 0.0 { + self.h.set_instances(parent, &name, want as usize); + } + Ok(()) + } + _ => Ok(()), + } + } + + // ── statements + + /// Run a block; returns how it ended and the value of its last expression statement. + pub(super) fn block(&mut self, body: &[Stmt]) -> R<(Flow, Val)> { + // Nesting of statements counts like nesting of expressions: both are stack. + self.depth += 1; + if self.depth > self.max_depth { + self.depth -= 1; + return Err(TOO_DEEP.into()); + } + let r = self.block_inner(body); + self.depth -= 1; + r + } + + fn block_inner(&mut self, body: &[Stmt]) -> R<(Flow, Val)> { + let mut last = Val::Null; + for s in body { + self.step()?; + match self.exec(s)? { + (Flow::Next, v) => { + if let Some(v) = v { + last = v; + } + if self.exited { + return Ok((Flow::Exit, last)); + } + } + (flow, _) => return Ok((flow, last)), + } + } + Ok((Flow::Next, last)) + } + + fn exec(&mut self, s: &Stmt) -> R<(Flow, Option)> { + match s { + Stmt::Var(name, init) => { + let v = match init { + Some(e) => { + let v = self.eval(e)?; + self.take(v)? + } + None => Val::Null, + }; + self.declare(name, v.clone()); + Ok((Flow::Next, Some(v))) + } + Stmt::Assign(target, e) => { + let v = self.eval(e)?; + let v = self.take(v)?; + self.assign(target, v.clone())?; + Ok((Flow::Next, Some(v))) + } + Stmt::Expr(e) => { + let v = self.eval(e)?; + let v = self.take(v)?; + Ok((Flow::Next, Some(v))) + } + Stmt::If(arms, otherwise) => { + for (cond, body) in arms { + let c = self.eval(cond)?; + if self.truthy(&c) { + let (f, v) = self.block(body)?; + return Ok((f, Some(v))); + } + } + if let Some(body) = otherwise { + let (f, v) = self.block(body)?; + return Ok((f, Some(v))); + } + Ok((Flow::Next, None)) + } + Stmt::While(cond, body) => { + loop { + let c = self.eval(cond)?; + if !self.truthy(&c) { + break; + } + self.iterate()?; + match self.block(body)? { + (Flow::Break, _) => break, + (Flow::Return(v), _) => return Ok((Flow::Return(v), None)), + (Flow::Exit, _) => return Ok((Flow::Exit, None)), + _ => {} + } + } + Ok((Flow::Next, None)) + } + Stmt::For { var, from, to, up, step, body } => { + let start = { + let v = self.eval(from)?; + self.to_num(&v) + }; + let end = { + let v = self.eval(to)?; + self.to_num(&v) + }; + let by = match step { + Some(e) => { + let v = self.eval(e)?; + self.to_num(&v).abs() + } + None => 1.0, + }; + if by.is_nan() || by <= 0.0 || !start.is_finite() || !end.is_finite() { + return Err("bad for loop bounds".into()); + } + self.declare(var, Val::Num(start)); + let mut i = start; + while if *up { i <= end } else { i >= end } { + self.iterate()?; + self.set_var(var, Val::Num(i)); + match self.block(body)? { + (Flow::Break, _) => break, + (Flow::Return(v), _) => return Ok((Flow::Return(v), None)), + (Flow::Exit, _) => return Ok((Flow::Exit, None)), + _ => {} + } + i = if *up { i + by } else { i - by }; + } + Ok((Flow::Next, None)) + } + Stmt::Foreach { var, list, body } => { + let items = self.arg_values_keeping(list, true)?; + self.declare(var, Val::Null); + for it in items { + self.iterate()?; + self.set_var(var, it); + match self.block(body)? { + (Flow::Break, _) => break, + (Flow::Return(v), _) => return Ok((Flow::Return(v), None)), + (Flow::Exit, _) => return Ok((Flow::Exit, None)), + _ => {} + } + } + Ok((Flow::Next, None)) + } + Stmt::Func { name, f } => { + self.funcs.insert(name.to_ascii_lowercase(), Rc::clone(f)); + Ok((Flow::Next, None)) + } + Stmt::Return(e) => { + let v = match e { + Some(e) => { + let v = self.eval(e)?; + self.take(v)? + } + None => Val::Null, + }; + Ok((Flow::Return(v), None)) + } + Stmt::Break => Ok((Flow::Break, None)), + Stmt::Continue => Ok((Flow::Continue, None)), + Stmt::Exit => Ok((Flow::Exit, None)), + Stmt::Throw(e) => { + let v = self.eval(e)?; + self.charge(self.cost(&v))?; + // The thrown value becomes the error message the caller keeps: cut it as + // messages are. + Err(clip(self.to_str(&v))) + } + } + } + + fn iterate(&mut self) -> R<()> { + if self.loops_left == 0 { + return Err(LOOPED_TOO_MUCH.into()); + } + self.loops_left -= 1; + Ok(()) + } +} diff --git a/crates/js/src/formcalc/lexer.rs b/crates/js/src/formcalc/lexer.rs new file mode 100644 index 0000000000..46af19f761 --- /dev/null +++ b/crates/js/src/formcalc/lexer.rs @@ -0,0 +1,129 @@ +use super::MAX_STRING; + +// ── lexer ─────────────────────────────────────────────────────────────────────────────────── + +#[derive(Clone, Debug, PartialEq)] +pub(super) enum Tok { + Num(f64), + Str(String), + Ident(String), + Op(&'static str), + Newline, + Eof, +} + +#[derive(Clone, Debug, PartialEq)] +pub(super) struct Token { + pub(super) tok: Tok, + pub(super) line: usize, +} + +const OPS: [&str; 20] = ["<=", ">=", "==", "<>", "..", "(", ")", "[", "]", ".", ",", "=", "<", ">", "+", "-", "*", "/", "&", "|"]; + +pub(super) fn lex(src: &str) -> Result, String> { + let chars: Vec = src.chars().collect(); + let mut out = Vec::new(); + let mut i = 0; + let mut line = 1; + let mut depth = 0usize; + while i < chars.len() { + let c = chars[i]; + match c { + '\n' => { + line += 1; + i += 1; + // Newlines inside brackets continue the expression. + if depth == 0 { + out.push(Token { tok: Tok::Newline, line }); + } + } + ' ' | '\t' | '\r' => i += 1, + ';' => { + while i < chars.len() && chars[i] != '\n' { + i += 1; + } + } + '/' if chars.get(i + 1) == Some(&'/') => { + while i < chars.len() && chars[i] != '\n' { + i += 1; + } + } + '"' => { + let mut s = String::new(); + i += 1; + loop { + match chars.get(i) { + None => return Err(format!("line {line}: unterminated string")), + Some('"') if chars.get(i + 1) == Some(&'"') => { + s.push('"'); + i += 2; + } + Some('"') => { + i += 1; + break; + } + Some(&ch) => { + if ch == '\n' { + line += 1; + } + s.push(ch); + i += 1; + } + } + if s.len() > MAX_STRING { + return Err(format!("line {line}: a string is too long")); + } + } + out.push(Token { tok: Tok::Str(s), line }); + } + c if c.is_ascii_digit() || (c == '.' && chars.get(i + 1).is_some_and(char::is_ascii_digit)) => { + let start = i; + while i < chars.len() && (chars[i].is_ascii_digit() || (chars[i] == '.' && chars.get(i + 1) != Some(&'.'))) { + i += 1; + } + if i < chars.len() && (chars[i] == 'e' || chars[i] == 'E') { + let mut j = i + 1; + if j < chars.len() && (chars[j] == '+' || chars[j] == '-') { + j += 1; + } + if j < chars.len() && chars[j].is_ascii_digit() { + i = j; + while i < chars.len() && chars[i].is_ascii_digit() { + i += 1; + } + } + } + let text: String = chars[start..i].iter().collect(); + let n: f64 = text.parse().map_err(|_| format!("line {line}: bad number {text}"))?; + out.push(Token { tok: Tok::Num(n), line }); + } + '!' => { + out.push(Token { tok: Tok::Ident("!".into()), line }); + i += 1; + } + c if c.is_alphabetic() || c == '_' || c == '$' => { + let start = i; + i += 1; + while i < chars.len() && (chars[i].is_alphanumeric() || chars[i] == '_' || chars[i] == '$') { + i += 1; + } + let text: String = chars[start..i].iter().collect(); + out.push(Token { tok: Tok::Ident(text), line }); + } + _ => { + let rest: String = chars[i..chars.len().min(i + 2)].iter().collect(); + let op = OPS.iter().find(|o| rest.starts_with(*o)).copied().ok_or_else(|| format!("line {line}: unexpected character {c:?}"))?; + match op { + "(" | "[" => depth += 1, + ")" | "]" => depth = depth.saturating_sub(1), + _ => {} + } + out.push(Token { tok: Tok::Op(op), line }); + i += op.len(); + } + } + } + out.push(Token { tok: Tok::Newline, line }); + out.push(Token { tok: Tok::Eof, line }); + Ok(out) +} diff --git a/crates/js/src/formcalc/parser.rs b/crates/js/src/formcalc/parser.rs new file mode 100644 index 0000000000..fceedadf5f --- /dev/null +++ b/crates/js/src/formcalc/parser.rs @@ -0,0 +1,451 @@ +use super::ast::{Expr, Idx, Stmt}; +use super::lexer::{Tok, Token, lex}; +use super::{Func, MAX_CHAIN, MAX_PARSE_DEPTH, Rc}; + +struct Parser { + toks: Vec, + at: usize, + depth: usize, + /// Operators and accessors chained so far in the expression being parsed. + chain: usize, +} + +fn kw(t: &Tok, word: &str) -> bool { + matches!(t, Tok::Ident(s) if s.eq_ignore_ascii_case(word)) +} + +impl Parser { + fn peek(&self) -> &Tok { + self.toks.get(self.at).map_or(&Tok::Eof, |t| &t.tok) + } + + fn line(&self) -> usize { + self.toks.get(self.at).map_or(0, |t| t.line) + } + + fn next(&mut self) -> Tok { + let t = self.peek().clone(); + self.at += 1; + t + } + + fn is_op(&self, op: &str) -> bool { + matches!(self.peek(), Tok::Op(o) if *o == op) + } + + fn eat_op(&mut self, op: &str) -> bool { + if self.is_op(op) { + self.at += 1; + true + } else { + false + } + } + + fn expect_op(&mut self, op: &str) -> Result<(), String> { + if self.eat_op(op) { Ok(()) } else { Err(format!("line {}: expected {op}", self.line())) } + } + + fn eat_kw(&mut self, word: &str) -> bool { + if kw(self.peek(), word) { + self.at += 1; + true + } else { + false + } + } + + fn expect_kw(&mut self, word: &str) -> Result<(), String> { + if self.eat_kw(word) { Ok(()) } else { Err(format!("line {}: expected {word}", self.line())) } + } + + fn skip_newlines(&mut self) { + while matches!(self.peek(), Tok::Newline) { + self.at += 1; + } + } + + fn enter(&mut self) -> Result<(), String> { + self.depth += 1; + if self.depth > MAX_PARSE_DEPTH { + return Err("the script is nested too deeply".into()); + } + Ok(()) + } + + fn leave(&mut self) { + self.depth = self.depth.saturating_sub(1); + } + + /// One more link in an operator or accessor chain. + fn link(&mut self) -> Result<(), String> { + self.chain += 1; + if self.chain > MAX_CHAIN { + return Err(format!("line {}: an expression is too long", self.line())); + } + Ok(()) + } + + /// Statements up to one of `enders` (not consumed). + fn block(&mut self, enders: &[&str]) -> Result, String> { + self.enter()?; + let mut out = Vec::new(); + loop { + self.skip_newlines(); + match self.peek() { + Tok::Eof => break, + Tok::Ident(w) if enders.iter().any(|e| w.eq_ignore_ascii_case(e)) => break, + _ => out.push(self.statement()?), + } + } + self.leave(); + Ok(out) + } + + fn statement(&mut self) -> Result { + let line = self.line(); + match self.peek().clone() { + Tok::Ident(w) if w.eq_ignore_ascii_case("var") => { + self.at += 1; + let Tok::Ident(name) = self.next() else { return Err(format!("line {line}: expected a name after var")) }; + let init = if self.eat_op("=") { Some(self.top_expr()?) } else { None }; + Ok(Stmt::Var(name, init)) + } + Tok::Ident(w) if w.eq_ignore_ascii_case("if") => { + self.at += 1; + let mut arms = Vec::new(); + let cond = self.top_expr()?; + self.expect_kw("then")?; + let body = self.block(&["elseif", "else", "endif"])?; + arms.push((cond, body)); + let mut otherwise = None; + loop { + if self.eat_kw("elseif") { + let c = self.top_expr()?; + self.expect_kw("then")?; + let b = self.block(&["elseif", "else", "endif"])?; + arms.push((c, b)); + } else if self.eat_kw("else") { + otherwise = Some(self.block(&["endif"])?); + } else { + self.expect_kw("endif")?; + break; + } + } + Ok(Stmt::If(arms, otherwise)) + } + Tok::Ident(w) if w.eq_ignore_ascii_case("while") => { + self.at += 1; + let cond = self.top_expr()?; + self.expect_kw("do")?; + let body = self.block(&["endwhile"])?; + self.expect_kw("endwhile")?; + Ok(Stmt::While(cond, body)) + } + Tok::Ident(w) if w.eq_ignore_ascii_case("for") => { + self.at += 1; + let Tok::Ident(var) = self.next() else { return Err(format!("line {line}: expected a variable after for")) }; + self.expect_op("=")?; + let from = self.top_expr()?; + let up = if self.eat_kw("upto") { + true + } else if self.eat_kw("downto") { + false + } else { + return Err(format!("line {line}: expected upto or downto")); + }; + let to = self.top_expr()?; + let step = if self.eat_kw("step") { Some(self.top_expr()?) } else { None }; + self.expect_kw("do")?; + let body = self.block(&["endfor"])?; + self.expect_kw("endfor")?; + Ok(Stmt::For { var, from, to, up, step, body }) + } + Tok::Ident(w) if w.eq_ignore_ascii_case("foreach") => { + self.at += 1; + let Tok::Ident(var) = self.next() else { return Err(format!("line {line}: expected a variable after foreach")) }; + self.expect_kw("in")?; + self.expect_op("(")?; + let mut list = Vec::new(); + if !self.is_op(")") { + loop { + list.push(self.top_expr()?); + if !self.eat_op(",") { + break; + } + } + } + self.expect_op(")")?; + self.expect_kw("do")?; + let body = self.block(&["endfor"])?; + self.expect_kw("endfor")?; + Ok(Stmt::Foreach { var, list, body }) + } + Tok::Ident(w) if w.eq_ignore_ascii_case("func") => { + self.at += 1; + let Tok::Ident(name) = self.next() else { return Err(format!("line {line}: expected a name after func")) }; + self.expect_op("(")?; + let mut params = Vec::new(); + if !self.is_op(")") { + loop { + let Tok::Ident(p) = self.next() else { return Err(format!("line {line}: expected a parameter name")) }; + params.push(p); + if !self.eat_op(",") { + break; + } + } + } + self.expect_op(")")?; + self.expect_kw("do")?; + let body = self.block(&["endfunc"])?; + self.expect_kw("endfunc")?; + Ok(Stmt::Func { name, f: Rc::new(Func { params, body }) }) + } + Tok::Ident(w) if w.eq_ignore_ascii_case("return") => { + self.at += 1; + let v = if matches!(self.peek(), Tok::Newline | Tok::Eof) { None } else { Some(self.top_expr()?) }; + Ok(Stmt::Return(v)) + } + Tok::Ident(w) if w.eq_ignore_ascii_case("break") => { + self.at += 1; + Ok(Stmt::Break) + } + Tok::Ident(w) if w.eq_ignore_ascii_case("continue") => { + self.at += 1; + Ok(Stmt::Continue) + } + Tok::Ident(w) if w.eq_ignore_ascii_case("exit") => { + self.at += 1; + Ok(Stmt::Exit) + } + Tok::Ident(w) if w.eq_ignore_ascii_case("throw") => { + self.at += 1; + Ok(Stmt::Throw(self.top_expr()?)) + } + Tok::Ident(w) if w.eq_ignore_ascii_case("do") => { + // `do … end`: a block as one statement. + self.at += 1; + let body = self.block(&["end"])?; + self.expect_kw("end")?; + Ok(Stmt::If(vec![(Expr::Num(1.0), body)], None)) + } + _ => { + let e = self.top_expr()?; + if self.eat_op("=") { + let v = self.top_expr()?; + return Ok(Stmt::Assign(e, v)); + } + Ok(Stmt::Expr(e)) + } + } + } + + /// An expression that starts a tree of its own (a condition, a bound, a statement's + /// expression): its operator chain is counted from zero, whatever came before it. + fn top_expr(&mut self) -> Result { + self.chain = 0; + self.expr() + } + + fn expr(&mut self) -> Result { + self.enter()?; + let e = self.or_expr(); + self.leave(); + e + } + + fn or_expr(&mut self) -> Result { + let mut l = self.and_expr()?; + loop { + if self.eat_op("|") || self.eat_kw("or") { + self.link()?; + let r = self.and_expr()?; + l = Expr::Bin("|", Box::new(l), Box::new(r)); + } else { + return Ok(l); + } + } + } + + fn and_expr(&mut self) -> Result { + let mut l = self.cmp_expr()?; + loop { + if self.eat_op("&") || self.eat_kw("and") { + self.link()?; + let r = self.cmp_expr()?; + l = Expr::Bin("&", Box::new(l), Box::new(r)); + } else { + return Ok(l); + } + } + } + + fn cmp_expr(&mut self) -> Result { + let mut l = self.add_expr()?; + loop { + let op = match self.peek() { + Tok::Op("==") => "==", + Tok::Op("<>") => "<>", + Tok::Op("<=") => "<=", + Tok::Op(">=") => ">=", + Tok::Op("<") => "<", + Tok::Op(">") => ">", + Tok::Ident(w) if w.eq_ignore_ascii_case("eq") => "==", + Tok::Ident(w) if w.eq_ignore_ascii_case("ne") => "<>", + Tok::Ident(w) if w.eq_ignore_ascii_case("le") => "<=", + Tok::Ident(w) if w.eq_ignore_ascii_case("ge") => ">=", + Tok::Ident(w) if w.eq_ignore_ascii_case("lt") => "<", + Tok::Ident(w) if w.eq_ignore_ascii_case("gt") => ">", + _ => return Ok(l), + }; + self.at += 1; + self.link()?; + let r = self.add_expr()?; + l = Expr::Bin(op, Box::new(l), Box::new(r)); + } + } + + fn add_expr(&mut self) -> Result { + let mut l = self.mul_expr()?; + loop { + let op = match self.peek() { + Tok::Op("+") => "+", + Tok::Op("-") => "-", + _ => return Ok(l), + }; + self.at += 1; + self.link()?; + let r = self.mul_expr()?; + l = Expr::Bin(op, Box::new(l), Box::new(r)); + } + } + + fn mul_expr(&mut self) -> Result { + let mut l = self.unary()?; + loop { + let op = match self.peek() { + Tok::Op("*") => "*", + Tok::Op("/") => "/", + _ => return Ok(l), + }; + self.at += 1; + self.link()?; + let r = self.unary()?; + l = Expr::Bin(op, Box::new(l), Box::new(r)); + } + } + + fn unary(&mut self) -> Result { + self.enter()?; + let e = if self.eat_op("-") { + Ok(Expr::Neg(Box::new(self.unary()?))) + } else if self.eat_op("+") { + self.unary() + } else if self.eat_kw("not") || matches!(self.peek(), Tok::Ident(w) if w == "!") { + if matches!(self.peek(), Tok::Ident(w) if w == "!") { + self.at += 1; + } + Ok(Expr::Not(Box::new(self.unary()?))) + } else { + self.postfix() + }; + self.leave(); + e + } + + fn postfix(&mut self) -> Result { + let mut e = self.primary()?; + loop { + if matches!(self.peek(), Tok::Op("." | ".." | "[" | "(")) { + self.link()?; + } + if self.eat_op(".") { + let Tok::Ident(name) = self.next() else { return Err(format!("line {}: expected a name after .", self.line())) }; + e = Expr::Member(Box::new(e), name); + } else if self.eat_op("..") { + let Tok::Ident(name) = self.next() else { return Err(format!("line {}: expected a name after ..", self.line())) }; + e = Expr::Descend(Box::new(e), name); + } else if self.eat_op("[") { + let sign = match self.peek() { + Tok::Op("-") => Some(-1), + Tok::Op("+") => Some(1), + _ => None, + }; + let relative = sign.and_then(|sign| match (self.toks.get(self.at + 1).map(|t| &t.tok), self.toks.get(self.at + 2).map(|t| &t.tok)) { + (Some(Tok::Num(n)), Some(Tok::Op("]"))) if n.is_finite() && *n >= 0.0 && *n < 1e6 => Some(sign * (*n as i64)), + _ => None, + }); + let idx = if let Some(k) = relative { + self.at += 2; + Idx::Rel(k) + } else if self.eat_op("*") { + Idx::All + } else { + Idx::Abs(Box::new(self.expr()?)) + }; + self.expect_op("]")?; + e = Expr::Index(Box::new(e), idx); + } else if self.eat_op("(") { + let mut args = Vec::new(); + self.skip_newlines(); + if !self.is_op(")") { + loop { + args.push(self.expr()?); + self.skip_newlines(); + if !self.eat_op(",") { + break; + } + self.skip_newlines(); + } + } + self.expect_op(")")?; + e = Expr::Call(Box::new(e), args); + } else { + return Ok(e); + } + } + } + + fn primary(&mut self) -> Result { + let line = self.line(); + match self.next() { + Tok::Num(n) => Ok(Expr::Num(n)), + Tok::Str(s) => Ok(Expr::Str(s)), + Tok::Op("(") => { + self.skip_newlines(); + let e = self.expr()?; + self.skip_newlines(); + self.expect_op(")")?; + Ok(e) + } + Tok::Ident(w) if w.eq_ignore_ascii_case("null") => Ok(Expr::Null), + Tok::Ident(w) if w.eq_ignore_ascii_case("nan") => Ok(Expr::Num(f64::NAN)), + Tok::Ident(w) if w.eq_ignore_ascii_case("infinity") => Ok(Expr::Num(f64::INFINITY)), + Tok::Ident(w) if w.eq_ignore_ascii_case("true") => Ok(Expr::Num(1.0)), + Tok::Ident(w) if w.eq_ignore_ascii_case("false") => Ok(Expr::Num(0.0)), + Tok::Ident(w) => Ok(Expr::Ident(w)), + t => Err(format!("line {line}: unexpected {}", describe(&t))), + } + } +} + +fn describe(t: &Tok) -> String { + match t { + Tok::Num(n) => format!("number {n}"), + Tok::Str(s) => format!("string {s:?}"), + Tok::Ident(s) => s.clone(), + Tok::Op(o) => format!("{o:?}"), + Tok::Newline => "end of line".into(), + Tok::Eof => "end of script".into(), + } +} + +pub(super) fn parse(src: &str) -> Result, String> { + let toks = lex(src)?; + let mut p = Parser { toks, at: 0, depth: 0, chain: 0 }; + let body = p.block(&[])?; + match p.peek() { + Tok::Eof => Ok(body), + t => Err(format!("line {}: unexpected {}", p.line(), describe(t))), + } +} diff --git a/crates/js/src/formcalc/value.rs b/crates/js/src/formcalc/value.rs new file mode 100644 index 0000000000..16705e67ec --- /dev/null +++ b/crates/js/src/formcalc/value.rs @@ -0,0 +1,92 @@ +// ── values ────────────────────────────────────────────────────────────────────────────────── + +#[derive(Clone, Debug, PartialEq)] +pub(super) enum Val { + Null, + Num(f64), + Str(String), + /// A form object, by index in the host. + Node(usize), + /// Several objects or values (`Row[*]`, `Row[*].Amount`). + List(Vec), + Host, + Layout, + Event, + /// `xfa` itself (`xfa.form`, `xfa.host`, …). + Xfa, + /// The instance manager of `name` under `parent`. + Im(usize, String), + /// A property object whose contents are not modelled (`$.border.fill…`): reads give + /// another sink, writes change nothing. + Sink, +} + +/// A number as FormCalc prints it: integers without a fraction, otherwise up to 15 digits. +pub(super) fn num_text(n: f64) -> String { + if !n.is_finite() { + return if n.is_nan() { + "NaN".into() + } else if n > 0.0 { + "Infinity".into() + } else { + "-Infinity".into() + }; + } + if n.fract() == 0.0 && n.abs() < 1e15 { + return format!("{}", n as i64); + } + let s = format!("{n:.12}"); + let s = s.trim_end_matches('0').trim_end_matches('.'); + if s.is_empty() || s == "-0" { "0".into() } else { s.to_string() } +} + +/// `v` with `decimals` places, rounding exact halves away from zero (Rust's formatter rounds them to even). +pub(super) fn fixed(v: f64, decimals: usize) -> String { + let k = 10f64.powi(decimals.min(20) as i32); + let scaled = (v * k).round() / k; + // A small negative value rounds to -0.0, which would print as "-0". + let scaled = if scaled == 0.0 { 0.0 } else { scaled }; + format!("{:.decimals$}", if scaled.is_finite() { scaled } else { v }) +} + +/// The number a string holds: its leading numeric part (`12abc` → 12), `None` for none. +pub(super) fn parse_num(s: &str) -> Option { + let t = s.trim(); + if t.is_empty() { + return None; + } + let mut end = 0; + let b = t.as_bytes(); + let mut seen_digit = false; + let mut seen_dot = false; + let mut seen_e = false; + let mut i = 0; + if i < b.len() && (b[i] == b'-' || b[i] == b'+') { + i += 1; + } + while i < b.len() { + let c = b[i]; + if c.is_ascii_digit() { + seen_digit = true; + end = i + 1; + } else if c == b'.' && !seen_dot && !seen_e { + seen_dot = true; + } else if (c == b'e' || c == b'E') && seen_digit && !seen_e { + seen_e = true; + if i + 1 < b.len() && (b[i + 1] == b'-' || b[i + 1] == b'+') { + i += 1; + } + } else { + break; + } + i += 1; + } + if !seen_digit { + return None; + } + t.get(..end).and_then(|p| p.parse::().ok()).filter(|v| v.is_finite()) +} + +pub(super) fn is_numeric_text(s: &str) -> bool { + s.trim().parse::().is_ok_and(f64::is_finite) +} diff --git a/crates/js/src/formcalc/words.rs b/crates/js/src/formcalc/words.rs new file mode 100644 index 0000000000..d0d119abb5 --- /dev/null +++ b/crates/js/src/formcalc/words.rs @@ -0,0 +1,75 @@ +const ONES: [&str; 20] = [ + "Zero", + "One", + "Two", + "Three", + "Four", + "Five", + "Six", + "Seven", + "Eight", + "Nine", + "Ten", + "Eleven", + "Twelve", + "Thirteen", + "Fourteen", + "Fifteen", + "Sixteen", + "Seventeen", + "Eighteen", + "Nineteen", +]; + +const TENS: [&str; 10] = ["", "", "Twenty", "Thirty", "Forty", "Fifty", "Sixty", "Seventy", "Eighty", "Ninety"]; + +fn words_below_1000(n: u64) -> String { + let mut parts = Vec::new(); + if n >= 100 { + parts.push(format!("{} Hundred", ONES[(n / 100) as usize])); + } + let r = n % 100; + if r >= 20 { + parts.push(if r.is_multiple_of(10) { + TENS[(r / 10) as usize].to_string() + } else { + format!("{}-{}", TENS[(r / 10) as usize], ONES[(r % 10) as usize]) + }); + } else if r > 0 || n == 0 { + parts.push(ONES[r as usize].to_string()); + } + parts.join(" ") +} + +/// `WordNum`: an integer in English words (up to the billions). +pub(super) fn word_num(n: f64, cents: bool) -> String { + if !n.is_finite() || !(0.0..1e12).contains(&n) { + return String::new(); + } + let cents_total = (n * 100.0).round() as u64; + let whole = if cents { cents_total / 100 } else { n.trunc() as u64 }; + if whole >= 1_000_000_000_000 { + // Rounding to cents carried past the supported range. + return String::new(); + } + let mut parts = Vec::new(); + let groups = [(1_000_000_000u64, "Billion"), (1_000_000, "Million"), (1_000, "Thousand")]; + let mut rest = whole; + for (unit, name) in groups { + if rest >= unit { + parts.push(format!("{} {name}", words_below_1000(rest / unit))); + rest %= unit; + } + } + if rest > 0 || parts.is_empty() { + parts.push(words_below_1000(rest)); + } + let mut out = parts.join(" "); + if cents { + let c = cents_total % 100; + let dollars = if whole == 1 { "Dollar" } else { "Dollars" }; + let cent = if c == 1 { "Cent" } else { "Cents" }; + out.push_str(&format!(" {dollars} And {c:02} {cent}")); + } + out +} diff --git a/crates/js/src/globals.rs b/crates/js/src/globals.rs new file mode 100644 index 0000000000..a9ad4d2b8f --- /dev/null +++ b/crates/js/src/globals.rs @@ -0,0 +1,239 @@ +use super::document::{field_object, get_field, get_nth_field_name, noop, num_fields, page_num_get, page_num_set, print, reset_form, submit_form}; +use super::format::{printd, printf, printx, scand}; +use super::host::{arg, function, host, s, text, text_line}; +use super::model::{DISPLAY_HIDDEN, DISPLAY_NO_PRINT, DISPLAY_NO_VIEW, DISPLAY_VISIBLE, Event, Panel, Request}; +use super::{Attribute, Context, JsArray, JsObject, JsResult, JsString, JsValue, NativeFunction, ObjectInitializer, js_string}; + +// ── app, util, console ────────────────────────────────────────────────────────────────────── + +/// A message argument: a string, or an object with `cMsg`, cut to a message's length. +fn message(v: &JsValue, key: &str, ctx: &mut Context) -> JsResult { + match v.as_object() { + Some(o) if !o.is_array() && !o.is_callable() => { + let m = o.get(JsString::from(key), ctx)?; + text_line(&m, ctx) + } + _ => text_line(v, ctx), + } +} + +/// `app.alert(cMsg)`: records the message, given as a string or as an object with `cMsg`, and +/// returns 1 (the OK button). +fn alert(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let m = message(&arg(args, 0), "cMsg", ctx)?; + host(ctx)?.borrow_mut().alert(m); + // The OK button. + Ok(JsValue::from(1)) +} + +/// `app.beep()`: queues a request to beep. +fn beep(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { + host(ctx)?.borrow_mut().request(Request::Beep); + Ok(JsValue::undefined()) +} + +/// `app.launchURL(cURL)`: queues a request to open the URL, given as a string or as an object +/// with `cURL`. +fn launch_url(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let u = message(&arg(args, 0), "cURL", ctx)?; + host(ctx)?.borrow_mut().request(Request::LaunchUrl(u)); + Ok(JsValue::undefined()) +} + +/// What `app.execMenuItem(name)` asks for, for the menu items forms use: the side panels, Print +/// and Save As. Other items do nothing. +pub(crate) fn menu_item(name: &str) -> Option { + Some(match name { + "ShowHideFileAttachment" => Request::ShowPanel(Panel::Attachments), + "ShowHideBookmarks" => Request::ShowPanel(Panel::Bookmarks), + "ShowHideThumbnails" => Request::ShowPanel(Panel::Pages), + "ShowHideSignatures" => Request::ShowPanel(Panel::Signatures), + "ShowHideOptCont" => Request::ShowPanel(Panel::Layers), + "Print" => Request::Print, + // Save opens Save As too, as it always has for XFA forms' buttons: a click on a form + // never overwrites the file unasked. + "SaveAs" | "Save" => Request::SaveAs, + _ => return None, + }) +} + +/// `app.execMenuItem(name)`: queues the request [`menu_item`] gives for the name (Save opens +/// Save As too); an unknown name does nothing. +fn exec_menu_item(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let item = text(&arg(args, 0), ctx)?; + if let Some(r) = menu_item(&item) { + host(ctx)?.borrow_mut().request(r); + } + Ok(JsValue::undefined()) +} + +/// `app.response(…)`: always `null`, the answer for a canceled dialog, since no one is there to +/// type one. +fn response(_: &JsValue, _: &[JsValue], _: &mut Context) -> JsResult { + // No one to answer: as if the dialog was cancelled. + Ok(JsValue::null()) +} + +fn println(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let m = text_line(&arg(args, 0), ctx)?; + host(ctx)?.borrow_mut().print(m); + Ok(JsValue::undefined()) +} + +fn console_clear(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { + host(ctx)?.borrow_mut().console.clear(); + Ok(JsValue::undefined()) +} + +fn timer(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { + // Timers never fire in the sandbox; scripts get an object they can pass to clear*. + Ok(ObjectInitializer::new(ctx).build().into()) +} + +/// The `color` object: each named color (`transparent`, `black`, `red`, `cyan`, `gray`, …) as +/// an array of its color space (`T`, `G`, `RGB` or `CMYK`) followed by its components. +fn color_object(ctx: &mut Context) -> JsObject { + let mut entries: Vec<(&str, Vec)> = vec![ + ("transparent", vec![s("T")]), + ("black", vec![s("G"), 0.into()]), + ("white", vec![s("G"), 1.into()]), + ("red", vec![s("RGB"), 1.into(), 0.into(), 0.into()]), + ("green", vec![s("RGB"), 0.into(), 1.into(), 0.into()]), + ("blue", vec![s("RGB"), 0.into(), 0.into(), 1.into()]), + ("cyan", vec![s("CMYK"), 1.into(), 0.into(), 0.into(), 0.into()]), + ("magenta", vec![s("CMYK"), 0.into(), 1.into(), 0.into(), 0.into()]), + ("yellow", vec![s("CMYK"), 0.into(), 0.into(), 1.into(), 0.into()]), + ("dkGray", vec![s("G"), JsValue::from(0.25)]), + ("gray", vec![s("G"), JsValue::from(0.5)]), + ("ltGray", vec![s("G"), JsValue::from(0.75)]), + ]; + let arrays: Vec<(&str, JsValue)> = entries.drain(..).map(|(k, v)| (k, JsArray::from_iter(v, ctx).into())).collect(); + let mut o = ObjectInitializer::new(ctx); + for (k, v) in arrays { + o.property(JsString::from(k), v, Attribute::all()); + } + o.build() +} + +/// The XFA version scripts see as `xfa_version`. +const XFA_VERSION: f64 = 3.5; + +/// Defines the form object model's globals: the document's functions (`getField`, `resetForm`, +/// `print`, `submitForm` and others) and properties (`numFields`, `pageNum`, `numPages`, +/// `documentFileName`, `info`, the XFA version), and the `app`, `util`, `console`, `display` and +/// `color` objects. +pub(crate) fn install(ctx: &mut Context) -> JsResult<()> { + let globals: [(&str, usize, NativeFunction); 9] = [ + ("getField", 1, NativeFunction::from_fn_ptr(get_field)), + ("getNthFieldName", 1, NativeFunction::from_fn_ptr(get_nth_field_name)), + ("resetForm", 1, NativeFunction::from_fn_ptr(reset_form)), + ("print", 1, NativeFunction::from_fn_ptr(print)), + ("submitForm", 1, NativeFunction::from_fn_ptr(submit_form)), + ("mailDoc", 1, NativeFunction::from_fn_ptr(noop)), + ("calculateNow", 0, NativeFunction::from_fn_ptr(noop)), + ("syncAnnotScan", 0, NativeFunction::from_fn_ptr(noop)), + ("dirty", 0, NativeFunction::from_fn_ptr(noop)), + ]; + for (name, len, f) in globals { + ctx.register_global_callable(JsString::from(name), len, f)?; + } + let global = ctx.global_object(); + let rw = Attribute::CONFIGURABLE; + let nf = function(ctx, NativeFunction::from_fn_ptr(num_fields)); + let pg = function(ctx, NativeFunction::from_fn_ptr(page_num_get)); + let ps = function(ctx, NativeFunction::from_fn_ptr(page_num_set)); + let (file, pages, info, xfa) = { + let h = host(ctx)?.borrow(); + (h.doc.file_name.clone(), h.doc.num_pages, h.doc.info.clone(), h.doc.xfa) + }; + let mut io = ObjectInitializer::new(ctx); + for (k, v) in &info { + io.property(JsString::from(k.as_str()), s(v), Attribute::all()); + } + let info = io.build(); + use boa_engine::property::PropertyDescriptor; + global.define_property_or_throw(js_string!("numFields"), PropertyDescriptor::builder().get(nf).configurable(true), ctx)?; + global.define_property_or_throw(js_string!("pageNum"), PropertyDescriptor::builder().get(pg).set(ps).configurable(true), ctx)?; + ctx.register_global_property(js_string!("numPages"), JsValue::from(pages as f64), rw)?; + ctx.register_global_property(js_string!("documentFileName"), s(&file), rw)?; + ctx.register_global_property(js_string!("info"), info, rw)?; + // XFA forms check both before they run: every document sees the XFA version, and only an + // XFA form sees `xfa_installed`. + ctx.register_global_property(js_string!("xfa_version"), JsValue::from(XFA_VERSION), rw)?; + if xfa { + ctx.register_global_property(js_string!("xfa_installed"), JsValue::from(true), rw)?; + } + + let app = ObjectInitializer::new(ctx) + .function(NativeFunction::from_fn_ptr(alert), js_string!("alert"), 1) + .function(NativeFunction::from_fn_ptr(beep), js_string!("beep"), 0) + .function(NativeFunction::from_fn_ptr(response), js_string!("response"), 1) + .function(NativeFunction::from_fn_ptr(launch_url), js_string!("launchURL"), 2) + .function(NativeFunction::from_fn_ptr(exec_menu_item), js_string!("execMenuItem"), 1) + .function(NativeFunction::from_fn_ptr(timer), js_string!("setTimeOut"), 2) + .function(NativeFunction::from_fn_ptr(timer), js_string!("setInterval"), 2) + .function(NativeFunction::from_fn_ptr(noop), js_string!("clearTimeOut"), 1) + .function(NativeFunction::from_fn_ptr(noop), js_string!("clearInterval"), 1) + .property(js_string!("viewerType"), s("Exchange-Pro"), Attribute::READONLY) + .property(js_string!("viewerVariation"), s("Full"), Attribute::READONLY) + .property(js_string!("viewerVersion"), JsValue::from(24.0), Attribute::READONLY) + .property(js_string!("formsVersion"), JsValue::from(24.0), Attribute::READONLY) + .property(js_string!("platform"), s(platform()), Attribute::READONLY) + .property(js_string!("language"), s("ENU"), Attribute::READONLY) + .build(); + ctx.register_global_property(js_string!("app"), app, rw)?; + let util = ObjectInitializer::new(ctx) + .function(NativeFunction::from_fn_ptr(printf), js_string!("printf"), 1) + .function(NativeFunction::from_fn_ptr(printd), js_string!("printd"), 2) + .function(NativeFunction::from_fn_ptr(printx), js_string!("printx"), 2) + .function(NativeFunction::from_fn_ptr(scand), js_string!("scand"), 2) + .build(); + ctx.register_global_property(js_string!("util"), util, rw)?; + let console = ObjectInitializer::new(ctx) + .function(NativeFunction::from_fn_ptr(println), js_string!("println"), 1) + .function(NativeFunction::from_fn_ptr(noop), js_string!("show"), 0) + .function(NativeFunction::from_fn_ptr(noop), js_string!("hide"), 0) + .function(NativeFunction::from_fn_ptr(console_clear), js_string!("clear"), 0) + .build(); + ctx.register_global_property(js_string!("console"), console, rw)?; + let display = ObjectInitializer::new(ctx) + .property(js_string!("visible"), DISPLAY_VISIBLE, Attribute::READONLY) + .property(js_string!("hidden"), DISPLAY_HIDDEN, Attribute::READONLY) + .property(js_string!("noPrint"), DISPLAY_NO_PRINT, Attribute::READONLY) + .property(js_string!("noView"), DISPLAY_NO_VIEW, Attribute::READONLY) + .build(); + ctx.register_global_property(js_string!("display"), display, rw)?; + let color = color_object(ctx); + ctx.register_global_property(js_string!("color"), color, rw)?; + Ok(()) +} + +pub(crate) fn platform() -> &'static str { + if cfg!(target_os = "macos") { + "MAC" + } else if cfg!(target_os = "windows") { + "WIN" + } else { + "UNIX" + } +} + +pub(crate) fn event_object(ctx: &mut Context, e: &Event) -> JsObject { + let target = e.target.as_ref().map_or(JsValue::null(), |t| field_object(ctx, t).into()); + ObjectInitializer::new(ctx) + .property(js_string!("name"), s(&e.name), Attribute::all()) + .property(js_string!("type"), s(&e.kind), Attribute::all()) + .property(js_string!("value"), s(&e.value), Attribute::all()) + .property(js_string!("change"), s(&e.change), Attribute::all()) + .property(js_string!("changeEx"), s(&e.change), Attribute::all()) + .property(js_string!("willCommit"), e.will_commit, Attribute::all()) + .property(js_string!("rc"), true, Attribute::all()) + .property(js_string!("target"), target, Attribute::all()) + .property(js_string!("targetName"), s(e.target.as_deref().unwrap_or("")), Attribute::all()) + .property(js_string!("selStart"), 0, Attribute::all()) + .property(js_string!("selEnd"), 0, Attribute::all()) + .property(js_string!("commitKey"), 0, Attribute::all()) + .property(js_string!("modifier"), false, Attribute::all()) + .property(js_string!("shift"), false, Attribute::all()) + .build() +} diff --git a/crates/js/src/host.rs b/crates/js/src/host.rs new file mode 100644 index 0000000000..6e442d2a2b --- /dev/null +++ b/crates/js/src/host.rs @@ -0,0 +1,200 @@ +use super::model::{DocInfo, FieldState, FieldType, Request}; +use super::run::MAX_ARRAY_LENGTH; +use super::{ + Arc, BTreeSet, Context, FunctionObjectBuilder, JsArray, JsError, JsFunction, JsNativeError, JsResult, JsString, JsValue, NativeFunction, RefCell, + xfa, +}; + +pub(crate) struct Host { + pub(crate) fields: Arc>, + pub(crate) changed: BTreeSet, + pub(crate) alerts: Vec, + pub(crate) console: Vec, + pub(crate) requests: Vec, + pub(crate) doc: DocInfo, + /// Bytes of text kept in alerts, console lines and requests, against [`xfa::MAX_PAYLOAD`]. + pub(crate) kept: usize, + /// Alerts, console lines and requests the limits left out. + pub(crate) dropped: usize, +} + +impl Host { + /// Room for `bytes` more bytes of kept text: charged when there is, a drop counted when not. + fn room(&mut self, bytes: usize) -> bool { + match self.kept.checked_add(bytes).filter(|k| *k <= xfa::MAX_PAYLOAD) { + Some(k) => { + self.kept = k; + true + } + None => { + self.dropped = self.dropped.saturating_add(1); + false + } + } + } + + pub(crate) fn alert(&mut self, m: String) { + if self.alerts.len() >= xfa::MAX_ALERTS { + self.dropped = self.dropped.saturating_add(1); + } else if self.room(m.len()) { + self.alerts.push(m); + } + } + + pub(crate) fn print(&mut self, m: String) { + if self.console.len() >= xfa::MAX_CONSOLE { + self.dropped = self.dropped.saturating_add(1); + } else if self.room(m.len()) { + self.console.push(m); + } + } + + /// Record `r`, its names and URLs cut to a message's length, up to [`xfa::MAX_EFFECTS`] + /// requests and the run's byte budget. + pub(crate) fn request(&mut self, r: Request) { + if self.requests.len() >= xfa::MAX_EFFECTS { + self.dropped = self.dropped.saturating_add(1); + return; + } + let r = match r { + Request::Reset(names) => Request::Reset(names.into_iter().map(xfa::clip).collect()), + Request::LaunchUrl(u) => Request::LaunchUrl(xfa::clip(u)), + Request::Submit(u) => Request::Submit(xfa::clip(u)), + Request::Focus(n) => Request::Focus(xfa::clip(n)), + other => other, + }; + let bytes = match &r { + Request::Reset(names) => names.iter().fold(0usize, |n, s| n.saturating_add(s.len()).saturating_add(std::mem::size_of::())), + Request::LaunchUrl(u) | Request::Submit(u) | Request::Focus(u) => u.len(), + _ => 0, + }; + if self.room(bytes) { + self.requests.push(r); + } + } +} + +pub(crate) type Shared = RefCell; + +/// The host state, installed before scripts run. +pub(crate) fn host(ctx: &Context) -> JsResult<&Shared> { + ctx.get_data::().ok_or_else(|| error("the script host is not set up")) +} + +pub(crate) fn s(v: &str) -> JsValue { + JsValue::from(JsString::from(v)) +} + +pub(crate) fn text(v: &JsValue, ctx: &mut Context) -> JsResult { + if v.is_undefined() || v.is_null() { + return Ok(String::new()); + } + Ok(v.to_string(ctx)?.to_std_string_escaped()) +} + +/// [`text`] cut to a message's length ([`xfa::clip`]), without first copying all of a longer +/// string. +pub(crate) fn text_line(v: &JsValue, ctx: &mut Context) -> JsResult { + if v.is_undefined() || v.is_null() { + return Ok(String::new()); + } + let js = v.to_string(ctx)?; + // Two UTF-16 units per character at most: the clip still sees more than a line's worth. + let keep = xfa::MAX_LINE.saturating_add(1).saturating_mul(2); + let js = if js.len() > keep { js.get(..keep).unwrap_or_default() } else { js }; + Ok(xfa::clip(js.to_std_string_escaped())) +} + +pub(crate) fn arg(args: &[JsValue], i: usize) -> JsValue { + args.get(i).cloned().unwrap_or_default() +} + +pub(crate) fn error(msg: impl Into) -> JsError { + JsNativeError::typ().with_message(msg.into()).into() +} + +/// A JavaScript number for a text value that reads as one, as Acrobat's `field.value` gives. +pub(crate) fn as_number(t: &str) -> Option { + let t = t.trim(); + if t.is_empty() || t.starts_with('+') || t.len() > 1 && t.starts_with('0') && !t.starts_with("0.") { + return None; + } + t.parse::().ok().filter(|v| v.is_finite()) +} + +pub(crate) fn function(ctx: &mut Context, f: NativeFunction) -> JsFunction { + FunctionObjectBuilder::new(ctx.realm(), f).build() +} + +pub(crate) fn strings(v: &JsValue, ctx: &mut Context) -> JsResult> { + if let Some(o) = v.as_object() + && o.is_array() + { + let a = JsArray::from_object(o.clone())?; + let n = a.length(ctx)?; + if n > MAX_ARRAY_LENGTH { + return Err(error(format!("array length exceeds {MAX_ARRAY_LENGTH}"))); + } + let mut out = Vec::new(); + for i in 0..n { + let x = a.get(i, ctx)?; + out.push(text(&x, ctx)?); + } + return Ok(out); + } + Ok(if v.is_undefined() || v.is_null() { Vec::new() } else { vec![text(v, ctx)?] }) +} + +pub(crate) fn colour(c: &Option>, ctx: &mut Context) -> JsValue { + match c { + None => JsArray::from_iter([s("T")], ctx).into(), + Some(parts) => { + let vals: Vec = + parts.iter().enumerate().map(|(i, p)| if i == 0 { s(p) } else { JsValue::from(p.parse::().unwrap_or(0.0)) }).collect(); + JsArray::from_iter(vals, ctx).into() + } + } +} + +// ── field objects ─────────────────────────────────────────────────────────────────────────── + +fn field_index(ctx: &Context, name: &JsString) -> JsResult { + let n = name.to_std_string_escaped(); + host(ctx)?.borrow().fields.iter().position(|f| f.name == n).ok_or_else(|| error(format!("no field named {n}"))) +} + +pub(crate) fn read(ctx: &Context, name: &JsString) -> JsResult { + let i = field_index(ctx, name)?; + Ok(host(ctx)?.borrow().fields[i].clone()) +} + +pub(crate) fn write(ctx: &Context, name: &JsString, change: impl FnOnce(&mut FieldState)) -> JsResult<()> { + let i = field_index(ctx, name)?; + let mut h = host(ctx)?.borrow_mut(); + let field = Arc::make_mut(&mut h.fields).get_mut(i).ok_or_else(|| error("the field is no longer available"))?; + change(field); + h.changed.insert(i); + Ok(()) +} + +pub(crate) fn value_of(f: &FieldState, ctx: &mut Context) -> JsValue { + match f.kind { + FieldType::CheckBox | FieldType::RadioButton => s(f.value.first().map_or("Off", String::as_str)), + FieldType::ListBox if f.value.len() > 1 => { + let vals: Vec = f.value.iter().map(|v| s(v)).collect(); + JsArray::from_iter(vals, ctx).into() + } + _ => { + let v = f.single(); + as_number(&v).map_or_else(|| s(&v), JsValue::from) + } + } +} + +pub(crate) fn set_value(f: &mut FieldState, vals: Vec) { + f.value = match f.kind { + FieldType::CheckBox | FieldType::RadioButton => vals.into_iter().filter(|v| v != "Off" && !v.is_empty()).take(1).collect(), + FieldType::ListBox => vals, + _ => vals.into_iter().take(1).collect(), + }; +} diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs index 4db899c1c1..896ad3466d 100644 --- a/crates/js/src/lib.rs +++ b/crates/js/src/lib.rs @@ -34,1712 +34,27 @@ use boa_engine::property::Attribute; use boa_engine::realm::Realm; use boa_engine::{Context, JsError, JsNativeError, JsObject, JsResult, JsString, JsValue, NativeFunction, Source, js_string}; -/// The kinds of field, as `field.type` names them. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum FieldType { - Text, - CheckBox, - RadioButton, - ComboBox, - ListBox, - Button, - Signature, -} +mod document; +mod format; +mod globals; +mod host; +mod model; +mod run; -impl FieldType { - pub fn name(self) -> &'static str { - match self { - FieldType::Text => "text", - FieldType::CheckBox => "checkbox", - FieldType::RadioButton => "radiobutton", - FieldType::ComboBox => "combobox", - FieldType::ListBox => "listbox", - FieldType::Button => "button", - FieldType::Signature => "signature", - } - } -} - -/// `display.*`: visible, hidden, noPrint, noView. -pub const DISPLAY_VISIBLE: i32 = 0; -pub const DISPLAY_HIDDEN: i32 = 1; -pub const DISPLAY_NO_PRINT: i32 = 2; -pub const DISPLAY_NO_VIEW: i32 = 3; - -/// A field as scripts see it. -#[derive(Clone, Debug, PartialEq)] -pub struct FieldState { - pub name: String, - pub kind: FieldType, - /// Text: the text. Check box / radio: the selected export value (empty when off). Choice: - /// the selected export values. - pub value: Vec, - pub default: Vec, - pub readonly: bool, - pub required: bool, - pub display: i32, - /// Choice options (export value, display text); check boxes and radios: their export - /// values, one per widget. - pub options: Vec<(String, String)>, - pub char_limit: Option, - /// `textColor` / `fillColor` as Acrobat colour arrays (`["RGB", r, g, b]` etc.), when set. - pub text_color: Option>, - pub fill_color: Option>, -} - -impl FieldState { - pub fn new(name: impl Into, kind: FieldType, value: Vec) -> FieldState { - FieldState { - name: name.into(), - kind, - value, - default: Vec::new(), - readonly: false, - required: false, - display: DISPLAY_VISIBLE, - options: Vec::new(), - char_limit: None, - text_color: None, - fill_color: None, - } - } - - fn single(&self) -> String { - self.value.first().cloned().unwrap_or_default() - } -} - -/// The event a script runs for. -#[derive(Clone, Debug, PartialEq)] -pub struct Event { - /// `Keystroke`, `Validate`, `Calculate`, `Format`, `Mouse Up`, `Open`, … - pub name: String, - /// `Field`, `Doc`, `Page`, … - pub kind: String, - /// The field the event is for. - pub target: Option, - pub value: String, - pub change: String, - pub will_commit: bool, -} - -impl Event { - /// A field event (`event.type` = "Field"). - pub fn field(name: &str, target: &str, value: &str) -> Event { - Event { name: name.into(), kind: "Field".into(), target: Some(target.into()), value: value.into(), change: String::new(), will_commit: true } - } - - /// A document event (`event.type` = "Doc"), e.g. Open or WillSave. - pub fn doc(name: &str) -> Event { - Event { name: name.into(), kind: "Doc".into(), target: None, value: String::new(), change: String::new(), will_commit: false } - } -} - -/// About the document, for `this.numPages`, `this.info` and the like. -#[derive(Clone, Debug, Default, PartialEq)] -pub struct DocInfo { - pub file_name: String, - pub num_pages: usize, - /// The current page, 0-based. - pub page: usize, - /// Document information (`this.info.title` etc.): lower-case key, value. - pub info: Vec<(String, String)>, - /// The document has an XFA form, so scripts see `xfa_installed`. - pub xfa: bool, -} - -/// Something a script asked the viewer to do. -#[derive(Clone, Debug, PartialEq)] -pub enum Request { - /// `this.resetForm([names])` (empty: every field). - Reset(Vec), - Print, - /// `this.pageNum = n` (0-based). - GoToPage(usize), - LaunchUrl(String), - /// `this.submitForm(url)`: never sent on PdfCraft's own. - Submit(String), - /// `field.setFocus()`. - Focus(String), - Beep, - /// `app.execMenuItem("SaveAs")` (XFA forms' Save buttons). - SaveAs, - /// `app.execMenuItem("ShowHide…")`: show the side panel, or hide it when it is showing. - ShowPanel(Panel), -} - -/// A side panel a script can show or hide (`app.execMenuItem("ShowHideFileAttachment")`, …). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum Panel { - /// The document's bookmarks (outline). - Bookmarks, - /// Page thumbnails. - Pages, - /// The files attached to the document. - Attachments, - /// The document's signatures and their validity. - Signatures, - /// The document's layers (optional content). - Layers, -} - -impl Panel { - /// The panel's name in automation output, fixed whatever the variant is called. - pub fn id(self) -> &'static str { - match self { - Panel::Bookmarks => "bookmarks", - Panel::Pages => "pages", - Panel::Attachments => "attachments", - Panel::Signatures => "signatures", - Panel::Layers => "layers", - } - } -} - -/// What running a script did. -#[derive(Clone, Debug, Default, PartialEq)] -pub struct Outcome { - /// `event.rc`: `false` rejects the keystroke or value. - pub rc: bool, - /// `event.value` after the script. - pub value: String, - pub change: String, - /// Fields whose value or properties the script changed, as they are now. - pub changed: Vec, - pub alerts: Vec, - pub console: Vec, - pub requests: Vec, - /// The script's error (syntax error, exception, or a limit reached), if it failed. - pub error: Option, - /// Errors in the document-level scripts that ran first. Each is a script of its own: one - /// that throws stops where it failed, and the others and the script itself still run (#3940). - pub doc_errors: Vec, - /// The script's completion value (what the console prints), unless undefined. - pub result: Option, -} - -/// Bounds that keep a script from hanging the viewer. -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct Limits { - pub loop_iterations: u64, - pub recursion: usize, -} - -impl Default for Limits { - fn default() -> Self { - Limits { loop_iterations: 1_000_000, recursion: 256 } - } -} - -struct Host { - fields: Arc>, - changed: BTreeSet, - alerts: Vec, - console: Vec, - requests: Vec, - doc: DocInfo, - /// Bytes of text kept in alerts, console lines and requests, against [`xfa::MAX_PAYLOAD`]. - kept: usize, - /// Alerts, console lines and requests the limits left out. - dropped: usize, -} - -impl Host { - /// Room for `bytes` more bytes of kept text: charged when there is, a drop counted when not. - fn room(&mut self, bytes: usize) -> bool { - match self.kept.checked_add(bytes).filter(|k| *k <= xfa::MAX_PAYLOAD) { - Some(k) => { - self.kept = k; - true - } - None => { - self.dropped = self.dropped.saturating_add(1); - false - } - } - } - - fn alert(&mut self, m: String) { - if self.alerts.len() >= xfa::MAX_ALERTS { - self.dropped = self.dropped.saturating_add(1); - } else if self.room(m.len()) { - self.alerts.push(m); - } - } - - fn print(&mut self, m: String) { - if self.console.len() >= xfa::MAX_CONSOLE { - self.dropped = self.dropped.saturating_add(1); - } else if self.room(m.len()) { - self.console.push(m); - } - } - - /// Record `r`, its names and URLs cut to a message's length, up to [`xfa::MAX_EFFECTS`] - /// requests and the run's byte budget. - fn request(&mut self, r: Request) { - if self.requests.len() >= xfa::MAX_EFFECTS { - self.dropped = self.dropped.saturating_add(1); - return; - } - let r = match r { - Request::Reset(names) => Request::Reset(names.into_iter().map(xfa::clip).collect()), - Request::LaunchUrl(u) => Request::LaunchUrl(xfa::clip(u)), - Request::Submit(u) => Request::Submit(xfa::clip(u)), - Request::Focus(n) => Request::Focus(xfa::clip(n)), - other => other, - }; - let bytes = match &r { - Request::Reset(names) => names.iter().fold(0usize, |n, s| n.saturating_add(s.len()).saturating_add(std::mem::size_of::())), - Request::LaunchUrl(u) | Request::Submit(u) | Request::Focus(u) => u.len(), - _ => 0, - }; - if self.room(bytes) { - self.requests.push(r); - } - } -} - -type Shared = RefCell; - -/// The host state, installed before scripts run. -fn host(ctx: &Context) -> JsResult<&Shared> { - ctx.get_data::().ok_or_else(|| error("the script host is not set up")) -} - -fn s(v: &str) -> JsValue { - JsValue::from(JsString::from(v)) -} - -fn text(v: &JsValue, ctx: &mut Context) -> JsResult { - if v.is_undefined() || v.is_null() { - return Ok(String::new()); - } - Ok(v.to_string(ctx)?.to_std_string_escaped()) -} - -/// [`text`] cut to a message's length ([`xfa::clip`]), without first copying all of a longer -/// string. -fn text_line(v: &JsValue, ctx: &mut Context) -> JsResult { - if v.is_undefined() || v.is_null() { - return Ok(String::new()); - } - let js = v.to_string(ctx)?; - // Two UTF-16 units per character at most: the clip still sees more than a line's worth. - let keep = xfa::MAX_LINE.saturating_add(1).saturating_mul(2); - let js = if js.len() > keep { js.get(..keep).unwrap_or_default() } else { js }; - Ok(xfa::clip(js.to_std_string_escaped())) -} - -fn arg(args: &[JsValue], i: usize) -> JsValue { - args.get(i).cloned().unwrap_or_default() -} - -fn error(msg: impl Into) -> JsError { - JsNativeError::typ().with_message(msg.into()).into() -} - -/// A JavaScript number for a text value that reads as one, as Acrobat's `field.value` gives. -fn as_number(t: &str) -> Option { - let t = t.trim(); - if t.is_empty() || t.starts_with('+') || t.len() > 1 && t.starts_with('0') && !t.starts_with("0.") { - return None; - } - t.parse::().ok().filter(|v| v.is_finite()) -} - -fn function(ctx: &mut Context, f: NativeFunction) -> JsFunction { - FunctionObjectBuilder::new(ctx.realm(), f).build() -} - -fn strings(v: &JsValue, ctx: &mut Context) -> JsResult> { - if let Some(o) = v.as_object() - && o.is_array() - { - let a = JsArray::from_object(o.clone())?; - let n = a.length(ctx)?; - if n > MAX_ARRAY_LENGTH { - return Err(error(format!("array length exceeds {MAX_ARRAY_LENGTH}"))); - } - let mut out = Vec::new(); - for i in 0..n { - let x = a.get(i, ctx)?; - out.push(text(&x, ctx)?); - } - return Ok(out); - } - Ok(if v.is_undefined() || v.is_null() { Vec::new() } else { vec![text(v, ctx)?] }) -} - -fn colour(c: &Option>, ctx: &mut Context) -> JsValue { - match c { - None => JsArray::from_iter([s("T")], ctx).into(), - Some(parts) => { - let vals: Vec = - parts.iter().enumerate().map(|(i, p)| if i == 0 { s(p) } else { JsValue::from(p.parse::().unwrap_or(0.0)) }).collect(); - JsArray::from_iter(vals, ctx).into() - } - } -} - -// ── field objects ─────────────────────────────────────────────────────────────────────────── - -fn field_index(ctx: &Context, name: &JsString) -> JsResult { - let n = name.to_std_string_escaped(); - host(ctx)?.borrow().fields.iter().position(|f| f.name == n).ok_or_else(|| error(format!("no field named {n}"))) -} - -fn read(ctx: &Context, name: &JsString) -> JsResult { - let i = field_index(ctx, name)?; - Ok(host(ctx)?.borrow().fields[i].clone()) -} - -fn write(ctx: &Context, name: &JsString, change: impl FnOnce(&mut FieldState)) -> JsResult<()> { - let i = field_index(ctx, name)?; - let mut h = host(ctx)?.borrow_mut(); - let field = Arc::make_mut(&mut h.fields).get_mut(i).ok_or_else(|| error("the field is no longer available"))?; - change(field); - h.changed.insert(i); - Ok(()) -} - -fn value_of(f: &FieldState, ctx: &mut Context) -> JsValue { - match f.kind { - FieldType::CheckBox | FieldType::RadioButton => s(f.value.first().map_or("Off", String::as_str)), - FieldType::ListBox if f.value.len() > 1 => { - let vals: Vec = f.value.iter().map(|v| s(v)).collect(); - JsArray::from_iter(vals, ctx).into() - } - _ => { - let v = f.single(); - as_number(&v).map_or_else(|| s(&v), JsValue::from) - } - } -} - -fn set_value(f: &mut FieldState, vals: Vec) { - f.value = match f.kind { - FieldType::CheckBox | FieldType::RadioButton => vals.into_iter().filter(|v| v != "Off" && !v.is_empty()).take(1).collect(), - FieldType::ListBox => vals, - _ => vals.into_iter().take(1).collect(), - }; -} - -macro_rules! getter { - ($ctx:expr, $name:expr, |$f:ident, $c:ident| $body:expr) => {{ - fn get(_: &JsValue, _: &[JsValue], name: &JsString, $c: &mut Context) -> JsResult { - let $f = read($c, name)?; - Ok($body) - } - function($ctx, NativeFunction::from_copy_closure_with_captures(get, $name.clone())) - }}; -} - -fn field_object(ctx: &mut Context, name: &str) -> JsObject { - let n = JsString::from(name); - let value_get = getter!(ctx, n, |f, c| value_of(&f, c)); - let value_set = { - fn set(_: &JsValue, args: &[JsValue], name: &JsString, c: &mut Context) -> JsResult { - let vals = strings(&arg(args, 0), c)?; - write(c, name, |f| set_value(f, vals))?; - Ok(JsValue::undefined()) - } - function(ctx, NativeFunction::from_copy_closure_with_captures(set, n.clone())) - }; - let as_string = getter!(ctx, n, |f, _c| s(&match f.kind { - FieldType::CheckBox | FieldType::RadioButton => f.value.first().cloned().unwrap_or_else(|| "Off".into()), - _ => f.value.join(","), - })); - let default = getter!(ctx, n, |f, _c| s(&f.default.first().cloned().unwrap_or_default())); - let kind = getter!(ctx, n, |f, _c| s(f.kind.name())); - let fname = getter!(ctx, n, |f, _c| s(&f.name)); - let readonly_get = getter!(ctx, n, |f, _c| JsValue::from(f.readonly)); - let readonly_set = { - fn set(_: &JsValue, args: &[JsValue], name: &JsString, c: &mut Context) -> JsResult { - let on = arg(args, 0).to_boolean(); - write(c, name, |f| f.readonly = on)?; - Ok(JsValue::undefined()) - } - function(ctx, NativeFunction::from_copy_closure_with_captures(set, n.clone())) - }; - let required_get = getter!(ctx, n, |f, _c| JsValue::from(f.required)); - let required_set = { - fn set(_: &JsValue, args: &[JsValue], name: &JsString, c: &mut Context) -> JsResult { - let on = arg(args, 0).to_boolean(); - write(c, name, |f| f.required = on)?; - Ok(JsValue::undefined()) - } - function(ctx, NativeFunction::from_copy_closure_with_captures(set, n.clone())) - }; - let display_get = getter!(ctx, n, |f, _c| JsValue::from(f.display)); - let display_set = { - fn set(_: &JsValue, args: &[JsValue], name: &JsString, c: &mut Context) -> JsResult { - let d = arg(args, 0).to_number(c)? as i32; - write(c, name, |f| f.display = d.clamp(0, 3))?; - Ok(JsValue::undefined()) - } - function(ctx, NativeFunction::from_copy_closure_with_captures(set, n.clone())) - }; - let hidden_get = getter!(ctx, n, |f, _c| JsValue::from(f.display == DISPLAY_HIDDEN)); - let hidden_set = { - fn set(_: &JsValue, args: &[JsValue], name: &JsString, c: &mut Context) -> JsResult { - let on = arg(args, 0).to_boolean(); - write(c, name, |f| f.display = if on { DISPLAY_HIDDEN } else { DISPLAY_VISIBLE })?; - Ok(JsValue::undefined()) - } - function(ctx, NativeFunction::from_copy_closure_with_captures(set, n.clone())) - }; - let num_items = getter!(ctx, n, |f, _c| JsValue::from(f.options.len() as f64)); - let char_limit = getter!(ctx, n, |f, _c| JsValue::from(f.char_limit.unwrap_or(0) as f64)); - let export_values = getter!(ctx, n, |f, c| { - let vals: Vec = f.options.iter().map(|o| s(&o.0)).collect(); - JsArray::from_iter(vals, c).into() - }); - let indices = getter!(ctx, n, |f, c| { - let idx: Vec = f.options.iter().enumerate().filter(|(_, o)| f.value.contains(&o.0)).map(|(i, _)| JsValue::from(i as f64)).collect(); - if idx.len() == 1 { idx[0].clone() } else { JsArray::from_iter(idx, c).into() } - }); - let text_color_get = getter!(ctx, n, |f, c| colour(&f.text_color, c)); - let text_color_set = { - fn set(_: &JsValue, args: &[JsValue], name: &JsString, c: &mut Context) -> JsResult { - let v = strings(&arg(args, 0), c)?; - write(c, name, |f| f.text_color = Some(v))?; - Ok(JsValue::undefined()) - } - function(ctx, NativeFunction::from_copy_closure_with_captures(set, n.clone())) - }; - let fill_color_get = getter!(ctx, n, |f, c| colour(&f.fill_color, c)); - let fill_color_set = { - fn set(_: &JsValue, args: &[JsValue], name: &JsString, c: &mut Context) -> JsResult { - let v = strings(&arg(args, 0), c)?; - write(c, name, |f| f.fill_color = Some(v))?; - Ok(JsValue::undefined()) - } - function(ctx, NativeFunction::from_copy_closure_with_captures(set, n.clone())) - }; - - fn get_item_at(_: &JsValue, args: &[JsValue], name: &JsString, c: &mut Context) -> JsResult { - let f = read(c, name)?; - let i = arg(args, 0).to_number(c)?; - // bExportValue defaults to true. - let export = args.get(1).is_none_or(JsValue::to_boolean); - let i = if i < 0.0 { f.options.len().saturating_sub(1) } else { i as usize }; - let o = f.options.get(i).ok_or_else(|| error(format!("{} has no item {i}", f.name)))?; - Ok(s(if export { &o.0 } else { &o.1 })) - } - fn is_box_checked(_: &JsValue, args: &[JsValue], name: &JsString, c: &mut Context) -> JsResult { - let f = read(c, name)?; - let i = arg(args, 0).to_number(c)? as usize; - let on = f.options.get(i).is_some_and(|o| f.value.first() == Some(&o.0)); - Ok(JsValue::from(on)) - } - fn check_this_box(_: &JsValue, args: &[JsValue], name: &JsString, c: &mut Context) -> JsResult { - let f = read(c, name)?; - let i = arg(args, 0).to_number(c)? as usize; - let on = args.get(1).is_none_or(JsValue::to_boolean); - let export = f.options.get(i).map(|o| o.0.clone()).ok_or_else(|| error(format!("{} has no widget {i}", f.name)))?; - write(c, name, |f| { - if on { - f.value = vec![export]; - } else if f.value.first() == Some(&export) { - f.value.clear(); - } - })?; - Ok(JsValue::undefined()) - } - fn set_focus(_: &JsValue, _: &[JsValue], name: &JsString, c: &mut Context) -> JsResult { - host(c)?.borrow_mut().request(Request::Focus(name.to_std_string_escaped())); - Ok(JsValue::undefined()) - } - - let rw = Attribute::CONFIGURABLE | Attribute::ENUMERABLE; - let get_item_at = NativeFunction::from_copy_closure_with_captures(get_item_at, n.clone()); - let is_box_checked = NativeFunction::from_copy_closure_with_captures(is_box_checked, n.clone()); - let check_this_box = NativeFunction::from_copy_closure_with_captures(check_this_box, n.clone()); - let set_focus = NativeFunction::from_copy_closure_with_captures(set_focus, n.clone()); - ObjectInitializer::new(ctx) - .accessor(js_string!("value"), Some(value_get), Some(value_set), rw) - .accessor(js_string!("valueAsString"), Some(as_string), None, rw) - .accessor(js_string!("defaultValue"), Some(default), None, rw) - .accessor(js_string!("type"), Some(kind), None, rw) - .accessor(js_string!("name"), Some(fname), None, rw) - .accessor(js_string!("readonly"), Some(readonly_get), Some(readonly_set), rw) - .accessor(js_string!("required"), Some(required_get), Some(required_set), rw) - .accessor(js_string!("display"), Some(display_get), Some(display_set), rw) - .accessor(js_string!("hidden"), Some(hidden_get), Some(hidden_set), rw) - .accessor(js_string!("numItems"), Some(num_items), None, rw) - .accessor(js_string!("charLimit"), Some(char_limit), None, rw) - .accessor(js_string!("exportValues"), Some(export_values), None, rw) - .accessor(js_string!("currentValueIndices"), Some(indices), None, rw) - .accessor(js_string!("textColor"), Some(text_color_get), Some(text_color_set), rw) - .accessor(js_string!("fillColor"), Some(fill_color_get), Some(fill_color_set), rw) - .function(get_item_at, js_string!("getItemAt"), 2) - .function(is_box_checked, js_string!("isBoxChecked"), 1) - .function(check_this_box, js_string!("checkThisBox"), 2) - .function(set_focus, js_string!("setFocus"), 0) - .build() -} - -// ── the document (global object) ──────────────────────────────────────────────────────────── - -fn get_field(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { - let name = text(&arg(args, 0), ctx)?; - let exists = host(ctx)?.borrow().fields.iter().any(|f| f.name == name); - if exists { - return Ok(field_object(ctx, &name).into()); - } - // A parent name (e.g. "total" for "total.0"): its first kid stands in, as for groups. - let prefix = format!("{name}."); - let kid = host(ctx)?.borrow().fields.iter().find(|f| f.name.starts_with(&prefix)).map(|f| f.name.clone()); - Ok(kid.map_or(JsValue::null(), |k| field_object(ctx, &k).into())) -} - -fn get_nth_field_name(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { - let i = arg(args, 0).to_number(ctx)?; - let name = host(ctx)?.borrow().fields.get(i.max(0.0) as usize).map(|f| f.name.clone()); - Ok(name.map_or(JsValue::null(), |n| s(&n))) -} - -fn reset_form(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { - let names = strings(&arg(args, 0), ctx)?; - host(ctx)?.borrow_mut().request(Request::Reset(names)); - Ok(JsValue::undefined()) -} - -fn print(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { - host(ctx)?.borrow_mut().request(Request::Print); - Ok(JsValue::undefined()) -} - -fn submit_form(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { - let a = arg(args, 0); - let url = match a.as_object() { - Some(o) if !o.is_array() => { - let u = o.get(js_string!("cURL"), ctx)?; - text(&u, ctx)? - } - _ => text(&a, ctx)?, - }; - host(ctx)?.borrow_mut().request(Request::Submit(url)); - Ok(JsValue::undefined()) -} - -fn noop(_: &JsValue, _: &[JsValue], _: &mut Context) -> JsResult { - Ok(JsValue::undefined()) -} - -fn num_fields(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { - Ok(JsValue::from(host(ctx)?.borrow().fields.len() as f64)) -} - -fn page_num_get(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { - Ok(JsValue::from(host(ctx)?.borrow().doc.page as f64)) -} - -fn page_num_set(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { - let n = arg(args, 0).to_number(ctx)?; - let mut h = host(ctx)?.borrow_mut(); - let last = h.doc.num_pages.saturating_sub(1); - let p = (n.max(0.0) as usize).min(last); - h.doc.page = p; - h.request(Request::GoToPage(p)); - Ok(JsValue::undefined()) -} - -// ── app, util, console ────────────────────────────────────────────────────────────────────── - -/// A message argument: a string, or an object with `cMsg`, cut to a message's length. -fn message(v: &JsValue, key: &str, ctx: &mut Context) -> JsResult { - match v.as_object() { - Some(o) if !o.is_array() && !o.is_callable() => { - let m = o.get(JsString::from(key), ctx)?; - text_line(&m, ctx) - } - _ => text_line(v, ctx), - } -} - -/// `app.alert(cMsg)`: records the message, given as a string or as an object with `cMsg`, and -/// returns 1 (the OK button). -fn alert(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { - let m = message(&arg(args, 0), "cMsg", ctx)?; - host(ctx)?.borrow_mut().alert(m); - // The OK button. - Ok(JsValue::from(1)) -} - -/// `app.beep()`: queues a request to beep. -fn beep(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { - host(ctx)?.borrow_mut().request(Request::Beep); - Ok(JsValue::undefined()) -} - -/// `app.launchURL(cURL)`: queues a request to open the URL, given as a string or as an object -/// with `cURL`. -fn launch_url(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { - let u = message(&arg(args, 0), "cURL", ctx)?; - host(ctx)?.borrow_mut().request(Request::LaunchUrl(u)); - Ok(JsValue::undefined()) -} - -/// What `app.execMenuItem(name)` asks for, for the menu items forms use: the side panels, Print -/// and Save As. Other items do nothing. -pub(crate) fn menu_item(name: &str) -> Option { - Some(match name { - "ShowHideFileAttachment" => Request::ShowPanel(Panel::Attachments), - "ShowHideBookmarks" => Request::ShowPanel(Panel::Bookmarks), - "ShowHideThumbnails" => Request::ShowPanel(Panel::Pages), - "ShowHideSignatures" => Request::ShowPanel(Panel::Signatures), - "ShowHideOptCont" => Request::ShowPanel(Panel::Layers), - "Print" => Request::Print, - // Save opens Save As too, as it always has for XFA forms' buttons: a click on a form - // never overwrites the file unasked. - "SaveAs" | "Save" => Request::SaveAs, - _ => return None, - }) -} - -/// `app.execMenuItem(name)`: queues the request [`menu_item`] gives for the name (Save opens -/// Save As too); an unknown name does nothing. -fn exec_menu_item(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { - let item = text(&arg(args, 0), ctx)?; - if let Some(r) = menu_item(&item) { - host(ctx)?.borrow_mut().request(r); - } - Ok(JsValue::undefined()) -} - -/// `app.response(…)`: always `null`, the answer for a canceled dialog, since no one is there to -/// type one. -fn response(_: &JsValue, _: &[JsValue], _: &mut Context) -> JsResult { - // No one to answer: as if the dialog was cancelled. - Ok(JsValue::null()) -} - -fn println(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { - let m = text_line(&arg(args, 0), ctx)?; - host(ctx)?.borrow_mut().print(m); - Ok(JsValue::undefined()) -} - -fn console_clear(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { - host(ctx)?.borrow_mut().console.clear(); - Ok(JsValue::undefined()) -} - -fn timer(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { - // Timers never fire in the sandbox; scripts get an object they can pass to clear*. - Ok(ObjectInitializer::new(ctx).build().into()) -} - -/// `util.printf(cFormat, …)`: `%[,n][+ 0#][width][.prec](d|f|s|x)`, where `,0` groups -/// thousands with commas (the default), `,1` doesn't, `,2` uses `.` and `,`, `,3` uses `.` -/// without grouping. -fn printf(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { - let fmt = text(&arg(args, 0), ctx)?; - let mut out = String::new(); - let check_output = |len: usize| -> JsResult<()> { - if len > MAX_PRINTF_OUTPUT_BYTES { - return Err(error(format!("util.printf output exceeds {MAX_PRINTF_OUTPUT_BYTES} bytes"))); - } - Ok(()) - }; - let mut next = 1; - let mut chars = fmt.chars().peekable(); - while let Some(c) = chars.next() { - if c != '%' { - check_output(out.len().saturating_add(c.len_utf8()))?; - out.push(c); - continue; - } - if chars.peek() == Some(&'%') { - chars.next(); - check_output(out.len().saturating_add(1))?; - out.push('%'); - continue; - } - let mut sep = 1u8; - if chars.peek() == Some(&',') { - chars.next(); - sep = chars.next().and_then(|d| d.to_digit(10)).unwrap_or(0) as u8; - } - let (mut plus, mut space, mut zero) = (false, false, false); - while let Some(&f) = chars.peek() { - match f { - '+' => plus = true, - ' ' => space = true, - '0' => zero = true, - '#' => {} - _ => break, - } - chars.next(); - } - let mut width = 0usize; - while let Some(d) = chars.peek().and_then(|d| d.to_digit(10)) { - width = width.saturating_mul(10).saturating_add(d as usize); - chars.next(); - } - let mut prec = None; - if chars.peek() == Some(&'.') { - chars.next(); - let mut p = 0usize; - while let Some(d) = chars.peek().and_then(|d| d.to_digit(10)) { - p = p.saturating_mul(10).saturating_add(d as usize); - chars.next(); - } - prec = Some(p); - } - if width > MAX_PRINTF_WIDTH_PRECISION || prec.is_some_and(|p| p > MAX_PRINTF_WIDTH_PRECISION) { - return Err(error(format!("util.printf width or precision exceeds {MAX_PRINTF_WIDTH_PRECISION}"))); - } - let conv = chars.next().unwrap_or('s'); - let v = arg(args, next); - next += 1; - let body = match conv { - 'd' | 'f' => { - let n = v.to_number(ctx)?; - let p = if conv == 'd' { 0 } else { prec.unwrap_or(6) }; - let n = if conv == 'd' { n.trunc() } else { n }; - let mut t = group(&format!("{:.*}", p, n.abs()), sep); - if n < 0.0 { - t.insert(0, '-'); - } else if plus { - t.insert(0, '+'); - } else if space { - t.insert(0, ' '); - } - t - } - 'x' => format!("{:X}", v.to_number(ctx)? as i64), - _ => { - let t = text(&v, ctx)?; - match prec { - Some(p) => t.chars().take(p).collect(), - None => t, - } - } - }; - let pad = width.saturating_sub(body.chars().count()); - check_output(out.len().saturating_add(pad).saturating_add(body.len()))?; - if zero && matches!(conv, 'd' | 'f') { - let (sign, digits) = if body.starts_with(['-', '+', ' ']) { body.split_at(1) } else { ("", body.as_str()) }; - out.push_str(sign); - out.push_str(&"0".repeat(pad)); - out.push_str(digits); - } else { - out.push_str(&" ".repeat(pad)); - out.push_str(&body); - } - } - Ok(s(&out)) -} - -/// Group the integer part of a plain decimal number per `util.printf`'s `,n` flag. -fn group(t: &str, sep: u8) -> String { - let (int, frac) = t.split_once('.').map_or((t, None), |(a, b)| (a, Some(b))); - let (thousands, point) = match sep { - 0 => (Some(','), '.'), - 2 => (Some('.'), ','), - 3 => (None, ','), - _ => (None, '.'), - }; - let mut g = String::new(); - for (i, ch) in int.chars().enumerate() { - if let Some(th) = thousands - && i > 0 - && (int.len() - i).is_multiple_of(3) - { - g.push(th); - } - g.push(ch); - } - if let Some(f) = frac { - g.push(point); - g.push_str(f); - } - g -} - -const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]; -const DAYS: [&str; 7] = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; - -/// `util.printd(cFormat, oDate)`: yyyy yy mmmm mmm mm m dddd ddd dd d HH H hh h MM M ss s tt, -/// and the numeric formats 0 (D:yyyymmddHHMMss), 1 (yyyy.mm.dd HH:MM:ss) and 2 (m/d/yy h:MM:ss tt). -fn printd(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { - let f = arg(args, 0); - let fmt = if f.is_number() { - match f.to_number(ctx)? as i32 { - 0 => "D:yyyymmddHHMMss".to_string(), - 1 => "yyyy.mm.dd HH:MM:ss".to_string(), - _ => "m/d/yy h:MM:ss tt".to_string(), - } - } else { - text(&f, ctx)? - }; - let d = arg(args, 1); - let Some(date) = d.as_object() else { return Err(error("util.printd needs a Date")) }; - let mut part = |m: &str| -> JsResult { - let func = date.get(JsString::from(m), ctx)?; - let func = func.as_callable().ok_or_else(|| error("util.printd needs a Date"))?; - let v = func.call(&d, &[], ctx)?.to_number(ctx)?; - if v.is_nan() { - return Err(error("invalid date")); - } - Ok(v as i64) - }; - let (y, mo, day, wd, h, mi, sec) = - (part("getFullYear")?, part("getMonth")?, part("getDate")?, part("getDay")?, part("getHours")?, part("getMinutes")?, part("getSeconds")?); - let mut out = String::new(); - let b = fmt.as_bytes(); - let mut i = 0; - while i < b.len() { - let c = b[i]; - // Only date letters read as runs; a literal is copied one character at a time, so - // measuring its run would rescan a long literal stretch at every position. - let run = if b"ymdHhMst".contains(&c) { b[i..].iter().take_while(|x| **x == c).count() } else { 1 }; - #[cfg(test)] - PRINTD_SCANNED.fetch_add(run as u64, std::sync::atomic::Ordering::Relaxed); - let push_num = |out: &mut String, v: i64, n: usize| { - if n >= 2 { - out.push_str(&format!("{v:02}")); - } else { - out.push_str(&v.to_string()); - } - }; - match c { - b'y' => { - if run >= 4 { - out.push_str(&format!("{y:04}")); - } else { - out.push_str(&format!("{:02}", y % 100)); - } - } - b'm' => match run { - r if r >= 4 => out.push_str(MONTHS[mo as usize % 12]), - 3 => out.push_str(&MONTHS[mo as usize % 12][..3]), - n => push_num(&mut out, mo + 1, n), - }, - b'd' => match run { - r if r >= 4 => out.push_str(DAYS[wd as usize % 7]), - 3 => out.push_str(&DAYS[wd as usize % 7][..3]), - n => push_num(&mut out, day, n), - }, - b'H' => push_num(&mut out, h, run), - b'h' => push_num(&mut out, if h % 12 == 0 { 12 } else { h % 12 }, run), - b'M' => push_num(&mut out, mi, run), - b's' => push_num(&mut out, sec, run), - b't' => { - let ampm = if h < 12 { "am" } else { "pm" }; - out.push_str(if run >= 2 { ampm } else { &m[..1] }); - } - b'\\' if i + 1 < b.len() => { - // The escaped character may be multi-byte; copy it whole. - let ch = fmt[i + 1..].chars().next().unwrap_or(' '); - out.push(ch); - i += 1 + ch.len_utf8(); - continue; - } - _ => { - // Copy the rest of a multi-byte character too. - let ch = fmt[i..].chars().next().unwrap_or(' '); - out.push(ch); - i += ch.len_utf8(); - continue; - } - } - i += run; - } - Ok(s(&out)) -} - -/// A field of a `util.scand` format. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum DateUnit { - /// `yyyy`: four digits. - Year, - /// `yy`: two digits, read as 1950–2049. - ShortYear, - Month, - /// `mmm`, `mmmm`: a month's name or its three-letter abbreviation. - MonthName, - Day, - /// `ddd`, `dddd`: a weekday's name, read and not checked. - Weekday, - Hour, - /// `h`, `hh`: 0–12, with `tt` for the afternoon. - Hour12, - Minute, - Second, - /// `t`, `tt`: am or pm. - AmPm, -} - -impl DateUnit { - /// A year, month or day: a format needs one of these for `util.scand` to read anything. - fn is_date(self) -> bool { - matches!(self, Self::Year | Self::ShortYear | Self::Month | Self::MonthName | Self::Day) - } - - /// Read from a run of digits, not from letters. - fn is_numeric(self) -> bool { - !matches!(self, Self::MonthName | Self::Weekday | Self::AmPm) - } -} - -/// A piece of a `util.scand` format. -#[derive(Debug, PartialEq, Eq)] -enum ScanPart { - /// A field, the digits it takes when typed together with the next one (`width`), and whether - /// the next one follows it with nothing between them in the format (`joined`). - Field { unit: DateUnit, width: usize, joined: bool }, - /// Letters the format writes as they are, which the text must have there. - Word(String), -} - -/// The fields and literal words of a `util.scand` format, in order. A numeric field is marked -/// `joined` when another numeric field follows it directly; anything but letters and digits -/// outside a field is left out. -fn scan_parts(fmt: &str) -> Vec { - let chars: Vec = fmt.chars().collect(); - let mut parts: Vec = Vec::new(); - // The last part is a field written right before this point, or letters still being collected. - let (mut after_field, mut in_word) = (false, false); - let mut i = 0; - while let Some(&c) = chars.get(i) { - // Counted for field letters only, so a long run of anything else is read once. - let run = if "ymdHhMst".contains(c) { chars.get(i..).unwrap_or_default().iter().take_while(|x| **x == c).count() } else { 1 }; - let field = match (c, run) { - ('y', 4..) => Some((DateUnit::Year, 4)), - ('y', _) => Some((DateUnit::ShortYear, 2)), - ('m', 3..) => Some((DateUnit::MonthName, 0)), - ('m', n) => Some((DateUnit::Month, n)), - ('d', 3..) => Some((DateUnit::Weekday, 0)), - ('d', n) => Some((DateUnit::Day, n)), - ('H', n) => Some((DateUnit::Hour, n.min(2))), - ('h', n) => Some((DateUnit::Hour12, n.min(2))), - ('M', n) => Some((DateUnit::Minute, n.min(2))), - ('s', n) => Some((DateUnit::Second, n.min(2))), - ('t', _) => Some((DateUnit::AmPm, 0)), - _ => None, - }; - if let Some((unit, width)) = field { - if after_field - && unit.is_numeric() - && let Some(ScanPart::Field { unit: last, joined, .. }) = parts.last_mut() - && last.is_numeric() - { - *joined = true; - } - parts.push(ScanPart::Field { unit, width, joined: false }); - (after_field, in_word) = (true, false); - i += run; - continue; - } - // `\` writes the next character as it is. - let (literal, step) = if c == '\\' { (chars.get(i + 1).copied(), 2) } else { (Some(c), 1) }; - i += step; - after_field = false; - match literal { - Some(l) if l.is_alphanumeric() => match parts.last_mut() { - Some(ScanPart::Word(w)) if in_word => w.push(l), - _ => { - parts.push(ScanPart::Word(l.to_string())); - in_word = true; - } - }, - _ => in_word = false, - } - } - parts -} - -/// A run of digits or of letters in the text `util.scand` reads. -enum ScanToken<'a> { - Digits(&'a str), - Letters(&'a str), -} - -/// The text's runs of digits and of letters, in order; anything else only separates them, and -/// may not begin or end the text. `None` when it does. -fn scan_tokens(text: &str) -> Option>> { - // Some(true) for a digit, Some(false) for a letter, None for a separator. - let kind = |c: char| (c.is_ascii_digit() || c.is_alphabetic()).then_some(c.is_ascii_digit()); - kind(text.chars().next()?)?; - kind(text.chars().next_back()?)?; - let mut out = Vec::new(); - let mut start: Option<(usize, bool)> = None; - for (at, c) in text.char_indices().chain(std::iter::once((text.len(), ' '))) { - let k = kind(c); - if let Some((from, digits)) = start - && k != Some(digits) - { - let piece = text.get(from..at)?; - out.push(if digits { ScanToken::Digits(piece) } else { ScanToken::Letters(piece) }); - start = None; - } - if let Some(digits) = k - && start.is_none() - { - start = Some((at, digits)); - } - } - Some(out) -} - -/// What `util.scand` reads. -#[derive(Debug, PartialEq, Eq)] -enum Scanned { - /// The text is empty: today, at noon. - Today, - /// A date and time: year, month (1–12), day, hours, minutes, seconds. - At(i32, u32, u32, u32, u32, u32), -} - -/// The fields `util.scand` has read so far. -#[derive(Default)] -struct ScanValues { - y: Option, - mo: Option, - d: Option, - h: Option, - mi: Option, - sec: Option, - pm: Option, - /// The hour was read from `h` or `hh`. - twelve: bool, -} - -impl ScanValues { - /// Records `v` as the value of `unit`: a `yy` year as 1950–2049, and an `h` or `hh` hour - /// marked as 12-hour. Month names, weekdays and am/pm are not read here. - fn set(&mut self, unit: DateUnit, v: u32) { - match unit { - DateUnit::Year => self.y = i32::try_from(v).ok(), - DateUnit::ShortYear => self.y = i32::try_from(v).ok().map(|v| if v < 50 { 2000 + v } else { 1900 + v }), - DateUnit::Month => self.mo = Some(v), - DateUnit::Day => self.d = Some(v), - DateUnit::Hour => self.h = Some(v), - DateUnit::Hour12 => { - self.h = Some(v); - self.twelve = true; - } - DateUnit::Minute => self.mi = Some(v), - DateUnit::Second => self.sec = Some(v), - DateUnit::MonthName | DateUnit::Weekday | DateUnit::AmPm => {} - } - } -} - -/// Reads `text` by a `util.scand` format, as another viewer was observed to (black-box): -/// -/// - Fields may be separated by anything but digits and letters, whether or not the format -/// separates them, but nothing may come before the first or after the last. -/// - Digits typed together fill the format's fields written together with exactly as many -/// digits as each has letters (`yyyymd`: 4, 1 and 1). A run of digits for one field alone may -/// have one or two, except a four-digit year. -/// - `yy` is 1950–2049; month names may be written in full or as three letters, in any case. -/// - Impossible dates are refused, but a month or day of 0 is read as 1. -/// - A format with no year, month or day reads nothing; an empty text is today, at noon. -/// -/// Not observed: a year left out is `this_year`, and a month or day left out is 1. -fn scan_date(fmt: &str, text: &str, this_year: i32) -> Option { - let parts = scan_parts(fmt); - if !parts.iter().any(|p| matches!(p, ScanPart::Field { unit, .. } if unit.is_date())) { - return None; - } - if text.is_empty() { - return Some(Scanned::Today); - } - let tokens = scan_tokens(text)?; - let mut v = ScanValues::default(); - let (mut p, mut t) = (0, 0); - while let Some(part) = parts.get(p) { - let token = tokens.get(t)?; - t += 1; - match (part, token) { - (ScanPart::Word(w), ScanToken::Letters(l) | ScanToken::Digits(l)) => { - if w.to_lowercase() != l.to_lowercase() { - return None; - } - p += 1; - } - (ScanPart::Field { unit: DateUnit::MonthName, .. }, ScanToken::Letters(l)) => { - v.mo = Some(named(&MONTHS, l)?.saturating_add(1)); - p += 1; - } - (ScanPart::Field { unit: DateUnit::Weekday, .. }, ScanToken::Letters(l)) => { - named(&DAYS, l)?; - p += 1; - } - (ScanPart::Field { unit: DateUnit::AmPm, .. }, ScanToken::Letters(l)) => { - v.pm = Some(match l.to_lowercase().as_str() { - "am" | "a" => false, - "pm" | "p" => true, - _ => return None, - }); - p += 1; - } - (ScanPart::Field { unit, width, joined }, ScanToken::Digits(run)) if unit.is_numeric() => { - // The fields written together from here, no more than the run's digits can fill, - // and the fewest of them the run fills. - let mut group = vec![(*unit, *width)]; - let (mut more, mut widths) = (*joined, *width); - while more - && widths < run.len() - && let Some(ScanPart::Field { unit, width, joined }) = parts.get(p + group.len()) - { - group.push((*unit, *width)); - widths = widths.saturating_add(*width); - more = *joined; - } - let mut sum = 0; - let fills = group.iter().position(|(_, w)| { - sum += w; - sum == run.len() - }); - if let Some(last) = fills { - let mut at = 0; - for (unit, w) in group.iter().take(last + 1) { - v.set(*unit, run.get(at..at + w)?.parse().ok()?); - at += w; - } - p += last + 1; - } else if *unit != DateUnit::Year && run.len() <= 2 { - v.set(*unit, run.parse().ok()?); - p += 1; - } else { - return None; - } - } - _ => return None, - } - } - if t != tokens.len() { - return None; - } - let y = v.y.unwrap_or(this_year); - let mo = v.mo.unwrap_or(1).max(1); - let d = v.d.unwrap_or(1).max(1); - if mo > 12 || i64::from(d) > i64::from(formcalc::days_in(i64::from(y), mo)) { - return None; - } - let mut h = v.h.unwrap_or(0); - if v.twelve && h > 12 { - return None; - } - match v.pm { - Some(true) if h < 12 => h += 12, - Some(false) if h == 12 => h = 0, - _ => {} - } - let (mi, sec) = (v.mi.unwrap_or(0), v.sec.unwrap_or(0)); - (h <= 23 && mi <= 59 && sec <= 59).then_some(Scanned::At(y, mo, d, h, mi, sec)) -} - -/// The index of the name `word` is, in full or as its first three letters, in any case. -fn named(names: &[&str], word: &str) -> Option { - let word = word.to_lowercase(); - let i = names.iter().position(|n| { - let n = n.to_lowercase(); - word == n || n.get(..3) == Some(word.as_str()) - })?; - u32::try_from(i).ok() -} - -/// `util.scand(cFormat, cDate)`: the date `cDate` gives by a `util.printd` format, as a local -/// `Date`, or `null` when it doesn't read as one (see [`scan_date`]). Only text is read. -fn scand(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { - let Some(typed) = arg(args, 1).as_string().map(|t| t.to_std_string_escaped()) else { - return Ok(JsValue::null()); - }; - let fmt = text(&arg(args, 0), ctx)?; - let date = JsDate::new(ctx); - let this_year = date.get_full_year(ctx)?.to_number(ctx)?; - let this_year = if this_year.is_finite() { this_year as i32 } else { 2000 }; - match scan_date(&fmt, &typed, this_year) { - None => return Ok(JsValue::null()), - Some(Scanned::Today) => { - date.set_hours(&[12.into(), 0.into(), 0.into(), 0.into()], ctx)?; - } - Some(Scanned::At(y, mo, d, h, mi, sec)) => { - // setFullYear, not the constructor, so years 0–99 stay as they are. - date.set_full_year(&[y.into(), (mo.saturating_sub(1)).into(), d.into()], ctx)?; - date.set_hours(&[h.into(), mi.into(), sec.into(), 0.into()], ctx)?; - } - } - Ok(date.into()) -} - -/// `util.printx(cFormat, cSource)`: `?` any character, `X` letter or digit, `A` letter, `9` -/// digit, `*` the rest, `\` escape, `>` / `<` / `=` upper / lower / as is. -fn printx(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { - let fmt: Vec = text(&arg(args, 0), ctx)?.chars().collect(); - let src: Vec = text(&arg(args, 1), ctx)?.chars().collect(); - let (mut out, mut j, mut case) = (String::new(), 0, 0); - let mut i = 0; - let push = |out: &mut String, c: char, case: i32| match case { - 1 => out.extend(c.to_uppercase()), - -1 => out.extend(c.to_lowercase()), - _ => out.push(c), - }; - while i < fmt.len() { - let f = fmt[i]; - i += 1; - let take = |j: &mut usize, ok: &dyn Fn(char) -> bool| -> Option { - while *j < src.len() { - let c = src[*j]; - *j += 1; - if ok(c) { - return Some(c); - } - } - None - }; - match f { - '?' => { - if let Some(c) = take(&mut j, &|_| true) { - push(&mut out, c, case) - } - } - 'X' => { - if let Some(c) = take(&mut j, &|c| c.is_alphanumeric()) { - push(&mut out, c, case) - } - } - 'A' => { - if let Some(c) = take(&mut j, &|c| c.is_alphabetic()) { - push(&mut out, c, case) - } - } - '9' => { - if let Some(c) = take(&mut j, &|c| c.is_ascii_digit()) { - push(&mut out, c, case) - } - } - '*' => { - for c in src[j.min(src.len())..].iter().copied() { - push(&mut out, c, case); - } - j = src.len(); - } - '\\' if i < fmt.len() => { - out.push(fmt[i]); - i += 1; - } - '>' => case = 1, - '<' => case = -1, - '=' => case = 0, - c => out.push(c), - } - } - Ok(s(&out)) -} - -/// The `color` object: each named color (`transparent`, `black`, `red`, `cyan`, `gray`, …) as -/// an array of its color space (`T`, `G`, `RGB` or `CMYK`) followed by its components. -fn color_object(ctx: &mut Context) -> JsObject { - let mut entries: Vec<(&str, Vec)> = vec![ - ("transparent", vec![s("T")]), - ("black", vec![s("G"), 0.into()]), - ("white", vec![s("G"), 1.into()]), - ("red", vec![s("RGB"), 1.into(), 0.into(), 0.into()]), - ("green", vec![s("RGB"), 0.into(), 1.into(), 0.into()]), - ("blue", vec![s("RGB"), 0.into(), 0.into(), 1.into()]), - ("cyan", vec![s("CMYK"), 1.into(), 0.into(), 0.into(), 0.into()]), - ("magenta", vec![s("CMYK"), 0.into(), 1.into(), 0.into(), 0.into()]), - ("yellow", vec![s("CMYK"), 0.into(), 0.into(), 1.into(), 0.into()]), - ("dkGray", vec![s("G"), JsValue::from(0.25)]), - ("gray", vec![s("G"), JsValue::from(0.5)]), - ("ltGray", vec![s("G"), JsValue::from(0.75)]), - ]; - let arrays: Vec<(&str, JsValue)> = entries.drain(..).map(|(k, v)| (k, JsArray::from_iter(v, ctx).into())).collect(); - let mut o = ObjectInitializer::new(ctx); - for (k, v) in arrays { - o.property(JsString::from(k), v, Attribute::all()); - } - o.build() -} - -/// The XFA version scripts see as `xfa_version`. -const XFA_VERSION: f64 = 3.5; - -/// Defines the form object model's globals: the document's functions (`getField`, `resetForm`, -/// `print`, `submitForm` and others) and properties (`numFields`, `pageNum`, `numPages`, -/// `documentFileName`, `info`, the XFA version), and the `app`, `util`, `console`, `display` and -/// `color` objects. -fn install(ctx: &mut Context) -> JsResult<()> { - let globals: [(&str, usize, NativeFunction); 9] = [ - ("getField", 1, NativeFunction::from_fn_ptr(get_field)), - ("getNthFieldName", 1, NativeFunction::from_fn_ptr(get_nth_field_name)), - ("resetForm", 1, NativeFunction::from_fn_ptr(reset_form)), - ("print", 1, NativeFunction::from_fn_ptr(print)), - ("submitForm", 1, NativeFunction::from_fn_ptr(submit_form)), - ("mailDoc", 1, NativeFunction::from_fn_ptr(noop)), - ("calculateNow", 0, NativeFunction::from_fn_ptr(noop)), - ("syncAnnotScan", 0, NativeFunction::from_fn_ptr(noop)), - ("dirty", 0, NativeFunction::from_fn_ptr(noop)), - ]; - for (name, len, f) in globals { - ctx.register_global_callable(JsString::from(name), len, f)?; - } - let global = ctx.global_object(); - let rw = Attribute::CONFIGURABLE; - let nf = function(ctx, NativeFunction::from_fn_ptr(num_fields)); - let pg = function(ctx, NativeFunction::from_fn_ptr(page_num_get)); - let ps = function(ctx, NativeFunction::from_fn_ptr(page_num_set)); - let (file, pages, info, xfa) = { - let h = host(ctx)?.borrow(); - (h.doc.file_name.clone(), h.doc.num_pages, h.doc.info.clone(), h.doc.xfa) - }; - let mut io = ObjectInitializer::new(ctx); - for (k, v) in &info { - io.property(JsString::from(k.as_str()), s(v), Attribute::all()); - } - let info = io.build(); - use boa_engine::property::PropertyDescriptor; - global.define_property_or_throw(js_string!("numFields"), PropertyDescriptor::builder().get(nf).configurable(true), ctx)?; - global.define_property_or_throw(js_string!("pageNum"), PropertyDescriptor::builder().get(pg).set(ps).configurable(true), ctx)?; - ctx.register_global_property(js_string!("numPages"), JsValue::from(pages as f64), rw)?; - ctx.register_global_property(js_string!("documentFileName"), s(&file), rw)?; - ctx.register_global_property(js_string!("info"), info, rw)?; - // XFA forms check both before they run: every document sees the XFA version, and only an - // XFA form sees `xfa_installed`. - ctx.register_global_property(js_string!("xfa_version"), JsValue::from(XFA_VERSION), rw)?; - if xfa { - ctx.register_global_property(js_string!("xfa_installed"), JsValue::from(true), rw)?; - } - - let app = ObjectInitializer::new(ctx) - .function(NativeFunction::from_fn_ptr(alert), js_string!("alert"), 1) - .function(NativeFunction::from_fn_ptr(beep), js_string!("beep"), 0) - .function(NativeFunction::from_fn_ptr(response), js_string!("response"), 1) - .function(NativeFunction::from_fn_ptr(launch_url), js_string!("launchURL"), 2) - .function(NativeFunction::from_fn_ptr(exec_menu_item), js_string!("execMenuItem"), 1) - .function(NativeFunction::from_fn_ptr(timer), js_string!("setTimeOut"), 2) - .function(NativeFunction::from_fn_ptr(timer), js_string!("setInterval"), 2) - .function(NativeFunction::from_fn_ptr(noop), js_string!("clearTimeOut"), 1) - .function(NativeFunction::from_fn_ptr(noop), js_string!("clearInterval"), 1) - .property(js_string!("viewerType"), s("Exchange-Pro"), Attribute::READONLY) - .property(js_string!("viewerVariation"), s("Full"), Attribute::READONLY) - .property(js_string!("viewerVersion"), JsValue::from(24.0), Attribute::READONLY) - .property(js_string!("formsVersion"), JsValue::from(24.0), Attribute::READONLY) - .property(js_string!("platform"), s(platform()), Attribute::READONLY) - .property(js_string!("language"), s("ENU"), Attribute::READONLY) - .build(); - ctx.register_global_property(js_string!("app"), app, rw)?; - let util = ObjectInitializer::new(ctx) - .function(NativeFunction::from_fn_ptr(printf), js_string!("printf"), 1) - .function(NativeFunction::from_fn_ptr(printd), js_string!("printd"), 2) - .function(NativeFunction::from_fn_ptr(printx), js_string!("printx"), 2) - .function(NativeFunction::from_fn_ptr(scand), js_string!("scand"), 2) - .build(); - ctx.register_global_property(js_string!("util"), util, rw)?; - let console = ObjectInitializer::new(ctx) - .function(NativeFunction::from_fn_ptr(println), js_string!("println"), 1) - .function(NativeFunction::from_fn_ptr(noop), js_string!("show"), 0) - .function(NativeFunction::from_fn_ptr(noop), js_string!("hide"), 0) - .function(NativeFunction::from_fn_ptr(console_clear), js_string!("clear"), 0) - .build(); - ctx.register_global_property(js_string!("console"), console, rw)?; - let display = ObjectInitializer::new(ctx) - .property(js_string!("visible"), DISPLAY_VISIBLE, Attribute::READONLY) - .property(js_string!("hidden"), DISPLAY_HIDDEN, Attribute::READONLY) - .property(js_string!("noPrint"), DISPLAY_NO_PRINT, Attribute::READONLY) - .property(js_string!("noView"), DISPLAY_NO_VIEW, Attribute::READONLY) - .build(); - ctx.register_global_property(js_string!("display"), display, rw)?; - let color = color_object(ctx); - ctx.register_global_property(js_string!("color"), color, rw)?; - Ok(()) -} - -fn platform() -> &'static str { - if cfg!(target_os = "macos") { - "MAC" - } else if cfg!(target_os = "windows") { - "WIN" - } else { - "UNIX" - } -} - -fn event_object(ctx: &mut Context, e: &Event) -> JsObject { - let target = e.target.as_ref().map_or(JsValue::null(), |t| field_object(ctx, t).into()); - ObjectInitializer::new(ctx) - .property(js_string!("name"), s(&e.name), Attribute::all()) - .property(js_string!("type"), s(&e.kind), Attribute::all()) - .property(js_string!("value"), s(&e.value), Attribute::all()) - .property(js_string!("change"), s(&e.change), Attribute::all()) - .property(js_string!("changeEx"), s(&e.change), Attribute::all()) - .property(js_string!("willCommit"), e.will_commit, Attribute::all()) - .property(js_string!("rc"), true, Attribute::all()) - .property(js_string!("target"), target, Attribute::all()) - .property(js_string!("targetName"), s(e.target.as_deref().unwrap_or("")), Attribute::all()) - .property(js_string!("selStart"), 0, Attribute::all()) - .property(js_string!("selEnd"), 0, Attribute::all()) - .property(js_string!("commitKey"), 0, Attribute::all()) - .property(js_string!("modifier"), false, Attribute::all()) - .property(js_string!("shift"), false, Attribute::all()) - .build() -} - -// ── stack safety ──────────────────────────────────────────────────────────────────────────── -// -// boa's parser and compiler recurse once per nesting level and have no limit of their own: about -// a hundred nested parentheses overflow a 2 MiB stack, which aborts the whole app. Scripts come -// from documents, so `run` refuses nesting no real form needs and runs the engine on a thread -// with a large stack (address space, committed only as it is used). Together these bound the -// stack a script can use: brackets and prefix operators by the limits below, and every other -// construct (a few KiB per level, several bytes of source each) by the length limit. - -/// Bytes `util.printd` has scanned for runs of a format character, for tests that bound its work. #[cfg(test)] -pub(crate) static PRINTD_SCANNED: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); - -/// Longest script, in bytes. -pub(crate) const MAX_SCRIPT_BYTES: usize = 256 * 1024; -/// Largest width or precision accepted by `util.printf`. -const MAX_PRINTF_WIDTH_PRECISION: usize = 4096; -/// Longest `util.printf` output, in bytes. -const MAX_PRINTF_OUTPUT_BYTES: usize = 1 << 20; -/// Longest array read item by item (`resetForm`, a field's `value` or colours). A sparse array's -/// length costs the script nothing but the host one string per item; real lists are tiny. -const MAX_ARRAY_LENGTH: u64 = 1 << 20; -/// Deepest nesting of `(`, `[` and `{`. -const MAX_BRACKET_DEPTH: usize = 64; -/// Longest run of prefix operators (`!`, `~`, `+`, `-`). -const MAX_PREFIX_RUN: usize = 64; -/// Most `?` (conditional expressions nest without brackets). -const MAX_CONDITIONALS: usize = 4096; -/// The script thread's stack. +pub(crate) use format::PRINTD_SCANNED; +use format::{printd, printf, printx, scand}; +pub(crate) use globals::menu_item; +use globals::platform; +use host::{arg, as_number, error, function, s, text}; +pub use model::{ + DISPLAY_HIDDEN, DISPLAY_NO_PRINT, DISPLAY_NO_VIEW, DISPLAY_VISIBLE, DocInfo, Event, FieldState, FieldType, Limits, Outcome, Panel, Request, +}; #[cfg(not(target_arch = "wasm32"))] -const SCRIPT_STACK: usize = 1 << 30; - -/// Why `source` is refused, if it is: too long, or nested deeper than any form needs. Strings -/// and comments are skipped; brackets in regular expressions and templates count, which only -/// errs on the safe side. -fn refuse(source: &str) -> Option { - if source.len() > MAX_SCRIPT_BYTES { - return Some(format!("the script is too long ({} KiB; the limit is {} KiB)", source.len() / 1024, MAX_SCRIPT_BYTES / 1024)); - } - let too_deep = || Some("the script is nested too deeply to run safely".to_string()); - let b = source.as_bytes(); - let (mut depth, mut prefix, mut conditionals) = (0usize, 0usize, 0usize); - let mut i = 0; - while let Some(&c) = b.get(i) { - i += 1; - match c { - b'\'' | b'"' => { - // To the closing quote (or the end of the line, where an unclosed string ends). - while let Some(&d) = b.get(i) { - i += 1; - if d == b'\\' { - i += 1; - } else if d == c || d == b'\n' { - break; - } - } - prefix = 0; - } - b'/' if b.get(i) == Some(&b'/') => { - while b.get(i).is_some_and(|d| *d != b'\n') { - i += 1; - } - } - b'/' if b.get(i) == Some(&b'*') => { - i += 1; - while i < b.len() && !b[i..].starts_with(b"*/") { - i += 1; - } - i += 2; - } - b'(' | b'[' | b'{' => { - depth += 1; - if depth > MAX_BRACKET_DEPTH { - return too_deep(); - } - prefix = 0; - } - b')' | b']' | b'}' => { - depth = depth.saturating_sub(1); - prefix = 0; - } - b'!' | b'~' | b'+' | b'-' => { - prefix += 1; - if prefix > MAX_PREFIX_RUN { - return too_deep(); - } - } - b'?' => { - conditionals += 1; - if conditionals > MAX_CONDITIONALS { - return too_deep(); - } - prefix = 0; - } - c if c.is_ascii_whitespace() => {} - _ => prefix = 0, - } - } - None -} - -/// Run `script` for `event`, after the document-level scripts (`doc_scripts`, which define the -/// functions field scripts call), with the form's `fields`. -pub fn run(script: &str, event: &Event, doc: &DocInfo, fields: &[FieldState], doc_scripts: &[String], limits: Limits) -> Outcome { - run_shared(script, event, doc, Arc::new(fields.to_vec()), doc_scripts, limits) -} - -/// Like [`run`], sharing an immutable initial field snapshot. Scripts still get a fresh -/// context; the first field write copies the snapshot, so neither the caller nor another -/// script can observe uncommitted writes. Read-only calculations avoid copying the whole form. -pub fn run_shared(script: &str, event: &Event, doc: &DocInfo, fields: Arc>, doc_scripts: &[String], limits: Limits) -> Outcome { - let failed = |why: String| Outcome { rc: true, value: event.value.clone(), change: event.change.clone(), error: Some(why), ..Default::default() }; - if let Some(why) = refuse(script) { - return failed(why); - } - // A document script too long or too deeply nested to run safely is skipped and reported, - // as one that throws is; the others and the script still run (#3940). - let mut skipped = Vec::new(); - let kept: Vec<(usize, &str)> = doc_scripts - .iter() - .enumerate() - .filter_map(|(i, d)| match refuse(d) { - Some(why) => { - skipped.push((i, why)); - None - } - None => Some((i, d.as_str())), - }) - .collect(); - let (kept, skipped) = (&kept, &skipped); - #[cfg(not(target_arch = "wasm32"))] - { - std::thread::scope(|scope| { - let spawned = std::thread::Builder::new() - .name("pdfcraft-js".into()) - .stack_size(SCRIPT_STACK) - .spawn_scoped(scope, || run_here(script, event, doc, fields, kept, skipped, limits)); - match spawned { - Ok(thread) => thread.join().unwrap_or_else(|_| failed("the script stopped with an internal error".into())), - Err(e) => failed(format!("the script engine could not start: {e}")), - } - }) - } - #[cfg(target_arch = "wasm32")] - run_here(script, event, doc, fields, kept, skipped, limits) -} - -/// Host hooks that refuse to compile code from strings (`eval`, `Function` and its generator -/// and async kin). Such code is built at run time, so it never passed [`refuse`], and boa's -/// recursive parser would overflow the stack on a deeply nested one. -struct NoCodeFromStrings; - -impl HostHooks for NoCodeFromStrings { - fn ensure_can_compile_strings(&self, _: Realm, _: &[JsString], _: &JsString, _: bool, _: &mut Context) -> JsResult<()> { - Err(JsNativeError::typ().with_message("scripts can't compile code from strings (eval, Function)").into()) - } -} - -/// Host hooks that hold code compiled from strings to the checks a script passes ([`refuse`]), -/// for runners that start the script itself through `eval`. -pub(crate) struct CheckedCodeFromStrings; - -impl HostHooks for CheckedCodeFromStrings { - fn ensure_can_compile_strings(&self, _: Realm, parameters: &[JsString], body: &JsString, _: bool, _: &mut Context) -> JsResult<()> { - let units = parameters.iter().chain(std::iter::once(body)).fold(0usize, |n, s| n.saturating_add(s.len())); - if units > MAX_SCRIPT_BYTES { - return Err(JsNativeError::range().with_message("the code is too long to compile").into()); - } - // As one text: brackets left open in one parameter carry into the next. - let mut source = parameters.iter().map(JsString::to_std_string_lossy).collect::>().join(","); - source.push('\n'); - source.push_str(&body.to_std_string_lossy()); - match refuse(&source) { - Some(why) => Err(JsNativeError::range().with_message(why).into()), - None => Ok(()), - } - } -} - -/// How [`Outcome::doc_errors`] names a failing document script: by its 1-based place. -fn doc_error(index: usize, error: &dyn std::fmt::Display) -> String { - format!("document script {}: {error}", index.saturating_add(1)) -} - -/// `doc_scripts` are the document scripts to run with their places among all of them; -/// `skipped` the ones refused before running, with the reason. -fn run_here( - script: &str, - event: &Event, - doc: &DocInfo, - fields: Arc>, - doc_scripts: &[(usize, &str)], - skipped: &[(usize, String)], - limits: Limits, -) -> Outcome { - let mut ctx = match Context::builder().host_hooks(std::rc::Rc::new(NoCodeFromStrings)).build() { - Ok(ctx) => ctx, - Err(e) => { - let error = Some(format!("the script engine could not start: {e}")); - return Outcome { rc: true, value: event.value.clone(), change: event.change.clone(), error, ..Default::default() }; - } - }; - ctx.runtime_limits_mut().set_loop_iteration_limit(limits.loop_iterations); - ctx.runtime_limits_mut().set_recursion_limit(limits.recursion); - ctx.insert_data(RefCell::new(Host { - fields, - changed: BTreeSet::new(), - alerts: Vec::new(), - console: Vec::new(), - requests: Vec::new(), - doc: doc.clone(), - kept: 0, - dropped: 0, - })); - let mut out = Outcome { rc: true, value: event.value.clone(), change: event.change.clone(), ..Default::default() }; - let mut doc_errors: Vec<(usize, String)> = skipped.iter().map(|(i, why)| (*i, doc_error(*i, why))).collect(); - let result = (|| -> JsResult<()> { - install(&mut ctx)?; - let ev = event_object(&mut ctx, event); - ctx.register_global_property(js_string!("event"), ev.clone(), Attribute::all())?; - // One document script that throws (calling a method PdfCraft lacks, say) must not keep - // every field script in the form from running: what it defined before the error, and - // the other scripts, stay available (#3940). - for &(i, d) in doc_scripts { - if let Err(e) = ctx.eval(Source::from_bytes(d)) { - doc_errors.push((i, doc_error(i, &e))); - } - } - let r = ctx.eval(Source::from_bytes(script))?; - if !r.is_undefined() { - out.result = Some(text(&r, &mut ctx)?); - } - let rc = ev.get(js_string!("rc"), &mut ctx)?; - out.rc = rc.to_boolean(); - let v = ev.get(js_string!("value"), &mut ctx)?; - out.value = text(&v, &mut ctx)?; - let c = ev.get(js_string!("change"), &mut ctx)?; - out.change = text(&c, &mut ctx)?; - Ok(()) - })(); - if let Err(e) = result { - out.error = Some(e.to_string()); - } - doc_errors.sort_by_key(|(i, _)| *i); - out.doc_errors = doc_errors.into_iter().map(|(_, e)| e).collect(); - if let Some(h) = ctx.remove_data::() { - let h = h.into_inner(); - out.changed = h.changed.iter().map(|i| h.fields[*i].clone()).collect(); - out.alerts = h.alerts; - out.console = h.console; - out.requests = h.requests; - if h.dropped > 0 { - out.console.push(format!("the script's messages, console lines and requests passed their limits; {} more were left out", h.dropped)); - } - } - out -} +use run::SCRIPT_STACK; +use run::refuse; +pub(crate) use run::{CheckedCodeFromStrings, MAX_SCRIPT_BYTES}; +pub use run::{run, run_shared}; pub mod formcalc; pub mod xfa; diff --git a/crates/js/src/model.rs b/crates/js/src/model.rs new file mode 100644 index 0000000000..80583d0de0 --- /dev/null +++ b/crates/js/src/model.rs @@ -0,0 +1,199 @@ +/// The kinds of field, as `field.type` names them. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum FieldType { + Text, + CheckBox, + RadioButton, + ComboBox, + ListBox, + Button, + Signature, +} + +impl FieldType { + pub fn name(self) -> &'static str { + match self { + FieldType::Text => "text", + FieldType::CheckBox => "checkbox", + FieldType::RadioButton => "radiobutton", + FieldType::ComboBox => "combobox", + FieldType::ListBox => "listbox", + FieldType::Button => "button", + FieldType::Signature => "signature", + } + } +} + +/// `display.*`: visible, hidden, noPrint, noView. +pub const DISPLAY_VISIBLE: i32 = 0; + +pub const DISPLAY_HIDDEN: i32 = 1; + +pub const DISPLAY_NO_PRINT: i32 = 2; + +pub const DISPLAY_NO_VIEW: i32 = 3; + +/// A field as scripts see it. +#[derive(Clone, Debug, PartialEq)] +pub struct FieldState { + pub name: String, + pub kind: FieldType, + /// Text: the text. Check box / radio: the selected export value (empty when off). Choice: + /// the selected export values. + pub value: Vec, + pub default: Vec, + pub readonly: bool, + pub required: bool, + pub display: i32, + /// Choice options (export value, display text); check boxes and radios: their export + /// values, one per widget. + pub options: Vec<(String, String)>, + pub char_limit: Option, + /// `textColor` / `fillColor` as Acrobat colour arrays (`["RGB", r, g, b]` etc.), when set. + pub text_color: Option>, + pub fill_color: Option>, +} + +impl FieldState { + pub fn new(name: impl Into, kind: FieldType, value: Vec) -> FieldState { + FieldState { + name: name.into(), + kind, + value, + default: Vec::new(), + readonly: false, + required: false, + display: DISPLAY_VISIBLE, + options: Vec::new(), + char_limit: None, + text_color: None, + fill_color: None, + } + } + + pub(crate) fn single(&self) -> String { + self.value.first().cloned().unwrap_or_default() + } +} + +/// The event a script runs for. +#[derive(Clone, Debug, PartialEq)] +pub struct Event { + /// `Keystroke`, `Validate`, `Calculate`, `Format`, `Mouse Up`, `Open`, … + pub name: String, + /// `Field`, `Doc`, `Page`, … + pub kind: String, + /// The field the event is for. + pub target: Option, + pub value: String, + pub change: String, + pub will_commit: bool, +} + +impl Event { + /// A field event (`event.type` = "Field"). + pub fn field(name: &str, target: &str, value: &str) -> Event { + Event { name: name.into(), kind: "Field".into(), target: Some(target.into()), value: value.into(), change: String::new(), will_commit: true } + } + + /// A document event (`event.type` = "Doc"), e.g. Open or WillSave. + pub fn doc(name: &str) -> Event { + Event { name: name.into(), kind: "Doc".into(), target: None, value: String::new(), change: String::new(), will_commit: false } + } +} + +/// About the document, for `this.numPages`, `this.info` and the like. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct DocInfo { + pub file_name: String, + pub num_pages: usize, + /// The current page, 0-based. + pub page: usize, + /// Document information (`this.info.title` etc.): lower-case key, value. + pub info: Vec<(String, String)>, + /// The document has an XFA form, so scripts see `xfa_installed`. + pub xfa: bool, +} + +/// Something a script asked the viewer to do. +#[derive(Clone, Debug, PartialEq)] +pub enum Request { + /// `this.resetForm([names])` (empty: every field). + Reset(Vec), + Print, + /// `this.pageNum = n` (0-based). + GoToPage(usize), + LaunchUrl(String), + /// `this.submitForm(url)`: never sent on PdfCraft's own. + Submit(String), + /// `field.setFocus()`. + Focus(String), + Beep, + /// `app.execMenuItem("SaveAs")` (XFA forms' Save buttons). + SaveAs, + /// `app.execMenuItem("ShowHide…")`: show the side panel, or hide it when it is showing. + ShowPanel(Panel), +} + +/// A side panel a script can show or hide (`app.execMenuItem("ShowHideFileAttachment")`, …). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Panel { + /// The document's bookmarks (outline). + Bookmarks, + /// Page thumbnails. + Pages, + /// The files attached to the document. + Attachments, + /// The document's signatures and their validity. + Signatures, + /// The document's layers (optional content). + Layers, +} + +impl Panel { + /// The panel's name in automation output, fixed whatever the variant is called. + pub fn id(self) -> &'static str { + match self { + Panel::Bookmarks => "bookmarks", + Panel::Pages => "pages", + Panel::Attachments => "attachments", + Panel::Signatures => "signatures", + Panel::Layers => "layers", + } + } +} + +/// What running a script did. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Outcome { + /// `event.rc`: `false` rejects the keystroke or value. + pub rc: bool, + /// `event.value` after the script. + pub value: String, + pub change: String, + /// Fields whose value or properties the script changed, as they are now. + pub changed: Vec, + pub alerts: Vec, + pub console: Vec, + pub requests: Vec, + /// The script's error (syntax error, exception, or a limit reached), if it failed. + pub error: Option, + /// Errors in the document-level scripts that ran first. Each is a script of its own: one + /// that throws stops where it failed, and the others and the script itself still run (#3940). + pub doc_errors: Vec, + /// The script's completion value (what the console prints), unless undefined. + pub result: Option, +} + +/// Bounds that keep a script from hanging the viewer. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Limits { + pub loop_iterations: u64, + pub recursion: usize, +} + +impl Default for Limits { + fn default() -> Self { + Limits { loop_iterations: 1_000_000, recursion: 256 } + } +} diff --git a/crates/js/src/run.rs b/crates/js/src/run.rs new file mode 100644 index 0000000000..07c0e984bb --- /dev/null +++ b/crates/js/src/run.rs @@ -0,0 +1,256 @@ +use super::globals::{event_object, install}; +use super::host::{Host, Shared, text}; +use super::model::{DocInfo, Event, FieldState, Limits, Outcome}; +use super::{Arc, Attribute, BTreeSet, Context, HostHooks, JsNativeError, JsResult, JsString, Realm, RefCell, Source, js_string}; + +/// Longest script, in bytes. +pub(crate) const MAX_SCRIPT_BYTES: usize = 256 * 1024; + +/// Largest width or precision accepted by `util.printf`. +pub(crate) const MAX_PRINTF_WIDTH_PRECISION: usize = 4096; + +/// Longest `util.printf` output, in bytes. +pub(crate) const MAX_PRINTF_OUTPUT_BYTES: usize = 1 << 20; + +/// Longest array read item by item (`resetForm`, a field's `value` or colours). A sparse array's +/// length costs the script nothing but the host one string per item; real lists are tiny. +pub(crate) const MAX_ARRAY_LENGTH: u64 = 1 << 20; + +/// Deepest nesting of `(`, `[` and `{`. +const MAX_BRACKET_DEPTH: usize = 64; + +/// Longest run of prefix operators (`!`, `~`, `+`, `-`). +const MAX_PREFIX_RUN: usize = 64; + +/// Most `?` (conditional expressions nest without brackets). +const MAX_CONDITIONALS: usize = 4096; + +/// The script thread's stack. +#[cfg(not(target_arch = "wasm32"))] +pub(crate) const SCRIPT_STACK: usize = 1 << 30; + +/// Why `source` is refused, if it is: too long, or nested deeper than any form needs. Strings +/// and comments are skipped; brackets in regular expressions and templates count, which only +/// errs on the safe side. +pub(crate) fn refuse(source: &str) -> Option { + if source.len() > MAX_SCRIPT_BYTES { + return Some(format!("the script is too long ({} KiB; the limit is {} KiB)", source.len() / 1024, MAX_SCRIPT_BYTES / 1024)); + } + let too_deep = || Some("the script is nested too deeply to run safely".to_string()); + let b = source.as_bytes(); + let (mut depth, mut prefix, mut conditionals) = (0usize, 0usize, 0usize); + let mut i = 0; + while let Some(&c) = b.get(i) { + i += 1; + match c { + b'\'' | b'"' => { + // To the closing quote (or the end of the line, where an unclosed string ends). + while let Some(&d) = b.get(i) { + i += 1; + if d == b'\\' { + i += 1; + } else if d == c || d == b'\n' { + break; + } + } + prefix = 0; + } + b'/' if b.get(i) == Some(&b'/') => { + while b.get(i).is_some_and(|d| *d != b'\n') { + i += 1; + } + } + b'/' if b.get(i) == Some(&b'*') => { + i += 1; + while i < b.len() && !b[i..].starts_with(b"*/") { + i += 1; + } + i += 2; + } + b'(' | b'[' | b'{' => { + depth += 1; + if depth > MAX_BRACKET_DEPTH { + return too_deep(); + } + prefix = 0; + } + b')' | b']' | b'}' => { + depth = depth.saturating_sub(1); + prefix = 0; + } + b'!' | b'~' | b'+' | b'-' => { + prefix += 1; + if prefix > MAX_PREFIX_RUN { + return too_deep(); + } + } + b'?' => { + conditionals += 1; + if conditionals > MAX_CONDITIONALS { + return too_deep(); + } + prefix = 0; + } + c if c.is_ascii_whitespace() => {} + _ => prefix = 0, + } + } + None +} + +/// Run `script` for `event`, after the document-level scripts (`doc_scripts`, which define the +/// functions field scripts call), with the form's `fields`. +pub fn run(script: &str, event: &Event, doc: &DocInfo, fields: &[FieldState], doc_scripts: &[String], limits: Limits) -> Outcome { + run_shared(script, event, doc, Arc::new(fields.to_vec()), doc_scripts, limits) +} + +/// Like [`run`], sharing an immutable initial field snapshot. Scripts still get a fresh +/// context; the first field write copies the snapshot, so neither the caller nor another +/// script can observe uncommitted writes. Read-only calculations avoid copying the whole form. +pub fn run_shared(script: &str, event: &Event, doc: &DocInfo, fields: Arc>, doc_scripts: &[String], limits: Limits) -> Outcome { + let failed = |why: String| Outcome { rc: true, value: event.value.clone(), change: event.change.clone(), error: Some(why), ..Default::default() }; + if let Some(why) = refuse(script) { + return failed(why); + } + // A document script too long or too deeply nested to run safely is skipped and reported, + // as one that throws is; the others and the script still run (#3940). + let mut skipped = Vec::new(); + let kept: Vec<(usize, &str)> = doc_scripts + .iter() + .enumerate() + .filter_map(|(i, d)| match refuse(d) { + Some(why) => { + skipped.push((i, why)); + None + } + None => Some((i, d.as_str())), + }) + .collect(); + let (kept, skipped) = (&kept, &skipped); + #[cfg(not(target_arch = "wasm32"))] + { + std::thread::scope(|scope| { + let spawned = std::thread::Builder::new() + .name("pdfcraft-js".into()) + .stack_size(SCRIPT_STACK) + .spawn_scoped(scope, || run_here(script, event, doc, fields, kept, skipped, limits)); + match spawned { + Ok(thread) => thread.join().unwrap_or_else(|_| failed("the script stopped with an internal error".into())), + Err(e) => failed(format!("the script engine could not start: {e}")), + } + }) + } + #[cfg(target_arch = "wasm32")] + run_here(script, event, doc, fields, kept, skipped, limits) +} + +/// Host hooks that refuse to compile code from strings (`eval`, `Function` and its generator +/// and async kin). Such code is built at run time, so it never passed [`refuse`], and boa's +/// recursive parser would overflow the stack on a deeply nested one. +struct NoCodeFromStrings; + +impl HostHooks for NoCodeFromStrings { + fn ensure_can_compile_strings(&self, _: Realm, _: &[JsString], _: &JsString, _: bool, _: &mut Context) -> JsResult<()> { + Err(JsNativeError::typ().with_message("scripts can't compile code from strings (eval, Function)").into()) + } +} + +/// Host hooks that hold code compiled from strings to the checks a script passes ([`refuse`]), +/// for runners that start the script itself through `eval`. +pub(crate) struct CheckedCodeFromStrings; + +impl HostHooks for CheckedCodeFromStrings { + fn ensure_can_compile_strings(&self, _: Realm, parameters: &[JsString], body: &JsString, _: bool, _: &mut Context) -> JsResult<()> { + let units = parameters.iter().chain(std::iter::once(body)).fold(0usize, |n, s| n.saturating_add(s.len())); + if units > MAX_SCRIPT_BYTES { + return Err(JsNativeError::range().with_message("the code is too long to compile").into()); + } + // As one text: brackets left open in one parameter carry into the next. + let mut source = parameters.iter().map(JsString::to_std_string_lossy).collect::>().join(","); + source.push('\n'); + source.push_str(&body.to_std_string_lossy()); + match refuse(&source) { + Some(why) => Err(JsNativeError::range().with_message(why).into()), + None => Ok(()), + } + } +} + +/// How [`Outcome::doc_errors`] names a failing document script: by its 1-based place. +fn doc_error(index: usize, error: &dyn std::fmt::Display) -> String { + format!("document script {}: {error}", index.saturating_add(1)) +} + +/// `doc_scripts` are the document scripts to run with their places among all of them; +/// `skipped` the ones refused before running, with the reason. +fn run_here( + script: &str, + event: &Event, + doc: &DocInfo, + fields: Arc>, + doc_scripts: &[(usize, &str)], + skipped: &[(usize, String)], + limits: Limits, +) -> Outcome { + let mut ctx = match Context::builder().host_hooks(std::rc::Rc::new(NoCodeFromStrings)).build() { + Ok(ctx) => ctx, + Err(e) => { + let error = Some(format!("the script engine could not start: {e}")); + return Outcome { rc: true, value: event.value.clone(), change: event.change.clone(), error, ..Default::default() }; + } + }; + ctx.runtime_limits_mut().set_loop_iteration_limit(limits.loop_iterations); + ctx.runtime_limits_mut().set_recursion_limit(limits.recursion); + ctx.insert_data(RefCell::new(Host { + fields, + changed: BTreeSet::new(), + alerts: Vec::new(), + console: Vec::new(), + requests: Vec::new(), + doc: doc.clone(), + kept: 0, + dropped: 0, + })); + let mut out = Outcome { rc: true, value: event.value.clone(), change: event.change.clone(), ..Default::default() }; + let mut doc_errors: Vec<(usize, String)> = skipped.iter().map(|(i, why)| (*i, doc_error(*i, why))).collect(); + let result = (|| -> JsResult<()> { + install(&mut ctx)?; + let ev = event_object(&mut ctx, event); + ctx.register_global_property(js_string!("event"), ev.clone(), Attribute::all())?; + // One document script that throws (calling a method PdfCraft lacks, say) must not keep + // every field script in the form from running: what it defined before the error, and + // the other scripts, stay available (#3940). + for &(i, d) in doc_scripts { + if let Err(e) = ctx.eval(Source::from_bytes(d)) { + doc_errors.push((i, doc_error(i, &e))); + } + } + let r = ctx.eval(Source::from_bytes(script))?; + if !r.is_undefined() { + out.result = Some(text(&r, &mut ctx)?); + } + let rc = ev.get(js_string!("rc"), &mut ctx)?; + out.rc = rc.to_boolean(); + let v = ev.get(js_string!("value"), &mut ctx)?; + out.value = text(&v, &mut ctx)?; + let c = ev.get(js_string!("change"), &mut ctx)?; + out.change = text(&c, &mut ctx)?; + Ok(()) + })(); + if let Err(e) = result { + out.error = Some(e.to_string()); + } + doc_errors.sort_by_key(|(i, _)| *i); + out.doc_errors = doc_errors.into_iter().map(|(_, e)| e).collect(); + if let Some(h) = ctx.remove_data::() { + let h = h.into_inner(); + out.changed = h.changed.iter().map(|i| h.fields[*i].clone()).collect(); + out.alerts = h.alerts; + out.console = h.console; + out.requests = h.requests; + if h.dropped > 0 { + out.console.push(format!("the script's messages, console lines and requests passed their limits; {} more were left out", h.dropped)); + } + } + out +} diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs index 9e96dcd2e3..38cebf54ce 100644 --- a/crates/js/src/tests.rs +++ b/crates/js/src/tests.rs @@ -1017,6 +1017,14 @@ mod formcalc { #[test] fn built_in_functions_cover_the_common_set() { + assert_numeric_and_logical_builtins(); + assert_string_builtins(); + assert_format_and_date_builtins(); + assert_financial_and_unit_builtins(); + assert_builtin_errors(); + } + + fn assert_numeric_and_logical_builtins() { assert_eq!(calc("Abs(-2.5)"), "2.5"); assert_eq!(calc(r#"Decode("a%2Fb", "url")"#), "a/b"); assert_eq!(calc(r#"Decode("a%+fb", "url")"#), "a%+fb"); @@ -1034,6 +1042,9 @@ mod formcalc { assert_eq!(calc(r#"Within(5, 1, 10)"#), "1"); assert_eq!(calc(r#"HasValue("")"#), "0"); assert_eq!(calc(r#"HasValue("x")"#), "1"); + } + + fn assert_string_builtins() { assert_eq!(calc(r#"At("abcd", "c")"#), "3"); assert_eq!(calc(r#"Left("hello", 2)"#), "he"); assert_eq!(calc(r#"Right("hello", 2)"#), "lo"); @@ -1050,6 +1061,11 @@ mod formcalc { assert_eq!(calc(r#"WordNum(42)"#), "Forty-Two"); assert_eq!(calc(r#"WordNum(1.999, 2)"#), "Two Dollars And 00 Cents"); assert_eq!(calc(r#"WordNum(1.01, 2)"#), "One Dollar And 01 Cent"); + assert_eq!(calc(r#"Encode("a b&c", "url")"#), "a%20b%26c"); + assert_eq!(calc(r#"Decode("a%20b", "url")"#), "a b"); + } + + fn assert_format_and_date_builtins() { assert_eq!(calc(r#"Format("zzz,zz9.99", 1234.5)"#), "1,234.50"); assert_eq!(calc(r#"Format("$z,zz9.99", -5)"#), "-$5.00"); assert_eq!(calc(r#"Format("MM/DD/YYYY", "2024-02-29")"#), "02/29/2024"); @@ -1065,6 +1081,9 @@ mod formcalc { assert_eq!(calc(r#"IsoTime2Num("12:30:45.250")"#), "45045250"); assert_eq!(calc(r#"IsoTime2Num("T12:30:45")"#), "45045000"); assert_eq!(calc(r#"Num2Time(3661000)"#), "01:01:01"); + } + + fn assert_financial_and_unit_builtins() { assert_eq!(calc("Round(Pmt(10000, 0.01, 12), 2)"), "888.49"); assert_eq!(calc("Round(FV(100, 0.01, 12), 2)"), "1268.25"); assert_eq!(calc("Round(PV(100, 0.01, 12), 2)"), "1125.51"); @@ -1073,8 +1092,9 @@ mod formcalc { assert_eq!(calc(r#"UnitValue("72points", "in")"#), "1"); assert_eq!(calc(r#"UnitValue("25.4millimeters", "inches")"#), "1"); assert_eq!(calc(r#"UnitValue("1in", "points")"#), "72"); - assert_eq!(calc(r#"Encode("a b&c", "url")"#), "a%20b%26c"); - assert_eq!(calc(r#"Decode("a%20b", "url")"#), "a b"); + } + + fn assert_builtin_errors() { assert!(go(r#"Get("http://example.com")"#, "form1[0].page1[0].total[0]", "calculate").error.unwrap().contains("network")); assert!(go("Nope(1)", "form1[0].page1[0].total[0]", "calculate").error.unwrap().contains("unknown function")); } @@ -1147,6 +1167,13 @@ s"# #[test] fn form_objects_resolve_assign_and_record_effects() { + assert_formcalc_reads_fields_and_rows(); + assert_formcalc_reads_runtime_properties(); + assert_formcalc_records_primary_effects(); + assert_formcalc_updates_instances_and_records_requests(); + } + + fn assert_formcalc_reads_fields_and_rows() { // Fields deref to their values; `$` is the current field. assert_eq!(calc("qty * price"), "10"); assert_eq!(calc("$.name"), "total"); @@ -1170,7 +1197,6 @@ s"# assert_eq!(in_row("Sum(row[*].amount)"), "15"); assert_eq!(in_row("row[1].amount"), "5"); assert_eq!(in_row("row[-1].amount"), "10"); - // Past the ends there is no object, so a property of it is an error (as in Acrobat). let past = go("row[+1].amount", "form1[0].page1[0].table[0].row[1].amount[0]", "calculate"); assert!(past.error.unwrap().contains("no object")); assert_eq!(in_row("Exists(row[-5])"), "0"); @@ -1182,12 +1208,18 @@ s"# assert_eq!(calc("table._row.count"), "2"); assert_eq!(calc("Exists(qty) & not Exists(nothing)"), "1"); assert_eq!(calc("HasValue(total)"), "0"); + } + + fn assert_formcalc_reads_runtime_properties() { assert_eq!(calc("$host.numPages"), "3"); assert_eq!(calc("$layout.pageCount()"), "3"); assert_eq!(calc("$event.name"), "calculate"); assert_eq!(calc("xfa.resolveNode(\"page1.price\").rawValue"), "2.5"); assert_eq!(calc("$.border.fill.color.value"), ""); assert!(go("nothing + 1", "form1[0].page1[0].total[0]", "calculate").error.unwrap().contains("unknown name")); + } + + fn assert_formcalc_records_primary_effects() { // Assignment to a field (by name and by path), a property and a variable. let o = go( r#"page1.qty = 6 @@ -1215,7 +1247,9 @@ table._row.count"#, XfaEffect::AddInstance { som: "form1[0].page1[0].table[0].row[2]".into() }, ] ); - // Instance managers: remove, setInstances, count assignment, and the floor of one. + } + + fn assert_formcalc_updates_instances_and_records_requests() { let o = go( "table._row.removeInstance(0)\ntable._row.setInstances(1)\ntable._row.count = 0\ntable._row.count", "form1[0].page1[0].go[0]", @@ -1238,7 +1272,7 @@ $host.gotoURL("https://example.org")"#, ); assert_eq!( o.effects, - vec![XfaEffect::ResetData(vec!["qty".into(), "price".into()]), XfaEffect::Print, XfaEffect::LaunchUrl("https://example.org".into())] + vec![XfaEffect::ResetData(vec!["qty".into(), "price".into()]), XfaEffect::Print, XfaEffect::LaunchUrl("https://example.org".into()),] ); } @@ -1279,58 +1313,79 @@ $host.gotoURL("https://example.org")"#, #[test] fn hostile_formcalc_scripts_end_with_an_error_and_bounded_output() { - let total = "form1[0].page1[0].total[0]"; - let fast = Limits { loop_iterations: 10_000, recursion: 32 }; - let ev = XfaEvent { activity: "calculate".into(), target: total.into(), ..Default::default() }; - let doc = XfaDoc::default(); - let run = |s: &str| run_formcalc(s, &ev, &doc, &form(), fast); - assert!(run("while (1) do endwhile").error.unwrap().contains("looped")); - assert!(run("for i = 1 upto 1000000 do endfor").error.unwrap().contains("looped")); - assert!(run("func f(n) do f(n + 1) endfunc\nf(1)").error.unwrap().contains("nested")); - assert!(run(&format!("{}1{}", "(".repeat(500), ")".repeat(500))).error.is_some()); + assert_hostile_control_flow_is_bounded(); + assert_hostile_numeric_inputs_are_safe(); + assert_hostile_outputs_are_bounded(); + assert_hostile_nesting_is_bounded(); + assert_hostile_deadlines_and_parse_errors(); + assert_hostile_instance_growth_is_bounded(); + assert_hostile_message_count_is_bounded(); + assert_hostile_small_stack_is_safe(); + } + + fn assert_hostile_control_flow_is_bounded() { + assert!(run_hostile_formcalc("while (1) do endwhile").error.unwrap().contains("looped")); + assert!(run_hostile_formcalc("for i = 1 upto 1000000 do endfor").error.unwrap().contains("looped")); + assert!(run_hostile_formcalc("func f(n) do f(n + 1) endfunc\nf(1)").error.unwrap().contains("nested")); + assert!(run_hostile_formcalc(&format!("{}1{}", "(".repeat(500), ")".repeat(500))).error.is_some()); // A 120k-term chain is refused at parse time (its tree would be as deep as it is long). - assert!(run(&format!("1{}", "+1".repeat(120_000))).error.unwrap().contains("too long")); - assert!(run(&format!("$form{}", ".a".repeat(120_000))).error.unwrap().contains("too long")); - assert!(run("nothing.x = 1").error.unwrap().contains("unknown name")); - assert!(run("$form.nothing = 1").error.unwrap().contains("no nothing")); + assert!(run_hostile_formcalc(&format!("1{}", "+1".repeat(120_000))).error.unwrap().contains("too long")); + assert!(run_hostile_formcalc(&format!("$form{}", ".a".repeat(120_000))).error.unwrap().contains("too long")); + assert!(run_hostile_formcalc("nothing.x = 1").error.unwrap().contains("unknown name")); + assert!(run_hostile_formcalc("$form.nothing = 1").error.unwrap().contains("no nothing")); // Values go into fields only: a subform or the form itself is refused, nothing is recorded. - let o = run("details = \"junk\""); + let o = run_hostile_formcalc("details = \"junk\""); assert!(o.error.unwrap().contains("not a field")); assert!(o.effects.is_empty()); - assert!(run("$form = \"junk\"").error.unwrap().contains("not a field")); + assert!(run_hostile_formcalc("$form = \"junk\"").error.unwrap().contains("not a field")); + } + + fn assert_hostile_numeric_inputs_are_safe() { // Hostile numbers into the string functions. - assert_eq!(run(r#"Stuff("abc", 2, 1e300, "X")"#).result.as_deref(), Some("aX")); - assert_eq!(run(r#"Stuff("abc", -1e300, 1, "X")"#).result.as_deref(), Some("Xbc")); - assert_eq!(run(r#"Substr("abc", 1e300, 1e300)"#).result.as_deref(), Some("")); - assert_eq!(run(r#"Left("abc", -1e300)"#).result.as_deref(), Some("")); - assert!(run(r#"Str(1e300, 1e300, 1e300)"#).error.is_none()); - assert!(run(r#"Round(1e300, 1e300)"#).error.is_none()); - assert!(run(r#"Choose(1e300, 1)"#).error.is_none()); + assert_eq!(run_hostile_formcalc(r#"Stuff("abc", 2, 1e300, "X")"#).result.as_deref(), Some("aX")); + assert_eq!(run_hostile_formcalc(r#"Stuff("abc", -1e300, 1, "X")"#).result.as_deref(), Some("Xbc")); + assert_eq!(run_hostile_formcalc(r#"Substr("abc", 1e300, 1e300)"#).result.as_deref(), Some("")); + assert_eq!(run_hostile_formcalc(r#"Left("abc", -1e300)"#).result.as_deref(), Some("")); + assert!(run_hostile_formcalc(r#"Str(1e300, 1e300, 1e300)"#).error.is_none()); + assert!(run_hostile_formcalc(r#"Round(1e300, 1e300)"#).error.is_none()); + assert!(run_hostile_formcalc(r#"Choose(1e300, 1)"#).error.is_none()); + } + + fn assert_hostile_outputs_are_bounded() { // Strings can't grow past the cap through any function, and messages and values are clipped. assert!( - run("var s = Space(1000000)\nfor i = 1 upto 10 do s = Encode(Replace(s, \" \", \"&\"), \"html\") endfor\nLen(s)") + run_hostile_formcalc("var s = Space(1000000)\nfor i = 1 upto 10 do s = Encode(Replace(s, \" \", \"&\"), \"html\") endfor\nLen(s)") .error .unwrap() .contains("too long") ); - assert!(run("Upper(Concat(Space(600000), Space(600000)))").error.unwrap().contains("too long")); - let o = run("$host.messageBox(Space(100000))"); + assert!(run_hostile_formcalc("Upper(Concat(Space(600000), Space(600000)))").error.unwrap().contains("too long")); + let o = run_hostile_formcalc("$host.messageBox(Space(100000))"); assert!(matches!(o.effects.as_slice(), [XfaEffect::MessageBox(m)] if m.chars().count() <= 4100), "{:?}", o.effects.len()); - let o = run("qty = Space(100000)"); + let o = run_hostile_formcalc("qty = Space(100000)"); assert!(matches!(o.effects.as_slice(), [XfaEffect::SetValue { value, .. }] if value.chars().count() == 65_536), "{:?}", o.effects.len()); assert!(o.notes.iter().any(|n| n.contains("cut")), "{:?}", o.notes); + } + + fn assert_hostile_nesting_is_bounded() { // A comment of dashes is not deep nesting. - assert_eq!(run(&format!("; {}\n1 + 1", "-".repeat(70))).result.as_deref(), Some("2")); + assert_eq!(run_hostile_formcalc(&format!("; {}\n1 + 1", "-".repeat(70))).result.as_deref(), Some("2")); // Parentheses don't reset the chain count: a tree is never deeper than the cap. let mut nested = "1+1+1+1+1+1+1+1+1+1".to_string(); for _ in 0..30 { nested = format!("({nested}{})", "+1".repeat(40)); } - assert!(run(&nested).error.unwrap().contains("too long")); - assert_eq!(run("(1+2)*(3+4) + (5+6)").result.as_deref(), Some("32")); + assert!(run_hostile_formcalc(&nested).error.unwrap().contains("too long")); + assert_eq!(run_hostile_formcalc("(1+2)*(3+4) + (5+6)").result.as_deref(), Some("32")); // Nested statements inside recursion count against the depth limit. let deep = format!("func f(n) do\n{}return f(n - 1)\n{}endfunc\nf(1000)", "if (n > 0) then\n".repeat(20), "endif\n".repeat(20)); - assert!(run(&deep).error.unwrap().contains("nested")); + assert!(run_hostile_formcalc(&deep).error.unwrap().contains("nested")); + } + + fn assert_hostile_deadlines_and_parse_errors() { + let total = "form1[0].page1[0].total[0]"; + let ev = XfaEvent { activity: "calculate".into(), target: total.into(), ..Default::default() }; + let doc = XfaDoc::default(); // A deadline already passed stops the script at its first look at the clock. let o = crate::formcalc::run_formcalc_at( "for i = 1 upto 100000 do Eval(\"1\") endfor", @@ -1341,29 +1396,40 @@ $host.gotoURL("https://example.org")"#, Some(std::time::Duration::ZERO), ); assert!(o.error.unwrap().contains("too long")); - assert!(run(&format!("{}1{}", "-".repeat(5000), "")).error.is_some()); - assert!(run("var s = \"x\"\nfor i = 1 upto 100 do s = Concat(s, s) endfor\nLen(s)").error.unwrap().contains("too long")); - assert!(run("Space(1e12)").error.is_none()); - assert!(run("Replace(Space(100000), \" \", Space(100000))").error.unwrap().contains("too long")); - assert_eq!(run(r#"Format("YYYY-MM-DD", 1e300)"#).error, None); - assert_eq!(run(r#"Date2Num("99999999999999999999-01-01", "YYYYYYYYYYYYYYYYYYYY-MM-DD")"#).error, None); - assert_eq!(run(r#"Time2Num("99999999999999999999:0:0")"#).error, None); - assert_eq!(run(r#"Num2Date(1e300, "YYYY")"#).result.as_deref(), Some("")); - assert!(run("for i = 1 upto 1000 do nothing endfor").error.unwrap().contains("unknown name")); - assert!(run("\"unterminated").error.unwrap().contains("unterminated")); - assert!(run("1 ) 2").error.is_some()); - assert!(run("Eval(\"Eval(\"\"Eval(\"\"\"\"1\"\"\"\")\"\")\")").error.is_none()); + assert!(run_hostile_formcalc(&format!("{}1{}", "-".repeat(5000), "")).error.is_some()); + assert!(run_hostile_formcalc("var s = \"x\"\nfor i = 1 upto 100 do s = Concat(s, s) endfor\nLen(s)").error.unwrap().contains("too long")); + assert!(run_hostile_formcalc("Space(1e12)").error.is_none()); + assert!(run_hostile_formcalc("Replace(Space(100000), \" \", Space(100000))").error.unwrap().contains("too long")); + assert_eq!(run_hostile_formcalc(r#"Format("YYYY-MM-DD", 1e300)"#).error, None); + assert_eq!(run_hostile_formcalc(r#"Date2Num("99999999999999999999-01-01", "YYYYYYYYYYYYYYYYYYYY-MM-DD")"#).error, None); + assert_eq!(run_hostile_formcalc(r#"Time2Num("99999999999999999999:0:0")"#).error, None); + assert_eq!(run_hostile_formcalc(r#"Num2Date(1e300, "YYYY")"#).result.as_deref(), Some("")); + assert!(run_hostile_formcalc("for i = 1 upto 1000 do nothing endfor").error.unwrap().contains("unknown name")); + assert!(run_hostile_formcalc("\"unterminated").error.unwrap().contains("unterminated")); + assert!(run_hostile_formcalc("1 ) 2").error.is_some()); + assert!(run_hostile_formcalc("Eval(\"Eval(\"\"Eval(\"\"\"\"1\"\"\"\")\"\")\")").error.is_none()); + } + + fn assert_hostile_instance_growth_is_bounded() { // Adding rows forever stops at the instance cap with the effects it managed. - let o = run("for i = 1 upto 100000 do table._row.addInstance(1) endfor\ntable._row.count"); + let o = run_hostile_formcalc("for i = 1 upto 100000 do table._row.addInstance(1) endfor\ntable._row.count"); assert!(o.error.unwrap().contains("looped")); assert!(o.effects.iter().filter(|e| matches!(e, XfaEffect::AddInstance { .. })).count() <= 1000, "{}", o.effects.len()); - let o = run("for i = 1 upto 5000 do table._row.addInstance(1) endfor\ntable._row.count"); + let o = run_hostile_formcalc("for i = 1 upto 5000 do table._row.addInstance(1) endfor\ntable._row.count"); assert_eq!(o.error, None); assert_eq!(o.result.as_deref(), Some("1002")); + } + + fn assert_hostile_message_count_is_bounded() { // Message boxes past the cap are dropped and noted. - let o = run("for i = 1 upto 500 do $host.messageBox(i) endfor"); + let o = run_hostile_formcalc("for i = 1 upto 500 do $host.messageBox(i) endfor"); assert_eq!(o.effects.iter().filter(|e| matches!(e, XfaEffect::MessageBox(_))).count(), 100); assert!(!o.notes.is_empty()); + } + + fn assert_hostile_small_stack_is_safe() { + let ev = XfaEvent { activity: "calculate".into(), target: "form1[0].page1[0].total[0]".into(), ..Default::default() }; + let doc = XfaDoc::default(); // On a small stack (the wasm build's), the limits stop the script before the stack does. let small = std::thread::Builder::new() .stack_size(1 << 20) @@ -1400,6 +1466,13 @@ $host.gotoURL("https://example.org")"#, ); assert!(o.abandoned || o.error.is_some(), "{o:?}"); } + + fn run_hostile_formcalc(script: &str) -> XfaOutcome { + let ev = XfaEvent { activity: "calculate".into(), target: "form1[0].page1[0].total[0]".into(), ..Default::default() }; + let doc = XfaDoc::default(); + let fast = Limits { loop_iterations: 10_000, recursion: 32 }; + run_formcalc(script, &ev, &doc, &form(), fast) + } } mod session { diff --git a/crates/js/src/xfa.rs b/crates/js/src/xfa.rs index 42b1688ed5..cddf8f2eb2 100644 --- a/crates/js/src/xfa.rs +++ b/crates/js/src/xfa.rs @@ -17,1639 +17,20 @@ use boa_engine::{Context, JsObject, JsResult, JsString, JsValue, NativeFunction, use crate::{Limits, arg, as_number, error, function, platform, printd, printf, printx, refuse, s, scand, text}; -/// What kind of template object a node is. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum XfaKind { - /// `xfa.form` itself. - Form, - Subform, - Field, - ExclGroup, - Draw, - Area, -} - -impl XfaKind { - pub(crate) fn class_name(self) -> &'static str { - match self { - XfaKind::Form => "form", - XfaKind::Subform => "subform", - XfaKind::Field => "field", - XfaKind::ExclGroup => "exclGroup", - XfaKind::Draw => "draw", - XfaKind::Area => "area", - } - } -} - -/// One object of the form, as the caller snapshots it. -#[derive(Clone, Debug, Default, PartialEq)] -pub struct XfaNode { - pub name: String, - /// The SOM expression naming this instance (`form[0].head[0].familyName[0]`). - pub som: String, - pub kind: Option, - /// Fields: the raw value (ISO dates, on values for buttons); empty when null. - pub value: String, - /// Numeric fields give numbers to scripts. - pub numeric: bool, - /// `visible`, `invisible`, `hidden` or `inactive`. - pub presence: String, - /// `open`, `readOnly`, `protected` or `nonInteractive`. - pub access: String, - /// Subforms: may have several instances (`occur max` other than 1). - pub repeatable: bool, - pub occur_min: usize, - pub occur_max: Option, - /// This instance's index among its same-named siblings. - pub index: usize, - pub children: Vec, - /// Fields: the value shown in the field's display picture, what `formattedValue` reads; - /// `None` when the picture doesn't change it (the raw value is read then). - pub formatted: Option, -} - -/// The event a script runs for. -#[derive(Clone, Debug, Default, PartialEq)] -pub struct XfaEvent { - /// `click`, `calculate`, `validate`, `initialize`, `change`, `exit`, … - pub activity: String, - /// The SOM of `this`. - pub target: String, - pub new_text: String, - pub prev_text: String, -} - -/// About the document. -#[derive(Clone, Debug, Default, PartialEq)] -pub struct XfaDoc { - pub file_name: String, - /// The current page, 0-based. - pub page: usize, - pub page_count: usize, -} - -/// Something a script changed or asked for. -#[derive(Clone, Debug, PartialEq)] -pub enum XfaEffect { - SetValue { - som: String, - value: String, - }, - SetPresence { - som: String, - presence: String, - }, - SetAccess { - som: String, - access: String, - }, - /// A new instance of a repeating subform, named by its own SOM (`…row[3]`). - AddInstance { - som: String, - }, - /// The instance at this SOM is removed. - RemoveInstance { - som: String, - }, - MessageBox(String), - /// `xfa.host.resetData()`: the named fields, or every field when empty. - ResetData(Vec), - /// `app.execMenuItem("ShowHide…")`: show or hide a side panel. - ShowPanel(crate::Panel), - Print, - SaveAs, - LaunchUrl(String), - SetFocus(String), - Beep, - /// `xfa.form.recalculate(true)`. - Recalculate, - /// `xfa.form.remerge()`, `xfa.layout.relayout()`. - Relayout, -} - -/// What running a script did. -#[derive(Clone, Debug, Default, PartialEq)] -pub struct XfaOutcome { - pub effects: Vec, - pub console: Vec, - /// The script's error, if it failed. - pub error: Option, - /// The script's completion value (a calculate script's result; a validate script's verdict). - pub result: Option, - /// The completion value as a boolean, when it was one. - pub result_bool: Option, - /// Limits the script ran into (too many effects, message boxes or console lines): what - /// was left out, for the caller to report. - pub notes: Vec, - /// The script ran past its time limit and was left running on its own thread (the engine - /// can't interrupt it): the caller should run no more of this form's scripts. - pub abandoned: bool, -} - -/// Most nodes a snapshot may hold. -const MAX_NODES: usize = 200_000; -/// Most instances a script may add in one run. -pub(crate) const MAX_ADDED: usize = 1_000; -/// Deepest tree followed. -const MAX_DEPTH: usize = 64; -/// Most effects one run may produce (after values set again on the same object are merged). -pub const MAX_EFFECTS: usize = 10_000; -/// Most message boxes one run may show. -pub const MAX_ALERTS: usize = 100; -/// Most console lines one run may print. -pub const MAX_CONSOLE: usize = 1_000; -/// Longest message or console line kept, in characters. -pub(crate) const MAX_LINE: usize = 4_096; -/// Most bytes of text one run may hand back or write: messages, console lines, URLs, names -/// and the values it sets. Past it, more are left out and noted. -pub(crate) const MAX_PAYLOAD: usize = 32 << 20; - -/// Longest value a script may put in a field (characters); longer ones are cut and noted. -const MAX_VALUE: usize = 65_536; - -/// `m` cut to [`MAX_LINE`] characters. -pub(crate) fn clip(m: String) -> String { - match m.char_indices().nth(MAX_LINE) { - Some((at, _)) => format!("{}…", m.get(..at).unwrap_or_default()), - None => m, - } -} - -/// `v` cut to [`MAX_VALUE`] characters, and whether it was cut. -pub(crate) fn clip_value(v: String) -> (String, bool) { - match v.char_indices().nth(MAX_VALUE) { - Some((at, _)) => (v.get(..at).unwrap_or_default().to_string(), true), - None => (v, false), - } -} - -/// The field names of a `resetData` list (comma-separated), each cut to [`MAX_LINE`] and -/// named once, at most as many as a snapshot holds nodes. -pub(crate) fn reset_names(list: &str) -> Vec { - let mut seen = HashSet::new(); - let mut out = Vec::new(); - for name in list.split(',').map(str::trim).filter(|s| !s.is_empty()) { - if out.len() >= MAX_NODES { - break; - } - let name = clip(name.to_string()); - if seen.insert(name.clone()) { - out.push(name); - } - } - out -} - -/// The (property, SOM) an effect setting a property of an object is merged by, see -/// [`XHost::set_at`]. -fn effect_key(e: &XfaEffect) -> Option<(u8, String)> { - match e { - XfaEffect::SetValue { som, .. } => Some((0, som.clone())), - XfaEffect::SetPresence { som, .. } => Some((1, som.clone())), - XfaEffect::SetAccess { som, .. } => Some((2, som.clone())), - _ => None, - } -} - -/// Bytes of text `e` holds. -fn payload(e: &XfaEffect) -> usize { - match e { - XfaEffect::SetValue { som, value: s } | XfaEffect::SetPresence { som, presence: s } | XfaEffect::SetAccess { som, access: s } => { - som.len().saturating_add(s.len()) - } - XfaEffect::AddInstance { som } | XfaEffect::RemoveInstance { som } => som.len(), - XfaEffect::MessageBox(s) | XfaEffect::LaunchUrl(s) | XfaEffect::SetFocus(s) => s.len(), - XfaEffect::ResetData(names) => names.iter().fold(0usize, |n, s| n.saturating_add(s.len()).saturating_add(std::mem::size_of::())), - XfaEffect::Print | XfaEffect::SaveAs | XfaEffect::Beep | XfaEffect::Recalculate | XfaEffect::Relayout | XfaEffect::ShowPanel(_) => 0, - } -} - -/// A script's completion value (what a calculate script puts in its field) cut to -/// [`MAX_VALUE`] characters, as values a script sets are, with a note when it was. -pub(crate) fn clip_result(out: &mut XfaOutcome) { - if let Some(r) = out.result.take() { - let (r, cut) = clip_value(r); - if cut { - out.notes.push(format!("the script's result was longer than {MAX_VALUE} characters; it was cut")); - } - out.result = Some(r); - } -} - -/// One node of the flattened form, shared by the JavaScript and FormCalc engines. -pub(crate) struct HNode { - pub(crate) name: String, - pub(crate) som: String, - pub(crate) kind: XfaKind, - pub(crate) value: String, - pub(crate) numeric: bool, - pub(crate) presence: String, - pub(crate) access: String, - pub(crate) repeatable: bool, - pub(crate) occur_min: usize, - pub(crate) occur_max: Option, - pub(crate) index: usize, - pub(crate) parent: Option, - pub(crate) children: Vec, - /// Removed by `removeInstance`: skipped everywhere. - pub(crate) gone: bool, - /// See [`XfaNode::formatted`]; dropped when a script sets the value, which it no longer shows. - pub(crate) formatted: Option, -} - -/// The form as a script sees and changes it, with the effects it records. -pub(crate) struct XHost { - pub(crate) nodes: Vec, - pub(crate) effects: Vec, - pub(crate) console: Vec, - pub(crate) doc: XfaDoc, - added: usize, - /// Index of `this`. - pub(crate) current: usize, - /// Where the effect setting a property of an object is in `effects`, by (property, SOM): - /// setting it again replaces that effect (the last value wins) unless something that - /// depends on order (rows added or removed, a reset) came after it. - set_at: HashMap<(u8, String), usize>, - /// Effects before this index are behind an order-dependent effect. - barrier: usize, - alerts: usize, - dropped_effects: usize, - clipped_values: usize, - dropped_alerts: usize, - dropped_console: usize, - /// Bytes of text kept in effects, console lines and values written, against [`MAX_PAYLOAD`]. - kept: usize, - dropped_bytes: usize, -} - -impl XHost { - /// The host for a script on `root` (the template's root subform), with `this` at the node - /// named by `target`. `xfa.form` is a synthetic root whose child is the root subform. - pub(crate) fn new(root: XfaNode, target: &str, doc: &XfaDoc) -> XHost { - let form = XfaNode { name: "form".into(), kind: Some(XfaKind::Form), children: vec![root], ..Default::default() }; - let mut nodes = Vec::new(); - flatten(&form, None, &mut nodes, 0); - let current = nodes.iter().position(|n| n.som == target && n.kind != XfaKind::Form).unwrap_or(1.min(nodes.len().saturating_sub(1))); - XHost { - nodes, - effects: Vec::new(), - console: Vec::new(), - doc: doc.clone(), - added: 0, - current, - set_at: HashMap::new(), - barrier: 0, - alerts: 0, - dropped_effects: 0, - clipped_values: 0, - dropped_alerts: 0, - dropped_console: 0, - kept: 0, - dropped_bytes: 0, - } - } - - /// Room for `bytes` more bytes of kept text: charged when there is, a drop counted when not. - fn room(&mut self, bytes: usize) -> bool { - match self.kept.checked_add(bytes).filter(|k| *k <= MAX_PAYLOAD) { - Some(k) => { - self.kept = k; - true - } - None => { - self.dropped_bytes = self.dropped_bytes.saturating_add(1); - false - } - } - } - - /// Record an effect: values, presence and access set again on the same object replace the - /// earlier one; past [`MAX_EFFECTS`] (or [`MAX_ALERTS`] message boxes, or [`MAX_PAYLOAD`] - /// bytes of text) effects are counted and dropped. - pub(crate) fn push(&mut self, e: XfaEffect) { - let e = self.clip_effect(e); - if self.room(payload(&e)) { - self.record(e); - } - } - - /// Room for `e`, which holds `bytes` of text, charged now: a change to the snapshot is made - /// only when the effect that applies it will be kept, so a script never sees a change its - /// effects leave out. An effect that replaces an earlier one needs no new slot. A drop is - /// counted when there is no room. - fn reserve(&mut self, e: &XfaEffect, bytes: usize) -> bool { - let replaces = effect_key(e).and_then(|k| self.set_at.get(&k)).is_some_and(|&at| at >= self.barrier && at < self.effects.len()); - if !replaces && self.effects.len() >= MAX_EFFECTS { - self.dropped_effects += 1; - return false; - } - self.room(bytes) - } - - /// Set the presence or access of node `i` to what `e` (a [`XfaEffect::SetPresence`] or - /// [`XfaEffect::SetAccess`]) says, in the snapshot only when the effect is kept. - pub(crate) fn set_state(&mut self, i: usize, e: XfaEffect) { - if !self.reserve(&e, payload(&e)) { - return; - } - if let Some(n) = self.nodes.get_mut(i) { - match &e { - XfaEffect::SetPresence { presence, .. } => n.presence = presence.clone(), - XfaEffect::SetAccess { access, .. } => n.access = access.clone(), - _ => {} - } - } - self.record(e); - } - - /// `e` with its text cut to the lengths an effect keeps. - fn clip_effect(&mut self, e: XfaEffect) -> XfaEffect { - match e { - XfaEffect::MessageBox(m) => XfaEffect::MessageBox(clip(m)), - XfaEffect::LaunchUrl(u) => XfaEffect::LaunchUrl(clip(u)), - XfaEffect::SetFocus(s) => XfaEffect::SetFocus(clip(s)), - XfaEffect::ResetData(names) => XfaEffect::ResetData(names.into_iter().map(clip).collect()), - XfaEffect::SetValue { som, value } => { - let (value, cut) = clip_value(value); - if cut { - self.clipped_values += 1; - } - XfaEffect::SetValue { som, value } - } - other => other, - } - } - - /// Keep `e`, whose bytes are already charged, against the count limits. - fn record(&mut self, e: XfaEffect) { - if let Some(key) = effect_key(&e) { - if let Some(&at) = self.set_at.get(&key) - && at >= self.barrier - && let Some(slot) = self.effects.get_mut(at) - { - *slot = e; - return; - } - if self.effects.len() >= MAX_EFFECTS { - self.dropped_effects += 1; - return; - } - self.set_at.insert(key, self.effects.len()); - self.effects.push(e); - return; - } - if matches!(e, XfaEffect::MessageBox(_)) { - if self.alerts >= MAX_ALERTS { - self.dropped_alerts += 1; - return; - } - self.alerts += 1; - } - if self.effects.len() >= MAX_EFFECTS { - self.dropped_effects += 1; - return; - } - if matches!(e, XfaEffect::AddInstance { .. } | XfaEffect::RemoveInstance { .. } | XfaEffect::ResetData(_)) { - self.barrier = self.effects.len() + 1; - } - self.effects.push(e); - } - - pub(crate) fn print(&mut self, line: String) { - if self.console.len() >= MAX_CONSOLE { - self.dropped_console += 1; - } else if self.room(line.len()) { - self.console.push(line); - } - } - - /// What the limits left out. - pub(crate) fn notes(&self) -> Vec { - let mut out = Vec::new(); - if self.dropped_effects > 0 { - out.push(format!("the script made more than {MAX_EFFECTS} changes; {} more were left out", self.dropped_effects)); - } - if self.dropped_alerts > 0 { - out.push(format!("the script showed more than {MAX_ALERTS} messages; {} more were left out", self.dropped_alerts)); - } - if self.dropped_console > 0 { - out.push(format!("the script printed more than {MAX_CONSOLE} console lines; {} more were left out", self.dropped_console)); - } - if self.dropped_bytes > 0 { - out.push(format!("the script's changes and output passed {} MiB of text; {} more were left out", MAX_PAYLOAD >> 20, self.dropped_bytes)); - } - if self.clipped_values > 0 { - out.push(format!("the script set {} value(s) longer than {MAX_VALUE} characters; they were cut", self.clipped_values)); - } - out - } - - /// Give node `i` the value `text` when it is a field or exclusion group (nothing else - /// holds a value; writing one into a subform would put text where another viewer expects - /// a data group). Returns whether it was. - pub(crate) fn set_value(&mut self, i: usize, text: String) -> bool { - let Some(n) = self.nodes.get(i) else { return false }; - if n.gone || !matches!(n.kind, XfaKind::Field | XfaKind::ExclGroup) { - return false; - } - let som = n.som.clone(); - // Cut here, not only in the effect: later reads in the script see what is saved, and - // the snapshot stays bounded however often a long value is written. - let (text, cut) = clip_value(text); - if cut { - self.clipped_values += 1; - } - // The snapshot's copy and the effect's are charged together, before either is kept: - // past the byte budget or the effect cap the write is left out of both. - let effect = XfaEffect::SetValue { som, value: text.clone() }; - if !self.reserve(&effect, text.len().saturating_add(payload(&effect))) { - return true; - } - if let Some(n) = self.nodes.get_mut(i) { - n.value = text; - n.formatted = None; - } - self.record(effect); - true - } -} - -type Shared = RefCell; - -/// The form host of the script running in `ctx`. -fn host(ctx: &Context) -> JsResult<&Shared> { - ctx.get_data::().ok_or_else(|| error("the XFA script host is not set up")) -} - -/// The snapshot `n` and its descendants flattened into `out` under `parent` (to [`MAX_DEPTH`] -/// and [`MAX_NODES`]): the index of `n`'s node, `None` past the limits. -fn flatten(n: &XfaNode, parent: Option, out: &mut Vec, depth: usize) -> Option { - if depth > MAX_DEPTH || out.len() >= MAX_NODES { - return None; - } - let idx = out.len(); - out.push(HNode { - name: n.name.clone(), - som: n.som.clone(), - kind: n.kind.unwrap_or(XfaKind::Subform), - value: n.value.clone(), - numeric: n.numeric, - presence: if n.presence.is_empty() { "visible".into() } else { n.presence.clone() }, - access: if n.access.is_empty() { "open".into() } else { n.access.clone() }, - repeatable: n.repeatable, - occur_min: n.occur_min, - occur_max: n.occur_max, - index: n.index, - parent, - children: Vec::new(), - gone: false, - formatted: n.formatted.clone(), - }); - let mut kids = Vec::new(); - for c in &n.children { - if let Some(ci) = flatten(c, Some(idx), out, depth + 1) { - kids.push(ci); - } - } - if let Some(h) = out.get_mut(idx) { - h.children = kids; - } - Some(idx) -} - -// ── node objects ──────────────────────────────────────────────────────────────────────────── - -const IDX: &str = "__xfaNode"; - -fn node_index(target: &JsValue, ctx: &mut Context) -> JsResult { - let o = target.as_object().ok_or_else(|| error("not an XFA node"))?; - let v = o.get(JsString::from(IDX), ctx)?; - let n = v.to_number(ctx)?; - if !n.is_finite() || n < 0.0 { - return Err(error("not an XFA node")); - } - Ok(n as usize) -} - -/// A Proxy standing for node `idx`. -fn node_object(ctx: &mut Context, idx: usize) -> JsResult { - let target = ObjectInitializer::new(ctx).property(JsString::from(IDX), JsValue::from(idx as f64), Attribute::READONLY).build(); - let proxy = JsProxy::builder(target).get(node_get).set(node_set).has(node_has).build(ctx)?; - Ok(proxy.into()) -} - -/// An object that takes any property read or write without complaint (`this.border.fill…`, -/// `this.fillColor = …`): what a script sets there changes nothing here. -fn sink(ctx: &mut Context) -> JsResult { - let target = ObjectInitializer::new(ctx).build(); - let proxy = JsProxy::builder(target).get(sink_get).set(sink_set).build(ctx)?; - Ok(proxy.into()) -} - -fn sink_get(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { - let key = arg(args, 1); - if key.as_symbol().is_some() { - return Ok(JsValue::undefined()); - } - match text(&key, ctx)?.as_str() { - "value" | "toString" | "valueOf" => Ok(s("")), - _ => sink(ctx), - } -} - -fn sink_set(_: &JsValue, _: &[JsValue], _: &mut Context) -> JsResult { - Ok(JsValue::from(true)) -} - -fn node_has(_: &JsValue, _: &[JsValue], _: &mut Context) -> JsResult { - Ok(JsValue::from(true)) -} - -fn value_js(h: &HNode) -> JsValue { - if h.value.is_empty() { - return JsValue::null(); - } - if h.numeric - && let Some(n) = as_number(&h.value) - { - return JsValue::from(n); - } - s(&h.value) -} - -/// `(idx, name)` of the child of `parent` named `name`, first instance. -pub(crate) fn child_named(h: &XHost, parent: usize, name: &str) -> Option { - let p = h.nodes.get(parent)?; - p.children.iter().copied().find(|&c| h.nodes.get(c).is_some_and(|n| !n.gone && n.name == name)) -} - -/// Every live instance named `name` under `parent`, in order. -pub(crate) fn instances(h: &XHost, parent: usize, name: &str) -> Vec { - h.nodes - .get(parent) - .map(|p| p.children.iter().copied().filter(|&c| h.nodes.get(c).is_some_and(|n| !n.gone && n.name == name)).collect()) - .unwrap_or_default() -} - -fn list_object(ctx: &mut Context, items: &[usize]) -> JsResult { - let vals: Vec = items.iter().map(|i| JsValue::from(*i as f64)).collect(); - let arr = JsArray::from_iter(vals, ctx); - let arr_obj: JsObject = arr.into(); - fn item(_: &JsValue, args: &[JsValue], arr: &JsObject, ctx: &mut Context) -> JsResult { - let i = arg(args, 0).to_number(ctx)?; - if !i.is_finite() || i < 0.0 { - return Ok(JsValue::null()); - } - let v = JsArray::from_object(arr.clone())?.get(i as u64, ctx)?; - if v.is_undefined() { - return Ok(JsValue::null()); - } - let idx = v.to_number(ctx)? as usize; - node_object(ctx, idx) - } - let item_fn = function(ctx, NativeFunction::from_copy_closure_with_captures(item, arr_obj)); - Ok(ObjectInitializer::new(ctx) - .property(js_string!("length"), JsValue::from(items.len() as f64), Attribute::READONLY) - .property(js_string!("item"), item_fn, Attribute::all()) - .build() - .into()) -} - -/// The instance manager of the subform named `name` under `parent`. -fn instance_manager(ctx: &mut Context, parent: usize, name: &str) -> JsResult { - let (count, min, max) = { - let h = host(ctx)?.borrow(); - let live = instances(&h, parent, name); - let first = live.first().and_then(|i| h.nodes.get(*i)); - (live.len(), first.map_or(0, |n| n.occur_min), first.and_then(|n| n.occur_max)) - }; - let key = ObjectInitializer::new(ctx) - .property(js_string!("parent"), JsValue::from(parent as f64), Attribute::READONLY) - .property(js_string!("name"), s(name), Attribute::READONLY) - .build(); - fn target_of(key: &JsObject, ctx: &mut Context) -> JsResult<(usize, String)> { - let p = key.get(js_string!("parent"), ctx)?.to_number(ctx)? as usize; - let n = text(&key.get(js_string!("name"), ctx)?, ctx)?; - Ok((p, n)) - } - fn add_instance(_: &JsValue, _: &[JsValue], key: &JsObject, ctx: &mut Context) -> JsResult { - let (p, n) = target_of(key, ctx)?; - match add_instance_of(ctx, p, &n)? { - Some(i) => node_object(ctx, i), - None => Ok(JsValue::null()), - } - } - fn insert_instance(this: &JsValue, args: &[JsValue], key: &JsObject, ctx: &mut Context) -> JsResult { - add_instance(this, args, key, ctx) - } - fn remove_instance(_: &JsValue, args: &[JsValue], key: &JsObject, ctx: &mut Context) -> JsResult { - let (p, n) = target_of(key, ctx)?; - let i = arg(args, 0).to_number(ctx)?; - if i.is_finite() && i >= 0.0 { - remove_instance_of(ctx, p, &n, i as usize)?; - } - Ok(JsValue::undefined()) - } - fn set_instances(_: &JsValue, args: &[JsValue], key: &JsObject, ctx: &mut Context) -> JsResult { - let (p, n) = target_of(key, ctx)?; - let want = arg(args, 0).to_number(ctx)?; - if !want.is_finite() || want < 0.0 { - return Ok(JsValue::undefined()); - } - host(ctx)?.borrow_mut().set_instances(p, &n, want as usize); - Ok(JsValue::undefined()) - } - fn move_instance(_: &JsValue, _: &[JsValue], _: &JsObject, _: &mut Context) -> JsResult { - Ok(JsValue::undefined()) - } - fn count_get(_: &JsValue, _: &[JsValue], key: &JsObject, ctx: &mut Context) -> JsResult { - let (p, n) = target_of(key, ctx)?; - let c = instances(&host(ctx)?.borrow(), p, &n).len(); - Ok(JsValue::from(c as f64)) - } - fn count_set(_: &JsValue, args: &[JsValue], key: &JsObject, ctx: &mut Context) -> JsResult { - set_instances(&JsValue::undefined(), args, key, ctx) - } - let count_g = function(ctx, NativeFunction::from_copy_closure_with_captures(count_get, key.clone())); - let count_s = function(ctx, NativeFunction::from_copy_closure_with_captures(count_set, key.clone())); - let add = function(ctx, NativeFunction::from_copy_closure_with_captures(add_instance, key.clone())); - let insert = function(ctx, NativeFunction::from_copy_closure_with_captures(insert_instance, key.clone())); - let remove = function(ctx, NativeFunction::from_copy_closure_with_captures(remove_instance, key.clone())); - let set = function(ctx, NativeFunction::from_copy_closure_with_captures(set_instances, key.clone())); - let mv = function(ctx, NativeFunction::from_copy_closure_with_captures(move_instance, key)); - let _ = count; - let o = ObjectInitializer::new(ctx) - .property(js_string!("min"), JsValue::from(min as f64), Attribute::READONLY) - .property(js_string!("max"), max.map_or(JsValue::from(-1.0), |m| JsValue::from(m as f64)), Attribute::READONLY) - .property(js_string!("addInstance"), add, Attribute::all()) - .property(js_string!("insertInstance"), insert, Attribute::all()) - .property(js_string!("removeInstance"), remove, Attribute::all()) - .property(js_string!("setInstances"), set, Attribute::all()) - .property(js_string!("moveInstance"), mv, Attribute::all()) - .property(js_string!("className"), s("instanceManager"), Attribute::READONLY) - .build(); - use boa_engine::property::PropertyDescriptor; - o.define_property_or_throw(js_string!("count"), PropertyDescriptor::builder().get(count_g).set(count_s).configurable(true), ctx)?; - Ok(o.into()) -} - -/// Re-derive the SOM of `idx` and everything under it from its parent and index. -fn renumber(h: &mut XHost, idx: usize, depth: usize) { - if depth > MAX_DEPTH { - return; - } - let (parent_som, name, index, kids) = { - let Some(n) = h.nodes.get(idx) else { return }; - let parent_som = n.parent.and_then(|p| h.nodes.get(p)).map(|p| p.som.clone()).unwrap_or_default(); - (parent_som, n.name.clone(), n.index, n.children.clone()) - }; - let som = child_som(parent_som, &name, index); - if let Some(n) = h.nodes.get_mut(idx) { - n.som = som; - } - for k in kids { - renumber(h, k, depth + 1); - } -} - -/// The SOM of instance `index` of the node `name` under a parent at `parent_som`. -fn child_som(parent_som: String, name: &str, index: usize) -> String { - if name.starts_with('#') || name.is_empty() { - parent_som - } else if parent_som.is_empty() { - format!("{name}[{index}]") - } else { - format!("{parent_som}.{name}[{index}]") - } -} - -/// A copy of `src`'s subtree (values cleared) under `parent`, returning the copy's index. -fn clone_subtree(h: &mut XHost, src: usize, parent: Option, depth: usize) -> Option { - if depth > MAX_DEPTH || h.nodes.len() >= MAX_NODES { - return None; - } - let (template, kids) = { - let n = h.nodes.get(src)?; - ( - HNode { - name: n.name.clone(), - som: String::new(), - kind: n.kind, - value: String::new(), - numeric: n.numeric, - presence: n.presence.clone(), - access: n.access.clone(), - repeatable: n.repeatable, - occur_min: n.occur_min, - occur_max: n.occur_max, - index: n.index, - parent, - children: Vec::new(), - gone: false, - // The copy holds no value, so nothing is shown in its picture either. - formatted: None, - }, - n.children.clone(), - ) - }; - let idx = h.nodes.len(); - h.nodes.push(template); - let mut copied = Vec::new(); - for k in kids { - // Nested repeating subforms copy their first instance only. - let skip = h.nodes.get(k).is_some_and(|c| c.gone || c.repeatable && c.index > 0); - if skip { - continue; - } - if let Some(ci) = clone_subtree(h, k, Some(idx), depth + 1) { - copied.push(ci); - } - } - if let Some(n) = h.nodes.get_mut(idx) { - n.children = copied; - } - Some(idx) -} - -fn add_instance_of(ctx: &mut Context, parent: usize, name: &str) -> JsResult> { - let mut h = host(ctx)?.borrow_mut(); - Ok(h.add_instance(parent, name)) -} - -fn remove_instance_of(ctx: &mut Context, parent: usize, name: &str, i: usize) -> JsResult<()> { - let mut h = host(ctx)?.borrow_mut(); - h.remove_instance(parent, name, i); - Ok(()) -} - -impl XHost { - /// A new instance of the subform `name` under `parent`, after the last one; `None` when the - /// subform doesn't repeat, is at its maximum, or the run added its limit. - pub(crate) fn add_instance(&mut self, parent: usize, name: &str) -> Option { - let h = self; - let live = instances(h, parent, name); - let &first = live.first()?; - let (repeatable, max) = h.nodes.get(first).map(|n| (n.repeatable, n.occur_max)).unwrap_or((false, Some(1))); - if !repeatable || max.is_some_and(|m| live.len() >= m) || h.added >= MAX_ADDED || h.nodes.len() >= MAX_NODES { - return None; - } - // The copy's SOM, as `renumber` will give it, so its effect is reserved before the copy. - let name = h.nodes.get(first).map(|n| n.name.clone()).unwrap_or_default(); - let parent_som = h.nodes.get(parent).map(|p| p.som.clone()).unwrap_or_default(); - let som = child_som(parent_som, &name, live.len()); - if !h.reserve(&XfaEffect::AddInstance { som: String::new() }, som.len()) { - return None; - } - let new = clone_subtree(h, first, Some(parent), 0)?; - let last = live.last().copied().unwrap_or(first); - if let Some(n) = h.nodes.get_mut(new) { - n.index = live.len(); - } - // Keep instances together: right after the last one. - if let Some(p) = h.nodes.get_mut(parent) { - let pos = p.children.iter().position(|&c| c == last).map_or(p.children.len(), |i| i + 1); - p.children.insert(pos, new); - } - renumber(h, new, 0); - h.added += 1; - h.record(XfaEffect::AddInstance { som }); - Some(new) - } - - /// Add or remove instances of the subform `name` under `parent` until there are `want`. - /// Bounded: adding stops at the cap or the maximum, removing at the floor, and a step that - /// changes nothing ends the loop. - pub(crate) fn set_instances(&mut self, parent: usize, name: &str, want: usize) { - let want = want.min(MAX_ADDED); - for _ in 0..=MAX_ADDED { - let have = instances(self, parent, name).len(); - if have < want { - if self.add_instance(parent, name).is_none() { - break; - } - } else if have > want && have > 0 { - self.remove_instance(parent, name, have - 1); - if instances(self, parent, name).len() == have { - break; - } - } else { - break; - } - } - } - - /// Remove instance `i` of the subform `name` under `parent` (never below the minimum, or - /// below one). - pub(crate) fn remove_instance(&mut self, parent: usize, name: &str, i: usize) { - let h = self; - let live = instances(h, parent, name); - let Some(&gone) = live.get(i) else { return }; - let min = h.nodes.get(gone).map_or(0, |n| n.occur_min).max(1); - if live.len() <= min { - return; - } - let som = h.nodes.get(gone).map(|n| n.som.clone()).unwrap_or_default(); - if !h.reserve(&XfaEffect::RemoveInstance { som: String::new() }, som.len()) { - return; - } - if let Some(n) = h.nodes.get_mut(gone) { - n.gone = true; - } - // Later instances move down one. - for (k, &inst) in live.iter().enumerate().skip(i + 1) { - if let Some(n) = h.nodes.get_mut(inst) { - n.index = k - 1; - } - renumber(h, inst, 0); - } - h.record(XfaEffect::RemoveInstance { som }); - } -} - -/// Resolve a SOM expression from node `from`: `$`, `$form`, `$record`, `$host`, `xfa.form…`, -/// `parent`, `..name` (descendant search), `name[n]`, `name[*]`, `_name`. -pub(crate) fn resolve_som(h: &XHost, from: usize, expr: &str) -> Vec { - let expr = expr.trim(); - if expr.is_empty() { - return vec![from]; - } - let root = 0usize; - let mut cur: Vec = vec![from]; - let mut rest = expr; - let starts = - [("$form", root), ("xfa.form", root), ("$record", root), ("xfa.datasets.data", root), ("xfa.record", root), ("$", from), ("this", from)]; - for (prefix, at) in starts { - if let Some(r) = rest.strip_prefix(prefix) - && (r.is_empty() || r.starts_with('.') || r.starts_with('[')) - { - cur = vec![at]; - rest = r; - break; - } - } - let mut depth = 0; - while !rest.is_empty() && depth < MAX_DEPTH { - depth += 1; - let descend = rest.starts_with(".."); - rest = rest.trim_start_matches('.'); - let end = rest.find(['.', '[']).unwrap_or(rest.len()); - let (name, after) = rest.split_at(end); - let name = name.trim(); - let (index, after) = match after.strip_prefix('[') { - Some(a) => { - let close = a.find(']').unwrap_or(a.len()); - let idx = a.get(..close).map(str::trim).unwrap_or(""); - (Some(idx.to_string()), a.get(close + 1..).unwrap_or("")) - } - None => (None, after), - }; - rest = after; - let mut next = Vec::new(); - for &c in &cur { - match name { - "" => {} - "parent" => { - if let Some(p) = h.nodes.get(c).and_then(|n| n.parent) { - next.push(p); - } - } - _ => { - let mut found: Vec = if descend { descendants_named(h, c, name, 0) } else { instances(h, c, name) }; - // A relative name a node has no child for is looked for among its siblings - // and in each enclosing container, nearest first (so a field's script can - // say `resolveNode("otherField")`). - if found.is_empty() && depth == 1 && !descend { - let mut at = h.nodes.get(c).and_then(|n| n.parent); - let mut guard = 0; - while let Some(p) = at - && guard < MAX_DEPTH - { - found = instances(h, p, name); - if !found.is_empty() { - break; - } - at = h.nodes.get(p).and_then(|n| n.parent); - guard += 1; - } - } - match index.as_deref() { - Some("*") => next.extend(found), - Some(i) => { - if let Some(k) = i.parse::().ok().and_then(|i| found.get(i)) { - next.push(*k); - } - } - None => next.extend(found.first()), - } - } - } - } - // Each node once: `row[*].parent.row[*]` would otherwise multiply the list by the - // number of rows at every step. - let mut seen = std::collections::HashSet::with_capacity(next.len()); - next.retain(|i| seen.insert(*i)); - cur = next; - if cur.is_empty() { - break; - } - } - cur -} - -/// The live descendants of node `from` named `name` (`from..name` in a SOM path), in document -/// order, to [`MAX_DEPTH`]. -pub(crate) fn descendants_named(h: &XHost, from: usize, name: &str, depth: usize) -> Vec { - let mut out = Vec::new(); - if depth > MAX_DEPTH { - return out; - } - let Some(n) = h.nodes.get(from) else { return out }; - for &c in &n.children { - if let Some(k) = h.nodes.get(c) - && !k.gone - { - if k.name == name { - out.push(c); - } else { - out.extend(descendants_named(h, c, name, depth + 1)); - } - } - } - out -} - -/// A property read on a form node's Proxy: its value (and `formattedValue` as its picture shows -/// it), attributes, relatives, methods, instance managers and children by name. -fn node_get(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { - let idx = node_index(&arg(args, 0), ctx)?; - let key = arg(args, 1); - if key.as_symbol().is_some() { - return Ok(JsValue::undefined()); - } - let key = text(&key, ctx)?; - let snapshot = { - let h = host(ctx)?.borrow(); - let Some(n) = h.nodes.get(idx) else { return Ok(JsValue::undefined()) }; - (n.name.clone(), n.som.clone(), n.kind, n.value.clone(), n.presence.clone(), n.access.clone(), n.index, n.parent, n.children.clone()) - }; - let (name, som, kind, value, presence, access, index, parent, children) = snapshot; - Ok(match key.as_str() { - IDX => JsValue::from(idx as f64), - "rawValue" | "value" => host(ctx)?.borrow().nodes.get(idx).map_or(JsValue::null(), value_js), - "formattedValue" => s(&host(ctx)?.borrow().nodes.get(idx).and_then(|n| n.formatted.clone()).unwrap_or(value)), - "name" => s(&name), - "somExpression" => s(&som), - "className" => s(kind.class_name()), - "index" | "instanceIndex" => JsValue::from(index as f64), - "presence" => s(&presence), - "access" => s(&access), - "isNull" => JsValue::from(value.is_empty()), - "mandatory" => s("disabled"), - "parent" => match parent { - Some(p) => node_object(ctx, p)?, - None => JsValue::null(), - }, - "nodes" => { - let live: Vec = { - let h = host(ctx)?.borrow(); - children.iter().copied().filter(|&c| h.nodes.get(c).is_some_and(|n| !n.gone)).collect() - }; - list_object(ctx, &live)? - } - "all" => { - let live = match parent { - Some(p) => instances(&host(ctx)?.borrow(), p, &name), - None => vec![idx], - }; - list_object(ctx, &live)? - } - "instanceManager" => match parent { - Some(p) => instance_manager(ctx, p, &name)?, - None => JsValue::undefined(), - }, - "resolveNode" => resolver(ctx, idx, false)?, - "resolveNodes" => resolver(ctx, idx, true)?, - "recalculate" => function(ctx, NativeFunction::from_fn_ptr(recalculate)).into(), - "remerge" => function(ctx, NativeFunction::from_fn_ptr(relayout)).into(), - "getAttribute" - | "setAttribute" - | "execEvent" - | "execInitialize" - | "execCalculate" - | "execValidate" - | "getElement" - | "setElement" - | "isPropertySpecified" - | "loadXML" - | "saveXML" - | "clone" => function(ctx, NativeFunction::from_fn_ptr(noop)).into(), - "toString" | "valueOf" | "toJSON" => function(ctx, NativeFunction::from_fn_ptr(node_to_string)).into(), - "length" => JsValue::undefined(), - "border" | "ui" | "font" | "caption" | "fillColor" | "borderColor" | "fontColor" | "margin" | "para" | "assist" | "validate" | "format" - | "items" | "bind" | "keep" | "h" | "w" | "x" | "y" | "minH" | "maxH" | "minW" | "maxW" | "layout" | "model" | "oneOfChild" | "locale" - | "relevant" | "colSpan" | "dataNode" | "defaultValue" | "editValue" | "selectedIndex" | "event" | "calculate" | "traversal" | "extras" - | "desc" => sink(ctx)?, - other => { - if let Some(child) = other.strip_prefix('_') { - let exists = child_named(&host(ctx)?.borrow(), idx, child).is_some(); - return if exists { instance_manager(ctx, idx, child) } else { Ok(JsValue::undefined()) }; - } - let found = child_named(&host(ctx)?.borrow(), idx, other); - match found { - Some(c) => node_object(ctx, c)?, - None => JsValue::undefined(), - } - } - }) -} - -fn node_set(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { - let idx = node_index(&arg(args, 0), ctx)?; - let key = arg(args, 1); - if key.as_symbol().is_some() { - return Ok(JsValue::from(true)); - } - let key = text(&key, ctx)?; - let value = arg(args, 2); - let value = if value.is_null() || value.is_undefined() { String::new() } else { text(&value, ctx)? }; - let mut h = host(ctx)?.borrow_mut(); - let Some(n) = h.nodes.get(idx) else { return Ok(JsValue::from(true)) }; - let som = n.som.clone(); - match key.as_str() { - "rawValue" | "value" | "formattedValue" => { - // Not a field: the property doesn't exist, as in Acrobat, and nothing is recorded. - h.set_value(idx, value); - } - "presence" => { - let p = match value.as_str() { - "hidden" | "invisible" | "inactive" | "visible" => value.clone(), - _ => return Ok(JsValue::from(true)), - }; - h.set_state(idx, XfaEffect::SetPresence { som, presence: p }); - } - "access" => { - let a = match value.as_str() { - "open" | "readOnly" | "protected" | "nonInteractive" => value.clone(), - _ => return Ok(JsValue::from(true)), - }; - h.set_state(idx, XfaEffect::SetAccess { som, access: a }); - } - _ => {} - } - Ok(JsValue::from(true)) -} - -fn node_to_string(this: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { - let idx = node_index(this, ctx)?; - Ok(s(&host(ctx)?.borrow().nodes.get(idx).map(|n| n.value.clone()).unwrap_or_default())) -} - -fn noop(_: &JsValue, _: &[JsValue], _: &mut Context) -> JsResult { - Ok(JsValue::undefined()) -} - -/// `resolveNode(som)` / `resolveNodes(som)` bound to node `from`. -fn resolver(ctx: &mut Context, from: usize, many: bool) -> JsResult { - let key = ObjectInitializer::new(ctx) - .property(js_string!("from"), JsValue::from(from as f64), Attribute::READONLY) - .property(js_string!("many"), JsValue::from(many), Attribute::READONLY) - .build(); - fn resolve(_: &JsValue, args: &[JsValue], key: &JsObject, ctx: &mut Context) -> JsResult { - let from = key.get(js_string!("from"), ctx)?.to_number(ctx)? as usize; - let many = key.get(js_string!("many"), ctx)?.to_boolean(); - let expr = text(&arg(args, 0), ctx)?; - let found = resolve_som(&host(ctx)?.borrow(), from, &expr); - if many { - return list_object(ctx, &found); - } - match found.first() { - Some(i) => node_object(ctx, *i), - None => Ok(JsValue::null()), - } - } - Ok(function(ctx, NativeFunction::from_copy_closure_with_captures(resolve, key)).into()) -} - -// ── xfa.host, xfa.layout, xfa.event, app, console ─────────────────────────────────────────── - -/// Records effect `e` on the host, as [`XHost::push`] does. -fn effect(ctx: &mut Context, e: XfaEffect) -> JsResult<()> { - host(ctx)?.borrow_mut().push(e); - Ok(()) -} - -/// `xfa.host.messageBox(message)`: records the message, cut to [`MAX_LINE`] characters, and -/// returns 1. -fn message_box(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { - let m = clip(text(&arg(args, 0), ctx)?); - effect(ctx, XfaEffect::MessageBox(m))?; - Ok(JsValue::from(1.0)) -} - -/// `xfa.host.resetData(names)`: asks to reset the comma-separated fields, or every field when -/// the list is empty. -fn reset_data(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { - let list = text(&arg(args, 0), ctx)?; - effect(ctx, XfaEffect::ResetData(reset_names(&list)))?; - Ok(JsValue::undefined()) -} - -/// `xfa.host.print(…)`: asks to print; its arguments are ignored. -fn print(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { - effect(ctx, XfaEffect::Print)?; - Ok(JsValue::undefined()) -} - -/// `xfa.host.beep()`: asks to beep. -fn beep(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { - effect(ctx, XfaEffect::Beep)?; - Ok(JsValue::undefined()) -} - -/// `xfa.host.gotoURL(url)`: asks to open the URL. -fn goto_url(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { - let u = text(&arg(args, 0), ctx)?; - effect(ctx, XfaEffect::LaunchUrl(u))?; - Ok(JsValue::undefined()) -} - -/// `xfa.host.setFocus(target)`: asks to focus the node, given as a node object or as a SOM -/// expression resolved from the current node; an expression that names no node is passed on -/// as written. -fn set_focus(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { - let target = arg(args, 0); - let som = match target.as_object() { - Some(_) if node_index(&target, ctx).is_ok() => { - let i = node_index(&target, ctx)?; - host(ctx)?.borrow().nodes.get(i).map(|n| n.som.clone()).unwrap_or_default() - } - _ => { - let expr = text(&target, ctx)?; - let h = host(ctx)?.borrow(); - let found = resolve_som(&h, h.current, &expr); - found.first().and_then(|i| h.nodes.get(*i)).map(|n| n.som.clone()).unwrap_or(expr) - } - }; - effect(ctx, XfaEffect::SetFocus(som))?; - Ok(JsValue::undefined()) -} - -/// `app.execMenuItem(name)`: the effect for the request the shared menu table gives the name -/// (Save opens Save As too); an unknown name does nothing. -fn exec_menu_item(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { - let item = text(&arg(args, 0), ctx)?; - match crate::menu_item(&item) { - Some(crate::Request::SaveAs) => effect(ctx, XfaEffect::SaveAs)?, - Some(crate::Request::Print) => effect(ctx, XfaEffect::Print)?, - Some(crate::Request::ShowPanel(p)) => effect(ctx, XfaEffect::ShowPanel(p))?, - _ => {} - } - Ok(JsValue::undefined()) -} - -fn recalculate(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { - effect(ctx, XfaEffect::Recalculate)?; - Ok(JsValue::undefined()) -} - -fn relayout(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { - effect(ctx, XfaEffect::Relayout)?; - Ok(JsValue::undefined()) -} - -fn console_println(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { - let m = clip(text(&arg(args, 0), ctx)?); - host(ctx)?.borrow_mut().print(m); - Ok(JsValue::undefined()) -} - -fn response(_: &JsValue, _: &[JsValue], _: &mut Context) -> JsResult { - Ok(JsValue::null()) -} - -fn layout_page(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { - Ok(JsValue::from(host(ctx)?.borrow().doc.page as f64 + 1.0)) -} - -fn layout_page0(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { - Ok(JsValue::from(host(ctx)?.borrow().doc.page as f64)) -} - -fn layout_page_count(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { - Ok(JsValue::from(host(ctx)?.borrow().doc.page_count as f64)) -} - -fn zero(_: &JsValue, _: &[JsValue], _: &mut Context) -> JsResult { - Ok(JsValue::from(0.0)) -} - -fn xfa_resolve(ctx: &mut Context, many: bool) -> JsResult { - let current = host(ctx)?.borrow().current; - resolver(ctx, current, many) -} - -/// Defines the globals for one script on node `current`: `xfa` (`form`, `host`, `layout`, `event`, -/// `resolveNode` and more), `event` as the same object as `xfa.event`, `app`, `console`, `util`, -/// and the nodes in scope by name, with `_name` for each repeatable one's instance manager. -fn install(ctx: &mut Context, event: &XfaEvent, doc: &XfaDoc, current: usize) -> JsResult<()> { - let rw = Attribute::CONFIGURABLE; - let form = node_object(ctx, 0)?; - let this_node = node_object(ctx, current)?; - let host_obj = ObjectInitializer::new(ctx) - .function(NativeFunction::from_fn_ptr(message_box), js_string!("messageBox"), 4) - .function(NativeFunction::from_fn_ptr(reset_data), js_string!("resetData"), 1) - .function(NativeFunction::from_fn_ptr(print), js_string!("print"), 8) - .function(NativeFunction::from_fn_ptr(beep), js_string!("beep"), 1) - .function(NativeFunction::from_fn_ptr(goto_url), js_string!("gotoURL"), 2) - .function(NativeFunction::from_fn_ptr(set_focus), js_string!("setFocus"), 1) - .function(NativeFunction::from_fn_ptr(response), js_string!("response"), 4) - .function(NativeFunction::from_fn_ptr(noop), js_string!("openList"), 1) - .function(NativeFunction::from_fn_ptr(noop), js_string!("exportData"), 2) - .function(NativeFunction::from_fn_ptr(noop), js_string!("importData"), 1) - .function(NativeFunction::from_fn_ptr(noop), js_string!("pageDown"), 0) - .function(NativeFunction::from_fn_ptr(noop), js_string!("pageUp"), 0) - .function(NativeFunction::from_fn_ptr(noop), js_string!("getFocus"), 0) - .property(js_string!("currentPage"), JsValue::from(doc.page as f64), rw) - .property(js_string!("numPages"), JsValue::from(doc.page_count as f64), rw) - .property(js_string!("name"), s("Acrobat"), Attribute::READONLY) - .property(js_string!("appType"), s("Exchange-Pro"), Attribute::READONLY) - .property(js_string!("version"), s("11"), Attribute::READONLY) - .property(js_string!("variation"), s("Full"), Attribute::READONLY) - .property(js_string!("language"), s("en"), Attribute::READONLY) - .property(js_string!("platform"), s(platform()), Attribute::READONLY) - .property(js_string!("calculationsEnabled"), JsValue::from(true), rw) - .property(js_string!("validationsEnabled"), JsValue::from(true), rw) - .property(js_string!("title"), s(&doc.file_name), rw) - .build(); - let layout = ObjectInitializer::new(ctx) - .function(NativeFunction::from_fn_ptr(layout_page), js_string!("page"), 1) - .function(NativeFunction::from_fn_ptr(layout_page0), js_string!("absPage"), 1) - .function(NativeFunction::from_fn_ptr(layout_page_count), js_string!("pageCount"), 0) - .function(NativeFunction::from_fn_ptr(layout_page_count), js_string!("absPageCount"), 0) - .function(NativeFunction::from_fn_ptr(relayout), js_string!("relayout"), 0) - .function(NativeFunction::from_fn_ptr(relayout), js_string!("relayoutPageArea"), 0) - .function(NativeFunction::from_fn_ptr(zero), js_string!("h"), 2) - .function(NativeFunction::from_fn_ptr(zero), js_string!("w"), 2) - .function(NativeFunction::from_fn_ptr(zero), js_string!("x"), 2) - .function(NativeFunction::from_fn_ptr(zero), js_string!("y"), 2) - .function(NativeFunction::from_fn_ptr(zero), js_string!("pageSpan"), 1) - .property(js_string!("ready"), JsValue::from(true), Attribute::READONLY) - .build(); - let ev = ObjectInitializer::new(ctx) - .property(js_string!("name"), s(&event.activity), Attribute::all()) - .property(js_string!("newText"), s(&event.new_text), Attribute::all()) - .property(js_string!("prevText"), s(&event.prev_text), Attribute::all()) - .property(js_string!("fullText"), s(&event.new_text), Attribute::all()) - .property(js_string!("change"), s(""), Attribute::all()) - .property(js_string!("target"), this_node.clone(), Attribute::all()) - .property(js_string!("selStart"), JsValue::from(0.0), Attribute::all()) - .property(js_string!("selEnd"), JsValue::from(0.0), Attribute::all()) - .property(js_string!("cancelAction"), JsValue::from(false), Attribute::all()) - .property(js_string!("reenter"), JsValue::from(false), Attribute::all()) - .property(js_string!("shift"), JsValue::from(false), Attribute::all()) - .property(js_string!("modifier"), JsValue::from(false), Attribute::all()) - .property(js_string!("keyDown"), JsValue::from(false), Attribute::all()) - .property(js_string!("commitKey"), JsValue::from(0.0), Attribute::all()) - .function(NativeFunction::from_fn_ptr(noop), js_string!("emit"), 0) - .function(NativeFunction::from_fn_ptr(noop), js_string!("reset"), 0) - .build(); - let datasets = ObjectInitializer::new(ctx).property(js_string!("data"), form.clone(), rw).build(); - let template = sink(ctx)?; - let resolve_one = xfa_resolve(ctx, false)?; - let resolve_many = xfa_resolve(ctx, true)?; - let xfa = ObjectInitializer::new(ctx) - .property(js_string!("form"), form.clone(), rw) - .property(js_string!("host"), host_obj, rw) - .property(js_string!("layout"), layout, rw) - .property(js_string!("event"), ev.clone(), rw) - .property(js_string!("datasets"), datasets, rw) - .property(js_string!("record"), form.clone(), rw) - .property(js_string!("template"), template, rw) - .property(js_string!("resolveNode"), resolve_one, rw) - .property(js_string!("resolveNodes"), resolve_many, rw) - .function(NativeFunction::from_fn_ptr(recalculate), js_string!("recalculate"), 1) - .function(NativeFunction::from_fn_ptr(relayout), js_string!("remerge"), 0) - .build(); - ctx.register_global_property(js_string!("xfa"), xfa, rw)?; - ctx.register_global_property(js_string!("event"), ev, rw)?; - ctx.register_global_property(js_string!("__xfa_this"), this_node, rw)?; - let app = ObjectInitializer::new(ctx) - .function(NativeFunction::from_fn_ptr(message_box), js_string!("alert"), 4) - .function(NativeFunction::from_fn_ptr(beep), js_string!("beep"), 1) - .function(NativeFunction::from_fn_ptr(response), js_string!("response"), 1) - .function(NativeFunction::from_fn_ptr(goto_url), js_string!("launchURL"), 2) - .function(NativeFunction::from_fn_ptr(exec_menu_item), js_string!("execMenuItem"), 1) - .function(NativeFunction::from_fn_ptr(noop), js_string!("setTimeOut"), 2) - .function(NativeFunction::from_fn_ptr(noop), js_string!("setInterval"), 2) - .function(NativeFunction::from_fn_ptr(noop), js_string!("clearTimeOut"), 1) - .function(NativeFunction::from_fn_ptr(noop), js_string!("clearInterval"), 1) - .property(js_string!("viewerType"), s("Exchange-Pro"), Attribute::READONLY) - .property(js_string!("viewerVariation"), s("Full"), Attribute::READONLY) - .property(js_string!("viewerVersion"), JsValue::from(24.0), Attribute::READONLY) - .property(js_string!("platform"), s(platform()), Attribute::READONLY) - .property(js_string!("language"), s("ENU"), Attribute::READONLY) - .build(); - ctx.register_global_property(js_string!("app"), app, rw)?; - let console = ObjectInitializer::new(ctx) - .function(NativeFunction::from_fn_ptr(console_println), js_string!("println"), 1) - .function(NativeFunction::from_fn_ptr(noop), js_string!("show"), 0) - .function(NativeFunction::from_fn_ptr(noop), js_string!("hide"), 0) - .function(NativeFunction::from_fn_ptr(noop), js_string!("clear"), 0) - .build(); - ctx.register_global_property(js_string!("console"), console, rw)?; - let util = ObjectInitializer::new(ctx) - .function(NativeFunction::from_fn_ptr(printf), js_string!("printf"), 1) - .function(NativeFunction::from_fn_ptr(printd), js_string!("printd"), 2) - .function(NativeFunction::from_fn_ptr(printx), js_string!("printx"), 2) - .function(NativeFunction::from_fn_ptr(scand), js_string!("scand"), 2) - .build(); - ctx.register_global_property(js_string!("util"), util, rw)?; - // Unqualified names: the current node's children, its siblings, then each ancestor's - // children, the nearest first. Instance managers as `_name`. - let scopes: Vec = { - let h = host(ctx)?.borrow(); - let mut out = vec![current]; - let mut at = h.nodes.get(current).and_then(|n| n.parent); - let mut guard = 0; - while let Some(p) = at - && guard < MAX_DEPTH - { - out.push(p); - at = h.nodes.get(p).and_then(|n| n.parent); - guard += 1; - } - out - }; - // Names that stay what JavaScript (or this host) means by them: a field called `Date` - // is reached as `this.parent.Date` or by resolveNode. - const RESERVED: &[&str] = &[ - "xfa", - "event", - "app", - "console", - "util", - "this", - "__xfa_this", - "__xfa_src", - "eval", - "arguments", - "globalThis", - "window", - "undefined", - "NaN", - "Infinity", - "Object", - "Function", - "Array", - "Number", - "String", - "Boolean", - "Symbol", - "Date", - "RegExp", - "Error", - "TypeError", - "RangeError", - "SyntaxError", - "Math", - "JSON", - "Map", - "Set", - "WeakMap", - "WeakSet", - "Promise", - "Proxy", - "Reflect", - "parseInt", - "parseFloat", - "isNaN", - "isFinite", - "encodeURIComponent", - "decodeURIComponent", - "encodeURI", - "decodeURI", - "escape", - "unescape", - "BigInt", - "ArrayBuffer", - "DataView", - "Intl", - ]; - let mut defined: Vec = RESERVED.iter().map(ToString::to_string).collect(); - for scope in scopes { - let kids: Vec<(usize, String, bool)> = { - let h = host(ctx)?.borrow(); - h.nodes - .get(scope) - .map(|n| n.children.iter().filter_map(|&c| h.nodes.get(c).map(|k| (c, k.name.clone(), k.repeatable && !k.gone))).collect()) - .unwrap_or_default() - }; - for (c, name, repeatable) in kids { - if !is_identifier(&name) || defined.contains(&name) { - continue; - } - let obj = node_object(ctx, c)?; - ctx.register_global_property(JsString::from(name.as_str()), obj, rw)?; - if repeatable { - let im = instance_manager(ctx, scope, &name)?; - ctx.register_global_property(JsString::from(format!("_{name}").as_str()), im, rw)?; - } - defined.push(name); - } - } - Ok(()) -} - -fn is_identifier(name: &str) -> bool { - let mut chars = name.chars(); - match chars.next() { - Some(c) if c.is_ascii_alphabetic() || c == '_' || c == '$' => {} - _ => return false, - } - chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$') - && !matches!( - name, - "var" - | "let" - | "const" - | "function" - | "return" - | "if" - | "else" - | "for" - | "while" - | "new" - | "null" - | "true" - | "false" - | "undefined" - | "typeof" - ) -} - -/// Run `script` for `event` on the form `root` (the root subform), from node `event.target`. -pub fn run_xfa(script: &str, event: &XfaEvent, doc: &XfaDoc, root: &XfaNode, limits: Limits) -> XfaOutcome { - let failed = |why: String| XfaOutcome { error: Some(why), ..Default::default() }; - if let Some(why) = refuse(script) { - return failed(why); - } - #[cfg(not(target_arch = "wasm32"))] - { - std::thread::scope(|scope| { - let spawned = std::thread::Builder::new() - .name("pdfcraft-xfa-js".into()) - .stack_size(crate::SCRIPT_STACK) - .spawn_scoped(scope, || run_here(script, event, doc, root.clone(), limits, &|| {})); - match spawned { - Ok(thread) => thread.join().unwrap_or_else(|_| failed("the script stopped with an internal error".into())), - Err(e) => failed(format!("the script engine could not start: {e}")), - } - }) - } - #[cfg(target_arch = "wasm32")] - run_here(script, event, doc, root.clone(), limits, &|| {}) -} - -/// [`run_xfa`] with a time limit: a script still running `timeout` after the engine is ready -/// (loops inside nested function calls can run for hours within the engine's per-frame loop -/// limit) is left on its thread, which ends when the engine's own limits stop it, and an -/// outcome with `abandoned` set comes back at once. In the browser build there are no -/// threads: the script runs to its limits. -pub fn run_xfa_within(script: &str, event: &XfaEvent, doc: &XfaDoc, root: XfaNode, limits: Limits, timeout: std::time::Duration) -> XfaOutcome { - if let Some(why) = refuse(script) { - return XfaOutcome { error: Some(why), ..Default::default() }; - } - let (script, event, doc) = (script.to_string(), event.clone(), doc.clone()); - run_within("pdfcraft-xfa-js", timeout, move |started| run_here(&script, &event, &doc, root, limits, started)) -} - -/// Longest the script engine may take to be ready for a script (its thread and context, and -/// for the first script in a process the engine's own set-up) before the script is abandoned. -/// It doesn't count against the script's time: on a busy machine the start alone can take -/// longer than the tightest limit, and abandoning a script turns the form's scripts off. -pub(crate) const ENGINE_START_LIMIT: std::time::Duration = std::time::Duration::from_secs(10); - -/// Run `script` on its own thread (with the script stack) and wait at most `timeout` for its -/// outcome, counted from when it calls `started` (once the engine is ready); past that the -/// thread is abandoned and the outcome says so. On wasm there are no threads: the script runs -/// here. -pub(crate) fn run_within(name: &str, timeout: std::time::Duration, script: impl FnOnce(&dyn Fn()) -> XfaOutcome + Send + 'static) -> XfaOutcome { - run_within_limits(name, ENGINE_START_LIMIT, timeout, script) -} - -/// [`run_within`] with the engine's start-up limit given. -pub(crate) fn run_within_limits( - name: &str, - start_limit: std::time::Duration, - timeout: std::time::Duration, - script: impl FnOnce(&dyn Fn()) -> XfaOutcome + Send + 'static, -) -> XfaOutcome { - #[cfg(not(target_arch = "wasm32"))] - { - use std::sync::mpsc::RecvTimeoutError; - use std::time::Instant; - enum Step { - Started, - Done(XfaOutcome), - } - let failed = |why: String| XfaOutcome { error: Some(why), ..Default::default() }; - let abandoned = |why: String| XfaOutcome { error: Some(why), abandoned: true, ..Default::default() }; - let (tx, rx) = std::sync::mpsc::channel(); - let spawned = std::thread::Builder::new().name(name.into()).stack_size(crate::SCRIPT_STACK).spawn(move || { - // The receiver is gone when the caller stopped waiting: nothing to report then. - let started = tx.clone(); - let outcome = script(&move || { - let _ = started.send(Step::Started); - }); - let _ = tx.send(Step::Done(outcome)); - }); - if let Err(e) = spawned { - return failed(format!("the script engine could not start: {e}")); - } - // Wait for the engine within its start-up limit, then for the script within its own. - let (mut running, mut limit) = (false, start_limit); - let mut deadline = Instant::now().checked_add(limit); - loop { - let left = deadline.map_or(limit, |d| d.saturating_duration_since(Instant::now())); - match rx.recv_timeout(left) { - Ok(Step::Done(o)) => return o, - Ok(Step::Started) => { - // A second call changes nothing: the script's time has already begun. - if !running { - (running, limit) = (true, timeout); - deadline = Instant::now().checked_add(limit); - } - } - Err(RecvTimeoutError::Timeout) if running => { - return abandoned(format!("the script ran longer than {:.1} s and was abandoned", timeout.as_secs_f64())); - } - Err(RecvTimeoutError::Timeout) => { - return abandoned(format!("the script engine took longer than {:.0} s to start", start_limit.as_secs_f64())); - } - Err(RecvTimeoutError::Disconnected) => return failed("the script stopped with an internal error".into()), - } - } - } - #[cfg(target_arch = "wasm32")] - { - let _ = (name, start_limit, timeout); - script(&|| {}) - } -} - -/// The first script in a process sets the engine up (its built-ins, parser and compiler come -/// into use): a trivial script takes that on, so a form's script is never timed with it. -pub(crate) fn warm_up() { - static WARM: std::sync::Once = std::sync::Once::new(); - // `call_once_force`: a poisoned `Once` (a panic while warming up) only means warming up again. - WARM.call_once_force(|_| { - let mut ctx = Context::default(); - let _ = ctx.eval(Source::from_bytes(b"(function () { return eval('0'); }).call({})")); - }); -} - -/// `started` is called once the engine is ready: a time limit on the script runs from then. -pub(crate) fn run_here(script: &str, event: &XfaEvent, doc: &XfaDoc, root: XfaNode, limits: Limits, started: &dyn Fn()) -> XfaOutcome { - warm_up(); - let mut ctx = match Context::builder().host_hooks(std::rc::Rc::new(crate::CheckedCodeFromStrings)).build() { - Ok(ctx) => ctx, - Err(e) => return XfaOutcome { error: Some(format!("the script engine could not start: {e}")), ..Default::default() }, - }; - ctx.runtime_limits_mut().set_loop_iteration_limit(limits.loop_iterations); - ctx.runtime_limits_mut().set_recursion_limit(limits.recursion); - let host_state = XHost::new(root, &event.target, doc); - let current = host_state.current; - ctx.insert_data(RefCell::new(host_state)); - let mut out = XfaOutcome::default(); - let result = (|| -> JsResult<()> { - install(&mut ctx, event, doc, current)?; - // Ready for the script: its time starts now, the set-up above not charged to it. - started(); - ctx.register_global_property(js_string!("__xfa_src"), s(script), Attribute::CONFIGURABLE)?; - // Direct eval inside a function called on the node: `this` is the node and the - // completion value is the script's last expression (what a calculate script yields). - let r = ctx.eval(Source::from_bytes(b"(function () { return eval(__xfa_src); }).call(__xfa_this)"))?; - if !r.is_undefined() { - // Best effort: a value that can't be made text (a symbol, say) is no failure. - out.result = Some(text(&r, &mut ctx).unwrap_or_default()); - if r.is_boolean() { - out.result_bool = Some(r.to_boolean()); - } else if r.is_null() { - out.result = Some(String::new()); - } - } - Ok(()) - })(); - if let Err(e) = result { - out.error = Some(e.to_string()); - } - if let Some(h) = ctx.remove_data::() { - let h = h.into_inner(); - out.notes = h.notes(); - out.effects = h.effects; - out.console = h.console; - } - clip_result(&mut out); - out -} +mod globals; +mod host; +mod instances; +mod model; +mod nodes; +mod run; + +pub(crate) use host::{HNode, XHost}; +pub use model::{MAX_ALERTS, MAX_CONSOLE, MAX_EFFECTS, XfaDoc, XfaEffect, XfaEvent, XfaKind, XfaNode, XfaOutcome}; +pub(crate) use model::{MAX_LINE, MAX_PAYLOAD, clip, clip_result, reset_names}; +pub(crate) use nodes::{child_named, descendants_named, instances, resolve_som}; +#[cfg(test)] +pub(crate) use run::run_within_limits; +#[cfg(not(target_arch = "wasm32"))] +pub(crate) use run::{ENGINE_START_LIMIT, warm_up}; +pub(crate) use run::{run_here, run_within}; +pub use run::{run_xfa, run_xfa_within}; diff --git a/crates/js/src/xfa/globals.rs b/crates/js/src/xfa/globals.rs new file mode 100644 index 0000000000..54508e6cab --- /dev/null +++ b/crates/js/src/xfa/globals.rs @@ -0,0 +1,365 @@ +use super::host::host; +use super::instances::instance_manager; +use super::model::{MAX_DEPTH, XfaDoc, XfaEffect, XfaEvent, clip, reset_names}; +use super::nodes::{node_index, node_object, noop, resolve_som, resolver, sink}; +use super::{ + Attribute, Context, JsResult, JsString, JsValue, NativeFunction, ObjectInitializer, arg, js_string, platform, printd, printf, printx, s, scand, + text, +}; + +// ── xfa.host, xfa.layout, xfa.event, app, console ─────────────────────────────────────────── + +/// Records effect `e` on the host, as [`XHost::push`] does. +fn effect(ctx: &mut Context, e: XfaEffect) -> JsResult<()> { + host(ctx)?.borrow_mut().push(e); + Ok(()) +} + +/// `xfa.host.messageBox(message)`: records the message, cut to [`MAX_LINE`] characters, and +/// returns 1. +fn message_box(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let m = clip(text(&arg(args, 0), ctx)?); + effect(ctx, XfaEffect::MessageBox(m))?; + Ok(JsValue::from(1.0)) +} + +/// `xfa.host.resetData(names)`: asks to reset the comma-separated fields, or every field when +/// the list is empty. +fn reset_data(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let list = text(&arg(args, 0), ctx)?; + effect(ctx, XfaEffect::ResetData(reset_names(&list)))?; + Ok(JsValue::undefined()) +} + +/// `xfa.host.print(…)`: asks to print; its arguments are ignored. +fn print(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { + effect(ctx, XfaEffect::Print)?; + Ok(JsValue::undefined()) +} + +/// `xfa.host.beep()`: asks to beep. +fn beep(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { + effect(ctx, XfaEffect::Beep)?; + Ok(JsValue::undefined()) +} + +/// `xfa.host.gotoURL(url)`: asks to open the URL. +fn goto_url(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let u = text(&arg(args, 0), ctx)?; + effect(ctx, XfaEffect::LaunchUrl(u))?; + Ok(JsValue::undefined()) +} + +/// `xfa.host.setFocus(target)`: asks to focus the node, given as a node object or as a SOM +/// expression resolved from the current node; an expression that names no node is passed on +/// as written. +fn set_focus(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let target = arg(args, 0); + let som = match target.as_object() { + Some(_) if node_index(&target, ctx).is_ok() => { + let i = node_index(&target, ctx)?; + host(ctx)?.borrow().nodes.get(i).map(|n| n.som.clone()).unwrap_or_default() + } + _ => { + let expr = text(&target, ctx)?; + let h = host(ctx)?.borrow(); + let found = resolve_som(&h, h.current, &expr); + found.first().and_then(|i| h.nodes.get(*i)).map(|n| n.som.clone()).unwrap_or(expr) + } + }; + effect(ctx, XfaEffect::SetFocus(som))?; + Ok(JsValue::undefined()) +} + +/// `app.execMenuItem(name)`: the effect for the request the shared menu table gives the name +/// (Save opens Save As too); an unknown name does nothing. +fn exec_menu_item(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let item = text(&arg(args, 0), ctx)?; + match crate::menu_item(&item) { + Some(crate::Request::SaveAs) => effect(ctx, XfaEffect::SaveAs)?, + Some(crate::Request::Print) => effect(ctx, XfaEffect::Print)?, + Some(crate::Request::ShowPanel(p)) => effect(ctx, XfaEffect::ShowPanel(p))?, + _ => {} + } + Ok(JsValue::undefined()) +} + +pub(super) fn recalculate(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { + effect(ctx, XfaEffect::Recalculate)?; + Ok(JsValue::undefined()) +} + +pub(super) fn relayout(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { + effect(ctx, XfaEffect::Relayout)?; + Ok(JsValue::undefined()) +} + +fn console_println(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let m = clip(text(&arg(args, 0), ctx)?); + host(ctx)?.borrow_mut().print(m); + Ok(JsValue::undefined()) +} + +fn response(_: &JsValue, _: &[JsValue], _: &mut Context) -> JsResult { + Ok(JsValue::null()) +} + +fn layout_page(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { + Ok(JsValue::from(host(ctx)?.borrow().doc.page as f64 + 1.0)) +} + +fn layout_page0(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { + Ok(JsValue::from(host(ctx)?.borrow().doc.page as f64)) +} + +fn layout_page_count(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { + Ok(JsValue::from(host(ctx)?.borrow().doc.page_count as f64)) +} + +fn zero(_: &JsValue, _: &[JsValue], _: &mut Context) -> JsResult { + Ok(JsValue::from(0.0)) +} + +fn xfa_resolve(ctx: &mut Context, many: bool) -> JsResult { + let current = host(ctx)?.borrow().current; + resolver(ctx, current, many) +} + +/// Defines the globals for one script on node `current`: `xfa` (`form`, `host`, `layout`, `event`, +/// `resolveNode` and more), `event` as the same object as `xfa.event`, `app`, `console`, `util`, +/// and the nodes in scope by name, with `_name` for each repeatable one's instance manager. +pub(super) fn install(ctx: &mut Context, event: &XfaEvent, doc: &XfaDoc, current: usize) -> JsResult<()> { + let rw = Attribute::CONFIGURABLE; + let form = node_object(ctx, 0)?; + let this_node = node_object(ctx, current)?; + let host_obj = ObjectInitializer::new(ctx) + .function(NativeFunction::from_fn_ptr(message_box), js_string!("messageBox"), 4) + .function(NativeFunction::from_fn_ptr(reset_data), js_string!("resetData"), 1) + .function(NativeFunction::from_fn_ptr(print), js_string!("print"), 8) + .function(NativeFunction::from_fn_ptr(beep), js_string!("beep"), 1) + .function(NativeFunction::from_fn_ptr(goto_url), js_string!("gotoURL"), 2) + .function(NativeFunction::from_fn_ptr(set_focus), js_string!("setFocus"), 1) + .function(NativeFunction::from_fn_ptr(response), js_string!("response"), 4) + .function(NativeFunction::from_fn_ptr(noop), js_string!("openList"), 1) + .function(NativeFunction::from_fn_ptr(noop), js_string!("exportData"), 2) + .function(NativeFunction::from_fn_ptr(noop), js_string!("importData"), 1) + .function(NativeFunction::from_fn_ptr(noop), js_string!("pageDown"), 0) + .function(NativeFunction::from_fn_ptr(noop), js_string!("pageUp"), 0) + .function(NativeFunction::from_fn_ptr(noop), js_string!("getFocus"), 0) + .property(js_string!("currentPage"), JsValue::from(doc.page as f64), rw) + .property(js_string!("numPages"), JsValue::from(doc.page_count as f64), rw) + .property(js_string!("name"), s("Acrobat"), Attribute::READONLY) + .property(js_string!("appType"), s("Exchange-Pro"), Attribute::READONLY) + .property(js_string!("version"), s("11"), Attribute::READONLY) + .property(js_string!("variation"), s("Full"), Attribute::READONLY) + .property(js_string!("language"), s("en"), Attribute::READONLY) + .property(js_string!("platform"), s(platform()), Attribute::READONLY) + .property(js_string!("calculationsEnabled"), JsValue::from(true), rw) + .property(js_string!("validationsEnabled"), JsValue::from(true), rw) + .property(js_string!("title"), s(&doc.file_name), rw) + .build(); + let layout = ObjectInitializer::new(ctx) + .function(NativeFunction::from_fn_ptr(layout_page), js_string!("page"), 1) + .function(NativeFunction::from_fn_ptr(layout_page0), js_string!("absPage"), 1) + .function(NativeFunction::from_fn_ptr(layout_page_count), js_string!("pageCount"), 0) + .function(NativeFunction::from_fn_ptr(layout_page_count), js_string!("absPageCount"), 0) + .function(NativeFunction::from_fn_ptr(relayout), js_string!("relayout"), 0) + .function(NativeFunction::from_fn_ptr(relayout), js_string!("relayoutPageArea"), 0) + .function(NativeFunction::from_fn_ptr(zero), js_string!("h"), 2) + .function(NativeFunction::from_fn_ptr(zero), js_string!("w"), 2) + .function(NativeFunction::from_fn_ptr(zero), js_string!("x"), 2) + .function(NativeFunction::from_fn_ptr(zero), js_string!("y"), 2) + .function(NativeFunction::from_fn_ptr(zero), js_string!("pageSpan"), 1) + .property(js_string!("ready"), JsValue::from(true), Attribute::READONLY) + .build(); + let ev = ObjectInitializer::new(ctx) + .property(js_string!("name"), s(&event.activity), Attribute::all()) + .property(js_string!("newText"), s(&event.new_text), Attribute::all()) + .property(js_string!("prevText"), s(&event.prev_text), Attribute::all()) + .property(js_string!("fullText"), s(&event.new_text), Attribute::all()) + .property(js_string!("change"), s(""), Attribute::all()) + .property(js_string!("target"), this_node.clone(), Attribute::all()) + .property(js_string!("selStart"), JsValue::from(0.0), Attribute::all()) + .property(js_string!("selEnd"), JsValue::from(0.0), Attribute::all()) + .property(js_string!("cancelAction"), JsValue::from(false), Attribute::all()) + .property(js_string!("reenter"), JsValue::from(false), Attribute::all()) + .property(js_string!("shift"), JsValue::from(false), Attribute::all()) + .property(js_string!("modifier"), JsValue::from(false), Attribute::all()) + .property(js_string!("keyDown"), JsValue::from(false), Attribute::all()) + .property(js_string!("commitKey"), JsValue::from(0.0), Attribute::all()) + .function(NativeFunction::from_fn_ptr(noop), js_string!("emit"), 0) + .function(NativeFunction::from_fn_ptr(noop), js_string!("reset"), 0) + .build(); + let datasets = ObjectInitializer::new(ctx).property(js_string!("data"), form.clone(), rw).build(); + let template = sink(ctx)?; + let resolve_one = xfa_resolve(ctx, false)?; + let resolve_many = xfa_resolve(ctx, true)?; + let xfa = ObjectInitializer::new(ctx) + .property(js_string!("form"), form.clone(), rw) + .property(js_string!("host"), host_obj, rw) + .property(js_string!("layout"), layout, rw) + .property(js_string!("event"), ev.clone(), rw) + .property(js_string!("datasets"), datasets, rw) + .property(js_string!("record"), form.clone(), rw) + .property(js_string!("template"), template, rw) + .property(js_string!("resolveNode"), resolve_one, rw) + .property(js_string!("resolveNodes"), resolve_many, rw) + .function(NativeFunction::from_fn_ptr(recalculate), js_string!("recalculate"), 1) + .function(NativeFunction::from_fn_ptr(relayout), js_string!("remerge"), 0) + .build(); + ctx.register_global_property(js_string!("xfa"), xfa, rw)?; + ctx.register_global_property(js_string!("event"), ev, rw)?; + ctx.register_global_property(js_string!("__xfa_this"), this_node, rw)?; + let app = ObjectInitializer::new(ctx) + .function(NativeFunction::from_fn_ptr(message_box), js_string!("alert"), 4) + .function(NativeFunction::from_fn_ptr(beep), js_string!("beep"), 1) + .function(NativeFunction::from_fn_ptr(response), js_string!("response"), 1) + .function(NativeFunction::from_fn_ptr(goto_url), js_string!("launchURL"), 2) + .function(NativeFunction::from_fn_ptr(exec_menu_item), js_string!("execMenuItem"), 1) + .function(NativeFunction::from_fn_ptr(noop), js_string!("setTimeOut"), 2) + .function(NativeFunction::from_fn_ptr(noop), js_string!("setInterval"), 2) + .function(NativeFunction::from_fn_ptr(noop), js_string!("clearTimeOut"), 1) + .function(NativeFunction::from_fn_ptr(noop), js_string!("clearInterval"), 1) + .property(js_string!("viewerType"), s("Exchange-Pro"), Attribute::READONLY) + .property(js_string!("viewerVariation"), s("Full"), Attribute::READONLY) + .property(js_string!("viewerVersion"), JsValue::from(24.0), Attribute::READONLY) + .property(js_string!("platform"), s(platform()), Attribute::READONLY) + .property(js_string!("language"), s("ENU"), Attribute::READONLY) + .build(); + ctx.register_global_property(js_string!("app"), app, rw)?; + let console = ObjectInitializer::new(ctx) + .function(NativeFunction::from_fn_ptr(console_println), js_string!("println"), 1) + .function(NativeFunction::from_fn_ptr(noop), js_string!("show"), 0) + .function(NativeFunction::from_fn_ptr(noop), js_string!("hide"), 0) + .function(NativeFunction::from_fn_ptr(noop), js_string!("clear"), 0) + .build(); + ctx.register_global_property(js_string!("console"), console, rw)?; + let util = ObjectInitializer::new(ctx) + .function(NativeFunction::from_fn_ptr(printf), js_string!("printf"), 1) + .function(NativeFunction::from_fn_ptr(printd), js_string!("printd"), 2) + .function(NativeFunction::from_fn_ptr(printx), js_string!("printx"), 2) + .function(NativeFunction::from_fn_ptr(scand), js_string!("scand"), 2) + .build(); + ctx.register_global_property(js_string!("util"), util, rw)?; + // Unqualified names: the current node's children, its siblings, then each ancestor's + // children, the nearest first. Instance managers as `_name`. + let scopes: Vec = { + let h = host(ctx)?.borrow(); + let mut out = vec![current]; + let mut at = h.nodes.get(current).and_then(|n| n.parent); + let mut guard = 0; + while let Some(p) = at + && guard < MAX_DEPTH + { + out.push(p); + at = h.nodes.get(p).and_then(|n| n.parent); + guard += 1; + } + out + }; + // Names that stay what JavaScript (or this host) means by them: a field called `Date` + // is reached as `this.parent.Date` or by resolveNode. + const RESERVED: &[&str] = &[ + "xfa", + "event", + "app", + "console", + "util", + "this", + "__xfa_this", + "__xfa_src", + "eval", + "arguments", + "globalThis", + "window", + "undefined", + "NaN", + "Infinity", + "Object", + "Function", + "Array", + "Number", + "String", + "Boolean", + "Symbol", + "Date", + "RegExp", + "Error", + "TypeError", + "RangeError", + "SyntaxError", + "Math", + "JSON", + "Map", + "Set", + "WeakMap", + "WeakSet", + "Promise", + "Proxy", + "Reflect", + "parseInt", + "parseFloat", + "isNaN", + "isFinite", + "encodeURIComponent", + "decodeURIComponent", + "encodeURI", + "decodeURI", + "escape", + "unescape", + "BigInt", + "ArrayBuffer", + "DataView", + "Intl", + ]; + let mut defined: Vec = RESERVED.iter().map(ToString::to_string).collect(); + for scope in scopes { + let kids: Vec<(usize, String, bool)> = { + let h = host(ctx)?.borrow(); + h.nodes + .get(scope) + .map(|n| n.children.iter().filter_map(|&c| h.nodes.get(c).map(|k| (c, k.name.clone(), k.repeatable && !k.gone))).collect()) + .unwrap_or_default() + }; + for (c, name, repeatable) in kids { + if !is_identifier(&name) || defined.contains(&name) { + continue; + } + let obj = node_object(ctx, c)?; + ctx.register_global_property(JsString::from(name.as_str()), obj, rw)?; + if repeatable { + let im = instance_manager(ctx, scope, &name)?; + ctx.register_global_property(JsString::from(format!("_{name}").as_str()), im, rw)?; + } + defined.push(name); + } + } + Ok(()) +} + +fn is_identifier(name: &str) -> bool { + let mut chars = name.chars(); + match chars.next() { + Some(c) if c.is_ascii_alphabetic() || c == '_' || c == '$' => {} + _ => return false, + } + chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$') + && !matches!( + name, + "var" + | "let" + | "const" + | "function" + | "return" + | "if" + | "else" + | "for" + | "while" + | "new" + | "null" + | "true" + | "false" + | "undefined" + | "typeof" + ) +} diff --git a/crates/js/src/xfa/host.rs b/crates/js/src/xfa/host.rs new file mode 100644 index 0000000000..ea2d4d4332 --- /dev/null +++ b/crates/js/src/xfa/host.rs @@ -0,0 +1,286 @@ +use super::model::{ + MAX_ALERTS, MAX_CONSOLE, MAX_DEPTH, MAX_EFFECTS, MAX_NODES, MAX_PAYLOAD, MAX_VALUE, XfaDoc, XfaEffect, XfaKind, XfaNode, clip, clip_value, + effect_key, payload, +}; +use super::{Context, HashMap, JsResult, RefCell, error}; + +/// One node of the flattened form, shared by the JavaScript and FormCalc engines. +pub(crate) struct HNode { + pub(crate) name: String, + pub(crate) som: String, + pub(crate) kind: XfaKind, + pub(crate) value: String, + pub(crate) numeric: bool, + pub(crate) presence: String, + pub(crate) access: String, + pub(crate) repeatable: bool, + pub(crate) occur_min: usize, + pub(crate) occur_max: Option, + pub(crate) index: usize, + pub(crate) parent: Option, + pub(crate) children: Vec, + /// Removed by `removeInstance`: skipped everywhere. + pub(crate) gone: bool, + /// See [`XfaNode::formatted`]; dropped when a script sets the value, which it no longer shows. + pub(crate) formatted: Option, +} + +/// The form as a script sees and changes it, with the effects it records. +pub(crate) struct XHost { + pub(crate) nodes: Vec, + pub(crate) effects: Vec, + pub(crate) console: Vec, + pub(crate) doc: XfaDoc, + pub(super) added: usize, + /// Index of `this`. + pub(crate) current: usize, + /// Where the effect setting a property of an object is in `effects`, by (property, SOM): + /// setting it again replaces that effect (the last value wins) unless something that + /// depends on order (rows added or removed, a reset) came after it. + set_at: HashMap<(u8, String), usize>, + /// Effects before this index are behind an order-dependent effect. + barrier: usize, + alerts: usize, + dropped_effects: usize, + clipped_values: usize, + dropped_alerts: usize, + dropped_console: usize, + /// Bytes of text kept in effects, console lines and values written, against [`MAX_PAYLOAD`]. + kept: usize, + dropped_bytes: usize, +} + +impl XHost { + /// The host for a script on `root` (the template's root subform), with `this` at the node + /// named by `target`. `xfa.form` is a synthetic root whose child is the root subform. + pub(crate) fn new(root: XfaNode, target: &str, doc: &XfaDoc) -> XHost { + let form = XfaNode { name: "form".into(), kind: Some(XfaKind::Form), children: vec![root], ..Default::default() }; + let mut nodes = Vec::new(); + flatten(&form, None, &mut nodes, 0); + let current = nodes.iter().position(|n| n.som == target && n.kind != XfaKind::Form).unwrap_or(1.min(nodes.len().saturating_sub(1))); + XHost { + nodes, + effects: Vec::new(), + console: Vec::new(), + doc: doc.clone(), + added: 0, + current, + set_at: HashMap::new(), + barrier: 0, + alerts: 0, + dropped_effects: 0, + clipped_values: 0, + dropped_alerts: 0, + dropped_console: 0, + kept: 0, + dropped_bytes: 0, + } + } + + /// Room for `bytes` more bytes of kept text: charged when there is, a drop counted when not. + fn room(&mut self, bytes: usize) -> bool { + match self.kept.checked_add(bytes).filter(|k| *k <= MAX_PAYLOAD) { + Some(k) => { + self.kept = k; + true + } + None => { + self.dropped_bytes = self.dropped_bytes.saturating_add(1); + false + } + } + } + + /// Record an effect: values, presence and access set again on the same object replace the + /// earlier one; past [`MAX_EFFECTS`] (or [`MAX_ALERTS`] message boxes, or [`MAX_PAYLOAD`] + /// bytes of text) effects are counted and dropped. + pub(crate) fn push(&mut self, e: XfaEffect) { + let e = self.clip_effect(e); + if self.room(payload(&e)) { + self.record(e); + } + } + + /// Room for `e`, which holds `bytes` of text, charged now: a change to the snapshot is made + /// only when the effect that applies it will be kept, so a script never sees a change its + /// effects leave out. An effect that replaces an earlier one needs no new slot. A drop is + /// counted when there is no room. + pub(super) fn reserve(&mut self, e: &XfaEffect, bytes: usize) -> bool { + let replaces = effect_key(e).and_then(|k| self.set_at.get(&k)).is_some_and(|&at| at >= self.barrier && at < self.effects.len()); + if !replaces && self.effects.len() >= MAX_EFFECTS { + self.dropped_effects += 1; + return false; + } + self.room(bytes) + } + + /// Set the presence or access of node `i` to what `e` (a [`XfaEffect::SetPresence`] or + /// [`XfaEffect::SetAccess`]) says, in the snapshot only when the effect is kept. + pub(crate) fn set_state(&mut self, i: usize, e: XfaEffect) { + if !self.reserve(&e, payload(&e)) { + return; + } + if let Some(n) = self.nodes.get_mut(i) { + match &e { + XfaEffect::SetPresence { presence, .. } => n.presence = presence.clone(), + XfaEffect::SetAccess { access, .. } => n.access = access.clone(), + _ => {} + } + } + self.record(e); + } + + /// `e` with its text cut to the lengths an effect keeps. + fn clip_effect(&mut self, e: XfaEffect) -> XfaEffect { + match e { + XfaEffect::MessageBox(m) => XfaEffect::MessageBox(clip(m)), + XfaEffect::LaunchUrl(u) => XfaEffect::LaunchUrl(clip(u)), + XfaEffect::SetFocus(s) => XfaEffect::SetFocus(clip(s)), + XfaEffect::ResetData(names) => XfaEffect::ResetData(names.into_iter().map(clip).collect()), + XfaEffect::SetValue { som, value } => { + let (value, cut) = clip_value(value); + if cut { + self.clipped_values += 1; + } + XfaEffect::SetValue { som, value } + } + other => other, + } + } + + /// Keep `e`, whose bytes are already charged, against the count limits. + pub(super) fn record(&mut self, e: XfaEffect) { + if let Some(key) = effect_key(&e) { + if let Some(&at) = self.set_at.get(&key) + && at >= self.barrier + && let Some(slot) = self.effects.get_mut(at) + { + *slot = e; + return; + } + if self.effects.len() >= MAX_EFFECTS { + self.dropped_effects += 1; + return; + } + self.set_at.insert(key, self.effects.len()); + self.effects.push(e); + return; + } + if matches!(e, XfaEffect::MessageBox(_)) { + if self.alerts >= MAX_ALERTS { + self.dropped_alerts += 1; + return; + } + self.alerts += 1; + } + if self.effects.len() >= MAX_EFFECTS { + self.dropped_effects += 1; + return; + } + if matches!(e, XfaEffect::AddInstance { .. } | XfaEffect::RemoveInstance { .. } | XfaEffect::ResetData(_)) { + self.barrier = self.effects.len() + 1; + } + self.effects.push(e); + } + + pub(crate) fn print(&mut self, line: String) { + if self.console.len() >= MAX_CONSOLE { + self.dropped_console += 1; + } else if self.room(line.len()) { + self.console.push(line); + } + } + + /// What the limits left out. + pub(crate) fn notes(&self) -> Vec { + let mut out = Vec::new(); + if self.dropped_effects > 0 { + out.push(format!("the script made more than {MAX_EFFECTS} changes; {} more were left out", self.dropped_effects)); + } + if self.dropped_alerts > 0 { + out.push(format!("the script showed more than {MAX_ALERTS} messages; {} more were left out", self.dropped_alerts)); + } + if self.dropped_console > 0 { + out.push(format!("the script printed more than {MAX_CONSOLE} console lines; {} more were left out", self.dropped_console)); + } + if self.dropped_bytes > 0 { + out.push(format!("the script's changes and output passed {} MiB of text; {} more were left out", MAX_PAYLOAD >> 20, self.dropped_bytes)); + } + if self.clipped_values > 0 { + out.push(format!("the script set {} value(s) longer than {MAX_VALUE} characters; they were cut", self.clipped_values)); + } + out + } + + /// Give node `i` the value `text` when it is a field or exclusion group (nothing else + /// holds a value; writing one into a subform would put text where another viewer expects + /// a data group). Returns whether it was. + pub(crate) fn set_value(&mut self, i: usize, text: String) -> bool { + let Some(n) = self.nodes.get(i) else { return false }; + if n.gone || !matches!(n.kind, XfaKind::Field | XfaKind::ExclGroup) { + return false; + } + let som = n.som.clone(); + // Cut here, not only in the effect: later reads in the script see what is saved, and + // the snapshot stays bounded however often a long value is written. + let (text, cut) = clip_value(text); + if cut { + self.clipped_values += 1; + } + // The snapshot's copy and the effect's are charged together, before either is kept: + // past the byte budget or the effect cap the write is left out of both. + let effect = XfaEffect::SetValue { som, value: text.clone() }; + if !self.reserve(&effect, text.len().saturating_add(payload(&effect))) { + return true; + } + if let Some(n) = self.nodes.get_mut(i) { + n.value = text; + n.formatted = None; + } + self.record(effect); + true + } +} + +pub(super) type Shared = RefCell; + +/// The form host of the script running in `ctx`. +pub(super) fn host(ctx: &Context) -> JsResult<&Shared> { + ctx.get_data::().ok_or_else(|| error("the XFA script host is not set up")) +} + +/// The snapshot `n` and its descendants flattened into `out` under `parent` (to [`MAX_DEPTH`] +/// and [`MAX_NODES`]): the index of `n`'s node, `None` past the limits. +fn flatten(n: &XfaNode, parent: Option, out: &mut Vec, depth: usize) -> Option { + if depth > MAX_DEPTH || out.len() >= MAX_NODES { + return None; + } + let idx = out.len(); + out.push(HNode { + name: n.name.clone(), + som: n.som.clone(), + kind: n.kind.unwrap_or(XfaKind::Subform), + value: n.value.clone(), + numeric: n.numeric, + presence: if n.presence.is_empty() { "visible".into() } else { n.presence.clone() }, + access: if n.access.is_empty() { "open".into() } else { n.access.clone() }, + repeatable: n.repeatable, + occur_min: n.occur_min, + occur_max: n.occur_max, + index: n.index, + parent, + children: Vec::new(), + gone: false, + formatted: n.formatted.clone(), + }); + let mut kids = Vec::new(); + for c in &n.children { + if let Some(ci) = flatten(c, Some(idx), out, depth + 1) { + kids.push(ci); + } + } + if let Some(h) = out.get_mut(idx) { + h.children = kids; + } + Some(idx) +} diff --git a/crates/js/src/xfa/instances.rs b/crates/js/src/xfa/instances.rs new file mode 100644 index 0000000000..c099c5abe8 --- /dev/null +++ b/crates/js/src/xfa/instances.rs @@ -0,0 +1,279 @@ +use super::host::{HNode, XHost, host}; +use super::model::{MAX_ADDED, MAX_DEPTH, MAX_NODES, XfaEffect}; +use super::nodes::{instances, node_object}; +use super::{Attribute, Context, JsArray, JsObject, JsResult, JsValue, NativeFunction, ObjectInitializer, arg, function, js_string, s, text}; + +pub(super) fn list_object(ctx: &mut Context, items: &[usize]) -> JsResult { + let vals: Vec = items.iter().map(|i| JsValue::from(*i as f64)).collect(); + let arr = JsArray::from_iter(vals, ctx); + let arr_obj: JsObject = arr.into(); + fn item(_: &JsValue, args: &[JsValue], arr: &JsObject, ctx: &mut Context) -> JsResult { + let i = arg(args, 0).to_number(ctx)?; + if !i.is_finite() || i < 0.0 { + return Ok(JsValue::null()); + } + let v = JsArray::from_object(arr.clone())?.get(i as u64, ctx)?; + if v.is_undefined() { + return Ok(JsValue::null()); + } + let idx = v.to_number(ctx)? as usize; + node_object(ctx, idx) + } + let item_fn = function(ctx, NativeFunction::from_copy_closure_with_captures(item, arr_obj)); + Ok(ObjectInitializer::new(ctx) + .property(js_string!("length"), JsValue::from(items.len() as f64), Attribute::READONLY) + .property(js_string!("item"), item_fn, Attribute::all()) + .build() + .into()) +} + +/// The instance manager of the subform named `name` under `parent`. +pub(super) fn instance_manager(ctx: &mut Context, parent: usize, name: &str) -> JsResult { + let (count, min, max) = { + let h = host(ctx)?.borrow(); + let live = instances(&h, parent, name); + let first = live.first().and_then(|i| h.nodes.get(*i)); + (live.len(), first.map_or(0, |n| n.occur_min), first.and_then(|n| n.occur_max)) + }; + let key = ObjectInitializer::new(ctx) + .property(js_string!("parent"), JsValue::from(parent as f64), Attribute::READONLY) + .property(js_string!("name"), s(name), Attribute::READONLY) + .build(); + fn target_of(key: &JsObject, ctx: &mut Context) -> JsResult<(usize, String)> { + let p = key.get(js_string!("parent"), ctx)?.to_number(ctx)? as usize; + let n = text(&key.get(js_string!("name"), ctx)?, ctx)?; + Ok((p, n)) + } + fn add_instance(_: &JsValue, _: &[JsValue], key: &JsObject, ctx: &mut Context) -> JsResult { + let (p, n) = target_of(key, ctx)?; + match add_instance_of(ctx, p, &n)? { + Some(i) => node_object(ctx, i), + None => Ok(JsValue::null()), + } + } + fn insert_instance(this: &JsValue, args: &[JsValue], key: &JsObject, ctx: &mut Context) -> JsResult { + add_instance(this, args, key, ctx) + } + fn remove_instance(_: &JsValue, args: &[JsValue], key: &JsObject, ctx: &mut Context) -> JsResult { + let (p, n) = target_of(key, ctx)?; + let i = arg(args, 0).to_number(ctx)?; + if i.is_finite() && i >= 0.0 { + remove_instance_of(ctx, p, &n, i as usize)?; + } + Ok(JsValue::undefined()) + } + fn set_instances(_: &JsValue, args: &[JsValue], key: &JsObject, ctx: &mut Context) -> JsResult { + let (p, n) = target_of(key, ctx)?; + let want = arg(args, 0).to_number(ctx)?; + if !want.is_finite() || want < 0.0 { + return Ok(JsValue::undefined()); + } + host(ctx)?.borrow_mut().set_instances(p, &n, want as usize); + Ok(JsValue::undefined()) + } + fn move_instance(_: &JsValue, _: &[JsValue], _: &JsObject, _: &mut Context) -> JsResult { + Ok(JsValue::undefined()) + } + fn count_get(_: &JsValue, _: &[JsValue], key: &JsObject, ctx: &mut Context) -> JsResult { + let (p, n) = target_of(key, ctx)?; + let c = instances(&host(ctx)?.borrow(), p, &n).len(); + Ok(JsValue::from(c as f64)) + } + fn count_set(_: &JsValue, args: &[JsValue], key: &JsObject, ctx: &mut Context) -> JsResult { + set_instances(&JsValue::undefined(), args, key, ctx) + } + let count_g = function(ctx, NativeFunction::from_copy_closure_with_captures(count_get, key.clone())); + let count_s = function(ctx, NativeFunction::from_copy_closure_with_captures(count_set, key.clone())); + let add = function(ctx, NativeFunction::from_copy_closure_with_captures(add_instance, key.clone())); + let insert = function(ctx, NativeFunction::from_copy_closure_with_captures(insert_instance, key.clone())); + let remove = function(ctx, NativeFunction::from_copy_closure_with_captures(remove_instance, key.clone())); + let set = function(ctx, NativeFunction::from_copy_closure_with_captures(set_instances, key.clone())); + let mv = function(ctx, NativeFunction::from_copy_closure_with_captures(move_instance, key)); + let _ = count; + let o = ObjectInitializer::new(ctx) + .property(js_string!("min"), JsValue::from(min as f64), Attribute::READONLY) + .property(js_string!("max"), max.map_or(JsValue::from(-1.0), |m| JsValue::from(m as f64)), Attribute::READONLY) + .property(js_string!("addInstance"), add, Attribute::all()) + .property(js_string!("insertInstance"), insert, Attribute::all()) + .property(js_string!("removeInstance"), remove, Attribute::all()) + .property(js_string!("setInstances"), set, Attribute::all()) + .property(js_string!("moveInstance"), mv, Attribute::all()) + .property(js_string!("className"), s("instanceManager"), Attribute::READONLY) + .build(); + use boa_engine::property::PropertyDescriptor; + o.define_property_or_throw(js_string!("count"), PropertyDescriptor::builder().get(count_g).set(count_s).configurable(true), ctx)?; + Ok(o.into()) +} + +/// Re-derive the SOM of `idx` and everything under it from its parent and index. +fn renumber(h: &mut XHost, idx: usize, depth: usize) { + if depth > MAX_DEPTH { + return; + } + let (parent_som, name, index, kids) = { + let Some(n) = h.nodes.get(idx) else { return }; + let parent_som = n.parent.and_then(|p| h.nodes.get(p)).map(|p| p.som.clone()).unwrap_or_default(); + (parent_som, n.name.clone(), n.index, n.children.clone()) + }; + let som = child_som(parent_som, &name, index); + if let Some(n) = h.nodes.get_mut(idx) { + n.som = som; + } + for k in kids { + renumber(h, k, depth + 1); + } +} + +/// The SOM of instance `index` of the node `name` under a parent at `parent_som`. +fn child_som(parent_som: String, name: &str, index: usize) -> String { + if name.starts_with('#') || name.is_empty() { + parent_som + } else if parent_som.is_empty() { + format!("{name}[{index}]") + } else { + format!("{parent_som}.{name}[{index}]") + } +} + +/// A copy of `src`'s subtree (values cleared) under `parent`, returning the copy's index. +fn clone_subtree(h: &mut XHost, src: usize, parent: Option, depth: usize) -> Option { + if depth > MAX_DEPTH || h.nodes.len() >= MAX_NODES { + return None; + } + let (template, kids) = { + let n = h.nodes.get(src)?; + ( + HNode { + name: n.name.clone(), + som: String::new(), + kind: n.kind, + value: String::new(), + numeric: n.numeric, + presence: n.presence.clone(), + access: n.access.clone(), + repeatable: n.repeatable, + occur_min: n.occur_min, + occur_max: n.occur_max, + index: n.index, + parent, + children: Vec::new(), + gone: false, + // The copy holds no value, so nothing is shown in its picture either. + formatted: None, + }, + n.children.clone(), + ) + }; + let idx = h.nodes.len(); + h.nodes.push(template); + let mut copied = Vec::new(); + for k in kids { + // Nested repeating subforms copy their first instance only. + let skip = h.nodes.get(k).is_some_and(|c| c.gone || c.repeatable && c.index > 0); + if skip { + continue; + } + if let Some(ci) = clone_subtree(h, k, Some(idx), depth + 1) { + copied.push(ci); + } + } + if let Some(n) = h.nodes.get_mut(idx) { + n.children = copied; + } + Some(idx) +} + +fn add_instance_of(ctx: &mut Context, parent: usize, name: &str) -> JsResult> { + let mut h = host(ctx)?.borrow_mut(); + Ok(h.add_instance(parent, name)) +} + +fn remove_instance_of(ctx: &mut Context, parent: usize, name: &str, i: usize) -> JsResult<()> { + let mut h = host(ctx)?.borrow_mut(); + h.remove_instance(parent, name, i); + Ok(()) +} + +impl XHost { + /// A new instance of the subform `name` under `parent`, after the last one; `None` when the + /// subform doesn't repeat, is at its maximum, or the run added its limit. + pub(crate) fn add_instance(&mut self, parent: usize, name: &str) -> Option { + let h = self; + let live = instances(h, parent, name); + let &first = live.first()?; + let (repeatable, max) = h.nodes.get(first).map(|n| (n.repeatable, n.occur_max)).unwrap_or((false, Some(1))); + if !repeatable || max.is_some_and(|m| live.len() >= m) || h.added >= MAX_ADDED || h.nodes.len() >= MAX_NODES { + return None; + } + // The copy's SOM, as `renumber` will give it, so its effect is reserved before the copy. + let name = h.nodes.get(first).map(|n| n.name.clone()).unwrap_or_default(); + let parent_som = h.nodes.get(parent).map(|p| p.som.clone()).unwrap_or_default(); + let som = child_som(parent_som, &name, live.len()); + if !h.reserve(&XfaEffect::AddInstance { som: String::new() }, som.len()) { + return None; + } + let new = clone_subtree(h, first, Some(parent), 0)?; + let last = live.last().copied().unwrap_or(first); + if let Some(n) = h.nodes.get_mut(new) { + n.index = live.len(); + } + // Keep instances together: right after the last one. + if let Some(p) = h.nodes.get_mut(parent) { + let pos = p.children.iter().position(|&c| c == last).map_or(p.children.len(), |i| i + 1); + p.children.insert(pos, new); + } + renumber(h, new, 0); + h.added += 1; + h.record(XfaEffect::AddInstance { som }); + Some(new) + } + + /// Add or remove instances of the subform `name` under `parent` until there are `want`. + /// Bounded: adding stops at the cap or the maximum, removing at the floor, and a step that + /// changes nothing ends the loop. + pub(crate) fn set_instances(&mut self, parent: usize, name: &str, want: usize) { + let want = want.min(MAX_ADDED); + for _ in 0..=MAX_ADDED { + let have = instances(self, parent, name).len(); + if have < want { + if self.add_instance(parent, name).is_none() { + break; + } + } else if have > want && have > 0 { + self.remove_instance(parent, name, have - 1); + if instances(self, parent, name).len() == have { + break; + } + } else { + break; + } + } + } + + /// Remove instance `i` of the subform `name` under `parent` (never below the minimum, or + /// below one). + pub(crate) fn remove_instance(&mut self, parent: usize, name: &str, i: usize) { + let h = self; + let live = instances(h, parent, name); + let Some(&gone) = live.get(i) else { return }; + let min = h.nodes.get(gone).map_or(0, |n| n.occur_min).max(1); + if live.len() <= min { + return; + } + let som = h.nodes.get(gone).map(|n| n.som.clone()).unwrap_or_default(); + if !h.reserve(&XfaEffect::RemoveInstance { som: String::new() }, som.len()) { + return; + } + if let Some(n) = h.nodes.get_mut(gone) { + n.gone = true; + } + // Later instances move down one. + for (k, &inst) in live.iter().enumerate().skip(i + 1) { + if let Some(n) = h.nodes.get_mut(inst) { + n.index = k - 1; + } + renumber(h, inst, 0); + } + h.record(XfaEffect::RemoveInstance { som }); + } +} diff --git a/crates/js/src/xfa/model.rs b/crates/js/src/xfa/model.rs new file mode 100644 index 0000000000..83b2d7d149 --- /dev/null +++ b/crates/js/src/xfa/model.rs @@ -0,0 +1,228 @@ +use super::HashSet; + +/// What kind of template object a node is. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum XfaKind { + /// `xfa.form` itself. + Form, + Subform, + Field, + ExclGroup, + Draw, + Area, +} + +impl XfaKind { + pub(crate) fn class_name(self) -> &'static str { + match self { + XfaKind::Form => "form", + XfaKind::Subform => "subform", + XfaKind::Field => "field", + XfaKind::ExclGroup => "exclGroup", + XfaKind::Draw => "draw", + XfaKind::Area => "area", + } + } +} + +/// One object of the form, as the caller snapshots it. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct XfaNode { + pub name: String, + /// The SOM expression naming this instance (`form[0].head[0].familyName[0]`). + pub som: String, + pub kind: Option, + /// Fields: the raw value (ISO dates, on values for buttons); empty when null. + pub value: String, + /// Numeric fields give numbers to scripts. + pub numeric: bool, + /// `visible`, `invisible`, `hidden` or `inactive`. + pub presence: String, + /// `open`, `readOnly`, `protected` or `nonInteractive`. + pub access: String, + /// Subforms: may have several instances (`occur max` other than 1). + pub repeatable: bool, + pub occur_min: usize, + pub occur_max: Option, + /// This instance's index among its same-named siblings. + pub index: usize, + pub children: Vec, + /// Fields: the value shown in the field's display picture, what `formattedValue` reads; + /// `None` when the picture doesn't change it (the raw value is read then). + pub formatted: Option, +} + +/// The event a script runs for. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct XfaEvent { + /// `click`, `calculate`, `validate`, `initialize`, `change`, `exit`, … + pub activity: String, + /// The SOM of `this`. + pub target: String, + pub new_text: String, + pub prev_text: String, +} + +/// About the document. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct XfaDoc { + pub file_name: String, + /// The current page, 0-based. + pub page: usize, + pub page_count: usize, +} + +/// Something a script changed or asked for. +#[derive(Clone, Debug, PartialEq)] +pub enum XfaEffect { + SetValue { + som: String, + value: String, + }, + SetPresence { + som: String, + presence: String, + }, + SetAccess { + som: String, + access: String, + }, + /// A new instance of a repeating subform, named by its own SOM (`…row[3]`). + AddInstance { + som: String, + }, + /// The instance at this SOM is removed. + RemoveInstance { + som: String, + }, + MessageBox(String), + /// `xfa.host.resetData()`: the named fields, or every field when empty. + ResetData(Vec), + /// `app.execMenuItem("ShowHide…")`: show or hide a side panel. + ShowPanel(crate::Panel), + Print, + SaveAs, + LaunchUrl(String), + SetFocus(String), + Beep, + /// `xfa.form.recalculate(true)`. + Recalculate, + /// `xfa.form.remerge()`, `xfa.layout.relayout()`. + Relayout, +} + +/// What running a script did. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct XfaOutcome { + pub effects: Vec, + pub console: Vec, + /// The script's error, if it failed. + pub error: Option, + /// The script's completion value (a calculate script's result; a validate script's verdict). + pub result: Option, + /// The completion value as a boolean, when it was one. + pub result_bool: Option, + /// Limits the script ran into (too many effects, message boxes or console lines): what + /// was left out, for the caller to report. + pub notes: Vec, + /// The script ran past its time limit and was left running on its own thread (the engine + /// can't interrupt it): the caller should run no more of this form's scripts. + pub abandoned: bool, +} + +/// Most nodes a snapshot may hold. +pub(super) const MAX_NODES: usize = 200_000; + +/// Most instances a script may add in one run. +pub(crate) const MAX_ADDED: usize = 1_000; + +/// Deepest tree followed. +pub(super) const MAX_DEPTH: usize = 64; + +/// Most effects one run may produce (after values set again on the same object are merged). +pub const MAX_EFFECTS: usize = 10_000; + +/// Most message boxes one run may show. +pub const MAX_ALERTS: usize = 100; + +/// Most console lines one run may print. +pub const MAX_CONSOLE: usize = 1_000; + +/// Longest message or console line kept, in characters. +pub(crate) const MAX_LINE: usize = 4_096; + +/// Most bytes of text one run may hand back or write: messages, console lines, URLs, names +/// and the values it sets. Past it, more are left out and noted. +pub(crate) const MAX_PAYLOAD: usize = 32 << 20; + +/// Longest value a script may put in a field (characters); longer ones are cut and noted. +pub(super) const MAX_VALUE: usize = 65_536; + +/// `m` cut to [`MAX_LINE`] characters. +pub(crate) fn clip(m: String) -> String { + match m.char_indices().nth(MAX_LINE) { + Some((at, _)) => format!("{}…", m.get(..at).unwrap_or_default()), + None => m, + } +} + +/// `v` cut to [`MAX_VALUE`] characters, and whether it was cut. +pub(crate) fn clip_value(v: String) -> (String, bool) { + match v.char_indices().nth(MAX_VALUE) { + Some((at, _)) => (v.get(..at).unwrap_or_default().to_string(), true), + None => (v, false), + } +} + +/// The field names of a `resetData` list (comma-separated), each cut to [`MAX_LINE`] and +/// named once, at most as many as a snapshot holds nodes. +pub(crate) fn reset_names(list: &str) -> Vec { + let mut seen = HashSet::new(); + let mut out = Vec::new(); + for name in list.split(',').map(str::trim).filter(|s| !s.is_empty()) { + if out.len() >= MAX_NODES { + break; + } + let name = clip(name.to_string()); + if seen.insert(name.clone()) { + out.push(name); + } + } + out +} + +/// The (property, SOM) an effect setting a property of an object is merged by, see +/// [`XHost::set_at`]. +pub(super) fn effect_key(e: &XfaEffect) -> Option<(u8, String)> { + match e { + XfaEffect::SetValue { som, .. } => Some((0, som.clone())), + XfaEffect::SetPresence { som, .. } => Some((1, som.clone())), + XfaEffect::SetAccess { som, .. } => Some((2, som.clone())), + _ => None, + } +} + +/// Bytes of text `e` holds. +pub(super) fn payload(e: &XfaEffect) -> usize { + match e { + XfaEffect::SetValue { som, value: s } | XfaEffect::SetPresence { som, presence: s } | XfaEffect::SetAccess { som, access: s } => { + som.len().saturating_add(s.len()) + } + XfaEffect::AddInstance { som } | XfaEffect::RemoveInstance { som } => som.len(), + XfaEffect::MessageBox(s) | XfaEffect::LaunchUrl(s) | XfaEffect::SetFocus(s) => s.len(), + XfaEffect::ResetData(names) => names.iter().fold(0usize, |n, s| n.saturating_add(s.len()).saturating_add(std::mem::size_of::())), + XfaEffect::Print | XfaEffect::SaveAs | XfaEffect::Beep | XfaEffect::Recalculate | XfaEffect::Relayout | XfaEffect::ShowPanel(_) => 0, + } +} + +/// A script's completion value (what a calculate script puts in its field) cut to +/// [`MAX_VALUE`] characters, as values a script sets are, with a note when it was. +pub(crate) fn clip_result(out: &mut XfaOutcome) { + if let Some(r) = out.result.take() { + let (r, cut) = clip_value(r); + if cut { + out.notes.push(format!("the script's result was longer than {MAX_VALUE} characters; it was cut")); + } + out.result = Some(r); + } +} diff --git a/crates/js/src/xfa/nodes.rs b/crates/js/src/xfa/nodes.rs new file mode 100644 index 0000000000..75943352e6 --- /dev/null +++ b/crates/js/src/xfa/nodes.rs @@ -0,0 +1,346 @@ +use super::globals::{recalculate, relayout}; +use super::host::{HNode, XHost, host}; +use super::instances::{instance_manager, list_object}; +use super::model::{MAX_DEPTH, XfaEffect}; +use super::{ + Attribute, Context, JsObject, JsProxy, JsResult, JsString, JsValue, NativeFunction, ObjectInitializer, arg, as_number, error, function, + js_string, s, text, +}; + +// ── node objects ──────────────────────────────────────────────────────────────────────────── + +const IDX: &str = "__xfaNode"; + +pub(super) fn node_index(target: &JsValue, ctx: &mut Context) -> JsResult { + let o = target.as_object().ok_or_else(|| error("not an XFA node"))?; + let v = o.get(JsString::from(IDX), ctx)?; + let n = v.to_number(ctx)?; + if !n.is_finite() || n < 0.0 { + return Err(error("not an XFA node")); + } + Ok(n as usize) +} + +/// A Proxy standing for node `idx`. +pub(super) fn node_object(ctx: &mut Context, idx: usize) -> JsResult { + let target = ObjectInitializer::new(ctx).property(JsString::from(IDX), JsValue::from(idx as f64), Attribute::READONLY).build(); + let proxy = JsProxy::builder(target).get(node_get).set(node_set).has(node_has).build(ctx)?; + Ok(proxy.into()) +} + +/// An object that takes any property read or write without complaint (`this.border.fill…`, +/// `this.fillColor = …`): what a script sets there changes nothing here. +pub(super) fn sink(ctx: &mut Context) -> JsResult { + let target = ObjectInitializer::new(ctx).build(); + let proxy = JsProxy::builder(target).get(sink_get).set(sink_set).build(ctx)?; + Ok(proxy.into()) +} + +fn sink_get(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let key = arg(args, 1); + if key.as_symbol().is_some() { + return Ok(JsValue::undefined()); + } + match text(&key, ctx)?.as_str() { + "value" | "toString" | "valueOf" => Ok(s("")), + _ => sink(ctx), + } +} + +fn sink_set(_: &JsValue, _: &[JsValue], _: &mut Context) -> JsResult { + Ok(JsValue::from(true)) +} + +fn node_has(_: &JsValue, _: &[JsValue], _: &mut Context) -> JsResult { + Ok(JsValue::from(true)) +} + +fn value_js(h: &HNode) -> JsValue { + if h.value.is_empty() { + return JsValue::null(); + } + if h.numeric + && let Some(n) = as_number(&h.value) + { + return JsValue::from(n); + } + s(&h.value) +} + +/// `(idx, name)` of the child of `parent` named `name`, first instance. +pub(crate) fn child_named(h: &XHost, parent: usize, name: &str) -> Option { + let p = h.nodes.get(parent)?; + p.children.iter().copied().find(|&c| h.nodes.get(c).is_some_and(|n| !n.gone && n.name == name)) +} + +/// Every live instance named `name` under `parent`, in order. +pub(crate) fn instances(h: &XHost, parent: usize, name: &str) -> Vec { + h.nodes + .get(parent) + .map(|p| p.children.iter().copied().filter(|&c| h.nodes.get(c).is_some_and(|n| !n.gone && n.name == name)).collect()) + .unwrap_or_default() +} + +/// Resolve a SOM expression from node `from`: `$`, `$form`, `$record`, `$host`, `xfa.form…`, +/// `parent`, `..name` (descendant search), `name[n]`, `name[*]`, `_name`. +pub(crate) fn resolve_som(h: &XHost, from: usize, expr: &str) -> Vec { + let expr = expr.trim(); + if expr.is_empty() { + return vec![from]; + } + let root = 0usize; + let mut cur: Vec = vec![from]; + let mut rest = expr; + let starts = + [("$form", root), ("xfa.form", root), ("$record", root), ("xfa.datasets.data", root), ("xfa.record", root), ("$", from), ("this", from)]; + for (prefix, at) in starts { + if let Some(r) = rest.strip_prefix(prefix) + && (r.is_empty() || r.starts_with('.') || r.starts_with('[')) + { + cur = vec![at]; + rest = r; + break; + } + } + let mut depth = 0; + while !rest.is_empty() && depth < MAX_DEPTH { + depth += 1; + let descend = rest.starts_with(".."); + rest = rest.trim_start_matches('.'); + let end = rest.find(['.', '[']).unwrap_or(rest.len()); + let (name, after) = rest.split_at(end); + let name = name.trim(); + let (index, after) = match after.strip_prefix('[') { + Some(a) => { + let close = a.find(']').unwrap_or(a.len()); + let idx = a.get(..close).map(str::trim).unwrap_or(""); + (Some(idx.to_string()), a.get(close + 1..).unwrap_or("")) + } + None => (None, after), + }; + rest = after; + let mut next = Vec::new(); + for &c in &cur { + match name { + "" => {} + "parent" => { + if let Some(p) = h.nodes.get(c).and_then(|n| n.parent) { + next.push(p); + } + } + _ => { + let mut found: Vec = if descend { descendants_named(h, c, name, 0) } else { instances(h, c, name) }; + // A relative name a node has no child for is looked for among its siblings + // and in each enclosing container, nearest first (so a field's script can + // say `resolveNode("otherField")`). + if found.is_empty() && depth == 1 && !descend { + let mut at = h.nodes.get(c).and_then(|n| n.parent); + let mut guard = 0; + while let Some(p) = at + && guard < MAX_DEPTH + { + found = instances(h, p, name); + if !found.is_empty() { + break; + } + at = h.nodes.get(p).and_then(|n| n.parent); + guard += 1; + } + } + match index.as_deref() { + Some("*") => next.extend(found), + Some(i) => { + if let Some(k) = i.parse::().ok().and_then(|i| found.get(i)) { + next.push(*k); + } + } + None => next.extend(found.first()), + } + } + } + } + // Each node once: `row[*].parent.row[*]` would otherwise multiply the list by the + // number of rows at every step. + let mut seen = std::collections::HashSet::with_capacity(next.len()); + next.retain(|i| seen.insert(*i)); + cur = next; + if cur.is_empty() { + break; + } + } + cur +} + +/// The live descendants of node `from` named `name` (`from..name` in a SOM path), in document +/// order, to [`MAX_DEPTH`]. +pub(crate) fn descendants_named(h: &XHost, from: usize, name: &str, depth: usize) -> Vec { + let mut out = Vec::new(); + if depth > MAX_DEPTH { + return out; + } + let Some(n) = h.nodes.get(from) else { return out }; + for &c in &n.children { + if let Some(k) = h.nodes.get(c) + && !k.gone + { + if k.name == name { + out.push(c); + } else { + out.extend(descendants_named(h, c, name, depth + 1)); + } + } + } + out +} + +/// A property read on a form node's Proxy: its value (and `formattedValue` as its picture shows +/// it), attributes, relatives, methods, instance managers and children by name. +fn node_get(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let idx = node_index(&arg(args, 0), ctx)?; + let key = arg(args, 1); + if key.as_symbol().is_some() { + return Ok(JsValue::undefined()); + } + let key = text(&key, ctx)?; + let snapshot = { + let h = host(ctx)?.borrow(); + let Some(n) = h.nodes.get(idx) else { return Ok(JsValue::undefined()) }; + (n.name.clone(), n.som.clone(), n.kind, n.value.clone(), n.presence.clone(), n.access.clone(), n.index, n.parent, n.children.clone()) + }; + let (name, som, kind, value, presence, access, index, parent, children) = snapshot; + Ok(match key.as_str() { + IDX => JsValue::from(idx as f64), + "rawValue" | "value" => host(ctx)?.borrow().nodes.get(idx).map_or(JsValue::null(), value_js), + "formattedValue" => s(&host(ctx)?.borrow().nodes.get(idx).and_then(|n| n.formatted.clone()).unwrap_or(value)), + "name" => s(&name), + "somExpression" => s(&som), + "className" => s(kind.class_name()), + "index" | "instanceIndex" => JsValue::from(index as f64), + "presence" => s(&presence), + "access" => s(&access), + "isNull" => JsValue::from(value.is_empty()), + "mandatory" => s("disabled"), + "parent" => match parent { + Some(p) => node_object(ctx, p)?, + None => JsValue::null(), + }, + "nodes" => { + let live: Vec = { + let h = host(ctx)?.borrow(); + children.iter().copied().filter(|&c| h.nodes.get(c).is_some_and(|n| !n.gone)).collect() + }; + list_object(ctx, &live)? + } + "all" => { + let live = match parent { + Some(p) => instances(&host(ctx)?.borrow(), p, &name), + None => vec![idx], + }; + list_object(ctx, &live)? + } + "instanceManager" => match parent { + Some(p) => instance_manager(ctx, p, &name)?, + None => JsValue::undefined(), + }, + "resolveNode" => resolver(ctx, idx, false)?, + "resolveNodes" => resolver(ctx, idx, true)?, + "recalculate" => function(ctx, NativeFunction::from_fn_ptr(recalculate)).into(), + "remerge" => function(ctx, NativeFunction::from_fn_ptr(relayout)).into(), + "getAttribute" + | "setAttribute" + | "execEvent" + | "execInitialize" + | "execCalculate" + | "execValidate" + | "getElement" + | "setElement" + | "isPropertySpecified" + | "loadXML" + | "saveXML" + | "clone" => function(ctx, NativeFunction::from_fn_ptr(noop)).into(), + "toString" | "valueOf" | "toJSON" => function(ctx, NativeFunction::from_fn_ptr(node_to_string)).into(), + "length" => JsValue::undefined(), + "border" | "ui" | "font" | "caption" | "fillColor" | "borderColor" | "fontColor" | "margin" | "para" | "assist" | "validate" | "format" + | "items" | "bind" | "keep" | "h" | "w" | "x" | "y" | "minH" | "maxH" | "minW" | "maxW" | "layout" | "model" | "oneOfChild" | "locale" + | "relevant" | "colSpan" | "dataNode" | "defaultValue" | "editValue" | "selectedIndex" | "event" | "calculate" | "traversal" | "extras" + | "desc" => sink(ctx)?, + other => { + if let Some(child) = other.strip_prefix('_') { + let exists = child_named(&host(ctx)?.borrow(), idx, child).is_some(); + return if exists { instance_manager(ctx, idx, child) } else { Ok(JsValue::undefined()) }; + } + let found = child_named(&host(ctx)?.borrow(), idx, other); + match found { + Some(c) => node_object(ctx, c)?, + None => JsValue::undefined(), + } + } + }) +} + +fn node_set(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let idx = node_index(&arg(args, 0), ctx)?; + let key = arg(args, 1); + if key.as_symbol().is_some() { + return Ok(JsValue::from(true)); + } + let key = text(&key, ctx)?; + let value = arg(args, 2); + let value = if value.is_null() || value.is_undefined() { String::new() } else { text(&value, ctx)? }; + let mut h = host(ctx)?.borrow_mut(); + let Some(n) = h.nodes.get(idx) else { return Ok(JsValue::from(true)) }; + let som = n.som.clone(); + match key.as_str() { + "rawValue" | "value" | "formattedValue" => { + // Not a field: the property doesn't exist, as in Acrobat, and nothing is recorded. + h.set_value(idx, value); + } + "presence" => { + let p = match value.as_str() { + "hidden" | "invisible" | "inactive" | "visible" => value.clone(), + _ => return Ok(JsValue::from(true)), + }; + h.set_state(idx, XfaEffect::SetPresence { som, presence: p }); + } + "access" => { + let a = match value.as_str() { + "open" | "readOnly" | "protected" | "nonInteractive" => value.clone(), + _ => return Ok(JsValue::from(true)), + }; + h.set_state(idx, XfaEffect::SetAccess { som, access: a }); + } + _ => {} + } + Ok(JsValue::from(true)) +} + +fn node_to_string(this: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { + let idx = node_index(this, ctx)?; + Ok(s(&host(ctx)?.borrow().nodes.get(idx).map(|n| n.value.clone()).unwrap_or_default())) +} + +pub(super) fn noop(_: &JsValue, _: &[JsValue], _: &mut Context) -> JsResult { + Ok(JsValue::undefined()) +} + +/// `resolveNode(som)` / `resolveNodes(som)` bound to node `from`. +pub(super) fn resolver(ctx: &mut Context, from: usize, many: bool) -> JsResult { + let key = ObjectInitializer::new(ctx) + .property(js_string!("from"), JsValue::from(from as f64), Attribute::READONLY) + .property(js_string!("many"), JsValue::from(many), Attribute::READONLY) + .build(); + fn resolve(_: &JsValue, args: &[JsValue], key: &JsObject, ctx: &mut Context) -> JsResult { + let from = key.get(js_string!("from"), ctx)?.to_number(ctx)? as usize; + let many = key.get(js_string!("many"), ctx)?.to_boolean(); + let expr = text(&arg(args, 0), ctx)?; + let found = resolve_som(&host(ctx)?.borrow(), from, &expr); + if many { + return list_object(ctx, &found); + } + match found.first() { + Some(i) => node_object(ctx, *i), + None => Ok(JsValue::null()), + } + } + Ok(function(ctx, NativeFunction::from_copy_closure_with_captures(resolve, key)).into()) +} diff --git a/crates/js/src/xfa/run.rs b/crates/js/src/xfa/run.rs new file mode 100644 index 0000000000..f951e02a54 --- /dev/null +++ b/crates/js/src/xfa/run.rs @@ -0,0 +1,170 @@ +use super::globals::install; +use super::host::{Shared, XHost}; +use super::model::{XfaDoc, XfaEvent, XfaNode, XfaOutcome, clip_result}; +use super::{Attribute, Context, JsResult, Limits, RefCell, Source, js_string, refuse, s, text}; + +/// Run `script` for `event` on the form `root` (the root subform), from node `event.target`. +pub fn run_xfa(script: &str, event: &XfaEvent, doc: &XfaDoc, root: &XfaNode, limits: Limits) -> XfaOutcome { + let failed = |why: String| XfaOutcome { error: Some(why), ..Default::default() }; + if let Some(why) = refuse(script) { + return failed(why); + } + #[cfg(not(target_arch = "wasm32"))] + { + std::thread::scope(|scope| { + let spawned = std::thread::Builder::new() + .name("pdfcraft-xfa-js".into()) + .stack_size(crate::SCRIPT_STACK) + .spawn_scoped(scope, || run_here(script, event, doc, root.clone(), limits, &|| {})); + match spawned { + Ok(thread) => thread.join().unwrap_or_else(|_| failed("the script stopped with an internal error".into())), + Err(e) => failed(format!("the script engine could not start: {e}")), + } + }) + } + #[cfg(target_arch = "wasm32")] + run_here(script, event, doc, root.clone(), limits, &|| {}) +} + +/// [`run_xfa`] with a time limit: a script still running `timeout` after the engine is ready +/// (loops inside nested function calls can run for hours within the engine's per-frame loop +/// limit) is left on its thread, which ends when the engine's own limits stop it, and an +/// outcome with `abandoned` set comes back at once. In the browser build there are no +/// threads: the script runs to its limits. +pub fn run_xfa_within(script: &str, event: &XfaEvent, doc: &XfaDoc, root: XfaNode, limits: Limits, timeout: std::time::Duration) -> XfaOutcome { + if let Some(why) = refuse(script) { + return XfaOutcome { error: Some(why), ..Default::default() }; + } + let (script, event, doc) = (script.to_string(), event.clone(), doc.clone()); + run_within("pdfcraft-xfa-js", timeout, move |started| run_here(&script, &event, &doc, root, limits, started)) +} + +/// Longest the script engine may take to be ready for a script (its thread and context, and +/// for the first script in a process the engine's own set-up) before the script is abandoned. +/// It doesn't count against the script's time: on a busy machine the start alone can take +/// longer than the tightest limit, and abandoning a script turns the form's scripts off. +pub(crate) const ENGINE_START_LIMIT: std::time::Duration = std::time::Duration::from_secs(10); + +/// Run `script` on its own thread (with the script stack) and wait at most `timeout` for its +/// outcome, counted from when it calls `started` (once the engine is ready); past that the +/// thread is abandoned and the outcome says so. On wasm there are no threads: the script runs +/// here. +pub(crate) fn run_within(name: &str, timeout: std::time::Duration, script: impl FnOnce(&dyn Fn()) -> XfaOutcome + Send + 'static) -> XfaOutcome { + run_within_limits(name, ENGINE_START_LIMIT, timeout, script) +} + +/// [`run_within`] with the engine's start-up limit given. +pub(crate) fn run_within_limits( + name: &str, + start_limit: std::time::Duration, + timeout: std::time::Duration, + script: impl FnOnce(&dyn Fn()) -> XfaOutcome + Send + 'static, +) -> XfaOutcome { + #[cfg(not(target_arch = "wasm32"))] + { + use std::sync::mpsc::RecvTimeoutError; + use std::time::Instant; + enum Step { + Started, + Done(XfaOutcome), + } + let failed = |why: String| XfaOutcome { error: Some(why), ..Default::default() }; + let abandoned = |why: String| XfaOutcome { error: Some(why), abandoned: true, ..Default::default() }; + let (tx, rx) = std::sync::mpsc::channel(); + let spawned = std::thread::Builder::new().name(name.into()).stack_size(crate::SCRIPT_STACK).spawn(move || { + // The receiver is gone when the caller stopped waiting: nothing to report then. + let started = tx.clone(); + let outcome = script(&move || { + let _ = started.send(Step::Started); + }); + let _ = tx.send(Step::Done(outcome)); + }); + if let Err(e) = spawned { + return failed(format!("the script engine could not start: {e}")); + } + // Wait for the engine within its start-up limit, then for the script within its own. + let (mut running, mut limit) = (false, start_limit); + let mut deadline = Instant::now().checked_add(limit); + loop { + let left = deadline.map_or(limit, |d| d.saturating_duration_since(Instant::now())); + match rx.recv_timeout(left) { + Ok(Step::Done(o)) => return o, + Ok(Step::Started) => { + // A second call changes nothing: the script's time has already begun. + if !running { + (running, limit) = (true, timeout); + deadline = Instant::now().checked_add(limit); + } + } + Err(RecvTimeoutError::Timeout) if running => { + return abandoned(format!("the script ran longer than {:.1} s and was abandoned", timeout.as_secs_f64())); + } + Err(RecvTimeoutError::Timeout) => { + return abandoned(format!("the script engine took longer than {:.0} s to start", start_limit.as_secs_f64())); + } + Err(RecvTimeoutError::Disconnected) => return failed("the script stopped with an internal error".into()), + } + } + } + #[cfg(target_arch = "wasm32")] + { + let _ = (name, start_limit, timeout); + script(&|| {}) + } +} + +/// The first script in a process sets the engine up (its built-ins, parser and compiler come +/// into use): a trivial script takes that on, so a form's script is never timed with it. +pub(crate) fn warm_up() { + static WARM: std::sync::Once = std::sync::Once::new(); + // `call_once_force`: a poisoned `Once` (a panic while warming up) only means warming up again. + WARM.call_once_force(|_| { + let mut ctx = Context::default(); + let _ = ctx.eval(Source::from_bytes(b"(function () { return eval('0'); }).call({})")); + }); +} + +/// `started` is called once the engine is ready: a time limit on the script runs from then. +pub(crate) fn run_here(script: &str, event: &XfaEvent, doc: &XfaDoc, root: XfaNode, limits: Limits, started: &dyn Fn()) -> XfaOutcome { + warm_up(); + let mut ctx = match Context::builder().host_hooks(std::rc::Rc::new(crate::CheckedCodeFromStrings)).build() { + Ok(ctx) => ctx, + Err(e) => return XfaOutcome { error: Some(format!("the script engine could not start: {e}")), ..Default::default() }, + }; + ctx.runtime_limits_mut().set_loop_iteration_limit(limits.loop_iterations); + ctx.runtime_limits_mut().set_recursion_limit(limits.recursion); + let host_state = XHost::new(root, &event.target, doc); + let current = host_state.current; + ctx.insert_data(RefCell::new(host_state)); + let mut out = XfaOutcome::default(); + let result = (|| -> JsResult<()> { + install(&mut ctx, event, doc, current)?; + // Ready for the script: its time starts now, the set-up above not charged to it. + started(); + ctx.register_global_property(js_string!("__xfa_src"), s(script), Attribute::CONFIGURABLE)?; + // Direct eval inside a function called on the node: `this` is the node and the + // completion value is the script's last expression (what a calculate script yields). + let r = ctx.eval(Source::from_bytes(b"(function () { return eval(__xfa_src); }).call(__xfa_this)"))?; + if !r.is_undefined() { + // Best effort: a value that can't be made text (a symbol, say) is no failure. + out.result = Some(text(&r, &mut ctx).unwrap_or_default()); + if r.is_boolean() { + out.result_bool = Some(r.to_boolean()); + } else if r.is_null() { + out.result = Some(String::new()); + } + } + Ok(()) + })(); + if let Err(e) = result { + out.error = Some(e.to_string()); + } + if let Some(h) = ctx.remove_data::() { + let h = h.into_inner(); + out.notes = h.notes(); + out.effects = h.effects; + out.console = h.console; + } + clip_result(&mut out); + out +} diff --git a/crates/measure/src/tests.rs b/crates/measure/src/tests.rs index 8d69a05cf0..5b277e5546 100644 --- a/crates/measure/src/tests.rs +++ b/crates/measure/src/tests.rs @@ -80,7 +80,7 @@ fn standard_annotations_captions_restyle_move_and_unknown_keys() { let (r, dict) = annot(&d, 0, 0).unwrap(); assert_eq!(dict.name(b"IT"), Some(&b"LineDimension"[..])); d.update_dict(r, |d| d.set(b"VendorKey".to_vec(), Object::Int(42))).unwrap(); - pdfcraft_annot::set_style(&mut d, 0, 0, Some([1.0, 0.0, 0.0]), None, Some(2.0), None, &meta).unwrap(); + pdfcraft_annot::set_style(&mut d, 0, 0, pdfcraft_annot::StyleChange::new(Some([1.0, 0.0, 0.0]), None, Some(2.0), None), &meta).unwrap(); let (_, dict) = annot(&d, 0, 0).unwrap(); assert_eq!(dict.int(b"VendorKey"), Some(42)); let ap = d.resolve(dict.get(b"AP").unwrap()); diff --git a/crates/ocr/Cargo.toml b/crates/ocr/Cargo.toml index 46bb3a1e0e..750e78b96d 100644 --- a/crates/ocr/Cargo.toml +++ b/crates/ocr/Cargo.toml @@ -13,5 +13,8 @@ workspace = true [dependencies] pdfcraft-fonts = { workspace = true } ocrs = { version = "0.13.1", default-features = false, features = ["rten"] } -rten = { version = "0.26.0", default-features = false, features = ["rten_format"] } +rten = { version = "0.26.0", default-features = false, features = ["onnx_format", "rten_format"] } thiserror.workspace = true + +[dev-dependencies] +tempfile = "3.27" diff --git a/crates/ocr/src/lib.rs b/crates/ocr/src/lib.rs index affa4ab163..7f58270e1a 100644 --- a/crates/ocr/src/lib.rs +++ b/crates/ocr/src/lib.rs @@ -15,8 +15,8 @@ use std::path::{Path, PathBuf}; pub use pdfcraft_fonts::helvetica_width; /// The model files, as named in ATTRIBUTION.toml. -pub const DETECTION_MODEL: &str = "text-detection.rten"; -pub const RECOGNITION_MODEL: &str = "text-recognition.rten"; +pub const DETECTION_MODEL: &str = "text-detection.onnx"; +pub const RECOGNITION_MODEL: &str = "text-recognition.onnx"; /// The recognition model's alphabet, as it was trained (ocrs-models' `DEFAULT_ALPHABET`): its /// output classes follow this order. The ocrs crate's own copy has an `E` where the training had @@ -48,10 +48,13 @@ pub struct Models { } impl Models { - /// The models in `dir`, if both files are there. + /// A complete model pair in `dir`, preferring current ONNX files over legacy RTEN files. + /// An incomplete update never mixes formats or hides an intact legacy pair. pub fn in_dir(dir: &Path) -> Option { - let m = Models { detection: dir.join(DETECTION_MODEL), recognition: dir.join(RECOGNITION_MODEL) }; - (m.detection.is_file() && m.recognition.is_file()).then_some(m) + [(DETECTION_MODEL, RECOGNITION_MODEL), ("text-detection.rten", "text-recognition.rten")].into_iter().find_map(|(detection, recognition)| { + let m = Models { detection: dir.join(detection), recognition: dir.join(recognition) }; + (m.detection.is_file() && m.recognition.is_file()).then_some(m) + }) } /// Look for the models: `$PDFCRAFT_MODELS`, then where the release packages install them @@ -321,6 +324,63 @@ mod tests { assert_eq!(w.up, [-10.0, 0.0]); } + /// The current ONNX pair wins when an installation also retains both legacy models. + #[test] + fn model_discovery_prefers_the_complete_onnx_pair() { + let dir = tempfile::tempdir().unwrap(); + for file in ["text-detection.onnx", "text-recognition.onnx"] { + std::fs::write(dir.path().join(file), b"model").unwrap(); + } + let expected = Some(Models { detection: dir.path().join("text-detection.onnx"), recognition: dir.path().join("text-recognition.onnx") }); + assert_eq!(Models::in_dir(dir.path()), expected, "a complete ONNX pair is sufficient"); + for file in ["text-detection.rten", "text-recognition.rten"] { + std::fs::write(dir.path().join(file), b"model").unwrap(); + } + assert_eq!(Models::in_dir(dir.path()), expected, "the complete ONNX pair takes precedence over legacy models"); + } + + /// An interrupted ONNX update still permits an intact legacy pair in the same directory. + #[test] + fn model_discovery_falls_back_to_legacy_when_the_onnx_pair_is_incomplete() { + for current in ["text-detection.onnx", "text-recognition.onnx"] { + let dir = tempfile::tempdir().unwrap(); + for file in [current, "text-detection.rten", "text-recognition.rten"] { + std::fs::write(dir.path().join(file), b"model").unwrap(); + } + assert_eq!( + Models::in_dir(dir.path()), + Some(Models { detection: dir.path().join("text-detection.rten"), recognition: dir.path().join("text-recognition.rten") }), + "{current} alone must not hide a complete legacy pair" + ); + } + } + + /// Detection and recognition must come from one complete format pair, never a mixed update. + #[test] + fn model_discovery_refuses_mixed_format_pairs() { + for pair in [["text-detection.onnx", "text-recognition.rten"], ["text-detection.rten", "text-recognition.onnx"]] { + let dir = tempfile::tempdir().unwrap(); + for file in pair { + std::fs::write(dir.path().join(file), b"model").unwrap(); + } + assert_eq!(Models::in_dir(dir.path()), None, "{pair:?} is not a complete model pair"); + } + } + + /// Existing source builds and PDFCRAFT_MODELS overrides retain their complete RTEN pair. + #[test] + fn model_discovery_keeps_legacy_only_installations() { + let dir = tempfile::tempdir().unwrap(); + assert_eq!(Models::in_dir(dir.path()), None); + std::fs::write(dir.path().join("text-detection.rten"), b"model").unwrap(); + assert_eq!(Models::in_dir(dir.path()), None, "one legacy model is incomplete"); + std::fs::write(dir.path().join("text-recognition.rten"), b"model").unwrap(); + assert_eq!( + Models::in_dir(dir.path()), + Some(Models { detection: dir.path().join("text-detection.rten"), recognition: dir.path().join("text-recognition.rten") }) + ); + } + /// #103: every release package's layout puts the models where the installed app looks. /// Mirrors packaging/{windows/package.ps1,macos/package.sh,linux/package.sh,freebsd/package.sh}. #[test] diff --git a/crates/organize/src/import.rs b/crates/organize/src/import.rs index ef11f10007..bd1878c3bc 100644 --- a/crates/organize/src/import.rs +++ b/crates/organize/src/import.rs @@ -23,223 +23,21 @@ use pdfcraft_cos::{Dict, Document, ObjRef, Object}; use crate::{INHERITABLE, OrganizeError, pages_root, rebuild, walk}; -/// Keys that must not be followed when copying: they point back up or across the source -/// document, and following them would drag in unrelated objects. -const SKIP_ON_PAGE: &[&[u8]] = &[b"Parent", b"B", b"StructParents", b"Thumb", b"PieceInfo"]; -const SKIP_ON_ANNOT: &[&[u8]] = &[b"StructParent", b"P"]; - -/// How deep a copy follows references and nested arrays and dictionaries, counted together. -/// Real documents stay well under this (a page's resources, fonts and descriptors, nested -/// form XObjects and field trees are a few levels each); a chain of thousands of indirect -/// objects, each shallow enough for the parser, would otherwise exhaust the stack (#1800). -const MAX_COPY_DEPTH: usize = 256; - -/// Keys that stay on the widget when a combined field/widget is split (#1810): the annotation's -/// appearance, placement and page. Field state (name, value, flags, default appearance) moves to -/// the new parent field, where the widgets inherit it. -const WIDGET_KEYS: &[&[u8]] = &[ - b"Type", - b"Subtype", - b"Rect", - b"P", - b"AP", - b"AS", - b"MK", - b"BS", - b"Border", - b"C", - b"CA", - b"F", - b"H", - b"R", - b"NM", - b"M", - b"StructParent", - b"StructParents", - b"OC", - b"Tabs", - b"A", - b"Contents", - b"BM", - b"Lang", - b"AF", -]; - -struct Copier<'a> { - src: &'a Document, - /// Current recursion depth, bounded by [`MAX_COPY_DEPTH`]. - depth: usize, - map: HashMap, - /// Source page → destination page, for rewriting `/P` and destinations. - pages: HashMap, - /// Destination objects that are annotation dictionaries (fixed up after copying). - annots: Vec, - /// Top-level form fields reached through widgets. - fields: Vec, - /// Optional content groups copied: (source, destination). - ocgs: Vec<(ObjRef, ObjRef)>, -} - -impl<'a> Copier<'a> { - fn new(src: &'a Document, map: HashMap, pages: HashMap) -> Self { - Copier { src, depth: 0, map, pages, annots: Vec::new(), fields: Vec::new(), ocgs: Vec::new() } - } - - /// Copy an indirect object (once), returning its destination reference. - fn copy_ref(&mut self, dst: &mut Document, r: ObjRef) -> Result { - if let Some(d) = self.map.get(&r) { - return Ok(*d); - } - // Reserve the number first so cycles (field ↔ widget) terminate. - let new = dst.add(Object::Null); - self.map.insert(r, new); - let obj = self.src.get(r); - if obj.as_dict().is_some_and(|d| d.name(b"Type") == Some(b"OCG")) { - self.ocgs.push((r, new)); - } - let copied = self.copy_value(dst, &obj, Some(new))?; - dst.set(new, copied); - Ok(new) - } - - /// One level deeper into the object graph, or the refusal when there are too many. - fn descend(&mut self) -> Result<(), OrganizeError> { - if self.depth >= MAX_COPY_DEPTH { - return Err(OrganizeError::Invalid(format!("the pages refer to objects nested more than {MAX_COPY_DEPTH} levels deep"))); - } - self.depth += 1; - Ok(()) - } - - fn copy_value(&mut self, dst: &mut Document, o: &Object, this: Option) -> Result { - if !matches!(o, Object::Ref(_) | Object::Array(_) | Object::Dict(_) | Object::Stream(_)) { - return Ok(o.clone()); - } - self.descend()?; - let copied = match o { - Object::Ref(r) => self.copy_ref(dst, *r).map(Object::Ref), - Object::Array(a) => a.iter().map(|x| self.copy_value(dst, x, None)).collect::, _>>().map(Object::Array), - Object::Dict(d) => self.copy_dict(dst, d, this).map(Object::Dict), - Object::Stream(s) => self.copy_dict(dst, &s.dict, None).map(|dict| { - let mut s = s.clone(); - s.dict = dict; - Object::Stream(s) - }), - other => Ok(other.clone()), - }; - self.depth = self.depth.saturating_sub(1); - copied - } - - fn copy_dict(&mut self, dst: &mut Document, d: &Dict, this: Option) -> Result { - let is_annot = d.name(b"Type") == Some(b"Annot") || (d.contains(b"Subtype") && d.contains(b"Rect") && !d.contains(b"Type")); - let is_field = d.contains(b"FT") || (d.contains(b"T") && d.contains(b"Kids")); - let mut out = Dict::new(); - for (k, v) in d.iter() { - if is_annot && SKIP_ON_ANNOT.contains(&k.as_slice()) { - continue; - } - // Destinations are rewritten afterwards (they may target pages copied later). - if is_annot && (k.as_slice() == b"Dest" || k.as_slice() == b"A") { - out.set(k.clone(), v.clone()); - continue; - } - out.set(k.clone(), self.copy_value(dst, v, None)?); - } - if let Some(this) = this { - if is_annot { - self.annots.push(this); - } - // A field (or widget) without a parent is a top-level field. - if is_field && !d.contains(b"Parent") { - self.fields.push(this); - } - } - Ok(out) - } - - /// Map a source destination (explicit array or reference to one) to the destination - /// document; `None` if it targets a page that was not copied. - fn map_dest(&self, dest: &Object) -> Option { - self.map_dest_depth(dest, 0) - } - - fn map_dest_depth(&self, dest: &Object, depth: u8) -> Option { - if depth > 4 { - return None; - } - match dest { - Object::Name(n) => self.map_dest_depth(&named_dest(self.src, n)?, depth + 1), - Object::String(s) => self.map_dest_depth(&named_dest(self.src, &s.bytes)?, depth + 1), - Object::Ref(_) => self.map_dest_depth(&self.src.resolve(dest), depth + 1), - Object::Dict(d) => self.map_dest_depth(d.get(b"D")?, depth + 1), - Object::Array(a) => { - let page = a.first()?.as_ref()?; - let new_page = *self.pages.get(&page)?; - let mut a = a.clone(); - a[0] = Object::Ref(new_page); - Some(Object::Array(a)) - } - _ => None, - } - } - - /// Rewrite `/P`, `/Dest` and GoTo actions on copied annotations. - fn fix_annotations(&mut self, dst: &mut Document, annot_pages: &HashMap) -> Result<(), OrganizeError> { - for &a in &std::mem::take(&mut self.annots) { - let src_dict = dst.get(a).as_dict().cloned(); - let Some(d) = src_dict else { continue }; - let mut d = d; - if let Some(p) = annot_pages.get(&a) { - d.set(b"P".to_vec(), Object::Ref(*p)); - } - if let Some(dest) = d.get(b"Dest").cloned() { - let resolved = self.src.resolve(&dest); - match self.map_dest(&resolved) { - Some(nd) => d.set(b"Dest".to_vec(), nd), - None => { - d.remove(b"Dest"); - } - } - } - if let Some(action) = d.get(b"A").cloned() { - let action = self.src.resolve(&action); - if let Some(ad) = action.as_dict() { - if ad.name(b"S") == Some(b"GoTo") { - let target = ad.get(b"D").map(|x| self.src.resolve(x)); - match target.and_then(|t| self.map_dest(&t)) { - Some(nd) => { - let mut na = Dict::new(); - na.set(b"S".to_vec(), Object::name("GoTo")); - na.set(b"D".to_vec(), nd); - d.set(b"A".to_vec(), Object::Dict(na)); - } - None => { - d.remove(b"A"); - } - } - } else { - // Other actions (URI, Launch, JavaScript…) carry no page references. The - // copier lends its map out and takes it back with the new copies in it, - // so actions sharing an object share its one copy. - let mut c = Copier::new(self.src, std::mem::take(&mut self.map), HashMap::new()); - let copied = c.copy_value(dst, &Object::Dict(ad.clone()), None); - self.map = c.map; - d.set(b"A".to_vec(), copied?); - } - } - } - dst.set(a, Object::Dict(d)); - } - Ok(()) - } -} - -/// Copy `o` from `src` into `dst`, reusing the copies listed in `map` (source → destination), so -/// an object the copied pages already brought along (an ICC profile) is not stored twice. -pub(crate) fn copy_object(dst: &mut Document, src: &Document, o: &Object, map: HashMap) -> Result { - Copier::new(src, map, HashMap::new()).copy_value(dst, o, None) -} +mod carry; +mod combine; +mod copy; +mod fields; + +pub(crate) use carry::set_attachments; +use carry::{Mark, source_outline, write_outline_level}; +pub use combine::{ + CombineOutcome, Combiner, Run, SplitBy, combine, combine_grouped, combine_grouped_with, combine_selected, combine_selected_with, combine_with, + extract_pages, split, split_ranges, +}; +pub(crate) use copy::copy_object; +use copy::{Copier, SKIP_ON_PAGE}; +use fields::{add_kid, field_and_widget, register_fields, resolve_field_conflicts, split_own_field}; +pub use fields::{combine_field_conflicts, field_name_conflicts}; /// How colliding fully-qualified field names are resolved when pages are imported. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] @@ -678,833 +476,6 @@ fn register_layers(dst: &mut Document, src: &Document, ocgs: &[(ObjRef, ObjRef)] Ok(()) } -/// Rename the top-level fields about to be registered in `dst` whose fully-qualified names -/// (`parent.T.child`) are already taken, so each imported field stays its own field instead of -/// blurring into the destination's field of the same name (fields of one fully-qualified name -/// are one field). A colliding node's own `/T` becomes the first free `Name-1`, `Name-2`… -/// variant, so everything below it keeps its place in the tree. Returns what was renamed -/// (old fully-qualified name → new one), in order. -fn resolve_field_conflicts(dst: &mut Document, fields: &[ObjRef]) -> Result, OrganizeError> { - let mut taken = taken_field_names(dst)?; - let planned = plan_renames(dst, fields, &mut taken); - let mut renamed = Vec::new(); - for (node, new_t, old, new) in planned { - dst.update_dict(node, |d| d.set(b"T".to_vec(), Object::String(pdfcraft_cos::PdfString::text(&new_t))))?; - renamed.push((old, new)); - } - Ok(renamed) -} - -/// What importing `src` into `dst` would rename, computed without changing either document: -/// each fully-qualified name of `src`'s fields that `dst` already uses (or that an earlier -/// field of `src` itself uses) is renamed to the first free variant, exactly as the import -/// would. Unreadable structure previews as nothing: the import itself will say why it failed. -pub fn field_name_conflicts(dst: &Document, src: &Document) -> Vec<(String, String)> { - let Ok(mut taken) = taken_field_names(dst) else { return Vec::new() }; - let Ok(roots) = field_roots(src) else { return Vec::new() }; - plan_renames(src, &roots, &mut taken).into_iter().map(|(_, _, old, new)| (old, new)).collect() -} - -/// What combining `sources`, in order, would rename in each one: the whole list is simulated -/// with the same first-free-variant rule the combine uses, so a later file's preview sees the -/// earlier files' renames. Unreadable structure previews as nothing (the combine itself will -/// say why it fails); non-colliding names still count as taken for the files after them. -pub fn combine_field_conflicts(sources: &[&Document]) -> Vec> { - let mut taken = std::collections::HashSet::new(); - sources - .iter() - .map(|src| match field_roots(src) { - Ok(roots) => plan_renames(src, &roots, &mut taken).into_iter().map(|(_, _, old, new)| (old, new)).collect(), - Err(_) => Vec::new(), - }) - .collect() -} - -/// Walk field trees `roots` in `doc` and plan the renames that would keep every name separate -/// from `taken`: each node's fully-qualified name joins `taken` (so a later field of the same -/// import collides with an earlier one), and a node already taken is renamed to the first free -/// variant of its own `/T`. Names are compared and renamed as decoded text, not as stored -/// bytes, so one name written in two encodings (PDFDocEncoding, UTF-16BE) is one name. -/// Read-only: returns (node, new `/T`, old name, new name). Cycle- and depth-safe. -fn plan_renames(doc: &Document, roots: &[ObjRef], taken: &mut std::collections::HashSet) -> Vec<(ObjRef, String, String, String)> { - let mut planned = Vec::new(); - let mut walk = RenameWalk::default(); - for &root in roots { - plan_renames_under(doc, root, "", taken, &mut walk, 0, &mut planned); - } - planned -} - -/// What one [`plan_renames`] call remembers: the nodes it visited, and for each colliding name -/// the suffix to try next. -#[derive(Default)] -struct RenameWalk { - seen: HashSet, - next: HashMap, -} - -/// One node of [`plan_renames`]. -fn plan_renames_under( - doc: &Document, - node: ObjRef, - prefix: &str, - taken: &mut std::collections::HashSet, - walk: &mut RenameWalk, - depth: u8, - planned: &mut Vec<(ObjRef, String, String, String)>, -) { - if depth > 32 || !walk.seen.insert(node) { - return; - } - let Some(d) = doc.get(node).as_dict().cloned() else { return }; - let mut name = d.get(b"T").and_then(|t| doc.resolve(t).as_string().map(|s| s.to_text())).unwrap_or_default(); - if !name.is_empty() { - let fqn = qualified(prefix, &name); - if taken.contains(&fqn) { - // `taken` only grows, so every suffix below the last one handed out for this name is - // still taken: resume there instead of at 1. - let mut n = walk.next.get(&fqn).copied().unwrap_or(1); - let mut variant; - while { - #[cfg(test)] - crate::work(1); - variant = format!("{name}-{n}"); - taken.contains(&qualified(prefix, &variant)) - } { - n = n.saturating_add(1); - } - walk.next.insert(fqn.clone(), n.saturating_add(1)); - let new_fqn = qualified(prefix, &variant); - planned.push((node, variant.clone(), fqn, new_fqn.clone())); - taken.insert(new_fqn); - name = variant; - } else { - taken.insert(fqn); - } - } - let prefix = qualified_optional(prefix, &name); - let kids = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_default(); - for kid in kids.iter().filter_map(|k| k.as_ref()) { - plan_renames_under(doc, kid, &prefix, taken, walk, depth + 1, planned); - } -} - -/// The field names in use in `dst`: every name in its form, and every name hanging only off a -/// page's widgets — a field the form never lists still owns its name. -fn taken_field_names(dst: &Document) -> Result, OrganizeError> { - let mut taken = std::collections::HashSet::new(); - let mut seen = std::collections::HashSet::new(); - for root in field_roots(dst)? { - collect_names(dst, root, "", &mut seen, 0, &mut taken); - } - Ok(taken) -} - -/// A document's field trees, wherever they are rooted: the `/AcroForm /Fields` entries, and -/// the fields behind widgets on pages (climbed to their root, for forms that never registered -/// them). -fn field_roots(doc: &Document) -> Result, OrganizeError> { - let mut roots: Vec = doc - .root() - .and_then(|r| doc.get(r).as_dict().cloned()) - .and_then(|c| c.get(b"AcroForm").map(|f| doc.resolve(f))) - .and_then(|f| f.as_dict().cloned()) - .and_then(|f| f.get(b"Fields").map(|l| doc.resolve(l))) - .and_then(|l| l.as_array().cloned()) - .unwrap_or_default() - .iter() - .filter_map(|f| f.as_ref()) - .collect(); - for (page, _) in walk(doc)? { - let Some(d) = doc.get(page).as_dict().cloned() else { continue }; - let annots = d.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default(); - for a in annots.iter() { - let Some(w) = doc.resolve(a).as_dict().cloned() else { continue }; - if w.name(b"Subtype") != Some(b"Widget") { - continue; - } - // Walk up to the field the widget belongs to, however deep it sits; a widget that - // is its own field is its own root. - let Some(mut root) = a.as_ref() else { continue }; - let mut up = 0; - while let Some(parent) = doc.get(root).as_dict().and_then(|p| p.reference(b"Parent")) { - if up >= 32 || root == parent { - break; - } - root = parent; - up += 1; - } - roots.push(root); - } - } - Ok(roots) -} - -/// Record the fully-qualified names of one field tree. Cycle- and depth-safe. -fn collect_names( - dst: &Document, - node: ObjRef, - prefix: &str, - seen: &mut std::collections::HashSet, - depth: u8, - out: &mut std::collections::HashSet, -) { - if depth > 32 || !seen.insert(node) { - return; - } - let Some(d) = dst.get(node).as_dict().cloned() else { return }; - let name = d.get(b"T").and_then(|t| dst.resolve(t).as_string().map(|s| s.to_text())).unwrap_or_default(); - let prefix = qualified_optional(prefix, &name); - if !name.is_empty() { - out.insert(prefix.clone()); - } - let kids = d.get(b"Kids").map(|k| dst.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_default(); - for kid in kids.iter().filter_map(|k| k.as_ref()) { - collect_names(dst, kid, &prefix, seen, depth + 1, out); - } -} - -/// `prefix.name`, or `name` at the top level. -fn qualified(prefix: &str, name: &str) -> String { - if prefix.is_empty() { name.to_string() } else { format!("{prefix}.{name}") } -} - -/// The prefix a node's children hang under: its own name, when it has one. -fn qualified_optional(prefix: &str, name: &str) -> String { - if name.is_empty() { prefix.to_string() } else { qualified(prefix, name) } -} - -/// Add `kid` to `field`'s `/Kids`, in the array object itself when `/Kids` is one, so the field -/// keeps its other widgets. -fn add_kid(dst: &mut Document, field: ObjRef, kid: ObjRef) -> Result<(), OrganizeError> { - match dst.get(field).as_dict().and_then(|f| f.get(b"Kids").cloned()) { - Some(Object::Ref(list)) => { - let mut kids = dst.get(list).as_array().cloned().unwrap_or_default(); - kids.push(Object::Ref(kid)); - dst.set(list, Object::Array(kids)); - } - _ => dst.update_dict(field, |f| { - let mut kids = f.get(b"Kids").and_then(|k| k.as_array()).cloned().unwrap_or_default(); - kids.push(Object::Ref(kid)); - f.set(b"Kids".to_vec(), Object::Array(kids)); - })?, - } - Ok(()) -} - -/// PdfCraft patch (#1810): split a widget that doubles as its own field into a parent field -/// holding the field keys (name, value, flags…) and a widget child holding the annotation keys -/// (rectangle, appearance, page). The parent takes the widget's place among the top-level fields; -/// later copies of the page add widget children of it, so every page has its own widget and the -/// field stays one. -fn split_own_field(dst: &mut Document, fields: &mut [ObjRef], widget: ObjRef) { - let Some(d) = dst.get(widget).as_dict().cloned() else { return }; - let (mut field, mut child) = field_and_widget(dst, &d); - let parent = dst.add(Object::Null); - field.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(widget)])); - child.set(b"Parent".to_vec(), Object::Ref(parent)); - dst.set(parent, Object::Dict(field)); - dst.set(widget, Object::Dict(child)); - if let Some(entry) = fields.iter_mut().find(|f| **f == widget) { - *entry = parent; - } -} - -/// A combined field/widget has two additional-action namespaces (ISO 32000-1 tables 194/196). -/// Partition them as well as the ordinary entries, including when copying a later widget. -fn field_and_widget(doc: &Document, d: &Dict) -> (Dict, Dict) { - let (mut field, mut child) = (Dict::new(), Dict::new()); - for (k, v) in d.iter() { - if k.as_slice() == b"AA" - && let Some(actions) = doc.resolve(v).as_dict().cloned() - { - let (mut field_actions, mut widget_actions) = (Dict::new(), Dict::new()); - for (trigger, action) in actions.iter() { - if [b"E".as_slice(), b"X", b"D", b"U", b"Fo", b"Bl", b"PO", b"PC", b"PV", b"PI"].contains(&trigger.as_slice()) { - widget_actions.set(trigger.clone(), action.clone()); - } else { - field_actions.set(trigger.clone(), action.clone()); - } - } - if field_actions.iter().next().is_some() { - field.set(k.clone(), Object::Dict(field_actions)); - } - if widget_actions.iter().next().is_some() { - child.set(k.clone(), Object::Dict(widget_actions)); - } - } else if WIDGET_KEYS.contains(&k.as_slice()) { - child.set(k.clone(), v.clone()); - } else { - field.set(k.clone(), v.clone()); - } - } - (field, child) -} - -/// Add top-level fields to the destination's `/AcroForm /Fields` (creating the form if needed). -fn register_fields(dst: &mut Document, fields: &[ObjRef]) -> Result<(), OrganizeError> { - if fields.is_empty() { - return Ok(()); - } - let root = dst.root().ok_or(OrganizeError::NoPageTree)?; - let catalog = dst.get(root).as_dict().cloned().ok_or(OrganizeError::NoPageTree)?; - let (form_ref, mut form) = match catalog.get(b"AcroForm") { - Some(Object::Ref(r)) => (Some(*r), dst.get(*r).as_dict().cloned().unwrap_or_default()), - Some(Object::Dict(d)) => (None, d.clone()), - _ => (None, Dict::new()), - }; - let mut list = form.get(b"Fields").map(|f| dst.resolve(f)).and_then(|f| f.as_array().cloned()).unwrap_or_default(); - let mut listed: HashSet = list.iter().filter_map(Object::as_ref).collect(); - for f in fields { - #[cfg(test)] - crate::work(1); - if listed.insert(*f) { - list.push(Object::Ref(*f)); - } - } - form.set(b"Fields".to_vec(), Object::Array(list)); - match form_ref { - Some(r) => dst.set(r, Object::Dict(form)), - None => { - let r = dst.add(form); - dst.update_dict(root, |c| c.set(b"AcroForm".to_vec(), Object::Ref(r)))?; - } - } - Ok(()) -} - -/// A new document containing copies of `pages` from `src` (Extract Pages / Split). -/// Document information (title, author…) is carried over, and so is the print standard the -/// source declares (PDF/X: its output intents and identification, #263), and the bookmarks that -/// go to the copied pages. -pub fn extract_pages(src: &Document, pages: &[usize]) -> Result { - let mut out = Document::new_empty(); - // One source: nothing to deduplicate. Its fields keep their names (a source with two - // same-named field roots stays one field, as it was), so Extract and Split never rename. - let imported = import_pages_mapped(&mut out, src, pages, 0, FieldConflicts::Merge)?; - // The bookmarks of these pages come along, mapped to the copies (#4020). - let outline = kept_bookmarks(source_outline(src, imported.page_map)); - if !outline.is_empty() { - let root = crate::outline::container(&mut out, &[])?; - if let Some((first, last, _)) = write_outline_level(&mut out, root, &outline)? { - out.update_dict(root, |d| { - d.set(b"First".to_vec(), Object::Ref(first)); - d.set(b"Last".to_vec(), Object::Ref(last)); - })?; - let top = crate::outline::children(&out, root); - crate::outline::relink_and_recount(&mut out, root, &top)?; - } - } - for key in crate::INFO_KEYS { - if let Some(v) = crate::info(src, key) { - crate::set_info(&mut out, key, &v)?; - } - } - // After the title: the XMP packet repeats it. - crate::pdfx::carry(&mut out, Some(src), &crate::pdfx::PrintStandard::of(src), imported.objects)?; - // The pages share their source's resource dictionary, so the part would otherwise carry every - // XObject the source lists — images and all (#204). Keep only what these pages draw. - crate::prune::prune_unused_xobjects(&mut out)?; - Ok(out) -} - -/// How to split a document. -#[derive(Clone, Debug, PartialEq)] -pub enum SplitBy { - /// Every `n` pages. - PageCount(usize), - /// Before each of these 0-based page indices (e.g. `[3, 7]` → 0–2, 3–6, 7–end). - Before(Vec), -} - -/// The page ranges (0-based, end-exclusive) a split produces. -pub fn split_ranges(page_count: usize, by: &SplitBy) -> Vec> { - let mut cuts: Vec = match by { - SplitBy::PageCount(n) => (1..).map(|i| i * (*n).max(1)).take_while(|c| *c < page_count).collect(), - SplitBy::Before(v) => v.iter().copied().filter(|c| *c > 0 && *c < page_count).collect(), - }; - cuts.sort_unstable(); - cuts.dedup(); - let mut ranges = Vec::new(); - let mut start = 0; - for c in cuts.into_iter().chain(std::iter::once(page_count)) { - if c > start { - ranges.push(start..c); - start = c; - } - } - ranges -} - -/// Split a document into several new documents. -pub fn split(src: &Document, by: &SplitBy) -> Result, OrganizeError> { - let n = crate::page_count(src)?; - split_ranges(n, by).into_iter().map(|r| extract_pages(src, &r.collect::>())).collect() -} - -/// What combining produced, beyond the document: the fields renamed to keep colliding names -/// separate (old fully-qualified name → new one), in order across all sources. -pub struct CombineOutcome { - /// The combined document. - pub doc: Document, - /// Every field renamed while combining. - pub renamed_fields: Vec<(String, String)>, -} - -/// Combine whole documents, in order, into a new document. Each source gets a top-level -/// bookmark (its `title`) pointing to its first page, like Acrobat's Combine Files. -/// The source's own bookmarks are nested (collapsed) under its entry, and its document-level -/// attachments are carried over. When every source declares the same print standard (PDF/X -/// with the same output intent), so does the result. -pub fn combine(sources: &[(&str, &Document)]) -> Result { - combine_with(sources, FieldConflicts::Rename).map(|o| o.doc) -} - -/// [`combine`] reporting what the conflict resolution renamed. -pub fn combine_with(sources: &[(&str, &Document)], conflicts: FieldConflicts) -> Result { - let all: Vec<(&str, &Document, Option<&[usize]>)> = sources.iter().map(|(t, d)| (*t, *d, None)).collect(); - combine_selected_with(&all, conflicts) -} - -/// Combine Files with chosen pages: each source contributes `pages` (0-based, in that order; -/// `None` for all of them). Bookmarks that point at pages left out lose their destination. -pub fn combine_selected(sources: &[(&str, &Document, Option<&[usize]>)]) -> Result { - combine_selected_with(sources, FieldConflicts::Rename).map(|o| o.doc) -} - -/// [`combine_selected`] reporting what the conflict resolution renamed. -pub fn combine_selected_with(sources: &[(&str, &Document, Option<&[usize]>)], conflicts: FieldConflicts) -> Result { - let mut combiner = Combiner::with_field_conflicts(conflicts); - for (title, src, chosen) in sources { - combiner.add(title, src, *chosen)?; - } - combiner.finish_with_renames() -} - -/// Combine Files one source at a time, with the same result as [`combine_selected`]. [`Combiner::add`] -/// copies a source's pages into the result and keeps only what the rest of the combine needs: its -/// bookmarks (read and mapped to the copied pages), its attachments (copied), and its document only -/// when it declares output intents. So each source can be dropped as soon as it is added, and a long -/// list holds one source at a time rather than all of them. -pub struct Combiner { - out: Document, - marks: Vec, - attachments: Vec<(Vec, Object)>, - /// The first source's print standard, and what its pages brought along. - standard: Option, - /// Whether every source so far declares the first source's print standard. - agreed: bool, - /// How colliding field names are resolved as each source is added. - field_conflicts: FieldConflicts, - /// What adding sources renamed (old → new fully-qualified field name), in order. - renamed_fields: Vec<(String, String)>, -} - -/// The first source's print standard (see [`Combiner`]). -struct Standard { - declared: crate::pdfx::PrintStandard, - /// The source objects the copied pages brought along, and their copies. - objects: HashMap, - /// The source itself, kept only for its output intents, which are copied from it. - source: Option, -} - -impl Default for Combiner { - fn default() -> Self { - Self::new() - } -} - -impl Combiner { - /// A combine with no sources yet, renaming colliding field names as sources are added. - pub fn new() -> Self { - Self::with_field_conflicts(FieldConflicts::Rename) - } - - /// [`Combiner::new`] resolving colliding field names as `conflicts` says. - pub fn with_field_conflicts(field_conflicts: FieldConflicts) -> Self { - Self { - out: Document::new_empty(), - marks: Vec::new(), - attachments: Vec::new(), - standard: None, - agreed: true, - field_conflicts, - renamed_fields: Vec::new(), - } - } - - /// What adding sources has renamed so far (old → new fully-qualified field name). - pub fn renamed_fields(&self) -> &[(String, String)] { - &self.renamed_fields - } - - /// Add `src`'s `pages` (0-based, in that order; `None` for all of them) after the pages added so far, - /// under a bookmark titled `title`. `src` need not outlive the call. - pub fn add(&mut self, title: &str, src: &Document, pages: Option<&[usize]>) -> Result<(), OrganizeError> { - let n = crate::page_count(src)?; - let pages: Vec = match pages { - Some(p) => { - if let Some(bad) = p.iter().find(|i| **i >= n) { - return Err(OrganizeError::NoSuchPage(*bad)); - } - p.to_vec() - } - None => (0..n).collect(), - }; - let at = crate::page_count(&self.out)?; - let imported = import_pages_mapped(&mut self.out, src, &pages, at, self.field_conflicts)?; - self.renamed_fields.extend(imported.renamed_fields); - if let Some(first) = imported.pages.first() { - let outline = source_outline(src, imported.page_map); - self.marks.push(Mark { title: title.to_string(), page: *first, outline }); - } - collect_attachments(&mut self.out, src, &mut self.attachments)?; - let declared = crate::pdfx::PrintStandard::of(src); - match self.standard.as_ref().map(|first| first.declared.same_as(&declared)) { - Some(same) => self.agreed &= same, - None => { - let source = declared.declares_output_intents().then(|| src.clone()); - self.standard = Some(Standard { declared, objects: imported.objects, source }); - } - } - Ok(()) - } - - /// The combined document: the pages as added, the print standard of the first source when every - /// source declares the same one, shared resources stored once, then the bookmarks and attachments. - pub fn finish(self) -> Result { - self.finish_with_renames().map(|o| o.doc) - } - - /// [`Combiner::finish`] reporting what the combine renamed. - pub fn finish_with_renames(self) -> Result { - let Combiner { mut out, marks, attachments, standard, agreed, renamed_fields, .. } = self; - if let Some(first) = standard.filter(|_| agreed) { - crate::pdfx::carry(&mut out, first.source.as_ref(), &first.declared, first.objects)?; - } - // Sources often share fonts, images and profiles (or are the same file): store them once. - let all: Vec = out.object_numbers().into_iter().map(|n| ObjRef::new(n, out.generation(n))).collect(); - crate::dedupe::dedupe_resources(&mut out, &all, false); - add_outline(&mut out, &marks)?; - set_attachments(&mut out, attachments)?; - Ok(CombineOutcome { doc: out, renamed_fields }) - } -} - -/// One run of pages for [`combine_grouped`]: the file it comes from (`group`: runs with the same -/// group are one file split around, and give the same document), its title, the document, and -/// its pages (0-based, in that order; `None` for all of them). -pub type Run<'a> = (usize, &'a str, &'a Document, Option<&'a [usize]>); - -/// Combine Files where a file's pages may be split into several runs with other files' pages -/// between them. Each file (group) is copied once, all its pages together, then the pages are put -/// in the runs' order: its links between its own pages, its form fields and its attachments stay -/// whole, and it gets one top-level bookmark (the title of its first run) at its first page, its -/// own bookmarks nested. Otherwise as [`combine_selected`] (print standard, shared resources). -pub fn combine_grouped(runs: &[Run<'_>]) -> Result { - combine_grouped_with(runs, FieldConflicts::Rename).map(|o| o.doc) -} - -/// [`combine_grouped`] reporting what the conflict resolution renamed. -pub fn combine_grouped_with(runs: &[Run<'_>], conflicts: FieldConflicts) -> Result { - // Each file's pages, in the order its runs list them, and each run's place in that list. - struct File<'a> { - title: &'a str, - src: &'a Document, - pages: Vec, - } - let mut files: Vec<(usize, File<'_>)> = Vec::new(); - let mut slices: Vec<(usize, usize, usize)> = Vec::with_capacity(runs.len()); - for (group, title, src, chosen) in runs { - let at = match files.iter().position(|(g, _)| g == group) { - Some(at) => at, - None => { - files.push((*group, File { title, src, pages: Vec::new() })); - files.len() - 1 - } - }; - let Some((_, file)) = files.get_mut(at) else { continue }; - let n = crate::page_count(file.src)?; - let pages: Vec = match chosen { - Some(p) => { - if let Some(bad) = p.iter().find(|i| **i >= n) { - return Err(OrganizeError::NoSuchPage(*bad)); - } - p.to_vec() - } - None => (0..n).collect(), - }; - slices.push((at, file.pages.len(), pages.len())); - file.pages.extend(pages); - } - let mut out = Document::new_empty(); - let mut marks = Vec::new(); - let mut attachments = Vec::new(); - let mut copies: Vec> = Vec::with_capacity(files.len()); - let mut renamed_fields = Vec::new(); - let mut standard: Option<(crate::pdfx::PrintStandard, &Document, HashMap)> = None; - let mut agreed = true; - for (_, file) in &files { - let at = crate::page_count(&out)?; - let imported = import_pages_mapped(&mut out, file.src, &file.pages, at, conflicts)?; - copies.push(imported.pages.clone()); - renamed_fields.extend(imported.renamed_fields); - if let Some(first) = imported.pages.first() { - marks.push(Mark { title: file.title.to_string(), page: *first, outline: source_outline(file.src, imported.page_map) }); - } - collect_attachments(&mut out, file.src, &mut attachments)?; - let declared = crate::pdfx::PrintStandard::of(file.src); - match &standard { - None => standard = Some((declared, file.src, imported.objects)), - Some((first, _, _)) => agreed &= first.same_as(&declared), - } - } - // The pages in the runs' order. A file's first run comes before its others, and its pages - // were copied in run order, so its bookmark (its first copied page) is its first page shown. - let mut order: Vec<(ObjRef, Dict)> = Vec::new(); - for (file, start, len) in slices { - let run = copies.get(file).and_then(|c| c.get(start..start.saturating_add(len))).unwrap_or_default(); - order.extend(run.iter().map(|r| (*r, Dict::new()))); - } - rebuild(&mut out, &order)?; - if let Some((declared, src, objects)) = standard.filter(|_| agreed) { - crate::pdfx::carry(&mut out, Some(src), &declared, objects)?; - } - let all: Vec = out.object_numbers().into_iter().map(|n| ObjRef::new(n, out.generation(n))).collect(); - crate::dedupe::dedupe_resources(&mut out, &all, false); - add_outline(&mut out, &marks)?; - set_attachments(&mut out, attachments)?; - Ok(CombineOutcome { doc: out, renamed_fields }) -} - -/// Copy a source's document-level attachments (`/Names /EmbeddedFiles`) into `dst`. -fn collect_attachments(dst: &mut Document, src: &Document, out: &mut Vec<(Vec, Object)>) -> Result<(), OrganizeError> { - let Some(tree) = src - .root() - .and_then(|r| src.get(r).as_dict().cloned()) - .and_then(|c| c.get(b"Names").map(|n| src.resolve(n))) - .and_then(|n| n.as_dict().and_then(|d| d.get(b"EmbeddedFiles").cloned())) - else { - return Ok(()); - }; - let mut entries = Vec::new(); - name_tree_entries(src, &tree, 0, &mut HashSet::new(), &mut entries); - let mut copier = Copier::new(src, HashMap::new(), HashMap::new()); - for (k, v) in entries { - let v = copier.copy_value(dst, &v, None)?; - out.push((k, v)); - } - Ok(()) -} - -/// Write attachments as a flat, sorted `/EmbeddedFiles` name tree; duplicate names get " (2)"… -pub(crate) fn set_attachments(dst: &mut Document, mut entries: Vec<(Vec, Object)>) -> Result<(), OrganizeError> { - if entries.is_empty() { - return Ok(()); - } - // Names are compared and numbered as text: a key written as UTF-16 (a name with an accent - // or Chinese letters) must not get ASCII bytes appended to it. - let mut seen = std::collections::HashSet::new(); - for (k, _) in entries.iter_mut() { - let base = pdfcraft_cos::PdfString::literal(k.clone()).to_text(); - let mut text = base.clone(); - let mut n = 2; - while !seen.insert(text.clone()) { - text = format!("{base} ({n})"); - n += 1; - } - if text != base { - *k = pdfcraft_cos::PdfString::text(&text).bytes; - } - } - entries.sort_by(|a, b| a.0.cmp(&b.0)); - let mut names = Vec::new(); - for (k, v) in entries { - names.push(Object::String(pdfcraft_cos::PdfString::literal(k))); - names.push(v); - } - let mut tree = Dict::new(); - tree.set(b"Names".to_vec(), Object::Array(names)); - let tree = dst.add(tree); - let root = dst.root().ok_or(OrganizeError::NoPageTree)?; - // `/Names` may be an indirect dictionary (Acrobat writes it so): update it where it is, so - // its other trees (/Dests, /JavaScript…) stay. - if let Some(Object::Ref(names)) = dst.get(root).as_dict().and_then(|c| c.get(b"Names").cloned()) - && dst.get(names).as_dict().is_some() - { - dst.update_dict(names, |n| n.set(b"EmbeddedFiles".to_vec(), Object::Ref(tree)))?; - return Ok(()); - } - dst.update_dict(root, |c| { - let mut n = match c.get(b"Names") { - Some(Object::Dict(d)) => d.clone(), - _ => Dict::new(), - }; - n.set(b"EmbeddedFiles".to_vec(), Object::Ref(tree)); - c.set(b"Names".to_vec(), Object::Dict(n)); - })?; - Ok(()) -} - -/// A source's entry in the combined outline (see [`add_outline`]): its title, its first page in the -/// result, and its own bookmarks, read from the source when it was added. -struct Mark { - title: String, - page: ObjRef, - outline: Vec, -} - -/// One of a source's bookmarks, read and ready to write: its title, colour and flags, its destination -/// in the result (`None` when the page it points at was not copied), whether it is open, and its own -/// bookmarks. -struct OutlineNode { - title: Option, - colour: Option, - flags: Option, - dest: Option, - open: bool, - children: Vec, -} - -/// The bookmarks of `src`, with destinations mapped through `pages` (source page to copied page). They -/// are read while `src` is at hand, so the source itself need not be kept. -fn source_outline(src: &Document, pages: HashMap) -> Vec { - let first = src - .root() - .and_then(|r| src.get(r).as_dict().cloned()) - .and_then(|c| c.get(b"Outlines").map(|o| src.resolve(o))) - .and_then(|o| o.as_dict().and_then(|d| d.reference(b"First"))); - let mapper = Copier::new(src, HashMap::new(), pages); - let mut budget = 10_000usize; - outline_level(&mapper, first, 0, &mut budget) -} - -/// `nodes` without the bookmarks whose page was not copied (no destination left), unless a bookmark -/// under them stays. [`outline_level`] already bounds the depth. -fn kept_bookmarks(nodes: Vec) -> Vec { - nodes - .into_iter() - .filter_map(|mut n| { - n.children = kept_bookmarks(n.children); - (n.dest.is_some() || !n.children.is_empty()).then_some(n) - }) - .collect() -} - -/// One level of bookmarks from `first` (its siblings), each with its children. Cycle- and size-safe: -/// at most `budget` bookmarks are read in all, and nesting stops at 32 levels. -fn outline_level(m: &Copier<'_>, first: Option, depth: u8, budget: &mut usize) -> Vec { - if depth > 32 { - return Vec::new(); - } - let mut seen = std::collections::HashSet::new(); - let mut made = Vec::new(); - let mut cur = first; - while let Some(r) = cur { - if !seen.insert(r) || *budget == 0 { - break; - } - *budget -= 1; - let Some(item) = m.src.get(r).as_dict().cloned() else { break }; - cur = item.reference(b"Next"); - let title = item.get(b"Title").map(|t| m.src.resolve(t).as_ref().clone()); - let colour = item.get(b"C").map(|v| m.src.resolve(v).as_ref().clone()); - let flags = item.get(b"F").map(|v| m.src.resolve(v).as_ref().clone()); - let target = item.get(b"Dest").cloned().or_else(|| { - let a = m.src.resolve(item.get(b"A")?); - let a = a.as_dict()?; - (a.name(b"S") == Some(b"GoTo")).then(|| a.get(b"D").cloned()).flatten() - }); - let dest = target.and_then(|t| m.map_dest(&t)); - let open = item.int(b"Count").is_some_and(|c| c > 0); - let children = outline_level(m, item.reference(b"First"), depth + 1, budget); - made.push(OutlineNode { title, colour, flags, dest, open, children }); - } - made -} - -/// Write `nodes` as one level under `parent`: each bookmark is created, then its children, depth first -/// and in order, so the object numbers come out as they always did. Returns the level's first and last -/// item and how many items it has (`None` when it is empty). -fn write_outline_level(dst: &mut Document, parent: ObjRef, nodes: &[OutlineNode]) -> Result, OrganizeError> { - let mut made: Vec = Vec::new(); - for node in nodes { - let new = dst.add(Object::Null); - let mut d = Dict::new(); - if let Some(t) = &node.title { - d.set(b"Title".to_vec(), t.clone()); - } - if let Some(c) = &node.colour { - d.set(b"C".to_vec(), c.clone()); - } - if let Some(f) = &node.flags { - d.set(b"F".to_vec(), f.clone()); - } - if let Some(dest) = &node.dest { - d.set(b"Dest".to_vec(), dest.clone()); - } - d.set(b"Parent".to_vec(), Object::Ref(parent)); - if let Some(prev) = made.last() { - d.set(b"Prev".to_vec(), Object::Ref(*prev)); - dst.update_dict(*prev, |p| p.set(b"Next".to_vec(), Object::Ref(new)))?; - } - if let Some((f, l, n)) = write_outline_level(dst, new, &node.children)? { - d.set(b"First".to_vec(), Object::Ref(f)); - d.set(b"Last".to_vec(), Object::Ref(l)); - // Keep the source's open/closed state. - d.set(b"Count".to_vec(), Object::Int(if node.open { n as i64 } else { -(n as i64) })); - } - dst.set(new, Object::Dict(d)); - made.push(new); - } - Ok(match (made.first(), made.last()) { - (Some(first), Some(last)) => Some((*first, *last, made.len())), - _ => None, - }) -} - -/// Replace the outline with one bookmark per source (title → its first page), each holding a collapsed -/// copy of that source's own bookmarks with destinations mapped to the copied pages. -fn add_outline(doc: &mut Document, marks: &[Mark]) -> Result<(), OrganizeError> { - if marks.is_empty() { - return Ok(()); - } - let outlines = doc.add(Object::Null); - let items: Vec = marks.iter().map(|_| doc.add(Object::Null)).collect(); - let (Some(&first_item), Some(&last_item)) = (items.first(), items.last()) else { return Ok(()) }; - for (i, (mark, r)) in marks.iter().zip(&items).enumerate() { - let mut d = Dict::new(); - d.set(b"Title".to_vec(), Object::String(pdfcraft_cos::PdfString::text(&mark.title))); - d.set(b"Parent".to_vec(), Object::Ref(outlines)); - d.set(b"Dest".to_vec(), Object::Array(vec![Object::Ref(mark.page), Object::name("Fit")])); - if i > 0 { - d.set(b"Prev".to_vec(), Object::Ref(items[i - 1])); - } - if let Some(next) = items.get(i + 1) { - d.set(b"Next".to_vec(), Object::Ref(*next)); - } - if let Some((f, l, n)) = write_outline_level(doc, *r, &mark.outline)? { - d.set(b"First".to_vec(), Object::Ref(f)); - d.set(b"Last".to_vec(), Object::Ref(l)); - d.set(b"Count".to_vec(), Object::Int(-(n as i64))); // collapsed - } - doc.set(*r, Object::Dict(d)); - } - let mut o = Dict::new(); - o.set(b"Type".to_vec(), Object::name("Outlines")); - o.set(b"First".to_vec(), Object::Ref(first_item)); - o.set(b"Last".to_vec(), Object::Ref(last_item)); - o.set(b"Count".to_vec(), Object::Int(items.len() as i64)); - doc.set(outlines, Object::Dict(o)); - let root = doc.root().ok_or(OrganizeError::NoPageTree)?; - doc.update_dict(root, |c| { - c.set(b"Outlines".to_vec(), Object::Ref(outlines)); - c.set(b"PageMode".to_vec(), Object::name("UseOutlines")); - })?; - Ok(()) -} - /// Page `page` of `src` as a form XObject in `dst` (its content and resources, no annotations), /// for backgrounds and watermarks taken from a PDF. The form's `/BBox` is the page's crop box /// and its `/Matrix` undoes the page rotation, so it draws upright as displayed. Returns the diff --git a/crates/organize/src/import/carry.rs b/crates/organize/src/import/carry.rs new file mode 100644 index 0000000000..15a412e231 --- /dev/null +++ b/crates/organize/src/import/carry.rs @@ -0,0 +1,233 @@ +use super::copy::Copier; +use super::{Dict, Document, HashMap, HashSet, ObjRef, Object, OrganizeError, name_tree_entries}; + +/// Copy a source's document-level attachments (`/Names /EmbeddedFiles`) into `dst`. +pub(super) fn collect_attachments(dst: &mut Document, src: &Document, out: &mut Vec<(Vec, Object)>) -> Result<(), OrganizeError> { + let Some(tree) = src + .root() + .and_then(|r| src.get(r).as_dict().cloned()) + .and_then(|c| c.get(b"Names").map(|n| src.resolve(n))) + .and_then(|n| n.as_dict().and_then(|d| d.get(b"EmbeddedFiles").cloned())) + else { + return Ok(()); + }; + let mut entries = Vec::new(); + name_tree_entries(src, &tree, 0, &mut HashSet::new(), &mut entries); + let mut copier = Copier::new(src, HashMap::new(), HashMap::new()); + for (k, v) in entries { + let v = copier.copy_value(dst, &v, None)?; + out.push((k, v)); + } + Ok(()) +} + +/// Write attachments as a flat, sorted `/EmbeddedFiles` name tree; duplicate names get " (2)"… +pub(crate) fn set_attachments(dst: &mut Document, mut entries: Vec<(Vec, Object)>) -> Result<(), OrganizeError> { + if entries.is_empty() { + return Ok(()); + } + // Names are compared and numbered as text: a key written as UTF-16 (a name with an accent + // or Chinese letters) must not get ASCII bytes appended to it. + let mut seen = std::collections::HashSet::new(); + for (k, _) in entries.iter_mut() { + let base = pdfcraft_cos::PdfString::literal(k.clone()).to_text(); + let mut text = base.clone(); + let mut n = 2; + while !seen.insert(text.clone()) { + text = format!("{base} ({n})"); + n += 1; + } + if text != base { + *k = pdfcraft_cos::PdfString::text(&text).bytes; + } + } + entries.sort_by(|a, b| a.0.cmp(&b.0)); + let mut names = Vec::new(); + for (k, v) in entries { + names.push(Object::String(pdfcraft_cos::PdfString::literal(k))); + names.push(v); + } + let mut tree = Dict::new(); + tree.set(b"Names".to_vec(), Object::Array(names)); + let tree = dst.add(tree); + let root = dst.root().ok_or(OrganizeError::NoPageTree)?; + // `/Names` may be an indirect dictionary (Acrobat writes it so): update it where it is, so + // its other trees (/Dests, /JavaScript…) stay. + if let Some(Object::Ref(names)) = dst.get(root).as_dict().and_then(|c| c.get(b"Names").cloned()) + && dst.get(names).as_dict().is_some() + { + dst.update_dict(names, |n| n.set(b"EmbeddedFiles".to_vec(), Object::Ref(tree)))?; + return Ok(()); + } + dst.update_dict(root, |c| { + let mut n = match c.get(b"Names") { + Some(Object::Dict(d)) => d.clone(), + _ => Dict::new(), + }; + n.set(b"EmbeddedFiles".to_vec(), Object::Ref(tree)); + c.set(b"Names".to_vec(), Object::Dict(n)); + })?; + Ok(()) +} + +/// A source's entry in the combined outline (see [`add_outline`]): its title, its first page in the +/// result, and its own bookmarks, read from the source when it was added. +pub(super) struct Mark { + pub(super) title: String, + pub(super) page: ObjRef, + pub(super) outline: Vec, +} + +/// One of a source's bookmarks, read and ready to write: its title, colour and flags, its destination +/// in the result (`None` when the page it points at was not copied), whether it is open, and its own +/// bookmarks. +pub(super) struct OutlineNode { + title: Option, + colour: Option, + flags: Option, + dest: Option, + open: bool, + children: Vec, +} + +/// The bookmarks of `src`, with destinations mapped through `pages` (source page to copied page). They +/// are read while `src` is at hand, so the source itself need not be kept. +pub(super) fn source_outline(src: &Document, pages: HashMap) -> Vec { + let first = src + .root() + .and_then(|r| src.get(r).as_dict().cloned()) + .and_then(|c| c.get(b"Outlines").map(|o| src.resolve(o))) + .and_then(|o| o.as_dict().and_then(|d| d.reference(b"First"))); + let mapper = Copier::new(src, HashMap::new(), pages); + let mut budget = 10_000usize; + outline_level(&mapper, first, 0, &mut budget) +} + +/// `nodes` without the bookmarks whose page was not copied (no destination left), unless a bookmark +/// under them stays. [`outline_level`] already bounds the depth. +pub(super) fn kept_bookmarks(nodes: Vec) -> Vec { + nodes + .into_iter() + .filter_map(|mut n| { + n.children = kept_bookmarks(n.children); + (n.dest.is_some() || !n.children.is_empty()).then_some(n) + }) + .collect() +} + +/// One level of bookmarks from `first` (its siblings), each with its children. Cycle- and size-safe: +/// at most `budget` bookmarks are read in all, and nesting stops at 32 levels. +fn outline_level(m: &Copier<'_>, first: Option, depth: u8, budget: &mut usize) -> Vec { + if depth > 32 { + return Vec::new(); + } + let mut seen = std::collections::HashSet::new(); + let mut made = Vec::new(); + let mut cur = first; + while let Some(r) = cur { + if !seen.insert(r) || *budget == 0 { + break; + } + *budget -= 1; + let Some(item) = m.src.get(r).as_dict().cloned() else { break }; + cur = item.reference(b"Next"); + let title = item.get(b"Title").map(|t| m.src.resolve(t).as_ref().clone()); + let colour = item.get(b"C").map(|v| m.src.resolve(v).as_ref().clone()); + let flags = item.get(b"F").map(|v| m.src.resolve(v).as_ref().clone()); + let target = item.get(b"Dest").cloned().or_else(|| { + let a = m.src.resolve(item.get(b"A")?); + let a = a.as_dict()?; + (a.name(b"S") == Some(b"GoTo")).then(|| a.get(b"D").cloned()).flatten() + }); + let dest = target.and_then(|t| m.map_dest(&t)); + let open = item.int(b"Count").is_some_and(|c| c > 0); + let children = outline_level(m, item.reference(b"First"), depth + 1, budget); + made.push(OutlineNode { title, colour, flags, dest, open, children }); + } + made +} + +/// Write `nodes` as one level under `parent`: each bookmark is created, then its children, depth first +/// and in order, so the object numbers come out as they always did. Returns the level's first and last +/// item and how many items it has (`None` when it is empty). +pub(super) fn write_outline_level( + dst: &mut Document, + parent: ObjRef, + nodes: &[OutlineNode], +) -> Result, OrganizeError> { + let mut made: Vec = Vec::new(); + for node in nodes { + let new = dst.add(Object::Null); + let mut d = Dict::new(); + if let Some(t) = &node.title { + d.set(b"Title".to_vec(), t.clone()); + } + if let Some(c) = &node.colour { + d.set(b"C".to_vec(), c.clone()); + } + if let Some(f) = &node.flags { + d.set(b"F".to_vec(), f.clone()); + } + if let Some(dest) = &node.dest { + d.set(b"Dest".to_vec(), dest.clone()); + } + d.set(b"Parent".to_vec(), Object::Ref(parent)); + if let Some(prev) = made.last() { + d.set(b"Prev".to_vec(), Object::Ref(*prev)); + dst.update_dict(*prev, |p| p.set(b"Next".to_vec(), Object::Ref(new)))?; + } + if let Some((f, l, n)) = write_outline_level(dst, new, &node.children)? { + d.set(b"First".to_vec(), Object::Ref(f)); + d.set(b"Last".to_vec(), Object::Ref(l)); + // Keep the source's open/closed state. + d.set(b"Count".to_vec(), Object::Int(if node.open { n as i64 } else { -(n as i64) })); + } + dst.set(new, Object::Dict(d)); + made.push(new); + } + Ok(match (made.first(), made.last()) { + (Some(first), Some(last)) => Some((*first, *last, made.len())), + _ => None, + }) +} + +/// Replace the outline with one bookmark per source (title → its first page), each holding a collapsed +/// copy of that source's own bookmarks with destinations mapped to the copied pages. +pub(super) fn add_outline(doc: &mut Document, marks: &[Mark]) -> Result<(), OrganizeError> { + if marks.is_empty() { + return Ok(()); + } + let outlines = doc.add(Object::Null); + let items: Vec = marks.iter().map(|_| doc.add(Object::Null)).collect(); + let (Some(&first_item), Some(&last_item)) = (items.first(), items.last()) else { return Ok(()) }; + for (i, (mark, r)) in marks.iter().zip(&items).enumerate() { + let mut d = Dict::new(); + d.set(b"Title".to_vec(), Object::String(pdfcraft_cos::PdfString::text(&mark.title))); + d.set(b"Parent".to_vec(), Object::Ref(outlines)); + d.set(b"Dest".to_vec(), Object::Array(vec![Object::Ref(mark.page), Object::name("Fit")])); + if i > 0 { + d.set(b"Prev".to_vec(), Object::Ref(items[i - 1])); + } + if let Some(next) = items.get(i + 1) { + d.set(b"Next".to_vec(), Object::Ref(*next)); + } + if let Some((f, l, n)) = write_outline_level(doc, *r, &mark.outline)? { + d.set(b"First".to_vec(), Object::Ref(f)); + d.set(b"Last".to_vec(), Object::Ref(l)); + d.set(b"Count".to_vec(), Object::Int(-(n as i64))); // collapsed + } + doc.set(*r, Object::Dict(d)); + } + let mut o = Dict::new(); + o.set(b"Type".to_vec(), Object::name("Outlines")); + o.set(b"First".to_vec(), Object::Ref(first_item)); + o.set(b"Last".to_vec(), Object::Ref(last_item)); + o.set(b"Count".to_vec(), Object::Int(items.len() as i64)); + doc.set(outlines, Object::Dict(o)); + let root = doc.root().ok_or(OrganizeError::NoPageTree)?; + doc.update_dict(root, |c| { + c.set(b"Outlines".to_vec(), Object::Ref(outlines)); + c.set(b"PageMode".to_vec(), Object::name("UseOutlines")); + })?; + Ok(()) +} diff --git a/crates/organize/src/import/combine.rs b/crates/organize/src/import/combine.rs new file mode 100644 index 0000000000..06972ae101 --- /dev/null +++ b/crates/organize/src/import/combine.rs @@ -0,0 +1,307 @@ +use super::carry::{Mark, add_outline, collect_attachments, kept_bookmarks, set_attachments, source_outline, write_outline_level}; +use super::{Dict, Document, FieldConflicts, HashMap, ObjRef, Object, OrganizeError, import_pages_mapped, rebuild}; + +/// A new document containing copies of `pages` from `src` (Extract Pages / Split). +/// Document information (title, author…) is carried over, and so is the print standard the +/// source declares (PDF/X: its output intents and identification, #263), and the bookmarks that +/// go to the copied pages. +pub fn extract_pages(src: &Document, pages: &[usize]) -> Result { + let mut out = Document::new_empty(); + // One source: nothing to deduplicate. Its fields keep their names (a source with two + // same-named field roots stays one field, as it was), so Extract and Split never rename. + let imported = import_pages_mapped(&mut out, src, pages, 0, FieldConflicts::Merge)?; + // The bookmarks of these pages come along, mapped to the copies (#4020). + let outline = kept_bookmarks(source_outline(src, imported.page_map)); + if !outline.is_empty() { + let root = crate::outline::container(&mut out, &[])?; + if let Some((first, last, _)) = write_outline_level(&mut out, root, &outline)? { + out.update_dict(root, |d| { + d.set(b"First".to_vec(), Object::Ref(first)); + d.set(b"Last".to_vec(), Object::Ref(last)); + })?; + let top = crate::outline::children(&out, root); + crate::outline::relink_and_recount(&mut out, root, &top)?; + } + } + for key in crate::INFO_KEYS { + if let Some(v) = crate::info(src, key) { + crate::set_info(&mut out, key, &v)?; + } + } + // After the title: the XMP packet repeats it. + crate::pdfx::carry(&mut out, Some(src), &crate::pdfx::PrintStandard::of(src), imported.objects)?; + // The pages share their source's resource dictionary, so the part would otherwise carry every + // XObject the source lists — images and all (#204). Keep only what these pages draw. + crate::prune::prune_unused_xobjects(&mut out)?; + Ok(out) +} + +/// How to split a document. +#[derive(Clone, Debug, PartialEq)] +pub enum SplitBy { + /// Every `n` pages. + PageCount(usize), + /// Before each of these 0-based page indices (e.g. `[3, 7]` → 0–2, 3–6, 7–end). + Before(Vec), +} + +/// The page ranges (0-based, end-exclusive) a split produces. +pub fn split_ranges(page_count: usize, by: &SplitBy) -> Vec> { + let mut cuts: Vec = match by { + SplitBy::PageCount(n) => (1..).map(|i| i * (*n).max(1)).take_while(|c| *c < page_count).collect(), + SplitBy::Before(v) => v.iter().copied().filter(|c| *c > 0 && *c < page_count).collect(), + }; + cuts.sort_unstable(); + cuts.dedup(); + let mut ranges = Vec::new(); + let mut start = 0; + for c in cuts.into_iter().chain(std::iter::once(page_count)) { + if c > start { + ranges.push(start..c); + start = c; + } + } + ranges +} + +/// Split a document into several new documents. +pub fn split(src: &Document, by: &SplitBy) -> Result, OrganizeError> { + let n = crate::page_count(src)?; + split_ranges(n, by).into_iter().map(|r| extract_pages(src, &r.collect::>())).collect() +} + +/// What combining produced, beyond the document: the fields renamed to keep colliding names +/// separate (old fully-qualified name → new one), in order across all sources. +pub struct CombineOutcome { + /// The combined document. + pub doc: Document, + /// Every field renamed while combining. + pub renamed_fields: Vec<(String, String)>, +} + +/// Combine whole documents, in order, into a new document. Each source gets a top-level +/// bookmark (its `title`) pointing to its first page, like Acrobat's Combine Files. +/// The source's own bookmarks are nested (collapsed) under its entry, and its document-level +/// attachments are carried over. When every source declares the same print standard (PDF/X +/// with the same output intent), so does the result. +pub fn combine(sources: &[(&str, &Document)]) -> Result { + combine_with(sources, FieldConflicts::Rename).map(|o| o.doc) +} + +/// [`combine`] reporting what the conflict resolution renamed. +pub fn combine_with(sources: &[(&str, &Document)], conflicts: FieldConflicts) -> Result { + let all: Vec<(&str, &Document, Option<&[usize]>)> = sources.iter().map(|(t, d)| (*t, *d, None)).collect(); + combine_selected_with(&all, conflicts) +} + +/// Combine Files with chosen pages: each source contributes `pages` (0-based, in that order; +/// `None` for all of them). Bookmarks that point at pages left out lose their destination. +pub fn combine_selected(sources: &[(&str, &Document, Option<&[usize]>)]) -> Result { + combine_selected_with(sources, FieldConflicts::Rename).map(|o| o.doc) +} + +/// [`combine_selected`] reporting what the conflict resolution renamed. +pub fn combine_selected_with(sources: &[(&str, &Document, Option<&[usize]>)], conflicts: FieldConflicts) -> Result { + let mut combiner = Combiner::with_field_conflicts(conflicts); + for (title, src, chosen) in sources { + combiner.add(title, src, *chosen)?; + } + combiner.finish_with_renames() +} + +/// Combine Files one source at a time, with the same result as [`combine_selected`]. [`Combiner::add`] +/// copies a source's pages into the result and keeps only what the rest of the combine needs: its +/// bookmarks (read and mapped to the copied pages), its attachments (copied), and its document only +/// when it declares output intents. So each source can be dropped as soon as it is added, and a long +/// list holds one source at a time rather than all of them. +pub struct Combiner { + out: Document, + marks: Vec, + attachments: Vec<(Vec, Object)>, + /// The first source's print standard, and what its pages brought along. + standard: Option, + /// Whether every source so far declares the first source's print standard. + agreed: bool, + /// How colliding field names are resolved as each source is added. + field_conflicts: FieldConflicts, + /// What adding sources renamed (old → new fully-qualified field name), in order. + renamed_fields: Vec<(String, String)>, +} + +/// The first source's print standard (see [`Combiner`]). +struct Standard { + declared: crate::pdfx::PrintStandard, + /// The source objects the copied pages brought along, and their copies. + objects: HashMap, + /// The source itself, kept only for its output intents, which are copied from it. + source: Option, +} + +impl Default for Combiner { + fn default() -> Self { + Self::new() + } +} + +impl Combiner { + /// A combine with no sources yet, renaming colliding field names as sources are added. + pub fn new() -> Self { + Self::with_field_conflicts(FieldConflicts::Rename) + } + + /// [`Combiner::new`] resolving colliding field names as `conflicts` says. + pub fn with_field_conflicts(field_conflicts: FieldConflicts) -> Self { + Self { + out: Document::new_empty(), + marks: Vec::new(), + attachments: Vec::new(), + standard: None, + agreed: true, + field_conflicts, + renamed_fields: Vec::new(), + } + } + + /// What adding sources has renamed so far (old → new fully-qualified field name). + pub fn renamed_fields(&self) -> &[(String, String)] { + &self.renamed_fields + } + + /// Add `src`'s `pages` (0-based, in that order; `None` for all of them) after the pages added so far, + /// under a bookmark titled `title`. `src` need not outlive the call. + pub fn add(&mut self, title: &str, src: &Document, pages: Option<&[usize]>) -> Result<(), OrganizeError> { + let n = crate::page_count(src)?; + let pages: Vec = match pages { + Some(p) => { + if let Some(bad) = p.iter().find(|i| **i >= n) { + return Err(OrganizeError::NoSuchPage(*bad)); + } + p.to_vec() + } + None => (0..n).collect(), + }; + let at = crate::page_count(&self.out)?; + let imported = import_pages_mapped(&mut self.out, src, &pages, at, self.field_conflicts)?; + self.renamed_fields.extend(imported.renamed_fields); + if let Some(first) = imported.pages.first() { + let outline = source_outline(src, imported.page_map); + self.marks.push(Mark { title: title.to_string(), page: *first, outline }); + } + collect_attachments(&mut self.out, src, &mut self.attachments)?; + let declared = crate::pdfx::PrintStandard::of(src); + match self.standard.as_ref().map(|first| first.declared.same_as(&declared)) { + Some(same) => self.agreed &= same, + None => { + let source = declared.declares_output_intents().then(|| src.clone()); + self.standard = Some(Standard { declared, objects: imported.objects, source }); + } + } + Ok(()) + } + + /// The combined document: the pages as added, the print standard of the first source when every + /// source declares the same one, shared resources stored once, then the bookmarks and attachments. + pub fn finish(self) -> Result { + self.finish_with_renames().map(|o| o.doc) + } + + /// [`Combiner::finish`] reporting what the combine renamed. + pub fn finish_with_renames(self) -> Result { + let Combiner { mut out, marks, attachments, standard, agreed, renamed_fields, .. } = self; + if let Some(first) = standard.filter(|_| agreed) { + crate::pdfx::carry(&mut out, first.source.as_ref(), &first.declared, first.objects)?; + } + // Sources often share fonts, images and profiles (or are the same file): store them once. + let all: Vec = out.object_numbers().into_iter().map(|n| ObjRef::new(n, out.generation(n))).collect(); + crate::dedupe::dedupe_resources(&mut out, &all, false); + add_outline(&mut out, &marks)?; + set_attachments(&mut out, attachments)?; + Ok(CombineOutcome { doc: out, renamed_fields }) + } +} + +/// One run of pages for [`combine_grouped`]: the file it comes from (`group`: runs with the same +/// group are one file split around, and give the same document), its title, the document, and +/// its pages (0-based, in that order; `None` for all of them). +pub type Run<'a> = (usize, &'a str, &'a Document, Option<&'a [usize]>); + +/// Combine Files where a file's pages may be split into several runs with other files' pages +/// between them. Each file (group) is copied once, all its pages together, then the pages are put +/// in the runs' order: its links between its own pages, its form fields and its attachments stay +/// whole, and it gets one top-level bookmark (the title of its first run) at its first page, its +/// own bookmarks nested. Otherwise as [`combine_selected`] (print standard, shared resources). +pub fn combine_grouped(runs: &[Run<'_>]) -> Result { + combine_grouped_with(runs, FieldConflicts::Rename).map(|o| o.doc) +} + +/// [`combine_grouped`] reporting what the conflict resolution renamed. +pub fn combine_grouped_with(runs: &[Run<'_>], conflicts: FieldConflicts) -> Result { + // Each file's pages, in the order its runs list them, and each run's place in that list. + struct File<'a> { + title: &'a str, + src: &'a Document, + pages: Vec, + } + let mut files: Vec<(usize, File<'_>)> = Vec::new(); + let mut slices: Vec<(usize, usize, usize)> = Vec::with_capacity(runs.len()); + for (group, title, src, chosen) in runs { + let at = match files.iter().position(|(g, _)| g == group) { + Some(at) => at, + None => { + files.push((*group, File { title, src, pages: Vec::new() })); + files.len() - 1 + } + }; + let Some((_, file)) = files.get_mut(at) else { continue }; + let n = crate::page_count(file.src)?; + let pages: Vec = match chosen { + Some(p) => { + if let Some(bad) = p.iter().find(|i| **i >= n) { + return Err(OrganizeError::NoSuchPage(*bad)); + } + p.to_vec() + } + None => (0..n).collect(), + }; + slices.push((at, file.pages.len(), pages.len())); + file.pages.extend(pages); + } + let mut out = Document::new_empty(); + let mut marks = Vec::new(); + let mut attachments = Vec::new(); + let mut copies: Vec> = Vec::with_capacity(files.len()); + let mut renamed_fields = Vec::new(); + let mut standard: Option<(crate::pdfx::PrintStandard, &Document, HashMap)> = None; + let mut agreed = true; + for (_, file) in &files { + let at = crate::page_count(&out)?; + let imported = import_pages_mapped(&mut out, file.src, &file.pages, at, conflicts)?; + copies.push(imported.pages.clone()); + renamed_fields.extend(imported.renamed_fields); + if let Some(first) = imported.pages.first() { + marks.push(Mark { title: file.title.to_string(), page: *first, outline: source_outline(file.src, imported.page_map) }); + } + collect_attachments(&mut out, file.src, &mut attachments)?; + let declared = crate::pdfx::PrintStandard::of(file.src); + match &standard { + None => standard = Some((declared, file.src, imported.objects)), + Some((first, _, _)) => agreed &= first.same_as(&declared), + } + } + // The pages in the runs' order. A file's first run comes before its others, and its pages + // were copied in run order, so its bookmark (its first copied page) is its first page shown. + let mut order: Vec<(ObjRef, Dict)> = Vec::new(); + for (file, start, len) in slices { + let run = copies.get(file).and_then(|c| c.get(start..start.saturating_add(len))).unwrap_or_default(); + order.extend(run.iter().map(|r| (*r, Dict::new()))); + } + rebuild(&mut out, &order)?; + if let Some((declared, src, objects)) = standard.filter(|_| agreed) { + crate::pdfx::carry(&mut out, Some(src), &declared, objects)?; + } + let all: Vec = out.object_numbers().into_iter().map(|n| ObjRef::new(n, out.generation(n))).collect(); + crate::dedupe::dedupe_resources(&mut out, &all, false); + add_outline(&mut out, &marks)?; + set_attachments(&mut out, attachments)?; + Ok(CombineOutcome { doc: out, renamed_fields }) +} diff --git a/crates/organize/src/import/copy.rs b/crates/organize/src/import/copy.rs new file mode 100644 index 0000000000..f4b5499625 --- /dev/null +++ b/crates/organize/src/import/copy.rs @@ -0,0 +1,220 @@ +use super::{Dict, Document, HashMap, ObjRef, Object, OrganizeError, named_dest}; + +/// Keys that must not be followed when copying: they point back up or across the source +/// document, and following them would drag in unrelated objects. +pub(super) const SKIP_ON_PAGE: &[&[u8]] = &[b"Parent", b"B", b"StructParents", b"Thumb", b"PieceInfo"]; + +const SKIP_ON_ANNOT: &[&[u8]] = &[b"StructParent", b"P"]; + +/// How deep a copy follows references and nested arrays and dictionaries, counted together. +/// Real documents stay well under this (a page's resources, fonts and descriptors, nested +/// form XObjects and field trees are a few levels each); a chain of thousands of indirect +/// objects, each shallow enough for the parser, would otherwise exhaust the stack (#1800). +const MAX_COPY_DEPTH: usize = 256; + +/// Keys that stay on the widget when a combined field/widget is split (#1810): the annotation's +/// appearance, placement and page. Field state (name, value, flags, default appearance) moves to +/// the new parent field, where the widgets inherit it. +pub(super) const WIDGET_KEYS: &[&[u8]] = &[ + b"Type", + b"Subtype", + b"Rect", + b"P", + b"AP", + b"AS", + b"MK", + b"BS", + b"Border", + b"C", + b"CA", + b"F", + b"H", + b"R", + b"NM", + b"M", + b"StructParent", + b"StructParents", + b"OC", + b"Tabs", + b"A", + b"Contents", + b"BM", + b"Lang", + b"AF", +]; + +pub(super) struct Copier<'a> { + pub(super) src: &'a Document, + /// Current recursion depth, bounded by [`MAX_COPY_DEPTH`]. + depth: usize, + pub(super) map: HashMap, + /// Source page → destination page, for rewriting `/P` and destinations. + pub(super) pages: HashMap, + /// Destination objects that are annotation dictionaries (fixed up after copying). + pub(super) annots: Vec, + /// Top-level form fields reached through widgets. + pub(super) fields: Vec, + /// Optional content groups copied: (source, destination). + pub(super) ocgs: Vec<(ObjRef, ObjRef)>, +} + +impl<'a> Copier<'a> { + pub(super) fn new(src: &'a Document, map: HashMap, pages: HashMap) -> Self { + Copier { src, depth: 0, map, pages, annots: Vec::new(), fields: Vec::new(), ocgs: Vec::new() } + } + + /// Copy an indirect object (once), returning its destination reference. + pub(super) fn copy_ref(&mut self, dst: &mut Document, r: ObjRef) -> Result { + if let Some(d) = self.map.get(&r) { + return Ok(*d); + } + // Reserve the number first so cycles (field ↔ widget) terminate. + let new = dst.add(Object::Null); + self.map.insert(r, new); + let obj = self.src.get(r); + if obj.as_dict().is_some_and(|d| d.name(b"Type") == Some(b"OCG")) { + self.ocgs.push((r, new)); + } + let copied = self.copy_value(dst, &obj, Some(new))?; + dst.set(new, copied); + Ok(new) + } + + /// One level deeper into the object graph, or the refusal when there are too many. + fn descend(&mut self) -> Result<(), OrganizeError> { + if self.depth >= MAX_COPY_DEPTH { + return Err(OrganizeError::Invalid(format!("the pages refer to objects nested more than {MAX_COPY_DEPTH} levels deep"))); + } + self.depth += 1; + Ok(()) + } + + pub(super) fn copy_value(&mut self, dst: &mut Document, o: &Object, this: Option) -> Result { + if !matches!(o, Object::Ref(_) | Object::Array(_) | Object::Dict(_) | Object::Stream(_)) { + return Ok(o.clone()); + } + self.descend()?; + let copied = match o { + Object::Ref(r) => self.copy_ref(dst, *r).map(Object::Ref), + Object::Array(a) => a.iter().map(|x| self.copy_value(dst, x, None)).collect::, _>>().map(Object::Array), + Object::Dict(d) => self.copy_dict(dst, d, this).map(Object::Dict), + Object::Stream(s) => self.copy_dict(dst, &s.dict, None).map(|dict| { + let mut s = s.clone(); + s.dict = dict; + Object::Stream(s) + }), + other => Ok(other.clone()), + }; + self.depth = self.depth.saturating_sub(1); + copied + } + + fn copy_dict(&mut self, dst: &mut Document, d: &Dict, this: Option) -> Result { + let is_annot = d.name(b"Type") == Some(b"Annot") || (d.contains(b"Subtype") && d.contains(b"Rect") && !d.contains(b"Type")); + let is_field = d.contains(b"FT") || (d.contains(b"T") && d.contains(b"Kids")); + let mut out = Dict::new(); + for (k, v) in d.iter() { + if is_annot && SKIP_ON_ANNOT.contains(&k.as_slice()) { + continue; + } + // Destinations are rewritten afterwards (they may target pages copied later). + if is_annot && (k.as_slice() == b"Dest" || k.as_slice() == b"A") { + out.set(k.clone(), v.clone()); + continue; + } + out.set(k.clone(), self.copy_value(dst, v, None)?); + } + if let Some(this) = this { + if is_annot { + self.annots.push(this); + } + // A field (or widget) without a parent is a top-level field. + if is_field && !d.contains(b"Parent") { + self.fields.push(this); + } + } + Ok(out) + } + + /// Map a source destination (explicit array or reference to one) to the destination + /// document; `None` if it targets a page that was not copied. + pub(super) fn map_dest(&self, dest: &Object) -> Option { + self.map_dest_depth(dest, 0) + } + + fn map_dest_depth(&self, dest: &Object, depth: u8) -> Option { + if depth > 4 { + return None; + } + match dest { + Object::Name(n) => self.map_dest_depth(&named_dest(self.src, n)?, depth + 1), + Object::String(s) => self.map_dest_depth(&named_dest(self.src, &s.bytes)?, depth + 1), + Object::Ref(_) => self.map_dest_depth(&self.src.resolve(dest), depth + 1), + Object::Dict(d) => self.map_dest_depth(d.get(b"D")?, depth + 1), + Object::Array(a) => { + let page = a.first()?.as_ref()?; + let new_page = *self.pages.get(&page)?; + let mut a = a.clone(); + a[0] = Object::Ref(new_page); + Some(Object::Array(a)) + } + _ => None, + } + } + + /// Rewrite `/P`, `/Dest` and GoTo actions on copied annotations. + pub(super) fn fix_annotations(&mut self, dst: &mut Document, annot_pages: &HashMap) -> Result<(), OrganizeError> { + for &a in &std::mem::take(&mut self.annots) { + let src_dict = dst.get(a).as_dict().cloned(); + let Some(d) = src_dict else { continue }; + let mut d = d; + if let Some(p) = annot_pages.get(&a) { + d.set(b"P".to_vec(), Object::Ref(*p)); + } + if let Some(dest) = d.get(b"Dest").cloned() { + let resolved = self.src.resolve(&dest); + match self.map_dest(&resolved) { + Some(nd) => d.set(b"Dest".to_vec(), nd), + None => { + d.remove(b"Dest"); + } + } + } + if let Some(action) = d.get(b"A").cloned() { + let action = self.src.resolve(&action); + if let Some(ad) = action.as_dict() { + if ad.name(b"S") == Some(b"GoTo") { + let target = ad.get(b"D").map(|x| self.src.resolve(x)); + match target.and_then(|t| self.map_dest(&t)) { + Some(nd) => { + let mut na = Dict::new(); + na.set(b"S".to_vec(), Object::name("GoTo")); + na.set(b"D".to_vec(), nd); + d.set(b"A".to_vec(), Object::Dict(na)); + } + None => { + d.remove(b"A"); + } + } + } else { + // Other actions (URI, Launch, JavaScript…) carry no page references. The + // copier lends its map out and takes it back with the new copies in it, + // so actions sharing an object share its one copy. + let mut c = Copier::new(self.src, std::mem::take(&mut self.map), HashMap::new()); + let copied = c.copy_value(dst, &Object::Dict(ad.clone()), None); + self.map = c.map; + d.set(b"A".to_vec(), copied?); + } + } + } + dst.set(a, Object::Dict(d)); + } + Ok(()) + } +} + +/// Copy `o` from `src` into `dst`, reusing the copies listed in `map` (source → destination), so +/// an object the copied pages already brought along (an ICC profile) is not stored twice. +pub(crate) fn copy_object(dst: &mut Document, src: &Document, o: &Object, map: HashMap) -> Result { + Copier::new(src, map, HashMap::new()).copy_value(dst, o, None) +} diff --git a/crates/organize/src/import/fields.rs b/crates/organize/src/import/fields.rs new file mode 100644 index 0000000000..44910d0406 --- /dev/null +++ b/crates/organize/src/import/fields.rs @@ -0,0 +1,297 @@ +use super::copy::WIDGET_KEYS; +use super::{Dict, Document, HashMap, HashSet, ObjRef, Object, OrganizeError, walk}; + +/// Rename the top-level fields about to be registered in `dst` whose fully-qualified names +/// (`parent.T.child`) are already taken, so each imported field stays its own field instead of +/// blurring into the destination's field of the same name (fields of one fully-qualified name +/// are one field). A colliding node's own `/T` becomes the first free `Name-1`, `Name-2`… +/// variant, so everything below it keeps its place in the tree. Returns what was renamed +/// (old fully-qualified name → new one), in order. +pub(super) fn resolve_field_conflicts(dst: &mut Document, fields: &[ObjRef]) -> Result, OrganizeError> { + let mut taken = taken_field_names(dst)?; + let planned = plan_renames(dst, fields, &mut taken); + let mut renamed = Vec::new(); + for (node, new_t, old, new) in planned { + dst.update_dict(node, |d| d.set(b"T".to_vec(), Object::String(pdfcraft_cos::PdfString::text(&new_t))))?; + renamed.push((old, new)); + } + Ok(renamed) +} + +/// What importing `src` into `dst` would rename, computed without changing either document: +/// each fully-qualified name of `src`'s fields that `dst` already uses (or that an earlier +/// field of `src` itself uses) is renamed to the first free variant, exactly as the import +/// would. Unreadable structure previews as nothing: the import itself will say why it failed. +pub fn field_name_conflicts(dst: &Document, src: &Document) -> Vec<(String, String)> { + let Ok(mut taken) = taken_field_names(dst) else { return Vec::new() }; + let Ok(roots) = field_roots(src) else { return Vec::new() }; + plan_renames(src, &roots, &mut taken).into_iter().map(|(_, _, old, new)| (old, new)).collect() +} + +/// What combining `sources`, in order, would rename in each one: the whole list is simulated +/// with the same first-free-variant rule the combine uses, so a later file's preview sees the +/// earlier files' renames. Unreadable structure previews as nothing (the combine itself will +/// say why it fails); non-colliding names still count as taken for the files after them. +pub fn combine_field_conflicts(sources: &[&Document]) -> Vec> { + let mut taken = std::collections::HashSet::new(); + sources + .iter() + .map(|src| match field_roots(src) { + Ok(roots) => plan_renames(src, &roots, &mut taken).into_iter().map(|(_, _, old, new)| (old, new)).collect(), + Err(_) => Vec::new(), + }) + .collect() +} + +/// Walk field trees `roots` in `doc` and plan the renames that would keep every name separate +/// from `taken`: each node's fully-qualified name joins `taken` (so a later field of the same +/// import collides with an earlier one), and a node already taken is renamed to the first free +/// variant of its own `/T`. Names are compared and renamed as decoded text, not as stored +/// bytes, so one name written in two encodings (PDFDocEncoding, UTF-16BE) is one name. +/// Read-only: returns (node, new `/T`, old name, new name). Cycle- and depth-safe. +fn plan_renames(doc: &Document, roots: &[ObjRef], taken: &mut std::collections::HashSet) -> Vec<(ObjRef, String, String, String)> { + let mut planned = Vec::new(); + let mut walk = RenameWalk::default(); + for &root in roots { + plan_renames_under(doc, root, "", taken, &mut walk, 0, &mut planned); + } + planned +} + +/// What one [`plan_renames`] call remembers: the nodes it visited, and for each colliding name +/// the suffix to try next. +#[derive(Default)] +struct RenameWalk { + seen: HashSet, + next: HashMap, +} + +/// One node of [`plan_renames`]. +fn plan_renames_under( + doc: &Document, + node: ObjRef, + prefix: &str, + taken: &mut std::collections::HashSet, + walk: &mut RenameWalk, + depth: u8, + planned: &mut Vec<(ObjRef, String, String, String)>, +) { + if depth > 32 || !walk.seen.insert(node) { + return; + } + let Some(d) = doc.get(node).as_dict().cloned() else { return }; + let mut name = d.get(b"T").and_then(|t| doc.resolve(t).as_string().map(|s| s.to_text())).unwrap_or_default(); + if !name.is_empty() { + let fqn = qualified(prefix, &name); + if taken.contains(&fqn) { + // `taken` only grows, so every suffix below the last one handed out for this name is + // still taken: resume there instead of at 1. + let mut n = walk.next.get(&fqn).copied().unwrap_or(1); + let mut variant; + while { + #[cfg(test)] + crate::work(1); + variant = format!("{name}-{n}"); + taken.contains(&qualified(prefix, &variant)) + } { + n = n.saturating_add(1); + } + walk.next.insert(fqn.clone(), n.saturating_add(1)); + let new_fqn = qualified(prefix, &variant); + planned.push((node, variant.clone(), fqn, new_fqn.clone())); + taken.insert(new_fqn); + name = variant; + } else { + taken.insert(fqn); + } + } + let prefix = qualified_optional(prefix, &name); + let kids = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_default(); + for kid in kids.iter().filter_map(|k| k.as_ref()) { + plan_renames_under(doc, kid, &prefix, taken, walk, depth + 1, planned); + } +} + +/// The field names in use in `dst`: every name in its form, and every name hanging only off a +/// page's widgets — a field the form never lists still owns its name. +fn taken_field_names(dst: &Document) -> Result, OrganizeError> { + let mut taken = std::collections::HashSet::new(); + let mut seen = std::collections::HashSet::new(); + for root in field_roots(dst)? { + collect_names(dst, root, "", &mut seen, 0, &mut taken); + } + Ok(taken) +} + +/// A document's field trees, wherever they are rooted: the `/AcroForm /Fields` entries, and +/// the fields behind widgets on pages (climbed to their root, for forms that never registered +/// them). +fn field_roots(doc: &Document) -> Result, OrganizeError> { + let mut roots: Vec = doc + .root() + .and_then(|r| doc.get(r).as_dict().cloned()) + .and_then(|c| c.get(b"AcroForm").map(|f| doc.resolve(f))) + .and_then(|f| f.as_dict().cloned()) + .and_then(|f| f.get(b"Fields").map(|l| doc.resolve(l))) + .and_then(|l| l.as_array().cloned()) + .unwrap_or_default() + .iter() + .filter_map(|f| f.as_ref()) + .collect(); + for (page, _) in walk(doc)? { + let Some(d) = doc.get(page).as_dict().cloned() else { continue }; + let annots = d.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default(); + for a in annots.iter() { + let Some(w) = doc.resolve(a).as_dict().cloned() else { continue }; + if w.name(b"Subtype") != Some(b"Widget") { + continue; + } + // Walk up to the field the widget belongs to, however deep it sits; a widget that + // is its own field is its own root. + let Some(mut root) = a.as_ref() else { continue }; + let mut up = 0; + while let Some(parent) = doc.get(root).as_dict().and_then(|p| p.reference(b"Parent")) { + if up >= 32 || root == parent { + break; + } + root = parent; + up += 1; + } + roots.push(root); + } + } + Ok(roots) +} + +/// Record the fully-qualified names of one field tree. Cycle- and depth-safe. +fn collect_names( + dst: &Document, + node: ObjRef, + prefix: &str, + seen: &mut std::collections::HashSet, + depth: u8, + out: &mut std::collections::HashSet, +) { + if depth > 32 || !seen.insert(node) { + return; + } + let Some(d) = dst.get(node).as_dict().cloned() else { return }; + let name = d.get(b"T").and_then(|t| dst.resolve(t).as_string().map(|s| s.to_text())).unwrap_or_default(); + let prefix = qualified_optional(prefix, &name); + if !name.is_empty() { + out.insert(prefix.clone()); + } + let kids = d.get(b"Kids").map(|k| dst.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_default(); + for kid in kids.iter().filter_map(|k| k.as_ref()) { + collect_names(dst, kid, &prefix, seen, depth + 1, out); + } +} + +/// `prefix.name`, or `name` at the top level. +fn qualified(prefix: &str, name: &str) -> String { + if prefix.is_empty() { name.to_string() } else { format!("{prefix}.{name}") } +} + +/// The prefix a node's children hang under: its own name, when it has one. +fn qualified_optional(prefix: &str, name: &str) -> String { + if name.is_empty() { prefix.to_string() } else { qualified(prefix, name) } +} + +/// Add `kid` to `field`'s `/Kids`, in the array object itself when `/Kids` is one, so the field +/// keeps its other widgets. +pub(super) fn add_kid(dst: &mut Document, field: ObjRef, kid: ObjRef) -> Result<(), OrganizeError> { + match dst.get(field).as_dict().and_then(|f| f.get(b"Kids").cloned()) { + Some(Object::Ref(list)) => { + let mut kids = dst.get(list).as_array().cloned().unwrap_or_default(); + kids.push(Object::Ref(kid)); + dst.set(list, Object::Array(kids)); + } + _ => dst.update_dict(field, |f| { + let mut kids = f.get(b"Kids").and_then(|k| k.as_array()).cloned().unwrap_or_default(); + kids.push(Object::Ref(kid)); + f.set(b"Kids".to_vec(), Object::Array(kids)); + })?, + } + Ok(()) +} + +/// PdfCraft patch (#1810): split a widget that doubles as its own field into a parent field +/// holding the field keys (name, value, flags…) and a widget child holding the annotation keys +/// (rectangle, appearance, page). The parent takes the widget's place among the top-level fields; +/// later copies of the page add widget children of it, so every page has its own widget and the +/// field stays one. +pub(super) fn split_own_field(dst: &mut Document, fields: &mut [ObjRef], widget: ObjRef) { + let Some(d) = dst.get(widget).as_dict().cloned() else { return }; + let (mut field, mut child) = field_and_widget(dst, &d); + let parent = dst.add(Object::Null); + field.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(widget)])); + child.set(b"Parent".to_vec(), Object::Ref(parent)); + dst.set(parent, Object::Dict(field)); + dst.set(widget, Object::Dict(child)); + if let Some(entry) = fields.iter_mut().find(|f| **f == widget) { + *entry = parent; + } +} + +/// A combined field/widget has two additional-action namespaces (ISO 32000-1 tables 194/196). +/// Partition them as well as the ordinary entries, including when copying a later widget. +pub(super) fn field_and_widget(doc: &Document, d: &Dict) -> (Dict, Dict) { + let (mut field, mut child) = (Dict::new(), Dict::new()); + for (k, v) in d.iter() { + if k.as_slice() == b"AA" + && let Some(actions) = doc.resolve(v).as_dict().cloned() + { + let (mut field_actions, mut widget_actions) = (Dict::new(), Dict::new()); + for (trigger, action) in actions.iter() { + if [b"E".as_slice(), b"X", b"D", b"U", b"Fo", b"Bl", b"PO", b"PC", b"PV", b"PI"].contains(&trigger.as_slice()) { + widget_actions.set(trigger.clone(), action.clone()); + } else { + field_actions.set(trigger.clone(), action.clone()); + } + } + if field_actions.iter().next().is_some() { + field.set(k.clone(), Object::Dict(field_actions)); + } + if widget_actions.iter().next().is_some() { + child.set(k.clone(), Object::Dict(widget_actions)); + } + } else if WIDGET_KEYS.contains(&k.as_slice()) { + child.set(k.clone(), v.clone()); + } else { + field.set(k.clone(), v.clone()); + } + } + (field, child) +} + +/// Add top-level fields to the destination's `/AcroForm /Fields` (creating the form if needed). +pub(super) fn register_fields(dst: &mut Document, fields: &[ObjRef]) -> Result<(), OrganizeError> { + if fields.is_empty() { + return Ok(()); + } + let root = dst.root().ok_or(OrganizeError::NoPageTree)?; + let catalog = dst.get(root).as_dict().cloned().ok_or(OrganizeError::NoPageTree)?; + let (form_ref, mut form) = match catalog.get(b"AcroForm") { + Some(Object::Ref(r)) => (Some(*r), dst.get(*r).as_dict().cloned().unwrap_or_default()), + Some(Object::Dict(d)) => (None, d.clone()), + _ => (None, Dict::new()), + }; + let mut list = form.get(b"Fields").map(|f| dst.resolve(f)).and_then(|f| f.as_array().cloned()).unwrap_or_default(); + let mut listed: HashSet = list.iter().filter_map(Object::as_ref).collect(); + for f in fields { + #[cfg(test)] + crate::work(1); + if listed.insert(*f) { + list.push(Object::Ref(*f)); + } + } + form.set(b"Fields".to_vec(), Object::Array(list)); + match form_ref { + Some(r) => dst.set(r, Object::Dict(form)), + None => { + let r = dst.add(form); + dst.update_dict(root, |c| c.set(b"AcroForm".to_vec(), Object::Ref(r)))?; + } + } + Ok(()) +} diff --git a/crates/platform/src/staging.rs b/crates/platform/src/staging.rs index 092aa65239..4316051352 100644 --- a/crates/platform/src/staging.rs +++ b/crates/platform/src/staging.rs @@ -292,455 +292,4 @@ fn sync_staged(f: &std::fs::File, sync_all: impl FnOnce(&std::fs::File) -> std:: } #[cfg(test)] -mod tests { - use super::*; - - /// A fresh, empty folder for one test. - fn test_dir(test: &str) -> PathBuf { - let dir = std::env::temp_dir().join(format!("pdfcraft-platform-staging-{}-{test}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - dir - } - - fn file_name(p: &Path) -> String { - p.file_name().unwrap().to_string_lossy().into_owned() - } - - /// The callers' tests plant files at these exact names; if a name changed, those tests would - /// plant where nothing writes and pass without testing anything. - #[test] - fn each_form_keeps_its_exact_name() { - let dir = test_dir("forms"); - let (tools, _) = create_staging(&dir, "out.pdf", StagingName::SuffixThenTag, [0xab]).unwrap(); - assert_eq!(file_name(&tools), ".out.pdf.00000000000000ab.pdfcraft-tmp"); - let (desktop, _) = create_staging(&dir, "out.pdf", StagingName::TagThenSuffix, [0xab]).unwrap(); - assert_eq!(file_name(&desktop), ".out.pdf.pdfcraft-00000000000000ab.tmp"); - assert_eq!(std::fs::read(&tools).unwrap(), b"", "a new, empty file"); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn a_long_name_is_cut_to_128_bytes_between_characters() { - let dir = test_dir("long"); - // One byte, then four-byte characters: the 128-byte limit falls inside the 32nd one. - let name = format!("a{}.pdf", "\u{1F600}".repeat(40)); - let (tmp, _) = create_staging(&dir, &name, StagingName::SuffixThenTag, [1]).unwrap(); - assert_eq!(file_name(&tmp), format!(".a{}.0000000000000001.pdfcraft-tmp", "\u{1F600}".repeat(31))); - // One-byte characters: exactly 128 of them are kept. - let (tmp, _) = create_staging(&dir, &"x".repeat(200), StagingName::SuffixThenTag, [2]).unwrap(); - assert_eq!(file_name(&tmp), format!(".{}.0000000000000002.pdfcraft-tmp", "x".repeat(128))); - let _ = std::fs::remove_dir_all(&dir); - } - - /// #2658: a read-only file is refused before anything is staged, on Unix (where a rename would - /// replace it) as on Windows (where the rename fails). - #[test] - fn a_read_only_target_is_refused_and_kept() { - let dir = test_dir("readonly"); - let target = dir.join("out.pdf"); - std::fs::write(&target, "OLD").unwrap(); - let mut perms = std::fs::metadata(&target).unwrap().permissions(); - perms.set_readonly(true); - std::fs::set_permissions(&target, perms.clone()).unwrap(); - let staged = std::cell::Cell::new(false); - let e = write_atomically_with(&target, b"NEW", StagingName::TagThenSuffix, [7], |_: &std::fs::File| { - staged.set(true); - Ok(()) - }) - .unwrap_err(); - assert_eq!(e.kind(), std::io::ErrorKind::PermissionDenied, "{e}"); - assert!(!staged.get(), "nothing was staged"); - assert_eq!(std::fs::read(&target).unwrap(), b"OLD"); - assert!(std::fs::metadata(&target).unwrap().permissions().readonly(), "still read-only"); - assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1, "no staging file is left"); - #[allow(clippy::permissions_set_readonly_false)] - perms.set_readonly(false); - std::fs::set_permissions(&target, perms).unwrap(); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn running_out_of_suffixes_refuses_and_creates_nothing() { - let dir = test_dir("empty"); - let e = create_staging(&dir, "out.pdf", StagingName::TagThenSuffix, []).unwrap_err(); - assert_eq!(e.kind(), std::io::ErrorKind::AlreadyExists); - assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn only_the_first_sixteen_names_are_tried() { - let dir = test_dir("cap"); - let name = |s: u64| dir.join(format!(".out.pdf.{s:016x}.pdfcraft-tmp")); - for s in 1..=STAGING_ATTEMPTS as u64 { - std::fs::write(name(s), "PLANTED").unwrap(); - } - assert!(create_staging(&dir, "out.pdf", StagingName::SuffixThenTag, 1..).is_err()); - assert!(!name(STAGING_ATTEMPTS as u64 + 1).exists(), "a 17th name was tried"); - let _ = std::fs::remove_dir_all(&dir); - } - - /// `write_atomically`: the target holds the new bytes only once they are complete; a refused - /// flush leaves the old file and removes the staging file; a missing folder writes nothing. - #[test] - fn an_atomic_write_replaces_the_target_only_when_complete() { - let dir = test_dir("write"); - let target = dir.join("out.pdf"); - std::fs::write(&target, b"OLD").unwrap(); - let names = |dir: &Path| { - let mut v: Vec = std::fs::read_dir(dir).unwrap().map(|e| file_name(&e.unwrap().path())).collect(); - v.sort(); - v - }; - // The staging file is written and flushed before the target changes. - let mut seen_old = false; - write_atomically_with(&target, b"NEW", StagingName::SuffixThenTag, [7], |f| { - seen_old = std::fs::read(&target).unwrap() == b"OLD" && f.metadata().unwrap().len() == 3; - Ok(()) - }) - .unwrap(); - assert!(seen_old, "flushed before publication, with the target still the old file"); - assert_eq!(std::fs::read(&target).unwrap(), b"NEW"); - assert_eq!(names(&dir), ["out.pdf"], "no staging file is left"); - // A refused flush keeps the target and cleans up. - let e = write_atomically_with(&target, b"NEWER", StagingName::TagThenSuffix, [8], |_| Err(std::io::Error::other("refused"))).unwrap_err(); - assert!(e.to_string().contains("refused")); - assert_eq!(std::fs::read(&target).unwrap(), b"NEW"); - assert_eq!(names(&dir), ["out.pdf"]); - // A folder that does not exist: nothing is written anywhere. - assert!(write_atomically(&dir.join("missing").join("out.pdf"), b"X", StagingName::SuffixThenTag).is_err()); - assert_eq!(names(&dir), ["out.pdf"]); - let _ = std::fs::remove_dir_all(&dir); - } - - #[cfg(target_os = "macos")] - #[test] - fn atomic_saves_keep_finder_tags_and_binary_extended_attributes() { - use rustix::fs::{XattrFlags, fgetxattr, setxattr}; - use std::os::macos::fs::FileTimesExt as _; - let dir = test_dir("xattrs"); - let target = dir.join("out.pdf"); - let attributes: &[(&str, &[u8])] = &[ - ("com.apple.metadata:_kMDItemUserTags", b"PdfCraft\n6"), - ("com.pdfcraft.test.binary", b"\0\xff\x80original\0"), - ("com.pdfcraft.test.empty", b""), - ]; - std::fs::write(&target, b"OLD").unwrap(); - for (name, value) in attributes { - setxattr(&target, *name, value, XattrFlags::empty()).unwrap(); - } - let created = std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_577_934_245); - let original = std::fs::OpenOptions::new().write(true).open(&target).unwrap(); - original.set_times(std::fs::FileTimes::new().set_created(created)).unwrap(); - assert_eq!(original.metadata().unwrap().created().unwrap(), created); - drop(original); - // Both desktop and automation name forms, including a second save of the replacement. - for form in [StagingName::TagThenSuffix, StagingName::SuffixThenTag] { - write_atomically_with(&target, b"NEW", form, [7], |staged| { - assert_eq!(staged.metadata().unwrap().created().unwrap(), created, "creation date must survive attribute copying before flush"); - for (name, value) in attributes { - let mut bytes = [0u8; 256]; - let len = fgetxattr(staged, *name, &mut bytes[..]).unwrap(); - assert_eq!(&bytes[..len], *value, "metadata must be present before flush: {name}"); - } - Ok(()) - }) - .unwrap(); - let saved = std::fs::File::open(&target).unwrap(); - assert_eq!(saved.metadata().unwrap().created().unwrap(), created); - for (name, value) in attributes { - let mut bytes = [0u8; 256]; - let len = fgetxattr(&saved, *name, &mut bytes[..]).unwrap(); - assert_eq!(&bytes[..len], *value); - } - assert_eq!(std::fs::read(&target).unwrap(), b"NEW"); - assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1); - } - std::fs::remove_dir_all(dir).unwrap(); - } - - #[cfg(target_os = "macos")] - #[test] - fn a_failed_flush_keeps_the_original_extended_attributes() { - use rustix::fs::{XattrFlags, getxattr, setxattr}; - let dir = test_dir("xattrs-flush"); - let target = dir.join("out.pdf"); - std::fs::write(&target, b"OLD").unwrap(); - setxattr(&target, "com.pdfcraft.test", b"original", XattrFlags::empty()).unwrap(); - let e = write_atomically_with(&target, b"NEW", StagingName::TagThenSuffix, [9], |_| Err(std::io::Error::other("flush refused"))).unwrap_err(); - assert!(e.to_string().contains("flush refused")); - assert_eq!(std::fs::read(&target).unwrap(), b"OLD"); - let mut value = [0u8; 64]; - let len = getxattr(&target, "com.pdfcraft.test", &mut value[..]).unwrap(); - assert_eq!(&value[..len], b"original"); - assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1); - std::fs::remove_dir_all(dir).unwrap(); - } - - /// Model an HFS-style partial read while using real macOS metadata and the atomic writer. - /// The shared read validator must reject growth before the staging file can be published. - #[cfg(target_os = "macos")] - #[test] - fn a_growing_resource_fork_refuses_publication_and_keeps_original() { - use rustix::fs::{XattrFlags, fgetxattr, fsetxattr, getxattr, setxattr}; - let dir = test_dir("xattrs-growing-fork"); - let target = dir.join("out.pdf"); - let name = "com.apple.ResourceFork"; - for form in [StagingName::TagThenSuffix, StagingName::SuffixThenTag] { - std::fs::write(&target, b"ORIGINAL PDF").unwrap(); - setxattr(&target, name, b"ABC", XattrFlags::empty()).unwrap(); - let source = std::fs::File::open(&target).unwrap(); - let result = write_atomically_with(&target, b"REPLACEMENT PDF", form, [23], |staged| { - let size = fgetxattr(&source, name, &mut [] as &mut [u8])?; - let value = read_xattr_value(size, |buffer| { - if buffer.is_empty() { - return Ok(fgetxattr(&source, name, buffer)?); - } - // HFS can return the prefix that fits instead of ERANGE. APFS need not - // have that behaviour: model the kernel response, not a timing race. - setxattr(&target, name, b"ABCD", XattrFlags::empty())?; - buffer.get_mut(..3).unwrap().copy_from_slice(b"ABC"); - Ok(3) - })?; - fsetxattr(staged, name, &value, XattrFlags::empty())?; - Ok(()) - }); - let error = result.expect_err("a short prefix of the grown fork must not be published"); - assert!(error.to_string().contains("changed size"), "{error}"); - assert_eq!(std::fs::read(&target).unwrap(), b"ORIGINAL PDF"); - let mut bytes = [0u8; 8]; - let len = getxattr(&target, name, &mut bytes[..]).unwrap(); - assert_eq!(&bytes[..len], b"ABCD", "the original's grown fork must remain intact"); - assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1, "staging files are removed on refusal"); - } - std::fs::remove_dir_all(dir).unwrap(); - } - - /// Stable empty/binary values are accepted; short reads, growth and failed rechecks are refused. - #[cfg(target_os = "macos")] - #[test] - fn attribute_reads_require_a_complete_value_and_stable_size() { - for bytes in [&b""[..], &b"\0\xffB"[..]] { - let value = read_xattr_value(bytes.len(), |buffer| { - if !buffer.is_empty() { - buffer.get_mut(..bytes.len()).unwrap().copy_from_slice(bytes); - } - Ok(bytes.len()) - }) - .unwrap(); - assert_eq!(value, bytes); - } - for (before, read_len, after) in [(3, 2, 3), (3, 3, 4), (0, 1, 1), (3, 2, 2)] { - let result = read_xattr_value(before, |buffer| Ok(if buffer.is_empty() { after } else { read_len })); - assert!(result.unwrap_err().to_string().contains("changed size")); - } - let result = read_xattr_value(3, |buffer| { - if buffer.is_empty() { Err(std::io::Error::new(std::io::ErrorKind::PermissionDenied, "size recheck refused")) } else { Ok(3) } - }); - assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::PermissionDenied); - } - - #[cfg(target_os = "macos")] - #[test] - fn oversized_extended_attributes_refuse_the_save_without_replacing_the_target() { - use rustix::fs::{XattrFlags, getxattr, setxattr}; - let dir = test_dir("xattrs-limit"); - let target = dir.join("out.pdf"); - std::fs::write(&target, b"OLD").unwrap(); - // A resource fork can exceed the ordinary attribute limit on macOS. No fixture file - // is committed; only the OS metadata of this temporary synthetic file holds the bytes. - let fork = vec![42u8; 16 * 1024 * 1024 + 1]; - setxattr(&target, "com.apple.ResourceFork", &fork, XattrFlags::empty()).unwrap(); - let mut flushed = false; - let e = write_atomically_with(&target, b"NEW", StagingName::TagThenSuffix, [10], |_| { - flushed = true; - Ok(()) - }) - .unwrap_err(); - assert!(e.to_string().contains("extended attributes"), "{e}"); - assert!(e.to_string().contains("Save As"), "{e}"); - assert!(!flushed, "metadata failure must stop before flush and rename"); - assert_eq!(std::fs::read(&target).unwrap(), b"OLD"); - let size = getxattr(&target, "com.apple.ResourceFork", &mut [] as &mut [u8]).unwrap(); - assert_eq!(size, fork.len()); - assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1); - std::fs::remove_dir_all(dir).unwrap(); - } - - #[cfg(target_os = "macos")] - #[test] - fn replacing_a_symlink_does_not_copy_attributes_from_its_referent() { - use rustix::fs::{XattrFlags, getxattr, setxattr}; - let dir = test_dir("xattrs-symlink"); - let outside = test_dir("xattrs-outside").join("private.pdf"); - std::fs::write(&outside, b"PRIVATE").unwrap(); - setxattr(&outside, "com.pdfcraft.test", b"private metadata", XattrFlags::empty()).unwrap(); - let target = dir.join("out.pdf"); - std::os::unix::fs::symlink(&outside, &target).unwrap(); - write_atomically(&target, b"NEW", StagingName::TagThenSuffix).unwrap(); - assert_eq!(std::fs::read(&outside).unwrap(), b"PRIVATE"); - assert_eq!(std::fs::read(&target).unwrap(), b"NEW"); - assert!(!target.symlink_metadata().unwrap().file_type().is_symlink()); - let mut bytes = [0u8; 64]; - assert_eq!(getxattr(&target, "com.pdfcraft.test", &mut bytes[..]).unwrap_err(), rustix::io::Errno::NOATTR); - let len = getxattr(&outside, "com.pdfcraft.test", &mut bytes[..]).unwrap(); - assert_eq!(&bytes[..len], b"private metadata"); - std::fs::remove_dir_all(dir).unwrap(); - std::fs::remove_dir_all(outside.parent().unwrap()).unwrap(); - } - - /// #1790: a replaced file keeps its permissions, and its staging file already has them while - /// it is written; a new file gets the same mode as any other new file. - #[cfg(unix)] - #[test] - fn a_replaced_file_keeps_its_permissions() { - use std::os::unix::fs::PermissionsExt as _; - let dir = test_dir("modes"); - let mode = |p: &Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777; - let target = dir.join("out.pdf"); - for keep in [0o600, 0o640, 0o664] { - std::fs::write(&target, b"OLD").unwrap(); - std::fs::set_permissions(&target, std::fs::Permissions::from_mode(keep)).unwrap(); - let mut staged = 0; - write_atomically_with(&target, b"NEW", StagingName::SuffixThenTag, [keep as u64], |f| { - staged = f.metadata()?.permissions().mode() & 0o777; - Ok(()) - }) - .unwrap(); - assert_eq!(staged, keep, "the staging file has the target's mode before it is flushed"); - assert_eq!(mode(&target), keep, "the saved file keeps mode {keep:o}"); - assert_eq!(std::fs::read(&target).unwrap(), b"NEW"); - } - let fresh = dir.join("new.pdf"); - write_atomically(&fresh, b"NEW", StagingName::TagThenSuffix).unwrap(); - let plain = dir.join("plain"); - std::fs::write(&plain, b"").unwrap(); - assert_eq!(mode(&fresh), mode(&plain), "a new file is not made private"); - let _ = std::fs::remove_dir_all(&dir); - } - - /// A replaced file keeps its group as well as its mode, so group permissions never reach a - /// group that couldn't read the file before (the saver's own group, by default). - #[cfg(target_os = "linux")] - #[test] - fn a_replaced_file_keeps_its_group() { - use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _}; - let dir = test_dir("group"); - let target = dir.join("out.pdf"); - std::fs::write(&target, b"OLD").unwrap(); - let created = std::fs::metadata(&target).unwrap().gid(); - let status = std::fs::read_to_string("/proc/self/status").unwrap(); - let groups = status.lines().find_map(|l| l.strip_prefix("Groups:")).unwrap_or_default(); - let Some(other) = groups.split_whitespace().filter_map(|g| g.parse::().ok()).find(|&g| g != created) else { - eprintln!("skipped: the test needs a second group"); - return; - }; - std::os::unix::fs::chown(&target, None, Some(other)).unwrap(); - std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o640)).unwrap(); - let mut staged = (0, 0); - write_atomically_with(&target, b"NEW", StagingName::SuffixThenTag, [7], |f| { - let m = f.metadata()?; - staged = (m.gid(), m.permissions().mode() & 0o777); - Ok(()) - }) - .unwrap(); - assert_eq!(staged, (other, 0o640), "the staging file has the target's group before it is flushed"); - let m = std::fs::metadata(&target).unwrap(); - assert_eq!((m.gid(), m.permissions().mode() & 0o777), (other, 0o640)); - let _ = std::fs::remove_dir_all(&dir); - } - - /// #2658: a save replaces the file, but the document is the same one: it keeps its creation - /// date where the system lets a program set it. - #[cfg(any(windows, target_os = "macos"))] - #[test] - fn a_replaced_file_keeps_its_creation_date() { - #[cfg(target_os = "macos")] - use std::os::macos::fs::FileTimesExt as _; - #[cfg(windows)] - use std::os::windows::fs::FileTimesExt as _; - let dir = test_dir("created"); - let target = dir.join("doc.pdf"); - std::fs::write(&target, b"OLD").unwrap(); - // 2020-01-02 03:04:05 UTC. - let then = std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_577_934_245); - let file = std::fs::OpenOptions::new().write(true).open(&target).unwrap(); - file.set_times(std::fs::FileTimes::new().set_created(then)).unwrap(); - drop(file); - assert_eq!(std::fs::metadata(&target).unwrap().created().unwrap(), then, "the fixture's date is set"); - write_atomically(&target, b"NEW", StagingName::SuffixThenTag).unwrap(); - assert_eq!(std::fs::read(&target).unwrap(), b"NEW"); - assert_eq!(std::fs::metadata(&target).unwrap().created().unwrap(), then, "the saved file keeps its creation date"); - // A new file gets a date of its own. - let fresh = dir.join("new.pdf"); - write_atomically(&fresh, b"NEW", StagingName::SuffixThenTag).unwrap(); - assert!(std::fs::metadata(&fresh).unwrap().created().unwrap() > then); - let _ = std::fs::remove_dir_all(&dir); - } - - /// #4593: a save replaces the file, so a hidden or system file stays hidden or system, and - /// its staging file already is while it is written; a plain file stays plain, though the - /// staging name starts with a dot; a new file gets no attribute from anywhere. - #[cfg(windows)] - #[test] - fn a_replaced_file_keeps_its_hidden_and_system_attributes() { - use std::os::windows::fs::{MetadataExt as _, OpenOptionsExt as _}; - const HIDDEN: u32 = 0x2; - const SYSTEM: u32 = 0x4; - let dir = test_dir("attributes"); - let kept = |p: &Path| std::fs::metadata(p).unwrap().file_attributes() & (HIDDEN | SYSTEM); - for (i, keep) in [0, HIDDEN, SYSTEM, HIDDEN | SYSTEM].into_iter().enumerate() { - let target = dir.join(format!("doc{i}.pdf")); - let mut fixture = std::fs::OpenOptions::new().write(true).create_new(true).attributes(keep).open(&target).unwrap(); - std::io::Write::write_all(&mut fixture, b"OLD").unwrap(); - drop(fixture); - assert_eq!(kept(&target), keep, "the fixture has attributes {keep:#x}"); - for form in [StagingName::TagThenSuffix, StagingName::SuffixThenTag] { - let mut staged = u32::MAX; - write_atomically_with(&target, b"NEW", form, [i as u64], |f| { - staged = f.metadata()?.file_attributes() & (HIDDEN | SYSTEM); - Ok(()) - }) - .unwrap(); - assert_eq!(staged, keep, "the staging file has the target's attributes before it is flushed"); - assert_eq!(kept(&target), keep, "the saved file keeps attributes {keep:#x}"); - assert_eq!(std::fs::read(&target).unwrap(), b"NEW"); - } - } - let fresh = dir.join("new.pdf"); - write_atomically(&fresh, b"NEW", StagingName::TagThenSuffix).unwrap(); - assert_eq!(kept(&fresh), 0, "a new file is neither hidden nor system"); - let _ = std::fs::remove_dir_all(&dir); - } - - /// A save replaces a symbolic link, not the file it points to, so the saved file takes the - /// link's own attributes: a plain link to a hidden file saves as a plain file, and the file - /// it pointed to is left as it was. - #[cfg(windows)] - #[test] - fn a_replaced_link_keeps_its_own_attributes_not_its_targets() { - use std::os::windows::fs::{MetadataExt as _, OpenOptionsExt as _}; - const HIDDEN: u32 = 0x2; - let dir = test_dir("link-attributes"); - let referent = dir.join("referent.pdf"); - let mut fixture = std::fs::OpenOptions::new().write(true).create_new(true).attributes(HIDDEN).open(&referent).unwrap(); - std::io::Write::write_all(&mut fixture, b"OLD").unwrap(); - drop(fixture); - let link = dir.join("link.pdf"); - if let Err(e) = std::os::windows::fs::symlink_file(&referent, &link) { - eprintln!("skipped: creating a symbolic link needs Developer Mode or administrator rights ({e})"); - let _ = std::fs::remove_dir_all(&dir); - return; - } - assert_eq!(std::fs::symlink_metadata(&link).unwrap().file_attributes() & HIDDEN, 0, "the link itself is visible"); - write_atomically(&link, b"NEW", StagingName::TagThenSuffix).unwrap(); - let saved = std::fs::symlink_metadata(&link).unwrap(); - assert!(!saved.file_type().is_symlink(), "the save replaced the link"); - assert_eq!(saved.file_attributes() & HIDDEN, 0, "the link was visible, so the saved file is"); - assert_eq!(std::fs::read(&link).unwrap(), b"NEW"); - assert_eq!(std::fs::read(&referent).unwrap(), b"OLD", "the file the link pointed to is untouched"); - assert_ne!(std::fs::metadata(&referent).unwrap().file_attributes() & HIDDEN, 0, "and still hidden"); - let _ = std::fs::remove_dir_all(&dir); - } -} +mod tests; diff --git a/crates/platform/src/staging/tests.rs b/crates/platform/src/staging/tests.rs new file mode 100644 index 0000000000..b5552a2c7b --- /dev/null +++ b/crates/platform/src/staging/tests.rs @@ -0,0 +1,450 @@ +use super::*; + +/// A fresh, empty folder for one test. +fn test_dir(test: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("pdfcraft-platform-staging-{}-{test}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +fn file_name(p: &Path) -> String { + p.file_name().unwrap().to_string_lossy().into_owned() +} + +/// The callers' tests plant files at these exact names; if a name changed, those tests would +/// plant where nothing writes and pass without testing anything. +#[test] +fn each_form_keeps_its_exact_name() { + let dir = test_dir("forms"); + let (tools, _) = create_staging(&dir, "out.pdf", StagingName::SuffixThenTag, [0xab]).unwrap(); + assert_eq!(file_name(&tools), ".out.pdf.00000000000000ab.pdfcraft-tmp"); + let (desktop, _) = create_staging(&dir, "out.pdf", StagingName::TagThenSuffix, [0xab]).unwrap(); + assert_eq!(file_name(&desktop), ".out.pdf.pdfcraft-00000000000000ab.tmp"); + assert_eq!(std::fs::read(&tools).unwrap(), b"", "a new, empty file"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn a_long_name_is_cut_to_128_bytes_between_characters() { + let dir = test_dir("long"); + // One byte, then four-byte characters: the 128-byte limit falls inside the 32nd one. + let name = format!("a{}.pdf", "\u{1F600}".repeat(40)); + let (tmp, _) = create_staging(&dir, &name, StagingName::SuffixThenTag, [1]).unwrap(); + assert_eq!(file_name(&tmp), format!(".a{}.0000000000000001.pdfcraft-tmp", "\u{1F600}".repeat(31))); + // One-byte characters: exactly 128 of them are kept. + let (tmp, _) = create_staging(&dir, &"x".repeat(200), StagingName::SuffixThenTag, [2]).unwrap(); + assert_eq!(file_name(&tmp), format!(".{}.0000000000000002.pdfcraft-tmp", "x".repeat(128))); + let _ = std::fs::remove_dir_all(&dir); +} + +/// #2658: a read-only file is refused before anything is staged, on Unix (where a rename would +/// replace it) as on Windows (where the rename fails). +#[test] +fn a_read_only_target_is_refused_and_kept() { + let dir = test_dir("readonly"); + let target = dir.join("out.pdf"); + std::fs::write(&target, "OLD").unwrap(); + let mut perms = std::fs::metadata(&target).unwrap().permissions(); + perms.set_readonly(true); + std::fs::set_permissions(&target, perms.clone()).unwrap(); + let staged = std::cell::Cell::new(false); + let e = write_atomically_with(&target, b"NEW", StagingName::TagThenSuffix, [7], |_: &std::fs::File| { + staged.set(true); + Ok(()) + }) + .unwrap_err(); + assert_eq!(e.kind(), std::io::ErrorKind::PermissionDenied, "{e}"); + assert!(!staged.get(), "nothing was staged"); + assert_eq!(std::fs::read(&target).unwrap(), b"OLD"); + assert!(std::fs::metadata(&target).unwrap().permissions().readonly(), "still read-only"); + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1, "no staging file is left"); + #[allow(clippy::permissions_set_readonly_false)] + perms.set_readonly(false); + std::fs::set_permissions(&target, perms).unwrap(); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn running_out_of_suffixes_refuses_and_creates_nothing() { + let dir = test_dir("empty"); + let e = create_staging(&dir, "out.pdf", StagingName::TagThenSuffix, []).unwrap_err(); + assert_eq!(e.kind(), std::io::ErrorKind::AlreadyExists); + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn only_the_first_sixteen_names_are_tried() { + let dir = test_dir("cap"); + let name = |s: u64| dir.join(format!(".out.pdf.{s:016x}.pdfcraft-tmp")); + for s in 1..=STAGING_ATTEMPTS as u64 { + std::fs::write(name(s), "PLANTED").unwrap(); + } + assert!(create_staging(&dir, "out.pdf", StagingName::SuffixThenTag, 1..).is_err()); + assert!(!name(STAGING_ATTEMPTS as u64 + 1).exists(), "a 17th name was tried"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// `write_atomically`: the target holds the new bytes only once they are complete; a refused +/// flush leaves the old file and removes the staging file; a missing folder writes nothing. +#[test] +fn an_atomic_write_replaces_the_target_only_when_complete() { + let dir = test_dir("write"); + let target = dir.join("out.pdf"); + std::fs::write(&target, b"OLD").unwrap(); + let names = |dir: &Path| { + let mut v: Vec = std::fs::read_dir(dir).unwrap().map(|e| file_name(&e.unwrap().path())).collect(); + v.sort(); + v + }; + // The staging file is written and flushed before the target changes. + let mut seen_old = false; + write_atomically_with(&target, b"NEW", StagingName::SuffixThenTag, [7], |f| { + seen_old = std::fs::read(&target).unwrap() == b"OLD" && f.metadata().unwrap().len() == 3; + Ok(()) + }) + .unwrap(); + assert!(seen_old, "flushed before publication, with the target still the old file"); + assert_eq!(std::fs::read(&target).unwrap(), b"NEW"); + assert_eq!(names(&dir), ["out.pdf"], "no staging file is left"); + // A refused flush keeps the target and cleans up. + let e = write_atomically_with(&target, b"NEWER", StagingName::TagThenSuffix, [8], |_| Err(std::io::Error::other("refused"))).unwrap_err(); + assert!(e.to_string().contains("refused")); + assert_eq!(std::fs::read(&target).unwrap(), b"NEW"); + assert_eq!(names(&dir), ["out.pdf"]); + // A folder that does not exist: nothing is written anywhere. + assert!(write_atomically(&dir.join("missing").join("out.pdf"), b"X", StagingName::SuffixThenTag).is_err()); + assert_eq!(names(&dir), ["out.pdf"]); + let _ = std::fs::remove_dir_all(&dir); +} + +#[cfg(target_os = "macos")] +#[test] +fn atomic_saves_keep_finder_tags_and_binary_extended_attributes() { + use rustix::fs::{XattrFlags, fgetxattr, setxattr}; + use std::os::macos::fs::FileTimesExt as _; + let dir = test_dir("xattrs"); + let target = dir.join("out.pdf"); + let attributes: &[(&str, &[u8])] = &[ + ("com.apple.metadata:_kMDItemUserTags", b"PdfCraft\n6"), + ("com.pdfcraft.test.binary", b"\0\xff\x80original\0"), + ("com.pdfcraft.test.empty", b""), + ]; + std::fs::write(&target, b"OLD").unwrap(); + for (name, value) in attributes { + setxattr(&target, *name, value, XattrFlags::empty()).unwrap(); + } + let created = std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_577_934_245); + let original = std::fs::OpenOptions::new().write(true).open(&target).unwrap(); + original.set_times(std::fs::FileTimes::new().set_created(created)).unwrap(); + assert_eq!(original.metadata().unwrap().created().unwrap(), created); + drop(original); + // Both desktop and automation name forms, including a second save of the replacement. + for form in [StagingName::TagThenSuffix, StagingName::SuffixThenTag] { + write_atomically_with(&target, b"NEW", form, [7], |staged| { + assert_eq!(staged.metadata().unwrap().created().unwrap(), created, "creation date must survive attribute copying before flush"); + for (name, value) in attributes { + let mut bytes = [0u8; 256]; + let len = fgetxattr(staged, *name, &mut bytes[..]).unwrap(); + assert_eq!(&bytes[..len], *value, "metadata must be present before flush: {name}"); + } + Ok(()) + }) + .unwrap(); + let saved = std::fs::File::open(&target).unwrap(); + assert_eq!(saved.metadata().unwrap().created().unwrap(), created); + for (name, value) in attributes { + let mut bytes = [0u8; 256]; + let len = fgetxattr(&saved, *name, &mut bytes[..]).unwrap(); + assert_eq!(&bytes[..len], *value); + } + assert_eq!(std::fs::read(&target).unwrap(), b"NEW"); + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1); + } + std::fs::remove_dir_all(dir).unwrap(); +} + +#[cfg(target_os = "macos")] +#[test] +fn a_failed_flush_keeps_the_original_extended_attributes() { + use rustix::fs::{XattrFlags, getxattr, setxattr}; + let dir = test_dir("xattrs-flush"); + let target = dir.join("out.pdf"); + std::fs::write(&target, b"OLD").unwrap(); + setxattr(&target, "com.pdfcraft.test", b"original", XattrFlags::empty()).unwrap(); + let e = write_atomically_with(&target, b"NEW", StagingName::TagThenSuffix, [9], |_| Err(std::io::Error::other("flush refused"))).unwrap_err(); + assert!(e.to_string().contains("flush refused")); + assert_eq!(std::fs::read(&target).unwrap(), b"OLD"); + let mut value = [0u8; 64]; + let len = getxattr(&target, "com.pdfcraft.test", &mut value[..]).unwrap(); + assert_eq!(&value[..len], b"original"); + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1); + std::fs::remove_dir_all(dir).unwrap(); +} + +/// Model an HFS-style partial read while using real macOS metadata and the atomic writer. +/// The shared read validator must reject growth before the staging file can be published. +#[cfg(target_os = "macos")] +#[test] +fn a_growing_resource_fork_refuses_publication_and_keeps_original() { + use rustix::fs::{XattrFlags, fgetxattr, fsetxattr, getxattr, setxattr}; + let dir = test_dir("xattrs-growing-fork"); + let target = dir.join("out.pdf"); + let name = "com.apple.ResourceFork"; + for form in [StagingName::TagThenSuffix, StagingName::SuffixThenTag] { + std::fs::write(&target, b"ORIGINAL PDF").unwrap(); + setxattr(&target, name, b"ABC", XattrFlags::empty()).unwrap(); + let source = std::fs::File::open(&target).unwrap(); + let result = write_atomically_with(&target, b"REPLACEMENT PDF", form, [23], |staged| { + let size = fgetxattr(&source, name, &mut [] as &mut [u8])?; + let value = read_xattr_value(size, |buffer| { + if buffer.is_empty() { + return Ok(fgetxattr(&source, name, buffer)?); + } + // HFS can return the prefix that fits instead of ERANGE. APFS need not + // have that behaviour: model the kernel response, not a timing race. + setxattr(&target, name, b"ABCD", XattrFlags::empty())?; + buffer.get_mut(..3).unwrap().copy_from_slice(b"ABC"); + Ok(3) + })?; + fsetxattr(staged, name, &value, XattrFlags::empty())?; + Ok(()) + }); + let error = result.expect_err("a short prefix of the grown fork must not be published"); + assert!(error.to_string().contains("changed size"), "{error}"); + assert_eq!(std::fs::read(&target).unwrap(), b"ORIGINAL PDF"); + let mut bytes = [0u8; 8]; + let len = getxattr(&target, name, &mut bytes[..]).unwrap(); + assert_eq!(&bytes[..len], b"ABCD", "the original's grown fork must remain intact"); + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1, "staging files are removed on refusal"); + } + std::fs::remove_dir_all(dir).unwrap(); +} + +/// Stable empty/binary values are accepted; short reads, growth and failed rechecks are refused. +#[cfg(target_os = "macos")] +#[test] +fn attribute_reads_require_a_complete_value_and_stable_size() { + for bytes in [&b""[..], &b"\0\xffB"[..]] { + let value = read_xattr_value(bytes.len(), |buffer| { + if !buffer.is_empty() { + buffer.get_mut(..bytes.len()).unwrap().copy_from_slice(bytes); + } + Ok(bytes.len()) + }) + .unwrap(); + assert_eq!(value, bytes); + } + for (before, read_len, after) in [(3, 2, 3), (3, 3, 4), (0, 1, 1), (3, 2, 2)] { + let result = read_xattr_value(before, |buffer| Ok(if buffer.is_empty() { after } else { read_len })); + assert!(result.unwrap_err().to_string().contains("changed size")); + } + let result = read_xattr_value(3, |buffer| { + if buffer.is_empty() { Err(std::io::Error::new(std::io::ErrorKind::PermissionDenied, "size recheck refused")) } else { Ok(3) } + }); + assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::PermissionDenied); +} + +#[cfg(target_os = "macos")] +#[test] +fn oversized_extended_attributes_refuse_the_save_without_replacing_the_target() { + use rustix::fs::{XattrFlags, getxattr, setxattr}; + let dir = test_dir("xattrs-limit"); + let target = dir.join("out.pdf"); + std::fs::write(&target, b"OLD").unwrap(); + // A resource fork can exceed the ordinary attribute limit on macOS. No fixture file + // is committed; only the OS metadata of this temporary synthetic file holds the bytes. + let fork = vec![42u8; 16 * 1024 * 1024 + 1]; + setxattr(&target, "com.apple.ResourceFork", &fork, XattrFlags::empty()).unwrap(); + let mut flushed = false; + let e = write_atomically_with(&target, b"NEW", StagingName::TagThenSuffix, [10], |_| { + flushed = true; + Ok(()) + }) + .unwrap_err(); + assert!(e.to_string().contains("extended attributes"), "{e}"); + assert!(e.to_string().contains("Save As"), "{e}"); + assert!(!flushed, "metadata failure must stop before flush and rename"); + assert_eq!(std::fs::read(&target).unwrap(), b"OLD"); + let size = getxattr(&target, "com.apple.ResourceFork", &mut [] as &mut [u8]).unwrap(); + assert_eq!(size, fork.len()); + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1); + std::fs::remove_dir_all(dir).unwrap(); +} + +#[cfg(target_os = "macos")] +#[test] +fn replacing_a_symlink_does_not_copy_attributes_from_its_referent() { + use rustix::fs::{XattrFlags, getxattr, setxattr}; + let dir = test_dir("xattrs-symlink"); + let outside = test_dir("xattrs-outside").join("private.pdf"); + std::fs::write(&outside, b"PRIVATE").unwrap(); + setxattr(&outside, "com.pdfcraft.test", b"private metadata", XattrFlags::empty()).unwrap(); + let target = dir.join("out.pdf"); + std::os::unix::fs::symlink(&outside, &target).unwrap(); + write_atomically(&target, b"NEW", StagingName::TagThenSuffix).unwrap(); + assert_eq!(std::fs::read(&outside).unwrap(), b"PRIVATE"); + assert_eq!(std::fs::read(&target).unwrap(), b"NEW"); + assert!(!target.symlink_metadata().unwrap().file_type().is_symlink()); + let mut bytes = [0u8; 64]; + assert_eq!(getxattr(&target, "com.pdfcraft.test", &mut bytes[..]).unwrap_err(), rustix::io::Errno::NOATTR); + let len = getxattr(&outside, "com.pdfcraft.test", &mut bytes[..]).unwrap(); + assert_eq!(&bytes[..len], b"private metadata"); + std::fs::remove_dir_all(dir).unwrap(); + std::fs::remove_dir_all(outside.parent().unwrap()).unwrap(); +} + +/// #1790: a replaced file keeps its permissions, and its staging file already has them while +/// it is written; a new file gets the same mode as any other new file. +#[cfg(unix)] +#[test] +fn a_replaced_file_keeps_its_permissions() { + use std::os::unix::fs::PermissionsExt as _; + let dir = test_dir("modes"); + let mode = |p: &Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777; + let target = dir.join("out.pdf"); + for keep in [0o600, 0o640, 0o664] { + std::fs::write(&target, b"OLD").unwrap(); + std::fs::set_permissions(&target, std::fs::Permissions::from_mode(keep)).unwrap(); + let mut staged = 0; + write_atomically_with(&target, b"NEW", StagingName::SuffixThenTag, [keep as u64], |f| { + staged = f.metadata()?.permissions().mode() & 0o777; + Ok(()) + }) + .unwrap(); + assert_eq!(staged, keep, "the staging file has the target's mode before it is flushed"); + assert_eq!(mode(&target), keep, "the saved file keeps mode {keep:o}"); + assert_eq!(std::fs::read(&target).unwrap(), b"NEW"); + } + let fresh = dir.join("new.pdf"); + write_atomically(&fresh, b"NEW", StagingName::TagThenSuffix).unwrap(); + let plain = dir.join("plain"); + std::fs::write(&plain, b"").unwrap(); + assert_eq!(mode(&fresh), mode(&plain), "a new file is not made private"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A replaced file keeps its group as well as its mode, so group permissions never reach a +/// group that couldn't read the file before (the saver's own group, by default). +#[cfg(target_os = "linux")] +#[test] +fn a_replaced_file_keeps_its_group() { + use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _}; + let dir = test_dir("group"); + let target = dir.join("out.pdf"); + std::fs::write(&target, b"OLD").unwrap(); + let created = std::fs::metadata(&target).unwrap().gid(); + let status = std::fs::read_to_string("/proc/self/status").unwrap(); + let groups = status.lines().find_map(|l| l.strip_prefix("Groups:")).unwrap_or_default(); + let Some(other) = groups.split_whitespace().filter_map(|g| g.parse::().ok()).find(|&g| g != created) else { + eprintln!("skipped: the test needs a second group"); + return; + }; + std::os::unix::fs::chown(&target, None, Some(other)).unwrap(); + std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o640)).unwrap(); + let mut staged = (0, 0); + write_atomically_with(&target, b"NEW", StagingName::SuffixThenTag, [7], |f| { + let m = f.metadata()?; + staged = (m.gid(), m.permissions().mode() & 0o777); + Ok(()) + }) + .unwrap(); + assert_eq!(staged, (other, 0o640), "the staging file has the target's group before it is flushed"); + let m = std::fs::metadata(&target).unwrap(); + assert_eq!((m.gid(), m.permissions().mode() & 0o777), (other, 0o640)); + let _ = std::fs::remove_dir_all(&dir); +} + +/// #2658: a save replaces the file, but the document is the same one: it keeps its creation +/// date where the system lets a program set it. +#[cfg(any(windows, target_os = "macos"))] +#[test] +fn a_replaced_file_keeps_its_creation_date() { + #[cfg(target_os = "macos")] + use std::os::macos::fs::FileTimesExt as _; + #[cfg(windows)] + use std::os::windows::fs::FileTimesExt as _; + let dir = test_dir("created"); + let target = dir.join("doc.pdf"); + std::fs::write(&target, b"OLD").unwrap(); + // 2020-01-02 03:04:05 UTC. + let then = std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_577_934_245); + let file = std::fs::OpenOptions::new().write(true).open(&target).unwrap(); + file.set_times(std::fs::FileTimes::new().set_created(then)).unwrap(); + drop(file); + assert_eq!(std::fs::metadata(&target).unwrap().created().unwrap(), then, "the fixture's date is set"); + write_atomically(&target, b"NEW", StagingName::SuffixThenTag).unwrap(); + assert_eq!(std::fs::read(&target).unwrap(), b"NEW"); + assert_eq!(std::fs::metadata(&target).unwrap().created().unwrap(), then, "the saved file keeps its creation date"); + // A new file gets a date of its own. + let fresh = dir.join("new.pdf"); + write_atomically(&fresh, b"NEW", StagingName::SuffixThenTag).unwrap(); + assert!(std::fs::metadata(&fresh).unwrap().created().unwrap() > then); + let _ = std::fs::remove_dir_all(&dir); +} + +/// #4593: a save replaces the file, so a hidden or system file stays hidden or system, and +/// its staging file already is while it is written; a plain file stays plain, though the +/// staging name starts with a dot; a new file gets no attribute from anywhere. +#[cfg(windows)] +#[test] +fn a_replaced_file_keeps_its_hidden_and_system_attributes() { + use std::os::windows::fs::{MetadataExt as _, OpenOptionsExt as _}; + const HIDDEN: u32 = 0x2; + const SYSTEM: u32 = 0x4; + let dir = test_dir("attributes"); + let kept = |p: &Path| std::fs::metadata(p).unwrap().file_attributes() & (HIDDEN | SYSTEM); + for (i, keep) in [0, HIDDEN, SYSTEM, HIDDEN | SYSTEM].into_iter().enumerate() { + let target = dir.join(format!("doc{i}.pdf")); + let mut fixture = std::fs::OpenOptions::new().write(true).create_new(true).attributes(keep).open(&target).unwrap(); + std::io::Write::write_all(&mut fixture, b"OLD").unwrap(); + drop(fixture); + assert_eq!(kept(&target), keep, "the fixture has attributes {keep:#x}"); + for form in [StagingName::TagThenSuffix, StagingName::SuffixThenTag] { + let mut staged = u32::MAX; + write_atomically_with(&target, b"NEW", form, [i as u64], |f| { + staged = f.metadata()?.file_attributes() & (HIDDEN | SYSTEM); + Ok(()) + }) + .unwrap(); + assert_eq!(staged, keep, "the staging file has the target's attributes before it is flushed"); + assert_eq!(kept(&target), keep, "the saved file keeps attributes {keep:#x}"); + assert_eq!(std::fs::read(&target).unwrap(), b"NEW"); + } + } + let fresh = dir.join("new.pdf"); + write_atomically(&fresh, b"NEW", StagingName::TagThenSuffix).unwrap(); + assert_eq!(kept(&fresh), 0, "a new file is neither hidden nor system"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A save replaces a symbolic link, not the file it points to, so the saved file takes the +/// link's own attributes: a plain link to a hidden file saves as a plain file, and the file +/// it pointed to is left as it was. +#[cfg(windows)] +#[test] +fn a_replaced_link_keeps_its_own_attributes_not_its_targets() { + use std::os::windows::fs::{MetadataExt as _, OpenOptionsExt as _}; + const HIDDEN: u32 = 0x2; + let dir = test_dir("link-attributes"); + let referent = dir.join("referent.pdf"); + let mut fixture = std::fs::OpenOptions::new().write(true).create_new(true).attributes(HIDDEN).open(&referent).unwrap(); + std::io::Write::write_all(&mut fixture, b"OLD").unwrap(); + drop(fixture); + let link = dir.join("link.pdf"); + if let Err(e) = std::os::windows::fs::symlink_file(&referent, &link) { + eprintln!("skipped: creating a symbolic link needs Developer Mode or administrator rights ({e})"); + let _ = std::fs::remove_dir_all(&dir); + return; + } + assert_eq!(std::fs::symlink_metadata(&link).unwrap().file_attributes() & HIDDEN, 0, "the link itself is visible"); + write_atomically(&link, b"NEW", StagingName::TagThenSuffix).unwrap(); + let saved = std::fs::symlink_metadata(&link).unwrap(); + assert!(!saved.file_type().is_symlink(), "the save replaced the link"); + assert_eq!(saved.file_attributes() & HIDDEN, 0, "the link was visible, so the saved file is"); + assert_eq!(std::fs::read(&link).unwrap(), b"NEW"); + assert_eq!(std::fs::read(&referent).unwrap(), b"OLD", "the file the link pointed to is untouched"); + assert_ne!(std::fs::metadata(&referent).unwrap().file_attributes() & HIDDEN, 0, "and still hidden"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/preflight/src/lib.rs b/crates/preflight/src/lib.rs index 13ca8997e3..1df39ebf05 100644 --- a/crates/preflight/src/lib.rs +++ b/crates/preflight/src/lib.rs @@ -514,97 +514,161 @@ pub fn verify(doc: &Document, level: Level) -> Vec { issue("6.5.2", "Page {} has additional actions", Some((i + 1).to_string()), Some(i), true); } } - for (r, o) in &objs { + check_objects(doc, level, &objs, &page_of, &mut issue); + // One message per kind of problem is enough for repeated ones (as preflight summaries do). + let mut seen = std::collections::HashSet::new(); + out.retain(|i| seen.insert((i.clause, i.message.clone(), i.page))); + out +} + +fn check_objects( + doc: &Document, + level: Level, + objects: &[(ObjRef, std::sync::Arc)], + page_of: &impl Fn(ObjRef) -> Option, + issue: &mut impl FnMut(&'static str, &'static str, Option, Option, bool), +) { + for (r, o) in objects { let Some(d) = dict_of(o) else { continue }; - let ty = d.name(b"Type"); - let sub = d.name(b"Subtype"); - // Actions anywhere (structure elements also have /S: their role), with the actions they - // chain to and those written directly in an /A or /OpenAction. - let own = (d.get(b"S").is_some() && is_action(d)).then_some(d); - let direct = [b"A".as_slice(), b"OpenAction"].map(|k| d.get(k).and_then(|a| a.as_dict())); - for a in [own].into_iter().chain(direct).flatten() { - if let Some(s) = chain_forbidden(doc, a) { - issue("6.5.1", "A {} action isn't allowed", Some(s), None, true); - } + check_one_object(doc, level, *r, d, o, page_of, issue); + } +} + +fn check_one_object( + doc: &Document, + level: Level, + reference: ObjRef, + dict: &Dict, + object: &Object, + page_of: &impl Fn(ObjRef) -> Option, + issue: &mut impl FnMut(&'static str, &'static str, Option, Option, bool), +) { + let ty = dict.name(b"Type"); + let subtype = dict.name(b"Subtype"); + check_actions(doc, dict, issue); + if is_annotation(dict, ty, subtype) && check_annotation(doc, dict, subtype, page_of(reference), issue) { + // A forbidden annotation stops every later check for its object, as the scan did before + // it was split into helpers: the rest of its own checks and the form field, font, stream + // and embedded file below all came after that `continue`. + return; + } + if dict.get(b"FT").is_some() && dict.get(b"AA").is_some() && ty != Some(b"Annot") { + issue("6.5.2", "A form field has additional actions", None, None, true); + } + if ty == Some(b"Font") || dict.get(b"BaseFont").is_some() && subtype.is_some() { + check_font(doc, dict, issue); + } + if let Object::Stream(stream) = object { + check_stream(doc, stream, subtype, issue); + } + if ty == Some(b"Filespec") && dict.get(b"EF").is_some() { + check_embedded_file(level, dict, issue); + } +} + +fn check_actions(doc: &Document, dict: &Dict, issue: &mut impl FnMut(&'static str, &'static str, Option, Option, bool)) { + let own = (dict.get(b"S").is_some() && is_action(dict)).then_some(dict); + let direct = [b"A".as_slice(), b"OpenAction"].map(|key| dict.get(key).and_then(|action| action.as_dict())); + for action in [own].into_iter().chain(direct).flatten() { + if let Some(name) = chain_forbidden(doc, action) { + issue("6.5.1", "A {} action isn't allowed", Some(name), None, true); } - // Annotations. - if ty == Some(b"Annot") || (sub.is_some() && d.get(b"Rect").is_some() && d.get(b"P").is_some()) { - let page = page_of(*r); - let name = sub.map(|s| String::from_utf8_lossy(s).into_owned()).unwrap_or_default(); - if sub.is_some_and(|s| FORBIDDEN_ANNOTS.contains(&s)) { - issue("6.3.1", "{} annotations aren't allowed", Some(name), page, true); - continue; - } - let f = d.int(b"F").unwrap_or(0); - if sub != Some(b"Popup") && (f & 4 == 0 || f & (1 | 2 | 32) != 0) { - issue("6.3.2", "A {} annotation isn't set to print, or is hidden", Some(name.clone()), page, true); - } - let has_ap = d.get(b"AP").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().cloned()).is_some_and(|a| a.get(b"N").is_some()); - if !has_ap && !matches!(sub, Some(b"Popup") | Some(b"Link")) { - let zero = d.get(b"Rect").map(|x| doc.resolve(x)).and_then(|x| x.as_array().cloned()).is_some_and(|a| { - let v: Vec = a.iter().filter_map(|n| doc.resolve(n).as_f64()).collect(); - v.len() == 4 && (v[2] - v[0]).abs() < 1e-6 && (v[3] - v[1]).abs() < 1e-6 - }); - if !zero { - issue("6.3.3", "A {} annotation has no appearance stream", Some(name.clone()), page, false); - } - } - if d.get(b"AA").is_some() { - issue("6.5.2", "A {} annotation has additional actions", Some(name), page, true); - } + } +} + +fn is_annotation(dict: &Dict, ty: Option<&[u8]>, subtype: Option<&[u8]>) -> bool { + ty == Some(b"Annot") || (subtype.is_some() && dict.get(b"Rect").is_some() && dict.get(b"P").is_some()) +} + +fn check_annotation( + doc: &Document, + dict: &Dict, + subtype: Option<&[u8]>, + page: Option, + issue: &mut impl FnMut(&'static str, &'static str, Option, Option, bool), +) -> bool { + let name = subtype.map(|bytes| String::from_utf8_lossy(bytes).into_owned()).unwrap_or_default(); + if subtype.is_some_and(|value| FORBIDDEN_ANNOTS.contains(&value)) { + issue("6.3.1", "{} annotations aren't allowed", Some(name), page, true); + return true; + } + let flags = dict.int(b"F").unwrap_or(0); + if subtype != Some(b"Popup") && (flags & 4 == 0 || flags & (1 | 2 | 32) != 0) { + issue("6.3.2", "A {} annotation isn't set to print, or is hidden", Some(name.clone()), page, true); + } + check_annotation_appearance(doc, dict, subtype, &name, page, issue); + if dict.get(b"AA").is_some() { + issue("6.5.2", "A {} annotation has additional actions", Some(name), page, true); + } + false +} + +fn check_annotation_appearance( + doc: &Document, + dict: &Dict, + subtype: Option<&[u8]>, + name: &str, + page: Option, + issue: &mut impl FnMut(&'static str, &'static str, Option, Option, bool), +) { + let has_appearance = dict + .get(b"AP") + .map(|appearance| doc.resolve(appearance)) + .and_then(|appearance| appearance.as_dict().cloned()) + .is_some_and(|appearance| appearance.get(b"N").is_some()); + if has_appearance || matches!(subtype, Some(b"Popup") | Some(b"Link")) { + return; + } + let zero_area = dict.get(b"Rect").map(|rect| doc.resolve(rect)).and_then(|rect| rect.as_array().cloned()).is_some_and(|values| { + let values: Vec = values.iter().filter_map(|value| doc.resolve(value).as_f64()).collect(); + values.len() == 4 && (values[2] - values[0]).abs() < 1e-6 && (values[3] - values[1]).abs() < 1e-6 + }); + if !zero_area { + issue("6.3.3", "A {} annotation has no appearance stream", Some(name.to_owned()), page, false); + } +} + +fn check_stream( + doc: &Document, + stream: &pdfcraft_cos::Stream, + subtype: Option<&[u8]>, + issue: &mut impl FnMut(&'static str, &'static str, Option, Option, bool), +) { + let filters: Vec> = match stream.dict.get(b"Filter").map(|filter| doc.resolve(filter)) { + Some(filter) => match &*filter { + Object::Name(name) => vec![name.clone()], + Object::Array(filters) => filters.iter().filter_map(|filter| filter.as_name().map(<[u8]>::to_vec)).collect(), + _ => Vec::new(), + }, + None => Vec::new(), + }; + if filters.iter().any(|filter| filter == b"LZWDecode") { + issue("6.1.7.2", "A stream uses LZW compression", None, None, false); + } + if stream.dict.get(b"F").is_some() || stream.dict.get(b"FFilter").is_some() { + issue("6.1.7.1", "A stream refers to an external file", None, None, false); + } + if subtype == Some(b"Image") { + if matches!(stream.dict.get(b"Interpolate"), Some(Object::Bool(true))) { + issue("6.2.8", "An image asks to be interpolated", None, None, true); } - // Form fields with additional actions (non-widget fields). - if d.get(b"FT").is_some() && d.get(b"AA").is_some() && ty != Some(b"Annot") { - issue("6.5.2", "A form field has additional actions", None, None, true); - } - // Fonts. - if ty == Some(b"Font") || d.get(b"BaseFont").is_some() && sub.is_some() { - check_font(doc, d, &mut issue); - } - // Streams. - if let Object::Stream(s) = &**o { - let filters: Vec> = match s.dict.get(b"Filter").map(|f| doc.resolve(f)) { - Some(f) => match &*f { - Object::Name(n) => vec![n.clone()], - Object::Array(a) => a.iter().filter_map(|x| x.as_name().map(<[u8]>::to_vec)).collect(), - _ => Vec::new(), - }, - None => Vec::new(), - }; - if filters.iter().any(|f| f == b"LZWDecode") { - issue("6.1.7.2", "A stream uses LZW compression", None, None, false); - } - if s.dict.get(b"F").is_some() || s.dict.get(b"FFilter").is_some() { - issue("6.1.7.1", "A stream refers to an external file", None, None, false); - } - if sub == Some(b"Image") { - if matches!(s.dict.get(b"Interpolate"), Some(Object::Bool(true))) { - issue("6.2.8", "An image asks to be interpolated", None, None, true); - } - if s.dict.get(b"Alternates").is_some() || s.dict.get(b"OPI").is_some() { - issue("6.2.8", "An image has alternates or OPI information", None, None, true); - } - } - if sub == Some(b"PS") { - issue("6.2.9", "PostScript XObjects aren't allowed", None, None, false); - } + if stream.dict.get(b"Alternates").is_some() || stream.dict.get(b"OPI").is_some() { + issue("6.2.8", "An image has alternates or OPI information", None, None, true); } - // Embedded files. - if ty == Some(b"Filespec") && d.get(b"EF").is_some() { - match level { - Level::A2b => issue("6.8", "Embedded files must be PDF/A files themselves (use PDF/A-3 for others)", None, None, false), - Level::A3b => { - if d.get(b"AFRelationship").is_none() { - issue("6.8", "An embedded file has no AFRelationship", None, None, true); - } - } - } + } + if subtype == Some(b"PS") { + issue("6.2.9", "PostScript XObjects aren't allowed", None, None, false); + } +} + +fn check_embedded_file(level: Level, dict: &Dict, issue: &mut impl FnMut(&'static str, &'static str, Option, Option, bool)) { + match level { + Level::A2b => issue("6.8", "Embedded files must be PDF/A files themselves (use PDF/A-3 for others)", None, None, false), + Level::A3b if dict.get(b"AFRelationship").is_none() => { + issue("6.8", "An embedded file has no AFRelationship", None, None, true); } + Level::A3b => {} } - // One message per kind of problem is enough for repeated ones (as preflight summaries do). - let mut seen = std::collections::HashSet::new(); - out.retain(|i| seen.insert((i.clause, i.message.clone(), i.page))); - out } fn check_font(doc: &Document, d: &Dict, issue: &mut impl FnMut(&'static str, &'static str, Option, Option, bool)) { diff --git a/crates/print/src/error.rs b/crates/print/src/error.rs new file mode 100644 index 0000000000..4b1fe95f21 --- /dev/null +++ b/crates/print/src/error.rs @@ -0,0 +1,84 @@ +/// What can go wrong when printing. +/// +/// The three things a *person* can be wrong about — there is nothing to print, a page number past +/// the end, a range that is not a page — each have their own variant with the numbers apart from +/// the words, so an interface can say them in its own language with [`PrintError::said`]. The rest +/// are faults rather than mistakes and carry their text as it is. +#[derive(Debug, thiserror::Error, PartialEq)] +pub enum PrintError { + #[error("there are no pages to print")] + NoPages, + /// A page number past the end of the document: the number asked for, and the page count. + #[error("page {0} is out of range (1–{1})")] + OutOfRange(usize, usize), + /// A range token that is neither a page number nor a page label. + #[error("{0:?} is not a page number or label")] + NotAPage(String), + #[error("{0}")] + Invalid(String), + #[error("{0}")] + Spool(String), + #[error(transparent)] + Cos(#[from] pdfcraft_cos::CosError), +} + +/// How an interface says one of our wordings in its own language, holes and all: given the English +/// in [`WORDINGS`], the same wording in that language. [`in_english`] keeps the English. +pub type Words<'a> = &'a dyn Fn(&str) -> String; + +/// The [`Words`] that translate nothing: the wording as it is written here. +pub fn in_english(wording: &str) -> String { + wording.to_owned() +} + +/// What a person can get wrong about what to print, in English, with their holes still in them. +/// +/// An interface that reports these in another language translates these three, which is the whole +/// reason they are gathered here; `wordings_are_said` keeps them in step with the errors. +pub const WORDINGS: &[&str] = &["there are no pages to print", "page {n} is out of range (1–{count})", "{page} is not a page number or label"]; + +impl PrintError { + /// This error as one sentence, said in the language `words` translates into. + /// + /// The wording is translated and the numbers go in afterwards, in a single pass, so what the + /// person typed cannot be read as a hole. With [`in_english`] this is the error's own + /// `Display`, which is what the command line and scripts report. + pub fn said(&self, words: Words) -> String { + match self { + PrintError::NoPages => words("there are no pages to print"), + PrintError::OutOfRange(n, count) => { + say(&words("page {n} is out of range (1–{count})"), &[("{n}", n.to_string()), ("{count}", count.to_string())]) + } + // The token goes in quoted, the way it is typed, as the error itself prints it. + PrintError::NotAPage(token) => say(&words("{page} is not a page number or label"), &[("{page}", format!("{token:?}"))]), + fault => fault.to_string(), + } + } +} + +/// A wording with its holes filled. +/// +/// A hole is a name in braces, such as `{n}`; what is not in `values` is left as it is written. +/// Values go in during a single pass, so a value with braces in it is never read as a hole. +fn say(wording: &str, values: &[(&str, String)]) -> String { + let mut out = String::with_capacity(wording.len() + 8); + let mut rest = wording; + while let Some(open) = rest.find('{') { + let (before, from_brace) = rest.split_at(open); + out.push_str(before); + // `find` counts from the brace and `}` is one byte, so this is a char boundary. + let Some(close) = from_brace.find('}') else { + // A brace with nothing closing it is just text. + out.push_str(from_brace); + return out; + }; + let Some(hole) = from_brace.get(..=close) else { return out + from_brace }; + match values.iter().find(|(name, _)| *name == hole) { + Some((_, value)) => out.push_str(value), + None => out.push_str(hole), + } + rest = from_brace.get(close + 1..).unwrap_or_default(); + } + out.push_str(rest); + out +} diff --git a/crates/print/src/imposition.rs b/crates/print/src/imposition.rs new file mode 100644 index 0000000000..aa9a501f66 --- /dev/null +++ b/crates/print/src/imposition.rs @@ -0,0 +1,348 @@ +use super::error::PrintError; +use super::settings::{Content, Settings}; +use super::sheets::{Sheet, layout}; +use super::{DecodeBudget, Dict, Document, Matrix, ObjRef, Object, SaveOptions, Stream, write_full}; + +// Model-derived transforms and clipping bounds need their full PDF decimal precision. +fn geometry_number(value: f64) -> String { + let mut bytes = Vec::new(); + pdfcraft_cos::serialize(&Object::Real(value), &mut bytes); + String::from_utf8_lossy(&bytes).into_owned() +} + +fn n(v: f64) -> String { + let s = format!("{v:.4}"); + let s = s.trim_end_matches('0').trim_end_matches('.'); + if s == "-0" { "0".into() } else { s.into() } +} + +/// The page's content streams decoded against `budget` and joined; streams that don't decode +/// are left out. `None` when the budget ran out, so part of the content is missing. +pub(crate) fn decoded_contents(doc: &Document, page: &Dict, budget: &mut DecodeBudget) -> Option> { + let Some(contents) = page.get(b"Contents") else { return Some(Vec::new()) }; + let mut out = Vec::new(); + for data in budget.decode_contents(doc, contents) { + match data { + Ok(data) => { + out.extend_from_slice(&data); + out.push(b'\n'); + } + Err(_) if budget.remaining() == 0 => return None, + Err(_) => {} + } + } + Some(out) +} + +/// Reduce `doc`'s pages to what [`impose`] draws of them under `content`: the annotations that +/// don't print are dropped, and with [`Content::FormFieldsOnly`] the page content too. For +/// printing pages as pictures, where a renderer draws each page as it stands. +pub fn keep_printed(doc: &mut Document, content: Content) { + for page in pdfcraft_model::pages(doc) { + let annots = page.dict.get(b"Annots").and_then(|a| doc.resolve(a).as_array().cloned()).unwrap_or_default(); + let kept: Vec = annots.into_iter().filter(|a| doc.resolve(a).as_dict().is_some_and(|d| prints(doc, d, content))).collect(); + // Fails only when the page object isn't a dictionary, and such a page draws nothing. + let _ = doc.update_dict(page.obj, |d| { + if content == Content::FormFieldsOnly { + d.remove(b"Contents"); + } + if d.contains(b"Annots") { + d.set(b"Annots".to_vec(), Object::Array(kept)); + } + }); + } +} + +/// The standard annotation types of ISO 32000-2 Table 171. +const STANDARD_ANNOTATION_TYPES: [&[u8]; 28] = [ + b"Text", + b"Link", + b"FreeText", + b"Line", + b"Square", + b"Circle", + b"Polygon", + b"PolyLine", + b"Highlight", + b"Underline", + b"Squiggly", + b"StrikeOut", + b"Caret", + b"Stamp", + b"Ink", + b"Popup", + b"FileAttachment", + b"Sound", + b"Movie", + b"Screen", + b"Widget", + b"PrinterMark", + b"TrapNet", + b"Watermark", + b"3D", + b"Redact", + b"Projection", + b"RichMedia", +]; + +/// Does annotation `d` print under `content`? +pub(crate) fn prints(doc: &Document, d: &Dict, content: Content) -> bool { + const INVISIBLE: i64 = 1; + const HIDDEN: i64 = 2; + const PRINT: i64 = 4; + let flags = d.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0); + if flags & PRINT == 0 || flags & HIDDEN != 0 { + return false; + } + // `/Subtype` may be an indirect reference (ISO 32000-2 §7.3.10), so resolve it first. + let subtype_obj = d.get(b"Subtype").map(|s| doc.resolve(s)); + let subtype = subtype_obj.as_deref().and_then(Object::as_name).unwrap_or(b""); + match subtype { + b"Widget" => true, + b"Link" | b"Popup" => false, + b"Stamp" => content != Content::Document && content != Content::FormFieldsOnly, + // ISO 32000-2 Table 167: an Invisible annotation of a type that isn't standard (Table + // 171) is not printed even with its Print flag set. + _ if flags & INVISIBLE != 0 && !STANDARD_ANNOTATION_TYPES.contains(&subtype) => false, + _ => content == Content::DocumentAndMarkups, + } +} + +/// The normal appearance stream to draw for an annotation (honouring `/AS`). +pub(crate) fn appearance(doc: &Document, d: &Dict) -> Option { + let ap = doc.resolve(d.get(b"AP")?); + let normal = ap.as_dict()?.get(b"N")?.clone(); + match &*doc.resolve(&normal) { + Object::Stream(_) => normal.as_ref(), + Object::Dict(states) => { + let state = d.name(b"AS")?; + states.get(state)?.as_ref() + } + _ => None, + } +} + +/// Algorithm 8.1: the matrix placing an appearance form (BBox × Matrix) onto the annotation rect. +pub(crate) fn appearance_matrix(doc: &Document, form: &Dict, rect: [f64; 4]) -> Option { + let nums = + |k: &[u8]| -> Option> { Some(doc.resolve(form.get(k)?).as_array()?.iter().filter_map(|x| doc.resolve(x).as_f64()).collect()) }; + let bbox = nums(b"BBox").filter(|b| b.len() == 4)?; + let m = nums(b"Matrix").filter(|m| m.len() == 6).map(|m| Matrix([m[0], m[1], m[2], m[3], m[4], m[5]])).unwrap_or_default(); + let b = m.bbox([bbox[0], bbox[1], bbox[2], bbox[3]]); + let (bw, bh) = (b[2] - b[0], b[3] - b[1]); + if bw <= 0.0 || bh <= 0.0 { + return None; + } + let a = Matrix([ + (rect[2] - rect[0]) / bw, + 0.0, + 0.0, + (rect[3] - rect[1]) / bh, + rect[0] - b[0] * (rect[2] - rect[0]) / bw, + rect[1] - b[1] * (rect[3] - rect[1]) / bh, + ]); + // The Do that draws the form applies its /Matrix itself, so only the placement goes here. + Some(a) +} + +/// A Form XObject holding page `index` as it prints (user space; BBox = crop box). +/// `pages` are the document's pages, listed once by the caller: listing them for each page +/// printed would make printing quadratic in the page count. +pub(crate) fn page_form( + doc: &mut Document, + pages: &[pdfcraft_model::Page], + index: usize, + content: Content, + promoted: &mut std::collections::HashMap, +) -> Result { + let page = pages.get(index).ok_or_else(|| PrintError::Invalid(format!("there is no page {}", index.saturating_add(1))))?; + let crop = page.crop(doc); + let mut res = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); + let mut body = if content == Content::FormFieldsOnly { + Vec::new() + } else { + decoded_contents(doc, &page.dict, &mut DecodeBudget::page()) + .ok_or_else(|| PrintError::Invalid(format!("page {} has more content than can be printed", index + 1)))? + }; + // Wrap the page content so a state it leaves behind can't affect the annotations. + if !body.is_empty() { + let mut wrapped = b"q\n".to_vec(); + wrapped.extend_from_slice(&body); + wrapped.extend_from_slice(b"\nQ\n"); + body = wrapped; + } + let annots = page.dict.get(b"Annots").and_then(|a| doc.resolve(a).as_array().cloned()).unwrap_or_default(); + let mut xobjects = res.get(b"XObject").and_then(|x| doc.resolve(x).as_dict().cloned()).unwrap_or_default(); + let mut k = 0usize; + for a in annots { + let Some(d) = doc.resolve(&a).as_dict().cloned() else { continue }; + if !prints(doc, &d, content) { + continue; + } + let rect: Vec = + d.get(b"Rect").and_then(|r| doc.resolve(r).as_array().map(|a| a.iter().filter_map(Object::as_f64).collect())).unwrap_or_default(); + if rect.len() != 4 { + continue; + } + let rect = [rect[0].min(rect[2]), rect[1].min(rect[3]), rect[0].max(rect[2]), rect[1].max(rect[3])]; + let Some(ap) = appearance(doc, &d) else { continue }; + let form = doc.get(ap).as_dict().cloned().unwrap_or_default(); + let Some(m) = appearance_matrix(doc, &form, rect) else { continue }; + let ap = if !form.contains(b"Subtype") + && let Object::Stream(stream) = &*doc.get(ap) + { + // Some existing annotation appearances omit Subtype, but Do dispatch needs a Form XObject. + // Keep the original intact: other document objects can share this appearance. + if let Some(&cached) = promoted.get(&ap) { + cached + } else { + let mut stream = stream.clone(); + stream.dict.set(b"Subtype".to_vec(), Object::name("Form")); + let copy = doc.add(Object::Stream(stream)); + promoted.insert(ap, copy); + copy + } + } else { + ap + }; + // Keep the page's own XObjects, including names previous print operations generated. + // Check the growing map so each appearance also keeps the name just assigned to it. + let name = loop { + let name = format!("PCPrA{k}"); + k = k.checked_add(1).ok_or_else(|| PrintError::Invalid("too many annotation appearance resources".into()))?; + if !xobjects.contains(name.as_bytes()) { + break name; + } + }; + let [a0, b0, c0, d0, e0, f0] = m.0; + body.extend(format!("q {} {} {} {} {} {} cm /{name} Do Q\n", n(a0), n(b0), n(c0), n(d0), n(e0), n(f0)).bytes()); + xobjects.set(name.into_bytes(), Object::Ref(ap)); + } + if !xobjects.is_empty() { + res.set(b"XObject".to_vec(), Object::Dict(xobjects)); + } + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("XObject")); + d.set(b"Subtype".to_vec(), Object::name("Form")); + d.set(b"BBox".to_vec(), Object::Array(crop.iter().map(|v| Object::Real(*v)).collect())); + d.set(b"Resources".to_vec(), Object::Dict(res)); + if let Some(g) = page.dict.get(b"Group") { + d.set(b"Group".to_vec(), g.clone()); + } + Ok(doc.add(Object::Stream(Stream::flate(d, &body)))) +} + +#[cfg(test)] +thread_local! { + /// Pages listed by [`source_pages`] on this thread. + pub(crate) static PAGES_LISTED: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// The source document's pages, for printing. +fn source_pages(doc: &Document) -> Vec { + let pages = pdfcraft_model::pages(doc); + #[cfg(test)] + PAGES_LISTED.with(|c| c.set(c.get() + pages.len())); + pages +} + +/// Build the print-ready PDF for `settings`. Encryption is not carried over (the file goes to a +/// printer or is the user's own copy); callers must check the print permission first. +pub fn impose(src: &Document, settings: &Settings) -> Result, PrintError> { + let pages = source_pages(src); + let sizes: Vec<(f64, f64)> = pages.iter().map(|p| p.display_size(src)).collect(); + let sheets = layout(&sizes, settings)?; + let mut doc = src.clone(); + // One form per source page used (a page may appear on several sheets: posters). + let mut forms: std::collections::HashMap = std::collections::HashMap::new(); + let mut promoted = std::collections::HashMap::new(); + let views: Vec<[f64; 6]> = pages.iter().map(|p| p.view_matrix(src)).collect(); + let pages_root = doc.add(Object::Null); + let mut kids = Vec::new(); + for sheet in &sheets { + let mut c = String::new(); + let mut xo = Dict::new(); + for (i, pl) in sheet.placed.iter().enumerate() { + let form = match forms.get(&pl.page) { + Some(f) => *f, + None => { + let f = page_form(&mut doc, &pages, pl.page, settings.content, &mut promoted)?; + forms.insert(pl.page, f); + f + } + }; + let name = format!("P{i}"); + xo.set(name.clone().into_bytes(), Object::Ref(form)); + // Display space → sheet, then clip to the visible part, then user → display. + let [a, b, cc, d, e, f] = pl.matrix.0; + let to_display = Matrix(views[pl.page]).invert().unwrap_or_default(); + let [ua, ub, uc, ud, ue, uf] = to_display.0; + let [x0, y0, x1, y1] = pl.clip; + c.push_str(&format!( + "q {} {} {} {} {} {} cm {} {} {} {} re W n {} {} {} {} {} {} cm /{name} Do Q\n", + geometry_number(a), + geometry_number(b), + geometry_number(cc), + geometry_number(d), + geometry_number(e), + geometry_number(f), + geometry_number(x0), + geometry_number(y0), + geometry_number(x1 - x0), + geometry_number(y1 - y0), + geometry_number(ua), + geometry_number(ub), + geometry_number(uc), + geometry_number(ud), + geometry_number(ue), + geometry_number(uf) + )); + } + if !sheet.borders.is_empty() || !sheet.lines.is_empty() { + c.push_str("q 0 G 0.5 w\n"); + for r in &sheet.borders { + c.push_str(&format!("{} {} {} {} re S\n", n(r[0]), n(r[1]), n(r[2] - r[0]), n(r[3] - r[1]))); + } + for l in &sheet.lines { + c.push_str(&format!("{} {} m {} {} l S\n", n(l[0]), n(l[1]), n(l[2]), n(l[3]))); + } + c.push_str("Q\n"); + } + let contents = doc.add(Object::Stream(Stream::flate(Dict::new(), c.as_bytes()))); + let mut res = Dict::new(); + res.set(b"XObject".to_vec(), Object::Dict(xo)); + let mut p = Dict::new(); + p.set(b"Type".to_vec(), Object::name("Page")); + p.set(b"Parent".to_vec(), Object::Ref(pages_root)); + p.set(b"MediaBox".to_vec(), Object::Array([0.0, 0.0, sheet.size.0, sheet.size.1].iter().map(|v| Object::Real(*v)).collect())); + p.set(b"Resources".to_vec(), Object::Dict(res)); + p.set(b"Contents".to_vec(), Object::Ref(contents)); + kids.push(Object::Ref(doc.add(Object::Dict(p)))); + } + let mut tree = Dict::new(); + tree.set(b"Type".to_vec(), Object::name("Pages")); + tree.set(b"Count".to_vec(), Object::Int(kids.len() as i64)); + tree.set(b"Kids".to_vec(), Object::Array(kids)); + doc.set(pages_root, Object::Dict(tree)); + let mut cat = Dict::new(); + cat.set(b"Type".to_vec(), Object::name("Catalog")); + cat.set(b"Pages".to_vec(), Object::Ref(pages_root)); + // Keep the layers' configuration, or a layer that is off in the document would print: the + // pages' marked content still refers to its groups, and without this nothing says they are off. + let layers = src.trailer().get(b"Root").and_then(|r| src.resolve(r).as_dict().and_then(|root| root.get(b"OCProperties").cloned())); + if let Some(layers) = layers { + cat.set(b"OCProperties".to_vec(), layers); + } + let root = doc.add(Object::Dict(cat)); + doc.trailer_mut().set(b"Root".to_vec(), Object::Ref(root)); + doc.trailer_mut().remove(b"Info"); + if doc.trailer().contains(b"Encrypt") { + doc.remove_encryption(); + } + Ok(write_full(&doc, &SaveOptions::default())?) +} + +/// Which source pages land on each sheet (for previews and the sheet count). +pub fn sheet_pages(sheets: &[Sheet]) -> Vec> { + sheets.iter().map(|s| s.placed.iter().map(|p| p.page).collect()).collect() +} diff --git a/crates/print/src/lib.rs b/crates/print/src/lib.rs index b731aff00f..067112b2e7 100644 --- a/crates/print/src/lib.rs +++ b/crates/print/src/lib.rs @@ -27,1026 +27,19 @@ mod tests; pub use range::{Subset, select_listed, select_pages}; -/// What can go wrong when printing. -/// -/// The three things a *person* can be wrong about — there is nothing to print, a page number past -/// the end, a range that is not a page — each have their own variant with the numbers apart from -/// the words, so an interface can say them in its own language with [`PrintError::said`]. The rest -/// are faults rather than mistakes and carry their text as it is. -#[derive(Debug, thiserror::Error, PartialEq)] -pub enum PrintError { - #[error("there are no pages to print")] - NoPages, - /// A page number past the end of the document: the number asked for, and the page count. - #[error("page {0} is out of range (1–{1})")] - OutOfRange(usize, usize), - /// A range token that is neither a page number nor a page label. - #[error("{0:?} is not a page number or label")] - NotAPage(String), - #[error("{0}")] - Invalid(String), - #[error("{0}")] - Spool(String), - #[error(transparent)] - Cos(#[from] pdfcraft_cos::CosError), -} - -/// How an interface says one of our wordings in its own language, holes and all: given the English -/// in [`WORDINGS`], the same wording in that language. [`in_english`] keeps the English. -pub type Words<'a> = &'a dyn Fn(&str) -> String; - -/// The [`Words`] that translate nothing: the wording as it is written here. -pub fn in_english(wording: &str) -> String { - wording.to_owned() -} - -/// What a person can get wrong about what to print, in English, with their holes still in them. -/// -/// An interface that reports these in another language translates these three, which is the whole -/// reason they are gathered here; `wordings_are_said` keeps them in step with the errors. -pub const WORDINGS: &[&str] = &["there are no pages to print", "page {n} is out of range (1–{count})", "{page} is not a page number or label"]; - -impl PrintError { - /// This error as one sentence, said in the language `words` translates into. - /// - /// The wording is translated and the numbers go in afterwards, in a single pass, so what the - /// person typed cannot be read as a hole. With [`in_english`] this is the error's own - /// `Display`, which is what the command line and scripts report. - pub fn said(&self, words: Words) -> String { - match self { - PrintError::NoPages => words("there are no pages to print"), - PrintError::OutOfRange(n, count) => { - say(&words("page {n} is out of range (1–{count})"), &[("{n}", n.to_string()), ("{count}", count.to_string())]) - } - // The token goes in quoted, the way it is typed, as the error itself prints it. - PrintError::NotAPage(token) => say(&words("{page} is not a page number or label"), &[("{page}", format!("{token:?}"))]), - fault => fault.to_string(), - } - } -} - -/// A wording with its holes filled. -/// -/// A hole is a name in braces, such as `{n}`; what is not in `values` is left as it is written. -/// Values go in during a single pass, so a value with braces in it is never read as a hole. -fn say(wording: &str, values: &[(&str, String)]) -> String { - let mut out = String::with_capacity(wording.len() + 8); - let mut rest = wording; - while let Some(open) = rest.find('{') { - let (before, from_brace) = rest.split_at(open); - out.push_str(before); - // `find` counts from the brace and `}` is one byte, so this is a char boundary. - let Some(close) = from_brace.find('}') else { - // A brace with nothing closing it is just text. - out.push_str(from_brace); - return out; - }; - let Some(hole) = from_brace.get(..=close) else { return out + from_brace }; - match values.iter().find(|(name, _)| *name == hole) { - Some((_, value)) => out.push_str(value), - None => out.push_str(hole), - } - rest = from_brace.get(close + 1..).unwrap_or_default(); - } - out.push_str(rest); - out -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] -pub enum Orientation { - #[default] - Auto, - Portrait, - Landscape, -} - -#[derive(Clone, Copy, Debug, PartialEq, Default)] -pub enum SizeMode { - #[default] - Fit, - Actual, - Shrink, - /// Percent. - Custom(f64), -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] -pub enum PageOrder { - #[default] - Horizontal, - HorizontalReversed, - Vertical, - VerticalReversed, - /// Single-sided sheets: cut into cell piles, then stack them in row-major order. - CutStack, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] -pub enum BookletSubset { - #[default] - BothSides, - FrontOnly, - BackOnly, -} - -/// How a booklet goes through the printer (#2707): it decides two-sided printing itself, as -/// Acrobat does (its Print dialog hides Two-sided while Booklet is chosen; Using Acrobat X Pro, -/// Print dialog box options: "Acrobat automatically enables duplex printing, if available, for -/// booklet printing"). Both sides flip on the short edge, the fold of a 2-up sheet turned -/// sideways; front side only or back side only is one pass of a manual duplex and prints -/// single-sided. -pub fn booklet_duplex(subset: BookletSubset) -> spool::Duplex { - match subset { - BookletSubset::BothSides => spool::Duplex::ShortEdge, - BookletSubset::FrontOnly | BookletSubset::BackOnly => spool::Duplex::Off, - } -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] -pub enum Binding { - #[default] - Left, - Right, -} - -#[derive(Clone, Copy, Debug, PartialEq)] -pub enum Layout { - Size(SizeMode), - Multiple { - cols: usize, - rows: usize, - /// Source pages on each sheet, at most `cols × rows`: three per sheet uses a 2 × 2 grid - /// with one cell left blank. - per_sheet: usize, - order: PageOrder, - border: bool, - auto_rotate: bool, - }, - Booklet { - subset: BookletSubset, - binding: Binding, - }, - /// Tile scale (percent), overlap (points), cut marks. - Poster { - scale: f64, - overlap: f64, - cut_marks: bool, - }, - /// Shipping label: each page cut to its [`Settings::crops`] box, turned so its text reads - /// upright on the sheet and fitted with [`LABEL_MARGIN`]; `flip` turns it a further 180°. - Label { - flip: bool, - }, -} - -impl Default for Layout { - fn default() -> Self { - Layout::Size(SizeMode::Fit) - } -} - -impl Layout { - /// Acrobat's pages-per-sheet choices as a grid (columns × rows on a portrait sheet). - pub fn multiple(per_sheet: usize) -> Layout { - let (cols, rows) = match per_sheet { - 2 => (1, 2), - 4 => (2, 2), - 6 => (2, 3), - 9 => (3, 3), - 16 => (4, 4), - n => { - let c = (n as f64).sqrt().ceil().max(1.0) as usize; - (c, n.div_ceil(c).max(1)) - } - }; - Layout::Multiple { cols, rows, per_sheet: per_sheet.max(1), order: PageOrder::Horizontal, border: false, auto_rotate: true } - } -} - -/// Acrobat's Comments & Forms choices. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] -pub enum Content { - /// Page content and form fields. - Document, - /// Page content, form fields and every printable comment. - #[default] - DocumentAndMarkups, - /// Page content, form fields and stamps. - DocumentAndStamps, - /// Form fields only. - FormFieldsOnly, -} - -#[derive(Clone, Debug, PartialEq)] -pub struct Settings { - /// Source pages (0-based) in print order; see [`select_pages`]. - pub pages: Vec, - /// Paper (width, height) in points, portrait. - pub paper: (f64, f64), - pub orientation: Orientation, - pub layout: Layout, - pub content: Content, - /// For [`Layout::Label`], per source page: the area to keep (display space) and whether its - /// text runs bottom-to-top (so a quarter turn clockwise makes it upright). `None` or a - /// missing entry keeps the whole page. - pub crops: Vec>, - /// Print as image (Acrobat's Advanced Print Setup): every page rendered to a picture at this - /// resolution before it is placed on the paper. The engine does the rendering; `impose` is - /// then given the image-only document. - pub raster: Option, -} - -/// How pages are rendered when printing as image. -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct Raster { - /// Dots per inch, 72–600 (Acrobat offers 150, 300 and 600). - pub dpi: f64, - /// Render the pictures in grey, so the print-ready PDF itself is grayscale. - pub grayscale: bool, -} - -impl Default for Raster { - fn default() -> Self { - Raster { dpi: 300.0, grayscale: false } - } -} - -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct Crop { - pub rect: [f64; 4], - pub text_up: bool, -} - -impl Default for Settings { - fn default() -> Self { - Settings { - pages: Vec::new(), - paper: PAPERS[0].1, - orientation: Orientation::Auto, - layout: Layout::default(), - content: Content::default(), - raster: None, - crops: Vec::new(), - } - } -} - -/// Common paper sizes (points, portrait). -pub const PAPERS: [(&str, (f64, f64)); 21] = [ - ("US Letter", (612.0, 792.0)), - ("US Legal", (612.0, 1008.0)), - ("Tabloid", (792.0, 1224.0)), - ("A3", (841.89, 1190.55)), - ("A4", (595.28, 841.89)), - ("A5", (419.53, 595.28)), - ("A6", (297.64, 419.53)), - ("ARCH A (9 x 12 in)", (648.0, 864.0)), - ("ARCH B (12 x 18 in)", (864.0, 1296.0)), - ("ARCH C (18 x 24 in)", (1296.0, 1728.0)), - ("ARCH D (24 x 36 in)", (1728.0, 2592.0)), - ("ARCH E1 (30 x 42 in)", (2160.0, 3024.0)), - ("ARCH E (36 x 48 in)", (2592.0, 3456.0)), - ("ANSI C (17 x 22 in)", (1224.0, 1584.0)), - ("ANSI D (22 x 34 in)", (1584.0, 2448.0)), - ("ANSI E (34 x 44 in)", (2448.0, 3168.0)), - ("A2", (1190.55, 1683.78)), - ("A1", (1683.78, 2383.94)), - ("A0", (2383.94, 3370.39)), - ("4 x 6 in label (101.6 x 152.4 mm)", (288.0, 432.0)), - ("US #10 Envelope (4.125 x 9.5 in)", (297.0, 684.0)), -]; - -/// `PAPERS` index of the 4 × 6 in shipping label. -pub const LABEL_PAPER: usize = 19; - -/// The CUPS media name for `PAPERS[index]`, so the spooler feeds the sheet the PDF was laid out -/// on rather than the queue's default; `None` only past the end of `PAPERS`. The common sizes -/// use their PPD keywords (`w288h432` is the usual 4×6 label, `Env10` the US #10 envelope). -/// The rest use PWG 5101.1 names (ARCH E1 is its `wide-format` size), spelled exactly as the -/// spec and the IANA registry have them: a driverless printer may take only the names it -/// advertises. They carry their dimensions, so CUPS feeds the PPD size of those dimensions -/// whatever the driver calls it (`ARCHD`, `AnsiD`) (#3752). -pub fn cups_media(index: usize) -> Option<&'static str> { - Some(match PAPERS.get(index)?.0 { - "US Letter" => "Letter", - "US Legal" => "Legal", - "Tabloid" => "Tabloid", - "A3" => "A3", - "A4" => "A4", - "A5" => "A5", - "A6" => "iso_a6_105x148mm", - "ARCH A (9 x 12 in)" => "na_arch-a_9x12in", - "ARCH B (12 x 18 in)" => "na_arch-b_12x18in", - "ARCH C (18 x 24 in)" => "na_arch-c_18x24in", - "ARCH D (24 x 36 in)" => "na_arch-d_24x36in", - "ARCH E1 (30 x 42 in)" => "na_wide-format_30x42in", - "ARCH E (36 x 48 in)" => "na_arch-e_36x48in", - "ANSI C (17 x 22 in)" => "na_c_17x22in", - "ANSI D (22 x 34 in)" => "na_d_22x34in", - "ANSI E (34 x 44 in)" => "na_e_34x44in", - "A2" => "iso_a2_420x594mm", - "A1" => "iso_a1_594x841mm", - "A0" => "iso_a0_841x1189mm", - "US #10 Envelope (4.125 x 9.5 in)" => "Env10", - _ if index == LABEL_PAPER => "w288h432", - // A size added to PAPERS without a name here: its tests fail, and meanwhile the queue's - // paper stands, as it did for every size before #1583. - _ => return None, - }) -} - -/// The job option that feeds `PAPERS[index]` (`media`, see [`cups_media`]), for the Print -/// dialog's jobs and `doc_print`'s alike. -pub fn media_option(index: usize) -> Option<(String, String)> { - cups_media(index).map(|m| ("media".to_string(), m.to_string())) -} - -/// Whether most of page `index`'s text runs bottom-to-top as the page displays (a label laid -/// sideways on a letter or A4 sheet), counting each text-showing operator once; form XObjects -/// are followed a few levels deep. -pub fn text_reads_up(doc: &Document, index: usize) -> bool { - let Some(page) = pdfcraft_model::pages(doc).into_iter().nth(index) else { return false }; - let to_display = Matrix(page.view_matrix(doc)).invert().unwrap_or_default(); - let res = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); - let mut score = 0i64; - let mut budget = DecodeBudget::page(); - // Content past the budget is left out: the answer is a guess from the text read so far. - let data = decoded_contents(doc, &page.dict, &mut budget).unwrap_or_default(); - text_direction(doc, &data, &res, to_display, 0, &mut score, &mut budget); - score > 0 -} - -fn text_direction(doc: &Document, data: &[u8], res: &Dict, base: Matrix, depth: usize, score: &mut i64, budget: &mut DecodeBudget) { - let (mut ctm, mut tm, mut stack) = (base, Matrix::IDENTITY, Vec::new()); - for op in pdfcraft_content::parse(data).ops { - match op.op.as_slice() { - // Bounded like the renderer's graphics-state stack: deeper saves are ignored. - b"q" if stack.len() < 256 => stack.push(ctm), - b"Q" => ctm = stack.pop().unwrap_or(base), - b"cm" => ctm = Matrix::from_operands(&op.operands).unwrap_or_default().then(&ctm), - b"BT" => tm = Matrix::IDENTITY, - b"Tm" => tm = Matrix::from_operands(&op.operands).unwrap_or_default(), - b"Tj" | b"TJ" | b"'" | b"\"" => { - let [a, b, ..] = tm.then(&ctm).0; - *score += i64::from(b > a.abs()) - i64::from(-b > a.abs()); - } - b"Do" if depth < 4 => { - let Some(name) = op.name(0) else { continue }; - let Some(xobj) = res.get(b"XObject").and_then(|x| doc.resolve(x).as_dict().and_then(|x| x.get(name).cloned())) else { continue }; - let Object::Stream(form) = &*doc.resolve(&xobj) else { continue }; - if form.dict.get(b"Subtype").and_then(|s| doc.resolve(s).as_name().map(<[u8]>::to_vec)).as_deref() != Some(b"Form".as_slice()) { - continue; - } - let m = form.dict.get(b"Matrix").and_then(|m| doc.resolve(m).as_array().and_then(|a| Matrix::from_operands(a))).unwrap_or_default(); - let inner = form.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_else(|| res.clone()); - if !budget.enter_form() { - continue; - } - if let Ok(body) = budget.decode(form) { - text_direction(doc, &body, &inner, m.then(&ctm), depth + 1, score, budget); - } - } - _ => {} - } - } -} - -/// Margin kept around a label: thermal heads miss the very edge, and many 4×6 drivers feed -/// 100 × 150 mm (283 × 425 pt), a little short of the 288 × 432 pt sheet. -pub const LABEL_MARGIN: f64 = 8.0; - -/// Place `crop` (display space) of `page` centred in `cell`, scaled by `s` and turned by -/// `quarter` quarter turns counter-clockwise (0–3). -fn place_crop(page: usize, crop: [f64; 4], cell: [f64; 4], s: f64, quarter: u8) -> Placement { - let (w, h) = (crop[2] - crop[0], crop[3] - crop[1]); - let (pw, ph) = if quarter % 2 == 1 { (h * s, w * s) } else { (w * s, h * s) }; - let (x, y) = (cell[0] + (cell[2] - cell[0] - pw) / 2.0, cell[1] + (cell[3] - cell[1] - ph) / 2.0); - let [a, b, c, d, e, f] = match quarter % 4 { - 0 => [s, 0.0, 0.0, s, x, y], - 1 => [0.0, s, -s, 0.0, x + pw, y], - 2 => [-s, 0.0, 0.0, -s, x + pw, y + ph], - _ => [0.0, -s, s, 0.0, x, y + ph], - }; - // Move the crop's corner to the origin first. - Placement { page, matrix: Matrix([a, b, c, d, e - a * crop[0] - c * crop[1], f - b * crop[0] - d * crop[1]]), clip: crop } -} - -/// Match a displayed PDF page to a standard sheet, independent of orientation. -/// Half a point allows rounded metric PDF dimensions without confusing distinct sizes. -pub fn matching_paper(size: (f64, f64)) -> Option { - if !size.0.is_finite() || !size.1.is_finite() { - return None; - } - let size = (size.0.min(size.1), size.0.max(size.1)); - PAPERS.iter().position(|(_, p)| (p.0 - size.0).abs() <= 0.5 && (p.1 - size.1).abs() <= 0.5) -} - -/// The unprintable margin assumed around the sheet for Fit, Multiple and Booklet. -pub const MARGIN: f64 = 18.0; - -/// One page as placed on a sheet: the source page, the matrix from its *display space* to the -/// sheet, and the visible part of it in display space (clipping). -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct Placement { - pub page: usize, - pub matrix: Matrix, - pub clip: [f64; 4], -} - -/// One output sheet. -#[derive(Clone, Debug, Default, PartialEq)] -pub struct Sheet { - pub size: (f64, f64), - pub placed: Vec, - /// Page borders (Multiple) and cut marks (Poster), in sheet space: rectangles and lines. - pub borders: Vec<[f64; 4]>, - pub lines: Vec<[f64; 4]>, -} - -fn oriented(paper: (f64, f64), o: Orientation, landscape_wanted: bool) -> (f64, f64) { - let (w, h) = (paper.0.min(paper.1), paper.0.max(paper.1)); - match o { - Orientation::Portrait => (w, h), - Orientation::Landscape => (h, w), - Orientation::Auto => { - if landscape_wanted { - (h, w) - } else { - (w, h) - } - } - } -} - -/// Place a page of display size `(dw, dh)` inside `cell` (sheet space), scaled by `s` -/// (centred), optionally rotated 90° (counter-clockwise). -fn place_in(page: usize, (dw, dh): (f64, f64), cell: [f64; 4], s: f64, rotate: bool) -> Placement { - let (cw, ch) = (cell[2] - cell[0], cell[3] - cell[1]); - let (pw, ph) = if rotate { (dh * s, dw * s) } else { (dw * s, dh * s) }; - let (x, y) = (cell[0] + (cw - pw) / 2.0, cell[1] + (ch - ph) / 2.0); - let matrix = if rotate { Matrix([0.0, s, -s, 0.0, x + pw, y]) } else { Matrix([s, 0.0, 0.0, s, x, y]) }; - Placement { page, matrix, clip: [0.0, 0.0, dw, dh] } -} - -fn fit_scale((dw, dh): (f64, f64), (cw, ch): (f64, f64)) -> f64 { - if dw <= 0.0 || dh <= 0.0 { 1.0 } else { (cw / dw).min(ch / dh) } -} - -/// Lay out the sheets for `settings` given each source page's display size. -pub fn layout(sizes: &[(f64, f64)], settings: &Settings) -> Result, PrintError> { - let pages: Vec = settings.pages.iter().copied().filter(|p| *p < sizes.len()).collect(); - if pages.is_empty() { - return Err(PrintError::NoPages); - } - let paper = settings.paper; - if !(paper.0 >= 72.0 && paper.1 >= 72.0 && paper.0.is_finite() && paper.1.is_finite()) { - return Err(PrintError::Invalid("invalid paper size".into())); - } - let mut sheets = Vec::new(); - match settings.layout { - Layout::Size(mode) => { - for &p in &pages { - let d = sizes[p]; - let size = oriented(paper, settings.orientation, d.0 > d.1); - let fit = fit_scale(d, (size.0 - 2.0 * MARGIN, size.1 - 2.0 * MARGIN)); - let s = match mode { - SizeMode::Fit => fit, - SizeMode::Actual => 1.0, - SizeMode::Shrink => fit.min(1.0), - SizeMode::Custom(pct) if pct.is_finite() && pct > 0.0 => pct / 100.0, - SizeMode::Custom(_) => return Err(PrintError::Invalid("the scale must be more than 0%".into())), - }; - sheets.push(Sheet { size, placed: vec![place_in(p, d, [0.0, 0.0, size.0, size.1], s, false)], ..Sheet::default() }); - } - } - Layout::Label { flip } => { - let size = oriented(paper, settings.orientation, false); - let cell = [LABEL_MARGIN, LABEL_MARGIN, size.0 - LABEL_MARGIN, size.1 - LABEL_MARGIN]; - for &p in &pages { - let d = sizes[p]; - let crop = settings.crops.get(p).copied().flatten(); - let rect = crop - .map(|c| [c.rect[0].max(0.0), c.rect[1].max(0.0), c.rect[2].min(d.0), c.rect[3].min(d.1)]) - .filter(|r| r[2] - r[0] > 1.0 && r[3] - r[1] > 1.0) - .unwrap_or([0.0, 0.0, d.0, d.1]); - let (w, h) = (rect[2] - rect[0], rect[3] - rect[1]); - let turn = (w > h) != (cell[2] - cell[0] > cell[3] - cell[1]); - let mut quarter = match (turn, crop.is_some_and(|c| c.text_up)) { - (false, _) => 0, - (true, true) => 3, - (true, false) => 1, - }; - if flip { - quarter = (quarter + 2) % 4; - } - let s = if turn { - fit_scale((h, w), (cell[2] - cell[0], cell[3] - cell[1])) - } else { - fit_scale((w, h), (cell[2] - cell[0], cell[3] - cell[1])) - }; - sheets.push(Sheet { size, placed: vec![place_crop(p, rect, cell, s, quarter)], ..Sheet::default() }); - } - } - Layout::Multiple { cols, rows, per_sheet, order, border, auto_rotate } => { - let cells = cols.checked_mul(rows).filter(|&n| n > 0 && n <= 256); - let Some(cells) = cells else { - return Err(PrintError::Invalid("invalid number of pages per sheet".into())); - }; - // The grid can have more cells than the pages asked for on each sheet; the rest stay blank. - let per_sheet = per_sheet.min(cells); - if per_sheet == 0 { - return Err(PrintError::Invalid("invalid number of pages per sheet".into())); - } - // A grid with more columns than rows wants a landscape sheet, and the reverse. - let first = pages.first().and_then(|&p| sizes.get(p)).copied().ok_or(PrintError::NoPages)?; - let page_landscape = first.0 > first.1; - let (gc, gr, sheet_landscape) = if cols == rows { - (cols, rows, page_landscape) - } else if settings.orientation == Orientation::Landscape || (settings.orientation == Orientation::Auto && cols < rows) { - // 2 per sheet (1 × 2 on portrait) prints side by side on landscape paper. - (rows, cols, true) - } else { - (cols, rows, false) - }; - let size = oriented(paper, settings.orientation, sheet_landscape); - let gap = 6.0; - let (aw, ah) = (size.0 - 2.0 * MARGIN, size.1 - 2.0 * MARGIN); - let (cw, ch) = ((aw - gap * (gc - 1) as f64) / gc as f64, (ah - gap * (gr - 1) as f64) / gr as f64); - if cw <= 0.0 || ch <= 0.0 { - return Err(PrintError::Invalid("the page grid does not fit on the paper".into())); - } - let sheet_count = pages.len().div_ceil(per_sheet); - for sheet_index in 0..sheet_count { - let mut sheet = Sheet { size, ..Sheet::default() }; - if order == PageOrder::CutStack { - // Marks align across every sheet, including unoccupied cells. Cut in the - // middle of each gutter; marks stay in the printable area, clear of cells. - for col in 1..gc { - let x = MARGIN + col as f64 * (cw + gap) - gap / 2.0; - sheet.lines.extend([[x, MARGIN, x, MARGIN + 8.0], [x, size.1 - MARGIN - 8.0, x, size.1 - MARGIN]]); - } - for row in 1..gr { - let y = size.1 - MARGIN - row as f64 * (ch + gap) + gap / 2.0; - sheet.lines.extend([[MARGIN, y, MARGIN + 8.0, y], [size.0 - MARGIN - 8.0, y, size.0 - MARGIN, y]]); - } - } - for k in 0..per_sheet { - // A cell's pile gets consecutive pages. Missing pages leave blank cells, - // rather than shifting later piles left on the final sheets. - let index = if order == PageOrder::CutStack { - k.checked_mul(sheet_count).and_then(|n| n.checked_add(sheet_index)) - } else { - sheet_index.checked_mul(per_sheet).and_then(|n| n.checked_add(k)) - }; - let Some(p) = index.and_then(|i| pages.get(i)).copied() else { continue }; - - let (col, row) = match order { - PageOrder::Horizontal | PageOrder::CutStack => (k % gc, k / gc), - PageOrder::HorizontalReversed => (gc - 1 - k % gc, k / gc), - PageOrder::Vertical => (k / gr, k % gr), - PageOrder::VerticalReversed => (gc - 1 - k / gr, k % gr), - }; - let x0 = MARGIN + col as f64 * (cw + gap); - let y1 = size.1 - MARGIN - row as f64 * (ch + gap); - let cell = [x0, y1 - ch, x0 + cw, y1]; - let d = sizes.get(p).copied().ok_or_else(|| PrintError::Invalid("page size is missing".into()))?; - if !(d.0.is_finite() && d.1.is_finite() && d.0 > 0.0 && d.1 > 0.0) { - return Err(PrintError::Invalid("invalid page size".into())); - } - let rotate = auto_rotate && (d.0 > d.1) != (cw > ch) && (d.0 - d.1).abs() > 1.0; - let s = fit_scale(if rotate { (d.1, d.0) } else { d }, (cw, ch)); - if !s.is_finite() || s <= 0.0 { - return Err(PrintError::Invalid("page size cannot be scaled to the grid".into())); - } - let pl = place_in(p, d, cell, s, rotate); - if border { - let b = pl.matrix.bbox(pl.clip); - sheet.borders.push(b); - } - sheet.placed.push(pl); - } - sheets.push(sheet); - } - } - Layout::Booklet { subset, binding } => { - // Pad to a multiple of 4 with blank pages (None). - let n4 = pages.len().div_ceil(4) * 4; - let at = |i: usize| pages.get(i).copied(); - let size = oriented(paper, Orientation::Landscape, true); - let half = (size.0 - 2.0 * MARGIN) / 2.0; - let cells = [[MARGIN, MARGIN, MARGIN + half, size.1 - MARGIN], [MARGIN + half, MARGIN, size.0 - MARGIN, size.1 - MARGIN]]; - for k in 0..n4 / 4 { - let front = (at(n4 - 1 - 2 * k), at(2 * k)); - let back = (at(2 * k + 1), at(n4 - 2 - 2 * k)); - let mut sides = Vec::new(); - if subset != BookletSubset::BackOnly { - sides.push(front); - } - if subset != BookletSubset::FrontOnly { - sides.push(back); - } - for (l, r) in sides { - let (l, r) = if binding == Binding::Right { (r, l) } else { (l, r) }; - let mut sheet = Sheet { size, ..Sheet::default() }; - for (p, cell) in [(l, cells[0]), (r, cells[1])] { - if let Some(p) = p { - let d = sizes[p]; - let s = fit_scale(d, (cell[2] - cell[0], cell[3] - cell[1])); - sheet.placed.push(place_in(p, d, cell, s, false)); - } - } - sheets.push(sheet); - } - } - } - Layout::Poster { scale, overlap, cut_marks } => { - if !(scale.is_finite() && scale > 0.0 && overlap.is_finite() && overlap >= 0.0) { - return Err(PrintError::Invalid("invalid tile scale or overlap".into())); - } - let s = scale / 100.0; - for &p in &pages { - let d = sizes[p]; - let size = oriented(paper, settings.orientation, d.0 > d.1); - // Each tile shows `step` of the scaled page plus `overlap` shared with the next. - let (tw, th) = (size.0 - 2.0 * MARGIN, size.1 - 2.0 * MARGIN); - if overlap * 2.0 >= tw.min(th) { - return Err(PrintError::Invalid("the overlap is larger than the tile".into())); - } - let (sw, sh) = (d.0 * s, d.1 * s); - let (stepx, stepy) = (tw - overlap, th - overlap); - let nx = (((sw - overlap) / stepx).ceil() as usize).max(1); - let ny = (((sh - overlap) / stepy).ceil() as usize).max(1); - // The counts saturate at usize::MAX for a huge scale, so their product can overflow. - if nx.checked_mul(ny).is_none_or(|n| n > 1024) { - return Err(PrintError::Invalid("the poster would need more than 1024 sheets".into())); - } - // Tiles from the top-left, row by row. - for ty in 0..ny { - for tx in 0..nx { - // The window into the scaled page (scaled display space, y up). - let wx0 = tx as f64 * stepx; - let wy1 = sh - ty as f64 * stepy; - let matrix = Matrix([s, 0.0, 0.0, s, MARGIN - wx0, size.1 - MARGIN - wy1]); - let clip = [wx0 / s, (wy1 - th) / s, (wx0 + tw) / s, wy1 / s]; - let mut sheet = Sheet { size, placed: vec![Placement { page: p, matrix, clip }], ..Sheet::default() }; - if cut_marks { - let (x0, y0, x1, y1) = (MARGIN, MARGIN, size.0 - MARGIN, size.1 - MARGIN); - for (x, y) in [(x0, y0), (x1, y0), (x0, y1), (x1, y1)] { - let dx = if x == x0 { -1.0 } else { 1.0 }; - let dy = if y == y0 { -1.0 } else { 1.0 }; - sheet.lines.push([x + dx * 2.0, y, x + dx * (MARGIN - 2.0), y]); - sheet.lines.push([x, y + dy * 2.0, x, y + dy * (MARGIN - 2.0)]); - } - } - sheets.push(sheet); - } - } - } - } - } - Ok(sheets) -} - -// Model-derived transforms and clipping bounds need their full PDF decimal precision. -fn geometry_number(value: f64) -> String { - let mut bytes = Vec::new(); - pdfcraft_cos::serialize(&Object::Real(value), &mut bytes); - String::from_utf8_lossy(&bytes).into_owned() -} - -fn n(v: f64) -> String { - let s = format!("{v:.4}"); - let s = s.trim_end_matches('0').trim_end_matches('.'); - if s == "-0" { "0".into() } else { s.into() } -} - -/// The page's content streams decoded against `budget` and joined; streams that don't decode -/// are left out. `None` when the budget ran out, so part of the content is missing. -fn decoded_contents(doc: &Document, page: &Dict, budget: &mut DecodeBudget) -> Option> { - let Some(contents) = page.get(b"Contents") else { return Some(Vec::new()) }; - let mut out = Vec::new(); - for data in budget.decode_contents(doc, contents) { - match data { - Ok(data) => { - out.extend_from_slice(&data); - out.push(b'\n'); - } - Err(_) if budget.remaining() == 0 => return None, - Err(_) => {} - } - } - Some(out) -} - -/// Reduce `doc`'s pages to what [`impose`] draws of them under `content`: the annotations that -/// don't print are dropped, and with [`Content::FormFieldsOnly`] the page content too. For -/// printing pages as pictures, where a renderer draws each page as it stands. -pub fn keep_printed(doc: &mut Document, content: Content) { - for page in pdfcraft_model::pages(doc) { - let annots = page.dict.get(b"Annots").and_then(|a| doc.resolve(a).as_array().cloned()).unwrap_or_default(); - let kept: Vec = annots.into_iter().filter(|a| doc.resolve(a).as_dict().is_some_and(|d| prints(doc, d, content))).collect(); - // Fails only when the page object isn't a dictionary, and such a page draws nothing. - let _ = doc.update_dict(page.obj, |d| { - if content == Content::FormFieldsOnly { - d.remove(b"Contents"); - } - if d.contains(b"Annots") { - d.set(b"Annots".to_vec(), Object::Array(kept)); - } - }); - } -} - -/// The standard annotation types of ISO 32000-2 Table 171. -const STANDARD_ANNOTATION_TYPES: [&[u8]; 28] = [ - b"Text", - b"Link", - b"FreeText", - b"Line", - b"Square", - b"Circle", - b"Polygon", - b"PolyLine", - b"Highlight", - b"Underline", - b"Squiggly", - b"StrikeOut", - b"Caret", - b"Stamp", - b"Ink", - b"Popup", - b"FileAttachment", - b"Sound", - b"Movie", - b"Screen", - b"Widget", - b"PrinterMark", - b"TrapNet", - b"Watermark", - b"3D", - b"Redact", - b"Projection", - b"RichMedia", -]; - -/// Does annotation `d` print under `content`? -fn prints(doc: &Document, d: &Dict, content: Content) -> bool { - const INVISIBLE: i64 = 1; - const HIDDEN: i64 = 2; - const PRINT: i64 = 4; - let flags = d.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0); - if flags & PRINT == 0 || flags & HIDDEN != 0 { - return false; - } - // `/Subtype` may be an indirect reference (ISO 32000-2 §7.3.10), so resolve it first. - let subtype_obj = d.get(b"Subtype").map(|s| doc.resolve(s)); - let subtype = subtype_obj.as_deref().and_then(Object::as_name).unwrap_or(b""); - match subtype { - b"Widget" => true, - b"Link" | b"Popup" => false, - b"Stamp" => content != Content::Document && content != Content::FormFieldsOnly, - // ISO 32000-2 Table 167: an Invisible annotation of a type that isn't standard (Table - // 171) is not printed even with its Print flag set. - _ if flags & INVISIBLE != 0 && !STANDARD_ANNOTATION_TYPES.contains(&subtype) => false, - _ => content == Content::DocumentAndMarkups, - } -} - -/// The normal appearance stream to draw for an annotation (honouring `/AS`). -fn appearance(doc: &Document, d: &Dict) -> Option { - let ap = doc.resolve(d.get(b"AP")?); - let normal = ap.as_dict()?.get(b"N")?.clone(); - match &*doc.resolve(&normal) { - Object::Stream(_) => normal.as_ref(), - Object::Dict(states) => { - let state = d.name(b"AS")?; - states.get(state)?.as_ref() - } - _ => None, - } -} - -/// Algorithm 8.1: the matrix placing an appearance form (BBox × Matrix) onto the annotation rect. -fn appearance_matrix(doc: &Document, form: &Dict, rect: [f64; 4]) -> Option { - let nums = - |k: &[u8]| -> Option> { Some(doc.resolve(form.get(k)?).as_array()?.iter().filter_map(|x| doc.resolve(x).as_f64()).collect()) }; - let bbox = nums(b"BBox").filter(|b| b.len() == 4)?; - let m = nums(b"Matrix").filter(|m| m.len() == 6).map(|m| Matrix([m[0], m[1], m[2], m[3], m[4], m[5]])).unwrap_or_default(); - let b = m.bbox([bbox[0], bbox[1], bbox[2], bbox[3]]); - let (bw, bh) = (b[2] - b[0], b[3] - b[1]); - if bw <= 0.0 || bh <= 0.0 { - return None; - } - let a = Matrix([ - (rect[2] - rect[0]) / bw, - 0.0, - 0.0, - (rect[3] - rect[1]) / bh, - rect[0] - b[0] * (rect[2] - rect[0]) / bw, - rect[1] - b[1] * (rect[3] - rect[1]) / bh, - ]); - // The Do that draws the form applies its /Matrix itself, so only the placement goes here. - Some(a) -} - -/// A Form XObject holding page `index` as it prints (user space; BBox = crop box). -/// `pages` are the document's pages, listed once by the caller: listing them for each page -/// printed would make printing quadratic in the page count. -fn page_form( - doc: &mut Document, - pages: &[pdfcraft_model::Page], - index: usize, - content: Content, - promoted: &mut std::collections::HashMap, -) -> Result { - let page = pages.get(index).ok_or_else(|| PrintError::Invalid(format!("there is no page {}", index.saturating_add(1))))?; - let crop = page.crop(doc); - let mut res = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); - let mut body = if content == Content::FormFieldsOnly { - Vec::new() - } else { - decoded_contents(doc, &page.dict, &mut DecodeBudget::page()) - .ok_or_else(|| PrintError::Invalid(format!("page {} has more content than can be printed", index + 1)))? - }; - // Wrap the page content so a state it leaves behind can't affect the annotations. - if !body.is_empty() { - let mut wrapped = b"q\n".to_vec(); - wrapped.extend_from_slice(&body); - wrapped.extend_from_slice(b"\nQ\n"); - body = wrapped; - } - let annots = page.dict.get(b"Annots").and_then(|a| doc.resolve(a).as_array().cloned()).unwrap_or_default(); - let mut xobjects = res.get(b"XObject").and_then(|x| doc.resolve(x).as_dict().cloned()).unwrap_or_default(); - let mut k = 0usize; - for a in annots { - let Some(d) = doc.resolve(&a).as_dict().cloned() else { continue }; - if !prints(doc, &d, content) { - continue; - } - let rect: Vec = - d.get(b"Rect").and_then(|r| doc.resolve(r).as_array().map(|a| a.iter().filter_map(Object::as_f64).collect())).unwrap_or_default(); - if rect.len() != 4 { - continue; - } - let rect = [rect[0].min(rect[2]), rect[1].min(rect[3]), rect[0].max(rect[2]), rect[1].max(rect[3])]; - let Some(ap) = appearance(doc, &d) else { continue }; - let form = doc.get(ap).as_dict().cloned().unwrap_or_default(); - let Some(m) = appearance_matrix(doc, &form, rect) else { continue }; - let ap = if !form.contains(b"Subtype") - && let Object::Stream(stream) = &*doc.get(ap) - { - // Some existing annotation appearances omit Subtype, but Do dispatch needs a Form XObject. - // Keep the original intact: other document objects can share this appearance. - if let Some(&cached) = promoted.get(&ap) { - cached - } else { - let mut stream = stream.clone(); - stream.dict.set(b"Subtype".to_vec(), Object::name("Form")); - let copy = doc.add(Object::Stream(stream)); - promoted.insert(ap, copy); - copy - } - } else { - ap - }; - // Keep the page's own XObjects, including names previous print operations generated. - // Check the growing map so each appearance also keeps the name just assigned to it. - let name = loop { - let name = format!("PCPrA{k}"); - k = k.checked_add(1).ok_or_else(|| PrintError::Invalid("too many annotation appearance resources".into()))?; - if !xobjects.contains(name.as_bytes()) { - break name; - } - }; - let [a0, b0, c0, d0, e0, f0] = m.0; - body.extend(format!("q {} {} {} {} {} {} cm /{name} Do Q\n", n(a0), n(b0), n(c0), n(d0), n(e0), n(f0)).bytes()); - xobjects.set(name.into_bytes(), Object::Ref(ap)); - } - if !xobjects.is_empty() { - res.set(b"XObject".to_vec(), Object::Dict(xobjects)); - } - let mut d = Dict::new(); - d.set(b"Type".to_vec(), Object::name("XObject")); - d.set(b"Subtype".to_vec(), Object::name("Form")); - d.set(b"BBox".to_vec(), Object::Array(crop.iter().map(|v| Object::Real(*v)).collect())); - d.set(b"Resources".to_vec(), Object::Dict(res)); - if let Some(g) = page.dict.get(b"Group") { - d.set(b"Group".to_vec(), g.clone()); - } - Ok(doc.add(Object::Stream(Stream::flate(d, &body)))) -} +mod error; +mod imposition; +mod settings; +mod sheets; +pub use error::{PrintError, WORDINGS, Words, in_english}; #[cfg(test)] -thread_local! { - /// Pages listed by [`source_pages`] on this thread. - static PAGES_LISTED: std::cell::Cell = const { std::cell::Cell::new(0) }; -} - -/// The source document's pages, for printing. -fn source_pages(doc: &Document) -> Vec { - let pages = pdfcraft_model::pages(doc); - #[cfg(test)] - PAGES_LISTED.with(|c| c.set(c.get() + pages.len())); - pages -} - -/// Build the print-ready PDF for `settings`. Encryption is not carried over (the file goes to a -/// printer or is the user's own copy); callers must check the print permission first. -pub fn impose(src: &Document, settings: &Settings) -> Result, PrintError> { - let pages = source_pages(src); - let sizes: Vec<(f64, f64)> = pages.iter().map(|p| p.display_size(src)).collect(); - let sheets = layout(&sizes, settings)?; - let mut doc = src.clone(); - // One form per source page used (a page may appear on several sheets: posters). - let mut forms: std::collections::HashMap = std::collections::HashMap::new(); - let mut promoted = std::collections::HashMap::new(); - let views: Vec<[f64; 6]> = pages.iter().map(|p| p.view_matrix(src)).collect(); - let pages_root = doc.add(Object::Null); - let mut kids = Vec::new(); - for sheet in &sheets { - let mut c = String::new(); - let mut xo = Dict::new(); - for (i, pl) in sheet.placed.iter().enumerate() { - let form = match forms.get(&pl.page) { - Some(f) => *f, - None => { - let f = page_form(&mut doc, &pages, pl.page, settings.content, &mut promoted)?; - forms.insert(pl.page, f); - f - } - }; - let name = format!("P{i}"); - xo.set(name.clone().into_bytes(), Object::Ref(form)); - // Display space → sheet, then clip to the visible part, then user → display. - let [a, b, cc, d, e, f] = pl.matrix.0; - let to_display = Matrix(views[pl.page]).invert().unwrap_or_default(); - let [ua, ub, uc, ud, ue, uf] = to_display.0; - let [x0, y0, x1, y1] = pl.clip; - c.push_str(&format!( - "q {} {} {} {} {} {} cm {} {} {} {} re W n {} {} {} {} {} {} cm /{name} Do Q\n", - geometry_number(a), - geometry_number(b), - geometry_number(cc), - geometry_number(d), - geometry_number(e), - geometry_number(f), - geometry_number(x0), - geometry_number(y0), - geometry_number(x1 - x0), - geometry_number(y1 - y0), - geometry_number(ua), - geometry_number(ub), - geometry_number(uc), - geometry_number(ud), - geometry_number(ue), - geometry_number(uf) - )); - } - if !sheet.borders.is_empty() || !sheet.lines.is_empty() { - c.push_str("q 0 G 0.5 w\n"); - for r in &sheet.borders { - c.push_str(&format!("{} {} {} {} re S\n", n(r[0]), n(r[1]), n(r[2] - r[0]), n(r[3] - r[1]))); - } - for l in &sheet.lines { - c.push_str(&format!("{} {} m {} {} l S\n", n(l[0]), n(l[1]), n(l[2]), n(l[3]))); - } - c.push_str("Q\n"); - } - let contents = doc.add(Object::Stream(Stream::flate(Dict::new(), c.as_bytes()))); - let mut res = Dict::new(); - res.set(b"XObject".to_vec(), Object::Dict(xo)); - let mut p = Dict::new(); - p.set(b"Type".to_vec(), Object::name("Page")); - p.set(b"Parent".to_vec(), Object::Ref(pages_root)); - p.set(b"MediaBox".to_vec(), Object::Array([0.0, 0.0, sheet.size.0, sheet.size.1].iter().map(|v| Object::Real(*v)).collect())); - p.set(b"Resources".to_vec(), Object::Dict(res)); - p.set(b"Contents".to_vec(), Object::Ref(contents)); - kids.push(Object::Ref(doc.add(Object::Dict(p)))); - } - let mut tree = Dict::new(); - tree.set(b"Type".to_vec(), Object::name("Pages")); - tree.set(b"Count".to_vec(), Object::Int(kids.len() as i64)); - tree.set(b"Kids".to_vec(), Object::Array(kids)); - doc.set(pages_root, Object::Dict(tree)); - let mut cat = Dict::new(); - cat.set(b"Type".to_vec(), Object::name("Catalog")); - cat.set(b"Pages".to_vec(), Object::Ref(pages_root)); - // Keep the layers' configuration, or a layer that is off in the document would print: the - // pages' marked content still refers to its groups, and without this nothing says they are off. - let layers = src.trailer().get(b"Root").and_then(|r| src.resolve(r).as_dict().and_then(|root| root.get(b"OCProperties").cloned())); - if let Some(layers) = layers { - cat.set(b"OCProperties".to_vec(), layers); - } - let root = doc.add(Object::Dict(cat)); - doc.trailer_mut().set(b"Root".to_vec(), Object::Ref(root)); - doc.trailer_mut().remove(b"Info"); - if doc.trailer().contains(b"Encrypt") { - doc.remove_encryption(); - } - Ok(write_full(&doc, &SaveOptions::default())?) -} - -/// Which source pages land on each sheet (for previews and the sheet count). -pub fn sheet_pages(sheets: &[Sheet]) -> Vec> { - sheets.iter().map(|s| s.placed.iter().map(|p| p.page).collect()).collect() -} +use imposition::{PAGES_LISTED, appearance, appearance_matrix, decoded_contents, page_form, prints}; +pub use imposition::{impose, keep_printed, sheet_pages}; +pub use settings::{ + Binding, BookletSubset, Content, Crop, LABEL_PAPER, Layout, Orientation, PAPERS, PageOrder, Raster, Settings, SizeMode, booklet_duplex, + cups_media, media_option, +}; +#[cfg(test)] +use sheets::text_direction; +pub use sheets::{LABEL_MARGIN, MARGIN, Placement, Sheet, layout, matching_paper, text_reads_up}; diff --git a/crates/print/src/settings.rs b/crates/print/src/settings.rs new file mode 100644 index 0000000000..0506eb5b18 --- /dev/null +++ b/crates/print/src/settings.rs @@ -0,0 +1,250 @@ +use super::spool; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum Orientation { + #[default] + Auto, + Portrait, + Landscape, +} + +#[derive(Clone, Copy, Debug, PartialEq, Default)] +pub enum SizeMode { + #[default] + Fit, + Actual, + Shrink, + /// Percent. + Custom(f64), +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum PageOrder { + #[default] + Horizontal, + HorizontalReversed, + Vertical, + VerticalReversed, + /// Single-sided sheets: cut into cell piles, then stack them in row-major order. + CutStack, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum BookletSubset { + #[default] + BothSides, + FrontOnly, + BackOnly, +} + +/// How a booklet goes through the printer (#2707): it decides two-sided printing itself, as +/// Acrobat does (its Print dialog hides Two-sided while Booklet is chosen; Using Acrobat X Pro, +/// Print dialog box options: "Acrobat automatically enables duplex printing, if available, for +/// booklet printing"). Both sides flip on the short edge, the fold of a 2-up sheet turned +/// sideways; front side only or back side only is one pass of a manual duplex and prints +/// single-sided. +pub fn booklet_duplex(subset: BookletSubset) -> spool::Duplex { + match subset { + BookletSubset::BothSides => spool::Duplex::ShortEdge, + BookletSubset::FrontOnly | BookletSubset::BackOnly => spool::Duplex::Off, + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum Binding { + #[default] + Left, + Right, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Layout { + Size(SizeMode), + Multiple { + cols: usize, + rows: usize, + /// Source pages on each sheet, at most `cols × rows`: three per sheet uses a 2 × 2 grid + /// with one cell left blank. + per_sheet: usize, + order: PageOrder, + border: bool, + auto_rotate: bool, + }, + Booklet { + subset: BookletSubset, + binding: Binding, + }, + /// Tile scale (percent), overlap (points), cut marks. + Poster { + scale: f64, + overlap: f64, + cut_marks: bool, + }, + /// Shipping label: each page cut to its [`Settings::crops`] box, turned so its text reads + /// upright on the sheet and fitted with [`LABEL_MARGIN`]; `flip` turns it a further 180°. + Label { + flip: bool, + }, +} + +impl Default for Layout { + fn default() -> Self { + Layout::Size(SizeMode::Fit) + } +} + +impl Layout { + /// Acrobat's pages-per-sheet choices as a grid (columns × rows on a portrait sheet). + pub fn multiple(per_sheet: usize) -> Layout { + let (cols, rows) = match per_sheet { + 2 => (1, 2), + 4 => (2, 2), + 6 => (2, 3), + 9 => (3, 3), + 16 => (4, 4), + n => { + let c = (n as f64).sqrt().ceil().max(1.0) as usize; + (c, n.div_ceil(c).max(1)) + } + }; + Layout::Multiple { cols, rows, per_sheet: per_sheet.max(1), order: PageOrder::Horizontal, border: false, auto_rotate: true } + } +} + +/// Acrobat's Comments & Forms choices. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum Content { + /// Page content and form fields. + Document, + /// Page content, form fields and every printable comment. + #[default] + DocumentAndMarkups, + /// Page content, form fields and stamps. + DocumentAndStamps, + /// Form fields only. + FormFieldsOnly, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Settings { + /// Source pages (0-based) in print order; see [`select_pages`]. + pub pages: Vec, + /// Paper (width, height) in points, portrait. + pub paper: (f64, f64), + pub orientation: Orientation, + pub layout: Layout, + pub content: Content, + /// For [`Layout::Label`], per source page: the area to keep (display space) and whether its + /// text runs bottom-to-top (so a quarter turn clockwise makes it upright). `None` or a + /// missing entry keeps the whole page. + pub crops: Vec>, + /// Print as image (Acrobat's Advanced Print Setup): every page rendered to a picture at this + /// resolution before it is placed on the paper. The engine does the rendering; `impose` is + /// then given the image-only document. + pub raster: Option, +} + +/// How pages are rendered when printing as image. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Raster { + /// Dots per inch, 72–600 (Acrobat offers 150, 300 and 600). + pub dpi: f64, + /// Render the pictures in grey, so the print-ready PDF itself is grayscale. + pub grayscale: bool, +} + +impl Default for Raster { + fn default() -> Self { + Raster { dpi: 300.0, grayscale: false } + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Crop { + pub rect: [f64; 4], + pub text_up: bool, +} + +impl Default for Settings { + fn default() -> Self { + Settings { + pages: Vec::new(), + paper: PAPERS[0].1, + orientation: Orientation::Auto, + layout: Layout::default(), + content: Content::default(), + raster: None, + crops: Vec::new(), + } + } +} + +/// Common paper sizes (points, portrait). +pub const PAPERS: [(&str, (f64, f64)); 21] = [ + ("US Letter", (612.0, 792.0)), + ("US Legal", (612.0, 1008.0)), + ("Tabloid", (792.0, 1224.0)), + ("A3", (841.89, 1190.55)), + ("A4", (595.28, 841.89)), + ("A5", (419.53, 595.28)), + ("A6", (297.64, 419.53)), + ("ARCH A (9 x 12 in)", (648.0, 864.0)), + ("ARCH B (12 x 18 in)", (864.0, 1296.0)), + ("ARCH C (18 x 24 in)", (1296.0, 1728.0)), + ("ARCH D (24 x 36 in)", (1728.0, 2592.0)), + ("ARCH E1 (30 x 42 in)", (2160.0, 3024.0)), + ("ARCH E (36 x 48 in)", (2592.0, 3456.0)), + ("ANSI C (17 x 22 in)", (1224.0, 1584.0)), + ("ANSI D (22 x 34 in)", (1584.0, 2448.0)), + ("ANSI E (34 x 44 in)", (2448.0, 3168.0)), + ("A2", (1190.55, 1683.78)), + ("A1", (1683.78, 2383.94)), + ("A0", (2383.94, 3370.39)), + ("4 x 6 in label (101.6 x 152.4 mm)", (288.0, 432.0)), + ("US #10 Envelope (4.125 x 9.5 in)", (297.0, 684.0)), +]; + +/// `PAPERS` index of the 4 × 6 in shipping label. +pub const LABEL_PAPER: usize = 19; + +/// The CUPS media name for `PAPERS[index]`, so the spooler feeds the sheet the PDF was laid out +/// on rather than the queue's default; `None` only past the end of `PAPERS`. The common sizes +/// use their PPD keywords (`w288h432` is the usual 4×6 label, `Env10` the US #10 envelope). +/// The rest use PWG 5101.1 names (ARCH E1 is its `wide-format` size), spelled exactly as the +/// spec and the IANA registry have them: a driverless printer may take only the names it +/// advertises. They carry their dimensions, so CUPS feeds the PPD size of those dimensions +/// whatever the driver calls it (`ARCHD`, `AnsiD`) (#3752). +pub fn cups_media(index: usize) -> Option<&'static str> { + Some(match PAPERS.get(index)?.0 { + "US Letter" => "Letter", + "US Legal" => "Legal", + "Tabloid" => "Tabloid", + "A3" => "A3", + "A4" => "A4", + "A5" => "A5", + "A6" => "iso_a6_105x148mm", + "ARCH A (9 x 12 in)" => "na_arch-a_9x12in", + "ARCH B (12 x 18 in)" => "na_arch-b_12x18in", + "ARCH C (18 x 24 in)" => "na_arch-c_18x24in", + "ARCH D (24 x 36 in)" => "na_arch-d_24x36in", + "ARCH E1 (30 x 42 in)" => "na_wide-format_30x42in", + "ARCH E (36 x 48 in)" => "na_arch-e_36x48in", + "ANSI C (17 x 22 in)" => "na_c_17x22in", + "ANSI D (22 x 34 in)" => "na_d_22x34in", + "ANSI E (34 x 44 in)" => "na_e_34x44in", + "A2" => "iso_a2_420x594mm", + "A1" => "iso_a1_594x841mm", + "A0" => "iso_a0_841x1189mm", + "US #10 Envelope (4.125 x 9.5 in)" => "Env10", + _ if index == LABEL_PAPER => "w288h432", + // A size added to PAPERS without a name here: its tests fail, and meanwhile the queue's + // paper stands, as it did for every size before #1583. + _ => return None, + }) +} + +/// The job option that feeds `PAPERS[index]` (`media`, see [`cups_media`]), for the Print +/// dialog's jobs and `doc_print`'s alike. +pub fn media_option(index: usize) -> Option<(String, String)> { + cups_media(index).map(|m| ("media".to_string(), m.to_string())) +} diff --git a/crates/print/src/sheets.rs b/crates/print/src/sheets.rs new file mode 100644 index 0000000000..061a6ed5c3 --- /dev/null +++ b/crates/print/src/sheets.rs @@ -0,0 +1,357 @@ +use super::error::PrintError; +use super::imposition::decoded_contents; +use super::settings::{Binding, BookletSubset, Layout, Orientation, PAPERS, PageOrder, Settings, SizeMode}; +use super::{DecodeBudget, Dict, Document, Matrix, Object}; + +/// Whether most of page `index`'s text runs bottom-to-top as the page displays (a label laid +/// sideways on a letter or A4 sheet), counting each text-showing operator once; form XObjects +/// are followed a few levels deep. +pub fn text_reads_up(doc: &Document, index: usize) -> bool { + let Some(page) = pdfcraft_model::pages(doc).into_iter().nth(index) else { return false }; + let to_display = Matrix(page.view_matrix(doc)).invert().unwrap_or_default(); + let res = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); + let mut score = 0i64; + let mut budget = DecodeBudget::page(); + // Content past the budget is left out: the answer is a guess from the text read so far. + let data = decoded_contents(doc, &page.dict, &mut budget).unwrap_or_default(); + text_direction(doc, &data, &res, to_display, 0, &mut score, &mut budget); + score > 0 +} + +pub(crate) fn text_direction(doc: &Document, data: &[u8], res: &Dict, base: Matrix, depth: usize, score: &mut i64, budget: &mut DecodeBudget) { + let (mut ctm, mut tm, mut stack) = (base, Matrix::IDENTITY, Vec::new()); + for op in pdfcraft_content::parse(data).ops { + match op.op.as_slice() { + // Bounded like the renderer's graphics-state stack: deeper saves are ignored. + b"q" if stack.len() < 256 => stack.push(ctm), + b"Q" => ctm = stack.pop().unwrap_or(base), + b"cm" => ctm = Matrix::from_operands(&op.operands).unwrap_or_default().then(&ctm), + b"BT" => tm = Matrix::IDENTITY, + b"Tm" => tm = Matrix::from_operands(&op.operands).unwrap_or_default(), + b"Tj" | b"TJ" | b"'" | b"\"" => { + let [a, b, ..] = tm.then(&ctm).0; + *score += i64::from(b > a.abs()) - i64::from(-b > a.abs()); + } + b"Do" if depth < 4 => { + let Some(name) = op.name(0) else { continue }; + let Some(xobj) = res.get(b"XObject").and_then(|x| doc.resolve(x).as_dict().and_then(|x| x.get(name).cloned())) else { continue }; + let Object::Stream(form) = &*doc.resolve(&xobj) else { continue }; + if form.dict.get(b"Subtype").and_then(|s| doc.resolve(s).as_name().map(<[u8]>::to_vec)).as_deref() != Some(b"Form".as_slice()) { + continue; + } + let m = form.dict.get(b"Matrix").and_then(|m| doc.resolve(m).as_array().and_then(|a| Matrix::from_operands(a))).unwrap_or_default(); + let inner = form.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_else(|| res.clone()); + if !budget.enter_form() { + continue; + } + if let Ok(body) = budget.decode(form) { + text_direction(doc, &body, &inner, m.then(&ctm), depth + 1, score, budget); + } + } + _ => {} + } + } +} + +/// Margin kept around a label: thermal heads miss the very edge, and many 4×6 drivers feed +/// 100 × 150 mm (283 × 425 pt), a little short of the 288 × 432 pt sheet. +pub const LABEL_MARGIN: f64 = 8.0; + +/// Place `crop` (display space) of `page` centred in `cell`, scaled by `s` and turned by +/// `quarter` quarter turns counter-clockwise (0–3). +fn place_crop(page: usize, crop: [f64; 4], cell: [f64; 4], s: f64, quarter: u8) -> Placement { + let (w, h) = (crop[2] - crop[0], crop[3] - crop[1]); + let (pw, ph) = if quarter % 2 == 1 { (h * s, w * s) } else { (w * s, h * s) }; + let (x, y) = (cell[0] + (cell[2] - cell[0] - pw) / 2.0, cell[1] + (cell[3] - cell[1] - ph) / 2.0); + let [a, b, c, d, e, f] = match quarter % 4 { + 0 => [s, 0.0, 0.0, s, x, y], + 1 => [0.0, s, -s, 0.0, x + pw, y], + 2 => [-s, 0.0, 0.0, -s, x + pw, y + ph], + _ => [0.0, -s, s, 0.0, x, y + ph], + }; + // Move the crop's corner to the origin first. + Placement { page, matrix: Matrix([a, b, c, d, e - a * crop[0] - c * crop[1], f - b * crop[0] - d * crop[1]]), clip: crop } +} + +/// Match a displayed PDF page to a standard sheet, independent of orientation. +/// Half a point allows rounded metric PDF dimensions without confusing distinct sizes. +pub fn matching_paper(size: (f64, f64)) -> Option { + if !size.0.is_finite() || !size.1.is_finite() { + return None; + } + let size = (size.0.min(size.1), size.0.max(size.1)); + PAPERS.iter().position(|(_, p)| (p.0 - size.0).abs() <= 0.5 && (p.1 - size.1).abs() <= 0.5) +} + +/// The unprintable margin assumed around the sheet for Fit, Multiple and Booklet. +pub const MARGIN: f64 = 18.0; + +/// One page as placed on a sheet: the source page, the matrix from its *display space* to the +/// sheet, and the visible part of it in display space (clipping). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Placement { + pub page: usize, + pub matrix: Matrix, + pub clip: [f64; 4], +} + +/// One output sheet. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Sheet { + pub size: (f64, f64), + pub placed: Vec, + /// Page borders (Multiple) and cut marks (Poster), in sheet space: rectangles and lines. + pub borders: Vec<[f64; 4]>, + pub lines: Vec<[f64; 4]>, +} + +fn oriented(paper: (f64, f64), o: Orientation, landscape_wanted: bool) -> (f64, f64) { + let (w, h) = (paper.0.min(paper.1), paper.0.max(paper.1)); + match o { + Orientation::Portrait => (w, h), + Orientation::Landscape => (h, w), + Orientation::Auto => { + if landscape_wanted { + (h, w) + } else { + (w, h) + } + } + } +} + +/// Place a page of display size `(dw, dh)` inside `cell` (sheet space), scaled by `s` +/// (centred), optionally rotated 90° (counter-clockwise). +fn place_in(page: usize, (dw, dh): (f64, f64), cell: [f64; 4], s: f64, rotate: bool) -> Placement { + let (cw, ch) = (cell[2] - cell[0], cell[3] - cell[1]); + let (pw, ph) = if rotate { (dh * s, dw * s) } else { (dw * s, dh * s) }; + let (x, y) = (cell[0] + (cw - pw) / 2.0, cell[1] + (ch - ph) / 2.0); + let matrix = if rotate { Matrix([0.0, s, -s, 0.0, x + pw, y]) } else { Matrix([s, 0.0, 0.0, s, x, y]) }; + Placement { page, matrix, clip: [0.0, 0.0, dw, dh] } +} + +fn fit_scale((dw, dh): (f64, f64), (cw, ch): (f64, f64)) -> f64 { + if dw <= 0.0 || dh <= 0.0 { 1.0 } else { (cw / dw).min(ch / dh) } +} + +/// Lay out the sheets for `settings` given each source page's display size. +pub fn layout(sizes: &[(f64, f64)], settings: &Settings) -> Result, PrintError> { + let pages: Vec = settings.pages.iter().copied().filter(|p| *p < sizes.len()).collect(); + if pages.is_empty() { + return Err(PrintError::NoPages); + } + let paper = settings.paper; + if !(paper.0 >= 72.0 && paper.1 >= 72.0 && paper.0.is_finite() && paper.1.is_finite()) { + return Err(PrintError::Invalid("invalid paper size".into())); + } + let mut sheets = Vec::new(); + match settings.layout { + Layout::Size(mode) => { + for &p in &pages { + let d = sizes[p]; + let size = oriented(paper, settings.orientation, d.0 > d.1); + let fit = fit_scale(d, (size.0 - 2.0 * MARGIN, size.1 - 2.0 * MARGIN)); + let s = match mode { + SizeMode::Fit => fit, + SizeMode::Actual => 1.0, + SizeMode::Shrink => fit.min(1.0), + SizeMode::Custom(pct) if pct.is_finite() && pct > 0.0 => pct / 100.0, + SizeMode::Custom(_) => return Err(PrintError::Invalid("the scale must be more than 0%".into())), + }; + sheets.push(Sheet { size, placed: vec![place_in(p, d, [0.0, 0.0, size.0, size.1], s, false)], ..Sheet::default() }); + } + } + Layout::Label { flip } => { + sheets.extend(layout_labels(sizes, &pages, paper, settings.orientation, settings, flip)); + } + Layout::Multiple { cols, rows, per_sheet, order, border, auto_rotate } => { + let cells = cols.checked_mul(rows).filter(|&n| n > 0 && n <= 256); + let Some(cells) = cells else { + return Err(PrintError::Invalid("invalid number of pages per sheet".into())); + }; + // The grid can have more cells than the pages asked for on each sheet; the rest stay blank. + let per_sheet = per_sheet.min(cells); + if per_sheet == 0 { + return Err(PrintError::Invalid("invalid number of pages per sheet".into())); + } + // A grid with more columns than rows wants a landscape sheet, and the reverse. + let first = pages.first().and_then(|&p| sizes.get(p)).copied().ok_or(PrintError::NoPages)?; + let page_landscape = first.0 > first.1; + let (gc, gr, sheet_landscape) = if cols == rows { + (cols, rows, page_landscape) + } else if settings.orientation == Orientation::Landscape || (settings.orientation == Orientation::Auto && cols < rows) { + // 2 per sheet (1 × 2 on portrait) prints side by side on landscape paper. + (rows, cols, true) + } else { + (cols, rows, false) + }; + let size = oriented(paper, settings.orientation, sheet_landscape); + let gap = 6.0; + let (aw, ah) = (size.0 - 2.0 * MARGIN, size.1 - 2.0 * MARGIN); + let (cw, ch) = ((aw - gap * (gc - 1) as f64) / gc as f64, (ah - gap * (gr - 1) as f64) / gr as f64); + if cw <= 0.0 || ch <= 0.0 { + return Err(PrintError::Invalid("the page grid does not fit on the paper".into())); + } + let sheet_count = pages.len().div_ceil(per_sheet); + for sheet_index in 0..sheet_count { + let mut sheet = Sheet { size, ..Sheet::default() }; + if order == PageOrder::CutStack { + // Marks align across every sheet, including unoccupied cells. Cut in the + // middle of each gutter; marks stay in the printable area, clear of cells. + for col in 1..gc { + let x = MARGIN + col as f64 * (cw + gap) - gap / 2.0; + sheet.lines.extend([[x, MARGIN, x, MARGIN + 8.0], [x, size.1 - MARGIN - 8.0, x, size.1 - MARGIN]]); + } + for row in 1..gr { + let y = size.1 - MARGIN - row as f64 * (ch + gap) + gap / 2.0; + sheet.lines.extend([[MARGIN, y, MARGIN + 8.0, y], [size.0 - MARGIN - 8.0, y, size.0 - MARGIN, y]]); + } + } + for k in 0..per_sheet { + // A cell's pile gets consecutive pages. Missing pages leave blank cells, + // rather than shifting later piles left on the final sheets. + let index = if order == PageOrder::CutStack { + k.checked_mul(sheet_count).and_then(|n| n.checked_add(sheet_index)) + } else { + sheet_index.checked_mul(per_sheet).and_then(|n| n.checked_add(k)) + }; + let Some(p) = index.and_then(|i| pages.get(i)).copied() else { continue }; + + let (col, row) = match order { + PageOrder::Horizontal | PageOrder::CutStack => (k % gc, k / gc), + PageOrder::HorizontalReversed => (gc - 1 - k % gc, k / gc), + PageOrder::Vertical => (k / gr, k % gr), + PageOrder::VerticalReversed => (gc - 1 - k / gr, k % gr), + }; + let x0 = MARGIN + col as f64 * (cw + gap); + let y1 = size.1 - MARGIN - row as f64 * (ch + gap); + let cell = [x0, y1 - ch, x0 + cw, y1]; + let d = sizes.get(p).copied().ok_or_else(|| PrintError::Invalid("page size is missing".into()))?; + if !(d.0.is_finite() && d.1.is_finite() && d.0 > 0.0 && d.1 > 0.0) { + return Err(PrintError::Invalid("invalid page size".into())); + } + let rotate = auto_rotate && (d.0 > d.1) != (cw > ch) && (d.0 - d.1).abs() > 1.0; + let s = fit_scale(if rotate { (d.1, d.0) } else { d }, (cw, ch)); + if !s.is_finite() || s <= 0.0 { + return Err(PrintError::Invalid("page size cannot be scaled to the grid".into())); + } + let pl = place_in(p, d, cell, s, rotate); + if border { + let b = pl.matrix.bbox(pl.clip); + sheet.borders.push(b); + } + sheet.placed.push(pl); + } + sheets.push(sheet); + } + } + Layout::Booklet { subset, binding } => { + // Pad to a multiple of 4 with blank pages (None). + let n4 = pages.len().div_ceil(4) * 4; + let at = |i: usize| pages.get(i).copied(); + let size = oriented(paper, Orientation::Landscape, true); + let half = (size.0 - 2.0 * MARGIN) / 2.0; + let cells = [[MARGIN, MARGIN, MARGIN + half, size.1 - MARGIN], [MARGIN + half, MARGIN, size.0 - MARGIN, size.1 - MARGIN]]; + for k in 0..n4 / 4 { + let front = (at(n4 - 1 - 2 * k), at(2 * k)); + let back = (at(2 * k + 1), at(n4 - 2 - 2 * k)); + let mut sides = Vec::new(); + if subset != BookletSubset::BackOnly { + sides.push(front); + } + if subset != BookletSubset::FrontOnly { + sides.push(back); + } + for (l, r) in sides { + let (l, r) = if binding == Binding::Right { (r, l) } else { (l, r) }; + let mut sheet = Sheet { size, ..Sheet::default() }; + for (p, cell) in [(l, cells[0]), (r, cells[1])] { + if let Some(p) = p { + let d = sizes[p]; + let s = fit_scale(d, (cell[2] - cell[0], cell[3] - cell[1])); + sheet.placed.push(place_in(p, d, cell, s, false)); + } + } + sheets.push(sheet); + } + } + } + Layout::Poster { scale, overlap, cut_marks } => { + if !(scale.is_finite() && scale > 0.0 && overlap.is_finite() && overlap >= 0.0) { + return Err(PrintError::Invalid("invalid tile scale or overlap".into())); + } + let s = scale / 100.0; + for &p in &pages { + let d = sizes[p]; + let size = oriented(paper, settings.orientation, d.0 > d.1); + // Each tile shows `step` of the scaled page plus `overlap` shared with the next. + let (tw, th) = (size.0 - 2.0 * MARGIN, size.1 - 2.0 * MARGIN); + if overlap * 2.0 >= tw.min(th) { + return Err(PrintError::Invalid("the overlap is larger than the tile".into())); + } + let (sw, sh) = (d.0 * s, d.1 * s); + let (stepx, stepy) = (tw - overlap, th - overlap); + let nx = (((sw - overlap) / stepx).ceil() as usize).max(1); + let ny = (((sh - overlap) / stepy).ceil() as usize).max(1); + // The counts saturate at usize::MAX for a huge scale, so their product can overflow. + if nx.checked_mul(ny).is_none_or(|n| n > 1024) { + return Err(PrintError::Invalid("the poster would need more than 1024 sheets".into())); + } + // Tiles from the top-left, row by row. + for ty in 0..ny { + for tx in 0..nx { + // The window into the scaled page (scaled display space, y up). + let wx0 = tx as f64 * stepx; + let wy1 = sh - ty as f64 * stepy; + let matrix = Matrix([s, 0.0, 0.0, s, MARGIN - wx0, size.1 - MARGIN - wy1]); + let clip = [wx0 / s, (wy1 - th) / s, (wx0 + tw) / s, wy1 / s]; + let mut sheet = Sheet { size, placed: vec![Placement { page: p, matrix, clip }], ..Sheet::default() }; + if cut_marks { + let (x0, y0, x1, y1) = (MARGIN, MARGIN, size.0 - MARGIN, size.1 - MARGIN); + for (x, y) in [(x0, y0), (x1, y0), (x0, y1), (x1, y1)] { + let dx = if x == x0 { -1.0 } else { 1.0 }; + let dy = if y == y0 { -1.0 } else { 1.0 }; + sheet.lines.push([x + dx * 2.0, y, x + dx * (MARGIN - 2.0), y]); + sheet.lines.push([x, y + dy * 2.0, x, y + dy * (MARGIN - 2.0)]); + } + } + sheets.push(sheet); + } + } + } + } + } + Ok(sheets) +} + +fn layout_labels(sizes: &[(f64, f64)], pages: &[usize], paper: (f64, f64), orientation: Orientation, settings: &Settings, flip: bool) -> Vec { + let size = oriented(paper, orientation, false); + let cell = [LABEL_MARGIN, LABEL_MARGIN, size.0 - LABEL_MARGIN, size.1 - LABEL_MARGIN]; + pages + .iter() + .map(|&p| { + let d = sizes[p]; + let crop = settings.crops.get(p).copied().flatten(); + let rect = crop + .map(|c| [c.rect[0].max(0.0), c.rect[1].max(0.0), c.rect[2].min(d.0), c.rect[3].min(d.1)]) + .filter(|r| r[2] - r[0] > 1.0 && r[3] - r[1] > 1.0) + .unwrap_or([0.0, 0.0, d.0, d.1]); + let (w, h) = (rect[2] - rect[0], rect[3] - rect[1]); + let turn = (w > h) != (cell[2] - cell[0] > cell[3] - cell[1]); + let mut quarter = match (turn, crop.is_some_and(|c| c.text_up)) { + (false, _) => 0, + (true, true) => 3, + (true, false) => 1, + }; + if flip { + quarter = (quarter + 2) % 4; + } + let s = if turn { + fit_scale((h, w), (cell[2] - cell[0], cell[3] - cell[1])) + } else { + fit_scale((w, h), (cell[2] - cell[0], cell[3] - cell[1])) + }; + Sheet { size, placed: vec![place_crop(p, rect, cell, s, quarter)], ..Sheet::default() } + }) + .collect() +} diff --git a/crates/redact/src/interp.rs b/crates/redact/src/interp.rs index cef566042a..3baec29c2c 100644 --- a/crates/redact/src/interp.rs +++ b/crates/redact/src/interp.rs @@ -26,6 +26,13 @@ use pdfcraft_cos::{CosError, DecodeBudget, Dict, Document, MAX_PAGE_FORMS, ObjRe use crate::{RedactError, Report, image, page_streams, tags}; use pdfcraft_fonts::pdf::Metrics; +mod layers; +mod prune; + +use layers::strip_hidden_layers; +use prune::{clip_out, extgstate_font, prune_form_resources}; +pub(crate) use prune::{prune, prune_properties, resource_ops, unlink_doomed}; + /// How deep this pass follows form XObjects. A form past it is removed rather than walked, which /// is the fail-safe choice for a redaction -- marked content inside a form we cannot read would /// otherwise survive -- but it also removes whatever else that form drew, marked or not. So the @@ -151,217 +158,6 @@ impl<'a> Scope<'a> { self.hidden_text.is_some() || self.layers_enabled } - /// Is this optional-content reference (an OCG or OCMD) hidden? - fn layer_hidden(&mut self, doc: &Document, o: &Object) -> bool { - if !self.layers_enabled { - return false; - } - let reference = o.as_ref(); - if let Some(r) = reference { - if let Some(hidden) = self.ocmd_cache.get(&r) { - return *hidden; - } - if self.hidden_layers.contains(&r) { - self.ocmd_cache.insert(r, true); - return true; - } - } - // Evaluate the policy against the same default configuration used to select hidden - // groups. A membership dictionary's default policy is AnyOn. - let d = doc.resolve(o); - let Some(d) = d.as_dict() else { return false }; - if d.name(b"Type") != Some(b"OCMD") { - return false; - } - let groups: Vec = match d.get(b"OCGs").map(|g| (*doc.resolve(g)).clone()) { - Some(Object::Array(a)) => a.iter().filter_map(Object::as_ref).collect(), - Some(_) => d.get(b"OCGs").and_then(Object::as_ref).into_iter().collect(), - None => Vec::new(), - }; - let fail_closed = || groups.iter().any(|g| self.hidden_layers.contains(g)); - let hidden = if let Some(expression) = d.get(b"VE") { - let visible = self.visibility_expression(doc, expression, 0, &mut HashSet::new(), &mut 4096); - match visible { - Some(visible) => !visible, - None => fail_closed() || self.expression_mentions_hidden(doc, expression, 0, &mut HashSet::new(), &mut 4096), - } - } else if groups.is_empty() { - false - } else { - let any_on = groups.iter().any(|g| !self.hidden_layers.contains(g)); - let all_on = groups.iter().all(|g| !self.hidden_layers.contains(g)); - let policy = d.get(b"P").map(|p| doc.resolve(p)).and_then(|p| p.as_name().map(|name| name.to_vec())); - match (d.get(b"P"), policy.as_deref()) { - (None, _) | (Some(_), Some(b"AnyOn")) => !any_on, - (Some(_), Some(b"AllOn")) => !all_on, - (Some(_), Some(b"AnyOff")) => all_on, - (Some(_), Some(b"AllOff")) => any_on, - // An invalid policy must not make a candidate hidden block survive sanitization. - _ => fail_closed(), - } - }; - if !hidden { - self.retained_off_layers.extend(groups.iter().filter(|g| self.hidden_layers.contains(g)).copied()); - if let Some(expression) = d.get(b"VE") { - let mut referenced = HashSet::new(); - if Self::collect_hidden_expression_groups(doc, expression, &self.hidden_layers, 0, &mut HashSet::new(), &mut 4096, &mut referenced) { - self.retained_off_layers.extend(referenced); - } else { - // If an expression cannot be fully walked, preserve every off group so that - // the content we kept does not change visibility after configuration cleanup. - self.retained_off_layers.extend(self.hidden_layers.iter().copied()); - } - } - } - if let Some(r) = reference { - self.ocmd_cache.insert(r, hidden); - } - hidden - } - - fn xobject_layer_hidden(&mut self, doc: &Document, xobject: ObjRef, oc: &Object) -> bool { - if oc.as_ref().is_some() { - return self.layer_hidden(doc, oc); - } - if let Some(hidden) = self.xobject_oc_cache.get(&xobject) { - return *hidden; - } - let hidden = self.layer_hidden(doc, oc); - self.xobject_oc_cache.insert(xobject, hidden); - hidden - } - - fn collect_hidden_expression_groups( - doc: &Document, - expression: &Object, - hidden_layers: &HashSet, - depth: usize, - path: &mut HashSet, - budget: &mut usize, - found: &mut HashSet, - ) -> bool { - if depth > 64 || *budget == 0 { - return false; - } - *budget -= 1; - let reference = expression.as_ref(); - if let Some(r) = reference - && !path.insert(r) - { - return false; - } - let resolved = doc.resolve(expression); - let complete = match &*resolved { - Object::Dict(d) if d.name(b"Type") == Some(b"OCG") => { - if let Some(r) = reference - && hidden_layers.contains(&r) - { - found.insert(r); - } - reference.is_some() - } - Object::Array(items) => { - let Some(Object::Name(operator)) = items.first() else { return false }; - let operands = match operator.as_slice() { - b"And" | b"Or" if (2..=4097).contains(&items.len()) => items.get(1..), - b"Not" if items.len() == 2 => items.get(1..), - _ => None, - }; - operands.is_some_and(|operands| { - operands.iter().all(|item| Self::collect_hidden_expression_groups(doc, item, hidden_layers, depth + 1, path, budget, found)) - }) - } - _ => false, - }; - if let Some(r) = reference { - path.remove(&r); - } - complete - } - - /// Evaluate an optional-content visibility expression (`/VE`) against the default config. - /// Malformed, cyclic, or excessive expressions return `None` so the caller can fail closed. - fn visibility_expression( - &self, - doc: &Document, - expression: &Object, - depth: usize, - path: &mut HashSet, - budget: &mut usize, - ) -> Option { - if depth > 64 || *budget == 0 { - return None; - } - *budget -= 1; - let reference = expression.as_ref(); - if let Some(r) = reference - && !path.insert(r) - { - return None; - } - let resolved = doc.resolve(expression); - let result = match &*resolved { - Object::Dict(d) if d.name(b"Type") == Some(b"OCG") => reference.map(|r| !self.hidden_layers.contains(&r)), - Object::Array(items) if (2..=4097).contains(&items.len()) => { - let operator = match items.first() { - Some(Object::Name(n)) => n.as_slice(), - _ => return None, - }; - match operator { - b"And" if items.len() >= 2 => { - let mut visible = true; - for item in items.iter().skip(1) { - visible &= self.visibility_expression(doc, item, depth + 1, path, budget)?; - } - Some(visible) - } - b"Or" if items.len() >= 2 => { - let mut visible = false; - for item in items.iter().skip(1) { - visible |= self.visibility_expression(doc, item, depth + 1, path, budget)?; - } - Some(visible) - } - b"Not" if items.len() == 2 => Some(!self.visibility_expression(doc, &items[1], depth + 1, path, budget)?), - _ => None, - } - } - _ => None, - }; - if let Some(r) = reference { - path.remove(&r); - } - result - } - - /// Conservatively find hidden groups in an expression that could not be evaluated. Unknown - /// syntax, cycles, and exhausted budgets count as a possible hidden reference. - fn expression_mentions_hidden(&self, doc: &Document, expression: &Object, depth: usize, path: &mut HashSet, budget: &mut usize) -> bool { - if depth > 64 || *budget == 0 { - return true; - } - *budget -= 1; - if let Some(r) = expression.as_ref() { - if self.hidden_layers.contains(&r) { - return true; - } - if !path.insert(r) { - return true; - } - } - let resolved = doc.resolve(expression); - let found = match &*resolved { - Object::Array(items) => items.iter().any(|item| self.expression_mentions_hidden(doc, item, depth + 1, path, budget)), - Object::Dict(d) if d.name(b"Type") == Some(b"OCG") => expression.as_ref().is_none_or(|r| self.hidden_layers.contains(&r)), - Object::Dict(_) => true, - _ => false, - }; - if let Some(r) = expression.as_ref() { - path.remove(&r); - } - found - } - fn hits(&self, b: [f64; 4]) -> bool { self.rects.iter().any(|r| overlaps(*r, b, 0.0)) } @@ -418,359 +214,6 @@ fn fresh_name(scope: &mut Scope<'_>, xobjects: &Dict) -> Vec { } } -/// Drop every `replaced` resource name whose `Do` no longer appears in the rewritten `content`, -/// so a full-rewrite save stops reaching the original image or form it named. A name that some -/// surviving `Do` still draws (the same image shown again outside a mark) is kept. -pub(crate) fn prune(xo: &mut Dict, replaced: &[Vec], content: &[Vec]) { - if replaced.is_empty() { - return; - } - let joined = Pieces::join(content); - let ops = joined.parse().ops; - let still_drawn = |n: &[u8]| ops.iter().any(|op| op.is("Do") && op.last_name() == Some(n)); - for n in replaced { - if !still_drawn(n) { - xo.remove(n); - } - } -} - -/// Whether `content` draws the XObject `name` of the resource dictionary `xo`: directly, or -/// through a form that has no /Resources of its own (such a form draws through its caller's). -/// `None` when that cannot be told (a form that will not decode, or nesting past the limit). -fn content_draws(doc: &Document, content: &[Vec], xo: &Dict, name: &[u8], depth: usize, seen: &mut HashSet) -> Option { - let joined = Pieces::join(content); - let ops = joined.parse().ops; - for op in ops.iter().filter(|op| op.is("Do")) { - let Some(n) = op.last_name() else { continue }; - if n == name { - return Some(true); - } - let Some(r) = xo.get(n).and_then(Object::as_ref) else { continue }; - let obj = doc.get(r); - let Object::Stream(s) = &*obj else { continue }; - if s.dict.name(b"Subtype") != Some(b"Form") || s.dict.contains(b"Resources") || !seen.insert(r) { - continue; - } - if depth >= MAX_DEPTH { - return None; - } - let Ok(data) = s.decoded() else { return None }; - match content_draws(doc, std::slice::from_ref(&data), xo, name, depth + 1, seen) { - Some(false) => {} - other => return other, - } - } - Some(false) -} - -/// Unlink the XObjects a rewrite dropped or replaced (`doomed`) from every page-tree resource -/// dictionary that still names them. The rewritten page has its own pruned /Resources, but the -/// originals stay reachable, and so stay in a full-rewrite save, when: -/// - the page inherited its /Resources from a /Pages node, which still lists them; -/// - several pages share one resource dictionary, and another page still refers to it; -/// - a form without /Resources drew them through the page's dictionary. -/// -/// An entry goes unless a page that draws through that dictionary still draws the name (the same -/// image shown on another page, or again outside a mark). A dictionary that loses an entry is -/// replaced by a private copy on its node, so a dictionary object shared with other nodes is -/// left as it is for them. Retired properties are likewise unlinked only when none of the -/// holder's pages or inheriting forms names them. Fails closed when use cannot be determined. -pub(crate) fn unlink_doomed(doc: &mut Document, doomed: &[ObjRef], retired_properties: &[Object]) -> Result<(), RedactError> { - crate::type3::unlink_type3(doc)?; - if doomed.is_empty() && retired_properties.is_empty() { - return Ok(()); - } - let doomed: HashSet = doomed.iter().copied().collect(); - let Some(top) = doc.root().and_then(|root| doc.get(root).as_dict().and_then(|d| d.reference(b"Pages"))) else { return Ok(()) }; - // Every page-tree node with a /Resources of its own, and the pages that draw through it - // (the nearest such node above a page, or the page itself). Cycles are cut as in - // `pdfcraft_model::pages`. - let mut holders: Vec<(ObjRef, Vec)> = Vec::new(); - let mut seen = HashSet::new(); - let mut stack: Vec<(ObjRef, Option)> = vec![(top, None)]; - while let Some((node, mut holder)) = stack.pop() { - if !seen.insert(node) || seen.len() > 1_000_000 { - continue; - } - let obj = doc.get(node); - let Some(d) = obj.as_dict() else { continue }; - if d.contains(b"Resources") { - holders.push((node, Vec::new())); - holder = Some(holders.len() - 1); - } - let is_pages = d.name(b"Type") == Some(b"Pages") || (d.contains(b"Kids") && d.name(b"Type") != Some(b"Page")); - if is_pages { - let kids = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_default(); - stack.extend(kids.iter().filter_map(Object::as_ref).map(|k| (k, holder))); - } else if let Some(h) = holder.and_then(|i| holders.get_mut(i)) { - h.1.push(node); - } - } - let index: HashMap = pdfcraft_model::pages(doc).iter().enumerate().map(|(i, p)| (p.obj, i)).collect(); - let holder_nodes: Vec = holders.iter().map(|(node, _)| *node).collect(); - for (node, users) in holders { - let Some(mut res) = doc.get(node).as_dict().and_then(|d| d.get(b"Resources")).and_then(|r| doc.resolve(r).as_dict().cloned()) else { - continue; - }; - let mut xo = res_dict(doc, &res, b"XObject"); - let names: Vec> = xo.iter().filter(|(_, v)| v.as_ref().is_some_and(|r| doomed.contains(&r))).map(|(n, _)| n.to_vec()).collect(); - let properties = res_dict(doc, &res, b"Properties"); - let property_names: Vec> = properties.iter().filter(|(_, v)| retired_properties.contains(v)).map(|(n, _)| n.to_vec()).collect(); - if names.is_empty() && property_names.is_empty() { - continue; - } - let mut contents = Vec::with_capacity(users.len()); - for user in &users { - let page = doc.get(*user).as_dict().cloned().unwrap_or_default(); - // A page whose content cannot be read may still draw the name: fail closed. - contents.push(page_streams(doc, &page, index.get(user).copied().unwrap_or(0))?.1); - } - let mut pruned = false; - for n in names { - let mut drawn = Some(false); - for content in &contents { - drawn = content_draws(doc, content, &xo, &n, 0, &mut HashSet::new()); - if drawn != Some(false) { - break; - } - } - match drawn { - Some(true) => {} - Some(false) => { - xo.remove(&n); - pruned = true; - } - None => return Err(RedactError::Residue(1)), - } - } - if pruned { - res.set(b"XObject".to_vec(), Object::Dict(xo)); - } - pruned |= prune_retired_properties(doc, &mut res, &property_names, &contents)?; - if pruned { - doc.update_dict(node, |d| d.set(b"Resources".to_vec(), Object::Dict(res)))?; - } - } - // A surviving form can own the same old resource dictionary as a rewritten page. Its - // unused descriptions and doomed XObjects remain reachable too, even after every page - // holder is cleaned. - unlink_from_forms(doc, &holder_nodes, &doomed, retired_properties)?; - Ok(()) -} - -/// Retire only properties removed from a rewritten scope, preserving names still used by any -/// content sharing this holder. Different pages' streams must never be concatenated together. -fn prune_retired_properties(doc: &Document, res: &mut Dict, names: &[Vec], contents: &[Vec>]) -> Result { - if names.is_empty() { - return Ok(false); - } - let mut used = HashSet::new(); - for content in contents { - for op in resource_ops(doc, res, content)? { - if matches!(op.op.as_slice(), b"BDC" | b"DP") - && let Some(name) = op.name(1) - { - used.insert(name.to_vec()); - } - } - } - let mut properties = res_dict(doc, res, b"Properties"); - let mut pruned = false; - for name in names { - if !used.contains(name) { - pruned |= properties.remove(name).is_some(); - } - } - if pruned { - if properties.is_empty() { - res.remove(b"Properties"); - } else { - res.set(b"Properties".to_vec(), Object::Dict(properties)); - } - } - Ok(pruned) -} - -/// Unlink retired properties and `doomed` XObjects from the resources of the forms reachable from -/// the page-tree `holders`, each unless the form's own content (or a form inheriting its -/// resources) still uses the name. Fails closed when that cannot be determined. -fn unlink_from_forms(doc: &mut Document, holders: &[ObjRef], doomed: &HashSet, retired: &[Object]) -> Result<(), CosError> { - if retired.is_empty() && doomed.is_empty() { - return Ok(()); - } - let mut pending: Vec = holders - .iter() - .filter_map(|r| doc.get(*r).as_dict().and_then(|d| d.get(b"Resources")).and_then(|r| doc.resolve(r).as_dict().cloned())) - .collect(); - let mut seen = HashSet::new(); - while let Some(resources) = pending.pop() { - for (_, value) in res_dict(doc, &resources, b"XObject").iter() { - let Some(reference) = value.as_ref() else { continue }; - if !seen.insert(reference) { - continue; - } - if seen.len() > 1_000_000 { - return Err(CosError::Syntax { offset: 0, detail: "redaction could not fully inspect shared form resources".into() }); - } - let object = doc.get(reference); - let Object::Stream(form) = &*object else { continue }; - if form.dict.name(b"Subtype") != Some(b"Form") { - continue; - } - let Some(mut res) = form.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()) else { continue }; - let names: Vec> = - res_dict(doc, &res, b"Properties").iter().filter(|(_, value)| retired.contains(value)).map(|(name, _)| name.to_vec()).collect(); - let mut xo = res_dict(doc, &res, b"XObject"); - let doomed_names: Vec> = - xo.iter().filter(|(_, v)| v.as_ref().is_some_and(|r| doomed.contains(&r))).map(|(n, _)| n.to_vec()).collect(); - if names.is_empty() && doomed_names.is_empty() { - pending.push(res); - continue; - } - let data = form.decoded()?; - let mut pruned = prune_doomed_xobjects(doc, std::slice::from_ref(&data), &mut xo, &doomed_names)?; - if pruned { - res.set(b"XObject".to_vec(), Object::Dict(xo)); - } - pruned |= prune_retired_properties(doc, &mut res, &names, &[vec![data]])?; - if pruned { - doc.update_dict(reference, |d| d.set(b"Resources".to_vec(), Object::Dict(res.clone())))?; - } - pending.push(res); - } - } - Ok(()) -} - -fn prune_doomed_xobjects(doc: &Document, content: &[Vec], xo: &mut Dict, doomed_names: &[Vec]) -> Result { - let mut pruned = false; - for name in doomed_names { - match content_draws(doc, content, xo, name, 0, &mut HashSet::new()) { - Some(true) => {} - Some(false) => pruned |= xo.remove(name).is_some(), - None => return Err(CosError::Syntax { offset: 0, detail: "redaction could not fully inspect shared form resources".into() }), - } - } - Ok(pruned) -} - -/// The clip that hides everything inside each region: one even-odd clip per region (a big -/// rectangle with the region cut out), in the current user space. Returns `None` when the CTM -/// can't be inverted (nothing is painted then anyway). -fn clip_out(rects: &[[f64; 4]], ctm: &Matrix, around: [f64; 4]) -> Option> { - let inv = ctm.invert()?; - let mut ops = Vec::new(); - let big = [around[0] - 10.0, around[1] - 10.0, around[2] + 10.0, around[3] + 10.0]; - for r in rects.iter().filter(|r| overlaps(**r, around, 0.0)) { - let outer = [(big[0], big[1]), (big[2], big[1]), (big[2], big[3]), (big[0], big[3])]; - let inner = [(r[0], r[1]), (r[0], r[3]), (r[2], r[3]), (r[2], r[1])]; - for poly in [outer, inner] { - for (k, (x, y)) in poly.iter().enumerate() { - let (u, v) = inv.apply(*x, *y); - ops.push(Op::new(if k == 0 { "m" } else { "l" }, vec![num(u), num(v)])); - } - ops.push(Op::new("h", vec![])); - } - ops.push(Op::new("W*", vec![])); - ops.push(Op::new("n", vec![])); - } - Some(ops) -} - -pub(crate) fn resource_ops(doc: &Document, res: &Dict, content: &[Vec]) -> Result, CosError> { - let parsed = Pieces::join(content).parse(); - if parsed.skipped > 0 || parsed.ops.len() > 2_000_000 { - return Err(CosError::Syntax { offset: 0, detail: "redaction could not fully inspect property resources".into() }); - } - let mut ops = parsed.ops; - let xobjects = res_dict(doc, res, b"XObject"); - let mut seen = HashSet::new(); - let mut cursor = 0; - while cursor < ops.len() { - let op = &ops[cursor]; - if op.is("Do") - && let Some(r) = op.last_name().and_then(|n| xobjects.get(n)).and_then(Object::as_ref) - && seen.insert(r) - && let Object::Stream(s) = &*doc.get(r) - && s.dict.name(b"Subtype") == Some(b"Form") - && s.dict.get(b"Resources").is_none_or(|r| doc.resolve(r).as_dict().is_none()) - { - let parsed = pdfcraft_content::parse(&s.decoded()?); - if seen.len() > 2_000_000 || ops.len().saturating_add(parsed.ops.len()) > 2_000_000 || parsed.skipped > 0 { - return Err(CosError::Syntax { offset: 0, detail: "redaction could not fully inspect inherited form resources".into() }); - } - ops.extend(parsed.ops); - } - cursor += 1; - } - Ok(ops) -} - -pub(crate) fn prune_properties(doc: &Document, res: &mut Dict, content: &[Vec]) -> Result, CosError> { - let mut properties = res_dict(doc, res, b"Properties"); - if properties.is_empty() { - return Ok(Vec::new()); - } - let mut retired = Vec::new(); - let ops = resource_ops(doc, res, content)?; - let names: Vec> = properties.iter().map(|(n, _)| n.to_vec()).collect(); - for name in names { - if !ops.iter().any(|op| matches!(op.op.as_slice(), b"BDC" | b"DP") && op.name(1) == Some(&name)) - && let Some(value) = properties.remove(&name) - { - retired.push(value); - } - } - if properties.is_empty() { - res.remove(b"Properties"); - } else { - res.set(b"Properties".to_vec(), Object::Dict(properties)); - } - Ok(retired) -} - -fn prune_form_resources(doc: &Document, res: &mut Dict, content: &[Vec]) -> Result<(), CosError> { - let ops = resource_ops(doc, res, content)?; - let mut xo = res_dict(doc, res, b"XObject"); - let names: Vec> = xo.iter().map(|(n, _)| n.to_vec()).collect(); - for name in names { - if !ops.iter().any(|op| op.is("Do") && op.last_name() == Some(&name)) { - xo.remove(&name); - } - } - res.set(b"XObject".to_vec(), Object::Dict(xo)); - Ok(()) -} - -/// The font, writing direction and size an ExtGState's `/Font [font size]` entry sets, as `Tf` -/// would (ISO 32000-2 §8.4.5); `None` when `name` sets no font. The metrics are cached with the -/// fonts `Tf` names, under a key no resource name can take (a name cannot hold a null byte). -fn extgstate_font( - doc: &Document, - scope: &mut Scope<'_>, - ext_gstates: &Dict, - name: &[u8], - fallback: &Rc, -) -> Option<(Rc, bool, f64)> { - let state = doc.resolve(ext_gstates.get(name)?).as_dict().cloned()?; - let entry = doc.resolve(state.get(b"Font")?).as_array().cloned()?; - let [font, size] = entry.as_slice() else { return None }; - let size = doc.resolve(size).as_f64()?; - let font_dict = doc.resolve(font).as_dict().cloned(); - let vertical = font_dict.as_ref().is_some_and(|d| is_vertical(doc, d)); - let key = [b"\0".as_slice(), name].concat(); - let metrics = match scope.fonts.get(&key) { - Some(m) => m.clone(), - None => { - let m = font_dict.as_ref().map(|d| Rc::new(Metrics::from_dict(doc, d))).unwrap_or_else(|| fallback.clone()); - scope.fonts.insert(key, m.clone()); - m - } - }; - Some((metrics, vertical, size)) -} - /// One glyph of a shown string. struct Glyph { bytes: std::ops::Range, @@ -799,32 +242,21 @@ pub(crate) fn process(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec< /// [`process`] from graphics state `gs`: a form XObject starts with its caller's, text state /// included (ISO 32000-2 §8.10.1), so text it shows without setting a font of its own is placed /// with the caller's font, size and spacing, as the renderer places it. -fn process_from(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec], resources: &Dict, mut gs: Gs) -> Output { - let mut out = Output::default(); - let fallback = Rc::new(Metrics::fallback()); - let mut stack: Vec = Vec::new(); - let (mut tm, mut tlm) = (Matrix::IDENTITY, Matrix::IDENTITY); - let fonts_res = res_dict(doc, resources, b"Font"); - let ext_gstates = res_dict(doc, resources, b"ExtGState"); - let mut xobjects = res_dict(doc, resources, b"XObject"); - // The current path: its operators, its bounding box in user space, and whether it clips. - let mut path: Vec = Vec::new(); - let mut path_box: Option<[f64; 4]> = None; - let mut clip = false; - let mut forced_change = false; - +fn process_from(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec], resources: &Dict, gs: Gs) -> Output { + let mut walk = Walk::new(doc, resources, gs); // The streams are one content stream in pieces, which may be split between any two tokens // (ISO 32000-2 §7.8.2): an operator's operands can end one piece and the operator start the // next. Parse them joined, then give each operator back to the piece its keyword is in. let joined = Pieces::join(streams); let mut parsed = joined.parse(); - out.ambiguous = parsed.ambiguous_images > 0; + walk.out.ambiguous = parsed.ambiguous_images > 0; // A rewritten piece loses its comments, so keep where they began and ended: a comment that // ran on into the next piece still hides that piece's first line. let comment_edges = joined.comment_edges(&parsed); // Layer removal also changes enclosing replacement spans, so remember the operators before // any cleanup, not just the survivors of the layer pass. let original = parsed.ops.clone(); + let mut forced_change = false; if scope.layers_enabled { let props = res_dict(doc, resources, b"Properties"); let (kept, removed) = strip_hidden_layers(doc, scope, parsed.ops, &props); @@ -834,12 +266,32 @@ fn process_from(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec], forced_change = true; } } - let mut pieces: Vec> = streams.iter().map(|_| Vec::new()).collect(); - let mut rewrite: Vec = vec![forced_change; streams.len()]; - for op in parsed.ops { + let (pieces, rewrite) = split_into_pieces(&joined, parsed.ops, streams.len(), forced_change); + let mut processed = Vec::new(); + for (piece, changed) in pieces.into_iter().zip(rewrite) { + let mut done = Piece { ops: Vec::with_capacity(piece.len()), changed }; + for op in piece { + walk.step(doc, scope, resources, op, &mut done); + } + if !walk.path.is_empty() { + done.ops.append(&mut walk.path); + } + processed.push((done.ops, done.changed)); + } + if scope.mode == Mode::Apply { + clean_replacements(doc, resources, &original, &mut processed, scope.report); + } + walk.out.streams = processed.into_iter().zip(comment_edges).map(|((ops, changed), edges)| changed.then(|| piece_bytes(&ops, edges))).collect(); + walk.out +} + +/// Gives each operator to the piece its keyword is in. An operator split across pieces is written +/// whole into its keyword's piece, so every piece it spans is rewritten. +fn split_into_pieces(joined: &Pieces, ops: Vec, count: usize, forced_change: bool) -> (Vec>, Vec) { + let mut pieces: Vec> = (0..count).map(|_| Vec::new()).collect(); + let mut rewrite: Vec = vec![forced_change; count]; + for op in ops { let (first, last) = joined.pieces_of(&op); - // An operator split across pieces is written whole into its keyword's piece, so every - // piece it spans is rewritten. if first < last { for r in rewrite.iter_mut().take(last + 1).skip(first) { *r = true; @@ -849,228 +301,297 @@ fn process_from(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec], p.push(op); } } + (pieces, rewrite) +} - let mut processed = Vec::new(); - for (piece, mut changed) in pieces.into_iter().zip(rewrite) { - let mut ops: Vec = Vec::with_capacity(piece.len()); - for op in piece { - let o = op.op.as_slice(); - // Path construction. - if matches!(o, b"m" | b"l" | b"c" | b"v" | b"y" | b"h" | b"re" | b"W" | b"W*") { - let pts: Vec<(f64, f64)> = match o { - b"re" => op.nums::<4>().map(|[x, y, w, h]| vec![(x, y), (x + w, y), (x, y + h), (x + w, y + h)]).unwrap_or_default(), - b"h" | b"W" | b"W*" => Vec::new(), - _ => op.operands.iter().filter_map(Object::as_f64).collect::>().as_chunks::<2>().0.iter().map(|p| (p[0], p[1])).collect(), - }; - for (x, y) in pts { - let (u, v) = gs.ctm.apply(x, y); - path_box = Some(match path_box { - None => [u, v, u, v], - Some(b) => [b[0].min(u), b[1].min(v), b[2].max(u), b[3].max(v)], - }); +/// A rewritten piece's bytes. `(comment_in, comment_out)` say whether the piece before ended +/// inside a comment, and whether this one did. +fn piece_bytes(ops: &[Op], (comment_in, comment_out): (bool, bool)) -> Vec { + // The piece before still ends inside a comment: end it before the first operator. + let mut bytes = if comment_in { b"\n".to_vec() } else { Vec::new() }; + bytes.extend(serialize_ops(ops)); + // The piece ended inside a comment that hid the start of the next one: keep it open. + if comment_out { + bytes.push(b'%'); + } + bytes +} + +/// The operators written for one piece of a scope's content, and whether they differ from the source. +struct Piece { + ops: Vec, + changed: bool, +} + +/// The interpreter state carried across the operators of a scope. +struct Walk { + gs: Gs, + stack: Vec, + tm: Matrix, + tlm: Matrix, + fonts_res: Dict, + ext_gstates: Dict, + xobjects: Dict, + fallback: Rc, + /// The current path: its operators, its bounding box in user space, and whether it clips. + path: Vec, + path_box: Option<[f64; 4]>, + clip: bool, + out: Output, +} + +impl Walk { + fn new(doc: &Document, resources: &Dict, gs: Gs) -> Self { + Self { + gs, + stack: Vec::new(), + tm: Matrix::IDENTITY, + tlm: Matrix::IDENTITY, + fonts_res: res_dict(doc, resources, b"Font"), + ext_gstates: res_dict(doc, resources, b"ExtGState"), + xobjects: res_dict(doc, resources, b"XObject"), + fallback: Rc::new(Metrics::fallback()), + path: Vec::new(), + path_box: None, + clip: false, + out: Output::default(), + } + } + + /// Interprets one operator, writing what replaces it to `piece`. + fn step(&mut self, doc: &mut Document, scope: &mut Scope<'_>, resources: &Dict, op: Op, piece: &mut Piece) { + let o = op.op.as_slice(); + if matches!(o, b"m" | b"l" | b"c" | b"v" | b"y" | b"h" | b"re" | b"W" | b"W*") { + self.construct_path(op); + return; + } + if matches!(o, b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" | b"n") { + self.paint_path(scope, op, piece); + return; + } + if !self.path.is_empty() { + // A path not ended by a painting operator: pass it through. + piece.ops.append(&mut self.path); + self.path_box = None; + self.clip = false; + } + let kept = match o { + b"q" | b"Q" | b"cm" | b"Tf" | b"gs" => { + self.graphics_state(doc, scope, &op); + Some(op) + } + b"BT" | b"Tc" | b"Tw" | b"Tz" | b"TL" | b"Ts" | b"Tr" | b"Td" | b"TD" | b"Tm" | b"T*" => { + self.text_state(&op); + Some(op) + } + b"Tj" | b"TJ" | b"'" | b"\"" => self.show_text(scope, op, piece), + b"BI" => Self::inline_image(&self.gs, scope, &mut self.out, op, piece), + b"sh" if scope.mode == Mode::Apply => Self::shading(&self.gs, scope, op, piece), + b"Do" => self.draw_xobject(doc, scope, resources, op, piece), + _ => Some(op), + }; + piece.ops.extend(kept); + } + + /// Path construction: collects the operator and grows the path's bounding box. + fn construct_path(&mut self, op: Op) { + let o = op.op.as_slice(); + let pts: Vec<(f64, f64)> = match o { + b"re" => op.nums::<4>().map(|[x, y, w, h]| vec![(x, y), (x + w, y), (x, y + h), (x + w, y + h)]).unwrap_or_default(), + b"h" | b"W" | b"W*" => Vec::new(), + _ => op.operands.iter().filter_map(Object::as_f64).collect::>().as_chunks::<2>().0.iter().map(|p| (p[0], p[1])).collect(), + }; + for (x, y) in pts { + let (u, v) = self.gs.ctm.apply(x, y); + self.path_box = Some(match self.path_box { + None => [u, v, u, v], + Some(b) => [b[0].min(u), b[1].min(v), b[2].max(u), b[3].max(v)], + }); + } + self.clip |= matches!(o, b"W" | b"W*"); + self.path.push(op); + } + + /// A painting operator ends the path; one under a region is removed (or kept as a clip). + fn paint_path(&mut self, scope: &mut Scope<'_>, op: Op, piece: &mut Piece) { + let body = std::mem::take(&mut self.path); + let bbox = self.path_box.take(); + let clips = std::mem::replace(&mut self.clip, false); + let hit = scope.mode == Mode::Apply && op.op.as_slice() != b"n" && bbox.is_some_and(|b| scope.hits(b)); + if !hit { + piece.ops.extend(body); + piece.ops.push(op); + return; + } + let b = bbox.unwrap_or_default(); + piece.changed = true; + scope.report.paths_removed += 1; + if scope.covered(b) && clips { + // Keep the clipping, drop the painting. + piece.ops.extend(body); + piece.ops.push(Op::new("n", vec![])); + } + // Partial vector redaction must not retain underlying drawing commands + // behind clipping (F-008). Refuse partial clip retention and remove the path. + } + + fn graphics_state(&mut self, doc: &Document, scope: &mut Scope<'_>, op: &Op) { + match op.op.as_slice() { + b"q" => self.stack.push(self.gs.clone()), + b"Q" => { + if let Some(g) = self.stack.pop() { + self.gs = g; } - clip |= matches!(o, b"W" | b"W*"); - path.push(op); - continue; } - if matches!(o, b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" | b"n") { - let body = std::mem::take(&mut path); - let bbox = path_box.take(); - let clips = std::mem::replace(&mut clip, false); - let hit = scope.mode == Mode::Apply && o != b"n" && bbox.is_some_and(|b| scope.hits(b)); - if !hit { - ops.extend(body); - ops.push(op); - continue; + b"cm" => { + if let Some(m) = op.nums::<6>() { + self.gs.ctm = Matrix(m).then(&self.gs.ctm); } - let b = bbox.unwrap_or_default(); - changed = true; - scope.report.paths_removed += 1; - if scope.covered(b) && clips { - // Keep the clipping, drop the painting. - ops.extend(body); - ops.push(Op::new("n", vec![])); + } + b"Tf" => self.select_font(doc, scope, op), + _ => { + if let Some((font, vertical, size)) = op.name(0).and_then(|n| extgstate_font(doc, scope, &self.ext_gstates, n, &self.fallback)) { + (self.gs.font, self.gs.vertical, self.gs.size) = (font, vertical, size); } - // Partial vector redaction must not retain underlying drawing commands - // behind clipping (F-008). Refuse partial clip retention and remove the path. - continue; } - if !path.is_empty() { - // A path not ended by a painting operator: pass it through. - ops.append(&mut path); - path_box = None; - clip = false; + } + } + + fn select_font(&mut self, doc: &Document, scope: &mut Scope<'_>, op: &Op) { + self.gs.size = op.num(1).unwrap_or(self.gs.size); + let Some(name) = op.name(0) else { return }; + let font_dict = self.fonts_res.get(name).and_then(|f| doc.resolve(f).as_dict().cloned()); + self.gs.vertical = font_dict.as_ref().is_some_and(|d| is_vertical(doc, d)); + self.gs.font = match scope.fonts.get(name) { + Some(f) => f.clone(), + None => { + let m = font_dict.as_ref().map_or_else(|| self.fallback.clone(), |d| Rc::new(Metrics::from_dict(doc, d))); + scope.fonts.insert(name.to_vec(), m.clone()); + m } - match o { - b"q" => stack.push(gs.clone()), - b"Q" => { - if let Some(g) = stack.pop() { - gs = g; - } - } - b"cm" => { - if let Some(m) = op.nums::<6>() { - gs.ctm = Matrix(m).then(&gs.ctm); - } - } - b"BT" => { - tm = Matrix::IDENTITY; - tlm = Matrix::IDENTITY; - } - b"Tf" => { - gs.size = op.num(1).unwrap_or(gs.size); - if let Some(name) = op.name(0) { - let font_dict = fonts_res.get(name).and_then(|f| doc.resolve(f).as_dict().cloned()); - gs.vertical = font_dict.as_ref().is_some_and(|d| is_vertical(doc, d)); - gs.font = match scope.fonts.get(name) { - Some(f) => f.clone(), - None => { - let m = font_dict.as_ref().map(|d| Rc::new(Metrics::from_dict(doc, d))).unwrap_or_else(|| fallback.clone()); - scope.fonts.insert(name.to_vec(), m.clone()); - m - } - }; - } - } - b"gs" => { - if let Some((font, vertical, size)) = op.name(0).and_then(|n| extgstate_font(doc, scope, &ext_gstates, n, &fallback)) { - (gs.font, gs.vertical, gs.size) = (font, vertical, size); - } - } - b"Tc" => gs.char_spacing = op.num(0).unwrap_or(0.0), - b"Tw" => gs.word_spacing = op.num(0).unwrap_or(0.0), - b"Tz" => gs.scale = op.num(0).unwrap_or(100.0) / 100.0, - b"TL" => gs.leading = op.num(0).unwrap_or(0.0), - b"Ts" => gs.rise = op.num(0).unwrap_or(0.0), - b"Tr" => gs.render_mode = op.num(0).unwrap_or(0.0) as i64, - b"Td" | b"TD" => { - if let Some([x, y]) = op.nums::<2>() { - if o == b"TD" { - gs.leading = -y; - } - tlm = Matrix::translate(x, y).then(&tlm); - tm = tlm; - } - } - b"Tm" => { - if let Some(m) = op.nums::<6>() { - tlm = Matrix(m); - tm = tlm; - } - } - b"T*" => { - tlm = Matrix::translate(0.0, -gs.leading).then(&tlm); - tm = tlm; - } - b"Tj" | b"TJ" | b"'" | b"\"" => { - if o == b"\"" { - gs.word_spacing = op.num(0).unwrap_or(gs.word_spacing); - gs.char_spacing = op.num(1).unwrap_or(gs.char_spacing); - } - if matches!(o, b"'" | b"\"") { - tlm = Matrix::translate(0.0, -gs.leading).then(&tlm); - tm = tlm; - } - let items: Vec = match o { - // A surplus array (`[(A)] [(B)] TJ`) is malformed, but the operator still - // takes the one immediately before it, as the renderer and every other - // reader here do (#3569), so a redaction search matches what is drawn. - b"TJ" => op.operands.last().and_then(Object::as_array).cloned().unwrap_or_default(), - _ => op.operands.last().cloned().into_iter().collect(), - }; - // Vertical-writing text is laid out horizontally by `show`, so its glyphs are - // never matched against a region. Rather than ship a document whose vertical - // text was silently left under a mark, record it so apply can fail closed. - // (Full vertical-text removal is a future enhancement.) - if gs.vertical - && scope.mode == Mode::Apply - && scope.hidden_text.is_none() - && !scope.rects.is_empty() - && items.iter().any(|o| matches!(o, Object::String(s) if !s.bytes.is_empty())) - { - out.vertical = true; - } - let (new_items, removed) = show(scope, &gs, &mut tm, &items); - if removed == 0 { - ops.push(op); - continue; - } - if scope.mode == Mode::Verify { - out.residue += removed; - ops.push(op); - continue; - } - changed = true; - scope.report.glyphs += removed; - match o { - b"\"" => { - ops.push(Op::new("Tw", vec![num(gs.word_spacing)])); - ops.push(Op::new("Tc", vec![num(gs.char_spacing)])); - ops.push(Op::new("T*", vec![])); - } - b"'" => ops.push(Op::new("T*", vec![])), - _ => {} - } - ops.push(Op::new("TJ", vec![Object::Array(new_items)])); - continue; - } - b"BI" => { - let b = gs.ctm.bbox([0.0, 0.0, 1.0, 1.0]); - if scope.hits(b) { - if scope.mode == Mode::Verify { - out.residue += 1; - } else { - scope.report.images_removed += 1; - changed = true; - continue; - } - } - } - b"sh" if scope.mode == Mode::Apply => { - // A shading fills the current clip: clip the regions out. - if let Some(c) = clip_out(scope.rects, &gs.ctm, [-1e6, -1e6, 1e6, 1e6]) { - changed = true; - ops.push(Op::new("q", vec![])); - ops.extend(c); - ops.push(op); - ops.push(Op::new("Q", vec![])); - continue; + }; + } + + /// Text state and text-positioning operators. + fn text_state(&mut self, op: &Op) { + let gs = &mut self.gs; + match op.op.as_slice() { + b"BT" => { + self.tm = Matrix::IDENTITY; + self.tlm = Matrix::IDENTITY; + } + b"Tc" => gs.char_spacing = op.num(0).unwrap_or(0.0), + b"Tw" => gs.word_spacing = op.num(0).unwrap_or(0.0), + b"Tz" => gs.scale = op.num(0).unwrap_or(100.0) / 100.0, + b"TL" => gs.leading = op.num(0).unwrap_or(0.0), + b"Ts" => gs.rise = op.num(0).unwrap_or(0.0), + b"Tr" => gs.render_mode = op.num(0).unwrap_or(0.0) as i64, + b"Td" | b"TD" => { + if let Some([x, y]) = op.nums::<2>() { + if op.is("TD") { + gs.leading = -y; } + self.tlm = Matrix::translate(x, y).then(&self.tlm); + self.tm = self.tlm; } - b"Do" => { - if let Some(new) = xobject(doc, scope, &op, &gs, resources, &mut xobjects, &mut out) { - changed = true; - if let Some(n) = new { - ops.push(n); - } - continue; - } + } + b"Tm" => { + if let Some(m) = op.nums::<6>() { + self.tlm = Matrix(m); + self.tm = self.tlm; } - _ => {} } - ops.push(op); + _ => { + self.tlm = Matrix::translate(0.0, -gs.leading).then(&self.tlm); + self.tm = self.tlm; + } + } + } + + /// `Tj`, `TJ`, `'` and `"`: glyphs under a region are cut out (or counted, when verifying). + fn show_text(&mut self, scope: &mut Scope<'_>, op: Op, piece: &mut Piece) -> Option { + let o = op.op.as_slice(); + if o == b"\"" { + self.gs.word_spacing = op.num(0).unwrap_or(self.gs.word_spacing); + self.gs.char_spacing = op.num(1).unwrap_or(self.gs.char_spacing); + } + if matches!(o, b"'" | b"\"") { + self.tlm = Matrix::translate(0.0, -self.gs.leading).then(&self.tlm); + self.tm = self.tlm; + } + let items: Vec = match o { + // A surplus array (`[(A)] [(B)] TJ`) is malformed, but the operator still + // takes the one immediately before it, as the renderer and every other + // reader here do (#3569), so a redaction search matches what is drawn. + b"TJ" => op.operands.last().and_then(Object::as_array).cloned().unwrap_or_default(), + _ => op.operands.last().cloned().into_iter().collect(), + }; + // Vertical-writing text is laid out horizontally by `show`, so its glyphs are + // never matched against a region. Rather than ship a document whose vertical + // text was silently left under a mark, record it so apply can fail closed. + // (Full vertical-text removal is a future enhancement.) + if self.gs.vertical + && scope.mode == Mode::Apply + && scope.hidden_text.is_none() + && !scope.rects.is_empty() + && items.iter().any(|o| matches!(o, Object::String(s) if !s.bytes.is_empty())) + { + self.out.vertical = true; } - if !path.is_empty() { - ops.append(&mut path); + let (new_items, removed) = show(scope, &self.gs, &mut self.tm, &items); + if removed == 0 { + return Some(op); } - processed.push((ops, changed)); + if scope.mode == Mode::Verify { + self.out.residue += removed; + return Some(op); + } + piece.changed = true; + scope.report.glyphs += removed; + match op.op.as_slice() { + b"\"" => { + piece.ops.push(Op::new("Tw", vec![num(self.gs.word_spacing)])); + piece.ops.push(Op::new("Tc", vec![num(self.gs.char_spacing)])); + piece.ops.push(Op::new("T*", vec![])); + } + b"'" => piece.ops.push(Op::new("T*", vec![])), + _ => {} + } + piece.ops.push(Op::new("TJ", vec![Object::Array(new_items)])); + None } - if scope.mode == Mode::Apply { - clean_replacements(doc, resources, &original, &mut processed, scope.report); + + /// An inline image under a region is removed (or counted, when verifying). + fn inline_image(gs: &Gs, scope: &mut Scope<'_>, out: &mut Output, op: Op, piece: &mut Piece) -> Option { + if !scope.hits(gs.ctm.bbox([0.0, 0.0, 1.0, 1.0])) { + return Some(op); + } + if scope.mode == Mode::Verify { + out.residue += 1; + return Some(op); + } + scope.report.images_removed += 1; + piece.changed = true; + None } - for ((ops, changed), (comment_in, comment_out)) in processed.into_iter().zip(comment_edges) { - out.streams.push(changed.then(|| { - // The piece before still ends inside a comment: end it before the first operator. - let mut bytes = if comment_in { b"\n".to_vec() } else { Vec::new() }; - bytes.extend(serialize_ops(&ops)); - // The piece ended inside a comment that hid the start of the next one: keep it open. - if comment_out { - bytes.push(b'%'); - } - bytes - })); + + /// A shading fills the current clip: the regions are clipped out. + fn shading(gs: &Gs, scope: &Scope<'_>, op: Op, piece: &mut Piece) -> Option { + let Some(c) = clip_out(scope.rects, &gs.ctm, [-1e6, -1e6, 1e6, 1e6]) else { return Some(op) }; + piece.changed = true; + piece.ops.push(Op::new("q", vec![])); + piece.ops.extend(c); + piece.ops.push(op); + piece.ops.push(Op::new("Q", vec![])); + None + } + + fn draw_xobject(&mut self, doc: &mut Document, scope: &mut Scope<'_>, resources: &Dict, op: Op, piece: &mut Piece) -> Option { + let Some(new) = xobject(doc, scope, &op, &self.gs, resources, &mut self.xobjects, &mut self.out) else { return Some(op) }; + piece.changed = true; + piece.ops.extend(new); + None } - out } fn clean_replacements(doc: &Document, resources: &Dict, original: &[Op], processed: &mut [(Vec, bool)], report: &mut Report) { @@ -1386,45 +907,3 @@ fn xobject( _ => None, } } - -/// Remove marked-content blocks of hidden layers (`/OC /name BDC … EMC`, nested blocks -/// included). Returns the remaining operators and how many blocks went. -fn strip_hidden_layers(doc: &Document, scope: &mut Scope<'_>, ops: Vec, props: &Dict) -> (Vec, usize) { - let mut out = Vec::with_capacity(ops.len()); - // For each open marked-content block: does it hide its contents? - let mut stack: Vec = Vec::new(); - let mut removed = 0; - for op in ops { - let hidden_now = stack.last().copied().unwrap_or(false); - match op.op.as_slice() { - b"BDC" | b"BMC" => { - let this = op.is("BDC") - && op.name(0) == Some(b"OC") - && match op.operands.get(1) { - Some(Object::Name(n)) => props.get(n).is_some_and(|o| scope.layer_hidden(doc, o)), - Some(o) => scope.layer_hidden(doc, o), - None => false, - }; - if this && !hidden_now { - removed += 1; - } - stack.push(hidden_now || this); - if !(hidden_now || this) { - out.push(op); - } - } - b"EMC" => { - let was = stack.pop().unwrap_or(false); - if !was { - out.push(op); - } - } - _ => { - if !hidden_now { - out.push(op); - } - } - } - } - (out, removed) -} diff --git a/crates/redact/src/interp/layers.rs b/crates/redact/src/interp/layers.rs new file mode 100644 index 0000000000..88d1c4e677 --- /dev/null +++ b/crates/redact/src/interp/layers.rs @@ -0,0 +1,256 @@ +use super::{Dict, Document, HashSet, ObjRef, Object, Op, Scope}; + +impl<'a> Scope<'a> { + /// Is this optional-content reference (an OCG or OCMD) hidden? + fn layer_hidden(&mut self, doc: &Document, o: &Object) -> bool { + if !self.layers_enabled { + return false; + } + let reference = o.as_ref(); + if let Some(r) = reference { + if let Some(hidden) = self.ocmd_cache.get(&r) { + return *hidden; + } + if self.hidden_layers.contains(&r) { + self.ocmd_cache.insert(r, true); + return true; + } + } + // Evaluate the policy against the same default configuration used to select hidden + // groups. A membership dictionary's default policy is AnyOn. + let d = doc.resolve(o); + let Some(d) = d.as_dict() else { return false }; + if d.name(b"Type") != Some(b"OCMD") { + return false; + } + let groups: Vec = match d.get(b"OCGs").map(|g| (*doc.resolve(g)).clone()) { + Some(Object::Array(a)) => a.iter().filter_map(Object::as_ref).collect(), + Some(_) => d.get(b"OCGs").and_then(Object::as_ref).into_iter().collect(), + None => Vec::new(), + }; + let fail_closed = || groups.iter().any(|g| self.hidden_layers.contains(g)); + let hidden = if let Some(expression) = d.get(b"VE") { + let visible = self.visibility_expression(doc, expression, 0, &mut HashSet::new(), &mut 4096); + match visible { + Some(visible) => !visible, + None => fail_closed() || self.expression_mentions_hidden(doc, expression, 0, &mut HashSet::new(), &mut 4096), + } + } else if groups.is_empty() { + false + } else { + let any_on = groups.iter().any(|g| !self.hidden_layers.contains(g)); + let all_on = groups.iter().all(|g| !self.hidden_layers.contains(g)); + let policy = d.get(b"P").map(|p| doc.resolve(p)).and_then(|p| p.as_name().map(|name| name.to_vec())); + match (d.get(b"P"), policy.as_deref()) { + (None, _) | (Some(_), Some(b"AnyOn")) => !any_on, + (Some(_), Some(b"AllOn")) => !all_on, + (Some(_), Some(b"AnyOff")) => all_on, + (Some(_), Some(b"AllOff")) => any_on, + // An invalid policy must not make a candidate hidden block survive sanitization. + _ => fail_closed(), + } + }; + if !hidden { + self.retained_off_layers.extend(groups.iter().filter(|g| self.hidden_layers.contains(g)).copied()); + if let Some(expression) = d.get(b"VE") { + let mut referenced = HashSet::new(); + if Self::collect_hidden_expression_groups(doc, expression, &self.hidden_layers, 0, &mut HashSet::new(), &mut 4096, &mut referenced) { + self.retained_off_layers.extend(referenced); + } else { + // If an expression cannot be fully walked, preserve every off group so that + // the content we kept does not change visibility after configuration cleanup. + self.retained_off_layers.extend(self.hidden_layers.iter().copied()); + } + } + } + if let Some(r) = reference { + self.ocmd_cache.insert(r, hidden); + } + hidden + } + + pub(super) fn xobject_layer_hidden(&mut self, doc: &Document, xobject: ObjRef, oc: &Object) -> bool { + if oc.as_ref().is_some() { + return self.layer_hidden(doc, oc); + } + if let Some(hidden) = self.xobject_oc_cache.get(&xobject) { + return *hidden; + } + let hidden = self.layer_hidden(doc, oc); + self.xobject_oc_cache.insert(xobject, hidden); + hidden + } + + fn collect_hidden_expression_groups( + doc: &Document, + expression: &Object, + hidden_layers: &HashSet, + depth: usize, + path: &mut HashSet, + budget: &mut usize, + found: &mut HashSet, + ) -> bool { + if depth > 64 || *budget == 0 { + return false; + } + *budget -= 1; + let reference = expression.as_ref(); + if let Some(r) = reference + && !path.insert(r) + { + return false; + } + let resolved = doc.resolve(expression); + let complete = match &*resolved { + Object::Dict(d) if d.name(b"Type") == Some(b"OCG") => { + if let Some(r) = reference + && hidden_layers.contains(&r) + { + found.insert(r); + } + reference.is_some() + } + Object::Array(items) => { + let Some(Object::Name(operator)) = items.first() else { return false }; + let operands = match operator.as_slice() { + b"And" | b"Or" if (2..=4097).contains(&items.len()) => items.get(1..), + b"Not" if items.len() == 2 => items.get(1..), + _ => None, + }; + operands.is_some_and(|operands| { + operands.iter().all(|item| Self::collect_hidden_expression_groups(doc, item, hidden_layers, depth + 1, path, budget, found)) + }) + } + _ => false, + }; + if let Some(r) = reference { + path.remove(&r); + } + complete + } + + /// Evaluate an optional-content visibility expression (`/VE`) against the default config. + /// Malformed, cyclic, or excessive expressions return `None` so the caller can fail closed. + fn visibility_expression( + &self, + doc: &Document, + expression: &Object, + depth: usize, + path: &mut HashSet, + budget: &mut usize, + ) -> Option { + if depth > 64 || *budget == 0 { + return None; + } + *budget -= 1; + let reference = expression.as_ref(); + if let Some(r) = reference + && !path.insert(r) + { + return None; + } + let resolved = doc.resolve(expression); + let result = match &*resolved { + Object::Dict(d) if d.name(b"Type") == Some(b"OCG") => reference.map(|r| !self.hidden_layers.contains(&r)), + Object::Array(items) if (2..=4097).contains(&items.len()) => { + let operator = match items.first() { + Some(Object::Name(n)) => n.as_slice(), + _ => return None, + }; + match operator { + b"And" if items.len() >= 2 => { + let mut visible = true; + for item in items.iter().skip(1) { + visible &= self.visibility_expression(doc, item, depth + 1, path, budget)?; + } + Some(visible) + } + b"Or" if items.len() >= 2 => { + let mut visible = false; + for item in items.iter().skip(1) { + visible |= self.visibility_expression(doc, item, depth + 1, path, budget)?; + } + Some(visible) + } + b"Not" if items.len() == 2 => Some(!self.visibility_expression(doc, &items[1], depth + 1, path, budget)?), + _ => None, + } + } + _ => None, + }; + if let Some(r) = reference { + path.remove(&r); + } + result + } + + /// Conservatively find hidden groups in an expression that could not be evaluated. Unknown + /// syntax, cycles, and exhausted budgets count as a possible hidden reference. + fn expression_mentions_hidden(&self, doc: &Document, expression: &Object, depth: usize, path: &mut HashSet, budget: &mut usize) -> bool { + if depth > 64 || *budget == 0 { + return true; + } + *budget -= 1; + if let Some(r) = expression.as_ref() { + if self.hidden_layers.contains(&r) { + return true; + } + if !path.insert(r) { + return true; + } + } + let resolved = doc.resolve(expression); + let found = match &*resolved { + Object::Array(items) => items.iter().any(|item| self.expression_mentions_hidden(doc, item, depth + 1, path, budget)), + Object::Dict(d) if d.name(b"Type") == Some(b"OCG") => expression.as_ref().is_none_or(|r| self.hidden_layers.contains(&r)), + Object::Dict(_) => true, + _ => false, + }; + if let Some(r) = expression.as_ref() { + path.remove(&r); + } + found + } +} + +/// Remove marked-content blocks of hidden layers (`/OC /name BDC … EMC`, nested blocks +/// included). Returns the remaining operators and how many blocks went. +pub(super) fn strip_hidden_layers(doc: &Document, scope: &mut Scope<'_>, ops: Vec, props: &Dict) -> (Vec, usize) { + let mut out = Vec::with_capacity(ops.len()); + // For each open marked-content block: does it hide its contents? + let mut stack: Vec = Vec::new(); + let mut removed = 0; + for op in ops { + let hidden_now = stack.last().copied().unwrap_or(false); + match op.op.as_slice() { + b"BDC" | b"BMC" => { + let this = op.is("BDC") + && op.name(0) == Some(b"OC") + && match op.operands.get(1) { + Some(Object::Name(n)) => props.get(n).is_some_and(|o| scope.layer_hidden(doc, o)), + Some(o) => scope.layer_hidden(doc, o), + None => false, + }; + if this && !hidden_now { + removed += 1; + } + stack.push(hidden_now || this); + if !(hidden_now || this) { + out.push(op); + } + } + b"EMC" => { + let was = stack.pop().unwrap_or(false); + if !was { + out.push(op); + } + } + _ => { + if !hidden_now { + out.push(op); + } + } + } + } + (out, removed) +} diff --git a/crates/redact/src/interp/prune.rs b/crates/redact/src/interp/prune.rs new file mode 100644 index 0000000000..6ca5ea2803 --- /dev/null +++ b/crates/redact/src/interp/prune.rs @@ -0,0 +1,357 @@ +use super::{ + CosError, Dict, Document, HashMap, HashSet, MAX_DEPTH, Matrix, Metrics, ObjRef, Object, Op, Pieces, Rc, RedactError, Scope, is_vertical, num, + overlaps, page_streams, res_dict, +}; + +/// Drop every `replaced` resource name whose `Do` no longer appears in the rewritten `content`, +/// so a full-rewrite save stops reaching the original image or form it named. A name that some +/// surviving `Do` still draws (the same image shown again outside a mark) is kept. +pub(crate) fn prune(xo: &mut Dict, replaced: &[Vec], content: &[Vec]) { + if replaced.is_empty() { + return; + } + let joined = Pieces::join(content); + let ops = joined.parse().ops; + let still_drawn = |n: &[u8]| ops.iter().any(|op| op.is("Do") && op.last_name() == Some(n)); + for n in replaced { + if !still_drawn(n) { + xo.remove(n); + } + } +} + +/// Whether `content` draws the XObject `name` of the resource dictionary `xo`: directly, or +/// through a form that has no /Resources of its own (such a form draws through its caller's). +/// `None` when that cannot be told (a form that will not decode, or nesting past the limit). +fn content_draws(doc: &Document, content: &[Vec], xo: &Dict, name: &[u8], depth: usize, seen: &mut HashSet) -> Option { + let joined = Pieces::join(content); + let ops = joined.parse().ops; + for op in ops.iter().filter(|op| op.is("Do")) { + let Some(n) = op.last_name() else { continue }; + if n == name { + return Some(true); + } + let Some(r) = xo.get(n).and_then(Object::as_ref) else { continue }; + let obj = doc.get(r); + let Object::Stream(s) = &*obj else { continue }; + if s.dict.name(b"Subtype") != Some(b"Form") || s.dict.contains(b"Resources") || !seen.insert(r) { + continue; + } + if depth >= MAX_DEPTH { + return None; + } + let Ok(data) = s.decoded() else { return None }; + match content_draws(doc, std::slice::from_ref(&data), xo, name, depth + 1, seen) { + Some(false) => {} + other => return other, + } + } + Some(false) +} + +/// Unlink the XObjects a rewrite dropped or replaced (`doomed`) from every page-tree resource +/// dictionary that still names them. The rewritten page has its own pruned /Resources, but the +/// originals stay reachable, and so stay in a full-rewrite save, when: +/// - the page inherited its /Resources from a /Pages node, which still lists them; +/// - several pages share one resource dictionary, and another page still refers to it; +/// - a form without /Resources drew them through the page's dictionary. +/// +/// An entry goes unless a page that draws through that dictionary still draws the name (the same +/// image shown on another page, or again outside a mark). A dictionary that loses an entry is +/// replaced by a private copy on its node, so a dictionary object shared with other nodes is +/// left as it is for them. Retired properties are likewise unlinked only when none of the +/// holder's pages or inheriting forms names them. Fails closed when use cannot be determined. +pub(crate) fn unlink_doomed(doc: &mut Document, doomed: &[ObjRef], retired_properties: &[Object]) -> Result<(), RedactError> { + crate::type3::unlink_type3(doc)?; + if doomed.is_empty() && retired_properties.is_empty() { + return Ok(()); + } + let doomed: HashSet = doomed.iter().copied().collect(); + let Some(top) = doc.root().and_then(|root| doc.get(root).as_dict().and_then(|d| d.reference(b"Pages"))) else { return Ok(()) }; + // Every page-tree node with a /Resources of its own, and the pages that draw through it + // (the nearest such node above a page, or the page itself). Cycles are cut as in + // `pdfcraft_model::pages`. + let mut holders: Vec<(ObjRef, Vec)> = Vec::new(); + let mut seen = HashSet::new(); + let mut stack: Vec<(ObjRef, Option)> = vec![(top, None)]; + while let Some((node, mut holder)) = stack.pop() { + if !seen.insert(node) || seen.len() > 1_000_000 { + continue; + } + let obj = doc.get(node); + let Some(d) = obj.as_dict() else { continue }; + if d.contains(b"Resources") { + holders.push((node, Vec::new())); + holder = Some(holders.len() - 1); + } + let is_pages = d.name(b"Type") == Some(b"Pages") || (d.contains(b"Kids") && d.name(b"Type") != Some(b"Page")); + if is_pages { + let kids = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_default(); + stack.extend(kids.iter().filter_map(Object::as_ref).map(|k| (k, holder))); + } else if let Some(h) = holder.and_then(|i| holders.get_mut(i)) { + h.1.push(node); + } + } + let index: HashMap = pdfcraft_model::pages(doc).iter().enumerate().map(|(i, p)| (p.obj, i)).collect(); + let holder_nodes: Vec = holders.iter().map(|(node, _)| *node).collect(); + for (node, users) in holders { + let Some(mut res) = doc.get(node).as_dict().and_then(|d| d.get(b"Resources")).and_then(|r| doc.resolve(r).as_dict().cloned()) else { + continue; + }; + let mut xo = res_dict(doc, &res, b"XObject"); + let names: Vec> = xo.iter().filter(|(_, v)| v.as_ref().is_some_and(|r| doomed.contains(&r))).map(|(n, _)| n.to_vec()).collect(); + let properties = res_dict(doc, &res, b"Properties"); + let property_names: Vec> = properties.iter().filter(|(_, v)| retired_properties.contains(v)).map(|(n, _)| n.to_vec()).collect(); + if names.is_empty() && property_names.is_empty() { + continue; + } + let mut contents = Vec::with_capacity(users.len()); + for user in &users { + let page = doc.get(*user).as_dict().cloned().unwrap_or_default(); + // A page whose content cannot be read may still draw the name: fail closed. + contents.push(page_streams(doc, &page, index.get(user).copied().unwrap_or(0))?.1); + } + let mut pruned = false; + for n in names { + let mut drawn = Some(false); + for content in &contents { + drawn = content_draws(doc, content, &xo, &n, 0, &mut HashSet::new()); + if drawn != Some(false) { + break; + } + } + match drawn { + Some(true) => {} + Some(false) => { + xo.remove(&n); + pruned = true; + } + None => return Err(RedactError::Residue(1)), + } + } + if pruned { + res.set(b"XObject".to_vec(), Object::Dict(xo)); + } + pruned |= prune_retired_properties(doc, &mut res, &property_names, &contents)?; + if pruned { + doc.update_dict(node, |d| d.set(b"Resources".to_vec(), Object::Dict(res)))?; + } + } + // A surviving form can own the same old resource dictionary as a rewritten page. Its + // unused descriptions and doomed XObjects remain reachable too, even after every page + // holder is cleaned. + unlink_from_forms(doc, &holder_nodes, &doomed, retired_properties)?; + Ok(()) +} + +/// Retire only properties removed from a rewritten scope, preserving names still used by any +/// content sharing this holder. Different pages' streams must never be concatenated together. +fn prune_retired_properties(doc: &Document, res: &mut Dict, names: &[Vec], contents: &[Vec>]) -> Result { + if names.is_empty() { + return Ok(false); + } + let mut used = HashSet::new(); + for content in contents { + for op in resource_ops(doc, res, content)? { + if matches!(op.op.as_slice(), b"BDC" | b"DP") + && let Some(name) = op.name(1) + { + used.insert(name.to_vec()); + } + } + } + let mut properties = res_dict(doc, res, b"Properties"); + let mut pruned = false; + for name in names { + if !used.contains(name) { + pruned |= properties.remove(name).is_some(); + } + } + if pruned { + if properties.is_empty() { + res.remove(b"Properties"); + } else { + res.set(b"Properties".to_vec(), Object::Dict(properties)); + } + } + Ok(pruned) +} + +/// Unlink retired properties and `doomed` XObjects from the resources of the forms reachable from +/// the page-tree `holders`, each unless the form's own content (or a form inheriting its +/// resources) still uses the name. Fails closed when that cannot be determined. +fn unlink_from_forms(doc: &mut Document, holders: &[ObjRef], doomed: &HashSet, retired: &[Object]) -> Result<(), CosError> { + if retired.is_empty() && doomed.is_empty() { + return Ok(()); + } + let mut pending: Vec = holders + .iter() + .filter_map(|r| doc.get(*r).as_dict().and_then(|d| d.get(b"Resources")).and_then(|r| doc.resolve(r).as_dict().cloned())) + .collect(); + let mut seen = HashSet::new(); + while let Some(resources) = pending.pop() { + for (_, value) in res_dict(doc, &resources, b"XObject").iter() { + let Some(reference) = value.as_ref() else { continue }; + if !seen.insert(reference) { + continue; + } + if seen.len() > 1_000_000 { + return Err(CosError::Syntax { offset: 0, detail: "redaction could not fully inspect shared form resources".into() }); + } + let object = doc.get(reference); + let Object::Stream(form) = &*object else { continue }; + if form.dict.name(b"Subtype") != Some(b"Form") { + continue; + } + let Some(mut res) = form.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()) else { continue }; + let names: Vec> = + res_dict(doc, &res, b"Properties").iter().filter(|(_, value)| retired.contains(value)).map(|(name, _)| name.to_vec()).collect(); + let mut xo = res_dict(doc, &res, b"XObject"); + let doomed_names: Vec> = + xo.iter().filter(|(_, v)| v.as_ref().is_some_and(|r| doomed.contains(&r))).map(|(n, _)| n.to_vec()).collect(); + if names.is_empty() && doomed_names.is_empty() { + pending.push(res); + continue; + } + let data = form.decoded()?; + let mut pruned = prune_doomed_xobjects(doc, std::slice::from_ref(&data), &mut xo, &doomed_names)?; + if pruned { + res.set(b"XObject".to_vec(), Object::Dict(xo)); + } + pruned |= prune_retired_properties(doc, &mut res, &names, &[vec![data]])?; + if pruned { + doc.update_dict(reference, |d| d.set(b"Resources".to_vec(), Object::Dict(res.clone())))?; + } + pending.push(res); + } + } + Ok(()) +} + +fn prune_doomed_xobjects(doc: &Document, content: &[Vec], xo: &mut Dict, doomed_names: &[Vec]) -> Result { + let mut pruned = false; + for name in doomed_names { + match content_draws(doc, content, xo, name, 0, &mut HashSet::new()) { + Some(true) => {} + Some(false) => pruned |= xo.remove(name).is_some(), + None => return Err(CosError::Syntax { offset: 0, detail: "redaction could not fully inspect shared form resources".into() }), + } + } + Ok(pruned) +} + +/// The clip that hides everything inside each region: one even-odd clip per region (a big +/// rectangle with the region cut out), in the current user space. Returns `None` when the CTM +/// can't be inverted (nothing is painted then anyway). +pub(super) fn clip_out(rects: &[[f64; 4]], ctm: &Matrix, around: [f64; 4]) -> Option> { + let inv = ctm.invert()?; + let mut ops = Vec::new(); + let big = [around[0] - 10.0, around[1] - 10.0, around[2] + 10.0, around[3] + 10.0]; + for r in rects.iter().filter(|r| overlaps(**r, around, 0.0)) { + let outer = [(big[0], big[1]), (big[2], big[1]), (big[2], big[3]), (big[0], big[3])]; + let inner = [(r[0], r[1]), (r[0], r[3]), (r[2], r[3]), (r[2], r[1])]; + for poly in [outer, inner] { + for (k, (x, y)) in poly.iter().enumerate() { + let (u, v) = inv.apply(*x, *y); + ops.push(Op::new(if k == 0 { "m" } else { "l" }, vec![num(u), num(v)])); + } + ops.push(Op::new("h", vec![])); + } + ops.push(Op::new("W*", vec![])); + ops.push(Op::new("n", vec![])); + } + Some(ops) +} + +pub(crate) fn resource_ops(doc: &Document, res: &Dict, content: &[Vec]) -> Result, CosError> { + let parsed = Pieces::join(content).parse(); + if parsed.skipped > 0 || parsed.ops.len() > 2_000_000 { + return Err(CosError::Syntax { offset: 0, detail: "redaction could not fully inspect property resources".into() }); + } + let mut ops = parsed.ops; + let xobjects = res_dict(doc, res, b"XObject"); + let mut seen = HashSet::new(); + let mut cursor = 0; + while cursor < ops.len() { + let op = &ops[cursor]; + if op.is("Do") + && let Some(r) = op.last_name().and_then(|n| xobjects.get(n)).and_then(Object::as_ref) + && seen.insert(r) + && let Object::Stream(s) = &*doc.get(r) + && s.dict.name(b"Subtype") == Some(b"Form") + && s.dict.get(b"Resources").is_none_or(|r| doc.resolve(r).as_dict().is_none()) + { + let parsed = pdfcraft_content::parse(&s.decoded()?); + if seen.len() > 2_000_000 || ops.len().saturating_add(parsed.ops.len()) > 2_000_000 || parsed.skipped > 0 { + return Err(CosError::Syntax { offset: 0, detail: "redaction could not fully inspect inherited form resources".into() }); + } + ops.extend(parsed.ops); + } + cursor += 1; + } + Ok(ops) +} + +pub(crate) fn prune_properties(doc: &Document, res: &mut Dict, content: &[Vec]) -> Result, CosError> { + let mut properties = res_dict(doc, res, b"Properties"); + if properties.is_empty() { + return Ok(Vec::new()); + } + let mut retired = Vec::new(); + let ops = resource_ops(doc, res, content)?; + let names: Vec> = properties.iter().map(|(n, _)| n.to_vec()).collect(); + for name in names { + if !ops.iter().any(|op| matches!(op.op.as_slice(), b"BDC" | b"DP") && op.name(1) == Some(&name)) + && let Some(value) = properties.remove(&name) + { + retired.push(value); + } + } + if properties.is_empty() { + res.remove(b"Properties"); + } else { + res.set(b"Properties".to_vec(), Object::Dict(properties)); + } + Ok(retired) +} + +pub(super) fn prune_form_resources(doc: &Document, res: &mut Dict, content: &[Vec]) -> Result<(), CosError> { + let ops = resource_ops(doc, res, content)?; + let mut xo = res_dict(doc, res, b"XObject"); + let names: Vec> = xo.iter().map(|(n, _)| n.to_vec()).collect(); + for name in names { + if !ops.iter().any(|op| op.is("Do") && op.last_name() == Some(&name)) { + xo.remove(&name); + } + } + res.set(b"XObject".to_vec(), Object::Dict(xo)); + Ok(()) +} + +/// The font, writing direction and size an ExtGState's `/Font [font size]` entry sets, as `Tf` +/// would (ISO 32000-2 §8.4.5); `None` when `name` sets no font. The metrics are cached with the +/// fonts `Tf` names, under a key no resource name can take (a name cannot hold a null byte). +pub(super) fn extgstate_font( + doc: &Document, + scope: &mut Scope<'_>, + ext_gstates: &Dict, + name: &[u8], + fallback: &Rc, +) -> Option<(Rc, bool, f64)> { + let state = doc.resolve(ext_gstates.get(name)?).as_dict().cloned()?; + let entry = doc.resolve(state.get(b"Font")?).as_array().cloned()?; + let [font, size] = entry.as_slice() else { return None }; + let size = doc.resolve(size).as_f64()?; + let font_dict = doc.resolve(font).as_dict().cloned(); + let vertical = font_dict.as_ref().is_some_and(|d| is_vertical(doc, d)); + let key = [b"\0".as_slice(), name].concat(); + let metrics = match scope.fonts.get(&key) { + Some(m) => m.clone(), + None => { + let m = font_dict.as_ref().map(|d| Rc::new(Metrics::from_dict(doc, d))).unwrap_or_else(|| fallback.clone()); + scope.fonts.insert(key, m.clone()); + m + } + }; + Some((metrics, vertical, size)) +} diff --git a/crates/redact/src/lib.rs b/crates/redact/src/lib.rs index 6f69151d89..de9dafe7db 100644 --- a/crates/redact/src/lib.rs +++ b/crates/redact/src/lib.rs @@ -409,193 +409,24 @@ fn apply_to(doc: &mut Document, pages: Option<&[usize]>) -> Result, _>>()?; report.pages = by_page.len(); localize_resources(doc, &by_page)?; - let mut doomed_fields: Vec = Vec::new(); - // The original image and form XObjects the rewrite drops or replaces, on any page. - let mut doomed_xobjects: Vec = Vec::new(); - let mut retired_properties = Vec::new(); let widget_owner: Vec<(ObjRef, ObjRef)> = pdfcraft_forms::fields(doc).into_iter().flat_map(|f| f.widgets.iter().map(|w| (w.obj, f.obj)).collect::>()).collect(); let listed_before = listed_annotations(doc); - let mut removed_everywhere: HashSet = HashSet::new(); - + let mut applying = Applying { + report, + doomed_fields: Vec::new(), + doomed_xobjects: Vec::new(), + retired_properties: Vec::new(), + widget_owner, + removed_everywhere: HashSet::new(), + }; for &(pi, page_marks) in &groups { let page = pdfcraft_model::pages(doc).swap_remove(pi); let rects: Vec<[f64; 4]> = page_marks.iter().flat_map(|m| m.rects.iter().copied()).collect(); - - // 1. Content. - let mut budget = pdfcraft_cos::DecodeBudget::page(); - let (list, data) = page_streams_within(doc, &page.dict, pi, &mut budget)?; - let resources = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); - let out = { - let mut scope = Scope::new(&rects, Mode::Apply, &mut report, budget); - process(doc, &mut scope, &data, &resources, pdfcraft_content::Matrix::IDENTITY) - }; - // Fail closed: the interpreter lays vertical-writing text out horizontally, so it cannot - // tell whether those glyphs lie under a mark. Rather than report success and possibly - // leave the text in place, refuse the whole operation (the caller keeps the previous - // document). The error does not claim the text was under the mark. Full vertical-text - // removal is a future enhancement. - if let Some(error) = out.error { - return Err(error.into()); - } - if out.vertical { - return Err(RedactError::VerticalText(pi + 1)); - } - // An inline image the renderer reads no image for (no `EI` it accepts, or parameters it - // can't read): where the page's operators resume after it is a guess, so neither removal - // nor the verify pass can be trusted on this page. - if out.ambiguous { - return Err(RedactError::Unreadable(pi + 1)); - } - doomed_xobjects.extend_from_slice(&out.doomed); - retired_properties.extend(out.retired_properties); - let mut new_list = list.clone(); - let mut changed = false; - for (i, new) in out.streams.into_iter().enumerate() { - let Some(bytes) = new else { continue }; - let mut dict = match &*doc.resolve(&list[i]) { - Object::Stream(s) => s.dict.clone(), - _ => Dict::new(), - }; - dict.remove(b"Length"); - // Edit ▸ Add content keeps an added item's source text in `/PCAdded`; once its glyphs - // are redacted, keeping it would leave the redacted text in the file. The item stays - // as plain drawn content but is no longer editable. - dict.remove(b"PCAdded"); - // A new object: the original may be shared with other pages. - new_list[i] = Object::Ref(doc.add(Object::Stream(Stream::flate(dict, &bytes)))); - changed = true; - } - if changed || !out.xobjects.is_empty() || !out.replaced.is_empty() { - let mut res = resources.clone(); - if !out.xobjects.is_empty() || !out.replaced.is_empty() { - let mut xo = res.get(b"XObject").and_then(|x| doc.resolve(x).as_dict().cloned()).unwrap_or_default(); - // A full save keeps whatever is still referenced: unlink the originals this page no - // longer draws so the removed image or form bytes cannot survive in the saved file. - let page_content: Vec> = new_list - .iter() - .map(|o| match &*doc.resolve(o) { - Object::Stream(s) => s.decoded().unwrap_or_default(), - _ => Vec::new(), - }) - .collect(); - interp::prune(&mut xo, &out.replaced, &page_content); - for (n, r) in &out.xobjects { - xo.set(n.clone(), Object::Ref(*r)); - } - res.set(b"XObject".to_vec(), Object::Dict(xo)); - } - let (_, cleaned_data) = page_streams( - doc, - &{ - let mut cleaned_page = page.dict.clone(); - cleaned_page.set(b"Contents".to_vec(), Object::Array(new_list.clone())); - cleaned_page - }, - pi, - )?; - retired_properties.extend(interp::prune_properties(doc, &mut res, &cleaned_data)?); - type3::prune_type3_from_resources(doc, &mut res, &cleaned_data)?; - doc.update_dict(page.obj, |d| { - d.set(b"Contents".to_vec(), Object::Array(new_list)); - d.set(b"Resources".to_vec(), Object::Dict(res)); - })?; - // Tags must not keep what the page no longer shows. - let after = doc.get(page.obj).as_dict().cloned().unwrap_or_default(); - let (_, new_data) = page_streams(doc, &after, pi)?; - let (changed_ids, empty_ids) = - tags::touched(doc, &resources, pdfcraft_content::Pieces::join(&data).data(), pdfcraft_content::Pieces::join(&new_data).data()); - report.tags += tags::clean(doc, page.obj, None, &changed_ids, &empty_ids)?; - } - - for (old, new, changed, empty) in out.tag_changes { - report.tags += tags::clean(doc, page.obj, Some((old, new)), &changed, &empty)?; - } - - // 2. Annotations: the marks, and whatever lies under them (with their pop-ups). - let annots = annots_of(doc, &doc.get(page.obj).as_dict().cloned().unwrap_or_default()); - let mut removed: Vec = Vec::new(); - for a in &annots { - let Some(r) = a.as_ref() else { continue }; - let obj = doc.get(r); - let Some(d) = obj.as_dict() else { continue }; - let subtype = d.name(b"Subtype").unwrap_or(b""); - if subtype == b"Redact" { - if page_marks.iter().any(|m| m.obj == r) { - removed.push(r); - } - continue; - } - if subtype == b"Popup" { - continue; - } - if rect_of(doc, d.get(b"Rect")).is_some_and(|b| rects.iter().any(|x| pdfcraft_content::overlaps(*x, b, 0.0))) { - removed.push(r); - if subtype == b"Widget" { - if let Some((_, field)) = widget_owner.iter().find(|(w, _)| *w == r) - && !doomed_fields.contains(field) - { - doomed_fields.push(*field); - } - } else { - report.annotations += 1; - } - } - } - // Replies (`/IRT`, ISO 32000-2 §12.5.6.2) to a removed annotation go too, and replies to - // those: a reply placed elsewhere would keep its parent — the comment and the QuadPoints - // of the redacted words — in the file through its `/IRT` (#2659). Each pass removes at - // least one more annotation of this page or stops, so it ends. - let replies: Vec<(ObjRef, ObjRef)> = annots - .iter() - .filter_map(Object::as_ref) - .filter_map(|r| { - let obj = doc.get(r); - let d = obj.as_dict()?; - (d.name(b"Subtype") != Some(b"Widget")).then_some(())?; - Some((r, d.get(b"IRT").and_then(Object::as_ref)?)) - }) - .collect(); - loop { - let before = removed.len(); - for (r, parent) in &replies { - if !removed.contains(r) && removed.contains(parent) { - removed.push(*r); - report.annotations += 1; - } - } - if removed.len() == before { - break; - } - } - removed_everywhere.extend(removed.iter().copied()); - // Pop-ups dropped with their parent below count too: a reply on another page may answer one. - for a in &annots { - let Some(r) = a.as_ref() else { continue }; - if doc.get(r).as_dict().and_then(|d| d.get(b"Parent")).and_then(Object::as_ref).is_some_and(|p| removed.contains(&p)) { - removed_everywhere.insert(r); - } - } - let kept: Vec = annots - .into_iter() - .filter(|a| { - let Some(r) = a.as_ref() else { return true }; - if removed.contains(&r) { - return false; - } - // Pop-ups of removed annotations go too. - let obj = doc.get(r); - !obj.as_dict().and_then(|d| d.get(b"Parent")).and_then(Object::as_ref).is_some_and(|p| removed.contains(&p)) - }) - .collect(); - doc.update_dict(page.obj, |d| { - if kept.is_empty() { - d.remove(b"Annots"); - } else { - d.set(b"Annots".to_vec(), Object::Array(kept)); - } - })?; + redact_page_content(doc, &mut applying, pi, &page, &rects)?; + remove_page_annotations(doc, &mut applying, &page, &rects, page_marks)?; } + let Applying { mut report, doomed_fields, doomed_xobjects, retired_properties, mut removed_everywhere, .. } = applying; // 2b. Replies and pop-ups of a removed annotation can be listed on another page than their // parent: their `/IRT` or `/Parent` would keep the removed comment reachable. @@ -702,6 +533,213 @@ fn apply_to(doc: &mut Document, pages: Option<&[usize]>) -> Result, + /// The original image and form XObjects the rewrite drops or replaces, on any page. + doomed_xobjects: Vec, + retired_properties: Vec, + widget_owner: Vec<(ObjRef, ObjRef)>, + removed_everywhere: HashSet, +} + +/// Step 1 for one page: rewrites its content so nothing readable stays under a mark, and cleans the tags it no longer shows. +fn redact_page_content( + doc: &mut Document, + applying: &mut Applying, + pi: usize, + page: &pdfcraft_model::Page, + rects: &[[f64; 4]], +) -> Result<(), RedactError> { + let Applying { report, doomed_xobjects, retired_properties, .. } = applying; + // 1. Content. + let mut budget = pdfcraft_cos::DecodeBudget::page(); + let (list, data) = page_streams_within(doc, &page.dict, pi, &mut budget)?; + let resources = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); + let out = { + let mut scope = Scope::new(rects, Mode::Apply, report, budget); + process(doc, &mut scope, &data, &resources, pdfcraft_content::Matrix::IDENTITY) + }; + // Fail closed: the interpreter lays vertical-writing text out horizontally, so it cannot + // tell whether those glyphs lie under a mark. Rather than report success and possibly + // leave the text in place, refuse the whole operation (the caller keeps the previous + // document). The error does not claim the text was under the mark. Full vertical-text + // removal is a future enhancement. + if let Some(error) = out.error { + return Err(error.into()); + } + if out.vertical { + return Err(RedactError::VerticalText(pi + 1)); + } + // An inline image the renderer reads no image for (no `EI` it accepts, or parameters it + // can't read): where the page's operators resume after it is a guess, so neither removal + // nor the verify pass can be trusted on this page. + if out.ambiguous { + return Err(RedactError::Unreadable(pi + 1)); + } + doomed_xobjects.extend_from_slice(&out.doomed); + retired_properties.extend(out.retired_properties); + let mut new_list = list.clone(); + let mut changed = false; + for (i, new) in out.streams.into_iter().enumerate() { + let Some(bytes) = new else { continue }; + let mut dict = match &*doc.resolve(&list[i]) { + Object::Stream(s) => s.dict.clone(), + _ => Dict::new(), + }; + dict.remove(b"Length"); + // Edit ▸ Add content keeps an added item's source text in `/PCAdded`; once its glyphs + // are redacted, keeping it would leave the redacted text in the file. The item stays + // as plain drawn content but is no longer editable. + dict.remove(b"PCAdded"); + // A new object: the original may be shared with other pages. + new_list[i] = Object::Ref(doc.add(Object::Stream(Stream::flate(dict, &bytes)))); + changed = true; + } + if changed || !out.xobjects.is_empty() || !out.replaced.is_empty() { + let mut res = resources.clone(); + if !out.xobjects.is_empty() || !out.replaced.is_empty() { + let mut xo = res.get(b"XObject").and_then(|x| doc.resolve(x).as_dict().cloned()).unwrap_or_default(); + // A full save keeps whatever is still referenced: unlink the originals this page no + // longer draws so the removed image or form bytes cannot survive in the saved file. + let page_content: Vec> = new_list + .iter() + .map(|o| match &*doc.resolve(o) { + Object::Stream(s) => s.decoded().unwrap_or_default(), + _ => Vec::new(), + }) + .collect(); + interp::prune(&mut xo, &out.replaced, &page_content); + for (n, r) in &out.xobjects { + xo.set(n.clone(), Object::Ref(*r)); + } + res.set(b"XObject".to_vec(), Object::Dict(xo)); + } + let (_, cleaned_data) = page_streams( + doc, + &{ + let mut cleaned_page = page.dict.clone(); + cleaned_page.set(b"Contents".to_vec(), Object::Array(new_list.clone())); + cleaned_page + }, + pi, + )?; + retired_properties.extend(interp::prune_properties(doc, &mut res, &cleaned_data)?); + type3::prune_type3_from_resources(doc, &mut res, &cleaned_data)?; + doc.update_dict(page.obj, |d| { + d.set(b"Contents".to_vec(), Object::Array(new_list)); + d.set(b"Resources".to_vec(), Object::Dict(res)); + })?; + // Tags must not keep what the page no longer shows. + let after = doc.get(page.obj).as_dict().cloned().unwrap_or_default(); + let (_, new_data) = page_streams(doc, &after, pi)?; + let (changed_ids, empty_ids) = + tags::touched(doc, &resources, pdfcraft_content::Pieces::join(&data).data(), pdfcraft_content::Pieces::join(&new_data).data()); + report.tags += tags::clean(doc, page.obj, None, &changed_ids, &empty_ids)?; + } + + for (old, new, changed, empty) in out.tag_changes { + report.tags += tags::clean(doc, page.obj, Some((old, new)), &changed, &empty)?; + } + Ok(()) +} + +/// Step 2 for one page: the marks, and whatever lies under them (with their pop-ups). +fn remove_page_annotations( + doc: &mut Document, + applying: &mut Applying, + page: &pdfcraft_model::Page, + rects: &[[f64; 4]], + page_marks: &[&Mark], +) -> Result<(), RedactError> { + let Applying { report, doomed_fields, widget_owner, removed_everywhere, .. } = applying; + // 2. Annotations: the marks, and whatever lies under them (with their pop-ups). + let annots = annots_of(doc, &doc.get(page.obj).as_dict().cloned().unwrap_or_default()); + let mut removed: Vec = Vec::new(); + for a in &annots { + let Some(r) = a.as_ref() else { continue }; + let obj = doc.get(r); + let Some(d) = obj.as_dict() else { continue }; + let subtype = d.name(b"Subtype").unwrap_or(b""); + if subtype == b"Redact" { + if page_marks.iter().any(|m| m.obj == r) { + removed.push(r); + } + continue; + } + if subtype == b"Popup" { + continue; + } + if rect_of(doc, d.get(b"Rect")).is_some_and(|b| rects.iter().any(|x| pdfcraft_content::overlaps(*x, b, 0.0))) { + removed.push(r); + if subtype == b"Widget" { + if let Some((_, field)) = widget_owner.iter().find(|(w, _)| *w == r) + && !doomed_fields.contains(field) + { + doomed_fields.push(*field); + } + } else { + report.annotations += 1; + } + } + } + // Replies (`/IRT`, ISO 32000-2 §12.5.6.2) to a removed annotation go too, and replies to + // those: a reply placed elsewhere would keep its parent — the comment and the QuadPoints + // of the redacted words — in the file through its `/IRT` (#2659). Each pass removes at + // least one more annotation of this page or stops, so it ends. + let replies: Vec<(ObjRef, ObjRef)> = annots + .iter() + .filter_map(Object::as_ref) + .filter_map(|r| { + let obj = doc.get(r); + let d = obj.as_dict()?; + (d.name(b"Subtype") != Some(b"Widget")).then_some(())?; + Some((r, d.get(b"IRT").and_then(Object::as_ref)?)) + }) + .collect(); + loop { + let before = removed.len(); + for (r, parent) in &replies { + if !removed.contains(r) && removed.contains(parent) { + removed.push(*r); + report.annotations += 1; + } + } + if removed.len() == before { + break; + } + } + removed_everywhere.extend(removed.iter().copied()); + // Pop-ups dropped with their parent below count too: a reply on another page may answer one. + for a in &annots { + let Some(r) = a.as_ref() else { continue }; + if doc.get(r).as_dict().and_then(|d| d.get(b"Parent")).and_then(Object::as_ref).is_some_and(|p| removed.contains(&p)) { + removed_everywhere.insert(r); + } + } + let kept: Vec = annots + .into_iter() + .filter(|a| { + let Some(r) = a.as_ref() else { return true }; + if removed.contains(&r) { + return false; + } + // Pop-ups of removed annotations go too. + let obj = doc.get(r); + !obj.as_dict().and_then(|d| d.get(b"Parent")).and_then(Object::as_ref).is_some_and(|p| removed.contains(&p)) + }) + .collect(); + doc.update_dict(page.obj, |d| { + if kept.is_empty() { + d.remove(b"Annots"); + } else { + d.set(b"Annots".to_vec(), Object::Array(kept)); + } + })?; + Ok(()) +} + /// Remove redaction marks without applying them (`None` = all), with their replies and pop-ups as /// deleting a comment does: a reply left behind would keep its mark in the file through `/IRT`. /// Returns how many marks went. diff --git a/crates/redact/src/type3.rs b/crates/redact/src/type3.rs index f431dab902..57971571af 100644 --- a/crates/redact/src/type3.rs +++ b/crates/redact/src/type3.rs @@ -178,42 +178,38 @@ fn collect_used_resources_in_stream(data: &[u8], used: &mut UsedInGlyphs) -> Res Ok(()) } -fn prune_type3_font_resources(doc: &Document, font_res: &mut Dict, used: &UsedInGlyphs) -> bool { - let mut changed = false; - - macro_rules! prune_category { - ($cat:expr, $used_set:expr) => { - if let Some(sub_obj) = font_res.get($cat) { - let mut sub = doc.resolve(sub_obj).as_dict().cloned().unwrap_or_default(); - let names: Vec> = sub.iter().map(|(n, _)| n.to_vec()).collect(); - let mut cat_changed = false; - for n in names { - if !$used_set.contains(&n) { - sub.remove(&n); - cat_changed = true; - } - } - if cat_changed { - changed = true; - if sub.is_empty() { - font_res.remove($cat); - } else { - font_res.set($cat.to_vec(), Object::Dict(sub)); - } - } - } - }; - } - - prune_category!(b"XObject", used.xobjects); - prune_category!(b"ColorSpace", used.colorspaces); - prune_category!(b"Pattern", used.patterns); - prune_category!(b"Shading", used.shadings); - prune_category!(b"ExtGState", used.extgstates); - prune_category!(b"Font", used.fonts); - prune_category!(b"Properties", used.properties); +/// Removes the entries of `font_res`'s `category` sub-dictionary that `used` does not name, +/// dropping the sub-dictionary when nothing is left. Whether anything changed. +fn prune_category(doc: &Document, font_res: &mut Dict, category: &[u8], used: &HashSet>) -> bool { + let Some(sub_obj) = font_res.get(category) else { return false }; + let mut sub = doc.resolve(sub_obj).as_dict().cloned().unwrap_or_default(); + let unused: Vec> = sub.iter().map(|(n, _)| n.to_vec()).filter(|n| !used.contains(n)).collect(); + if unused.is_empty() { + return false; + } + for name in &unused { + sub.remove(name); + } + if sub.is_empty() { + font_res.remove(category); + } else { + font_res.set(category.to_vec(), Object::Dict(sub)); + } + true +} - changed +fn prune_type3_font_resources(doc: &Document, font_res: &mut Dict, used: &UsedInGlyphs) -> bool { + let categories: [(&[u8], &HashSet>); 7] = [ + (b"XObject", &used.xobjects), + (b"ColorSpace", &used.colorspaces), + (b"Pattern", &used.patterns), + (b"Shading", &used.shadings), + (b"ExtGState", &used.extgstates), + (b"Font", &used.fonts), + (b"Properties", &used.properties), + ]; + // Every category is pruned, even after an earlier one changed. + categories.into_iter().fold(false, |changed, (category, names)| prune_category(doc, font_res, category, names) | changed) } struct ScanContext<'a> { diff --git a/crates/render/src/inspect.rs b/crates/render/src/inspect.rs index 71b672e547..0cf287b3f1 100644 --- a/crates/render/src/inspect.rs +++ b/crates/render/src/inspect.rs @@ -23,60 +23,27 @@ use pdfcraft_cos::page_labels::{ use crate::OpenError; use crate::structure::{LazyStructure, Structure}; -/// lopdf decodes object and cross-reference streams while it loads, with no limit unless one is -/// set: a few hundred bytes of nested FlateDecode then inflate to gigabytes. Real object and -/// xref streams are far below this. -const LOAD_STREAM_LIMIT: usize = 256 << 20; +mod annotations; +mod core; +mod model; +mod navigation; +mod resources; -/// At most this many `/State` entries of a set-layer-visibility action are read. -const MAX_LAYER_STATE: usize = 1024; - -/// Decoded bytes read from an XFA form's configuration packet (or a whole-XDP `/XFA` stream) to -/// find `dynamicRender`. Designer's packets are a few kilobytes; a whole XDP can hold the template. -const MAX_XFA_CONFIG_BYTES: usize = 16 << 20; - -/// File specifications retained from a remote-Go-To action are deliberately small. -const MAX_REMOTE_FILE_SPEC_BYTES: usize = 4096; -const MAX_REMOTE_DESTINATION_NAME_BYTES: usize = 1024; - -/// Text and geometry `DocInfo::annotations` keeps, as counted by [`retained`]. Past it the rest -/// are not listed: real comments stay far below, many small ones sharing one long string do not. -const MAX_ANNOTATION_BYTES: usize = 64 << 20; - -/// Web-address text the link listing, and name, description and embedded-file key text the -/// attachment listing, keep together. Many links or attachments can share one long string, which -/// each would otherwise copy. Past it, a link to a web address lists as a URI action without its -/// address, an attachment under its generic name without a description, and an embedded file whose -/// key does not fit is not listed. -const MAX_LINK_ATTACHMENT_TEXT_BYTES: usize = 16 << 20; - -/// The bytes a listed annotation keeps. -fn retained(a: &Annotation) -> usize { - let text = [&a.author, &a.contents, &a.modified, &a.name, &a.in_reply_to, &a.state, &a.intent]; - text.into_iter() - .map(|t| t.as_ref().map_or(0, String::len)) - .chain([ - a.subtype.len(), - std::mem::size_of_val(a.quads.as_slice()), - std::mem::size_of_val(a.points.as_slice()), - std::mem::size_of::(), - ]) - .fold(0, usize::saturating_add) -} - -/// At most this many layers are read from all of `/RBGroups` together. -const MAX_LAYER_GROUP_ENTRIES: usize = 4096; - -/// `DocInfo::outline` holds the first this many bookmarks in outline order. Opening a document -/// reads only this window; the rest are listed by page through `pdfcraft_organize`. -const OUTLINE_WINDOW: usize = 10_000; - -/// Deepest level `DocInfo::outline` nests (the top level is 0); deeper bookmarks are left out. -const OUTLINE_DEPTH: usize = 32; - -fn load_options(password: Option<&str>) -> LoadOptions { - LoadOptions { password: password.map(str::to_owned), max_decompressed_size: Some(LOAD_STREAM_LIMIT), ..LoadOptions::default() } -} +#[cfg(test)] +use annotations::{MAX_ANNOTATION_BYTES, MAX_LINK_ATTACHMENT_TEXT_BYTES, MAX_REMOTE_FILE_SPEC_BYTES, retained}; +use core::Inspector; +pub use core::{check_pages, inspect, pretty_date}; +#[cfg(test)] +use core::{inspect_structure, spend}; +pub use model::{ + Annotation, Attachment, AttachmentSource, DestView, Field, FieldKind, FontInfo, Layer, LayerOp, Link, LinkTarget, OutlineItem, PageInfo, + PageTransition, RemoteDestination, TransitionStyle, Xfa, link_replies, thread_tops, +}; +#[cfg(test)] +use navigation::{OUTLINE_DEPTH, OUTLINE_WINDOW}; +pub use resources::attachment_data; +#[cfg(test)] +use resources::{xfa_config_dynamic_render, xfa_packet_text}; #[derive(Clone, Debug, Default)] pub struct DocInfo { @@ -118,4252 +85,5 @@ pub struct DocInfo { pub warnings: Vec, } -/// What kind of XFA form a document has (`/AcroForm /XFA`). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum Xfa { - /// The pages and fields are ordinary PDF; the XFA packets describe the same form (and hold - /// its data, which Acrobat shows in preference to the fields). - Static, - /// The form is laid out from the XFA packets at open (`/NeedsRendering`, or no fields and a - /// configuration that doesn't forbid dynamic rendering): the PDF pages are only a placeholder. - Dynamic, -} - -/// A page's presentation transition and display time (ISO 32000-2 §12.4.4): how the page comes -/// into view in a full-screen presentation, and how long it stays before advancing. Only pages -/// carrying `/Trans` or `/Dur` are listed. Values outside the spec's ranges fall back to the -/// defaults rather than being refused. -#[derive(Clone, Debug, PartialEq)] -pub struct PageTransition { - pub page: usize, - /// `/Trans /S`; `None` when the page has only `/Dur`. - pub style: Option, - /// `/D`: how long the transition takes, in seconds (default 1). - pub duration: f32, - /// `/Dm /H` (true) or `/V` (Split, Blinds). - pub horizontal: bool, - /// `/M /I` (true) or `/O` (Split, Box, Fly). - pub inward: bool, - /// `/Di`: 0, 90, 180, 270 or 315 degrees (Wipe, Glitter, Fly, Cover, Uncover, Push); `None` - /// for `/None` (Fly only). - pub direction: Option, - /// `/SS`: the Fly scale (default 1). - pub scale: f32, - /// `/B`: Fly with an opaque backdrop. - pub opaque: bool, - /// `/Dur`: seconds the page shows before the presentation advances; `None` waits for the - /// presenter. - pub display: Option, -} - -/// The transition styles of ISO 32000-2 table 164 (`/Trans /S`). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum TransitionStyle { - Split, - Blinds, - Box, - Wipe, - Dissolve, - Glitter, - /// `/R`: the next page replaces the old one with no effect (the default). - Replace, - Fly, - Push, - Cover, - Uncover, - Fade, -} - -impl TransitionStyle { - /// The style a `/S` name means; an unknown name is `Replace`, the spec's default. - pub fn from_name(name: &str) -> Self { - match name { - "Split" => Self::Split, - "Blinds" => Self::Blinds, - "Box" => Self::Box, - "Wipe" => Self::Wipe, - "Dissolve" => Self::Dissolve, - "Glitter" => Self::Glitter, - "Fly" => Self::Fly, - "Push" => Self::Push, - "Cover" => Self::Cover, - "Uncover" => Self::Uncover, - "Fade" => Self::Fade, - _ => Self::Replace, - } - } - - /// The name as written in a PDF (`/S`). - pub fn name(self) -> &'static str { - match self { - Self::Split => "Split", - Self::Blinds => "Blinds", - Self::Box => "Box", - Self::Wipe => "Wipe", - Self::Dissolve => "Dissolve", - Self::Glitter => "Glitter", - Self::Replace => "R", - Self::Fly => "Fly", - Self::Push => "Push", - Self::Cover => "Cover", - Self::Uncover => "Uncover", - Self::Fade => "Fade", - } - } -} - -#[derive(Clone, Debug)] -pub struct PageInfo { - /// Displayed size in points, after `/Rotate` and `/UserUnit`. - pub width: f32, - pub height: f32, - /// Page label (`/PageLabels`), falling back to the 1-based page number. - pub label: String, - /// Effective crop box in user space [x0, y0, x1, y1] (the visible region). - pub crop: [f32; 4], - /// Clockwise page rotation in degrees (0, 90, 180, 270). - pub rotation: u16, -} - -impl PageInfo { - /// A point in view space (points, y down, after `/Rotate`; what the text layer uses) → - /// PDF user space. - pub fn view_to_user(&self, x: f32, y: f32) -> [f32; 2] { - let [cx0, cy0, cx1, cy1] = self.crop; - let (a, b) = (x / self.width.max(1e-3), y / self.height.max(1e-3)); - let (u, v) = match self.rotation { - 90 => (b, 1.0 - a), - 180 => (1.0 - a, 1.0 - b), - 270 => (1.0 - b, a), - _ => (a, b), - }; - [cx0 + u * (cx1 - cx0), cy1 - v * (cy1 - cy0)] - } - - /// PDF user space → view space (the inverse of [`Self::view_to_user`]). - pub fn user_to_view(&self, x: f32, y: f32) -> [f32; 2] { - let [cx0, cy0, cx1, cy1] = self.crop; - let (u, v) = ((x - cx0) / (cx1 - cx0).max(1e-3), (cy1 - y) / (cy1 - cy0).max(1e-3)); - let (a, b) = match self.rotation { - 90 => (1.0 - v, u), - 180 => (1.0 - u, 1.0 - v), - 270 => (v, 1.0 - u), - _ => (u, v), - }; - [a * self.width, b * self.height] - } - - /// A point as displayed (points from the page's top-left, y down, before any view rotation) - /// → the `/XYZ` position `[left, top]` in user space that puts it at the window's top-left. - /// Only a coordinate that is given is pinned: a bookmark made at the top of the window - /// leaves the horizontal position alone. On a page turned a quarter, the displayed `y` decides - /// the user `left` and the displayed `x` the user `top` (the inverse of [`Self::dest_fraction`]). - /// A value that is not finite counts as not given. - pub fn xyz_for_view(&self, x: Option, y: Option) -> [Option; 2] { - let (x, y) = (x.filter(|v| v.is_finite()), y.filter(|v| v.is_finite())); - let [ux, uy] = self.view_to_user(x.unwrap_or(0.0), y.unwrap_or(0.0)); - if self.rotation.is_multiple_of(180) { [x.map(|_| ux), y.map(|_| uy)] } else { [y.map(|_| ux), x.map(|_| uy)] } - } - - /// A destination's user-space point (each coordinate possibly unspecified) → the same point - /// as a fraction of the displayed page `[x, y]` (0..1 from its top-left corner, y down), - /// after `/Rotate` and a further clockwise `view_rotation` (0, 90, 180 or 270). On a page - /// turned a quarter, the user `x` decides the displayed `y` and vice versa, so each output - /// is `None` when the input it comes from is (or is not finite). Points off the page are - /// clamped to its edge. - pub fn dest_fraction(&self, x: Option, y: Option, view_rotation: u16) -> [Option; 2] { - let [cx0, cy0, cx1, cy1] = self.crop; - let frac = |v: f32| if v.is_finite() { Some(v.clamp(0.0, 1.0)) } else { None }; - // Unrotated fractions across (u, from the crop's left) and down (v, from its top). - let u = x.and_then(|x| frac((x - cx0) / (cx1 - cx0).max(1e-3))); - let v = y.and_then(|y| frac((cy1 - y) / (cy1 - cy0).max(1e-3))); - let flip = |f: Option| f.map(|f| 1.0 - f); - match (u32::from(self.rotation) + u32::from(view_rotation)) % 360 { - 90 => [flip(v), u], - 180 => [flip(u), flip(v)], - 270 => [v, flip(u)], - _ => [u, v], - } - } - - /// A view-space rectangle → the normalized user-space rectangle covering the same area of the - /// page (`[x0, y0, x1, y1]`, `x0 <= x1`, `y0 <= y1`). Under `/Rotate` the corners swap roles, - /// so a view rectangle's top-left is not in general the user rectangle's `[x0, y1]`. - pub fn view_rect_to_user(&self, r: [f32; 4]) -> [f32; 4] { - let (a, b) = (self.view_to_user(r[0], r[1]), self.view_to_user(r[2], r[3])); - [a[0].min(b[0]), a[1].min(b[1]), a[0].max(b[0]), a[1].max(b[1])] - } - - /// A view-space rectangle as a text-markup quad in user space: top-left, top-right, - /// bottom-left, bottom-right as read on screen. - pub fn view_rect_to_quad(&self, r: [f32; 4]) -> [f64; 8] { - let mut q = [0.0; 8]; - for (i, (x, y)) in [(r[0], r[1]), (r[2], r[1]), (r[0], r[3]), (r[2], r[3])].into_iter().enumerate() { - let [ux, uy] = self.view_to_user(x, y); - q[2 * i] = ux as f64; - q[2 * i + 1] = uy as f64; - } - q - } -} - -/// A clickable link annotation. -#[derive(Clone, Debug)] -pub struct Link { - pub page: usize, - /// Zero-based position in the page's raw `/Annots` array. - pub index: usize, - pub rect: [f32; 4], - pub target: LinkTarget, -} - -#[derive(Clone, Debug, PartialEq)] -pub enum LinkTarget { - /// A destination in this document: the 0-based page and where on it the view goes. - Page(usize, DestView), - Uri(String), - /// A set-layer-visibility action (`SetOCGState`, ISO 32000-2 §12.6.4.13): each change in - /// order, naming the layer by its optional content group. With `preserve_rb`, a layer turned - /// on turns off the other layers of its radio-button groups. - SetLayers { - changes: Vec<(LayerOp, (u32, u16))>, - preserve_rb: bool, - }, - /// A local-file remote Go-To action. The caller resolves `file` relative to the source path. - /// `launch` marks a Launch action that names a PDF file (see `launch_pdf`), opened the same - /// way: as a document in PdfCraft, never by the operating system. - RemoteGoTo { - launch: bool, - file: String, - destination: RemoteDestination, - new_window: Option, - }, - Other(String), -} - -#[derive(Clone, Debug, PartialEq)] -pub enum RemoteDestination { - Default, - Page { page: usize, view: DestView }, - Named(String), -} - -/// What a set-layer-visibility action does to a layer. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum LayerOp { - On, - Off, - Toggle, -} - -/// Where an explicit destination (ISO 32000-2 §12.3.2.2, Table 149) places its page in the -/// window. Coordinates are PDF user space; `None` is a `null` (or unusable) operand, which keeps -/// that part of the view as it is. -#[derive(Clone, Copy, Debug, Default, PartialEq)] -pub enum DestView { - /// The top of the page at the current zoom: a destination with no position, or one whose - /// type is missing or unknown. - #[default] - Top, - /// `/XYZ left top zoom`: the point (left, top) at the window's upper-left corner, at `zoom` - /// (1.0 = 100%; 0 or null keeps the current zoom). - Xyz { left: Option, top: Option, zoom: Option }, - /// `/Fit`, and `/FitB` (fit the page's content box; read as the whole page). - Fit, - /// `/FitH top`, and `/FitBH`: fit the width, with `top` at the window's top edge. - FitH { top: Option }, - /// `/FitV left`, and `/FitBV`: fit the height, with `left` at the window's left edge. - FitV { left: Option }, - /// `/FitR left bottom right top`: fit the rectangle `[x0, y0, x1, y1]` (normalized). - FitR { rect: [f32; 4] }, -} - -impl DestView { - /// The view an explicit destination array asks for, from its elements after the page - /// (`/XYZ left top zoom`, `/FitH top`, …). Hostile input never fails: a missing or unknown - /// type, or a `/FitR` without four usable numbers, is [`DestView::Top`]; a non-numeric, - /// non-finite or missing operand is `None`; a zoom that is not positive is `None`. - pub fn from_operands(kind: Option<&[u8]>, operands: &[Option]) -> Self { - let n = |i: usize| operands.get(i).copied().flatten().filter(|v| v.is_finite()); - match kind { - Some(b"XYZ") => Self::Xyz { left: n(0), top: n(1), zoom: n(2).filter(|z| *z > 0.0) }, - Some(b"Fit" | b"FitB") => Self::Fit, - Some(b"FitH" | b"FitBH") => Self::FitH { top: n(0) }, - Some(b"FitV" | b"FitBV") => Self::FitV { left: n(0) }, - Some(b"FitR") => match (n(0), n(1), n(2), n(3)) { - (Some(x0), Some(y0), Some(x1), Some(y1)) => Self::FitR { rect: [x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1)] }, - _ => Self::Top, - }, - _ => Self::Top, - } - } -} - -#[derive(Clone, Debug)] -pub struct OutlineItem { - pub title: String, - pub page: Option, - /// Where on `page` the bookmark goes ([`DestView::Top`] for a bookmark without a page). - pub view: DestView, - pub children: Vec, - /// Whether the bookmark starts open: only a positive `/Count` says so (ISO 32000-2 §12.3.3, - /// Table 151). A negative one is closed, and so is a missing one, which a bookmark with - /// children should not have. - pub open: bool, - /// The title's colour (`/C`, DeviceRGB, ISO 32000-2 §12.3.3 Table 151), or `None` for the - /// default black or a value that isn't three numbers. - pub color: Option<[f32; 3]>, - /// The title's style (`/F`, Table 152): bit 1 italic, bit 2 bold. - pub italic: bool, - pub bold: bool, -} - -#[derive(Clone, Debug)] -pub struct Annotation { - pub page: usize, - pub subtype: String, - pub author: Option, - pub contents: Option, - pub modified: Option, - /// `/NM` of this annotation, and of the one it replies to (`/IRT`). - pub name: Option, - pub in_reply_to: Option, - /// The page and `/Annots` position of the comment `/IRT` points at, found by object - /// reference. Threads follow this rather than `/NM`, which need not be unique: PyMuPDF - /// names the first comment on every page `fitz-A0`. `None` when `/IRT` leads to no listed - /// comment ([`link_replies`]). - pub parent: Option<(usize, usize)>, - /// `/RT /Group`: grouped with the annotation `/IRT` points at (Replace Text's caret with its - /// strikeout) rather than a reply to it. - pub grouped: bool, - /// Rect in PDF user space: [x0, y0, x1, y1]. - pub rect: [f32; 4], - pub color: Option<[f32; 3]>, - /// Position in the page's `/Annots` (how edits address the comment). - pub index: usize, - /// For a status reply (`/State`, Acrobat's "Set status"): the state it sets. - pub state: Option, - /// Text markup quadrilaterals (`/QuadPoints`), 8 numbers each. - pub quads: Vec<[f32; 8]>, - /// The Locked flag (`/F` bit 8): the comment can't be moved, resized, restyled or deleted. - pub locked: bool, - /// `/IT`, the intent: `FreeTextCallout`, `PolygonCloud`, `FreeTextTypeWriter`… - pub intent: Option, - /// A Fill & Sign mark: typed text, a check, cross, dot or line, or a typed, drawn or picture - /// signature or initials (the rules of `pdfcraft_annot::is_fill_sign`). - pub fill_sign: bool, - /// The NoRotate flag (`/F` bit 5), set on sticky notes and attachments: on a turned page the - /// appearance stays upright, turned about the upper-left corner of `rect` - /// ([`Self::shown_rect`]). - pub no_rotate: bool, - /// The points that shape a line (its two ends, `/L`) or connected lines, a polygon or a - /// cloud (their corners, `/Vertices`), in user space; empty for other comments. - pub points: Vec<[f32; 2]>, -} - -impl Annotation { - /// A checkmark reply (`/StateModel /Marked`): "Marked" or "Unmarked" rather than a review status. - pub fn is_mark(&self) -> bool { - matches!(self.state.as_deref(), Some("Marked" | "Unmarked")) - } - - /// A reply (or status reply) to another comment rather than a comment of its own. - pub fn is_reply(&self) -> bool { - self.parent.is_some() - } - - /// A comment of its own: not a reply, and not a status or checkmark. One whose comment can't - /// be found (deleted from another page, or never listed) has nothing to show, so it is listed - /// nowhere: not as a card, on the page or in a count. - pub fn is_comment(&self) -> bool { - !self.is_reply() && self.state.is_none() - } - - /// Whether this annotation's `/IRT` points at `other`. - pub fn replies_to(&self, other: &Annotation) -> bool { - self.parent == Some((other.page, other.index)) - } - - /// Where the comment is drawn on a page turned by `rotation` (`PageInfo::rotation`), in user - /// space: `rect`, or for a NoRotate comment `rect` turned upright about its upper-left corner - /// (see [`Self::no_rotate`]). Hit-testing, selection and anything placed beside the comment - /// use this, so they match the drawing. - pub fn shown_rect(&self, rotation: u16) -> [f32; 4] { - self.shown(self.rect.map(f64::from), rotation).map(|v| v as f32) - } - - /// [`Self::shown_rect`] for this comment with its `/Rect` at `rect` (one being moved or - /// resized). - pub fn shown(&self, rect: [f64; 4], rotation: u16) -> [f64; 4] { - if self.no_rotate { upright_rect(rect, rotation) } else { rect } - } - - /// The `/Rect` that shows this comment in the user-space box `shown`: the inverse of - /// [`Self::shown`], for a box resized as drawn. - pub fn rect_for_shown(&self, shown: [f64; 4], rotation: u16) -> [f64; 4] { - if self.no_rotate { rect_from_upright(shown, rotation) } else { shown } - } -} - -/// The user-space box a NoRotate annotation with `/Rect` `rect` is drawn in on a page turned -/// clockwise by `rotation` (ISO 32000-2 §12.5.3): `rect` turned as far counterclockwise about its -/// upper-left corner `(x0, y1)`, which stays where it is on the page. As displayed, the box's -/// top-left is that corner, the rectangle's width runs across and its height down, as on an -/// unturned page. `rect` itself for 0 or anything that isn't a quarter turn. The vendored -/// renderer draws the appearance there (hayro-interpret patch 37). -fn upright_rect(rect: [f64; 4], rotation: u16) -> [f64; 4] { - let [x0, y0, x1, y1] = [rect[0].min(rect[2]), rect[1].min(rect[3]), rect[0].max(rect[2]), rect[1].max(rect[3])]; - let (w, h) = (x1 - x0, y1 - y0); - match rotation { - 90 => [x0, y1, x0 + h, y1 + w], - 180 => [x0 - w, y1, x0, y1 + h], - 270 => [x0 - h, y1 - w, x0, y1], - _ => [x0, y0, x1, y1], - } -} - -/// The `/Rect` whose [`upright_rect`] is `shown`: `shown` turned back about the corner that -/// stays put. -fn rect_from_upright(shown: [f64; 4], rotation: u16) -> [f64; 4] { - let [s0, s1, s2, s3] = [shown[0].min(shown[2]), shown[1].min(shown[3]), shown[0].max(shown[2]), shown[1].max(shown[3])]; - let (sw, sh) = (s2 - s0, s3 - s1); - // The upper-left corner of `/Rect` and the rectangle's width and height. - let ((x0, y1), w, h) = match rotation { - 90 => ((s0, s1), sh, sw), - 180 => ((s2, s1), sw, sh), - 270 => ((s2, s3), sh, sw), - _ => return [s0, s1, s2, s3], - }; - [x0, y1 - h, x0 + w, y1] -} - -/// Settle the reply links of a document's annotations: a reply keeps its `parent` only when -/// following the links reaches a comment of its own within 64 steps. An `/IRT` to an unlisted -/// object, a pop-up, the annotation itself or a loop leaves it a comment of its own, so it stays -/// in view instead of hanging off nothing. Both comment readers (inspect, and the engine's list -/// after an edit) run this. -/// -/// Only comments can be parents: both readers leave links, form widgets, pop-ups and other -/// non-markup annotations out of `annotations` (`pdfcraft_annot::is_comment_subtype`), so an -/// `/IRT` to one of those finds no parent here. Pop-ups are skipped again in case a caller's list -/// has them. -pub fn link_replies(annotations: &mut [Annotation]) { - let at: HashMap<(usize, usize), usize> = - annotations.iter().enumerate().filter(|(_, a)| a.subtype != "Popup").map(|(i, a)| ((a.page, a.index), i)).collect(); - let links: Vec> = - annotations.iter().enumerate().map(|(i, a)| a.parent.and_then(|p| at.get(&p).copied()).filter(|&j| j != i)).collect(); - for (i, a) in annotations.iter_mut().enumerate() { - let mut up = links.get(i).copied().flatten(); - let mut settled = false; - for _ in 0..64 { - let Some(j) = up else { break }; - up = links.get(j).copied().flatten(); - if up.is_none() { - settled = true; - break; - } - } - if !settled { - a.parent = None; - } - } -} - -/// The comment each reply's thread starts from, by the reply's page and `/Annots` position: a -/// reply to a reply files under that comment too. Expects links [`link_replies`] settled. -pub fn thread_tops(annotations: &[Annotation]) -> HashMap<(usize, usize), (usize, usize)> { - let parents: HashMap<(usize, usize), (usize, usize)> = annotations.iter().filter_map(|a| Some(((a.page, a.index), a.parent?))).collect(); - parents - .iter() - .map(|(&reply, &first)| { - let mut top = first; - for _ in 0..64 { - let Some(&up) = parents.get(&top) else { break }; - top = up; - } - (reply, top) - }) - .collect() -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum FieldKind { - Text, - CheckBox, - Radio, - PushButton, - Combo, - List, - Signature, - Unknown, -} - -#[derive(Clone, Debug)] -pub struct Field { - pub name: String, - pub kind: FieldKind, - pub value: Option, - pub page: Option, - pub tooltip: Option, - pub has_actions: bool, - /// Rect of the first widget, in user space. - pub rect: Option<[f32; 4]>, -} - -#[derive(Clone, Debug)] -pub struct Layer { - /// Object number and generation of the optional content group. - pub id: (u32, u16), - pub name: String, - pub visible: bool, - /// Shown as the file opens: the default configuration (`/OCProperties /D`). - pub initial: bool, -} - -#[derive(Clone, Debug)] -pub struct Attachment { - pub name: String, - pub description: Option, - pub size: Option, - pub source: AttachmentSource, -} - -/// Where an attachment lives (so its bytes can be fetched on demand). -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum AttachmentSource { - /// Document-level: `/Names /EmbeddedFiles` entry with this key. - Document { key: String }, - /// A FileAttachment annotation: page index and position in the page's `/Annots`. - Annotation { page: usize, index: usize }, -} - -/// A font used by the document (Document Properties ▸ Fonts). -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct FontInfo { - /// BaseFont without the subset tag. - pub name: String, - /// Type1, TrueType, Type0, Type3, MMType1, CIDFontType0/2 … - pub kind: String, - pub embedded: bool, - pub subset: bool, - pub encoding: Option, - /// The face standing in for a program the document does not embed. `None` when the font is - /// embedded (nothing stands in) or when no face was available at all -- `substitution` then - /// says which of the two it is. - pub substitute: Option, - /// Why a face was substituted, as a sentence for whoever has to act on it. `None` only when - /// the document's own glyphs are being shown. - pub substitution: Option, -} - -/// Check that `bytes` still open as the same document: the parser reads them and the page -/// count matches. Content-only edits skip the full [`inspect`] (seconds on very large files) -/// but must still catch an edit that produced something the viewer cannot display, so the -/// caller can roll it back. Never panics: parser crashes are caught and reported. -pub fn check_pages(bytes: Arc>, password: Option<&str>, expected: usize) -> Result<(), OpenError> { - let pdf = catch_unwind(AssertUnwindSafe(|| Pdf::new_with_password(bytes, password.unwrap_or("")))) - .map_err(|p| OpenError::Invalid(format!("the parser crashed: {}", crate::raster::panic_message(&p))))? - .map_err(|e| match e { - LoadPdfError::Decryption(DecryptionError::PasswordProtected) => { - if password.is_some() { - OpenError::WrongPassword - } else { - OpenError::NeedsPassword - } - } - LoadPdfError::Decryption(other) => OpenError::Unsupported(format!("encryption not supported: {other:?}")), - LoadPdfError::Invalid => OpenError::Invalid("no readable page tree or cross-reference data was found".into()), - })?; - let pages = catch_unwind(AssertUnwindSafe(|| pdf.pages().len())) - .map_err(|p| OpenError::Invalid(format!("the page tree could not be read: {}", crate::raster::panic_message(&p))))?; - if pages != expected { - return Err(OpenError::Invalid(format!("the edit changed the page count from {expected} to {pages}"))); - } - Ok(()) -} - -/// Inspect a document. Fails only if the renderer itself cannot open the file (or needs a -/// password). Never panics: parser crashes are caught and reported. -pub fn inspect(bytes: Arc>, password: Option<&str>) -> Result { - let pdf = catch_unwind(AssertUnwindSafe(|| Pdf::new_with_password(bytes.clone(), password.unwrap_or("")))) - .map_err(|p| OpenError::Invalid(format!("the parser crashed: {}", crate::raster::panic_message(&p))))? - .map_err(|e| match e { - LoadPdfError::Decryption(DecryptionError::PasswordProtected) => { - if password.is_some() { - OpenError::WrongPassword - } else { - OpenError::NeedsPassword - } - } - LoadPdfError::Decryption(other) => OpenError::Unsupported(format!("encryption not supported: {other:?}")), - LoadPdfError::Invalid => OpenError::Invalid("no readable page tree or cross-reference data was found".into()), - })?; - let mut info = DocInfo { file_size: bytes.len(), pdf_version: version_label(&format!("{:?}", pdf.version())), ..Default::default() }; - let pages = catch_unwind(AssertUnwindSafe(|| { - let mut out = Vec::new(); - for (i, page) in pdf.pages().iter().enumerate() { - let (w, h) = page.render_dimensions(); - let c = page.intersected_crop_box(); - let rotation = match page.rotation() { - hayro::hayro_syntax::page::Rotation::None => 0, - hayro::hayro_syntax::page::Rotation::Horizontal => 90, - hayro::hayro_syntax::page::Rotation::Flipped => 180, - hayro::hayro_syntax::page::Rotation::FlippedHorizontal => 270, - }; - let crop = [c.x0 as f32, c.y0 as f32, c.x1 as f32, c.y1 as f32]; - // Degenerate boxes still get a usable placeholder size so layout never divides by zero. - let (w, h) = if w.is_finite() && h.is_finite() && w >= 1.0 && h >= 1.0 { (w, h) } else { (612.0, 792.0) }; - out.push(PageInfo { width: w, height: h, label: (i + 1).to_string(), crop, rotation }); - } - out - })); - match pages { - Ok(p) => info.pages = p, - Err(p) => return Err(OpenError::Invalid(format!("the page tree could not be read: {}", crate::raster::panic_message(&p)))), - } - let options = load_options(password); - // Geometry no longer needs the renderer parser. Do not overlap its allocations with - // structural inspection, including the compatibility fallback for damaged files. - drop(pdf); - inspect_structure(&mut info, |tmp| { - // The lazy reader first; lopdf's repair/tolerance path when it can't expose the same - // page tree or an object a panel needs. No partial result is published. - let lazy = catch_unwind(AssertUnwindSafe(|| { - let doc = LazyStructure::new(bytes.clone(), password).ok()?; - let mut t = DocInfo { pages: tmp.pages.clone(), ..Default::default() }; - let inspector = Inspector::new(&doc); - if inspector.page_index.len() != t.pages.len() { - return None; - } - inspector.fill(&mut t); - (!doc.failed()).then_some(t) - })) - .ok() - .flatten(); - if let Some(t) = lazy { - *tmp = t; - return Ok(()); - } - let doc = Document::load_mem_with_options(&bytes, options).map_err(|e| e.to_string())?; - Inspector::new(&doc).fill(tmp); - Ok(()) - }); - if info.pages.is_empty() { - return Err(OpenError::Invalid("the document has no pages".into())); - } - Ok(info) -} - -// Commit auxiliary metadata only after inspection succeeds; renderer geometry is the fallback. -fn inspect_structure(info: &mut DocInfo, fill: impl FnOnce(&mut DocInfo) -> Result<(), String>) { - let structure = catch_unwind(AssertUnwindSafe(|| { - let mut tmp = DocInfo { pages: info.pages.clone(), ..Default::default() }; - fill(&mut tmp)?; - Ok::<_, String>(tmp) - })); - match structure { - Ok(Ok(mut filled)) => { - filled.file_size = info.file_size; - filled.pdf_version = std::mem::take(&mut info.pdf_version); - *info = filled; - } - Ok(Err(e)) => info.warnings.push(format!("Some document structure (bookmarks, comments, fields) could not be read: {e}")), - Err(p) => { - info.warnings.push(format!("Document structure inspection crashed and was skipped: {}", crate::raster::panic_message(&p))); - } - } -} - -struct Inspector<'a> { - doc: &'a dyn Structure, - page_ids: Vec, - page_index: HashMap, - /// Named destinations (`/Names /Dests` tree), keyed by raw string bytes, built once. - /// Looking each name up by walking the tree was O(links × names): minutes on manuals. - named: HashMap, &'a Object>, - /// The page of each annotation (the first page that lists it), built when a field's widget - /// has no `/P`. Searching every page's `/Annots` for each such field was O(fields × - /// annotations): a second on a form of 8,000 fields. - annotation_pages: OnceCell>, -} - -impl<'a> Inspector<'a> { - fn new(doc: &'a dyn Structure) -> Self { - let page_ids = doc.page_ids(); - let page_index = page_ids.iter().copied().enumerate().map(|(index, id)| (id, index)).collect(); - let mut me = Self { doc, page_ids, page_index, named: HashMap::new(), annotation_pages: OnceCell::new() }; - let mut named = HashMap::new(); - if let Some(tree) = - doc.catalog().ok().and_then(|c| c.get(b"Names").ok()).and_then(|o| me.dict(o)).and_then(|n| n.get(b"Dests").ok()).and_then(|o| me.dict(o)) - { - me.name_tree_raw(tree, &mut HashSet::new(), 0, &mut named); - } - me.named = named; - me - } - - fn fill(&self, info: &mut DocInfo) { - // lopdf drops /Encrypt from the trailer once it has decrypted the file. - info.encrypted = self.doc.encrypted(); - if let Some(d) = self.doc.trailer().get(b"Info").ok().and_then(|o| self.dict(o)) { - info.title = self.text(d, b"Title"); - info.author = self.text(d, b"Author"); - info.subject = self.text(d, b"Subject"); - info.keywords = self.text(d, b"Keywords"); - info.creator = self.text(d, b"Creator"); - info.producer = self.text(d, b"Producer"); - const STANDARD: [&[u8]; 9] = - [b"Title", b"Author", b"Subject", b"Keywords", b"Creator", b"Producer", b"CreationDate", b"ModDate", b"Trapped"]; - // The same rule as the engine's list after an edit (`pdfcraft_organize::custom_info`, - // trimmed and without empty values): text strings only, so a name value is not a - // custom property, and the cap counts listed entries. - info.custom = d - .iter() - .filter(|(k, _)| !STANDARD.contains(&k.as_slice())) - .filter_map(|(k, v)| match self.resolve(v) { - Object::String(bytes, _) => { - let v = text_string(bytes).trim_matches(|c: char| c == '\0' || c.is_whitespace()).to_string(); - (!v.is_empty()).then(|| (String::from_utf8_lossy(k).into_owned(), v)) - } - _ => None, - }) - .take(256) - .collect(); - } - let Ok(catalog) = self.doc.catalog() else { return }; - info.tagged = catalog - .get(b"MarkInfo") - .ok() - .and_then(|o| self.dict(o)) - .and_then(|m| m.get(b"Marked").ok()) - // Any value may be written as an indirect reference, `/Marked 9 0 R` included, so the - // flag is resolved before it is read as a boolean (#3671). - .map(|o| self.resolve(o)) - .and_then(|o| o.as_bool().ok()) - .unwrap_or(false); - match self.page_labels(catalog, &mut info.pages) { - Ok(notes) => info.warnings.extend(notes.into_iter().map(|note| format!("Page labels: {note}."))), - Err(reason) => info.warnings.push(format!("Page labels were skipped: {reason}; using physical page numbers.")), - } - if let Some(first) = catalog.get(b"Outlines").ok().and_then(|o| self.dict(o)).and_then(|d| d.get(b"First").ok()) { - (info.outline, info.outline_more) = self.outline_window(first); - } - let mut text_budget = MAX_LINK_ATTACHMENT_TEXT_BYTES; - self.annotations(info, &mut text_budget); - self.transitions(info); - let mut field_seen = HashSet::new(); - if let Some(form) = catalog.get(b"AcroForm").ok().and_then(|o| self.dict(o)) - && let Ok(fields) = form.get(b"Fields").and_then(|o| self.resolve(o).as_array()) - { - for f in fields { - self.field(f, None, &mut info.fields, &mut field_seen, 0); - } - } - // Widgets no `/Fields` entry reaches are fields too (Acrobat and the browsers fill them); - // each is adopted through its topmost unlisted `/Parent`, so a split field stays one. - for &pid in &self.page_ids { - let Ok(page_dict) = self.doc.get_dictionary(pid) else { continue }; - let Ok(Object::Array(annots)) = page_dict.get(b"Annots").map(|o| self.resolve(o)) else { continue }; - for a in annots { - let Some(d) = self.dict(a) else { continue }; - if self.name(d, b"Subtype").as_deref() != Some("Widget") - || (d.get(b"FT").is_err() && d.get(b"T").is_err() && d.get(b"Parent").is_err()) - { - continue; - } - if let Object::Reference(id) = a - && let Some(root) = self.unlisted_field(*id, &field_seen) - { - self.field(&Object::Reference(root), None, &mut info.fields, &mut field_seen, 0); - } - } - } - info.has_javascript |= info.fields.iter().any(|f| f.has_actions); - let needs_rendering = catalog.get(b"NeedsRendering").ok().and_then(|o| self.resolve(o).as_bool().ok()).unwrap_or(false); - if let Some(xfa) = catalog.get(b"AcroForm").ok().and_then(|o| self.dict(o)).and_then(|form| form.get(b"XFA").ok()) { - // A form with no fields is taken to be laid out from its template, unless its - // configuration says it is a static form (#3180): Designer writes - // `forbidden` for one, whose pages are the form. - let static_form = self.xfa_dynamic_render(xfa).as_deref() == Some("forbidden"); - info.xfa = Some(if needs_rendering || (info.fields.is_empty() && !static_form) { Xfa::Dynamic } else { Xfa::Static }); - } - self.layers(catalog, &mut info.layers); - info.layer_groups = self.layer_groups(catalog); - self.fonts(&mut info.fonts); - if let Some(names) = catalog.get(b"Names").ok().and_then(|o| self.dict(o)) { - if let Some(ef) = names.get(b"EmbeddedFiles").ok().and_then(|o| self.dict(o)) { - let mut seen = HashSet::new(); - for (name, spec) in self.name_tree(ef, &mut seen, 0, &mut text_budget) { - // The key is kept a second time when it is also the name. - let fallback = |budget: &mut usize| spend(name.as_bytes(), budget, |_| name.clone()); - info.attachments.push(self.attachment(spec, AttachmentSource::Document { key: name.clone() }, &mut text_budget, fallback)); - } - } - info.has_javascript |= names.get(b"JavaScript").is_ok(); - } - if let Ok(action) = catalog.get(b"OpenAction") - && let Some(dict) = self.dict(action) - && dict.get(b"S").ok().and_then(|o| self.resolve(o).as_name().ok()) == Some(&b"JavaScript"[..]) - { - info.has_javascript = true; - } - info.open_dest = catalog.get(b"OpenAction").ok().and_then(|o| self.open_dest(o)); - } - - /// The destination `/OpenAction` opens at (ISO 32000-2 §7.7.2 Table 29): an explicit - /// destination array, a `/GoTo` action's `/D`, or (leniently, since Table 29 asks for an array - /// or an action) a destination name or string; a name or string is looked up as a named - /// destination (§12.3.2.4). Any other action (a script, a remote go-to) has none here, and - /// neither has a page number past the end of the document. - fn open_dest(&self, action: &Object) -> Option<(usize, DestView)> { - let dest = match self.resolve(action) { - Object::Dictionary(d) => { - if d.get(b"S").ok().and_then(|o| self.resolve(o).as_name().ok()) != Some(&b"GoTo"[..]) { - return None; - } - d.get(b"D").ok()? - } - other => other, - }; - self.dest(dest, 0).filter(|(page, _)| *page < self.page_ids.len()) - } - - // ── object helpers ────────────────────────────────────────────────────────────────────── - - /// `/Trans` and `/Dur` of every page (ISO 32000-2 §12.4.4), in page order. Lenient: a value - /// outside the spec's choices takes that entry's default, and a page with neither entry is - /// not listed. Bounded by the page count. - fn transitions(&self, info: &mut DocInfo) { - for (page, &pid) in self.page_ids.iter().enumerate() { - let Ok(d) = self.doc.get_dictionary(pid) else { continue }; - let display = d.get(b"Dur").ok().and_then(|o| self.resolve(o).as_float().ok()).filter(|s| s.is_finite() && *s >= 0.0); - let trans = d.get(b"Trans").ok().and_then(|o| self.dict(o)); - if trans.is_none() && display.is_none() { - continue; - } - let mut t = PageTransition { - page, - style: None, - duration: 1.0, - horizontal: true, - inward: true, - direction: Some(0), - scale: 1.0, - opaque: false, - display, - }; - if let Some(tr) = trans { - t.style = Some(self.name(tr, b"S").map_or(TransitionStyle::Replace, |n| TransitionStyle::from_name(&n))); - let num = |key: &[u8]| tr.get(key).ok().and_then(|o| self.resolve(o).as_float().ok()).filter(|v| v.is_finite()); - t.duration = num(b"D").filter(|d| *d >= 0.0).unwrap_or(1.0); - t.horizontal = self.name(tr, b"Dm").as_deref() != Some("V"); - t.inward = self.name(tr, b"M").as_deref() != Some("O"); - t.direction = match tr.get(b"Di").map(|o| self.resolve(o)) { - Ok(Object::Name(n)) if n.as_slice() == b"None" => None, - Ok(o) => match o.as_i64().ok() { - Some(0) => Some(0), - Some(90) => Some(90), - Some(180) => Some(180), - Some(270) => Some(270), - Some(315) => Some(315), - _ => Some(0), - }, - Err(_) => Some(0), - }; - t.scale = num(b"SS").filter(|s| *s > 0.0).unwrap_or(1.0); - t.opaque = matches!(tr.get(b"B").map(|o| self.resolve(o)), Ok(Object::Boolean(true))); - } - info.transitions.push(t); - } - } - - /// The value of `dynamicRender` in an XFA form's configuration packet (`config`, under - /// `acrobat/acrobat7`): `required` for a dynamic form, `forbidden` for a static one. `/XFA` is - /// either one stream holding the whole XDP or an array of packet names and streams (ISO - /// 32000-1 §12.7.8). Lenient: `None` when the packet is missing, unreadable or over - /// `MAX_XFA_CONFIG_BYTES` decoded, or has no such element. - fn xfa_dynamic_render(&self, xfa: &'a Object) -> Option { - let stream = match self.resolve(xfa) { - Object::Array(items) => { - let at = items.iter().position(|o| matches!(self.resolve(o), Object::String(name, _) if name.as_slice() == b"config"))?; - self.resolve(items.get(at.checked_add(1)?)?) - } - whole => whole, - }; - let stream = stream.as_stream().ok()?; - let bytes = if stream.dict.get(b"Filter").is_ok() { - stream.decompressed_content_with_limit(MAX_XFA_CONFIG_BYTES).ok()? - } else { - stream.content.get(..stream.content.len().min(MAX_XFA_CONFIG_BYTES))?.to_vec() - }; - xfa_config_dynamic_render(&xfa_packet_text(&bytes)) - } - - fn resolve(&self, o: &'a Object) -> &'a Object { - self.doc.dereference(o).map(|(_, o)| o).unwrap_or(o) - } - - /// The object a reference ends at after following any chain of references; `None` for a - /// direct object or a broken reference. - fn resolved_id(&self, o: &Object) -> Option { - self.doc.dereference(o).ok().and_then(|(id, _)| id) - } - - fn dict(&self, o: &'a Object) -> Option<&'a Dictionary> { - match self.resolve(o) { - Object::Dictionary(d) => Some(d), - Object::Stream(s) => Some(&s.dict), - _ => None, - } - } - - fn text(&self, d: &Dictionary, key: &[u8]) -> Option { - let s = trimmed_text(self.resolve(d.get(key).ok()?))?; - (!s.is_empty()).then_some(s) - } - - /// [`Self::text`], converted and spent from `budget` as [`spend`] does, so text that trims - /// short or to nothing still spends its raw length. - fn budgeted_text(&self, d: &Dictionary, key: &[u8], budget: &mut usize) -> Option { - let o = self.resolve(d.get(key).ok()?); - let (Object::String(bytes, _) | Object::Name(bytes)) = o else { return None }; - let text = spend(bytes, budget, |_| trimmed_text(o).unwrap_or_default())?; - (!text.is_empty()).then_some(text) - } - - fn name(&self, d: &Dictionary, key: &[u8]) -> Option { - self.resolve(d.get(key).ok()?).as_name().ok().map(|n| String::from_utf8_lossy(n).into_owned()) - } - - /// Whether an annotation is a Fill & Sign mark. Keep in step with - /// `pdfcraft_annot::is_fill_sign`, which the engine's comment list uses. - fn is_fill_sign(&self, d: &Dictionary, subtype: &str) -> bool { - let yes = |key: &[u8]| matches!(d.get(key).map(|o| self.resolve(o)), Ok(Object::Boolean(true))); - if yes(b"PCFillSign") { - return true; - } - match subtype { - "FreeText" => self.name(d, b"IT").as_deref() == Some("FreeTextTypeWriter"), - "Stamp" => match self.name(d, b"Name").as_deref() { - Some("PCCheck" | "PCCross" | "PCDot" | "PCLine" | "PCTypedSignature") => true, - Some("PCCustomSignature" | "PCCustomInitials") => yes(b"PCPictureImage"), - _ => false, - }, - "Ink" => self.text(d, b"Subj").as_deref() == Some("Signature"), - _ => false, - } - } - - fn page_of(&self, o: &Object) -> Option { - match o { - Object::Reference(id) => self.page_index.get(id).copied(), - // Some producers write page *numbers* in remote-style destinations. - Object::Integer(n) => usize::try_from(*n).ok(), - _ => None, - } - } - - // ── destinations ──────────────────────────────────────────────────────────────────────── - - /// The page and view of a destination: an explicit array (`[page /XYZ left top zoom]`, …), - /// a dictionary with `/D`, or a named destination that resolves to one. - fn dest(&self, dest: &Object, depth: u32) -> Option<(usize, DestView)> { - if depth > 8 { - return None; - } - match self.resolve(dest) { - Object::Array(a) => { - let page = a.first().and_then(|p| self.page_of(p))?; - let kind = a.get(1).map(|k| self.resolve(k)).and_then(|k| k.as_name().ok()); - let operands: Vec> = a.iter().skip(2).take(4).map(|o| self.resolve(o).as_float().ok()).collect(); - Some((page, DestView::from_operands(kind, &operands))) - } - Object::Dictionary(d) => d.get(b"D").ok().and_then(|d| self.dest(d, depth + 1)), - Object::String(key, _) | Object::Name(key) => self.named_dest(key).and_then(|d| self.dest(d, depth + 1)), - _ => None, - } - } - - fn named_dest(&self, key: &[u8]) -> Option<&'a Object> { - let catalog = self.doc.catalog().ok()?; - if let Some(v) = self.named.get(key) { - return Some(v); - } - // PDF 1.1 style /Dests dictionary. - catalog.get(b"Dests").ok().and_then(|o| self.dict(o)).and_then(|d| d.get(key).ok()) - } - - /// A name tree's leaves keyed by their raw key bytes (first occurrence wins). - fn name_tree_raw(&self, node: &'a Dictionary, seen: &mut HashSet<*const Dictionary>, depth: u32, out: &mut HashMap, &'a Object>) { - if depth > 32 || !seen.insert(node as *const _) { - return; - } - if let Ok(Object::Array(pairs)) = node.get(b"Names").map(|o| self.resolve(o)) { - for pair in pairs.chunks(2) { - if let [k, v] = pair { - let key = match self.resolve(k) { - Object::String(s, _) => s.clone(), - Object::Name(n) => n.clone(), - _ => continue, - }; - out.entry(key).or_insert(v); - } - } - } - if let Ok(Object::Array(kids)) = node.get(b"Kids").map(|o| self.resolve(o)) { - for k in kids { - if let Some(d) = self.dict(k) { - self.name_tree_raw(d, seen, depth + 1, out); - } - } - } - } - - /// The entries of an embedded-files name tree. Each string key is spent from `text_budget` - /// (see [`MAX_LINK_ATTACHMENT_TEXT_BYTES`]), and an entry whose key does not fit is left out. - fn name_tree( - &self, - node: &'a Dictionary, - seen: &mut HashSet<*const Dictionary>, - depth: u32, - text_budget: &mut usize, - ) -> Vec<(String, &'a Object)> { - let mut out = Vec::new(); - if depth > 32 || !seen.insert(node as *const _) { - return out; - } - if let Ok(Object::Array(pairs)) = node.get(b"Names").map(|o| self.resolve(o)) { - for pair in pairs.chunks(2) { - if let [k, v] = pair { - let key = match self.resolve(k) { - Object::String(bytes, _) => match spend(bytes, text_budget, text_string) { - Some(key) => key, - None => continue, - }, - _ => String::new(), - }; - out.push((key, v)); - } - } - } - if let Ok(Object::Array(kids)) = node.get(b"Kids").map(|o| self.resolve(o)) { - for k in kids { - if let Some(d) = self.dict(k) { - out.extend(self.name_tree(d, seen, depth + 1, text_budget)); - } - } - } - out - } - - /// The first entry of an embedded-files name tree whose key is `key`, in the order - /// [`Self::name_tree`] lists them, with no key budget. It keeps no key, and converts a key - /// object or `/Names` array that several places reference only once (a second visit cannot - /// match, since the first would have returned). - fn name_tree_find(&self, node: &'a Dictionary, key: &str, seen: &mut NameTreeSeen, depth: u32) -> Option<&'a Object> { - if depth > 32 || !seen.nodes.insert(node as *const _) { - return None; - } - let names = node.get(b"Names").ok(); - if let Some(names) = names - && self.resolved_id(names).is_none_or(|id| seen.objects.insert(id)) - && let Object::Array(pairs) = self.resolve(names) - { - for pair in pairs.chunks(2) { - if let [k, v] = pair { - if self.resolved_id(k).is_some_and(|id| !seen.objects.insert(id)) { - continue; - } - let found = match self.resolve(k) { - Object::String(bytes, _) => text_string(bytes) == key, - _ => key.is_empty(), - }; - if found { - return Some(v); - } - } - } - } - if let Ok(Object::Array(kids)) = node.get(b"Kids").map(|o| self.resolve(o)) { - for k in kids { - if let Some(d) = self.dict(k) - && let Some(v) = self.name_tree_find(d, key, seen, depth + 1) - { - return Some(v); - } - } - } - None - } - - // ── outline ───────────────────────────────────────────────────────────────────────────── - - /// The window `DocInfo::outline` keeps: the first `OUTLINE_WINDOW` bookmarks in outline order, - /// nested no deeper than `OUTLINE_DEPTH`, and whether any bookmark was left out. Iterative, and - /// each bookmark is read once, so the work and memory stay within the window. - fn outline_window(&self, first: &'a Object) -> (Vec, bool) { - let mut seen = HashSet::new(); - // The listed bookmarks in outline order, with their depth. - let mut listed: Vec<(usize, OutlineItem)> = Vec::new(); - // One entry per open level: its next sibling, or `None` once that run of siblings has ended. - let mut levels: Vec> = vec![Some(first)]; - let mut more = false; - while let Some(depth) = levels.len().checked_sub(1) { - let Some(slot) = levels.get_mut(depth) else { break }; - let Some(o) = slot.take() else { - levels.pop(); - continue; - }; - if let Object::Reference(id) = o - && !seen.insert(*id) - { - continue; // cycle: this run of siblings ends here - } - let Some(d) = self.dict(o) else { continue }; - if listed.len() >= OUTLINE_WINDOW { - more = true; - break; - } - *slot = d.get(b"Next").ok(); - let dest = d - .get(b"Dest") - .ok() - .and_then(|dest| self.dest(dest, 0)) - .or_else(|| d.get(b"A").ok().and_then(|a| self.dict(a)).and_then(|a| a.get(b"D").ok()).and_then(|dest| self.dest(dest, 0))); - let (page, view) = dest.map_or((None, DestView::Top), |(p, v)| (Some(p), v)); - match d.get(b"First").ok() { - Some(child) if depth < OUTLINE_DEPTH => levels.push(Some(child)), - Some(_) => more = true, - None => {} - } - let open = d.get(b"Count").ok().and_then(|c| c.as_i64().ok()).is_some_and(|c| c > 0); - let title = self.text(d, b"Title").unwrap_or_default(); - let color = match d.get(b"C").map(|o| self.resolve(o)) { - Ok(Object::Array(c)) => outline_color(c.iter().map(|v| self.resolve(v).as_float().ok())), - _ => None, - }; - let flags = d.get(b"F").ok().and_then(|f| self.resolve(f).as_i64().ok()).unwrap_or(0); - let (italic, bold) = (flags & 1 != 0, flags & 2 != 0); - listed.push((depth, OutlineItem { title, page, view, children: Vec::new(), open, color, italic, bold })); - } - (nest_outline(listed), more) - } - - // ── page labels (ISO 32000-2 §12.4.2) ─────────────────────────────────────────────────── - - fn label_object(&self, object: &'a Object) -> Result<&'a Object, &'static str> { - self.doc.dereference(object).map(|(_, o)| o).map_err(|_| "a number tree reference is missing or cyclic") - } - - fn label_dict(&self, object: &'a Object) -> Result, &'static str> { - Ok(match self.label_object(object)? { - Object::Dictionary(d) => Some(d), - Object::Stream(s) => Some(&s.dict), - _ => None, - }) - } - - /// Apply `/PageLabels` to `pages`. `Err`: the tree is unusable and no label is applied. - /// `Ok(notes)`: labels applied, with what was repaired on the way (a duplicated start page, - /// a range whose labels are too long to show). - fn page_labels(&self, catalog: &Dictionary, pages: &mut [PageInfo]) -> Result, &'static str> { - let mut notes = Vec::new(); - let Ok(tree) = catalog.get(b"PageLabels") else { return Ok(notes) }; - let Some(tree) = self.label_dict(tree)? else { return Ok(notes) }; - let mut ranges: BTreeMap = BTreeMap::new(); - let mut stack = vec![(tree, 0usize)]; - let mut seen = HashSet::new(); - let mut left = MAX_LABEL_TREE_WORK; - while let Some((node, depth)) = stack.pop() { - label_work(&mut left, 1)?; - if depth > MAX_LABEL_TREE_DEPTH { - return Err("the number tree is too deep"); - } - if !seen.insert(node as *const Dictionary) { - return Err("the number tree repeats a node or contains a cycle"); - } - if let Ok(nums) = node.get(b"Nums") - && let Object::Array(nums) = self.label_object(nums)? - { - label_work(&mut left, nums.len().div_ceil(2))?; - for pair in nums.chunks(2) { - if let [k, v] = pair - && let (Ok(start), Some(d)) = (self.label_object(k)?.as_i64(), self.label_dict(v)?) - && let Ok(start) = usize::try_from(start) - && ranges.insert(start, d).is_some() - { - // The later range wins, as in readers that overwrite. - notes.push(format!("a range starts on page {} twice; the later one is used", start.saturating_add(1))); - } - } - } - if let Ok(kids) = node.get(b"Kids") - && let Object::Array(kids) = self.label_object(kids)? - { - label_work(&mut left, kids.len())?; - if kids.len() > MAX_LABEL_TREE_WORK.saturating_sub(stack.len()) { - return Err("the number tree has too many pending nodes"); - } - if !kids.is_empty() && depth == MAX_LABEL_TREE_DEPTH { - return Err("the number tree is too deep"); - } - for kid in kids { - if let Some(d) = self.label_dict(kid)? { - stack.push((d, depth + 1)); - } - } - } - } - // Decode each bounded prefix once, rather than once for every page in its range. - let mut specs = BTreeMap::new(); - let mut prefix_total = 0usize; - for (start, d) in ranges { - let first = d.get(b"St").ok().and_then(|o| self.resolve(o).as_i64().ok()).unwrap_or(1).max(1) as u64; - let prefix = match d.get(b"P").ok().map(|o| self.resolve(o)) { - Some(Object::String(raw, _)) => { - if raw.len() > MAX_PREFIX_BYTES { - return Err("a raw prefix exceeds 2050 bytes"); - } - // A label prefix is literal text, not trimmed document metadata. - text_string(raw).trim_matches('\0').to_string() - } - Some(Object::Name(raw)) => { - if raw.len() > MAX_PREFIX_BYTES { - return Err("a raw prefix exceeds 2050 bytes"); - } - // Retain the existing name compatibility and boundary-NUL handling. - String::from_utf8_lossy(raw).trim_matches('\0').to_string() - } - _ => String::new(), - }; - if prefix.len() > MAX_LABEL_BYTES { - return Err("a decoded prefix exceeds 1024 UTF-8 bytes"); - } - // A prefix object shared by many ranges is decoded once per range: count every copy. - prefix_total = prefix_total.saturating_add(prefix.len()); - if prefix_total > MAX_PREFIX_TOTAL_BYTES { - return Err("decoded prefixes exceed 4 MiB in total"); - } - let style = d.get(b"S").ok().and_then(|o| self.resolve(o).as_name().ok()); - specs.insert(start, (first, prefix, style)); - } - let mut staged = Vec::new(); - let mut total = 0usize; - for (i, _) in pages.iter().enumerate() { - let Some((&start, (first, prefix, style))) = specs.range(..=i).next_back() else { continue }; - let n = if matches!(*style, Some(b"D" | b"R" | b"r" | b"A" | b"a")) { - let offset = u64::try_from(i - start).map_err(|_| "a page number is too large")?; - first.checked_add(offset).ok_or("a page number overflows")? - } else { - 0 // /St is unused when the label consists only of its prefix. - }; - let remaining = MAX_LABEL_BYTES - prefix.len(); - let number = match *style { - Some(b"D") => Some(n.to_string()), - Some(b"R" | b"r") => roman(n, remaining), - Some(b"A" | b"a") => alpha(n, remaining), - _ => Some(String::new()), - } - .filter(|number| number.len() <= remaining); - // A label too long to show keeps that page's physical number; other ranges stay. - let Some(mut number) = number else { - let note = format!( - "labels from page {} exceed {MAX_LABEL_BYTES} UTF-8 bytes; those pages show their physical page numbers", - start.saturating_add(1) - ); - if !notes.contains(¬e) { - notes.push(note); - } - continue; - }; - if matches!(*style, Some(b"R" | b"A")) { - number.make_ascii_uppercase(); - } - let mut label = prefix.clone(); - label.push_str(&number); - if label.is_empty() { - continue; // Keep the existing physical number for an empty custom label. - } - total = total.checked_add(label.len()).ok_or("the total label size overflows")?; - if total > MAX_LABEL_TOTAL_BYTES { - return Err("custom labels exceed 4 MiB in total"); - } - staged.push((i, label)); - } - // A malformed later range must not leave a partially applied custom label sequence. - for (i, label) in staged { - if let Some(page) = pages.get_mut(i) { - page.label = label; - } - } - Ok(notes) - } - - // ── annotations ───────────────────────────────────────────────────────────────────────── - - /// `text_budget` is what is left of [`MAX_LINK_ATTACHMENT_TEXT_BYTES`]. - fn annotations(&self, info: &mut DocInfo, text_budget: &mut usize) { - let mut pages: Vec<(ObjectId, usize)> = self.page_index.iter().map(|(&id, &page)| (id, page)).collect(); - pages.sort_unstable_by_key(|&(_, page)| page); - // An annotation, or a whole `/Annots` array, that several entries or pages list is on the - // first of them (§12.5.2 allows one page per annotation). Both are told apart by the object - // a reference resolves to, since an indirect object may itself hold a reference. Where each - // annotation is listed is collected before any is read, so a reply's `/IRT` finds its - // parent by reference even when the parent comes later. - let mut seen_arrays = HashSet::new(); - let mut slots: HashMap = HashMap::new(); - let mut entries = Vec::new(); - for (id, page) in pages { - let Ok(page_dict) = self.doc.get_dictionary(id) else { continue }; - if let Ok(annots) = page_dict.get(b"Annots") - && let Some(r) = self.resolved_id(annots) - && !seen_arrays.insert(r) - { - continue; - } - let Ok(Object::Array(annots)) = page_dict.get(b"Annots").map(|o| self.resolve(o)) else { continue }; - for (index, a) in annots.iter().enumerate() { - if let Some(r) = self.resolved_id(a) { - let Entry::Vacant(slot) = slots.entry(r) else { continue }; - slot.insert((page, index)); - } - entries.push((page, index, a)); - } - } - let mut kept = 0usize; - // Once the comment budget is spent, comments are no longer built, but links and attachments - // are still listed. - let mut comments_full = false; - for (page, index, a) in entries { - let Some(d) = self.dict(a) else { continue }; - let Some(subtype) = self.name(d, b"Subtype") else { continue }; - if subtype == "FileAttachment" - && let Ok(fs) = d.get(b"FS") - { - let fallback = |budget: &mut usize| self.budgeted_text(d, b"Contents", budget); - info.attachments.push(self.attachment(fs, AttachmentSource::Annotation { page, index }, text_budget, fallback)); - } - if subtype == "Link" { - if let Some(link) = self.link(page, index, d, text_budget) { - if matches!(&link.target, LinkTarget::Other(s) if s == "JavaScript") { - info.has_javascript = true; - } - info.links.push(link); - } - continue; - } - // Not comments: form widgets, pop-ups and non-markup annotations such as the - // Screen annotation that drives a LaTeX `animate` player (keep in step with - // `pdfcraft_annot::is_comment_subtype`). - if matches!(subtype.as_str(), "Widget" | "Popup" | "Screen" | "Movie" | "RichMedia" | "3D" | "PrinterMark" | "TrapNet" | "Watermark") { - continue; - } - if comments_full { - continue; - } - let rect = rect4(self.resolve(d.get(b"Rect").unwrap_or(&Object::Null))); - let mut color = match d.get(b"C").map(|o| self.resolve(o)) { - Ok(Object::Array(c)) if c.len() == 3 => { - let f = |i: usize| c[i].as_float().unwrap_or(0.0); - Some([f(0), f(1), f(2)]) - } - _ => None, - }; - // A text box's /C is its background; its text colour is in /DA. - if subtype == "FreeText" - && let Some(da) = self.text(d, b"DA") - { - let t: Vec<&str> = da.split_whitespace().collect(); - if let Some(i) = t.iter().position(|x| *x == "rg") - && i >= 3 - { - let f = |k: usize| t[k].parse::().unwrap_or(0.0); - color = Some([f(i - 3), f(i - 2), f(i - 1)]); - } - } - let quads = match d.get(b"QuadPoints").map(|o| self.resolve(o)) { - Ok(Object::Array(q)) => { - let v: Vec = q.iter().map(|o| o.as_float().unwrap_or(0.0)).collect(); - v.as_chunks::<8>().0.to_vec() - } - _ => Vec::new(), - }; - let in_reply_to = d.get(b"IRT").ok().and_then(|o| self.dict(o)).and_then(|p| self.text(p, b"NM")); - let parent = d.get(b"IRT").ok().and_then(|o| self.resolved_id(o)).and_then(|r| slots.get(&r).copied()); - let fill_sign = self.is_fill_sign(d, &subtype); - // Keep in step with `pdfcraft_annot::summaries`. - let key: &[u8] = if subtype == "Line" { b"L" } else { b"Vertices" }; - let points = match d.get(key).map(|o| self.resolve(o)) { - Ok(Object::Array(v)) if matches!(subtype.as_str(), "Line" | "PolyLine" | "Polygon") => { - let v: Vec = v.iter().map(|o| o.as_float().unwrap_or(0.0)).collect(); - let pts: Vec<[f32; 2]> = v.as_chunks::<2>().0.to_vec(); - if subtype == "Line" && pts.len() != 2 { Vec::new() } else { pts } - } - _ => Vec::new(), - }; - let annotation = Annotation { - page, - subtype, - author: self.text(d, b"T"), - // Acrobat ends lines with CR, which the interface would draw as nothing (keep - // in step with `pdfcraft_annot::summaries`). - contents: self.text(d, b"Contents").map(|s| s.replace("\r\n", "\n").replace('\r', "\n")), - modified: self.text(d, b"M").map(|m| pretty_date(&m)), - name: self.text(d, b"NM"), - in_reply_to, - parent, - grouped: self.name(d, b"RT").as_deref() == Some("Group"), - rect, - color, - index, - state: self.text(d, b"State"), - quads, - locked: d.get(b"F").ok().and_then(|f| self.resolve(f).as_i64().ok()).unwrap_or(0) & 128 != 0, - intent: self.name(d, b"IT"), - fill_sign, - no_rotate: d.get(b"F").is_ok_and(|f| draw_flags(self.resolve(f)) & 16 != 0), - points, - }; - kept = kept.saturating_add(retained(&annotation)); - if kept > MAX_ANNOTATION_BYTES { - comments_full = true; - continue; - } - info.annotations.push(annotation); - } - info.annotations.sort_by(|a, b| a.page.cmp(&b.page).then(b.rect[3].total_cmp(&a.rect[3]))); - link_replies(&mut info.annotations); - } - - fn link(&self, page: usize, index: usize, d: &Dictionary, text_budget: &mut usize) -> Option { - let rect = rect4(self.resolve(d.get(b"Rect").ok()?)); - let target = if let Ok(dest) = d.get(b"Dest") { - let (page, view) = self.dest(dest, 0)?; - LinkTarget::Page(page, view) - } else { - let a = d.get(b"A").ok().and_then(|a| self.dict(a))?; - match self.name(a, b"S").as_deref() { - Some("GoTo") => { - let (page, view) = self.dest(a.get(b"D").ok()?, 0)?; - LinkTarget::Page(page, view) - } - Some("URI") => { - let uri = a.get(b"URI").ok().and_then(|u| self.resolve(u).as_str().ok())?; - match spend(uri, text_budget, |b| String::from_utf8_lossy(b).into_owned()) { - Some(uri) => LinkTarget::Uri(uri), - None => LinkTarget::Other("URI".into()), - } - } - Some("SetOCGState") => self.layer_state(a), - Some("GoToR") => self.remote_go_to(a).unwrap_or_else(|| LinkTarget::Other("GoToR".into())), - Some("Launch") => self.launch_pdf(a).unwrap_or_else(|| LinkTarget::Other("Launch".into())), - Some(other) => LinkTarget::Other(other.to_string()), - None => return None, - } - }; - Some(Link { page, index, rect, target }) - } - - fn remote_go_to(&self, action: &Dictionary) -> Option { - let file = self.action_file(action)?; - let destination = match action.get(b"D") { - Err(_) => RemoteDestination::Default, - Ok(o) => match self.resolve(o) { - Object::Array(items) => { - let page = items.first().and_then(|o| self.resolve(o).as_i64().ok()).and_then(|n| usize::try_from(n).ok())?; - let kind = items.get(1).map(|o| self.resolve(o)).and_then(|o| o.as_name().ok()); - let operands: [Option; 4] = std::array::from_fn(|i| items.get(i + 2).and_then(|o| self.resolve(o).as_float().ok())); - RemoteDestination::Page { page, view: DestView::from_operands(kind, &operands) } - } - Object::Name(name) if name.len() <= MAX_REMOTE_DESTINATION_NAME_BYTES => { - let name = String::from_utf8_lossy(name).into_owned(); - if name.is_empty() || name.len() > MAX_REMOTE_DESTINATION_NAME_BYTES { - return None; - } - RemoteDestination::Named(name) - } - Object::String(bytes, _) if bytes.len() <= MAX_REMOTE_DESTINATION_NAME_BYTES => { - let name = text_string(bytes); - if name.is_empty() || name.len() > MAX_REMOTE_DESTINATION_NAME_BYTES { - return None; - } - RemoteDestination::Named(name) - } - _ => return None, - }, - }; - Some(LinkTarget::RemoteGoTo { launch: false, file, destination, new_window: self.new_window(action) }) - } - - /// The non-empty local file an action's `/F` names (a string or a file specification), shared - /// by GoToR and Launch. A URL file system (`/FS /URL`) names no local file. - fn action_file(&self, action: &Dictionary) -> Option { - if self.name(action, b"FS").as_deref() == Some("URL") { - return None; - } - let file = match self.resolve(action.get(b"F").ok()?) { - Object::String(bytes, _) => limited_pdf_string(bytes, MAX_REMOTE_FILE_SPEC_BYTES)?, - Object::Dictionary(spec) => { - if self.name(spec, b"FS").as_deref() == Some("URL") { - return None; - } - spec.get(b"UF") - .ok() - .and_then(|o| self.pdf_text(o, MAX_REMOTE_FILE_SPEC_BYTES)) - .or_else(|| spec.get(b"F").ok().and_then(|o| self.pdf_text(o, MAX_REMOTE_FILE_SPEC_BYTES)))? - } - _ => return None, - }; - (!file.is_empty()).then_some(file) - } - - fn new_window(&self, action: &Dictionary) -> Option { - match action.get(b"NewWindow").map(|o| self.resolve(o)) { - Ok(Object::Boolean(value)) => Some(*value), - _ => None, - } - } - - /// A Launch action (ISO 32000-2 §12.6.4.6, Table 207) whose `/F` names a `.pdf` file, read as - /// the remote Go-To to that file's first page that it amounts to (#4168). Launching anything - /// else, or with the deprecated `/Win` launch parameters (an application, an operation, its - /// arguments), is not followed: a document must not start programs. `\` separators, which - /// Windows writers put in such paths, are read as `/` (§7.11.2.1 separates components with - /// `/`) when the path has no `/` of its own. Launch has no `/D`, so one present (written - /// for GoToR, or junk) is ignored rather than checked as a destination. - fn launch_pdf(&self, action: &Dictionary) -> Option { - if action.has(b"Win") { - return None; - } - let file = self.action_file(action)?; - let new_window = self.new_window(action); - let file = if file.contains('/') { file } else { file.replace('\\', "/") }; - let pdf = file.rsplit('/').next().is_some_and(|name| name.len() > 4 && name.to_ascii_lowercase().ends_with(".pdf")); - pdf.then_some(LinkTarget::RemoteGoTo { launch: true, file, destination: RemoteDestination::Default, new_window }) - } - - fn pdf_text(&self, o: &Object, max_bytes: usize) -> Option { - match self.resolve(o) { - Object::String(bytes, _) => limited_pdf_string(bytes, max_bytes), - _ => None, - } - } - - /// A set-OCG-state action: `/State` is `ON`, `OFF` or `Toggle`, each followed by the groups - /// it applies to; `/PreserveRB` is true unless it is `false`. - fn layer_state(&self, a: &Dictionary) -> LinkTarget { - let mut changes = Vec::new(); - if let Ok(Object::Array(items)) = a.get(b"State").map(|o| self.resolve(o)) { - let mut op = None; - for item in items.iter().take(MAX_LAYER_STATE) { - match item { - Object::Name(n) => { - op = match n.as_slice() { - b"ON" => Some(LayerOp::On), - b"OFF" => Some(LayerOp::Off), - b"Toggle" => Some(LayerOp::Toggle), - _ => None, - } - } - Object::Reference(id) => changes.extend(op.map(|op| (op, *id))), - _ => {} - } - } - } - let preserve_rb = !matches!(a.get(b"PreserveRB").map(|o| self.resolve(o)), Ok(Object::Boolean(false))); - LinkTarget::SetLayers { changes, preserve_rb } - } - - // ── form fields ───────────────────────────────────────────────────────────────────────── - - fn field( - &self, - o: &'a Object, - parent: Option<(&str, Option, Option)>, - out: &mut Vec, - seen: &mut HashSet, - depth: u32, - ) { - if depth > 32 { - return; - } - if let Object::Reference(id) = o - && !seen.insert(*id) - { - return; - } - let Some(d) = self.dict(o) else { return }; - let (parent_name, parent_ft, parent_ff) = parent.unwrap_or(("", None, None)); - let partial = self.text(d, b"T"); - let full = match (&partial, parent_name.is_empty()) { - (Some(t), true) => t.clone(), - (Some(t), false) => format!("{parent_name}.{t}"), - (None, _) => parent_name.to_string(), - }; - let ft = self.name(d, b"FT").or(parent_ft); - let ff = d.get(b"Ff").ok().and_then(|x| x.as_i64().ok()).or(parent_ff); - // Non-terminal: has kids that are fields (they carry /T). Widgets-only kids are terminal. - let kids = match d.get(b"Kids").map(|k| self.resolve(k)) { - Ok(Object::Array(k)) => k.as_slice(), - _ => &[], - }; - let field_kids: Vec<_> = kids.iter().filter(|k| self.dict(k).is_some_and(|kd| kd.get(b"T").is_ok())).collect(); - if !field_kids.is_empty() { - for k in field_kids { - self.field(k, Some((&full, ft.clone(), ff)), out, seen, depth + 1); - } - return; - } - let flags = ff.unwrap_or(0); - let kind = match ft.as_deref() { - Some("Tx") => FieldKind::Text, - Some("Btn") if flags & (1 << 16) != 0 => FieldKind::PushButton, - Some("Btn") if flags & (1 << 15) != 0 => FieldKind::Radio, - Some("Btn") => FieldKind::CheckBox, - Some("Ch") if flags & (1 << 17) != 0 => FieldKind::Combo, - Some("Ch") => FieldKind::List, - Some("Sig") => FieldKind::Signature, - _ => FieldKind::Unknown, - }; - let value = match d.get(b"V").map(|v| self.resolve(v)) { - Ok(Object::Name(n)) => Some(String::from_utf8_lossy(n).into_owned()), - Ok(Object::String(bytes, _)) => Some(text_string(bytes)), - Ok(Object::Array(a)) => Some( - a.iter() - .filter_map(|x| match self.resolve(x) { - Object::String(bytes, _) => Some(text_string(bytes)), - _ => None, - }) - .collect::>() - .join(", "), - ), - Ok(Object::Dictionary(_)) if kind == FieldKind::Signature => Some("signed".into()), - _ => None, - }; - // The widget is either this dict (merged field/widget) or its first kid. - let widget = if d.get(b"Rect").is_ok() { Some(d) } else { kids.first().and_then(|k| self.dict(k)) }; - let page = widget.and_then(|w| w.get(b"P").ok()).and_then(|p| self.page_of(p)).or_else(|| self.find_widget_page(o, kids)); - let has_actions = d.get(b"AA").is_ok() || widget.is_some_and(|w| w.get(b"AA").is_ok() || w.get(b"A").is_ok()); - let rect = widget.and_then(|w| w.get(b"Rect").ok()).map(|r| rect4(self.resolve(r))); - out.push(Field { name: full, kind, value, page, tooltip: self.text(d, b"TU"), has_actions, rect }); - } - - /// The first page whose `/Annots` lists the field or one of its kids. - fn find_widget_page(&self, field: &Object, kids: &[Object]) -> Option { - let pages = self.annotation_pages.get_or_init(|| self.annotation_pages()); - std::iter::once(field).chain(kids).filter_map(|o| if let Object::Reference(id) = o { pages.get(id).copied() } else { None }).min() - } - - /// Each annotation listed in a page's `/Annots`, with the first page that lists it. - fn annotation_pages(&self) -> HashMap { - let mut pages = HashMap::new(); - for (idx, &pid) in self.page_ids.iter().enumerate() { - let Some(annots) = self.doc.get_dictionary(pid).ok().and_then(|d| d.get(b"Annots").ok()) else { continue }; - let Ok(annots) = self.resolve(annots).as_array() else { continue }; - for a in annots { - if let Object::Reference(id) = a { - pages.entry(*id).or_insert(idx); - } - } - } - pages - } - - /// The topmost field above widget `id` (itself without a `/Parent`), or `None` when it or an - /// ancestor is already reachable from the `/Fields` tree, or a `/Parent` loop hides the top. - fn unlisted_field(&self, id: ObjectId, seen: &HashSet) -> Option { - let mut top = id; - let mut visited = HashSet::from([id]); - loop { - let o = Object::Reference(top); - let d = self.dict(&o)?; - let parent = match d.get(b"Parent") { - Ok(Object::Reference(p)) => Some(*p), - _ => None, - }; - let Some(p) = parent else { - return if seen.contains(&top) { None } else { Some(top) }; - }; - if !visited.insert(p) || visited.len() > 64 { - return None; - } - if seen.contains(&p) { - return None; - } - top = p; - } - } - - // ── optional content ──────────────────────────────────────────────────────────────────── - - fn layers(&self, catalog: &Dictionary, out: &mut Vec) { - let Some(props) = catalog.get(b"OCProperties").ok().and_then(|o| self.dict(o)) else { return }; - let config = props.get(b"D").ok().and_then(|o| self.dict(o)); - let off: HashSet = config - .and_then(|c| c.get(b"OFF").ok()) - .and_then(|o| self.resolve(o).as_array().ok()) - .map(|a| a.iter().filter_map(|x| x.as_reference().ok()).collect()) - .unwrap_or_default(); - let base_off = config.and_then(|c| self.name(c, b"BaseState")).as_deref() == Some("OFF"); - let on: HashSet = config - .and_then(|c| c.get(b"ON").ok()) - .and_then(|o| self.resolve(o).as_array().ok()) - .map(|a| a.iter().filter_map(|x| x.as_reference().ok()).collect()) - .unwrap_or_default(); - let Ok(Object::Array(ocgs)) = props.get(b"OCGs").map(|o| self.resolve(o)) else { return }; - for g in ocgs { - let Some(d) = self.dict(g) else { continue }; - let id = g.as_reference().ok(); - let visible = match id { - Some(id) if off.contains(&id) => false, - Some(id) if on.contains(&id) => true, - _ => !base_off, - }; - let Some(id) = id else { continue }; - out.push(Layer { id, name: self.text(d, b"Name").unwrap_or_else(|| "Layer".into()), visible, initial: visible }); - } - } - - /// The default configuration's radio-button groups (`/RBGroups`) of two or more layers. - fn layer_groups(&self, catalog: &Dictionary) -> Vec> { - let groups = catalog - .get(b"OCProperties") - .ok() - .and_then(|o| self.dict(o)) - .and_then(|p| p.get(b"D").ok()) - .and_then(|o| self.dict(o)) - .and_then(|c| c.get(b"RBGroups").ok()) - .and_then(|o| self.resolve(o).as_array().ok()); - let mut left = MAX_LAYER_GROUP_ENTRIES; - let mut out = Vec::new(); - for g in groups.into_iter().flatten() { - let Ok(members) = self.resolve(g).as_array() else { continue }; - let group: Vec = members.iter().filter_map(|x| x.as_reference().ok()).take(left).collect(); - left = left.saturating_sub(group.len()); - if group.len() > 1 { - out.push(group); - } - if left == 0 { - break; - } - } - out - } - - fn fonts(&self, out: &mut Vec) { - let mut seen = HashSet::new(); - for &pid in &self.page_ids { - let Ok(fonts) = self.doc.get_page_fonts(pid) else { continue }; - for (_, f) in fonts { - let base = f - .get(b"BaseFont") - .ok() - .and_then(|o| o.as_name().ok()) - .map(|n| String::from_utf8_lossy(n).into_owned()) - .unwrap_or_else(|| "(unnamed)".into()); - let kind = self.name(f, b"Subtype").unwrap_or_default(); - let encoding = match f.get(b"Encoding").map(|o| self.resolve(o)) { - Ok(Object::Name(n)) => Some(String::from_utf8_lossy(n).into_owned()), - Ok(Object::Dictionary(_)) => Some("Custom".into()), - Ok(Object::Stream(_)) => Some("Embedded CMap".into()), - _ => None, - }; - // Embedded: FontFile/2/3 in the descriptor (for Type0, in the descendant font). - let descriptor_of = |d: &'a Dictionary| d.get(b"FontDescriptor").ok().and_then(|o| self.dict(o)); - let descendant = - f.get(b"DescendantFonts").ok().and_then(|o| self.resolve(o).as_array().ok()).and_then(|a| a.first()).and_then(|o| self.dict(o)); - let desc = descriptor_of(f).or_else(|| descendant.and_then(descriptor_of)); - let embedded = - kind == "Type3" || desc.is_some_and(|d| d.get(b"FontFile").is_ok() || d.get(b"FontFile2").is_ok() || d.get(b"FontFile3").is_ok()); - // What the substitution chain needs beyond the name: the descriptor flags, and for - // a composite font the script its descendant declares (ISO 32000-2 9.7.3). - let flags = desc - .and_then(|d| d.get(b"Flags").ok()) - .and_then(|o| self.resolve(o).as_i64().ok()) - .and_then(|n| u32::try_from(n).ok()) - .unwrap_or(0); - let ordering = - descendant.and_then(|d| d.get(b"CIDSystemInfo").ok()).and_then(|o| self.dict(o)).and_then(|c| self.text(c, b"Ordering")); - let subset = base.len() > 7 && base.as_bytes()[6] == b'+' && base[..6].bytes().all(|b| b.is_ascii_uppercase()); - let name = if subset { base[7..].to_string() } else { base }; - let chosen = pdfcraft_fonts::fallback::substitute(&pdfcraft_fonts::fallback::Request { - base_font: &name, - embedded, - flags, - ordering: ordering.as_deref(), - }); - let info = FontInfo { - name, - kind, - embedded, - subset, - encoding, - substitute: chosen.face_name(), - substitution: (!chosen.why.is_faithful()).then(|| chosen.why.note().to_owned()), - }; - if seen.insert((info.name.clone(), info.kind.clone(), info.embedded)) { - out.push(info); - } - } - } - out.sort_by(|a, b| a.name.cmp(&b.name)); - } - - /// The attachment's name is the file specification's, else `fallback`'s, else a generic one. - fn attachment( - &self, - spec: &Object, - source: AttachmentSource, - text_budget: &mut usize, - fallback: impl FnOnce(&mut usize) -> Option, - ) -> Attachment { - let d = self.dict(spec); - let size = - d.and_then(|d| d.get(b"EF").ok()).and_then(|o| self.dict(o)).and_then(|ef| ef.get(b"F").ok().or_else(|| ef.get(b"UF").ok())).and_then( - |f| match self.resolve(f) { - Object::Stream(s) => s - .dict - .get(b"Params") - .ok() - .and_then(|p| self.dict(p)) - .and_then(|p| p.get(b"Size").ok()) - .and_then(|s| s.as_i64().ok()) - .map(|s| s as usize) - .or(Some(s.content.len())), - _ => None, - }, - ); - let display = d - .and_then(|d| self.budgeted_text(d, b"UF", text_budget).or_else(|| self.budgeted_text(d, b"F", text_budget))) - .or_else(|| fallback(text_budget)) - .unwrap_or_else(|| "attachment".into()); - Attachment { name: display, description: d.and_then(|d| self.budgeted_text(d, b"Desc", text_budget)), size, source } - } -} - -/// A text string's value, decoded by the same rules as the engine's object model -/// ([`pdfcraft_cos::PdfString::to_text`]) so the panels and outline editing agree. -fn text_string(bytes: &[u8]) -> String { - pdfcraft_cos::PdfString::literal(bytes).to_text() -} - -/// What [`Inspector::name_tree_find`] has visited: node dictionaries, and the key and `/Names` -/// array objects it reached by reference. -#[derive(Default)] -struct NameTreeSeen { - nodes: HashSet<*const Dictionary>, - objects: HashSet, -} - -/// A string or name value as text, without surrounding NULs and white space. -fn trimmed_text(o: &Object) -> Option { - let s = match o { - Object::String(bytes, _) => text_string(bytes), - Object::Name(n) => String::from_utf8_lossy(n).into_owned(), - _ => return None, - }; - Some(s.trim_matches('\0').trim().to_string()) -} - -/// `convert(bytes)` when the result fits in `budget`, which it then spends by the longer of the raw -/// and converted lengths. Bytes longer than the budget are not converted. A conversion that comes -/// out too long spends the raw length, so a string many entries share is converted only until the -/// budget runs out. -fn spend(bytes: &[u8], budget: &mut usize, convert: impl FnOnce(&[u8]) -> String) -> Option { - if bytes.len() > *budget { - return None; - } - let text = convert(bytes); - if text.len() > *budget { - *budget = budget.saturating_sub(bytes.len()); - return None; - } - // The conversion's work follows the raw length, even when the text it keeps is shorter. - *budget = budget.saturating_sub(text.len().max(bytes.len())); - Some(text) -} - -fn limited_pdf_string(bytes: &[u8], max_bytes: usize) -> Option { - if bytes.len() > max_bytes { - return None; - } - let text = text_string(bytes); - (text.len() <= max_bytes).then_some(text) -} - -/// A bookmark title's colour from its `/C` entries, each kept within 0–1 (ISO 32000-2 §12.3.3, -/// Table 151: "An array of three numbers in the range 0.0 to 1.0, representing the components in -/// the DeviceRGB colour space"). Black, the default, and anything that isn't three numbers give -/// `None`. -pub(crate) fn outline_color(values: impl Iterator>) -> Option<[f32; 3]> { - let v: Vec> = values.take(4).collect(); - let [Some(r), Some(g), Some(b)] = v.as_slice() else { return None }; - // A number past the range, however large, is kept at its edge. - let rgb = [*r, *g, *b].map(|c| c.clamp(0.0, 1.0)); - (rgb != [0.0; 3]).then_some(rgb) -} - -/// Nests a pre-order list of `(depth, item)` into the outline tree. -fn nest_outline(listed: Vec<(usize, OutlineItem)>) -> Vec { - let mut roots = Vec::new(); - // The open ancestors, outermost first; each collects its children until it closes. - let mut open: Vec<(usize, OutlineItem)> = Vec::new(); - for (depth, item) in listed { - while open.last().is_some_and(|(d, _)| *d >= depth) { - close_outline(&mut open, &mut roots); - } - open.push((depth, item)); - } - while !open.is_empty() { - close_outline(&mut open, &mut roots); - } - roots -} - -/// Closes the innermost open bookmark: it joins its parent's children, or the top level. -fn close_outline(open: &mut Vec<(usize, OutlineItem)>, roots: &mut Vec) { - if let Some((_, item)) = open.pop() { - match open.last_mut() { - Some((_, parent)) => parent.children.push(item), - None => roots.push(item), - } - } -} - -/// An annotation's `/F` as the renderer reads it: a real is truncated, and a value that isn't a -/// `u32` counts as no flags. -fn draw_flags(o: &Object) -> u32 { - let f = match o { - Object::Integer(i) => *i, - Object::Real(r) => *r as i64, - _ => 0, - }; - u32::try_from(f).unwrap_or(0) -} - -fn rect4(o: &Object) -> [f32; 4] { - match o { - Object::Array(a) if a.len() == 4 => { - let v: Vec = a.iter().map(|x| x.as_float().unwrap_or(0.0)).collect(); - [v[0].min(v[2]), v[1].min(v[3]), v[0].max(v[2]), v[1].max(v[3])] - } - _ => [0.0; 4], - } -} - -fn label_work(left: &mut usize, count: usize) -> Result<(), &'static str> { - *left = left.checked_sub(count).ok_or("the number tree has too many entries")?; - Ok(()) -} - -/// `D:20260930104512-04'00'` → `2026-09-30 10:45`. -/// A PDF date (`D:20261001123000Z`) as "2026-10-01 12:30"; other strings unchanged. -pub fn pretty_date(s: &str) -> String { - let d = s.trim_start_matches("D:"); - if let Some(d) = d.get(..12).filter(|d| d.bytes().all(|b| b.is_ascii_digit())) { - // Twelve ASCII digits make every slice below a UTF-8 character boundary. - format!("{}-{}-{} {}:{}", &d[0..4], &d[4..6], &d[6..8], &d[8..10], &d[10..12]) - } else { - s.to_string() - } -} - -/// Fetch an attachment's bytes (decoded). Capped at 1 GiB to defuse decompression bombs. -pub fn attachment_data(bytes: &[u8], password: Option<&str>, att: &Attachment) -> Result, String> { - let run = || -> Result, String> { - let options = load_options(password); - let doc = Document::load_mem_with_options(bytes, options).map_err(|e| e.to_string())?; - let insp = Inspector::new(&doc); - let spec: &Object = match &att.source { - AttachmentSource::Document { key } => { - let names = doc.catalog().ok().and_then(|c| c.get(b"Names").ok()).and_then(|o| insp.dict(o)).ok_or("no /Names")?; - let ef = names.get(b"EmbeddedFiles").ok().and_then(|o| insp.dict(o)).ok_or("no /EmbeddedFiles")?; - insp.name_tree_find(ef, key, &mut NameTreeSeen::default(), 0).ok_or("attachment not found")? - } - AttachmentSource::Annotation { page, index } => { - let (&pid, _) = insp.page_index.iter().find(|(_, i)| **i == *page).ok_or("page not found")?; - let annots = doc.get_dictionary(pid).map_err(|e| e.to_string())?.get(b"Annots").map_err(|e| e.to_string())?; - let a = insp.resolve(annots).as_array().map_err(|e| e.to_string())?.get(*index).ok_or("annotation not found")?; - insp.dict(a).and_then(|d| d.get(b"FS").ok()).ok_or("annotation has no file")? - } - }; - let fs = insp.dict(spec).ok_or("bad file specification")?; - let ef = fs.get(b"EF").ok().and_then(|o| insp.dict(o)).ok_or("the file is not embedded (external reference)")?; - let stream = ef.get(b"UF").ok().or_else(|| ef.get(b"F").ok()).map(|o| insp.resolve(o)).ok_or("no embedded stream")?; - let stream = stream.as_stream().map_err(|e| e.to_string())?; - if stream.dict.get(b"Filter").is_ok() { - stream.decompressed_content_with_limit(1 << 30).map_err(|e| e.to_string()) - } else { - Ok(stream.content.clone()) - } - }; - catch_unwind(AssertUnwindSafe(run)).unwrap_or_else(|p| Err(format!("reading the attachment crashed: {}", crate::raster::panic_message(&p)))) -} - -/// An XFA packet's bytes as text: UTF-16 (little- or big-endian, told by a byte order mark or by -/// how `<` is written) or UTF-8, as `pdfcraft_xfa::packets::decode` reads them. Lossy: only one -/// element is looked for. -fn xfa_packet_text(bytes: &[u8]) -> String { - let utf16 = |b: &[u8], from: fn([u8; 2]) -> u16| { - let units = b.as_chunks::<2>().0.iter().map(|c| from(*c)); - char::decode_utf16(units).map(|c| c.unwrap_or(char::REPLACEMENT_CHARACTER)).collect::() - }; - match bytes { - [0xFF, 0xFE, rest @ ..] => utf16(rest, u16::from_le_bytes), - [0xFE, 0xFF, rest @ ..] => utf16(rest, u16::from_be_bytes), - [b'<', 0, ..] => utf16(bytes, u16::from_le_bytes), - [0, b'<', ..] => utf16(bytes, u16::from_be_bytes), - [0xEF, 0xBB, 0xBF, rest @ ..] => String::from_utf8_lossy(rest).into_owned(), - _ => String::from_utf8_lossy(bytes).into_owned(), - } -} - -/// One markup tag found by [`next_tag`]: where it starts (`<`) and ends (after `>`), whether it -/// closes (``) or closes itself (``), and its name without a namespace prefix. -struct Tag<'a> { - start: usize, - end: usize, - close: bool, - empty: bool, - local: &'a str, -} - -/// The next element tag in `xml` at or after `from`, skipping comments, CDATA sections, -/// processing instructions and declarations. A byte scan, not an XML parser: enough to find one -/// known element in an XFA packet, whose tags Designer writes with a line break before each `>`. -fn next_tag(xml: &str, mut from: usize) -> Option> { - loop { - let lt = from + xml.get(from..)?.find('<')?; - let rest = xml.get(lt..)?; - let skip_to = |end: &str| rest.find(end).map(|i| lt + i + end.len()); - if rest.starts_with("")?; - continue; - } - if rest.starts_with("")?; - continue; - } - if rest.starts_with("")?; - continue; - } - let gt = lt + rest.find('>')?; - let inner = xml.get(lt + 1..gt)?; - let (close, inner) = match inner.strip_prefix('/') { - Some(i) => (true, i), - None => (false, inner), - }; - let empty = !close && inner.ends_with('/'); - let name = inner.split(|c: char| c.is_whitespace() || c == '/').next().unwrap_or_default(); - let local = name.rsplit(':').next().unwrap_or(name); - return Some(Tag { start: lt, end: gt + 1, close, empty, local }); - } -} - -/// What `…` holds: the content of the first top-level element of `xml` called `name` -/// (any prefix). Elements nested in other elements are not looked at. `None` when there is none, -/// it closes itself, or it is never closed. -fn xml_child_body<'a>(xml: &'a str, name: &str) -> Option<&'a str> { - let mut at = 0; - // The elements open around `at`, innermost last: a closing tag must name the innermost one, - // or the markup is malformed and has no value. - let mut open_tags: Vec<&str> = Vec::new(); - // Where the wanted element's content starts, once its opening tag is seen at the top level. - let mut open = None; - loop { - let tag = next_tag(xml, at)?; - at = tag.end; - if tag.close { - if open_tags.pop()? != tag.local { - return None; - } - if open_tags.is_empty() - && let Some(start) = open - { - return xml.get(start..tag.start); - } - } else if !tag.empty { - if open_tags.is_empty() && tag.local == name { - open = Some(tag.end); - } - open_tags.push(tag.local); - } else if open_tags.is_empty() && tag.local == name { - return None; - } - } -} - -/// The trimmed text at `path` (element names from the outside in, any prefix, each a child of the -/// one before) in `xml`, if it is plain text. -fn xml_path_text(xml: &str, path: &[&str]) -> Option { - let body = path.iter().try_fold(xml, |within, name| xml_child_body(within, name))?; - let text = body.trim(); - (!text.is_empty() && !text.contains('<')).then(|| text.to_owned()) -} - -/// `dynamicRender` at `config/acrobat/acrobat7` in the configuration packet: the `config` child -/// of the `xdp` root in a whole-XDP stream, or the `config` packet itself. The same names inside -/// the form data or the template are not the configuration. -fn xfa_config_dynamic_render(packet: &str) -> Option { - const IN_CONFIG: [&str; 4] = ["config", "acrobat", "acrobat7", "dynamicRender"]; - xml_path_text(packet, &["xdp", IN_CONFIG[0], IN_CONFIG[1], IN_CONFIG[2], IN_CONFIG[3]]).or_else(|| xml_path_text(packet, &IN_CONFIG)) -} - -#[cfg(test)] -mod tests { - #[test] - fn png_predictor_rows_longer_than_the_data_are_refused_before_allocating() { - // Vendored lopdf patch: a fuzzed `/Columns 4294967295` allocated two 4 GiB rows before - // reading any data. A row that cannot fit in the data is refused up front. - let e = lopdf::filters::png::decode_frame(&[2, 0, 0, 0], 1, 64 << 20).unwrap_err(); - assert!(e.to_string().contains("longer than the data"), "{e}"); - // A real frame still decodes: two 3-byte rows, the second Up-filtered. - assert_eq!(lopdf::filters::png::decode_frame(&[0, 1, 2, 3, 2, 1, 1, 1], 1, 3).unwrap(), [1, 2, 3, 2, 3, 4]); - } - - #[test] - fn empty_png_predictor_frames_do_not_allocate_rows() { - // usize::MAX cannot be reserved, so the old code safely errors before allocating. - // An empty frame needs no rows regardless of the declared width. - assert!(lopdf::filters::png::decode_frame(&[], 1, usize::MAX).unwrap().is_empty()); - } - - fn tiff_predictor_stream(data: Vec, columns: i64, colors: i64, bits: i64) -> lopdf::Stream { - let mut params = lopdf::Dictionary::new(); - params.set("Predictor", 2i64); - params.set("Columns", columns); - params.set("Colors", colors); - params.set("BitsPerComponent", bits); - let mut dict = lopdf::Dictionary::new(); - dict.set("DecodeParms", params); - let mut stream = lopdf::Stream::new(dict, data); - stream.compress().unwrap(); - assert_eq!(stream.dict.get(b"Filter").unwrap().as_name().unwrap(), b"FlateDecode"); - stream - } - - #[test] - fn tiff_subbyte_predictor_row_width_overflow_is_refused() { - // Each multiplication overflows before any scratch allocation in the old debug build. - for bits in [1, 2, 4] { - let stream = tiff_predictor_stream(vec![0; 64], i64::MAX, 3, bits); - assert!( - matches!(stream.decompressed_content_with_limit(64), Err(lopdf::Error::Decompress(lopdf::DecompressError::Predictor(_)))), - "{bits}-bit row" - ); - } - } - - #[test] - fn tiff_subbyte_predictor_scratch_is_bounded_by_available_samples() { - // 1/2-bit row widths fit usize on 32- and 64-bit hosts; Colors previously made Vec - // reject the capacity before allocating. A partial row has no preceding pixel. - for bits in [1, 2] { - let data = vec![0b1010_0110; 64]; - let stream = tiff_predictor_stream(data.clone(), 1, i64::try_from(isize::MAX).unwrap(), bits); - assert_eq!(stream.decompressed_content_with_limit(64).unwrap(), data, "{bits}-bit row"); - } - } - - #[test] - fn tiff_subbyte_predictors_keep_components_rows_and_padding() { - // Repeated tiny rows compress through the public Stream API. Rows remain independent, - // differences wrap at each component depth, and trailing padding bits survive. - for (columns, colors, bits, encoded, decoded) in [ - (16, 1, 1, vec![255, 170, 8, 255], vec![170, 204, 15, 85]), - (6, 1, 2, vec![85, 179], vec![108, 147]), - (3, 1, 4, vec![25, 16], vec![26, 176]), - (2, 2, 4, vec![18, 34], vec![18, 52]), - ] { - let stream = tiff_predictor_stream(encoded.repeat(32), columns, colors, bits); - assert_eq!(stream.decompressed_content_with_limit(128).unwrap(), decoded.repeat(32), "{bits}-bit, {colors} colours"); - } - } - - // Xref-stream fixtures exercise indirect /Length lookup during document loading: stream 2's /Length is `3 0 R`, - // `containers` lists compressed (id, container, index) entries and `objects` the other objects in the file. - fn compressed_length_pdf(containers: &[(u32, u32, u16)], objects: &[(u32, &[u8])]) -> Vec { - let mut pdf = b"%PDF-1.7\n".to_vec(); - let ids = containers.iter().flat_map(|&(id, container, _)| [id, container]).chain(objects.iter().map(|&(id, _)| id)); - let xref_id = ids.max().unwrap_or(3) + 1; - let mut records = vec![[0u8; 7]; (xref_id + 1) as usize]; - records[0] = [0, 0, 0, 0, 0, 255, 255]; - let mut append = |id: u32, body: &[u8]| { - records[id as usize][0] = 1; - records[id as usize][1..5].copy_from_slice(&(pdf.len() as u32).to_be_bytes()); - pdf.extend_from_slice(format!("{id} 0 obj\n").as_bytes()); - pdf.extend_from_slice(body); - pdf.extend_from_slice(b"\nendobj\n"); - }; - append(1, b"<< /Type /Catalog >>"); - append(2, b"<< /Length 3 0 R >>\nstream\nhello\nendstream"); - for &(id, body) in objects { - append(id, body); - } - for &(id, container, index) in containers { - records[id as usize][0] = 2; - records[id as usize][1..5].copy_from_slice(&container.to_be_bytes()); - records[id as usize][5..7].copy_from_slice(&index.to_be_bytes()); - } - let xref_offset = pdf.len(); - records[xref_id as usize][0] = 1; - records[xref_id as usize][1..5].copy_from_slice(&(xref_offset as u32).to_be_bytes()); - pdf.extend_from_slice( - format!("{xref_id} 0 obj\n<< /Type /XRef /Size {} /Root 1 0 R /W [1 4 2] /Length {} >>\nstream\n", records.len(), records.len() * 7) - .as_bytes(), - ); - for record in records { - pdf.extend_from_slice(&record); - } - pdf.extend_from_slice(format!("\nendstream\nendobj\nstartxref\n{xref_offset}\n%%EOF\n").as_bytes()); - pdf - } - - // Stream 2's /Length starts a chain of `hops` indirect /Length lookups, each naming a new object; only the last - // link's /Length is direct. Compressed: link k is the integer 3 + 2k (the length it measures) alone in the object - // stream 4 + 2k, whose own /Length is link k + 1. Plain: link k is the stream 3 + k, whose /Length is link k + 1. - fn length_chain_pdf(hops: u32, compressed: bool) -> Vec { - let length = |hop: u32, next: u32, data: &str| if hop + 1 < hops { format!("{next} 0 R") } else { data.len().to_string() }; - let (mut containers, mut objects) = (Vec::new(), Vec::new()); - let mut value = 5; // the length of stream 2's data, "hello" - for hop in 0..hops { - if compressed { - let (id, container) = (3 + 2 * hop, 4 + 2 * hop); - let index = format!("{id} 0 "); - let data = format!("{index}{value}"); - let dict = format!("/Type /ObjStm /N 1 /First {} /Length {}", index.len(), length(hop, id + 2, &data)); - objects.push((container, format!("<< {dict} >>\nstream\n{data}\nendstream"))); - containers.push((id, container, 0)); - value = data.len(); - } else { - objects.push((3 + hop, format!("<< /Length {} >>\nstream\n\nendstream", length(hop, 4 + hop, "")))); - } - } - let objects: Vec<_> = objects.iter().map(|(id, body)| (*id, body.as_bytes())).collect(); - compressed_length_pdf(&containers, &objects) - } - - // Lenient loading keeps a document whose stream 2 has an unresolvable /Length: the catalog, and stream 2's - // dictionary without its data. - fn load_without_stream_2_data(pdf: &[u8]) -> Document { - let doc = Document::load_mem(pdf).unwrap(); - assert!(doc.catalog().unwrap().has_type(b"Catalog")); - assert!(doc.get_object((2, 0)).unwrap().as_stream().unwrap().content.is_empty()); - doc - } - - #[test] - fn compressed_object_self_container_does_not_recurse() { - let doc = load_without_stream_2_data(&compressed_length_pdf(&[(3, 3, 0)], &[])); - assert!(doc.get_object((3, 0)).is_err()); - } - - #[test] - fn compressed_object_mutual_containers_do_not_recurse() { - let doc = load_without_stream_2_data(&compressed_length_pdf(&[(3, 4, 0), (4, 3, 0)], &[])); - assert!(doc.get_object((3, 0)).is_err()); - assert!(doc.get_object((4, 0)).is_err()); - } - - #[test] - fn object_stream_length_cycle_does_not_recurse() { - let pdf = compressed_length_pdf(&[(3, 4, 0)], &[(4, b"<< /Type /ObjStm /N 1 /First 4 /Length 3 0 R >>\nstream\n3 0 5\nendstream")]); - let doc = load_without_stream_2_data(&pdf); - // The container's /Length is the object it holds: the container is kept without data, so 3 never loads. - assert!(doc.get_object((4, 0)).unwrap().as_stream().unwrap().content.is_empty()); - assert!(doc.get_object((3, 0)).is_err()); - } - - #[test] - fn compressed_object_container_chain_does_not_recurse() { - let containers: Vec<_> = (3..100_003).map(|id| (id, id + 1, 0)).collect(); - let pdf = compressed_length_pdf(&containers, &[(100_003, b"<< /Type /ObjStm /N 1 /First 4 /Length 5 >>\nstream\n3 0 5\nendstream")]); - let doc = load_without_stream_2_data(&pdf); - // The object stream in the file loads, but the xref puts 3 in the compressed container 4, which is refused. - assert_eq!(doc.get_object((100_003, 0)).unwrap().as_stream().unwrap().content, b"3 0 5"); - assert!(doc.get_object((3, 0)).is_err()); - } - - #[test] - fn compressed_objects_and_indirect_stream_length_still_load() { - let pdf = compressed_length_pdf( - &[(3, 4, 0), (5, 4, 1)], - &[(4, b"<< /Type /ObjStm /N 2 /First 8 /Length 17 >>\nstream\n3 0 5 2 5 (value)\nendstream")], - ); - let doc = Document::load_mem_with_options(&pdf, LoadOptions { strict: true, ..Default::default() }).unwrap(); - assert_eq!(doc.get_object((3, 0)).unwrap().as_i64().unwrap(), 5); - assert_eq!(doc.get_object((5, 0)).unwrap().as_str().unwrap(), b"value"); - assert_eq!(doc.get_object((2, 0)).unwrap().as_stream().unwrap().content, b"hello"); - } - - #[test] - fn compressed_length_in_a_container_with_an_indirect_length_still_loads() { - // Two hops, as in a valid file: stream 2's /Length (3) is compressed in container 4, whose own /Length (5) is - // a plain indirect integer. - let pdf = - compressed_length_pdf(&[(3, 4, 0)], &[(4, b"<< /Type /ObjStm /N 1 /First 4 /Length 5 0 R >>\nstream\n3 0 5\nendstream"), (5, b"5")]); - let doc = Document::load_mem_with_options(&pdf, LoadOptions { strict: true, ..Default::default() }).unwrap(); - assert_eq!(doc.get_object((3, 0)).unwrap().as_i64().unwrap(), 5); - assert_eq!(doc.get_object((5, 0)).unwrap().as_i64().unwrap(), 5); - assert_eq!(doc.get_object((2, 0)).unwrap().as_stream().unwrap().content, b"hello"); - } - - #[test] - fn compressed_length_chain_does_not_recurse() { - // Every container is an ordinary object in the file and every hop names a new object, so neither the - // container check nor the cycle history stops the chain; each hop recursed once more (a 512 KiB stack - // overflowed within 150 hops). Lookups past the cap fail: the first link never loads, while the last one, - // next to the direct /Length, does. - let hops = 5_000; - let doc = load_without_stream_2_data(&length_chain_pdf(hops, true)); - assert!(doc.get_object((3, 0)).is_err()); - assert!(doc.get_object((3 + 2 * (hops - 1), 0)).unwrap().as_i64().is_ok()); - // The cap itself: loading link j reads its container's /Length, a chain of 2 * (hops - 1 - j) lookups, and - // 16 are allowed, so the 9th link from the end loads and the 10th does not. - assert!(doc.get_object((3 + 2 * (hops - 9), 0)).is_ok()); - assert!(doc.get_object((3 + 2 * (hops - 10), 0)).is_err()); - } - - #[test] - fn plain_stream_length_chain_does_not_recurse() { - // The same recursion without object streams: each stream's /Length names the next stream (a 512 KiB stack - // overflowed within 200 hops). A stream is no length, so every link is kept as a stream without data. - let hops = 5_000; - let doc = load_without_stream_2_data(&length_chain_pdf(hops, false)); - assert!((3..3 + hops).all(|id| doc.get_object((id, 0)).is_ok_and(|link| link.as_stream().is_ok_and(|s| s.content.is_empty())))); - } - - fn indexed_object_stream(pairs: &[(u32, usize)], members: &[u8]) -> lopdf::Stream { - let mut content = Vec::new(); - for &(id, offset) in pairs { - content.extend_from_slice(format!("{id} {offset} ").as_bytes()); - } - let mut dict = Dictionary::new(); - dict.set("Type", "ObjStm"); - dict.set("N", pairs.len() as i64); - dict.set("First", content.len() as i64); - content.extend_from_slice(members); - lopdf::Stream::new(dict, content) - } - - #[test] - fn object_stream_member_extents_bound_owned_strings() { - let mut members = vec![b'('; 32]; - members.extend(vec![b'x'; 1 << 20]); - members.extend([b')'; 32]); - // Put the innermost (complete) string first in index order, then overlapping starts - // and duplicates. Only this first member at offset 31 may own the large string. - let mut pairs = vec![(100, 31)]; - pairs.extend((0..31).map(|offset| (offset as u32 + 1, offset))); - pairs.extend([(200, 31), (201, 31)]); - let stream = indexed_object_stream(&pairs, &members); - let parsed = lopdf::ObjectStream::new_with_limit(&stream, Some(2 << 20)).unwrap(); - assert_eq!(parsed.objects.get(&(100, 0)).unwrap().as_str().unwrap().len(), 1 << 20); - let total: usize = parsed.objects.values().filter_map(|object| object.as_str().ok()).map(<[u8]>::len).sum(); - assert!(total <= stream.content.len(), "owned {total} string bytes from {} decoded bytes", stream.content.len()); - assert!(!parsed.objects.contains_key(&(200, 0))); - assert!(!parsed.objects.contains_key(&(201, 0))); - } - - #[test] - fn object_stream_member_extents_preserve_index_order_and_whitespace() { - let members = b" \n42\n /Name \n[true 7]\n << /Key (value) >>\n (last)"; - // Unsorted offsets, and object 9 appears twice: its last index entry still wins. - let pairs = [(9, 0), (12, 21), (10, 5), (11, 12), (9, 41)]; - let parsed = lopdf::ObjectStream::new(&indexed_object_stream(&pairs, members)).unwrap(); - assert_eq!(parsed.objects.len(), 4); - assert_eq!(parsed.objects.get(&(9, 0)).unwrap().as_str().unwrap(), b"last"); - assert_eq!(parsed.objects.get(&(10, 0)).unwrap().as_name().unwrap(), b"Name"); - assert_eq!(parsed.objects.get(&(11, 0)).unwrap().as_array().unwrap(), &[Object::Boolean(true), Object::Integer(7)]); - assert_eq!(parsed.objects.get(&(12, 0)).unwrap().as_dict().unwrap().get(b"Key").unwrap().as_str().unwrap(), b"value"); - } - - #[test] - fn view_and_user_space_round_trip_for_every_rotation() { - for rotation in [0u16, 90, 180, 270] { - let (w, h) = if rotation % 180 == 0 { (200.0, 300.0) } else { (300.0, 200.0) }; - let p = super::PageInfo { width: w, height: h, label: String::new(), crop: [10.0, 20.0, 210.0, 320.0], rotation }; - for (x, y) in [(0.0, 0.0), (15.0, 40.0), (w, h)] { - let [ux, uy] = p.view_to_user(x, y); - let [vx, vy] = p.user_to_view(ux, uy); - assert!((vx - x).abs() < 1e-3 && (vy - y).abs() < 1e-3, "{rotation}: {x},{y} → {ux},{uy} → {vx},{vy}"); - } - } - // Unrotated: the view's top-left is the crop box's top-left. - let p = super::PageInfo { width: 200.0, height: 300.0, label: String::new(), crop: [10.0, 20.0, 210.0, 320.0], rotation: 0 }; - assert_eq!(p.view_to_user(0.0, 0.0), [10.0, 320.0]); - assert_eq!(p.view_rect_to_quad([0.0, 0.0, 10.0, 5.0]), [10.0, 320.0, 20.0, 320.0, 10.0, 315.0, 20.0, 315.0]); - } - - /// #2583: a NoRotate comment is drawn upright with its top-left as displayed at the upper-left - /// corner of `/Rect`, its width across and its height down; a box resized as drawn maps back to - /// the `/Rect` that shows it. Other comments, and pages that aren't quarter-turned, keep `/Rect`. - #[test] - fn no_rotate_comments_are_shown_upright_from_their_corner_and_back() { - let mut note = super::Annotation { - page: 0, - subtype: "Text".into(), - author: None, - contents: None, - modified: None, - name: None, - in_reply_to: None, - parent: None, - grouped: false, - rect: [40.0, 50.0, 70.0, 70.0], - color: None, - index: 0, - state: None, - quads: Vec::new(), - locked: false, - intent: None, - fill_sign: false, - no_rotate: true, - points: Vec::new(), - }; - let rect = note.rect.map(f64::from); - for rotation in [0u16, 90, 180, 270, 45] { - let (w, h) = if rotation % 180 == 90 { (140.0, 200.0) } else { (200.0, 140.0) }; - let p = super::PageInfo { width: w, height: h, label: String::new(), crop: [0.0, 0.0, 200.0, 140.0], rotation }; - let shown = note.shown_rect(rotation); - let (a, b) = (p.user_to_view(shown[0], shown[1]), p.user_to_view(shown[2], shown[3])); - let [cx, cy] = p.user_to_view(40.0, 70.0); - let want = [cx, cy, cx + 30.0, cy + 20.0]; - let got = [a[0].min(b[0]), a[1].min(b[1]), a[0].max(b[0]), a[1].max(b[1])]; - assert!(got.iter().zip(want).all(|(g, w)| (g - w).abs() < 1e-3), "{rotation}: shown {got:?}, want {want:?}"); - assert_eq!(note.rect_for_shown(note.shown(rect, rotation), rotation), rect, "{rotation}"); - // Moved by (5, -7) and resized to 50 × 10 as drawn, back to a /Rect. - let resized = [shown[0] as f64 + 5.0, shown[1] as f64 - 7.0, 0.0, 0.0]; - let across = if rotation % 180 == 90 { [10.0, 50.0] } else { [50.0, 10.0] }; - let resized = [resized[0], resized[1], resized[0] + across[0], resized[1] + across[1]]; - let back = note.rect_for_shown(resized, rotation); - assert_eq!((back[2] - back[0], back[3] - back[1]), (50.0, 10.0), "{rotation}: {back:?}"); - assert_eq!(note.shown(back, rotation), resized, "{rotation}"); - } - note.no_rotate = false; - assert_eq!(note.shown_rect(90), note.rect); - assert_eq!(note.rect_for_shown([1.0, 2.0, 3.0, 4.0], 90), [1.0, 2.0, 3.0, 4.0]); - } - - use super::*; - - #[test] - fn roman_and_alpha_labels() { - assert_eq!(roman(4, MAX_LABEL_BYTES).as_deref(), Some("iv")); - assert_eq!(roman(1994, MAX_LABEL_BYTES).as_deref(), Some("mcmxciv")); - assert_eq!(alpha(1, MAX_LABEL_BYTES).as_deref(), Some("a")); - assert_eq!(alpha(27, MAX_LABEL_BYTES).as_deref(), Some("aa")); - assert_eq!(alpha(53, MAX_LABEL_BYTES).as_deref(), Some("aaa")); - } - - // Synthetic label-only inspection fixtures: never construct an explosive branching tree. - fn label_info(nums: Vec, kids: Vec) -> DocInfo { - let mut doc = Document::with_version("1.7"); - let mut tree = Dictionary::new(); - tree.set("Nums", nums); - tree.set("Kids", kids); - doc.objects.insert((6, 0), Object::Dictionary(tree)); - let mut catalog = Dictionary::new(); - catalog.set("Type", Object::Name(b"Catalog".to_vec())); - catalog.set("PageLabels", Object::Reference((6, 0))); - doc.objects.insert((1, 0), Object::Dictionary(catalog)); - doc.trailer.set("Root", Object::Reference((1, 0))); - let mut info = DocInfo { - pages: (1..=3) - .map(|n| PageInfo { width: 200.0, height: 300.0, crop: [0.0, 0.0, 200.0, 300.0], rotation: 0, label: n.to_string() }) - .collect(), - ..Default::default() - }; - Inspector::new(&doc).fill(&mut info); - info - } - - fn label_spec(style: &str, prefix: Vec, first: i64) -> Object { - let mut d = Dictionary::new(); - d.set("S", Object::Name(style.as_bytes().to_vec())); - d.set("P", Object::String(prefix, lopdf::StringFormat::Hexadecimal)); - d.set("St", first); - Object::Dictionary(d) - } - - fn assert_label_fallback(info: &DocInfo) { - assert_eq!(info.pages.iter().map(|p| p.label.as_str()).collect::>(), ["1", "2", "3"]); - assert_eq!(info.warnings.len(), 1, "{:?}", info.warnings); - assert!(info.warnings[0].contains("Page labels"), "{:?}", info.warnings); - assert!(info.warnings[0].contains("physical page numbers"), "{:?}", info.warnings); - } - - // Contributor-original, blank three-page PDFs. The public importer reads both labels and metadata. - fn page_prefix_space_pdf(prefix: Vec, decimal: bool, indirect: bool) -> Vec { - use lopdf::dictionary; - let mut doc = Document::with_version("1.7"); - let pages_id = doc.new_object_id(); - let kids: Vec = (0..3) - .map(|_| { - doc.add_object(dictionary! { - "Type" => "Page", - "Parent" => pages_id, - "MediaBox" => vec![0.into(), 0.into(), 200.into(), 300.into()], - "Resources" => Dictionary::new(), - }) - .into() - }) - .collect(); - doc.objects.insert(pages_id, dictionary! { "Type" => "Pages", "Kids" => kids, "Count" => 3 }.into()); - let prefix = Object::String(prefix, lopdf::StringFormat::Hexadecimal); - let prefix = if indirect { Object::Reference(doc.add_object(prefix)) } else { prefix }; - let mut label = Dictionary::new(); - label.set("P", prefix); - if decimal { - label.set("S", Object::Name(b"D".to_vec())); - } - let labels = dictionary! { "Nums" => vec![Object::Integer(0), Object::Dictionary(label)] }; - let catalog_id = doc.add_object(dictionary! { "Type" => "Catalog", "Pages" => pages_id, "PageLabels" => labels }); - let info_id = doc.add_object(dictionary! { "Title" => Object::string_literal(" Metadata title ") }); - doc.trailer.set("Root", catalog_id); - doc.trailer.set("Info", info_id); - let mut bytes = Vec::new(); - doc.save_to(&mut bytes).expect("write original page-prefix fixture"); - bytes - } - - #[test] - fn page_label_prefix_spaces_survive_public_inspection() { - let mut utf16 = vec![0xfe, 0xff]; - utf16.extend(" Ω ".encode_utf16().flat_map(u16::to_be_bytes)); - for (raw, prefix, decimal, indirect) in [ - (b" leading".to_vec(), " leading", true, false), - (b"trailing ".to_vec(), "trailing ", true, false), - (b" both ".to_vec(), " both ", false, false), - (b" ".to_vec(), " ", false, false), - (b" Caf\xe9 ".to_vec(), " Café ", true, false), - (utf16.clone(), " Ω ", true, false), - (utf16, " Ω ", false, true), - (Vec::new(), "", true, false), - (Vec::new(), "", false, false), - ] { - let bytes = page_prefix_space_pdf(raw, decimal, indirect); - let info = inspect(Arc::new(bytes), None).expect("public import of original page-prefix fixture"); - let expected: Vec = (1..=3) - .map(|n| { - if decimal { - format!("{prefix}{n}") - } else if prefix.is_empty() { - n.to_string() - } else { - prefix.to_owned() - } - }) - .collect(); - assert_eq!( - info.pages.iter().map(|p| p.label.clone()).collect::>(), - expected, - "{prefix:?}, decimal={decimal}, indirect={indirect}" - ); - assert!(info.warnings.is_empty(), "{:?}", info.warnings); - assert_eq!(info.title.as_deref(), Some("Metadata title"), "page-prefix decoding must not change metadata normalization"); - } - } - - #[test] - fn page_label_limits_keep_normal_pdf_numbering() { - for (style, first, expected) in [ - ("r", 4, ["iv", "v", "vi"]), - ("R", 1994, ["MCMXCIV", "MCMXCV", "MCMXCVI"]), - ("a", 26, ["z", "aa", "bb"]), - ("A", 52, ["ZZ", "AAA", "BBB"]), - ] { - let info = label_info(vec![Object::Integer(0), label_spec(style, Vec::new(), first)], Vec::new()); - assert_eq!(info.pages.iter().map(|p| p.label.as_str()).collect::>(), expected); - assert!(info.warnings.is_empty(), "{:?}", info.warnings); - } - } - - #[test] - fn page_label_limits_reject_long_prefixes_without_partial_labels() { - for prefix in [vec![b'x'; 1025], vec![b'x'; 2051]] { - let nums = vec![Object::Integer(0), label_spec("D", b"ok-".to_vec(), 1), Object::Integer(1), label_spec("D", prefix, 1)]; - assert_label_fallback(&label_info(nums, Vec::new())); - } - } - - #[test] - fn page_label_limits_count_decoded_utf8_bytes() { - let mut prefix = vec![0xfe, 0xff]; - for _ in 0..342 { - prefix.extend_from_slice(&0x2022u16.to_be_bytes()); - } - assert_label_fallback(&label_info(vec![Object::Integer(0), label_spec("", prefix, 1)], Vec::new())); - } - - #[test] - fn page_label_limits_bound_roman_and_alpha_output() { - // Just 1025 output bytes on the old path, so RED does not consume excessive memory. - for (style, first) in [("r", 1_025_000), ("a", 26 * 1024 + 1)] { - assert_label_fallback(&label_info(vec![Object::Integer(0), label_spec(style, Vec::new(), first)], Vec::new())); - } - } - - #[test] - fn page_label_limits_detect_one_node_cycle() { - let nums = vec![Object::Integer(0), label_spec("D", b"custom-".to_vec(), 1)]; - assert_label_fallback(&label_info(nums, vec![Object::Reference((6, 0))])); - } - - #[test] - fn page_label_limits_accept_utf16_ascii_at_the_output_boundary() { - let mut prefix = vec![0xfe, 0xff]; - for _ in 0..1023 { - prefix.extend_from_slice(&u16::from(b'x').to_be_bytes()); - } - let info = label_info(vec![Object::Integer(0), label_spec("D", prefix, 1)], Vec::new()); - assert!(info.warnings.is_empty(), "{:?}", info.warnings); - assert_eq!(info.pages[0].label, format!("{}1", "x".repeat(1023))); - assert!(info.pages.iter().all(|p| p.label.len() == MAX_LABEL_BYTES)); - } - - #[test] - fn page_label_limits_refuse_hostile_numbers_before_allocating() { - for style in ["r", "R", "a", "A"] { - assert_label_fallback(&label_info(vec![Object::Integer(0), label_spec(style, Vec::new(), i64::MAX)], Vec::new())); - } - // Decimal labels remain small even when their first number exceeds 32-bit usize. - let info = label_info(vec![Object::Integer(0), label_spec("D", Vec::new(), i64::MAX)], Vec::new()); - assert_eq!(info.pages[0].label, i64::MAX.to_string()); - assert_eq!(info.pages[1].label, (i64::MAX as u64 + 1).to_string()); - assert!(info.warnings.is_empty()); - } - - #[test] - fn page_label_limits_bound_wide_number_trees() { - assert_label_fallback(&label_info(Vec::new(), vec![Object::Null; MAX_LABEL_TREE_WORK + 1])); - let pair = [Object::Integer(0), label_spec("D", Vec::new(), 1)]; - let nums = (0..MAX_LABEL_TREE_WORK).flat_map(|_| pair.clone()).collect(); - assert_label_fallback(&label_info(nums, Vec::new())); - } - - #[test] - fn page_label_limits_bound_total_custom_output_atomically() { - let doc = Document::with_version("1.7"); - let mut tree = Dictionary::new(); - tree.set("Nums", vec![Object::Integer(0), label_spec("", vec![b'x'; MAX_LABEL_BYTES], 1)]); - let mut catalog = Dictionary::new(); - catalog.set("PageLabels", Object::Dictionary(tree)); - let count = MAX_LABEL_TOTAL_BYTES / MAX_LABEL_BYTES + 1; - let mut pages: Vec<_> = (1..=count) - .map(|n| PageInfo { width: 200.0, height: 300.0, crop: [0.0, 0.0, 200.0, 300.0], rotation: 0, label: n.to_string() }) - .collect(); - let err = Inspector::new(&doc).page_labels(&catalog, &mut pages).unwrap_err(); - assert!(err.contains("4 MiB"), "{err}"); - assert!(pages.iter().enumerate().all(|(i, p)| p.label == (i + 1).to_string())); - // Exact aggregate boundary remains supported. - pages.pop(); - Inspector::new(&doc).page_labels(&catalog, &mut pages).unwrap(); - assert!(pages.iter().all(|p| p.label.len() == MAX_LABEL_BYTES)); - } - - #[test] - fn page_label_limits_bound_the_total_of_decoded_prefixes() { - // `ranges` ranges naming one shared prefix object of `prefix` bytes, without a style. - let shared = |ranges: i64, prefix: usize| { - let mut doc = Document::with_version("1.7"); - doc.objects.insert((7, 0), Object::String(vec![b'x'; prefix], lopdf::StringFormat::Literal)); - let mut spec = Dictionary::new(); - spec.set("P", Object::Reference((7, 0))); - doc.objects.insert((8, 0), Object::Dictionary(spec)); - let mut tree = Dictionary::new(); - tree.set("Nums", (0..ranges).flat_map(|i| [Object::Integer(i), Object::Reference((8, 0))]).collect::>()); - let mut catalog = Dictionary::new(); - catalog.set("PageLabels", Object::Dictionary(tree)); - (doc, catalog) - }; - let mut pages: Vec<_> = - (1..=3).map(|n| PageInfo { width: 200.0, height: 300.0, crop: [0.0, 0.0, 200.0, 300.0], rotation: 0, label: n.to_string() }).collect(); - // Each range decodes its prefix, so a small file sharing one prefix object asks for a - // copy per range: 5,000 ranges and a 1,000-byte prefix make 5 MB, past the 4 MiB total. - // Three pages make three labels, far below the label total: the prefixes alone refuse it. - let (doc, catalog) = shared(5000, 1000); - let err = Inspector::new(&doc).page_labels(&catalog, &mut pages).unwrap_err(); - assert!(err.contains("prefixes exceed 4 MiB"), "{err}"); - assert!(pages.iter().enumerate().all(|(i, p)| p.label == (i + 1).to_string())); - // Exactly 4 MiB of prefixes is still read. - let (doc, catalog) = shared((MAX_PREFIX_TOTAL_BYTES / MAX_LABEL_BYTES) as i64, MAX_LABEL_BYTES); - Inspector::new(&doc).page_labels(&catalog, &mut pages).unwrap(); - assert!(pages.iter().all(|p| p.label.len() == MAX_LABEL_BYTES)); - } - - #[test] - fn page_label_limits_handle_prefix_only_and_reference_cycles() { - let info = label_info(vec![Object::Integer(0), label_spec("", b"prefix".to_vec(), i64::MAX)], Vec::new()); - assert!(info.warnings.is_empty()); - assert!(info.pages.iter().all(|p| p.label == "prefix")); - let mut doc = Document::with_version("1.7"); - doc.objects.insert((6, 0), Object::Reference((6, 0))); - let mut catalog = Dictionary::new(); - catalog.set("PageLabels", Object::Reference((6, 0))); - assert!(Inspector::new(&doc).page_labels(&catalog, &mut []).unwrap_err().contains("cyclic")); - } - - #[test] - fn page_label_limits_bound_depth_and_count_combined_work() { - for depth in [MAX_LABEL_TREE_DEPTH, MAX_LABEL_TREE_DEPTH + 1] { - let mut child = Dictionary::new(); - child.set("Nums", vec![Object::Integer(0), label_spec("D", b"custom-".to_vec(), 1)]); - let mut child = Object::Dictionary(child); - for _ in 1..depth { - let mut parent = Dictionary::new(); - parent.set("Kids", vec![child]); - child = Object::Dictionary(parent); - } - let info = label_info(Vec::new(), vec![child]); - if depth == MAX_LABEL_TREE_DEPTH { - assert!(info.warnings.is_empty()); - assert_eq!(info.pages[0].label, "custom-1"); - } else { - assert_label_fallback(&info); - } - } - // Each array alone fits: their combined work still must be bounded. - let half = MAX_LABEL_TREE_WORK / 2; - let spec = label_spec("D", Vec::new(), 1); - let nums = (0..half).flat_map(|i| [Object::Integer(i as i64), spec.clone()]).collect(); - assert_label_fallback(&label_info(nums, vec![Object::Null; half])); - } - - #[test] - fn page_label_limits_keep_the_later_of_duplicate_ranges() { - let nums = vec![Object::Integer(0), label_spec("D", b"first-".to_vec(), 1), Object::Integer(0), label_spec("D", b"last-".to_vec(), 1)]; - let info = label_info(nums, Vec::new()); - assert_eq!(info.pages.iter().map(|p| p.label.as_str()).collect::>(), ["last-1", "last-2", "last-3"]); - assert_eq!(info.warnings.len(), 1, "{:?}", info.warnings); - assert!(info.warnings[0].contains("twice"), "{:?}", info.warnings); - } - - #[test] - fn page_label_limits_keep_other_ranges_when_one_is_too_long() { - // Page 1 is labelled normally; pages 2–3 would need over 1024 bytes of roman numerals. - let nums = vec![Object::Integer(0), label_spec("D", b"ok-".to_vec(), 1), Object::Integer(1), label_spec("r", Vec::new(), 1_025_000)]; - let info = label_info(nums, Vec::new()); - assert_eq!(info.pages.iter().map(|p| p.label.as_str()).collect::>(), ["ok-1", "2", "3"]); - assert_eq!(info.warnings.len(), 1, "{:?}", info.warnings); - assert!(info.warnings[0].contains("physical page numbers"), "{:?}", info.warnings); - } - - const ATTACHMENTS: &[u8] = b"%PDF-1.7 -1 0 obj << /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles << /Names [(notes.txt) 6 0 R] >> >> >> endobj -2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj -3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [4 0 R] /Resources << /Font << /F1 9 0 R >> >> >> endobj -4 0 obj << /Type /Annot /Subtype /FileAttachment /Rect [10 10 30 30] /Contents (data.csv) /FS 8 0 R >> endobj -5 0 obj << /Type /EmbeddedFile /Length 11 >> stream -hello notes -endstream endobj -6 0 obj << /Type /Filespec /F (notes.txt) /UF (notes.txt) /EF << /F 5 0 R >> /Desc (Doc-level) >> endobj -7 0 obj << /Type /EmbeddedFile /Length 5 >> stream -a,b,c -endstream endobj -8 0 obj << /Type /Filespec /F (data.csv) /EF << /F 7 0 R >> >> endobj -9 0 obj << /Type /Font /Subtype /Type1 /BaseFont /ABCDEF+Helvetica >> endobj -trailer << /Root 1 0 R >> -%%EOF"; - - #[test] - fn attachments_of_both_kinds_are_listed_and_extracted() { - let bytes = Arc::new(ATTACHMENTS.to_vec()); - let info = inspect(bytes.clone(), None).expect("opens"); - let names: Vec<_> = info.attachments.iter().map(|a| a.name.as_str()).collect(); - assert!(names.contains(&"notes.txt") && names.contains(&"data.csv"), "{names:?}"); - for a in &info.attachments { - let data = attachment_data(&bytes, None, a).expect("extracts"); - let expected: &[u8] = if a.name == "notes.txt" { b"hello notes" } else { b"a,b,c" }; - assert_eq!(data, expected, "{}", a.name); - } - // A non-embedded /Helvetica is reported as standing in for itself: the standard-14 - // metrics are exact, so the substitution is named rather than left implicit. - assert_eq!( - info.fonts, - vec![FontInfo { - name: "Helvetica".into(), - kind: "Type1".into(), - embedded: false, - subset: true, - encoding: None, - substitute: Some("Helvetica".into()), - substitution: Some(pdfcraft_fonts::fallback::Why::NamedStandard14.note().into()), - }] - ); - } - - const LAYERS: &[u8] = b"%PDF-1.7 -1 0 obj << /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [5 0 R 6 0 R 7 0 R] /D << /OFF [6 0 R] /RBGroups [[5 0 R 6 0 R] [7 0 R] 8 0 R] >> >> >> endobj -2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj -3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [10 0 R 11 0 R] >> endobj -5 0 obj << /Type /OCG /Name (Red) >> endobj -6 0 obj << /Type /OCG /Name (Green) >> endobj -7 0 obj << /Type /OCG /Name (Blue) >> endobj -8 0 obj [6 0 R 7 0 R] endobj -9 0 obj [/OFF 5 0 R] endobj -10 0 obj << /Type /Annot /Subtype /Link /Rect [10 10 50 30] /A << /S /SetOCGState /State [7 0 R /ON 6 0 R /Bogus 5 0 R /Toggle 5 0 R 1 7 0 R] /PreserveRB false >> >> endobj -11 0 obj << /Type /Annot /Subtype /Link /Rect [60 10 100 30] /A << /S /SetOCGState /State 9 0 R >> >> endobj -trailer << /Root 1 0 R >> -%%EOF"; - - /// `/Trans` and `/Dur` (ISO 32000-2 §12.4.4): read with the spec's defaults for what a page - /// leaves out, lenient about what is out of range, and only for pages that have either. - const TRANSITIONS: &[u8] = 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 5 0 R 6 0 R 7 0 R] /Count 5 /MediaBox [0 0 200 200] >> endobj -3 0 obj << /Type /Page /Parent 2 0 R /Trans << /Type /Trans /S /Wipe /D 2.5 /Di 270 >> /Dur 5 >> endobj -4 0 obj << /Type /Page /Parent 2 0 R /Dur 3 >> endobj -5 0 obj << /Type /Page /Parent 2 0 R /Trans 8 0 R >> endobj -6 0 obj << /Type /Page /Parent 2 0 R /Trans << /S /Bogus /D (slow) /Di 45 /Dm /Q >> /Dur -1 >> endobj -7 0 obj << /Type /Page /Parent 2 0 R >> endobj -8 0 obj << /S /Fly /M /O /SS 0.5 /B true /Di /None /Dm /V >> endobj -trailer << /Root 1 0 R >> -%%EOF"; - - #[test] - fn page_transitions_and_display_times_are_read() { - use super::TransitionStyle as S; - let info = inspect(Arc::new(TRANSITIONS.to_vec()), None).expect("opens"); - let t = &info.transitions; - assert_eq!(t.iter().map(|t| t.page).collect::>(), [0, 1, 2, 3], "pages with /Trans or /Dur, in order; page 5 has neither"); - assert_eq!((t[0].style, t[0].duration, t[0].direction, t[0].display), (Some(S::Wipe), 2.5, Some(270), Some(5.0))); - assert!((t[0].horizontal, t[0].inward, t[0].scale, t[0].opaque) == (true, true, 1.0, false), "defaults: {:?}", t[0]); - assert_eq!((t[1].style, t[1].duration, t[1].display), (None, 1.0, Some(3.0)), "/Dur without /Trans"); - assert_eq!( - (t[2].style, t[2].inward, t[2].scale, t[2].opaque, t[2].direction, t[2].horizontal), - (Some(S::Fly), false, 0.5, true, None, false), - "an indirect /Trans: {:?}", - t[2] - ); - // Out of range: an unknown style is the spec's default R, a non-numeric /D the default - // second, a direction that is not one of the five falls back to 0, a bad /Dm to - // horizontal, and a negative /Dur is no display time. - assert_eq!( - (t[3].style, t[3].duration, t[3].direction, t[3].horizontal, t[3].display), - (Some(S::Replace), 1.0, Some(0), true, None), - "{:?}", - t[3] - ); - } - - const CUSTOM_INFO: &[u8] = 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 200] >> endobj -3 0 obj << /Type /Page /Parent 2 0 R >> endobj -4 0 obj << /Title (Report) /Producer (Test) /Project (Apollo) /Revision 7 /Dept (R&D) /Trapped /False /Note >> endobj -trailer << /Root 1 0 R /Info 4 0 R >> -%%EOF"; - - #[test] - fn custom_document_properties_are_listed_apart_from_the_standard_ones() { - let info = inspect(Arc::new(CUSTOM_INFO.to_vec()), None).expect("opens"); - assert_eq!((info.title.as_deref(), info.producer.as_deref()), (Some("Report"), Some("Test"))); - let mut custom = info.custom.clone(); - custom.sort(); - // Revision is a number, not text, so it is not listed; Trapped is a standard entry. - assert_eq!( - custom, - [("Dept".to_string(), "R&D".to_string()), ("Note".to_string(), "部".to_string()), ("Project".to_string(), "Apollo".to_string())] - ); - } - - #[test] - fn radio_button_layer_groups_are_read() { - let info = inspect(Arc::new(LAYERS.to_vec()), None).expect("opens"); - let layers: Vec<_> = info.layers.iter().map(|l| (l.id, l.name.as_str(), l.visible)).collect(); - assert_eq!(layers, [((5, 0), "Red", true), ((6, 0), "Green", false), ((7, 0), "Blue", true)]); - // A group of one constrains nothing; an indirect group is read. - assert_eq!(info.layer_groups, [vec![(5, 0), (6, 0)], vec![(6, 0), (7, 0)]]); - } - - #[test] - fn initial_layer_visibility_is_the_default_configuration() { - // BaseState OFF: Red is listed ON; Green is listed both ON and OFF, and OFF wins; Blue - // isn't listed, so it takes the base state. - let pdf = b"%PDF-1.7 -1 0 obj << /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [5 0 R 6 0 R 7 0 R] /D << /BaseState /OFF /ON [5 0 R 6 0 R] /OFF [6 0 R] >> >> >> endobj -2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj -3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] >> endobj -5 0 obj << /Type /OCG /Name (Red) >> endobj -6 0 obj << /Type /OCG /Name (Green) >> endobj -7 0 obj << /Type /OCG /Name (Blue) >> endobj -trailer << /Root 1 0 R >> -%%EOF"; - let info = inspect(Arc::new(pdf.to_vec()), None).expect("opens"); - let layers: Vec<_> = info.layers.iter().map(|l| (l.name.as_str(), l.initial)).collect(); - assert_eq!(layers, [("Red", true), ("Green", false), ("Blue", false)]); - } - - const ORPHAN_FIELDS: &[u8] = b"%PDF-1.7 -1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >> endobj -2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj -3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [5 0 R 6 0 R] >> endobj -4 0 obj << /Fields [] >> endobj -5 0 obj << /Type /Annot /Subtype /Widget /FT /Tx /T (alpha) /V (one) /Rect [10 100 90 120] >> endobj -6 0 obj << /Type /Annot /Subtype /Widget /FT /Tx /T (beta) /Rect [10 60 90 80] >> endobj -trailer << /Root 1 0 R >> -%%EOF"; - - #[test] - fn fields_listed_only_on_the_pages_are_listed() { - let info = inspect(Arc::new(ORPHAN_FIELDS.to_vec()), None).expect("opens"); - let listed: Vec<_> = info.fields.iter().map(|f| (f.name.as_str(), f.value.as_deref(), f.page)).collect(); - assert_eq!(listed, [("alpha", Some("one"), Some(0)), ("beta", None, Some(0))], "{:?}", info.fields); - } - - /// A `Structure` that counts the objects read through it. - struct CountingReads<'a> { - doc: &'a Document, - reads: std::cell::Cell, - } - - impl Structure for CountingReads<'_> { - fn trailer(&self) -> &Dictionary { - &self.doc.trailer - } - fn get_object(&self, id: ObjectId) -> lopdf::Result<&Object> { - self.reads.set(self.reads.get() + 1); - self.doc.get_object(id) - } - fn encrypted(&self) -> bool { - false - } - } - - /// One comment referenced many times, or a page-shared `/Annots` array, is listed once, and many - /// small comments sharing one large `/Contents` string stop at the retained-bytes budget. - #[test] - fn repeated_annotations_are_listed_once_within_a_byte_budget() { - let small = "<< /Subtype /Text /Rect [0 0 1 1] /Contents 7 0 R >> ".repeat(1200); - let pdf = format!( - "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ - 2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >> endobj\n\ - 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots 5 0 R >> endobj\n\ - 4 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots 5 0 R >> endobj\n\ - 5 0 obj [{} {small}] endobj\n\ - 6 0 obj << /Type /Annot /Subtype /Text /Rect [0 0 1 1] /Contents (note) >> endobj\n\ - 7 0 obj ({}) endobj\ntrailer << /Root 1 0 R >>\n%%EOF", - "6 0 R ".repeat(1000), - "x".repeat(64 << 10), - ); - let doc = Document::load_mem(pdf.as_bytes()).expect("loads"); - let page = PageInfo { width: 200.0, height: 200.0, crop: [0.0, 0.0, 200.0, 200.0], rotation: 0, label: String::new() }; - let mut info = DocInfo { pages: vec![page.clone(), page], ..Default::default() }; - Inspector::new(&doc).fill(&mut info); - let notes = &info.annotations; - assert_eq!(notes.iter().filter(|a| a.contents.as_deref() == Some("note")).count(), 1); - assert!(notes.iter().all(|a| a.page == 0), "the shared array is the first page's"); - assert!(notes.len() > 1); - assert!(notes.iter().map(retained).sum::() <= MAX_ANNOTATION_BYTES); - } - - /// A link reached through an indirect object that holds only a reference to it is the same - /// annotation as the link itself, and so is a page array reached the same way: each is listed once. - #[test] - fn an_annotation_reached_through_a_reference_chain_is_listed_once() { - let pdf = "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ - 2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >> endobj\n\ - 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots 7 0 R >> endobj\n\ - 4 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots 8 0 R >> endobj\n\ - 5 0 obj 6 0 R endobj\n\ - 6 0 obj << /Type /Annot /Subtype /Link /Rect [0 0 1 1] /A << /S /URI /URI (https://example.com) >> >> endobj\n\ - 7 0 obj [5 0 R 6 0 R] endobj\n\ - 8 0 obj 7 0 R endobj\n\ - trailer << /Root 1 0 R >>\n%%EOF"; - let mut doc = Document::load_mem(pdf.as_bytes()).expect("loads"); - let mut saved = Vec::new(); - doc.save_to(&mut saved).expect("saves"); - let lazy = LazyStructure::new(Arc::new(saved), None).expect("opens"); - let page = PageInfo { width: 200.0, height: 200.0, crop: [0.0, 0.0, 200.0, 200.0], rotation: 0, label: String::new() }; - for reader in [&doc as &dyn Structure, &lazy] { - let mut info = DocInfo { pages: vec![page.clone(), page.clone()], ..Default::default() }; - Inspector::new(reader).fill(&mut info); - assert_eq!(info.links.len(), 1, "{:?}", info.links); - } - } - - /// A reply finds its comment where the listing keeps it: on the first page of a page-shared - /// `/Annots` array, whether its `/IRT` names the comment or an object holding a reference to it. - #[test] - fn a_reply_finds_its_comment_through_a_reference_chain_and_a_shared_array() { - let pdf = "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ - 2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >> endobj\n\ - 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots 7 0 R >> endobj\n\ - 4 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots 7 0 R >> endobj\n\ - 5 0 obj 6 0 R endobj\n\ - 6 0 obj << /Type /Annot /Subtype /Text /Rect [0 0 1 1] /Contents (comment) >> endobj\n\ - 7 0 obj [5 0 R 8 0 R 9 0 R] endobj\n\ - 8 0 obj << /Type /Annot /Subtype /Text /Rect [0 0 1 1] /Contents (to the comment) /IRT 6 0 R >> endobj\n\ - 9 0 obj << /Type /Annot /Subtype /Text /Rect [0 0 1 1] /Contents (through the chain) /IRT 5 0 R >> endobj\n\ - trailer << /Root 1 0 R >>\n%%EOF"; - let mut doc = Document::load_mem(pdf.as_bytes()).expect("loads"); - let mut saved = Vec::new(); - doc.save_to(&mut saved).expect("saves"); - let lazy = LazyStructure::new(Arc::new(saved), None).expect("opens"); - let page = PageInfo { width: 200.0, height: 200.0, crop: [0.0, 0.0, 200.0, 200.0], rotation: 0, label: String::new() }; - for reader in [&doc as &dyn Structure, &lazy] { - let mut info = DocInfo { pages: vec![page.clone(), page.clone()], ..Default::default() }; - Inspector::new(reader).fill(&mut info); - let notes = &info.annotations; - assert_eq!(notes.len(), 3, "{notes:?}"); - let comment = notes.iter().find(|a| a.contents.as_deref() == Some("comment")).expect("listed"); - assert_eq!((comment.page, comment.index, comment.is_reply()), (0, 0, false)); - for reply in ["to the comment", "through the chain"] { - let a = notes.iter().find(|a| a.contents.as_deref() == Some(reply)).expect("listed"); - assert_eq!((a.page, a.parent), (0, Some((0, 0))), "{reply}"); - } - } - } - - /// A conversion rejected as too long, or trimmed short or to nothing, still spends the raw - /// length, so a string many entries share is converted only a few times. - #[test] - fn rejected_text_conversions_spend_the_budget() { - let mut budget = 10; - let mut conversions = 0; - for _ in 0..10 { - // Each 0xFF becomes the three-byte replacement character. - let kept = spend(&[0xFF; 5], &mut budget, |b| { - conversions += 1; - String::from_utf8_lossy(b).into_owned() - }); - assert_eq!(kept, None); - } - assert_eq!((conversions, budget), (2, 0)); - - let pdf = "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ - 2 0 obj << /Type /Pages /Kids [] /Count 0 >> endobj\n\ - trailer << /Root 1 0 R >>\n%%EOF"; - let doc = Document::load_mem(pdf.as_bytes()).expect("loads"); - let d = Dictionary::from_iter([("Contents", Object::string_literal(" "))]); - let mut budget = 10; - assert_eq!(Inspector::new(&doc).budgeted_text(&d, b"Contents", &mut budget), None); - assert_eq!(budget, 7); - // Text that trims short spends its raw length. - let d = Dictionary::from_iter([("Contents", Object::string_literal(" x"))]); - let mut budget = 10; - assert_eq!(Inspector::new(&doc).budgeted_text(&d, b"Contents", &mut budget).as_deref(), Some("x")); - assert_eq!(budget, 3); - } - - /// An annotation attachment's `/Contents` spends the text budget only when it names the - /// attachment, so long descriptions of named files leave a later web address listed. - #[test] - fn a_named_attachment_leaves_its_contents_unspent() { - let files = "<< /Subtype /FileAttachment /Rect [0 0 1 1] /Contents 6 0 R /FS << /F (a.txt) >> >> ".repeat(20); - // Longer than what 20 MiB of charged `/Contents` would leave of the 16 MiB budget. - let uri = format!("https://example.com/{}", "a".repeat(2 << 20)); - let pdf = format!( - "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ - 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\ - 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [{files} \ - << /Subtype /Link /Rect [0 0 1 1] /A << /S /URI /URI ({uri}) >> >>] >> endobj\n\ - 6 0 obj ({}) endobj\n\ - trailer << /Root 1 0 R >>\n%%EOF", - "x".repeat(1 << 20), - ); - let doc = Document::load_mem(pdf.as_bytes()).expect("loads"); - let page = PageInfo { width: 200.0, height: 200.0, crop: [0.0, 0.0, 200.0, 200.0], rotation: 0, label: String::new() }; - let mut info = DocInfo { pages: vec![page], ..Default::default() }; - Inspector::new(&doc).fill(&mut info); - assert!(info.attachments.iter().all(|a| a.name == "a.txt")); - assert_eq!(info.links.len(), 1); - assert!(matches!(&info.links[0].target, LinkTarget::Uri(u) if *u == uri)); - } - - /// Embedded files whose name-tree keys share one long string keep it, as key and as name, only - /// within `MAX_LINK_ATTACHMENT_TEXT_BYTES`. - #[test] - fn embedded_file_keys_sharing_a_long_string_keep_it_within_a_budget() { - let entries = "4 0 R << /Type /Filespec >> ".repeat(20); - let pdf = format!( - "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles << /Names [{entries}] >> >> >> endobj\n\ - 2 0 obj << /Type /Pages /Kids [] /Count 0 >> endobj\n\ - 4 0 obj ({}) endobj\n\ - trailer << /Root 1 0 R >>\n%%EOF", - "x".repeat(1 << 20), - ); - let doc = Document::load_mem(pdf.as_bytes()).expect("loads"); - let mut info = DocInfo::default(); - Inspector::new(&doc).fill(&mut info); - let kept: usize = - info.attachments.iter().map(|a| a.name.len() + if let AttachmentSource::Document { key } = &a.source { key.len() } else { 0 }).sum(); - assert!(!info.attachments.is_empty()); - // Past the budget an attachment still has the generic name. - let generic = info.attachments.len() * "attachment".len(); - assert!(kept <= MAX_LINK_ATTACHMENT_TEXT_BYTES + generic, "{kept} bytes kept"); - } - - /// An embedded file the listing keeps is extracted, though earlier keys the listing could not - /// keep would fill a fresh key budget before it. - #[test] - fn a_listed_embedded_file_is_extracted_past_keys_the_listing_left_out() { - let skipped = "6 0 R 7 0 R ".repeat(9); - let pdf = format!( - "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles << /Names [{skipped}(b.txt) 9 0 R] >> >> >> endobj\n\ - 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\ - 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [\ - << /Subtype /Link /Rect [0 0 1 1] /A << /S /URI /URI ({}) >> >>] >> endobj\n\ - 6 0 obj ({}) endobj\n\ - 7 0 obj << /Type /Filespec /F (a.txt) >> endobj\n\ - 8 0 obj << /Type /EmbeddedFile /Length 5 >> stream\nhello\nendstream endobj\n\ - 9 0 obj << /Type /Filespec /F (b.txt) /EF << /F 8 0 R >> >> endobj\n\ - trailer << /Root 1 0 R >>\n%%EOF", - "u".repeat(15 << 20), - "k".repeat(2 << 20), - ); - let bytes = Arc::new(pdf.into_bytes()); - let info = inspect(bytes.clone(), None).expect("opens"); - let listed = info.attachments.iter().find(|a| a.name == "b.txt").expect("b.txt is listed"); - assert_eq!(attachment_data(&bytes, None, listed).as_deref(), Ok(&b"hello"[..])); - } - - /// Links and attachments sharing one long string each keep a copy only within - /// `MAX_LINK_ATTACHMENT_TEXT_BYTES`, and every one is still listed. - #[test] - fn links_and_attachments_sharing_a_long_string_keep_it_within_a_budget() { - const EACH: usize = 20; - let links = "<< /Subtype /Link /Rect [0 0 1 1] /A 5 0 R >> ".repeat(EACH); - let files = "<< /Subtype /FileAttachment /Rect [0 0 1 1] /FS 7 0 R >> ".repeat(EACH); - let pdf = format!( - "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ - 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\ - 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [{links} {files}] >> endobj\n\ - 5 0 obj << /S /URI /URI 6 0 R >> endobj\n\ - 6 0 obj ({}) endobj\n\ - 7 0 obj << /Type /Filespec /F (a.txt) /Desc 6 0 R >> endobj\n\ - trailer << /Root 1 0 R >>\n%%EOF", - "x".repeat(1 << 20), - ); - let doc = Document::load_mem(pdf.as_bytes()).expect("loads"); - let page = PageInfo { width: 200.0, height: 200.0, crop: [0.0, 0.0, 200.0, 200.0], rotation: 0, label: String::new() }; - let mut info = DocInfo { pages: vec![page], ..Default::default() }; - Inspector::new(&doc).fill(&mut info); - assert_eq!(info.links.len(), EACH); - assert_eq!(info.attachments.len(), EACH); - let uris = info.links.iter().map(|l| if let LinkTarget::Uri(u) = &l.target { u.len() } else { 0 }); - let descriptions = info.attachments.iter().map(|a| a.description.as_ref().map_or(0, String::len)); - let kept: usize = uris.chain(descriptions).sum(); - assert!(kept > 0 && kept <= MAX_LINK_ATTACHMENT_TEXT_BYTES, "{kept} bytes kept"); - assert!(info.links.iter().any(|l| matches!(&l.target, LinkTarget::Other(s) if s == "URI"))); - } - - /// Links and attachments after the comment budget fills are still listed, and a JavaScript link - /// among them still marks the document. - #[test] - fn links_and_attachments_past_the_comment_budget_are_listed() { - let small = "<< /Subtype /Text /Rect [0 0 1 1] /Contents 6 0 R >> ".repeat(1100); - let pdf = format!( - "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ - 2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >> endobj\n\ - 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [{small} \ - << /Subtype /Link /Rect [0 0 1 1] /A << /S /JavaScript /JS (1) >> >>] >> endobj\n\ - 4 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [\ - << /Subtype /FileAttachment /Rect [0 0 1 1] /FS << /Type /Filespec /F (a.txt) >> >>] >> endobj\n\ - 6 0 obj ({}) endobj\ntrailer << /Root 1 0 R >>\n%%EOF", - "x".repeat(64 << 10), - ); - let doc = Document::load_mem(pdf.as_bytes()).expect("loads"); - let page = PageInfo { width: 200.0, height: 200.0, crop: [0.0, 0.0, 200.0, 200.0], rotation: 0, label: String::new() }; - let mut info = DocInfo { pages: vec![page.clone(), page], ..Default::default() }; - Inspector::new(&doc).fill(&mut info); - assert!(info.annotations.len() < 1100, "the comment budget filled"); - assert_eq!(info.links.len(), 1); - assert!(info.has_javascript); - assert_eq!(info.attachments.len(), 1); - } - - /// Widgets without `/P` are placed on the page that lists them with one pass over the pages' - /// `/Annots`, not a search of every page per field: that was O(fields × annotations), a - /// second to open a form of 8,000 fields. A widget two pages list is on the first of them. - #[test] - fn widgets_without_a_page_entry_are_placed_in_one_pass() { - const PAGES: usize = 100; - const PER_PAGE: usize = 10; - let field = |p: usize, k: usize| 4 + PAGES + p * PER_PAGE + k; - let mut pdf = String::from("%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm 3 0 R >> endobj\n"); - let kids: Vec = (0..PAGES).map(|p| format!("{} 0 R", 4 + p)).collect(); - pdf += &format!("2 0 obj << /Type /Pages /Kids [{}] /Count {PAGES} >> endobj\n", kids.join(" ")); - let fields: Vec = (0..PAGES).flat_map(|p| (0..PER_PAGE).map(move |k| format!("{} 0 R", field(p, k)))).collect(); - pdf += &format!("3 0 obj << /Fields [{}] >> endobj\n", fields.join(" ")); - for p in 0..PAGES { - let mut annots: Vec = (0..PER_PAGE).map(|k| format!("{} 0 R", field(p, k))).collect(); - // The last page lists the first page's first widget too. - if p == PAGES - 1 { - annots.push(format!("{} 0 R", field(0, 0))); - } - pdf += &format!("{} 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [{}] >> endobj\n", 4 + p, annots.join(" ")); - } - for p in 0..PAGES { - for k in 0..PER_PAGE { - pdf += &format!("{} 0 obj << /Type /Annot /Subtype /Widget /FT /Tx /T (f{p}_{k}) /Rect [10 10 90 30] >> endobj\n", field(p, k)); - } - } - pdf += "trailer << /Root 1 0 R >>\n%%EOF"; - let doc = Document::load_mem(pdf.as_bytes()).expect("loads"); - let counting = CountingReads { doc: &doc, reads: std::cell::Cell::new(0) }; - let mut info = DocInfo { - pages: (0..PAGES) - .map(|_| PageInfo { width: 200.0, height: 200.0, crop: [0.0, 0.0, 200.0, 200.0], rotation: 0, label: String::new() }) - .collect(), - ..Default::default() - }; - Inspector::new(&counting).fill(&mut info); - let placed: Vec<_> = info.fields.iter().map(|f| (f.name.clone(), f.page)).collect(); - let want: Vec<_> = (0..PAGES).flat_map(|p| (0..PER_PAGE).map(move |k| (format!("f{p}_{k}"), Some(p)))).collect(); - assert_eq!(placed, want); - // A handful of reads per page and field; searching the pages for each field read the - // page dictionaries about 50,000 times. - let reads = counting.reads.get(); - assert!(reads < 20 * (PAGES + PAGES * PER_PAGE), "{reads} object reads"); - } - - /// #2365: the issue's fixture is two PDFs concatenated, the second cut off before its - /// cross-reference data, and the first's only trailer is a cross-reference stream. The - /// lazy reader refuses a file that needed repair, and lopdf's rebuild found no `trailer` - /// keyword and failed, so Document Properties showed every field empty. The rebuild now - /// takes the cross-reference stream's dictionary as the trailer. - #[test] - fn rebuilt_file_keeps_the_info_of_its_cross_reference_stream() { - let header = "%PDF-1.5\n"; - let obj1 = "1 0 obj<>endobj\n"; - let obj2 = "2 0 obj<>endobj\n"; - let obj3 = "3 0 obj<>endobj\n"; - let obj4 = "4 0 obj<>endobj\n"; - let o1 = header.len(); - let o2 = o1 + obj1.len(); - let o3 = o2 + obj2.len(); - let o4 = o3 + obj3.len(); - let o5 = o4 + obj4.len(); - let rows: Vec = - [[0u8, 0, 0, 255], [1, 0, o1 as u8, 0], [1, 0, o2 as u8, 0], [1, 0, o3 as u8, 0], [1, 0, o4 as u8, 0], [1, (o5 >> 8) as u8, o5 as u8, 0]] - .concat(); - let mut bytes = format!("{header}{obj1}{obj2}{obj3}{obj4}").into_bytes(); - bytes.extend_from_slice(format!("5 0 obj<>stream\n", rows.len()).as_bytes()); - bytes.extend_from_slice(&rows); - // A wrong startxref, and a second file after %%EOF: every reader rebuilds the table. - bytes.extend_from_slice(b"\nendstream\nendobj\nstartxref\n999999\n%%EOF\n%PDF-1.3\n"); - bytes.extend_from_slice(&[b'x'; 1024]); - - let info = inspect(Arc::new(bytes), None).unwrap(); - assert_eq!(info.pages.len(), 1); - assert_eq!(info.title.as_deref(), Some("Kept")); - assert_eq!(info.creator.as_deref(), Some("Me")); - } - - /// #2365 review: the rebuild prefers a trailer whose /Root is the catalog. A later - /// cross-reference stream whose /Root points at the Info dictionary used to replace the - /// earlier `trailer`, so Document Properties lost the catalog it should have read. - #[test] - fn rebuilt_file_prefers_a_trailer_whose_root_is_the_catalog() { - let mut bytes = b"%PDF-1.5\n".to_vec(); - for body in [ - "<>", - "<>", - "<>", - "<>", - ] { - let n = bytes.iter().filter(|b| **b == b'\n').count(); - bytes.extend_from_slice(format!("{n} 0 obj{body}endobj\n").as_bytes()); - } - bytes.extend_from_slice(b"trailer\n<>\n"); - bytes.extend_from_slice(b"5 0 obj<>stream\n\x01\x00\x09\x00\nendstream\nendobj\n"); - // A wrong startxref, and a second file after %%EOF: every reader rebuilds the table. - bytes.extend_from_slice(b"startxref\n999999\n%%EOF\n%PDF-1.3\n"); - bytes.extend_from_slice(&[b'x'; 1024]); - - let info = inspect(Arc::new(bytes.clone()), None).unwrap(); - assert_eq!(info.pages.len(), 1); - assert_eq!(info.title.as_deref(), Some("Kept")); - // A later stream whose /Root names the catalog's number with another generation does - // not count either: loading `1 1 R` finds no such object. - let at = bytes.windows(10).position(|w| w == b"/Root 4 0 ").unwrap(); - bytes[at..at + 10].copy_from_slice(b"/Root 1 1 "); - let info = inspect(Arc::new(bytes), None).unwrap(); - assert_eq!((info.pages.len(), info.title.as_deref()), (1, Some("Kept"))); - } - - #[test] - fn lazy_structure_matches_the_compatibility_inspector() { - // Each fixture as lopdf writes it, and with its objects in object streams. - let packed = |bytes: &[u8]| { - let doc = pdfcraft_cos::Document::open(Arc::new(bytes.to_vec())).unwrap(); - pdfcraft_cos::write_full(&doc, &pdfcraft_cos::SaveOptions { object_streams: true, ..Default::default() }).unwrap() - }; - let fixtures = [ATTACHMENTS, LAYERS, DESTS].into_iter().flat_map(|bytes| { - let mut original = Document::load_mem(bytes).unwrap(); - let mut plain = Vec::new(); - original.save_to(&mut plain).unwrap(); - let stm = packed(&plain); - [plain, stm] - }); - for bytes in fixtures { - let lazy = LazyStructure::new(Arc::new(bytes.clone()), None).unwrap(); - let eager = Document::load_mem(&bytes).unwrap(); - let mut a = inspect(Arc::new(bytes), None).unwrap(); - let mut b = DocInfo { pages: a.pages.clone(), ..Default::default() }; - let mut c = DocInfo { pages: a.pages.clone(), ..Default::default() }; - Inspector::new(&lazy).fill(&mut b); - Inspector::new(&eager).fill(&mut c); - assert!(!lazy.failed()); - assert_eq!(format!("{b:?}"), format!("{c:?}")); - a.file_size = 0; - a.pdf_version.clear(); - assert_eq!(format!("{a:?}"), format!("{c:?}")); - } - } - - #[test] - fn links_read_set_layer_actions() { - let info = inspect(Arc::new(LAYERS.to_vec()), None).expect("opens"); - let targets: Vec<_> = info.links.iter().map(|l| l.target.clone()).collect(); - use LayerOp::{Off, On, Toggle}; - assert_eq!( - targets, - [ - // Groups before the first name or after an unknown one are skipped, and so is - // anything that isn't a group. - LinkTarget::SetLayers { changes: vec![(On, (6, 0)), (Toggle, (5, 0)), (Toggle, (7, 0))], preserve_rb: false }, - // An indirect /State; /PreserveRB defaults to true. - LinkTarget::SetLayers { changes: vec![(Off, (5, 0))], preserve_rb: true }, - ] - ); - } - - /// #4168: a Launch action naming a `.pdf` file is read as the link to that file it amounts - /// to, `\` separators included, and a stray `/D` (which Launch has no use for) doesn't stop - /// it. One naming anything else, with no file, or with `/Win` launch parameters stays an - /// action that isn't followed. - #[test] - fn launch_actions_that_name_a_pdf_open_it_like_a_remote_link() { - let pdf = "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ - 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >> endobj\n\ - 3 0 obj << /Type /Page /Parent 2 0 R /Annots [30 0 R 31 0 R 32 0 R 33 0 R 34 0 R 35 0 R 36 0 R 37 0 R 38 0 R] >> endobj\n\ - 30 0 obj << /Type /Annot /Subtype /Link /Rect [0 0 10 10] /A << /S /Launch /F (..\\\\se\\\\se_ST.pdf) >> >> endobj\n\ - 31 0 obj << /Type /Annot /Subtype /Link /Rect [0 20 10 30] /A << /S /Launch /F << /Type /Filespec /F (chapters/Two.PDF) >> /NewWindow true >> >> endobj\n\ - 32 0 obj << /Type /Annot /Subtype /Link /Rect [0 40 10 50] /A << /S /Launch /F (tool.exe) >> >> endobj\n\ - 33 0 obj << /Type /Annot /Subtype /Link /Rect [0 60 10 70] /A << /S /Launch /F (a.pdf) /Win << /F (cmd.exe) /P (/c x) >> >> >> endobj\n\ - 34 0 obj << /Type /Annot /Subtype /Link /Rect [0 80 10 90] /A << /S /Launch /Win << /F (b.pdf) >> >> >> endobj\n\ - 35 0 obj << /Type /Annot /Subtype /Link /Rect [0 100 10 110] /A << /S /Launch /F (.pdf) >> >> endobj\n\ - 36 0 obj << /Type /Annot /Subtype /Link /Rect [0 120 10 130] /A << /S /Launch /F (dir/a\\\\b.pdf) >> >> endobj\n\ - 37 0 obj << /Type /Annot /Subtype /Link /Rect [0 140 10 150] /A << /S /Launch /FS /URL /F (https://example.com/x.pdf) >> >> endobj\n\ - 38 0 obj << /Type /Annot /Subtype /Link /Rect [0 160 10 170] /A << /S /Launch /F (c.pdf) /D 0 >> >> endobj\n\ - trailer << /Root 1 0 R >>\n%%EOF"; - let info = inspect(Arc::new(pdf.as_bytes().to_vec()), None).expect("opens"); - let targets: Vec<_> = info.links.iter().map(|l| l.target.clone()).collect(); - let launch = - |file: &str, new_window| LinkTarget::RemoteGoTo { launch: true, file: file.into(), destination: RemoteDestination::Default, new_window }; - let other = || LinkTarget::Other("Launch".into()); - assert_eq!( - targets, - [ - launch("../se/se_ST.pdf", None), - launch("chapters/Two.PDF", Some(true)), - other(), - other(), - other(), - other(), - launch("dir/a\\b.pdf", None), - other(), - launch("c.pdf", None), - ] - ); - } - - #[test] - fn links_read_bounded_local_remote_go_to_actions() { - use DestView::{Fit, Xyz}; - let too_long = "a".repeat(MAX_REMOTE_FILE_SPEC_BYTES + 1); - let wide_file = format!("", "4E00".repeat(1400)); - let wide_name = format!("", "4E00".repeat(342)); - let pdf = format!( - "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ - 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >> endobj\n\ - 3 0 obj << /Type /Page /Parent 2 0 R /Annots [99 0 R 30 0 R 31 0 R 32 0 R 33 0 R 34 0 R 35 0 R 36 0 R 37 0 R 38 0 R 39 0 R 40 0 R 41 0 R] >> endobj\n\ - 30 0 obj << /Type /Annot /Subtype /Link /Rect [0 0 10 10] /A << /S /GoToR /F (chapters/remote.pdf) /D [2 /XYZ 10 20 1.5] /NewWindow true >> >> endobj\n\ - 31 0 obj << /Type /Annot /Subtype /Link /Rect [0 20 10 30] /A << /S /GoToR /F << /Type /Filespec /F (fallback.pdf) /UF >> /D [0 /Fit] /NewWindow false >> >> endobj\n\ - 32 0 obj << /Type /Annot /Subtype /Link /Rect [0 40 10 50] /A << /S /GoToR /F (remote.pdf) >> >> endobj\n\ - 33 0 obj << /Type /Annot /Subtype /Link /Rect [0 60 10 70] /A << /S /GoToR /F (chapter.pdf) /D (chapter) >> >> endobj\n\ - 34 0 obj << /Type /Annot /Subtype /Link /Rect [0 80 10 90] /A << /S /GoToR /FS /URL /F (https://example.com) /D [0 /Fit] >> >> endobj\n\ - 35 0 obj << /Type /Annot /Subtype /Link /Rect [0 100 10 110] /A << /S /GoToR /F ({too_long}) /D [0 /Fit] >> >> endobj\n\ - 36 0 obj << /Type /Annot /Subtype /Link /Rect [0 120 10 130] /A << /S /GoToR /F (chapter.pdf) /D /chapter >> >> endobj\n\ - 37 0 obj << /Type /Annot /Subtype /Link /Rect [0 140 10 150] /A << /S /GoToR /F (chapter.pdf) /D [-1 /Fit] >> >> endobj\n\ - 38 0 obj << /Type /Annot /Subtype /Link /Rect [0 160 10 170] /A << /S /GoToR /F (chapter.pdf) /D [1.5 /Fit] >> >> endobj\n\ - 39 0 obj << /Type /Annot /Subtype /Link /Rect [0 180 10 190] /A << /S /GoToR /F (chapter.pdf) /D [9223372036854775807 /Fit] >> >> endobj\n\ - 40 0 obj << /Type /Annot /Subtype /Link /Rect [0 200 10 210] /A << /S /GoToR /F << /Type /Filespec /UF {wide_file} >> /D [0 /Fit] >> >> endobj\n\ - 41 0 obj << /Type /Annot /Subtype /Link /Rect [0 220 10 230] /A << /S /GoToR /F (chapter.pdf) /D {wide_name} >> >> endobj\n\ - trailer << /Root 1 0 R >>\n%%EOF" - ); - let info = inspect(Arc::new(pdf.into_bytes()), None).expect("opens"); - let targets: Vec<_> = info.links.iter().map(|l| (l.index, l.target.clone())).collect(); - let huge_page_target = usize::try_from(i64::MAX) - .map(|page| LinkTarget::RemoteGoTo { - launch: false, - file: "chapter.pdf".into(), - destination: RemoteDestination::Page { page, view: Fit }, - new_window: None, - }) - .unwrap_or_else(|_| LinkTarget::Other("GoToR".into())); - assert_eq!( - targets, - [ - ( - 1, - LinkTarget::RemoteGoTo { - launch: false, - file: "chapters/remote.pdf".into(), - destination: RemoteDestination::Page { page: 2, view: Xyz { left: Some(10.0), top: Some(20.0), zoom: Some(1.5) } }, - new_window: Some(true), - } - ), - ( - 2, - LinkTarget::RemoteGoTo { - launch: false, - file: "foo.pdf".into(), - destination: RemoteDestination::Page { page: 0, view: Fit }, - new_window: Some(false), - } - ), - (3, LinkTarget::RemoteGoTo { launch: false, file: "remote.pdf".into(), destination: RemoteDestination::Default, new_window: None }), - ( - 4, - LinkTarget::RemoteGoTo { - launch: false, - file: "chapter.pdf".into(), - destination: RemoteDestination::Named("chapter".into()), - new_window: None - } - ), - (5, LinkTarget::Other("GoToR".into())), - (6, LinkTarget::Other("GoToR".into())), - ( - 7, - LinkTarget::RemoteGoTo { - launch: false, - file: "chapter.pdf".into(), - destination: RemoteDestination::Named("chapter".into()), - new_window: None - } - ), - (8, LinkTarget::Other("GoToR".into())), - (9, LinkTarget::Other("GoToR".into())), - (10, huge_page_target), - (11, LinkTarget::Other("GoToR".into())), - (12, LinkTarget::Other("GoToR".into())), - ] - ); - } - - #[test] - fn destination_operands_become_views() { - use DestView::*; - let v = |kind: &[u8], ops: &[Option]| DestView::from_operands(Some(kind), ops); - // ISO 32000-2 Table 149, each type. - assert_eq!(v(b"XYZ", &[Some(0.0), Some(420.0), Some(0.0)]), Xyz { left: Some(0.0), top: Some(420.0), zoom: None }); - assert_eq!(v(b"XYZ", &[Some(72.0), Some(500.0), Some(1.5)]), Xyz { left: Some(72.0), top: Some(500.0), zoom: Some(1.5) }); - assert_eq!(v(b"Fit", &[]), Fit); - assert_eq!(v(b"FitB", &[]), Fit); - assert_eq!(v(b"FitH", &[Some(250.0)]), FitH { top: Some(250.0) }); - assert_eq!(v(b"FitBH", &[None]), FitH { top: None }); - assert_eq!(v(b"FitV", &[Some(120.0)]), FitV { left: Some(120.0) }); - assert_eq!(v(b"FitBV", &[Some(5.0)]), FitV { left: Some(5.0) }); - // FitR's corners are normalized. - assert_eq!(v(b"FitR", &[Some(110.0), Some(220.0), Some(10.0), Some(20.0)]), FitR { rect: [10.0, 20.0, 110.0, 220.0] }); - // Missing operands are null; so are non-finite ones, and a zoom that isn't positive. - assert_eq!(v(b"XYZ", &[]), Xyz { left: None, top: None, zoom: None }); - assert_eq!(v(b"XYZ", &[Some(f32::NAN), Some(f32::INFINITY), Some(f32::NEG_INFINITY)]), Xyz { left: None, top: None, zoom: None }); - assert_eq!(v(b"XYZ", &[None, Some(-5.0), Some(-1.0)]), Xyz { left: None, top: Some(-5.0), zoom: None }); - assert_eq!(v(b"XYZ", &[None, None, Some(f32::NAN)]), Xyz { left: None, top: None, zoom: None }); - assert_eq!(v(b"FitH", &[Some(f32::NAN)]), FitH { top: None }); - // A FitR without four usable numbers, an unknown type or no type: the top of the page. - assert_eq!(v(b"FitR", &[Some(1.0), Some(2.0)]), Top); - assert_eq!(v(b"FitR", &[Some(1.0), Some(2.0), Some(f32::INFINITY), Some(4.0)]), Top); - assert_eq!(v(b"Zoom", &[Some(3.0)]), Top); - assert_eq!(v(b"xyz", &[Some(3.0)]), Top); - assert_eq!(DestView::from_operands(None, &[Some(1.0)]), Top); - } - - /// Two 300×400 pages. Bookmarks and links on page 1 go to page 2 in every destination form, - /// named (`/Names /Dests` and the PDF 1.1 `/Dests`) and malformed. - const DESTS: &[u8] = b"%PDF-1.7 -1 0 obj << /Type /Catalog /Pages 2 0 R /Outlines 10 0 R /Names << /Dests 40 0 R >> /Dests << /old << /D [4 0 R /FitV 120] >> >> >> endobj -2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 300 400] >> endobj -3 0 obj << /Type /Page /Parent 2 0 R /Annots [30 0 R 31 0 R 32 0 R 33 0 R] >> endobj -4 0 obj << /Type /Page /Parent 2 0 R >> endobj -10 0 obj << /Type /Outlines /First 11 0 R /Last 20 0 R /Count 10 >> endobj -11 0 obj << /Title (xyz) /Parent 10 0 R /Next 12 0 R /Dest [4 0 R /XYZ 0 420 0] >> endobj -12 0 obj << /Title (fith) /Parent 10 0 R /Next 13 0 R /Dest [4 0 R /FitH 250] >> endobj -13 0 obj << /Title (named) /Parent 10 0 R /Next 14 0 R /Dest (sec) >> endobj -14 0 obj << /Title (old named) /Parent 10 0 R /Next 15 0 R /Dest /old >> endobj -15 0 obj << /Title (action) /Parent 10 0 R /Next 16 0 R /A << /S /GoTo /D [4 0 R /FitR 110 220 10 20] >> >> endobj -16 0 obj << /Title (wrong types) /Parent 10 0 R /Next 17 0 R /Dest [4 0 R /XYZ (left) /Top true] >> endobj -17 0 obj << /Title (short) /Parent 10 0 R /Next 18 0 R /Dest [4 0 R /FitR 1 2] >> endobj -18 0 obj << /Title (no type) /Parent 10 0 R /Next 19 0 R /Dest [4 0 R] >> endobj -19 0 obj << /Title (huge) /Parent 10 0 R /Next 20 0 R /Dest [4 0 R /XYZ 1000000000000 -5 -1] >> endobj -20 0 obj << /Title (no page) /Parent 10 0 R /Dest [/XYZ 0 420 0] >> endobj -30 0 obj << /Type /Annot /Subtype /Link /Rect [0 0 10 10] /Dest [4 0 R /XYZ 72 500 1.5] >> endobj -31 0 obj << /Type /Annot /Subtype /Link /Rect [0 20 10 30] /A << /S /GoTo /D (sec) >> >> endobj -32 0 obj << /Type /Annot /Subtype /Link /Rect [0 40 10 50] /Dest [4 0 R /FitBH 100] >> endobj -33 0 obj << /Type /Annot /Subtype /Link /Rect [0 60 10 70] /Dest [4 0 R /Fit] >> endobj -40 0 obj << /Names [(sec) << /D [4 0 R /XYZ null 300 2] >>] >> endobj -trailer << /Root 1 0 R >> -%%EOF"; - - #[test] - fn bookmarks_and_links_keep_their_destination_view() { - use DestView::*; - let info = inspect(Arc::new(DESTS.to_vec()), None).expect("opens"); - let outline: Vec<_> = info.outline.iter().map(|o| (o.title.as_str(), o.page, o.view)).collect(); - assert_eq!( - outline, - [ - ("xyz", Some(1), Xyz { left: Some(0.0), top: Some(420.0), zoom: None }), - ("fith", Some(1), FitH { top: Some(250.0) }), - ("named", Some(1), Xyz { left: None, top: Some(300.0), zoom: Some(2.0) }), - ("old named", Some(1), FitV { left: Some(120.0) }), - ("action", Some(1), FitR { rect: [10.0, 20.0, 110.0, 220.0] }), - // Hostile operands fall back without failing the bookmark. - ("wrong types", Some(1), Xyz { left: None, top: None, zoom: None }), - ("short", Some(1), Top), - ("no type", Some(1), Top), - ("huge", Some(1), Xyz { left: Some(1e12), top: Some(-5.0), zoom: None }), - ("no page", None, Top), - ] - ); - let links: Vec<_> = info.links.iter().map(|l| l.target.clone()).collect(); - assert_eq!( - links, - [ - LinkTarget::Page(1, Xyz { left: Some(72.0), top: Some(500.0), zoom: Some(1.5) }), - LinkTarget::Page(1, Xyz { left: None, top: Some(300.0), zoom: Some(2.0) }), - LinkTarget::Page(1, FitH { top: Some(100.0) }), - LinkTarget::Page(1, Fit), - ] - ); - } - - #[test] - fn outline_titles_decode_every_text_string_encoding() { - // Issue #142: CJK bookmark titles showed as mojibake or with a stray BOM. - let pdf = b"%PDF-1.7 -1 0 obj << /Type /Catalog /Pages 2 0 R /Outlines 10 0 R >> endobj -2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >> endobj -3 0 obj << /Type /Page /Parent 2 0 R >> endobj -10 0 obj << /Type /Outlines /First 11 0 R /Last 16 0 R /Count 6 >> endobj -11 0 obj << /Title /Parent 10 0 R /Next 12 0 R /Dest [3 0 R /Fit] >> endobj -12 0 obj << /Title /Parent 10 0 R /Next 13 0 R /Dest [3 0 R /Fit] >> endobj -13 0 obj << /Title /Parent 10 0 R /Next 14 0 R /Dest [3 0 R /Fit] >> endobj -14 0 obj << /Title /Parent 10 0 R /Next 15 0 R /Dest [3 0 R /Fit] >> endobj -15 0 obj << /Title <436166E9> /Parent 10 0 R /Next 16 0 R /Dest [3 0 R /Fit] >> endobj -16 0 obj << /Title /Parent 10 0 R /Dest [3 0 R /Fit] >> endobj -trailer << /Root 1 0 R >> -%%EOF"; - let info = inspect(Arc::new(pdf.to_vec()), None).expect("opens"); - let titles: Vec<_> = info.outline.iter().map(|o| o.title.as_str()).collect(); - // UTF-16BE, UTF-8 with BOM, invalid UTF-8 after a BOM (lossy, not dropped), BOM-less - // raw UTF-8, PDFDocEncoding Latin-1, and a doubled BOM. - assert_eq!(titles, ["第一章", "目录", "A\u{FFFD}B", "概述", "Café", "A"]); - } - - #[test] - fn xfa_dynamic_render_is_read_from_config_acrobat7_only() { - let text = xfa_config_dynamic_render; - let config = - |inner: &str| format!("{inner}"); - // Designer's style: a line break before each `>`. - assert_eq!( - text("forbidden").as_deref(), - Some("forbidden") - ); - assert_eq!(text(&config(" required ")).as_deref(), Some("required")); - // Commented out, in CDATA, or a longer name: not the element. - assert_eq!(text(&config("")), None); - assert_eq!(text(&config(" b forbidden ]]>")), None); - assert_eq!(text(&config("forbidden")), None); - // Self-closing, empty, or holding markup: no value. - assert_eq!(text(&config("")), None); - assert_eq!(text(&config("")), None); - assert_eq!(text(&config("forbidden")), None); - // Closing tags that don't match what they close: malformed, no value (review on #3192). - assert_eq!(text(&config("forbidden")), None); - assert_eq!(text("forbidden"), None); - // The same name anywhere but config/acrobat/acrobat7 (form data, another config branch). - let xdp = "forbiddenforbidden"; - assert_eq!(text(xdp), None); - // A whole XDP with the config after the template. - let xdp = format!("{}", config("forbidden")); - assert_eq!(text(&xdp).as_deref(), Some("forbidden")); - // A whole config path inside the form data, before the real config packet, is data: the - // real packet's value counts (review on #3192). - let xdp = format!( - "{}{}", - config("forbidden"), - config("required") - ); - assert_eq!(text(&xdp).as_deref(), Some("required")); - // Only in the data, or nested in the template: no configuration at all. - let xdp = - format!("{}", config("forbidden")); - assert_eq!(text(&xdp), None); - let xdp = format!("", config("forbidden")); - assert_eq!(text(&xdp), None); - // acrobat7 must be a child of acrobat, not deeper. - assert_eq!(text("forbidden"), None); - // Cut short anywhere: no panic, nothing read. - let whole = config("forbidden"); - for end in 0..whole.len() { - assert_eq!(text(&whole[..end]), None, "cut at {end}"); - } - assert_eq!(text(&whole).as_deref(), Some("forbidden")); - } - - #[test] - fn xfa_config_packets_are_read_in_utf16_too() { - let xml = "forbidden"; - let le: Vec = xml.encode_utf16().flat_map(u16::to_le_bytes).collect(); - let be: Vec = xml.encode_utf16().flat_map(u16::to_be_bytes).collect(); - for bytes in [ - le.clone(), - be.clone(), - [&[0xFF, 0xFE][..], &le].concat(), - [&[0xFE, 0xFF][..], &be].concat(), - [&[0xEF, 0xBB, 0xBF][..], xml.as_bytes()].concat(), - ] { - assert_eq!(xfa_packet_text(&bytes), xml); - } - // An odd byte or a broken surrogate doesn't stop the scan. - assert!(xfa_packet_text(&[0xFF, 0xFE, b'<', 0, 0x00, 0xD8, b'x']).starts_with('<')); - } - - /// #3180: an XFA form with no fields was always taken to be dynamic, so its pages were - /// replaced by a layout on open, even when its configuration says it is a static form. - #[test] - fn an_xfa_form_without_fields_is_static_when_its_config_forbids_dynamic_rendering() { - let xfa_of = |config: &str, catalog_extra: &str| { - let bodies = [ - format!("<< /Type /Catalog /Pages 2 0 R {catalog_extra} /AcroForm << /Fields [] /XFA [(config) 4 0 R (template) 5 0 R] >> >>"), - "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>".to_string(), - "<< /Type /Page /Parent 2 0 R >>".to_string(), - format!("<< /Length {} >>\nstream\n{config}\nendstream", config.len()), - "<< /Length 11 >>\nstream\n
3kept nowherex
")))).unwrap(); diff --git a/crates/xfdf/src/common.rs b/crates/xfdf/src/common.rs new file mode 100644 index 0000000000..c4ac9f600d --- /dev/null +++ b/crates/xfdf/src/common.rs @@ -0,0 +1,266 @@ +use super::{DataError, Dict, Document, Object}; + +// ── names ─────────────────────────────────────────────────────────────────────────────────── + +/// Form data files are attacker-supplied. Refuse a DTD and bound the node count, exactly as the +/// XFA template parser does (`crates/xfa/src/parse.rs`), and bound how deep a nested field tree +/// may go: the walk below recurses once per nesting level. +const MAX_NODES: u32 = 2_000_000; + +pub(crate) const MAX_DEPTH: usize = 64; + +/// The deepest element nesting accepted before the text reaches the XML parser. +/// +/// roxmltree parses by recursive descent, so its stack use grows with the nesting depth: ten +/// thousand levels overflow the main thread's stack and abort the process. A stack overflow is +/// `abort()`, so `catch_unwind` cannot intercept it and the whole application dies - which is why +/// the bound has to be applied before parsing, not while walking the result. Around three thousand +/// levels still parse on the default stack, so this stays clear of the cliff while leaving far more +/// room than the walks below, which cut off at 64. +pub(crate) const MAX_XML_DEPTH: usize = 1_024; + +/// Element nesting depth of `text`, skipping comments, CDATA, processing instructions, the DOCTYPE +/// and quoted attribute values. A tag that closes itself (``) is one level while it is read +/// and does not stay open, so a long run of them cannot accumulate into the bound. It cannot +/// under-count a real element; a stray `<` in text is invalid XML and only makes it over-count, +/// which errs towards refusing the file. +pub(crate) fn xml_nesting_depth(text: &str) -> usize { + fn find(hay: &[u8], from: usize, needle: &[u8]) -> Option { + (from..hay.len()).find(|&p| hay[p..].starts_with(needle)) + } + let b = text.as_bytes(); + let (mut i, mut depth, mut max) = (0usize, 0usize, 0usize); + while i < b.len() { + if b[i] != b'<' { + i += 1; + } else if b[i..].starts_with(b"").map_or(b.len(), |p| p + 3); + } else if b[i..].starts_with(b"").map_or(b.len(), |p| p + 3); + } else if b[i..].starts_with(b"").map_or(b.len(), |p| p + 2); + } else if b[i..].starts_with(b"").map_or(b.len(), |p| p + 1); + } else if b[i..].starts_with(b"").map_or(b.len(), |p| p + 1); + } else { + depth += 1; + max = max.max(depth); + // Skip the rest of the start tag, over any quoted attribute value, and remember its last + // significant byte: a tag that ends in `/` closes itself, so it is one level deep while it + // is read rather than a level left open. + i += 1; + let mut quote = 0u8; + let mut last = 0u8; + while i < b.len() { + let c = b[i]; + if quote != 0 { + if c == quote { + quote = 0; + } + } else if c == b'"' || c == b'\'' { + quote = c; + } else if c == b'>' { + i += 1; + break; + } else if !c.is_ascii_whitespace() { + last = c; + } + i += 1; + } + if last == b'/' { + depth -= 1; + } + } + } + max +} + +pub(crate) fn check_form_xml_depth(text: &str) -> Result<(), DataError> { + let depth = xml_nesting_depth(text); + if depth > MAX_XML_DEPTH { + return Err(DataError::Malformed(format!("the file nests XML elements {depth} deep, past the {MAX_XML_DEPTH} the reader accepts"))); + } + Ok(()) +} + +pub(crate) fn parse_form_xml(text: &str) -> Result, roxmltree::Error> { + roxmltree::Document::parse_with_options(text, roxmltree::ParsingOptions { allow_dtd: false, nodes_limit: MAX_NODES }) +} + +pub(crate) const SUBTYPES: [(&str, &str); 17] = [ + ("Text", "text"), + ("FreeText", "freetext"), + ("Line", "line"), + ("Square", "square"), + ("Circle", "circle"), + ("Polygon", "polygon"), + ("PolyLine", "polyline"), + ("Highlight", "highlight"), + ("Underline", "underline"), + ("Squiggly", "squiggly"), + ("StrikeOut", "strikeout"), + ("Stamp", "stamp"), + ("Caret", "caret"), + ("Ink", "ink"), + ("FileAttachment", "fileattachment"), + ("Sound", "sound"), + ("Redact", "redact"), +]; + +pub(crate) const FLAGS: [(i64, &str); 9] = [ + (1, "invisible"), + (2, "hidden"), + (4, "print"), + (8, "nozoom"), + (16, "norotate"), + (32, "noview"), + (64, "readonly"), + (128, "locked"), + (256, "togglenoview"), +]; + +pub(crate) fn esc(s: &str) -> String { + let mut o = String::with_capacity(s.len()); + for c in s.chars() { + match c { + '&' => o.push_str("&"), + '<' => o.push_str("<"), + '>' => o.push_str(">"), + '"' => o.push_str("""), + // XML 1.0 §3.3.3 normalizes literal attribute whitespace, but not references. + '\t' => o.push_str(" "), + '\n' => o.push_str(" "), + '\r' => o.push_str(" "), + c if (c as u32) < 0x20 && c != '\n' && c != '\t' => {} + // XML 1.0 noncharacters are not allowed even as raw text. + '\u{FFFE}' | '\u{FFFF}' => {} + c => o.push(c), + } + } + o +} + +pub(crate) fn n(v: f64) -> String { + let s = format!("{v:.4}"); + let s = s.trim_end_matches('0').trim_end_matches('.'); + if s == "-0" { "0".into() } else { s.into() } +} + +pub(crate) fn hex(c: &[f64]) -> Option { + let rgb = match c.len() { + 1 => [c[0]; 3], + 3 => [c[0], c[1], c[2]], + 4 => [(1.0 - c[0]) * (1.0 - c[3]), (1.0 - c[1]) * (1.0 - c[3]), (1.0 - c[2]) * (1.0 - c[3])], + _ => return None, + }; + Some(format!( + "#{:02X}{:02X}{:02X}", + (rgb[0].clamp(0.0, 1.0) * 255.0).round() as u8, + (rgb[1].clamp(0.0, 1.0) * 255.0).round() as u8, + (rgb[2].clamp(0.0, 1.0) * 255.0).round() as u8 + )) +} + +pub(crate) fn parse_hex(s: &str) -> Option<[f64; 3]> { + let h = s.trim().trim_start_matches('#'); + if h.len() != 6 || !h.bytes().all(|b| b.is_ascii_hexdigit()) { + return None; + } + let p = |i: usize| h.get(i..i + 2).and_then(|s| u8::from_str_radix(s, 16).ok()).map(|v| f64::from(v) / 255.0); + Some([p(0)?, p(2)?, p(4)?]) +} + +pub(crate) fn nums_of(doc: &Document, o: Option<&Object>) -> Vec { + o.map(|o| doc.resolve(o)).and_then(|a| a.as_array().map(|a| a.iter().filter_map(|x| doc.resolve(x).as_f64()).collect())).unwrap_or_default() +} + +/// A `/Rect` of exactly four finite numbers; any other entry makes the whole rectangle unusable. +pub(crate) fn rect_strict(doc: &Document, o: Option<&Object>) -> Option<[f64; 4]> { + let num = |x: &Object| doc.resolve(x).as_f64().filter(|v| v.is_finite()); + match doc.resolve(o?).as_array()?.as_slice() { + [x0, y0, x1, y1] => Some([num(x0)?, num(y0)?, num(x1)?, num(y1)?]), + _ => None, + } +} + +/// Whether a quad's four corners enclose any area: some three of them are not collinear. +pub(crate) fn quad_has_area(q: &[f64; 8]) -> bool { + let [ax, ay, bx, by, cx, cy, dx, dy] = *q; + let cross = |(px, py): (f64, f64), (qx, qy): (f64, f64), (rx, ry): (f64, f64)| (qx - px) * (ry - py) - (qy - py) * (rx - px); + let (a, b, c, d) = ((ax, ay), (bx, by), (cx, cy), (dx, dy)); + [cross(a, b, c), cross(a, b, d), cross(a, c, d), cross(b, c, d)].iter().any(|v| v.abs() > 0.0) +} + +pub(crate) fn text_of(doc: &Document, d: &Dict, k: &[u8]) -> Option { + d.get(k).and_then(|o| match &*doc.resolve(o) { + Object::String(s) => Some(s.to_text()), + Object::Name(n) => Some(pdfcraft_forms::name_text(n)), + _ => None, + }) +} + +/// The numbers of a list; empty when any entry is non-finite (`nan`, `inf`), so the list is unusable as a whole. +pub(crate) fn csv(list: &str) -> Vec { + let v: Vec = list.split([',', ';', ' ']).filter(|t| !t.is_empty()).filter_map(|t| t.trim().parse().ok()).collect(); + if v.iter().all(|x| x.is_finite()) { v } else { Vec::new() } +} + +pub(crate) fn arr(v: &[f64]) -> Object { + Object::Array(v.iter().map(|x| Object::Real(*x)).collect()) +} + +/// XML 1.0 fifth-edition NameStartChar, excluding `:` for an unprefixed NCName. +/// https://www.w3.org/TR/2008/REC-xml-20081126/#NT-NameStartChar +fn xml_name_start(c: char) -> bool { + matches!( + c, + '_' | 'A'..='Z' | 'a'..='z' + | '\u{C0}'..='\u{D6}' | '\u{D8}'..='\u{F6}' | '\u{F8}'..='\u{2FF}' + | '\u{370}'..='\u{37D}' | '\u{37F}'..='\u{1FFF}' + | '\u{200C}'..='\u{200D}' | '\u{2070}'..='\u{218F}' + | '\u{2C00}'..='\u{2FEF}' | '\u{3001}'..='\u{D7FF}' + | '\u{F900}'..='\u{FDCF}' | '\u{FDF0}'..='\u{FFFD}' + | '\u{10000}'..='\u{EFFFF}' + ) +} + +fn xml_name_char(c: char) -> bool { + xml_name_start(c) || matches!(c, '-' | '.' | '0'..='9' | '\u{B7}' | '\u{300}'..='\u{36F}' | '\u{203F}'..='\u{2040}') +} + +/// Keep the existing letters/digits/`_`/`-`/`.` normalization, constrained to valid NCNames. +pub(crate) fn xml_name(name: &str) -> String { + let mut s: String = + name.chars().map(|c| if (c.is_alphanumeric() && xml_name_char(c)) || matches!(c, '_' | '-' | '.') { c } else { '_' }).collect(); + if !s.chars().next().is_some_and(|c| (c.is_alphabetic() && xml_name_start(c)) || c == '_') { + s.insert(0, '_'); + } + s +} + +pub(crate) fn walk_xfdf(node: roxmltree::Node, prefix: &str, out: &mut Vec<(String, Vec)>, depth: usize) -> Result<(), DataError> { + if depth > MAX_DEPTH { + return Err(DataError::Malformed(format!("XFDF fields nest more than {MAX_DEPTH} deep"))); + } + for f in node.children().filter(|c| c.has_tag_name("field")) { + let Some(name) = f.attribute("name") else { continue }; + let full = if prefix.is_empty() { name.to_string() } else { format!("{prefix}.{name}") }; + let vals: Vec = f + .children() + .filter(|c| c.has_tag_name("value")) + // Comments and processing instructions can split a value into several text nodes. + .map(|v| v.children().filter(|n| n.is_text()).filter_map(|n| n.text()).collect()) + .collect(); + // A leaf with no `` is an empty field (the export writes one that way), so it + // clears the field like FDF's `/V ()`; a field with kids only names their parent. A + // leaf holding only `` is not read yet, so it leaves the field alone. + let names_kids_or_rich = f.children().any(|c| c.has_tag_name("field") || c.has_tag_name("value-richtext")); + if !vals.is_empty() || !names_kids_or_rich { + out.push((full.clone(), vals)); + } + walk_xfdf(f, &full, out, depth + 1)?; + } + Ok(()) +} diff --git a/crates/xfdf/src/fdf.rs b/crates/xfdf/src/fdf.rs new file mode 100644 index 0000000000..34b59017d2 --- /dev/null +++ b/crates/xfdf/src/fdf.rs @@ -0,0 +1,516 @@ +use super::common::{MAX_DEPTH, SUBTYPES, rect_strict, text_of}; +use super::xfdf::{add_popup, apply_values, find_by_name, place}; +use super::*; + +fn pdf_string(s: &str) -> Vec { + let mut v = Vec::new(); + pdfcraft_cos::serialize(&Object::String(PdfString::text(s)), &mut v); + v +} + +// Keep aliases as aliases instead of expanding a shared graph once per use. References are +// queued, not followed recursively, so cycles and long indirect chains cost linear work. +pub(crate) const MAX_FDF_OBJECTS: usize = 100_000; + +pub(crate) const MAX_FDF_BYTES: usize = 64 * 1024 * 1024; + +/// Direct values stay borrowed: `Document::resolve` would deep-clone them. Only +/// indirect values need an owner, and annotation bodies are never retained in discovery. +enum FdfValue<'a> { + Borrowed(&'a Object), + Indirect(std::sync::Arc), +} + +impl<'a> FdfValue<'a> { + fn resolve(doc: &Document, value: &'a Object) -> Self { + match value { + Object::Ref(_) => Self::Indirect(doc.resolve(value)), + _ => Self::Borrowed(value), + } + } + + fn object(&self) -> &Object { + match self { + Self::Borrowed(value) => value, + Self::Indirect(value) => value.as_ref(), + } + } +} + +struct FdfCopy<'a> { + doc: &'a Document, + objects: Vec, + numbers: HashMap, + pending: Vec<(ObjRef, usize)>, + nodes: usize, + bytes: usize, +} + +impl<'a> FdfCopy<'a> { + fn new(doc: &'a Document) -> Self { + Self { doc, objects: Vec::new(), numbers: HashMap::new(), pending: Vec::new(), nodes: MAX_FDF_OBJECTS, bytes: MAX_FDF_BYTES } + } + + fn charge(&mut self, bytes: usize) -> Result<(), DataError> { + self.bytes = self.bytes.checked_sub(bytes).ok_or(DataError::ExportLimit("64 MiB data"))?; + Ok(()) + } + + fn slot(&mut self) -> Result<(usize, u32), DataError> { + if self.objects.len() >= MAX_FDF_OBJECTS { + return Err(DataError::ExportLimit("100,000 objects")); + } + // Object 1 is the FDF root; arena indices start at object 2. The arena is capped above. + let index = self.objects.len(); + let number = index as u32 + 2; + self.charge(100)?; // indirect-object and xref overhead + self.objects.push(Object::Null); + Ok((index, number)) + } + + fn dict(&mut self, dict: &Dict, depth: usize) -> Result { + if dict.len() > self.nodes { + return Err(DataError::ExportLimit("100,000 values")); + } + let mut out = Dict::new(); + for (key, value) in dict.iter() { + self.dict_entry(&mut out, key, value, depth + 1)?; + } + Ok(out) + } + + fn dict_entry(&mut self, out: &mut Dict, key: &[u8], value: &Object, depth: usize) -> Result<(), DataError> { + self.charge(key.len().saturating_mul(3).saturating_add(2))?; + let value = self.copy(value, depth)?; + out.set(key.to_vec(), value); + Ok(()) + } + + /// Skip source-document links and appearances without cloning their values. The + /// dictionary and every retained entry are charged before any owned copy is made. + fn annotation_dict(&mut self, dict: &Dict) -> Result { + self.charge_value(0)?; + let mut out = Dict::new(); + for (key, value) in dict.iter() { + if matches!(key.as_slice(), b"P" | b"AP" | b"Popup" | b"IRT" | b"Parent" | b"FS" | b"Sound") { + continue; + } + self.dict_entry(&mut out, key, value, 1)?; + } + Ok(out) + } + + fn charge_value(&mut self, depth: usize) -> Result<(), DataError> { + if depth > MAX_DEPTH { + return Err(DataError::ExportLimit("64 direct nesting levels")); + } + self.nodes = self.nodes.checked_sub(1).ok_or(DataError::ExportLimit("100,000 values"))?; + // Includes delimiters, spacing and even the longest finite f64 in plain decimal form. + self.charge(330)?; + Ok(()) + } + + fn is_source_page(&self, dict: &Dict) -> Result { + let Some(value) = dict.get(b"Type") else { return Ok(false) }; + let mut value = FdfValue::Borrowed(value); + let mut seen = HashSet::new(); + for hops in 0..=MAX_DEPTH { + match value.object() { + Object::Name(name) => return Ok(matches!(name.as_slice(), b"Page" | b"Pages")), + Object::Ref(reference) => { + if self.doc.generation(reference.num) != reference.generation || !seen.insert(*reference) { + return Ok(false); + } + if hops == MAX_DEPTH { + return Err(DataError::ExportLimit("64 Type reference levels")); + } + value = FdfValue::Indirect(self.doc.get(*reference)); + } + _ => return Ok(false), + } + } + Err(DataError::ExportLimit("64 Type reference levels")) + } + + fn copy(&mut self, value: &Object, depth: usize) -> Result { + self.charge_value(depth)?; + Ok(match value { + Object::Ref(source) => { + let number = if let Some(number) = self.numbers.get(source) { + *number + } else { + let (index, number) = self.slot()?; + self.numbers.insert(*source, number); + self.pending.push((*source, index)); + number + }; + Object::Ref(ObjRef::new(number, 0)) + } + Object::Array(values) => { + if values.len() > self.nodes { + return Err(DataError::ExportLimit("100,000 values")); + } + let mut out = Vec::new(); + for value in values { + out.push(self.copy(value, depth + 1)?); + } + Object::Array(out) + } + // Source page links must not drag page contents or the page tree into an FDF. + Object::Dict(dict) if self.is_source_page(dict)? => Object::Null, + Object::Dict(dict) => Object::Dict(self.dict(dict, depth)?), + Object::String(s) => { + self.charge(s.bytes.len().saturating_mul(4))?; + value.clone() + } + Object::Name(name) => { + self.charge(name.len().saturating_mul(3))?; + value.clone() + } + // A PDF stream can only be an indirect object, never a dictionary/array value. + Object::Stream(_) => return Err(DataError::Malformed("FDF export encountered a direct stream".into())), + other => other.clone(), + }) + } + + fn finish(&mut self, doc: &Document) -> Result<(), DataError> { + let mut cursor = 0; + while let Some((source, index)) = self.pending.get(cursor).copied() { + // ISO 32000-2 §7.3.10: an absent or stale reference denotes null. Keep reader + // errors distinct so damaged objects cannot be silently lost during export. + let value = if doc.generation(source.num) != source.generation { + std::sync::Arc::new(Object::Null) + } else { + match doc.try_get(source.num) { + Ok(value) => value, + Err(pdfcraft_cos::CosError::MissingObject(_)) => std::sync::Arc::new(Object::Null), + Err(error) => return Err(error.into()), + } + }; + let copied = match &*value { + Object::Stream(stream) => { + self.charge(stream.raw.len())?; + Object::Stream(pdfcraft_cos::Stream { dict: self.dict(&stream.dict, 0)?, raw: stream.raw.clone() }) + } + other => self.copy(other, 0)?, + }; + let slot = self.objects.get_mut(index).ok_or_else(|| DataError::Malformed("FDF object slot is missing".into()))?; + *slot = copied; + cursor += 1; + } + Ok(()) + } +} + +/// FDF with field values and/or comments (appearances are not carried). Each comment is an +/// indirect object of the FDF, so a reply's `/IRT` can point at its parent's object there +/// (§12.7.8.3.1). Nested values are copied and renumbered, including shared or cyclic graphs. +/// Source `/Page` and `/Pages` dictionaries become null at every copy depth. +/// Refuses exports above the object, value, direct-depth or data budget instead of losing data. +pub fn export_fdf(doc: &Document, comments: bool, fields: bool, file: &str) -> Result, DataError> { + let mut copy = FdfCopy::new(doc); + copy.charge(file.len().saturating_mul(8))?; + let mut body: Vec = b"<< /FDF << ".to_vec(); + // Comment dictionaries, written after the root as objects 2, 3, … + if fields { + body.extend_from_slice(b"/Fields ["); + for f in pdfcraft_forms::fields(doc) { + if matches!(f.kind, FieldKind::PushButton | FieldKind::Signature) { + continue; + } + // Field dictionary, /T string and /V scalar/array, plus every emitted list + // entry. Other field kinds emit only their first value (or a default). + let field_nodes = if matches!(f.kind, FieldKind::List) && f.value.len() > 1 { f.value.len().saturating_add(3) } else { 3 }; + copy.nodes = copy.nodes.checked_sub(field_nodes).ok_or(DataError::ExportLimit("100,000 values"))?; + copy.charge(f.name.len().saturating_mul(8).saturating_add(100))?; + for value in &f.value { + copy.charge(value.len().saturating_mul(8).saturating_add(100))?; + } + body.extend_from_slice(b"<< /T "); + body.extend(pdf_string(&f.name)); + body.extend_from_slice(b" /V "); + match f.kind { + FieldKind::CheckBox | FieldKind::Radio => { + let v = f.value.first().cloned().unwrap_or_else(|| "Off".into()); + pdfcraft_cos::serialize(&Object::Name(pdfcraft_forms::name_bytes(&v)), &mut body); + } + FieldKind::List if f.value.len() > 1 => { + pdfcraft_cos::serialize(&Object::Array(f.value.iter().map(|v| Object::String(PdfString::text(v))).collect()), &mut body); + } + _ => body.extend(pdf_string(f.value.first().map(String::as_str).unwrap_or(""))), + } + body.extend_from_slice(b" >> "); + } + body.extend_from_slice(b"] "); + } + if comments { + // Keep the page/array owners once. Handles retain positions, never annotation + // bodies: the document cache can evict and reparse even a repeated source reference. + let pages = pdfcraft_model::pages(doc); + let mut arrays = Vec::new(); + let mut picked: Vec<(Option, usize, usize)> = Vec::new(); + for (pi, page) in pages.iter().enumerate() { + let owner = page.dict.get(b"Annots").map(|value| FdfValue::resolve(doc, value)); + let before = picked.len(); + if let Some(values) = owner.as_ref().and_then(|owner| owner.object().as_array()) { + for (ai, a) in values.iter().enumerate() { + let value = FdfValue::resolve(doc, a); + let Some(d) = value.object().as_dict() else { continue }; + if d.name(b"Subtype").is_some_and(|sub| SUBTYPES.iter().any(|(s, _)| s.as_bytes() == sub)) { + if picked.len() >= MAX_FDF_OBJECTS { + return Err(DataError::ExportLimit("100,000 comments")); + } + picked.push((a.as_ref(), pi, ai)); + } + } + } + // Empty/unselected arrays need no owner. Resolve subsequent pages only after + // this page passes the cap, rather than loading them all before discovery. + arrays.push(if picked.len() > before { owner } else { None }); + } + let mut numbers = HashMap::new(); + for (source, _, _) in &picked { + let (_, number) = copy.slot()?; + if let Some(source) = source { + numbers.insert(*source, number); + } + } + copy.numbers = numbers.clone(); + let count = picked.len(); + for (index, (_, pi, ai)) in picked.into_iter().enumerate() { + let annotation = arrays + .get(pi) + .and_then(Option::as_ref) + .and_then(|owner| owner.object().as_array()) + .and_then(|values| values.get(ai)) + .ok_or_else(|| DataError::Malformed("FDF annotation slot is missing".into()))?; + let value = FdfValue::resolve(doc, annotation); + let d = value.object().as_dict().ok_or_else(|| DataError::Malformed("FDF comment is not a dictionary".into()))?; + // A reply points at its parent's object in this FDF; a parent that isn't exported + // leaves the reply unthreaded. + let irt = d.get(b"IRT").and_then(Object::as_ref).and_then(|r| numbers.get(&r)).copied(); + let mut d = copy.annotation_dict(d)?; + if let Some(num) = irt { + d.set(b"IRT".to_vec(), Object::Ref(ObjRef::new(num, 0))); + } + d.set(b"Page".to_vec(), Object::Int(pi as i64)); + let slot = copy.objects.get_mut(index).ok_or_else(|| DataError::Malformed("FDF comment slot is missing".into()))?; + *slot = Object::Dict(d); + } + body.extend_from_slice(b"/Annots ["); + for i in 0..count { + let _ = write!(body, "{} 0 R ", i + 2); + } + body.extend_from_slice(b"] "); + } + body.extend_from_slice(b"/F "); + body.extend(pdf_string(file)); + body.extend_from_slice(b" >> >>"); + copy.finish(doc)?; + let mut out = b"%FDF-1.2\n%\xE2\xE3\xCF\xD3\n".to_vec(); + let mut offsets = Vec::with_capacity(copy.objects.len() + 1); + offsets.push(out.len()); + out.extend_from_slice(b"1 0 obj\n"); + out.extend(body); + out.extend_from_slice(b"\nendobj\n"); + for (i, object) in copy.objects.into_iter().enumerate() { + offsets.push(out.len()); + let _ = writeln!(out, "{} 0 obj", i + 2); + pdfcraft_cos::serialize(&object, &mut out); + out.extend_from_slice(b"\nendobj\n"); + } + let xref = out.len(); + let _ = write!(out, "xref\n0 {}\n0000000000 65535 f \n", offsets.len() + 1); + for o in offsets { + let _ = writeln!(out, "{o:010} 00000 n "); + } + let _ = write!(out, "trailer\n<< /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n"); + Ok(out) +} + +/// What one FDF import may copy out of the file. A field tree or comment that repeats a reference +/// level after level would otherwise expand a small file into millions of values. +const MAX_FDF_IMPORT_VALUES: usize = 1_000_000; + +pub(crate) struct ImportBudget { + values: usize, + bytes: usize, +} + +impl ImportBudget { + pub(crate) fn new() -> Self { + Self { values: MAX_FDF_IMPORT_VALUES, bytes: MAX_FDF_BYTES } + } + + fn spend(&mut self, values: usize, bytes: usize) -> Result<(), DataError> { + match (self.values.checked_sub(values), self.bytes.checked_sub(bytes)) { + (Some(v), Some(b)) => { + (self.values, self.bytes) = (v, b); + Ok(()) + } + _ => Err(DataError::Malformed(format!( + "the file expands to more than {MAX_FDF_IMPORT_VALUES} values or {} MiB of text", + MAX_FDF_BYTES >> 20 + ))), + } + } +} + +/// Copy an object out of another document, resolving references. A reference back to an object +/// being copied becomes null. +fn deep(src: &Document, o: &Object, depth: usize, path: &mut Vec, budget: &mut ImportBudget) -> Result { + budget.spend(1, 0)?; + if depth > 16 { + return Ok(Object::Null); + } + Ok(match o { + Object::Ref(r) => { + if path.contains(r) { + return Ok(Object::Null); + } + path.push(*r); + let copy = deep(src, &src.get(*r), depth + 1, path, budget); + path.pop(); + copy? + } + Object::Array(a) => Object::Array(a.iter().map(|x| deep(src, x, depth + 1, path, budget)).collect::>()?), + Object::Dict(d) => { + Object::Dict(d.iter().map(|(k, v)| Ok((k.clone(), deep(src, v, depth + 1, path, budget)?))).collect::>()?) + } + Object::Stream(_) => Object::Null, + Object::String(s) => { + budget.spend(0, s.bytes.len())?; + o.clone() + } + Object::Name(n) => { + budget.spend(0, n.len())?; + o.clone() + } + other => other.clone(), + }) +} + +/// Read an FDF field tree. A field listed again beneath itself is skipped. +pub(crate) fn walk_fdf( + fdf: &Document, + list: &[Object], + prefix: &str, + out: &mut Vec<(String, Vec)>, + depth: usize, + path: &mut Vec, + budget: &mut ImportBudget, +) -> Result<(), DataError> { + if depth > 32 { + return Ok(()); + } + for f in list { + budget.spend(1, 0)?; + let held; + let field = match f { + Object::Ref(r) if path.contains(r) => continue, + Object::Ref(r) => { + held = fdf.get(*r); + &*held + } + direct => direct, + }; + let Some(d) = field.as_dict() else { continue }; + let Some(t) = d.get(b"T").and_then(|t| fdf.resolve(t).as_string().map(|s| s.to_text())) else { continue }; + let full = if prefix.is_empty() { t } else { format!("{prefix}.{t}") }; + budget.spend(0, full.len())?; + if let Some(v) = d.get(b"V").map(|v| fdf.resolve(v)) { + let vals: Vec = match &*v { + Object::String(s) => vec![s.to_text()], + Object::Name(n) => vec![pdfcraft_forms::name_text(n)], + Object::Array(a) => { + budget.spend(a.len(), 0)?; + a.iter().filter_map(|x| fdf.resolve(x).as_string().map(|s| s.to_text())).collect() + } + _ => Vec::new(), + }; + budget.spend(vals.len(), vals.iter().map(String::len).fold(0, usize::saturating_add))?; + out.push((full.clone(), vals)); + } + if let Some(kids) = d.get(b"Kids").map(|k| fdf.resolve(k)) + && let Some(kids) = kids.as_array() + { + let entered = f.as_ref().map(|r| path.push(r)).is_some(); + let walked = walk_fdf(fdf, kids, &full, out, depth + 1, path, budget); + if entered { + path.pop(); + } + walked?; + } + } + Ok(()) +} + +pub(crate) fn fdf_body(fdf: &Document) -> Result { + let root = fdf.root().ok_or_else(|| DataError::Malformed("no /Root".into()))?; + fdf.get(root) + .as_dict() + .and_then(|d| d.get(b"FDF").cloned()) + .map(|f| fdf.resolve(&f)) + .and_then(|f| f.as_dict().cloned()) + .ok_or(DataError::UnknownFormat) +} + +pub(crate) fn import_fdf(doc: &mut Document, bytes: &[u8]) -> Result { + let fdf = Document::open(std::sync::Arc::new(bytes.to_vec())).map_err(|e| DataError::Malformed(e.to_string()))?; + let body = fdf_body(&fdf)?; + let mut report = Report::default(); + let mut budget = ImportBudget::new(); + // Fields (with /Kids for hierarchical names). + let mut values = Vec::new(); + if let Some(list) = body.get(b"Fields").map(|f| fdf.resolve(f)).and_then(|f| f.as_array().cloned()) { + walk_fdf(&fdf, &list, "", &mut values, 0, &mut Vec::new(), &mut budget)?; + } + // Comments: parents first, then replies (whose /IRT needs the parent's new reference). + let pages: Vec = pdfcraft_model::pages(doc).iter().map(|p| p.obj).collect(); + let mut placed: HashMap = HashMap::new(); + let mut later: HashMap, Option)> = HashMap::new(); + for a in body.get(b"Annots").map(|a| fdf.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default() { + let Some(mut src) = fdf.resolve(&a).as_dict().cloned() else { continue }; + // The parent is another comment: link it below rather than copying it in. + let irt = src.get(b"IRT").cloned(); + src.remove(b"IRT"); + // The pop-up is an annotation of its own (/Popup shall be an indirect reference, ISO + // 32000-2 Table 172): copied in, it would become a direct dictionary. A new one is + // placed on the page below, as the XFDF import does. + let popup = src.get(b"Popup").map(|p| fdf.resolve(p)).and_then(|p| p.as_dict().cloned()); + src.remove(b"Popup"); + let mut path: Vec = a.as_ref().into_iter().collect(); + let Object::Dict(mut d) = deep(&fdf, &Object::Dict(src), 0, &mut path, &mut budget)? else { continue }; + let Some(page) = d.get(b"Page").and_then(Object::as_int).and_then(|p| usize::try_from(p).ok()).and_then(|p| pages.get(p).copied()) else { + continue; + }; + if !d.name(b"Subtype").is_some_and(|s| SUBTYPES.iter().any(|(x, _)| x.as_bytes() == s)) { + continue; + } + d.remove(b"Page"); + d.set(b"P".to_vec(), Object::Ref(page)); + let r = place(doc, page, d)?; + later.remove(&r); + if let Some(rect) = popup.as_ref().and_then(|p| rect_strict(&fdf, p.get(b"Rect"))) { + let open = popup.as_ref().and_then(|p| p.get(b"Open")).is_some_and(|o| matches!(&*fdf.resolve(o), Object::Bool(true))); + add_popup(doc, page, r, &rect, open)?; + } + if let Some(fr) = a.as_ref() { + placed.insert(fr, r); + } + if let Some(irt) = irt { + let parent_name = fdf.resolve(&irt).as_dict().and_then(|p| text_of(&fdf, p, b"NM")); + later.insert(r, (irt.as_ref(), parent_name)); + } + report.comments += 1; + } + for (r, (parent_obj, parent_name)) in later { + // The parent imported from this file, else one already in the document with its name. + let parent = parent_obj.and_then(|p| placed.get(&p).copied()).or_else(|| parent_name.and_then(|nm| find_by_name(doc, &nm))); + if let Some(parent) = parent.filter(|p| *p != r) { + doc.update_dict(r, |d| d.set(b"IRT".to_vec(), Object::Ref(parent)))?; + } + } + apply_values(doc, &values, &mut report); + Ok(report) +} diff --git a/crates/xfdf/src/lib.rs b/crates/xfdf/src/lib.rs index 9c3d9ab017..ba8cb6f7f7 100644 --- a/crates/xfdf/src/lib.rs +++ b/crates/xfdf/src/lib.rs @@ -18,6 +18,22 @@ use std::io::Write as _; use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString}; use pdfcraft_forms::{FieldKind, FieldValue}; +mod common; +mod fdf; +mod xfdf; + +#[cfg(test)] +use common::{MAX_DEPTH, MAX_XML_DEPTH, arr, nums_of, parse_hex, text_of, xml_nesting_depth}; +use common::{check_form_xml_depth, esc, parse_form_xml, walk_xfdf, xml_name}; +pub use fdf::export_fdf; +use fdf::{ImportBudget, fdf_body, import_fdf, walk_fdf}; +#[cfg(test)] +use fdf::{MAX_FDF_BYTES, MAX_FDF_OBJECTS}; +pub use xfdf::export_xfdf; +#[cfg(test)] +use xfdf::{LOCKED, find_by_name}; +use xfdf::{apply_values, import_xfdf}; + #[cfg(test)] mod tests; @@ -71,774 +87,6 @@ pub struct Report { pub rejected: Vec, } -// ── names ─────────────────────────────────────────────────────────────────────────────────── - -/// Form data files are attacker-supplied. Refuse a DTD and bound the node count, exactly as the -/// XFA template parser does (`crates/xfa/src/parse.rs`), and bound how deep a nested field tree -/// may go: the walk below recurses once per nesting level. -const MAX_NODES: u32 = 2_000_000; -const MAX_DEPTH: usize = 64; - -/// The deepest element nesting accepted before the text reaches the XML parser. -/// -/// roxmltree parses by recursive descent, so its stack use grows with the nesting depth: ten -/// thousand levels overflow the main thread's stack and abort the process. A stack overflow is -/// `abort()`, so `catch_unwind` cannot intercept it and the whole application dies - which is why -/// the bound has to be applied before parsing, not while walking the result. Around three thousand -/// levels still parse on the default stack, so this stays clear of the cliff while leaving far more -/// room than the walks below, which cut off at 64. -const MAX_XML_DEPTH: usize = 1_024; - -/// Element nesting depth of `text`, skipping comments, CDATA, processing instructions, the DOCTYPE -/// and quoted attribute values. A tag that closes itself (``) is one level while it is read -/// and does not stay open, so a long run of them cannot accumulate into the bound. It cannot -/// under-count a real element; a stray `<` in text is invalid XML and only makes it over-count, -/// which errs towards refusing the file. -fn xml_nesting_depth(text: &str) -> usize { - fn find(hay: &[u8], from: usize, needle: &[u8]) -> Option { - (from..hay.len()).find(|&p| hay[p..].starts_with(needle)) - } - let b = text.as_bytes(); - let (mut i, mut depth, mut max) = (0usize, 0usize, 0usize); - while i < b.len() { - if b[i] != b'<' { - i += 1; - } else if b[i..].starts_with(b"").map_or(b.len(), |p| p + 3); - } else if b[i..].starts_with(b"").map_or(b.len(), |p| p + 3); - } else if b[i..].starts_with(b"").map_or(b.len(), |p| p + 2); - } else if b[i..].starts_with(b"").map_or(b.len(), |p| p + 1); - } else if b[i..].starts_with(b"").map_or(b.len(), |p| p + 1); - } else { - depth += 1; - max = max.max(depth); - // Skip the rest of the start tag, over any quoted attribute value, and remember its last - // significant byte: a tag that ends in `/` closes itself, so it is one level deep while it - // is read rather than a level left open. - i += 1; - let mut quote = 0u8; - let mut last = 0u8; - while i < b.len() { - let c = b[i]; - if quote != 0 { - if c == quote { - quote = 0; - } - } else if c == b'"' || c == b'\'' { - quote = c; - } else if c == b'>' { - i += 1; - break; - } else if !c.is_ascii_whitespace() { - last = c; - } - i += 1; - } - if last == b'/' { - depth -= 1; - } - } - } - max -} - -fn check_form_xml_depth(text: &str) -> Result<(), DataError> { - let depth = xml_nesting_depth(text); - if depth > MAX_XML_DEPTH { - return Err(DataError::Malformed(format!("the file nests XML elements {depth} deep, past the {MAX_XML_DEPTH} the reader accepts"))); - } - Ok(()) -} - -fn parse_form_xml(text: &str) -> Result, roxmltree::Error> { - roxmltree::Document::parse_with_options(text, roxmltree::ParsingOptions { allow_dtd: false, nodes_limit: MAX_NODES }) -} - -const SUBTYPES: [(&str, &str); 17] = [ - ("Text", "text"), - ("FreeText", "freetext"), - ("Line", "line"), - ("Square", "square"), - ("Circle", "circle"), - ("Polygon", "polygon"), - ("PolyLine", "polyline"), - ("Highlight", "highlight"), - ("Underline", "underline"), - ("Squiggly", "squiggly"), - ("StrikeOut", "strikeout"), - ("Stamp", "stamp"), - ("Caret", "caret"), - ("Ink", "ink"), - ("FileAttachment", "fileattachment"), - ("Sound", "sound"), - ("Redact", "redact"), -]; - -const FLAGS: [(i64, &str); 9] = [ - (1, "invisible"), - (2, "hidden"), - (4, "print"), - (8, "nozoom"), - (16, "norotate"), - (32, "noview"), - (64, "readonly"), - (128, "locked"), - (256, "togglenoview"), -]; - -fn esc(s: &str) -> String { - let mut o = String::with_capacity(s.len()); - for c in s.chars() { - match c { - '&' => o.push_str("&"), - '<' => o.push_str("<"), - '>' => o.push_str(">"), - '"' => o.push_str("""), - // XML 1.0 §3.3.3 normalizes literal attribute whitespace, but not references. - '\t' => o.push_str(" "), - '\n' => o.push_str(" "), - '\r' => o.push_str(" "), - c if (c as u32) < 0x20 && c != '\n' && c != '\t' => {} - // XML 1.0 noncharacters are not allowed even as raw text. - '\u{FFFE}' | '\u{FFFF}' => {} - c => o.push(c), - } - } - o -} - -fn n(v: f64) -> String { - let s = format!("{v:.4}"); - let s = s.trim_end_matches('0').trim_end_matches('.'); - if s == "-0" { "0".into() } else { s.into() } -} - -fn hex(c: &[f64]) -> Option { - let rgb = match c.len() { - 1 => [c[0]; 3], - 3 => [c[0], c[1], c[2]], - 4 => [(1.0 - c[0]) * (1.0 - c[3]), (1.0 - c[1]) * (1.0 - c[3]), (1.0 - c[2]) * (1.0 - c[3])], - _ => return None, - }; - Some(format!( - "#{:02X}{:02X}{:02X}", - (rgb[0].clamp(0.0, 1.0) * 255.0).round() as u8, - (rgb[1].clamp(0.0, 1.0) * 255.0).round() as u8, - (rgb[2].clamp(0.0, 1.0) * 255.0).round() as u8 - )) -} - -fn parse_hex(s: &str) -> Option<[f64; 3]> { - let h = s.trim().trim_start_matches('#'); - if h.len() != 6 || !h.bytes().all(|b| b.is_ascii_hexdigit()) { - return None; - } - let p = |i: usize| h.get(i..i + 2).and_then(|s| u8::from_str_radix(s, 16).ok()).map(|v| f64::from(v) / 255.0); - Some([p(0)?, p(2)?, p(4)?]) -} - -fn nums_of(doc: &Document, o: Option<&Object>) -> Vec { - o.map(|o| doc.resolve(o)).and_then(|a| a.as_array().map(|a| a.iter().filter_map(|x| doc.resolve(x).as_f64()).collect())).unwrap_or_default() -} - -/// A `/Rect` of exactly four finite numbers; any other entry makes the whole rectangle unusable. -fn rect_strict(doc: &Document, o: Option<&Object>) -> Option<[f64; 4]> { - let num = |x: &Object| doc.resolve(x).as_f64().filter(|v| v.is_finite()); - match doc.resolve(o?).as_array()?.as_slice() { - [x0, y0, x1, y1] => Some([num(x0)?, num(y0)?, num(x1)?, num(y1)?]), - _ => None, - } -} - -/// Whether a quad's four corners enclose any area: some three of them are not collinear. -fn quad_has_area(q: &[f64; 8]) -> bool { - let [ax, ay, bx, by, cx, cy, dx, dy] = *q; - let cross = |(px, py): (f64, f64), (qx, qy): (f64, f64), (rx, ry): (f64, f64)| (qx - px) * (ry - py) - (qy - py) * (rx - px); - let (a, b, c, d) = ((ax, ay), (bx, by), (cx, cy), (dx, dy)); - [cross(a, b, c), cross(a, b, d), cross(a, c, d), cross(b, c, d)].iter().any(|v| v.abs() > 0.0) -} - -fn text_of(doc: &Document, d: &Dict, k: &[u8]) -> Option { - d.get(k).and_then(|o| match &*doc.resolve(o) { - Object::String(s) => Some(s.to_text()), - Object::Name(n) => Some(pdfcraft_forms::name_text(n)), - _ => None, - }) -} - -/// The numbers of a list; empty when any entry is non-finite (`nan`, `inf`), so the list is unusable as a whole. -fn csv(list: &str) -> Vec { - let v: Vec = list.split([',', ';', ' ']).filter(|t| !t.is_empty()).filter_map(|t| t.trim().parse().ok()).collect(); - if v.iter().all(|x| x.is_finite()) { v } else { Vec::new() } -} - -fn arr(v: &[f64]) -> Object { - Object::Array(v.iter().map(|x| Object::Real(*x)).collect()) -} - -// ── export ────────────────────────────────────────────────────────────────────────────────── - -/// Every comment as an XFDF element (one line per comment, replies included). -fn xfdf_annots(doc: &Document, out: &mut String) { - let pages = pdfcraft_model::pages(doc); - // NM of every annotation, for inreplyto. - let mut names: HashMap = HashMap::new(); - for p in &pages { - for a in p.dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default() { - if let Some(r) = a.as_ref() - && let Some(nm) = doc.get(r).as_dict().and_then(|d| text_of(doc, d, b"NM")) - { - names.insert(r, nm); - } - } - } - for (pi, p) in pages.iter().enumerate() { - for a in p.dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default() { - let Some(d) = doc.resolve(&a).as_dict().cloned() else { continue }; - let Some(sub) = d.name(b"Subtype") else { continue }; - let Some((_, tag)) = SUBTYPES.iter().find(|(s, _)| s.as_bytes() == sub) else { continue }; - let mut attrs = format!(" page=\"{pi}\""); - let rect = nums_of(doc, d.get(b"Rect")); - if rect.len() == 4 { - let _ = write!( - attrs, - " rect=\"{},{},{},{}\"", - n(rect[0].min(rect[2])), - n(rect[1].min(rect[3])), - n(rect[0].max(rect[2])), - n(rect[1].max(rect[3])) - ); - } - for (key, attr) in [ - (&b"NM"[..], "name"), - (b"T", "title"), - (b"Subj", "subject"), - (b"M", "date"), - (b"CreationDate", "creationdate"), - (b"RT", "replyType"), - (b"State", "state"), - (b"StateModel", "statemodel"), - (b"Name", "icon"), - ] { - if let Some(v) = text_of(doc, &d, key) { - let _ = write!(attrs, " {attr}=\"{}\"", esc(&v)); - } - } - let flags = d.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0); - let fl: Vec<&str> = FLAGS.iter().filter(|(b, _)| flags & b != 0).map(|(_, s)| *s).collect(); - let _ = write!(attrs, " flags=\"{}\"", fl.join(",")); - if let Some(c) = hex(&nums_of(doc, d.get(b"C"))) { - let _ = write!(attrs, " color=\"{c}\""); - } - if let Some(c) = hex(&nums_of(doc, d.get(b"IC"))) { - let _ = write!(attrs, " interior-color=\"{c}\""); - } - if let Some(o) = d.get(b"CA").and_then(|o| doc.resolve(o).as_f64()) { - let _ = write!(attrs, " opacity=\"{}\"", n(o)); - } - if let Some(w) = d.get(b"BS").and_then(|b| doc.resolve(b).as_dict().and_then(|b| b.get(b"W").and_then(Object::as_f64))) { - let _ = write!(attrs, " width=\"{}\"", n(w)); - } - let quads = nums_of(doc, d.get(b"QuadPoints")); - if !quads.is_empty() { - let _ = write!(attrs, " coords=\"{}\"", quads.iter().map(|v| n(*v)).collect::>().join(",")); - } - let l = nums_of(doc, d.get(b"L")); - if l.len() == 4 { - let _ = write!(attrs, " start=\"{},{}\" end=\"{},{}\"", n(l[0]), n(l[1]), n(l[2]), n(l[3])); - } - if let Some(le) = d.get(b"LE").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()) - && le.len() == 2 - { - let name = |o: &Object| o.as_name().map(|x| String::from_utf8_lossy(x).into_owned()).unwrap_or_else(|| "None".into()); - let _ = write!(attrs, " head=\"{}\" tail=\"{}\"", esc(&name(&le[0])), esc(&name(&le[1]))); - } - // A callout: its leader path, text-box inset, intent and (single-name) line ending. - let cl = nums_of(doc, d.get(b"CL")); - if cl.len() == 4 || cl.len() == 6 { - let _ = write!(attrs, " callout=\"{}\"", cl.iter().map(|v| n(*v)).collect::>().join(",")); - let rd = nums_of(doc, d.get(b"RD")); - if rd.len() == 4 { - let _ = write!(attrs, " fringe=\"{}\"", rd.iter().map(|v| n(*v)).collect::>().join(",")); - } - if let Some(it) = d.get(b"IT").map(|o| doc.resolve(o)).and_then(|o| o.as_name().map(|x| String::from_utf8_lossy(x).into_owned())) { - let _ = write!(attrs, " intent=\"{}\"", esc(&it)); - } - if let Some(le) = d.get(b"LE").map(|o| doc.resolve(o)).and_then(|o| o.as_name().map(|x| String::from_utf8_lossy(x).into_owned())) { - let _ = write!(attrs, " head=\"{}\"", esc(&le)); - } - } - if let Some(irt) = d.get(b"IRT").and_then(Object::as_ref).and_then(|r| names.get(&r)) { - let _ = write!(attrs, " inreplyto=\"{}\"", esc(irt)); - } - let _ = write!(out, "<{tag}{attrs}>"); - if let Some(c) = text_of(doc, &d, b"Contents") { - let _ = write!(out, "{}", esc(&c)); - } - if let Some(da) = text_of(doc, &d, b"DA") { - let _ = write!(out, "{}", esc(&da)); - } - let ink = d.get(b"InkList").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default(); - if !ink.is_empty() { - out.push_str(""); - for g in ink { - let v = nums_of(doc, Some(&g)); - let pts: Vec = v.as_chunks::<2>().0.iter().map(|p| format!("{},{}", n(p[0]), n(p[1]))).collect(); - let _ = write!(out, "{}", pts.join(";")); - } - out.push_str(""); - } - // Polygon and polyline points (XFDF ``: `x,y;x,y;…`). - let vertices = nums_of(doc, d.get(b"Vertices")); - let pts: Vec = vertices.as_chunks::<2>().0.iter().map(|p| format!("{},{}", n(p[0]), n(p[1]))).collect(); - if !pts.is_empty() { - let _ = write!(out, "{}", pts.join(";")); - } - if let Some(pd) = d.get(b"Popup").map(|p| doc.resolve(p)).and_then(|p| p.as_dict().cloned()) { - let r = nums_of(doc, pd.get(b"Rect")); - if r.len() == 4 { - let open = matches!(pd.get(b"Open"), Some(Object::Bool(true))); - let _ = write!( - out, - "", - n(r[0]), - n(r[1]), - n(r[2]), - n(r[3]), - if open { "yes" } else { "no" } - ); - } - } - let _ = writeln!(out, ""); - } - } -} - -/// Field values as nested XFDF `` elements. -fn xfdf_fields(doc: &Document, out: &mut String) { - #[derive(Default)] - struct Node { - values: Vec, - kids: Vec<(String, Node)>, - } - let mut root = Node::default(); - for f in pdfcraft_forms::fields(doc) { - if matches!(f.kind, FieldKind::PushButton | FieldKind::Signature) { - continue; - } - let mut node = &mut root; - // One `` level per part of the name, but no more levels than the import reads - // back. A `/T` can hold periods of its own, so a name can split into far more parts than - // the field tree has levels: nesting every part wrote a file the import refuses and, for - // a long enough name, recursed in `write_node` until the stack overflowed. The parts past - // the last level stay together in the deepest element's name, and the import joins them - // back with periods into the same full name. - for part in f.name.splitn(MAX_DEPTH, '.') { - let i = match node.kids.iter().position(|(k, _)| k == part) { - Some(i) => i, - None => { - node.kids.push((part.to_string(), Node::default())); - node.kids.len() - 1 - } - }; - node = &mut node.kids[i].1; - } - node.values = match f.kind { - FieldKind::CheckBox | FieldKind::Radio if f.value.is_empty() => vec!["Off".into()], - _ => f.value.clone(), - }; - } - fn write_node(name: &str, node: &Node, out: &mut String) { - let _ = write!(out, "", esc(name)); - for v in &node.values { - let _ = write!(out, "{}", esc(v)); - } - for (k, kid) in &node.kids { - write_node(k, kid, out); - } - out.push_str("\n"); - } - for (k, kid) in &root.kids { - write_node(k, kid, out); - } -} - -/// XFDF with the document's comments and/or field values. -pub fn export_xfdf(doc: &Document, comments: bool, fields: bool, file: &str) -> String { - let mut out = String::from("\n\n"); - if comments { - out.push_str("\n"); - xfdf_annots(doc, &mut out); - out.push_str("\n"); - } - if fields { - out.push_str("\n"); - xfdf_fields(doc, &mut out); - out.push_str("\n"); - } - let _ = writeln!(out, "", esc(file)); - out.push_str("\n"); - out -} - -fn pdf_string(s: &str) -> Vec { - let mut v = Vec::new(); - pdfcraft_cos::serialize(&Object::String(PdfString::text(s)), &mut v); - v -} - -// Keep aliases as aliases instead of expanding a shared graph once per use. References are -// queued, not followed recursively, so cycles and long indirect chains cost linear work. -const MAX_FDF_OBJECTS: usize = 100_000; -const MAX_FDF_BYTES: usize = 64 * 1024 * 1024; - -/// Direct values stay borrowed: `Document::resolve` would deep-clone them. Only -/// indirect values need an owner, and annotation bodies are never retained in discovery. -enum FdfValue<'a> { - Borrowed(&'a Object), - Indirect(std::sync::Arc), -} - -impl<'a> FdfValue<'a> { - fn resolve(doc: &Document, value: &'a Object) -> Self { - match value { - Object::Ref(_) => Self::Indirect(doc.resolve(value)), - _ => Self::Borrowed(value), - } - } - - fn object(&self) -> &Object { - match self { - Self::Borrowed(value) => value, - Self::Indirect(value) => value.as_ref(), - } - } -} - -struct FdfCopy<'a> { - doc: &'a Document, - objects: Vec, - numbers: HashMap, - pending: Vec<(ObjRef, usize)>, - nodes: usize, - bytes: usize, -} - -impl<'a> FdfCopy<'a> { - fn new(doc: &'a Document) -> Self { - Self { doc, objects: Vec::new(), numbers: HashMap::new(), pending: Vec::new(), nodes: MAX_FDF_OBJECTS, bytes: MAX_FDF_BYTES } - } - - fn charge(&mut self, bytes: usize) -> Result<(), DataError> { - self.bytes = self.bytes.checked_sub(bytes).ok_or(DataError::ExportLimit("64 MiB data"))?; - Ok(()) - } - - fn slot(&mut self) -> Result<(usize, u32), DataError> { - if self.objects.len() >= MAX_FDF_OBJECTS { - return Err(DataError::ExportLimit("100,000 objects")); - } - // Object 1 is the FDF root; arena indices start at object 2. The arena is capped above. - let index = self.objects.len(); - let number = index as u32 + 2; - self.charge(100)?; // indirect-object and xref overhead - self.objects.push(Object::Null); - Ok((index, number)) - } - - fn dict(&mut self, dict: &Dict, depth: usize) -> Result { - if dict.len() > self.nodes { - return Err(DataError::ExportLimit("100,000 values")); - } - let mut out = Dict::new(); - for (key, value) in dict.iter() { - self.dict_entry(&mut out, key, value, depth + 1)?; - } - Ok(out) - } - - fn dict_entry(&mut self, out: &mut Dict, key: &[u8], value: &Object, depth: usize) -> Result<(), DataError> { - self.charge(key.len().saturating_mul(3).saturating_add(2))?; - let value = self.copy(value, depth)?; - out.set(key.to_vec(), value); - Ok(()) - } - - /// Skip source-document links and appearances without cloning their values. The - /// dictionary and every retained entry are charged before any owned copy is made. - fn annotation_dict(&mut self, dict: &Dict) -> Result { - self.charge_value(0)?; - let mut out = Dict::new(); - for (key, value) in dict.iter() { - if matches!(key.as_slice(), b"P" | b"AP" | b"Popup" | b"IRT" | b"Parent" | b"FS" | b"Sound") { - continue; - } - self.dict_entry(&mut out, key, value, 1)?; - } - Ok(out) - } - - fn charge_value(&mut self, depth: usize) -> Result<(), DataError> { - if depth > MAX_DEPTH { - return Err(DataError::ExportLimit("64 direct nesting levels")); - } - self.nodes = self.nodes.checked_sub(1).ok_or(DataError::ExportLimit("100,000 values"))?; - // Includes delimiters, spacing and even the longest finite f64 in plain decimal form. - self.charge(330)?; - Ok(()) - } - - fn is_source_page(&self, dict: &Dict) -> Result { - let Some(value) = dict.get(b"Type") else { return Ok(false) }; - let mut value = FdfValue::Borrowed(value); - let mut seen = HashSet::new(); - for hops in 0..=MAX_DEPTH { - match value.object() { - Object::Name(name) => return Ok(matches!(name.as_slice(), b"Page" | b"Pages")), - Object::Ref(reference) => { - if self.doc.generation(reference.num) != reference.generation || !seen.insert(*reference) { - return Ok(false); - } - if hops == MAX_DEPTH { - return Err(DataError::ExportLimit("64 Type reference levels")); - } - value = FdfValue::Indirect(self.doc.get(*reference)); - } - _ => return Ok(false), - } - } - Err(DataError::ExportLimit("64 Type reference levels")) - } - - fn copy(&mut self, value: &Object, depth: usize) -> Result { - self.charge_value(depth)?; - Ok(match value { - Object::Ref(source) => { - let number = if let Some(number) = self.numbers.get(source) { - *number - } else { - let (index, number) = self.slot()?; - self.numbers.insert(*source, number); - self.pending.push((*source, index)); - number - }; - Object::Ref(ObjRef::new(number, 0)) - } - Object::Array(values) => { - if values.len() > self.nodes { - return Err(DataError::ExportLimit("100,000 values")); - } - let mut out = Vec::new(); - for value in values { - out.push(self.copy(value, depth + 1)?); - } - Object::Array(out) - } - // Source page links must not drag page contents or the page tree into an FDF. - Object::Dict(dict) if self.is_source_page(dict)? => Object::Null, - Object::Dict(dict) => Object::Dict(self.dict(dict, depth)?), - Object::String(s) => { - self.charge(s.bytes.len().saturating_mul(4))?; - value.clone() - } - Object::Name(name) => { - self.charge(name.len().saturating_mul(3))?; - value.clone() - } - // A PDF stream can only be an indirect object, never a dictionary/array value. - Object::Stream(_) => return Err(DataError::Malformed("FDF export encountered a direct stream".into())), - other => other.clone(), - }) - } - - fn finish(&mut self, doc: &Document) -> Result<(), DataError> { - let mut cursor = 0; - while let Some((source, index)) = self.pending.get(cursor).copied() { - // ISO 32000-2 §7.3.10: an absent or stale reference denotes null. Keep reader - // errors distinct so damaged objects cannot be silently lost during export. - let value = if doc.generation(source.num) != source.generation { - std::sync::Arc::new(Object::Null) - } else { - match doc.try_get(source.num) { - Ok(value) => value, - Err(pdfcraft_cos::CosError::MissingObject(_)) => std::sync::Arc::new(Object::Null), - Err(error) => return Err(error.into()), - } - }; - let copied = match &*value { - Object::Stream(stream) => { - self.charge(stream.raw.len())?; - Object::Stream(pdfcraft_cos::Stream { dict: self.dict(&stream.dict, 0)?, raw: stream.raw.clone() }) - } - other => self.copy(other, 0)?, - }; - let slot = self.objects.get_mut(index).ok_or_else(|| DataError::Malformed("FDF object slot is missing".into()))?; - *slot = copied; - cursor += 1; - } - Ok(()) - } -} - -/// FDF with field values and/or comments (appearances are not carried). Each comment is an -/// indirect object of the FDF, so a reply's `/IRT` can point at its parent's object there -/// (§12.7.8.3.1). Nested values are copied and renumbered, including shared or cyclic graphs. -/// Source `/Page` and `/Pages` dictionaries become null at every copy depth. -/// Refuses exports above the object, value, direct-depth or data budget instead of losing data. -pub fn export_fdf(doc: &Document, comments: bool, fields: bool, file: &str) -> Result, DataError> { - let mut copy = FdfCopy::new(doc); - copy.charge(file.len().saturating_mul(8))?; - let mut body: Vec = b"<< /FDF << ".to_vec(); - // Comment dictionaries, written after the root as objects 2, 3, … - if fields { - body.extend_from_slice(b"/Fields ["); - for f in pdfcraft_forms::fields(doc) { - if matches!(f.kind, FieldKind::PushButton | FieldKind::Signature) { - continue; - } - // Field dictionary, /T string and /V scalar/array, plus every emitted list - // entry. Other field kinds emit only their first value (or a default). - let field_nodes = if matches!(f.kind, FieldKind::List) && f.value.len() > 1 { f.value.len().saturating_add(3) } else { 3 }; - copy.nodes = copy.nodes.checked_sub(field_nodes).ok_or(DataError::ExportLimit("100,000 values"))?; - copy.charge(f.name.len().saturating_mul(8).saturating_add(100))?; - for value in &f.value { - copy.charge(value.len().saturating_mul(8).saturating_add(100))?; - } - body.extend_from_slice(b"<< /T "); - body.extend(pdf_string(&f.name)); - body.extend_from_slice(b" /V "); - match f.kind { - FieldKind::CheckBox | FieldKind::Radio => { - let v = f.value.first().cloned().unwrap_or_else(|| "Off".into()); - pdfcraft_cos::serialize(&Object::Name(pdfcraft_forms::name_bytes(&v)), &mut body); - } - FieldKind::List if f.value.len() > 1 => { - pdfcraft_cos::serialize(&Object::Array(f.value.iter().map(|v| Object::String(PdfString::text(v))).collect()), &mut body); - } - _ => body.extend(pdf_string(f.value.first().map(String::as_str).unwrap_or(""))), - } - body.extend_from_slice(b" >> "); - } - body.extend_from_slice(b"] "); - } - if comments { - // Keep the page/array owners once. Handles retain positions, never annotation - // bodies: the document cache can evict and reparse even a repeated source reference. - let pages = pdfcraft_model::pages(doc); - let mut arrays = Vec::new(); - let mut picked: Vec<(Option, usize, usize)> = Vec::new(); - for (pi, page) in pages.iter().enumerate() { - let owner = page.dict.get(b"Annots").map(|value| FdfValue::resolve(doc, value)); - let before = picked.len(); - if let Some(values) = owner.as_ref().and_then(|owner| owner.object().as_array()) { - for (ai, a) in values.iter().enumerate() { - let value = FdfValue::resolve(doc, a); - let Some(d) = value.object().as_dict() else { continue }; - if d.name(b"Subtype").is_some_and(|sub| SUBTYPES.iter().any(|(s, _)| s.as_bytes() == sub)) { - if picked.len() >= MAX_FDF_OBJECTS { - return Err(DataError::ExportLimit("100,000 comments")); - } - picked.push((a.as_ref(), pi, ai)); - } - } - } - // Empty/unselected arrays need no owner. Resolve subsequent pages only after - // this page passes the cap, rather than loading them all before discovery. - arrays.push(if picked.len() > before { owner } else { None }); - } - let mut numbers = HashMap::new(); - for (source, _, _) in &picked { - let (_, number) = copy.slot()?; - if let Some(source) = source { - numbers.insert(*source, number); - } - } - copy.numbers = numbers.clone(); - let count = picked.len(); - for (index, (_, pi, ai)) in picked.into_iter().enumerate() { - let annotation = arrays - .get(pi) - .and_then(Option::as_ref) - .and_then(|owner| owner.object().as_array()) - .and_then(|values| values.get(ai)) - .ok_or_else(|| DataError::Malformed("FDF annotation slot is missing".into()))?; - let value = FdfValue::resolve(doc, annotation); - let d = value.object().as_dict().ok_or_else(|| DataError::Malformed("FDF comment is not a dictionary".into()))?; - // A reply points at its parent's object in this FDF; a parent that isn't exported - // leaves the reply unthreaded. - let irt = d.get(b"IRT").and_then(Object::as_ref).and_then(|r| numbers.get(&r)).copied(); - let mut d = copy.annotation_dict(d)?; - if let Some(num) = irt { - d.set(b"IRT".to_vec(), Object::Ref(ObjRef::new(num, 0))); - } - d.set(b"Page".to_vec(), Object::Int(pi as i64)); - let slot = copy.objects.get_mut(index).ok_or_else(|| DataError::Malformed("FDF comment slot is missing".into()))?; - *slot = Object::Dict(d); - } - body.extend_from_slice(b"/Annots ["); - for i in 0..count { - let _ = write!(body, "{} 0 R ", i + 2); - } - body.extend_from_slice(b"] "); - } - body.extend_from_slice(b"/F "); - body.extend(pdf_string(file)); - body.extend_from_slice(b" >> >>"); - copy.finish(doc)?; - let mut out = b"%FDF-1.2\n%\xE2\xE3\xCF\xD3\n".to_vec(); - let mut offsets = Vec::with_capacity(copy.objects.len() + 1); - offsets.push(out.len()); - out.extend_from_slice(b"1 0 obj\n"); - out.extend(body); - out.extend_from_slice(b"\nendobj\n"); - for (i, object) in copy.objects.into_iter().enumerate() { - offsets.push(out.len()); - let _ = writeln!(out, "{} 0 obj", i + 2); - pdfcraft_cos::serialize(&object, &mut out); - out.extend_from_slice(b"\nendobj\n"); - } - let xref = out.len(); - let _ = write!(out, "xref\n0 {}\n0000000000 65535 f \n", offsets.len() + 1); - for o in offsets { - let _ = writeln!(out, "{o:010} 00000 n "); - } - let _ = write!(out, "trailer\n<< /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n"); - Ok(out) -} -/// XML 1.0 fifth-edition NameStartChar, excluding `:` for an unprefixed NCName. -/// https://www.w3.org/TR/2008/REC-xml-20081126/#NT-NameStartChar -fn xml_name_start(c: char) -> bool { - matches!( - c, - '_' | 'A'..='Z' | 'a'..='z' - | '\u{C0}'..='\u{D6}' | '\u{D8}'..='\u{F6}' | '\u{F8}'..='\u{2FF}' - | '\u{370}'..='\u{37D}' | '\u{37F}'..='\u{1FFF}' - | '\u{200C}'..='\u{200D}' | '\u{2070}'..='\u{218F}' - | '\u{2C00}'..='\u{2FEF}' | '\u{3001}'..='\u{D7FF}' - | '\u{F900}'..='\u{FDCF}' | '\u{FDF0}'..='\u{FFFD}' - | '\u{10000}'..='\u{EFFFF}' - ) -} - -fn xml_name_char(c: char) -> bool { - xml_name_start(c) || matches!(c, '-' | '.' | '0'..='9' | '\u{B7}' | '\u{300}'..='\u{36F}' | '\u{203F}'..='\u{2040}') -} - -/// Keep the existing letters/digits/`_`/`-`/`.` normalization, constrained to valid NCNames. -fn xml_name(name: &str) -> String { - let mut s: String = - name.chars().map(|c| if (c.is_alphanumeric() && xml_name_char(c)) || matches!(c, '_' | '-' | '.') { c } else { '_' }).collect(); - if !s.chars().next().is_some_and(|c| (c.is_alphabetic() && xml_name_start(c)) || c == '_') { - s.insert(0, '_'); - } - s -} - /// Form data as XML, CSV or tab-delimited text (Acrobat's Export data formats). pub fn export_data(doc: &Document, format: Format) -> String { let fields: Vec<(String, String)> = pdfcraft_forms::fields(doc) @@ -882,483 +130,6 @@ pub fn export_data(doc: &Document, format: Format) -> String { } } -// ── import ────────────────────────────────────────────────────────────────────────────────── - -/// Set the values of fields named in `values` (full names), counting what took. -fn apply_values(doc: &mut Document, values: &[(String, Vec)], report: &mut Report) { - let all = pdfcraft_forms::fields(doc); - for (name, vals) in values { - let Some(f) = all.iter().find(|f| &f.name == name) else { continue }; - let first = vals.first().cloned().unwrap_or_default(); - let v = match f.kind { - FieldKind::Text => FieldValue::Text(first), - FieldKind::CheckBox => FieldValue::Check(!first.is_empty() && first != "Off"), - FieldKind::Radio => FieldValue::Radio((!first.is_empty() && first != "Off").then_some(first)), - // XML, CSV and text hold a multi-select list's values in one cell, joined by ", " - // (as export_data writes them); split it unless the whole cell is one option. - FieldKind::List if f.has(pdfcraft_forms::flags::MULTI_SELECT) && vals.len() == 1 && !f.options.iter().any(|(e, _)| *e == first) => { - FieldValue::Choice(first.split(", ").filter(|v| !v.is_empty()).map(str::to_string).collect()) - } - FieldKind::Combo | FieldKind::List => FieldValue::Choice(vals.iter().filter(|v| !v.is_empty()).cloned().collect()), - FieldKind::PushButton | FieldKind::Signature => continue, - }; - // Read-only fields still take imported data, as in Acrobat. - let ro = f.read_only(); - if ro { - let _ = doc.update_dict(f.obj, |d| d.set(b"Ff".to_vec(), Object::Int((f.flags & !pdfcraft_forms::flags::READ_ONLY) as i64))); - } - match pdfcraft_forms::set_value(doc, name, &v) { - Ok(()) => report.fields += 1, - Err(_) => report.rejected.push(name.clone()), - } - if ro { - let _ = doc.update_dict(f.obj, |d| d.set(b"Ff".to_vec(), Object::Int(f.flags as i64))); - } - } -} - -/// A new annotation dictionary from an XFDF element. -fn annot_from_xml(node: roxmltree::Node, subtype: &str, page_ref: ObjRef) -> Option { - let mut d = Dict::new(); - d.set(b"Type".to_vec(), Object::name("Annot")); - d.set(b"Subtype".to_vec(), Object::name(subtype)); - d.set(b"P".to_vec(), Object::Ref(page_ref)); - let rect = csv(node.attribute("rect")?); - if rect.len() != 4 { - return None; - } - d.set(b"Rect".to_vec(), arr(&rect)); - for (attr, key) in [ - ("name", &b"NM"[..]), - ("title", b"T"), - ("subject", b"Subj"), - ("date", b"M"), - ("creationdate", b"CreationDate"), - ("state", b"State"), - ("statemodel", b"StateModel"), - ] { - if let Some(v) = node.attribute(attr) { - d.set(key.to_vec(), PdfString::text(v)); - } - } - if let Some(v) = node.attribute("replyType") { - d.set(b"RT".to_vec(), Object::name(if v.eq_ignore_ascii_case("group") { "Group" } else { "R" })); - } - if let Some(v) = node.attribute("icon") { - d.set(b"Name".to_vec(), Object::name(v)); - } - let flags: i64 = node - .attribute("flags") - .unwrap_or("print") - .split(',') - .filter_map(|f| FLAGS.iter().find(|(_, s)| s.eq_ignore_ascii_case(f.trim())).map(|x| x.0)) - .fold(0, |bits, flag| bits | flag); - d.set(b"F".to_vec(), Object::Int(flags)); - if let Some(c) = node.attribute("color").and_then(parse_hex) { - d.set(b"C".to_vec(), arr(&c)); - } - if let Some(c) = node.attribute("interior-color").and_then(parse_hex) { - d.set(b"IC".to_vec(), arr(&c)); - } - if let Some(o) = node.attribute("opacity").and_then(|o| o.parse::().ok()) { - d.set(b"CA".to_vec(), Object::Real(o.clamp(0.0, 1.0))); - } - if let Some(w) = node.attribute("width").and_then(|w| w.parse::().ok()) { - let mut bs = Dict::new(); - bs.set(b"W".to_vec(), Object::Real(w.max(0.0))); - bs.set(b"S".to_vec(), Object::name("S")); - d.set(b"BS".to_vec(), Object::Dict(bs)); - } - if let Some(q) = node.attribute("coords") { - d.set(b"QuadPoints".to_vec(), arr(&csv(q))); - } - if let (Some(s), Some(e)) = (node.attribute("start"), node.attribute("end")) { - let (s, e) = (csv(s), csv(e)); - if s.len() == 2 && e.len() == 2 { - d.set(b"L".to_vec(), arr(&[s[0], s[1], e[0], e[1]])); - } - } - if node.attribute("head").is_some() || node.attribute("tail").is_some() { - d.set( - b"LE".to_vec(), - Object::Array(vec![Object::name(node.attribute("head").unwrap_or("None")), Object::name(node.attribute("tail").unwrap_or("None"))]), - ); - } - if let Some(cl) = node.attribute("callout").map(csv).filter(|c| c.len() == 4 || c.len() == 6) { - d.set(b"CL".to_vec(), arr(&cl)); - if let Some(rd) = node.attribute("fringe").map(csv).filter(|r| r.len() == 4) { - d.set(b"RD".to_vec(), arr(&rd)); - } - d.set(b"IT".to_vec(), Object::name(node.attribute("intent").unwrap_or("FreeTextCallout"))); - // A callout's /LE is one name, not the head/tail pair of a line. - match node.attribute("head") { - Some(h) => d.set(b"LE".to_vec(), Object::name(h)), - None => { - d.remove(b"LE"); - } - } - } - for child in node.children().filter(|c| c.is_element()) { - match child.tag_name().name() { - "contents" => { - d.set(b"Contents".to_vec(), PdfString::text(&child.text().unwrap_or("").replace("\r\n", "\r").replace('\n', "\r"))); - } - "defaultappearance" => { - d.set(b"DA".to_vec(), PdfString::literal(child.text().unwrap_or("").as_bytes().to_vec())); - } - "inklist" => { - let list: Vec = child - .children() - .filter(|g| g.has_tag_name("gesture")) - .map(|g| arr(&g.text().unwrap_or("").split(';').flat_map(|p| csv(p).into_iter()).collect::>())) - .collect(); - d.set(b"InkList".to_vec(), Object::Array(list)); - } - "vertices" => { - // Whole x,y pairs only; a stray trailing number is dropped. - let mut v = csv(child.text().unwrap_or("")); - v.truncate(v.len() - v.len() % 2); - if !v.is_empty() { - d.set(b"Vertices".to_vec(), arr(&v)); - } - } - _ => {} - } - } - Some(d) -} - -/// Put an annotation dictionary on a page, replacing one with the same `/NM`. Returns its -/// reference. -/// `/F` bits an imported file may not set on a redaction mark: Invisible, Hidden, NoView — so a -/// mark it brings in is always drawn for the user to review before content is removed. -const REDACT_UNSEEN: i64 = 1 | 2 | 32; -/// `/F` Locked (ISO 32000-2 Table 167): the user protected this annotation from change. -const LOCKED: i64 = 128; - -/// An existing annotation an import must never replace, move or unlock: one the user locked, a -/// redaction mark (replacing it would drop the region the user marked to remove), or a form -/// widget or field (replacing it would set the field's value and policy around the form setter). -fn import_protected(doc: &Document, x: &Dict) -> bool { - let flags = x.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0); - flags & LOCKED != 0 || matches!(x.name(b"Subtype"), Some(b"Redact" | b"Widget")) || x.contains(b"FT") -} - -fn place(doc: &mut Document, page_ref: ObjRef, mut d: Dict) -> Result { - // A redaction mark the import carries is always made visible: clear any Invisible/Hidden/NoView - // flag so it cannot remove content the user never sees. - if d.name(b"Subtype") == Some(b"Redact".as_slice()) { - let f = d.int(b"F").unwrap_or(0); - if f & REDACT_UNSEEN != 0 { - d.set(b"F".to_vec(), Object::Int(f & !REDACT_UNSEEN)); - } - // Its review outline is drawn opaque, and from the area it removes: applying a mark - // without usable /QuadPoints removes its /Rect, which the outline is then drawn from. - d.remove(b"CA"); - d.remove(b"ca"); - let quads_drawable = d.get(b"QuadPoints").and_then(Object::as_array).is_some_and(|q| { - let v: Vec = q.iter().filter_map(Object::as_f64).filter(|v| v.is_finite()).collect(); - let (quads, rest) = v.as_chunks::<8>(); - v.len() == q.len() && rest.is_empty() && !quads.is_empty() && quads.iter().all(quad_has_area) - }); - if !quads_drawable { - // Quads the outline can't draw, or any quad that encloses no area, are replaced by the - // /Rect's, or dropped when there is no usable /Rect either; such a mark then removes - // nothing. - match rect_strict(doc, d.get(b"Rect")) { - Some([x0, y0, x1, y1]) => { - let (l, r, b, t) = (x0.min(x1), x0.max(x1), y0.min(y1), y0.max(y1)); - d.set(b"QuadPoints".to_vec(), arr(&[l, t, r, t, l, b, r, b])); - } - _ => { - d.remove(b"QuadPoints"); - } - } - } - } - let nm = d.get(b"NM").and_then(|n| n.as_string().map(|s| s.to_text())); - let annots: Vec = doc - .get(page_ref) - .as_dict() - .and_then(|p| p.get(b"Annots").cloned()) - .map(|a| doc.resolve(&a)) - .and_then(|a| a.as_array().cloned()) - .unwrap_or_default(); - let mut kept = Vec::new(); - let mut removed: Vec = Vec::new(); - // Never let a same-name import replace a protected annotation (`import_protected`): keep it, - // and add the imported dict beside it under no name of its own. - let mut clash = false; - for a in annots { - let existing = a.as_ref().and_then(|r| doc.get(r).as_dict().cloned()); - let same = nm.is_some() && existing.as_ref().and_then(|x| x.get(b"NM").and_then(|n| doc.resolve(n).as_string().map(|s| s.to_text()))) == nm; - if same && existing.as_ref().is_some_and(|x| import_protected(doc, x)) { - clash = true; - kept.push(a); - } else if same { - removed.extend(a.as_ref()); - } else { - kept.push(a); - } - } - if clash { - d.remove(b"NM"); - } - // The replaced note's pop-up goes with it; otherwise it stays on the page pointing at a note that is no longer there. - kept.retain(|a| { - let Some(x) = a.as_ref().map(|r| doc.get(r)).and_then(|o| o.as_dict().cloned()) else { return true }; - x.name(b"Subtype") != Some(b"Popup".as_slice()) || x.reference(b"Parent").is_none_or(|p| !removed.contains(&p)) - }); - // Keep the replaced comment's identity: replies and review statuses not included in a - // partial import still point at it through /IRT. Allocating a new object would detach - // those comments and leave their parent outside the page's annotation list. - let r = match removed.first().copied() { - Some(r) => { - doc.set(r, Object::Dict(d)); - r - } - None => doc.add(Object::Dict(d)), - }; - // Our own appearance where we can draw one; others keep none (viewers draw a default). - let _ = pdfcraft_annot::set_appearance(doc, r); - kept.push(Object::Ref(r)); - doc.update_dict(page_ref, |p| p.set(b"Annots".to_vec(), Object::Array(kept)))?; - Ok(r) -} - -/// A pop-up window for the comment `parent`, on its page. -fn add_popup(doc: &mut Document, page: ObjRef, parent: ObjRef, rect: &[f64], open: bool) -> Result<(), DataError> { - let mut p = Dict::new(); - p.set(b"Type".to_vec(), Object::name("Annot")); - p.set(b"Subtype".to_vec(), Object::name("Popup")); - p.set(b"Rect".to_vec(), arr(rect)); - p.set(b"Parent".to_vec(), Object::Ref(parent)); - p.set(b"Open".to_vec(), Object::Bool(open)); - let pr = doc.add(Object::Dict(p)); - doc.update_dict(parent, |d| d.set(b"Popup".to_vec(), Object::Ref(pr)))?; - doc.update_dict(page, |pd| { - if let Some(Object::Array(a)) = pd.get_mut(b"Annots") { - a.push(Object::Ref(pr)); - } - })?; - Ok(()) -} - -fn import_xfdf(doc: &mut Document, text: &str) -> Result { - check_form_xml_depth(text)?; - let xml = parse_form_xml(text).map_err(|e| DataError::Malformed(e.to_string()))?; - let root = xml.root_element(); - if root.tag_name().name() != "xfdf" { - return Err(DataError::UnknownFormat); - } - let mut report = Report::default(); - let pages: Vec = pdfcraft_model::pages(doc).iter().map(|p| p.obj).collect(); - // Comments: parents first, then replies (whose /IRT needs the parent's reference). - let mut by_name: HashMap = HashMap::new(); - let mut later: HashMap = HashMap::new(); - if let Some(annots) = root.children().find(|c| c.has_tag_name("annots")) { - for node in annots.children().filter(|c| c.is_element()) { - let tag = node.tag_name().name().to_ascii_lowercase(); - let Some((subtype, _)) = SUBTYPES.iter().find(|(_, t)| *t == tag) else { continue }; - let Some(page) = node.attribute("page").and_then(|p| p.parse::().ok()).and_then(|p| pages.get(p).copied()) else { continue }; - let Some(d) = annot_from_xml(node, subtype, page) else { continue }; - let r = place(doc, page, d)?; - // A later same-name record replaces this comment, including its reply relation. - later.remove(&r); - if let Some(nm) = node.attribute("name") { - by_name.insert(nm.to_string(), r); - } - if let Some(irt) = node.attribute("inreplyto") { - later.insert(r, irt.to_string()); - } - // A pop-up window. - if let Some(pop) = node.children().find(|c| c.has_tag_name("popup")) - && let Some(rect) = pop.attribute("rect").map(csv).filter(|r| r.len() == 4) - { - add_popup(doc, page, r, &rect, pop.attribute("open") == Some("yes"))?; - } - report.comments += 1; - } - } - for (r, irt) in later { - if let Some(parent) = by_name.get(&irt).copied().or_else(|| find_by_name(doc, &irt)) { - doc.update_dict(r, |d| d.set(b"IRT".to_vec(), Object::Ref(parent)))?; - } - } - // Fields. - if let Some(fields) = root.children().find(|c| c.has_tag_name("fields")) { - let mut values = Vec::new(); - walk_xfdf(fields, "", &mut values, 0)?; - apply_values(doc, &values, &mut report); - } - Ok(report) -} - -fn find_by_name(doc: &Document, nm: &str) -> Option { - for p in pdfcraft_model::pages(doc) { - for a in p.dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default() { - if let Some(r) = a.as_ref() - && doc.get(r).as_dict().and_then(|d| text_of(doc, d, b"NM")).as_deref() == Some(nm) - { - return Some(r); - } - } - } - None -} - -/// What one FDF import may copy out of the file. A field tree or comment that repeats a reference -/// level after level would otherwise expand a small file into millions of values. -const MAX_FDF_IMPORT_VALUES: usize = 1_000_000; - -struct ImportBudget { - values: usize, - bytes: usize, -} - -impl ImportBudget { - fn new() -> Self { - Self { values: MAX_FDF_IMPORT_VALUES, bytes: MAX_FDF_BYTES } - } - - fn spend(&mut self, values: usize, bytes: usize) -> Result<(), DataError> { - match (self.values.checked_sub(values), self.bytes.checked_sub(bytes)) { - (Some(v), Some(b)) => { - (self.values, self.bytes) = (v, b); - Ok(()) - } - _ => Err(DataError::Malformed(format!( - "the file expands to more than {MAX_FDF_IMPORT_VALUES} values or {} MiB of text", - MAX_FDF_BYTES >> 20 - ))), - } - } -} - -/// Copy an object out of another document, resolving references. A reference back to an object -/// being copied becomes null. -fn deep(src: &Document, o: &Object, depth: usize, path: &mut Vec, budget: &mut ImportBudget) -> Result { - budget.spend(1, 0)?; - if depth > 16 { - return Ok(Object::Null); - } - Ok(match o { - Object::Ref(r) => { - if path.contains(r) { - return Ok(Object::Null); - } - path.push(*r); - let copy = deep(src, &src.get(*r), depth + 1, path, budget); - path.pop(); - copy? - } - Object::Array(a) => Object::Array(a.iter().map(|x| deep(src, x, depth + 1, path, budget)).collect::>()?), - Object::Dict(d) => { - Object::Dict(d.iter().map(|(k, v)| Ok((k.clone(), deep(src, v, depth + 1, path, budget)?))).collect::>()?) - } - Object::Stream(_) => Object::Null, - Object::String(s) => { - budget.spend(0, s.bytes.len())?; - o.clone() - } - Object::Name(n) => { - budget.spend(0, n.len())?; - o.clone() - } - other => other.clone(), - }) -} - -fn walk_xfdf(node: roxmltree::Node, prefix: &str, out: &mut Vec<(String, Vec)>, depth: usize) -> Result<(), DataError> { - if depth > MAX_DEPTH { - return Err(DataError::Malformed(format!("XFDF fields nest more than {MAX_DEPTH} deep"))); - } - for f in node.children().filter(|c| c.has_tag_name("field")) { - let Some(name) = f.attribute("name") else { continue }; - let full = if prefix.is_empty() { name.to_string() } else { format!("{prefix}.{name}") }; - let vals: Vec = f - .children() - .filter(|c| c.has_tag_name("value")) - // Comments and processing instructions can split a value into several text nodes. - .map(|v| v.children().filter(|n| n.is_text()).filter_map(|n| n.text()).collect()) - .collect(); - // A leaf with no `` is an empty field (the export writes one that way), so it - // clears the field like FDF's `/V ()`; a field with kids only names their parent. A - // leaf holding only `` is not read yet, so it leaves the field alone. - let names_kids_or_rich = f.children().any(|c| c.has_tag_name("field") || c.has_tag_name("value-richtext")); - if !vals.is_empty() || !names_kids_or_rich { - out.push((full.clone(), vals)); - } - walk_xfdf(f, &full, out, depth + 1)?; - } - Ok(()) -} - -/// Read an FDF field tree. A field listed again beneath itself is skipped. -fn walk_fdf( - fdf: &Document, - list: &[Object], - prefix: &str, - out: &mut Vec<(String, Vec)>, - depth: usize, - path: &mut Vec, - budget: &mut ImportBudget, -) -> Result<(), DataError> { - if depth > 32 { - return Ok(()); - } - for f in list { - budget.spend(1, 0)?; - let held; - let field = match f { - Object::Ref(r) if path.contains(r) => continue, - Object::Ref(r) => { - held = fdf.get(*r); - &*held - } - direct => direct, - }; - let Some(d) = field.as_dict() else { continue }; - let Some(t) = d.get(b"T").and_then(|t| fdf.resolve(t).as_string().map(|s| s.to_text())) else { continue }; - let full = if prefix.is_empty() { t } else { format!("{prefix}.{t}") }; - budget.spend(0, full.len())?; - if let Some(v) = d.get(b"V").map(|v| fdf.resolve(v)) { - let vals: Vec = match &*v { - Object::String(s) => vec![s.to_text()], - Object::Name(n) => vec![pdfcraft_forms::name_text(n)], - Object::Array(a) => { - budget.spend(a.len(), 0)?; - a.iter().filter_map(|x| fdf.resolve(x).as_string().map(|s| s.to_text())).collect() - } - _ => Vec::new(), - }; - budget.spend(vals.len(), vals.iter().map(String::len).fold(0, usize::saturating_add))?; - out.push((full.clone(), vals)); - } - if let Some(kids) = d.get(b"Kids").map(|k| fdf.resolve(k)) - && let Some(kids) = kids.as_array() - { - let entered = f.as_ref().map(|r| path.push(r)).is_some(); - let walked = walk_fdf(fdf, kids, &full, out, depth + 1, path, budget); - if entered { - path.pop(); - } - walked?; - } - } - Ok(()) -} - -fn fdf_body(fdf: &Document) -> Result { - let root = fdf.root().ok_or_else(|| DataError::Malformed("no /Root".into()))?; - fdf.get(root) - .as_dict() - .and_then(|d| d.get(b"FDF").cloned()) - .map(|f| fdf.resolve(&f)) - .and_then(|f| f.as_dict().cloned()) - .ok_or(DataError::UnknownFormat) -} - /// The field values in a form data file (FDF or XFDF) or a filled-in PDF form, in file order. pub fn data_values(bytes: &[u8]) -> Result)>, DataError> { if bytes.starts_with(b"%FDF") { @@ -1436,66 +207,6 @@ pub fn merge_csv(files: &[Vec<(String, Vec)>]) -> String { out } -fn import_fdf(doc: &mut Document, bytes: &[u8]) -> Result { - let fdf = Document::open(std::sync::Arc::new(bytes.to_vec())).map_err(|e| DataError::Malformed(e.to_string()))?; - let body = fdf_body(&fdf)?; - let mut report = Report::default(); - let mut budget = ImportBudget::new(); - // Fields (with /Kids for hierarchical names). - let mut values = Vec::new(); - if let Some(list) = body.get(b"Fields").map(|f| fdf.resolve(f)).and_then(|f| f.as_array().cloned()) { - walk_fdf(&fdf, &list, "", &mut values, 0, &mut Vec::new(), &mut budget)?; - } - // Comments: parents first, then replies (whose /IRT needs the parent's new reference). - let pages: Vec = pdfcraft_model::pages(doc).iter().map(|p| p.obj).collect(); - let mut placed: HashMap = HashMap::new(); - let mut later: HashMap, Option)> = HashMap::new(); - for a in body.get(b"Annots").map(|a| fdf.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default() { - let Some(mut src) = fdf.resolve(&a).as_dict().cloned() else { continue }; - // The parent is another comment: link it below rather than copying it in. - let irt = src.get(b"IRT").cloned(); - src.remove(b"IRT"); - // The pop-up is an annotation of its own (/Popup shall be an indirect reference, ISO - // 32000-2 Table 172): copied in, it would become a direct dictionary. A new one is - // placed on the page below, as the XFDF import does. - let popup = src.get(b"Popup").map(|p| fdf.resolve(p)).and_then(|p| p.as_dict().cloned()); - src.remove(b"Popup"); - let mut path: Vec = a.as_ref().into_iter().collect(); - let Object::Dict(mut d) = deep(&fdf, &Object::Dict(src), 0, &mut path, &mut budget)? else { continue }; - let Some(page) = d.get(b"Page").and_then(Object::as_int).and_then(|p| usize::try_from(p).ok()).and_then(|p| pages.get(p).copied()) else { - continue; - }; - if !d.name(b"Subtype").is_some_and(|s| SUBTYPES.iter().any(|(x, _)| x.as_bytes() == s)) { - continue; - } - d.remove(b"Page"); - d.set(b"P".to_vec(), Object::Ref(page)); - let r = place(doc, page, d)?; - later.remove(&r); - if let Some(rect) = popup.as_ref().and_then(|p| rect_strict(&fdf, p.get(b"Rect"))) { - let open = popup.as_ref().and_then(|p| p.get(b"Open")).is_some_and(|o| matches!(&*fdf.resolve(o), Object::Bool(true))); - add_popup(doc, page, r, &rect, open)?; - } - if let Some(fr) = a.as_ref() { - placed.insert(fr, r); - } - if let Some(irt) = irt { - let parent_name = fdf.resolve(&irt).as_dict().and_then(|p| text_of(&fdf, p, b"NM")); - later.insert(r, (irt.as_ref(), parent_name)); - } - report.comments += 1; - } - for (r, (parent_obj, parent_name)) in later { - // The parent imported from this file, else one already in the document with its name. - let parent = parent_obj.and_then(|p| placed.get(&p).copied()).or_else(|| parent_name.and_then(|nm| find_by_name(doc, &nm))); - if let Some(parent) = parent.filter(|p| *p != r) { - doc.update_dict(r, |d| d.set(b"IRT".to_vec(), Object::Ref(parent)))?; - } - } - apply_values(doc, &values, &mut report); - Ok(report) -} - // Inspect the first logical record: tabs within quoted CSV cells are data. A quote // in the middle of an unquoted TSV name stays literal, as in the TSV reader. fn has_tab_delimiter(text: &str) -> bool { diff --git a/crates/xfdf/src/tests.rs b/crates/xfdf/src/tests.rs index 2bb54f9232..52a4a5b86d 100644 --- a/crates/xfdf/src/tests.rs +++ b/crates/xfdf/src/tests.rs @@ -1,6 +1,6 @@ use std::sync::Arc; -use pdfcraft_annot::{Markup, Meta, NewAnnotation, Shape, Style, add_annotation, add_reply, add_text_replacement, summaries}; +use pdfcraft_annot::{Markup, Meta, NewAnnotation, Shape, Style, TextReplacement, add_annotation, add_reply, add_text_replacement, summaries}; use pdfcraft_cos::{SaveOptions, write_full, write_incremental}; use pdfcraft_forms::{NewField, add_field, fields, set_value}; @@ -151,7 +151,20 @@ fn a_replace_text_pair_survives_an_xfdf_round_trip() { let quads = [[100.0, 720.0, 200.0, 720.0, 100.0, 706.0, 200.0, 706.0]]; let style = Style { color: [0.89, 0.13, 0.13], opacity: 1.0, width: 1.0, fill: None }; let meta = |id: &str| Meta { date: Some("D:20261001120000Z".into()), id: id.into() }; - add_text_replacement(&mut src, 0, &quads, "new words", "Ada", &style, &style, &meta("strike"), &meta("caret")).unwrap(); + add_text_replacement( + &mut src, + TextReplacement { + page: 0, + quads: &quads, + replacement: "new words", + author: "Ada", + strike: &style, + caret: &style, + strike_meta: &meta("strike"), + caret_meta: &meta("caret"), + }, + ) + .unwrap(); assert_eq!(summaries(&src).len(), 2, "a strikeout and a caret"); let xfdf = export_xfdf(&src, true, false, "a.pdf"); diff --git a/crates/xfdf/src/xfdf.rs b/crates/xfdf/src/xfdf.rs new file mode 100644 index 0000000000..a5881adf26 --- /dev/null +++ b/crates/xfdf/src/xfdf.rs @@ -0,0 +1,536 @@ +use super::common::{ + FLAGS, MAX_DEPTH, SUBTYPES, arr, check_form_xml_depth, csv, esc, hex, n, nums_of, parse_form_xml, parse_hex, quad_has_area, rect_strict, text_of, + walk_xfdf, +}; +use super::*; + +// ── export ────────────────────────────────────────────────────────────────────────────────── + +/// Every comment as an XFDF element (one line per comment, replies included). +fn xfdf_annots(doc: &Document, out: &mut String) { + let pages = pdfcraft_model::pages(doc); + // NM of every annotation, for inreplyto. + let mut names: HashMap = HashMap::new(); + for p in &pages { + for a in p.dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default() { + if let Some(r) = a.as_ref() + && let Some(nm) = doc.get(r).as_dict().and_then(|d| text_of(doc, d, b"NM")) + { + names.insert(r, nm); + } + } + } + for (pi, p) in pages.iter().enumerate() { + for a in p.dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default() { + let Some(d) = doc.resolve(&a).as_dict().cloned() else { continue }; + let Some(sub) = d.name(b"Subtype") else { continue }; + let Some((_, tag)) = SUBTYPES.iter().find(|(s, _)| s.as_bytes() == sub) else { continue }; + let mut attrs = format!(" page=\"{pi}\""); + let rect = nums_of(doc, d.get(b"Rect")); + if rect.len() == 4 { + let _ = write!( + attrs, + " rect=\"{},{},{},{}\"", + n(rect[0].min(rect[2])), + n(rect[1].min(rect[3])), + n(rect[0].max(rect[2])), + n(rect[1].max(rect[3])) + ); + } + for (key, attr) in [ + (&b"NM"[..], "name"), + (b"T", "title"), + (b"Subj", "subject"), + (b"M", "date"), + (b"CreationDate", "creationdate"), + (b"RT", "replyType"), + (b"State", "state"), + (b"StateModel", "statemodel"), + (b"Name", "icon"), + ] { + if let Some(v) = text_of(doc, &d, key) { + let _ = write!(attrs, " {attr}=\"{}\"", esc(&v)); + } + } + let flags = d.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0); + let fl: Vec<&str> = FLAGS.iter().filter(|(b, _)| flags & b != 0).map(|(_, s)| *s).collect(); + let _ = write!(attrs, " flags=\"{}\"", fl.join(",")); + if let Some(c) = hex(&nums_of(doc, d.get(b"C"))) { + let _ = write!(attrs, " color=\"{c}\""); + } + if let Some(c) = hex(&nums_of(doc, d.get(b"IC"))) { + let _ = write!(attrs, " interior-color=\"{c}\""); + } + if let Some(o) = d.get(b"CA").and_then(|o| doc.resolve(o).as_f64()) { + let _ = write!(attrs, " opacity=\"{}\"", n(o)); + } + if let Some(w) = d.get(b"BS").and_then(|b| doc.resolve(b).as_dict().and_then(|b| b.get(b"W").and_then(Object::as_f64))) { + let _ = write!(attrs, " width=\"{}\"", n(w)); + } + let quads = nums_of(doc, d.get(b"QuadPoints")); + if !quads.is_empty() { + let _ = write!(attrs, " coords=\"{}\"", quads.iter().map(|v| n(*v)).collect::>().join(",")); + } + let l = nums_of(doc, d.get(b"L")); + if l.len() == 4 { + let _ = write!(attrs, " start=\"{},{}\" end=\"{},{}\"", n(l[0]), n(l[1]), n(l[2]), n(l[3])); + } + if let Some(le) = d.get(b"LE").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()) + && le.len() == 2 + { + let name = |o: &Object| o.as_name().map(|x| String::from_utf8_lossy(x).into_owned()).unwrap_or_else(|| "None".into()); + let _ = write!(attrs, " head=\"{}\" tail=\"{}\"", esc(&name(&le[0])), esc(&name(&le[1]))); + } + // A callout: its leader path, text-box inset, intent and (single-name) line ending. + let cl = nums_of(doc, d.get(b"CL")); + if cl.len() == 4 || cl.len() == 6 { + let _ = write!(attrs, " callout=\"{}\"", cl.iter().map(|v| n(*v)).collect::>().join(",")); + let rd = nums_of(doc, d.get(b"RD")); + if rd.len() == 4 { + let _ = write!(attrs, " fringe=\"{}\"", rd.iter().map(|v| n(*v)).collect::>().join(",")); + } + if let Some(it) = d.get(b"IT").map(|o| doc.resolve(o)).and_then(|o| o.as_name().map(|x| String::from_utf8_lossy(x).into_owned())) { + let _ = write!(attrs, " intent=\"{}\"", esc(&it)); + } + if let Some(le) = d.get(b"LE").map(|o| doc.resolve(o)).and_then(|o| o.as_name().map(|x| String::from_utf8_lossy(x).into_owned())) { + let _ = write!(attrs, " head=\"{}\"", esc(&le)); + } + } + if let Some(irt) = d.get(b"IRT").and_then(Object::as_ref).and_then(|r| names.get(&r)) { + let _ = write!(attrs, " inreplyto=\"{}\"", esc(irt)); + } + let _ = write!(out, "<{tag}{attrs}>"); + if let Some(c) = text_of(doc, &d, b"Contents") { + let _ = write!(out, "{}", esc(&c)); + } + if let Some(da) = text_of(doc, &d, b"DA") { + let _ = write!(out, "{}", esc(&da)); + } + let ink = d.get(b"InkList").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default(); + if !ink.is_empty() { + out.push_str(""); + for g in ink { + let v = nums_of(doc, Some(&g)); + let pts: Vec = v.as_chunks::<2>().0.iter().map(|p| format!("{},{}", n(p[0]), n(p[1]))).collect(); + let _ = write!(out, "{}", pts.join(";")); + } + out.push_str(""); + } + // Polygon and polyline points (XFDF ``: `x,y;x,y;…`). + let vertices = nums_of(doc, d.get(b"Vertices")); + let pts: Vec = vertices.as_chunks::<2>().0.iter().map(|p| format!("{},{}", n(p[0]), n(p[1]))).collect(); + if !pts.is_empty() { + let _ = write!(out, "{}", pts.join(";")); + } + if let Some(pd) = d.get(b"Popup").map(|p| doc.resolve(p)).and_then(|p| p.as_dict().cloned()) { + let r = nums_of(doc, pd.get(b"Rect")); + if r.len() == 4 { + let open = matches!(pd.get(b"Open"), Some(Object::Bool(true))); + let _ = write!( + out, + "", + n(r[0]), + n(r[1]), + n(r[2]), + n(r[3]), + if open { "yes" } else { "no" } + ); + } + } + let _ = writeln!(out, ""); + } + } +} + +/// Field values as nested XFDF `` elements. +fn xfdf_fields(doc: &Document, out: &mut String) { + #[derive(Default)] + struct Node { + values: Vec, + kids: Vec<(String, Node)>, + } + let mut root = Node::default(); + for f in pdfcraft_forms::fields(doc) { + if matches!(f.kind, FieldKind::PushButton | FieldKind::Signature) { + continue; + } + let mut node = &mut root; + // One `` level per part of the name, but no more levels than the import reads + // back. A `/T` can hold periods of its own, so a name can split into far more parts than + // the field tree has levels: nesting every part wrote a file the import refuses and, for + // a long enough name, recursed in `write_node` until the stack overflowed. The parts past + // the last level stay together in the deepest element's name, and the import joins them + // back with periods into the same full name. + for part in f.name.splitn(MAX_DEPTH, '.') { + let i = match node.kids.iter().position(|(k, _)| k == part) { + Some(i) => i, + None => { + node.kids.push((part.to_string(), Node::default())); + node.kids.len() - 1 + } + }; + node = &mut node.kids[i].1; + } + node.values = match f.kind { + FieldKind::CheckBox | FieldKind::Radio if f.value.is_empty() => vec!["Off".into()], + _ => f.value.clone(), + }; + } + fn write_node(name: &str, node: &Node, out: &mut String) { + let _ = write!(out, "", esc(name)); + for v in &node.values { + let _ = write!(out, "{}", esc(v)); + } + for (k, kid) in &node.kids { + write_node(k, kid, out); + } + out.push_str("\n"); + } + for (k, kid) in &root.kids { + write_node(k, kid, out); + } +} + +/// XFDF with the document's comments and/or field values. +pub fn export_xfdf(doc: &Document, comments: bool, fields: bool, file: &str) -> String { + let mut out = String::from("\n\n"); + if comments { + out.push_str("\n"); + xfdf_annots(doc, &mut out); + out.push_str("\n"); + } + if fields { + out.push_str("\n"); + xfdf_fields(doc, &mut out); + out.push_str("\n"); + } + let _ = writeln!(out, "", esc(file)); + out.push_str("\n"); + out +} + +// ── import ────────────────────────────────────────────────────────────────────────────────── + +/// Set the values of fields named in `values` (full names), counting what took. +pub(crate) fn apply_values(doc: &mut Document, values: &[(String, Vec)], report: &mut Report) { + let all = pdfcraft_forms::fields(doc); + for (name, vals) in values { + let Some(f) = all.iter().find(|f| &f.name == name) else { continue }; + let first = vals.first().cloned().unwrap_or_default(); + let v = match f.kind { + FieldKind::Text => FieldValue::Text(first), + FieldKind::CheckBox => FieldValue::Check(!first.is_empty() && first != "Off"), + FieldKind::Radio => FieldValue::Radio((!first.is_empty() && first != "Off").then_some(first)), + // XML, CSV and text hold a multi-select list's values in one cell, joined by ", " + // (as export_data writes them); split it unless the whole cell is one option. + FieldKind::List if f.has(pdfcraft_forms::flags::MULTI_SELECT) && vals.len() == 1 && !f.options.iter().any(|(e, _)| *e == first) => { + FieldValue::Choice(first.split(", ").filter(|v| !v.is_empty()).map(str::to_string).collect()) + } + FieldKind::Combo | FieldKind::List => FieldValue::Choice(vals.iter().filter(|v| !v.is_empty()).cloned().collect()), + FieldKind::PushButton | FieldKind::Signature => continue, + }; + // Read-only fields still take imported data, as in Acrobat. + let ro = f.read_only(); + if ro { + let _ = doc.update_dict(f.obj, |d| d.set(b"Ff".to_vec(), Object::Int((f.flags & !pdfcraft_forms::flags::READ_ONLY) as i64))); + } + match pdfcraft_forms::set_value(doc, name, &v) { + Ok(()) => report.fields += 1, + Err(_) => report.rejected.push(name.clone()), + } + if ro { + let _ = doc.update_dict(f.obj, |d| d.set(b"Ff".to_vec(), Object::Int(f.flags as i64))); + } + } +} + +/// A new annotation dictionary from an XFDF element. +fn annot_from_xml(node: roxmltree::Node, subtype: &str, page_ref: ObjRef) -> Option { + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("Annot")); + d.set(b"Subtype".to_vec(), Object::name(subtype)); + d.set(b"P".to_vec(), Object::Ref(page_ref)); + let rect = csv(node.attribute("rect")?); + if rect.len() != 4 { + return None; + } + d.set(b"Rect".to_vec(), arr(&rect)); + for (attr, key) in [ + ("name", &b"NM"[..]), + ("title", b"T"), + ("subject", b"Subj"), + ("date", b"M"), + ("creationdate", b"CreationDate"), + ("state", b"State"), + ("statemodel", b"StateModel"), + ] { + if let Some(v) = node.attribute(attr) { + d.set(key.to_vec(), PdfString::text(v)); + } + } + if let Some(v) = node.attribute("replyType") { + d.set(b"RT".to_vec(), Object::name(if v.eq_ignore_ascii_case("group") { "Group" } else { "R" })); + } + if let Some(v) = node.attribute("icon") { + d.set(b"Name".to_vec(), Object::name(v)); + } + let flags: i64 = node + .attribute("flags") + .unwrap_or("print") + .split(',') + .filter_map(|f| FLAGS.iter().find(|(_, s)| s.eq_ignore_ascii_case(f.trim())).map(|x| x.0)) + .fold(0, |bits, flag| bits | flag); + d.set(b"F".to_vec(), Object::Int(flags)); + if let Some(c) = node.attribute("color").and_then(parse_hex) { + d.set(b"C".to_vec(), arr(&c)); + } + if let Some(c) = node.attribute("interior-color").and_then(parse_hex) { + d.set(b"IC".to_vec(), arr(&c)); + } + if let Some(o) = node.attribute("opacity").and_then(|o| o.parse::().ok()) { + d.set(b"CA".to_vec(), Object::Real(o.clamp(0.0, 1.0))); + } + if let Some(w) = node.attribute("width").and_then(|w| w.parse::().ok()) { + let mut bs = Dict::new(); + bs.set(b"W".to_vec(), Object::Real(w.max(0.0))); + bs.set(b"S".to_vec(), Object::name("S")); + d.set(b"BS".to_vec(), Object::Dict(bs)); + } + if let Some(q) = node.attribute("coords") { + d.set(b"QuadPoints".to_vec(), arr(&csv(q))); + } + if let (Some(s), Some(e)) = (node.attribute("start"), node.attribute("end")) { + let (s, e) = (csv(s), csv(e)); + if s.len() == 2 && e.len() == 2 { + d.set(b"L".to_vec(), arr(&[s[0], s[1], e[0], e[1]])); + } + } + if node.attribute("head").is_some() || node.attribute("tail").is_some() { + d.set( + b"LE".to_vec(), + Object::Array(vec![Object::name(node.attribute("head").unwrap_or("None")), Object::name(node.attribute("tail").unwrap_or("None"))]), + ); + } + if let Some(cl) = node.attribute("callout").map(csv).filter(|c| c.len() == 4 || c.len() == 6) { + d.set(b"CL".to_vec(), arr(&cl)); + if let Some(rd) = node.attribute("fringe").map(csv).filter(|r| r.len() == 4) { + d.set(b"RD".to_vec(), arr(&rd)); + } + d.set(b"IT".to_vec(), Object::name(node.attribute("intent").unwrap_or("FreeTextCallout"))); + // A callout's /LE is one name, not the head/tail pair of a line. + match node.attribute("head") { + Some(h) => d.set(b"LE".to_vec(), Object::name(h)), + None => { + d.remove(b"LE"); + } + } + } + for child in node.children().filter(|c| c.is_element()) { + match child.tag_name().name() { + "contents" => { + d.set(b"Contents".to_vec(), PdfString::text(&child.text().unwrap_or("").replace("\r\n", "\r").replace('\n', "\r"))); + } + "defaultappearance" => { + d.set(b"DA".to_vec(), PdfString::literal(child.text().unwrap_or("").as_bytes().to_vec())); + } + "inklist" => { + let list: Vec = child + .children() + .filter(|g| g.has_tag_name("gesture")) + .map(|g| arr(&g.text().unwrap_or("").split(';').flat_map(|p| csv(p).into_iter()).collect::>())) + .collect(); + d.set(b"InkList".to_vec(), Object::Array(list)); + } + "vertices" => { + // Whole x,y pairs only; a stray trailing number is dropped. + let mut v = csv(child.text().unwrap_or("")); + v.truncate(v.len() - v.len() % 2); + if !v.is_empty() { + d.set(b"Vertices".to_vec(), arr(&v)); + } + } + _ => {} + } + } + Some(d) +} + +/// Put an annotation dictionary on a page, replacing one with the same `/NM`. Returns its +/// reference. +/// `/F` bits an imported file may not set on a redaction mark: Invisible, Hidden, NoView — so a +/// mark it brings in is always drawn for the user to review before content is removed. +const REDACT_UNSEEN: i64 = 1 | 2 | 32; + +/// `/F` Locked (ISO 32000-2 Table 167): the user protected this annotation from change. +pub(crate) const LOCKED: i64 = 128; + +/// An existing annotation an import must never replace, move or unlock: one the user locked, a +/// redaction mark (replacing it would drop the region the user marked to remove), or a form +/// widget or field (replacing it would set the field's value and policy around the form setter). +fn import_protected(doc: &Document, x: &Dict) -> bool { + let flags = x.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0); + flags & LOCKED != 0 || matches!(x.name(b"Subtype"), Some(b"Redact" | b"Widget")) || x.contains(b"FT") +} + +pub(crate) fn place(doc: &mut Document, page_ref: ObjRef, mut d: Dict) -> Result { + // A redaction mark the import carries is always made visible: clear any Invisible/Hidden/NoView + // flag so it cannot remove content the user never sees. + if d.name(b"Subtype") == Some(b"Redact".as_slice()) { + let f = d.int(b"F").unwrap_or(0); + if f & REDACT_UNSEEN != 0 { + d.set(b"F".to_vec(), Object::Int(f & !REDACT_UNSEEN)); + } + // Its review outline is drawn opaque, and from the area it removes: applying a mark + // without usable /QuadPoints removes its /Rect, which the outline is then drawn from. + d.remove(b"CA"); + d.remove(b"ca"); + let quads_drawable = d.get(b"QuadPoints").and_then(Object::as_array).is_some_and(|q| { + let v: Vec = q.iter().filter_map(Object::as_f64).filter(|v| v.is_finite()).collect(); + let (quads, rest) = v.as_chunks::<8>(); + v.len() == q.len() && rest.is_empty() && !quads.is_empty() && quads.iter().all(quad_has_area) + }); + if !quads_drawable { + // Quads the outline can't draw, or any quad that encloses no area, are replaced by the + // /Rect's, or dropped when there is no usable /Rect either; such a mark then removes + // nothing. + match rect_strict(doc, d.get(b"Rect")) { + Some([x0, y0, x1, y1]) => { + let (l, r, b, t) = (x0.min(x1), x0.max(x1), y0.min(y1), y0.max(y1)); + d.set(b"QuadPoints".to_vec(), arr(&[l, t, r, t, l, b, r, b])); + } + _ => { + d.remove(b"QuadPoints"); + } + } + } + } + let nm = d.get(b"NM").and_then(|n| n.as_string().map(|s| s.to_text())); + let annots: Vec = doc + .get(page_ref) + .as_dict() + .and_then(|p| p.get(b"Annots").cloned()) + .map(|a| doc.resolve(&a)) + .and_then(|a| a.as_array().cloned()) + .unwrap_or_default(); + let mut kept = Vec::new(); + let mut removed: Vec = Vec::new(); + // Never let a same-name import replace a protected annotation (`import_protected`): keep it, + // and add the imported dict beside it under no name of its own. + let mut clash = false; + for a in annots { + let existing = a.as_ref().and_then(|r| doc.get(r).as_dict().cloned()); + let same = nm.is_some() && existing.as_ref().and_then(|x| x.get(b"NM").and_then(|n| doc.resolve(n).as_string().map(|s| s.to_text()))) == nm; + if same && existing.as_ref().is_some_and(|x| import_protected(doc, x)) { + clash = true; + kept.push(a); + } else if same { + removed.extend(a.as_ref()); + } else { + kept.push(a); + } + } + if clash { + d.remove(b"NM"); + } + // The replaced note's pop-up goes with it; otherwise it stays on the page pointing at a note that is no longer there. + kept.retain(|a| { + let Some(x) = a.as_ref().map(|r| doc.get(r)).and_then(|o| o.as_dict().cloned()) else { return true }; + x.name(b"Subtype") != Some(b"Popup".as_slice()) || x.reference(b"Parent").is_none_or(|p| !removed.contains(&p)) + }); + // Keep the replaced comment's identity: replies and review statuses not included in a + // partial import still point at it through /IRT. Allocating a new object would detach + // those comments and leave their parent outside the page's annotation list. + let r = match removed.first().copied() { + Some(r) => { + doc.set(r, Object::Dict(d)); + r + } + None => doc.add(Object::Dict(d)), + }; + // Our own appearance where we can draw one; others keep none (viewers draw a default). + let _ = pdfcraft_annot::set_appearance(doc, r); + kept.push(Object::Ref(r)); + doc.update_dict(page_ref, |p| p.set(b"Annots".to_vec(), Object::Array(kept)))?; + Ok(r) +} + +/// A pop-up window for the comment `parent`, on its page. +pub(crate) fn add_popup(doc: &mut Document, page: ObjRef, parent: ObjRef, rect: &[f64], open: bool) -> Result<(), DataError> { + let mut p = Dict::new(); + p.set(b"Type".to_vec(), Object::name("Annot")); + p.set(b"Subtype".to_vec(), Object::name("Popup")); + p.set(b"Rect".to_vec(), arr(rect)); + p.set(b"Parent".to_vec(), Object::Ref(parent)); + p.set(b"Open".to_vec(), Object::Bool(open)); + let pr = doc.add(Object::Dict(p)); + doc.update_dict(parent, |d| d.set(b"Popup".to_vec(), Object::Ref(pr)))?; + doc.update_dict(page, |pd| { + if let Some(Object::Array(a)) = pd.get_mut(b"Annots") { + a.push(Object::Ref(pr)); + } + })?; + Ok(()) +} + +pub(crate) fn import_xfdf(doc: &mut Document, text: &str) -> Result { + check_form_xml_depth(text)?; + let xml = parse_form_xml(text).map_err(|e| DataError::Malformed(e.to_string()))?; + let root = xml.root_element(); + if root.tag_name().name() != "xfdf" { + return Err(DataError::UnknownFormat); + } + let mut report = Report::default(); + let pages: Vec = pdfcraft_model::pages(doc).iter().map(|p| p.obj).collect(); + // Comments: parents first, then replies (whose /IRT needs the parent's reference). + let mut by_name: HashMap = HashMap::new(); + let mut later: HashMap = HashMap::new(); + if let Some(annots) = root.children().find(|c| c.has_tag_name("annots")) { + for node in annots.children().filter(|c| c.is_element()) { + let tag = node.tag_name().name().to_ascii_lowercase(); + let Some((subtype, _)) = SUBTYPES.iter().find(|(_, t)| *t == tag) else { continue }; + let Some(page) = node.attribute("page").and_then(|p| p.parse::().ok()).and_then(|p| pages.get(p).copied()) else { continue }; + let Some(d) = annot_from_xml(node, subtype, page) else { continue }; + let r = place(doc, page, d)?; + // A later same-name record replaces this comment, including its reply relation. + later.remove(&r); + if let Some(nm) = node.attribute("name") { + by_name.insert(nm.to_string(), r); + } + if let Some(irt) = node.attribute("inreplyto") { + later.insert(r, irt.to_string()); + } + // A pop-up window. + if let Some(pop) = node.children().find(|c| c.has_tag_name("popup")) + && let Some(rect) = pop.attribute("rect").map(csv).filter(|r| r.len() == 4) + { + add_popup(doc, page, r, &rect, pop.attribute("open") == Some("yes"))?; + } + report.comments += 1; + } + } + for (r, irt) in later { + if let Some(parent) = by_name.get(&irt).copied().or_else(|| find_by_name(doc, &irt)) { + doc.update_dict(r, |d| d.set(b"IRT".to_vec(), Object::Ref(parent)))?; + } + } + // Fields. + if let Some(fields) = root.children().find(|c| c.has_tag_name("fields")) { + let mut values = Vec::new(); + walk_xfdf(fields, "", &mut values, 0)?; + apply_values(doc, &values, &mut report); + } + Ok(report) +} + +pub(crate) fn find_by_name(doc: &Document, nm: &str) -> Option { + for p in pdfcraft_model::pages(doc) { + for a in p.dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default() { + if let Some(r) = a.as_ref() + && doc.get(r).as_dict().and_then(|d| text_of(doc, d, b"NM")).as_deref() == Some(nm) + { + return Some(r); + } + } + } + None +} diff --git a/docs/egui-037-migration-plan.md b/docs/egui-037-migration-plan.md index 2871a57f7c..5866330f28 100644 --- a/docs/egui-037-migration-plan.md +++ b/docs/egui-037-migration-plan.md @@ -2,7 +2,7 @@ The local integration includes main `d764a8ebca2dc289841b34ea3048f613a9869bbb` and pins craft-ui, craft-quit and craft-print to published `42e30cd5b3f4b114270dd053fccbc33bedc47f68`. The egui, winit and independent craft-docpath pins are unchanged. Its `Cargo.lock` SHA-256 is `3e0c10576c8584eb1f45a27908177e7c65bb83f8e855bf3a5429edd4e7268c80`. -Main replaced the floating docking workspace with fixed shared side panes in `crates/ui-egui/src/side_panels.rs`. `PaneHost` owns the Tools and inspector columns; `Rail` owns the fixed navigation rail and footer. The integration retains the 0.37 ID, text alignment, capture, IME and accessibility adaptations. The former `docking.rs`, `rail-nav` panel and read-mode inspector panel no longer exist. Floating windows, tab groups and drag/redock operations from the earlier design are not capabilities of this integrated UI. Their historical acceptance items below do not establish current behavior. +Main replaced the floating docking workspace with fixed shared side panes in `crates/ui-egui/src/panels/side_panels.rs`. `PaneHost` owns the Tools and inspector columns; `Rail` owns the fixed navigation rail and footer. The integration retains the 0.37 ID, text alignment, capture, IME and accessibility adaptations. The former `docking.rs`, `rail-nav` panel and read-mode inspector panel no longer exist. Floating windows, tab groups and drag/redock operations from the earlier design are not capabilities of this integrated UI. Their historical acceptance items below do not establish current behavior. Focused scroll, zoom and RTL palette regressions passed. Removing the production fixes while retaining the tests reproduced double scrolling, double zooming and failed reordered-word RTL search; both source files were then restored byte-for-byte. Independent source review accepted the integration. Full combined-tree CI and the separate web WASM check passed with required external oracles: 5,190 passing test executions, zero failures and 22 ignored across 89 summaries. The first attempt stopped at the storage reservation guard; the successful rerun used an increased reservation. The 5,190 result comes from managed run `egui037-pdf-combined-ci-20261025-1340` and qualifies the integration of main `d764a8ebca2dc289841b34ea3048f613a9869bbb` with `Cargo.lock` SHA-256 `3e0c10576c8584eb1f45a27908177e7c65bb83f8e855bf3a5429edd4e7268c80`, published as `fbfad2039c650a02b39ceee5c3f0e4d6fd4b3f74`. The older 5,183-pass result covered commit `9032aeb35f7abe7777f66d4b835fd461a865c516`, integrating main `71b47b51`; it is a separate checkpoint and is not evidence for the current tree. Both runs recorded 22 ignored executions across 89 summaries. @@ -165,7 +165,7 @@ Docking is `crates/ui-egui/src/docking.rs`; `DockArea::new(egui::Id::unique("pdf ### 2.7 Window frame, decorations and restoration -`apps/pdfcraft/src/main.rs` owns native options, app creation and GPU setup. `apps/pdfcraft/src/decorations.rs` decides whether GNOME Wayland leaves the frame to the app. `crates/ui-egui/src/window_controls.rs` draws the app frame and resize edges, and `window_state.rs` repairs a 64×64 restored window. +`apps/pdfcraft/src/main.rs` owns native options, app creation and GPU setup. `apps/pdfcraft/src/decorations.rs` decides whether GNOME Wayland leaves the frame to the app. `crates/ui-egui/src/platform/window_controls.rs` draws the app frame and resize edges, and `window_state.rs` repairs a 64×64 restored window. `craft-window` owns `Frame`, `Layout` and `Button`; `window_controls.rs` currently has distinct duplicate types and its own GNOME `button-layout` parser. The migration makes `window_controls` re-export `craft_window::{Button, Layout}` and removes the duplicate parser. `decorations::app_drawn` uses `craft_window::Choice::probe(Prefix::new("PDFCRAFT"))`; `PDFCRAFT_TITLEBAR` accepts `app`, `system` and `desktop` (the last two force the desktop frame), matching craft-window. PdfCraft still applies `with_decorations(false)` only when it will draw its own header bar, and the runtime “Use system title bar” preference still toggles `ViewportCommand::Decorations`; craft-window decides the startup frame, not the runtime preference. The probe is called once at startup and never from `App::ui`. diff --git a/docs/form-performance.md b/docs/form-performance.md index 1ef72dfc6d..637fd94986 100644 --- a/docs/form-performance.md +++ b/docs/form-performance.md @@ -6,7 +6,7 @@ not fresh measurements on upstream main. No private document is needed for the b ## Architecture -- `crates/ui-egui/src/forms_ui.rs` queues `Edit::SetFieldValue` for a text commit or checkbox +- `crates/ui-egui/src/tools/forms_ui.rs` queues `Edit::SetFieldValue` for a text commit or checkbox toggle. `crates/automation/src/forms.rs::form_fill` uses the same edit API. - `crates/engine/src/session.rs::Session::apply` runs edits on a copy, preserving rollback and undo. `run_edit` (`crates/engine/src/edit.rs`) calls `pdfcraft_forms::set_value_with` with an diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md index fcd0e8ac0d..da2b8d92b1 100644 --- a/docs/hardware-parity.md +++ b/docs/hardware-parity.md @@ -17,7 +17,7 @@ printing are counted in their feature areas). **Platforms ≈ 70% ready, 15–30 | Multi-threaded rendering | yes | yes (render pool, tiles, watchdog) | yes | yes | single thread | `view.render-priority` | | HiDPI / Retina | yes | yes | yes, per-monitor DPI v2 (#324, #725) | yes | yes | text blurry at 1080p/1440p (#730) | | Multiple monitors | yes, multiple windows | one window | one window; surfaces fit the GPU limit (#577) | one window | n/a | `view.multiple-windows` planned | -| Printers | all, PostScript, printer properties | CUPS via craft-print | Windows PDF raster spooler via craft-print (#756, PR #847) | CUPS via craft-print | the browser's print dialog (every printer the system knows): sheets drawn by PdfCraft at 150–300 dpi; copies, two-sided and colour set there | `crates/print/README.md`, `crates/ui-egui/src/web_print.rs` | +| Printers | all, PostScript, printer properties | CUPS via craft-print | Windows PDF raster spooler via craft-print (#756, PR #847) | CUPS via craft-print | the browser's print dialog (every printer the system knows): sheets drawn by PdfCraft at 150–300 dpi; copies, two-sided and colour set there | `crates/print/README.md`, `crates/ui-egui/src/workflows/web_print.rs` | | Scanners (TWAIN, WIA, ICA) | yes | none | none | none (SANE) | n/a | `ocr.scanner-acquire` planned | | Smart cards and USB tokens | yes (PKCS #11, CryptoTokenKit, CNG) | Keychain identities (software keys) | CNG store keys, with PIN prompts for cards (#742) | none (PKCS #11 planned) | none | `sign.pkcs11` planned | | Pen and touch | Touch mode, pen ink, Windows Ink | trackpad pinch zoom; ink from pointer, no pressure | ink from pointer, no pressure or touch mode | as macOS | pointer | touchpad scrolling 1:1 (#759, PR #868) | diff --git a/docs/localization.md b/docs/localization.md index e2ce23ef54..e73a149d1d 100644 --- a/docs/localization.md +++ b/docs/localization.md @@ -87,7 +87,7 @@ Error details that come from the operating system, and faults rather than mistak ## Adding a language 1. Copy the header of `ja.tsv` into `xx.tsv` and translate entries. -2. Add one row to `LANGUAGES` in `crates/ui-egui/src/i18n/mod.rs`: code, native name, catalog and plural rule. +2. Add one row to `LANGUAGES` in `crates/ui-egui/src/i18n.rs`: code, native name, catalog and plural rule. 3. Add an `ATTRIBUTION.toml` entry for the catalog (`kind = "translation"`, no `sha256`), then run `cargo xtask assets --write` (it regenerates `ATTRIBUTION.md` only) and `cargo xtask assets`. The Preferences dropdown, the system-language match, the catalog tests (format, duplicates, placeholders, plural forms, command ids) and the translation gaps report then pick it up. diff --git a/docs/releasing.md b/docs/releasing.md index 496ef4a672..72206e16f1 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -69,13 +69,20 @@ pinned in `release.yml` and builds with `CRAFT_FONTS_DIR` and `CRAFT_FONTS_REQUI embed its Japanese fonts and fail rather than ship without them (`AGENTS.md` §1.4). Bump the pin deliberately. -**OCR models:** every desktop package ships the Scan & OCR models (#103). The packaging scripts call -`stage_models` (`packaging/env.sh`; `package.ps1` on Windows), which runs `cargo xtask models` to -fetch every `kind = "model"` file in `ATTRIBUTION.toml` (verified by SHA-256) with its licence text -and an `ATTRIBUTION.txt`, straight into the package, so packaging needs the network. They go where -`pdfcraft_ocr::Models::dirs_beside_exe` looks: `models\` beside `pdfcraft.exe` (MSI, portable zip), -`PdfCraft.app/Contents/Resources/models` (macOS), and `share/pdfcraft/models` beside `bin/` -(deb, rpm, tar.gz, AppImage, Flatpak, FreeBSD). The web build doesn't include them yet. +**OCR models:** desktop packaging stages the Scan & OCR models (#103). +The packaging scripts call `stage_models` (`packaging/env.sh`; `package.ps1` on Windows), which runs `cargo xtask models` to fetch every `kind = "model"` file in `ATTRIBUTION.toml` and verify its SHA-256. +The current detection and recognition files are `text-detection.onnx` and `text-recognition.onnx`. +Each model ships with its licence text and the directory's `ATTRIBUTION.txt`, so packaging needs the network. +Upstream announced the legacy RTEN download URLs' brownout in [ocrs #282](https://github.com/robertknight/ocrs/issues/282) on 2026-10-27 and directs consumers to the ONNX models. + +Runtime discovery prefers a complete ONNX detection/recognition pair within each directory, then a complete legacy `text-detection.rten` / `text-recognition.rten` pair. +An incomplete ONNX update can therefore use an intact RTEN pair in the same directory. +Discovery never combines model formats or files from different directories. +Directory precedence stays the same: `$PDFCRAFT_MODELS`, the release-package locations beside the executable, then the source tree's `assets/models/`. +The first directory with a complete supported pair wins, even if an earlier legacy pair precedes a later ONNX pair. + +The release-package locations are `models\` beside `pdfcraft.exe` (MSI, portable zip), `PdfCraft.app/Contents/Resources/models` (macOS), and `share/pdfcraft/models` beside `bin/` (deb, rpm, tar.gz, AppImage, Flatpak, FreeBSD). +The web build does not include these models, and OCR remains limited to the Latin alphabet without accents. ### macOS @@ -111,7 +118,9 @@ neither the MSI nor the portable zip needs the Visual C++ redistributable. - Before packaging, it reads both executables' PE headers: the machine type must match `-Arch`, and `pdfcraft.exe` must be a GUI-subsystem program (no console window, #57) while `pdfcraft-cli.exe` stays a console program. -- `pdfcraft.wxs` (WiX v5) installs per machine into Program Files with a Start Menu shortcut, a +- `pdfcraft.wxs` (WiX v5) is a dual-scope package (per-user by default into + `%LOCALAPPDATA%\Programs\ArtCraft\PdfCraft`, per-machine into `Program Files\ArtCraft\PdfCraft` (`Program Files (x86)` for the 32-bit build), + see `packaging/windows/INSTALLATION.md`) with a Start Menu shortcut, a desktop shortcut (on by default) and an App Paths entry. Both shortcuts are plain links to `pdfcraft.exe`, not advertised MSI shortcuts (#107, #143). The MSI version is the numeric `X.Y.Z` (MSI has no pre-release field), and same-version upgrades are allowed so release candidates replace @@ -148,7 +157,7 @@ neither the MSI nor the portable zip needs the Visual C++ redistributable. - The portable zip holds both executables, the README, the licences, the OFL licence of each embedded craft-fonts family, and `portable.txt`. That marker beside `pdfcraft.exe` keeps the settings, logs, crash recovery and new digital IDs in `PdfCraftData\` next to the exe instead of - `%APPDATA%` and `%LOCALAPPDATA%` (`crates/ui-egui/src/portable.rs`). The MSI carries the same + `%APPDATA%` and `%LOCALAPPDATA%` (`crates/ui-egui/src/platform/portable.rs`). The MSI carries the same OFL notices; `-SkipBuild` requires `CRAFT_FONTS_DIR` so they can be staged, or `-BuiltWithoutCraftFonts` for binaries built without that input, and `test-msi.ps1` checks that the compiled MSI has OFL notice rows when expected (and none for a no-font build). diff --git a/docs/roadmap.md b/docs/roadmap.md index 40fa711c24..25205e92c1 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,5 +1,6 @@ # PdfCraft roadmap: milestones and what's next +> **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) @@ -193,7 +194,7 @@ total matches the parity estimate. | M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Missing: editing existing text and images in place (the longest pole), image/PDF watermarks | | M8 | Security + redaction | 100–180 | 68% | 15–30 | Done: opening protected documents, permissions, Protect Using Password, Remove security; redaction (mark text/areas/pages, Search & Redact with patterns, apply removing glyphs, image pixels, vectors, XObject content, annotations and fields, verification, full rewrite on save); Remove hidden information and Sanitize.
Done since: redaction codes (U.S. FOIA and Privacy Act sets as overlay text, in Redaction properties and `redact_mark`). Redaction cleans indirect and form structure references, preserving surviving form tags. Redaction retires inherited resource dictionaries, so a parent `/Pages` can't keep a removed XObject reachable; unredacted pages are left untouched. Image redaction removes alternate images and unsupported masks only on affected images. Changed marked-content spans lose replacement descriptions; untouched spans keep theirs. Hidden-text cleanup retires unused named descriptions across inherited/shared page and form resources (64 focused tests and scoped Rust 1.96 Clippy/wasm checks pass; publication CI pending). The redaction audit counts a filled cover that hides half of any one word of a long line (#2662), applied or not. Phone-pattern redaction also matches labelled Italian geographic numbers with `+39` and a slash after the area code (#3205 follow-up); unlabelled international slash numbers remain unsupported. Missing: certificate security, custom redaction code sets and pattern locales, DCT re-encoding | | M9 | Signatures (PAdES, validation) | 160–280 | 60% | 40–70 | Done: new `sign` crate (DER, X.509, CMS, PKCS #12 on RustCrypto; aws-lc-rs for RSA private keys); PAdES B-B signing (visible, invisible, existing fields, certification with DocMDP); validation with trust store and changes-after-signing classification; self-signed digital IDs; Signatures panel, message bar, sign dialogs; agent tools. Checked with pdfsig and OpenSSL.
Done since: macOS Keychain and Windows Current User Personal store signing (software-backed CNG keys), certificate viewer, signed documents protected from rewrites, page validation overlays separate from original signature artwork (baked Not Verified captions remain; #79 open), snapshot tabs that reuse instead of multiplying. Missing: timestamps (B-T), LTV (DSS, OCSP, CRL), FieldMDP, smart cards, PKCS #11 | -| M10 | OCR, create, export, print | 200–350 | 33% | 120–215 | Done: create from blank/text/PNG/JPEG/TIFF (multi-page)/GIF/BMP; export PNG/JPEG/TIFF and text; Print (Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, the CUPS and Windows spoolers, print-ready PDF).
Done since: embedded/72/custom DPI choices for image imports; export all images; OCR (searchable image for pages, ranges and multiple files); single-sided cut-and-stack imposition with cut marks, through Print and doc_print; architectural/ANSI/large ISO sheet presets, Match page at 100% and PDF-scale preview (#599 follow-up); Create PDF from multiple files (PDFs, images and text, edited as pages in the grid); OCR can also read pictures on pages that have text, without adding that text twice (#1028). Done since: Word invoice export uses borderless inferred tables, floating positioned text/artwork only where text overlaps an image or is rotated, and per-page sizes, covered by synthetic invoice automation regressions (#773); printing on the web through the browser's print dialog, sheets drawn by PdfCraft (gap 4). Lossless image export applies per-component Decode, including Indexed mapping (#1201 partial). Existing 16-bit precision and color-management limits remain. #3532 partial: vector printing retains existing appearances with absent Subtype through a print-owned Form copy; source artwork stays unchanged. #3120: Office export reads form XObjects 50 levels deep, as the renderer does, and up to 50 000 a page; pages past that are listed in `truncated_pages`. Word table cells use the grid columns they span, including cells after a merged cell. Done since: text in a table slot covered by another cell's span remains as paragraph text in Word, HTML, RTF and PowerPoint. Missing: OCR languages beyond Latin, editable-text OCR output, full Office layout fidelity/import. Done since: dotted-leader contents entries stay in separate complete paragraphs (#1757) #2156 candidate: last selected print paper persists across sessions; other print preferences unchanged. Image export applies soft-mask Decode opacity intervals before resizing, including indirect entries and inverted ranges. Vector printing gives annotation appearances unoccupied XObject names so existing page artwork survives. | +| M10 | OCR, create, export, print | 200–350 | 33% | 120–215 | Done: create from blank/text/PNG/JPEG/TIFF (multi-page)/GIF/BMP; export PNG/JPEG/TIFF and text; Print (Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, the CUPS and Windows spoolers, print-ready PDF).
Done since: embedded/72/custom DPI choices for image imports; export all images; OCR (searchable image for pages, ranges and multiple files); single-sided cut-and-stack imposition with cut marks, through Print and doc_print; architectural/ANSI/large ISO sheet presets, Match page at 100% and PDF-scale preview (#599 follow-up); Create PDF from multiple files (PDFs, images and text, edited as pages in the grid); OCR can also read pictures on pages that have text, without adding that text twice (#1028). Model staging fetches SHA-256-verified ONNX detection/recognition files after the upstream RTEN URL brownout; runtime discovery prefers complete ONNX pairs per directory, then complete legacy RTEN pairs, without mixing formats or changing directory precedence. Done since: Word invoice export uses borderless inferred tables, floating positioned text/artwork only where text overlaps an image or is rotated, and per-page sizes, covered by synthetic invoice automation regressions (#773); printing on the web through the browser's print dialog, sheets drawn by PdfCraft (gap 4). Lossless image export applies per-component Decode, including Indexed mapping (#1201 partial). Existing 16-bit precision and color-management limits remain. #3532 partial: vector printing retains existing appearances with absent Subtype through a print-owned Form copy; source artwork stays unchanged. #3120: Office export reads form XObjects 50 levels deep, as the renderer does, and up to 50 000 a page; pages past that are listed in `truncated_pages`. Word table cells use the grid columns they span, including cells after a merged cell. Done since: text in a table slot covered by another cell's span remains as paragraph text in Word, HTML, RTF and PowerPoint. Missing: OCR languages beyond Latin, editable-text OCR output, full Office layout fidelity/import. Done since: dotted-leader contents entries stay in separate complete paragraphs (#1757) #2156 candidate: last selected print paper persists across sessions; other print preferences unchanged. Image export applies soft-mask Decode opacity intervals before resizing, including indirect entries and inverted ranges. Vector printing gives annotation appearances unoccupied XObject names so existing page artwork survives. | | M11 | Optimize, preflight, PDF/A/X/UA, print production | 200–350 | 18% | 80–140 | Done: new `optimize` crate: Reduce File Size and the PDF Optimizer (images measured where drawn, bicubic downsampling, JPEG/ZIP recompression only when smaller, discard objects and user data, Flate clean-up, resource merging, object streams). Target-version compatibility is partial: newer content is not detected or converted. Content stream boundaries preserve comments, split operands and graphics state when image fragments merge. Image downsampling includes page-local `/UserUnit` scaling when measuring physical resolution. Done since: reject invalid image and soft-mask dimensions with checked sizing (#2178). Missing: fonts and transparency panels, space audit, preflight, PDF/A/X/UA | | M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Locatable figure, annotation and form findings highlight their page bounds. Character encoding accepts Identity-H/V with Adobe CJK collections and predefined CJK CMaps independently (#3673); per-glyph validation and embedded-font extraction remain separate.
Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export; a bounded, text-only auto-tag proposal tool for one page. Marking a figure decorative rewrites only that stream (#1324). An XFA radio item whose value contains a space is written back unchanged (#1319). Done since: #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. Done since: #2572: Every repeated page-master button resolves its own field to the shared XFA click script, including print buttons. Done since: #2570: Dynamic XFA rich-text fields carry exData/maxLength into AcroForm MaxLen for reported and enforced Unicode character limits, retained after save/reopen. Done since: #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. Missing: writing proposed tags and parent tree, field autotag, visual Tags/Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA
Reading colours: pending page backgrounds follow the configured colour (#2059). | | M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers
Done since: 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. `text_find` also caps the per-page match ranges it materializes at the remaining result limit plus one continuation match. CLI robustness scans visit each directory once through overlapping roots and symlink aliases. | @@ -254,6 +255,7 @@ Estimates unchanged. | Date | Change | Summary | |---|---|---| +| 2026-10-27 | minor | M10: OCR stages SHA-256-verified ONNX models after the upstream RTEN URL brownout; discovery prefers a complete ONNX pair per directory, falls back to a complete RTEN pair, and never mixes formats or directories; language/web limits and estimates unchanged. | | 2026-10-24 | minor | M3, #3970 follow-up: the button release given back after a window move or resize goes before the pointer leaving, so hover doesn't stick. Estimates unchanged. | | 2026-10-23 | minor | M8, #3205 follow-up: phone-pattern redaction includes the +39 country code on labelled Italian geographic numbers with a slash; dates, protocol identifiers and whole-run guards remain. Estimates unchanged. | | 2026-10-23 | minor | M10: Word table cells use the grid columns they span, including cells after a merged cell; synthetic export regression and CLI reproduction. Estimates unchanged. | diff --git a/packaging/windows/INSTALLATION.md b/packaging/windows/INSTALLATION.md new file mode 100644 index 0000000000..982a5b4202 --- /dev/null +++ b/packaging/windows/INSTALLATION.md @@ -0,0 +1,35 @@ +# Windows installation policy checks + +New installations default to the current account under `Programs\ArtCraft\`. +The first setup page offers installation for everyone. Scope is selected before duplicate +checks and directory costing. An update uses the existing context's remembered exact folder; +`INSTALLFOLDER` supplied explicitly is never replaced. It is a secure MSI property so the +chosen folder reaches the elevated installer. Silent machine updates pass `ALLUSERS=1`. +A cross-context copy is refused; changing context means uninstalling the old copy first. + +App registration follows the selected context. Existing optional shortcuts and file-handler +choices stay app-owned. No new cleanup operation or model/settings migration is introduced. +Packaged resources remain package-owned; separately downloaded shared models are not caches +and are outside this installer's ownership. + +## Repeatable metadata tests + +On Windows with WiX 5.0.2 and its matching UI/Util extensions: + +```powershell +./packaging/windows/test-install-policy.ps1 -OutputDirectory ./target/install-policy-tests +``` + +The script builds x64, x86 and arm64 packages with deliberately non-executable payloads. It +uses the actual package source and compiled database. A safe Windows Installer session checks +24 scope/registration/maintenance cases, remembered and explicit custom folders, sequencing, +upgrade identity and the original registration inventory recorded in `install-policy.json`. +The release packaging script runs the compiled checker before signing. Packaging CI runs the +small fixture builds without compiling the Rust application. + +Never install or distribute the `NOT-INSTALLABLE.msi` fixtures as an app. Passing these checks +does not prove real installation, elevation, legacy-version upgrades, registry writes, optional +shortcut/menu behaviour, cancellation, rollback or uninstall. Those still require the real +payload in an authorised clean Windows test environment. Cross-architecture metadata is not +execution evidence on those architectures. Existing open installer/association PRs must be +reconciled before publication. Nothing here changes macOS/Linux native packaging conventions. diff --git a/packaging/windows/check-install-policy.ps1 b/packaging/windows/check-install-policy.ps1 new file mode 100644 index 0000000000..cc9416e9c9 --- /dev/null +++ b/packaging/windows/check-install-policy.ps1 @@ -0,0 +1,296 @@ +<# +.SYNOPSIS + Check the compiled installation policy without installing any product. +.DESCRIPTION + Uses Windows Installer's safe session (OpenPackage option 1). Only property-setting custom + actions are exercised. No AppSearch, install, repair, removal or file-writing action runs. +#> +param([Parameter(Mandatory = $true)] [string] $MsiPath) +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$policy = Get-Content -Raw -LiteralPath (Join-Path $PSScriptRoot 'install-policy.json') | ConvertFrom-Json +function Read-Rows($Database, [string] $Sql, [int] $Columns) { + $view = $Database.OpenView($Sql) + try { + [void]$view.Execute() + while ($null -ne ($record = $view.Fetch())) { + try { + $values = @(for ($i = 1; $i -le $Columns; $i++) { + $record.GetType().InvokeMember('StringData', 'GetProperty', $null, $record, @($i)) + }) + ,$values + } finally { [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($record) } + } + } finally { + [void]$view.Close() + [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($view) + } +} +function Set-SessionProperty($Session, [string] $Name, [string] $Value) { + [void]$Session.GetType().InvokeMember('Property', 'SetProperty', $null, $Session, @($Name, $Value)) +} +function Get-SessionProperty($Session, [string] $Name) { + $Session.GetType().InvokeMember('Property', 'GetProperty', $null, $Session, @($Name)) +} +function Get-PackageTemplate($Database) { + # Summary information property 7 is the platform: "Intel;1033", "x64;1033" or "Arm64;1033". + $summary = $Database.GetType().InvokeMember('SummaryInformation', 'GetProperty', $null, $Database, @(0)) + try { [string]$summary.GetType().InvokeMember('Property', 'GetProperty', $null, $summary, @(7)) } + finally { [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($summary) } +} +$installer = $null; $database = $null; $session = $null +try { + $installer = New-Object -ComObject WindowsInstaller.Installer + $file = (Resolve-Path -LiteralPath $MsiPath).Path + $database = $installer.OpenDatabase($file, 0) + $session = $installer.OpenPackage($file, 1) + $properties = @{} + foreach ($row in (Read-Rows $database 'SELECT `Property`, `Value` FROM `Property`' 2)) { $properties[$row[0]] = $row[1] } + $secureProperties = @($properties['SecureCustomProperties'] -split ';') + if ('INSTALLFOLDER' -notin $secureProperties) { + throw 'The selected installation folder must survive elevation (secure INSTALLFOLDER).' + } + if ($policy.mode -eq 'perUser') { + if ($properties.ContainsKey('ALLUSERS')) { throw 'Launcher must remain per-user; machine self-update is not part of this change.' } + if ($properties['UpgradeCode'].Trim('{}') -ne $policy.upgradeCode) { throw 'Launcher upgrade identity changed.' } + $dirs = @{} + foreach ($row in (Read-Rows $database 'SELECT `Directory`, `Directory_Parent`, `DefaultDir` FROM `Directory`' 3)) { $dirs[$row[0]] = @($row[1],$row[2]) } + if ($dirs['INSTALLFOLDER'][0] -ne 'CraftSuiteFolder' -or $dirs['CraftSuiteFolder'][1] -ne 'ArtCraft' -or + $dirs['CraftSuiteFolder'][0] -ne 'LocalProgramsFolder' -or $dirs['LocalProgramsFolder'][0] -ne 'LocalAppDataFolder') { + throw 'Launcher new-install folder must be under the per-user ArtCraft suite root.' + } + foreach ($explicit in @('', 'F:\Chosen exact launcher')) { + Set-SessionProperty $session 'PREVIOUSINSTALLFOLDER' 'E:\Existing exact launcher' + Set-SessionProperty $session 'INSTALLFOLDER' $explicit + $row = @(Read-Rows $database 'SELECT `Condition` FROM `InstallUISequence` WHERE `Action` = ''CraftRestoreLauncherFolder''' 1)[0] + if ($session.EvaluateCondition($row[0]) -eq 1) { + if ($session.DoAction('CraftRestoreLauncherFolder') -ne 1) { throw 'Launcher restore action failed.' } + } + $expected = if ($explicit) { $explicit } else { 'E:\Existing exact launcher' } + if ((Get-SessionProperty $session 'INSTALLFOLDER') -ne $expected) { throw 'Launcher custom or existing path was lost.' } + } + $rows = @(Read-Rows $database 'SELECT `Root`, `Key`, `Name`, `Value` FROM `Registry`' 4) + if (@($rows | Where-Object { $_[0] -ne '1' }).Count) { throw 'Launcher registration must stay under the current account.' } + foreach ($entry in $policy.registry) { + if (@($rows | Where-Object { $_[0] -eq $entry[0] -and $_[1] -eq $entry[1] -and $_[2] -eq $entry[2] -and $_[3] -eq $entry[3] }).Count -ne 1) { + throw 'Launcher registration or shortcut preference changed unexpectedly.' + } + } + Write-Output 'PASS ArtCraft Launcher: per-user scope, suite folder, remembered/custom paths and existing registration. No installation performed.' + return + } + if ($properties['ALLUSERS'] -ne '2' -or $properties['MSIINSTALLPERUSER'] -ne '1') { + throw 'Policy regression: new packages must default to the per-user dual-scope context.' + } + if ($properties['UpgradeCode'].Trim('{}') -ne $policy.upgradeCode) { throw 'The existing upgrade identity must not change.' } + $directories = @{} + foreach ($row in (Read-Rows $database 'SELECT `Directory`, `Directory_Parent`, `DefaultDir` FROM `Directory`' 3)) { + $directories[$row[0]] = @($row[1], $row[2]) + } + if ($directories['INSTALLFOLDER'][0] -ne 'CraftSuiteFolder' -or + $directories['CraftSuiteFolder'][1] -ne 'ArtCraft') { throw 'Expected the app under the ArtCraft suite folder.' } + $programs = $directories['CraftSuiteFolder'][0] + if ($programs -eq 'ProgramFiles6432Folder') { $programs = $directories[$programs][0] } + if ($programs -notin 'ProgramFilesFolder', 'ProgramFiles64Folder') { + throw 'Use Windows Installer native folder redirection, not a hard-coded profile path.' + } + $guards = @(Read-Rows $database 'SELECT `Condition`, `Description` FROM `LaunchCondition`' 2 | + Where-Object { $_[0] -match 'CRAFT_(USER|MACHINE)_DIR' }) + if ($guards.Count -ne 2) { throw 'Expected both duplicate-installation guards.' } + $cases = 0 + foreach ($scope in @('', '2', '1')) { + foreach ($user in @('', 'D:\Existing user app')) { + foreach ($machine in @('', 'E:\Existing machine app')) { + foreach ($installed in @('', '1')) { + Set-SessionProperty $session 'ALLUSERS' $scope + Set-SessionProperty $session 'Installed' $installed + Set-SessionProperty $session 'CRAFT_USER_DIR' $user + Set-SessionProperty $session 'CRAFT_MACHINE_DIR' $machine + $allowed = $true + foreach ($guard in $guards) { + $value = $session.EvaluateCondition($guard[0]) + if ($value -notin 0, 1) { throw 'Invalid duplicate guard.' } + if ($value -eq 0) { $allowed = $false } + } + $opposite = if ($scope -eq '1') { $user } else { $machine } + $expected = [bool]$installed -or -not [bool]$opposite + if ($allowed -ne $expected) { throw "Scope regression: ALLUSERS='$scope', user=$([bool]$user), machine=$([bool]$machine), maintenance=$([bool]$installed)." } + $cases++ + } + } + } + } + # Execute the actual authored type-51 actions in a safe session, not copied resolver logic. + foreach ($scope in @('', '2', '1')) { + foreach ($explicit in @('', 'F:\Chosen exact app')) { + Set-SessionProperty $session 'ALLUSERS' $scope + Set-SessionProperty $session 'INSTALLFOLDER' $explicit + Set-SessionProperty $session 'CRAFT_USER_DIR' 'D:\Existing user app' + Set-SessionProperty $session 'CRAFT_MACHINE_DIR' 'E:\Existing machine app' + $action = if ($scope -eq '1') { 'CraftRestoreMachineFolder' } else { 'CraftRestoreUserFolder' } + # Evaluate the compiled condition, so the test exercises it instead of calling the action directly. + $restore = @(Read-Rows $database ('SELECT `Condition` FROM `InstallExecuteSequence` WHERE `Action` = ''{0}''' -f $action) 1)[0] + $selected = $session.EvaluateCondition($restore[0]) -eq 1 + if ($selected -eq [bool]$explicit) { throw "$action must run only when no folder was supplied (condition: $($restore[0]))." } + if ($selected -and $session.DoAction($action) -ne 1) { throw 'Remembered-folder property action failed.' } + $expected = if ($explicit) { $explicit } elseif ($scope -eq '1') { 'E:\Existing machine app' } else { 'D:\Existing user app' } + if ((Get-SessionProperty $session 'INSTALLFOLDER') -ne $expected) { throw 'Custom or remembered folder was not preserved.' } + } + } + $ui = @{} + foreach ($row in (Read-Rows $database 'SELECT `Action`, `Sequence` FROM `InstallUISequence`' 2)) { $ui[$row[0]] = [int]$row[1] } + if (-not ($ui['AppSearch'] -lt $ui['CraftPreferMachineScope'] -and + $ui['CraftPreferMachineScope'] -lt $ui['CraftScopeDlg'] -and + $ui['CraftScopeDlg'] -lt $ui['FindRelatedProducts'] -and + $ui['FindRelatedProducts'] -lt $ui['LaunchConditions'] -and + $ui['CraftScopeDlg'] -lt $ui['CraftRestoreUserFolder'] -and $ui['CraftRestoreUserFolder'] -lt $ui['CostInitialize'])) { + throw 'Scope must be chosen after AppSearch and before FindRelatedProducts, launch checks and costing.' + } + # Windows Installer resolves Program Files for the context it started in, before the wizard's scope choice. + # The wizard therefore sets the new-install folder itself, and only in the wizard. + $defaults = @(Read-Rows $database 'SELECT `Action`, `Sequence`, `Condition` FROM `InstallUISequence`' 3 | Where-Object { $_[0] -like 'CraftDefault*' }) + if ($defaults.Count -lt 2) { throw 'The wizard must set the new-install folder after the scope page; Program Files is resolved before the choice.' } + foreach ($row in $defaults) { + if (-not ($ui['CraftScopeDlg'] -lt [int]$row[1] -and [int]$row[1] -lt $ui['CostInitialize'])) { throw "$($row[0]) must run after the scope page and before costing." } + } + if (@(Read-Rows $database 'SELECT `Action` FROM `InstallExecuteSequence`' 1 | Where-Object { $_[0] -like 'CraftDefault*' }).Count) { + throw 'Silent installs must not use the wizard-only default folder actions.' + } + # The machine default comes from the environment of the package architecture, not from a folder name. + # x86 packages use Program Files (x86) on 64-bit Windows; x64 and Arm64 packages use the 64-bit root. + $machineRoot = if ((Get-PackageTemplate $database) -match '^Intel') { + if (Get-SessionProperty $session 'VersionNT64') { ${env:ProgramFiles(x86)} } else { $env:ProgramFiles } + } elseif ($env:ProgramW6432) { $env:ProgramW6432 } else { $env:ProgramFiles } + $machineFolder = (Join-Path $machineRoot "ArtCraft\$($policy.name)") + '\' + foreach ($scope in @('1', '')) { + Set-SessionProperty $session 'ALLUSERS' $scope + Set-SessionProperty $session 'INSTALLFOLDER' '' + Set-SessionProperty $session 'CRAFT_MACHINE_DIR' '' + Set-SessionProperty $session 'CRAFT_USER_DIR' '' + foreach ($row in @($defaults | Sort-Object { [int]$_[1] })) { + if ($session.EvaluateCondition($row[2]) -eq 1 -and $session.DoAction($row[0]) -ne 1) { throw "$($row[0]) failed." } + } + $folder = Get-SessionProperty $session 'INSTALLFOLDER' + $wanted = if ($scope -eq '1') { $machineFolder } else { (Join-Path $env:LOCALAPPDATA "Programs\ArtCraft\$($policy.name)") + '\' } + if ($folder -ine $wanted) { throw "Wizard default folder for ALLUSERS='$scope' is '$folder', expected '$wanted'." } + } + $execute = @{} + foreach ($row in (Read-Rows $database 'SELECT `Action`, `Sequence` FROM `InstallExecuteSequence`' 2)) { $execute[$row[0]] = [int]$row[1] } + foreach ($action in @('CraftRestoreUserFolder','CraftRestoreMachineFolder')) { + if (-not ($execute['AppSearch'] -lt $execute[$action] -and $execute[$action] -lt $execute['CostInitialize'])) { + throw 'Silent updates must restore the folder before directory costing.' + } + } + foreach ($resolver in @('CraftUFromLegacyExe','CraftMFromLegacyExe')) { + if (-not ($execute['AppSearch'] -lt $execute[$resolver] -and $execute[$resolver] -lt $execute['LaunchConditions'])) { + throw 'Both legacy folder resolvers must run after AppSearch and before the launch checks.' + } + } + # Switching the first-page choice must reset BOTH properties, including a back-and-forth change. + $events = @(Read-Rows $database 'SELECT `Event`, `Argument`, `Condition`, `Ordering` FROM `ControlEvent` WHERE `Dialog_` = ''CraftScopeDlg'' AND `Control_` = ''Next'' ORDER BY `Ordering`' 4) + # Model the full-UI upgrade entry with an existing machine-wide copy. Run + # the actual preference action, then the compiled Next events before discovery. + Set-SessionProperty $session 'ALLUSERS' '2' + Set-SessionProperty $session 'CRAFT_SCOPE' 'user' + Set-SessionProperty $session 'CRAFT_USER_DIR' '' + Set-SessionProperty $session 'CRAFT_MACHINE_DIR' 'E:\Existing machine app' + $preference = @(Read-Rows $database 'SELECT `Condition` FROM `InstallUISequence` WHERE `Action` = ''CraftPreferMachineScope''' 1)[0] + if ($session.EvaluateCondition($preference[0]) -ne 1 -or $session.DoAction('CraftPreferMachineScope') -ne 1 -or + (Get-SessionProperty $session 'CRAFT_SCOPE') -ne 'machine') { + throw 'Full-UI machine upgrades must initially select the existing machine context.' + } + foreach ($choice in @('machine','user','machine','user')) { + Set-SessionProperty $session 'CRAFT_SCOPE' $choice + foreach ($event in $events) { + if ($event[0] -notmatch '^\[([^\]]+)\]$') { continue } + $property = $Matches[1] + if ($session.EvaluateCondition($event[2]) -eq 1) { + Set-SessionProperty $session $property $(if ($event[1] -eq '{}') { '' } else { $event[1] }) + } + } + $allUsers = Get-SessionProperty $session 'ALLUSERS' + $perUser = Get-SessionProperty $session 'MSIINSTALLPERUSER' + if (($choice -eq 'machine' -and ($allUsers -ne '1' -or $perUser)) -or + ($choice -eq 'user' -and ($allUsers -or $perUser -ne '1'))) { + throw 'Scope selector does not reset the requested installation context.' + } + } + $actions = @(Read-Rows $database 'SELECT `Action`, `Source`, `Target`, `Type` FROM `CustomAction`' 4) + $savedOptions = @($actions | Where-Object { $_[0] -match '^CraftLoadOption[0-9]+M$' }) + foreach ($option in $savedOptions) { + if ([int]$option[3] -ne 51) { throw 'Saved-option test may run only property-setting actions.' } + $number = [regex]::Match($option[0], '[0-9]+').Value + foreach ($scope in @('2','1')) { + Set-SessionProperty $session 'ALLUSERS' $scope + Set-SessionProperty $session "CRAFT_OPTION_${number}_M" 'saved-machine-choice' + Set-SessionProperty $session "CRAFT_OPTION_${number}_U" 'saved-user-choice' + Set-SessionProperty $session $option[1] '' + $action = "CraftLoadOption$number" + $(if ($scope -eq '1') { 'M' } else { 'U' }) + if ($session.DoAction($action) -ne 1) { throw 'Saved-option property action failed.' } + $expected = if ($scope -eq '1') { 'saved-machine-choice' } else { 'saved-user-choice' } + if ((Get-SessionProperty $session $option[1]) -ne $expected) { throw 'Saved installer choices leaked across contexts.' } + } + } + # Follow the compiled control conditions, not the source's intended appearance. + $controls = @(Read-Rows $database 'SELECT `Dialog_`, `Control`, `Type`, `Attributes`, `Text` FROM `Control`' 5) + $conditions = @() + if (@(Read-Rows $database 'SELECT `Name` FROM `_Tables` WHERE `Name` = ''ControlCondition''' 1).Count) { + $conditions = @(Read-Rows $database 'SELECT `Dialog_`, `Control_`, `Action`, `Condition` FROM `ControlCondition`' 4) + } + function Test-ControlVisible($Control) { + $visible = ([int]$Control[3] -band 1) -ne 0 + foreach ($condition in $conditions) { + if ($condition[0] -eq $Control[0] -and $condition[1] -eq $Control[1] -and $session.EvaluateCondition($condition[3]) -eq 1) { + if ($condition[2] -eq 'Show') { $visible = $true } + if ($condition[2] -eq 'Hide') { $visible = $false } + } + } + return $visible + } + foreach ($button in @($controls | Where-Object { $_[0] -in 'InstallWelcome','InstallMaintenance','DesktopShortcutDlg' -and ([int]$_[3] -band 8388608) -ne 0 })) { + $plain = @($controls | Where-Object { $_[0] -eq $button[0] -and $_[1] -eq ($button[1] + 'User') }) + if ($plain.Count -ne 1 -or ([int]$plain[0][3] -band 8388608)) { throw 'Scope-aware buttons need an unshielded per-user counterpart.' } + foreach ($scope in @('', '2', '1')) { + Set-SessionProperty $session 'ALLUSERS' $scope + if ((Test-ControlVisible $button) -ne ($scope -eq '1') -or (Test-ControlVisible $plain[0]) -ne ($scope -ne '1')) { + throw 'The elevation shield must follow the selected scope.' + } + } + $dialog = $button[0]; $shieldId = $button[1]; $plainId = $plain[0][1] + $shieldEvents = @(Read-Rows $database "SELECT ``Event``, ``Argument``, ``Condition``, ``Ordering`` FROM ``ControlEvent`` WHERE ``Dialog_`` = '$dialog' AND ``Control_`` = '$shieldId' ORDER BY ``Ordering``" 4 | ForEach-Object { $_ -join '|' }) + $plainEvents = @(Read-Rows $database "SELECT ``Event``, ``Argument``, ``Condition``, ``Ordering`` FROM ``ControlEvent`` WHERE ``Dialog_`` = '$dialog' AND ``Control_`` = '$plainId' ORDER BY ``Ordering``" 4 | ForEach-Object { $_ -join '|' }) + if (($shieldEvents -join "`n") -ne ($plainEvents -join "`n")) { throw 'Per-user and machine buttons must perform the same operation.' } + } + foreach ($scope in @('', '2', '1')) { + Set-SessionProperty $session 'ALLUSERS' $scope + foreach ($text in @($controls | Where-Object { $_[4] -like '*for all users*' })) { + if ((Test-ControlVisible $text) -ne ($scope -eq '1')) { throw 'The welcome-page scope text must follow the selected scope.' } + } + } + if ($policy.id -in 'deckcraft','filmcraft','soundcraft') { + foreach ($route in @(@('WelcomeDlg','Next','InstallDirDlg'),@('InstallDirDlg','Back','WelcomeDlg'))) { + $dialog = $route[0]; $control = $route[1] + $routes = @(Read-Rows $database "SELECT ``Argument``, ``Condition`` FROM ``ControlEvent`` WHERE ``Dialog_`` = '$dialog' AND ``Control_`` = '$control' AND ``Event`` = 'NewDialog' ORDER BY ``Ordering``" 2) + Set-SessionProperty $session 'Installed' '' + $destination = '' + foreach ($event in $routes) { if ($session.EvaluateCondition($event[1]) -eq 1) { $destination = $event[0] } } + if ($destination -ne $route[2]) { throw 'Skipping the licence page must work in both navigation directions.' } + } + } + $registry = @(Read-Rows $database 'SELECT `Root`, `Key`, `Name`, `Value` FROM `Registry`' 4) + if (@($registry | Where-Object { $_[0] -in '1', '2' }).Count) { throw 'Registration must follow the selected context, not force HKCU or HKLM.' } + if (@($registry | Where-Object { $_[0] -eq '0' -and $_[1] -match '^Software\\Classes(\\|$)' }).Count) { + throw 'HKCR keys must be relative to Classes.' + } + foreach ($entry in $policy.registry) { + $found = @($registry | Where-Object { $_[0] -eq $entry[0] -and $_[1] -eq $entry[1] -and $_[2] -eq $entry[2] -and $_[3] -eq $entry[3] }) + if ($found.Count -ne 1) { throw "Lost or changed registration: $($entry[1]) / $($entry[2])" } + } + $installDir = @($registry | Where-Object { $_[0] -eq '-1' -and $_[1] -eq "Software\$($policy.name)" -and $_[2] -eq 'InstallDir' -and $_[3] -eq '[INSTALLFOLDER]' }) + if ($installDir.Count -ne 1) { throw 'A context-sensitive installation-folder record is required.' } + Write-Output "PASS $($policy.name): $cases scope cases, folder preservation, action sequencing and registry contract. No installation performed." +} finally { + foreach ($com in @($session, $database, $installer)) { + if ($null -ne $com) { [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($com) } + } +} diff --git a/packaging/windows/install-policy.json b/packaging/windows/install-policy.json new file mode 100644 index 0000000000..374bd4e5fe --- /dev/null +++ b/packaging/windows/install-policy.json @@ -0,0 +1,329 @@ +{ + "id": "pdfcraft", + "name": "PdfCraft", + "upgradeCode": "EA00C367-7270-4C50-9D8B-A7A5C88AD27F", + "sources": [ + "pdfcraft.wxs", + "installer-ui.wxs" + ], + "icon": "assets/app-icon/pdfcraft.ico", + "localization": [], + "extensions": [], + "payloads": [ + "LICENSE-APACHE", + "LICENSE-MIT", + "LICENSE-document.txt", + "NOTICE", + "pdfcraft-cli.exe", + "pdfcraft.exe" + ], + "registry": [ + [ + "-1", + "Software\\Microsoft\\Windows\\CurrentVersion\\App Paths\\pdfcraft.exe", + "", + "[#PdfcraftExe]" + ], + [ + "-1", + "Software\\Classes\\PdfCraft.Document", + "", + "PDF Document" + ], + [ + "-1", + "Software\\Classes\\PdfCraft.Document\\DefaultIcon", + "", + "\"[#PdfcraftExe]\",-2" + ], + [ + "-1", + "Software\\Classes\\PdfCraft.Document\\shell\\open\\command", + "", + "\"[#PdfcraftExe]\" \"%1\"" + ], + [ + "-1", + "Software\\Classes\\PdfCraft.Document", + "TypeOverlay", + "" + ], + [ + "-1", + "Software\\Classes\\.pdf\\OpenWithProgids", + "PdfCraft.Document", + "" + ], + [ + "-1", + "Software\\Classes\\Applications\\pdfcraft.exe", + "FriendlyAppName", + "PdfCraft" + ], + [ + "-1", + "Software\\Classes\\Applications\\pdfcraft.exe\\DefaultIcon", + "", + "\"[#PdfcraftExe]\",-2" + ], + [ + "-1", + "Software\\Classes\\Applications\\pdfcraft.exe\\SupportedTypes", + ".pdf", + "" + ], + [ + "-1", + "Software\\Classes\\Applications\\pdfcraft.exe\\shell\\open\\command", + "", + "\"[#PdfcraftExe]\" \"%1\"" + ], + [ + "-1", + "Software\\PdfCraft\\Capabilities", + "ApplicationName", + "PdfCraft" + ], + [ + "-1", + "Software\\PdfCraft\\Capabilities", + "ApplicationDescription", + "Open-source PDF workbench" + ], + [ + "-1", + "Software\\PdfCraft\\Capabilities", + "ApplicationIcon", + "[#PdfcraftExe],0" + ], + [ + "-1", + "Software\\PdfCraft\\Capabilities\\FileAssociations", + ".pdf", + "PdfCraft.Document" + ], + [ + "-1", + "Software\\RegisteredApplications", + "PdfCraft", + "Software\\PdfCraft\\Capabilities" + ], + [ + "-1", + "Software\\PdfCraft\\Shortcuts", + "StartMenu", + "#1" + ], + [ + "-1", + "Software\\PdfCraft\\Shortcuts", + "Desktop", + "#1" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.pdf\\shell\\PdfCraft.Edit", + "", + "Edit with PdfCraft" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.pdf\\shell\\PdfCraft.Edit", + "Icon", + "\"[#PdfcraftExe]\",0" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.pdf\\shell\\PdfCraft.Edit", + "MultiSelectModel", + "Document" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.pdf\\shell\\PdfCraft.Edit\\command", + "", + "\"[#PdfcraftExe]\" \"%1\"" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.png\\shell\\PdfCraft.CreatePdf", + "", + "Create PDF with PdfCraft\u2026" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.png\\shell\\PdfCraft.CreatePdf", + "MultiSelectModel", + "Single" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.png\\shell\\PdfCraft.CreatePdf\\command", + "", + "\"[#PdfcraftExe]\" --create-images \"%1\"" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.jpg\\shell\\PdfCraft.CreatePdf", + "", + "Create PDF with PdfCraft\u2026" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.jpg\\shell\\PdfCraft.CreatePdf", + "MultiSelectModel", + "Single" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.jpg\\shell\\PdfCraft.CreatePdf\\command", + "", + "\"[#PdfcraftExe]\" --create-images \"%1\"" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.jpeg\\shell\\PdfCraft.CreatePdf", + "", + "Create PDF with PdfCraft\u2026" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.jpeg\\shell\\PdfCraft.CreatePdf", + "MultiSelectModel", + "Single" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.jpeg\\shell\\PdfCraft.CreatePdf\\command", + "", + "\"[#PdfcraftExe]\" --create-images \"%1\"" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.tif\\shell\\PdfCraft.CreatePdf", + "", + "Create PDF with PdfCraft\u2026" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.tif\\shell\\PdfCraft.CreatePdf", + "MultiSelectModel", + "Single" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.tif\\shell\\PdfCraft.CreatePdf\\command", + "", + "\"[#PdfcraftExe]\" --create-images \"%1\"" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.tiff\\shell\\PdfCraft.CreatePdf", + "", + "Create PDF with PdfCraft\u2026" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.tiff\\shell\\PdfCraft.CreatePdf", + "MultiSelectModel", + "Single" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.tiff\\shell\\PdfCraft.CreatePdf\\command", + "", + "\"[#PdfcraftExe]\" --create-images \"%1\"" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.gif\\shell\\PdfCraft.CreatePdf", + "", + "Create PDF with PdfCraft\u2026" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.gif\\shell\\PdfCraft.CreatePdf", + "MultiSelectModel", + "Single" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.gif\\shell\\PdfCraft.CreatePdf\\command", + "", + "\"[#PdfcraftExe]\" --create-images \"%1\"" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.bmp\\shell\\PdfCraft.CreatePdf", + "", + "Create PDF with PdfCraft\u2026" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.bmp\\shell\\PdfCraft.CreatePdf", + "MultiSelectModel", + "Single" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.bmp\\shell\\PdfCraft.CreatePdf\\command", + "", + "\"[#PdfcraftExe]\" --create-images \"%1\"" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.jp2\\shell\\PdfCraft.CreatePdf", + "", + "Create PDF with PdfCraft\u2026" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.jp2\\shell\\PdfCraft.CreatePdf", + "MultiSelectModel", + "Single" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.jp2\\shell\\PdfCraft.CreatePdf\\command", + "", + "\"[#PdfcraftExe]\" --create-images \"%1\"" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.j2k\\shell\\PdfCraft.CreatePdf", + "", + "Create PDF with PdfCraft\u2026" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.j2k\\shell\\PdfCraft.CreatePdf", + "MultiSelectModel", + "Single" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.j2k\\shell\\PdfCraft.CreatePdf\\command", + "", + "\"[#PdfcraftExe]\" --create-images \"%1\"" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.jpx\\shell\\PdfCraft.CreatePdf", + "", + "Create PDF with PdfCraft\u2026" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.jpx\\shell\\PdfCraft.CreatePdf", + "MultiSelectModel", + "Single" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.jpx\\shell\\PdfCraft.CreatePdf\\command", + "", + "\"[#PdfcraftExe]\" --create-images \"%1\"" + ] + ], + "mode": "dualScope" +} diff --git a/packaging/windows/install-policy.wxi b/packaging/windows/install-policy.wxi new file mode 100644 index 0000000000..a0cde6e26a --- /dev/null +++ b/packaging/windows/install-policy.wxi @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packaging/windows/installer-ui.wxs b/packaging/windows/installer-ui.wxs index a437c2a8c7..7bea8562f8 100644 --- a/packaging/windows/installer-ui.wxs +++ b/packaging/windows/installer-ui.wxs @@ -1,6 +1,4 @@ - @@ -10,13 +8,14 @@ - - - - + + + + + + + + @@ -26,14 +25,22 @@ - - + + - + + + + + + + + + + @@ -66,8 +73,7 @@ - + @@ -75,8 +81,7 @@ - + @@ -84,8 +89,7 @@ - + @@ -96,8 +100,7 @@ continues; Cancel ends setup. --> - + diff --git a/packaging/windows/package.ps1 b/packaging/windows/package.ps1 index dccc21f559..e9b9e4432b 100644 --- a/packaging/windows/package.ps1 +++ b/packaging/windows/package.ps1 @@ -154,6 +154,8 @@ Invoke-Native 'test-msi' { & (Get-Process -Id $PID).Path -NoProfile -File (Join-Path $PSScriptRoot 'test-msi.ps1') $Msi ` -CraftFontLicences $HasCraftFontLicences -ExpectedFontNoticesDirectory $Docs } +# Check the compiled scope, path and registration contract before signing. +& (Join-Path $PSScriptRoot 'check-install-policy.ps1') -MsiPath $Msi # wix writes its debug symbols (.wixpdb) next to the MSI; keep them out of the release assets. Remove-Item -Force -ErrorAction SilentlyContinue ([IO.Path]::ChangeExtension($Msi, '.wixpdb')) & (Join-Path $PSScriptRoot 'sign.ps1') $Msi diff --git a/packaging/windows/pdfcraft.wxs b/packaging/windows/pdfcraft.wxs index 65e76f254b..44f6012ba0 100644 --- a/packaging/windows/pdfcraft.wxs +++ b/packaging/windows/pdfcraft.wxs @@ -1,37 +1,16 @@ - - - + + + - - + - - + + + + + + + + - + + + - + - - + + - - - - - - - - - - - + + + + + + + + + + + + - - + + - - - + + + - - - - - + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packaging/windows/test-install-policy.ps1 b/packaging/windows/test-install-policy.ps1 new file mode 100644 index 0000000000..d455d19d16 --- /dev/null +++ b/packaging/windows/test-install-policy.ps1 @@ -0,0 +1,54 @@ +<# +.SYNOPSIS + Build non-executable installer metadata fixtures; never install them. +#> +param( + [Parameter(Mandatory = $true)] [string] $OutputDirectory, + [ValidateSet('x64','x86','arm64')] [string[]] $Architectures = @('x64','x86','arm64') +) +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$root = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path +$policy = Get-Content -Raw -LiteralPath (Join-Path $PSScriptRoot 'install-policy.json') | ConvertFrom-Json +[void](New-Item -ItemType Directory -Force -Path $OutputDirectory) +$output = (Resolve-Path -LiteralPath $OutputDirectory).Path +Add-Type -AssemblyName System.Drawing +foreach ($arch in $Architectures) { + $stage = Join-Path $output $arch + [void](New-Item -ItemType Directory -Force -Path $stage) + foreach ($name in $policy.payloads) { + $destination = Join-Path $stage $name + [void](New-Item -ItemType Directory -Force -Path ([IO.Path]::GetDirectoryName($destination))) + Set-Content -LiteralPath $destination -Encoding Ascii -Value 'NON-EXECUTABLE MSI METADATA TEST FIXTURE' + } + foreach ($folder in @('docs','models','notices')) { + $path = Join-Path $stage $folder + [void](New-Item -ItemType Directory -Force -Path $path) + Set-Content -LiteralPath (Join-Path $path "$folder-fixture.txt") -Value 'Metadata test fixture, not a user file or model.' + } + foreach ($model in @('metadata-one.rten','metadata-two.rten')) { + Set-Content -LiteralPath (Join-Path (Join-Path $stage 'models') $model) -Encoding Ascii -Value 'NON-EXECUTABLE MODEL METADATA FIXTURE' + } + foreach ($spec in @(@('banner.bmp',493,58),@('dialog.bmp',493,312))) { + $bitmap = [Drawing.Bitmap]::new($spec[1],$spec[2],[Drawing.Imaging.PixelFormat]::Format24bppRgb) + try { $bitmap.Save((Join-Path $stage $spec[0]),[Drawing.Imaging.ImageFormat]::Bmp) } + finally { $bitmap.Dispose() } + } + Set-Content -LiteralPath (Join-Path $stage 'licence.rtf') -Encoding Ascii -Value '{\rtf1 Metadata test fixture.}' + $sources = @($policy.sources | ForEach-Object { Join-Path $PSScriptRoot $_ }) + $msi = Join-Path $stage "$($policy.id)-NOT-INSTALLABLE.msi" + $extensions = @($policy.extensions | ForEach-Object { '-ext'; "WixToolset.$_.wixext" }) + & wix build @sources -arch $arch @extensions ` + -culture en-US -d 'Version=0.0.1' -d 'DisplayVersion=0.0.1-metadata-test' ` + -d "BinDir=$stage" -d "IconPath=$(Join-Path $root $policy.icon)" ` + -d "DocsDir=$(Join-Path $stage 'docs')" -d "ModelsDir=$(Join-Path $stage 'models')" ` + -d "AppNoticesDir=$(Join-Path $stage 'notices')" -d 'HasCraftFontLicences=1' -d 'HasCli=1' ` + -d "BannerBmp=$(Join-Path $stage 'banner.bmp')" -d "DialogBmp=$(Join-Path $stage 'dialog.bmp')" ` + -d "UiBannerBmp=$(Join-Path $stage 'banner.bmp')" -d "UiDialogBmp=$(Join-Path $stage 'dialog.bmp')" ` + -d "LicenseRtf=$(Join-Path $stage 'licence.rtf')" -o $msi @($policy.localization | ForEach-Object { '-loc'; Join-Path $PSScriptRoot $_ }) + if ($LASTEXITCODE -ne 0) { throw "WiX validation failed for $arch (exit $LASTEXITCODE)." } + $shell = (Get-Process -Id $PID).Path + & $shell -NoProfile -ExecutionPolicy Bypass -File (Join-Path $PSScriptRoot 'check-install-policy.ps1') -MsiPath $msi + if ($LASTEXITCODE -ne 0) { throw "Compiled installer policy failed for $arch (exit $LASTEXITCODE)." } + Write-Output "PASS $arch metadata-only package. This is not an executable installer." +} diff --git a/packaging/windows/test-msi.ps1 b/packaging/windows/test-msi.ps1 index 24a4c6d75a..5dc4152d8f 100644 --- a/packaging/windows/test-msi.ps1 +++ b/packaging/windows/test-msi.ps1 @@ -38,41 +38,11 @@ function Assert-NoRow([string] $Sql, [string] $What) { } finally { [void] $view.Close() } } -# Test the compiled condition with Windows Installer's evaluator, in a restricted session that -# cannot change machine state. Normal installs/repairs work, per-user overrides fail, and removal -# of an older incorrectly scoped installation remains possible (#305). -$scopeMessage = 'PdfCraft must be installed for all users. Run setup with administrator privileges and ALLUSERS=1; per-user installation is not supported.' -$scopeCondition = Read-Row ('SELECT `Condition` FROM `LaunchCondition` WHERE `Description` = ''' + $scopeMessage + '''') 1 +# The shared compiled contract covers both scopes, duplicate guards and safe maintenance. +# Keep it in a separate process because Windows Installer permits one session per process. +& (Get-Process -Id $PID).Path -NoProfile -ExecutionPolicy Bypass -File (Join-Path $PSScriptRoot 'check-install-policy.ps1') -MsiPath $Path +if ($LASTEXITCODE -ne 0) { throw 'Dual-scope installer contract failed.' } $Installer.UILevel = 2 -$session = $null -try { - $session = $Installer.OpenPackage((Resolve-Path -LiteralPath $Path).Path, 1) - foreach ($case in @( - @('machine install', '1', '', '', '', 1), - @('machine repair', '1', '', '1', '', 1), - @('forced per-user install', '2', '1', '', '', 0), - @('empty ALLUSERS', '', '', '', '', 0), - @('resolved per-user install', '', '1', '', '', 0), - @('forced per-user repair', '2', '1', '1', '', 0), - @('machine uninstall', '1', '', '1', 'ALL', 1), - @('legacy per-user uninstall', '', '1', '1', 'ALL', 1))) { - $session.Property('ALLUSERS') = $case[1] - $session.Property('MSIINSTALLPERUSER') = $case[2] - $session.Property('Installed') = $case[3] - $session.Property('REMOVE') = $case[4] - Assert-Equal ($session.EvaluateCondition($scopeCondition[0])) $case[5] $case[0] - } -} finally { - if ($session) { [void] [Runtime.InteropServices.Marshal]::FinalReleaseComObject($session) } -} -foreach ($sequence in @('InstallUISequence', 'InstallExecuteSequence')) { - $launch = Read-Row ('SELECT `Condition`, `Sequence` FROM `' + $sequence + '` WHERE `Action` = ''LaunchConditions''') 2 - Assert-Equal $launch[0] '' "$sequence launch conditions are unconditional" - $cost = Read-Row ('SELECT `Sequence` FROM `' + $sequence + '` WHERE `Action` = ''CostInitialize''') 1 - if ([int] $launch[1] -le 0 -or [int] $launch[1] -ge [int] $cost[0]) { - throw "$sequence must reject per-user overrides before costing" - } -} $manufacturer = Read-Row 'SELECT `Value` FROM `Property` WHERE `Property` = ''Manufacturer''' 1 Assert-Equal $manufacturer[0] 'Learning Machines LLC' 'MSI manufacturer' @@ -173,7 +143,7 @@ Assert-Equal $checked[0] '1' 'Welcome checkbox value' $app = Read-Row 'SELECT `KeyPath` FROM `Component` WHERE `Component` = ''PdfcraftApp''' 1 Assert-Equal $app[0] 'PdfcraftExe' 'Shortcut executable key path' $scope = Read-Row 'SELECT `Value` FROM `Property` WHERE `Property` = ''ALLUSERS''' 1 -Assert-Equal $scope[0] '1' 'Per-machine shortcut scope' +Assert-Equal $scope[0] '2' 'Dual-scope shortcut package' # Each File Explorer menu remembers whether it was installed by finding its key path in HKLM, in the # package's own registry view (64-bit for x64 and arm64). Only a release that offered the menu can @@ -181,12 +151,22 @@ Assert-Equal $scope[0] '1' 'Per-machine shortcut scope' $upgradeCode = Read-Row 'SELECT `Value` FROM `Property` WHERE `Property` = ''UpgradeCode''' 1 $view64 = $Database.SummaryInformation(0).Property(7) -notmatch '^Intel;' function Assert-MenuSearch([string] $Found, [string] $Key, [string] $Release, [string] $Since, [string] $What) { - $search = Read-Row ('SELECT `Root`, `Key`, `Name`, `Type` FROM `RegLocator`, `AppSearch` WHERE `AppSearch`.`Signature_` = `RegLocator`.`Signature_` AND `AppSearch`.`Property` = ''' + $Found + '''') 4 - Assert-Equal $search[0] '2' "$What search HKLM root" - Assert-Equal $search[1] $Key "$What search key" - Assert-Equal $search[2] '' "$What search reads the default value" - # msidbLocatorTypeRawValue (2), plus msidbLocatorType64bit (16) in a 64-bit package. - Assert-Equal ([int] $search[3]) $(if ($view64) { 18 } else { 2 }) "$What search type" + foreach ($context in @(@('M', '2'), @('U', '1'))) { + $cache = '' + $loaders = $Database.OpenView('SELECT `Action`, `Target` FROM `CustomAction` WHERE `Source` = ''' + $Found + '''') + try { + [void]$loaders.Execute() + while ($row = $loaders.Fetch()) { + if ($row.StringData(1) -match ('^CraftLoadOption[0-9]+' + $context[0] + '$')) { $cache = $row.StringData(2).Trim('[]') } + } + } finally { [void]$loaders.Close() } + if (-not $cache) { throw "$What has no context-specific loader." } + $search = Read-Row ('SELECT `Root`, `Key`, `Name`, `Type` FROM `RegLocator`, `AppSearch` WHERE `AppSearch`.`Signature_` = `RegLocator`.`Signature_` AND `AppSearch`.`Property` = ''' + $cache + '''') 4 + Assert-Equal $search[0] $context[1] "$What context $($context[0]) search root" + Assert-Equal $search[1] $Key "$What search key" + Assert-Equal $search[2] '' "$What search reads the default value" + Assert-Equal ([int] $search[3]) $(if ($view64) { 18 } else { 2 }) "$What search type" + } $upgrade = Read-Row ('SELECT `UpgradeCode`, `VersionMin`, `VersionMax`, `Attributes` FROM `Upgrade` WHERE `ActionProperty` = ''' + $Release + '''') 4 Assert-Equal $upgrade[0] $upgradeCode[0] "$What release detection upgrade code" Assert-Equal $upgrade[1] $Since "$What release detection minimum" @@ -213,7 +193,7 @@ foreach ($ext in @('png', 'jpg', 'jpeg', 'tif', 'tiff', 'gif', 'bmp', 'jp2', 'j2 $menu = Read-Row ('SELECT `Value`, `Component_`, `Root` FROM `Registry` WHERE `Key` = ''' + $key + ''' AND `Name` IS NULL') 3 Assert-Equal $menu[0] 'Create PDF with PdfCraft…' "$ext context menu label" Assert-Equal $menu[1] 'PdfcraftImageContextMenu' "$ext context menu component" - Assert-Equal $menu[2] '2' "$ext context menu HKLM root" + Assert-Equal $menu[2] '-1' "$ext context menu context-sensitive root" $command = Read-Row ('SELECT `Value`, `Component_` FROM `Registry` WHERE `Key` = ''' + $key + '\command''') 2 Assert-Equal $command[0] '"[#PdfcraftExe]" --create-images "%1"' "$ext context menu command" Assert-Equal $command[1] 'PdfcraftImageContextMenu' "$ext context menu command component" @@ -246,7 +226,7 @@ $pdfKey = 'Software\Classes\SystemFileAssociations\.pdf\shell\PdfCraft.Edit' $pdfMenu = Read-Row ('SELECT `Registry`, `Value`, `Component_`, `Root` FROM `Registry` WHERE `Key` = ''' + $pdfKey + ''' AND `Name` IS NULL') 4 Assert-Equal $pdfMenu[1] 'Edit with PdfCraft' 'PDF context menu label' Assert-Equal $pdfMenu[2] 'PdfcraftPdfContextMenu' 'PDF context menu component' -Assert-Equal $pdfMenu[3] '2' 'PDF context menu HKLM root' +Assert-Equal $pdfMenu[3] '-1' 'PDF context menu context-sensitive root' foreach ($value in @(@('Icon', '"[#PdfcraftExe]",0'), @('MultiSelectModel', 'Document'))) { $row = Read-Row ('SELECT `Value`, `Component_` FROM `Registry` WHERE `Key` = ''' + $pdfKey + ''' AND `Name` = ''' + $value[0] + '''') 2 Assert-Equal $row[0] $value[1] "PDF context menu $($value[0])" @@ -288,15 +268,18 @@ $modelsDir = Read-Row 'SELECT `Directory_Parent`, `DefaultDir` FROM `Directory` Assert-Equal $modelsDir[0] 'INSTALLFOLDER' 'OCR models folder parent' # DefaultDir is `models`, or `SHORT|models` with a generated 8.3 name. if ($modelsDir[1] -notmatch '(^|\|)models$') { throw "OCR models folder name: '$($modelsDir[1])'" } -$modelFiles = 0 +$modelFiles = @() $view = $Database.OpenView('SELECT `File`.`FileName` FROM `File`, `Component` WHERE `File`.`Component_` = `Component`.`Component` AND `Component`.`Directory_` = ''ModelsFolder''') try { [void] $view.Execute() - while ($record = $view.Fetch()) { if ($record.StringData(1) -match '\.rten$') { $modelFiles++ } } + # FileName can contain a generated 8.3 name before the long name: SHORT|long. + while ($record = $view.Fetch()) { $modelFiles += ($record.StringData(1) -split '\|')[-1] } } finally { [void] $view.Close() } -if ($modelFiles -lt 2) { throw "expected the OCR models (*.rten) in models\, found $modelFiles" } +foreach ($model in @('text-detection.onnx', 'text-recognition.onnx')) { + Assert-Equal (@($modelFiles | Where-Object { $_ -ceq $model }).Count) 1 "OCR model $model in models\" +} $rm = Read-Row 'SELECT `Dialog` FROM `Dialog` WHERE `Dialog` = ''MsiRMFilesInUse''' 1 Assert-Equal $rm[0] 'MsiRMFilesInUse' 'Files-in-use dialog' [void] [Runtime.InteropServices.Marshal]::FinalReleaseComObject($Database) [void] [Runtime.InteropServices.Marshal]::FinalReleaseComObject($Installer) -Write-Output 'ok MSI: per-machine scope guard, publisher, persistent progress text, Start Menu shortcut, optional desktop shortcut and PDF and image context menus (on for a first install, kept on update only while present; checkboxes for the first two), icon/key path, full-UI success/cancel/error and Finish controls, files-in-use dialog, OCR models' +Write-Output 'ok MSI: dual-scope guards, publisher, persistent progress text, Start Menu shortcut, optional desktop shortcut and PDF and image context menus (on for a first install, kept on update only while present; checkboxes for the first two), icon/key path, full-UI success/cancel/error and Finish controls, files-in-use dialog, OCR models' diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 5e1c4a7e75..a0ff80d4da 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -22,7 +22,7 @@ milestone = "M2" status = "shipped" commands = ["file.word_count"] tools = ["text_word_count"] -evidence = ["crates/engine/src/word_count.rs::word_count_filters_are_independent_and_do_not_change_normal_extraction", "crates/automation/tests/it/automation.rs::word_count_reads_selected_pages_and_unsaved_edits_without_mutating", "crates/ui-egui/src/word_count_ui.rs::word_count_dialog_counts_changes_pages_and_closes"] +evidence = ["crates/engine/src/word_count.rs::word_count_filters_are_independent_and_do_not_change_normal_extraction", "crates/automation/tests/it/automation.rs::word_count_reads_selected_pages_and_unsaved_edits_without_mutating", "crates/ui-egui/src/dialogs/word_count_ui.rs::word_count_dialog_counts_changes_pages_and_closes"] notes = "Unicode words and characters for the current snapshot; all or current page in GUI, selected pages headlessly. Desktop background work with cancellation. Optional semantic table/code/Figure and invisible-text exclusions; no OCR or visual layout classification. Unsupported structure remains counted and is reported. Mixed ActualText exclusions fail explicitly. See docs/word-count.md." [[feature]] @@ -34,7 +34,7 @@ milestone = "M1" status = "shipped" commands = ["file.open"] tools = ["doc_open"] -evidence = ["crates/automation/tests/it/automation.rs::open_inspect_render_and_find", "crates/ui-egui/tests/it/ui.rs::opening_a_pdf_shows_comments_and_bookmarks", "crates/ui-egui/tests/it/ui.rs::files_and_quit_from_the_operating_system", "crates/ui-egui/src/open_worker_tests.rs::a_chosen_tool_runs_on_its_file_only_after_native_preparation_commits", "crates/ui-egui/tests/it/editing.rs::opening_the_same_path_again_focuses_its_tab", "crates/ui-egui/tests/it/editing.rs::opening_a_file_whose_copy_vanished_from_disk_focuses_its_tab"] +evidence = ["crates/automation/tests/it/automation.rs::open_inspect_render_and_find", "crates/ui-egui/tests/it/ui.rs::opening_a_pdf_shows_comments_and_bookmarks", "crates/ui-egui/tests/it/ui.rs::files_and_quit_from_the_operating_system", "crates/ui-egui/src/platform/open_worker/tests.rs::a_chosen_tool_runs_on_its_file_only_after_native_preparation_commits", "crates/ui-egui/tests/it/editing.rs::opening_the_same_path_again_focuses_its_tab", "crates/ui-egui/tests/it/editing.rs::opening_a_file_whose_copy_vanished_from_disk_focuses_its_tab"] notes = "File ▸ Open, the command line (Linux and Windows file associations), the Windows MSI's \"Edit with PdfCraft\" entry on PDF files' File Explorer right-click menu (#682; several selected PDFs open as tabs of one window), and on macOS Finder, Open With and the Dock (Apple events; the bundle declares PDF as a document type); an already-open file focuses its tab instead of opening a second copy (#1620). Not yet: a first-level Windows 11 menu entry (needs package identity) or Combine from the file manager (#585)" [[feature]] @@ -46,7 +46,7 @@ milestone = "M1" status = "shipped" commands = ["file.open"] tools = ["doc_open"] -evidence = ["crates/cos/src/document.rs::damaged_xref_is_reconstructed", "crates/cos/src/parser.rs::indirect_stream_with_wrong_length_recovers", "crates/cos/src/document.rs::self_referential_length_does_not_overflow", "crates/engine/src/tests.rs::a_repaired_file_is_rewritten_by_save_even_without_edits", "crates/automation/tests/it/automation.rs::saving_a_repaired_file_puts_the_repair_on_disk", "crates/engine/src/tests.rs::a_signed_repaired_file_keeps_its_bytes_when_saved_without_edits", "crates/cos/src/document.rs::a_table_numbered_from_1_that_begins_with_object_0_is_read_from_0"] +evidence = ["crates/cos/src/document/tests.rs::damaged_xref_is_reconstructed", "crates/cos/src/parser.rs::indirect_stream_with_wrong_length_recovers", "crates/cos/src/document/tests.rs::self_referential_length_does_not_overflow", "crates/engine/src/tests.rs::a_repaired_file_is_rewritten_by_save_even_without_edits", "crates/automation/tests/it/automation.rs::saving_a_repaired_file_puts_the_repair_on_disk", "crates/engine/src/tests.rs::a_signed_repaired_file_keeps_its_bytes_when_saved_without_edits", "crates/cos/src/document/tests.rs::a_table_numbered_from_1_that_begins_with_object_0_is_read_from_0"] notes = "Save and doc_save write a reconstructed file in full even when nothing was edited, so the repair reaches the disk and the saved file opens without one (#1937); a signed one keeps its bytes, so its signatures stay valid. A repair does not mark the document as having unsaved changes, so closing it does not offer to save the repaired file. A classic table numbered 1 n that begins with object 0's free entry is read from 0 (#2676) and logged." [[feature]] @@ -58,7 +58,7 @@ milestone = "M1" status = "shipped" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/it/ui.rs::garbage_input_is_rejected_without_panicking", "crates/cos/src/parser.rs::garbage_never_panics", "crates/render/src/raster.rs::missing_page_and_garbage_input_fail_gracefully"] +evidence = ["crates/ui-egui/tests/it/ui.rs::garbage_input_is_rejected_without_panicking", "crates/cos/src/parser.rs::garbage_never_panics", "crates/render/src/raster/tests.rs::missing_page_and_garbage_input_fail_gracefully"] notes = "fuzzed nightly (M1.10)" [[feature]] @@ -83,7 +83,7 @@ status = "shipped" commands = [] tools = [] evidence = ["crates/cos/tests/it/writer.rs::incremental_saves_stack_on_an_xref_stream_file", "crates/cos/tests/it/writer.rs::page_tree_loops_in_object_streams_do_not_crash_readers"] -notes = "" +notes = "Reads classic tables, cross-reference streams, hybrid /XRefStm files and objects inside object streams (crates/cos/src/document.rs), and rebuilds a damaged table with a note in the repair log. The cited tests read files our own writer produced (plus a hand-built looping page tree); no third-party xref-stream sample is cited here." [[feature]] id = "core.hybrid-xref" @@ -94,7 +94,7 @@ milestone = "M1" status = "shipped" commands = [] tools = ["doc_open"] -evidence = ["crates/cos/src/document.rs::hybrid_reference_files_read_the_xref_stream_too"] +evidence = ["crates/cos/src/document/tests.rs::hybrid_reference_files_read_the_xref_stream_too"] notes = "/XRefStm entries supplement the classic table and replace its free entries for objects in object streams; each section is merged as a unit so older revisions only fill gaps" [[feature]] @@ -106,7 +106,7 @@ milestone = "M1" status = "shipped" commands = ["file.properties"] tools = ["doc_revisions"] -evidence = ["crates/cos/src/document.rs::looping_revision_chains_stop", "crates/cos/src/document.rs::revision_ends_split_incremental_updates"] +evidence = ["crates/cos/src/document/tests.rs::looping_revision_chains_stop", "crates/cos/src/document/tests.rs::revision_ends_split_incremental_updates"] notes = "/Prev chains followed newest first with a visited set (loops stop and are logged); revisions listed and opened (doc_revisions, doc_open_revision)" [[feature]] @@ -143,7 +143,7 @@ status = "shipped" commands = [] tools = [] evidence = ["crates/filters/src/flate.rs::bomb_is_bounded", "crates/filters/src/lib.rs::limit_applies_to_intermediate_buffers"] -notes = "" +notes = "Every filter decode is capped (1 GiB per stream, MAX_DECODED), and a DecodeBudget caps one page walk at 256 MiB decoded and 50,000 form entries, so a repeated bomb is inflated once. The cited tests cover Flate and chained ASCII filters only; image codecs (DCT, JPX, CCITT) and the budget itself are not covered by the cited tests, and the limits are fixed, not user settings." [[feature]] id = "core.open-rc4" @@ -155,7 +155,7 @@ status = "shipped" commands = ["file.open"] tools = ["doc_open"] evidence = ["crates/crypt/src/tests.rs::rc4_reference_vectors", "crates/cos/tests/it/encryption.rs::every_algorithm_round_trips_and_hides_the_content"] -notes = "" +notes = "Opens R2 (40-bit) and R3/R4 (128-bit) RC4 files with the user or owner password, asks for one when needed, and also tries a UTF-8 form of the password for characters PDFDocEncoding lacks. Tests cover RC4 reference vectors and files our own writer encrypts (checked against hayro and qpdf); no RC4 file made by another producer is cited, and Acrobat behaviour was not observed." [[feature]] id = "core.open-aes" @@ -167,7 +167,7 @@ status = "shipped" commands = ["file.open"] tools = ["doc_open"] evidence = ["crates/crypt/src/tests.rs::aes_cbc_nist_sp800_38a_vectors", "crates/cos/tests/it/encryption.rs::every_algorithm_round_trips_and_hides_the_content"] -notes = "" +notes = "Opens AES-128 (R4, AESV2) and AES-256 (R6, AESV3) files; the code also accepts R5, which the cited tests do not generate. Tests use NIST CBC vectors and files our own writer encrypts (checked against hayro and qpdf), not files from other producers; docs/file-format-parity.md says Acrobat DC opened our AES-256 output by hand, with no automated interop suite." [[feature]] id = "core.password-prompt" @@ -178,7 +178,7 @@ milestone = "M1" status = "shipped" commands = ["file.open"] tools = ["doc_open"] -evidence = ["crates/ui-egui/tests/it/editing.rs::password_prompt_opens_and_security_tab_reports_the_details", "crates/crypt/src/tests.rs::empty_user_password_opens_without_asking", "crates/ui-egui/src/open_worker_tests.rs::a_chosen_tool_survives_wrong_password_retry_and_runs_after_unlock", "crates/ui-egui/src/open_worker_tests.rs::startup_intake_waits_for_local_authentication_and_keeps_its_own_background_password_prompt", "crates/ui-egui/tests/it/pickers.rs::the_open_password_can_be_typed_through_an_input_method_however_the_file_is_opened"] +evidence = ["crates/ui-egui/tests/it/editing.rs::password_prompt_opens_and_security_tab_reports_the_details", "crates/crypt/src/tests.rs::empty_user_password_opens_without_asking", "crates/ui-egui/src/platform/open_worker/tests.rs::a_chosen_tool_survives_wrong_password_retry_and_runs_after_unlock", "crates/ui-egui/src/platform/open_worker/tests.rs::startup_intake_waits_for_local_authentication_and_keeps_its_own_background_password_prompt", "crates/ui-egui/tests/it/pickers.rs::the_open_password_can_be_typed_through_an_input_method_however_the_file_is_opened"] notes = "the field takes typing through the platform input method, which typed keys go through on Linux, however the file was opened (#2818); checked with synthetic input-method events, not on a Linux desktop" [[feature]] @@ -191,7 +191,7 @@ status = "shipped" commands = [] tools = [] evidence = ["crates/crypt/src/tests.rs::r6_passwords_are_saslprepped"] -notes = "" +notes = "R5/R6 passwords go through SASLprep (RFC 4013, NFKC, soft hyphens dropped) as UTF-8 capped at 127 bytes, using the raw password if SASLprep refuses it; R2-R4 use PDFDocEncoding padded or cut to 32 bytes. The cited test checks only the R6 case; the R2-R4 rules are covered by effective_password tests that are not cited here, and no other application's output was compared." [[feature]] id = "core.owner-password-unlock" @@ -215,7 +215,7 @@ status = "shipped" commands = [] tools = [] evidence = ["crates/ui-egui/tests/it/editing.rs::restricted_documents_show_a_notice_and_block_page_changes", "crates/crypt/src/tests.rs::permission_bits_follow_table_22", "crates/crypt/src/tests.rs::perms_limits_what_an_edited_p_grants", "crates/engine/src/tests.rs::combining_protected_files_is_refused_clearly", "crates/engine/src/tests.rs::print_restricted_documents_disable_print_unless_opened_by_the_owner", "crates/ui-egui/tests/it/print.rs::print_restricted_documents_say_so_before_the_dialog", "crates/engine/src/tests.rs::convert_to_pdfa_needs_the_owners_rights_on_an_encrypted_document"] -notes = "" +notes = "Session gates print, modify, assemble, copy, annotate and fill-forms on the /P bits for non-owners, with an owner-password unlock, and a low-resolution-only print limit makes the print file pictures of at most 150 dpi. Reading for assistive technology is never blocked, and this is software enforcement only: other readers can ignore the bits, and Acrobat's own per-bit behaviour was not observed." [[feature]] id = "core.crypt-filters" @@ -227,7 +227,7 @@ status = "shipped" commands = [] tools = [] evidence = ["crates/crypt/src/tests.rs::identity_crypt_filters_leave_data_alone"] -notes = "" +notes = "Reads /CF, /StmF, /StrF and /EFF, so Identity strings or streams and embedded-file-only encryption work, and named crypt filters on streams are honoured. An unrecognised /CFM is treated as Identity (data left as is) and custom crypt-filter handlers are not supported; the cited test covers one embedded-file-only case built by our own writer." [[feature]] id = "core.attachment-only-encryption" @@ -251,7 +251,7 @@ status = "shipped" commands = [] tools = [] evidence = ["crates/cos/tests/it/encryption.rs::unencrypted_metadata_is_left_readable"] -notes = "" +notes = "With /EncryptMetadata false the XMP metadata stream stays readable in the saved file, and a Metadata stream is left alone on read when that flag says so (crates/cos/src/security.rs). Only AES-256 written by our own writer is cited; reading such a file from another producer and the R2-R4 variants are not covered by the cited test." [[feature]] id = "core.unsupported-handlers" @@ -263,7 +263,7 @@ status = "shipped" commands = [] tools = [] evidence = ["crates/crypt/src/tests.rs::unsupported_handlers_are_reported"] -notes = "" +notes = "A non-Standard /Filter (such as Adobe.PubSec certificate security) or a revision other than 2-6 stops opening with an unsupported security handler error. The cited test checks the crypt-level error only, not the dialog wording, and public-key (certificate) encryption itself is not supported." [[feature]] id = "core.open-public-key" @@ -286,7 +286,7 @@ milestone = "M1" status = "shipped" commands = ["file.save"] tools = ["doc_save"] -evidence = ["crates/platform/src/staging.rs::atomic_saves_keep_finder_tags_and_binary_extended_attributes", "crates/platform/src/staging.rs::a_growing_resource_fork_refuses_publication_and_keeps_original", "crates/automation/tests/it/automation.rs::saving_a_pdf_keeps_the_destinations_extended_attributes", "crates/automation/tests/it/automation.rs::a_metadata_preservation_error_keeps_the_pdf_and_unsaved_edit", "crates/ui-egui/tests/it/editing.rs::save_writes_an_incremental_update_and_clears_dirty", "crates/engine/src/tests.rs::save_is_incremental_stamps_mod_date_and_rebases", "crates/organize/src/tests.rs::successive_incremental_saves_stack_revisions", "crates/automation/src/lib.rs::staging_never_writes_through_a_file_planted_at_its_name", "crates/ui-egui/src/editing.rs::saving_never_writes_through_a_file_planted_at_the_staging_name"] +evidence = ["crates/platform/src/staging/tests.rs::atomic_saves_keep_finder_tags_and_binary_extended_attributes", "crates/platform/src/staging/tests.rs::a_growing_resource_fork_refuses_publication_and_keeps_original", "crates/automation/tests/it/automation.rs::saving_a_pdf_keeps_the_destinations_extended_attributes", "crates/automation/tests/it/automation.rs::a_metadata_preservation_error_keeps_the_pdf_and_unsaved_edit", "crates/ui-egui/tests/it/editing.rs::save_writes_an_incremental_update_and_clears_dirty", "crates/engine/src/tests.rs::save_is_incremental_stamps_mod_date_and_rebases", "crates/organize/src/tests.rs::successive_incremental_saves_stack_revisions", "crates/automation/src/tests.rs::staging_never_writes_through_a_file_planted_at_its_name", "crates/ui-egui/src/editing/tests.rs::saving_never_writes_through_a_file_planted_at_the_staging_name"] notes = "The atomic write stages to a new, randomly named file created with create_new, so a file or link planted at the staging path is never written through (desktop app, automation and CLI). On macOS, accessible destination xattrs (including Finder tags) are copied before flush/rename; errors, metadata limits or a changed post-read size refuse the save without replacing the file. System-managed provenance is excluded. Permissions, ACLs, hard-link identity and creation dates are not preserved by this change (#2658)." [[feature]] @@ -299,7 +299,7 @@ status = "shipped" commands = ["file.save_as"] tools = ["doc_save"] evidence = ["crates/organize/src/tests.rs::full_save_drops_unreachable_objects", "crates/engine/src/tests.rs::full_save_is_readable_and_smaller_after_deletes"] -notes = "" +notes = "Save As to another path rewrites the file in full and drops unreachable objects, while Save to the same path stays incremental and a signed document is never rewritten; doc_save takes a full flag. The cited tests check that deleted pages' objects go and the file reopens smaller; unused fonts or images are only dropped when nothing references them, with no further optimisation here (see the optimize.* entries)." [[feature]] id = "core.object-stream-compression" @@ -311,7 +311,7 @@ status = "shipped" commands = ["file.save_as"] tools = ["doc_save"] evidence = ["crates/cos/tests/it/writer.rs::full_save_packs_objects_into_object_streams", "crates/cos/tests/it/writer.rs::qpdf_accepts_object_stream_output", "crates/cos/tests/it/writer.rs::objects_referenced_by_the_encrypt_dictionary_stay_outside_object_streams"] -notes = "" +notes = "A full save packs non-stream objects into object streams (100 per stream, 8 MiB batches) behind an xref stream, keeping the catalog and encryption objects outside them. It is on by default in SaveOptions (the engine's save paths never turn it off) and is not a user setting; qpdf_accepts_object_stream_output uses qpdf as an outside check, and old-reader compatibility was not tested." [[feature]] id = "core.encrypted-save" @@ -323,7 +323,7 @@ status = "shipped" commands = ["file.save"] tools = ["doc_save"] evidence = ["crates/cos/tests/it/encryption.rs::incremental_edits_of_encrypted_documents_stay_encrypted", "crates/engine/src/tests.rs::encrypted_documents_open_edit_and_save_encrypted"] -notes = "" +notes = "An incremental save appends to the encrypted file and encrypts only the new objects with the existing handler; a full rewrite re-encrypts all content subject to the encryption policy (metadata stays unencrypted when /EncryptMetadata is false), and changing or removing security forces a full rewrite. Cited tests cover all four algorithms through our own writer and a hayro reopen; docs/file-format-parity.md says Acrobat DC opened AES-256 and RC4-128 output by hand." [[feature]] id = "core.copy-on-write-fidelity" @@ -334,8 +334,8 @@ milestone = "M1" status = "shipped" commands = [] tools = [] -evidence = ["crates/cos/src/document.rs::reads_classic_revision_and_edits_are_copy_on_write"] -notes = "" +evidence = ["crates/cos/src/document/tests.rs::reads_classic_revision_and_edits_are_copy_on_write"] +notes = "Edits live in an overlay over the original bytes, so a clone is an independent snapshot and an incremental save appends without changing the original bytes (checked by the incremental-save tests in crates/cos and crates/engine). A full rewrite re-serialises objects, so byte identity holds for incremental saves only, and the cited test is a single small in-memory case." [[feature]] id = "core.write-validity" @@ -346,7 +346,7 @@ milestone = "M1" status = "shipped" commands = [] tools = [] -evidence = ["crates/cos/tests/it/encryption.rs::qpdf_agrees", "crates/cos/tests/it/writer.rs::qpdf_accepts_object_stream_output", "crates/platform/src/staging.rs::an_atomic_write_replaces_the_target_only_when_complete", "apps/pdfcraft-cli/tests/it/edit.rs::editing_in_place_leaves_a_valid_file_and_no_staging_file", "crates/automation/tests/it/automation.rs::a_root_less_write_output_is_atomic_too"] +evidence = ["crates/cos/tests/it/encryption.rs::qpdf_agrees", "crates/cos/tests/it/writer.rs::qpdf_accepts_object_stream_output", "crates/platform/src/staging/tests.rs::an_atomic_write_replaces_the_target_only_when_complete", "apps/pdfcraft-cli/tests/it/edit.rs::editing_in_place_leaves_a_valid_file_and_no_staging_file", "crates/automation/tests/it/automation.rs::a_root_less_write_output_is_atomic_too"] notes = "qpdf is installed on the Linux CI runner and PDFCRAFT_ORACLES_REQUIRED makes its absence a failure, so these run on every CI run rather than skipping; on a machine without qpdf they skip with a notice (crates/oracle). Every PDF leaves the engine checked (it reopens with its pages): saves, new files from Combine/Extract/Split/Create, signed, timestamped and LTV-embedded files, OCR batch output; and every writer goes through one staging-file writer (the desktop app, the tools with or without --root, the CLI), so a failed write never leaves a truncated file (#1698, #2166)" [[feature]] @@ -382,7 +382,7 @@ milestone = "M1" status = "shipped" commands = ["file.properties"] tools = ["doc_revisions"] -evidence = ["crates/cos/src/document.rs::revision_ends_split_incremental_updates", "crates/automation/tests/it/automation.rs::digital_ids_signing_and_validation_through_tools"] +evidence = ["crates/cos/src/document/tests.rs::revision_ends_split_incremental_updates", "crates/automation/tests/it/automation.rs::digital_ids_signing_and_validation_through_tools"] notes = "revision ends from the xref chain (linearized first-page sections fold into their revision); listed in Document Properties ▸ Advanced and by doc_revisions with the signatures covering each" [[feature]] @@ -479,7 +479,7 @@ status = "shipped" commands = ["edit.undo", "edit.redo"] tools = ["edit_undo", "edit_redo"] evidence = ["crates/engine/src/tests.rs::undo_and_redo_restore_exact_states", "crates/ui-egui/tests/it/editing.rs::organize_delete_then_undo_and_redo_with_keys"] -notes = "" +notes = "Every edit goes through the session's snapshot history (100 steps, MAX_UNDO) with redo cleared by a new edit, and undo back to the opened bytes restores them exactly. Missing: the history is per open document and is not saved with the file, and the depth limit is fixed; the cited UI test covers organize delete only." [[feature]] id = "core.undo-labels" @@ -491,7 +491,7 @@ status = "shipped" commands = ["edit.undo", "edit.redo"] tools = [] evidence = ["crates/ui-egui/tests/it/editing.rs::edit_menu_names_the_step_to_undo", "crates/engine/src/commands.rs::enablement_follows_the_document_state"] -notes = "" +notes = "The Edit menu shows Undo and Redo with the name of the step (Delete page, Rotate page and so on) taken from the history labels, and the command is disabled when nothing is left. The cited UI test checks one label (organize), not every edit's wording or its translations." [[feature]] id = "core.undo-after-save" @@ -503,7 +503,7 @@ status = "shipped" commands = ["edit.undo"] tools = ["edit_undo"] evidence = ["crates/engine/src/tests.rs::undo_still_works_after_save"] -notes = "" +notes = "Saving rebases the history rather than clearing it, so undo still steps back past a save, and undoing to the saved file clears the dirty flag while undoing past it sets it again. The cited engine test covers an incremental save only, not Save As or encrypted files." [[feature]] id = "core.atomic-batch-edits" @@ -515,7 +515,7 @@ status = "shipped" commands = [] tools = [] evidence = ["crates/engine/src/tests.rs::batch_is_one_undo_step_and_all_or_nothing", "crates/engine/src/tests.rs::failed_edit_changes_nothing"] -notes = "" +notes = "Edit::Batch applies several edits as one undo step under one label, and if any member fails the document is left as it was; a single failing edit also changes nothing. Missing: no cited test for batches inside signed or encrypted documents, and the cited cases are small." [[feature]] id = "core.history-panel" @@ -539,7 +539,7 @@ status = "shipped" commands = [] tools = [] evidence = ["crates/engine/src/tests.rs::autosave_snapshots_only_changed_documents", "crates/ui-egui/tests/it/recovery.rs::saving_or_closing_clears_the_entry_and_nothing_is_written_for_clean_documents"] -notes = "" +notes = "Every 60 seconds the app writes a snapshot of each changed document to a recovery folder (the desktop app turns it on in apps/pdfcraft/src/main.rs), offers recovery after a crash, and removes the entry on save or close. Missing: it is a recovery copy, never the user's own file, and the browser build was not checked." [[feature]] id = "core.crash-recovery" @@ -563,7 +563,7 @@ status = "shipped" commands = [] tools = [] evidence = ["crates/ui-egui/tests/it/recovery.rs::encrypted_documents_are_autosaved_encrypted_and_recovered_with_the_password"] -notes = "" +notes = "Snapshots of encrypted documents are written with the document's encryption policy, so document content stays encrypted (metadata stays unencrypted when /EncryptMetadata is false), and recovery asks for the password. The cited UI test covers one AES case; whether other methods and owner-only restricted files recover the same way is not tested." [[feature]] id = "core.web-recovery" @@ -599,7 +599,7 @@ status = "shipped" commands = [] tools = [] evidence = ["crates/ui-egui/tests/it/editing.rs::organize_delete_then_undo_and_redo_with_keys", "crates/engine/src/tests.rs::edits_update_view_data_and_mark_dirty"] -notes = "" +notes = "The tab strip reads each document's dirty flag (crates/ui-egui/src/chrome.rs) and the flag follows edits, undo to the saved state and saves. The cited tests check the flag, not the drawn marker; the marker itself was not checked by a test or screenshot for this note." [[feature]] id = "core.close-document" @@ -611,7 +611,7 @@ status = "shipped" commands = ["file.close"] tools = ["doc_close"] evidence = ["crates/ui-egui/tests/it/editing.rs::clean_tabs_close_without_asking"] -notes = "" +notes = "File Close and Cmd/Ctrl+W close a clean tab at once, and a document with unsaved changes asks to save first; doc_close fails unless discard_changes is set. The cited test covers only a clean tab; the prompt is covered by other tests (quitting_with_unsaved_changes_asks_for_each_document) not cited here." [[feature]] id = "core.close-all" @@ -694,7 +694,7 @@ milestone = "M3" status = "shipped" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/it/editing.rs::the_last_session_reopens_its_files_pages_and_active_tab", "crates/ui-egui/tests/it/editing.rs::quitting_with_unsaved_changes_still_remembers_every_file", "crates/ui-egui/tests/it/editing.rs::the_last_session_is_off_by_default_and_skips_missing_and_duplicate_files", "crates/ui-egui/src/last_session.rs::malformed_sessions_are_skipped_and_capped"] +evidence = ["crates/ui-egui/tests/it/editing.rs::the_last_session_reopens_its_files_pages_and_active_tab", "crates/ui-egui/tests/it/editing.rs::quitting_with_unsaved_changes_still_remembers_every_file", "crates/ui-egui/tests/it/editing.rs::the_last_session_is_off_by_default_and_skips_missing_and_duplicate_files", "crates/ui-egui/src/persist/last_session.rs::malformed_sessions_are_skipped_and_capped"] notes = "Desktop, off by default (Preferences ▸ Documents and view, #442): the files open when PdfCraft last closed reopen at the page each showed, with the same tab in front; files named on the command line open after them, in front; files that are gone, and documents the Recovery dialog offers, are skipped. Quitting with unsaved changes still remembers every tab. Zoom and page display come back through core.restore-last-view (#1767); not yet the scroll position within the page (#418); only the first encrypted file asks for its password (later ones stay closed), and a file opened after it dismisses that prompt; a save that fails partway through a quit can leave the remembered list from that quit; never-saved documents and the web build have no paths to reopen." [[feature]] @@ -734,7 +734,7 @@ milestone = "M2" status = "partial" commands = [] tools = ["page_render"] -evidence = ["crates/render/src/raster.rs::renders_a_blue_rectangle", "crates/automation/tests/it/automation.rs::open_inspect_render_and_find", "crates/render/src/raster.rs::user_unit_scales_the_page_and_everything_on_it"] +evidence = ["crates/render/src/raster/tests.rs::renders_a_blue_rectangle", "crates/automation/tests/it/automation.rs::open_inspect_render_and_find", "crates/render/src/raster/tests.rs::user_unit_scales_the_page_and_everything_on_it"] notes = "hayro bootstrap renderer; own DisplayList devices in M2.6. Pages with /UserUnit are shown at their size in points, as Acrobat reads the value" [[feature]] @@ -746,7 +746,7 @@ milestone = "M2" status = "partial" commands = [] tools = [] -evidence = ["crates/render/src/raster.rs::self_referencing_tiling_pattern_terminates", "crates/render/tests/it/tiling_cell_placement.rs::far_cells_of_a_fill_sit_at_whole_steps", "crates/render/tests/it/tiling_cell_placement.rs::far_cells_sit_at_whole_steps_under_a_turned_scaled_matrix", "crates/render/tests/it/tiling_cell_placement.rs::far_cells_of_a_stroke_sit_at_whole_steps"] +evidence = ["crates/render/src/raster/tests.rs::self_referencing_tiling_pattern_terminates", "crates/render/tests/it/tiling_cell_placement.rs::far_cells_of_a_fill_sit_at_whole_steps", "crates/render/tests/it/tiling_cell_placement.rs::far_cells_sit_at_whole_steps_under_a_turned_scaled_matrix", "crates/render/tests/it/tiling_cell_placement.rs::far_cells_of_a_stroke_sit_at_whole_steps"] notes = "via hayro bootstrap. Tiling pattern cells sit at whole multiples of /XStep and /YStep at any zoom, filled or stroked, under any pattern matrix. Missing: a cell is cut at its step, so content reaching past the step into the next cell is lost; a cell pixmap is capped at 3000 pixels a side, so very large steps draw at reduced resolution" [[feature]] @@ -758,7 +758,7 @@ milestone = "M2" status = "partial" commands = [] tools = [] -evidence = ["crates/render/src/raster.rs::soft_mask_without_group_colour_space_is_applied", "crates/render/src/raster.rs::a_multiply_highlight_inside_a_transparency_group_keeps_the_text", "crates/render/tests/it/luminosity_group_space.rs::black_ink_in_a_device_cmyk_group_masks_fully_and_no_ink_shows_fully"] +evidence = ["crates/render/src/raster/tests.rs::soft_mask_without_group_colour_space_is_applied", "crates/render/src/raster/tests.rs::a_multiply_highlight_inside_a_transparency_group_keeps_the_text", "crates/render/tests/it/luminosity_group_space.rs::black_ink_in_a_device_cmyk_group_masks_fully_and_no_ink_shows_fully"] notes = "via hayro bootstrap. A luminosity soft mask whose group is in DeviceCMYK reads its flat CMYK colours and backdrop as their gray, 1 - min(1, 0.3 c + 0.59 m + 0.11 y + k) (ISO 32000-2 §11.5.3), so 100% K masks fully; CMYK images, shadings and tiling patterns inside such a mask still go through the display profile. A transparency group that is neither isolated nor knockout and is entered at full opacity, with no soft mask and Normal blending, is drawn straight onto the page, so a blend mode inside it (the Multiply of a highlight appearance written by other editors) sees the page content (#1968). Missing: a non-isolated group that needs a layer (partial opacity, a soft mask or a blend mode on the group itself) still composites its elements against a blank backdrop, and knockout groups are drawn as ordinary ones" [[feature]] @@ -794,7 +794,7 @@ milestone = "M2" status = "partial" commands = [] tools = [] -evidence = ["crates/render/src/raster.rs::huge_cid_width_ranges_terminate", "crates/render/src/raster.rs::non_embedded_japanese_cid_fonts_draw_with_a_craft_fonts_face", "crates/render/src/raster.rs::mincho_substitute_covers_half_width_katakana", "crates/render/src/text.rs::identity_to_unicode_names_give_text", "crates/fonts/src/pdf.rs::identity_to_unicode_names_read_codes_as_unicode"] +evidence = ["crates/render/src/raster/tests.rs::huge_cid_width_ranges_terminate", "crates/render/src/raster/tests.rs::non_embedded_japanese_cid_fonts_draw_with_a_craft_fonts_face", "crates/render/src/raster/tests.rs::mincho_substitute_covers_half_width_katakana", "crates/render/src/text/tests.rs::identity_to_unicode_names_give_text", "crates/fonts/src/pdf/tests.rs::identity_to_unicode_names_read_codes_as_unicode"] notes = "CID-to-Unicode tables are M2.4; a non-embedded Adobe-Japan1 font is drawn in a craft-fonts Japanese face (Mincho or Gothic by name, else by the descendant font descriptor's Serif flag; bold Gothic for a Bold name or a `/FontWeight` of 600 or more; BIZ UDMincho for Mincho, which covers half-width katakana) when built with craft-fonts; non-embedded GB1, CNS1 and Korea1 fonts still draw nothing (no allowed face); a `/ToUnicode` given as the name `/Identity-H` or `/Identity-V` (WSCAD) reads codes as Unicode for selection, search and editing, without control characters" [[feature]] @@ -818,7 +818,7 @@ milestone = "M2" status = "shipped" commands = [] tools = [] -evidence = ["crates/render/src/raster.rs::widget_appearance_states", "crates/render/src/raster.rs::highlights_without_an_appearance_are_drawn", "crates/engine/src/tests.rs::comments_without_appearances_are_drawn_but_not_saved", "crates/engine/src/tests.rs::cli_display_adds_missing_appearances_without_changing_the_source", "crates/engine/src/tests.rs::fields_left_to_the_viewer_are_displayed_from_their_values", "crates/forms/src/tests.rs::need_appearances_draws_every_text_and_choice_field_in_the_display_copy", "crates/forms/src/tests.rs::the_field_appearance_cache_sees_every_input", "crates/engine/src/tests.rs::buttons_and_combs_left_to_the_viewer_are_displayed", "crates/forms/src/tests.rs::check_boxes_without_an_appearance_show_their_caption_symbol", "crates/forms/src/tests.rs::push_buttons_without_an_appearance_show_their_caption_and_frame", "crates/forms/src/tests.rs::need_appearances_draws_check_boxes_and_radio_buttons_from_their_captions", "crates/engine/src/tests.rs::need_appearances_buttons_are_displayed_from_their_captions", "crates/render/src/raster.rs::invisible_unknown_annotations_and_popups_paint_nothing", "crates/print/src/tests.rs::invisible_annotations_of_unknown_types_do_not_print", "crates/engine/src/tests.rs::stamps_without_appearances_draw_the_stamp_they_name", "crates/automation/tests/it/automation.rs::page_render_draws_stamps_without_an_appearance"] +evidence = ["crates/render/src/raster/tests.rs::widget_appearance_states", "crates/render/src/raster/tests.rs::highlights_without_an_appearance_are_drawn", "crates/engine/src/tests.rs::comments_without_appearances_are_drawn_but_not_saved", "crates/engine/src/tests.rs::cli_display_adds_missing_appearances_without_changing_the_source", "crates/engine/src/tests.rs::fields_left_to_the_viewer_are_displayed_from_their_values", "crates/forms/src/tests.rs::need_appearances_draws_every_text_and_choice_field_in_the_display_copy", "crates/forms/src/tests.rs::the_field_appearance_cache_sees_every_input", "crates/engine/src/tests.rs::buttons_and_combs_left_to_the_viewer_are_displayed", "crates/forms/src/tests.rs::check_boxes_without_an_appearance_show_their_caption_symbol", "crates/forms/src/tests.rs::push_buttons_without_an_appearance_show_their_caption_and_frame", "crates/forms/src/tests.rs::need_appearances_draws_check_boxes_and_radio_buttons_from_their_captions", "crates/engine/src/tests.rs::need_appearances_buttons_are_displayed_from_their_captions", "crates/render/src/raster/tests.rs::invisible_unknown_annotations_and_popups_paint_nothing", "crates/print/src/tests.rs::invisible_annotations_of_unknown_types_do_not_print", "crates/engine/src/tests.rs::stamps_without_appearances_draw_the_stamp_they_name", "crates/automation/tests/it/automation.rs::page_render_draws_stamps_without_an_appearance"] notes = "comments without /AP (FreeText, Ink, notes, stamps, shapes, markup) are drawn from their dictionaries in the page view, page_render and page images, including the CLI render command, never saved; a stamp from another program draws the stamp its /Name names (the fourteen standard names of ISO 32000-2 Table 184, Draft when there is none) or, for any other name, a grey frame with both diagonals; text and choice fields are drawn from their values when the form sets /NeedAppearances (every one) or a widget has no /N (#3470), also display-only and cached per widget (a field edit on a 1000-field /NeedAppearances form takes ~13 ms against ~7 ms on a form that needs none) (a read-only document still gets the renderer's Highlight fallback only); check boxes and radio buttons with no appearance for their /AS are drawn display-only from /MK (background, border, and when on the /MK /CA ZapfDingbats symbol in the /DA colour, a check or a dot by default), and when the form sets /NeedAppearances so is every one that has an /MK, over the appearance the file carries (ISO 32000-2 Table 224; one without /MK keeps its own), and push buttons with none from /MK and /BS with their caption; Link borders without /AP and printing are not covered yet; an annotation of a non-standard type with the Invisible flag (ISO 32000-2 Table 167) is neither drawn nor printed, and a popup's own /AP is never painted on the page (its parent's text shows in the popup window, §12.5.6.14)" [[feature]] @@ -830,8 +830,8 @@ milestone = "M3" status = "shipped" commands = [] tools = [] -evidence = ["crates/render/src/raster.rs::missing_page_and_garbage_input_fail_gracefully", "crates/render/src/raster.rs::scale_is_clamped_for_huge_pages", "crates/render/src/raster.rs::watchdog_skips_a_stuck_page_and_keeps_rendering"] -notes = "" +evidence = ["crates/render/src/raster/tests.rs::missing_page_and_garbage_input_fail_gracefully", "crates/render/src/raster/tests.rs::scale_is_clamped_for_huge_pages", "crates/render/src/raster/tests.rs::watchdog_skips_a_stuck_page_and_keeps_rendering"] +notes = "Each page render runs under catch_unwind and a 20 second watchdog (STUCK_AFTER) that answers with an error and keeps a replacement worker rendering, and failed requests are remembered so they are not retried each frame. The cited tests cover garbage input, scale clamping and the watchdog; a page that overruns shows an error, but no screenshot of what the user sees was checked." [[feature]] id = "view.render-priority" @@ -842,8 +842,8 @@ milestone = "M3" status = "shipped" commands = [] tools = [] -evidence = ["crates/render/src/raster.rs::inline_pool_renders_in_priority_order"] -notes = "" +evidence = ["crates/render/src/raster/tests.rs::inline_pool_renders_in_priority_order"] +notes = "The canvas builds the request queue with visible pages and thumbnails first and text/search work last, and the pool answers in queue order. The cited test checks only the pool's order for two queued requests; the canvas ordering itself (prepare_render_queue) is not covered by a cited test, and no timing was measured." [[feature]] id = "view.deep-zoom-tiles" @@ -854,7 +854,7 @@ milestone = "M3" status = "partial" commands = [] tools = [] -evidence = ["crates/render/src/raster.rs::tiles_match_full_render", "crates/render/src/raster.rs::effective_scale_never_exceeds_the_caps", "crates/ui-egui/tests/it/view.rs::returning_to_a_visited_zoom_reuses_its_cached_tiles", "crates/ui-egui/tests/it/view.rs::zooming_a_tiled_page_shows_its_earlier_tiles_until_new_ones_arrive"] +evidence = ["crates/render/src/raster/tests.rs::tiles_match_full_render", "crates/render/src/raster/tests.rs::effective_scale_never_exceeds_the_caps", "crates/ui-egui/tests/it/view.rs::returning_to_a_visited_zoom_reuses_its_cached_tiles", "crates/ui-egui/tests/it/view.rs::zooming_a_tiled_page_shows_its_earlier_tiles_until_new_ones_arrive"] notes = "capped at 8192 px per side (M3.3); tiles of a zoom already rendered stay cached while their page is on screen, so zooming away and back shows the area sharp instead of re-rendering it tile by tile (#2196)" [[feature]] @@ -866,7 +866,7 @@ milestone = "M3" status = "shipped" commands = ["view.continuous", "view.page_gaps"] tools = [] -evidence = ["crates/ui-egui/tests/it/view.rs::page_gaps_in_organizer_do_not_move_the_document_scroll_position", "crates/ui-egui/tests/it/view.rs::page_gaps_comparison_menu_preserves_the_active_panes_choice", "crates/ui-egui/tests/it/view.rs::page_gaps_comparison_control_preserves_both_states", "crates/ui-egui/tests/it/view.rs::page_gaps_stay_closed_at_fractional_device_scales", "crates/ui-egui/tests/it/view.rs::page_gaps_toggle_joins_edges_without_changing_the_document", "crates/ui-egui/tests/it/view.rs::page_gaps_default_survives_restart_and_applies_to_new_documents", "crates/ui-egui/tests/it/view.rs::page_gaps_menu_joins_pages_without_a_shadow_at_the_seam", "crates/ui-egui/tests/it/view.rs::page_gaps_work_for_mixed_sizes_and_two_page_rows", "crates/ui-egui/tests/it/view.rs::continuous_layout_stacks_pages_vertically", "crates/ui-egui/tests/it/view.rs::page_display_commands_switch_layouts", "crates/ui-egui/tests/it/view.rs::default_page_display_is_used_for_new_documents", "crates/ui-egui/src/canvas.rs::fit_page_and_height_hold_still_while_mixed_page_sizes_scroll_past", "crates/ui-egui/tests/it/view.rs::touchpad_swipe_glides_on_after_the_fingers_lift_and_stops", "crates/ui-egui/tests/it/view.rs::touching_the_touchpad_again_or_turning_animations_off_stops_the_glide", "crates/ui-egui/tests/it/view.rs::rapid_wheel_notches_accelerate_and_follow_touchpad_input", "crates/ui-egui/tests/it/view.rs::changing_continuous_layout_resets_wheel_acceleration"] +evidence = ["crates/ui-egui/tests/it/view.rs::page_gaps_in_organizer_do_not_move_the_document_scroll_position", "crates/ui-egui/tests/it/view.rs::page_gaps_comparison_menu_preserves_the_active_panes_choice", "crates/ui-egui/tests/it/view.rs::page_gaps_comparison_control_preserves_both_states", "crates/ui-egui/tests/it/view.rs::page_gaps_stay_closed_at_fractional_device_scales", "crates/ui-egui/tests/it/view.rs::page_gaps_toggle_joins_edges_without_changing_the_document", "crates/ui-egui/tests/it/view.rs::page_gaps_default_survives_restart_and_applies_to_new_documents", "crates/ui-egui/tests/it/view.rs::page_gaps_menu_joins_pages_without_a_shadow_at_the_seam", "crates/ui-egui/tests/it/view.rs::page_gaps_work_for_mixed_sizes_and_two_page_rows", "crates/ui-egui/tests/it/view.rs::continuous_layout_stacks_pages_vertically", "crates/ui-egui/tests/it/view.rs::page_display_commands_switch_layouts", "crates/ui-egui/tests/it/view.rs::default_page_display_is_used_for_new_documents", "crates/ui-egui/src/canvas/tests.rs::fit_page_and_height_hold_still_while_mixed_page_sizes_scroll_past", "crates/ui-egui/tests/it/view.rs::touchpad_swipe_glides_on_after_the_fingers_lift_and_stops", "crates/ui-egui/tests/it/view.rs::touching_the_touchpad_again_or_turning_animations_off_stops_the_glide", "crates/ui-egui/tests/it/view.rs::rapid_wheel_notches_accelerate_and_follow_touchpad_input", "crates/ui-egui/tests/it/view.rs::changing_continuous_layout_resets_wheel_acceleration"] notes = "Page gaps, shadows and outlines can be hidden through the page-display menu, with a persisted default in Preferences and matching UI control options; page margins in the PDF are unchanged. Continuous scrolling remains the default for PDFs that don't ask for a layout (Preferences ▸ Default page display); pages stack with no snapping. Fit page and Fit height fit the largest page, so the zoom holds still across mixed page sizes. Rapid line-wheel notches gain a bounded, constant-space arrival-rate boost (#1072); isolated notches still use the chosen Scroll speed, and page units stay unboosted. A touchpad swipe glides on after the fingers lift on Wayland (#531; macOS glides by itself; Windows and X11 report no lift through winit), unless Animation effects is off; a click, key, zoom or new scroll catches it. Not yet: fingers resting on the touchpad don't catch it (needs Wayland hold gestures, which the vendored winit doesn't bind)" [[feature]] @@ -1022,7 +1022,7 @@ milestone = "M3" status = "shipped" commands = ["view.fit_visible"] tools = [] -evidence = ["crates/ui-egui/tests/it/viewer.rs::fit_visible_zooms_to_the_content_width", "crates/ui-egui/src/zoom_snap.rs::ink_bounds_finds_the_marked_area"] +evidence = ["crates/ui-egui/tests/it/viewer.rs::fit_visible_zooms_to_the_content_width", "crates/ui-egui/src/canvas/zoom_snap.rs::ink_bounds_finds_the_marked_area"] notes = "View ▸ Zoom ▸ Fit visible (⌘3): the current page's inked area (found by a quick low-resolution render) spans the window width; respects view rotation; drivable via the control channel's command" [[feature]] @@ -1095,7 +1095,7 @@ status = "shipped" commands = [] tools = [] evidence = ["crates/ui-egui/tests/it/ui.rs::selection_works_in_every_view_rotation"] -notes = "" +notes = "View menu rotate clockwise and counter-clockwise turn the view in 90 degree steps per open tab, and the page is not changed (the /Rotate entries are untouched). The cited test checks text selection under rotation; the view rotation is not remembered after closing and has no headless tool." [[feature]] id = "view.read-mode" @@ -1190,7 +1190,7 @@ milestone = "M3" status = "shipped" commands = [] tools = ["link_open"] -evidence = ["crates/ui-egui/tests/it/view.rs::clicking_a_link_goes_to_its_destination", "crates/ui-egui/tests/it/view.rs::link_destinations_scroll_to_their_position", "crates/ui-egui/tests/it/view.rs::a_link_without_a_position_keeps_the_place_on_the_next_page", "crates/ui-egui/src/canvas.rs::a_null_xyz_coordinate_keeps_the_readers_place_on_the_new_page", "crates/render/src/inspect.rs::bookmarks_and_links_keep_their_destination_view", "crates/render/src/inspect.rs::links_read_bounded_local_remote_go_to_actions", "crates/automation/tests/it/automation.rs::local_gotor_link_open_applies_destinations_and_honors_root", "crates/ui-egui/tests/it/document_links.rs::a_local_gotor_link_opens_in_a_tab_and_applies_its_destination", "crates/ui-egui/tests/it/document_links.rs::an_absolute_gotor_waits_for_consent_and_keeps_its_destination_across_tabs", "crates/ui-egui/tests/it/document_links.rs::cancelling_an_absolute_gotor_by_button_escape_or_dismissal_changes_nothing", "crates/automation/tests/it/automation.rs::absolute_gotor_consent_does_not_bypass_validation_or_passwords", "crates/render/src/inspect.rs::launch_actions_that_name_a_pdf_open_it_like_a_remote_link", "crates/automation/tests/it/automation.rs::a_launch_link_to_a_pdf_opens_it_like_a_remote_link"] +evidence = ["crates/ui-egui/tests/it/view.rs::clicking_a_link_goes_to_its_destination", "crates/ui-egui/tests/it/view.rs::link_destinations_scroll_to_their_position", "crates/ui-egui/tests/it/view.rs::a_link_without_a_position_keeps_the_place_on_the_next_page", "crates/ui-egui/src/canvas/tests.rs::a_null_xyz_coordinate_keeps_the_readers_place_on_the_new_page", "crates/render/src/inspect/tests.rs::bookmarks_and_links_keep_their_destination_view", "crates/render/src/inspect/tests.rs::links_read_bounded_local_remote_go_to_actions", "crates/automation/tests/it/automation.rs::local_gotor_link_open_applies_destinations_and_honors_root", "crates/ui-egui/tests/it/document_links.rs::a_local_gotor_link_opens_in_a_tab_and_applies_its_destination", "crates/ui-egui/tests/it/document_links.rs::an_absolute_gotor_waits_for_consent_and_keeps_its_destination_across_tabs", "crates/ui-egui/tests/it/document_links.rs::cancelling_an_absolute_gotor_by_button_escape_or_dismissal_changes_nothing", "crates/automation/tests/it/automation.rs::absolute_gotor_consent_does_not_bypass_validation_or_passwords", "crates/render/src/inspect/tests.rs::launch_actions_that_name_a_pdf_open_it_like_a_remote_link", "crates/automation/tests/it/automation.rs::a_launch_link_to_a_pdf_opens_it_like_a_remote_link"] notes = "GoTo and URI links plus local-PDF GoToR links: source-relative or local absolute /F paths, /UF preferred in file-spec dictionaries, explicit numeric page/view destinations, and internal tabs. Absolute local GoToR targets require desktop Allow/Cancel confirmation or an explicit allow_absolute opt-in in link_open; relative links open normally. link_open honors --root. Named remote destinations open at default view with a warning; /NewWindow is a tab hint. A Launch action whose /F names a .pdf file (and has no /Win launch parameters) opens that PDF the same way, backslash separators read as / (#4168); URL/network file systems, GoToE, other Launch actions and other actions are not executed. Same-document destinations retain their supported /XYZ, /Fit, /FitH, /FitV and /FitR views; /FitB* is read as whole-page fit. A null /XYZ left or top keeps the reader's place, as ISO 32000-2 §12.3.2.2 says and Acrobat Reader was observed to do (synthetic fixture): the user-space point at the window's top-left stays there on the target page at the same zoom, along each axis the page overflows; CAD exports (WSCAD) write every cross-reference this way. Null /FitH top and /FitV left still go to the page edge." [[feature]] @@ -1214,7 +1214,7 @@ milestone = "M3" status = "shipped" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/it/control.rs::page_size_shows_while_the_pointer_is_in_the_bottom_left_quadrant", "crates/ui-egui/src/canvas.rs::page_size_hint_zone_is_the_bottom_left_quadrant"] +evidence = ["crates/ui-egui/tests/it/control.rs::page_size_shows_while_the_pointer_is_in_the_bottom_left_quadrant", "crates/ui-egui/src/canvas/tests.rs::page_size_hint_zone_is_the_bottom_left_quadrant"] notes = "While the pointer is in the bottom-left quadrant of the page view, a small label shows the current page's size (after /Rotate and /UserUnit) in the Preferences unit, formatted as in Document Properties (#1855). It never takes clicks and hides when the pointer leaves. Acrobat's trigger is the corner itself and may differ in exact area and wording (not compared with real Acrobat); the size of the current page only, not of the page under the pointer; no headless tool (page sizes are in document info)." [[feature]] @@ -1322,7 +1322,7 @@ milestone = "M3" status = "shipped" commands = [] tools = ["doc_info"] -evidence = ["crates/render/src/inspect.rs::attachments_of_both_kinds_are_listed_and_extracted", "crates/ui-egui/tests/it/attachment_row.rs::the_right_click_menu_opens_saves_and_deletes", "crates/ui-egui/tests/it/attachment_row.rs::double_clicking_a_pdf_attachment_opens_it_in_a_new_tab"] +evidence = ["crates/render/src/inspect/tests.rs::attachments_of_both_kinds_are_listed_and_extracted", "crates/ui-egui/tests/it/attachment_row.rs::the_right_click_menu_opens_saves_and_deletes", "crates/ui-egui/tests/it/attachment_row.rs::double_clicking_a_pdf_attachment_opens_it_in_a_new_tab"] notes = "Each row has Save, Open in a new tab (PDFs) and Delete (the document's own attachments) buttons, the same items on a right-click, and a double-click that opens a PDF in a new tab or saves any other file (#3466). Not yet: Acrobat's Search attachments, and opening a non-PDF attachment in another program (it is saved instead)." [[feature]] @@ -1334,7 +1334,7 @@ milestone = "M3" status = "shipped" commands = [] tools = ["doc_info", "layer_set"] -evidence = ["crates/render/src/raster.rs::layer_override_hides_content", "crates/engine/src/tests.rs::layer_choices_survive_edits", "crates/automation/tests/it/automation.rs::layers_and_hidden_comments_change_what_page_render_draws"] +evidence = ["crates/render/src/raster/tests.rs::layer_override_hides_content", "crates/engine/src/tests.rs::layer_choices_survive_edits", "crates/automation/tests/it/automation.rs::layers_and_hidden_comments_change_what_page_render_draws"] notes = "layer_set shows or hides a layer by name (every group with that name, as CAD exporters repeat names) or by position, and page_render draws with the document's layer visibility. Still missing: Reset to initial visibility (view.layers-reset), layer properties, and /OC on annotations (#2176)." [[feature]] @@ -1346,7 +1346,7 @@ milestone = "M12" status = "partial" commands = [] tools = ["doc_info"] -evidence = ["crates/engine/src/tests.rs::reset_layers_returns_to_the_files_visibility", "crates/render/src/inspect.rs::initial_layer_visibility_is_the_default_configuration"] +evidence = ["crates/engine/src/tests.rs::reset_layers_returns_to_the_files_visibility", "crates/render/src/inspect/tests.rs::initial_layer_visibility_is_the_default_configuration"] notes = "Layers panel ▸ Layer options ▸ Reset to Initial Visibility shows each layer as the default configuration (/OCProperties /D) has it; doc_info reports it as `initial`. Not yet: listing the layers of all pages or the current page only." [[feature]] @@ -1394,8 +1394,8 @@ milestone = "M3" status = "shipped" commands = [] tools = ["doc_info"] -evidence = ["crates/render/src/inspect.rs::roman_and_alpha_labels", "crates/automation/tests/it/automation.rs::open_inspect_render_and_find"] -notes = "" +evidence = ["crates/render/src/inspect/tests.rs::roman_and_alpha_labels", "crates/automation/tests/it/automation.rs::open_inspect_render_and_find"] +notes = "Page labels from /PageLabels (decimal, roman, letters, prefixes) show in the page box and thumbnails (long labels are cut to the column), and doc_info lists them. The cited tests cover roman and alpha numbering and the tool listing; labels in a nested number tree and in the page-input box are not covered by them." [[feature]] id = "view.find" @@ -1406,7 +1406,7 @@ milestone = "M3" status = "shipped" commands = ["edit.find"] tools = ["text_find"] -evidence = ["crates/ui-egui/tests/it/ui.rs::find_counts_matches_across_pages", "crates/ui-egui/tests/it/ui.rs::find_reports_no_matches", "crates/automation/tests/it/automation.rs::open_inspect_render_and_find", "crates/render/src/text.rs::identity_v_columns_are_searchable_in_reading_order", "crates/render/src/text.rs::toc_leaders_do_not_hide_word_gaps", "crates/render/src/text.rs::short_tracked_ellipses_remain_searchable"] +evidence = ["crates/ui-egui/tests/it/ui.rs::find_counts_matches_across_pages", "crates/ui-egui/tests/it/ui.rs::find_reports_no_matches", "crates/automation/tests/it/automation.rs::open_inspect_render_and_find", "crates/render/src/text/tests.rs::identity_v_columns_are_searchable_in_reading_order", "crates/render/src/text/tests.rs::toc_leaders_do_not_hide_word_gaps", "crates/render/src/text/tests.rs::short_tracked_ellipses_remain_searchable"] notes = "Horizontal and WMode 1 vertical text are grouped independently, including a horizontal heading above right-to-left vertical columns. Dotted TOC leaders preserve word spaces for phrase search (#3086), while short tracked ellipses remain intact and searchable; sparse lines and condensed OCR gaps below the existing height-based threshold remain limitations. Complex interleaved flows still use a geometric reading-order heuristic." [[feature]] @@ -1538,7 +1538,7 @@ milestone = "M3" status = "shipped" commands = ["edit.column_select"] tools = ["text_extract"] -evidence = ["crates/render/src/text.rs::a_column_selection_takes_one_table_column", "crates/render/src/text.rs::a_column_selection_across_columns_reads_row_by_row", "crates/render/src/text.rs::right_to_left_rows_read_right_to_left", "crates/render/src/text.rs::vertical_lines_keep_reading_order", "crates/ui-egui/tests/it/column_select.rs::alt_drag_selects_only_the_text_inside_the_rectangle", "crates/ui-egui/tests/it/column_select.rs::the_column_select_tool_needs_no_modifier", "crates/ui-egui/tests/it/column_select.rs::alt_drag_with_the_highlighter_marks_only_the_column", "crates/automation/tests/it/automation.rs::text_extract_with_a_rect_takes_one_column"] +evidence = ["crates/render/src/text/tests.rs::a_column_selection_takes_one_table_column", "crates/render/src/text/tests.rs::a_column_selection_across_columns_reads_row_by_row", "crates/render/src/text/tests.rs::right_to_left_rows_read_right_to_left", "crates/render/src/text/tests.rs::vertical_lines_keep_reading_order", "crates/ui-egui/tests/it/column_select.rs::alt_drag_selects_only_the_text_inside_the_rectangle", "crates/ui-egui/tests/it/column_select.rs::the_column_select_tool_needs_no_modifier", "crates/ui-egui/tests/it/column_select.rs::alt_drag_with_the_highlighter_marks_only_the_column", "crates/automation/tests/it/automation.rs::text_extract_with_a_rect_takes_one_column"] notes = "Issue #740. Holding Alt (Option on macOS) when a drag starts selects only the text inside the rectangle drawn, with the Select tool, the text markup tools and Redact (with Highlight and Redact the drag must start on text, since off text they draw an area box). Edit ▸ Column select does the same with a plain drag. A glyph counts when its centre is inside. Copy gives one row per visual line, top to bottom; pieces side by side (table cells) are separated by a tab, right to left in right-to-left rows; vertical text keeps reading order. text_extract takes the same rectangle as rect. Platforms: on Linux desktops whose window manager moves windows with Alt-drag (Xfce, Cinnamon and MATE by default; KDE Plasma 5 and other setups with Alt as the window modifier; GNOME and Plasma 6 use Super) the drag never reaches PdfCraft, so use Edit ▸ Column select or change that setting. On Windows, releasing Alt does not open a system menu (the window has no native menu). The web build has the same Linux caveat in the browser. Checked by hand on Linux (Fedora) with the Column select tool; Alt/Option-drag, and macOS and Windows generally, rest on the kittest tests. Missing: selection across pages, ⇧-click extending a column, a default shortcut, and a check of Acrobat's own copied-text format (not observed)." [[feature]] @@ -1562,7 +1562,7 @@ milestone = "M3" status = "shipped" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/it/viewer.rs::view_history_select_all_and_find_options", "crates/ui-egui/src/canvas.rs::select_all_copies_the_whole_document_while_scrolling_continuously", "crates/ui-egui/src/canvas.rs::select_all_of_the_document_only_while_scrolling_through_several_pages"] +evidence = ["crates/ui-egui/tests/it/viewer.rs::view_history_select_all_and_find_options", "crates/ui-egui/src/canvas/tests.rs::select_all_copies_the_whole_document_while_scrolling_continuously", "crates/ui-egui/src/canvas/tests.rs::select_all_of_the_document_only_while_scrolling_through_several_pages"] notes = "⌘A: the current page one page at a time; the whole document scrolling continuously (#2639), copied whole" [[feature]] @@ -1670,7 +1670,7 @@ milestone = "M3" status = "shipped" commands = ["view.theme", "view.theme.light", "view.theme.dark"] tools = [] -evidence = ["crates/ui-egui/tests/it/theme.rs::toolbar_and_view_menu_share_three_state_preferences", "crates/ui-egui/src/theme.rs::text_is_readable_on_every_surface"] +evidence = ["crates/ui-egui/tests/it/theme.rs::toolbar_and_view_menu_share_three_state_preferences", "crates/ui-egui/src/theme/tests.rs::text_is_readable_on_every_surface"] notes = "Toolbar and Display theme share three choices and a preference setter; manual choices exit system mode. Every text colour meets WCAG AA (4.5:1) on every surface in both themes; glyphs are pixel-aligned for sharper text at 100–150 % scaling." [[feature]] @@ -1878,7 +1878,7 @@ milestone = "M7" status = "partial" commands = ["edit.edit_text"] tools = ["text_lines", "text_paragraphs", "text_edit", "text_edit_layout", "text_font_files", "text_font_import", "text_fonts"] -evidence = ["crates/edit/src/tests.rs::paragraphs_are_found_and_rewrapped", "crates/edit/src/tests.rs::text_lines_are_found_and_replaced_in_place", "crates/ui-egui/tests/it/editing.rs::editing_existing_text_in_place", "crates/automation/tests/it/automation.rs::editing_existing_text_through_tools", "crates/edit/src/tests.rs::a_line_whose_operator_starts_the_next_stream_can_be_edited", "crates/automation/tests/it/automation.rs::a_line_split_across_content_streams_is_listed_and_edited", "crates/ui-egui/tests/it/editing.rs::selected_text_formatting_keeps_the_rest_of_the_paragraph_and_reopens", "crates/edit/src/tests.rs::marked_paragraph_boundaries_and_cross_stream_transforms_survive_rewrite", "crates/automation/tests/it/automation.rs::explicit_font_and_disjoint_ranges_save_reopen_through_tools", "crates/edit/src/tests.rs::disjoint_style_ranges_preserve_unselected_runs_and_validate_utf8_atomically", "crates/engine/tests/it/document_fonts.rs::opentype_cff_document_font_preserves_program_codes_and_rendering_after_reopen", "crates/ui-egui/tests/it/editing.rs::cropped_images_do_not_intercept_text_and_stay_inside_each_page", "crates/automation/tests/it/automation.rs::clipped_image_bounds_and_text_edit_save_reopen_through_tools", "crates/edit/src/tests.rs::mixed_styles_and_matrix_sizes_form_visual_regions_without_merging_columns", "crates/edit/src/tests.rs::rich_region_edits_preserve_styles_positions_and_delete_all_runs_after_reopen", "crates/ui-egui/tests/it/editing.rs::one_editor_contains_mixed_styles_and_visual_rows", "crates/ui-egui/tests/it/editing.rs::inline_editor_preserves_source_font_and_previews_live_bounds", "crates/fonts/src/pdf.rs::mac_subset_cmap_uses_pdf_unicode_without_changing_source_program", "crates/automation/tests/it/automation.rs::mixed_visual_region_edit_preview_save_reopen_and_undo_through_tools", "crates/ui-egui/tests/it/ui.rs::the_edit_tab_turns_on_edit_text_and_images", "crates/ui-egui/tests/it/editing.rs::the_caret_on_a_small_row_is_as_tall_as_that_row", "crates/edit/src/tests.rs::uzbek_and_kazakh_cyrillic_use_the_bundled_latin_face", "crates/automation/tests/it/automation.rs::editing_uzbek_and_kazakh_cyrillic_through_tools", "crates/edit/src/tests.rs::styled_paragraph_batches_cyrillic_without_changing_source_fonts", "crates/edit/src/tests.rs::styled_paragraph_over_240_fallback_glyphs_splits_and_refuses_atomically", "crates/edit/src/tests.rs::styled_fallback_batches_keep_face_choices_and_range_styles", "crates/edit/src/tests.rs::styled_fallback_shards_keep_text_advances_and_alignment", "crates/edit/src/tests.rs::styled_fallback_budget_is_enforced_before_objects_are_written", "crates/edit/src/tests.rs::styled_fallback_same_base_font_and_character_keep_both_weights", "crates/automation/tests/it/automation.rs::styled_cyrillic_fallback_is_batched_through_tools_and_reopen"] +evidence = ["crates/edit/src/tests.rs::paragraphs_are_found_and_rewrapped", "crates/edit/src/tests.rs::text_lines_are_found_and_replaced_in_place", "crates/ui-egui/tests/it/editing.rs::editing_existing_text_in_place", "crates/automation/tests/it/automation.rs::editing_existing_text_through_tools", "crates/edit/src/tests.rs::a_line_whose_operator_starts_the_next_stream_can_be_edited", "crates/automation/tests/it/automation.rs::a_line_split_across_content_streams_is_listed_and_edited", "crates/ui-egui/tests/it/editing.rs::selected_text_formatting_keeps_the_rest_of_the_paragraph_and_reopens", "crates/edit/src/tests.rs::marked_paragraph_boundaries_and_cross_stream_transforms_survive_rewrite", "crates/automation/tests/it/automation.rs::explicit_font_and_disjoint_ranges_save_reopen_through_tools", "crates/edit/src/tests.rs::disjoint_style_ranges_preserve_unselected_runs_and_validate_utf8_atomically", "crates/engine/tests/it/document_fonts.rs::opentype_cff_document_font_preserves_program_codes_and_rendering_after_reopen", "crates/ui-egui/tests/it/editing.rs::cropped_images_do_not_intercept_text_and_stay_inside_each_page", "crates/automation/tests/it/automation.rs::clipped_image_bounds_and_text_edit_save_reopen_through_tools", "crates/edit/src/tests.rs::mixed_styles_and_matrix_sizes_form_visual_regions_without_merging_columns", "crates/edit/src/tests.rs::rich_region_edits_preserve_styles_positions_and_delete_all_runs_after_reopen", "crates/ui-egui/tests/it/editing.rs::one_editor_contains_mixed_styles_and_visual_rows", "crates/ui-egui/tests/it/editing.rs::inline_editor_preserves_source_font_and_previews_live_bounds", "crates/fonts/src/pdf/tests.rs::mac_subset_cmap_uses_pdf_unicode_without_changing_source_program", "crates/automation/tests/it/automation.rs::mixed_visual_region_edit_preview_save_reopen_and_undo_through_tools", "crates/ui-egui/tests/it/ui.rs::the_edit_tab_turns_on_edit_text_and_images", "crates/ui-egui/tests/it/editing.rs::the_caret_on_a_small_row_is_as_tall_as_that_row", "crates/edit/src/tests.rs::uzbek_and_kazakh_cyrillic_use_the_bundled_latin_face", "crates/automation/tests/it/automation.rs::editing_uzbek_and_kazakh_cyrillic_through_tools", "crates/edit/src/tests.rs::styled_paragraph_batches_cyrillic_without_changing_source_fonts", "crates/edit/src/tests.rs::styled_paragraph_over_240_fallback_glyphs_splits_and_refuses_atomically", "crates/edit/src/tests.rs::styled_fallback_batches_keep_face_choices_and_range_styles", "crates/edit/src/tests.rs::styled_fallback_shards_keep_text_advances_and_alignment", "crates/edit/src/tests.rs::styled_fallback_budget_is_enforced_before_objects_are_written", "crates/edit/src/tests.rs::styled_fallback_same_base_font_and_character_keep_both_weights", "crates/automation/tests/it/automation.rs::styled_cyrillic_fallback_is_batched_through_tools_and_reopen"] notes = "The Edit tab turns Edit text & images on at once, as in Acrobat (leaving the tab puts the tool down and applies an edit in progress); click a paragraph box and retype it in place (font reused or Helvetica substituted, colour and spacing kept, rewrapped); a page's /Contents pieces are parsed joined, so a line whose operands and operator sit in different pieces is listed and edited (lines and paragraphs still don't join across pieces; Edit image still parses each piece alone); drag a box to move it or its right-edge handle to rewrap it (edit.text-move-resize); per-character formatting changes are next Since #1116: mixed-style visual regions retain source rows, subset encodings, colour and baseline anchors in one textless-background editor. Explicit paragraph/heading scopes constrain grouping; compatible graphics planes permit cross-stream regions. Full matching document fonts complete absent subset glyphs; installed/file selection, TrueType/OpenType-CFF embedding and disjoint UTF-8 range styles have save/reopen coverage. Single-face OpenType shaping/bidi retains logical text through ToUnicode/ActualText. Replacement text the paragraph's font can't show takes the craft-fonts Japanese faces when they draw all of it (Uzbek қ, Kazakh ә are in none of them), else the bundled Inter face, so those letters write in every build (#2593). Legacy non-SFNT preview, mixed-font/style contextual runs, full structure-tree preservation and author intent in untagged PDFs remain limited; Styled-path outline fallback batches by actual face with 240 glyphs per font and preserves source styles; adjacent equal-font/style characters form runs. At most 15360 distinct fallback glyphs per styled paragraph; missing glyphs are refused before fallback objects are written. Glyph coverage, mixed contextual shaping, single-style fallback and stale-font cleanup are unchanged; see docs/editing-existing-text.md" [[feature]] @@ -1926,7 +1926,7 @@ milestone = "M7" status = "shipped" commands = ["edit.edit_text"] tools = ["text_edit"] -evidence = ["crates/edit/src/tests.rs::missing_glyphs_substitute_helvetica_and_impossible_text_is_refused", "crates/edit/src/tests.rs::a_bold_stand_in_font_leaves_an_earlier_regular_one_alone", "crates/fonts/src/pdf.rs::subset_fonts_only_encode_the_glyphs_they_have", "crates/fonts/src/pdf.rs::composite_fonts_use_to_unicode"] +evidence = ["crates/edit/src/tests.rs::missing_glyphs_substitute_helvetica_and_impossible_text_is_refused", "crates/edit/src/tests.rs::a_bold_stand_in_font_leaves_an_earlier_regular_one_alone", "crates/fonts/src/pdf/tests.rs::subset_fonts_only_encode_the_glyphs_they_have", "crates/fonts/src/pdf/tests.rs::composite_fonts_use_to_unicode"] notes = "the line's font is reused when every character has a code and a glyph (ToUnicode, encodings, Differences; subset glyph checks); otherwise a standard face of the source font's family (Helvetica, Times or Courier, in its weight and slant; WinAnsi) is used and reported; text no font can show is refused. Each standard stand-in has a resource of its own face (PCEdTimesBold, ...), so a bold paragraph's stand-in no longer turns paragraphs set earlier in a regular one bold (#4280); pages already saved with the shared PCEdHelv name by older builds are not repaired" [[feature]] @@ -2351,7 +2351,7 @@ milestone = "M12" status = "partial" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/it/layer_actions.rs::links_set_layer_visibility", "crates/ui-egui/tests/it/layer_actions.rs::push_buttons_set_layer_visibility", "crates/engine/src/tests.rs::set_layer_actions_change_what_renders", "crates/engine/src/tests.rs::set_layer_actions_toggle_in_order_and_keep_radio_groups", "crates/render/src/inspect.rs::links_read_set_layer_actions", "crates/forms/src/tests.rs::push_buttons_read_set_layer_actions"] +evidence = ["crates/ui-egui/tests/it/layer_actions.rs::links_set_layer_visibility", "crates/ui-egui/tests/it/layer_actions.rs::push_buttons_set_layer_visibility", "crates/engine/src/tests.rs::set_layer_actions_change_what_renders", "crates/engine/src/tests.rs::set_layer_actions_toggle_in_order_and_keep_radio_groups", "crates/render/src/inspect/tests.rs::links_read_set_layer_actions", "crates/forms/src/tests.rs::push_buttons_read_set_layer_actions"] notes = "Links and push buttons run it: ON, OFF and Toggle in order, by optional content group, and with /PreserveRB (the default) a layer turned on turns off the rest of its /RBGroups group. Like the Layers panel it changes the view, not the document (no undo step, not saved). Not yet: creating the action (Link Properties, a field's Actions), bookmarks and other triggers, /Next chains, JavaScript ocg.state, and the Layers panel keeping radio-button groups itself." [[feature]] @@ -2459,7 +2459,7 @@ milestone = "M7" status = "shipped" commands = ["edit.header_footer"] tools = ["doc_header_footer"] -evidence = ["crates/ui-egui/src/marks_ui.rs::page_ranges_with_subsets", "crates/ui-egui/tests/it/marks.rs::header_and_footer_dialog_inserts_tokens_and_applies_to_a_subset"] +evidence = ["crates/ui-egui/src/dialogs/marks_ui.rs::page_ranges_with_subsets", "crates/ui-egui/tests/it/marks.rs::header_and_footer_dialog_inserts_tokens_and_applies_to_a_subset"] notes = "all / from–to, all / even / odd" [[feature]] @@ -2519,7 +2519,7 @@ milestone = "M7" status = "partial" commands = ["edit.watermark"] tools = ["doc_watermark"] -evidence = ["crates/edit/src/tests.rs::watermarks_rotate_fade_and_can_go_behind", "crates/edit/src/tests.rs::watermark_offset_moves_the_centre_right_and_up", "crates/edit/src/tests.rs::a_watermark_offset_that_is_not_finite_is_refused", "crates/automation/tests/it/automation.rs::watermarks_move_from_the_page_centre_through_tools", "crates/ui-egui/src/marks_ui.rs::the_watermark_edit_carries_the_offset"] +evidence = ["crates/edit/src/tests.rs::watermarks_rotate_fade_and_can_go_behind", "crates/edit/src/tests.rs::watermark_offset_moves_the_centre_right_and_up", "crates/edit/src/tests.rs::a_watermark_offset_that_is_not_finite_is_refused", "crates/automation/tests/it/automation.rs::watermarks_move_from_the_page_centre_through_tools", "crates/ui-egui/src/dialogs/marks_ui.rs::the_watermark_edit_carries_the_offset"] notes = "rotation, opacity, behind/on top, and a horizontal/vertical offset from the page centre in points (dialog and offset_x/offset_y); no offset from the page edge or in page units, and no print/screen-only variants yet" [[feature]] @@ -2847,7 +2847,7 @@ milestone = "M4" status = "shipped" commands = ["page.replace"] tools = ["page_replace"] -evidence = ["crates/organize/src/tests.rs::replace_pages_swaps_content_but_keeps_annotations", "crates/ui-egui/tests/it/editing.rs::replace_pages_dialog_swaps_page_content", "crates/ui-egui/tests/it/editing.rs::replace_button_replaces_selected_pages_in_organize", "crates/automation/tests/it/automation.rs::replacing_pages_through_tools", "crates/ui-egui/src/canvas.rs::replacing_a_page_after_layout_never_uploads_an_oversized_texture", "crates/ui-egui/src/canvas.rs::oversized_raster_results_do_not_replace_cached_textures"] +evidence = ["crates/organize/src/tests.rs::replace_pages_swaps_content_but_keeps_annotations", "crates/ui-egui/tests/it/editing.rs::replace_pages_dialog_swaps_page_content", "crates/ui-egui/tests/it/editing.rs::replace_button_replaces_selected_pages_in_organize", "crates/automation/tests/it/automation.rs::replacing_pages_through_tools", "crates/ui-egui/src/canvas/tests.rs::replacing_a_page_after_layout_never_uploads_an_oversized_texture", "crates/ui-egui/src/canvas/tests.rs::oversized_raster_results_do_not_replace_cached_textures"] notes = "Content, resources and boxes replaced; links, comments, fields and bookmarks kept. Page-list edits discard demand laid out against the previous geometry; the next frame plans bounded backdrops and tiles. Raster uploads reject empty images and dimensions beyond the current GPU texture limit before replacing cached textures (#3149). This covers the viewer's page, tile, thumbnail, organize and print-preview result paths; it does not change the renderer's output resolution or add a global GPU memory budget." [[feature]] @@ -3051,7 +3051,7 @@ milestone = "M4" status = "shipped" commands = ["page.combine"] tools = ["doc_combine"] -evidence = ["crates/ui-egui/tests/it/combine_grid.rs::combine_shows_a_grid_by_default_and_the_view_is_kept_in_the_settings", "crates/ui-egui/tests/it/combine_grid.rs::cards_select_like_the_list_and_the_selection_survives_switching_views", "crates/ui-egui/tests/it/combine_grid.rs::dragging_a_card_moves_it_and_undo_puts_it_back", "crates/ui-egui/tests/it/combine_grid.rs::a_dragged_selection_moves_together_to_the_first_and_last_gaps", "crates/ui-egui/tests/it/combine_grid.rs::thumbnails_show_the_first_page_each_file_takes", "crates/ui-egui/tests/it/combine_grid.rs::many_files_render_only_what_is_in_view_a_few_at_a_time", "crates/ui-egui/tests/it/combine_grid.rs::long_thin_pages_get_thumbnails_within_the_size_limits", "crates/ui-egui/tests/it/combine_grid.rs::combine_follows_the_order_shown_in_the_grid", "crates/ui-egui/src/combine_grid.rs::render_threads_never_exceed_three_while_files_come_and_go", "crates/ui-egui/src/combine_grid.rs::textures_over_budget_go_longest_unseen_first_and_those_in_view_stay", "crates/ui-egui/src/combine_grid.rs::on_the_web_one_thumbnail_waits_at_a_time", "crates/ui-egui/tests/it/combine_grid.rs::a_file_whose_pages_cant_be_measured_says_there_is_no_preview", "crates/ui-egui/tests/it/combine_grid.rs::the_toolbar_sizes_the_cards_within_limits_and_the_size_is_kept_in_the_settings", "crates/ui-egui/src/combine_grid.rs::the_cards_zoom_far_enough_to_read_a_value", "crates/ui-egui/tests/it/combine_grid.rs::ctrl_keys_and_ctrl_wheel_size_the_cards_and_leave_the_window_alone", "crates/ui-egui/tests/it/combine_grid.rs::zooming_keeps_the_card_under_the_pointer_in_place_and_thumbnails_never_go_blank", "crates/ui-egui/tests/it/combine_grid.rs::zooming_quickly_never_runs_more_than_three_render_threads", "crates/ui-egui/tests/it/combine_grid.rs::hovering_a_card_shows_its_trash_and_magnifier_and_only_then", "crates/ui-egui/tests/it/combine_grid.rs::the_card_trash_removes_only_that_file_and_undo_brings_it_back", "crates/ui-egui/tests/it/combine_grid.rs::the_magnifier_shows_the_file_large_and_flips_through_the_pages_it_adds", "crates/ui-egui/tests/it/combine_grid.rs::the_preview_closes_when_its_file_leaves_and_keeps_to_three_render_threads", "crates/ui-egui/tests/it/combine_grid.rs::a_right_click_on_a_card_offers_preview_and_remove", "crates/ui-egui/tests/it/combine_grid.rs::space_shows_the_selected_file_large_in_the_grid_and_the_list", "crates/ui-egui/tests/it/combine_grid.rs::expanding_a_file_shows_the_pages_it_adds_in_order_with_their_numbers", "crates/ui-egui/tests/it/combine_grid.rs::expand_all_and_collapse_all_from_the_toolbar_and_a_right_click", "crates/ui-egui/tests/it/combine_grid.rs::expanding_is_a_view_kept_across_views_and_zoom_and_never_an_undo_step", "crates/ui-egui/tests/it/combine_grid.rs::dragging_a_page_moves_that_page_even_between_another_files_pages", "crates/ui-egui/tests/it/combine_grid.rs::combining_with_files_expanded_gives_the_same_document", "crates/ui-egui/tests/it/combine_grid.rs::a_page_click_selects_that_page_and_the_arrows_step_rows_of_cards", "crates/ui-egui/tests/it/combine_grid.rs::a_page_cards_magnifier_opens_the_preview_on_that_page", "crates/ui-egui/tests/it/combine_grid.rs::a_long_file_expanded_renders_only_its_pages_in_view_within_three_threads", "crates/ui-egui/tests/it/combine_grid.rs::collapsing_brings_the_files_card_into_view", "crates/ui-egui/tests/it/combine_grid.rs::a_file_too_long_to_spread_out_offers_no_expand", "crates/ui-egui/src/combine_grid.rs::the_next_page_of_a_file_reuses_its_open_document_until_it_closes", "crates/ui-egui/tests/it/combine_grid.rs::removing_pages_from_the_hover_bar_the_toolbar_and_delete_never_touches_the_file", "crates/ui-egui/tests/it/combine_grid.rs::a_split_file_counts_once_and_combines_with_one_bookmark", "crates/ui-egui/src/combine_ui.rs::a_page_moved_between_another_files_pages_splits_its_file_and_parts_rejoin", "crates/ui-egui/src/combine_ui.rs::removing_pages_drops_emptied_parts_and_joins_what_meets", "crates/ui-egui/src/combine_ui.rs::page_lists_become_the_shortest_range", "crates/ui-egui/tests/it/combine_grid.rs::pages_picked_out_of_sight_or_before_an_undo_are_let_go_of", "crates/ui-egui/tests/it/combine_grid.rs::a_files_parts_join_when_what_was_between_them_goes_and_one_file_cannot_be_combined", "crates/organize/src/tests.rs::a_page_shown_twice_gets_its_own_annotations", "crates/engine/src/tests.rs::a_page_with_a_form_field_shown_twice_keeps_one_field", "crates/ui-egui/tests/it/combine_grid.rs::files_picked_by_automation_let_go_of_the_pages_picked"] +evidence = ["crates/ui-egui/tests/it/combine_grid.rs::combine_shows_a_grid_by_default_and_the_view_is_kept_in_the_settings", "crates/ui-egui/tests/it/combine_grid.rs::cards_select_like_the_list_and_the_selection_survives_switching_views", "crates/ui-egui/tests/it/combine_grid.rs::dragging_a_card_moves_it_and_undo_puts_it_back", "crates/ui-egui/tests/it/combine_grid.rs::a_dragged_selection_moves_together_to_the_first_and_last_gaps", "crates/ui-egui/tests/it/combine_grid.rs::thumbnails_show_the_first_page_each_file_takes", "crates/ui-egui/tests/it/combine_grid.rs::many_files_render_only_what_is_in_view_a_few_at_a_time", "crates/ui-egui/tests/it/combine_grid.rs::long_thin_pages_get_thumbnails_within_the_size_limits", "crates/ui-egui/tests/it/combine_grid.rs::combine_follows_the_order_shown_in_the_grid", "crates/ui-egui/src/workflows/combine_grid/tests.rs::render_threads_never_exceed_three_while_files_come_and_go", "crates/ui-egui/src/workflows/combine_grid/tests.rs::textures_over_budget_go_longest_unseen_first_and_those_in_view_stay", "crates/ui-egui/src/workflows/combine_grid/tests.rs::on_the_web_one_thumbnail_waits_at_a_time", "crates/ui-egui/tests/it/combine_grid.rs::a_file_whose_pages_cant_be_measured_says_there_is_no_preview", "crates/ui-egui/tests/it/combine_grid.rs::the_toolbar_sizes_the_cards_within_limits_and_the_size_is_kept_in_the_settings", "crates/ui-egui/src/workflows/combine_grid/tests.rs::the_cards_zoom_far_enough_to_read_a_value", "crates/ui-egui/tests/it/combine_grid.rs::ctrl_keys_and_ctrl_wheel_size_the_cards_and_leave_the_window_alone", "crates/ui-egui/tests/it/combine_grid.rs::zooming_keeps_the_card_under_the_pointer_in_place_and_thumbnails_never_go_blank", "crates/ui-egui/tests/it/combine_grid.rs::zooming_quickly_never_runs_more_than_three_render_threads", "crates/ui-egui/tests/it/combine_grid.rs::hovering_a_card_shows_its_trash_and_magnifier_and_only_then", "crates/ui-egui/tests/it/combine_grid.rs::the_card_trash_removes_only_that_file_and_undo_brings_it_back", "crates/ui-egui/tests/it/combine_grid.rs::the_magnifier_shows_the_file_large_and_flips_through_the_pages_it_adds", "crates/ui-egui/tests/it/combine_grid.rs::the_preview_closes_when_its_file_leaves_and_keeps_to_three_render_threads", "crates/ui-egui/tests/it/combine_grid.rs::a_right_click_on_a_card_offers_preview_and_remove", "crates/ui-egui/tests/it/combine_grid.rs::space_shows_the_selected_file_large_in_the_grid_and_the_list", "crates/ui-egui/tests/it/combine_grid.rs::expanding_a_file_shows_the_pages_it_adds_in_order_with_their_numbers", "crates/ui-egui/tests/it/combine_grid.rs::expand_all_and_collapse_all_from_the_toolbar_and_a_right_click", "crates/ui-egui/tests/it/combine_grid.rs::expanding_is_a_view_kept_across_views_and_zoom_and_never_an_undo_step", "crates/ui-egui/tests/it/combine_grid.rs::dragging_a_page_moves_that_page_even_between_another_files_pages", "crates/ui-egui/tests/it/combine_grid.rs::combining_with_files_expanded_gives_the_same_document", "crates/ui-egui/tests/it/combine_grid.rs::a_page_click_selects_that_page_and_the_arrows_step_rows_of_cards", "crates/ui-egui/tests/it/combine_grid.rs::a_page_cards_magnifier_opens_the_preview_on_that_page", "crates/ui-egui/tests/it/combine_grid.rs::a_long_file_expanded_renders_only_its_pages_in_view_within_three_threads", "crates/ui-egui/tests/it/combine_grid.rs::collapsing_brings_the_files_card_into_view", "crates/ui-egui/tests/it/combine_grid.rs::a_file_too_long_to_spread_out_offers_no_expand", "crates/ui-egui/src/workflows/combine_grid/tests.rs::the_next_page_of_a_file_reuses_its_open_document_until_it_closes", "crates/ui-egui/tests/it/combine_grid.rs::removing_pages_from_the_hover_bar_the_toolbar_and_delete_never_touches_the_file", "crates/ui-egui/tests/it/combine_grid.rs::a_split_file_counts_once_and_combines_with_one_bookmark", "crates/ui-egui/src/workflows/combine_ui/tests.rs::a_page_moved_between_another_files_pages_splits_its_file_and_parts_rejoin", "crates/ui-egui/src/workflows/combine_ui/tests.rs::removing_pages_drops_emptied_parts_and_joins_what_meets", "crates/ui-egui/src/workflows/combine_ui/tests.rs::page_lists_become_the_shortest_range", "crates/ui-egui/tests/it/combine_grid.rs::pages_picked_out_of_sight_or_before_an_undo_are_let_go_of", "crates/ui-egui/tests/it/combine_grid.rs::a_files_parts_join_when_what_was_between_them_goes_and_one_file_cannot_be_combined", "crates/organize/src/tests.rs::a_page_shown_twice_gets_its_own_annotations", "crates/engine/src/tests.rs::a_page_with_a_form_field_shown_twice_keeps_one_field", "crates/ui-egui/tests/it/combine_grid.rs::files_picked_by_automation_let_go_of_the_pages_picked"] notes = "Combine files shows a card per file (the default; Grid/List toggle kept in the settings, --combine-view grid|list): the first page the file contributes (its page range's first page), stacked when it contributes more, and its name shortened in the middle. Cards are sized from 60% to 500% (toolbar slider, buttons and percentage reset, Ctrl/⌘ +/−/0, Ctrl/⌘ with the wheel or a pinch over the grid; the buttons step 60/80/100/125/150/175/200/250/300/400/500%, kept in the settings, --combine-zoom 60..500), keeping the card under the pointer in place. 200% used to be the ceiling, which was too small to read values off an invoice while reordering the pages of a statement (#2531). Hovering a card shows its own actions along the top of its page: remove that file only (whatever else is selected; undoable) and a magnifier that shows the file large, flipping through the pages it adds (buttons, Left/Right; Esc, close or a click outside closes it), also on the card's right-click menu, and Space shows the selected file large (the way there by keyboard). Cards select (click, Ctrl, Shift, arrows by card and by row), drag to reorder (one undo step; the grid scrolls at its edges) and share the list's toolbar. Thumbnails render off the UI thread, only for cards in view, sized to both sides of the page and at most 1536 px (room for a Letter page at 500% on a 1.5 pixels-per-point screen); at most three render threads at once, counted as threads until they exit (no watchdog replacements; a page stuck for good in one step that can't be interrupted keeps its thread, and so its place, for the rest of the run); textures out of view are dropped over a 32 MB budget (those in view are kept). Locked, unreadable and bad-range files show why instead of a page, and a file whose page size can't be read (e.g. opened with an RC4 owner password) says No preview; page ranges are edited in the list. On the web a thumbnail, and the page shown large, render on the UI thread (one per frame, and only one waits at a time), so a very slow or very large page stalls that frame. A file that adds more than one page (up to 2000) expands into its pages (hover bar, right-click, or Expand all / Collapse all in the toolbar): one card per page it adds, in its range's order, each saying Page N and the file's name, on a tinted band; a click on a page selects that page, Up/Down step rows of cards, dragging a file's card moves the whole file (a drop among a file's pages goes to its nearer end), and its magnifier opens the preview on that page; expanding is a view, not an undo step, and changes nothing in the combined file; a page's render reuses its file's open document for a moment rather than reading the file again. With a file expanded, single pages are selected (click, Ctrl, Shift), dragged anywhere (a page moved between another file's pages splits its file into parts, shown as rows in the list with their own page ranges; parts side by side join again) and removed (a page card's trash, the toolbar, Delete), each one undo step, never changing the files themselves; a split file still counts and combines as one file." [[feature]] @@ -3231,7 +3231,7 @@ milestone = "M4" status = "partial" commands = [] tools = ["doc_info", "page_transition_set"] -evidence = ["crates/render/src/inspect.rs::page_transitions_and_display_times_are_read", "crates/automation/tests/it/automation.rs::doc_info_lists_page_transitions_and_display_times", "crates/organize/src/tests.rs::page_transitions_are_set_removed_and_checked", "crates/automation/tests/it/automation.rs::page_transitions_are_set_and_removed_through_the_tools"] +evidence = ["crates/render/src/inspect/tests.rs::page_transitions_and_display_times_are_read", "crates/automation/tests/it/automation.rs::doc_info_lists_page_transitions_and_display_times", "crates/organize/src/tests.rs::page_transitions_are_set_removed_and_checked", "crates/automation/tests/it/automation.rs::page_transitions_are_set_and_removed_through_the_tools"] notes = "Read and set, not played: each page's /Trans (style, duration, dimension, motion, direction, Fly scale and backdrop) and /Dur display time are read with the spec's defaults and listed by doc_info (DocInfo::transitions); page_transition_set (Edit::SetTransition, one undo step) writes or removes them on chosen pages with the values checked first. Not yet: playing them in full screen, auto-advancing on /Dur, and the Page Transitions dialog" [[feature]] @@ -3339,7 +3339,7 @@ milestone = "M4" status = "shipped" commands = [] tools = ["bookmark_list", "bookmark_set_style"] -evidence = ["crates/render/src/inspect.rs::outline_items_carry_their_colour_and_style", "crates/organize/src/tests.rs::bookmarks_report_their_colour_and_style", "crates/organize/src/tests.rs::bookmark_style_and_colour_are_set_as_the_table_says", "crates/ui-egui/src/panels.rs::bookmark_titles_take_their_style_and_colour", "crates/ui-egui/src/theme.rs::a_document_colour_is_kept_where_it_reads", "crates/ui-egui/tests/it/bookmarks.rs::style_and_colour_from_the_bookmark_menu", "crates/automation/tests/it/automation.rs::bookmark_list_reports_colour_and_style"] +evidence = ["crates/render/src/inspect/tests.rs::outline_items_carry_their_colour_and_style", "crates/organize/src/tests.rs::bookmarks_report_their_colour_and_style", "crates/organize/src/tests.rs::bookmark_style_and_colour_are_set_as_the_table_says", "crates/ui-egui/src/panels/bookmarks.rs::bookmark_titles_take_their_style_and_colour", "crates/ui-egui/src/theme/tests.rs::a_document_colour_is_kept_where_it_reads", "crates/ui-egui/tests/it/bookmarks.rs::style_and_colour_from_the_bookmark_menu", "crates/automation/tests/it/automation.rs::bookmark_list_reports_colour_and_style"] notes = "#3193: the Bookmarks panel draws each title in its /C colour and /F style (ISO 32000-2 §12.3.3, Tables 151 and 152: bit 1 italic, bit 2 bold), lightening a colour too dark for the dark theme until it reads (3:1). A bookmark's menu has Style (Bold, Italic) and Colour (the comment swatches and Other colour…); bookmark_set_style does the same and bookmark_list reports them. Regular removes /F, black removes /C, and /F bits the table doesn't define are kept. Not yet: one change for several selected bookmarks, and a Properties dialog." [[feature]] @@ -3495,7 +3495,7 @@ milestone = "M3" status = "shipped" commands = ["file.properties"] tools = ["doc_info"] -evidence = ["crates/ui-egui/tests/it/view.rs::fonts_tab_lists_fonts_and_embedding", "crates/render/src/inspect.rs::doc_info_says_which_face_stands_in_and_why", "crates/render/src/inspect.rs::an_embedded_font_reports_no_substitution_at_all"] +evidence = ["crates/ui-egui/tests/it/view.rs::fonts_tab_lists_fonts_and_embedding", "crates/render/src/inspect/resources.rs::doc_info_says_which_face_stands_in_and_why", "crates/render/src/inspect/resources.rs::an_embedded_font_reports_no_substitution_at_all"] notes = "name, type, embedding, subset and encoding; doc_info also reports the face standing in for a program the document does not embed and why it was chosen (Acrobat's Actual Font), including the case where no licensed face exists so nothing is drawn. Still missing: the Fonts tab in the interface does not show the substituted face yet, only the data layer does" [[feature]] @@ -3507,7 +3507,7 @@ milestone = "M4" status = "shipped" commands = ["file.properties"] tools = ["doc_initial_view"] -evidence = ["crates/organize/src/view.rs::initial_view_resolves_indirect_boolean_preferences", "crates/ui-egui/tests/it/ui.rs::a_document_asking_for_full_screen_is_asked_about_first", "crates/organize/src/tests.rs::initial_view_round_trips_and_keeps_scripts", "crates/ui-egui/tests/it/viewer.rs::initial_view_is_edited_and_honoured_on_open", "crates/automation/tests/it/automation.rs::initial_view_through_tools", "crates/render/src/inspect.rs::a_go_to_action_opens_at_its_destination_explicit_or_named", "crates/ui-egui/tests/it/open_action.rs::an_xyz_open_action_puts_its_point_at_the_window_top_left", "crates/ui-egui/tests/it/open_action.rs::a_fith_open_action_puts_its_top_at_the_window_top", "crates/ui-egui/tests/it/open_action.rs::a_named_open_destination_opens_its_page", "crates/ui-egui/tests/it/open_action.rs::a_full_screen_document_shows_its_non_full_screen_panel_outside_full_screen", "crates/organize/src/tests.rs::an_unchanged_opening_destination_keeps_its_position_and_name", "crates/automation/tests/it/automation.rs::doc_info_reports_the_open_destination", "crates/organize/src/tests.rs::choosing_page_one_replaces_a_named_opening_destination", "crates/organize/src/tests.rs::a_named_opening_destination_is_kept_while_it_still_holds"] +evidence = ["crates/organize/src/view.rs::initial_view_resolves_indirect_boolean_preferences", "crates/ui-egui/tests/it/ui.rs::a_document_asking_for_full_screen_is_asked_about_first", "crates/organize/src/tests.rs::initial_view_round_trips_and_keeps_scripts", "crates/ui-egui/tests/it/viewer.rs::initial_view_is_edited_and_honoured_on_open", "crates/automation/tests/it/automation.rs::initial_view_through_tools", "crates/render/src/inspect/navigation.rs::a_go_to_action_opens_at_its_destination_explicit_or_named", "crates/ui-egui/tests/it/open_action.rs::an_xyz_open_action_puts_its_point_at_the_window_top_left", "crates/ui-egui/tests/it/open_action.rs::a_fith_open_action_puts_its_top_at_the_window_top", "crates/ui-egui/tests/it/open_action.rs::a_named_open_destination_opens_its_page", "crates/ui-egui/tests/it/open_action.rs::a_full_screen_document_shows_its_non_full_screen_panel_outside_full_screen", "crates/organize/src/tests.rs::an_unchanged_opening_destination_keeps_its_position_and_name", "crates/automation/tests/it/automation.rs::doc_info_reports_the_open_destination", "crates/organize/src/tests.rs::choosing_page_one_replaces_a_named_opening_destination", "crates/organize/src/tests.rs::a_named_opening_destination_is_kept_while_it_still_holds"] notes = "navigation panel, layout, magnification, page, window and interface options; honoured on open; script open actions kept. Open in Full Screen mode (/PageMode /FullScreen) asks first (Full Screen / Not now), as Acrobat does, instead of taking the screen. Window and interface options also read indirect boolean ViewerPreferences values. Missing: Acrobat's Full Screen preference to turn that alert off and its \"remember my choice for this document\" (misc.prefs-full-screen). On open the /OpenAction destination places the view as ISO 32000-2 Table 149 says (the /XYZ point at the window's top-left, /FitH and /FitV top or left at the edge), and a named destination (name or string, /Names /Dests tree or catalog /Dests, §12.3.2.4) resolves; Always use my default zoom keeps only the page. A full-screen document shows its /NonFullScreenPageMode panel outside full screen (Table 147). doc_info reports the opening destination. Document Properties keeps an opening action whose page and magnification are left alone, but still edits only those two; it shows a named destination's page and magnification, and choosing another page or magnification replaces the name with an explicit destination, except that page 1 at the default magnification removes /OpenAction so the first page opens. A name that resolves nowhere reads as page 1 and is kept until another page is chosen" [[feature]] @@ -3519,7 +3519,7 @@ milestone = "M4" status = "partial" commands = ["file.properties"] tools = ["doc_info", "doc_set_info"] -evidence = ["crates/organize/src/tests.rs::custom_document_properties_are_listed_set_removed_and_their_names_checked", "crates/render/src/inspect.rs::custom_document_properties_are_listed_apart_from_the_standard_ones", "crates/automation/tests/it/automation.rs::custom_document_properties_are_set_listed_and_cleared_through_the_tools", "crates/ui-egui/tests/it/editing.rs::custom_properties_are_added_in_the_custom_tab_as_one_undoable_step", "crates/ui-egui/tests/it/editing.rs::custom_properties_are_changed_and_deleted_in_the_custom_tab", "crates/ui-egui/tests/it/editing.rs::the_custom_tab_refuses_names_a_custom_property_cannot_have", "crates/ui-egui/tests/it/editing.rs::a_custom_property_whose_name_the_tools_refuse_is_listed_and_left_alone", "crates/ui-egui/tests/it/editing.rs::a_custom_property_is_deleted_from_an_info_dictionary_in_the_trailer", "crates/ui-egui/tests/it/editing.rs::add_stops_at_the_256_custom_properties_the_tab_lists", "crates/organize/src/tests.rs::document_info_entries_are_cleared_from_an_info_dictionary_in_the_trailer"] +evidence = ["crates/organize/src/tests.rs::custom_document_properties_are_listed_set_removed_and_their_names_checked", "crates/render/src/inspect/tests.rs::custom_document_properties_are_listed_apart_from_the_standard_ones", "crates/automation/tests/it/automation.rs::custom_document_properties_are_set_listed_and_cleared_through_the_tools", "crates/ui-egui/tests/it/editing.rs::custom_properties_are_added_in_the_custom_tab_as_one_undoable_step", "crates/ui-egui/tests/it/editing.rs::custom_properties_are_changed_and_deleted_in_the_custom_tab", "crates/ui-egui/tests/it/editing.rs::the_custom_tab_refuses_names_a_custom_property_cannot_have", "crates/ui-egui/tests/it/editing.rs::a_custom_property_whose_name_the_tools_refuse_is_listed_and_left_alone", "crates/ui-egui/tests/it/editing.rs::a_custom_property_is_deleted_from_an_info_dictionary_in_the_trailer", "crates/ui-egui/tests/it/editing.rs::add_stops_at_the_256_custom_properties_the_tab_lists", "crates/organize/src/tests.rs::document_info_entries_are_cleared_from_an_info_dictionary_in_the_trailer"] notes = "Document Properties ▸ Custom lists them, with Name and Value fields and Add, Update (the value of the listed property the Name field names) and Delete; OK writes the changes as one undo step with the other tabs' changes. A property already in the file under a name the tools refuse (a space, a delimiter) or a name that is not UTF-8 is listed and cannot be changed or deleted there, and Add is off for a name the file already uses for an entry that is not listed (not text, only padding, or past the 256 listed), and once the tab or the file lists 256 properties. Through the tools: doc_set_info sets or clears an /Info entry under any name the spec does not use (names checked as Acrobat does: no standard name, no empty name, no spaces or delimiters, at most 127 bytes), and doc_info lists them under custom, apart from the standard entries. Not yet: mirroring them into XMP (pdfx:) as Acrobat does" [[feature]] @@ -3607,7 +3607,7 @@ milestone = "M5" status = "shipped" commands = ["comment.note"] tools = ["comment_add"] -evidence = ["crates/ui-egui/tests/it/comments.rs::sticky_note_composer_posts_and_returns_to_select", "crates/ui-egui/tests/it/comments.rs::the_comment_composer_shortcut_only_posts_the_focused_editor", "crates/annot/src/tests.rs::every_tool_creates_a_drawable_comment", "crates/annot/src/tests.rs::every_note_icon_draws_its_own_shape", "crates/annot/src/tests.rs::a_star_note_keeps_its_star_when_recoloured", "crates/engine/src/tests.rs::sticky_notes_stay_upright_where_they_are_placed_on_rotated_pages", "crates/render/src/raster.rs::no_rotate_notes_stay_upright_at_their_corner_on_rotated_pages", "crates/ui-egui/tests/it/comments.rs::a_sticky_note_on_a_turned_page_is_picked_where_it_is_drawn"] +evidence = ["crates/ui-egui/tests/it/comments.rs::sticky_note_composer_posts_and_returns_to_select", "crates/ui-egui/tests/it/comments.rs::the_comment_composer_shortcut_only_posts_the_focused_editor", "crates/annot/src/tests.rs::every_tool_creates_a_drawable_comment", "crates/annot/src/tests.rs::every_note_icon_draws_its_own_shape", "crates/annot/src/tests.rs::a_star_note_keeps_its_star_when_recoloured", "crates/engine/src/tests.rs::sticky_notes_stay_upright_where_they_are_placed_on_rotated_pages", "crates/render/src/raster/tests.rs::no_rotate_notes_stay_upright_at_their_corner_on_rotated_pages", "crates/ui-egui/tests/it/comments.rs::a_sticky_note_on_a_turned_page_is_picked_where_it_is_drawn"] notes = "our own icon drawings for the seven standard /Name values of ISO 32000-2 Table 175 (Comment, Note, Help, Insert, Key, NewParagraph, Paragraph) and seven further names (Check, Circle, Cross, CrossHairs, Star, RightArrow, UpArrow), each its own shape, chosen in Properties or comment_add, and kept when the colour changes; another name draws as Note. With a pop-up; Ctrl+Enter (also Command+Enter) posts from the focused editor, Shift+Enter adds a newline. Notes are saved with the NoRotate flag, and it is honoured: on a page with /Rotate the icon stays upright with its top-left where it was placed, and is selected and hovered there (#2583). Not yet: opening the pop-ups a file saves open (/Open); NoZoom (the icon scales with the zoom); printing and flattening still place the icon in its /Rect, turned with the page." [[feature]] @@ -4303,7 +4303,7 @@ milestone = "M12" status = "partial" commands = ["measure.distance"] tools = ["measure_distance"] -evidence = ["crates/automation/tests/it/automation.rs::measurements_calibrate_draw_save_reopen_and_export", "crates/measure/src/tests.rs::calibrated_lengths_concave_areas_and_axis_scales", "crates/ui-egui/tests/it/control.rs::measurement_tools_draw_live_calibrate_save_and_export", "crates/ui-egui/tests/it/control.rs::a_plan_scale_picked_or_typed_applies_at_once", "crates/ui-egui/src/measure_ui.rs::typed_ratios_are_read_leniently"] +evidence = ["crates/automation/tests/it/automation.rs::measurements_calibrate_draw_save_reopen_and_export", "crates/measure/src/tests.rs::calibrated_lengths_concave_areas_and_axis_scales", "crates/ui-egui/tests/it/control.rs::measurement_tools_draw_live_calibrate_save_and_export", "crates/ui-egui/tests/it/control.rs::a_plan_scale_picked_or_typed_applies_at_once", "crates/ui-egui/src/tools/measure_ui.rs::typed_ratios_are_read_leniently"] notes = "Two-point distance annotations with saved numeric captions and rectilinear scales. Partial: only single-unit decimal formats; compound (ft-in) and fractional formats are not supported (listed as unsupported, data kept), no leader lines or caption placement options, geospatial scales unsupported, and interop with Acrobat-authored measurements (reading theirs, Acrobat reading ours) is untested." [[feature]] @@ -4451,7 +4451,7 @@ milestone = "M3" status = "shipped" commands = ["form.fields"] tools = ["doc_info"] -evidence = ["crates/ui-egui/tests/it/view.rs::field_list_jumps_to_a_fields_page", "crates/render/src/inspect.rs::fields_listed_only_on_the_pages_are_listed", "crates/ui-egui/tests/it/bulk_fields.rs::fields_panel_selects_multiple_fields_and_opens_their_shared_properties", "crates/ui-egui/tests/it/bulk_fields.rs::double_clicking_a_field_in_the_panel_opens_its_individual_properties"] +evidence = ["crates/ui-egui/tests/it/view.rs::field_list_jumps_to_a_fields_page", "crates/render/src/inspect/tests.rs::fields_listed_only_on_the_pages_are_listed", "crates/ui-egui/tests/it/bulk_fields.rs::fields_panel_selects_multiple_fields_and_opens_their_shared_properties", "crates/ui-egui/tests/it/bulk_fields.rs::double_clicking_a_field_in_the_panel_opens_its_individual_properties"] notes = "list with jump-to-field; in Prepare a form, Shift/Ctrl/Command-click selects several fields and Properties edits their shared properties; double-click opens field properties; fields a file lists only as page widgets (empty /Fields) are adopted too" [[feature]] @@ -4475,7 +4475,7 @@ milestone = "M2" status = "shipped" commands = [] tools = [] -evidence = ["crates/render/src/raster.rs::widget_appearance_states", "crates/engine/src/tests.rs::validated_signatures_without_layers_keep_their_appearance", "crates/engine/src/tests.rs::real_layered_signatures_keep_their_original_appearance", "crates/engine/src/tests.rs::real_layered_signatures_round_trip_behind_a_user_password", "crates/engine/src/tests.rs::a_metadata_edit_keeps_the_original_signature_appearance", "crates/engine/src/tests.rs::saving_after_an_edit_keeps_the_baked_appearance", "crates/engine/src/tests.rs::chamfered_signature_borders_survive_trust_exports_printing_and_save", "crates/engine/src/tests.rs::mixed_signature_caption_details_survive_trust_exports_printing_and_save", "crates/engine/src/tests.rs::buttons_and_combs_left_to_the_viewer_are_displayed", "crates/forms/src/tests.rs::button_states_without_an_appearance"] +evidence = ["crates/render/src/raster/tests.rs::widget_appearance_states", "crates/engine/src/tests.rs::validated_signatures_without_layers_keep_their_appearance", "crates/engine/src/tests.rs::real_layered_signatures_keep_their_original_appearance", "crates/engine/src/tests.rs::real_layered_signatures_round_trip_behind_a_user_password", "crates/engine/src/tests.rs::a_metadata_edit_keeps_the_original_signature_appearance", "crates/engine/src/tests.rs::saving_after_an_edit_keeps_the_baked_appearance", "crates/engine/src/tests.rs::chamfered_signature_borders_survive_trust_exports_printing_and_save", "crates/engine/src/tests.rs::mixed_signature_caption_details_survive_trust_exports_printing_and_save", "crates/engine/src/tests.rs::buttons_and_combs_left_to_the_viewer_are_displayed", "crates/forms/src/tests.rs::button_states_without_an_appearance"] notes = "widgets retain their original artwork. Signature validation is a separate viewer page overlay; it is never written into document streams or included in image/text exports, vector printing or Print as Image. Saves retain signed appearance bytes. Generic in-place appearance rewriting is deferred: no supported producer template is positively identified, and caption text plus drawing uniqueness does not establish an icon role. Baked Not Verified captions remain unchanged; issue #79 is not fully resolved. Check boxes and radio buttons with no appearance for their state are shown (display-only) with their /MK border and background and, when on, the /MK /CA ZapfDingbats symbol in the /DA colour (a check or a dot by default); push buttons with none show their /MK frame and caption; files that carry their own appearances are not redrawn." [[feature]] @@ -4571,7 +4571,7 @@ milestone = "M6" status = "shipped" commands = ["form.field.properties"] tools = ["form_set_props", "form_fill"] -evidence = ["crates/forms/src/af.rs::numbers_format_like_acrobat", "crates/forms/src/tests.rs::formats_validation_and_calculations_run_like_acrobat", "crates/automation/tests/it/automation.rs::form_formats_and_calculations_through_tools"] +evidence = ["crates/forms/src/af/tests.rs::numbers_format_like_acrobat", "crates/forms/src/tests.rs::formats_validation_and_calculations_run_like_acrobat", "crates/automation/tests/it/automation.rs::form_formats_and_calculations_through_tools"] notes = "AFNumber_Format/Keystroke run natively: decimals, 5 separator styles, currency before/after, negative styles (red shown as minus/parentheses)" [[feature]] @@ -4583,7 +4583,7 @@ milestone = "M6" status = "shipped" commands = ["form.field.properties"] tools = ["form_set_props", "form_fill"] -evidence = ["crates/forms/src/af.rs::numbers_format_like_acrobat"] +evidence = ["crates/forms/src/af/tests.rs::numbers_format_like_acrobat"] notes = "AFPercent_Format/Keystroke" [[feature]] @@ -4595,7 +4595,7 @@ milestone = "M6" status = "shipped" commands = ["form.field.properties"] tools = ["form_set_props", "form_fill"] -evidence = ["crates/forms/src/af.rs::dates_times_and_masks", "crates/forms/src/tests.rs::formats_validation_and_calculations_run_like_acrobat", "crates/forms/src/af.rs::digits_typed_without_separators_fill_adjacent_fields"] +evidence = ["crates/forms/src/af/tests.rs::dates_times_and_masks", "crates/forms/src/tests.rs::formats_validation_and_calculations_run_like_acrobat", "crates/forms/src/af.rs::digits_typed_without_separators_fill_adjacent_fields"] notes = "AFDate_FormatEx/KeystrokeEx, AFDate_Format presets, AFTime_Format(Ex): lenient parsing, month and day names, impossible dates refused; digits typed without separators fill fields the format writes side by side by their fixed widths (20261018 for yyyymmdd, 1430 for HHMM). A run of digits is split only where the format itself has no separator: 20261018 typed into a yyyy-mm-dd field is still refused" [[feature]] @@ -4607,7 +4607,7 @@ milestone = "M6" status = "shipped" commands = ["form.field.properties"] tools = ["form_set_props", "form_fill"] -evidence = ["crates/forms/src/af.rs::dates_times_and_masks"] +evidence = ["crates/forms/src/af/tests.rs::dates_times_and_masks"] notes = "zip, zip+4, phone, SSN, arbitrary masks (9 A O X)" [[feature]] @@ -4619,7 +4619,7 @@ milestone = "M6" status = "shipped" commands = ["form.field.properties"] tools = ["form_set_props", "form_fill"] -evidence = ["crates/forms/src/af.rs::validation_and_calculation", "crates/forms/src/tests.rs::formats_validation_and_calculations_run_like_acrobat", "crates/automation/tests/it/automation.rs::form_formats_and_calculations_through_tools"] +evidence = ["crates/forms/src/af/tests.rs::validation_and_calculation", "crates/forms/src/tests.rs::formats_validation_and_calculations_run_like_acrobat", "crates/automation/tests/it/automation.rs::form_formats_and_calculations_through_tools"] notes = "AFRange_Validate with Acrobat's messages" [[feature]] @@ -4631,7 +4631,7 @@ milestone = "M6" status = "shipped" commands = ["form.field.properties"] tools = ["form_set_props", "form_fill"] -evidence = ["crates/forms/src/af.rs::validation_and_calculation", "crates/forms/src/tests.rs::formats_validation_and_calculations_run_like_acrobat"] +evidence = ["crates/forms/src/af/tests.rs::validation_and_calculation", "crates/forms/src/tests.rs::formats_validation_and_calculations_run_like_acrobat"] notes = "SUM, PRD, AVG, MIN, MAX over fields and groups" [[feature]] @@ -4643,7 +4643,7 @@ milestone = "M6" status = "shipped" commands = ["form.field.properties"] tools = ["form_set_props", "form_fill"] -evidence = ["crates/forms/src/af.rs::validation_and_calculation", "crates/forms/src/tests.rs::formats_validation_and_calculations_run_like_acrobat", "crates/automation/tests/it/automation.rs::form_formats_and_calculations_through_tools", "crates/forms/src/af.rs::notation_is_read_only_from_a_comment"] +evidence = ["crates/forms/src/af/tests.rs::validation_and_calculation", "crates/forms/src/tests.rs::formats_validation_and_calculations_run_like_acrobat", "crates/automation/tests/it/automation.rs::form_formats_and_calculations_through_tools", "crates/forms/src/af/tests.rs::notation_is_read_only_from_a_comment"] notes = "BVCALC expressions with + - * / and parentheses, escaped names, read from the script's comment as Acrobat writes it; a string or code that spells the markers is not notation" [[feature]] @@ -4811,7 +4811,7 @@ milestone = "M6" status = "shipped" commands = ["form.prepare"] tools = ["form_add_field", "form_set_props", "form_delete_field"] -evidence = ["crates/ui-egui/tests/it/prepare.rs::placing_moving_editing_and_deleting_a_field", "crates/ui-egui/tests/it/prepare.rs::arrow_keys_nudge_the_selected_field_instead_of_turning_the_page", "crates/ui-egui/src/prepare.rs::corner_drags_stretch_freely_and_keep_proportions_with_shift", "crates/ui-egui/tests/it/prepare.rs::deleting_several_selected_fields_and_detecting_fields_from_the_panel", "crates/engine/src/tests.rs::preparing_a_form_adds_renames_and_deletes_fields", "crates/ui-egui/tests/it/bulk_fields.rs::shared_properties_preserve_mixed_values_and_undo_the_whole_change", "crates/ui-egui/tests/it/bulk_fields.rs::select_all_in_prepare_a_form_selects_only_the_current_pages_fields", "crates/ui-egui/tests/it/bulk_fields.rs::dragging_an_empty_page_area_selects_fields_without_moving_them"] +evidence = ["crates/ui-egui/tests/it/prepare.rs::placing_moving_editing_and_deleting_a_field", "crates/ui-egui/tests/it/prepare.rs::arrow_keys_nudge_the_selected_field_instead_of_turning_the_page", "crates/ui-egui/src/tools/prepare.rs::corner_drags_stretch_freely_and_keep_proportions_with_shift", "crates/ui-egui/tests/it/prepare.rs::deleting_several_selected_fields_and_detecting_fields_from_the_panel", "crates/engine/src/tests.rs::preparing_a_form_adds_renames_and_deletes_fields", "crates/ui-egui/tests/it/bulk_fields.rs::shared_properties_preserve_mixed_values_and_undo_the_whole_change", "crates/ui-egui/tests/it/bulk_fields.rs::select_all_in_prepare_a_form_selects_only_the_current_pages_fields", "crates/ui-egui/tests/it/bulk_fields.rs::dragging_an_empty_page_area_selects_fields_without_moving_them"] notes = "fields shown with blue fill and name tags; select, move (drag, or the arrow keys: a point, Shift ten points, #2608), resize by corners (Shift keeps the field's proportions, #2199), Delete (every selected field, one undo step); field tools return to Select after one field. Shared General, Appearance and applicable Options edits are atomic and opt-in; Ctrl/Command+A selects the current page, rectangle and panel selection are supported. Names, positions, choices/defaults and script tabs remain individual GUI edits" [[feature]] @@ -5039,7 +5039,7 @@ milestone = "M6" status = "shipped" commands = ["form.field.properties"] tools = ["form_set_props", "form_fill"] -evidence = ["crates/ui-egui/tests/it/prepare.rs::format_validate_and_calculate_tabs", "crates/automation/tests/it/automation.rs::form_formats_and_calculations_through_tools", "crates/forms/src/tests.rs::renaming_a_field_renames_it_in_the_calculations_that_read_it", "crates/forms/src/tests.rs::a_rename_reaches_every_stored_form_of_a_calculation", "crates/automation/tests/it/automation.rs::renaming_a_field_keeps_the_formulas_that_read_it", "crates/ui-egui/tests/it/prepare.rs::renaming_a_field_says_how_many_calculations_now_use_the_new_name", "crates/forms/src/af.rs::a_regex_after_a_condition_is_not_read_as_code", "crates/forms/src/af.rs::a_line_comment_ends_at_any_line_terminator", "crates/forms/src/af.rs::a_token_ends_at_a_line_separator"] +evidence = ["crates/ui-egui/tests/it/prepare.rs::format_validate_and_calculate_tabs", "crates/automation/tests/it/automation.rs::form_formats_and_calculations_through_tools", "crates/forms/src/tests.rs::renaming_a_field_renames_it_in_the_calculations_that_read_it", "crates/forms/src/tests.rs::a_rename_reaches_every_stored_form_of_a_calculation", "crates/automation/tests/it/automation.rs::renaming_a_field_keeps_the_formulas_that_read_it", "crates/ui-egui/tests/it/prepare.rs::renaming_a_field_says_how_many_calculations_now_use_the_new_name", "crates/forms/src/af/tests.rs::a_regex_after_a_condition_is_not_read_as_code", "crates/forms/src/af/tests.rs::a_line_comment_ends_at_any_line_terminator", "crates/forms/src/af/tests.rs::a_token_ends_at_a_line_separator"] notes = "sum/product/average/min/max of picked fields, simplified field notation; custom script pending. Renaming a field renames it in the calculations that read it (field lists, notation, and getField calls naming it in a string literal), in the rename's undo step; Field Properties says how many changed and form_set_props lists them. Not rewritten: names built at run time, other events' scripts, document-level scripts. A regular expression that starts a statement right after a `}` is still read as code, so a quote inside one can hide the getField calls after it from a rename and from the calculation order. Whether Acrobat does this is not observed." [[feature]] @@ -5380,7 +5380,7 @@ milestone = "M5" status = "shipped" commands = ["sign.fill.signature", "sign.fill.initials", "sign.fill.signature.change", "sign.fill.initials.change", "sign.fill.signature.remove", "sign.fill.initials.remove"] tools = ["fill_sign_add", "comment_edit", "comment_image_preview"] -evidence = ["crates/ui-egui/tests/it/fill_sign.rs::drawn_signatures_resize_from_their_corners", "crates/ui-egui/tests/it/fill_sign.rs::drawn_and_typed_signatures_stay_upright_on_rotated_pages", "crates/automation/tests/it/automation.rs::typed_signatures_stay_upright_on_rotated_pages", "crates/ui-egui/tests/it/fill_sign.rs::image_signature_corners_restore_original_aspect_after_edge_resize_and_reopen", "crates/ui-egui/src/comments.rs::proportional_corners_keep_the_opposite_anchor_and_edges_stretch_one_axis", "crates/ui-egui/tests/it/fill_sign.rs::image_signature_live_resize_and_move_preserve_background_and_hide_moving_handles", "crates/ui-egui/tests/it/fill_sign.rs::embedded_image_signature_live_gestures_follow_document_and_view_rotation", "crates/automation/tests/it/automation.rs::image_signature_preview_layers_are_read_only_and_survive_encrypted_save", "crates/engine/src/signature_image.rs::embedded_signature_preview_preserves_alpha_without_editing_the_document", "crates/engine/src/signature_image.rs::oversized_embedded_signature_preview_is_rejected_before_decoding", "crates/ui-egui/tests/it/fill_sign.rs::image_signature_preview_follows_pointer_at_page_size_with_zoom_and_rotation", "crates/ui-egui/tests/it/fill_sign.rs::image_signature_placement_selects_resize_handles_and_requires_reselecting_to_repeat", "crates/ui-egui/tests/it/fill_sign.rs::typed_signatures_and_initials", "crates/ui-egui/tests/it/fill_sign.rs::signing_draws_a_signature_once_and_places_it", "crates/ui-egui/tests/it/fill_sign.rs::changing_saved_signatures_and_initials_preserves_placed_marks", "crates/ui-egui/tests/it/fill_sign.rs::changing_drawn_signatures_can_be_cancelled_or_switched_to_type", "crates/ui-egui/tests/it/fill_sign.rs::saved_signature_cards_remove_and_add_without_changing_the_document", "crates/ui-egui/tests/it/fill_sign.rs::long_typed_names_fit_the_placed_signature_and_keep_every_outline", "crates/ui-egui/src/fill_sign.rs::long_signature_previews_have_clear_margins", "crates/ui-egui/tests/it/control.rs::saved_signature_can_be_changed_through_the_control_channel", "crates/fonts/src/script.rs::a_name_becomes_outlines", "crates/fonts/src/script.rs::long_names_keep_every_glyph_with_bounded_outline_work", "crates/automation/tests/it/automation.rs::fill_and_sign_through_tools", "crates/ui-egui/tests/it/fill_sign.rs::image_signatures_and_initials_can_be_imported_placed_and_remembered", "crates/ui-egui/tests/it/fill_sign.rs::image_import_cancel_clear_and_errors_preserve_saved_signatures", "crates/engine/src/signature_image.rs::image_signatures_preserve_alpha_and_fit_the_signature_box", "crates/engine/src/signature_image.rs::invalid_and_oversized_signature_images_are_rejected", "crates/engine/src/signature_image.rs::large_signature_images_are_scaled_down", "crates/engine/src/signature_image.rs::jpeg_signatures_are_normalized_to_png", "crates/engine/src/signature_image.rs::jpeg_signature_orientation_is_applied_before_preview_and_embedding", "crates/engine/src/signature_image.rs::very_wide_image_signatures_keep_the_requested_rectangle", "crates/automation/tests/it/automation.rs::image_signatures_through_tools_preserve_transparency_and_survive_save", "crates/ui-egui/tests/it/fill_sign.rs::fill_and_sign_tools_pick_up_placed_marks_to_move_and_resize_them", "crates/ui-egui/tests/it/fill_sign.rs::select_tool_picks_up_a_signature_over_a_form_field", "crates/ui-egui/tests/it/fill_sign.rs::scanned_signatures_are_framed_cleaned_adjusted_placed_and_remembered", "crates/ui-egui/tests/it/fill_sign.rs::scan_cancel_errors_and_stale_answers_preserve_saved_signatures", "crates/automation/tests/it/automation.rs::scanned_signatures_through_tools_drop_the_paper_and_report_bad_arguments", "crates/engine/src/signature_scan.rs::a_photo_is_scanned_into_a_trimmed_transparent_signature_in_its_ink_colour", "crates/ui-egui/tests/it/fill_sign.rs::undo_button_and_ctrl_z_take_back_one_drawn_stroke_at_a_time", "crates/ui-egui/src/fill_sign.rs::undo_removes_the_last_stroke_and_skips_empty_ones", "crates/ui-egui/src/comments.rs::shift_keeps_the_current_proportions_unless_the_box_has_its_own"] +evidence = ["crates/ui-egui/tests/it/fill_sign.rs::drawn_signatures_resize_from_their_corners", "crates/ui-egui/tests/it/fill_sign.rs::drawn_and_typed_signatures_stay_upright_on_rotated_pages", "crates/automation/tests/it/automation.rs::typed_signatures_stay_upright_on_rotated_pages", "crates/ui-egui/tests/it/fill_sign.rs::image_signature_corners_restore_original_aspect_after_edge_resize_and_reopen", "crates/ui-egui/src/tools/comments/tests.rs::proportional_corners_keep_the_opposite_anchor_and_edges_stretch_one_axis", "crates/ui-egui/tests/it/fill_sign.rs::image_signature_live_resize_and_move_preserve_background_and_hide_moving_handles", "crates/ui-egui/tests/it/fill_sign.rs::embedded_image_signature_live_gestures_follow_document_and_view_rotation", "crates/automation/tests/it/automation.rs::image_signature_preview_layers_are_read_only_and_survive_encrypted_save", "crates/engine/src/signature_image.rs::embedded_signature_preview_preserves_alpha_without_editing_the_document", "crates/engine/src/signature_image.rs::oversized_embedded_signature_preview_is_rejected_before_decoding", "crates/ui-egui/tests/it/fill_sign.rs::image_signature_preview_follows_pointer_at_page_size_with_zoom_and_rotation", "crates/ui-egui/tests/it/fill_sign.rs::image_signature_placement_selects_resize_handles_and_requires_reselecting_to_repeat", "crates/ui-egui/tests/it/fill_sign.rs::typed_signatures_and_initials", "crates/ui-egui/tests/it/fill_sign.rs::signing_draws_a_signature_once_and_places_it", "crates/ui-egui/tests/it/fill_sign.rs::changing_saved_signatures_and_initials_preserves_placed_marks", "crates/ui-egui/tests/it/fill_sign.rs::changing_drawn_signatures_can_be_cancelled_or_switched_to_type", "crates/ui-egui/tests/it/fill_sign.rs::saved_signature_cards_remove_and_add_without_changing_the_document", "crates/ui-egui/tests/it/fill_sign.rs::long_typed_names_fit_the_placed_signature_and_keep_every_outline", "crates/ui-egui/src/tools/fill_sign.rs::long_signature_previews_have_clear_margins", "crates/ui-egui/tests/it/control.rs::saved_signature_can_be_changed_through_the_control_channel", "crates/fonts/src/script.rs::a_name_becomes_outlines", "crates/fonts/src/script.rs::long_names_keep_every_glyph_with_bounded_outline_work", "crates/automation/tests/it/automation.rs::fill_and_sign_through_tools", "crates/ui-egui/tests/it/fill_sign.rs::image_signatures_and_initials_can_be_imported_placed_and_remembered", "crates/ui-egui/tests/it/fill_sign.rs::image_import_cancel_clear_and_errors_preserve_saved_signatures", "crates/engine/src/signature_image.rs::image_signatures_preserve_alpha_and_fit_the_signature_box", "crates/engine/src/signature_image.rs::invalid_and_oversized_signature_images_are_rejected", "crates/engine/src/signature_image.rs::large_signature_images_are_scaled_down", "crates/engine/src/signature_image.rs::jpeg_signatures_are_normalized_to_png", "crates/engine/src/signature_image.rs::jpeg_signature_orientation_is_applied_before_preview_and_embedding", "crates/engine/src/signature_image.rs::very_wide_image_signatures_keep_the_requested_rectangle", "crates/automation/tests/it/automation.rs::image_signatures_through_tools_preserve_transparency_and_survive_save", "crates/ui-egui/tests/it/fill_sign.rs::fill_and_sign_tools_pick_up_placed_marks_to_move_and_resize_them", "crates/ui-egui/tests/it/fill_sign.rs::select_tool_picks_up_a_signature_over_a_form_field", "crates/ui-egui/tests/it/fill_sign.rs::scanned_signatures_are_framed_cleaned_adjusted_placed_and_remembered", "crates/ui-egui/tests/it/fill_sign.rs::scan_cancel_errors_and_stale_answers_preserve_saved_signatures", "crates/automation/tests/it/automation.rs::scanned_signatures_through_tools_drop_the_paper_and_report_bad_arguments", "crates/engine/src/signature_scan/tests.rs::a_photo_is_scanned_into_a_trimmed_transparent_signature_in_its_ink_colour", "crates/ui-egui/tests/it/fill_sign.rs::undo_button_and_ctrl_z_take_back_one_drawn_stroke_at_a_time", "crates/ui-egui/src/tools/fill_sign.rs::undo_removes_the_last_stroke_and_skips_empty_ones", "crates/ui-egui/src/tools/comments/tests.rs::shift_keeps_the_current_proportions_unless_the_box_has_its_own"] notes = "signature and initials, typed (Dancing Script outlines as filled paths, no font embedded), drawn (an Undo button and Ctrl/Cmd+Z in the pad take back the last stroke; Clear empties it) or imported from local PNG/JPEG images; all three are placed upright and anchored as displayed on pages with /Rotate (#299); saved in settings with preview cards and add/change/remove controls; changes leave placed annotations untouched; images preserve alpha, aspect ratio and EXIF orientation, fit within 150 pt by 32 pt (24 pt for initials), and are normalized without source paths or metadata; image previews follow the pointer at placement size with zoom and rotation; signatures/initials place once then select the new annotation; while a Fill & Sign tool is active, marks already placed are picked up as with Select (hover shows the move cursor, a click selects, a drag moves, the selected mark's handles resize) and the tool places only away from them; embedded image signatures resize and move live, hiding the frame during movement and restoring it on release; corner handles preserve the original aspect ratio of the embedded image with the opposite corner anchored, while edge midpoint handles stretch width or height independently; cached background layers preserve page content and alpha, including reopened/encrypted documents; Escape cancels a gesture without committing; stamps resize through selection handles or comment_edit with their original appearance; holding Shift on a corner handle keeps any resizable comment's current proportions (#2199); native and web asynchronous pickers; fill_sign_add accepts path instead of text; image imports capped at 4 MiB, 32 megapixels and 8192 pixels per side, larger than 4 megapixels or 4096 pixels per side scaled down to fit (#1075); Scan tab (fill_sign_add scan/sensitivity headless): a phone photo of a signature on paper (PNG/JPEG within the same image limits, EXIF-oriented, kept at most 4 megapixels) is framed automatically (all words; table, sheet edge and dust ignored; a sideways signature turned upright), the frame can be moved, resized or redrawn and the photo turned in quarter turns, and an ink-sensitivity slider (0-100) controls how faint ink is kept; the paper is estimated by a morphological closing, so shadows and uneven light are removed, and the result is trimmed ink in its own colour on transparency, saved like an imported image. Still missing: perspective correction of a skewed sheet, live camera capture, keyboard control of the frame, and processing off the UI thread (bounded by the 4-megapixel working copy; quick 900 px previews while dragging)" [[feature]] @@ -6300,7 +6300,7 @@ milestone = "M9" status = "shipped" commands = ["sign.panel"] tools = [] -evidence = ["crates/ui-egui/tests/it/signing.rs::drawing_a_signature_creating_an_id_signing_and_trusting", "crates/ui-egui/tests/it/signing.rs::certifying_without_a_visible_signature", "crates/ui-egui/src/canvas.rs::signature_badges_keep_signed_visible_widgets_on_the_page"] +evidence = ["crates/ui-egui/tests/it/signing.rs::drawing_a_signature_creating_an_id_signing_and_trusting", "crates/ui-egui/tests/it/signing.rs::certifying_without_a_visible_signature", "crates/ui-egui/src/canvas/tests.rs::signature_badges_keep_signed_visible_widgets_on_the_page"] notes = "signed/valid, problems, invalid and certified bars; visible signed widgets also carry a separate viewer validation overlay. Trust and explicit validation invalidate view caches without editing document artwork; baked appearance captions remain unchanged." [[feature]] @@ -6544,8 +6544,8 @@ milestone = "M10" status = "shipped" commands = ["ocr.recognize"] tools = ["ocr_recognize", "ocr_status"] -evidence = ["crates/engine/src/tests.rs::recognize_text_makes_a_scanned_page_searchable", "crates/automation/tests/it/automation.rs::ocr_tools_make_a_scan_searchable", "crates/ui-egui/tests/it/ocr.rs::recognize_text_dialog_adds_searchable_text"] -notes = "ocrs engine (MIT/Apache) with CC-BY-SA models fetched by `cargo xtask models`; the desktop release packages ship them (#103; packaging/env.sh stage_models), the web build does not yet; runs on a worker thread with progress; one undoable step; pages with text are skipped unless asked (ocr.skip-renderable-text)." +evidence = ["crates/ocr/src/lib.rs::model_discovery_prefers_the_complete_onnx_pair", "crates/ocr/src/lib.rs::model_discovery_falls_back_to_legacy_when_the_onnx_pair_is_incomplete", "crates/ocr/src/lib.rs::model_discovery_refuses_mixed_format_pairs", "crates/ocr/src/lib.rs::model_discovery_keeps_legacy_only_installations", "crates/engine/src/tests.rs::recognize_text_makes_a_scanned_page_searchable", "crates/automation/tests/it/automation.rs::ocr_tools_make_a_scan_searchable", "crates/ui-egui/tests/it/ocr.rs::recognize_text_dialog_adds_searchable_text"] +notes = "ocrs engine (MIT/Apache) with CC-BY-SA ONNX models fetched and SHA-256 checked by `cargo xtask models`; within each search directory, a complete ONNX pair takes precedence over a complete legacy RTEN pair, incomplete and mixed-format pairs are not combined, and directory precedence is unchanged; the desktop release packages ship them (#103; packaging/env.sh stage_models), the web build does not yet; runs on a worker thread with progress; one undoable step; pages with text are skipped unless asked (ocr.skip-renderable-text)." [[feature]] id = "ocr.recognize-range" @@ -7052,7 +7052,7 @@ milestone = "M10" status = "partial" commands = ["export.docx"] tools = ["doc_export_office"] -evidence = ["crates/export/src/lib.rs::word_table_cell_widths_follow_the_columns_they_span", "crates/export/src/lib.rs::table_spans_do_not_consume_text_they_do_not_emit", "crates/edit/src/tests.rs::reading_keeps_dotted_leader_entries_whole_and_in_order", "crates/engine/src/tests.rs::exporting_office_keeps_dotted_leader_entries_as_paragraphs", "crates/export/src/lib.rs::word_does_not_invent_borders_for_aligned_invoice_fields", "crates/export/src/lib.rs::word_overlap_does_not_consume_invoice_body_space", "crates/export/src/lib.rs::word_keeps_a_page_with_a_clear_image_and_big_title_in_flow", "crates/export/src/lib.rs::word_rotated_pages_keep_separate_page_sizes_and_unique_shapes", "crates/engine/src/tests.rs::office_export_positions_follow_the_displayed_crop_rotation_and_units", "crates/automation/tests/it/automation.rs::exporting_an_invoice_to_word_keeps_the_watermark_out_of_flow", "crates/export/src/lib.rs::docx_is_a_word_package", "crates/export/src/lib.rs::text_colour_survives_every_format", "crates/engine/src/tests.rs::exporting_office_files_keeps_text_colour", "crates/export/src/lib.rs::grid_text_becomes_a_real_table_in_every_format", "crates/export/src/lib.rs::a_link_inside_a_sentence_is_not_a_table_column", "crates/export/src/lib.rs::sentences_that_run_on_into_links_are_not_a_table", "crates/export/src/lib.rs::a_superscript_beside_a_number_keeps_its_table", "crates/export/src/lib.rs::word_opens_text_with_control_characters_and_long_pages", "crates/engine/src/tests.rs::exporting_office_files_keeps_images", "crates/automation/tests/it/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/it/compare.rs::export_to_word_html_and_rtf", "crates/export/src/lib.rs::docx_keeps_a_shared_page_background", "crates/edit/src/tests.rs::a_page_painted_first_with_a_full_page_fill_has_that_background", "crates/engine/src/tests.rs::exporting_to_word_keeps_the_page_background", "crates/export/src/lib.rs::an_italic_word_stays_in_its_sentence", "crates/export/src/lib.rs::a_sentence_keeps_its_reading_order", "crates/export/src/lib.rs::runs_keep_the_spaces_the_source_had_between_them", "crates/export/src/lib.rs::a_paragraph_is_a_heading_only_when_its_text_is", "crates/automation/tests/it/automation.rs::exporting_to_word_keeps_an_italic_word_inside_its_sentence"] +evidence = ["crates/export/src/tests.rs::word_table_cell_widths_follow_the_columns_they_span", "crates/export/src/tests.rs::table_spans_do_not_consume_text_they_do_not_emit", "crates/edit/src/tests.rs::reading_keeps_dotted_leader_entries_whole_and_in_order", "crates/engine/src/tests.rs::exporting_office_keeps_dotted_leader_entries_as_paragraphs", "crates/export/src/tests.rs::word_does_not_invent_borders_for_aligned_invoice_fields", "crates/export/src/tests.rs::word_overlap_does_not_consume_invoice_body_space", "crates/export/src/tests.rs::word_keeps_a_page_with_a_clear_image_and_big_title_in_flow", "crates/export/src/tests.rs::word_rotated_pages_keep_separate_page_sizes_and_unique_shapes", "crates/engine/src/tests.rs::office_export_positions_follow_the_displayed_crop_rotation_and_units", "crates/automation/tests/it/automation.rs::exporting_an_invoice_to_word_keeps_the_watermark_out_of_flow", "crates/export/src/tests.rs::docx_is_a_word_package", "crates/export/src/tests.rs::text_colour_survives_every_format", "crates/engine/src/tests.rs::exporting_office_files_keeps_text_colour", "crates/export/src/tests.rs::grid_text_becomes_a_real_table_in_every_format", "crates/export/src/tests.rs::a_link_inside_a_sentence_is_not_a_table_column", "crates/export/src/tests.rs::sentences_that_run_on_into_links_are_not_a_table", "crates/export/src/tests.rs::a_superscript_beside_a_number_keeps_its_table", "crates/export/src/tests.rs::word_opens_text_with_control_characters_and_long_pages", "crates/engine/src/tests.rs::exporting_office_files_keeps_images", "crates/automation/tests/it/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/it/compare.rs::export_to_word_html_and_rtf", "crates/export/src/tests.rs::docx_keeps_a_shared_page_background", "crates/edit/src/tests.rs::a_page_painted_first_with_a_full_page_fill_has_that_background", "crates/engine/src/tests.rs::exporting_to_word_keeps_the_page_background", "crates/export/src/tests.rs::an_italic_word_stays_in_its_sentence", "crates/export/src/tests.rs::a_sentence_keeps_its_reading_order", "crates/export/src/tests.rs::runs_keep_the_spaces_the_source_had_between_them", "crates/export/src/tests.rs::a_paragraph_is_a_heading_only_when_its_text_is", "crates/automation/tests/it/automation.rs::exporting_to_word_keeps_an_italic_word_inside_its_sentence"] notes = "Text in slots covered by a preceding table-cell span remains in paragraph flow instead of being discarded. Flowing Word document: paragraphs, Heading 1/2 from larger text, bold/italic, text colour (fill as sRGB; gray/CMYK converted naively, colour spaces via cs/sc read as black), borderless detected tables with spans/empty cells (cell widths sum the grid columns they occupy, including cells after spans, verified in the XML; a row whose blocks run on into each other, such as a link or superscript inside a sentence, or overlap, as a paragraph's lines do, is text and not a table row, #2806), per-page sections/sizes, the page colour (a full-page opaque fill painted first, such as Edit a PDF ▸ Background) as the document background when every page shares one non-white colour (Word has no per-page background, so differing colours are dropped; #2992; checked in the XML only, not yet opened in Word), and positioned editable text/artwork only for pages with rotated text or text overlapped by an image or watermark-sized text, other pages flowing with inline images (#773); synthetic invoice regression; dotted-leader contents entries keep the title, dots and page number together in separate paragraphs (#1757); the emitted package validates with Open XML SDK 2.20 (Office 2010 schema). Word/LibreOffice rendering of the positioned output remains unverified. Pictures on positioned pages are DrawingML anchors behind the text (a VML rectangle with an image showed no picture, #1936; not yet opened in Word); a Word file embeds at most 1000 pictures and 512 MiB of them. The earlier flowing output was checked with macOS textutil and opened in Microsoft Word (Windows). Characters XML forbids are dropped; the page size stays within Word's 22 in. A block that continues another on its line (an italic case name, a link, a superscript) is a run in its own size, bold/italic and colour inside that paragraph, keeping the source's word space between them, rather than a paragraph of its own; a lead-in in bold no longer makes its whole line a heading (#4158). Not yet: a style change that wraps onto the next line still splits its paragraph there (the reading blocks keep style boundaries, #4158), exact font matching, vector rules, arbitrary paint-order/transparency, general exact layout fidelity." [[feature]] @@ -7064,7 +7064,7 @@ milestone = "M10" status = "shipped" commands = ["export.rtf"] tools = ["doc_export_office"] -evidence = ["crates/export/src/lib.rs::table_spans_do_not_consume_text_they_do_not_emit", "crates/export/src/lib.rs::rtf_escapes_and_sizes", "crates/export/src/lib.rs::text_colour_survives_every_format", "crates/export/src/lib.rs::word_opens_text_with_control_characters_and_long_pages", "crates/automation/tests/it/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/it/compare.rs::export_to_word_html_and_rtf"] +evidence = ["crates/export/src/tests.rs::table_spans_do_not_consume_text_they_do_not_emit", "crates/export/src/tests.rs::rtf_escapes_and_sizes", "crates/export/src/tests.rs::text_colour_survives_every_format", "crates/export/src/tests.rs::word_opens_text_with_control_characters_and_long_pages", "crates/automation/tests/it/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/it/compare.rs::export_to_word_html_and_rtf"] notes = "Text in slots covered by a preceding table-cell span remains in paragraph flow instead of being discarded. Paragraphs with sizes, bold, italic, text colour (colortbl), Unicode; a block that continues another on its line is a formatted group inside that paragraph (#4158), though a style change that wraps onto the next line still splits it; page breaks. Images are left out." [[feature]] @@ -7088,18 +7088,18 @@ milestone = "M10" status = "partial" commands = ["export.pptx"] tools = ["doc_export_office"] -evidence = ["crates/export/src/lib.rs::table_spans_do_not_consume_text_they_do_not_emit", - "crates/export/src/pptx.rs::pptx_is_a_presentationml_package", - "crates/export/src/pptx.rs::pptx_slide_has_title_body_and_images_in_order", - "crates/export/src/pptx.rs::pptx_text_colour_and_formatting", - "crates/export/src/pptx.rs::pptx_empty_pages_produce_valid_slide", - "crates/export/src/pptx.rs::pptx_slide_dimensions_and_clamping", - "crates/export/src/pptx.rs::pptx_table_cell_with_largest_font_stays_in_table_and_not_in_title", - "crates/export/src/pptx.rs::pptx_opens_in_libreoffice", - "crates/export/src/pptx.rs::pptx_package_parts_are_complete_and_consistent", - "crates/export/src/pptx.rs::hostile_sizes_do_not_overflow_the_slide_position", - "crates/export/src/lib.rs::grid_text_becomes_a_real_table_in_every_format", - "crates/export/src/lib.rs::text_colour_survives_every_format", +evidence = ["crates/export/src/tests.rs::table_spans_do_not_consume_text_they_do_not_emit", + "crates/export/src/pptx/tests.rs::pptx_is_a_presentationml_package", + "crates/export/src/pptx/tests.rs::pptx_slide_has_title_body_and_images_in_order", + "crates/export/src/pptx/tests.rs::pptx_text_colour_and_formatting", + "crates/export/src/pptx/tests.rs::pptx_empty_pages_produce_valid_slide", + "crates/export/src/pptx/tests.rs::pptx_slide_dimensions_and_clamping", + "crates/export/src/pptx/tests.rs::pptx_table_cell_with_largest_font_stays_in_table_and_not_in_title", + "crates/export/src/pptx/tests.rs::pptx_opens_in_libreoffice", + "crates/export/src/pptx/tests.rs::pptx_package_parts_are_complete_and_consistent", + "crates/export/src/pptx/tests.rs::hostile_sizes_do_not_overflow_the_slide_position", + "crates/export/src/tests.rs::grid_text_becomes_a_real_table_in_every_format", + "crates/export/src/tests.rs::text_colour_survives_every_format", "crates/engine/src/tests.rs::exporting_office_files_keeps_images", "crates/engine/src/tests.rs::exporting_office_files_keeps_text_colour", "crates/automation/tests/it/automation.rs::exporting_to_word_html_and_rtf", @@ -7164,7 +7164,7 @@ milestone = "M10" status = "partial" commands = ["export.html"] tools = ["doc_export_office"] -evidence = ["crates/export/src/lib.rs::table_spans_do_not_consume_text_they_do_not_emit", "crates/export/src/lib.rs::html_has_headings_paragraphs_and_images_in_order", "crates/export/src/lib.rs::text_colour_survives_every_format", "crates/export/src/lib.rs::grid_text_becomes_a_real_table_in_every_format", "crates/automation/tests/it/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/it/compare.rs::export_to_word_html_and_rtf", "crates/automation/tests/it/automation.rs::exporting_office_files_says_which_pages_left_form_text_out", "crates/edit/src/tests.rs::reading_follows_forms_as_far_as_the_renderer_and_says_where_it_stops"] +evidence = ["crates/export/src/tests.rs::table_spans_do_not_consume_text_they_do_not_emit", "crates/export/src/tests.rs::html_has_headings_paragraphs_and_images_in_order", "crates/export/src/tests.rs::text_colour_survives_every_format", "crates/export/src/tests.rs::grid_text_becomes_a_real_table_in_every_format", "crates/automation/tests/it/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/it/compare.rs::export_to_word_html_and_rtf", "crates/automation/tests/it/automation.rs::exporting_office_files_says_which_pages_left_form_text_out", "crates/edit/src/tests.rs::reading_follows_forms_as_far_as_the_renderer_and_says_where_it_stops"] notes = "Text in slots covered by a preceding table-cell span remains in paragraph flow instead of being discarded. Single HTML file with headings, paragraphs (with text colour), inline images, and detected tables with spans/empty cells. Text and images in form XObjects are read 50 levels deep, as the renderer does, and up to 50 000 forms a page; the tool lists pages past that in truncated_pages, with a warning (#3120). Not yet: multi-page folder output, CSS from fonts." [[feature]] @@ -7272,7 +7272,7 @@ milestone = "M2" status = "shipped" commands = [] tools = ["text_extract"] -evidence = ["crates/render/src/text.rs::rtl_runs_come_out_in_logical_order_and_cjk_has_no_spaces", "crates/render/src/text.rs::lines_words_and_search", "crates/automation/tests/it/automation.rs::parallel_text_extraction_keeps_page_order_and_follows_edits"] +evidence = ["crates/render/src/text/tests.rs::rtl_runs_come_out_in_logical_order_and_cjk_has_no_spaces", "crates/render/src/text/tests.rs::lines_words_and_search", "crates/automation/tests/it/automation.rs::parallel_text_extraction_keeps_page_order_and_follows_edits"] notes = "" [[feature]] @@ -7336,7 +7336,7 @@ milestone = "M11" status = "partial" commands = ["optimize.advanced"] tools = ["doc_optimize"] -evidence = ["crates/automation/tests/it/optimize_content_boundaries.rs::fragment_merge_preserves_a_comment_across_content_pieces", "crates/ui-egui/tests/it/create.rs::the_pdf_optimizer_dialog_saves_an_optimized_copy", "crates/automation/tests/it/automation.rs::optimizing_through_tools", "crates/ui-egui/src/optimize_ui.rs::the_progress_card_shows_the_stage_and_cancels", "crates/engine/src/tests.rs::an_optimize_job_reports_its_stages_and_can_be_cancelled", "crates/optimize/tests/optimize.rs::images_and_forms_nothing_draws_are_dropped", "crates/automation/tests/it/automation.rs::a_deleted_image_is_not_kept_by_reduce_or_optimize", "crates/ui-egui/tests/it/create.rs::the_pdf_optimizer_dialog_picks_a_preset", "crates/automation/tests/it/automation.rs::a_page_printed_in_strips_gets_small_through_tools", "crates/optimize/tests/optimize.rs::the_mobile_preset_makes_a_printed_page_small"] +evidence = ["crates/automation/tests/it/optimize_content_boundaries.rs::fragment_merge_preserves_a_comment_across_content_pieces", "crates/ui-egui/tests/it/create.rs::the_pdf_optimizer_dialog_saves_an_optimized_copy", "crates/automation/tests/it/automation.rs::optimizing_through_tools", "crates/ui-egui/src/workflows/optimize_ui.rs::the_progress_card_shows_the_stage_and_cancels", "crates/engine/src/tests.rs::an_optimize_job_reports_its_stages_and_can_be_cancelled", "crates/optimize/tests/optimize.rs::images_and_forms_nothing_draws_are_dropped", "crates/automation/tests/it/automation.rs::a_deleted_image_is_not_kept_by_reduce_or_optimize", "crates/ui-egui/tests/it/create.rs::the_pdf_optimizer_dialog_picks_a_preset", "crates/automation/tests/it/automation.rs::a_page_printed_in_strips_gets_small_through_tools", "crates/optimize/tests/optimize.rs::the_mobile_preset_makes_a_printed_page_small"] notes = "Settings menu with the Standard and Mobile presets (Custom once an option changes; doc_optimize preset), Images (with Merge image fragments: a page picture drawn as one-pixel strips, as some PDF printers write it, is put back together so it can be resampled), Discard Objects, Discard User Data, Clean Up (also drops images and forms the pages list but never draw, such as deleted images); runs in the background with a progress bar (per image) and Cancel; no Fonts or Transparency panels, no saved custom presets, no space audit" [[feature]] @@ -8012,7 +8012,7 @@ milestone = "M10" status = "shipped" commands = ["print.dialog"] tools = ["doc_print", "printers"] -evidence = ["crates/ui-egui/tests/it/print.rs::paper_choice_survives_cancel_and_settings_restore", "crates/ui-egui/tests/it/print.rs::restored_paper_keeps_preview_and_saved_sheet_dimensions", "crates/ui-egui/tests/it/print.rs::print_dialog_lays_out_sheets_and_saves_a_pdf", "crates/engine/src/tests.rs::printing_lays_out_sheets_that_render", "crates/ui-egui/src/web_print.rs::a_job_draws_every_sheet_at_its_planned_resolution_then_builds_the_page", "crates/ui-egui/src/web_print.rs::the_print_page_turns_sideways_sheets_names_other_sizes_and_ends_without_a_blank_page", "crates/print/src/tests.rs::every_paper_reaches_the_spooler_by_a_name_of_its_size", "crates/automation/src/printing.rs::printing_to_a_printer_feeds_the_laid_out_paper"] +evidence = ["crates/ui-egui/tests/it/print.rs::paper_choice_survives_cancel_and_settings_restore", "crates/ui-egui/tests/it/print.rs::restored_paper_keeps_preview_and_saved_sheet_dimensions", "crates/ui-egui/tests/it/print.rs::print_dialog_lays_out_sheets_and_saves_a_pdf", "crates/engine/src/tests.rs::printing_lays_out_sheets_that_render", "crates/ui-egui/src/workflows/web_print.rs::a_job_draws_every_sheet_at_its_planned_resolution_then_builds_the_page", "crates/ui-egui/src/workflows/web_print.rs::the_print_page_turns_sideways_sheets_names_other_sizes_and_ends_without_a_blank_page", "crates/print/src/tests.rs::every_paper_reaches_the_spooler_by_a_name_of_its_size", "crates/automation/src/printing.rs::printing_to_a_printer_feeds_the_laid_out_paper"] notes = "⌘P; settings and live sheet preview; on the web the same dialog prints through the browser's print dialog; last paper selection, including cancelled Print dialogs, persists by canonical name; this change does not alter persistence of other print preferences. CUPS jobs from the dialog and doc_print ask for the chosen paper (media) in every size: PPD keywords for the common ones, PWG 5101.1 names for A6, A0–A2, ARCH and ANSI C–E, checked against a synthetic PPD with cupsfilter, not on a physical large-format printer (#3752). On macOS jobs still take the PPD's default paper tray until craft-print sends Auto Select (#3752)" [[feature]] @@ -8024,7 +8024,7 @@ milestone = "M10" status = "shipped" commands = ["print.dialog"] tools = ["printers", "printer_options", "doc_print"] -evidence = ["crates/print/src/tests.rs::spooler_arguments_and_printer_list", "crates/print/src/tests.rs::driverless_printers_join_the_list_without_a_queue", "crates/print/src/tests.rs::windows_printers_carry_one_default_and_keep_their_names", "crates/print/src/tests.rs::printer_options_come_from_the_ppd", "crates/print/src/tests.rs::printer_option_defaults_and_job_arguments", "crates/ui-egui/tests/it/print.rs::print_dialog_shows_the_printer_driver_options", "crates/ui-egui/tests/it/print.rs::printers_listed_in_the_background_preselect_the_default_unless_one_was_picked", "crates/ui-egui/src/web_print.rs::resolution_falls_with_the_sheet_count_and_long_jobs_are_refused", "crates/ui-egui/src/web_print.rs::large_sheets_are_drawn_within_the_pixel_cap_and_bad_sizes_are_refused", "crates/ui-egui/src/web_print.rs::a_sink_error_or_a_page_that_isnt_there_stops_the_job", "crates/print/src/tests.rs::a_command_that_never_answers_is_stopped_at_the_deadline", "crates/ui-egui/tests/it/print.rs::printer_options_are_read_off_the_gui_thread_and_a_dead_read_offers_nothing"] +evidence = ["crates/print/src/tests.rs::spooler_arguments_and_printer_list", "crates/print/src/tests.rs::driverless_printers_join_the_list_without_a_queue", "crates/print/src/tests.rs::windows_printers_carry_one_default_and_keep_their_names", "crates/print/src/tests.rs::printer_options_come_from_the_ppd", "crates/print/src/tests.rs::printer_option_defaults_and_job_arguments", "crates/ui-egui/tests/it/print.rs::print_dialog_shows_the_printer_driver_options", "crates/ui-egui/tests/it/print.rs::printers_listed_in_the_background_preselect_the_default_unless_one_was_picked", "crates/ui-egui/src/workflows/web_print.rs::resolution_falls_with_the_sheet_count_and_long_jobs_are_refused", "crates/ui-egui/src/workflows/web_print.rs::large_sheets_are_drawn_within_the_pixel_cap_and_bad_sizes_are_refused", "crates/ui-egui/src/workflows/web_print.rs::a_sink_error_or_a_page_that_isnt_there_stops_the_job", "crates/print/src/tests.rs::a_command_that_never_answers_is_stopped_at_the_deadline", "crates/ui-egui/tests/it/print.rs::printer_options_are_read_off_the_gui_thread_and_a_dead_read_offers_nothing"] notes = "CUPS queues via lpstat -p -d and driverless destinations without a queue via lpstat -e; on Windows shared craft-print lists the queues (EnumPrinters), the default (GetDefaultPrinter) and each driver's two-sided support in this process, and draws jobs as vectors with Windows.Data.Pdf into the XPS print API with a driver-validated PrintTicket (exact paper, orientation, no scaling; portrait and landscape sheets in one job via /Rotate), starting no PowerShell, so printing works under App Control script enforcement (#1107); the printer list and submission run on workers; Save as PDF on every platform. Properties…: on CUPS the driver's own PPD job options (tray, paper type, finishing …, not installable options or what the dialog sets) for this job, sent as lp -o; on Windows the driver's Printing Preferences window (printui), saved as that printer's per-user defaults rather than per job. On the web: \"Browser's print dialog\" (the default there) reaches the system's printers through the browser; PdfCraft draws the sheets itself (300 dpi up to 100 sheets, 200 up to 300, 150 up to 1,000; at most 64 megapixels a sheet and 1 GiB a job) and prints them from a hidden iframe, one per printed page, a landscape sheet among portrait ones turned onto the paper; copies, two-sided and colour are chosen in the browser's dialog; a browser that refuses to print gets the print-ready PDF as a download. Checked in headless Chromium, not yet in Firefox or Safari; pictures, not vectors; sheets of another paper size print at their own size only where the browser honours named @page sizes (Chromium doesn't). Properties… on CUPS reads the driver options off the GUI thread and gives lpoptions ten seconds before the PPD defaults stand (#1818)" [[feature]] @@ -8096,7 +8096,7 @@ milestone = "M10" status = "shipped" commands = ["print.dialog"] tools = ["doc_print"] -evidence = ["crates/print/src/tests.rs::spooler_arguments_and_printer_list", "crates/ui-egui/src/print_ui.rs::two_sided_follows_the_printer_until_chosen", "crates/automation/src/printing.rs::duplex_printer_leaves_two_sided_to_the_printer"] +evidence = ["crates/print/src/tests.rs::spooler_arguments_and_printer_list", "crates/ui-egui/src/workflows/print_ui.rs::two_sided_follows_the_printer_until_chosen", "crates/automation/src/printing.rs::duplex_printer_leaves_two_sided_to_the_printer"] notes = "long/short edge via the spooler: lp -o sides=…, and the Windows driver's Duplex setting. Two-sided starts at Printer default, as in Acrobat: nothing is sent, so the printer's own default (its Properties) applies until Off or an edge is chosen (doc_print: duplex \"printer\"). Missing: the document's /ViewerPreferences /Duplex preset isn't read yet" [[feature]] @@ -9180,7 +9180,7 @@ milestone = "M12" status = "shipped" commands = [] tools = ["view_reading_colors"] -evidence = ["crates/automation/tests/it/automation.rs::reading_colors_through_tools", "crates/render/src/raster.rs::reading_colors_map_solid_paints_and_leave_images", "crates/render/src/raster.rs::reading_colors_tiles_match_full_render", "crates/render/src/raster.rs::print_preview_requests_render_with_reading_colors_off", "crates/render/src/raster.rs::reading_colors_keep_highlight_annotations_readable", "crates/ui-egui/tests/it/reading_colors.rs::reading_colours_preference_round_trips", "crates/ui-egui/tests/it/reading_colors.rs::reading_colours_seed_on_open", "crates/ui-egui/tests/it/reading_colors.rs::pending_pages_use_document_reading_background"] +evidence = ["crates/automation/tests/it/automation.rs::reading_colors_through_tools", "crates/render/src/raster/tests.rs::reading_colors_map_solid_paints_and_leave_images", "crates/render/src/raster/tests.rs::reading_colors_tiles_match_full_render", "crates/render/src/raster/tests.rs::print_preview_requests_render_with_reading_colors_off", "crates/render/src/raster/tests.rs::reading_colors_keep_highlight_annotations_readable", "crates/ui-egui/tests/it/reading_colors.rs::reading_colours_preference_round_trips", "crates/ui-egui/tests/it/reading_colors.rs::reading_colours_seed_on_open", "crates/ui-egui/tests/it/reading_colors.rs::pending_pages_use_document_reading_background"] notes = "View only, off by default: Preferences ▸ Reading colours holds the app-wide preference, seeded into every document as it opens (including created ones), and view_reading_colors reads and sets it per document (Acrobat's Replace Document Colors). Solid text paints and, by default, line-art paints (including stencil-mask image paints) are remapped by luminance — each lerps between the preset's text and background colour by how dark or light the original paint is — while images, shadings and tiling patterns pass through untouched. Annotations keep the colours they were given (both /AP appearances and the no-/AP Highlight fallback), so a Multiply highlight over a dark background shows the marked-up words in the highlight's colour instead of a flat bar that hides them (#1548). Presets: black on white, white on black, yellow on black, green on black, or a custom #RRGGBB pair; only_black limits the mapping to near-black paints (a 10% luminance threshold — our choice; Acrobat's was not observed). Display only: the document is not changed and print and export are unaffected; print previews and view snapshots render with the document's true colours (a preview matches the uncoloured printout, and a snapshot is an export surface), so reading colours apply to the reading view only. Pending canvas pages use the configured reading background before their raster arrives (#2059). Closes #897 (duplicate #488)." [[feature]] @@ -10012,7 +10012,7 @@ milestone = "M14" status = "partial" commands = ["app.preferences"] tools = [] -evidence = ["crates/ui-egui/src/commands.rs::localized_shortcut_labels_follow_language_and_platform", "crates/ui-egui/src/i18n/mod.rs::keyboard_key_names_have_a_separate_context_and_english_fallback", "crates/ui-egui/tests/it/commands.rs::localized_shortcuts_update_immediately_and_keep_command_ids_and_key_bindings", "crates/ui-egui/src/i18n/mod.rs::windows_display_language_resolves_chinese_scripts", "crates/ui-egui/src/i18n/mod.rs::neutral_locale_does_not_override_display_language", "crates/ui-egui/src/i18n/mod.rs::windows_display_language_query_returns_a_locale_tag", "crates/ui-egui/src/i18n/mod.rs::language_persists_and_invalid_input_keeps_current_language", "crates/ui-egui/src/i18n/mod.rs::bundled_catalogs_are_consistent", "crates/ui-egui/src/i18n/mod.rs::arabic_is_registered_and_persists", "crates/ui-egui/src/i18n/mod.rs::arabic_catalog_stays_logical", "crates/ui-egui/src/i18n/mod.rs::arabic_plurals_follow_the_six_cldr_forms", "crates/ui-egui/src/i18n/mod.rs::arabic_labels_have_glyphs", "crates/ui-egui/src/bidi.rs::arabic_ui_labels_use_an_explicit_paragraph_direction", "crates/ui-egui/src/theme.rs::arabic_chrome_is_right_to_left_after_the_language_changes", "crates/ui-egui/tests/it/control.rs::simplified_chinese_signature_prompts_and_errors_keep_document_state", "crates/ui-egui/tests/it/control.rs::language_switch_preserves_document_and_command_ids", "crates/ui-egui/tests/it/control.rs::japanese_controls_and_search_keep_command_ids", "crates/ui-egui/tests/it/control.rs::japanese_dialogs_errors_and_custom_action_names", "crates/ui-egui/src/i18n/mod.rs::french_is_registered", "crates/ui-egui/src/widgets.rs::search_placeholder_never_overlaps_the_shortcut", "crates/ui-egui/src/home.rs::translated_card_description_leaves_room_for_the_action", "crates/ui-egui/tests/it/control.rs::german_preferences_and_search_keep_command_ids", "crates/ui-egui/src/i18n/mod.rs::telugu_labels_have_glyphs", "crates/ui-egui/src/i18n/mod.rs::hungarian_is_registered", "crates/ui-egui/src/i18n/mod.rs::ukrainian_is_registered_and_persists", "crates/ui-egui/src/i18n/mod.rs::ukrainian_integer_plurals_cover_teens_and_large_counts", "crates/ui-egui/src/i18n/mod.rs::spanish_keeps_the_holes_in_what_the_engine_says", "crates/print/src/tests.rs::wordings_are_said", "crates/ui-egui/src/i18n/mod.rs::norwegian_bokmal_is_registered_and_persists"] +evidence = ["crates/ui-egui/src/commands.rs::localized_shortcut_labels_follow_language_and_platform", "crates/ui-egui/src/i18n/tests.rs::keyboard_key_names_have_a_separate_context_and_english_fallback", "crates/ui-egui/tests/it/commands.rs::localized_shortcuts_update_immediately_and_keep_command_ids_and_key_bindings", "crates/ui-egui/src/i18n/tests.rs::windows_display_language_resolves_chinese_scripts", "crates/ui-egui/src/i18n/tests.rs::neutral_locale_does_not_override_display_language", "crates/ui-egui/src/i18n/tests.rs::windows_display_language_query_returns_a_locale_tag", "crates/ui-egui/src/i18n/tests.rs::language_persists_and_invalid_input_keeps_current_language", "crates/ui-egui/src/i18n/tests.rs::bundled_catalogs_are_consistent", "crates/ui-egui/src/i18n/tests.rs::arabic_is_registered_and_persists", "crates/ui-egui/src/i18n/tests.rs::arabic_catalog_stays_logical", "crates/ui-egui/src/i18n/tests.rs::arabic_plurals_follow_the_six_cldr_forms", "crates/ui-egui/src/i18n/tests.rs::arabic_labels_have_glyphs", "crates/ui-egui/src/bidi/tests.rs::arabic_ui_labels_use_an_explicit_paragraph_direction", "crates/ui-egui/src/theme/tests.rs::arabic_chrome_is_right_to_left_after_the_language_changes", "crates/ui-egui/tests/it/control.rs::simplified_chinese_signature_prompts_and_errors_keep_document_state", "crates/ui-egui/tests/it/control.rs::language_switch_preserves_document_and_command_ids", "crates/ui-egui/tests/it/control.rs::japanese_controls_and_search_keep_command_ids", "crates/ui-egui/tests/it/control.rs::japanese_dialogs_errors_and_custom_action_names", "crates/ui-egui/src/i18n/tests.rs::french_is_registered", "crates/ui-egui/src/widgets.rs::search_placeholder_never_overlaps_the_shortcut", "crates/ui-egui/src/home.rs::translated_card_description_leaves_room_for_the_action", "crates/ui-egui/tests/it/control.rs::german_preferences_and_search_keep_command_ids", "crates/ui-egui/src/i18n/tests.rs::telugu_labels_have_glyphs", "crates/ui-egui/src/i18n/tests.rs::hungarian_is_registered", "crates/ui-egui/src/i18n/tests.rs::ukrainian_is_registered_and_persists", "crates/ui-egui/src/i18n/tests.rs::ukrainian_integer_plurals_cover_teens_and_large_counts", "crates/ui-egui/src/i18n/tests.rs::spanish_keeps_the_holes_in_what_the_engine_says", "crates/print/src/tests.rs::wordings_are_said", "crates/ui-egui/src/i18n/tests.rs::norwegian_bokmal_is_registered_and_persists"] notes = "Shortcut modifiers and key names use a dedicated catalog context across toolbar, menus, palette, Help and tooltips, with English fallback and macOS symbols preserved. Preferences switches Auto (system language)/English/Japanese/Simplified Chinese/Traditional Chinese/Czech/Brazilian Portuguese/German/Spanish/French/Telugu/Hungarian/Ukrainian/Italian/Arabic/Norwegian Bokmål and persists; ui.set language auto|en|ja|zh-hans|zh-hant|cs|pt-br|de|es|fr|te|hu|uk|it|ar|nb and ui.state expose it. TSV catalogs per language (same system as PhotoCraft); every dialog, panel and notice goes through tl!. Japanese, Traditional Chinese, Simplified Chinese, German, Spanish, French and Hungarian cover commands, dialogs, panels, shortcuts and history labels; Brazilian Portuguese covers the current union of catalog keys, including signature and accessibility messages; Czech remains limited to menus. French locales (fr, fr_FR, fr_CA, fr_BE) select the French catalog, German locales (de, de_DE, de_AT, de_CH) the German one, Hungarian locales (hu, hu_HU) the Hungarian one, and Norwegian locales (nb, nb_NO, and no/nn until a Nynorsk catalog exists) the Bokmål one. German search hints reserve shortcut space, Home cards bound translated text, certificate Subject has its own context, and inserted tool names keep their capitalization. The sentences the layers below word for themselves (accessibility findings and the saved report, PDF/A problems, signature details, page-range mistakes, edit labels) are handed over apart from the document's own values and go through the catalog too, through said(&i18n::words); Spanish, Italian and Brazilian Portuguese translate them, Polish the signature details and the changes after signing; strings absent from other catalogs fall back to English. OS error details, and faults rather than mistakes, stay as they are. Auto detects the Windows display language after explicitly named supported locale overrides; neutral C/POSIX launcher locales are skipped on Windows, with Chinese script/region mapping and English fallback. Arabic (ar and every ar-* locale) covers commands, dialogs and panels with the six CLDR plural forms. Catalogs stay logical and egui 0.37's epaint applies UAX #9 reordering; Arabic and Kurdish labels take an explicit right-to-left paragraph direction and stock chrome is mirrored through Style::right_to_left. Missing: native and browser end-to-end RTL input, IME and complex bidirectional workflows are not yet qualified; Czech dialogs, Chinese and Telugu font delivery (native release builds embed LXGW WenKai Lite for Simplified Chinese, while the web build has no Chinese face beyond BIZ UDPGothic and the release-pinned craft-fonts has no Telugu face), the engine's sentences in the other catalogs, a full localization audit." [[feature]] @@ -10144,7 +10144,7 @@ milestone = "M14" status = "partial" commands = [] tools = [] -evidence = ["crates/ui-egui/src/i18n/mod.rs::bundled_catalogs_are_consistent", "crates/ui-egui/src/i18n/mod.rs::date_languages_are_the_interface_languages", "crates/ui-egui/src/i18n/mod.rs::language_persists_and_invalid_input_keeps_current_language"] +evidence = ["crates/ui-egui/src/i18n/tests.rs::bundled_catalogs_are_consistent", "crates/ui-egui/src/i18n/tests.rs::date_languages_are_the_interface_languages", "crates/ui-egui/src/i18n/tests.rs::language_persists_and_invalid_input_keeps_current_language"] notes = "The UI offers 22 non-English catalogs plus English fallback (entry counts vary by language), and the selected language persists. Partial because the languages are not all usable yet: Simplified Chinese has a native release LXGW WenKai Lite face but the web build has no Hans face (#826, #688), the release-pinned craft-fonts has no Telugu face, Arabic labels stay logical with explicit right-to-left direction and mirrored stock chrome while native/browser/IME RTL workflows are not yet qualified (misc.rtl-ui), and strings missing from a catalog fall back to English (localization-parity.md). The language can be set through the UI control channel (`set language`) but not through an automation tool, so pdfcraft-cli run and MCP cannot change it." [[feature]] diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml index 7a33bad875..028282e073 100644 --- a/xtask/Cargo.toml +++ b/xtask/Cargo.toml @@ -5,6 +5,9 @@ edition = "2024" publish = false license.workspace = true +[lints] +workspace = true + [dependencies] pdfcraft-oracle = { workspace = true } pdfcraft-cos = { workspace = true } diff --git a/xtask/src/demo_pdf/mod.rs b/xtask/src/demo_pdf.rs similarity index 100% rename from xtask/src/demo_pdf/mod.rs rename to xtask/src/demo_pdf.rs diff --git a/xtask/src/demo_pdf/form.rs b/xtask/src/demo_pdf/form.rs index e4d782bc0e..39f918feba 100644 --- a/xtask/src/demo_pdf/form.rs +++ b/xtask/src/demo_pdf/form.rs @@ -38,6 +38,15 @@ struct Builder<'a> { fields: Vec, } +struct TextFieldSpec<'a> { + field: &'a str, + tooltip: &'a str, + rect: Rect, + value: &'a str, + display: Option<&'a str>, + quadding: i64, +} + pub fn build(doc: &mut Document, annotator: &mut Annotator, inputs: FormInputs<'_>) -> FormPage { let page = doc.new_object_id(); let mut b = Builder { doc, fonts: inputs.fonts, page, widgets: Vec::new(), fields: Vec::new() }; @@ -48,26 +57,76 @@ pub fn build(doc: &mut Document, annotator: &mut Annotator, inputs: FormInputs<' section(&mut c, 612.0, "Contact"); let row1 = 575.0; label(&mut c, LEFT, row1 + 24.0, "Full name"); - b.text_field("name", "Full name", Rect::new(LEFT, row1, 296.0, row1 + 20.0), "Ada Typesetter", None, 0, |_| {}); + b.text_field( + TextFieldSpec { + field: "name", + tooltip: "Full name", + rect: Rect::new(LEFT, row1, 296.0, row1 + 20.0), + value: "Ada Typesetter", + display: None, + quadding: 0, + }, + |_| {}, + ); label(&mut c, COL2, row1 + 24.0, "Email"); - b.text_field("email", "Email address", Rect::new(COL2, row1, RIGHT, row1 + 20.0), "ada@example.com", None, 0, |_| {}); + b.text_field( + TextFieldSpec { + field: "email", + tooltip: "Email address", + rect: Rect::new(COL2, row1, RIGHT, row1 + 20.0), + value: "ada@example.com", + display: None, + quadding: 0, + }, + |_| {}, + ); let row2 = 531.0; label(&mut c, LEFT, row2 + 24.0, "Date (AFDate_FormatEx yyyy-mm-dd)"); - b.text_field("date", "Date", Rect::new(LEFT, row2, 296.0, row2 + 20.0), "2026-09-30", None, 0, |d| { - d.set("AA", js_actions("AFDate_FormatEx(\"yyyy-mm-dd\");", "AFDate_KeystrokeEx(\"yyyy-mm-dd\");")); - }); + b.text_field( + TextFieldSpec { + field: "date", + tooltip: "Date", + rect: Rect::new(LEFT, row2, 296.0, row2 + 20.0), + value: "2026-09-30", + display: None, + quadding: 0, + }, + |d| { + d.set("AA", js_actions("AFDate_FormatEx(\"yyyy-mm-dd\");", "AFDate_KeystrokeEx(\"yyyy-mm-dd\");")); + }, + ); label(&mut c, COL2, row2 + 24.0, "Amount (AFNumber_Format, right-aligned)"); - b.text_field("amount", "Amount in US dollars", Rect::new(COL2, row2, RIGHT, row2 + 20.0), "1234.5", Some("$1,234.50"), 2, |d| { - d.set("AA", js_actions("AFNumber_Format(2, 0, 0, 0, \"$\", true);", "AFNumber_Keystroke(2, 0, 0, 0, \"$\", true);")); - }); + b.text_field( + TextFieldSpec { + field: "amount", + tooltip: "Amount in US dollars", + rect: Rect::new(COL2, row2, RIGHT, row2 + 20.0), + value: "1234.5", + display: Some("$1,234.50"), + quadding: 2, + }, + |d| { + d.set("AA", js_actions("AFNumber_Format(2, 0, 0, 0, \"$\", true);", "AFNumber_Keystroke(2, 0, 0, 0, \"$\", true);")); + }, + ); label(&mut c, LEFT, 510.0, "Comments (multiline)"); let comments = "PdfCraft renders this field from its own appearance stream, so it looks \ the same in every viewer. Edit it to see your viewer regenerate the text."; - b.text_field("comments", "Comments", Rect::new(LEFT, 446.0, RIGHT, 506.0), comments, None, 0, |d| { - d.set("Ff", 4096); - }); + b.text_field( + TextFieldSpec { + field: "comments", + tooltip: "Comments", + rect: Rect::new(LEFT, 446.0, RIGHT, 506.0), + value: comments, + display: None, + quadding: 0, + }, + |d| { + d.set("Ff", 4096); + }, + ); // --- Preferences ----------------------------------------------------------- section(&mut c, 418.0, "Preferences"); @@ -239,17 +298,8 @@ impl Builder<'_> { /// A text field; `display` overrides the appearance text (for formatted values), /// `quadding` is 0 left, 1 centre, 2 right. - #[allow(clippy::too_many_arguments)] - fn text_field( - &mut self, - field: &str, - tooltip: &str, - r: Rect, - value: &str, - display: Option<&str>, - quadding: i64, - customise: impl FnOnce(&mut Dictionary), - ) { + fn text_field(&mut self, spec: TextFieldSpec<'_>, customise: impl FnOnce(&mut Dictionary)) { + let TextFieldSpec { field, tooltip, rect: r, value, display, quadding } = spec; let (w, h) = (r.w(), r.h()); let mut d = self.widget_base(r); d.set("FT", "Tx"); diff --git a/xtask/src/parity.rs b/xtask/src/parity.rs index ac32f32e85..39e0cac8de 100644 --- a/xtask/src/parity.rs +++ b/xtask/src/parity.rs @@ -229,6 +229,16 @@ pub fn run(args: &[String]) -> Result<()> { ); } } + // A warning, not an error: shipped means exists and tested, and the notes say what is still missing (CLAUDE.md). + let without_notes: Vec<&str> = + file.features.iter().filter(|f| f.status == "shipped" && f.notes.trim().is_empty()).map(|f| f.id.as_str()).collect(); + if !without_notes.is_empty() && !args.iter().any(|a| a == "--json") { + eprintln!( + "parity warning: {} shipped feature(s) have empty notes (say what is still missing): {}", + without_notes.len(), + without_notes.join(", ") + ); + } if !errors.is_empty() { for e in &errors { eprintln!("parity error: {e}");