diff --git a/.config/nextest.toml b/.config/nextest.toml new file mode 100644 index 0000000000..2a4cd2cb93 --- /dev/null +++ b/.config/nextest.toml @@ -0,0 +1,3 @@ +# Minimum cargo-nextest for this repository: GHSA-vc25-qg58-w57c is fixed in 0.9.150 and CI +# installs exactly that version, so an older nextest refuses to run (exit 92 REQUIRED_VERSION_NOT_MET). +nextest-version = "0.9.150" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9d168c7aa6..94a4c545fe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -82,7 +82,7 @@ jobs: libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev libgtk-3-dev qpdf poppler-utils # cargo-deny for the deny step of `cargo xtask ci`, which requires it under CI. - tools: cargo-nextest@0.9.149,cargo-deny@0.20.2 + tools: cargo-nextest@0.9.150,cargo-deny@0.20.2 # A missing oracle fails the job instead of printing a skip notice that reads as a pass. linux-env: PDFCRAFT_ORACLES_REQUIRED=1 windows-env: PDFCRAFT_REQUIRE_WINRES=1 diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index c5be9d7bc1..fa3efb62a7 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -8,34 +8,34 @@ Every asset PdfCraft includes, bundles or uses to build its published material, | Asset | Title | Author | Licence | Source | Used for | |---|---|---|---|---|---| -| `crates/ui-egui/src/i18n/el.tsv` | Greek interface translation catalog | Camthalion77 and contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft English UI labels; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Greek UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/zh-hans.tsv` | Simplified Chinese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/zh-hant.tsv` | Traditional Chinese (Taiwan) interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/cs.tsv` | Czech interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check and standard-identification wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Czech UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/pt-br.tsv` | Brazilian Portuguese interface translation catalog | André Cardoso (andrecards), PdfCraft contributors | MIT OR Apache-2.0 | Clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources: the ~1,826-entry gist posted in issue #288 (https://gist.github.com/andrecards/ac388c25fba17312ce83207f8b7b467e), completed and reviewed by PdfCraft contributors; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table limit messages are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/es.tsv` | Spanish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; the 2026-10-20 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/fr.tsv` | French interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/de.tsv` | German interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/lo.tsv` | Lao interface translation catalog | @bounkirdni-2025 | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Lao UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/te.tsv` | Telugu interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/it.tsv` | Italian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Italian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/pl.tsv` | Polish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Polish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/ru.tsv` | Russian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/hu.tsv` | Hungarian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/bg.tsv` | Bulgarian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/kk.tsv` | Kazakh interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of the English UI labels into Kazakh, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Kazakh UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/ja.tsv` | Japanese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/uk.tsv` | Ukrainian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/ar.tsv` | Arabic interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/ckb.tsv` | Central Kurdish (Sorani) interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Central Kurdish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/fa.tsv` | Persian (Farsi) interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-25 initial catalog covering the current 2,776-entry key union, AI-assisted translations from English, not separately native-reviewed | Persian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/tr.tsv` | Turkish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Turkish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/lt.tsv` | Lithuanian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; original catalog reviewed by a native Lithuanian-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/hi.tsv` | Hindi interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Hindi UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/id.tsv` | Indonesian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Indonesian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/ko.tsv` | Korean interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Korean UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/ro.tsv` | Romanian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; AI-assisted and not yet reviewed line by line by a native Romanian speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Romanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/fi.tsv` | Finnish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: AI-assisted (Claude Code) clean-room translations of PdfCraft's English UI labels and user-facing messages; not yet proofread by a native Finnish speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 calculation-order, text-format, print, thumbnail, layer, comment-reshape and signature labels (previously English placeholders) are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Finnish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/el.tsv` | Greek interface translation catalog | Camthalion77 and contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft English UI labels; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Greek UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/zh-hans.tsv` | Simplified Chinese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/zh-hant.tsv` | Traditional Chinese (Taiwan) interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/cs.tsv` | Czech interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check and standard-identification wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Czech UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/pt-br.tsv` | Brazilian Portuguese interface translation catalog | André Cardoso (andrecards), PdfCraft contributors | MIT OR Apache-2.0 | Clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources: the ~1,826-entry gist posted in issue #288 (https://gist.github.com/andrecards/ac388c25fba17312ce83207f8b7b467e), completed and reviewed by PdfCraft contributors; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table limit messages are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/es.tsv` | Spanish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; the 2026-10-20 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Spanish UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/fr.tsv` | French interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | French UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/de.tsv` | German interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | German UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/lo.tsv` | Lao interface translation catalog | @bounkirdni-2025 | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Lao UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/te.tsv` | Telugu interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Telugu UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/it.tsv` | Italian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Italian UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/pl.tsv` | Polish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Polish UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/ru.tsv` | Russian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Russian UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/hu.tsv` | Hungarian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Hungarian UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/bg.tsv` | Bulgarian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/kk.tsv` | Kazakh interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of the English UI labels into Kazakh, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Kazakh UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/ja.tsv` | Japanese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Japanese UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/uk.tsv` | Ukrainian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/ar.tsv` | Arabic interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Arabic UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/ckb.tsv` | Central Kurdish (Sorani) interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Central Kurdish UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/fa.tsv` | Persian (Farsi) interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-25 initial catalog covering the current 2,776-entry key union, AI-assisted translations from English, not separately native-reviewed | Persian UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/tr.tsv` | Turkish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Turkish UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/lt.tsv` | Lithuanian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; original catalog reviewed by a native Lithuanian-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/hi.tsv` | Hindi interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Hindi UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/id.tsv` | Indonesian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Indonesian UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/ko.tsv` | Korean interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Korean UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/ro.tsv` | Romanian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; AI-assisted and not yet reviewed line by line by a native Romanian speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Romanian UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/fi.tsv` | Finnish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: AI-assisted (Claude Code) clean-room translations of PdfCraft's English UI labels and user-facing messages; not yet proofread by a native Finnish speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 calculation-order, text-format, print, thumbnail, layer, comment-reshape and signature labels (previously English placeholders) are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Finnish UI strings, compiled in by crates/ui-egui/src/i18n.rs | | `assets/icons/accessibility.svg` | Lucide icon "accessibility" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/accessibility.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/align-left.svg` | Lucide icon "align-left" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-left.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/align-center.svg` | Lucide icon "align-center" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-center.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | @@ -286,17 +286,17 @@ Every asset PdfCraft includes, bundles or uses to build its published material, | `packaging/macos/dmg/background.tiff` | PdfCraft macOS DMG window background (rendered) | @XusBadia | MIT OR Apache-2.0 | Rendered from packaging/macos/dmg/background.svg by packaging/macos/dmg/generate.py | 1x + 2x HiDPI 16-colour palette TIFF copied into the macOS DMG by packaging/macos/package.sh; includes the app icon and the free LittleCMS built-in sRGB profile | | `crates/sign/tests/data/x509-rsa-sha1.pdf` | One-page PDF with a legacy adbe.x509.rsa_sha1 signature by the RSA test key | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: hand-written PDF objects; signature made with `openssl dgst -sha1 -sign` over its byte ranges (see crates/sign/tests/data/README.md) | Signature validation tests (crates/sign/tests/it/pdf.rs): the legacy adbe.x509.rsa_sha1 format | | `assets/icons/monitor.svg` | Lucide icon "monitor" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/monitor.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `crates/ui-egui/src/i18n/vi.tsv` | Vietnamese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels; PhotoCraft and VectorCraft Vietnamese catalogs used only as translation memory when English source and semantic context matched; original catalog reviewed by a native Vietnamese-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/sl.tsv` | Slovene interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Slovene UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/vi.tsv` | Vietnamese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels; PhotoCraft and VectorCraft Vietnamese catalogs used only as translation memory when English source and semantic context matched; original catalog reviewed by a native Vietnamese-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/sl.tsv` | Slovene interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Slovene UI strings, compiled in by crates/ui-egui/src/i18n.rs | | `vendor/vello_common/assets/probe.png` | vello_common probe reference image (rendered from probe.rgba) | Linebender vello contributors | MIT OR Apache-2.0 | vello_common 0.0.8 (crates.io), vendored in vendor/vello_common; regenerated by its vello_sparse_tests regenerate_probe_reference binary | Reference picture of the vendored vello_common probe module, for people only; the build reads probe.rgba, not this file | -| `crates/ui-egui/src/i18n/nb.tsv` | Norwegian Bokmål interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Norwegian Bokmål UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/nb.tsv` | Norwegian Bokmål interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Norwegian Bokmål UI strings, compiled in by crates/ui-egui/src/i18n.rs | | `assets/icons/superscript.svg` | Lucide icon "superscript" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/superscript.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/subscript.svg` | Lucide icon "subscript" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/subscript.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/pilcrow.svg` | Lucide icon "pilcrow" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pilcrow.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/move-horizontal.svg` | Lucide icon "move-horizontal" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/move-horizontal.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `crates/ui-egui/src/i18n/sq.tsv` | Albanian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-appearance labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Albanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/th.tsv` | Thai interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Thai UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/ca.tsv` | Catalan interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, written from their meaning; no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Catalan UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/sq.tsv` | Albanian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-appearance labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Albanian UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/th.tsv` | Thai interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Thai UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/ca.tsv` | Catalan interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, written from their meaning; no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Catalan UI strings, compiled in by crates/ui-egui/src/i18n.rs | ## Compiled in through dependencies (6) diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index 8b2186d5b4..bfaf948862 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -6,7 +6,7 @@ source = "Contributor-original: clean-room translations of PdfCraft English UI l licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "Greek UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" +usage = "Greek UI strings, compiled in by crates/ui-egui/src/i18n.rs" # ATTRIBUTION.toml: one entry per asset (AGENTS.md §1). The source of truth for provenance and licences. # ATTRIBUTION.md is generated from this file: `cargo xtask assets --write`. Checked by `cargo xtask assets` (part of `xtask ci`). @@ -30,7 +30,7 @@ source = "Contributor-original: clean-room translations of PdfCraft's English UI licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" +usage = "Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/zh-hant.tsv" @@ -40,7 +40,7 @@ source = "Contributor-original: clean-room translations of PdfCraft's English UI licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" +usage = "Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/cs.tsv" @@ -50,7 +50,7 @@ source = "Contributor-original: clean-room translations of PdfCraft's English UI licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "Czech UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" +usage = "Czech UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/pt-br.tsv" @@ -60,7 +60,7 @@ source = "Clean-room translations of PdfCraft's English UI labels, no proprietar licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" +usage = "Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/es.tsv" @@ -70,7 +70,7 @@ source = "Contributor-original: clean-room translations of PdfCraft's English UI licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" +usage = "Spanish UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/fr.tsv" @@ -80,7 +80,7 @@ source = "Contributor-original: clean-room translations of PdfCraft's English UI licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" +usage = "French UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/de.tsv" @@ -90,7 +90,7 @@ source = "Contributor-original: clean-room translations of PdfCraft's English UI licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" +usage = "German UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/lo.tsv" @@ -100,7 +100,7 @@ source = "Contributor-original: clean-room translations of PdfCraft English UI l licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "Lao UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" +usage = "Lao UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/te.tsv" @@ -110,7 +110,7 @@ source = "Contributor-original: clean-room translations of PdfCraft's English UI licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" +usage = "Telugu UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/it.tsv" @@ -120,7 +120,7 @@ source = "Contributor-original: clean-room translations of PdfCraft's English UI licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "Italian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" +usage = "Italian UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/pl.tsv" @@ -130,7 +130,7 @@ source = "Contributor-original: clean-room translations of PdfCraft's English UI licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "Polish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" +usage = "Polish UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/ru.tsv" @@ -140,7 +140,7 @@ source = "Contributor-original: clean-room translations of PdfCraft's English UI licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" +usage = "Russian UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/hu.tsv" @@ -150,7 +150,7 @@ source = "Contributor-original: clean-room translations of PdfCraft's English UI licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" +usage = "Hungarian UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/bg.tsv" @@ -160,7 +160,7 @@ source = "Contributor-original: clean-room translations of PdfCraft's English UI licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" +usage = "Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/kk.tsv" @@ -170,7 +170,7 @@ source = "Contributor-original: clean-room translations of the English UI labels licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "Kazakh UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" +usage = "Kazakh UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/ja.tsv" @@ -180,7 +180,7 @@ source = "Contributor-original: clean-room translations of PdfCraft's English UI licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" +usage = "Japanese UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/uk.tsv" @@ -190,7 +190,7 @@ source = "Contributor-original: clean-room translations of PdfCraft's English UI licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" +usage = "Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/ar.tsv" @@ -200,7 +200,7 @@ source = "Contributor-original: clean-room translations of PdfCraft's English UI licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" +usage = "Arabic UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/ckb.tsv" @@ -210,7 +210,7 @@ source = "Contributor-original: clean-room translations of PdfCraft's English UI licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "Central Kurdish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" +usage = "Central Kurdish UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/fa.tsv" @@ -220,7 +220,7 @@ source = "Contributor-original: clean-room translations of PdfCraft's English UI licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "Persian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" +usage = "Persian UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/tr.tsv" @@ -230,7 +230,7 @@ source = "Contributor-original: clean-room translations of PdfCraft's English UI licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "Turkish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" +usage = "Turkish UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/lt.tsv" @@ -240,7 +240,7 @@ source = "Contributor-original: clean-room translations of PdfCraft's English UI licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" +usage = "Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/hi.tsv" @@ -250,7 +250,7 @@ source = "Contributor-original: clean-room translations of PdfCraft's English UI licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "Hindi UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" +usage = "Hindi UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/id.tsv" title = "Indonesian interface translation catalog" @@ -259,7 +259,7 @@ source = "Contributor-original: clean-room translations of PdfCraft's English UI licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "Indonesian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" +usage = "Indonesian UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/ko.tsv" title = "Korean interface translation catalog" @@ -268,7 +268,7 @@ source = "Contributor-original: clean-room translations of PdfCraft's English UI licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "Korean UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" +usage = "Korean UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/ro.tsv" @@ -278,7 +278,7 @@ source = "Contributor-original: clean-room translations of PdfCraft's English UI licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "Romanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" +usage = "Romanian UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] @@ -289,7 +289,7 @@ source = "Contributor-original: AI-assisted (Claude Code) clean-room translation licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "Finnish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" +usage = "Finnish UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "assets/icons/accessibility.svg" @@ -3422,7 +3422,7 @@ source = "Contributor-original: clean-room translations of PdfCraft's English UI licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" +usage = "Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/sl.tsv" @@ -3432,7 +3432,7 @@ source = "Contributor-original: clean-room translations of PdfCraft's English UI licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "Slovene UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" +usage = "Slovene UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] @@ -3454,7 +3454,7 @@ source = "Contributor-original: clean-room translations of PdfCraft's English UI licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "Norwegian Bokmål UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" +usage = "Norwegian Bokmål UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "assets/icons/superscript.svg" @@ -3508,7 +3508,7 @@ source = "Contributor-original: clean-room translations of PdfCraft's English UI licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "Albanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" +usage = "Albanian UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/th.tsv" @@ -3518,7 +3518,7 @@ source = "Contributor-original: clean-room translations of PdfCraft's English UI licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "Thai UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" +usage = "Thai UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/ca.tsv" @@ -3528,4 +3528,4 @@ source = "Contributor-original: clean-room translations of PdfCraft's English UI licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "Catalan UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" +usage = "Catalan UI strings, compiled in by crates/ui-egui/src/i18n.rs" diff --git a/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/src/main.rs b/apps/pdfcraft/src/main.rs index ee8ae152cc..f8e4e5b0d2 100644 --- a/apps/pdfcraft/src/main.rs +++ b/apps/pdfcraft/src/main.rs @@ -802,705 +802,4 @@ fn pick_adapter(adapters: &[(u32, u32, eframe::wgpu::DeviceType)], displays: &[D } #[cfg(test)] -mod tests { - /// The lockfile package block for `name`, or a panic naming the missing package. - fn locked_package(root: &std::path::Path, name: &str) -> String { - let lock = std::fs::read_to_string(root.join("Cargo.lock")).expect("Cargo.lock"); - lock.split("[[package]]") - .find(|p| p.contains(&format!("\nname = \"{name}\"\n"))) - .map(str::to_owned) - .unwrap_or_else(|| panic!("{name} is not in Cargo.lock")) - } - - /// Interim graph check for the window stack while the reviewed fork revision is not yet - /// published. The old `vendor/winit` / `vendor/egui-wgpu` path patches were removed from the - /// published manifest; local integration supplies the reviewed worktrees through an external - /// Cargo config patch. A git source must name the public storytold fork; a path source is the - /// local integration graph. Behaviour (surface fitting, error scopes, scroll phases) is tested - /// directly elsewhere; the retained vendor copies keep provenance until the fork revision is - /// pinned. No public revision is invented here. - fn assert_window_stack_graph(root: &std::path::Path, name: &str, version: &str) -> String { - let package = locked_package(root, name); - assert!(package.contains(&format!("\nversion = \"{version}\"\n")), "{name} must resolve to {version}: {package}"); - if let Some(source) = package.lines().find_map(|line| line.strip_prefix("source = ")) { - assert!(source.contains("github.com/storytold/"), "{name} must come from the reviewed storytold fork once published, not {source}"); - assert!( - source.split(|c: char| !c.is_ascii_hexdigit()).any(|part| part.len() == 40), - "{name} must pin the fork's 40-character revision, not {source}" - ); - } - // A path patch has no `source =` in Cargo.lock. The local integration config is the only - // allowed path source; a machine-local path must never be in the published manifest, - // which the root-manifest assertion below checks. A path entry carries no remote identity - // to verify, so this test does not claim the resolved code is the published fork. - let manifest = std::fs::read_to_string(root.join("Cargo.toml")).expect("root Cargo.toml"); - assert!(!manifest.contains(&format!("{name} = {{ path = \"vendor/")), "vendor/{name} must not be a published path patch"); - package - } - - use pdfcraft_ui_egui::control::Endpoint; - - fn endpoint() -> std::io::Result { - Ok(Endpoint { port: 4321, token: "0123456789abcdef".into() }) - } - - /// Tests that open a real GPU device take this first, so only one of them is inside the - /// graphics libraries at a time. The test threads otherwise run them together, and on - /// FreeBSD's CI machine the test process then died with SIGSEGV, in about half the runs: - /// the first device was still being set up when the second test began its own. The UI - /// tests take turns the same way (`crates/ui-egui/tests/it/main.rs`). Hold it for the whole - /// test (declared before the instance, so it's released after the device is dropped). - static GPU: std::sync::Mutex<()> = std::sync::Mutex::new(()); - - fn gpu() -> std::sync::MutexGuard<'static, ()> { - // A test that panicked while holding it leaves nothing to clean up. - GPU.lock().unwrap_or_else(std::sync::PoisonError::into_inner) - } - - #[test] - fn a_control_channel_that_cant_be_published_stops_the_app() { - let dir = std::env::temp_dir().join(format!("pdfcraft-app-control-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - let path = |name: &str| dir.join(name).to_str().unwrap().to_owned(); - - // A folder in the way stands in for a file another user created first: the write fails. - std::fs::create_dir(dir.join("taken.json")).unwrap(); - let err = super::open_control_channel(&path("taken.json"), endpoint()).unwrap_err(); - assert!(err.contains("--control") && err.contains("taken.json") && err.contains("only you can write"), "{err}"); - // No listener: nothing to publish. - let err = super::open_control_channel(&path("pc.json"), Err(std::io::Error::other("no loopback"))).unwrap_err(); - assert!(err.contains("no loopback"), "{err}"); - assert!(!dir.join("pc.json").exists()); - - super::open_control_channel(&path("pc.json"), endpoint()).unwrap(); - let written: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(dir.join("pc.json")).unwrap()).unwrap(); - assert_eq!((written["port"].as_u64(), written["token"].as_str()), (Some(4321), Some("0123456789abcdef"))); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn path_from_arg_decodes_file_uris() { - assert_eq!(super::path_from_arg("file:///home/alice/report.pdf".to_string()), "/home/alice/report.pdf"); - assert_eq!(super::path_from_arg("file:///home/alice/my%20report.pdf".to_string()), "/home/alice/my report.pdf"); - assert_eq!(super::path_from_arg("file://localhost/tmp/a%C3%A9.pdf".to_string()), "/tmp/aé.pdf"); - // Malformed or truncated escapes are kept as written; bytes that aren't UTF-8 keep the URI. - assert_eq!(super::path_from_arg("file:///tmp/100%.pdf".to_string()), "/tmp/100%.pdf"); - assert_eq!(super::path_from_arg("file:///tmp/a%2".to_string()), "/tmp/a%2"); - assert_eq!(super::path_from_arg("file:///tmp/%zz%".to_string()), "/tmp/%zz%"); - assert_eq!(super::path_from_arg("file:///tmp/%FF.pdf".to_string()), "file:///tmp/%FF.pdf"); - } - - #[test] - fn unquoted_paths_with_spaces_are_rejoined() { - let have = ["P:\\Archive\\00042A26 Contract.PDF", "C:\\a b c.pdf", "C:\\a.pdf", "C:\\x y.pdf"]; - let exists = |p: &str| have.contains(&p); - let args = |a: &[&str]| a.iter().map(|s| s.to_string()).collect::>(); - // The case an office program produced: one path, split at its space. - assert_eq!( - super::rejoin_split_paths(args(&["P:\\Archive\\00042A26", "Contract.PDF"]), exists), - args(&["P:\\Archive\\00042A26 Contract.PDF"]) - ); - // The longest run wins; files that exist on their own are left alone. - assert_eq!(super::rejoin_split_paths(args(&["C:\\a", "b", "c.pdf", "C:\\a.pdf"]), exists), args(&["C:\\a b c.pdf", "C:\\a.pdf"])); - assert_eq!(super::rejoin_split_paths(args(&["C:\\a.pdf", "C:\\x", "y.pdf"]), exists), args(&["C:\\a.pdf", "C:\\x y.pdf"])); - // Nothing to join: kept as given, so the missing file is still reported by name. - assert_eq!(super::rejoin_split_paths(args(&["missing.pdf", "other"]), exists), args(&["missing.pdf", "other"])); - assert!(super::rejoin_split_paths(Vec::new(), exists).is_empty()); - } - - #[test] - fn path_from_arg_leaves_plain_paths_and_other_schemes_alone() { - assert_eq!(super::path_from_arg("report.pdf".to_string()), "report.pdf"); - assert_eq!(super::path_from_arg("/home/alice/report.pdf".to_string()), "/home/alice/report.pdf"); - assert_eq!(super::path_from_arg("https://example.com/report.pdf".to_string()), "https://example.com/report.pdf"); - assert_eq!(super::path_from_arg("file://server/share/a.pdf".to_string()), "file://server/share/a.pdf"); - assert_eq!(super::path_from_arg("file://".to_string()), "file://"); - } - - #[test] - fn opengl_is_the_fallback_unless_a_renderer_was_chosen() { - use super::{RendererChoice, native_options, renderer_choice, retry_with_gl}; - assert_eq!(renderer_choice(None), RendererChoice::Auto); - assert_eq!(renderer_choice(Some("")), RendererChoice::Auto); - assert_eq!(renderer_choice(Some("vulkan")), RendererChoice::Auto); - assert_eq!(renderer_choice(Some(" WGPU ")), RendererChoice::Wgpu); - for gl in ["gl", "OpenGL", "glow"] { - assert_eq!(renderer_choice(Some(gl)), RendererChoice::Gl); - } - // Retried with OpenGL only when wgpu failed to start by default, never after the app ran. - assert!(retry_with_gl(RendererChoice::Auto, false)); - assert!(!retry_with_gl(RendererChoice::Auto, true)); - assert!(!retry_with_gl(RendererChoice::Wgpu, false)); - assert!(!retry_with_gl(RendererChoice::Gl, false)); - // Each run states its renderer rather than relying on eframe's default. - assert_eq!(native_options(false, false, eframe::Renderer::Wgpu).renderer, eframe::Renderer::Wgpu); - assert_eq!(native_options(false, false, eframe::Renderer::Glow).renderer, eframe::Renderer::Glow); - } - - #[test] - fn the_desktop_frame_is_turned_off_only_when_the_app_draws_one() { - use super::native_options; - assert_eq!(native_options(false, false, eframe::Renderer::Glow).viewport.decorations, None); - assert_eq!(native_options(false, true, eframe::Renderer::Glow).viewport.decorations, Some(false)); - } - - #[test] - fn a_software_renderer_is_shown_to_the_user() { - use eframe::wgpu::DeviceType; - // Issue #519: WARP or llvmpipe is announced in the app, not just in pdfcraft.log. - let mut app = super::PdfCraftApp::new(); - super::notify_software_renderer(&mut app, DeviceType::Cpu); - let notice = app.toast.as_ref().map(|(m, _)| m.as_str()).unwrap_or_default(); - assert!(notice.contains("without a graphics processor"), "{notice:?}"); - for gpu in [DeviceType::IntegratedGpu, DeviceType::DiscreteGpu, DeviceType::VirtualGpu, DeviceType::Other] { - let mut app = super::PdfCraftApp::new(); - super::notify_software_renderer(&mut app, gpu); - assert!(app.toast.is_none(), "{gpu:?}: {:?}", app.toast); - } - } - - #[test] - fn gpu_setup_avoids_vulkan_and_stretched_resizes_on_windows_and_prefers_low_power() { - let mut native = eframe::NativeOptions::default(); - super::configure_gpu(&mut native); - let eframe::egui_wgpu::WgpuSetup::CreateNew(setup) = &native.wgpu_options.wgpu_setup else { - panic!("default setup creates its own instance") - }; - if std::env::var_os("WGPU_POWER_PREF").is_none() { - assert_eq!(setup.power_preference, eframe::wgpu::PowerPreference::LowPower); - } - if std::env::var_os("WGPU_VALIDATION_INDIRECT_CALL").is_none() { - let flags = setup.instance_descriptor.flags; - assert!(!flags.contains(eframe::wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL), "Intel Metal can't compile its shader: {flags:?}"); - } - if cfg!(target_os = "windows") && std::env::var_os("WGPU_BACKEND").is_none() { - let backends = setup.instance_descriptor.backends; - assert!(backends.contains(eframe::wgpu::Backends::DX12), "{backends:?}"); - assert!(!backends.contains(eframe::wgpu::Backends::VULKAN), "issue #37: {backends:?}"); - } - use eframe::wgpu::Dx12SwapchainKind::{DxgiFromHwnd, DxgiFromVisual}; - assert_eq!(super::dx12_presentation(None), DxgiFromVisual); - assert_eq!(super::dx12_presentation(Some(DxgiFromHwnd)), DxgiFromHwnd, "WGPU_DX12_PRESENTATION_SYSTEM still decides"); - assert_eq!(super::dx12_presentation(Some(DxgiFromVisual)), DxgiFromVisual); - let presentation = setup.instance_descriptor.backend_options.dx12.presentation_system; - if cfg!(target_os = "windows") { - // A composition visual unless WGPU_DX12_PRESENTATION_SYSTEM asks otherwise. - let asked = eframe::wgpu::Dx12SwapchainKind::from_env(); - assert_eq!(presentation, asked.unwrap_or(DxgiFromVisual), "issue #2738: resizing stretches a window swap chain"); - } else { - assert_eq!(presentation, eframe::wgpu::Dx12SwapchainKind::default().with_env(), "only Windows changes it"); - } - } - - #[test] - fn restored_hidpi_window_fits_the_adapters_surface_limit() { - // The Outlook launch crash: 3440 x 1369 saved points at 250% DPI requested this surface. - let (width, height) = (8600, 3423); - let required = eframe::wgpu::Limits::default(); - assert!(width > required.max_texture_dimension_2d); - let supported = eframe::wgpu::Limits { max_texture_dimension_2d: 16384, ..required.clone() }; - let enabled = super::surface_texture_limits(required, &supported); - assert!(width <= enabled.max_texture_dimension_2d); - assert!(height <= enabled.max_texture_dimension_2d); - } - - #[test] - fn surface_limits_preserve_other_requirements_and_never_overrequest() { - for required in [eframe::wgpu::Limits::default(), eframe::wgpu::Limits::downlevel_webgl2_defaults()] { - for extent in [4096, 8192, 16384] { - let supported = eframe::wgpu::Limits { - max_texture_dimension_2d: extent, - max_bind_groups: 8, - min_uniform_buffer_offset_alignment: 128, - min_storage_buffer_offset_alignment: 128, - ..required.clone() - }; - let enabled = super::surface_texture_limits(required.clone(), &supported); - let mut expected = required.clone(); - expected.max_texture_dimension_2d = extent; - assert_eq!(enabled, expected); - assert_eq!(enabled.max_texture_dimension_2d, supported.max_texture_dimension_2d); - assert!(enabled.check_limits(&supported)); - } - } - } - - #[test] - fn surface_limits_fit_v3d_shader_variable_limit() { - // Issue #2383: V3D supports 15 variables, below the default request of 16. - let required = eframe::wgpu::Limits::default(); - let supported = eframe::wgpu::Limits { max_texture_dimension_2d: 7680, max_inter_stage_shader_variables: 15, ..required.clone() }; - let enabled = super::surface_texture_limits(required.clone(), &supported); - assert!(enabled.check_limits(&supported), "unsupported device limits: {enabled:?}"); - let expected = eframe::wgpu::Limits { max_texture_dimension_2d: 7680, max_inter_stage_shader_variables: 15, ..required }; - assert_eq!(enabled, expected); - } - - #[test] - fn surface_limits_fit_downlevel_maxima_and_minimum_alignments() { - let required = eframe::wgpu::Limits::downlevel_webgl2_defaults(); - let supported = eframe::wgpu::Limits { - max_texture_dimension_2d: 2048, - max_bind_groups: 2, - max_vertex_attributes: 8, - min_uniform_buffer_offset_alignment: 512, - min_storage_buffer_offset_alignment: 512, - ..required.clone() - }; - let enabled = super::surface_texture_limits(required, &supported); - assert!(enabled.check_limits(&supported), "unsupported device limits: {enabled:?}"); - // Larger minimum alignments are weaker capabilities, unlike maximum limits. - assert_eq!(enabled, supported); - } - - #[test] - fn a_window_larger_than_the_gpu_limit_gets_a_surface_within_it() { - use eframe::egui_wgpu::winit::surface_fit; - // Issue #577: 3440 x 1369 points restored at 250% asked an 8192-pixel device for this. - let (width, height, scale) = surface_fit(8600, 3423, 8192); - assert_eq!((width, height), (8192, 3260)); - // Both sides shrink alike and egui draws at that factor: the whole window is drawn. - assert!((8600.0 * scale - 8192.0).abs() < 0.01, "{scale}"); - assert!((3423.0 * scale - height as f32).abs() < 1.0, "{scale}"); - // Within the limit nothing changes, up to and including the limit itself. - assert_eq!(surface_fit(8600, 3423, 16384), (8600, 3423, 1.0)); - assert_eq!(surface_fit(8192, 8192, 8192), (8192, 8192, 1.0)); - assert_eq!(surface_fit(0, 0, 8192), (0, 0, 1.0)); - // One side over the limit: the window as it would be stretched across monitors. - assert_eq!(surface_fit(8193, 600, 8192).0, 8192); - assert_eq!(surface_fit(600, 8193, 8192).1, 8192); - } - - #[test] - fn surface_fit_never_empties_or_overflows_a_surface() { - use eframe::egui_wgpu::winit::surface_fit; - for (w, h, max) in [ - (1, u32::MAX, 8192), - (u32::MAX, 1, 2048), - (u32::MAX, u32::MAX, 16384), - (0, u32::MAX, 8192), - (u32::MAX, 0, 8192), - (5, 7, 0), - (40_000, 3, 1), - ] { - let (fw, fh, scale) = surface_fit(w, h, max); - assert!(fw <= max.max(1) && fh <= max.max(1), "{w} x {h} in {max}: {fw} x {fh}"); - // A side that wasn't zero stays non-zero (`Surface::configure` rejects an empty one), - // and a zero side stays zero (egui-wgpu skips configuring it). - assert_eq!((fw == 0, fh == 0), (w == 0, h == 0), "{w} x {h} in {max}: {fw} x {fh}"); - assert!(scale.is_finite() && scale > 0.0 && scale <= 1.0, "{w} x {h} in {max}: {scale}"); - } - // The longer side lands exactly on the limit (rounding never leaves it a pixel short), - // and the shorter side keeps the window's proportions to within a pixel. - for max in [2048, 8192, 16384] { - for long in (max + 1..=max * 5).step_by(997).chain([max * 2, max * 4, u32::MAX]) { - for short in [1, 3, 600, max / 3, max - 1, max, long - 1, long] { - let (fw, fh, scale) = surface_fit(long, short, max); - assert_eq!(fw, max, "{long} x {short} in {max}"); - let expected = f64::from(short) * f64::from(max) / f64::from(long); - assert!((f64::from(fh) - expected).abs() <= 1.0, "{long} x {short} in {max}: {fh}"); - assert_eq!(surface_fit(short, long, max), (fh, fw, scale), "transposed"); - } - } - } - } - - /// Regression test: the lockfile resolves egui-wgpu 0.37.0 through an allowed source, the root manifest - /// does not path-patch the vendor copy, and that copy stays as provenance for the surface-fit patch. - #[test] - fn vendor_provenance_egui_wgpu_surface_fit_identity() { - // Issue #577: egui-wgpu configured a window's surface at the window's size, and - // `Surface::configure` panics when that's beyond the device's `max_texture_dimension_2d` - // (emilk/egui#8361). The reviewed egui 0.37 worktree carries E2 (`surface_fit`); the - // behavioural test above exercises it. The old 0.36 vendor copy is retained for - // provenance only and is no longer a published path patch, so reading it would not prove - // what the app runs. - let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); - assert_window_stack_graph(&root, "egui-wgpu", "0.37.0"); - assert!(root.join("vendor/egui-wgpu/src/winit.rs").exists(), "the vendor copy and its provenance stay until the fork revision is pinned"); - } - - #[test] - fn a_gpu_error_while_a_window_is_set_up_is_returned_not_a_panic() { - // Issue #519: on a 2015 Intel GPU, wgpu's GL backend couldn't configure the window's - // surface (`GpuWaitTimeout`), and wgpu's default error handler panicked before the app was - // created, so PdfCraft never got to retry with OpenGL. vendor/egui-wgpu configures a new - // window's surface inside `catch_errors`. A real device on PdfCraft's own GPU settings, - // and an error wgpu raises before anything reaches the driver: a texture one pixel wider - // than the device allows. - use eframe::wgpu; - let _gpu = gpu(); - let native = super::native_options(false, false, eframe::Renderer::Wgpu); - let eframe::egui_wgpu::WgpuSetup::CreateNew(setup) = &native.wgpu_options.wgpu_setup else { - panic!("default setup creates its own instance") - }; - let instance = pollster::block_on(native.wgpu_options.wgpu_setup.new_instance()); - let options = wgpu::RequestAdapterOptions { power_preference: setup.power_preference, ..Default::default() }; - // CI runners have a software adapter (WARP, llvmpipe); a machine without any skips. - let Ok(adapter) = pollster::block_on(instance.request_adapter(&options)) else { - eprintln!("skipping: no GPU adapter on this machine"); - return; - }; - let Ok((device, _queue)) = pollster::block_on(adapter.request_device(&(setup.device_descriptor)(&adapter))) else { - eprintln!("skipping: the adapter gives no device"); - return; - }; - let texture = |width: u32| { - device.create_texture(&wgpu::TextureDescriptor { - label: Some("issue-519"), - size: wgpu::Extent3d { width, height: 1, depth_or_array_layers: 1 }, - mip_level_count: 1, - sample_count: 1, - dimension: wgpu::TextureDimension::D2, - format: wgpu::TextureFormat::Rgba8Unorm, - usage: wgpu::TextureUsages::TEXTURE_BINDING, - view_formats: &[], - }) - }; - let too_wide = device.limits().max_texture_dimension_2d.saturating_add(1); - let caught = pollster::block_on(eframe::egui_wgpu::winit::catch_errors(&device, || texture(too_wide))); - assert!(matches!(caught, Err(wgpu::Error::Validation { .. })), "{caught:?}"); - // The device stays usable, and nothing is left behind to catch later errors by mistake. - let fine = pollster::block_on(eframe::egui_wgpu::winit::catch_errors(&device, || texture(16))); - assert!(fine.is_ok(), "{fine:?}"); - assert_eq!(pollster::block_on(eframe::egui_wgpu::winit::catch_errors(&device, || 7)).ok(), Some(7)); - // Nested: the inner call keeps its own error, and the outer one still catches what follows. - let caught = pollster::block_on(eframe::egui_wgpu::winit::catch_errors(&device, || { - let inner = pollster::block_on(eframe::egui_wgpu::winit::catch_errors(&device, || texture(too_wide))); - assert!(inner.is_err(), "inner: {inner:?}"); - texture(too_wide) - })); - assert!(matches!(caught, Err(wgpu::Error::Validation { .. })), "outer: {caught:?}"); - } - - /// Regression test: the lockfile resolves egui-wgpu 0.37.0 through an allowed source, the root manifest - /// does not path-patch the vendor copy, and that copy stays as provenance for the catch-errors patch. - #[test] - fn vendor_provenance_egui_wgpu_catch_errors_identity() { - // Issue #519: E3 (`catch_errors`) is tested above against a real device. The reviewed 0.37 - // worktree carries it around the first configure of every new window's surface, and its - // error reaches eframe as `WgpuError` (eframe then returns `Error::Wgpu`, which `main` - // retries with OpenGL). Without it a failed configure panics before the app is created. - // `catch_errors` is exercised against a real device in the test above and below; the - // graph check makes sure the app runs the reviewed 0.37 worktree (the retained vendor - // copy is not read as proof of behaviour). - let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); - assert_window_stack_graph(&root, "egui-wgpu", "0.37.0"); - assert!(root.join("vendor/egui-wgpu/src/winit.rs").exists(), "the vendor copy and its provenance stay until the fork revision is pinned"); - } - - #[test] - fn pipeline_creation_error_is_returned_and_device_remains_usable() { - use eframe::{egui_wgpu, wgpu}; - let _gpu = gpu(); - let mut native = super::native_options(false, false, eframe::Renderer::Wgpu); - let egui_wgpu::WgpuSetup::CreateNew(setup) = &native.wgpu_options.wgpu_setup else { panic!("default setup creates its own instance") }; - let instance = pollster::block_on(native.wgpu_options.wgpu_setup.new_instance()); - let options = wgpu::RequestAdapterOptions { power_preference: setup.power_preference, ..Default::default() }; - let Ok(adapter) = pollster::block_on(instance.request_adapter(&options)) else { - assert!(std::env::var_os("PDFCRAFT_TEST_REQUIRE_GPU").is_none(), "a GPU adapter is required for this run"); - eprintln!("skipping pipeline regression: no GPU adapter"); - return; - }; - let (device, queue) = pollster::block_on(adapter.request_device(&(setup.device_descriptor)(&adapter))) - .expect("the selected adapter must provide a test device"); - native.wgpu_options.wgpu_setup = - egui_wgpu::WgpuSetup::Existing(egui_wgpu::WgpuSetupExisting { instance: instance.clone(), adapter, device, queue }); - // Three samples is invalid on every backend, so this reproduces a pipeline error - // without depending on a particular driver. The unpatched constructor panics. - for samples in [3, 1, 3, 1] { - let options = egui_wgpu::RendererOptions { msaa_samples: samples, ..Default::default() }; - let result = pollster::block_on(egui_wgpu::RenderState::create(&native.wgpu_options, &instance, None, options)); - if samples == 3 { - let error = result.err().expect("invalid pipeline must return an error"); - assert!(error.to_string().contains("egui_pipeline"), "{error}"); - } else { - assert!(result.is_ok(), "the device must remain usable: {:?}", result.err()); - } - } - } - - /// Regression test: winit resolves to 0.30.13 without a published vendor path patch, the retained copy - /// still carries the W1 Windows 11 monitor-scale fix, and its upstream base is recorded. - #[test] - fn winit_carries_the_windows_11_monitor_scale_fix() { - // Issue #324: winit 0.30.13 as released nudges a window dragged onto a monitor with another - // scale factor back onto the one it is leaving, so on Windows 11 it ends up on the wrong - // monitor, at the wrong size and scale. vendor/winit carries the fix from winit master. A - // dependency bump that resolves winit from crates.io again, or a re-vendored copy without - // the patch, would silently bring the bug back: re-apply the patch, or drop the copy once a - // winit 0.30 release has the fix (vendor/README.md). - // W1 (and W2–W13) were audited against the reviewed winit worktree: the vendor rows in - // `vendor/README.md` match the fork's PATCHES.md entries W1–W13. The vendor source is no - // longer a published path patch, so this checks the actual graph, not the old copy. - let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); - assert_window_stack_graph(&root, "winit", "0.30.13"); - let event_loop = std::fs::read_to_string(root.join("vendor/winit/src/platform_impl/windows/event_loop.rs")).unwrap(); - // The audited W1 hunk itself, not merely the file's presence: Windows 11 applies the - // rect Windows suggests, while Windows 10 keeps the old adjustment. - for marker in [ - "PdfCraft patch: from Windows 11 (build 22000) on, apply the rect Windows suggests", - "if !WIN10_BUILD_VERSION.is_some_and(|build| build < 22000) {", - "new_outer_rect = suggested_rect;", - ] { - assert!(event_loop.contains(marker), "vendor/winit lost the W1 monitor-scale patch: {marker}"); - } - let provenance = std::fs::read_to_string(root.join("vendor/winit/.cargo_vcs_info.json")).unwrap(); - assert!(provenance.contains("e9809ef54b18499bb4f2cac945719ecc2a61061b"), "vendor/winit keeps its upstream base provenance"); - } - - const NVIDIA: (u32, u32) = (0x10de, 0x2684); - const NVIDIA_3090: (u32, u32) = (0x10de, 0x2204); - const AMD_IGPU: (u32, u32) = (0x1002, 0x164e); - - fn adapters() -> Vec<(u32, u32, eframe::wgpu::DeviceType)> { - use eframe::wgpu::DeviceType; - vec![(NVIDIA.0, NVIDIA.1, DeviceType::DiscreteGpu), (AMD_IGPU.0, AMD_IGPU.1, DeviceType::IntegratedGpu), (0, 0, DeviceType::Cpu)] - } - - const INTEL_IGPU: (u32, u32) = (0x8086, 0xa780); - - fn monitor(pci: (u32, u32)) -> super::DisplayGpu { - super::DisplayGpu { pci, primary: false } - } - - fn panel(pci: (u32, u32)) -> super::DisplayGpu { - super::DisplayGpu { pci, primary: true } - } - - #[test] - fn pick_adapter_prefers_the_gpu_driving_the_monitors() { - // A desktop whose monitors are all on the discrete GPU: the integrated one shows black. - assert_eq!(super::pick_adapter(&adapters(), &[monitor(NVIDIA)], None), Some(0)); - // Issue #378: the same on Windows, where the Ryzen integrated GPU without a monitor crashed - // the display driver. Windows also says which display is the primary one. - assert_eq!(super::pick_adapter(&adapters(), &[panel(NVIDIA)], None), Some(0)); - } - - #[test] - fn pick_adapter_prefers_the_gpu_driving_the_primary_display() { - // A Windows desktop with a monitor on each GPU: the window opens on the primary display. - assert_eq!(super::pick_adapter(&adapters(), &[monitor(AMD_IGPU), panel(NVIDIA)], None), Some(0)); - assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU), monitor(NVIDIA)], None), Some(1)); - } - - #[test] - fn pci_ids_come_from_windows_device_ids() { - assert_eq!(super::pci_ids("PCI\\VEN_10DE&DEV_2204&SUBSYS_40421458&REV_A1"), Some(NVIDIA_3090)); - assert_eq!(super::pci_ids("PCI\\VEN_1002&DEV_164E&SUBSYS_88771043&REV_C1"), Some(AMD_IGPU)); - assert_eq!(super::pci_ids("pci\\ven_1002&dev_164e"), Some(AMD_IGPU)); - // Remote Desktop and other non-PCI display devices, and malformed ids. - assert_eq!(super::pci_ids("ROOT\\BasicDisplay\\0000"), None); - assert_eq!(super::pci_ids("PCI\\VEN_10DE&SUBSYS_40421458"), None); - assert_eq!(super::pci_ids("PCI\\VEN_10DE&DEV_ZZZZ"), None); - assert_eq!(super::pci_ids("VEN_&DEV_"), None); - assert_eq!(super::pci_ids(""), None); - } - - #[cfg(target_os = "windows")] - #[test] - fn windows_display_gpus_lists_pci_gpus_with_a_monitor() { - // Whatever this machine has: every entry is a PCI GPU, at most one drives the primary - // display, and no GPU is listed twice. (A headless CI runner may list none.) - let gpus = super::windows_display_gpus(); - assert!(gpus.iter().filter(|g| g.primary).count() <= 1, "{gpus:?}"); - for (i, g) in gpus.iter().enumerate() { - assert!(g.pci.0 != 0, "{gpus:?}"); - assert!(!gpus[..i].iter().any(|h| h.pci == g.pci), "{gpus:?}"); - } - } - - #[test] - fn pick_adapter_keeps_the_integrated_gpu_on_hybrid_laptops() { - // Issue #8: the panel is on the integrated GPU, an external monitor on the discrete one. - assert_eq!(super::pick_adapter(&adapters(), &[monitor(NVIDIA), panel(AMD_IGPU)], None), Some(1)); - assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU), monitor(NVIDIA)], None), Some(1)); - assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU)], None), Some(1)); - assert_eq!(super::pick_adapter(&adapters(), &[monitor(AMD_IGPU)], None), Some(1)); - } - - #[test] - fn pick_adapter_prefers_the_discrete_gpu_on_desktops_with_a_monitor_on_each() { - // Issue #445: one monitor on the Intel iGPU, one on the NVIDIA card, no built-in panel. - use eframe::wgpu::DeviceType; - let desktop = - [(INTEL_IGPU.0, INTEL_IGPU.1, DeviceType::IntegratedGpu), (NVIDIA.0, NVIDIA.1, DeviceType::DiscreteGpu), (0, 0, DeviceType::Cpu)]; - assert_eq!(super::pick_adapter(&desktop, &[monitor(INTEL_IGPU), monitor(NVIDIA)], None), Some(1)); - assert_eq!(super::pick_adapter(&desktop, &[monitor(NVIDIA), monitor(INTEL_IGPU)], None), Some(1)); - // A GPU driving no display still loses to one that does. - assert_eq!(super::pick_adapter(&adapters(), &[monitor(AMD_IGPU), monitor(INTEL_IGPU)], None), Some(1)); - } - - #[test] - fn pick_adapter_prefers_the_gpu_the_compositor_draws_with() { - // A hybrid laptop whose Wayland compositor runs on the discrete GPU (Hyprland with - // `AQ_DRM_DEVICES` naming it first): the integrated GPU drives the built-in panel, yet the - // compositor shows nothing it draws, and reports no error either. - assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU)], Some(NVIDIA)), Some(0)); - assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU), monitor(NVIDIA)], Some(NVIDIA)), Some(0)); - // The usual hybrid laptop, with the compositor on the integrated GPU: as before (#8). - assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU), monitor(NVIDIA)], Some(AMD_IGPU)), Some(1)); - // No GPU drives a display as far as sysfs says (a container), but the compositor knows. - assert_eq!(super::pick_adapter(&adapters(), &[], Some(NVIDIA)), Some(0)); - // A compositor GPU no adapter matches (a virtual one) leaves the displays in charge. - assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU)], Some((0x1af4, 0x1050))), Some(1)); - } - - #[test] - fn pick_adapter_falls_back_to_low_power_without_a_match() { - assert_eq!(super::pick_adapter(&adapters(), &[monitor((0x8086, 0x1234))], None), Some(1)); - assert_eq!(super::pick_adapter(&adapters(), &[monitor((0x8086, 0x1234)), monitor((0x8086, 0x5678))], None), Some(1)); - assert_eq!(super::pick_adapter(&[], &[monitor(NVIDIA)], None), None); - } - - #[test] - fn internal_panels_are_edp_lvds_and_dsi_connectors() { - for name in ["eDP-1", "LVDS-1", "DSI-1"] { - assert!(super::is_internal_panel(name), "{name}"); - } - for name in ["DP-3", "HDMI-A-1", "DVI-D-1", "VGA-1", "Writeback-1", ""] { - assert!(!super::is_internal_panel(name), "{name}"); - } - } - - #[cfg(target_os = "linux")] - #[test] - fn linux_display_gpus_reads_connected_connectors() -> std::io::Result<()> { - let dir = std::env::temp_dir().join(format!("pdfcraft-drm-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - let card = |name: &str, (vendor, device): (u32, u32)| -> std::io::Result<()> { - std::fs::create_dir_all(dir.join(name).join("device"))?; - std::fs::write(dir.join(name).join("device/vendor"), format!("{vendor:#06x}\n"))?; - std::fs::write(dir.join(name).join("device/device"), format!("{device:#06x}\n")) - }; - let connector = |name: &str, status: &str| -> std::io::Result<()> { - std::fs::create_dir_all(dir.join(name))?; - std::fs::write(dir.join(name).join("status"), format!("{status}\n")) - }; - card("card1", NVIDIA)?; - card("card2", AMD_IGPU)?; - connector("card1-DP-3", "connected")?; - connector("card1-DP-4", "connected")?; - connector("card2-HDMI-A-1", "disconnected")?; - connector("card2-Writeback-1", "unknown")?; - let desktop = super::linux_display_gpus(&dir); - // Issue #445: a monitor on the integrated GPU too, still no built-in panel. - connector("card2-HDMI-A-1", "connected")?; - let mut both = super::linux_display_gpus(&dir); - // A hybrid laptop: the integrated GPU drives the built-in panel as well. - connector("card2-eDP-1", "connected")?; - let mut laptop = super::linux_display_gpus(&dir); - std::fs::remove_dir_all(&dir)?; - assert_eq!(desktop, vec![monitor(NVIDIA)]); - both.sort_by_key(|g| g.pci); - assert_eq!(both, vec![monitor(AMD_IGPU), monitor(NVIDIA)]); - laptop.sort_by_key(|g| g.pci); - assert_eq!(laptop, vec![panel(AMD_IGPU), monitor(NVIDIA)]); - assert!(super::linux_display_gpus(std::path::Path::new("/nonexistent/drm")).is_empty()); - Ok(()) - } - - /// A fresh folder per call: tests run in parallel. - fn scratch() -> std::path::PathBuf { - static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); - let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - let d = std::env::temp_dir().join(format!("pdfcraft-control-file-{}-{n}", std::process::id())); - let _ = std::fs::remove_dir_all(&d); - std::fs::create_dir_all(&d).unwrap(); - d - } - - /// The control file's port and token, and every name in its folder. - fn written(dir: &std::path::Path) -> (serde_json::Value, Vec) { - let json = serde_json::from_str(&std::fs::read_to_string(dir.join("ctl.json")).unwrap()).unwrap(); - let mut names: Vec = std::fs::read_dir(dir).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect(); - names.sort(); - (json, names) - } - - #[test] - fn the_control_file_replaces_a_hard_link_instead_of_writing_through_it() { - // The file used to be opened with create + truncate, so a link planted at the path had its - // target truncated and overwritten with the token. - let dir = scratch(); - std::fs::write(dir.join("victim.txt"), "keep me").unwrap(); - std::fs::hard_link(dir.join("victim.txt"), dir.join("ctl.json")).unwrap(); - super::write_control_file(&dir.join("ctl.json").to_string_lossy(), 4242, "t0ken").unwrap(); - assert_eq!(std::fs::read_to_string(dir.join("victim.txt")).unwrap(), "keep me"); - let (json, names) = written(&dir); - assert_eq!((json["port"].as_u64(), json["token"].as_str()), (Some(4242), Some("t0ken"))); - assert_eq!(names, ["ctl.json", "victim.txt"], "no temporary file left behind"); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn the_control_file_replaces_a_symlink_instead_of_writing_through_it() { - let dir = scratch(); - let (link, target) = (dir.join("ctl.json"), dir.join("victim.txt")); - std::fs::write(&target, "keep me").unwrap(); - #[cfg(unix)] - let made = std::os::unix::fs::symlink(&target, &link); - #[cfg(windows)] - let made = std::os::windows::fs::symlink_file(&target, &link); - if let Err(e) = made { - // Windows needs Developer Mode (or admin) for symlinks. - eprintln!("skipped: can't create a symlink here: {e}"); - let _ = std::fs::remove_dir_all(&dir); - return; - } - super::write_control_file(&link.to_string_lossy(), 4242, "t0ken").unwrap(); - assert_eq!(std::fs::read_to_string(&target).unwrap(), "keep me"); - assert!(!std::fs::symlink_metadata(&link).unwrap().file_type().is_symlink()); - assert_eq!(written(&dir).0["token"].as_str(), Some("t0ken")); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn the_control_file_replaces_the_one_a_previous_run_left() { - let dir = scratch(); - std::fs::write(dir.join("ctl.json"), r#"{"port":1,"token":"old"}"#).unwrap(); - super::write_control_file(&dir.join("ctl.json").to_string_lossy(), 4242, "t0ken").unwrap(); - let (json, names) = written(&dir); - assert_eq!(json["token"].as_str(), Some("t0ken")); - assert_eq!(names, ["ctl.json"]); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let mode = std::fs::metadata(dir.join("ctl.json")).unwrap().permissions().mode(); - assert_eq!(mode & 0o077, 0, "only the owner can read the token: {mode:o}"); - } - let _ = std::fs::remove_dir_all(&dir); - } - - /// X11 startup notification and docks match a window to its launcher by `StartupWMClass`, - /// so it has to be the class the window carries: the app id, on Wayland and X11 alike. - #[test] - fn the_desktop_entry_names_the_window_class() { - let entry = include_str!("../../../packaging/linux/ai.storyteller.pdfcraft.desktop"); - let class = entry.lines().find_map(|line| line.strip_prefix("StartupWMClass=")); - assert_eq!(class, Some(super::APP_ID)); - } - - // Regression test for #246: `eprintln!` panics when standard error is a broken pipe (a GUI - // launch on Windows), and a panic inside the panic hook aborts the process. The start-up - // path and the hook must write to standard error without it. - #[test] - fn start_up_and_the_panic_hook_never_use_eprintln() { - let source = include_str!("main.rs"); - // Everything before the top-level test module is product code. - let product = source.split("\n#[cfg(test)]\nmod tests {").next().unwrap_or(source); - assert!(product.len() < source.len(), "found the end of the product code"); - let offenders: Vec<_> = product - .lines() - .enumerate() - .filter(|(_, line)| !line.trim_start().starts_with("//") && line.contains("eprintln!")) - .map(|(n, line)| format!("main.rs:{}: {}", n + 1, line.trim())) - .collect(); - assert!( - offenders.is_empty(), - "`eprintln!` panics on a broken standard error pipe; use `log::warn!` or `write_fmt` on `stderr()` and ignore the error:\n{}", - offenders.join("\n") - ); - } -} +mod tests; diff --git a/apps/pdfcraft/src/tests.rs b/apps/pdfcraft/src/tests.rs new file mode 100644 index 0000000000..c6e6f36518 --- /dev/null +++ b/apps/pdfcraft/src/tests.rs @@ -0,0 +1,686 @@ +/// The lockfile package block for `name`, or a panic naming the missing package. +fn locked_package(root: &std::path::Path, name: &str) -> String { + let lock = std::fs::read_to_string(root.join("Cargo.lock")).expect("Cargo.lock"); + lock.split("[[package]]") + .find(|p| p.contains(&format!("\nname = \"{name}\"\n"))) + .map(str::to_owned) + .unwrap_or_else(|| panic!("{name} is not in Cargo.lock")) +} + +/// Interim graph check for the window stack while the reviewed fork revision is not yet +/// published. The old `vendor/winit` / `vendor/egui-wgpu` path patches were removed from the +/// published manifest; local integration supplies the reviewed worktrees through an external +/// Cargo config patch. A git source must name the public storytold fork; a path source is the +/// local integration graph. Behaviour (surface fitting, error scopes, scroll phases) is tested +/// directly elsewhere; the retained vendor copies keep provenance until the fork revision is +/// pinned. No public revision is invented here. +fn assert_window_stack_graph(root: &std::path::Path, name: &str, version: &str) -> String { + let package = locked_package(root, name); + assert!(package.contains(&format!("\nversion = \"{version}\"\n")), "{name} must resolve to {version}: {package}"); + if let Some(source) = package.lines().find_map(|line| line.strip_prefix("source = ")) { + assert!(source.contains("github.com/storytold/"), "{name} must come from the reviewed storytold fork once published, not {source}"); + assert!( + source.split(|c: char| !c.is_ascii_hexdigit()).any(|part| part.len() == 40), + "{name} must pin the fork's 40-character revision, not {source}" + ); + } + // A path patch has no `source =` in Cargo.lock. The local integration config is the only + // allowed path source; a machine-local path must never be in the published manifest, + // which the root-manifest assertion below checks. A path entry carries no remote identity + // to verify, so this test does not claim the resolved code is the published fork. + let manifest = std::fs::read_to_string(root.join("Cargo.toml")).expect("root Cargo.toml"); + assert!(!manifest.contains(&format!("{name} = {{ path = \"vendor/")), "vendor/{name} must not be a published path patch"); + package +} + +use pdfcraft_ui_egui::control::Endpoint; + +fn endpoint() -> std::io::Result { + Ok(Endpoint { port: 4321, token: "0123456789abcdef".into() }) +} + +/// Tests that open a real GPU device take this first, so only one of them is inside the +/// graphics libraries at a time. The test threads otherwise run them together, and on +/// FreeBSD's CI machine the test process then died with SIGSEGV, in about half the runs: +/// the first device was still being set up when the second test began its own. The UI +/// tests take turns the same way (`crates/ui-egui/tests/it/main.rs`). Hold it for the whole +/// test (declared before the instance, so it's released after the device is dropped). +static GPU: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +fn gpu() -> std::sync::MutexGuard<'static, ()> { + // A test that panicked while holding it leaves nothing to clean up. + GPU.lock().unwrap_or_else(std::sync::PoisonError::into_inner) +} + +#[test] +fn a_control_channel_that_cant_be_published_stops_the_app() { + let dir = std::env::temp_dir().join(format!("pdfcraft-app-control-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = |name: &str| dir.join(name).to_str().unwrap().to_owned(); + + // A folder in the way stands in for a file another user created first: the write fails. + std::fs::create_dir(dir.join("taken.json")).unwrap(); + let err = super::open_control_channel(&path("taken.json"), endpoint()).unwrap_err(); + assert!(err.contains("--control") && err.contains("taken.json") && err.contains("only you can write"), "{err}"); + // No listener: nothing to publish. + let err = super::open_control_channel(&path("pc.json"), Err(std::io::Error::other("no loopback"))).unwrap_err(); + assert!(err.contains("no loopback"), "{err}"); + assert!(!dir.join("pc.json").exists()); + + super::open_control_channel(&path("pc.json"), endpoint()).unwrap(); + let written: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(dir.join("pc.json")).unwrap()).unwrap(); + assert_eq!((written["port"].as_u64(), written["token"].as_str()), (Some(4321), Some("0123456789abcdef"))); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn path_from_arg_decodes_file_uris() { + assert_eq!(super::path_from_arg("file:///home/alice/report.pdf".to_string()), "/home/alice/report.pdf"); + assert_eq!(super::path_from_arg("file:///home/alice/my%20report.pdf".to_string()), "/home/alice/my report.pdf"); + assert_eq!(super::path_from_arg("file://localhost/tmp/a%C3%A9.pdf".to_string()), "/tmp/aé.pdf"); + // Malformed or truncated escapes are kept as written; bytes that aren't UTF-8 keep the URI. + assert_eq!(super::path_from_arg("file:///tmp/100%.pdf".to_string()), "/tmp/100%.pdf"); + assert_eq!(super::path_from_arg("file:///tmp/a%2".to_string()), "/tmp/a%2"); + assert_eq!(super::path_from_arg("file:///tmp/%zz%".to_string()), "/tmp/%zz%"); + assert_eq!(super::path_from_arg("file:///tmp/%FF.pdf".to_string()), "file:///tmp/%FF.pdf"); +} + +#[test] +fn unquoted_paths_with_spaces_are_rejoined() { + let have = ["P:\\Archive\\00042A26 Contract.PDF", "C:\\a b c.pdf", "C:\\a.pdf", "C:\\x y.pdf"]; + let exists = |p: &str| have.contains(&p); + let args = |a: &[&str]| a.iter().map(|s| s.to_string()).collect::>(); + // The case an office program produced: one path, split at its space. + assert_eq!(super::rejoin_split_paths(args(&["P:\\Archive\\00042A26", "Contract.PDF"]), exists), args(&["P:\\Archive\\00042A26 Contract.PDF"])); + // The longest run wins; files that exist on their own are left alone. + assert_eq!(super::rejoin_split_paths(args(&["C:\\a", "b", "c.pdf", "C:\\a.pdf"]), exists), args(&["C:\\a b c.pdf", "C:\\a.pdf"])); + assert_eq!(super::rejoin_split_paths(args(&["C:\\a.pdf", "C:\\x", "y.pdf"]), exists), args(&["C:\\a.pdf", "C:\\x y.pdf"])); + // Nothing to join: kept as given, so the missing file is still reported by name. + assert_eq!(super::rejoin_split_paths(args(&["missing.pdf", "other"]), exists), args(&["missing.pdf", "other"])); + assert!(super::rejoin_split_paths(Vec::new(), exists).is_empty()); +} + +#[test] +fn path_from_arg_leaves_plain_paths_and_other_schemes_alone() { + assert_eq!(super::path_from_arg("report.pdf".to_string()), "report.pdf"); + assert_eq!(super::path_from_arg("/home/alice/report.pdf".to_string()), "/home/alice/report.pdf"); + assert_eq!(super::path_from_arg("https://example.com/report.pdf".to_string()), "https://example.com/report.pdf"); + assert_eq!(super::path_from_arg("file://server/share/a.pdf".to_string()), "file://server/share/a.pdf"); + assert_eq!(super::path_from_arg("file://".to_string()), "file://"); +} + +#[test] +fn opengl_is_the_fallback_unless_a_renderer_was_chosen() { + use super::{RendererChoice, native_options, renderer_choice, retry_with_gl}; + assert_eq!(renderer_choice(None), RendererChoice::Auto); + assert_eq!(renderer_choice(Some("")), RendererChoice::Auto); + assert_eq!(renderer_choice(Some("vulkan")), RendererChoice::Auto); + assert_eq!(renderer_choice(Some(" WGPU ")), RendererChoice::Wgpu); + for gl in ["gl", "OpenGL", "glow"] { + assert_eq!(renderer_choice(Some(gl)), RendererChoice::Gl); + } + // Retried with OpenGL only when wgpu failed to start by default, never after the app ran. + assert!(retry_with_gl(RendererChoice::Auto, false)); + assert!(!retry_with_gl(RendererChoice::Auto, true)); + assert!(!retry_with_gl(RendererChoice::Wgpu, false)); + assert!(!retry_with_gl(RendererChoice::Gl, false)); + // Each run states its renderer rather than relying on eframe's default. + assert_eq!(native_options(false, false, eframe::Renderer::Wgpu).renderer, eframe::Renderer::Wgpu); + assert_eq!(native_options(false, false, eframe::Renderer::Glow).renderer, eframe::Renderer::Glow); +} + +#[test] +fn the_desktop_frame_is_turned_off_only_when_the_app_draws_one() { + use super::native_options; + assert_eq!(native_options(false, false, eframe::Renderer::Glow).viewport.decorations, None); + assert_eq!(native_options(false, true, eframe::Renderer::Glow).viewport.decorations, Some(false)); +} + +#[test] +fn a_software_renderer_is_shown_to_the_user() { + use eframe::wgpu::DeviceType; + // Issue #519: WARP or llvmpipe is announced in the app, not just in pdfcraft.log. + let mut app = super::PdfCraftApp::new(); + super::notify_software_renderer(&mut app, DeviceType::Cpu); + let notice = app.toast.as_ref().map(|(m, _)| m.as_str()).unwrap_or_default(); + assert!(notice.contains("without a graphics processor"), "{notice:?}"); + for gpu in [DeviceType::IntegratedGpu, DeviceType::DiscreteGpu, DeviceType::VirtualGpu, DeviceType::Other] { + let mut app = super::PdfCraftApp::new(); + super::notify_software_renderer(&mut app, gpu); + assert!(app.toast.is_none(), "{gpu:?}: {:?}", app.toast); + } +} + +#[test] +fn gpu_setup_avoids_vulkan_and_stretched_resizes_on_windows_and_prefers_low_power() { + let mut native = eframe::NativeOptions::default(); + super::configure_gpu(&mut native); + let eframe::egui_wgpu::WgpuSetup::CreateNew(setup) = &native.wgpu_options.wgpu_setup else { panic!("default setup creates its own instance") }; + if std::env::var_os("WGPU_POWER_PREF").is_none() { + assert_eq!(setup.power_preference, eframe::wgpu::PowerPreference::LowPower); + } + if std::env::var_os("WGPU_VALIDATION_INDIRECT_CALL").is_none() { + let flags = setup.instance_descriptor.flags; + assert!(!flags.contains(eframe::wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL), "Intel Metal can't compile its shader: {flags:?}"); + } + if cfg!(target_os = "windows") && std::env::var_os("WGPU_BACKEND").is_none() { + let backends = setup.instance_descriptor.backends; + assert!(backends.contains(eframe::wgpu::Backends::DX12), "{backends:?}"); + assert!(!backends.contains(eframe::wgpu::Backends::VULKAN), "issue #37: {backends:?}"); + } + use eframe::wgpu::Dx12SwapchainKind::{DxgiFromHwnd, DxgiFromVisual}; + assert_eq!(super::dx12_presentation(None), DxgiFromVisual); + assert_eq!(super::dx12_presentation(Some(DxgiFromHwnd)), DxgiFromHwnd, "WGPU_DX12_PRESENTATION_SYSTEM still decides"); + assert_eq!(super::dx12_presentation(Some(DxgiFromVisual)), DxgiFromVisual); + let presentation = setup.instance_descriptor.backend_options.dx12.presentation_system; + if cfg!(target_os = "windows") { + // A composition visual unless WGPU_DX12_PRESENTATION_SYSTEM asks otherwise. + let asked = eframe::wgpu::Dx12SwapchainKind::from_env(); + assert_eq!(presentation, asked.unwrap_or(DxgiFromVisual), "issue #2738: resizing stretches a window swap chain"); + } else { + assert_eq!(presentation, eframe::wgpu::Dx12SwapchainKind::default().with_env(), "only Windows changes it"); + } +} + +#[test] +fn restored_hidpi_window_fits_the_adapters_surface_limit() { + // The Outlook launch crash: 3440 x 1369 saved points at 250% DPI requested this surface. + let (width, height) = (8600, 3423); + let required = eframe::wgpu::Limits::default(); + assert!(width > required.max_texture_dimension_2d); + let supported = eframe::wgpu::Limits { max_texture_dimension_2d: 16384, ..required.clone() }; + let enabled = super::surface_texture_limits(required, &supported); + assert!(width <= enabled.max_texture_dimension_2d); + assert!(height <= enabled.max_texture_dimension_2d); +} + +#[test] +fn surface_limits_preserve_other_requirements_and_never_overrequest() { + for required in [eframe::wgpu::Limits::default(), eframe::wgpu::Limits::downlevel_webgl2_defaults()] { + for extent in [4096, 8192, 16384] { + let supported = eframe::wgpu::Limits { + max_texture_dimension_2d: extent, + max_bind_groups: 8, + min_uniform_buffer_offset_alignment: 128, + min_storage_buffer_offset_alignment: 128, + ..required.clone() + }; + let enabled = super::surface_texture_limits(required.clone(), &supported); + let mut expected = required.clone(); + expected.max_texture_dimension_2d = extent; + assert_eq!(enabled, expected); + assert_eq!(enabled.max_texture_dimension_2d, supported.max_texture_dimension_2d); + assert!(enabled.check_limits(&supported)); + } + } +} + +#[test] +fn surface_limits_fit_v3d_shader_variable_limit() { + // Issue #2383: V3D supports 15 variables, below the default request of 16. + let required = eframe::wgpu::Limits::default(); + let supported = eframe::wgpu::Limits { max_texture_dimension_2d: 7680, max_inter_stage_shader_variables: 15, ..required.clone() }; + let enabled = super::surface_texture_limits(required.clone(), &supported); + assert!(enabled.check_limits(&supported), "unsupported device limits: {enabled:?}"); + let expected = eframe::wgpu::Limits { max_texture_dimension_2d: 7680, max_inter_stage_shader_variables: 15, ..required }; + assert_eq!(enabled, expected); +} + +#[test] +fn surface_limits_fit_downlevel_maxima_and_minimum_alignments() { + let required = eframe::wgpu::Limits::downlevel_webgl2_defaults(); + let supported = eframe::wgpu::Limits { + max_texture_dimension_2d: 2048, + max_bind_groups: 2, + max_vertex_attributes: 8, + min_uniform_buffer_offset_alignment: 512, + min_storage_buffer_offset_alignment: 512, + ..required.clone() + }; + let enabled = super::surface_texture_limits(required, &supported); + assert!(enabled.check_limits(&supported), "unsupported device limits: {enabled:?}"); + // Larger minimum alignments are weaker capabilities, unlike maximum limits. + assert_eq!(enabled, supported); +} + +#[test] +fn a_window_larger_than_the_gpu_limit_gets_a_surface_within_it() { + use eframe::egui_wgpu::winit::surface_fit; + // Issue #577: 3440 x 1369 points restored at 250% asked an 8192-pixel device for this. + let (width, height, scale) = surface_fit(8600, 3423, 8192); + assert_eq!((width, height), (8192, 3260)); + // Both sides shrink alike and egui draws at that factor: the whole window is drawn. + assert!((8600.0 * scale - 8192.0).abs() < 0.01, "{scale}"); + assert!((3423.0 * scale - height as f32).abs() < 1.0, "{scale}"); + // Within the limit nothing changes, up to and including the limit itself. + assert_eq!(surface_fit(8600, 3423, 16384), (8600, 3423, 1.0)); + assert_eq!(surface_fit(8192, 8192, 8192), (8192, 8192, 1.0)); + assert_eq!(surface_fit(0, 0, 8192), (0, 0, 1.0)); + // One side over the limit: the window as it would be stretched across monitors. + assert_eq!(surface_fit(8193, 600, 8192).0, 8192); + assert_eq!(surface_fit(600, 8193, 8192).1, 8192); +} + +#[test] +fn surface_fit_never_empties_or_overflows_a_surface() { + use eframe::egui_wgpu::winit::surface_fit; + for (w, h, max) in + [(1, u32::MAX, 8192), (u32::MAX, 1, 2048), (u32::MAX, u32::MAX, 16384), (0, u32::MAX, 8192), (u32::MAX, 0, 8192), (5, 7, 0), (40_000, 3, 1)] + { + let (fw, fh, scale) = surface_fit(w, h, max); + assert!(fw <= max.max(1) && fh <= max.max(1), "{w} x {h} in {max}: {fw} x {fh}"); + // A side that wasn't zero stays non-zero (`Surface::configure` rejects an empty one), + // and a zero side stays zero (egui-wgpu skips configuring it). + assert_eq!((fw == 0, fh == 0), (w == 0, h == 0), "{w} x {h} in {max}: {fw} x {fh}"); + assert!(scale.is_finite() && scale > 0.0 && scale <= 1.0, "{w} x {h} in {max}: {scale}"); + } + // The longer side lands exactly on the limit (rounding never leaves it a pixel short), + // and the shorter side keeps the window's proportions to within a pixel. + for max in [2048, 8192, 16384] { + for long in (max + 1..=max * 5).step_by(997).chain([max * 2, max * 4, u32::MAX]) { + for short in [1, 3, 600, max / 3, max - 1, max, long - 1, long] { + let (fw, fh, scale) = surface_fit(long, short, max); + assert_eq!(fw, max, "{long} x {short} in {max}"); + let expected = f64::from(short) * f64::from(max) / f64::from(long); + assert!((f64::from(fh) - expected).abs() <= 1.0, "{long} x {short} in {max}: {fh}"); + assert_eq!(surface_fit(short, long, max), (fh, fw, scale), "transposed"); + } + } + } +} + +/// Regression test: the lockfile resolves egui-wgpu 0.37.0 through an allowed source, the root manifest +/// does not path-patch the vendor copy, and that copy stays as provenance for the surface-fit patch. +#[test] +fn vendor_provenance_egui_wgpu_surface_fit_identity() { + // Issue #577: egui-wgpu configured a window's surface at the window's size, and + // `Surface::configure` panics when that's beyond the device's `max_texture_dimension_2d` + // (emilk/egui#8361). The reviewed egui 0.37 worktree carries E2 (`surface_fit`); the + // behavioural test above exercises it. The old 0.36 vendor copy is retained for + // provenance only and is no longer a published path patch, so reading it would not prove + // what the app runs. + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + assert_window_stack_graph(&root, "egui-wgpu", "0.37.0"); + assert!(root.join("vendor/egui-wgpu/src/winit.rs").exists(), "the vendor copy and its provenance stay until the fork revision is pinned"); +} + +#[test] +fn a_gpu_error_while_a_window_is_set_up_is_returned_not_a_panic() { + // Issue #519: on a 2015 Intel GPU, wgpu's GL backend couldn't configure the window's + // surface (`GpuWaitTimeout`), and wgpu's default error handler panicked before the app was + // created, so PdfCraft never got to retry with OpenGL. vendor/egui-wgpu configures a new + // window's surface inside `catch_errors`. A real device on PdfCraft's own GPU settings, + // and an error wgpu raises before anything reaches the driver: a texture one pixel wider + // than the device allows. + use eframe::wgpu; + let _gpu = gpu(); + let native = super::native_options(false, false, eframe::Renderer::Wgpu); + let eframe::egui_wgpu::WgpuSetup::CreateNew(setup) = &native.wgpu_options.wgpu_setup else { panic!("default setup creates its own instance") }; + let instance = pollster::block_on(native.wgpu_options.wgpu_setup.new_instance()); + let options = wgpu::RequestAdapterOptions { power_preference: setup.power_preference, ..Default::default() }; + // CI runners have a software adapter (WARP, llvmpipe); a machine without any skips. + let Ok(adapter) = pollster::block_on(instance.request_adapter(&options)) else { + eprintln!("skipping: no GPU adapter on this machine"); + return; + }; + let Ok((device, _queue)) = pollster::block_on(adapter.request_device(&(setup.device_descriptor)(&adapter))) else { + eprintln!("skipping: the adapter gives no device"); + return; + }; + let texture = |width: u32| { + device.create_texture(&wgpu::TextureDescriptor { + label: Some("issue-519"), + size: wgpu::Extent3d { width, height: 1, depth_or_array_layers: 1 }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8Unorm, + usage: wgpu::TextureUsages::TEXTURE_BINDING, + view_formats: &[], + }) + }; + let too_wide = device.limits().max_texture_dimension_2d.saturating_add(1); + let caught = pollster::block_on(eframe::egui_wgpu::winit::catch_errors(&device, || texture(too_wide))); + assert!(matches!(caught, Err(wgpu::Error::Validation { .. })), "{caught:?}"); + // The device stays usable, and nothing is left behind to catch later errors by mistake. + let fine = pollster::block_on(eframe::egui_wgpu::winit::catch_errors(&device, || texture(16))); + assert!(fine.is_ok(), "{fine:?}"); + assert_eq!(pollster::block_on(eframe::egui_wgpu::winit::catch_errors(&device, || 7)).ok(), Some(7)); + // Nested: the inner call keeps its own error, and the outer one still catches what follows. + let caught = pollster::block_on(eframe::egui_wgpu::winit::catch_errors(&device, || { + let inner = pollster::block_on(eframe::egui_wgpu::winit::catch_errors(&device, || texture(too_wide))); + assert!(inner.is_err(), "inner: {inner:?}"); + texture(too_wide) + })); + assert!(matches!(caught, Err(wgpu::Error::Validation { .. })), "outer: {caught:?}"); +} + +/// Regression test: the lockfile resolves egui-wgpu 0.37.0 through an allowed source, the root manifest +/// does not path-patch the vendor copy, and that copy stays as provenance for the catch-errors patch. +#[test] +fn vendor_provenance_egui_wgpu_catch_errors_identity() { + // Issue #519: E3 (`catch_errors`) is tested above against a real device. The reviewed 0.37 + // worktree carries it around the first configure of every new window's surface, and its + // error reaches eframe as `WgpuError` (eframe then returns `Error::Wgpu`, which `main` + // retries with OpenGL). Without it a failed configure panics before the app is created. + // `catch_errors` is exercised against a real device in the test above and below; the + // graph check makes sure the app runs the reviewed 0.37 worktree (the retained vendor + // copy is not read as proof of behaviour). + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + assert_window_stack_graph(&root, "egui-wgpu", "0.37.0"); + assert!(root.join("vendor/egui-wgpu/src/winit.rs").exists(), "the vendor copy and its provenance stay until the fork revision is pinned"); +} + +#[test] +fn pipeline_creation_error_is_returned_and_device_remains_usable() { + use eframe::{egui_wgpu, wgpu}; + let _gpu = gpu(); + let mut native = super::native_options(false, false, eframe::Renderer::Wgpu); + let egui_wgpu::WgpuSetup::CreateNew(setup) = &native.wgpu_options.wgpu_setup else { panic!("default setup creates its own instance") }; + let instance = pollster::block_on(native.wgpu_options.wgpu_setup.new_instance()); + let options = wgpu::RequestAdapterOptions { power_preference: setup.power_preference, ..Default::default() }; + let Ok(adapter) = pollster::block_on(instance.request_adapter(&options)) else { + assert!(std::env::var_os("PDFCRAFT_TEST_REQUIRE_GPU").is_none(), "a GPU adapter is required for this run"); + eprintln!("skipping pipeline regression: no GPU adapter"); + return; + }; + let (device, queue) = + pollster::block_on(adapter.request_device(&(setup.device_descriptor)(&adapter))).expect("the selected adapter must provide a test device"); + native.wgpu_options.wgpu_setup = + egui_wgpu::WgpuSetup::Existing(egui_wgpu::WgpuSetupExisting { instance: instance.clone(), adapter, device, queue }); + // Three samples is invalid on every backend, so this reproduces a pipeline error + // without depending on a particular driver. The unpatched constructor panics. + for samples in [3, 1, 3, 1] { + let options = egui_wgpu::RendererOptions { msaa_samples: samples, ..Default::default() }; + let result = pollster::block_on(egui_wgpu::RenderState::create(&native.wgpu_options, &instance, None, options)); + if samples == 3 { + let error = result.err().expect("invalid pipeline must return an error"); + assert!(error.to_string().contains("egui_pipeline"), "{error}"); + } else { + assert!(result.is_ok(), "the device must remain usable: {:?}", result.err()); + } + } +} + +/// Regression test: winit resolves to 0.30.13 without a published vendor path patch, the retained copy +/// still carries the W1 Windows 11 monitor-scale fix, and its upstream base is recorded. +#[test] +fn winit_carries_the_windows_11_monitor_scale_fix() { + // Issue #324: winit 0.30.13 as released nudges a window dragged onto a monitor with another + // scale factor back onto the one it is leaving, so on Windows 11 it ends up on the wrong + // monitor, at the wrong size and scale. vendor/winit carries the fix from winit master. A + // dependency bump that resolves winit from crates.io again, or a re-vendored copy without + // the patch, would silently bring the bug back: re-apply the patch, or drop the copy once a + // winit 0.30 release has the fix (vendor/README.md). + // W1 (and W2–W13) were audited against the reviewed winit worktree: the vendor rows in + // `vendor/README.md` match the fork's PATCHES.md entries W1–W13. The vendor source is no + // longer a published path patch, so this checks the actual graph, not the old copy. + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + assert_window_stack_graph(&root, "winit", "0.30.13"); + let event_loop = std::fs::read_to_string(root.join("vendor/winit/src/platform_impl/windows/event_loop.rs")).unwrap(); + // The audited W1 hunk itself, not merely the file's presence: Windows 11 applies the + // rect Windows suggests, while Windows 10 keeps the old adjustment. + for marker in [ + "PdfCraft patch: from Windows 11 (build 22000) on, apply the rect Windows suggests", + "if !WIN10_BUILD_VERSION.is_some_and(|build| build < 22000) {", + "new_outer_rect = suggested_rect;", + ] { + assert!(event_loop.contains(marker), "vendor/winit lost the W1 monitor-scale patch: {marker}"); + } + let provenance = std::fs::read_to_string(root.join("vendor/winit/.cargo_vcs_info.json")).unwrap(); + assert!(provenance.contains("e9809ef54b18499bb4f2cac945719ecc2a61061b"), "vendor/winit keeps its upstream base provenance"); +} + +const NVIDIA: (u32, u32) = (0x10de, 0x2684); +const NVIDIA_3090: (u32, u32) = (0x10de, 0x2204); +const AMD_IGPU: (u32, u32) = (0x1002, 0x164e); + +fn adapters() -> Vec<(u32, u32, eframe::wgpu::DeviceType)> { + use eframe::wgpu::DeviceType; + vec![(NVIDIA.0, NVIDIA.1, DeviceType::DiscreteGpu), (AMD_IGPU.0, AMD_IGPU.1, DeviceType::IntegratedGpu), (0, 0, DeviceType::Cpu)] +} + +const INTEL_IGPU: (u32, u32) = (0x8086, 0xa780); + +fn monitor(pci: (u32, u32)) -> super::DisplayGpu { + super::DisplayGpu { pci, primary: false } +} + +fn panel(pci: (u32, u32)) -> super::DisplayGpu { + super::DisplayGpu { pci, primary: true } +} + +#[test] +fn pick_adapter_prefers_the_gpu_driving_the_monitors() { + // A desktop whose monitors are all on the discrete GPU: the integrated one shows black. + assert_eq!(super::pick_adapter(&adapters(), &[monitor(NVIDIA)], None), Some(0)); + // Issue #378: the same on Windows, where the Ryzen integrated GPU without a monitor crashed + // the display driver. Windows also says which display is the primary one. + assert_eq!(super::pick_adapter(&adapters(), &[panel(NVIDIA)], None), Some(0)); +} + +#[test] +fn pick_adapter_prefers_the_gpu_driving_the_primary_display() { + // A Windows desktop with a monitor on each GPU: the window opens on the primary display. + assert_eq!(super::pick_adapter(&adapters(), &[monitor(AMD_IGPU), panel(NVIDIA)], None), Some(0)); + assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU), monitor(NVIDIA)], None), Some(1)); +} + +#[test] +fn pci_ids_come_from_windows_device_ids() { + assert_eq!(super::pci_ids("PCI\\VEN_10DE&DEV_2204&SUBSYS_40421458&REV_A1"), Some(NVIDIA_3090)); + assert_eq!(super::pci_ids("PCI\\VEN_1002&DEV_164E&SUBSYS_88771043&REV_C1"), Some(AMD_IGPU)); + assert_eq!(super::pci_ids("pci\\ven_1002&dev_164e"), Some(AMD_IGPU)); + // Remote Desktop and other non-PCI display devices, and malformed ids. + assert_eq!(super::pci_ids("ROOT\\BasicDisplay\\0000"), None); + assert_eq!(super::pci_ids("PCI\\VEN_10DE&SUBSYS_40421458"), None); + assert_eq!(super::pci_ids("PCI\\VEN_10DE&DEV_ZZZZ"), None); + assert_eq!(super::pci_ids("VEN_&DEV_"), None); + assert_eq!(super::pci_ids(""), None); +} + +#[cfg(target_os = "windows")] +#[test] +fn windows_display_gpus_lists_pci_gpus_with_a_monitor() { + // Whatever this machine has: every entry is a PCI GPU, at most one drives the primary + // display, and no GPU is listed twice. (A headless CI runner may list none.) + let gpus = super::windows_display_gpus(); + assert!(gpus.iter().filter(|g| g.primary).count() <= 1, "{gpus:?}"); + for (i, g) in gpus.iter().enumerate() { + assert!(g.pci.0 != 0, "{gpus:?}"); + assert!(!gpus[..i].iter().any(|h| h.pci == g.pci), "{gpus:?}"); + } +} + +#[test] +fn pick_adapter_keeps_the_integrated_gpu_on_hybrid_laptops() { + // Issue #8: the panel is on the integrated GPU, an external monitor on the discrete one. + assert_eq!(super::pick_adapter(&adapters(), &[monitor(NVIDIA), panel(AMD_IGPU)], None), Some(1)); + assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU), monitor(NVIDIA)], None), Some(1)); + assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU)], None), Some(1)); + assert_eq!(super::pick_adapter(&adapters(), &[monitor(AMD_IGPU)], None), Some(1)); +} + +#[test] +fn pick_adapter_prefers_the_discrete_gpu_on_desktops_with_a_monitor_on_each() { + // Issue #445: one monitor on the Intel iGPU, one on the NVIDIA card, no built-in panel. + use eframe::wgpu::DeviceType; + let desktop = [(INTEL_IGPU.0, INTEL_IGPU.1, DeviceType::IntegratedGpu), (NVIDIA.0, NVIDIA.1, DeviceType::DiscreteGpu), (0, 0, DeviceType::Cpu)]; + assert_eq!(super::pick_adapter(&desktop, &[monitor(INTEL_IGPU), monitor(NVIDIA)], None), Some(1)); + assert_eq!(super::pick_adapter(&desktop, &[monitor(NVIDIA), monitor(INTEL_IGPU)], None), Some(1)); + // A GPU driving no display still loses to one that does. + assert_eq!(super::pick_adapter(&adapters(), &[monitor(AMD_IGPU), monitor(INTEL_IGPU)], None), Some(1)); +} + +#[test] +fn pick_adapter_prefers_the_gpu_the_compositor_draws_with() { + // A hybrid laptop whose Wayland compositor runs on the discrete GPU (Hyprland with + // `AQ_DRM_DEVICES` naming it first): the integrated GPU drives the built-in panel, yet the + // compositor shows nothing it draws, and reports no error either. + assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU)], Some(NVIDIA)), Some(0)); + assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU), monitor(NVIDIA)], Some(NVIDIA)), Some(0)); + // The usual hybrid laptop, with the compositor on the integrated GPU: as before (#8). + assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU), monitor(NVIDIA)], Some(AMD_IGPU)), Some(1)); + // No GPU drives a display as far as sysfs says (a container), but the compositor knows. + assert_eq!(super::pick_adapter(&adapters(), &[], Some(NVIDIA)), Some(0)); + // A compositor GPU no adapter matches (a virtual one) leaves the displays in charge. + assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU)], Some((0x1af4, 0x1050))), Some(1)); +} + +#[test] +fn pick_adapter_falls_back_to_low_power_without_a_match() { + assert_eq!(super::pick_adapter(&adapters(), &[monitor((0x8086, 0x1234))], None), Some(1)); + assert_eq!(super::pick_adapter(&adapters(), &[monitor((0x8086, 0x1234)), monitor((0x8086, 0x5678))], None), Some(1)); + assert_eq!(super::pick_adapter(&[], &[monitor(NVIDIA)], None), None); +} + +#[test] +fn internal_panels_are_edp_lvds_and_dsi_connectors() { + for name in ["eDP-1", "LVDS-1", "DSI-1"] { + assert!(super::is_internal_panel(name), "{name}"); + } + for name in ["DP-3", "HDMI-A-1", "DVI-D-1", "VGA-1", "Writeback-1", ""] { + assert!(!super::is_internal_panel(name), "{name}"); + } +} + +#[cfg(target_os = "linux")] +#[test] +fn linux_display_gpus_reads_connected_connectors() -> std::io::Result<()> { + let dir = std::env::temp_dir().join(format!("pdfcraft-drm-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let card = |name: &str, (vendor, device): (u32, u32)| -> std::io::Result<()> { + std::fs::create_dir_all(dir.join(name).join("device"))?; + std::fs::write(dir.join(name).join("device/vendor"), format!("{vendor:#06x}\n"))?; + std::fs::write(dir.join(name).join("device/device"), format!("{device:#06x}\n")) + }; + let connector = |name: &str, status: &str| -> std::io::Result<()> { + std::fs::create_dir_all(dir.join(name))?; + std::fs::write(dir.join(name).join("status"), format!("{status}\n")) + }; + card("card1", NVIDIA)?; + card("card2", AMD_IGPU)?; + connector("card1-DP-3", "connected")?; + connector("card1-DP-4", "connected")?; + connector("card2-HDMI-A-1", "disconnected")?; + connector("card2-Writeback-1", "unknown")?; + let desktop = super::linux_display_gpus(&dir); + // Issue #445: a monitor on the integrated GPU too, still no built-in panel. + connector("card2-HDMI-A-1", "connected")?; + let mut both = super::linux_display_gpus(&dir); + // A hybrid laptop: the integrated GPU drives the built-in panel as well. + connector("card2-eDP-1", "connected")?; + let mut laptop = super::linux_display_gpus(&dir); + std::fs::remove_dir_all(&dir)?; + assert_eq!(desktop, vec![monitor(NVIDIA)]); + both.sort_by_key(|g| g.pci); + assert_eq!(both, vec![monitor(AMD_IGPU), monitor(NVIDIA)]); + laptop.sort_by_key(|g| g.pci); + assert_eq!(laptop, vec![panel(AMD_IGPU), monitor(NVIDIA)]); + assert!(super::linux_display_gpus(std::path::Path::new("/nonexistent/drm")).is_empty()); + Ok(()) +} + +/// A fresh folder per call: tests run in parallel. +fn scratch() -> std::path::PathBuf { + static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); + let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let d = std::env::temp_dir().join(format!("pdfcraft-control-file-{}-{n}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d +} + +/// The control file's port and token, and every name in its folder. +fn written(dir: &std::path::Path) -> (serde_json::Value, Vec) { + let json = serde_json::from_str(&std::fs::read_to_string(dir.join("ctl.json")).unwrap()).unwrap(); + let mut names: Vec = std::fs::read_dir(dir).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect(); + names.sort(); + (json, names) +} + +#[test] +fn the_control_file_replaces_a_hard_link_instead_of_writing_through_it() { + // The file used to be opened with create + truncate, so a link planted at the path had its + // target truncated and overwritten with the token. + let dir = scratch(); + std::fs::write(dir.join("victim.txt"), "keep me").unwrap(); + std::fs::hard_link(dir.join("victim.txt"), dir.join("ctl.json")).unwrap(); + super::write_control_file(&dir.join("ctl.json").to_string_lossy(), 4242, "t0ken").unwrap(); + assert_eq!(std::fs::read_to_string(dir.join("victim.txt")).unwrap(), "keep me"); + let (json, names) = written(&dir); + assert_eq!((json["port"].as_u64(), json["token"].as_str()), (Some(4242), Some("t0ken"))); + assert_eq!(names, ["ctl.json", "victim.txt"], "no temporary file left behind"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn the_control_file_replaces_a_symlink_instead_of_writing_through_it() { + let dir = scratch(); + let (link, target) = (dir.join("ctl.json"), dir.join("victim.txt")); + std::fs::write(&target, "keep me").unwrap(); + #[cfg(unix)] + let made = std::os::unix::fs::symlink(&target, &link); + #[cfg(windows)] + let made = std::os::windows::fs::symlink_file(&target, &link); + if let Err(e) = made { + // Windows needs Developer Mode (or admin) for symlinks. + eprintln!("skipped: can't create a symlink here: {e}"); + let _ = std::fs::remove_dir_all(&dir); + return; + } + super::write_control_file(&link.to_string_lossy(), 4242, "t0ken").unwrap(); + assert_eq!(std::fs::read_to_string(&target).unwrap(), "keep me"); + assert!(!std::fs::symlink_metadata(&link).unwrap().file_type().is_symlink()); + assert_eq!(written(&dir).0["token"].as_str(), Some("t0ken")); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn the_control_file_replaces_the_one_a_previous_run_left() { + let dir = scratch(); + std::fs::write(dir.join("ctl.json"), r#"{"port":1,"token":"old"}"#).unwrap(); + super::write_control_file(&dir.join("ctl.json").to_string_lossy(), 4242, "t0ken").unwrap(); + let (json, names) = written(&dir); + assert_eq!(json["token"].as_str(), Some("t0ken")); + assert_eq!(names, ["ctl.json"]); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(dir.join("ctl.json")).unwrap().permissions().mode(); + assert_eq!(mode & 0o077, 0, "only the owner can read the token: {mode:o}"); + } + let _ = std::fs::remove_dir_all(&dir); +} + +/// X11 startup notification and docks match a window to its launcher by `StartupWMClass`, +/// so it has to be the class the window carries: the app id, on Wayland and X11 alike. +#[test] +fn the_desktop_entry_names_the_window_class() { + let entry = include_str!("../../../packaging/linux/ai.storyteller.pdfcraft.desktop"); + let class = entry.lines().find_map(|line| line.strip_prefix("StartupWMClass=")); + assert_eq!(class, Some(super::APP_ID)); +} + +// Regression test for #246: `eprintln!` panics when standard error is a broken pipe (a GUI +// launch on Windows), and a panic inside the panic hook aborts the process. The start-up +// path and the hook must write to standard error without it. +#[test] +fn start_up_and_the_panic_hook_never_use_eprintln() { + let source = include_str!("main.rs"); + // Everything before the top-level test module is product code. + let product = source.split("\n#[cfg(test)]\nmod tests;").next().unwrap_or(source); + assert!(product.len() < source.len(), "found the end of the product code"); + let offenders: Vec<_> = product + .lines() + .enumerate() + .filter(|(_, line)| !line.trim_start().starts_with("//") && line.contains("eprintln!")) + .map(|(n, line)| format!("main.rs:{}: {}", n + 1, line.trim())) + .collect(); + assert!( + offenders.is_empty(), + "`eprintln!` panics on a broken standard error pipe; use `log::warn!` or `write_fmt` on `stderr()` and ignore the error:\n{}", + offenders.join("\n") + ); +} diff --git a/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 a9c43fb423..cf32ea7930 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -18,1833 +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, - /// 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], -} +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> { - /// 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, -} +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 { - continued_from(blocks).into_iter().map(|from| from.is_some()).collect() -} - -/// Per block, the block it continues on the same line (see [`continuations`]): of those it could -/// continue, the one that ends nearest to where it starts. -fn continued_from(blocks: &[Block]) -> Vec> { - let mut by_end: Vec<(f64, usize)> = blocks.iter().enumerate().map(|(i, b)| (b.rect[2], i)).collect(); - by_end.sort_by(|a, b| a.0.total_cmp(&b.0)); - blocks - .iter() - .enumerate() - .map(|(bi, b)| { - let k = by_end.partition_point(|(end, _)| *end < b.rect[0] - INLINE_GAP * b.size); - by_end - .get(k..) - .unwrap_or_default() - .iter() - .take_while(|(end, _)| *end <= b.rect[0] + 1.0) - .take(CONTINUATION_PROBE) - .filter_map(|(_, ai)| blocks.get(*ai).filter(|_| *ai != bi).map(|a| (*ai, a))) - .filter(|(_, a)| a.rect[3].min(b.rect[3]) - a.rect[1].max(b.rect[1]) > 0.5 * (a.rect[3] - a.rect[1]).min(b.rect[3] - b.rect[1])) - .min_by(|(_, a), (_, c)| (a.rect[2] - b.rect[0]).abs().total_cmp(&(c.rect[2] - b.rect[0]).abs())) - .map(|(ai, _)| ai) - }) - .collect() -} - -/// A gap between two runs wider than this (as a fraction of the font size) is a word space the -/// text left out. It is the gap the reading-block grouping uses for the same purpose. -const WORD_GAP: f64 = 0.12; - -/// One run of a paragraph: a block, and the text it contributes (its own, plus the word space -/// before the next run when the source had one there or the page shows a gap, but the text has -/// none). -pub(crate) struct Run<'a> { - pub(crate) block: &'a Block, - pub(crate) text: String, -} - -/// A page's paragraphs, each a list of runs from left to right along its line. A block that -/// continues another on the same line ([`continued_from`]: an italic case name, a link, a -/// superscript) is a run in its own style inside that block's paragraph, not a paragraph of its -/// own (#4158). Blocks marked in `skip` (table cells, a slide title) are left out and join -/// nothing. Paragraphs come in the order of their first runs in `blocks`. -pub(crate) fn paragraphs<'a>(blocks: &'a [Block], skip: &[bool]) -> Vec>> { - let skipped = |i: usize| skip.get(i).copied().unwrap_or(false); - let from = continued_from(blocks); - // Left to right. A block joins only one that starts strictly further left (in `total_cmp`'s - // order, NaN included), so that one is visited first and has its paragraph already, and the - // links cannot form a cycle. - let mut order: Vec<(f64, usize)> = blocks.iter().enumerate().filter(|(i, _)| !skipped(*i)).map(|(i, b)| (b.rect[0], i)).collect(); - order.sort_by(|a, b| a.0.total_cmp(&b.0).then(a.1.cmp(&b.1))); - // Each block's paragraph, named by its first block, and each paragraph's blocks in order. - let mut head: Vec = (0..blocks.len()).collect(); - let mut members: Vec> = vec![Vec::new(); blocks.len()]; - for &(x0, bi) in &order { - let Some(b) = blocks.get(bi) else { continue }; - let joined = from - .get(bi) - .copied() - .flatten() - .filter(|ai| !skipped(*ai)) - .filter(|ai| blocks.get(*ai).is_some_and(|a| a.rect[0].total_cmp(&x0).is_lt() && (a.rotation - b.rotation).abs() <= 0.1)) - .and_then(|ai| head.get(ai).copied()); - let h = joined.unwrap_or(bi); - if let Some(slot) = head.get_mut(bi) { - *slot = h; - } - if let Some(m) = members.get_mut(h) { - m.push(bi); - } - } - members - .into_iter() - .filter(|m| !m.is_empty()) - .map(|m| { - let mut runs: Vec> = Vec::with_capacity(m.len()); - for b in m.into_iter().filter_map(|bi| blocks.get(bi)) { - if let Some(prev) = runs.last_mut() - && !prev.text.ends_with(char::is_whitespace) - && !b.text.starts_with(char::is_whitespace) - && (prev.block.space_around[1] || b.space_around[0] || b.rect[0] - prev.block.rect[2] > b.size * WORD_GAP) - { - prev.text.push(' '); - } - runs.push(Run { block: b, text: b.text.clone() }); - } - runs - }) - .collect() -} - -/// A paragraph's heading level: the lowest of its runs', so body text that runs on after a bold -/// lead-in on the same line keeps the paragraph body text. A marker set smaller than the first run -/// (a footnote number, "st" in "1st") does not count, so a heading keeps its footnote marker. -fn para_level(runs: &[Run], body: f64) -> u8 { - let Some(first) = runs.first() else { return 0 }; - let marker = |r: &Run| r.block.size < first.block.size * 0.9 && r.block.text.trim().chars().count() <= 3; - runs.iter().filter(|r| !marker(r)).map(|r| level(r.block, body)).min().unwrap_or(0) -} - -/// A cell is short: taller blocks are body text (or a multi-column layout), not table cells. -fn is_cell_like(b: &Block) -> bool { - (b.rect[3] - b.rect[1]) <= b.size * 5.0 && !b.text.trim().is_empty() -} - -/// Detect tables in a page's blocks from their text layout: columns are left edges shared by -/// several short blocks, rows are blocks whose vertical spans overlap. Returns the tables and, -/// per block, whether a table consumed it (so `items` can drop it from the paragraph flow). -pub(crate) fn tables(blocks: &[Block]) -> (Vec
, Vec) { - let mut consumed = vec![false; blocks.len()]; - // Candidate columns: cluster the left edges of cell-like blocks. - let mut xs: Vec<(f64, usize)> = blocks.iter().enumerate().filter(|(_, b)| is_cell_like(b)).map(|(i, b)| (b.rect[0], i)).collect(); - xs.sort_by(|a, b| a.0.total_cmp(&b.0)); - let mut cols: Vec<(f64, Vec)> = Vec::new(); // (left edge, member block indices) - for (x, i) in xs { - match cols.last_mut() { - Some((e, m)) if (x - *e).abs() <= COL_TOL => { - *e = (*e * m.len() as f64 + x) / (m.len() + 1) as f64; - m.push(i); - } - _ => cols.push((x, vec![i])), - } - } - let cols: Vec<(f64, Vec)> = cols.into_iter().filter(|(_, m)| m.len() >= 2).collect(); - if cols.len() < 2 { - return (Vec::new(), consumed); - } - // Each block's column (an index into `cols`), so lookups stay linear on pages with many blocks. - let mut col_of: Vec> = vec![None; blocks.len()]; - for (ci, (_, members)) in cols.iter().enumerate() { - for &i in members { - if let Some(c) = col_of.get_mut(i) { - *c = Some(ci); - } - } - } - // The column whose edge is within tolerance of `x` (edges are in ascending order). - let column_at = |x: f64| -> Option { - let k = cols.partition_point(|(e, _)| *e < x - COL_TOL); - cols.get(k).filter(|(e, _)| (*e - x).abs() <= COL_TOL).map(|_| k) - }; - // Assign every cell-like block to its nearest column (within tolerance). - let mut rows: Vec> = Vec::new(); // (column index, block index), top to bottom - let mut order: Vec = (0..blocks.len()).collect(); - order.sort_by(|a, b| blocks[*b].rect[3].total_cmp(&blocks[*a].rect[3]).then(blocks[*a].rect[0].total_cmp(&blocks[*b].rect[0]))); - for &bi in &order { - let b = &blocks[bi]; - if !is_cell_like(b) { - continue; - } - let Some(ci) = column_at(b.rect[0]) else { continue }; - let joins = rows - .last() - .and_then(|row| row.first()) - .map(|(_, fi)| { - let rb = &blocks[*fi]; - let overlap = rb.rect[3].min(b.rect[3]) - rb.rect[1].max(b.rect[1]); - overlap > 0.5 * (rb.rect[3] - rb.rect[1]).min(b.rect[3] - b.rect[1]) - }) - .unwrap_or(false); - if joins { - if let Some(row) = rows.last_mut() { - row.push((ci, bi)); - } - } else { - rows.push(vec![(ci, bi)]); - } - } - // Runs of consecutive rows that have two or more distinct columns are tables. - let mut out = Vec::new(); - let flush = |run: &mut Vec>, out: &mut Vec
, consumed: &mut Vec| { - if run.len() < 2 { - run.clear(); - return; - } - let mut used: Vec = run.iter().flatten().map(|(_, i)| *i).collect(); - used.sort_unstable(); - used.dedup(); - let mut edges: Vec = used.iter().filter_map(|&i| col_of.get(i).copied().flatten()).filter_map(|ci| cols.get(ci)).map(|c| c.0).collect(); - edges.sort_by(f64::total_cmp); - edges.dedup_by(|a, b| (*a - *b).abs() <= COL_TOL); - let ncols = edges.len(); - let ok = (ncols >= 3 || (ncols == 2 && run.len() >= 3)) && ncols <= MAX_COLS; - if !ok { - run.clear(); - return; - } - let right = used.iter().map(|&i| blocks[i].rect[2]).fold(f64::MIN, f64::max) + COL_TOL; - let mut rows_out = Vec::new(); - let mut slots: Vec)>> = vec![None; ncols]; - for row in run.drain(..) { - for slot in slots.iter_mut() { - *slot = None; - } - for (_ci, bi) in row { - let b = &blocks[bi]; - let Some(slot) = edges.iter().position(|e| (*e - b.rect[0]).abs() <= COL_TOL) else { continue }; - let span = edges[slot + 1..].iter().filter(|e| **e > b.rect[0] + COL_TOL && **e < b.rect[2] - COL_TOL).count() + 1; - let cell = Cell { text: b.text.trim().to_string(), size: b.size, bold: b.bold, italic: b.italic, color: b.color, span }; - match slots.get_mut(slot) { - Some(Some((c, sources))) => { - c.text.push(' '); - c.text.push_str(&cell.text); - sources.push(bi); - } - Some(empty @ None) => *empty = Some((cell, vec![bi])), - None => continue, - } - } - let mut materialized = Vec::with_capacity(ncols); - let mut column = 0; - while column < ncols { - match slots.get_mut(column).and_then(Option::take) { - Some((mut cell, sources)) => { - // A spanning cell can skip later slots. Only consume source blocks - // when their text is emitted, leaving covered text in paragraph flow. - for bi in sources { - if let Some(taken) = consumed.get_mut(bi) { - *taken = true; - } - } - cell.span = cell.span.min(ncols - column).max(1); - column += cell.span; - materialized.push(cell); - } - None => { - column += 1; - materialized.push(Cell { text: String::new(), size: 11.0, bold: false, italic: false, color: [0.0; 3], span: 1 }); - } - } - } - while materialized.last().is_some_and(|cell| cell.text.is_empty()) && materialized.iter().any(|cell| cell.span > 1) { - materialized.pop(); - } - rows_out.push(materialized); - } - let rect = [ - edges[0], - used.iter().map(|&i| blocks[i].rect[1]).fold(f64::MAX, f64::min), - right, - used.iter().map(|&i| blocks[i].rect[3]).fold(f64::MIN, f64::max), - ]; - out.push(Table { rect, cols: edges, rows: rows_out }); - }; - // Runs of consecutive multi-column rows are tables. Rows on either side of a run that - // hold a single cell wide enough to span two columns (a header band, a totals row) join - // the table; other single-column rows (captions, following text) stay paragraphs. - let spans_run = |edges: &[f64], bi: usize| -> bool { - let b = &blocks[bi]; - edges.iter().any(|e| (*e - b.rect[0]).abs() <= COL_TOL) && edges.iter().any(|e| *e > b.rect[0] + COL_TOL && *e < b.rect[2] - COL_TOL) - }; - let run_edges = |run: &[Vec<(usize, usize)>]| -> Vec { - let mut es: Vec = - run.iter().flatten().filter_map(|(_, i)| col_of.get(*i).copied().flatten()).filter_map(|ci| cols.get(ci)).map(|c| c.0).collect(); - es.sort_by(f64::total_cmp); - es.dedup_by(|a, b| (*a - *b).abs() <= COL_TOL); - es - }; - let cont = continuations(blocks); - let qual: Vec = rows - .iter() - .map(|row| { - // A block that continues another adds no column of its own, and the cells of a table row - // sit side by side: blocks that overlap are a paragraph's lines, not cells (#2806). - let cells: Vec<(usize, usize)> = row.iter().copied().filter(|(_, bi)| !cont.get(*bi).copied().unwrap_or(false)).collect(); - let mut spans: Vec<[f64; 2]> = cells.iter().filter_map(|(_, bi)| blocks.get(*bi)).map(|b| [b.rect[0], b.rect[2]]).collect(); - spans.sort_by(|a, b| a[0].total_cmp(&b[0])); - let apart = spans.windows(2).all(|w| w[1][0] >= w[0][1] - COL_TOL); - let mut cs: Vec = cells.iter().map(|(c, _)| *c).collect(); - cs.sort_unstable(); - cs.dedup(); - apart && cs.len() >= 2 - }) - .collect(); - let mut i = 0; - while i < rows.len() { - if !qual[i] { - i += 1; - continue; - } - let start = i; - while i < rows.len() && qual[i] { - i += 1; - } - let end = i; // run = rows[start..end] - let mut pieces: Vec> = Vec::new(); - if start > 0 && rows[start - 1].len() == 1 && spans_run(&run_edges(&rows[start..end]), rows[start - 1][0].1) { - pieces.push(rows[start - 1].clone()); - } - pieces.extend(rows[start..end].iter().cloned()); - if end < rows.len() && rows[end].len() == 1 && spans_run(&run_edges(&rows[start..end]), rows[end][0].1) { - pieces.push(rows[end].clone()); - } - flush(&mut pieces, &mut out, &mut consumed); - } - (out, consumed) -} - -fn items(pages: &[Page]) -> Vec> { - let body = body_size(pages); - let mut out = Vec::new(); - for (i, p) in pages.iter().enumerate() { - if i > 0 { - out.push(Item::PageBreak); - } - let (tables, consumed) = tables(&p.blocks); - // Blocks and images by their top edge, top to bottom. - // A paragraph sits where its first run does: its other runs follow along the same line. - let mut parts: Vec<(f64, f64, Item)> = paragraphs(&p.blocks, &consumed) - .into_iter() - .filter_map(|runs| { - let first = runs.first()?.block; - let lvl = para_level(&runs, body); - Some((first.rect[3], first.rect[0], Item::Para(runs, lvl))) - }) - .collect(); - parts.extend(tables.into_iter().map(|t| (t.rect[3], t.rect[0], Item::Table(t)))); - parts.extend(p.images.iter().map(|im| (im.rect[3], im.rect[0], Item::Img(im)))); - parts.sort_by(|a, b| b.0.total_cmp(&a.0).then(a.1.total_cmp(&b.1))); - out.extend(parts.into_iter().map(|x| x.2)); - } - out -} - -/// Whether a character may appear in the output. XML 1.0 forbids the C0 controls other than tab, -/// line feed and carriage return, and U+FFFE/U+FFFF. Text extracted from PDFs often holds them -/// (fonts without a usable `/ToUnicode`), and Word refuses a whole document over one (#72). -fn allowed(c: char) -> bool { - matches!(c, '\t' | '\n' | '\r') || !(c.is_ascii_control() || c == '\u{FFFE}' || c == '\u{FFFF}') -} - -pub(crate) fn esc(s: &str) -> String { - let mut o = String::with_capacity(s.len()); - for c in s.chars().filter(|c| allowed(*c)) { - match c { - '&' => o.push_str("&"), - '<' => o.push_str("<"), - '>' => o.push_str(">"), - '"' => o.push_str("""), - c => o.push(c), - } - } - o -} - -fn base64(data: &[u8]) -> String { - const A: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; - let mut s = String::with_capacity(data.len().div_ceil(3) * 4); - for c in data.chunks(3) { - let n = (c[0] as u32) << 16 | (*c.get(1).unwrap_or(&0) as u32) << 8 | *c.get(2).unwrap_or(&0) as u32; - s.push(A[(n >> 18) as usize & 63] as char); - s.push(A[(n >> 12) as usize & 63] as char); - s.push(if c.len() > 1 { A[(n >> 6) as usize & 63] as char } else { '=' }); - s.push(if c.len() > 2 { A[n as usize & 63] as char } else { '=' }); - } - s -} - -/// One HTML file: headings and paragraphs, tables as real `
` elements, images inline, a -/// rule between pages. -pub fn html(pages: &[Page], title: &str) -> String { - let mut s = format!( - "\n\n\n\n{}\n\n\n\n", - esc(title) - ); - for it in items(pages) { - match it { - Item::Para(runs, lvl) => { - let Some(first) = runs.first().map(|r| r.block) else { continue }; - let mut t = String::new(); - for r in &runs { - let mut run = esc(&r.text); - if r.block.italic { - run = format!("{run}"); - } - if r.block.bold && lvl == 0 { - run = format!("{run}"); - } - // The paragraph has its first run's colour; a run in another colour says so. - let colour = rgb8(r.block.color); - if colour != rgb8(first.color) { - let [red, green, blue] = colour; - run = format!("{run}"); - } - t.push_str(&run); - } - let style = hex(first.color).map_or(String::new(), |h| format!(" style=\"color:#{h}\"")); - match lvl { - 0 => s.push_str(&format!("{t}

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

\"\"

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

Annual Report

", "

Overview

", "

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


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

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

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

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

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

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

Before Chevron after.

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

Before

") && h.contains("

SIDE

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

Annual Report

", "

Overview

", "

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


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

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

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

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

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

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

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

Before Chevron after.

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

Before

") && h.contains("

SIDE

"), "{h}"); +} +/// A block that starts where the line above ends is the next line, not a run of it. +#[test] +fn a_block_on_the_next_line_starts_a_paragraph() { + let blocks = vec![ + block("The first line ends here", 72.0, 700.0, 190.0, 712.0), + Block { italic: true, ..block("and the next starts there.", 190.5, 690.0, 320.0, 702.0) }, + ]; + let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None }; + let xml = part(&docx(std::slice::from_ref(&p), "Lines"), "word/document.xml"); + assert_eq!(word_paragraphs(&xml), vec![runs(&[("The first line ends here", false)]), runs(&[("and the next starts there.", true)])], "{xml}"); +} + +/// A superscript beside a table cell stays in the table: table detection consumes it before +/// paragraphs are formed, and a sentence after the table still keeps its italic run. +#[test] +fn table_cells_are_not_runs_of_a_paragraph() { + let mut blocks = vec![block("PLACE", 100.0, 700.0, 140.0, 712.0), block("PRIZE (euros)", 300.0, 700.0, 380.0, 712.0)]; + for (n, (row, sup)) in [("1", "st"), ("2", "nd"), ("3", "rd")].iter().enumerate() { + let y = 680.0 - 20.0 * n as f64; + blocks.push(block(row, 100.0, y, 106.0, y + 12.0)); + blocks.push(Block { size: 8.0, ..block(sup, 106.0, y + 5.0, 113.0, y + 13.0) }); + blocks.push(block(&format!("{},000", 30 - 5 * n), 300.0, y, 340.0, y + 12.0)); + } + blocks.extend(italic_sentence().into_iter().map(|b| Block { rect: [b.rect[0], b.rect[1] - 200.0, b.rect[2], b.rect[3] - 200.0], ..b })); + let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None }; + let xml = part(&docx(std::slice::from_ref(&p), "Prizes"), "word/document.xml"); + assert_eq!(xml.matches("").count(), 1, "{xml}"); + for cell in ["st", "nd", "rd", "30,000"] { + assert_eq!(xml.matches(&format!("preserve\">{cell}")).count(), 1, "{cell}: {xml}"); + } + // The cells are all in the table: the only flowing paragraph is the sentence. + assert_eq!(word_paragraphs(&xml), vec![runs(&[("Before ", false), ("Chevron ", true), ("after.", false)])], "{xml}"); +} + +/// A bold lead-in followed by body text on its line is body text, not a heading; a heading +/// with a smaller footnote marker after it stays a heading. +#[test] +fn a_paragraph_is_a_heading_only_when_its_text_is() { + let blocks = vec![ + Block { size: 24.0, ..block("Annual Report", 72.0, 740.0, 250.0, 764.0) }, + Block { size: 14.0, ..block("1", 251.0, 752.0, 258.0, 766.0) }, + Block { bold: true, ..block("Summary", 72.0, 700.0, 125.0, 712.0) }, + block("the year closed with more members than it opened with, and fewer debts.", 128.0, 700.0, 540.0, 712.0), + ]; + let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None }; + let xml = part(&docx(std::slice::from_ref(&p), "Report"), "word/document.xml"); + assert_eq!(xml.matches("").count(), 1, "{xml}"); + assert!(!xml.contains("Heading2"), "the lead-in made its paragraph a heading: {xml}"); + assert_eq!(word_paragraphs(&xml).len(), 2, "{xml}"); +} diff --git a/crates/fonts/src/pdf.rs b/crates/fonts/src/pdf.rs index 6e7ac7a8c2..240b1c4d55 100644 --- a/crates/fonts/src/pdf.rs +++ b/crates/fonts/src/pdf.rs @@ -1330,818 +1330,7 @@ fn identity_to_unicode() -> Vec { } #[cfg(test)] -mod tests { - use pdfcraft_cos::{Dict, Document, Object, Stream}; - - use super::*; - - /// Span records read while `f` runs. - fn span_reads(f: impl FnOnce()) -> usize { - SPAN_READS.with(|c| c.set(0)); - f(); - SPAN_READS.with(|c| c.get()) - } - - /// A composite font with `encoding` as its CMap, `w` as its widths and `to_unicode`. - fn composite(doc: &mut Document, encoding: &str, w: Vec, to_unicode: &str) -> Dict { - let mut desc = Dict::new(); - desc.set(b"Subtype".to_vec(), Object::name("CIDFontType2")); - desc.set(b"W".to_vec(), Object::Array(w)); - let enc = Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), encoding.as_bytes())))); - let tu = Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), to_unicode.as_bytes())))); - font( - doc, - vec![ - ("Subtype", Object::name("Type0")), - ("Encoding", enc), - ("DescendantFonts", Object::Array(vec![Object::Dict(desc)])), - ("ToUnicode", tu), - ], - ) - } - - /// Many `/W` ranges that leave a code out cost a glyph of it a binary search, not a scan of them all. - #[test] - fn widths_of_a_code_no_range_covers_take_few_steps() { - let mut doc = Document::new_empty(); - let w: Vec = (1..=10_000i64).flat_map(|k| [Object::Int(2 * k), Object::Int(2 * k), Object::Int(500)]).collect(); - let f = composite(&mut doc, "", w, ""); - let m = Metrics::from_dict(&doc, &f); - assert_eq!(m.width(4), 0.5); - let reads = span_reads(|| { - for _ in 0..1000 { - assert_eq!(m.width(3), 1.0); - } - }); - assert!(reads <= 1000 * 64, "{reads} span reads"); - } - - /// Where spans overlap, the first-declared one wins, as a scan in declaration order finds it. - #[test] - fn overlapping_spans_resolve_to_the_first_declared() { - let spans = Spans::new(vec![(0, 10, 'a'), (5, 20, 'b'), (3, 3, 'c'), (30, 25, 'd'), (u32::MAX, u32::MAX, 'e')]); - let found = |code| spans.find(code).map(|s| s.2); - let want = [(0, Some('a')), (3, Some('a')), (10, Some('a')), (11, Some('b')), (20, Some('b')), (21, None), (27, None), (u32::MAX, Some('e'))]; - for (code, value) in want { - assert_eq!(found(code), value, "code {code}"); - } - } - - /// Checking the glyph of every explicit ToUnicode entry looks its code up once in the CID map. - #[test] - fn many_unicode_entries_look_up_their_cids_in_few_steps() { - let n = 2000; - let cidchars: String = (0..n).map(|k| format!("<{:04X}> {} ", 0x8000 + k, k + 1)).collect(); - let encoding = format!("1 begincodespacerange <0000> endcodespacerange {n} begincidchar {cidchars} endcidchar"); - let bfchars: String = (0..n).map(|k| format!("<{:04X}> <{:04X}> ", 0x1000 + k, 0x4E00 + k)).collect(); - let to_unicode = format!("{n} beginbfchar {bfchars} endbfchar"); - let mut doc = Document::new_empty(); - let f = composite(&mut doc, &encoding, Vec::new(), &to_unicode); - let reads = span_reads(|| { - Metrics::from_dict(&doc, &f); - }); - assert!(reads <= n * 64, "{reads} span reads"); - } - - /// Every glyph of a sequential range builds its text from the destination, so a long one is cut - /// to the CMap limit once, when it is read. - #[test] - fn unicode_destinations_are_cut_to_the_cmap_limit() { - let long = "0041".repeat(4096); - let cmap = format!("2 beginbfrange <0010> <0020> <{long}> <0030> <0030> [<{long}>] endbfrange 1 beginbfchar <0040> <{long}> endbfchar"); - let mut u = Unicode::default(); - parse_to_unicode(cmap.as_bytes(), &mut u); - for code in [0x10, 0x20, 0x30, 0x40] { - let text = u.text(code).unwrap_or_default(); - assert!(!text.is_empty() && text.len() <= MAX_DESTINATION_UNITS * 3, "code {code:#x}: {} bytes", text.len()); - } - } - - /// A font whose ranges cover a few low codes keeps tables that size, not a fixed 64k-entry one. - #[test] - fn low_code_tables_end_with_the_last_covered_code() { - // One range over codes 0x20..=0x21, then nothing. - let cover = [(0x20u64, Some(0usize)), (0x22, None)]; - let owner = super::owners_below_64k(&cover); - assert_eq!(owner.len(), 0x22); - assert_eq!((owner[0x1F], owner[0x20], owner[0x21]), (u32::MAX, 0, 0)); - assert!(super::owners_below_64k(&[(0x2_0000, Some(0))]).is_empty(), "no code below 65 536"); - let bits = super::bit_set_below_64k([3u32, 130, 0x2_0000].into_iter()); - assert_eq!(bits.len(), 3); - assert_eq!((bits[0], bits[2]), (1 << 3, 1 << 2)); - assert!(super::bit_set_below_64k([0x1_0000u32].into_iter()).is_empty()); - } - - fn font(doc: &mut Document, entries: Vec<(&str, Object)>) -> Dict { - let mut d = Dict::new(); - d.set(b"Type".to_vec(), Object::name("Font")); - for (k, v) in entries { - d.set(k.as_bytes().to_vec(), v); - } - let _ = doc; - d - } - - #[test] - fn embedded_editor_fonts_are_validated_without_installing_or_exporting() { - use pdfcraft_cos::Stream; - let mut doc = Document::new_empty(); - let bytes = include_bytes!("../../../assets/fonts/Inter-Regular.ttf"); - let program = doc.add(Object::Stream(Stream::flate(Dict::new(), bytes))); - let mut descriptor = Dict::new(); - descriptor.set(b"FontFile2".to_vec(), Object::Ref(program)); - let simple = font(&mut doc, vec![("FontDescriptor", Object::Dict(descriptor.clone()))]); - assert_eq!(embedded_sfnt(&doc, &simple).unwrap(), bytes); - let composite = font(&mut doc, vec![("DescendantFonts", Object::Array(vec![Object::Dict(simple.clone())]))]); - assert_eq!(embedded_sfnt(&doc, &composite).unwrap(), bytes); - let broken = doc.add(Object::Stream(Stream::flate(Dict::new(), b"broken font"))); - descriptor.set(b"FontFile2".to_vec(), Object::Ref(broken)); - let broken = font(&mut doc, vec![("FontDescriptor", Object::Dict(descriptor))]); - assert!(embedded_sfnt(&doc, &broken).is_none()); - assert!(embedded_sfnt(&doc, &Dict::new()).is_none()); - } - - /// #2130: with no `/Widths`, an embedded program's advances win over a guess from the name - /// ("Mono" would mean Courier, 600 units for every glyph). - #[test] - fn a_font_without_widths_measures_with_its_embedded_program() { - use pdfcraft_cos::Stream; - use skrifa::MetadataProvider as _; - let mut doc = Document::new_empty(); - let bytes = include_bytes!("../../../assets/fonts/Inter-Regular.ttf"); - let face = skrifa::FontRef::new(bytes).unwrap(); - let (size, location) = (skrifa::instance::Size::unscaled(), skrifa::instance::LocationRef::default()); - let upem = f64::from(face.metrics(size, location).units_per_em); - let advance = |c: char| f64::from(face.glyph_metrics(size, location).advance_width(face.charmap().map(c).unwrap()).unwrap()) / upem; - let program = doc.add(Object::Stream(Stream::flate(Dict::new(), bytes))); - let mut descriptor = Dict::new(); - descriptor.set(b"FontFile2".to_vec(), Object::Ref(program)); - let f = font( - &mut doc, - vec![ - ("Subtype", Object::name("TrueType")), - ("BaseFont", Object::name("HouseMono")), - ("Encoding", Object::name("WinAnsiEncoding")), - ("FontDescriptor", Object::Dict(descriptor)), - ], - ); - let m = Metrics::from_dict(&doc, &f); - for c in ['i', 'l', 'W', ' '] { - assert!((m.width(u32::from(c)) - advance(c)).abs() < 1e-9, "{c}: {} vs {}", m.width(u32::from(c)), advance(c)); - } - assert!(m.width(u32::from('i')) < 0.4, "not Courier's 0.6"); - } - - #[test] - fn mac_subset_cmap_uses_pdf_unicode_without_changing_source_program() { - use skrifa::{MetadataProvider as _, raw::TableProvider as _}; - let original = include_bytes!("../../../assets/fonts/Inter-Regular.ttf"); - let face = skrifa::FontRef::new(original).unwrap(); - let expected = face.charmap().map('Ω').unwrap(); - let mut subset = original.to_vec(); - let count = u16::from_be_bytes(subset[4..6].try_into().unwrap()) as usize; - let entry = (0..count).map(|i| 12 + i * 16).find(|i| &subset[*i..*i + 4] == b"cmap").unwrap(); - let mut cmap = vec![0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 12]; - cmap.extend_from_slice(&[0, 6, 0, 12, 0, 0, 0, 65, 0, 1]); - cmap.extend_from_slice(&u16::try_from(expected.to_u32()).unwrap().to_be_bytes()); - let offset = subset.len() as u32; - subset[entry + 8..entry + 12].copy_from_slice(&offset.to_be_bytes()); - subset[entry + 12..entry + 16].copy_from_slice(&(cmap.len() as u32).to_be_bytes()); - subset.extend_from_slice(&cmap); - let face = skrifa::FontRef::new(&subset).unwrap(); - assert!(!face.charmap().has_map(), "egui cannot directly use Mac format 6"); - assert_eq!(face.cmap().unwrap().map_codepoint(65_u32), Some(expected)); - let mut doc = Document::new_empty(); - let program = doc.add(Object::Stream(Stream::flate(Dict::new(), &subset))); - let mut descriptor = Dict::new(); - descriptor.set(b"FontFile2".to_vec(), Object::Ref(program)); - let unicode = doc.add(Object::Stream(Stream::flate(Dict::new(), b"1 beginbfchar <41> <03A9> endbfchar"))); - let source = font(&mut doc, vec![("FontDescriptor", Object::Dict(descriptor)), ("ToUnicode", Object::Ref(unicode))]); - let editor = embedded_sfnt(&doc, &source).unwrap(); - let face = skrifa::FontRef::new(&editor).unwrap(); - assert_eq!(face.charmap().map('Ω'), Some(expected), "Unicode selects the original glyph"); - let Object::Stream(source) = doc.resolve(&Object::Ref(program)).as_ref().clone() else { - panic!("source stream"); - }; - assert_eq!(source.decoded().unwrap(), subset, "only an in-memory editor copy is adapted"); - let mut broken = vec![0; 16]; - assert!(append_editor_cmap(&mut broken, &std::collections::BTreeMap::from([(65, 1)])).is_none()); - } - - fn metrics_with_flags(flags: Option) -> Metrics { - let mut doc = Document::new_empty(); - let mut descriptor = Dict::new(); - if let Some(flags) = flags { - descriptor.set(b"Flags".to_vec(), flags); - } - let font = font(&mut doc, vec![("Subtype", Object::name("TrueType")), ("FontDescriptor", Object::Dict(descriptor))]); - Metrics::from_dict(&doc, &font) - } - - #[test] - fn font_descriptor_flags_stay_within_the_u32_domain() { - let valid = metrics_with_flags(Some(Object::Int(64 | 262_144))); - assert!(valid.italic && valid.bold); - - for flags in [Some(Object::Int(-1)), Some(Object::Int(4_294_967_296)), Some(Object::Bool(true)), None] { - let metrics = metrics_with_flags(flags); - assert!(!metrics.italic && !metrics.bold); - } - } - - #[test] - fn type3_descriptor_names_the_font_without_changing_its_metrics() { - let mut doc = Document::new_empty(); - let mut f = font( - &mut doc, - vec![ - ("Subtype", Object::name("Type3")), - ( - "FontMatrix", - Object::Array(vec![Object::Real(0.001), Object::Int(0), Object::Int(0), Object::Real(0.001), Object::Int(0), Object::Int(0)]), - ), - ("FontBBox", Object::Array(vec![Object::Int(0), Object::Int(-300), Object::Int(1000), Object::Int(1000)])), - ], - ); - let before = Metrics::from_dict(&doc, &f); - let mut descriptor = Dict::new(); - descriptor.set(b"Type".to_vec(), Object::name("FontDescriptor")); - descriptor.set(b"FontName".to_vec(), Object::name("ExampleMincho-Regular")); - descriptor.set(b"Flags".to_vec(), Object::Int(6)); - descriptor.set(b"ItalicAngle".to_vec(), Object::Int(0)); - let reference = doc.add(Object::Dict(descriptor)); - f.set(b"FontDescriptor".to_vec(), Object::Ref(reference)); - let after = Metrics::from_dict(&doc, &f); - assert_eq!(after.base_font, "ExampleMincho-Regular"); - assert_eq!((after.ascent, after.descent, after.scale), (before.ascent, before.descent, before.scale)); - assert!(!after.bold && !after.italic && !after.subset); - f.set(b"BaseFont".to_vec(), Object::name("ExplicitName")); - assert_eq!(Metrics::from_dict(&doc, &f).base_font, "ExplicitName", "an explicit BaseFont takes precedence"); - } - - #[test] - fn simple_fonts_decode_and_encode_through_their_encoding() { - let mut doc = Document::new_empty(); - let f = font( - &mut doc, - vec![("Subtype", Object::name("Type1")), ("BaseFont", Object::name("Helvetica")), ("Encoding", Object::name("WinAnsiEncoding"))], - ); - let m = Metrics::from_dict(&doc, &f); - assert_eq!(m.decode(b"Caf\xe9 \x80 \x93x\x94"), "Café € “x”"); - assert_eq!(m.encode("Café €").as_deref(), Some(&b"Caf\xe9 \x80"[..])); - assert_eq!(m.encode("Ω"), None, "not in WinAnsi"); - // Differences rename codes by glyph name. - let mut enc = Dict::new(); - enc.set(b"Differences".to_vec(), Object::Array(vec![Object::Int(65), Object::name("eacute"), Object::name("uni03A9")])); - let f = font(&mut doc, vec![("Subtype", Object::name("Type1")), ("BaseFont", Object::name("Custom")), ("Encoding", Object::Dict(enc))]); - let m = Metrics::from_dict(&doc, &f); - assert_eq!(m.decode(b"AB"), "éΩ"); - assert_eq!(m.encode("Ω").as_deref(), Some(&b"B"[..])); - } - - /// A standard-14 font with no `/Widths` is measured by the glyph each code draws, not by the - /// glyph WinAnsi gives that code (#2894); `/Widths`, when present, still go by code. - #[test] - fn standard_fonts_without_widths_measure_the_glyph_their_differences_name() { - let mut doc = Document::new_empty(); - let mut enc = Dict::new(); - enc.set(b"BaseEncoding".to_vec(), Object::name("WinAnsiEncoding")); - let diffs = - vec![Object::Int(65), Object::name("W"), Object::Int(87), Object::name("A"), Object::name("uni03A9"), Object::name("not.a.glyph")]; - enc.set(b"Differences".to_vec(), Object::Array(diffs)); - let f = font( - &mut doc, - vec![("Subtype", Object::name("Type1")), ("BaseFont", Object::name("Helvetica")), ("Encoding", Object::Dict(enc.clone()))], - ); - let m = Metrics::from_dict(&doc, &f); - assert_eq!((m.width(65), m.width(87)), (0.944, 0.667), "A and W swap their widths"); - // Omega has no WinAnsi code and the last name reads as nothing: both keep their code's width. - assert_eq!((m.width(88), m.width(89)), (0.667, 0.667)); - let f = font( - &mut doc, - vec![ - ("Subtype", Object::name("Type1")), - ("BaseFont", Object::name("Helvetica")), - ("FirstChar", Object::Int(87)), - ("Widths", Object::Array(vec![Object::Int(500)])), - ("Encoding", Object::Dict(enc)), - ], - ); - assert_eq!(Metrics::from_dict(&doc, &f).width(87), 0.5, "/Widths go by code"); - } - - #[test] - fn subset_fonts_only_encode_the_glyphs_they_have() { - let mut doc = Document::new_empty(); - // Widths for a, b only (97, 98); c (99) is 0. - let widths = Object::Array(vec![Object::Int(500), Object::Int(520), Object::Int(0)]); - let f = font( - &mut doc, - vec![ - ("Subtype", Object::name("TrueType")), - ("BaseFont", Object::name("ABCDEF+Arial")), - ("FirstChar", Object::Int(97)), - ("Widths", widths), - ("Encoding", Object::name("WinAnsiEncoding")), - ], - ); - let m = Metrics::from_dict(&doc, &f); - assert!(m.subset); - assert_eq!(m.encode("ab").as_deref(), Some(&b"ab"[..])); - assert_eq!(m.encode("abc"), None); - } - - #[test] - fn cmap_streams_decode_within_a_size_cap() { - // A valid CMap padded past the cap (a small Flate stream of whitespace) is not decoded whole. - let to_unicode = b"1 begincodespacerange <0000> endcodespacerange 1 beginbfrange <0024> <0026> <0041> endbfrange"; - let encoding = b"1 begincodespacerange <0000> endcodespacerange 1 begincidrange <0000> 0 endcidrange"; - for padded in [false, true] { - let mut doc = Document::new_empty(); - let mut stream = |cmap: &[u8]| { - let mut data = cmap.to_vec(); - if padded { - data.resize(MAX_CMAP_BYTES + 1, b' '); - } - Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), &data)))) - }; - let (tu, enc) = (stream(to_unicode), stream(encoding)); - let mut desc = Dict::new(); - desc.set(b"Subtype".to_vec(), Object::name("CIDFontType2")); - let f = font( - &mut doc, - vec![ - ("Subtype", Object::name("Type0")), - ("Encoding", enc), - ("DescendantFonts", Object::Array(vec![Object::Dict(desc)])), - ("ToUnicode", tu), - ], - ); - let m = Metrics::from_dict(&doc, &f); - assert_eq!(m.decode(&[0, 0x24]) == "A", !padded, "ToUnicode, padded: {padded}"); - assert_eq!(matches!(m.codes, Codes::Ranges(_)), !padded, "Encoding, padded: {padded}"); - } - } - - #[test] - fn composite_fonts_use_to_unicode() { - let mut doc = Document::new_empty(); - let cmap = b"/CIDInit /ProcSet findresource begin 1 begincodespacerange <0000> endcodespacerange \ - 2 beginbfchar <0003> <0020> <0010> <00E9> endbfchar 1 beginbfrange <0024> <0026> <0041> endbfrange \ - 1 beginbfrange <0030> <0031> [<0048> <0069>] endbfrange end"; - let tu = doc.add(Object::Stream(Stream::from_raw(Dict::new(), cmap.to_vec()))); - let mut desc = Dict::new(); - desc.set(b"Subtype".to_vec(), Object::name("CIDFontType2")); - desc.set( - b"W".to_vec(), - Object::Array(vec![ - Object::Int(3), - Object::Array(vec![Object::Int(250)]), - Object::Int(16), - Object::Int(16), - Object::Int(500), - Object::Int(36), - Object::Int(49), - Object::Int(600), - ]), - ); - let f = font( - &mut doc, - vec![ - ("Subtype", Object::name("Type0")), - ("BaseFont", Object::name("QWERTY+Noto")), - ("Encoding", Object::name("Identity-H")), - ("DescendantFonts", Object::Array(vec![Object::Dict(desc)])), - ("ToUnicode", Object::Ref(tu)), - ], - ); - let m = Metrics::from_dict(&doc, &f); - assert_eq!(m.decode(&[0, 0x24, 0, 0x25, 0, 3, 0, 0x30, 0, 0x31, 0, 0x10]), "AB Hié"); - assert_eq!(m.encode("Hi A").as_deref(), Some(&[0, 0x30, 0, 0x31, 0, 3, 0, 0x24][..])); - assert_eq!(m.encode("Z"), None); - } - - /// Word's Type 0 subsets map CID 0 as well as the real space glyph to U+0020 and give CID 0 a - /// width; CID 0 is `.notdef`, so a typed space takes the real one (#3223). - #[test] - fn composite_fonts_never_encode_with_cid_0() { - let mut doc = Document::new_empty(); - let cmap = b"/CIDInit /ProcSet findresource begin 1 begincodespacerange <0000> endcodespacerange \ - 1 beginbfrange <0000> <0002> [<0020> <004D> <00E1>] endbfrange \ - 1 beginbfrange <0005> <0007> [<0065> <0072> <0020>] endbfrange end"; - let tu = doc.add(Object::Stream(Stream::from_raw(Dict::new(), cmap.to_vec()))); - let mut desc = Dict::new(); - desc.set(b"Subtype".to_vec(), Object::name("CIDFontType2")); - let widths = [889, 444, 389, 278, 444, 333, 250].map(Object::Int).to_vec(); - desc.set(b"W".to_vec(), Object::Array(vec![Object::Int(0), Object::Array(vec![Object::Int(778)]), Object::Int(1), Object::Array(widths)])); - let f = font( - &mut doc, - vec![ - ("Subtype", Object::name("Type0")), - ("BaseFont", Object::name("BCDEEE+TimesNewRomanPSMT")), - ("Encoding", Object::name("Identity-H")), - ("DescendantFonts", Object::Array(vec![Object::Dict(desc)])), - ("ToUnicode", Object::Ref(tu)), - ], - ); - let m = Metrics::from_dict(&doc, &f); - assert_eq!(m.encode("Me er").as_deref(), Some(&[0, 1, 0, 5, 0, 7, 0, 5, 0, 6][..])); - assert_eq!(m.decode(&[0, 1, 0, 7, 0, 2]), "M á"); - } - - /// WSCAD writes `/ToUnicode /Identity-H` (a name, not a CMap stream) on composite fonts whose - /// codes are UTF-16 code units: the codes read as the same Unicode, as in Acrobat. Controls - /// and lone surrogates map to nothing, and a simple font's name is not a ToUnicode map. - #[test] - fn identity_to_unicode_names_read_codes_as_unicode() { - let doc = Document::new_empty(); - for name in ["Identity-H", "Identity-V"] { - let f = font( - &mut Document::new_empty(), - vec![ - ("Subtype", Object::name("Type0")), - ("BaseFont", Object::name("RobotoMono-Regular")), - ("Encoding", Object::name("Identity-H")), - ("DescendantFonts", Object::Array(vec![Object::Dict(Dict::new())])), - ("ToUnicode", Object::name(name)), - ], - ); - let m = Metrics::from_dict(&doc, &f); - assert_eq!(m.decode(&[0, b'Z', 0, 0xFC, 0, b'r', 0x20, 0xAC, 0x4E, 0x2D]), "Zür€中", "{name}"); - assert_eq!(m.text_of(0x0001), None); - assert_eq!(m.text_of(0x007F), None, "DEL is a control"); - assert_eq!(m.text_of(0x0085), None, "a C1 control"); - assert_eq!(m.text_of(0x00A0).as_deref(), Some("\u{A0}"), "the range resumes after C1"); - assert_eq!(m.text_of(0x007E).as_deref(), Some("~")); - assert_eq!(m.text_of(0xD800), None); - assert_eq!(m.text_of(0xFFFD).as_deref(), Some("\u{FFFD}")); - } - let simple = font( - &mut Document::new_empty(), - vec![("Subtype", Object::name("TrueType")), ("BaseFont", Object::name("Arial")), ("ToUnicode", Object::name("Identity-H"))], - ); - let m = Metrics::from_dict(&doc, &simple); - assert_eq!(m.text_of(0x41).as_deref(), Some("A"), "the encoding still applies"); - } - - /// Chrome's subsets leave the glyphs of `/DW` width out of `/W` (#1443): "s" (CID 0x56) is - /// still in the font, but nothing is when the font gives no default width to point at. - #[test] - fn composite_subset_glyphs_of_the_default_width_are_present() { - let metrics = |dw: bool| { - let mut doc = Document::new_empty(); - let cmap = b"/CIDInit /ProcSet findresource begin 1 begincodespacerange <0000> endcodespacerange \ - 1 beginbfrange <0055> <0057> <0072> endbfrange end"; - let tu = doc.add(Object::Stream(Stream::from_raw(Dict::new(), cmap.to_vec()))); - let mut desc = Dict::new(); - desc.set(b"Subtype".to_vec(), Object::name("CIDFontType2")); - if dw { - desc.set(b"DW".to_vec(), Object::Int(500)); - } - desc.set( - b"W".to_vec(), - Object::Array(vec![ - Object::Int(0x55), - Object::Array(vec![Object::Int(333)]), - Object::Int(0x57), - Object::Array(vec![Object::Int(277)]), - ]), - ); - let f = font( - &mut doc, - vec![ - ("Subtype", Object::name("Type0")), - ("BaseFont", Object::name("AAAAAA+LiberationSans")), - ("Encoding", Object::name("Identity-H")), - ("DescendantFonts", Object::Array(vec![Object::Dict(desc)])), - ("ToUnicode", Object::Ref(tu)), - ], - ); - Metrics::from_dict(&doc, &f) - }; - let chrome = metrics(true); - assert_eq!(chrome.encode("rst").as_deref(), Some(&[0, 0x55, 0, 0x56, 0, 0x57][..])); - assert_eq!(chrome.width(0x56), 0.5); - assert_eq!(chrome.encode("x"), None, "a character the ToUnicode map doesn't give"); - assert_eq!(metrics(false).encode("s"), None); - } - - /// A `/W` run that starts at the last CID stops there instead of overflowing onto CID 0. - #[test] - fn composite_width_run_past_the_last_cid_is_dropped() { - let widths = |first: i64| { - let mut doc = Document::new_empty(); - let mut desc = Dict::new(); - desc.set(b"Subtype".to_vec(), Object::name("CIDFontType2")); - desc.set(b"DW".to_vec(), Object::Int(1000)); - desc.set(b"W".to_vec(), Object::Array(vec![Object::Int(first), Object::Array(vec![Object::Int(100), Object::Int(200)])])); - let f = font( - &mut doc, - vec![ - ("Subtype", Object::name("Type0")), - ("Encoding", Object::name("Identity-H")), - ("DescendantFonts", Object::Array(vec![Object::Dict(desc)])), - ], - ); - Metrics::from_dict(&doc, &f) - }; - let ordinary = widths(0); - assert_eq!((ordinary.width(0), ordinary.width(1)), (0.1, 0.2)); - let last = widths(i64::from(u32::MAX)); - assert_eq!((last.width(0), last.width(u32::MAX)), (1.0, 0.1)); - } - - #[test] - fn glyph_names() { - assert_eq!(glyph_unicode("quotedblleft"), Some('“')); - assert_eq!(glyph_unicode("uni20AC"), Some('€')); - assert_eq!(glyph_unicode("u1F600"), Some('😀')); - assert_eq!(glyph_unicode("nonsense"), None); - } - - fn to_unicode(cmap: &[u8]) -> HashMap { - let mut u = Unicode::default(); - parse_to_unicode(cmap, &mut u); - let codes: Vec = u.explicit.keys().copied().chain(u.ranges.iter().flat_map(|r| r.lo..=r.hi)).collect(); - codes.into_iter().filter_map(|c| u.text(c).map(|t| (c, t.into_owned()))).collect() - } - - #[test] - fn cmap_tokens_need_no_space_between_them() { - // Hex strings, brackets and operators may touch; comments and literal strings are skipped. - let cmap = b"/CIDSystemInfo << /Registry (Adobe (x) \\) beginbfchar) >> def % beginbfchar <0001> <0058>\n\ - 2 beginbfchar<0003><0020><0010><00 E9>endbfchar \ - 1 beginbfrange<0024><0026><0041>endbfrange 1 beginbfrange<0030><0031>[<0048><0069>]endbfrange"; - let map = to_unicode(cmap); - let mut got: Vec<_> = map.iter().map(|(c, u)| (*c, u.as_str())).collect(); - got.sort_unstable(); - assert_eq!(got, [(3, " "), (0x10, "é"), (0x24, "A"), (0x25, "B"), (0x26, "C"), (0x30, "H"), (0x31, "i")]); - let (spaces, cids) = parse_cmap(b"1 begincodespacerange<00>endcodespacerange 1 begincidrange<20><7E>1 endcidrange"); - assert_eq!(spaces, [(1, vec![0], vec![0xff])]); - assert_eq!(cids, [(0x20, 0x7e, 1)]); - } - - #[test] - fn malformed_cmaps_never_panic() { - // Non-ASCII bytes inside hex strings once split a UTF-8 character and panicked. - for cmap in [ - "1 beginbfchar <0é0> <0041> <0042> endbfchar".as_bytes(), - "1 beginbfrange <0é0> <00é> <0041> endbfrange".as_bytes(), - b"1 beginbfchar <0001> <004", - b"1 beginbfchar <123> <0041> <0002> <0003> endbfchar", - b"1 beginbfrange <0001> [<0041>] endbfrange", - b"1 beginbfrange <0001> <0002> [<0041> (", - b"1 beginbfrange <0001>", - b"\xff\xfe beginbfchar \x80<\xc3\xa9> <0041> endbfchar %", - ] { - to_unicode(cmap); - parse_cmap(cmap); - } - // Code counters at the top of the range stop instead of overflowing. - let map = to_unicode(b"1 beginbfrange [<0041> <0042> <0043>] endbfrange"); - assert_eq!(map.len(), 2); - assert_eq!(map.get(&u32::MAX).map(String::as_str), Some("B")); - // A malformed array entry keeps its place, so the next one maps the next code. - let map = to_unicode(b"1 beginbfrange <0001> <0003> [<0041> <0043>] endbfrange"); - assert_eq!((map.get(&2), map.get(&3).map(String::as_str)), (None, Some("C"))); - let mut m = Metrics::fallback(); - m.cid_map = Spans::new(parse_cmap(b"1 begincidrange <0000> 4294967295 endcidrange").1); - assert_eq!(m.cid(5), u32::MAX); - let mut doc = Document::new_empty(); - let mut enc = Dict::new(); - enc.set(b"Differences".to_vec(), Object::Array(vec![Object::Int(i64::MAX), Object::name("A"), Object::name("B")])); - let f = font(&mut doc, vec![("Subtype", Object::name("Type1")), ("Encoding", Object::Dict(enc))]); - assert_eq!(unicode_map(&doc, &f, false).text(u32::MAX).as_deref(), Some("B")); - } - - #[test] - fn sequential_ranges_past_the_entry_cap_still_map() { - // 17 ranges of 65 536 codes: 1 114 112 codes from 0.4 KB of CMap, more than MAX_CMAP_ENTRIES - // (the first 16 ranges use up the whole cap). Each range maps its codes upward from 'A', so - // the last code of the last range, 0x100019, is 'A' + 0x19 = 'Z'. - let mut cmap = b"17 beginbfrange ".to_vec(); - for k in 0..17u32 { - cmap.extend(format!("<{:08X}> <{:08X}> <0041> ", k << 16, (k << 16) | 0xffff).bytes()); - } - cmap.extend(b"endbfrange"); - let mut doc = Document::new_empty(); - let tu = doc.add(Object::Stream(Stream::from_raw(Dict::new(), cmap))); - let f = font( - &mut doc, - vec![ - ("Subtype", Object::name("Type0")), - ("BaseFont", Object::name("Wide")), - ("Encoding", Object::name("Identity-H")), - ("ToUnicode", Object::Ref(tu)), - ], - ); - let m = Metrics::from_dict(&doc, &f); - assert_eq!(m.text_of(0x0010_0019).as_deref(), Some("Z")); - } - - #[test] - fn a_cmap_adds_at_most_max_entries() { - // 200 ranges of 65 536 codes would be 13 million codes from 4 KB of stream: 200 records. - let mut cmap = b"200 beginbfrange ".to_vec(); - for k in 0..200u32 { - cmap.extend(format!("<{:08X}> <{:08X}> <0041> ", k << 16, (k << 16) | 0xffff).bytes()); - } - cmap.extend(b"endbfrange"); - let mut u = Unicode::default(); - parse_to_unicode(&cmap, &mut u); - assert_eq!((u.ranges.len(), u.explicit.len()), (200, 0)); - // Explicit entries are what the budget counts: 250 of the 300 here are kept. - let mut many = b"300 beginbfchar ".to_vec(); - for k in 0..300u32 { - many.extend(format!("<{k:04X}> <{:04X}> ", 0x4E00 + k).bytes()); - } - many.extend(b"endbfchar"); - let mut u = Unicode::default(); - parse_to_unicode_within(&many, &mut u, 250); - assert_eq!((u.explicit.len(), u.text(249).is_some(), u.text(250).is_some()), (250, true, false)); - let mut many = b"begincidrange ".to_vec(); - for k in 0..MAX_CMAP_RANGES + 10 { - many.extend(format!("<{k:08X}> <{k:08X}> 1 ").bytes()); - } - assert_eq!(parse_cmap(&many).1.len(), MAX_CMAP_RANGES); - } - - #[test] - fn a_range_is_one_record_however_many_codes() { - let mut u = Unicode::default(); - parse_to_unicode(b"1 beginbfrange <0000> <0041> endbfrange", &mut u); - assert_eq!((u.ranges.len(), u.explicit.len()), (1, 0)); - assert_eq!(u.text(0xFFFF).as_deref(), Some("@"), "0x41 + 0xFFFF wraps to U+0040"); - } - - #[test] - fn sequential_ranges_count_against_the_budget() { - // Range k maps codes k and k + 1 and costs two, so a budget of 250 keeps ranges 0 to 124. - let mut cmap = Vec::new(); - for k in 0..300u32 { - cmap.extend(format!("1 beginbfrange <{k:08X}> <{:08X}> <0041> endbfrange ", k + 1).bytes()); - } - let mut u = Unicode::default(); - parse_to_unicode_within(&cmap, &mut u, 250); - assert_eq!((u.ranges.len(), u.text(125).is_some(), u.text(126).is_none()), (125, true, true)); - } - - #[test] - fn explicit_and_range_mappings_override_the_encoding() { - let mut doc = Document::new_empty(); - let cmap = b"1 beginbfchar <41> <0058> endbfchar 1 beginbfrange <42> <43> <0059> endbfrange"; - let tu = doc.add(Object::Stream(Stream::from_raw(Dict::new(), cmap.to_vec()))); - let f = font( - &mut doc, - vec![ - ("Subtype", Object::name("Type1")), - ("BaseFont", Object::name("Custom")), - ("Encoding", Object::name("WinAnsiEncoding")), - ("ToUnicode", Object::Ref(tu)), - ], - ); - let m = Metrics::from_dict(&doc, &f); - let shown: Vec> = (0x41..=0x44).map(|c| m.text_of(c).map(|t| t.into_owned())).collect(); - assert_eq!(shown, [Some("X".to_string()), Some("Y".to_string()), Some("Z".to_string()), Some("D".to_string())]); - } - - /// Deterministic input for the differential test below. - struct Gen(u64); - - impl Gen { - fn below(&mut self, n: u32) -> u32 { - self.0 = self.0.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1_442_695_040_888_963_407); - ((self.0 >> 33) % u64::from(n)) as u32 - } - - /// A destination: a unit from a pool of edge cases, a surrogate pair, or a lone high surrogate. - fn units(&mut self) -> Vec { - const POOL: [u16; 12] = [0x41, 0x42, 0xE9, 0x0, 0xFFFD, 0xFFFE, 0xFFFF, 0x100, 0x3042, 0xD800, 0xDC00, 0xDFFF]; - match self.below(6) { - 0 => vec![0xD835, 0xDC00 + self.below(0x400) as u16], - 1 => vec![0xD800, 0x41], - _ => vec![POOL[self.below(POOL.len() as u32) as usize]], - } - } - } - - fn hex(units: &[u16]) -> String { - units.iter().map(|u| format!("{u:04X}")).collect() - } - - #[test] - fn lookups_match_the_expanded_mappings() { - // Random ToUnicode CMaps; `expected` is what expanding their entries in order gives. - let mut g = Gen(0x2545_f491_4f6c_dd1d); - for _ in 0..300 { - let mut cmap = String::from("1 begincodespacerange <0000> endcodespacerange "); - let mut expected: HashMap = HashMap::new(); - for _ in 0..1 + g.below(12) { - match g.below(3) { - 0 => { - let (code, u) = (g.below(300), g.units()); - cmap += &format!("1 beginbfchar <{code:04X}> <{}> endbfchar ", hex(&u)); - expected.insert(code, String::from_utf16_lossy(&u)); - } - 1 => { - let (lo, width, u) = (g.below(300), g.below(13), g.units()); - cmap += &format!("1 beginbfrange <{lo:04X}> <{:04X}> <{}> endbfrange ", lo + width, hex(&u)); - for k in 0..=width { - let mut v = u.clone(); - if let Some(last) = v.last_mut() { - *last = last.wrapping_add(k as u16); - } - expected.insert(lo + k, String::from_utf16_lossy(&v)); - } - } - _ => { - let (lo, width) = (g.below(300), g.below(5)); - let dests: Vec> = (0..=width).map(|_| g.units()).collect(); - let list: Vec = dests.iter().map(|d| format!("<{}>", hex(d))).collect(); - cmap += &format!("1 beginbfrange <{lo:04X}> <{:04X}> [{}] endbfrange ", lo + width, list.join(" ")); - for (k, d) in dests.iter().enumerate() { - expected.insert(lo + k as u32, String::from_utf16_lossy(d)); - } - } - } - } - let mut doc = Document::new_empty(); - let tu = doc.add(Object::Stream(Stream::from_raw(Dict::new(), cmap.clone().into_bytes()))); - let f = font( - &mut doc, - vec![ - ("Subtype", Object::name("Type0")), - ("BaseFont", Object::name("Gen")), - ("Encoding", Object::name("Identity-H")), - ("ToUnicode", Object::Ref(tu)), - ], - ); - let m = Metrics::from_dict(&doc, &f); - for code in 0..=320u32 { - let got = m.text_of(code).map(|t| t.into_owned()); - assert_eq!(got, expected.get(&code).cloned(), "code {code:#06x} in {cmap}"); - } - let mut reverse: HashMap = HashMap::new(); - // Code 0 is CID 0, `.notdef`, which no character is encoded with. - for (&code, text) in expected.iter().filter(|&(&code, _)| code != 0) { - if let Some(c) = only_char(text) { - keep(&mut reverse, c, code); - } - } - let mut chars: Vec = expected.values().flat_map(|t| t.chars()).chain(['A', '\u{FFFD}', '\u{0}', '\u{1D400}']).collect(); - chars.sort_unstable(); - chars.dedup(); - for c in chars { - let want = reverse.get(&c).map(|code| code.to_be_bytes()[2..].to_vec()); - assert_eq!(m.encode(&c.to_string()), want, "char {c:?} in {cmap}"); - } - } - } - - #[test] - fn fffd_lookups_skip_overridden_surrogates() { - // Each record maps every code to itself, so its surrogate offsets decode to U+FFFD. The last - // record overrides the surrogates with letters, so each lookup walks past them to U+FFFD itself. - let mut cmap = "1 beginbfrange <0000> <0000> endbfrange ".repeat(300); - cmap.push_str("1 beginbfrange <0041> endbfrange"); - let mut doc = Document::new_empty(); - let tu = doc.add(Object::Stream(Stream::from_raw(Dict::new(), cmap.into_bytes()))); - let f = font( - &mut doc, - vec![ - ("Subtype", Object::name("Type0")), - ("BaseFont", Object::name("Gen")), - ("Encoding", Object::name("Identity-H")), - ("ToUnicode", Object::Ref(tu)), - ], - ); - let m = Metrics::from_dict(&doc, &f); - assert_eq!(m.encode("\u{FFFD}"), Some(vec![0xFF, 0xFD])); - } - - #[test] - fn hostile_ranges_stay_bounded() { - // A range wider than 65 536 codes is ignored, a reversed one maps nothing, and a maximal - // one-code range maps just its own code. - let mut u = Unicode::default(); - let cmap = b"1 beginbfrange <00000000> <0041> endbfrange \ - 1 beginbfrange <0005> <0001> <0041> endbfrange \ - 1 beginbfrange endbfrange"; - parse_to_unicode(cmap, &mut u); - assert_eq!((u.ranges.len(), u.text(u32::MAX).as_deref()), (0, Some("\u{FFFF}"))); - } - - #[test] - fn low_codes_resolve_to_the_covering_range() { - let mut u = Unicode::default(); - let cmap = b"1 beginbfrange <0005> <0007> <0041> endbfrange 1 beginbfrange <0064> <0065> <0042> endbfrange \ - 1 beginbfrange <0000FFFE> <00010001> <0043> endbfrange"; - parse_to_unicode(cmap, &mut u); - let covered: Vec = (0..0x70).filter(|&c| u.covering(c).is_some()).collect(); - assert_eq!(covered, [5, 6, 7, 100, 101]); - assert_eq!((u.covering(0xFFFF).is_some(), u.covering(0x1_0001).is_some(), u.covering(0x1_0002).is_none()), (true, true, true)); - } -} +mod tests; #[cfg(test)] mod encoding_tests { diff --git a/crates/fonts/src/pdf/tests.rs b/crates/fonts/src/pdf/tests.rs new file mode 100644 index 0000000000..c2acef191c --- /dev/null +++ b/crates/fonts/src/pdf/tests.rs @@ -0,0 +1,797 @@ +use pdfcraft_cos::{Dict, Document, Object, Stream}; + +use super::*; + +/// Span records read while `f` runs. +fn span_reads(f: impl FnOnce()) -> usize { + SPAN_READS.with(|c| c.set(0)); + f(); + SPAN_READS.with(|c| c.get()) +} + +/// A composite font with `encoding` as its CMap, `w` as its widths and `to_unicode`. +fn composite(doc: &mut Document, encoding: &str, w: Vec, to_unicode: &str) -> Dict { + let mut desc = Dict::new(); + desc.set(b"Subtype".to_vec(), Object::name("CIDFontType2")); + desc.set(b"W".to_vec(), Object::Array(w)); + let enc = Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), encoding.as_bytes())))); + let tu = Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), to_unicode.as_bytes())))); + font( + doc, + vec![("Subtype", Object::name("Type0")), ("Encoding", enc), ("DescendantFonts", Object::Array(vec![Object::Dict(desc)])), ("ToUnicode", tu)], + ) +} + +/// Many `/W` ranges that leave a code out cost a glyph of it a binary search, not a scan of them all. +#[test] +fn widths_of_a_code_no_range_covers_take_few_steps() { + let mut doc = Document::new_empty(); + let w: Vec = (1..=10_000i64).flat_map(|k| [Object::Int(2 * k), Object::Int(2 * k), Object::Int(500)]).collect(); + let f = composite(&mut doc, "", w, ""); + let m = Metrics::from_dict(&doc, &f); + assert_eq!(m.width(4), 0.5); + let reads = span_reads(|| { + for _ in 0..1000 { + assert_eq!(m.width(3), 1.0); + } + }); + assert!(reads <= 1000 * 64, "{reads} span reads"); +} + +/// Where spans overlap, the first-declared one wins, as a scan in declaration order finds it. +#[test] +fn overlapping_spans_resolve_to_the_first_declared() { + let spans = Spans::new(vec![(0, 10, 'a'), (5, 20, 'b'), (3, 3, 'c'), (30, 25, 'd'), (u32::MAX, u32::MAX, 'e')]); + let found = |code| spans.find(code).map(|s| s.2); + let want = [(0, Some('a')), (3, Some('a')), (10, Some('a')), (11, Some('b')), (20, Some('b')), (21, None), (27, None), (u32::MAX, Some('e'))]; + for (code, value) in want { + assert_eq!(found(code), value, "code {code}"); + } +} + +/// Checking the glyph of every explicit ToUnicode entry looks its code up once in the CID map. +#[test] +fn many_unicode_entries_look_up_their_cids_in_few_steps() { + let n = 2000; + let cidchars: String = (0..n).map(|k| format!("<{:04X}> {} ", 0x8000 + k, k + 1)).collect(); + let encoding = format!("1 begincodespacerange <0000> endcodespacerange {n} begincidchar {cidchars} endcidchar"); + let bfchars: String = (0..n).map(|k| format!("<{:04X}> <{:04X}> ", 0x1000 + k, 0x4E00 + k)).collect(); + let to_unicode = format!("{n} beginbfchar {bfchars} endbfchar"); + let mut doc = Document::new_empty(); + let f = composite(&mut doc, &encoding, Vec::new(), &to_unicode); + let reads = span_reads(|| { + Metrics::from_dict(&doc, &f); + }); + assert!(reads <= n * 64, "{reads} span reads"); +} + +/// Every glyph of a sequential range builds its text from the destination, so a long one is cut +/// to the CMap limit once, when it is read. +#[test] +fn unicode_destinations_are_cut_to_the_cmap_limit() { + let long = "0041".repeat(4096); + let cmap = format!("2 beginbfrange <0010> <0020> <{long}> <0030> <0030> [<{long}>] endbfrange 1 beginbfchar <0040> <{long}> endbfchar"); + let mut u = Unicode::default(); + parse_to_unicode(cmap.as_bytes(), &mut u); + for code in [0x10, 0x20, 0x30, 0x40] { + let text = u.text(code).unwrap_or_default(); + assert!(!text.is_empty() && text.len() <= MAX_DESTINATION_UNITS * 3, "code {code:#x}: {} bytes", text.len()); + } +} + +/// A font whose ranges cover a few low codes keeps tables that size, not a fixed 64k-entry one. +#[test] +fn low_code_tables_end_with_the_last_covered_code() { + // One range over codes 0x20..=0x21, then nothing. + let cover = [(0x20u64, Some(0usize)), (0x22, None)]; + let owner = super::owners_below_64k(&cover); + assert_eq!(owner.len(), 0x22); + assert_eq!((owner[0x1F], owner[0x20], owner[0x21]), (u32::MAX, 0, 0)); + assert!(super::owners_below_64k(&[(0x2_0000, Some(0))]).is_empty(), "no code below 65 536"); + let bits = super::bit_set_below_64k([3u32, 130, 0x2_0000].into_iter()); + assert_eq!(bits.len(), 3); + assert_eq!((bits[0], bits[2]), (1 << 3, 1 << 2)); + assert!(super::bit_set_below_64k([0x1_0000u32].into_iter()).is_empty()); +} + +fn font(doc: &mut Document, entries: Vec<(&str, Object)>) -> Dict { + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("Font")); + for (k, v) in entries { + d.set(k.as_bytes().to_vec(), v); + } + let _ = doc; + d +} + +#[test] +fn embedded_editor_fonts_are_validated_without_installing_or_exporting() { + use pdfcraft_cos::Stream; + let mut doc = Document::new_empty(); + let bytes = include_bytes!("../../../../assets/fonts/Inter-Regular.ttf"); + let program = doc.add(Object::Stream(Stream::flate(Dict::new(), bytes))); + let mut descriptor = Dict::new(); + descriptor.set(b"FontFile2".to_vec(), Object::Ref(program)); + let simple = font(&mut doc, vec![("FontDescriptor", Object::Dict(descriptor.clone()))]); + assert_eq!(embedded_sfnt(&doc, &simple).unwrap(), bytes); + let composite = font(&mut doc, vec![("DescendantFonts", Object::Array(vec![Object::Dict(simple.clone())]))]); + assert_eq!(embedded_sfnt(&doc, &composite).unwrap(), bytes); + let broken = doc.add(Object::Stream(Stream::flate(Dict::new(), b"broken font"))); + descriptor.set(b"FontFile2".to_vec(), Object::Ref(broken)); + let broken = font(&mut doc, vec![("FontDescriptor", Object::Dict(descriptor))]); + assert!(embedded_sfnt(&doc, &broken).is_none()); + assert!(embedded_sfnt(&doc, &Dict::new()).is_none()); +} + +/// #2130: with no `/Widths`, an embedded program's advances win over a guess from the name +/// ("Mono" would mean Courier, 600 units for every glyph). +#[test] +fn a_font_without_widths_measures_with_its_embedded_program() { + use pdfcraft_cos::Stream; + use skrifa::MetadataProvider as _; + let mut doc = Document::new_empty(); + let bytes = include_bytes!("../../../../assets/fonts/Inter-Regular.ttf"); + let face = skrifa::FontRef::new(bytes).unwrap(); + let (size, location) = (skrifa::instance::Size::unscaled(), skrifa::instance::LocationRef::default()); + let upem = f64::from(face.metrics(size, location).units_per_em); + let advance = |c: char| f64::from(face.glyph_metrics(size, location).advance_width(face.charmap().map(c).unwrap()).unwrap()) / upem; + let program = doc.add(Object::Stream(Stream::flate(Dict::new(), bytes))); + let mut descriptor = Dict::new(); + descriptor.set(b"FontFile2".to_vec(), Object::Ref(program)); + let f = font( + &mut doc, + vec![ + ("Subtype", Object::name("TrueType")), + ("BaseFont", Object::name("HouseMono")), + ("Encoding", Object::name("WinAnsiEncoding")), + ("FontDescriptor", Object::Dict(descriptor)), + ], + ); + let m = Metrics::from_dict(&doc, &f); + for c in ['i', 'l', 'W', ' '] { + assert!((m.width(u32::from(c)) - advance(c)).abs() < 1e-9, "{c}: {} vs {}", m.width(u32::from(c)), advance(c)); + } + assert!(m.width(u32::from('i')) < 0.4, "not Courier's 0.6"); +} + +#[test] +fn mac_subset_cmap_uses_pdf_unicode_without_changing_source_program() { + use skrifa::{MetadataProvider as _, raw::TableProvider as _}; + let original = include_bytes!("../../../../assets/fonts/Inter-Regular.ttf"); + let face = skrifa::FontRef::new(original).unwrap(); + let expected = face.charmap().map('Ω').unwrap(); + let mut subset = original.to_vec(); + let count = u16::from_be_bytes(subset[4..6].try_into().unwrap()) as usize; + let entry = (0..count).map(|i| 12 + i * 16).find(|i| &subset[*i..*i + 4] == b"cmap").unwrap(); + let mut cmap = vec![0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 12]; + cmap.extend_from_slice(&[0, 6, 0, 12, 0, 0, 0, 65, 0, 1]); + cmap.extend_from_slice(&u16::try_from(expected.to_u32()).unwrap().to_be_bytes()); + let offset = subset.len() as u32; + subset[entry + 8..entry + 12].copy_from_slice(&offset.to_be_bytes()); + subset[entry + 12..entry + 16].copy_from_slice(&(cmap.len() as u32).to_be_bytes()); + subset.extend_from_slice(&cmap); + let face = skrifa::FontRef::new(&subset).unwrap(); + assert!(!face.charmap().has_map(), "egui cannot directly use Mac format 6"); + assert_eq!(face.cmap().unwrap().map_codepoint(65_u32), Some(expected)); + let mut doc = Document::new_empty(); + let program = doc.add(Object::Stream(Stream::flate(Dict::new(), &subset))); + let mut descriptor = Dict::new(); + descriptor.set(b"FontFile2".to_vec(), Object::Ref(program)); + let unicode = doc.add(Object::Stream(Stream::flate(Dict::new(), b"1 beginbfchar <41> <03A9> endbfchar"))); + let source = font(&mut doc, vec![("FontDescriptor", Object::Dict(descriptor)), ("ToUnicode", Object::Ref(unicode))]); + let editor = embedded_sfnt(&doc, &source).unwrap(); + let face = skrifa::FontRef::new(&editor).unwrap(); + assert_eq!(face.charmap().map('Ω'), Some(expected), "Unicode selects the original glyph"); + let Object::Stream(source) = doc.resolve(&Object::Ref(program)).as_ref().clone() else { + panic!("source stream"); + }; + assert_eq!(source.decoded().unwrap(), subset, "only an in-memory editor copy is adapted"); + let mut broken = vec![0; 16]; + assert!(append_editor_cmap(&mut broken, &std::collections::BTreeMap::from([(65, 1)])).is_none()); +} + +fn metrics_with_flags(flags: Option) -> Metrics { + let mut doc = Document::new_empty(); + let mut descriptor = Dict::new(); + if let Some(flags) = flags { + descriptor.set(b"Flags".to_vec(), flags); + } + let font = font(&mut doc, vec![("Subtype", Object::name("TrueType")), ("FontDescriptor", Object::Dict(descriptor))]); + Metrics::from_dict(&doc, &font) +} + +#[test] +fn font_descriptor_flags_stay_within_the_u32_domain() { + let valid = metrics_with_flags(Some(Object::Int(64 | 262_144))); + assert!(valid.italic && valid.bold); + + for flags in [Some(Object::Int(-1)), Some(Object::Int(4_294_967_296)), Some(Object::Bool(true)), None] { + let metrics = metrics_with_flags(flags); + assert!(!metrics.italic && !metrics.bold); + } +} + +#[test] +fn type3_descriptor_names_the_font_without_changing_its_metrics() { + let mut doc = Document::new_empty(); + let mut f = font( + &mut doc, + vec![ + ("Subtype", Object::name("Type3")), + ( + "FontMatrix", + Object::Array(vec![Object::Real(0.001), Object::Int(0), Object::Int(0), Object::Real(0.001), Object::Int(0), Object::Int(0)]), + ), + ("FontBBox", Object::Array(vec![Object::Int(0), Object::Int(-300), Object::Int(1000), Object::Int(1000)])), + ], + ); + let before = Metrics::from_dict(&doc, &f); + let mut descriptor = Dict::new(); + descriptor.set(b"Type".to_vec(), Object::name("FontDescriptor")); + descriptor.set(b"FontName".to_vec(), Object::name("ExampleMincho-Regular")); + descriptor.set(b"Flags".to_vec(), Object::Int(6)); + descriptor.set(b"ItalicAngle".to_vec(), Object::Int(0)); + let reference = doc.add(Object::Dict(descriptor)); + f.set(b"FontDescriptor".to_vec(), Object::Ref(reference)); + let after = Metrics::from_dict(&doc, &f); + assert_eq!(after.base_font, "ExampleMincho-Regular"); + assert_eq!((after.ascent, after.descent, after.scale), (before.ascent, before.descent, before.scale)); + assert!(!after.bold && !after.italic && !after.subset); + f.set(b"BaseFont".to_vec(), Object::name("ExplicitName")); + assert_eq!(Metrics::from_dict(&doc, &f).base_font, "ExplicitName", "an explicit BaseFont takes precedence"); +} + +#[test] +fn simple_fonts_decode_and_encode_through_their_encoding() { + let mut doc = Document::new_empty(); + let f = font( + &mut doc, + vec![("Subtype", Object::name("Type1")), ("BaseFont", Object::name("Helvetica")), ("Encoding", Object::name("WinAnsiEncoding"))], + ); + let m = Metrics::from_dict(&doc, &f); + assert_eq!(m.decode(b"Caf\xe9 \x80 \x93x\x94"), "Café € “x”"); + assert_eq!(m.encode("Café €").as_deref(), Some(&b"Caf\xe9 \x80"[..])); + assert_eq!(m.encode("Ω"), None, "not in WinAnsi"); + // Differences rename codes by glyph name. + let mut enc = Dict::new(); + enc.set(b"Differences".to_vec(), Object::Array(vec![Object::Int(65), Object::name("eacute"), Object::name("uni03A9")])); + let f = font(&mut doc, vec![("Subtype", Object::name("Type1")), ("BaseFont", Object::name("Custom")), ("Encoding", Object::Dict(enc))]); + let m = Metrics::from_dict(&doc, &f); + assert_eq!(m.decode(b"AB"), "éΩ"); + assert_eq!(m.encode("Ω").as_deref(), Some(&b"B"[..])); +} + +/// A standard-14 font with no `/Widths` is measured by the glyph each code draws, not by the +/// glyph WinAnsi gives that code (#2894); `/Widths`, when present, still go by code. +#[test] +fn standard_fonts_without_widths_measure_the_glyph_their_differences_name() { + let mut doc = Document::new_empty(); + let mut enc = Dict::new(); + enc.set(b"BaseEncoding".to_vec(), Object::name("WinAnsiEncoding")); + let diffs = vec![Object::Int(65), Object::name("W"), Object::Int(87), Object::name("A"), Object::name("uni03A9"), Object::name("not.a.glyph")]; + enc.set(b"Differences".to_vec(), Object::Array(diffs)); + let f = + font(&mut doc, vec![("Subtype", Object::name("Type1")), ("BaseFont", Object::name("Helvetica")), ("Encoding", Object::Dict(enc.clone()))]); + let m = Metrics::from_dict(&doc, &f); + assert_eq!((m.width(65), m.width(87)), (0.944, 0.667), "A and W swap their widths"); + // Omega has no WinAnsi code and the last name reads as nothing: both keep their code's width. + assert_eq!((m.width(88), m.width(89)), (0.667, 0.667)); + let f = font( + &mut doc, + vec![ + ("Subtype", Object::name("Type1")), + ("BaseFont", Object::name("Helvetica")), + ("FirstChar", Object::Int(87)), + ("Widths", Object::Array(vec![Object::Int(500)])), + ("Encoding", Object::Dict(enc)), + ], + ); + assert_eq!(Metrics::from_dict(&doc, &f).width(87), 0.5, "/Widths go by code"); +} + +#[test] +fn subset_fonts_only_encode_the_glyphs_they_have() { + let mut doc = Document::new_empty(); + // Widths for a, b only (97, 98); c (99) is 0. + let widths = Object::Array(vec![Object::Int(500), Object::Int(520), Object::Int(0)]); + let f = font( + &mut doc, + vec![ + ("Subtype", Object::name("TrueType")), + ("BaseFont", Object::name("ABCDEF+Arial")), + ("FirstChar", Object::Int(97)), + ("Widths", widths), + ("Encoding", Object::name("WinAnsiEncoding")), + ], + ); + let m = Metrics::from_dict(&doc, &f); + assert!(m.subset); + assert_eq!(m.encode("ab").as_deref(), Some(&b"ab"[..])); + assert_eq!(m.encode("abc"), None); +} + +#[test] +fn cmap_streams_decode_within_a_size_cap() { + // A valid CMap padded past the cap (a small Flate stream of whitespace) is not decoded whole. + let to_unicode = b"1 begincodespacerange <0000> endcodespacerange 1 beginbfrange <0024> <0026> <0041> endbfrange"; + let encoding = b"1 begincodespacerange <0000> endcodespacerange 1 begincidrange <0000> 0 endcidrange"; + for padded in [false, true] { + let mut doc = Document::new_empty(); + let mut stream = |cmap: &[u8]| { + let mut data = cmap.to_vec(); + if padded { + data.resize(MAX_CMAP_BYTES + 1, b' '); + } + Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), &data)))) + }; + let (tu, enc) = (stream(to_unicode), stream(encoding)); + let mut desc = Dict::new(); + desc.set(b"Subtype".to_vec(), Object::name("CIDFontType2")); + let f = font( + &mut doc, + vec![ + ("Subtype", Object::name("Type0")), + ("Encoding", enc), + ("DescendantFonts", Object::Array(vec![Object::Dict(desc)])), + ("ToUnicode", tu), + ], + ); + let m = Metrics::from_dict(&doc, &f); + assert_eq!(m.decode(&[0, 0x24]) == "A", !padded, "ToUnicode, padded: {padded}"); + assert_eq!(matches!(m.codes, Codes::Ranges(_)), !padded, "Encoding, padded: {padded}"); + } +} + +#[test] +fn composite_fonts_use_to_unicode() { + let mut doc = Document::new_empty(); + let cmap = b"/CIDInit /ProcSet findresource begin 1 begincodespacerange <0000> endcodespacerange \ + 2 beginbfchar <0003> <0020> <0010> <00E9> endbfchar 1 beginbfrange <0024> <0026> <0041> endbfrange \ + 1 beginbfrange <0030> <0031> [<0048> <0069>] endbfrange end"; + let tu = doc.add(Object::Stream(Stream::from_raw(Dict::new(), cmap.to_vec()))); + let mut desc = Dict::new(); + desc.set(b"Subtype".to_vec(), Object::name("CIDFontType2")); + desc.set( + b"W".to_vec(), + Object::Array(vec![ + Object::Int(3), + Object::Array(vec![Object::Int(250)]), + Object::Int(16), + Object::Int(16), + Object::Int(500), + Object::Int(36), + Object::Int(49), + Object::Int(600), + ]), + ); + let f = font( + &mut doc, + vec![ + ("Subtype", Object::name("Type0")), + ("BaseFont", Object::name("QWERTY+Noto")), + ("Encoding", Object::name("Identity-H")), + ("DescendantFonts", Object::Array(vec![Object::Dict(desc)])), + ("ToUnicode", Object::Ref(tu)), + ], + ); + let m = Metrics::from_dict(&doc, &f); + assert_eq!(m.decode(&[0, 0x24, 0, 0x25, 0, 3, 0, 0x30, 0, 0x31, 0, 0x10]), "AB Hié"); + assert_eq!(m.encode("Hi A").as_deref(), Some(&[0, 0x30, 0, 0x31, 0, 3, 0, 0x24][..])); + assert_eq!(m.encode("Z"), None); +} + +/// Word's Type 0 subsets map CID 0 as well as the real space glyph to U+0020 and give CID 0 a +/// width; CID 0 is `.notdef`, so a typed space takes the real one (#3223). +#[test] +fn composite_fonts_never_encode_with_cid_0() { + let mut doc = Document::new_empty(); + let cmap = b"/CIDInit /ProcSet findresource begin 1 begincodespacerange <0000> endcodespacerange \ + 1 beginbfrange <0000> <0002> [<0020> <004D> <00E1>] endbfrange \ + 1 beginbfrange <0005> <0007> [<0065> <0072> <0020>] endbfrange end"; + let tu = doc.add(Object::Stream(Stream::from_raw(Dict::new(), cmap.to_vec()))); + let mut desc = Dict::new(); + desc.set(b"Subtype".to_vec(), Object::name("CIDFontType2")); + let widths = [889, 444, 389, 278, 444, 333, 250].map(Object::Int).to_vec(); + desc.set(b"W".to_vec(), Object::Array(vec![Object::Int(0), Object::Array(vec![Object::Int(778)]), Object::Int(1), Object::Array(widths)])); + let f = font( + &mut doc, + vec![ + ("Subtype", Object::name("Type0")), + ("BaseFont", Object::name("BCDEEE+TimesNewRomanPSMT")), + ("Encoding", Object::name("Identity-H")), + ("DescendantFonts", Object::Array(vec![Object::Dict(desc)])), + ("ToUnicode", Object::Ref(tu)), + ], + ); + let m = Metrics::from_dict(&doc, &f); + assert_eq!(m.encode("Me er").as_deref(), Some(&[0, 1, 0, 5, 0, 7, 0, 5, 0, 6][..])); + assert_eq!(m.decode(&[0, 1, 0, 7, 0, 2]), "M á"); +} + +/// WSCAD writes `/ToUnicode /Identity-H` (a name, not a CMap stream) on composite fonts whose +/// codes are UTF-16 code units: the codes read as the same Unicode, as in Acrobat. Controls +/// and lone surrogates map to nothing, and a simple font's name is not a ToUnicode map. +#[test] +fn identity_to_unicode_names_read_codes_as_unicode() { + let doc = Document::new_empty(); + for name in ["Identity-H", "Identity-V"] { + let f = font( + &mut Document::new_empty(), + vec![ + ("Subtype", Object::name("Type0")), + ("BaseFont", Object::name("RobotoMono-Regular")), + ("Encoding", Object::name("Identity-H")), + ("DescendantFonts", Object::Array(vec![Object::Dict(Dict::new())])), + ("ToUnicode", Object::name(name)), + ], + ); + let m = Metrics::from_dict(&doc, &f); + assert_eq!(m.decode(&[0, b'Z', 0, 0xFC, 0, b'r', 0x20, 0xAC, 0x4E, 0x2D]), "Zür€中", "{name}"); + assert_eq!(m.text_of(0x0001), None); + assert_eq!(m.text_of(0x007F), None, "DEL is a control"); + assert_eq!(m.text_of(0x0085), None, "a C1 control"); + assert_eq!(m.text_of(0x00A0).as_deref(), Some("\u{A0}"), "the range resumes after C1"); + assert_eq!(m.text_of(0x007E).as_deref(), Some("~")); + assert_eq!(m.text_of(0xD800), None); + assert_eq!(m.text_of(0xFFFD).as_deref(), Some("\u{FFFD}")); + } + let simple = font( + &mut Document::new_empty(), + vec![("Subtype", Object::name("TrueType")), ("BaseFont", Object::name("Arial")), ("ToUnicode", Object::name("Identity-H"))], + ); + let m = Metrics::from_dict(&doc, &simple); + assert_eq!(m.text_of(0x41).as_deref(), Some("A"), "the encoding still applies"); +} + +/// Chrome's subsets leave the glyphs of `/DW` width out of `/W` (#1443): "s" (CID 0x56) is +/// still in the font, but nothing is when the font gives no default width to point at. +#[test] +fn composite_subset_glyphs_of_the_default_width_are_present() { + let metrics = |dw: bool| { + let mut doc = Document::new_empty(); + let cmap = b"/CIDInit /ProcSet findresource begin 1 begincodespacerange <0000> endcodespacerange \ + 1 beginbfrange <0055> <0057> <0072> endbfrange end"; + let tu = doc.add(Object::Stream(Stream::from_raw(Dict::new(), cmap.to_vec()))); + let mut desc = Dict::new(); + desc.set(b"Subtype".to_vec(), Object::name("CIDFontType2")); + if dw { + desc.set(b"DW".to_vec(), Object::Int(500)); + } + desc.set( + b"W".to_vec(), + Object::Array(vec![Object::Int(0x55), Object::Array(vec![Object::Int(333)]), Object::Int(0x57), Object::Array(vec![Object::Int(277)])]), + ); + let f = font( + &mut doc, + vec![ + ("Subtype", Object::name("Type0")), + ("BaseFont", Object::name("AAAAAA+LiberationSans")), + ("Encoding", Object::name("Identity-H")), + ("DescendantFonts", Object::Array(vec![Object::Dict(desc)])), + ("ToUnicode", Object::Ref(tu)), + ], + ); + Metrics::from_dict(&doc, &f) + }; + let chrome = metrics(true); + assert_eq!(chrome.encode("rst").as_deref(), Some(&[0, 0x55, 0, 0x56, 0, 0x57][..])); + assert_eq!(chrome.width(0x56), 0.5); + assert_eq!(chrome.encode("x"), None, "a character the ToUnicode map doesn't give"); + assert_eq!(metrics(false).encode("s"), None); +} + +/// A `/W` run that starts at the last CID stops there instead of overflowing onto CID 0. +#[test] +fn composite_width_run_past_the_last_cid_is_dropped() { + let widths = |first: i64| { + let mut doc = Document::new_empty(); + let mut desc = Dict::new(); + desc.set(b"Subtype".to_vec(), Object::name("CIDFontType2")); + desc.set(b"DW".to_vec(), Object::Int(1000)); + desc.set(b"W".to_vec(), Object::Array(vec![Object::Int(first), Object::Array(vec![Object::Int(100), Object::Int(200)])])); + let f = font( + &mut doc, + vec![ + ("Subtype", Object::name("Type0")), + ("Encoding", Object::name("Identity-H")), + ("DescendantFonts", Object::Array(vec![Object::Dict(desc)])), + ], + ); + Metrics::from_dict(&doc, &f) + }; + let ordinary = widths(0); + assert_eq!((ordinary.width(0), ordinary.width(1)), (0.1, 0.2)); + let last = widths(i64::from(u32::MAX)); + assert_eq!((last.width(0), last.width(u32::MAX)), (1.0, 0.1)); +} + +#[test] +fn glyph_names() { + assert_eq!(glyph_unicode("quotedblleft"), Some('“')); + assert_eq!(glyph_unicode("uni20AC"), Some('€')); + assert_eq!(glyph_unicode("u1F600"), Some('😀')); + assert_eq!(glyph_unicode("nonsense"), None); +} + +fn to_unicode(cmap: &[u8]) -> HashMap { + let mut u = Unicode::default(); + parse_to_unicode(cmap, &mut u); + let codes: Vec = u.explicit.keys().copied().chain(u.ranges.iter().flat_map(|r| r.lo..=r.hi)).collect(); + codes.into_iter().filter_map(|c| u.text(c).map(|t| (c, t.into_owned()))).collect() +} + +#[test] +fn cmap_tokens_need_no_space_between_them() { + // Hex strings, brackets and operators may touch; comments and literal strings are skipped. + let cmap = b"/CIDSystemInfo << /Registry (Adobe (x) \\) beginbfchar) >> def % beginbfchar <0001> <0058>\n\ + 2 beginbfchar<0003><0020><0010><00 E9>endbfchar \ + 1 beginbfrange<0024><0026><0041>endbfrange 1 beginbfrange<0030><0031>[<0048><0069>]endbfrange"; + let map = to_unicode(cmap); + let mut got: Vec<_> = map.iter().map(|(c, u)| (*c, u.as_str())).collect(); + got.sort_unstable(); + assert_eq!(got, [(3, " "), (0x10, "é"), (0x24, "A"), (0x25, "B"), (0x26, "C"), (0x30, "H"), (0x31, "i")]); + let (spaces, cids) = parse_cmap(b"1 begincodespacerange<00>endcodespacerange 1 begincidrange<20><7E>1 endcidrange"); + assert_eq!(spaces, [(1, vec![0], vec![0xff])]); + assert_eq!(cids, [(0x20, 0x7e, 1)]); +} + +#[test] +fn malformed_cmaps_never_panic() { + // Non-ASCII bytes inside hex strings once split a UTF-8 character and panicked. + for cmap in [ + "1 beginbfchar <0é0> <0041> <0042> endbfchar".as_bytes(), + "1 beginbfrange <0é0> <00é> <0041> endbfrange".as_bytes(), + b"1 beginbfchar <0001> <004", + b"1 beginbfchar <123> <0041> <0002> <0003> endbfchar", + b"1 beginbfrange <0001> [<0041>] endbfrange", + b"1 beginbfrange <0001> <0002> [<0041> (", + b"1 beginbfrange <0001>", + b"\xff\xfe beginbfchar \x80<\xc3\xa9> <0041> endbfchar %", + ] { + to_unicode(cmap); + parse_cmap(cmap); + } + // Code counters at the top of the range stop instead of overflowing. + let map = to_unicode(b"1 beginbfrange [<0041> <0042> <0043>] endbfrange"); + assert_eq!(map.len(), 2); + assert_eq!(map.get(&u32::MAX).map(String::as_str), Some("B")); + // A malformed array entry keeps its place, so the next one maps the next code. + let map = to_unicode(b"1 beginbfrange <0001> <0003> [<0041> <0043>] endbfrange"); + assert_eq!((map.get(&2), map.get(&3).map(String::as_str)), (None, Some("C"))); + let mut m = Metrics::fallback(); + m.cid_map = Spans::new(parse_cmap(b"1 begincidrange <0000> 4294967295 endcidrange").1); + assert_eq!(m.cid(5), u32::MAX); + let mut doc = Document::new_empty(); + let mut enc = Dict::new(); + enc.set(b"Differences".to_vec(), Object::Array(vec![Object::Int(i64::MAX), Object::name("A"), Object::name("B")])); + let f = font(&mut doc, vec![("Subtype", Object::name("Type1")), ("Encoding", Object::Dict(enc))]); + assert_eq!(unicode_map(&doc, &f, false).text(u32::MAX).as_deref(), Some("B")); +} + +#[test] +fn sequential_ranges_past_the_entry_cap_still_map() { + // 17 ranges of 65 536 codes: 1 114 112 codes from 0.4 KB of CMap, more than MAX_CMAP_ENTRIES + // (the first 16 ranges use up the whole cap). Each range maps its codes upward from 'A', so + // the last code of the last range, 0x100019, is 'A' + 0x19 = 'Z'. + let mut cmap = b"17 beginbfrange ".to_vec(); + for k in 0..17u32 { + cmap.extend(format!("<{:08X}> <{:08X}> <0041> ", k << 16, (k << 16) | 0xffff).bytes()); + } + cmap.extend(b"endbfrange"); + let mut doc = Document::new_empty(); + let tu = doc.add(Object::Stream(Stream::from_raw(Dict::new(), cmap))); + let f = font( + &mut doc, + vec![ + ("Subtype", Object::name("Type0")), + ("BaseFont", Object::name("Wide")), + ("Encoding", Object::name("Identity-H")), + ("ToUnicode", Object::Ref(tu)), + ], + ); + let m = Metrics::from_dict(&doc, &f); + assert_eq!(m.text_of(0x0010_0019).as_deref(), Some("Z")); +} + +#[test] +fn a_cmap_adds_at_most_max_entries() { + // 200 ranges of 65 536 codes would be 13 million codes from 4 KB of stream: 200 records. + let mut cmap = b"200 beginbfrange ".to_vec(); + for k in 0..200u32 { + cmap.extend(format!("<{:08X}> <{:08X}> <0041> ", k << 16, (k << 16) | 0xffff).bytes()); + } + cmap.extend(b"endbfrange"); + let mut u = Unicode::default(); + parse_to_unicode(&cmap, &mut u); + assert_eq!((u.ranges.len(), u.explicit.len()), (200, 0)); + // Explicit entries are what the budget counts: 250 of the 300 here are kept. + let mut many = b"300 beginbfchar ".to_vec(); + for k in 0..300u32 { + many.extend(format!("<{k:04X}> <{:04X}> ", 0x4E00 + k).bytes()); + } + many.extend(b"endbfchar"); + let mut u = Unicode::default(); + parse_to_unicode_within(&many, &mut u, 250); + assert_eq!((u.explicit.len(), u.text(249).is_some(), u.text(250).is_some()), (250, true, false)); + let mut many = b"begincidrange ".to_vec(); + for k in 0..MAX_CMAP_RANGES + 10 { + many.extend(format!("<{k:08X}> <{k:08X}> 1 ").bytes()); + } + assert_eq!(parse_cmap(&many).1.len(), MAX_CMAP_RANGES); +} + +#[test] +fn a_range_is_one_record_however_many_codes() { + let mut u = Unicode::default(); + parse_to_unicode(b"1 beginbfrange <0000> <0041> endbfrange", &mut u); + assert_eq!((u.ranges.len(), u.explicit.len()), (1, 0)); + assert_eq!(u.text(0xFFFF).as_deref(), Some("@"), "0x41 + 0xFFFF wraps to U+0040"); +} + +#[test] +fn sequential_ranges_count_against_the_budget() { + // Range k maps codes k and k + 1 and costs two, so a budget of 250 keeps ranges 0 to 124. + let mut cmap = Vec::new(); + for k in 0..300u32 { + cmap.extend(format!("1 beginbfrange <{k:08X}> <{:08X}> <0041> endbfrange ", k + 1).bytes()); + } + let mut u = Unicode::default(); + parse_to_unicode_within(&cmap, &mut u, 250); + assert_eq!((u.ranges.len(), u.text(125).is_some(), u.text(126).is_none()), (125, true, true)); +} + +#[test] +fn explicit_and_range_mappings_override_the_encoding() { + let mut doc = Document::new_empty(); + let cmap = b"1 beginbfchar <41> <0058> endbfchar 1 beginbfrange <42> <43> <0059> endbfrange"; + let tu = doc.add(Object::Stream(Stream::from_raw(Dict::new(), cmap.to_vec()))); + let f = font( + &mut doc, + vec![ + ("Subtype", Object::name("Type1")), + ("BaseFont", Object::name("Custom")), + ("Encoding", Object::name("WinAnsiEncoding")), + ("ToUnicode", Object::Ref(tu)), + ], + ); + let m = Metrics::from_dict(&doc, &f); + let shown: Vec> = (0x41..=0x44).map(|c| m.text_of(c).map(|t| t.into_owned())).collect(); + assert_eq!(shown, [Some("X".to_string()), Some("Y".to_string()), Some("Z".to_string()), Some("D".to_string())]); +} + +/// Deterministic input for the differential test below. +struct Gen(u64); + +impl Gen { + fn below(&mut self, n: u32) -> u32 { + self.0 = self.0.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1_442_695_040_888_963_407); + ((self.0 >> 33) % u64::from(n)) as u32 + } + + /// A destination: a unit from a pool of edge cases, a surrogate pair, or a lone high surrogate. + fn units(&mut self) -> Vec { + const POOL: [u16; 12] = [0x41, 0x42, 0xE9, 0x0, 0xFFFD, 0xFFFE, 0xFFFF, 0x100, 0x3042, 0xD800, 0xDC00, 0xDFFF]; + match self.below(6) { + 0 => vec![0xD835, 0xDC00 + self.below(0x400) as u16], + 1 => vec![0xD800, 0x41], + _ => vec![POOL[self.below(POOL.len() as u32) as usize]], + } + } +} + +fn hex(units: &[u16]) -> String { + units.iter().map(|u| format!("{u:04X}")).collect() +} + +#[test] +fn lookups_match_the_expanded_mappings() { + // Random ToUnicode CMaps; `expected` is what expanding their entries in order gives. + let mut g = Gen(0x2545_f491_4f6c_dd1d); + for _ in 0..300 { + let mut cmap = String::from("1 begincodespacerange <0000> endcodespacerange "); + let mut expected: HashMap = HashMap::new(); + for _ in 0..1 + g.below(12) { + match g.below(3) { + 0 => { + let (code, u) = (g.below(300), g.units()); + cmap += &format!("1 beginbfchar <{code:04X}> <{}> endbfchar ", hex(&u)); + expected.insert(code, String::from_utf16_lossy(&u)); + } + 1 => { + let (lo, width, u) = (g.below(300), g.below(13), g.units()); + cmap += &format!("1 beginbfrange <{lo:04X}> <{:04X}> <{}> endbfrange ", lo + width, hex(&u)); + for k in 0..=width { + let mut v = u.clone(); + if let Some(last) = v.last_mut() { + *last = last.wrapping_add(k as u16); + } + expected.insert(lo + k, String::from_utf16_lossy(&v)); + } + } + _ => { + let (lo, width) = (g.below(300), g.below(5)); + let dests: Vec> = (0..=width).map(|_| g.units()).collect(); + let list: Vec = dests.iter().map(|d| format!("<{}>", hex(d))).collect(); + cmap += &format!("1 beginbfrange <{lo:04X}> <{:04X}> [{}] endbfrange ", lo + width, list.join(" ")); + for (k, d) in dests.iter().enumerate() { + expected.insert(lo + k as u32, String::from_utf16_lossy(d)); + } + } + } + } + let mut doc = Document::new_empty(); + let tu = doc.add(Object::Stream(Stream::from_raw(Dict::new(), cmap.clone().into_bytes()))); + let f = font( + &mut doc, + vec![ + ("Subtype", Object::name("Type0")), + ("BaseFont", Object::name("Gen")), + ("Encoding", Object::name("Identity-H")), + ("ToUnicode", Object::Ref(tu)), + ], + ); + let m = Metrics::from_dict(&doc, &f); + for code in 0..=320u32 { + let got = m.text_of(code).map(|t| t.into_owned()); + assert_eq!(got, expected.get(&code).cloned(), "code {code:#06x} in {cmap}"); + } + let mut reverse: HashMap = HashMap::new(); + // Code 0 is CID 0, `.notdef`, which no character is encoded with. + for (&code, text) in expected.iter().filter(|&(&code, _)| code != 0) { + if let Some(c) = only_char(text) { + keep(&mut reverse, c, code); + } + } + let mut chars: Vec = expected.values().flat_map(|t| t.chars()).chain(['A', '\u{FFFD}', '\u{0}', '\u{1D400}']).collect(); + chars.sort_unstable(); + chars.dedup(); + for c in chars { + let want = reverse.get(&c).map(|code| code.to_be_bytes()[2..].to_vec()); + assert_eq!(m.encode(&c.to_string()), want, "char {c:?} in {cmap}"); + } + } +} + +#[test] +fn fffd_lookups_skip_overridden_surrogates() { + // Each record maps every code to itself, so its surrogate offsets decode to U+FFFD. The last + // record overrides the surrogates with letters, so each lookup walks past them to U+FFFD itself. + let mut cmap = "1 beginbfrange <0000> <0000> endbfrange ".repeat(300); + cmap.push_str("1 beginbfrange <0041> endbfrange"); + let mut doc = Document::new_empty(); + let tu = doc.add(Object::Stream(Stream::from_raw(Dict::new(), cmap.into_bytes()))); + let f = font( + &mut doc, + vec![ + ("Subtype", Object::name("Type0")), + ("BaseFont", Object::name("Gen")), + ("Encoding", Object::name("Identity-H")), + ("ToUnicode", Object::Ref(tu)), + ], + ); + let m = Metrics::from_dict(&doc, &f); + assert_eq!(m.encode("\u{FFFD}"), Some(vec![0xFF, 0xFD])); +} + +#[test] +fn hostile_ranges_stay_bounded() { + // A range wider than 65 536 codes is ignored, a reversed one maps nothing, and a maximal + // one-code range maps just its own code. + let mut u = Unicode::default(); + let cmap = b"1 beginbfrange <00000000> <0041> endbfrange \ + 1 beginbfrange <0005> <0001> <0041> endbfrange \ + 1 beginbfrange endbfrange"; + parse_to_unicode(cmap, &mut u); + assert_eq!((u.ranges.len(), u.text(u32::MAX).as_deref()), (0, Some("\u{FFFF}"))); +} + +#[test] +fn low_codes_resolve_to_the_covering_range() { + let mut u = Unicode::default(); + let cmap = b"1 beginbfrange <0005> <0007> <0041> endbfrange 1 beginbfrange <0064> <0065> <0042> endbfrange \ + 1 beginbfrange <0000FFFE> <00010001> <0043> endbfrange"; + parse_to_unicode(cmap, &mut u); + let covered: Vec = (0..0x70).filter(|&c| u.covering(c).is_some()).collect(); + assert_eq!(covered, [5, 6, 7, 100, 101]); + assert_eq!((u.covering(0xFFFF).is_some(), u.covering(0x1_0001).is_some(), u.covering(0x1_0002).is_none()), (true, true, true)); +} diff --git a/crates/forms/src/actions.rs b/crates/forms/src/actions.rs index 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 3119481cff..ee4aa18e3c 100644 --- a/crates/forms/src/af.rs +++ b/crates/forms/src/af.rs @@ -13,6 +13,17 @@ use std::collections::VecDeque; use std::fmt::Write as _; use std::ops::Range; +mod calc; +mod format; +mod script; + +#[cfg(test)] +use calc::{Notation, eval_notation, notation_name, notation_tokens, unique}; +pub use calc::{calculate, calculation_reads, notation_js, rename_in_calculation}; +pub use format::{DATE_PRESETS, DateTime, TIME_PRESETS, format_date, format_value, js_number, keystroke, make_number, parse_date, validate}; +use script::{Arg, call}; +pub use script::{calculate_js, format_js, parse_calculate, parse_format, parse_validate, validate_js}; + /// The Format tab. #[derive(Clone, Debug, PartialEq, Default)] pub enum Format { @@ -112,1699 +123,8 @@ impl Actions { } } -// ── parsing scripts ───────────────────────────────────────────────────────────────────────── - -#[derive(Clone, Debug, PartialEq)] -enum Arg { - Num(f64), - Str(String), - Bool(bool), - List(Vec), -} - -impl Arg { - fn num(&self) -> Option { - match self { - Arg::Num(n) => Some(*n), - Arg::Bool(b) => Some(f64::from(u8::from(*b))), - Arg::Str(s) => s.trim().parse().ok(), - Arg::List(_) => None, - } - } - - fn string(&self) -> Option { - match self { - Arg::Str(s) => Some(s.clone()), - Arg::Num(n) => Some(n.to_string()), - _ => None, - } - } - - fn truthy(&self) -> bool { - match self { - Arg::Bool(b) => *b, - Arg::Num(n) => *n != 0.0, - Arg::Str(s) => !s.is_empty(), - Arg::List(l) => !l.is_empty(), - } - } -} - -/// How deeply script arguments and field notation may nest; deeper input is rejected rather -/// than recursed into (a hostile file could otherwise overflow the stack). -const MAX_NESTING: usize = 64; - -fn hex4(bytes: &[u8]) -> Option { - if bytes.len() != 4 { - return None; - } - bytes.iter().try_fold(0u16, |value, byte| { - let digit = match byte { - b'0'..=b'9' => byte - b'0', - b'a'..=b'f' => byte - b'a' + 10, - b'A'..=b'F' => byte - b'A' + 10, - _ => return None, - }; - value.checked_mul(16)?.checked_add(u16::from(digit)) - }) -} - -struct Parser<'a> { - s: &'a [u8], - i: usize, - depth: usize, -} - -impl Parser<'_> { - fn ws(&mut self) { - while self.i < self.s.len() && (self.s[self.i].is_ascii_whitespace()) { - self.i += 1; - } - } - - fn string(&mut self) -> Option { - let q = self.s[self.i]; - self.i += 1; - let mut out = Vec::new(); - while self.i < self.s.len() { - let c = self.s[self.i]; - self.i += 1; - match c { - b'\\' if self.i < self.s.len() => { - let e = self.s[self.i]; - self.i += 1; - match e { - b'n' => out.push(b'\n'), - b'r' => out.push(b'\r'), - b't' => out.push(b'\t'), - b'b' => out.push(0x08), - b'f' => out.push(0x0C), - b'v' => out.push(0x0B), - b'0' => out.push(0), - b'x' => { - let digits = self.s.get(self.i..self.i.checked_add(2)?)?; - let code = hex4(&[b'0', b'0', digits[0], digits[1]])?; - self.i += 2; - let ch = char::from_u32(u32::from(code)).unwrap_or('\u{FFFD}'); - let mut encoded = [0u8; 4]; - out.extend_from_slice(ch.encode_utf8(&mut encoded).as_bytes()); - } - b'u' => { - let code = hex4(self.s.get(self.i..self.i.checked_add(4)?)?)?; - self.i += 4; - let code = if (0xD800..=0xDBFF).contains(&code) && self.s.get(self.i..self.i.checked_add(6)?)?.starts_with(b"\\u") { - let low = hex4(self.s.get(self.i.checked_add(2)?..self.i.checked_add(6)?)?)?; - if (0xDC00..=0xDFFF).contains(&low) { - self.i += 6; - 0x10000 + ((u32::from(code) - 0xD800) << 10) + (u32::from(low) - 0xDC00) - } else { - u32::from(code) - } - } else { - u32::from(code) - }; - let ch = char::from_u32(code).unwrap_or('\u{FFFD}'); - let mut encoded = [0u8; 4]; - out.extend_from_slice(ch.encode_utf8(&mut encoded).as_bytes()); - } - other => out.push(other), - } - } - c if c == q => return Some(String::from_utf8_lossy(&out).into_owned()), - c => out.push(c), - } - } - None - } - - fn arg(&mut self) -> Option { - self.ws(); - let c = *self.s.get(self.i)?; - match c { - b'"' | b'\'' => self.string().map(Arg::Str), - b'[' => { - self.i += 1; - self.nested(b']').map(Arg::List) - } - _ if self.s[self.i..].starts_with(b"new Array") => { - self.i += b"new Array".len(); - self.ws(); - if self.s.get(self.i) != Some(&b'(') { - return None; - } - self.i += 1; - self.nested(b')').map(Arg::List) - } - _ if self.s[self.i..].starts_with(b"true") => { - self.i += 4; - Some(Arg::Bool(true)) - } - _ if self.s[self.i..].starts_with(b"false") => { - self.i += 5; - Some(Arg::Bool(false)) - } - _ => { - let start = self.i; - while self.i < self.s.len() && (self.s[self.i].is_ascii_digit() || matches!(self.s[self.i], b'.' | b'-' | b'+' | b'e' | b'E')) { - self.i += 1; - } - std::str::from_utf8(&self.s[start..self.i]).ok()?.parse().ok().map(Arg::Num) - } - } - } - - /// A list inside another, at most [`MAX_NESTING`] deep. - fn nested(&mut self, close: u8) -> Option> { - if self.depth >= MAX_NESTING { - return None; - } - self.depth += 1; - let out = self.list(close); - self.depth -= 1; - out - } - - fn list(&mut self, close: u8) -> Option> { - let mut out = Vec::new(); - loop { - self.ws(); - if self.s.get(self.i) == Some(&close) { - self.i += 1; - return Some(out); - } - out.push(self.arg()?); - self.ws(); - match self.s.get(self.i) { - Some(b',') => self.i += 1, - Some(c) if *c == close => { - self.i += 1; - return Some(out); - } - _ => return None, - } - } - } -} - -/// The first call to `name(` in `js` and its arguments. -fn call(js: &str, name: &str) -> Option> { - let at = js.find(&format!("{name}("))?; - // Not part of a longer identifier (AFDate_Format vs AFDate_FormatEx). - if js[..at].chars().last().is_some_and(|c| c.is_alphanumeric() || c == '_') { - return None; - } - let mut p = Parser { s: js.as_bytes(), i: at + name.len() + 1, depth: 0 }; - p.list(b')') -} - -/// Acrobat's AFDate_Format(n) / AFTime_Format(n) presets. -pub const DATE_PRESETS: [&str; 14] = [ - "m/d", - "m/d/yy", - "mm/dd/yy", - "mm/yy", - "d-mmm", - "d-mmm-yy", - "dd-mmm-yy", - "yy-mm-dd", - "mmm-yy", - "mmmm-yy", - "mmm d, yyyy", - "mmmm d, yyyy", - "m/d/yy h:MM tt", - "m/d/yy HH:MM", -]; -pub const TIME_PRESETS: [&str; 4] = ["HH:MM", "h:MM tt", "HH:MM:ss", "h:MM:ss tt"]; - -pub fn parse_format(js: &str) -> Option { - let a = |args: &[Arg], i: usize| args.get(i).and_then(Arg::num).unwrap_or(0.0).clamp(0.0, 255.0) as u8; - if let Some(args) = call(js, "AFNumber_Format") { - return Some(Format::Number { - decimals: a(&args, 0), - sep: a(&args, 1), - neg: a(&args, 2), - currency: args.get(4).and_then(Arg::string).unwrap_or_default(), - prepend: args.get(5).is_none_or(Arg::truthy), - }); - } - if let Some(args) = call(js, "AFPercent_Format") { - return Some(Format::Percent { decimals: a(&args, 0), sep: a(&args, 1) }); - } - if let Some(args) = call(js, "AFDate_FormatEx") { - return Some(Format::Date(args.first().and_then(Arg::string)?)); - } - if let Some(args) = call(js, "AFDate_Format") { - return Some(Format::Date(DATE_PRESETS.get(a(&args, 0) as usize)?.to_string())); - } - if let Some(args) = call(js, "AFTime_FormatEx") { - return Some(Format::Time(args.first().and_then(Arg::string)?)); - } - if let Some(args) = call(js, "AFTime_Format") { - return Some(Format::Time(TIME_PRESETS.get(a(&args, 0) as usize)?.to_string())); - } - if let Some(args) = call(js, "AFSpecial_Format") { - return Some(Format::Special(a(&args, 0).min(3))); - } - if let Some(args) = call(js, "AFSpecial_KeystrokeEx") { - return Some(Format::Mask(args.first().and_then(Arg::string)?)); - } - None -} - -pub fn parse_validate(js: &str) -> Option { - let args = call(js, "AFRange_Validate")?; - let flag = |i: usize| args.get(i).is_some_and(Arg::truthy); - let num = |i: usize| args.get(i).and_then(Arg::num); - Some(Validate::Range { min: if flag(0) { num(1) } else { None }, max: if flag(2) { num(3) } else { None } }) -} - -pub fn parse_calculate(js: &str) -> Option { - // Simplified field notation: Acrobat keeps the expression in a comment. A script is read for - // its comments only when it spells the marker. - if js.contains("BVCALC") - && let Some(range) = notation_range(js, &js_tokens(js).1) - { - return Some(Calculate::Notation(js.get(range)?.trim().to_string())); - } - let args = call(js, "AFSimple_Calculate")?; - let op = match args.first().and_then(Arg::string)?.as_str() { - "SUM" => CalcOp::Sum, - "PRD" => CalcOp::Product, - "AVG" => CalcOp::Average, - "MIN" => CalcOp::Minimum, - "MAX" => CalcOp::Maximum, - _ => return None, - }; - let fields = match args.get(1)? { - Arg::List(l) => l.iter().filter_map(Arg::string).collect(), - Arg::Str(s) => s.split(',').map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).collect(), - _ => return None, - }; - Some(Calculate::Simple { op, fields }) -} - -// ── writing scripts (what Acrobat's tabs generate) ───────────────────────────────────────── - -fn js_str(s: &str) -> String { - let mut o = String::from("\""); - for c in s.chars() { - match c { - '"' | '\\' => { - o.push('\\'); - o.push(c); - } - '\n' => o.push_str("\\n"), - '\r' => o.push_str("\\r"), - '\t' => o.push_str("\\t"), - c if c.is_ascii() && !c.is_control() => o.push(c), - c => { - let mut units = [0u16; 2]; - for unit in c.encode_utf16(&mut units) { - o.push_str(&format!("\\u{unit:04X}")); - } - } - } - } - o.push('"'); - o -} - -/// The (format, keystroke) scripts for a format. -pub fn format_js(f: &Format) -> Option<(String, String)> { - Some(match f { - Format::None => return None, - Format::Number { decimals, sep, neg, currency, prepend } => { - let args = format!("{decimals}, {sep}, {neg}, 0, {}, {prepend}", js_str(currency)); - (format!("AFNumber_Format({args});"), format!("AFNumber_Keystroke({args});")) - } - Format::Percent { decimals, sep } => (format!("AFPercent_Format({decimals}, {sep});"), format!("AFPercent_Keystroke({decimals}, {sep});")), - Format::Date(f) => (format!("AFDate_FormatEx({});", js_str(f)), format!("AFDate_KeystrokeEx({});", js_str(f))), - Format::Time(f) => (format!("AFTime_FormatEx({});", js_str(f)), format!("AFTime_KeystrokeEx({});", js_str(f))), - Format::Special(n) => (format!("AFSpecial_Format({n});"), format!("AFSpecial_Keystroke({n});")), - Format::Mask(m) => (String::new(), format!("AFSpecial_KeystrokeEx({});", js_str(m))), - }) -} - -pub fn validate_js(v: &Validate) -> Option { - match v { - Validate::None => None, - Validate::Range { min, max } => { - Some(format!("AFRange_Validate({}, {}, {}, {});", min.is_some(), min.unwrap_or(0.0), max.is_some(), max.unwrap_or(0.0))) - } - } -} - -pub fn calculate_js(c: &Calculate) -> Option { - match c { - Calculate::None => None, - Calculate::Simple { op, fields } => Some(format!( - "AFSimple_Calculate({}, new Array({}));", - js_str(op.code()), - fields.iter().map(|f| js_str(f)).collect::>().join(", ") - )), - // Acrobat's own translation follows the comment, so a viewer that runs the script instead - // of reading the comment computes the same value (a placeholder here showed 0). - Calculate::Notation(expr) => Some(format!("/** BVCALC {expr} EVCALC **/ event.value = {};", notation_js(expr))), - } -} - -// ── renaming fields in calculations ───────────────────────────────────────────────────────── - -/// A JavaScript token, as far as finding the field names a calculation reads needs. -#[derive(Clone, Debug, PartialEq)] -enum Js { - Ident, - /// A string literal, its escapes undone. - Str(String), - Punct(char), - /// A number, a regular expression or a template literal. - Other, -} - -/// A [`Js`] token with its byte range in the script. -type Token = (Range, Js); - -/// Words after which `/` starts a regular expression instead of dividing. -const BEFORE_REGEX: [&str; 13] = ["return", "typeof", "case", "do", "else", "in", "of", "new", "delete", "void", "throw", "instanceof", "yield"]; - -/// Words whose condition, in parentheses, comes before a statement, so a `/` after it starts a -/// regular expression. The `while` that ends a `do` loop counts too: the loop ends at its `)`, so -/// a `/` after it starts the next statement, and can't divide. -const BEFORE_CONDITION: [&str; 4] = ["if", "while", "for", "with"]; - -/// Whether a line ends at `s[j]`: JavaScript ends one at `\n`, `\r`, U+2028 and U+2029. -fn line_ends_at(s: &[u8], j: usize) -> bool { - matches!(s.get(j..), Some([b'\n' | b'\r', ..] | [0xE2, 0x80, 0xA8 | 0xA9, ..])) -} - -/// JavaScript's tokens with their byte ranges, and apart from them the text of each comment -/// (inside its `//`, or its `/*` and `*/`): enough of the language to tell code from strings, -/// comments and regular expressions. The tokens stop where a string, comment or template literal -/// never ends; a comment that never ends runs to the end of the script. -fn js_tokens(js: &str) -> (Vec, Vec>) { - let s = js.as_bytes(); - // Whether `s[j]` is part of a word. Bytes past ASCII are parts of identifiers, except - // U+2028 and U+2029, which end a line: a range never ends inside a character. - let word = |j: usize| s.get(j).is_some_and(|&b| b.is_ascii_alphanumeric() || b == b'_' || b == b'$' || !b.is_ascii()) && !line_ends_at(s, j); - let mut out: Vec = Vec::new(); - let mut comments = Vec::new(); - let mut i = 0; - // Where a regular expression that never ended stopped being read: every `/` before it - // divides, so a line of them is read once, not once for each. - let mut divides_until = 0; - // For each `(` still open, whether it opened the condition of `if`, `while`, `for` or - // `with`; and whether the last token is the `)` that closed one. - let mut conditions: Vec = Vec::new(); - let mut after_condition = false; - 'scan: while let Some(&c) = s.get(i) { - let start = i; - let token = match c { - _ if c.is_ascii_whitespace() => { - i += 1; - continue; - } - // U+2028 or U+2029 between tokens ends a line, and is skipped whole: read a byte at a - // time, the rest of it would be read from inside the character. - 0xE2 if line_ends_at(s, i) => { - i += 3; - continue; - } - b'/' if s.get(i + 1) == Some(&b'/') => { - i = (i + 2..s.len()).find(|&j| line_ends_at(s, j)).unwrap_or(s.len()); - comments.push(start + 2..i); - continue; - } - b'/' if s.get(i + 1) == Some(&b'*') => match js.get(i + 2..).and_then(|rest| rest.find("*/")) { - Some(n) => { - comments.push(i + 2..i + 2 + n); - i += n + 4; - continue; - } - None => { - comments.push(i + 2..s.len()); - break 'scan; - } - }, - b'"' | b'\'' => { - let mut p = Parser { s, i, depth: 0 }; - let Some(text) = p.string() else { break 'scan }; - i = p.i; - Js::Str(text) - } - b'`' => { - let mut j = i + 1; - loop { - match s.get(j) { - None => break 'scan, - Some(b'\\') => j += 2, - Some(b'`') => break, - Some(_) => j += 1, - } - } - i = j + 1; - Js::Other - } - b'/' if i >= divides_until && regex_may_follow(js, &out, after_condition) => { - // A regular expression ends at a `/` outside a class; a line break first means it - // was a division after all. - let (mut j, mut class) = (i + 1, false); - let end = loop { - match s.get(j) { - None => break None, - Some(_) if line_ends_at(s, j) => break None, - Some(b'\\') => j += 2, - Some(b'/') if !class => break Some(j), - Some(b) => { - class = match b { - b'[' => true, - b']' => false, - _ => class, - }; - j += 1; - } - } - }; - match end { - Some(e) => { - i = e + 1; - while word(i) { - i += 1; - } - Js::Other - } - None => { - divides_until = j; - i += 1; - Js::Punct('/') - } - } - } - _ if c.is_ascii_digit() => { - while word(i) || s.get(i) == Some(&b'.') { - i += 1; - } - Js::Other - } - _ if word(i) => { - while word(i) { - i += 1; - } - Js::Ident - } - _ => { - i += 1; - Js::Punct(char::from(c)) - } - }; - after_condition = match token { - Js::Punct('(') => { - conditions.push(opens_condition(js, &out)); - false - } - Js::Punct(')') => conditions.pop().unwrap_or(false), - _ => false, - }; - out.push((start..i, token)); - } - (out, comments) -} - -/// Whether a `/` after `tokens` starts a regular expression: after an operator, an opening -/// bracket, a keyword such as `return` or the condition of `if`, `while`, `for` or `with`, not -/// after a value. `after_condition` says the last token is the `)` that ends such a condition. -fn regex_may_follow(js: &str, tokens: &[Token], after_condition: bool) -> bool { - match tokens.last() { - None => true, - Some((range, Js::Ident)) => js.get(range.clone()).is_some_and(|w| BEFORE_REGEX.contains(&w)), - Some((_, Js::Punct(')'))) => after_condition, - Some((_, Js::Punct(c))) => !matches!(c, ']' | '}'), - Some((_, Js::Str(_) | Js::Other)) => false, - } -} - -/// Whether a `(` after `tokens` opens the condition of `if`, `while`, `for` or `with`, and not -/// the arguments of a method that has one of those names (`o.if(x)`). -fn opens_condition(js: &str, tokens: &[Token]) -> bool { - let mut back = tokens.iter().rev(); - let Some((range, Js::Ident)) = back.next() else { return false }; - js.get(range.clone()).is_some_and(|w| BEFORE_CONDITION.contains(&w)) && !matches!(back.next(), Some((_, Js::Punct('.')))) -} - -/// Where a Calculate script keeps its simplified field notation: between `BVCALC` and the -/// `EVCALC` after it, in the first of `comments` that holds both, as Acrobat writes it -/// (`/** BVCALC a + b EVCALC **/`). A string or code that spells the markers is not the notation. -fn notation_range(js: &str, comments: &[Range]) -> Option> { - comments.iter().find_map(|comment| { - let text = js.get(comment.clone())?; - let a = text.find("BVCALC")? + 6; - let b = a + text.get(a..)?.find("EVCALC")?; - Some(comment.start + a..comment.start + b) - }) -} - -/// A place where a Calculate script names a field. -#[derive(Clone, Debug, PartialEq)] -enum Site { - /// A string literal holding one name: `getField("a")`, or an item of an - /// `AFSimple_Calculate` list. - Literal(String), - /// An `AFSimple_Calculate` list written as one string of comma-separated names. - Commas(String), - /// A name in the simplified field notation of a `BVCALC` comment. - Notation(String), -} - -impl Site { - fn names(&self) -> Vec<&str> { - match self { - Site::Literal(name) | Site::Notation(name) => vec![name.as_str()], - Site::Commas(list) => list.split(',').map(str::trim).filter(|n| !n.is_empty()).collect(), - } - } - - /// The site written again with field `old` named `new`, or `None` when it doesn't name `old`. - fn renamed(&self, old: &str, new: &str) -> Option { - match self { - Site::Literal(name) => (name == old).then(|| js_str(new)), - Site::Notation(name) => (name == old).then(|| notation_name(new)), - Site::Commas(list) => { - let parts: Vec<&str> = list.split(',').collect(); - parts.iter().any(|p| p.trim() == old).then(|| { - // The spaces around each name stay as they were. - let parts: Vec = parts.iter().map(|p| if p.trim() == old { p.replacen(old, new, 1) } else { (*p).to_owned() }).collect(); - js_str(&parts.join(",")) - }) - } - } - } -} - -/// Where a Calculate script names fields, in order: the simplified field notation in its comment -/// (see [`notation_range`]; [`parse_calculate`] reads it there too), the field list of -/// `AFSimple_Calculate`, and every `getField` whose whole argument is one string literal. -/// A name built at run time (`getField("row" + i)`) is not a site. -fn calculation_sites(js: &str) -> Vec<(Range, Site)> { - let mut sites = Vec::new(); - let (tokens, comments) = js_tokens(js); - if let Some(at) = notation_range(js, &comments) - && let Some(expr) = js.get(at.clone()) - { - for (range, token) in notation_tokens(expr) { - if let Notation::Name(name) = token { - sites.push((at.start + range.start..at.start + range.end, Site::Notation(name))); - } - } - } - let ident = |k: usize, word: &str| tokens.get(k).is_some_and(|(range, t)| *t == Js::Ident && js.get(range.clone()) == Some(word)); - let punct = |k: usize, c: char| tokens.get(k).is_some_and(|(_, t)| *t == Js::Punct(c)); - let string = |k: usize| match tokens.get(k) { - Some((range, Js::Str(text))) => Some((range.clone(), text.clone())), - _ => None, - }; - for k in 0..tokens.len() { - if ident(k, "getField") - && punct(k + 1, '(') - && punct(k + 3, ')') - && let Some((range, name)) = string(k + 2) - { - sites.push((range, Site::Literal(name))); - } - if !(ident(k, "AFSimple_Calculate") && punct(k + 1, '(') && string(k + 2).is_some() && punct(k + 3, ',')) { - continue; - } - if let Some((range, list)) = string(k + 4) { - sites.push((range, Site::Commas(list))); - continue; - } - // `new Array("a", "b")` or `["a", "b"]`: its items, up to the first that isn't a string. - // Nothing else is read, so no token is read for two calls. - let first = if punct(k + 4, '[') { - k + 5 - } else if ident(k + 4, "new") && ident(k + 5, "Array") && punct(k + 6, '(') { - k + 7 - } else { - continue; - }; - for (range, token) in tokens.iter().skip(first) { - match token { - Js::Str(name) => sites.push((range.clone(), Site::Literal(name.clone()))), - Js::Punct(',') => {} - _ => break, - } - } - } - sites.sort_by_key(|(range, _)| range.start); - sites -} - -/// A Calculate script with field `old` named `new` everywhere it reads it (see -/// [`calculation_sites`]), the rest kept as written; `None` when it doesn't read `old`. Also -/// `None` when the new name would read as more than a name there (one holding `EVCALC` would end -/// the notation early), so a script is never left meaning something else. -pub fn rename_in_calculation(js: &str, old: &str, new: &str) -> Option { - let sites = calculation_sites(js); - let edits: Vec<(Range, String)> = sites.iter().filter_map(|(range, site)| Some((range.clone(), site.renamed(old, new)?))).collect(); - if edits.is_empty() { - return None; - } - let out = splice(js, edits); - let expected: Vec<&str> = sites.iter().flat_map(|(_, site)| site.names()).map(|n| if n == old { new } else { n }).collect(); - let written = calculation_sites(&out); - let got: Vec<&str> = written.iter().flat_map(|(_, site)| site.names()).collect(); - (got == expected).then_some(out) -} - -/// The fields a Calculate script reads by name (see [`calculation_sites`]), each once, in the -/// order it first names them. -pub fn calculation_reads(js: &str) -> Vec { - let sites = calculation_sites(js); - unique(sites.iter().flat_map(|(_, site)| site.names()).map(str::to_owned)) -} - -impl Calculate { - /// The fields it reads, each once, in the order it first names them. A name also stands for - /// the fields under it (`row` for `row.0` and `row.1`), as [`calculate`] reads them. - pub fn reads(&self) -> Vec { - match self { - Calculate::None => Vec::new(), - Calculate::Simple { fields, .. } => unique(fields.iter().cloned()), - Calculate::Notation(expr) => unique(notation_tokens(expr).into_iter().filter_map(|(_, token)| match token { - Notation::Name(name) => Some(name), - _ => None, - })), - } - } -} - -/// `names` without repeats, in order. -fn unique(names: impl IntoIterator) -> Vec { - let mut seen = std::collections::HashSet::new(); - names.into_iter().filter(|n| seen.insert(n.clone())).collect() -} - -// ── running them ──────────────────────────────────────────────────────────────────────────── - -/// AFMakeNumber: a number from typed text (currency symbols, group separators, spaces and a -/// trailing % ignored; a comma decimal separator accepted). -pub fn make_number(s: &str) -> Option { - let t: String = s.trim().chars().filter(|c| !c.is_whitespace() && !matches!(c, '$' | '€' | '£' | '¥' | '\'' | '%')).collect(); - if t.is_empty() { - return None; - } - let neg = t.starts_with('(') && t.ends_with(')'); - let t = t.trim_start_matches('(').trim_end_matches(')'); - // "1.234,56" or "1234,56": comma is the decimal separator when it comes last. - let normalized = match (t.rfind(','), t.rfind('.')) { - (Some(c), Some(d)) if c > d => t.replace('.', "").replace(',', "."), - (Some(c), None) if t.len() - c - 1 != 3 => t.replace(',', "."), - _ => t.replace(',', ""), - }; - let v: f64 = normalized.parse().ok()?; - v.is_finite().then_some(if neg { -v } else { v }) -} - -/// A number as JavaScript prints it (integers without a fraction). -pub fn js_number(v: f64) -> String { - if v.fract() == 0.0 && v.abs() < 1e15 { format!("{}", v as i64) } else { format!("{v}") } -} - -fn group(int: &str, sep: char) -> String { - let mut out = String::new(); - for (i, c) in int.chars().enumerate() { - if i > 0 && (int.len() - i).is_multiple_of(3) { - out.push(sep); - } - out.push(c); - } - out -} - -/// A number with `decimals` places in separator style `sep` (0 "1,234.56", 1 "1234.56", -/// 2 "1.234,56", 3 "1234,56", 4 "1'234.56"). -fn number_text(v: f64, decimals: u8, sep: u8) -> String { - // Round half away from zero, as JavaScript's toFixed does for these values. - let p = 10f64.powi(i32::from(decimals)); - let scaled = v.abs() * p; - // Past 2^53 every value is already whole, and scaling could overflow to infinity. - let r = if scaled.is_finite() { scaled.round() / p } else { v.abs() }; - let s = format!("{:.*}", decimals as usize, r); - let (int, frac) = s.split_once('.').map_or((s.as_str(), ""), |(a, b)| (a, b)); - let (g, d) = match sep { - 1 => (None, '.'), - 2 => (Some('.'), ','), - 3 => (None, ','), - 4 => (Some('\''), '.'), - _ => (Some(','), '.'), - }; - let mut out = match g { - Some(c) => group(int, c), - None => int.to_string(), - }; - if !frac.is_empty() { - out.push(d); - out.push_str(frac); - } - out -} - -const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]; -const DAYS: [&str; 7] = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; - -/// A date and time read from text. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct DateTime { - pub y: i32, - pub m: u32, - pub d: u32, - pub hh: u32, - pub mm: u32, - pub ss: u32, -} - -fn days_in(y: i32, m: u32) -> u32 { - match m { - 2 if (y % 4 == 0 && y % 100 != 0) || y % 400 == 0 => 29, - 2 => 28, - 4 | 6 | 9 | 11 => 30, - _ => 31, - } -} - -fn weekday(y: i32, m: u32, d: u32) -> usize { - // Sakamoto's method, in i64 so no year or day can overflow it. - const T: [i64; 12] = [0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4]; - let y = i64::from(y) - i64::from(m < 3); - let t = T.get((m as usize).wrapping_sub(1)).copied().unwrap_or(0); - (y + y / 4 - y / 100 + y / 400 + t + i64::from(d)).rem_euclid(7) as usize -} - -/// One field of a date format, in the order the format has them. -struct Part { - /// `'y'`, `'m'`, `'d'`, `'H'`, `'M'` or `'s'`; a run of literal digits (the fixed year in - /// "dd.mm.26") is `'#'`. - kind: char, - /// The value of a `'#'` run; 0 for the others. - literal: u32, - /// The digits the field takes when typed run together with the next one: 4 for "yyyy", 2 for - /// "yy", "mm", "dd", "HH", "MM" and "ss". 0 when the width varies ("m", "d") or the field is - /// a name ("mmm"). - width: usize, - /// The next field follows with nothing between them ("yyyymmdd"), so one run of typed digits - /// can hold both. - joined: bool, -} - -/// The fields of a format (year, month, day, hours, minutes, seconds and literal digits), in -/// order. -fn order(fmt: &str) -> Vec { - let mut out: Vec = Vec::new(); - // Weekday names (ddd, dddd) are shown, not typed: leave them out. - let mut cleaned = fmt.replace("dddd", " "); - cleaned = cleaned.replace("ddd", " "); - let mut prev = ' '; - // The letter of the last field's current run and its length so far. - let mut run: Option<(char, usize)> = None; - let fixed = |k: char, n: usize| match (k, n) { - ('y', 2 | 4) | ('m' | 'd' | 'H' | 'M' | 's', 2) => n, - _ => 0, - }; - for c in cleaned.chars() { - let continues = run.is_some_and(|(r, _)| r == c); - if let Some((r, n)) = run - && !continues - { - // The run ended; it was the last field's width. - if let Some(last) = out.last_mut().filter(|p| p.kind != '#') { - last.width = fixed(if r == 'h' { 'H' } else { r }, n); - } - run = None; - } - if let Some(digit) = c.to_digit(10) { - match out.last_mut() { - Some(p) if p.kind == '#' && prev.is_ascii_digit() => p.literal = p.literal.saturating_mul(10).saturating_add(digit), - _ => out.push(Part { kind: '#', literal: digit, width: 0, joined: false }), - } - prev = c; - continue; - } - let k = match c { - 'y' => 'y', - 'm' => 'm', - 'd' => 'd', - 'H' | 'h' => 'H', - 'M' => 'M', - 's' => 's', - _ => { - prev = c; - continue; - } - }; - if continues { - run = run.map(|(r, n)| (r, n.saturating_add(1))); - } else { - // A field starting right where the last one's letters stopped is joined to it. - let adjacent = matches!(prev, 'y' | 'm' | 'd' | 'H' | 'h' | 'M' | 's'); - if out.last().map(|p| p.kind) != Some(k) { - if let Some(last) = out.last_mut().filter(|p| p.kind != '#') { - last.joined = adjacent; - } - out.push(Part { kind: k, literal: 0, width: 0, joined: false }); - } - run = Some((c, 1)); - } - prev = c; - } - if let (Some((r, n)), Some(last)) = (run, out.last_mut().filter(|p| p.kind != '#')) { - last.width = fixed(if r == 'h' { 'H' } else { r }, n); - } - out -} - -/// Parse typed text against a date/time format (AFDate_KeystrokeEx's leniency: any -/// separators, month names, two-digit years). `None` when it isn't a valid date. -pub fn parse_date(text: &str, fmt: &str) -> Option { - let t = text.trim(); - if t.is_empty() { - return None; - } - let lower = t.to_lowercase(); - // A bare trailing "p" ("5:30p") only counts when it follows a digit, not a word like "Sep". - let bare_p = lower.strip_suffix('p').is_some_and(|rest| rest.trim_end().ends_with(|c: char| c.is_ascii_digit())); - let pm = lower.contains("pm") || bare_p; - let bare_a = lower.strip_suffix('a').is_some_and(|rest| rest.trim_end().ends_with(|c: char| c.is_ascii_digit())); - let am = lower.contains("am") || bare_a; - // Tokens: runs of digits (kept as text, so a run can be split between fields typed without - // separators) and month names. - let mut nums: VecDeque = VecDeque::new(); - let mut month_name: Option = None; - let mut cur = String::new(); - let flush = |cur: &mut String, nums: &mut VecDeque, month: &mut Option| { - if cur.is_empty() { - return; - } - if cur.starts_with(|c: char| c.is_ascii_digit()) { - nums.push_back(std::mem::take(cur)); - } else if cur.len() >= 3 - && let Some(i) = MONTHS.iter().position(|m| m.to_lowercase().starts_with(&cur.to_lowercase())) - { - *month = Some(i as u32 + 1); - } - cur.clear(); - }; - for c in t.chars() { - // Digits and letters form separate tokens ("5Mar" is 5 and Mar). - let same_kind = cur.is_empty() || cur.chars().next().is_some_and(|x| x.is_ascii_digit()) == c.is_ascii_digit(); - if c.is_alphanumeric() && same_kind { - cur.push(c); - } else { - flush(&mut cur, &mut nums, &mut month_name); - if c.is_alphanumeric() { - cur.push(c); - } - } - } - flush(&mut cur, &mut nums, &mut month_name); - let mut dt = DateTime { y: 0, m: 1, d: 1, hh: 0, mm: 0, ss: 0 }; - // The next number for a field. A field joined to the next one in the format ("yyyymmdd") - // takes only its own width from a longer run of digits and leaves the rest for the next. - // A run typed on its own is split only when fewer runs were typed than the fields of its kind - // still to fill (`need`, this one included: date fields for a date field, time fields for a - // time field, since times may be left out), and only when its rest can fill the next field - // (`next_width`): with separators each run is one field's number, so "2026-10-18" keeps its - // four-digit year in a yymmdd or yymmddHHMM field, and "2026 10 018" is the 18th. What is left - // of a split run (`tail`: the front of `nums` is one) goes on being split along the fields - // written side by side, and must fill its last field exactly, so "202610018" leaves "018" for - // dd and is refused. `None` when there is no number left, it doesn't fit a u32, or such a - // rest has the wrong width. - let next_number = |nums: &mut VecDeque, tail: &mut bool, part: &Part, need: usize, next_width: usize| -> Option { - let run = nums.pop_front()?; - let was_tail = std::mem::replace(tail, false); - let short = nums.len() < need.saturating_sub(1); - let digits = match (run.get(..part.width), run.get(part.width..)) { - (Some(head), Some(rest)) if part.joined && part.width > 0 && !rest.is_empty() && (was_tail || (short && rest.len() >= next_width)) => { - nums.push_front(rest.to_string()); - *tail = true; - head - } - _ if was_tail && part.width > 0 && run.len() != part.width => return None, - _ => run.as_str(), - }; - digits.parse::().ok() - }; - let parts = order(fmt); - let takes_number = |p: &Part| matches!(p.kind, 'y' | 'd' | 'H' | 'M' | 's') || (p.kind == 'm' && month_name.is_none()); - let is_time = |p: &Part| matches!(p.kind, 'H' | 'M' | 's'); - // The fields of each kind still to fill: [date, time]. - let mut wanted = [parts.iter().filter(|p| takes_number(p) && !is_time(p)).count(), parts.iter().filter(|p| is_time(p)).count()]; - let mut tail = false; - let mut have_y = false; - for (i, part) in parts.iter().enumerate() { - let kind = usize::from(is_time(part)); - let need = wanted.get(kind).copied().unwrap_or(0); - let next_width = parts.get(i + 1).map_or(0, |p| p.width); - if takes_number(part) - && let Some(left) = wanted.get_mut(kind) - { - *left = left.saturating_sub(1); - } - let take = |nums: &mut VecDeque, tail: &mut bool| next_number(nums, tail, part, need, next_width); - match part.kind { - // Literal digits in the format may be typed or left out ("15.05.26" or "15.05" - // for "dd.mm.26"); a different number there is left over and rejected below. - '#' if nums.front().and_then(|n| n.parse::().ok()) == Some(part.literal) => { - nums.pop_front(); - tail = false; - } - 'm' => { - dt.m = match month_name { - Some(m) => m, - None => take(&mut nums, &mut tail)?, - } - } - 'd' => dt.d = take(&mut nums, &mut tail)?, - 'y' => { - let y = i32::try_from(take(&mut nums, &mut tail)?).ok()?; - dt.y = if y < 100 { if y < 50 { 2000 + y } else { 1900 + y } } else { y }; - have_y = true; - } - // Times may be left out; a number that is there must be readable. - 'H' => dt.hh = if nums.is_empty() { 0 } else { take(&mut nums, &mut tail)? }, - 'M' => dt.mm = if nums.is_empty() { 0 } else { take(&mut nums, &mut tail)? }, - 's' => dt.ss = if nums.is_empty() { 0 } else { take(&mut nums, &mut tail)? }, - _ => {} - } - } - if !nums.is_empty() { - return None; - } - if !have_y { - dt.y = 2000; - } - if pm && dt.hh < 12 { - dt.hh += 12; - } - if am && dt.hh == 12 { - dt.hh = 0; - } - let has_date = fmt.contains(['y', 'm', 'd']); - if has_date && (!(1..=12).contains(&dt.m) || dt.d == 0 || dt.d > days_in(dt.y, dt.m)) { - return None; - } - if dt.hh > 23 || dt.mm > 59 || dt.ss > 59 { - return None; - } - Some(dt) -} - -/// Format a date/time with Acrobat's date format letters. -pub fn format_date(dt: DateTime, fmt: &str) -> String { - let chars: Vec = fmt.chars().collect(); - let mut out = String::new(); - let mut i = 0; - while i < chars.len() { - let c = chars[i]; - let mut n = 1; - while i + n < chars.len() && chars[i + n] == c { - n += 1; - } - let h12 = if dt.hh.is_multiple_of(12) { 12 } else { dt.hh % 12 }; - match (c, n) { - ('y', 4..) => { - let _ = write!(out, "{:04}", dt.y); - } - ('y', _) => { - let _ = write!(out, "{:02}", dt.y.rem_euclid(100)); - } - ('m', 3..) => match MONTHS.get((dt.m as usize).wrapping_sub(1)) { - Some(name) if n >= 4 => out.push_str(name), - Some(name) => out.push_str(name.get(..3).unwrap_or(name)), - None => { - let _ = write!(out, "{}", dt.m); - } - }, - ('m', 2) => { - let _ = write!(out, "{:02}", dt.m); - } - ('m', _) => { - let _ = write!(out, "{}", dt.m); - } - ('d', 3..) => { - let name = DAYS.get(weekday(dt.y, dt.m, dt.d)).copied().unwrap_or_default(); - out.push_str(if n >= 4 { name } else { name.get(..3).unwrap_or(name) }); - } - ('d', 2) => { - let _ = write!(out, "{:02}", dt.d); - } - ('d', _) => { - let _ = write!(out, "{}", dt.d); - } - ('H', 2) => { - let _ = write!(out, "{:02}", dt.hh); - } - ('H', _) => { - let _ = write!(out, "{}", dt.hh); - } - ('h', 2) => { - let _ = write!(out, "{h12:02}"); - } - ('h', _) => { - let _ = write!(out, "{h12}"); - } - ('M', 2) => { - let _ = write!(out, "{:02}", dt.mm); - } - ('M', _) => { - let _ = write!(out, "{}", dt.mm); - } - ('s', 2) => { - let _ = write!(out, "{:02}", dt.ss); - } - ('s', _) => { - let _ = write!(out, "{}", dt.ss); - } - ('t', 2) => out.push_str(if dt.hh < 12 { "am" } else { "pm" }), - ('t', _) => out.push(if dt.hh < 12 { 'a' } else { 'p' }), - (c, n) => out.extend(std::iter::repeat_n(c, n)), - } - i += n; - } - out -} - -const SPECIAL_MASKS: [&str; 4] = ["99999", "99999-9999", "(999) 999-9999", "999-99-9999"]; - -fn mask_fits(mask: &str, text: &str) -> bool { - let t: Vec = text.chars().collect(); - let m: Vec = mask.chars().collect(); - t.len() == m.len() - && m.iter().zip(&t).all(|(mc, tc)| match mc { - '9' => tc.is_ascii_digit(), - 'A' => tc.is_alphabetic(), - 'O' => tc.is_alphanumeric(), - 'X' => true, - other => other == tc, - }) -} - -/// Fill a mask from the typed characters that fit its slots. -fn apply_mask(mask: &str, text: &str) -> Option { - let mut src = text.chars().filter(|c| c.is_alphanumeric()); - let mut out = String::new(); - for mc in mask.chars() { - match mc { - '9' | 'A' | 'O' | 'X' => { - let c = src.next()?; - let ok = match mc { - '9' => c.is_ascii_digit(), - 'A' => c.is_alphabetic(), - _ => true, - }; - if !ok { - return None; - } - out.push(c); - } - other => out.push(other), - } - } - src.next().is_none().then_some(out) -} - -/// The text shown for a value (the Format event). -pub fn format_value(f: &Format, value: &str) -> String { - if value.is_empty() { - return String::new(); - } - match f { - Format::None => value.to_string(), - Format::Number { decimals, sep, neg, currency, prepend } => { - let Some(v) = make_number(value) else { return value.to_string() }; - let body = number_text(v, *decimals, *sep); - let body = match (currency.is_empty(), *prepend) { - (true, _) => body, - (false, true) => format!("{currency}{body}"), - (false, false) => format!("{body}{currency}"), - }; - match (v < 0.0, neg) { - (false, _) => body, - // Styles 2 and 3 use parentheses (3 is also red, which the appearance can't - // know here); 0 and 1 a minus sign. - (true, 2 | 3) => format!("({body})"), - (true, _) => format!("-{body}"), - } - } - Format::Percent { decimals, sep } => match make_number(value) { - Some(v) => format!( - "{}%", - if v < 0.0 { format!("-{}", number_text(v * 100.0, *decimals, *sep)) } else { number_text(v * 100.0, *decimals, *sep) } - ), - None => value.to_string(), - }, - Format::Date(fmt) | Format::Time(fmt) => parse_date(value, fmt).map_or_else(|| value.to_string(), |dt| format_date(dt, fmt)), - Format::Special(n) => apply_mask(SPECIAL_MASKS[(*n).min(3) as usize], value).unwrap_or_else(|| value.to_string()), - Format::Mask(m) => apply_mask(m, value).unwrap_or_else(|| value.to_string()), - } -} - -/// The Keystroke event on commit: does the value fit the format? Returns the value to store -/// (numbers are normalised), or Acrobat's message. -pub fn keystroke(f: &Format, field: &str, value: &str) -> Result { - let bad = || format!("The value entered does not match the format of the field [ {field} ]"); - // Blank input clears a formatted field; without a format, whitespace is text like any other. - if *f != Format::None && value.trim().is_empty() { - return Ok(String::new()); - } - match f { - Format::None => Ok(value.to_string()), - Format::Number { .. } => make_number(value).map(js_number).ok_or_else(bad), - Format::Percent { .. } => { - // Typed "12.5%" or "12.5" both mean 0.125 once stored? Acrobat stores the number - // as typed and shows it ×100: keep the typed number. - make_number(value).map(js_number).ok_or_else(bad) - } - Format::Date(fmt) | Format::Time(fmt) => parse_date(value, fmt) - .map(|_| value.trim().to_string()) - .ok_or_else(|| format!("Invalid date/time: please ensure that the date/time exists. Field [ {field} ] should match format {fmt}")), - Format::Special(n) => { - let m = SPECIAL_MASKS[(*n).min(3) as usize]; - apply_mask(m, value).filter(|s| mask_fits(m, s)).map(|_| value.trim().to_string()).ok_or_else(bad) - } - Format::Mask(m) => apply_mask(m, value).map(|_| value.trim().to_string()).ok_or_else(bad), - } -} - -/// The Validate event: `Err` with Acrobat's message when the value is out of range. -pub fn validate(v: &Validate, value: &str) -> Result<(), String> { - let Validate::Range { min, max } = v else { return Ok(()) }; - if value.trim().is_empty() { - return Ok(()); - } - let Some(x) = make_number(value) else { return Ok(()) }; - let fmt = |n: f64| js_number(n); - let ok = min.is_none_or(|m| x >= m) && max.is_none_or(|m| x <= m); - if ok { - return Ok(()); - } - Err(match (min, max) { - (Some(a), Some(b)) => format!("Invalid value: must be greater than or equal to {} and less than or equal to {}.", fmt(*a), fmt(*b)), - (Some(a), None) => format!("Invalid value: must be greater than or equal to {}.", fmt(*a)), - (None, Some(b)) => format!("Invalid value: must be less than or equal to {}.", fmt(*b)), - (None, None) => String::new(), - }) -} - -/// A token of simplified field notation. -#[derive(Clone, Debug, PartialEq)] -enum Notation { - /// A number, as written. - Number, - /// `+ - * / ( )`. - Op(char), - /// A field name, its `\` escapes undone. - Name(String), -} - -/// Simplified field notation's tokens, each with its byte range in `src`. Whitespace separates -/// them, and `\` escapes a space or an operator inside a name, as Acrobat writes them. -fn notation_tokens(src: &str) -> Vec<(Range, Notation)> { - let breaks = |c: char| c.is_whitespace() || "+-*/()".contains(c); - let mut out = Vec::new(); - let mut cs = src.char_indices().peekable(); - while let Some(&(start, c)) = cs.peek() { - let mut end = start + c.len_utf8(); - if c.is_whitespace() { - cs.next(); - } else if breaks(c) { - cs.next(); - out.push((start..end, Notation::Op(c))); - } else if c.is_ascii_digit() || c == '.' { - while let Some((i, d)) = cs.next_if(|(_, d)| d.is_ascii_digit() || *d == '.') { - end = i + d.len_utf8(); - } - out.push((start..end, Notation::Number)); - } else { - let mut name = String::new(); - while let Some((i, n)) = cs.next_if(|(_, n)| !breaks(*n)) { - end = i + n.len_utf8(); - if n == '\\' - && let Some((j, next)) = cs.next() - { - end = j + next.len_utf8(); - name.push(next); - } else { - name.push(n); - } - } - out.push((start..end, Notation::Name(name))); - } - } - out -} - -/// A field name as simplified field notation writes it: `\` before a space, an operator or a -/// `\`, and before a first character that would start a number. -fn notation_name(name: &str) -> String { - let mut out = String::new(); - for (i, c) in name.chars().enumerate() { - if c.is_whitespace() || "+-*/()\\".contains(c) || (i == 0 && (c.is_ascii_digit() || c == '.')) { - out.push('\\'); - } - out.push(c); - } - out -} - -/// `src` with each range replaced. The ranges come in order and don't overlap; one that does -/// is left out rather than written over another. -fn splice(src: &str, edits: impl IntoIterator, String)>) -> String { - let mut out = String::with_capacity(src.len()); - let mut done = 0; - for (range, with) in edits { - let Some(kept) = src.get(done..range.start).filter(|_| range.start <= range.end) else { continue }; - out.push_str(kept); - out.push_str(&with); - done = range.end; - } - out.push_str(src.get(done..).unwrap_or_default()); - out -} - -/// Simplified field notation as the JavaScript Acrobat writes after its `BVCALC` comment: -/// each field name becomes `AFMakeNumber(this.getField("name").value)`, the rest stays. -pub fn notation_js(expr: &str) -> String { - let names = notation_tokens(expr).into_iter().filter_map(|(range, t)| match t { - Notation::Name(name) => Some((range, format!("AFMakeNumber(this.getField({}).value)", js_str(&name)))), - _ => None, - }); - splice(expr, names) -} - -/// Evaluate simplified field notation: + - * / and parentheses over numbers and field names. -fn eval_notation(src: &str, value_of: &dyn Fn(&str) -> f64) -> Option { - #[derive(Debug, Clone, PartialEq)] - enum T { - N(f64), - Op(char), - } - let toks = notation_tokens(src) - .into_iter() - .map(|(range, t)| match t { - Notation::Number => src.get(range)?.parse().ok().map(T::N), - Notation::Op(c) => Some(T::Op(c)), - Notation::Name(name) => Some(T::N(value_of(&name))), - }) - .collect::>>()?; - // Recursive descent, `d` levels deep (at most MAX_NESTING). - fn expr(t: &[T], i: &mut usize, d: usize) -> Option { - let mut v = term(t, i, d)?; - while let Some(T::Op(o @ ('+' | '-'))) = t.get(*i) { - *i += 1; - let r = term(t, i, d)?; - v = if *o == '+' { v + r } else { v - r }; - } - Some(v) - } - fn term(t: &[T], i: &mut usize, d: usize) -> Option { - let mut v = factor(t, i, d)?; - while let Some(T::Op(o @ ('*' | '/'))) = t.get(*i) { - *i += 1; - let r = factor(t, i, d)?; - v = if *o == '*' { v * r } else { v / r }; - } - Some(v) - } - fn factor(t: &[T], i: &mut usize, d: usize) -> Option { - if d > MAX_NESTING { - return None; - } - match t.get(*i)? { - T::N(n) => { - *i += 1; - Some(*n) - } - T::Op('-') => { - *i += 1; - factor(t, i, d + 1).map(|v| -v) - } - T::Op('(') => { - *i += 1; - let v = expr(t, i, d + 1)?; - (t.get(*i) == Some(&T::Op(')'))).then(|| *i += 1)?; - Some(v) - } - _ => None, - } - } - let mut i = 0; - let v = expr(&toks, &mut i, 0)?; - (i == toks.len() && v.is_finite()).then_some(v) -} - -/// The Calculate event: the new value, given the current values of the other fields -/// (`values(name)` returns every field named `name` or below it, for groups). -pub fn calculate(c: &Calculate, values: &dyn Fn(&str) -> Vec) -> Option { - let num = |s: &String| make_number(s).unwrap_or(0.0); - match c { - Calculate::None => None, - Calculate::Simple { op, fields } => { - let xs: Vec = fields.iter().flat_map(|f| values(f)).map(|v| num(&v)).collect(); - let v = match op { - CalcOp::Sum => xs.iter().sum(), - CalcOp::Product => xs.iter().product(), - CalcOp::Average => { - if xs.is_empty() { - 0.0 - } else { - xs.iter().sum::() / xs.len() as f64 - } - } - CalcOp::Minimum => xs.iter().copied().fold(f64::INFINITY, f64::min), - CalcOp::Maximum => xs.iter().copied().fold(f64::NEG_INFINITY, f64::max), - }; - Some(if v.is_finite() { js_number(v) } else { "0".into() }) - } - Calculate::Notation(expr) => { - let v = eval_notation(expr, &|name| values(name).first().map(num).unwrap_or(0.0))?; - Some(js_number(v)) - } - } -} - #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn javascript_escapes_are_decoded() { - assert_eq!( - parse_format("AFNumber_Format(2, 0, 0, 0, \"\\x24\", true);"), - Some(Format::Number { decimals: 2, sep: 0, neg: 0, currency: "$".into(), prepend: true }) - ); - assert_eq!(parse_format("AFDate_FormatEx(\"\\b\\f\\v\\0\\q\");"), Some(Format::Date("\u{8}\u{c}\u{b}\0q".into()))); - } - - #[test] - fn scripts_are_recognised_and_written_back() { - assert_eq!( - parse_format("AFNumber_Format(2, 0, 0, 0, \"$\", true);"), - Some(Format::Number { decimals: 2, sep: 0, neg: 0, currency: "$".into(), prepend: true }) - ); - assert_eq!(parse_format("AFPercent_Format(1, 0);"), Some(Format::Percent { decimals: 1, sep: 0 })); - assert_eq!(parse_format("AFDate_FormatEx(\"dd-mmm-yyyy\");"), Some(Format::Date("dd-mmm-yyyy".into()))); - assert_eq!(parse_format("AFDate_Format(2);"), Some(Format::Date("mm/dd/yy".into()))); - assert_eq!(parse_format("AFTime_Format(1);"), Some(Format::Time("h:MM tt".into()))); - assert_eq!(parse_format("AFSpecial_Format(2);"), Some(Format::Special(2))); - assert_eq!(parse_format("AFSpecial_KeystrokeEx(\"AA-9999\");"), Some(Format::Mask("AA-9999".into()))); - assert_eq!(parse_format("app.alert('hi')"), None); - assert_eq!(parse_validate("AFRange_Validate(true, 0, true, 100);"), Some(Validate::Range { min: Some(0.0), max: Some(100.0) })); - assert_eq!(parse_validate("AFRange_Validate(false, 0, true, 10);"), Some(Validate::Range { min: None, max: Some(10.0) })); - assert_eq!( - parse_calculate("AFSimple_Calculate(\"SUM\", new Array (\"a\", \"b.c\"));"), - Some(Calculate::Simple { op: CalcOp::Sum, fields: vec!["a".into(), "b.c".into()] }) - ); - assert_eq!(parse_calculate("AFSimple_Calculate('AVG', ['x']);"), Some(Calculate::Simple { op: CalcOp::Average, fields: vec!["x".into()] })); - assert_eq!( - parse_calculate("/** BVCALC Price * Qty EVCALC **/ event.value = AFMakeNumber(getField(\"Price\").value) * …"), - Some(Calculate::Notation("Price * Qty".into())) - ); - // Round trips through what the tabs write. - for f in [ - Format::Number { decimals: 2, sep: 2, neg: 2, currency: "€".into(), prepend: false }, - Format::Percent { decimals: 0, sep: 0 }, - Format::Date("mmmm d, yyyy".into()), - Format::Special(3), - ] { - let (fj, _) = format_js(&f).unwrap(); - assert_eq!(parse_format(&fj), Some(f)); - } - let euro = format_js(&Format::Number { decimals: 2, sep: 2, neg: 0, currency: "€".into(), prepend: true }).unwrap().0; - assert!(euro.contains("\\u20AC"), "{euro}"); - assert!(!euro.contains('€'), "{euro}"); - let pound = format_js(&Format::Number { decimals: 2, sep: 0, neg: 0, currency: "£".into(), prepend: true }).unwrap().0; - assert!(pound.contains("\\u00A3"), "{pound}"); - assert!(!pound.contains('£'), "{pound}"); - let c = Calculate::Simple { op: CalcOp::Product, fields: vec!["q".into(), "p".into()] }; - assert_eq!(parse_calculate(&calculate_js(&c).unwrap()), Some(c)); - let c = Calculate::Notation("a + b".into()); - assert_eq!(parse_calculate(&calculate_js(&c).unwrap()), Some(c)); - // The JavaScript after the comment is Acrobat's translation, so viewers that run it - // instead of reading the comment compute the same value (not 0). - assert_eq!( - calculate_js(&Calculate::Notation("(Unit\\ Price * Qty) / 2".into())).unwrap(), - "/** BVCALC (Unit\\ Price * Qty) / 2 EVCALC **/ event.value = (AFMakeNumber(this.getField(\"Unit Price\").value) * AFMakeNumber(this.getField(\"Qty\").value)) / 2;" - ); - } - - #[test] - fn a_rename_rewrites_only_where_a_calculation_reads_the_field() { - let renamed = |js: &str| rename_in_calculation(js, "a", "qty"); - // The field lists Acrobat and other tools write for AFSimple_Calculate. - assert_eq!( - renamed("AFSimple_Calculate(\"SUM\", new Array (\"a\", \"b\"));").as_deref(), - Some("AFSimple_Calculate(\"SUM\", new Array (\"qty\", \"b\"));") - ); - assert_eq!(renamed("AFSimple_Calculate('AVG', ['b', 'a']);").as_deref(), Some("AFSimple_Calculate('AVG', ['b', \"qty\"]);")); - assert_eq!(renamed("AFSimple_Calculate(\"SUM\", \"a, b,a\");").as_deref(), Some("AFSimple_Calculate(\"SUM\", \"qty, b,qty\");")); - assert_eq!(renamed("AFSimple_Calculate(\"SUM\", new Array(\"ab\", \"b.a\"));"), None, "other names that contain it"); - // Notation: the expression in the comment and Acrobat's translation after it. - let notation = |expr: &str| calculate_js(&Calculate::Notation(expr.into())).unwrap(); - assert_eq!(rename_in_calculation(¬ation("(a + ab) * a / 2"), "a", "Unit Price"), Some(notation("(Unit\\ Price + ab) * Unit\\ Price / 2"))); - assert_eq!(rename_in_calculation(¬ation("Unit\\ Price * 2"), "Unit Price", "price"), Some(notation("price * 2"))); - // Scripts: a getField whose argument is one string literal. Comments, strings, template - // literals, regular expressions and names built at run time are left as written. - let script = "// getField(\"a\")\nvar s = \"getField('a')\", t = `getField(\"a\")`; /* getField(\"a\") */\n\ - if (/\"/.test(s)) event.value = this.getField('a').value / 2 * getField(\"a\" + 1).value + getField ( \"ab\" ).value;"; - assert_eq!(renamed(script), Some(script.replacen("this.getField('a')", "this.getField(\"qty\")", 1))); - assert_eq!(renamed("event.value = getField(\"b\").value;"), None); - // A new name is escaped as the script needs it. - assert_eq!( - rename_in_calculation("event.value = getField(\"a\").value;", "a", "say \"é\"").as_deref(), - Some("event.value = getField(\"say \\\"\\u00E9\\\"\").value;") - ); - // A name that would end the notation early is not written in. - assert_eq!(rename_in_calculation(¬ation("a * 2"), "a", "qtyEVCALC"), None); - // Unfinished scripts end their tokens, not the program. - for js in ["getField(\"a", "/* getField(\"a\")", "`getField(\"a\")", "x = /[/", "getField('a\\u12"] { - assert_eq!(renamed(js), None, "{js}"); - } - } - - /// The notation is read only from a comment, where Acrobat writes it: a string or code that - /// spells the markers is left alone, by the Calculate tab and by a rename. - #[test] - fn notation_is_read_only_from_a_comment() { - let string = "var s = \"BVCALC a EVCALC\"; event.value = getField(\"b\").value;"; - assert_eq!(parse_calculate(string), None); - assert_eq!(rename_in_calculation(string, "a", "qty"), None); - // A comment after such a string holds the notation. - let both = "var s = 'BVCALC x EVCALC'; /** BVCALC a + 1 EVCALC **/ event.value = 1;"; - assert_eq!(parse_calculate(both), Some(Calculate::Notation("a + 1".into()))); - assert_eq!(rename_in_calculation(both, "x", "y"), None); - assert_eq!(rename_in_calculation(both, "a", "qty"), Some(both.replace("a + 1", "qty + 1"))); - // A line comment is a comment too, and an unfinished one still holds it. - assert_eq!(parse_calculate("// BVCALC a EVCALC\nevent.value = 1;"), Some(Calculate::Notation("a".into()))); - assert_eq!(parse_calculate("/** BVCALC a EVCALC"), Some(Calculate::Notation("a".into()))); - } - - /// A line comment ends at any of JavaScript's line terminators, not only `\n`, so with `\r` line - /// endings the code after one is still code; and a `/` read as starting a regular expression - /// divides after all when the line ends first. - #[test] - fn a_line_comment_ends_at_any_line_terminator() { - for end in ["\n", "\r", "\r\n", "\u{2028}", "\u{2029}"] { - let string = format!("// header{end}var s = \"BVCALC a EVCALC\"; event.value = getField(\"b\").value;"); - assert_eq!(parse_calculate(&string), None, "{end:?}"); - assert_eq!(rename_in_calculation(&string, "a", "qty"), None, "{end:?}"); - let script = format!("// note{end}getField(\"a\").value;"); - assert_eq!(rename_in_calculation(&script, "a", "qty"), Some(script.replace("\"a\"", "\"qty\"")), "{end:?}"); - let divides = format!("var n = i++ / 2;{end}event.value = getField(\"a\").value / 4;"); - assert_eq!(rename_in_calculation(÷s, "a", "qty"), Some(divides.replace("\"a\"", "\"qty\"")), "{end:?}"); - } - } - - /// U+2028 and U+2029 end a word, a number or a regular expression's flags, though as bytes - /// past ASCII they could be part of one, so a `getField` right after one is still a call. - #[test] - fn a_token_ends_at_a_line_separator() { - for end in ["\u{2028}", "\u{2029}"] { - let scripts = ["x", "1", "/r/g"].map(|token| format!("var t = {token}{end}getField(\"a\").value;")); - let renamed = scripts.clone().map(|js| rename_in_calculation(&js, "a", "qty")); - assert_eq!(renamed, scripts.map(|js| Some(js.replace("\"a\"", "\"qty\""))), "{end:?}"); - } - } - - /// A `/` after the condition of `if`, `while`, `for` or `with` starts a regular expression, - /// as after an operator; after a call's parentheses it still divides. - #[test] - fn a_regex_after_a_condition_is_not_read_as_code() { - let renamed = |js: &str| rename_in_calculation(js, "a", "qty"); - assert_eq!(renamed("if (flag) /getField(\"a\")/.test(x); event.value = 1;"), None); - // A quote inside it doesn't start a string that hides the rest of the script. - let quote = "while (i--) /\"/.test(s); event.value = getField(\"a\").value;"; - assert_eq!(renamed(quote), Some(quote.replace("\"a\"", "\"qty\""))); - // The same after the `while` that ends a `do` loop. - let after_do = "do i++; while (i < n) /\"/.test(s); event.value = getField(\"a\").value;"; - assert_eq!(renamed(after_do), Some(after_do.replace("\"a\"", "\"qty\""))); - // After a call, or a method that happens to be called `if`, the `/` divides. - let divides = "event.value = Math.abs(getField(\"b\").value) / 2 + getField(\"a\").value / 3 + o.if(x) / 4 + getField(\"a\").value / 5;"; - assert_eq!(renamed(divides), Some(divides.replace("\"a\"", "\"qty\""))); - } - - /// Scripts are read in one pass: a line of unfinished regular expressions, or calls nested in - /// one another's field lists, don't make a rename read the rest of the script again for each. - #[test] - fn hostile_scripts_are_read_in_one_pass() { - let regexes = format!("x = {} getField(\"a\")", "/[".repeat(500_000)); - assert!(rename_in_calculation(®exes, "a", "b").is_some_and(|js| js.ends_with("getField(\"b\")"))); - let nested = format!("{}\"a\"", "AFSimple_Calculate(\"SUM\", new Array(".repeat(100_000)); - assert_eq!(rename_in_calculation(&nested, "a", "b"), Some(format!("{}\"b\"", "AFSimple_Calculate(\"SUM\", new Array(".repeat(100_000)))); - } - - /// Notation is read through one lexer: field names with escapes, numbers and operators. - #[test] - fn notation_is_read_by_one_lexer() { - let tokens: Vec = notation_tokens("(Unit\\ Price+1.5)*qty_2\\").into_iter().map(|(_, t)| t).collect(); - assert_eq!( - tokens, - [ - Notation::Op('('), - Notation::Name("Unit Price".into()), - Notation::Op('+'), - Notation::Number, - Notation::Op(')'), - Notation::Op('*'), - Notation::Name("qty_2\\".into()) - ] - ); - assert_eq!(notation_js("2 * a\\ b"), "2 * AFMakeNumber(this.getField(\"a b\").value)"); - let values = |name: &str| if name == "Unit Price" { 4.0 } else { 0.5 }; - assert_eq!(eval_notation("(Unit\\ Price + 1.5) * qty", &values), Some(2.75)); - assert_eq!(eval_notation("1.2.3 + a", &values), None, "a number that isn't one"); - assert_eq!(notation_name("2nd + (x)\\"), "\\2nd\\ \\+\\ \\(x\\)\\\\"); - } - - proptest::proptest! { - /// Any field name written in notation reads back as that one name. - #[test] - fn any_name_written_in_notation_reads_back(name in "\\PC{1,12}") { - let written = notation_name(&name); - proptest::prop_assert_eq!(notation_tokens(&written), vec![(0..written.len(), Notation::Name(name))]); - } - - /// Renaming a field in a formula and back gives the formula as it was. - #[test] - fn a_rename_round_trips(old in "\\PC{1,8}", new in "\\PC{1,8}", others in proptest::collection::vec("\\PC{1,8}", 0..4), sum in proptest::bool::ANY) { - proptest::prop_assume!(old != new && !others.contains(&new) && ![&old, &new].into_iter().chain(&others).any(|n| n.contains("VCALC"))); - let names: Vec = std::iter::once(old.clone()).chain(others).collect(); - let js = if sum { - calculate_js(&Calculate::Simple { op: CalcOp::Sum, fields: names }).unwrap() - } else { - calculate_js(&Calculate::Notation(names.iter().map(|n| notation_name(n)).collect::>().join(" * "))).unwrap() - }; - let renamed = rename_in_calculation(&js, &old, &new); - proptest::prop_assert!(renamed.is_some(), "{js}"); - let back = renamed.as_deref().and_then(|r| rename_in_calculation(r, &new, &old)); - proptest::prop_assert_eq!(back, Some(js)); - } - - /// What a formula reads is the same whether it is read from the tab's settings or from - /// the script they are written as. - #[test] - fn a_formula_reads_the_same_fields_as_its_script(names in proptest::collection::vec("\\PC{1,8}", 1..5), sum in proptest::bool::ANY) { - proptest::prop_assume!(!names.iter().any(|n| n.contains("VCALC"))); - let c = if sum { - Calculate::Simple { op: CalcOp::Product, fields: names.clone() } - } else { - Calculate::Notation(names.iter().map(|n| notation_name(n)).collect::>().join(" + ")) - }; - let js = calculate_js(&c).unwrap(); - proptest::prop_assert_eq!(calculation_reads(&js), c.reads()); - proptest::prop_assert_eq!(c.reads(), unique(names)); - } - } - - #[test] - fn a_calculation_reads_each_named_field_once() { - assert_eq!(Calculate::Simple { op: CalcOp::Sum, fields: vec!["a".into(), "b".into(), "a".into()] }.reads(), ["a", "b"]); - assert_eq!(Calculate::Notation("(Unit\\ Price + 2) * qty / qty".into()).reads(), ["Unit Price", "qty"]); - assert_eq!(Calculate::None.reads(), Vec::::new()); - let script = "var t = getField(\"sub\").value; // getField(\"x\")\nevent.value = t + getField(\"tax\").value + getField(\"row\" + 1).value;"; - assert_eq!(calculation_reads(script), ["sub", "tax"], "names built at run time aren't known"); - assert_eq!(calculation_reads("AFSimple_Calculate(\"SUM\", \"a, b\");"), ["a", "b"]); - } - - #[test] - fn numbers_format_like_acrobat() { - let n = |d, s, ng, c: &str, p| Format::Number { decimals: d, sep: s, neg: ng, currency: c.into(), prepend: p }; - assert_eq!(format_value(&n(2, 0, 0, "$", true), "1234.5"), "$1,234.50"); - assert_eq!(format_value(&n(0, 1, 0, "", true), "1234567"), "1234567"); - assert_eq!(format_value(&n(2, 2, 0, " €", false), "1234.5"), "1.234,50 €"); - assert_eq!(format_value(&n(2, 0, 2, "", true), "-12"), "(12.00)"); - assert_eq!(format_value(&n(1, 0, 0, "", true), "-0.25"), "-0.3"); - assert_eq!(format_value(&Format::Percent { decimals: 1, sep: 0 }, "0.125"), "12.5%"); - assert_eq!(make_number("$1,234.50"), Some(1234.5)); - assert_eq!(make_number("1.234,5"), Some(1234.5)); - assert_eq!(make_number("(7)"), Some(-7.0)); - assert_eq!(make_number("abc"), None); - assert_eq!(keystroke(&n(2, 0, 0, "$", true), "Total", "$1,000"), Ok("1000".into())); - assert_eq!( - keystroke(&n(2, 0, 0, "$", true), "Total", "ten"), - Err("The value entered does not match the format of the field [ Total ]".into()) - ); - // Blank input clears a formatted field (#1323); without a format it is text like any other. - assert_eq!(keystroke(&n(2, 0, 0, "$", true), "Total", " \t"), Ok(String::new())); - assert_eq!(keystroke(&Format::None, "Name", " \t"), Ok(" \t".into())); - } - - #[test] - fn dates_times_and_masks() { - let d = Format::Date("mm/dd/yyyy".into()); - assert_eq!(format_value(&d, "1/2/24"), "01/02/2024"); - assert_eq!(format_value(&Format::Date("mmmm d, yyyy".into()), "10/1/2026"), "October 1, 2026"); - assert_eq!(format_value(&Format::Date("dddd, mmm d, yyyy".into()), "10/1/2026"), "Thursday, Oct 1, 2026"); - assert_eq!(format_value(&Format::Date("dd-mmm-yy".into()), "5-Mar-21"), "05-Mar-21", "month names are read"); - assert!(keystroke(&d, "Due", "2/30/2024").is_err(), "no 30 February"); - assert!(keystroke(&d, "Due", "13/1/2024").is_err()); - // Literal digits in the format (a leave form fixing the year as "dd.mm.26"). - let fixed = Format::Date("dd.mm.26".into()); - assert_eq!(keystroke(&fixed, "Von", "15.05.26"), Ok("15.05.26".into())); - assert_eq!(format_value(&fixed, "15.05.26"), "15.05.26"); - assert_eq!(format_value(&fixed, "3.1"), "03.01.26", "the literal may be left out"); - assert!(keystroke(&fixed, "Von", "15.05.27").is_err(), "a different number there is not the literal"); - assert!(keystroke(&fixed, "Von", "32.05.26").is_err()); - assert_eq!(format_value(&Format::Time("h:MM tt".into()), "14:05"), "2:05 pm"); - assert_eq!(format_value(&Format::Time("HH:MM".into()), "2:05 pm"), "14:05"); - assert_eq!(format_value(&Format::Special(2), "5551234567"), "(555) 123-4567"); - assert_eq!(format_value(&Format::Special(3), "123-45-6789"), "123-45-6789"); - assert!(keystroke(&Format::Special(0), "Zip", "1234").is_err()); - assert_eq!(format_value(&Format::Mask("AA-9999".into()), "ab1234"), "ab-1234"); - assert!(keystroke(&Format::Mask("AA-9999".into()), "Code", "a11234").is_err()); - } - - #[test] - fn validation_and_calculation() { - let r = Validate::Range { min: Some(0.0), max: Some(100.0) }; - assert!(validate(&r, "50").is_ok() && validate(&r, "").is_ok()); - assert_eq!(validate(&r, "120"), Err("Invalid value: must be greater than or equal to 0 and less than or equal to 100.".into())); - let vals = |name: &str| -> Vec { - match name { - "a" => vec!["2".into()], - "b" => vec!["$3.50".into()], - "row" => vec!["1".into(), "2".into(), "3".into()], - "Unit Price" => vec!["4".into()], - _ => vec![], - } - }; - let simple = |op, f: &[&str]| calculate(&Calculate::Simple { op, fields: f.iter().map(|s| s.to_string()).collect() }, &vals); - assert_eq!(simple(CalcOp::Sum, &["a", "b"]), Some("5.5".into())); - assert_eq!(simple(CalcOp::Product, &["a", "b"]), Some("7".into())); - assert_eq!(simple(CalcOp::Average, &["row"]), Some("2".into()), "groups count every child"); - assert_eq!(simple(CalcOp::Maximum, &["a", "row"]), Some("3".into())); - assert_eq!(simple(CalcOp::Sum, &["missing"]), Some("0".into())); - assert_eq!(calculate(&Calculate::Notation("(a + b) * 2 - Unit\\ Price / 4".into()), &vals), Some("10".into())); - assert_eq!(calculate(&Calculate::Notation("a +".into()), &vals), None); - } -} +mod tests; // ── push buttons ──────────────────────────────────────────────────────────────────────────── diff --git a/crates/forms/src/af/calc.rs b/crates/forms/src/af/calc.rs new file mode 100644 index 0000000000..b3a1524a4f --- /dev/null +++ b/crates/forms/src/af/calc.rs @@ -0,0 +1,514 @@ +use super::format::{js_number, make_number}; +use super::script::{MAX_NESTING, Parser, js_str}; +use super::*; + +// ── renaming fields in calculations ───────────────────────────────────────────────────────── + +/// A JavaScript token, as far as finding the field names a calculation reads needs. +#[derive(Clone, Debug, PartialEq)] +pub(super) enum Js { + Ident, + /// A string literal, its escapes undone. + Str(String), + Punct(char), + /// A number, a regular expression or a template literal. + Other, +} + +/// A [`Js`] token with its byte range in the script. +type Token = (Range, Js); + +/// Words after which `/` starts a regular expression instead of dividing. +const BEFORE_REGEX: [&str; 13] = ["return", "typeof", "case", "do", "else", "in", "of", "new", "delete", "void", "throw", "instanceof", "yield"]; + +/// Words whose condition, in parentheses, comes before a statement, so a `/` after it starts a +/// regular expression. The `while` that ends a `do` loop counts too: the loop ends at its `)`, so +/// a `/` after it starts the next statement, and can't divide. +const BEFORE_CONDITION: [&str; 4] = ["if", "while", "for", "with"]; + +/// Whether a line ends at `s[j]`: JavaScript ends one at `\n`, `\r`, U+2028 and U+2029. +fn line_ends_at(s: &[u8], j: usize) -> bool { + matches!(s.get(j..), Some([b'\n' | b'\r', ..] | [0xE2, 0x80, 0xA8 | 0xA9, ..])) +} + +/// JavaScript's tokens with their byte ranges, and apart from them the text of each comment +/// (inside its `//`, or its `/*` and `*/`): enough of the language to tell code from strings, +/// comments and regular expressions. The tokens stop where a string, comment or template literal +/// never ends; a comment that never ends runs to the end of the script. +pub(super) fn js_tokens(js: &str) -> (Vec, Vec>) { + let s = js.as_bytes(); + // Whether `s[j]` is part of a word. Bytes past ASCII are parts of identifiers, except + // U+2028 and U+2029, which end a line: a range never ends inside a character. + let word = |j: usize| s.get(j).is_some_and(|&b| b.is_ascii_alphanumeric() || b == b'_' || b == b'$' || !b.is_ascii()) && !line_ends_at(s, j); + let mut out: Vec = Vec::new(); + let mut comments = Vec::new(); + let mut i = 0; + // Where a regular expression that never ended stopped being read: every `/` before it + // divides, so a line of them is read once, not once for each. + let mut divides_until = 0; + // For each `(` still open, whether it opened the condition of `if`, `while`, `for` or + // `with`; and whether the last token is the `)` that closed one. + let mut conditions: Vec = Vec::new(); + let mut after_condition = false; + 'scan: while let Some(&c) = s.get(i) { + let start = i; + let token = match c { + _ if c.is_ascii_whitespace() => { + i += 1; + continue; + } + // U+2028 or U+2029 between tokens ends a line, and is skipped whole: read a byte at a + // time, the rest of it would be read from inside the character. + 0xE2 if line_ends_at(s, i) => { + i += 3; + continue; + } + b'/' if s.get(i + 1) == Some(&b'/') => { + i = (i + 2..s.len()).find(|&j| line_ends_at(s, j)).unwrap_or(s.len()); + comments.push(start + 2..i); + continue; + } + b'/' if s.get(i + 1) == Some(&b'*') => match js.get(i + 2..).and_then(|rest| rest.find("*/")) { + Some(n) => { + comments.push(i + 2..i + 2 + n); + i += n + 4; + continue; + } + None => { + comments.push(i + 2..s.len()); + break 'scan; + } + }, + b'"' | b'\'' => { + let mut p = Parser { s, i, depth: 0 }; + let Some(text) = p.string() else { break 'scan }; + i = p.i; + Js::Str(text) + } + b'`' => { + let mut j = i + 1; + loop { + match s.get(j) { + None => break 'scan, + Some(b'\\') => j += 2, + Some(b'`') => break, + Some(_) => j += 1, + } + } + i = j + 1; + Js::Other + } + b'/' if i >= divides_until && regex_may_follow(js, &out, after_condition) => { + // A regular expression ends at a `/` outside a class; a line break first means it + // was a division after all. + let (mut j, mut class) = (i + 1, false); + let end = loop { + match s.get(j) { + None => break None, + Some(_) if line_ends_at(s, j) => break None, + Some(b'\\') => j += 2, + Some(b'/') if !class => break Some(j), + Some(b) => { + class = match b { + b'[' => true, + b']' => false, + _ => class, + }; + j += 1; + } + } + }; + match end { + Some(e) => { + i = e + 1; + while word(i) { + i += 1; + } + Js::Other + } + None => { + divides_until = j; + i += 1; + Js::Punct('/') + } + } + } + _ if c.is_ascii_digit() => { + while word(i) || s.get(i) == Some(&b'.') { + i += 1; + } + Js::Other + } + _ if word(i) => { + while word(i) { + i += 1; + } + Js::Ident + } + _ => { + i += 1; + Js::Punct(char::from(c)) + } + }; + after_condition = match token { + Js::Punct('(') => { + conditions.push(opens_condition(js, &out)); + false + } + Js::Punct(')') => conditions.pop().unwrap_or(false), + _ => false, + }; + out.push((start..i, token)); + } + (out, comments) +} + +/// Whether a `/` after `tokens` starts a regular expression: after an operator, an opening +/// bracket, a keyword such as `return` or the condition of `if`, `while`, `for` or `with`, not +/// after a value. `after_condition` says the last token is the `)` that ends such a condition. +fn regex_may_follow(js: &str, tokens: &[Token], after_condition: bool) -> bool { + match tokens.last() { + None => true, + Some((range, Js::Ident)) => js.get(range.clone()).is_some_and(|w| BEFORE_REGEX.contains(&w)), + Some((_, Js::Punct(')'))) => after_condition, + Some((_, Js::Punct(c))) => !matches!(c, ']' | '}'), + Some((_, Js::Str(_) | Js::Other)) => false, + } +} + +/// Whether a `(` after `tokens` opens the condition of `if`, `while`, `for` or `with`, and not +/// the arguments of a method that has one of those names (`o.if(x)`). +fn opens_condition(js: &str, tokens: &[Token]) -> bool { + let mut back = tokens.iter().rev(); + let Some((range, Js::Ident)) = back.next() else { return false }; + js.get(range.clone()).is_some_and(|w| BEFORE_CONDITION.contains(&w)) && !matches!(back.next(), Some((_, Js::Punct('.')))) +} + +/// Where a Calculate script keeps its simplified field notation: between `BVCALC` and the +/// `EVCALC` after it, in the first of `comments` that holds both, as Acrobat writes it +/// (`/** BVCALC a + b EVCALC **/`). A string or code that spells the markers is not the notation. +pub(super) fn notation_range(js: &str, comments: &[Range]) -> Option> { + comments.iter().find_map(|comment| { + let text = js.get(comment.clone())?; + let a = text.find("BVCALC")? + 6; + let b = a + text.get(a..)?.find("EVCALC")?; + Some(comment.start + a..comment.start + b) + }) +} + +/// A place where a Calculate script names a field. +#[derive(Clone, Debug, PartialEq)] +enum Site { + /// A string literal holding one name: `getField("a")`, or an item of an + /// `AFSimple_Calculate` list. + Literal(String), + /// An `AFSimple_Calculate` list written as one string of comma-separated names. + Commas(String), + /// A name in the simplified field notation of a `BVCALC` comment. + Notation(String), +} + +impl Site { + fn names(&self) -> Vec<&str> { + match self { + Site::Literal(name) | Site::Notation(name) => vec![name.as_str()], + Site::Commas(list) => list.split(',').map(str::trim).filter(|n| !n.is_empty()).collect(), + } + } + + /// The site written again with field `old` named `new`, or `None` when it doesn't name `old`. + fn renamed(&self, old: &str, new: &str) -> Option { + match self { + Site::Literal(name) => (name == old).then(|| js_str(new)), + Site::Notation(name) => (name == old).then(|| notation_name(new)), + Site::Commas(list) => { + let parts: Vec<&str> = list.split(',').collect(); + parts.iter().any(|p| p.trim() == old).then(|| { + // The spaces around each name stay as they were. + let parts: Vec = parts.iter().map(|p| if p.trim() == old { p.replacen(old, new, 1) } else { (*p).to_owned() }).collect(); + js_str(&parts.join(",")) + }) + } + } + } +} + +/// Where a Calculate script names fields, in order: the simplified field notation in its comment +/// (see [`notation_range`]; [`parse_calculate`] reads it there too), the field list of +/// `AFSimple_Calculate`, and every `getField` whose whole argument is one string literal. +/// A name built at run time (`getField("row" + i)`) is not a site. +fn calculation_sites(js: &str) -> Vec<(Range, Site)> { + let mut sites = Vec::new(); + let (tokens, comments) = js_tokens(js); + if let Some(at) = notation_range(js, &comments) + && let Some(expr) = js.get(at.clone()) + { + for (range, token) in notation_tokens(expr) { + if let Notation::Name(name) = token { + sites.push((at.start + range.start..at.start + range.end, Site::Notation(name))); + } + } + } + let ident = |k: usize, word: &str| tokens.get(k).is_some_and(|(range, t)| *t == Js::Ident && js.get(range.clone()) == Some(word)); + let punct = |k: usize, c: char| tokens.get(k).is_some_and(|(_, t)| *t == Js::Punct(c)); + let string = |k: usize| match tokens.get(k) { + Some((range, Js::Str(text))) => Some((range.clone(), text.clone())), + _ => None, + }; + for k in 0..tokens.len() { + if ident(k, "getField") + && punct(k + 1, '(') + && punct(k + 3, ')') + && let Some((range, name)) = string(k + 2) + { + sites.push((range, Site::Literal(name))); + } + if !(ident(k, "AFSimple_Calculate") && punct(k + 1, '(') && string(k + 2).is_some() && punct(k + 3, ',')) { + continue; + } + if let Some((range, list)) = string(k + 4) { + sites.push((range, Site::Commas(list))); + continue; + } + // `new Array("a", "b")` or `["a", "b"]`: its items, up to the first that isn't a string. + // Nothing else is read, so no token is read for two calls. + let first = if punct(k + 4, '[') { + k + 5 + } else if ident(k + 4, "new") && ident(k + 5, "Array") && punct(k + 6, '(') { + k + 7 + } else { + continue; + }; + for (range, token) in tokens.iter().skip(first) { + match token { + Js::Str(name) => sites.push((range.clone(), Site::Literal(name.clone()))), + Js::Punct(',') => {} + _ => break, + } + } + } + sites.sort_by_key(|(range, _)| range.start); + sites +} + +/// A Calculate script with field `old` named `new` everywhere it reads it (see +/// [`calculation_sites`]), the rest kept as written; `None` when it doesn't read `old`. Also +/// `None` when the new name would read as more than a name there (one holding `EVCALC` would end +/// the notation early), so a script is never left meaning something else. +pub fn rename_in_calculation(js: &str, old: &str, new: &str) -> Option { + let sites = calculation_sites(js); + let edits: Vec<(Range, String)> = sites.iter().filter_map(|(range, site)| Some((range.clone(), site.renamed(old, new)?))).collect(); + if edits.is_empty() { + return None; + } + let out = splice(js, edits); + let expected: Vec<&str> = sites.iter().flat_map(|(_, site)| site.names()).map(|n| if n == old { new } else { n }).collect(); + let written = calculation_sites(&out); + let got: Vec<&str> = written.iter().flat_map(|(_, site)| site.names()).collect(); + (got == expected).then_some(out) +} + +/// The fields a Calculate script reads by name (see [`calculation_sites`]), each once, in the +/// order it first names them. +pub fn calculation_reads(js: &str) -> Vec { + let sites = calculation_sites(js); + unique(sites.iter().flat_map(|(_, site)| site.names()).map(str::to_owned)) +} + +impl Calculate { + /// The fields it reads, each once, in the order it first names them. A name also stands for + /// the fields under it (`row` for `row.0` and `row.1`), as [`calculate`] reads them. + pub fn reads(&self) -> Vec { + match self { + Calculate::None => Vec::new(), + Calculate::Simple { fields, .. } => unique(fields.iter().cloned()), + Calculate::Notation(expr) => unique(notation_tokens(expr).into_iter().filter_map(|(_, token)| match token { + Notation::Name(name) => Some(name), + _ => None, + })), + } + } +} + +/// `names` without repeats, in order. +pub(super) fn unique(names: impl IntoIterator) -> Vec { + let mut seen = std::collections::HashSet::new(); + names.into_iter().filter(|n| seen.insert(n.clone())).collect() +} + +/// A token of simplified field notation. +#[derive(Clone, Debug, PartialEq)] +pub(super) enum Notation { + /// A number, as written. + Number, + /// `+ - * / ( )`. + Op(char), + /// A field name, its `\` escapes undone. + Name(String), +} + +/// Simplified field notation's tokens, each with its byte range in `src`. Whitespace separates +/// them, and `\` escapes a space or an operator inside a name, as Acrobat writes them. +pub(super) fn notation_tokens(src: &str) -> Vec<(Range, Notation)> { + let breaks = |c: char| c.is_whitespace() || "+-*/()".contains(c); + let mut out = Vec::new(); + let mut cs = src.char_indices().peekable(); + while let Some(&(start, c)) = cs.peek() { + let mut end = start + c.len_utf8(); + if c.is_whitespace() { + cs.next(); + } else if breaks(c) { + cs.next(); + out.push((start..end, Notation::Op(c))); + } else if c.is_ascii_digit() || c == '.' { + while let Some((i, d)) = cs.next_if(|(_, d)| d.is_ascii_digit() || *d == '.') { + end = i + d.len_utf8(); + } + out.push((start..end, Notation::Number)); + } else { + let mut name = String::new(); + while let Some((i, n)) = cs.next_if(|(_, n)| !breaks(*n)) { + end = i + n.len_utf8(); + if n == '\\' + && let Some((j, next)) = cs.next() + { + end = j + next.len_utf8(); + name.push(next); + } else { + name.push(n); + } + } + out.push((start..end, Notation::Name(name))); + } + } + out +} + +/// A field name as simplified field notation writes it: `\` before a space, an operator or a +/// `\`, and before a first character that would start a number. +pub(super) fn notation_name(name: &str) -> String { + let mut out = String::new(); + for (i, c) in name.chars().enumerate() { + if c.is_whitespace() || "+-*/()\\".contains(c) || (i == 0 && (c.is_ascii_digit() || c == '.')) { + out.push('\\'); + } + out.push(c); + } + out +} + +/// `src` with each range replaced. The ranges come in order and don't overlap; one that does +/// is left out rather than written over another. +fn splice(src: &str, edits: impl IntoIterator, String)>) -> String { + let mut out = String::with_capacity(src.len()); + let mut done = 0; + for (range, with) in edits { + let Some(kept) = src.get(done..range.start).filter(|_| range.start <= range.end) else { continue }; + out.push_str(kept); + out.push_str(&with); + done = range.end; + } + out.push_str(src.get(done..).unwrap_or_default()); + out +} + +/// Simplified field notation as the JavaScript Acrobat writes after its `BVCALC` comment: +/// each field name becomes `AFMakeNumber(this.getField("name").value)`, the rest stays. +pub fn notation_js(expr: &str) -> String { + let names = notation_tokens(expr).into_iter().filter_map(|(range, t)| match t { + Notation::Name(name) => Some((range, format!("AFMakeNumber(this.getField({}).value)", js_str(&name)))), + _ => None, + }); + splice(expr, names) +} + +/// Evaluate simplified field notation: + - * / and parentheses over numbers and field names. +pub(super) fn eval_notation(src: &str, value_of: &dyn Fn(&str) -> f64) -> Option { + #[derive(Debug, Clone, PartialEq)] + enum T { + N(f64), + Op(char), + } + let toks = notation_tokens(src) + .into_iter() + .map(|(range, t)| match t { + Notation::Number => src.get(range)?.parse().ok().map(T::N), + Notation::Op(c) => Some(T::Op(c)), + Notation::Name(name) => Some(T::N(value_of(&name))), + }) + .collect::>>()?; + // Recursive descent, `d` levels deep (at most MAX_NESTING). + fn expr(t: &[T], i: &mut usize, d: usize) -> Option { + let mut v = term(t, i, d)?; + while let Some(T::Op(o @ ('+' | '-'))) = t.get(*i) { + *i += 1; + let r = term(t, i, d)?; + v = if *o == '+' { v + r } else { v - r }; + } + Some(v) + } + fn term(t: &[T], i: &mut usize, d: usize) -> Option { + let mut v = factor(t, i, d)?; + while let Some(T::Op(o @ ('*' | '/'))) = t.get(*i) { + *i += 1; + let r = factor(t, i, d)?; + v = if *o == '*' { v * r } else { v / r }; + } + Some(v) + } + fn factor(t: &[T], i: &mut usize, d: usize) -> Option { + if d > MAX_NESTING { + return None; + } + match t.get(*i)? { + T::N(n) => { + *i += 1; + Some(*n) + } + T::Op('-') => { + *i += 1; + factor(t, i, d + 1).map(|v| -v) + } + T::Op('(') => { + *i += 1; + let v = expr(t, i, d + 1)?; + (t.get(*i) == Some(&T::Op(')'))).then(|| *i += 1)?; + Some(v) + } + _ => None, + } + } + let mut i = 0; + let v = expr(&toks, &mut i, 0)?; + (i == toks.len() && v.is_finite()).then_some(v) +} + +/// The Calculate event: the new value, given the current values of the other fields +/// (`values(name)` returns every field named `name` or below it, for groups). +pub fn calculate(c: &Calculate, values: &dyn Fn(&str) -> Vec) -> Option { + let num = |s: &String| make_number(s).unwrap_or(0.0); + match c { + Calculate::None => None, + Calculate::Simple { op, fields } => { + let xs: Vec = fields.iter().flat_map(|f| values(f)).map(|v| num(&v)).collect(); + let v = match op { + CalcOp::Sum => xs.iter().sum(), + CalcOp::Product => xs.iter().product(), + CalcOp::Average => { + if xs.is_empty() { + 0.0 + } else { + xs.iter().sum::() / xs.len() as f64 + } + } + CalcOp::Minimum => xs.iter().copied().fold(f64::INFINITY, f64::min), + CalcOp::Maximum => xs.iter().copied().fold(f64::NEG_INFINITY, f64::max), + }; + Some(if v.is_finite() { js_number(v) } else { "0".into() }) + } + Calculate::Notation(expr) => { + let v = eval_notation(expr, &|name| values(name).first().map(num).unwrap_or(0.0))?; + Some(js_number(v)) + } + } +} diff --git a/crates/forms/src/af/format.rs b/crates/forms/src/af/format.rs new file mode 100644 index 0000000000..df4720bec7 --- /dev/null +++ b/crates/forms/src/af/format.rs @@ -0,0 +1,533 @@ +use super::*; + +/// Acrobat's AFDate_Format(n) / AFTime_Format(n) presets. +pub const DATE_PRESETS: [&str; 14] = [ + "m/d", + "m/d/yy", + "mm/dd/yy", + "mm/yy", + "d-mmm", + "d-mmm-yy", + "dd-mmm-yy", + "yy-mm-dd", + "mmm-yy", + "mmmm-yy", + "mmm d, yyyy", + "mmmm d, yyyy", + "m/d/yy h:MM tt", + "m/d/yy HH:MM", +]; + +pub const TIME_PRESETS: [&str; 4] = ["HH:MM", "h:MM tt", "HH:MM:ss", "h:MM:ss tt"]; + +// ── running them ──────────────────────────────────────────────────────────────────────────── + +/// AFMakeNumber: a number from typed text (currency symbols, group separators, spaces and a +/// trailing % ignored; a comma decimal separator accepted). +pub fn make_number(s: &str) -> Option { + let t: String = s.trim().chars().filter(|c| !c.is_whitespace() && !matches!(c, '$' | '€' | '£' | '¥' | '\'' | '%')).collect(); + if t.is_empty() { + return None; + } + let neg = t.starts_with('(') && t.ends_with(')'); + let t = t.trim_start_matches('(').trim_end_matches(')'); + // "1.234,56" or "1234,56": comma is the decimal separator when it comes last. + let normalized = match (t.rfind(','), t.rfind('.')) { + (Some(c), Some(d)) if c > d => t.replace('.', "").replace(',', "."), + (Some(c), None) if t.len() - c - 1 != 3 => t.replace(',', "."), + _ => t.replace(',', ""), + }; + let v: f64 = normalized.parse().ok()?; + v.is_finite().then_some(if neg { -v } else { v }) +} + +/// A number as JavaScript prints it (integers without a fraction). +pub fn js_number(v: f64) -> String { + if v.fract() == 0.0 && v.abs() < 1e15 { format!("{}", v as i64) } else { format!("{v}") } +} + +fn group(int: &str, sep: char) -> String { + let mut out = String::new(); + for (i, c) in int.chars().enumerate() { + if i > 0 && (int.len() - i).is_multiple_of(3) { + out.push(sep); + } + out.push(c); + } + out +} + +/// A number with `decimals` places in separator style `sep` (0 "1,234.56", 1 "1234.56", +/// 2 "1.234,56", 3 "1234,56", 4 "1'234.56"). +fn number_text(v: f64, decimals: u8, sep: u8) -> String { + // Round half away from zero, as JavaScript's toFixed does for these values. + let p = 10f64.powi(i32::from(decimals)); + let scaled = v.abs() * p; + // Past 2^53 every value is already whole, and scaling could overflow to infinity. + let r = if scaled.is_finite() { scaled.round() / p } else { v.abs() }; + let s = format!("{:.*}", decimals as usize, r); + let (int, frac) = s.split_once('.').map_or((s.as_str(), ""), |(a, b)| (a, b)); + let (g, d) = match sep { + 1 => (None, '.'), + 2 => (Some('.'), ','), + 3 => (None, ','), + 4 => (Some('\''), '.'), + _ => (Some(','), '.'), + }; + let mut out = match g { + Some(c) => group(int, c), + None => int.to_string(), + }; + if !frac.is_empty() { + out.push(d); + out.push_str(frac); + } + out +} + +const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]; + +const DAYS: [&str; 7] = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; + +/// A date and time read from text. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct DateTime { + pub y: i32, + pub m: u32, + pub d: u32, + pub hh: u32, + pub mm: u32, + pub ss: u32, +} + +fn days_in(y: i32, m: u32) -> u32 { + match m { + 2 if (y % 4 == 0 && y % 100 != 0) || y % 400 == 0 => 29, + 2 => 28, + 4 | 6 | 9 | 11 => 30, + _ => 31, + } +} + +fn weekday(y: i32, m: u32, d: u32) -> usize { + // Sakamoto's method, in i64 so no year or day can overflow it. + const T: [i64; 12] = [0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4]; + let y = i64::from(y) - i64::from(m < 3); + let t = T.get((m as usize).wrapping_sub(1)).copied().unwrap_or(0); + (y + y / 4 - y / 100 + y / 400 + t + i64::from(d)).rem_euclid(7) as usize +} + +/// One field of a date format, in the order the format has them. +struct Part { + /// `'y'`, `'m'`, `'d'`, `'H'`, `'M'` or `'s'`; a run of literal digits (the fixed year in + /// "dd.mm.26") is `'#'`. + kind: char, + /// The value of a `'#'` run; 0 for the others. + literal: u32, + /// The digits the field takes when typed run together with the next one: 4 for "yyyy", 2 for + /// "yy", "mm", "dd", "HH", "MM" and "ss". 0 when the width varies ("m", "d") or the field is + /// a name ("mmm"). + width: usize, + /// The next field follows with nothing between them ("yyyymmdd"), so one run of typed digits + /// can hold both. + joined: bool, +} + +/// The fields of a format (year, month, day, hours, minutes, seconds and literal digits), in +/// order. +fn order(fmt: &str) -> Vec { + let mut out: Vec = Vec::new(); + // Weekday names (ddd, dddd) are shown, not typed: leave them out. + let mut cleaned = fmt.replace("dddd", " "); + cleaned = cleaned.replace("ddd", " "); + let mut prev = ' '; + // The letter of the last field's current run and its length so far. + let mut run: Option<(char, usize)> = None; + let fixed = |k: char, n: usize| match (k, n) { + ('y', 2 | 4) | ('m' | 'd' | 'H' | 'M' | 's', 2) => n, + _ => 0, + }; + for c in cleaned.chars() { + let continues = run.is_some_and(|(r, _)| r == c); + if let Some((r, n)) = run + && !continues + { + // The run ended; it was the last field's width. + if let Some(last) = out.last_mut().filter(|p| p.kind != '#') { + last.width = fixed(if r == 'h' { 'H' } else { r }, n); + } + run = None; + } + if let Some(digit) = c.to_digit(10) { + match out.last_mut() { + Some(p) if p.kind == '#' && prev.is_ascii_digit() => p.literal = p.literal.saturating_mul(10).saturating_add(digit), + _ => out.push(Part { kind: '#', literal: digit, width: 0, joined: false }), + } + prev = c; + continue; + } + let k = match c { + 'y' => 'y', + 'm' => 'm', + 'd' => 'd', + 'H' | 'h' => 'H', + 'M' => 'M', + 's' => 's', + _ => { + prev = c; + continue; + } + }; + if continues { + run = run.map(|(r, n)| (r, n.saturating_add(1))); + } else { + // A field starting right where the last one's letters stopped is joined to it. + let adjacent = matches!(prev, 'y' | 'm' | 'd' | 'H' | 'h' | 'M' | 's'); + if out.last().map(|p| p.kind) != Some(k) { + if let Some(last) = out.last_mut().filter(|p| p.kind != '#') { + last.joined = adjacent; + } + out.push(Part { kind: k, literal: 0, width: 0, joined: false }); + } + run = Some((c, 1)); + } + prev = c; + } + if let (Some((r, n)), Some(last)) = (run, out.last_mut().filter(|p| p.kind != '#')) { + last.width = fixed(if r == 'h' { 'H' } else { r }, n); + } + out +} + +/// Parse typed text against a date/time format (AFDate_KeystrokeEx's leniency: any +/// separators, month names, two-digit years). `None` when it isn't a valid date. +pub fn parse_date(text: &str, fmt: &str) -> Option { + let t = text.trim(); + if t.is_empty() { + return None; + } + let lower = t.to_lowercase(); + // A bare trailing "p" ("5:30p") only counts when it follows a digit, not a word like "Sep". + let bare_p = lower.strip_suffix('p').is_some_and(|rest| rest.trim_end().ends_with(|c: char| c.is_ascii_digit())); + let pm = lower.contains("pm") || bare_p; + let bare_a = lower.strip_suffix('a').is_some_and(|rest| rest.trim_end().ends_with(|c: char| c.is_ascii_digit())); + let am = lower.contains("am") || bare_a; + // Tokens: runs of digits (kept as text, so a run can be split between fields typed without + // separators) and month names. + let mut nums: VecDeque = VecDeque::new(); + let mut month_name: Option = None; + let mut cur = String::new(); + let flush = |cur: &mut String, nums: &mut VecDeque, month: &mut Option| { + if cur.is_empty() { + return; + } + if cur.starts_with(|c: char| c.is_ascii_digit()) { + nums.push_back(std::mem::take(cur)); + } else if cur.len() >= 3 + && let Some(i) = MONTHS.iter().position(|m| m.to_lowercase().starts_with(&cur.to_lowercase())) + { + *month = Some(i as u32 + 1); + } + cur.clear(); + }; + for c in t.chars() { + // Digits and letters form separate tokens ("5Mar" is 5 and Mar). + let same_kind = cur.is_empty() || cur.chars().next().is_some_and(|x| x.is_ascii_digit()) == c.is_ascii_digit(); + if c.is_alphanumeric() && same_kind { + cur.push(c); + } else { + flush(&mut cur, &mut nums, &mut month_name); + if c.is_alphanumeric() { + cur.push(c); + } + } + } + flush(&mut cur, &mut nums, &mut month_name); + let mut dt = DateTime { y: 0, m: 1, d: 1, hh: 0, mm: 0, ss: 0 }; + // The next number for a field. A field joined to the next one in the format ("yyyymmdd") + // takes only its own width from a longer run of digits and leaves the rest for the next. + // A run typed on its own is split only when fewer runs were typed than the fields of its kind + // still to fill (`need`, this one included: date fields for a date field, time fields for a + // time field, since times may be left out), and only when its rest can fill the next field + // (`next_width`): with separators each run is one field's number, so "2026-10-18" keeps its + // four-digit year in a yymmdd or yymmddHHMM field, and "2026 10 018" is the 18th. What is left + // of a split run (`tail`: the front of `nums` is one) goes on being split along the fields + // written side by side, and must fill its last field exactly, so "202610018" leaves "018" for + // dd and is refused. `None` when there is no number left, it doesn't fit a u32, or such a + // rest has the wrong width. + let next_number = |nums: &mut VecDeque, tail: &mut bool, part: &Part, need: usize, next_width: usize| -> Option { + let run = nums.pop_front()?; + let was_tail = std::mem::replace(tail, false); + let short = nums.len() < need.saturating_sub(1); + let digits = match (run.get(..part.width), run.get(part.width..)) { + (Some(head), Some(rest)) if part.joined && part.width > 0 && !rest.is_empty() && (was_tail || (short && rest.len() >= next_width)) => { + nums.push_front(rest.to_string()); + *tail = true; + head + } + _ if was_tail && part.width > 0 && run.len() != part.width => return None, + _ => run.as_str(), + }; + digits.parse::().ok() + }; + let parts = order(fmt); + let takes_number = |p: &Part| matches!(p.kind, 'y' | 'd' | 'H' | 'M' | 's') || (p.kind == 'm' && month_name.is_none()); + let is_time = |p: &Part| matches!(p.kind, 'H' | 'M' | 's'); + // The fields of each kind still to fill: [date, time]. + let mut wanted = [parts.iter().filter(|p| takes_number(p) && !is_time(p)).count(), parts.iter().filter(|p| is_time(p)).count()]; + let mut tail = false; + let mut have_y = false; + for (i, part) in parts.iter().enumerate() { + let kind = usize::from(is_time(part)); + let need = wanted.get(kind).copied().unwrap_or(0); + let next_width = parts.get(i + 1).map_or(0, |p| p.width); + if takes_number(part) + && let Some(left) = wanted.get_mut(kind) + { + *left = left.saturating_sub(1); + } + let take = |nums: &mut VecDeque, tail: &mut bool| next_number(nums, tail, part, need, next_width); + match part.kind { + // Literal digits in the format may be typed or left out ("15.05.26" or "15.05" + // for "dd.mm.26"); a different number there is left over and rejected below. + '#' if nums.front().and_then(|n| n.parse::().ok()) == Some(part.literal) => { + nums.pop_front(); + tail = false; + } + 'm' => { + dt.m = match month_name { + Some(m) => m, + None => take(&mut nums, &mut tail)?, + } + } + 'd' => dt.d = take(&mut nums, &mut tail)?, + 'y' => { + let y = i32::try_from(take(&mut nums, &mut tail)?).ok()?; + dt.y = if y < 100 { if y < 50 { 2000 + y } else { 1900 + y } } else { y }; + have_y = true; + } + // Times may be left out; a number that is there must be readable. + 'H' => dt.hh = if nums.is_empty() { 0 } else { take(&mut nums, &mut tail)? }, + 'M' => dt.mm = if nums.is_empty() { 0 } else { take(&mut nums, &mut tail)? }, + 's' => dt.ss = if nums.is_empty() { 0 } else { take(&mut nums, &mut tail)? }, + _ => {} + } + } + if !nums.is_empty() { + return None; + } + if !have_y { + dt.y = 2000; + } + if pm && dt.hh < 12 { + dt.hh += 12; + } + if am && dt.hh == 12 { + dt.hh = 0; + } + let has_date = fmt.contains(['y', 'm', 'd']); + if has_date && (!(1..=12).contains(&dt.m) || dt.d == 0 || dt.d > days_in(dt.y, dt.m)) { + return None; + } + if dt.hh > 23 || dt.mm > 59 || dt.ss > 59 { + return None; + } + Some(dt) +} + +/// Format a date/time with Acrobat's date format letters. +pub fn format_date(dt: DateTime, fmt: &str) -> String { + let chars: Vec = fmt.chars().collect(); + let mut out = String::new(); + let mut i = 0; + while i < chars.len() { + let c = chars[i]; + let mut n = 1; + while i + n < chars.len() && chars[i + n] == c { + n += 1; + } + let h12 = if dt.hh.is_multiple_of(12) { 12 } else { dt.hh % 12 }; + match (c, n) { + ('y', 4..) => { + let _ = write!(out, "{:04}", dt.y); + } + ('y', _) => { + let _ = write!(out, "{:02}", dt.y.rem_euclid(100)); + } + ('m', 3..) => match MONTHS.get((dt.m as usize).wrapping_sub(1)) { + Some(name) if n >= 4 => out.push_str(name), + Some(name) => out.push_str(name.get(..3).unwrap_or(name)), + None => { + let _ = write!(out, "{}", dt.m); + } + }, + ('m', 2) => { + let _ = write!(out, "{:02}", dt.m); + } + ('m', _) => { + let _ = write!(out, "{}", dt.m); + } + ('d', 3..) => { + let name = DAYS.get(weekday(dt.y, dt.m, dt.d)).copied().unwrap_or_default(); + out.push_str(if n >= 4 { name } else { name.get(..3).unwrap_or(name) }); + } + ('d', 2) => { + let _ = write!(out, "{:02}", dt.d); + } + ('d', _) => { + let _ = write!(out, "{}", dt.d); + } + ('H', 2) => { + let _ = write!(out, "{:02}", dt.hh); + } + ('H', _) => { + let _ = write!(out, "{}", dt.hh); + } + ('h', 2) => { + let _ = write!(out, "{h12:02}"); + } + ('h', _) => { + let _ = write!(out, "{h12}"); + } + ('M', 2) => { + let _ = write!(out, "{:02}", dt.mm); + } + ('M', _) => { + let _ = write!(out, "{}", dt.mm); + } + ('s', 2) => { + let _ = write!(out, "{:02}", dt.ss); + } + ('s', _) => { + let _ = write!(out, "{}", dt.ss); + } + ('t', 2) => out.push_str(if dt.hh < 12 { "am" } else { "pm" }), + ('t', _) => out.push(if dt.hh < 12 { 'a' } else { 'p' }), + (c, n) => out.extend(std::iter::repeat_n(c, n)), + } + i += n; + } + out +} + +const SPECIAL_MASKS: [&str; 4] = ["99999", "99999-9999", "(999) 999-9999", "999-99-9999"]; + +fn mask_fits(mask: &str, text: &str) -> bool { + let t: Vec = text.chars().collect(); + let m: Vec = mask.chars().collect(); + t.len() == m.len() + && m.iter().zip(&t).all(|(mc, tc)| match mc { + '9' => tc.is_ascii_digit(), + 'A' => tc.is_alphabetic(), + 'O' => tc.is_alphanumeric(), + 'X' => true, + other => other == tc, + }) +} + +/// Fill a mask from the typed characters that fit its slots. +fn apply_mask(mask: &str, text: &str) -> Option { + let mut src = text.chars().filter(|c| c.is_alphanumeric()); + let mut out = String::new(); + for mc in mask.chars() { + match mc { + '9' | 'A' | 'O' | 'X' => { + let c = src.next()?; + let ok = match mc { + '9' => c.is_ascii_digit(), + 'A' => c.is_alphabetic(), + _ => true, + }; + if !ok { + return None; + } + out.push(c); + } + other => out.push(other), + } + } + src.next().is_none().then_some(out) +} + +/// The text shown for a value (the Format event). +pub fn format_value(f: &Format, value: &str) -> String { + if value.is_empty() { + return String::new(); + } + match f { + Format::None => value.to_string(), + Format::Number { decimals, sep, neg, currency, prepend } => { + let Some(v) = make_number(value) else { return value.to_string() }; + let body = number_text(v, *decimals, *sep); + let body = match (currency.is_empty(), *prepend) { + (true, _) => body, + (false, true) => format!("{currency}{body}"), + (false, false) => format!("{body}{currency}"), + }; + match (v < 0.0, neg) { + (false, _) => body, + // Styles 2 and 3 use parentheses (3 is also red, which the appearance can't + // know here); 0 and 1 a minus sign. + (true, 2 | 3) => format!("({body})"), + (true, _) => format!("-{body}"), + } + } + Format::Percent { decimals, sep } => match make_number(value) { + Some(v) => format!( + "{}%", + if v < 0.0 { format!("-{}", number_text(v * 100.0, *decimals, *sep)) } else { number_text(v * 100.0, *decimals, *sep) } + ), + None => value.to_string(), + }, + Format::Date(fmt) | Format::Time(fmt) => parse_date(value, fmt).map_or_else(|| value.to_string(), |dt| format_date(dt, fmt)), + Format::Special(n) => apply_mask(SPECIAL_MASKS[(*n).min(3) as usize], value).unwrap_or_else(|| value.to_string()), + Format::Mask(m) => apply_mask(m, value).unwrap_or_else(|| value.to_string()), + } +} + +/// The Keystroke event on commit: does the value fit the format? Returns the value to store +/// (numbers are normalised), or Acrobat's message. +pub fn keystroke(f: &Format, field: &str, value: &str) -> Result { + let bad = || format!("The value entered does not match the format of the field [ {field} ]"); + // Blank input clears a formatted field; without a format, whitespace is text like any other. + if *f != Format::None && value.trim().is_empty() { + return Ok(String::new()); + } + match f { + Format::None => Ok(value.to_string()), + Format::Number { .. } => make_number(value).map(js_number).ok_or_else(bad), + Format::Percent { .. } => { + // Typed "12.5%" or "12.5" both mean 0.125 once stored? Acrobat stores the number + // as typed and shows it ×100: keep the typed number. + make_number(value).map(js_number).ok_or_else(bad) + } + Format::Date(fmt) | Format::Time(fmt) => parse_date(value, fmt) + .map(|_| value.trim().to_string()) + .ok_or_else(|| format!("Invalid date/time: please ensure that the date/time exists. Field [ {field} ] should match format {fmt}")), + Format::Special(n) => { + let m = SPECIAL_MASKS[(*n).min(3) as usize]; + apply_mask(m, value).filter(|s| mask_fits(m, s)).map(|_| value.trim().to_string()).ok_or_else(bad) + } + Format::Mask(m) => apply_mask(m, value).map(|_| value.trim().to_string()).ok_or_else(bad), + } +} + +/// The Validate event: `Err` with Acrobat's message when the value is out of range. +pub fn validate(v: &Validate, value: &str) -> Result<(), String> { + let Validate::Range { min, max } = v else { return Ok(()) }; + if value.trim().is_empty() { + return Ok(()); + } + let Some(x) = make_number(value) else { return Ok(()) }; + let fmt = |n: f64| js_number(n); + let ok = min.is_none_or(|m| x >= m) && max.is_none_or(|m| x <= m); + if ok { + return Ok(()); + } + Err(match (min, max) { + (Some(a), Some(b)) => format!("Invalid value: must be greater than or equal to {} and less than or equal to {}.", fmt(*a), fmt(*b)), + (Some(a), None) => format!("Invalid value: must be greater than or equal to {}.", fmt(*a)), + (None, Some(b)) => format!("Invalid value: must be less than or equal to {}.", fmt(*b)), + (None, None) => String::new(), + }) +} diff --git a/crates/forms/src/af/script.rs b/crates/forms/src/af/script.rs new file mode 100644 index 0000000000..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/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 215bd095fc..ce89fad806 100644 --- a/crates/forms/src/author.rs +++ b/crates/forms/src/author.rs @@ -10,6 +10,17 @@ use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream}; use crate::{Field, FieldKind, FormError, Widget, appearance, fields, flags, page_refs}; +mod edit; +mod lifecycle; +mod look; +mod tree; + +pub use edit::set_props; +pub use lifecycle::{add_field, delete_field, delete_field_object, duplicate_field}; +pub(crate) use look::button_appearance; +pub use look::{BorderStyle, CheckStyle, FieldFont, Look, LookPatch, check_style, look, redraw_field, set_button_icon}; +pub(crate) use tree::{ensure_form, form_fonts_as_objects}; + /// The smallest width and height of a field's widget, in points: a smaller one is refused. pub const MIN_FIELD_SIDE: f64 = 4.0; @@ -113,1599 +124,3 @@ fn invalid(m: impl Into) -> Result { fn rgb(c: [f64; 3]) -> Object { Object::Array(c.iter().map(|v| Object::Real(*v)).collect()) } - -/// The AcroForm dictionary's reference, creating the form (with `/Helv` in `/DR`) if needed. -pub(crate) fn ensure_form(doc: &mut Document) -> Result { - let root = doc.root().ok_or(FormError::NoForm)?; - let existing = doc.get(root).as_dict().and_then(|d| d.get(b"AcroForm").cloned()); - let r = match existing { - Some(Object::Ref(r)) if doc.get(r).as_dict().is_some() => r, - other => { - let d = other.and_then(|o| o.as_dict().cloned()).unwrap_or_default(); - let r = doc.add(Object::Dict(d)); - doc.update_dict(root, |c| c.set(b"AcroForm".to_vec(), Object::Ref(r)))?; - r - } - }; - let mut af = doc.get(r).as_dict().cloned().unwrap_or_default(); - if !af.contains(b"Fields") { - af.set(b"Fields".to_vec(), Object::Array(Vec::new())); - } - if !af.contains(b"DA") { - af.set(b"DA".to_vec(), PdfString::literal(b"/Helv 0 Tf 0 g".to_vec())); - } - let (dr, mut fonts) = form_fonts(doc, &af); - let mut added = inline_fonts_as_objects(doc, &mut fonts); - // Acrobat's resource names for the standard fonts its Appearance tab offers. - for (name, base) in [("Helv", "Helvetica"), ("TiRo", "Times-Roman"), ("Cour", "Courier")] { - if fonts.contains(name.as_bytes()) { - continue; - } - let mut f = Dict::new(); - f.set(b"Type".to_vec(), Object::name("Font")); - f.set(b"Subtype".to_vec(), Object::name("Type1")); - f.set(b"BaseFont".to_vec(), Object::name(base)); - f.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding")); - let r = doc.add(Object::Dict(f)); - fonts.set(name.as_bytes().to_vec(), Object::Ref(r)); - added = true; - } - if added { - store_form_fonts(doc, &mut af, dr, fonts); - } - doc.set(r, Object::Dict(af)); - Ok(r) -} - -/// The form dictionary's `/DR` and its `/Font` dictionary, resolved (empty where missing). -fn form_fonts(doc: &Document, af: &Dict) -> (Dict, Dict) { - let dr = af.get(b"DR").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default(); - let fonts = dr.get(b"Font").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default(); - (dr, fonts) -} - -/// Each /DR font its own object: Acrobat, redrawing a text field after it is typed in, shows -/// nothing when the font is a dictionary written inline in /DR (#3441). Fonts written inline -/// (by an earlier save, or another writer) become objects saying the same; a font that is an -/// object already, or an entry that isn't a dictionary, is left alone. Whether any changed. -fn inline_fonts_as_objects(doc: &mut Document, fonts: &mut Dict) -> bool { - let inline: Vec<(Vec, Dict)> = fonts.iter().filter_map(|(k, v)| Some((k.clone(), v.as_dict()?.clone()))).collect(); - let changed = !inline.is_empty(); - for (name, font) in inline { - let r = doc.add(Object::Dict(font)); - fonts.set(name, Object::Ref(r)); - } - changed -} - -/// Write `fonts` back as the `/DR /Font` of the form dictionary `af`. An indirect `/DR` or -/// `/Font` stays the object it is. -fn store_form_fonts(doc: &mut Document, af: &mut Dict, mut dr: Dict, fonts: Dict) { - match dr.get(b"Font").and_then(Object::as_ref) { - Some(fr) => doc.set(fr, Object::Dict(fonts)), - None => dr.set(b"Font".to_vec(), Object::Dict(fonts)), - } - match af.get(b"DR").and_then(Object::as_ref) { - Some(dr_ref) => doc.set(dr_ref, Object::Dict(dr)), - None => af.set(b"DR".to_vec(), Object::Dict(dr)), - } -} - -/// Filling a field: the form's inline `/DR` fonts become objects (see -/// [`inline_fonts_as_objects`]), so a form an earlier version of PdfCraft made works in Acrobat -/// once it has been filled here too (#3441). Only the font storage changes; nothing is added. -/// Not in a form a signature covers: there the file stays as the signer saw it, apart from the -/// value itself. -pub(crate) fn form_fonts_as_objects(doc: &mut Document, all: &[crate::Field]) -> Result<(), FormError> { - let signed = all.iter().any(|f| f.kind == crate::FieldKind::Signature && holds_value(doc, f.obj)); - if signed { - return Ok(()); - } - let Some(root) = doc.root() else { return Ok(()) }; - let Some(entry) = doc.get(root).as_dict().and_then(|c| c.get(b"AcroForm").cloned()) else { return Ok(()) }; - match entry { - Object::Ref(r) => { - let Some(mut af) = doc.get(r).as_dict().cloned() else { return Ok(()) }; - let (dr, mut fonts) = form_fonts(doc, &af); - if inline_fonts_as_objects(doc, &mut fonts) { - store_form_fonts(doc, &mut af, dr, fonts); - doc.set(r, Object::Dict(af)); - } - } - // A form dictionary written directly in the catalog (the catalog's /AcroForm need not be - // an indirect reference) is written back there. - Object::Dict(mut af) => { - let (dr, mut fonts) = form_fonts(doc, &af); - if inline_fonts_as_objects(doc, &mut fonts) { - store_form_fonts(doc, &mut af, dr, fonts); - doc.update_dict(root, |c| c.set(b"AcroForm".to_vec(), Object::Dict(af)))?; - } - } - _ => {} - } - Ok(()) -} - -/// Whether the field `r`, or the nearest ancestor that has one, holds a `/V` (the entry is -/// inheritable, ISO 32000-2 §12.7.4). The `/Parent` chain is followed at most 64 deep, as deep -/// as the field walk goes, so a cyclic chain ends. -fn holds_value(doc: &Document, r: ObjRef) -> bool { - let mut at = r; - for _ in 0..=64 { - let obj = doc.get(at); - let Some(d) = obj.as_dict() else { return false }; - if d.contains(b"V") { - return true; - } - match d.reference(b"Parent") { - Some(p) => at = p, - None => return false, - } - } - false -} - -/// The Appearance tab: borders, colours and text. -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct Look { - pub border: Option<[f64; 3]>, - pub fill: Option<[f64; 3]>, - /// Line thickness in points (Acrobat: thin 1, medium 2, thick 3). - pub width: f64, - pub style: BorderStyle, - pub text: [f64; 3], - pub font: FieldFont, -} - -/// A partial Appearance-tab edit. Outer `None` keeps a property; `Some(None)` removes a -/// border or fill colour. The patch is applied to each widget's own dictionaries. -#[derive(Clone, Copy, Debug, Default, PartialEq)] -pub struct LookPatch { - pub border: Option>, - pub fill: Option>, - pub width: Option, - pub style: Option, - pub text: Option<[f64; 3]>, - pub font: Option, -} - -impl LookPatch { - fn validate(&self) -> Result<(), FormError> { - for c in self.border.flatten().into_iter().chain(self.fill.flatten()).chain(self.text) { - if !c.iter().all(|v| v.is_finite() && (0.0..=1.0).contains(v)) { - return invalid("appearance colours must be finite RGB values between 0 and 1"); - } - } - if self.width.is_some_and(|v| !v.is_finite() || !(0.0..=12.0).contains(&v)) { - return invalid("border width must be between 0 and 12 points"); - } - Ok(()) - } -} - -/// Rewrite only the selected operators in place: the last `Tf` (font and size) and the last -/// fill colour (`g`, `rg` or `k`), appending one when the string has none. Everything else (a -/// custom font resource, colour space or other graphics state) is kept, and repeated edits don't -/// grow the string. -fn patch_da(da: &str, size: Option, patch: &LookPatch) -> String { - if patch.font.is_none() && size.is_none() && patch.text.is_none() { - return da.to_string(); - } - let mut toks: Vec = da.split_whitespace().map(str::to_string).collect(); - let numeric = |s: &str| s.parse::().is_ok(); - if patch.font.is_some() || size.is_some() { - let old = appearance::parse_da(da); - let font = patch.font.map_or_else(|| old.font.clone(), |f| f.resource().to_string()); - // `parse_da` reads sizes up to 300 points; keep the same bound when writing. - let size = appearance::fmt(size.unwrap_or(old.size).clamp(0.0, 300.0)); - let at = toks.iter().enumerate().rev().find(|(i, t)| t.as_str() == "Tf" && *i >= 2).map(|(i, _)| i); - match at { - Some(i) => { - if let Some(slot) = toks.get_mut(i - 2) { - *slot = format!("/{font}"); - } - if let Some(slot) = toks.get_mut(i - 1) { - *slot = size; - } - } - None => toks.extend([format!("/{font}"), size, "Tf".into()]), - } - } - if let Some(c) = patch.text { - let rgb = [appearance::fmt(c[0]), appearance::fmt(c[1]), appearance::fmt(c[2]), "rg".into()]; - let at = toks.iter().enumerate().rev().find_map(|(i, t)| { - let n = match t.as_str() { - "g" => 1, - "rg" => 3, - "k" => 4, - _ => return None, - }; - let start = i.checked_sub(n)?; - toks.get(start..i).is_some_and(|ops| ops.iter().all(|o| numeric(o))).then_some((start, i)) - }); - match at { - Some((start, end)) => { - toks.splice(start..=end, rgb); - } - None => toks.extend(rgb), - } - } - toks.join(" ") -} - -fn patch_widget_look(doc: &mut Document, w: &Widget, patch: &LookPatch) -> Result<(), FormError> { - let touches_mk = patch.border.is_some() || patch.fill.is_some(); - let touches_bs = patch.width.is_some() || patch.style.is_some(); - // A size- or font-only edit changes /DA, not the widget: leave it out of the save. - if !touches_mk && !touches_bs { - return Ok(()); - } - let wd = doc.get(w.obj).as_dict().cloned().ok_or_else(|| FormError::Invalid("the widget is not a dictionary".into()))?; - if touches_mk { - let indirect = wd.get(b"MK").and_then(Object::as_ref); - let mut mk = wd.get(b"MK").map(|m| doc.resolve(m)).and_then(|m| m.as_dict().cloned()).unwrap_or_default(); - for (key, change) in [(b"BC".as_slice(), patch.border), (b"BG".as_slice(), patch.fill)] { - if let Some(change) = change { - match change { - Some(c) => mk.set(key.to_vec(), rgb(c)), - None => { - mk.remove(key); - } - } - } - } - // A shared indirect /MK is updated where it lives, not copied into each widget. - match indirect { - Some(r) => doc.set(r, Object::Dict(mk)), - None => doc.update_dict(w.obj, |d| d.set(b"MK".to_vec(), Object::Dict(mk)))?, - } - } - if touches_bs { - let indirect = wd.get(b"BS").and_then(Object::as_ref); - let mut bs = wd.get(b"BS").map(|b| doc.resolve(b)).and_then(|b| b.as_dict().cloned()).unwrap_or_default(); - if let Some(width) = patch.width { - bs.set(b"W".to_vec(), Object::Real(width)); - } - if let Some(style) = patch.style { - bs.set(b"S".to_vec(), Object::name(style.code())); - if style == BorderStyle::Dashed && !bs.contains(b"D") { - bs.set(b"D".to_vec(), Object::Array(vec![Object::Int(3)])); - } - } - match indirect { - Some(r) => doc.set(r, Object::Dict(bs)), - None => doc.update_dict(w.obj, |d| d.set(b"BS".to_vec(), Object::Dict(bs)))?, - } - } - Ok(()) -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum BorderStyle { - Solid, - Dashed, - Beveled, - Inset, - Underline, -} - -impl BorderStyle { - pub const ALL: [BorderStyle; 5] = [BorderStyle::Solid, BorderStyle::Dashed, BorderStyle::Beveled, BorderStyle::Inset, BorderStyle::Underline]; - - fn code(self) -> &'static str { - match self { - BorderStyle::Solid => "S", - BorderStyle::Dashed => "D", - BorderStyle::Beveled => "B", - BorderStyle::Inset => "I", - BorderStyle::Underline => "U", - } - } - - pub fn label(self) -> &'static str { - match self { - BorderStyle::Solid => "Solid", - BorderStyle::Dashed => "Dashed", - BorderStyle::Beveled => "Beveled", - BorderStyle::Inset => "Inset", - BorderStyle::Underline => "Underline", - } - } -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum FieldFont { - Helvetica, - Times, - Courier, -} - -impl FieldFont { - pub const ALL: [FieldFont; 3] = [FieldFont::Helvetica, FieldFont::Times, FieldFont::Courier]; - - fn resource(self) -> &'static str { - match self { - FieldFont::Helvetica => "Helv", - FieldFont::Times => "TiRo", - FieldFont::Courier => "Cour", - } - } - - pub fn label(self) -> &'static str { - match self { - FieldFont::Helvetica => "Helvetica", - FieldFont::Times => "Times Roman", - FieldFont::Courier => "Courier", - } - } -} - -/// The mark a check box or radio button shows when on (Field Properties ▸ Options ▸ style). -/// Stored as `/MK /CA`, the ZapfDingbats character Acrobat uses for it; PdfCraft draws the -/// mark as paths, so no symbol font is needed. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum CheckStyle { - Check, - Circle, - Cross, - Diamond, - Square, - Star, -} - -impl CheckStyle { - pub const ALL: [CheckStyle; 6] = - [CheckStyle::Check, CheckStyle::Circle, CheckStyle::Cross, CheckStyle::Diamond, CheckStyle::Square, CheckStyle::Star]; - - fn code(self) -> &'static str { - match self { - CheckStyle::Check => "4", - CheckStyle::Circle => "l", - CheckStyle::Cross => "8", - CheckStyle::Diamond => "u", - CheckStyle::Square => "n", - CheckStyle::Star => "H", - } - } - - pub fn label(self) -> &'static str { - match self { - CheckStyle::Check => "Check", - CheckStyle::Circle => "Circle", - CheckStyle::Cross => "Cross", - CheckStyle::Diamond => "Diamond", - CheckStyle::Square => "Square", - CheckStyle::Star => "Star", - } - } - - /// A widget's style: its `/MK /CA`, else a check for check boxes and a circle for radios. - pub(crate) fn of_widget(doc: &Document, wd: &Dict, kind: FieldKind) -> Self { - let mk = wd.get(b"MK").and_then(|m| doc.resolve(m).as_dict().cloned()).unwrap_or_default(); - let ca = mk.get(b"CA").and_then(|c| doc.resolve(c).as_string().map(|s| s.to_text())); - CheckStyle::ALL.into_iter().find(|s| ca.as_deref() == Some(s.code())).unwrap_or(if kind == FieldKind::Radio { - CheckStyle::Circle - } else { - CheckStyle::Check - }) - } -} - -/// A check box's or radio button's style (from its first widget). -pub fn check_style(doc: &Document, f: &Field) -> CheckStyle { - let wd = f.widgets.first().and_then(|w| doc.get(w.obj).as_dict().cloned()).unwrap_or_default(); - CheckStyle::of_widget(doc, &wd, f.kind) -} - -fn rgb_of(doc: &Document, o: Option<&Object>) -> Option<[f64; 3]> { - let v: Vec = doc.resolve(o?).as_array()?.iter().filter_map(Object::as_f64).collect(); - match v.len() { - 1 => Some([v[0]; 3]), - 3 => Some([v[0], v[1], v[2]]), - 4 => Some([(1.0 - v[0]) * (1.0 - v[3]), (1.0 - v[1]) * (1.0 - v[3]), (1.0 - v[2]) * (1.0 - v[3])]), - _ => None, - } -} - -/// A field's current look (from its first widget and its default appearance). -pub fn look(doc: &Document, f: &Field) -> Look { - let wd = f.widgets.first().and_then(|w| doc.get(w.obj).as_dict().cloned()).unwrap_or_default(); - let mk = wd.get(b"MK").and_then(|m| doc.resolve(m).as_dict().cloned()).unwrap_or_default(); - let bs = wd.get(b"BS").and_then(|b| doc.resolve(b).as_dict().cloned()).unwrap_or_default(); - let da = wd.get(b"DA").and_then(|o| doc.resolve(o).as_string().map(|s| s.to_text())).unwrap_or_else(|| f.da.clone()); - let parsed = appearance::parse_da(&da); - let nums: Vec = parsed.color.split_whitespace().filter_map(|t| t.parse().ok()).collect(); - let text = match nums.len() { - 1 => [nums[0]; 3], - 3 => [nums[0], nums[1], nums[2]], - 4 => [(1.0 - nums[0]) * (1.0 - nums[3]), (1.0 - nums[1]) * (1.0 - nums[3]), (1.0 - nums[2]) * (1.0 - nums[3])], - _ => [0.0; 3], - }; - Look { - border: rgb_of(doc, mk.get(b"BC")), - fill: rgb_of(doc, mk.get(b"BG")), - width: bs.get(b"W").and_then(|w| doc.resolve(w).as_f64()).unwrap_or(1.0), - style: match bs.name(b"S") { - Some(b"D") => BorderStyle::Dashed, - Some(b"B") => BorderStyle::Beveled, - Some(b"I") => BorderStyle::Inset, - Some(b"U") => BorderStyle::Underline, - _ => BorderStyle::Solid, - }, - text, - font: match parsed.font.as_str() { - "TiRo" | "Times-Roman" => FieldFont::Times, - "Cour" | "Courier" => FieldFont::Courier, - _ => FieldFont::Helvetica, - }, - } -} - -/// A `/DA` string with the given font, size and colour. -fn da_string(font: FieldFont, size: f64, c: [f64; 3]) -> String { - let f = crate::appearance::fmt; - format!("/{} {} Tf {} {} {} rg", font.resource(), f(size.clamp(0.0, 100.0)), f(c[0]), f(c[1]), f(c[2])) -} - -fn add_to_page(doc: &mut Document, page: ObjRef, widget: ObjRef) -> Result<(), FormError> { - let existing = doc.get(page).as_dict().and_then(|d| d.get(b"Annots").cloned()); - match existing.as_ref().and_then(|o| o.as_ref()).filter(|r| doc.get(*r).as_array().is_some()) { - Some(r) => { - let mut a = doc.get(r).as_array().cloned().unwrap_or_default(); - a.push(Object::Ref(widget)); - doc.set(r, Object::Array(a)); - } - None => { - let mut a = existing.and_then(|o| o.as_array().cloned()).unwrap_or_default(); - a.push(Object::Ref(widget)); - doc.update_dict(page, |d| d.set(b"Annots".to_vec(), Object::Array(a)))?; - } - } - Ok(()) -} - -fn add_to_fields(doc: &mut Document, af: ObjRef, field: ObjRef) -> Result<(), FormError> { - let mut d = doc.get(af).as_dict().cloned().unwrap_or_default(); - let mut list = d.get(b"Fields").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default(); - list.push(Object::Ref(field)); - d.set(b"Fields".to_vec(), Object::Array(list)); - doc.set(af, Object::Dict(d)); - Ok(()) -} - -/// The most parts a dotted field name may have ("a.b.c" has three). -const MAX_NAME_PARTS: usize = 32; - -/// The field attributes a field inherits from its parents (ISO 32000-2 §12.7.4.1). -const INHERITED_KEYS: [&[u8]; 7] = [b"FT", b"Ff", b"V", b"DV", b"DA", b"Q", b"MaxLen"]; - -/// The parts of a full field name: `a.b` is the field `b` under the parent field `a`, since a -/// field's own (partial) name can't contain a period (ISO 32000-2 §12.7.4.2) (#2696). -fn name_parts(name: &str) -> Result, FormError> { - let parts: Vec<&str> = name.split('.').take(MAX_NAME_PARTS.saturating_add(1)).collect(); - if parts.len() > MAX_NAME_PARTS { - return invalid(format!("field names can have at most {MAX_NAME_PARTS} parts")); - } - if parts.iter().any(|p| p.trim().is_empty()) { - return invalid("field names can't be empty, or have an empty part between periods"); - } - Ok(parts) -} - -/// Why the full name `name` can't be used beside the field named `other`, if it can't: the -/// same name, or one inside the other (a field that has a value can't also hold fields). -fn clash(name: &str, other: &str) -> Option { - if name == other { - return Some(format!("a field named {name:?} already exists")); - } - let inside = |a: &str, b: &str| a.strip_prefix(b).is_some_and(|rest| rest.starts_with('.')); - (inside(name, other) || inside(other, name)).then(|| format!("{name:?} can't be used beside the field {other:?}")) -} - -/// The array `key` of `holder` (`/Fields` of the form, `/Kids` of a field). -fn list_of(doc: &Document, holder: ObjRef, key: &[u8]) -> Vec { - let d = doc.get(holder).as_dict().cloned().unwrap_or_default(); - d.get(key).map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default() -} - -/// Add `field` to `parent`'s `/Kids`, or to the form's `/Fields` at the top. -fn link(doc: &mut Document, af: ObjRef, parent: Option, field: ObjRef) -> Result<(), FormError> { - let Some(p) = parent else { return add_to_fields(doc, af, field) }; - let mut kids = list_of(doc, p, b"Kids"); - kids.push(Object::Ref(field)); - doc.update_dict(p, |d| d.set(b"Kids".to_vec(), Object::Array(kids)))?; - Ok(()) -} - -/// The node named `name` (its own `/T`) directly under `parent`, or at the top of the form. -fn child_named(doc: &Document, af: ObjRef, parent: Option, name: &str) -> Option { - let list = match parent { - Some(p) => list_of(doc, p, b"Kids"), - None => list_of(doc, af, b"Fields"), - }; - list.iter() - .filter_map(|o| o.as_ref()) - .find(|r| doc.get(*r).as_dict().and_then(|d| d.get(b"T").cloned()).and_then(|t| crate::text_of(&doc.resolve(&t))).as_deref() == Some(name)) -} - -/// The values a parent field passes to the fields under it that can't be turned off by giving -/// the field a neutral one of its own, as `/Ff` and `/Q` can. -const PASSED_VALUES: [&[u8]; 3] = [b"V", b"DV", b"MaxLen"]; - -/// Whether `field` may go in the field tree under the full name `parts`, changing nothing. The -/// field list holds only terminal fields, so a parent left empty by a move isn't in it: a node -/// of the same name there is found here. -/// `inherited` is what the field will hold as its own before it moves (see [`inherited`]). -fn check_place(doc: &Document, af: ObjRef, field: ObjRef, parts: &[&str], inherited: &Dict) -> Result<(), FormError> { - let Some((own, parents)) = parts.split_last() else { return invalid("field names can't be empty") }; - let mut owned = doc.get(field).as_dict().cloned().unwrap_or_default(); - for (k, v) in inherited.iter() { - owned.set(k.to_vec(), v.clone()); - } - let mut at: Option = None; - let mut whole_path = true; - for part in parents { - let Some(r) = child_named(doc, af, at, part) else { - whole_path = false; - break; - }; - // A value, reset value or length limit held by a parent would pass to this field. - let held = doc.get(r).as_dict().is_some_and(|d| PASSED_VALUES.iter().any(|k| d.contains(k) && !owned.contains(k))); - if held { - return invalid(format!("{part:?} holds a value for the fields under it, so {:?} can't be put there", parts.join("."))); - } - at = Some(r); - } - // A field being moved is still in the tree here, but under another full name (a move to its - // own name changes nothing and doesn't get here), so a node found is another. - if whole_path && child_named(doc, af, at, own).is_some() { - return invalid(format!("a field named {:?} already exists", parts.join("."))); - } - Ok(()) -} - -/// Put `field` in the field tree under the full name `parts`: its own `/T` is the last part, -/// and the parts before it name parent fields, reused where the tree has them and made where -/// not (Acrobat makes `a.b` a field `b` under a field `a`). -fn attach(doc: &mut Document, af: ObjRef, field: ObjRef, parts: &[&str]) -> Result<(), FormError> { - let Some((own, parents)) = parts.split_last() else { return invalid("field names can't be empty") }; - check_place(doc, af, field, parts, &Dict::new())?; - let mut parent: Option = None; - let mut inherits = false; - for part in parents { - let next = match child_named(doc, af, parent, part) { - Some(r) => { - inherits |= doc.get(r).as_dict().is_some_and(|d| d.contains(b"Ff") || d.contains(b"Q")); - r - } - None => { - let mut d = Dict::new(); - d.set(b"T".to_vec(), PdfString::text(part)); - d.set(b"Kids".to_vec(), Object::Array(Vec::new())); - if let Some(p) = parent { - d.set(b"Parent".to_vec(), Object::Ref(p)); - } - let r = doc.add(Object::Dict(d)); - link(doc, af, parent, r)?; - r - } - }; - parent = Some(next); - } - doc.update_dict(field, |d| { - d.set(b"T".to_vec(), PdfString::text(own)); - match parent { - Some(p) => d.set(b"Parent".to_vec(), Object::Ref(p)), - None => { - d.remove(b"Parent"); - } - } - // An existing parent's flags and alignment would otherwise pass to this field. - if inherits { - for k in [&b"Ff"[..], b"Q"] { - if !d.contains(k) { - d.set(k.to_vec(), Object::Int(0)); - } - } - } - })?; - link(doc, af, parent, field) -} - -/// What `field` inherits from its parents and doesn't hold itself, changing nothing. -fn inherited(doc: &Document, field: ObjRef) -> Dict { - let own = doc.get(field).as_dict().cloned().unwrap_or_default(); - let mut add = Dict::new(); - let mut seen = std::collections::HashSet::new(); - let mut parent = own.reference(b"Parent"); - while let Some(p) = parent { - if seen.len() > 64 || !seen.insert(p) { - break; - } - let Some(d) = doc.get(p).as_dict().cloned() else { break }; - for k in INHERITED_KEYS { - if let Some(v) = d.get(k).filter(|_| !own.contains(k) && !add.contains(k)) { - add.set(k.to_vec(), v.clone()); - } - } - parent = d.reference(b"Parent"); - } - add -} - -/// Copy onto `field` what it inherits (`add`, from [`inherited`]), so it keeps it when it moves. -fn own_inherited(doc: &mut Document, field: ObjRef, add: &Dict) -> Result<(), FormError> { - if add.iter().next().is_some() { - doc.update_dict(field, |d| { - for (k, v) in add.iter() { - d.set(k.to_vec(), v.clone()); - } - })?; - } - Ok(()) -} - -/// Take `field` out of the field tree: from its parent's `/Kids`, or from `/Fields`. -fn detach(doc: &mut Document, field: ObjRef) -> Result<(), FormError> { - let parent = doc.get(field).as_dict().and_then(|d| d.reference(b"Parent")); - let remove_from = |doc: &mut Document, holder: ObjRef, key: &[u8]| -> Result<(), FormError> { - let mut d = doc.get(holder).as_dict().cloned().unwrap_or_default(); - let list = d.get(key).map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default(); - let kept: Vec = list.into_iter().filter(|o| o.as_ref() != Some(field)).collect(); - d.set(key.to_vec(), Object::Array(kept)); - doc.set(holder, Object::Dict(d)); - Ok(()) - }; - match parent { - Some(p) => remove_from(doc, p, b"Kids")?, - None => { - let root = doc.root().ok_or(FormError::NoForm)?; - match doc.get(root).as_dict().and_then(|d| d.get(b"AcroForm").cloned()) { - Some(Object::Ref(af)) => remove_from(doc, af, b"Fields")?, - // A form dictionary held directly in the catalog. - Some(Object::Dict(mut af)) => { - let list = af.get(b"Fields").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default(); - let kept: Vec = list.into_iter().filter(|o| o.as_ref() != Some(field)).collect(); - af.set(b"Fields".to_vec(), Object::Array(kept)); - doc.update_dict(root, |d| d.set(b"AcroForm".to_vec(), Object::Dict(af)))?; - } - _ => {} - } - } - } - Ok(()) -} - -/// The first free "N" name: not a field's, and not held by a node at the top of the field -/// tree (`held`), such as a parent field left empty by a move, which isn't in `existing`. -fn free_name(existing: &[Field], held: &[String], base: &str) -> String { - (1..=u64::MAX) - .map(|n| format!("{base}{n}")) - .find(|c| !held.contains(c) && !existing.iter().any(|f| f.name == *c || f.name.starts_with(&format!("{c}.")))) - .unwrap_or_else(|| base.to_string()) -} - -/// The own names (`/T`) of the nodes in the form's `/Fields`, changing nothing. -fn top_names(doc: &Document) -> Vec { - let Some(root) = doc.root() else { return Vec::new() }; - let Some(af) = doc.get(root).as_dict().and_then(|d| d.get(b"AcroForm").map(|o| doc.resolve(o))).and_then(|o| o.as_dict().cloned()) else { - return Vec::new(); - }; - let list = af.get(b"Fields").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default(); - list.iter().filter_map(|o| doc.resolve(o).as_dict().and_then(|d| d.get(b"T").cloned())).filter_map(|t| crate::text_of(&doc.resolve(&t))).collect() -} - -fn widget_dict(page: ObjRef, rect: [f64; 4]) -> Dict { - let mut d = Dict::new(); - d.set(b"Type".to_vec(), Object::name("Annot")); - d.set(b"Subtype".to_vec(), Object::name("Widget")); - d.set(b"Rect".to_vec(), Object::Array(rect.iter().map(|v| Object::Real(*v)).collect())); - d.set(b"P".to_vec(), Object::Ref(page)); - d.set(b"F".to_vec(), Object::Int(4)); - let mut mk = Dict::new(); - mk.set(b"BC".to_vec(), rgb([0.55, 0.55, 0.55])); - mk.set(b"BG".to_vec(), rgb([1.0, 1.0, 1.0])); - d.set(b"MK".to_vec(), Object::Dict(mk)); - let mut bs = Dict::new(); - bs.set(b"W".to_vec(), Object::Int(1)); - bs.set(b"S".to_vec(), Object::name("S")); - d.set(b"BS".to_vec(), Object::Dict(bs)); - d -} - -/// Add a field on `page` (0-based) in `rect` (user space). Returns its full name. -pub fn add_field(doc: &mut Document, page: usize, rect: [f64; 4], kind: &NewField, name: Option<&str>) -> Result { - let pages = page_refs(doc); - let page_ref = *pages.get(page).ok_or_else(|| FormError::Invalid(format!("page {} does not exist", page + 1)))?; - let rect = [rect[0].min(rect[2]), rect[1].min(rect[3]), rect[0].max(rect[2]), rect[1].max(rect[3])]; - if !rect.iter().all(|v| v.is_finite()) || rect[2] - rect[0] < MIN_FIELD_SIDE || rect[3] - rect[1] < MIN_FIELD_SIDE { - return invalid("the field is too small"); - } - let existing = fields(doc); - // A radio button names its group: a new group takes that name. - let name = match (name, kind) { - (None, NewField::Radio { group: Some(g), .. }) => Some(g.as_str()), - _ => name, - }; - let name = match name.map(str::trim) { - Some(n) if !n.is_empty() => n.to_string(), - _ => free_name(&existing, &top_names(doc), kind.base_name()), - }; - let parts = name_parts(&name)?; - let radio_group = match kind { - NewField::Radio { group, .. } => existing.iter().find(|f| f.kind == FieldKind::Radio && Some(&f.name) == group.as_ref()).cloned(), - _ => None, - }; - if radio_group.is_none() - && let Some(why) = existing.iter().find_map(|f| clash(&name, &f.name)) - { - return invalid(why); - } - let af = ensure_form(doc)?; - let mut w = widget_dict(page_ref, rect); - let field_name = match kind { - NewField::Radio { group: Some(g), .. } if radio_group.is_some() => g.clone(), - _ => name.clone(), - }; - match kind { - NewField::Text { multiline } => { - w.set(b"FT".to_vec(), Object::name("Tx")); - // Auto size (0): a fixed 12 pt is clipped in a box shorter than that (#1888). - w.set(b"DA".to_vec(), PdfString::literal(b"/Helv 0 Tf 0 g".to_vec())); - if *multiline { - w.set(b"Ff".to_vec(), Object::Int(flags::MULTILINE as i64)); - } - } - NewField::Date => { - w.set(b"FT".to_vec(), Object::name("Tx")); - w.set(b"DA".to_vec(), PdfString::literal(b"/Helv 0 Tf 0 g".to_vec())); - // Acrobat's format and keystroke actions for mm/dd/yyyy (run by its JavaScript engine). - let js = |s: &str| { - let mut a = Dict::new(); - a.set(b"S".to_vec(), Object::name("JavaScript")); - a.set(b"JS".to_vec(), PdfString::literal(s.as_bytes().to_vec())); - Object::Dict(a) - }; - let mut aa = Dict::new(); - aa.set(b"F".to_vec(), js("AFDate_FormatEx(\"mm/dd/yyyy\");")); - aa.set(b"K".to_vec(), js("AFDate_KeystrokeEx(\"mm/dd/yyyy\");")); - w.set(b"AA".to_vec(), Object::Dict(aa)); - } - NewField::CheckBox => { - w.set(b"FT".to_vec(), Object::name("Btn")); - w.set(b"V".to_vec(), Object::name("Off")); - w.set(b"AS".to_vec(), Object::name("Off")); - w.set(b"DA".to_vec(), PdfString::literal(b"/ZaDb 0 Tf 0 g".to_vec())); - } - NewField::Radio { export, .. } => { - w.set(b"AS".to_vec(), Object::name("Off")); - if export.trim().is_empty() || export == "Off" { - return invalid("a radio button needs an export value other than Off"); - } - } - NewField::Combo { options, editable } | NewField::List { options, multi: editable } => { - w.set(b"FT".to_vec(), Object::name("Ch")); - w.set(b"DA".to_vec(), PdfString::literal(b"/Helv 12 Tf 0 g".to_vec())); - let combo = matches!(kind, NewField::Combo { .. }); - let mut ff = 0; - if combo { - ff |= flags::COMBO; - if *editable { - ff |= flags::EDIT; - } - } else if *editable { - ff |= flags::MULTI_SELECT; - } - w.set(b"Ff".to_vec(), Object::Int(ff as i64)); - w.set(b"Opt".to_vec(), Object::Array(options.iter().map(|o| Object::String(PdfString::text(o))).collect())); - } - NewField::Button { caption } => { - w.set(b"FT".to_vec(), Object::name("Btn")); - w.set(b"Ff".to_vec(), Object::Int(flags::PUSH_BUTTON as i64)); - w.set(b"DA".to_vec(), PdfString::literal(b"/Helv 0 Tf 0 g".to_vec())); - let mut mk = w.get(b"MK").and_then(|m| m.as_dict()).cloned().unwrap_or_default(); - mk.set(b"BG".to_vec(), rgb([0.86, 0.86, 0.86])); - mk.set(b"CA".to_vec(), PdfString::text(caption)); - w.set(b"MK".to_vec(), Object::Dict(mk)); - } - NewField::Image => { - w.set(b"FT".to_vec(), Object::name("Btn")); - w.set(b"Ff".to_vec(), Object::Int(flags::PUSH_BUTTON as i64)); - w.set(b"DA".to_vec(), PdfString::literal(b"/Helv 0 Tf 0 g".to_vec())); - let mut mk = w.get(b"MK").and_then(|m| m.as_dict()).cloned().unwrap_or_default(); - // Icon only, no border or fill: the picture is the field. - mk.remove(b"BC"); - mk.remove(b"BG"); - mk.set(b"TP".to_vec(), Object::Int(1)); - w.set(b"MK".to_vec(), Object::Dict(mk)); - let mut a = Dict::new(); - a.set(b"S".to_vec(), Object::name("JavaScript")); - a.set(b"JS".to_vec(), PdfString::literal(b"event.target.buttonImportIcon();".to_vec())); - w.set(b"A".to_vec(), Object::Dict(a)); - } - NewField::Signature => { - w.set(b"FT".to_vec(), Object::name("Sig")); - } - } - let widget = doc.add(Object::Dict(w)); - match kind { - NewField::Radio { export, .. } => { - let group = match &radio_group { - Some(g) => g.obj, - None => { - let mut g = Dict::new(); - g.set(b"FT".to_vec(), Object::name("Btn")); - g.set(b"Ff".to_vec(), Object::Int((flags::RADIO | flags::NO_TOGGLE_TO_OFF) as i64)); - g.set(b"V".to_vec(), Object::name("Off")); - g.set(b"DA".to_vec(), PdfString::literal(b"/ZaDb 0 Tf 0 g".to_vec())); - g.set(b"Kids".to_vec(), Object::Array(Vec::new())); - let g = doc.add(Object::Dict(g)); - attach(doc, af, g, &parts)?; - g - } - }; - doc.update_dict(widget, |d| d.set(b"Parent".to_vec(), Object::Ref(group)))?; - doc.update_dict(group, |d| { - let mut kids = d.get(b"Kids").and_then(|k| k.as_array().cloned()).unwrap_or_default(); - kids.push(Object::Ref(widget)); - d.set(b"Kids".to_vec(), Object::Array(kids)); - })?; - let w = Widget { - obj: widget, - page: Some(page), - rect, - on_state: Some(export.clone()), - state: Some("Off".into()), - tab: usize::MAX, - locked: false, - hidden: false, - read_only: false, - rotation: 0, - caption: None, - list: None, - round: true, - }; - let ap = appearance::check_box_states(doc, &w, FieldKind::Radio, export, "/ZaDb 0 Tf 0 g"); - doc.update_dict(widget, |d| d.set(b"AP".to_vec(), Object::Dict(ap)))?; - } - _ => attach(doc, af, widget, &parts)?, - } - add_to_page(doc, page_ref, widget)?; - redraw_field(doc, &field_name)?; - Ok(field_name) -} - -/// Regenerate every widget appearance of a field from its current value and settings. A value or -/// button caption its font can't hold is drawn with `?` for those characters, the widget is marked -/// (`pdfcraft_fonts::UNDRAWN_MARK`) and other viewers are asked to redraw the form (see -/// `ask_viewers_to_redraw`), as when a value is set. -pub fn redraw_field(doc: &mut Document, name: &str) -> Result<(), FormError> { - redraw_widgets(doc, name, None) -} - -/// `redraw_field` for the widget at index `only` alone (so a caption-only edit leaves the field's -/// other widgets' appearances as they are), or for every widget when `only` is `None`. Fails with -/// `FormError::NoSuchField` when no field is named `name`. -fn redraw_widgets(doc: &mut Document, name: &str, only: Option) -> Result<(), FormError> { - let f = fields(doc).into_iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?; - let mut undrawn = false; - for (index, w) in f.widgets.iter().enumerate() { - if only.is_some_and(|only| only != index) { - continue; - } - let mut missing = None; - let ap = match f.kind { - FieldKind::Text | FieldKind::Combo | FieldKind::List => { - let (s, m) = appearance::field_appearance_drawn(doc, &f, w, &f.value, true); - missing = m; - let r = doc.add(Object::Stream(s)); - let mut d = Dict::new(); - d.set(b"N".to_vec(), Object::Ref(r)); - d - } - FieldKind::CheckBox | FieldKind::Radio => { - let on = w.on_state.clone().unwrap_or_else(|| "Yes".into()); - appearance::check_box_states(doc, w, f.kind, &on, &f.da) - } - FieldKind::PushButton => { - let (s, m) = button_appearance(doc, w, &f.da); - missing = m; - let r = doc.add(Object::Stream(s)); - let mut d = Dict::new(); - d.set(b"N".to_vec(), Object::Ref(r)); - d - } - FieldKind::Signature => { - let s = empty_box(doc, w); - let r = doc.add(Object::Stream(s)); - let mut d = Dict::new(); - d.set(b"N".to_vec(), Object::Ref(r)); - d - } - }; - undrawn |= missing.is_some(); - let existing = crate::merged_appearance(doc, w.obj, &ap); - doc.update_dict(w.obj, |d| { - d.set(b"AP".to_vec(), Object::Dict(existing)); - pdfcraft_fonts::set_undrawn_mark(d, missing); - })?; - } - if undrawn { - crate::ask_viewers_to_redraw(doc)?; - } - Ok(()) -} - -fn frame_only(doc: &Document, w: &Widget) -> (String, crate::appearance::Placement) { - let (page_w, page_h) = ((w.rect[2] - w.rect[0]).max(1.0), (w.rect[3] - w.rect[1]).max(1.0)); - let wobj = doc.get(w.obj); - let wd = wobj.as_dict().cloned().unwrap_or_default(); - let place = crate::appearance::placement(crate::appearance::mk_rotation(doc, &wd), page_w, page_h); - let (width, height) = (place.layout_w, place.layout_h); - let mk = wd.get(b"MK").and_then(|m| m.as_dict()).cloned().unwrap_or_default(); - let col = |k: &[u8]| -> Option { - let v: Vec = mk.get(k)?.as_array()?.iter().filter_map(|x| x.as_f64()).collect(); - (v.len() == 3).then(|| format!("{} {} {}", crate::appearance::fmt(v[0]), crate::appearance::fmt(v[1]), crate::appearance::fmt(v[2]))) - }; - let mut c = String::new(); - if let Some(bg) = col(b"BG") { - c.push_str(&format!("{bg} rg 0 0 {width:.3} {height:.3} re f\n")); - } - if let Some(bc) = col(b"BC") { - c.push_str(&format!("{bc} RG 1 w 0.5 0.5 {:.3} {:.3} re S\n", width - 1.0, height - 1.0)); - } - (c, place) -} - -fn form_stream(place: crate::appearance::Placement, content: Vec, resources: Dict) -> Stream { - let mut d = Dict::new(); - d.set(b"Type".to_vec(), Object::name("XObject")); - d.set(b"Subtype".to_vec(), Object::name("Form")); - crate::appearance::set_form_box(&mut d, place); - d.set(b"Resources".to_vec(), Object::Dict(resources)); - Stream::flate(d, &content) -} - -/// A push button: background, border, its icon (`/MK /I`, scaled to fit and centred) and its -/// centred caption (unless the layout is icon only, `/TP 1`). The caption uses the font, size and -/// color its `/DA` asks for (the widget's, else `field_da`, the field's own or inherited one). -/// Also returns the first caption character the font can't draw (drawn as `?`), for the caller to -/// mark and report. -pub(crate) fn button_appearance(doc: &Document, w: &Widget, field_da: &str) -> (Stream, Option) { - let (mut c, place) = frame_only(doc, w); - let (width, height) = (place.layout_w, place.layout_h); - let wobj = doc.get(w.obj); - let mk = wobj.as_dict().and_then(|d| d.get(b"MK")).map(|m| doc.resolve(m)).and_then(|m| m.as_dict().cloned()).unwrap_or_default(); - let caption = mk.get(b"CA").map(|c| doc.resolve(c)).and_then(|c| c.as_string().map(|s| s.to_text())).unwrap_or_default(); - let icon_only = mk.get(b"TP").and_then(|t| doc.resolve(t).as_int()) == Some(1); - let mut content = std::mem::take(&mut c).into_bytes(); - let mut xobjects = Dict::new(); - if let Some(icon) = mk.get(b"I").and_then(Object::as_ref) - && let Object::Stream(s) = &*doc.get(icon) - { - let bb: Vec = s.dict.get(b"BBox").and_then(|b| b.as_array().map(|a| a.iter().filter_map(Object::as_f64).collect())).unwrap_or_default(); - if let [x0, y0, x1, y1] = bb[..] - && x1 > x0 - && y1 > y0 - { - // Proportional, as large as fits (Acrobat's default icon placement), centred. - let k = ((width - 2.0) / (x1 - x0)).min((height - 2.0) / (y1 - y0)).max(0.0); - let (dx, dy) = ((width - (x1 - x0) * k) / 2.0 - x0 * k, (height - (y1 - y0) * k) / 2.0 - y0 * k); - content.extend(format!("q {k:.6} 0 0 {k:.6} {dx:.3} {dy:.3} cm /Icon Do Q\n").bytes()); - xobjects.set(b"Icon".to_vec(), Object::Ref(icon)); - } - } - let mut missing = None; - let mut fonts = Dict::new(); - if !icon_only && !caption.is_empty() { - let da = wobj.as_dict().and_then(|d| d.get(b"DA")).and_then(|o| doc.resolve(o).as_string().map(|s| s.to_text())); - let auto = ((height - 4.0) * 0.6).clamp(4.0, 14.0); - let (ops, name, font, m) = appearance::button_caption(doc, da.as_deref().unwrap_or(field_da), &caption, width, height, auto); - content.extend(ops); - fonts.set(name.into_bytes(), font); - missing = m; - } - let mut res = Dict::new(); - res.set(b"Font".to_vec(), Object::Dict(fonts)); - if !xobjects.is_empty() { - res.set(b"XObject".to_vec(), Object::Dict(xobjects)); - } - (form_stream(place, content, res), missing) -} - -/// Give a push button (an image field) the picture `image` (an image XObject of `px` pixels): -/// it becomes the button's icon (`/MK /I`) and its appearance. -pub fn set_button_icon(doc: &mut Document, name: &str, image: ObjRef, px: (u32, u32)) -> Result<(), FormError> { - let f = fields(doc).into_iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?; - if f.kind != FieldKind::PushButton { - return invalid(format!("{name} is not a button or image field")); - } - // The icon form draws the image in a box of its pixel size (keeping its aspect ratio). - let (w, h) = (px.0.max(1) as f64, px.1.max(1) as f64); - let mut xo = Dict::new(); - xo.set(b"Im0".to_vec(), Object::Ref(image)); - let mut res = Dict::new(); - res.set(b"XObject".to_vec(), Object::Dict(xo)); - let icon = - doc.add(Object::Stream(form_stream(crate::appearance::placement(0, w, h), format!("q {w} 0 0 {h} 0 0 cm /Im0 Do Q").into_bytes(), res))); - for wd in &f.widgets { - let mut mk = - doc.get(wd.obj).as_dict().and_then(|d| d.get(b"MK").map(|m| doc.resolve(m))).and_then(|m| m.as_dict().cloned()).unwrap_or_default(); - mk.set(b"I".to_vec(), Object::Ref(icon)); - if !mk.contains(b"TP") { - mk.set(b"TP".to_vec(), Object::Int(1)); - } - doc.update_dict(wd.obj, |d| d.set(b"MK".to_vec(), Object::Dict(mk)))?; - } - redraw_field(doc, name) -} - -fn empty_box(doc: &Document, w: &Widget) -> Stream { - let (c, place) = frame_only(doc, w); - form_stream(place, c.into_bytes(), Dict::new()) -} - -/// Swap width and height, keeping the center. `None` when that is not a usable widget. -fn swapped_around_center(r: [f64; 4]) -> Option<[f64; 4]> { - if !r.iter().all(|v| v.is_finite()) { - return None; - } - let (x0, y0, x1, y1) = (r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])); - let (cx, cy) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0); - let (nw, nh) = (y1 - y0, x1 - x0); - let out = [cx - nw / 2.0, cy - nh / 2.0, cx + nw / 2.0, cy + nh / 2.0]; - (out.iter().all(|v| v.is_finite()) && out[2] - out[0] >= MIN_FIELD_SIDE && out[3] - out[1] >= MIN_FIELD_SIDE).then_some(out) -} - -/// Change a field's properties (General and Options tabs) and redraw it. -pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result { - if props.font_size.is_some_and(|size| !size.is_finite() || size < 0.0) { - return invalid("font size must be a finite, non-negative number"); - } - if let Some(patch) = &props.appearance { - if props.look.is_some() { - return invalid("give an appearance patch or a complete look, not both"); - } - patch.validate()?; - } - let all = fields(doc); - let f = all.iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?.clone(); - // Refuse a wrong type/index before any other requested property is written. - if let Some((wi, _)) = &props.caption { - if f.kind != FieldKind::PushButton { - return invalid("only push buttons have a caption"); - } - if f.widgets.get(*wi).is_none() { - return invalid(format!("{name} has no widget {}", wi.saturating_add(1))); - } - } - // Validated before anything is written, so a refused rotation leaves the document unchanged. - // `Some(Some(rect))`: the widget's axis changes and it gets the swapped rectangle. - let mut rotated_rect: Option> = None; - if let Some((wi, rot)) = props.rotation { - if !matches!(rot, 0 | 90 | 180 | 270) { - return invalid("rotation must be 0, 90, 180 or 270 degrees"); - } - let Some(w) = f.widgets.get(wi) else { - return invalid(format!("{name} has no widget {}", wi.saturating_add(1))); - }; - let axis_change = (w.rotation % 180 == 0) != (rot % 180 == 0); - rotated_rect = Some(None); - if axis_change && props.rect.is_none() { - let Some(rect) = swapped_around_center(w.rect) else { return invalid("the field is too small") }; - rotated_rect = Some(Some(rect)); - } - } - // A locked field only takes unlocking (Acrobat greys its properties out). - if f.locked() && props.locked != Some(false) && *props != (FieldProps { locked: props.locked, ..FieldProps::default() }) { - return invalid(format!("{name} is locked: unlock it first")); - } - if let Some(lock) = props.locked { - for w in &f.widgets { - let flags = doc.get(w.obj).as_dict().and_then(|d| d.get(b"F").and_then(|v| doc.resolve(v).as_int())).unwrap_or(0); - let v = if lock { flags | 128 } else { flags & !128 }; - doc.update_dict(w.obj, |d| d.set(b"F".to_vec(), Object::Int(v)))?; - } - if *props == (FieldProps { locked: props.locked, ..FieldProps::default() }) { - return Ok(name.to_string()); - } - } - if let Some(acts) = &props.actions { - crate::set_field_actions(doc, name, acts)?; - } - let mut new_name = name.to_string(); - if let Some(n) = props.name.as_deref().map(str::trim).filter(|n| *n != f.name) { - // A full name under the same parents only renames the field's own part (the editor - // shows nested fields by their full name): it isn't moved, so it keeps its place and - // goes on inheriting from its parents. - let n = match (n.rsplit_once('.'), f.name.rsplit_once('.')) { - (Some((parents, own)), Some((current, _))) if parents == current => own, - _ => n, - }; - if n.contains('.') { - // A full name: the field moves under the parent fields it names (#2696). - let parts = name_parts(n)?; - if let Some(why) = all.iter().filter(|x| x.obj != f.obj).find_map(|x| clash(n, &x.name)) { - return invalid(why); - } - let af = ensure_form(doc)?; - // Checked before anything changes, so a refused move leaves the field as it was. - let add = inherited(doc, f.obj); - check_place(doc, af, f.obj, &parts, &add)?; - // From here a failure (a write that `check_place` couldn't foresee) would leave the - // field half moved in `doc`. The engine runs every edit on a copy of the document and - // keeps it only when the edit succeeds (`Session::apply`), so the open document never - // sees that. - own_inherited(doc, f.obj, &add)?; - detach(doc, f.obj)?; - attach(doc, af, f.obj, &parts)?; - new_name = n.to_string(); - } else { - if n.is_empty() { - return invalid("field names can't be empty"); - } - // Rename the terminal part (the field's own /T). The parents' part stays, so the name - // that must be free is the full one: `MASTER.a` renamed to `b` takes `MASTER.b` (ISO - // 32000-2 §12.7.4.2). The field itself is no clash when it keeps its own name. - let prefix = f.name.rsplit_once('.').map(|(p, _)| format!("{p}.")).unwrap_or_default(); - new_name = format!("{prefix}{n}"); - if let Some(why) = all.iter().filter(|x| x.obj != f.obj).find_map(|x| clash(&new_name, &x.name)) { - return invalid(why); - } - // A node of that name beside it that isn't a terminal field, such as a parent - // field left empty by a move. - let af = ensure_form(doc)?; - let parent = doc.get(f.obj).as_dict().and_then(|d| d.reference(b"Parent")); - if child_named(doc, af, parent, n).is_some_and(|r| r != f.obj) { - return invalid(format!("a field named {new_name:?} already exists")); - } - doc.update_dict(f.obj, |d| d.set(b"T".to_vec(), PdfString::text(n)))?; - } - // Calculations read the field by its new name, unless another field keeps the old one. - if all.iter().filter(|x| x.name == f.name).count() == 1 { - rename_in_calculations(doc, &all, &f.name, &new_name)?; - } - } - // The new default appearance when the size or the look changes. - let size = props.font_size.unwrap_or_else(|| crate::appearance::parse_da(&f.da).size); - let new_da = match &props.look { - Some(l) => da_string(l.font, size, l.text), - None => patch_da(&f.da, props.font_size, &props.appearance.unwrap_or_default()), - }; - if props.look.is_some() || props.appearance.is_some_and(|p| p.font.is_some()) { - ensure_form(doc)?; - } - let mut ff = f.flags; - let mut set_flag = |flag: u32, on: Option| { - if let Some(on) = on { - if on { - ff |= flag; - } else { - ff &= !flag; - } - } - }; - set_flag(flags::READ_ONLY, props.read_only); - set_flag(flags::REQUIRED, props.required); - if f.kind == FieldKind::Text { - set_flag(flags::MULTILINE, props.multiline); - } - for (flag, on) in &props.flags { - set_flag(*flag, Some(*on)); - } - // A comb spreads a fixed number of characters across the field: it needs a limit and - // excludes multi-line, password, file selection and scrolling (ISO 32000-2 Table 229). - if f.kind == FieldKind::Text && ff & flags::COMB != 0 { - let limit = match props.max_len { - Some(m) => m, - None => f.max_len, - }; - if limit.is_none_or(|m| m == 0) { - return invalid("a comb of characters needs a character limit"); - } - ff &= !(flags::MULTILINE | flags::PASSWORD | flags::FILE_SELECT); - } - if let Some(q) = props.quadding - && !(0..=2).contains(&q) - { - return invalid("alignment is 0 (left), 1 (centre) or 2 (right)"); - } - if props.options.as_ref().is_some_and(Vec::is_empty) && matches!(f.kind, FieldKind::Combo | FieldKind::List) { - return invalid("a list needs at least one option"); - } - if props.check_style.is_some() && !matches!(f.kind, FieldKind::CheckBox | FieldKind::Radio) { - return invalid("only check boxes and radio buttons have a check style"); - } - doc.update_dict(f.obj, |d| { - d.set(b"Ff".to_vec(), Object::Int(ff as i64)); - if let Some(t) = &props.tooltip { - if t.is_empty() { - d.remove(b"TU"); - } else { - d.set(b"TU".to_vec(), PdfString::text(t)); - } - } - if let Some(m) = props.max_len { - match m { - Some(m) => d.set(b"MaxLen".to_vec(), Object::Int(m as i64)), - None => { - d.remove(b"MaxLen"); - } - } - } - if let Some(opts) = &props.options { - d.set(b"Opt".to_vec(), Object::Array(opts.iter().map(|o| Object::String(PdfString::text(o))).collect())); - } - // "Sort items": the list itself is kept in order (export values stay with their items). - if ff & flags::SORT != 0 && matches!(f.kind, FieldKind::Combo | FieldKind::List) { - let mut items: Vec<(String, String)> = match &props.options { - Some(o) => o.iter().map(|x| (x.clone(), x.clone())).collect(), - None => f.options.clone(), - }; - items.sort_by_key(|(_, display)| display.to_lowercase()); - let opt = items - .iter() - .map(|(e, disp)| { - if e == disp { - Object::String(PdfString::text(disp)) - } else { - Object::Array(vec![Object::String(PdfString::text(e)), Object::String(PdfString::text(disp))]) - } - }) - .collect(); - d.set(b"Opt".to_vec(), Object::Array(opt)); - } - if let Some(q) = props.quadding { - d.set(b"Q".to_vec(), Object::Int(q)); - } - match &props.default_value { - // Buttons name their default state; text and choices hold a string. - Some(Some(v)) if matches!(f.kind, FieldKind::CheckBox | FieldKind::Radio) => d.set(b"DV".to_vec(), Object::Name(crate::name_bytes(v))), - Some(Some(v)) => d.set(b"DV".to_vec(), PdfString::text(v)), - Some(None) => { - d.remove(b"DV"); - } - None => {} - } - if props.font_size.is_some() || props.look.is_some() || props.appearance.is_some_and(|p| p.font.is_some() || p.text.is_some()) { - d.set(b"DA".to_vec(), PdfString::literal(new_da.clone().into_bytes())); - } - })?; - if props.appearance.is_some() || (props.font_size.is_some() && props.look.is_none()) { - let patch = &props.appearance.unwrap_or_default(); - for w in &f.widgets { - patch_widget_look(doc, w, patch)?; - // A widget's own /DA overrides its field's /DA. Patch that override separately - // rather than replacing its font or colour with the first widget's defaults. - if w.obj != f.obj && (props.font_size.is_some() || patch.font.is_some() || patch.text.is_some()) { - let da = doc - .get(w.obj) - .as_dict() - .and_then(|d| d.get(b"DA").cloned()) - .map(|v| doc.resolve(&v)) - .and_then(|v| v.as_string().map(|s| s.to_text())); - if let Some(da) = da { - let da = patch_da(&da, props.font_size, patch); - doc.update_dict(w.obj, |d| d.set(b"DA".to_vec(), PdfString::literal(da.into_bytes())))?; - } - } - } - } - if let Some(l) = &props.look { - let arr = |c: [f64; 3]| Object::Array(c.iter().map(|v| Object::Real(v.clamp(0.0, 1.0))).collect()); - for w in &f.widgets { - // Resolved first: an indirect /MK keeps its mark, rotation and other entries. - let mut mk = doc.get(w.obj).as_dict().and_then(|d| d.get(b"MK").map(|m| doc.resolve(m).as_dict().cloned())).flatten().unwrap_or_default(); - doc.update_dict(w.obj, |d| { - match l.border { - Some(c) => mk.set(b"BC".to_vec(), arr(c)), - None => { - mk.remove(b"BC"); - } - } - match l.fill { - Some(c) => mk.set(b"BG".to_vec(), arr(c)), - None => { - mk.remove(b"BG"); - } - } - d.set(b"MK".to_vec(), Object::Dict(mk)); - let mut bs = Dict::new(); - bs.set(b"W".to_vec(), Object::Real(l.width.clamp(0.0, 12.0))); - bs.set(b"S".to_vec(), Object::name(l.style.code())); - if l.style == BorderStyle::Dashed { - bs.set(b"D".to_vec(), Object::Array(vec![Object::Int(3)])); - } - d.set(b"BS".to_vec(), Object::Dict(bs)); - })?; - } - } - if let Some(style) = props.check_style { - for w in &f.widgets { - // Resolved first: an indirect /MK keeps its colours. - let mut mk = doc.get(w.obj).as_dict().and_then(|d| d.get(b"MK").map(|m| doc.resolve(m).as_dict().cloned())).flatten().unwrap_or_default(); - mk.set(b"CA".to_vec(), PdfString::text(style.code())); - doc.update_dict(w.obj, |d| d.set(b"MK".to_vec(), Object::Dict(mk)))?; - } - } - if let Some((wi, r)) = props.rect { - let w = f.widgets.get(wi).ok_or_else(|| FormError::Invalid(format!("{name} has no widget {}", wi + 1)))?; - let r = [r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])]; - if !r.iter().all(|v| v.is_finite()) || r[2] - r[0] < MIN_FIELD_SIDE || r[3] - r[1] < MIN_FIELD_SIDE { - return invalid("the field is too small"); - } - doc.update_dict(w.obj, |d| d.set(b"Rect".to_vec(), Object::Array(r.iter().map(|v| Object::Real(*v)).collect())))?; - } - if props.format.is_some() || props.validate.is_some() || props.calculate.is_some() { - if !matches!(f.kind, FieldKind::Text | FieldKind::Combo) { - return invalid("only text fields and drop-down lists have format, validate and calculate scripts"); - } - let mut aa = doc - .get(f.obj) - .as_dict() - .and_then(|d| d.get(b"AA").cloned()) - .map(|a| doc.resolve(&a)) - .and_then(|a| a.as_dict().cloned()) - .unwrap_or_default(); - let action = |js: &str| -> Object { - let mut a = Dict::new(); - a.set(b"S".to_vec(), Object::name("JavaScript")); - a.set(b"JS".to_vec(), PdfString::literal(js.as_bytes().to_vec())); - Object::Dict(a) - }; - if let Some(fm) = &props.format { - aa.remove(b"F"); - aa.remove(b"K"); - if let Some((fjs, kjs)) = crate::af::format_js(fm) { - if !fjs.is_empty() { - aa.set(b"F".to_vec(), action(&fjs)); - } - aa.set(b"K".to_vec(), action(&kjs)); - } - } - if let Some(v) = &props.validate { - aa.remove(b"V"); - if let Some(js) = crate::af::validate_js(v) { - aa.set(b"V".to_vec(), action(&js)); - } - } - if let Some(c) = &props.calculate { - aa.remove(b"C"); - if let Some(js) = crate::af::calculate_js(c) { - aa.set(b"C".to_vec(), action(&js)); - } - crate::calc_order::keep_listed(doc, f.obj, *c != crate::af::Calculate::None)?; - } - doc.update_dict(f.obj, |d| { - if aa.is_empty() { - d.remove(b"AA"); - } else { - d.set(b"AA".to_vec(), Object::Dict(aa)); - } - })?; - } - // A complete look replaces widget /DA overrides. Partial changes were patched above - // against each override, including size-only changes that keep its font and colour. - if props.look.is_some() { - for w in &f.widgets { - if w.obj != f.obj { - doc.update_dict(w.obj, |d| { - if d.contains(b"DA") { - d.set(b"DA".to_vec(), PdfString::literal(new_da.clone().into_bytes())); - } - })?; - } - } - } - if let (Some((wi, rot)), Some(swapped)) = (props.rotation, rotated_rect) - && let Some(w) = f.widgets.get(wi) - { - if let Some(rect) = swapped { - doc.update_dict(w.obj, |d| d.set(b"Rect".to_vec(), Object::Array(rect.iter().map(|v| Object::Real(*v)).collect())))?; - } - let mut mk = doc.get(w.obj).as_dict().and_then(|d| d.get(b"MK").map(|m| doc.resolve(m).as_dict().cloned())).flatten().unwrap_or_default(); - if rot == 0 { - mk.remove(b"R"); - } else { - mk.set(b"R".to_vec(), Object::Int(rot)); - } - doc.update_dict(w.obj, |d| { - if mk.is_empty() { - d.remove(b"MK"); - } else { - d.set(b"MK".to_vec(), Object::Dict(mk)); - } - })?; - } - if let Some((wi, caption)) = &props.caption - && let Some(w) = f.widgets.get(*wi) - { - // Copy an indirect/shared MK before patching it; colours, icons, rotation and - // unknown entries belong to this widget, and other owners keep their old label. - let mut mk = doc.get(w.obj).as_dict().and_then(|d| d.get(b"MK").map(|m| doc.resolve(m).as_dict().cloned())).flatten().unwrap_or_default(); - mk.set(b"CA".to_vec(), PdfString::text(caption)); - doc.update_dict(w.obj, |d| d.set(b"MK".to_vec(), Object::Dict(mk)))?; - } - // A label-only edit must not replace another widget's custom appearances. - let only = props.caption.as_ref().filter(|_| *props == (FieldProps { caption: props.caption.clone(), ..Default::default() })).map(|(wi, _)| *wi); - redraw_widgets(doc, &new_name, only)?; - if props.calculate.is_some() { - crate::recalculate(doc)?; - } - Ok(new_name) -} - -/// Name field `old` `new` in each field's Calculate script that reads it -/// ([`crate::af::rename_in_calculation`]). -fn rename_in_calculations(doc: &mut Document, all: &[Field], old: &str, new: &str) -> Result<(), FormError> { - let aa_of = - |doc: &Document, r: ObjRef| doc.get(r).as_dict().and_then(|d| d.get(b"AA").map(|a| doc.resolve(a))).and_then(|a| a.as_dict().cloned()); - for f in all { - // The script is the field's, or its only widget's when the field has none, as - // `actions_of` reads it. - let only_widget = || match f.widgets.as_slice() { - [w] => aa_of(doc, w.obj).map(|aa| (w.obj, aa)), - _ => None, - }; - let Some((owner, mut aa)) = aa_of(doc, f.obj).map(|aa| (f.obj, aa)).or_else(only_widget) else { continue }; - let Some(mut action) = aa.get(b"C").and_then(|a| doc.resolve(a).as_dict().cloned()) else { continue }; - let js = match action.get(b"JS").map(|js| doc.resolve(js)).as_deref() { - Some(Object::String(s)) => s.to_text(), - // A stream that isn't UTF-8 reads with replacement characters, which must not be - // written back in place of what it holds. - Some(Object::Stream(s)) => match s.decoded().ok().and_then(|b| String::from_utf8(b).ok()) { - Some(js) => js, - None => continue, - }, - _ => continue, - }; - let Some(js) = crate::af::rename_in_calculation(&js, old, new) else { continue }; - action.set(b"JS".to_vec(), PdfString::text(&js)); - aa.set(b"C".to_vec(), Object::Dict(action)); - doc.update_dict(owner, |d| d.set(b"AA".to_vec(), Object::Dict(aa)))?; - } - Ok(()) -} - -/// Delete a field: its widgets leave their pages and the field leaves the form. -pub fn delete_field(doc: &mut Document, name: &str) -> Result<(), FormError> { - let f = fields(doc).into_iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?; - delete_found_field(doc, f) -} - -/// [`delete_field`] for the field object `obj`, when separate fields share one name. -pub fn delete_field_object(doc: &mut Document, obj: ObjRef) -> Result<(), FormError> { - let f = fields(doc).into_iter().find(|f| f.obj == obj).ok_or_else(|| FormError::NoSuchField(format!("{obj:?}")))?; - delete_found_field(doc, f) -} - -fn delete_found_field(doc: &mut Document, f: Field) -> Result<(), FormError> { - let widgets: std::collections::HashSet = f.widgets.iter().map(|w| w.obj).collect(); - for p in page_refs(doc) { - let Some(a) = doc.get(p).as_dict().and_then(|d| d.get(b"Annots").cloned()) else { continue }; - let list = doc.resolve(&a).as_array().cloned().unwrap_or_default(); - let kept: Vec = list.iter().filter(|o| !o.as_ref().is_some_and(|r| widgets.contains(&r))).cloned().collect(); - if kept.len() != list.len() { - match a.as_ref() { - Some(r) if doc.get(r).as_array().is_some() => doc.set(r, Object::Array(kept)), - _ => doc.update_dict(p, |d| d.set(b"Annots".to_vec(), Object::Array(kept)))?, - } - } - } - detach(doc, f.obj)?; - // Out of the calculation order too: a `/CO` entry would keep the field, its value and its - // widgets reachable for a full save. - drop_from_calculation_order(doc, |r| r == f.obj || widgets.contains(&r)) -} - -/// Remove the `/CO` entries `gone` matches, whether the form and the array are held directly or -/// by reference. -fn drop_from_calculation_order(doc: &mut Document, gone: impl Fn(ObjRef) -> bool) -> Result<(), FormError> { - let Some(root) = doc.root() else { return Ok(()) }; - let Some(af_obj) = doc.get(root).as_dict().and_then(|d| d.get(b"AcroForm").cloned()) else { return Ok(()) }; - let Some(mut af) = doc.resolve(&af_obj).as_dict().cloned() else { return Ok(()) }; - let Some(co_obj) = af.get(b"CO").cloned() else { return Ok(()) }; - let co = doc.resolve(&co_obj).as_array().cloned().unwrap_or_default(); - let kept: Vec = co.iter().filter(|o| !o.as_ref().is_some_and(&gone)).cloned().collect(); - if kept.len() == co.len() { - return Ok(()); - } - match (co_obj.as_ref(), af_obj.as_ref()) { - (Some(r), _) if doc.get(r).as_array().is_some() => doc.set(r, Object::Array(kept)), - (_, Some(af_ref)) => doc.update_dict(af_ref, |d| d.set(b"CO".to_vec(), Object::Array(kept)))?, - (_, None) => { - af.set(b"CO".to_vec(), Object::Array(kept)); - doc.update_dict(root, |d| d.set(b"AcroForm".to_vec(), Object::Dict(af)))?; - } - } - Ok(()) -} - -/// Widget-only keys: they stay with each widget when a merged field is split. -const WIDGET_KEYS: [&[u8]; 12] = [b"Type", b"Subtype", b"Rect", b"P", b"AP", b"AS", b"MK", b"F", b"BS", b"Border", b"H", b"StructParent"]; - -/// Duplicate a field onto other pages (Acrobat: right-click a field ▸ Duplicate): each page -/// gets a widget at the same place, belonging to the same field, so they share one value. -/// A field that is its own widget is first split into a field and a widget kid. Returns how -/// many widgets were added (pages that already have one are skipped). -pub fn duplicate_field(doc: &mut Document, name: &str, pages: &[usize]) -> Result { - let f = fields(doc).into_iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?; - let model = f.widgets.first().cloned().ok_or_else(|| FormError::Invalid(format!("{name} has no widget")))?; - let refs = page_refs(doc); - for p in pages { - if *p >= refs.len() { - return invalid(format!("page {} does not exist", p + 1)); - } - } - let have: std::collections::HashSet = f.widgets.iter().filter_map(|w| w.page).collect(); - let targets: Vec = pages.iter().copied().filter(|p| !have.contains(p)).collect(); - if targets.is_empty() { - return Ok(0); - } - // A merged field/widget becomes a field with one widget kid. - let mut model_ref = model.obj; - if model.obj == f.obj { - let d = doc.get(f.obj).as_dict().cloned().unwrap_or_default(); - let mut kid = Dict::new(); - for k in WIDGET_KEYS { - if let Some(v) = d.get(k) { - kid.set(k.to_vec(), v.clone()); - } - } - kid.set(b"Parent".to_vec(), Object::Ref(f.obj)); - let kr = doc.add(Object::Dict(kid)); - doc.update_dict(f.obj, |d| { - for k in WIDGET_KEYS { - d.remove(k); - } - d.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(kr)])); - })?; - // The page lists the widget now, not the field. - if let Some(p) = model.page { - let pr = refs[p]; - let annots = doc.get(pr).as_dict().and_then(|d| d.get(b"Annots").cloned()); - let swap = - |a: Vec| -> Vec { a.into_iter().map(|o| if o.as_ref() == Some(f.obj) { Object::Ref(kr) } else { o }).collect() }; - match annots { - Some(Object::Ref(ar)) => { - let a = doc.get(ar).as_array().cloned().unwrap_or_default(); - doc.set(ar, Object::Array(swap(a))); - } - Some(Object::Array(a)) => doc.update_dict(pr, |d| d.set(b"Annots".to_vec(), Object::Array(swap(a))))?, - _ => {} - } - } - model_ref = kr; - } - let template = doc.get(model_ref).as_dict().cloned().unwrap_or_default(); - let mut added = Vec::new(); - for p in &targets { - let pr = refs[*p]; - let mut w = template.clone(); - w.set(b"P".to_vec(), Object::Ref(pr)); - w.set(b"Parent".to_vec(), Object::Ref(f.obj)); - w.remove(b"StructParent"); - let wr = doc.add(Object::Dict(w)); - let annots = doc.get(pr).as_dict().and_then(|d| d.get(b"Annots").cloned()); - match annots { - Some(Object::Ref(ar)) => { - let mut a = doc.get(ar).as_array().cloned().unwrap_or_default(); - a.push(Object::Ref(wr)); - doc.set(ar, Object::Array(a)); - } - other => { - let mut a = other.and_then(|o| o.as_array().cloned()).unwrap_or_default(); - a.push(Object::Ref(wr)); - doc.update_dict(pr, |d| d.set(b"Annots".to_vec(), Object::Array(a)))?; - } - } - added.push(wr); - } - doc.update_dict(f.obj, |d| { - let mut kids = d.get(b"Kids").and_then(|k| k.as_array().cloned()).unwrap_or_default(); - kids.extend(added.iter().map(|r| Object::Ref(*r))); - d.set(b"Kids".to_vec(), Object::Array(kids)); - })?; - redraw_field(doc, name)?; - Ok(added.len()) -} diff --git a/crates/forms/src/author/edit.rs b/crates/forms/src/author/edit.rs new file mode 100644 index 0000000000..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 39cebd8fdd..6d8cf9c99d 100644 --- a/crates/forms/src/lib.rs +++ b/crates/forms/src/lib.rs @@ -35,6 +35,27 @@ pub use scripting::{ set_field_script, }; +mod appearances; +mod read; +mod tab_order; +mod values; + +pub use appearances::{FieldAppearanceCache, declares_pdfa, need_appearances, undrawn_fields, with_field_appearances, with_field_appearances_cached}; +pub(crate) use read::page_refs; +pub use read::{Fields, acroform, adopted_page_fields, fields, fields_with_adopted}; +#[cfg(test)] +use read::{MAX_SCRIPT_BYTES, PageWalk, enumerate, form_reads}; +use read::{script, script_budget}; +pub use tab_order::{TabOrder, move_in_tab_order, set_tab_order}; +#[cfg(test)] +use values::has_password_flag; +use values::write_value; +pub(crate) use values::{ask_viewers_to_redraw, merged_appearance}; +pub use values::{ + calculations_changed, clear_password_values, has_password_values, password_values, recalculate, recalculate_with, reset, restore_password_values, + set_value, set_value_with, set_values, +}; + #[cfg(test)] mod tests; @@ -267,1592 +288,6 @@ fn name_obj(text: &str) -> Object { Object::Name(name_bytes(text)) } -/// At most this many `/State` entries of a set-layer-visibility action are read. -const MAX_LAYER_STATE: usize = 1024; - -/// A push button's mouse-up action (`/A`, or `/AA /U`, on the field or its widget). -fn button_action(doc: &Document, d: &Dict, widgets: &[ObjRef], scripts: &mut DecodeBudget) -> Option { - // A button PdfCraft generated for an XFA image field (`/PCXfaImage true`, no PDF action): - // clicking it picks a picture, as `buttonImportIcon()` does. - if matches!(d.get(b"PCXfaImage").map(|o| doc.resolve(o)).as_deref(), Some(Object::Bool(true))) { - return Some(af::ButtonAction::ImportIcon); - } - let pick = |dict: &Dict| -> Option { - dict.get(b"A").cloned().or_else(|| dict.get(b"AA").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().and_then(|aa| aa.get(b"U").cloned()))) - }; - let action = pick(d).or_else(|| widgets.iter().find_map(|w| doc.get(*w).as_dict().and_then(pick)))?; - let a = doc.resolve(&action); - let a = a.as_dict()?; - let s = |k: &[u8]| a.get(k).and_then(|v| text_of(&doc.resolve(v))); - Some(match a.name(b"S")? { - b"ResetForm" => { - let fields = a - .get(b"Fields") - .map(|f| doc.resolve(f)) - .and_then(|f| f.as_array().cloned()) - .unwrap_or_default() - .iter() - .filter_map(|o| match o { - Object::Ref(r) => doc.get(*r).as_dict().and_then(|fd| fd.get(b"T").and_then(|t| text_of(&doc.resolve(t)))), - other => text_of(&doc.resolve(other)), - }) - .collect(); - let exclude = a.get(b"Flags").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0) & 1 != 0; - af::ButtonAction::Reset { fields, exclude } - } - b"Named" => af::ButtonAction::Named(String::from_utf8_lossy(a.name(b"N")?).into_owned()), - b"URI" => af::ButtonAction::Uri(s(b"URI")?), - b"SubmitForm" => af::ButtonAction::Submit( - a.get(b"F") - .map(|f| doc.resolve(f)) - .and_then(|f| f.as_dict().and_then(|fd| fd.get(b"F").and_then(|x| text_of(&doc.resolve(x)))).or_else(|| text_of(&f))) - .unwrap_or_default(), - ), - b"GoTo" => { - let dest = doc.resolve(a.get(b"D")?); - let target = dest.as_array()?.first()?.as_ref()?; - af::ButtonAction::GoTo(page_refs(doc).iter().position(|p| *p == target)?) - } - b"Hide" => { - // /T: a field name, an annotation (or field) reference, or an array of them. - let target = |o: &Object| match o { - Object::Ref(r) => qualified_name(doc, *r).or_else(|| text_of(&doc.resolve(o))), - other => text_of(other), - }; - let fields = match a.get(b"T") { - Some(t) => match &*doc.resolve(t) { - Object::Array(items) => items.iter().filter_map(target).collect(), - _ => target(t).into_iter().collect(), - }, - None => Vec::new(), - }; - let hide = !a.get(b"H").is_some_and(|h| matches!(&*doc.resolve(h), Object::Bool(false))); - af::ButtonAction::ShowHide { fields, hide } - } - b"SetOCGState" => { - // /State: ON, OFF or Toggle, each followed by the groups it applies to. - let mut changes = Vec::new(); - if let Some(state) = a.get(b"State") { - let mut op = None; - for item in doc.resolve(state).as_array().into_iter().flatten().take(MAX_LAYER_STATE) { - match item { - Object::Name(n) => { - op = match n.as_slice() { - b"ON" => Some(af::LayerOp::On), - b"OFF" => Some(af::LayerOp::Off), - b"Toggle" => Some(af::LayerOp::Toggle), - _ => None, - } - } - Object::Ref(r) => changes.extend(op.map(|op| (op, (r.num, r.generation)))), - _ => {} - } - } - } - let preserve_rb = !a.get(b"PreserveRB").is_some_and(|p| matches!(&*doc.resolve(p), Object::Bool(false))); - af::ButtonAction::SetLayers { changes, preserve_rb } - } - b"JavaScript" => af::button_script(&script(doc, &action, scripts)?), - _ => return None, - }) -} - -/// The fully qualified name of the field that `r` (a field or one of its widgets) belongs to, -/// from the partial names (`/T`) up its `/Parent` chain. -fn qualified_name(doc: &Document, r: ObjRef) -> Option { - let mut parts = Vec::new(); - let mut seen = std::collections::HashSet::new(); - let mut cur = Some(r); - while let Some(c) = cur { - if seen.len() > 64 || !seen.insert(c) { - break; - } - let obj = doc.get(c); - let d = obj.as_dict()?; - if let Some(t) = d.get(b"T").and_then(|o| text_of(&doc.resolve(o))) { - parts.push(t); - } - cur = d.get(b"Parent").and_then(|p| p.as_ref()); - } - parts.reverse(); - (!parts.is_empty()).then(|| parts.join(".")) -} - -/// JavaScript streams one reading of the form (its fields, or its document scripts) may decode -/// in all, however many actions name the same stream. -const MAX_SCRIPT_BYTES: usize = 64 << 20; - -/// A budget for the script streams of one reading of the form. -fn script_budget() -> DecodeBudget { - DecodeBudget::new(MAX_SCRIPT_BYTES, 0) -} - -/// The JavaScript of an action dictionary (`/JS` string or stream). A stream is decoded against -/// `budget`, and is `None` once it is spent. -fn script(doc: &Document, action: &Object, budget: &mut DecodeBudget) -> Option { - let a = doc.resolve(action); - let js = a.as_dict()?.get(b"JS")?.clone(); - match &*doc.resolve(&js) { - Object::String(s) => Some(s.to_text()), - Object::Stream(s) => budget.decode(s).ok().map(|b| String::from_utf8_lossy(&b).into_owned()), - _ => None, - } -} - -/// A field's format/keystroke, validate and calculate scripts (field dictionary, or its only -/// widget's when the field has none). -fn actions_of(doc: &Document, d: &Dict, widgets: &[ObjRef], scripts: &mut DecodeBudget) -> af::Actions { - let aa = d.get(b"AA").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().cloned()).or_else(|| { - (widgets.len() == 1) - .then(|| doc.get(widgets[0])) - .and_then(|w| w.as_dict().and_then(|wd| wd.get(b"AA").map(|a| doc.resolve(a)))) - .and_then(|a| a.as_dict().cloned()) - }); - let mut out = af::Actions::default(); - let Some(aa) = aa else { return out }; - let mut js = |k: &[u8]| aa.get(k).and_then(|a| script(doc, a, scripts)); - if let Some(f) = js(b"F") { - match af::parse_format(&f) { - Some(fm) => out.format = fm, - None if !f.trim().is_empty() => { - out.unsupported.push("format"); - out.scripts.format = Some(f); - } - None => {} - } - } - if let Some(k) = js(b"K") - && out.format == af::Format::None - { - // A mask has only a keystroke script. - match af::parse_format(&k) { - Some(fm @ af::Format::Mask(_)) => out.format = fm, - Some(_) => {} - None if !k.trim().is_empty() => { - out.unsupported.push("keystroke"); - out.scripts.keystroke = Some(k); - } - None => {} - } - } - if let Some(v) = js(b"V") { - match af::parse_validate(&v) { - Some(vv) => out.validate = vv, - None if !v.trim().is_empty() => { - out.unsupported.push("validate"); - out.scripts.validate = Some(v); - } - None => {} - } - } - if let Some(c) = js(b"C") { - match af::parse_calculate(&c) { - Some(cc) => out.calculate = cc, - None if !c.trim().is_empty() => { - out.unsupported.push("calculate"); - out.scripts.calculate = Some(c); - } - None => {} - } - } - out -} - -fn values_of(doc: &Document, o: Option<&Object>) -> Vec { - let Some(o) = o else { return Vec::new() }; - match &*doc.resolve(o) { - Object::Array(a) => a.iter().filter_map(|x| text_of(&doc.resolve(x))).collect(), - Object::Stream(s) => s.decoded().ok().map(|b| PdfString::literal(b).to_text()).into_iter().collect(), - other => text_of(other).filter(|s| s != "Off" && !s.is_empty()).into_iter().collect(), - } -} - -fn nums(doc: &Document, o: Option<&Object>) -> Option> { - let o = doc.resolve(o?); - o.as_array()?.iter().map(|x| doc.resolve(x).as_f64()).collect() -} - -/// Leaf page objects in order (a small walker; `organize` and `annot` have their own). -pub(crate) fn page_refs(doc: &Document) -> Vec { - let Some(root) = doc.root() else { return Vec::new() }; - let Some(pages) = doc.get(root).as_dict().and_then(|d| d.reference(b"Pages")) else { return Vec::new() }; - PageWalk::new(doc, pages).collect() -} - -/// Depth-first walk over the leaf pages under `top`. It keeps one cursor per open `/Kids` array, -/// never one entry per child, so a child listed many times or shared between nodes is skipped by -/// the seen check without being queued first (`pdfcraft_model::pages` walks the same way). -/// Cycles end at the seen check and at most a million nodes are visited. -struct PageWalk<'a> { - doc: &'a Document, - seen: std::collections::HashSet, - pending: Vec<(std::sync::Arc, usize)>, -} - -impl<'a> PageWalk<'a> { - fn new(doc: &'a Document, top: ObjRef) -> Self { - let top = std::sync::Arc::new(Object::Array(vec![Object::Ref(top)])); - Self { doc, seen: Default::default(), pending: vec![(top, 0)] } - } -} - -impl Iterator for PageWalk<'_> { - type Item = ObjRef; - - fn next(&mut self) -> Option { - while let Some((kids, next)) = self.pending.last_mut() { - let Some(kid) = kids.as_array().and_then(|a| a.get(*next)) else { - self.pending.pop(); - continue; - }; - let node = kid.as_ref(); - *next += 1; - let Some(node) = node else { continue }; - if self.seen.len() >= 1_000_000 { - self.pending.clear(); - return None; - } - if !self.seen.insert(node) { - continue; - } - let obj = self.doc.get(node); - let Some(d) = obj.as_dict() else { continue }; - match d.get(b"Kids").map(|k| self.doc.resolve(k)) { - Some(kids) if d.name(b"Type") != Some(b"Page") => self.pending.push((kids, 0)), - _ => return Some(node), - } - } - None - } -} - -/// The AcroForm dictionary. -pub fn acroform(doc: &Document) -> Option { - let root = doc.root()?; - let cat = doc.get(root); - let af = cat.as_dict()?.get(b"AcroForm")?.clone(); - doc.resolve(&af).as_dict().cloned() -} - -#[derive(Clone, Default)] -struct Inherited { - name: String, - ft: Option>, - ff: Option, - v: Option, - dv: Option, - da: Option, - q: Option, - max_len: Option, -} - -/// Every terminal field: the `/Fields` tree in order, then the fields reachable only through the -/// page annotations (see [`adopt_page_fields`]). -pub fn fields(doc: &Document) -> Vec { - fields_with_adopted(doc).items -} - -/// How many of [`fields`] were reachable only through the page annotations: the form's `/Fields` -/// list names none or only some of them, so the leniency that adopts them is worth recording. -pub fn adopted_page_fields(doc: &Document) -> usize { - fields_with_adopted(doc).adopted_page_fields -} - -/// The terminal fields and page-only fields adopted while reading a form. -#[derive(Clone, Debug, PartialEq)] -pub struct Fields { - /// Terminal fields in tree order, followed by adopted page-only fields. - pub items: Vec, - /// How many fields were reachable only through page annotations. - pub adopted_page_fields: usize, -} - -/// Read all terminal fields and the page-only adoption count in one enumeration. -pub fn fields_with_adopted(doc: &Document) -> Fields { - let (items, adopted_page_fields) = enumerate(doc); - Fields { items, adopted_page_fields } -} - -#[cfg(test)] -thread_local! { - /// Whole-form readings ([`enumerate`]) on this thread. - static FORM_READS: std::cell::Cell = const { std::cell::Cell::new(0) }; -} - -/// How many times this thread has read the whole form. -#[cfg(test)] -fn form_reads() -> usize { - FORM_READS.with(std::cell::Cell::get) -} - -fn enumerate(doc: &Document) -> (Vec, usize) { - #[cfg(test)] - FORM_READS.with(|n| n.set(n.get() + 1)); - let af = acroform(doc); - let mut page_of = std::collections::HashMap::new(); - let mut annot_index = std::collections::HashMap::new(); - let pages = page_refs(doc); - for (i, p) in pages.iter().enumerate() { - if let Some(a) = doc.get(*p).as_dict().and_then(|d| d.get(b"Annots").cloned()) { - for (k, e) in doc.resolve(&a).as_array().into_iter().flatten().enumerate() { - if let Some(r) = e.as_ref() { - page_of.entry(r).or_insert(i); - annot_index.entry(r).or_insert(k); - } - } - } - } - let base = match &af { - Some(af) => Inherited { - da: af.get(b"DA").and_then(|o| text_of(&doc.resolve(o))), - q: af.get(b"Q").and_then(|o| doc.resolve(o).as_int()), - ..Default::default() - }, - None => Inherited::default(), - }; - let mut out = Vec::new(); - let mut seen = Seen { fields: std::collections::HashSet::new(), widgets: std::collections::HashSet::new(), scripts: script_budget() }; - if let Some(af) = &af { - for f in af.get(b"Fields").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default() { - if let Some(r) = f.as_ref() { - walk(doc, r, &base, &page_of, &mut seen, &mut out, 0); - } - } - } - let listed = out.len(); - adopt_page_fields(doc, &pages, &base, &page_of, &mut seen, &mut out); - let adopted = out.len() - listed; - rank_tabs(doc, &pages, &annot_index, &mut out); - (out, adopted) -} - -/// Widget annotations no `/Fields` entry reaches are adopted as fields, as Acrobat and the -/// browsers do: some writers list only some of the fields, or none at all. Each widget is adopted -/// through its topmost unlisted `/Parent`, so a field split across several widgets stays one -/// field. Tree-listed fields keep their order first; the rest follow in page-annotation order. -fn adopt_page_fields( - doc: &Document, - pages: &[ObjRef], - base: &Inherited, - page_of: &std::collections::HashMap, - seen: &mut Seen, - out: &mut Vec, -) { - for p in pages { - let Some(a) = doc.get(*p).as_dict().and_then(|d| d.get(b"Annots").cloned()) else { continue }; - let annots = doc.resolve(&a); - let Some(annots) = annots.as_array() else { continue }; - for e in annots { - let Some(r) = e.as_ref() else { continue }; - if seen.fields.contains(&r) || seen.widgets.contains(&r) { - continue; - } - let obj = doc.get(r); - let Some(d) = obj.as_dict() else { continue }; - if d.name(b"Subtype") != Some(b"Widget") { - continue; - } - if !d.contains(b"FT") && !d.contains(b"T") && !d.contains(b"Parent") { - continue; - } - if let Some(root) = unlisted_root(doc, r, &seen.fields) { - walk(doc, root, base, page_of, seen, out, 0); - } - } - } -} - -/// The topmost field above `r` (itself without a `/Parent`), or `None` when `r` or an ancestor is -/// already reachable from the `/Fields` tree, or a `/Parent` loop hides the top. -fn unlisted_root(doc: &Document, r: ObjRef, seen: &std::collections::HashSet) -> Option { - let mut top = r; - let mut visited = std::collections::HashSet::from([r]); - loop { - let parent = doc.get(top).as_dict().and_then(|d| d.reference(b"Parent")); - let Some(p) = parent else { - return if seen.contains(&top) { None } else { Some(top) }; - }; - if !visited.insert(p) || visited.len() > 64 { - return None; - } - if seen.contains(&p) { - return None; - } - top = p; - } -} - -/// A page's tab order (`/Tabs`). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum TabOrder { - /// Rows, top to bottom, left to right within a row (`/R`). - Row, - /// Columns, left to right, top to bottom within a column (`/C`). - Column, - /// The document structure (`/S`; annotation order when the file has none). - Structure, - /// Unspecified: annotation order. - Annotations, -} - -impl TabOrder { - fn of(name: Option<&[u8]>) -> TabOrder { - match name { - Some(b"R") => TabOrder::Row, - Some(b"C") => TabOrder::Column, - Some(b"S") => TabOrder::Structure, - _ => TabOrder::Annotations, - } - } -} - -/// A widget on a page for tab ordering: (page, field, widget, rect, annotation index). -type TabItem = (usize, usize, usize, [f64; 4], usize); - -/// Every widget's place in the tab order. -fn rank_tabs(doc: &Document, pages: &[ObjRef], annot_index: &std::collections::HashMap, out: &mut [Field]) { - let mut items: Vec = Vec::new(); - for (fi, f) in out.iter().enumerate() { - for (wi, w) in f.widgets.iter().enumerate() { - if let Some(p) = w.page { - items.push((p, fi, wi, w.rect, annot_index.get(&w.obj).copied().unwrap_or(usize::MAX))); - } - } - } - let mut ordered: Vec<(usize, usize)> = Vec::with_capacity(items.len()); - for (pi, p) in pages.iter().enumerate() { - let mut on: Vec<_> = items.iter().filter(|i| i.0 == pi).collect(); - if on.is_empty() { - continue; - } - let tabs = TabOrder::of(doc.get(*p).as_dict().and_then(|d| d.name(b"Tabs"))); - match tabs { - TabOrder::Row | TabOrder::Column => { - let row = tabs == TabOrder::Row; - // Primary axis: rows by top edge (down the page), columns by left edge. - on.sort_by(|a, b| if row { b.3[3].total_cmp(&a.3[3]) } else { a.3[0].total_cmp(&b.3[0]) }); - let mut groups: Vec> = Vec::new(); - for it in on { - let fits = groups.last().is_some_and(|g| { - let first = g[0]; - if row { - (first.3[3] - it.3[3]).abs() < ((first.3[3] - first.3[1]) / 2.0).max(2.0) - } else { - (it.3[0] - first.3[0]).abs() < ((first.3[2] - first.3[0]) / 2.0).max(2.0) - } - }); - if fits && let Some(g) = groups.last_mut() { - g.push(it); - } else { - groups.push(vec![it]); - } - } - for mut g in groups { - g.sort_by(|a, b| if row { a.3[0].total_cmp(&b.3[0]) } else { b.3[3].total_cmp(&a.3[3]) }); - ordered.extend(g.iter().map(|i| (i.1, i.2))); - } - } - TabOrder::Structure | TabOrder::Annotations => { - on.sort_by_key(|i| i.4); - ordered.extend(on.iter().map(|i| (i.1, i.2))); - } - } - } - for f in out.iter_mut() { - for w in &mut f.widgets { - w.tab = usize::MAX; - } - } - for (rank, (fi, wi)) in ordered.into_iter().enumerate() { - out[fi].widgets[wi].tab = rank; - } -} - -/// Set the tab order of pages (0-based). -pub fn set_tab_order(doc: &mut Document, pages: &[usize], order: TabOrder) -> Result<(), FormError> { - let refs = page_refs(doc); - for &p in pages { - let r = *refs.get(p).ok_or_else(|| FormError::Invalid(format!("page {} does not exist", p + 1)))?; - doc.update_dict(r, |d| match order { - TabOrder::Row => d.set(b"Tabs".to_vec(), Object::name("R")), - TabOrder::Column => d.set(b"Tabs".to_vec(), Object::name("C")), - TabOrder::Structure => d.set(b"Tabs".to_vec(), Object::name("S")), - TabOrder::Annotations => { - d.remove(b"Tabs"); - } - })?; - } - Ok(()) -} - -/// What one reading of the form's fields has used: the field and widget dictionaries read, and -/// the script budget their actions share. -struct Seen { - fields: std::collections::HashSet, - /// A widget belongs to the first field that lists it, however often it is listed. - widgets: std::collections::HashSet, - scripts: DecodeBudget, -} - -/// Read the field at `r` and the fields under it into `out`, with what they inherit from -/// `parent`: kids with a name are fields of their own, kids without are this field's widgets. -fn walk( - doc: &Document, - r: ObjRef, - parent: &Inherited, - page_of: &std::collections::HashMap, - seen: &mut Seen, - out: &mut Vec, - depth: usize, -) { - if depth > 64 || !seen.fields.insert(r) { - return; - } - let obj = doc.get(r); - let Some(d) = obj.as_dict() else { return }; - let mut inh = parent.clone(); - if let Some(t) = d.get(b"T").and_then(|o| text_of(&doc.resolve(o))) { - inh.name = if inh.name.is_empty() { t } else { format!("{}.{t}", inh.name) }; - } - if let Some(ft) = d.name(b"FT") { - inh.ft = Some(ft.to_vec()); - } - if let Some(ff) = field_flags(doc, d) { - inh.ff = Some(ff); - } - for (k, slot) in [(&b"V"[..], &mut inh.v), (b"DV", &mut inh.dv)] { - if let Some(v) = d.get(k) { - *slot = Some(v.clone()); - } - } - if let Some(da) = d.get(b"DA").and_then(|o| text_of(&doc.resolve(o))) { - inh.da = Some(da); - } - if let Some(q) = d.get(b"Q").and_then(|o| doc.resolve(o).as_int()) { - inh.q = Some(q); - } - if let Some(m) = d.get(b"MaxLen").and_then(|o| doc.resolve(o).as_int()) { - // Compatibility: some writers emit zero for an unlimited text field. Keep /MaxLen - // untouched in the object graph, but expose only positive limits to both UI and API. - // An explicit zero also clears an inherited limit rather than inheriting it again. - inh.max_len = usize::try_from(m).ok().filter(|limit| *limit > 0); - } - let kids: Vec = - d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_default().iter().filter_map(|k| k.as_ref()).collect(); - // Kids with /T are fields; kids without are this field's widgets. - let field_kids: Vec = kids.iter().copied().filter(|k| doc.get(*k).as_dict().is_some_and(|kd| kd.contains(b"T"))).collect(); - if !field_kids.is_empty() { - for k in field_kids { - walk(doc, k, &inh, page_of, seen, out, depth + 1); - } - return; - } - // A field of no kind this reads is skipped before it claims widgets or scripts, which a later - // field listing them then reads. - let ff = inh.ff.unwrap_or(0); - let kind = match inh.ft.as_deref() { - Some(b"Tx") => FieldKind::Text, - Some(b"Btn") if ff & flags::PUSH_BUTTON != 0 => FieldKind::PushButton, - Some(b"Btn") if ff & flags::RADIO != 0 => FieldKind::Radio, - Some(b"Btn") => FieldKind::CheckBox, - Some(b"Ch") if ff & flags::COMBO != 0 => FieldKind::Combo, - Some(b"Ch") => FieldKind::List, - Some(b"Sig") => FieldKind::Signature, - _ => return, - }; - let widget_refs: Vec = if d.contains(b"Rect") { vec![r] } else { kids }; - let widget_refs: Vec = widget_refs.into_iter().filter(|w| seen.widgets.insert(*w)).collect(); - let actions = actions_of(doc, d, &widget_refs, &mut seen.scripts); - let button = (inh.ft.as_deref() == Some(b"Btn") && inh.ff.unwrap_or(0) & flags::PUSH_BUTTON != 0) - .then(|| button_action(doc, d, &widget_refs, &mut seen.scripts)) - .flatten(); - let widgets = widget_refs - .into_iter() - .filter_map(|w| { - let wo = doc.get(w); - let wd = wo.as_dict()?; - let rect = nums(doc, wd.get(b"Rect")).filter(|r| r.len() == 4).unwrap_or_else(|| vec![0.0; 4]); - let annot_flags = wd.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0); - let own_ff = if w == r { 0 } else { field_flags(doc, wd).unwrap_or(0) }; - let on_state = wd - .get(b"AP") - .map(|ap| doc.resolve(ap)) - .and_then(|ap| ap.as_dict().and_then(|a| a.get(b"N").cloned())) - .and_then(|n| doc.resolve(&n).as_dict().cloned()) - .and_then(|n| n.iter().map(|(k, _)| name_text(k)).find(|k| k != "Off")); - Some(Widget { - obj: w, - page: page_of.get(&w).copied(), - rect: [rect[0].min(rect[2]), rect[1].min(rect[3]), rect[0].max(rect[2]), rect[1].max(rect[3])], - on_state: on_state.filter(|_| matches!(kind, FieldKind::CheckBox | FieldKind::Radio)), - state: wd.name(b"AS").map(name_text), - tab: usize::MAX, - locked: annot_flags & 128 != 0, - hidden: annot_flags & (2 | 32) != 0, - read_only: (ff | own_ff) & flags::READ_ONLY != 0, - rotation: appearance::mk_rotation(doc, wd), - caption: (kind == FieldKind::PushButton) - .then(|| { - let mk = doc.resolve(wd.get(b"MK")?); - let value = doc.resolve(mk.as_dict()?.get(b"CA")?); - value.as_string().map(|s| s.to_text()) - }) - .flatten(), - list: (kind == FieldKind::List).then(|| appearance::list_rows_of(doc, inh.da.as_deref().unwrap_or("/Helv 0 Tf 0 g"), Some(d), wd)), - round: kind == FieldKind::Radio || (kind == FieldKind::CheckBox && appearance::is_round(doc, wd)), - }) - }) - .collect(); - let options = d - .get(b"Opt") - .map(|o| doc.resolve(o)) - .and_then(|o| o.as_array().cloned()) - .unwrap_or_default() - .iter() - .filter_map(|o| match &*doc.resolve(o) { - Object::Array(pair) if pair.len() >= 2 => Some((text_of(&doc.resolve(&pair[0]))?, text_of(&doc.resolve(&pair[1]))?)), - other => text_of(other).map(|t| (t.clone(), t)), - }) - .collect(); - out.push(Field { - name: inh.name.clone(), - obj: r, - kind, - value: values_of(doc, inh.v.as_ref()), - default: values_of(doc, inh.dv.as_ref()), - flags: ff, - max_len: inh.max_len, - options, - da: inh.da.clone().unwrap_or_else(|| "/Helv 0 Tf 0 g".into()), - quadding: inh.q.unwrap_or(0), - tooltip: d.get(b"TU").and_then(|o| text_of(&doc.resolve(o))), - widgets, - actions, - button, - }); -} - -fn field_flags(doc: &Document, dict: &Dict) -> Option { - dict.get(b"Ff").and_then(|value| doc.resolve(value).as_int()).and_then(|value| u32::try_from(value).ok()) -} - -// ── writing ───────────────────────────────────────────────────────────────────────────────── - -/// Fill the field `name`. -pub fn set_value(doc: &mut Document, name: &str, value: &FieldValue) -> Result<(), FormError> { - set_value_with(doc, name, value, &mut NoScripts) -} - -/// [`set_value`], running the fields' JavaScript through `scripts`. -pub fn set_value_with(doc: &mut Document, name: &str, value: &FieldValue, scripts: &mut dyn Scripts) -> Result<(), FormError> { - let all = fields(doc); - if all.is_empty() { - return Err(FormError::NoForm); - } - let named: Vec = all.iter().enumerate().filter(|(_, f)| f.name == name).map(|(i, _)| i).collect(); - fill(doc, &all, &named, name, value, scripts)?; - recalculate_with(doc, scripts)?; - Ok(()) -} - -/// Fill several fields, as [`set_value`] fills each in turn, but reading the form once and -/// recalculating once, after the last value. Filling fields one by one reads the whole form twice -/// per field, which takes minutes on a form with thousands of fields. -/// -/// Returns each value's result, in order. The outer `Err` is the recalculation failing, after -/// which, as with [`set_value`], no value counts as filled (though each was written). -pub fn set_values(doc: &mut Document, values: &[(String, FieldValue)]) -> Result>, FormError> { - let mut scripts = NoScripts; - let mut form = NamedFields::read(doc); - // The field objects this batch wrote since the form was last read. - let mut written = std::collections::HashSet::new(); - let mut results = Vec::with_capacity(values.len()); - let mut filled = false; - for (name, value) in values { - if form.all.is_empty() { - results.push(Err(FormError::NoForm)); - continue; - } - // A field written earlier in the batch (named twice, or a twin's name) is read again, - // so this write starts from what the earlier one left, as it would one by one. - if form.objs(name).any(|o| written.contains(&o)) { - form = NamedFields::read(doc); - written.clear(); - } - let named = form.named(name); - let result = fill(doc, &form.all, named, name, value, &mut scripts); - written.extend(form.objs(name)); - filled |= result.is_ok(); - results.push(result); - } - if filled { - recalculate_with(doc, &mut scripts)?; - } - Ok(results) -} - -/// The form's fields, with the positions of each fully qualified name in them (in field order). -struct NamedFields { - all: Vec, - by_name: std::collections::HashMap>, -} - -impl NamedFields { - fn read(doc: &Document) -> Self { - let all = fields(doc); - let mut by_name: std::collections::HashMap> = std::collections::HashMap::new(); - for (i, f) in all.iter().enumerate() { - by_name.entry(f.name.clone()).or_default().push(i); - } - NamedFields { all, by_name } - } - - fn named(&self, name: &str) -> &[usize] { - self.by_name.get(name).map_or(&[], Vec::as_slice) - } - - /// The field objects named `name`. - fn objs<'a>(&'a self, name: &str) -> impl Iterator + 'a { - self.named(name).iter().filter_map(|&i| self.all.get(i)).map(|f| f.obj) - } -} - -/// Write `value` into the field `name`, without recalculating: `named` are the positions in -/// `all` (the form as read before this write) of the fields with that name, in field order. -fn fill(doc: &mut Document, all: &[Field], named: &[usize], name: &str, value: &FieldValue, scripts: &mut dyn Scripts) -> Result<(), FormError> { - let f = named.first().and_then(|&i| all.get(i)).ok_or_else(|| FormError::NoSuchField(name.into()))?; - if f.read_only() { - return Err(FormError::ReadOnly(name.into())); - } - write_value(doc, f, value, scripts)?; - author::form_fonts_as_objects(doc, all)?; - // Fields with the same fully qualified name are one field (§12.7.3.1). Writers that repeat a - // name as separate field dictionaries, instead of widgets under one parent, get the value - // the first one took after its scripts, without running them again. - let twins: Vec<&Field> = named.iter().filter_map(|&i| all.get(i)).filter(|t| t.obj != f.obj && t.kind == f.kind).collect(); - if !twins.is_empty() { - let taken = fields(doc).into_iter().find(|x| x.obj == f.obj).map(|x| x.value).unwrap_or_default(); - for t in twins { - share_value(doc, f.obj, t, &taken, scripts)?; - } - } - Ok(()) -} - -/// Give `twin` the value `source` holds: its `/V` (and a choice's `/I`), drawn in the twin's own -/// widgets. -fn share_value(doc: &mut Document, source: ObjRef, twin: &Field, value: &[String], scripts: &mut dyn Scripts) -> Result<(), FormError> { - match twin.kind { - FieldKind::CheckBox => { - let state = twin.widgets.iter().find_map(|w| w.on_state.clone()).unwrap_or_else(|| "Yes".into()); - set_states(doc, twin, (!value.is_empty()).then_some(state.as_str())) - } - FieldKind::Radio => { - let choice = value.first().filter(|c| twin.widgets.iter().any(|w| w.on_state.as_deref() == Some(c.as_str()))); - set_states(doc, twin, choice.map(String::as_str)) - } - FieldKind::Text | FieldKind::Combo | FieldKind::List => { - let src = doc.get(source).as_dict().cloned().unwrap_or_default(); - doc.update_dict(twin.obj, |d| { - for key in [&b"V"[..], b"I"] { - match src.get(key) { - Some(v) => d.set(key.to_vec(), v.clone()), - None => { - d.remove(key); - } - } - } - d.remove(b"RV"); - })?; - redraw(doc, twin, value, scripts) - } - FieldKind::PushButton | FieldKind::Signature => Ok(()), - } -} - -/// Run every field's Calculate script in the form's calculation order (`/CO`, then the other -/// calculated fields), as Acrobat does after any value changes. Returns how many changed. -pub fn recalculate(doc: &mut Document) -> Result { - recalculate_with(doc, &mut NoScripts) -} - -/// [`recalculate`], running custom Calculate (and Format) scripts through `scripts`. -pub fn recalculate_with(doc: &mut Document, scripts: &mut dyn Scripts) -> Result { - let mut all = fields(doc); - if !all.iter().any(calc_order::is_calculated) { - return Ok(0); - } - // By object: two fields can share a name in a malformed form, and each calculates. - let order: Vec = calc_order::run_order(doc, &all).0.into_iter().filter_map(|(i, _)| all.get(i)).map(|f| f.obj).collect(); - let mut changed = 0; - for obj in order { - let Some(f) = all.iter().find(|f| f.obj == obj).cloned() else { continue }; - let lookup = |n: &str| -> Vec { - let prefix = format!("{n}."); - all.iter().filter(|x| x.name == n || x.name.starts_with(&prefix)).flat_map(|x| x.value.first().cloned()).collect() - }; - let current = f.value.first().cloned().unwrap_or_default(); - let v = if let Some(js) = &f.actions.scripts.calculate { - let r = scripts.run(FieldEvent::Calculate, js, &f, ¤t, &all); - scripting::apply_changes(doc, &r.changes, &f.name)?; - if !r.changes.is_empty() { - all = fields(doc); - } - if !r.rc { - continue; - } - r.value - } else { - let Some(v) = af::calculate(&f.actions.calculate, &lookup) else { continue }; - v - }; - if current != v && matches!(f.kind, FieldKind::Text | FieldKind::Combo) { - doc.update_dict(f.obj, |d| d.set(b"V".to_vec(), PdfString::text(&v)))?; - redraw(doc, &f, std::slice::from_ref(&v), scripts)?; - if let Some(target) = all.iter_mut().find(|x| x.obj == obj) { - target.value = vec![v]; - } - changed += 1; - } - } - Ok(changed) -} - -/// The fields whose calculation differs between two readings of a form, matched by field object -/// and named as in `after`: the formulas an edit rewrote, such as a rename's. -pub fn calculations_changed(before: &[Field], after: &[Field]) -> Vec { - let before: std::collections::HashMap = before.iter().map(|f| (f.obj, &f.actions)).collect(); - after - .iter() - .filter(|f| before.get(&f.obj).is_some_and(|b| b.calculate != f.actions.calculate || b.scripts.calculate != f.actions.scripts.calculate)) - .map(|f| f.name.clone()) - .collect() -} - fn invalid(m: impl Into) -> Result { Err(FormError::Invalid(m.into())) } - -fn write_value(doc: &mut Document, f: &Field, value: &FieldValue, scripts: &mut dyn Scripts) -> Result<(), FormError> { - match (f.kind, value) { - (FieldKind::Text, FieldValue::Text(t)) => { - if let Some(max) = f.max_len - && t.chars().count() > max - { - return invalid(format!("{:?} takes at most {max} characters", f.name)); - } - // Keystroke (on commit) and Validate, as Acrobat runs them before accepting a value. - let t = &af::keystroke(&f.actions.format, &f.name, t).map_err(FormError::Format)?; - af::validate(&f.actions.validate, t).map_err(FormError::Invalid)?; - let t = &scripting::accept(doc, f, t, scripts)?; - doc.update_dict(f.obj, |d| { - if t.is_empty() { - d.remove(b"V"); - } else { - d.set(b"V".to_vec(), PdfString::text(t)); - } - d.remove(b"RV"); - })?; - redraw(doc, f, std::slice::from_ref(t), scripts) - } - (FieldKind::CheckBox, v) => { - let state = f.widgets.iter().find_map(|w| w.on_state.clone()).unwrap_or_else(|| "Yes".into()); - let on = match v { - FieldValue::Check(on) => *on, - // The on-state name or a yes/no word also work (agents, FDF-style data). - FieldValue::Text(t) | FieldValue::Radio(Some(t)) => { - let t = t.trim(); - if t == state || f.state_for(t).is_some() || ["yes", "true", "on", "1", "x", "checked"].contains(&t.to_lowercase().as_str()) { - true - } else if t.is_empty() || ["no", "false", "off", "0", "unchecked"].contains(&t.to_lowercase().as_str()) { - false - } else { - return invalid(format!("{:?} is a check box: use true or false", f.name)); - } - } - FieldValue::Radio(None) => false, - FieldValue::Choice(_) => return invalid(format!("{:?} is a check box: use true or false", f.name)), - }; - set_states(doc, f, on.then_some(state.as_str())) - } - (FieldKind::Radio, v) => { - let choice = match v { - FieldValue::Radio(c) => c.clone(), - FieldValue::Text(t) if t.is_empty() => None, - FieldValue::Text(t) => Some(t.clone()), - FieldValue::Check(false) => None, - _ => return invalid(format!("{:?} is a radio group: choose one of its options", f.name)), - }; - // An export value from /Opt selects its widget's on state. - let choice = choice.map(|c| f.state_for(&c).map(str::to_owned).unwrap_or(c)); - if let Some(c) = &choice - && !f.widgets.iter().any(|w| w.on_state.as_deref() == Some(c.as_str())) - { - let opts: Vec<&str> = (0..f.widgets.len()).filter_map(|i| f.export_of(i)).collect(); - return invalid(format!("{:?} has no option {c:?} (options: {})", f.name, opts.join(", "))); - } - if choice.is_none() && f.has(flags::NO_TOGGLE_TO_OFF) && !f.value.is_empty() { - return invalid(format!("{:?} must keep one option selected", f.name)); - } - set_states(doc, f, choice.as_deref()) - } - (FieldKind::Combo | FieldKind::List, FieldValue::Choice(_) | FieldValue::Text(_)) => { - let vals: Vec = match value { - FieldValue::Choice(v) => v.clone(), - FieldValue::Text(t) if t.is_empty() => Vec::new(), - FieldValue::Text(t) => vec![t.clone()], - other => return invalid(format!("{:?} can't take {other:?}", f.name)), - }; - if vals.len() > 1 && !(f.kind == FieldKind::List && f.has(flags::MULTI_SELECT)) { - return invalid(format!("{:?} takes a single value", f.name)); - } - // Accept export values or display texts; free text only in editable combo boxes. - let mut exports = Vec::new(); - for v in &vals { - match f.options.iter().find(|(e, d)| e == v || d == v) { - Some((e, _)) => exports.push(e.clone()), - None if f.kind == FieldKind::Combo && f.has(flags::EDIT) => exports.push(v.clone()), - None if f.options.is_empty() => exports.push(v.clone()), - None => { - let opts: Vec<&str> = f.options.iter().map(|(_, d)| d.as_str()).collect(); - return invalid(format!("{:?} has no option {v:?} (options: {})", f.name, opts.join(", "))); - } - } - } - let mut indices: Vec = - exports.iter().filter_map(|e| f.options.iter().position(|(x, _)| x == e)).map(|i| Object::Int(i as i64)).collect(); - // /I lists option indices in ascending order; /V keeps the caller's value order. - // Every entry above is an integer, so this sorts by its numeric option index. - indices.sort_unstable_by_key(Object::as_int); - // A list box scrolls (`/TI`) so the picked options are among the rows it draws, as - // in other viewers: a pick below the last visible row would otherwise not show (#1524). - let top = match (f.kind, f.widgets.first()) { - (FieldKind::List, Some(w)) => w.list.map(|rows| { - let height = if matches!(w.rotation, 90 | 270) { w.rect[2] - w.rect[0] } else { w.rect[3] - w.rect[1] }; - let picked: Vec = indices.iter().filter_map(|i| i.as_int()).filter_map(|i| usize::try_from(i).ok()).collect(); - (rows.top, appearance::ListRows::top_showing(rows.top, &picked, rows.visible(height), f.options.len())) - }), - _ => None, - }; - doc.update_dict(f.obj, |d| { - match exports.as_slice() { - [] => { - d.remove(b"V"); - } - [one] => d.set(b"V".to_vec(), PdfString::text(one)), - many => d.set(b"V".to_vec(), Object::Array(many.iter().map(|e| Object::String(PdfString::text(e))).collect())), - } - if indices.is_empty() { - d.remove(b"I"); - } else { - d.set(b"I".to_vec(), Object::Array(indices)); - } - if let Some((old, new)) = top - && old != new - { - d.set(b"TI".to_vec(), Object::Int(i64::try_from(new).unwrap_or(0))); - } - })?; - // A widget with its own `/TI` (read before the field's) scrolls too. - if let Some((old, new)) = top - && old != new - { - for w in f.widgets.iter().filter(|w| w.obj != f.obj) { - if doc.get(w.obj).as_dict().is_some_and(|d| d.contains(b"TI")) { - doc.update_dict(w.obj, |d| d.set(b"TI".to_vec(), Object::Int(i64::try_from(new).unwrap_or(0))))?; - } - } - } - redraw(doc, f, &exports, scripts) - } - (FieldKind::PushButton, _) => invalid(format!("{:?} is a button; it has no value", f.name)), - (FieldKind::Signature, _) => invalid(format!("{:?} is a signature field; sign it with Fill & Sign or a digital ID", f.name)), - (kind, v) => invalid(format!("{:?} is a {kind:?} field and can't take {v:?}", f.name)), - } -} - -/// Check boxes and radio buttons: `/V` on the field, `/AS` on each widget. -fn set_states(doc: &mut Document, f: &Field, on: Option<&str>) -> Result<(), FormError> { - let v = on.unwrap_or("Off"); - doc.update_dict(f.obj, |d| d.set(b"V".to_vec(), name_obj(v)))?; - for w in &f.widgets { - let state = match (on, w.on_state.as_deref()) { - (Some(c), Some(s)) if c == s => s, - _ => "Off", - }; - // A widget without appearances for its states gets PdfCraft's own. - let has_ap = doc.get(w.obj).as_dict().and_then(|d| d.get(b"AP").cloned()).is_some(); - if !has_ap { - let on_name = w.on_state.clone().unwrap_or_else(|| on.unwrap_or("Yes").to_string()); - let ap = appearance::check_box_states(doc, w, f.kind, &on_name, &f.da); - doc.update_dict(w.obj, |d| d.set(b"AP".to_vec(), Object::Dict(ap)))?; - } - doc.update_dict(w.obj, |d| d.set(b"AS".to_vec(), name_obj(state)))?; - } - Ok(()) -} - -/// Copy an existing appearance dictionary before replacing its normal appearance. -fn appearance_dict(doc: &Document, widget: ObjRef) -> Dict { - doc.get(widget).as_dict().and_then(|d| d.get(b"AP").map(|a| doc.resolve(a))).and_then(|a| a.as_dict().cloned()).unwrap_or_default() -} - -/// `widget`'s appearance dictionary (a copy, so a shared one is left alone) with the freshly -/// drawn entries of `fresh` (its `/N`). Other entries are kept, but the old down and rollover -/// appearances (`/D`, `/R`) would show the old value, caption or colour on press or hover, so -/// they go — except, for check boxes and radio buttons (whose `/N` is a dictionary of states), -/// the states the new `/N` still draws under the same names. -pub(crate) fn merged_appearance(doc: &Document, widget: ObjRef, fresh: &Dict) -> Dict { - let mut apd = appearance_dict(doc, widget); - // Only a dictionary of states counts (`as_dict` would also see a stream's own dictionary). - let states_of = |o: &Object| match &*doc.resolve(o) { - Object::Dict(d) => Some(d.clone()), - _ => None, - }; - let states: Option>> = fresh.get(b"N").and_then(states_of).map(|d| d.iter().map(|(k, _)| k.clone()).collect()); - for key in [&b"D"[..], &b"R"[..]] { - if fresh.contains(key) { - continue; - } - let kept = states.as_ref().and_then(|names| { - let old = apd.get(key).and_then(states_of)?; - let mut d = Dict::new(); - for (k, v) in old.iter().filter(|(k, _)| names.contains(k)) { - d.set(k.clone(), v.clone()); - } - (!d.is_empty()).then_some(d) - }); - match kept { - Some(d) => apd.set(key.to_vec(), Object::Dict(d)), - None => { - apd.remove(key); - } - } - } - for (k, v) in fresh.iter() { - apd.set(k.clone(), v.clone()); - } - apd -} - -/// Regenerate the normal appearance of every widget of a text or choice field. -fn redraw(doc: &mut Document, f: &Field, values: &[String], scripts: &mut dyn Scripts) -> Result<(), FormError> { - let shown = match values { - [one] if matches!(f.kind, FieldKind::Text | FieldKind::Combo) => scripting::formatted(doc, f, one, scripts), - _ => None, - }; - let in_place = f.kind == FieldKind::Text && f.has(flags::PASSWORD); - let mut undrawn = false; - for w in &f.widgets { - let (stream, missing) = match &shown { - Some(s) => appearance::field_appearance_drawn(doc, f, w, std::slice::from_ref(s), false), - None => appearance::field_appearance_drawn(doc, f, w, values, true), - }; - undrawn |= missing.is_some(); - let existing_ap = in_place - .then(|| { - doc.get(w.obj) - .as_dict() - .and_then(|d| d.get(b"AP").map(|a| doc.resolve(a))) - .and_then(|a| a.as_dict().and_then(|a| a.get(b"N").cloned())) - .and_then(|n| n.as_ref()) - .filter(|r| doc.is_edited(r.num) && matches!(&*doc.get(*r), Object::Stream(_))) - }) - .flatten(); - let ap = match existing_ap { - Some(r) => { - doc.set(r, Object::Stream(stream)); - r - } - None => doc.add(Object::Stream(stream)), - }; - let mut fresh = Dict::new(); - fresh.set(b"N".to_vec(), Object::Ref(ap)); - let apd = merged_appearance(doc, w.obj, &fresh); - doc.update_dict(w.obj, |d| { - d.set(b"AP".to_vec(), Object::Dict(apd)); - d.remove(b"AS"); - pdfcraft_fonts::set_undrawn_mark(d, missing); - })?; - } - if undrawn { - ask_viewers_to_redraw(doc)?; - } - Ok(()) -} - -/// A value its widget's font can't show is stored as typed and drawn with `?` (as Acrobat keeps -/// such a value): `/NeedAppearances` then asks other viewers to draw the fields from their values -/// with fonts that have those characters (the interactive form dictionary, §12.7.3). Not in a -/// document that declares PDF/A, which forbids it: there the widget's mark -/// (`pdfcraft_fonts::UNDRAWN_MARK`) is all, and the edit's report says the `?` shows everywhere. -pub(crate) fn ask_viewers_to_redraw(doc: &mut Document) -> Result<(), FormError> { - if declares_pdfa(doc) || need_appearances(doc) { - return Ok(()); - } - let Some(root) = doc.root() else { return Ok(()) }; - let entry = doc.get(root).as_dict().and_then(|c| c.get(b"AcroForm").cloned()); - match entry { - Some(Object::Ref(r)) => doc.update_dict(r, |d| d.set(b"NeedAppearances".to_vec(), Object::Bool(true)))?, - Some(Object::Dict(mut af)) => { - af.set(b"NeedAppearances".to_vec(), Object::Bool(true)); - doc.update_dict(root, |c| c.set(b"AcroForm".to_vec(), Object::Dict(af)))?; - } - _ => {} - } - Ok(()) -} - -/// Text fields with the Password flag (inherited or their own) whose value (`/V`, inherited or -/// their own) is not empty and not their default value (`/DV`, which the form's author wrote and -/// a save restores), or one of whose separate widgets carries a `/V`. -fn password_fields(doc: &Document) -> Vec { - if !has_password_flag(doc) { - return Vec::new(); - } - let on_widget = |f: &Field| f.widgets.iter().any(|w| w.obj != f.obj && doc.get(w.obj).as_dict().is_some_and(|d| d.get(b"V").is_some())); - let typed = |f: &Field| f.value.iter().any(|v| !v.is_empty()) && f.value.first() != f.default.first(); - fields(doc).into_iter().filter(|f| f.kind == FieldKind::Text && f.has(flags::PASSWORD) && (typed(f) || on_widget(f))).collect() -} - -/// Whether any field dictionary sets the Password flag (#3943 review). A field that inherits the -/// flag has an ancestor that sets it, so that ancestor is found instead; a kid clearing the flag -/// again only makes the answer cautious. It reads only `/Ff`, `/Kids` and `/Parent`, so a save -/// or recovery snapshot of a form without such a field doesn't build every [`Field`]. Like -/// [`fields`], it looks at the form's field tree and at the widgets on the pages, which may be -/// missing from `/Fields`. Each dictionary is visited once and at most 64 levels deep. -fn has_password_flag(doc: &Document) -> bool { - let sets_it = |r: ObjRef| doc.get(r).as_dict().and_then(|d| field_flags(doc, d)).is_some_and(|ff| ff & flags::PASSWORD != 0); - let top = acroform(doc).and_then(|af| af.get(b"Fields").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned())).unwrap_or_default(); - let mut stack: Vec<(Object, usize)> = top.into_iter().map(|o| (o, 0)).collect(); - let mut seen = std::collections::HashSet::new(); - while let Some((o, depth)) = stack.pop() { - let Object::Ref(r) = o else { continue }; - if depth > 64 || !seen.insert(r) { - continue; - } - if sets_it(r) { - return true; - } - let kids = doc.get(r).as_dict().and_then(|d| d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned())).unwrap_or_default(); - stack.extend(kids.into_iter().map(|k| (k, depth + 1))); - } - // Widgets on the pages, and the fields above them. - page_refs(doc).into_iter().any(|p| { - let annots = - doc.get(p).as_dict().and_then(|d| d.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned())).unwrap_or_default(); - annots.iter().filter_map(Object::as_ref).filter(|r| !seen.contains(r)).any(|r| field_chain(doc, r).into_iter().any(sets_it)) - }) -} - -/// Whether any text field with the Password flag holds a value in `doc`. -pub fn has_password_values(doc: &Document) -> bool { - !password_fields(doc).is_empty() -} - -/// The typed values of the text fields with the Password flag, as (name, text) pairs, for -/// [`restore_password_values`] after the document was rebased on a file written without them. -pub fn password_values(doc: &Document) -> Vec<(String, String)> { - password_fields(doc).into_iter().filter_map(|f| f.value.first().map(|v| (f.name.clone(), v.clone()))).collect() -} - -/// Put values from [`password_values`] back into the open document, in memory only. A field -/// that is gone or read-only keeps what it has. -pub fn restore_password_values(doc: &mut Document, values: &[(String, String)]) -> Result<(), FormError> { - for (name, v) in values { - match set_value(doc, name, &FieldValue::Text(v.clone())) { - Ok(()) | Err(FormError::NoSuchField(_) | FormError::ReadOnly(_)) => {} - Err(e) => return Err(e), - } - } - Ok(()) -} - -/// `r` and its field ancestors (`/Parent`), nearest first, at most 64 deep and without repeats. -fn field_chain(doc: &Document, r: ObjRef) -> Vec { - let mut chain = vec![r]; - let mut cur = r; - while chain.len() < 64 { - let Some(Object::Ref(parent)) = doc.get(cur).as_dict().and_then(|d| d.get(b"Parent").cloned()) else { break }; - if chain.contains(&parent) { - break; - } - chain.push(parent); - cur = parent; - } - chain -} - -/// Remove the values of the text fields with the Password flag, for a copy of the document about -/// to be written to a file (#3926). ISO 32000-2 §12.7.5.3, Table 231, Password flag, note: "it is -/// imperative that PDF processors never store the value of the text field in the PDF file if -/// this flag is set". As Acrobat saves such a field (#3943 review, black-box test): a field with -/// a default value (`/DV`, which the form's author wrote) gets it back as its value, one without -/// is left empty, and its appearance is drawn again from that value, so the masking characters -/// no longer show how long the typed password was. `/DV` and the field's scripts stay. -/// -/// A value can sit on the field, on one of its widgets, or on an ancestor it inherits from -/// (`/V` is inheritable). Each is removed; an ancestor's only when every field inheriting from it -/// is such a password field. Where an ancestor's value is shared with other fields and stays, the -/// password field gets an empty value of its own so it no longer inherits it. Returns how many -/// fields were cleared. -pub fn clear_password_values(doc: &mut Document) -> Result { - let targets = password_fields(doc); - if targets.is_empty() { - return Ok(0); - } - let is_password = |f: &Field| f.kind == FieldKind::Text && f.has(flags::PASSWORD); - // Each field's ancestors, found once: only values change below, never `/Parent` (#3943 - // review: walking them again for every ancestor of every target was quadratic). - let chains: Vec<(bool, Vec)> = fields(doc).iter().map(|g| (is_password(g), field_chain(doc, g.obj))).collect(); - for f in &targets { - let chain = field_chain(doc, f.obj); - for w in &f.widgets { - if w.obj != f.obj { - doc.update_dict(w.obj, |d| { - d.remove(b"V"); - })?; - } - } - doc.update_dict(f.obj, |d| { - d.remove(b"V"); - })?; - let mut shared_value_kept = false; - for a in chain.iter().skip(1) { - if doc.get(*a).as_dict().is_none_or(|d| d.get(b"V").is_none()) { - continue; - } - let exclusive = chains.iter().filter(|(_, chain)| chain.contains(a)).all(|(password, _)| *password); - if exclusive { - doc.update_dict(*a, |d| { - d.remove(b"V"); - })?; - } else { - shared_value_kept = true; - } - } - let restored: Vec = f.default.first().cloned().into_iter().collect(); - match restored.first() { - Some(dv) => doc.update_dict(f.obj, |d| d.set(b"V".to_vec(), PdfString::text(dv)))?, - None if shared_value_kept => doc.update_dict(f.obj, |d| d.set(b"V".to_vec(), PdfString::text("")))?, - None => {} - } - redraw(doc, f, &restored, &mut NoScripts)?; - } - Ok(targets.len()) -} - -/// Whether the form asks viewers to draw its fields' appearances themselves (`/NeedAppearances`). -pub fn need_appearances(doc: &Document) -> bool { - acroform(doc).is_some_and(|af| matches!(af.get(b"NeedAppearances").map(|o| doc.resolve(o)).as_deref(), Some(Object::Bool(true)))) -} - -/// A copy of `doc` in which form fields are drawn the way the file leaves to the viewer, for -/// displaying the document (`None` when no widget needs it). Text and choice fields are drawn from -/// their values: every one when the form sets `/NeedAppearances` (ISO 32000-2 Table 224: the -/// writer left the appearances to the viewer), and otherwise each widget without a normal -/// appearance (`/AP /N`), as typing the value would draw it (#3470). Check boxes and radio buttons -/// with no normal appearance for their state (`/AS`) are drawn from their `/MK` background, border -/// and caption, and with `/NeedAppearances` every one that has an `/MK` is, over its own -/// appearance; push buttons with none are drawn from their `/MK` and `/BS` (Table 192). `doc` is -/// not changed: saving writes the file as it was. -pub fn with_field_appearances(doc: &Document) -> Option { - with_field_appearances_cached(doc, &mut FieldAppearanceCache::default()) -} - -/// [`with_field_appearances`], reusing the appearances `cache` drew for widgets whose inputs are -/// the same objects as then (see [`FieldAppearanceCache`]). -pub fn with_field_appearances_cached(doc: &Document, cache: &mut FieldAppearanceCache) -> Option { - let all = need_appearances(doc); - let mut copy: Option = None; - for f in fields(doc) { - for w in &f.widgets { - let widget = doc.get(w.obj); - let normal = widget - .as_dict() - .and_then(|d| d.get(b"AP")) - .and_then(|ap| doc.resolve(ap).as_dict().and_then(|ap| ap.get(b"N").map(|n| doc.resolve(n)))); - // A check box or radio button shows the appearance of its state (12.7.5.2.3): `/AS`, - // else the value when it names this widget's on state (any check box value when the - // widget names none), else Off. An Off state may go without one (it shows nothing); - // an on state needs one. - let state = match f.kind { - FieldKind::CheckBox | FieldKind::Radio => { - let mine = |v: &&String| w.on_state.as_ref().map_or(f.kind == FieldKind::CheckBox, |on| on == *v); - let value = f.value.first().filter(|v| !v.is_empty() && v.as_str() != "Off").filter(mine); - Some(w.state.clone().or_else(|| value.cloned()).unwrap_or_else(|| "Off".into())) - } - _ => None, - }; - // With /NeedAppearances a check box or radio button that describes its look in /MK is - // drawn from it (Table 192), its own appearance or not; one without /MK keeps its own. - let described = || widget.as_dict().and_then(|d| d.get(b"MK")).is_some_and(|mk| doc.resolve(mk).as_dict().is_some()); - let missing = match f.kind { - FieldKind::Text | FieldKind::Combo | FieldKind::List => normal.is_none() || all, - FieldKind::PushButton => normal.is_none(), - FieldKind::CheckBox | FieldKind::Radio => match (normal.as_deref(), state.as_deref()) { - _ if all && described() => true, - (None, _) => true, - (Some(Object::Dict(states)), Some(s)) => s != "Off" && !states.contains(&name_bytes(s)), - _ => false, - }, - FieldKind::Signature => false, - }; - if !missing { - continue; - } - let inputs = appearance_inputs(doc, w); - let stream = match cache.get(w.obj, &inputs) { - Some(stream) => stream, - None => { - let stream = match f.kind { - FieldKind::CheckBox | FieldKind::Radio => appearance::button_state_shown(doc, &f, w, state.as_deref() != Some("Off")), - FieldKind::PushButton => appearance::push_button_shown(doc, &f, w), - _ => appearance::field_appearance_drawn(doc, &f, w, &f.value, true).0, - }; - cache.insert(w.obj, inputs, &stream); - stream - } - }; - let target = copy.get_or_insert_with(|| doc.clone()); - let ap = target.add(Object::Stream(stream)); - let mut fresh = Dict::new(); - match &state { - // The drawn look under its state's name, and that state as the widget's own. - Some(s) => { - let mut states = Dict::new(); - states.set(name_bytes(s), Object::Ref(ap)); - fresh.set(b"N".to_vec(), Object::Dict(states)); - } - None => fresh.set(b"N".to_vec(), Object::Ref(ap)), - } - let apd = merged_appearance(target, w.obj, &fresh); - // Fails only when the widget isn't a dictionary; that widget then keeps its own look. - target - .update_dict(w.obj, |d| { - d.set(b"AP".to_vec(), Object::Dict(apd)); - if let Some(s) = &state { - d.set(b"AS".to_vec(), Object::Name(name_bytes(s))); - } - }) - .ok(); - } - } - copy -} - -/// The objects a widget's drawn appearance is built from, as the document holds them: the widget -/// and the fields above it (value, `/DA`, `/Q`, flags, options, `/MK`, formatting actions), the -/// form dictionary (`/DA`) and the fonts of its `/DR`, each with the objects its entries refer to -/// (an indirect `/MK` or `/V`). Not the page: a field's rotation is its own `/MK /R`. -fn appearance_inputs(doc: &Document, w: &Widget) -> Vec> { - // The objects a dictionary's entries refer to. The tree links and the drawn appearance itself - // are not inputs, and `/Kids` and `/Fields` can be long. - let referred = |d: &Dict, inputs: &mut Vec>| { - for (k, v) in d.iter() { - if !matches!(&k[..], b"Kids" | b"Fields" | b"P" | b"Parent" | b"AP") - && let Some(r) = v.as_ref() - { - inputs.push(doc.get(r)); - } - } - }; - let mut inputs = Vec::new(); - let mut seen = std::collections::HashSet::new(); - let mut node = Some(w.obj); - // The /Parent chain; the seen-set and the cap end a cycle or an absurd depth. - while let Some(r) = node.filter(|r| seen.insert(*r) && seen.len() <= 64) { - let obj = doc.get(r); - if let Some(d) = obj.as_dict() { - node = d.reference(b"Parent"); - referred(d, &mut inputs); - } else { - node = None; - } - inputs.push(obj); - } - let Some(root) = doc.root() else { return inputs }; - let catalog = doc.get(root); - let form = match catalog.as_dict().and_then(|c| c.reference(b"AcroForm")) { - Some(r) => doc.get(r), - None => catalog, - }; - if let Some(f) = form.as_dict() { - referred(f, &mut inputs); - // The fonts of /DR. An indirect /DR or /Font is listed through the entries that refer to - // it; a direct one is part of the object that holds it. `held` only finds them: a copy - // of a direct object is a new one each time, never an input. - let held = |o: &Object| o.as_ref().map_or_else(|| std::sync::Arc::new(o.clone()), |r| doc.get(r)); - if let Some(dr) = f.get(b"DR").map(held) - && let Some(dr) = dr.as_dict() - { - referred(dr, &mut inputs); - if let Some(font) = dr.get(b"Font").map(held) - && let Some(fonts) = font.as_dict() - { - referred(fonts, &mut inputs); - } - } - } - inputs.push(form); - inputs -} - -/// Field appearances drawn for the display copy ([`with_field_appearances_cached`]), so a refresh -/// redraws only the widgets whose inputs changed: a form that leaves every appearance to the -/// viewer otherwise redrew all of its fields on each keystroke in any one of them (#3470). An -/// entry holds the input objects it was drawn from and is used only while each is still the same -/// object (`Arc::ptr_eq`); the document replaces an object it edits, so a changed input is a -/// miss, never a stale hit. -#[derive(Clone, Debug, Default)] -pub struct FieldAppearanceCache { - entries: std::collections::HashMap>, pdfcraft_cos::Stream)>, - bytes: usize, - hits: u64, - builds: u64, -} - -impl FieldAppearanceCache { - /// Cached appearance data restarts past this instead of growing without bound. - const MAX_BYTES: usize = 64 << 20; - - /// Forget every entry. - pub fn clear(&mut self) { - *self = Self::default(); - } - - fn get(&mut self, w: ObjRef, inputs: &[std::sync::Arc]) -> Option { - let (from, stream) = self.entries.get(&w)?; - if from.len() != inputs.len() || !from.iter().zip(inputs).all(|(a, b)| std::sync::Arc::ptr_eq(a, b)) { - return None; - } - self.hits += 1; - Some(stream.clone()) - } - - fn insert(&mut self, w: ObjRef, inputs: Vec>, stream: &pdfcraft_cos::Stream) { - self.builds += 1; - if self.bytes.saturating_add(stream.raw.len()) > Self::MAX_BYTES { - self.clear(); - } - self.bytes = self.bytes.saturating_add(stream.raw.len()); - if let Some((_, old)) = self.entries.insert(w, (inputs, stream.clone())) { - self.bytes = self.bytes.saturating_sub(old.raw.len()); - } - } - - /// Cache hits so far (reused appearances); for tests. - pub fn hits(&self) -> u64 { - self.hits - } - - /// Appearances drawn so far; for tests. - pub fn builds(&self) -> u64 { - self.builds - } -} - -/// Whether the document's XMP metadata declares PDF/A conformance (`pdfaid:part`). -pub fn declares_pdfa(doc: &Document) -> bool { - let Some(root) = doc.root() else { return false }; - let Some(m) = doc.get(root).as_dict().and_then(|c| c.get(b"Metadata").cloned()) else { return false }; - let m = doc.resolve(&m); - match &*m { - Object::Stream(s) => s.decoded().is_ok_and(|b| b.windows(11).any(|w| w == b"pdfaid:part")), - _ => false, - } -} - -/// Every field with a widget whose appearance PdfCraft drew with `?` for a character its font -/// can't hold (`pdfcraft_fonts::UNDRAWN_MARK`): the field's name and that character, once per -/// field. -pub fn undrawn_fields(doc: &Document) -> Vec<(String, char)> { - fields(doc) - .into_iter() - .filter_map(|f| { - let c = f.widgets.iter().find_map(|w| doc.get(w.obj).as_dict().and_then(pdfcraft_fonts::undrawn_mark))?; - Some((f.name, c)) - }) - .collect() -} - -/// Acrobat's Clear form: every field (or the named ones) back to its default value. -pub fn reset(doc: &mut Document, names: Option<&[String]>) -> Result { - let all = fields(doc); - if all.is_empty() { - return Err(FormError::NoForm); - } - if let Some(n) = names - && let Some(missing) = n.iter().find(|n| !all.iter().any(|f| &f.name == *n)) - { - return Err(FormError::NoSuchField(missing.clone())); - } - let mut changed = 0; - for f in all.iter().filter(|f| names.is_none_or(|n| n.contains(&f.name))) { - if f.value == f.default { - continue; - } - let v = match f.kind { - FieldKind::Text => FieldValue::Text(f.default.first().cloned().unwrap_or_default()), - FieldKind::CheckBox => FieldValue::Check(!f.default.is_empty()), - FieldKind::Radio => FieldValue::Radio(f.default.first().cloned()), - FieldKind::Combo | FieldKind::List => FieldValue::Choice(f.default.clone()), - FieldKind::PushButton | FieldKind::Signature => continue, - }; - // A reset is not blocked by NoToggleToOff: go through the writer directly. - let mut tmp = f.clone(); - tmp.flags &= !flags::NO_TOGGLE_TO_OFF; - write_value(doc, &tmp, &v, &mut NoScripts)?; - changed += 1; - } - recalculate(doc)?; - Ok(changed) -} - -/// Order tabs manually (Acrobat: Fields ▸ Tab Order ▸ Order Tabs Manually, then drag a field): -/// move field `name` one place earlier or later in its page's tab order. The page switches to -/// annotation order (`/Tabs` removed) and its widgets are reordered in `/Annots`; other -/// annotations keep their places. -pub fn move_in_tab_order(doc: &mut Document, name: &str, earlier: bool) -> Result<(), FormError> { - let all = fields(doc); - let f = all.iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?; - let page = f.widgets.iter().filter_map(|w| w.page).min().ok_or_else(|| FormError::Invalid(format!("{name} is not on a page")))?; - // The page's fields in their current tab order (a field counts once, at its first widget). - let mut order: Vec<(usize, &str)> = - all.iter().filter_map(|g| g.widgets.iter().filter(|w| w.page == Some(page)).map(|w| w.tab).min().map(|t| (t, g.name.as_str()))).collect(); - order.sort(); - let names: Vec<&str> = order.iter().map(|(_, n)| *n).collect(); - let Some(i) = names.iter().position(|n| *n == name) else { return invalid(format!("{name} is not on a page")) }; - let j = if earlier { i.checked_sub(1) } else { Some(i + 1).filter(|j| *j < names.len()) }; - let Some(j) = j else { return Ok(()) }; - let mut names = names.into_iter().map(str::to_string).collect::>(); - names.swap(i, j); - // The widgets of this page in the new order. - let mut widgets: Vec = Vec::new(); - for n in &names { - if let Some(g) = all.iter().find(|g| &g.name == n) { - widgets.extend(g.widgets.iter().filter(|w| w.page == Some(page)).map(|w| w.obj)); - } - } - let pr = *page_refs(doc).get(page).ok_or_else(|| FormError::Invalid("no such page".into()))?; - let annots_obj = - doc.get(pr).as_dict().and_then(|d| d.get(b"Annots").cloned()).ok_or_else(|| FormError::Invalid("the page has no annotations".into()))?; - let list = doc.resolve(&annots_obj).as_array().cloned().unwrap_or_default(); - let mut next = widgets.into_iter(); - let set: std::collections::HashSet = all.iter().flat_map(|g| g.widgets.iter().filter(|w| w.page == Some(page)).map(|w| w.obj)).collect(); - let new_list: Vec = list - .iter() - .map(|o| match o.as_ref() { - Some(r) if set.contains(&r) => next.next().map(Object::Ref).unwrap_or_else(|| o.clone()), - _ => o.clone(), - }) - .collect(); - match annots_obj { - Object::Ref(ar) => doc.set(ar, Object::Array(new_list)), - _ => doc.update_dict(pr, |d| d.set(b"Annots".to_vec(), Object::Array(new_list)))?, - } - doc.update_dict(pr, |d| { - d.remove(b"Tabs"); - })?; - Ok(()) -} diff --git a/crates/forms/src/read.rs b/crates/forms/src/read.rs new file mode 100644 index 0000000000..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/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 39ecdbde5a..5c80177021 100644 --- a/crates/js/src/formcalc.rs +++ b/crates/js/src/formcalc.rs @@ -18,6 +18,27 @@ use crate::xfa::{ reset_names, resolve_som, run_within, }; +mod access; +mod ast; +mod builtins; +mod dates; +mod eval; +mod lexer; +mod parser; +mod value; +mod words; + +use ast::Stmt; +pub(crate) use dates::days_in; +#[cfg(test)] +use dates::{civil_from_days, days_from_civil, format_date, parse_date}; +use parser::parse; +use value::Val; +#[cfg(test)] +use value::{fixed, num_text, parse_num}; +#[cfg(test)] +use words::word_num; + /// Deepest nesting the parser follows. const MAX_PARSE_DEPTH: usize = 64; /// Most statements and expressions evaluated in one run (loops included). @@ -47,722 +68,6 @@ fn is_limit(e: &str) -> bool { #[cfg(not(target_arch = "wasm32"))] const CLOCK_EVERY: u64 = 256; -// ── lexer ─────────────────────────────────────────────────────────────────────────────────── - -#[derive(Clone, Debug, PartialEq)] -enum Tok { - Num(f64), - Str(String), - Ident(String), - Op(&'static str), - Newline, - Eof, -} - -#[derive(Clone, Debug, PartialEq)] -struct Token { - tok: Tok, - line: usize, -} - -const OPS: [&str; 20] = ["<=", ">=", "==", "<>", "..", "(", ")", "[", "]", ".", ",", "=", "<", ">", "+", "-", "*", "/", "&", "|"]; - -fn lex(src: &str) -> Result, String> { - let chars: Vec = src.chars().collect(); - let mut out = Vec::new(); - let mut i = 0; - let mut line = 1; - let mut depth = 0usize; - while i < chars.len() { - let c = chars[i]; - match c { - '\n' => { - line += 1; - i += 1; - // Newlines inside brackets continue the expression. - if depth == 0 { - out.push(Token { tok: Tok::Newline, line }); - } - } - ' ' | '\t' | '\r' => i += 1, - ';' => { - while i < chars.len() && chars[i] != '\n' { - i += 1; - } - } - '/' if chars.get(i + 1) == Some(&'/') => { - while i < chars.len() && chars[i] != '\n' { - i += 1; - } - } - '"' => { - let mut s = String::new(); - i += 1; - loop { - match chars.get(i) { - None => return Err(format!("line {line}: unterminated string")), - Some('"') if chars.get(i + 1) == Some(&'"') => { - s.push('"'); - i += 2; - } - Some('"') => { - i += 1; - break; - } - Some(&ch) => { - if ch == '\n' { - line += 1; - } - s.push(ch); - i += 1; - } - } - if s.len() > MAX_STRING { - return Err(format!("line {line}: a string is too long")); - } - } - out.push(Token { tok: Tok::Str(s), line }); - } - c if c.is_ascii_digit() || (c == '.' && chars.get(i + 1).is_some_and(char::is_ascii_digit)) => { - let start = i; - while i < chars.len() && (chars[i].is_ascii_digit() || (chars[i] == '.' && chars.get(i + 1) != Some(&'.'))) { - i += 1; - } - if i < chars.len() && (chars[i] == 'e' || chars[i] == 'E') { - let mut j = i + 1; - if j < chars.len() && (chars[j] == '+' || chars[j] == '-') { - j += 1; - } - if j < chars.len() && chars[j].is_ascii_digit() { - i = j; - while i < chars.len() && chars[i].is_ascii_digit() { - i += 1; - } - } - } - let text: String = chars[start..i].iter().collect(); - let n: f64 = text.parse().map_err(|_| format!("line {line}: bad number {text}"))?; - out.push(Token { tok: Tok::Num(n), line }); - } - '!' => { - out.push(Token { tok: Tok::Ident("!".into()), line }); - i += 1; - } - c if c.is_alphabetic() || c == '_' || c == '$' => { - let start = i; - i += 1; - while i < chars.len() && (chars[i].is_alphanumeric() || chars[i] == '_' || chars[i] == '$') { - i += 1; - } - let text: String = chars[start..i].iter().collect(); - out.push(Token { tok: Tok::Ident(text), line }); - } - _ => { - let rest: String = chars[i..chars.len().min(i + 2)].iter().collect(); - let op = OPS.iter().find(|o| rest.starts_with(*o)).copied().ok_or_else(|| format!("line {line}: unexpected character {c:?}"))?; - match op { - "(" | "[" => depth += 1, - ")" | "]" => depth = depth.saturating_sub(1), - _ => {} - } - out.push(Token { tok: Tok::Op(op), line }); - i += op.len(); - } - } - } - out.push(Token { tok: Tok::Newline, line }); - out.push(Token { tok: Tok::Eof, line }); - Ok(out) -} - -// ── syntax ────────────────────────────────────────────────────────────────────────────────── - -#[derive(Clone, Debug, PartialEq)] -enum Expr { - Num(f64), - Str(String), - Null, - Ident(String), - /// `base.name` - Member(Box, String), - /// `base[n]`, `base[*]`, `base[-n]` - Index(Box, Idx), - /// `base..name` - Descend(Box, String), - Call(Box, Vec), - Neg(Box), - Not(Box), - Bin(&'static str, Box, Box), -} - -#[derive(Clone, Debug, PartialEq)] -enum Idx { - /// `[*]`: every instance. - All, - /// `[n]`: the n-th instance (from 0). - Abs(Box), - /// `[-n]` / `[+n]`: n instances before or after the one the script runs in. - Rel(i64), -} - -#[derive(Clone, Debug, PartialEq)] -enum Stmt { - Var(String, Option), - Assign(Expr, Expr), - Expr(Expr), - If(Vec<(Expr, Vec)>, Option>), - While(Expr, Vec), - For { var: String, from: Expr, to: Expr, up: bool, step: Option, body: Vec }, - Foreach { var: String, list: Vec, body: Vec }, - Func { name: String, f: Rc }, - Return(Option), - Break, - Continue, - Exit, - Throw(Expr), -} - -struct Parser { - toks: Vec, - at: usize, - depth: usize, - /// Operators and accessors chained so far in the expression being parsed. - chain: usize, -} - -fn kw(t: &Tok, word: &str) -> bool { - matches!(t, Tok::Ident(s) if s.eq_ignore_ascii_case(word)) -} - -impl Parser { - fn peek(&self) -> &Tok { - self.toks.get(self.at).map_or(&Tok::Eof, |t| &t.tok) - } - - fn line(&self) -> usize { - self.toks.get(self.at).map_or(0, |t| t.line) - } - - fn next(&mut self) -> Tok { - let t = self.peek().clone(); - self.at += 1; - t - } - - fn is_op(&self, op: &str) -> bool { - matches!(self.peek(), Tok::Op(o) if *o == op) - } - - fn eat_op(&mut self, op: &str) -> bool { - if self.is_op(op) { - self.at += 1; - true - } else { - false - } - } - - fn expect_op(&mut self, op: &str) -> Result<(), String> { - if self.eat_op(op) { Ok(()) } else { Err(format!("line {}: expected {op}", self.line())) } - } - - fn eat_kw(&mut self, word: &str) -> bool { - if kw(self.peek(), word) { - self.at += 1; - true - } else { - false - } - } - - fn expect_kw(&mut self, word: &str) -> Result<(), String> { - if self.eat_kw(word) { Ok(()) } else { Err(format!("line {}: expected {word}", self.line())) } - } - - fn skip_newlines(&mut self) { - while matches!(self.peek(), Tok::Newline) { - self.at += 1; - } - } - - fn enter(&mut self) -> Result<(), String> { - self.depth += 1; - if self.depth > MAX_PARSE_DEPTH { - return Err("the script is nested too deeply".into()); - } - Ok(()) - } - - fn leave(&mut self) { - self.depth = self.depth.saturating_sub(1); - } - - /// One more link in an operator or accessor chain. - fn link(&mut self) -> Result<(), String> { - self.chain += 1; - if self.chain > MAX_CHAIN { - return Err(format!("line {}: an expression is too long", self.line())); - } - Ok(()) - } - - /// Statements up to one of `enders` (not consumed). - fn block(&mut self, enders: &[&str]) -> Result, String> { - self.enter()?; - let mut out = Vec::new(); - loop { - self.skip_newlines(); - match self.peek() { - Tok::Eof => break, - Tok::Ident(w) if enders.iter().any(|e| w.eq_ignore_ascii_case(e)) => break, - _ => out.push(self.statement()?), - } - } - self.leave(); - Ok(out) - } - - fn statement(&mut self) -> Result { - let line = self.line(); - match self.peek().clone() { - Tok::Ident(w) if w.eq_ignore_ascii_case("var") => { - self.at += 1; - let Tok::Ident(name) = self.next() else { return Err(format!("line {line}: expected a name after var")) }; - let init = if self.eat_op("=") { Some(self.top_expr()?) } else { None }; - Ok(Stmt::Var(name, init)) - } - Tok::Ident(w) if w.eq_ignore_ascii_case("if") => { - self.at += 1; - let mut arms = Vec::new(); - let cond = self.top_expr()?; - self.expect_kw("then")?; - let body = self.block(&["elseif", "else", "endif"])?; - arms.push((cond, body)); - let mut otherwise = None; - loop { - if self.eat_kw("elseif") { - let c = self.top_expr()?; - self.expect_kw("then")?; - let b = self.block(&["elseif", "else", "endif"])?; - arms.push((c, b)); - } else if self.eat_kw("else") { - otherwise = Some(self.block(&["endif"])?); - } else { - self.expect_kw("endif")?; - break; - } - } - Ok(Stmt::If(arms, otherwise)) - } - Tok::Ident(w) if w.eq_ignore_ascii_case("while") => { - self.at += 1; - let cond = self.top_expr()?; - self.expect_kw("do")?; - let body = self.block(&["endwhile"])?; - self.expect_kw("endwhile")?; - Ok(Stmt::While(cond, body)) - } - Tok::Ident(w) if w.eq_ignore_ascii_case("for") => { - self.at += 1; - let Tok::Ident(var) = self.next() else { return Err(format!("line {line}: expected a variable after for")) }; - self.expect_op("=")?; - let from = self.top_expr()?; - let up = if self.eat_kw("upto") { - true - } else if self.eat_kw("downto") { - false - } else { - return Err(format!("line {line}: expected upto or downto")); - }; - let to = self.top_expr()?; - let step = if self.eat_kw("step") { Some(self.top_expr()?) } else { None }; - self.expect_kw("do")?; - let body = self.block(&["endfor"])?; - self.expect_kw("endfor")?; - Ok(Stmt::For { var, from, to, up, step, body }) - } - Tok::Ident(w) if w.eq_ignore_ascii_case("foreach") => { - self.at += 1; - let Tok::Ident(var) = self.next() else { return Err(format!("line {line}: expected a variable after foreach")) }; - self.expect_kw("in")?; - self.expect_op("(")?; - let mut list = Vec::new(); - if !self.is_op(")") { - loop { - list.push(self.top_expr()?); - if !self.eat_op(",") { - break; - } - } - } - self.expect_op(")")?; - self.expect_kw("do")?; - let body = self.block(&["endfor"])?; - self.expect_kw("endfor")?; - Ok(Stmt::Foreach { var, list, body }) - } - Tok::Ident(w) if w.eq_ignore_ascii_case("func") => { - self.at += 1; - let Tok::Ident(name) = self.next() else { return Err(format!("line {line}: expected a name after func")) }; - self.expect_op("(")?; - let mut params = Vec::new(); - if !self.is_op(")") { - loop { - let Tok::Ident(p) = self.next() else { return Err(format!("line {line}: expected a parameter name")) }; - params.push(p); - if !self.eat_op(",") { - break; - } - } - } - self.expect_op(")")?; - self.expect_kw("do")?; - let body = self.block(&["endfunc"])?; - self.expect_kw("endfunc")?; - Ok(Stmt::Func { name, f: Rc::new(Func { params, body }) }) - } - Tok::Ident(w) if w.eq_ignore_ascii_case("return") => { - self.at += 1; - let v = if matches!(self.peek(), Tok::Newline | Tok::Eof) { None } else { Some(self.top_expr()?) }; - Ok(Stmt::Return(v)) - } - Tok::Ident(w) if w.eq_ignore_ascii_case("break") => { - self.at += 1; - Ok(Stmt::Break) - } - Tok::Ident(w) if w.eq_ignore_ascii_case("continue") => { - self.at += 1; - Ok(Stmt::Continue) - } - Tok::Ident(w) if w.eq_ignore_ascii_case("exit") => { - self.at += 1; - Ok(Stmt::Exit) - } - Tok::Ident(w) if w.eq_ignore_ascii_case("throw") => { - self.at += 1; - Ok(Stmt::Throw(self.top_expr()?)) - } - Tok::Ident(w) if w.eq_ignore_ascii_case("do") => { - // `do … end`: a block as one statement. - self.at += 1; - let body = self.block(&["end"])?; - self.expect_kw("end")?; - Ok(Stmt::If(vec![(Expr::Num(1.0), body)], None)) - } - _ => { - let e = self.top_expr()?; - if self.eat_op("=") { - let v = self.top_expr()?; - return Ok(Stmt::Assign(e, v)); - } - Ok(Stmt::Expr(e)) - } - } - } - - /// An expression that starts a tree of its own (a condition, a bound, a statement's - /// expression): its operator chain is counted from zero, whatever came before it. - fn top_expr(&mut self) -> Result { - self.chain = 0; - self.expr() - } - - fn expr(&mut self) -> Result { - self.enter()?; - let e = self.or_expr(); - self.leave(); - e - } - - fn or_expr(&mut self) -> Result { - let mut l = self.and_expr()?; - loop { - if self.eat_op("|") || self.eat_kw("or") { - self.link()?; - let r = self.and_expr()?; - l = Expr::Bin("|", Box::new(l), Box::new(r)); - } else { - return Ok(l); - } - } - } - - fn and_expr(&mut self) -> Result { - let mut l = self.cmp_expr()?; - loop { - if self.eat_op("&") || self.eat_kw("and") { - self.link()?; - let r = self.cmp_expr()?; - l = Expr::Bin("&", Box::new(l), Box::new(r)); - } else { - return Ok(l); - } - } - } - - fn cmp_expr(&mut self) -> Result { - let mut l = self.add_expr()?; - loop { - let op = match self.peek() { - Tok::Op("==") => "==", - Tok::Op("<>") => "<>", - Tok::Op("<=") => "<=", - Tok::Op(">=") => ">=", - Tok::Op("<") => "<", - Tok::Op(">") => ">", - Tok::Ident(w) if w.eq_ignore_ascii_case("eq") => "==", - Tok::Ident(w) if w.eq_ignore_ascii_case("ne") => "<>", - Tok::Ident(w) if w.eq_ignore_ascii_case("le") => "<=", - Tok::Ident(w) if w.eq_ignore_ascii_case("ge") => ">=", - Tok::Ident(w) if w.eq_ignore_ascii_case("lt") => "<", - Tok::Ident(w) if w.eq_ignore_ascii_case("gt") => ">", - _ => return Ok(l), - }; - self.at += 1; - self.link()?; - let r = self.add_expr()?; - l = Expr::Bin(op, Box::new(l), Box::new(r)); - } - } - - fn add_expr(&mut self) -> Result { - let mut l = self.mul_expr()?; - loop { - let op = match self.peek() { - Tok::Op("+") => "+", - Tok::Op("-") => "-", - _ => return Ok(l), - }; - self.at += 1; - self.link()?; - let r = self.mul_expr()?; - l = Expr::Bin(op, Box::new(l), Box::new(r)); - } - } - - fn mul_expr(&mut self) -> Result { - let mut l = self.unary()?; - loop { - let op = match self.peek() { - Tok::Op("*") => "*", - Tok::Op("/") => "/", - _ => return Ok(l), - }; - self.at += 1; - self.link()?; - let r = self.unary()?; - l = Expr::Bin(op, Box::new(l), Box::new(r)); - } - } - - fn unary(&mut self) -> Result { - self.enter()?; - let e = if self.eat_op("-") { - Ok(Expr::Neg(Box::new(self.unary()?))) - } else if self.eat_op("+") { - self.unary() - } else if self.eat_kw("not") || matches!(self.peek(), Tok::Ident(w) if w == "!") { - if matches!(self.peek(), Tok::Ident(w) if w == "!") { - self.at += 1; - } - Ok(Expr::Not(Box::new(self.unary()?))) - } else { - self.postfix() - }; - self.leave(); - e - } - - fn postfix(&mut self) -> Result { - let mut e = self.primary()?; - loop { - if matches!(self.peek(), Tok::Op("." | ".." | "[" | "(")) { - self.link()?; - } - if self.eat_op(".") { - let Tok::Ident(name) = self.next() else { return Err(format!("line {}: expected a name after .", self.line())) }; - e = Expr::Member(Box::new(e), name); - } else if self.eat_op("..") { - let Tok::Ident(name) = self.next() else { return Err(format!("line {}: expected a name after ..", self.line())) }; - e = Expr::Descend(Box::new(e), name); - } else if self.eat_op("[") { - let sign = match self.peek() { - Tok::Op("-") => Some(-1), - Tok::Op("+") => Some(1), - _ => None, - }; - let relative = sign.and_then(|sign| match (self.toks.get(self.at + 1).map(|t| &t.tok), self.toks.get(self.at + 2).map(|t| &t.tok)) { - (Some(Tok::Num(n)), Some(Tok::Op("]"))) if n.is_finite() && *n >= 0.0 && *n < 1e6 => Some(sign * (*n as i64)), - _ => None, - }); - let idx = if let Some(k) = relative { - self.at += 2; - Idx::Rel(k) - } else if self.eat_op("*") { - Idx::All - } else { - Idx::Abs(Box::new(self.expr()?)) - }; - self.expect_op("]")?; - e = Expr::Index(Box::new(e), idx); - } else if self.eat_op("(") { - let mut args = Vec::new(); - self.skip_newlines(); - if !self.is_op(")") { - loop { - args.push(self.expr()?); - self.skip_newlines(); - if !self.eat_op(",") { - break; - } - self.skip_newlines(); - } - } - self.expect_op(")")?; - e = Expr::Call(Box::new(e), args); - } else { - return Ok(e); - } - } - } - - fn primary(&mut self) -> Result { - let line = self.line(); - match self.next() { - Tok::Num(n) => Ok(Expr::Num(n)), - Tok::Str(s) => Ok(Expr::Str(s)), - Tok::Op("(") => { - self.skip_newlines(); - let e = self.expr()?; - self.skip_newlines(); - self.expect_op(")")?; - Ok(e) - } - Tok::Ident(w) if w.eq_ignore_ascii_case("null") => Ok(Expr::Null), - Tok::Ident(w) if w.eq_ignore_ascii_case("nan") => Ok(Expr::Num(f64::NAN)), - Tok::Ident(w) if w.eq_ignore_ascii_case("infinity") => Ok(Expr::Num(f64::INFINITY)), - Tok::Ident(w) if w.eq_ignore_ascii_case("true") => Ok(Expr::Num(1.0)), - Tok::Ident(w) if w.eq_ignore_ascii_case("false") => Ok(Expr::Num(0.0)), - Tok::Ident(w) => Ok(Expr::Ident(w)), - t => Err(format!("line {line}: unexpected {}", describe(&t))), - } - } -} - -fn describe(t: &Tok) -> String { - match t { - Tok::Num(n) => format!("number {n}"), - Tok::Str(s) => format!("string {s:?}"), - Tok::Ident(s) => s.clone(), - Tok::Op(o) => format!("{o:?}"), - Tok::Newline => "end of line".into(), - Tok::Eof => "end of script".into(), - } -} - -fn parse(src: &str) -> Result, String> { - let toks = lex(src)?; - let mut p = Parser { toks, at: 0, depth: 0, chain: 0 }; - let body = p.block(&[])?; - match p.peek() { - Tok::Eof => Ok(body), - t => Err(format!("line {}: unexpected {}", p.line(), describe(t))), - } -} - -// ── values ────────────────────────────────────────────────────────────────────────────────── - -#[derive(Clone, Debug, PartialEq)] -enum Val { - Null, - Num(f64), - Str(String), - /// A form object, by index in the host. - Node(usize), - /// Several objects or values (`Row[*]`, `Row[*].Amount`). - List(Vec), - Host, - Layout, - Event, - /// `xfa` itself (`xfa.form`, `xfa.host`, …). - Xfa, - /// The instance manager of `name` under `parent`. - Im(usize, String), - /// A property object whose contents are not modelled (`$.border.fill…`): reads give - /// another sink, writes change nothing. - Sink, -} - -/// A number as FormCalc prints it: integers without a fraction, otherwise up to 15 digits. -fn num_text(n: f64) -> String { - if !n.is_finite() { - return if n.is_nan() { - "NaN".into() - } else if n > 0.0 { - "Infinity".into() - } else { - "-Infinity".into() - }; - } - if n.fract() == 0.0 && n.abs() < 1e15 { - return format!("{}", n as i64); - } - let s = format!("{n:.12}"); - let s = s.trim_end_matches('0').trim_end_matches('.'); - if s.is_empty() || s == "-0" { "0".into() } else { s.to_string() } -} - -/// `v` with `decimals` places, rounding exact halves away from zero (Rust's formatter rounds them to even). -fn fixed(v: f64, decimals: usize) -> String { - let k = 10f64.powi(decimals.min(20) as i32); - let scaled = (v * k).round() / k; - // A small negative value rounds to -0.0, which would print as "-0". - let scaled = if scaled == 0.0 { 0.0 } else { scaled }; - format!("{:.decimals$}", if scaled.is_finite() { scaled } else { v }) -} - -/// The number a string holds: its leading numeric part (`12abc` → 12), `None` for none. -fn parse_num(s: &str) -> Option { - let t = s.trim(); - if t.is_empty() { - return None; - } - let mut end = 0; - let b = t.as_bytes(); - let mut seen_digit = false; - let mut seen_dot = false; - let mut seen_e = false; - let mut i = 0; - if i < b.len() && (b[i] == b'-' || b[i] == b'+') { - i += 1; - } - while i < b.len() { - let c = b[i]; - if c.is_ascii_digit() { - seen_digit = true; - end = i + 1; - } else if c == b'.' && !seen_dot && !seen_e { - seen_dot = true; - } else if (c == b'e' || c == b'E') && seen_digit && !seen_e { - seen_e = true; - if i + 1 < b.len() && (b[i + 1] == b'-' || b[i + 1] == b'+') { - i += 1; - } - } else { - break; - } - i += 1; - } - if !seen_digit { - return None; - } - t.get(..end).and_then(|p| p.parse::().ok()).filter(|v| v.is_finite()) -} - -fn is_numeric_text(s: &str) -> bool { - s.trim().parse::().is_ok_and(f64::is_finite) -} - // ── the evaluator ─────────────────────────────────────────────────────────────────────────── enum Flow { @@ -799,1760 +104,6 @@ struct Interp<'h> { type R = Result; -impl Interp<'_> { - fn step(&mut self) -> R<()> { - self.charge(1) - } - - /// `n` units of work done (a step per statement or expression, more for work on long - /// strings and lists). - fn charge(&mut self, n: u64) -> R<()> { - let before = self.steps; - self.steps = self.steps.saturating_add(n); - if self.steps > MAX_STEPS { - return Err(RAN_TOO_LONG.into()); - } - #[cfg(not(target_arch = "wasm32"))] - if self.steps / CLOCK_EVERY != before / CLOCK_EVERY - && let Some(d) = self.deadline - && std::time::Instant::now() > d - { - return Err(RAN_TOO_LONG.into()); - } - #[cfg(target_arch = "wasm32")] - let _ = before; - Ok(()) - } - - /// `more` nodes into `out`, each once, never past [`MAX_LIST`]. - fn gather(&mut self, out: &mut Vec, seen: &mut HashSet, more: impl IntoIterator) -> R<()> { - for i in more { - if seen.insert(i) { - out.push(i); - if out.len() > MAX_LIST { - return Err(LIST_TOO_LONG.into()); - } - } - } - self.charge(1) - } - - /// The nodes of `v`, each once. - fn nodes_list(&self, v: &Val) -> Vec { - let mut seen = HashSet::new(); - self.nodes_of(v).into_iter().filter(|i| seen.insert(*i)).collect() - } - - fn node(&self, i: usize) -> Option<&HNode> { - self.h.nodes.get(i).filter(|n| !n.gone) - } - - // ── coercions - - /// A node's value as a FormCalc value. - fn node_value(&self, i: usize) -> Val { - let Some(n) = self.node(i) else { return Val::Null }; - if n.value.is_empty() { - return Val::Null; - } - if n.numeric - && let Some(v) = parse_num(&n.value) - { - return Val::Num(v); - } - Val::Str(n.value.clone()) - } - - /// What copying `v` (or the values it stands for) costs, in steps: one per 256 bytes, so - /// copying long strings over and over runs out of steps even where there is no clock - /// (wasm). A form object counts its value; a list its items too. - fn cost(&self, v: &Val) -> u64 { - let mut bytes = 0usize; - let mut todo = vec![v]; - while let Some(v) = todo.pop() { - match v { - Val::Str(s) => bytes = bytes.saturating_add(s.len()), - Val::Node(i) => bytes = bytes.saturating_add(self.node(*i).map_or(0, |n| n.value.len())), - Val::List(items) => { - bytes = bytes.saturating_add(items.len().saturating_mul(16)); - todo.extend(items.iter()); - } - _ => {} - } - } - (bytes / 256) as u64 - } - - /// [`Self::deref`], charged for the copy. - fn take(&mut self, v: Val) -> R { - self.charge(self.cost(&v))?; - Ok(self.deref(v)) - } - - /// Objects become their values; lists of objects become lists of values. - fn deref(&self, v: Val) -> Val { - match v { - Val::Node(i) => self.node_value(i), - Val::List(items) => Val::List(items.into_iter().map(|x| self.deref(x)).collect()), - other => other, - } - } - - fn to_num(&self, v: &Val) -> f64 { - match v { - Val::Null => 0.0, - Val::Num(n) => *n, - Val::Str(s) => parse_num(s).unwrap_or(0.0), - Val::Node(i) => self.to_num(&self.node_value(*i)), - Val::List(items) => items.first().map_or(0.0, |x| self.to_num(x)), - _ => 0.0, - } - } - - fn to_str(&self, v: &Val) -> String { - match v { - Val::Null => String::new(), - Val::Num(n) => num_text(*n), - Val::Str(s) => s.clone(), - Val::Node(i) => self.to_str(&self.node_value(*i)), - Val::List(items) => items.first().map_or(String::new(), |x| self.to_str(x)), - Val::Host => "host".into(), - Val::Layout => "layout".into(), - Val::Event => "event".into(), - Val::Xfa => "xfa".into(), - Val::Im(..) => "instanceManager".into(), - Val::Sink => String::new(), - } - } - - fn truthy(&self, v: &Val) -> bool { - match v { - Val::Null => false, - Val::Num(n) => *n != 0.0 && !n.is_nan(), - Val::Str(s) => parse_num(s).is_some_and(|n| n != 0.0), - Val::Node(i) => { - let d = self.node_value(*i); - self.truthy(&d) - } - Val::List(items) => !items.is_empty(), - _ => true, - } - } - - fn is_null(&self, v: &Val) -> bool { - match v { - Val::Null => true, - Val::Node(i) => self.node(*i).is_none_or(|n| n.value.is_empty()), - Val::List(items) => items.is_empty(), - _ => false, - } - } - - // ── scopes - - fn lookup(&self, name: &str) -> Option { - self.scopes.iter().rev().find_map(|s| s.get(name).cloned()) - } - - fn set_var(&mut self, name: &str, v: Val) -> bool { - for s in self.scopes.iter_mut().rev() { - if let Some(slot) = s.get_mut(name) { - *slot = v; - return true; - } - } - false - } - - fn declare(&mut self, name: &str, v: Val) { - if let Some(s) = self.scopes.last_mut() { - s.insert(name.to_string(), v); - } - } - - // ── accessors - - /// The objects an identifier names from the current node: `$`, the special objects, an - /// instance manager (`_name`), a variable, or a form object by name (children first, then - /// the containing subforms). - fn ident(&self, name: &str) -> R { - if let Some(v) = self.lookup(name) { - return Ok(v); - } - let lower = name.to_ascii_lowercase(); - Ok(match lower.as_str() { - "$" | "this" => Val::Node(self.h.current), - "$host" => Val::Host, - "$layout" => Val::Layout, - "$event" => Val::Event, - "$form" | "$data" | "$dataset" | "$datasets" => Val::Node(0), - "$record" => Val::Node(if self.node(1).is_some() { 1 } else { 0 }), - "$template" | "$xfa" => Val::Xfa, - "xfa" => Val::Xfa, - _ => { - if let Some(child) = name.strip_prefix('_') { - // The instance manager of `child`, looked for like a name. - let found = resolve_som(self.h, self.h.current, child); - let Some(&first) = found.first() else { return Err(format!("unknown instance manager _{child}")) }; - let parent = self.node(first).and_then(|n| n.parent).ok_or_else(|| format!("unknown instance manager _{child}"))?; - return Ok(Val::Im(parent, child.to_string())); - } - let found = resolve_som(self.h, self.h.current, name); - match found.as_slice() { - [] => return Err(format!("unknown name {name}")), - [one] => Val::Node(*one), - many => Val::List(many.iter().map(|i| Val::Node(*i)).collect()), - } - } - }) - } - - /// Every object a value stands for. - fn nodes_of(&self, v: &Val) -> Vec { - match v { - Val::Node(i) => vec![*i], - Val::List(items) => items.iter().flat_map(|x| self.nodes_of(x)).collect(), - _ => Vec::new(), - } - } - - fn member(&mut self, base: Val, name: &str) -> R { - match base { - Val::Node(i) => self.node_member(i, name), - Val::List(items) => { - let mut out = Vec::new(); - let mut seen = HashSet::new(); - for it in items { - match self.member(it, name)? { - Val::List(more) => { - for m in more { - self.list_push(&mut out, &mut seen, m)?; - } - } - Val::Null => {} - v => self.list_push(&mut out, &mut seen, v)?, - } - } - Ok(match out.len() { - 0 => Val::Null, - 1 => out.remove(0), - _ => Val::List(out), - }) - } - Val::Host => Ok(match name.to_ascii_lowercase().as_str() { - "numpages" => Val::Num(self.h.doc.page_count as f64), - "currentpage" => Val::Num(self.h.doc.page as f64), - "name" => Val::Str("Acrobat".into()), - "apptype" => Val::Str("Exchange-Pro".into()), - "version" => Val::Str("11".into()), - "variation" => Val::Str("Full".into()), - "language" => Val::Str("en".into()), - "platform" => Val::Str(crate::platform().into()), - "title" => Val::Str(self.h.doc.file_name.clone()), - "calculationsenabled" | "validationsenabled" => Val::Num(1.0), - _ => Val::Sink, - }), - Val::Layout => Ok(Val::Sink), - Val::Event => Ok(match name.to_ascii_lowercase().as_str() { - "name" => Val::Str(self.event.activity.clone()), - "newtext" | "fulltext" => Val::Str(self.event.new_text.clone()), - "prevtext" => Val::Str(self.event.prev_text.clone()), - "change" => Val::Str(String::new()), - "target" => Val::Node(self.h.current), - "selstart" | "selend" | "cancelaction" | "reenter" | "shift" | "modifier" | "keydown" | "commitkey" => Val::Num(0.0), - _ => Val::Sink, - }), - Val::Xfa => Ok(match name.to_ascii_lowercase().as_str() { - "form" | "datasets" | "data" => Val::Node(0), - "record" => Val::Node(if self.node(1).is_some() { 1 } else { 0 }), - "host" => Val::Host, - "layout" => Val::Layout, - "event" => Val::Event, - _ => Val::Sink, - }), - Val::Im(parent, child) => Ok(match name.to_ascii_lowercase().as_str() { - "count" => Val::Num(instances(self.h, parent, &child).len() as f64), - "min" => Val::Num(instances(self.h, parent, &child).first().and_then(|i| self.node(*i)).map_or(0.0, |n| n.occur_min as f64)), - "max" => Val::Num( - instances(self.h, parent, &child).first().and_then(|i| self.node(*i)).and_then(|n| n.occur_max).map_or(-1.0, |m| m as f64), - ), - "classname" => Val::Str("instanceManager".into()), - _ => Val::Sink, - }), - Val::Sink => Ok(Val::Sink), - Val::Null => Err(format!("no object to take {name} from")), - Val::Num(_) | Val::Str(_) => Err(format!("{} has no property {name}", self.to_str(&base))), - } - } - - /// `v` onto `out`: a node only once, never past [`MAX_LIST`]. - fn list_push(&mut self, out: &mut Vec, seen: &mut HashSet, v: Val) -> R<()> { - if let Val::Node(i) = v - && !seen.insert(i) - { - return Ok(()); - } - out.push(v); - if out.len() > MAX_LIST { - return Err(LIST_TOO_LONG.into()); - } - self.charge(1) - } - - /// Property `name` of form object `i` as FormCalc reads it: its value, attributes, - /// relatives and children by name. - fn node_member(&mut self, i: usize, name: &str) -> R { - let Some(n) = self.node(i) else { return Ok(Val::Null) }; - Ok(match name { - "formattedValue" => match &n.formatted { - Some(f) => Val::Str(f.clone()), - None => self.node_value(i), - }, - "rawValue" | "value" => self.node_value(i), - "name" => Val::Str(n.name.clone()), - "somExpression" => Val::Str(n.som.clone()), - "className" => Val::Str(n.kind.class_name().into()), - "index" | "instanceIndex" => Val::Num(n.index as f64), - "presence" => Val::Str(n.presence.clone()), - "access" => Val::Str(n.access.clone()), - "isNull" => Val::Num(if n.value.is_empty() { 1.0 } else { 0.0 }), - "mandatory" => Val::Str("disabled".into()), - "parent" => n.parent.map_or(Val::Null, Val::Node), - "nodes" => Val::List(n.children.iter().copied().filter(|c| self.node(*c).is_some()).map(Val::Node).collect()), - "all" => match n.parent { - Some(p) => Val::List(instances(self.h, p, &n.name).into_iter().map(Val::Node).collect()), - None => Val::Node(i), - }, - "instanceManager" => match n.parent { - Some(p) => Val::Im(p, n.name.clone()), - None => Val::Null, - }, - "border" | "ui" | "font" | "caption" | "fillColor" | "borderColor" | "fontColor" | "margin" | "para" | "assist" | "validate" - | "format" | "items" | "bind" | "keep" | "h" | "w" | "x" | "y" | "minH" | "maxH" | "minW" | "maxW" | "layout" | "model" - | "oneOfChild" | "locale" | "relevant" | "colSpan" | "dataNode" | "defaultValue" | "editValue" | "selectedIndex" | "event" - | "calculate" | "traversal" | "extras" | "desc" => Val::Sink, - other => { - if let Some(child) = other.strip_prefix('_') { - return Ok(if child_named(self.h, i, child).is_some() { Val::Im(i, child.to_string()) } else { Val::Null }); - } - let found = instances(self.h, i, other); - match found.as_slice() { - [] => Val::Null, - [one] => Val::Node(*one), - many => Val::List(many.iter().map(|k| Val::Node(*k)).collect()), - } - } - }) - } - - /// `base[n]` / `base[*]`: all the instances the last step named, then one or all. - fn index(&mut self, base: &Expr, idx: &Idx) -> R { - let all = self.all_of(base)?; - match idx { - Idx::All => Ok(Val::List(all.into_iter().map(Val::Node).collect())), - Idx::Abs(e) => { - let v = self.eval(e)?; - let n = self.to_num(&v); - if !n.is_finite() || n < 0.0 { - return Ok(Val::Null); - } - Ok(all.get(n as usize).map_or(Val::Null, |i| Val::Node(*i))) - } - Idx::Rel(k) => { - // From the instance the script runs in (`row[-1]` in a row is the row above), - // else from the first. - let here = all.iter().position(|&i| self.encloses(i, self.h.current)).unwrap_or(0); - let at = (here as i64).saturating_add(*k); - Ok(usize::try_from(at).ok().and_then(|at| all.get(at)).map_or(Val::Null, |i| Val::Node(*i))) - } - } - } - - /// Whether `node` is `inner` or one of its containers. - fn encloses(&self, node: usize, inner: usize) -> bool { - let mut at = Some(inner); - let mut guard = 0; - while let Some(i) = at - && guard < MAX_PARSE_DEPTH - { - if i == node { - return true; - } - at = self.h.nodes.get(i).and_then(|n| n.parent); - guard += 1; - } - false - } - - /// Every instance an accessor names (`Row` → all rows, not just the first), each once. - fn all_of(&mut self, e: &Expr) -> R> { - let mut out = Vec::new(); - let mut seen = HashSet::new(); - match e { - Expr::Ident(name) => { - if self.lookup(name).is_some() || name.starts_with('$') || name.eq_ignore_ascii_case("xfa") { - let v = self.eval(e)?; - return Ok(self.nodes_list(&v)); - } - // The name resolves to its first instance; `row[*]` means every sibling of it. - for first in resolve_som(self.h, self.h.current, name) { - match self.node(first).and_then(|n| n.parent.map(|p| (p, n.name.clone()))) { - Some((p, n)) => self.gather(&mut out, &mut seen, instances(self.h, p, &n))?, - None => self.gather(&mut out, &mut seen, [first])?, - } - } - } - Expr::Member(base, name) => { - let b = self.eval(base)?; - for i in self.nodes_list(&b) { - self.gather(&mut out, &mut seen, instances(self.h, i, name))?; - } - if out.is_empty() { - let v = self.member(b, name)?; - out = self.nodes_list(&v); - } - } - Expr::Descend(base, name) => { - let b = self.eval(base)?; - for i in self.nodes_list(&b) { - self.gather(&mut out, &mut seen, descendants_named(self.h, i, name, 0))?; - } - } - other => { - let v = self.eval(other)?; - out = self.nodes_list(&v); - } - } - Ok(out) - } - - // ── expressions - - fn eval(&mut self, e: &Expr) -> R { - self.step()?; - self.depth += 1; - if self.depth > self.max_depth { - self.depth -= 1; - return Err(TOO_DEEP.into()); - } - let r = self.eval_inner(e); - self.depth -= 1; - r - } - - fn eval_inner(&mut self, e: &Expr) -> R { - Ok(match e { - Expr::Num(n) => Val::Num(*n), - Expr::Str(s) => { - self.charge((s.len() / 256) as u64)?; - Val::Str(s.clone()) - } - Expr::Null => Val::Null, - Expr::Ident(name) => { - let v = self.ident(name)?; - self.charge(self.cost(&v))?; - v - } - Expr::Member(base, name) => { - let b = self.eval(base)?; - self.member(b, name)? - } - Expr::Index(base, idx) => self.index(base, idx)?, - Expr::Descend(base, name) => { - let b = self.eval(base)?; - let mut out = Vec::new(); - let mut seen = HashSet::new(); - for i in self.nodes_list(&b) { - self.gather(&mut out, &mut seen, descendants_named(self.h, i, name, 0))?; - } - match out.as_slice() { - [] => Val::Null, - [one] => Val::Node(*one), - many => Val::List(many.iter().map(|k| Val::Node(*k)).collect()), - } - } - Expr::Call(callee, args) => self.call(callee, args)?, - Expr::Neg(x) => { - let v = self.eval(x)?; - if self.is_null(&v) { Val::Null } else { Val::Num(-self.to_num(&v)) } - } - Expr::Not(x) => { - let v = self.eval(x)?; - Val::Num(if self.truthy(&v) { 0.0 } else { 1.0 }) - } - Expr::Bin(..) => { - // The parser leans left (`a + b + c` is `(a + b) + c`), so a chain is walked - // from its leftmost operand and folded without recursing per operator. - let mut links = Vec::new(); - let mut leftmost = e; - while let Expr::Bin(op, l, r) = leftmost { - links.push((*op, r.as_ref())); - leftmost = l; - } - let mut acc = self.eval(leftmost)?; - for (op, r) in links.into_iter().rev() { - self.step()?; - acc = match op { - "&" => { - if !self.truthy(&acc) { - // Not evaluated: the result is settled. - Val::Num(0.0) - } else { - let b = self.eval(r)?; - Val::Num(if self.truthy(&b) { 1.0 } else { 0.0 }) - } - } - "|" => { - if self.truthy(&acc) { - Val::Num(1.0) - } else { - let b = self.eval(r)?; - Val::Num(if self.truthy(&b) { 1.0 } else { 0.0 }) - } - } - _ => { - let b = self.eval(r)?; - // Comparing (and dereferencing) long strings is work too. - self.charge(self.cost(&acc).saturating_add(self.cost(&b)))?; - self.binary(op, acc, b)? - } - }; - } - acc - } - }) - } - - fn binary(&self, op: &str, a: Val, b: Val) -> R { - let (a, b) = (self.deref(a), self.deref(b)); - // Null with an arithmetic operator is null only when both sides are null. - match op { - "+" | "-" | "*" | "/" => { - if self.is_null(&a) && self.is_null(&b) { - return Ok(Val::Null); - } - let (x, y) = (self.to_num(&a), self.to_num(&b)); - let v = match op { - "+" => x + y, - "-" => x - y, - "*" => x * y, - _ => { - if y == 0.0 { - return Err("division by zero".into()); - } - x / y - } - }; - Ok(Val::Num(v)) - } - _ => { - let ord = match (&a, &b) { - (Val::Null, Val::Null) => Some(std::cmp::Ordering::Equal), - (Val::Null, _) | (_, Val::Null) => None, - (Val::Str(s), Val::Str(t)) if !(is_numeric_text(s) && is_numeric_text(t)) => Some(s.cmp(t)), - (Val::Str(s), Val::Num(_)) if !is_numeric_text(s) => Some(s.as_str().cmp(num_text(self.to_num(&b)).as_str())), - (Val::Num(_), Val::Str(t)) if !is_numeric_text(t) => Some(num_text(self.to_num(&a)).as_str().cmp(t.as_str())), - _ => self.to_num(&a).partial_cmp(&self.to_num(&b)), - }; - let r = match (op, ord) { - ("==", Some(o)) => o.is_eq(), - ("<>", Some(o)) => !o.is_eq(), - ("<>", None) => true, - ("<", Some(o)) => o.is_lt(), - ("<=", Some(o)) => o.is_le(), - (">", Some(o)) => o.is_gt(), - (">=", Some(o)) => o.is_ge(), - _ => false, - }; - Ok(Val::Num(if r { 1.0 } else { 0.0 })) - } - } - } - - // ── calls - - fn call(&mut self, callee: &Expr, args: &[Expr]) -> R { - match callee { - Expr::Ident(name) => { - if let Some(f) = self.funcs.get(&name.to_ascii_lowercase()).cloned() { - let mut vals = Vec::new(); - for a in args { - let v = self.eval(a)?; - vals.push(self.take(v)?); - } - return self.call_user(&f, vals); - } - self.builtin(name, args) - } - Expr::Member(base, method) => { - let b = self.eval(base)?; - self.method(b, method, args) - } - _ => Err("not a function".into()), - } - } - - fn call_user(&mut self, f: &Func, vals: Vec) -> R { - if self.scopes.len() > self.max_depth { - return Err(CALLS_TOO_DEEP.into()); - } - let mut scope = HashMap::new(); - for (i, p) in f.params.iter().enumerate() { - scope.insert(p.clone(), vals.get(i).cloned().unwrap_or(Val::Null)); - } - self.scopes.push(scope); - let r = self.block(&f.body); - self.scopes.pop(); - match r? { - (Flow::Return(v), _) => Ok(v), - (Flow::Exit, last) => { - self.exited = true; - Ok(last) - } - (_, last) => Ok(last), - } - } - - fn arg(&mut self, args: &[Expr], i: usize) -> R { - match args.get(i) { - Some(e) => { - let v = self.eval(e)?; - self.take(v) - } - None => Ok(Val::Null), - } - } - - fn arg_num(&mut self, args: &[Expr], i: usize) -> R { - let v = self.arg(args, i)?; - Ok(self.to_num(&v)) - } - - fn arg_str(&mut self, args: &[Expr], i: usize) -> R { - let v = self.arg(args, i)?; - Ok(self.to_str(&v)) - } - - /// Every value the arguments hold, lists flattened, nulls left out. - fn arg_values(&mut self, args: &[Expr]) -> R> { - self.arg_values_keeping(args, false) - } - - /// [`Self::arg_values`]; `objects` keeps form objects (fields, subforms) as themselves, so - /// `Count(row[*])` counts rows rather than filled-in values. - fn arg_values_keeping(&mut self, args: &[Expr], objects: bool) -> R> { - let mut out = Vec::new(); - for a in args { - let v = self.eval(a)?; - let v = if objects { v } else { self.take(v)? }; - match v { - Val::List(items) => out.extend(items.into_iter().filter(|x| !matches!(x, Val::Null))), - Val::Null => {} - other => out.push(other), - } - if out.len() > MAX_LIST { - return Err(LIST_TOO_LONG.into()); - } - } - Ok(out) - } - - fn method(&mut self, base: Val, method: &str, args: &[Expr]) -> R { - let m = method.to_ascii_lowercase(); - match base { - Val::Host => match m.as_str() { - "messagebox" => { - let t = self.arg_str(args, 0)?; - self.h.push(XfaEffect::MessageBox(t)); - Ok(Val::Num(1.0)) - } - "resetdata" => { - let list = self.arg_str(args, 0)?; - self.h.push(XfaEffect::ResetData(reset_names(&list))); - Ok(Val::Null) - } - "print" => { - self.h.push(XfaEffect::Print); - Ok(Val::Null) - } - "beep" => { - self.h.push(XfaEffect::Beep); - Ok(Val::Null) - } - "gotourl" => { - let u = self.arg_str(args, 0)?; - self.h.push(XfaEffect::LaunchUrl(u)); - Ok(Val::Null) - } - "setfocus" => { - let target = match args.first() { - Some(e) => { - let raw = self.eval(e)?; - match self.nodes_of(&raw).first() { - Some(i) => self.node(*i).map(|n| n.som.clone()).unwrap_or_default(), - None => { - let s = self.to_str(&raw); - let found = resolve_som(self.h, self.h.current, &s); - found.first().and_then(|i| self.node(*i)).map(|n| n.som.clone()).unwrap_or(s) - } - } - } - None => String::new(), - }; - self.h.push(XfaEffect::SetFocus(target)); - Ok(Val::Null) - } - _ => Ok(Val::Null), - }, - Val::Layout => match m.as_str() { - "page" | "abspage" => Ok(Val::Num(self.h.doc.page as f64 + if m == "page" { 1.0 } else { 0.0 })), - "pagecount" | "abspagecount" => Ok(Val::Num(self.h.doc.page_count as f64)), - "relayout" | "relayoutpagearea" => { - self.h.push(XfaEffect::Relayout); - Ok(Val::Null) - } - _ => Ok(Val::Num(0.0)), - }, - Val::Xfa => match m.as_str() { - "resolvenode" | "resolvenodes" => { - let s = self.arg_str(args, 0)?; - let found = resolve_som(self.h, self.h.current, &s); - Ok(self.set_val(found, m == "resolvenodes")) - } - "recalculate" => { - self.h.push(XfaEffect::Recalculate); - Ok(Val::Null) - } - "remerge" => { - self.h.push(XfaEffect::Relayout); - Ok(Val::Null) - } - _ => Ok(Val::Null), - }, - Val::Node(i) => match m.as_str() { - "resolvenode" | "resolvenodes" => { - let s = self.arg_str(args, 0)?; - let found = resolve_som(self.h, i, &s); - Ok(self.set_val(found, m == "resolvenodes")) - } - "recalculate" => { - self.h.push(XfaEffect::Recalculate); - Ok(Val::Null) - } - "remerge" => { - self.h.push(XfaEffect::Relayout); - Ok(Val::Null) - } - _ => Ok(Val::Null), - }, - Val::Im(parent, name) => match m.as_str() { - "addinstance" | "insertinstance" => Ok(self.h.add_instance(parent, &name).map_or(Val::Null, Val::Node)), - "removeinstance" => { - let i = self.arg_num(args, 0)?; - if i.is_finite() && i >= 0.0 { - self.h.remove_instance(parent, &name, i as usize); - } - Ok(Val::Null) - } - "setinstances" => { - let want = self.arg_num(args, 0)?; - if want.is_finite() && want >= 0.0 { - self.h.set_instances(parent, &name, want as usize); - } - Ok(Val::Null) - } - _ => Ok(Val::Null), - }, - Val::Event | Val::Sink | Val::List(_) => Ok(Val::Null), - Val::Null => Err(format!("no object to call {method} on")), - Val::Num(_) | Val::Str(_) => Err(format!("{} has no method {method}", self.to_str(&base))), - } - } - - fn set_val(&self, found: Vec, many: bool) -> Val { - if many { - return Val::List(found.into_iter().map(Val::Node).collect()); - } - match found.as_slice() { - [] => Val::Null, - [one] => Val::Node(*one), - all => Val::List(all.iter().map(|i| Val::Node(*i)).collect()), - } - } - - // ── assignment - - fn assign(&mut self, target: &Expr, v: Val) -> R<()> { - let v = self.deref(v); - match target { - Expr::Ident(name) => { - if self.set_var(name, v.clone()) { - return Ok(()); - } - let t = self.ident(name)?; - self.assign_to(t, "rawValue", v) - } - Expr::Member(base, prop) => { - let b = self.eval(base)?; - self.assign_to(b, prop, v) - } - Expr::Index(..) | Expr::Descend(..) => { - let t = self.eval(target)?; - self.assign_to(t, "rawValue", v) - } - _ => Err("cannot assign to that".into()), - } - } - - fn assign_to(&mut self, target: Val, prop: &str, v: Val) -> R<()> { - match target { - Val::Node(i) => { - let Some(n) = self.node(i) else { return Ok(()) }; - let som = n.som.clone(); - match prop { - "rawValue" | "value" | "formattedValue" => { - let text = self.to_str(&v); - if !self.h.set_value(i, text) { - return Err(format!("{} is not a field", if som.is_empty() { "the form" } else { som.as_str() })); - } - } - "presence" => { - let p = self.to_str(&v); - if matches!(p.as_str(), "visible" | "invisible" | "hidden" | "inactive") { - self.h.set_state(i, XfaEffect::SetPresence { som, presence: p }); - } - } - "access" => { - let a = self.to_str(&v); - if matches!(a.as_str(), "open" | "readOnly" | "protected" | "nonInteractive") { - self.h.set_state(i, XfaEffect::SetAccess { som, access: a }); - } - } - other => { - // `page1.qty = 7`: the child object named by the last step takes the value. - if let Some(child) = other.strip_prefix('_') { - if child_named(self.h, i, child).is_some() { - return self.assign_to(Val::Im(i, child.to_string()), "count", v); - } - return Ok(()); - } - let kids = instances(self.h, i, other); - if kids.is_empty() { - return Err(format!("{som} has no {other} to assign to")); - } - for k in kids { - // Every destination copies the value: charge each copy. - self.charge(self.cost(&v))?; - self.assign_to(Val::Node(k), "rawValue", v.clone())?; - } - } - } - Ok(()) - } - Val::List(items) => { - for it in items { - self.charge(self.cost(&v))?; - self.assign_to(it, prop, v.clone())?; - } - Ok(()) - } - Val::Im(parent, name) if prop.eq_ignore_ascii_case("count") => { - let want = self.to_num(&v); - if want.is_finite() && want >= 0.0 { - self.h.set_instances(parent, &name, want as usize); - } - Ok(()) - } - _ => Ok(()), - } - } - - // ── statements - - /// Run a block; returns how it ended and the value of its last expression statement. - fn block(&mut self, body: &[Stmt]) -> R<(Flow, Val)> { - // Nesting of statements counts like nesting of expressions: both are stack. - self.depth += 1; - if self.depth > self.max_depth { - self.depth -= 1; - return Err(TOO_DEEP.into()); - } - let r = self.block_inner(body); - self.depth -= 1; - r - } - - fn block_inner(&mut self, body: &[Stmt]) -> R<(Flow, Val)> { - let mut last = Val::Null; - for s in body { - self.step()?; - match self.exec(s)? { - (Flow::Next, v) => { - if let Some(v) = v { - last = v; - } - if self.exited { - return Ok((Flow::Exit, last)); - } - } - (flow, _) => return Ok((flow, last)), - } - } - Ok((Flow::Next, last)) - } - - fn exec(&mut self, s: &Stmt) -> R<(Flow, Option)> { - match s { - Stmt::Var(name, init) => { - let v = match init { - Some(e) => { - let v = self.eval(e)?; - self.take(v)? - } - None => Val::Null, - }; - self.declare(name, v.clone()); - Ok((Flow::Next, Some(v))) - } - Stmt::Assign(target, e) => { - let v = self.eval(e)?; - let v = self.take(v)?; - self.assign(target, v.clone())?; - Ok((Flow::Next, Some(v))) - } - Stmt::Expr(e) => { - let v = self.eval(e)?; - let v = self.take(v)?; - Ok((Flow::Next, Some(v))) - } - Stmt::If(arms, otherwise) => { - for (cond, body) in arms { - let c = self.eval(cond)?; - if self.truthy(&c) { - let (f, v) = self.block(body)?; - return Ok((f, Some(v))); - } - } - if let Some(body) = otherwise { - let (f, v) = self.block(body)?; - return Ok((f, Some(v))); - } - Ok((Flow::Next, None)) - } - Stmt::While(cond, body) => { - loop { - let c = self.eval(cond)?; - if !self.truthy(&c) { - break; - } - self.iterate()?; - match self.block(body)? { - (Flow::Break, _) => break, - (Flow::Return(v), _) => return Ok((Flow::Return(v), None)), - (Flow::Exit, _) => return Ok((Flow::Exit, None)), - _ => {} - } - } - Ok((Flow::Next, None)) - } - Stmt::For { var, from, to, up, step, body } => { - let start = { - let v = self.eval(from)?; - self.to_num(&v) - }; - let end = { - let v = self.eval(to)?; - self.to_num(&v) - }; - let by = match step { - Some(e) => { - let v = self.eval(e)?; - self.to_num(&v).abs() - } - None => 1.0, - }; - if by.is_nan() || by <= 0.0 || !start.is_finite() || !end.is_finite() { - return Err("bad for loop bounds".into()); - } - self.declare(var, Val::Num(start)); - let mut i = start; - while if *up { i <= end } else { i >= end } { - self.iterate()?; - self.set_var(var, Val::Num(i)); - match self.block(body)? { - (Flow::Break, _) => break, - (Flow::Return(v), _) => return Ok((Flow::Return(v), None)), - (Flow::Exit, _) => return Ok((Flow::Exit, None)), - _ => {} - } - i = if *up { i + by } else { i - by }; - } - Ok((Flow::Next, None)) - } - Stmt::Foreach { var, list, body } => { - let items = self.arg_values_keeping(list, true)?; - self.declare(var, Val::Null); - for it in items { - self.iterate()?; - self.set_var(var, it); - match self.block(body)? { - (Flow::Break, _) => break, - (Flow::Return(v), _) => return Ok((Flow::Return(v), None)), - (Flow::Exit, _) => return Ok((Flow::Exit, None)), - _ => {} - } - } - Ok((Flow::Next, None)) - } - Stmt::Func { name, f } => { - self.funcs.insert(name.to_ascii_lowercase(), Rc::clone(f)); - Ok((Flow::Next, None)) - } - Stmt::Return(e) => { - let v = match e { - Some(e) => { - let v = self.eval(e)?; - self.take(v)? - } - None => Val::Null, - }; - Ok((Flow::Return(v), None)) - } - Stmt::Break => Ok((Flow::Break, None)), - Stmt::Continue => Ok((Flow::Continue, None)), - Stmt::Exit => Ok((Flow::Exit, None)), - Stmt::Throw(e) => { - let v = self.eval(e)?; - self.charge(self.cost(&v))?; - // The thrown value becomes the error message the caller keeps: cut it as - // messages are. - Err(clip(self.to_str(&v))) - } - } - } - - fn iterate(&mut self) -> R<()> { - if self.loops_left == 0 { - return Err(LOOPED_TOO_MUCH.into()); - } - self.loops_left -= 1; - Ok(()) - } -} - -// ── built-in functions ────────────────────────────────────────────────────────────────────── - -/// `NaiveDate::num_days_from_ce` of 1900-01-01 minus one: a civil date's day count rebased so -/// that 1900-01-01 is FormCalc day 1 (and 1970-01-01 is day 25_568). -const FORMCALC_EPOCH_FROM_CE: i64 = 693_595; - -/// Days in one 400-year Gregorian cycle; the calendar repeats after it. -const DAYS_PER_400_YEARS: i64 = 146_097; - -/// Days from 1900-01-01 (day 1, as FormCalc counts) to a civil date. -/// -/// chrono's `NaiveDate` stops near ±262143, but FormCalc accepts day numbers up to `MAX_DAYS` -/// (years past 270000). The calendar repeats every 400 years, so chrono converts the date -/// within its cycle and whole cycles are added back; the result matches the proleptic Gregorian -/// count for every year. Callers pass dates `parse_date`/`civil_from_days` already validated; -/// anything else reads as 0. -fn days_from_civil(y: i64, m: u32, d: u32) -> i64 { - use chrono::Datelike; - let (cycles, year) = (i128::from(y.div_euclid(400)), y.rem_euclid(400)); - i32::try_from(year) - .ok() - .and_then(|year| chrono::NaiveDate::from_ymd_opt(year, m, d)) - .and_then(|date| { - let days = i128::from(date.num_days_from_ce()) - i128::from(FORMCALC_EPOCH_FROM_CE) + cycles * i128::from(DAYS_PER_400_YEARS); - i64::try_from(days).ok() - }) - .unwrap_or(0) -} - -/// The civil date (year, month, day) at FormCalc day number `n` (day 1 is 1900-01-01), for any -/// `n`: chrono converts the day within its 400-year cycle and the cycles are added back as years. -fn civil_from_days(n: i64) -> (i64, u32, u32) { - use chrono::Datelike; - // Days since CE day 0 (0000-12-31), in i128 so no `n` overflows. - let from_ce = i128::from(n) + i128::from(FORMCALC_EPOCH_FROM_CE); - let (cycles, rest) = (from_ce.div_euclid(i128::from(DAYS_PER_400_YEARS)), from_ce.rem_euclid(i128::from(DAYS_PER_400_YEARS))); - // `rest` is 0..146_097, so CE day `rest` lies in years 0..=400 (day 0 is 0000-12-31). - let date = i32::try_from(rest).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt); - match date { - Some(date) => { - let y = i128::from(date.year()) + cycles * 400; - (i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), date.month(), date.day()) - } - // Unreachable: every `rest` in 0..146_097 is a valid CE day. - None => (1900, 1, 1), - } -} - -/// Day numbers (and years) beyond this are refused rather than computed. -const MAX_DAYS: f64 = 1e8; - -const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]; - -/// Parse `text` as a date by `picture` (`YYYY-MM-DD`, `MM/DD/YYYY`, `MMM D, YYYY`, …). -fn parse_date(text: &str, picture: &str) -> Option<(i64, u32, u32)> { - let pic: Vec = picture.chars().collect(); - let txt: Vec = text.trim().chars().collect(); - let (mut y, mut m, mut d) = (None, None, None); - let (mut i, mut j) = (0, 0); - while i < pic.len() { - let c = pic[i].to_ascii_uppercase(); - if c == 'Y' || c == 'M' || c == 'D' { - let mut run = 1; - while i + run < pic.len() && pic[i + run].to_ascii_uppercase() == c { - run += 1; - } - i += run; - if c == 'M' && run >= 3 { - // Month name. - let rest: String = txt.get(j..).unwrap_or(&[]).iter().collect(); - let lower = rest.to_ascii_lowercase(); - let found = MONTHS - .iter() - .position(|name| lower.starts_with(&name.to_ascii_lowercase()) || lower.starts_with(&name[..3].to_ascii_lowercase())); - let k = found?; - m = Some(k as u32 + 1); - let full = MONTHS[k].to_ascii_lowercase(); - j += if lower.starts_with(&full) { full.chars().count() } else { 3 }; - continue; - } - let width = if run == 1 { 2 } else { run }; - let mut digits = String::new(); - while j < txt.len() && txt[j].is_ascii_digit() && digits.len() < width { - digits.push(txt[j]); - j += 1; - } - if digits.is_empty() { - return None; - } - let v: i64 = digits.parse().ok()?; - match c { - 'Y' => y = Some(if run <= 2 { if v < 50 { 2000 + v } else { 1900 + v } } else { v }), - 'M' => m = Some(v as u32), - _ => d = Some(v as u32), - } - } else { - if txt.get(j) != Some(&pic[i]) { - return None; - } - i += 1; - j += 1; - } - } - let (y, m, d) = (y?, m.unwrap_or(1), d.unwrap_or(1)); - ((-200_000..=200_000).contains(&y) && (1..=12).contains(&m) && (1..=days_in(y, m)).contains(&d)).then_some((y, m, d)) -} - -/// Number of days in month `m` (1-12) of year `y`, leap years included. -pub(crate) fn days_in(y: i64, m: u32) -> u32 { - // The length of the month from chrono (the first of the next month, less the first of this - // one). An invalid month keeps the old 31-day default. - // Month lengths repeat every 400 years, so the year within its cycle is enough (and always - // in chrono's range, whatever `y` is). - let y = y.rem_euclid(400); - let first = |y: i64, m: u32| i32::try_from(y).ok().and_then(|y| chrono::NaiveDate::from_ymd_opt(y, m, 1)); - let next = if m == 12 { first(y + 1, 1) } else { first(y, m + 1) }; - match (first(y, m), next) { - (Some(a), Some(b)) => (b - a).num_days() as u32, - _ => 31, - } -} - -const WEEKDAYS: [&str; 7] = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; - -fn format_date(y: i64, m: u32, d: u32, picture: &str) -> String { - let pic: Vec = picture.chars().collect(); - let mut out = String::new(); - let mut i = 0; - while i < pic.len() { - let c = pic[i].to_ascii_uppercase(); - if c == 'Y' || c == 'M' || c == 'D' { - let mut run = 1; - while i + run < pic.len() && pic[i + run].to_ascii_uppercase() == c { - run += 1; - } - i += run; - match (c, run) { - ('Y', 1 | 2) => out.push_str(&format!("{:02}", y.rem_euclid(100))), - ('Y', _) => out.push_str(&format!("{y:04}")), - ('M', 1) => out.push_str(&m.to_string()), - ('M', 2) => out.push_str(&format!("{m:02}")), - ('M', 3) => out.push_str(MONTHS.get((m as usize).saturating_sub(1)).map_or("", |n| &n[..3])), - ('M', _) => out.push_str(MONTHS.get((m as usize).saturating_sub(1)).copied().unwrap_or("")), - ('D', 1) => out.push_str(&d.to_string()), - (_, _) => out.push_str(&format!("{d:02}")), - } - } else if pic[i] == 'E' { - let mut run = 1; - while i + run < pic.len() && pic[i + run] == 'E' { - run += 1; - } - i += run; - // Day 1 (1900-01-01) was a Monday, so the serial modulo 7 indexes from Sunday. - let weekday = days_from_civil(y, m, d).rem_euclid(7) as usize; - match run { - 1 | 2 => out.push_str(&(weekday + 1).to_string()), - 3 => out.push_str(WEEKDAYS.get(weekday).map_or("", |n| &n[..3])), - _ => out.push_str(WEEKDAYS.get(weekday).copied().unwrap_or("")), - } - } else { - out.push(pic[i]); - i += 1; - } - } - out -} - -const ONES: [&str; 20] = [ - "Zero", - "One", - "Two", - "Three", - "Four", - "Five", - "Six", - "Seven", - "Eight", - "Nine", - "Ten", - "Eleven", - "Twelve", - "Thirteen", - "Fourteen", - "Fifteen", - "Sixteen", - "Seventeen", - "Eighteen", - "Nineteen", -]; -const TENS: [&str; 10] = ["", "", "Twenty", "Thirty", "Forty", "Fifty", "Sixty", "Seventy", "Eighty", "Ninety"]; - -fn words_below_1000(n: u64) -> String { - let mut parts = Vec::new(); - if n >= 100 { - parts.push(format!("{} Hundred", ONES[(n / 100) as usize])); - } - let r = n % 100; - if r >= 20 { - parts.push(if r.is_multiple_of(10) { - TENS[(r / 10) as usize].to_string() - } else { - format!("{}-{}", TENS[(r / 10) as usize], ONES[(r % 10) as usize]) - }); - } else if r > 0 || n == 0 { - parts.push(ONES[r as usize].to_string()); - } - parts.join(" ") -} - -/// `WordNum`: an integer in English words (up to the billions). -fn word_num(n: f64, cents: bool) -> String { - if !n.is_finite() || !(0.0..1e12).contains(&n) { - return String::new(); - } - let cents_total = (n * 100.0).round() as u64; - let whole = if cents { cents_total / 100 } else { n.trunc() as u64 }; - if whole >= 1_000_000_000_000 { - // Rounding to cents carried past the supported range. - return String::new(); - } - let mut parts = Vec::new(); - let groups = [(1_000_000_000u64, "Billion"), (1_000_000, "Million"), (1_000, "Thousand")]; - let mut rest = whole; - for (unit, name) in groups { - if rest >= unit { - parts.push(format!("{} {name}", words_below_1000(rest / unit))); - rest %= unit; - } - } - if rest > 0 || parts.is_empty() { - parts.push(words_below_1000(rest)); - } - let mut out = parts.join(" "); - if cents { - let c = cents_total % 100; - let dollars = if whole == 1 { "Dollar" } else { "Dollars" }; - let cent = if c == 1 { "Cent" } else { "Cents" }; - out.push_str(&format!(" {dollars} And {c:02} {cent}")); - } - out -} - -impl Interp<'_> { - /// A built-in function: its result is never a string past [`MAX_STRING`], and work on - /// long strings is charged to the step budget. - fn builtin(&mut self, name: &str, args: &[Expr]) -> R { - let v = self.builtin_inner(name, args)?; - if let Val::Str(s) = &v { - if s.len() > MAX_STRING { - return Err(STRING_TOO_LONG.into()); - } - self.charge((s.len() / 256) as u64)?; - } - Ok(v) - } - - fn builtin_inner(&mut self, name: &str, args: &[Expr]) -> R { - let lower = name.to_ascii_lowercase(); - let n = |v: f64| Ok(Val::Num(v)); - Ok(match lower.as_str() { - // ── arithmetic - "abs" => return n(self.arg_num(args, 0)?.abs()), - "ceil" => return n(self.arg_num(args, 0)?.ceil()), - "floor" => return n(self.arg_num(args, 0)?.floor()), - "mod" => { - let (a, b) = (self.arg_num(args, 0)?, self.arg_num(args, 1)?); - if b == 0.0 { - return Err("division by zero".into()); - } - Val::Num(a - b * (a / b).trunc()) - } - "round" => { - let a = self.arg_num(args, 0)?; - let places = if args.len() > 1 { self.arg_num(args, 1)?.clamp(0.0, 12.0) as i32 } else { 0 }; - let k = 10f64.powi(places); - let scaled = a * k; - if scaled.is_finite() { Val::Num(scaled.round() / k) } else { Val::Num(a) } - } - "count" => { - // Non-null values and objects: an empty field doesn't count, a subform does. - let vals = self.arg_values_keeping(args, true)?; - let counts = |v: &Val| match v { - Val::Node(i) => self.node(*i).is_some_and(|n| !matches!(n.kind, XfaKind::Field | XfaKind::ExclGroup) || !n.value.is_empty()), - other => !self.is_null(other), - }; - Val::Num(vals.iter().filter(|v| counts(v)).count() as f64) - } - "sum" | "avg" | "max" | "min" => { - let vals = self.arg_values(args)?; - let nums: Vec = vals.iter().map(|v| self.to_num(v)).collect(); - match lower.as_str() { - "sum" => Val::Num(nums.iter().sum()), - "avg" => { - if nums.is_empty() { - Val::Null - } else { - Val::Num(nums.iter().sum::() / nums.len() as f64) - } - } - "max" => nums.iter().copied().fold(None, |m: Option, x| Some(m.map_or(x, |m| m.max(x)))).map_or(Val::Null, Val::Num), - _ => nums.iter().copied().fold(None, |m: Option, x| Some(m.map_or(x, |m| m.min(x)))).map_or(Val::Null, Val::Num), - } - } - // ── logical - "choose" => { - let i = self.arg_num(args, 0)?; - if !i.is_finite() || i < 1.0 { - return Ok(Val::Null); - } - self.arg(args, i as usize)? - } - "exists" => { - let Some(e) = args.first() else { return Ok(Val::Num(0.0)) }; - // A name that resolves to nothing is the answer "no"; a limit the lookup ran - // into (time, steps, depth, size) still stops the script. - match self.all_of(e) { - Ok(v) => Val::Num(if v.is_empty() { 0.0 } else { 1.0 }), - Err(e) if is_limit(&e) => return Err(e), - Err(_) => Val::Num(0.0), - } - } - "hasvalue" => { - let v = self.arg(args, 0)?; - Val::Num(if self.is_null(&v) || matches!(&v, Val::Str(s) if s.trim().is_empty()) { 0.0 } else { 1.0 }) - } - "oneof" => { - let v = self.arg(args, 0)?; - let rest = self.arg_values(args.get(1..).unwrap_or_default())?; - let mut hit = false; - for x in &rest { - // Each comparison copies and compares `v`: charged per item. - self.charge(self.cost(&v).saturating_add(self.cost(x)).saturating_add(1))?; - if self.binary("==", v.clone(), x.clone()).is_ok_and(|r| self.truthy(&r)) { - hit = true; - break; - } - } - Val::Num(if hit { 1.0 } else { 0.0 }) - } - "within" => { - let v = self.arg(args, 0)?; - let (lo, hi) = (self.arg(args, 1)?, self.arg(args, 2)?); - let ok = self.binary(">=", v.clone(), lo).is_ok_and(|r| self.truthy(&r)) && self.binary("<=", v, hi).is_ok_and(|r| self.truthy(&r)); - Val::Num(if ok { 1.0 } else { 0.0 }) - } - // ── strings - "at" => { - let (s, sub) = (self.arg_str(args, 0)?, self.arg_str(args, 1)?); - Val::Num(if sub.is_empty() { 0.0 } else { s.find(&sub).map_or(0.0, |b| s[..b].chars().count() as f64 + 1.0) }) - } - "concat" => { - let mut out = String::new(); - for i in 0..args.len() { - out.push_str(&self.arg_str(args, i)?); - if out.len() > MAX_STRING { - return Err(STRING_TOO_LONG.into()); - } - } - Val::Str(out) - } - "left" => { - let (s, k) = (self.arg_str(args, 0)?, self.arg_num(args, 1)?.max(0.0) as usize); - Val::Str(s.chars().take(k).collect()) - } - "right" => { - let (s, k) = (self.arg_str(args, 0)?, self.arg_num(args, 1)?.max(0.0) as usize); - let len = s.chars().count(); - Val::Str(s.chars().skip(len.saturating_sub(k)).collect()) - } - "len" => Val::Num(self.arg_str(args, 0)?.chars().count() as f64), - "lower" => Val::Str(self.arg_str(args, 0)?.to_lowercase()), - "upper" => Val::Str(self.arg_str(args, 0)?.to_uppercase()), - "ltrim" => Val::Str(self.arg_str(args, 0)?.trim_start().to_string()), - "rtrim" => Val::Str(self.arg_str(args, 0)?.trim_end().to_string()), - "space" => { - let k = self.arg_num(args, 0)?.clamp(0.0, MAX_STRING as f64) as usize; - Val::Str(" ".repeat(k)) - } - "str" => { - let v = self.arg_num(args, 0)?; - let width = if args.len() > 1 { self.arg_num(args, 1)?.clamp(0.0, 1000.0) as usize } else { 10 }; - let prec = if args.len() > 2 { self.arg_num(args, 2)?.clamp(0.0, 20.0) as usize } else { 0 }; - let text = fixed(v, prec); - Val::Str(if text.len() > width { "*".repeat(width) } else { format!("{text:>width$}") }) - } - "substr" => { - let s = self.arg_str(args, 0)?; - let start = self.arg_num(args, 1)?.max(1.0) as usize; - let len = self.arg_num(args, 2)?.max(0.0) as usize; - Val::Str(s.chars().skip(start - 1).take(len).collect()) - } - "stuff" => { - let s = self.arg_str(args, 0)?; - let start = self.arg_num(args, 1)?.max(1.0) as usize; - let del = self.arg_num(args, 2)?.max(0.0) as usize; - let ins = if args.len() > 3 { self.arg_str(args, 3)? } else { String::new() }; - let chars: Vec = s.chars().collect(); - let a = start.saturating_sub(1).min(chars.len()); - let b = a.saturating_add(del).min(chars.len()); - let out: String = chars[..a].iter().chain(ins.chars().collect::>().iter()).chain(chars[b..].iter()).collect(); - if out.len() > MAX_STRING { - return Err(STRING_TOO_LONG.into()); - } - Val::Str(out) - } - "replace" => { - let (s, old) = (self.arg_str(args, 0)?, self.arg_str(args, 1)?); - let new = if args.len() > 2 { self.arg_str(args, 2)? } else { String::new() }; - if old.is_empty() { - return Ok(Val::Str(s)); - } - let grown = s.matches(old.as_str()).count().saturating_mul(new.len()).saturating_add(s.len()); - if grown > MAX_STRING { - return Err(STRING_TOO_LONG.into()); - } - Val::Str(s.replace(&old, &new)) - } - "wordnum" => { - let v = self.arg_num(args, 0)?; - let kind = if args.len() > 1 { self.arg_num(args, 1)? } else { 0.0 }; - Val::Str(word_num(v, kind == 2.0)) - } - "uuid" => Val::Str(format!("{:032x}", (self.steps as u128).wrapping_mul(0x9E37_79B9_7F4A_7C15_F39C_C060_5CED_C835))), - "encode" | "decode" => { - let s = self.arg_str(args, 0)?; - let kind = if args.len() > 1 { self.arg_str(args, 1)?.to_ascii_lowercase() } else { "url".into() }; - Val::Str(match (lower.as_str(), kind.as_str()) { - ("encode", "html" | "xml") => s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """), - ("decode", "html" | "xml") => s.replace("<", "<").replace(">", ">").replace(""", "\"").replace("&", "&"), - ("encode", _) => s - .bytes() - .map(|b| if b.is_ascii_alphanumeric() || b"-_.~".contains(&b) { (b as char).to_string() } else { format!("%{b:02X}") }) - .collect(), - (_, _) => { - let mut out = Vec::new(); - let b = s.as_bytes(); - let mut i = 0; - while i < b.len() { - if b[i] == b'%' - && let Some(h) = b - .get(i + 1..i + 3) - .filter(|h| h.iter().all(u8::is_ascii_hexdigit)) - .and_then(|h| std::str::from_utf8(h).ok()) - .and_then(|h| u8::from_str_radix(h, 16).ok()) - { - out.push(h); - i += 3; - } else { - out.push(b[i]); - i += 1; - } - } - String::from_utf8_lossy(&out).into_owned() - } - }) - } - "format" => { - // Number pictures: `9` digit, `z` optional digit, `,` separator, `.` point, `$`. - let pic = self.arg_str(args, 0)?; - let v = self.arg(args, 1)?; - if pic.to_ascii_uppercase().contains("YY") || pic.to_ascii_uppercase().contains("DD") { - let text = self.to_str(&v); - let days = parse_num(&text).filter(|d| is_numeric_text(&text) && d.abs() < MAX_DAYS).map(|d| d as i64); - return Ok(match days { - Some(d) => { - let (y, m, dd) = civil_from_days(d); - Val::Str(format_date(y, m, dd, &pic)) - } - None => parse_date(&text, "YYYY-MM-DD").map_or(Val::Str(text), |(y, m, d)| Val::Str(format_date(y, m, d, &pic))), - }); - } - let num = self.to_num(&v); - let decimals = pic.split_once('.').map_or(0, |(_, f)| f.chars().filter(|c| matches!(c, '9' | 'z' | 'Z')).count()).min(20); - // `9` in the integer part forces a digit; `z` does not. - let min_int = pic.split('.').next().map_or(0, |i| i.chars().filter(|c| *c == '9').count()).min(20); - let width = min_int + if decimals > 0 { decimals + 1 } else { 0 }; - let grouped = pic.contains(','); - let mut text = format!("{:0>width$}", fixed(num.abs(), decimals)); - if grouped { - let (int, frac) = text.split_once('.').map_or((text.as_str(), ""), |(a, b)| (a, b)); - let mut g = String::new(); - for (k, c) in int.chars().enumerate() { - if k > 0 && (int.len() - k) % 3 == 0 { - g.push(','); - } - g.push(c); - } - text = if frac.is_empty() { g } else { format!("{g}.{frac}") }; - } - let nonzero = text.chars().any(|c| ('1'..='9').contains(&c)); - if pic.contains('$') { - text = format!("${text}"); - } - if num < 0.0 && nonzero { - text = format!("-{text}"); - } - Val::Str(text) - } - "parse" => { - let pic = self.arg_str(args, 0)?; - let s = self.arg_str(args, 1)?; - if pic.to_ascii_uppercase().contains("YY") { - // The canonical form of a date is ISO. - return Ok(parse_date(&s, &pic).map_or(Val::Null, |(y, m, d)| Val::Str(format_date(y, m, d, "YYYY-MM-DD")))); - } - let cleaned: String = s.chars().filter(|c| c.is_ascii_digit() || *c == '.' || *c == '-').collect(); - parse_num(&cleaned).map_or(Val::Null, Val::Num) - } - // ── dates and times (days since 1900-01-01 = 1; milliseconds since midnight) - "date" => { - #[cfg(not(target_arch = "wasm32"))] - { - let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or(0); - Val::Num((secs.div_euclid(86_400) + 25_568) as f64) - } - #[cfg(target_arch = "wasm32")] - { - Val::Num(25_568.0) - } - } - "time" => { - #[cfg(not(target_arch = "wasm32"))] - { - let ms = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as i64).unwrap_or(0); - Val::Num(ms.rem_euclid(86_400_000) as f64) - } - #[cfg(target_arch = "wasm32")] - { - Val::Num(0.0) - } - } - "date2num" | "isodate2num" => { - let s = self.arg_str(args, 0)?; - let pic = if lower == "isodate2num" || args.len() < 2 { "YYYY-MM-DD".to_string() } else { self.arg_str(args, 1)? }; - let parsed = parse_date(&s, &pic).or_else(|| parse_date(&s, "YYYYMMDD")).or_else(|| parse_date(&s, "YYYY-MM-DD")); - parsed.map_or(Val::Num(0.0), |(y, m, d)| Val::Num(days_from_civil(y, m, d) as f64)) - } - "num2date" => { - let days = self.arg_num(args, 0)?; - let pic = if args.len() > 1 { self.arg_str(args, 1)? } else { "MMM D, YYYY".into() }; - if !days.is_finite() || days.abs() >= MAX_DAYS { - return Ok(Val::Null); - } - let (y, m, d) = civil_from_days(days as i64); - Val::Str(format_date(y, m, d, &pic)) - } - "datefmt" | "localdatefmt" => { - let style = if args.is_empty() { 0.0 } else { self.arg_num(args, 0)? }; - Val::Str( - match style as i32 { - 1 => "M/D/YY", - 2 => "MMM D, YYYY", - 3 => "MMMM D, YYYY", - 4 => "EEEE, MMMM D, YYYY", - _ => "MMM D, YYYY", - } - .into(), - ) - } - "timefmt" | "localtimefmt" => Val::Str("HH:MM:SS".into()), - "num2time" | "num2gmtime" => { - let ms = self.arg_num(args, 0)?; - if !ms.is_finite() || ms < 0.0 { - return Ok(Val::Null); - } - let s = (ms / 1000.0) as u64; - Val::Str(format!("{:02}:{:02}:{:02}", (s / 3600) % 24, (s / 60) % 60, s % 60)) - } - "time2num" | "isotime2num" => { - let s = self.arg_str(args, 0)?; - // ISO 8601 allows a leading "T", a trailing "Z" and fractional seconds. - let t = s.trim(); - let t = t.strip_prefix(['T', 't']).unwrap_or(t); - let t = t.strip_suffix(['Z', 'z']).unwrap_or(t); - let mut parts = t.split(':'); - let mut whole = || parts.next().and_then(|p| p.trim().parse::().ok()).unwrap_or(0).min(1_000_000); - let (h, m) = (whole(), whole()); - let sec = - parts.next().and_then(|p| p.trim().parse::().ok()).filter(|v| v.is_finite() && *v >= 0.0).unwrap_or(0.0).min(1_000_000.0); - Val::Num(((h * 3600 + m * 60) * 1000) as f64 + (sec * 1000.0).round()) - } - // ── financial - "fv" => { - let (p, r, k) = (self.arg_num(args, 0)?, self.arg_num(args, 1)?, self.arg_num(args, 2)?); - Val::Num(if r == 0.0 { p * k } else { p * (((1.0 + r).powf(k) - 1.0) / r) }) - } - "pv" => { - let (p, r, k) = (self.arg_num(args, 0)?, self.arg_num(args, 1)?, self.arg_num(args, 2)?); - Val::Num(if r == 0.0 { p * k } else { p * ((1.0 - (1.0 + r).powf(-k)) / r) }) - } - "pmt" => { - let (pv, r, k) = (self.arg_num(args, 0)?, self.arg_num(args, 1)?, self.arg_num(args, 2)?); - if k <= 0.0 { - return Err("Pmt needs a positive number of periods".into()); - } - Val::Num(if r == 0.0 { pv / k } else { pv * r / (1.0 - (1.0 + r).powf(-k)) }) - } - "term" => { - let (p, r, fv) = (self.arg_num(args, 0)?, self.arg_num(args, 1)?, self.arg_num(args, 2)?); - if p <= 0.0 || r <= 0.0 || fv <= 0.0 { - return Err("Term needs positive arguments".into()); - } - Val::Num(((fv * r / p) + 1.0).ln() / (1.0 + r).ln()) - } - "cterm" => { - let (r, fv, pv) = (self.arg_num(args, 0)?, self.arg_num(args, 1)?, self.arg_num(args, 2)?); - if r <= 0.0 || fv <= 0.0 || pv <= 0.0 { - return Err("CTerm needs positive arguments".into()); - } - Val::Num((fv / pv).ln() / (1.0 + r).ln()) - } - "rate" => { - let (fv, pv, k) = (self.arg_num(args, 0)?, self.arg_num(args, 1)?, self.arg_num(args, 2)?); - if fv <= 0.0 || pv <= 0.0 || k <= 0.0 { - return Err("Rate needs positive arguments".into()); - } - Val::Num((fv / pv).powf(1.0 / k) - 1.0) - } - "npv" => { - let r = self.arg_num(args, 0)?; - let flows = self.arg_values(args.get(1..).unwrap_or_default())?; - Val::Num(flows.iter().enumerate().map(|(i, v)| self.to_num(v) / (1.0 + r).powi(i as i32 + 1)).sum()) - } - "ipmt" | "ppmt" => { - let (pv, r, pmt, first, count) = ( - self.arg_num(args, 0)?, - self.arg_num(args, 1)?, - self.arg_num(args, 2)?, - self.arg_num(args, 3)?.max(1.0), - self.arg_num(args, 4)?.max(1.0), - ); - let (mut balance, mut interest, mut principal) = (pv, 0.0, 0.0); - for period in 1..=((first + count - 1.0).min(10_000.0) as u32) { - self.step()?; - let i = balance * r; - let p = (pmt - i).min(balance); - if period >= first as u32 { - interest += i; - principal += p; - } - balance -= p; - if balance <= 0.0 { - break; - } - } - Val::Num(if lower == "ipmt" { interest } else { principal }) - } - "apr" => { - let (principal, payment, periods) = (self.arg_num(args, 0)?, self.arg_num(args, 1)?, self.arg_num(args, 2)?); - if principal <= 0.0 || payment <= 0.0 || periods <= 0.0 { - return Err("Apr needs positive arguments".into()); - } - // Bisection on the monthly rate. - let (mut lo, mut hi) = (0.0f64, 1.0f64); - for _ in 0..200 { - let mid = (lo + hi) / 2.0; - let pmt = if mid == 0.0 { principal / periods } else { principal * mid / (1.0 - (1.0 + mid).powf(-periods)) }; - if pmt > payment { - hi = mid; - } else { - lo = mid; - } - } - Val::Num((lo + hi) / 2.0 * 12.0) - } - // ── units and references - "unittype" => { - let s = self.arg_str(args, 0)?; - let unit: String = s.trim().chars().skip_while(|c| c.is_ascii_digit() || *c == '.' || *c == '-' || *c == '+').collect(); - Val::Str( - match unit.trim().to_ascii_lowercase().as_str() { - "in" | "inch" | "inches" => "in", - "mm" | "millimeter" | "millimeters" => "mm", - "cm" | "centimeter" | "centimeters" => "cm", - "pt" | "point" | "points" => "pt", - "pc" | "pica" => "pc", - _ => "in", - } - .into(), - ) - } - "unitvalue" => { - let s = self.arg_str(args, 0)?; - let to = if args.len() > 1 { self.arg_str(args, 1)? } else { String::new() }; - let v = parse_num(&s).unwrap_or(0.0); - let unit: String = s.trim().chars().skip_while(|c| c.is_ascii_digit() || *c == '.' || *c == '-' || *c == '+').collect(); - let per_in = |u: &str| match u.trim().to_ascii_lowercase().as_str() { - "mm" | "millimeter" | "millimeters" => 25.4, - "cm" | "centimeter" | "centimeters" => 2.54, - "pt" | "point" | "points" => 72.0, - "pc" | "pica" => 6.0, - _ => 1.0, - }; - let target = if to.trim().is_empty() { unit.clone() } else { to }; - Val::Num(v / per_in(&unit) * per_in(&target)) - } - "ref" => { - let Some(e) = args.first() else { return Ok(Val::Null) }; - self.eval(e)? - } - "eval" => { - let src = self.arg_str(args, 0)?; - self.charge((src.len() / 16) as u64)?; - let body = parse(&src)?; - let (_, v) = self.block(&body)?; - v - } - "get" | "post" | "put" => return Err(format!("{name}: network access is not available to form scripts")), - _ => return Err(format!("unknown function {name}")), - }) - } -} - // ── entry points ──────────────────────────────────────────────────────────────────────────── /// Run a FormCalc `script` for `event` on the form `root`, as [`crate::xfa::run_xfa`] runs a diff --git a/crates/js/src/formcalc/access.rs b/crates/js/src/formcalc/access.rs new file mode 100644 index 0000000000..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/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/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/platform/src/staging.rs b/crates/platform/src/staging.rs index 092aa65239..4316051352 100644 --- a/crates/platform/src/staging.rs +++ b/crates/platform/src/staging.rs @@ -292,455 +292,4 @@ fn sync_staged(f: &std::fs::File, sync_all: impl FnOnce(&std::fs::File) -> std:: } #[cfg(test)] -mod tests { - use super::*; - - /// A fresh, empty folder for one test. - fn test_dir(test: &str) -> PathBuf { - let dir = std::env::temp_dir().join(format!("pdfcraft-platform-staging-{}-{test}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - dir - } - - fn file_name(p: &Path) -> String { - p.file_name().unwrap().to_string_lossy().into_owned() - } - - /// The callers' tests plant files at these exact names; if a name changed, those tests would - /// plant where nothing writes and pass without testing anything. - #[test] - fn each_form_keeps_its_exact_name() { - let dir = test_dir("forms"); - let (tools, _) = create_staging(&dir, "out.pdf", StagingName::SuffixThenTag, [0xab]).unwrap(); - assert_eq!(file_name(&tools), ".out.pdf.00000000000000ab.pdfcraft-tmp"); - let (desktop, _) = create_staging(&dir, "out.pdf", StagingName::TagThenSuffix, [0xab]).unwrap(); - assert_eq!(file_name(&desktop), ".out.pdf.pdfcraft-00000000000000ab.tmp"); - assert_eq!(std::fs::read(&tools).unwrap(), b"", "a new, empty file"); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn a_long_name_is_cut_to_128_bytes_between_characters() { - let dir = test_dir("long"); - // One byte, then four-byte characters: the 128-byte limit falls inside the 32nd one. - let name = format!("a{}.pdf", "\u{1F600}".repeat(40)); - let (tmp, _) = create_staging(&dir, &name, StagingName::SuffixThenTag, [1]).unwrap(); - assert_eq!(file_name(&tmp), format!(".a{}.0000000000000001.pdfcraft-tmp", "\u{1F600}".repeat(31))); - // One-byte characters: exactly 128 of them are kept. - let (tmp, _) = create_staging(&dir, &"x".repeat(200), StagingName::SuffixThenTag, [2]).unwrap(); - assert_eq!(file_name(&tmp), format!(".{}.0000000000000002.pdfcraft-tmp", "x".repeat(128))); - let _ = std::fs::remove_dir_all(&dir); - } - - /// #2658: a read-only file is refused before anything is staged, on Unix (where a rename would - /// replace it) as on Windows (where the rename fails). - #[test] - fn a_read_only_target_is_refused_and_kept() { - let dir = test_dir("readonly"); - let target = dir.join("out.pdf"); - std::fs::write(&target, "OLD").unwrap(); - let mut perms = std::fs::metadata(&target).unwrap().permissions(); - perms.set_readonly(true); - std::fs::set_permissions(&target, perms.clone()).unwrap(); - let staged = std::cell::Cell::new(false); - let e = write_atomically_with(&target, b"NEW", StagingName::TagThenSuffix, [7], |_: &std::fs::File| { - staged.set(true); - Ok(()) - }) - .unwrap_err(); - assert_eq!(e.kind(), std::io::ErrorKind::PermissionDenied, "{e}"); - assert!(!staged.get(), "nothing was staged"); - assert_eq!(std::fs::read(&target).unwrap(), b"OLD"); - assert!(std::fs::metadata(&target).unwrap().permissions().readonly(), "still read-only"); - assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1, "no staging file is left"); - #[allow(clippy::permissions_set_readonly_false)] - perms.set_readonly(false); - std::fs::set_permissions(&target, perms).unwrap(); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn running_out_of_suffixes_refuses_and_creates_nothing() { - let dir = test_dir("empty"); - let e = create_staging(&dir, "out.pdf", StagingName::TagThenSuffix, []).unwrap_err(); - assert_eq!(e.kind(), std::io::ErrorKind::AlreadyExists); - assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn only_the_first_sixteen_names_are_tried() { - let dir = test_dir("cap"); - let name = |s: u64| dir.join(format!(".out.pdf.{s:016x}.pdfcraft-tmp")); - for s in 1..=STAGING_ATTEMPTS as u64 { - std::fs::write(name(s), "PLANTED").unwrap(); - } - assert!(create_staging(&dir, "out.pdf", StagingName::SuffixThenTag, 1..).is_err()); - assert!(!name(STAGING_ATTEMPTS as u64 + 1).exists(), "a 17th name was tried"); - let _ = std::fs::remove_dir_all(&dir); - } - - /// `write_atomically`: the target holds the new bytes only once they are complete; a refused - /// flush leaves the old file and removes the staging file; a missing folder writes nothing. - #[test] - fn an_atomic_write_replaces_the_target_only_when_complete() { - let dir = test_dir("write"); - let target = dir.join("out.pdf"); - std::fs::write(&target, b"OLD").unwrap(); - let names = |dir: &Path| { - let mut v: Vec = std::fs::read_dir(dir).unwrap().map(|e| file_name(&e.unwrap().path())).collect(); - v.sort(); - v - }; - // The staging file is written and flushed before the target changes. - let mut seen_old = false; - write_atomically_with(&target, b"NEW", StagingName::SuffixThenTag, [7], |f| { - seen_old = std::fs::read(&target).unwrap() == b"OLD" && f.metadata().unwrap().len() == 3; - Ok(()) - }) - .unwrap(); - assert!(seen_old, "flushed before publication, with the target still the old file"); - assert_eq!(std::fs::read(&target).unwrap(), b"NEW"); - assert_eq!(names(&dir), ["out.pdf"], "no staging file is left"); - // A refused flush keeps the target and cleans up. - let e = write_atomically_with(&target, b"NEWER", StagingName::TagThenSuffix, [8], |_| Err(std::io::Error::other("refused"))).unwrap_err(); - assert!(e.to_string().contains("refused")); - assert_eq!(std::fs::read(&target).unwrap(), b"NEW"); - assert_eq!(names(&dir), ["out.pdf"]); - // A folder that does not exist: nothing is written anywhere. - assert!(write_atomically(&dir.join("missing").join("out.pdf"), b"X", StagingName::SuffixThenTag).is_err()); - assert_eq!(names(&dir), ["out.pdf"]); - let _ = std::fs::remove_dir_all(&dir); - } - - #[cfg(target_os = "macos")] - #[test] - fn atomic_saves_keep_finder_tags_and_binary_extended_attributes() { - use rustix::fs::{XattrFlags, fgetxattr, setxattr}; - use std::os::macos::fs::FileTimesExt as _; - let dir = test_dir("xattrs"); - let target = dir.join("out.pdf"); - let attributes: &[(&str, &[u8])] = &[ - ("com.apple.metadata:_kMDItemUserTags", b"PdfCraft\n6"), - ("com.pdfcraft.test.binary", b"\0\xff\x80original\0"), - ("com.pdfcraft.test.empty", b""), - ]; - std::fs::write(&target, b"OLD").unwrap(); - for (name, value) in attributes { - setxattr(&target, *name, value, XattrFlags::empty()).unwrap(); - } - let created = std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_577_934_245); - let original = std::fs::OpenOptions::new().write(true).open(&target).unwrap(); - original.set_times(std::fs::FileTimes::new().set_created(created)).unwrap(); - assert_eq!(original.metadata().unwrap().created().unwrap(), created); - drop(original); - // Both desktop and automation name forms, including a second save of the replacement. - for form in [StagingName::TagThenSuffix, StagingName::SuffixThenTag] { - write_atomically_with(&target, b"NEW", form, [7], |staged| { - assert_eq!(staged.metadata().unwrap().created().unwrap(), created, "creation date must survive attribute copying before flush"); - for (name, value) in attributes { - let mut bytes = [0u8; 256]; - let len = fgetxattr(staged, *name, &mut bytes[..]).unwrap(); - assert_eq!(&bytes[..len], *value, "metadata must be present before flush: {name}"); - } - Ok(()) - }) - .unwrap(); - let saved = std::fs::File::open(&target).unwrap(); - assert_eq!(saved.metadata().unwrap().created().unwrap(), created); - for (name, value) in attributes { - let mut bytes = [0u8; 256]; - let len = fgetxattr(&saved, *name, &mut bytes[..]).unwrap(); - assert_eq!(&bytes[..len], *value); - } - assert_eq!(std::fs::read(&target).unwrap(), b"NEW"); - assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1); - } - std::fs::remove_dir_all(dir).unwrap(); - } - - #[cfg(target_os = "macos")] - #[test] - fn a_failed_flush_keeps_the_original_extended_attributes() { - use rustix::fs::{XattrFlags, getxattr, setxattr}; - let dir = test_dir("xattrs-flush"); - let target = dir.join("out.pdf"); - std::fs::write(&target, b"OLD").unwrap(); - setxattr(&target, "com.pdfcraft.test", b"original", XattrFlags::empty()).unwrap(); - let e = write_atomically_with(&target, b"NEW", StagingName::TagThenSuffix, [9], |_| Err(std::io::Error::other("flush refused"))).unwrap_err(); - assert!(e.to_string().contains("flush refused")); - assert_eq!(std::fs::read(&target).unwrap(), b"OLD"); - let mut value = [0u8; 64]; - let len = getxattr(&target, "com.pdfcraft.test", &mut value[..]).unwrap(); - assert_eq!(&value[..len], b"original"); - assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1); - std::fs::remove_dir_all(dir).unwrap(); - } - - /// Model an HFS-style partial read while using real macOS metadata and the atomic writer. - /// The shared read validator must reject growth before the staging file can be published. - #[cfg(target_os = "macos")] - #[test] - fn a_growing_resource_fork_refuses_publication_and_keeps_original() { - use rustix::fs::{XattrFlags, fgetxattr, fsetxattr, getxattr, setxattr}; - let dir = test_dir("xattrs-growing-fork"); - let target = dir.join("out.pdf"); - let name = "com.apple.ResourceFork"; - for form in [StagingName::TagThenSuffix, StagingName::SuffixThenTag] { - std::fs::write(&target, b"ORIGINAL PDF").unwrap(); - setxattr(&target, name, b"ABC", XattrFlags::empty()).unwrap(); - let source = std::fs::File::open(&target).unwrap(); - let result = write_atomically_with(&target, b"REPLACEMENT PDF", form, [23], |staged| { - let size = fgetxattr(&source, name, &mut [] as &mut [u8])?; - let value = read_xattr_value(size, |buffer| { - if buffer.is_empty() { - return Ok(fgetxattr(&source, name, buffer)?); - } - // HFS can return the prefix that fits instead of ERANGE. APFS need not - // have that behaviour: model the kernel response, not a timing race. - setxattr(&target, name, b"ABCD", XattrFlags::empty())?; - buffer.get_mut(..3).unwrap().copy_from_slice(b"ABC"); - Ok(3) - })?; - fsetxattr(staged, name, &value, XattrFlags::empty())?; - Ok(()) - }); - let error = result.expect_err("a short prefix of the grown fork must not be published"); - assert!(error.to_string().contains("changed size"), "{error}"); - assert_eq!(std::fs::read(&target).unwrap(), b"ORIGINAL PDF"); - let mut bytes = [0u8; 8]; - let len = getxattr(&target, name, &mut bytes[..]).unwrap(); - assert_eq!(&bytes[..len], b"ABCD", "the original's grown fork must remain intact"); - assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1, "staging files are removed on refusal"); - } - std::fs::remove_dir_all(dir).unwrap(); - } - - /// Stable empty/binary values are accepted; short reads, growth and failed rechecks are refused. - #[cfg(target_os = "macos")] - #[test] - fn attribute_reads_require_a_complete_value_and_stable_size() { - for bytes in [&b""[..], &b"\0\xffB"[..]] { - let value = read_xattr_value(bytes.len(), |buffer| { - if !buffer.is_empty() { - buffer.get_mut(..bytes.len()).unwrap().copy_from_slice(bytes); - } - Ok(bytes.len()) - }) - .unwrap(); - assert_eq!(value, bytes); - } - for (before, read_len, after) in [(3, 2, 3), (3, 3, 4), (0, 1, 1), (3, 2, 2)] { - let result = read_xattr_value(before, |buffer| Ok(if buffer.is_empty() { after } else { read_len })); - assert!(result.unwrap_err().to_string().contains("changed size")); - } - let result = read_xattr_value(3, |buffer| { - if buffer.is_empty() { Err(std::io::Error::new(std::io::ErrorKind::PermissionDenied, "size recheck refused")) } else { Ok(3) } - }); - assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::PermissionDenied); - } - - #[cfg(target_os = "macos")] - #[test] - fn oversized_extended_attributes_refuse_the_save_without_replacing_the_target() { - use rustix::fs::{XattrFlags, getxattr, setxattr}; - let dir = test_dir("xattrs-limit"); - let target = dir.join("out.pdf"); - std::fs::write(&target, b"OLD").unwrap(); - // A resource fork can exceed the ordinary attribute limit on macOS. No fixture file - // is committed; only the OS metadata of this temporary synthetic file holds the bytes. - let fork = vec![42u8; 16 * 1024 * 1024 + 1]; - setxattr(&target, "com.apple.ResourceFork", &fork, XattrFlags::empty()).unwrap(); - let mut flushed = false; - let e = write_atomically_with(&target, b"NEW", StagingName::TagThenSuffix, [10], |_| { - flushed = true; - Ok(()) - }) - .unwrap_err(); - assert!(e.to_string().contains("extended attributes"), "{e}"); - assert!(e.to_string().contains("Save As"), "{e}"); - assert!(!flushed, "metadata failure must stop before flush and rename"); - assert_eq!(std::fs::read(&target).unwrap(), b"OLD"); - let size = getxattr(&target, "com.apple.ResourceFork", &mut [] as &mut [u8]).unwrap(); - assert_eq!(size, fork.len()); - assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1); - std::fs::remove_dir_all(dir).unwrap(); - } - - #[cfg(target_os = "macos")] - #[test] - fn replacing_a_symlink_does_not_copy_attributes_from_its_referent() { - use rustix::fs::{XattrFlags, getxattr, setxattr}; - let dir = test_dir("xattrs-symlink"); - let outside = test_dir("xattrs-outside").join("private.pdf"); - std::fs::write(&outside, b"PRIVATE").unwrap(); - setxattr(&outside, "com.pdfcraft.test", b"private metadata", XattrFlags::empty()).unwrap(); - let target = dir.join("out.pdf"); - std::os::unix::fs::symlink(&outside, &target).unwrap(); - write_atomically(&target, b"NEW", StagingName::TagThenSuffix).unwrap(); - assert_eq!(std::fs::read(&outside).unwrap(), b"PRIVATE"); - assert_eq!(std::fs::read(&target).unwrap(), b"NEW"); - assert!(!target.symlink_metadata().unwrap().file_type().is_symlink()); - let mut bytes = [0u8; 64]; - assert_eq!(getxattr(&target, "com.pdfcraft.test", &mut bytes[..]).unwrap_err(), rustix::io::Errno::NOATTR); - let len = getxattr(&outside, "com.pdfcraft.test", &mut bytes[..]).unwrap(); - assert_eq!(&bytes[..len], b"private metadata"); - std::fs::remove_dir_all(dir).unwrap(); - std::fs::remove_dir_all(outside.parent().unwrap()).unwrap(); - } - - /// #1790: a replaced file keeps its permissions, and its staging file already has them while - /// it is written; a new file gets the same mode as any other new file. - #[cfg(unix)] - #[test] - fn a_replaced_file_keeps_its_permissions() { - use std::os::unix::fs::PermissionsExt as _; - let dir = test_dir("modes"); - let mode = |p: &Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777; - let target = dir.join("out.pdf"); - for keep in [0o600, 0o640, 0o664] { - std::fs::write(&target, b"OLD").unwrap(); - std::fs::set_permissions(&target, std::fs::Permissions::from_mode(keep)).unwrap(); - let mut staged = 0; - write_atomically_with(&target, b"NEW", StagingName::SuffixThenTag, [keep as u64], |f| { - staged = f.metadata()?.permissions().mode() & 0o777; - Ok(()) - }) - .unwrap(); - assert_eq!(staged, keep, "the staging file has the target's mode before it is flushed"); - assert_eq!(mode(&target), keep, "the saved file keeps mode {keep:o}"); - assert_eq!(std::fs::read(&target).unwrap(), b"NEW"); - } - let fresh = dir.join("new.pdf"); - write_atomically(&fresh, b"NEW", StagingName::TagThenSuffix).unwrap(); - let plain = dir.join("plain"); - std::fs::write(&plain, b"").unwrap(); - assert_eq!(mode(&fresh), mode(&plain), "a new file is not made private"); - let _ = std::fs::remove_dir_all(&dir); - } - - /// A replaced file keeps its group as well as its mode, so group permissions never reach a - /// group that couldn't read the file before (the saver's own group, by default). - #[cfg(target_os = "linux")] - #[test] - fn a_replaced_file_keeps_its_group() { - use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _}; - let dir = test_dir("group"); - let target = dir.join("out.pdf"); - std::fs::write(&target, b"OLD").unwrap(); - let created = std::fs::metadata(&target).unwrap().gid(); - let status = std::fs::read_to_string("/proc/self/status").unwrap(); - let groups = status.lines().find_map(|l| l.strip_prefix("Groups:")).unwrap_or_default(); - let Some(other) = groups.split_whitespace().filter_map(|g| g.parse::().ok()).find(|&g| g != created) else { - eprintln!("skipped: the test needs a second group"); - return; - }; - std::os::unix::fs::chown(&target, None, Some(other)).unwrap(); - std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o640)).unwrap(); - let mut staged = (0, 0); - write_atomically_with(&target, b"NEW", StagingName::SuffixThenTag, [7], |f| { - let m = f.metadata()?; - staged = (m.gid(), m.permissions().mode() & 0o777); - Ok(()) - }) - .unwrap(); - assert_eq!(staged, (other, 0o640), "the staging file has the target's group before it is flushed"); - let m = std::fs::metadata(&target).unwrap(); - assert_eq!((m.gid(), m.permissions().mode() & 0o777), (other, 0o640)); - let _ = std::fs::remove_dir_all(&dir); - } - - /// #2658: a save replaces the file, but the document is the same one: it keeps its creation - /// date where the system lets a program set it. - #[cfg(any(windows, target_os = "macos"))] - #[test] - fn a_replaced_file_keeps_its_creation_date() { - #[cfg(target_os = "macos")] - use std::os::macos::fs::FileTimesExt as _; - #[cfg(windows)] - use std::os::windows::fs::FileTimesExt as _; - let dir = test_dir("created"); - let target = dir.join("doc.pdf"); - std::fs::write(&target, b"OLD").unwrap(); - // 2020-01-02 03:04:05 UTC. - let then = std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_577_934_245); - let file = std::fs::OpenOptions::new().write(true).open(&target).unwrap(); - file.set_times(std::fs::FileTimes::new().set_created(then)).unwrap(); - drop(file); - assert_eq!(std::fs::metadata(&target).unwrap().created().unwrap(), then, "the fixture's date is set"); - write_atomically(&target, b"NEW", StagingName::SuffixThenTag).unwrap(); - assert_eq!(std::fs::read(&target).unwrap(), b"NEW"); - assert_eq!(std::fs::metadata(&target).unwrap().created().unwrap(), then, "the saved file keeps its creation date"); - // A new file gets a date of its own. - let fresh = dir.join("new.pdf"); - write_atomically(&fresh, b"NEW", StagingName::SuffixThenTag).unwrap(); - assert!(std::fs::metadata(&fresh).unwrap().created().unwrap() > then); - let _ = std::fs::remove_dir_all(&dir); - } - - /// #4593: a save replaces the file, so a hidden or system file stays hidden or system, and - /// its staging file already is while it is written; a plain file stays plain, though the - /// staging name starts with a dot; a new file gets no attribute from anywhere. - #[cfg(windows)] - #[test] - fn a_replaced_file_keeps_its_hidden_and_system_attributes() { - use std::os::windows::fs::{MetadataExt as _, OpenOptionsExt as _}; - const HIDDEN: u32 = 0x2; - const SYSTEM: u32 = 0x4; - let dir = test_dir("attributes"); - let kept = |p: &Path| std::fs::metadata(p).unwrap().file_attributes() & (HIDDEN | SYSTEM); - for (i, keep) in [0, HIDDEN, SYSTEM, HIDDEN | SYSTEM].into_iter().enumerate() { - let target = dir.join(format!("doc{i}.pdf")); - let mut fixture = std::fs::OpenOptions::new().write(true).create_new(true).attributes(keep).open(&target).unwrap(); - std::io::Write::write_all(&mut fixture, b"OLD").unwrap(); - drop(fixture); - assert_eq!(kept(&target), keep, "the fixture has attributes {keep:#x}"); - for form in [StagingName::TagThenSuffix, StagingName::SuffixThenTag] { - let mut staged = u32::MAX; - write_atomically_with(&target, b"NEW", form, [i as u64], |f| { - staged = f.metadata()?.file_attributes() & (HIDDEN | SYSTEM); - Ok(()) - }) - .unwrap(); - assert_eq!(staged, keep, "the staging file has the target's attributes before it is flushed"); - assert_eq!(kept(&target), keep, "the saved file keeps attributes {keep:#x}"); - assert_eq!(std::fs::read(&target).unwrap(), b"NEW"); - } - } - let fresh = dir.join("new.pdf"); - write_atomically(&fresh, b"NEW", StagingName::TagThenSuffix).unwrap(); - assert_eq!(kept(&fresh), 0, "a new file is neither hidden nor system"); - let _ = std::fs::remove_dir_all(&dir); - } - - /// A save replaces a symbolic link, not the file it points to, so the saved file takes the - /// link's own attributes: a plain link to a hidden file saves as a plain file, and the file - /// it pointed to is left as it was. - #[cfg(windows)] - #[test] - fn a_replaced_link_keeps_its_own_attributes_not_its_targets() { - use std::os::windows::fs::{MetadataExt as _, OpenOptionsExt as _}; - const HIDDEN: u32 = 0x2; - let dir = test_dir("link-attributes"); - let referent = dir.join("referent.pdf"); - let mut fixture = std::fs::OpenOptions::new().write(true).create_new(true).attributes(HIDDEN).open(&referent).unwrap(); - std::io::Write::write_all(&mut fixture, b"OLD").unwrap(); - drop(fixture); - let link = dir.join("link.pdf"); - if let Err(e) = std::os::windows::fs::symlink_file(&referent, &link) { - eprintln!("skipped: creating a symbolic link needs Developer Mode or administrator rights ({e})"); - let _ = std::fs::remove_dir_all(&dir); - return; - } - assert_eq!(std::fs::symlink_metadata(&link).unwrap().file_attributes() & HIDDEN, 0, "the link itself is visible"); - write_atomically(&link, b"NEW", StagingName::TagThenSuffix).unwrap(); - let saved = std::fs::symlink_metadata(&link).unwrap(); - assert!(!saved.file_type().is_symlink(), "the save replaced the link"); - assert_eq!(saved.file_attributes() & HIDDEN, 0, "the link was visible, so the saved file is"); - assert_eq!(std::fs::read(&link).unwrap(), b"NEW"); - assert_eq!(std::fs::read(&referent).unwrap(), b"OLD", "the file the link pointed to is untouched"); - assert_ne!(std::fs::metadata(&referent).unwrap().file_attributes() & HIDDEN, 0, "and still hidden"); - let _ = std::fs::remove_dir_all(&dir); - } -} +mod tests; diff --git a/crates/platform/src/staging/tests.rs b/crates/platform/src/staging/tests.rs new file mode 100644 index 0000000000..b5552a2c7b --- /dev/null +++ b/crates/platform/src/staging/tests.rs @@ -0,0 +1,450 @@ +use super::*; + +/// A fresh, empty folder for one test. +fn test_dir(test: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("pdfcraft-platform-staging-{}-{test}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +fn file_name(p: &Path) -> String { + p.file_name().unwrap().to_string_lossy().into_owned() +} + +/// The callers' tests plant files at these exact names; if a name changed, those tests would +/// plant where nothing writes and pass without testing anything. +#[test] +fn each_form_keeps_its_exact_name() { + let dir = test_dir("forms"); + let (tools, _) = create_staging(&dir, "out.pdf", StagingName::SuffixThenTag, [0xab]).unwrap(); + assert_eq!(file_name(&tools), ".out.pdf.00000000000000ab.pdfcraft-tmp"); + let (desktop, _) = create_staging(&dir, "out.pdf", StagingName::TagThenSuffix, [0xab]).unwrap(); + assert_eq!(file_name(&desktop), ".out.pdf.pdfcraft-00000000000000ab.tmp"); + assert_eq!(std::fs::read(&tools).unwrap(), b"", "a new, empty file"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn a_long_name_is_cut_to_128_bytes_between_characters() { + let dir = test_dir("long"); + // One byte, then four-byte characters: the 128-byte limit falls inside the 32nd one. + let name = format!("a{}.pdf", "\u{1F600}".repeat(40)); + let (tmp, _) = create_staging(&dir, &name, StagingName::SuffixThenTag, [1]).unwrap(); + assert_eq!(file_name(&tmp), format!(".a{}.0000000000000001.pdfcraft-tmp", "\u{1F600}".repeat(31))); + // One-byte characters: exactly 128 of them are kept. + let (tmp, _) = create_staging(&dir, &"x".repeat(200), StagingName::SuffixThenTag, [2]).unwrap(); + assert_eq!(file_name(&tmp), format!(".{}.0000000000000002.pdfcraft-tmp", "x".repeat(128))); + let _ = std::fs::remove_dir_all(&dir); +} + +/// #2658: a read-only file is refused before anything is staged, on Unix (where a rename would +/// replace it) as on Windows (where the rename fails). +#[test] +fn a_read_only_target_is_refused_and_kept() { + let dir = test_dir("readonly"); + let target = dir.join("out.pdf"); + std::fs::write(&target, "OLD").unwrap(); + let mut perms = std::fs::metadata(&target).unwrap().permissions(); + perms.set_readonly(true); + std::fs::set_permissions(&target, perms.clone()).unwrap(); + let staged = std::cell::Cell::new(false); + let e = write_atomically_with(&target, b"NEW", StagingName::TagThenSuffix, [7], |_: &std::fs::File| { + staged.set(true); + Ok(()) + }) + .unwrap_err(); + assert_eq!(e.kind(), std::io::ErrorKind::PermissionDenied, "{e}"); + assert!(!staged.get(), "nothing was staged"); + assert_eq!(std::fs::read(&target).unwrap(), b"OLD"); + assert!(std::fs::metadata(&target).unwrap().permissions().readonly(), "still read-only"); + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1, "no staging file is left"); + #[allow(clippy::permissions_set_readonly_false)] + perms.set_readonly(false); + std::fs::set_permissions(&target, perms).unwrap(); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn running_out_of_suffixes_refuses_and_creates_nothing() { + let dir = test_dir("empty"); + let e = create_staging(&dir, "out.pdf", StagingName::TagThenSuffix, []).unwrap_err(); + assert_eq!(e.kind(), std::io::ErrorKind::AlreadyExists); + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn only_the_first_sixteen_names_are_tried() { + let dir = test_dir("cap"); + let name = |s: u64| dir.join(format!(".out.pdf.{s:016x}.pdfcraft-tmp")); + for s in 1..=STAGING_ATTEMPTS as u64 { + std::fs::write(name(s), "PLANTED").unwrap(); + } + assert!(create_staging(&dir, "out.pdf", StagingName::SuffixThenTag, 1..).is_err()); + assert!(!name(STAGING_ATTEMPTS as u64 + 1).exists(), "a 17th name was tried"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// `write_atomically`: the target holds the new bytes only once they are complete; a refused +/// flush leaves the old file and removes the staging file; a missing folder writes nothing. +#[test] +fn an_atomic_write_replaces_the_target_only_when_complete() { + let dir = test_dir("write"); + let target = dir.join("out.pdf"); + std::fs::write(&target, b"OLD").unwrap(); + let names = |dir: &Path| { + let mut v: Vec = std::fs::read_dir(dir).unwrap().map(|e| file_name(&e.unwrap().path())).collect(); + v.sort(); + v + }; + // The staging file is written and flushed before the target changes. + let mut seen_old = false; + write_atomically_with(&target, b"NEW", StagingName::SuffixThenTag, [7], |f| { + seen_old = std::fs::read(&target).unwrap() == b"OLD" && f.metadata().unwrap().len() == 3; + Ok(()) + }) + .unwrap(); + assert!(seen_old, "flushed before publication, with the target still the old file"); + assert_eq!(std::fs::read(&target).unwrap(), b"NEW"); + assert_eq!(names(&dir), ["out.pdf"], "no staging file is left"); + // A refused flush keeps the target and cleans up. + let e = write_atomically_with(&target, b"NEWER", StagingName::TagThenSuffix, [8], |_| Err(std::io::Error::other("refused"))).unwrap_err(); + assert!(e.to_string().contains("refused")); + assert_eq!(std::fs::read(&target).unwrap(), b"NEW"); + assert_eq!(names(&dir), ["out.pdf"]); + // A folder that does not exist: nothing is written anywhere. + assert!(write_atomically(&dir.join("missing").join("out.pdf"), b"X", StagingName::SuffixThenTag).is_err()); + assert_eq!(names(&dir), ["out.pdf"]); + let _ = std::fs::remove_dir_all(&dir); +} + +#[cfg(target_os = "macos")] +#[test] +fn atomic_saves_keep_finder_tags_and_binary_extended_attributes() { + use rustix::fs::{XattrFlags, fgetxattr, setxattr}; + use std::os::macos::fs::FileTimesExt as _; + let dir = test_dir("xattrs"); + let target = dir.join("out.pdf"); + let attributes: &[(&str, &[u8])] = &[ + ("com.apple.metadata:_kMDItemUserTags", b"PdfCraft\n6"), + ("com.pdfcraft.test.binary", b"\0\xff\x80original\0"), + ("com.pdfcraft.test.empty", b""), + ]; + std::fs::write(&target, b"OLD").unwrap(); + for (name, value) in attributes { + setxattr(&target, *name, value, XattrFlags::empty()).unwrap(); + } + let created = std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_577_934_245); + let original = std::fs::OpenOptions::new().write(true).open(&target).unwrap(); + original.set_times(std::fs::FileTimes::new().set_created(created)).unwrap(); + assert_eq!(original.metadata().unwrap().created().unwrap(), created); + drop(original); + // Both desktop and automation name forms, including a second save of the replacement. + for form in [StagingName::TagThenSuffix, StagingName::SuffixThenTag] { + write_atomically_with(&target, b"NEW", form, [7], |staged| { + assert_eq!(staged.metadata().unwrap().created().unwrap(), created, "creation date must survive attribute copying before flush"); + for (name, value) in attributes { + let mut bytes = [0u8; 256]; + let len = fgetxattr(staged, *name, &mut bytes[..]).unwrap(); + assert_eq!(&bytes[..len], *value, "metadata must be present before flush: {name}"); + } + Ok(()) + }) + .unwrap(); + let saved = std::fs::File::open(&target).unwrap(); + assert_eq!(saved.metadata().unwrap().created().unwrap(), created); + for (name, value) in attributes { + let mut bytes = [0u8; 256]; + let len = fgetxattr(&saved, *name, &mut bytes[..]).unwrap(); + assert_eq!(&bytes[..len], *value); + } + assert_eq!(std::fs::read(&target).unwrap(), b"NEW"); + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1); + } + std::fs::remove_dir_all(dir).unwrap(); +} + +#[cfg(target_os = "macos")] +#[test] +fn a_failed_flush_keeps_the_original_extended_attributes() { + use rustix::fs::{XattrFlags, getxattr, setxattr}; + let dir = test_dir("xattrs-flush"); + let target = dir.join("out.pdf"); + std::fs::write(&target, b"OLD").unwrap(); + setxattr(&target, "com.pdfcraft.test", b"original", XattrFlags::empty()).unwrap(); + let e = write_atomically_with(&target, b"NEW", StagingName::TagThenSuffix, [9], |_| Err(std::io::Error::other("flush refused"))).unwrap_err(); + assert!(e.to_string().contains("flush refused")); + assert_eq!(std::fs::read(&target).unwrap(), b"OLD"); + let mut value = [0u8; 64]; + let len = getxattr(&target, "com.pdfcraft.test", &mut value[..]).unwrap(); + assert_eq!(&value[..len], b"original"); + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1); + std::fs::remove_dir_all(dir).unwrap(); +} + +/// Model an HFS-style partial read while using real macOS metadata and the atomic writer. +/// The shared read validator must reject growth before the staging file can be published. +#[cfg(target_os = "macos")] +#[test] +fn a_growing_resource_fork_refuses_publication_and_keeps_original() { + use rustix::fs::{XattrFlags, fgetxattr, fsetxattr, getxattr, setxattr}; + let dir = test_dir("xattrs-growing-fork"); + let target = dir.join("out.pdf"); + let name = "com.apple.ResourceFork"; + for form in [StagingName::TagThenSuffix, StagingName::SuffixThenTag] { + std::fs::write(&target, b"ORIGINAL PDF").unwrap(); + setxattr(&target, name, b"ABC", XattrFlags::empty()).unwrap(); + let source = std::fs::File::open(&target).unwrap(); + let result = write_atomically_with(&target, b"REPLACEMENT PDF", form, [23], |staged| { + let size = fgetxattr(&source, name, &mut [] as &mut [u8])?; + let value = read_xattr_value(size, |buffer| { + if buffer.is_empty() { + return Ok(fgetxattr(&source, name, buffer)?); + } + // HFS can return the prefix that fits instead of ERANGE. APFS need not + // have that behaviour: model the kernel response, not a timing race. + setxattr(&target, name, b"ABCD", XattrFlags::empty())?; + buffer.get_mut(..3).unwrap().copy_from_slice(b"ABC"); + Ok(3) + })?; + fsetxattr(staged, name, &value, XattrFlags::empty())?; + Ok(()) + }); + let error = result.expect_err("a short prefix of the grown fork must not be published"); + assert!(error.to_string().contains("changed size"), "{error}"); + assert_eq!(std::fs::read(&target).unwrap(), b"ORIGINAL PDF"); + let mut bytes = [0u8; 8]; + let len = getxattr(&target, name, &mut bytes[..]).unwrap(); + assert_eq!(&bytes[..len], b"ABCD", "the original's grown fork must remain intact"); + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1, "staging files are removed on refusal"); + } + std::fs::remove_dir_all(dir).unwrap(); +} + +/// Stable empty/binary values are accepted; short reads, growth and failed rechecks are refused. +#[cfg(target_os = "macos")] +#[test] +fn attribute_reads_require_a_complete_value_and_stable_size() { + for bytes in [&b""[..], &b"\0\xffB"[..]] { + let value = read_xattr_value(bytes.len(), |buffer| { + if !buffer.is_empty() { + buffer.get_mut(..bytes.len()).unwrap().copy_from_slice(bytes); + } + Ok(bytes.len()) + }) + .unwrap(); + assert_eq!(value, bytes); + } + for (before, read_len, after) in [(3, 2, 3), (3, 3, 4), (0, 1, 1), (3, 2, 2)] { + let result = read_xattr_value(before, |buffer| Ok(if buffer.is_empty() { after } else { read_len })); + assert!(result.unwrap_err().to_string().contains("changed size")); + } + let result = read_xattr_value(3, |buffer| { + if buffer.is_empty() { Err(std::io::Error::new(std::io::ErrorKind::PermissionDenied, "size recheck refused")) } else { Ok(3) } + }); + assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::PermissionDenied); +} + +#[cfg(target_os = "macos")] +#[test] +fn oversized_extended_attributes_refuse_the_save_without_replacing_the_target() { + use rustix::fs::{XattrFlags, getxattr, setxattr}; + let dir = test_dir("xattrs-limit"); + let target = dir.join("out.pdf"); + std::fs::write(&target, b"OLD").unwrap(); + // A resource fork can exceed the ordinary attribute limit on macOS. No fixture file + // is committed; only the OS metadata of this temporary synthetic file holds the bytes. + let fork = vec![42u8; 16 * 1024 * 1024 + 1]; + setxattr(&target, "com.apple.ResourceFork", &fork, XattrFlags::empty()).unwrap(); + let mut flushed = false; + let e = write_atomically_with(&target, b"NEW", StagingName::TagThenSuffix, [10], |_| { + flushed = true; + Ok(()) + }) + .unwrap_err(); + assert!(e.to_string().contains("extended attributes"), "{e}"); + assert!(e.to_string().contains("Save As"), "{e}"); + assert!(!flushed, "metadata failure must stop before flush and rename"); + assert_eq!(std::fs::read(&target).unwrap(), b"OLD"); + let size = getxattr(&target, "com.apple.ResourceFork", &mut [] as &mut [u8]).unwrap(); + assert_eq!(size, fork.len()); + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1); + std::fs::remove_dir_all(dir).unwrap(); +} + +#[cfg(target_os = "macos")] +#[test] +fn replacing_a_symlink_does_not_copy_attributes_from_its_referent() { + use rustix::fs::{XattrFlags, getxattr, setxattr}; + let dir = test_dir("xattrs-symlink"); + let outside = test_dir("xattrs-outside").join("private.pdf"); + std::fs::write(&outside, b"PRIVATE").unwrap(); + setxattr(&outside, "com.pdfcraft.test", b"private metadata", XattrFlags::empty()).unwrap(); + let target = dir.join("out.pdf"); + std::os::unix::fs::symlink(&outside, &target).unwrap(); + write_atomically(&target, b"NEW", StagingName::TagThenSuffix).unwrap(); + assert_eq!(std::fs::read(&outside).unwrap(), b"PRIVATE"); + assert_eq!(std::fs::read(&target).unwrap(), b"NEW"); + assert!(!target.symlink_metadata().unwrap().file_type().is_symlink()); + let mut bytes = [0u8; 64]; + assert_eq!(getxattr(&target, "com.pdfcraft.test", &mut bytes[..]).unwrap_err(), rustix::io::Errno::NOATTR); + let len = getxattr(&outside, "com.pdfcraft.test", &mut bytes[..]).unwrap(); + assert_eq!(&bytes[..len], b"private metadata"); + std::fs::remove_dir_all(dir).unwrap(); + std::fs::remove_dir_all(outside.parent().unwrap()).unwrap(); +} + +/// #1790: a replaced file keeps its permissions, and its staging file already has them while +/// it is written; a new file gets the same mode as any other new file. +#[cfg(unix)] +#[test] +fn a_replaced_file_keeps_its_permissions() { + use std::os::unix::fs::PermissionsExt as _; + let dir = test_dir("modes"); + let mode = |p: &Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777; + let target = dir.join("out.pdf"); + for keep in [0o600, 0o640, 0o664] { + std::fs::write(&target, b"OLD").unwrap(); + std::fs::set_permissions(&target, std::fs::Permissions::from_mode(keep)).unwrap(); + let mut staged = 0; + write_atomically_with(&target, b"NEW", StagingName::SuffixThenTag, [keep as u64], |f| { + staged = f.metadata()?.permissions().mode() & 0o777; + Ok(()) + }) + .unwrap(); + assert_eq!(staged, keep, "the staging file has the target's mode before it is flushed"); + assert_eq!(mode(&target), keep, "the saved file keeps mode {keep:o}"); + assert_eq!(std::fs::read(&target).unwrap(), b"NEW"); + } + let fresh = dir.join("new.pdf"); + write_atomically(&fresh, b"NEW", StagingName::TagThenSuffix).unwrap(); + let plain = dir.join("plain"); + std::fs::write(&plain, b"").unwrap(); + assert_eq!(mode(&fresh), mode(&plain), "a new file is not made private"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A replaced file keeps its group as well as its mode, so group permissions never reach a +/// group that couldn't read the file before (the saver's own group, by default). +#[cfg(target_os = "linux")] +#[test] +fn a_replaced_file_keeps_its_group() { + use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _}; + let dir = test_dir("group"); + let target = dir.join("out.pdf"); + std::fs::write(&target, b"OLD").unwrap(); + let created = std::fs::metadata(&target).unwrap().gid(); + let status = std::fs::read_to_string("/proc/self/status").unwrap(); + let groups = status.lines().find_map(|l| l.strip_prefix("Groups:")).unwrap_or_default(); + let Some(other) = groups.split_whitespace().filter_map(|g| g.parse::().ok()).find(|&g| g != created) else { + eprintln!("skipped: the test needs a second group"); + return; + }; + std::os::unix::fs::chown(&target, None, Some(other)).unwrap(); + std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o640)).unwrap(); + let mut staged = (0, 0); + write_atomically_with(&target, b"NEW", StagingName::SuffixThenTag, [7], |f| { + let m = f.metadata()?; + staged = (m.gid(), m.permissions().mode() & 0o777); + Ok(()) + }) + .unwrap(); + assert_eq!(staged, (other, 0o640), "the staging file has the target's group before it is flushed"); + let m = std::fs::metadata(&target).unwrap(); + assert_eq!((m.gid(), m.permissions().mode() & 0o777), (other, 0o640)); + let _ = std::fs::remove_dir_all(&dir); +} + +/// #2658: a save replaces the file, but the document is the same one: it keeps its creation +/// date where the system lets a program set it. +#[cfg(any(windows, target_os = "macos"))] +#[test] +fn a_replaced_file_keeps_its_creation_date() { + #[cfg(target_os = "macos")] + use std::os::macos::fs::FileTimesExt as _; + #[cfg(windows)] + use std::os::windows::fs::FileTimesExt as _; + let dir = test_dir("created"); + let target = dir.join("doc.pdf"); + std::fs::write(&target, b"OLD").unwrap(); + // 2020-01-02 03:04:05 UTC. + let then = std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_577_934_245); + let file = std::fs::OpenOptions::new().write(true).open(&target).unwrap(); + file.set_times(std::fs::FileTimes::new().set_created(then)).unwrap(); + drop(file); + assert_eq!(std::fs::metadata(&target).unwrap().created().unwrap(), then, "the fixture's date is set"); + write_atomically(&target, b"NEW", StagingName::SuffixThenTag).unwrap(); + assert_eq!(std::fs::read(&target).unwrap(), b"NEW"); + assert_eq!(std::fs::metadata(&target).unwrap().created().unwrap(), then, "the saved file keeps its creation date"); + // A new file gets a date of its own. + let fresh = dir.join("new.pdf"); + write_atomically(&fresh, b"NEW", StagingName::SuffixThenTag).unwrap(); + assert!(std::fs::metadata(&fresh).unwrap().created().unwrap() > then); + let _ = std::fs::remove_dir_all(&dir); +} + +/// #4593: a save replaces the file, so a hidden or system file stays hidden or system, and +/// its staging file already is while it is written; a plain file stays plain, though the +/// staging name starts with a dot; a new file gets no attribute from anywhere. +#[cfg(windows)] +#[test] +fn a_replaced_file_keeps_its_hidden_and_system_attributes() { + use std::os::windows::fs::{MetadataExt as _, OpenOptionsExt as _}; + const HIDDEN: u32 = 0x2; + const SYSTEM: u32 = 0x4; + let dir = test_dir("attributes"); + let kept = |p: &Path| std::fs::metadata(p).unwrap().file_attributes() & (HIDDEN | SYSTEM); + for (i, keep) in [0, HIDDEN, SYSTEM, HIDDEN | SYSTEM].into_iter().enumerate() { + let target = dir.join(format!("doc{i}.pdf")); + let mut fixture = std::fs::OpenOptions::new().write(true).create_new(true).attributes(keep).open(&target).unwrap(); + std::io::Write::write_all(&mut fixture, b"OLD").unwrap(); + drop(fixture); + assert_eq!(kept(&target), keep, "the fixture has attributes {keep:#x}"); + for form in [StagingName::TagThenSuffix, StagingName::SuffixThenTag] { + let mut staged = u32::MAX; + write_atomically_with(&target, b"NEW", form, [i as u64], |f| { + staged = f.metadata()?.file_attributes() & (HIDDEN | SYSTEM); + Ok(()) + }) + .unwrap(); + assert_eq!(staged, keep, "the staging file has the target's attributes before it is flushed"); + assert_eq!(kept(&target), keep, "the saved file keeps attributes {keep:#x}"); + assert_eq!(std::fs::read(&target).unwrap(), b"NEW"); + } + } + let fresh = dir.join("new.pdf"); + write_atomically(&fresh, b"NEW", StagingName::TagThenSuffix).unwrap(); + assert_eq!(kept(&fresh), 0, "a new file is neither hidden nor system"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A save replaces a symbolic link, not the file it points to, so the saved file takes the +/// link's own attributes: a plain link to a hidden file saves as a plain file, and the file +/// it pointed to is left as it was. +#[cfg(windows)] +#[test] +fn a_replaced_link_keeps_its_own_attributes_not_its_targets() { + use std::os::windows::fs::{MetadataExt as _, OpenOptionsExt as _}; + const HIDDEN: u32 = 0x2; + let dir = test_dir("link-attributes"); + let referent = dir.join("referent.pdf"); + let mut fixture = std::fs::OpenOptions::new().write(true).create_new(true).attributes(HIDDEN).open(&referent).unwrap(); + std::io::Write::write_all(&mut fixture, b"OLD").unwrap(); + drop(fixture); + let link = dir.join("link.pdf"); + if let Err(e) = std::os::windows::fs::symlink_file(&referent, &link) { + eprintln!("skipped: creating a symbolic link needs Developer Mode or administrator rights ({e})"); + let _ = std::fs::remove_dir_all(&dir); + return; + } + assert_eq!(std::fs::symlink_metadata(&link).unwrap().file_attributes() & HIDDEN, 0, "the link itself is visible"); + write_atomically(&link, b"NEW", StagingName::TagThenSuffix).unwrap(); + let saved = std::fs::symlink_metadata(&link).unwrap(); + assert!(!saved.file_type().is_symlink(), "the save replaced the link"); + assert_eq!(saved.file_attributes() & HIDDEN, 0, "the link was visible, so the saved file is"); + assert_eq!(std::fs::read(&link).unwrap(), b"NEW"); + assert_eq!(std::fs::read(&referent).unwrap(), b"OLD", "the file the link pointed to is untouched"); + assert_ne!(std::fs::metadata(&referent).unwrap().file_attributes() & HIDDEN, 0, "and still hidden"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/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/redact/src/interp.rs b/crates/redact/src/interp.rs index bd9da5215f..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, std::slice::from_ref(&data), xo, name, depth + 1, seen) { - Some(false) => {} - other => return other, - } - } - Some(false) -} - -/// Unlink the XObjects a rewrite dropped or replaced (`doomed`) from every page-tree resource -/// dictionary that still names them. The rewritten page has its own pruned /Resources, but the -/// originals stay reachable, and so stay in a full-rewrite save, when: -/// - the page inherited its /Resources from a /Pages node, which still lists them; -/// - several pages share one resource dictionary, and another page still refers to it; -/// - a form without /Resources drew them through the page's dictionary. -/// -/// An entry goes unless a page that draws through that dictionary still draws the name (the same -/// image shown on another page, or again outside a mark). A dictionary that loses an entry is -/// replaced by a private copy on its node, so a dictionary object shared with other nodes is -/// left as it is for them. Retired properties are likewise unlinked only when none of the -/// holder's pages or inheriting forms names them. Fails closed when use cannot be determined. -pub(crate) fn unlink_doomed(doc: &mut Document, doomed: &[ObjRef], retired_properties: &[Object]) -> Result<(), RedactError> { - if doomed.is_empty() && retired_properties.is_empty() { - return Ok(()); - } - let doomed: HashSet = doomed.iter().copied().collect(); - let Some(top) = doc.root().and_then(|root| doc.get(root).as_dict().and_then(|d| d.reference(b"Pages"))) else { return Ok(()) }; - // Every page-tree node with a /Resources of its own, and the pages that draw through it - // (the nearest such node above a page, or the page itself). Cycles are cut as in - // `pdfcraft_model::pages`. - let mut holders: Vec<(ObjRef, Vec)> = Vec::new(); - let mut seen = HashSet::new(); - let mut stack: Vec<(ObjRef, Option)> = vec![(top, None)]; - while let Some((node, mut holder)) = stack.pop() { - if !seen.insert(node) || seen.len() > 1_000_000 { - continue; - } - let obj = doc.get(node); - let Some(d) = obj.as_dict() else { continue }; - if d.contains(b"Resources") { - holders.push((node, Vec::new())); - holder = Some(holders.len() - 1); - } - let is_pages = d.name(b"Type") == Some(b"Pages") || (d.contains(b"Kids") && d.name(b"Type") != Some(b"Page")); - if is_pages { - let kids = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_default(); - stack.extend(kids.iter().filter_map(Object::as_ref).map(|k| (k, holder))); - } else if let Some(h) = holder.and_then(|i| holders.get_mut(i)) { - h.1.push(node); - } - } - let index: HashMap = pdfcraft_model::pages(doc).iter().enumerate().map(|(i, p)| (p.obj, i)).collect(); - let holder_nodes: Vec = holders.iter().map(|(node, _)| *node).collect(); - for (node, users) in holders { - let Some(mut res) = doc.get(node).as_dict().and_then(|d| d.get(b"Resources")).and_then(|r| doc.resolve(r).as_dict().cloned()) else { - continue; - }; - let mut xo = res_dict(doc, &res, b"XObject"); - let names: Vec> = xo.iter().filter(|(_, v)| v.as_ref().is_some_and(|r| doomed.contains(&r))).map(|(n, _)| n.to_vec()).collect(); - let properties = res_dict(doc, &res, b"Properties"); - let property_names: Vec> = properties.iter().filter(|(_, v)| retired_properties.contains(v)).map(|(n, _)| n.to_vec()).collect(); - if names.is_empty() && property_names.is_empty() { - continue; - } - let mut contents = Vec::with_capacity(users.len()); - for user in &users { - let page = doc.get(*user).as_dict().cloned().unwrap_or_default(); - // A page whose content cannot be read may still draw the name: fail closed. - contents.push(page_streams(doc, &page, index.get(user).copied().unwrap_or(0))?.1); - } - let mut pruned = false; - for n in names { - let mut drawn = Some(false); - for content in &contents { - drawn = content_draws(doc, content, &xo, &n, 0, &mut HashSet::new()); - if drawn != Some(false) { - break; - } - } - match drawn { - Some(true) => {} - Some(false) => { - xo.remove(&n); - pruned = true; - } - None => return Err(RedactError::Residue(1)), - } - } - if pruned { - res.set(b"XObject".to_vec(), Object::Dict(xo)); - } - pruned |= prune_retired_properties(doc, &mut res, &property_names, &contents)?; - if pruned { - doc.update_dict(node, |d| d.set(b"Resources".to_vec(), Object::Dict(res)))?; - } - } - // A surviving form can own the same old resource dictionary as a rewritten page. Its - // unused descriptions 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, @@ -1359,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)); @@ -1381,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 bdf1db873e..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 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)) } diff --git a/crates/redact/src/tests/surviving_references.rs b/crates/redact/src/tests/surviving_references.rs index a297ded2a4..477f03f9a5 100644 --- a/crates/redact/src/tests/surviving_references.rs +++ b/crates/redact/src/tests/surviving_references.rs @@ -105,3 +105,196 @@ fn an_image_another_page_draws_through_a_form_without_resources_is_kept() { 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 71b672e547..0cf287b3f1 100644 --- a/crates/render/src/inspect.rs +++ b/crates/render/src/inspect.rs @@ -23,60 +23,27 @@ use pdfcraft_cos::page_labels::{ use crate::OpenError; use crate::structure::{LazyStructure, Structure}; -/// lopdf decodes object and cross-reference streams while it loads, with no limit unless one is -/// set: a few hundred bytes of nested FlateDecode then inflate to gigabytes. Real object and -/// xref streams are far below this. -const LOAD_STREAM_LIMIT: usize = 256 << 20; +mod annotations; +mod core; +mod model; +mod navigation; +mod resources; -/// At most this many `/State` entries of a set-layer-visibility action are read. -const MAX_LAYER_STATE: usize = 1024; - -/// Decoded bytes read from an XFA form's configuration packet (or a whole-XDP `/XFA` stream) to -/// find `dynamicRender`. Designer's packets are a few kilobytes; a whole XDP can hold the template. -const MAX_XFA_CONFIG_BYTES: usize = 16 << 20; - -/// File specifications retained from a remote-Go-To action are deliberately small. -const MAX_REMOTE_FILE_SPEC_BYTES: usize = 4096; -const MAX_REMOTE_DESTINATION_NAME_BYTES: usize = 1024; - -/// Text and geometry `DocInfo::annotations` keeps, as counted by [`retained`]. Past it the rest -/// are not listed: real comments stay far below, many small ones sharing one long string do not. -const MAX_ANNOTATION_BYTES: usize = 64 << 20; - -/// Web-address text the link listing, and name, description and embedded-file key text the -/// attachment listing, keep together. Many links or attachments can share one long string, which -/// each would otherwise copy. Past it, a link to a web address lists as a URI action without its -/// address, an attachment under its generic name without a description, and an embedded file whose -/// key does not fit is not listed. -const MAX_LINK_ATTACHMENT_TEXT_BYTES: usize = 16 << 20; - -/// The bytes a listed annotation keeps. -fn retained(a: &Annotation) -> usize { - let text = [&a.author, &a.contents, &a.modified, &a.name, &a.in_reply_to, &a.state, &a.intent]; - text.into_iter() - .map(|t| t.as_ref().map_or(0, String::len)) - .chain([ - a.subtype.len(), - std::mem::size_of_val(a.quads.as_slice()), - std::mem::size_of_val(a.points.as_slice()), - std::mem::size_of::(), - ]) - .fold(0, usize::saturating_add) -} - -/// At most this many layers are read from all of `/RBGroups` together. -const MAX_LAYER_GROUP_ENTRIES: usize = 4096; - -/// `DocInfo::outline` holds the first this many bookmarks in outline order. Opening a document -/// reads only this window; the rest are listed by page through `pdfcraft_organize`. -const OUTLINE_WINDOW: usize = 10_000; - -/// Deepest level `DocInfo::outline` nests (the top level is 0); deeper bookmarks are left out. -const OUTLINE_DEPTH: usize = 32; - -fn load_options(password: Option<&str>) -> LoadOptions { - LoadOptions { password: password.map(str::to_owned), max_decompressed_size: Some(LOAD_STREAM_LIMIT), ..LoadOptions::default() } -} +#[cfg(test)] +use annotations::{MAX_ANNOTATION_BYTES, MAX_LINK_ATTACHMENT_TEXT_BYTES, MAX_REMOTE_FILE_SPEC_BYTES, retained}; +use core::Inspector; +pub use core::{check_pages, inspect, pretty_date}; +#[cfg(test)] +use core::{inspect_structure, spend}; +pub use model::{ + Annotation, Attachment, AttachmentSource, DestView, Field, FieldKind, FontInfo, Layer, LayerOp, Link, LinkTarget, OutlineItem, PageInfo, + PageTransition, RemoteDestination, TransitionStyle, Xfa, link_replies, thread_tops, +}; +#[cfg(test)] +use navigation::{OUTLINE_DEPTH, OUTLINE_WINDOW}; +pub use resources::attachment_data; +#[cfg(test)] +use resources::{xfa_config_dynamic_render, xfa_packet_text}; #[derive(Clone, Debug, Default)] pub struct DocInfo { @@ -118,4252 +85,5 @@ pub struct DocInfo { pub warnings: Vec, } -/// What kind of XFA form a document has (`/AcroForm /XFA`). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum Xfa { - /// The pages and fields are ordinary PDF; the XFA packets describe the same form (and hold - /// its data, which Acrobat shows in preference to the fields). - Static, - /// The form is laid out from the XFA packets at open (`/NeedsRendering`, or no fields and a - /// configuration that doesn't forbid dynamic rendering): the PDF pages are only a placeholder. - Dynamic, -} - -/// A page's presentation transition and display time (ISO 32000-2 §12.4.4): how the page comes -/// into view in a full-screen presentation, and how long it stays before advancing. Only pages -/// carrying `/Trans` or `/Dur` are listed. Values outside the spec's ranges fall back to the -/// defaults rather than being refused. -#[derive(Clone, Debug, PartialEq)] -pub struct PageTransition { - pub page: usize, - /// `/Trans /S`; `None` when the page has only `/Dur`. - pub style: Option, - /// `/D`: how long the transition takes, in seconds (default 1). - pub duration: f32, - /// `/Dm /H` (true) or `/V` (Split, Blinds). - pub horizontal: bool, - /// `/M /I` (true) or `/O` (Split, Box, Fly). - pub inward: bool, - /// `/Di`: 0, 90, 180, 270 or 315 degrees (Wipe, Glitter, Fly, Cover, Uncover, Push); `None` - /// for `/None` (Fly only). - pub direction: Option, - /// `/SS`: the Fly scale (default 1). - pub scale: f32, - /// `/B`: Fly with an opaque backdrop. - pub opaque: bool, - /// `/Dur`: seconds the page shows before the presentation advances; `None` waits for the - /// presenter. - pub display: Option, -} - -/// The transition styles of ISO 32000-2 table 164 (`/Trans /S`). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum TransitionStyle { - Split, - Blinds, - Box, - Wipe, - Dissolve, - Glitter, - /// `/R`: the next page replaces the old one with no effect (the default). - Replace, - Fly, - Push, - Cover, - Uncover, - Fade, -} - -impl TransitionStyle { - /// The style a `/S` name means; an unknown name is `Replace`, the spec's default. - pub fn from_name(name: &str) -> Self { - match name { - "Split" => Self::Split, - "Blinds" => Self::Blinds, - "Box" => Self::Box, - "Wipe" => Self::Wipe, - "Dissolve" => Self::Dissolve, - "Glitter" => Self::Glitter, - "Fly" => Self::Fly, - "Push" => Self::Push, - "Cover" => Self::Cover, - "Uncover" => Self::Uncover, - "Fade" => Self::Fade, - _ => Self::Replace, - } - } - - /// The name as written in a PDF (`/S`). - pub fn name(self) -> &'static str { - match self { - Self::Split => "Split", - Self::Blinds => "Blinds", - Self::Box => "Box", - Self::Wipe => "Wipe", - Self::Dissolve => "Dissolve", - Self::Glitter => "Glitter", - Self::Replace => "R", - Self::Fly => "Fly", - Self::Push => "Push", - Self::Cover => "Cover", - Self::Uncover => "Uncover", - Self::Fade => "Fade", - } - } -} - -#[derive(Clone, Debug)] -pub struct PageInfo { - /// Displayed size in points, after `/Rotate` and `/UserUnit`. - pub width: f32, - pub height: f32, - /// Page label (`/PageLabels`), falling back to the 1-based page number. - pub label: String, - /// Effective crop box in user space [x0, y0, x1, y1] (the visible region). - pub crop: [f32; 4], - /// Clockwise page rotation in degrees (0, 90, 180, 270). - pub rotation: u16, -} - -impl PageInfo { - /// A point in view space (points, y down, after `/Rotate`; what the text layer uses) → - /// PDF user space. - pub fn view_to_user(&self, x: f32, y: f32) -> [f32; 2] { - let [cx0, cy0, cx1, cy1] = self.crop; - let (a, b) = (x / self.width.max(1e-3), y / self.height.max(1e-3)); - let (u, v) = match self.rotation { - 90 => (b, 1.0 - a), - 180 => (1.0 - a, 1.0 - b), - 270 => (1.0 - b, a), - _ => (a, b), - }; - [cx0 + u * (cx1 - cx0), cy1 - v * (cy1 - cy0)] - } - - /// PDF user space → view space (the inverse of [`Self::view_to_user`]). - pub fn user_to_view(&self, x: f32, y: f32) -> [f32; 2] { - let [cx0, cy0, cx1, cy1] = self.crop; - let (u, v) = ((x - cx0) / (cx1 - cx0).max(1e-3), (cy1 - y) / (cy1 - cy0).max(1e-3)); - let (a, b) = match self.rotation { - 90 => (1.0 - v, u), - 180 => (1.0 - u, 1.0 - v), - 270 => (v, 1.0 - u), - _ => (u, v), - }; - [a * self.width, b * self.height] - } - - /// A point as displayed (points from the page's top-left, y down, before any view rotation) - /// → the `/XYZ` position `[left, top]` in user space that puts it at the window's top-left. - /// Only a coordinate that is given is pinned: a bookmark made at the top of the window - /// leaves the horizontal position alone. On a page turned a quarter, the displayed `y` decides - /// the user `left` and the displayed `x` the user `top` (the inverse of [`Self::dest_fraction`]). - /// A value that is not finite counts as not given. - pub fn xyz_for_view(&self, x: Option, y: Option) -> [Option; 2] { - let (x, y) = (x.filter(|v| v.is_finite()), y.filter(|v| v.is_finite())); - let [ux, uy] = self.view_to_user(x.unwrap_or(0.0), y.unwrap_or(0.0)); - if self.rotation.is_multiple_of(180) { [x.map(|_| ux), y.map(|_| uy)] } else { [y.map(|_| ux), x.map(|_| uy)] } - } - - /// A destination's user-space point (each coordinate possibly unspecified) → the same point - /// as a fraction of the displayed page `[x, y]` (0..1 from its top-left corner, y down), - /// after `/Rotate` and a further clockwise `view_rotation` (0, 90, 180 or 270). On a page - /// turned a quarter, the user `x` decides the displayed `y` and vice versa, so each output - /// is `None` when the input it comes from is (or is not finite). Points off the page are - /// clamped to its edge. - pub fn dest_fraction(&self, x: Option, y: Option, view_rotation: u16) -> [Option; 2] { - let [cx0, cy0, cx1, cy1] = self.crop; - let frac = |v: f32| if v.is_finite() { Some(v.clamp(0.0, 1.0)) } else { None }; - // Unrotated fractions across (u, from the crop's left) and down (v, from its top). - let u = x.and_then(|x| frac((x - cx0) / (cx1 - cx0).max(1e-3))); - let v = y.and_then(|y| frac((cy1 - y) / (cy1 - cy0).max(1e-3))); - let flip = |f: Option| f.map(|f| 1.0 - f); - match (u32::from(self.rotation) + u32::from(view_rotation)) % 360 { - 90 => [flip(v), u], - 180 => [flip(u), flip(v)], - 270 => [v, flip(u)], - _ => [u, v], - } - } - - /// A view-space rectangle → the normalized user-space rectangle covering the same area of the - /// page (`[x0, y0, x1, y1]`, `x0 <= x1`, `y0 <= y1`). Under `/Rotate` the corners swap roles, - /// so a view rectangle's top-left is not in general the user rectangle's `[x0, y1]`. - pub fn view_rect_to_user(&self, r: [f32; 4]) -> [f32; 4] { - let (a, b) = (self.view_to_user(r[0], r[1]), self.view_to_user(r[2], r[3])); - [a[0].min(b[0]), a[1].min(b[1]), a[0].max(b[0]), a[1].max(b[1])] - } - - /// A view-space rectangle as a text-markup quad in user space: top-left, top-right, - /// bottom-left, bottom-right as read on screen. - pub fn view_rect_to_quad(&self, r: [f32; 4]) -> [f64; 8] { - let mut q = [0.0; 8]; - for (i, (x, y)) in [(r[0], r[1]), (r[2], r[1]), (r[0], r[3]), (r[2], r[3])].into_iter().enumerate() { - let [ux, uy] = self.view_to_user(x, y); - q[2 * i] = ux as f64; - q[2 * i + 1] = uy as f64; - } - q - } -} - -/// A clickable link annotation. -#[derive(Clone, Debug)] -pub struct Link { - pub page: usize, - /// Zero-based position in the page's raw `/Annots` array. - pub index: usize, - pub rect: [f32; 4], - pub target: LinkTarget, -} - -#[derive(Clone, Debug, PartialEq)] -pub enum LinkTarget { - /// A destination in this document: the 0-based page and where on it the view goes. - Page(usize, DestView), - Uri(String), - /// A set-layer-visibility action (`SetOCGState`, ISO 32000-2 §12.6.4.13): each change in - /// order, naming the layer by its optional content group. With `preserve_rb`, a layer turned - /// on turns off the other layers of its radio-button groups. - SetLayers { - changes: Vec<(LayerOp, (u32, u16))>, - preserve_rb: bool, - }, - /// A local-file remote Go-To action. The caller resolves `file` relative to the source path. - /// `launch` marks a Launch action that names a PDF file (see `launch_pdf`), opened the same - /// way: as a document in PdfCraft, never by the operating system. - RemoteGoTo { - launch: bool, - file: String, - destination: RemoteDestination, - new_window: Option, - }, - Other(String), -} - -#[derive(Clone, Debug, PartialEq)] -pub enum RemoteDestination { - Default, - Page { page: usize, view: DestView }, - Named(String), -} - -/// What a set-layer-visibility action does to a layer. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum LayerOp { - On, - Off, - Toggle, -} - -/// Where an explicit destination (ISO 32000-2 §12.3.2.2, Table 149) places its page in the -/// window. Coordinates are PDF user space; `None` is a `null` (or unusable) operand, which keeps -/// that part of the view as it is. -#[derive(Clone, Copy, Debug, Default, PartialEq)] -pub enum DestView { - /// The top of the page at the current zoom: a destination with no position, or one whose - /// type is missing or unknown. - #[default] - Top, - /// `/XYZ left top zoom`: the point (left, top) at the window's upper-left corner, at `zoom` - /// (1.0 = 100%; 0 or null keeps the current zoom). - Xyz { left: Option, top: Option, zoom: Option }, - /// `/Fit`, and `/FitB` (fit the page's content box; read as the whole page). - Fit, - /// `/FitH top`, and `/FitBH`: fit the width, with `top` at the window's top edge. - FitH { top: Option }, - /// `/FitV left`, and `/FitBV`: fit the height, with `left` at the window's left edge. - FitV { left: Option }, - /// `/FitR left bottom right top`: fit the rectangle `[x0, y0, x1, y1]` (normalized). - FitR { rect: [f32; 4] }, -} - -impl DestView { - /// The view an explicit destination array asks for, from its elements after the page - /// (`/XYZ left top zoom`, `/FitH top`, …). Hostile input never fails: a missing or unknown - /// type, or a `/FitR` without four usable numbers, is [`DestView::Top`]; a non-numeric, - /// non-finite or missing operand is `None`; a zoom that is not positive is `None`. - pub fn from_operands(kind: Option<&[u8]>, operands: &[Option]) -> Self { - let n = |i: usize| operands.get(i).copied().flatten().filter(|v| v.is_finite()); - match kind { - Some(b"XYZ") => Self::Xyz { left: n(0), top: n(1), zoom: n(2).filter(|z| *z > 0.0) }, - Some(b"Fit" | b"FitB") => Self::Fit, - Some(b"FitH" | b"FitBH") => Self::FitH { top: n(0) }, - Some(b"FitV" | b"FitBV") => Self::FitV { left: n(0) }, - Some(b"FitR") => match (n(0), n(1), n(2), n(3)) { - (Some(x0), Some(y0), Some(x1), Some(y1)) => Self::FitR { rect: [x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1)] }, - _ => Self::Top, - }, - _ => Self::Top, - } - } -} - -#[derive(Clone, Debug)] -pub struct OutlineItem { - pub title: String, - pub page: Option, - /// Where on `page` the bookmark goes ([`DestView::Top`] for a bookmark without a page). - pub view: DestView, - pub children: Vec, - /// Whether the bookmark starts open: only a positive `/Count` says so (ISO 32000-2 §12.3.3, - /// Table 151). A negative one is closed, and so is a missing one, which a bookmark with - /// children should not have. - pub open: bool, - /// The title's colour (`/C`, DeviceRGB, ISO 32000-2 §12.3.3 Table 151), or `None` for the - /// default black or a value that isn't three numbers. - pub color: Option<[f32; 3]>, - /// The title's style (`/F`, Table 152): bit 1 italic, bit 2 bold. - pub italic: bool, - pub bold: bool, -} - -#[derive(Clone, Debug)] -pub struct Annotation { - pub page: usize, - pub subtype: String, - pub author: Option, - pub contents: Option, - pub modified: Option, - /// `/NM` of this annotation, and of the one it replies to (`/IRT`). - pub name: Option, - pub in_reply_to: Option, - /// The page and `/Annots` position of the comment `/IRT` points at, found by object - /// reference. Threads follow this rather than `/NM`, which need not be unique: PyMuPDF - /// names the first comment on every page `fitz-A0`. `None` when `/IRT` leads to no listed - /// comment ([`link_replies`]). - pub parent: Option<(usize, usize)>, - /// `/RT /Group`: grouped with the annotation `/IRT` points at (Replace Text's caret with its - /// strikeout) rather than a reply to it. - pub grouped: bool, - /// Rect in PDF user space: [x0, y0, x1, y1]. - pub rect: [f32; 4], - pub color: Option<[f32; 3]>, - /// Position in the page's `/Annots` (how edits address the comment). - pub index: usize, - /// For a status reply (`/State`, Acrobat's "Set status"): the state it sets. - pub state: Option, - /// Text markup quadrilaterals (`/QuadPoints`), 8 numbers each. - pub quads: Vec<[f32; 8]>, - /// The Locked flag (`/F` bit 8): the comment can't be moved, resized, restyled or deleted. - pub locked: bool, - /// `/IT`, the intent: `FreeTextCallout`, `PolygonCloud`, `FreeTextTypeWriter`… - pub intent: Option, - /// A Fill & Sign mark: typed text, a check, cross, dot or line, or a typed, drawn or picture - /// signature or initials (the rules of `pdfcraft_annot::is_fill_sign`). - pub fill_sign: bool, - /// The NoRotate flag (`/F` bit 5), set on sticky notes and attachments: on a turned page the - /// appearance stays upright, turned about the upper-left corner of `rect` - /// ([`Self::shown_rect`]). - pub no_rotate: bool, - /// The points that shape a line (its two ends, `/L`) or connected lines, a polygon or a - /// cloud (their corners, `/Vertices`), in user space; empty for other comments. - pub points: Vec<[f32; 2]>, -} - -impl Annotation { - /// A checkmark reply (`/StateModel /Marked`): "Marked" or "Unmarked" rather than a review status. - pub fn is_mark(&self) -> bool { - matches!(self.state.as_deref(), Some("Marked" | "Unmarked")) - } - - /// A reply (or status reply) to another comment rather than a comment of its own. - pub fn is_reply(&self) -> bool { - self.parent.is_some() - } - - /// A comment of its own: not a reply, and not a status or checkmark. One whose comment can't - /// be found (deleted from another page, or never listed) has nothing to show, so it is listed - /// nowhere: not as a card, on the page or in a count. - pub fn is_comment(&self) -> bool { - !self.is_reply() && self.state.is_none() - } - - /// Whether this annotation's `/IRT` points at `other`. - pub fn replies_to(&self, other: &Annotation) -> bool { - self.parent == Some((other.page, other.index)) - } - - /// Where the comment is drawn on a page turned by `rotation` (`PageInfo::rotation`), in user - /// space: `rect`, or for a NoRotate comment `rect` turned upright about its upper-left corner - /// (see [`Self::no_rotate`]). Hit-testing, selection and anything placed beside the comment - /// use this, so they match the drawing. - pub fn shown_rect(&self, rotation: u16) -> [f32; 4] { - self.shown(self.rect.map(f64::from), rotation).map(|v| v as f32) - } - - /// [`Self::shown_rect`] for this comment with its `/Rect` at `rect` (one being moved or - /// resized). - pub fn shown(&self, rect: [f64; 4], rotation: u16) -> [f64; 4] { - if self.no_rotate { upright_rect(rect, rotation) } else { rect } - } - - /// The `/Rect` that shows this comment in the user-space box `shown`: the inverse of - /// [`Self::shown`], for a box resized as drawn. - pub fn rect_for_shown(&self, shown: [f64; 4], rotation: u16) -> [f64; 4] { - if self.no_rotate { rect_from_upright(shown, rotation) } else { shown } - } -} - -/// The user-space box a NoRotate annotation with `/Rect` `rect` is drawn in on a page turned -/// clockwise by `rotation` (ISO 32000-2 §12.5.3): `rect` turned as far counterclockwise about its -/// upper-left corner `(x0, y1)`, which stays where it is on the page. As displayed, the box's -/// top-left is that corner, the rectangle's width runs across and its height down, as on an -/// unturned page. `rect` itself for 0 or anything that isn't a quarter turn. The vendored -/// renderer draws the appearance there (hayro-interpret patch 37). -fn upright_rect(rect: [f64; 4], rotation: u16) -> [f64; 4] { - let [x0, y0, x1, y1] = [rect[0].min(rect[2]), rect[1].min(rect[3]), rect[0].max(rect[2]), rect[1].max(rect[3])]; - let (w, h) = (x1 - x0, y1 - y0); - match rotation { - 90 => [x0, y1, x0 + h, y1 + w], - 180 => [x0 - w, y1, x0, y1 + h], - 270 => [x0 - h, y1 - w, x0, y1], - _ => [x0, y0, x1, y1], - } -} - -/// The `/Rect` whose [`upright_rect`] is `shown`: `shown` turned back about the corner that -/// stays put. -fn rect_from_upright(shown: [f64; 4], rotation: u16) -> [f64; 4] { - let [s0, s1, s2, s3] = [shown[0].min(shown[2]), shown[1].min(shown[3]), shown[0].max(shown[2]), shown[1].max(shown[3])]; - let (sw, sh) = (s2 - s0, s3 - s1); - // The upper-left corner of `/Rect` and the rectangle's width and height. - let ((x0, y1), w, h) = match rotation { - 90 => ((s0, s1), sh, sw), - 180 => ((s2, s1), sw, sh), - 270 => ((s2, s3), sh, sw), - _ => return [s0, s1, s2, s3], - }; - [x0, y1 - h, x0 + w, y1] -} - -/// Settle the reply links of a document's annotations: a reply keeps its `parent` only when -/// following the links reaches a comment of its own within 64 steps. An `/IRT` to an unlisted -/// object, a pop-up, the annotation itself or a loop leaves it a comment of its own, so it stays -/// in view instead of hanging off nothing. Both comment readers (inspect, and the engine's list -/// after an edit) run this. -/// -/// Only comments can be parents: both readers leave links, form widgets, pop-ups and other -/// non-markup annotations out of `annotations` (`pdfcraft_annot::is_comment_subtype`), so an -/// `/IRT` to one of those finds no parent here. Pop-ups are skipped again in case a caller's list -/// has them. -pub fn link_replies(annotations: &mut [Annotation]) { - let at: HashMap<(usize, usize), usize> = - annotations.iter().enumerate().filter(|(_, a)| a.subtype != "Popup").map(|(i, a)| ((a.page, a.index), i)).collect(); - let links: Vec> = - annotations.iter().enumerate().map(|(i, a)| a.parent.and_then(|p| at.get(&p).copied()).filter(|&j| j != i)).collect(); - for (i, a) in annotations.iter_mut().enumerate() { - let mut up = links.get(i).copied().flatten(); - let mut settled = false; - for _ in 0..64 { - let Some(j) = up else { break }; - up = links.get(j).copied().flatten(); - if up.is_none() { - settled = true; - break; - } - } - if !settled { - a.parent = None; - } - } -} - -/// The comment each reply's thread starts from, by the reply's page and `/Annots` position: a -/// reply to a reply files under that comment too. Expects links [`link_replies`] settled. -pub fn thread_tops(annotations: &[Annotation]) -> HashMap<(usize, usize), (usize, usize)> { - let parents: HashMap<(usize, usize), (usize, usize)> = annotations.iter().filter_map(|a| Some(((a.page, a.index), a.parent?))).collect(); - parents - .iter() - .map(|(&reply, &first)| { - let mut top = first; - for _ in 0..64 { - let Some(&up) = parents.get(&top) else { break }; - top = up; - } - (reply, top) - }) - .collect() -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum FieldKind { - Text, - CheckBox, - Radio, - PushButton, - Combo, - List, - Signature, - Unknown, -} - -#[derive(Clone, Debug)] -pub struct Field { - pub name: String, - pub kind: FieldKind, - pub value: Option, - pub page: Option, - pub tooltip: Option, - pub has_actions: bool, - /// Rect of the first widget, in user space. - pub rect: Option<[f32; 4]>, -} - -#[derive(Clone, Debug)] -pub struct Layer { - /// Object number and generation of the optional content group. - pub id: (u32, u16), - pub name: String, - pub visible: bool, - /// Shown as the file opens: the default configuration (`/OCProperties /D`). - pub initial: bool, -} - -#[derive(Clone, Debug)] -pub struct Attachment { - pub name: String, - pub description: Option, - pub size: Option, - pub source: AttachmentSource, -} - -/// Where an attachment lives (so its bytes can be fetched on demand). -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum AttachmentSource { - /// Document-level: `/Names /EmbeddedFiles` entry with this key. - Document { key: String }, - /// A FileAttachment annotation: page index and position in the page's `/Annots`. - Annotation { page: usize, index: usize }, -} - -/// A font used by the document (Document Properties ▸ Fonts). -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct FontInfo { - /// BaseFont without the subset tag. - pub name: String, - /// Type1, TrueType, Type0, Type3, MMType1, CIDFontType0/2 … - pub kind: String, - pub embedded: bool, - pub subset: bool, - pub encoding: Option, - /// The face standing in for a program the document does not embed. `None` when the font is - /// embedded (nothing stands in) or when no face was available at all -- `substitution` then - /// says which of the two it is. - pub substitute: Option, - /// Why a face was substituted, as a sentence for whoever has to act on it. `None` only when - /// the document's own glyphs are being shown. - pub substitution: Option, -} - -/// Check that `bytes` still open as the same document: the parser reads them and the page -/// count matches. Content-only edits skip the full [`inspect`] (seconds on very large files) -/// but must still catch an edit that produced something the viewer cannot display, so the -/// caller can roll it back. Never panics: parser crashes are caught and reported. -pub fn check_pages(bytes: Arc>, password: Option<&str>, expected: usize) -> Result<(), OpenError> { - let pdf = catch_unwind(AssertUnwindSafe(|| Pdf::new_with_password(bytes, password.unwrap_or("")))) - .map_err(|p| OpenError::Invalid(format!("the parser crashed: {}", crate::raster::panic_message(&p))))? - .map_err(|e| match e { - LoadPdfError::Decryption(DecryptionError::PasswordProtected) => { - if password.is_some() { - OpenError::WrongPassword - } else { - OpenError::NeedsPassword - } - } - LoadPdfError::Decryption(other) => OpenError::Unsupported(format!("encryption not supported: {other:?}")), - LoadPdfError::Invalid => OpenError::Invalid("no readable page tree or cross-reference data was found".into()), - })?; - let pages = catch_unwind(AssertUnwindSafe(|| pdf.pages().len())) - .map_err(|p| OpenError::Invalid(format!("the page tree could not be read: {}", crate::raster::panic_message(&p))))?; - if pages != expected { - return Err(OpenError::Invalid(format!("the edit changed the page count from {expected} to {pages}"))); - } - Ok(()) -} - -/// Inspect a document. Fails only if the renderer itself cannot open the file (or needs a -/// password). Never panics: parser crashes are caught and reported. -pub fn inspect(bytes: Arc>, password: Option<&str>) -> Result { - let pdf = catch_unwind(AssertUnwindSafe(|| Pdf::new_with_password(bytes.clone(), password.unwrap_or("")))) - .map_err(|p| OpenError::Invalid(format!("the parser crashed: {}", crate::raster::panic_message(&p))))? - .map_err(|e| match e { - LoadPdfError::Decryption(DecryptionError::PasswordProtected) => { - if password.is_some() { - OpenError::WrongPassword - } else { - OpenError::NeedsPassword - } - } - LoadPdfError::Decryption(other) => OpenError::Unsupported(format!("encryption not supported: {other:?}")), - LoadPdfError::Invalid => OpenError::Invalid("no readable page tree or cross-reference data was found".into()), - })?; - let mut info = DocInfo { file_size: bytes.len(), pdf_version: version_label(&format!("{:?}", pdf.version())), ..Default::default() }; - let pages = catch_unwind(AssertUnwindSafe(|| { - let mut out = Vec::new(); - for (i, page) in pdf.pages().iter().enumerate() { - let (w, h) = page.render_dimensions(); - let c = page.intersected_crop_box(); - let rotation = match page.rotation() { - hayro::hayro_syntax::page::Rotation::None => 0, - hayro::hayro_syntax::page::Rotation::Horizontal => 90, - hayro::hayro_syntax::page::Rotation::Flipped => 180, - hayro::hayro_syntax::page::Rotation::FlippedHorizontal => 270, - }; - let crop = [c.x0 as f32, c.y0 as f32, c.x1 as f32, c.y1 as f32]; - // Degenerate boxes still get a usable placeholder size so layout never divides by zero. - let (w, h) = if w.is_finite() && h.is_finite() && w >= 1.0 && h >= 1.0 { (w, h) } else { (612.0, 792.0) }; - out.push(PageInfo { width: w, height: h, label: (i + 1).to_string(), crop, rotation }); - } - out - })); - match pages { - Ok(p) => info.pages = p, - Err(p) => return Err(OpenError::Invalid(format!("the page tree could not be read: {}", crate::raster::panic_message(&p)))), - } - let options = load_options(password); - // Geometry no longer needs the renderer parser. Do not overlap its allocations with - // structural inspection, including the compatibility fallback for damaged files. - drop(pdf); - inspect_structure(&mut info, |tmp| { - // The lazy reader first; lopdf's repair/tolerance path when it can't expose the same - // page tree or an object a panel needs. No partial result is published. - let lazy = catch_unwind(AssertUnwindSafe(|| { - let doc = LazyStructure::new(bytes.clone(), password).ok()?; - let mut t = DocInfo { pages: tmp.pages.clone(), ..Default::default() }; - let inspector = Inspector::new(&doc); - if inspector.page_index.len() != t.pages.len() { - return None; - } - inspector.fill(&mut t); - (!doc.failed()).then_some(t) - })) - .ok() - .flatten(); - if let Some(t) = lazy { - *tmp = t; - return Ok(()); - } - let doc = Document::load_mem_with_options(&bytes, options).map_err(|e| e.to_string())?; - Inspector::new(&doc).fill(tmp); - Ok(()) - }); - if info.pages.is_empty() { - return Err(OpenError::Invalid("the document has no pages".into())); - } - Ok(info) -} - -// Commit auxiliary metadata only after inspection succeeds; renderer geometry is the fallback. -fn inspect_structure(info: &mut DocInfo, fill: impl FnOnce(&mut DocInfo) -> Result<(), String>) { - let structure = catch_unwind(AssertUnwindSafe(|| { - let mut tmp = DocInfo { pages: info.pages.clone(), ..Default::default() }; - fill(&mut tmp)?; - Ok::<_, String>(tmp) - })); - match structure { - Ok(Ok(mut filled)) => { - filled.file_size = info.file_size; - filled.pdf_version = std::mem::take(&mut info.pdf_version); - *info = filled; - } - Ok(Err(e)) => info.warnings.push(format!("Some document structure (bookmarks, comments, fields) could not be read: {e}")), - Err(p) => { - info.warnings.push(format!("Document structure inspection crashed and was skipped: {}", crate::raster::panic_message(&p))); - } - } -} - -struct Inspector<'a> { - doc: &'a dyn Structure, - page_ids: Vec, - page_index: HashMap, - /// Named destinations (`/Names /Dests` tree), keyed by raw string bytes, built once. - /// Looking each name up by walking the tree was O(links × names): minutes on manuals. - named: HashMap, &'a Object>, - /// The page of each annotation (the first page that lists it), built when a field's widget - /// has no `/P`. Searching every page's `/Annots` for each such field was O(fields × - /// annotations): a second on a form of 8,000 fields. - annotation_pages: OnceCell>, -} - -impl<'a> Inspector<'a> { - fn new(doc: &'a dyn Structure) -> Self { - let page_ids = doc.page_ids(); - let page_index = page_ids.iter().copied().enumerate().map(|(index, id)| (id, index)).collect(); - let mut me = Self { doc, page_ids, page_index, named: HashMap::new(), annotation_pages: OnceCell::new() }; - let mut named = HashMap::new(); - if let Some(tree) = - doc.catalog().ok().and_then(|c| c.get(b"Names").ok()).and_then(|o| me.dict(o)).and_then(|n| n.get(b"Dests").ok()).and_then(|o| me.dict(o)) - { - me.name_tree_raw(tree, &mut HashSet::new(), 0, &mut named); - } - me.named = named; - me - } - - fn fill(&self, info: &mut DocInfo) { - // lopdf drops /Encrypt from the trailer once it has decrypted the file. - info.encrypted = self.doc.encrypted(); - if let Some(d) = self.doc.trailer().get(b"Info").ok().and_then(|o| self.dict(o)) { - info.title = self.text(d, b"Title"); - info.author = self.text(d, b"Author"); - info.subject = self.text(d, b"Subject"); - info.keywords = self.text(d, b"Keywords"); - info.creator = self.text(d, b"Creator"); - info.producer = self.text(d, b"Producer"); - const STANDARD: [&[u8]; 9] = - [b"Title", b"Author", b"Subject", b"Keywords", b"Creator", b"Producer", b"CreationDate", b"ModDate", b"Trapped"]; - // The same rule as the engine's list after an edit (`pdfcraft_organize::custom_info`, - // trimmed and without empty values): text strings only, so a name value is not a - // custom property, and the cap counts listed entries. - info.custom = d - .iter() - .filter(|(k, _)| !STANDARD.contains(&k.as_slice())) - .filter_map(|(k, v)| match self.resolve(v) { - Object::String(bytes, _) => { - let v = text_string(bytes).trim_matches(|c: char| c == '\0' || c.is_whitespace()).to_string(); - (!v.is_empty()).then(|| (String::from_utf8_lossy(k).into_owned(), v)) - } - _ => None, - }) - .take(256) - .collect(); - } - let Ok(catalog) = self.doc.catalog() else { return }; - info.tagged = catalog - .get(b"MarkInfo") - .ok() - .and_then(|o| self.dict(o)) - .and_then(|m| m.get(b"Marked").ok()) - // Any value may be written as an indirect reference, `/Marked 9 0 R` included, so the - // flag is resolved before it is read as a boolean (#3671). - .map(|o| self.resolve(o)) - .and_then(|o| o.as_bool().ok()) - .unwrap_or(false); - match self.page_labels(catalog, &mut info.pages) { - Ok(notes) => info.warnings.extend(notes.into_iter().map(|note| format!("Page labels: {note}."))), - Err(reason) => info.warnings.push(format!("Page labels were skipped: {reason}; using physical page numbers.")), - } - if let Some(first) = catalog.get(b"Outlines").ok().and_then(|o| self.dict(o)).and_then(|d| d.get(b"First").ok()) { - (info.outline, info.outline_more) = self.outline_window(first); - } - let mut text_budget = MAX_LINK_ATTACHMENT_TEXT_BYTES; - self.annotations(info, &mut text_budget); - self.transitions(info); - let mut field_seen = HashSet::new(); - if let Some(form) = catalog.get(b"AcroForm").ok().and_then(|o| self.dict(o)) - && let Ok(fields) = form.get(b"Fields").and_then(|o| self.resolve(o).as_array()) - { - for f in fields { - self.field(f, None, &mut info.fields, &mut field_seen, 0); - } - } - // Widgets no `/Fields` entry reaches are fields too (Acrobat and the browsers fill them); - // each is adopted through its topmost unlisted `/Parent`, so a split field stays one. - for &pid in &self.page_ids { - let Ok(page_dict) = self.doc.get_dictionary(pid) else { continue }; - let Ok(Object::Array(annots)) = page_dict.get(b"Annots").map(|o| self.resolve(o)) else { continue }; - for a in annots { - let Some(d) = self.dict(a) else { continue }; - if self.name(d, b"Subtype").as_deref() != Some("Widget") - || (d.get(b"FT").is_err() && d.get(b"T").is_err() && d.get(b"Parent").is_err()) - { - continue; - } - if let Object::Reference(id) = a - && let Some(root) = self.unlisted_field(*id, &field_seen) - { - self.field(&Object::Reference(root), None, &mut info.fields, &mut field_seen, 0); - } - } - } - info.has_javascript |= info.fields.iter().any(|f| f.has_actions); - let needs_rendering = catalog.get(b"NeedsRendering").ok().and_then(|o| self.resolve(o).as_bool().ok()).unwrap_or(false); - if let Some(xfa) = catalog.get(b"AcroForm").ok().and_then(|o| self.dict(o)).and_then(|form| form.get(b"XFA").ok()) { - // A form with no fields is taken to be laid out from its template, unless its - // configuration says it is a static form (#3180): Designer writes - // `forbidden` for one, whose pages are the form. - let static_form = self.xfa_dynamic_render(xfa).as_deref() == Some("forbidden"); - info.xfa = Some(if needs_rendering || (info.fields.is_empty() && !static_form) { Xfa::Dynamic } else { Xfa::Static }); - } - self.layers(catalog, &mut info.layers); - info.layer_groups = self.layer_groups(catalog); - self.fonts(&mut info.fonts); - if let Some(names) = catalog.get(b"Names").ok().and_then(|o| self.dict(o)) { - if let Some(ef) = names.get(b"EmbeddedFiles").ok().and_then(|o| self.dict(o)) { - let mut seen = HashSet::new(); - for (name, spec) in self.name_tree(ef, &mut seen, 0, &mut text_budget) { - // The key is kept a second time when it is also the name. - let fallback = |budget: &mut usize| spend(name.as_bytes(), budget, |_| name.clone()); - info.attachments.push(self.attachment(spec, AttachmentSource::Document { key: name.clone() }, &mut text_budget, fallback)); - } - } - info.has_javascript |= names.get(b"JavaScript").is_ok(); - } - if let Ok(action) = catalog.get(b"OpenAction") - && let Some(dict) = self.dict(action) - && dict.get(b"S").ok().and_then(|o| self.resolve(o).as_name().ok()) == Some(&b"JavaScript"[..]) - { - info.has_javascript = true; - } - info.open_dest = catalog.get(b"OpenAction").ok().and_then(|o| self.open_dest(o)); - } - - /// The destination `/OpenAction` opens at (ISO 32000-2 §7.7.2 Table 29): an explicit - /// destination array, a `/GoTo` action's `/D`, or (leniently, since Table 29 asks for an array - /// or an action) a destination name or string; a name or string is looked up as a named - /// destination (§12.3.2.4). Any other action (a script, a remote go-to) has none here, and - /// neither has a page number past the end of the document. - fn open_dest(&self, action: &Object) -> Option<(usize, DestView)> { - let dest = match self.resolve(action) { - Object::Dictionary(d) => { - if d.get(b"S").ok().and_then(|o| self.resolve(o).as_name().ok()) != Some(&b"GoTo"[..]) { - return None; - } - d.get(b"D").ok()? - } - other => other, - }; - self.dest(dest, 0).filter(|(page, _)| *page < self.page_ids.len()) - } - - // ── object helpers ────────────────────────────────────────────────────────────────────── - - /// `/Trans` and `/Dur` of every page (ISO 32000-2 §12.4.4), in page order. Lenient: a value - /// outside the spec's choices takes that entry's default, and a page with neither entry is - /// not listed. Bounded by the page count. - fn transitions(&self, info: &mut DocInfo) { - for (page, &pid) in self.page_ids.iter().enumerate() { - let Ok(d) = self.doc.get_dictionary(pid) else { continue }; - let display = d.get(b"Dur").ok().and_then(|o| self.resolve(o).as_float().ok()).filter(|s| s.is_finite() && *s >= 0.0); - let trans = d.get(b"Trans").ok().and_then(|o| self.dict(o)); - if trans.is_none() && display.is_none() { - continue; - } - let mut t = PageTransition { - page, - style: None, - duration: 1.0, - horizontal: true, - inward: true, - direction: Some(0), - scale: 1.0, - opaque: false, - display, - }; - if let Some(tr) = trans { - t.style = Some(self.name(tr, b"S").map_or(TransitionStyle::Replace, |n| TransitionStyle::from_name(&n))); - let num = |key: &[u8]| tr.get(key).ok().and_then(|o| self.resolve(o).as_float().ok()).filter(|v| v.is_finite()); - t.duration = num(b"D").filter(|d| *d >= 0.0).unwrap_or(1.0); - t.horizontal = self.name(tr, b"Dm").as_deref() != Some("V"); - t.inward = self.name(tr, b"M").as_deref() != Some("O"); - t.direction = match tr.get(b"Di").map(|o| self.resolve(o)) { - Ok(Object::Name(n)) if n.as_slice() == b"None" => None, - Ok(o) => match o.as_i64().ok() { - Some(0) => Some(0), - Some(90) => Some(90), - Some(180) => Some(180), - Some(270) => Some(270), - Some(315) => Some(315), - _ => Some(0), - }, - Err(_) => Some(0), - }; - t.scale = num(b"SS").filter(|s| *s > 0.0).unwrap_or(1.0); - t.opaque = matches!(tr.get(b"B").map(|o| self.resolve(o)), Ok(Object::Boolean(true))); - } - info.transitions.push(t); - } - } - - /// The value of `dynamicRender` in an XFA form's configuration packet (`config`, under - /// `acrobat/acrobat7`): `required` for a dynamic form, `forbidden` for a static one. `/XFA` is - /// either one stream holding the whole XDP or an array of packet names and streams (ISO - /// 32000-1 §12.7.8). Lenient: `None` when the packet is missing, unreadable or over - /// `MAX_XFA_CONFIG_BYTES` decoded, or has no such element. - fn xfa_dynamic_render(&self, xfa: &'a Object) -> Option { - let stream = match self.resolve(xfa) { - Object::Array(items) => { - let at = items.iter().position(|o| matches!(self.resolve(o), Object::String(name, _) if name.as_slice() == b"config"))?; - self.resolve(items.get(at.checked_add(1)?)?) - } - whole => whole, - }; - let stream = stream.as_stream().ok()?; - let bytes = if stream.dict.get(b"Filter").is_ok() { - stream.decompressed_content_with_limit(MAX_XFA_CONFIG_BYTES).ok()? - } else { - stream.content.get(..stream.content.len().min(MAX_XFA_CONFIG_BYTES))?.to_vec() - }; - xfa_config_dynamic_render(&xfa_packet_text(&bytes)) - } - - fn resolve(&self, o: &'a Object) -> &'a Object { - self.doc.dereference(o).map(|(_, o)| o).unwrap_or(o) - } - - /// The object a reference ends at after following any chain of references; `None` for a - /// direct object or a broken reference. - fn resolved_id(&self, o: &Object) -> Option { - self.doc.dereference(o).ok().and_then(|(id, _)| id) - } - - fn dict(&self, o: &'a Object) -> Option<&'a Dictionary> { - match self.resolve(o) { - Object::Dictionary(d) => Some(d), - Object::Stream(s) => Some(&s.dict), - _ => None, - } - } - - fn text(&self, d: &Dictionary, key: &[u8]) -> Option { - let s = trimmed_text(self.resolve(d.get(key).ok()?))?; - (!s.is_empty()).then_some(s) - } - - /// [`Self::text`], converted and spent from `budget` as [`spend`] does, so text that trims - /// short or to nothing still spends its raw length. - fn budgeted_text(&self, d: &Dictionary, key: &[u8], budget: &mut usize) -> Option { - let o = self.resolve(d.get(key).ok()?); - let (Object::String(bytes, _) | Object::Name(bytes)) = o else { return None }; - let text = spend(bytes, budget, |_| trimmed_text(o).unwrap_or_default())?; - (!text.is_empty()).then_some(text) - } - - fn name(&self, d: &Dictionary, key: &[u8]) -> Option { - self.resolve(d.get(key).ok()?).as_name().ok().map(|n| String::from_utf8_lossy(n).into_owned()) - } - - /// Whether an annotation is a Fill & Sign mark. Keep in step with - /// `pdfcraft_annot::is_fill_sign`, which the engine's comment list uses. - fn is_fill_sign(&self, d: &Dictionary, subtype: &str) -> bool { - let yes = |key: &[u8]| matches!(d.get(key).map(|o| self.resolve(o)), Ok(Object::Boolean(true))); - if yes(b"PCFillSign") { - return true; - } - match subtype { - "FreeText" => self.name(d, b"IT").as_deref() == Some("FreeTextTypeWriter"), - "Stamp" => match self.name(d, b"Name").as_deref() { - Some("PCCheck" | "PCCross" | "PCDot" | "PCLine" | "PCTypedSignature") => true, - Some("PCCustomSignature" | "PCCustomInitials") => yes(b"PCPictureImage"), - _ => false, - }, - "Ink" => self.text(d, b"Subj").as_deref() == Some("Signature"), - _ => false, - } - } - - fn page_of(&self, o: &Object) -> Option { - match o { - Object::Reference(id) => self.page_index.get(id).copied(), - // Some producers write page *numbers* in remote-style destinations. - Object::Integer(n) => usize::try_from(*n).ok(), - _ => None, - } - } - - // ── destinations ──────────────────────────────────────────────────────────────────────── - - /// The page and view of a destination: an explicit array (`[page /XYZ left top zoom]`, …), - /// a dictionary with `/D`, or a named destination that resolves to one. - fn dest(&self, dest: &Object, depth: u32) -> Option<(usize, DestView)> { - if depth > 8 { - return None; - } - match self.resolve(dest) { - Object::Array(a) => { - let page = a.first().and_then(|p| self.page_of(p))?; - let kind = a.get(1).map(|k| self.resolve(k)).and_then(|k| k.as_name().ok()); - let operands: Vec> = a.iter().skip(2).take(4).map(|o| self.resolve(o).as_float().ok()).collect(); - Some((page, DestView::from_operands(kind, &operands))) - } - Object::Dictionary(d) => d.get(b"D").ok().and_then(|d| self.dest(d, depth + 1)), - Object::String(key, _) | Object::Name(key) => self.named_dest(key).and_then(|d| self.dest(d, depth + 1)), - _ => None, - } - } - - fn named_dest(&self, key: &[u8]) -> Option<&'a Object> { - let catalog = self.doc.catalog().ok()?; - if let Some(v) = self.named.get(key) { - return Some(v); - } - // PDF 1.1 style /Dests dictionary. - catalog.get(b"Dests").ok().and_then(|o| self.dict(o)).and_then(|d| d.get(key).ok()) - } - - /// A name tree's leaves keyed by their raw key bytes (first occurrence wins). - fn name_tree_raw(&self, node: &'a Dictionary, seen: &mut HashSet<*const Dictionary>, depth: u32, out: &mut HashMap, &'a Object>) { - if depth > 32 || !seen.insert(node as *const _) { - return; - } - if let Ok(Object::Array(pairs)) = node.get(b"Names").map(|o| self.resolve(o)) { - for pair in pairs.chunks(2) { - if let [k, v] = pair { - let key = match self.resolve(k) { - Object::String(s, _) => s.clone(), - Object::Name(n) => n.clone(), - _ => continue, - }; - out.entry(key).or_insert(v); - } - } - } - if let Ok(Object::Array(kids)) = node.get(b"Kids").map(|o| self.resolve(o)) { - for k in kids { - if let Some(d) = self.dict(k) { - self.name_tree_raw(d, seen, depth + 1, out); - } - } - } - } - - /// The entries of an embedded-files name tree. Each string key is spent from `text_budget` - /// (see [`MAX_LINK_ATTACHMENT_TEXT_BYTES`]), and an entry whose key does not fit is left out. - fn name_tree( - &self, - node: &'a Dictionary, - seen: &mut HashSet<*const Dictionary>, - depth: u32, - text_budget: &mut usize, - ) -> Vec<(String, &'a Object)> { - let mut out = Vec::new(); - if depth > 32 || !seen.insert(node as *const _) { - return out; - } - if let Ok(Object::Array(pairs)) = node.get(b"Names").map(|o| self.resolve(o)) { - for pair in pairs.chunks(2) { - if let [k, v] = pair { - let key = match self.resolve(k) { - Object::String(bytes, _) => match spend(bytes, text_budget, text_string) { - Some(key) => key, - None => continue, - }, - _ => String::new(), - }; - out.push((key, v)); - } - } - } - if let Ok(Object::Array(kids)) = node.get(b"Kids").map(|o| self.resolve(o)) { - for k in kids { - if let Some(d) = self.dict(k) { - out.extend(self.name_tree(d, seen, depth + 1, text_budget)); - } - } - } - out - } - - /// The first entry of an embedded-files name tree whose key is `key`, in the order - /// [`Self::name_tree`] lists them, with no key budget. It keeps no key, and converts a key - /// object or `/Names` array that several places reference only once (a second visit cannot - /// match, since the first would have returned). - fn name_tree_find(&self, node: &'a Dictionary, key: &str, seen: &mut NameTreeSeen, depth: u32) -> Option<&'a Object> { - if depth > 32 || !seen.nodes.insert(node as *const _) { - return None; - } - let names = node.get(b"Names").ok(); - if let Some(names) = names - && self.resolved_id(names).is_none_or(|id| seen.objects.insert(id)) - && let Object::Array(pairs) = self.resolve(names) - { - for pair in pairs.chunks(2) { - if let [k, v] = pair { - if self.resolved_id(k).is_some_and(|id| !seen.objects.insert(id)) { - continue; - } - let found = match self.resolve(k) { - Object::String(bytes, _) => text_string(bytes) == key, - _ => key.is_empty(), - }; - if found { - return Some(v); - } - } - } - } - if let Ok(Object::Array(kids)) = node.get(b"Kids").map(|o| self.resolve(o)) { - for k in kids { - if let Some(d) = self.dict(k) - && let Some(v) = self.name_tree_find(d, key, seen, depth + 1) - { - return Some(v); - } - } - } - None - } - - // ── outline ───────────────────────────────────────────────────────────────────────────── - - /// The window `DocInfo::outline` keeps: the first `OUTLINE_WINDOW` bookmarks in outline order, - /// nested no deeper than `OUTLINE_DEPTH`, and whether any bookmark was left out. Iterative, and - /// each bookmark is read once, so the work and memory stay within the window. - fn outline_window(&self, first: &'a Object) -> (Vec, bool) { - let mut seen = HashSet::new(); - // The listed bookmarks in outline order, with their depth. - let mut listed: Vec<(usize, OutlineItem)> = Vec::new(); - // One entry per open level: its next sibling, or `None` once that run of siblings has ended. - let mut levels: Vec> = vec![Some(first)]; - let mut more = false; - while let Some(depth) = levels.len().checked_sub(1) { - let Some(slot) = levels.get_mut(depth) else { break }; - let Some(o) = slot.take() else { - levels.pop(); - continue; - }; - if let Object::Reference(id) = o - && !seen.insert(*id) - { - continue; // cycle: this run of siblings ends here - } - let Some(d) = self.dict(o) else { continue }; - if listed.len() >= OUTLINE_WINDOW { - more = true; - break; - } - *slot = d.get(b"Next").ok(); - let dest = d - .get(b"Dest") - .ok() - .and_then(|dest| self.dest(dest, 0)) - .or_else(|| d.get(b"A").ok().and_then(|a| self.dict(a)).and_then(|a| a.get(b"D").ok()).and_then(|dest| self.dest(dest, 0))); - let (page, view) = dest.map_or((None, DestView::Top), |(p, v)| (Some(p), v)); - match d.get(b"First").ok() { - Some(child) if depth < OUTLINE_DEPTH => levels.push(Some(child)), - Some(_) => more = true, - None => {} - } - let open = d.get(b"Count").ok().and_then(|c| c.as_i64().ok()).is_some_and(|c| c > 0); - let title = self.text(d, b"Title").unwrap_or_default(); - let color = match d.get(b"C").map(|o| self.resolve(o)) { - Ok(Object::Array(c)) => outline_color(c.iter().map(|v| self.resolve(v).as_float().ok())), - _ => None, - }; - let flags = d.get(b"F").ok().and_then(|f| self.resolve(f).as_i64().ok()).unwrap_or(0); - let (italic, bold) = (flags & 1 != 0, flags & 2 != 0); - listed.push((depth, OutlineItem { title, page, view, children: Vec::new(), open, color, italic, bold })); - } - (nest_outline(listed), more) - } - - // ── page labels (ISO 32000-2 §12.4.2) ─────────────────────────────────────────────────── - - fn label_object(&self, object: &'a Object) -> Result<&'a Object, &'static str> { - self.doc.dereference(object).map(|(_, o)| o).map_err(|_| "a number tree reference is missing or cyclic") - } - - fn label_dict(&self, object: &'a Object) -> Result, &'static str> { - Ok(match self.label_object(object)? { - Object::Dictionary(d) => Some(d), - Object::Stream(s) => Some(&s.dict), - _ => None, - }) - } - - /// Apply `/PageLabels` to `pages`. `Err`: the tree is unusable and no label is applied. - /// `Ok(notes)`: labels applied, with what was repaired on the way (a duplicated start page, - /// a range whose labels are too long to show). - fn page_labels(&self, catalog: &Dictionary, pages: &mut [PageInfo]) -> Result, &'static str> { - let mut notes = Vec::new(); - let Ok(tree) = catalog.get(b"PageLabels") else { return Ok(notes) }; - let Some(tree) = self.label_dict(tree)? else { return Ok(notes) }; - let mut ranges: BTreeMap = BTreeMap::new(); - let mut stack = vec![(tree, 0usize)]; - let mut seen = HashSet::new(); - let mut left = MAX_LABEL_TREE_WORK; - while let Some((node, depth)) = stack.pop() { - label_work(&mut left, 1)?; - if depth > MAX_LABEL_TREE_DEPTH { - return Err("the number tree is too deep"); - } - if !seen.insert(node as *const Dictionary) { - return Err("the number tree repeats a node or contains a cycle"); - } - if let Ok(nums) = node.get(b"Nums") - && let Object::Array(nums) = self.label_object(nums)? - { - label_work(&mut left, nums.len().div_ceil(2))?; - for pair in nums.chunks(2) { - if let [k, v] = pair - && let (Ok(start), Some(d)) = (self.label_object(k)?.as_i64(), self.label_dict(v)?) - && let Ok(start) = usize::try_from(start) - && ranges.insert(start, d).is_some() - { - // The later range wins, as in readers that overwrite. - notes.push(format!("a range starts on page {} twice; the later one is used", start.saturating_add(1))); - } - } - } - if let Ok(kids) = node.get(b"Kids") - && let Object::Array(kids) = self.label_object(kids)? - { - label_work(&mut left, kids.len())?; - if kids.len() > MAX_LABEL_TREE_WORK.saturating_sub(stack.len()) { - return Err("the number tree has too many pending nodes"); - } - if !kids.is_empty() && depth == MAX_LABEL_TREE_DEPTH { - return Err("the number tree is too deep"); - } - for kid in kids { - if let Some(d) = self.label_dict(kid)? { - stack.push((d, depth + 1)); - } - } - } - } - // Decode each bounded prefix once, rather than once for every page in its range. - let mut specs = BTreeMap::new(); - let mut prefix_total = 0usize; - for (start, d) in ranges { - let first = d.get(b"St").ok().and_then(|o| self.resolve(o).as_i64().ok()).unwrap_or(1).max(1) as u64; - let prefix = match d.get(b"P").ok().map(|o| self.resolve(o)) { - Some(Object::String(raw, _)) => { - if raw.len() > MAX_PREFIX_BYTES { - return Err("a raw prefix exceeds 2050 bytes"); - } - // A label prefix is literal text, not trimmed document metadata. - text_string(raw).trim_matches('\0').to_string() - } - Some(Object::Name(raw)) => { - if raw.len() > MAX_PREFIX_BYTES { - return Err("a raw prefix exceeds 2050 bytes"); - } - // Retain the existing name compatibility and boundary-NUL handling. - String::from_utf8_lossy(raw).trim_matches('\0').to_string() - } - _ => String::new(), - }; - if prefix.len() > MAX_LABEL_BYTES { - return Err("a decoded prefix exceeds 1024 UTF-8 bytes"); - } - // A prefix object shared by many ranges is decoded once per range: count every copy. - prefix_total = prefix_total.saturating_add(prefix.len()); - if prefix_total > MAX_PREFIX_TOTAL_BYTES { - return Err("decoded prefixes exceed 4 MiB in total"); - } - let style = d.get(b"S").ok().and_then(|o| self.resolve(o).as_name().ok()); - specs.insert(start, (first, prefix, style)); - } - let mut staged = Vec::new(); - let mut total = 0usize; - for (i, _) in pages.iter().enumerate() { - let Some((&start, (first, prefix, style))) = specs.range(..=i).next_back() else { continue }; - let n = if matches!(*style, Some(b"D" | b"R" | b"r" | b"A" | b"a")) { - let offset = u64::try_from(i - start).map_err(|_| "a page number is too large")?; - first.checked_add(offset).ok_or("a page number overflows")? - } else { - 0 // /St is unused when the label consists only of its prefix. - }; - let remaining = MAX_LABEL_BYTES - prefix.len(); - let number = match *style { - Some(b"D") => Some(n.to_string()), - Some(b"R" | b"r") => roman(n, remaining), - Some(b"A" | b"a") => alpha(n, remaining), - _ => Some(String::new()), - } - .filter(|number| number.len() <= remaining); - // A label too long to show keeps that page's physical number; other ranges stay. - let Some(mut number) = number else { - let note = format!( - "labels from page {} exceed {MAX_LABEL_BYTES} UTF-8 bytes; those pages show their physical page numbers", - start.saturating_add(1) - ); - if !notes.contains(¬e) { - notes.push(note); - } - continue; - }; - if matches!(*style, Some(b"R" | b"A")) { - number.make_ascii_uppercase(); - } - let mut label = prefix.clone(); - label.push_str(&number); - if label.is_empty() { - continue; // Keep the existing physical number for an empty custom label. - } - total = total.checked_add(label.len()).ok_or("the total label size overflows")?; - if total > MAX_LABEL_TOTAL_BYTES { - return Err("custom labels exceed 4 MiB in total"); - } - staged.push((i, label)); - } - // A malformed later range must not leave a partially applied custom label sequence. - for (i, label) in staged { - if let Some(page) = pages.get_mut(i) { - page.label = label; - } - } - Ok(notes) - } - - // ── annotations ───────────────────────────────────────────────────────────────────────── - - /// `text_budget` is what is left of [`MAX_LINK_ATTACHMENT_TEXT_BYTES`]. - fn annotations(&self, info: &mut DocInfo, text_budget: &mut usize) { - let mut pages: Vec<(ObjectId, usize)> = self.page_index.iter().map(|(&id, &page)| (id, page)).collect(); - pages.sort_unstable_by_key(|&(_, page)| page); - // An annotation, or a whole `/Annots` array, that several entries or pages list is on the - // first of them (§12.5.2 allows one page per annotation). Both are told apart by the object - // a reference resolves to, since an indirect object may itself hold a reference. Where each - // annotation is listed is collected before any is read, so a reply's `/IRT` finds its - // parent by reference even when the parent comes later. - let mut seen_arrays = HashSet::new(); - let mut slots: HashMap = HashMap::new(); - let mut entries = Vec::new(); - for (id, page) in pages { - let Ok(page_dict) = self.doc.get_dictionary(id) else { continue }; - if let Ok(annots) = page_dict.get(b"Annots") - && let Some(r) = self.resolved_id(annots) - && !seen_arrays.insert(r) - { - continue; - } - let Ok(Object::Array(annots)) = page_dict.get(b"Annots").map(|o| self.resolve(o)) else { continue }; - for (index, a) in annots.iter().enumerate() { - if let Some(r) = self.resolved_id(a) { - let Entry::Vacant(slot) = slots.entry(r) else { continue }; - slot.insert((page, index)); - } - entries.push((page, index, a)); - } - } - let mut kept = 0usize; - // Once the comment budget is spent, comments are no longer built, but links and attachments - // are still listed. - let mut comments_full = false; - for (page, index, a) in entries { - let Some(d) = self.dict(a) else { continue }; - let Some(subtype) = self.name(d, b"Subtype") else { continue }; - if subtype == "FileAttachment" - && let Ok(fs) = d.get(b"FS") - { - let fallback = |budget: &mut usize| self.budgeted_text(d, b"Contents", budget); - info.attachments.push(self.attachment(fs, AttachmentSource::Annotation { page, index }, text_budget, fallback)); - } - if subtype == "Link" { - if let Some(link) = self.link(page, index, d, text_budget) { - if matches!(&link.target, LinkTarget::Other(s) if s == "JavaScript") { - info.has_javascript = true; - } - info.links.push(link); - } - continue; - } - // Not comments: form widgets, pop-ups and non-markup annotations such as the - // Screen annotation that drives a LaTeX `animate` player (keep in step with - // `pdfcraft_annot::is_comment_subtype`). - if matches!(subtype.as_str(), "Widget" | "Popup" | "Screen" | "Movie" | "RichMedia" | "3D" | "PrinterMark" | "TrapNet" | "Watermark") { - continue; - } - if comments_full { - continue; - } - let rect = rect4(self.resolve(d.get(b"Rect").unwrap_or(&Object::Null))); - let mut color = match d.get(b"C").map(|o| self.resolve(o)) { - Ok(Object::Array(c)) if c.len() == 3 => { - let f = |i: usize| c[i].as_float().unwrap_or(0.0); - Some([f(0), f(1), f(2)]) - } - _ => None, - }; - // A text box's /C is its background; its text colour is in /DA. - if subtype == "FreeText" - && let Some(da) = self.text(d, b"DA") - { - let t: Vec<&str> = da.split_whitespace().collect(); - if let Some(i) = t.iter().position(|x| *x == "rg") - && i >= 3 - { - let f = |k: usize| t[k].parse::().unwrap_or(0.0); - color = Some([f(i - 3), f(i - 2), f(i - 1)]); - } - } - let quads = match d.get(b"QuadPoints").map(|o| self.resolve(o)) { - Ok(Object::Array(q)) => { - let v: Vec = q.iter().map(|o| o.as_float().unwrap_or(0.0)).collect(); - v.as_chunks::<8>().0.to_vec() - } - _ => Vec::new(), - }; - let in_reply_to = d.get(b"IRT").ok().and_then(|o| self.dict(o)).and_then(|p| self.text(p, b"NM")); - let parent = d.get(b"IRT").ok().and_then(|o| self.resolved_id(o)).and_then(|r| slots.get(&r).copied()); - let fill_sign = self.is_fill_sign(d, &subtype); - // Keep in step with `pdfcraft_annot::summaries`. - let key: &[u8] = if subtype == "Line" { b"L" } else { b"Vertices" }; - let points = match d.get(key).map(|o| self.resolve(o)) { - Ok(Object::Array(v)) if matches!(subtype.as_str(), "Line" | "PolyLine" | "Polygon") => { - let v: Vec = v.iter().map(|o| o.as_float().unwrap_or(0.0)).collect(); - let pts: Vec<[f32; 2]> = v.as_chunks::<2>().0.to_vec(); - if subtype == "Line" && pts.len() != 2 { Vec::new() } else { pts } - } - _ => Vec::new(), - }; - let annotation = Annotation { - page, - subtype, - author: self.text(d, b"T"), - // Acrobat ends lines with CR, which the interface would draw as nothing (keep - // in step with `pdfcraft_annot::summaries`). - contents: self.text(d, b"Contents").map(|s| s.replace("\r\n", "\n").replace('\r', "\n")), - modified: self.text(d, b"M").map(|m| pretty_date(&m)), - name: self.text(d, b"NM"), - in_reply_to, - parent, - grouped: self.name(d, b"RT").as_deref() == Some("Group"), - rect, - color, - index, - state: self.text(d, b"State"), - quads, - locked: d.get(b"F").ok().and_then(|f| self.resolve(f).as_i64().ok()).unwrap_or(0) & 128 != 0, - intent: self.name(d, b"IT"), - fill_sign, - no_rotate: d.get(b"F").is_ok_and(|f| draw_flags(self.resolve(f)) & 16 != 0), - points, - }; - kept = kept.saturating_add(retained(&annotation)); - if kept > MAX_ANNOTATION_BYTES { - comments_full = true; - continue; - } - info.annotations.push(annotation); - } - info.annotations.sort_by(|a, b| a.page.cmp(&b.page).then(b.rect[3].total_cmp(&a.rect[3]))); - link_replies(&mut info.annotations); - } - - fn link(&self, page: usize, index: usize, d: &Dictionary, text_budget: &mut usize) -> Option { - let rect = rect4(self.resolve(d.get(b"Rect").ok()?)); - let target = if let Ok(dest) = d.get(b"Dest") { - let (page, view) = self.dest(dest, 0)?; - LinkTarget::Page(page, view) - } else { - let a = d.get(b"A").ok().and_then(|a| self.dict(a))?; - match self.name(a, b"S").as_deref() { - Some("GoTo") => { - let (page, view) = self.dest(a.get(b"D").ok()?, 0)?; - LinkTarget::Page(page, view) - } - Some("URI") => { - let uri = a.get(b"URI").ok().and_then(|u| self.resolve(u).as_str().ok())?; - match spend(uri, text_budget, |b| String::from_utf8_lossy(b).into_owned()) { - Some(uri) => LinkTarget::Uri(uri), - None => LinkTarget::Other("URI".into()), - } - } - Some("SetOCGState") => self.layer_state(a), - Some("GoToR") => self.remote_go_to(a).unwrap_or_else(|| LinkTarget::Other("GoToR".into())), - Some("Launch") => self.launch_pdf(a).unwrap_or_else(|| LinkTarget::Other("Launch".into())), - Some(other) => LinkTarget::Other(other.to_string()), - None => return None, - } - }; - Some(Link { page, index, rect, target }) - } - - fn remote_go_to(&self, action: &Dictionary) -> Option { - let file = self.action_file(action)?; - let destination = match action.get(b"D") { - Err(_) => RemoteDestination::Default, - Ok(o) => match self.resolve(o) { - Object::Array(items) => { - let page = items.first().and_then(|o| self.resolve(o).as_i64().ok()).and_then(|n| usize::try_from(n).ok())?; - let kind = items.get(1).map(|o| self.resolve(o)).and_then(|o| o.as_name().ok()); - let operands: [Option; 4] = std::array::from_fn(|i| items.get(i + 2).and_then(|o| self.resolve(o).as_float().ok())); - RemoteDestination::Page { page, view: DestView::from_operands(kind, &operands) } - } - Object::Name(name) if name.len() <= MAX_REMOTE_DESTINATION_NAME_BYTES => { - let name = String::from_utf8_lossy(name).into_owned(); - if name.is_empty() || name.len() > MAX_REMOTE_DESTINATION_NAME_BYTES { - return None; - } - RemoteDestination::Named(name) - } - Object::String(bytes, _) if bytes.len() <= MAX_REMOTE_DESTINATION_NAME_BYTES => { - let name = text_string(bytes); - if name.is_empty() || name.len() > MAX_REMOTE_DESTINATION_NAME_BYTES { - return None; - } - RemoteDestination::Named(name) - } - _ => return None, - }, - }; - Some(LinkTarget::RemoteGoTo { launch: false, file, destination, new_window: self.new_window(action) }) - } - - /// The non-empty local file an action's `/F` names (a string or a file specification), shared - /// by GoToR and Launch. A URL file system (`/FS /URL`) names no local file. - fn action_file(&self, action: &Dictionary) -> Option { - if self.name(action, b"FS").as_deref() == Some("URL") { - return None; - } - let file = match self.resolve(action.get(b"F").ok()?) { - Object::String(bytes, _) => limited_pdf_string(bytes, MAX_REMOTE_FILE_SPEC_BYTES)?, - Object::Dictionary(spec) => { - if self.name(spec, b"FS").as_deref() == Some("URL") { - return None; - } - spec.get(b"UF") - .ok() - .and_then(|o| self.pdf_text(o, MAX_REMOTE_FILE_SPEC_BYTES)) - .or_else(|| spec.get(b"F").ok().and_then(|o| self.pdf_text(o, MAX_REMOTE_FILE_SPEC_BYTES)))? - } - _ => return None, - }; - (!file.is_empty()).then_some(file) - } - - fn new_window(&self, action: &Dictionary) -> Option { - match action.get(b"NewWindow").map(|o| self.resolve(o)) { - Ok(Object::Boolean(value)) => Some(*value), - _ => None, - } - } - - /// A Launch action (ISO 32000-2 §12.6.4.6, Table 207) whose `/F` names a `.pdf` file, read as - /// the remote Go-To to that file's first page that it amounts to (#4168). Launching anything - /// else, or with the deprecated `/Win` launch parameters (an application, an operation, its - /// arguments), is not followed: a document must not start programs. `\` separators, which - /// Windows writers put in such paths, are read as `/` (§7.11.2.1 separates components with - /// `/`) when the path has no `/` of its own. Launch has no `/D`, so one present (written - /// for GoToR, or junk) is ignored rather than checked as a destination. - fn launch_pdf(&self, action: &Dictionary) -> Option { - if action.has(b"Win") { - return None; - } - let file = self.action_file(action)?; - let new_window = self.new_window(action); - let file = if file.contains('/') { file } else { file.replace('\\', "/") }; - let pdf = file.rsplit('/').next().is_some_and(|name| name.len() > 4 && name.to_ascii_lowercase().ends_with(".pdf")); - pdf.then_some(LinkTarget::RemoteGoTo { launch: true, file, destination: RemoteDestination::Default, new_window }) - } - - fn pdf_text(&self, o: &Object, max_bytes: usize) -> Option { - match self.resolve(o) { - Object::String(bytes, _) => limited_pdf_string(bytes, max_bytes), - _ => None, - } - } - - /// A set-OCG-state action: `/State` is `ON`, `OFF` or `Toggle`, each followed by the groups - /// it applies to; `/PreserveRB` is true unless it is `false`. - fn layer_state(&self, a: &Dictionary) -> LinkTarget { - let mut changes = Vec::new(); - if let Ok(Object::Array(items)) = a.get(b"State").map(|o| self.resolve(o)) { - let mut op = None; - for item in items.iter().take(MAX_LAYER_STATE) { - match item { - Object::Name(n) => { - op = match n.as_slice() { - b"ON" => Some(LayerOp::On), - b"OFF" => Some(LayerOp::Off), - b"Toggle" => Some(LayerOp::Toggle), - _ => None, - } - } - Object::Reference(id) => changes.extend(op.map(|op| (op, *id))), - _ => {} - } - } - } - let preserve_rb = !matches!(a.get(b"PreserveRB").map(|o| self.resolve(o)), Ok(Object::Boolean(false))); - LinkTarget::SetLayers { changes, preserve_rb } - } - - // ── form fields ───────────────────────────────────────────────────────────────────────── - - fn field( - &self, - o: &'a Object, - parent: Option<(&str, Option, Option)>, - out: &mut Vec, - seen: &mut HashSet, - depth: u32, - ) { - if depth > 32 { - return; - } - if let Object::Reference(id) = o - && !seen.insert(*id) - { - return; - } - let Some(d) = self.dict(o) else { return }; - let (parent_name, parent_ft, parent_ff) = parent.unwrap_or(("", None, None)); - let partial = self.text(d, b"T"); - let full = match (&partial, parent_name.is_empty()) { - (Some(t), true) => t.clone(), - (Some(t), false) => format!("{parent_name}.{t}"), - (None, _) => parent_name.to_string(), - }; - let ft = self.name(d, b"FT").or(parent_ft); - let ff = d.get(b"Ff").ok().and_then(|x| x.as_i64().ok()).or(parent_ff); - // Non-terminal: has kids that are fields (they carry /T). Widgets-only kids are terminal. - let kids = match d.get(b"Kids").map(|k| self.resolve(k)) { - Ok(Object::Array(k)) => k.as_slice(), - _ => &[], - }; - let field_kids: Vec<_> = kids.iter().filter(|k| self.dict(k).is_some_and(|kd| kd.get(b"T").is_ok())).collect(); - if !field_kids.is_empty() { - for k in field_kids { - self.field(k, Some((&full, ft.clone(), ff)), out, seen, depth + 1); - } - return; - } - let flags = ff.unwrap_or(0); - let kind = match ft.as_deref() { - Some("Tx") => FieldKind::Text, - Some("Btn") if flags & (1 << 16) != 0 => FieldKind::PushButton, - Some("Btn") if flags & (1 << 15) != 0 => FieldKind::Radio, - Some("Btn") => FieldKind::CheckBox, - Some("Ch") if flags & (1 << 17) != 0 => FieldKind::Combo, - Some("Ch") => FieldKind::List, - Some("Sig") => FieldKind::Signature, - _ => FieldKind::Unknown, - }; - let value = match d.get(b"V").map(|v| self.resolve(v)) { - Ok(Object::Name(n)) => Some(String::from_utf8_lossy(n).into_owned()), - Ok(Object::String(bytes, _)) => Some(text_string(bytes)), - Ok(Object::Array(a)) => Some( - a.iter() - .filter_map(|x| match self.resolve(x) { - Object::String(bytes, _) => Some(text_string(bytes)), - _ => None, - }) - .collect::>() - .join(", "), - ), - Ok(Object::Dictionary(_)) if kind == FieldKind::Signature => Some("signed".into()), - _ => None, - }; - // The widget is either this dict (merged field/widget) or its first kid. - let widget = if d.get(b"Rect").is_ok() { Some(d) } else { kids.first().and_then(|k| self.dict(k)) }; - let page = widget.and_then(|w| w.get(b"P").ok()).and_then(|p| self.page_of(p)).or_else(|| self.find_widget_page(o, kids)); - let has_actions = d.get(b"AA").is_ok() || widget.is_some_and(|w| w.get(b"AA").is_ok() || w.get(b"A").is_ok()); - let rect = widget.and_then(|w| w.get(b"Rect").ok()).map(|r| rect4(self.resolve(r))); - out.push(Field { name: full, kind, value, page, tooltip: self.text(d, b"TU"), has_actions, rect }); - } - - /// The first page whose `/Annots` lists the field or one of its kids. - fn find_widget_page(&self, field: &Object, kids: &[Object]) -> Option { - let pages = self.annotation_pages.get_or_init(|| self.annotation_pages()); - std::iter::once(field).chain(kids).filter_map(|o| if let Object::Reference(id) = o { pages.get(id).copied() } else { None }).min() - } - - /// Each annotation listed in a page's `/Annots`, with the first page that lists it. - fn annotation_pages(&self) -> HashMap { - let mut pages = HashMap::new(); - for (idx, &pid) in self.page_ids.iter().enumerate() { - let Some(annots) = self.doc.get_dictionary(pid).ok().and_then(|d| d.get(b"Annots").ok()) else { continue }; - let Ok(annots) = self.resolve(annots).as_array() else { continue }; - for a in annots { - if let Object::Reference(id) = a { - pages.entry(*id).or_insert(idx); - } - } - } - pages - } - - /// The topmost field above widget `id` (itself without a `/Parent`), or `None` when it or an - /// ancestor is already reachable from the `/Fields` tree, or a `/Parent` loop hides the top. - fn unlisted_field(&self, id: ObjectId, seen: &HashSet) -> Option { - let mut top = id; - let mut visited = HashSet::from([id]); - loop { - let o = Object::Reference(top); - let d = self.dict(&o)?; - let parent = match d.get(b"Parent") { - Ok(Object::Reference(p)) => Some(*p), - _ => None, - }; - let Some(p) = parent else { - return if seen.contains(&top) { None } else { Some(top) }; - }; - if !visited.insert(p) || visited.len() > 64 { - return None; - } - if seen.contains(&p) { - return None; - } - top = p; - } - } - - // ── optional content ──────────────────────────────────────────────────────────────────── - - fn layers(&self, catalog: &Dictionary, out: &mut Vec) { - let Some(props) = catalog.get(b"OCProperties").ok().and_then(|o| self.dict(o)) else { return }; - let config = props.get(b"D").ok().and_then(|o| self.dict(o)); - let off: HashSet = config - .and_then(|c| c.get(b"OFF").ok()) - .and_then(|o| self.resolve(o).as_array().ok()) - .map(|a| a.iter().filter_map(|x| x.as_reference().ok()).collect()) - .unwrap_or_default(); - let base_off = config.and_then(|c| self.name(c, b"BaseState")).as_deref() == Some("OFF"); - let on: HashSet = config - .and_then(|c| c.get(b"ON").ok()) - .and_then(|o| self.resolve(o).as_array().ok()) - .map(|a| a.iter().filter_map(|x| x.as_reference().ok()).collect()) - .unwrap_or_default(); - let Ok(Object::Array(ocgs)) = props.get(b"OCGs").map(|o| self.resolve(o)) else { return }; - for g in ocgs { - let Some(d) = self.dict(g) else { continue }; - let id = g.as_reference().ok(); - let visible = match id { - Some(id) if off.contains(&id) => false, - Some(id) if on.contains(&id) => true, - _ => !base_off, - }; - let Some(id) = id else { continue }; - out.push(Layer { id, name: self.text(d, b"Name").unwrap_or_else(|| "Layer".into()), visible, initial: visible }); - } - } - - /// The default configuration's radio-button groups (`/RBGroups`) of two or more layers. - fn layer_groups(&self, catalog: &Dictionary) -> Vec> { - let groups = catalog - .get(b"OCProperties") - .ok() - .and_then(|o| self.dict(o)) - .and_then(|p| p.get(b"D").ok()) - .and_then(|o| self.dict(o)) - .and_then(|c| c.get(b"RBGroups").ok()) - .and_then(|o| self.resolve(o).as_array().ok()); - let mut left = MAX_LAYER_GROUP_ENTRIES; - let mut out = Vec::new(); - for g in groups.into_iter().flatten() { - let Ok(members) = self.resolve(g).as_array() else { continue }; - let group: Vec = members.iter().filter_map(|x| x.as_reference().ok()).take(left).collect(); - left = left.saturating_sub(group.len()); - if group.len() > 1 { - out.push(group); - } - if left == 0 { - break; - } - } - out - } - - fn fonts(&self, out: &mut Vec) { - let mut seen = HashSet::new(); - for &pid in &self.page_ids { - let Ok(fonts) = self.doc.get_page_fonts(pid) else { continue }; - for (_, f) in fonts { - let base = f - .get(b"BaseFont") - .ok() - .and_then(|o| o.as_name().ok()) - .map(|n| String::from_utf8_lossy(n).into_owned()) - .unwrap_or_else(|| "(unnamed)".into()); - let kind = self.name(f, b"Subtype").unwrap_or_default(); - let encoding = match f.get(b"Encoding").map(|o| self.resolve(o)) { - Ok(Object::Name(n)) => Some(String::from_utf8_lossy(n).into_owned()), - Ok(Object::Dictionary(_)) => Some("Custom".into()), - Ok(Object::Stream(_)) => Some("Embedded CMap".into()), - _ => None, - }; - // Embedded: FontFile/2/3 in the descriptor (for Type0, in the descendant font). - let descriptor_of = |d: &'a Dictionary| d.get(b"FontDescriptor").ok().and_then(|o| self.dict(o)); - let descendant = - f.get(b"DescendantFonts").ok().and_then(|o| self.resolve(o).as_array().ok()).and_then(|a| a.first()).and_then(|o| self.dict(o)); - let desc = descriptor_of(f).or_else(|| descendant.and_then(descriptor_of)); - let embedded = - kind == "Type3" || desc.is_some_and(|d| d.get(b"FontFile").is_ok() || d.get(b"FontFile2").is_ok() || d.get(b"FontFile3").is_ok()); - // What the substitution chain needs beyond the name: the descriptor flags, and for - // a composite font the script its descendant declares (ISO 32000-2 9.7.3). - let flags = desc - .and_then(|d| d.get(b"Flags").ok()) - .and_then(|o| self.resolve(o).as_i64().ok()) - .and_then(|n| u32::try_from(n).ok()) - .unwrap_or(0); - let ordering = - descendant.and_then(|d| d.get(b"CIDSystemInfo").ok()).and_then(|o| self.dict(o)).and_then(|c| self.text(c, b"Ordering")); - let subset = base.len() > 7 && base.as_bytes()[6] == b'+' && base[..6].bytes().all(|b| b.is_ascii_uppercase()); - let name = if subset { base[7..].to_string() } else { base }; - let chosen = pdfcraft_fonts::fallback::substitute(&pdfcraft_fonts::fallback::Request { - base_font: &name, - embedded, - flags, - ordering: ordering.as_deref(), - }); - let info = FontInfo { - name, - kind, - embedded, - subset, - encoding, - substitute: chosen.face_name(), - substitution: (!chosen.why.is_faithful()).then(|| chosen.why.note().to_owned()), - }; - if seen.insert((info.name.clone(), info.kind.clone(), info.embedded)) { - out.push(info); - } - } - } - out.sort_by(|a, b| a.name.cmp(&b.name)); - } - - /// The attachment's name is the file specification's, else `fallback`'s, else a generic one. - fn attachment( - &self, - spec: &Object, - source: AttachmentSource, - text_budget: &mut usize, - fallback: impl FnOnce(&mut usize) -> Option, - ) -> Attachment { - let d = self.dict(spec); - let size = - d.and_then(|d| d.get(b"EF").ok()).and_then(|o| self.dict(o)).and_then(|ef| ef.get(b"F").ok().or_else(|| ef.get(b"UF").ok())).and_then( - |f| match self.resolve(f) { - Object::Stream(s) => s - .dict - .get(b"Params") - .ok() - .and_then(|p| self.dict(p)) - .and_then(|p| p.get(b"Size").ok()) - .and_then(|s| s.as_i64().ok()) - .map(|s| s as usize) - .or(Some(s.content.len())), - _ => None, - }, - ); - let display = d - .and_then(|d| self.budgeted_text(d, b"UF", text_budget).or_else(|| self.budgeted_text(d, b"F", text_budget))) - .or_else(|| fallback(text_budget)) - .unwrap_or_else(|| "attachment".into()); - Attachment { name: display, description: d.and_then(|d| self.budgeted_text(d, b"Desc", text_budget)), size, source } - } -} - -/// A text string's value, decoded by the same rules as the engine's object model -/// ([`pdfcraft_cos::PdfString::to_text`]) so the panels and outline editing agree. -fn text_string(bytes: &[u8]) -> String { - pdfcraft_cos::PdfString::literal(bytes).to_text() -} - -/// What [`Inspector::name_tree_find`] has visited: node dictionaries, and the key and `/Names` -/// array objects it reached by reference. -#[derive(Default)] -struct NameTreeSeen { - nodes: HashSet<*const Dictionary>, - objects: HashSet, -} - -/// A string or name value as text, without surrounding NULs and white space. -fn trimmed_text(o: &Object) -> Option { - let s = match o { - Object::String(bytes, _) => text_string(bytes), - Object::Name(n) => String::from_utf8_lossy(n).into_owned(), - _ => return None, - }; - Some(s.trim_matches('\0').trim().to_string()) -} - -/// `convert(bytes)` when the result fits in `budget`, which it then spends by the longer of the raw -/// and converted lengths. Bytes longer than the budget are not converted. A conversion that comes -/// out too long spends the raw length, so a string many entries share is converted only until the -/// budget runs out. -fn spend(bytes: &[u8], budget: &mut usize, convert: impl FnOnce(&[u8]) -> String) -> Option { - if bytes.len() > *budget { - return None; - } - let text = convert(bytes); - if text.len() > *budget { - *budget = budget.saturating_sub(bytes.len()); - return None; - } - // The conversion's work follows the raw length, even when the text it keeps is shorter. - *budget = budget.saturating_sub(text.len().max(bytes.len())); - Some(text) -} - -fn limited_pdf_string(bytes: &[u8], max_bytes: usize) -> Option { - if bytes.len() > max_bytes { - return None; - } - let text = text_string(bytes); - (text.len() <= max_bytes).then_some(text) -} - -/// A bookmark title's colour from its `/C` entries, each kept within 0–1 (ISO 32000-2 §12.3.3, -/// Table 151: "An array of three numbers in the range 0.0 to 1.0, representing the components in -/// the DeviceRGB colour space"). Black, the default, and anything that isn't three numbers give -/// `None`. -pub(crate) fn outline_color(values: impl Iterator>) -> Option<[f32; 3]> { - let v: Vec> = values.take(4).collect(); - let [Some(r), Some(g), Some(b)] = v.as_slice() else { return None }; - // A number past the range, however large, is kept at its edge. - let rgb = [*r, *g, *b].map(|c| c.clamp(0.0, 1.0)); - (rgb != [0.0; 3]).then_some(rgb) -} - -/// Nests a pre-order list of `(depth, item)` into the outline tree. -fn nest_outline(listed: Vec<(usize, OutlineItem)>) -> Vec { - let mut roots = Vec::new(); - // The open ancestors, outermost first; each collects its children until it closes. - let mut open: Vec<(usize, OutlineItem)> = Vec::new(); - for (depth, item) in listed { - while open.last().is_some_and(|(d, _)| *d >= depth) { - close_outline(&mut open, &mut roots); - } - open.push((depth, item)); - } - while !open.is_empty() { - close_outline(&mut open, &mut roots); - } - roots -} - -/// Closes the innermost open bookmark: it joins its parent's children, or the top level. -fn close_outline(open: &mut Vec<(usize, OutlineItem)>, roots: &mut Vec) { - if let Some((_, item)) = open.pop() { - match open.last_mut() { - Some((_, parent)) => parent.children.push(item), - None => roots.push(item), - } - } -} - -/// An annotation's `/F` as the renderer reads it: a real is truncated, and a value that isn't a -/// `u32` counts as no flags. -fn draw_flags(o: &Object) -> u32 { - let f = match o { - Object::Integer(i) => *i, - Object::Real(r) => *r as i64, - _ => 0, - }; - u32::try_from(f).unwrap_or(0) -} - -fn rect4(o: &Object) -> [f32; 4] { - match o { - Object::Array(a) if a.len() == 4 => { - let v: Vec = a.iter().map(|x| x.as_float().unwrap_or(0.0)).collect(); - [v[0].min(v[2]), v[1].min(v[3]), v[0].max(v[2]), v[1].max(v[3])] - } - _ => [0.0; 4], - } -} - -fn label_work(left: &mut usize, count: usize) -> Result<(), &'static str> { - *left = left.checked_sub(count).ok_or("the number tree has too many entries")?; - Ok(()) -} - -/// `D:20260930104512-04'00'` → `2026-09-30 10:45`. -/// A PDF date (`D:20261001123000Z`) as "2026-10-01 12:30"; other strings unchanged. -pub fn pretty_date(s: &str) -> String { - let d = s.trim_start_matches("D:"); - if let Some(d) = d.get(..12).filter(|d| d.bytes().all(|b| b.is_ascii_digit())) { - // Twelve ASCII digits make every slice below a UTF-8 character boundary. - format!("{}-{}-{} {}:{}", &d[0..4], &d[4..6], &d[6..8], &d[8..10], &d[10..12]) - } else { - s.to_string() - } -} - -/// Fetch an attachment's bytes (decoded). Capped at 1 GiB to defuse decompression bombs. -pub fn attachment_data(bytes: &[u8], password: Option<&str>, att: &Attachment) -> Result, String> { - let run = || -> Result, String> { - let options = load_options(password); - let doc = Document::load_mem_with_options(bytes, options).map_err(|e| e.to_string())?; - let insp = Inspector::new(&doc); - let spec: &Object = match &att.source { - AttachmentSource::Document { key } => { - let names = doc.catalog().ok().and_then(|c| c.get(b"Names").ok()).and_then(|o| insp.dict(o)).ok_or("no /Names")?; - let ef = names.get(b"EmbeddedFiles").ok().and_then(|o| insp.dict(o)).ok_or("no /EmbeddedFiles")?; - insp.name_tree_find(ef, key, &mut NameTreeSeen::default(), 0).ok_or("attachment not found")? - } - AttachmentSource::Annotation { page, index } => { - let (&pid, _) = insp.page_index.iter().find(|(_, i)| **i == *page).ok_or("page not found")?; - let annots = doc.get_dictionary(pid).map_err(|e| e.to_string())?.get(b"Annots").map_err(|e| e.to_string())?; - let a = insp.resolve(annots).as_array().map_err(|e| e.to_string())?.get(*index).ok_or("annotation not found")?; - insp.dict(a).and_then(|d| d.get(b"FS").ok()).ok_or("annotation has no file")? - } - }; - let fs = insp.dict(spec).ok_or("bad file specification")?; - let ef = fs.get(b"EF").ok().and_then(|o| insp.dict(o)).ok_or("the file is not embedded (external reference)")?; - let stream = ef.get(b"UF").ok().or_else(|| ef.get(b"F").ok()).map(|o| insp.resolve(o)).ok_or("no embedded stream")?; - let stream = stream.as_stream().map_err(|e| e.to_string())?; - if stream.dict.get(b"Filter").is_ok() { - stream.decompressed_content_with_limit(1 << 30).map_err(|e| e.to_string()) - } else { - Ok(stream.content.clone()) - } - }; - catch_unwind(AssertUnwindSafe(run)).unwrap_or_else(|p| Err(format!("reading the attachment crashed: {}", crate::raster::panic_message(&p)))) -} - -/// An XFA packet's bytes as text: UTF-16 (little- or big-endian, told by a byte order mark or by -/// how `<` is written) or UTF-8, as `pdfcraft_xfa::packets::decode` reads them. Lossy: only one -/// element is looked for. -fn xfa_packet_text(bytes: &[u8]) -> String { - let utf16 = |b: &[u8], from: fn([u8; 2]) -> u16| { - let units = b.as_chunks::<2>().0.iter().map(|c| from(*c)); - char::decode_utf16(units).map(|c| c.unwrap_or(char::REPLACEMENT_CHARACTER)).collect::() - }; - match bytes { - [0xFF, 0xFE, rest @ ..] => utf16(rest, u16::from_le_bytes), - [0xFE, 0xFF, rest @ ..] => utf16(rest, u16::from_be_bytes), - [b'<', 0, ..] => utf16(bytes, u16::from_le_bytes), - [0, b'<', ..] => utf16(bytes, u16::from_be_bytes), - [0xEF, 0xBB, 0xBF, rest @ ..] => String::from_utf8_lossy(rest).into_owned(), - _ => String::from_utf8_lossy(bytes).into_owned(), - } -} - -/// One markup tag found by [`next_tag`]: where it starts (`<`) and ends (after `>`), whether it -/// closes (``) or closes itself (``), and its name without a namespace prefix. -struct Tag<'a> { - start: usize, - end: usize, - close: bool, - empty: bool, - local: &'a str, -} - -/// The next element tag in `xml` at or after `from`, skipping comments, CDATA sections, -/// processing instructions and declarations. A byte scan, not an XML parser: enough to find one -/// known element in an XFA packet, whose tags Designer writes with a line break before each `>`. -fn next_tag(xml: &str, mut from: usize) -> Option> { - loop { - let lt = from + xml.get(from..)?.find('<')?; - let rest = xml.get(lt..)?; - let skip_to = |end: &str| rest.find(end).map(|i| lt + i + end.len()); - if rest.starts_with("")?; - continue; - } - if rest.starts_with("")?; - continue; - } - if rest.starts_with("")?; - continue; - } - let gt = lt + rest.find('>')?; - let inner = xml.get(lt + 1..gt)?; - let (close, inner) = match inner.strip_prefix('/') { - Some(i) => (true, i), - None => (false, inner), - }; - let empty = !close && inner.ends_with('/'); - let name = inner.split(|c: char| c.is_whitespace() || c == '/').next().unwrap_or_default(); - let local = name.rsplit(':').next().unwrap_or(name); - return Some(Tag { start: lt, end: gt + 1, close, empty, local }); - } -} - -/// What `…` holds: the content of the first top-level element of `xml` called `name` -/// (any prefix). Elements nested in other elements are not looked at. `None` when there is none, -/// it closes itself, or it is never closed. -fn xml_child_body<'a>(xml: &'a str, name: &str) -> Option<&'a str> { - let mut at = 0; - // The elements open around `at`, innermost last: a closing tag must name the innermost one, - // or the markup is malformed and has no value. - let mut open_tags: Vec<&str> = Vec::new(); - // Where the wanted element's content starts, once its opening tag is seen at the top level. - let mut open = None; - loop { - let tag = next_tag(xml, at)?; - at = tag.end; - if tag.close { - if open_tags.pop()? != tag.local { - return None; - } - if open_tags.is_empty() - && let Some(start) = open - { - return xml.get(start..tag.start); - } - } else if !tag.empty { - if open_tags.is_empty() && tag.local == name { - open = Some(tag.end); - } - open_tags.push(tag.local); - } else if open_tags.is_empty() && tag.local == name { - return None; - } - } -} - -/// The trimmed text at `path` (element names from the outside in, any prefix, each a child of the -/// one before) in `xml`, if it is plain text. -fn xml_path_text(xml: &str, path: &[&str]) -> Option { - let body = path.iter().try_fold(xml, |within, name| xml_child_body(within, name))?; - let text = body.trim(); - (!text.is_empty() && !text.contains('<')).then(|| text.to_owned()) -} - -/// `dynamicRender` at `config/acrobat/acrobat7` in the configuration packet: the `config` child -/// of the `xdp` root in a whole-XDP stream, or the `config` packet itself. The same names inside -/// the form data or the template are not the configuration. -fn xfa_config_dynamic_render(packet: &str) -> Option { - const IN_CONFIG: [&str; 4] = ["config", "acrobat", "acrobat7", "dynamicRender"]; - xml_path_text(packet, &["xdp", IN_CONFIG[0], IN_CONFIG[1], IN_CONFIG[2], IN_CONFIG[3]]).or_else(|| xml_path_text(packet, &IN_CONFIG)) -} - #[cfg(test)] -mod tests { - #[test] - fn png_predictor_rows_longer_than_the_data_are_refused_before_allocating() { - // Vendored lopdf patch: a fuzzed `/Columns 4294967295` allocated two 4 GiB rows before - // reading any data. A row that cannot fit in the data is refused up front. - let e = lopdf::filters::png::decode_frame(&[2, 0, 0, 0], 1, 64 << 20).unwrap_err(); - assert!(e.to_string().contains("longer than the data"), "{e}"); - // A real frame still decodes: two 3-byte rows, the second Up-filtered. - assert_eq!(lopdf::filters::png::decode_frame(&[0, 1, 2, 3, 2, 1, 1, 1], 1, 3).unwrap(), [1, 2, 3, 2, 3, 4]); - } - - #[test] - fn empty_png_predictor_frames_do_not_allocate_rows() { - // usize::MAX cannot be reserved, so the old code safely errors before allocating. - // An empty frame needs no rows regardless of the declared width. - assert!(lopdf::filters::png::decode_frame(&[], 1, usize::MAX).unwrap().is_empty()); - } - - fn tiff_predictor_stream(data: Vec, columns: i64, colors: i64, bits: i64) -> lopdf::Stream { - let mut params = lopdf::Dictionary::new(); - params.set("Predictor", 2i64); - params.set("Columns", columns); - params.set("Colors", colors); - params.set("BitsPerComponent", bits); - let mut dict = lopdf::Dictionary::new(); - dict.set("DecodeParms", params); - let mut stream = lopdf::Stream::new(dict, data); - stream.compress().unwrap(); - assert_eq!(stream.dict.get(b"Filter").unwrap().as_name().unwrap(), b"FlateDecode"); - stream - } - - #[test] - fn tiff_subbyte_predictor_row_width_overflow_is_refused() { - // Each multiplication overflows before any scratch allocation in the old debug build. - for bits in [1, 2, 4] { - let stream = tiff_predictor_stream(vec![0; 64], i64::MAX, 3, bits); - assert!( - matches!(stream.decompressed_content_with_limit(64), Err(lopdf::Error::Decompress(lopdf::DecompressError::Predictor(_)))), - "{bits}-bit row" - ); - } - } - - #[test] - fn tiff_subbyte_predictor_scratch_is_bounded_by_available_samples() { - // 1/2-bit row widths fit usize on 32- and 64-bit hosts; Colors previously made Vec - // reject the capacity before allocating. A partial row has no preceding pixel. - for bits in [1, 2] { - let data = vec![0b1010_0110; 64]; - let stream = tiff_predictor_stream(data.clone(), 1, i64::try_from(isize::MAX).unwrap(), bits); - assert_eq!(stream.decompressed_content_with_limit(64).unwrap(), data, "{bits}-bit row"); - } - } - - #[test] - fn tiff_subbyte_predictors_keep_components_rows_and_padding() { - // Repeated tiny rows compress through the public Stream API. Rows remain independent, - // differences wrap at each component depth, and trailing padding bits survive. - for (columns, colors, bits, encoded, decoded) in [ - (16, 1, 1, vec![255, 170, 8, 255], vec![170, 204, 15, 85]), - (6, 1, 2, vec![85, 179], vec![108, 147]), - (3, 1, 4, vec![25, 16], vec![26, 176]), - (2, 2, 4, vec![18, 34], vec![18, 52]), - ] { - let stream = tiff_predictor_stream(encoded.repeat(32), columns, colors, bits); - assert_eq!(stream.decompressed_content_with_limit(128).unwrap(), decoded.repeat(32), "{bits}-bit, {colors} colours"); - } - } - - // Xref-stream fixtures exercise indirect /Length lookup during document loading: stream 2's /Length is `3 0 R`, - // `containers` lists compressed (id, container, index) entries and `objects` the other objects in the file. - fn compressed_length_pdf(containers: &[(u32, u32, u16)], objects: &[(u32, &[u8])]) -> Vec { - let mut pdf = b"%PDF-1.7\n".to_vec(); - let ids = containers.iter().flat_map(|&(id, container, _)| [id, container]).chain(objects.iter().map(|&(id, _)| id)); - let xref_id = ids.max().unwrap_or(3) + 1; - let mut records = vec![[0u8; 7]; (xref_id + 1) as usize]; - records[0] = [0, 0, 0, 0, 0, 255, 255]; - let mut append = |id: u32, body: &[u8]| { - records[id as usize][0] = 1; - records[id as usize][1..5].copy_from_slice(&(pdf.len() as u32).to_be_bytes()); - pdf.extend_from_slice(format!("{id} 0 obj\n").as_bytes()); - pdf.extend_from_slice(body); - pdf.extend_from_slice(b"\nendobj\n"); - }; - append(1, b"<< /Type /Catalog >>"); - append(2, b"<< /Length 3 0 R >>\nstream\nhello\nendstream"); - for &(id, body) in objects { - append(id, body); - } - for &(id, container, index) in containers { - records[id as usize][0] = 2; - records[id as usize][1..5].copy_from_slice(&container.to_be_bytes()); - records[id as usize][5..7].copy_from_slice(&index.to_be_bytes()); - } - let xref_offset = pdf.len(); - records[xref_id as usize][0] = 1; - records[xref_id as usize][1..5].copy_from_slice(&(xref_offset as u32).to_be_bytes()); - pdf.extend_from_slice( - format!("{xref_id} 0 obj\n<< /Type /XRef /Size {} /Root 1 0 R /W [1 4 2] /Length {} >>\nstream\n", records.len(), records.len() * 7) - .as_bytes(), - ); - for record in records { - pdf.extend_from_slice(&record); - } - pdf.extend_from_slice(format!("\nendstream\nendobj\nstartxref\n{xref_offset}\n%%EOF\n").as_bytes()); - pdf - } - - // Stream 2's /Length starts a chain of `hops` indirect /Length lookups, each naming a new object; only the last - // link's /Length is direct. Compressed: link k is the integer 3 + 2k (the length it measures) alone in the object - // stream 4 + 2k, whose own /Length is link k + 1. Plain: link k is the stream 3 + k, whose /Length is link k + 1. - fn length_chain_pdf(hops: u32, compressed: bool) -> Vec { - let length = |hop: u32, next: u32, data: &str| if hop + 1 < hops { format!("{next} 0 R") } else { data.len().to_string() }; - let (mut containers, mut objects) = (Vec::new(), Vec::new()); - let mut value = 5; // the length of stream 2's data, "hello" - for hop in 0..hops { - if compressed { - let (id, container) = (3 + 2 * hop, 4 + 2 * hop); - let index = format!("{id} 0 "); - let data = format!("{index}{value}"); - let dict = format!("/Type /ObjStm /N 1 /First {} /Length {}", index.len(), length(hop, id + 2, &data)); - objects.push((container, format!("<< {dict} >>\nstream\n{data}\nendstream"))); - containers.push((id, container, 0)); - value = data.len(); - } else { - objects.push((3 + hop, format!("<< /Length {} >>\nstream\n\nendstream", length(hop, 4 + hop, "")))); - } - } - let objects: Vec<_> = objects.iter().map(|(id, body)| (*id, body.as_bytes())).collect(); - compressed_length_pdf(&containers, &objects) - } - - // Lenient loading keeps a document whose stream 2 has an unresolvable /Length: the catalog, and stream 2's - // dictionary without its data. - fn load_without_stream_2_data(pdf: &[u8]) -> Document { - let doc = Document::load_mem(pdf).unwrap(); - assert!(doc.catalog().unwrap().has_type(b"Catalog")); - assert!(doc.get_object((2, 0)).unwrap().as_stream().unwrap().content.is_empty()); - doc - } - - #[test] - fn compressed_object_self_container_does_not_recurse() { - let doc = load_without_stream_2_data(&compressed_length_pdf(&[(3, 3, 0)], &[])); - assert!(doc.get_object((3, 0)).is_err()); - } - - #[test] - fn compressed_object_mutual_containers_do_not_recurse() { - let doc = load_without_stream_2_data(&compressed_length_pdf(&[(3, 4, 0), (4, 3, 0)], &[])); - assert!(doc.get_object((3, 0)).is_err()); - assert!(doc.get_object((4, 0)).is_err()); - } - - #[test] - fn object_stream_length_cycle_does_not_recurse() { - let pdf = compressed_length_pdf(&[(3, 4, 0)], &[(4, b"<< /Type /ObjStm /N 1 /First 4 /Length 3 0 R >>\nstream\n3 0 5\nendstream")]); - let doc = load_without_stream_2_data(&pdf); - // The container's /Length is the object it holds: the container is kept without data, so 3 never loads. - assert!(doc.get_object((4, 0)).unwrap().as_stream().unwrap().content.is_empty()); - assert!(doc.get_object((3, 0)).is_err()); - } - - #[test] - fn compressed_object_container_chain_does_not_recurse() { - let containers: Vec<_> = (3..100_003).map(|id| (id, id + 1, 0)).collect(); - let pdf = compressed_length_pdf(&containers, &[(100_003, b"<< /Type /ObjStm /N 1 /First 4 /Length 5 >>\nstream\n3 0 5\nendstream")]); - let doc = load_without_stream_2_data(&pdf); - // The object stream in the file loads, but the xref puts 3 in the compressed container 4, which is refused. - assert_eq!(doc.get_object((100_003, 0)).unwrap().as_stream().unwrap().content, b"3 0 5"); - assert!(doc.get_object((3, 0)).is_err()); - } - - #[test] - fn compressed_objects_and_indirect_stream_length_still_load() { - let pdf = compressed_length_pdf( - &[(3, 4, 0), (5, 4, 1)], - &[(4, b"<< /Type /ObjStm /N 2 /First 8 /Length 17 >>\nstream\n3 0 5 2 5 (value)\nendstream")], - ); - let doc = Document::load_mem_with_options(&pdf, LoadOptions { strict: true, ..Default::default() }).unwrap(); - assert_eq!(doc.get_object((3, 0)).unwrap().as_i64().unwrap(), 5); - assert_eq!(doc.get_object((5, 0)).unwrap().as_str().unwrap(), b"value"); - assert_eq!(doc.get_object((2, 0)).unwrap().as_stream().unwrap().content, b"hello"); - } - - #[test] - fn compressed_length_in_a_container_with_an_indirect_length_still_loads() { - // Two hops, as in a valid file: stream 2's /Length (3) is compressed in container 4, whose own /Length (5) is - // a plain indirect integer. - let pdf = - compressed_length_pdf(&[(3, 4, 0)], &[(4, b"<< /Type /ObjStm /N 1 /First 4 /Length 5 0 R >>\nstream\n3 0 5\nendstream"), (5, b"5")]); - let doc = Document::load_mem_with_options(&pdf, LoadOptions { strict: true, ..Default::default() }).unwrap(); - assert_eq!(doc.get_object((3, 0)).unwrap().as_i64().unwrap(), 5); - assert_eq!(doc.get_object((5, 0)).unwrap().as_i64().unwrap(), 5); - assert_eq!(doc.get_object((2, 0)).unwrap().as_stream().unwrap().content, b"hello"); - } - - #[test] - fn compressed_length_chain_does_not_recurse() { - // Every container is an ordinary object in the file and every hop names a new object, so neither the - // container check nor the cycle history stops the chain; each hop recursed once more (a 512 KiB stack - // overflowed within 150 hops). Lookups past the cap fail: the first link never loads, while the last one, - // next to the direct /Length, does. - let hops = 5_000; - let doc = load_without_stream_2_data(&length_chain_pdf(hops, true)); - assert!(doc.get_object((3, 0)).is_err()); - assert!(doc.get_object((3 + 2 * (hops - 1), 0)).unwrap().as_i64().is_ok()); - // The cap itself: loading link j reads its container's /Length, a chain of 2 * (hops - 1 - j) lookups, and - // 16 are allowed, so the 9th link from the end loads and the 10th does not. - assert!(doc.get_object((3 + 2 * (hops - 9), 0)).is_ok()); - assert!(doc.get_object((3 + 2 * (hops - 10), 0)).is_err()); - } - - #[test] - fn plain_stream_length_chain_does_not_recurse() { - // The same recursion without object streams: each stream's /Length names the next stream (a 512 KiB stack - // overflowed within 200 hops). A stream is no length, so every link is kept as a stream without data. - let hops = 5_000; - let doc = load_without_stream_2_data(&length_chain_pdf(hops, false)); - assert!((3..3 + hops).all(|id| doc.get_object((id, 0)).is_ok_and(|link| link.as_stream().is_ok_and(|s| s.content.is_empty())))); - } - - fn indexed_object_stream(pairs: &[(u32, usize)], members: &[u8]) -> lopdf::Stream { - let mut content = Vec::new(); - for &(id, offset) in pairs { - content.extend_from_slice(format!("{id} {offset} ").as_bytes()); - } - let mut dict = Dictionary::new(); - dict.set("Type", "ObjStm"); - dict.set("N", pairs.len() as i64); - dict.set("First", content.len() as i64); - content.extend_from_slice(members); - lopdf::Stream::new(dict, content) - } - - #[test] - fn object_stream_member_extents_bound_owned_strings() { - let mut members = vec![b'('; 32]; - members.extend(vec![b'x'; 1 << 20]); - members.extend([b')'; 32]); - // Put the innermost (complete) string first in index order, then overlapping starts - // and duplicates. Only this first member at offset 31 may own the large string. - let mut pairs = vec![(100, 31)]; - pairs.extend((0..31).map(|offset| (offset as u32 + 1, offset))); - pairs.extend([(200, 31), (201, 31)]); - let stream = indexed_object_stream(&pairs, &members); - let parsed = lopdf::ObjectStream::new_with_limit(&stream, Some(2 << 20)).unwrap(); - assert_eq!(parsed.objects.get(&(100, 0)).unwrap().as_str().unwrap().len(), 1 << 20); - let total: usize = parsed.objects.values().filter_map(|object| object.as_str().ok()).map(<[u8]>::len).sum(); - assert!(total <= stream.content.len(), "owned {total} string bytes from {} decoded bytes", stream.content.len()); - assert!(!parsed.objects.contains_key(&(200, 0))); - assert!(!parsed.objects.contains_key(&(201, 0))); - } - - #[test] - fn object_stream_member_extents_preserve_index_order_and_whitespace() { - let members = b" \n42\n /Name \n[true 7]\n << /Key (value) >>\n (last)"; - // Unsorted offsets, and object 9 appears twice: its last index entry still wins. - let pairs = [(9, 0), (12, 21), (10, 5), (11, 12), (9, 41)]; - let parsed = lopdf::ObjectStream::new(&indexed_object_stream(&pairs, members)).unwrap(); - assert_eq!(parsed.objects.len(), 4); - assert_eq!(parsed.objects.get(&(9, 0)).unwrap().as_str().unwrap(), b"last"); - assert_eq!(parsed.objects.get(&(10, 0)).unwrap().as_name().unwrap(), b"Name"); - assert_eq!(parsed.objects.get(&(11, 0)).unwrap().as_array().unwrap(), &[Object::Boolean(true), Object::Integer(7)]); - assert_eq!(parsed.objects.get(&(12, 0)).unwrap().as_dict().unwrap().get(b"Key").unwrap().as_str().unwrap(), b"value"); - } - - #[test] - fn view_and_user_space_round_trip_for_every_rotation() { - for rotation in [0u16, 90, 180, 270] { - let (w, h) = if rotation % 180 == 0 { (200.0, 300.0) } else { (300.0, 200.0) }; - let p = super::PageInfo { width: w, height: h, label: String::new(), crop: [10.0, 20.0, 210.0, 320.0], rotation }; - for (x, y) in [(0.0, 0.0), (15.0, 40.0), (w, h)] { - let [ux, uy] = p.view_to_user(x, y); - let [vx, vy] = p.user_to_view(ux, uy); - assert!((vx - x).abs() < 1e-3 && (vy - y).abs() < 1e-3, "{rotation}: {x},{y} → {ux},{uy} → {vx},{vy}"); - } - } - // Unrotated: the view's top-left is the crop box's top-left. - let p = super::PageInfo { width: 200.0, height: 300.0, label: String::new(), crop: [10.0, 20.0, 210.0, 320.0], rotation: 0 }; - assert_eq!(p.view_to_user(0.0, 0.0), [10.0, 320.0]); - assert_eq!(p.view_rect_to_quad([0.0, 0.0, 10.0, 5.0]), [10.0, 320.0, 20.0, 320.0, 10.0, 315.0, 20.0, 315.0]); - } - - /// #2583: a NoRotate comment is drawn upright with its top-left as displayed at the upper-left - /// corner of `/Rect`, its width across and its height down; a box resized as drawn maps back to - /// the `/Rect` that shows it. Other comments, and pages that aren't quarter-turned, keep `/Rect`. - #[test] - fn no_rotate_comments_are_shown_upright_from_their_corner_and_back() { - let mut note = super::Annotation { - page: 0, - subtype: "Text".into(), - author: None, - contents: None, - modified: None, - name: None, - in_reply_to: None, - parent: None, - grouped: false, - rect: [40.0, 50.0, 70.0, 70.0], - color: None, - index: 0, - state: None, - quads: Vec::new(), - locked: false, - intent: None, - fill_sign: false, - no_rotate: true, - points: Vec::new(), - }; - let rect = note.rect.map(f64::from); - for rotation in [0u16, 90, 180, 270, 45] { - let (w, h) = if rotation % 180 == 90 { (140.0, 200.0) } else { (200.0, 140.0) }; - let p = super::PageInfo { width: w, height: h, label: String::new(), crop: [0.0, 0.0, 200.0, 140.0], rotation }; - let shown = note.shown_rect(rotation); - let (a, b) = (p.user_to_view(shown[0], shown[1]), p.user_to_view(shown[2], shown[3])); - let [cx, cy] = p.user_to_view(40.0, 70.0); - let want = [cx, cy, cx + 30.0, cy + 20.0]; - let got = [a[0].min(b[0]), a[1].min(b[1]), a[0].max(b[0]), a[1].max(b[1])]; - assert!(got.iter().zip(want).all(|(g, w)| (g - w).abs() < 1e-3), "{rotation}: shown {got:?}, want {want:?}"); - assert_eq!(note.rect_for_shown(note.shown(rect, rotation), rotation), rect, "{rotation}"); - // Moved by (5, -7) and resized to 50 × 10 as drawn, back to a /Rect. - let resized = [shown[0] as f64 + 5.0, shown[1] as f64 - 7.0, 0.0, 0.0]; - let across = if rotation % 180 == 90 { [10.0, 50.0] } else { [50.0, 10.0] }; - let resized = [resized[0], resized[1], resized[0] + across[0], resized[1] + across[1]]; - let back = note.rect_for_shown(resized, rotation); - assert_eq!((back[2] - back[0], back[3] - back[1]), (50.0, 10.0), "{rotation}: {back:?}"); - assert_eq!(note.shown(back, rotation), resized, "{rotation}"); - } - note.no_rotate = false; - assert_eq!(note.shown_rect(90), note.rect); - assert_eq!(note.rect_for_shown([1.0, 2.0, 3.0, 4.0], 90), [1.0, 2.0, 3.0, 4.0]); - } - - use super::*; - - #[test] - fn roman_and_alpha_labels() { - assert_eq!(roman(4, MAX_LABEL_BYTES).as_deref(), Some("iv")); - assert_eq!(roman(1994, MAX_LABEL_BYTES).as_deref(), Some("mcmxciv")); - assert_eq!(alpha(1, MAX_LABEL_BYTES).as_deref(), Some("a")); - assert_eq!(alpha(27, MAX_LABEL_BYTES).as_deref(), Some("aa")); - assert_eq!(alpha(53, MAX_LABEL_BYTES).as_deref(), Some("aaa")); - } - - // Synthetic label-only inspection fixtures: never construct an explosive branching tree. - fn label_info(nums: Vec, kids: Vec) -> DocInfo { - let mut doc = Document::with_version("1.7"); - let mut tree = Dictionary::new(); - tree.set("Nums", nums); - tree.set("Kids", kids); - doc.objects.insert((6, 0), Object::Dictionary(tree)); - let mut catalog = Dictionary::new(); - catalog.set("Type", Object::Name(b"Catalog".to_vec())); - catalog.set("PageLabels", Object::Reference((6, 0))); - doc.objects.insert((1, 0), Object::Dictionary(catalog)); - doc.trailer.set("Root", Object::Reference((1, 0))); - let mut info = DocInfo { - pages: (1..=3) - .map(|n| PageInfo { width: 200.0, height: 300.0, crop: [0.0, 0.0, 200.0, 300.0], rotation: 0, label: n.to_string() }) - .collect(), - ..Default::default() - }; - Inspector::new(&doc).fill(&mut info); - info - } - - fn label_spec(style: &str, prefix: Vec, first: i64) -> Object { - let mut d = Dictionary::new(); - d.set("S", Object::Name(style.as_bytes().to_vec())); - d.set("P", Object::String(prefix, lopdf::StringFormat::Hexadecimal)); - d.set("St", first); - Object::Dictionary(d) - } - - fn assert_label_fallback(info: &DocInfo) { - assert_eq!(info.pages.iter().map(|p| p.label.as_str()).collect::>(), ["1", "2", "3"]); - assert_eq!(info.warnings.len(), 1, "{:?}", info.warnings); - assert!(info.warnings[0].contains("Page labels"), "{:?}", info.warnings); - assert!(info.warnings[0].contains("physical page numbers"), "{:?}", info.warnings); - } - - // Contributor-original, blank three-page PDFs. The public importer reads both labels and metadata. - fn page_prefix_space_pdf(prefix: Vec, decimal: bool, indirect: bool) -> Vec { - use lopdf::dictionary; - let mut doc = Document::with_version("1.7"); - let pages_id = doc.new_object_id(); - let kids: Vec = (0..3) - .map(|_| { - doc.add_object(dictionary! { - "Type" => "Page", - "Parent" => pages_id, - "MediaBox" => vec![0.into(), 0.into(), 200.into(), 300.into()], - "Resources" => Dictionary::new(), - }) - .into() - }) - .collect(); - doc.objects.insert(pages_id, dictionary! { "Type" => "Pages", "Kids" => kids, "Count" => 3 }.into()); - let prefix = Object::String(prefix, lopdf::StringFormat::Hexadecimal); - let prefix = if indirect { Object::Reference(doc.add_object(prefix)) } else { prefix }; - let mut label = Dictionary::new(); - label.set("P", prefix); - if decimal { - label.set("S", Object::Name(b"D".to_vec())); - } - let labels = dictionary! { "Nums" => vec![Object::Integer(0), Object::Dictionary(label)] }; - let catalog_id = doc.add_object(dictionary! { "Type" => "Catalog", "Pages" => pages_id, "PageLabels" => labels }); - let info_id = doc.add_object(dictionary! { "Title" => Object::string_literal(" Metadata title ") }); - doc.trailer.set("Root", catalog_id); - doc.trailer.set("Info", info_id); - let mut bytes = Vec::new(); - doc.save_to(&mut bytes).expect("write original page-prefix fixture"); - bytes - } - - #[test] - fn page_label_prefix_spaces_survive_public_inspection() { - let mut utf16 = vec![0xfe, 0xff]; - utf16.extend(" Ω ".encode_utf16().flat_map(u16::to_be_bytes)); - for (raw, prefix, decimal, indirect) in [ - (b" leading".to_vec(), " leading", true, false), - (b"trailing ".to_vec(), "trailing ", true, false), - (b" both ".to_vec(), " both ", false, false), - (b" ".to_vec(), " ", false, false), - (b" Caf\xe9 ".to_vec(), " Café ", true, false), - (utf16.clone(), " Ω ", true, false), - (utf16, " Ω ", false, true), - (Vec::new(), "", true, false), - (Vec::new(), "", false, false), - ] { - let bytes = page_prefix_space_pdf(raw, decimal, indirect); - let info = inspect(Arc::new(bytes), None).expect("public import of original page-prefix fixture"); - let expected: Vec = (1..=3) - .map(|n| { - if decimal { - format!("{prefix}{n}") - } else if prefix.is_empty() { - n.to_string() - } else { - prefix.to_owned() - } - }) - .collect(); - assert_eq!( - info.pages.iter().map(|p| p.label.clone()).collect::>(), - expected, - "{prefix:?}, decimal={decimal}, indirect={indirect}" - ); - assert!(info.warnings.is_empty(), "{:?}", info.warnings); - assert_eq!(info.title.as_deref(), Some("Metadata title"), "page-prefix decoding must not change metadata normalization"); - } - } - - #[test] - fn page_label_limits_keep_normal_pdf_numbering() { - for (style, first, expected) in [ - ("r", 4, ["iv", "v", "vi"]), - ("R", 1994, ["MCMXCIV", "MCMXCV", "MCMXCVI"]), - ("a", 26, ["z", "aa", "bb"]), - ("A", 52, ["ZZ", "AAA", "BBB"]), - ] { - let info = label_info(vec![Object::Integer(0), label_spec(style, Vec::new(), first)], Vec::new()); - assert_eq!(info.pages.iter().map(|p| p.label.as_str()).collect::>(), expected); - assert!(info.warnings.is_empty(), "{:?}", info.warnings); - } - } - - #[test] - fn page_label_limits_reject_long_prefixes_without_partial_labels() { - for prefix in [vec![b'x'; 1025], vec![b'x'; 2051]] { - let nums = vec![Object::Integer(0), label_spec("D", b"ok-".to_vec(), 1), Object::Integer(1), label_spec("D", prefix, 1)]; - assert_label_fallback(&label_info(nums, Vec::new())); - } - } - - #[test] - fn page_label_limits_count_decoded_utf8_bytes() { - let mut prefix = vec![0xfe, 0xff]; - for _ in 0..342 { - prefix.extend_from_slice(&0x2022u16.to_be_bytes()); - } - assert_label_fallback(&label_info(vec![Object::Integer(0), label_spec("", prefix, 1)], Vec::new())); - } - - #[test] - fn page_label_limits_bound_roman_and_alpha_output() { - // Just 1025 output bytes on the old path, so RED does not consume excessive memory. - for (style, first) in [("r", 1_025_000), ("a", 26 * 1024 + 1)] { - assert_label_fallback(&label_info(vec![Object::Integer(0), label_spec(style, Vec::new(), first)], Vec::new())); - } - } - - #[test] - fn page_label_limits_detect_one_node_cycle() { - let nums = vec![Object::Integer(0), label_spec("D", b"custom-".to_vec(), 1)]; - assert_label_fallback(&label_info(nums, vec![Object::Reference((6, 0))])); - } - - #[test] - fn page_label_limits_accept_utf16_ascii_at_the_output_boundary() { - let mut prefix = vec![0xfe, 0xff]; - for _ in 0..1023 { - prefix.extend_from_slice(&u16::from(b'x').to_be_bytes()); - } - let info = label_info(vec![Object::Integer(0), label_spec("D", prefix, 1)], Vec::new()); - assert!(info.warnings.is_empty(), "{:?}", info.warnings); - assert_eq!(info.pages[0].label, format!("{}1", "x".repeat(1023))); - assert!(info.pages.iter().all(|p| p.label.len() == MAX_LABEL_BYTES)); - } - - #[test] - fn page_label_limits_refuse_hostile_numbers_before_allocating() { - for style in ["r", "R", "a", "A"] { - assert_label_fallback(&label_info(vec![Object::Integer(0), label_spec(style, Vec::new(), i64::MAX)], Vec::new())); - } - // Decimal labels remain small even when their first number exceeds 32-bit usize. - let info = label_info(vec![Object::Integer(0), label_spec("D", Vec::new(), i64::MAX)], Vec::new()); - assert_eq!(info.pages[0].label, i64::MAX.to_string()); - assert_eq!(info.pages[1].label, (i64::MAX as u64 + 1).to_string()); - assert!(info.warnings.is_empty()); - } - - #[test] - fn page_label_limits_bound_wide_number_trees() { - assert_label_fallback(&label_info(Vec::new(), vec![Object::Null; MAX_LABEL_TREE_WORK + 1])); - let pair = [Object::Integer(0), label_spec("D", Vec::new(), 1)]; - let nums = (0..MAX_LABEL_TREE_WORK).flat_map(|_| pair.clone()).collect(); - assert_label_fallback(&label_info(nums, Vec::new())); - } - - #[test] - fn page_label_limits_bound_total_custom_output_atomically() { - let doc = Document::with_version("1.7"); - let mut tree = Dictionary::new(); - tree.set("Nums", vec![Object::Integer(0), label_spec("", vec![b'x'; MAX_LABEL_BYTES], 1)]); - let mut catalog = Dictionary::new(); - catalog.set("PageLabels", Object::Dictionary(tree)); - let count = MAX_LABEL_TOTAL_BYTES / MAX_LABEL_BYTES + 1; - let mut pages: Vec<_> = (1..=count) - .map(|n| PageInfo { width: 200.0, height: 300.0, crop: [0.0, 0.0, 200.0, 300.0], rotation: 0, label: n.to_string() }) - .collect(); - let err = Inspector::new(&doc).page_labels(&catalog, &mut pages).unwrap_err(); - assert!(err.contains("4 MiB"), "{err}"); - assert!(pages.iter().enumerate().all(|(i, p)| p.label == (i + 1).to_string())); - // Exact aggregate boundary remains supported. - pages.pop(); - Inspector::new(&doc).page_labels(&catalog, &mut pages).unwrap(); - assert!(pages.iter().all(|p| p.label.len() == MAX_LABEL_BYTES)); - } - - #[test] - fn page_label_limits_bound_the_total_of_decoded_prefixes() { - // `ranges` ranges naming one shared prefix object of `prefix` bytes, without a style. - let shared = |ranges: i64, prefix: usize| { - let mut doc = Document::with_version("1.7"); - doc.objects.insert((7, 0), Object::String(vec![b'x'; prefix], lopdf::StringFormat::Literal)); - let mut spec = Dictionary::new(); - spec.set("P", Object::Reference((7, 0))); - doc.objects.insert((8, 0), Object::Dictionary(spec)); - let mut tree = Dictionary::new(); - tree.set("Nums", (0..ranges).flat_map(|i| [Object::Integer(i), Object::Reference((8, 0))]).collect::>()); - let mut catalog = Dictionary::new(); - catalog.set("PageLabels", Object::Dictionary(tree)); - (doc, catalog) - }; - let mut pages: Vec<_> = - (1..=3).map(|n| PageInfo { width: 200.0, height: 300.0, crop: [0.0, 0.0, 200.0, 300.0], rotation: 0, label: n.to_string() }).collect(); - // Each range decodes its prefix, so a small file sharing one prefix object asks for a - // copy per range: 5,000 ranges and a 1,000-byte prefix make 5 MB, past the 4 MiB total. - // Three pages make three labels, far below the label total: the prefixes alone refuse it. - let (doc, catalog) = shared(5000, 1000); - let err = Inspector::new(&doc).page_labels(&catalog, &mut pages).unwrap_err(); - assert!(err.contains("prefixes exceed 4 MiB"), "{err}"); - assert!(pages.iter().enumerate().all(|(i, p)| p.label == (i + 1).to_string())); - // Exactly 4 MiB of prefixes is still read. - let (doc, catalog) = shared((MAX_PREFIX_TOTAL_BYTES / MAX_LABEL_BYTES) as i64, MAX_LABEL_BYTES); - Inspector::new(&doc).page_labels(&catalog, &mut pages).unwrap(); - assert!(pages.iter().all(|p| p.label.len() == MAX_LABEL_BYTES)); - } - - #[test] - fn page_label_limits_handle_prefix_only_and_reference_cycles() { - let info = label_info(vec![Object::Integer(0), label_spec("", b"prefix".to_vec(), i64::MAX)], Vec::new()); - assert!(info.warnings.is_empty()); - assert!(info.pages.iter().all(|p| p.label == "prefix")); - let mut doc = Document::with_version("1.7"); - doc.objects.insert((6, 0), Object::Reference((6, 0))); - let mut catalog = Dictionary::new(); - catalog.set("PageLabels", Object::Reference((6, 0))); - assert!(Inspector::new(&doc).page_labels(&catalog, &mut []).unwrap_err().contains("cyclic")); - } - - #[test] - fn page_label_limits_bound_depth_and_count_combined_work() { - for depth in [MAX_LABEL_TREE_DEPTH, MAX_LABEL_TREE_DEPTH + 1] { - let mut child = Dictionary::new(); - child.set("Nums", vec![Object::Integer(0), label_spec("D", b"custom-".to_vec(), 1)]); - let mut child = Object::Dictionary(child); - for _ in 1..depth { - let mut parent = Dictionary::new(); - parent.set("Kids", vec![child]); - child = Object::Dictionary(parent); - } - let info = label_info(Vec::new(), vec![child]); - if depth == MAX_LABEL_TREE_DEPTH { - assert!(info.warnings.is_empty()); - assert_eq!(info.pages[0].label, "custom-1"); - } else { - assert_label_fallback(&info); - } - } - // Each array alone fits: their combined work still must be bounded. - let half = MAX_LABEL_TREE_WORK / 2; - let spec = label_spec("D", Vec::new(), 1); - let nums = (0..half).flat_map(|i| [Object::Integer(i as i64), spec.clone()]).collect(); - assert_label_fallback(&label_info(nums, vec![Object::Null; half])); - } - - #[test] - fn page_label_limits_keep_the_later_of_duplicate_ranges() { - let nums = vec![Object::Integer(0), label_spec("D", b"first-".to_vec(), 1), Object::Integer(0), label_spec("D", b"last-".to_vec(), 1)]; - let info = label_info(nums, Vec::new()); - assert_eq!(info.pages.iter().map(|p| p.label.as_str()).collect::>(), ["last-1", "last-2", "last-3"]); - assert_eq!(info.warnings.len(), 1, "{:?}", info.warnings); - assert!(info.warnings[0].contains("twice"), "{:?}", info.warnings); - } - - #[test] - fn page_label_limits_keep_other_ranges_when_one_is_too_long() { - // Page 1 is labelled normally; pages 2–3 would need over 1024 bytes of roman numerals. - let nums = vec![Object::Integer(0), label_spec("D", b"ok-".to_vec(), 1), Object::Integer(1), label_spec("r", Vec::new(), 1_025_000)]; - let info = label_info(nums, Vec::new()); - assert_eq!(info.pages.iter().map(|p| p.label.as_str()).collect::>(), ["ok-1", "2", "3"]); - assert_eq!(info.warnings.len(), 1, "{:?}", info.warnings); - assert!(info.warnings[0].contains("physical page numbers"), "{:?}", info.warnings); - } - - const ATTACHMENTS: &[u8] = b"%PDF-1.7 -1 0 obj << /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles << /Names [(notes.txt) 6 0 R] >> >> >> endobj -2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj -3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [4 0 R] /Resources << /Font << /F1 9 0 R >> >> >> endobj -4 0 obj << /Type /Annot /Subtype /FileAttachment /Rect [10 10 30 30] /Contents (data.csv) /FS 8 0 R >> endobj -5 0 obj << /Type /EmbeddedFile /Length 11 >> stream -hello notes -endstream endobj -6 0 obj << /Type /Filespec /F (notes.txt) /UF (notes.txt) /EF << /F 5 0 R >> /Desc (Doc-level) >> endobj -7 0 obj << /Type /EmbeddedFile /Length 5 >> stream -a,b,c -endstream endobj -8 0 obj << /Type /Filespec /F (data.csv) /EF << /F 7 0 R >> >> endobj -9 0 obj << /Type /Font /Subtype /Type1 /BaseFont /ABCDEF+Helvetica >> endobj -trailer << /Root 1 0 R >> -%%EOF"; - - #[test] - fn attachments_of_both_kinds_are_listed_and_extracted() { - let bytes = Arc::new(ATTACHMENTS.to_vec()); - let info = inspect(bytes.clone(), None).expect("opens"); - let names: Vec<_> = info.attachments.iter().map(|a| a.name.as_str()).collect(); - assert!(names.contains(&"notes.txt") && names.contains(&"data.csv"), "{names:?}"); - for a in &info.attachments { - let data = attachment_data(&bytes, None, a).expect("extracts"); - let expected: &[u8] = if a.name == "notes.txt" { b"hello notes" } else { b"a,b,c" }; - assert_eq!(data, expected, "{}", a.name); - } - // A non-embedded /Helvetica is reported as standing in for itself: the standard-14 - // metrics are exact, so the substitution is named rather than left implicit. - assert_eq!( - info.fonts, - vec![FontInfo { - name: "Helvetica".into(), - kind: "Type1".into(), - embedded: false, - subset: true, - encoding: None, - substitute: Some("Helvetica".into()), - substitution: Some(pdfcraft_fonts::fallback::Why::NamedStandard14.note().into()), - }] - ); - } - - const LAYERS: &[u8] = b"%PDF-1.7 -1 0 obj << /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [5 0 R 6 0 R 7 0 R] /D << /OFF [6 0 R] /RBGroups [[5 0 R 6 0 R] [7 0 R] 8 0 R] >> >> >> endobj -2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj -3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [10 0 R 11 0 R] >> endobj -5 0 obj << /Type /OCG /Name (Red) >> endobj -6 0 obj << /Type /OCG /Name (Green) >> endobj -7 0 obj << /Type /OCG /Name (Blue) >> endobj -8 0 obj [6 0 R 7 0 R] endobj -9 0 obj [/OFF 5 0 R] endobj -10 0 obj << /Type /Annot /Subtype /Link /Rect [10 10 50 30] /A << /S /SetOCGState /State [7 0 R /ON 6 0 R /Bogus 5 0 R /Toggle 5 0 R 1 7 0 R] /PreserveRB false >> >> endobj -11 0 obj << /Type /Annot /Subtype /Link /Rect [60 10 100 30] /A << /S /SetOCGState /State 9 0 R >> >> endobj -trailer << /Root 1 0 R >> -%%EOF"; - - /// `/Trans` and `/Dur` (ISO 32000-2 §12.4.4): read with the spec's defaults for what a page - /// leaves out, lenient about what is out of range, and only for pages that have either. - const TRANSITIONS: &[u8] = b"%PDF-1.7 -1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj -2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R 5 0 R 6 0 R 7 0 R] /Count 5 /MediaBox [0 0 200 200] >> endobj -3 0 obj << /Type /Page /Parent 2 0 R /Trans << /Type /Trans /S /Wipe /D 2.5 /Di 270 >> /Dur 5 >> endobj -4 0 obj << /Type /Page /Parent 2 0 R /Dur 3 >> endobj -5 0 obj << /Type /Page /Parent 2 0 R /Trans 8 0 R >> endobj -6 0 obj << /Type /Page /Parent 2 0 R /Trans << /S /Bogus /D (slow) /Di 45 /Dm /Q >> /Dur -1 >> endobj -7 0 obj << /Type /Page /Parent 2 0 R >> endobj -8 0 obj << /S /Fly /M /O /SS 0.5 /B true /Di /None /Dm /V >> endobj -trailer << /Root 1 0 R >> -%%EOF"; - - #[test] - fn page_transitions_and_display_times_are_read() { - use super::TransitionStyle as S; - let info = inspect(Arc::new(TRANSITIONS.to_vec()), None).expect("opens"); - let t = &info.transitions; - assert_eq!(t.iter().map(|t| t.page).collect::>(), [0, 1, 2, 3], "pages with /Trans or /Dur, in order; page 5 has neither"); - assert_eq!((t[0].style, t[0].duration, t[0].direction, t[0].display), (Some(S::Wipe), 2.5, Some(270), Some(5.0))); - assert!((t[0].horizontal, t[0].inward, t[0].scale, t[0].opaque) == (true, true, 1.0, false), "defaults: {:?}", t[0]); - assert_eq!((t[1].style, t[1].duration, t[1].display), (None, 1.0, Some(3.0)), "/Dur without /Trans"); - assert_eq!( - (t[2].style, t[2].inward, t[2].scale, t[2].opaque, t[2].direction, t[2].horizontal), - (Some(S::Fly), false, 0.5, true, None, false), - "an indirect /Trans: {:?}", - t[2] - ); - // Out of range: an unknown style is the spec's default R, a non-numeric /D the default - // second, a direction that is not one of the five falls back to 0, a bad /Dm to - // horizontal, and a negative /Dur is no display time. - assert_eq!( - (t[3].style, t[3].duration, t[3].direction, t[3].horizontal, t[3].display), - (Some(S::Replace), 1.0, Some(0), true, None), - "{:?}", - t[3] - ); - } - - const CUSTOM_INFO: &[u8] = b"%PDF-1.7 -1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj -2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >> endobj -3 0 obj << /Type /Page /Parent 2 0 R >> endobj -4 0 obj << /Title (Report) /Producer (Test) /Project (Apollo) /Revision 7 /Dept (R&D) /Trapped /False /Note >> endobj -trailer << /Root 1 0 R /Info 4 0 R >> -%%EOF"; - - #[test] - fn custom_document_properties_are_listed_apart_from_the_standard_ones() { - let info = inspect(Arc::new(CUSTOM_INFO.to_vec()), None).expect("opens"); - assert_eq!((info.title.as_deref(), info.producer.as_deref()), (Some("Report"), Some("Test"))); - let mut custom = info.custom.clone(); - custom.sort(); - // Revision is a number, not text, so it is not listed; Trapped is a standard entry. - assert_eq!( - custom, - [("Dept".to_string(), "R&D".to_string()), ("Note".to_string(), "部".to_string()), ("Project".to_string(), "Apollo".to_string())] - ); - } - - #[test] - fn radio_button_layer_groups_are_read() { - let info = inspect(Arc::new(LAYERS.to_vec()), None).expect("opens"); - let layers: Vec<_> = info.layers.iter().map(|l| (l.id, l.name.as_str(), l.visible)).collect(); - assert_eq!(layers, [((5, 0), "Red", true), ((6, 0), "Green", false), ((7, 0), "Blue", true)]); - // A group of one constrains nothing; an indirect group is read. - assert_eq!(info.layer_groups, [vec![(5, 0), (6, 0)], vec![(6, 0), (7, 0)]]); - } - - #[test] - fn initial_layer_visibility_is_the_default_configuration() { - // BaseState OFF: Red is listed ON; Green is listed both ON and OFF, and OFF wins; Blue - // isn't listed, so it takes the base state. - let pdf = b"%PDF-1.7 -1 0 obj << /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [5 0 R 6 0 R 7 0 R] /D << /BaseState /OFF /ON [5 0 R 6 0 R] /OFF [6 0 R] >> >> >> endobj -2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj -3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] >> endobj -5 0 obj << /Type /OCG /Name (Red) >> endobj -6 0 obj << /Type /OCG /Name (Green) >> endobj -7 0 obj << /Type /OCG /Name (Blue) >> endobj -trailer << /Root 1 0 R >> -%%EOF"; - let info = inspect(Arc::new(pdf.to_vec()), None).expect("opens"); - let layers: Vec<_> = info.layers.iter().map(|l| (l.name.as_str(), l.initial)).collect(); - assert_eq!(layers, [("Red", true), ("Green", false), ("Blue", false)]); - } - - const ORPHAN_FIELDS: &[u8] = b"%PDF-1.7 -1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >> endobj -2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj -3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [5 0 R 6 0 R] >> endobj -4 0 obj << /Fields [] >> endobj -5 0 obj << /Type /Annot /Subtype /Widget /FT /Tx /T (alpha) /V (one) /Rect [10 100 90 120] >> endobj -6 0 obj << /Type /Annot /Subtype /Widget /FT /Tx /T (beta) /Rect [10 60 90 80] >> endobj -trailer << /Root 1 0 R >> -%%EOF"; - - #[test] - fn fields_listed_only_on_the_pages_are_listed() { - let info = inspect(Arc::new(ORPHAN_FIELDS.to_vec()), None).expect("opens"); - let listed: Vec<_> = info.fields.iter().map(|f| (f.name.as_str(), f.value.as_deref(), f.page)).collect(); - assert_eq!(listed, [("alpha", Some("one"), Some(0)), ("beta", None, Some(0))], "{:?}", info.fields); - } - - /// A `Structure` that counts the objects read through it. - struct CountingReads<'a> { - doc: &'a Document, - reads: std::cell::Cell, - } - - impl Structure for CountingReads<'_> { - fn trailer(&self) -> &Dictionary { - &self.doc.trailer - } - fn get_object(&self, id: ObjectId) -> lopdf::Result<&Object> { - self.reads.set(self.reads.get() + 1); - self.doc.get_object(id) - } - fn encrypted(&self) -> bool { - false - } - } - - /// One comment referenced many times, or a page-shared `/Annots` array, is listed once, and many - /// small comments sharing one large `/Contents` string stop at the retained-bytes budget. - #[test] - fn repeated_annotations_are_listed_once_within_a_byte_budget() { - let small = "<< /Subtype /Text /Rect [0 0 1 1] /Contents 7 0 R >> ".repeat(1200); - let pdf = format!( - "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ - 2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >> endobj\n\ - 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots 5 0 R >> endobj\n\ - 4 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots 5 0 R >> endobj\n\ - 5 0 obj [{} {small}] endobj\n\ - 6 0 obj << /Type /Annot /Subtype /Text /Rect [0 0 1 1] /Contents (note) >> endobj\n\ - 7 0 obj ({}) endobj\ntrailer << /Root 1 0 R >>\n%%EOF", - "6 0 R ".repeat(1000), - "x".repeat(64 << 10), - ); - let doc = Document::load_mem(pdf.as_bytes()).expect("loads"); - let page = PageInfo { width: 200.0, height: 200.0, crop: [0.0, 0.0, 200.0, 200.0], rotation: 0, label: String::new() }; - let mut info = DocInfo { pages: vec![page.clone(), page], ..Default::default() }; - Inspector::new(&doc).fill(&mut info); - let notes = &info.annotations; - assert_eq!(notes.iter().filter(|a| a.contents.as_deref() == Some("note")).count(), 1); - assert!(notes.iter().all(|a| a.page == 0), "the shared array is the first page's"); - assert!(notes.len() > 1); - assert!(notes.iter().map(retained).sum::() <= MAX_ANNOTATION_BYTES); - } - - /// A link reached through an indirect object that holds only a reference to it is the same - /// annotation as the link itself, and so is a page array reached the same way: each is listed once. - #[test] - fn an_annotation_reached_through_a_reference_chain_is_listed_once() { - let pdf = "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ - 2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >> endobj\n\ - 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots 7 0 R >> endobj\n\ - 4 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots 8 0 R >> endobj\n\ - 5 0 obj 6 0 R endobj\n\ - 6 0 obj << /Type /Annot /Subtype /Link /Rect [0 0 1 1] /A << /S /URI /URI (https://example.com) >> >> endobj\n\ - 7 0 obj [5 0 R 6 0 R] endobj\n\ - 8 0 obj 7 0 R endobj\n\ - trailer << /Root 1 0 R >>\n%%EOF"; - let mut doc = Document::load_mem(pdf.as_bytes()).expect("loads"); - let mut saved = Vec::new(); - doc.save_to(&mut saved).expect("saves"); - let lazy = LazyStructure::new(Arc::new(saved), None).expect("opens"); - let page = PageInfo { width: 200.0, height: 200.0, crop: [0.0, 0.0, 200.0, 200.0], rotation: 0, label: String::new() }; - for reader in [&doc as &dyn Structure, &lazy] { - let mut info = DocInfo { pages: vec![page.clone(), page.clone()], ..Default::default() }; - Inspector::new(reader).fill(&mut info); - assert_eq!(info.links.len(), 1, "{:?}", info.links); - } - } - - /// A reply finds its comment where the listing keeps it: on the first page of a page-shared - /// `/Annots` array, whether its `/IRT` names the comment or an object holding a reference to it. - #[test] - fn a_reply_finds_its_comment_through_a_reference_chain_and_a_shared_array() { - let pdf = "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ - 2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >> endobj\n\ - 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots 7 0 R >> endobj\n\ - 4 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots 7 0 R >> endobj\n\ - 5 0 obj 6 0 R endobj\n\ - 6 0 obj << /Type /Annot /Subtype /Text /Rect [0 0 1 1] /Contents (comment) >> endobj\n\ - 7 0 obj [5 0 R 8 0 R 9 0 R] endobj\n\ - 8 0 obj << /Type /Annot /Subtype /Text /Rect [0 0 1 1] /Contents (to the comment) /IRT 6 0 R >> endobj\n\ - 9 0 obj << /Type /Annot /Subtype /Text /Rect [0 0 1 1] /Contents (through the chain) /IRT 5 0 R >> endobj\n\ - trailer << /Root 1 0 R >>\n%%EOF"; - let mut doc = Document::load_mem(pdf.as_bytes()).expect("loads"); - let mut saved = Vec::new(); - doc.save_to(&mut saved).expect("saves"); - let lazy = LazyStructure::new(Arc::new(saved), None).expect("opens"); - let page = PageInfo { width: 200.0, height: 200.0, crop: [0.0, 0.0, 200.0, 200.0], rotation: 0, label: String::new() }; - for reader in [&doc as &dyn Structure, &lazy] { - let mut info = DocInfo { pages: vec![page.clone(), page.clone()], ..Default::default() }; - Inspector::new(reader).fill(&mut info); - let notes = &info.annotations; - assert_eq!(notes.len(), 3, "{notes:?}"); - let comment = notes.iter().find(|a| a.contents.as_deref() == Some("comment")).expect("listed"); - assert_eq!((comment.page, comment.index, comment.is_reply()), (0, 0, false)); - for reply in ["to the comment", "through the chain"] { - let a = notes.iter().find(|a| a.contents.as_deref() == Some(reply)).expect("listed"); - assert_eq!((a.page, a.parent), (0, Some((0, 0))), "{reply}"); - } - } - } - - /// A conversion rejected as too long, or trimmed short or to nothing, still spends the raw - /// length, so a string many entries share is converted only a few times. - #[test] - fn rejected_text_conversions_spend_the_budget() { - let mut budget = 10; - let mut conversions = 0; - for _ in 0..10 { - // Each 0xFF becomes the three-byte replacement character. - let kept = spend(&[0xFF; 5], &mut budget, |b| { - conversions += 1; - String::from_utf8_lossy(b).into_owned() - }); - assert_eq!(kept, None); - } - assert_eq!((conversions, budget), (2, 0)); - - let pdf = "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ - 2 0 obj << /Type /Pages /Kids [] /Count 0 >> endobj\n\ - trailer << /Root 1 0 R >>\n%%EOF"; - let doc = Document::load_mem(pdf.as_bytes()).expect("loads"); - let d = Dictionary::from_iter([("Contents", Object::string_literal(" "))]); - let mut budget = 10; - assert_eq!(Inspector::new(&doc).budgeted_text(&d, b"Contents", &mut budget), None); - assert_eq!(budget, 7); - // Text that trims short spends its raw length. - let d = Dictionary::from_iter([("Contents", Object::string_literal(" x"))]); - let mut budget = 10; - assert_eq!(Inspector::new(&doc).budgeted_text(&d, b"Contents", &mut budget).as_deref(), Some("x")); - assert_eq!(budget, 3); - } - - /// An annotation attachment's `/Contents` spends the text budget only when it names the - /// attachment, so long descriptions of named files leave a later web address listed. - #[test] - fn a_named_attachment_leaves_its_contents_unspent() { - let files = "<< /Subtype /FileAttachment /Rect [0 0 1 1] /Contents 6 0 R /FS << /F (a.txt) >> >> ".repeat(20); - // Longer than what 20 MiB of charged `/Contents` would leave of the 16 MiB budget. - let uri = format!("https://example.com/{}", "a".repeat(2 << 20)); - let pdf = format!( - "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ - 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\ - 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [{files} \ - << /Subtype /Link /Rect [0 0 1 1] /A << /S /URI /URI ({uri}) >> >>] >> endobj\n\ - 6 0 obj ({}) endobj\n\ - trailer << /Root 1 0 R >>\n%%EOF", - "x".repeat(1 << 20), - ); - let doc = Document::load_mem(pdf.as_bytes()).expect("loads"); - let page = PageInfo { width: 200.0, height: 200.0, crop: [0.0, 0.0, 200.0, 200.0], rotation: 0, label: String::new() }; - let mut info = DocInfo { pages: vec![page], ..Default::default() }; - Inspector::new(&doc).fill(&mut info); - assert!(info.attachments.iter().all(|a| a.name == "a.txt")); - assert_eq!(info.links.len(), 1); - assert!(matches!(&info.links[0].target, LinkTarget::Uri(u) if *u == uri)); - } - - /// Embedded files whose name-tree keys share one long string keep it, as key and as name, only - /// within `MAX_LINK_ATTACHMENT_TEXT_BYTES`. - #[test] - fn embedded_file_keys_sharing_a_long_string_keep_it_within_a_budget() { - let entries = "4 0 R << /Type /Filespec >> ".repeat(20); - let pdf = format!( - "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles << /Names [{entries}] >> >> >> endobj\n\ - 2 0 obj << /Type /Pages /Kids [] /Count 0 >> endobj\n\ - 4 0 obj ({}) endobj\n\ - trailer << /Root 1 0 R >>\n%%EOF", - "x".repeat(1 << 20), - ); - let doc = Document::load_mem(pdf.as_bytes()).expect("loads"); - let mut info = DocInfo::default(); - Inspector::new(&doc).fill(&mut info); - let kept: usize = - info.attachments.iter().map(|a| a.name.len() + if let AttachmentSource::Document { key } = &a.source { key.len() } else { 0 }).sum(); - assert!(!info.attachments.is_empty()); - // Past the budget an attachment still has the generic name. - let generic = info.attachments.len() * "attachment".len(); - assert!(kept <= MAX_LINK_ATTACHMENT_TEXT_BYTES + generic, "{kept} bytes kept"); - } - - /// An embedded file the listing keeps is extracted, though earlier keys the listing could not - /// keep would fill a fresh key budget before it. - #[test] - fn a_listed_embedded_file_is_extracted_past_keys_the_listing_left_out() { - let skipped = "6 0 R 7 0 R ".repeat(9); - let pdf = format!( - "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles << /Names [{skipped}(b.txt) 9 0 R] >> >> >> endobj\n\ - 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\ - 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [\ - << /Subtype /Link /Rect [0 0 1 1] /A << /S /URI /URI ({}) >> >>] >> endobj\n\ - 6 0 obj ({}) endobj\n\ - 7 0 obj << /Type /Filespec /F (a.txt) >> endobj\n\ - 8 0 obj << /Type /EmbeddedFile /Length 5 >> stream\nhello\nendstream endobj\n\ - 9 0 obj << /Type /Filespec /F (b.txt) /EF << /F 8 0 R >> >> endobj\n\ - trailer << /Root 1 0 R >>\n%%EOF", - "u".repeat(15 << 20), - "k".repeat(2 << 20), - ); - let bytes = Arc::new(pdf.into_bytes()); - let info = inspect(bytes.clone(), None).expect("opens"); - let listed = info.attachments.iter().find(|a| a.name == "b.txt").expect("b.txt is listed"); - assert_eq!(attachment_data(&bytes, None, listed).as_deref(), Ok(&b"hello"[..])); - } - - /// Links and attachments sharing one long string each keep a copy only within - /// `MAX_LINK_ATTACHMENT_TEXT_BYTES`, and every one is still listed. - #[test] - fn links_and_attachments_sharing_a_long_string_keep_it_within_a_budget() { - const EACH: usize = 20; - let links = "<< /Subtype /Link /Rect [0 0 1 1] /A 5 0 R >> ".repeat(EACH); - let files = "<< /Subtype /FileAttachment /Rect [0 0 1 1] /FS 7 0 R >> ".repeat(EACH); - let pdf = format!( - "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ - 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\ - 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [{links} {files}] >> endobj\n\ - 5 0 obj << /S /URI /URI 6 0 R >> endobj\n\ - 6 0 obj ({}) endobj\n\ - 7 0 obj << /Type /Filespec /F (a.txt) /Desc 6 0 R >> endobj\n\ - trailer << /Root 1 0 R >>\n%%EOF", - "x".repeat(1 << 20), - ); - let doc = Document::load_mem(pdf.as_bytes()).expect("loads"); - let page = PageInfo { width: 200.0, height: 200.0, crop: [0.0, 0.0, 200.0, 200.0], rotation: 0, label: String::new() }; - let mut info = DocInfo { pages: vec![page], ..Default::default() }; - Inspector::new(&doc).fill(&mut info); - assert_eq!(info.links.len(), EACH); - assert_eq!(info.attachments.len(), EACH); - let uris = info.links.iter().map(|l| if let LinkTarget::Uri(u) = &l.target { u.len() } else { 0 }); - let descriptions = info.attachments.iter().map(|a| a.description.as_ref().map_or(0, String::len)); - let kept: usize = uris.chain(descriptions).sum(); - assert!(kept > 0 && kept <= MAX_LINK_ATTACHMENT_TEXT_BYTES, "{kept} bytes kept"); - assert!(info.links.iter().any(|l| matches!(&l.target, LinkTarget::Other(s) if s == "URI"))); - } - - /// Links and attachments after the comment budget fills are still listed, and a JavaScript link - /// among them still marks the document. - #[test] - fn links_and_attachments_past_the_comment_budget_are_listed() { - let small = "<< /Subtype /Text /Rect [0 0 1 1] /Contents 6 0 R >> ".repeat(1100); - let pdf = format!( - "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ - 2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >> endobj\n\ - 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [{small} \ - << /Subtype /Link /Rect [0 0 1 1] /A << /S /JavaScript /JS (1) >> >>] >> endobj\n\ - 4 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [\ - << /Subtype /FileAttachment /Rect [0 0 1 1] /FS << /Type /Filespec /F (a.txt) >> >>] >> endobj\n\ - 6 0 obj ({}) endobj\ntrailer << /Root 1 0 R >>\n%%EOF", - "x".repeat(64 << 10), - ); - let doc = Document::load_mem(pdf.as_bytes()).expect("loads"); - let page = PageInfo { width: 200.0, height: 200.0, crop: [0.0, 0.0, 200.0, 200.0], rotation: 0, label: String::new() }; - let mut info = DocInfo { pages: vec![page.clone(), page], ..Default::default() }; - Inspector::new(&doc).fill(&mut info); - assert!(info.annotations.len() < 1100, "the comment budget filled"); - assert_eq!(info.links.len(), 1); - assert!(info.has_javascript); - assert_eq!(info.attachments.len(), 1); - } - - /// Widgets without `/P` are placed on the page that lists them with one pass over the pages' - /// `/Annots`, not a search of every page per field: that was O(fields × annotations), a - /// second to open a form of 8,000 fields. A widget two pages list is on the first of them. - #[test] - fn widgets_without_a_page_entry_are_placed_in_one_pass() { - const PAGES: usize = 100; - const PER_PAGE: usize = 10; - let field = |p: usize, k: usize| 4 + PAGES + p * PER_PAGE + k; - let mut pdf = String::from("%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm 3 0 R >> endobj\n"); - let kids: Vec = (0..PAGES).map(|p| format!("{} 0 R", 4 + p)).collect(); - pdf += &format!("2 0 obj << /Type /Pages /Kids [{}] /Count {PAGES} >> endobj\n", kids.join(" ")); - let fields: Vec = (0..PAGES).flat_map(|p| (0..PER_PAGE).map(move |k| format!("{} 0 R", field(p, k)))).collect(); - pdf += &format!("3 0 obj << /Fields [{}] >> endobj\n", fields.join(" ")); - for p in 0..PAGES { - let mut annots: Vec = (0..PER_PAGE).map(|k| format!("{} 0 R", field(p, k))).collect(); - // The last page lists the first page's first widget too. - if p == PAGES - 1 { - annots.push(format!("{} 0 R", field(0, 0))); - } - pdf += &format!("{} 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [{}] >> endobj\n", 4 + p, annots.join(" ")); - } - for p in 0..PAGES { - for k in 0..PER_PAGE { - pdf += &format!("{} 0 obj << /Type /Annot /Subtype /Widget /FT /Tx /T (f{p}_{k}) /Rect [10 10 90 30] >> endobj\n", field(p, k)); - } - } - pdf += "trailer << /Root 1 0 R >>\n%%EOF"; - let doc = Document::load_mem(pdf.as_bytes()).expect("loads"); - let counting = CountingReads { doc: &doc, reads: std::cell::Cell::new(0) }; - let mut info = DocInfo { - pages: (0..PAGES) - .map(|_| PageInfo { width: 200.0, height: 200.0, crop: [0.0, 0.0, 200.0, 200.0], rotation: 0, label: String::new() }) - .collect(), - ..Default::default() - }; - Inspector::new(&counting).fill(&mut info); - let placed: Vec<_> = info.fields.iter().map(|f| (f.name.clone(), f.page)).collect(); - let want: Vec<_> = (0..PAGES).flat_map(|p| (0..PER_PAGE).map(move |k| (format!("f{p}_{k}"), Some(p)))).collect(); - assert_eq!(placed, want); - // A handful of reads per page and field; searching the pages for each field read the - // page dictionaries about 50,000 times. - let reads = counting.reads.get(); - assert!(reads < 20 * (PAGES + PAGES * PER_PAGE), "{reads} object reads"); - } - - /// #2365: the issue's fixture is two PDFs concatenated, the second cut off before its - /// cross-reference data, and the first's only trailer is a cross-reference stream. The - /// lazy reader refuses a file that needed repair, and lopdf's rebuild found no `trailer` - /// keyword and failed, so Document Properties showed every field empty. The rebuild now - /// takes the cross-reference stream's dictionary as the trailer. - #[test] - fn rebuilt_file_keeps_the_info_of_its_cross_reference_stream() { - let header = "%PDF-1.5\n"; - let obj1 = "1 0 obj<>endobj\n"; - let obj2 = "2 0 obj<>endobj\n"; - let obj3 = "3 0 obj<>endobj\n"; - let obj4 = "4 0 obj<>endobj\n"; - let o1 = header.len(); - let o2 = o1 + obj1.len(); - let o3 = o2 + obj2.len(); - let o4 = o3 + obj3.len(); - let o5 = o4 + obj4.len(); - let rows: Vec = - [[0u8, 0, 0, 255], [1, 0, o1 as u8, 0], [1, 0, o2 as u8, 0], [1, 0, o3 as u8, 0], [1, 0, o4 as u8, 0], [1, (o5 >> 8) as u8, o5 as u8, 0]] - .concat(); - let mut bytes = format!("{header}{obj1}{obj2}{obj3}{obj4}").into_bytes(); - bytes.extend_from_slice(format!("5 0 obj<>stream\n", rows.len()).as_bytes()); - bytes.extend_from_slice(&rows); - // A wrong startxref, and a second file after %%EOF: every reader rebuilds the table. - bytes.extend_from_slice(b"\nendstream\nendobj\nstartxref\n999999\n%%EOF\n%PDF-1.3\n"); - bytes.extend_from_slice(&[b'x'; 1024]); - - let info = inspect(Arc::new(bytes), None).unwrap(); - assert_eq!(info.pages.len(), 1); - assert_eq!(info.title.as_deref(), Some("Kept")); - assert_eq!(info.creator.as_deref(), Some("Me")); - } - - /// #2365 review: the rebuild prefers a trailer whose /Root is the catalog. A later - /// cross-reference stream whose /Root points at the Info dictionary used to replace the - /// earlier `trailer`, so Document Properties lost the catalog it should have read. - #[test] - fn rebuilt_file_prefers_a_trailer_whose_root_is_the_catalog() { - let mut bytes = b"%PDF-1.5\n".to_vec(); - for body in [ - "<>", - "<>", - "<>", - "<>", - ] { - let n = bytes.iter().filter(|b| **b == b'\n').count(); - bytes.extend_from_slice(format!("{n} 0 obj{body}endobj\n").as_bytes()); - } - bytes.extend_from_slice(b"trailer\n<>\n"); - bytes.extend_from_slice(b"5 0 obj<>stream\n\x01\x00\x09\x00\nendstream\nendobj\n"); - // A wrong startxref, and a second file after %%EOF: every reader rebuilds the table. - bytes.extend_from_slice(b"startxref\n999999\n%%EOF\n%PDF-1.3\n"); - bytes.extend_from_slice(&[b'x'; 1024]); - - let info = inspect(Arc::new(bytes.clone()), None).unwrap(); - assert_eq!(info.pages.len(), 1); - assert_eq!(info.title.as_deref(), Some("Kept")); - // A later stream whose /Root names the catalog's number with another generation does - // not count either: loading `1 1 R` finds no such object. - let at = bytes.windows(10).position(|w| w == b"/Root 4 0 ").unwrap(); - bytes[at..at + 10].copy_from_slice(b"/Root 1 1 "); - let info = inspect(Arc::new(bytes), None).unwrap(); - assert_eq!((info.pages.len(), info.title.as_deref()), (1, Some("Kept"))); - } - - #[test] - fn lazy_structure_matches_the_compatibility_inspector() { - // Each fixture as lopdf writes it, and with its objects in object streams. - let packed = |bytes: &[u8]| { - let doc = pdfcraft_cos::Document::open(Arc::new(bytes.to_vec())).unwrap(); - pdfcraft_cos::write_full(&doc, &pdfcraft_cos::SaveOptions { object_streams: true, ..Default::default() }).unwrap() - }; - let fixtures = [ATTACHMENTS, LAYERS, DESTS].into_iter().flat_map(|bytes| { - let mut original = Document::load_mem(bytes).unwrap(); - let mut plain = Vec::new(); - original.save_to(&mut plain).unwrap(); - let stm = packed(&plain); - [plain, stm] - }); - for bytes in fixtures { - let lazy = LazyStructure::new(Arc::new(bytes.clone()), None).unwrap(); - let eager = Document::load_mem(&bytes).unwrap(); - let mut a = inspect(Arc::new(bytes), None).unwrap(); - let mut b = DocInfo { pages: a.pages.clone(), ..Default::default() }; - let mut c = DocInfo { pages: a.pages.clone(), ..Default::default() }; - Inspector::new(&lazy).fill(&mut b); - Inspector::new(&eager).fill(&mut c); - assert!(!lazy.failed()); - assert_eq!(format!("{b:?}"), format!("{c:?}")); - a.file_size = 0; - a.pdf_version.clear(); - assert_eq!(format!("{a:?}"), format!("{c:?}")); - } - } - - #[test] - fn links_read_set_layer_actions() { - let info = inspect(Arc::new(LAYERS.to_vec()), None).expect("opens"); - let targets: Vec<_> = info.links.iter().map(|l| l.target.clone()).collect(); - use LayerOp::{Off, On, Toggle}; - assert_eq!( - targets, - [ - // Groups before the first name or after an unknown one are skipped, and so is - // anything that isn't a group. - LinkTarget::SetLayers { changes: vec![(On, (6, 0)), (Toggle, (5, 0)), (Toggle, (7, 0))], preserve_rb: false }, - // An indirect /State; /PreserveRB defaults to true. - LinkTarget::SetLayers { changes: vec![(Off, (5, 0))], preserve_rb: true }, - ] - ); - } - - /// #4168: a Launch action naming a `.pdf` file is read as the link to that file it amounts - /// to, `\` separators included, and a stray `/D` (which Launch has no use for) doesn't stop - /// it. One naming anything else, with no file, or with `/Win` launch parameters stays an - /// action that isn't followed. - #[test] - fn launch_actions_that_name_a_pdf_open_it_like_a_remote_link() { - let pdf = "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ - 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >> endobj\n\ - 3 0 obj << /Type /Page /Parent 2 0 R /Annots [30 0 R 31 0 R 32 0 R 33 0 R 34 0 R 35 0 R 36 0 R 37 0 R 38 0 R] >> endobj\n\ - 30 0 obj << /Type /Annot /Subtype /Link /Rect [0 0 10 10] /A << /S /Launch /F (..\\\\se\\\\se_ST.pdf) >> >> endobj\n\ - 31 0 obj << /Type /Annot /Subtype /Link /Rect [0 20 10 30] /A << /S /Launch /F << /Type /Filespec /F (chapters/Two.PDF) >> /NewWindow true >> >> endobj\n\ - 32 0 obj << /Type /Annot /Subtype /Link /Rect [0 40 10 50] /A << /S /Launch /F (tool.exe) >> >> endobj\n\ - 33 0 obj << /Type /Annot /Subtype /Link /Rect [0 60 10 70] /A << /S /Launch /F (a.pdf) /Win << /F (cmd.exe) /P (/c x) >> >> >> endobj\n\ - 34 0 obj << /Type /Annot /Subtype /Link /Rect [0 80 10 90] /A << /S /Launch /Win << /F (b.pdf) >> >> >> endobj\n\ - 35 0 obj << /Type /Annot /Subtype /Link /Rect [0 100 10 110] /A << /S /Launch /F (.pdf) >> >> endobj\n\ - 36 0 obj << /Type /Annot /Subtype /Link /Rect [0 120 10 130] /A << /S /Launch /F (dir/a\\\\b.pdf) >> >> endobj\n\ - 37 0 obj << /Type /Annot /Subtype /Link /Rect [0 140 10 150] /A << /S /Launch /FS /URL /F (https://example.com/x.pdf) >> >> endobj\n\ - 38 0 obj << /Type /Annot /Subtype /Link /Rect [0 160 10 170] /A << /S /Launch /F (c.pdf) /D 0 >> >> endobj\n\ - trailer << /Root 1 0 R >>\n%%EOF"; - let info = inspect(Arc::new(pdf.as_bytes().to_vec()), None).expect("opens"); - let targets: Vec<_> = info.links.iter().map(|l| l.target.clone()).collect(); - let launch = - |file: &str, new_window| LinkTarget::RemoteGoTo { launch: true, file: file.into(), destination: RemoteDestination::Default, new_window }; - let other = || LinkTarget::Other("Launch".into()); - assert_eq!( - targets, - [ - launch("../se/se_ST.pdf", None), - launch("chapters/Two.PDF", Some(true)), - other(), - other(), - other(), - other(), - launch("dir/a\\b.pdf", None), - other(), - launch("c.pdf", None), - ] - ); - } - - #[test] - fn links_read_bounded_local_remote_go_to_actions() { - use DestView::{Fit, Xyz}; - let too_long = "a".repeat(MAX_REMOTE_FILE_SPEC_BYTES + 1); - let wide_file = format!("", "4E00".repeat(1400)); - let wide_name = format!("", "4E00".repeat(342)); - let pdf = format!( - "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ - 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >> endobj\n\ - 3 0 obj << /Type /Page /Parent 2 0 R /Annots [99 0 R 30 0 R 31 0 R 32 0 R 33 0 R 34 0 R 35 0 R 36 0 R 37 0 R 38 0 R 39 0 R 40 0 R 41 0 R] >> endobj\n\ - 30 0 obj << /Type /Annot /Subtype /Link /Rect [0 0 10 10] /A << /S /GoToR /F (chapters/remote.pdf) /D [2 /XYZ 10 20 1.5] /NewWindow true >> >> endobj\n\ - 31 0 obj << /Type /Annot /Subtype /Link /Rect [0 20 10 30] /A << /S /GoToR /F << /Type /Filespec /F (fallback.pdf) /UF >> /D [0 /Fit] /NewWindow false >> >> endobj\n\ - 32 0 obj << /Type /Annot /Subtype /Link /Rect [0 40 10 50] /A << /S /GoToR /F (remote.pdf) >> >> endobj\n\ - 33 0 obj << /Type /Annot /Subtype /Link /Rect [0 60 10 70] /A << /S /GoToR /F (chapter.pdf) /D (chapter) >> >> endobj\n\ - 34 0 obj << /Type /Annot /Subtype /Link /Rect [0 80 10 90] /A << /S /GoToR /FS /URL /F (https://example.com) /D [0 /Fit] >> >> endobj\n\ - 35 0 obj << /Type /Annot /Subtype /Link /Rect [0 100 10 110] /A << /S /GoToR /F ({too_long}) /D [0 /Fit] >> >> endobj\n\ - 36 0 obj << /Type /Annot /Subtype /Link /Rect [0 120 10 130] /A << /S /GoToR /F (chapter.pdf) /D /chapter >> >> endobj\n\ - 37 0 obj << /Type /Annot /Subtype /Link /Rect [0 140 10 150] /A << /S /GoToR /F (chapter.pdf) /D [-1 /Fit] >> >> endobj\n\ - 38 0 obj << /Type /Annot /Subtype /Link /Rect [0 160 10 170] /A << /S /GoToR /F (chapter.pdf) /D [1.5 /Fit] >> >> endobj\n\ - 39 0 obj << /Type /Annot /Subtype /Link /Rect [0 180 10 190] /A << /S /GoToR /F (chapter.pdf) /D [9223372036854775807 /Fit] >> >> endobj\n\ - 40 0 obj << /Type /Annot /Subtype /Link /Rect [0 200 10 210] /A << /S /GoToR /F << /Type /Filespec /UF {wide_file} >> /D [0 /Fit] >> >> endobj\n\ - 41 0 obj << /Type /Annot /Subtype /Link /Rect [0 220 10 230] /A << /S /GoToR /F (chapter.pdf) /D {wide_name} >> >> endobj\n\ - trailer << /Root 1 0 R >>\n%%EOF" - ); - let info = inspect(Arc::new(pdf.into_bytes()), None).expect("opens"); - let targets: Vec<_> = info.links.iter().map(|l| (l.index, l.target.clone())).collect(); - let huge_page_target = usize::try_from(i64::MAX) - .map(|page| LinkTarget::RemoteGoTo { - launch: false, - file: "chapter.pdf".into(), - destination: RemoteDestination::Page { page, view: Fit }, - new_window: None, - }) - .unwrap_or_else(|_| LinkTarget::Other("GoToR".into())); - assert_eq!( - targets, - [ - ( - 1, - LinkTarget::RemoteGoTo { - launch: false, - file: "chapters/remote.pdf".into(), - destination: RemoteDestination::Page { page: 2, view: Xyz { left: Some(10.0), top: Some(20.0), zoom: Some(1.5) } }, - new_window: Some(true), - } - ), - ( - 2, - LinkTarget::RemoteGoTo { - launch: false, - file: "foo.pdf".into(), - destination: RemoteDestination::Page { page: 0, view: Fit }, - new_window: Some(false), - } - ), - (3, LinkTarget::RemoteGoTo { launch: false, file: "remote.pdf".into(), destination: RemoteDestination::Default, new_window: None }), - ( - 4, - LinkTarget::RemoteGoTo { - launch: false, - file: "chapter.pdf".into(), - destination: RemoteDestination::Named("chapter".into()), - new_window: None - } - ), - (5, LinkTarget::Other("GoToR".into())), - (6, LinkTarget::Other("GoToR".into())), - ( - 7, - LinkTarget::RemoteGoTo { - launch: false, - file: "chapter.pdf".into(), - destination: RemoteDestination::Named("chapter".into()), - new_window: None - } - ), - (8, LinkTarget::Other("GoToR".into())), - (9, LinkTarget::Other("GoToR".into())), - (10, huge_page_target), - (11, LinkTarget::Other("GoToR".into())), - (12, LinkTarget::Other("GoToR".into())), - ] - ); - } - - #[test] - fn destination_operands_become_views() { - use DestView::*; - let v = |kind: &[u8], ops: &[Option]| DestView::from_operands(Some(kind), ops); - // ISO 32000-2 Table 149, each type. - assert_eq!(v(b"XYZ", &[Some(0.0), Some(420.0), Some(0.0)]), Xyz { left: Some(0.0), top: Some(420.0), zoom: None }); - assert_eq!(v(b"XYZ", &[Some(72.0), Some(500.0), Some(1.5)]), Xyz { left: Some(72.0), top: Some(500.0), zoom: Some(1.5) }); - assert_eq!(v(b"Fit", &[]), Fit); - assert_eq!(v(b"FitB", &[]), Fit); - assert_eq!(v(b"FitH", &[Some(250.0)]), FitH { top: Some(250.0) }); - assert_eq!(v(b"FitBH", &[None]), FitH { top: None }); - assert_eq!(v(b"FitV", &[Some(120.0)]), FitV { left: Some(120.0) }); - assert_eq!(v(b"FitBV", &[Some(5.0)]), FitV { left: Some(5.0) }); - // FitR's corners are normalized. - assert_eq!(v(b"FitR", &[Some(110.0), Some(220.0), Some(10.0), Some(20.0)]), FitR { rect: [10.0, 20.0, 110.0, 220.0] }); - // Missing operands are null; so are non-finite ones, and a zoom that isn't positive. - assert_eq!(v(b"XYZ", &[]), Xyz { left: None, top: None, zoom: None }); - assert_eq!(v(b"XYZ", &[Some(f32::NAN), Some(f32::INFINITY), Some(f32::NEG_INFINITY)]), Xyz { left: None, top: None, zoom: None }); - assert_eq!(v(b"XYZ", &[None, Some(-5.0), Some(-1.0)]), Xyz { left: None, top: Some(-5.0), zoom: None }); - assert_eq!(v(b"XYZ", &[None, None, Some(f32::NAN)]), Xyz { left: None, top: None, zoom: None }); - assert_eq!(v(b"FitH", &[Some(f32::NAN)]), FitH { top: None }); - // A FitR without four usable numbers, an unknown type or no type: the top of the page. - assert_eq!(v(b"FitR", &[Some(1.0), Some(2.0)]), Top); - assert_eq!(v(b"FitR", &[Some(1.0), Some(2.0), Some(f32::INFINITY), Some(4.0)]), Top); - assert_eq!(v(b"Zoom", &[Some(3.0)]), Top); - assert_eq!(v(b"xyz", &[Some(3.0)]), Top); - assert_eq!(DestView::from_operands(None, &[Some(1.0)]), Top); - } - - /// Two 300×400 pages. Bookmarks and links on page 1 go to page 2 in every destination form, - /// named (`/Names /Dests` and the PDF 1.1 `/Dests`) and malformed. - const DESTS: &[u8] = b"%PDF-1.7 -1 0 obj << /Type /Catalog /Pages 2 0 R /Outlines 10 0 R /Names << /Dests 40 0 R >> /Dests << /old << /D [4 0 R /FitV 120] >> >> >> endobj -2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 300 400] >> endobj -3 0 obj << /Type /Page /Parent 2 0 R /Annots [30 0 R 31 0 R 32 0 R 33 0 R] >> endobj -4 0 obj << /Type /Page /Parent 2 0 R >> endobj -10 0 obj << /Type /Outlines /First 11 0 R /Last 20 0 R /Count 10 >> endobj -11 0 obj << /Title (xyz) /Parent 10 0 R /Next 12 0 R /Dest [4 0 R /XYZ 0 420 0] >> endobj -12 0 obj << /Title (fith) /Parent 10 0 R /Next 13 0 R /Dest [4 0 R /FitH 250] >> endobj -13 0 obj << /Title (named) /Parent 10 0 R /Next 14 0 R /Dest (sec) >> endobj -14 0 obj << /Title (old named) /Parent 10 0 R /Next 15 0 R /Dest /old >> endobj -15 0 obj << /Title (action) /Parent 10 0 R /Next 16 0 R /A << /S /GoTo /D [4 0 R /FitR 110 220 10 20] >> >> endobj -16 0 obj << /Title (wrong types) /Parent 10 0 R /Next 17 0 R /Dest [4 0 R /XYZ (left) /Top true] >> endobj -17 0 obj << /Title (short) /Parent 10 0 R /Next 18 0 R /Dest [4 0 R /FitR 1 2] >> endobj -18 0 obj << /Title (no type) /Parent 10 0 R /Next 19 0 R /Dest [4 0 R] >> endobj -19 0 obj << /Title (huge) /Parent 10 0 R /Next 20 0 R /Dest [4 0 R /XYZ 1000000000000 -5 -1] >> endobj -20 0 obj << /Title (no page) /Parent 10 0 R /Dest [/XYZ 0 420 0] >> endobj -30 0 obj << /Type /Annot /Subtype /Link /Rect [0 0 10 10] /Dest [4 0 R /XYZ 72 500 1.5] >> endobj -31 0 obj << /Type /Annot /Subtype /Link /Rect [0 20 10 30] /A << /S /GoTo /D (sec) >> >> endobj -32 0 obj << /Type /Annot /Subtype /Link /Rect [0 40 10 50] /Dest [4 0 R /FitBH 100] >> endobj -33 0 obj << /Type /Annot /Subtype /Link /Rect [0 60 10 70] /Dest [4 0 R /Fit] >> endobj -40 0 obj << /Names [(sec) << /D [4 0 R /XYZ null 300 2] >>] >> endobj -trailer << /Root 1 0 R >> -%%EOF"; - - #[test] - fn bookmarks_and_links_keep_their_destination_view() { - use DestView::*; - let info = inspect(Arc::new(DESTS.to_vec()), None).expect("opens"); - let outline: Vec<_> = info.outline.iter().map(|o| (o.title.as_str(), o.page, o.view)).collect(); - assert_eq!( - outline, - [ - ("xyz", Some(1), Xyz { left: Some(0.0), top: Some(420.0), zoom: None }), - ("fith", Some(1), FitH { top: Some(250.0) }), - ("named", Some(1), Xyz { left: None, top: Some(300.0), zoom: Some(2.0) }), - ("old named", Some(1), FitV { left: Some(120.0) }), - ("action", Some(1), FitR { rect: [10.0, 20.0, 110.0, 220.0] }), - // Hostile operands fall back without failing the bookmark. - ("wrong types", Some(1), Xyz { left: None, top: None, zoom: None }), - ("short", Some(1), Top), - ("no type", Some(1), Top), - ("huge", Some(1), Xyz { left: Some(1e12), top: Some(-5.0), zoom: None }), - ("no page", None, Top), - ] - ); - let links: Vec<_> = info.links.iter().map(|l| l.target.clone()).collect(); - assert_eq!( - links, - [ - LinkTarget::Page(1, Xyz { left: Some(72.0), top: Some(500.0), zoom: Some(1.5) }), - LinkTarget::Page(1, Xyz { left: None, top: Some(300.0), zoom: Some(2.0) }), - LinkTarget::Page(1, FitH { top: Some(100.0) }), - LinkTarget::Page(1, Fit), - ] - ); - } - - #[test] - fn outline_titles_decode_every_text_string_encoding() { - // Issue #142: CJK bookmark titles showed as mojibake or with a stray BOM. - let pdf = b"%PDF-1.7 -1 0 obj << /Type /Catalog /Pages 2 0 R /Outlines 10 0 R >> endobj -2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >> endobj -3 0 obj << /Type /Page /Parent 2 0 R >> endobj -10 0 obj << /Type /Outlines /First 11 0 R /Last 16 0 R /Count 6 >> endobj -11 0 obj << /Title /Parent 10 0 R /Next 12 0 R /Dest [3 0 R /Fit] >> endobj -12 0 obj << /Title /Parent 10 0 R /Next 13 0 R /Dest [3 0 R /Fit] >> endobj -13 0 obj << /Title /Parent 10 0 R /Next 14 0 R /Dest [3 0 R /Fit] >> endobj -14 0 obj << /Title /Parent 10 0 R /Next 15 0 R /Dest [3 0 R /Fit] >> endobj -15 0 obj << /Title <436166E9> /Parent 10 0 R /Next 16 0 R /Dest [3 0 R /Fit] >> endobj -16 0 obj << /Title /Parent 10 0 R /Dest [3 0 R /Fit] >> endobj -trailer << /Root 1 0 R >> -%%EOF"; - let info = inspect(Arc::new(pdf.to_vec()), None).expect("opens"); - let titles: Vec<_> = info.outline.iter().map(|o| o.title.as_str()).collect(); - // UTF-16BE, UTF-8 with BOM, invalid UTF-8 after a BOM (lossy, not dropped), BOM-less - // raw UTF-8, PDFDocEncoding Latin-1, and a doubled BOM. - assert_eq!(titles, ["第一章", "目录", "A\u{FFFD}B", "概述", "Café", "A"]); - } - - #[test] - fn xfa_dynamic_render_is_read_from_config_acrobat7_only() { - let text = xfa_config_dynamic_render; - let config = - |inner: &str| format!("{inner}"); - // Designer's style: a line break before each `>`. - assert_eq!( - text("forbidden").as_deref(), - Some("forbidden") - ); - assert_eq!(text(&config(" required ")).as_deref(), Some("required")); - // Commented out, in CDATA, or a longer name: not the element. - assert_eq!(text(&config("")), None); - assert_eq!(text(&config(" b forbidden ]]>")), None); - assert_eq!(text(&config("forbidden")), None); - // Self-closing, empty, or holding markup: no value. - assert_eq!(text(&config("")), None); - assert_eq!(text(&config("")), None); - assert_eq!(text(&config("forbidden")), None); - // Closing tags that don't match what they close: malformed, no value (review on #3192). - assert_eq!(text(&config("forbidden")), None); - assert_eq!(text("forbidden"), None); - // The same name anywhere but config/acrobat/acrobat7 (form data, another config branch). - let xdp = "forbiddenforbidden"; - assert_eq!(text(xdp), None); - // A whole XDP with the config after the template. - let xdp = format!("{}", config("forbidden")); - assert_eq!(text(&xdp).as_deref(), Some("forbidden")); - // A whole config path inside the form data, before the real config packet, is data: the - // real packet's value counts (review on #3192). - let xdp = format!( - "{}{}", - config("forbidden"), - config("required") - ); - assert_eq!(text(&xdp).as_deref(), Some("required")); - // Only in the data, or nested in the template: no configuration at all. - let xdp = - format!("{}", config("forbidden")); - assert_eq!(text(&xdp), None); - let xdp = format!("", config("forbidden")); - assert_eq!(text(&xdp), None); - // acrobat7 must be a child of acrobat, not deeper. - assert_eq!(text("forbidden"), None); - // Cut short anywhere: no panic, nothing read. - let whole = config("forbidden"); - for end in 0..whole.len() { - assert_eq!(text(&whole[..end]), None, "cut at {end}"); - } - assert_eq!(text(&whole).as_deref(), Some("forbidden")); - } - - #[test] - fn xfa_config_packets_are_read_in_utf16_too() { - let xml = "forbidden"; - let le: Vec = xml.encode_utf16().flat_map(u16::to_le_bytes).collect(); - let be: Vec = xml.encode_utf16().flat_map(u16::to_be_bytes).collect(); - for bytes in [ - le.clone(), - be.clone(), - [&[0xFF, 0xFE][..], &le].concat(), - [&[0xFE, 0xFF][..], &be].concat(), - [&[0xEF, 0xBB, 0xBF][..], xml.as_bytes()].concat(), - ] { - assert_eq!(xfa_packet_text(&bytes), xml); - } - // An odd byte or a broken surrogate doesn't stop the scan. - assert!(xfa_packet_text(&[0xFF, 0xFE, b'<', 0, 0x00, 0xD8, b'x']).starts_with('<')); - } - - /// #3180: an XFA form with no fields was always taken to be dynamic, so its pages were - /// replaced by a layout on open, even when its configuration says it is a static form. - #[test] - fn an_xfa_form_without_fields_is_static_when_its_config_forbids_dynamic_rendering() { - let xfa_of = |config: &str, catalog_extra: &str| { - let bodies = [ - format!("<< /Type /Catalog /Pages 2 0 R {catalog_extra} /AcroForm << /Fields [] /XFA [(config) 4 0 R (template) 5 0 R] >> >>"), - "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>".to_string(), - "<< /Type /Page /Parent 2 0 R >>".to_string(), - format!("<< /Length {} >>\nstream\n{config}\nendstream", config.len()), - "<< /Length 11 >>\nstream\n