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 ac276f13c9..94a4c545fe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,6 +32,9 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + - name: Windows font notice regressions + shell: pwsh + run: ./packaging/windows/test-font-notices.ps1 - name: Git dependencies come from storytold run: | set -euo pipefail @@ -73,13 +76,13 @@ jobs: # the org's 5 macOS slots are kept for release builds, and release.yml builds macOS. os: ${{ (github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'full-ci')) && '["ubuntu-24.04","windows-latest"]' || '["ubuntu-24.04"]' }} # Same craft-fonts commit as release.yml and freebsd.yml. - craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3 + craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83 # egui/winit/rfd, and the independent oracles (crates/oracle). packages: >- libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev libgtk-3-dev qpdf poppler-utils # cargo-deny for the deny step of `cargo xtask ci`, which requires it under CI. - tools: cargo-nextest@0.9.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/freebsd.yml b/.github/workflows/freebsd.yml index fba20b1cc7..268da70ca1 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -26,4 +26,4 @@ jobs: # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main with: - craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3 + craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c6ee0e1c31..a5c2ba7f7f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,7 +24,7 @@ jobs: with: app-name: pdfcraft app-title: PdfCraft - craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3 + craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83 version: ${{ inputs.version }} secrets: APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 7ca1124ce2..23ebcbbe82 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -4,37 +4,38 @@ Every asset PdfCraft includes, bundles or uses to build its published material, with its author, source and licence. The policy is in [AGENTS.md](AGENTS.md) §1. The machine-readable list, with the SHA-256 of every asset but the translation catalogs, is [ATTRIBUTION.toml](ATTRIBUTION.toml). Licence texts are kept beside the assets and summarised in [NOTICE](NOTICE). -## In this repository (288) +## In this repository (289) | Asset | Title | Author | Licence | Source | Used for | |---|---|---|---|---|---| -| `crates/ui-egui/src/i18n/el.tsv` | Greek interface translation catalog | Camthalion77 and contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft English UI labels; 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | Central Kurdish 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | @@ -285,26 +286,27 @@ 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 | 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 | 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 | 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 | 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 | 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 | 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 (5) +## Compiled in through dependencies (6) | Crate | File | Title | Author | Licence | Source | Used for | |---|---|---|---|---|---|---| -| `epaint_default_fonts` 0.36.2 | `fonts/Ubuntu-Light.ttf` | Ubuntu Light | Canonical Ltd. / Dalton Maag | Ubuntu-font-1.0 | https://design.ubuntu.com/font | egui fallback fonts (eframe feature default_fonts) | -| `epaint_default_fonts` 0.36.2 | `fonts/Hack-Regular.ttf` | Hack Regular | Source Foundry Authors (derived from Bitstream Vera / DejaVu) | MIT AND Bitstream-Vera | https://github.com/source-foundry/Hack | egui fallback fonts (eframe feature default_fonts) | -| `epaint_default_fonts` 0.36.2 | `fonts/NotoEmoji-Regular.ttf` | Noto Emoji (monochrome) | Google Inc. | OFL-1.1 | https://github.com/googlefonts/noto-emoji | egui fallback fonts (eframe feature default_fonts) | -| `epaint_default_fonts` 0.36.2 | `fonts/emoji-icon-font.ttf` | emoji-icon-font | John Slegers | MIT | https://github.com/jslegers/emoji-icon-font | egui fallback fonts (eframe feature default_fonts) | +| `epaint_default_fonts` 0.37.0 | `fonts/Ubuntu-Light.ttf` | Ubuntu Light | Canonical Ltd. / Dalton Maag | Ubuntu-font-1.0 | https://design.ubuntu.com/font | egui fallback fonts (eframe feature default_fonts) | +| `epaint_default_fonts` 0.37.0 | `fonts/Hack-Regular.ttf` | Hack Regular | Source Foundry Authors (derived from Bitstream Vera / DejaVu) | MIT AND Bitstream-Vera | https://github.com/source-foundry/Hack | egui fallback fonts (eframe feature default_fonts) | +| `epaint_default_fonts` 0.37.0 | `fonts/NotoEmoji-Regular.ttf` | Noto Emoji (monochrome) | Google Inc. | OFL-1.1 | https://github.com/googlefonts/noto-emoji | egui monochrome fallback font (egui feature monochrome_emoji_fonts) | +| `epaint_default_fonts` 0.37.0 | `fonts/emoji-icon-font.ttf` | emoji-icon-font | John Slegers | MIT | https://github.com/jslegers/emoji-icon-font | egui monochrome fallback font (egui feature monochrome_emoji_fonts) | +| `epaint_default_fonts` 0.37.0 | `fonts/egui-icons.ttf` | egui icons (monochrome emoji-icon-font subset) | John Slegers | MIT | https://github.com/jslegers/emoji-icon-font | Small special-icon subset embedded by egui default_fonts; retained in FontDefinitions::default(). Provenance: https://github.com/storytold/egui/blob/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts/egui-icons.txt; complete MIT copyright and permission text in NOTICE. | | `hayro-interpret` 0.7.0 | `src/font/generated/metrics.rs` | Standard-14 font metrics and encodings (as Rust tables) (non-visual data, AGENTS.md §1.1) | Adobe (Core 14 AFM metrics, PDF specification encodings); tables by The Hayro Authors | MIT OR Apache-2.0 | ISO 32000-2 Annex D; Adobe Core14 AFM files (via vendored hayro-interpret) | Glyph widths and encodings for non-embedded standard fonts. Non-visual data, allowed by AGENTS.md §1.1 | ## Downloaded at build time (22) @@ -333,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) @@ -342,4 +344,4 @@ Not in this repository and never downloaded by it: compiled in only when the bui | Input | Option | Title | Author | Licence | Source | Attribution | Used for | |---|---|---|---|---|---|---|---| -| `craft-fonts` | `CRAFT_FONTS_DIR` | Japanese fonts: BIZ UDPGothic (Regular, Bold), Shippori Mincho, BIZ UDMincho; Arabic font: Noto Sans Arabic | The BIZ UDGothic and BIZ UDMincho Project Authors (Morisawa Inc.), The Shippori Mincho Project Authors (FONTDASU), The Noto Project Authors (Noto Sans Arabic) | OFL-1.1 | https://github.com/storytold/craft-fonts (pinned by commit in .github/workflows/release.yml) | https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md | Japanese fallback in the interface (BIZ UDPGothic first) and Japanese glyphs in edited PDF text (Shippori Mincho); Arabic interface text, file names and document titles (Noto Sans Arabic); the web build embeds only BIZ UDPGothic Regular and Noto Sans Arabic. The checkout also holds Noto Sans CJK SC, which crates/fonts/build.rs leaves out for size | +| `craft-fonts` | `CRAFT_FONTS_DIR` | Japanese fonts: BIZ UDPGothic (Regular, Bold), Shippori Mincho, BIZ UDMincho; native Simplified Chinese font: LXGW WenKai Lite; Arabic font: Noto Sans Arabic | The BIZ UDGothic and BIZ UDMincho Project Authors (Morisawa Inc.), The Shippori Mincho Project Authors (FONTDASU), LXGW and the original Klee Project Authors / Fontworks (LXGW WenKai Lite), The Noto Project Authors (Noto Sans Arabic) | OFL-1.1 | https://github.com/storytold/craft-fonts (pinned by commit in .github/workflows/release.yml and .github/workflows/ci.yml); LXGW WenKai Lite from https://github.com/lxgw/LxgwWenKai-Lite/releases/tag/v1.530 (commit b712c83d98acf977bacfb7fb010207802808bda9) | https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md | Japanese fallback in the interface (BIZ UDPGothic first) and Japanese glyphs in edited PDF text (Shippori Mincho); Simplified Chinese interface fallback on native builds only (LXGW WenKai Lite Regular, font SHA-256 f799a538a3355877e597a0ae04050e897932c9ae7689eb9227c2953c5dd549ea, OFL SHA-256 0333a85fe077b6849f8e417acc44e8bd879442ceb681e271acf93e2d57fd4e2e, read from fonts/simplified-chinese-manifest.txt and never embedded on wasm); Arabic interface text, file names and document titles (Noto Sans Arabic); the web build embeds BIZ UDPGothic Regular and the pinned small Arabic, Hebrew, Thai and Devanagari faces; the selected craft-fonts revision contains no Telugu face. Other non-Japanese CJK faces retain the general 4 MiB cap; the reviewed native-only LXGW fallback has a separate 16 MiB cap. Open-source OFL releases are permitted under current AGENTS.md §1.1 | diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index 9ca337e396..489247559d 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -2,11 +2,11 @@ path = "crates/ui-egui/src/i18n/el.tsv" title = "Greek interface translation catalog" author = "Camthalion77 and contributors" -source = "Contributor-original: clean-room translations of PdfCraft English UI labels; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft English UI labels; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "Greek UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" +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`). @@ -26,260 +26,270 @@ usage = "Greek UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" path = "crates/ui-egui/src/i18n/zh-hans.tsv" title = "Simplified Chinese interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 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" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" +usage = "Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/zh-hant.tsv" title = "Traditional Chinese (Taiwan) interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 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" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" +usage = "Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/cs.tsv" title = "Czech interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 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" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check and standard-identification wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "Czech UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" +usage = "Czech UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/pt-br.tsv" title = "Brazilian Portuguese interface translation catalog" author = "André Cardoso (andrecards), PdfCraft contributors" -source = "Clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources: the ~1,826-entry gist posted in issue #288 (https://gist.github.com/andrecards/ac388c25fba17312ce83207f8b7b467e), completed and reviewed by PdfCraft contributors; 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" +source = "Clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources: the ~1,826-entry gist posted in issue #288 (https://gist.github.com/andrecards/ac388c25fba17312ce83207f8b7b467e), completed and reviewed by PdfCraft contributors; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table limit messages are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" +usage = "Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/es.tsv" title = "Spanish interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 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" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; the 2026-10-20 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" +usage = "Spanish UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/fr.tsv" title = "French interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 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" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" +usage = "French UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/de.tsv" title = "German interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 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" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" +usage = "German UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/lo.tsv" title = "Lao interface translation catalog" author = "@bounkirdni-2025" -source = "Contributor-original: clean-room translations of PdfCraft English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "Lao UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" +usage = "Lao UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/te.tsv" title = "Telugu interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 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" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" +usage = "Telugu UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/it.tsv" title = "Italian interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 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" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "Italian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" +usage = "Italian UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/pl.tsv" title = "Polish interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 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" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "Polish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" +usage = "Polish UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/ru.tsv" title = "Russian interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 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" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" +usage = "Russian UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/hu.tsv" title = "Hungarian interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 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" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" +usage = "Hungarian UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/bg.tsv" title = "Bulgarian interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 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" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" +usage = "Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/kk.tsv" title = "Kazakh interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of the English UI labels into Kazakh, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of the English UI labels into Kazakh, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "Kazakh UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" +usage = "Kazakh UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/ja.tsv" title = "Japanese interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 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" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" +usage = "Japanese UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/uk.tsv" title = "Ukrainian interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 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" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" +usage = "Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/ar.tsv" title = "Arabic interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 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" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" +usage = "Arabic UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/ckb.tsv" title = "Central Kurdish (Sorani) interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "Central Kurdish UI strings, compiled in by crates/ui-egui/src/i18n/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" +title = "Persian (Farsi) interface translation catalog" +author = "PdfCraft contributors" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-25 initial catalog covering the current 2,776-entry key union, AI-assisted translations from English, not separately native-reviewed" +licence = "MIT OR Apache-2.0" +licence_file = "LICENSE-MIT" +kind = "translation" +usage = "Persian UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/tr.tsv" title = "Turkish interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "Turkish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" +usage = "Turkish UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/lt.tsv" title = "Lithuanian interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; original catalog reviewed by a native Lithuanian-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; original catalog reviewed by a native Lithuanian-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" +usage = "Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/hi.tsv" title = "Hindi interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "Hindi UI strings, compiled in by crates/ui-egui/src/i18n/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" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "Indonesian UI strings, compiled in by crates/ui-egui/src/i18n/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" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "Korean UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" +usage = "Korean UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/ro.tsv" title = "Romanian interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; AI-assisted and not yet reviewed line by line by a native Romanian speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; AI-assisted and not yet reviewed line by line by a native Romanian speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "Romanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" +usage = "Romanian UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/fi.tsv" title = "Finnish interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: AI-assisted (Claude Code) clean-room translations of PdfCraft's English UI labels and user-facing messages; not yet proofread by a native Finnish speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 calculation-order, text-format, print, thumbnail, layer, comment-reshape and signature labels (previously English placeholders) are contributor-original AI-assisted translations from English, not separately native-reviewed" +source = "Contributor-original: AI-assisted (Claude Code) clean-room translations of PdfCraft's English UI labels and user-facing messages; not yet proofread by a native Finnish speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 calculation-order, text-format, print, thumbnail, layer, comment-reshape and signature labels (previously English placeholders) are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "Finnish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" +usage = "Finnish UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "assets/icons/accessibility.svg" @@ -2429,7 +2439,7 @@ sha256 = "f225e0b044875b8fa6375b6b5ad935f0d99fcc1ad03ecf9f0baae2cedbfca4d2" [[bundled]] crate = "epaint_default_fonts" -version = "0.36.2" +version = "0.37.0" path = "fonts/Ubuntu-Light.ttf" title = "Ubuntu Light" author = "Canonical Ltd. / Dalton Maag" @@ -2441,7 +2451,7 @@ sha256 = "80307b8da7649aa4ee4d484b232140e3ce1ec0ca093073d3c53c8f5a5ced7a70" [[bundled]] crate = "epaint_default_fonts" -version = "0.36.2" +version = "0.37.0" path = "fonts/Hack-Regular.ttf" title = "Hack Regular" author = "Source Foundry Authors (derived from Bitstream Vera / DejaVu)" @@ -2453,28 +2463,40 @@ sha256 = "15f55cc0c85a2988d2b4b3a8cdb5d77fdfbaf319e1bb5309d725db9818fb7125" [[bundled]] crate = "epaint_default_fonts" -version = "0.36.2" +version = "0.37.0" path = "fonts/NotoEmoji-Regular.ttf" title = "Noto Emoji (monochrome)" author = "Google Inc." source = "https://github.com/googlefonts/noto-emoji" licence = "OFL-1.1" kind = "font" -usage = "egui fallback fonts (eframe feature default_fonts)" +usage = "egui monochrome fallback font (egui feature monochrome_emoji_fonts)" sha256 = "415dc6290378574135b64c808dc640c1df7531973290c4970c51fdeb849cb0c5" [[bundled]] crate = "epaint_default_fonts" -version = "0.36.2" +version = "0.37.0" path = "fonts/emoji-icon-font.ttf" title = "emoji-icon-font" author = "John Slegers" source = "https://github.com/jslegers/emoji-icon-font" licence = "MIT" kind = "font" -usage = "egui fallback fonts (eframe feature default_fonts)" +usage = "egui monochrome fallback font (egui feature monochrome_emoji_fonts)" sha256 = "f426bec371f484646d002717e2c555a95cd5145e635d83a48bbef83f3abf18ca" +[[bundled]] +crate = "epaint_default_fonts" +version = "0.37.0" +path = "fonts/egui-icons.ttf" +title = "egui icons (monochrome emoji-icon-font subset)" +author = "John Slegers" +source = "https://github.com/jslegers/emoji-icon-font" +licence = "MIT" +kind = "font" +usage = "Small special-icon subset embedded by egui default_fonts; retained in FontDefinitions::default(). Provenance: https://github.com/storytold/egui/blob/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts/egui-icons.txt; complete MIT copyright and permission text in NOTICE." +sha256 = "122806555c73df7cef55aa1d945289ae27fbc4dbbd31abbb4204ee0cda829cc8" + [[asset]] path = "vendor/hayro-cmap/assets/cmaps.brotli" title = "Adobe CMap resources (predefined CJK CMaps)" @@ -3334,41 +3356,41 @@ 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" option = "CRAFT_FONTS_DIR" -title = "Japanese fonts: BIZ UDPGothic (Regular, Bold), Shippori Mincho, BIZ UDMincho; Arabic font: Noto Sans Arabic" -author = "The BIZ UDGothic and BIZ UDMincho Project Authors (Morisawa Inc.), The Shippori Mincho Project Authors (FONTDASU), The Noto Project Authors (Noto Sans Arabic)" -source = "https://github.com/storytold/craft-fonts (pinned by commit in .github/workflows/release.yml)" +title = "Japanese fonts: BIZ UDPGothic (Regular, Bold), Shippori Mincho, BIZ UDMincho; native Simplified Chinese font: LXGW WenKai Lite; Arabic font: Noto Sans Arabic" +author = "The BIZ UDGothic and BIZ UDMincho Project Authors (Morisawa Inc.), The Shippori Mincho Project Authors (FONTDASU), LXGW and the original Klee Project Authors / Fontworks (LXGW WenKai Lite), The Noto Project Authors (Noto Sans Arabic)" +source = "https://github.com/storytold/craft-fonts (pinned by commit in .github/workflows/release.yml and .github/workflows/ci.yml); LXGW WenKai Lite from https://github.com/lxgw/LxgwWenKai-Lite/releases/tag/v1.530 (commit b712c83d98acf977bacfb7fb010207802808bda9)" licence = "OFL-1.1" attribution = "https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md" kind = "font" -usage = "Japanese fallback in the interface (BIZ UDPGothic first) and Japanese glyphs in edited PDF text (Shippori Mincho); Arabic interface text, file names and document titles (Noto Sans Arabic); the web build embeds only BIZ UDPGothic Regular and Noto Sans Arabic. The checkout also holds Noto Sans CJK SC, which crates/fonts/build.rs leaves out for size" +usage = "Japanese fallback in the interface (BIZ UDPGothic first) and Japanese glyphs in edited PDF text (Shippori Mincho); Simplified Chinese interface fallback on native builds only (LXGW WenKai Lite Regular, font SHA-256 f799a538a3355877e597a0ae04050e897932c9ae7689eb9227c2953c5dd549ea, OFL SHA-256 0333a85fe077b6849f8e417acc44e8bd879442ceb681e271acf93e2d57fd4e2e, read from fonts/simplified-chinese-manifest.txt and never embedded on wasm); Arabic interface text, file names and document titles (Noto Sans Arabic); the web build embeds BIZ UDPGothic Regular and the pinned small Arabic, Hebrew, Thai and Devanagari faces; the selected craft-fonts revision contains no Telugu face. Other non-Japanese CJK faces retain the general 4 MiB cap; the reviewed native-only LXGW fallback has a separate 16 MiB cap. Open-source OFL releases are permitted under current AGENTS.md §1.1" [[asset]] path = "crates/sign/tests/data/x509-rsa-sha1.pdf" @@ -3396,21 +3418,21 @@ sha256 = "d1a443233345724859de8e4fb48968ba91e885e12ddbd710701548bd91b39428" path = "crates/ui-egui/src/i18n/vi.tsv" title = "Vietnamese interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels; PhotoCraft and VectorCraft Vietnamese catalogs used only as translation memory when English source and semantic context matched; original catalog reviewed by a native Vietnamese-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 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" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels; PhotoCraft and VectorCraft Vietnamese catalogs used only as translation memory when English source and semantic context matched; original catalog reviewed by a native Vietnamese-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" +usage = "Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/sl.tsv" title = "Slovene interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "Slovene UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" +usage = "Slovene UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] @@ -3428,11 +3450,11 @@ sha256 = "78c59a0cfd2c829909d77006f94e34dac1ae5d1f90e7f10f72e84de541fef697" path = "crates/ui-egui/src/i18n/nb.tsv" title = "Norwegian Bokmål interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "Norwegian Bokmål UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" +usage = "Norwegian Bokmål UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "assets/icons/superscript.svg" @@ -3482,28 +3504,28 @@ sha256 = "864ab2f19254e6ac7240bc6a98323197332e8f714d4d3f94831f41309e5ef2f7" path = "crates/ui-egui/src/i18n/sq.tsv" title = "Albanian interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-appearance labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-appearance labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "Albanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" +usage = "Albanian UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/th.tsv" title = "Thai interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "Thai UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" +usage = "Thai UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/ca.tsv" title = "Catalan interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, written from their meaning; no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, written from their meaning; no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "Catalan UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" +usage = "Catalan UI strings, compiled in by crates/ui-egui/src/i18n.rs" diff --git a/CLAUDE.md b/CLAUDE.md index 085e8356f6..f51a96d56e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,6 +21,7 @@ PdfCraft is a clean-room, open-source, Rust-native PDF application targeting Ado - See `plan/README.md` §Clean-room and `plan/adr/0001`. - **Privacy.** When observing Acrobat, use synthetic fixtures only. Never capture the Home view, recent files or account info. Capture by window id (`plan/acrobat/tools/`). Never commit Acrobat outputs, corpus files or personal data. - **Layering.** Nothing below L7 depends on egui/winit/eframe/rfd (`plan/architecture.md` §3). `cos`/`filters`/`crypt`/`arlington` stay standalone. +- **Page input.** Page modules read `PagePointer` (`crates/ui-egui/src/page_pointer.rs`), never egui's raw pointer state (`hover_pos`, `interact_pos`, `latest_pos`, `press_origin`, button reads); `page_pointer::tests::page_modules_read_the_pointer_only_through_page_pointer` enforces it. - **Fidelity.** The PDF object graph is the model. Preserve unknown data. Saves are incremental unless a full rewrite is required. Never silently drop or repair data without recording it. - **Never panic.** Every PDF, script, MCP call, settings file and keystroke is untrusted input, and none of it may crash the app or lose the user's work. This outranks feature work. Return `Result` (the crate's error enum) and propagate with `?`, or fall back leniently and record it. In non-test code: - No `unwrap`/`expect`/`panic!`/`unreachable!`/`todo!` unless it is provably infallible, with a comment saying why. diff --git a/Cargo.lock b/Cargo.lock index 8b49c9521c..b201da8413 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,9 +4,9 @@ version = 4 [[package]] name = "accesskit" -version = "0.24.1" +version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320" +checksum = "ad442f58ee04714aaa0ba0a2768c1ea1935b29507bb22ddece8cbbc76db02932" dependencies = [ "enumn", "serde", @@ -15,57 +15,51 @@ dependencies = [ [[package]] name = "accesskit_atspi_common" -version = "0.18.1" +version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e8c61bee90b42a772d39d06a740207dc71a4e780004ace1db8d99fb1baaa954" +checksum = "52c182f9c282ac9c5638d876d551d15e5f7d397ec263349a0c6a2b61595dd5e4" dependencies = [ "accesskit", - "accesskit_consumer 0.36.0", + "accesskit_consumer", "atspi-common", - "phf 0.13.1", + "phf 0.14.0", "serde", "zvariant", ] [[package]] name = "accesskit_consumer" -version = "0.35.0" +version = "0.39.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53cf47daed85312e763fbf85ceca136e0d7abc68e0a7e12abe11f48172bc3b10" +checksum = "8a45e2bb300bea6d5f33a82c3e051c3ddc6f41c209750f68a0acbe991a284344" dependencies = [ "accesskit", - "hashbrown 0.16.1", + "hashbrown 0.17.1", ] [[package]] -name = "accesskit_consumer" -version = "0.36.0" +name = "accesskit_ios" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25e0d7e25d06f4dc21d1774d67146e9e80d6789216cbd4d1e88185b0095dba60" +checksum = "28e539d64b27fd4c14de8f78ada8f7c944c092f52f7e49fb63316507a57f6937" dependencies = [ "accesskit", - "hashbrown 0.16.1", -] - -[[package]] -name = "accesskit_consumer" -version = "0.38.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d10a236f96f87d70732e44520046785431ef01d5bcd6b041317bfadd2f88245" -dependencies = [ - "accesskit", - "hashbrown 0.16.1", + "accesskit_consumer", + "hashbrown 0.17.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-ui-kit 0.2.2", ] [[package]] name = "accesskit_macos" -version = "0.26.3" +version = "0.27.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce02dc63b43f0c9296af9ac946312a2dc8814427d7a64d2d600971dac55b6076" +checksum = "5701624bf6a11efb6d4c8e922bf44126ed96250e5faa9a7a0468c979635839a4" dependencies = [ "accesskit", - "accesskit_consumer 0.38.0", - "hashbrown 0.16.1", + "accesskit_consumer", + "hashbrown 0.17.1", "objc2 0.5.2", "objc2-app-kit 0.2.2", "objc2-foundation 0.2.2", @@ -73,9 +67,9 @@ dependencies = [ [[package]] name = "accesskit_unix" -version = "0.21.1" +version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b016ca8db0ea0ea2ceff29a9d6240391492d960716aa471967c00e8cc8cb197c" +checksum = "202f24df034a7476d07b7f74284de84f6d62aabd858dbe7ee9cad3b7ad6f8f9d" dependencies = [ "accesskit", "accesskit_atspi_common", @@ -91,13 +85,13 @@ dependencies = [ [[package]] name = "accesskit_windows" -version = "0.32.1" +version = "0.35.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eff7009f1a532e917d66970a1e80c965140c6cfbbabbdde3d64e5431e6c78e21" +checksum = "ce63f35d6bdcf59f26b76b3379063f738e6412cef46999cc772d46aa3de35adb" dependencies = [ "accesskit", - "accesskit_consumer 0.35.0", - "hashbrown 0.16.1", + "accesskit_consumer", + "hashbrown 0.17.1", "static_assertions", "windows", "windows-core", @@ -105,11 +99,12 @@ dependencies = [ [[package]] name = "accesskit_winit" -version = "0.32.2" +version = "0.34.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fe9a94394896352cc4660ca2288bd4ef883d83238853c038b44070c8f134313" +checksum = "ee96dd150375c05912d8a473f98c2fb155fb3234fac88292d396548a2460fa77" dependencies = [ "accesskit", + "accesskit_ios", "accesskit_macos", "accesskit_unix", "accesskit_windows", @@ -220,21 +215,22 @@ checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "arboard" -version = "3.6.1" +version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +checksum = "aeb9553e62312c8756a5979254fc6d7da8bbeb5986a1e17037fab932f7037917" dependencies = [ "clipboard-win", "image", "log", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-core-graphics", "objc2-foundation 0.3.2", "parking_lot", "percent-encoding", - "windows-sys 0.60.2", + "windows-sys 0.59.0", + "wl-clipboard-rs", "x11rb", ] @@ -447,9 +443,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-lc-rs" -version = "1.18.1" +version = "1.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e" +checksum = "faac5829c2b74c28f830747e7818ccfb684261b5f48a1118b1e2a13d36dfab13" dependencies = [ "aws-lc-sys", "untrusted 0.7.1", @@ -458,9 +454,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27" +checksum = "f1622d8446a2d4b2ce0c7eefc73dd43a99779028d5ee5c2dd8073a658ba8a2bc" dependencies = [ "cc", "cmake", @@ -493,15 +489,6 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" -[[package]] -name = "bit-set" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" -dependencies = [ - "bit-vec 0.8.0", -] - [[package]] name = "bit-set" version = "0.10.0" @@ -512,10 +499,13 @@ dependencies = [ ] [[package]] -name = "bit-vec" -version = "0.8.0" +name = "bit-set" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" +checksum = "56d87354e4229f54a44f7bf2435906a4656dba36026ab6eaca629a2c436a691c" +dependencies = [ + "bit-vec 0.10.1", +] [[package]] name = "bit-vec" @@ -523,6 +513,16 @@ version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" +[[package]] +name = "bit-vec" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5727b15fa97d4f4fee0a3b7c3d550ed0269f54329207b86388de918604e31269" +dependencies = [ + "borsh", + "serde", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -580,7 +580,7 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" dependencies = [ - "objc2 0.6.4", + "objc2 0.6.5", ] [[package]] @@ -649,7 +649,7 @@ dependencies = [ "oneshot", "pastey", "portable-atomic", - "rand 0.10.3", + "rand", "regress", "rustc-hash 2.1.3", "ryu-js", @@ -740,6 +740,30 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "borsh" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a" +dependencies = [ + "borsh-derive", + "bytes", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12cdfe656708a01f89b451a7d36466e6fe6c414de0aa18fc54f864f6f9ca9f56" +dependencies = [ + "once_cell", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "bp256" version = "0.14.0" @@ -999,6 +1023,15 @@ dependencies = [ "x509-cert", ] +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror 2.0.21", +] + [[package]] name = "codespan-reporting" version = "0.13.1" @@ -1145,15 +1178,67 @@ 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=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" + +[[package]] +name = "craft-fonts-build" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" + +[[package]] +name = "craft-i18n" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +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=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +dependencies = [ + "egui", + "serde", + "serde_json", +] + +[[package]] +name = "craft-menu" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +dependencies = [ + "craft-keymap", + "craft-quit", + "egui", + "muda", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", +] [[package]] name = "craft-print" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=666c358065261a2816b376fa3bb10538dbf53040#666c358065261a2816b376fa3bb10538dbf53040" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" dependencies = [ - "miniz_oxide 0.8.9", - "roxmltree", + "craft-winprint-gdi", + "miniz_oxide 0.9.1", + "roxmltree 0.21.2", "tempfile", "winprint", "winsafe", @@ -1162,22 +1247,66 @@ dependencies = [ [[package]] name = "craft-quit" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=f58bc1a3df490992d633393d0898af0588ce882c#f58bc1a3df490992d633393d0898af0588ce882c" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" dependencies = [ "dispatch2", "egui", - "muda", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", ] [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=5c874d51eeb3de70e735c8ce87a59a7dedd47675#5c874d51eeb3de70e735c8ce87a59a7dedd47675" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" 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=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +dependencies = [ + "chrono", + "dunce", + "egui", + "flate2", + "getrandom 0.3.4", + "log", + "plist", + "semver", + "serde", + "serde_json", + "sha2 0.10.9", + "tar", + "tempfile", + "ureq", + "verifysign", + "winreg", + "winsafe", + "zip", +] + +[[package]] +name = "craft-winprint-gdi" +version = "0.1.0" +source = "git+https://github.com/storytold/winprint.rs?rev=417ac3c4d14d3da5485a99c7d5ce8f05664ec312#417ac3c4d14d3da5485a99c7d5ce8f05664ec312" +dependencies = [ + "windows-sys 0.61.2", ] [[package]] @@ -1439,7 +1568,7 @@ dependencies = [ "bitflags 2.13.2", "block2 0.6.2", "libc", - "objc2 0.6.4", + "objc2 0.6.5", ] [[package]] @@ -1535,9 +1664,8 @@ dependencies = [ [[package]] name = "ecolor" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0656a7530b9e2841ee7a5f983965bdd9840c32f4012f759cfd44b912cced6b34" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "bytemuck", "emath", @@ -1561,16 +1689,15 @@ checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de" dependencies = [ "curve25519-dalek", "ed25519", - "sha2 0.11.0", + "sha2 0.11.1", "subtle", "zeroize", ] [[package]] name = "eframe" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55ad41fac6e3149abb4866e93df37dbd33baa95db525c3368c944584e95a5dc2" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "ahash", "bytemuck", @@ -1585,7 +1712,7 @@ dependencies = [ "image", "js-sys", "log", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "parking_lot", @@ -1594,6 +1721,7 @@ dependencies = [ "profiling", "raw-window-handle", "ron", + "send_wrapper", "serde", "static_assertions", "wasm-bindgen", @@ -1606,9 +1734,8 @@ dependencies = [ [[package]] name = "egui" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc938cc27cd911415e1e4d151bc56ff9df063d8db081374e87aeb805ea74b2ba" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "accesskit", "ahash", @@ -1628,7 +1755,8 @@ dependencies = [ [[package]] name = "egui-wgpu" -version = "0.36.2" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "ahash", "bytemuck", @@ -1646,32 +1774,35 @@ dependencies = [ [[package]] name = "egui-winit" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98466000559d66ba4db786a30448b01483946add4188e7f10f522a27b4fb89cc" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "accesskit_winit", "arboard", + "block2 0.6.2", "bytemuck", "egui", + "image", "log", - "objc2 0.6.4", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "objc2-ui-kit 0.3.2", + "parking_lot", "profiling", "raw-window-handle", "serde", "smithay-clipboard", "web-time", "webbrowser", + "windows-sys 0.61.2", "winit", ] [[package]] name = "egui_extras" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05cbdc785020861553f230f23d330ba1fb72613b9d8e0e457f3bdc8ba6db6dcf" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "ahash", "egui", @@ -1684,9 +1815,8 @@ dependencies = [ [[package]] name = "egui_glow" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "606ed4167daf341e5753d703285c4c1d562324b2c07aa8f405cd7d066fe58f3b" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "bytemuck", "egui", @@ -1698,9 +1828,8 @@ dependencies = [ [[package]] name = "egui_kittest" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eb3f3effb2726fee23449293e67acbf6cc6ce35525b1722bd5743bb348114d5" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "eframe", "egui", @@ -1742,9 +1871,8 @@ dependencies = [ [[package]] name = "emath" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52c8d4141cf0f60fb2f7aefaf352d3ffa5171ef0ab09731c64391801e95e48f1" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "bytemuck", "serde", @@ -1772,22 +1900,22 @@ checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" [[package]] name = "enum-map" -version = "2.7.3" +version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6866f3bfdf8207509a033af1a75a7b08abda06bbaaeae6669323fd5a097df2e9" +checksum = "d4532960034c15ba2f55e9eee631040ecc473c32e6c4b200828c759840f13a06" dependencies = [ "enum-map-derive", ] [[package]] name = "enum-map-derive" -version = "0.17.0" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f282cfdfe92516eb26c2af8589c274c7c17681f5ecc03c18255fe741c6aa64eb" +checksum = "7127272f64b3e098f7d81f350d672081b879bff896d219c42370dc858b1af9ff" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.6", ] [[package]] @@ -1824,9 +1952,8 @@ dependencies = [ [[package]] name = "epaint" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0ec0308dc23130fc1a7d4623a8534c2ed069c6dc84e258ee4f3de99615aa1a3" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "ahash", "bytemuck", @@ -1843,16 +1970,17 @@ dependencies = [ "serde", "skrifa 0.44.0", "smallvec", + "unicode-bidi", "unicode-general-category", + "unicode-properties", "unicode-segmentation", - "vello_cpu 0.1.0", + "vello_cpu 0.2.0", ] [[package]] name = "epaint_default_fonts" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773fa9c96dd0dbef887e39d0ed6177f141cce4d68e6041df77570aa3702dfa13" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" [[package]] name = "equator" @@ -1887,7 +2015,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] [[package]] @@ -1990,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" @@ -2057,7 +2196,7 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90642cf66dde7f205dc8b42dfe57b47d0f3a29f571b832742883ef512e23fe55" dependencies = [ - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", ] @@ -2068,6 +2207,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foldhash" version = "0.2.0" @@ -2099,7 +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]] @@ -2318,7 +2463,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d99fc21d493812643aae86d53b7bbd02f376434a90317e8a790bc209fdd6605e" dependencies = [ "bytemuck", - "foldhash", + "foldhash 0.2.0", "hashbrown 0.17.1", "log", "peniko", @@ -2330,18 +2475,18 @@ dependencies = [ [[package]] name = "glifo" -version = "0.2.0" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed4a1bb24121291d27230c1b1b44e07d6a9b28cefdb32fe1581dfb84e14f940a" +checksum = "282a26c1e23de04bdab3e34a21b6f877a479b96737792516b9b8b8f69b6661be" dependencies = [ "bytemuck", - "foldhash", + "foldhash 0.2.0", "hashbrown 0.17.1", "log", "peniko", "skrifa 0.44.0", "smallvec", - "vello_common 0.1.0", + "vello_common 0.2.0", ] [[package]] @@ -2370,7 +2515,7 @@ dependencies = [ "glutin_glx_sys", "glutin_wgl_sys", "libloading", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-foundation 0.3.2", @@ -2505,6 +2650,15 @@ version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + [[package]] name = "hashbrown" version = "0.16.1" @@ -2513,7 +2667,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.2.0", ] [[package]] @@ -2524,7 +2678,7 @@ checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.2.0", ] [[package]] @@ -2784,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" @@ -2792,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" @@ -2809,6 +3001,7 @@ dependencies = [ "icu_provider", "smallvec", "utf16_iter", + "utf8_iter", "write16", "zerovec", ] @@ -2819,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" @@ -2848,6 +3067,9 @@ checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" dependencies = [ "displaydoc", "icu_locale_core", + "postcard", + "serde", + "stable_deref_trait", "writeable", "yoke", "zerofrom", @@ -2855,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" @@ -3065,12 +3311,12 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" [[package]] name = "kittest" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90ceaa75eb0036a32b6b9833962eb18137449e9817e2e586006471925b727fd5" +checksum = "0bb81a721ba76aa095b7f45deb180504f786904e56ca4b8e141f66eb89a78b75" dependencies = [ "accesskit", - "accesskit_consumer 0.35.0", + "accesskit_consumer", ] [[package]] @@ -3153,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" @@ -3193,10 +3442,10 @@ dependencies = [ "log", "md-5", "nom", - "rand 0.10.3", + "rand", "rangemap", "rayon", - "sha2 0.11.0", + "sha2 0.11.1", "stringprep", "thiserror 2.0.21", "weezl 0.2.1", @@ -3298,13 +3547,13 @@ dependencies = [ "crossbeam-channel", "dpi", "keyboard-types", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-foundation 0.3.2", "png 0.18.1", "thiserror 2.0.21", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -3381,6 +3630,18 @@ dependencies = [ "jni-sys 0.3.1", ] +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nohash-hasher" version = "0.2.0" @@ -3491,9 +3752,9 @@ dependencies = [ [[package]] name = "objc2" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8" dependencies = [ "objc2-encode", ] @@ -3522,7 +3783,7 @@ checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ "bitflags 2.13.2", "block2 0.6.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-core-graphics", "objc2-foundation 0.3.2", @@ -3572,7 +3833,7 @@ checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ "bitflags 2.13.2", "dispatch2", - "objc2 0.6.4", + "objc2 0.6.5", ] [[package]] @@ -3583,7 +3844,7 @@ checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ "bitflags 2.13.2", "dispatch2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-io-surface", ] @@ -3639,7 +3900,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags 2.13.2", "block2 0.6.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", ] @@ -3650,7 +3911,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ "bitflags 2.13.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", ] @@ -3686,7 +3947,7 @@ checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" dependencies = [ "bitflags 2.13.2", "block2 0.6.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-foundation 0.3.2", ] @@ -3710,7 +3971,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" dependencies = [ "bitflags 2.13.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-core-graphics", "objc2-foundation 0.3.2", @@ -3755,7 +4016,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" dependencies = [ "bitflags 2.13.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-foundation 0.3.2", ] @@ -3846,6 +4107,16 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "os_pipe" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "p256" version = "0.14.0" @@ -3856,7 +4127,7 @@ dependencies = [ "elliptic-curve", "primefield", "primeorder", - "sha2 0.11.0", + "sha2 0.11.1", ] [[package]] @@ -3870,7 +4141,7 @@ dependencies = [ "fiat-crypto", "primefield", "primeorder", - "sha2 0.11.0", + "sha2 0.11.1", ] [[package]] @@ -3884,7 +4155,7 @@ dependencies = [ "elliptic-curve", "primefield", "primeorder", - "sha2 0.11.0", + "sha2 0.11.1", ] [[package]] @@ -3934,10 +4205,10 @@ dependencies = [ [[package]] name = "pdfcraft" -version = "0.13.0" +version = "0.14.0" dependencies = [ "chrono", - "craft-quit", + "craft-update", "eframe", "egui", "fmv-macos-events", @@ -3946,10 +4217,8 @@ dependencies = [ "pdfcraft-ui-egui", "pollster 1.0.1", "raw-window-handle", - "rustls-native-certs", "serde_json", "uds_windows", - "ureq", "wayland-client", "wayland-protocols", "windows", @@ -3960,7 +4229,7 @@ dependencies = [ [[package]] name = "pdfcraft-a11y" -version = "0.13.0" +version = "0.14.0" dependencies = [ "pdfcraft-content", "pdfcraft-cos", @@ -3972,7 +4241,7 @@ dependencies = [ [[package]] name = "pdfcraft-annot" -version = "0.13.0" +version = "0.14.0" dependencies = [ "hayro-syntax", "pdfcraft-cos", @@ -3983,7 +4252,7 @@ dependencies = [ [[package]] name = "pdfcraft-audit" -version = "0.13.0" +version = "0.14.0" dependencies = [ "pdfcraft-content", "pdfcraft-cos", @@ -3994,9 +4263,9 @@ dependencies = [ [[package]] name = "pdfcraft-automation" -version = "0.13.0" +version = "0.14.0" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "flate2", "image", "pdfcraft-cos", @@ -4010,15 +4279,15 @@ dependencies = [ "png 0.18.1", "rustix 1.1.5", "serde_json", - "sha2 0.11.0", + "sha2 0.11.1", "tempfile", ] [[package]] name = "pdfcraft-cli" -version = "0.13.0" +version = "0.14.0" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "pdfcraft-audit", "pdfcraft-automation", "pdfcraft-cos", @@ -4034,11 +4303,11 @@ dependencies = [ [[package]] name = "pdfcraft-compare" -version = "0.13.0" +version = "0.14.0" [[package]] name = "pdfcraft-content" -version = "0.13.0" +version = "0.14.0" dependencies = [ "hayro-syntax", "pdfcraft-cos", @@ -4046,7 +4315,7 @@ dependencies = [ [[package]] name = "pdfcraft-cos" -version = "0.13.0" +version = "0.14.0" dependencies = [ "chrono", "hayro-syntax", @@ -4061,7 +4330,7 @@ dependencies = [ [[package]] name = "pdfcraft-create" -version = "0.13.0" +version = "0.14.0" dependencies = [ "encoding_rs", "hayro-syntax", @@ -4077,19 +4346,19 @@ dependencies = [ [[package]] name = "pdfcraft-crypt" -version = "0.13.0" +version = "0.14.0" dependencies = [ "aes", "md-5", "proptest", - "sha2 0.11.0", + "sha2 0.11.1", "stringprep", "thiserror 2.0.21", ] [[package]] name = "pdfcraft-edit" -version = "0.13.0" +version = "0.14.0" dependencies = [ "hayro-syntax", "kurbo 0.13.1", @@ -4104,7 +4373,7 @@ dependencies = [ [[package]] name = "pdfcraft-engine" -version = "0.13.0" +version = "0.14.0" dependencies = [ "chrono", "craft-docpath", @@ -4145,14 +4414,14 @@ dependencies = [ [[package]] name = "pdfcraft-export" -version = "0.13.0" +version = "0.14.0" dependencies = [ "flate2", ] [[package]] name = "pdfcraft-filters" -version = "0.13.0" +version = "0.14.0" dependencies = [ "flate2", "proptest", @@ -4162,7 +4431,7 @@ dependencies = [ [[package]] name = "pdfcraft-fonts" -version = "0.13.0" +version = "0.14.0" dependencies = [ "harfrust", "pdfcraft-cos", @@ -4172,7 +4441,7 @@ dependencies = [ [[package]] name = "pdfcraft-forms" -version = "0.13.0" +version = "0.14.0" dependencies = [ "hayro-syntax", "pdfcraft-content", @@ -4185,11 +4454,11 @@ dependencies = [ [[package]] name = "pdfcraft-geom" -version = "0.13.0" +version = "0.14.0" [[package]] name = "pdfcraft-js" -version = "0.13.0" +version = "0.14.0" dependencies = [ "boa_engine", "chrono", @@ -4197,7 +4466,7 @@ dependencies = [ [[package]] name = "pdfcraft-measure" -version = "0.13.0" +version = "0.14.0" dependencies = [ "pdfcraft-annot", "pdfcraft-content", @@ -4210,24 +4479,25 @@ dependencies = [ [[package]] name = "pdfcraft-model" -version = "0.13.0" +version = "0.14.0" dependencies = [ "pdfcraft-cos", ] [[package]] name = "pdfcraft-ocr" -version = "0.13.0" +version = "0.14.0" dependencies = [ "ocrs", "pdfcraft-fonts", "rten", + "tempfile", "thiserror 2.0.21", ] [[package]] name = "pdfcraft-optimize" -version = "0.13.0" +version = "0.14.0" dependencies = [ "image", "pdfcraft-annot", @@ -4242,11 +4512,11 @@ dependencies = [ [[package]] name = "pdfcraft-oracle" -version = "0.13.0" +version = "0.14.0" [[package]] name = "pdfcraft-organize" -version = "0.13.0" +version = "0.14.0" dependencies = [ "hayro-syntax", "pdfcraft-content", @@ -4257,7 +4527,7 @@ dependencies = [ [[package]] name = "pdfcraft-platform" -version = "0.13.0" +version = "0.14.0" dependencies = [ "async-channel", "futures-lite", @@ -4267,18 +4537,18 @@ dependencies = [ [[package]] name = "pdfcraft-preflight" -version = "0.13.0" +version = "0.14.0" dependencies = [ "flate2", "pdfcraft-content", "pdfcraft-cos", "pdfcraft-model", - "roxmltree", + "roxmltree 0.20.0", ] [[package]] name = "pdfcraft-print" -version = "0.13.0" +version = "0.14.0" dependencies = [ "craft-print", "pdfcraft-content", @@ -4290,7 +4560,7 @@ dependencies = [ [[package]] name = "pdfcraft-redact" -version = "0.13.0" +version = "0.14.0" dependencies = [ "pdfcraft-annot", "pdfcraft-content", @@ -4306,7 +4576,7 @@ dependencies = [ [[package]] name = "pdfcraft-render" -version = "0.13.0" +version = "0.14.0" dependencies = [ "bytemuck", "flate2", @@ -4325,11 +4595,11 @@ dependencies = [ [[package]] name = "pdfcraft-sign" -version = "0.13.0" +version = "0.14.0" dependencies = [ "aes", "aws-lc-rs", - "base64 0.22.1", + "base64 0.23.1", "bp256", "bp384", "cbc", @@ -4354,26 +4624,29 @@ dependencies = [ "rustls-cng", "security-framework", "sha1 0.11.0", - "sha2 0.11.0", + "sha2 0.11.1", "sha3", "thiserror 2.0.21", ] [[package]] name = "pdfcraft-testkit" -version = "0.13.0" +version = "0.14.0" [[package]] name = "pdfcraft-ui-egui" -version = "0.13.0" +version = "0.14.0" dependencies = [ "arboard", - "base64 0.22.1", + "base64 0.23.1", "block2 0.6.2", "bytemuck", "chrono", + "craft-keymap", + "craft-menu", "craft-quit", "craft-ui", + "craft-update", "eframe", "egui", "egui_extras", @@ -4383,7 +4656,7 @@ dependencies = [ "image", "js-sys", "log", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "pdfcraft-cos", @@ -4407,13 +4680,14 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", + "web-time", "winsafe", "xattr", ] [[package]] name = "pdfcraft-web" -version = "0.13.0" +version = "0.14.0" dependencies = [ "eframe", "egui", @@ -4425,27 +4699,27 @@ dependencies = [ [[package]] name = "pdfcraft-xfa" -version = "0.13.0" +version = "0.14.0" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "image", "pdfcraft-cos", "pdfcraft-fonts", "png 0.18.1", - "roxmltree", - "sha2 0.11.0", + "roxmltree 0.20.0", + "sha2 0.11.1", "thiserror 2.0.21", ] [[package]] name = "pdfcraft-xfdf" -version = "0.13.0" +version = "0.14.0" dependencies = [ "pdfcraft-annot", "pdfcraft-cos", "pdfcraft-forms", "pdfcraft-model", - "roxmltree", + "roxmltree 0.20.0", "thiserror 2.0.21", ] @@ -4496,6 +4770,17 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap", +] + [[package]] name = "phf" version = "0.13.1" @@ -4504,7 +4789,6 @@ checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" dependencies = [ "phf_macros 0.13.1", "phf_shared 0.13.1", - "serde", ] [[package]] @@ -4515,6 +4799,7 @@ checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6" dependencies = [ "phf_macros 0.14.0", "phf_shared 0.14.0", + "serde", ] [[package]] @@ -4668,9 +4953,9 @@ checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" [[package]] name = "plist" -version = "1.10.2" +version = "1.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65dca778f41c79ebbe8b2b2139fee86f01b766b67130d108c7c2b8ffbd949f77" +checksum = "327a4cb2ad49dc48adfdc9da89e22c64d488b508c67a044d71ba729e4521a285" dependencies = [ "base64 0.23.1", "indexmap", @@ -4755,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", ] @@ -4770,15 +5067,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - [[package]] name = "presser" version = "0.3.1" @@ -4862,16 +5150,17 @@ checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" [[package]] name = "proptest" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +checksum = "b8530004ccb15eae51c7e40009fbe317f341f804db54dc033eec1c50be28cfa0" dependencies = [ - "bit-set 0.8.0", - "bit-vec 0.8.0", + "bit-set 0.11.1", + "bit-vec 0.10.1", "bitflags 2.13.2", + "chacha20", + "core_detect", "num-traits", - "rand 0.9.5", - "rand_chacha", + "rand", "rand_xorshift", "regex-syntax", "rusty-fork", @@ -4945,7 +5234,7 @@ dependencies = [ "bytes", "getrandom 0.4.3", "lru-slab", - "rand 0.10.3", + "rand", "rand_pcg", "ring", "rustc-hash 2.1.3", @@ -4969,7 +5258,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] [[package]] @@ -4993,16 +5282,6 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" -[[package]] -name = "rand" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" -dependencies = [ - "rand_chacha", - "rand_core 0.9.5", -] - [[package]] name = "rand" version = "0.10.3" @@ -5014,31 +5293,12 @@ dependencies = [ "rand_core 0.10.1", ] -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core 0.9.5", -] - [[package]] name = "rand_core" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -[[package]] -name = "rand_core" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom 0.3.4", -] - [[package]] name = "rand_core" version = "0.10.1" @@ -5056,11 +5316,11 @@ dependencies = [ [[package]] name = "rand_xorshift" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +checksum = "60aa6af80be32871323012e02e6e65f8a7cc7890931ae421d217ad8fe0df2ccf" dependencies = [ - "rand_core 0.9.5", + "rand_core 0.10.1", ] [[package]] @@ -5087,7 +5347,7 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40d213455a5f1dc59214213c7330e074ddf8114c9a42411eb890c767357ce135" dependencies = [ - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-foundation 0.3.2", "objc2-quartz-core 0.3.2", @@ -5280,7 +5540,7 @@ dependencies = [ "js-sys", "libc", "log", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-foundation 0.3.2", @@ -5348,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" @@ -5377,6 +5646,7 @@ dependencies = [ "rten-base", "rten-gemm", "rten-model-file", + "rten-onnx", "rten-parallel", "rten-shape-inference", "rten-simd", @@ -5426,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" @@ -5506,7 +5782,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.4.15", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -5519,7 +5795,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] [[package]] @@ -5551,9 +5827,9 @@ dependencies = [ [[package]] name = "rustls-native-certs" -version = "0.8.4" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4" dependencies = [ "openssl-probe", "rustls-pki-types", @@ -5589,7 +5865,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] [[package]] @@ -5718,6 +5994,16 @@ name = "semver" version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "send_wrapper" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" [[package]] name = "serde" @@ -5751,9 +6037,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.151" +version = "1.0.154" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" dependencies = [ "itoa", "memchr", @@ -5850,9 +6136,9 @@ dependencies = [ [[package]] name = "sha2" -version = "0.11.0" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19" dependencies = [ "cfg-if", "cpufeatures 0.3.1", @@ -6008,7 +6294,7 @@ dependencies = [ "const-oid 0.9.6", "der 0.7.10", "hex", - "rand 0.10.3", + "rand", "reqwest", "rustls-pki-types", "rustls-webpki", @@ -6244,7 +6530,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -6409,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]] @@ -6463,10 +6752,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix 1.1.5", - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] [[package]] @@ -6604,6 +6893,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" dependencies = [ "displaydoc", + "serde_core", "zerovec", ] @@ -6800,6 +7090,17 @@ dependencies = [ "once_cell", ] +[[package]] +name = "tree_magic_mini" +version = "3.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6" +dependencies = [ + "memchr", + "nom", + "petgraph", +] + [[package]] name = "try-lock" version = "0.2.5" @@ -6815,6 +7116,12 @@ dependencies = [ "rustc-hash 2.1.3", ] +[[package]] +name = "typed-path" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce898823d8cd0f028662d2f349694b1af0a5cc9be83f1d213a00d3147445e1e" + [[package]] name = "typeid" version = "1.0.3" @@ -6835,7 +7142,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -6903,24 +7210,26 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "ureq" -version = "3.4.2" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a7ac20be9b7726e0bbdbf974c059676d9acb1cd414961f570a4e8231cacd7fc" +checksum = "cde93ff05758a36a851a398a772b0b79a82fbe3c186a46f2696baaeaf84c966b" dependencies = [ "base64 0.23.1", + "flate2", "log", "percent-encoding", "rustls", "rustls-pki-types", + "rustls-platform-verifier", "ureq-proto", "utf8-zero", ] [[package]] name = "ureq-proto" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f86fd172ccca569e458f61b6bdd6220965a9ef36e672a6852953b51a0e1583be" +checksum = "841583fc36089d7d59419568ae157f43a5a03a2e5a81ba87c77d1596ef46e922" dependencies = [ "base64 0.23.1", "http", @@ -6953,7 +7262,7 @@ dependencies = [ "kurbo 0.11.3", "log", "pico-args", - "roxmltree", + "roxmltree 0.20.0", "simplecss", "siphasher", "strict-num", @@ -7025,9 +7334,9 @@ dependencies = [ [[package]] name = "vello_common" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2e9aed918117e8152c9eddfd8362d73c465c23f26c44786aa331707b8a64fa2" +checksum = "bbb2141a2bca6e6d598e471fd4d1d7eed8e020aad6a28187edda07f091a325dd" dependencies = [ "bytemuck", "fearless_simd", @@ -7053,14 +7362,24 @@ dependencies = [ [[package]] name = "vello_cpu" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac7349e1f55f6b801c7c277958df4ea53e7f20f21e8014910ad888b2ecda93ea" +checksum = "ace506ca414548966fcf3c283be94eb1cad8254de488791ee698bf438b0890a9" dependencies = [ "bytemuck", - "glifo 0.2.0", + "glifo 0.3.0", "hashbrown 0.17.1", - "vello_common 0.1.0", + "vello_common 0.2.0", +] + +[[package]] +name = "verifysign" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6acfcc42a8d44f340d001193d97ef56bf33210b51ad760ae8308c97d5241ca51" +dependencies = [ + "core-foundation 0.10.1", + "windows-sys 0.61.2", ] [[package]] @@ -7332,7 +7651,7 @@ dependencies = [ "jni", "log", "ndk-context", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "url", @@ -7488,7 +7807,7 @@ dependencies = [ "naga", "naga-types", "ndk-sys", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-core-graphics", "objc2-foundation 0.3.2", @@ -7549,7 +7868,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] [[package]] @@ -7670,16 +7989,16 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.6", + "windows-targets", ] [[package]] name = "windows-sys" -version = "0.60.2" +version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ - "windows-targets 0.53.5", + "windows-targets", ] [[package]] @@ -7697,31 +8016,14 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] [[package]] @@ -7739,99 +8041,52 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - [[package]] name = "winit" version = "0.30.13" +source = "git+https://github.com/storytold/winit.git?rev=a517b3846ee8457d84ee4975110bfda6797c44dc#a517b3846ee8457d84ee4975110bfda6797c44dc" dependencies = [ "ahash", "android-activity", @@ -7909,10 +8164,20 @@ dependencies = [ ] [[package]] -name = "winresource" -version = "0.1.31" +name = "winreg" +version = "0.55.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0986a8b1d586b7d3e4fe3d9ea39fb451ae22869dcea4aa109d287a374d866087" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] + +[[package]] +name = "winresource" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2bc6d36480b829272a5bdabf08e6dca8004761c1da7e35efef800540c66221b" dependencies = [ "toml", "version_check", @@ -7930,6 +8195,24 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "wl-clipboard-rs" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d7888ccd4896447b2d14d3a9350a85df2aeb6f181e2e7a31349d104ac46cac1" +dependencies = [ + "libc", + "log", + "os_pipe", + "rustix 1.1.5", + "thiserror 2.0.21", + "tree_magic_mini", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-wlr", +] + [[package]] name = "wnaf" version = "0.14.1" @@ -7953,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" @@ -8076,7 +8362,7 @@ dependencies = [ "png 0.18.1", "serde", "serde_json", - "sha2 0.11.0", + "sha2 0.11.1", "toml", ] @@ -8277,8 +8563,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ "displaydoc", + "litemap", + "serde_core", "yoke", "zerofrom", + "zerovec", ] [[package]] @@ -8287,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", @@ -8303,6 +8593,23 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "zip" +version = "9.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2886b00003a72c4ccee3f42cfc34908d33b2508a74d827dc2a4244706475c704" +dependencies = [ + "crc32fast", + "flate2", + "getrandom 0.4.3", + "indexmap", + "memchr", + "nix", + "typed-path", + "windows-sys 0.61.2", + "zopfli", +] + [[package]] name = "zlib-rs" version = "0.6.8" @@ -8315,6 +8622,18 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" +[[package]] +name = "zopfli" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aaf7fc5d30c28483d93805c4a5e12b05bbb52407fa67c5f8bd552374cd01fb11" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + [[package]] name = "zune-core" version = "0.5.3" @@ -8370,3 +8689,18 @@ dependencies = [ "syn 3.0.6", "winnow", ] + +[[patch.unused]] +name = "egui_demo_lib" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" + +[[patch.unused]] +name = "egui_inspection" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" + +[[patch.unused]] +name = "egui_system_font_provider" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" diff --git a/Cargo.toml b/Cargo.toml index e7acfd31f9..99f9fa251b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ exclude = ["vendor"] default-members = ["crates/*", "apps/pdfcraft", "apps/pdfcraft-cli", "xtask"] [workspace.package] -version = "0.13.0" +version = "0.14.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" @@ -13,47 +13,51 @@ repository = "https://github.com/storytold/pdfcraft" [workspace.dependencies] # Shared frontend components; engines remain independent of egui. -craft-ui = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] } +craft-ui = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["serde"] } # Quit the macOS way (#1136): ⌘Q asks about unsaved changes, and closing quits through `terminate:`. -craft-quit = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "f58bc1a3df490992d633393d0898af0588ce882c" } +craft-quit = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } +# 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 = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } +craft-keymap = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", default-features = false } # Shared system spooler; document composition remains in pdfcraft-print. -craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "666c358065261a2816b376fa3bb10538dbf53040" } +craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } # Whether a path stored in a document may be opened (GoToR link targets). -craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" } +craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } +craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["egui"] } # internal -pdfcraft-geom = { path = "crates/geom", version = "0.13.0" } -pdfcraft-filters = { path = "crates/filters", version = "0.13.0" } -pdfcraft-cos = { path = "crates/cos", version = "0.13.0" } -pdfcraft-crypt = { path = "crates/crypt", version = "0.13.0" } -pdfcraft-organize = { path = "crates/organize", version = "0.13.0" } -pdfcraft-measure = { path = "crates/measure", version = "0.13.0" } -pdfcraft-annot = { path = "crates/annot", version = "0.13.0" } -pdfcraft-audit = { path = "crates/audit", version = "0.13.0" } -pdfcraft-fonts = { path = "crates/fonts", version = "0.13.0" } -pdfcraft-model = { path = "crates/model", version = "0.13.0" } -pdfcraft-content = { path = "crates/content", version = "0.13.0" } -pdfcraft-redact = { path = "crates/redact", version = "0.13.0" } -pdfcraft-print = { path = "crates/print", version = "0.13.0" } -pdfcraft-xfdf = { path = "crates/xfdf", version = "0.13.0" } -pdfcraft-sign = { path = "crates/sign", version = "0.13.0" } -pdfcraft-optimize = { path = "crates/optimize", version = "0.13.0" } -pdfcraft-a11y = { path = "crates/a11y", version = "0.13.0" } -pdfcraft-oracle = { path = "crates/oracle", version = "0.13.0" } -pdfcraft-testkit = { path = "crates/testkit", version = "0.13.0" } -pdfcraft-ocr = { path = "crates/ocr", version = "0.13.0" } -pdfcraft-js = { path = "crates/js", version = "0.13.0" } -pdfcraft-compare = { path = "crates/compare", version = "0.13.0" } -pdfcraft-preflight = { path = "crates/preflight", version = "0.13.0" } -pdfcraft-export = { path = "crates/export", version = "0.13.0" } -pdfcraft-edit = { path = "crates/edit", version = "0.13.0" } -pdfcraft-create = { path = "crates/create", version = "0.13.0" } -pdfcraft-forms = { path = "crates/forms", version = "0.13.0" } -pdfcraft-xfa = { path = "crates/xfa", version = "0.13.0" } -pdfcraft-render = { path = "crates/render", version = "0.13.0" } -pdfcraft-engine = { path = "crates/engine", version = "0.13.0" } -pdfcraft-platform = { path = "crates/platform", version = "0.13.0" } -pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.13.0" } -pdfcraft-automation = { path = "crates/automation", version = "0.13.0", default-features = false } +pdfcraft-geom = { path = "crates/geom", version = "0.14.0" } +pdfcraft-filters = { path = "crates/filters", version = "0.14.0" } +pdfcraft-cos = { path = "crates/cos", version = "0.14.0" } +pdfcraft-crypt = { path = "crates/crypt", version = "0.14.0" } +pdfcraft-organize = { path = "crates/organize", version = "0.14.0" } +pdfcraft-measure = { path = "crates/measure", version = "0.14.0" } +pdfcraft-annot = { path = "crates/annot", version = "0.14.0" } +pdfcraft-audit = { path = "crates/audit", version = "0.14.0" } +pdfcraft-fonts = { path = "crates/fonts", version = "0.14.0" } +pdfcraft-model = { path = "crates/model", version = "0.14.0" } +pdfcraft-content = { path = "crates/content", version = "0.14.0" } +pdfcraft-redact = { path = "crates/redact", version = "0.14.0" } +pdfcraft-print = { path = "crates/print", version = "0.14.0" } +pdfcraft-xfdf = { path = "crates/xfdf", version = "0.14.0" } +pdfcraft-sign = { path = "crates/sign", version = "0.14.0" } +pdfcraft-optimize = { path = "crates/optimize", version = "0.14.0" } +pdfcraft-a11y = { path = "crates/a11y", version = "0.14.0" } +pdfcraft-oracle = { path = "crates/oracle", version = "0.14.0" } +pdfcraft-testkit = { path = "crates/testkit", version = "0.14.0" } +pdfcraft-ocr = { path = "crates/ocr", version = "0.14.0" } +pdfcraft-js = { path = "crates/js", version = "0.14.0" } +pdfcraft-compare = { path = "crates/compare", version = "0.14.0" } +pdfcraft-preflight = { path = "crates/preflight", version = "0.14.0" } +pdfcraft-export = { path = "crates/export", version = "0.14.0" } +pdfcraft-edit = { path = "crates/edit", version = "0.14.0" } +pdfcraft-create = { path = "crates/create", version = "0.14.0" } +pdfcraft-forms = { path = "crates/forms", version = "0.14.0" } +pdfcraft-xfa = { path = "crates/xfa", version = "0.14.0" } +pdfcraft-render = { path = "crates/render", version = "0.14.0" } +pdfcraft-engine = { path = "crates/engine", version = "0.14.0" } +pdfcraft-platform = { path = "crates/platform", version = "0.14.0" } +pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.14.0" } +pdfcraft-automation = { path = "crates/automation", version = "0.14.0", default-features = false } # pdf hayro = "0.7.1" @@ -75,11 +79,13 @@ chrono = { version = "0.4.45", default-features = false, features = ["std"] } proptest = "1" # ui -egui = "0.36" -egui_extras = { version = "0.36", default-features = false, features = ["svg"] } -eframe = { version = "0.36", default-features = false } -egui_kittest = "0.36" +egui = "0.37" +egui_extras = { version = "0.37", default-features = false, features = ["svg"] } +eframe = { version = "0.37", default-features = false } +egui_kittest = "0.37" rfd = "0.17" +# Monotonic time for logic-only work on native and web (`App::logic` runs without a UI pass). +web-time = "1.1" [workspace.lints.rust] unsafe_code = "forbid" @@ -118,6 +124,24 @@ hayro-cmap = { path = "vendor/hayro-cmap" } hayro-postscript = { path = "vendor/hayro-postscript" } hayro-jbig2 = { path = "vendor/hayro-jbig2" } lopdf = { path = "vendor/lopdf" } -winit = { path = "vendor/winit" } -egui-wgpu = { path = "vendor/egui-wgpu" } vello_common = { path = "vendor/vello_common" } +# The egui and winit crates below are pinned to immutable storytold git revisions (egui +# 6d518fc799681571963842f95b55a6b68431b15a, winit a517b3846ee8457d84ee4975110bfda6797c44dc). +# Only the retained `vendor/winit` and `vendor/egui-wgpu` copies are provenance for the app's +# earlier patches and their source guards, not path patches; the other `vendor/` entries above +# remain real path patches. See vendor/README.md and the migration plan. +ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui-winit = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_glow = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_extras = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +eframe = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457d84ee4975110bfda6797c44dc" } diff --git a/NOTICE b/NOTICE index 8cacb0b585..63a8fe672c 100644 --- a/NOTICE +++ b/NOTICE @@ -58,3 +58,21 @@ dependencies change (plan/adr/0002, 0006). Vendored (vendor/, patched; see vendor/README.md): - hayro 0.7.1 — Apache-2.0 OR MIT, Copyright the hayro authors (licence files kept in vendor/hayro). - hayro-interpret 0.7.0 — Apache-2.0 OR MIT, Copyright the hayro authors (LICENSE-APACHE, LICENSE-MIT kept in vendor/hayro-interpret). + +Bundled egui font icons + +The pinned egui dependency includes egui-icons.ttf, a small subset of John Slegers' +emoji-icon-font, through default_fonts. The parent emoji-icon-font.ttf face is +included through monochrome_emoji_fonts. Their upstream project is +https://github.com/jslegers/emoji-icon-font; both use the same exact copyright +and permission notice below. + +MIT License + +Copyright (c) 2014 John Slegers + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md index d39bec5aa9..f319ffeadb 100644 --- a/README.md +++ b/README.md @@ -332,15 +332,13 @@ pdfcraft-cli ui --control ~/.pdfcraft-control.json inspect focused=true pdfcraft-cli ui --control ~/.pdfcraft-control.json quit ``` -`ui.dock` arranges the `tools` and `inspector` panels; `canvas` is the protected document view. -Each per-panel request requires a `panel` ID and an `operation`: `open`, `close`, -`activate`, `float` with `rect: [x, y, width, height]`, or `move` with `target` and -`zone: center|left|right|top|bottom`. For example, -`{"operation":"float","panel":"tools","rect":[100,100,320,440]}` floats Tools. -`ui.dock.reset` restores the default -arrangement. The control channel accepts these methods directly; `ui.command` also accepts -`{"id":"ui.dock","params":{...}}`. Saved preferences retain groups, active tabs, visibility -and floating geometry. Invalid requests return an error without changing the document or layout. +`ui.panel` opens, closes or sizes a side panel: `{"panel":"tools"|"side","open":true|false,"width":320}`. +`tools` is the Tools panel and `side` the panel the rail opens (reopening it shows the one open last). +`width` is clamped to the panel's limits (Tools 240–520, side panel 260–520 points). `ui.dock.reset` +resets the side panels: Tools open on the left, default widths. The control channel accepts these +methods directly; `ui.command` also accepts `{"id":"ui.panel","params":{...}}`. Saved preferences keep +the Tools side, the open panels and their widths. Invalid requests return an error without changing +the document or the panels. `resize` requests a native inner-window size in logical points (each dimension must be between 320 and 8192); the window manager may constrain it. `state` reports the observed `window.width`, `window.height` and `window.pixels_per_point`. `focus` accepts exactly one widget `id` or unique enabled, focusable `label`; subsequent `key` requests traverse or activate the actual widgets. `inspect` includes focusability and accepts a `focused` filter. `quit` takes no parameters: clean documents exit normally, while unsaved edits or typing open the existing Save/Don't save/Cancel prompt and return `quitting: false, needs_confirmation: true`. It never silently discards work. Window resizing and application quit are native-only. @@ -595,13 +593,4 @@ Forks and modified versions must remove them. Made by the ArtCraft team and community.

-Tools and Inspector panels can be dragged into tab groups, split beside the PDF, or floated within the app window. Read and full-screen modes keep their existing focused document view. Drag a panel header to a target edge or another header. Its context menu offers Float panel, Move to group, and Close panel. Existing panel toggles reopen panels at their saved location; Reset Panel Layout restores the app layout. Floating panels remain inside the current app window. - -Advanced `ui.dock` operations use the same validated transaction as pointer gestures: -`moveFloating` updates `rect`, `resizeSplit` accepts a boolean child `path` and -`size` (`{"Ratio":0.5}`, `{"FixedFirst":280}`, or `{"FixedSecond":280}`), -`setStackOpen` takes `open`, and `resizeStack` takes a point `height` or null. -A center `move` can include `before` (panel ID or null to append) for tab order. -The equivalent `{"action": ...}` form accepts a serialized shared docking action; -this is also the form emitted by the renderer. Protected-canvas and app-specific panel -rules apply equally to both forms. +The Tools panel sits on the left, or on the right with Preferences ▸ Tools panel side. The rail stays at the right edge and opens one side panel beside it. Drag a panel's inner edge to resize it; widths are remembered. F4 shows or hides the side panel and Shift+F4 the Tools panel (also in the View menu); View ▸ Reset side panels restores the default arrangement. diff --git a/ROADMAP.md b/ROADMAP.md index 72e123df4c..f5de8044e1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,8 @@ **Stage: alpha** · next: beta, ~25% (ready for real work 50% → 75%) and ~400–700 h away -> **Last reviewed:** 2026-10-23 | **Last updated:** 2026-10-23 | **Change:** minor (M8, #3205 follow-up: labelled Italian international phone numbers with a slash match whole; estimates unchanged) +> **Last reviewed:** 2026-10-27 | **Last updated:** 2026-10-27 | **Change:** minor (M10: OCR model downloads use verified ONNX pairs with legacy discovery fallback; estimates unchanged) +> **Last reviewed:** 2026-10-25 | **Last updated:** 2026-10-25 | **Change:** minor (M14 localization current state: Arabic logical catalog, explicit RTL label direction and mirrored stock chrome; native/browser RTL workflows unqualified. Native release Hans face via LXGW WenKai Lite; estimates unchanged) > **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M7, #2636 partial: pasting added text boxes and images in the engine and through `content_copy`; estimates unchanged) > **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M11: image optimization accounts for page-local UserUnit; estimates unchanged) > **Last reviewed:** 2026-10-23 | **Last updated:** 2026-10-23 | **Change:** minor (M10: Word table cells use the widths of their own grid columns; estimates unchanged) @@ -165,10 +166,10 @@ Portuguese and Spanish vs the current union). Detail and script notes: [localiza | Language | Code | Strings | Status | |---|---|---|---| | English | `en` | 100% | full | -| Simplified Chinese | `zh-hans` | 97.4% | partial (no Hans UI face in releases, #826) | +| Simplified Chinese | `zh-hans` | 97.4% | partial (native LXGW WenKai Lite; no Hans face in the web build, #826) | | Spanish | `es` | 100% of current union | partial (dialogs / tooltips; no help) | | Hindi | `hi` | 0% | none | -| Arabic | `ar` | 100% | partial (layout not mirrored) | +| Arabic | `ar` | 100% | partial (logical catalog, explicit RTL labels, mirrored stock chrome; native/browser/IME workflows unqualified) | | French | `fr` | 99.3% | partial | | Portuguese (Brazil) | `pt-br` | 100% of current union | partial | | Indonesian | `id` | 0% | none | @@ -188,7 +189,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md | Rank | Milestone | Estimate (h) | |---|---|---| | 0 | Convert and share: Create PDF from Office, Word export that keeps layout (#773) | 32–55 | -| 1 | Backlog and platform: crash and wrong-result reports, Chinese UI face (#826) | 50–90 | +| 1 | Backlog and platform: crash and wrong-result reports, Chinese web UI face (#826) | 50–90 | | 2 | Fidelity harness against Acrobat and the renderer decision (M2; owner decision on #841) | 110–190 | | 3 | Editing existing content (M7) | 100–180 | | 4 | OCR languages and Office export/import (M10) | 95–175 | @@ -199,6 +200,11 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md ## Progress log +- **2026-10-27 (M10, OCR models):** Model staging downloads and verifies the ONNX pair after the upstream RTEN URL brownout; runtime discovery prefers complete ONNX pairs per directory, retains complete RTEN fallback and preserves directory precedence. +- **2026-10-24 (M10, #4158 partial):** Word, HTML, RTF and PowerPoint export keep a block that continues another on its line (an italic case name, a link, a superscript) as a run in its own style inside that paragraph, with the source's word space, instead of a paragraph of its own. Style changes that wrap onto the next line still split paragraphs (the reading blocks keep style boundaries). Estimates unchanged. +- **2026-10-24 (M3, window move on Wayland, #3970 follow-up):** After a move or resize by PdfCraft's own title bar or edges, the button release given back to egui goes before the pointer leaving the window, so hover no longer sticks at the old place until the pointer moves. Found by CodeRabbit in review of #3970; a unit test covers it. Estimates unchanged. +- **2026-10-24 (M14, #4253):** The annotation quick bar remembers Keep tool selected across restarts, including unpinning. Old and malformed settings keep safe defaults; the opt-in UI control channel reads and changes the same preference. Estimates unchanged. + - **2026-10-23 (M7, #4280):** Edit text names each standard stand-in font after its face (`PCEdTimesBold`, ...) instead of one shared `PCEdHelv`. Rewriting a bold paragraph whose subset lacks an accent no longer repoints the paragraphs set earlier in a regular stand-in at Times-Bold, and the editor's frame stays on the paragraph being edited. Pages saved by older builds keep their shared name and are not repaired. Estimates unchanged. - **2026-10-23 (M8, #3205 follow-up):** Phone-pattern redaction matches complete labelled Italian geographic numbers such as `Tel. +39 0426/123456`, with synthetic matcher and mark/apply/save/reopen coverage; unlabelled international slash numbers remain unsupported. - **2026-10-23 (M7, #2636 partial):** A new `PasteContent` edit pastes an added text box or image copied in the same document or another, keeping its style, picture, turns, flips and crop, as one undo step; `content_copy` drives it headlessly. A paste lands beside earlier copies (18 pt steps) and stays on the page. A pasted image reuses its picture object in its own document and after the first paste into another; there a colour profile is dropped and CMYK colours stored losslessly are turned to RGB. The desktop keys and menus follow in separate PRs. Estimates unchanged. @@ -223,6 +229,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-20 (M3, #3535):** Escape with a menu open closes the menu and keeps the text selection; the next Escape clears it, as before. One keypress used to do both. A synthetic UI regression checks it. Estimates unchanged. - **2026-10-16 (M3, close prompt keyboard):** The "Save changes?" prompt opens with Save chosen (Enter saves, as before) and the arrow keys and Tab / Shift+Tab move the choice to Cancel or Don't save, shown with a focus ring; Enter or Space press the chosen button and Escape still cancels. Before, Enter always saved whatever was Tab-focused. Estimates unchanged. - **2026-10-21 (M3, #3808):** Page thumbnails in the Inspector can be resized independently from the document and Organize, with a slider, buttons and `thumbnail-zoom` control option. Selection, page following and reload are covered by regressions. Estimates unchanged. +- **2026-10-22 (M5, comment threads):** Replies join the comment their `/IRT` references, not every comment sharing its `/NM`; PyMuPDF names each page's first comment `fitz-A0`, so all replies used to land under page 1. The Comments header counts comments without their replies, with the number of replies in small text under it (in every catalogue), and a reply's thread line runs from the avatar above it to its own. A reply to a reply shows in its thread in the panel too (it was listed nowhere), an `/IRT` that leads to no comment (itself, a loop, a pop-up, an unlisted object) leaves its annotation a comment of its own instead of hiding it (a status or checkmark that leads nowhere is listed nowhere), and a Replace Text caret isn't counted as a reply. The comment tools refuse an `id` several comments share instead of acting on the first (page 1's), and take `page` + `index` for those; `comment_list` reads a status or checkmark from the annotation it was set on, as the panel does, and reports a reply's own on the reply. UI, engine and automation regressions cover it. Estimates unchanged. - **2026-10-22 (M2, #3107 / PR #3438):** Inline-image parsing falls back to the existing EI heuristic when missing declared samples, headers or terminators exceed the content-window budget, instead of waiting for an impossible allocation. Exact-length parsing for available samples and ordinary large streamed images is retained. Estimates unchanged. - **2026-10-21 (M14, Brazilian Portuguese):** Completed the `pt-br` catalog against the current 2,674-key union, including signature validation and accessibility messages; no build or compilation run. Localization remains partial because help and unpredictable engine/OS error details are not translated. - **2026-10-21 (M14, #3557):** Preferences: the OK button stays below the scrolling settings instead of being the last thing in them, so it is in reach in a short window. @@ -316,6 +323,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-15 (M12, XFA, #2574):** A `pageArea` with no `` repeats without limit instead of taking a subform's default of one use, so a later page area with `occur max="1"` is no longer drawn (with a copy of its fields) over every body page. Synthetic regression test; the page area's `occur min` (a once-only page of its own) is still not laid out, recorded in `form.xfa-dynamic`. Estimates unchanged. - **2026-10-14 (M12, reading colours, #2059):** Pending page backgrounds use the document's reading colour immediately, preventing white flashes while scrolling to uncached pages. A headless canvas regression covers three pending pages in every preset and a custom background. Estimates unchanged. - **2026-10-15 (community, M6, #2608):** In Prepare a form the selected field moves with the arrow keys: a point per press, ten with Shift, as the object nudge and dragging it do. Each press is one undo step ("Change field properties"), and the keys no longer turn the page while a field is selected. Estimates unchanged. +- **2026-10-15 (M3, closable community card):** The ArtCraft community card on Home has a close button that hides it for the rest of the run; it returns on the next launch, and its links stay in the top bar's Discord button and the Help menu. A UI regression covers closing and the fresh-launch default. Estimates unchanged. - **2026-10-13 (community, M3, #1569 review):** Pixel-only Wayland wheel, wheel-tilt and continuous input use standalone moved events, so Ctrl zoom does not remain latched after the modifier is released (#991); vendored winit patch (13), with a policy test. Estimates unchanged. - **2026-10-15 (M14, #2383 candidate):** Device-limit requests now clamp unsupported maxima and minimum alignments before enabling the adapter's full 2D texture extent. This addresses the reported V3D request of 16 shader variables on an adapter supporting 15. Synthetic regressions fail before the clamp and pass afterward, along with the existing HiDPI case and Windows GPU-required application tests. Raspberry Pi startup remains unverified; milestone and overall estimates are unchanged. - **2026-10-12 (M6):** XFDF field import and data-file extraction join all text nodes in a value, preserving text after XML comments and processing instructions. Synthetic regressions cover Unicode, CDATA, empty values and PDF save/reopen. Estimates unchanged. @@ -670,10 +678,13 @@ Newest first: the date, what moved, and the new overall estimate. ## Revision history +**2026-10-25 · minor:** M14 localization current state: Arabic catalogs stay logical, Arabic/Kurdish labels take an explicit RTL direction, and stock chrome mirrors; native/browser RTL input, IME and complex workflows remain unqualified. Native Simplified Chinese uses LXGW WenKai Lite; the web build has no Hans face. Estimates unchanged. + **2026-10-15 · minor:** M14/#2383 candidate: GPU device requests clamp unsupported limits while retaining the adapter texture extent. Synthetic V3D/downlevel regressions and Windows GPU-required tests pass; Raspberry Pi hardware remains unverified. Estimates unchanged. | Date | Change | Summary | |---|---|---| +| 2026-10-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/Cargo.toml b/apps/pdfcraft-cli/Cargo.toml index 7b5f9a82f3..3d9d87a812 100644 --- a/apps/pdfcraft-cli/Cargo.toml +++ b/apps/pdfcraft-cli/Cargo.toml @@ -21,7 +21,7 @@ pdfcraft-cos = { workspace = true } pdfcraft-preflight = { workspace = true } pdfcraft-automation = { workspace = true } pdfcraft-platform = { workspace = true } -base64 = "0.22" +base64 = "0.23" [dev-dependencies] # Decodes `render` output in tests/it/owner_password.rs; already a dependency of the engine. diff --git a/apps/pdfcraft-cli/src/check.rs b/apps/pdfcraft-cli/src/check.rs new file mode 100644 index 0000000000..1821f85826 --- /dev/null +++ b/apps/pdfcraft-cli/src/check.rs @@ -0,0 +1,206 @@ +use super::commands::{parse_dpi, read}; +use super::options::{AUDIT_OPTIONS, CHECK_ONE_OPTIONS, CHECK_OPTIONS, flag, positional, validate_options}; +use super::*; + +/// 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..5efec8331c --- /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::*; + +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..ab1a3db34b --- /dev/null +++ b/apps/pdfcraft-cli/src/options.rs @@ -0,0 +1,148 @@ +use super::*; + +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..19eed21876 --- /dev/null +++ b/apps/pdfcraft-cli/src/run.rs @@ -0,0 +1,186 @@ +#[cfg(feature = "mcp")] +use super::options::MCP_OPTIONS; +use super::options::{RUN_OPTIONS, flag, positional, validate_options}; +use super::*; + +// ---- 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..9d12233528 --- /dev/null +++ b/apps/pdfcraft-cli/src/ui.rs @@ -0,0 +1,131 @@ +use super::options::{UI_OPTIONS, flag, positional, validate_options}; +use super::*; + +// ---- 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-web/Cargo.toml b/apps/pdfcraft-web/Cargo.toml index a64d729dcd..ad8243d594 100644 --- a/apps/pdfcraft-web/Cargo.toml +++ b/apps/pdfcraft-web/Cargo.toml @@ -12,7 +12,7 @@ workspace = true [dependencies] pdfcraft-ui-egui = { workspace = true } eframe = { workspace = true, features = ["default_fonts", "glow", "persistence", "accesskit"] } -egui = { workspace = true } +egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"] } [target.'cfg(target_arch = "wasm32")'.dependencies] wasm-bindgen-futures = "0.4" diff --git a/apps/pdfcraft/Cargo.toml b/apps/pdfcraft/Cargo.toml index 6943ad6e44..966af354d1 100644 --- a/apps/pdfcraft/Cargo.toml +++ b/apps/pdfcraft/Cargo.toml @@ -11,15 +11,13 @@ workspace = true [dependencies] # Help ▸ Check for updates (issue #28): one HTTPS request to GitHub, over rustls with the OS's # trusted roots. No bundled root list (webpki-roots and webpki-root-certs are CDLA-licensed). -ureq = { version = "3.4", default-features = false, features = ["rustls-no-provider", "_ring"] } -rustls-native-certs = "0.8" pdfcraft-ui-egui = { workspace = true } # `build_info`: the version and compile-time build label for `--version` and the log. pdfcraft-engine = { workspace = true } # wgpu draws; OpenGL (glow) is the fallback when wgpu can't start on an old or unusual GPU or # driver (#461, #435, #392), and can be chosen with PDFCRAFT_RENDERER=gl. -eframe = { workspace = true, features = ["default_fonts", "wgpu", "glow", "accesskit", "wayland", "x11", "persistence", "links"] } -egui = { workspace = true } +eframe = { workspace = true, features = ["default_fonts", "wgpu", "glow", "accesskit", "wayland", "x11", "persistence", "links", "clipboard"] } +egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"] } serde_json = { workspace = true } # Records from every crate go to standard error and the log file (src/logging.rs). log = { workspace = true } @@ -31,9 +29,6 @@ chrono = { workspace = true } # crate registers the handler (Objective-C interop needs unsafe, which our workspace forbids) # behind a safe API. Same version as PhotoCraft. Pinned exactly: review the diff before bumping. fmv-macos-events = "=0.1.0" -# ⌘Q asks about unsaved changes like the close button (#1136): an app menu whose Quit asks the -# window to close instead of winit's, which ends the process without asking. -craft-quit = { workspace = true, features = ["menu"] } [target.'cfg(target_os = "linux")'.dependencies] # Asks the Wayland compositor which GPU it draws with (linux-dmabuf feedback), so the window is @@ -71,3 +66,6 @@ uds_windows = "=1.2.1" [build-dependencies] # Icon + VERSIONINFO for pdfcraft.exe; build.rs only uses it when targeting Windows. winresource = "0.1" + +[target.'cfg(windows)'.dependencies] +craft-update = { workspace = true } diff --git a/apps/pdfcraft/src/main.rs b/apps/pdfcraft/src/main.rs index 96725cd8d5..f8e4e5b0d2 100644 --- a/apps/pdfcraft/src/main.rs +++ b/apps/pdfcraft/src/main.rs @@ -41,7 +41,6 @@ mod reopen; mod settings; mod single_instance; mod text_scale; -mod updates; #[cfg(test)] #[path = "windows_manifest.rs"] mod windows_manifest; @@ -195,6 +194,11 @@ fn rejoin_split_paths(args: Vec, exists: impl Fn(&str) -> bool) -> Vec eframe::Result { + // Windows: the update helper is a copy of this program started with `--finish-update`. + #[cfg(windows)] + if let Some(result) = craft_update::self_update::finish(&craft_update::suite::PDFCRAFT, &std::env::args_os().skip(1).collect::>()) { + std::process::exit(i32::from(result.is_err())); + } // First, so the panic hook and every start-up warning are recorded (`logging`). let logger = logging::install(); // Last-resort guard (AGENTS.md §4): commands, edits, opens and saves catch panics and report @@ -357,6 +361,9 @@ fn native_options(integrated: bool, app_frame: bool, renderer: eframe::Renderer) let persistence_path = settings_dir().map(|d| d.join("app.ron")); // Set explicitly, so the renderer never depends on which one eframe defaults to. let mut native = eframe::NativeOptions { viewport, persistence_path, renderer, ..Default::default() }; + // PdfCraft installs its own last-resort system face (`PDFCRAFT_SYSTEM_FONTS`); eframe's + // provider would read every installed face, including proprietary ones, into every UI pass. + native.system_font_fallback = false; if renderer == eframe::Renderer::Wgpu { configure_gpu(&mut native); } @@ -416,16 +423,17 @@ fn app_creator<'a>( { app.motion.source = Some(motion::source()); } - app.update_source = Some(std::sync::Arc::new(updates::latest_release)); + app.set_startup_update_check(true); app.printer_source = Some(pdfcraft_ui_egui::system_printers()); app.os_key_store_ids = cfg!(any(target_os = "macos", target_os = "windows")); #[cfg(target_os = "macos")] { app.os_events = Some(apple_events.connect(&cc.egui_ctx)); - // winit's Quit (⌘Q) ends the process without asking about unsaved changes (#1136). This - // menu's Quit closes the window instead, so `guard_quit` asks, as for the close button. - if let Err(e) = craft_quit::install_app_menu(&cc.egui_ctx, "PdfCraft") { - log::warn!("couldn't install the app menu, so ⌘Q quits without asking to save: {e}"); + // The native menu bar. Its Quit (⌘Q) closes the window rather than ending the process as + // winit's does (#1136), so `guard_quit` asks about unsaved changes, as for the close + // button. + if !app.install_native_menu(&cc.egui_ctx) { + log::warn!("couldn't install the macOS menu bar; the menus stay in the window"); } } if let Some(server) = instance { @@ -794,671 +802,4 @@ fn pick_adapter(adapters: &[(u32, u32, eframe::wgpu::DeviceType)], displays: &[D } #[cfg(test)] -mod tests { - use pdfcraft_ui_egui::control::Endpoint; - - fn endpoint() -> std::io::Result { - Ok(Endpoint { port: 4321, token: "0123456789abcdef".into() }) - } - - /// Tests that open a real GPU device take this first, so only one of them is inside the - /// graphics libraries at a time. The test threads otherwise run them together, and on - /// FreeBSD's CI machine the test process then died with SIGSEGV, in about half the runs: - /// the first device was still being set up when the second test began its own. The UI - /// tests take turns the same way (`crates/ui-egui/tests/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"); - } - } - } - } - - #[test] - fn egui_wgpu_carries_the_surface_size_fit() { - // Issue #577: egui-wgpu 0.36.2 configures a window's surface at the window's size, and - // `Surface::configure` panics when that's beyond the device's `max_texture_dimension_2d` - // (emilk/egui#8361). vendor/egui-wgpu fits it within the limit. A dependency bump that - // resolves egui-wgpu from crates.io again, or a re-vendored copy without the patch, would - // bring the crash back: re-apply the patch, or drop the copy once an egui release has a - // fix (vendor/README.md). - let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); - let lock = std::fs::read_to_string(root.join("Cargo.lock")).unwrap(); - let egui_wgpu = lock.split("[[package]]").find(|p| p.contains("\nname = \"egui-wgpu\"\n")).expect("egui-wgpu is in Cargo.lock"); - assert!(!egui_wgpu.contains("\nsource = "), "egui-wgpu must resolve to vendor/egui-wgpu, not:{egui_wgpu}"); - // `surface_fit` is tested above; these keep it in the paths that size a surface. - let painter = std::fs::read_to_string(root.join("vendor/egui-wgpu/src/winit.rs")).unwrap().replace("\r\n", "\n"); - for patch in [ - "let (width, height, render_scale) = surface_fit(window_width, window_height, max_side);", - "let (fit_width, fit_height, _) = surface_fit(width, height, self.max_surface_side());", - "pixels_per_point: pixels_per_point * surface_state.render_scale,", - "old_state.window_width,\n old_state.window_height,", - ] { - assert!(painter.contains(patch), "vendor/egui-wgpu lost its surface size patch: {patch}"); - } - } - - #[test] - fn a_gpu_error_while_a_window_is_set_up_is_returned_not_a_panic() { - // Issue #519: on a 2015 Intel GPU, wgpu's GL backend couldn't configure the window's - // surface (`GpuWaitTimeout`), and wgpu's default error handler panicked before the app was - // created, so PdfCraft never got to retry with OpenGL. vendor/egui-wgpu configures a new - // window's surface inside `catch_errors`. A real device on PdfCraft's own GPU settings, - // and an error wgpu raises before anything reaches the driver: a texture one pixel wider - // than the device allows. - use eframe::wgpu; - let _gpu = gpu(); - let native = super::native_options(false, false, eframe::Renderer::Wgpu); - let eframe::egui_wgpu::WgpuSetup::CreateNew(setup) = &native.wgpu_options.wgpu_setup else { - panic!("default setup creates its own instance") - }; - let instance = pollster::block_on(native.wgpu_options.wgpu_setup.new_instance()); - let options = wgpu::RequestAdapterOptions { power_preference: setup.power_preference, ..Default::default() }; - // CI runners have a software adapter (WARP, llvmpipe); a machine without any skips. - let Ok(adapter) = pollster::block_on(instance.request_adapter(&options)) else { - eprintln!("skipping: no GPU adapter on this machine"); - return; - }; - let Ok((device, _queue)) = pollster::block_on(adapter.request_device(&(setup.device_descriptor)(&adapter))) else { - eprintln!("skipping: the adapter gives no device"); - return; - }; - let texture = |width: u32| { - device.create_texture(&wgpu::TextureDescriptor { - label: Some("issue-519"), - size: wgpu::Extent3d { width, height: 1, depth_or_array_layers: 1 }, - mip_level_count: 1, - sample_count: 1, - dimension: wgpu::TextureDimension::D2, - format: wgpu::TextureFormat::Rgba8Unorm, - usage: wgpu::TextureUsages::TEXTURE_BINDING, - view_formats: &[], - }) - }; - let too_wide = device.limits().max_texture_dimension_2d.saturating_add(1); - let caught = pollster::block_on(eframe::egui_wgpu::winit::catch_errors(&device, || texture(too_wide))); - assert!(matches!(caught, Err(wgpu::Error::Validation { .. })), "{caught:?}"); - // The device stays usable, and nothing is left behind to catch later errors by mistake. - let fine = pollster::block_on(eframe::egui_wgpu::winit::catch_errors(&device, || texture(16))); - assert!(fine.is_ok(), "{fine:?}"); - assert_eq!(pollster::block_on(eframe::egui_wgpu::winit::catch_errors(&device, || 7)).ok(), Some(7)); - // Nested: the inner call keeps its own error, and the outer one still catches what follows. - let caught = pollster::block_on(eframe::egui_wgpu::winit::catch_errors(&device, || { - let inner = pollster::block_on(eframe::egui_wgpu::winit::catch_errors(&device, || texture(too_wide))); - assert!(inner.is_err(), "inner: {inner:?}"); - texture(too_wide) - })); - assert!(matches!(caught, Err(wgpu::Error::Validation { .. })), "outer: {caught:?}"); - } - - #[test] - fn egui_wgpu_returns_a_surface_it_cannot_configure_as_an_error() { - // Issue #519: `catch_errors` is tested above; this keeps it around the first configure of - // every new window's surface, and its error reaching eframe as `WgpuError` (eframe then - // returns `Error::Wgpu`, which `main` retries with OpenGL). Without it a failed configure - // panics before the app is created, and the OpenGL retry never runs. - let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); - let lock = std::fs::read_to_string(root.join("Cargo.lock")).unwrap(); - let egui_wgpu = lock.split("[[package]]").find(|p| p.contains("\nname = \"egui-wgpu\"\n")).expect("egui-wgpu is in Cargo.lock"); - assert!(!egui_wgpu.contains("\nsource = "), "egui-wgpu must resolve to vendor/egui-wgpu, not:{egui_wgpu}"); - let painter = std::fs::read_to_string(root.join("vendor/egui-wgpu/src/winit.rs")).unwrap().replace("\r\n", "\n"); - let add_surface = painter.split("async fn add_surface(").nth(1).and_then(|s| s.split("\n fn ").next()).expect("add_surface"); - for patch in [ - "let installed = catch_errors(&device, || {\n self.install_surface(surface, viewport_id, size.width, size.height, false);\n })", - "return Err(crate::WgpuError::ConfigureSurface(error));", - ] { - assert!(add_surface.contains(patch), "vendor/egui-wgpu lost its surface configure patch: {patch}"); - } - } - - #[test] - fn pipeline_creation_error_is_returned_and_device_remains_usable() { - use eframe::{egui_wgpu, wgpu}; - let _gpu = gpu(); - let mut native = super::native_options(false, false, eframe::Renderer::Wgpu); - let egui_wgpu::WgpuSetup::CreateNew(setup) = &native.wgpu_options.wgpu_setup else { panic!("default setup creates its own instance") }; - let instance = pollster::block_on(native.wgpu_options.wgpu_setup.new_instance()); - let options = wgpu::RequestAdapterOptions { power_preference: setup.power_preference, ..Default::default() }; - let Ok(adapter) = pollster::block_on(instance.request_adapter(&options)) else { - assert!(std::env::var_os("PDFCRAFT_TEST_REQUIRE_GPU").is_none(), "a GPU adapter is required for this run"); - eprintln!("skipping pipeline regression: no GPU adapter"); - return; - }; - let (device, queue) = pollster::block_on(adapter.request_device(&(setup.device_descriptor)(&adapter))) - .expect("the selected adapter must provide a test device"); - native.wgpu_options.wgpu_setup = - egui_wgpu::WgpuSetup::Existing(egui_wgpu::WgpuSetupExisting { instance: instance.clone(), adapter, device, queue }); - // Three samples is invalid on every backend, so this reproduces a pipeline error - // without depending on a particular driver. The unpatched constructor panics. - for samples in [3, 1, 3, 1] { - let options = egui_wgpu::RendererOptions { msaa_samples: samples, ..Default::default() }; - let result = pollster::block_on(egui_wgpu::RenderState::create(&native.wgpu_options, &instance, None, options)); - if samples == 3 { - let error = result.err().expect("invalid pipeline must return an error"); - assert!(error.to_string().contains("egui_pipeline"), "{error}"); - } else { - assert!(result.is_ok(), "the device must remain usable: {:?}", result.err()); - } - } - } - - #[test] - fn winit_carries_the_windows_11_monitor_scale_fix() { - // Issue #324: winit 0.30.13 as released nudges a window dragged onto a monitor with another - // scale factor back onto the one it is leaving, so on Windows 11 it ends up on the wrong - // monitor, at the wrong size and scale. vendor/winit carries the fix from winit master. A - // dependency bump that resolves winit from crates.io again, or a re-vendored copy without - // the patch, would silently bring the bug back: re-apply the patch, or drop the copy once a - // winit 0.30 release has the fix (vendor/README.md). - let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); - let lock = std::fs::read_to_string(root.join("Cargo.lock")).unwrap(); - let winit = lock.split("[[package]]").find(|p| p.contains("\nname = \"winit\"\n")).expect("winit is in Cargo.lock"); - assert!(!winit.contains("\nsource = "), "winit must resolve to vendor/winit, not:{winit}"); - let dpi_changed = std::fs::read_to_string(root.join("vendor/winit/src/platform_impl/windows/event_loop.rs")).unwrap(); - let patch = "if !WIN10_BUILD_VERSION.is_some_and(|build| build < 22000) {\n new_outer_rect = suggested_rect;"; - assert!(dpi_changed.contains(patch), "vendor/winit lost its WM_DPICHANGED patch"); - } - - const NVIDIA: (u32, u32) = (0x10de, 0x2684); - const NVIDIA_3090: (u32, u32) = (0x10de, 0x2204); - const AMD_IGPU: (u32, u32) = (0x1002, 0x164e); - - fn adapters() -> Vec<(u32, u32, eframe::wgpu::DeviceType)> { - use eframe::wgpu::DeviceType; - vec![(NVIDIA.0, NVIDIA.1, DeviceType::DiscreteGpu), (AMD_IGPU.0, AMD_IGPU.1, DeviceType::IntegratedGpu), (0, 0, DeviceType::Cpu)] - } - - const INTEL_IGPU: (u32, u32) = (0x8086, 0xa780); - - fn monitor(pci: (u32, u32)) -> super::DisplayGpu { - super::DisplayGpu { pci, primary: false } - } - - fn panel(pci: (u32, u32)) -> super::DisplayGpu { - super::DisplayGpu { pci, primary: true } - } - - #[test] - fn pick_adapter_prefers_the_gpu_driving_the_monitors() { - // A desktop whose monitors are all on the discrete GPU: the integrated one shows black. - assert_eq!(super::pick_adapter(&adapters(), &[monitor(NVIDIA)], None), Some(0)); - // Issue #378: the same on Windows, where the Ryzen integrated GPU without a monitor crashed - // the display driver. Windows also says which display is the primary one. - assert_eq!(super::pick_adapter(&adapters(), &[panel(NVIDIA)], None), Some(0)); - } - - #[test] - fn pick_adapter_prefers_the_gpu_driving_the_primary_display() { - // A Windows desktop with a monitor on each GPU: the window opens on the primary display. - assert_eq!(super::pick_adapter(&adapters(), &[monitor(AMD_IGPU), panel(NVIDIA)], None), Some(0)); - assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU), monitor(NVIDIA)], None), Some(1)); - } - - #[test] - fn pci_ids_come_from_windows_device_ids() { - assert_eq!(super::pci_ids("PCI\\VEN_10DE&DEV_2204&SUBSYS_40421458&REV_A1"), Some(NVIDIA_3090)); - assert_eq!(super::pci_ids("PCI\\VEN_1002&DEV_164E&SUBSYS_88771043&REV_C1"), Some(AMD_IGPU)); - assert_eq!(super::pci_ids("pci\\ven_1002&dev_164e"), Some(AMD_IGPU)); - // Remote Desktop and other non-PCI display devices, and malformed ids. - assert_eq!(super::pci_ids("ROOT\\BasicDisplay\\0000"), None); - assert_eq!(super::pci_ids("PCI\\VEN_10DE&SUBSYS_40421458"), None); - assert_eq!(super::pci_ids("PCI\\VEN_10DE&DEV_ZZZZ"), None); - assert_eq!(super::pci_ids("VEN_&DEV_"), None); - assert_eq!(super::pci_ids(""), None); - } - - #[cfg(target_os = "windows")] - #[test] - fn windows_display_gpus_lists_pci_gpus_with_a_monitor() { - // Whatever this machine has: every entry is a PCI GPU, at most one drives the primary - // display, and no GPU is listed twice. (A headless CI runner may list none.) - let gpus = super::windows_display_gpus(); - assert!(gpus.iter().filter(|g| g.primary).count() <= 1, "{gpus:?}"); - for (i, g) in gpus.iter().enumerate() { - assert!(g.pci.0 != 0, "{gpus:?}"); - assert!(!gpus[..i].iter().any(|h| h.pci == g.pci), "{gpus:?}"); - } - } - - #[test] - fn pick_adapter_keeps_the_integrated_gpu_on_hybrid_laptops() { - // Issue #8: the panel is on the integrated GPU, an external monitor on the discrete one. - assert_eq!(super::pick_adapter(&adapters(), &[monitor(NVIDIA), panel(AMD_IGPU)], None), Some(1)); - assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU), monitor(NVIDIA)], None), Some(1)); - assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU)], None), Some(1)); - assert_eq!(super::pick_adapter(&adapters(), &[monitor(AMD_IGPU)], None), Some(1)); - } - - #[test] - fn pick_adapter_prefers_the_discrete_gpu_on_desktops_with_a_monitor_on_each() { - // Issue #445: one monitor on the Intel iGPU, one on the NVIDIA card, no built-in panel. - use eframe::wgpu::DeviceType; - let desktop = - [(INTEL_IGPU.0, INTEL_IGPU.1, DeviceType::IntegratedGpu), (NVIDIA.0, NVIDIA.1, DeviceType::DiscreteGpu), (0, 0, DeviceType::Cpu)]; - assert_eq!(super::pick_adapter(&desktop, &[monitor(INTEL_IGPU), monitor(NVIDIA)], None), Some(1)); - assert_eq!(super::pick_adapter(&desktop, &[monitor(NVIDIA), monitor(INTEL_IGPU)], None), Some(1)); - // A GPU driving no display still loses to one that does. - assert_eq!(super::pick_adapter(&adapters(), &[monitor(AMD_IGPU), monitor(INTEL_IGPU)], None), Some(1)); - } - - #[test] - fn pick_adapter_prefers_the_gpu_the_compositor_draws_with() { - // A hybrid laptop whose Wayland compositor runs on the discrete GPU (Hyprland with - // `AQ_DRM_DEVICES` naming it first): the integrated GPU drives the built-in panel, yet the - // compositor shows nothing it draws, and reports no error either. - assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU)], Some(NVIDIA)), Some(0)); - assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU), monitor(NVIDIA)], Some(NVIDIA)), Some(0)); - // The usual hybrid laptop, with the compositor on the integrated GPU: as before (#8). - assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU), monitor(NVIDIA)], Some(AMD_IGPU)), Some(1)); - // No GPU drives a display as far as sysfs says (a container), but the compositor knows. - assert_eq!(super::pick_adapter(&adapters(), &[], Some(NVIDIA)), Some(0)); - // A compositor GPU no adapter matches (a virtual one) leaves the displays in charge. - assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU)], Some((0x1af4, 0x1050))), Some(1)); - } - - #[test] - fn pick_adapter_falls_back_to_low_power_without_a_match() { - assert_eq!(super::pick_adapter(&adapters(), &[monitor((0x8086, 0x1234))], None), Some(1)); - assert_eq!(super::pick_adapter(&adapters(), &[monitor((0x8086, 0x1234)), monitor((0x8086, 0x5678))], None), Some(1)); - assert_eq!(super::pick_adapter(&[], &[monitor(NVIDIA)], None), None); - } - - #[test] - fn internal_panels_are_edp_lvds_and_dsi_connectors() { - for name in ["eDP-1", "LVDS-1", "DSI-1"] { - assert!(super::is_internal_panel(name), "{name}"); - } - for name in ["DP-3", "HDMI-A-1", "DVI-D-1", "VGA-1", "Writeback-1", ""] { - assert!(!super::is_internal_panel(name), "{name}"); - } - } - - #[cfg(target_os = "linux")] - #[test] - fn linux_display_gpus_reads_connected_connectors() -> std::io::Result<()> { - let dir = std::env::temp_dir().join(format!("pdfcraft-drm-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - let card = |name: &str, (vendor, device): (u32, u32)| -> std::io::Result<()> { - std::fs::create_dir_all(dir.join(name).join("device"))?; - std::fs::write(dir.join(name).join("device/vendor"), format!("{vendor:#06x}\n"))?; - std::fs::write(dir.join(name).join("device/device"), format!("{device:#06x}\n")) - }; - let connector = |name: &str, status: &str| -> std::io::Result<()> { - std::fs::create_dir_all(dir.join(name))?; - std::fs::write(dir.join(name).join("status"), format!("{status}\n")) - }; - card("card1", NVIDIA)?; - card("card2", AMD_IGPU)?; - connector("card1-DP-3", "connected")?; - connector("card1-DP-4", "connected")?; - connector("card2-HDMI-A-1", "disconnected")?; - connector("card2-Writeback-1", "unknown")?; - let desktop = super::linux_display_gpus(&dir); - // Issue #445: a monitor on the integrated GPU too, still no built-in panel. - connector("card2-HDMI-A-1", "connected")?; - let mut both = super::linux_display_gpus(&dir); - // A hybrid laptop: the integrated GPU drives the built-in panel as well. - connector("card2-eDP-1", "connected")?; - let mut laptop = super::linux_display_gpus(&dir); - std::fs::remove_dir_all(&dir)?; - assert_eq!(desktop, vec![monitor(NVIDIA)]); - both.sort_by_key(|g| g.pci); - assert_eq!(both, vec![monitor(AMD_IGPU), monitor(NVIDIA)]); - laptop.sort_by_key(|g| g.pci); - assert_eq!(laptop, vec![panel(AMD_IGPU), monitor(NVIDIA)]); - assert!(super::linux_display_gpus(std::path::Path::new("/nonexistent/drm")).is_empty()); - Ok(()) - } - - /// A fresh folder per call: tests run in parallel. - fn scratch() -> std::path::PathBuf { - static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); - let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - let d = std::env::temp_dir().join(format!("pdfcraft-control-file-{}-{n}", std::process::id())); - let _ = std::fs::remove_dir_all(&d); - std::fs::create_dir_all(&d).unwrap(); - d - } - - /// The control file's port and token, and every name in its folder. - fn written(dir: &std::path::Path) -> (serde_json::Value, Vec) { - let json = serde_json::from_str(&std::fs::read_to_string(dir.join("ctl.json")).unwrap()).unwrap(); - let mut names: Vec = std::fs::read_dir(dir).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect(); - names.sort(); - (json, names) - } - - #[test] - fn the_control_file_replaces_a_hard_link_instead_of_writing_through_it() { - // The file used to be opened with create + truncate, so a link planted at the path had its - // target truncated and overwritten with the token. - let dir = scratch(); - std::fs::write(dir.join("victim.txt"), "keep me").unwrap(); - std::fs::hard_link(dir.join("victim.txt"), dir.join("ctl.json")).unwrap(); - super::write_control_file(&dir.join("ctl.json").to_string_lossy(), 4242, "t0ken").unwrap(); - assert_eq!(std::fs::read_to_string(dir.join("victim.txt")).unwrap(), "keep me"); - let (json, names) = written(&dir); - assert_eq!((json["port"].as_u64(), json["token"].as_str()), (Some(4242), Some("t0ken"))); - assert_eq!(names, ["ctl.json", "victim.txt"], "no temporary file left behind"); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn the_control_file_replaces_a_symlink_instead_of_writing_through_it() { - let dir = scratch(); - let (link, target) = (dir.join("ctl.json"), dir.join("victim.txt")); - std::fs::write(&target, "keep me").unwrap(); - #[cfg(unix)] - let made = std::os::unix::fs::symlink(&target, &link); - #[cfg(windows)] - let made = std::os::windows::fs::symlink_file(&target, &link); - if let Err(e) = made { - // Windows needs Developer Mode (or admin) for symlinks. - eprintln!("skipped: can't create a symlink here: {e}"); - let _ = std::fs::remove_dir_all(&dir); - return; - } - super::write_control_file(&link.to_string_lossy(), 4242, "t0ken").unwrap(); - assert_eq!(std::fs::read_to_string(&target).unwrap(), "keep me"); - assert!(!std::fs::symlink_metadata(&link).unwrap().file_type().is_symlink()); - assert_eq!(written(&dir).0["token"].as_str(), Some("t0ken")); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn the_control_file_replaces_the_one_a_previous_run_left() { - let dir = scratch(); - std::fs::write(dir.join("ctl.json"), r#"{"port":1,"token":"old"}"#).unwrap(); - super::write_control_file(&dir.join("ctl.json").to_string_lossy(), 4242, "t0ken").unwrap(); - let (json, names) = written(&dir); - assert_eq!(json["token"].as_str(), Some("t0ken")); - assert_eq!(names, ["ctl.json"]); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let mode = std::fs::metadata(dir.join("ctl.json")).unwrap().permissions().mode(); - assert_eq!(mode & 0o077, 0, "only the owner can read the token: {mode:o}"); - } - let _ = std::fs::remove_dir_all(&dir); - } - - /// X11 startup notification and docks match a window to its launcher by `StartupWMClass`, - /// so it has to be the class the window carries: the app id, on Wayland and X11 alike. - #[test] - fn the_desktop_entry_names_the_window_class() { - let entry = include_str!("../../../packaging/linux/ai.storyteller.pdfcraft.desktop"); - let class = entry.lines().find_map(|line| line.strip_prefix("StartupWMClass=")); - assert_eq!(class, Some(super::APP_ID)); - } - - // 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/apps/pdfcraft/src/updates.rs b/apps/pdfcraft/src/updates.rs deleted file mode 100644 index 686c8cee84..0000000000 --- a/apps/pdfcraft/src/updates.rs +++ /dev/null @@ -1,127 +0,0 @@ -//! Asks GitHub for the latest PdfCraft release (Help ▸ Check for updates, issue #28). - -use std::time::Duration; - -use pdfcraft_ui_egui::updates::{RELEASES_PAGE, Release}; - -const LATEST: &str = "https://api.github.com/repos/storytold/pdfcraft/releases/latest"; - -/// The latest release. The answer is untrusted: its size is capped, and only a page under -/// [`RELEASES_PAGE`] is ever offered for download (anything else falls back to that list). -pub fn latest_release() -> Result { - let agent = ureq::Agent::config_builder() - .timeout_global(Some(Duration::from_secs(10))) - .tls_config(ureq::tls::TlsConfig::builder().root_certs(os_roots()?).build()) - .build() - .new_agent(); - let mut response = agent - .get(LATEST) - .header("Accept", "application/vnd.github+json") - .header("User-Agent", concat!("PdfCraft/", env!("CARGO_PKG_VERSION"))) - // Read error statuses ourselves: a 403 from GitHub usually means its anonymous - // rate limit ran out, and the retry time rides along in a response header. - .config() - .http_status_as_error(false) - .build() - .call() - .map_err(|e| format!("couldn't reach GitHub ({e})"))?; - let status = response.status().as_u16(); - if status == 403 { - let reset = response.headers().get("x-ratelimit-reset").and_then(|v| v.to_str().ok()); - return Err(rate_limited(reset, now_unix())); - } - if !(200..300).contains(&status) { - return Err(format!("couldn't reach GitHub (http status: {status})")); - } - let body = response.body_mut().with_config().limit(1 << 20).read_to_string().map_err(|e| format!("unreadable answer ({e})"))?; - parse(&body) -} - -/// The certificate authorities the operating system trusts. -fn os_roots() -> Result { - let found = rustls_native_certs::load_native_certs(); - let certs: Vec> = found.certs.iter().map(|c| ureq::tls::Certificate::from_der(c.as_ref()).to_owned()).collect(); - if certs.is_empty() { - return Err("no trusted certificates found on this system".into()); - } - Ok(ureq::tls::RootCerts::new_with_certs(&certs)) -} - -/// The current Unix time in seconds (0 when the clock gives nothing usable). -fn now_unix() -> u64 { - std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0) -} - -/// What to tell the user when GitHub answers 403: its anonymous quota (60 checks an hour, -/// shared by everyone behind the same address) ran out. `reset` is the `x-ratelimit-reset` -/// header, the Unix time the quota refills; `now` is the current Unix time. Anything -/// surprising (no header, not a number, already past) falls back to asking for patience. -fn rate_limited(reset: Option<&str>, now: u64) -> String { - const FALLBACK: &str = "GitHub is limiting update checks for now (shared hourly quota); try again later"; - let wait = reset.and_then(|r| r.trim().parse::().ok()).and_then(|reset| reset.checked_sub(now)).filter(|&wait| wait > 0); - match wait { - Some(wait) => { - let minutes = wait.div_ceil(60).max(1); - let plural = if minutes == 1 { "" } else { "s" }; - format!("GitHub is limiting update checks for now (shared hourly quota); try again in about {minutes} minute{plural}") - } - None => FALLBACK.into(), - } -} - -fn parse(body: &str) -> Result { - let v: serde_json::Value = serde_json::from_str(body).map_err(|e| format!("unreadable answer ({e})"))?; - let version = v["tag_name"].as_str().filter(|t| !t.is_empty() && t.len() <= 64).ok_or("no release found")?.to_string(); - let url = v["html_url"] - .as_str() - .filter(|u| u.strip_prefix(RELEASES_PAGE).is_some_and(|rest| rest.starts_with('/') && !rest.contains(['?', '#', '\\']))) - .unwrap_or(RELEASES_PAGE) - .to_string(); - Ok(Release { version, url }) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn answers_are_read_and_only_our_release_pages_are_offered() { - let r = parse(r#"{"tag_name":"v0.2.0","html_url":"https://github.com/storytold/pdfcraft/releases/tag/v0.2.0"}"#).unwrap(); - assert_eq!(r, Release { version: "v0.2.0".into(), url: "https://github.com/storytold/pdfcraft/releases/tag/v0.2.0".into() }); - for elsewhere in ["https://example.com/pdfcraft.exe", "https://github.com/storytold/pdfcraft/releases.evil/x", "javascript:alert(1)"] { - let r = parse(&format!(r#"{{"tag_name":"v9.9.9","html_url":"{elsewhere}"}}"#)).unwrap(); - assert_eq!(r.url, RELEASES_PAGE, "{elsewhere}"); - } - assert!(parse(r#"{"message":"Not Found"}"#).is_err()); - assert!(parse("").is_err()); - } - - #[test] - fn rate_limit_messages_name_the_wait() { - let limited = "GitHub is limiting update checks for now (shared hourly quota); try again in about 20 minutes"; - assert_eq!(rate_limited(Some("1800001200"), 1800000000), limited); - // Rounds up: 61 seconds out reads as 2 minutes. - assert_eq!( - rate_limited(Some("1800000061"), 1800000000), - "GitHub is limiting update checks for now (shared hourly quota); try again in about 2 minutes" - ); - assert_eq!( - rate_limited(Some("1800000060"), 1800000000), - "GitHub is limiting update checks for now (shared hourly quota); try again in about 1 minute" - ); - // No header, not a number, or already past: ask for patience instead of a time. - let later = "GitHub is limiting update checks for now (shared hourly quota); try again later"; - for bad in [None, Some(""), Some("soon"), Some("1799999999"), Some("1800000000")] { - assert_eq!(rate_limited(bad, 1800000000), later); - } - } - - /// Live: asks GitHub over TLS with the OS's roots (`cargo test -p pdfcraft -- --ignored`). - #[test] - #[ignore = "needs network access"] - fn github_answers_with_the_latest_release() { - let r = latest_release().unwrap(); - assert!(pdfcraft_ui_egui::updates::is_newer(&r.version, "0.0.0"), "{r:?}"); - assert!(r.url.starts_with(RELEASES_PAGE), "{r:?}"); - } -} diff --git a/apps/pdfcraft/tests/it/main.rs b/apps/pdfcraft/tests/it/main.rs index bc05099a51..b09ace5f40 100644 --- a/apps/pdfcraft/tests/it/main.rs +++ b/apps/pdfcraft/tests/it/main.rs @@ -7,6 +7,7 @@ mod event { pub use winit::event::TouchPhase; } +mod vendor_graph; mod winit_macos_emoji_picker; mod winit_macos_ime_commit; mod winit_macos_ime_discard; diff --git a/apps/pdfcraft/tests/it/vendor_graph.rs b/apps/pdfcraft/tests/it/vendor_graph.rs new file mode 100644 index 0000000000..a6a4d4b292 --- /dev/null +++ b/apps/pdfcraft/tests/it/vendor_graph.rs @@ -0,0 +1,81 @@ +//! Dependency-graph verification for the egui 0.37 window stack. +//! +//! The twelve `winit_*` modules next to this one read the retained `vendor/winit` / +//! `vendor/egui-wgpu` copies. They are provenance for W1–W13 and E2+E3, not proof of what the app +//! runs: the root manifest pins both crates to immutable storytold git revisions, and the retained +//! copies are not path patches. These tests check the resolved graph and the retained evidence +//! instead of treating the copies as the runtime source. The winit fork's own W17 tests own patch +//! behaviour. + +/// Return the workspace root above the PdfCraft app crate. +fn root() -> std::path::PathBuf { + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..") +} + +/// Every `[[package]]` block in `Cargo.lock` whose `name` is exactly `name`. +fn locked_packages(name: &str) -> Vec { + let lock = std::fs::read_to_string(root().join("Cargo.lock")).expect("Cargo.lock"); + let needle = format!("name = \"{name}\""); + lock.split("[[package]]").filter(|package| package.lines().any(|line| line.trim() == needle.as_str())).map(str::to_owned).collect() +} + +/// The one resolved package called `name`. A duplicate name is a lockfile problem, not something +/// to resolve by taking the first match. +fn locked(name: &str) -> String { + let mut packages = locked_packages(name); + assert_eq!(packages.len(), 1, "Cargo.lock must contain exactly one `{name}` package, found {}", packages.len()); + packages.remove(0) +} + +/// The resolved package must be the exact canonical storytold git source at the reviewed +/// 40-character revision, including the `Cargo.lock` URL and fragment. A path source (no `source` +/// line) is refused: it would make the graph depend on a developer's checkout. A published path +/// patch is refused as well. +fn assert_resolved(name: &str, version: &str, url: &str, rev: &str) { + let package = locked(name); + let expected_version = format!("version = \"{version}\""); + assert!(package.lines().any(|line| line.trim() == expected_version.as_str()), "{name} must resolve to {version}: {package}"); + let sources: Vec<&str> = package.lines().filter_map(|line| line.trim().strip_prefix("source = ")).collect(); + assert_eq!(sources.len(), 1, "{name} must have exactly one `source` in Cargo.lock; a local path source is not accepted: {package}"); + let expected_source = format!("\"git+{url}?rev={rev}#{rev}\""); + assert_eq!(sources[0], expected_source.as_str(), "{name} must resolve to the exact canonical git URL, revision and fragment, not {}", sources[0]); + + let manifest = std::fs::read_to_string(root().join("Cargo.toml")).expect("root Cargo.toml"); + assert!(!manifest.contains(&format!("{name} = {{ path = \"vendor/")), "vendor/{name} cannot stay a published path patch"); +} + +/// Regression test: the resolved window stack points at the reviewed forks and the retained vendor provenance +/// files remain. +#[test] +fn vendor_provenance_window_stack_graph_is_resolved() { + assert_resolved("winit", "0.30.13", "https://github.com/storytold/winit.git", "a517b3846ee8457d84ee4975110bfda6797c44dc"); + assert_resolved("egui-wgpu", "0.37.0", "https://github.com/storytold/egui.git", "6d518fc799681571963842f95b55a6b68431b15a"); + // Retained provenance: the audited copies and their recorded W1–W13 / E2+E3 rows. + let readme = std::fs::read_to_string(root().join("vendor/README.md")).expect("vendor/README.md"); + for marker in ["W1–W13", "surface_fit", "catch_errors", "e9809ef54b18499bb4f2cac945719ecc2a61061b"] { + assert!(readme.contains(marker), "vendor/README.md lost window-stack provenance: {marker}"); + } + let vcs = std::fs::read_to_string(root().join("vendor/winit/.cargo_vcs_info.json")).expect("vendor/winit provenance"); + assert!(vcs.contains("e9809ef54b18499bb4f2cac945719ecc2a61061b"), "vendor/winit keeps its upstream base"); + for path in ["vendor/winit/src/platform_impl", "vendor/egui-wgpu/src/winit.rs"] { + assert!(root().join(path).exists(), "retain {path} as provenance for the pinned fork"); + } +} + +/// Renderer, security and PostScript vendors are unrelated to the UI port and must stay path patches. +#[test] +fn renderer_and_security_vendors_remain() { + let manifest = std::fs::read_to_string(root().join("Cargo.toml")).expect("root Cargo.toml"); + for vendor in [ + "vendor/hayro", + "vendor/hayro-interpret", + "vendor/hayro-syntax", + "vendor/hayro-cmap", + "vendor/hayro-jbig2", + "vendor/hayro-postscript", + "vendor/lopdf", + "vendor/vello_common", + ] { + assert!(manifest.contains(&format!("path = \"{vendor}\"")), "{vendor} must stay a path patch"); + } +} diff --git a/apps/pdfcraft/tests/it/winit_macos_emoji_picker.rs b/apps/pdfcraft/tests/it/winit_macos_emoji_picker.rs index 6cedf57e33..b95058ba94 100644 --- a/apps/pdfcraft/tests/it/winit_macos_emoji_picker.rs +++ b/apps/pdfcraft/tests/it/winit_macos_emoji_picker.rs @@ -1,7 +1,11 @@ -//! vendor/winit patch (8) (vendor/README.md). +//! Provenance-only: reads the retained `vendor/` copy, not the resolved dependency, so it is +//! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity. +//! Retained vendor provenance for winit patch (8) (vendor/README.md); `vendor_graph.rs` checks the resolved graph. +/// Regression test: the vendored winit commits text inserted outside a key press and leaves `selectedRange` +/// unmarked. #[test] -fn winit_commits_macos_text_inserted_outside_a_key_press() { +fn vendor_provenance_winit_macos_text_inserted_outside_a_key_press() { // The macOS emoji picker inserts its text with `insertText` outside any key press and without // a composition. winit 0.30.13 reports `insertText` only as part of a key press or a // composition, so an emoji picked into a PdfCraft text field was dropped. vendor/winit commits diff --git a/apps/pdfcraft/tests/it/winit_macos_ime_commit.rs b/apps/pdfcraft/tests/it/winit_macos_ime_commit.rs index 8701fc20a1..877f9e2a22 100644 --- a/apps/pdfcraft/tests/it/winit_macos_ime_commit.rs +++ b/apps/pdfcraft/tests/it/winit_macos_ime_commit.rs @@ -1,7 +1,11 @@ -//! vendor/winit patch (6) (vendor/README.md). +//! Provenance-only: reads the retained `vendor/` copy, not the resolved dependency, so it is +//! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity. +//! Retained vendor provenance for winit patch (6) (vendor/README.md); `vendor_graph.rs` checks the resolved graph. +/// Regression test: the vendored winit commits a composition that ended without marked text and keeps the +/// marked-text underline. #[test] -fn winit_commits_macos_compositions_that_end_without_marked_text() { +fn vendor_provenance_winit_macos_compositions_that_end_without_marked_text() { // winit 0.30.13 treats `insertText` as an IME commit only while marked text is left. Input // methods that end a composition with `unmarkText` first lost the committed text, and Apple // Korean, which clears its marked text before inserting the key typed after a syllable (`한` diff --git a/apps/pdfcraft/tests/it/winit_macos_ime_discard.rs b/apps/pdfcraft/tests/it/winit_macos_ime_discard.rs index 52e65622f6..89efbd3d81 100644 --- a/apps/pdfcraft/tests/it/winit_macos_ime_discard.rs +++ b/apps/pdfcraft/tests/it/winit_macos_ime_discard.rs @@ -1,7 +1,10 @@ -//! vendor/winit patch (7) (vendor/README.md). +//! Provenance-only: reads the retained `vendor/` copy, not the resolved dependency, so it is +//! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity. +//! Retained vendor provenance for winit patch (7) (vendor/README.md); `vendor_graph.rs` checks the resolved graph. +/// Regression test: disallowing IME in the vendored winit discards the macOS input method's marked text. #[test] -fn winit_discards_the_macos_composition_when_ime_is_disallowed() { +fn vendor_provenance_winit_discards_the_macos_composition_when_ime_is_disallowed() { // egui disallows IME when a text field loses focus. winit 0.30.13 then forgets the marked text // but doesn't tell the input method, which keeps the composition and continues it once IME is // allowed again: the abandoned syllable comes back in the next field. vendor/winit calls diff --git a/apps/pdfcraft/tests/it/winit_macos_korean_first_key.rs b/apps/pdfcraft/tests/it/winit_macos_korean_first_key.rs index 76bb32ccbd..f18dde0f2a 100644 --- a/apps/pdfcraft/tests/it/winit_macos_korean_first_key.rs +++ b/apps/pdfcraft/tests/it/winit_macos_korean_first_key.rs @@ -1,7 +1,10 @@ -//! vendor/winit patch (10) (vendor/README.md). +//! Provenance-only: reads the retained `vendor/` copy, not the resolved dependency, so it is +//! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity. +//! Retained vendor provenance for winit patch (10) (vendor/README.md); `vendor_graph.rs` checks the resolved graph. +/// Regression test: the vendored winit retries the first Apple Korean key once so it starts a composition. #[test] -fn winit_retries_the_first_apple_korean_key_once() { +fn vendor_provenance_winit_retries_the_first_apple_korean_key_once() { // On the first key in a freshly started app, Apple Korean can answer with `insertText` and the // raw compatibility jamo instead of starting a composition, so typing g k s in PdfCraft gave // ㅎㅏㄴ instead of 한. vendor/winit interprets that one key again before sending it to the diff --git a/apps/pdfcraft/tests/it/winit_macos_korean_space.rs b/apps/pdfcraft/tests/it/winit_macos_korean_space.rs index 30c9d4779c..f5f2aada2f 100644 --- a/apps/pdfcraft/tests/it/winit_macos_korean_space.rs +++ b/apps/pdfcraft/tests/it/winit_macos_korean_space.rs @@ -1,7 +1,10 @@ -//! vendor/winit patch (9) (vendor/README.md). +//! Provenance-only: reads the retained `vendor/` copy, not the resolved dependency, so it is +//! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity. +//! Retained vendor provenance for winit patch (9) (vendor/README.md); `vendor_graph.rs` checks the resolved graph. +/// Regression test: the vendored winit sends a key committed through macOS IME only once. #[test] -fn winit_sends_a_key_committed_through_macos_ime_only_once() { +fn vendor_provenance_winit_sends_a_key_committed_through_macos_ime_only_once() { // Apple Korean answers Space with no syllable in progress with `setMarkedText(" ")`, // `insertText(" ")` and `setMarkedText("")`. The last call resets winit 0.30.13's IME state, so // the key went to the app both as `Ime::Commit(" ")` and as `KeyboardInput` with text " ", and diff --git a/apps/pdfcraft/tests/it/winit_macos_title_bar_drag.rs b/apps/pdfcraft/tests/it/winit_macos_title_bar_drag.rs index 5422af59b5..5c8e892f25 100644 --- a/apps/pdfcraft/tests/it/winit_macos_title_bar_drag.rs +++ b/apps/pdfcraft/tests/it/winit_macos_title_bar_drag.rs @@ -1,7 +1,11 @@ -//! vendor/winit patch (12) (vendor/README.md). +//! Provenance-only: reads the retained `vendor/` copy, not the resolved dependency, so it is +//! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity. +//! Retained vendor provenance for winit patch (12) (vendor/README.md); `vendor_graph.rs` checks the resolved graph. +/// Regression test: the vendored winit lets title-bar drags reach the app while the empty tab strip still +/// moves the window. #[test] -fn winit_lets_macos_title_bar_drags_reach_the_app() { +fn vendor_provenance_winit_lets_macos_title_bar_drags_reach_the_app() { // On macOS the tab strip is the title bar: a transparent title bar over a full-size content // view. NSView answers YES to `mouseDownCanMoveWindow` for a view that isn't opaque, so AppKit // moved the whole window for a drag that began on a document tab, and tabs could not be diff --git a/apps/pdfcraft/tests/it/winit_wayland_scroll.rs b/apps/pdfcraft/tests/it/winit_wayland_scroll.rs index 96686f7ecf..8087219b21 100644 --- a/apps/pdfcraft/tests/it/winit_wayland_scroll.rs +++ b/apps/pdfcraft/tests/it/winit_wayland_scroll.rs @@ -1,3 +1,5 @@ +//! Provenance-only: reads the retained `vendor/` copy, not the resolved dependency, so it is +//! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity. #![cfg(target_os = "linux")] use egui::{Event, Modifiers, MouseWheelUnit, RawInput, TouchPhase, vec2}; @@ -16,6 +18,7 @@ fn egui_phase(phase: event::TouchPhase) -> TouchPhase { } } +/// Run three wheel frames through egui and return each frame's zoom and smooth-scroll deltas. fn wheel_trace(source: AxisSource, discrete: bool) -> Vec<(f32, egui::Vec2)> { let ctx = egui::Context::default(); let mut previous = event::TouchPhase::Ended; @@ -27,7 +30,13 @@ fn wheel_trace(source: AxisSource, discrete: bool) -> Vec<(f32, egui::Vec2)> { let (unit, dy) = if discrete { (MouseWheelUnit::Line, 1.0) } else { (MouseWheelUnit::Point, 24.0) }; let input = RawInput { time: Some(frame as f64 / 60.0), - events: vec![Event::MouseWheel { unit, delta: vec2(0.0, dy), phase: egui_phase(phase), modifiers }], + events: vec![Event::MouseWheel { + unit, + delta: vec2(0.0, dy), + phase: egui_phase(phase), + modifiers, + source: egui::MouseWheelSource::Unknown, + }], ..Default::default() }; ctx.run_ui(input, |ui| seen.push(ui.input(|i| (i.zoom_delta(), i.smooth_scroll_delta)))).textures_delta.clear(); @@ -35,8 +44,10 @@ fn wheel_trace(source: AxisSource, discrete: bool) -> Vec<(f32, egui::Vec2)> { seen } +/// Regression test: releasing Ctrl after a pixel-wheel zoom makes the next wheel event scroll instead of +/// zooming. #[test] -fn releasing_ctrl_after_pixel_wheel_zoom_returns_to_scrolling() { +fn vendor_provenance_releasing_ctrl_after_pixel_wheel_zoom_returns_to_scrolling() { for source in [AxisSource::Wheel, AxisSource::WheelTilt, AxisSource::Continuous] { let seen = wheel_trace(source, false); assert!(seen[1].0 > 1.0, "Ctrl must zoom: {source:?}"); @@ -45,16 +56,18 @@ fn releasing_ctrl_after_pixel_wheel_zoom_returns_to_scrolling() { } } +/// Regression test: discrete wheels follow the modifiers of the current event, just like pixel wheels. #[test] -fn discrete_wheels_still_follow_current_modifiers() { +fn vendor_provenance_discrete_wheels_still_follow_current_modifiers() { let seen = wheel_trace(AxisSource::Wheel, true); assert!(seen[1].0 > 1.0); assert_eq!(seen[2].0, 1.0); assert!(seen[2].1.y > 0.0); } +/// Regression test: a finger gesture keeps its Ctrl zoom through momentum until the gesture ends. #[test] -fn finger_gestures_keep_their_modifier_until_the_gesture_ends() { +fn vendor_provenance_finger_gestures_keep_their_modifier_until_the_gesture_ends() { // A finger scroll's momentum may arrive after Ctrl is released. let seen = wheel_trace(AxisSource::Finger, false); assert!(seen[1].0 > 1.0); @@ -64,8 +77,9 @@ fn finger_gestures_keep_their_modifier_until_the_gesture_ends() { assert_eq!(scroll::phase(event::TouchPhase::Ended, Some(AxisSource::Finger), false, false), event::TouchPhase::Started); } +/// Regression test: wheel sources without a phase keep the existing start, move and end policy. #[test] -fn unknown_sources_keep_the_existing_phase_policy() { +fn vendor_provenance_unknown_sources_keep_the_existing_phase_policy() { assert_eq!(scroll::phase(event::TouchPhase::Ended, None, false, false), event::TouchPhase::Started); assert_eq!(scroll::phase(event::TouchPhase::Started, None, false, false), event::TouchPhase::Moved); assert_eq!(scroll::phase(event::TouchPhase::Ended, None, true, false), event::TouchPhase::Moved); diff --git a/apps/pdfcraft/tests/it/winit_wayland_text_input.rs b/apps/pdfcraft/tests/it/winit_wayland_text_input.rs index 691659e5a6..6469354cdc 100644 --- a/apps/pdfcraft/tests/it/winit_wayland_text_input.rs +++ b/apps/pdfcraft/tests/it/winit_wayland_text_input.rs @@ -1,7 +1,10 @@ -//! vendor/winit patch (2) (vendor/README.md). +//! Provenance-only: reads the retained `vendor/` copy, not the resolved dependency, so it is +//! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity. +//! Retained vendor provenance for winit patch (2) (vendor/README.md); `vendor_graph.rs` checks the resolved graph. +/// Regression test: the vendored winit keeps a Wayland seat's text input until the seat itself is removed. #[test] -fn winit_keeps_the_wayland_text_input_until_the_seat_goes() { +fn vendor_provenance_winit_keeps_the_wayland_text_input_until_the_seat_goes() { // winit 0.30.13 destroys a Wayland seat's text input whenever the seat loses any capability (a // touch screen or tablet unplugged, a remote-desktop session ending) and recreates it only when // a capability is added, so IME stops working in a running PdfCraft until it is restarted. diff --git a/apps/pdfcraft/tests/it/winit_windows_hidden_maximized.rs b/apps/pdfcraft/tests/it/winit_windows_hidden_maximized.rs index a1bdd11840..a8009591bf 100644 --- a/apps/pdfcraft/tests/it/winit_windows_hidden_maximized.rs +++ b/apps/pdfcraft/tests/it/winit_windows_hidden_maximized.rs @@ -1,7 +1,10 @@ -//! vendor/winit patch (4) (vendor/README.md). +//! Provenance-only: reads the retained `vendor/` copy, not the resolved dependency, so it is +//! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity. +//! Retained vendor provenance for winit patch (4) (vendor/README.md); `vendor_graph.rs` checks the resolved graph. +/// Regression test: the vendored winit maximizes a hidden Windows window only when it is shown. #[test] -fn winit_maximizes_a_hidden_windows_window_only_when_it_is_shown() { +fn vendor_provenance_winit_maximizes_a_hidden_windows_window_only_when_it_is_shown() { // eframe creates PdfCraft's window hidden, restores the saved maximized state and shows the // window once its first frame is drawn. winit 0.30.13 applies the maximized state with // `ShowWindow(SW_MAXIMIZE)`, which shows a hidden window, so a window closed maximized came back diff --git a/apps/pdfcraft/tests/it/winit_windows_ime_cursor.rs b/apps/pdfcraft/tests/it/winit_windows_ime_cursor.rs index aef55877d7..1df11d8a68 100644 --- a/apps/pdfcraft/tests/it/winit_windows_ime_cursor.rs +++ b/apps/pdfcraft/tests/it/winit_windows_ime_cursor.rs @@ -1,7 +1,10 @@ -//! vendor/winit patch (3) (vendor/README.md). +//! Provenance-only: reads the retained `vendor/` copy, not the resolved dependency, so it is +//! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity. +//! Retained vendor provenance for winit patch (3) (vendor/README.md); `vendor_graph.rs` checks the resolved graph. +/// Regression test: the vendored winit reads the Windows IME cursor only when the composition reports it. #[test] -fn winit_reads_the_windows_ime_cursor_only_when_it_is_reported() { +fn vendor_provenance_winit_reads_the_windows_ime_cursor_only_when_it_is_reported() { // winit 0.30.13 reads `GCS_CURSORPOS` for every composition without a target clause, whether // or not `WM_IME_COMPOSITION` says the IME set it. The Microsoft Korean IME never sets it, so // the composition's cursor read as 0, before the syllable being composed. vendor/winit puts it diff --git a/apps/pdfcraft/tests/it/winit_x11_dpi.rs b/apps/pdfcraft/tests/it/winit_x11_dpi.rs index fed0cf3e42..a9bde6a1ee 100644 --- a/apps/pdfcraft/tests/it/winit_x11_dpi.rs +++ b/apps/pdfcraft/tests/it/winit_x11_dpi.rs @@ -1,5 +1,9 @@ +//! Provenance-only: reads the retained `vendor/` copy, not the resolved dependency, so it is +//! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity. +/// Regression test: the vendored winit keeps the X11 server-DPI fallback and the desktop file does not +/// override scaling. #[test] -fn winit_carries_the_x11_server_dpi_fallback() { +fn vendor_provenance_winit_carries_the_x11_server_dpi_fallback() { // The behavior tests live beside the policy in winit (util::randr::tests). // This guards PdfCraft's vendored copy, which workspace tests do not test itself. let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); diff --git a/apps/pdfcraft/tests/it/winit_x11_ime_focus.rs b/apps/pdfcraft/tests/it/winit_x11_ime_focus.rs index f244d1387a..61e5c8ebfa 100644 --- a/apps/pdfcraft/tests/it/winit_x11_ime_focus.rs +++ b/apps/pdfcraft/tests/it/winit_x11_ime_focus.rs @@ -1,7 +1,10 @@ -//! vendor/winit patch (5) (vendor/README.md). +//! Provenance-only: reads the retained `vendor/` copy, not the resolved dependency, so it is +//! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity. +//! Retained vendor provenance for winit patch (5) (vendor/README.md); `vendor_graph.rs` checks the resolved graph. +/// Regression test: the vendored winit focuses the X11 input context it replaces. #[test] -fn winit_focuses_the_x11_input_context_it_replaces() { +fn vendor_provenance_winit_focuses_the_x11_input_context_it_replaces() { // egui allows IME when a text field gains focus and disallows it when the field loses focus. // On X11, winit 0.30.13 replaces the window's input context each time and leaves focusing the // new one to the next focus event, which never comes while PdfCraft's window keeps the focus: diff --git a/assets/demo/showcase.html b/assets/demo/showcase.html index b26f24565a..3b14ea3b47 100644 --- a/assets/demo/showcase.html +++ b/assets/demo/showcase.html @@ -14,27 +14,27 @@ (highlight, sticky note, stamp, ...), so annotation geometry follows the layout. --> \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..d7495df135 --- /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 super::*; + +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..8a06050898 --- /dev/null +++ b/crates/export/src/layout.rs @@ -0,0 +1,468 @@ +use super::*; + +/// 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 1303254d66..cf32ea7930 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -2,7 +2,9 @@ //! //! The engine reduces each page to [`Page`]: paragraphs (text, box, size, bold/italic) and //! images (encoded bytes and box), in reading order. The writers turn that into a flowing -//! document: headings are the paragraphs set larger than the body text. Word keeps pages with +//! document: headings are the paragraphs set larger than the body text, and a block that +//! continues another on its line (an italic word, a link) is a run of that block's paragraph. +//! Word keeps pages with //! rotated text, or text overlapped by artwork or watermark-sized text, as positioned, editable //! shapes to avoid reflowing overlays; other pages flow with images inline. @@ -16,1529 +18,24 @@ mod zip; pub use pptx::pptx; pub use zip::Zip; -/// A paragraph of a page. -#[derive(Clone, Debug, PartialEq)] -pub struct Block { - pub text: String, - /// [x0, y0, x1, y1] on the displayed page in points (y up). - pub rect: [f64; 4], - /// Font size in points. - pub size: f64, - pub bold: bool, - pub italic: bool, - /// Text fill colour as sRGB components in 0..=1 (black when unknown). - pub color: [f64; 3], - /// Counterclockwise baseline angle in user space (degrees). - pub rotation: f64, -} +mod as_docx; +mod as_html; +mod as_rtf; +mod layout; -/// 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> { - Para(&'a Block, u8), - /// Owned: tables are detected per page inside [`items`], so they cannot borrow. - Table(Table), - Img(&'a Image), - PageBreak, -} +pub use as_docx::docx; +pub(crate) use as_docx::hex; +#[cfg(test)] +use as_docx::{MAX_MEDIA, docx_table}; +pub(crate) use as_html::esc; +pub use as_html::html; +#[cfg(test)] +use as_html::{allowed, base64}; +pub use as_rtf::rtf; +pub use layout::{Block, Cell, Image, Page, Table}; +#[cfg(test)] +use layout::{LOOKUP_STEPS, MAX_COLS, body_size}; +pub(crate) use layout::{Run, paragraphs, tables}; #[cfg(test)] -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 { - let mut by_end: Vec<(f64, usize)> = blocks.iter().enumerate().map(|(i, b)| (b.rect[2], i)).collect(); - by_end.sort_by(|a, b| a.0.total_cmp(&b.0)); - blocks - .iter() - .enumerate() - .map(|(bi, b)| { - let k = by_end.partition_point(|(end, _)| *end < b.rect[0] - INLINE_GAP * b.size); - by_end - .get(k..) - .unwrap_or_default() - .iter() - .take_while(|(end, _)| *end <= b.rect[0] + 1.0) - .take(CONTINUATION_PROBE) - .filter_map(|(_, ai)| blocks.get(*ai).filter(|_| *ai != bi)) - .any(|a| a.rect[3].min(b.rect[3]) - a.rect[1].max(b.rect[1]) > 0.5 * (a.rect[3] - a.rect[1]).min(b.rect[3] - b.rect[1])) - }) - .collect() -} -/// A cell is short: taller blocks are body text (or a multi-column layout), not table cells. -fn is_cell_like(b: &Block) -> bool { - (b.rect[3] - b.rect[1]) <= b.size * 5.0 && !b.text.trim().is_empty() -} - -/// Detect tables in a page's blocks from their text layout: columns are left edges shared by -/// several short blocks, rows are blocks whose vertical spans overlap. Returns the tables and, -/// per block, whether a table consumed it (so `items` can drop it from the paragraph flow). -pub(crate) fn tables(blocks: &[Block]) -> (Vec
, Vec) { - let mut consumed = vec![false; blocks.len()]; - // Candidate columns: cluster the left edges of cell-like blocks. - let mut xs: Vec<(f64, usize)> = blocks.iter().enumerate().filter(|(_, b)| is_cell_like(b)).map(|(i, b)| (b.rect[0], i)).collect(); - xs.sort_by(|a, b| a.0.total_cmp(&b.0)); - let mut cols: Vec<(f64, Vec)> = Vec::new(); // (left edge, member block indices) - for (x, i) in xs { - match cols.last_mut() { - Some((e, m)) if (x - *e).abs() <= COL_TOL => { - *e = (*e * m.len() as f64 + x) / (m.len() + 1) as f64; - m.push(i); - } - _ => cols.push((x, vec![i])), - } - } - let cols: Vec<(f64, Vec)> = cols.into_iter().filter(|(_, m)| m.len() >= 2).collect(); - if cols.len() < 2 { - return (Vec::new(), consumed); - } - // Each block's column (an index into `cols`), so lookups stay linear on pages with many blocks. - let mut col_of: Vec> = vec![None; blocks.len()]; - for (ci, (_, members)) in cols.iter().enumerate() { - for &i in members { - if let Some(c) = col_of.get_mut(i) { - *c = Some(ci); - } - } - } - // The column whose edge is within tolerance of `x` (edges are in ascending order). - let column_at = |x: f64| -> Option { - let k = cols.partition_point(|(e, _)| *e < x - COL_TOL); - cols.get(k).filter(|(e, _)| (*e - x).abs() <= COL_TOL).map(|_| k) - }; - // Assign every cell-like block to its nearest column (within tolerance). - let mut rows: Vec> = Vec::new(); // (column index, block index), top to bottom - let mut order: Vec = (0..blocks.len()).collect(); - order.sort_by(|a, b| blocks[*b].rect[3].total_cmp(&blocks[*a].rect[3]).then(blocks[*a].rect[0].total_cmp(&blocks[*b].rect[0]))); - for &bi in &order { - let b = &blocks[bi]; - if !is_cell_like(b) { - continue; - } - let Some(ci) = column_at(b.rect[0]) else { continue }; - let joins = rows - .last() - .and_then(|row| row.first()) - .map(|(_, fi)| { - let rb = &blocks[*fi]; - let overlap = rb.rect[3].min(b.rect[3]) - rb.rect[1].max(b.rect[1]); - overlap > 0.5 * (rb.rect[3] - rb.rect[1]).min(b.rect[3] - b.rect[1]) - }) - .unwrap_or(false); - if joins { - if let Some(row) = rows.last_mut() { - row.push((ci, bi)); - } - } else { - rows.push(vec![(ci, bi)]); - } - } - // Runs of consecutive rows that have two or more distinct columns are tables. - let mut out = Vec::new(); - let flush = |run: &mut Vec>, out: &mut Vec
, consumed: &mut Vec| { - if run.len() < 2 { - run.clear(); - return; - } - let mut used: Vec = run.iter().flatten().map(|(_, i)| *i).collect(); - used.sort_unstable(); - used.dedup(); - let mut edges: Vec = used.iter().filter_map(|&i| col_of.get(i).copied().flatten()).filter_map(|ci| cols.get(ci)).map(|c| c.0).collect(); - edges.sort_by(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. - let mut parts: Vec<(f64, f64, Item)> = p - .blocks - .iter() - .enumerate() - .filter(|(bi, _)| !consumed.get(*bi).copied().unwrap_or(false)) - .map(|(_, b)| (b.rect[3], b.rect[0], Item::Para(b, level(b, body)))) - .collect(); - parts.extend(tables.into_iter().map(|t| (t.rect[3], t.rect[0], Item::Table(t)))); - parts.extend(p.images.iter().map(|im| (im.rect[3], im.rect[0], Item::Img(im)))); - parts.sort_by(|a, b| b.0.total_cmp(&a.0).then(a.1.total_cmp(&b.1))); - out.extend(parts.into_iter().map(|x| x.2)); - } - out -} - -/// Whether a character may appear in the output. XML 1.0 forbids the C0 controls other than tab, -/// line feed and carriage return, and U+FFFE/U+FFFF. Text extracted from PDFs often holds them -/// (fonts without a usable `/ToUnicode`), and Word refuses a whole document over one (#72). -fn allowed(c: char) -> bool { - matches!(c, '\t' | '\n' | '\r') || !(c.is_ascii_control() || c == '\u{FFFE}' || c == '\u{FFFF}') -} - -pub(crate) fn esc(s: &str) -> String { - let mut o = String::with_capacity(s.len()); - for c in s.chars().filter(|c| allowed(*c)) { - match c { - '&' => o.push_str("&"), - '<' => o.push_str("<"), - '>' => o.push_str(">"), - '"' => o.push_str("""), - c => o.push(c), - } - } - o -} - -fn base64(data: &[u8]) -> String { - const A: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; - let mut s = String::with_capacity(data.len().div_ceil(3) * 4); - for c in data.chunks(3) { - let n = (c[0] as u32) << 16 | (*c.get(1).unwrap_or(&0) as u32) << 8 | *c.get(2).unwrap_or(&0) as u32; - s.push(A[(n >> 18) as usize & 63] as char); - s.push(A[(n >> 12) as usize & 63] as char); - s.push(if c.len() > 1 { A[(n >> 6) as usize & 63] as char } else { '=' }); - s.push(if c.len() > 2 { A[n as usize & 63] as char } else { '=' }); - } - s -} - -/// One HTML file: headings and paragraphs, tables as real `
` elements, images inline, a -/// rule between pages. -pub fn html(pages: &[Page], title: &str) -> String { - let mut s = format!( - "\n\n\n\n{}\n\n\n\n", - esc(title) - ); - for it in items(pages) { - match it { - Item::Para(b, lvl) => { - let mut t = esc(&b.text); - if b.italic { - t = format!("{t}"); - } - if b.bold && lvl == 0 { - t = format!("{t}"); - } - let style = hex(b.color).map_or(String::new(), |h| format!(" style=\"color:#{h}\"")); - match lvl { - 0 => s.push_str(&format!("{t}

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

\"\"

\n", base64(&im.bytes))); - } - Item::PageBreak => s.push_str("
\n"), - } - } - s.push_str("\n\n"); - s -} - -/// A colour's 8-bit sRGB components (NaN and out-of-range values clamp; NaN reads as 0). -pub(crate) fn rgb8(c: [f64; 3]) -> [u8; 3] { - // The value is clamped to 0..=255 before the cast, so it cannot truncate. - c.map(|v| if v.is_finite() { (v.clamp(0.0, 1.0) * 255.0).round() as u8 } else { 0 }) -} - -/// The colour as `RRGGBB`, or `None` for black (the default every format already uses). -pub(crate) fn hex(c: [f64; 3]) -> Option { - let [r, g, b] = rgb8(c); - (r | g | b != 0).then(|| format!("{r:02X}{g:02X}{b:02X}")) -} - -/// A formatted text run (shared by paragraphs and table cells). -fn run_xml(text: &str, size: f64, bold: bool, italic: bool, color: [f64; 3]) -> String { - let mut rpr = String::new(); - if bold { - rpr.push_str(""); - } - if italic { - rpr.push_str(""); - } - if let Some(h) = hex(color) { - rpr.push_str(&format!("")); - } - let half_points = if size.is_finite() { (size * 2.0).round().clamp(2.0, 3276.0) as i64 } else { 24 }; - rpr.push_str(&format!("")); - format!("{rpr}{}", esc(text)) -} - -/// One Word table: explicit absent borders (alignment alone cannot prove PDF rules), a grid sized -/// from the detected column edges, `gridSpan` for spanning cells, empty cells for holes. -fn docx_table(t: &Table) -> String { - let mut edges = t.cols.clone(); - edges.push(t.rect[2]); - let widths: Vec = edges.windows(2).map(|w| ((w[1] - w[0]).max(20.0) * 20.0).round().clamp(60.0, 31680.0) as i64).collect(); - let mut s = String::from(""); - for edge in ["top", "left", "bottom", "right", "insideH", "insideV"] { - s.push_str(&format!("")); - } - s.push_str(""); - for w in &widths { - s.push_str(&format!("")); - } - s.push_str(""); - for row in &t.rows { - s.push_str(""); - 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 run = |b: &Block| run_xml(&b.text, b.size, b.bold, b.italic, b.color); - let mut shape_id = 0usize; - for (page_index, page) in pages.iter().enumerate() { - let (pw, _) = word_page_size(page); - let text_w = (pw - 2 * (pw / 8).min(1440)) as f64 / 20.0; - if positioned(page) { - body.push_str(&positioned_page(page, &mut media, &mut shape_id)); - after_table = false; - } else { - for it in items(std::slice::from_ref(page)) { - match &it { - Item::Para(b, lvl) => { - let style = match lvl { - 1 => "", - 2 => "", - _ => "", - }; - body.push_str(&format!("{style}{}", run(b))); - } - Item::Table(t) => { - if after_table { - body.push_str(""); - } - body.push_str(&docx_table(t)); - } - Item::Img(im) => { - if room_for(&media, im) { - let n = media.len() + 1; - let name = format!("image{n}.{}", im.ext); - // Size on the page, at most the text width. - let (w, h) = ((im.rect[2] - im.rect[0]).max(1.0), (im.rect[3] - im.rect[1]).max(1.0)); - let k = (text_w / w).min(1.0); - let (cx, cy) = (emu(w * k), emu(h * k)); - body.push_str(&format!("")); - body.push_str(&picture_xml(n, &name, (cx, cy), None)); - body.push_str(""); - media.push((name, im)); - } - } - Item::PageBreak => body.push_str(""), - } - after_table = matches!(it, Item::Table(_)); - } - } - // Section breaks carry this source page's size and create exactly one next page. - // The last section must be a direct child of the body (OOXML 17.6.17). - if page_index + 1 < pages.len() { - body.push_str(&format!( - "{}", - section(page) - )); - } else { - if after_table || page.blocks.is_empty() && page.images.is_empty() { - body.push_str(""); - } - body.push_str(§ion(page)); - } - after_table = false; - } - // 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(b, _) => note(b.color), - Item::Table(t) => t.rows.iter().flatten().for_each(|c| note(c.color)), - Item::Img(_) | Item::PageBreak => {} - } - } - // `\cfN` for a colour (nothing for black, which keeps the default). - let cf = |c: [f64; 3]| { - let c = rgb8(c); - 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(b, _) => { - let mut fmt = format!("\\fs{}", (b.size * 2.0).round() as i64); - if b.bold { - fmt.push_str("\\b"); - } - if b.italic { - fmt.push_str("\\i"); - } - fmt.push_str(&cf(b.color)); - s.push_str(&format!("{{\\pard{fmt} {}\\par}}\n", rtf_text(&b.text))); - } - Item::Table(t) => { - let mut edges = t.cols.clone(); - edges.push(t.rect[2]); - let cellx: Vec = edges.iter().skip(1).map(|e| (e * 20.0).round() as i64).collect(); - for row in &t.rows { - s.push_str("\\trowd\\trgaph108"); - // The row's cell boundaries come first: each cell ends at the right edge of - // the last grid column it spans. - let mut column = 0; - for c in row { - column += c.span.max(1); - if let Some(x) = cellx.get(column.min(cellx.len()).saturating_sub(1)) { - s.push_str(&format!("\\cellx{x}")); - } - } - for c in row { - let mut fmt = format!("\\intbl\\fs{}", (c.size * 2.0).round() as i64); - if c.bold { - fmt.push_str("\\b"); - } - if c.italic { - fmt.push_str("\\i"); - } - fmt.push_str(&cf(c.color)); - s.push_str(&format!("{{{fmt} {}}}\\cell", rtf_text(&c.text))); - } - s.push_str("\\row\n"); - } - } - Item::Img(_) => {} - Item::PageBreak => s.push_str("\\page\n"), - } - } - s.push('}'); - s -} - -#[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, - }) - .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, - }; - 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, - }); - 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, - }); - let xml = part(&docx(&[p.clone()], "Receipt\u{1}"), "word/document.xml"); - assert!(xml.contains(">Total 4.50\tGBP"), "{xml}"); - assert!(!xml.chars().any(|c| !allowed(c))); - assert!(xml.contains(""), "clamped to 22 in: {xml}"); - assert!(xml.contains(""), "a non-finite size falls back to 12 pt"); - assert!(rtf(&[p]).contains("Total 4.50\tGBP")); - // A narrow page keeps room for text: margins shrink. - let narrow = Page { width: 100.0, height: 200.0, ..page() }; - let xml = part(&docx(&[narrow], "Narrow"), "word/document.xml"); - assert!(xml.contains(""), "{xml}"); - assert!(xml.contains(""), "out-of-range values clamp: {xml}"); - // Black text keeps Word's default colour. - assert_eq!(xml.matches("Annual Report"), "{h}"); - assert!(h.contains("

"), "{h}"); - let r = rtf(&[p.clone()]); - assert!(r.contains("{\\colortbl;\\red13\\green59\\blue46;\\red0\\green255\\blue0;}"), "{r}"); - assert!(r.contains("\\cf1 Annual Report"), "{r}"); - let ppt = pptx(&[p], "Colour"); - let pxml = part(&ppt, "ppt/slides/slide1.xml"); - assert!(pxml.contains(""), "{pxml}"); - assert!(pxml.contains(""), "out-of-range values clamp: {pxml}"); - // A table cell keeps its colour too. - let mut t = table_page(); - t.blocks[0].color = [1.0, 0.0, 0.0]; - let xml = part(&docx(&[t.clone()], "Grid"), "word/document.xml"); - assert!(xml.contains(""), "{xml}"); - assert!(html(&[t], "Grid").contains("style=\"color:#FF0000\"")); - } - - #[test] - fn rtf_escapes_and_sizes() { - let mut p = page(); - p.blocks.push(Block { - text: "Café {x}".into(), - rect: [72.0, 100.0, 200.0, 110.0], - size: 10.0, - bold: false, - italic: true, - color: [0.0; 3], - rotation: 0.0, - }); - let r = rtf(&[p]); - assert!(r.starts_with("{\\rtf1") && r.ends_with('}')); - assert!(r.contains("\\fs48\\b Annual Report")); - assert!(r.contains("\\fs20\\i Caf\\u233? \\{x\\}"), "{r}"); - } - - /// A 3-column table with a spanning header row and a hole in the last row. - fn table_page() -> Page { - let cell = |t: &str, x: f64, y: f64, w: f64| Block { - text: t.into(), - rect: [x, y, x + w, y + 12.0], - size: 11.0, - bold: false, - italic: false, - color: [0.0; 3], - rotation: 0.0, - }; - Page { - width: 612.0, - height: 792.0, - blocks: vec![ - cell("Meter", 72.0, 620.0, 120.0), - cell("Unit", 232.0, 620.0, 80.0), - cell("Reading", 392.0, 620.0, 100.0), - cell("A1", 72.0, 590.0, 120.0), - cell("kWh", 232.0, 590.0, 80.0), - cell("37414.00", 392.0, 590.0, 100.0), - cell("A2", 72.0, 560.0, 120.0), - cell("kW", 232.0, 560.0, 80.0), - // A2's row is missing its third cell (an empty cell in the grid). - cell("Totals", 72.0, 530.0, 240.0), - Block { - text: "Notes follow the table and are long enough to be the body text size of the page here.".into(), - rect: [72.0, 460.0, 500.0, 471.0], - size: 11.0, - bold: false, - italic: false, - color: [0.0; 3], - rotation: 0.0, - }, - ], - images: Vec::new(), - 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 } - }) - .collect(); - let started = std::time::Instant::now(); - let (tables, _) = tables(&blocks); - assert!(tables.iter().all(|t| t.cols.len() <= MAX_COLS), "{} columns", tables.iter().map(|t| t.cols.len()).max().unwrap_or(0)); - assert!(started.elapsed() < std::time::Duration::from_secs(5), "took {:?}", started.elapsed()); - } - - #[test] - fn word_opens_tables_up_to_its_column_limit() { - // Rows of short numbers on a regular grid, n columns wide. - let grid = |n: usize| Page { - width: 1500.0, - height: 792.0, - blocks: (0..3) - .flat_map(|row| { - (0..n).map(move |col| { - let (x, y) = (20.0 + col as f64 * 22.0, 700.0 - row as f64 * 14.0); - Block { - text: (col + 1).to_string(), - rect: [x, y, x + 8.0, y + 8.0], - size: 8.0, - bold: false, - italic: false, - color: [0.0; 3], - rotation: 0.0, - } - }) - }) - .collect(), - images: Vec::new(), - 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 } - } - - /// #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}"); - 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, - }; - 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(" { - Para(&'a Block), + /// A paragraph's runs (at least one). + Para(Vec>), Table(Table), Img(&'a Image), } @@ -136,11 +137,11 @@ pub fn pptx(pages: &[Page], title: &str) -> Vec { .map(|(i, _)| i); let mut items: Vec<(f64, f64, SlideBodyItem)> = Vec::new(); - for (bi, b) in p.blocks.iter().enumerate() { - if Some(bi) == title_idx || consumed.get(bi).copied().unwrap_or(false) { - continue; - } - items.push((b.rect[3], b.rect[0], SlideBodyItem::Para(b))); + // The title is a shape of its own, so it joins no paragraph. + let skip: Vec = (0..p.blocks.len()).map(|bi| Some(bi) == title_idx || consumed.get(bi).copied().unwrap_or(false)).collect(); + for runs in crate::paragraphs(&p.blocks, &skip) { + let Some(first) = runs.first().map(|r| r.block) else { continue }; + items.push((first.rect[3], first.rect[0], SlideBodyItem::Para(runs))); } for t in tables { items.push((t.rect[3], t.rect[0], SlideBodyItem::Table(t))); @@ -173,11 +174,16 @@ pub fn pptx(pages: &[Page], title: &str) -> Vec { SlideBodyItem::Para(_) => { let mut paras_xml = String::new(); let mut est_h_pt = 0.0; - while let Some((_, _, SlideBodyItem::Para(b))) = items.get(i) { - let run = run_drawingml(&b.text, b.size, b.bold, b.italic, b.color); - paras_xml.push_str(&format!("{run}")); - let lines = (b.text.len() as f64 / 60.0).ceil().max(1.0); - est_h_pt += (b.size.max(10.0) * 1.3) * lines + 6.0; + while let Some((_, _, SlideBodyItem::Para(runs))) = items.get(i) { + paras_xml.push_str(""); + for r in runs { + paras_xml.push_str(&run_drawingml(&r.text, r.block.size, r.block.bold, r.block.italic, r.block.color)); + } + paras_xml.push_str(""); + let len = runs.iter().map(|r| r.text.len()).fold(0usize, usize::saturating_add); + let size = runs.iter().map(|r| r.block.size).fold(10.0, f64::max); + let lines = (len as f64 / 60.0).ceil().max(1.0); + est_h_pt += (size * 1.3) * lines + 6.0; i += 1; } let box_h = height_emu(est_h_pt); @@ -352,390 +358,4 @@ xmlns:a=\"http://schemas.openxmlformats.org/drawingml/2006/main\" type=\"blank\" } #[cfg(test)] -mod tests { - use super::*; - use crate::tests::part; - - fn sample_page() -> Page { - Page { - width: 720.0, - height: 540.0, - blocks: vec![ - Block { - text: "Q3 Business Update".into(), - rect: [72.0, 460.0, 500.0, 500.0], - size: 28.0, - bold: true, - italic: false, - color: [0.1, 0.2, 0.3], - rotation: 0.0, - }, - Block { - text: "Revenue grew 25% year over year across all regions.".into(), - rect: [72.0, 380.0, 500.0, 410.0], - size: 14.0, - bold: false, - italic: false, - color: [0.0; 3], - rotation: 0.0, - }, - Block { - text: "Operational expenses remained flat.".into(), - rect: [72.0, 340.0, 500.0, 370.0], - size: 14.0, - bold: false, - italic: true, - color: [0.0; 3], - rotation: 0.0, - }, - ], - images: vec![Image { - ext: "png", - bytes: b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR\0\0\0\x02\0\0\0\x03".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, - }; - 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, - }; - let p = Page { - width: 720.0, - height: 540.0, - blocks: vec![ - // Heading outside the table (short width, does not span table columns) - Block { - text: "Monthly Summary".into(), - rect: [50.0, 480.0, 150.0, 500.0], - size: 20.0, - bold: true, - italic: false, - color: [0.0; 3], - rotation: 0.0, - }, - // Table where one cell has a very large number (e.g. 36 pt callout in the table) - cell("Metric", 72.0, 400.0, 100.0, 12.0), - cell("Value", 200.0, 400.0, 100.0, 12.0), - cell("Growth", 72.0, 360.0, 100.0, 12.0), - cell("+99%", 200.0, 360.0, 100.0, 36.0), - cell("Target", 72.0, 320.0, 100.0, 12.0), - cell("100%", 200.0, 320.0, 100.0, 12.0), - ], - images: Vec::new(), - 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/build.rs b/crates/fonts/build.rs index bc87a16634..980c195709 100644 --- a/crates/fonts/build.rs +++ b/crates/fonts/build.rs @@ -2,7 +2,9 @@ //! The recipe is craft-fonts' `docs/integration.md`; unset, `CRAFT_FONTS` is empty. It only reads //! the local checkout (no network). On wasm32 it embeds only the UI faces (BIZ UDPGothic Regular //! and any `Arab`, `Telu`, `Hebr`, `Thai` or `Deva` face), to keep the web build within hosting -//! limits (Cloudflare Pages: 25 MiB per file). +//! limits (Cloudflare Pages: 25 MiB per file). Native builds also read the optional +//! `fonts/simplified-chinese-manifest.txt` (LXGW WenKai Lite); it is never embedded on wasm. +//! `CRAFT_FONTS_REQUIRED` makes both the checkout and that native manifest required. use std::fmt::Write as _; use std::path::PathBuf; @@ -10,18 +12,21 @@ use std::path::PathBuf; #[path = "src/cjk_scripts.rs"] mod cjk_scripts; +/// Build-script entry point: embed craft-fonts from `CRAFT_FONTS_DIR`, make a missing or invalid input fatal +/// when `CRAFT_FONTS_REQUIRED` is set, and write `craft_fonts.rs` to `OUT_DIR`. fn main() { println!("cargo::rerun-if-env-changed=CRAFT_FONTS_DIR"); println!("cargo::rerun-if-env-changed=CRAFT_FONTS_REQUIRED"); let mut src = String::from("pub static CRAFT_FONTS: &[CraftFont] = &[\n"); + let required = std::env::var_os("CRAFT_FONTS_REQUIRED").is_some(); if let Some(dir) = std::env::var_os("CRAFT_FONTS_DIR").map(PathBuf::from) { - match craft_fonts(&dir) { + match craft_fonts(&dir, required) { Ok(entries) => src.push_str(&entries), - Err(e) if std::env::var_os("CRAFT_FONTS_REQUIRED").is_some() => { - println!("cargo::error=CRAFT_FONTS_DIR={}: {e}", dir.display()); - } + Err(e) if required => println!("cargo::error=CRAFT_FONTS_DIR={}: {e}", dir.display()), Err(e) => println!("cargo::warning=building without craft-fonts: CRAFT_FONTS_DIR={}: {e}", dir.display()), } + } else if required { + println!("cargo::error=CRAFT_FONTS_REQUIRED is set but CRAFT_FONTS_DIR is missing"); } src.push_str("];\n"); let out = PathBuf::from(std::env::var_os("OUT_DIR").unwrap_or_default()).join("craft_fonts.rs"); @@ -30,26 +35,66 @@ fn main() { } } -/// One `CraftFont { .. }` initialiser per manifest line. -fn craft_fonts(dir: &std::path::Path) -> Result { - let manifest = dir.join("fonts/manifest.txt"); - println!("cargo::rerun-if-changed={}", manifest.display()); - let text = std::fs::read_to_string(&manifest).map_err(|e| format!("{}: {e}", manifest.display()))?; +/// The main manifest, plus the native-only Simplified Chinese manifest. The latter is optional +/// for ordinary builds and required when `CRAFT_FONTS_REQUIRED` is set, so a required build can +/// never pass the Chinese UI tests only by skipping them. +fn craft_fonts(dir: &std::path::Path, required: bool) -> Result { let wasm = std::env::var("CARGO_CFG_TARGET_ARCH").is_ok_and(|a| a == "wasm32"); + let mut out = manifest(dir, &dir.join("fonts/manifest.txt"), wasm, false)?; + if !wasm { + let chinese = dir.join("fonts/simplified-chinese-manifest.txt"); + println!("cargo::rerun-if-changed={}", chinese.display()); + match chinese.try_exists() { + Ok(false) if required => { + return Err(format!("{}: CRAFT_FONTS_REQUIRED is set but the simplified-Chinese font manifest is missing", chinese.display())); + } + Ok(false) => {} + Ok(true) => match manifest(dir, &chinese, false, required) { + Ok(entries) => out.push_str(&entries), + // A broken optional addition must never discard the main Japanese/symbol/RTL faces. + Err(e) if required => return Err(e), + Err(e) => println!("cargo::warning=skipping optional Chinese fonts: {e}"), + }, + Err(e) if required => return Err(format!("{}: {e}", chinese.display())), + Err(e) => println!("cargo::warning=skipping optional Chinese fonts: {}: {e}", chinese.display()), + } + } + Ok(out) +} + +/// One `CraftFont { .. }` initialiser per manifest line. +/// +/// `strict_cap` is set for the required native Simplified Chinese manifest: a face that does not +/// fit its cap must fail the build rather than be silently left out, so a required build cannot +/// pass without the Chinese face it promises. +fn manifest(dir: &std::path::Path, manifest: &std::path::Path, wasm: bool, strict_cap: bool) -> Result { + println!("cargo::rerun-if-changed={}", manifest.display()); + let text = std::fs::read_to_string(manifest).map_err(|e| format!("{}: {e}", manifest.display()))?; let mut out = String::new(); + // A required native Chinese manifest must add the Hans face itself, not merely some other + // CJK face that happens to fit: otherwise CRAFT_FONTS_REQUIRED would pass while the promised + // Simplified Chinese fallback is still absent. + let mut has_hans = false; for line in text.lines().map(str::trim).filter(|l| !l.is_empty() && !l.starts_with('#')) { let f: Vec<&str> = line.split(" | ").map(str::trim).collect(); let [family, style, file, scripts, ..] = f.as_slice() else { return Err(format!("malformed manifest line: {line}")); }; - // Whole CJK faces aren't embedded for size; interface CJK comes from cut faces once the app - // moves to craft-fonts-build (storytold/craft-libs#213). + // Keep the upstream general CJK size cap. The separately pinned native Chinese UI + // face has its own bound; web builds retain the smaller general limit. A required + // Chinese face over its cap fails the build instead of being silently omitted. let cjk = scripts.split(',').any(cjk_scripts::is_cjk_script); - if cjk - && !cjk_scripts::WHOLE_CJK_FAMILIES.contains(family) - && std::fs::metadata(dir.join(file)).is_ok_and(|m| m.len() > cjk_scripts::MAX_OTHER_CJK_BYTES) - { - continue; + if cjk && let Ok(metadata) = std::fs::metadata(dir.join(file)) { + match cjk_scripts::cjk_size_decision(family, metadata.len(), wasm, strict_cap) { + cjk_scripts::CjkSizeDecision::Embed => {} + cjk_scripts::CjkSizeDecision::Skip => continue, + cjk_scripts::CjkSizeDecision::Reject => { + let cap = cjk_scripts::cjk_cap_bytes(family, wasm); + return Err(format!( + "{file}: the {family} face is larger than the {cap}-byte native Chinese UI cap; CRAFT_FONTS_REQUIRED cannot embed it" + )); + } + } } // Arabic interface faces are small, so the web build keeps them too. let arabic = scripts.split(',').any(|s| s.trim() == "Arab"); @@ -68,6 +113,7 @@ fn craft_fonts(dir: &std::path::Path) -> Result { if wasm && !web_face { continue; } + has_hans |= cjk_scripts::has_script_tag(scripts, "Hans"); let path = dir.join(file).canonicalize().map_err(|e| format!("{file}: {e}"))?; println!("cargo::rerun-if-changed={}", path.display()); let scripts: Vec = scripts.split(',').map(|s| format!("{:?}", s.trim())).collect(); @@ -78,5 +124,36 @@ fn craft_fonts(dir: &std::path::Path) -> Result { path.display().to_string(), ); } + if strict_cap && !has_hans { + return Err(format!( + "{}: CRAFT_FONTS_REQUIRED is set but the manifest emits no Hans face (a Simplified Chinese face is required)", + manifest.display() + )); + } Ok(out) } + +#[cfg(test)] +mod manifest_tests { + use super::*; + + /// Regression test: a required native-font manifest must declare at least one Hans face, while optional + /// input accepts another script and a missing asset still fails. + #[test] + fn required_native_manifest_rejects_non_hans_faces_after_parsing() { + let root = std::env::temp_dir().join(format!("pdfcraft-required-hans-{}", std::process::id())); + std::fs::create_dir_all(&root).unwrap(); + std::fs::write(root.join("face.ttf"), b"synthetic manifest fixture").unwrap(); + let path = root.join("manifest.txt"); + for scripts in ["Hant,Latn", "Hans-HK,Latn", "Latn"] { + std::fs::write(&path, format!("Fixture | Regular | face.ttf | {scripts}\n")).unwrap(); + assert!(manifest(&root, &path, false, true).unwrap_err().contains("no Hans face"), "{scripts}"); + assert!(manifest(&root, &path, false, false).unwrap().contains("CraftFont {"), "optional input stays usable"); + } + std::fs::write(&path, "Fixture | Regular | face.ttf | Latn, Hans\n").unwrap(); + assert!(manifest(&root, &path, false, true).unwrap().contains("CraftFont {")); + std::fs::remove_file(root.join("face.ttf")).unwrap(); + assert!(manifest(&root, &path, false, true).is_err(), "a missing Hans asset cannot satisfy required input"); + std::fs::remove_dir_all(root).unwrap(); + } +} diff --git a/crates/fonts/src/cjk_scripts.rs b/crates/fonts/src/cjk_scripts.rs index 16a863c683..1058c6e61b 100644 --- a/crates/fonts/src/cjk_scripts.rs +++ b/crates/fonts/src/cjk_scripts.rs @@ -8,6 +8,11 @@ pub const WHOLE_CJK_FAMILIES: [&str; 3] = ["Shippori Mincho", "BIZ UDPGothic", " /// Any other CJK face over this size is left out. pub const MAX_OTHER_CJK_BYTES: u64 = 4 << 20; +/// Reviewed native-only UI fallback from the separate Chinese manifest. +/// It is never included in the browser payload. Other CJK families keep the 4 MiB cap. +pub const NATIVE_UI_CJK_FAMILIES: [&str; 1] = ["LXGW WenKai Lite"]; +pub const MAX_NATIVE_UI_CJK_BYTES: u64 = 16 << 20; + /// ISO 15924 codes of the Han, Japanese and Korean scripts, Hangul on its own included. const CJK_SCRIPTS: [&str; 6] = ["Hani", "Hans", "Hant", "Jpan", "Kore", "Hang"]; @@ -17,3 +22,76 @@ pub fn is_cjk_script(tag: &str) -> bool { let code = tag.trim().split('-').next().unwrap_or_default(); CJK_SCRIPTS.contains(&code) } + +/// Whether a manifest's comma-separated script list contains `script` exactly, ignoring spaces. +/// The interface and document lookups use exact tags (`CraftFont::covers`), so a regional tag +/// such as `Hans-HK` is not a `Hans` entry. +pub fn has_script_tag(scripts: &str, script: &str) -> bool { + scripts.split(',').any(|tag| tag.trim() == script) +} + +/// What `build.rs` does with one CJK face from a manifest. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CjkSizeDecision { + /// The face fits its cap (or is a Japanese family embedded whole). + Embed, + /// An optional face over its cap: leave it out and keep the rest. + Skip, + /// A required face over its cap: fail the build rather than silently omit it. + Reject, +} + +/// The size cap that applies to `family` in this build. +pub fn cjk_cap_bytes(family: &str, wasm: bool) -> u64 { + if !wasm && NATIVE_UI_CJK_FAMILIES.contains(&family) { MAX_NATIVE_UI_CJK_BYTES } else { MAX_OTHER_CJK_BYTES } +} + +/// Decide whether a CJK face is embedded, skipped, or rejected. +/// +/// The Japanese document/UI families are embedded whole. Every other CJK family uses its cap; +/// `required` marks the native Simplified Chinese manifest of a `CRAFT_FONTS_REQUIRED` build, +/// where an over-cap face must fail instead of quietly disappearing. +pub fn cjk_size_decision(family: &str, bytes: u64, wasm: bool, required: bool) -> CjkSizeDecision { + if WHOLE_CJK_FAMILIES.contains(&family) { + return CjkSizeDecision::Embed; + } + if bytes <= cjk_cap_bytes(family, wasm) { + CjkSizeDecision::Embed + } else if required { + CjkSizeDecision::Reject + } else { + CjkSizeDecision::Skip + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Check that a required Chinese manifest needs an exact `Hans` script tag and does not accept + /// Traditional or region-suffixed tags. + #[test] + fn a_required_chinese_manifest_needs_an_exact_hans_entry() { + assert!(has_script_tag("Hans,Latn", "Hans")); + assert!(has_script_tag(" Latn , Hans ", "Hans")); + assert!(!has_script_tag("Hant,Latn", "Hans"), "Traditional Chinese is not the required Simplified face"); + assert!(!has_script_tag("Hans-HK,Latn", "Hans"), "the UI looks for the exact Hans tag"); + assert!(!has_script_tag("Hani,Jpan,Kore", "Hans")); + } + + /// Check that a native Chinese face over its cap is rejected when required, skipped when optional, and + /// uses the web cap on the web. + #[test] + fn a_required_native_chinese_face_over_its_cap_is_rejected_not_skipped() { + let at_cap = MAX_NATIVE_UI_CJK_BYTES; + assert_eq!(cjk_size_decision("LXGW WenKai Lite", at_cap, false, true), CjkSizeDecision::Embed); + assert_eq!(cjk_size_decision("LXGW WenKai Lite", at_cap + 1, false, true), CjkSizeDecision::Reject); + assert_eq!(cjk_size_decision("LXGW WenKai Lite", at_cap + 1, false, false), CjkSizeDecision::Skip); + // The web build keeps the smaller general cap even for the native Chinese family. + assert_eq!(cjk_size_decision("LXGW WenKai Lite", MAX_OTHER_CJK_BYTES + 1, true, true), CjkSizeDecision::Reject); + // Japanese document/UI families are embedded whole. + assert_eq!(cjk_size_decision("BIZ UDPGothic", u64::MAX, false, true), CjkSizeDecision::Embed); + assert_eq!(cjk_cap_bytes("LXGW WenKai Lite", false), MAX_NATIVE_UI_CJK_BYTES); + assert_eq!(cjk_cap_bytes("LXGW WenKai Lite", true), MAX_OTHER_CJK_BYTES); + } +} diff --git a/crates/fonts/src/craft.rs b/crates/fonts/src/craft.rs index e778a42ad7..dff96762aa 100644 --- a/crates/fonts/src/craft.rs +++ b/crates/fonts/src/craft.rs @@ -315,12 +315,13 @@ mod tests { } /// `build.rs` embeds the Japanese CJK faces whole and any other CJK face only up to - /// `MAX_OTHER_CJK_BYTES`, so a checkout with a large Chinese or Korean face (craft-fonts has the + /// `MAX_OTHER_CJK_BYTES` (the explicitly pinned native-only LXGW UI fallback has a separate + /// 16 MiB cap), so a checkout with a large Chinese or Korean face (craft-fonts has the /// 16.4 MB Noto Sans CJK SC) leaves it out. The four Japanese faces of the pinned craft-fonts /// are 24.1 MB. #[test] fn cjk_faces_follow_the_size_rule() { - use crate::cjk_scripts::{MAX_OTHER_CJK_BYTES, WHOLE_CJK_FAMILIES, is_cjk_script}; + use crate::cjk_scripts::{MAX_NATIVE_UI_CJK_BYTES, MAX_OTHER_CJK_BYTES, NATIVE_UI_CJK_FAMILIES, WHOLE_CJK_FAMILIES, is_cjk_script}; // A tag can carry a region after the code: craft-fonts tags Noto Sans HK UI `Hant-HK`. assert!(["Hant-HK", "Hans", "Jpan", "Hang"].iter().all(|s| is_cjk_script(s))); assert!(!["Latn", "Arab", "Hebr"].iter().any(|s| is_cjk_script(s))); @@ -328,7 +329,10 @@ mod tests { let (whole, other): (Vec<_>, Vec<_>) = cjk.partition(|f| WHOLE_CJK_FAMILIES.contains(&f.family)); for face in &other { let len = face.bytes.len(); - assert!(u64::try_from(len).is_ok_and(|n| n <= MAX_OTHER_CJK_BYTES), "{} is embedded at {len} bytes", face.name()); + let native_ui = NATIVE_UI_CJK_FAMILIES.contains(&face.family); + assert!(!cfg!(target_arch = "wasm32") || !native_ui, "native UI face in web payload"); + let cap = if native_ui { MAX_NATIVE_UI_CJK_BYTES } else { MAX_OTHER_CJK_BYTES }; + assert!(u64::try_from(len).is_ok_and(|n| n <= cap), "{} is embedded at {len} bytes", face.name()); } let bytes: usize = whole.iter().map(|f| f.bytes.len()).sum(); assert!(bytes < 25_000_000, "{bytes} bytes of Japanese faces"); 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/fonts/tests/build_input.rs b/crates/fonts/tests/build_input.rs new file mode 100644 index 0000000000..832cc2c16c --- /dev/null +++ b/crates/fonts/tests/build_input.rs @@ -0,0 +1,4 @@ +//! Required manifest checks execute the same parser as the production build script. +#[allow(dead_code)] +#[path = "../build.rs"] +mod build_script; diff --git a/crates/forms/README.md b/crates/forms/README.md index 4aec223ee9..93b65bd14e 100644 --- a/crates/forms/README.md +++ b/crates/forms/README.md @@ -14,6 +14,8 @@ set_value(&mut doc, "name", &FieldValue::Text("Ada".into()))?; set_value(&mut doc, "agree", &FieldValue::Check(true))?; set_value(&mut doc, "size", &FieldValue::Radio(Some("L".into())))?; set_value(&mut doc, "toppings", &FieldValue::Choice(vec!["Ham".into()]))?; +// Many values: as set_value one by one, but reading the form once and recalculating once. +let results: Vec> = set_values(&mut doc, &[("name".into(), FieldValue::Text("Ada".into()))])?; reset(&mut doc, None)?; // Clear form (back to /DV) // Prepare a form @@ -58,7 +60,9 @@ let cells: Vec = grid.cells_for(names)?; // "Table1_1", "Table drawing while the objects it was drawn from are the same (`FieldAppearanceCache`), so typing in one field redraws that field, not the form. Check boxes and radio buttons with no appearance for their state are drawn from `/MK` (border, background, and when on the `/MK /CA` ZapfDingbats - symbol in the `/DA` colour), and push buttons with none from `/MK`, `/BS` and their caption. + symbol in the `/DA` colour), and with `/NeedAppearances` so is every one that has an `/MK`, + over the appearance the file carries; push buttons with none are drawn from `/MK`, `/BS` and + their caption. - Check boxes and radio buttons switch `/V` and each widget's `/AS`. Widgets with no appearances get PdfCraft's own (a drawn check mark or dot; no symbol font needed). - Imported `/MaxLen 0` is treated as unlimited (also clearing an inherited limit), while the diff --git a/crates/forms/src/actions.rs b/crates/forms/src/actions.rs index f58bd10783..5eb57c6c1b 100644 --- a/crates/forms/src/actions.rs +++ b/crates/forms/src/actions.rs @@ -260,6 +260,47 @@ fn write(doc: &mut Document, a: &FieldAction, pages: &[pdfcraft_cos::ObjRef]) -> Ok(Some(Object::Ref(doc.add(Object::Dict(d))))) } +/// Rewrite one trigger of a widget to `new` (`None` clears it), given the `old` value read from +/// the widget. `aa` and `a_entry` are the widget's `/AA` dict and `/A` entry, updated in place. +#[allow(clippy::too_many_arguments)] +fn replace_trigger( + doc: &mut Document, + pages: &[pdfcraft_cos::ObjRef], + scripts: &mut pdfcraft_cos::DecodeBudget, + t: Trigger, + new: Option<&FieldAction>, + old: Option, + aa: &mut Dict, + a_entry: &mut Option, +) -> Result<(), FormError> { + let old_action = old.as_ref().and_then(|o| read(doc, o, pages, scripts)); + if new.is_some() && new == old_action.as_ref() { + return Ok(()); + } + // The list shows a trigger's first action: replacing it keeps the ones chained after it. + let next = new.and(old).and_then(|o| doc.resolve(&o).as_dict().and_then(|d| d.get(b"Next").cloned())); + let obj = match new { + Some(a) => write(doc, a, pages)?, + None => None, + }; + if let (Some(Object::Ref(r)), Some(next)) = (&obj, next) { + doc.update_dict(*r, |d| d.set(b"Next".to_vec(), next))?; + } + match t.key() { + None => { + *a_entry = obj; + aa.remove(b"U"); + } + Some(k) => match obj { + Some(o) => aa.set(k.to_vec(), o), + None => { + aa.remove(k); + } + }, + } + Ok(()) +} + /// Replace field `name`'s actions (on every widget) with `actions`; triggers not listed lose /// theirs. `Other` actions, and actions that read the same as before, are left as they are (with /// their chain and the keys this module doesn't read); a replaced action keeps its chain. @@ -281,31 +322,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(), }; - let old_action = old.as_ref().and_then(|o| read(doc, o, &pages, &mut scripts)); - if new.is_some() && new == old_action.as_ref() { - continue; - } - // The list shows a trigger's first action: replacing it keeps the ones chained after it. - let next = new.and(old).and_then(|o| doc.resolve(&o).as_dict().and_then(|d| d.get(b"Next").cloned())); - let obj = match new { - Some(a) => write(doc, a, &pages)?, - None => None, - }; - if let (Some(Object::Ref(r)), Some(next)) = (&obj, next) { - doc.update_dict(*r, |d| d.set(b"Next".to_vec(), next))?; - } - match t.key() { - None => { - a_entry = obj; - aa.remove(b"U"); - } - Some(k) => match obj { - Some(o) => aa.set(k.to_vec(), o), - None => { - aa.remove(k); - } - }, - } + replace_trigger(doc, &pages, &mut scripts, t, new, 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 7b706e9ae9..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,1696 +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 s = format!("{:.*}", decimals as usize, (v.abs() * p).round() / p); - let (int, frac) = s.split_once('.').map_or((s.as_str(), ""), |(a, b)| (a, b)); - let (g, d) = match sep { - 1 => (None, '.'), - 2 => (Some('.'), ','), - 3 => (None, ','), - 4 => (Some('\''), '.'), - _ => (Some(','), '.'), - }; - let mut out = match g { - Some(c) => group(int, c), - None => int.to_string(), - }; - if !frac.is_empty() { - out.push(d); - out.push_str(frac); - } - out -} - -const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]; -const DAYS: [&str; 7] = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; - -/// A date and time read from text. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct DateTime { - pub y: i32, - pub m: u32, - pub d: u32, - pub hh: u32, - pub mm: u32, - pub ss: u32, -} - -fn days_in(y: i32, m: u32) -> u32 { - match m { - 2 if (y % 4 == 0 && y % 100 != 0) || y % 400 == 0 => 29, - 2 => 28, - 4 | 6 | 9 | 11 => 30, - _ => 31, - } -} - -fn weekday(y: i32, m: u32, d: u32) -> usize { - // Sakamoto's method, in i64 so no year or day can overflow it. - const T: [i64; 12] = [0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4]; - let y = i64::from(y) - i64::from(m < 3); - let t = T.get((m as usize).wrapping_sub(1)).copied().unwrap_or(0); - (y + y / 4 - y / 100 + y / 400 + t + i64::from(d)).rem_euclid(7) as usize -} - -/// 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 ──────────────────────────────────────────────────────────────────────────── @@ -2060,4 +383,12 @@ mod undelimited_tests { assert_eq!(parse_date("99999999999999999999", "yyyymmdd"), None); assert_eq!(parse_date("2026 99999999999", "yyyy"), None, "an unreadable number is left over, not ignored"); } + + #[test] + fn huge_finite_numbers_do_not_print_inf() { + let n = format_value(&Format::Number { decimals: 2, sep: 1, neg: 0, currency: String::new(), prepend: true }, "1e307"); + assert!(n.len() > 300 && n.ends_with(".00") && !n.contains("inf"), "got {n}"); + let p = format_value(&Format::Percent { decimals: 2, sep: 1 }, "1e306"); + assert!(!p.contains("inf"), "got {p}"); + } } diff --git a/crates/forms/src/af/calc.rs b/crates/forms/src/af/calc.rs new file mode 100644 index 0000000000..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..c807f2d7d7 --- /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::*; + +// ── 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/appearance.rs b/crates/forms/src/appearance.rs index da25c211c0..c1bb5630a6 100644 --- a/crates/forms/src/appearance.rs +++ b/crates/forms/src/appearance.rs @@ -480,7 +480,7 @@ fn frame(doc: &Document, wd: &Dict, w: f64, h: f64) -> (String, f64) { /// Whether `wd` is a check box or radio button PdfCraft laid out for a round XFA check button /// (`/PCXfaRound true`, written by the XFA layout). -fn is_round(doc: &Document, wd: &Dict) -> bool { +pub(crate) fn is_round(doc: &Document, wd: &Dict) -> bool { matches!(wd.get(b"PCXfaRound").map(|o| doc.resolve(o)).as_deref(), Some(Object::Bool(true))) } @@ -1029,9 +1029,10 @@ fn check_mark(style: crate::author::CheckStyle, cx: f64, cy: f64, s: f64, color: } /// How a check box or radio button widget `w` of field `f` looks in state on (`on`) or Off when -/// the file has no appearance for that state: its `/MK` background and border ([`frame`]), and -/// when on, its caption `/MK /CA` (ISO 32000-2 Table 192: the normal caption, which check boxes -/// and radio buttons may use too) as a ZapfDingbats character, as in the check box example of +/// the file has no appearance for that state, or leaves its appearances to the viewer +/// (`/NeedAppearances`): its `/MK` background and border ([`frame`]), and when on, its caption +/// `/MK /CA` (ISO 32000-2 Table 192: the normal caption, which check boxes and radio buttons may +/// use too) as a ZapfDingbats character, as in the check box example of /// 12.7.5.2.3: `4` (a check mark) by default, `l` (a dot) for a radio button. The character is /// drawn in the `/DA` colour, at the `/DA` size or, for an automatic size (`0 Tf`), filling most /// of the box. ZapfDingbats is one of the standard 14 fonts, so no font program is needed. diff --git a/crates/forms/src/appearances.rs b/crates/forms/src/appearances.rs new file mode 100644 index 0000000000..d035feb4ef --- /dev/null +++ b/crates/forms/src/appearances.rs @@ -0,0 +1,237 @@ +use super::read::{acroform, fields}; +use super::values::merged_appearance; +use super::*; + +/// 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 873ef3d716..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,1598 +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, - }; - 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..dc7d91d4fc --- /dev/null +++ b/crates/forms/src/author/edit.rs @@ -0,0 +1,425 @@ +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::*; + +/// 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(()) +} diff --git a/crates/forms/src/author/lifecycle.rs b/crates/forms/src/author/lifecycle.rs new file mode 100644 index 0000000000..745ab3b341 --- /dev/null +++ b/crates/forms/src/author/lifecycle.rs @@ -0,0 +1,326 @@ +use super::look::redraw_field; +use super::tree::{add_to_page, attach, clash, detach, ensure_form, free_name, name_parts, top_names}; +use super::*; + +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..6044bdc70b --- /dev/null +++ b/crates/forms/src/author/look.rs @@ -0,0 +1,473 @@ +use super::*; + +/// 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..4f7db4d8e1 --- /dev/null +++ b/crates/forms/src/author/tree.rs @@ -0,0 +1,380 @@ +use super::*; + +/// 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 75c3dbee00..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; @@ -118,6 +139,9 @@ pub struct Widget { pub caption: Option, /// List boxes: where the options are drawn, so a click can pick the one under the pointer. pub list: Option, + /// Drawn round: a radio button, or a check box laid out for a round XFA check button + /// (`/PCXfaRound true`). + pub round: bool, } /// A terminal form field. @@ -264,1483 +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 } -} - -fn enumerate(doc: &Document) -> (Vec, usize) { - let af = acroform(doc); - let mut page_of = std::collections::HashMap::new(); - let mut annot_index = std::collections::HashMap::new(); - let pages = page_refs(doc); - for (i, p) in pages.iter().enumerate() { - if let Some(a) = doc.get(*p).as_dict().and_then(|d| d.get(b"Annots").cloned()) { - for (k, e) in doc.resolve(&a).as_array().into_iter().flatten().enumerate() { - if let Some(r) = e.as_ref() { - page_of.entry(r).or_insert(i); - annot_index.entry(r).or_insert(k); - } - } - } - } - let base = match &af { - Some(af) => Inherited { - da: af.get(b"DA").and_then(|o| text_of(&doc.resolve(o))), - q: af.get(b"Q").and_then(|o| doc.resolve(o).as_int()), - ..Default::default() - }, - None => Inherited::default(), - }; - let mut out = Vec::new(); - let mut seen = 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)), - }) - }) - .collect(); - let options = d - .get(b"Opt") - .map(|o| doc.resolve(o)) - .and_then(|o| o.as_array().cloned()) - .unwrap_or_default() - .iter() - .filter_map(|o| match &*doc.resolve(o) { - Object::Array(pair) if pair.len() >= 2 => Some((text_of(&doc.resolve(&pair[0]))?, text_of(&doc.resolve(&pair[1]))?)), - other => text_of(other).map(|t| (t.clone(), t)), - }) - .collect(); - out.push(Field { - name: inh.name.clone(), - obj: r, - kind, - value: values_of(doc, inh.v.as_ref()), - default: values_of(doc, inh.dv.as_ref()), - flags: ff, - max_len: inh.max_len, - options, - da: inh.da.clone().unwrap_or_else(|| "/Helv 0 Tf 0 g".into()), - quadding: inh.q.unwrap_or(0), - tooltip: d.get(b"TU").and_then(|o| text_of(&doc.resolve(o))), - widgets, - actions, - button, - }); -} - -fn field_flags(doc: &Document, dict: &Dict) -> Option { - dict.get(b"Ff").and_then(|value| doc.resolve(value).as_int()).and_then(|value| u32::try_from(value).ok()) -} - -// ── writing ───────────────────────────────────────────────────────────────────────────────── - -/// Fill the field `name`. -pub fn set_value(doc: &mut Document, name: &str, value: &FieldValue) -> Result<(), FormError> { - set_value_with(doc, name, value, &mut NoScripts) -} - -/// [`set_value`], running the fields' JavaScript through `scripts`. -pub fn set_value_with(doc: &mut Document, name: &str, value: &FieldValue, scripts: &mut dyn Scripts) -> Result<(), FormError> { - let all = fields(doc); - if all.is_empty() { - return Err(FormError::NoForm); - } - let f = all.iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?; - if f.read_only() { - return Err(FormError::ReadOnly(name.into())); - } - write_value(doc, f, value, scripts)?; - 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> = all.iter().filter(|t| t.name == name && t.obj != f.obj && t.kind == f.kind).collect(); - if !twins.is_empty() { - let taken = fields(doc).into_iter().find(|x| x.obj == f.obj).map(|x| x.value).unwrap_or_default(); - for t in twins { - share_value(doc, f.obj, t, &taken, scripts)?; - } - } - recalculate_with(doc, scripts)?; - Ok(()) -} - -/// Give `twin` the value `source` holds: its `/V` (and a choice's `/I`), drawn in the twin's own -/// widgets. -fn share_value(doc: &mut Document, source: ObjRef, twin: &Field, value: &[String], scripts: &mut dyn Scripts) -> Result<(), FormError> { - match twin.kind { - FieldKind::CheckBox => { - let state = twin.widgets.iter().find_map(|w| w.on_state.clone()).unwrap_or_else(|| "Yes".into()); - set_states(doc, twin, (!value.is_empty()).then_some(state.as_str())) - } - FieldKind::Radio => { - let choice = value.first().filter(|c| twin.widgets.iter().any(|w| w.on_state.as_deref() == Some(c.as_str()))); - set_states(doc, twin, choice.map(String::as_str)) - } - FieldKind::Text | FieldKind::Combo | FieldKind::List => { - let src = doc.get(source).as_dict().cloned().unwrap_or_default(); - doc.update_dict(twin.obj, |d| { - for key in [&b"V"[..], b"I"] { - match src.get(key) { - Some(v) => d.set(key.to_vec(), v.clone()), - None => { - d.remove(key); - } - } - } - d.remove(b"RV"); - })?; - redraw(doc, twin, value, scripts) - } - FieldKind::PushButton | FieldKind::Signature => Ok(()), - } -} - -/// Run every field's Calculate script in the form's calculation order (`/CO`, then the other -/// calculated fields), as Acrobat does after any value changes. Returns how many changed. -pub fn recalculate(doc: &mut Document) -> Result { - recalculate_with(doc, &mut NoScripts) -} - -/// [`recalculate`], running custom Calculate (and Format) scripts through `scripts`. -pub fn recalculate_with(doc: &mut Document, scripts: &mut dyn Scripts) -> Result { - let mut all = fields(doc); - 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 mut undrawn = false; - for w in &f.widgets { - let (stream, missing) = match &shown { - Some(s) => appearance::field_appearance_drawn(doc, f, w, std::slice::from_ref(s), false), - None => appearance::field_appearance_drawn(doc, f, w, values, true), - }; - undrawn |= missing.is_some(); - let ap = doc.add(Object::Stream(stream)); - let mut fresh = Dict::new(); - fresh.set(b"N".to_vec(), Object::Ref(ap)); - let apd = merged_appearance(doc, w.obj, &fresh); - doc.update_dict(w.obj, |d| { - d.set(b"AP".to_vec(), Object::Dict(apd)); - d.remove(b"AS"); - pdfcraft_fonts::set_undrawn_mark(d, missing); - })?; - } - if undrawn { - ask_viewers_to_redraw(doc)?; - } - Ok(()) -} - -/// A value its widget's font can't show is stored as typed and drawn with `?` (as Acrobat keeps -/// such a value): `/NeedAppearances` then asks other viewers to draw the fields from their values -/// with fonts that have those characters (the interactive form dictionary, §12.7.3). Not in a -/// document that declares PDF/A, which forbids it: there the widget's mark -/// (`pdfcraft_fonts::UNDRAWN_MARK`) is all, and the edit's report says the `?` shows everywhere. -pub(crate) fn ask_viewers_to_redraw(doc: &mut Document) -> Result<(), FormError> { - if declares_pdfa(doc) || need_appearances(doc) { - return Ok(()); - } - let Some(root) = doc.root() else { return Ok(()) }; - let entry = doc.get(root).as_dict().and_then(|c| c.get(b"AcroForm").cloned()); - match entry { - Some(Object::Ref(r)) => doc.update_dict(r, |d| d.set(b"NeedAppearances".to_vec(), Object::Bool(true)))?, - Some(Object::Dict(mut af)) => { - af.set(b"NeedAppearances".to_vec(), Object::Bool(true)); - doc.update_dict(root, |c| c.set(b"AcroForm".to_vec(), Object::Dict(af)))?; - } - _ => {} - } - Ok(()) -} - -/// 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 32 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 > 32 || !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 32 deep and without repeats. -fn field_chain(doc: &Document, r: ObjRef) -> Vec { - let mut chain = vec![r]; - let mut cur = r; - while chain.len() < 32 { - 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 push buttons with none 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 a check box's value, 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 value = f.value.first().filter(|v| f.kind == FieldKind::CheckBox && !v.is_empty() && v.as_str() != "Off"); - Some(w.state.clone().or_else(|| value.cloned()).unwrap_or_else(|| "Off".into())) - } - _ => None, - }; - 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()) { - (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..a962af91bd --- /dev/null +++ b/crates/forms/src/read.rs @@ -0,0 +1,568 @@ +use super::tab_order::rank_tabs; +use super::*; + +/// 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..9c5c33b694 --- /dev/null +++ b/crates/forms/src/tab_order.rs @@ -0,0 +1,153 @@ +use super::read::{fields, page_refs}; +use super::*; + +/// 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 54bc137abd..61dab131e0 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -4088,6 +4088,65 @@ fn a_password_field_missing_from_the_field_list_is_still_cleared() { } } +/// A password field nested beyond depth 32 (e.g. depth 35) is recognized by `has_password_flag` +/// and `has_password_values`, matching the field tree walk limit of 64. +#[test] +fn a_password_field_nested_beyond_depth_32_is_recognized() { + let target_depth = 35; + let leaf_obj_num = 5 + target_depth; + let mut objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R >>".into(), + "<< /Fields [5 0 R] /DA (/Helv 0 Tf 0 g) >>".into(), + ]; + for d in 0..target_depth { + let obj_num = 5 + d; + let next_obj_num = obj_num + 1; + let parent_entry = if d == 0 { String::new() } else { format!(" /Parent {} 0 R", obj_num - 1) }; + objs.push(format!("<< /T (node{d}) /Kids [{next_obj_num} 0 R]{parent_entry} >>")); + } + objs.push(format!( + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (pass) /Ff 8192 /V (secret35) /Parent {} 0 R /Rect [50 600 150 620] /P 3 0 R >>", + leaf_obj_num - 1 + )); + let mut doc = document(&objs); + assert!(has_password_flag(&doc)); + assert!(has_password_values(&doc)); + assert_eq!(clear_password_values(&mut doc).unwrap(), 1); + assert!(!has_password_values(&doc)); +} + +/// A password field whose widget is reachable through a page's Annots and whose inherited Password +/// flag is deeper than 32 levels is recognized by `has_password_flag`. +#[test] +fn a_page_widget_inheriting_password_flag_beyond_depth_32_is_recognized() { + let target_depth = 35; + let leaf_obj_num = 5 + target_depth; + let mut objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + format!("<< /Type /Page /Parent 2 0 R /Annots [{leaf_obj_num} 0 R] >>"), + "<< /Fields [5 0 R] /DA (/Helv 0 Tf 0 g) >>".into(), + ]; + for d in 0..target_depth { + let obj_num = 5 + d; + let next_obj_num = obj_num + 1; + let parent_entry = if d == 0 { String::new() } else { format!(" /Parent {} 0 R", obj_num - 1) }; + let ff_entry = if d == 0 { " /Ff 8192" } else { "" }; + objs.push(format!("<< /T (node{d}){ff_entry} /Kids [{next_obj_num} 0 R]{parent_entry} >>")); + } + objs.push(format!( + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (pass) /V (secret_chain) /Parent {} 0 R /Rect [50 600 150 620] /P 3 0 R >>", + leaf_obj_num - 1 + )); + let mut doc = document(&objs); + assert!(has_password_flag(&doc)); + assert!(has_password_values(&doc)); + assert_eq!(clear_password_values(&mut doc).unwrap(), 1); + assert!(!has_password_values(&doc)); +} + /// #4018: a value that doesn't fit its box at the field's font size is reported (it is drawn cut /// at the box). An auto-sized field shrinks to fit, and a short or empty value fits. #[test] @@ -4300,3 +4359,221 @@ fn comb_fields_show_their_cells() { let plain = saved_look(&doc, "d"); assert!(!plain.contains(" m ") && plain.contains("(A) Tj"), "{plain}"); } + +/// Check boxes and radio buttons with an `/MK` look and appearances of their own, in a form that +/// sets `/NeedAppearances` (`need`) or not. +fn mk_buttons(need: bool) -> Document { + let flag = if need { "/NeedAppearances true " } else { "" }; + let widget = |n: &str, rest: &str| format!("<< /Type /Annot /Subtype /Widget /FT /Btn /T ({n}) /F 4 /P 3 0 R {rest} >>"); + let states = |on: &str| format!("/AP << /N << /{on} 15 0 R /Off 16 0 R >> >>"); + document(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [6 0 R 7 0 R 8 0 R 9 0 R 10 0 R 11 0 R 12 0 R 13 0 R 14 0 R] >>".into(), + format!( + "<< /Fields [6 0 R 7 0 R 8 0 R 9 0 R 10 0 R 11 0 R 12 0 R 13 0 R 14 0 R] {flag}/DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >>" + ), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + widget("check", &format!("/V /Yes /AS /Yes /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (4) >> /Rect [50 700 74 724] {}", states("Yes"))), // 6 + widget("cross", &format!("/V /Yes /AS /Yes /DA (/ZaDb 0 Tf 1 0 0 rg) /MK << /CA (8) >> /Rect [100 700 124 724] {}", states("Yes"))), + widget("diamond", &format!("/V /Yes /AS /Yes /MK << /CA (u) /BG [1 1 0] >> /Rect [150 700 174 724] {}", states("Yes"))), + widget("square", &format!("/V /Yes /AS /Yes /MK << /CA (n) >> /Rect [200 700 224 724] {}", states("Yes"))), + widget("star", &format!("/V /Yes /AS /Yes /MK << /CA (H) >> /Rect [250 700 274 724] {}", states("Yes"))), // 10 + widget("off", &format!("/V /Off /AS /Off /MK << /BC [0 0 0] /CA (4) >> /Rect [300 700 324 724] {}", states("Yes"))), + widget("radio", &format!("/Ff 32768 /V /B /MK << /BC [0 0 1] /CA (l) >> /Rect [350 700 374 724] {}", states("B"))), + widget("plain", &format!("/V /Yes /AS /Yes /Rect [400 700 424 724] {}", states("Yes"))), + widget( + "odd", + &format!( + "/V /Yes /AS /Yes /DA (/ZaDb nan Tf 1 x rg) /MK << /CA 42 /BC [(no) /Name] /BG 7 >> /BS << /W -3 >> /Rect [450 700 474 724] {}", + states("Yes") + ), + ), + "<< /Length 24 >>\nstream\n0 g 4 4 16 16 re f\nendstream".into(), // 15: a filled square + "<< /Length 4 >>\nstream\nq\nQ\nendstream".into(), + ]) +} + +/// ISO 32000-2 Table 224 (`/NeedAppearances`) and Table 192 (`/MK /CA`): a form that leaves its +/// appearances to the viewer shows each check box and radio button with an `/MK` drawn from it, +/// over the appearance the file carries: the caption's ZapfDingbats symbol in the `/DA` colour +/// when on, only the border when off. A widget with no `/MK` keeps its own; the file is untouched. +#[test] +fn need_appearances_draws_check_boxes_and_radio_buttons_from_their_captions() { + let doc = mk_buttons(true); + let copy = with_field_appearances(&doc).expect("buttons to draw"); + let all = fields(©); + for (name, state, symbol, colour) in [ + ("check", "Yes", "(4) Tj", "0 g"), + ("cross", "Yes", "(8) Tj", "1 0 0 rg"), + ("diamond", "Yes", "(u) Tj", "0 g"), + ("square", "Yes", "(n) Tj", "0 g"), + ("star", "Yes", "(H) Tj", "0 g"), + ("radio", "B", "(l) Tj", "0 g"), + ] { + let shown = state_ap(©, &field(&all, name).widgets[0], state); + assert!(shown.contains(symbol) && shown.contains(&format!("{colour}\n")), "{name}: {shown}"); + assert!(!shown.contains("4 4 16 16 re f"), "{name} is not the file's filled square: {shown}"); + } + let check = state_ap(©, &field(&all, "check").widgets[0], "Yes"); + assert!(check.contains("0 0 0 RG\n1 w\n0.5 0.5 23 23 re S"), "the /BC border: {check}"); + assert!(state_ap(©, &field(&all, "diamond").widgets[0], "Yes").contains("1 1 0 rg\n0 0 24 24 re f"), "the /BG background"); + assert!(state_ap(©, &field(&all, "radio").widgets[0], "B").contains("0 0 1 RG"), "the radio's own border"); + let off = state_ap(©, &field(&all, "off").widgets[0], "Off"); + assert!(off.contains("re S") && !off.contains("Tj"), "off: only the border: {off}"); + // Nonsense in /MK, /DA and /BS: the default check, no border, and no panic. + let odd = state_ap(©, &field(&all, "odd").widgets[0], "Yes"); + assert!(odd.contains("(4) Tj") && !odd.contains("re S"), "{odd}"); + let own = |d: &Document, name: &str| d.get(field(&fields(d), name).widgets[0].obj).as_dict().and_then(|w| w.get(b"AP").cloned()); + assert_eq!(own(©, "plain"), own(&doc, "plain"), "a box without /MK keeps its own look"); + assert_eq!(state_ap(&doc, &field(&fields(&doc), "check").widgets[0], "Yes").trim_end(), "0 g 4 4 16 16 re f", "the file is untouched"); +} + +/// Without `/NeedAppearances` the file's own check box and radio button appearances are shown +/// as they are, `/MK` or not: no copy is needed. +#[test] +fn without_need_appearances_buttons_keep_their_own_appearances() { + assert!(with_field_appearances(&mk_buttons(false)).is_none()); +} + +/// A form for filling many values at once: two summed fields and their calculated total, a +/// name two field dictionaries share, a check box, a radio group, a list and a read-only field. +fn batch_form() -> Document { + let tx = |name: &str, y: u32, rest: &str| { + format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T ({name}) /Rect [50 {y} 250 {}] /P 3 0 R {rest} >>", y + 20) + }; + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [5 0 R 6 0 R 7 0 R 8 0 R 9 0 R 10 0 R 12 0 R 13 0 R 14 0 R 17 0 R] >>".into(), + "<< /Fields [5 0 R 6 0 R 7 0 R 8 0 R 9 0 R 10 0 R 11 0 R 14 0 R 17 0 R] /CO [7 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 15 0 R >> >> >>" + .into(), + tx("a", 700, "/V (1)"), + tx("b", 660, ""), + tx("total", 620, r#"/AA << /C << /S /JavaScript /JS (AFSimple_Calculate\("SUM", new Array\("a", "b"\)\);) >> >>"#), + tx("dup", 580, ""), + tx("dup", 540, ""), + "<< /Type /Annot /Subtype /Widget /FT /Btn /T (agree) /V /Off /AS /Off /Rect [300 700 315 715] /P 3 0 R >>".into(), + "<< /FT /Btn /T (size) /Ff 49152 /Kids [12 0 R 13 0 R] >>".into(), + "<< /Type /Annot /Subtype /Widget /Parent 11 0 R /AS /Off /Rect [300 650 315 665] /P 3 0 R /AP << /N << /S 16 0 R /Off 16 0 R >> >> >>" + .into(), + "<< /Type /Annot /Subtype /Widget /Parent 11 0 R /AS /Off /Rect [330 650 345 665] /P 3 0 R /AP << /N << /L 16 0 R /Off 16 0 R >> >> >>" + .into(), + "<< /Type /Annot /Subtype /Widget /FT /Ch /T (country) /Ff 131072 /Opt [[(ca) (Canada)] [(fr) (France)]] /Rect [300 600 450 620] /P 3 0 R >>" + .into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + "<< /Length 0 >>\nstream\n\nendstream".into(), + tx("locked", 500, "/Ff 1 /V (kept)"), + ]; + document(&objs) +} + +/// What a filled form shows: every field as read, and each widget's drawn appearance. +fn filled(doc: &Document) -> (Vec, Vec) { + let all = fields(doc); + let looks = all + .iter() + .flat_map(|f| &f.widgets) + .map(|w| { + let wd = doc.get(w.obj).as_dict().cloned().unwrap_or_default(); + let n = wd.get(b"AP").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().and_then(|a| a.get(b"N").cloned())); + match n.map(|n| doc.resolve(&n)) { + Some(n) => match &*n { + Object::Stream(s) => String::from_utf8_lossy(&s.decoded().unwrap_or_default()).into_owned(), + other => format!("{other:?}").chars().filter(|c| !c.is_ascii_digit()).collect(), + }, + None => String::new(), + } + }) + .collect(); + (all, looks) +} + +/// Filling values in one batch leaves the form as filling them one by one does: the values, +/// the twin of a shared name, the calculated total and every widget's appearance; a name given +/// twice ends with its last value, and is checked against the value it took earlier in the batch; +/// and each value fails as it would on its own. +#[test] +fn set_values_fills_as_set_value_does_one_by_one() { + let values: Vec<(String, FieldValue)> = vec![ + ("a".into(), FieldValue::Text("2".into())), + ("b".into(), FieldValue::Text("3".into())), + ("dup".into(), FieldValue::Text("shared".into())), + ("agree".into(), FieldValue::Check(true)), + ("size".into(), FieldValue::Radio(Some("L".into()))), + ("country".into(), FieldValue::Choice(vec!["fr".into()])), + ("nowhere".into(), FieldValue::Text("x".into())), + ("locked".into(), FieldValue::Text("changed".into())), + ("size".into(), FieldValue::Radio(Some("XL".into()))), + ("dup".into(), FieldValue::Text("again".into())), + ("a".into(), FieldValue::Text("10".into())), + // Refused only because the batch already selected L: the group keeps one selected. + ("size".into(), FieldValue::Radio(None)), + ]; + let mut one_by_one = batch_form(); + let expected: Vec> = values.iter().map(|(n, v)| set_value(&mut one_by_one, n, v)).collect(); + let mut batch = batch_form(); + let results = set_values(&mut batch, &values).expect("recalculates"); + assert_eq!(results, expected); + assert_eq!( + results.iter().filter(|r| r.is_err()).count(), + 4, + "the unknown name, the read-only field, the unknown option and clearing the group fail: {results:?}" + ); + let (all, looks) = filled(&batch); + assert_eq!((all.clone(), looks), filled(&one_by_one)); + assert_eq!(field(&all, "total").value, ["13"], "recalculated once, after the last value"); + assert!(all.iter().filter(|f| f.name == "dup").all(|f| f.value == ["again"]), "both dup fields take the last value"); + // And as saved. + assert_eq!(filled(&reopen(&batch)), filled(&reopen(&one_by_one))); +} + +/// A form without fields refuses each value, and an empty batch changes nothing. +#[test] +fn set_values_without_a_form_or_values() { + let mut doc = document(&[ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R >>".into(), + ]); + let r = set_values(&mut doc, &[("a".into(), FieldValue::Text("1".into()))]).unwrap(); + assert_eq!(r, vec![Err(FormError::NoForm)]); + let mut form = batch_form(); + assert_eq!(set_values(&mut form, &[]).unwrap(), Vec::new()); + assert!(!form.is_modified()); + // Nothing filled, nothing recalculated: the total stays as the file has it. + let r = set_values(&mut form, &[("nowhere".into(), FieldValue::Text("1".into()))]).unwrap(); + assert_eq!(r, vec![Err(FormError::NoSuchField("nowhere".into()))]); + assert!(field(&fields(&form), "total").value.is_empty() && !form.is_modified()); +} + +/// Filling many fields reads the form a fixed number of times, not once or twice per field +/// (which took minutes to open a form with thousands of fields whose XFA data was filled in +/// elsewhere). +#[test] +fn set_values_reads_the_form_once_not_per_value() { + let n: u32 = 60; + let mut objs: Vec = + vec!["<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into()]; + let kids: Vec = (0..n).map(|i| format!("{} 0 R", i + 5)).collect(); + objs.push(format!("<< /Type /Page /Parent 2 0 R /Annots [{}] >>", kids.join(" "))); + objs.push(format!("<< /Fields [{}] /DA (/Helv 10 Tf 0 g) >>", kids.join(" "))); + for i in 0..n { + objs.push(format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T (f{i}) /Rect [50 {y} 250 {top}] /P 3 0 R >>", y = 10 * i, top = 10 * i + 10)); + } + let mut doc = document(&objs); + let values: Vec<(String, FieldValue)> = (0..n).map(|i| (format!("f{i}"), FieldValue::Text(format!("v{i}")))).collect(); + let before = form_reads(); + let results = set_values(&mut doc, &values).unwrap(); + let reads = form_reads() - before; + assert!(results.iter().all(Result::is_ok)); + // One reading to fill, one to recalculate. + assert!(reads <= 2, "{reads} readings of the form for {n} values"); + assert!(fields(&doc).iter().all(|f| f.value == [format!("v{}", &f.name[1..])])); + // A name filled again is read again, once: f0 and f1 again cost one more reading. + let again: Vec<(String, FieldValue)> = ["f0", "f1", "f0", "f1"].iter().map(|n| (n.to_string(), FieldValue::Text("w".into()))).collect(); + let before = form_reads(); + assert!(set_values(&mut doc, &again).unwrap().iter().all(Result::is_ok)); + let reads = form_reads() - before; + assert_eq!(reads, 3, "one to fill, one when f0 comes again, one to recalculate"); +} diff --git a/crates/forms/src/values.rs b/crates/forms/src/values.rs new file mode 100644 index 0000000000..9dd79f0a70 --- /dev/null +++ b/crates/forms/src/values.rs @@ -0,0 +1,638 @@ +use super::appearances::{declares_pdfa, need_appearances}; +use super::read::{acroform, field_flags, fields, page_refs}; +use super::*; + +// ── 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..3d0fe194ee --- /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::*; + +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..5f2eed1021 --- /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::*; + +/// `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 7b0d0e88a7..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,1756 +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); - Val::Num((a * k).round() / k) - } - "count" => { - // Non-null values and objects: an empty field doesn't count, a subform does. - let vals = self.arg_values_keeping(args, true)?; - let counts = |v: &Val| match v { - Val::Node(i) => self.node(*i).is_some_and(|n| !matches!(n.kind, XfaKind::Field | XfaKind::ExclGroup) || !n.value.is_empty()), - other => !self.is_null(other), - }; - Val::Num(vals.iter().filter(|v| counts(v)).count() as f64) - } - "sum" | "avg" | "max" | "min" => { - let vals = self.arg_values(args)?; - let nums: Vec = vals.iter().map(|v| self.to_num(v)).collect(); - match lower.as_str() { - "sum" => Val::Num(nums.iter().sum()), - "avg" => { - if nums.is_empty() { - Val::Null - } else { - Val::Num(nums.iter().sum::() / nums.len() as f64) - } - } - "max" => nums.iter().copied().fold(None, |m: Option, x| Some(m.map_or(x, |m| m.max(x)))).map_or(Val::Null, Val::Num), - _ => nums.iter().copied().fold(None, |m: Option, x| Some(m.map_or(x, |m| m.min(x)))).map_or(Val::Null, Val::Num), - } - } - // ── logical - "choose" => { - let i = self.arg_num(args, 0)?; - if !i.is_finite() || i < 1.0 { - return Ok(Val::Null); - } - self.arg(args, i as usize)? - } - "exists" => { - let Some(e) = args.first() else { return Ok(Val::Num(0.0)) }; - // A name that resolves to nothing is the answer "no"; a limit the lookup ran - // into (time, steps, depth, size) still stops the script. - match self.all_of(e) { - Ok(v) => Val::Num(if v.is_empty() { 0.0 } else { 1.0 }), - Err(e) if is_limit(&e) => return Err(e), - Err(_) => Val::Num(0.0), - } - } - "hasvalue" => { - let v = self.arg(args, 0)?; - Val::Num(if self.is_null(&v) || matches!(&v, Val::Str(s) if s.trim().is_empty()) { 0.0 } else { 1.0 }) - } - "oneof" => { - let v = self.arg(args, 0)?; - let rest = self.arg_values(args.get(1..).unwrap_or_default())?; - let mut hit = false; - for x in &rest { - // Each comparison copies and compares `v`: charged per item. - self.charge(self.cost(&v).saturating_add(self.cost(x)).saturating_add(1))?; - if self.binary("==", v.clone(), x.clone()).is_ok_and(|r| self.truthy(&r)) { - hit = true; - break; - } - } - Val::Num(if hit { 1.0 } else { 0.0 }) - } - "within" => { - let v = self.arg(args, 0)?; - let (lo, hi) = (self.arg(args, 1)?, self.arg(args, 2)?); - let ok = self.binary(">=", v.clone(), lo).is_ok_and(|r| self.truthy(&r)) && self.binary("<=", v, hi).is_ok_and(|r| self.truthy(&r)); - Val::Num(if ok { 1.0 } else { 0.0 }) - } - // ── strings - "at" => { - let (s, sub) = (self.arg_str(args, 0)?, self.arg_str(args, 1)?); - Val::Num(if sub.is_empty() { 0.0 } else { s.find(&sub).map_or(0.0, |b| s[..b].chars().count() as f64 + 1.0) }) - } - "concat" => { - let mut out = String::new(); - for i in 0..args.len() { - out.push_str(&self.arg_str(args, i)?); - if out.len() > MAX_STRING { - return Err(STRING_TOO_LONG.into()); - } - } - Val::Str(out) - } - "left" => { - let (s, k) = (self.arg_str(args, 0)?, self.arg_num(args, 1)?.max(0.0) as usize); - Val::Str(s.chars().take(k).collect()) - } - "right" => { - let (s, k) = (self.arg_str(args, 0)?, self.arg_num(args, 1)?.max(0.0) as usize); - let len = s.chars().count(); - Val::Str(s.chars().skip(len.saturating_sub(k)).collect()) - } - "len" => Val::Num(self.arg_str(args, 0)?.chars().count() as f64), - "lower" => Val::Str(self.arg_str(args, 0)?.to_lowercase()), - "upper" => Val::Str(self.arg_str(args, 0)?.to_uppercase()), - "ltrim" => Val::Str(self.arg_str(args, 0)?.trim_start().to_string()), - "rtrim" => Val::Str(self.arg_str(args, 0)?.trim_end().to_string()), - "space" => { - let k = self.arg_num(args, 0)?.clamp(0.0, MAX_STRING as f64) as usize; - Val::Str(" ".repeat(k)) - } - "str" => { - let v = self.arg_num(args, 0)?; - let width = if args.len() > 1 { self.arg_num(args, 1)?.clamp(0.0, 1000.0) as usize } else { 10 }; - let prec = if args.len() > 2 { self.arg_num(args, 2)?.clamp(0.0, 20.0) as usize } else { 0 }; - let text = fixed(v, prec); - Val::Str(if text.len() > width { "*".repeat(width) } else { format!("{text:>width$}") }) - } - "substr" => { - let s = self.arg_str(args, 0)?; - let start = self.arg_num(args, 1)?.max(1.0) as usize; - let len = self.arg_num(args, 2)?.max(0.0) as usize; - Val::Str(s.chars().skip(start - 1).take(len).collect()) - } - "stuff" => { - let s = self.arg_str(args, 0)?; - let start = self.arg_num(args, 1)?.max(1.0) as usize; - let del = self.arg_num(args, 2)?.max(0.0) as usize; - let ins = if args.len() > 3 { self.arg_str(args, 3)? } else { String::new() }; - let chars: Vec = s.chars().collect(); - let a = start.saturating_sub(1).min(chars.len()); - let b = a.saturating_add(del).min(chars.len()); - let out: String = chars[..a].iter().chain(ins.chars().collect::>().iter()).chain(chars[b..].iter()).collect(); - if out.len() > MAX_STRING { - return Err(STRING_TOO_LONG.into()); - } - Val::Str(out) - } - "replace" => { - let (s, old) = (self.arg_str(args, 0)?, self.arg_str(args, 1)?); - let new = if args.len() > 2 { self.arg_str(args, 2)? } else { String::new() }; - if old.is_empty() { - return Ok(Val::Str(s)); - } - let grown = s.matches(old.as_str()).count().saturating_mul(new.len()).saturating_add(s.len()); - if grown > MAX_STRING { - return Err(STRING_TOO_LONG.into()); - } - Val::Str(s.replace(&old, &new)) - } - "wordnum" => { - let v = self.arg_num(args, 0)?; - let kind = if args.len() > 1 { self.arg_num(args, 1)? } else { 0.0 }; - Val::Str(word_num(v, kind == 2.0)) - } - "uuid" => Val::Str(format!("{:032x}", (self.steps as u128).wrapping_mul(0x9E37_79B9_7F4A_7C15_F39C_C060_5CED_C835))), - "encode" | "decode" => { - let s = self.arg_str(args, 0)?; - let kind = if args.len() > 1 { self.arg_str(args, 1)?.to_ascii_lowercase() } else { "url".into() }; - Val::Str(match (lower.as_str(), kind.as_str()) { - ("encode", "html" | "xml") => s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """), - ("decode", "html" | "xml") => s.replace("<", "<").replace(">", ">").replace(""", "\"").replace("&", "&"), - ("encode", _) => s - .bytes() - .map(|b| if b.is_ascii_alphanumeric() || b"-_.~".contains(&b) { (b as char).to_string() } else { format!("%{b:02X}") }) - .collect(), - (_, _) => { - let mut out = Vec::new(); - let b = s.as_bytes(); - let mut i = 0; - while i < b.len() { - if b[i] == b'%' - && let Some(h) = - b.get(i + 1..i + 3).and_then(|h| std::str::from_utf8(h).ok()).and_then(|h| u8::from_str_radix(h, 16).ok()) - { - out.push(h); - i += 3; - } else { - out.push(b[i]); - i += 1; - } - } - String::from_utf8_lossy(&out).into_owned() - } - }) - } - "format" => { - // Number pictures: `9` digit, `z` optional digit, `,` separator, `.` point, `$`. - let pic = self.arg_str(args, 0)?; - let v = self.arg(args, 1)?; - if pic.to_ascii_uppercase().contains("YY") || pic.to_ascii_uppercase().contains("DD") { - let text = self.to_str(&v); - let days = parse_num(&text).filter(|d| is_numeric_text(&text) && d.abs() < MAX_DAYS).map(|d| d as i64); - return Ok(match days { - Some(d) => { - let (y, m, dd) = civil_from_days(d); - Val::Str(format_date(y, m, dd, &pic)) - } - None => parse_date(&text, "YYYY-MM-DD").map_or(Val::Str(text), |(y, m, d)| Val::Str(format_date(y, m, d, &pic))), - }); - } - let num = self.to_num(&v); - let decimals = pic.split_once('.').map_or(0, |(_, f)| f.chars().filter(|c| matches!(c, '9' | 'z' | 'Z')).count()).min(20); - // `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..3911a5223f --- /dev/null +++ b/crates/js/src/formcalc/access.rs @@ -0,0 +1,431 @@ +use super::ast::{Expr, Idx}; +use super::value::{Val, num_text, parse_num}; +use super::*; + +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..f9a88be798 --- /dev/null +++ b/crates/js/src/formcalc/ast.rs @@ -0,0 +1,48 @@ +use super::*; + +// ── 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..1e7bb94245 --- /dev/null +++ b/crates/js/src/formcalc/builtins.rs @@ -0,0 +1,477 @@ +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::*; + +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)); + 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}")), + }) + } +} 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..23ce3ec0c1 --- /dev/null +++ b/crates/js/src/formcalc/eval.rs @@ -0,0 +1,618 @@ +use super::ast::{Expr, Stmt}; +use super::value::{Val, is_numeric_text, num_text}; +use super::*; + +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..ba9ae4f16a --- /dev/null +++ b/crates/js/src/formcalc/lexer.rs @@ -0,0 +1,129 @@ +use super::*; + +// ── 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..84cf121cde --- /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::*; + +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..dd487c2482 --- /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::*; + +// ── 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..131b8851c9 --- /dev/null +++ b/crates/js/src/host.rs @@ -0,0 +1,197 @@ +use super::model::{DocInfo, FieldState, FieldType, Request}; +use super::run::MAX_ARRAY_LENGTH; +use super::*; + +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..17b5c252ff --- /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::*; + +/// 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 3d3c4ab524..9e96dcd2e3 100644 --- a/crates/js/src/tests.rs +++ b/crates/js/src/tests.rs @@ -1009,9 +1009,17 @@ mod formcalc { assert_eq!(calc(r#"Format("9.99", 2.344)"#), "2.34"); } + #[test] + fn round_keeps_huge_values_finite() { + assert_eq!(calc("Round(1e300, 12) == 1e300"), "1"); + assert_eq!(calc("Round(1.2345, 2)"), "1.23"); + } + #[test] fn built_in_functions_cover_the_common_set() { assert_eq!(calc("Abs(-2.5)"), "2.5"); + assert_eq!(calc(r#"Decode("a%2Fb", "url")"#), "a/b"); + assert_eq!(calc(r#"Decode("a%+fb", "url")"#), "a%+fb"); assert_eq!(calc("Ceil(2.1)"), "3"); assert_eq!(calc("Floor(-2.1)"), "-3"); assert_eq!(calc("Mod(7, 3)"), "1"); 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..2507941658 --- /dev/null +++ b/crates/js/src/xfa/globals.rs @@ -0,0 +1,362 @@ +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::*; + +// ── 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..419d2141fc --- /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::*; + +/// 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..b03e6aef09 --- /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::*; + +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..b9c6cedaae --- /dev/null +++ b/crates/js/src/xfa/model.rs @@ -0,0 +1,228 @@ +use super::*; + +/// 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..b9483f25d1 --- /dev/null +++ b/crates/js/src/xfa/nodes.rs @@ -0,0 +1,343 @@ +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::*; + +// ── 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..2db77d7062 --- /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::*; + +/// Run `script` for `event` on the form `root` (the root subform), from node `event.target`. +pub fn run_xfa(script: &str, event: &XfaEvent, doc: &XfaDoc, root: &XfaNode, limits: Limits) -> XfaOutcome { + let failed = |why: String| XfaOutcome { error: Some(why), ..Default::default() }; + if let Some(why) = refuse(script) { + return failed(why); + } + #[cfg(not(target_arch = "wasm32"))] + { + std::thread::scope(|scope| { + let spawned = std::thread::Builder::new() + .name("pdfcraft-xfa-js".into()) + .stack_size(crate::SCRIPT_STACK) + .spawn_scoped(scope, || run_here(script, event, doc, root.clone(), limits, &|| {})); + match spawned { + Ok(thread) => thread.join().unwrap_or_else(|_| failed("the script stopped with an internal error".into())), + Err(e) => failed(format!("the script engine could not start: {e}")), + } + }) + } + #[cfg(target_arch = "wasm32")] + run_here(script, event, doc, root.clone(), limits, &|| {}) +} + +/// [`run_xfa`] with a time limit: a script still running `timeout` after the engine is ready +/// (loops inside nested function calls can run for hours within the engine's per-frame loop +/// limit) is left on its thread, which ends when the engine's own limits stop it, and an +/// outcome with `abandoned` set comes back at once. In the browser build there are no +/// threads: the script runs to its limits. +pub fn run_xfa_within(script: &str, event: &XfaEvent, doc: &XfaDoc, root: XfaNode, limits: Limits, timeout: std::time::Duration) -> XfaOutcome { + if let Some(why) = refuse(script) { + return XfaOutcome { error: Some(why), ..Default::default() }; + } + let (script, event, doc) = (script.to_string(), event.clone(), doc.clone()); + run_within("pdfcraft-xfa-js", timeout, move |started| run_here(&script, &event, &doc, root, limits, started)) +} + +/// Longest the script engine may take to be ready for a script (its thread and context, and +/// for the first script in a process the engine's own set-up) before the script is abandoned. +/// It doesn't count against the script's time: on a busy machine the start alone can take +/// longer than the tightest limit, and abandoning a script turns the form's scripts off. +pub(crate) const ENGINE_START_LIMIT: std::time::Duration = std::time::Duration::from_secs(10); + +/// Run `script` on its own thread (with the script stack) and wait at most `timeout` for its +/// outcome, counted from when it calls `started` (once the engine is ready); past that the +/// thread is abandoned and the outcome says so. On wasm there are no threads: the script runs +/// here. +pub(crate) fn run_within(name: &str, timeout: std::time::Duration, script: impl FnOnce(&dyn Fn()) -> XfaOutcome + Send + 'static) -> XfaOutcome { + run_within_limits(name, ENGINE_START_LIMIT, timeout, script) +} + +/// [`run_within`] with the engine's start-up limit given. +pub(crate) fn run_within_limits( + name: &str, + start_limit: std::time::Duration, + timeout: std::time::Duration, + script: impl FnOnce(&dyn Fn()) -> XfaOutcome + Send + 'static, +) -> XfaOutcome { + #[cfg(not(target_arch = "wasm32"))] + { + use std::sync::mpsc::RecvTimeoutError; + use std::time::Instant; + enum Step { + Started, + Done(XfaOutcome), + } + let failed = |why: String| XfaOutcome { error: Some(why), ..Default::default() }; + let abandoned = |why: String| XfaOutcome { error: Some(why), abandoned: true, ..Default::default() }; + let (tx, rx) = std::sync::mpsc::channel(); + let spawned = std::thread::Builder::new().name(name.into()).stack_size(crate::SCRIPT_STACK).spawn(move || { + // The receiver is gone when the caller stopped waiting: nothing to report then. + let started = tx.clone(); + let outcome = script(&move || { + let _ = started.send(Step::Started); + }); + let _ = tx.send(Step::Done(outcome)); + }); + if let Err(e) = spawned { + return failed(format!("the script engine could not start: {e}")); + } + // Wait for the engine within its start-up limit, then for the script within its own. + let (mut running, mut limit) = (false, start_limit); + let mut deadline = Instant::now().checked_add(limit); + loop { + let left = deadline.map_or(limit, |d| d.saturating_duration_since(Instant::now())); + match rx.recv_timeout(left) { + Ok(Step::Done(o)) => return o, + Ok(Step::Started) => { + // A second call changes nothing: the script's time has already begun. + if !running { + (running, limit) = (true, timeout); + deadline = Instant::now().checked_add(limit); + } + } + Err(RecvTimeoutError::Timeout) if running => { + return abandoned(format!("the script ran longer than {:.1} s and was abandoned", timeout.as_secs_f64())); + } + Err(RecvTimeoutError::Timeout) => { + return abandoned(format!("the script engine took longer than {:.0} s to start", start_limit.as_secs_f64())); + } + Err(RecvTimeoutError::Disconnected) => return failed("the script stopped with an internal error".into()), + } + } + } + #[cfg(target_arch = "wasm32")] + { + let _ = (name, start_limit, timeout); + script(&|| {}) + } +} + +/// The first script in a process sets the engine up (its built-ins, parser and compiler come +/// into use): a trivial script takes that on, so a form's script is never timed with it. +pub(crate) fn warm_up() { + static WARM: std::sync::Once = std::sync::Once::new(); + // `call_once_force`: a poisoned `Once` (a panic while warming up) only means warming up again. + WARM.call_once_force(|_| { + let mut ctx = Context::default(); + let _ = ctx.eval(Source::from_bytes(b"(function () { return eval('0'); }).call({})")); + }); +} + +/// `started` is called once the engine is ready: a time limit on the script runs from then. +pub(crate) fn run_here(script: &str, event: &XfaEvent, doc: &XfaDoc, root: XfaNode, limits: Limits, started: &dyn Fn()) -> XfaOutcome { + warm_up(); + let mut ctx = match Context::builder().host_hooks(std::rc::Rc::new(crate::CheckedCodeFromStrings)).build() { + Ok(ctx) => ctx, + Err(e) => return XfaOutcome { error: Some(format!("the script engine could not start: {e}")), ..Default::default() }, + }; + ctx.runtime_limits_mut().set_loop_iteration_limit(limits.loop_iterations); + ctx.runtime_limits_mut().set_recursion_limit(limits.recursion); + let host_state = XHost::new(root, &event.target, doc); + let current = host_state.current; + ctx.insert_data(RefCell::new(host_state)); + let mut out = XfaOutcome::default(); + let result = (|| -> JsResult<()> { + install(&mut ctx, event, doc, current)?; + // Ready for the script: its time starts now, the set-up above not charged to it. + started(); + ctx.register_global_property(js_string!("__xfa_src"), s(script), Attribute::CONFIGURABLE)?; + // Direct eval inside a function called on the node: `this` is the node and the + // completion value is the script's last expression (what a calculate script yields). + let r = ctx.eval(Source::from_bytes(b"(function () { return eval(__xfa_src); }).call(__xfa_this)"))?; + if !r.is_undefined() { + // Best effort: a value that can't be made text (a symbol, say) is no failure. + out.result = Some(text(&r, &mut ctx).unwrap_or_default()); + if r.is_boolean() { + out.result_bool = Some(r.to_boolean()); + } else if r.is_null() { + out.result = Some(String::new()); + } + } + Ok(()) + })(); + if let Err(e) = result { + out.error = Some(e.to_string()); + } + if let Some(h) = ctx.remove_data::() { + let h = h.into_inner(); + out.notes = h.notes(); + out.effects = h.effects; + out.console = h.console; + } + clip_result(&mut out); + out +} diff --git a/crates/js/src/xfa_session.rs b/crates/js/src/xfa_session.rs index 91447454dd..28ce53994d 100644 --- a/crates/js/src/xfa_session.rs +++ b/crates/js/src/xfa_session.rs @@ -18,6 +18,18 @@ use crate::xfa::{XfaDoc, XfaEvent, XfaNode, XfaOutcome}; #[cfg(test)] pub(crate) static START_DELAY_MS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); +std::thread_local! { + static ENGINES_STARTED: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// How many script engine threads the sessions made on this thread have started, for +/// diagnostics and tests: an event's scripts share one, so opening a form with hundreds of +/// calculations starts one. Per thread, so a caller counting its own events is not disturbed +/// by forms on other threads. Always 0 on wasm, where the scripts run on the caller's thread. +pub fn engines_started() -> u64 { + ENGINES_STARTED.with(std::cell::Cell::get) +} + /// What one script run needs. #[derive(Clone, Debug)] pub struct Script { @@ -58,7 +70,7 @@ fn run_script(s: Script, doc: &XfaDoc, started: &dyn Fn()) -> XfaOutcome { #[cfg(not(target_arch = "wasm32"))] mod native { use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender, channel}; - use std::time::Instant; + use std::time::{Duration, Instant}; use super::{Script, run_script}; use crate::xfa::{ENGINE_START_LIMIT, XfaDoc, XfaOutcome}; @@ -83,11 +95,20 @@ mod native { worker: Option, /// A script ran past its limit: its thread is left to it and nothing more runs. dead: Option, + /// How long the engine has to be ready for a script ([`ENGINE_START_LIMIT`] by default). + start_limit: Duration, } impl XfaSession { pub fn new(doc: XfaDoc) -> XfaSession { - XfaSession { doc, worker: None, dead: None } + XfaSession { doc, worker: None, dead: None, start_limit: ENGINE_START_LIMIT } + } + + /// Multiply the time the engine has to be ready for a script by `factor` (a script's + /// own time is [`Script::timeout`]). + pub fn with_time_scale(mut self, factor: u32) -> XfaSession { + self.start_limit = ENGINE_START_LIMIT.saturating_mul(factor); + self } /// Why nothing more runs, once a script was abandoned. @@ -115,11 +136,12 @@ mod native { } }); spawned.map_err(|e| format!("the script engine could not start: {e}"))?; - match from.recv_timeout(ENGINE_START_LIMIT) { + super::ENGINES_STARTED.with(|n| n.set(n.get().saturating_add(1))); + match from.recv_timeout(self.start_limit) { Ok(Reply::Ready) => {} Ok(_) => return Err("the script engine answered out of turn".into()), Err(RecvTimeoutError::Timeout) => { - return Err(format!("the script engine took longer than {:.0} s to start", ENGINE_START_LIMIT.as_secs_f64())); + return Err(format!("the script engine took longer than {:.0} s to start", self.start_limit.as_secs_f64())); } Err(RecvTimeoutError::Disconnected) => return Err("the script engine stopped with an internal error".into()), } @@ -129,8 +151,9 @@ mod native { } /// Run one script, waiting at most its `timeout` from when the engine is ready for it - /// (the engine itself gets [`ENGINE_START_LIMIT`] to be ready); past that it is - /// abandoned on its thread and the session is dead. + /// (the engine itself gets [`ENGINE_START_LIMIT`] to be ready, unless + /// [`XfaSession::with_time_scale`] changed it); past that it is abandoned on its thread + /// and the session is dead. pub fn run(&mut self, script: Script) -> XfaOutcome { let abandoned = |why: String| XfaOutcome { error: Some(why), abandoned: true, ..Default::default() }; let failed = |why: String| XfaOutcome { error: Some(why), ..Default::default() }; @@ -138,6 +161,7 @@ mod native { return abandoned(why.clone()); } let timeout = script.timeout; + let start_limit = self.start_limit; let worker = match self.worker() { Ok(w) => w, Err(why) => return failed(why), @@ -148,7 +172,7 @@ mod native { return failed("the script engine stopped with an internal error".into()); } // The engine's start, then the script's own time. - let (mut running, mut limit) = (false, ENGINE_START_LIMIT); + let (mut running, mut limit) = (false, start_limit); let mut deadline = Instant::now().checked_add(limit); loop { let left = deadline.map_or(limit, |d| d.saturating_duration_since(Instant::now())); @@ -167,7 +191,7 @@ mod native { let why = if running { format!("the script ran longer than {:.1} s and was abandoned", timeout.as_secs_f64()) } else { - format!("the script engine took longer than {:.0} s to start", ENGINE_START_LIMIT.as_secs_f64()) + format!("the script engine took longer than {:.0} s to start", start_limit.as_secs_f64()) }; self.dead = Some(why.clone()); // Dropping the sender ends the thread's loop once the script is done. @@ -199,6 +223,11 @@ mod native { XfaSession { doc } } + /// No engine to start on wasm: nothing to scale. + pub fn with_time_scale(self, _factor: u32) -> XfaSession { + self + } + pub fn dead(&self) -> Option<&str> { None } diff --git a/crates/ocr/Cargo.toml b/crates/ocr/Cargo.toml index 46bb3a1e0e..750e78b96d 100644 --- a/crates/ocr/Cargo.toml +++ b/crates/ocr/Cargo.toml @@ -13,5 +13,8 @@ workspace = true [dependencies] pdfcraft-fonts = { workspace = true } ocrs = { version = "0.13.1", default-features = false, features = ["rten"] } -rten = { version = "0.26.0", default-features = false, features = ["rten_format"] } +rten = { version = "0.26.0", default-features = false, features = ["onnx_format", "rten_format"] } thiserror.workspace = true + +[dev-dependencies] +tempfile = "3.27" diff --git a/crates/ocr/src/lib.rs b/crates/ocr/src/lib.rs index affa4ab163..7f58270e1a 100644 --- a/crates/ocr/src/lib.rs +++ b/crates/ocr/src/lib.rs @@ -15,8 +15,8 @@ use std::path::{Path, PathBuf}; pub use pdfcraft_fonts::helvetica_width; /// The model files, as named in ATTRIBUTION.toml. -pub const DETECTION_MODEL: &str = "text-detection.rten"; -pub const RECOGNITION_MODEL: &str = "text-recognition.rten"; +pub const DETECTION_MODEL: &str = "text-detection.onnx"; +pub const RECOGNITION_MODEL: &str = "text-recognition.onnx"; /// The recognition model's alphabet, as it was trained (ocrs-models' `DEFAULT_ALPHABET`): its /// output classes follow this order. The ocrs crate's own copy has an `E` where the training had @@ -48,10 +48,13 @@ pub struct Models { } impl Models { - /// The models in `dir`, if both files are there. + /// A complete model pair in `dir`, preferring current ONNX files over legacy RTEN files. + /// An incomplete update never mixes formats or hides an intact legacy pair. pub fn in_dir(dir: &Path) -> Option { - let m = Models { detection: dir.join(DETECTION_MODEL), recognition: dir.join(RECOGNITION_MODEL) }; - (m.detection.is_file() && m.recognition.is_file()).then_some(m) + [(DETECTION_MODEL, RECOGNITION_MODEL), ("text-detection.rten", "text-recognition.rten")].into_iter().find_map(|(detection, recognition)| { + let m = Models { detection: dir.join(detection), recognition: dir.join(recognition) }; + (m.detection.is_file() && m.recognition.is_file()).then_some(m) + }) } /// Look for the models: `$PDFCRAFT_MODELS`, then where the release packages install them @@ -321,6 +324,63 @@ mod tests { assert_eq!(w.up, [-10.0, 0.0]); } + /// The current ONNX pair wins when an installation also retains both legacy models. + #[test] + fn model_discovery_prefers_the_complete_onnx_pair() { + let dir = tempfile::tempdir().unwrap(); + for file in ["text-detection.onnx", "text-recognition.onnx"] { + std::fs::write(dir.path().join(file), b"model").unwrap(); + } + let expected = Some(Models { detection: dir.path().join("text-detection.onnx"), recognition: dir.path().join("text-recognition.onnx") }); + assert_eq!(Models::in_dir(dir.path()), expected, "a complete ONNX pair is sufficient"); + for file in ["text-detection.rten", "text-recognition.rten"] { + std::fs::write(dir.path().join(file), b"model").unwrap(); + } + assert_eq!(Models::in_dir(dir.path()), expected, "the complete ONNX pair takes precedence over legacy models"); + } + + /// An interrupted ONNX update still permits an intact legacy pair in the same directory. + #[test] + fn model_discovery_falls_back_to_legacy_when_the_onnx_pair_is_incomplete() { + for current in ["text-detection.onnx", "text-recognition.onnx"] { + let dir = tempfile::tempdir().unwrap(); + for file in [current, "text-detection.rten", "text-recognition.rten"] { + std::fs::write(dir.path().join(file), b"model").unwrap(); + } + assert_eq!( + Models::in_dir(dir.path()), + Some(Models { detection: dir.path().join("text-detection.rten"), recognition: dir.path().join("text-recognition.rten") }), + "{current} alone must not hide a complete legacy pair" + ); + } + } + + /// Detection and recognition must come from one complete format pair, never a mixed update. + #[test] + fn model_discovery_refuses_mixed_format_pairs() { + for pair in [["text-detection.onnx", "text-recognition.rten"], ["text-detection.rten", "text-recognition.onnx"]] { + let dir = tempfile::tempdir().unwrap(); + for file in pair { + std::fs::write(dir.path().join(file), b"model").unwrap(); + } + assert_eq!(Models::in_dir(dir.path()), None, "{pair:?} is not a complete model pair"); + } + } + + /// Existing source builds and PDFCRAFT_MODELS overrides retain their complete RTEN pair. + #[test] + fn model_discovery_keeps_legacy_only_installations() { + let dir = tempfile::tempdir().unwrap(); + assert_eq!(Models::in_dir(dir.path()), None); + std::fs::write(dir.path().join("text-detection.rten"), b"model").unwrap(); + assert_eq!(Models::in_dir(dir.path()), None, "one legacy model is incomplete"); + std::fs::write(dir.path().join("text-recognition.rten"), b"model").unwrap(); + assert_eq!( + Models::in_dir(dir.path()), + Some(Models { detection: dir.path().join("text-detection.rten"), recognition: dir.path().join("text-recognition.rten") }) + ); + } + /// #103: every release package's layout puts the models where the installed app looks. /// Mirrors packaging/{windows/package.ps1,macos/package.sh,linux/package.sh,freebsd/package.sh}. #[test] diff --git a/crates/optimize/src/links.rs b/crates/optimize/src/links.rs index 082d56e7df..cbd64eb928 100644 --- a/crates/optimize/src/links.rs +++ b/crates/optimize/src/links.rs @@ -152,11 +152,12 @@ fn referenced_names(doc: &Document) -> HashSet> { Object::Dict(d) => { // Resolve first: /Dest and a GoTo's /D may be an indirect reference to the name // string, and an unresolved ref has no name, so the destination it uses would - // look unreferenced and be removed. + // look unreferenced and be removed. A GoToR or GoToE /D names a destination in + // another document, so it does not count as a use of this one's. if let Some(n) = d.get(b"Dest").and_then(|x| dest_name(doc.resolve(x).as_ref())) { out.insert(n); } - if matches!(d.name(b"S"), Some(b"GoTo" | b"GoToR" | b"GoToE")) + if d.name(b"S") == Some(b"GoTo") && let Some(n) = d.get(b"D").and_then(|x| dest_name(doc.resolve(x).as_ref())) { out.insert(n); diff --git a/crates/optimize/tests/optimize.rs b/crates/optimize/tests/optimize.rs index d00773f088..f16b9bc3fe 100644 --- a/crates/optimize/tests/optimize.rs +++ b/crates/optimize/tests/optimize.rs @@ -1700,6 +1700,50 @@ fn indirectly_referenced_destination_is_kept() { assert!(!left.contains(&b"unused".to_vec())); } +#[test] +fn a_destination_named_only_by_a_remote_or_embedded_go_to_is_removed() { + // The /D of a GoToR or GoToE action names a destination in another document. A local + // destination of the same name that nothing else uses is unreferenced. + let mut doc = Document::new_empty(); + let p = page(&mut doc, &[], "BT ET"); + let s = |t: &str| Object::String(pdfcraft_cos::PdfString::literal(t.as_bytes().to_vec())); + let link = |doc: &mut Document, action: Dict| { + let mut link = Dict::new(); + link.set(b"Type".to_vec(), Object::name("Annot")); + link.set(b"Subtype".to_vec(), Object::name("Link")); + link.set(b"Rect".to_vec(), Object::Array(vec![0.into(), 0.into(), 10.into(), 10.into()])); + link.set(b"A".to_vec(), Object::Dict(action)); + Object::Ref(doc.add(Object::Dict(link))) + }; + let mut go_to = Dict::new(); + go_to.set(b"S".to_vec(), Object::name("GoTo")); + go_to.set(b"D".to_vec(), s("local")); + let mut remote = Dict::new(); + remote.set(b"S".to_vec(), Object::name("GoToR")); + remote.set(b"F".to_vec(), s("other.pdf")); + remote.set(b"D".to_vec(), s("remote")); + let mut target = Dict::new(); + target.set(b"R".to_vec(), Object::name("C")); + target.set(b"N".to_vec(), s("attached.pdf")); + let mut embedded = Dict::new(); + embedded.set(b"S".to_vec(), Object::name("GoToE")); + embedded.set(b"T".to_vec(), Object::Dict(target)); + embedded.set(b"D".to_vec(), s("embedded")); + let links = vec![link(&mut doc, go_to), link(&mut doc, remote), link(&mut doc, embedded)]; + doc.update_dict(p, |d| d.set(b"Annots".to_vec(), Object::Array(links))).unwrap(); + let fit = Object::Array(vec![Object::Ref(p), Object::name("Fit")]); + let mut tree = Dict::new(); + tree.set(b"Names".to_vec(), Object::Array(vec![s("embedded"), fit.clone(), s("local"), fit.clone(), s("remote"), fit])); + let tree = doc.add(Object::Dict(tree)); + let mut names = Dict::new(); + names.set(b"Dests".to_vec(), Object::Ref(tree)); + let root = doc.root().unwrap(); + doc.update_dict(root, |c| c.set(b"Names".to_vec(), Object::Dict(names))).unwrap(); + let report = optimize(&mut doc, &Settings::default()).unwrap(); + assert_eq!(report.unreferenced_dests, 2); + assert_eq!(named_dest_keys(&doc), vec![b"local".to_vec()]); +} + /// The keys in the catalog's /Names /Dests name tree (one level, as the optimizer writes it). fn named_dest_keys(doc: &Document) -> Vec> { let cat = doc.get(doc.root().unwrap()).as_dict().cloned().unwrap(); 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"]; +mod carry; +mod combine; +mod copy; +mod fields; -/// How deep a copy follows references and nested arrays and dictionaries, counted together. -/// Real documents stay well under this (a page's resources, fonts and descriptors, nested -/// form XObjects and field trees are a few levels each); a chain of thousands of indirect -/// objects, each shallow enough for the parser, would otherwise exhaust the stack (#1800). -const MAX_COPY_DEPTH: usize = 256; - -/// 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) -} +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..21c21b9be3 --- /dev/null +++ b/crates/organize/src/import/carry.rs @@ -0,0 +1,233 @@ +use super::copy::Copier; +use super::*; + +/// 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..09a1b0725a --- /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::*; + +/// 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..f78411806f --- /dev/null +++ b/crates/organize/src/import/copy.rs @@ -0,0 +1,220 @@ +use super::*; + +/// 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..98f69c69ec --- /dev/null +++ b/crates/organize/src/import/fields.rs @@ -0,0 +1,297 @@ +use super::copy::WIDGET_KEYS; +use super::*; + +/// Rename the top-level fields about to be registered in `dst` whose fully-qualified names +/// (`parent.T.child`) are already taken, so each imported field stays its own field instead of +/// blurring into the destination's field of the same name (fields of one fully-qualified name +/// are one field). A colliding node's own `/T` becomes the first free `Name-1`, `Name-2`… +/// variant, so everything below it keeps its place in the tree. Returns what was renamed +/// (old fully-qualified name → new one), in order. +pub(super) fn resolve_field_conflicts(dst: &mut Document, fields: &[ObjRef]) -> Result, OrganizeError> { + let mut taken = taken_field_names(dst)?; + let planned = plan_renames(dst, fields, &mut taken); + let mut renamed = Vec::new(); + for (node, new_t, old, new) in planned { + dst.update_dict(node, |d| d.set(b"T".to_vec(), Object::String(pdfcraft_cos::PdfString::text(&new_t))))?; + renamed.push((old, new)); + } + Ok(renamed) +} + +/// What importing `src` into `dst` would rename, computed without changing either document: +/// each fully-qualified name of `src`'s fields that `dst` already uses (or that an earlier +/// field of `src` itself uses) is renamed to the first free variant, exactly as the import +/// would. Unreadable structure previews as nothing: the import itself will say why it failed. +pub fn field_name_conflicts(dst: &Document, src: &Document) -> Vec<(String, String)> { + let Ok(mut taken) = taken_field_names(dst) else { return Vec::new() }; + let Ok(roots) = field_roots(src) else { return Vec::new() }; + plan_renames(src, &roots, &mut taken).into_iter().map(|(_, _, old, new)| (old, new)).collect() +} + +/// What combining `sources`, in order, would rename in each one: the whole list is simulated +/// with the same first-free-variant rule the combine uses, so a later file's preview sees the +/// earlier files' renames. Unreadable structure previews as nothing (the combine itself will +/// say why it fails); non-colliding names still count as taken for the files after them. +pub fn combine_field_conflicts(sources: &[&Document]) -> Vec> { + let mut taken = std::collections::HashSet::new(); + sources + .iter() + .map(|src| match field_roots(src) { + Ok(roots) => plan_renames(src, &roots, &mut taken).into_iter().map(|(_, _, old, new)| (old, new)).collect(), + Err(_) => Vec::new(), + }) + .collect() +} + +/// Walk field trees `roots` in `doc` and plan the renames that would keep every name separate +/// from `taken`: each node's fully-qualified name joins `taken` (so a later field of the same +/// import collides with an earlier one), and a node already taken is renamed to the first free +/// variant of its own `/T`. Names are compared and renamed as decoded text, not as stored +/// bytes, so one name written in two encodings (PDFDocEncoding, UTF-16BE) is one name. +/// Read-only: returns (node, new `/T`, old name, new name). Cycle- and depth-safe. +fn plan_renames(doc: &Document, roots: &[ObjRef], taken: &mut std::collections::HashSet) -> Vec<(ObjRef, String, String, String)> { + let mut planned = Vec::new(); + let mut walk = RenameWalk::default(); + for &root in roots { + plan_renames_under(doc, root, "", taken, &mut walk, 0, &mut planned); + } + planned +} + +/// What one [`plan_renames`] call remembers: the nodes it visited, and for each colliding name +/// the suffix to try next. +#[derive(Default)] +struct RenameWalk { + seen: HashSet, + next: HashMap, +} + +/// One node of [`plan_renames`]. +fn plan_renames_under( + doc: &Document, + node: ObjRef, + prefix: &str, + taken: &mut std::collections::HashSet, + walk: &mut RenameWalk, + depth: u8, + planned: &mut Vec<(ObjRef, String, String, String)>, +) { + if depth > 32 || !walk.seen.insert(node) { + return; + } + let Some(d) = doc.get(node).as_dict().cloned() else { return }; + let mut name = d.get(b"T").and_then(|t| doc.resolve(t).as_string().map(|s| s.to_text())).unwrap_or_default(); + if !name.is_empty() { + let fqn = qualified(prefix, &name); + if taken.contains(&fqn) { + // `taken` only grows, so every suffix below the last one handed out for this name is + // still taken: resume there instead of at 1. + let mut n = walk.next.get(&fqn).copied().unwrap_or(1); + let mut variant; + while { + #[cfg(test)] + crate::work(1); + variant = format!("{name}-{n}"); + taken.contains(&qualified(prefix, &variant)) + } { + n = n.saturating_add(1); + } + walk.next.insert(fqn.clone(), n.saturating_add(1)); + let new_fqn = qualified(prefix, &variant); + planned.push((node, variant.clone(), fqn, new_fqn.clone())); + taken.insert(new_fqn); + name = variant; + } else { + taken.insert(fqn); + } + } + let prefix = qualified_optional(prefix, &name); + let kids = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_default(); + for kid in kids.iter().filter_map(|k| k.as_ref()) { + plan_renames_under(doc, kid, &prefix, taken, walk, depth + 1, planned); + } +} + +/// The field names in use in `dst`: every name in its form, and every name hanging only off a +/// page's widgets — a field the form never lists still owns its name. +fn taken_field_names(dst: &Document) -> Result, OrganizeError> { + let mut taken = std::collections::HashSet::new(); + let mut seen = std::collections::HashSet::new(); + for root in field_roots(dst)? { + collect_names(dst, root, "", &mut seen, 0, &mut taken); + } + Ok(taken) +} + +/// A document's field trees, wherever they are rooted: the `/AcroForm /Fields` entries, and +/// the fields behind widgets on pages (climbed to their root, for forms that never registered +/// them). +fn field_roots(doc: &Document) -> Result, OrganizeError> { + let mut roots: Vec = doc + .root() + .and_then(|r| doc.get(r).as_dict().cloned()) + .and_then(|c| c.get(b"AcroForm").map(|f| doc.resolve(f))) + .and_then(|f| f.as_dict().cloned()) + .and_then(|f| f.get(b"Fields").map(|l| doc.resolve(l))) + .and_then(|l| l.as_array().cloned()) + .unwrap_or_default() + .iter() + .filter_map(|f| f.as_ref()) + .collect(); + for (page, _) in walk(doc)? { + let Some(d) = doc.get(page).as_dict().cloned() else { continue }; + let annots = d.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default(); + for a in annots.iter() { + let Some(w) = doc.resolve(a).as_dict().cloned() else { continue }; + if w.name(b"Subtype") != Some(b"Widget") { + continue; + } + // Walk up to the field the widget belongs to, however deep it sits; a widget that + // is its own field is its own root. + let Some(mut root) = a.as_ref() else { continue }; + let mut up = 0; + while let Some(parent) = doc.get(root).as_dict().and_then(|p| p.reference(b"Parent")) { + if up >= 32 || root == parent { + break; + } + root = parent; + up += 1; + } + roots.push(root); + } + } + Ok(roots) +} + +/// Record the fully-qualified names of one field tree. Cycle- and depth-safe. +fn collect_names( + dst: &Document, + node: ObjRef, + prefix: &str, + seen: &mut std::collections::HashSet, + depth: u8, + out: &mut std::collections::HashSet, +) { + if depth > 32 || !seen.insert(node) { + return; + } + let Some(d) = dst.get(node).as_dict().cloned() else { return }; + let name = d.get(b"T").and_then(|t| dst.resolve(t).as_string().map(|s| s.to_text())).unwrap_or_default(); + let prefix = qualified_optional(prefix, &name); + if !name.is_empty() { + out.insert(prefix.clone()); + } + let kids = d.get(b"Kids").map(|k| dst.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_default(); + for kid in kids.iter().filter_map(|k| k.as_ref()) { + collect_names(dst, kid, &prefix, seen, depth + 1, out); + } +} + +/// `prefix.name`, or `name` at the top level. +fn qualified(prefix: &str, name: &str) -> String { + if prefix.is_empty() { name.to_string() } else { format!("{prefix}.{name}") } +} + +/// The prefix a node's children hang under: its own name, when it has one. +fn qualified_optional(prefix: &str, name: &str) -> String { + if name.is_empty() { prefix.to_string() } else { qualified(prefix, name) } +} + +/// Add `kid` to `field`'s `/Kids`, in the array object itself when `/Kids` is one, so the field +/// keeps its other widgets. +pub(super) fn add_kid(dst: &mut Document, field: ObjRef, kid: ObjRef) -> Result<(), OrganizeError> { + match dst.get(field).as_dict().and_then(|f| f.get(b"Kids").cloned()) { + Some(Object::Ref(list)) => { + let mut kids = dst.get(list).as_array().cloned().unwrap_or_default(); + kids.push(Object::Ref(kid)); + dst.set(list, Object::Array(kids)); + } + _ => dst.update_dict(field, |f| { + let mut kids = f.get(b"Kids").and_then(|k| k.as_array()).cloned().unwrap_or_default(); + kids.push(Object::Ref(kid)); + f.set(b"Kids".to_vec(), Object::Array(kids)); + })?, + } + Ok(()) +} + +/// PdfCraft patch (#1810): split a widget that doubles as its own field into a parent field +/// holding the field keys (name, value, flags…) and a widget child holding the annotation keys +/// (rectangle, appearance, page). The parent takes the widget's place among the top-level fields; +/// later copies of the page add widget children of it, so every page has its own widget and the +/// field stays one. +pub(super) fn split_own_field(dst: &mut Document, fields: &mut [ObjRef], widget: ObjRef) { + let Some(d) = dst.get(widget).as_dict().cloned() else { return }; + let (mut field, mut child) = field_and_widget(dst, &d); + let parent = dst.add(Object::Null); + field.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(widget)])); + child.set(b"Parent".to_vec(), Object::Ref(parent)); + dst.set(parent, Object::Dict(field)); + dst.set(widget, Object::Dict(child)); + if let Some(entry) = fields.iter_mut().find(|f| **f == widget) { + *entry = parent; + } +} + +/// A combined field/widget has two additional-action namespaces (ISO 32000-1 tables 194/196). +/// Partition them as well as the ordinary entries, including when copying a later widget. +pub(super) fn field_and_widget(doc: &Document, d: &Dict) -> (Dict, Dict) { + let (mut field, mut child) = (Dict::new(), Dict::new()); + for (k, v) in d.iter() { + if k.as_slice() == b"AA" + && let Some(actions) = doc.resolve(v).as_dict().cloned() + { + let (mut field_actions, mut widget_actions) = (Dict::new(), Dict::new()); + for (trigger, action) in actions.iter() { + if [b"E".as_slice(), b"X", b"D", b"U", b"Fo", b"Bl", b"PO", b"PC", b"PV", b"PI"].contains(&trigger.as_slice()) { + widget_actions.set(trigger.clone(), action.clone()); + } else { + field_actions.set(trigger.clone(), action.clone()); + } + } + if field_actions.iter().next().is_some() { + field.set(k.clone(), Object::Dict(field_actions)); + } + if widget_actions.iter().next().is_some() { + child.set(k.clone(), Object::Dict(widget_actions)); + } + } else if WIDGET_KEYS.contains(&k.as_slice()) { + child.set(k.clone(), v.clone()); + } else { + field.set(k.clone(), v.clone()); + } + } + (field, child) +} + +/// Add top-level fields to the destination's `/AcroForm /Fields` (creating the form if needed). +pub(super) fn register_fields(dst: &mut Document, fields: &[ObjRef]) -> Result<(), OrganizeError> { + if fields.is_empty() { + return Ok(()); + } + let root = dst.root().ok_or(OrganizeError::NoPageTree)?; + let catalog = dst.get(root).as_dict().cloned().ok_or(OrganizeError::NoPageTree)?; + let (form_ref, mut form) = match catalog.get(b"AcroForm") { + Some(Object::Ref(r)) => (Some(*r), dst.get(*r).as_dict().cloned().unwrap_or_default()), + Some(Object::Dict(d)) => (None, d.clone()), + _ => (None, Dict::new()), + }; + let mut list = form.get(b"Fields").map(|f| dst.resolve(f)).and_then(|f| f.as_array().cloned()).unwrap_or_default(); + let mut listed: HashSet = list.iter().filter_map(Object::as_ref).collect(); + for f in fields { + #[cfg(test)] + crate::work(1); + if listed.insert(*f) { + list.push(Object::Ref(*f)); + } + } + form.set(b"Fields".to_vec(), Object::Array(list)); + match form_ref { + Some(r) => dst.set(r, Object::Dict(form)), + None => { + let r = dst.add(form); + dst.update_dict(root, |c| c.set(b"AcroForm".to_vec(), Object::Ref(r)))?; + } + } + Ok(()) +} diff --git a/crates/organize/src/lib.rs b/crates/organize/src/lib.rs index 9f8cc5c457..e24056a9c3 100644 --- a/crates/organize/src/lib.rs +++ b/crates/organize/src/lib.rs @@ -672,6 +672,11 @@ pub fn info(doc: &Document, key: &str) -> Option { in_info.filter(|v| !v.trim_matches('\0').trim().is_empty()).or_else(|| xmp::info_value(doc, key)) } +/// Whether the document information has an entry `key`, of any type. +pub fn has_info(doc: &Document, key: &str) -> bool { + doc.trailer().get(b"Info").map(|o| doc.resolve(o)).is_some_and(|info| info.as_dict().is_some_and(|d| d.get(key.as_bytes()).is_some())) +} + /// Set (or clear, with an empty value) a document-information entry. /// /// Documents with XMP metadata also carry Title, Author, Subject, Keywords, Creator and Producer @@ -699,12 +704,18 @@ fn set_info_entry(doc: &mut Document, key: &[u8], entry: Option) -> Resu })?; } _ => { - let Some(v) = entry else { return Ok(()) }; let mut d = match doc.trailer().get(b"Info") { Some(Object::Dict(d)) => d.clone(), _ => Dict::new(), }; - d.set(key.to_vec(), v); + match entry { + Some(v) => d.set(key.to_vec(), v), + None => { + if d.remove(key).is_none() { + return Ok(()); + } + } + } let r = doc.add(d); doc.trailer_mut().set(b"Info".to_vec(), Object::Ref(r)); } diff --git a/crates/organize/src/outline.rs b/crates/organize/src/outline.rs index 4bcfe93ae3..2bb4122d2f 100644 --- a/crates/organize/src/outline.rs +++ b/crates/organize/src/outline.rs @@ -265,6 +265,15 @@ fn explicit_dest(doc: &Document, targets: &mut Targets, dest: &Object, depth: u8 } } +/// The explicit destination array `dest` stands for: itself, a dictionary whose `/D` is one, or a +/// named destination (a name or string, ISO 32000-2 §12.3.2.4) looked up in the `/Names /Dests` +/// tree, then the catalog `/Dests`. `None` when a name names nothing. +pub(crate) fn resolve_dest(doc: &Document, dest: &Object) -> Option> { + // Names only: the page map is not needed to look one up. + let mut targets = Targets { pages: HashMap::new(), names: None }; + explicit_dest(doc, &mut targets, dest, 0) +} + /// The page index an explicit destination array names. fn page_of_explicit(targets: &Targets, dest: &[Object]) -> Option { match dest.first()? { @@ -553,10 +562,7 @@ pub fn add_bookmark_at( let at = index.min(kids.len()); kids.insert(at, item); relink(doc, parent, &kids)?; - // Show the new bookmark: open its parent (the root is always open). - if !parent_path.is_empty() && doc.get(parent).as_dict().and_then(|d| d.int(b"Count")).is_none_or(|c| c <= 0) { - put(doc, parent, b"Count", Some(Object::Int(1)))?; - } + show_children(doc, parent, parent_path)?; recount(doc)?; let mut path = parent_path.to_vec(); path.push(at); @@ -699,11 +705,23 @@ pub fn move_bookmark(doc: &mut Document, from: &[usize], to_parent: &[usize], in let at = index.min(kids.len()); kids.insert(at, r); relink(doc, parent, &kids)?; + if parent != old_parent { + show_children(doc, parent, &to_parent)?; + } recount(doc)?; to_parent.push(at); Ok(to_parent) } +/// Open `parent` so a bookmark just added or moved into it shows (the root is always open). +/// Only the sign of `/Count` matters here: [`recount`] sets its value. +fn show_children(doc: &mut Document, parent: ObjRef, parent_path: &[usize]) -> Result<()> { + if !parent_path.is_empty() && doc.get(parent).as_dict().and_then(|d| d.int(b"Count")).is_none_or(|c| c <= 0) { + put(doc, parent, b"Count", Some(Object::Int(1)))?; + } + Ok(()) +} + /// Expand or collapse a bookmark that has children. pub fn set_bookmark_open(doc: &mut Document, path: &[usize], open: bool) -> Result<()> { let (r, _) = resolve_path(doc, path)?; diff --git a/crates/organize/src/tests.rs b/crates/organize/src/tests.rs index 02cee53d49..1fd60d744a 100644 --- a/crates/organize/src/tests.rs +++ b/crates/organize/src/tests.rs @@ -528,6 +528,29 @@ fn custom_document_properties_are_listed_set_removed_and_their_names_checked() { assert!(custom_info(&doc).is_empty()); } +/// An `/Info` dictionary written directly in the trailer: a cleared entry is removed, and the +/// other entries are kept. +#[test] +fn document_info_entries_are_cleared_from_an_info_dictionary_in_the_trailer() { + let b = build( + &[ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R >>".into(), + body("X"), + ], + "/Root 1 0 R /Info << /Title (Report) /Project (Apollo) /Revision (7) >>", + ); + let mut doc = open(b); + set_info(&mut doc, "Missing", "").unwrap(); + assert!(matches!(doc.trailer().get(b"Info"), Some(Object::Dict(_))), "clearing an absent entry changes nothing"); + set_info(&mut doc, "Project", "").unwrap(); + set_info(&mut doc, "Title", "").unwrap(); + let doc = save_and_reopen(&doc); + assert_eq!(custom_info(&doc), [("Revision".to_string(), "7".to_string())]); + assert_eq!(info(&doc, "Title"), None); +} + /// Padding is not part of a custom value, in any mix of NULs and whitespace, a value that is only /// padding is not listed, and such entries don't count towards the 256 listed: a real property /// after 300 blank ones is still there. @@ -1725,6 +1748,18 @@ fn bookmarks_add_rename_move_delete() { assert_eq!(crate::move_bookmark(&mut d, &[0], &[2], 9).unwrap(), vec![1, 1]); assert_eq!(titles(&crate::bookmarks(&d)), ["Intro", "Results[Table,Figure 1]"]); assert_eq!(crate::move_bookmark(&mut d, &[1], &[1, 0], 0), Err(crate::OutlineError::IntoItself)); + // Into a bookmark with no children yet (Indent): it opens, so the moved one shows. + assert_eq!(crate::move_bookmark(&mut d, &[1, 1], &[0], 0).unwrap(), vec![0, 0]); + let intro = crate::bookmarks(&d)[0].obj; + assert_eq!(count_of(&d, intro), Some(1)); + // Reordering inside a closed parent leaves it closed. + crate::add_bookmark(&mut d, &[0], 1, "Second", 0).unwrap(); + crate::set_bookmark_open(&mut d, &[0], false).unwrap(); + crate::move_bookmark(&mut d, &[0, 1], &[0], 0).unwrap(); + assert_eq!(count_of(&d, intro), Some(-2)); + crate::delete_bookmark(&mut d, &[0, 0]).unwrap(); + crate::move_bookmark(&mut d, &[0, 0], &[1], 9).unwrap(); + assert_eq!(titles(&crate::bookmarks(&d)), ["Intro", "Results[Table,Figure 1]"]); crate::delete_bookmark(&mut d, &[1, 0]).unwrap(); assert_eq!(titles(&crate::bookmarks(&d)), ["Intro", "Results[Figure 1]"]); @@ -2392,6 +2427,125 @@ fn initial_view_round_trips_and_keeps_scripts() { assert!(crate::set_initial_view(&mut doc, &crate::InitialView { page: 9, ..Default::default() }).is_err()); } +#[test] +fn an_unchanged_opening_destination_keeps_its_position_and_name() { + use crate::view::{Magnification, Navigation}; + let page2 = Object::Ref(pdfcraft_cos::ObjRef::new(5, 0)); + let xyz = Object::Array(vec![page2.clone(), Object::name("XYZ"), Object::Int(100), Object::Int(500), Object::Int(2)]); + let mut named = pdfcraft_cos::Dict::new(); + named.set(b"S".to_vec(), Object::name("GoTo")); + named.set(b"D".to_vec(), Object::name("there")); + let mut dests = pdfcraft_cos::Dict::new(); + dests.set(b"there".to_vec(), Object::Array(vec![page2, Object::name("Fit")])); + let mut prefs = pdfcraft_cos::Dict::new(); + prefs.set(b"NonFullScreenPageMode".to_vec(), Object::name("UseOutlines")); + for action in [xyz, Object::Dict(named)] { + let mut doc = open(fixture()); + let root = doc.root().unwrap(); + doc.update_dict(root, |c| { + c.set(b"OpenAction".to_vec(), action.clone()); + c.set(b"Dests".to_vec(), Object::Dict(dests.clone())); + c.set(b"PageMode".to_vec(), Object::name("FullScreen")); + c.set(b"ViewerPreferences".to_vec(), Object::Dict(prefs.clone())); + }) + .unwrap(); + // Another option changes: the opening action is written back as it was, and so is the + // full-screen panel, which the dialog doesn't edit. + let v = crate::initial_view(&doc); + assert_eq!(v.non_full_screen, Navigation::Bookmarks); + crate::set_initial_view(&mut doc, &crate::InitialView { hide_toolbar: true, ..v.clone() }).unwrap(); + let catalog = doc.get(root).as_dict().cloned().unwrap(); + assert_eq!(catalog.get(b"OpenAction"), Some(&action)); + let doc = save_and_reopen(&doc); + assert_eq!(crate::initial_view(&doc), crate::InitialView { hide_toolbar: true, ..v.clone() }); + // A changed magnification writes a destination of its own. + let mut doc = doc; + crate::set_initial_view(&mut doc, &crate::InitialView { magnification: Magnification::FitWidth, ..v }).unwrap(); + let catalog = doc.get(root).as_dict().cloned().unwrap(); + assert_ne!(catalog.get(b"OpenAction"), Some(&action)); + assert_eq!(crate::initial_view(&doc).magnification, Magnification::FitWidth); + } +} + +/// A document whose `/OpenAction` is `action`, with the named destination `there` (page 3 at +/// 200%) in the catalog `/Dests` and `here` (page 1, fit) in a `/Names /Dests` tree. +fn doc_opening_at(action: Object) -> Document { + let page1 = Object::Ref(pdfcraft_cos::ObjRef::new(4, 0)); + let page3 = Object::Ref(pdfcraft_cos::ObjRef::new(6, 0)); + let mut dests = pdfcraft_cos::Dict::new(); + dests.set(b"there".to_vec(), Object::Array(vec![page3, Object::name("XYZ"), Object::Null, Object::Null, Object::Int(2)])); + let mut tree = pdfcraft_cos::Dict::new(); + tree.set(b"Names".to_vec(), Object::Array(vec![Object::String(PdfString::text("here")), Object::Array(vec![page1, Object::name("Fit")])])); + let mut names = pdfcraft_cos::Dict::new(); + names.set(b"Dests".to_vec(), Object::Dict(tree)); + let mut doc = open(fixture()); + let root = doc.root().unwrap(); + doc.update_dict(root, |c| { + c.set(b"OpenAction".to_vec(), action); + c.set(b"Dests".to_vec(), Object::Dict(dests)); + c.set(b"Names".to_vec(), Object::Dict(names)); + }) + .unwrap(); + doc +} + +fn open_action(doc: &Document) -> Option { + doc.get(doc.root().unwrap()).as_dict().and_then(|c| c.get(b"OpenAction").cloned()) +} + +/// ISO 32000-2 §12.3.2.4: a named destination stands for the explicit one it names. Choosing +/// page 1 in Document Properties on a document that opens at a named destination elsewhere +/// writes a destination to page 1; it used to read the name as page 1 and change nothing. +#[test] +fn choosing_page_one_replaces_a_named_opening_destination() { + use crate::view::Magnification; + let page1 = Object::Ref(pdfcraft_cos::ObjRef::new(4, 0)); + let mut goto = pdfcraft_cos::Dict::new(); + goto.set(b"S".to_vec(), Object::name("GoTo")); + goto.set(b"D".to_vec(), Object::name("there")); + for action in [Object::Dict(goto), Object::name("there"), Object::String(PdfString::text("there"))] { + let mut doc = doc_opening_at(action.clone()); + // The dialog shows where the document really opens. + let v = crate::initial_view(&doc); + assert_eq!((v.page, v.magnification), (2, Magnification::Percent(200.0)), "{action:?}"); + // Page 1 at the same magnification: an explicit destination to page 1. + crate::set_initial_view(&mut doc, &crate::InitialView { page: 0, ..v.clone() }).unwrap(); + let written = Object::Array(vec![page1.clone(), Object::name("XYZ"), Object::Null, Object::Null, Object::Real(2.0)]); + assert_eq!(open_action(&doc), Some(written), "{action:?}"); + let reopened = save_and_reopen(&doc); + assert_eq!(crate::initial_view(&reopened), crate::InitialView { page: 0, ..v.clone() }, "{action:?}"); + // Page 1 at the default magnification: no opening action, so the first page opens. + let mut doc = doc_opening_at(action.clone()); + crate::set_initial_view(&mut doc, &crate::InitialView { page: 0, magnification: Magnification::Default, ..v }).unwrap(); + assert_eq!(open_action(&doc), None, "{action:?}"); + assert_eq!(crate::initial_view(&save_and_reopen(&doc)).page, 0, "{action:?}"); + } +} + +/// A named opening destination that already goes to the chosen page and magnification is kept, +/// and so is one that names nothing (it reads as page 1) until another page is chosen. +#[test] +fn a_named_opening_destination_is_kept_while_it_still_holds() { + use crate::view::Magnification; + let page2 = Object::Ref(pdfcraft_cos::ObjRef::new(5, 0)); + for (action, magnification) in + [(Object::String(PdfString::text("here")), Magnification::FitPage), (Object::name("nowhere"), Magnification::Default)] + { + let mut doc = doc_opening_at(action.clone()); + let v = crate::initial_view(&doc); + assert_eq!((v.page, v.magnification), (0, magnification), "{action:?}"); + crate::set_initial_view(&mut doc, &crate::InitialView { hide_toolbar: true, ..v.clone() }).unwrap(); + assert_eq!(open_action(&doc), Some(action.clone()), "{action:?}"); + crate::set_initial_view(&mut doc, &crate::InitialView { page: 1, ..v.clone() }).unwrap(); + let kind = if magnification == Magnification::FitPage { "Fit" } else { "XYZ" }; + assert_eq!( + open_action(&doc).as_ref().and_then(Object::as_array).map(|a| (a.first().cloned(), a.get(1).cloned())), + Some((Some(page2.clone()), Some(Object::name(kind)))), + "{action:?}" + ); + } +} + // ── Split / Extract keep only the resources the part draws (#204) ────────────────────────────── /// A content stream with no text operators (an image draw). diff --git a/crates/organize/src/view.rs b/crates/organize/src/view.rs index 0f115d3051..899293b980 100644 --- a/crates/organize/src/view.rs +++ b/crates/organize/src/view.rs @@ -63,6 +63,10 @@ pub struct InitialView { pub language: Option, /// Right-edge binding (`/Direction /R2L`). pub right_to_left: bool, + /// The navigation panel on leaving full screen (`/ViewerPreferences /NonFullScreenPageMode`, + /// ISO 32000-2 Table 147), read only when `full_screen` is set. It is read, not written: + /// [`set_initial_view`] leaves the entry as it is. + pub non_full_screen: Navigation, } fn catalog(doc: &Document) -> Result<(pdfcraft_cos::ObjRef, Dict), OrganizeError> { @@ -104,11 +108,22 @@ pub fn initial_view(doc: &Document) -> InitialView { v.hide_toolbar = flag(b"HideToolbar"); v.hide_window_ui = flag(b"HideWindowUI"); v.right_to_left = name(&vp, b"Direction").as_deref() == Some(b"R2L"); + // Table 147: meaningful only with `/PageMode /FullScreen`, and only these four values. + if v.full_screen { + v.non_full_screen = match name(&vp, b"NonFullScreenPageMode").as_deref() { + Some(b"UseOutlines") => Navigation::Bookmarks, + Some(b"UseThumbs") => Navigation::Pages, + Some(b"UseOC") => Navigation::Layers, + _ => Navigation::PageOnly, + }; + } } - // The opening destination: an explicit array, or a GoTo action's. + // The opening destination: an explicit array, a GoTo action's `/D`, or a named destination (a + // name or string, §12.3.2.4) as the explicit one it names, so that the page and magnification + // read here are where the document opens. A name that names nothing reads as page 1. let dest = c.get(b"OpenAction").map(|o| doc.resolve(o)).and_then(|o| match &*o { - Object::Array(a) => Some(a.clone()), - Object::Dict(d) if d.name(b"S") == Some(b"GoTo") => d.get(b"D").map(|x| doc.resolve(x)).and_then(|x| x.as_array().cloned()), + Object::Array(_) | Object::Name(_) | Object::String(_) => crate::outline::resolve_dest(doc, &o), + Object::Dict(d) if d.name(b"S") == Some(b"GoTo") => d.get(b"D").and_then(|x| crate::outline::resolve_dest(doc, x)), _ => None, }); if let Some(d) = dest { @@ -149,8 +164,13 @@ pub fn displays_doc_title(doc: &Document) -> bool { } /// Write the initial view. An opening action that isn't a plain destination (a script, say) is -/// kept unless the page or magnification asks for a destination. +/// kept unless the page or magnification asks for a destination, and an opening action whose page +/// and magnification `v` leaves as [`initial_view`] reads them is kept as it is. pub fn set_initial_view(doc: &mut Document, v: &InitialView) -> Result<(), OrganizeError> { + let same_destination = { + let now = initial_view(doc); + now.page == v.page && now.magnification == v.magnification + }; let (root, c) = catalog(doc)?; let pages = crate::walk(doc)?; if v.page >= pages.len() { @@ -177,9 +197,12 @@ pub fn set_initial_view(doc: &mut Document, v: &InitialView) -> Result<(), Organ } else { vp.remove(b"Direction"); } + // A destination, explicit or named (§12.3.2.4), or a GoTo action to one. let open_action_is_dest = match c.get(b"OpenAction").map(|o| doc.resolve(o)) { None => true, - Some(o) => matches!(&*o, Object::Array(_)) || o.as_dict().is_some_and(|d| d.name(b"S") == Some(b"GoTo")), + Some(o) => { + matches!(&*o, Object::Array(_) | Object::Name(_) | Object::String(_)) || o.as_dict().is_some_and(|d| d.name(b"S") == Some(b"GoTo")) + } }; let dest = match v.magnification { Magnification::Default if v.page == 0 => None, @@ -238,6 +261,9 @@ pub fn set_initial_view(doc: &mut Document, v: &InitialView) -> Result<(), Organ } } match (&dest, open_action_is_dest) { + // The opening page and magnification are as read: the action stays as written, with + // its position or named destination (§12.3.2.4) that the view model doesn't hold. + _ if same_destination => {} (Some(d), _) => c.set(b"OpenAction".to_vec(), Object::Array(d.clone())), (None, true) => { c.remove(b"OpenAction"); @@ -294,4 +320,40 @@ mod tests { assert_eq!(flags(initial_view(&doc)), [false; 6]); } } + + /// A catalog with `/PageMode page_mode` and `/ViewerPreferences << /NonFullScreenPageMode mode >>`. + fn with_modes(page_mode: &str, mode: Object) -> Document { + let mut doc = Document::new_empty(); + let mode = doc.add(mode); + let mut preferences = Dict::new(); + preferences.set(b"NonFullScreenPageMode".to_vec(), Object::Ref(mode)); + let root = doc.root().unwrap(); + doc.update_dict(root, |c| { + c.set(b"PageMode".to_vec(), Object::name(page_mode)); + c.set(b"ViewerPreferences".to_vec(), Object::Dict(preferences)); + }) + .unwrap(); + doc + } + + #[test] + fn non_full_screen_page_mode_is_read_with_full_screen_only() { + for (mode, want) in [ + ("UseOutlines", Navigation::Bookmarks), + ("UseThumbs", Navigation::Pages), + ("UseOC", Navigation::Layers), + ("UseNone", Navigation::PageOnly), + // Not a value Table 147 lists. + ("UseAttachments", Navigation::PageOnly), + ("FullScreen", Navigation::PageOnly), + ] { + let v = initial_view(&with_modes("FullScreen", Object::name(mode))); + assert!(v.full_screen); + assert_eq!(v.non_full_screen, want, "{mode}"); + // Ignored unless the document opens in full screen. + let v = initial_view(&with_modes("UseThumbs", Object::name(mode))); + assert_eq!((v.navigation, v.non_full_screen), (Navigation::Pages, Navigation::PageOnly), "{mode}"); + } + assert_eq!(initial_view(&with_modes("FullScreen", Object::Int(1))).non_full_screen, Navigation::PageOnly); + } } diff --git a/crates/platform/README.md b/crates/platform/README.md index ea7af7a079..fea358e508 100644 --- a/crates/platform/README.md +++ b/crates/platform/README.md @@ -8,4 +8,4 @@ Synthetic tests pin both name forms, the 128-byte cut, the attempt limit and ref On macOS, `write_atomically_with` copies the existing regular destination's accessible extended attributes to the open staging file after writing the data and before flushing and renaming. Finder tags, comments, binary values and empty attributes therefore survive both incremental and full saves (#2658). Save As retains attributes of an existing destination; a new destination does not inherit metadata from the source PDF. Missing files and filesystems without xattr support keep working. The metadata read does not follow the destination's symlink or block on a FIFO. macOS manages the new file's `com.apple.provenance`; it is not copied. Kernel-hidden compression attributes are not requested. -Attribute copying is bounded: 64 KiB of names, 1024 attributes, 16 MiB per value and 64 MiB total. Each value must fill the initially queried size and retain that size in a second query after reading: a resource fork may return a partial prefix on growth rather than an error. A read/copy error or exceeded bound returns an error suggesting Save As; the original bytes and metadata stay intact and the staging file is removed. Flush errors retain the same guarantee. Tests cover exact metadata before flush and after repeated saves, empty/binary values, oversized resource forks, a simulated partial read of a growing fork and symlink isolation. Automation covers incremental/full saves, Save As and retaining an unsaved edit after metadata refusal. The staging writer also retains Unix permissions and, where supported, Windows/macOS creation dates; attribute copying runs before the creation date is restored. ACLs and hard-link identity are not preserved. +Attribute copying is bounded: 64 KiB of names, 1024 attributes, 16 MiB per value and 64 MiB total. Each value must fill the initially queried size and retain that size in a second query after reading: a resource fork may return a partial prefix on growth rather than an error. A read/copy error or exceeded bound returns an error suggesting Save As; the original bytes and metadata stay intact and the staging file is removed. Flush errors retain the same guarantee. Tests cover exact metadata before flush and after repeated saves, empty/binary values, oversized resource forks, a simulated partial read of a growing fork and symlink isolation. Automation covers incremental/full saves, Save As and retaining an unsaved edit after metadata refusal. The staging writer also retains Unix permissions, the Windows hidden and system attributes (the staging file is created with them, so a hidden file is never published visible and a plain one never hidden, #4593) and, where supported, Windows/macOS creation dates; attribute copying runs before the creation date is restored. ACLs and hard-link identity are not preserved. diff --git a/crates/platform/src/staging.rs b/crates/platform/src/staging.rs index c0e8f990bc..4316051352 100644 --- a/crates/platform/src/staging.rs +++ b/crates/platform/src/staging.rs @@ -34,7 +34,8 @@ pub fn create_staging( /// [`create_staging`], giving the new file the mode and group of the file `keep` describes on /// Unix. It is created readable and writable by its owner only and then given them, so its mode /// is never more open than that of the file it will replace, not even while it is still empty (a -/// handle opened then would keep reading what is written later). The owner is the saver's. +/// handle opened then would keep reading what is written later). The owner is the saver's. On +/// Windows it is created with that file's [`KEPT_ATTRIBUTES`]. fn create_staging_as( dir: &Path, name: &str, @@ -63,8 +64,15 @@ fn create_staging_as( use std::os::unix::fs::OpenOptionsExt as _; options.mode(0o600); } + // Given as the file is created, so a hidden target's staging file is never visible. + #[cfg(windows)] + if let Some(keep) = keep { + use std::os::windows::fs::{MetadataExt as _, OpenOptionsExt as _}; + options.attributes(keep.file_attributes() & KEPT_ATTRIBUTES); + } match options.open(&tmp) { Ok(file) => { + #[cfg(unix)] if let Some(keep) = keep { // A volume that can't change modes (FAT, some network shares) leaves the file // owner-only: stricter than the file it replaces, never looser. @@ -78,9 +86,18 @@ fn create_staging_as( Err(e) => return Err(e), } } + #[cfg(not(any(unix, windows)))] + let _ = keep; Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, "every temporary file name tried is taken")) } +/// The Windows attributes a replaced file passes on to its staging file (#4593): +/// `FILE_ATTRIBUTE_HIDDEN` and `FILE_ATTRIBUTE_SYSTEM`, the two that say who should see the +/// file. A read-only file is refused before anything is staged, and the archive bit is the +/// system's to set on a changed file; the other attributes describe how the volume stores it. +#[cfg(windows)] +const KEPT_ATTRIBUTES: u32 = 0x2 | 0x4; + /// The permissions a staging file gets to replace the file `keep` describes. The mode's group bits /// are meant for that file's group, so the staging file joins it first. Where it can't (the saver /// isn't a member), group and others get nothing: someone the group bits shut out would @@ -96,11 +113,6 @@ fn kept_permissions(file: &std::fs::File, keep: &std::fs::Metadata) -> std::fs:: mode } -#[cfg(not(unix))] -fn kept_permissions(_: &std::fs::File, keep: &std::fs::Metadata) -> std::fs::Permissions { - keep.permissions() -} - /// Unpredictable staging-name suffixes, so a name can't be planted in advance. Each is the /// standard library's keyed hash of a counter (`RandomState::hash_one`, currently SipHash-1-3), /// keyed from the operating system's random source. That is all a staging name needs, but it is @@ -119,7 +131,8 @@ pub fn staging_suffixes() -> impl Iterator { /// a folder at the target is refused by the rename. Flushing is the staging file's own /// (`sync_all`); parent-directory durability after a power loss is not promised. On Unix a file /// that is replaced keeps its permissions and group, so a private file stays private; a new file gets the -/// usual mode for a new file. On Windows and macOS a replaced file keeps its creation date, where +/// usual mode for a new file. On Windows a replaced file stays hidden or system if it was, and +/// plain if it was plain. On Windows and macOS a replaced file keeps its creation date, where /// the volume stores one. /// On macOS, accessible extended attributes of an existing regular destination are copied /// before flushing; a copy failure refuses the save rather than losing metadata (#2658). @@ -146,10 +159,14 @@ pub fn write_atomically_with( } let dir = path.parent().filter(|d| !d.as_os_str().is_empty()).unwrap_or(Path::new(".")); let name = path.file_name().and_then(std::ffi::OsStr::to_str).unwrap_or("save"); - // A file that is replaced keeps its permissions (#1790) and group. Unix only: Windows is as before. + // A file that is replaced keeps its permissions (#1790) and group on Unix, and its hidden and + // system attributes on Windows (#4593). A save replaces a symbolic link, not the file it + // points to, so on Windows the attributes are the link's own. #[cfg(unix)] let keep = std::fs::metadata(path).ok(); - #[cfg(not(unix))] + #[cfg(windows)] + let keep = std::fs::symlink_metadata(path).ok(); + #[cfg(not(any(unix, windows)))] let keep = None; // A saved file is the same document: it keeps its creation date (#2658). #[cfg(any(windows, target_os = "macos"))] @@ -275,390 +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); - } -} +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/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..1257bcf25e --- /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::*; + +// 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..7c9554f25e --- /dev/null +++ b/crates/print/src/settings.rs @@ -0,0 +1,250 @@ +use super::*; + +#[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..ce80adc3eb --- /dev/null +++ b/crates/print/src/sheets.rs @@ -0,0 +1,350 @@ +use super::error::PrintError; +use super::imposition::decoded_contents; +use super::settings::{Binding, BookletSubset, Layout, Orientation, PAPERS, PageOrder, Settings, SizeMode}; +use super::*; + +/// 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 } => { + 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) +} diff --git a/crates/print/src/tests.rs b/crates/print/src/tests.rs index bc0454c999..e6432d8aec 100644 --- a/crates/print/src/tests.rs +++ b/crates/print/src/tests.rs @@ -283,8 +283,8 @@ fn spooler_arguments_and_printer_list() { assert_eq!( parse_lpstat(out), [ - spool::Printer { name: "Office_Laser".into(), default: true, two_sided: None }, - spool::Printer { name: "Label_Writer".into(), default: false, two_sided: None } + spool::Printer { name: "Office_Laser".into(), default: true, two_sided: None, description: String::new() }, + spool::Printer { name: "Label_Writer".into(), default: false, two_sided: None, description: String::new() } ] ); assert!(parse_lpstat("lpstat: No destinations added.\nno system default destination\n").is_empty()); @@ -327,18 +327,21 @@ fn driverless_printers_join_the_list_without_a_queue() { assert_eq!( spool::printers_parsed(queues, Some("Basement_Color\nOffice_Laser\n")), [ - spool::Printer { name: "Office_Laser".into(), default: true, two_sided: None }, - spool::Printer { name: "Basement_Color".into(), default: false, two_sided: None }, + spool::Printer { name: "Office_Laser".into(), default: true, two_sided: None, description: String::new() }, + spool::Printer { name: "Basement_Color".into(), default: false, two_sided: None, description: String::new() }, ], "queues first, then the driverless destination; the queue listed twice is not doubled" ); // An old spooler without `-e` (CUPS < 1.7) keeps its queues; only the driverless names are lost. - assert_eq!(spool::printers_parsed(queues, None), [spool::Printer { name: "Office_Laser".into(), default: true, two_sided: None }]); + assert_eq!( + spool::printers_parsed(queues, None), + [spool::Printer { name: "Office_Laser".into(), default: true, two_sided: None, description: String::new() }] + ); // The default can itself be driverless (`lpoptions -d` names a destination no queue exists // for): it keeps its marker, so the dialog preselects the user's default, not the first queue. assert_eq!( spool::printers_parsed("system default destination: Basement_Color\n", Some("Basement_Color\n")), - [spool::Printer { name: "Basement_Color".into(), default: true, two_sided: None }], + [spool::Printer { name: "Basement_Color".into(), default: true, two_sided: None, description: String::new() }], "a default destination without a queue is still the default" ); assert!(spool::parse_lpstat_e("\n \n").is_empty(), "no destinations is empty, not [\"\"]"); @@ -414,17 +417,17 @@ fn windows_printers_carry_one_default_and_keep_their_names() { // Windows marks the system default with a `*` no queue name may contain; names hold spaces, // parentheses and non-ASCII letters, and the lines end with CRLF. let printers = parse_windows_printers("*Microsoft Print to PDF\r\nHP LaserJet 400 (Copy 2)\r\nКопир Canon iR\r\n\r\n*XPS via USB\r\n"); - assert_eq!(printers[0], Printer { name: "Microsoft Print to PDF".into(), default: true, two_sided: None }); - assert_eq!(printers[1], Printer { name: "HP LaserJet 400 (Copy 2)".into(), default: false, two_sided: None }); - assert_eq!(printers[2], Printer { name: "Копир Canon iR".into(), default: false, two_sided: None }); + assert_eq!(printers[0], Printer { name: "Microsoft Print to PDF".into(), default: true, two_sided: None, description: String::new() }); + assert_eq!(printers[1], Printer { name: "HP LaserJet 400 (Copy 2)".into(), default: false, two_sided: None, description: String::new() }); + assert_eq!(printers[2], Printer { name: "Копир Canon iR".into(), default: false, two_sided: None, description: String::new() }); // A spooler marking two defaults: the first still wins, so the dialog has one to preselect. - assert_eq!(printers[3], Printer { name: "XPS via USB".into(), default: false, two_sided: None }); + assert_eq!(printers[3], Printer { name: "XPS via USB".into(), default: false, two_sided: None, description: String::new() }); assert_eq!(parse_windows_printers("\r\n"), Vec::new()); // After a tab, whether the driver reports two-sided printing; a tab inside a name stays. let sided = parse_windows_printers("*Office PCL\t2\r\nOffice PS\t1\r\nTab\tName\t2\r\n"); - assert_eq!(sided[0], Printer { name: "Office PCL".into(), default: true, two_sided: Some(true) }); - assert_eq!(sided[1], Printer { name: "Office PS".into(), default: false, two_sided: Some(false) }); - assert_eq!(sided[2], Printer { name: "Tab\tName".into(), default: false, two_sided: Some(true) }); + assert_eq!(sided[0], Printer { name: "Office PCL".into(), default: true, two_sided: Some(true), description: String::new() }); + assert_eq!(sided[1], Printer { name: "Office PS".into(), default: false, two_sided: Some(false), description: String::new() }); + assert_eq!(sided[2], Printer { name: "Tab\tName".into(), default: false, two_sided: Some(true), description: String::new() }); } /// A PPD shaped like a Fiery's: installable options, multi-line PostScript in the choices, Latin-1 diff --git a/crates/redact/src/interp.rs b/crates/redact/src/interp.rs index 3380fece14..71b4684bc7 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,358 +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, content, xo, name, depth + 1, seen) { - Some(false) => {} - other => return other, - } - } - Some(false) -} - -/// Unlink the XObjects a rewrite dropped or replaced (`doomed`) from every page-tree resource -/// dictionary that still names them. The rewritten page has its own pruned /Resources, but the -/// originals stay reachable, and so stay in a full-rewrite save, when: -/// - the page inherited its /Resources from a /Pages node, which still lists them; -/// - several pages share one resource dictionary, and another page still refers to it; -/// - a form without /Resources drew them through the page's dictionary. -/// -/// An entry goes unless a page that draws through that dictionary still draws the name (the same -/// image shown on another page, or again outside a mark). A dictionary that loses an entry is -/// replaced by a private copy on its node, so a dictionary object shared with other nodes is -/// left as it is for them. Retired properties are likewise unlinked only when none of the -/// holder's pages or inheriting forms names them. Fails closed when use cannot be determined. -pub(crate) fn unlink_doomed(doc: &mut Document, doomed: &[ObjRef], retired_properties: &[Object]) -> Result<(), RedactError> { - if doomed.is_empty() && retired_properties.is_empty() { - return Ok(()); - } - let doomed: HashSet = doomed.iter().copied().collect(); - let Some(top) = doc.root().and_then(|root| doc.get(root).as_dict().and_then(|d| d.reference(b"Pages"))) else { return Ok(()) }; - // Every page-tree node with a /Resources of its own, and the pages that draw through it - // (the nearest such node above a page, or the page itself). Cycles are cut as in - // `pdfcraft_model::pages`. - let mut holders: Vec<(ObjRef, Vec)> = Vec::new(); - let mut seen = HashSet::new(); - let mut stack: Vec<(ObjRef, Option)> = vec![(top, None)]; - while let Some((node, mut holder)) = stack.pop() { - if !seen.insert(node) || seen.len() > 1_000_000 { - continue; - } - let obj = doc.get(node); - let Some(d) = obj.as_dict() else { continue }; - if d.contains(b"Resources") { - holders.push((node, Vec::new())); - holder = Some(holders.len() - 1); - } - let is_pages = d.name(b"Type") == Some(b"Pages") || (d.contains(b"Kids") && d.name(b"Type") != Some(b"Page")); - if is_pages { - let kids = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_default(); - stack.extend(kids.iter().filter_map(Object::as_ref).map(|k| (k, holder))); - } else if let Some(h) = holder.and_then(|i| holders.get_mut(i)) { - h.1.push(node); - } - } - let index: HashMap = pdfcraft_model::pages(doc).iter().enumerate().map(|(i, p)| (p.obj, i)).collect(); - let holder_nodes: Vec = holders.iter().map(|(node, _)| *node).collect(); - for (node, users) in holders { - let Some(mut res) = doc.get(node).as_dict().and_then(|d| d.get(b"Resources")).and_then(|r| doc.resolve(r).as_dict().cloned()) else { - continue; - }; - let mut xo = res_dict(doc, &res, b"XObject"); - let names: Vec> = xo.iter().filter(|(_, v)| v.as_ref().is_some_and(|r| doomed.contains(&r))).map(|(n, _)| n.to_vec()).collect(); - let properties = res_dict(doc, &res, b"Properties"); - let property_names: Vec> = properties.iter().filter(|(_, v)| retired_properties.contains(v)).map(|(n, _)| n.to_vec()).collect(); - if names.is_empty() && property_names.is_empty() { - continue; - } - let mut contents = Vec::with_capacity(users.len()); - for user in &users { - let page = doc.get(*user).as_dict().cloned().unwrap_or_default(); - // A page whose content cannot be read may still draw the name: fail closed. - contents.push(page_streams(doc, &page, index.get(user).copied().unwrap_or(0))?.1); - } - let mut pruned = false; - for n in names { - let mut drawn = Some(false); - for content in &contents { - drawn = content_draws(doc, content, &xo, &n, 0, &mut HashSet::new()); - if drawn != Some(false) { - break; - } - } - match drawn { - Some(true) => {} - Some(false) => { - xo.remove(&n); - pruned = true; - } - None => return Err(RedactError::Residue(1)), - } - } - if pruned { - res.set(b"XObject".to_vec(), Object::Dict(xo)); - } - pruned |= prune_retired_properties(doc, &mut res, &property_names, &contents)?; - if pruned { - doc.update_dict(node, |d| d.set(b"Resources".to_vec(), Object::Dict(res)))?; - } - } - // A surviving form can own the same old resource dictionary as a rewritten page. Its - // unused descriptions 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) -} - -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, @@ -884,37 +328,14 @@ fn process_from(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec], } let b = bbox.unwrap_or_default(); changed = true; - if scope.covered(b) { - scope.report.paths_removed += 1; - if clips { - // Keep the clipping, drop the painting. - ops.extend(body); - ops.push(Op::new("n", vec![])); - } - } else if clips { - // A clip-and-paint path can't be wrapped in q/Q without losing its clip: - // paint it clipped, then set its clip with a no-op path. - scope.report.paths_clipped += 1; - if let Some(c) = clip_out(scope.rects, &gs.ctm, b) { - let plain: Vec = body.iter().filter(|p| !p.is("W") && !p.is("W*")).cloned().collect(); - ops.push(Op::new("q", vec![])); - ops.extend(c); - ops.extend(plain); - ops.push(op); - ops.push(Op::new("Q", vec![])); - } + 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![])); - } else { - scope.report.paths_clipped += 1; - if let Some(c) = clip_out(scope.rects, &gs.ctm, b) { - ops.push(Op::new("q", vec![])); - ops.extend(c); - ops.extend(body); - ops.push(op); - ops.push(Op::new("Q", vec![])); - } } + // 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() { @@ -1382,6 +803,10 @@ fn xobject( out.error = Some(error); return None; } + if let Err(error) = crate::type3::prune_type3_from_resources(doc, &mut cleaned_res, content) { + out.error = Some(error); + return None; + } } if cleaned_res != res { dict.set(b"Resources".to_vec(), Object::Dict(cleaned_res)); @@ -1404,45 +829,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..32d7afdf1e --- /dev/null +++ b/crates/redact/src/interp/layers.rs @@ -0,0 +1,256 @@ +use super::*; + +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..2708a87a42 --- /dev/null +++ b/crates/redact/src/interp/prune.rs @@ -0,0 +1,354 @@ +use super::*; + +/// 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 29f23d3c96..6f69151d89 100644 --- a/crates/redact/src/lib.rs +++ b/crates/redact/src/lib.rs @@ -27,6 +27,8 @@ pub mod sanitize; mod tags; #[cfg(test)] mod tests; +pub(crate) mod type3; +pub(crate) mod type3_tables; use interp::{Mode, Scope, process}; @@ -493,6 +495,7 @@ fn apply_to(doc: &mut Document, pages: Option<&[usize]>) -> Result) -> Result) -> Result { - let all = marks(doc); - let doomed: Vec = all.iter().filter(|m| pages.is_none_or(|p| p.contains(&m.page))).map(|m| m.obj).collect(); - if doomed.is_empty() { + let targets: HashSet = marks(doc).iter().filter(|m| pages.is_none_or(|p| p.contains(&m.page))).map(|m| m.obj).collect(); + if targets.is_empty() { return Err(RedactError::NothingToApply); } + let listed: Vec = pdfcraft_model::pages(doc).iter().flat_map(|p| annots_of(doc, &p.dict)).collect(); + let doomed = pdfcraft_annot::with_replies_and_popups(doc, &listed, targets.iter().copied()); for p in pdfcraft_model::pages(doc) { let annots = annots_of(doc, &p.dict); - let kept: Vec = annots - .iter() - .filter(|a| { - let Some(r) = a.as_ref() else { return true }; - let parent = doc.get(r).as_dict().and_then(|d| d.get(b"Parent")).and_then(Object::as_ref); - !doomed.contains(&r) && !parent.is_some_and(|p| doomed.contains(&p)) - }) - .cloned() - .collect(); + let kept: Vec = annots.iter().filter(|a| !pdfcraft_annot::goes_with(a, &doomed)).cloned().collect(); + // As deleting a comment does: a page whose marks were all it had loses `/Annots` again, + // and an indirect array stays the one object the page points to. if kept.len() != annots.len() { - doc.update_dict(p.obj, |d| d.set(b"Annots".to_vec(), Object::Array(kept)))?; + pdfcraft_annot::set_annots(doc, p.obj, kept)?; } } - Ok(doomed.len()) + Ok(targets.len()) } diff --git a/crates/redact/src/sanitize.rs b/crates/redact/src/sanitize.rs index 8625f99c25..98703dfbf2 100644 --- a/crates/redact/src/sanitize.rs +++ b/crates/redact/src/sanitize.rs @@ -424,6 +424,7 @@ fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Re res.set(b"XObject".to_vec(), Object::Dict(xo)); } retired_properties.extend(crate::interp::prune_properties(doc, &mut res, &page_content)?); + crate::type3::prune_type3_from_resources(doc, &mut res, &page_content)?; doc.update_dict(page.obj, |d| { d.set(b"Contents".to_vec(), Object::Array(new_list)); d.set(b"Resources".to_vec(), Object::Dict(res)); @@ -443,7 +444,11 @@ fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Re } // What a page no longer draws must not stay reachable through an inherited or shared // /Resources either. - crate::interp::unlink_doomed(doc, &doomed, &retired_properties)?; + // Only a removal rewrites the document; a count on a clone has nothing to unlink, and a + // cleanup error there would be mistaken for "nothing to remove". + if write { + crate::interp::unlink_doomed(doc, &doomed, &retired_properties)?; + } Ok((total, retained_off_layers)) } @@ -474,7 +479,16 @@ pub fn scan(doc: &Document) -> Vec<(Hidden, usize)> { + owned(Hidden::Attachments) } Hidden::Comments => annots.iter().filter(|a| is_comment(a.name(b"Subtype").unwrap_or(b""))).count(), - Hidden::FormFields => pdfcraft_forms::fields(doc).len(), + Hidden::FormFields => { + let n = pdfcraft_forms::fields(doc).len(); + if n > 0 { + n + } else if cat.contains(b"AcroForm") { + 1 + } else { + 0 + } + } Hidden::HiddenText => content_pass(&mut doc2, true, false, false).map(|(n, _)| n).unwrap_or(0), Hidden::HiddenLayers => { let off = hidden_layers(doc).len(); @@ -510,7 +524,8 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result 0; + let has_acroform = doc.get(root).as_dict().is_some_and(|d| d.contains(b"AcroForm")); + let fields = which.contains(&Hidden::FormFields) && (count(Hidden::FormFields) > 0 || has_acroform); if fields { // Fields without appearances get one first, so their values stay visible. for f in pdfcraft_forms::fields(doc) { @@ -631,9 +646,12 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result { + cat.remove(b"AcroForm"); + } _ => {} } - let n = count(h); + let n = if h == Hidden::FormFields && fields { count(h).max(1) } else { count(h) }; if n > 0 { done.push((h, n)); } diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 9ffb07e6f8..c072d19db7 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -281,14 +281,40 @@ fn vectors_are_removed_or_clipped_and_inline_images_go() { let mut doc = one_page(src, "", vec![]); mark(&mut doc, 0, &[[5.0, 5.0, 35.0, 35.0], [45.0, 45.0, 65.0, 65.0]], ""); let r = apply(&mut doc, None).unwrap(); - assert_eq!((r.paths_removed, r.paths_clipped, r.images_removed), (1, 1, 1), "{r:?}"); + assert_eq!((r.paths_removed, r.paths_clipped, r.images_removed), (2, 0, 1), "{r:?}"); let c = content(&doc, 0); assert!(!c.contains("10 10 20 20 re"), "{c}"); - assert!(c.contains("W*\nn\n0 0 300 300 re\nf\nQ"), "the page-size rect is clipped: {c}"); + assert!(!c.contains("300 300 re"), "the intersecting rect is removed: {c}"); assert!(!c.contains("BI"), "{c}"); assert!(c.contains("150 150 m"), "the line elsewhere stays"); } +#[test] +fn partially_covered_vector_paths_do_not_leak_drawing_commands() { + // A vector path with detailed drawing commands (e.g. outlined text or confidential geometry) + // that is only partially covered by a redaction mark must be completely removed, not retained + // behind a clipping path (F-008). + let src = b"0 g 100 100 m 150 150 l 200 100 l h f 10 10 m 20 20 l W b 300 300 m 350 350 l S"; + let mut doc = one_page(src, "", vec![]); + // Redaction mark covers only part of the triangle (100..200, 100..150): + // mark is [120, 120, 160, 160], partially intersecting the triangle. + // Also partially intersect the clip-and-stroke path [10..20, 10..20] with [15, 15, 25, 25]. + mark(&mut doc, 0, &[[120.0, 120.0, 160.0, 160.0], [15.0, 15.0, 25.0, 25.0]], ""); + let r = apply(&mut doc, None).unwrap(); + assert_eq!(r.paths_removed, 2, "both partially intersected paths removed"); + assert_eq!(r.paths_clipped, 0, "no paths retained via clipping"); + + let c = content(&doc, 0); + // Neither the drawing operators nor the coordinates should leak in the content stream. + assert!(!c.contains("100 100 m"), "underlying geometry must not leak: {c}"); + assert!(!c.contains("150 150 l"), "underlying geometry must not leak: {c}"); + assert!(!c.contains("200 100 l"), "underlying geometry must not leak: {c}"); + assert!(!c.contains("10 10 m"), "partial clip geometry must not leak: {c}"); + assert!(!c.contains("20 20 l"), "partial clip geometry must not leak: {c}"); + // Unrelated non-intersecting path remains untouched. + assert!(c.contains("300 300 m\n350 350 l\nS"), "unaffected vector path must be kept: {c}"); +} + /// A chain of nested forms deeper than the pass can walk was removed from the page, taking /// whatever else it drew with it even when nothing in it was marked. The limit now reaches as /// deep as the renderer and Edit text read (50), so anything a reader can see can be walked; @@ -1145,6 +1171,41 @@ fn nothing_to_apply_and_clearing_marks() { assert!(content(&doc, 0).contains("(AB) Tj")); } +/// Clearing marks puts a page's annotation list back as it was: a page that had none has no +/// `/Annots` again, replies to the marks included, and one listed through an indirect array keeps +/// pointing at that array. +#[test] +fn clearing_marks_leaves_the_annotation_list_as_it_was() { + let mut doc = one_page(b"BT /F1 10 Tf 10 100 Td (AB) Tj ET", "", vec![]); + let page = pdfcraft_model::pages(&doc)[0].obj; + let before = doc.get(page); + mark(&mut doc, 0, &[[0.0, 0.0, 50.0, 50.0]], ""); + pdfcraft_annot::add_reply(&mut doc, 0, 0, "a reply", "Tester", &Meta::default()).unwrap(); + // And a reply written inline, as a file can. + let mut inline = Dict::new(); + inline.set(b"Subtype".to_vec(), Object::name("Text")); + inline.set(b"IRT".to_vec(), Object::Ref(marks(&doc)[0].obj)); + let mut listed = annots_of(&doc, doc.get(page).as_dict().unwrap()); + listed.push(Object::Dict(inline)); + doc.update_dict(page, |d| d.set(b"Annots".to_vec(), Object::Array(listed))).unwrap(); + assert_eq!(clear_marks(&mut doc, None), Ok(1), "one mark"); + assert_eq!(doc.get(page), before, "the page has no /Annots again: both replies went with their mark"); + + let mut doc = pdf(vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Annots 4 0 R >>".to_vec(), + b"[5 0 R]".to_vec(), + b"<< /Type /Annot /Subtype /Link /Rect [0 0 10 10] >>".to_vec(), + ]); + let (page, list) = (ObjRef::new(3, 0), ObjRef::new(4, 0)); + let before = (doc.get(page), doc.get(list)); + mark(&mut doc, 0, &[[0.0, 0.0, 50.0, 50.0]], ""); + assert_eq!(doc.get(list).as_array().map(|a| a.len()), Some(2), "the mark is listed in the shared array"); + clear_marks(&mut doc, None).unwrap(); + assert_eq!((doc.get(page), doc.get(list)), before, "the page points at its array, which lists the link alone"); +} + #[test] fn unreadable_content_fails_closed() { let mut doc = one_page(b"", "", vec![]); @@ -2230,6 +2291,74 @@ fn unselected_pages_keep_inherited_forms() { assert_eq!(under(&mut saved, 1, &[[0.0, 190.0, 300.0, 220.0]]), 6); } +#[test] +fn nested_resource_less_forms_keep_live_resources_after_redacting_another_page() { + for own_resources in [false, true] { + for still_drawn in [false, true] { + let original = b"BT /F1 10 Tf 10 200 Td (SECRET) Tj ET"; + let mut extra = vec![ + stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300]", original), + stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300]", if still_drawn { b"/Fm Do".as_slice() } else { b"q Q".as_slice() }), + ]; + if own_resources { + extra.push(stream( + "/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Resources << /Font << /F1 5 0 R >> /XObject << /Fm 7 0 R /Relay 8 0 R >> >>", + b"/Relay Do", + )); + } + let resources = + if own_resources { "/XObject << /Fm 7 0 R /Relay 8 0 R /Wrapper 9 0 R >>" } else { "/XObject << /Fm 7 0 R /Relay 8 0 R >>" }; + let mut doc = one_page(b"/Fm Do", resources, extra); + let mut page = doc.get(ObjRef::new(3, 0)).as_dict().unwrap().clone(); + let inherited = page.remove(b"Resources").unwrap(); + doc.set(ObjRef::new(3, 0), Object::Dict(page.clone())); + let second_content = + doc.add(Object::Stream(Stream::from_raw(Dict::new(), if own_resources { b"/Wrapper Do".to_vec() } else { b"/Relay Do".to_vec() }))); + page.set(b"Contents".to_vec(), Object::Ref(second_content)); + let second = doc.add(Object::Dict(page)); + doc.update_dict(ObjRef::new(2, 0), |d| { + d.set(b"Resources".to_vec(), inherited); + d.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(3, 0)), Object::Ref(second)])); + d.set(b"Count".to_vec(), Object::Int(2)); + }) + .unwrap(); + + let region = [[0.0, 190.0, 300.0, 220.0]]; + let surviving = if still_drawn { 6 } else { 0 }; + assert_eq!(under(&mut doc, 0, ®ion), 6, "the marked occurrence is real text"); + assert_eq!(under(&mut doc, 1, ®ion), surviving, "the relay's original nested content is visible"); + mark(&mut doc, 0, ®ion, ""); + let report = apply(&mut doc, Some(&[0])).unwrap(); + assert_eq!((report.marks, report.glyphs), (1, 6)); + assert_eq!(under(&mut doc, 0, ®ion), 0); + assert_eq!(under(&mut doc, 1, ®ion), surviving, "redacting another page must preserve live nested content"); + + // A full saved document must retain both the reachable resource and its content, + // without relying on an old incremental revision's otherwise-unreachable objects. + let mut saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + assert_eq!(under(&mut saved, 0, ®ion), 0); + assert_eq!(under(&mut saved, 1, ®ion), surviving); + let page = pdfcraft_model::pages(&saved).swap_remove(1); + let res = saved.resolve(page.dict.get(b"Resources").unwrap()); + let xo = saved.resolve(res.as_dict().unwrap().get(b"XObject").unwrap()); + let mut holder = xo.as_dict().unwrap().clone(); + if own_resources { + let wrapper = saved.resolve(holder.get(b"Wrapper").unwrap()); + let Object::Stream(wrapper) = &*wrapper else { panic!("a real wrapper form") }; + let res = saved.resolve(wrapper.dict.get(b"Resources").unwrap()); + let xo = saved.resolve(res.as_dict().unwrap().get(b"XObject").unwrap()); + holder = xo.as_dict().unwrap().clone(); + } + assert_eq!(holder.contains(b"Fm"), still_drawn, "keep only resources a surviving nested form actually uses"); + if still_drawn { + let form = saved.resolve(holder.get(b"Fm").unwrap()); + let Object::Stream(form) = &*form else { panic!("the preserved original form") }; + assert_eq!(form.decoded().unwrap(), original); + } + } + } +} + /// A page tree of `n` pages that all draw form /Fm and inherit a direct `/Resources` from the root. fn inheriting_pages(n: usize) -> (Document, Vec) { let form = stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300]", b"BT /F1 10 Tf 10 200 Td (SECRET) Tj ET"); @@ -2673,6 +2802,27 @@ fn removing_form_fields_takes_hidden_widgets_and_their_values_out_of_the_file() assert!(annots_of(&doc, &pdfcraft_model::pages(&doc).swap_remove(0).dict).is_empty()); } +/// F-021: removing form fields on a document with an AcroForm dictionary (even one with no +/// enumerable terminal fields) removes the AcroForm dictionary, processes widgets, and requires full save. +#[test] +fn removing_form_fields_removes_acroform_even_without_terminal_fields() { + use crate::sanitize::{Hidden, remove_hidden, scan}; + let mut doc = + one_page(b"BT /F1 10 Tf 10 100 Td (Visible) Tj ET", "", vec![b"<< /Type /Annot /Subtype /Widget /Rect [10 200 150 220] >>".to_vec()]); + doc.update_dict(doc.root().unwrap(), |d| d.set(b"AcroForm".to_vec(), fields_dict(&[]))).unwrap(); + doc.update_dict(ObjRef::new(3, 0), |d| d.set(b"Annots".to_vec(), ref_array(&[7]))).unwrap(); + let counts = scan(&doc); + assert_eq!(counts.iter().find(|c| c.0 == Hidden::FormFields).map(|c| c.1), Some(1)); + assert!(doc.get(doc.root().unwrap()).as_dict().is_some_and(|d| d.contains(b"AcroForm"))); + assert!(!doc.full_save_required()); + + let done = remove_hidden(&mut doc, &[Hidden::FormFields]).unwrap(); + assert_eq!(done, [(Hidden::FormFields, 1)]); + assert!(!doc.get(doc.root().unwrap()).as_dict().is_some_and(|d| d.contains(b"AcroForm"))); + assert!(annots_of(&doc, &pdfcraft_model::pages(&doc).swap_remove(0).dict).is_empty()); + assert!(doc.full_save_required()); +} + /// Removing hidden text cleans the structure tree as redaction does: the elements describing the /// removed text lose their descriptions, and a reference into a rewritten Form points at the copy. #[test] diff --git a/crates/redact/src/tests/surviving_references.rs b/crates/redact/src/tests/surviving_references.rs index d3aa98af67..477f03f9a5 100644 --- a/crates/redact/src/tests/surviving_references.rs +++ b/crates/redact/src/tests/surviving_references.rs @@ -66,3 +66,235 @@ fn an_image_a_surviving_form_draws_is_kept() { assert_eq!(apply(&mut doc, None).unwrap().images_removed, 1); assert!(full_save_holds(&doc, b"IMAGESECRET"), "the image the form still draws outside the mark"); } + +/// The surviving form draws the image through a nested form without /Resources of its own, +/// which draws through the outer form's: the image is kept. +#[test] +fn an_image_a_surviving_forms_nested_form_draws_is_kept() { + let mut doc = pdf(vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources 5 0 R >>".to_vec(), + stream("", b"q 100 0 0 100 0 0 cm /Im Do Q /Fm Do"), + b"<< /XObject << /Im 6 0 R /Fm 7 0 R /Inner 8 0 R >> >>".to_vec(), + stream("/Type /XObject /Subtype /Image /Width 11 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", b"IMAGESECRET"), + stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Resources 5 0 R", b"/Inner Do"), + stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300]", b"q 50 0 0 50 200 200 cm /Im Do Q"), + ]); + mark(&mut doc, 0, &[[0.0, 0.0, 120.0, 120.0]], ""); + assert_eq!(apply(&mut doc, None).unwrap().images_removed, 1); + assert!(full_save_holds(&doc, b"IMAGESECRET"), "the image the nested form still draws outside the mark"); +} + +/// Two pages share one resource dictionary, and the second draws the image only through a form +/// without /Resources: redacting the image on the first page keeps it for the second. +#[test] +fn an_image_another_page_draws_through_a_form_without_resources_is_kept() { + let mut doc = pdf(vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 5 0 R /Resources 7 0 R >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 6 0 R /Resources 7 0 R >>".to_vec(), + stream("", b"q 100 0 0 100 0 0 cm /Im Do Q"), + stream("", b"/Inner Do"), + b"<< /XObject << /Im 8 0 R /Inner 9 0 R >> >>".to_vec(), + stream("/Type /XObject /Subtype /Image /Width 11 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", b"IMAGESECRET"), + stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300]", b"q 50 0 0 50 200 200 cm /Im Do Q"), + ]); + mark(&mut doc, 0, &[[0.0, 0.0, 120.0, 120.0]], ""); + assert_eq!(apply(&mut doc, None).unwrap().images_removed, 1); + assert!(full_save_holds(&doc, b"IMAGESECRET"), "the second page still draws the image through its form"); +} + +/// A Type 3 font whose displayed glyphs are redacted has its unused glyph programs unlinked from +/// /CharProcs and exclusive graphical resources pruned, so they do not survive in a full save (F-011). +#[test] +fn redacted_type3_glyph_and_exclusive_resources_do_not_survive_full_save() { + let mut doc = pdf(vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources 5 0 R >>".to_vec(), + // Text showing AB with Type 3 font /T3 + stream("", b"BT /T3 10 Tf 10 100 Td (AB) Tj ET"), + b"<< /Font << /T3 6 0 R >> >>".to_vec(), + // Type 3 font with /CharProcs << /A 7 0 R /B 8 0 R >> and /Resources with images for both + b"<< /Type /Font /Subtype /Type3 /FontBBox [0 -200 1000 800] /FontMatrix [0.001 0 0 0.001 0 0] /FirstChar 65 /LastChar 66 /Widths [500 500] /Encoding << /Differences [65 /A /B] >> /CharProcs << /A 7 0 R /B 8 0 R >> /Resources << /XObject << /ImA 9 0 R /ImB 10 0 R >> >> >>".to_vec(), + stream("", b"/ImA Do % SECRET_GLYPH_A_PAYLOAD"), + stream("", b"/ImB Do % SURVIVING_GLYPH_B_PAYLOAD"), + stream("/Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", b"CONFIDENTIAL_IMAGE_A"), + stream("/Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", b"PUBLIC_IMAGE_B"), + ]); + + // Redaction mark covers only 'A' (x: 10..15, y: 95..110) + mark(&mut doc, 0, &[[9.0, 95.0, 14.9, 110.0]], ""); + + assert!(full_save_holds(&doc, b"SECRET_GLYPH_A_PAYLOAD")); + assert!(full_save_holds(&doc, b"CONFIDENTIAL_IMAGE_A")); + assert!(full_save_holds(&doc, b"SURVIVING_GLYPH_B_PAYLOAD")); + assert!(full_save_holds(&doc, b"PUBLIC_IMAGE_B")); + + let report = apply(&mut doc, None).unwrap(); + assert_eq!(report.glyphs, 1); + + // After redaction, unreferenced glyph A and its exclusive image A must not survive full save + assert!(!full_save_holds(&doc, b"SECRET_GLYPH_A_PAYLOAD"), "redacted glyph A program survived"); + assert!(!full_save_holds(&doc, b"CONFIDENTIAL_IMAGE_A"), "exclusive image for redacted glyph A survived"); + + // Surviving glyph B and its image B must survive + assert!(full_save_holds(&doc, b"SURVIVING_GLYPH_B_PAYLOAD"), "surviving glyph B was lost"); + assert!(full_save_holds(&doc, b"PUBLIC_IMAGE_B"), "surviving glyph B image was lost"); + + // Reopen and inspect the Type 3 font dictionary + let bytes = write_full(&doc, &SaveOptions::default()).unwrap(); + let saved = Document::open(Arc::new(bytes)).unwrap(); + let page = pdfcraft_model::pages(&saved).swap_remove(0); + let res = saved.resolve(page.dict.get(b"Resources").unwrap()).as_dict().unwrap().clone(); + let fonts = saved.resolve(res.get(b"Font").unwrap()).as_dict().unwrap().clone(); + let t3 = saved.resolve(fonts.get(b"T3").unwrap()).as_dict().unwrap().clone(); + let procs = saved.resolve(t3.get(b"CharProcs").unwrap()).as_dict().unwrap().clone(); + assert!(!procs.contains(b"A"), "CharProcs still contains unreferenced glyph A"); + assert!(procs.contains(b"B"), "CharProcs lost surviving glyph B"); + + let t3_res = saved.resolve(t3.get(b"Resources").unwrap()).as_dict().unwrap().clone(); + let t3_xo = saved.resolve(t3_res.get(b"XObject").unwrap()).as_dict().unwrap().clone(); + assert!(!t3_xo.contains(b"ImA"), "Type 3 font resources still contains exclusive image ImA"); + assert!(t3_xo.contains(b"ImB"), "Type 3 font resources lost surviving image ImB"); +} + +/// When all glyphs of a Type 3 font are redacted, the entire font is removed from Resources (F-011). +#[test] +fn all_redacted_type3_glyphs_cause_font_removal_from_resources() { + let mut doc = pdf(vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources 5 0 R >>".to_vec(), + stream("", b"BT /T3 10 Tf 10 100 Td (A) Tj ET"), + b"<< /Font << /T3 6 0 R >> >>".to_vec(), + b"<< /Type /Font /Subtype /Type3 /FontBBox [0 -200 1000 800] /FontMatrix [0.001 0 0 0.001 0 0] /FirstChar 65 /LastChar 65 /Widths [500] /Encoding << /Differences [65 /A] >> /CharProcs << /A 7 0 R >> /Resources << /XObject << /ImA 8 0 R >> >> >>".to_vec(), + stream("", b"/ImA Do % SECRET_GLYPH_ONLY_PAYLOAD"), + stream("/Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", b"CONFIDENTIAL_IMAGE_ONLY"), + ]); + + mark(&mut doc, 0, &[[9.0, 95.0, 16.0, 110.0]], ""); + + let report = apply(&mut doc, None).unwrap(); + assert_eq!(report.glyphs, 1); + + assert!(!full_save_holds(&doc, b"SECRET_GLYPH_ONLY_PAYLOAD")); + assert!(!full_save_holds(&doc, b"CONFIDENTIAL_IMAGE_ONLY")); + + let bytes = write_full(&doc, &SaveOptions::default()).unwrap(); + let saved = Document::open(Arc::new(bytes)).unwrap(); + let page = pdfcraft_model::pages(&saved).swap_remove(0); + if let Some(res_val) = page.dict.get(b"Resources") + && let Some(res) = saved.resolve(res_val).as_dict() + && let Some(fonts_val) = res.get(b"Font") + { + let fonts = saved.resolve(fonts_val).as_dict().unwrap().clone(); + assert!(!fonts.contains(b"T3"), "Font /T3 was not removed from Resources"); + } +} + +/// In shared Type 3 fonts across multiple pages, glyphs used on another page are preserved (F-011). +#[test] +fn shared_type3_font_preserves_glyphs_used_by_another_page() { + let mut doc = pdf(vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >>".to_vec(), + // Page 1: draws 'A' (redacted) + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 5 0 R /Resources 7 0 R >>".to_vec(), + // Page 2: draws 'B' (survives) + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 6 0 R /Resources 7 0 R >>".to_vec(), + stream("", b"BT /T3 10 Tf 10 100 Td (A) Tj ET"), + stream("", b"BT /T3 10 Tf 10 100 Td (B) Tj ET"), + // Shared Resources 7 0 R + b"<< /Font << /T3 8 0 R >> >>".to_vec(), + // Shared Type 3 font 8 0 R + b"<< /Type /Font /Subtype /Type3 /FontBBox [0 -200 1000 800] /FontMatrix [0.001 0 0 0.001 0 0] /FirstChar 65 /LastChar 66 /Widths [500 500] /Encoding << /Differences [65 /A /B] >> /CharProcs << /A 9 0 R /B 10 0 R >> >>".to_vec(), + stream("", b"% SECRET_PAGE1_GLYPH_A"), + stream("", b"% PUBLIC_PAGE2_GLYPH_B"), + ]); + + // Redact only Page 1's 'A' + mark(&mut doc, 0, &[[9.0, 95.0, 16.0, 110.0]], ""); + + let report = apply(&mut doc, None).unwrap(); + assert_eq!(report.glyphs, 1); + + // Glyph A was removed from Page 1 and used nowhere else -> unlinked! + assert!(!full_save_holds(&doc, b"SECRET_PAGE1_GLYPH_A")); + + // Glyph B is still used on Page 2 -> kept! + assert!(full_save_holds(&doc, b"PUBLIC_PAGE2_GLYPH_B")); + + let bytes = write_full(&doc, &SaveOptions::default()).unwrap(); + let saved = Document::open(Arc::new(bytes)).unwrap(); + let pages = pdfcraft_model::pages(&saved); + let p2_res = saved.resolve(pages[1].dict.get(b"Resources").unwrap()).as_dict().unwrap().clone(); + let fonts = saved.resolve(p2_res.get(b"Font").unwrap()).as_dict().unwrap().clone(); + let t3 = saved.resolve(fonts.get(b"T3").unwrap()).as_dict().unwrap().clone(); + let procs = saved.resolve(t3.get(b"CharProcs").unwrap()).as_dict().unwrap().clone(); + assert!(!procs.contains(b"A")); + assert!(procs.contains(b"B")); +} + +/// Fails closed when Type 3 font cleanup cannot be safely established (F-011). +#[test] +fn unparseable_type3_font_encoding_fails_closed() { + let mut doc = pdf(vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources 5 0 R >>".to_vec(), + stream("", b"BT /T3 10 Tf 10 100 Td (A) Tj ET"), + b"<< /Font << /T3 6 0 R >> >>".to_vec(), + // Unsupported encoding /UnknownCustomEncoding without Differences + b"<< /Type /Font /Subtype /Type3 /FontBBox [0 -200 1000 800] /FontMatrix [0.001 0 0 0.001 0 0] /FirstChar 65 /LastChar 65 /Widths [500] /Encoding /UnknownCustomEncoding /CharProcs << /A 7 0 R >> >>".to_vec(), + stream("", b"% SECRET_PAYLOAD_UNPARSEABLE"), + ]); + + mark(&mut doc, 0, &[[9.0, 95.0, 16.0, 110.0]], ""); + + let res = apply(&mut doc, None); + assert!(res.is_err(), "redaction must fail closed when Type 3 cleanup cannot be established: {res:?}"); +} + +/// A Type 3 font selected through an ExtGState /Font entry is a use of the font for pruning: +/// its redacted glyph program does not survive a full save (F-011). +#[test] +fn type3_font_set_through_extgstate_has_its_redacted_glyph_removed() { + let mut doc = pdf(vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources 5 0 R >>".to_vec(), + stream("", b"/GS1 gs BT 10 100 Td (A) Tj ET"), + b"<< /Font << /T3 6 0 R >> /ExtGState << /GS1 8 0 R >> >>".to_vec(), + b"<< /Type /Font /Subtype /Type3 /FontBBox [0 -200 1000 800] /FontMatrix [0.001 0 0 0.001 0 0] /FirstChar 65 /LastChar 65 /Widths [500] /Encoding << /Differences [65 /A] >> /CharProcs << /A 7 0 R >> >>".to_vec(), + stream("", b"% SECRET_EXTGSTATE_GLYPH_PAYLOAD"), + b"<< /Type /ExtGState /Font [6 0 R 10] >>".to_vec(), + ]); + mark(&mut doc, 0, &[[9.0, 95.0, 16.0, 110.0]], ""); + let report = apply(&mut doc, None).unwrap(); + assert_eq!(report.glyphs, 1); + assert!(!full_save_holds(&doc, b"SECRET_EXTGSTATE_GLYPH_PAYLOAD"), "redacted glyph program survived"); +} + +/// A Type 3 font used only inside a Form XObject is found by the scan and its redacted glyph +/// program does not survive a full save (F-011). +#[test] +fn type3_font_used_in_a_form_xobject_has_its_redacted_glyph_removed() { + let mut doc = pdf(vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources 5 0 R >>".to_vec(), + stream("", b"/Fm Do"), + b"<< /XObject << /Fm 8 0 R >> >>".to_vec(), + b"<< /Type /Font /Subtype /Type3 /FontBBox [0 -200 1000 800] /FontMatrix [0.001 0 0 0.001 0 0] /FirstChar 65 /LastChar 65 /Widths [500] /Encoding << /Differences [65 /A] >> /CharProcs << /A 7 0 R >> >>".to_vec(), + stream("", b"% SECRET_FORM_GLYPH_PAYLOAD"), + stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Resources 9 0 R", b"BT /T3 10 Tf 10 100 Td (A) Tj ET"), + b"<< /Font << /T3 6 0 R >> >>".to_vec(), + ]); + mark(&mut doc, 0, &[[9.0, 95.0, 16.0, 110.0]], ""); + let report = apply(&mut doc, None).unwrap(); + assert_eq!(report.glyphs, 1); + assert!(!full_save_holds(&doc, b"SECRET_FORM_GLYPH_PAYLOAD"), "redacted glyph program survived"); +} diff --git a/crates/redact/src/type3.rs b/crates/redact/src/type3.rs new file mode 100644 index 0000000000..f431dab902 --- /dev/null +++ b/crates/redact/src/type3.rs @@ -0,0 +1,692 @@ +//! Unlink unused Type 3 font glyph programs and prune exclusive graphical resources (F-011). + +use std::collections::{HashMap, HashSet}; + +use pdfcraft_content::Pieces; +use pdfcraft_cos::{CosError, Dict, Document, ObjRef, Object}; + +use crate::{RedactError, page_streams}; + +const MAX_DEPTH: usize = 64; +const MAX_HOLDERS: usize = 1_000_000; + +fn res_dict(doc: &Document, res: &Dict, key: &[u8]) -> Dict { + res.get(key).and_then(|o| doc.resolve(o).as_dict().cloned()).unwrap_or_default() +} + +/// Identifies a font in the document. Only fonts that are indirect objects are cleaned: a Type 3 +/// font held directly in a resource dictionary fails closed (see [`unlink_type3`]), because its +/// glyph programs could not be told apart or rewritten reliably. +#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] +pub(crate) enum FontKey { + Ref(ObjRef), +} + +/// Build the mapping from character code (0..=255) to glyph name in a Type 3 font. +/// Fails closed if the encoding cannot be safely resolved or parsed. +pub(crate) fn type3_code_to_glyph(doc: &Document, font_dict: &Dict) -> Result<[Option>; 256], CosError> { + let Some(enc_obj) = font_dict.get(b"Encoding") else { + return Err(CosError::Syntax { offset: 0, detail: "redaction could not establish safe Type 3 font cleanup: missing Encoding".into() }); + }; + let resolved_enc = doc.resolve(enc_obj); + + let mut differences = HashMap::new(); + let base_name: Option>; + + match &*resolved_enc { + Object::Dict(enc_dict) => { + if let Some(diffs) = enc_dict.get(b"Differences").and_then(|d| doc.resolve(d).as_array().cloned()) { + let mut cur_code = 0i64; + for item in &diffs { + match &*doc.resolve(item) { + Object::Int(n) => cur_code = *n, + Object::Name(name) => { + if let Ok(c) = u8::try_from(cur_code) { + differences.insert(c, name.clone()); + } + cur_code = cur_code.saturating_add(1); + } + _ => {} + } + } + } + if let Some(base) = enc_dict.name(b"BaseEncoding") { + base_name = Some(base.to_vec()); + } else { + // ISO 32000 §9.6.6.1: default base encoding for Type 3 fonts is StandardEncoding. + base_name = Some(b"StandardEncoding".to_vec()); + } + } + Object::Name(name) => { + base_name = Some(name.clone()); + } + _ => { + return Err(CosError::Syntax { + offset: 0, + detail: "redaction could not establish safe Type 3 font cleanup: invalid Encoding object".into(), + }); + } + } + + let base_table: Option<&'static [Option<&'static [u8]>; 256]> = match base_name.as_deref() { + Some(b"StandardEncoding") => Some(&crate::type3_tables::STANDARD), + Some(b"WinAnsiEncoding") => Some(&crate::type3_tables::WIN_ANSI), + Some(b"MacRomanEncoding") => Some(&crate::type3_tables::MAC_ROMAN), + Some(b"MacExpertEncoding") => Some(&crate::type3_tables::MAC_EXPERT), + Some(other) => { + return Err(CosError::Syntax { + offset: 0, + detail: format!( + "redaction could not establish safe Type 3 font cleanup: unsupported BaseEncoding: {}", + String::from_utf8_lossy(other) + ), + }); + } + None => None, + }; + + let mut map: [Option>; 256] = std::array::from_fn(|_| None); + for (code, entry) in map.iter_mut().enumerate() { + let c = code as u8; + if let Some(diff_glyph) = differences.get(&c) { + *entry = Some(diff_glyph.clone()); + } else if let Some(table) = base_table + && let Some(base_glyph) = table[code] + { + *entry = Some(base_glyph.to_vec()); + } + } + + Ok(map) +} + +fn resolve_font_in_res(doc: &Document, res: &Dict, font_name: &[u8]) -> Option<(FontKey, Dict)> { + let fonts = res_dict(doc, res, b"Font"); + let font_obj = fonts.get(font_name)?; + // A direct Type 3 font is refused during discovery, so none reaches the scan. + let font_ref = font_obj.as_ref()?; + let dict = doc.resolve(font_obj).as_dict()?.clone(); + (dict.name(b"Subtype") == Some(b"Type3")).then_some((FontKey::Ref(font_ref), dict)) +} + +fn resolve_extgstate_font(doc: &Document, res: &Dict, gs_name: &[u8]) -> Option<(FontKey, Dict)> { + let extg = res_dict(doc, res, b"ExtGState"); + let state = doc.resolve(extg.get(gs_name)?).as_dict().cloned()?; + let font_entry = state.get(b"Font").and_then(|f| doc.resolve(f).as_array().cloned())?; + let font_obj = font_entry.first()?; + let font_ref = font_obj.as_ref()?; + let dict = doc.resolve(font_obj).as_dict()?.clone(); + (dict.name(b"Subtype") == Some(b"Type3")).then_some((FontKey::Ref(font_ref), dict)) +} + +#[derive(Default)] +struct UsedInGlyphs { + xobjects: HashSet>, + colorspaces: HashSet>, + patterns: HashSet>, + shadings: HashSet>, + extgstates: HashSet>, + fonts: HashSet>, + properties: HashSet>, +} + +fn collect_used_resources_in_stream(data: &[u8], used: &mut UsedInGlyphs) -> Result<(), CosError> { + let parsed = pdfcraft_content::parse(data); + if parsed.skipped > 0 { + return Err(CosError::Syntax { offset: 0, detail: "redaction could not fully inspect glyph stream syntax".into() }); + } + for op in parsed.ops { + match op.op.as_slice() { + b"Do" => { + if let Some(n) = op.last_name() { + used.xobjects.insert(n.to_vec()); + } + } + b"cs" | b"CS" => { + if let Some(n) = op.name(0) { + used.colorspaces.insert(n.to_vec()); + } + } + b"scn" | b"SCN" => { + if let Some(n) = op.last_name() { + used.patterns.insert(n.to_vec()); + } + } + b"sh" => { + if let Some(n) = op.last_name() { + used.shadings.insert(n.to_vec()); + } + } + b"gs" => { + if let Some(n) = op.name(0) { + used.extgstates.insert(n.to_vec()); + } + } + b"Tf" => { + if let Some(n) = op.name(0) { + used.fonts.insert(n.to_vec()); + } + } + b"BDC" | b"DP" => { + if let Some(n) = op.name(1) { + used.properties.insert(n.to_vec()); + } + } + _ => {} + } + } + 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); + + changed +} + +struct ScanContext<'a> { + doc: &'a Document, + font_maps: &'a HashMap>; 256]>, + used_glyphs: &'a mut HashMap>>, + /// A form scanned with its inherited font and the resources it draws through: the same form + /// drawn in another context is scanned again. + seen_forms: &'a mut HashSet<(ObjRef, Option, ObjRef)>, +} + +fn scan_content_stream( + ctx: &mut ScanContext<'_>, + content: &[Vec], + holder_ref: ObjRef, + res: &Dict, + initial_font: Option, + depth: usize, +) -> Result<(), CosError> { + if depth > MAX_DEPTH { + return Err(CosError::Syntax { offset: 0, detail: "redaction exceeded max depth inspecting Type 3 font content".into() }); + } + let joined = Pieces::join(content); + let parsed = joined.parse(); + if parsed.skipped > 0 { + return Err(CosError::Syntax { offset: 0, detail: "redaction could not fully inspect content stream syntax".into() }); + } + + let mut stack: Vec> = Vec::new(); + let mut current_font = initial_font; + + for op in parsed.ops { + match op.op.as_slice() { + b"q" => stack.push(current_font), + b"Q" => { + if let Some(saved) = stack.pop() { + current_font = saved; + } + } + b"Do" => { + if let Some(name) = op.last_name() { + scan_xobject(ctx, res, holder_ref, name, current_font, depth)?; + } + } + _ => apply_text_op(ctx, res, &op, &mut current_font), + } + } + Ok(()) +} + +/// Apply an operator that changes or uses the current font: `Tf`, `gs`, and the text-showing +/// operators, which record the glyphs they draw. +fn apply_text_op(ctx: &mut ScanContext<'_>, res: &Dict, op: &pdfcraft_content::Op, current_font: &mut Option) { + match op.op.as_slice() { + b"Tf" => { + if let Some(name) = op.name(0) { + *current_font = resolve_font_in_res(ctx.doc, res, name).map(|(k, _)| k); + } + } + b"gs" => { + if let Some(name) = op.name(0) + && let Some((k, _)) = resolve_extgstate_font(ctx.doc, res, name) + { + *current_font = Some(k); + } + } + b"Tj" | b"'" | b"\"" => { + if let Some(s) = op.operands.last().and_then(Object::as_string) { + mark_shown(ctx, *current_font, &s.bytes); + } + } + b"TJ" => { + if let Some(arr) = op.operands.last().and_then(Object::as_array) { + for item in arr { + if let Some(s) = item.as_string() { + mark_shown(ctx, *current_font, &s.bytes); + } + } + } + } + _ => {} + } +} + +/// Record every glyph of `font` that the shown string `bytes` uses. +fn mark_shown(ctx: &mut ScanContext<'_>, font: Option, bytes: &[u8]) { + let Some(fk) = font else { return }; + let Some(map) = ctx.font_maps.get(&fk) else { return }; + for b in bytes { + if let Some(g) = &map[*b as usize] { + ctx.used_glyphs.entry(fk).or_default().insert(g.clone()); + } + } +} + +/// Scan the Form XObject `name` drawn by a `Do` operator, once per (form, font, drawing holder). +fn scan_xobject( + ctx: &mut ScanContext<'_>, + res: &Dict, + holder_ref: ObjRef, + name: &[u8], + current_font: Option, + depth: usize, +) -> Result<(), CosError> { + let xo = res_dict(ctx.doc, res, b"XObject"); + let Some(r) = xo.get(name).and_then(Object::as_ref) else { return Ok(()) }; + let obj = ctx.doc.get(r); + let Object::Stream(s) = &*obj else { return Ok(()) }; + if s.dict.name(b"Subtype") != Some(b"Form") { + return Ok(()); + } + let form_own_res = s.dict.get(b"Resources").and_then(|ro| ctx.doc.resolve(ro).as_dict().cloned()); + // A form with resources of its own draws through them; one without draws through its caller's. + let through = if form_own_res.is_some() { r } else { holder_ref }; + if !ctx.seen_forms.insert((r, current_font, through)) { + return Ok(()); + } + let data = s.decoded()?; + match form_own_res { + Some(form_res) => scan_content_stream(ctx, std::slice::from_ref(&data), r, &form_res, current_font, depth + 1), + None => scan_content_stream(ctx, std::slice::from_ref(&data), holder_ref, res, current_font, depth + 1), + } +} + +/// The font object an ExtGState's `/Font` entry names (its first element), if any. +fn gs_font_object(doc: &Document, state_obj: &Object) -> Option { + let state = doc.resolve(state_obj).as_dict()?.clone(); + let font_entry = doc.resolve(state.get(b"Font")?).as_array()?.clone(); + font_entry.into_iter().next() +} + +/// Prune Type 3 fonts from a rewritten page or form's `/Resources` dictionary if the rewritten +/// content no longer draws any text using that font name. +pub(crate) fn prune_type3_from_resources(doc: &Document, res: &mut Dict, content: &[Vec]) -> Result<(), CosError> { + let mut fonts = res_dict(doc, res, b"Font"); + if fonts.is_empty() { + return Ok(()); + } + + // The operators of forms that draw through these resources count too: dropping a font a child + // form still shows would leave its text without one. + let ops = crate::interp::resource_ops(doc, res, content)?; + + let mut drawn_font_names = HashSet::new(); + for op in &ops { + if op.is("Tf") { + if let Some(name) = op.name(0) { + drawn_font_names.insert(name.to_vec()); + } + } else if op.is("gs") + && let Some(name) = op.name(0) + { + let extg = res_dict(doc, res, b"ExtGState"); + if let Some(font_obj) = extg.get(name).and_then(|state_obj| gs_font_object(doc, state_obj)) { + drawn_font_names.extend( + fonts + .iter() + .filter(|(_, fn_obj)| fn_obj.as_ref().is_some() && fn_obj.as_ref() == font_obj.as_ref()) + .map(|(fn_name, _)| fn_name.to_vec()), + ); + } + } + } + + let mut changed = false; + let font_names: Vec> = fonts.iter().map(|(n, _)| n.to_vec()).collect(); + for name in font_names { + if let Some(font_obj) = fonts.get(&name) { + let resolved = doc.resolve(font_obj); + if let Some(d) = resolved.as_dict() + && d.name(b"Subtype") == Some(b"Type3") + && !drawn_font_names.contains(&name) + { + fonts.remove(&name); + changed = true; + } + } + } + + if changed { + if fonts.is_empty() { + res.remove(b"Font"); + } else { + res.set(b"Font".to_vec(), Object::Dict(fonts)); + } + } + + Ok(()) +} + +/// Unlink unused Type 3 glyph programs from `CharProcs`, prune their exclusive resources from the +/// font's `/Resources`, and remove completely unused Type 3 fonts across all reachable resource +/// holders in the document. Fails closed if safe cleanup cannot be established. +pub(crate) fn unlink_type3(doc: &mut Document) -> Result<(), RedactError> { + let Some(top) = doc.root().and_then(|root| doc.get(root).as_dict().and_then(|d| d.reference(b"Pages"))) else { + return Ok(()); + }; + + // Find all page-tree nodes with /Resources of their own, and the pages that draw through them. + 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() > MAX_HOLDERS { + 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 form_holders = reachable_form_holders(doc, &holders)?; + + // Discover all Type 3 fonts in holders and forms. + let mut type3_fonts: HashMap = HashMap::new(); + for (node, _) in &holders { + if let Some(res) = doc.get(*node).as_dict().and_then(|d| d.get(b"Resources")).and_then(|r| doc.resolve(r).as_dict().cloned()) { + collect_type3_fonts(doc, &res, &mut type3_fonts)?; + } + } + for (_, form_res) in &form_holders { + collect_type3_fonts(doc, form_res, &mut type3_fonts)?; + } + + if type3_fonts.is_empty() { + return Ok(()); + } + + // Build code-to-glyph mappings for each Type 3 font. Fails closed on error. + let mut font_maps: HashMap>; 256]> = HashMap::new(); + for (key, font_dict) in &type3_fonts { + let map = type3_code_to_glyph(doc, font_dict)?; + font_maps.insert(*key, map); + } + + // Scan all page contents and reachable forms to discover surviving glyph usage. + let page_list = pdfcraft_model::pages(doc); + let mut used_glyphs: HashMap>> = HashMap::new(); + let mut seen_drawn_forms = HashSet::new(); + + let mut ctx = ScanContext { doc, font_maps: &font_maps, used_glyphs: &mut used_glyphs, seen_forms: &mut seen_drawn_forms }; + + for (pi, p) in page_list.iter().enumerate() { + let (_, content) = page_streams(ctx.doc, &p.dict, pi)?; + let page_res = p + .dict + .get(b"Resources") + .and_then(|r| ctx.doc.resolve(r).as_dict().cloned()) + .or_else(|| { + holders.iter().find(|(_, users)| users.contains(&p.obj)).and_then(|(h, _)| { + ctx.doc.get(*h).as_dict().and_then(|d| d.get(b"Resources")).and_then(|ro| ctx.doc.resolve(ro).as_dict().cloned()) + }) + }) + .unwrap_or_default(); + + scan_content_stream(&mut ctx, &content, p.obj, &page_res, None, 0)?; + } + + // Also scan any surviving form that wasn't reached by page streams directly. + for (form_ref, form_res) in &form_holders { + if !ctx.seen_forms.iter().any(|(form, _, _)| form == form_ref) { + let Object::Stream(s) = &*ctx.doc.get(*form_ref) else { continue }; + let data = s.decoded()?; + scan_content_stream(&mut ctx, std::slice::from_ref(&data), *form_ref, form_res, None, 0)?; + } + } + + // For each Type 3 font: unlink unused glyphs from CharProcs and prune exclusive resources. + for (font_key, font_dict) in &type3_fonts { + let surviving = used_glyphs.get(font_key).cloned().unwrap_or_default(); + clean_type3_font(doc, &holders, &form_holders, font_key, font_dict, &surviving)?; + } + + Ok(()) +} + +/// Every reachable Form XObject with resources of its own, with those resources, found by +/// walking the resources of the holders and of the forms they draw. +fn reachable_form_holders(doc: &Document, holders: &[(ObjRef, Vec)]) -> Result, CosError> { + let mut form_holders: Vec<(ObjRef, Dict)> = Vec::new(); + let mut pending_res: Vec = holders + .iter() + .filter_map(|(r, _)| doc.get(*r).as_dict().and_then(|d| d.get(b"Resources")).and_then(|ro| doc.resolve(ro).as_dict().cloned())) + .collect(); + let mut seen_forms = HashSet::new(); + while let Some(res) = pending_res.pop() { + for (_, value) in res_dict(doc, &res, b"XObject").iter() { + let Some(reference) = value.as_ref() else { continue }; + if !seen_forms.insert(reference) { + continue; + } + if seen_forms.len() > MAX_HOLDERS { + return Err(CosError::Syntax { offset: 0, detail: "redaction exceeded max depth inspecting 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; + } + if let Some(r) = form.dict.get(b"Resources").and_then(|ro| doc.resolve(ro).as_dict().cloned()) { + form_holders.push((reference, r.clone())); + pending_res.push(r); + } + } + } + Ok(form_holders) +} + +/// Record every Type 3 font named by a resource dictionary's `/Font` and `/ExtGState` entries. +/// A Type 3 font held directly in a resource dictionary can neither be named reliably nor have its +/// glyph programs rewritten, so redaction fails closed rather than leaving its glyphs unchecked. +fn collect_type3_fonts(doc: &Document, res: &Dict, t3_fonts: &mut HashMap) -> Result<(), CosError> { + let direct = || CosError::Syntax { + offset: 0, + detail: "redaction could not establish safe Type 3 font cleanup: a Type 3 font is not an indirect object".into(), + }; + let mut consider = |font_obj: &Object| -> Result<(), CosError> { + if let Some(dict) = doc.resolve(font_obj).as_dict().cloned() + && dict.name(b"Subtype") == Some(b"Type3") + { + let font_ref = font_obj.as_ref().ok_or_else(direct)?; + t3_fonts.entry(FontKey::Ref(font_ref)).or_insert(dict); + } + Ok(()) + }; + for (_, font_obj) in res_dict(doc, res, b"Font").iter() { + consider(font_obj)?; + } + for (_, state_obj) in res_dict(doc, res, b"ExtGState").iter() { + if let Some(font_obj) = gs_font_object(doc, state_obj) { + consider(&font_obj)?; + } + } + Ok(()) +} + +/// Unlink the glyph programs of one Type 3 font that no longer draw, then either remove the font +/// (when no glyph survives) or prune the resources only its surviving glyphs used. +fn clean_type3_font( + doc: &mut Document, + holders: &[(ObjRef, Vec)], + form_holders: &[(ObjRef, Dict)], + font_key: &FontKey, + font_dict: &Dict, + surviving: &HashSet>, +) -> Result<(), RedactError> { + let Some(char_procs_val) = font_dict.get(b"CharProcs") else { + return Err(CosError::Syntax { offset: 0, detail: "redaction could not establish safe Type 3 font cleanup: missing CharProcs".into() }.into()); + }; + let mut char_procs = doc.resolve(char_procs_val).as_dict().cloned().ok_or_else(|| CosError::Syntax { + offset: 0, + detail: "redaction could not establish safe Type 3 font cleanup: CharProcs is not a dict".into(), + })?; + + let mut procs_changed = false; + let glyph_names: Vec> = char_procs.iter().map(|(n, _)| n.to_vec()).collect(); + for g in glyph_names { + if !surviving.contains(&g) { + char_procs.remove(&g); + procs_changed = true; + } + } + + let FontKey::Ref(fr) = font_key; + if char_procs.is_empty() { + // The font has no surviving glyphs: clear its own glyph programs and resources, then + // remove font from /Font across all holders. + doc.update_dict(*fr, |d| { + d.remove(b"CharProcs"); + d.remove(b"Resources"); + })?; + return remove_font_from_holders(doc, holders, form_holders, font_key); + } + + // A CharProcs object can be shared with another Type 3 font, so never rewrite it in place: + // give this font its own filtered dictionary instead. + if procs_changed { + doc.update_dict(*fr, |d| { + d.set(b"CharProcs".to_vec(), Object::Dict(char_procs.clone())); + })?; + } + + // Clean exclusive resources from the Type 3 font's /Resources. + let Some(f_res_val) = font_dict.get(b"Resources") else { return Ok(()) }; + let mut f_res = doc.resolve(f_res_val).as_dict().cloned().unwrap_or_default(); + + let mut used_in_glyphs = UsedInGlyphs::default(); + for (_, proc_val) in char_procs.iter() { + let resolved = doc.resolve(proc_val); + let Object::Stream(s) = &*resolved else { + return Err(CosError::Syntax { + offset: 0, + detail: "redaction could not establish safe Type 3 font cleanup: CharProc is not a stream".into(), + } + .into()); + }; + let data = s.decoded()?; + collect_used_resources_in_stream(&data, &mut used_in_glyphs)?; + } + + if prune_type3_font_resources(doc, &mut f_res, &used_in_glyphs) { + // The pruned dictionary goes into the font object: a font's /Resources object is + // often the page's own, and rewriting that would strip the page of its resources. + doc.update_dict(*fr, |d| { + if f_res.is_empty() { + d.remove(b"Resources"); + } else { + d.set(b"Resources".to_vec(), Object::Dict(f_res.clone())); + } + })?; + } + Ok(()) +} + +fn remove_font_from_holders( + doc: &mut Document, + holders: &[(ObjRef, Vec)], + form_holders: &[(ObjRef, Dict)], + target_key: &FontKey, +) -> Result<(), RedactError> { + let clean_res = |doc: &Document, res: &mut Dict| -> bool { + let mut fonts = res_dict(doc, res, b"Font"); + let mut changed = false; + let names: Vec> = fonts.iter().map(|(n, _)| n.to_vec()).collect(); + for n in names { + if let Some(val) = fonts.get(&n) + && val.as_ref().map(FontKey::Ref) == Some(*target_key) + { + fonts.remove(&n); + changed = true; + } + } + if changed { + if fonts.is_empty() { + res.remove(b"Font"); + } else { + res.set(b"Font".to_vec(), Object::Dict(fonts)); + } + } + changed + }; + + for (node, _) in holders { + if 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()) + && clean_res(doc, &mut res) + { + doc.update_dict(*node, |d| { + d.set(b"Resources".to_vec(), Object::Dict(res)); + })?; + } + } + + for (form_ref, _) in form_holders { + let obj = doc.get(*form_ref); + if let Object::Stream(s) = &*obj + && let Some(mut res) = s.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()) + && clean_res(doc, &mut res) + { + doc.update_dict(*form_ref, |d| { + d.set(b"Resources".to_vec(), Object::Dict(res)); + })?; + } + } + + Ok(()) +} diff --git a/crates/redact/src/type3_tables.rs b/crates/redact/src/type3_tables.rs new file mode 100644 index 0000000000..62128772de --- /dev/null +++ b/crates/redact/src/type3_tables.rs @@ -0,0 +1,775 @@ +//! Predefined font encodings (ISO 32000 Annex D) mapping character codes to glyph names. + +//! Each encoding lists only the codes it maps; every other code is unmapped. + +type Entries = &'static [(u8, &'static [u8])]; + +const fn build(entries: Entries) -> [Option<&'static [u8]>; 256] { + let mut out: [Option<&'static [u8]>; 256] = [None; 256]; + let mut i = 0; + while i < entries.len() { + out[entries[i].0 as usize] = Some(entries[i].1); + i += 1; + } + out +} + +const STANDARD_ENTRIES: Entries = &[ + (32, b"space"), + (33, b"exclam"), + (34, b"quotedbl"), + (35, b"numbersign"), + (36, b"dollar"), + (37, b"percent"), + (38, b"ampersand"), + (39, b"quoteright"), + (40, b"parenleft"), + (41, b"parenright"), + (42, b"asterisk"), + (43, b"plus"), + (44, b"comma"), + (45, b"hyphen"), + (46, b"period"), + (47, b"slash"), + (48, b"zero"), + (49, b"one"), + (50, b"two"), + (51, b"three"), + (52, b"four"), + (53, b"five"), + (54, b"six"), + (55, b"seven"), + (56, b"eight"), + (57, b"nine"), + (58, b"colon"), + (59, b"semicolon"), + (60, b"less"), + (61, b"equal"), + (62, b"greater"), + (63, b"question"), + (64, b"at"), + (65, b"A"), + (66, b"B"), + (67, b"C"), + (68, b"D"), + (69, b"E"), + (70, b"F"), + (71, b"G"), + (72, b"H"), + (73, b"I"), + (74, b"J"), + (75, b"K"), + (76, b"L"), + (77, b"M"), + (78, b"N"), + (79, b"O"), + (80, b"P"), + (81, b"Q"), + (82, b"R"), + (83, b"S"), + (84, b"T"), + (85, b"U"), + (86, b"V"), + (87, b"W"), + (88, b"X"), + (89, b"Y"), + (90, b"Z"), + (91, b"bracketleft"), + (92, b"backslash"), + (93, b"bracketright"), + (94, b"asciicircum"), + (95, b"underscore"), + (96, b"quoteleft"), + (97, b"a"), + (98, b"b"), + (99, b"c"), + (100, b"d"), + (101, b"e"), + (102, b"f"), + (103, b"g"), + (104, b"h"), + (105, b"i"), + (106, b"j"), + (107, b"k"), + (108, b"l"), + (109, b"m"), + (110, b"n"), + (111, b"o"), + (112, b"p"), + (113, b"q"), + (114, b"r"), + (115, b"s"), + (116, b"t"), + (117, b"u"), + (118, b"v"), + (119, b"w"), + (120, b"x"), + (121, b"y"), + (122, b"z"), + (123, b"braceleft"), + (124, b"bar"), + (125, b"braceright"), + (126, b"asciitilde"), + (161, b"exclamdown"), + (162, b"cent"), + (163, b"sterling"), + (164, b"fraction"), + (165, b"yen"), + (166, b"florin"), + (167, b"section"), + (168, b"currency"), + (169, b"quotesingle"), + (170, b"quotedblleft"), + (171, b"guillemotleft"), + (172, b"guilsinglleft"), + (173, b"guilsinglright"), + (174, b"fi"), + (175, b"fl"), + (177, b"endash"), + (178, b"dagger"), + (179, b"daggerdbl"), + (180, b"periodcentered"), + (182, b"paragraph"), + (183, b"bullet"), + (184, b"quotesinglbase"), + (185, b"quotedblbase"), + (186, b"quotedblright"), + (187, b"guillemotright"), + (188, b"ellipsis"), + (189, b"perthousand"), + (191, b"questiondown"), + (193, b"grave"), + (194, b"acute"), + (195, b"circumflex"), + (196, b"tilde"), + (197, b"macron"), + (198, b"breve"), + (199, b"dotaccent"), + (200, b"dieresis"), + (202, b"ring"), + (203, b"cedilla"), + (205, b"hungarumlaut"), + (206, b"ogonek"), + (207, b"caron"), + (208, b"emdash"), + (225, b"AE"), + (227, b"ordfeminine"), + (232, b"Lslash"), + (233, b"Oslash"), + (234, b"OE"), + (235, b"ordmasculine"), + (241, b"ae"), + (245, b"dotlessi"), + (248, b"lslash"), + (249, b"oslash"), + (250, b"oe"), + (251, b"germandbls"), +]; + +pub(crate) static STANDARD: [Option<&[u8]>; 256] = build(STANDARD_ENTRIES); + +const WIN_ANSI_ENTRIES: Entries = &[ + (32, b"space"), + (33, b"exclam"), + (34, b"quotedbl"), + (35, b"numbersign"), + (36, b"dollar"), + (37, b"percent"), + (38, b"ampersand"), + (39, b"quotesingle"), + (40, b"parenleft"), + (41, b"parenright"), + (42, b"asterisk"), + (43, b"plus"), + (44, b"comma"), + (45, b"hyphen"), + (46, b"period"), + (47, b"slash"), + (48, b"zero"), + (49, b"one"), + (50, b"two"), + (51, b"three"), + (52, b"four"), + (53, b"five"), + (54, b"six"), + (55, b"seven"), + (56, b"eight"), + (57, b"nine"), + (58, b"colon"), + (59, b"semicolon"), + (60, b"less"), + (61, b"equal"), + (62, b"greater"), + (63, b"question"), + (64, b"at"), + (65, b"A"), + (66, b"B"), + (67, b"C"), + (68, b"D"), + (69, b"E"), + (70, b"F"), + (71, b"G"), + (72, b"H"), + (73, b"I"), + (74, b"J"), + (75, b"K"), + (76, b"L"), + (77, b"M"), + (78, b"N"), + (79, b"O"), + (80, b"P"), + (81, b"Q"), + (82, b"R"), + (83, b"S"), + (84, b"T"), + (85, b"U"), + (86, b"V"), + (87, b"W"), + (88, b"X"), + (89, b"Y"), + (90, b"Z"), + (91, b"bracketleft"), + (92, b"backslash"), + (93, b"bracketright"), + (94, b"asciicircum"), + (95, b"underscore"), + (96, b"grave"), + (97, b"a"), + (98, b"b"), + (99, b"c"), + (100, b"d"), + (101, b"e"), + (102, b"f"), + (103, b"g"), + (104, b"h"), + (105, b"i"), + (106, b"j"), + (107, b"k"), + (108, b"l"), + (109, b"m"), + (110, b"n"), + (111, b"o"), + (112, b"p"), + (113, b"q"), + (114, b"r"), + (115, b"s"), + (116, b"t"), + (117, b"u"), + (118, b"v"), + (119, b"w"), + (120, b"x"), + (121, b"y"), + (122, b"z"), + (123, b"braceleft"), + (124, b"bar"), + (125, b"braceright"), + (126, b"asciitilde"), + (128, b"Euro"), + (130, b"quotesinglbase"), + (131, b"florin"), + (132, b"quotedblbase"), + (133, b"ellipsis"), + (134, b"dagger"), + (135, b"daggerdbl"), + (136, b"circumflex"), + (137, b"perthousand"), + (138, b"Scaron"), + (139, b"guilsinglleft"), + (140, b"OE"), + (142, b"Zcaron"), + (145, b"quoteleft"), + (146, b"quoteright"), + (147, b"quotedblleft"), + (148, b"quotedblright"), + (149, b"bullet"), + (150, b"endash"), + (151, b"emdash"), + (152, b"tilde"), + (153, b"trademark"), + (154, b"scaron"), + (155, b"guilsinglright"), + (156, b"oe"), + (158, b"zcaron"), + (159, b"Ydieresis"), + (160, b"space"), + (161, b"exclamdown"), + (162, b"cent"), + (163, b"sterling"), + (164, b"currency"), + (165, b"yen"), + (166, b"brokenbar"), + (167, b"section"), + (168, b"dieresis"), + (169, b"copyright"), + (170, b"ordfeminine"), + (171, b"guillemotleft"), + (172, b"logicalnot"), + (173, b"hyphen"), + (174, b"registered"), + (175, b"macron"), + (176, b"degree"), + (177, b"plusminus"), + (178, b"twosuperior"), + (179, b"threesuperior"), + (180, b"acute"), + (181, b"mu"), + (182, b"paragraph"), + (183, b"periodcentered"), + (184, b"cedilla"), + (185, b"onesuperior"), + (186, b"ordmasculine"), + (187, b"guillemotright"), + (188, b"onequarter"), + (189, b"onehalf"), + (190, b"threequarters"), + (191, b"questiondown"), + (192, b"Agrave"), + (193, b"Aacute"), + (194, b"Acircumflex"), + (195, b"Atilde"), + (196, b"Adieresis"), + (197, b"Aring"), + (198, b"AE"), + (199, b"Ccedilla"), + (200, b"Egrave"), + (201, b"Eacute"), + (202, b"Ecircumflex"), + (203, b"Edieresis"), + (204, b"Igrave"), + (205, b"Iacute"), + (206, b"Icircumflex"), + (207, b"Idieresis"), + (208, b"Eth"), + (209, b"Ntilde"), + (210, b"Ograve"), + (211, b"Oacute"), + (212, b"Ocircumflex"), + (213, b"Otilde"), + (214, b"Odieresis"), + (215, b"multiply"), + (216, b"Oslash"), + (217, b"Ugrave"), + (218, b"Uacute"), + (219, b"Ucircumflex"), + (220, b"Udieresis"), + (221, b"Yacute"), + (222, b"Thorn"), + (223, b"germandbls"), + (224, b"agrave"), + (225, b"aacute"), + (226, b"acircumflex"), + (227, b"atilde"), + (228, b"adieresis"), + (229, b"aring"), + (230, b"ae"), + (231, b"ccedilla"), + (232, b"egrave"), + (233, b"eacute"), + (234, b"ecircumflex"), + (235, b"edieresis"), + (236, b"igrave"), + (237, b"iacute"), + (238, b"icircumflex"), + (239, b"idieresis"), + (240, b"eth"), + (241, b"ntilde"), + (242, b"ograve"), + (243, b"oacute"), + (244, b"ocircumflex"), + (245, b"otilde"), + (246, b"odieresis"), + (247, b"divide"), + (248, b"oslash"), + (249, b"ugrave"), + (250, b"uacute"), + (251, b"ucircumflex"), + (252, b"udieresis"), + (253, b"yacute"), + (254, b"thorn"), + (255, b"ydieresis"), +]; + +pub(crate) static WIN_ANSI: [Option<&[u8]>; 256] = build(WIN_ANSI_ENTRIES); + +const MAC_ROMAN_ENTRIES: Entries = &[ + (32, b"space"), + (33, b"exclam"), + (34, b"quotedbl"), + (35, b"numbersign"), + (36, b"dollar"), + (37, b"percent"), + (38, b"ampersand"), + (39, b"quotesingle"), + (40, b"parenleft"), + (41, b"parenright"), + (42, b"asterisk"), + (43, b"plus"), + (44, b"comma"), + (45, b"hyphen"), + (46, b"period"), + (47, b"slash"), + (48, b"zero"), + (49, b"one"), + (50, b"two"), + (51, b"three"), + (52, b"four"), + (53, b"five"), + (54, b"six"), + (55, b"seven"), + (56, b"eight"), + (57, b"nine"), + (58, b"colon"), + (59, b"semicolon"), + (60, b"less"), + (61, b"equal"), + (62, b"greater"), + (63, b"question"), + (64, b"at"), + (65, b"A"), + (66, b"B"), + (67, b"C"), + (68, b"D"), + (69, b"E"), + (70, b"F"), + (71, b"G"), + (72, b"H"), + (73, b"I"), + (74, b"J"), + (75, b"K"), + (76, b"L"), + (77, b"M"), + (78, b"N"), + (79, b"O"), + (80, b"P"), + (81, b"Q"), + (82, b"R"), + (83, b"S"), + (84, b"T"), + (85, b"U"), + (86, b"V"), + (87, b"W"), + (88, b"X"), + (89, b"Y"), + (90, b"Z"), + (91, b"bracketleft"), + (92, b"backslash"), + (93, b"bracketright"), + (94, b"asciicircum"), + (95, b"underscore"), + (96, b"grave"), + (97, b"a"), + (98, b"b"), + (99, b"c"), + (100, b"d"), + (101, b"e"), + (102, b"f"), + (103, b"g"), + (104, b"h"), + (105, b"i"), + (106, b"j"), + (107, b"k"), + (108, b"l"), + (109, b"m"), + (110, b"n"), + (111, b"o"), + (112, b"p"), + (113, b"q"), + (114, b"r"), + (115, b"s"), + (116, b"t"), + (117, b"u"), + (118, b"v"), + (119, b"w"), + (120, b"x"), + (121, b"y"), + (122, b"z"), + (123, b"braceleft"), + (124, b"bar"), + (125, b"braceright"), + (126, b"asciitilde"), + (128, b"Adieresis"), + (129, b"Aring"), + (130, b"Ccedilla"), + (131, b"Eacute"), + (132, b"Ntilde"), + (133, b"Odieresis"), + (134, b"Udieresis"), + (135, b"aacute"), + (136, b"agrave"), + (137, b"acircumflex"), + (138, b"adieresis"), + (139, b"atilde"), + (140, b"aring"), + (141, b"ccedilla"), + (142, b"eacute"), + (143, b"egrave"), + (144, b"ecircumflex"), + (145, b"edieresis"), + (146, b"iacute"), + (147, b"igrave"), + (148, b"icircumflex"), + (149, b"idieresis"), + (150, b"ntilde"), + (151, b"oacute"), + (152, b"ograve"), + (153, b"ocircumflex"), + (154, b"odieresis"), + (155, b"otilde"), + (156, b"uacute"), + (157, b"ugrave"), + (158, b"ucircumflex"), + (159, b"udieresis"), + (160, b"dagger"), + (161, b"degree"), + (162, b"cent"), + (163, b"sterling"), + (164, b"section"), + (165, b"bullet"), + (166, b"paragraph"), + (167, b"germandbls"), + (168, b"registered"), + (169, b"copyright"), + (170, b"trademark"), + (171, b"acute"), + (172, b"dieresis"), + (174, b"AE"), + (175, b"Oslash"), + (177, b"plusminus"), + (180, b"yen"), + (181, b"mu"), + (187, b"ordfeminine"), + (188, b"ordmasculine"), + (190, b"ae"), + (191, b"oslash"), + (192, b"questiondown"), + (193, b"exclamdown"), + (194, b"logicalnot"), + (196, b"florin"), + (199, b"guillemotleft"), + (200, b"guillemotright"), + (201, b"ellipsis"), + (202, b"space"), + (203, b"Agrave"), + (204, b"Atilde"), + (205, b"Otilde"), + (206, b"OE"), + (207, b"oe"), + (208, b"endash"), + (209, b"emdash"), + (210, b"quotedblleft"), + (211, b"quotedblright"), + (212, b"quoteleft"), + (213, b"quoteright"), + (214, b"divide"), + (216, b"ydieresis"), + (217, b"Ydieresis"), + (218, b"fraction"), + (219, b"currency"), + (220, b"guilsinglleft"), + (221, b"guilsinglright"), + (222, b"fi"), + (223, b"fl"), + (224, b"daggerdbl"), + (225, b"periodcentered"), + (226, b"quotesinglbase"), + (227, b"quotedblbase"), + (228, b"perthousand"), + (229, b"Acircumflex"), + (230, b"Ecircumflex"), + (231, b"Aacute"), + (232, b"Edieresis"), + (233, b"Egrave"), + (234, b"Iacute"), + (235, b"Icircumflex"), + (236, b"Idieresis"), + (237, b"Igrave"), + (238, b"Oacute"), + (239, b"Ocircumflex"), + (241, b"Ograve"), + (242, b"Uacute"), + (243, b"Ucircumflex"), + (244, b"Ugrave"), + (245, b"dotlessi"), + (246, b"circumflex"), + (247, b"tilde"), + (248, b"macron"), + (249, b"breve"), + (250, b"dotaccent"), + (251, b"ring"), + (252, b"cedilla"), + (253, b"hungarumlaut"), + (254, b"ogonek"), + (255, b"caron"), +]; + +pub(crate) static MAC_ROMAN: [Option<&[u8]>; 256] = build(MAC_ROMAN_ENTRIES); + +const MAC_EXPERT_ENTRIES: Entries = &[ + (32, b"space"), + (33, b"exclamsmall"), + (34, b"Hungarumlautsmall"), + (35, b"centoldstyle"), + (36, b"dollaroldstyle"), + (37, b"dollarsuperior"), + (38, b"ampersandsmall"), + (39, b"Acutesmall"), + (40, b"parenleftsuperior"), + (41, b"parenrightsuperior"), + (42, b"twodotenleader"), + (43, b"onedotenleader"), + (44, b"comma"), + (45, b"hyphen"), + (46, b"period"), + (47, b"fraction"), + (48, b"zerooldstyle"), + (49, b"oneoldstyle"), + (50, b"twooldstyle"), + (51, b"threeoldstyle"), + (52, b"fouroldstyle"), + (53, b"fiveoldstyle"), + (54, b"sixoldstyle"), + (55, b"sevenoldstyle"), + (56, b"eightoldstyle"), + (57, b"nineoldstyle"), + (58, b"colon"), + (59, b"semicolon"), + (61, b"threequartersemdash"), + (63, b"questionsmall"), + (68, b"Ethsmall"), + (71, b"onequarter"), + (72, b"onehalf"), + (73, b"threequarters"), + (74, b"oneeighth"), + (75, b"threeeighths"), + (76, b"fiveeighths"), + (77, b"seveneighths"), + (78, b"onethird"), + (79, b"twothirds"), + (86, b"ff"), + (87, b"fi"), + (88, b"fl"), + (89, b"ffi"), + (90, b"ffl"), + (91, b"parenleftinferior"), + (93, b"parenrightinferior"), + (94, b"Circumflexsmall"), + (95, b"hypheninferior"), + (96, b"Gravesmall"), + (97, b"Asmall"), + (98, b"Bsmall"), + (99, b"Csmall"), + (100, b"Dsmall"), + (101, b"Esmall"), + (102, b"Fsmall"), + (103, b"Gsmall"), + (104, b"Hsmall"), + (105, b"Ismall"), + (106, b"Jsmall"), + (107, b"Ksmall"), + (108, b"Lsmall"), + (109, b"Msmall"), + (110, b"Nsmall"), + (111, b"Osmall"), + (112, b"Psmall"), + (113, b"Qsmall"), + (114, b"Rsmall"), + (115, b"Ssmall"), + (116, b"Tsmall"), + (117, b"Usmall"), + (118, b"Vsmall"), + (119, b"Wsmall"), + (120, b"Xsmall"), + (121, b"Ysmall"), + (122, b"Zsmall"), + (123, b"colonmonetary"), + (124, b"onefitted"), + (125, b"rupiah"), + (126, b"Tildesmall"), + (129, b"asuperior"), + (130, b"centsuperior"), + (135, b"Aacutesmall"), + (136, b"Agravesmall"), + (137, b"Acircumflexsmall"), + (138, b"Adieresissmall"), + (139, b"Atildesmall"), + (140, b"Aringsmall"), + (141, b"Ccedillasmall"), + (142, b"Eacutesmall"), + (143, b"Egravesmall"), + (144, b"Ecircumflexsmall"), + (145, b"Edieresissmall"), + (146, b"Iacutesmall"), + (147, b"Igravesmall"), + (148, b"Icircumflexsmall"), + (149, b"Idieresissmall"), + (150, b"Ntildesmall"), + (151, b"Oacutesmall"), + (152, b"Ogravesmall"), + (153, b"Ocircumflexsmall"), + (154, b"Odieresissmall"), + (155, b"Otildesmall"), + (156, b"Uacutesmall"), + (157, b"Ugravesmall"), + (158, b"Ucircumflexsmall"), + (159, b"Udieresissmall"), + (161, b"eightsuperior"), + (162, b"fourinferior"), + (163, b"threeinferior"), + (164, b"sixinferior"), + (165, b"eightinferior"), + (166, b"seveninferior"), + (167, b"Scaronsmall"), + (169, b"centinferior"), + (170, b"twoinferior"), + (172, b"Dieresissmall"), + (174, b"Caronsmall"), + (175, b"osuperior"), + (176, b"fiveinferior"), + (178, b"commainferior"), + (179, b"periodinferior"), + (180, b"Yacutesmall"), + (182, b"dollarinferior"), + (185, b"Thornsmall"), + (187, b"nineinferior"), + (188, b"zeroinferior"), + (189, b"Zcaronsmall"), + (190, b"AEsmall"), + (191, b"Oslashsmall"), + (192, b"questiondownsmall"), + (193, b"oneinferior"), + (194, b"Lslashsmall"), + (201, b"Cedillasmall"), + (207, b"OEsmall"), + (208, b"figuredash"), + (209, b"hyphensuperior"), + (214, b"exclamdownsmall"), + (216, b"Ydieresissmall"), + (218, b"onesuperior"), + (219, b"twosuperior"), + (220, b"threesuperior"), + (221, b"foursuperior"), + (222, b"fivesuperior"), + (223, b"sixsuperior"), + (224, b"sevensuperior"), + (225, b"ninesuperior"), + (226, b"zerosuperior"), + (228, b"esuperior"), + (229, b"rsuperior"), + (230, b"tsuperior"), + (233, b"isuperior"), + (234, b"ssuperior"), + (235, b"dsuperior"), + (241, b"lsuperior"), + (242, b"Ogoneksmall"), + (243, b"Brevesmall"), + (244, b"Macronsmall"), + (245, b"bsuperior"), + (246, b"nsuperior"), + (247, b"msuperior"), + (248, b"commasuperior"), + (249, b"periodsuperior"), + (250, b"Dotaccentsmall"), + (251, b"Ringsmall"), +]; + +pub(crate) static MAC_EXPERT: [Option<&[u8]>; 256] = build(MAC_EXPERT_ENTRIES); diff --git a/crates/render/src/inspect.rs b/crates/render/src/inspect.rs index 26969396a1..0cf287b3f1 100644 --- a/crates/render/src/inspect.rs +++ b/crates/render/src/inspect.rs @@ -6,6 +6,7 @@ //! are empty and `warnings` says why. use std::cell::OnceCell; +use std::collections::hash_map::Entry; use std::collections::{BTreeMap, HashMap, HashSet}; use std::sync::Arc; @@ -22,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 { @@ -110,4007 +78,12 @@ pub struct DocInfo { pub attachments: Vec, /// Pages with a presentation transition (`/Trans`) or display time (`/Dur`), in page order. pub transitions: Vec, + /// Where the document opens (`/OpenAction`): the (0-based page, view) of its destination, + /// named destinations resolved. `None` without one, for an action that goes nowhere in this + /// document (a script, say), or for one that doesn't resolve to a page of it. + pub open_dest: Option<(usize, DestView)>, 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, - 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`), for threading. - pub name: Option, - pub in_reply_to: Option, - /// 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")) - } - - /// 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] -} - -#[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; - } - } - - // ── 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. - let mut seen_arrays = HashSet::new(); - let mut seen_annotations = HashSet::new(); - 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 (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) - && !seen_annotations.insert(r) - { - continue; - } - 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 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, - 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]))); - } - - 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, - 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 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