diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 94a4c545fe..38ee53caf3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,6 +29,10 @@ jobs: permissions: contents: read steps: + - name: Harden runner + uses: step-security/harden-runner@1d88384fd173af6ecb012dfd0264a306b711241a # v2.22.4 + with: + egress-policy: audit - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml index b7b1d409c8..78dc1b614b 100644 --- a/.github/workflows/fuzz.yml +++ b/.github/workflows/fuzz.yml @@ -24,13 +24,17 @@ jobs: name: fuzz (mutation, 15 min) runs-on: ubuntu-latest steps: + - name: Harden runner + uses: step-security/harden-runner@1d88384fd173af6ecb012dfd0264a306b711241a # v2.22.4 + with: + egress-policy: audit - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable + - uses: step-security/dtolnay-rust-toolchain@8dd607977d06adf4ed49b190381e74c17a9dd25f # v1.0.0 with: toolchain: stable - - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 + - uses: step-security/rust-cache@8871c8ba063416da7c32246932ea6ae08700453e # v2.9.2 with: key: fuzz - name: Fetch the pdf.js corpus (seeds) diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml index ea61d84303..1771a35fea 100644 --- a/.github/workflows/packaging-lint.yml +++ b/.github/workflows/packaging-lint.yml @@ -27,6 +27,10 @@ jobs: name: Windows installation policy (no app build) runs-on: windows-latest steps: + - name: Harden runner + uses: step-security/harden-runner@1d88384fd173af6ecb012dfd0264a306b711241a # v2.22.4 + with: + egress-policy: audit - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false diff --git a/Cargo.lock b/Cargo.lock index b5867bcf8d..cecedc12c9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -238,7 +238,7 @@ dependencies = [ "objc2-foundation 0.3.2", "parking_lot", "percent-encoding", - "windows-sys 0.61.2", + "windows-sys 0.52.0", "wl-clipboard-rs", "x11rb", ] @@ -321,7 +321,7 @@ dependencies = [ "futures-lite", "parking", "polling", - "rustix 1.1.5", + "rustix 1.1.6", "slab", "windows-sys 0.61.2", ] @@ -352,7 +352,7 @@ dependencies = [ "cfg-if", "event-listener", "futures-lite", - "rustix 1.1.5", + "rustix 1.1.6", ] [[package]] @@ -378,7 +378,7 @@ dependencies = [ "cfg-if", "futures-core", "futures-io", - "rustix 1.1.5", + "rustix 1.1.6", "signal-hook-registry", "slab", "windows-sys 0.61.2", @@ -878,7 +878,7 @@ checksum = "4dbf9978365bac10f54d1d4b04f7ce4427e51f71d61f2fe15e3fed5166474df7" dependencies = [ "bitflags 2.13.2", "polling", - "rustix 1.1.5", + "rustix 1.1.6", "slab", "tracing", ] @@ -902,7 +902,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" dependencies = [ "calloop 0.14.4", - "rustix 1.1.5", + "rustix 1.1.6", "wayland-backend", "wayland-client", ] @@ -1243,14 +1243,14 @@ dependencies = [ [[package]] name = "craft-print" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=1ee01a4c58729d781638b8afbd8982726dbdefcb#1ee01a4c58729d781638b8afbd8982726dbdefcb" +source = "git+https://github.com/storytold/craft-libs?rev=60ede99985ef1e5df87a2aca66ed538eb23dec99#60ede99985ef1e5df87a2aca66ed538eb23dec99" dependencies = [ "craft-winprint-gdi", "log", "miniz_oxide 0.9.1", "roxmltree 0.21.2", "tempfile", - "winprint 0.2.1 (git+https://github.com/storytold/winprint.rs?rev=3c978dfcf3d460577fabd87aeea7a84ffdcfb396)", + "winprint 0.2.1 (git+https://github.com/storytold/winprint.rs?rev=bc06a7c48b768eb27a828c7986f6d2868b245ee0)", "winsafe", ] @@ -1289,7 +1289,7 @@ dependencies = [ [[package]] name = "craft-update" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d" +source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b" dependencies = [ "chrono", "dunce", @@ -1299,6 +1299,7 @@ dependencies = [ "image", "log", "plist", + "rustix 1.1.6", "semver", "serde", "serde_json", @@ -2050,7 +2051,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -2427,7 +2428,7 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" dependencies = [ - "rustix 1.1.5", + "rustix 1.1.6", "windows-link", ] @@ -4172,7 +4173,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -4263,7 +4264,7 @@ dependencies = [ [[package]] name = "pdfcraft" -version = "0.18.1" +version = "0.21.0" dependencies = [ "chrono", "craft-update", @@ -4288,7 +4289,7 @@ dependencies = [ [[package]] name = "pdfcraft-a11y" -version = "0.18.1" +version = "0.21.0" dependencies = [ "pdfcraft-content", "pdfcraft-cos", @@ -4299,7 +4300,7 @@ dependencies = [ [[package]] name = "pdfcraft-annot" -version = "0.18.1" +version = "0.21.0" dependencies = [ "hayro-syntax", "pdfcraft-cos", @@ -4311,7 +4312,7 @@ dependencies = [ [[package]] name = "pdfcraft-audit" -version = "0.18.1" +version = "0.21.0" dependencies = [ "pdfcraft-content", "pdfcraft-cos", @@ -4322,7 +4323,7 @@ dependencies = [ [[package]] name = "pdfcraft-automation" -version = "0.18.1" +version = "0.21.0" dependencies = [ "base64 0.23.1", "flate2", @@ -4338,7 +4339,7 @@ dependencies = [ "pdfcraft-render", "pdfcraft-xfa", "png 0.18.1", - "rustix 1.1.5", + "rustix 1.1.6", "serde_json", "sha2 0.11.1", "tempfile", @@ -4346,7 +4347,7 @@ dependencies = [ [[package]] name = "pdfcraft-cli" -version = "0.18.1" +version = "0.21.0" dependencies = [ "base64 0.23.1", "pdfcraft-audit", @@ -4357,18 +4358,18 @@ dependencies = [ "pdfcraft-preflight", "pdfcraft-render", "png 0.18.1", - "rustix 1.1.5", + "rustix 1.1.6", "serde_json", "winresource", ] [[package]] name = "pdfcraft-compare" -version = "0.18.1" +version = "0.21.0" [[package]] name = "pdfcraft-content" -version = "0.18.1" +version = "0.21.0" dependencies = [ "hayro-syntax", "pdfcraft-cos", @@ -4376,7 +4377,7 @@ dependencies = [ [[package]] name = "pdfcraft-cos" -version = "0.18.1" +version = "0.21.0" dependencies = [ "chrono", "hayro-syntax", @@ -4393,7 +4394,7 @@ dependencies = [ [[package]] name = "pdfcraft-create" -version = "0.18.1" +version = "0.21.0" dependencies = [ "encoding_rs", "hayro-ccitt", @@ -4405,6 +4406,7 @@ dependencies = [ "pdfcraft-model", "pdfcraft-xfa", "png 0.18.1", + "rustix 1.1.6", "thiserror 2.0.21", "tiff", "zune-jpeg", @@ -4412,7 +4414,7 @@ dependencies = [ [[package]] name = "pdfcraft-crypt" -version = "0.18.1" +version = "0.21.0" dependencies = [ "aes", "md-5", @@ -4425,7 +4427,7 @@ dependencies = [ [[package]] name = "pdfcraft-edit" -version = "0.18.1" +version = "0.21.0" dependencies = [ "hayro-syntax", "icu_segmenter", @@ -4444,7 +4446,7 @@ dependencies = [ [[package]] name = "pdfcraft-engine" -version = "0.18.1" +version = "0.21.0" dependencies = [ "chrono", "craft-docpath", @@ -4488,14 +4490,14 @@ dependencies = [ [[package]] name = "pdfcraft-export" -version = "0.18.1" +version = "0.21.0" dependencies = [ "flate2", ] [[package]] name = "pdfcraft-filters" -version = "0.18.1" +version = "0.21.0" dependencies = [ "fax", "flate2", @@ -4508,7 +4510,7 @@ dependencies = [ [[package]] name = "pdfcraft-fonts" -version = "0.18.1" +version = "0.21.0" dependencies = [ "harfrust", "pdfcraft-cos", @@ -4518,7 +4520,7 @@ dependencies = [ [[package]] name = "pdfcraft-forms" -version = "0.18.1" +version = "0.21.0" dependencies = [ "hayro-syntax", "pdfcraft-content", @@ -4531,11 +4533,11 @@ dependencies = [ [[package]] name = "pdfcraft-geom" -version = "0.18.1" +version = "0.21.0" [[package]] name = "pdfcraft-js" -version = "0.18.1" +version = "0.21.0" dependencies = [ "boa_engine", "chrono", @@ -4543,7 +4545,7 @@ dependencies = [ [[package]] name = "pdfcraft-measure" -version = "0.18.1" +version = "0.21.0" dependencies = [ "pdfcraft-annot", "pdfcraft-content", @@ -4556,14 +4558,14 @@ dependencies = [ [[package]] name = "pdfcraft-model" -version = "0.18.1" +version = "0.21.0" dependencies = [ "pdfcraft-cos", ] [[package]] name = "pdfcraft-ocr" -version = "0.18.1" +version = "0.21.0" dependencies = [ "ocrs", "pdfcraft-fonts", @@ -4578,7 +4580,7 @@ dependencies = [ [[package]] name = "pdfcraft-ocr-worker" -version = "0.18.1" +version = "0.21.0" dependencies = [ "js-sys", "pdfcraft-ocr", @@ -4588,7 +4590,7 @@ dependencies = [ [[package]] name = "pdfcraft-optimize" -version = "0.18.1" +version = "0.21.0" dependencies = [ "image", "pdfcraft-annot", @@ -4605,11 +4607,11 @@ dependencies = [ [[package]] name = "pdfcraft-oracle" -version = "0.18.1" +version = "0.21.0" [[package]] name = "pdfcraft-organize" -version = "0.18.1" +version = "0.21.0" dependencies = [ "hayro-syntax", "pdfcraft-content", @@ -4620,18 +4622,18 @@ dependencies = [ [[package]] name = "pdfcraft-platform" -version = "0.18.1" +version = "0.21.0" dependencies = [ "async-channel", "futures-lite", "log", - "rustix 1.1.5", + "rustix 1.1.6", "zbus", ] [[package]] name = "pdfcraft-prc" -version = "0.18.1" +version = "0.21.0" dependencies = [ "flate2", "proptest", @@ -4640,7 +4642,7 @@ dependencies = [ [[package]] name = "pdfcraft-prc-render" -version = "0.18.1" +version = "0.21.0" dependencies = [ "flate2", "pdfcraft-prc", @@ -4649,7 +4651,7 @@ dependencies = [ [[package]] name = "pdfcraft-preflight" -version = "0.18.1" +version = "0.21.0" dependencies = [ "flate2", "pdfcraft-content", @@ -4660,7 +4662,7 @@ dependencies = [ [[package]] name = "pdfcraft-print" -version = "0.18.1" +version = "0.21.0" dependencies = [ "craft-print", "log", @@ -4675,7 +4677,7 @@ dependencies = [ [[package]] name = "pdfcraft-redact" -version = "0.18.1" +version = "0.21.0" dependencies = [ "aho-corasick", "pdfcraft-annot", @@ -4699,7 +4701,7 @@ dependencies = [ [[package]] name = "pdfcraft-render" -version = "0.18.1" +version = "0.21.0" dependencies = [ "bytemuck", "flate2", @@ -4723,7 +4725,7 @@ dependencies = [ [[package]] name = "pdfcraft-sign" -version = "0.18.1" +version = "0.21.0" dependencies = [ "aes", "aws-lc-rs", @@ -4762,11 +4764,11 @@ dependencies = [ [[package]] name = "pdfcraft-testkit" -version = "0.18.1" +version = "0.21.0" [[package]] name = "pdfcraft-ui-egui" -version = "0.18.1" +version = "0.21.0" dependencies = [ "arboard", "base64 0.23.1", @@ -4807,7 +4809,7 @@ dependencies = [ "pollster 1.0.1", "proptest", "rfd", - "rustix 1.1.5", + "rustix 1.1.6", "serde", "serde_json", "skrifa 0.44.0", @@ -4825,7 +4827,7 @@ dependencies = [ [[package]] name = "pdfcraft-web" -version = "0.18.1" +version = "0.21.0" dependencies = [ "craft-web-api", "craft-web-host", @@ -4840,7 +4842,7 @@ dependencies = [ [[package]] name = "pdfcraft-xfa" -version = "0.18.1" +version = "0.21.0" dependencies = [ "base64 0.23.1", "image", @@ -4854,7 +4856,7 @@ dependencies = [ [[package]] name = "pdfcraft-xfdf" -version = "0.18.1" +version = "0.21.0" dependencies = [ "base64 0.23.1", "pdfcraft-annot", @@ -5143,7 +5145,7 @@ dependencies = [ "concurrent-queue", "hermit-abi", "pin-project-lite", - "rustix 1.1.5", + "rustix 1.1.6", "windows-sys 0.61.2", ] @@ -5401,7 +5403,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -5953,15 +5955,15 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.5" +version = "1.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa" dependencies = [ "bitflags 2.13.2", "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -6031,7 +6033,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -6655,7 +6657,7 @@ dependencies = [ "libc", "log", "memmap2", - "rustix 1.1.5", + "rustix 1.1.6", "thiserror 2.0.21", "wayland-backend", "wayland-client", @@ -6920,8 +6922,8 @@ dependencies = [ "fastrand", "getrandom 0.4.3", "once_cell", - "rustix 1.1.5", - "windows-sys 0.61.2", + "rustix 1.1.6", + "windows-sys 0.52.0", ] [[package]] @@ -7673,7 +7675,7 @@ checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" dependencies = [ "cc", "downcast-rs", - "rustix 1.1.5", + "rustix 1.1.6", "scoped-tls", "smallvec", "wayland-sys", @@ -7686,7 +7688,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" dependencies = [ "bitflags 2.13.2", - "rustix 1.1.5", + "rustix 1.1.6", "wayland-backend", "wayland-scanner", ] @@ -7708,7 +7710,7 @@ version = "0.31.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" dependencies = [ - "rustix 1.1.5", + "rustix 1.1.6", "wayland-client", "xcursor", ] @@ -8046,7 +8048,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -8320,28 +8322,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "winprint" -version = "0.2.1" -source = "git+https://github.com/storytold/winprint.rs?rev=3c978dfcf3d460577fabd87aeea7a84ffdcfb396#3c978dfcf3d460577fabd87aeea7a84ffdcfb396" -dependencies = [ - "flate2", - "fmt-derive", - "reqwest", - "scopeguard", - "sigstore-trust-root", - "sigstore-types", - "sigstore-verify", - "strum", - "tar", - "thiserror 2.0.21", - "windows", - "windows-core", - "windows-implement", - "windows-numerics", - "xml", -] - [[package]] name = "winprint" version = "0.2.1" @@ -8363,6 +8343,28 @@ dependencies = [ "xml", ] +[[package]] +name = "winprint" +version = "0.2.1" +source = "git+https://github.com/storytold/winprint.rs?rev=bc06a7c48b768eb27a828c7986f6d2868b245ee0#bc06a7c48b768eb27a828c7986f6d2868b245ee0" +dependencies = [ + "flate2", + "fmt-derive", + "reqwest", + "scopeguard", + "sigstore-trust-root", + "sigstore-types", + "sigstore-verify", + "strum", + "tar", + "thiserror 2.0.21", + "windows", + "windows-core", + "windows-implement", + "windows-numerics", + "xml", +] + [[package]] name = "winreg" version = "0.55.0" @@ -8404,7 +8406,7 @@ dependencies = [ "libc", "log", "os_pipe", - "rustix 1.1.5", + "rustix 1.1.6", "thiserror 2.0.21", "tree_magic_mini", "wayland-backend", @@ -8462,7 +8464,7 @@ dependencies = [ "libc", "libloading", "once_cell", - "rustix 1.1.5", + "rustix 1.1.6", "x11rb-protocol", ] @@ -8504,7 +8506,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" dependencies = [ "libc", - "rustix 1.1.5", + "rustix 1.1.6", ] [[package]] @@ -8611,7 +8613,7 @@ dependencies = [ "hex", "libc", "ordered-stream", - "rustix 1.1.5", + "rustix 1.1.6", "serde", "serde_repr", "tracing", diff --git a/Cargo.toml b/Cargo.toml index 30367fa35c..9cd5b3b7c3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ exclude = ["vendor"] default-members = ["crates/*", "apps/pdfcraft", "apps/pdfcraft-cli", "xtask"] [workspace.package] -version = "0.18.1" +version = "0.21.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" @@ -26,47 +26,47 @@ craft-keymap = { git = "https://github.com/storytold/craft-libs", version = "0.1 # the commit in `craft-i18n.rev`). Already in the tree through craft-ui. craft-i18n = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" } # Shared system spooler; document composition remains in pdfcraft-print. -craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "1ee01a4c58729d781638b8afbd8982726dbdefcb" } +craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "60ede99985ef1e5df87a2aca66ed538eb23dec99" } # Whether a path stored in a document may be opened (GoToR link targets). craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" } # craft-update: app-specific pretend versions apply to startup and manual checks. -craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "1b34eba41e62138fc2438d2734523b302840b45d", features = ["egui"] } +craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b", features = ["egui"] } # internal -pdfcraft-geom = { path = "crates/geom", version = "0.18.1" } -pdfcraft-filters = { path = "crates/filters", version = "0.18.1" } -pdfcraft-cos = { path = "crates/cos", version = "0.18.1" } -pdfcraft-crypt = { path = "crates/crypt", version = "0.18.1" } -pdfcraft-organize = { path = "crates/organize", version = "0.18.1" } -pdfcraft-measure = { path = "crates/measure", version = "0.18.1" } -pdfcraft-annot = { path = "crates/annot", version = "0.18.1" } -pdfcraft-audit = { path = "crates/audit", version = "0.18.1" } -pdfcraft-fonts = { path = "crates/fonts", version = "0.18.1" } -pdfcraft-model = { path = "crates/model", version = "0.18.1" } -pdfcraft-content = { path = "crates/content", version = "0.18.1" } -pdfcraft-redact = { path = "crates/redact", version = "0.18.1" } -pdfcraft-print = { path = "crates/print", version = "0.18.1" } -pdfcraft-xfdf = { path = "crates/xfdf", version = "0.18.1" } -pdfcraft-sign = { path = "crates/sign", version = "0.18.1" } -pdfcraft-optimize = { path = "crates/optimize", version = "0.18.1" } -pdfcraft-a11y = { path = "crates/a11y", version = "0.18.1" } -pdfcraft-oracle = { path = "crates/oracle", version = "0.18.1" } -pdfcraft-testkit = { path = "crates/testkit", version = "0.18.1" } -pdfcraft-ocr = { path = "crates/ocr", version = "0.18.1" } -pdfcraft-js = { path = "crates/js", version = "0.18.1" } -pdfcraft-compare = { path = "crates/compare", version = "0.18.1" } -pdfcraft-preflight = { path = "crates/preflight", version = "0.18.1" } -pdfcraft-export = { path = "crates/export", version = "0.18.1" } -pdfcraft-edit = { path = "crates/edit", version = "0.18.1" } -pdfcraft-create = { path = "crates/create", version = "0.18.1" } -pdfcraft-forms = { path = "crates/forms", version = "0.18.1" } -pdfcraft-xfa = { path = "crates/xfa", version = "0.18.1" } -pdfcraft-render = { path = "crates/render", version = "0.18.1" } -pdfcraft-engine = { path = "crates/engine", version = "0.18.1" } -pdfcraft-platform = { path = "crates/platform", version = "0.18.1" } -pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.18.1" } -pdfcraft-automation = { path = "crates/automation", version = "0.18.1", default-features = false } -pdfcraft-prc = { path = "crates/prc", version = "0.18.1" } -pdfcraft-prc-render = { path = "crates/prc-render", version = "0.18.1" } +pdfcraft-geom = { path = "crates/geom", version = "0.21.0" } +pdfcraft-filters = { path = "crates/filters", version = "0.21.0" } +pdfcraft-cos = { path = "crates/cos", version = "0.21.0" } +pdfcraft-crypt = { path = "crates/crypt", version = "0.21.0" } +pdfcraft-organize = { path = "crates/organize", version = "0.21.0" } +pdfcraft-measure = { path = "crates/measure", version = "0.21.0" } +pdfcraft-annot = { path = "crates/annot", version = "0.21.0" } +pdfcraft-audit = { path = "crates/audit", version = "0.21.0" } +pdfcraft-fonts = { path = "crates/fonts", version = "0.21.0" } +pdfcraft-model = { path = "crates/model", version = "0.21.0" } +pdfcraft-content = { path = "crates/content", version = "0.21.0" } +pdfcraft-redact = { path = "crates/redact", version = "0.21.0" } +pdfcraft-print = { path = "crates/print", version = "0.21.0" } +pdfcraft-xfdf = { path = "crates/xfdf", version = "0.21.0" } +pdfcraft-sign = { path = "crates/sign", version = "0.21.0" } +pdfcraft-optimize = { path = "crates/optimize", version = "0.21.0" } +pdfcraft-a11y = { path = "crates/a11y", version = "0.21.0" } +pdfcraft-oracle = { path = "crates/oracle", version = "0.21.0" } +pdfcraft-testkit = { path = "crates/testkit", version = "0.21.0" } +pdfcraft-ocr = { path = "crates/ocr", version = "0.21.0" } +pdfcraft-js = { path = "crates/js", version = "0.21.0" } +pdfcraft-compare = { path = "crates/compare", version = "0.21.0" } +pdfcraft-preflight = { path = "crates/preflight", version = "0.21.0" } +pdfcraft-export = { path = "crates/export", version = "0.21.0" } +pdfcraft-edit = { path = "crates/edit", version = "0.21.0" } +pdfcraft-create = { path = "crates/create", version = "0.21.0" } +pdfcraft-forms = { path = "crates/forms", version = "0.21.0" } +pdfcraft-xfa = { path = "crates/xfa", version = "0.21.0" } +pdfcraft-render = { path = "crates/render", version = "0.21.0" } +pdfcraft-engine = { path = "crates/engine", version = "0.21.0" } +pdfcraft-platform = { path = "crates/platform", version = "0.21.0" } +pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.21.0" } +pdfcraft-automation = { path = "crates/automation", version = "0.21.0", default-features = false } +pdfcraft-prc = { path = "crates/prc", version = "0.21.0" } +pdfcraft-prc-render = { path = "crates/prc-render", version = "0.21.0" } # pdf hayro = "0.7.1" diff --git a/ROADMAP.md b/ROADMAP.md index f7573603c7..8472b709d9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,3 +1,8 @@ +- 2026-11-04 (M6, PR #5699 group/cache/MK/button review): Expand calculation groups/duplicates with streaming bounded lookup; retain one canonical Field object per exposed identity and prune stale roots. Protect outside shared-MK owners and carry exact button IDs through native clicks and js_run, with regression and persistence coverage. +- 2026-11-04 (M6, PR #5699 early reset/duplicate identities): Enforce the shared 64-call reset budget before argument conversion/scanning, with sticky atomic rejection and Open/event budget sharing. Preserve PDF object identity in Calculate order, live targets and value writeback; regress duplicate names, reversed order, native completion and persistence. +- 2026-11-04 (M6, PR #5699 independent review): Preserve unknown defaults through the JS reset shadow; bound menu/response text before copies. Cover native/console reset persistence, inherited defaults, decode-budget exhaustion and exact Unicode dialog boundaries. +- 2026-11-04 (M6, PR #5699 memory/widget bounds): Count reset widgets and bound escaped UTF-8 text conversion before copies; add negative and Unicode boundary regressions. +- 2026-11-04 (M6, PR #5699 reset estimate): Charge expanded-name scans and field-name copy/comparison bytes; add three negative regressions and byte-exact refusal checks. # PdfCraft roadmap > **Last reviewed:** 2026-10-31 | **Last updated:** 2026-10-31 | **Change:** patch (M6, PR #4166/#4088: integrate main f137a289 at 0.16.1; retain automatic-field Print suppression, explicit button/console printing and document-error provenance; 761 scoped tests and verification gates passed; 13-step CLI exercise and render inspection passed; existing warnings retained; estimates unchanged) @@ -6,22 +11,25 @@ > **Last reviewed:** 2026-11-08 | **Last updated:** 2026-11-08 | **Change:** patch (M10 #6514: refresh craft-print to 7ee35a9f, PDF loader to 51934db3 and public GDI kept at 64e7afd4 after #605 merged; preserve crash fix and stream-test repairs; new-graph CI pending; estimates unchanged) > **Last reviewed:** 2026-11-07 | **Last updated:** 2026-11-07 | **Change:** patch (M10: file-backed Windows PDF loading without whole-job buffering or per-chunk async waits; approved dependency chain retains #5006; physical output still unqualified; estimates unchanged) - > **Last reviewed:** 2026-11-05 | **Last updated:** 2026-11-05 | **Change:** patch (M7 #2964: directly edit Thai source text inside supported Form XObjects; estimates unchanged) - > **Last reviewed:** 2026-11-05 | **Last updated:** 2026-11-05 | **Change:** minor (M3, #5452: native Recent rows gain Reveal in Finder / File Explorer; estimates unchanged) - > **Last reviewed:** 2026-11-03 | **Last updated:** 2026-11-03 | **Change:** patch (M5 #5850 review: indirect typewriter intent and composer edits preserve outer whitespace; movement regression commits by outside click; estimates unchanged) - > **Last reviewed:** 2026-11-03 | **Last updated:** 2026-11-03 | **Change:** patch (M5 #5850: Enter adds Fill & Sign line breaks; outside clicks commit, Escape cancels, and saved multiline appearances retain their text and height; estimates unchanged) > **Last reviewed:** 2026-10-31 | **Last updated:** 2026-10-31 | **Change:** minor (M3/M4, #5330: Crop pages is directly available in the document quick toolbar; estimates unchanged) - - +> **2026-11-04 (M6, PR #5699 reset budget):** Reject reset amplification before request copies, preview and PDF mutation; cap reset/selector counts and aggregate estimated work. Add native completion, boundary, flood and large-form regressions; preserve ordered under-budget resets. +> **2026-11-04 (M6, PR #5699 dependent reset):** Reuse form reset on an isolated comparison document so unnamed built-in calculated text/combo fields are included; retain normal post-write calculation. Extend persisted-default and typed-response regressions. +> **2026-11-04 (M6, PR #5699 reset ordering):** Reset live field values synchronously, commit resets before final script deltas using a reset baseline, and cover Doc Open reinitialization, save/reopen, Undo/Redo and replay-sensitive UI counters. +> **2026-11-04 (M6, PR #5699 review):** Bound worker capacity and synchronous deadlines, isolate expired/pending realms before edits, keep script prompts above other dialogs, and cover block ASI, control-position regex literals, event routing and complete undo state. Validation is reported in the PR; parity remains partial. +> **2026-11-03 (M6, PR #5699 follow-up):** Accept independent top-level statements separated by automatic semicolon insertion while retaining continuation-chain limits; assert cyclic calculation results and preserve upstream multiline Fill & Sign coverage. +> **2026-11-02 (M6, PR #5699 second review):** Count active expression chains instead of whole-source tokens; align native button events and automation schemas, block document input under script prompts, and validate prompt dismissal and idle repaint scheduling. +> **2026-11-02 (M6, PR #5699 review):** Cache per-edit script geometry/calculation plans, expose live document metadata, and bound persistent worker stacks to 64 MiB; add operation-count, lifecycle and isolated hostile-input/address-space regressions. +> **2026-11-02 (M6, #5211/#269):** Integrate persistent native AcroForm realms, synchronous calculation, page boxes, appearance/geometry/icon writeback and native button prompt continuation. Synthetic workflow and cancellation tests added; validation details accompany the PR. Remaining JavaScript parity stays partial. > **Last reviewed:** 2026-11-01 | **Last updated:** 2026-11-01 | **Change:** patch (M7, PR #2155 further review: inherited drawing/text state, affine image gestures, paste/selection ownership, no-op scripts and menu permissions; estimates unchanged) > **Last reviewed:** 2026-11-01 | **Last updated:** 2026-11-01 | **Change:** patch (M7, PR #2155 latest comments: explicit image-paste events, mixed-size field refresh, and documented mixed-script normalization with Undo; estimates unchanged) > **Last reviewed:** 2026-11-01 | **Last updated:** 2026-11-01 | **Change:** patch (M7, PR #2155 review follow-up: preserve image crop/tag/stream ownership, resize in page orientation, harden clipboard and text formatting, and reload installed fonts; estimates unchanged) + > **Last reviewed:** 2026-10-31 | **Last updated:** 2026-10-31 | **Change:** patch (M2, PR #4172: GBK rasters compared with explicit Unicode and independently selected installed faces; wrong-glyph and wrong-family negative controls fail; estimates unchanged) > **Last reviewed:** 2026-10-31 | **Last updated:** 2026-10-31 | **Change:** patch (M2, PR #4172: integrate main fa5f9f8c; retain CJK viewer and upstream raster regressions, restore explicit display_config imports; estimates unchanged) > **Last reviewed:** 2026-10-28 · **Last updated:** 2026-10-28 · **Change:** patch (M2, PR #4172: integrate main 5af10276; retain both roadmap histories and viewer-only CJK font policy; estimates unchanged) @@ -80,6 +88,7 @@ > **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M8, #1350: named replacement descriptions retired during Sanitize and across shared resources; estimates unchanged) > **Last reviewed:** 2026-10-17 · **Last updated:** 2026-10-17 · **Change:** minor (M4 #2997: Ctrl-drag (Option on macOS) in the Organize grid copies the pages into the drop gap; `page_duplicate` takes `at`; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M6: AcroForm XML text; estimates unchanged) +> **Last reviewed:** 2026-10-17 · **Last updated:** 2026-10-17 · **Change:** minor (M10, gap 8: Office documents open through an installed LibreOffice; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M2/gap 12: the Annex D.2 alias codes read as the ink they draw in both tables; the text oracle scores reading order; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M14/#2383 GPU-limit candidate; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (M4: indirect page rotations; M13: CLI check visits each directory once through aliases and cycles; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -297,8 +306,6 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-11-02 (M10, #5743):** macOS print jobs name their title and owner (`com.apple.print.JobInfo.PMJobName`, `PMJobOwner`), as the system print dialog does, because a Kyocera filter crashes on a job without them. Checked by a test and through `doc_print` against a stand-in `lp`; not yet on a physical printer. -- **2026-10-31 (M3, #5452):** Native Home Recent rows offer Reveal in Finder / File Explorer from their context menu. They reuse the tab action’s platform dispatch and missing-file notification without opening the PDF or changing Recent. Browser behavior and milestone estimates unchanged. - - **2026-11-02 (M3, #5708):** Pages shorter than the window sit in its middle, one page or row at a time and when scrolling continuously with every page in view. - **2026-11-02 (M3, #5716):** A snapshot is drawn at 300 ppi, or at the zoom if that is finer, so one taken zoomed out stays readable; an area too large for one 4096-pixel tile comes out as fine as a tile allows (about 147 ppi for 2000 pt). A choice of resolution and a selection that stays active remain. - **2026-11-02 (M14, #5737, #5460):** Preferences can turn off the update check at start. It stays on by default, and Help ▸ Check for updates still asks. @@ -316,6 +323,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - 2026-11-01: PR #2155 maintainer edits remove redundant image/text helpers and add regressions for crop ownership, arrange scope/state fidelity, clipboard precedence, crop-drag bounds, and font scanning. Transform and Unicode-script tools complete the headless paths. - **2026-10-31 (M3/M4, #5330):** The document quick toolbar offers Crop pages directly through the existing page.crop command, with the command’s assembly-permission check and an active-tool highlight. The existing crop, undo and headless page_set_box paths are unchanged. No toolbar customization or new shortcut. Estimates unchanged. +- **2026-10-30 (M6):** Button scripts can read and change the normal push-button caption with `buttonGetCaption` and `buttonSetCaption`. The caption is drawn and retained through undo/redo and save/reopen; unsupported face indices are refused. Estimates unchanged. - **2026-10-18 (M3, #1354, local candidate):** Integrated --two-paged with current main, retaining upstream modal/picker ownership and single-tab reuse. Native launch/opening/password regressions, negative checks against the old wrapper, visual inspection, formatting, strict scoped Clippy, layers, assets, parity and scoped WASM compilation pass. Three existing socket tests are unavailable in this workspace; one existing network test remains ignored. No push or GitHub merge; estimates unchanged. - **2026-10-31 (M2, PR #4172 review):** Added same-machine pixel oracles for GBK Song, Hei, Kai and FangSong, including all four subset-prefixed Founder aliases. Reference PDFs use explicit Unicode mappings and independently selected installed faces; font data and rasters remain in memory. All eight cases ran; wrong-glyph and wrong-family production mutations each failed the new assertion and were restored. Validation: 422 renderer unit tests, renderer all-target Clippy with -D warnings, formatting, layers/assets/parity and renderer WASM checks passed; existing optional-patch/WASM warnings and parity notes remain. Estimates unchanged. @@ -488,6 +496,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-16 (M14, localization):** Czech goes from menus-and-dialogs to the whole surface: 2,674 entries after adding the ~220 keys the other catalogs had -- the sentences the engine words (signature details, accessibility findings and report, PDF/A problems, pages to print, history labels), the date picker's weekday heads, the certificate viewer's fields and shared buttons. "Tag" is *tag* throughout (the Tags panel said *značky*, which is also a redaction mark), and "{n} tags" no longer reads "1 značek". Two new catalog tests. Estimates unchanged. - **2026-10-16 (M5, #2583, #1869):** A sticky note on a page with `/Rotate` is drawn upright, with its top-left where it was placed. Notes are saved with the NoRotate flag, and the renderer now honours it: the appearance is turned against the page's rotation about the upper-left corner of `/Rect`, which stays put (ISO 32000-2 §12.5.3; vendored hayro-interpret patch 37). Attachment icons follow the same rule. Selection, hover, the edit composer, the Comments panel's flash and the `comment_list` and `doc_info` rectangles use the drawn box, and `comment_add` anchors an icon's displayed top-left at `at`. Estimates unchanged. - **2026-10-16 (M11, #1201):** Optimize decodes a JPEG with its colour transform: an Adobe APP14 marker's flag, else the filter's `/ColorTransform` (ISO 32000-2 §7.4.8). An RGB JPEG stored untransformed (`/ColorTransform 0`, no marker) used to be converted from YCbCr anyway and came out in other colours, recompressed or merged from strips; it now keeps them. A JPEG whose marker and `/ColorTransform` disagree, or whose components are named R, G and B without `/ColorTransform 0`, is left as it is, since readers differ on it. Estimates unchanged. +- **2026-10-17 (M10, gap 8):** File ▸ Open opens Word, Excel, PowerPoint, OpenDocument and RTF files as new, unsaved PDFs, converted on the open worker by an installed LibreOffice (headless, a private temporary folder and fresh profile with macros off, stopped after 120 s); without LibreOffice the user is told it is needed. The Open dialog lists PDF (the default), Images, Office documents, Text and All files. Insert, Combine and Create ▸ Multiple files refuse Office documents for now, since they convert on the frame thread. `create.from-office` planned → partial. On 2026-10-26 the profile also stopped Writer and Calc from updating linked content (Impress has no such setting), and tests that skip without LibreOffice convert generated DOCX, XLSX, PPTX and RTF files through `doc_create_multiple` and `doc_combine` and check their text. Estimates unchanged. - **2026-10-15 (M2, gap 12):** `cargo xtask text-oracle` scores reading order as well as word F1: the longest common subsequence of the two word sequences over the longer one. F1 is a bag of words and cannot see order at all -- measuring #2431, a change that moves text and nothing else, it called 26 of the 29 files whose output changed identical, where the order score separated 18 improvements from 6 regressions. Reports, does not gate; a disagreement with Poppler, not a verdict. Estimates unchanged. - **2026-10-15 (M2, #2305 follow-up):** WinAnsiEncoding 0xA0 and 0xAD and MacRomanEncoding 0xCA extract as the ordinary space and hyphen Annex D.2 names them, in the vendored hayro tables (patch 30) as well as `crates/fonts`, so search, copy and redaction see characters that were hidden behind U+00A0 and U+00AD. Across the pdf.js corpus the soft hyphens we extract fall 117 to 13 and the non-breaking spaces 30 to 26; the rest come from `/ToUnicode`. An explicit `/Differences` name keeps its glyph list value, as pdftotext does. Estimates unchanged. - **2026-10-15 (M14, gap 16, #1683):** Split view. View ▸ Split (⌘\\ / Ctrl+\\) and a tab's Split right / Split down show two panes, each with its own tab strip, scroll and zoom; a tab dropped on the right half of the page area splits right, and the divider drags. One document in both panes is one document with one Undo; closing one of its views keeps it open. The pane last clicked is the one menus, keys and panels act on. Nothing about the panes is kept across restarts. `view.split-window` planned → shipped. Multiple windows stay planned. Estimates unchanged. @@ -991,3 +1000,5 @@ PR #2133 retains document-local Pages sidebar focus for Ctrl/Cmd+A and Escape af PR #1952 retains per-widget checkbox/radio export-value editing in the split form-authoring and Prepare modules. Caption editing remains supported. Tests cover preserved appearances and selection, invalid/locked refusals, data-format round trips, and Apply/Cancel/Undo. Current-head hosted validation is pending; feature estimates are unchanged. - **2026-10-31 (#1577):** Reconciled the page-shadow preference with split viewer, organizer, settings and control modules, preserving upstream page-gap shadow suppression. CI validation pending. + +- 2026-11-04: AcroForm review follow-up covers field-instance retirement across history, rejected-writeback realm invalidation, mixed widget icons, shared calculation budgets and multi-select/checkbox consistency, frozen-global events, exact image imports, comparison-half scripts, focus reveal and explicit busy/timeout feedback. Synthetic regressions and elapsed UI deadlines accompany the fixes. diff --git a/crates/a11y/src/headings.rs b/crates/a11y/src/headings.rs index 235ba4b452..a9de970b65 100644 --- a/crates/a11y/src/headings.rs +++ b/crates/a11y/src/headings.rs @@ -206,7 +206,9 @@ fn sub(doc: &Document, d: &Dict, key: &[u8]) -> Dict { pub(crate) fn mcid_texts(doc: &Document, page: &Dict) -> HashMap { let res = sub(doc, page, b"Resources"); let (fonts, props) = (sub(doc, &res, b"Font"), sub(doc, &res, b"Properties")); - let mut metrics: HashMap, Metrics> = HashMap::new(); + let mut metrics: HashMap, std::rc::Rc> = HashMap::new(); + // Built once per font object, however many resource names alias it. + let mut by_font: HashMap> = HashMap::new(); // The id each open marked-content sequence is in: its own, else the enclosing one's. let mut stack: Vec> = Vec::new(); let mut out: HashMap = HashMap::new(); @@ -255,9 +257,30 @@ pub(crate) fn mcid_texts(doc: &Document, page: &Dict) -> HashMap { gs.font = op.name(0).map(<[u8]>::to_vec); if let Some(name) = op.name(0) && !metrics.contains_key(name) - && let Some(f) = fonts.get(name).map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned()) { - metrics.insert(name.to_vec(), Metrics::from_dict(doc, &f)); + // Keyed by the object at the end of the reference chain, so names that reach one + // font through different chains share its metrics (the hop limit is resolve's). + let font = fonts.get(name).and_then(|o| { + let (mut cur, mut last) = (o.clone(), None); + for _ in 0..16 { + let Object::Ref(r) = cur else { break }; + last = Some(r); + cur = (*doc.get(r)).clone(); + } + last + }); + let built = match font.and_then(|r| by_font.get(&r)) { + Some(m) => Some(m.clone()), + None => { + fonts.get(name).map(|f| doc.resolve(f)).and_then(|f| f.as_dict().map(|d| std::rc::Rc::new(Metrics::from_dict(doc, d)))) + } + }; + if let Some(m) = built { + if let Some(r) = font { + by_font.insert(r, m.clone()); + } + metrics.insert(name.to_vec(), m); + } } } b"Tc" => gs.char_spacing = op.num(0).unwrap_or(0.0), diff --git a/crates/annot/src/modify.rs b/crates/annot/src/modify.rs index ae2222b387..6566337ca1 100644 --- a/crates/annot/src/modify.rs +++ b/crates/annot/src/modify.rs @@ -857,19 +857,38 @@ pub fn set_points(doc: &mut Document, page: usize, index: usize, points: &[[f64; /// Scale a drawing's strokes into `rect`. The margin between the strokes and the old `/Rect` (half /// the line width and a little more) stays the same on each side, so the line isn't clipped. +/// Most stroke points a stored drawing may have for resizing or erasing it. Counted as each +/// stroke is resolved, so one stroke array referenced again and again can't expand past it. +const MAX_STORED_INK_POINTS: usize = 1_000_000; +/// Most points the eraser densifies a drawing into before testing them against its path. +const MAX_ERASE_SAMPLES: usize = 4_000_000; + +/// The strokes of an ink annotation's `/InkList`, at most [`MAX_STORED_INK_POINTS`] points. +fn ink_strokes(doc: &Document, d: &Dict) -> Result>, AnnotError> { + let too_many = || AnnotError::Invalid(format!("the drawing has more than {MAX_STORED_INK_POINTS} points")); + let list = d.get(b"InkList").map(|l| doc.resolve(l)); + let mut points = 0usize; + let mut strokes = Vec::new(); + for s in list.as_deref().and_then(Object::as_array).map(Vec::as_slice).unwrap_or_default() { + let stroke = doc.resolve(s); + let Some(a) = stroke.as_array() else { + strokes.push(Vec::new()); + continue; + }; + // Charged before the coordinates are read: a shared array costs each time it is listed. + points = points.saturating_add(a.len() / 2); + if points > MAX_STORED_INK_POINTS { + return Err(too_many()); + } + let v: Vec = a.iter().filter_map(|x| doc.resolve(x).as_f64()).collect(); + strokes.push(v.as_chunks::<2>().0.iter().map(|p| [p[0], p[1]]).collect()); + } + Ok(strokes) +} + fn resize_ink(doc: &mut Document, r: ObjRef, rect: [f64; 4], meta: &Meta) -> Result<(), AnnotError> { let d = annot_dict(doc, r); - let strokes: Vec> = d - .get(b"InkList") - .map(|l| doc.resolve(l)) - .and_then(|l| l.as_array().cloned()) - .unwrap_or_default() - .iter() - .map(|s| { - let v: Vec = doc.resolve(s).as_array().map(|a| a.iter().filter_map(|x| doc.resolve(x).as_f64()).collect()).unwrap_or_default(); - v.as_chunks::<2>().0.iter().map(|p| [p[0], p[1]]).collect() - }) - .collect(); + let strokes = ink_strokes(doc, &d)?; let Some(inner) = bounds(strokes.iter().flatten().copied()) else { return Err(AnnotError::Invalid("the drawing has no strokes".into())); }; @@ -1475,17 +1494,7 @@ pub fn erase_ink(doc: &mut Document, page: usize, index: usize, path: &[[f64; 2] if path.is_empty() || !path.iter().all(|p| finite(p)) || !radius.is_finite() || radius <= 0.0 { return Err(AnnotError::Invalid("invalid eraser path".into())); } - let strokes: Vec> = d - .get(b"InkList") - .map(|l| doc.resolve(l)) - .and_then(|l| l.as_array().cloned()) - .unwrap_or_default() - .iter() - .map(|s| { - let v: Vec = doc.resolve(s).as_array().map(|a| a.iter().filter_map(|x| doc.resolve(x).as_f64()).collect()).unwrap_or_default(); - v.as_chunks::<2>().0.iter().map(|p| [p[0], p[1]]).collect() - }) - .collect(); + let strokes = ink_strokes(doc, &d)?; // Distance from a point to the eraser's path (segments, or a single point). let near = |p: [f64; 2]| -> bool { if path.len() == 1 { @@ -1501,17 +1510,27 @@ pub fn erase_ink(doc: &mut Document, page: usize, index: usize, path: &[[f64; 2] }; let mut kept: Vec> = Vec::new(); let mut erased = false; + let mut samples = 0usize; for s in &strokes { // Densify so long segments are cut where the eraser crosses them. let mut pts: Vec<[f64; 2]> = Vec::new(); for w in s.windows(2) { let n = ((w[1][0] - w[0][0]).hypot(w[1][1] - w[0][1]) / (radius / 2.0)).ceil().clamp(1.0, 2000.0) as usize; + samples = samples.saturating_add(n); + if samples > MAX_ERASE_SAMPLES { + return Err(AnnotError::Invalid("the drawing is too large to erase at this eraser size".into())); + } for k in 0..n { let t = k as f64 / n as f64; pts.push([w[0][0] + (w[1][0] - w[0][0]) * t, w[0][1] + (w[1][1] - w[0][1]) * t]); } } if let Some(last) = s.last() { + // The final point is a sample too. + samples = samples.saturating_add(1); + if samples > MAX_ERASE_SAMPLES { + return Err(AnnotError::Invalid("the drawing is too large to erase at this eraser size".into())); + } pts.push(*last); } let mut run: Vec<[f64; 2]> = Vec::new(); @@ -1537,6 +1556,12 @@ pub fn erase_ink(doc: &mut Document, page: usize, index: usize, path: &[[f64; 2] delete_annotation(doc, page, index)?; return Ok(true); } + // The retained runs are stored as the new `/InkList`: past the cap, writing them would leave a + // drawing that later `resize_ink` and `erase_ink` refuse to read. + let points: usize = kept.iter().map(Vec::len).sum(); + if points > MAX_STORED_INK_POINTS { + return Err(AnnotError::Invalid(format!("erasing would leave a drawing with more than {MAX_STORED_INK_POINTS} points"))); + } let width = d.get(b"BS").and_then(|b| b.as_dict()).and_then(|b| b.get(b"W")).and_then(|w| w.as_f64()).unwrap_or(1.0); let rect = grow(bounds(kept.iter().flatten().copied()).unwrap_or_default(), width / 2.0 + 1.0); doc.update_dict(r, |d| { diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index ce5a8c1604..980959d4d9 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -4797,6 +4797,73 @@ fn freehand_highlight_is_compatible_ink_with_marker_blending() { } } +/// A drawing whose /InkList lists one stroke array over and over is refused once its points +/// pass the cap, counted as each stroke is resolved, before they are collected or scaled. +#[test] +fn ink_points_are_counted_as_strokes_resolve() { + let numbers: String = (0..2000).map(|k| format!(" {}", k % 100)).collect(); + let refs = " 5 0 R".repeat(501); + let doc_with = |refs: &str| { + doc_of(&[ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>".into(), + format!("<< /Type /Annot /Subtype /Ink /Rect [0 0 100 100] /InkList [{refs}] >>"), + format!("[{numbers}]"), + ]) + }; + // 501 × 1,000 points is under the cap: the points are read (this bare drawing then has no + // appearance it can be redrawn with, which is another matter). + let mut doc = doc_with(&refs); + let under = set_rect(&mut doc, 0, 0, [0.0, 0.0, 50.0, 50.0], &meta("")); + assert!(!matches!(&under, Err(AnnotError::Invalid(m)) if m.contains("points")), "{under:?}"); + // 1,001 × 1,000 points is over it. + let mut doc = doc_with(&" 5 0 R".repeat(1001)); + match set_rect(&mut doc, 0, 0, [0.0, 0.0, 50.0, 50.0], &meta("")) { + Err(AnnotError::Invalid(m)) => assert!(m.contains("more than 1000000 points"), "{m}"), + other => panic!("refused: {other:?}"), + } + match erase_ink(&mut doc, 0, 0, &[[1.0, 1.0]], 1.0, &meta("")) { + Err(AnnotError::Invalid(m)) => assert!(m.contains("more than 1000000 points"), "{m}"), + other => panic!("refused: {other:?}"), + } +} + +/// The eraser's densified samples have a total: a tiny eraser over long strokes is refused +/// instead of interpolating millions of points. +#[test] +fn the_eraser_densifies_within_a_total() { + let zigzag: String = (0..2100).map(|k| format!(" {} {}", if k % 2 == 0 { 0 } else { 5000 }, k)).collect(); + let mut doc = doc_of(&[ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>".into(), + format!("<< /Type /Annot /Subtype /Ink /Rect [0 0 5000 2100] /InkList [[{zigzag}]] >>"), + ]); + match erase_ink(&mut doc, 0, 0, &[[1.0, 1.0]], 0.01, &meta("")) { + Err(AnnotError::Invalid(m)) => assert!(m.contains("too large to erase"), "{m}"), + other => panic!("refused: {other:?}"), + } +} + +/// Each stroke's final point is a sample too: 500,000 strokes of two points at 8 samples a segment +/// are 4,000,000 densified samples and as many final points again, past the total. +#[test] +fn the_eraser_counts_each_strokes_final_point() { + let refs = " 5 0 R".repeat(500_000); + let mut doc = doc_of(&[ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>".into(), + format!("<< /Type /Annot /Subtype /Ink /Rect [0 0 100 100] /InkList [{refs}] >>"), + "[0 0 8 0]".into(), + ]); + match erase_ink(&mut doc, 0, 0, &[[1.0, 1.0]], 2.0, &meta("")) { + Err(AnnotError::Invalid(m)) => assert!(m.contains("too large to erase"), "{m}"), + other => panic!("refused: {other:?}"), + } +} + /// A 3D annotation is PDF 1.6 and a PRC model 1.7 (ISO 32000-2 §13.6): an older document's /// catalog says so, and a newer one is left as it is. #[test] diff --git a/crates/automation/README.md b/crates/automation/README.md index cf9e82982b..82a00c509a 100644 --- a/crates/automation/README.md +++ b/crates/automation/README.md @@ -333,6 +333,14 @@ navigation-history tool; use the opt-in UI control channel to navigate the displ FreeText appearance and survives reopening. Other mark/signature types reject `color`; their appearance controls are separate. +## AcroForm script interactions + +`js_run` accepts optional `answers` (strings or null, at most 32), consumed in popup, response and alert order. Missing responses cancel menus/input; explicit viewer requests remain data in automation. See [the runtime contract](../../docs/acroform-script-workflows.md). + +For distinct PDF fields sharing a name, form_fields exposes a document-local integer field_id. Pass it with field to js_run to bind event.target to that exact field (including ordered answers); omit it for existing first-match behavior. Mismatched names/IDs, missing names and non-integer/out-of-range IDs are refused before execution. + +`form_set_image` accepts the optional document-local `field_id` from `form_fields` alongside the matching `field` name, selecting an exact duplicate-name image button. Omit it for the legacy first-name match. IDs are unsigned 48-bit integers and must be rediscovered after structural edits or reopening. + ## Reviewing OCR headlessly `ocr_words {doc, retain_review: true}` reads words without editing the document and returns diff --git a/crates/automation/src/a11y/documents.rs b/crates/automation/src/a11y/documents.rs index 137c98c5d4..bfc8b3379a 100644 --- a/crates/automation/src/a11y/documents.rs +++ b/crates/automation/src/a11y/documents.rs @@ -118,6 +118,9 @@ impl Automation { Ok(json!({ "actions": actions, "steps": steps })) } + /// `action_run`: run a named action or a list of steps on each file in `paths`, writing the + /// results into `folder`; each file is processed in its own session that keeps this + /// session's JavaScript preference. pub(crate) fn action_run(&mut self, a: &Args) -> Result { use pdfcraft_engine::actions::{Action, Step, builtin, run_on}; let action = if let Some(name) = a.opt_str("action")? { @@ -138,6 +141,8 @@ impl Automation { }; let folder = self.resolve(a.str("folder")?, true)?; std::fs::create_dir_all(&folder).map_err(|e| failed(e.to_string()))?; + // Each file runs in its own session; it keeps this one's JavaScript preference. + let javascript = self.session.javascript(); let mut out = Vec::new(); for p in a.strs("paths")? { let name = std::path::Path::new(p).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "document.pdf".into()); @@ -145,7 +150,7 @@ impl Automation { .resolve(p, false) .map_err(|e| e.to_string()) .and_then(|src| crate::read_input_bytes(&src).map_err(|e| e.to_string())) - .and_then(|bytes| run_on(&action, &name, std::sync::Arc::new(bytes), |_, _| {})); + .and_then(|bytes| run_on(&action, &name, std::sync::Arc::new(bytes), javascript, |_, _| {})); out.push(match result { Ok(r) => { let target = crate::batch::publish(&child(&folder, &name), &r.bytes)?; diff --git a/crates/automation/src/a11y/form_tools.rs b/crates/automation/src/a11y/form_tools.rs index dee8eff646..0ef6b92821 100644 --- a/crates/automation/src/a11y/form_tools.rs +++ b/crates/automation/src/a11y/form_tools.rs @@ -11,6 +11,8 @@ fn request_json(r: &pdfcraft_engine::js::Request) -> Value { R::LaunchUrl(u) => json!({ "url": u }), R::Submit(u) => json!({ "submit": u }), R::Focus(f) => json!({ "focus": f }), + R::FocusField { name, object_id } => json!({ "focus": name, "field_id": object_id }), + R::Zoom(z) => json!({"zoom":z}), R::Beep => json!({ "beep": true }), R::Highlight(on) => json!({ "highlight": on }), R::SaveAs => json!({ "save_as": true }), @@ -19,20 +21,56 @@ fn request_json(r: &pdfcraft_engine::js::Request) -> Value { } } +/// The optional `field_id` argument: a field's identity from form_fields, selecting one of +/// fields sharing a name. +fn field_id(a: &Args) -> Result> { + a.0.get("field_id") + .map(|v| { + v.as_u64() + .filter(|id| *id < 1 << 48) + .ok_or_else(|| ToolError::InvalidArgs("field_id must be an integer between 0 and 281474976710655".into())) + }) + .transpose() +} + /// A message a script showed, as `js_run` reports it: its text, title (null when it gave none), /// icon and buttons. pub(crate) fn alert_json(m: &pdfcraft_engine::js::Message) -> Value { json!({ "message": m.text, "title": m.title, "icon": m.icon.name(), "buttons": m.buttons.name() }) } +/// What scripts that ran unasked (a document's Will Save or Will Print actions) showed, said +/// or asked for, as tools report it. +pub(crate) fn js_output_json(out: &pdfcraft_engine::js::JsOutput) -> Value { + json!({ + "alerts": out.alerts.iter().map(alert_json).collect::>(), + "console": out.console, + "requests": out.requests.iter().map(request_json).collect::>(), + "errors": out.errors, + }) +} + impl Automation { /// `js_run`: run `script` in the document, or as button `field`'s script, and report its /// messages, console lines, requests, error, the failing document scripts' errors and result. pub(crate) fn js_run(&mut self, a: &Args) -> Result { let id = self.doc(a)?.id; + let answers = match a.0.get("answers") { + None => Vec::new(), + Some(Value::Array(values)) if values.len() <= 32 => values + .iter() + .map(|v| match v { + Value::Null => Ok(None), + Value::String(s) if s.len() <= 4096 => Ok(Some(s.clone())), + _ => Err(ToolError::InvalidArgs("answers must contain strings or null".into())), + }) + .collect::>>()?, + _ => return Err(ToolError::InvalidArgs("answers must be an array of at most 32 strings or null".into())), + }; + let identity = field_id(a)?; let (script, field) = (a.str("script")?, a.opt_str("field")?); - // Applying the script's field changes runs the field scripts again; their output is reported with the script's. - let (mut o, edit) = self.session.with_own_js_output(id, |s| s.run_javascript(id, script, field)).map_err(failed)?; + let (mut o, edit) = + self.session.with_own_js_output(id, |s| s.run_javascript_answers_target(id, script, field, identity, &answers)).map_err(failed)?; o.alerts.extend(edit.alerts); o.console.extend(edit.console); o.requests.extend(edit.requests); @@ -100,6 +138,39 @@ impl Automation { ) } + /// `js_event`: run the JavaScript widget `widget` (1-based) of field `field` has for `event` + /// (a trigger id: mouse_enter, mouse_exit, mouse_down, mouse_up, on_focus, on_blur); returns + /// whether the form changed and what the script showed or asked for. + pub(crate) fn js_event(&mut self, a: &Args) -> Result { + let event = a.str("event")?; + let trigger = pdfcraft_engine::FieldTrigger::from_id(event).ok_or_else(|| { + ToolError::InvalidArgs(format!("event must be mouse_enter, mouse_exit, mouse_down, mouse_up, on_focus or on_blur, not {event:?}")) + })?; + let name = a.str("field")?; + let identity = field_id(a)?; + let doc = self.doc(a)?; + let field = + doc.form.iter().find(|f| f.name == name && identity.is_none_or(|i| pdfcraft_engine::js::field_object_id(f.obj) == i)).ok_or_else( + || match identity { + Some(i) => failed(format!("there is no field named {name:?} with field_id {i} (see form_fields)")), + None => failed(format!("there is no field named {name:?} (see form_fields)")), + }, + )?; + let widgets = field.widgets.len(); + let widget = a.opt_int("widget")?.unwrap_or(1); + let widget = usize::try_from(widget).ok().and_then(|w| w.checked_sub(1)).filter(|w| *w < widgets).ok_or_else(|| match widgets { + 0 => ToolError::InvalidArgs(format!("{name:?} has no widget")), + n => ToolError::InvalidArgs(format!("widget must be 1 to {n} for {name:?}")), + })?; + let (id, object) = (doc.id, pdfcraft_engine::js::field_object_id(field.obj)); + // Only this event's output: what opening or an earlier tool left waiting stays. + let (changed, out) = + self.session.with_own_js_output(id, |s| s.field_trigger_target(id, name, Some(object), widget, trigger)).map_err(failed)?; + let mut result = js_output_json(&out); + result["changed"] = json!(changed); + Ok(result) + } + /// `form_merge_data`: merge the form data files at `paths` into one CSV at `path`, a row per /// file; returns the path and the row and column counts. pub(crate) fn form_merge_data(&mut self, a: &Args) -> Result { diff --git a/crates/automation/src/a11y/ocr.rs b/crates/automation/src/a11y/ocr.rs index 6d1eaaa389..9f21fc285e 100644 --- a/crates/automation/src/a11y/ocr.rs +++ b/crates/automation/src/a11y/ocr.rs @@ -164,6 +164,8 @@ impl Automation { } // The engines are built (and named if they cannot be) before the first file is read. let recognizers = pdfcraft_engine::ocr::recognizers(&settings).map_err(failed)?; + // Each file opens in its own session; it keeps this one's JavaScript preference. + let javascript = self.session.javascript(); let mut out = Vec::new(); let (mut ok, mut skipped_files, mut words) = (0usize, 0usize, 0usize); for p in paths { @@ -171,7 +173,15 @@ impl Automation { let result = self.resolve(p, false).map_err(|e| e.to_string()).and_then(|src| crate::read_input_bytes(&src).map_err(|e| e.to_string())).and_then( |bytes| { - pdfcraft_engine::ocr::recognize_file(&name, std::sync::Arc::new(bytes), None, settings.clone(), &recognizers, |_, _| true) + pdfcraft_engine::ocr::recognize_file( + &name, + std::sync::Arc::new(bytes), + None, + settings.clone(), + &recognizers, + javascript, + |_, _| true, + ) }, ); match result { diff --git a/crates/automation/src/assemble.rs b/crates/automation/src/assemble.rs index 5fd9d9b0be..b091e54d19 100644 --- a/crates/automation/src/assemble.rs +++ b/crates/automation/src/assemble.rs @@ -239,6 +239,8 @@ impl Automation { Ok(json!({ "files": out })) } + /// `page_extract`: the chosen pages as one new PDF, or with `separate` one file per page in + /// `out_dir`; with `delete` the pages are then removed from the original. pub(crate) fn page_extract(&mut self, a: &Args) -> Result { let doc = self.doc(a)?; let (id, name) = (doc.id, format!("{} (extract)", doc.name)); @@ -246,16 +248,8 @@ impl Automation { let pages = self.pages(a, "pages")?; let mut out = if a.opt_bool("separate")?.unwrap_or(false) { // Each page as its own file. - let dir = self.resolve(a.str("out_dir").map_err(|_| ToolError::InvalidArgs("separate files need out_dir".into()))?, true)?; - std::fs::create_dir_all(&dir).map_err(|e| failed(format!("{}: {e}", dir.display())))?; - let mut files = Vec::new(); - for &p in &pages { - let bytes = self.session.extract(id, &[p]).map_err(failed)?; - let path = child(&dir, &format!("{stem}-page{}.pdf", p + 1)); - write_atomic(&path, &bytes)?; - files.push(path.to_string_lossy().into_owned()); - } - json!({ "files": files }) + let out_dir = a.str("out_dir").map_err(|_| ToolError::InvalidArgs("separate files need out_dir".into()))?; + json!({ "files": self.extract_separate(id, &pages, out_dir, &stem)? }) } else { let bytes = self.session.extract(id, &pages).map_err(failed)?; self.deliver(a, &name, bytes)? @@ -267,6 +261,45 @@ impl Automation { Ok(out) } + /// Each of `pages` as its own `-page.pdf` in `out_dir`, extracted and written one at a + /// time, so only one page's PDF is held at once. The first page is extracted before `out_dir` is + /// resolved and made, so a refusal (the permissions refuse every page alike, #5081) leaves nothing + /// behind; if a later page fails, the files this call created, and the directory if it made it, + /// are removed. + fn extract_separate(&self, id: pdfcraft_engine::DocId, pages: &[usize], out_dir: &str, stem: &str) -> Result> { + let Some(&first) = pages.first() else { return Ok(Vec::new()) }; + let mut first_bytes = Some(self.session.extract(id, &[first]).map_err(failed)?); + let dir = self.resolve(out_dir, true)?; + let made_dir = !dir.exists(); + std::fs::create_dir_all(&dir).map_err(|e| failed(format!("{}: {e}", dir.display())))?; + let (mut files, mut created) = (Vec::new(), Vec::new()); + let outcome = pages.iter().try_for_each(|&p| -> Result<()> { + let bytes = match first_bytes.take() { + Some(bytes) => bytes, + None => self.session.extract(id, &[p]).map_err(failed)?, + }; + let path = child(&dir, &format!("{stem}-page{}.pdf", p.saturating_add(1))); + let existed = path.exists(); + write_atomic(&path, &bytes)?; + if !existed { + created.push(path.clone()); + } + files.push(path.to_string_lossy().into_owned()); + Ok(()) + }); + if let Err(e) = outcome { + // Best effort: whatever can't be removed stays, and the error reported is the extraction's. + for path in &created { + let _ = std::fs::remove_file(path); + } + if made_dir { + let _ = std::fs::remove_dir(&dir); + } + return Err(e); + } + Ok(files) + } + pub(crate) fn doc_combine(&mut self, a: &Args) -> Result { let paths = a.strs("paths")?; if paths.len() < 2 { diff --git a/crates/automation/src/batch.rs b/crates/automation/src/batch.rs index 14a98f23e2..9359cb50b8 100644 --- a/crates/automation/src/batch.rs +++ b/crates/automation/src/batch.rs @@ -19,9 +19,13 @@ struct Recipe<'a> { images: optimize::ImageSettings, mono: optimize::MonoSettings, ocr: ocr::OcrSettings, + /// The calling session's JavaScript preference, carried into each file's own session. + javascript: bool, } impl<'a> Recipe<'a> { + /// Read and validate `doc_batch`'s steps and settings, taking the JavaScript preference from + /// `auto`'s session. fn parse(auto: &Automation, args: &'a Args) -> Result { let steps = args.strs("steps")?; if steps.is_empty() || steps.len() > 3 { @@ -60,7 +64,7 @@ impl<'a> Recipe<'a> { if args.opt_num("dpi")?.is_some_and(|dpi| !(72.0..=600.0).contains(&dpi)) { return Err(Args::wrong("dpi", "72 to 600")); } - Ok(Self { steps, images, mono, ocr: auto.ocr_settings(args)? }) + Ok(Self { steps, images, mono, ocr: auto.ocr_settings(args)?, javascript: auto.session.javascript() }) } fn accepts(&self, path: &Path) -> bool { @@ -68,9 +72,13 @@ impl<'a> Recipe<'a> { extension == "pdf" || (self.steps.contains(&"convert") && pdfcraft_engine::CONVERTIBLE.contains(&extension.as_str())) } + /// Run the recipe's steps on the file at `path` in a fresh session; returns the output bytes + /// and a report entry per step. fn process(&self, path: &Path) -> Result<(Arc>, Vec)> { // A fresh session per file bounds retained document state and never touches caller tabs. + // It keeps the caller's JavaScript preference, set before any open runs open-time scripts. let mut session = Session::new(); + session.set_javascript(self.javascript); let mut input = Vec::new(); std::fs::File::open(path).map_err(failed)?.take(MAX_INPUT + 1).read_to_end(&mut input).map_err(failed)?; if input.len() as u64 > MAX_INPUT { @@ -85,7 +93,8 @@ impl<'a> Recipe<'a> { "convert" => bytes = session.convert_to_pdf(name, &bytes)?.1, "ocr" => { let recognizers = ocr::recognizers(&self.ocr).map_err(failed)?; - let result = ocr::recognize_file(name, bytes, None, self.ocr.clone(), &recognizers, |_, _| true).map_err(failed)?; + let result = + ocr::recognize_file(name, bytes, None, self.ocr.clone(), &recognizers, self.javascript, |_, _| true).map_err(failed)?; log.push( json!({"step":"ocr","words":result.words(),"skipped_pages":result.pages.iter().filter(|p| p.skipped.is_some()).count()}), ); diff --git a/crates/automation/src/dispatch.rs b/crates/automation/src/dispatch.rs index 3b40832b83..4a91942fe4 100644 --- a/crates/automation/src/dispatch.rs +++ b/crates/automation/src/dispatch.rs @@ -711,11 +711,7 @@ impl Automation { "form_fields" => self.form_fields(&a)?, "form_fill" => self.form_fill(&a)?, "form_focus" => self.form_focus(&a)?, - "form_set_image" => { - let path = self.resolve(a.str("path")?, false)?; - let bytes = read_input(&path)?; - self.apply(&a, Edit::SetFieldImage { name: a.str("field")?.to_owned(), image: Arc::new(bytes) })? - } + "form_set_image" => self.form_set_image(&a)?, "form_reset" => self.form_reset(&a)?, "form_add_field" => self.form_add_field(&a)?, "form_add_field_table" => self.form_add_field_table(&a)?, @@ -839,6 +835,7 @@ impl Automation { "accessibility_autotag_form_fields" => self.accessibility_autotag_form_fields(&a)?, "ocr_recognize" => self.ocr_recognize(&a)?, "js_run" => self.js_run(&a)?, + "js_event" => self.js_event(&a)?, "js_document_scripts" => self.js_document_scripts(&a)?, "js_set_document_script" => self.js_set_document_script(&a)?, "js_enabled" => self.js_enabled(&a)?, diff --git a/crates/automation/src/documents.rs b/crates/automation/src/documents.rs index c00034521c..7dd78cff98 100644 --- a/crates/automation/src/documents.rs +++ b/crates/automation/src/documents.rs @@ -168,11 +168,19 @@ impl Automation { if flatten_fill_sign { self.apply(a, Edit::FlattenFillSign)?; } + // The document's Will Save actions run first: what they change is saved (#269). + let scripts = self.session.run_document_actions(id, pdfcraft_engine::js::DocumentScriptEvent::WillSave); let bytes = if full { self.session.save_full_bytes(id) } else { self.session.save_bytes(id) }.map_err(failed)?; // A save that adds or removes encryption, or must not keep the old revision (Sanitize, // redaction), is rewritten in full even when an incremental save was asked for. let incremental = !full && bytes.starts_with(&self.doc(a)?.saved_bytes()); - self.publish_save(id, &target, bytes, incremental, write) + let mut out = self.publish_save(id, &target, bytes, incremental, write)?; + if !scripts.is_empty() + && let Some(o) = out.as_object_mut() + { + o.insert("will_save".into(), crate::a11y::form_tools::js_output_json(&scripts)); + } + Ok(out) } /// Validate the prepared bytes before publishing them or marking the document saved. diff --git a/crates/automation/src/forms.rs b/crates/automation/src/forms.rs index 37f1ed2cbb..f1f34836ad 100644 --- a/crates/automation/src/forms.rs +++ b/crates/automation/src/forms.rs @@ -272,6 +272,27 @@ fn value_for(f: &FormField, v: &Value) -> Result { } impl Automation { + /// Import an image using an optional exact field ID, preserving the legacy name-only tool. + pub(crate) fn form_set_image(&mut self, a: &Args) -> Result { + let name = a.str("field")?.to_owned(); + let object = a + .0 + .get("field_id") + .map(|value| -> Result { + let key = + value.as_u64().filter(|key| *key < 1u64 << 48).ok_or_else(|| bad("field_id must be an integer between 0 and 281474976710655"))?; + Ok(pdfcraft_cos::ObjRef::new((key >> 16) as u32, key as u16)) + }) + .transpose()?; + let path = self.resolve(a.str("path")?, false)?; + let image = std::sync::Arc::new(crate::paths::read_input(&path)?); + let edit = match object { + Some(object) => Edit::SetFieldImageTarget { name, object, image }, + None => Edit::SetFieldImage { name, image }, + }; + self.apply(a, edit) + } + pub(crate) fn form_fields(&self, a: &Args) -> Result { let doc = self.doc(a)?; let out: Vec = doc @@ -286,6 +307,7 @@ impl Automation { }); let mut v = json!({ "name": f.name, + "field_id": pdfcraft_engine::js::field_object_id(f.obj), "type": kind_name(f.kind), "value": match f.kind { FormFieldKind::CheckBox => json!(!f.value.is_empty()), @@ -645,6 +667,7 @@ impl Automation { Ok((wi, caption.to_owned())) }) .transpose()?, + caption_all: None, read_only: a.opt_bool("read_only")?, required: a.opt_bool("required")?, multiline: a.opt_bool("multiline")?, diff --git a/crates/automation/src/json.rs b/crates/automation/src/json.rs index e6ca95e905..f612c3050d 100644 --- a/crates/automation/src/json.rs +++ b/crates/automation/src/json.rs @@ -256,7 +256,7 @@ pub(crate) fn security(d: &Document) -> Value { "method": s.method, "pending": s.pending, "printing": if !p.print() { "none" } else if p.print_high_quality() { "high" } else { "low" }, - "modify": p.modify(), "assemble": p.assemble(), "copy": p.copy(), "annotate": p.annotate(), + "modify": p.modify(), "assemble": p.assemble(), "extract_pages": p.extract_pages(), "copy": p.copy(), "annotate": p.annotate(), "fill_forms": p.fill_forms(), "accessibility": p.extract_for_accessibility(), }) } diff --git a/crates/automation/src/printing.rs b/crates/automation/src/printing.rs index c2a330a095..3d7a76865a 100644 --- a/crates/automation/src/printing.rs +++ b/crates/automation/src/printing.rs @@ -361,26 +361,50 @@ impl Automation { let settings = print::Settings { pages, paper, orientation, layout, content, raster, crops: Vec::new(), area }; let laid_out = print::layout(&sizes, &settings).map_err(|e| bad(e.to_string()))?; let sheets = laid_out.len(); + // Where it goes is checked before the Will Print actions run, so a call that can't print + // doesn't change the document. + let destination = match (a.opt_str("path")?, printer) { + (Some(p), None) => Destination::File(self.resolve(p, true)?), + (None, Some(printer)) => { + let actual_printer = resolved_printer.as_deref().unwrap_or(printer); + Destination::Printer(spool_job(a, actual_printer, self.doc(a)?.name.clone(), duplex, media.as_deref(), settings.raster, &laid_out)?) + } + _ => return Err(bad("pass either path (save the print-ready PDF) or printer (a name from printers, or \"default\")")), + }; + // The document's Will Print actions run first: what they change is printed (#269). + let scripts = if self.doc(a)?.allows_printing() { + self.session.run_document_actions(id, pdfcraft_engine::js::DocumentScriptEvent::WillPrint) + } else { + Default::default() + }; let bytes = self.session.print_pdf(id, &settings).map_err(failed)?; let mut out = json!({ "sheets": sheets, "pages": settings.pages.len(), "bytes": bytes.len() }); - match (a.opt_str("path")?, printer) { - (Some(p), None) => { - let path = self.resolve(p, true)?; + if !scripts.is_empty() { + out["will_print"] = crate::a11y::form_tools::js_output_json(&scripts); + } + match destination { + Destination::File(path) => { write_atomic(&path, &bytes)?; out["path"] = json!(path.to_string_lossy()); } - (None, Some(printer)) => { - let actual_printer = resolved_printer.as_deref().unwrap_or(printer); - let mut job = spool_job(a, actual_printer, self.doc(a)?.name.clone(), duplex, media.as_deref(), settings.raster, &laid_out)?; - let bytes = pdfcraft_engine::spooler::spooler_copy(bytes, &mut job); + Destination::Printer(mut job) => { + // A GDI (v3) driver gets transparent sheets flattened (#6155, #5838, #5940); XPS + // (v4) drivers print them as they are. + let flatten = job.printer.as_deref().is_some_and(spool::gdi_driver); + let bytes = pdfcraft_engine::spooler::spooler_copy(bytes, &mut job, flatten); out["job"] = json!(spool::submit(&bytes, &job).map_err(|e| failed(e.to_string()))?); } - _ => return Err(bad("pass either path (save the print-ready PDF) or printer (a name from printers, or \"default\")")), } Ok(out) } } +/// Where `doc_print` sends the sheets: a print-ready PDF file, or a printer's queue. +enum Destination { + File(std::path::PathBuf), + Printer(spool::Job), +} + /// "WxH" with a unit (in, mm, cm or pt) as a portrait paper size in points; each side 1 to 200 /// inches (PDF's own page limit). fn paper_size(spec: &str) -> Option<(f64, f64)> { diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 56c654fa1c..fa29d07634 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -347,7 +347,7 @@ pub fn tools() -> Vec { json!({ "doc": doc(), "pages": pages("to extract"), "out": save_out.clone(), "open": open.clone(), "separate": { "type": "boolean" }, "out_dir": { "type": "string" }, "delete": { "type": "boolean" } }), &["doc", "pages"], )), - t("doc_combine", "Combine files", "Combine files, in order, into one PDF (bookmarks are kept under one entry per file). Images (PNG, JPEG, TIFF, GIF, BMP, JPEG 2000) and .txt files are made into PDFs first. pages optionally chooses each file's pages, in step with paths: a range such as \"1-3, 6\" or null for all pages. passwords, also in step with paths, opens encrypted files (the open password, or the permissions password where a file's security doesn't allow copying pages; null for none). The result is not encrypted. Passwords are never echoed back. groups, also in step with paths, splits a file around others: entries with the same number (they must name the same path) are one file, copied once, so it keeps one bookmark at its first page, its links and its form fields; null for a file of its own. A group uses its first entry's password. When two files' forms name the same field, field_conflicts says what happens: \"rename\" (the default) keeps the fields separate by renaming the later one (Text1 becomes Text1-1) and the result lists renamed_fields as [[old, new], …] — scripts that refer to the old name are not rewritten; \"merge\" makes them one field, as before.") + t("doc_combine", "Combine files", "Combine files, in order, into one PDF (bookmarks are kept under one entry per file). Images (PNG, JPEG, TIFF, GIF, BMP, JPEG 2000), .txt files and Office documents (Word, Excel, PowerPoint, OpenDocument, RTF; through an installed LibreOffice) are made into PDFs first. pages optionally chooses each file's pages, in step with paths: a range such as \"1-3, 6\" or null for all pages. passwords, also in step with paths, opens encrypted files (the open password, or the permissions password where a file's security doesn't allow copying pages; null for none). The result is not encrypted. Passwords are never echoed back. groups, also in step with paths, splits a file around others: entries with the same number (they must name the same path) are one file, copied once, so it keeps one bookmark at its first page, its links and its form fields; null for a file of its own. A group uses its first entry's password. When two files' forms name the same field, field_conflicts says what happens: \"rename\" (the default) keeps the fields separate by renaming the later one (Text1 becomes Text1-1) and the result lists renamed_fields as [[old, new], …] — scripts that refer to the old name are not rewritten; \"merge\" makes them one field, as before.") .cmd("page.combine") .with(schema( json!({ @@ -361,7 +361,7 @@ pub fn tools() -> Vec { }), &["paths"], )), - t("doc_create_multiple", "Create PDF from multiple files", "Convert several files (PDFs, PNG/JPEG/JPEG 2000/TIFF/GIF/BMP images, .txt files) to PDF in one run. mode \"combine\" (default) joins them, in order, into one PDF with a bookmark per file; pages optionally chooses each file's pages, in step with paths (a range such as \"1-3, 6\" or null). mode \"separate\" writes one PDF per file into out_dir (files that are already PDFs are skipped; existing files are never overwritten) and reports each file's result.") + t("doc_create_multiple", "Create PDF from multiple files", "Convert several files (PDFs, PNG/JPEG/JPEG 2000/TIFF/GIF/BMP images, .txt files, Office documents through an installed LibreOffice) to PDF in one run. mode \"combine\" (default) joins them, in order, into one PDF with a bookmark per file; pages optionally chooses each file's pages, in step with paths (a range such as \"1-3, 6\" or null). mode \"separate\" writes one PDF per file into out_dir (files that are already PDFs are skipped; existing files are never overwritten) and reports each file's result.") .cmd("create.multiple") .with(schema( json!({ @@ -543,7 +543,7 @@ pub fn tools() -> Vec { t( "form_focus", "Move the focus into or out of a field", - "A field gains (event enter) or loses (event exit) focus, as when someone clicks or tabs into or out of it, and the dynamic XFA form's enter or exit scripts for it run (other forms have none, and nothing happens). widget picks the radio button or copy of the field (1-based, default 1; see form_fields). What the scripts change is one undoable edit, and none when they change nothing. Changing a field's value (form_fill) runs its exit scripts already, so the next exit of that field doesn't run them again. Returns changed, and the scripts' alerts, console output, requests and errors.", + "A field gains (event enter) or loses (event exit) focus, as when someone clicks or tabs into or out of it, and the dynamic XFA form's enter or exit scripts for it run; on another form, the widget's own On Focus or On Blur JavaScript runs, if it has one (see also js_event). widget picks the radio button or copy of the field (1-based, default 1; see form_fields). What the scripts change is one undoable edit, and none when they change nothing. Changing a field's value (form_fill) runs its exit scripts already, so the next exit of that field doesn't run them again. Returns changed, and the scripts' alerts, console output, requests and errors.", ) .with(schema( json!({ @@ -631,7 +631,7 @@ pub fn tools() -> Vec { &["doc", "page", "path", "rect"], )), t("form_set_image", "Set an image field's picture", "Show an image file (PNG, JPEG, TIFF, GIF or BMP) in an image field or button, scaled to fit and centred (what clicking an image field does). On an XFA form the picture is kept in its data too (base64, as Designer saves it) and the form shows it from there. Undoable.") - .with(schema(json!({ "doc": doc(), "field": { "type": "string" }, "path": { "type": "string" } }), &["doc", "field", "path"])), + .with(schema(json!({ "doc": doc(), "field": { "type": "string" }, "field_id": {"type":"integer","minimum":0,"maximum":281474976710655u64,"description":"Optional exact document-local identity from form_fields; requires matching field name."}, "path": { "type": "string" } }), &["doc", "field", "path"])), t( "form_set_props", "Field properties", @@ -726,7 +726,7 @@ pub fn tools() -> Vec { t( "doc_hidden_info", "Find hidden information", - "What Remove Hidden Information would remove, by category (metadata, attachments, comments, form-fields, hidden-text, hidden-layers, bookmarks, links-actions-scripts, private-data) with counts.", + "What Remove Hidden Information would remove, by category (metadata, attachments, comments, form-fields, hidden-text, hidden-layers, bookmarks, links-actions-scripts, private-data) with counts. A page that can't be read in full (its content streams don't decode, or they or a form they draw hold bytes the parser can't read or an inline image whose end is a guess) counts as one item of hidden-text and of hidden-layers: removing either category then fails on that page. With form-fields removed too, a field's appearance becomes page content and is read the same way, though it isn't counted here.", ) .ro() .with(schema(json!({ "doc": doc() }), &["doc"])), @@ -757,7 +757,7 @@ pub fn tools() -> Vec { t( "printer_papers", "List printer paper sizes", - "The media sizes (names and dimensions in points) and default paper supported by a printer driver. Pass printer: a name from printers, or \"default\".", + "The media sizes (names and dimensions in points) and default paper supported by a printer driver (CUPS: its PPD's, by the driver's names, the default being its *DefaultPageSize; a job on one, or on a standard size of one, is fed it under the PPD's own keyword). Pass printer: a name from printers, or \"default\".", ) .ro() .with(schema(json!({ "printer": { "type": "string" } }), &["printer"])), @@ -1415,12 +1415,27 @@ pub fn tools() -> Vec { t("form_merge_data", "Merge data files into spreadsheet", "Collect the field values of form data files (FDF, XFDF) or filled-in PDF forms into one CSV file at path: a column per field name, a row per file. Returns the row and column counts.") .cmd("form.merge_data") .with(schema(json!({ "paths": { "type": "array", "items": { "type": "string" }, "minItems": 1 }, "path": { "type": "string" } }), &["paths", "path"])), - t("js_run", "Run JavaScript", "Run Acrobat JavaScript in the document, as the JavaScript console does (or as push button `field`'s Mouse Up script when field is given; on a laid-out XFA form, `field` runs that button's XFA click script instead, JavaScript or FormCalc, which can add and remove rows and show or hide subforms). The form object model is available: this/getField, event, app, util, console, display, color, and the document-level scripts (except the PDF container scripts when XFA layout is active; they remain available through js_document_scripts). Field changes and resetForm are applied as one undoable step; returns the alerts (each with its message, title, icon and buttons, in order; every question is still answered OK), console output and requests (print, page, url, submit, attachment) of the script and of the field scripts its changes ran, the script's error, the errors of document-level scripts that failed (the rest still ran), and in warnings the errors of applying the changes, such as values of an XFA form that could not be written to its data.") + t("js_run", "Run JavaScript", "Run Acrobat JavaScript in the document, as the JavaScript console does (or as push button `field`'s Mouse Up script when field is given; on a laid-out XFA form, `field` runs that button's XFA click script instead, JavaScript or FormCalc, which can add and remove rows and show or hide subforms). The form object model is available: this/getField, event, app, util, console, display, color, and the document-level scripts (except the PDF container scripts when XFA layout is active; they remain available through js_document_scripts). Field changes and resetForm are applied as one undoable step; returns the script's and subsequent field scripts' alerts (each with its message, title, icon and buttons, in order; AcroForm replies consume ordered answers; missing replies cancel input or use the alert button set's safe default), console output, requests (print, page, url, submit, attachment), error, and the errors of document-level scripts that failed (the rest still ran), plus warnings from applying the changes.") .cmd("tools.js_console") .with(schema( - json!({ "doc": doc(), "script": { "type": "string" }, "field": { "type": "string", "description": "Run as this button's Mouse Up event." } }), + json!({ "doc": doc(), "script": { "type": "string" }, "field": { "type": "string", "description": "Run as this button's Mouse Up event." }, "field_id": {"type":"integer","minimum":0,"maximum":281474976710655u64,"description":"Optional exact field identity from form_fields; requires matching field name and is scoped to this document."}, "answers": {"type":"array","maxItems":32,"items":{"type":["string","null"],"maxLength":4096},"description":"Ordered popup, response and alert answers; strings are limited to 4096 UTF-8 bytes at runtime; null cancels a popup or response, and selects an alert default (OK, Cancel or No, by button set)."} }), &["doc", "script"], )), + t( + "js_event", + "Run a field's pointer or focus script", + "Run the JavaScript action widget `widget` of AcroForm field `field` has for `event`, as when the pointer enters (mouse_enter), leaves (mouse_exit), presses (mouse_down) or releases (mouse_up) it, or it gains (on_focus) or loses (on_blur) the focus, in the document's script realm (variables document scripts and earlier scripts left are kept), with event.name Mouse Enter, Mouse Exit, Mouse Down, Mouse Up, Focus or Blur. Nothing runs when the widget has no JavaScript for that event, JavaScript is off, or the form is a laid-out XFA form (use form_focus and js_run there). What it changes is one undoable edit. Pointer-over and focus scripts run unasked in a viewer, so their requests to print, open Save As or show a panel are dropped (noted on the console); mouse_down and mouse_up keep them. Returns changed, and the script's alerts, console output, requests and errors.", + ) + .with(schema( + json!({ + "doc": doc(), + "field": { "type": "string", "description": "Field name (from form_fields)." }, + "event": { "type": "string", "enum": ["mouse_enter", "mouse_exit", "mouse_down", "mouse_up", "on_focus", "on_blur"] }, + "field_id": {"type":"integer","minimum":0,"maximum":281474976710655u64,"description":"Optional exact field identity from form_fields: of fields sharing the name, the one to run."}, + "widget": { "type": "integer", "minimum": 1, "description": "Widget of the field (1-based, default 1): a radio button of the group, or a copy of the field." }, + }), + &["doc", "field", "event"], + )), t("js_document_scripts", "Document JavaScripts", "List the document-level JavaScripts (name and source), which define functions field scripts use.") .ro() .cmd("tools.document_js") @@ -1745,7 +1760,7 @@ pub fn tools() -> Vec { .with(schema(json!({ "doc": doc() }), &["doc"])), t("doc_open_revision", "Open a revision", "Open saved revision `revision` (1 = the oldest) of a document as a new, unsaved document, to see the file as it was then.") .with(schema(json!({ "doc": doc(), "revision": { "type": "integer", "minimum": 1 } }), &["doc", "revision"])), - t("doc_optimize", "Optimize PDF", "Write an optimized copy to `path` (Acrobat's PDF Optimizer). preset: standard (default; Reduce File Size's choices) or mobile (images to 96 ppi above 144, JPEG high quality); the options below change it. compatibility: retain (default; keeps the document's version, or 1.5 when object streams are written), 1.4, 1.5, 1.6, 1.7 or 2.0. merge_fragments (default true) first puts a page picture drawn as many stacked image strips (as some PDF printers write it) back together into one image, so it can be resampled (merged_images, fragments_merged). color / gray: { downsample, ppi, above_ppi, compression: jpeg|zip|retain, quality 1–100 } (defaults: downsample to 150 ppi above 225, JPEG 60). mono: { downsample, ppi, above_ppi, compression: ccitt_g4|jbig2|retain } for black-and-white images and image masks (default: averaged down to 300 ppi above 450 ppi, counted in images_resampled; stored losslessly as CCITT Group 4 or JBIG2 (or 1-bit Flate, once resampled or packed from more bits) when that is smaller, up to 16 JBIG2 images sharing one symbol dictionary (/JBIG2Globals) where that is smaller still (image masks and DeviceGray images named directly, of at most 64 Mi pixels, which redaction can store again without it), Group 3 fax images, JBIG2 images, black-and-white RGB and indexed images using only palette entries 0 and 1 among them, counted in images_recompressed; retain resamples only, a CCITT image staying CCITT, a JBIG2 image JBIG2 and any other becoming Flate). Images past what one run may decode stay as they are, counted in images_refused; streams it only stores with Flate past that stay LZW, uncounted. Images are measured where pages draw them and replaced only when smaller. discard_*: thumbnails (default true), alternate_images (true), tags, search_index (removes the catalog's /PieceInfo entries whose application name contains \"index\" in any letter case; search_indexes), print_settings (entries removed: print_settings), form_actions (submit, import and reset actions removed: form_actions; the fields and links stay, and the actions chained after one still run); flate_unencoded (true); object_streams (default true; always off for 1.4); lzw_to_flate (true) decodes LZW streams and stores them with Flate instead, leaving any it cannot decode unchanged (lzw_streams counts the streams converted); remove_invalid_links and remove_unreferenced_dests (true); discard_external_refs takes GoToR, GoToE, Launch and ImportData actions, and Thread actions with /F, out of links and bookmarks, removes a link left with nothing to do, and keeps links within the document and web links (external_links, external_bookmarks); images and forms the pages list but never draw are always dropped (unused_xobjects). discard: Remove Hidden Information categories (metadata, attachments, comments, form-fields, hidden-text, hidden-layers, bookmarks, links-actions-scripts, private-data). Signed documents are refused. target_bytes (optional): if the copy is larger, retry with up to 5 progressively stronger image settings (downsample to at most 110, 96, 72, 54, 36 ppi, applied to colour and grey images; images set to JPEG also get JPEG quality at most 45, 35, 28, 20, 12, while zip and retain images keep their encoding and only get the lower ppi) and stop at the first copy that fits; reports target_reached, attempts and target_settings, and writes the smallest copy with target_reached false when none fits. Without it nothing changes. The open document is unchanged.") + t("doc_optimize", "Optimize PDF", "Write an optimized copy to `path` (Acrobat's PDF Optimizer). preset: standard (default; Reduce File Size's choices) or mobile (images to 96 ppi above 144, JPEG high quality); the options below change it. compatibility: retain (default; keeps the document's version, or 1.5 when object streams are written), 1.4, 1.5, 1.6, 1.7 or 2.0. merge_fragments (default true) first puts a page picture drawn as many stacked image strips (as some PDF printers write it) back together into one image, so it can be resampled (merged_images, fragments_merged). color / gray: { downsample, ppi, above_ppi, compression: jpeg|zip|retain, quality 1–100 } (defaults: downsample to 150 ppi above 225, JPEG 60). mono: { downsample, ppi, above_ppi, compression: ccitt_g4|jbig2|retain } for black-and-white images and image masks (default: averaged down to 300 ppi above 450 ppi, counted in images_resampled; stored losslessly as CCITT Group 4 or JBIG2 (or 1-bit Flate, once resampled or packed from more bits) when that is smaller, up to 16 JBIG2 images sharing one symbol dictionary (/JBIG2Globals) where that is smaller still (image masks and DeviceGray images named directly, of at most 64 Mi pixels, which redaction can store again without it), Group 3 fax images, JBIG2 images, black-and-white RGB and indexed images using only palette entries 0 and 1 among them, counted in images_recompressed; a picture's stencil /Mask and one-bit /SMask are stored again the same way, at their size, not resampled and sharing no dictionary, counted as images; retain resamples only, a CCITT image staying CCITT, a JBIG2 image JBIG2 and any other becoming Flate). Images past what one run may decode stay as they are, counted in images_refused; streams it only stores with Flate past that stay LZW, uncounted. Images are measured where pages draw them and replaced only when smaller. discard_*: thumbnails (default true), alternate_images (true), tags, search_index (removes the catalog's /PieceInfo entries whose application name contains \"index\" in any letter case; search_indexes), print_settings (entries removed: print_settings), form_actions (submit, import and reset actions removed: form_actions; the fields and links stay, and the actions chained after one still run); flate_unencoded (true); object_streams (default true; always off for 1.4); lzw_to_flate (true) decodes LZW streams and stores them with Flate instead, leaving any it cannot decode unchanged (lzw_streams counts the streams converted); remove_invalid_links and remove_unreferenced_dests (true); discard_external_refs takes GoToR, GoToE, Launch and ImportData actions, and Thread actions with /F, out of links and bookmarks, removes a link left with nothing to do, and keeps links within the document and web links (external_links, external_bookmarks); images and forms the pages list but never draw are always dropped (unused_xobjects). discard: Remove Hidden Information categories (metadata, attachments, comments, form-fields, hidden-text, hidden-layers, bookmarks, links-actions-scripts, private-data). Signed documents are refused. target_bytes (optional): if the copy is larger, retry with up to 5 progressively stronger image settings (downsample to at most 110, 96, 72, 54, 36 ppi, applied to colour and grey images; images set to JPEG also get JPEG quality at most 45, 35, 28, 20, 12, while zip and retain images keep their encoding and only get the lower ppi) and stop at the first copy that fits; reports target_reached, attempts and target_settings, and writes the smallest copy with target_reached false when none fits. Without it nothing changes. The open document is unchanged.") .cmd("optimize.advanced") .with(schema( json!({ diff --git a/crates/automation/tests/it/action_failures.rs b/crates/automation/tests/it/action_failures.rs index b487103a21..cae72832ab 100644 --- a/crates/automation/tests/it/action_failures.rs +++ b/crates/automation/tests/it/action_failures.rs @@ -19,14 +19,14 @@ fn one_step(step: Step) -> Action { /// The standard fonts can't draw the character, so the watermark step fails and no file is produced. #[test] fn failing_watermark_step_fails_the_file_and_publishes_nothing() { - let r = run_on(&one_step(Step::AddWatermark("中".into())), "one.pdf", Arc::new(pdf()), |_, _| {}); + let r = run_on(&one_step(Step::AddWatermark("中".into())), "one.pdf", Arc::new(pdf()), true, |_, _| {}); assert!(r.is_err(), "a failed step must fail its file: {r:?}"); } /// A comment step on a document with no comments has nothing to do, which is not a failure. #[test] fn nothing_to_do_step_still_succeeds() { - let r = run_on(&one_step(Step::FlattenComments), "one.pdf", Arc::new(pdf()), |_, _| {}); + let r = run_on(&one_step(Step::FlattenComments), "one.pdf", Arc::new(pdf()), true, |_, _| {}); assert!(r.is_ok(), "a step with nothing to do is not a failure: {r:?}"); } @@ -61,6 +61,6 @@ fn action_run_marks_the_failing_file_as_error_without_output() { /// A removal step with nothing to remove keeps its existing note and succeeds. #[test] fn removal_step_with_nothing_to_remove_still_succeeds() { - let r = run_on(&one_step(Step::RemoveHiddenInformation), "one.pdf", Arc::new(pdf()), |_, _| {}); + let r = run_on(&one_step(Step::RemoveHiddenInformation), "one.pdf", Arc::new(pdf()), true, |_, _| {}); assert!(r.is_ok(), "nothing to remove is not a failure: {r:?}"); } diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 9516a76542..db139844a7 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -1226,7 +1226,7 @@ fn combine_takes_images_and_text_files() { std::fs::write(dir.join("scan.png"), png).unwrap(); std::fs::write(dir.join("notes.txt"), "Some notes").unwrap(); std::fs::write(dir.join("broken.png"), b"\x89PNG\r\n\x1a\nbroken").unwrap(); - std::fs::write(dir.join("report.docx"), b"PK\x03\x04").unwrap(); + std::fs::write(dir.join("archive.zip"), b"PK\x03\x04").unwrap(); let mut a = auto(&dir); let combined = ok(&mut a, "doc_combine", json!({ "paths": ["a.pdf", "scan.png", "notes.txt"], "pages": [null, null, "1"], "open": true })); let doc = combined["document"]["doc"].as_u64().unwrap(); @@ -1236,7 +1236,7 @@ fn combine_takes_images_and_text_files() { let titles: Vec<&str> = marks.as_array().unwrap().iter().map(|b| b["title"].as_str().unwrap()).collect(); assert_eq!(titles, ["a", "scan", "notes"]); // A file that can't be made into a PDF stops the combine, and says which. - for bad in ["broken.png", "report.docx"] { + for bad in ["broken.png", "archive.zip"] { let refused = a.call("doc_combine", &json!({ "paths": ["a.pdf", bad] })); assert!(matches!(&refused, Err(ToolError::Failed(e)) if e.contains(bad)), "{bad}: {refused:?}"); } @@ -3040,6 +3040,8 @@ fn replace_text_lists_its_replacement() { } } +/// Protect through the tools, then reopen: the open password is required, the restrictions +/// (including page extraction) hold for the user, and the permissions password lifts them. #[test] fn protecting_through_tools() { let dir = workdir("protect"); @@ -3057,12 +3059,14 @@ fn protecting_through_tools() { (r["security"]["printing"].as_str(), r["security"]["annotate"].as_bool(), r["security"]["copy"].as_bool()), (Some("low"), Some(true), Some(false)) ); + assert_eq!(r["security"]["extract_pages"], false); assert!(!r.to_string().contains("boss"), "passwords are never echoed"); ok(&mut a, "doc_save", json!({ "doc": doc, "path": "locked.pdf" })); // Another session: the open password is required, and the restrictions hold. let mut b = auto(&dir); assert!(matches!(b.call("doc_open", &json!({ "path": "locked.pdf" })), Err(ToolError::Failed(_)))); let re = ok(&mut b, "doc_open", json!({ "path": "locked.pdf", "password": "open" }))["doc"].as_u64().unwrap(); + assert_eq!(ok(&mut b, "doc_info", json!({ "doc": re }))["security"]["extract_pages"], false); assert!(matches!(b.call("page_delete", &json!({ "doc": re, "pages": [1] })), Err(ToolError::Failed(_)))); assert!(matches!(b.call("doc_unprotect", &json!({ "doc": re })), Err(ToolError::Failed(_)))); ok(&mut b, "comment_add", json!({ "doc": re, "page": 1, "type": "note", "at": [10, 10], "contents": "allowed" })); @@ -3086,6 +3090,7 @@ fn protecting_through_tools() { assert_eq!(ok(&mut b, "text_find", json!({ "doc": re, "query": "Page" }))["count"], 3); // With the permissions password everything is possible, including removing security. let owner = ok(&mut b, "doc_open", json!({ "path": "locked.pdf", "password": "boss" }))["doc"].as_u64().unwrap(); + assert_eq!(ok(&mut b, "doc_info", json!({ "doc": owner }))["security"]["extract_pages"], true); assert!(ok(&mut b, "text_extract", json!({ "doc": owner }))["pages"][0]["text"].as_str().is_some_and(|t| t.contains("Page"))); assert_eq!(ok(&mut b, "doc_unprotect", json!({ "doc": owner }))["security"]["protected"], false); ok(&mut b, "doc_save", json!({ "doc": owner, "path": "open.pdf" })); @@ -3093,6 +3098,45 @@ fn protecting_through_tools() { ok(&mut c, "doc_open", json!({ "path": "open.pdf" })); } +/// A refused `doc_split` or separate-file `page_extract` leaves no output directory behind +/// (#5081), whether the protection allows no page changes ("Any except extracting pages") or +/// page changes without copying; with copying allowed as well, the user may extract. +#[test] +fn refused_extraction_writes_no_split_or_page_output() { + let dir = workdir("refused-extraction-output"); + // (changes, copy, assemble, extract_pages) as the user sees them. + let cases = [("any-except-extract", false, false, false), ("pages", false, true, false), ("pages", true, true, true)]; + for (i, (changes, copy, assemble, extract)) in cases.into_iter().enumerate() { + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + let protect = json!({ "doc": doc, "open_password": "open", "permissions_password": "boss", "changes": changes, "copy": copy }); + ok(&mut a, "doc_protect", protect); + let path = format!("restricted-{i}.pdf"); + ok(&mut a, "doc_save", json!({ "doc": doc, "path": path })); + + let mut b = auto(&dir); + let restricted = ok(&mut b, "doc_open", json!({ "path": path, "password": "open" }))["doc"].as_u64().unwrap(); + let security = ok(&mut b, "doc_info", json!({ "doc": restricted }))["security"].clone(); + assert_eq!((security["assemble"].as_bool(), security["extract_pages"].as_bool()), (Some(assemble), Some(extract)), "{changes}, copy {copy}"); + let (split_dir, extract_dir) = (format!("split-{i}"), format!("extract-{i}")); + let split = b.call("doc_split", &json!({ "doc": restricted, "every": 1, "out_dir": split_dir })); + let pages = b.call("page_extract", &json!({ "doc": restricted, "pages": [1, 2], "separate": true, "out_dir": extract_dir })); + if extract { + assert!(split.is_ok() && dir.join(&split_dir).exists(), "{changes}, copy {copy}"); + let files = match pages.as_deref() { + Ok([Content::Json(v), ..]) => v["files"].as_array().map(Vec::len), + other => panic!("{changes}, copy {copy}: page_extract: {other:?}"), + }; + assert_eq!(files, Some(2), "{changes}, copy {copy}"); + } else { + assert!(matches!(split, Err(ToolError::Failed(_))), "{changes}, copy {copy}"); + assert!(!dir.join(&split_dir).exists(), "refused split creates no output directory"); + assert!(matches!(pages, Err(ToolError::Failed(_))), "{changes}, copy {copy}"); + assert!(!dir.join(&extract_dir).exists(), "refused extraction creates no output directory"); + } + } +} + /// A document that allows changes but not copying: the tools that return a page's text refuse, /// or leave the text out where the tool also changes the page (a restyled paragraph, recognized /// words). @@ -5408,7 +5452,7 @@ fn creating_from_multiple_files_through_tools() { } std::fs::write(dir.join("scan.png"), png).unwrap(); std::fs::write(dir.join("notes.txt"), "hello").unwrap(); - std::fs::write(dir.join("report.docx"), b"PK\x03\x04").unwrap(); + std::fs::write(dir.join("archive.zip"), b"PK\x03\x04").unwrap(); let mut a = auto(&dir); // Combine: every file converted, in the order given, with a bookmark per file. @@ -5428,8 +5472,8 @@ fn creating_from_multiple_files_through_tools() { assert_eq!(titles, ["notes", "a", "scan"]); assert!(dir.join("all.pdf").is_file()); // A file that can't be converted fails the whole combine, naming it. - let err = a.call("doc_create_multiple", &json!({ "paths": ["a.pdf", "report.docx"] })).unwrap_err(); - assert!(matches!(&err, ToolError::Failed(m) if m.contains("report.docx") && m.contains("can't be converted")), "{err}"); + let err = a.call("doc_create_multiple", &json!({ "paths": ["a.pdf", "archive.zip"] })).unwrap_err(); + assert!(matches!(&err, ToolError::Failed(m) if m.contains("archive.zip") && m.contains("can't be converted")), "{err}"); assert!(matches!(a.call("doc_create_multiple", &json!({ "paths": [] })), Err(ToolError::InvalidArgs(_)))); assert!(matches!(a.call("doc_create_multiple", &json!({ "paths": ["a.pdf"], "mode": "zip" })), Err(ToolError::InvalidArgs(_)))); assert!(matches!(a.call("doc_create_multiple", &json!({ "paths": ["a.pdf"], "mode": "separate" })), Err(ToolError::InvalidArgs(_)))); @@ -5438,7 +5482,7 @@ fn creating_from_multiple_files_through_tools() { // existing file is never overwritten. std::fs::create_dir_all(dir.join("out")).unwrap(); std::fs::write(dir.join("out/notes.pdf"), b"mine").unwrap(); - let args = json!({ "paths": ["notes.txt", "report.docx", "a.pdf", "scan.png", "missing.txt"], "mode": "separate", "out_dir": "out" }); + let args = json!({ "paths": ["notes.txt", "archive.zip", "a.pdf", "scan.png", "missing.txt"], "mode": "separate", "out_dir": "out" }); let made = ok(&mut a, "doc_create_multiple", args); let files = made["files"].as_array().unwrap(); assert!(files[0]["output"].as_str().unwrap().ends_with("notes (2).pdf"), "{files:?}"); @@ -8942,6 +8986,61 @@ fn doc_optimize_stores_black_and_white_images_as_ccitt_group_4() { } } +/// A mixed-raster page: a flat colour layer drawn through a one-bit soft mask, [`scanned_page`]'s +/// scan, which the page no longer draws by name. `doc_optimize` stores the mask as Group 4, at its +/// size, still the picture's soft mask, and counts it as an image; with `mono: retain` it stays. +#[test] +fn doc_optimize_stores_a_pictures_one_bit_soft_mask_as_ccitt_group_4() { + use pdfcraft_cos::{Dict, Document, ObjRef, Object, SaveOptions, Stream, write_full}; + let dir = workdir("optimize-mono-mask"); + let mut doc = Document::open(std::sync::Arc::new(scanned_page())).unwrap(); + let find = |doc: &Document, kind: &[u8], key: &[u8]| { + doc.object_numbers().into_iter().map(|num| ObjRef { num, generation: doc.generation(num) }).find(|r| match &*doc.get(*r) { + Object::Stream(s) => s.dict.name(key) == Some(kind), + Object::Dict(d) => d.name(key) == Some(kind), + _ => false, + }) + }; + let scan = find(&doc, b"Image", b"Subtype").unwrap(); + let page = find(&doc, b"Page", b"Type").unwrap(); + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("XObject")); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(250)); + d.set(b"Height".to_vec(), Object::Int(100)); + d.set(b"BitsPerComponent".to_vec(), Object::Int(8)); + d.set(b"ColorSpace".to_vec(), Object::name("DeviceRGB")); + d.set(b"SMask".to_vec(), Object::Ref(scan)); + let picture = doc.add(Object::Stream(Stream::flate(d, &[0, 102, 204].repeat(250 * 100)))); + doc.update_dict(page, |p| { + let mut x = Dict::new(); + x.set(b"Scan".to_vec(), Object::Ref(picture)); + let mut res = Dict::new(); + res.set(b"XObject".to_vec(), Object::Dict(x)); + p.set(b"Resources".to_vec(), Object::Dict(res)); + }) + .unwrap(); + std::fs::write(dir.join("masked.pdf"), write_full(&doc, &SaveOptions::default()).unwrap()).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "masked.pdf" }))["doc"].as_u64().unwrap(); + let kept = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "kept.pdf", "mono": { "compression": "retain" } })); + assert_eq!(kept["images_recompressed"].as_u64(), Some(0), "{kept}"); + let r = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "small.pdf" })); + assert_eq!((r["images"].as_u64(), r["images_recompressed"].as_u64()), (Some(2), Some(1)), "the picture and its mask: {r}"); + // In the copy written, the picture's soft mask is Group 4, at its size. + let small = Document::open(std::sync::Arc::new(std::fs::read(dir.join("small.pdf")).unwrap())).unwrap(); + let picture = small + .object_numbers() + .into_iter() + .map(|num| small.get(ObjRef { num, generation: small.generation(num) })) + .find(|o| matches!(&**o, Object::Stream(s) if s.dict.contains(b"SMask"))) + .unwrap(); + let Object::Stream(picture) = &*picture else { unreachable!() }; + let Object::Stream(mask) = &*small.get(picture.dict.reference(b"SMask").unwrap()) else { panic!("the soft mask is a stream") }; + assert_eq!(mask.dict.name(b"Filter"), Some(&b"CCITTFaxDecode"[..])); + assert_eq!((mask.dict.int(b"Width"), mask.dict.int(b"Height")), (Some(1000), Some(400))); +} + /// A scan drawn at 600 ppi is averaged down to 300 ppi by default. `mono: { downsample: false }` /// keeps its size, `ppi` and `above_ppi` choose others, and values of the wrong type are refused. #[test] @@ -9482,6 +9581,13 @@ fn javascript_through_tools() { assert_eq!(r["requests"][0]["page"], 2); let r = ok(&mut a, "js_run", json!({ "doc": doc, "script": "JSON.stringify(this.getPageBox())" })); assert_eq!(r["result"], "[0,300,200,0]"); + ok(&mut a, "page_set_box", json!({ "doc":doc, "pages":[1], "box":"crop", "margins":[10,20,30,40] })); + let cropped = ok(&mut a, "js_run", json!({ "doc":doc, "script":"JSON.stringify(getPageBox('Crop',0))" })); + assert_eq!(cropped["result"], "[0,240,160,0]"); + ok(&mut a, "page_set_box", json!({ "doc":doc, "pages":[2], "box":"media", "margins":[0,0,40,50] })); + let other_boxes = ok(&mut a, "js_run", json!({ "doc":doc, "script":"JSON.stringify([getPageBox('Media',0),getPageBox('Crop',1)])" })); + assert_eq!(other_boxes["result"], "[[-10,280,190,-20],[0,250,160,0]]"); + ok(&mut a, "page_set_box", json!({ "doc":doc, "pages":[1], "box":"crop" })); let f = ok(&mut a, "form_fields", json!({ "doc": doc })); let total = f["fields"].as_array().unwrap().iter().find(|x| x["name"] == "Total").unwrap().clone(); assert_eq!(total["value"], "3750"); @@ -9501,6 +9607,26 @@ fn javascript_through_tools() { let r = ok(&mut a, "js_run", json!({ "doc": doc, "script": "money(5)" })); assert_eq!((r["error"].clone(), r["result"].clone()), (json!(null), json!("EUR 5,00"))); assert!(r["document_errors"][0].as_str().unwrap().starts_with("document script 2: TypeError"), "{r}"); + ok( + &mut a, + "form_add_field", + json!({ "doc": doc, "page": 1, "type": "button", "rect": [20, 150, 120, 180], "name": "CaptionButton", "caption": "Before" }), + ); + let r = ok( + &mut a, + "js_run", + json!({ "doc": doc, "field": "CaptionButton", "script": "getField('CaptionButton').buttonSetCaption('Continue'); getField('CaptionButton').buttonGetCaption();" }), + ); + assert_eq!(r["result"], "Continue"); + let routed = ok(&mut a, "js_run", json!({ "doc":doc, "field":"CaptionButton", "script":"event.name+'|'+event.targetName" })); + assert_eq!(routed["result"], "Mouse Up|CaptionButton"); + assert_eq!( + ok(&mut a, "form_fields", json!({ "doc": doc }))["fields"].as_array().unwrap().iter().find(|f| f["name"] == "CaptionButton").unwrap()["caption"], + "Continue" + ); + let bad_face = + ok(&mut a, "js_run", json!({ "doc": doc, "field": "CaptionButton", "script": "getField('CaptionButton').buttonSetCaption('Ignored', 1);" })); + assert!(bad_face["error"].as_str().is_some_and(|e| e.contains("normal button face"))); assert_eq!(ok(&mut a, "js_enabled", json!({ "enabled": false }))["enabled"], false); assert!(a.call("js_run", &json!({ "doc": doc, "script": "1" })).is_err()); } @@ -9589,6 +9715,14 @@ fn detecting_form_fields_preserves_content_piece_comments() { } else { json!([]) }; + let check_inventory = |a: &mut Automation, doc| { + let mut fields = ok(a, "form_fields", json!({"doc": doc}))["fields"].clone(); + for field in fields.as_array_mut().unwrap() { + assert!(field["field_id"].as_u64().is_some()); + field.as_object_mut().unwrap().remove("field_id"); + } + assert_eq!(fields, inventory); + }; let check_text = |a: &mut Automation, doc| { let text = page_text(a, doc).join(" "); assert_eq!(text.split_whitespace().collect::>(), ["FIRST", "LAST"]); @@ -9609,12 +9743,12 @@ fn detecting_form_fields_preserves_content_piece_comments() { assert_eq!(ok(&mut a, "form_detect_fields", json!({"doc": doc, "add": false}))["fields"], candidates); assert_eq!(ok(&mut a, "form_fields", json!({"doc": doc}))["fields"], json!([])); assert_eq!(ok(&mut a, "form_detect_fields", json!({"doc": doc}))["fields"], candidates); - assert_eq!(ok(&mut a, "form_fields", json!({"doc": doc}))["fields"], inventory); + check_inventory(&mut a, doc); assert_eq!(ok(&mut a, "form_detect_fields", json!({"doc": doc}))["fields"], json!([])); ok(&mut a, "doc_save", json!({"doc": doc, "path": "saved.pdf"})); let mut reopened = auto(&dir); let again = ok(&mut reopened, "doc_open", json!({"path": "saved.pdf"}))["doc"].as_u64().unwrap(); - assert_eq!(ok(&mut reopened, "form_fields", json!({"doc": again}))["fields"], inventory); + check_inventory(&mut reopened, again); check_text(&mut reopened, again); check_render(&mut reopened, again); std::fs::remove_dir_all(dir).unwrap(); @@ -9676,6 +9810,8 @@ fn comparing_documents_through_tools() { assert!(a.call("doc_compare", &json!({ "doc": v2, "other": 999 })).is_err()); } +/// `action_list` and `action_run`: built-in actions by name and ad-hoc steps run on files, +/// missing inputs are reported per file, and an unknown step is refused. #[test] fn actions_through_tools() { let dir = workdir("actions"); @@ -9693,6 +9829,71 @@ fn actions_through_tools() { assert!(a.call("action_run", &json!({ "steps": [{ "step": "fly" }], "paths": ["a.pdf"], "folder": "x" })).is_err()); } +/// JavaScript stays off in the short-lived sessions used by the three file-processing entry +/// points (#5080). The input's initialize script changes `qty` to 42, which is visible in the +/// saved XFA data if any entry point opens it with JavaScript enabled. +#[test] +fn file_processing_keeps_javascript_off_for_xfa_open_scripts() { + let dir = workdir("batch-javascript-off-xfa"); + let mut seed = 0x1234_5678_u32; + let padding: String = (0..32_000) + .map(|_| { + seed ^= seed << 13; + seed ^= seed >> 17; + seed ^= seed << 5; + char::from(b'a' + (seed % 26) as u8) + }) + .collect(); + let template = pdfcraft_xfa::fixtures::scripted_template() + .replace("if (qty.rawValue === null) qty.rawValue = 2;", "qty.rawValue = 42; pad.rawValue = '';") + .replace( + "{padding}\n ().unwrap(); + let orphan = "x".repeat(64_000); + let orphan_offset = source.len(); + source.extend_from_slice(format!("999 0 obj\n<< /Length {} >>\nstream\n{orphan}\nendstream\nendobj\n", orphan.len()).as_bytes()); + let xref = source.len(); + source.extend_from_slice( + format!("xref\n999 1\n{orphan_offset:010} 00000 n \ntrailer\n<< /Size 1000 /Root 1 0 R /Prev {previous_xref} >>\nstartxref\n{xref}\n%%EOF\n") + .as_bytes(), + ); + std::fs::write(dir.join("scripted.pdf"), &source).unwrap(); + + let mut a = auto(&dir); + ok(&mut a, "js_enabled", json!({ "enabled": false })); + + ok(&mut a, "action_run", json!({ "steps": [{ "step": "set_title", "arg": "Processed" }], "paths": ["scripted.pdf"], "folder": "actions" })); + ok(&mut a, "doc_batch", json!({ "paths": ["scripted.pdf"], "steps": ["optimize"], "out_dir": "batch" })); + + // Open each result through the same JavaScript-off session and inspect the field data. + for path in ["actions/scripted.pdf", "batch/scripted.pdf"] { + let doc = ok(&mut a, "doc_open", json!({ "path": path }))["doc"].as_u64().unwrap(); + let fields = ok(&mut a, "form_fields", json!({ "doc": doc })); + let qty = fields["fields"].as_array().unwrap().iter().find(|field| field["name"] == "qty").unwrap(); + assert_ne!(qty["value"], "42", "{path} ran the XFA initialize script"); + } + + // OCR needs its language models. When present, it uses the same per-file open path and must + // also keep the script from changing the saved field value. Without them, the engine's + // `ocr::tests::files_open_for_recognition_with_the_callers_javascript_preference` still + // checks the preference `recognize_file` opens each file with. + if ok(&mut a, "ocr_status", json!({}))["available"] == true { + ok(&mut a, "ocr_recognize_files", json!({ "paths": ["scripted.pdf"], "folder": "ocr", "skip_text_pages": false })); + let doc = ok(&mut a, "doc_open", json!({ "path": "ocr/scripted.pdf" }))["doc"].as_u64().unwrap(); + let fields = ok(&mut a, "form_fields", json!({ "doc": doc })); + let qty = fields["fields"].as_array().unwrap().iter().find(|field| field["name"] == "qty").unwrap(); + assert_ne!(qty["value"], "42", "OCR ran the XFA initialize script"); + } +} + #[test] fn action_run_keeps_outputs_with_duplicate_input_basenames() { let dir = workdir("action-duplicate-basenames"); diff --git a/crates/automation/tests/it/button_caption.rs b/crates/automation/tests/it/button_caption.rs index cb1a22a026..0290df7343 100644 --- a/crates/automation/tests/it/button_caption.rs +++ b/crates/automation/tests/it/button_caption.rs @@ -12,6 +12,7 @@ fn tool(a: &mut Automation, name: &str, args: Value) -> Value { } #[test] +/// Changing an existing caption regenerates the saved appearance and preserves form values. fn button_caption_can_be_changed_after_creation() { let dir = std::env::temp_dir().join(format!("pdfcraft-button-caption-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); @@ -34,11 +35,16 @@ fn button_caption_can_be_changed_after_creation() { let id = tool(&mut a, "doc_open", json!({"path": "buttons.pdf"}))["doc"].as_u64().unwrap(); let before = a.session().save_bytes(DocId(id)).unwrap(); let (_, _, empty_pixels) = a.render_pixels(id, 1, 1.0).unwrap(); - tool(&mut a, "form_set_props", json!({"doc": id, "field": "clear_button", "caption": "Clear Form"})); + let result = tool( + &mut a, + "js_run", + json!({ "doc": id, "field": "clear_button", "script": "getField('clear_button').value='ignored'; getField('clear_button').buttonSetCaption('Clear Form'); getField('clear_button').buttonGetCaption();" }), + ); + assert_eq!(result["result"], "Clear Form"); let fields = tool(&mut a, "form_fields", json!({"doc": id})); assert_eq!(fields["fields"][0]["caption"], "Clear Form"); assert_eq!(fields["fields"][0]["captions"], json!([{"widget": 1, "caption": "Clear Form"}])); - assert_eq!(a.session().get(DocId(id)).unwrap().can_undo(), Some("Change field properties")); + assert_eq!(a.session().get(DocId(id)).unwrap().can_undo(), Some("Run JavaScript")); let (_, _, labelled_pixels) = a.render_pixels(id, 1, 1.0).unwrap(); assert_ne!(empty_pixels, labelled_pixels, "the label must reach the actual page appearance"); tool(&mut a, "edit_undo", json!({"doc": id})); @@ -89,3 +95,69 @@ fn button_caption_can_be_changed_after_creation() { assert_eq!(pixels, empty_pixels); std::fs::remove_dir_all(dir).unwrap(); } + +/// An unindexed setter reaches both widgets even when the first caption already matches. +#[test] +fn javascript_caption_updates_all_widgets_with_undo_and_save() { + use pdfcraft_cos::{Dict, Document, Object, PdfString, SaveOptions, write_full}; + + let dir = std::env::temp_dir().join(format!("pdfcraft-js-multi-caption-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let mut session = Session::new(); + let bytes = session.create_blank(300.0, 400.0, 1).unwrap(); + let id = session.open("buttons.pdf", None, bytes, None).unwrap(); + for (name, caption, y) in [("first", "First", 300.0), ("second", "Second", 240.0)] { + session + .apply( + id, + Edit::AddField { + page: 0, + rect: [40.0, y, 200.0, y + 30.0], + kind: NewField::Button { caption: caption.into() }, + name: Some(name.into()), + }, + ) + .unwrap(); + } + let mut doc = Document::open(session.save_bytes(id).unwrap()).unwrap(); + let af = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"AcroForm").unwrap(); + let form = doc.get(af); + let fields = doc.resolve(form.as_dict().unwrap().get(b"Fields").unwrap()); + let widgets: Vec<_> = fields.as_array().unwrap().iter().map(|w| w.as_ref().unwrap()).collect(); + let mut parent = Dict::new(); + parent.set(b"FT".to_vec(), Object::name("Btn")); + parent.set(b"Ff".to_vec(), Object::Int(1 << 16)); + parent.set(b"T".to_vec(), PdfString::text("go")); + parent.set(b"Kids".to_vec(), Object::Array(widgets.iter().copied().map(Object::Ref).collect())); + let parent = doc.add(parent); + for w in widgets { + doc.update_dict(w, |d| { + for key in [b"FT".as_slice(), b"Ff", b"T"] { + d.remove(key); + } + d.set(b"Parent".to_vec(), Object::Ref(parent)); + }) + .unwrap(); + } + doc.update_dict(af, |d| d.set(b"Fields".to_vec(), Object::Array(vec![Object::Ref(parent)]))).unwrap(); + std::fs::write(dir.join("buttons.pdf"), write_full(&doc, &SaveOptions::default()).unwrap()).unwrap(); + let mut a = Automation::new().with_root(&dir).unwrap(); + let id = tool(&mut a, "doc_open", json!({"path": "buttons.pdf"}))["doc"].as_u64().unwrap(); + let (_, _, before) = a.render_pixels(id, 1, 1.0).unwrap(); + let result = tool(&mut a, "js_run", json!({"doc": id, "script": "getField('go').buttonSetCaption('First'); getField('go').buttonGetCaption();"})); + assert_eq!(result["error"], Value::Null); + assert_eq!(result["result"], "First"); + let expected = json!([{"widget": 1, "caption": "First"}, {"widget": 2, "caption": "First"}]); + assert_eq!(tool(&mut a, "form_fields", json!({"doc": id}))["fields"][0]["captions"], expected); + let (_, _, after) = a.render_pixels(id, 1, 1.0).unwrap(); + assert_ne!(before, after, "the second widget must visibly change"); + tool(&mut a, "edit_undo", json!({"doc": id})); + assert_eq!(a.render_pixels(id, 1, 1.0).unwrap().2, before); + tool(&mut a, "edit_redo", json!({"doc": id})); + assert_eq!(a.render_pixels(id, 1, 1.0).unwrap().2, after); + tool(&mut a, "doc_save", json!({"doc": id, "path": "saved.pdf"})); + let reopened = tool(&mut a, "doc_open", json!({"path": "saved.pdf"}))["doc"].as_u64().unwrap(); + assert_eq!(tool(&mut a, "form_fields", json!({"doc": reopened}))["fields"][0]["captions"], expected); + assert_eq!(a.render_pixels(reopened, 1, 1.0).unwrap().2, after); + std::fs::remove_dir_all(dir).unwrap(); +} diff --git a/crates/automation/tests/it/document_actions.rs b/crates/automation/tests/it/document_actions.rs new file mode 100644 index 0000000000..a8646a4437 --- /dev/null +++ b/crates/automation/tests/it/document_actions.rs @@ -0,0 +1,125 @@ +//! A form's document actions and pointer/focus scripts through the tool table (#269, ported +//! from #344): the open action runs on doc_open, Will Save on doc_save, Will Print on +//! doc_print, and js_event runs a widget's pointer and focus scripts, all in one realm. + +use pdfcraft_automation::{Automation, Content}; +use serde_json::{Value, json}; + +fn tool(a: &mut Automation, name: &str, args: Value) -> Value { + match a.call(name, &args).unwrap().remove(0) { + Content::Json(value) => value, + other => panic!("expected JSON: {other:?}"), + } +} + +fn pdf_of(objs: &[String]) -> Vec { + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +/// `log` records what ran; `hot` has Mouse Enter and On Blur scripts. The open action counts +/// in a variable the Will Save and Will Print actions and `hot`'s scripts read. +fn form() -> Vec { + pdf_of(&[ + "<< /Type /Catalog /Pages 2 0 R /OpenAction 6 0 R /AA << /WS 7 0 R /WP 8 0 R >> /AcroForm << /Fields [4 0 R 9 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 9 0 R] >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (log) /V () /Rect [20 250 180 270] /P 3 0 R /F 4 >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + "<< /S /JavaScript /JS (var n = 1; getField\\('log'\\).value = 'open' + n;) >>".into(), + "<< /S /JavaScript /JS (n++; getField\\('log'\\).value += ' save' + n; console.println\\('saving'\\);) >>".into(), + "<< /S /JavaScript /JS (n++; getField\\('log'\\).value += ' print' + n;) >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (hot) /V () /Rect [20 200 180 220] /P 3 0 R /F 4 /AA << /E << /S /JavaScript /JS (n++; getField\\('log'\\).value += ' ' + event.name + n;) >> /Bl << /S /JavaScript /JS (this.print\\(\\); getField\\('log'\\).value += ' ' + event.name;) >> >> >>".into(), + ]) +} + +fn log(a: &mut Automation, doc: u64) -> Value { + let f = tool(a, "form_fields", json!({ "doc": doc })); + f["fields"].as_array().unwrap().iter().find(|f| f["name"] == "log").map(|f| f["value"].clone()).unwrap() +} + +#[test] +fn open_will_save_will_print_and_field_events_share_the_document_realm() { + let dir = std::env::temp_dir().join(format!("pdfcraft-document-actions-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("form.pdf"), form()).unwrap(); + let mut a = Automation::new().with_root(&dir).unwrap().with_clock(|| 1_700_000_000); + let doc = tool(&mut a, "doc_open", json!({ "path": "form.pdf" }))["doc"].as_u64().unwrap(); + assert_eq!(log(&mut a, doc), "open1"); + + let entered = tool(&mut a, "js_event", json!({ "doc": doc, "field": "hot", "event": "mouse_enter" })); + assert_eq!(entered["changed"], true, "{entered}"); + assert_eq!(log(&mut a, doc), "open1 Mouse Enter2"); + // Losing the focus happens unasked: its print request is dropped and noted. + let blurred = tool(&mut a, "js_event", json!({ "doc": doc, "field": "hot", "event": "on_blur" })); + assert_eq!(blurred["requests"], json!([]), "{blurred}"); + assert_eq!(log(&mut a, doc), "open1 Mouse Enter2 Blur"); + let none = tool(&mut a, "js_event", json!({ "doc": doc, "field": "log", "event": "mouse_down" })); + assert_eq!(none["changed"], false, "no script: {none}"); + assert!(a.call("js_event", &json!({ "doc": doc, "field": "hot", "event": "keystroke" })).is_err()); + assert!(a.call("js_event", &json!({ "doc": doc, "field": "hot", "event": "mouse_up", "widget": 2 })).is_err()); + + // Saved in place (incrementally): object numbers stay, so the realm is kept. A full + // rewrite starts a fresh realm for the next event. + let saved = tool(&mut a, "doc_save", json!({ "doc": doc })); + assert_eq!(saved["incremental"], true, "{saved}"); + std::fs::copy(dir.join("form.pdf"), dir.join("saved.pdf")).unwrap(); + assert_eq!(saved["will_save"]["console"], json!(["saving"]), "{saved}"); + assert_eq!(log(&mut a, doc), "open1 Mouse Enter2 Blur save3"); + + let printed = tool(&mut a, "doc_print", json!({ "doc": doc, "path": "printed.pdf" })); + assert!(printed.get("will_print").is_none(), "nothing to report: {printed}"); + assert_eq!(log(&mut a, doc), "open1 Mouse Enter2 Blur save3 print4"); + // A print with nowhere to go fails before Will Print runs. + assert!(a.call("doc_print", &json!({ "doc": doc })).is_err()); + assert_eq!(log(&mut a, doc), "open1 Mouse Enter2 Blur save3 print4"); + + // What Will Save changed is in the saved file. + tool(&mut a, "js_enabled", json!({ "enabled": false })); + let reopened = tool(&mut a, "doc_open", json!({ "path": "saved.pdf" }))["doc"].as_u64().unwrap(); + assert_eq!(log(&mut a, reopened), "open1 Mouse Enter2 Blur save3"); +} + +/// Of two fields sharing a name, `field_id` (from form_fields) picks the one js_event runs; by +/// name alone it is the first. +#[test] +fn js_event_runs_the_field_its_field_id_names() { + let enter = |text: &str| format!("/AA << /E << /S /JavaScript /JS (getField\\('log'\\).value += '{text}';) >> >>"); + let pdf = pdf_of(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 6 0 R 7 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 6 0 R 7 0 R] >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (log) /V () /Rect [20 250 180 270] /P 3 0 R /F 4 >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T (dup) /V () /Rect [20 200 180 220] /P 3 0 R /F 4 {} >>", enter("first;")), + format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T (dup) /V () /Rect [20 150 180 170] /P 3 0 R /F 4 {} >>", enter("second;")), + ]); + let dir = std::env::temp_dir().join(format!("pdfcraft-js-event-dup-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("dup.pdf"), pdf).unwrap(); + let mut a = Automation::new().with_root(&dir).unwrap(); + let doc = tool(&mut a, "doc_open", json!({ "path": "dup.pdf" }))["doc"].as_u64().unwrap(); + let fields = tool(&mut a, "form_fields", json!({ "doc": doc })); + let ids: Vec = fields["fields"].as_array().unwrap().iter().filter(|f| f["name"] == "dup").map(|f| f["field_id"].as_u64().unwrap()).collect(); + assert_eq!(ids.len(), 2, "{fields}"); + tool(&mut a, "js_event", json!({ "doc": doc, "field": "dup", "field_id": ids[1], "event": "mouse_enter" })); + assert_eq!(log(&mut a, doc), "second;"); + tool(&mut a, "js_event", json!({ "doc": doc, "field": "dup", "event": "mouse_enter" })); + assert_eq!(log(&mut a, doc), "second;first;"); + assert!( + a.call("js_event", &json!({ "doc": doc, "field": "log", "field_id": ids[1], "event": "mouse_enter" })).is_err(), + "the id is another field's" + ); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/automation/tests/it/main.rs b/crates/automation/tests/it/main.rs index cdb072a888..77c3d3eff3 100644 --- a/crates/automation/tests/it/main.rs +++ b/crates/automation/tests/it/main.rs @@ -9,16 +9,19 @@ mod batch; mod batch_failures; mod batch_secrets; mod button_caption; +mod document_actions; mod find_text; mod form_bulk; mod hostile_args; mod list_selection; mod model_3d; mod model_render; +mod office; mod optimize_content_boundaries; mod portfolio_info; mod redact_after_stray_q_form; mod redact_text_after_q; mod render_warnings; mod text_copy; +mod timesheet_compat; mod unreadable_objects; diff --git a/crates/automation/tests/it/office.rs b/crates/automation/tests/it/office.rs new file mode 100644 index 0000000000..e406b19ad8 --- /dev/null +++ b/crates/automation/tests/it/office.rs @@ -0,0 +1,277 @@ +//! Office documents converted by a real LibreOffice through the tools that take them +//! (`doc_create_multiple`, `doc_combine`), with the converted text checked. Every test here +//! skips when LibreOffice isn't installed, which is the case on CI; run them on a machine that +//! has it. The Word, Excel and PowerPoint files are written here, as the smallest packages +//! LibreOffice reads, so no binary fixtures are committed. + +use std::path::Path; + +use pdfcraft_automation::{Automation, Content, ToolError}; +use serde_json::{Value, json}; + +fn auto(dir: &Path) -> Automation { + Automation::new().with_root(dir).unwrap().with_clock(|| 1_700_000_000) +} + +fn ok(a: &mut Automation, tool: &str, args: Value) -> Value { + match a.call(tool, &args) { + Ok(mut c) => match c.remove(0) { + Content::Json(v) => v, + other => panic!("{tool}: expected JSON, got {other:?}"), + }, + Err(e) => panic!("{tool} {args}: {e}"), + } +} + +/// Each page's text, with runs of whitespace made single spaces. +fn page_text(a: &mut Automation, doc: u64) -> Vec { + ok(a, "text_extract", json!({ "doc": doc }))["pages"] + .as_array() + .unwrap() + .iter() + .map(|p| p["text"].as_str().unwrap().split_whitespace().collect::>().join(" ")) + .collect() +} + +/// A ZIP of `entries`, stored without compression (Office packages may be). +fn zip(entries: &[(&str, &str)]) -> Vec { + fn crc32(data: &[u8]) -> u32 { + let mut crc = !0u32; + for &b in data { + crc ^= u32::from(b); + for _ in 0..8 { + crc = if crc & 1 == 1 { (crc >> 1) ^ 0xEDB8_8320 } else { crc >> 1 }; + } + } + !crc + } + let (mut out, mut central) = (Vec::new(), Vec::new()); + for (name, data) in entries { + let (crc, size, offset) = (crc32(data.as_bytes()), data.len() as u32, out.len() as u32); + // version 2.0, no flags, stored, 1980-01-01 00:00 + let common = [&20u16.to_le_bytes()[..], &0u16.to_le_bytes(), &0u16.to_le_bytes(), &0u16.to_le_bytes(), &0x21u16.to_le_bytes()].concat(); + let sizes = [crc.to_le_bytes(), size.to_le_bytes(), size.to_le_bytes()].concat(); + out.extend_from_slice(&0x0403_4b50u32.to_le_bytes()); + out.extend_from_slice(&common); + out.extend_from_slice(&sizes); + out.extend_from_slice(&(name.len() as u16).to_le_bytes()); + out.extend_from_slice(&0u16.to_le_bytes()); + out.extend_from_slice(name.as_bytes()); + out.extend_from_slice(data.as_bytes()); + central.extend_from_slice(&0x0201_4b50u32.to_le_bytes()); + central.extend_from_slice(&20u16.to_le_bytes()); + central.extend_from_slice(&common); + central.extend_from_slice(&sizes); + central.extend_from_slice(&(name.len() as u16).to_le_bytes()); + // extra, comment, disk, internal and external attributes + central.extend_from_slice(&[0u8; 12]); + central.extend_from_slice(&offset.to_le_bytes()); + central.extend_from_slice(name.as_bytes()); + } + let (at, count) = (out.len() as u32, entries.len() as u16); + out.extend_from_slice(¢ral); + out.extend_from_slice(&0x0605_4b50u32.to_le_bytes()); + out.extend_from_slice(&[0u8; 4]); + out.extend_from_slice(&count.to_le_bytes()); + out.extend_from_slice(&count.to_le_bytes()); + out.extend_from_slice(&(central.len() as u32).to_le_bytes()); + out.extend_from_slice(&at.to_le_bytes()); + out.extend_from_slice(&0u16.to_le_bytes()); + out +} + +const XML: &str = r#""#; +const PACKAGE_RELS: &str = "http://schemas.openxmlformats.org/package/2006/relationships"; +const DOC_RELS: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; + +/// `[Content_Types].xml` with one override per (part, type). +fn content_types(parts: &[(&str, &str)]) -> String { + let overrides: String = parts.iter().map(|(part, kind)| format!(r#""#)).collect(); + format!( + r#"{XML}{overrides}"# + ) +} + +/// A relationships part with one relationship per (id, type under officeDocument, target). +fn rels(list: &[(&str, &str, &str)]) -> String { + let items: String = + list.iter().map(|(id, kind, target)| format!(r#""#)).collect(); + format!(r#"{XML}{items}"#) +} + +/// A Word document of one paragraph. +fn docx(text: &str) -> Vec { + let document = format!( + r#"{XML}{text}"# + ); + zip(&[ + ( + "[Content_Types].xml", + &content_types(&[("word/document.xml", "application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml")]), + ), + ("_rels/.rels", &rels(&[("rId1", "officeDocument", "word/document.xml")])), + ("word/document.xml", &document), + ]) +} + +/// An Excel workbook whose one sheet holds `text` in A1 and a formula, `=6*7`, in A2. +fn xlsx(text: &str) -> Vec { + let workbook = format!( + r#"{XML}"# + ); + let sheet = format!( + r#"{XML}{text}6*7"# + ); + zip(&[ + ( + "[Content_Types].xml", + &content_types(&[ + ("xl/workbook.xml", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"), + ("xl/worksheets/sheet1.xml", "application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"), + ]), + ), + ("_rels/.rels", &rels(&[("rId1", "officeDocument", "xl/workbook.xml")])), + ("xl/workbook.xml", &workbook), + ("xl/_rels/workbook.xml.rels", &rels(&[("rId1", "worksheet", "worksheets/sheet1.xml")])), + ("xl/worksheets/sheet1.xml", &sheet), + ]) +} + +/// A PowerPoint presentation of one slide with one text box. +fn pptx(text: &str) -> Vec { + const P: &str = + r#"xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main""#; + let presentation = format!( + r#"{XML}"# + ); + let tree = r#""#; + let master = format!( + r#"{XML}{tree}"# + ); + let layout = format!(r#"{XML}{tree}"#); + let slide = format!( + r#"{XML}{tree}{text}"# + ); + let theme = format!( + r#"{XML}"# + ); + const PML: &str = "application/vnd.openxmlformats-officedocument.presentationml"; + let types = content_types(&[ + ("ppt/presentation.xml", &format!("{PML}.presentation.main+xml")), + ("ppt/slideMasters/slideMaster1.xml", &format!("{PML}.slideMaster+xml")), + ("ppt/slideLayouts/slideLayout1.xml", &format!("{PML}.slideLayout+xml")), + ("ppt/slides/slide1.xml", &format!("{PML}.slide+xml")), + ("ppt/theme/theme1.xml", "application/vnd.openxmlformats-officedocument.theme+xml"), + ]); + zip(&[ + ("[Content_Types].xml", &types), + ("_rels/.rels", &rels(&[("rId1", "officeDocument", "ppt/presentation.xml")])), + ("ppt/presentation.xml", &presentation), + ( + "ppt/_rels/presentation.xml.rels", + &rels(&[ + ("rId1", "slideMaster", "slideMasters/slideMaster1.xml"), + ("rId2", "slide", "slides/slide1.xml"), + ("rId3", "theme", "theme/theme1.xml"), + ]), + ), + ("ppt/slideMasters/slideMaster1.xml", &master), + ( + "ppt/slideMasters/_rels/slideMaster1.xml.rels", + &rels(&[("rId1", "slideLayout", "../slideLayouts/slideLayout1.xml"), ("rId2", "theme", "../theme/theme1.xml")]), + ), + ("ppt/slideLayouts/slideLayout1.xml", &layout), + ("ppt/slideLayouts/_rels/slideLayout1.xml.rels", &rels(&[("rId1", "slideMaster", "../slideMasters/slideMaster1.xml")])), + ("ppt/slides/slide1.xml", &slide), + ("ppt/slides/_rels/slide1.xml.rels", &rels(&[("rId1", "slideLayout", "../slideLayouts/slideLayout1.xml")])), + ("ppt/theme/theme1.xml", &theme), + ]) +} + +/// An RTF document of one paragraph. +fn rtf(text: &str) -> Vec { + format!("{{\\rtf1\\ansi{{\\fonttbl\\f0\\fswiss Helvetica;}}\\f0\\pard {text}\\par}}").into_bytes() +} + +/// A folder with one Word, Excel, PowerPoint and RTF file, each carrying its own word, and the +/// tools rooted there; `None` when LibreOffice isn't installed. +fn office_files() -> Option<(tempfile::TempDir, Automation)> { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("letter.docx"), docx("Quokka letter")).unwrap(); + std::fs::write(dir.path().join("budget.xlsx"), xlsx("Narwhal budget")).unwrap(); + std::fs::write(dir.path().join("deck.pptx"), pptx("Axolotl deck")).unwrap(); + std::fs::write(dir.path().join("notes.rtf"), rtf("Pangolin notes")).unwrap(); + let mut a = auto(dir.path()); + match a.call("doc_create_multiple", &json!({ "paths": ["notes.rtf"] })) { + Err(ToolError::Failed(e)) if e.contains("needs LibreOffice") => { + eprintln!("skipped: LibreOffice isn't installed"); + None + } + _ => Some((dir, a)), + } +} + +/// Each Office format converts to a PDF of its own that holds the document's text (and, for +/// the spreadsheet, the formula's value). +#[test] +fn office_documents_convert_separately_with_their_text() { + let Some((dir, mut a)) = office_files() else { return }; + let paths = ["letter.docx", "budget.xlsx", "deck.pptx", "notes.rtf"]; + let made = ok(&mut a, "doc_create_multiple", json!({ "paths": paths, "mode": "separate", "out_dir": "out" })); + let files = made["files"].as_array().unwrap(); + assert_eq!(files.len(), 4, "{made}"); + let expected = [ + ("letter.pdf", &["Quokka letter"][..]), + ("budget.pdf", &["Narwhal budget", "42"]), + ("deck.pdf", &["Axolotl deck"]), + ("notes.pdf", &["Pangolin notes"]), + ]; + for (file, (name, words)) in files.iter().zip(expected) { + let output = file["output"].as_str().unwrap_or_else(|| panic!("{file}")); + // The tool reports the folder in its canonical form (`\\?\C:\…` on Windows). + assert_eq!(Path::new(output).file_name(), Some(std::ffi::OsStr::new(name)), "{output}"); + assert!(dir.path().join("out").join(name).is_file(), "{name}"); + let doc = ok(&mut a, "doc_open", json!({ "path": output }))["doc"].as_u64().unwrap(); + let text = page_text(&mut a, doc).join("\n"); + for word in words { + assert!(text.contains(word), "{output}: {word:?} not in {text:?}"); + } + } +} + +/// Office documents combine, in the order given, with a PDF made here, one bookmark each. +#[test] +fn office_documents_combine_in_order_with_a_bookmark_each() { + let Some((dir, mut a)) = office_files() else { return }; + let made = ok( + &mut a, + "doc_create_multiple", + json!({ "paths": ["deck.pptx", "letter.docx", "budget.xlsx", "notes.rtf"], "out": "all.pdf", "open": true }), + ); + let doc = made["document"]["doc"].as_u64().unwrap(); + let pages = page_text(&mut a, doc); + assert_eq!(pages.len(), 4, "{pages:?}"); + for (page, word) in pages.iter().zip(["Axolotl deck", "Quokka letter", "Narwhal budget", "Pangolin notes"]) { + assert!(page.contains(word), "{word:?} not in {page:?}"); + } + let titles: Vec = ok(&mut a, "bookmark_list", json!({ "doc": doc }))["bookmarks"] + .as_array() + .unwrap() + .iter() + .map(|b| b["title"].as_str().unwrap().to_string()) + .collect(); + assert_eq!(titles, ["deck", "letter", "budget", "notes"]); + assert!(dir.path().join("all.pdf").is_file()); + + // doc_combine takes them beside a PDF, with a page range for one of them. + std::fs::write(dir.path().join("cover.txt"), "Cover page").unwrap(); + ok(&mut a, "doc_create_multiple", json!({ "paths": ["cover.txt"], "out": "cover.pdf" })); + assert!(dir.path().join("cover.pdf").is_file()); + let combined = ok(&mut a, "doc_combine", json!({ "paths": ["cover.pdf", "notes.rtf", "letter.docx"], "pages": [null, "1", null], "open": true })); + let doc = combined["document"]["doc"].as_u64().unwrap(); + let pages = page_text(&mut a, doc); + assert_eq!(pages.len(), 3, "{pages:?}"); + for (page, word) in pages.iter().zip(["Cover page", "Pangolin notes", "Quokka letter"]) { + assert!(page.contains(word), "{word:?} not in {page:?}"); + } +} diff --git a/crates/automation/tests/it/timesheet_compat.rs b/crates/automation/tests/it/timesheet_compat.rs new file mode 100644 index 0000000000..f6910543b6 --- /dev/null +++ b/crates/automation/tests/it/timesheet_compat.rs @@ -0,0 +1,243 @@ +//! Original synthetic workflow: no private timesheet, employee, PIN or signature assets. +use pdfcraft_automation::{Automation, Content}; +use pdfcraft_cos::{Dict, Document, Object, SaveOptions, Stream, write_full}; +use pdfcraft_engine::{Edit, NewField, Session}; +use serde_json::{Value, json}; +use std::sync::Arc; + +/// Dispatch a synthetic automation step and fail with the returned tool error on refusal. +fn call(a: &mut Automation, name: &str, args: Value) -> Value { + match a.call(name, &args).unwrap().remove(0) { + Content::Json(v) => v, + _ => panic!("expected JSON"), + } +} +/// Construct a synthetic form and transparent Form XObject without private files or font assets. +fn fixture() -> Vec { + let mut session = Session::new(); + let bytes = session.create_blank(600.0, 800.0, 1).unwrap(); + let id = session.open("synthetic.pdf", None, bytes, None).unwrap(); + for (i, name) in ["input", "double", "seen", "choice", "go", "source", "target"].into_iter().enumerate() { + session + .apply( + id, + Edit::AddField { + page: 0, + rect: [40.0, 700.0 - i as f64 * 45.0, 200.0, 730.0 - i as f64 * 45.0], + kind: if i >= 4 { NewField::Button { caption: name.into() } } else { NewField::Text { multiline: false } }, + name: Some(name.into()), + }, + ) + .unwrap(); + } + let mut doc = Document::open(session.save_bytes(id).unwrap()).unwrap(); + let source = session.get(id).unwrap().form.iter().find(|f| f.name == "source").unwrap().obj; + let mut gs = Dict::new(); + gs.set(b"ca".to_vec(), Object::Real(0.5)); + let mut states = Dict::new(); + states.set(b"Half".to_vec(), Object::Dict(gs)); + let mut resources = Dict::new(); + resources.set(b"ExtGState".to_vec(), Object::Dict(states)); + let mut dict = Dict::new(); + dict.set(b"Type".to_vec(), Object::name("XObject")); + dict.set(b"Subtype".to_vec(), Object::name("Form")); + dict.set(b"BBox".to_vec(), Object::Array([0, 0, 60, 30].map(Object::Int).to_vec())); + dict.set(b"Resources".to_vec(), Object::Dict(resources)); + let icon = doc.add(Object::Stream(Stream::from_raw(dict, b"q /Half gs 1 0 0 rg 0 0 60 30 re f Q".to_vec()))); + let mut mk = Dict::new(); + mk.set(b"I".to_vec(), Object::Ref(icon)); + mk.set(b"TP".to_vec(), Object::Int(1)); + doc.update_dict(source, |d| d.set(b"MK".to_vec(), Object::Dict(mk))).unwrap(); + write_full(&doc, &SaveOptions::default()).unwrap() +} +const HELPERS: &str = r#" +var initialized=1, buffer='', savedIcon=null; +function openPad(){var b=getPageBox('Crop',0);var f=getField('go');f.rect=[b[0]+40,b[1]-400,b[0]+200,b[1]-450];f.textSize=16;f.fillColor=['RGB',0.9,0.8,0.7];f.strokeColor=['G',0.4];f.lineWidth=2;f.borderStyle='dashed';f.textColor=color.blue;f.userName='Synthetic keypad';f.display=display.noPrint;} +function digit(d){buffer+=d;getField('go').buttonSetCaption(buffer);if(buffer==='1234'){getField('target').buttonSetIcon(savedIcon);getField('go').display=display.hidden;}} +function selectTime(){var v=app.popUpMenuEx({cName:'Unavailable',cReturn:'bad',bEnabled:false},{cName:'Times',oSubMenu:[{cName:'Seven',cReturn:'07:00',bMarked:true},{cName:'Manual',cReturn:'manual'}]});if(v==='manual')v=app.response({cQuestion:'Time',cDefault:'07:00'});if(v!==null)getField('choice').value=v;} +"#; + +#[test] +/// Exercise retained keypad state, geometry, synchronous calculation and transparent icons through save/reopen and Undo/Redo. +fn timesheet_keypad_geometry_icons_calculation_and_persistence() { + let dir = std::env::temp_dir().join(format!("pdfcraft-timesheet-compat-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("synthetic.pdf"), fixture()).unwrap(); + let mut a = Automation::new().with_root(&dir).unwrap(); + let id = call(&mut a, "doc_open", json!({"path":"synthetic.pdf"}))["doc"].as_u64().unwrap(); + call(&mut a, "form_set_script", json!({"doc":id,"field":"double","event":"calculate","script":"event.value=Number(getField('input').value)*2;"})); + call(&mut a, "js_set_document_script", json!({"doc":id,"name":"helpers","script":HELPERS})); + let out = call( + &mut a, + "js_run", + json!({"doc":id,"script":"getField('input').value=3;calculateNow();getField('seen').value=getField('double').value;getField('seen').value;"}), + ); + assert_eq!(out["error"], Value::Null); + assert_eq!(out["result"], "6"); + let out = call(&mut a, "js_run", json!({"doc":id,"script":"openPad();savedIcon=getField('source').buttonGetIcon();initialized;"})); + assert_eq!(out["error"], Value::Null); + assert_eq!(out["result"], "1"); + let fields = call(&mut a, "form_fields", json!({"doc":id})); + let go = fields["fields"].as_array().unwrap().iter().find(|f| f["name"] == "go").unwrap(); + assert_eq!(go["tooltip"], "Synthetic keypad"); + let model = a.session().get(pdfcraft_engine::DocId(id)).unwrap().form.iter().find(|f| f.name == "go").unwrap(); + assert_eq!(model.widgets[0].rect, [40.0, 350.0, 200.0, 400.0]); + assert!(model.da.contains("16 Tf"), "{}", model.da); + for (key, expected) in [("1", "1"), ("2", "12"), ("3", "123")] { + let out = call(&mut a, "js_run", json!({"doc":id,"field":"go","script":format!("digit('{key}');buffer;")})); + assert_eq!(out["error"], Value::Null); + assert_eq!(out["result"], expected); + } + let (_, _, before) = a.render_pixels(id, 1, 1.0).unwrap(); + let out = call(&mut a, "js_run", json!({"doc":id,"field":"go","script":"digit('4');buffer;"})); + assert_eq!(out["error"], Value::Null); + assert_eq!(out["result"], "1234"); + let (_, _, after) = a.render_pixels(id, 1, 1.0).unwrap(); + assert_ne!(before, after); + let blended = after + .as_chunks::<4>() + .0 + .iter() + .filter(|p| p[0] > p[1].saturating_add(50) && (50..180).contains(&p[1]) && (p[1] as i16 - p[2] as i16).abs() <= 2) + .count(); + assert!(blended > 500, "half-opacity red icon must blend across its area with the button's grey background, got {blended} pixels"); + call(&mut a, "edit_undo", json!({"doc":id})); + assert_eq!(a.render_pixels(id, 1, 1.0).unwrap().2, before); + call(&mut a, "edit_redo", json!({"doc":id})); + assert_eq!(a.render_pixels(id, 1, 1.0).unwrap().2, after); + let rejected = call(&mut a, "js_run", json!({"doc":id,"script":"getField('target').buttonSetIcon({});"})); + assert!(rejected["error"].as_str().unwrap().contains("icon")); + call(&mut a, "doc_save", json!({"doc":id,"path":"saved.pdf"})); + let saved = Document::open(Arc::new(std::fs::read(dir.join("saved.pdf")).unwrap())).unwrap(); + let root = saved.get(saved.root().unwrap()); + let af = saved.resolve(root.as_dict().unwrap().get(b"AcroForm").unwrap()); + let fields = saved.resolve(af.as_dict().unwrap().get(b"Fields").unwrap()); + let target = fields + .as_array() + .unwrap() + .iter() + .map(|f| saved.resolve(f)) + .find(|f| f.as_dict().unwrap().get(b"T").and_then(Object::as_string).is_some_and(|s| s.to_text() == "target")) + .unwrap(); + let mk = saved.resolve(target.as_dict().unwrap().get(b"MK").unwrap()); + let icon = saved.resolve(mk.as_dict().unwrap().get(b"I").unwrap()); + let Object::Stream(icon) = &*icon else { panic!("icon stream") }; + assert!(String::from_utf8_lossy(&icon.decoded().unwrap()).contains("/Half gs")); + let reopened = call(&mut a, "doc_open", json!({"path":"saved.pdf"}))["doc"].as_u64().unwrap(); + assert_eq!(a.render_pixels(reopened, 1, 1.0).unwrap().2, after); + let reset = call(&mut a, "js_run", json!({"doc":reopened,"script":"buffer;"})); + assert_eq!(reset["result"], ""); + std::fs::remove_dir_all(dir).unwrap(); +} + +#[test] +/// Resume one invocation across popup/text prompts and reject disabled or invented choices. +fn popup_and_manual_response_resume_the_same_script_and_validate_choices() { + let dir = std::env::temp_dir().join(format!("pdfcraft-timesheet-menu-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("synthetic.pdf"), fixture()).unwrap(); + let mut a = Automation::new().with_root(&dir).unwrap(); + let id = call(&mut a, "doc_open", json!({"path":"synthetic.pdf"}))["doc"].as_u64().unwrap(); + call(&mut a, "js_set_document_script", json!({"doc":id,"name":"helpers","script":HELPERS})); + let out = call(&mut a, "js_run", json!({"doc":id,"script":"selectTime();getField('choice').value;","answers":["manual","07:45"]})); + assert_eq!(out["error"], Value::Null); + assert_eq!(out["result"], "07:45"); + let cancel = call(&mut a, "js_run", json!({"doc":id,"script":"selectTime();getField('choice').value;","answers":[null]})); + assert_eq!(cancel["result"], "07:45"); + let invalid = call(&mut a, "js_run", json!({"doc":id,"script":"selectTime();","answers":["bad"]})); + assert!(invalid["error"].as_str().unwrap().contains("invalid popup")); + let alert = call(&mut a, "js_run", json!({"doc":id,"script":"app.alert({cMsg:'Continue?',nType:2});","answers":["4"]})); + assert_eq!(alert["result"], "4"); + std::fs::remove_dir_all(dir).unwrap(); +} + +/// The advertised schema must bound prompt strings before clients dispatch them. +#[test] +fn prompt_answer_schema_advertises_the_length_budget() { + let tool = pdfcraft_automation::tools().into_iter().find(|t| t.name == "js_run").unwrap(); + assert_eq!(tool.input_schema["properties"]["answers"]["items"]["maxLength"], 4096); +} + +#[test] +fn js_run_exact_field_ids_are_discoverable_and_strictly_validated() { + let dir = std::env::temp_dir().join(format!("pdfcraft-field-identity-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("synthetic.pdf"), fixture()).unwrap(); + let mut a = Automation::new().with_root(&dir).unwrap(); + let id = call(&mut a, "doc_open", json!({"path":"synthetic.pdf"}))["doc"].as_u64().unwrap(); + let fields = call(&mut a, "form_fields", json!({"doc":id})); + let entries = fields["fields"].as_array().unwrap(); + let go = entries.iter().find(|f| f["name"] == "go").unwrap()["field_id"].as_u64().unwrap(); + let other = entries.iter().find(|f| f["name"] == "input").unwrap()["field_id"].as_u64().unwrap(); + let out = call( + &mut a, + "js_run", + json!({"doc":id,"field":"go","field_id":go,"script":"event.target.buttonSetCaption(app.response({cQuestion:'Caption'}));event.target.name;","answers":["exact"]}), + ); + assert_eq!(out["error"], Value::Null); + assert_eq!(out["result"], "go"); + for invalid in [json!(-1), json!(1.5), json!("bad"), json!(281474976710656u64), json!(other)] { + assert!(a.call("js_run", &json!({"doc":id,"field":"go","field_id":invalid,"script":"getField('input').value='bad';"})).is_err()); + } + assert!(a.call("js_run", &json!({"doc":id,"field_id":go,"script":"true;"})).is_err()); + let schema = pdfcraft_automation::tools().into_iter().find(|t| t.name == "js_run").unwrap(); + assert_eq!(schema.input_schema["properties"]["field_id"]["maximum"], 281474976710655u64); + std::fs::remove_dir_all(dir).unwrap(); +} + +/// The headless image picker counterpart accepts exact duplicate identities and keeps legacy lookup. +#[test] +fn exact_image_field_tool_targets_duplicates_and_rejects_invalid_ids() { + let dir = std::env::temp_dir().join(format!("pdfcraft-exact-image-tool-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let mut session = Session::new(); + let bytes = session.create_blank(300.0, 300.0, 1).unwrap(); + let id = session.open("synthetic.pdf", None, bytes, None).unwrap(); + for (name, x) in [("first", 20.0), ("second", 160.0)] { + session.apply(id, Edit::AddField { page: 0, rect: [x, 20.0, x + 100.0, 80.0], kind: NewField::Image, name: Some(name.into()) }).unwrap(); + } + let mut cos = Document::open(session.save_bytes(id).unwrap()).unwrap(); + for field in session.get(id).unwrap().form.iter() { + cos.update_dict(field.obj, |d| d.set(b"T".to_vec(), pdfcraft_cos::PdfString::text("same"))).unwrap(); + } + std::fs::write(dir.join("synthetic.pdf"), write_full(&cos, &Default::default()).unwrap()).unwrap(); + std::fs::write(dir.join("picture.gif"), pdfcraft_xfa::fixtures::TINY_GIF).unwrap(); + let mut a = Automation::new().with_root(&dir).unwrap(); + let id = call(&mut a, "doc_open", json!({"path":"synthetic.pdf"}))["doc"].as_u64().unwrap(); + let fields = call(&mut a, "form_fields", json!({"doc":id})); + let second = fields["fields"][1]["field_id"].as_u64().unwrap(); + call(&mut a, "form_set_image", json!({"doc":id,"field":"same","field_id":second,"path":"picture.gif"})); + let icons = |a: &mut Automation| { + call(a, "doc_save", json!({"doc":id,"path":"saved.pdf"})); + let cos = Document::open(Arc::new(std::fs::read(dir.join("saved.pdf")).unwrap())).unwrap(); + let root = cos.get(cos.root().unwrap()); + let form = cos.resolve(root.as_dict().unwrap().get(b"AcroForm").unwrap()); + form.as_dict() + .unwrap() + .get(b"Fields") + .unwrap() + .as_array() + .unwrap() + .iter() + .map(|r| { + cos.resolve(r) + .as_dict() + .and_then(|d| d.get(b"MK").map(|v| cos.resolve(v))) + .is_some_and(|m| m.as_dict().is_some_and(|d| d.contains(b"I"))) + }) + .collect::>() + }; + assert_eq!(icons(&mut a), [false, true]); + for invalid in [json!(-1), json!(1.5), json!("bad"), json!(281474976710656u64)] { + assert!(a.call("form_set_image", &json!({"doc":id,"field":"same","field_id":invalid,"path":"picture.gif"})).is_err()); + } + assert!(a.call("form_set_image", &json!({"doc":id,"field":"missing","field_id":second,"path":"picture.gif"})).is_err()); + assert_eq!(icons(&mut a), [false, true]); + call(&mut a, "edit_undo", json!({"doc":id})); + assert_eq!(icons(&mut a), [false, false]); + call(&mut a, "form_set_image", json!({"doc":id,"field":"same","path":"picture.gif"})); + assert_eq!(icons(&mut a), [true, false]); + let tool = pdfcraft_automation::tools().into_iter().find(|t| t.name == "form_set_image").unwrap(); + assert_eq!(tool.input_schema["properties"]["field_id"]["maximum"], 281474976710655u64); + std::fs::remove_dir_all(dir).unwrap(); +} diff --git a/crates/content/src/lib.rs b/crates/content/src/lib.rs index fb17a903d1..f77c54e1dc 100644 --- a/crates/content/src/lib.rs +++ b/crates/content/src/lib.rs @@ -58,6 +58,23 @@ impl Op { pub fn last_name(&self) -> Option<&[u8]> { self.operands.last().and_then(Object::as_name) } + + /// Operand `k` counted from the end (0 is the last), as the renderer reads an operator's + /// operands: `(x) /OC /MC0 BDC` marks with `/OC /MC0`, and `(x) /F1 10 Tf` selects `/F1`. + pub fn back(&self, k: usize) -> Option<&Object> { + let i = self.operands.len().checked_sub(k)?.checked_sub(1)?; + self.operands.get(i) + } + + /// [`Op::back`] as a name. + pub fn name_back(&self, k: usize) -> Option<&[u8]> { + self.back(k).and_then(Object::as_name) + } + + /// [`Op::back`] as a number. + pub fn num_back(&self, k: usize) -> Option { + self.back(k).and_then(Object::as_f64) + } } /// A parsed stream. diff --git a/crates/content/src/tests.rs b/crates/content/src/tests.rs index 9ca12c4536..89f258853d 100644 --- a/crates/content/src/tests.rs +++ b/crates/content/src/tests.rs @@ -10,6 +10,17 @@ thread_local! { pub(crate) static PIECE_VISITS: std::cell::Cell = const { std::cell::Cell::new(0) }; } +#[test] +fn operands_are_counted_from_the_end() { + let op = parse(b"(x) /OC /MC0 BDC").ops.remove(0); + assert_eq!((op.name_back(1), op.name_back(0)), (Some(&b"OC"[..]), Some(&b"MC0"[..]))); + assert_eq!(op.back(2).and_then(Object::as_string).map(|s| s.bytes.clone()), Some(b"x".to_vec())); + assert_eq!((op.back(3), op.back(usize::MAX)), (None, None)); + let op = parse(b"/F1 10 Tf").ops.remove(0); + assert_eq!((op.name_back(1), op.num_back(0), op.num_back(1)), (Some(&b"F1"[..]), Some(10.0), None)); + assert_eq!(Op::new("Q", vec![]).back(0), None); +} + #[test] fn splicing_visits_each_piece_a_few_times() { let n = 5_000; diff --git a/crates/cos/src/document/objects.rs b/crates/cos/src/document/objects.rs index 925a7c879d..42aef4af0f 100644 --- a/crates/cos/src/document/objects.rs +++ b/crates/cos/src/document/objects.rs @@ -2,8 +2,8 @@ use std::collections::HashSet; use super::reader::find; use super::state::{ - LENGTHS, LoadGuard, ObjStm, ObjectReader, ReadState, ScanLengths, Slot, SourceIdentity, loaded_bytes, loading, note_overlay_read, objstm_member, - overlay_reads, + LENGTHS, LoadGuard, ObjStm, ObjectReader, ReadFrame, ReadState, ScanLengths, Slot, SourceIdentity, loaded_bytes, loading, note_overlay_read, + note_read, objstm_member, overlay_reads, }; use super::xref::{Revision, XrefEntry, XrefIndex}; use super::{ @@ -244,6 +244,12 @@ impl Document { pub(super) fn try_get_reference(&self, r: ObjRef) -> Result, CosError> { if !self.reference_matches(r) { + // An edit of `r.num` can make it match, so a load that read null here read it too; + // with `r.num` edited (freed, or set at another generation) the null is the edit's. + if self.overlay.contains_key(&r.num) { + note_overlay_read(); + } + note_read(r.num, None); return Ok(Arc::new(Object::Null)); } self.try_get(r.num) @@ -554,22 +560,32 @@ impl Document { pub fn try_get(&self, num: u32) -> Result, CosError> { if let Some(slot) = self.overlay.get(&num) { + note_read(num, None); note_overlay_read(); return Ok(match slot { Slot::Set(_, o) | Slot::RepairedSet(_, _, o) => o.clone(), Slot::Freed(_) => Arc::new(Object::Null), }); } - if let Some(loaded) = self.cache.lock().map_err(|_| CosError::Poisoned)?.get(&num) { + // Clones share the cache: what a load read that this document has edited since (the + // member's object stream, a `/Length`) makes the entry another document's. + if let Some((loaded, reads)) = self.cache.lock().map_err(|_| CosError::Poisoned)?.get(&num) + && !reads.touch(&self.overlay) + { + note_read(num, Some(reads)); return loaded.clone(); } + note_read(num, None); // Loading can re-enter (indirect stream `/Length`, object streams); damaged files make // cycles such as `6 0 obj << /Length 6 0 R >>`. Bound the nesting on every path. let _guard = LoadGuard::enter(num).ok_or_else(|| CosError::Syntax { offset: 0, detail: format!("reference cycle while loading object {num}") })?; let _scope = crate::security::FollowScope::enter(); let edits_read = overlay_reads(); + let frame = ReadFrame::enter(); let loaded = self.load(num, 0).map(Arc::new); + let reads = frame.reads(); + drop(frame); if let Err(e) = &loaded && !matches!(e, CosError::Poisoned) { @@ -581,7 +597,7 @@ impl Document { if overlay_reads() == edits_read && !matches!(loaded, Err(CosError::Poisoned)) { // The editing cache is bounded like the scan reader's: re-parsing a file-backed view // is cheap, and returned objects outlive eviction. - self.cache.lock().map_err(|_| CosError::Poisoned)?.insert(num, loaded.clone(), loaded_bytes(&loaded, &self.data)); + self.cache.lock().map_err(|_| CosError::Poisoned)?.insert(num, loaded.clone(), loaded_bytes(&loaded, &self.data), reads); self.evict_editor_cache_if_over_budget(num)?; } loaded @@ -724,9 +740,14 @@ impl Document { pub(super) fn objstm(&self, num: u32) -> Result, CosError> { // An edited or freed stream is this document's alone: clones share the parsed streams. let edited = self.overlay.contains_key(&num); - if !edited && let Some(s) = self.objstms.lock().map_err(|_| CosError::Poisoned)?.get(&num) { + if !edited + && let Some(s) = self.objstms.lock().map_err(|_| CosError::Poisoned)?.get(&num) + && !s.reads.touch(&self.overlay) + { + note_read(num, Some(&s.reads)); return Ok(s.clone()); } + let frame = ReadFrame::enter(); // A file entry that cannot place the stream (one that says it is itself compressed // inside another stream, as in #2367, or points nowhere) is tried by the stream's own // header, as a plain object with a wrong offset is. An edited or freed stream, in the @@ -740,8 +761,6 @@ impl Document { } _ => Arc::new(self.scan_for(num).map(|(id, o)| self.decrypted(id, o)).ok_or(CosError::MissingObject(num))?), }; - // A stream whose load read this document's edits (its `/Length`) is as much its own as an edited one. - let own = edited || overlay_reads() != edits_read; let Object::Stream(s) = &*loaded else { return Err(CosError::MissingObject(num)) }; let n = s.dict.int(b"N").unwrap_or(0).clamp(0, 1_000_000) as usize; let first = usize::try_from(s.dict.int(b"First").unwrap_or(0).max(0)).ok(); @@ -758,8 +777,10 @@ impl Document { let (Some(Object::Int(onum)), Some(Object::Int(off))) = (lx.object().ok(), lx.object().ok()) else { break }; index.push(objstm_member(onum, off, first, data.len())); } - let stm = Arc::new(ObjStm { index, data: Arc::new(data) }); - if own { + let stm = Arc::new(ObjStm { index, data: Arc::new(data), reads: frame.reads() }); + // A stream whose load or decoding read this document's edits (its `/Length`, its + // `/DecodeParms`) is as much its own as an edited one. + if edited || overlay_reads() != edits_read { return Ok(stm); } // Same bound as the object cache: decoded object streams can be megabytes each. @@ -777,17 +798,21 @@ impl Document { note_overlay_read(); } let cached = loading(); - // A length resolved through this document's edits counts as an edit read on every reuse. - if cached && let Some((known, edits)) = LENGTHS.with(|m| m.borrow().get(&r).copied()) { + // Each reuse counts as a read of the length and of what resolving it read, and as an edit + // read when that went through this document's edits. + if cached && let Some((known, edits, reads)) = LENGTHS.with(|m| m.borrow().get(&r).cloned()) { if edits { note_overlay_read(); } + note_read(r.num, Some(&reads)); return known; } + note_read(r.num, None); let edits_read = overlay_reads(); + let frame = ReadFrame::enter(); let length = self.try_get_reference(r).ok().and_then(|o| o.as_int()); if cached { - LENGTHS.with(|m| m.borrow_mut().insert(r, (length, overlay_reads() != edits_read))); + LENGTHS.with(|m| m.borrow_mut().insert(r, (length, overlay_reads() != edits_read, frame.reads()))); } length } @@ -847,7 +872,6 @@ impl Document { pub fn set(&mut self, r: ObjRef, obj: impl Into) { let obj = self.adopt(obj.into()); self.overlay.insert(r.num, Slot::Set(r.generation, Arc::new(obj))); - self.forget_member_failures(r.num); if r.num >= self.next_num { self.next_num = r.num.saturating_add(1); } @@ -907,16 +931,6 @@ impl Document { /// Delete an object (its number becomes free with the next generation). pub fn free(&mut self, r: ObjRef) { self.overlay.insert(r.num, Slot::Freed(r.generation.saturating_add(1))); - self.forget_member_failures(r.num); - } - - /// Forget cached failures of the members `stream` held in the file: they were failures of - /// the file's copy, and an edit of `stream` may read. Clones share the cache, so theirs are - /// searched again too, which costs time only. A poisoned lock leaves the cache as it is. - fn forget_member_failures(&self, stream: u32) { - if let Ok(mut cache) = self.cache.lock() { - cache.forget_failures(|n| matches!(self.entries.lookup(*n, &self.data), Some(XrefEntry::InStream { stream: s, .. }) if s == stream)); - } } /// Free what was added since the file was read and nothing refers to any more: the objects diff --git a/crates/cos/src/document/reader.rs b/crates/cos/src/document/reader.rs index 18f5ce848a..3e0b9684c1 100644 --- a/crates/cos/src/document/reader.rs +++ b/crates/cos/src/document/reader.rs @@ -320,10 +320,25 @@ impl Document { Ok(Some((section, stream.dict.clone(), true))) } + /// Read the cross-reference chain from `startxref` into one map of entries, newer sections + /// winning, with the trailer, the revisions oldest first and the set-aside entries. A chain + /// that lists more than [`MAX_XREF_OBJECTS`] rows in all is refused with + /// [`CosError::Syntax`], and the caller rebuilds the file from object headers (#5082). #[allow(clippy::type_complexity)] pub(super) fn read_xref_chain_materialized( &self, log: &mut Vec, + ) -> Result<(BTreeMap, Dict, Vec, Vec<(u64, u32)>, Vec), CosError> { + self.read_xref_chain_materialized_with_limit(log, MAX_XREF_OBJECTS) + } + + /// [`Self::read_xref_chain_materialized`] refusing a chain past `row_limit` rows in all + /// instead of [`MAX_XREF_OBJECTS`], so tests can reach the limit with a few rows. + #[allow(clippy::type_complexity)] + pub(super) fn read_xref_chain_materialized_with_limit( + &self, + log: &mut Vec, + row_limit: usize, ) -> Result<(BTreeMap, Dict, Vec, Vec<(u64, u32)>, Vec), CosError> { let data = &self.data; let tail_start = data.len().saturating_sub(4096); @@ -347,6 +362,9 @@ impl Document { let mut generation_repairs = Vec::new(); let mut recorded = HashSet::new(); let mut diagnose = true; + // Every row the sections (and their hybrid streams) list, counted by `read_section` as it + // reads them, before any are merged or deduplicated. + let mut merged_rows = 0usize; let mut next = Some(first); while let Some(off) = next.take() { if !visited.insert(off) || visited.len() > 10_000 { @@ -357,13 +375,15 @@ impl Document { let mut sect = BTreeMap::new(); // Object numbers whose entry in this section was set aside ([`Self::xref_oddities`]). let mut odd = Vec::new(); - let ((sect_trailer, is_stream), header_relative) = match self.read_section(off as usize, &mut sect, &mut odd, log) { + let rows_before = merged_rows; + let ((sect_trailer, is_stream), header_relative) = match self.read_section(off as usize, &mut sect, &mut odd, &mut merged_rows, log) { Ok(v) => (v, false), Err(e) if self.header_offset > 0 => { log.push(format!("xref at {off} unreadable ({e}); retrying relative to the header")); sect.clear(); odd.clear(); - (self.read_section(off as usize + self.header_offset, &mut sect, &mut odd, log)?, true) + merged_rows = rows_before; + (self.read_section(off as usize + self.header_offset, &mut sect, &mut odd, &mut merged_rows, log)?, true) } Err(e) => { // An unreadable older section loses every object only it locates (the page @@ -398,7 +418,7 @@ impl Document { } } else if let Some(x) = hybrid { let mut stm = BTreeMap::new(); - let read = match self.read_section(x as usize, &mut stm, &mut odd, log) { + let read = match self.read_section(x as usize, &mut stm, &mut odd, &mut merged_rows, log) { Ok(_) => { for (&num, &entry) in &stm { if !diagnose { @@ -429,6 +449,22 @@ impl Document { hybrids.insert(x, read); } } + // Each section's rows are capped, so the chain is too: at most `row_limit` rows + // (MAX_XREF_OBJECTS outside tests) read in all, or the file is rebuilt from object + // headers, as when one section's stream decodes past that. This counts every row + // read, before deduplication, not the map's size: a chain that relists the same + // objects, or a stream whose `/Index` repeats a range (`[0 4 0 4]`), costs as much to + // merge (#5082). Rows set aside are among the rows read, and a row is set aside at + // most twice (in its section and against a hybrid stream), so they stay bounded too. A + // section's rows (its hybrid stream's included) never outnumber the rows the compact + // reader counts for it, and that reader stops at about a quarter of MAX_XREF_OBJECTS: + // no chain within its limit is refused. + if merged_rows > row_limit { + return Err(CosError::Syntax { + offset: usize::try_from(off).unwrap_or(usize::MAX), + detail: format!("cross-reference chain lists more than {row_limit} entries"), + }); + } oddities.extend(odd.into_iter().map(|num| (off, num))); for (num, e) in sect { entries.entry(num).or_insert(e); @@ -447,12 +483,14 @@ impl Document { } /// Read one section at `off`, adding entries not already known (newer sections win). `odd` - /// collects the object numbers whose entry here is set aside ([`Self::xref_oddities`]). + /// collects the object numbers whose entry here is set aside ([`Self::xref_oddities`]), and + /// `counted` grows by every row read, repeated and set-aside rows included (#5082). fn read_section( &self, off: usize, entries: &mut BTreeMap, odd: &mut Vec, + counted: &mut usize, log: &mut Vec, ) -> Result<(Dict, bool), CosError> { #[cfg(test)] @@ -479,6 +517,7 @@ impl Document { } let start = shifted; for i in 0..count { + *counted = counted.saturating_add(1); lx.skip_ws(); let o = lx.token(); lx.skip_ws(); @@ -549,6 +588,7 @@ impl Document { } for i in 0..*count { let Some(r) = rows.next() else { break }; + *counted = counted.saturating_add(1); let t = field(r, 0, w[0], 1); let a = field(r, w[0], w[1], 0); let b = field(r, w[0] + w[1], w[2], 0); diff --git a/crates/cos/src/document/state.rs b/crates/cos/src/document/state.rs index c5bd85c065..7359ce715a 100644 --- a/crates/cos/src/document/state.rs +++ b/crates/cos/src/document/state.rs @@ -8,7 +8,10 @@ thread_local! { /// retries a parse several ways, and each retry would resolve the same lengths again /// (exponential in the chain of streams whose lengths point at further streams). Each is /// kept with whether resolving it read unsaved edits. - pub(super) static LENGTHS: std::cell::RefCell, bool)>> = std::cell::RefCell::new(HashMap::new()); + pub(super) static LENGTHS: std::cell::RefCell> = std::cell::RefCell::new(HashMap::new()); + /// What the loads running on this thread have read ([`ReadFrame`]): how many frames are + /// open, the object numbers read inside them, and how often that list was full. + static READS: std::cell::RefCell<(u32, Vec, u64)> = const { std::cell::RefCell::new((0, Vec::new(), 0)) }; /// Unsaved edits read on this thread (`Document::try_get`, and a `/Length` in `Document::load`). static OVERLAY_READS: std::cell::Cell = const { std::cell::Cell::new(0) }; /// How deep `Document::scan_for` is nested on this thread, and how many indirect `/Length` @@ -59,6 +62,94 @@ impl Drop for ScanLengths { } } +/// A resolved `/Length` in [`LENGTHS`]: its value, whether resolving it read unsaved edits, and +/// what it read. +pub(super) type Length = (Option, bool, Reads); + +/// The other objects a cached load read: an object stream for a member, an indirect `/Length`, +/// and what those read in turn. Clones share the caches, so a document whose edits include one of +/// them reads the object again instead (`Document::try_get`); `None` is a load that read more +/// than [`Reads::MAX`], which such a document always reads again. +#[derive(Clone, Debug, Default)] +pub(super) struct Reads(Option>); + +impl Reads { + /// Objects one entry keeps: a member's stream and a length or two, and what they read. + const MAX: usize = 64; + /// Object numbers the open frames keep between them. + const HELD: usize = 4096; + + /// Whether `overlay` (a document's edits) holds any of these objects. + pub(super) fn touch(&self, overlay: &BTreeMap) -> bool { + !overlay.is_empty() && self.0.as_ref().is_none_or(|nums| nums.iter().any(|n| overlay.contains_key(n))) + } + + fn bytes(&self) -> usize { + self.0.as_ref().map_or(0, |nums| nums.len().saturating_mul(std::mem::size_of::())) + } +} + +/// Counts `num` as read by the loads open on this thread, with what loading it read (`reads`). +pub(super) fn note_read(num: u32, reads: Option<&Reads>) { + READS.with(|r| { + let (depth, held, full) = &mut *r.borrow_mut(); + if *depth == 0 { + return; + } + let more = reads.map_or(Some(&[][..]), |r| r.0.as_deref()); + match more { + Some(more) if held.len().saturating_add(more.len()) < Reads::HELD => { + held.push(num); + held.extend_from_slice(more); + } + _ => *full = full.wrapping_add(1), + } + }); +} + +/// Collects what one load reads, for [`Reads`]; frames nest, and each read counts for every +/// frame open around it. +pub(super) struct ReadFrame { + start: usize, + full: u64, +} + +impl ReadFrame { + pub(super) fn enter() -> Self { + READS.with(|r| { + let (depth, held, full) = &mut *r.borrow_mut(); + *depth = depth.saturating_add(1); + ReadFrame { start: held.len(), full: *full } + }) + } + + /// What was read since the frame opened. + pub(super) fn reads(&self) -> Reads { + READS.with(|r| { + let (_, held, full) = &*r.borrow(); + if *full != self.full { + return Reads(None); + } + let mut nums = held.get(self.start..).unwrap_or_default().to_vec(); + nums.sort_unstable(); + nums.dedup(); + Reads((nums.len() <= Reads::MAX).then(|| Arc::from(nums))) + }) + } +} + +impl Drop for ReadFrame { + fn drop(&mut self) { + READS.with(|r| { + let (depth, held, _) = &mut *r.borrow_mut(); + *depth = depth.saturating_sub(1); + if *depth == 0 { + held.clear(); + } + }); + } +} + /// Unsaved edits read so far on this thread: a load during which this changes depended on one. pub(super) fn overlay_reads() -> u64 { OVERLAY_READS.with(std::cell::Cell::get) @@ -122,6 +213,8 @@ pub(super) struct ObjStm { /// or offset is unusable ([`objstm_member`]). pub(super) index: Vec>, pub(super) data: Arc>, + /// What parsing it read: the stream itself and its `/Length`, `/DecodeParms` and so on. + pub(super) reads: Reads, } /// Where member `num` of an object stream starts: its `offset` past `/First` (`first`), when the @@ -317,13 +410,14 @@ thread_local! { /// (#5873). #[derive(Default)] pub(crate) struct ObjectCache { - entries: HashMap, + entries: HashMap, owned: usize, } impl ObjectCache { - pub(crate) fn get(&self, num: &u32) -> Option<&Loaded> { - self.entries.get(num).map(|(object, _)| object) + /// What loading `num` gave, and what that load read. + pub(super) fn get(&self, num: &u32) -> Option<(&Loaded, &Reads)> { + self.entries.get(num).map(|(object, _, reads)| (object, reads)) } #[cfg(test)] @@ -331,9 +425,10 @@ impl ObjectCache { self.entries.contains_key(num) } - /// Keep what loading `num` gave, which holds `owned` bytes ([`loaded_bytes`]). - pub(crate) fn insert(&mut self, num: u32, object: Loaded, owned: usize) { - if let Some((_, before)) = self.entries.insert(num, (object, owned)) { + /// Keep what loading `num` gave, which holds `owned` bytes ([`loaded_bytes`]), and what it read. + pub(super) fn insert(&mut self, num: u32, object: Loaded, owned: usize, reads: Reads) { + let owned = owned.saturating_add(reads.bytes()); + if let Some((_, before, _)) = self.entries.insert(num, (object, owned, reads)) { self.owned = self.owned.saturating_sub(before); } self.owned = self.owned.saturating_add(owned); @@ -358,19 +453,14 @@ impl ObjectCache { self.owned = 0; } - /// Drop the cached failures `stale` picks. A failure holds no bytes, so the total stands. - pub(crate) fn forget_failures(&mut self, mut stale: impl FnMut(&u32) -> bool) { - self.entries.retain(|num, (loaded, _)| loaded.is_ok() || !stale(num)); - } - pub(crate) fn retain(&mut self, mut keep: impl FnMut(&u32) -> bool) { self.entries.retain(|num, _| keep(num)); - self.owned = self.entries.values().fold(0usize, |n, (_, owned)| n.saturating_add(*owned)); + self.owned = self.entries.values().fold(0usize, |n, (_, owned, _)| n.saturating_add(*owned)); } #[cfg(test)] pub(crate) fn values(&self) -> impl Iterator> { - self.entries.values().filter_map(|(object, _)| object.as_ref().ok()) + self.entries.values().filter_map(|(object, _, _)| object.as_ref().ok()) } } diff --git a/crates/cos/src/document/tests.rs b/crates/cos/src/document/tests.rs index 68215efc98..814d4ef8eb 100644 --- a/crates/cos/src/document/tests.rs +++ b/crates/cos/src/document/tests.rs @@ -883,6 +883,179 @@ fn an_edited_object_stream_leaves_other_clones_members_alone() { assert_eq!(text_of(&doc, 3, b"Hidden").as_deref(), Some("yes"), "the replacement reached another clone"); } +/// A member another clone read from the file's object stream is not what a clone that replaced +/// the stream reads, nor is one the document read itself before replacing it. +#[test] +fn a_replaced_object_stream_hides_the_members_read_before() { + let replacement = || { + let mut dict = Dict::new(); + dict.set(b"Type".to_vec(), Object::name("ObjStm")); + dict.set(b"N".to_vec(), Object::Int(1)); + dict.set(b"First".to_vec(), Object::Int(4)); + crate::Stream::from_raw(dict, b"3 0 << /Hidden (no) >>".to_vec()) + }; + let file = Arc::new(object_stream_file("3 0 ", "<< /Hidden (yes) >>", 0)); + let doc = Document::open(file.clone()).unwrap(); + assert_eq!(text_of(&doc, 3, b"Hidden").as_deref(), Some("yes")); + let mut replaced = doc.clone(); + replaced.set(ObjRef::new(4, 0), replacement()); + assert_eq!(text_of(&replaced, 3, b"Hidden").as_deref(), Some("no"), "another clone's read of the file's stream"); + assert_eq!(text_of(&doc, 3, b"Hidden").as_deref(), Some("yes"), "the replacement reached another clone"); + let mut doc = Document::open(file.clone()).unwrap(); + assert_eq!(text_of(&doc, 3, b"Hidden").as_deref(), Some("yes")); + doc.set(ObjRef::new(4, 0), replacement()); + assert_eq!(text_of(&doc, 3, b"Hidden").as_deref(), Some("no"), "the document's own read before the edit"); + let doc = Document::open(file).unwrap(); + assert_eq!(text_of(&doc, 3, b"Hidden").as_deref(), Some("yes")); + let mut freed = doc.clone(); + freed.free(ObjRef::new(4, 0)); + assert_eq!(freed.try_get(3), Err(CosError::MissingObject(4)), "another clone's read before the free"); +} + +/// An object stream another clone framed by the file's indirect `/Length` is not what a clone +/// that edited that length reads. +#[test] +fn an_edited_object_stream_length_hides_the_members_read_before() { + let file = Arc::new(object_stream_file_with("3 0 ", "<< /Hidden (cut endstream here) >>", 0, true)); + let doc = Document::open(file).unwrap(); + assert_eq!(text_of(&doc, 3, b"Hidden").as_deref(), Some("cut endstream here")); + let mut edited = doc.clone(); + edited.set(ObjRef::new(6, 0), Object::Int("3 0 << /Hidden (cut".len() as i64)); + assert_ne!(text_of(&edited, 3, b"Hidden").as_deref(), Some("cut endstream here"), "another clone's framing of the stream"); + assert_eq!(text_of(&doc, 3, b"Hidden").as_deref(), Some("cut endstream here"), "the edited length reached another clone"); +} + +/// A stream another clone read by the file's indirect `/Length` is not what a clone that edited +/// that length reads. +#[test] +fn an_edited_stream_length_hides_the_stream_read_before() { + let bodies = [ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [] /Count 0 >>", + "<< /Length 4 0 R >>\nstream\nshort\nendstream long\nendstream", + "20", + ]; + let mut out = b"%PDF-1.4\n".to_vec(); + let mut table = String::from("xref\n0 5\n0000000000 65535 f \n"); + for (n, body) in bodies.iter().enumerate() { + table.push_str(&format!("{:010} 00000 n \n", out.len())); + out.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", n + 1).as_bytes()); + } + let start = out.len(); + out.extend_from_slice(format!("{table}trailer\n<< /Size 5 /Root 1 0 R >>\nstartxref\n{start}\n%%EOF\n").as_bytes()); + let data = |doc: &Document| match &*doc.get(ObjRef::new(3, 0)) { + Object::Stream(s) => s.raw.to_vec(), + other => panic!("{other:?}"), + }; + let doc = Document::open(Arc::new(out)).unwrap(); + assert_eq!(data(&doc), b"short\nendstream long"); + let mut edited = doc.clone(); + edited.set(ObjRef::new(4, 0), Object::Int(5)); + assert_eq!(data(&edited), b"short", "another clone's read by the file's length"); + assert_eq!(data(&doc), b"short\nendstream long", "the edited length reached another clone"); +} + +/// A file whose object 3 sits in an object stream PNG-predicted by `/DecodeParms 6 1 R`, a +/// generation the file doesn't have, and the parameters that make it decode. +fn predicted_object_stream() -> (Vec, Dict) { + let members = b"3 0 << /Hidden (yes) >>"; + // PNG Sub, one row. + let mut rows = vec![1u8]; + let mut before = 0u8; + for b in members { + rows.push(b.wrapping_sub(before)); + before = *b; + } + let coded = pdfcraft_filters::encode_flate(&rows); + let mut out = b"%PDF-1.5\n".to_vec(); + let mut offs = [0usize; 7]; + let mut obj = |out: &mut Vec, n: usize, body: &[u8]| { + offs[n] = out.len(); + out.extend_from_slice(format!("{n} 0 obj\n").as_bytes()); + out.extend_from_slice(body); + out.extend_from_slice(b"\nendobj\n"); + }; + obj(&mut out, 1, b"<< /Type /Catalog /Pages 2 0 R >>"); + obj(&mut out, 2, b"<< /Type /Pages /Kids [] /Count 0 /Extra 3 0 R >>"); + let mut stm = format!("<< /Type /ObjStm /N 1 /First 4 /Filter /FlateDecode /DecodeParms 6 1 R /Length {} >>\nstream\n", coded.len()).into_bytes(); + stm.extend_from_slice(&coded); + stm.extend_from_slice(b"\nendstream"); + obj(&mut out, 4, &stm); + let data = [2u8, 0, 4, 0]; + let mut xs = format!("<< /Type /XRef /Size 7 /W [1 2 1] /Index [3 1] /Length {} >>\nstream\n", data.len()).into_bytes(); + xs.extend_from_slice(&data); + xs.extend_from_slice(b"\nendstream"); + obj(&mut out, 5, &xs); + obj(&mut out, 6, b"null"); + let table = out.len(); + out.extend_from_slice(b"xref\n0 7\n0000000000 65535 f \n"); + for (n, off) in offs.iter().enumerate().skip(1) { + let line = if n == 3 { "0000000000 65535 f \n".to_string() } else { format!("{off:010} 00000 n \n") }; + out.extend_from_slice(line.as_bytes()); + } + out.extend_from_slice(format!("trailer << /Size 7 /Root 1 0 R /XRefStm {} >>\nstartxref\n{table}\n%%EOF\n", offs[5]).as_bytes()); + let mut parms = Dict::new(); + parms.set(b"Predictor".to_vec(), Object::Int(11)); + parms.set(b"Columns".to_vec(), Object::Int(members.len() as i64)); + (out, parms) +} + +fn hidden(doc: &Document) -> Option> { + doc.get(ObjRef::new(3, 0)).as_dict().and_then(|d| d.get(b"Hidden")).and_then(Object::as_string).map(|s| s.bytes.clone()) +} + +/// An object stream whose `/DecodeParms` names a generation the file doesn't have decodes without +/// them; a clone that sets that generation decodes with them, not by the stream another clone read. +#[test] +fn parameters_an_edit_makes_match_hide_the_object_stream_read_before() { + let (file, parms) = predicted_object_stream(); + let doc = Document::open(Arc::new(file)).unwrap(); + assert_eq!(hidden(&doc), None, "no object 6 1: no predictor undone"); + let mut edited = doc.clone(); + edited.set(ObjRef::new(6, 1), Object::Dict(parms)); + assert_eq!(hidden(&edited), Some(b"yes".to_vec()), "another clone's stream, decoded without them"); +} + +/// The other way round: an object stream a clone decoded by its edited parameters stays that +/// clone's, so the document it was cloned from still decodes by the file's. +#[test] +fn an_object_stream_decoded_by_edited_parameters_is_not_shared() { + let (file, parms) = predicted_object_stream(); + let doc = Document::open(Arc::new(file)).unwrap(); + let mut edited = doc.clone(); + edited.set(ObjRef::new(6, 1), Object::Dict(parms)); + assert_eq!(hidden(&edited), Some(b"yes".to_vec())); + assert_eq!(hidden(&doc), None, "the clone's decoding, read from the shared streams"); +} + +/// What a load read is kept only up to a bound; past it the load counts as having read every +/// object, so a document with any edit reads it again. +#[test] +fn a_load_that_read_too_much_counts_as_reading_everything() { + use super::state::{ReadFrame, note_read}; + let mut overlay = BTreeMap::new(); + overlay.insert(999_999, Slot::Freed(1)); + let frame = ReadFrame::enter(); + for n in 0..8 { + note_read(n, None); + } + assert!(!frame.reads().touch(&overlay), "a few reads, none edited"); + assert!(!frame.reads().touch(&BTreeMap::new()), "nothing is edited"); + for n in 8..100 { + note_read(n, None); + } + assert!(frame.reads().touch(&overlay), "more objects than an entry keeps"); + drop(frame); + let outer = ReadFrame::enter(); + let inner = ReadFrame::enter(); + for n in 0..5_000 { + note_read(n % 4, None); + } + assert!(inner.reads().touch(&overlay), "more reads than the thread keeps"); + drop(inner); + assert!(outer.reads().touch(&overlay), "an outer frame missed them too"); +} + /// A member that failed to load from the file's object stream is kept as a failure, but replacing /// or freeing that stream forgets it: the member then reads from the edit. #[test] @@ -1500,7 +1673,7 @@ fn temporary_reader_keeps_file_views_and_evicts_data_of_its_own() { // cleared after the next read, including a failed one. let owned = Object::Stream(crate::Stream::from_raw(Dict::new(), vec![b'y'; ObjectReader::STREAM_BYTES + 1])); let size = held_bytes(&owned, &reader.document.data, 0); - reader.document.cache.lock().unwrap().insert(3, Ok(Arc::new(owned)), size); + reader.document.cache.lock().unwrap().insert(3, Ok(Arc::new(owned)), size, Default::default()); assert_eq!(*reader.get(ObjRef::new(99, 0)), Object::Null); assert!(reader.document.cache.lock().unwrap().is_empty(), "owned data over budget is evicted"); } @@ -2085,6 +2258,176 @@ fn a_decompression_bomb_in_an_xref_stream_is_refused_and_the_file_reconstructed( assert!(doc.repair_log().iter().any(|l| l.contains("limit")), "{:?}", doc.repair_log()); } +/// A chain of cross-reference streams whose sections each stay well under the per-section +/// limit but together list more than 8,388,607 rows (#5082). Too many rows for the compact +/// reader, the chain was merged whole; it is now refused and the file rebuilt from object +/// headers. The bulk rows are set aside (in use at offset 0), so the test holds a few bytes a row. +#[test] +fn a_cross_reference_chain_past_the_total_cap_is_refused_and_the_file_reconstructed() { + const BULK: usize = 4_200_000; + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for body in + ["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>"] + { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", offsets.len()).as_bytes()); + } + // Objects 4 and 5: /W [1 0 0] rows of type 1 at offset 0, each a set-aside entry. + let bulk = pdfcraft_filters::encode_flate(&vec![1u8; BULK]); + let size = 16 + 2 * BULK; + let mut prev = None; + for (num, start) in [(4, 16 + BULK), (5, 16)] { + offsets.push(bytes.len()); + let prev_key = prev.map(|p| format!(" /Prev {p}")).unwrap_or_default(); + bytes.extend_from_slice( + format!( + "{num} 0 obj\n<< /Type /XRef /Size {size} /W [1 0 0] /Index [{start} {BULK}]{prev_key} /Filter /FlateDecode /Length {} >>\nstream\n", + bulk.len() + ) + .as_bytes(), + ); + bytes.extend_from_slice(&bulk); + bytes.extend_from_slice(b"\nendstream\nendobj\n"); + prev = offsets.last().copied(); + } + // Object 6, the newest section: objects 0 to 6 at their offsets. + let x = bytes.len(); + offsets.push(x); + let mut rows = vec![0u8, 0, 0, 0, 0, 255]; + for o in &offsets { + rows.push(1); + rows.extend_from_slice(&u32::try_from(*o).unwrap().to_be_bytes()); + rows.push(0); + } + bytes.extend_from_slice( + format!("6 0 obj\n<< /Type /XRef /Size {size} /Root 1 0 R /W [1 4 1] /Index [0 7] /Prev {} /Length {} >>\nstream\n", offsets[4], rows.len()) + .as_bytes(), + ); + bytes.extend_from_slice(&rows); + bytes.extend_from_slice(format!("\nendstream\nendobj\nstartxref\n{x}\n%%EOF\n").as_bytes()); + + let doc = Document::open(Arc::new(bytes)).unwrap(); + let log = doc.repair_log(); + assert!( + log.iter().any(|l| l.contains("cross-reference chain lists more than 8388607 entries") && l.contains("rebuilding from object headers")), + "{log:?}" + ); + assert!(log.iter().any(|l| l.starts_with("rebuilt cross-reference index")), "{log:?}"); + assert!(doc.revisions().is_empty()); + assert_eq!(doc.root(), Some(ObjRef::new(1, 0))); + let page = doc.try_get(3).unwrap(); + assert_eq!(page.as_dict().and_then(|d| d.name(b"Type")), Some(&b"Page"[..])); + // The chain read itself is refused at the second bulk section, which each decode within its own limit. + let Err(CosError::Syntax { offset, detail }) = doc.read_xref_chain_materialized(&mut Vec::new()) else { panic!("the chain must be refused") }; + assert_eq!(offset, offsets[3]); + assert!(detail.contains("more than 8388607 entries"), "{detail}"); +} + +/// Three classic tables listing 2, 1 and 1 rows, newest first: objects 1 and 2, then object 3 +/// (or object 1 again when `overlap`), then object 4. +fn three_table_chain(overlap: bool) -> (Vec, usize) { + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (num, body) in + ["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", "<< /Value 3 >>", "<< /Value 4 >>"].iter().enumerate() + { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", num + 1).as_bytes()); + } + let older = bytes.len(); + bytes.extend_from_slice(format!("xref\n4 1\n{:010} 00000 n \ntrailer << /Size 5 /Root 1 0 R >>\n", offsets[3]).as_bytes()); + let middle = bytes.len(); + let middle_num = if overlap { 1 } else { 3 }; + bytes.extend_from_slice( + format!("xref\n{middle_num} 1\n{:010} 00000 n \ntrailer << /Size 5 /Root 1 0 R /Prev {older} >>\n", offsets[middle_num - 1]).as_bytes(), + ); + let newer = bytes.len(); + bytes.extend_from_slice( + format!( + "xref\n1 1\n{:010} 00000 n \n2 1\n{:010} 00000 n \ntrailer << /Size 5 /Root 1 0 R /Prev {middle} >>\nstartxref\n{newer}\n%%EOF\n", + offsets[0], offsets[1], + ) + .as_bytes(), + ); + (bytes, older) +} + +/// The chain's row limit counts every row merged (#5082): four rows over three sections pass a +/// limit of 4 and are refused at the oldest section by a limit of 3, both when the sections +/// list different objects and when one relists an object a newer section has (a map of three +/// entries, but four rows merged). +#[test] +fn the_chain_row_limit_counts_disjoint_and_relisted_rows() { + for overlap in [false, true] { + let (bytes, older) = three_table_chain(overlap); + let doc = Document::open(Arc::new(bytes)).unwrap(); + let (entries, ..) = doc.read_xref_chain_materialized_with_limit(&mut Vec::new(), 4).unwrap(); + assert_eq!(entries.len(), if overlap { 3 } else { 4 }); + let Err(CosError::Syntax { offset, detail }) = doc.read_xref_chain_materialized_with_limit(&mut Vec::new(), 3) else { + panic!("four rows must be refused by a limit of 3 (overlap: {overlap})") + }; + assert_eq!(offset, older); + assert!(detail.contains("more than 3 entries"), "{detail}"); + } +} + +/// Rows set aside count against the chain's row limit (#5082): a hybrid file whose table puts +/// object 3 at an offset while its /XRefStm puts it in an object stream merges six rows and sets +/// one aside, so a limit of 7 passes and a limit of 6 refuses it. +#[test] +fn the_chain_row_limit_counts_hybrid_rows_set_aside() { + let mut bytes = object_stream_file("3 0 ", "<< /Hidden (yes) >>", 0); + // Object 3's row in the table (`xref\n0 6\n`, 20-byte rows): free, now in use at offset 9. + let row = find(&bytes, b"xref\n0 6\n", 0, bytes.len()).unwrap() + 9 + 3 * 20; + assert_eq!(&bytes[row..row + 20], b"0000000000 65535 f \n"); + bytes[row..row + 20].copy_from_slice(b"0000000009 00000 n \n"); + let doc = Document::open(Arc::new(bytes)).unwrap(); + let (entries, _, _, oddities, _) = doc.read_xref_chain_materialized_with_limit(&mut Vec::new(), 7).unwrap(); + assert_eq!(entries.len(), 6); + assert_eq!(oddities.iter().map(|(_, num)| *num).collect::>(), vec![3]); + let Err(CosError::Syntax { detail, .. }) = doc.read_xref_chain_materialized_with_limit(&mut Vec::new(), 6) else { + panic!("six rows and one set aside must be refused by a limit of 6") + }; + assert!(detail.contains("more than 6 entries"), "{detail}"); +} + +/// A cross-reference stream whose `/Index [0 4 0 4]` lists objects 0 to 3 twice, the same rows +/// both times: eight rows read, four entries (#5082). The chain's row limit counts the rows +/// read, before deduplication, so a limit of 8 passes and a limit of 7 refuses it. +#[test] +fn the_chain_row_limit_counts_rows_an_xref_stream_index_repeats() { + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for body in ["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>"] { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", offsets.len()).as_bytes()); + } + let x = bytes.len(); + offsets.push(x); + let mut once = vec![0u8, 0, 0, 0, 0, 255]; + for o in &offsets { + once.push(1); + once.extend_from_slice(&u32::try_from(*o).unwrap().to_be_bytes()); + once.push(0); + } + let rows = [once.clone(), once].concat(); + bytes.extend_from_slice( + format!("3 0 obj\n<< /Type /XRef /Size 4 /Root 1 0 R /W [1 4 1] /Index [0 4 0 4] /Length {} >>\nstream\n", rows.len()).as_bytes(), + ); + bytes.extend_from_slice(&rows); + bytes.extend_from_slice(format!("\nendstream\nendobj\nstartxref\n{x}\n%%EOF\n").as_bytes()); + let doc = Document::open(Arc::new(bytes)).unwrap(); + let (entries, _, _, oddities, _) = doc.read_xref_chain_materialized_with_limit(&mut Vec::new(), 8).unwrap(); + assert_eq!(entries.len(), 4); + assert!(oddities.is_empty(), "{oddities:?}"); + let Err(CosError::Syntax { offset, detail }) = doc.read_xref_chain_materialized_with_limit(&mut Vec::new(), 7) else { + panic!("eight rows listing four objects must be refused by a limit of 7") + }; + assert_eq!(offset, x); + assert!(detail.contains("more than 7 entries"), "{detail}"); +} + #[test] fn a_stream_whose_length_is_itself_and_never_ends_loads_quickly() { // From the nightly fuzz job: `6 0 obj << /Length 6 0 R >> stream` with no `endstream` @@ -2238,10 +2581,10 @@ fn the_object_cache_keeps_its_total_as_objects_come_and_go() { let two = Arc::new(Object::Array(vec![Object::Int(1); 10])); let (a, b) = (held_bytes(&one, &data, 0), held_bytes(&two, &data, 0)); let mut cache = super::state::ObjectCache::default(); - cache.insert(1, Ok(one.clone()), a); - cache.insert(2, Ok(two), b); + cache.insert(1, Ok(one.clone()), a, Default::default()); + cache.insert(2, Ok(two), b, Default::default()); assert_eq!(cache.owned(), a + b); - cache.insert(2, Ok(one), a); + cache.insert(2, Ok(one), a, Default::default()); assert_eq!(cache.owned(), a + a, "a replaced object no longer counts"); cache.retain(|n| *n == 1); assert_eq!((cache.len(), cache.owned()), (1, a)); diff --git a/crates/cos/src/object.rs b/crates/cos/src/object.rs index f2c9760c12..5d121dc41e 100644 --- a/crates/cos/src/object.rs +++ b/crates/cos/src/object.rs @@ -746,6 +746,13 @@ mod tests { } } + #[test] + fn strict_decoding_is_bounded_by_the_callers_remaining_allowance() { + let stream = Stream::flate(Dict::new(), &vec![0u8; 1 << 20]); + assert!(matches!(stream.decoded_strict_within(1000), Err(CosError::Filter(_)))); + assert_eq!(stream.decoded_strict_within(1 << 20).map(|v| v.len()).ok(), Some(1 << 20)); + } + #[test] fn empty_filter_arrays_refuse_decoding_past_a_caller_limit() { let mut dict = Dict::new(); diff --git a/crates/create/Cargo.toml b/crates/create/Cargo.toml index 51eca668d0..79c7a4f276 100644 --- a/crates/create/Cargo.toml +++ b/crates/create/Cargo.toml @@ -24,6 +24,11 @@ thiserror = { workspace = true } # Already in the graph through lopdf; used only to read plain-text files (decode_text). encoding_rs = "0.8" +[target.'cfg(unix)'.dependencies] +# Already in the graph (pdfcraft-platform, ui-egui); stops a timed-out LibreOffice's whole process +# group, soffice.bin included, without unsafe code. +rustix = { version = "1", features = ["process"] } + [dev-dependencies] # An independent CCITT decoder, to check Create PDF's Group 4 images. hayro-ccitt.workspace = true diff --git a/crates/create/README.md b/crates/create/README.md index 95d47d27a9..4c9d1a8823 100644 --- a/crates/create/README.md +++ b/crates/create/README.md @@ -28,6 +28,30 @@ Layer L4. Create a PDF (Acrobat's Create a PDF tool), execution plan M10.2: Every function returns a `pdfcraft_cos::Document`; the caller writes it. +Office documents (`office.rs`) are the exception: PdfCraft doesn't read Word, Excel, PowerPoint, +OpenDocument or RTF itself. `is_office(name, bytes)` recognises them (an `OFFICE_EXTENSIONS` +name and an OLE, ZIP or RTF signature; `source_kind` reports `SourceKind::Office`), and +`office_to_pdf(name, bytes)` returns PDF bytes made by an installed LibreOffice, run headless as +a separate process: a private temporary folder (owner-only on Unix; the document is always +`document.` there, never its own name), a fresh profile with macros disabled, untrusted +links blocked and Writer and Calc set never to update linked content (sections, files, external +data) on load, no console window on Windows, and a stop after `TIMEOUT` (120 s) or as soon as the +output grows past 512 MB (the whole process tree is stopped, not only `soffice`). LibreOffice is +found through `PDFCRAFT_SOFFICE`, the `PATH`, then the usual install folders (`libreoffice()`). +A snap-installed LibreOffice is not among those folders: its private `/tmp` cannot see the work +folder. Where `/snap/bin` is on the `PATH` it can still be found there, so point +`PDFCRAFT_SOFFICE` at a non-snap install if conversions fail. It takes seconds, so callers keep it +off the frame thread. On the web it returns an error. + +Impress has no setting for linked content, and the profile settings are LibreOffice's own: +PdfCraft doesn't sandbox the process's network access. Converting test documents whose linked +sections, pictures and external cell references point at a local listener made no connection, +with or without the Writer and Calc settings (LibreOffice 26.8.1, Windows, 2026-10-26), so the settings are a +second line of defence rather than a measured fix. + +`IMAGE_EXTENSIONS`, `TEXT_EXTENSIONS` and `OFFICE_EXTENSIONS` are what the app's file pickers +offer, so detection and pickers agree. + Image export and Save Image As apply each lossless image's Decode pair before converting its color components. JBIG2 images and masks are decoded through `pdfcraft_cos::ImageBudget` (`pdfcraft_filters::decode_jbig2_within`), behind other filters too, with the `/JBIG2Globals` of their own `/DecodeParms` entry, as `JBIG2Decode` gives them: black as 0. An image diff --git a/crates/create/src/lib.rs b/crates/create/src/lib.rs index 389173d848..d6c2208a83 100644 --- a/crates/create/src/lib.rs +++ b/crates/create/src/lib.rs @@ -1,4 +1,4 @@ -//! pdfcraft-create — create PDFs from nothing, images or text (L4). See the README. +//! pdfcraft-create — create PDFs from nothing, images, text or (through LibreOffice) Office documents (L4). See the README. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] @@ -8,6 +8,10 @@ mod extract; mod text_font; mod upright_image; pub use extract::{ExtractedImage, ImageExport, extract_images, image_file, image_file_within}; +mod office; +#[cfg(not(target_arch = "wasm32"))] +pub use office::libreoffice; +pub use office::{OFFICE_EXTENSIONS, is_office, office_to_pdf}; use pdfcraft_fonts::wrap_with; use text_font::TextFont; @@ -17,6 +21,9 @@ pub enum CreateError { Image(String, String), #[error("{0}")] Invalid(String), + /// An Office document LibreOffice couldn't convert, or LibreOffice isn't installed. + #[error("{0}")] + Office(String), } /// US Letter in points. @@ -757,9 +764,18 @@ pub enum SourceKind { Pdf, Image, Text, + /// A Word, Excel, PowerPoint or OpenDocument file, converted through LibreOffice. + Office, } -/// File extensions Create converts to PDF (images and plain text). +/// File extensions of the images Create converts to PDF. +pub const IMAGE_EXTENSIONS: [&str; 11] = ["png", "jpg", "jpeg", "tif", "tiff", "gif", "bmp", "jp2", "j2k", "jpx", "pcx"]; + +/// File extensions of the plain-text files Create converts to PDF. +pub const TEXT_EXTENSIONS: [&str; 2] = ["txt", "text"]; + +/// File extensions Create converts to PDF itself: [`IMAGE_EXTENSIONS`] and [`TEXT_EXTENSIONS`] +/// (Office documents go through LibreOffice, [`OFFICE_EXTENSIONS`]). pub const CONVERTIBLE: [&str; 13] = ["png", "jpg", "jpeg", "tif", "tiff", "gif", "bmp", "jp2", "j2k", "jpx", "pcx", "txt", "text"]; /// A PCX file: its manufacturer byte, a version (1 was never used), run-length coding and 1 or 8 @@ -792,8 +808,8 @@ fn is_image(bytes: &[u8]) -> bool { && bytes.get(14..18).and_then(|h| <[u8; 4]>::try_from(h).ok()).is_some_and(|h| matches!(u32::from_le_bytes(h), 12 | 40 | 52 | 56 | 108 | 124))) } -/// Whether `bytes` named `name` is a PDF, an image Create can embed, or plain text (a `.txt` or -/// `.text` file). `None` for anything else. +/// Whether `bytes` named `name` is a PDF, an image Create can embed, plain text (a `.txt` or +/// `.text` file) or an Office document ([`is_office`]). `None` for anything else. pub fn source_kind(name: &str, bytes: &[u8]) -> Option { let head = bytes.get(..bytes.len().min(1024)).unwrap_or_default(); if head.windows(5).any(|w| w == b"%PDF-") { @@ -802,6 +818,9 @@ pub fn source_kind(name: &str, bytes: &[u8]) -> Option { if is_image(bytes) { return Some(SourceKind::Image); } + if is_office(name, bytes) { + return Some(SourceKind::Office); + } let lower = name.to_ascii_lowercase(); (lower.ends_with(".txt") || lower.ends_with(".text")).then_some(SourceKind::Text) } diff --git a/crates/create/src/office.rs b/crates/create/src/office.rs new file mode 100644 index 0000000000..3d8332c149 --- /dev/null +++ b/crates/create/src/office.rs @@ -0,0 +1,599 @@ +//! Office documents (Word, Excel, PowerPoint, OpenDocument, RTF) to PDF through an installed +//! LibreOffice, run as a separate process: the "LibreOffice sidecar" of +//! `docs/file-format-parity.md`. PdfCraft never parses these formats itself. +//! +//! Detection ([`is_office`]) is pure and works everywhere. Conversion ([`office_to_pdf`]) runs +//! only on desktop builds: each run gets its own temporary folder and a fresh LibreOffice +//! profile with macros and untrusted links turned off, and is stopped after [`TIMEOUT`]. + +use crate::CreateError; + +/// File extensions of the Office documents converted through LibreOffice. +pub const OFFICE_EXTENSIONS: &[&str] = &[ + // Word and Writer + "doc", "docx", "docm", "dot", "dotx", "dotm", "odt", "ott", "rtf", // + // Excel and Calc + "xls", "xlsx", "xlsm", "xlt", "xltx", "xltm", "ods", "ots", // + // PowerPoint and Impress + "ppt", "pptx", "pptm", "pot", "potx", "potm", "pps", "ppsx", "ppsm", "odp", "otp", +]; + +/// The OLE compound-file signature of Office 97–2003 files (`.doc`, `.xls`, `.ppt`). +const OLE: &[u8] = &[0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1]; + +/// The longest a conversion may run before LibreOffice is stopped. A fresh profile costs a +/// few seconds on its own; large spreadsheets take longer. +#[cfg_attr(target_arch = "wasm32", allow(dead_code))] +pub const TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120); + +/// The largest PDF read back from LibreOffice. +#[cfg_attr(target_arch = "wasm32", allow(dead_code))] +const MAX_PDF: u64 = 512 * 1024 * 1024; + +/// `name`'s extension as it appears in [`OFFICE_EXTENSIONS`], if it is one. +fn office_extension(name: &str) -> Option<&'static str> { + let (_, ext) = name.rsplit_once('.')?; + OFFICE_EXTENSIONS.iter().copied().find(|e| ext.eq_ignore_ascii_case(e)) +} + +/// Whether `bytes` named `name` is an Office document: an Office extension, and the bytes start +/// like one (an OLE compound file, a ZIP package as OOXML and OpenDocument are, or RTF). +pub fn is_office(name: &str, bytes: &[u8]) -> bool { + office_extension(name).is_some() && (bytes.starts_with(OLE) || bytes.starts_with(b"PK\x03\x04") || bytes.starts_with(b"{\\rtf")) +} + +/// Convert the Office document `bytes` named `name` to PDF bytes with LibreOffice. +#[cfg(target_arch = "wasm32")] +pub fn office_to_pdf(name: &str, _bytes: &[u8]) -> Result, CreateError> { + Err(CreateError::Office(format!("{name}: Office documents open only in the desktop app, which converts them with LibreOffice"))) +} + +/// Convert the Office document `bytes` named `name` to PDF bytes with LibreOffice. +#[cfg(not(target_arch = "wasm32"))] +pub fn office_to_pdf(name: &str, bytes: &[u8]) -> Result, CreateError> { + let program = native::libreoffice().ok_or_else(|| { + CreateError::Office(format!( + "{name}: opening Word, Excel and PowerPoint files needs LibreOffice, which isn't installed (get it free from libreoffice.org)" + )) + })?; + native::convert_with(&program, name, bytes, TIMEOUT) +} + +/// Where LibreOffice is installed, if it is: `PDFCRAFT_SOFFICE`, then the `PATH`, then the +/// usual install folders. +#[cfg(not(target_arch = "wasm32"))] +pub fn libreoffice() -> Option { + native::libreoffice() +} + +#[cfg(not(target_arch = "wasm32"))] +mod native { + use super::{CreateError, MAX_PDF, office_extension}; + use std::ffi::OsString; + use std::path::{Path, PathBuf}; + use std::process::{Child, Command, ExitStatus, Stdio}; + use std::sync::atomic::{AtomicU64, Ordering}; + use std::time::{Duration, Instant}; + + /// The program names LibreOffice installs on the `PATH`. + #[cfg(windows)] + const PATH_NAMES: &[&str] = &["soffice.exe"]; + #[cfg(not(windows))] + const PATH_NAMES: &[&str] = &["soffice", "libreoffice"]; + + pub(super) fn libreoffice() -> Option { + let defaults = default_locations(&|key| std::env::var_os(key)); + candidates(std::env::var_os("PDFCRAFT_SOFFICE"), std::env::var_os("PATH"), defaults).into_iter().find(|p| p.is_file()) + } + + /// Where to look, in order: the override, each `PATH` folder, then `defaults`. + pub(super) fn candidates(override_path: Option, path: Option, defaults: Vec) -> Vec { + let mut out: Vec = override_path.filter(|p| !p.is_empty()).map(PathBuf::from).into_iter().collect(); + if let Some(path) = path { + for dir in std::env::split_paths(&path) { + out.extend(PATH_NAMES.iter().map(|name| dir.join(name))); + } + } + out.extend(defaults); + out + } + + /// The usual install locations, given a way to read environment variables. + #[cfg(windows)] + pub(super) fn default_locations(env: &dyn Fn(&str) -> Option) -> Vec { + ["ProgramFiles", "ProgramW6432", "ProgramFiles(x86)"] + .iter() + .filter_map(|key| env(key)) + .map(|dir| PathBuf::from(dir).join("LibreOffice").join("program").join("soffice.exe")) + .collect() + } + + /// The usual install locations, given a way to read environment variables. + #[cfg(target_os = "macos")] + pub(super) fn default_locations(env: &dyn Fn(&str) -> Option) -> Vec { + let app = Path::new("LibreOffice.app/Contents/MacOS/soffice"); + let mut out = vec![Path::new("/Applications").join(app)]; + out.extend(env("HOME").map(|home| PathBuf::from(home).join("Applications").join(app))); + out + } + + /// The usual install locations, given a way to read environment variables. + #[cfg(not(any(windows, target_os = "macos")))] + pub(super) fn default_locations(_env: &dyn Fn(&str) -> Option) -> Vec { + [ + "/usr/bin/soffice", + "/usr/bin/libreoffice", + "/usr/local/bin/soffice", + "/usr/local/bin/libreoffice", + "/usr/lib/libreoffice/program/soffice", + "/opt/libreoffice/program/soffice", + ] + .iter() + .map(PathBuf::from) + .collect() + } + + /// The fresh profile's settings: no macros at all, no links to untrusted locations, and + /// linked content in Writer and Calc documents (sections, files, external data) never + /// updated on load, so a document can't make LibreOffice fetch from the network or a share. + /// The two `Link` settings number "Never" differently (Writer.xcs: 0 Always, 1 On request, + /// 2 Never; Calc.xcs: 0 Always, 1 Never, 2 On request). Impress has no such setting. + pub(super) const PROFILE_SETTINGS: &str = r#" + +true +3 +true +2 +1 + +"#; + + /// A private temporary folder, removed (with everything in it) when dropped. + pub(super) struct WorkDir(pub(super) PathBuf); + + impl WorkDir { + pub(super) fn new() -> std::io::Result { + static NEXT: AtomicU64 = AtomicU64::new(0); + let n = NEXT.fetch_add(1, Ordering::Relaxed); + let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.subsec_nanos()); + let dir = std::env::temp_dir().join(format!("pdfcraft-office-{}-{n}-{nanos}", std::process::id())); + // Not recursive: a folder that already exists is not ours. Owner-only on Unix, where the + // temporary folder is shared: the document and its PDF are not for other users. + #[cfg_attr(not(unix), allow(unused_mut))] + let mut builder = std::fs::DirBuilder::new(); + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + builder.mode(0o700); + } + builder.create(&dir)?; + Ok(Self(dir)) + } + } + + impl Drop for WorkDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + /// `path` as a `file://` URL, which `-env:UserInstallation` takes. + pub(super) fn file_url(path: &Path) -> String { + let path = path.to_string_lossy().replace('\\', "/"); + let mut url = String::from("file://"); + // A Windows path (`C:/…`) needs the third slash a Unix path already starts with. + if !path.starts_with('/') { + url.push('/'); + } + for b in path.bytes() { + if b.is_ascii_alphanumeric() || b"-._~/:".contains(&b) { + url.push(char::from(b)); + } else { + url.push_str(&format!("%{b:02X}")); + } + } + url + } + + pub(super) enum Run { + Exited(ExitStatus), + TimedOut, + /// The watched folder grew past its limit while the command ran. + TooLarge, + } + + /// The bytes in the files directly inside `dir` (LibreOffice writes its one output there). + /// Anything unreadable counts as nothing, and the sum saturates. Each size is read from the + /// file itself: on Windows a directory listing keeps the old size while a process still + /// has the file open for writing. + pub(super) fn folder_size(dir: &Path) -> u64 { + let Ok(entries) = std::fs::read_dir(dir) else { return 0 }; + entries + .flatten() + .filter_map(|entry| std::fs::symlink_metadata(entry.path()).ok()) + .filter(|meta| meta.is_file()) + .fold(0u64, |sum, meta| sum.saturating_add(meta.len())) + } + + /// Run `command` to completion, killing it and every process it started once `timeout` + /// passes, or once the files in `watch`'s folder add up to more than its limit (so a + /// document that converts to an enormous PDF can't fill the disk before it is checked). + /// `soffice` hands the conversion to its own child, `soffice.bin`, which would + /// otherwise outlive the kill, holding the profile folder and its memory. + pub(super) fn run_limited(mut command: Command, timeout: Duration, watch: Option<(&Path, u64)>) -> std::io::Result { + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + // Its own process group, so the whole group can be stopped together. + command.process_group(0); + } + let mut child = command.spawn()?; + let start = Instant::now(); + loop { + match child.try_wait() { + Ok(Some(status)) => return Ok(Run::Exited(status)), + Ok(None) => {} + Err(error) => { + kill_tree(&mut child); + return Err(error); + } + } + if watch.is_some_and(|(dir, limit)| folder_size(dir) > limit) { + kill_tree(&mut child); + return Ok(Run::TooLarge); + } + if start.elapsed() >= timeout { + kill_tree(&mut child); + return Ok(Run::TimedOut); + } + std::thread::sleep(Duration::from_millis(50)); + } + } + + /// Kill `child` and every process it started, then reap it. + fn kill_tree(child: &mut Child) { + #[cfg(unix)] + { + use rustix::process::{Pid, Signal, kill_process_group}; + // The group `process_group(0)` made has the child's id. + if let Some(group) = i32::try_from(child.id()).ok().and_then(Pid::from_raw) { + let _ = kill_process_group(group, Signal::KILL); + } + } + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + // `/T` walks the tree from the child's id, so it runs while the child is still alive. + // Windows' own copy, not whatever `taskkill` the PATH finds first. + let taskkill = std::env::var_os("SystemRoot") + .map_or_else(|| PathBuf::from("taskkill.exe"), |root| PathBuf::from(root).join("System32").join("taskkill.exe")); + let _ = Command::new(taskkill) + .args(["/T", "/F", "/PID", &child.id().to_string()]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + // CREATE_NO_WINDOW, as for LibreOffice itself. + .creation_flags(0x0800_0000) + .status(); + } + // The child itself, if the tree kill couldn't reach it. + let _ = child.kill(); + let _ = child.wait(); + } + + /// Convert with the LibreOffice at `program`, giving up after `timeout`. + pub(super) fn convert_with(program: &Path, name: &str, bytes: &[u8], timeout: Duration) -> Result, CreateError> { + convert_capped(program, name, bytes, timeout, MAX_PDF) + } + + /// [`convert_with`] with the converted PDF's size limit given: the output folder is watched + /// while LibreOffice runs, and the finished file is checked again. + pub(super) fn convert_capped(program: &Path, name: &str, bytes: &[u8], timeout: Duration, max_pdf: u64) -> Result, CreateError> { + let too_large = || format!("the converted PDF is larger than {} MB", max_pdf / (1024 * 1024)); + let failed = |why: String| CreateError::Office(format!("{name}: {why}")); + let ext = office_extension(name).ok_or_else(|| failed("this isn't a Word, Excel or PowerPoint file".into()))?; + let work = WorkDir::new().map_err(|e| failed(format!("couldn't make a temporary folder: {e}")))?; + // A fixed input name: the document's own name never reaches LibreOffice's command line. + let input = work.0.join(format!("document.{ext}")); + let profile = work.0.join("profile"); + let out = work.0.join("out"); + let prepared = std::fs::write(&input, bytes) + .and_then(|()| std::fs::create_dir_all(profile.join("user"))) + .and_then(|()| std::fs::write(profile.join("user").join("registrymodifications.xcu"), PROFILE_SETTINGS)) + .and_then(|()| std::fs::create_dir(&out)); + prepared.map_err(|e| failed(format!("couldn't prepare the conversion: {e}")))?; + + let mut command = Command::new(program); + command + .arg(format!("-env:UserInstallation={}", file_url(&profile))) + .args(["--headless", "--invisible", "--nologo", "--norestore", "--nodefault", "--nolockcheck", "--convert-to", "pdf", "--outdir"]) + .arg(&out) + .arg(&input) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + // CREATE_NO_WINDOW: no console window flashes up during the conversion. + command.creation_flags(0x0800_0000); + } + let status = match run_limited(command, timeout, Some((&out, max_pdf))) { + Ok(Run::Exited(status)) => status, + Ok(Run::TooLarge) => return Err(failed(format!("{}; LibreOffice was stopped", too_large()))), + Ok(Run::TimedOut) => return Err(failed(format!("LibreOffice took longer than {} seconds and was stopped", timeout.as_secs()))), + Err(e) => return Err(failed(format!("couldn't start LibreOffice ({}): {e}", program.display()))), + }; + let pdf = out.join("document.pdf"); + let size = match std::fs::metadata(&pdf) { + Ok(meta) => meta.len(), + Err(_) if !status.success() => return Err(failed(format!("LibreOffice couldn't convert it ({status})"))), + Err(_) => return Err(failed("LibreOffice didn't produce a PDF; the file may be damaged or password-protected".into())), + }; + if size > max_pdf { + return Err(failed(too_large())); + } + let bytes = std::fs::read(&pdf).map_err(|e| failed(format!("couldn't read the converted PDF: {e}")))?; + if !bytes.starts_with(b"%PDF-") { + return Err(failed("LibreOffice's output isn't a PDF".into())); + } + Ok(bytes) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const DOCX_HEAD: &[u8] = b"PK\x03\x04\x14\x00\x06\x00"; + + #[test] + fn office_files_are_recognised_by_extension_and_signature() { + assert!(is_office("Report.docx", DOCX_HEAD)); + assert!(is_office("SHEET.XLSX", DOCX_HEAD)); + assert!(is_office("deck.odp", DOCX_HEAD)); + assert!(is_office("old.doc", OLE)); + assert!(is_office("old.ppt", &[OLE, b"rest"].concat())); + assert!(is_office("letter.rtf", b"{\\rtf1\\ansi")); + // Word saves RTF under .doc too. + assert!(is_office("letter.doc", b"{\\rtf1\\ansi")); + } + + #[test] + fn other_files_are_not_office_documents() { + // The right signature with another extension, or an Office name with other bytes. + assert!(!is_office("archive.zip", DOCX_HEAD)); + assert!(!is_office("Report.docx", b"%PDF-1.7")); + assert!(!is_office("Report.docx", b"plain text")); + assert!(!is_office("Report.docx", b"")); + assert!(!is_office("docx", DOCX_HEAD)); + assert!(!is_office("", DOCX_HEAD)); + // A multi-byte name never splits a character. + assert!(!is_office("résumé.dócx", DOCX_HEAD)); + assert!(is_office("résumé.docx", DOCX_HEAD)); + } + + #[cfg(not(target_arch = "wasm32"))] + mod native { + use super::super::native::*; + use super::DOCX_HEAD; + use std::ffi::OsString; + use std::path::{Path, PathBuf}; + use std::time::Duration; + + #[test] + fn file_urls_escape_spaces_and_keep_drive_letters() { + assert_eq!(file_url(Path::new("/tmp/a b/profile")), "file:///tmp/a%20b/profile"); + assert_eq!(file_url(Path::new(r"C:\Users\Jo Ann\profile")), "file:///C:/Users/Jo%20Ann/profile"); + assert_eq!(file_url(Path::new("/tmp/é")), "file:///tmp/%C3%A9"); + } + + #[test] + fn the_override_comes_first_then_the_path_then_the_defaults() { + let path = std::env::join_paths([Path::new("first"), Path::new("second")]).unwrap(); + let found = candidates(Some(OsString::from("custom/soffice")), Some(path), vec![PathBuf::from("default/soffice")]); + assert_eq!(found.first(), Some(&PathBuf::from("custom/soffice"))); + assert!(found.get(1).is_some_and(|p| p.starts_with("first"))); + assert_eq!(found.last(), Some(&PathBuf::from("default/soffice"))); + // An empty override is no override. + assert_eq!(candidates(Some(OsString::new()), None, Vec::new()), Vec::::new()); + } + + #[test] + fn default_locations_read_only_the_given_environment() { + let found = default_locations(&|_| None); + assert!(found.iter().all(|p| p.is_absolute()), "{found:?}"); + } + + /// Each setting the fresh profile makes, as (path, property, value). + fn profile_settings() -> Vec<(&'static str, &'static str, &'static str)> { + let between = |s: &'static str, start: &str, end: &str| s.split_once(start).and_then(|(_, rest)| rest.split_once(end)).map(|(v, _)| v); + PROFILE_SETTINGS + .split("", "")?)) + }) + .collect() + } + + #[test] + fn the_profile_turns_off_macros_and_never_updates_links() { + let settings = profile_settings(); + let scripting = "/org.openoffice.Office.Common/Security/Scripting"; + assert!(settings.contains(&(scripting, "DisableMacrosExecution", "true")), "{settings:?}"); + assert!(settings.contains(&(scripting, "MacroSecurityLevel", "3")), "{settings:?}"); + assert!(settings.contains(&(scripting, "BlockUntrustedRefererLinks", "true")), "{settings:?}"); + // "Never" is 2 for Writer but 1 for Calc (Writer.xcs and Calc.xcs); Writer's 1 is + // its default, "On request". + assert!(settings.contains(&("/org.openoffice.Office.Writer/Content/Update", "Link", "2")), "{settings:?}"); + assert!(settings.contains(&("/org.openoffice.Office.Calc/Content/Update", "Link", "1")), "{settings:?}"); + assert_eq!(settings.len(), 5, "{settings:?}"); + } + + #[test] + fn a_missing_converter_is_an_error_not_a_crash() { + let missing = std::env::temp_dir().join("pdfcraft-no-such-soffice").join("soffice"); + let error = convert_with(&missing, "Report.docx", DOCX_HEAD, Duration::from_secs(5)).unwrap_err(); + assert!(error.to_string().contains("couldn't start LibreOffice"), "{error}"); + } + + #[test] + fn a_converter_that_makes_no_pdf_is_reported() { + // This test binary stands in for LibreOffice: it rejects the arguments and exits + // without writing anything. + let program = std::env::current_exe().unwrap(); + let error = convert_with(&program, "Report.docx", DOCX_HEAD, Duration::from_secs(60)).unwrap_err(); + assert!(error.to_string().contains("LibreOffice"), "{error}"); + } + + #[test] + fn a_converter_that_hangs_is_stopped() { + let mut command = std::process::Command::new(std::env::current_exe().unwrap()); + command.args(["--ignored", "--exact", "office::tests::native::sleeps_for_a_minute", "--nocapture"]); + command.stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()); + let started = std::time::Instant::now(); + assert!(matches!(run_limited(command, Duration::from_millis(500), None), Ok(Run::TimedOut))); + assert!(started.elapsed() < Duration::from_secs(30)); + } + + /// The stand-in for a LibreOffice that never finishes (run only by the test above). + #[test] + #[ignore] + fn sleeps_for_a_minute() { + std::thread::sleep(Duration::from_secs(60)); + } + + /// Where the stand-ins below write their heartbeat. + const HEARTBEAT: &str = "PDFCRAFT_TEST_OFFICE_HEARTBEAT"; + + /// This test binary run as one of its own ignored tests, with the heartbeat file set. + fn stand_in(test: &str, heartbeat: &Path) -> std::process::Command { + let mut command = std::process::Command::new(std::env::current_exe().unwrap()); + command.args(["--ignored", "--exact", test, "--nocapture"]).env(HEARTBEAT, heartbeat); + command.stdin(std::process::Stdio::null()).stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()); + command + } + + /// `soffice` starts `soffice.bin` as its own child: a timeout must stop both, or the + /// child keeps running, holding the profile folder. + #[test] + fn a_timeout_stops_the_processes_the_converter_started() { + let work = WorkDir::new().unwrap(); + let heartbeat = work.0.join("heartbeat"); + let parent = stand_in("office::tests::native::starts_a_child_that_keeps_beating", &heartbeat); + assert!(matches!(run_limited(parent, Duration::from_secs(5), None), Ok(Run::TimedOut))); + let beats = || std::fs::metadata(&heartbeat).map_or(0, |m| m.len()); + // A killed process may take a moment to go. + std::thread::sleep(Duration::from_millis(300)); + let after_kill = beats(); + assert!(after_kill > 0, "the grandchild started and was beating before the timeout"); + std::thread::sleep(Duration::from_secs(1)); + assert_eq!(beats(), after_kill, "the grandchild was stopped with its parent"); + } + + /// The stand-in for `soffice`: starts [`beats_until_stopped`] and waits (run only by the + /// test above). + #[test] + #[ignore] + fn starts_a_child_that_keeps_beating() { + let Some(heartbeat) = std::env::var_os(HEARTBEAT) else { return }; + let mut child = stand_in("office::tests::native::beats_until_stopped", Path::new(&heartbeat)).spawn().unwrap(); + std::thread::sleep(Duration::from_secs(60)); + let _ = child.kill(); + let _ = child.wait(); + } + + /// The stand-in for `soffice.bin`: appends to the heartbeat file every 50 ms for a minute. + #[test] + #[ignore] + fn beats_until_stopped() { + use std::io::Write; + let Some(heartbeat) = std::env::var_os(HEARTBEAT) else { return }; + let mut file = std::fs::OpenOptions::new().create(true).append(true).open(heartbeat).unwrap(); + for _ in 0..1200 { + file.write_all(b".").unwrap(); + std::thread::sleep(Duration::from_millis(50)); + } + } + + /// Where the stand-in below writes. + const FILL_DIR: &str = "PDFCRAFT_TEST_OFFICE_FILL_DIR"; + + /// A converter that writes without end is stopped as soon as its output passes the + /// limit, not after it has run out its time. + #[test] + fn a_converter_whose_output_grows_past_the_limit_is_stopped() { + let work = WorkDir::new().unwrap(); + let mut command = std::process::Command::new(std::env::current_exe().unwrap()); + command.args(["--ignored", "--exact", "office::tests::native::writes_without_end", "--nocapture"]).env(FILL_DIR, &work.0); + command.stdin(std::process::Stdio::null()).stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()); + let limit = 256 * 1024; + let started = std::time::Instant::now(); + let run = run_limited(command, Duration::from_secs(60), Some((&work.0, limit))); + assert!(matches!(run, Ok(Run::TooLarge)), "stopped for size, not for time"); + assert!(started.elapsed() < Duration::from_secs(30), "stopped long before the timeout"); + // A killed process may take a moment to go; after that nothing is written. + std::thread::sleep(Duration::from_millis(300)); + let after_kill = folder_size(&work.0); + assert!(after_kill > limit, "the stand-in had written past the limit"); + std::thread::sleep(Duration::from_secs(1)); + assert_eq!(folder_size(&work.0), after_kill, "the converter was stopped"); + } + + /// The stand-in for a LibreOffice that fills the disk: 64 KiB every 10 ms for a minute + /// (run only by the test above). + #[test] + #[ignore] + fn writes_without_end() { + use std::io::Write; + let Some(dir) = std::env::var_os(FILL_DIR) else { return }; + let mut file = std::fs::File::create(Path::new(&dir).join("document.pdf")).unwrap(); + let chunk = vec![0u8; 64 * 1024]; + for _ in 0..6000 { + file.write_all(&chunk).unwrap(); + std::thread::sleep(Duration::from_millis(10)); + } + } + + #[test] + fn a_folder_that_cannot_be_read_counts_as_empty() { + assert_eq!(folder_size(&std::env::temp_dir().join("pdfcraft-no-such-folder")), 0); + } + + /// The temporary folder holds a confidential document and its PDF: on Unix only the owner + /// may enter it. + #[cfg(unix)] + #[test] + fn the_temporary_folder_is_private_to_its_owner() { + use std::os::unix::fs::PermissionsExt; + let work = WorkDir::new().unwrap(); + let mode = std::fs::metadata(&work.0).unwrap().permissions().mode(); + assert_eq!(mode & 0o777, 0o700, "{mode:o}"); + } + + #[test] + fn temporary_folders_are_removed_with_their_contents() { + let work = WorkDir::new().unwrap(); + let other = WorkDir::new().unwrap(); + assert_ne!(work.0, other.0); + let dir = work.0.clone(); + std::fs::create_dir_all(dir.join("profile").join("user")).unwrap(); + std::fs::write(dir.join("document.docx"), DOCX_HEAD).unwrap(); + drop(work); + assert!(!dir.exists()); + } + + /// A real conversion, when LibreOffice is installed (skipped otherwise). + #[test] + fn libreoffice_converts_a_document_when_installed() { + let Some(program) = super::super::libreoffice() else { + eprintln!("skipped: LibreOffice isn't installed"); + return; + }; + let rtf = b"{\\rtf1\\ansi{\\fonttbl\\f0\\fswiss Helvetica;}\\f0\\pard Hello from PdfCraft.\\par}"; + let pdf = convert_with(&program, "hello.rtf", rtf, super::super::TIMEOUT).unwrap(); + assert!(pdf.starts_with(b"%PDF-")); + } + } +} diff --git a/crates/create/src/tests.rs b/crates/create/src/tests.rs index d2a8c143e7..f8b870832b 100644 --- a/crates/create/src/tests.rs +++ b/crates/create/src/tests.rs @@ -559,7 +559,7 @@ fn source_kind_tells_pdfs_images_and_text_apart() { // Text that merely starts like a BMP stays text; a truncated BMP header is not an image. assert_eq!(source_kind("b.txt", b"BMW drivers"), Some(SourceKind::Text)); assert_eq!(source_kind("b.bmp", b"BM"), None); - assert_eq!(source_kind("a.docx", b"PK\x03\x04"), None); + assert_eq!(source_kind("a.docx", b"PK\x03\x04"), Some(SourceKind::Office), "Office documents convert through LibreOffice"); assert_eq!(source_kind("", b""), None); // Only the GIF versions Create decodes count as images, so detection never promises a // conversion that then fails. @@ -2952,3 +2952,19 @@ fn stripping_extended_xmp_leaves_the_rest_of_the_jpeg() { // The helper puts a stray byte before the chunks, which the decoder skips: it stays. assert_eq!(crate::upright_image::without_extended_xmp(&flooded), [&plain[..2], b"\0".as_slice(), &plain[2..]].concat()); } + +#[test] +fn convertible_is_the_image_and_text_extensions() { + let both: Vec<&str> = IMAGE_EXTENSIONS.iter().chain(TEXT_EXTENSIONS.iter()).copied().collect(); + assert_eq!(both, CONVERTIBLE.to_vec()); + assert!(OFFICE_EXTENSIONS.iter().all(|e| !CONVERTIBLE.contains(e) && *e != "pdf")); +} + +#[test] +fn office_documents_are_their_own_kind() { + assert_eq!(source_kind("Report.docx", b"PK\x03\x04\x14\x00"), Some(SourceKind::Office)); + assert_eq!(source_kind("Budget.xls", &[0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1]), Some(SourceKind::Office)); + // A PDF saved with a Word name is still a PDF; a ZIP that isn't named like Office is nothing. + assert_eq!(source_kind("Report.docx", b"%PDF-1.7\n"), Some(SourceKind::Pdf)); + assert_eq!(source_kind("photos.zip", b"PK\x03\x04\x14\x00"), None); +} diff --git a/crates/crypt/src/lib.rs b/crates/crypt/src/lib.rs index 8b520d281d..31c4963ae5 100644 --- a/crates/crypt/src/lib.rs +++ b/crates/crypt/src/lib.rs @@ -127,6 +127,13 @@ impl Permissions { pub fn assemble(&self) -> bool { if self.is_revision_3_or_later() { self.bit(11) } else { self.bit(4) } } + /// Take pages out into another document (Extract, Split, a source of Combine or Insert + /// Pages): [`Self::assemble`] and copying (bit 5), since the pages carry their text and + /// graphics into a new file without the document's protection (#5081). "Any except + /// extracting pages" sets bit 4 but not bit 11, so at revision 3 and later it grants neither. + pub fn extract_pages(&self) -> bool { + self.assemble() && self.copy() + } /// Revision 3 and later need bit 3 as well as bit 12. Table 22 defines bit 12 for those /// revisions only: at revision 2 the print flag (bit 3) is all there is. pub fn print_high_quality(&self) -> bool { diff --git a/crates/crypt/src/tests.rs b/crates/crypt/src/tests.rs index f7bbb874bb..39853f33ad 100644 --- a/crates/crypt/src/tests.rs +++ b/crates/crypt/src/tests.rs @@ -248,6 +248,23 @@ fn permission_bits_follow_table_22() { assert!(p.modify(), "owners ignore /P"); } +/// Page extraction (#5081) is assembly as the revision reads it, and copying (bit 5). +#[test] +fn page_extraction_needs_assembly_and_copying() { + let bit = |n: u32| 1i32 << (n - 1); + let user = |bits: i32, revision: i64| Permissions { bits, owner: false, revision }; + // "Any except extracting pages" (bits 4, 6, 9; copying off) at revision 3 and later. + assert!(!user(bit(4) | bit(6) | bit(9), 4).extract_pages()); + // Pages may change in place, but copying is withheld. + assert!(user(bit(11), 6).assemble() && !user(bit(11), 6).extract_pages()); + assert!(user(bit(11) | bit(5), 6).extract_pages()); + // Revision 2: bit 4 covers assembly, and its reserved bit 11 grants nothing. + assert!(user(bit(4), 2).assemble() && !user(bit(4) | bit(11), 2).extract_pages(), "copying withheld"); + assert!(user(bit(4) | bit(5), 2).extract_pages()); + assert!(!user(bit(5) | bit(11), 2).extract_pages(), "no assembly without bit 4 at revision 2"); + assert!(Permissions { bits: 0, owner: true, revision: 2 }.extract_pages(), "owners ignore /P"); +} + #[test] fn perms_limits_what_an_edited_p_grants() { let made = new(Algorithm::Aes256, "user pw", "owner pw", 0b0100); // print only diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index e9bf8370f8..b956cb7079 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -10060,11 +10060,16 @@ fn rewriting_a_paragraph_drawn_with_quote_operators_keeps_their_line_move_and_sp fn replacing_a_line_keeps_what_a_dropped_quote_copy_did() { let cases = [ // The copy's `'` moves to the line the footer is placed from. - "BT /F1 12 Tf 14 TL 72 700 Td (Target line here) Tj ET BT 72.3 714 Td (Target line here) ' 0 -100 Td (Footer stays put) Tj ET", + (2, "BT /F1 12 Tf 14 TL 72 700 Td (Target line here) Tj ET BT 72.3 714 Td (Target line here) ' 0 -100 Td (Footer stays put) Tj ET"), // The copy's `"` sets the spacing the footer inherits in the next text object. - "BT /F1 12 Tf 0 TL 72 700 Td (Target line here) Tj ET BT 72.3 700 Td 3 2 (Target line here) \" ET BT 72 600 Td (Footer stays put) Tj ET", + (2, "BT /F1 12 Tf 0 TL 72 700 Td (Target line here) Tj ET BT 72.3 700 Td 3 2 (Target line here) \" ET BT 72 600 Td (Footer stays put) Tj ET"), + // Two copies, a `'` one and a `"` one: the footer takes the second's line and spacing. + ( + 3, + "BT /F1 12 Tf 0 TL 72 700 Td (Target line here) Tj ET BT 72.3 700 Td (Target line here) ' ET BT 72.6 700 Td 3 2 (Target line here) \" 0 -100 Td (Footer stays put) Tj ET", + ), ]; - for content in cases { + for (drawn, content) in cases { let footer = |doc: &Document| { let lines = text::text_lines(doc, 0).unwrap(); let f = lines.iter().find(|l| l.text == "Footer stays put").unwrap_or_else(|| panic!("no footer in {content}")); @@ -10074,7 +10079,7 @@ fn replacing_a_line_keeps_what_a_dropped_quote_copy_did() { let before = footer(&original); let targets: Vec = text::text_lines(&original, 0).unwrap().iter().enumerate().filter(|(_, l)| l.text == "Target line here").map(|(i, _)| i).collect(); - assert_eq!(targets.len(), 2, "{content}"); + assert_eq!(targets.len(), drawn, "{content}"); for target in targets { let mut doc = original.clone(); text::replace_line(&mut doc, 0, target, "NEW TEXT").unwrap(); @@ -14037,6 +14042,39 @@ fn review_inside_scope_group_move_matches_direct_transform_under_outer_scale() { } } +/// A page that names one font under 300 resource names builds its metrics once: each alias +/// used to build (and keep) a set of its own. +#[test] +fn font_aliases_share_one_set_of_metrics() { + let fonts: String = (0..300).map(|i| format!(" /A{i} 5 0 R")).collect(); + let content: String = format!("BT 72 700 Td {} ET", (0..300).map(|i| format!("/A{i} 12 Tf (x) Tj ")).collect::()); + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Contents 4 0 R /Resources << /Font <<{fonts} >> >> >>"), + format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offs = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offs.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let x = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offs { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes()); + let doc = Document::open(Arc::new(out)).unwrap(); + text::METRICS_BUILT.with(|n| n.set(0)); + let lines = text::text_lines(&doc, 0).unwrap(); + assert!(lines.iter().map(|l| l.text.matches('x').count()).sum::() >= 300, "{lines:?}"); + let built = text::METRICS_BUILT.with(std::cell::Cell::get); + assert!(built < 10, "metrics built {built} times for one font"); +} + /// Each `Tj`/`TJ` moves the text matrix by the width of its text. A run shown later in the same /// text object without a `Td` or `Tm` of its own (a raised run in another colour, a column after /// a `TJ` gap) is placed by those advances. Rewriting the paragraph before it must keep them, as @@ -14845,6 +14883,38 @@ fn a_thai_edit_that_spends_its_allowance_to_the_byte_goes_through() { assert!(error.contains("THSarabunPSK") && error.contains("MiB of font data"), "{error}"); } +/// Only a read the allowance refuses leaves a font of the face unread for want of it. With the +/// allowance spent to the byte as above, another font of the face, whose ToUnicode is an empty +/// stream behind a filter nothing decodes (unread with any allowance), still has its program read, +/// which the allowance refuses: the edit is refused, rather than going through without that font. +#[test] +fn a_font_of_the_face_unread_on_its_own_once_the_allowance_is_spent_still_refuses_the_edit() { + let mut program = legacy_thai_program(); + program.resize((16 << 20) - 192, 0); + let mut unicode = LEGACY_THAI_UNICODE.to_vec(); + unicode.resize(256, b' '); + let mut doc = text_page("BT /F1 16 Tf 50 650 Td <00d6> Tj ET"); + let font = legacy_thai_font(&mut doc, "ABCDEF+THSarabunPSK", &program, &unicode); + doc.set(pdfcraft_cos::ObjRef::new(5, 0), Object::Dict(font)); + // Not a subset, so its metrics read no program, only the ToUnicode. + let mut other = legacy_thai_font(&mut doc, "THSarabunPSK", &legacy_thai_program(), LEGACY_THAI_UNICODE); + let mut dict = Dict::new(); + dict.set(b"Filter".to_vec(), Object::name("NoSuchFilter")); + other.set(b"ToUnicode".to_vec(), Object::Ref(doc.add(Object::Stream(Stream::from_raw(dict, Vec::new()))))); + let other = doc.add(Object::Dict(other)); + doc.update_dict(pdfcraft_cos::ObjRef::new(3, 0), |page| { + let Some(Object::Dict(mut resources)) = page.get(b"Resources").cloned() else { panic!("fixture") }; + let Some(Object::Dict(mut fonts)) = resources.get(b"Font").cloned() else { panic!("fixture") }; + fonts.set(b"F2".to_vec(), Object::Ref(other)); + resources.set(b"Font".to_vec(), Object::Dict(fonts)); + page.set(b"Resources".to_vec(), Object::Dict(resources)); + }) + .unwrap(); + let style = BlockStyle { width: Some(400.0), ..Default::default() }; + let error = text::rewrite_block(&mut doc, 0, 0, Some("กขค"), &style).unwrap_err().to_string(); + assert!(error.contains("THSarabunPSK") && error.contains("MiB of font data"), "{error}"); +} + /// A THSarabunPSK-named subset that has OpenType Thai mark positioning is a modern face: it is /// shaped with its own layout like any source font, not by the legacy shaper (which would ask /// for Windows PUA alternates such a face doesn't need). diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index b58c41303f..eba8834c2b 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -54,6 +54,8 @@ pub(crate) use fallback::{SOURCE_SPACE_CALLS, take_source_character_measurements #[cfg(test)] use fallback::{add_type3_font, is_type3_fallback}; pub(crate) use fallback::{glyph_path, type3_font_dict, unique_font_name}; +#[cfg(test)] +pub(crate) use interp::METRICS_BUILT; use interp::{Origin, Paint, union}; #[cfg(test)] pub(crate) use lines::dropped_advances_within; diff --git a/crates/edit/src/text/editor.rs b/crates/edit/src/text/editor.rs index 4f4abf3a43..e6df1dbfee 100644 --- a/crates/edit/src/text/editor.rs +++ b/crates/edit/src/text/editor.rs @@ -2,7 +2,7 @@ use super::blocks::{BlockStyle, TextBlock}; use super::fallback::MAX_TYPE3_GLYPHS; use super::lines::{page_dict, text_lines}; use super::rewrite::rewrite_block_from; -use super::{DecodeBudget, Dict, Document, EditError, Metrics, Object, TextLine, TextSession}; +use super::{DecodeBudget, Dict, Document, EditError, Object, TextLine, TextSession}; /// An embedded source font or an approved bundled document-font choice. /// Source bytes stay in memory and are never installed or copied into another document. @@ -45,7 +45,7 @@ pub fn text_editor_fonts(doc: &Document, page: usize) -> Result, Option, Result, String>); +#[cfg(test)] +thread_local! { + /// Font metrics built by [`interpret`] on this thread, for the test that keeps font aliases + /// sharing one set. + pub(crate) static METRICS_BUILT: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + /// A `Do` met while interpreting: its operator index, XObject name and the state there. type Invocation = (usize, Vec, Ts); @@ -976,6 +983,7 @@ pub(super) fn interpret( let fonts_res = super::resource_dict(doc, resources, b"Font", &mut held_fonts).unwrap_or(&none); let states = super::resource_dict(doc, resources, b"ExtGState", &mut held_states).unwrap_or(&none); let mut out: Vec = Vec::new(); + let mut by_font: HashMap = HashMap::new(); let mut ts = carry.ts.clone(); let mut at_bt = (0usize, ts.clone()); let mut stack: Vec<(Ts, Matrix, Matrix)> = std::mem::take(&mut carry.stack); @@ -1105,11 +1113,23 @@ pub(super) fn interpret( ts.size = op.num(1).unwrap_or(ts.size); if let Some(name) = op.name(0) { let (m, face, vertical) = cache.entry(name.to_vec()).or_insert_with(|| { + // Names that refer to one font object share what was built for it: a + // thousand aliases of one font are one set of metrics, not a thousand. + let font = fonts_res.get(name).and_then(Object::as_ref); + if let Some(built) = font.and_then(|r| by_font.get(&r)) { + return built.clone(); + } + #[cfg(test)] + METRICS_BUILT.with(|n| n.set(n.get() + 1)); let dict = fonts_res.get(name).and_then(|f| doc.resolve(f).as_dict().cloned()); let metrics = dict.as_ref().map_or_else(Metrics::fallback, |d| Metrics::from_dict(doc, d)); let vertical = dict.as_ref().map_or(Ok(None), |d| vertical_advance(doc, d).map_err(|e| e.to_string())); let face = dict.filter(is_type3_fallback).map(|_| metrics.base_font.clone()); - (Rc::new(metrics), face, vertical) + let built: CachedFont = (Rc::new(metrics), face, vertical); + if let Some(r) = font { + by_font.insert(r, built.clone()); + } + built }); ts.font = Some((name.to_vec(), m.clone())); ts.face = face.clone(); diff --git a/crates/engine/src/actions.rs b/crates/engine/src/actions.rs index 249279e09d..6608cb3297 100644 --- a/crates/engine/src/actions.rs +++ b/crates/engine/src/actions.rs @@ -151,6 +151,8 @@ fn images_refused_note(images: usize) -> Option { (images > 0).then(|| format!("Reduce file size: {images} image(s) stayed as they were, past what one run may decode")) } +/// Apply one action step to document `id`, adding what it did to `log`; returns the new +/// document when the step replaced it. fn apply_step(s: &mut Session, id: DocId, step: &Step, log: &mut Vec) -> Result, EditError> { let pages = s.get(id).map_or(0, |d| d.info.pages.len()); let all: Vec = (0..pages).collect(); @@ -209,9 +211,19 @@ fn apply_step(s: &mut Session, id: DocId, step: &Step, log: &mut Vec) -> Ok(None) } -/// Run `action` on one file. `progress(step, steps)` is called before each step. -pub fn run_on(action: &Action, name: &str, bytes: Arc>, mut progress: impl FnMut(usize, usize)) -> Result { +/// Run `action` on one file. `javascript` is the caller's [`Session::javascript`] preference: +/// when off, neither open-time scripts nor "Run a JavaScript" steps run. `progress(step, steps)` +/// is called before each step. +pub fn run_on( + action: &Action, + name: &str, + bytes: Arc>, + javascript: bool, + mut progress: impl FnMut(usize, usize), +) -> Result { let mut s = Session::new(); + // Before the open: open-time (XFA) scripts read it there. + s.set_javascript(javascript); let mut id = s.open(name, None, bytes, None).map_err(|e| e.to_string())?; if let Some(why) = s.get(id).and_then(|d| d.read_only_reason.clone()) { return Err(why); diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index dfbc33d658..55a5d840ba 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -18,8 +18,10 @@ pub enum Needs { Nothing, /// A document is open. Document, - /// The open document's security allows page changes (insert, delete, rotate, extract…). + /// The open document's security allows page changes (insert, delete, rotate…). Assembly, + /// The open document's security allows taking pages out into new files (extract, split). + PageExtraction, /// The open document's security allows content and metadata changes. Modification, /// The open document's security allows adding and changing comments. @@ -416,8 +418,8 @@ pub const COMMANDS: &[CommandSpec] = &[ c("page.boxes", "Set page boxes…", PAGES, None, Assembly, "square-dashed-mouse-pointer"), c("page.insert", "Insert pages from file…", PAGES, None, Assembly, "file-input"), c("page.replace", "Replace pages…", PAGES, None, Assembly, "replace"), - c("page.extract", "Extract pages…", PAGES, None, Assembly, "file-output"), - c("page.split", "Split document…", PAGES, None, Assembly, "scissors"), + c("page.extract", "Extract pages…", PAGES, None, PageExtraction, "file-output"), + c("page.split", "Split document…", PAGES, None, PageExtraction, "scissors"), c("page.number", "Number pages…", PAGES, None, Assembly, "hash"), c("prepress.marks", "Add printer marks…", None, None, Modification, "ruler"), c("prepress.marks.remove", "Remove printer marks", None, None, Marks(crate::MarkKind::PrinterMarks), "ruler"), @@ -449,6 +451,7 @@ pub fn is_enabled(spec: &CommandSpec, session: &Session, active: Option) Nothing => true, Document | TwoPageView => doc.is_some(), Assembly => doc.is_some_and(|d| d.allows_assembly()), + PageExtraction => doc.is_some_and(|d| d.allows_page_extraction()), Modification => doc.is_some_and(|d| d.allows_modification()), Annotate => doc.is_some_and(|d| d.allows_annotation()), Copying => doc.is_some_and(|d| d.allows_copying()), diff --git a/crates/engine/src/document.rs b/crates/engine/src/document.rs index 530f8787af..805e089dd7 100644 --- a/crates/engine/src/document.rs +++ b/crates/engine/src/document.rs @@ -52,6 +52,9 @@ pub struct Document { pub(crate) config: RenderConfig, /// What field scripts printed or asked for (see [`Session::take_js_output`]). pub(crate) js_output: js::JsOutput, + pub(crate) js_runtime: js::Runtime, + pub(crate) js_zoom: f64, + pub(crate) js_page: usize, /// Dynamic XFA forms: what laying the template out produced (pages and fields are /// PdfCraft's; Adobe's viewers draw the form from the XFA packets themselves). pub xfa: Option, @@ -551,11 +554,17 @@ impl Document { if self.allows_copying() { Ok(()) } else { Err(EditError::NotPermitted("copying text and images")) } } - /// Page changes (insert, delete, rotate, move, extract) are allowed. + /// Page changes (insert, delete, rotate, move) are allowed. pub fn allows_assembly(&self) -> bool { self.editable() && self.permissions().is_none_or(|p| p.assemble()) } + /// Pages may be taken out into new files (Extract, Split): the page changes above and copying, + /// since the new files don't keep the document's protection (#5081). + pub fn allows_page_extraction(&self) -> bool { + self.editable() && self.permissions().is_none_or(|p| p.extract_pages()) + } + /// Redaction marks waiting to be applied. pub fn redaction_marks(&self) -> usize { self.info.annotations.iter().filter(|a| a.subtype == "Redact").count() @@ -949,41 +958,60 @@ pub(crate) fn recovered_render_password(cos: &pdfcraft_cos::Document) -> Option< /// the document as it was. pub(crate) fn shown_under_redactions( after: &pdfcraft_cos::Document, - marks: &[pdfcraft_redact::Mark], + marks: &[(pdfcraft_cos::ObjRef, pdfcraft_redact::Mark)], overlays: &[pdfcraft_cos::ObjRef], - pages: &[pdfcraft_render::PageInfo], password: Option<&str>, ) -> Result<(), EditError> { use pdfcraft_cos::Object; use pdfcraft_redact::RedactError; - let mut marked: Vec = marks.iter().map(|m| m.page).collect(); + let mut shown = after.clone(); + let model = pdfcraft_model::pages(&shown); + // A batch can move pages after the marks were collected: a mark is found by the page object it + // was placed on, not by the index that page had when it was drawn. A page the batch deleted + // takes its marks with it, so they are not checked against whatever page now holds the index. + // One index, built once: each mark is resolved against it instead of scanning every page, + // and the marks are grouped by resolved page in the same pass. + let mut index: std::collections::HashMap = std::collections::HashMap::new(); + for (i, p) in model.iter().enumerate() { + index.entry(p.obj).or_insert(i); + } + let mut marked: Vec = Vec::new(); + let mut rects_of: std::collections::HashMap> = std::collections::HashMap::new(); + for (obj, m) in marks { + let Some(&pi) = index.get(obj) else { continue }; + if !rects_of.contains_key(&pi) { + marked.push(pi); + } + rects_of.entry(pi).or_default().extend(m.rects.iter().map(|r| [r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])])); + } marked.sort_unstable(); - marked.dedup(); if marked.is_empty() { return Ok(()); } - let mut shown = after.clone(); - let model = pdfcraft_model::pages(&shown); for &pi in &marked { let Some(page) = model.get(pi) else { continue }; let Some(Object::Array(list)) = page.dict.get(b"Contents").map(|c| (*shown.resolve(c)).clone()) else { continue }; let kept: Vec = list.into_iter().filter(|o| !o.as_ref().is_some_and(|r| overlays.contains(&r))).collect(); shown.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), Object::Array(kept))).map_err(|e| EditError::Write(e.to_string()))?; } - let bytes = write_incremental(&shown, &SaveOptions::default()).map_err(|e| EditError::Write(e.to_string()))?; + let bytes = Arc::new(write_incremental(&shown, &SaveOptions::default()).map_err(|e| EditError::Write(e.to_string()))?); + // Page geometry comes from the document as it is now written: a page moved earlier in the batch + // brings its own size and rotation with it. + let pages = pdfcraft_render::inspect(bytes.clone(), password).map_err(|e| EditError::Write(e.to_string()))?.pages; + // The model counts one entry per `/Kids` entry; the renderer one per page it draws. If they + // disagree, a mark's index could land on another page's text, and the check cannot confirm + // the redaction. + if pages.len() != model.len() { + return Err(RedactError::AmbiguousPages.into()); + } let config = RenderConfig { password: password.map(Arc::from), ..Default::default() }; - let mut renderer = pdfcraft_render::PageRenderer::new(Arc::new(bytes), config); + let mut renderer = pdfcraft_render::PageRenderer::new(bytes, config); let mut residue = 0; for &pi in &marked { let request = pdfcraft_render::RenderRequest { page: pi, kind: pdfcraft_render::RequestKind::Text, scale: 1.0, ..Default::default() }; // A page that cannot be read back cannot be confirmed clean. let (Some(info), Some(text)) = (pages.get(pi), renderer.render(request).text) else { return Err(RedactError::Unreadable(pi + 1).into()) }; - let rects: Vec<[f64; 4]> = marks - .iter() - .filter(|m| m.page == pi) - .flat_map(|m| m.rects.iter()) - .map(|r| [r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])]) - .collect(); + let rects = rects_of.get(&pi).cloned().unwrap_or_default(); for glyph in text.glyphs.iter().filter(|g| !g.text.trim().is_empty()) { let [x0, y0, x1, y1] = info.view_rect_to_user(glyph.rect).map(f64::from); let area = (x1 - x0) * (y1 - y0); diff --git a/crates/engine/src/document_actions_tests.rs b/crates/engine/src/document_actions_tests.rs new file mode 100644 index 0000000000..8511163eff --- /dev/null +++ b/crates/engine/src/document_actions_tests.rs @@ -0,0 +1,379 @@ +//! Catalog JavaScript actions (#269, ported from #344): the `/OpenAction` runs once on opening +//! and again on Revert, `/AA /WS` before a save and `/AA /WP` before printing, all in the +//! document's retained realm. + +use std::sync::Arc; + +use crate::js::DocumentScriptEvent; +use crate::{DocId, Session}; + +/// A PDF of `objs`, numbered from 1, with a cross-reference table. +fn pdf_of(objs: &[String]) -> Vec { + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +/// A one-field form (`log`, a text field) whose catalog carries `catalog` (PDF syntax, e.g. +/// `/OpenAction 6 0 R`), with `extra` objects numbered from 6. +fn form(catalog: &str, extra: &[&str]) -> Vec { + let mut objs = vec![ + format!("<< /Type /Catalog /Pages 2 0 R {catalog} /AcroForm << /Fields [4 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> >>"), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (log) /V () /Rect [20 250 180 270] /P 3 0 R /F 4 >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + ]; + objs.extend(extra.iter().map(|s| s.to_string())); + pdf_of(&objs) +} + +fn log(s: &Session, id: DocId) -> String { + s.get(id).unwrap().form.iter().find(|f| f.name == "log").unwrap().value.join(",") +} + +/// The open action runs once on opening, after the document scripts and in their realm, and +/// sees `event.name` "Open"; its field change is applied. +#[test] +fn the_open_action_runs_once_on_opening_in_the_document_realm() { + let bytes = form( + "/OpenAction 6 0 R /Names << /JavaScript << /Names [(init) 7 0 R] >> >>", + &[ + "<< /S /JavaScript /JS (opened++; getField\\('log'\\).value = event.type + '/' + event.name + '/' + opened;) >>", + "<< /S /JavaScript /JS (var opened = 0;) >>", + ], + ); + let mut s = Session::new(); + let id = s.open("open.pdf", None, Arc::new(bytes), None).unwrap(); + assert_eq!(log(&s, id), "Doc/Open/1"); + assert!(s.take_js_output(id).errors.is_empty()); + // The realm is kept: a later script sees the count the open action left. + assert_eq!(s.run_javascript(id, "opened;", None).unwrap().result.as_deref(), Some("1")); +} + +/// An open action chain runs in order through `/Next` (a dictionary or an array), skipping +/// actions of other types, and a cycle in it runs each action once. +#[test] +fn an_open_action_chain_runs_in_order_and_a_cycle_runs_each_action_once() { + let bytes = form( + "/OpenAction 6 0 R", + &[ + "<< /S /JavaScript /JS (var seen = ['a'];) /Next [7 0 R 8 0 R] >>", + "<< /S /GoTo /D [3 0 R /Fit] /Next 9 0 R >>", + "<< /S /JavaScript /JS (seen.push\\('c'\\); getField\\('log'\\).value = seen.join\\(''\\);) /Next 6 0 R >>", + "<< /S /JavaScript /JS (seen.push\\('b'\\);) /Next 7 0 R >>", + ], + ); + let mut s = Session::new(); + let id = s.open("chain.pdf", None, Arc::new(bytes), None).unwrap(); + assert_eq!(log(&s, id), "abc"); +} + +/// What an open action asks for that needs a click (a URL, printing) is dropped; a page +/// change it asks for is kept. +#[test] +fn an_open_action_may_not_open_a_url_or_print() { + let bytes = form("/OpenAction << /S /JavaScript /JS (app.launchURL\\('https://example.com'\\); this.print\\(\\); this.pageNum = 0;) >>", &[]); + let mut s = Session::new(); + let id = s.open("asks.pdf", None, Arc::new(bytes), None).unwrap(); + let out = s.take_js_output(id); + assert_eq!(out.requests, [crate::js::Request::GoToPage(0)], "{out:?}"); +} + +/// With JavaScript off the open action doesn't run. +#[test] +fn the_open_action_does_not_run_with_javascript_off() { + let bytes = form("/OpenAction << /S /JavaScript /JS (getField\\('log'\\).value = 'ran';) >>", &[]); + let mut s = Session::new(); + s.set_javascript(false); + let id = s.open("off.pdf", None, Arc::new(bytes), None).unwrap(); + assert_eq!(log(&s, id), ""); + assert!(s.run_document_actions(id, DocumentScriptEvent::CatalogOpen).is_empty()); +} + +/// Revert reopens the saved file in a fresh realm, so the open action runs again from scratch. +#[test] +fn revert_runs_the_open_action_again_in_a_fresh_realm() { + let bytes = form( + "/OpenAction << /S /JavaScript /JS (if \\(typeof runs == 'undefined'\\) var runs = 0; runs++; getField\\('log'\\).value = 'run ' + runs;) >>", + &[], + ); + let mut s = Session::new(); + let id = s.open("revert.pdf", None, Arc::new(bytes), None).unwrap(); + assert_eq!(log(&s, id), "run 1"); + s.run_javascript(id, "getField('log').value = 'typed';", None).unwrap(); + s.revert(id).unwrap(); + assert_eq!(log(&s, id), "run 1", "a fresh realm counts from zero again"); +} + +/// Will Save runs in the document realm before the bytes are written, and what it changes is in +/// the saved file; a failing Will Save is reported and the save still has bytes to write. +#[test] +fn will_save_changes_what_is_saved_and_a_failing_one_does_not_stop_the_save() { + let bytes = form( + "/Names << /JavaScript << /Names [(init) 6 0 R] >> >> /AA << /WS << /S /JavaScript /JS (saves++; getField\\('log'\\).value = event.name + ' ' + saves;) >> >>", + &["<< /S /JavaScript /JS (var saves = 0;) >>"], + ); + let mut s = Session::new(); + let id = s.open("save.pdf", None, Arc::new(bytes), None).unwrap(); + let out = s.run_document_actions(id, DocumentScriptEvent::WillSave); + assert!(out.errors.is_empty(), "{out:?}"); + assert_eq!(log(&s, id), "WillSave 1"); + let saved = s.save_bytes(id).unwrap(); + let mut reopened = Session::new(); + reopened.set_javascript(false); + let again = reopened.open("saved.pdf", None, saved, None).unwrap(); + assert_eq!(log(&reopened, again), "WillSave 1"); + + let failing = form("/AA << /WS << /S /JavaScript /JS (throw new Error\\('no'\\);) >> >>", &[]); + let id = s.open("fail.pdf", None, Arc::new(failing), None).unwrap(); + let out = s.run_document_actions(id, DocumentScriptEvent::WillSave); + assert!(out.errors.iter().any(|e| e.contains("no")), "{out:?}"); + assert!(s.save_bytes(id).is_ok()); +} + +/// Will Print runs before printing, in the same realm, and sees `event.name` "WillPrint". +#[test] +fn will_print_runs_in_the_document_realm() { + let bytes = form("/AA << /WP << /S /JavaScript /JS (getField\\('log'\\).value = event.name;) >> >>", &[]); + let mut s = Session::new(); + let id = s.open("print.pdf", None, Arc::new(bytes), None).unwrap(); + assert!(s.run_document_actions(id, DocumentScriptEvent::WillSave).is_empty(), "no Will Save action: nothing runs"); + assert_eq!(log(&s, id), ""); + s.run_document_actions(id, DocumentScriptEvent::WillPrint); + assert_eq!(log(&s, id), "WillPrint"); +} + +/// A form with a text field `log` and a text field `hot` whose widget has JavaScript for every +/// pointer and focus trigger, each appending `event.name` to `log`. +fn triggers_form() -> Vec { + let add = + || "<< /S /JavaScript /JS (var l = getField\\('log'\\); l.value = l.value + event.name + ':' + event.target.name + ';';) >>".to_string(); + let objs = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 6 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 6 0 R] >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (log) /V () /Rect [20 250 180 270] /P 3 0 R /F 4 >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + format!( + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (hot) /V () /Rect [20 200 180 220] /P 3 0 R /F 4 /AA << /E {} /X {} /D {} /U {} /Fo {} /Bl {} >> >>", + add(), + add(), + "<< /S /JavaScript /JS (getField\\('log'\\).value += 'D;'; this.print\\(\\);) >>", + add(), + add(), + "<< /S /JavaScript /JS (getField\\('log'\\).value += 'Bl;'; this.print\\(\\);) >>", + ), + ]; + pdf_of(&objs) +} + +/// Pointer and focus triggers run the widget's JavaScript with the event names Acrobat uses, +/// each as one undoable edit; a field without such a script runs nothing. +#[test] +fn pointer_and_focus_triggers_run_the_widget_scripts() { + use crate::js::FieldFocus; + use pdfcraft_forms::Trigger; + let mut s = Session::new(); + let id = s.open("hot.pdf", None, Arc::new(triggers_form()), None).unwrap(); + assert!(s.field_trigger(id, "hot", 0, Trigger::MouseEnter).unwrap()); + assert!(s.field_focus(id, "hot", 0, FieldFocus::Enter).unwrap()); + assert!(s.field_trigger(id, "hot", 0, Trigger::MouseUp).unwrap()); + assert!(s.field_trigger(id, "hot", 0, Trigger::MouseExit).unwrap()); + assert_eq!(log(&s, id), "Mouse Enter:hot;Focus:hot;Mouse Up:hot;Mouse Exit:hot;"); + assert_eq!(s.get(id).unwrap().can_undo(), Some("Run JavaScript")); + assert!(!s.field_trigger(id, "log", 0, Trigger::MouseEnter).unwrap(), "no script: nothing runs"); + assert!(!s.field_trigger(id, "hot", 5, Trigger::MouseEnter).unwrap(), "no such widget: nothing runs"); + assert!(s.field_trigger(id, "missing", 0, Trigger::MouseEnter).is_err()); + s.set_javascript(false); + assert!(!s.field_trigger(id, "hot", 0, Trigger::MouseEnter).unwrap(), "JavaScript off"); +} + +/// A click's Mouse Down may ask to print; losing the focus, which happens unasked, may not. +#[test] +fn only_a_click_trigger_may_print() { + use crate::js::{FieldFocus, Request}; + use pdfcraft_forms::Trigger; + let mut s = Session::new(); + let id = s.open("hot.pdf", None, Arc::new(triggers_form()), None).unwrap(); + s.field_trigger(id, "hot", 0, Trigger::MouseDown).unwrap(); + assert_eq!(s.take_js_output(id).requests, [Request::Print]); + s.field_focus(id, "hot", 0, FieldFocus::Exit).unwrap(); + let out = s.take_js_output(id); + assert!(out.requests.is_empty(), "{out:?}"); + assert_eq!(log(&s, id), "D;Bl;"); +} + +/// `setItems` and `setAction` are written to the document: the combo box gets its new items +/// (pairs where text and export value differ), the installed scripts run on their triggers, and +/// both survive a save (#269, from #344). +#[test] +fn set_items_and_set_action_are_written_to_the_document() { + use pdfcraft_forms::Trigger; + let objs = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 6 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 6 0 R] >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (log) /V () /Rect [20 250 180 270] /P 3 0 R /F 4 >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Ch /Ff 131072 /T (size) /Opt [(Small) (Large)] /V (Large) /Rect [20 200 180 220] /P 3 0 R /F 4 >>" + .into(), + ]; + let mut s = Session::new(); + let id = s.open("items.pdf", None, Arc::new(pdf_of(&objs)), None).unwrap(); + let out = s + .run_javascript( + id, + "var f = getField('size'); f.setItems([['Medium', 'm'], 'Large']); \ + f.setAction('MouseEnter', \"getField('log').value = 'entered ' + event.target.value;\");", + None, + ) + .unwrap(); + assert_eq!(out.error, None, "{out:?}"); + let size = s.get(id).unwrap().form.iter().find(|f| f.name == "size").unwrap().clone(); + assert_eq!(size.options, [("m".to_string(), "Medium".to_string()), ("Large".into(), "Large".into())]); + assert_eq!(size.value, ["Large"], "the value is still an item: it stays"); + assert!(s.field_trigger(id, "size", 0, Trigger::MouseEnter).unwrap()); + assert_eq!(log(&s, id), "entered Large"); + s.undo(id).unwrap(); + s.undo(id).unwrap(); + assert_eq!(s.get(id).unwrap().form.iter().find(|f| f.name == "size").unwrap().options[0].1, "Small", "undone"); + s.redo(id).unwrap(); + let saved = s.save_bytes(id).unwrap(); + let mut again = Session::new(); + let reopened = again.open("saved.pdf", None, saved, None).unwrap(); + let size = again.get(reopened).unwrap().form.iter().find(|f| f.name == "size").unwrap().clone(); + assert_eq!(size.options[0], ("m".to_string(), "Medium".to_string())); + assert!(again.field_trigger(reopened, "size", 0, Trigger::MouseEnter).unwrap(), "the installed script is in the file"); +} + +/// A check box's Mouse Up, run after the click stored the new value, reads that value and can +/// show or hide another field by it (#247). +#[test] +fn a_check_box_mouse_up_reads_the_value_its_click_stored() { + use crate::{Edit, FieldValue}; + use pdfcraft_forms::Trigger; + let objs = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 6 0 R 7 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 6 0 R 7 0 R] >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (log) /V () /Rect [20 250 180 270] /P 3 0 R /F 4 >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Btn /T (strike) /V /Off /AS /Off /Rect [20 150 50 180] /P 3 0 R /F 4 /MK << /CA (4) >> /AP << /N << /Yes 8 0 R /Off 8 0 R >> >> /A << /S /JavaScript /JS (getField\\('note'\\).display = event.target.value == 'Off' ? display.hidden : display.visible; getField\\('log'\\).value += 'up:' + event.target.value + ';';) >> >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (note) /V (struck) /Rect [60 150 180 180] /P 3 0 R /F 6 >>".into(), + "<< /Type /XObject /Subtype /Form /BBox [0 0 30 30] /Length 0 >>\nstream\n\nendstream".into(), + ]; + let mut s = Session::new(); + let id = s.open("strike.pdf", None, Arc::new(pdf_of(&objs)), None).unwrap(); + s.apply(id, Edit::SetFieldValue { name: "strike".into(), value: FieldValue::Text("Yes".into()) }).unwrap(); + let ran = s.field_trigger(id, "strike", 0, Trigger::MouseUp); + let out = s.take_js_output(id); + assert!(ran.unwrap(), "{out:?}"); + assert_eq!(log(&s, id), "up:Yes;"); + let note = |s: &Session| s.get(id).unwrap().form.iter().find(|f| f.name == "note").unwrap().widgets[0].hidden; + assert!(!note(&s), "shown by the script"); + s.apply(id, Edit::SetFieldValue { name: "strike".into(), value: FieldValue::Check(false) }).unwrap(); + assert!(s.field_trigger(id, "strike", 0, Trigger::MouseUp).unwrap()); + assert_eq!(log(&s, id), "up:Yes;up:Off;"); + assert!(note(&s), "hidden again"); +} + +/// A widget action's `/Next` chain runs too, each JavaScript action in order (depth first), past +/// an action of another type at its head. +#[test] +fn a_widget_actions_next_chain_runs_each_script_in_order() { + use pdfcraft_forms::Trigger; + let js = |text: &str, next: &str| format!("<< /S /JavaScript /JS (getField\\('log'\\).value += '{text}';) {next} >>"); + let chain = format!("<< /S /URI /URI (https://example.com/) /Next [{} {}] >>", js("a;", &format!("/Next {}", js("b;", ""))), js("c;", "")); + let objs = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 6 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 6 0 R] >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (log) /V () /Rect [20 250 180 270] /P 3 0 R /F 4 >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T (hot) /V () /Rect [20 200 180 220] /P 3 0 R /F 4 /AA << /E {chain} >> >>"), + ]; + let mut s = Session::new(); + let id = s.open("chain.pdf", None, Arc::new(pdf_of(&objs)), None).unwrap(); + assert!(s.field_trigger(id, "hot", 0, Trigger::MouseEnter).unwrap()); + assert_eq!(log(&s, id), "a;b;c;"); +} + +/// `setAction` on a field that shares its name with another installs the script on that field, +/// the one the script's object stands for, not the first of the name. +#[test] +fn set_action_installs_on_the_field_the_object_stands_for() { + let objs = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 6 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 6 0 R] >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (dup) /V () /Rect [20 250 180 270] /P 3 0 R /F 4 >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (dup) /V () /Rect [20 200 180 220] /P 3 0 R /F 4 >>".into(), + ]; + let mut s = Session::new(); + let id = s.open("dup.pdf", None, Arc::new(pdf_of(&objs)), None).unwrap(); + let second = crate::js::field_object_id(pdfcraft_cos::ObjRef::new(6, 0)); + let out = + s.run_javascript_answers_target(id, "event.target.setAction('Format', \"event.value = 'x';\");", Some("dup"), Some(second), &[]).unwrap(); + assert_eq!(out.error, None, "{out:?}"); + let cos = s.get(id).unwrap().editor.as_ref().unwrap().cos.clone(); + let formats: Vec<(u32, bool)> = pdfcraft_forms::fields(&cos).iter().map(|f| (f.obj.num, f.actions.scripts.format.is_some())).collect(); + assert_eq!(formats, [(4, false), (6, true)]); +} + +/// Of two fields sharing a name, the one the pointer is on runs its own widget script. +#[test] +fn a_trigger_on_a_field_sharing_a_name_runs_that_fields_script() { + use pdfcraft_forms::Trigger; + let enter = |text: &str| format!("/AA << /E << /S /JavaScript /JS (getField\\('log'\\).value += '{text}';) >> >>"); + let objs = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 6 0 R 7 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> >>" + .to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 6 0 R 7 0 R] >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (log) /V () /Rect [20 250 180 270] /P 3 0 R /F 4 >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T (dup) /V () /Rect [20 200 180 220] /P 3 0 R /F 4 {} >>", enter("first;")), + format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T (dup) /V () /Rect [20 150 180 170] /P 3 0 R /F 4 {} >>", enter("second;")), + ]; + let mut s = Session::new(); + let id = s.open("dup.pdf", None, Arc::new(pdf_of(&objs)), None).unwrap(); + let second = crate::js::field_object_id(pdfcraft_cos::ObjRef::new(7, 0)); + assert!(s.field_trigger_target(id, "dup", Some(second), 0, Trigger::MouseEnter).unwrap()); + assert_eq!(log(&s, id), "second;"); + assert!(s.field_trigger(id, "dup", 0, Trigger::MouseEnter).unwrap(), "by name alone, the first"); + assert_eq!(log(&s, id), "second;first;"); +} + +/// Focus too: of two fields sharing a name, the one that gained focus runs its On Focus script. +#[test] +fn focus_on_a_field_sharing_a_name_runs_that_fields_script() { + use crate::js::FieldFocus; + let focus = |text: &str| format!("/AA << /Fo << /S /JavaScript /JS (getField\\('log'\\).value += '{text}';) >> >>"); + let objs = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 6 0 R 7 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> >>" + .to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 6 0 R 7 0 R] >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (log) /V () /Rect [20 250 180 270] /P 3 0 R /F 4 >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T (dup) /V () /Rect [20 200 180 220] /P 3 0 R /F 4 {} >>", focus("first;")), + format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T (dup) /V () /Rect [20 150 180 170] /P 3 0 R /F 4 {} >>", focus("second;")), + ]; + let mut s = Session::new(); + let id = s.open("dup.pdf", None, Arc::new(pdf_of(&objs)), None).unwrap(); + let second = crate::js::field_object_id(pdfcraft_cos::ObjRef::new(7, 0)); + assert!(s.field_focus_target(id, "dup", Some(second), 0, FieldFocus::Enter).unwrap()); + assert_eq!(log(&s, id), "second;"); +} diff --git a/crates/engine/src/edit.rs b/crates/engine/src/edit.rs index 1d5181bf65..80c51658e4 100644 --- a/crates/engine/src/edit.rs +++ b/crates/engine/src/edit.rs @@ -6,8 +6,8 @@ use crate::{ AddedContent, AddedText, Background, BorderShape, BoxSpec, EditError, FieldProps, FieldValue, FillMark, Hairlines, HeaderFooter, Hidden, ImageEdit, InitialView, Label, LineEnding, LinkAction, LinkStyle, MarkFile, MarkKind, Markup, NewAnnotation, NewField, NoteIcon, ObjectTarget, PageBox, PrinterMarks, Protection, ReviewState, Rgb, Shape, Standard, Style, TabOrder, TransitionSpec, Watermark, a11y, copy_image, - decode_data_file, embed_image, field_table, fit_picture, image_content_type, is_xfa_data_file, js, mark_source, measure, model_poster, - open_source, strip_xfa, unrestricted, xfa, xfa_after_data_change, + decode_data_file, embed_image, field_table, fit_picture, image_content_type, is_xfa_data_file, js, mark_source, measure, model_poster, strip_xfa, + unrestricted, xfa, xfa_after_data_change, }; mod labels; @@ -338,6 +338,12 @@ pub enum Edit { name: String, image: Arc>, }, + /// Set the picture of one exact push-button field; legacy name-only calls remain available. + SetFieldImageTarget { + name: String, + object: pdfcraft_cos::ObjRef, + image: Arc>, + }, /// Duplicate a field onto other pages (same field, shared value). DuplicateField { name: String, @@ -452,8 +458,8 @@ pub enum Edit { RemoveStandardId { standard: Standard, }, - /// What a script (button or console) changed in form fields: values, read-only, required - /// and visibility. + /// What a script (button or console) changed in form fields: values, appearance colours, + /// read-only, required and visibility. ApplyScriptChanges { changes: Vec, }, diff --git a/crates/engine/src/edit/labels.rs b/crates/engine/src/edit/labels.rs index bfbdc5f7e5..93045146ae 100644 --- a/crates/engine/src/edit/labels.rs +++ b/crates/engine/src/edit/labels.rs @@ -256,7 +256,7 @@ impl Edit { | Edit::BorderShapeAnnotation { .. } | Edit::SetAnnotationInfo { .. } => Label::of("Change comment properties"), Edit::SetFieldValue { name, .. } => Label::with("Fill in {name}", name.as_str()), - Edit::SetFieldImage { name, .. } => Label::with("Set the image of {name}", name.as_str()), + Edit::SetFieldImage { name, .. } | Edit::SetFieldImageTarget { name, .. } => Label::with("Set the image of {name}", name.as_str()), Edit::ResetForm { .. } => Label::of("Clear form"), Edit::AddField { .. } => Label::of("Add field"), Edit::SetFieldProps { .. } => Label::of("Change field properties"), diff --git a/crates/engine/src/edit/run.rs b/crates/engine/src/edit/run.rs index ebb8a5f323..0a7a348fca 100644 --- a/crates/engine/src/edit/run.rs +++ b/crates/engine/src/edit/run.rs @@ -1,6 +1,6 @@ use super::{ AddedContent, Arc, Edit, EditError, FieldValue, ImageEdit, NewAnnotation, Shape, a11y, copy_image, decode_data_file, embed_image, fit_picture, - image_content_type, is_xfa_data_file, js, mark_source, measure, model_poster, open_source, strip_xfa, unrestricted, xfa, xfa_after_data_change, + image_content_type, is_xfa_data_file, js, mark_source, measure, model_poster, strip_xfa, unrestricted, xfa, xfa_after_data_change, }; use crate::SourceProblem; use crate::sources::{comment_source, import_pdf_comments}; @@ -78,7 +78,7 @@ pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Re Err(EditError::NotPermitted("comments")) } } - Edit::SetFieldValue { .. } | Edit::ResetForm { .. } | Edit::SetFieldImage { .. } | Edit::ApplyScriptChanges { .. } | Edit::XfaEvent { .. } => { + Edit::SetFieldValue { .. } | Edit::ResetForm { .. } | Edit::SetFieldImage { .. } | Edit::SetFieldImageTarget { .. } | Edit::ApplyScriptChanges { .. } | Edit::XfaEvent { .. } => { if p.fill_forms() { Ok(()) } else { @@ -221,6 +221,7 @@ fn skipped_text(s: &str) -> String { pub(crate) struct EditCtx { /// Field scripts run (`None`: JavaScript is off) and what they produced. pub(crate) js: Option, + defer_reset_callbacks: bool, /// A laid-out XFA form's template (`None`: not an XFA form, or JavaScript is off) and what /// its scripts produced. pub(crate) xfa: Option>, @@ -252,6 +253,11 @@ pub(crate) struct EditCtx { /// The overlay streams the edit's applied redactions added, which alone the renderer's check /// leaves out. pub(crate) redaction_overlays: Vec, + /// The marks the edit's applied redactions took out (a batch's included), as they were when + /// each apply ran, for the renderer's check. + /// Each applied mark with the page object it was drawn on, which a later page move changes the + /// index of but not the identity. + pub(crate) redaction_marks: Vec<(pdfcraft_cos::ObjRef, pdfcraft_redact::Mark)>, /// What the edit's applied redactions left as it was that still holds the removed text. pub(crate) redaction_elsewhere: Option, /// What became of the PDF/A and PDF/UA identification when the edit's applied redactions @@ -266,7 +272,7 @@ impl EditCtx { /// The field highlight (`app.runtimeHighlight`) the edit's next script reads: the field /// scripts' runner holds it while there is one (its scripts change it there), else the edit. pub(crate) fn highlight(&self) -> bool { - self.js.as_ref().map_or(self.highlight, |js| js.doc.highlight) + self.js.as_ref().map_or(self.highlight, |js| js.doc.runtime_highlight) } /// A script changed the field highlight: both script runtimes of the edit read the new one, @@ -274,7 +280,7 @@ impl EditCtx { pub(crate) fn set_highlight(&mut self, on: bool) { self.highlight = on; if let Some(js) = self.js.as_mut() { - js.doc.highlight = on; + js.doc.runtime_highlight = on; } } @@ -292,6 +298,7 @@ impl EditCtx { } Self { js: None, + defer_reset_callbacks: false, xfa: None, xfa_out: Default::default(), xfa_datasets: None, @@ -308,6 +315,7 @@ impl EditCtx { rng: getrandom::fill, renamed_fields: Vec::new(), redaction_overlays: Vec::new(), + redaction_marks: Vec::new(), redaction_elsewhere: None, redaction_identification: pdfcraft_redact::Identification::default(), prepared: None, @@ -508,11 +516,11 @@ fn run_edit_page_image(doc: &mut pdfcraft_cos::Document, page: usize, index: usi Ok(()) } -/// After an edit that changes fields, their scripts or the calculation order: the edit's -/// script runner reads the document as it now is, and builds its `calculateNow()` list again. -fn invalidate_calculations(cx: &mut EditCtx, doc: &pdfcraft_cos::Document) { +/// Forget cached calculation order and field metadata after changing the form structure. +fn invalidate_form_script_snapshot(cx: &mut EditCtx) { if let Some(js) = cx.js.as_mut() { - js.invalidate_calculations(doc); + js.invalidate_calculations(); + js.invalidate_field_snapshots(); } } @@ -527,15 +535,25 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E pdfcraft_organize::insert_blank_page(doc, *at, *width, *height)?; } Edit::SetInfo { key, value } => pdfcraft_organize::set_info(doc, key, value)?, - Edit::DuplicatePages { pages, at } => pdfcraft_organize::duplicate_pages(doc, pages, *at)?, + Edit::DuplicatePages { pages, at } => { + // A copy of a page cannot be tied back to the marks an earlier redaction of this edit + // applied: text put back under a mark would sit on the copy, unchecked. Fail closed. + if !cx.redaction_marks.is_empty() { + return Err(EditError::Invalid( + "a batch that applies redactions can't also copy pages: the copies can't be checked against the marks; the document was not changed" + .into(), + )); + } + pdfcraft_organize::duplicate_pages(doc, pages, *at)? + } Edit::ReplacePages { pages, name, bytes, src_pages } => { - let src = open_source(name, bytes)?; + let src = crate::sources::open_source_into(name, bytes, doc)?; pdfcraft_organize::replace_pages(doc, pages, &src, src_pages)?; } Edit::SetPageBox { pages, which, spec } => pdfcraft_organize::set_page_box(doc, pages, *which, *spec)?, Edit::SetTransition { pages, spec } => pdfcraft_organize::set_transition(doc, pages, spec)?, Edit::InsertPagesFrom { name, bytes, pages, at, field_conflicts } => { - let src = open_source(name, bytes)?; + let src = crate::sources::open_source_into(name, bytes, doc)?; let pages = match pages { Some(p) => p.clone(), None => (0..pdfcraft_organize::page_count(&src)?).collect(), @@ -544,7 +562,7 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E let title = std::path::Path::new(name).file_stem().map_or_else(|| name.clone(), |s| s.to_string_lossy().into_owned()); let outcome = pdfcraft_organize::insert_file_pages(doc, &src, &title, &pages, *at, *field_conflicts)?; cx.renamed_fields = outcome.renamed_fields; - invalidate_calculations(cx, doc); + invalidate_form_script_snapshot(cx); } Edit::AddBookmark { parent, index, title, page, left, top } => { pdfcraft_organize::add_bookmark_at(doc, parent, *index, title, *page, *left, *top)?; @@ -731,7 +749,10 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E set(doc, typed.as_ref().unwrap_or(value))?; } Edit::ApplyScriptChanges { changes } => match cx.js.as_mut() { - Some(js) => pdfcraft_forms::apply_script_changes(doc, changes, js)?, + Some(js) => { + js.invalidate_field_snapshots(); + pdfcraft_forms::apply_script_changes(doc, changes, js)?; + } None => pdfcraft_forms::apply_script_changes(doc, changes, &mut pdfcraft_forms::NoScripts)?, }, Edit::XfaEvent { som, activity } => { @@ -757,7 +778,14 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E cx.set_highlight(on); cx.xfa_unchanged = ran.map_err(EditError::Invalid)?; } - Edit::SetFieldImage { name, image } => { + Edit::SetFieldImage { name, image } | Edit::SetFieldImageTarget { name, image, .. } => { + let object = match edit { + Edit::SetFieldImageTarget { object, .. } => Some(*object), + _ => None, + }; + if object.is_some_and(|object| !pdfcraft_forms::fields(doc).iter().any(|f| f.name == *name && f.obj == object)) { + return Err(EditError::Invalid("the selected image field is no longer available".into())); + } let (img, _) = embed_image(doc, name, image)?; let px = match &*doc.get(img) { pdfcraft_cos::Object::Stream(s) => (s.dict.int(b"Width").unwrap_or(1) as u32, s.dict.int(b"Height").unwrap_or(1) as u32), @@ -774,7 +802,7 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E let Some(content_type) = image_content_type(image) else { return Err(EditError::Invalid(format!("{name}: an XFA form keeps a PNG, JPEG, GIF, BMP or TIFF picture; choose one of those"))); }; - pdfcraft_forms::set_button_icon(doc, name, img, px)?; + pdfcraft_forms::set_button_icon_target(doc, name, object, img, px)?; let som = pdfcraft_xfa::fields_by_som(doc).into_iter().find(|(_, n)| n == name).map(|(s, _)| s).unwrap_or_else(|| name.clone()); let path = match cx.xfa.as_deref() { Some(tpl) => pdfcraft_xfa::data_path(tpl, &som), @@ -789,40 +817,46 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E cx.xfa_out.errors.extend(r.warnings); xfa_after_data_change(doc, cx, false)?; } else { - pdfcraft_forms::set_button_icon(doc, name, img, px)?; + pdfcraft_forms::set_button_icon_target(doc, name, object, img, px)?; } } Edit::ResetForm { names } => { - pdfcraft_forms::reset(doc, names.as_deref())?; + if let Some(js) = cx.js.as_mut() { + pdfcraft_forms::reset_with_formatting(doc, names.as_deref(), js, !cx.defer_reset_callbacks)?; + } else { + pdfcraft_forms::reset(doc, names.as_deref())?; + } } Edit::AddField { page, rect, kind, name } => { pdfcraft_forms::add_field(doc, *page, *rect, kind, name.as_deref())?; - invalidate_calculations(cx, doc); + invalidate_form_script_snapshot(cx); } Edit::SetFieldProps { name, props } => { pdfcraft_forms::set_props(doc, name, props)?; - invalidate_calculations(cx, doc); + invalidate_form_script_snapshot(cx); } Edit::DuplicateField { name, pages } => { if pdfcraft_forms::duplicate_field(doc, name, pages)? == 0 { return Err(EditError::Form(pdfcraft_forms::FormError::Invalid(format!("{name} is already on those pages")))); } - invalidate_calculations(cx, doc); + invalidate_form_script_snapshot(cx); } Edit::DeleteField { name } => { pdfcraft_forms::delete_field(doc, name)?; - invalidate_calculations(cx, doc); + invalidate_form_script_snapshot(cx); } Edit::DeleteWidget { name, widget } => { pdfcraft_forms::delete_widget(doc, name, *widget)?; - invalidate_calculations(cx, doc); + invalidate_form_script_snapshot(cx); } Edit::SetTabOrder { pages, order } => pdfcraft_forms::set_tab_order(doc, pages, *order)?, Edit::MoveInTabOrder { name, earlier } => pdfcraft_forms::move_in_tab_order(doc, name, *earlier)?, // As after a script edit: the values the new order gives show at once. Edit::SetCalculationOrder { names } => { pdfcraft_forms::set_calculation_order(doc, names)?; - invalidate_calculations(cx, doc); + if let Some(js) = cx.js.as_mut() { + js.invalidate_calculations(); + } match cx.js.as_mut() { Some(js) => pdfcraft_forms::recalculate_with(doc, js)?, None => pdfcraft_forms::recalculate(doc)?, @@ -850,7 +884,9 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E } Edit::SetFieldScript { name, event, script } => { pdfcraft_forms::set_field_script(doc, name, event, script.as_deref())?; - invalidate_calculations(cx, doc); + if let Some(js) = cx.js.as_mut() { + js.invalidate_calculations(); + } match cx.js.as_mut() { Some(js) => pdfcraft_forms::recalculate_with(doc, js)?, None => pdfcraft_forms::recalculate(doc)?, @@ -971,8 +1007,15 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E pdfcraft_edit::add_content(doc, *page, &content)?; } Edit::ApplyRedactions { pages } => { + let model = pdfcraft_model::pages(doc); + cx.redaction_marks.extend( + pdfcraft_redact::marks(doc) + .into_iter() + .filter(|m| pages.as_ref().is_none_or(|p| p.contains(&m.page))) + .filter_map(|m| model.get(m.page).map(|p| (p.obj, m))), + ); let report = pdfcraft_redact::apply(doc, pages.as_deref())?; - cx.redaction_overlays = report.overlays; + cx.redaction_overlays.extend(report.overlays); cx.redaction_elsewhere = Some(report.still_holding); cx.redaction_identification = report.identification; } @@ -1090,15 +1133,40 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E } doc.remove_encryption(); } - Edit::Batch { edits, .. } => { - // A form-script event runs on its own: in a batch, its scripts and those the batch's - // value edits run afterwards (change, exit) would interleave, an exit running twice. - if edits.iter().any(|e| matches!(e, Edit::XfaEvent { .. })) { - return Err(EditError::Invalid("a form script event can't be part of a batch of edits".into())); - } - for e in edits { - run_edit(doc, e, cx)?; + Edit::Batch { edits, .. } => run_batch(doc, edits, cx)?, + } + Ok(()) +} + +/// Apply an edit batch while keeping reset deferral scoped to adjacent script writeback. +fn run_batch(doc: &mut pdfcraft_cos::Document, edits: &[Edit], cx: &mut EditCtx) -> Result<(), EditError> { + // A form-script event runs on its own: in a batch, its scripts and those the batch's + // value edits run afterwards (change, exit) would interleave, an exit running twice. + if edits.iter().any(|e| matches!(e, Edit::XfaEvent { .. })) { + return Err(EditError::Invalid("a form script event can't be part of a batch of edits".into())); + } + // Only contiguous resets immediately preceding script value writeback can + // defer callbacks. Other edits may read calculated values during validation. + let mut script_writeback = vec![false; edits.len()]; + let mut writes_values = false; + for (index, edit) in edits.iter().enumerate().rev() { + match edit { + Edit::ResetForm { .. } => script_writeback[index] = writes_values, + Edit::ApplyScriptChanges { changes } => writes_values = changes.iter().any(|change| change.value.is_some()), + _ => writes_values = false, + } + } + let deferred = cx.defer_reset_callbacks; + for (index, e) in edits.iter().enumerate() { + cx.defer_reset_callbacks = script_writeback[index]; + let result = run_edit(doc, e, cx); + cx.defer_reset_callbacks = deferred; + result?; + if let Some(js) = cx.js.as_mut() { + if js.refused.is_some() { + break; } + js.refresh_after_batch_edit(doc, e); } } Ok(()) diff --git a/crates/engine/src/history.rs b/crates/engine/src/history.rs index 4259e6c721..393c233297 100644 --- a/crates/engine/src/history.rs +++ b/crates/engine/src/history.rs @@ -38,7 +38,11 @@ pub(crate) enum Scope { /// Edits during which field scripts run. pub(crate) fn uses_scripts(edit: &Edit) -> bool { match edit { - Edit::SetFieldValue { .. } | Edit::ApplyScriptChanges { .. } | Edit::SetFieldScript { .. } | Edit::SetCalculationOrder { .. } => true, + Edit::SetFieldValue { .. } + | Edit::ResetForm { .. } + | Edit::ApplyScriptChanges { .. } + | Edit::SetFieldScript { .. } + | Edit::SetCalculationOrder { .. } => true, Edit::Batch { edits, .. } => edits.iter().any(uses_scripts), _ => false, } @@ -77,7 +81,11 @@ pub(crate) fn scope_of(edit: &Edit) -> Scope { | Edit::BorderShapeAnnotation { .. } | Edit::SetAnnotationInfo { .. } => Scope::Comments, Edit::AddLink { .. } | Edit::SetLink { .. } | Edit::DeleteLink { .. } | Edit::RemoveLinks { .. } | Edit::AddLinks { .. } => Scope::Links, - Edit::SetFieldValue { .. } | Edit::ResetForm { .. } | Edit::SetFieldImage { .. } | Edit::ApplyScriptChanges { .. } => Scope::Form, + Edit::SetFieldValue { .. } + | Edit::ResetForm { .. } + | Edit::SetFieldImage { .. } + | Edit::SetFieldImageTarget { .. } + | Edit::ApplyScriptChanges { .. } => Scope::Form, // Page content, resources and added content/marks only: the page tree, annotations, // fields, links and document metadata are untouched (see `refresh_content`). Edit::MoveObjects { .. } diff --git a/crates/engine/src/js.rs b/crates/engine/src/js.rs index 13939aad54..bde0d5a02c 100644 --- a/crates/engine/src/js.rs +++ b/crates/engine/src/js.rs @@ -11,6 +11,77 @@ pub use pdfcraft_js::{Limits, Message, MessageButtons, MessageIcon, Outcome, Pan use crate::{DocId, Edit, EditError, Session}; +/// Reset writeback does two full passes (preview and commit); reject amplification before either. +const MAX_SCRIPT_RESETS: usize = pdfcraft_js::MAX_RESET_CALLS; +const MAX_SCRIPT_RESET_SELECTORS: usize = 4096; +const MAX_SCRIPT_RESET_WORK: usize = 1 << 24; + +/// Bound request count, selector storage/scans and estimated form/calculation visits before cloning a PDF. +fn validate_reset_work(fields: &[Field], states: &[pdfcraft_js::FieldState], requests: &[Request]) -> Result<(), EditError> { + let invalid = || EditError::Invalid("script reset work limit exceeded; use fewer reset calls or combine field selections".into()); + let (mut resets, mut selectors, mut bytes) = (0usize, 0usize, 0usize); + for request in requests { + if let Request::Reset(names) = request { + resets = resets.saturating_add(1); + selectors = selectors.saturating_add(names.len()); + if resets > MAX_SCRIPT_RESETS || selectors > MAX_SCRIPT_RESET_SELECTORS { + return Err(invalid()); + } + bytes = names.iter().fold(bytes, |n, name| n.saturating_add(name.len())); + } + } + if resets == 0 { + return Ok(()); + } + let count = fields.len().max(states.len()); + let (mut calculations, mut references, mut source_bytes) = (0usize, 0usize, 0usize); + for field in fields { + use pdfcraft_forms::af::Calculate; + if field.actions.calculate != Calculate::None || field.actions.scripts.calculate.is_some() { + calculations = calculations.saturating_add(1); + } + references = references.saturating_add(match &field.actions.calculate { + Calculate::Simple { fields, .. } => fields.len(), + Calculate::Notation(source) | Calculate::Custom(source) => source.len(), + Calculate::None => 0, + }); + source_bytes = source_bytes.saturating_add(field.actions.scripts.calculate.as_ref().map_or(0, String::len)); + } + let name_bytes = + fields.iter().fold(0usize, |n, f| n.saturating_add(f.name.len())).max(states.iter().fold(0usize, |n, f| n.saturating_add(f.name.len()))); + let widgets = fields.iter().fold(0usize, |n, f| n.saturating_add(f.widgets.len())); + let per_pass = count + .saturating_mul(1usize.saturating_add(calculations).saturating_add(references)) + .saturating_add(source_bytes) + .saturating_add(name_bytes) + .saturating_add(widgets); + // Bound the expansion scan before doing it, then charge only the actual selections + // for the forms layer's membership and validation scans in both reset passes. + let comparisons = count.saturating_mul(selectors).saturating_add(count.saturating_mul(bytes)); + let mut work = resets.saturating_mul(per_pass).saturating_add(comparisons).saturating_add(bytes); + if work > MAX_SCRIPT_RESET_WORK / 2 { + return Err(invalid()); + } + for request in requests { + let Request::Reset(names) = request else { continue }; + if names.is_empty() { + continue; + } + for field in states.iter().filter(|f| reset_name_matches(&f.name, names)) { + work = work.saturating_add(count.saturating_mul(field.name.len().saturating_add(1)).saturating_mul(2)); + if work > MAX_SCRIPT_RESET_WORK / 2 { + return Err(invalid()); + } + } + } + Ok(()) +} + +/// Match an exact reset selector or one of its dotted descendants. +fn reset_name_matches(name: &str, selectors: &[String]) -> bool { + selectors.iter().any(|selector| name == selector || name.strip_prefix(selector).is_some_and(|rest| rest.starts_with('.'))) +} + /// A field gaining or losing focus ([`Session::field_focus`]). #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum FieldFocus { @@ -206,23 +277,90 @@ fn display(doc: &pdfcraft_cos::Document, f: &Field) -> i32 { } } -/// A form field as scripts see it. -pub fn field_state(doc: &pdfcraft_cos::Document, f: &Field) -> pdfcraft_js::FieldState { - field_state_with_display(f, display(doc, f)) +/// Document-local PDF field identity used by snapshots, tools and native hit tests. +pub fn field_object_id(object: pdfcraft_cos::ObjRef) -> u64 { + (u64::from(object.num) << 16) | u64::from(object.generation) } -fn field_state_with_display(f: &Field, display: i32) -> pdfcraft_js::FieldState { +/// Check that a supplied field identity belongs to the requested field name. +fn validate_script_target(target: Option<&str>, identity: Option, fields: &[Field]) -> Result<(), EditError> { + if let Some(id) = identity + && !fields.iter().any(|f| Some(f.name.as_str()) == target && field_object_id(f.obj) == id) + { + return Err(EditError::Invalid("script field identity does not match the named field in this document".into())); + } + Ok(()) +} + +/// Snapshot a current form field using page geometry already read once for this runner. +/// Values/appearances come from `doc` and `f`, never from the cached calculation plan. +pub fn field_state(doc: &pdfcraft_cos::Document, f: &Field, geometry: &pdfcraft_js::DocInfo) -> pdfcraft_js::FieldState { let mut s = pdfcraft_js::FieldState::new(f.name.clone(), kind(f.kind), f.value.clone()); + s.object_id = Some(field_object_id(f.obj)); s.default = f.default.clone(); + s.default_known = pdfcraft_forms::default_value_known(doc, f); s.readonly = f.read_only(); s.required = f.has(pdfcraft_forms::flags::REQUIRED); - s.display = display; - s.rect = f.widgets.first().map(|w| w.rect); + s.display = display(doc, f); + let look = pdfcraft_forms::look(doc, f); + s.text_color = Some(std::iter::once("RGB".to_string()).chain(look.text.map(|v| v.to_string())).collect()); s.char_limit = f.max_len; s.options = match f.kind { FieldKind::CheckBox | FieldKind::Radio => f.widgets.iter().filter_map(|w| w.on_state.clone()).map(|o| (o.clone(), o)).collect(), _ => f.options.clone(), }; + s.caption = f.widgets.first().and_then(|w| w.caption.clone()); + s.caption_mixed = f.widgets.iter().any(|w| w.caption != s.caption); + s.line_width = look.width; + s.border_style = match look.style { + pdfcraft_forms::BorderStyle::Solid => "solid", + pdfcraft_forms::BorderStyle::Dashed => "dashed", + pdfcraft_forms::BorderStyle::Beveled => "beveled", + pdfcraft_forms::BorderStyle::Inset => "inset", + pdfcraft_forms::BorderStyle::Underline => "underline", + } + .into(); + s.stroke_color = Some( + look.border + .map_or_else(|| std::sync::Arc::from(["T".into()]), |rgb| std::iter::once("RGB".into()).chain(rgb.map(|v| v.to_string())).collect()), + ); + s.fill_color = Some( + look.fill.map_or_else(|| std::sync::Arc::from(["T".into()]), |rgb| std::iter::once("RGB".into()).chain(rgb.map(|v| v.to_string())).collect()), + ); + s.user_name = doc + .get(f.obj) + .as_dict() + .and_then(|d| d.get(b"TU").map(|v| doc.resolve(v))) + .and_then(|v| v.as_string().map(|v| v.to_text())) + .unwrap_or_default(); + s.text_size = + f.da.split_whitespace() + .collect::>() + .windows(2) + .find_map(|p| (p.get(1) == Some(&"Tf")).then(|| p.first()?.parse::().ok()).flatten()) + .unwrap_or(0.0); + if let Some(w) = f.widgets.first() { + let page = w.page.unwrap_or(0); + s.page_rotation = geometry.page_rotations.get(page).copied().unwrap_or(0); + s.page_crop = geometry.page_boxes.get(page).map_or([0.0; 4], |boxes| boxes[1]); + s.rect = Some(pdfcraft_js::rotate_rect(w.rect, s.page_crop, s.page_rotation)); + s.icon = doc + .get(w.obj) + .as_dict() + .and_then(|d| d.get(b"MK").map(|v| doc.resolve(v))) + .and_then(|v| v.as_dict().and_then(|d| d.get(b"I")).and_then(pdfcraft_cos::Object::as_ref)) + .map(|r| ((r.num as u64) << 16) | r.generation as u64); + } + + s.icon_mixed = f.widgets.iter().any(|w| { + let icon = doc + .get(w.obj) + .as_dict() + .and_then(|d| d.get(b"MK").map(|v| doc.resolve(v))) + .and_then(|v| v.as_dict().and_then(|d| d.get(b"I")).and_then(pdfcraft_cos::Object::as_ref)) + .map(field_object_id); + icon != s.icon + }); s } @@ -241,55 +379,191 @@ fn changes( for field in before { by_name.entry(field.name.as_str()).or_insert(field); } + let by_id: std::collections::HashMap<_, _> = before.iter().filter_map(|f| f.object_id.map(|id| (id, f))).collect(); + let mut names = std::collections::HashSet::new(); + let duplicates: std::collections::HashSet<_> = + before.iter().filter_map(|f| (!names.insert(f.name.as_str())).then_some(f.name.as_str())).collect(); after .iter() .filter_map(|a| { - let b = by_name.get(a.name.as_str())?; + let b = if let Some(id) = a.object_id { by_id.get(&id)? } else { by_name.get(a.name.as_str())? }; let c = FieldChange { name: a.name.clone(), + object: a + .object_id + .filter(|_| duplicates.contains(a.name.as_str())) + .map(|id| pdfcraft_cos::ObjRef::new((id >> 16) as u32, id as u16)), value: (a.value != b.value).then(|| a.value.clone()), formatted: a.value != b.value && formatted.contains(&a.name), read_only: (a.readonly != b.readonly).then_some(a.readonly), required: (a.required != b.required).then_some(a.required), display: (a.display != b.display).then_some(a.display), - rect: (a.rect != b.rect).then_some(a.rect).flatten(), + rect: (a.rect != b.rect).then(|| a.rect.map(|r| pdfcraft_js::unrotate_rect(r, b.page_crop, b.page_rotation))).flatten(), + stroke_color: a.stroke_color.as_deref().filter(|_| a.stroke_color != b.stroke_color).map(<[_]>::to_vec), + text_size: (a.text_size != b.text_size).then_some(a.text_size), + line_width: (a.line_width != b.line_width).then_some(a.line_width), + border_style: (a.border_style != b.border_style).then(|| a.border_style.clone()), + user_name: (a.user_name != b.user_name).then(|| a.user_name.clone()), + icon: (a.icon != b.icon || (a.icon_written && b.icon_mixed)).then_some(a.icon), + text_color: a.text_color.as_deref().filter(|_| a.text_color != b.text_color).map(<[_]>::to_vec), + fill_color: a.fill_color.as_deref().filter(|_| a.fill_color != b.fill_color).map(<[_]>::to_vec), + caption: (a.caption != b.caption || (a.caption_written && b.caption_mixed)).then(|| a.caption.clone()).flatten(), + options: (a.options != b.options).then(|| a.options.clone()), + actions: a.actions.clone(), }; - (c.value.is_some() || c.read_only.is_some() || c.required.is_some() || c.display.is_some() || c.rect.is_some()).then_some(c) + (c.value.is_some() + || c.read_only.is_some() + || c.required.is_some() + || c.display.is_some() + || c.caption.is_some() + || c.text_color.is_some() + || c.fill_color.is_some() + || c.rect.is_some() + || c.stroke_color.is_some() + || c.text_size.is_some() + || c.line_width.is_some() + || c.border_style.is_some() + || c.user_name.is_some() + || c.icon.is_some() + || c.options.is_some() + || !c.actions.is_empty()) + .then_some(c) }) .collect() } /// Runs field scripts with the document's object model and keeps what they print or ask for. +pub type Runtime = std::sync::Arc>; + +/// Invalidate pending results while retaining the document's native worker reservation. +pub(crate) fn replace_runtime(runtime: &mut Runtime) { + let Ok(mut old) = runtime.lock() else { return }; + let next = old.fresh_realm(); + drop(old); + *runtime = std::sync::Arc::new(std::sync::Mutex::new(next)); +} + pub struct JsRunner { + pub runtime: Runtime, pub doc: pdfcraft_js::DocInfo, pub doc_scripts: Vec, pub output: JsOutput, - /// Immutable document snapshot from the start of the edit (for widget `display`). - cos: pdfcraft_cos::Document, - // `cos` is the same immutable pre-edit snapshot for the runner's lifetime. Cache only - // its scalar display readings, not COS objects: large forms exceed the object cache and - // otherwise reparse every widget for every calculation. Values/flags still come from - // the live `fields` snapshot on each call. A new edit gets a new runner and cache. - displays: std::collections::HashMap, - calculations: Option>>, + pub(crate) refused: Option, + pub(crate) callbacks_touched_realm: bool, + #[cfg(not(target_arch = "wasm32"))] + callback_started: Option, + /// Source/order cache for this edit only; each callback still snapshots current field values. + calculations: Option>, + // Cache at most 4,096 field identities in the current edit. Entries only survive value-only + // callbacks. Appearance and structural edits clear them before the next snapshot. + snapshot_fields: std::collections::HashMap, + // The last callback's snapshot. A calculation chain changes one or two values per callback, + // so the next callback patches those entries instead of rebuilding every field. + last_snapshot: Option>>, } impl JsRunner { + pub(crate) fn invalidate_field_snapshots(&mut self) { + self.snapshot_fields.clear(); + self.last_snapshot = None; + } + + fn callback_fields(&mut self, doc: &pdfcraft_cos::Document, fields: &[Field]) -> std::sync::Arc> { + if let Some(snapshot) = self.last_snapshot.as_mut() + && snapshot.len() == fields.len() + && snapshot.iter().zip(fields).all(|(state, field)| state.object_id == Some(field_object_id(field.obj))) + { + let stale = |state: &pdfcraft_js::FieldState, field: &Field| { + state.name != field.name + || state.value != field.value + || state.readonly != field.read_only() + || state.required != field.has(pdfcraft_forms::flags::REQUIRED) + }; + if snapshot.iter().zip(fields).any(|(state, field)| stale(state, field)) { + for (state, field) in std::sync::Arc::make_mut(snapshot).iter_mut().zip(fields) { + if stale(state, field) { + state.name.clone_from(&field.name); + state.value.clone_from(&field.value); + state.readonly = field.read_only(); + state.required = field.has(pdfcraft_forms::flags::REQUIRED); + } + } + } + if let Ok(mut runtime) = self.runtime.lock() { + runtime.stamp_fields(snapshot); + } + return std::sync::Arc::clone(snapshot); + } + let states = fields + .iter() + .map(|field| { + if self.snapshot_fields.len() >= 4096 && !self.snapshot_fields.contains_key(&field.obj) { + return field_state(doc, field, &self.doc); + } + let template = self.snapshot_fields.entry(field.obj).or_insert_with(|| { + let mut state = field_state(doc, field, &self.doc); + state.value.clear(); + state + }); + let mut state = template.clone(); + state.name.clone_from(&field.name); + state.value.clone_from(&field.value); + state.readonly = field.read_only(); + state.required = field.has(pdfcraft_forms::flags::REQUIRED); + state + }) + .collect(); + let mut states = std::sync::Arc::new(states); + if let Ok(mut runtime) = self.runtime.lock() { + runtime.stamp_fields(&mut states); + } + self.last_snapshot = Some(std::sync::Arc::clone(&states)); + states + } + + /// Forget the per-edit calculation plan after a calculation source is changed. + pub(crate) fn invalidate_calculations(&mut self) { + self.calculations = None; + } + + /// Refresh structural/document sources between batch children, retaining the realm, + /// accumulated output and the current viewer snapshot. Ordinary value edits reuse caches. + pub(crate) fn refresh_after_batch_edit(&mut self, cos: &pdfcraft_cos::Document, edit: &Edit) { + if matches!(edit, Edit::SetFieldValue { .. } | Edit::ResetForm { .. } | Edit::ApplyScriptChanges { .. } | Edit::Batch { .. }) { + return; + } + let mut current = Self::new(cos, &self.doc.file_name, self.doc.runtime_highlight); + current.doc.zoom = self.doc.zoom; + current.doc.page = self.doc.page; + self.doc = current.doc; + self.doc_scripts = current.doc_scripts; + self.calculations = None; + self.invalidate_field_snapshots(); + } + /// A runner for `cos`: what scripts read about the document (pages, info, whether it is /// an XFA form) and its document-level scripts, read once. `highlight`: whether the viewer /// highlights form fields (`app.runtimeHighlight`). pub fn new(cos: &pdfcraft_cos::Document, file_name: &str, highlight: bool) -> JsRunner { let xfa = pdfcraft_xfa::xfa_shape(cos).is_some(); + #[cfg(test)] + SNAPSHOT_WORK.with(|c| { + let (g, p) = c.get(); + c.set((g + 1, p)); + }); let pages = pdfcraft_model::pages(cos); let doc = pdfcraft_js::DocInfo { file_name: file_name.to_string(), num_pages: pages.len(), page: 0, info: info(cos), - highlight, page_boxes: pdfcraft_organize::page_boxes(cos).unwrap_or_default(), page_rotations: pages.iter().map(|page| page.rotation(cos)).collect(), xfa, + zoom: 100.0, + runtime_highlight: highlight, + icon_epoch: 0, + icon_versions: Default::default(), }; // Once XFA owns the form, the PDF container's scripts belong to its fallback view. // Keep them in the file and in Document JavaScripts, but do not initialize an @@ -297,33 +571,40 @@ impl JsRunner { // Packets alone are not enough: static XFA and an unlaid-out shell keep their scripts. let xfa_active = xfa && pdfcraft_xfa::existing_layout(cos).is_some(); let doc_scripts = if xfa_active { Vec::new() } else { pdfcraft_forms::document_scripts(cos) }; - JsRunner { doc, doc_scripts, output: JsOutput::default(), cos: cos.clone(), displays: std::collections::HashMap::new(), calculations: None } - } - - fn calculations(&mut self, fields: &[Field]) -> std::sync::Arc> { - if let Some(calculations) = &self.calculations { - return std::sync::Arc::clone(calculations); + JsRunner { + doc, + doc_scripts, + output: JsOutput::default(), + refused: None, + callbacks_touched_realm: false, + #[cfg(not(target_arch = "wasm32"))] + callback_started: None, + runtime: Default::default(), + calculations: None, + snapshot_fields: Default::default(), + last_snapshot: None, } - let calculations = std::sync::Arc::new(calculation_scripts(&self.cos, fields)); - self.calculations = Some(std::sync::Arc::clone(&calculations)); - calculations } - pub(crate) fn invalidate_calculations(&mut self, cos: &pdfcraft_cos::Document) { - self.cos = cos.clone(); - self.displays.clear(); - self.calculations = None; + #[cfg(not(target_arch = "wasm32"))] + fn callback_time_left(&mut self) -> std::time::Duration { + std::time::Duration::from_secs(10).saturating_sub(self.callback_started.get_or_insert_with(std::time::Instant::now).elapsed()) } } -fn calculation_scripts(cos: &pdfcraft_cos::Document, fields: &[Field]) -> Vec<(String, String)> { +/// Index calculation sources by PDF object identity, retaining their matching snapshot positions. +fn calculation_scripts(cos: &pdfcraft_cos::Document, fields: &[Field]) -> Vec<(usize, String)> { #[cfg(test)] - CALCULATION_SCRIPT_BUILDS.with(|calls| calls.set(calls.get().saturating_add(1))); + SNAPSHOT_WORK.with(|c| { + let (g, p) = c.get(); + c.set((g, p + 1)); + }); + let by_object: std::collections::HashMap<_, _> = fields.iter().enumerate().map(|(i, f)| (f.obj, (i, f))).collect(); pdfcraft_forms::calculation_order(cos, fields) .fields .iter() .filter_map(|ordered| { - let field = fields.iter().find(|f| f.name == ordered.name)?; + let (index, field) = by_object.get(&ordered.obj)?; let script = field.actions.scripts.calculate.clone().or_else(|| { let source = pdfcraft_forms::af::calculate_js(&field.actions.calculate)?; Some(match &field.actions.calculate { @@ -331,29 +612,11 @@ fn calculation_scripts(cos: &pdfcraft_cos::Document, fields: &[Field]) -> Vec<(S _ => source, }) })?; - Some((field.name.clone(), script)) + Some((*index, script)) }) .collect() } -#[cfg(test)] -thread_local! { - pub(crate) static CALCULATION_SCRIPT_BUILDS: std::cell::Cell = const { std::cell::Cell::new(0) }; -} - -fn calls_calculate_now(script: &str, doc_scripts: &[String]) -> bool { - script.contains("calculateNow") || doc_scripts.iter().any(|source| source.contains("calculateNow")) -} - -fn calculations_for_script( - cos: &pdfcraft_cos::Document, - fields: &[Field], - script: &str, - doc_scripts: &[String], -) -> std::sync::Arc> { - if calls_calculate_now(script, doc_scripts) { std::sync::Arc::new(calculation_scripts(cos, fields)) } else { std::sync::Arc::new(Vec::new()) } -} - /// The field highlight the last of `requests` that sets it asks for (`app.runtimeHighlight`). fn last_highlight(requests: &[Request]) -> Option { requests.iter().rev().find_map(|r| match r { @@ -374,67 +637,140 @@ fn info(cos: &pdfcraft_cos::Document) -> Vec<(String, String)> { } impl Scripts for JsRunner { + fn stopped(&self) -> bool { + self.refused.is_some() + } + /// Run field `target`'s script for `event` over the form's fields, keeping what it showed and /// asked for; a refusal carries its last message. - fn run(&mut self, event: FieldEvent, script: &str, target: &Field, value: &str, fields: &[Field]) -> ScriptResult { - // As in Acrobat, a failing script leaves the value alone (the error goes to the console). - self.run_logged(event, script, target, value, fields, true).unwrap_or_else(|| ScriptResult { - rc: true, - value: value.to_string(), - value_set: false, - changes: Vec::new(), - message: None, - }) - } - - fn rerun_with_english_dates(&mut self, event: FieldEvent, script: &str, target: &Field, value: &str, fields: &[Field]) -> Option { - pdfcraft_js::with_english_printd(true, || self.run_logged(event, script, target, value, fields, false)) - } -} - -impl JsRunner { - /// [`Scripts::run`], keeping what the script showed and asked for only when `log`; `None` - /// when the script failed. - fn run_logged(&mut self, event: FieldEvent, script: &str, target: &Field, value: &str, fields: &[Field], log: bool) -> Option { - let states: Vec<_> = fields - .iter() - .map(|f| { - let display = match f.widgets.first() { - Some(w) => *self.displays.entry(w.obj).or_insert_with(|| display(&self.cos, f)), - None => pdfcraft_js::DISPLAY_VISIBLE, - }; - field_state_with_display(f, display) - }) - .collect(); - let states = std::sync::Arc::new(states); - let ev = pdfcraft_js::Event::field(event.name(), &target.name, value); - let calculations = if calls_calculate_now(script, &self.doc_scripts) { self.calculations(fields) } else { std::sync::Arc::new(Vec::new()) }; - let o = pdfcraft_js::run_shared_calculations_shared( - script, - &ev, - &self.doc, - std::sync::Arc::clone(&states), - &self.doc_scripts, - &calculations, - Limits::default(), - ); - if log { - self.output.absorb(&o); + fn run(&mut self, event: FieldEvent, script: &str, target: &Field, value: &str, fields: &[Field], doc: &pdfcraft_cos::Document) -> ScriptResult { + let unchanged = || ScriptResult { rc: true, value: value.to_string(), value_set: false, changes: Vec::new(), message: None }; + if self.stopped() { + return unchanged(); + } + #[cfg(not(target_arch = "wasm32"))] + if self.callback_time_left().is_zero() { + self.refused = Some("form script callbacks exceeded their shared execution budget".into()); + return unchanged(); + } + let states = self.callback_fields(doc, fields); + let mut ev = pdfcraft_js::Event::field(event.name(), &target.name, value); + ev.target_id = Some((u64::from(target.obj.num) << 16) | u64::from(target.obj.generation)); + let calculations = self.calculations.get_or_insert_with(|| calculation_scripts(doc, fields)).clone(); + let job = pdfcraft_js::session::Job { + script: script.into(), + event: ev, + doc: self.doc.clone(), + fields: states, + doc_scripts: self.doc_scripts.clone(), + calculations, + }; + #[cfg(not(target_arch = "wasm32"))] + let remaining = self.callback_time_left(); + let o = match self.runtime.lock() { + #[cfg(not(target_arch = "wasm32"))] + Ok(mut runtime) if !remaining.is_zero() => runtime.run_with_limit(job, &[], remaining), + #[cfg(not(target_arch = "wasm32"))] + Ok(_) => Outcome { + execution_refused: true, + realm_untouched: true, + error: Some("form script callbacks exceeded their shared execution budget".into()), + ..Default::default() + }, + #[cfg(target_arch = "wasm32")] + Ok(mut runtime) => runtime.run(job, &[]), + Err(_) => Outcome { execution_refused: true, error: Some("document script runtime is unavailable".into()), ..Default::default() }, + }; + self.callbacks_touched_realm |= !o.realm_untouched; + self.output.absorb(&o); + if o.execution_refused || o.reset_limit_exceeded || o.calculation_limit_exceeded { + self.refused = Some(o.error.clone().unwrap_or_else(|| "form script exceeded its execution budget".into())); + } + if o.error.is_some() || o.reset_limit_exceeded || o.calculation_limit_exceeded { + // As in Acrobat, a failing script leaves the value alone (the error goes to the console). + return unchanged(); } // The edit's later scripts read the field highlight this one set. if let Some(on) = last_highlight(&o.requests) { - self.doc.highlight = on; + self.doc.runtime_highlight = on; } - if o.error.is_some() { - return None; + // Most field callbacks only set event.value. Hand the unique snapshot to the + // session so stamping field identities does not clone every field. Reconstruct + // the baseline only for a callback that also wrote to Field objects. + let changes = if o.changed.is_empty() { + Vec::new() + } else { + let baseline = self.callback_fields(doc, fields); + changes(&baseline, &o.changed, &o.formatted) + }; + if changes.iter().any(|change| { + *change != FieldChange { name: change.name.clone(), object: change.object, value: change.value.clone(), ..Default::default() } + }) { + self.invalidate_field_snapshots(); } - Some(ScriptResult { + ScriptResult { rc: o.rc, value: o.value.clone(), value_set: o.value_set, - changes: changes(&states, &o.changed, &o.formatted), + changes, message: (!o.rc).then(|| o.alerts.last().map(|m| m.text.clone())).flatten(), - }) + } + } + + /// Retry appearance formatting in isolation so the font fallback cannot replay persistent side effects. + fn rerun_with_english_dates( + &mut self, + event: FieldEvent, + script: &str, + target: &Field, + value: &str, + fields: &[Field], + doc: &pdfcraft_cos::Document, + ) -> Option { + if self.stopped() { + return None; + } + #[cfg(not(target_arch = "wasm32"))] + if self.callback_time_left().is_zero() { + self.refused = Some("form script callbacks exceeded their shared execution budget".into()); + return None; + } + let states = self.callback_fields(doc, fields); + let mut ev = pdfcraft_js::Event::field(event.name(), &target.name, value); + ev.target_id = Some(field_object_id(target.obj)); + let calculations = self.calculations.get_or_insert_with(|| calculation_scripts(doc, fields)); + #[cfg(target_arch = "wasm32")] + let o = pdfcraft_js::with_english_printd(true, || { + pdfcraft_js::run_shared_calculations(script, &ev, &self.doc, states, &self.doc_scripts, calculations, Limits::default()) + }); + #[cfg(not(target_arch = "wasm32"))] + let o = { + let job = pdfcraft_js::session::Job { + script: script.into(), + event: ev, + doc: self.doc.clone(), + fields: states, + doc_scripts: self.doc_scripts.clone(), + calculations: calculations.clone(), + }; + let remaining = self.callback_time_left(); + let outcome = if remaining.is_zero() { + Outcome { execution_refused: true, ..Default::default() } + } else if let Ok(mut runtime) = self.runtime.lock() { + runtime.run_english_retry(job, remaining) + } else { + Outcome { execution_refused: true, error: Some("document script runtime is unavailable".into()), ..Default::default() } + }; + if outcome.execution_refused { + self.refused = Some(outcome.error.unwrap_or_else(|| "form script callbacks exceeded their shared execution budget".into())); + return None; + } + outcome + }; + if o.error.is_some() || o.reset_limit_exceeded || o.calculation_limit_exceeded { + return None; + } + Some(ScriptResult { rc: o.rc, value: o.value.clone(), value_set: o.value_set, changes: Vec::new(), message: None }) } } @@ -479,10 +815,50 @@ impl SignEvent { } } +/// A PDF document action dispatched by the host through its existing AcroForm session. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum DocumentScriptEvent { + CatalogOpen, + WillSave, + WillPrint, +} + +impl DocumentScriptEvent { + fn name(self) -> &'static str { + match self { + Self::CatalogOpen => "Open", + Self::WillSave => "WillSave", + Self::WillPrint => "WillPrint", + } + } + + /// Where the catalog keeps this event's actions. + fn trigger(self) -> pdfcraft_forms::DocumentTrigger { + match self { + Self::CatalogOpen => pdfcraft_forms::DocumentTrigger::Open, + Self::WillSave => pdfcraft_forms::DocumentTrigger::WillSave, + Self::WillPrint => pdfcraft_forms::DocumentTrigger::WillPrint, + } + } +} + +/// Keep only what a script that ran unasked (on opening, before saving or printing) may ask +/// the viewer for: highlighting, zoom, focus and the page shown. Opening a URL, a file, mail or +/// a dialog needs a click. +fn unasked_requests(requests: &mut Vec) { + requests + .retain(|r| matches!(r, Request::Highlight(_) | Request::Zoom(_) | Request::Focus(_) | Request::FocusField { .. } | Request::GoToPage(_))); +} + impl Session { /// Preferences ▸ JavaScript ▸ Enable Acrobat JavaScript (on by default). pub fn set_javascript(&mut self, on: bool) { self.js_off = !on; + if !on { + for doc in &mut self.docs { + replace_runtime(&mut doc.js_runtime); + } + } } /// Whether JavaScript is on (Preferences ▸ JavaScript ▸ Enable Acrobat JavaScript). @@ -523,13 +899,33 @@ impl Session { /// change runs it) isn't run a second time. Returns whether the document changed; what the /// scripts showed or asked for is in [`Session::take_js_output`]. pub fn field_focus(&mut self, id: DocId, name: &str, widget: usize, focus: FieldFocus) -> Result { + self.field_focus_target(id, name, None, widget, focus) + } + + /// [`Session::field_focus`] for the field object `target` ([`field_object_id`]) when given: + /// of fields sharing `name`, the one that gained or lost focus runs its own script. + pub fn field_focus_target(&mut self, id: DocId, name: &str, target: Option, widget: usize, focus: FieldFocus) -> Result { if self.js_off { return Ok(false); } let doc = self.doc_mut(id)?; - let Some(tpl) = doc.xfa_template.clone().filter(|t| pdfcraft_xfa::has_focus_scripts(t)) else { return Ok(false) }; + let Some(tpl) = doc.xfa_template.clone().filter(|t| pdfcraft_xfa::has_focus_scripts(t)) else { + // An AcroForm widget's own On Focus / On Blur JavaScript (#269). + let trigger = match focus { + FieldFocus::Enter => pdfcraft_forms::Trigger::OnFocus, + FieldFocus::Exit => pdfcraft_forms::Trigger::OnBlur, + }; + return self.field_trigger_target(id, name, target, widget, trigger); + }; let Some(cos) = doc.editor.as_ref().map(|e| &e.cos) else { return Ok(false) }; - let Some(som) = doc.form.iter().find(|f| f.name == name).and_then(|f| crate::xfa::widget_som(cos, f, widget)) else { return Ok(false) }; + let Some(som) = doc + .form + .iter() + .find(|f| f.name == name && target.is_none_or(|t| field_object_id(f.obj) == t)) + .and_then(|f| crate::xfa::widget_som(cos, f, widget)) + else { + return Ok(false); + }; let activity = match focus { FieldFocus::Enter => "enter", FieldFocus::Exit => "exit", @@ -547,6 +943,85 @@ impl Session { Ok(self.get(id).is_some_and(|d| d.generation != before)) } + /// Widget `widget` (0-based) of AcroForm field `name` in document `id` saw `trigger`: the + /// pointer entered, left, pressed or released it, or it gained or lost the focus. Run that + /// widget's JavaScript action for the trigger, if it has one, in the document's realm, with + /// `event.type` "Field" and `event.name` "Mouse Enter", "Mouse Exit", "Mouse Down", "Mouse + /// Up", "Focus" or "Blur" (#269, ported from #344), then those of the actions chained after + /// it (`/Next`). What each action changes is one undoable edit. + /// Returns whether the document changed; what the script showed or asked for is in + /// [`Session::take_js_output`]. Pointer and focus scripts run unasked, so they may not + /// print, open Save As or show a panel; Mouse Down and Mouse Up come from a click and may. + /// Nothing runs on an XFA form (its own events run instead), with JavaScript off, or for a + /// widget with no JavaScript for the trigger. + pub fn field_trigger(&mut self, id: DocId, name: &str, widget: usize, trigger: pdfcraft_forms::Trigger) -> Result { + self.field_trigger_target(id, name, None, widget, trigger) + } + + /// [`Session::field_trigger`] for the field object `target` ([`field_object_id`]) when given: + /// of fields sharing `name`, the one the pointer is on runs its own script. + pub fn field_trigger_target( + &mut self, + id: DocId, + name: &str, + target: Option, + widget: usize, + trigger: pdfcraft_forms::Trigger, + ) -> Result { + use pdfcraft_forms::Trigger; + if self.js_off { + return Ok(false); + } + let doc = self.get(id).ok_or(EditError::NoDocument)?; + if doc.info.xfa.is_some() { + return Ok(false); + } + let Some(cos) = doc.editor.as_ref().map(|e| &e.cos) else { return Ok(false) }; + let field = doc + .form + .iter() + .find(|f| f.name == name && target.is_none_or(|t| field_object_id(f.obj) == t)) + .ok_or_else(|| EditError::Invalid(format!("there is no field named {name:?}")))?; + let Some(w) = field.widgets.get(widget) else { return Ok(false) }; + let scripts = pdfcraft_forms::widget_scripts(cos, w.obj, trigger); + if scripts.is_empty() { + return Ok(false); + } + let target_id = Some(field_object_id(field.obj)); + let event = match trigger { + Trigger::OnFocus => "Focus", + Trigger::OnBlur => "Blur", + other => other.label(), + }; + let before = doc.generation; + let mut out = JsOutput::default(); + // Each action of a /Next chain runs on its own, as Acrobat runs them: one that fails + // doesn't stop the next. + for script in scripts { + let o = match self.run_javascript_job(id, &script, (Some(name), target_id), &[], Some(event), false) { + Ok(o) => o, + Err(e) => { + out.errors.push(e.to_string()); + continue; + } + }; + if matches!(trigger, Trigger::MouseUp | Trigger::MouseDown) { + out.append(JsOutput { + alerts: o.alerts, + console: o.console, + requests: o.requests, + errors: o.error.into_iter().chain(o.doc_errors.iter().cloned()).collect(), + doc_errors: o.doc_errors, + }); + } else { + out.absorb(&o); + } + } + let doc = self.doc_mut(id)?; + doc.js_output.append(out); + Ok(doc.generation != before) + } + /// Run signature field `field`'s XFA signing scripts for `event` in document `id`: `preSign` /// before it is signed, `postSign` after, as one undoable step, as a form's signing events run /// (DA 31's dates the signature and locks the date). `Ok(false)`, with nothing changed, when @@ -582,16 +1057,122 @@ impl Session { /// the console (no target). Field changes and `resetForm` are applied as one undoable step; /// everything else the script asked for is returned. pub fn run_javascript(&mut self, id: DocId, script: &str, target: Option<&str>) -> Result { + self.run_javascript_answers(id, script, target, &[]) + } + + /// Run a button/console event with ordered headless prompt answers and commit field changes as one undo step. + pub fn run_javascript_answers( + &mut self, + id: DocId, + script: &str, + target: Option<&str>, + answers: &[Option], + ) -> Result { + self.run_javascript_answers_target(id, script, target, None, answers) + } + /// Run with an optional exact PDF field identity; name-only callers keep first-match behavior. + pub fn run_javascript_target(&mut self, id: DocId, script: &str, target: Option<&str>, target_id: Option) -> Result { + self.run_javascript_answers_target(id, script, target, target_id, &[]) + } + /// Exact-target counterpart of run_javascript_answers, including deterministic prompt replies. + pub fn run_javascript_answers_target( + &mut self, + id: DocId, + script: &str, + target: Option<&str>, + target_id: Option, + answers: &[Option], + ) -> Result { + self.run_javascript_job(id, script, (target, target_id), answers, None, false) + } + + pub(crate) fn run_opening_javascript(&mut self, id: DocId) -> Result { + self.run_javascript_job(id, "", (None, None), &[], None, true) + } + + /// Opening document `id`: its document-level scripts run once, then the catalog + /// `/OpenAction`'s JavaScript, in the document's retained realm (#269). Not on an XFA form + /// (its own opening scripts run instead) or with JavaScript off. What they showed or asked + /// for waits in [`Session::take_js_output`]. + pub(crate) fn run_open_scripts(&mut self, id: DocId) { + if self.js_off { + return; + } + let Some(doc) = self.get(id).filter(|d| d.info.xfa.is_none()) else { return }; + let has_document_scripts = !doc.document_scripts().is_empty(); + let opens = + doc.editor.as_ref().map(|e| pdfcraft_forms::document_action_scripts(&e.cos, pdfcraft_forms::DocumentTrigger::Open)).unwrap_or_default(); + if !has_document_scripts && opens.is_empty() { + return; + } + let mut out = JsOutput::default(); + if has_document_scripts && opens.is_empty() { + match self.run_opening_javascript(id) { + Ok(o) => out.absorb(&o), + Err(e) => out.errors.push(e.to_string()), + } + } + // The first open action initializes the realm with the document scripts too. + out.append(self.run_actions(id, DocumentScriptEvent::CatalogOpen, &opens)); + unasked_requests(&mut out.requests); + if let Some(doc) = self.docs.iter_mut().find(|d| d.id == id) { + doc.js_output.append(out); + } + } + + /// Run the catalog's JavaScript actions for document event `event` (`/AA /WS` before a save, + /// `/AA /WP` before printing, `/OpenAction` on opening) in document `id`'s retained realm. + /// Each action's field changes are one undoable edit. A failing action is reported in the + /// output and the next one still runs; it never stops the save or print, which must not + /// lose the user's work. Requests are limited to what an unasked script may make. With + /// JavaScript off, on an XFA form, or with no such action, nothing runs. + pub fn run_document_actions(&mut self, id: DocId, event: DocumentScriptEvent) -> JsOutput { + if self.js_off { + return JsOutput::default(); + } + let Some(doc) = self.get(id).filter(|d| d.info.xfa.is_none()) else { return JsOutput::default() }; + let scripts = doc.editor.as_ref().map(|e| pdfcraft_forms::document_action_scripts(&e.cos, event.trigger())).unwrap_or_default(); + self.run_actions(id, event, &scripts) + } + + fn run_actions(&mut self, id: DocId, event: DocumentScriptEvent, scripts: &[String]) -> JsOutput { + let mut out = JsOutput::default(); + for script in scripts { + match self.run_javascript_job(id, script, (None, None), &[], Some(event.name()), event == DocumentScriptEvent::CatalogOpen) { + Ok(o) => out.absorb(&o), + Err(e) => out.errors.push(format!("{} script: {e}", event.name())), + } + } + unasked_requests(&mut out.requests); + out + } + + /// Run an explicitly dispatched PDF document action in the document's retained runtime. + pub fn run_document_script_event(&mut self, id: DocId, event: DocumentScriptEvent, script: &str) -> Result { + self.run_javascript_job(id, script, (None, None), &[], Some(event.name()), false) + } + + /// Route generated XFA clicks to the XFA engine, or run a synchronous AcroForm job and apply its outcome. + fn run_javascript_job( + &mut self, + id: DocId, + script: &str, + (target, target_id): (Option<&str>, Option), + answers: &[Option], + event_name: Option<&'static str>, + opening: bool, + ) -> Result { if self.js_off { return Err(EditError::Invalid("JavaScript is turned off (Preferences ▸ JavaScript)".into())); } let doc = self.get(id).ok_or(EditError::NoDocument)?; let cos = doc.editor.as_ref().map(|e| e.cos.clone()).ok_or_else(|| EditError::ReadOnly(doc.read_only_reason.clone().unwrap_or_default()))?; - // A button PdfCraft generated from an XFA template runs its XFA click script (the app runs - // click scripts for buttons only, so other fields' click events don't run here either). + validate_script_target(target, target_id, &doc.form)?; + // A button PdfCraft generated from an XFA template runs its XFA click script. if let Some(t) = target + && event_name.is_none_or(|n| n == "Mouse Up") && let Some(tpl) = doc.xfa_template.clone() - && let Some(field) = doc.form.iter().find(|f| f.name == t) + && let Some(field) = doc.form.iter().find(|f| f.name == t && target_id.is_none_or(|id| field_object_id(f.obj) == id)) && field.kind == crate::FormFieldKind::PushButton && let Some(som) = crate::xfa::clickable_som(&cos, &tpl, field) { @@ -606,24 +1187,53 @@ impl Session { ..Default::default() }); } - let runner = JsRunner::new(&cos, &doc.name, self.field_highlight); + let mut runner = JsRunner::new(&cos, &doc.name, self.field_highlight); + runner.doc.zoom = doc.js_zoom; + runner.doc.page = doc.js_page; let fields = pdfcraft_forms::fields(&cos); - let states = std::sync::Arc::new(fields.iter().map(|f| field_state(&cos, f)).collect::>()); + let states = std::sync::Arc::new(fields.iter().map(|f| field_state(&cos, f, &runner.doc)).collect::>()); let event = match target { - Some(t) => pdfcraft_js::Event { will_commit: false, ..pdfcraft_js::Event::field("Mouse Up", t, "") }, - None => pdfcraft_js::Event::doc("Console"), + Some(t) => pdfcraft_js::Event { will_commit: false, target_id, ..pdfcraft_js::Event::field(event_name.unwrap_or("Mouse Up"), t, "") }, + None => pdfcraft_js::Event::doc(event_name.unwrap_or("Console")), }; - let calculations = calculations_for_script(&cos, &fields, script, &runner.doc_scripts); - let mut o = pdfcraft_js::run_shared_calculations_shared( - script, - &event, - &runner.doc, - std::sync::Arc::clone(&states), - &runner.doc_scripts, - &calculations, - Limits::default(), + let calculations = calculation_scripts(&cos, &fields); + let runtime = doc.js_runtime.clone(); + let o = runtime.lock().map_err(|_| EditError::Invalid("document script runtime is unavailable".into()))?.run( + pdfcraft_js::session::Job { + script: script.into(), + event, + doc: runner.doc, + fields: states.clone(), + doc_scripts: runner.doc_scripts, + calculations, + }, + answers, ); - let changes = changes(&states, &o.changed, &o.formatted); + let discard = o.execution_refused && !o.realm_untouched; + let result = self.apply_script_outcome(id, &states, o, opening); + if discard || result.is_err() { + self.invalidate_javascript(id); + } + result + } + + /// Convert the worker snapshot delta and reset requests into permission-checked atomic document edits. + fn apply_script_outcome(&mut self, id: DocId, states: &[pdfcraft_js::FieldState], mut o: Outcome, opening: bool) -> Result { + if o.execution_refused { + o.changed.clear(); + o.requests.clear(); + return Ok(o); + } + if o.calculation_limit_exceeded { + return Err(EditError::Invalid(o.error.unwrap_or_else(|| "script calculation work limit exceeded".into()))); + } + if o.reset_limit_exceeded { + return Err(EditError::Invalid("script reset call limit exceeded; no script edits were committed".into())); + } + if o.requests.iter().any(|r| matches!(r, Request::Reset(_))) { + let doc = self.get(id).ok_or(EditError::NoDocument)?; + validate_reset_work(&doc.form, states, &o.requests)?; + } let resets: Vec> = o .requests .iter() @@ -633,18 +1243,47 @@ impl Session { }) .collect(); o.requests.retain(|r| !matches!(r, Request::Reset(_))); + // Reset the PDF first, then apply the final live shadow. Compare against the + // reset baseline so writes restoring an original value are not mistaken for no-ops. + let mut baseline = std::borrow::Cow::Borrowed(states); + let mut reset_document = if resets.is_empty() { + None + } else { + let doc = self.get(id).ok_or(EditError::NoDocument)?; + let editor = doc.editor.as_ref().ok_or_else(|| EditError::ReadOnly(doc.read_only_reason.clone().unwrap_or_default()))?; + Some(editor.cos.clone()) + }; // The calculations the script's changes set off read the field highlight it asked for. if let Some(on) = last_highlight(&o.requests) { self.field_highlight = on; } let mut edits = Vec::new(); + for names in resets { + let names = (!names.is_empty()) + .then(|| states.iter().filter(|field| reset_name_matches(&field.name, &names)).map(|field| field.name.clone()).collect::>()); + if let Some(doc) = reset_document.as_mut() { + // The preview includes dependent calculation writes as well as direct resets. + let (reset, recalculated) = pdfcraft_forms::reset_with_counts(doc, names.as_deref())?; + if !opening || reset != 0 || recalculated != 0 { + edits.push(Edit::ResetForm { names }); + } + } + } + if let Some(doc) = reset_document { + let mut values = std::collections::HashMap::new(); + for field in pdfcraft_forms::fields(&doc) { + values.entry((u64::from(field.obj.num) << 16) | u64::from(field.obj.generation)).or_insert(field.value); + } + for field in baseline.to_mut() { + if let Some(value) = field.object_id.and_then(|id| values.get(&id)) { + field.value.clone_from(value); + } + } + } + let changes = changes(&baseline, &o.changed, &o.formatted); if !changes.is_empty() { edits.push(Edit::ApplyScriptChanges { changes }); } - if !resets.is_empty() { - let all: Vec<&str> = fields.iter().map(|f| f.name.as_str()).collect(); - edits.push(Edit::ResetForm { names: reset_targets(&all, &resets) }); - } if !edits.is_empty() { self.apply(id, Edit::Batch { label: "Run JavaScript".into(), edits })?; } @@ -652,21 +1291,6 @@ impl Session { } } -/// The fields every `resetForm` call of one script asked for, as one reset: a reset is to the -/// defaults, so resetting the union once does what each call in turn would, and recalculates -/// once. `None` is the whole form (a call with no names). A field is named by a selector that -/// is its name or the name of a field above it, looked up in a set of the selectors, so the -/// work is the fields times their depth, not the fields times the selectors, which a script -/// controls. -fn reset_targets(fields: &[&str], requests: &[Vec]) -> Option> { - if requests.iter().any(Vec::is_empty) { - return None; - } - let wanted: std::collections::HashSet<&str> = requests.iter().flatten().map(String::as_str).collect(); - let matched = |name: &str| name.match_indices('.').map(|(i, _)| &name[..i]).chain(std::iter::once(name)).any(|p| wanted.contains(p)); - Some(fields.iter().filter(|n| matched(n)).map(|n| n.to_string()).collect()) -} - /// A page's words for form-field detection, in user space (underscore runs split from the /// text around them). fn detection_words(text: &pdfcraft_render::PageText, info: &pdfcraft_render::PageInfo) -> Vec { @@ -816,26 +1440,6 @@ impl Session { mod review_tests { use super::*; - /// Many `resetForm` calls with many selectors over a big form: one reset of the union, found - /// by looking each field's name and parents up, not by comparing every field with every - /// selector. - #[test] - fn reset_requests_are_coalesced_and_matched_through_an_index() { - let fields: Vec = (0..20_000).map(|i| format!("group{}.row{i}.cell", i % 100)).collect(); - let names: Vec<&str> = fields.iter().map(String::as_str).collect(); - let requests: Vec> = (0..1_000).map(|k| (0..20).map(|j| format!("nothing{}", k * 20 + j)).collect()).collect(); - let started = std::time::Instant::now(); - assert_eq!(reset_targets(&names, &requests), Some(Vec::new()), "no selector names a field"); - let mut requests = requests; - requests.push(vec!["group7".into(), "group7.row7.cell".into(), "group8.row8".into()]); - let hit = reset_targets(&names, &requests).unwrap(); - assert_eq!(hit.len(), 200 + 1, "group7 has 200 fields, one more by its exact name"); - assert!(hit.iter().all(|n| n.starts_with("group7.") || n == "group8.row8.cell")); - assert!(started.elapsed() < std::time::Duration::from_secs(5), "{:?}", started.elapsed()); - requests.push(Vec::new()); - assert_eq!(reset_targets(&names, &requests), None, "a call without names resets the whole form"); - } - /// A click returns its own alert when the output waiting in the document is full, and the /// waiting output stays. #[test] @@ -995,3 +1599,467 @@ mod review_tests { assert!(changes(&before, &[missing], &formatted).is_empty()); } } + +#[cfg(test)] +mod caption_tests { + use super::*; + + #[test] + /// A setter matching widget zero still updates other widgets whose normal captions differ. + fn setting_the_first_widgets_caption_still_emits_a_field_wide_change() { + let mut button = pdfcraft_js::FieldState::new("go", pdfcraft_js::FieldType::Button, vec![]); + button.caption = Some("First".into()); + button.caption_mixed = true; + let before = vec![button]; + let outcome = pdfcraft_js::run( + "getField('go').buttonSetCaption('First');", + &pdfcraft_js::Event::field("Mouse Up", "go", ""), + &pdfcraft_js::DocInfo { file_name: "buttons.pdf".into(), num_pages: 1, page: 0, info: vec![], xfa: false, ..Default::default() }, + &before, + &[], + Limits::default(), + ); + assert_eq!(outcome.error, None); + let changed = changes(&before, &outcome.changed, &outcome.formatted); + assert_eq!(changed.len(), 1, "other widgets may have different captions"); + assert_eq!(changed[0].caption.as_deref(), Some("First")); + } +} + +#[cfg(not(target_arch = "wasm32"))] +pub struct PendingScript { + pub id: DocId, + generation: u64, + runtime: Runtime, + states: std::sync::Arc>, + pub receiver: std::sync::mpsc::Receiver, +} +#[cfg(not(target_arch = "wasm32"))] +impl Session { + /// Check document identity/generation and the JavaScript preference before accepting a worker completion. + pub fn script_pending_current(&self, p: &PendingScript) -> bool { + !self.js_off && self.get(p.id).is_some_and(|d| d.generation == p.generation && std::sync::Arc::ptr_eq(&d.js_runtime, &p.runtime)) + } + /// Capture the current fields/view and start a native script without blocking the caller on its prompts. + pub fn start_javascript(&mut self, id: DocId, script: &str, target: Option<&str>) -> Result { + self.start_javascript_target(id, script, target, None) + } + /// Start an asynchronous button event retaining the clicked field's exact identity. + pub fn start_javascript_target( + &mut self, + id: DocId, + script: &str, + target: Option<&str>, + target_id: Option, + ) -> Result { + if self.js_off { + return Err(EditError::Invalid("JavaScript is disabled".into())); + } + let doc = self.get(id).ok_or(EditError::NoDocument)?; + let cos = &doc.editor.as_ref().ok_or_else(|| EditError::ReadOnly(doc.read_only_reason.clone().unwrap_or_default()))?.cos; + let mut runner = JsRunner::new(cos, &doc.name, self.field_highlight); + runner.doc.zoom = doc.js_zoom; + runner.doc.page = doc.js_page; + let fields = pdfcraft_forms::fields(cos); + validate_script_target(target, target_id, &fields)?; + let states = std::sync::Arc::new(fields.iter().map(|f| field_state(cos, f, &runner.doc)).collect::>()); + let job = pdfcraft_js::session::Job { + script: script.into(), + event: target.map_or_else( + || pdfcraft_js::Event::doc("Console"), + |t| pdfcraft_js::Event { will_commit: false, target_id, ..pdfcraft_js::Event::field("Mouse Up", t, "") }, + ), + doc: runner.doc, + fields: states.clone(), + doc_scripts: runner.doc_scripts, + calculations: calculation_scripts(cos, &fields), + }; + let receiver = + doc.js_runtime.lock().map_err(|_| EditError::Invalid("script runtime unavailable".into()))?.start(job).map_err(EditError::Invalid)?; + Ok(PendingScript { id, generation: doc.generation, runtime: doc.js_runtime.clone(), states, receiver }) + } + /// Discard only the captured runtime, preserving a newer realm created by an intervening edit. + pub fn discard_javascript(&mut self, p: &PendingScript) { + if let Some(doc) = self.docs.iter_mut().find(|d| d.id == p.id) + && std::sync::Arc::ptr_eq(&doc.js_runtime, &p.runtime) + { + replace_runtime(&mut doc.js_runtime); + } + } + /// Discard a timed-out invocation and retain the worker slot until its thread exits. + pub fn timeout_javascript(&mut self, p: PendingScript) { + if self.script_pending_current(&p) + && let Ok(mut runtime) = p.runtime.lock() + { + runtime.retire_timed_out_worker(); + } else { + self.discard_javascript(&p); + } + } + + /// Apply a current worker result, or discard it and reset the realm when the document changed. + pub fn finish_javascript(&mut self, p: PendingScript, out: Outcome) -> Result { + if !self.script_pending_current(&p) { + self.discard_javascript(&p); + return Err(EditError::Invalid("document changed while the script was running".into())); + } + p.runtime.lock().map_err(|_| EditError::Invalid("script runtime unavailable".into()))?.acknowledge_completion(); + let discard = out.execution_refused && !out.realm_untouched; + let result = self.apply_script_outcome(p.id, &p.states, out, false); + if discard || result.is_err() { + self.invalidate_javascript(p.id); + } + result + } +} +pub use pdfcraft_js::interaction::{InteractiveEvent, MenuItem, Prompt}; +impl Session { + /// Supply current viewer state for script getters, clamping invalid zoom to the supported range. + pub fn set_javascript_view(&mut self, id: DocId, zoom: f64, highlight: bool, page: usize) { + self.set_field_highlight(highlight); + if let Some(doc) = self.docs.iter_mut().find(|d| d.id == id) { + doc.js_zoom = if zoom.is_finite() { zoom.clamp(1.0, 6400.0) } else { 100.0 }; + + doc.js_page = page; + } + } +} + +impl Session { + /// Release this document runtime after cancellation or lifecycle changes; the next event initializes anew. + pub fn invalidate_javascript(&mut self, id: DocId) { + if let Some(doc) = self.docs.iter_mut().find(|d| d.id == id) { + replace_runtime(&mut doc.js_runtime); + } + } +} + +#[cfg(test)] +thread_local! { + /// Page-tree snapshots and calculation-plan builds performed on this test thread. + static SNAPSHOT_WORK: std::cell::Cell<(usize,usize)> = const { std::cell::Cell::new((0,0)) }; +} + +#[cfg(test)] +mod snapshot_review_tests { + use super::*; + + #[test] + fn document_actions_use_the_document_realm_and_obey_javascript_setting() { + let mut session = Session::new(); + let bytes = session.create_blank(200.0, 300.0, 1).unwrap(); + let first = session.open("first.pdf", None, bytes.clone(), None).unwrap(); + let second = session.open("second.pdf", None, bytes, None).unwrap(); + session.run_javascript(first, "var counter = 4;", None).unwrap(); + + for (event, name) in + [(DocumentScriptEvent::CatalogOpen, "Open"), (DocumentScriptEvent::WillSave, "WillSave"), (DocumentScriptEvent::WillPrint, "WillPrint")] + { + let result = session.run_document_script_event(first, event, "[event.name,counter].join('/');").unwrap(); + assert_eq!(result.result, Some(format!("{name}/4"))); + } + let result = session.run_document_script_event(second, DocumentScriptEvent::WillSave, "typeof counter;").unwrap(); + assert_eq!(result.result.as_deref(), Some("undefined")); + session.set_javascript(false); + assert!(session.run_document_script_event(first, DocumentScriptEvent::WillPrint, "counter++;").is_err()); + } + + /// Real page/info edits and completed Save As refresh metadata without losing script state. + #[test] + fn retained_document_metadata_tracks_edits_undo_and_save_as() { + let mut session = Session::new(); + let bytes = session.create_blank(200.0, 300.0, 1).unwrap(); + let id = session.open("before.pdf", None, bytes, None).unwrap(); + assert_eq!(session.run_javascript(id, "var retained=41;numPages;", None).unwrap().result.as_deref(), Some("1")); + session.apply(id, Edit::InsertBlankPage { at: 1, width: 200.0, height: 300.0 }).unwrap(); + session.apply(id, Edit::SetInfo { key: "Title".into(), value: "After".into() }).unwrap(); + let out = session.run_javascript(id, "[numPages,info.title,retained].join('/');", None).unwrap(); + assert_eq!(out.result.as_deref(), Some("2/After/41")); + session.undo(id).unwrap(); + assert_eq!(session.run_javascript(id, "typeof info.title;", None).unwrap().result.as_deref(), Some("undefined")); + session.redo(id).unwrap(); + let saved = session.save_bytes(id).unwrap(); + session.mark_saved(id, saved, Some("after.pdf".into())).unwrap(); + assert_eq!( + session.run_javascript(id, "[documentFileName,numPages,info.title,retained].join('/');", None).unwrap().result.as_deref(), + Some("after.pdf/2/After/41") + ); + session.apply(id, Edit::DeletePages { pages: vec![1] }).unwrap(); + assert_eq!(session.run_javascript(id, "[numPages,retained].join('/');", None).unwrap().result.as_deref(), Some("1/41")); + session.undo(id).unwrap(); + assert_eq!(session.run_javascript(id, "numPages;", None).unwrap().result.as_deref(), Some("2")); + session.redo(id).unwrap(); + assert_eq!(session.run_javascript(id, "numPages;", None).unwrap().result.as_deref(), Some("1")); + } + + /// A batch child that changes page structure or calculation sources invalidates cached plans. + #[test] + fn batch_children_refresh_geometry_metadata_and_calculation_sources() { + let mut session = Session::new(); + let bytes = session.create_blank(200.0, 300.0, 1).unwrap(); + let id = session.open("batch.pdf", None, bytes, None).unwrap(); + for (i, name) in ["input", "total", "seen"].into_iter().enumerate() { + session + .apply( + id, + Edit::AddField { + page: 0, + rect: [20.0, 20.0 + i as f64 * 40.0, 120.0, 40.0 + i as f64 * 40.0], + kind: crate::NewField::Text { multiline: false }, + name: Some(name.into()), + }, + ) + .unwrap(); + } + session.apply(id, Edit::SetFieldValue { name: "input".into(), value: crate::FieldValue::Text("2".into()) }).unwrap(); + session + .apply( + id, + Edit::SetFieldScript { + name: "total".into(), + event: "calculate".into(), + script: Some("event.value=Number(getField('input').value)+1;".into()), + }, + ) + .unwrap(); + session + .apply( + id, + Edit::SetFieldScript { + name: "input".into(), + event: "validate".into(), + script: Some("calculateNow();getField('seen').value=numPages+'/'+getField('total').value;".into()), + }, + ) + .unwrap(); + session + .apply( + id, + Edit::Batch { + label: "Structural batch".into(), + edits: vec![ + Edit::SetFieldValue { name: "input".into(), value: crate::FieldValue::Text("3".into()) }, + Edit::InsertBlankPage { at: 1, width: 200.0, height: 300.0 }, + Edit::SetFieldScript { + name: "total".into(), + event: "calculate".into(), + script: Some("event.value=Number(getField('input').value)+10;".into()), + }, + Edit::SetFieldValue { name: "input".into(), value: crate::FieldValue::Text("4".into()) }, + ], + }, + ) + .unwrap(); + let out = session.run_javascript(id, "getField('seen').value;", None).unwrap(); + assert_eq!(out.error, None); + assert_eq!(out.result.as_deref(), Some("2/13")); + } + + #[test] + fn callback_field_cache_keeps_live_values_and_uses_object_identity() { + let mut session = Session::new(); + let bytes = session.create_blank(200.0, 300.0, 1).unwrap(); + let id = session.open("cache.pdf", None, bytes, None).unwrap(); + for (name, rect) in [("first", [20.0, 20.0, 100.0, 40.0]), ("second", [20.0, 60.0, 100.0, 80.0])] { + session.apply(id, Edit::AddField { page: 0, rect, kind: crate::NewField::Text { multiline: false }, name: Some(name.into()) }).unwrap(); + } + + let cos = &session.get(id).unwrap().editor.as_ref().unwrap().cos; + let mut fields = pdfcraft_forms::fields(cos); + fields[1].name = fields[0].name.clone(); + let mut runner = JsRunner::new(cos, "cache.pdf", false); + let initial = runner.callback_fields(cos, &fields); + assert_ne!(initial[0].object_id, initial[1].object_id); + assert_ne!(initial[0].rect, initial[1].rect); + fields[0].value = vec!["fresh".into()]; + let updated = runner.callback_fields(cos, &fields); + assert_eq!(updated[0].value, ["fresh"]); + assert_eq!(updated[1].value, initial[1].value); + assert_eq!(runner.snapshot_fields.len(), 2); + + fields[0].widgets[0].rect = [20.0, 100.0, 100.0, 120.0]; + runner.invalidate_field_snapshots(); + let moved = runner.callback_fields(cos, &fields[..1]); + assert_ne!(moved[0].rect, updated[0].rect); + assert_eq!(runner.snapshot_fields.len(), 1); + } + + /// A callback reuses the previous callback's snapshot, patching only values that changed, + /// and a snapshot a caller still holds is never changed under it. + #[test] + fn callback_snapshots_are_patched_in_place_and_copied_only_when_shared() { + let mut session = Session::new(); + let bytes = session.create_blank(200.0, 300.0, 1).unwrap(); + let id = session.open("patch.pdf", None, bytes, None).unwrap(); + for (name, rect) in [("first", [20.0, 20.0, 100.0, 40.0]), ("second", [20.0, 60.0, 100.0, 80.0])] { + session.apply(id, Edit::AddField { page: 0, rect, kind: crate::NewField::Text { multiline: false }, name: Some(name.into()) }).unwrap(); + } + let cos = &session.get(id).unwrap().editor.as_ref().unwrap().cos; + let mut fields = pdfcraft_forms::fields(cos); + let mut runner = JsRunner::new(cos, "patch.pdf", false); + let first = runner.callback_fields(cos, &fields); + let first_ptr = std::sync::Arc::as_ptr(&first); + drop(first); + // Nothing changed: the same snapshot comes back. + let same = runner.callback_fields(cos, &fields); + assert_eq!(std::sync::Arc::as_ptr(&same), first_ptr); + drop(same); + // A value changed and no one else holds the snapshot: it is patched in place. + fields[1].value = vec!["7".into()]; + let patched = runner.callback_fields(cos, &fields); + assert_eq!(std::sync::Arc::as_ptr(&patched), first_ptr); + assert_eq!(patched[1].value, ["7"]); + // A held snapshot keeps its values; the next callback gets a copy. + fields[0].value = vec!["held".into()]; + let copied = runner.callback_fields(cos, &fields); + assert_eq!(patched[0].value, Vec::::new()); + assert_eq!(copied[0].value, ["held"]); + assert_eq!(copied[1].value, ["7"]); + // Instance tokens are stamped before dispatch, so the worker does not copy it again. + assert!(copied.iter().all(|f| f.instance_id != 0)); + } + + /// Operation counts, rather than wall time, pin the absence of per-field tree/plan walks. + #[test] + fn field_events_reuse_page_geometry_and_calculation_plans() { + let mut session = Session::new(); + let bytes = session.create_blank(600.0, 800.0, 4).unwrap(); + let id = session.open("synthetic.pdf", None, bytes, None).unwrap(); + for i in 0..12 { + session + .apply( + id, + Edit::AddField { + page: i % 4, + rect: [20.0, 20.0, 100.0, 40.0], + kind: crate::NewField::Text { multiline: false }, + name: Some(format!("f{i}")), + }, + ) + .unwrap(); + } + let cos = &session.get(id).unwrap().editor.as_ref().unwrap().cos; + let mut fields = pdfcraft_forms::fields(cos); + fields[1].actions.scripts.calculate = Some("event.value=Number(getField('f0').value)+1;".into()); + SNAPSHOT_WORK.with(|c| c.set((0, 0))); + let mut runner = JsRunner::new(cos, "synthetic.pdf", false); + for i in 0..8 { + fields[0].value = vec![i.to_string()]; + let out = runner.run(FieldEvent::Calculate, "calculateNow();event.value=getField('f1').value;", &fields[1], "", &fields, cos); + assert_eq!(out.value, (i + 1).to_string()); + assert!(runner.output.errors.is_empty(), "{:?}", runner.output.errors); + } + let work = SNAPSHOT_WORK.with(std::cell::Cell::get); + eprintln!("geometry snapshots={}, calculation plan builds={}", work.0, work.1); + assert_eq!(work, (1, 1), "one geometry snapshot and one calculation plan per runner"); + } +} + +#[cfg(test)] +mod reset_budget_review_tests { + use super::*; + fn state(name: String) -> pdfcraft_js::FieldState { + pdfcraft_js::FieldState::new(name, pdfcraft_js::FieldType::Text, Vec::new()) + } + #[test] + fn reset_budget_accepts_one_selected_field_in_a_large_form() { + let states: Vec<_> = (0..1000).map(|i| state(format!("parent.field_{i:04}"))).collect(); + assert!(validate_reset_work(&[], &states, &[Request::Reset(vec!["parent.field_0001".into()])]).is_ok()); + assert!(validate_reset_work(&[], &states, &[Request::Reset(vec!["parent".into()])]).is_err()); + } + + #[test] + fn reset_budget_charges_expanded_descendants() { + let states: Vec<_> = (0..3000).map(|i| state(format!("parent.{i}"))).collect(); + assert!(validate_reset_work(&[], &states, &[Request::Reset(vec!["parent".into()])]).is_err()); + assert!(validate_reset_work(&[], &states, &[Request::Reset(Vec::new())]).is_ok()); + } + #[test] + fn reset_budget_charges_shared_prefix_comparisons() { + let prefix = "p".repeat(4096); + let states: Vec<_> = (0..128).map(|i| state(format!("{prefix}.{i}"))).collect(); + let names = (0..128).map(|i| format!("{prefix}.missing{i}")).collect(); + assert!(validate_reset_work(&[], &states, &[Request::Reset(names)]).is_err()); + } + #[test] + fn reset_budget_charges_full_form_name_copies() { + let states = vec![state("n".repeat(140_000))]; + let resets = vec![Request::Reset(Vec::new()); 64]; + assert!(validate_reset_work(&[], &states, &resets).is_err()); + assert!(validate_reset_work(&[], &states, &[Request::Reset(Vec::new())]).is_ok()); + } +} + +#[cfg(test)] +mod widget_budget_review_tests { + use super::*; + #[test] + fn reset_budget_charges_all_widgets() { + let widget = pdfcraft_forms::Widget { + obj: pdfcraft_cos::ObjRef { num: 1, generation: 0 }, + page: None, + rect: [0.0; 4], + on_state: None, + state: None, + tab: 0, + locked: false, + hidden: false, + read_only: false, + rotation: 0, + caption: None, + mouse_up_script: None, + list: None, + round: false, + }; + let mut field = Field { + name: "x".into(), + obj: widget.obj, + kind: FieldKind::Text, + value: vec![], + default: vec![], + flags: 0, + max_len: None, + options: vec![], + da: String::new(), + quadding: 0, + tooltip: None, + selected: vec![], + single_value_is_array: false, + widgets: vec![widget; 140_000], + actions: Default::default(), + button: None, + }; + let resets = vec![Request::Reset(Vec::new()); 64]; + assert!(validate_reset_work(std::slice::from_ref(&field), &[], &resets).is_err()); + field.widgets.truncate(1); + assert!(validate_reset_work(&[field], &[], &resets).is_ok()); + } +} + +#[cfg(test)] +mod icon_mixed_review_tests { + use super::*; + /// Mixed widgets need a field-wide icon delta even when the first icon is already correct. + #[test] + fn mixed_icons_emit_only_after_an_explicit_setter() { + for icon in [None, Some(7)] { + let mut before = pdfcraft_js::FieldState::new("go", pdfcraft_js::FieldType::Button, vec![]); + before.icon = icon; + before.icon_mixed = true; + let fields = vec![before.clone()]; + let script = + if icon.is_some() { "getField('go').buttonSetIcon(getField('go').buttonGetIcon());" } else { "getField('go').buttonSetIcon(null);" }; + let out = pdfcraft_js::run( + script, + &pdfcraft_js::Event::doc("Test"), + &pdfcraft_js::DocInfo::default(), + &fields, + &[], + pdfcraft_js::Limits::default(), + ); + assert_eq!(out.error, None); + assert_eq!(changes(&[before.clone()], &out.changed, &out.formatted)[0].icon, Some(icon)); + assert!(changes(&[before.clone()], &[before], &Default::default()).is_empty()); + } + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 161b2c1d34..adb69cec46 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -92,7 +92,9 @@ pub use pdfcraft_organize::check_custom_key; pub use pdfcraft_organize::view::{InitialView, Layout as InitialLayout, Magnification, Navigation}; pub use pdfcraft_cos::{Algorithm, civil_from_days}; -pub use pdfcraft_create::{CONVERTIBLE, CreateError, ImageResolution, SourceKind, decode_text, source_kind}; +pub use pdfcraft_create::{ + CONVERTIBLE, CreateError, IMAGE_EXTENSIONS, ImageResolution, OFFICE_EXTENSIONS, SourceKind, TEXT_EXTENSIONS, decode_text, source_kind, +}; pub use pdfcraft_edit::added::{ CHAR_SPACING_LIMIT as ADDED_CHAR_SPACING_LIMIT, paste_place as added_paste_place, paste_rect as added_paste_rect, text_rect as added_text_rect, text_width as added_text_width, @@ -166,7 +168,7 @@ use pictures::{embed_image, fit_picture, mark_source, model_poster}; use protection::unrestricted; pub use protection::{Changes, Printing, Protection, SecuritySummary}; pub use session::Session; -pub use sources::{CombineSource, Combined, combine_source_check, source_page_count}; +pub use sources::{CombineSource, Combined, combine_source_check, office_to_pdf, source_page_count}; use sources::{open_source, open_source_with}; #[cfg(test)] use test_hooks::{ @@ -198,5 +200,7 @@ fn display_config(config: &pdfcraft_render::RenderConfig) -> pdfcraft_render::Re /// How long a custom stamp's text line may be, in characters (#1741). pub const MAX_STAMP_LINE: usize = 200; +#[cfg(test)] +mod document_actions_tests; #[cfg(test)] mod tests; diff --git a/crates/engine/src/ocr.rs b/crates/engine/src/ocr.rs index 539a03a6aa..b2a4717e46 100644 --- a/crates/engine/src/ocr.rs +++ b/crates/engine/src/ocr.rs @@ -1156,6 +1156,7 @@ pub struct FileResult { } impl FileResult { + /// How many words were recognized on all of the file's pages. pub fn words(&self) -> usize { self.pages.iter().map(|p| p.words.len()).sum() } @@ -1202,19 +1203,21 @@ pub fn low_confidence_pages(pages: &[OcrPage], threshold: f32) -> Option<(usize, (count > 0).then_some((count, page_numbers)) } -/// Recognize text in multiple files: one PDF's bytes in, the searchable PDF out. `progress` -/// works as for [`OcrJob::run`]. A file that the skip-files option passes over comes back with -/// [`FileResult::file_skipped`] set and its original bytes; the caller decides what to do with -/// it, but it is never an OCR success. +/// Recognize text in multiple files: one PDF's bytes in, the searchable PDF out. `javascript` is +/// the caller's [`Session::javascript`] preference (when off, no open-time script runs). +/// `progress` works as for [`OcrJob::run`]. A file that the skip-files option passes over comes +/// back with [`FileResult::file_skipped`] set and its original bytes; the caller decides what to +/// do with it, but it is never an OCR success. pub fn recognize_file( name: &str, bytes: Arc>, password: Option<&str>, settings: OcrSettings, recognizers: &Recognizers, + javascript: bool, progress: impl FnMut(usize, usize) -> bool, ) -> Result { - match FileJob::open(name, bytes, password, settings)? { + match FileJob::open(name, bytes, password, settings, javascript)? { Opened::Skipped(result) => Ok(result), Opened::Read(mut file) => { let job = file.job.take().ok_or("the document could not be read")?; @@ -1242,12 +1245,9 @@ pub enum Opened { } impl FileJob { - pub fn open(name: &str, bytes: Arc>, password: Option<&str>, settings: OcrSettings) -> Result { - let mut session = Session::new(); - let id = session.open(name, None, bytes.clone(), password).map_err(|e| e.to_string())?; - if let Some(why) = session.get(id).and_then(|d| d.read_only_reason.clone()) { - return Err(why); - } + /// Open the file; `javascript` is as for [`recognize_file`]. + pub fn open(name: &str, bytes: Arc>, password: Option<&str>, settings: OcrSettings, javascript: bool) -> Result { + let (session, id) = open_file(name, bytes.clone(), password, javascript)?; let job = session.ocr_job(id, &[], settings.clone()).ok_or("the document could not be read")?; // The skip-files probe runs before any engine work: a file with text needs no engine at // all, and the original bytes come back unchanged. @@ -1280,11 +1280,38 @@ impl FileJob { } } +/// Open one of [`recognize_file`]'s files in a session of its own that keeps the caller's +/// JavaScript preference; a file that cannot be changed is refused with the reason. +fn open_file(name: &str, bytes: Arc>, password: Option<&str>, javascript: bool) -> Result<(Session, DocId), String> { + let mut s = Session::new(); + // Before the open: open-time (XFA) scripts read it there. + s.set_javascript(javascript); + let id = s.open(name, None, bytes, password).map_err(|e| e.to_string())?; + if let Some(why) = s.get(id).and_then(|d| d.read_only_reason.clone()) { + return Err(why); + } + Ok((s, id)) +} + #[cfg(test)] mod tests { use super::*; use crate::export; + /// #5080: [`recognize_file`] opens each file with the caller's JavaScript preference, so an + /// XFA initialize script (it sets an empty `qty` to 2) runs only when that is on. This checks + /// the open path without the recognition models, which the end-to-end test needs. + #[test] + fn files_open_for_recognition_with_the_callers_javascript_preference() { + let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::scripted_template())); + let qty = |javascript| { + let (s, id) = open_file("scripted.pdf", bytes.clone(), None, javascript).unwrap(); + s.get(id).unwrap().form.iter().find(|f| f.name == "qty").unwrap().value.clone() + }; + assert!(qty(false).is_empty(), "the initialize script ran with JavaScript off"); + assert_eq!(qty(true), ["2"], "the initialize script did not run with JavaScript on"); + } + /// One 300 × 300 page drawing `content`, with Helvetica as `/F1`. fn page(content: &str) -> Arc> { let pdf = format!( @@ -1741,7 +1768,7 @@ mod tests { let bytes = s.create_from_text("t", "Already typed here").unwrap(); let (r, fake) = fake_recognizers(); let settings = OcrSettings { skip_text_files: true, ..Default::default() }; - let out = recognize_file("t.pdf", bytes.clone(), None, settings, &r, |_, _| true).unwrap(); + let out = recognize_file("t.pdf", bytes.clone(), None, settings, &r, true, |_, _| true).unwrap(); assert_eq!(fake.calls(), 0, "the engine never ran"); assert_eq!(out.bytes.as_ptr(), bytes.as_ptr(), "the original bytes came back"); assert_eq!(out.file_skipped.as_deref(), Some("the file already contains text")); @@ -1756,7 +1783,7 @@ mod tests { let bytes = s.create_from_text("t", "Already typed here").unwrap(); let (r, fake) = fake_recognizers(); let settings = OcrSettings { skip_text_files: true, force_ocr: true, ..Default::default() }; - let out = recognize_file("t.pdf", bytes, None, settings, &r, |_, _| true).unwrap(); + let out = recognize_file("t.pdf", bytes, None, settings, &r, true, |_, _| true).unwrap(); assert_eq!(fake.calls(), 1); assert_eq!(out.file_skipped, None); assert_eq!(out.words(), 1); @@ -1770,7 +1797,7 @@ mod tests { let bytes = s.create_from_text("t", "A page with text").unwrap(); let (r, fake) = fake_recognizers(); let settings = OcrSettings { force_ocr: true, ..Default::default() }; - let out = recognize_file("t.pdf", bytes, None, settings, &r, |_, _| true).unwrap(); + let out = recognize_file("t.pdf", bytes, None, settings, &r, true, |_, _| true).unwrap(); assert_eq!(fake.calls(), 1, "the page's own text did not stop the run"); assert_eq!(out.pages[0].skipped, None); } @@ -1782,12 +1809,12 @@ mod tests { let s = Session::new(); let bytes = s.create_from_text("t", "A page with text").unwrap(); let (r, fake) = fake_recognizers(); - let out = recognize_file("t.pdf", bytes, None, OcrSettings::default(), &r, |_, _| true).unwrap(); + let out = recognize_file("t.pdf", bytes, None, OcrSettings::default(), &r, true, |_, _| true).unwrap(); assert_eq!(fake.calls(), 0); assert_eq!(out.pages[0].skipped.as_deref(), Some("the page already contains text")); assert_eq!(out.words(), 0); // Second run over the same (already text-bearing) file: skipped pages again, no calls. - let again = recognize_file("t.pdf", out.bytes, None, OcrSettings::default(), &r, |_, _| true).unwrap(); + let again = recognize_file("t.pdf", out.bytes, None, OcrSettings::default(), &r, true, |_, _| true).unwrap(); assert_eq!(fake.calls(), 0); assert_eq!(again.pages[0].skipped.as_deref(), Some("the page already contains text")); } @@ -1863,14 +1890,14 @@ mod tests { let bytes = s.create_from_text("t", "Already typed here").unwrap(); let (r, fake) = fake_recognizers(); let skip = OcrSettings { skip_text_files: true, ..Default::default() }; - let Ok(Opened::Skipped(out)) = FileJob::open("t.pdf", bytes.clone(), None, skip) else { panic!("the file should be skipped") }; + let Ok(Opened::Skipped(out)) = FileJob::open("t.pdf", bytes.clone(), None, skip, true) else { panic!("the file should be skipped") }; assert_eq!(out.file_skipped.as_deref(), Some("the file already contains text")); let force = OcrSettings { force_ocr: true, ..Default::default() }; - let Ok(Opened::Read(mut file)) = FileJob::open("t.pdf", bytes.clone(), None, force.clone()) else { panic!("the file should be read") }; + let Ok(Opened::Read(mut file)) = FileJob::open("t.pdf", bytes.clone(), None, force.clone(), true) else { panic!("the file should be read") }; let pages = file.job.take().unwrap().run(&r, |_, _| true); let out = file.finish(pages).unwrap(); assert_eq!((fake.calls(), out.words(), out.file_skipped.clone()), (1, 1, None)); - let whole = recognize_file("t.pdf", bytes, None, force, &r, |_, _| true).unwrap(); + let whole = recognize_file("t.pdf", bytes, None, force, &r, true, |_, _| true).unwrap(); // The saved bytes carry a modification time, so two saves a second apart differ there alone. assert_eq!((out.pages, out.file_skipped), (whole.pages, whole.file_skipped), "the same as reading it in one call"); } diff --git a/crates/engine/src/open.rs b/crates/engine/src/open.rs index 28dd235928..685ac50432 100644 --- a/crates/engine/src/open.rs +++ b/crates/engine/src/open.rs @@ -101,6 +101,7 @@ impl Session { Self::draw_captions(&mut document, self.captions.clone()); } self.docs.push(document); + self.run_open_scripts(id); id } @@ -381,6 +382,9 @@ impl Session { editor, config, js_output: Default::default(), + js_runtime: Default::default(), + js_zoom: 100.0, + js_page: 0, xfa, xfa_template: None, xfa_scripts_off: false, diff --git a/crates/engine/src/print_raster.rs b/crates/engine/src/print_raster.rs index c5a1295e92..a6076d5b05 100644 --- a/crates/engine/src/print_raster.rs +++ b/crates/engine/src/print_raster.rs @@ -59,7 +59,7 @@ pub fn rasterize(doc: &Document, pages: &[usize], raster: &Raster, content: Cont } /// Premultiplied RGBA composed over white paper (opaque), in grey when asked (Rec. 601 luma). -fn over_white(rgba: &mut [u8], grayscale: bool) { +pub(crate) fn over_white(rgba: &mut [u8], grayscale: bool) { for px in rgba.as_chunks_mut::<4>().0 { let gap = 255 - u32::from(px[3]); let (r, g, b) = ((u32::from(px[0]) + gap).min(255), (u32::from(px[1]) + gap).min(255), (u32::from(px[2]) + gap).min(255)); diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index bd83242682..187cc217d8 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -13,7 +13,7 @@ use crate::{ BookmarkPage, BookmarkRow, CombineSource, Combined, DataFormat, DocId, Document, Edit, EditError, Hairlines, HairlinesFixed, Hidden, ImageResolution, LOW_RESOLUTION_PRINT_DPI, Label, ObjectKind, OptimizeReport, RecoverySnapshot, SignOptions, SourceKind, SplitPart, StatusCaptions, SummarySort, TrustStore, XFA_REMOVED_PREFIX, XFA_SCRIPTS_OFF, apply_layer_state, carry_verdict_marks, comment_list, - comment_summary, decode_text, display_bytes, display_config, guard, info_fields, js, local_utc_offset, note_warnings, open_source, + comment_summary, decode_text, display_bytes, display_config, guard, info_fields, js, local_utc_offset, note_warnings, office_to_pdf, open_source, open_source_with, optimize, outline_rows, parse_ymd, print, print_layer_states, print_raster, render_threads, scan_colours, set_default_layer_state, shown_under_redactions, signatures_of, source_kind, sync_xfa_state, undrawn_notices, use_layer_choices, writable, xfa, xfa_sync_datasets, diff --git a/crates/engine/src/session/editing.rs b/crates/engine/src/session/editing.rs index 1584fb5209..a039f56798 100644 --- a/crates/engine/src/session/editing.rs +++ b/crates/engine/src/session/editing.rs @@ -8,6 +8,34 @@ use super::{ }; use crate::{EditNotice, MarkKind, print_preview_layers}; +/// Add scan cover and ink to edits of invisible OCR text, returning no edit for an unchanged draft. +fn prepare_ocr_text_edit(editor: &mut crate::history::Editor, info: &pdfcraft_render::DocInfo, edit: Edit) -> Option { + // Rewriting an OCR word (invisible text over a scan) also hides the scanned word: cover it + // in the paper's colour and draw the new text in the ink's. + Some(match edit { + Edit::EditTextBlock { page, block, text, mut style } => { + let plain = style == pdfcraft_edit::BlockStyle::default(); + // Only invisible text is sampled; the blocks are usually cached from opening it. + if style.cover.is_none() + && let Some(info) = info.pages.get(page) + && let Some(b) = editor.text_blocks(page).ok().and_then(|b| b.get(block).filter(|b| b.invisible).cloned()) + && let Some((paper, ink)) = guard(|| scan_colours::sample(&editor.cos, info, page, &b)).ok().flatten() + { + style.cover = Some(paper); + style.color = style.color.or(Some(ink)); + // A text edit alone leaves the words it doesn't change as the scan shows them. + style.changed_words_only = plain; + // Nothing to redraw: leave undo history and the dirty flag alone. + if plain && b.same_words(&text) { + return None; + } + } + Edit::EditTextBlock { page, block, text, style } + } + other => other, + }) +} + /// Whether the edit, or any edit inside a batch, deletes pages. fn deletes_pages(edit: &Edit) -> bool { match edit { @@ -17,6 +45,83 @@ fn deletes_pages(edit: &Edit) -> bool { } } +/// Keep edits to the working copy until both the operation and its script callbacks succeed. +fn run_checked_edit(next: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> Result<(), EditError> { + let result = guard(|| run_edit(next, edit, cx)) + .unwrap_or_else(|m| Err(EditError::Invalid(format!("{} failed unexpectedly ({m}); the document was not changed", edit.label())))); + let refusal = cx.js.as_mut().and_then(|runner| runner.refused.take()); + if refusal.is_some() || result.is_err() { + discard_edit_callbacks(cx); + } + refusal.map_or(result, |reason| Err(EditError::Invalid(reason))) +} + +/// Discard callback effects if PDF commit fails after any code entered the retained realm. +fn discard_edit_callbacks(cx: &EditCtx) { + if let Some(runner) = cx.js.as_ref() + && runner.callbacks_touched_realm + && let Ok(mut runtime) = runner.runtime.lock() + { + runtime.reset_after_refusal(); + } +} + +/// Compare the icon and all of its indirect resources. Conservatively retire on budget exhaustion. +fn icon_changed(previous: &pdfcraft_cos::Document, restored: &pdfcraft_cos::Document, root: pdfcraft_cos::ObjRef, budget: &mut usize) -> bool { + use pdfcraft_cos::Object; + + fn visit(object: &Object, refs: &mut Vec, budget: &mut usize, depth: usize) -> Option<()> { + *budget = budget.checked_sub(1)?; + if depth == 128 { + return None; + } + match object { + Object::Ref(reference) => refs.push(*reference), + Object::Array(values) => { + for value in values { + visit(value, refs, budget, depth + 1)?; + } + } + _ => { + if let Some(dict) = object.as_dict() { + for (_, value) in dict.iter() { + visit(value, refs, budget, depth + 1)?; + } + } + } + } + Some(()) + } + + let mut pending = vec![root]; + let mut seen = std::collections::HashSet::new(); + while let Some(reference) = pending.pop() { + if !seen.insert(reference) { + continue; + } + if *budget == 0 { + return true; + } + let before = previous.get(reference); + let after = restored.get(reference); + if (!Arc::ptr_eq(&before, &after) && before != after) || visit(&before, &mut pending, budget, 0).is_none() { + return true; + } + } + false +} + +fn sync_script_history(doc: &Document, previous: &pdfcraft_cos::Document, restored: &pdfcraft_cos::Document) { + if let Ok(mut runtime) = doc.js_runtime.lock() { + let mut budget = 250_000; + runtime.retire_changed_icons(|id| { + let object = pdfcraft_cos::ObjRef::new((id >> 16) as u32, id as u16); + icon_changed(previous, restored, object, &mut budget) + }); + runtime.sync_field_ids(doc.form.iter().map(|f| crate::js::field_object_id(f.obj))); + } +} + impl Session { /// Apply an edit. On success the previous state is undoable and the view data is refreshed. pub fn apply(&mut self, id: DocId, edit: Edit) -> Result<(), EditError> { @@ -39,6 +144,12 @@ impl Session { let highlight = self.field_highlight; let xfa_time_scale = self.xfa_time_scale.unwrap_or(1); let doc = self.doc_mut(id)?; + // An edit must never read globals from a completed-but-unapplied button invocation. + // Fork its realm before any edit-triggered calculation, even if the edit later fails. + #[cfg(not(target_arch = "wasm32"))] + if doc.js_runtime.lock().map_err(|_| EditError::Invalid("script runtime unavailable".into()))?.is_pending() { + js::replace_runtime(&mut doc.js_runtime); + } doc.edit_notices.clear(); let name = doc.name.clone(); let is_xfa = doc.info.xfa.is_some(); @@ -57,29 +168,8 @@ impl Session { if let Some(p) = editor.cos.permissions() { check_permission(&edit, &p)?; } - // Rewriting an OCR word (invisible text over a scan) also hides the scanned word: cover it - // in the paper's colour and draw the new text in the ink's. - let edit = match edit { - Edit::EditTextBlock { page, block, text, mut style } => { - let plain = style == pdfcraft_edit::BlockStyle::default(); - // Only invisible text is sampled; the blocks are usually cached from opening it. - if style.cover.is_none() - && let Some(info) = doc.info.pages.get(page) - && let Some(b) = editor.text_blocks(page).ok().and_then(|b| b.get(block).filter(|b| b.invisible).cloned()) - && let Some((paper, ink)) = guard(|| scan_colours::sample(&editor.cos, info, page, &b)).ok().flatten() - { - style.cover = Some(paper); - style.color = style.color.or(Some(ink)); - // A text edit alone leaves the words it doesn't change as the scan shows them. - style.changed_words_only = plain; - // Nothing to redraw: leave undo history and the dirty flag alone. - if plain && b.same_words(&text) { - return Ok(None); - } - } - Edit::EditTextBlock { page, block, text, style } - } - other => other, + let Some(edit) = prepare_ocr_text_edit(editor, &doc.info, edit) else { + return Ok(None); }; // Nothing to bake: leave undo history and the dirty flag alone. if matches!(&edit, Edit::FlattenFillSign) && !pdfcraft_annot::has_visible_fill_sign(&editor.cos) { @@ -87,13 +177,17 @@ impl Session { } let mut next = editor.cos.clone(); if !js_off && uses_scripts(&edit) { - cx.js = Some(js::JsRunner::new(&next, &name, highlight)); + let mut runner = js::JsRunner::new(&next, &name, highlight); + runner.runtime = doc.js_runtime.clone(); + runner.doc.zoom = doc.js_zoom; + runner.doc.page = doc.js_page; + cx.js = Some(runner); } cx.renamed_fields.clear(); cx.redaction_overlays.clear(); + cx.redaction_marks.clear(); // `next` is a copy: if the edit fails or crashes, the document is unchanged. - guard(|| run_edit(&mut next, &edit, &mut cx)) - .unwrap_or_else(|m| Err(EditError::Invalid(format!("{} failed unexpectedly ({m}); the document was not changed", edit.label()))))?; + run_checked_edit(&mut next, &edit, &mut cx)?; // A field's enter or exit scripts that changed nothing leave no edit behind (no undo step, // nothing unsaved); what they showed or asked for still goes out. Only an event on its own: // a batch keeps the other edits it holds. @@ -102,11 +196,20 @@ impl Session { return Ok(None); } // Redaction removed what its own interpreter places under the marks. The marks were drawn - // over the page as the renderer shows it, so the renderer has the last word. - if let Edit::ApplyRedactions { pages } = &edit { - let marks: Vec = - pdfcraft_redact::marks(&editor.cos).into_iter().filter(|m| pages.as_ref().is_none_or(|p| p.contains(&m.page))).collect(); - guard(|| shown_under_redactions(&next, &marks, &cx.redaction_overlays, &doc.info.pages, doc.password.as_deref())) + // over the page as the renderer shows it, so the renderer has the last word: on every + // apply the edit ran, a batch's included. + if !cx.redaction_marks.is_empty() { + // A batch that deletes a marked page and adds pages cannot be tied back to the marks: + // a copy of a page may carry the text the redaction removed from the original only. + // Fail closed. + let before: std::collections::HashSet<_> = pdfcraft_model::pages(&editor.cos).iter().map(|p| p.obj).collect(); + let after: std::collections::HashSet<_> = pdfcraft_model::pages(&next).iter().map(|p| p.obj).collect(); + if cx.redaction_marks.iter().any(|(o, _)| !after.contains(o)) && after.iter().any(|o| !before.contains(o)) { + return Err(EditError::Invalid( + "a batch that deletes a page with a redaction mark and adds pages can't be verified; the document was not changed".into(), + )); + } + guard(|| shown_under_redactions(&next, &cx.redaction_marks, &cx.redaction_overlays, doc.password.as_deref())) .unwrap_or_else(|m| Err(EditError::Invalid(format!("{} failed unexpectedly ({m}); the document was not changed", edit.label()))))?; } // Renamed fields are said, not slipped in: a repair-log note (with the caveat that @@ -192,12 +295,14 @@ impl Session { let (tpl, datasets) = (cx.xfa.as_deref(), &mut cx.xfa_datasets); xfa_notes = guard(|| xfa_sync_datasets(&mut next, tpl, datasets)) .unwrap_or_else(|m| Err(format!("writing the XFA data failed unexpectedly ({m})"))) - .map_err(EditError::Write)?; + .map_err(EditError::Write) + .inspect_err(|_| discard_edit_callbacks(&cx))?; // Rebuilding from stale data would discard accepted widget values. if xfa_notes.is_empty() { let reflow = guard(|| xfa::relayout_changed_text(&mut next, tpl, &doc.form)) .unwrap_or_else(|m| Err(format!("laying out the XFA text failed unexpectedly ({m})"))) - .map_err(EditError::Write)?; + .map_err(EditError::Write) + .inspect_err(|_| discard_edit_callbacks(&cx))?; if let Some(warnings) = reflow { xfa_notes.extend(warnings); // Layout replaces pages and widgets, even if the page count is unchanged. @@ -216,6 +321,7 @@ impl Session { } let rewrites = |c: &pdfcraft_cos::Document| c.full_save_required() || c.encryption_changed(); if signed && ((rewrites(&next) && !rewrites(&editor.cos)) || (next.is_modified() && next.revisions().is_empty())) { + discard_edit_callbacks(&cx); return Err(EditError::SignedRewrite(edit.label().to_string())); } let mut notices = edit_notices(&edit, &editor.cos, &next); @@ -237,6 +343,7 @@ impl Session { let refreshed = guard(|| if scope == Scope::Content { Self::refresh_content(doc, Some(&edit)) } else { Self::refresh_scoped(doc, scope) }) .unwrap_or_else(|m| Err(EditError::Reopen(m))); if let Err(e) = refreshed { + discard_edit_callbacks(&cx); // Roll back: the edit produced something we cannot display. if let Some(ed) = doc.editor.as_mut() && let Some((_, prev, keys, _)) = ed.undo.pop() @@ -251,6 +358,9 @@ impl Session { return Err(e); } doc.dirty = true; + if let Ok(mut runtime) = doc.js_runtime.lock() { + runtime.sync_field_ids(doc.form.iter().map(|f| crate::js::field_object_id(f.obj))); + } doc.generation += 1; doc.edit_notices = notices; // Only this edit's exits are skipped on the next focus loss: one an earlier change ran is @@ -299,6 +409,11 @@ impl Session { } doc.edit_notices.clear(); Self::sync_dirty(doc); + if let Some(editor) = doc.editor.as_ref() + && let Some((_, previous, _, _)) = editor.redo.last() + { + sync_script_history(doc, previous, &editor.cos); + } // The change whose exit ran is undone (or redone without its scripts): skip no exit. doc.xfa_exited.clear(); doc.generation += 1; @@ -331,6 +446,11 @@ impl Session { } doc.edit_notices.clear(); Self::sync_dirty(doc); + if let Some(editor) = doc.editor.as_ref() + && let Some((_, previous, _, _)) = editor.undo.last() + { + sync_script_history(doc, previous, &editor.cos); + } // The change whose exit ran is undone (or redone without its scripts): skip no exit. doc.xfa_exited.clear(); doc.generation += 1; diff --git a/crates/engine/src/session/files.rs b/crates/engine/src/session/files.rs index 4177d1705f..2f2774ed22 100644 --- a/crates/engine/src/session/files.rs +++ b/crates/engine/src/session/files.rs @@ -1,9 +1,17 @@ use super::{ Arc, CombineSource, Combined, DataFormat, DocId, EditError, Hairlines, HairlinesFixed, Hidden, ImageResolution, LOW_RESOLUTION_PRINT_DPI, - OptimizeReport, SaveOptions, Session, SourceKind, SplitPart, decode_text, guard, open_source, open_source_with, optimize, print, + OptimizeReport, SaveOptions, Session, SourceKind, SplitPart, decode_text, guard, office_to_pdf, open_source, open_source_with, optimize, print, print_layer_states, print_raster, set_default_layer_state, source_kind, writable, write_full, }; +/// Whether a conversion may run LibreOffice for an Office document (seconds, in another +/// process) or must refuse it because the caller is on the frame thread. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Office { + Convert, + Refuse, +} + impl Session { /// The page count of another PDF (Replace Pages, Insert Pages dialogs). pub fn page_count_of(&self, name: &str, bytes: &Arc>) -> Result { @@ -41,33 +49,96 @@ impl Session { self.write_new(&doc) } - /// Convert a file Create understands (an image or plain text) to PDF bytes; a PDF is checked - /// (it must open and allow copying pages) and returned as it is. + /// Convert a file Create understands (an image, plain text or an Office document) to PDF + /// bytes; a PDF is checked (it must open and allow copying pages) and returned as it is. + /// An Office document runs LibreOffice ([`office_to_pdf`]), which takes seconds: keep it off + /// the frame thread ([`Self::convert_to_pdf_inline`] there). pub fn convert_to_pdf(&self, name: &str, bytes: &Arc>) -> Result<(SourceKind, Arc>), EditError> { + self.convert(name, bytes, Office::Convert) + } + + /// [`Self::convert_to_pdf`] for a caller on the frame thread, which must not wait seconds for + /// LibreOffice: an Office document is refused, pointing to File ▸ Open, which converts it in + /// the background. + pub fn convert_to_pdf_inline(&self, name: &str, bytes: &Arc>) -> Result<(SourceKind, Arc>), EditError> { + self.convert(name, bytes, Office::Refuse) + } + + fn convert(&self, name: &str, bytes: &Arc>, office: Office) -> Result<(SourceKind, Arc>), EditError> { let Some(kind) = source_kind(name, bytes) else { - return Err(EditError::Source(format!("{name}: this file type can't be converted; use a PDF, an image or a .txt file"))); + return Err(EditError::Source(format!( + "{name}: this file type can't be converted; use a PDF, an image, a .txt file or a Word, Excel or PowerPoint file" + ))); }; + if kind == SourceKind::Office && office == Office::Refuse { + return Err(EditError::Source(format!( + "{name}: Word, Excel and PowerPoint files can't be added here yet; open it with File ▸ Open, which converts it to a PDF" + ))); + } let title = name.rsplit_once('.').map_or(name, |(s, _)| s); // Image decoders read untrusted bytes: a panic in one must not take the app down. let created = guard(|| match kind { SourceKind::Pdf => open_source(name, bytes).map(|_| bytes.clone()), SourceKind::Image => self.create_from_images(&[(name.to_string(), bytes.to_vec())]), SourceKind::Text => self.create_from_text(title, &decode_text(bytes)), + SourceKind::Office => office_to_pdf(name, bytes), }) .map_err(|_| EditError::Source(format!("{name}: the file could not be read")))?; Ok((kind, created?)) } - /// A file for Combine as PDF bytes: an image or text file is converted; a PDF is returned as - /// it is, unchecked (Combine reads it itself, with its password), as is a file named .pdf - /// without a `%PDF-` header, which the reader may still open. + /// A file for Insert Pages into `dest`, as PDF bytes and its page count: an image, text or + /// Office file is converted, and a PDF is checked as the insert itself checks it, so a file + /// under `dest`'s own protection that withholds only page extraction is accepted, as pasted + /// pages are (#5081). An Office document runs LibreOffice: keep it off the frame thread + /// ([`Self::insert_source_inline`] there). + pub fn insert_source(&self, dest: DocId, name: &str, bytes: &Arc>) -> Result<(usize, Arc>), EditError> { + self.insert_source_with(dest, name, bytes, Office::Convert) + } + + /// [`Self::insert_source`] on the frame thread: Office documents are refused, as + /// [`Self::convert_to_pdf_inline`] refuses them. PDFs are still checked as the insert itself + /// checks them (#5081). + pub fn insert_source_inline(&self, dest: DocId, name: &str, bytes: &Arc>) -> Result<(usize, Arc>), EditError> { + self.insert_source_with(dest, name, bytes, Office::Refuse) + } + + fn insert_source_with(&self, dest: DocId, name: &str, bytes: &Arc>, office: Office) -> Result<(usize, Arc>), EditError> { + if !matches!(source_kind(name, bytes), Some(SourceKind::Pdf)) { + let (_, pdf) = self.convert(name, bytes, office)?; + return Ok((self.page_count_of(name, &pdf)?, pdf)); + } + let src = crate::sources::open_source_into(name, bytes, self.cos(dest)?)?; + Ok((pdfcraft_organize::page_count(&src)?, bytes.clone())) + } + + /// The page count of a PDF for Replace Pages in `dest`, checked as the replace itself checks + /// it: as with [`Self::insert_source`], a file under `dest`'s own protection that withholds + /// only page extraction is accepted (#5081). + pub fn replace_source(&self, dest: DocId, name: &str, bytes: &Arc>) -> Result { + Ok(pdfcraft_organize::page_count(&crate::sources::open_source_into(name, bytes, self.cos(dest)?)?)?) + } + + /// A file for Combine as PDF bytes: an image, text or Office file is converted; a PDF is + /// returned as it is, unchecked (Combine reads it itself, with its password), as is a file + /// named .pdf without a `%PDF-` header, which the reader may still open. pub fn as_pdf(&self, name: &str, bytes: &Arc>) -> Result>, EditError> { + self.as_pdf_with(name, bytes, Office::Convert) + } + + /// [`Self::as_pdf`] on the frame thread: Office documents are refused, as + /// [`Self::convert_to_pdf_inline`] refuses them. + pub fn as_pdf_inline(&self, name: &str, bytes: &Arc>) -> Result>, EditError> { + self.as_pdf_with(name, bytes, Office::Refuse) + } + + fn as_pdf_with(&self, name: &str, bytes: &Arc>, office: Office) -> Result>, EditError> { let pdf = match source_kind(name, bytes) { Some(SourceKind::Pdf) => true, - Some(SourceKind::Image | SourceKind::Text) => false, + Some(SourceKind::Image | SourceKind::Text | SourceKind::Office) => false, None => name.to_ascii_lowercase().ends_with(".pdf"), }; - if pdf { Ok(bytes.clone()) } else { self.convert_to_pdf(name, bytes).map(|(_, pdf)| pdf) } + if pdf { Ok(bytes.clone()) } else { self.convert(name, bytes, office).map(|(_, pdf)| pdf) } } /// Reduce File Size: Acrobat's defaults (colour and grey images above 225 ppi to 150 ppi, @@ -397,7 +468,7 @@ impl Session { /// New PDF bytes containing copies of `pages` of the document (Extract Pages). pub fn extract(&self, id: DocId, pages: &[usize]) -> Result>, EditError> { let src = self.cos(id)?; - if src.permissions().is_some_and(|p| !p.assemble()) { + if src.permissions().is_some_and(|p| !p.extract_pages()) { return Err(EditError::NotPermitted("extracting pages")); } let out = pdfcraft_organize::extract_pages(src, pages)?; @@ -407,7 +478,7 @@ impl Session { /// Split the document into several new PDFs. pub fn split(&self, id: DocId, by: &pdfcraft_organize::SplitBy) -> Result, EditError> { let src = self.cos(id)?; - if src.permissions().is_some_and(|p| !p.assemble()) { + if src.permissions().is_some_and(|p| !p.extract_pages()) { return Err(EditError::NotPermitted("splitting the document")); } let n = pdfcraft_organize::page_count(src)?; @@ -425,7 +496,7 @@ impl Session { /// images count once per part) fits; a page larger than the limit is a part of its own. pub fn split_by_size(&self, id: DocId, max_bytes: usize) -> Result, EditError> { let src = self.cos(id)?; - if src.permissions().is_some_and(|p| !p.assemble()) { + if src.permissions().is_some_and(|p| !p.extract_pages()) { return Err(EditError::NotPermitted("splitting the document")); } let n = pdfcraft_organize::page_count(src)?; diff --git a/crates/engine/src/session/save.rs b/crates/engine/src/session/save.rs index dd32cdbc5c..ec0033d318 100644 --- a/crates/engine/src/session/save.rs +++ b/crates/engine/src/session/save.rs @@ -71,8 +71,15 @@ impl Session { let keys = editor.keys.clone(); // The saved file carries no Password-field values (#3943, #3944); the open document keeps them. let typed = pdfcraft_forms::password_values(&editor.cos); - editor.cos = + // An incremental save keeps the old byte prefix and its object identities. A + // rewrite can reuse the same numbers for unrelated fields and icons. + let preserves_objects = bytes.starts_with(editor.saved.as_slice()) || bytes.starts_with(editor.cos.bytes().as_slice()); + let reopened = pdfcraft_cos::Document::open_with_password(bytes.clone(), keys.reopen.as_deref()).map_err(|e| EditError::Reopen(e.to_string()))?; + if !preserves_objects { + crate::js::replace_runtime(&mut doc.js_runtime); + } + editor.cos = reopened; pdfcraft_forms::restore_password_values(&mut editor.cos, &typed).map_err(|e| EditError::Reopen(e.to_string()))?; if !typed.is_empty() && doc.info.xfa.is_some() { let mut datasets = None; @@ -125,8 +132,9 @@ impl Session { } /// File ▸ Revert: back to the last saved version (or the file as opened). Undo history is - /// cleared, as in Acrobat. + /// cleared, as in Acrobat, and the document's opening scripts run again. pub fn revert(&mut self, id: DocId) -> Result<(), EditError> { + self.invalidate_javascript(id); let doc = self.doc_mut(id)?; let editor = doc.editor.as_mut().ok_or_else(|| EditError::ReadOnly(doc.read_only_reason.clone().unwrap_or_default()))?; let base = editor.saved.clone(); @@ -148,7 +156,11 @@ impl Session { // The changes whose exits ran are gone with the rest: skip no exit. doc.xfa_exited.clear(); doc.generation += 1; - Self::refresh(doc).inspect_err(|_| Self::show_released(doc)) + Self::refresh(doc).inspect_err(|_| Self::show_released(doc))?; + // Reverting reopens the saved file, in a fresh realm: its opening scripts run again, + // as they do on opening it (#269). + self.run_open_scripts(id); + Ok(()) } /// The document's file changed on disk (a LaTeX or Typst build rewrote it, #431): open @@ -159,6 +171,12 @@ impl Session { pub fn reload(&mut self, id: DocId, bytes: Arc>) -> Result<(), OpenError> { let old = self.docs.iter().position(|d| d.id == id).ok_or_else(|| OpenError::Invalid("the document is not open".into()))?; let doc = &self.docs[old]; + // Opening the replacement can initialize a new worker. Wait for the old + // invocation to finish before allowing repeated reloads of this document. + #[cfg(not(target_arch = "wasm32"))] + if doc.js_runtime.lock().map_err(|_| OpenError::Invalid("script runtime unavailable".into()))?.is_executing() { + return Err(OpenError::Invalid("a script is still running in this document; retry the reload once it finishes".into())); + } let (name, path, generation) = (doc.name.clone(), doc.path.clone(), doc.generation); let password = doc.editor.as_ref().and_then(|e| e.keys.reopen.clone()).or_else(|| doc.password.clone()); let new_id = self.open(name, path, bytes, password.as_deref())?; diff --git a/crates/engine/src/sources.rs b/crates/engine/src/sources.rs index 91d6efa3ee..4d1b5ab133 100644 --- a/crates/engine/src/sources.rs +++ b/crates/engine/src/sources.rs @@ -15,6 +15,16 @@ pub struct Combined { pub renamed_fields: Vec<(String, String)>, } +/// Convert the Office document `bytes` named `name` (Word, Excel, PowerPoint, OpenDocument, +/// RTF) to PDF with an installed LibreOffice, and check the result opens. It runs a separate +/// process for seconds, so callers keep it off the frame thread. Desktop only: on the web it +/// returns an error. +pub fn office_to_pdf(name: &str, bytes: &Arc>) -> Result>, EditError> { + let pdf = Arc::new(pdfcraft_create::office_to_pdf(name, bytes)?); + open_source(name, &pdf)?; + Ok(pdf) +} + /// Parse another PDF to copy pages from. pub(crate) fn open_source(name: &str, bytes: &Arc>) -> Result { open_source_with(name, bytes, None) @@ -25,6 +35,19 @@ pub(crate) fn open_source_with(name: &str, bytes: &Arc>, password: Optio source_document(bytes, password).map_err(|p| EditError::Source(format!("{name}: {p}"))) } +/// [`open_source`] for pages inserted into `dest`. A source that withholds page extraction but +/// allows assembly is accepted when `dest` will be saved under the very same protection (file +/// key and `/Encrypt` values), as when pages cut or copied from a document are pasted back into +/// it: they never leave its protection (#5081). +pub(crate) fn open_source_into(name: &str, bytes: &Arc>, dest: &pdfcraft_cos::Document) -> Result { + let same_protection = |src: &pdfcraft_cos::Document| match (src.security(), dest.output_handler()) { + (Some(s), Some(d)) => s.file_key() == d.file_key() && s.dict() == d.dict(), + _ => false, + }; + let permitted = |src: &pdfcraft_cos::Document| src.permissions().is_none_or(|p| p.extract_pages() || (p.assemble() && same_protection(src))); + source_document_if(bytes, None, permitted).map_err(|p| EditError::Source(format!("{name}: {p}"))) +} + /// Import the comments of `src` onto the pages of `doc` with the same numbers. pub(crate) fn import_pdf_comments(doc: &mut pdfcraft_cos::Document, src: &pdfcraft_cos::Document) -> Result<(), EditError> { if pdfcraft_organize::import_comments(doc, src)? == 0 { @@ -44,8 +67,17 @@ pub(crate) fn comment_source(bytes: &Arc>) -> Result>, password: Option<&str>) -> Result { + source_document_if(bytes, password, |d| d.permissions().is_none_or(|p| p.extract_pages())) +} + +/// Open `bytes` as a page source, refused as [`SourceProblem::NotPermitted`] unless `permitted`. +fn source_document_if( + bytes: &Arc>, + password: Option<&str>, + permitted: impl Fn(&pdfcraft_cos::Document) -> bool, +) -> Result { match std::panic::catch_unwind(|| pdfcraft_cos::Document::open_with_password(bytes.clone(), password)) { - Ok(Ok(d)) if d.permissions().is_some_and(|p| !p.assemble()) => Err(SourceProblem::NotPermitted), + Ok(Ok(d)) if !permitted(&d) => Err(SourceProblem::NotPermitted), Ok(Ok(d)) => Ok(d), Ok(Err(pdfcraft_cos::CosError::NeedsPassword)) => Err(SourceProblem::Password), Ok(Err(pdfcraft_cos::CosError::WrongPassword)) => Err(SourceProblem::WrongPassword), diff --git a/crates/engine/src/spooler.rs b/crates/engine/src/spooler.rs index 56347d8f09..5c76bd6739 100644 --- a/crates/engine/src/spooler.rs +++ b/crates/engine/src/spooler.rs @@ -2,25 +2,51 @@ use std::sync::Arc; -use pdfcraft_cos::{SaveOptions, write_full}; +use pdfcraft_cos::{Dict, Object, SaveOptions, Stream, write_full}; +use pdfcraft_render::{PRINT_TAG, PageRenderer, RenderConfig, RenderRequest, Tile}; use crate::guard; +/// The resolution [`spooler_copy`] flattens a sheet at, unless the sheet is too large for it +/// ([`MAX_FLATTENED_PIXELS`], [`MAX_FLATTENED_SIDE`]). +pub const FLATTEN_DPI: f64 = 300.0; +/// The side of the largest tile a flattened sheet is drawn with, in pixels: 2000 × 2000 stays +/// under the 2²² pixels past which Windows' PDF renderer shrinks an image (#5131), so [`tiled`] +/// leaves the tiles alone, and under the renderer's own tile limit. +pub const FLATTEN_TILE: u32 = 2000; +/// The most pixels one flattened sheet has. A 24 × 36 in sheet at 300 dpi (78 MP) keeps its +/// resolution; a larger one is drawn at a lower one. The sheet is rendered and encoded a tile at +/// a time, so this bounds the encoded picture (240 MB as raw RGB) and the time it takes, not the +/// memory one render needs. +pub const MAX_FLATTENED_PIXELS: f64 = 80.0e6; +/// The most pixels along a flattened sheet's longer side: a long banner gets a lower resolution +/// rather than hundreds of tiles, each of which interprets the whole sheet's content again. +pub const MAX_FLATTENED_SIDE: f64 = 16_000.0; +/// The most bytes of encoded pictures one copy holds. A sheet whose picture would pass it keeps +/// its vectors and prints as it would have without flattening. +const MAX_FLATTENED_BYTES: usize = 512 << 20; + /// The print-ready PDF `bytes` ([`crate::Session::print_pdf`]) as they go to the system spooler -/// with `job`. Two things happen on Windows only; elsewhere the bytes go as they are. +/// with `job`. Three things happen on Windows only, in this order; elsewhere the bytes go as they +/// are. /// /// - A job in grayscale is made grey here first ([`greyed`]), and `job.grey_pages` says so. /// Windows has no colour conversion of its own, and a driver that lists no grey option (Microsoft /// Print to PDF, or one with only a vendor's own grey setting) refused the job outright /// (#5806). The driver is still asked for grey where it has it. +/// - With `flatten_transparency`, each sheet that uses transparency becomes an opaque picture of +/// itself at [`FLATTEN_DPI`] ([`flattened`]). It is meant for GDI (v3) drivers, which get the +/// job through Windows' XPS-to-GDI conversion: that conversion fails on soft masks (#6155), +/// prints halos around them (#5838) or prints transparency as black boxes (#5940). It comes +/// after greying, so the picture is of the grey sheet. /// - Large images are drawn as tiles ([`pdfcraft_optimize::tile_large_images`], #5131): Windows' /// PDF renderer prints an image of more than 2²² pixels from a shrunk copy, so a scan comes out -/// at the printer's resolution. +/// at the printer's resolution. A flattened sheet's tiles are already under that. /// -/// When the copy can't be read or written, the bytes go as they are: neither step ever stops a -/// print, and a grayscale job whose pages couldn't be made grey keeps asking the driver for grey, +/// When the copy can't be read or written, the bytes go as they are: no step ever stops a print, +/// and a grayscale job whose pages couldn't be made grey keeps asking the driver for grey, /// strictly, as before. -pub fn spooler_copy(bytes: Vec, job: &mut crate::print::spool::Job) -> Vec { +pub fn spooler_copy(bytes: Vec, job: &mut crate::print::spool::Job, flatten_transparency: bool) -> Vec { if !cfg!(windows) { return bytes; } @@ -35,6 +61,9 @@ pub fn spooler_copy(bytes: Vec, job: &mut crate::print::spool::Job) -> Vec {} } } + if flatten_transparency && let Some(flat) = flattened(bytes.clone(), FLATTEN_DPI) { + bytes = Arc::new(flat); + } // Once `tiled` returns, its document is gone and `bytes` is the only owner left: no copy. tiled(bytes.clone()).unwrap_or_else(|| Arc::try_unwrap(bytes).unwrap_or_else(|shared| (*shared).clone())) } @@ -77,6 +106,154 @@ pub fn tiled(bytes: Arc>) -> Option> { .flatten() } +/// `bytes` with each page that uses transparency ([`pdfcraft_preflight::page_uses_transparency`]) +/// replaced by an opaque picture of itself, or `None` when no page needs it, none could be +/// rendered, or the copy can't be read or written. Pages without transparency stay as they are. +/// +/// The picture is at `dpi` (72 to 600; [`FLATTEN_DPI`] if not a number), lower on a sheet past +/// [`MAX_FLATTENED_PIXELS`] or [`MAX_FLATTENED_SIDE`], in tiles of at most [`FLATTEN_TILE`] +/// pixels a side, each one image (RGB over white, Flate) rendered and encoded on its own. The +/// page keeps its boxes, `/Rotate` and `/UserUnit`, so the sheet keeps its size and orientation. +/// Its transparency group goes, since nothing on it is transparent any more, and so do its +/// annotations, which the picture already shows. A print-ready PDF has none: +/// [`pdfcraft_print::impose`] draws them into the sheet. +/// +/// A page whose picture can't be rendered keeps its vectors, as does one whose picture would take +/// the pictures past 512 MB encoded. +pub fn flattened(bytes: Arc>, dpi: f64) -> Option> { + guard(|| { + let mut doc = pdfcraft_cos::Document::open(bytes.clone()).ok()?; + let pages = pdfcraft_model::pages(&doc); + let transparent: Vec = + pages.iter().enumerate().filter(|(_, p)| pdfcraft_preflight::page_uses_transparency(&doc, &p.dict)).map(|(i, _)| i).collect(); + if transparent.is_empty() { + return None; + } + // As Print as image renders (`print_raster`): printing is not the View event, comments + // are drawn, and the sheet clears to white; `PRINT_TAG` turns reading colours off. The + // print-ready PDF already holds the layers as they print. + let config = RenderConfig { for_print: true, ..RenderConfig::default() }; + let mut renderer = PageRenderer::new(bytes, config); + // The renderer must list the same pages, or a sheet could get another one's picture. + if renderer.page_count() != pages.len() { + return None; + } + let dpi = if dpi.is_finite() { dpi.clamp(72.0, 600.0) } else { FLATTEN_DPI }; + let mut budget = MAX_FLATTENED_BYTES; + let mut done = 0usize; + for i in transparent { + let Some(page) = pages.get(i) else { continue }; + let Some(picture) = sheet_picture(&doc, &mut renderer, page, i, dpi, &mut budget) else { continue }; + let mut xobjects = Dict::new(); + for (name, image) in picture.tiles { + xobjects.set(name, Object::Ref(doc.add(Object::Stream(image)))); + } + let mut resources = Dict::new(); + resources.set("XObject", Object::Dict(xobjects)); + let contents = doc.add(Object::Stream(Stream::flate(Dict::new(), &picture.content))); + let replaced = doc.update_dict(page.obj, |d| { + d.set("Contents", Object::Ref(contents)); + d.set("Resources", Object::Dict(resources)); + d.remove(b"Annots"); + d.remove(b"Group"); + }); + if replaced.is_ok() { + done += 1; + } + } + (done > 0).then(|| write_full(&doc, &SaveOptions::default()).ok()).flatten() + }) + .ok() + .flatten() +} + +/// A page drawn as tiles: the content that draws them, and each tile's resource name and image. +struct Picture { + content: Vec, + tiles: Vec<(Vec, Stream)>, +} + +/// The scale (pixels per point) a `w` × `h` pt sheet is flattened at for `dpi`: lowered so the +/// sheet stays within [`MAX_FLATTENED_PIXELS`] and [`MAX_FLATTENED_SIDE`]. +fn flatten_scale(w: f64, h: f64, dpi: f64) -> f64 { + let (w, h) = (w.max(1.0), h.max(1.0)); + (dpi / 72.0).min((MAX_FLATTENED_PIXELS / (w * h)).sqrt()).min(MAX_FLATTENED_SIDE / w.max(h)) +} + +/// Page `index` of `doc` rendered by `renderer` as tiles at `dpi` (see [`flattened`]), with the +/// content that draws them where the page shows. `None` when a tile doesn't render, or the +/// tiles' encoded size would pass what is left of `budget`, which a page that succeeds is +/// charged. +fn sheet_picture( + doc: &pdfcraft_cos::Document, + renderer: &mut PageRenderer, + page: &pdfcraft_model::Page, + index: usize, + dpi: f64, + budget: &mut usize, +) -> Option { + // The renderer draws the page as displayed (crop box, `/UserUnit`, `/Rotate`), y down; the + // view matrix takes that display space (y up) back to the page's user space. + let (w, h) = page.display_size(doc); + let scale = flatten_scale(w, h, dpi) as f32; + if !(scale.is_finite() && scale > 0.0) { + return None; + } + let (width, height) = (pdfcraft_render::device_pixels(w as f32, scale), pdfcraft_render::device_pixels(h as f32, scale)); + let s = f64::from(scale); + let mut content = format!("q {} cm\n", matrix(page.view_matrix(doc))); + let mut tiles = Vec::new(); + let mut used = 0usize; + for y in (0..height).step_by(FLATTEN_TILE as usize) { + for x in (0..width).step_by(FLATTEN_TILE as usize) { + let (tw, th) = (FLATTEN_TILE.min(width - x), FLATTEN_TILE.min(height - y)); + let r = + renderer.render(RenderRequest { page: index, scale, tile: Some(Tile { x, y, w: tw, h: th }), tag: PRINT_TAG, ..Default::default() }); + let pixels = (tw as usize) * (th as usize); + if r.error.is_some() || r.width != tw || r.height != th || r.rgba.len() != pixels * 4 { + return None; + } + let mut rgba = r.rgba.to_vec(); + crate::print_raster::over_white(&mut rgba, false); + let rgb: Vec = rgba.as_chunks::<4>().0.iter().flat_map(|p| [p[0], p[1], p[2]]).collect(); + let mut image = Dict::new(); + image.set("Type", Object::name("XObject")); + image.set("Subtype", Object::name("Image")); + image.set("Width", Object::Int(i64::from(tw))); + image.set("Height", Object::Int(i64::from(th))); + image.set("ColorSpace", Object::name("DeviceRGB")); + image.set("BitsPerComponent", Object::Int(8)); + let image = Stream::flate(image, &rgb); + used = used.saturating_add(image.raw.len()); + if used > *budget { + return None; + } + // The tile's place in display space, y up: its top edge is `y` pixels below the top. + // The last row and column may reach past the page by under a pixel, which the crop + // box clips. + let name = format!("PCFlat{}", tiles.len()); + let place = [f64::from(tw) / s, 0.0, 0.0, f64::from(th) / s, f64::from(x) / s, h - f64::from(y + th) / s]; + content.push_str(&format!("q {} cm /{name} Do Q\n", matrix(place))); + tiles.push((name.into_bytes(), image)); + } + } + content.push_str("Q\n"); + *budget -= used; + Some(Picture { content: content.into_bytes(), tiles }) +} + +/// `m` as the six operands of `cm`, at full precision. +fn matrix(m: [f64; 6]) -> String { + let mut out = Vec::new(); + for (i, v) in m.into_iter().enumerate() { + if i > 0 { + out.push(b' '); + } + pdfcraft_cos::serialize(&Object::Real(v), &mut out); + } + String::from_utf8_lossy(&out).into_owned() +} + #[cfg(test)] mod tests { use pdfcraft_cos::{Dict, Document, Object, Stream}; @@ -137,13 +314,13 @@ mod tests { assert_eq!(tiled(Arc::new(page_with_image(100, 100))), None); assert_eq!(tiled(Arc::new(b"not a PDF".to_vec())), None); let mut job = Job::default(); - assert_eq!(spooler_copy(b"not a PDF".to_vec(), &mut job), b"not a PDF"); + assert_eq!(spooler_copy(b"not a PDF".to_vec(), &mut job, false), b"not a PDF"); let small = page_with_image(100, 100); - assert_eq!(spooler_copy(small.clone(), &mut job), small); + assert_eq!(spooler_copy(small.clone(), &mut job, false), small); assert!(!job.grey_pages); // A grayscale job whose copy can't be read asks the driver for grey as before. let mut grey = Job { grayscale: true, ..Job::default() }; - assert_eq!(spooler_copy(b"not a PDF".to_vec(), &mut grey), b"not a PDF"); + assert_eq!(spooler_copy(b"not a PDF".to_vec(), &mut grey, false), b"not a PDF"); assert!(!grey.grey_pages); } @@ -195,7 +372,7 @@ mod tests { fn a_grayscale_job_sends_grey_pages_on_windows_only() { let red = red_page(); let mut job = Job { grayscale: true, ..Job::default() }; - let sent = spooler_copy(red.clone(), &mut job); + let sent = spooler_copy(red.clone(), &mut job, false); if cfg!(windows) { assert!(job.grey_pages); assert!(!first_page_content(sent).contains(" rg")); @@ -204,7 +381,7 @@ mod tests { assert_eq!(sent, red); } let mut colour = Job::default(); - assert_eq!(spooler_copy(red.clone(), &mut colour), red); + assert_eq!(spooler_copy(red.clone(), &mut colour, false), red); assert!(!colour.grey_pages); } @@ -212,7 +389,7 @@ mod tests { #[test] fn the_spooler_gets_tiles_on_windows_only() { let large = page_with_image(2100, 2100); - let sent = spooler_copy(large.clone(), &mut Job::default()); + let sent = spooler_copy(large.clone(), &mut Job::default(), false); if cfg!(windows) { assert_eq!(Some(&sent), tiled(Arc::new(large.clone())).as_ref()); assert_ne!(sent, large); @@ -220,4 +397,239 @@ mod tests { assert_eq!(sent, large); } } + + /// A PDF with the pages `make` returns, each given its type, its parent and, when it has + /// none, a US Letter MediaBox. `make` adds what the pages refer to. + fn pdf_of(make: impl FnOnce(&mut Document) -> Vec) -> Vec { + let mut doc = Document::new_empty(); + let pages = doc.root().and_then(|r| doc.get(r).as_dict().and_then(|d| d.reference(b"Pages"))).unwrap(); + let made = make(&mut doc); + let count = made.len() as i64; + let kids: Vec = made + .into_iter() + .map(|mut page| { + page.set("Type", Object::name("Page")); + page.set("Parent", Object::Ref(pages)); + if !page.contains(b"MediaBox") { + page.set("MediaBox", Object::Array([0, 0, 612, 792].map(Object::Int).to_vec())); + } + Object::Ref(doc.add(Object::Dict(page))) + }) + .collect(); + doc.update_dict(pages, |d| { + d.set("Count", Object::Int(count)); + d.set("Kids", Object::Array(kids)); + }) + .unwrap(); + write_full(&doc, &SaveOptions::default()).unwrap() + } + + /// A page drawing `content` with `resources`. + fn page(doc: &mut Document, resources: Dict, content: &[u8]) -> Dict { + let content = doc.add(Object::Stream(Stream::from_raw(Dict::new(), content.to_vec()))); + let mut page = Dict::new(); + page.set("Resources", Object::Dict(resources)); + page.set("Contents", Object::Ref(content)); + page + } + + /// An image XObject dictionary of `w` × `h` 8-bit samples in `space`. + fn image_dict(w: i64, h: i64, space: &str) -> Dict { + let mut image = Dict::new(); + image.set("Type", Object::name("XObject")); + image.set("Subtype", Object::name("Image")); + image.set("Width", Object::Int(w)); + image.set("Height", Object::Int(h)); + image.set("ColorSpace", Object::name(space)); + image.set("BitsPerComponent", Object::Int(8)); + image + } + + /// A blue square, the content of a page without transparency. + const BLUE_SQUARE: &[u8] = b"0 0 1 rg 100 100 200 200 re f"; + + /// A page without transparency: a blue square. + fn vector_page(doc: &mut Document) -> Dict { + page(doc, Dict::new(), BLUE_SQUARE) + } + + /// A page drawing a red image over a blue square, with a soft mask that fades the image in + /// from left to right. + fn soft_masked_page(doc: &mut Document) -> Dict { + let alpha: Vec = (0..64 * 64).map(|i| (i % 64 * 4) as u8).collect(); + let mask = doc.add(Object::Stream(Stream::flate(image_dict(64, 64, "DeviceGray"), &alpha))); + let mut image = image_dict(64, 64, "DeviceRGB"); + image.set("SMask", Object::Ref(mask)); + let red: Vec = (0..64 * 64).flat_map(|_| [255, 0, 0]).collect(); + let image = doc.add(Object::Stream(Stream::flate(image, &red))); + let mut xobjects = Dict::new(); + xobjects.set("Im", Object::Ref(image)); + let mut resources = Dict::new(); + resources.set("XObject", Object::Dict(xobjects)); + page(doc, resources, b"0 0 1 rg 100 100 200 200 re f q 300 0 0 300 150 150 cm /Im Do Q") + } + + /// Page `n`'s dictionary, inherited entries filled in, and the operators of its content. + fn page_of(doc: &Document, n: usize) -> (Dict, String) { + let page = pdfcraft_model::pages(doc).swap_remove(n); + let contents = page.dict.reference(b"Contents").unwrap(); + let content = match &*doc.get(contents) { + Object::Stream(s) => String::from_utf8_lossy(&s.decoded().unwrap()).into_owned(), + other => panic!("{other:?}"), + }; + (page.dict, content) + } + + /// The images among page `n`'s XObjects, as (width, height, has a soft mask), sorted. + fn page_images(doc: &Document, n: usize) -> Vec<(i64, i64, bool)> { + let (page, _) = page_of(doc, n); + let resources = doc.resolve(page.get(b"Resources").unwrap()); + let xobjects = doc.resolve(resources.as_dict().unwrap().get(b"XObject").unwrap()); + let mut images: Vec<(i64, i64, bool)> = xobjects + .as_dict() + .unwrap() + .iter() + .filter_map(|(_, x)| match &*doc.resolve(x) { + Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Image") => { + Some((s.dict.int(b"Width")?, s.dict.int(b"Height")?, s.dict.contains(b"SMask"))) + } + _ => None, + }) + .collect(); + images.sort(); + images + } + + /// #6155, #5838, #5940: a page with a soft-masked image becomes an opaque picture in tiles + /// under Windows' image cap, on a sheet of the same size, and tiling leaves them alone. + #[test] + fn a_soft_masked_page_is_flattened_into_tiles() { + let flat = flattened(Arc::new(pdf_of(|doc| vec![soft_masked_page(doc)])), FLATTEN_DPI).expect("flattened"); + let doc = Document::open(Arc::new(flat.clone())).unwrap(); + // US Letter at 300 dpi is 2550 × 3300 pixels: two tiles across, two down. + assert_eq!(page_images(&doc, 0), [(550, 1300, false), (550, 2000, false), (2000, 1300, false), (2000, 2000, false)]); + assert!(doc.scan_objects().all(|(_, o)| !matches!(&*o, Object::Stream(s) if s.dict.contains(b"SMask")))); + let (page, content) = page_of(&doc, 0); + assert_eq!(page.get(b"MediaBox"), Some(&Object::Array([0, 0, 612, 792].map(Object::Int).to_vec()))); + assert_eq!(content.matches(" Do").count(), 4, "{content}"); + assert!(!pdfcraft_preflight::page_uses_transparency(&doc, &page)); + assert_eq!(tiled(Arc::new(flat)), None); + } + + /// A page without transparency, or with a transparency group around opaque content only (as + /// a print-ready sheet draws a page that Word gave one), keeps its vectors; so does a copy + /// that can't be read. + #[test] + fn a_page_without_transparency_or_unreadable_is_not_flattened() { + assert_eq!(flattened(Arc::new(pdf_of(|doc| vec![vector_page(doc)])), FLATTEN_DPI), None); + assert_eq!(flattened(Arc::new(page_with_image(100, 100)), FLATTEN_DPI), None); + let grouped = pdf_of(|doc| { + let mut group = Dict::new(); + group.set("S", Object::name("Transparency")); + let mut form = Dict::new(); + form.set("Type", Object::name("XObject")); + form.set("Subtype", Object::name("Form")); + form.set("BBox", Object::Array([0, 0, 612, 792].map(Object::Int).to_vec())); + form.set("Group", Object::Dict(group)); + let form = doc.add(Object::Stream(Stream::from_raw(form, BLUE_SQUARE.to_vec()))); + let mut xobjects = Dict::new(); + xobjects.set("Fm", Object::Ref(form)); + let mut resources = Dict::new(); + resources.set("XObject", Object::Dict(xobjects)); + vec![page(doc, resources, b"/Fm Do")] + }); + assert_eq!(flattened(Arc::new(grouped), FLATTEN_DPI), None); + assert_eq!(flattened(Arc::new(b"not a PDF".to_vec()), FLATTEN_DPI), None); + } + + /// Of two pages, only the one with transparency becomes a picture; the other keeps its + /// content as it was. + #[test] + fn only_the_transparent_page_of_two_is_flattened() { + let source = pdf_of(|doc| vec![vector_page(doc), soft_masked_page(doc)]); + let flat = flattened(Arc::new(source), 150.0).expect("flattened"); + let doc = Document::open(Arc::new(flat)).unwrap(); + assert_eq!(page_of(&doc, 0).1, String::from_utf8_lossy(BLUE_SQUARE)); + // US Letter at 150 dpi is 1275 × 1650 pixels: one tile. + assert_eq!(page_images(&doc, 1), [(1275, 1650, false)]); + assert_eq!(page_of(&doc, 1).1.matches(" Do").count(), 1); + } + + /// The picture lands where the page showed what it pictures, on a page turned a quarter + /// with a crop box away from the origin. + #[test] + fn a_flattened_turned_page_looks_as_it_did() { + let source = pdf_of(|doc| { + let mut half = Dict::new(); + half.set("ca", Object::Real(0.5)); + let mut states = Dict::new(); + states.set("Half", Object::Dict(half)); + let mut resources = Dict::new(); + resources.set("ExtGState", Object::Dict(states)); + // Half-transparent red over the crop box's lower left 100 × 100 pt. + let mut page = page(doc, resources, b"/Half gs 1 0 0 rg 50 0 100 100 re f"); + page.set("MediaBox", Object::Array([0, 0, 300, 200].map(Object::Int).to_vec())); + page.set("CropBox", Object::Array([50, 0, 300, 200].map(Object::Int).to_vec())); + page.set("Rotate", Object::Int(90)); + vec![page] + }); + let flat = flattened(Arc::new(source.clone()), 72.0).expect("flattened"); + let (page, _) = page_of(&Document::open(Arc::new(flat.clone())).unwrap(), 0); + assert_eq!(page.get(b"Rotate"), Some(&Object::Int(90))); + assert_eq!(page.get(b"CropBox"), Some(&Object::Array([50, 0, 300, 200].map(Object::Int).to_vec()))); + let render = |bytes: Vec| { + let r = PageRenderer::new(Arc::new(bytes), RenderConfig::default()).render(RenderRequest { + page: 0, + scale: 1.0, + tag: PRINT_TAG, + ..Default::default() + }); + assert!(r.error.is_none(), "{:?}", r.error); + r + }; + let (before, after) = (render(source), render(flat)); + // Turned a quarter clockwise, the page shows 200 × 250 pt with the red at its top left. + assert_eq!((before.width, before.height), (200, 250)); + assert_eq!((after.width, after.height), (200, 250)); + let px = |r: &pdfcraft_render::RenderedPage, x: u32, y: u32| { + let i = ((y * r.width + x) * 4) as usize; + [r.rgba[i], r.rgba[i + 1], r.rgba[i + 2]] + }; + let pink = px(&before, 50, 50); + assert!(pink[0] > 240 && (100..160).contains(&pink[1]), "{pink:?}"); + assert_eq!(px(&before, 150, 200), [255, 255, 255]); + // Sampled away from the red's edges (at 100 px), where pixels may blend. + for y in (5..250).step_by(10) { + for x in (5..200).step_by(10) { + let (b, a) = (px(&before, x, y), px(&after, x, y)); + assert!(b.iter().zip(a).all(|(b, a)| b.abs_diff(a) <= 3), "({x}, {y}): {b:?} became {a:?}"); + } + } + } + + /// A sheet keeps the full resolution up to 24 × 36 in; a larger one gets a lower one within + /// the pixel bound, and a long banner one within the side bound. + #[test] + fn a_large_sheet_is_flattened_at_a_lower_resolution() { + let dpi = |w: f64, h: f64| flatten_scale(w * 72.0, h * 72.0, FLATTEN_DPI) * 72.0; + assert!((dpi(8.5, 11.0) - 300.0).abs() < 1e-9); + assert!((dpi(24.0, 36.0) - 300.0).abs() < 1e-9); + let large = dpi(36.0, 48.0); + assert!(large < 300.0 && 36.0 * 48.0 * large * large <= MAX_FLATTENED_PIXELS * 1.000_001, "{large}"); + assert!((dpi(36.0, 1200.0) * 1200.0 - MAX_FLATTENED_SIDE).abs() < 1e-6); + } + + /// With the flag off a transparent page goes to the spooler as it is; on, Windows gets the + /// flattened copy. + #[test] + fn the_spooler_flattens_transparency_only_when_asked() { + let source = pdf_of(|doc| vec![soft_masked_page(doc)]); + assert_eq!(spooler_copy(source.clone(), &mut Job::default(), false), source); + let sent = spooler_copy(source.clone(), &mut Job::default(), true); + if cfg!(windows) { + assert_eq!(Some(&sent), flattened(Arc::new(source), FLATTEN_DPI).as_ref()); + } else { + assert_eq!(sent, source); + } + } } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 56972abaa6..91b00df4cc 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1575,8 +1575,13 @@ fn a_page_with_a_form_field_shown_twice_keeps_one_field() { #[test] fn files_that_cannot_be_converted_are_refused_clearly() { let s = Session::new(); - let err = s.convert_to_pdf("report.docx", &Arc::new(b"PK\x03\x04".to_vec())).unwrap_err(); - assert_eq!(err, EditError::Source("report.docx: this file type can't be converted; use a PDF, an image or a .txt file".into())); + let err = s.convert_to_pdf("archive.zip", &Arc::new(b"PK\x03\x04".to_vec())).unwrap_err(); + assert_eq!( + err, + EditError::Source( + "archive.zip: this file type can't be converted; use a PDF, an image, a .txt file or a Word, Excel or PowerPoint file".into() + ) + ); assert!(matches!(s.convert_to_pdf("empty", &Arc::new(Vec::new())), Err(EditError::Source(_)))); // Damaged inputs of a known type fail with an error, not a panic. assert!(s.convert_to_pdf("bad.png", &Arc::new(b"\x89PNG\r\n\x1a\nnope".to_vec())).is_err()); @@ -1597,8 +1602,8 @@ fn files_for_combine_are_made_into_pdfs_and_pdfs_pass_through() { assert!(Arc::ptr_eq(&s.as_pdf("old.PDF", &headless).unwrap(), &headless)); assert!(s.as_pdf("scan.jpg", &Arc::new(jpeg_bytes())).unwrap().starts_with(b"%PDF-")); assert!(s.as_pdf("notes.txt", &Arc::new(b"hello".to_vec())).unwrap().starts_with(b"%PDF-")); - let err = s.as_pdf("report.docx", &Arc::new(b"PK\x03\x04".to_vec())).unwrap_err(); - assert!(matches!(err, EditError::Source(m) if m.starts_with("report.docx: this file type can't be converted"))); + let err = s.as_pdf("archive.zip", &Arc::new(b"PK\x03\x04".to_vec())).unwrap_err(); + assert!(matches!(err, EditError::Source(m) if m.starts_with("archive.zip: this file type can't be converted"))); } fn protected(user: &str, owner: &str, permissions: i32) -> Arc> { @@ -1615,6 +1620,206 @@ fn protected(user: &str, owner: &str, permissions: i32) -> Arc> { Arc::new(pdfcraft_cos::write_full(&doc, &Default::default()).unwrap()) } +/// Two pages protected with `algorithm` under the permissions password "boss" (no open +/// password), restricting changes to `changes`. +fn protected_with_changes(algorithm: pdfcraft_cos::Algorithm, changes: Changes) -> Arc> { + protected_pages(2, algorithm, changes, 8) +} + +/// [`protected_with_changes`] with `pages` pages; `seed` gives the file key and salts, so two +/// files with the same arguments but `pages` share their protection exactly. +fn protected_pages(pages: usize, algorithm: pdfcraft_cos::Algorithm, changes: Changes, seed: u8) -> Arc> { + let mut doc = pdfcraft_cos::Document::open(Arc::new(fixture(pages))).unwrap(); + let protection = Protection { permissions_password: Some("boss".into()), changes, algorithm, ..Protection::default() }; + doc.set_encryption(&pdfcraft_cos::NewEncryption { + algorithm, + user_password: "", + owner_password: "boss", + permissions: protection.permission_bits(), + encrypt_metadata: true, + seed: [seed; 32], + }) + .unwrap(); + Arc::new(pdfcraft_cos::write_full(&doc, &Default::default()).unwrap()) +} + +/// "Any except extracting pages" (#5081) withholds page extraction on every handler revision. At +/// revision 3 and later the preset (bit 4 without bit 11) allows no page changes either; at +/// revision 2, where bit 4 covers assembly, extraction, splitting and being a page source are +/// still refused because copying is withheld. +#[test] +fn extraction_permissions_refuse_any_except_extract_on_every_revision() { + use pdfcraft_cos::Algorithm::{Aes128, Aes256, Rc4_40, Rc4_128}; + + for algorithm in [Rc4_128, Aes128, Aes256] { + let bytes = protected_with_changes(algorithm, Changes::AnyExceptExtract); + let doc = pdfcraft_cos::Document::open_with_password(bytes.clone(), None).unwrap(); + let permissions = doc.permissions().unwrap(); + assert!(!permissions.extract_pages(), "{algorithm:?}, /P={:#x}", permissions.bits); + assert!(!permissions.assemble(), "{algorithm:?}: bit 4 without bit 11 allows no page changes"); + } + let r2 = protected_with_changes(Rc4_40, Changes::AnyExceptExtract); + let mut session = Session::new(); + let r2_id = session.open("r2.pdf", None, r2.clone(), None).unwrap(); + let permissions = session.get(r2_id).unwrap().permissions().unwrap(); + assert!(permissions.assemble() && !permissions.extract_pages(), "revision 2, /P={:#x}", permissions.bits); + assert_eq!(session.extract(r2_id, &[0]), Err(EditError::NotPermitted("extracting pages"))); + assert_eq!(session.split(r2_id, &pdfcraft_organize::SplitBy::PageCount(1)), Err(EditError::NotPermitted("splitting the document"))); + assert_eq!(crate::combine_source_check(&r2, None), Err(crate::SourceProblem::NotPermitted)); +} + +/// "Any except extracting pages" (#5081): Extract, Split and Combine refuse the file without the +/// permissions password and leave it unchanged; Combine takes it with that password. +#[test] +fn extraction_permissions_cover_revision_three_to_six_and_leave_no_partial_output() { + let bytes = protected_with_changes(pdfcraft_cos::Algorithm::Aes256, Changes::AnyExceptExtract); + assert_eq!(crate::combine_source_check(&bytes, None), Err(crate::SourceProblem::NotPermitted)); + assert_eq!(crate::combine_source_check(&bytes, Some("boss")), Ok(())); + let mut restricted = Session::new(); + let id = restricted.open("restricted.pdf", None, bytes.clone(), None).unwrap(); + let before = restricted.get(id).unwrap().bytes.clone(); + assert_eq!(restricted.extract(id, &[0]), Err(EditError::NotPermitted("extracting pages"))); + assert_eq!(restricted.split(id, &pdfcraft_organize::SplitBy::PageCount(1)), Err(EditError::NotPermitted("splitting the document"))); + assert_eq!(restricted.split_by_size(id, 1), Err(EditError::NotPermitted("splitting the document"))); + let doc = restricted.get(id).unwrap(); + assert_eq!(doc.info.pages.len(), 2); + assert!(!doc.dirty); + assert_eq!(doc.bytes, before, "a refused extraction leaves the source untouched"); + + let sources = vec![("restricted.pdf".into(), bytes.clone(), None)]; + assert!(matches!(restricted.combine_unlocked(&sources, &[None]), Err(EditError::Source(_)))); + let combined = restricted.combine_unlocked(&sources, &[Some("boss")]).unwrap(); + assert_eq!(pdfcraft_organize::page_count(&pdfcraft_cos::Document::open(combined).unwrap()).unwrap(), 2); +} + +/// A file under "Any except extracting pages" (#5081) is refused as an Insert Pages or Replace +/// Pages source for an unprotected document, which is left as it was, with nothing to undo. +#[test] +fn extraction_permissions_refuse_a_restricted_page_source() { + let bytes = protected_with_changes(pdfcraft_cos::Algorithm::Aes256, Changes::AnyExceptExtract); + let plain = Arc::new(fixture(1)); + let mut destination = Session::new(); + let dest = destination.open("destination.pdf", None, plain.clone(), None).unwrap(); + let insert = Edit::InsertPagesFrom { + name: "restricted.pdf".into(), + bytes: bytes.clone(), + pages: None, + at: 1, + field_conflicts: pdfcraft_organize::FieldConflicts::Rename, + }; + assert!(matches!(destination.apply(dest, insert), Err(EditError::Source(_)))); + assert_eq!(destination.get(dest).unwrap().bytes, plain, "a refused insert leaves the destination untouched"); + assert_eq!(destination.get(dest).unwrap().info.pages.len(), 1); + assert_eq!(destination.get(dest).unwrap().can_undo(), None); + assert!(matches!( + destination.apply(dest, Edit::ReplacePages { pages: vec![0], name: "restricted.pdf".into(), bytes: bytes.clone(), src_pages: vec![0] }), + Err(EditError::Source(_)) + )); + assert_eq!(destination.get(dest).unwrap().bytes, plain, "a refused replace leaves the destination untouched"); +} + +/// Opened with the permissions password, a file under "Any except extracting pages" (#5081) can +/// be extracted from and split. +#[test] +fn extraction_permissions_yield_to_the_owner_password() { + let bytes = protected_with_changes(pdfcraft_cos::Algorithm::Aes256, Changes::AnyExceptExtract); + let mut owner = Session::new(); + let owner_id = owner.open("restricted.pdf", None, bytes, Some("boss")).unwrap(); + assert!(owner.get(owner_id).unwrap().permissions().unwrap().extract_pages()); + assert!(owner.extract(owner_id, &[0]).is_ok()); + assert_eq!(owner.split(owner_id, &pdfcraft_organize::SplitBy::PageCount(1)).unwrap().len(), 2); + assert_eq!(owner.split_by_size(owner_id, 1).unwrap().len(), 2); +} + +/// A document that allows page changes but not copying, so not page extraction (#5081), takes +/// pages back from a file under exactly its own protection (a cut and paste, or another file +/// made with the same key and `/Encrypt` values), through Insert Pages' file check as well, and +/// through Replace Pages and its check, and keeps the restriction; the same file is refused as a +/// source for an unprotected or a differently protected document. +#[test] +fn pages_under_the_same_protection_are_inserted_without_page_extraction() { + use pdfcraft_cos::Algorithm::Aes256; + + let bytes = protected_pages(2, Aes256, Changes::Pages, 8); + let mut same = Session::new(); + let same_id = same.open("same.pdf", None, bytes.clone(), None).unwrap(); + let permissions = same.get(same_id).unwrap().permissions().unwrap(); + assert!(permissions.assemble() && !permissions.extract_pages(), "/P={:#x}", permissions.bits); + let insert = |name: &str, bytes: &Arc>, at| Edit::InsertPagesFrom { + name: name.into(), + bytes: bytes.clone(), + pages: None, + at, + field_conflicts: pdfcraft_organize::FieldConflicts::Rename, + }; + // Its own bytes, as Paste inserts them after a cut or copy. + same.apply(same_id, insert("same.pdf", &bytes, 2)).unwrap(); + assert_eq!(same.get(same_id).unwrap().info.pages.len(), 4); + assert!(same.get(same_id).unwrap().dirty); + + // A separate file made with the same key and /Encrypt values, and other pages. + let other = protected_pages(3, Aes256, Changes::Pages, 8); + let (a, b) = (pdfcraft_cos::Document::open(bytes.clone()).unwrap(), pdfcraft_cos::Document::open(other.clone()).unwrap()); + let (a, b) = (a.security().unwrap(), b.security().unwrap()); + assert!(a.file_key() == b.file_key() && a.dict() == b.dict(), "the two files share their protection"); + assert!(matches!(same.page_count_of("other.pdf", &other), Err(EditError::Source(_))), "a plain page source must allow extraction"); + assert_eq!(same.insert_source(same_id, "other.pdf", &other).map(|(n, _)| n), Ok(3)); + same.apply(same_id, insert("other.pdf", &other, 4)).unwrap(); + // Replace Pages takes them on the same terms. + let replace = |bytes: &Arc>| Edit::ReplacePages { pages: vec![0], name: "other.pdf".into(), bytes: bytes.clone(), src_pages: vec![2] }; + assert_eq!(same.replace_source(same_id, "other.pdf", &other), Ok(3)); + same.apply(same_id, replace(&other)).unwrap(); + assert_eq!(same.get(same_id).unwrap().info.pages.len(), 7); + let saved = same.save_bytes(same_id).unwrap(); + let reopened = pdfcraft_cos::Document::open_with_password(saved, None).unwrap(); + assert_eq!(pdfcraft_organize::page_count(&reopened).unwrap(), 7); + assert!(!reopened.permissions().unwrap().extract_pages(), "same-protection assembly keeps the extraction restriction"); + + // Neither an unprotected document nor one under other protection takes them. + for destination in [Arc::new(fixture(1)), protected_pages(1, Aes256, Changes::Pages, 9)] { + let mut session = Session::new(); + let dest = session.open("destination.pdf", None, destination, None).unwrap(); + let before = session.get(dest).unwrap().bytes.clone(); + assert!(matches!(session.insert_source(dest, "same.pdf", &bytes), Err(EditError::Source(_)))); + assert!(matches!(session.apply(dest, insert("same.pdf", &bytes, 1)), Err(EditError::Source(_)))); + assert!(matches!(session.replace_source(dest, "same.pdf", &bytes), Err(EditError::Source(_)))); + assert!(matches!(session.apply(dest, replace(&bytes)), Err(EditError::Source(_)))); + assert_eq!(session.get(dest).unwrap().bytes, before, "a refused insert or replace leaves the destination untouched"); + assert_eq!(session.get(dest).unwrap().info.pages.len(), 1); + } +} + +/// Taking pages out also needs copying (bit 5, #5081): a document that withholds only copying +/// refuses Extract, Split and being a page source, on a revision-2 handler too, whose reserved +/// bit 11 is always set. The owner password still lifts it. +#[test] +fn page_extraction_needs_the_copy_permission_on_every_revision() { + for algorithm in [pdfcraft_cos::Algorithm::Rc4_40, pdfcraft_cos::Algorithm::Aes256] { + let mut doc = pdfcraft_cos::Document::open(Arc::new(fixture(2))).unwrap(); + doc.set_encryption(&pdfcraft_cos::NewEncryption { + algorithm, + user_password: "", + owner_password: "boss", + permissions: -1 ^ 0b1_0000, // everything but copying + encrypt_metadata: true, + seed: [9; 32], + }) + .unwrap(); + let bytes = Arc::new(pdfcraft_cos::write_full(&doc, &Default::default()).unwrap()); + let mut s = Session::new(); + let id = s.open("no-copy.pdf", None, bytes.clone(), None).unwrap(); + let permissions = s.get(id).unwrap().permissions().unwrap(); + assert!(permissions.assemble() && !permissions.extract_pages(), "{algorithm:?}"); + assert!(s.get(id).unwrap().allows_assembly() && !s.get(id).unwrap().allows_page_extraction(), "{algorithm:?}"); + assert_eq!(s.extract(id, &[0]), Err(EditError::NotPermitted("extracting pages")), "{algorithm:?}"); + assert_eq!(s.split(id, &pdfcraft_organize::SplitBy::PageCount(1)), Err(EditError::NotPermitted("splitting the document"))); + assert_eq!(crate::combine_source_check(&bytes, None), Err(crate::SourceProblem::NotPermitted), "{algorithm:?}"); + assert_eq!(crate::combine_source_check(&bytes, Some("boss")), Ok(()), "{algorithm:?}"); + let owner = s.open("no-copy.pdf", None, bytes, Some("boss")).unwrap(); + assert!(s.extract(owner, &[0]).is_ok(), "{algorithm:?}"); + } +} + #[test] fn encrypted_documents_open_edit_and_save_encrypted() { let bytes = protected("pw", "owner", -1); @@ -3337,9 +3542,10 @@ fn the_renderer_check_refuses_text_still_shown_under_a_mark() { mark_shown_text(&mut s, id, "SECRET42"); let doc = s.get(id).unwrap(); let mut cos = doc.editor.as_ref().unwrap().cos.clone(); - let marks = pdfcraft_redact::marks(&cos); + let model = pdfcraft_model::pages(&cos); + let marks: Vec<_> = pdfcraft_redact::marks(&cos).into_iter().filter_map(|m| model.get(m.page).map(|p| (p.obj, m))).collect(); let refused = |cos: &pdfcraft_cos::Document| { - let shown = shown_under_redactions(cos, &marks, &[], &doc.info.pages, None); + let shown = shown_under_redactions(cos, &marks, &[], None); matches!(shown, Err(EditError::Redact(pdfcraft_redact::RedactError::Residue(n))) if n > 0) }; assert!(refused(&cos)); @@ -6625,6 +6831,43 @@ fn form_javascript_validates_calculates_and_formats() { assert!(s.get(id).unwrap().form.iter().find(|f| f.name == "total").unwrap().read_only()); assert_eq!(s.get(id).unwrap().can_undo(), Some("Run JavaScript")); + s.apply(id, Edit::InsertBlankPage { at: 1, width: 200.0, height: 300.0 }).unwrap(); + s.apply(id, Edit::DuplicateField { name: "go".into(), pages: vec![1] }).unwrap(); + let props = Box::new(FieldProps { caption: Some((0, "Continue".into())), ..Default::default() }); + s.apply(id, Edit::SetFieldProps { name: "go".into(), props }).unwrap(); + s.apply(id, Edit::SetFieldProps { name: "go".into(), props: Box::new(FieldProps { caption: Some((1, "Old".into())), ..Default::default() }) }) + .unwrap(); + let caption = + s.run_javascript(id, "getField('go').buttonSetCaption('Continue'); event.value = getField('go').buttonGetCaption();", Some("go")).unwrap(); + assert_eq!(caption.error, None); + assert_eq!(caption.result.as_deref(), Some("Continue")); + assert_eq!( + s.get(id).unwrap().form.iter().find(|f| f.name == "go").map(|f| f.widgets.iter().map(|w| w.caption.as_deref()).collect::>()), + Some(vec![Some("Continue"), Some("Continue")]), + "setting a caption already shown by the first widget updates every widget", + ); + assert_eq!(s.get(id).unwrap().can_undo(), Some("Run JavaScript")); + s.undo(id).unwrap(); + assert_eq!( + s.get(id).unwrap().form.iter().find(|f| f.name == "go").and_then(|f| f.widgets.first()).and_then(|w| w.caption.as_deref()), + Some("Continue"), + "undo keeps the first widget's original caption", + ); + assert_eq!( + s.get(id).unwrap().form.iter().find(|f| f.name == "go").and_then(|f| f.widgets.get(1)).and_then(|w| w.caption.as_deref()), + Some("Old"), + "undo restores the second widget's original caption", + ); + s.redo(id).unwrap(); + assert_eq!( + s.get(id).unwrap().form.iter().find(|f| f.name == "go").and_then(|f| f.widgets.first()).and_then(|w| w.caption.as_deref()), + Some("Continue") + ); + assert_eq!( + s.get(id).unwrap().form.iter().find(|f| f.name == "go").and_then(|f| f.widgets.get(1)).and_then(|w| w.caption.as_deref()), + Some("Continue") + ); + // JavaScript off: scripts don't run. s.set_javascript(false); s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("500".into()) }).unwrap(); @@ -6633,9 +6876,96 @@ fn form_javascript_validates_calculates_and_formats() { } #[test] +fn javascript_field_colours_persist_and_undo_without_changing_other_appearance_properties() { + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("order.pdf", None, Arc::new(scripted_form()), None).unwrap(); + s.apply( + id, + Edit::SetFieldProps { + name: "price".into(), + props: Box::new(FieldProps { + appearance: Some(pdfcraft_forms::LookPatch { + border: Some(Some([0.1, 0.2, 0.3])), + font: Some(pdfcraft_forms::FieldFont::Courier), + ..Default::default() + }), + ..Default::default() + }), + }, + ) + .unwrap(); + let before = s.get(id).unwrap().form.iter().find(|f| f.name == "price").unwrap().value.clone(); + let out = s.run_javascript(id, "var f = getField('price'); f.fillColor = color.red; f.textColor = color.blue;", None).unwrap(); + assert_eq!(out.error, None); + assert_eq!(s.get(id).unwrap().can_undo(), Some("Run JavaScript")); + + let inspect = |bytes: Arc>| { + let doc = pdfcraft_cos::Document::open(bytes).unwrap(); + let f = pdfcraft_forms::fields(&doc).into_iter().find(|f| f.name == "price").unwrap(); + let look = pdfcraft_forms::look(&doc, &f); + assert_eq!(look.fill, Some([1.0, 0.0, 0.0])); + assert_eq!(look.text, [0.0, 0.0, 1.0]); + assert_eq!(look.border, Some([0.1, 0.2, 0.3])); + assert_eq!(look.font, pdfcraft_forms::FieldFont::Courier); + assert_eq!(f.value, before); + }; + inspect(s.save_bytes(id).unwrap()); + s.undo(id).unwrap(); + let undone = pdfcraft_cos::Document::open(s.save_bytes(id).unwrap()).unwrap(); + let f = pdfcraft_forms::fields(&undone).into_iter().find(|f| f.name == "price").unwrap(); + assert_eq!(pdfcraft_forms::look(&undone, &f).fill, None); + s.redo(id).unwrap(); + inspect(s.save_bytes(id).unwrap()); + + let reopened = s.open("saved.pdf", None, s.save_bytes(id).unwrap(), None).unwrap(); + let reopened_doc = pdfcraft_cos::Document::open(s.save_bytes(reopened).unwrap()).unwrap(); + let f = pdfcraft_forms::fields(&reopened_doc).into_iter().find(|f| f.name == "price").unwrap(); + assert_eq!(pdfcraft_forms::look(&reopened_doc, &f).fill, Some([1.0, 0.0, 0.0])); + assert_eq!(pdfcraft_forms::look(&reopened_doc, &f).text, [0.0, 0.0, 1.0]); +} + +#[test] +fn javascript_invalid_field_colour_array_is_refused_without_partial_changes() { + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("order.pdf", None, Arc::new(scripted_form()), None).unwrap(); + let before = s.save_bytes(id).unwrap(); + let err = s.run_javascript(id, "var f = getField('price'); f.value = '9'; f.fillColor = ['RGB', 2, 0, 0];", None).unwrap_err(); + assert!(err.to_string().contains("finite components between 0 and 1"), "{err}"); + assert_eq!(s.save_bytes(id).unwrap(), before, "a refused script changes nothing"); +} + +#[test] +fn javascript_field_colours_respect_document_fill_permissions() { + let mut plain = pdfcraft_cos::Document::open(Arc::new(scripted_form())).unwrap(); + plain + .set_encryption(&pdfcraft_cos::NewEncryption { + algorithm: pdfcraft_cos::Algorithm::Aes256, + user_password: "", + owner_password: "owner", + permissions: 0, + encrypt_metadata: true, + seed: [7; 32], + }) + .unwrap(); + let bytes = Arc::new(pdfcraft_cos::write_full(&plain, &Default::default()).unwrap()); + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("locked.pdf", None, bytes, None).unwrap(); + assert!(!s.get(id).unwrap().allows_form_filling()); + assert_eq!( + s.run_javascript(id, "getField('price').fillColor = color.red;", None).unwrap_err(), + EditError::NotPermitted("filling in form fields") + ); + assert!(!s.get(id).unwrap().dirty); +} + +#[test] +/// Synchronous calculation exposes pending values immediately and persists the invocation as one undo transaction. fn calculate_now_updates_live_fields_in_one_undo_step() { let mut s = Session::new().with_clock(|| 1_700_000_000); let id = s.open("calculate-now.pdf", None, Arc::new(scripted_form()), None).unwrap(); + let value = |s: &Session, name: &str| s.get(id).unwrap().form.iter().find(|f| f.name == name).unwrap().value.clone(); + let before_price = value(&s, "price"); + let before_qty = value(&s, "qty"); let out = s .run_javascript( id, @@ -6648,52 +6978,18 @@ fn calculate_now_updates_live_fields_in_one_undo_step() { assert_eq!(total, ["6"]); assert_eq!(s.get(id).unwrap().can_undo(), Some("Run JavaScript")); s.undo(id).unwrap(); + assert_eq!(value(&s, "price"), before_price); + assert_eq!(value(&s, "qty"), before_qty); assert!(s.get(id).unwrap().form.iter().find(|f| f.name == "total").unwrap().value.is_empty()); s.redo(id).unwrap(); + assert_eq!(value(&s, "price"), ["3"]); + assert_eq!(value(&s, "qty"), ["2"]); assert_eq!(s.get(id).unwrap().form.iter().find(|f| f.name == "total").unwrap().value, ["6"]); let bytes = s.save_bytes(id).unwrap(); let reopened = s.open("reopened.pdf", None, bytes, None).unwrap(); assert_eq!(s.get(reopened).unwrap().form.iter().find(|f| f.name == "total").unwrap().value, ["6"]); } -#[test] -fn calculate_now_applies_format_script_to_updated_appearance() { - let mut session = Session::new().with_clock(|| 1_700_000_000); - let id = session.open("calculate-format.pdf", None, Arc::new(scripted_form()), None).unwrap(); - session.run_javascript(id, "getField('price').value = 3; getField('qty').value = 2; calculateNow();", None).unwrap(); - - assert_eq!(session.get(id).unwrap().form.iter().find(|field| field.name == "total").unwrap().value, ["6"]); - assert!(page_texts(&session, id)[0].contains("USD 6.00"), "the appearance uses the Format script"); -} - -#[test] -fn calculation_scripts_are_built_only_for_scripts_that_call_calculate_now() { - let mut s = Session::new().with_clock(|| 1_700_000_000); - let id = s.open("calculate-list.pdf", None, Arc::new(scripted_form()), None).unwrap(); - crate::js::CALCULATION_SCRIPT_BUILDS.with(|calls| calls.set(0)); - - s.run_javascript(id, "1 + 1", None).unwrap(); - assert_eq!(crate::js::CALCULATION_SCRIPT_BUILDS.with(std::cell::Cell::get), 0); - - s.run_javascript(id, "calculateNow();", None).unwrap(); - assert_eq!(crate::js::CALCULATION_SCRIPT_BUILDS.with(std::cell::Cell::get), 1); - - use pdfcraft_forms::{FieldEvent, Scripts}; - let cos = pdfcraft_cos::Document::open(Arc::new(scripted_form())).unwrap(); - let fields = pdfcraft_forms::fields(&cos); - let target = fields.iter().find(|f| f.name == "go").unwrap(); - let mut runner = crate::js::JsRunner::new(&cos, "calculate-list.pdf", false); - crate::js::CALCULATION_SCRIPT_BUILDS.with(|calls| calls.set(0)); - - runner.run(FieldEvent::Calculate, "1 + 1", target, "", &fields); - assert_eq!(crate::js::CALCULATION_SCRIPT_BUILDS.with(std::cell::Cell::get), 0); - - runner.run(FieldEvent::Calculate, "calculateNow();", target, "", &fields); - assert_eq!(crate::js::CALCULATION_SCRIPT_BUILDS.with(std::cell::Cell::get), 1); - runner.run(FieldEvent::Calculate, "calculateNow();", target, "", &fields); - assert_eq!(crate::js::CALCULATION_SCRIPT_BUILDS.with(std::cell::Cell::get), 1); -} - #[test] fn calculate_now_in_a_document_script_helper_gets_the_calculation_list() { let helpers = "function times\\(a, b\\) { return a * b; } function recalc\\(\\) { calculateNow\\(\\); }"; @@ -6708,6 +7004,7 @@ fn calculate_now_in_a_document_script_helper_gets_the_calculation_list() { } #[test] +/// Respect explicit calculation order, restore the outer event and stop recursive calculation requests. fn calculate_now_uses_calculation_order_and_bounds_recursion() { let mut s = Session::new().with_clock(|| 1_700_000_000); let id = s.open("calculate-chain.pdf", None, Arc::new(scripted_form()), None).unwrap(); @@ -6721,18 +7018,34 @@ fn calculate_now_uses_calculation_order_and_bounds_recursion() { assert!(out.error.is_none(), "{out:?}"); assert!(out.console.iter().any(|line| line == "8"), "{out:?}"); + s.set_javascript(false); // Installing scripts must not change the 4/8 starting values. for (name, script) in [("qty", "event.value = Number(getField('total').value) + 1;"), ("total", "event.value = Number(getField('qty').value) + 1;")] { s.apply(id, Edit::SetFieldScript { name: name.into(), event: "calculate".into(), script: Some(script.into()) }).unwrap(); } - let cyclic = s.run_javascript(id, "calculateNow();", None).unwrap(); + s.set_javascript(true); + let starting = |name: &str| s.get(id).unwrap().form.iter().find(|f| f.name == name).unwrap().value.clone(); + assert_eq!(starting("qty"), ["4"]); + assert_eq!(starting("total"), ["8"]); + let cyclic = + s.run_javascript(id, "calculateNow(); console.println(getField('qty').value); console.println(getField('total').value);", None).unwrap(); assert!(cyclic.error.is_none(), "a cyclic calculation runs once in order: {cyclic:?}"); + let field = |name: &str| s.get(id).unwrap().form.iter().find(|f| f.name == name).unwrap().value.clone(); + assert_eq!(cyclic.console, ["9", "10"], "the explicit ordered pass runs exactly once"); + // Applying the value delta invokes the existing automatic form-recalculation pass. + assert_eq!(field("qty"), ["11"]); + assert_eq!(field("total"), ["12"]); - s.apply(id, Edit::SetFieldScript { name: "total".into(), event: "calculate".into(), script: Some("calculateNow(); event.value = 12;".into()) }) - .unwrap(); - let out = s.run_javascript(id, "calculateNow();", None).unwrap(); - assert!(out.console.iter().any(|line| line.contains("calculateNow recursion limit exceeded")), "{out:?}"); + let recursive = + Edit::SetFieldScript { name: "total".into(), event: "calculate".into(), script: Some("calculateNow(); event.value = 12;".into()) }; + let refused = s.apply(id, recursive.clone()).unwrap_err(); + assert!(refused.to_string().contains("calculateNow recursion limit exceeded"), "{refused:?}"); + s.set_javascript(false); + s.apply(id, recursive).unwrap(); + s.set_javascript(true); + let error = s.run_javascript(id, "calculateNow();", None).unwrap_err(); + assert!(error.to_string().contains("calculateNow recursion limit exceeded"), "{error:?}"); s.set_javascript(false); assert!(s.run_javascript(id, "calculateNow();", None).is_err()); s.set_javascript(true); @@ -6815,7 +7128,7 @@ fn calculation_order_cache_refreshes_after_inserting_pages() { let fields = pdfcraft_forms::fields(&doc); let qty = fields.iter().find(|field| field.name == "qty").unwrap(); let mut js = crate::js::JsRunner::new(&doc, "destination.pdf", false); - js.run(FieldEvent::Format, "calculateNow();", qty, "", &fields); + js.run(FieldEvent::Format, "calculateNow();", qty, "", &fields, &doc); let source = pdfcraft_testkit::Pdf::new() .obj("<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R] /CO [4 0 R] >> >>") @@ -6841,7 +7154,7 @@ fn calculation_order_cache_refreshes_after_inserting_pages() { let fields = pdfcraft_forms::fields(&doc); let qty = fields.iter().find(|field| field.name == "qty").unwrap(); let js = cx.js.as_mut().unwrap(); - let result = js.run(FieldEvent::Format, "calculateNow();", qty, "", &fields); + let result = js.run(FieldEvent::Format, "calculateNow();", qty, "", &fields, &doc); assert!( result.changes.iter().any(|change| { change.name == "importedTotal" && change.value.as_ref().is_some_and(|value| value.len() == 1 && value.first().is_some_and(|v| v == "9")) @@ -6857,7 +7170,7 @@ fn javascript_field_rect_moves_first_widget_and_undoes() { let mut s = Session::new(); let id = s.open("rect.pdf", None, Arc::new(scripted_form()), None).unwrap(); let initial = s.get(id).unwrap().form.iter().find(|f| f.name == "price").unwrap().widgets[0].rect; - s.run_javascript(id, "getField('price').rect = [30, 40, 130, 60];", Some("go")).unwrap(); + s.run_javascript(id, "getField('price').rect = [30, 60, 130, 40];", Some("go")).unwrap(); assert_eq!(s.get(id).unwrap().form.iter().find(|f| f.name == "price").unwrap().widgets[0].rect, [30.0, 40.0, 130.0, 60.0]); assert_eq!(s.get(id).unwrap().can_undo(), Some("Run JavaScript")); s.undo(id).unwrap(); @@ -6885,20 +7198,39 @@ fn scripts_in_an_xfa_form_see_the_xfa_plugin_installed() { /// helper stays defined, the form calculates, and the error is reported once. #[test] fn a_document_script_that_throws_still_lets_the_form_calculate() { - let helpers = - "function times\\(a, b\\) { return a * b; } this.getField\\('go'\\).buttonSetIcon\\(this.getField\\('go'\\).buttonGetIcon\\(\\)\\);"; + let helpers = "function times\\(a, b\\) { return a * b; } this.getField\\('go'\\).missingTestMethod\\(\\);"; let mut s = Session::new().with_clock(|| 1_700_000_000); let id = s.open("order.pdf", None, Arc::new(scripted_form_with(helpers)), None).unwrap(); - let _ = s.take_js_output(id); + let initial = s.take_js_output(id); + assert_eq!(initial.doc_errors.len(), 1, "the initializer error is reported at open"); s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("4".into()) }).unwrap(); let total = s.get(id).unwrap().form.iter().find(|f| f.name == "total").unwrap().value.clone(); assert_eq!(total, ["10"], "calculated through the helper the failing script defined"); let out = s.take_js_output(id); - assert_eq!(out.errors.len(), 1, "reported once, not once per script: {out:?}"); - assert!(out.errors[0].starts_with("document script 1: TypeError"), "{out:?}"); - // The console runs too, and says which document script failed. + assert!(out.errors.is_empty(), "the initializer is not replayed: {out:?}"); + // The console runs too, without replaying the failed initializer. let o = s.run_javascript(id, "times(2, 3)", None).unwrap(); - assert_eq!((o.error, o.result.as_deref(), o.doc_errors.len()), (None, Some("6"), 1)); + assert_eq!((o.error, o.result.as_deref(), o.doc_errors.len()), (None, Some("6"), 0)); +} + +/// A looping initializer is reported once and later initializers and field events still run. +#[test] +fn a_document_script_at_the_vm_limit_does_not_block_field_edits() { + let mut cos = pdfcraft_cos::Document::open(Arc::new(scripted_form_with(r"while \(true\) {}"))).unwrap(); + pdfcraft_forms::set_document_script(&mut cos, "later", Some("function times(a, b) { return a * b; }")).unwrap(); + let bytes = Arc::new(pdfcraft_cos::write_full(&cos, &Default::default()).unwrap()); + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("order.pdf", None, bytes, None).unwrap(); + let initial = s.take_js_output(id); + assert_eq!(initial.doc_errors.len(), 1, "{initial:?}"); + assert!(initial.doc_errors[0].contains("RuntimeLimitError"), "{initial:?}"); + + for (qty, total) in [("4", "10"), ("6", "15")] { + s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text(qty.into()) }).unwrap(); + assert_eq!(s.get(id).unwrap().form.iter().find(|f| f.name == "total").unwrap().value, [total]); + let out = s.take_js_output(id); + assert!(out.errors.is_empty() && out.doc_errors.is_empty(), "{out:?}"); + } } #[test] @@ -6974,14 +7306,13 @@ fn inactive_xfa_and_ordinary_pdfs_keep_document_helpers_and_alerts() { } #[test] +/// Read inherited page boxes without dirtying the document and reject invalid box/page requests. fn js_get_page_box_reads_effective_boxes_without_editing_the_document() { let (mut s, id) = session_with(1); let before = s.get(id).expect("opened document").can_undo().map(str::to_owned); - let out = s - .run_javascript(id, "JSON.stringify([this.getPageBox(), this.getPageBox('Media', 0), this.getPageBox('Trim', 0)])", None) - .expect("script runs"); + let out = s.run_javascript(id, "JSON.stringify([this.getPageBox(), this.getPageBox('Media', 0)])", None).expect("script runs"); assert_eq!(out.error, None, "{out:?}"); - assert_eq!(out.result.as_deref(), Some("[[0,300,200,0],[0,300,200,0],[0,300,200,0]]")); + assert_eq!(out.result.as_deref(), Some("[[0,300,200,0],[0,300,200,0]]")); assert_eq!(s.get(id).expect("document remains open").can_undo(), before.as_deref()); s.apply(id, Edit::RotatePages { pages: vec![0], degrees: 90 }).expect("rotate page"); @@ -7007,6 +7338,7 @@ fn automatic_field_scripts_cannot_print() { target, "2", &fields, + &cos, ); assert!(result.rc, "{event:?}: {result:?}"); assert_eq!(result.value, "4"); @@ -7018,7 +7350,7 @@ fn automatic_field_scripts_cannot_print() { assert!(runner.output.errors.is_empty()); runner.output = js::JsOutput::default(); - runner.run(event, "this.print(); throw new Error('broken script');", target, "2", &fields); + runner.run(event, "this.print(); throw new Error('broken script');", target, "2", &fields, &cos); assert!(runner.output.requests.is_empty(), "{event:?}: {:?}", runner.output); assert!(runner.output.errors.iter().any(|e| e.contains("broken script"))); } @@ -7033,7 +7365,7 @@ fn automatic_print_suppression_preserves_document_errors() { let target = fields.iter().find(|f| f.name == "qty").unwrap(); let mut runner = js::JsRunner::new(&cos, "order.pdf", false); for event in [FieldEvent::Keystroke, FieldEvent::Validate, FieldEvent::Calculate, FieldEvent::Format] { - runner.run(event, "this.print(); throw new Error('field failure');", target, "2", &fields); + runner.run(event, "this.print(); throw new Error('field failure');", target, "2", &fields, &cos); } let out = runner.output; assert!(out.requests.is_empty(), "{out:?}"); @@ -7371,13 +7703,15 @@ fn comparing_two_versions_of_a_document() { assert!(marks[0].contents.as_deref().unwrap().starts_with("Replaced: \"Monday.\"")); } +/// Built-in and custom actions run their steps on a file (with JavaScript on), report each +/// step's progress, and fail cleanly on a file that is not a PDF. #[test] fn actions_run_their_steps_on_files() { let s = Session::new(); let src = s.create_from_text("t", "Quarterly numbers").unwrap(); let built = actions::builtin(); let numbers = built.iter().find(|a| a.name == "Add Page Numbers").unwrap(); - let r = actions::run_on(numbers, "q.pdf", src.clone(), |_, _| {}).unwrap(); + let r = actions::run_on(numbers, "q.pdf", src.clone(), true, |_, _| {}).unwrap(); let mut s2 = Session::new(); let id = s2.open("out.pdf", None, r.bytes, None).unwrap(); assert!(page_texts(&s2, id)[0].contains("Page 1 of 1"), "{:?}", page_texts(&s2, id)); @@ -7395,14 +7729,38 @@ fn actions_run_their_steps_on_files() { builtin: false, }; let mut steps = Vec::new(); - let r = actions::run_on(&custom, "q.pdf", src, |i, n| steps.push((i, n))).unwrap(); + let r = actions::run_on(&custom, "q.pdf", src, true, |i, n| steps.push((i, n))).unwrap(); assert_eq!(steps, [(0, 4), (1, 4), (2, 4), (3, 4)]); assert_eq!(r.log.len(), 4, "{:?}", r.log); let id = s2.open("out2.pdf", None, r.bytes, None).unwrap(); let d = s2.get(id).unwrap(); assert_eq!(d.info.title.as_deref(), Some("Q3")); assert!(page_texts(&s2, id)[0].contains("DRAFT")); - assert!(actions::run_on(&custom, "bad.pdf", Arc::new(b"nope".to_vec()), |_, _| {}).is_err()); + assert!(actions::run_on(&custom, "bad.pdf", Arc::new(b"nope".to_vec()), true, |_, _| {}).is_err()); +} + +/// #5080: a "Run a JavaScript" step honours the caller's JavaScript preference. With it on, the +/// step runs and writes the field; with it off, the step does not run, so `run_on` reports the +/// refused script's error instead of saving a script-produced value. Before the fix each file +/// opened in a fresh JavaScript-on session, so the step ran whatever the caller chose. +#[test] +fn an_action_runs_a_javascript_step_only_when_javascript_is_on() { + let action = actions::Action { + name: "Set price".into(), + description: String::new(), + steps: vec![actions::Step::RunJavaScript("getField('price').value = 3;".into())], + builtin: false, + }; + let src = Arc::new(scripted_form()); + // JavaScript on: the step runs, so the saved file has price = 3. + let on = actions::run_on(&action, "f.pdf", src.clone(), true, |_, _| {}).unwrap(); + let mut s = Session::new(); + s.set_javascript(false); // read the saved state without re-running any script + let id = s.open("on.pdf", None, on.bytes, None).unwrap(); + let price = s.get(id).unwrap().form.iter().find(|f| f.name == "price").unwrap().value.clone(); + assert_eq!(price, ["3"], "the script ran with JavaScript on"); + // JavaScript off: the step does not run, so run_on fails rather than writing a scripted value. + assert!(actions::run_on(&action, "f.pdf", src, false, |_, _| {}).is_err()); } /// A removal step that fails (here: a signed file can't be rewritten without its hidden data) @@ -7419,7 +7777,7 @@ fn actions_fail_when_hidden_information_could_not_be_removed() { let signed = s.sign(id, &digital_id, SignOptions { page: 1, ..SignOptions::default() }).unwrap(); let built = actions::builtin(); let sanitize = built.iter().find(|a| a.name == "Sanitize Documents").unwrap(); - let e = actions::run_on(sanitize, "signed.pdf", signed, |_, _| {}).map(|r| r.log).unwrap_err(); + let e = actions::run_on(sanitize, "signed.pdf", signed, true, |_, _| {}).map(|r| r.log).unwrap_err(); assert!(e.starts_with("Sanitize document:"), "{e}"); } @@ -9377,6 +9735,26 @@ fn hostile_xfa_scripts_cannot_hang_or_bloat_the_document() { assert!(started.elapsed() < std::time::Duration::from_secs(60), "{:?}", started.elapsed()); } +#[test] +fn xfa_focus_follows_the_surviving_row_after_removal() { + let tpl = pdfcraft_xfa::fixtures::scripted_template().replace( + r#"if (table._row.count > 1) { table._row.removeInstance(table._row.count - 1); }"#, + r#"xfa.host.setFocus("table.row[0].amount"); xfa.host.setFocus("table.row[1].amount"); table._row.removeInstance(0);"#, + ); + let mut s = Session::new(); + let id = s.open("focus-row.pdf", None, Arc::new(pdfcraft_xfa::fixtures::shell(&tpl)), None).unwrap(); + for _ in 0..2 { + s.run_javascript(id, "", Some("addRow")).unwrap(); + } + let out = s.run_javascript(id, "", Some("removeRow")).unwrap(); + let doc = s.get(id).unwrap(); + assert_eq!(doc.form.iter().filter(|f| f.name.starts_with("amount")).count(), 2); + let cos = &doc.editor.as_ref().unwrap().cos; + let by_som = pdfcraft_xfa::field_objects_by_som(cos); + let (name, object) = &by_som["form[0].page1[0].table[0].row[0].amount[0]"]; + assert_eq!(out.requests, [pdfcraft_js::Request::FocusField { name: name.clone(), object_id: crate::js::field_object_id(*object) }], "{out:?}"); +} + #[test] fn xfa_scripts_can_remove_rows_twice_and_reset_fields_by_name() { let tpl = pdfcraft_xfa::fixtures::scripted_template() @@ -10847,7 +11225,7 @@ fn an_action_appends_to_a_file_whose_field_walk_passes_a_signature_over() { let bytes = Arc::new(signed_field_named(64 << 10)); let steps = vec![actions::Step::from_id("set_title", "Checked").unwrap()]; let title = actions::Action { name: "Title".into(), description: String::new(), steps, builtin: false }; - let r = actions::run_on(&title, "form.pdf", bytes.clone(), |_, _| {}).unwrap(); + let r = actions::run_on(&title, "form.pdf", bytes.clone(), true, |_, _| {}).unwrap(); assert!(r.bytes.starts_with(&bytes[..]), "the action rewrote the file instead of appending to it"); let mut s = Session::new(); let id = s.open("out.pdf", None, r.bytes, None).unwrap(); @@ -11586,39 +11964,51 @@ fn dynamic_calculation_reads_and_side_effects_reach_later_fields() { assert!(s.take_js_output(id).errors.is_empty()); } -/// Within one edit, a field's `display` reads as the file had it before the edit, even after a -/// script set it with the value cached; the next edit's runner reads what the script wrote. #[test] -fn scripted_display_changes_survive_warm_cache_and_new_edit() { +/// Later field callbacks see current COS appearance/flags rather than the runner creation snapshot. +fn field_scripts_read_current_appearance_during_an_edit() { use pdfcraft_forms::{FieldEvent, Scripts}; let mut cos = pdfcraft_cos::Document::open(Arc::new(synthetic_acroform(2, 0))).unwrap(); for (display, flags) in [(1, 6), (3, 36), (2, 0), (0, 4)] { let fields = pdfcraft_forms::fields(&cos); - let target = fields.iter().find(|f| f.name == "ranks").unwrap(); - let other = fields.iter().find(|f| f.name == "trained").unwrap(); - let old_display = crate::js::field_state(&cos, other).display; + let target = fields.iter().find(|f| f.name == "trained").unwrap(); + let other = fields.iter().find(|f| f.name == "ranks").unwrap(); let mut runner = crate::js::JsRunner::new(&cos, "synthetic.pdf", false); - // Populate the display cache before a script writes the same field. - let warm = runner.run(FieldEvent::Calculate, "event.value = getField('trained').display;", target, "", &fields); + let old_display = crate::js::field_state(&cos, other, &runner.doc).display; + // Populate script state before a script writes the same field. + let warm = runner.run(FieldEvent::Calculate, "event.value = getField('ranks').display;", target, "", &fields, &cos); assert_eq!(warm.value, old_display.to_string()); let result = runner.run( FieldEvent::Calculate, - &format!("getField('trained').display = {display}; event.value = getField('trained').display;"), + &format!("getField('ranks').display = {display}; event.value = getField('ranks').display;"), target, "", &fields, + &cos, ); assert_eq!(result.value, display.to_string()); assert_eq!(result.changes.len(), 1); assert_eq!(result.changes.first().unwrap().display, Some(display)); pdfcraft_forms::apply_script_changes(&mut cos, &result.changes, &mut pdfcraft_forms::NoScripts).unwrap(); assert_eq!(cos.get(other.widgets.first().unwrap().obj).as_dict().unwrap().int(b"F").unwrap() & 38, flags); - // Within an edit the original implementation reads an immutable pre-edit COS snapshot. - let same_edit = runner.run(FieldEvent::Calculate, "event.value = getField('trained').display;", target, "", &fields); - assert_eq!(same_edit.value, old_display.to_string()); - // The next edit must rebuild its cache from the newly written annotation flags. + pdfcraft_forms::apply_script_changes( + &mut cos, + &[pdfcraft_forms::FieldChange { + name: "ranks".into(), + fill_color: Some(vec!["RGB".into(), "0".into(), "1".into(), "0".into()]), + ..Default::default() + }], + &mut pdfcraft_forms::NoScripts, + ) + .unwrap(); + // A later script in this edit sees the document after the earlier appearance change. + let same_edit = runner.run(FieldEvent::Calculate, "event.value = getField('ranks').display;", target, "", &fields, &cos); + assert_eq!(same_edit.value, display.to_string()); + let same_edit_color = runner.run(FieldEvent::Calculate, "event.value = getField('ranks').fillColor.join(',');", target, "", &fields, &cos); + assert_eq!(same_edit_color.value, "RGB,0,1,0"); + // A new runner sees the same current appearance. let mut next = crate::js::JsRunner::new(&cos, "synthetic.pdf", false); - let read = next.run(FieldEvent::Calculate, "event.value = getField('trained').display;", target, "", &pdfcraft_forms::fields(&cos)); + let read = next.run(FieldEvent::Calculate, "event.value = getField('ranks').display;", target, "", &pdfcraft_forms::fields(&cos), &cos); assert_eq!(read.value, display.to_string()); assert!(read.changes.is_empty()); assert!(runner.output.errors.is_empty()); @@ -11779,9 +12169,9 @@ fn an_edits_xfa_and_field_scripts_share_the_field_highlight() { let mut cx = EditCtx::new(None, 0); cx.js = Some(js::JsRunner::new(&cos, "synthetic.pdf", false)); cx.set_highlight(true); - assert!(cx.js.as_ref().is_some_and(|js| js.doc.highlight), "an XFA script's change reaches the field scripts"); + assert!(cx.js.as_ref().is_some_and(|js| js.doc.runtime_highlight), "an XFA script's change reaches the field scripts"); if let Some(js) = cx.js.as_mut() { - js.doc.highlight = false; + js.doc.runtime_highlight = false; } assert!(!cx.highlight(), "the next XFA script reads the field scripts' change"); } @@ -12075,6 +12465,36 @@ fn a_reset_clears_a_floating_field_in_every_row() { /// An XFA button's click script may show the attachments panel; a text field's exit script /// asking the same is noted on the console and ignored. +#[test] +fn xfa_focus_targets_the_widget_after_relayout() { + let xdp = r##" + + + +"##; + let mut s = Session::new(); + let id = s.open("focus.pdf", None, Arc::new(pdfcraft_xfa::fixtures::shell(xdp)), None).unwrap(); + let note = |s: &Session| s.get(id).unwrap().form.iter().find(|f| f.name == "note").unwrap().obj; + let before = note(&s); + s.take_js_output(id); + s.apply(id, Edit::XfaEvent { som: "form[0].go[0]".into(), activity: "click".into() }).unwrap(); + let after = note(&s); + assert_ne!(before, after); + let out = s.take_js_output(id); + assert_eq!(out.requests, [pdfcraft_js::Request::FocusField { name: "note".into(), object_id: crate::js::field_object_id(after) }], "{out:?}"); +} + #[test] fn an_xfa_button_may_show_a_side_panel_and_other_events_may_not() { let xdp = r##" @@ -13359,6 +13779,66 @@ fn image_transform_engine_refuses_singular_matrices_without_mutation() { } } +/// The renderer's check covers a redaction applied inside a batch too, with the marks as they +/// were when it ran: one the batch itself added is checked, and text a later edit of the batch +/// puts back under a mark refuses the whole batch. +#[test] +fn redactions_applied_in_a_batch_get_the_renderer_check() { + let mut s = Session::new().with_clock(|| 1_700_000_000); + let page = one_line_page_with("/Font << /F1 4 0 R >>", "", "BT /F1 16 Tf 20 150 Td (Public words then SECRET42) Tj ET"); + let id = s.open("batch.pdf", None, Arc::new(page), None).unwrap(); + mark_shown_text(&mut s, id, "SECRET42"); + let apply = Edit::ApplyRedactions { pages: None }; + // Text added where the mark was shows under it again: the renderer sees it and refuses. + let under = AddedText { rect: [150.0, 130.0, 300.0, 160.0], text: "SECRET42 SECRET42".into(), ..Default::default() }; + let refused = s.apply(id, Edit::Batch { label: "Redact then write".into(), edits: vec![apply.clone(), Edit::AddText { page: 0, text: under }] }); + assert!(matches!(refused, Err(EditError::Redact(pdfcraft_redact::RedactError::Residue(n))) if n > 0), "{refused:?}"); + assert!(page_texts(&s, id)[0].contains("SECRET42"), "nothing changed"); + assert_eq!(s.get(id).unwrap().redaction_marks(), 1); + // Applied alone in a batch, the redaction goes through, and the text is gone. + s.apply(id, Edit::Batch { label: "Redact".into(), edits: vec![apply] }).unwrap(); + assert!(!page_texts(&s, id)[0].contains("SECRET42"), "{:?}", page_texts(&s, id)); + assert_eq!(s.get(id).unwrap().redaction_marks(), 0); +} + +/// A batch that deletes a page holding a redaction mark and adds pages is refused whole: the +/// marks cannot be tied to copies, so redaction fails closed. Without added pages it goes through. +#[test] +fn a_batch_that_deletes_a_marked_page_and_adds_pages_is_refused() { + let mut s = Session::new().with_clock(|| 1_700_000_000); + let page = one_line_page_with("/Font << /F1 4 0 R >>", "", "BT /F1 16 Tf 20 150 Td (Public words then SECRET42) Tj ET"); + let id = s.open("batch.pdf", None, Arc::new(page), None).unwrap(); + mark_shown_text(&mut s, id, "SECRET42"); + let edits = vec![ + Edit::ApplyRedactions { pages: None }, + Edit::InsertBlankPage { at: 1, width: 612.0, height: 792.0 }, + Edit::DeletePages { pages: vec![0] }, + ]; + let refused = s.apply(id, Edit::Batch { label: "x".into(), edits }); + assert!(matches!(refused, Err(EditError::Invalid(_))), "{refused:?}"); + assert!(page_texts(&s, id)[0].contains("SECRET42"), "nothing changed"); + assert_eq!(s.get(id).unwrap().redaction_marks(), 1); +} + +/// A batch that applies redactions and then copies a page is refused whole: the copy can't be tied +/// to the marks, so text restored under one would be unchecked. Copying first, or alone, is fine. +#[test] +fn a_batch_that_applies_redactions_and_copies_a_page_is_refused() { + let mut s = Session::new().with_clock(|| 1_700_000_000); + let page = one_line_page_with("/Font << /F1 4 0 R >>", "", "BT /F1 16 Tf 20 150 Td (Public words then SECRET42) Tj ET"); + let id = s.open("batch.pdf", None, Arc::new(page), None).unwrap(); + mark_shown_text(&mut s, id, "SECRET42"); + let apply = Edit::ApplyRedactions { pages: None }; + let copy = Edit::DuplicatePages { pages: vec![0], at: None }; + let refused = s.apply(id, Edit::Batch { label: "x".into(), edits: vec![apply.clone(), copy.clone()] }); + assert!(matches!(&refused, Err(EditError::Invalid(m)) if m.contains("copy pages")), "{refused:?}"); + assert!(page_texts(&s, id)[0].contains("SECRET42"), "nothing changed"); + assert_eq!(s.get(id).unwrap().redaction_marks(), 1); + // Copying before the redaction is applied puts the mark on the copy too, and both are checked. + s.apply(id, Edit::Batch { label: "y".into(), edits: vec![copy, apply] }).unwrap(); + assert!(page_texts(&s, id).iter().all(|t| !t.contains("SECRET42")), "{:?}", page_texts(&s, id)); +} + /// A reset in an opening script reaches a field of a row an earlier opening script added, before /// the form is laid out again with the row's widgets: its data goes back to its default and stays /// so when the form is saved. @@ -13675,6 +14155,803 @@ fn fill_sign_multiline_text_survives_engine_save_reopen_and_render() { assert_eq!(before.rgba, render_page(&reopened, again).rgba, "the saved appearance is identical"); } +/// An intervening edit gets a fresh realm before calculation, even after the worker has completed. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn pending_button_globals_cannot_leak_into_intervening_edits() { + use crate::js::InteractiveEvent; + for valid in [true, false] { + let mut s = Session::new(); + let id = s.open("pending.pdf", None, Arc::new(scripted_form()), None).unwrap(); + let value = |s: &Session, name: &str| s.get(id).unwrap().form.iter().find(|f| f.name == name).unwrap().value.clone(); + s.apply(id, Edit::SetDocumentScript { name: "lib".into(), script: Some("var retained='clean';".into()) }).unwrap(); + s.apply(id, Edit::SetFieldScript { name: "total".into(), event: "calculate".into(), script: Some("event.value=retained;".into()) }).unwrap(); + let pending = s.start_javascript(id, "retained='stale';getField('price').value=99;", Some("go")).unwrap(); + let InteractiveEvent::Complete(out) = pending.receiver.recv_timeout(std::time::Duration::from_secs(2)).unwrap() else { panic!("completion") }; + let edited = s.apply(id, Edit::SetFieldValue { name: if valid { "qty" } else { "missing" }.into(), value: FieldValue::Text("3".into()) }); + assert_eq!(edited.is_ok(), valid); + assert_eq!(value(&s, "total"), ["clean"]); + assert_eq!(s.run_javascript(id, "retained='fresh'", None).unwrap().error, None); + assert!(s.finish_javascript(pending, out).is_err()); + assert_eq!(s.run_javascript(id, "retained", None).unwrap().result.as_deref(), Some("fresh")); + assert_ne!(value(&s, "price"), ["99"]); + } +} + +/// Shared synthetic reset fixture; scripts are disabled while the document and actions are installed. +fn reset_order_session(reset: &str) -> (Session, DocId) { + let mut cos = pdfcraft_cos::Document::open(Arc::new(scripted_form())).unwrap(); + let source = pdfcraft_forms::fields(&cos).into_iter().find(|f| f.name == "price").unwrap(); + cos.update_dict(source.obj, |d| { + d.set(b"DV".to_vec(), pdfcraft_cos::PdfString::text("7")); + d.set(b"V".to_vec(), pdfcraft_cos::PdfString::text("99")); + }) + .unwrap(); + let bytes = Arc::new(pdfcraft_cos::write_full(&cos, &Default::default()).unwrap()); + let mut s = Session::new(); + s.set_javascript(false); + let id = s.open("reset-order.pdf", None, bytes, None).unwrap(); + s.apply( + id, + Edit::SetDocumentScript { name: "reset".into(), script: Some(format!("var held=getField('price');{reset}var openedValue=held.value;")) }, + ) + .unwrap(); + (s, id) +} + +#[test] +fn an_unavailable_validation_runtime_rejects_the_field_edit() { + let mut s = Session::new(); + let script = " ".repeat((1 << 20) + 1); + let id = s.open("oversized-script-library.pdf", None, Arc::new(scripted_form_with(&script)), None).unwrap(); + let result = s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: pdfcraft_forms::FieldValue::Text("101".into()) }); + assert!(matches!(result, Err(EditError::Invalid(reason)) if reason.contains("document scripts exceed"))); + assert!(s.get(id).unwrap().can_undo().is_none()); +} + +#[test] +fn opening_reset_recalculates_custom_dependents() { + let (mut s, id) = reset_order_session("resetForm(['price']);"); + s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: pdfcraft_forms::FieldValue::Text("2".into()) }).unwrap(); + let bytes = s.save_bytes(id).unwrap(); + let mut s = Session::new(); + let id = s.open("opening-calculation.pdf", None, bytes, None).unwrap(); + let doc = s.get(id).unwrap(); + assert_eq!(doc.form.iter().find(|f| f.name == "total").unwrap().value, ["14"]); +} + +#[test] +fn batch_reset_recalculates_before_later_validation() { + let (mut s, id) = reset_order_session(""); + for (name, event, script) in [ + ("total", "calculate", "event.value = Number(getField('price').value);"), + ("qty", "validate", "event.rc = Number(getField('total').value) === 7;"), + ] { + s.apply(id, Edit::SetFieldScript { name: name.into(), event: event.into(), script: Some(script.into()) }).unwrap(); + } + s.set_javascript(true); + s.run_javascript(id, "calculateNow();", None).unwrap(); + let value = |s: &Session, name: &str| s.get(id).unwrap().form.iter().find(|f| f.name == name).unwrap().value.clone(); + assert_eq!(value(&s, "total"), ["99"]); + s.apply( + id, + Edit::Batch { + label: "Reset and edit".into(), + edits: vec![ + Edit::ResetForm { names: Some(vec!["price".into()]) }, + Edit::SetFieldValue { name: "qty".into(), value: pdfcraft_forms::FieldValue::Text("10".into()) }, + ], + }, + ) + .unwrap(); + assert_eq!(value(&s, "qty"), ["10"]); + assert_eq!(value(&s, "total"), ["7"]); +} + +#[test] +fn reset_draws_the_formatted_default() { + let (mut s, id) = reset_order_session(""); + s.apply( + id, + Edit::SetFieldProps { name: "qty".into(), props: Box::new(FieldProps { default_value: Some(Some("3".into())), ..Default::default() }) }, + ) + .unwrap(); + s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("9".into()) }).unwrap(); + for (name, event, script) in [ + ("price", "format", "event.value = 'USD ' + Number(event.value).toFixed(2) + ' ' + getField('qty').value + '/' + getField('total').value;"), + ("price", "validate", "var resetValidated = true; event.rc = false;"), + ("price", "keystroke", "var resetKeystroked = true;"), + ("total", "calculate", "event.value = Number(getField('price').value) * Number(getField('qty').value);"), + ] { + s.apply(id, Edit::SetFieldScript { name: name.into(), event: event.into(), script: Some(script.into()) }).unwrap(); + } + s.set_javascript(true); + s.apply(id, Edit::ResetForm { names: Some(vec!["price".into(), "qty".into()]) }).unwrap(); + let form = &s.get(id).unwrap().form; + for (name, value) in [("price", "7"), ("qty", "3"), ("total", "21")] { + assert_eq!(form.iter().find(|f| f.name == name).unwrap().value, [value]); + } + assert!(page_texts(&s, id)[0].contains("USD 7.00 3/21"), "{:?}", page_texts(&s, id)); + let scripts = s.run_javascript(id, "typeof resetValidated + '/' + typeof resetKeystroked;", None).unwrap(); + assert_eq!(scripts.result.as_deref(), Some("undefined/undefined")); +} + +/// VM limits reject earlier writes in the same script and clear its uncommitted globals. +#[test] +fn vm_limit_refuses_partial_script_writes() { + let (mut s, id) = reset_order_session(""); + s.set_javascript(true); + let before = s.save_bytes(id).unwrap(); + let script = "var rejected = 42; getField('price').value = 1; function recurse(){ return recurse(); } recurse();"; + let out = s.run_javascript(id, script, None).unwrap(); + assert!(out.execution_refused, "{out:?}"); + assert!(out.changed.is_empty() && out.requests.is_empty()); + assert_eq!(s.save_bytes(id).unwrap(), before); + assert_eq!(s.run_javascript(id, "typeof rejected;", None).unwrap().result.as_deref(), Some("undefined")); +} + +/// A script refused before it runs fails on its own, as on `main`: the edit goes through, the +/// error is reported, and the committed realm is untouched. +#[test] +fn preflight_refusal_preserves_committed_callback_state() { + let (mut s, id) = reset_order_session(""); + let nested = format!("{}1{}", "(".repeat(1_000), ")".repeat(1_000)); + s.apply(id, Edit::SetFieldScript { name: "qty".into(), event: "validate".into(), script: Some(nested) }).unwrap(); + s.set_javascript(true); + s.run_javascript(id, "var committed = 42;", None).unwrap(); + let _ = s.take_js_output(id); + let edit = Edit::SetFieldValue { name: "qty".into(), value: pdfcraft_forms::FieldValue::Text("10".into()) }; + s.apply(id, edit).unwrap(); + assert!(s.take_js_output(id).errors.iter().any(|e| e.contains("nested too deeply"))); + assert_eq!(s.run_javascript(id, "committed;", None).unwrap().result.as_deref(), Some("42")); +} + +/// Validation can reject an edit without hitting a runtime limit. +#[test] +fn validation_rejection_discards_callback_side_effects() { + let (mut s, id) = reset_order_session(""); + let script = "var rejectedValue = event.value; event.rc = false;"; + s.apply(id, Edit::SetFieldScript { name: "qty".into(), event: "validate".into(), script: Some(script.into()) }).unwrap(); + s.set_javascript(true); + let before = s.save_bytes(id).unwrap(); + let edit = Edit::SetFieldValue { name: "qty".into(), value: pdfcraft_forms::FieldValue::Text("10".into()) }; + assert!(s.apply(id, edit).is_err()); + assert_eq!(s.save_bytes(id).unwrap(), before); + assert_eq!(s.run_javascript(id, "typeof rejectedValue;", None).unwrap().result.as_deref(), Some("undefined")); +} + +/// Replacing a document realm while a prompt is outstanding cannot spawn a second worker. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn invalidated_prompt_retains_the_documents_worker_until_completion() { + use crate::js::InteractiveEvent; + let (mut s, id) = reset_order_session(""); + s.set_javascript(true); + let pending = s.start_javascript(id, "var discarded = app.response('wait');", None).unwrap(); + let InteractiveEvent::Prompt(_, reply) = pending.receiver.recv_timeout(std::time::Duration::from_secs(2)).unwrap() else { + panic!("expected prompt") + }; + let same_file = s.get(id).unwrap().bytes.clone(); + assert!(matches!(s.reload(id, same_file), Err(OpenError::Invalid(reason)) if reason.contains("a script is still running"))); + for _ in 0..3 { + s.invalidate_javascript(id); + assert!(matches!(s.start_javascript(id, "1;", None), Err(EditError::Invalid(reason)) if reason.contains("another script is active"))); + } + reply.send(Some("discarded".into())).unwrap(); + let InteractiveEvent::Complete(out) = pending.receiver.recv_timeout(std::time::Duration::from_secs(2)).unwrap() else { + panic!("expected completion") + }; + assert!(s.finish_javascript(pending, out).is_err()); + assert_eq!(s.run_javascript(id, "typeof discarded;", None).unwrap().result.as_deref(), Some("undefined")); +} + +#[test] +fn a_budget_refusal_discards_pdf_edits_and_callback_globals() { + let (mut s, id) = reset_order_session(""); + for (event, script) in [("keystroke", "var fromRejectedEdit = 99;"), ("validate", "for (var i = 0; i < 65; i++) resetForm();")] { + s.apply(id, Edit::SetFieldScript { name: "qty".into(), event: event.into(), script: Some(script.into()) }).unwrap(); + } + s.set_javascript(true); + let before = s.save_bytes(id).unwrap(); + let edit = Edit::SetFieldValue { name: "qty".into(), value: pdfcraft_forms::FieldValue::Text("10".into()) }; + assert!(matches!(s.apply(id, edit), Err(EditError::Invalid(_)))); + assert_eq!(s.save_bytes(id).unwrap(), before); + assert_eq!(s.run_javascript(id, "typeof fromRejectedEdit;", None).unwrap().result.as_deref(), Some("undefined")); +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn timeout_after_undo_releases_the_stale_script_runtime() { + let (mut s, id) = reset_order_session(""); + s.set_javascript(true); + let pending = s.start_javascript(id, "1+1", None).unwrap(); + assert!(matches!(pending.receiver.recv_timeout(std::time::Duration::from_secs(2)).unwrap(), crate::js::InteractiveEvent::Complete(_))); + s.undo(id).unwrap(); + s.timeout_javascript(pending); + assert_eq!(s.run_javascript(id, "2+2", None).unwrap().result.as_deref(), Some("4")); +} + +#[test] +fn a_no_op_opening_reset_leaves_the_document_clean() { + let (mut s, id) = reset_order_session("resetForm(['price']);"); + s.apply(id, Edit::SetFieldValue { name: "price".into(), value: pdfcraft_forms::FieldValue::Text("7".into()) }).unwrap(); + let bytes = s.save_bytes(id).unwrap(); + let mut s = Session::new(); + let id = s.open("already-reset.pdf", None, bytes, None).unwrap(); + assert!(s.get(id).unwrap().can_undo().is_none()); + assert_eq!(s.run_javascript(id, "openedValue;", None).unwrap().result.as_deref(), Some("7")); +} + +/// Reinitialization resets before the first event reads, while later writes survive save and Undo/Redo. +#[test] +fn doc_open_reset_and_first_event_persist_in_script_order() { + for reset in ["resetForm();", "resetForm(['price']);"] { + for (target, restore) in [(None, false), (None, true), (Some("go"), false), (Some("go"), true)] { + let (mut s, id) = reset_order_session(reset); + let value = |s: &Session, id, name: &str| s.get(id).unwrap().form.iter().find(|f| f.name == name).unwrap().value.clone(); + let before = [value(&s, id, "price"), value(&s, id, "qty")]; + s.set_javascript(true); + let script = + format!("getField('qty').value=held.value;{}openedValue+'/'+getField('qty').value", if restore { "held.value='99';" } else { "" }); + let out = s.run_javascript(id, &script, target).unwrap(); + let expected = if restore { "99" } else { "7" }; + assert_eq!(out.error, None); + assert_eq!(out.result.as_deref(), Some("7/7")); + assert_eq!(value(&s, id, "price"), [expected]); + assert_eq!(value(&s, id, "qty"), ["7"]); + s.undo(id).unwrap(); + assert_eq!([value(&s, id, "price"), value(&s, id, "qty")], before); + s.redo(id).unwrap(); + assert_eq!(value(&s, id, "price"), [expected]); + assert_eq!(value(&s, id, "qty"), ["7"]); + let saved = s.save_bytes(id).unwrap(); + s.set_javascript(false); + let reopened = s.open("saved-reset.pdf", None, saved, None).unwrap(); + assert_eq!(value(&s, reopened, "price"), [expected]); + assert_eq!(value(&s, reopened, "qty"), ["7"]); + } + } +} + +/// Reset comparison includes unnamed built-in dependents and never runs custom calculations in the preview. +#[test] +fn reset_baseline_includes_builtin_dependents_without_custom_preview_runs() { + use form_scripts::{CalcOp, Calculate}; + for combo in [false, true] { + let mut cos = pdfcraft_cos::Document::open(Arc::new(scripted_form())).unwrap(); + for f in pdfcraft_forms::fields(&cos) { + match f.name.as_str() { + "price" => cos + .update_dict(f.obj, |d| { + d.set(b"V".to_vec(), pdfcraft_cos::PdfString::text("99")); + d.set(b"DV".to_vec(), pdfcraft_cos::PdfString::text("7")); + }) + .unwrap(), + "qty" => cos.update_dict(f.obj, |d| d.set(b"V".to_vec(), pdfcraft_cos::PdfString::text("2"))).unwrap(), + "total" if combo => cos + .update_dict(f.obj, |d| { + d.set(b"FT".to_vec(), pdfcraft_cos::Object::Name(b"Ch".to_vec())); + d.set(b"Ff".to_vec(), pdfcraft_cos::Object::Int((1 << 17) | (1 << 18))); + d.set( + b"Opt".to_vec(), + pdfcraft_cos::Object::Array(vec![ + pdfcraft_cos::PdfString::text("14").into(), + pdfcraft_cos::PdfString::text("198").into(), + ]), + ); + }) + .unwrap(), + _ => (), + } + } + let mut s = Session::new(); + s.set_javascript(false); + let id = s.open("reset-dependent.pdf", None, Arc::new(pdfcraft_cos::write_full(&cos, &Default::default()).unwrap()), None).unwrap(); + s.apply(id, Edit::SetDocumentScript { name: "counter".into(), script: Some("var calculationCalls=0;".into()) }).unwrap(); + s.apply(id, Edit::SetFieldScript { name: "qty".into(), event: "calculate".into(), script: Some("calculationCalls++;event.value=2;".into()) }) + .unwrap(); + s.apply( + id, + Edit::SetFieldProps { + name: "total".into(), + props: Box::new(FieldProps { + calculate: Some(Calculate::Simple { op: CalcOp::Product, fields: vec!["price".into(), "qty".into()] }), + ..Default::default() + }), + }, + ) + .unwrap(); + let value = |s: &Session, n: &str| s.get(id).unwrap().form.iter().find(|f| f.name == n).unwrap().value.clone(); + assert_eq!(value(&s, "total"), ["198"]); + s.set_javascript(true); + let out = s.run_javascript(id, "resetForm(['price']);getField('total').value=198;", None).unwrap(); + assert_eq!(out.error, None); + // The explicit dependent-field delta must be emitted even though it restores its original value. + // Its normal writeback recalculation runs the custom callback exactly once; preview/reset run none. + assert_eq!(s.run_javascript(id, "calculationCalls", None).unwrap().result.as_deref(), Some("1")); + assert_eq!(value(&s, "price"), ["7"]); + assert_eq!(value(&s, "total"), ["14"], "existing automatic calculation after a value write is preserved"); + } +} + +/// Native completions preserve reset/default/restore ordering and persist the resulting values. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn native_reset_completion_preserves_values_and_atomic_undo() { + use crate::js::InteractiveEvent; + for repetitions in [1, 64] { + for restore in [false, true] { + let (mut s, id) = reset_order_session(&"resetForm(['price']);".repeat(repetitions)); + s.set_javascript(true); + let script = + format!("getField('qty').value=held.value;{}openedValue+'/'+getField('qty').value", if restore { "held.value='99';" } else { "" }); + let pending = s.start_javascript(id, &script, Some("go")).unwrap(); + let InteractiveEvent::Complete(out) = pending.receiver.recv_timeout(std::time::Duration::from_secs(3)).unwrap() else { + panic!("completion") + }; + let out = s.finish_javascript(pending, out).unwrap(); + assert_eq!(out.error, None); + assert_eq!(out.result.as_deref(), Some("7/7")); + let value = |s: &Session, id, n: &str| s.get(id).unwrap().form.iter().find(|f| f.name == n).unwrap().value.clone(); + let expected = if restore { "99" } else { "7" }; + assert_eq!(value(&s, id, "price"), [expected]); + assert_eq!(value(&s, id, "qty"), ["7"]); + assert_eq!(s.get(id).unwrap().can_undo(), Some("Run JavaScript")); + s.undo(id).unwrap(); + assert_eq!(value(&s, id, "price"), ["99"]); + s.redo(id).unwrap(); + assert_eq!(value(&s, id, "price"), [expected]); + let bytes = s.save_bytes(id).unwrap(); + s.set_javascript(false); + let reopened = s.open("native-reset-saved.pdf", None, bytes, None).unwrap(); + assert_eq!(value(&s, reopened, "price"), [expected]); + assert_eq!(value(&s, reopened, "qty"), ["7"]); + } + } +} + +/// Over-budget native results are rejected before resetting or committing any PDF edits. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn native_reset_flood_is_rejected_without_changing_the_document() { + use crate::js::InteractiveEvent; + for (repetitions, caught) in [(65, false), (10_000, false), (10_000, true)] { + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("reset-flood.pdf", None, Arc::new(scripted_form()), None).unwrap(); + let before = s.save_bytes(id).unwrap(); + let generation = s.get(id).unwrap().generation; + let reset = if caught { "try{resetForm(['price']);}catch(e){}" } else { "resetForm(['price']);" }; + let script = format!("for(var i=0;i<{repetitions};i++){{{reset}}}getField('qty').value='uncommitted';"); + let pending = s.start_javascript(id, &script, Some("go")).unwrap(); + let InteractiveEvent::Complete(out) = pending.receiver.recv_timeout(std::time::Duration::from_secs(3)).unwrap() else { panic!("completion") }; + assert!(out.reset_limit_exceeded); + assert_eq!(out.error.is_none(), caught); + assert_eq!(out.requests.iter().filter(|r| matches!(r, crate::js::Request::Reset(_))).count(), pdfcraft_js::MAX_RESET_CALLS); + let error = s.finish_javascript(pending, out).expect_err("the reset flood must be rejected before applying it"); + assert!(error.to_string().contains("reset"), "{error}"); + assert_eq!(s.get(id).unwrap().generation, generation); + assert_eq!(s.get(id).unwrap().can_undo(), None); + assert_eq!(s.save_bytes(id).unwrap(), before); + assert!(s.run_javascript(id, "1+1", None).unwrap().error.is_none()); + } +} + +/// The aggregate budget also rejects expensive batches below the reset-count cap. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn native_reset_aggregate_work_is_bounded() { + use crate::js::InteractiveEvent; + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("large-reset.pdf", None, Arc::new(synthetic_acroform(1200, 500)), None).unwrap(); + let before = s.save_bytes(id).unwrap(); + let generation = s.get(id).unwrap().generation; + let pending = s.start_javascript(id, "for(var i=0;i<32;i++)resetForm();", None).unwrap(); + let InteractiveEvent::Complete(out) = pending.receiver.recv_timeout(std::time::Duration::from_secs(3)).unwrap() else { panic!("completion") }; + assert_eq!(out.error, None); + let error = s.finish_javascript(pending, out).expect_err("aggregate reset work must be refused"); + assert!(error.to_string().contains("reset work limit"), "{error}"); + assert_eq!(s.get(id).unwrap().generation, generation); + assert_eq!(s.get(id).unwrap().can_undo(), None); + assert_eq!(s.save_bytes(id).unwrap(), before); +} + +/// A huge selector list must be rejected before copying requests or expanding names against fields. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn native_reset_selector_count_is_bounded() { + use crate::js::InteractiveEvent; + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("selectors.pdf", None, Arc::new(scripted_form()), None).unwrap(); + let before = s.save_bytes(id).unwrap(); + let generation = s.get(id).unwrap().generation; + let pending = s.start_javascript(id, "resetForm(new Array(4097).fill('price'));", Some("go")).unwrap(); + let InteractiveEvent::Complete(out) = pending.receiver.recv_timeout(std::time::Duration::from_secs(3)).unwrap() else { panic!("completion") }; + assert_eq!(out.error, None); + assert!(s.finish_javascript(pending, out).unwrap_err().to_string().contains("reset work limit")); + assert_eq!(s.get(id).unwrap().generation, generation); + assert_eq!(s.save_bytes(id).unwrap(), before); +} + +/// An unread default is preserved by both reset paths; explicit script assignments still win. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn javascript_reset_preserves_unknown_default_like_native_reset() { + for inherited in [false, true] { + for native in [false, true] { + for reset in ["resetForm(['price']);", "resetForm();"] { + for (before, after, expected) in + [("", "", "99"), ("getField('price').value=42;", "", "42"), ("", "getField('price').value=43;", "43")] + { + let mut cos = pdfcraft_cos::Document::open(Arc::new(scripted_form())).unwrap(); + let price = pdfcraft_forms::fields(&cos).into_iter().find(|f| f.name == "price").unwrap(); + let mut dict = pdfcraft_cos::Dict::new(); + dict.set(b"Filter".to_vec(), pdfcraft_cos::Object::name("UnknownReviewFilter")); + let dv = cos.add(pdfcraft_cos::Object::Stream(pdfcraft_cos::Stream::from_raw(dict, vec![1]))); + let parent = if inherited { + let mut parent = pdfcraft_cos::Dict::new(); + parent.set(b"DV".to_vec(), pdfcraft_cos::Object::Ref(dv)); + parent.set(b"Kids".to_vec(), pdfcraft_cos::Object::Array(vec![pdfcraft_cos::Object::Ref(price.obj)])); + Some(cos.add(pdfcraft_cos::Object::Dict(parent))) + } else { + None + }; + cos.update_dict(price.obj, |d| { + d.set(b"V".to_vec(), pdfcraft_cos::PdfString::text("99")); + if let Some(parent) = parent { + d.set(b"Parent".to_vec(), pdfcraft_cos::Object::Ref(parent)); + } else { + d.set(b"DV".to_vec(), pdfcraft_cos::Object::Ref(dv)); + } + }) + .unwrap(); + if let Some(parent) = parent { + let root = cos.root().unwrap(); + cos.update_dict(root, |catalog| { + let mut form = catalog.get(b"AcroForm").unwrap().as_dict().unwrap().clone(); + let mut refs = form.get(b"Fields").unwrap().as_array().unwrap().clone(); + for reference in &mut refs { + if reference.as_ref() == Some(price.obj) { + *reference = pdfcraft_cos::Object::Ref(parent); + } + } + form.set(b"Fields".to_vec(), pdfcraft_cos::Object::Array(refs)); + catalog.set(b"AcroForm".to_vec(), pdfcraft_cos::Object::Dict(form)); + }) + .unwrap(); + } + let mut direct = cos.clone(); + pdfcraft_forms::reset(&mut direct, Some(&["price".into()])).unwrap(); + assert_eq!(pdfcraft_forms::fields(&direct).into_iter().find(|f| f.name == "price").unwrap().value, ["99"]); + let mut s = Session::new().with_clock(|| 1_700_000_000); + s.set_javascript(false); + let id = + s.open("unknown-default.pdf", None, Arc::new(pdfcraft_cos::write_full(&cos, &Default::default()).unwrap()), None).unwrap(); + s.set_javascript(true); + let script = format!("{before}{reset}{after}String(getField('price').value);"); + let out = if native { + let pending = s.start_javascript(id, &script, Some("go")).unwrap(); + let crate::js::InteractiveEvent::Complete(out) = pending.receiver.recv_timeout(std::time::Duration::from_secs(3)).unwrap() + else { + panic!("completion") + }; + s.finish_javascript(pending, out).unwrap() + } else { + s.run_javascript(id, &script, None).unwrap() + }; + assert_eq!(out.error, None); + assert_eq!(out.result.as_deref(), Some(expected)); + let value = |s: &Session, id| s.get(id).unwrap().form.iter().find(|f| f.name == "price").unwrap().value.clone(); + assert_eq!(value(&s, id), [expected]); + assert_eq!(s.get(id).unwrap().can_undo(), Some("Run JavaScript")); + s.undo(id).unwrap(); + assert_eq!(value(&s, id), ["99"]); + s.redo(id).unwrap(); + assert_eq!(value(&s, id), [expected]); + let saved = s.save_bytes(id).unwrap(); + s.set_javascript(false); + let reopened = s.open("saved-unknown.pdf", None, saved, None).unwrap(); + assert_eq!(value(&s, reopened), [expected]); + } + } + } + } +} + +/// Exhausting the shared default decode budget must also reach the JavaScript snapshot as unknown. +#[test] +fn javascript_snapshot_preserves_default_decode_budget_exhaustion() { + let mut cos = pdfcraft_cos::Document::open(Arc::new(scripted_form())).unwrap(); + let stream = cos.add(pdfcraft_cos::Object::Stream(pdfcraft_cos::Stream::from_raw(pdfcraft_cos::Dict::new(), vec![b'd'; 1 << 20]))); + let mut refs = Vec::new(); + for i in 0..70 { + let mut field = pdfcraft_cos::Dict::new(); + field.set(b"FT".to_vec(), pdfcraft_cos::Object::name("Tx")); + field.set(b"T".to_vec(), pdfcraft_cos::PdfString::text(&format!("budget{i}"))); + field.set(b"V".to_vec(), pdfcraft_cos::PdfString::text("typed")); + field.set(b"DV".to_vec(), pdfcraft_cos::Object::Ref(stream)); + refs.push(pdfcraft_cos::Object::Ref(cos.add(pdfcraft_cos::Object::Dict(field)))); + } + let root = cos.root().unwrap(); + cos.update_dict(root, |catalog| { + let mut form = pdfcraft_cos::Dict::new(); + form.set(b"Fields".to_vec(), pdfcraft_cos::Object::Array(refs)); + catalog.set(b"AcroForm".to_vec(), pdfcraft_cos::Object::Dict(form)); + }) + .unwrap(); + let fields = pdfcraft_forms::fields(&cos); + assert_eq!(fields.first().unwrap().default.first().unwrap().len(), 1 << 20); + let last = fields.iter().find(|f| f.name == "budget69").unwrap(); + assert!(last.default.is_empty()); + let snapshot = crate::js::field_state(&cos, last, &pdfcraft_js::DocInfo::default()); + assert!(!snapshot.default_known); + let out = pdfcraft_js::run( + "resetForm();String(getField('budget69').value);", + &pdfcraft_js::Event::doc("Console"), + &Default::default(), + &[snapshot], + &[], + Default::default(), + ); + assert_eq!(out.error, None); + assert_eq!(out.result.as_deref(), Some("typed")); + assert!(out.changed.is_empty()); +} + +/// Immediate dependent reads can explicitly request calculation without changing reset callback ordering. +#[test] +fn calculate_now_after_reset_reads_and_persists_recalculated_dependents() { + use form_scripts::{CalcOp, Calculate}; + let (mut s, id) = reset_order_session(""); + s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("2".into()) }).unwrap(); + s.apply( + id, + Edit::SetFieldProps { + name: "total".into(), + props: Box::new(FieldProps { + calculate: Some(Calculate::Simple { op: CalcOp::Product, fields: vec!["price".into(), "qty".into()] }), + ..Default::default() + }), + }, + ) + .unwrap(); + s.set_javascript(true); + let out = s.run_javascript(id, "resetForm(['price']);calculateNow();String(getField('total').value);", None).unwrap(); + assert_eq!(out.error, None); + assert_eq!(out.result.as_deref(), Some("14")); + assert_eq!(s.get(id).unwrap().form.iter().find(|f| f.name == "total").unwrap().value, ["14"]); +} + +/// Duplicate names retain distinct Calculate scripts, targets and persisted values. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn calculate_now_preserves_duplicate_field_object_identity() { + for (native, reverse) in [(false, false), (false, true), (true, false), (true, true)] { + let mut cos = pdfcraft_cos::Document::open(Arc::new(scripted_form())).unwrap(); + let fields = pdfcraft_forms::fields(&cos); + let a = fields.iter().find(|f| f.name == "price").unwrap().obj; + let b = fields.iter().find(|f| f.name == "qty").unwrap().obj; + for (obj, tag, color) in [(a, "A", "color.red"), (b, "B", "color.blue")] { + cos.update_dict(obj, |d| { + d.set(b"T".to_vec(), pdfcraft_cos::PdfString::text("duplicate")); + d.set(b"V".to_vec(), pdfcraft_cos::PdfString::text(tag)); + let mut action = pdfcraft_cos::Dict::new(); + action.set(b"S".to_vec(), pdfcraft_cos::Object::name("JavaScript")); + action.set( + b"JS".to_vec(), + pdfcraft_cos::PdfString::text(&format!( + "trace+='{tag}'+event.target.value;event.target.userName='{tag}';event.target.fillColor={color};event.target.value='{tag}-temporary';event.value='{tag}-done';" + )), + ); + let mut aa = pdfcraft_cos::Dict::new(); + aa.set(b"C".to_vec(), pdfcraft_cos::Object::Dict(action)); + d.set(b"AA".to_vec(), pdfcraft_cos::Object::Dict(aa)); + }) + .unwrap(); + } + let root = cos.root().unwrap(); + cos.update_dict(root, |d| { + let mut af = d.get(b"AcroForm").unwrap().as_dict().unwrap().clone(); + af.set(b"CO".to_vec(), pdfcraft_cos::Object::Array(if reverse { vec![b.into(), a.into()] } else { vec![a.into(), b.into()] })); + d.set(b"AcroForm".to_vec(), pdfcraft_cos::Object::Dict(af)); + }) + .unwrap(); + // Disable the unrelated original total calculation. + let total = fields.iter().find(|f| f.name == "total").unwrap().obj; + cos.update_dict(total, |d| { + d.remove(b"AA"); + }) + .unwrap(); + let mut s = Session::new(); + s.set_javascript(false); + let id = s.open("duplicate-calculations.pdf", None, Arc::new(pdfcraft_cos::write_full(&cos, &Default::default()).unwrap()), None).unwrap(); + s.set_javascript(true); + let script = "var trace='';calculateNow();trace;"; + let out = if native { + let p = s.start_javascript(id, script, Some("go")).unwrap(); + let crate::js::InteractiveEvent::Complete(out) = p.receiver.recv_timeout(std::time::Duration::from_secs(3)).unwrap() else { + panic!("completion") + }; + s.finish_javascript(p, out).unwrap() + } else { + s.run_javascript(id, script, None).unwrap() + }; + assert_eq!(out.error, None); + let duplicate_fields: Vec<_> = s.get(id).unwrap().form.iter().filter(|f| f.name == "duplicate").collect(); + assert_eq!(duplicate_fields.iter().map(|f| f.tooltip.as_deref()).collect::>(), [Some("A"), Some("B")]); + let cos = &s.get(id).unwrap().editor.as_ref().unwrap().cos; + assert_eq!(pdfcraft_forms::look(cos, duplicate_fields[0]).fill, Some([1.0, 0.0, 0.0])); + assert_eq!(pdfcraft_forms::look(cos, duplicate_fields[1]).fill, Some([0.0, 0.0, 1.0])); + + assert_eq!(out.result.as_deref(), Some(if reverse { "BBAA" } else { "AABB" })); + let values = |s: &Session| s.get(id).unwrap().form.iter().filter(|f| f.name == "duplicate").map(|f| f.value.clone()).collect::>(); + assert_eq!(values(&s), vec![vec!["A-done"], vec!["B-done"]]); + s.undo(id).unwrap(); + assert_eq!(values(&s), vec![vec!["A"], vec!["B"]]); + s.redo(id).unwrap(); + assert_eq!(values(&s), vec![vec!["A-done"], vec!["B-done"]]); + let bytes = s.save_bytes(id).unwrap(); + s.set_javascript(false); + let reopened = s.open("saved-duplicates.pdf", None, bytes, None).unwrap(); + assert_eq!( + s.get(reopened).unwrap().form.iter().filter(|f| f.name == "duplicate").map(|f| f.value.clone()).collect::>(), + vec![vec!["A-done"], vec!["B-done"]] + ); + } +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn script_colors_do_not_mutate_another_fields_shared_mk() { + for native in [false, true] { + for (script, fill, border) in [ + ("getField('price').fillColor=color.red;", Some([1., 0., 0.]), Some([0., 0., 0.])), + ("getField('price').strokeColor=color.blue;", Some([1., 1., 1.]), Some([0., 0., 1.])), + ("getField('price').fillColor=color.red;getField('price').strokeColor=color.blue;", Some([1., 0., 0.]), Some([0., 0., 1.])), + ("getField('price').fillColor=color.transparent;", None, Some([0., 0., 0.])), + ] { + let mut cos = pdfcraft_cos::Document::open(Arc::new(scripted_form())).unwrap(); + let all = pdfcraft_forms::fields(&cos); + let price = all.iter().find(|f| f.name == "price").unwrap().obj; + let qty = all.iter().find(|f| f.name == "qty").unwrap().obj; + let total = all.iter().find(|f| f.name == "total").unwrap().obj; + let mut mk = pdfcraft_cos::Dict::new(); + mk.set(b"BG".to_vec(), pdfcraft_cos::Object::Array(vec![1.into(), 1.into(), 1.into()])); + mk.set(b"BC".to_vec(), pdfcraft_cos::Object::Array(vec![0.into(), 0.into(), 0.into()])); + mk.set(b"CA".to_vec(), pdfcraft_cos::PdfString::text("keep")); + let shared = cos.add(pdfcraft_cos::Object::Dict(mk)); + let alias = cos.add(pdfcraft_cos::Object::Ref(shared)); + for (object, reference) in [(qty, shared), (total, alias)] { + cos.update_dict(object, |d| d.set(b"MK".to_vec(), reference)).unwrap(); + } + let mut widgets = Vec::new(); + for x in [20, 90] { + let mut w = pdfcraft_cos::Dict::new(); + w.set(b"Subtype".to_vec(), pdfcraft_cos::Object::name("Widget")); + w.set(b"Parent".to_vec(), price); + w.set(b"P".to_vec(), pdfcraft_cos::ObjRef::new(3, 0)); + w.set(b"Rect".to_vec(), pdfcraft_cos::Object::Array(vec![x.into(), 250.into(), (x + 50).into(), 270.into()])); + w.set(b"MK".to_vec(), shared); + widgets.push(cos.add(pdfcraft_cos::Object::Dict(w))); + } + cos.update_dict(price, |d| { + d.remove(b"Rect"); + d.set(b"Kids".to_vec(), pdfcraft_cos::Object::Array(widgets.iter().map(|r| (*r).into()).collect())); + }) + .unwrap(); + cos.update_dict(pdfcraft_cos::ObjRef::new(3, 0), |d| { + let mut a = d.get(b"Annots").unwrap().as_array().unwrap().clone(); + a.retain(|o| o.as_ref() != Some(price)); + a.extend(widgets.iter().map(|r| (*r).into())); + d.set(b"Annots".to_vec(), pdfcraft_cos::Object::Array(a)); + }) + .unwrap(); + let mut alias_probe = cos.clone(); + pdfcraft_forms::apply_script_changes( + &mut alias_probe, + &[pdfcraft_forms::FieldChange { + name: "price".into(), + fill_color: Some(vec!["RGB".into(), "1".into(), "0".into(), "0".into()]), + ..Default::default() + }], + &mut pdfcraft_forms::NoScripts, + ) + .unwrap(); + for name in ["qty", "total"] { + let field = pdfcraft_forms::fields(&alias_probe).into_iter().find(|f| f.name == name).unwrap(); + assert_eq!(pdfcraft_forms::look(&alias_probe, &field).fill, Some([1., 1., 1.])); + } + let mut s = Session::new(); + s.set_javascript(false); + let id = s.open("shared-colors.pdf", None, Arc::new(pdfcraft_cos::write_full(&cos, &Default::default()).unwrap()), None).unwrap(); + s.set_javascript(true); + let original_id = id; + let owned_reference = { + let doc = s.get(id).unwrap(); + let widget = doc.form.iter().find(|f| f.name == "price").unwrap().widgets.first().unwrap().obj; + doc.editor.as_ref().unwrap().cos.get(widget).as_dict().unwrap().reference(b"MK").unwrap() + }; + let out = if native { + let p = s.start_javascript(id, script, Some("go")).unwrap(); + let crate::js::InteractiveEvent::Complete(out) = p.receiver.recv_timeout(std::time::Duration::from_secs(3)).unwrap() else { + panic!("completion") + }; + s.finish_javascript(p, out).unwrap() + } else { + s.run_javascript(id, script, None).unwrap() + }; + assert_eq!(out.error, None); + let verify = |s: &Session, id, expected_fill, expected_border| { + let doc = s.get(id).unwrap(); + let cos = &doc.editor.as_ref().unwrap().cos; + let f = doc.form.iter().find(|f| f.name == "price").unwrap(); + assert_eq!(f.widgets.len(), 2); + let look = pdfcraft_forms::look(cos, f); + assert_eq!(look.fill, expected_fill); + assert_eq!(look.border, expected_border); + let actual_reference = cos.get(f.widgets.first().unwrap().obj).as_dict().unwrap().reference(b"MK").unwrap(); + if id == original_id { + assert_eq!(actual_reference, owned_reference); + } + for w in &f.widgets { + assert_eq!(cos.get(w.obj).as_dict().unwrap().reference(b"MK"), Some(actual_reference)); + } + for name in ["qty", "total"] { + let f = doc.form.iter().find(|f| f.name == name).unwrap(); + let look = pdfcraft_forms::look(cos, f); + assert_eq!(look.fill, Some([1., 1., 1.])); + assert_eq!(look.border, Some([0., 0., 0.])); + let mk = cos.get(f.widgets.first().unwrap().obj).as_dict().unwrap().get(b"MK").cloned().unwrap(); + assert_eq!(cos.resolve(&mk).as_dict().unwrap().get(b"CA").unwrap().as_string().unwrap().to_text(), "keep"); + } + }; + verify(&s, id, fill, border); + s.undo(id).unwrap(); + verify(&s, id, Some([1., 1., 1.]), Some([0., 0., 0.])); + s.redo(id).unwrap(); + verify(&s, id, fill, border); + let bytes = s.save_bytes(id).unwrap(); + s.set_javascript(false); + let reopened = s.open("saved-colors.pdf", None, bytes, None).unwrap(); + verify(&s, reopened, fill, border); + } + } +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn exact_button_target_ids_are_validated_before_script_execution() { + for native in [false, true] { + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("buttons.pdf", None, Arc::new(scripted_form()), None).unwrap(); + let fields = s.get(id).unwrap().form.clone(); + let go = fields.iter().find(|f| f.name == "go").unwrap(); + let identity = crate::js::field_object_id(go.obj); + let price = crate::js::field_object_id(fields.iter().find(|f| f.name == "price").unwrap().obj); + let before = s.save_bytes(id).unwrap(); + for (name, key) in [(Some("go"), price), (None, identity), (Some("missing"), identity), (Some("go"), u64::MAX)] { + if native { + assert!(s.start_javascript_target(id, "var shouldNotRun=1;", name, Some(key)).is_err()); + } else { + assert!(s.run_javascript_target(id, "var shouldNotRun=1;", name, Some(key)).is_err()); + } + } + assert_eq!(s.save_bytes(id).unwrap(), before); + assert_eq!(s.run_javascript(id, "typeof shouldNotRun", None).unwrap().result.as_deref(), Some("undefined")); + let out = s + .run_javascript_answers_target( + id, + "event.target.buttonSetCaption(app.response({cQuestion:'Caption'}));event.target.name;", + Some("go"), + Some(identity), + &[Some("answered".into())], + ) + .unwrap(); + assert_eq!(out.error, None); + assert_eq!(out.result.as_deref(), Some("go")); + assert_eq!(s.get(id).unwrap().form.iter().find(|f| f.name == "go").unwrap().widgets.first().unwrap().caption.as_deref(), Some("answered")); + } +} + /// A whole tree goes in as one undoable step, and a refused tree changes nothing. #[test] fn imported_bookmarks_undo_in_one_step() { @@ -13707,6 +14984,214 @@ fn imported_bookmarks_undo_in_one_step() { assert!(s.get(id).unwrap().info.outline.is_empty()); } +/// Rejected host writeback must invalidate globals for both synchronous and native completion. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn refused_script_writeback_retires_the_runtime() { + for native in [false, true] { + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("bounded.pdf", None, Arc::new(synthetic_acroform(1200, 500)), None).unwrap(); + let before = s.save_bytes(id).unwrap(); + let script = "var refusedGlobal=42;for(var i=0;i<32;i++)resetForm();"; + if native { + let pending = s.start_javascript(id, script, None).unwrap(); + let crate::js::InteractiveEvent::Complete(out) = pending.receiver.recv_timeout(std::time::Duration::from_secs(5)).unwrap() else { + panic!("completion") + }; + assert!(s.finish_javascript(pending, out).is_err()); + } else { + assert!(s.run_javascript(id, script, None).is_err()); + } + assert_eq!(s.save_bytes(id).unwrap(), before); + assert_eq!(s.run_javascript(id, "typeof refusedGlobal", None).unwrap().result.as_deref(), Some("undefined")); + } +} +/// History retires removed fields without discarding document globals or aliasing reused numbers. +#[test] +fn undo_removed_field_references_cannot_write_replacement_fields() { + let (mut s, id) = session_with(1); + let add = + |name: &str| Edit::AddField { page: 0, rect: [20.0, 20.0, 120.0, 40.0], kind: NewField::Text { multiline: false }, name: Some(name.into()) }; + s.apply(id, add("old")).unwrap(); + let original = s.get(id).unwrap().form.iter().find(|f| f.name == "old").unwrap().obj; + s.run_javascript(id, "var kept=99;var held=getField('old');held.custom=42;", None).unwrap(); + s.undo(id).unwrap(); + s.apply(id, add("replacement")).unwrap(); + let replacement = s.get(id).unwrap().form.iter().find(|f| f.name == "replacement").unwrap().obj; + assert_eq!(original, replacement, "exercise the reused PDF number"); + assert_eq!( + s.run_javascript(id, "kept===99 && getField('replacement')!==held && getField('replacement').custom===undefined;", None) + .unwrap() + .result + .as_deref(), + Some("true") + ); + let out = s.run_javascript(id, "held.value='wrong';", None).unwrap(); + assert!(out.error.is_some()); + assert!(s.get(id).unwrap().form.iter().find(|f| f.name == "replacement").unwrap().value.is_empty()); + s.undo(id).unwrap(); + s.redo(id).unwrap(); + assert_eq!(s.run_javascript(id, "kept", None).unwrap().result.as_deref(), Some("99")); +} +/// An exact image target changes only the selected duplicate-name field, with history and persistence. +#[test] +fn exact_image_import_targets_only_the_selected_duplicate_button() { + let (mut s, id) = session_with(1); + for (name, x) in [("first", 20.0), ("second", 160.0)] { + s.apply(id, Edit::AddField { page: 0, rect: [x, 20.0, x + 100.0, 80.0], kind: NewField::Image, name: Some(name.into()) }).unwrap(); + } + let mut cos = pdfcraft_cos::Document::open(s.save_bytes(id).unwrap()).unwrap(); + for field in pdfcraft_forms::fields(&cos) { + cos.update_dict(field.obj, |d| d.set(b"T".to_vec(), pdfcraft_cos::PdfString::text("same"))).unwrap(); + } + let bytes = pdfcraft_cos::write_full(&cos, &Default::default()).unwrap(); + let id = s.open("duplicate-image.pdf", None, Arc::new(bytes), None).unwrap(); + let target = s.get(id).unwrap().form[1].obj; + let edit = Edit::SetFieldImageTarget { name: "same".into(), object: target, image: Arc::new(pdfcraft_xfa::fixtures::TINY_GIF.to_vec()) }; + let has_icons = |s: &Session, id| { + let doc = s.get(id).unwrap(); + let cos = &doc.editor.as_ref().unwrap().cos; + doc.form + .iter() + .map(|f| { + cos.get(f.widgets[0].obj) + .as_dict() + .and_then(|d| d.get(b"MK").map(|m| cos.resolve(m))) + .is_some_and(|m| m.as_dict().is_some_and(|d| d.contains(b"I"))) + }) + .collect::>() + }; + s.apply(id, edit).unwrap(); + assert_eq!(has_icons(&s, id), [false, true]); + s.undo(id).unwrap(); + assert_eq!(has_icons(&s, id), [false, false]); + s.redo(id).unwrap(); + assert_eq!(has_icons(&s, id), [false, true]); + let bytes = s.save_bytes(id).unwrap(); + let reopened = s.open("again.pdf", None, bytes, None).unwrap(); + s.apply(reopened, Edit::SetInfo { key: "Title".into(), value: "check".into() }).unwrap(); + assert_eq!(has_icons(&s, reopened), [false, true]); +} + +/// Native automatic calculations and explicit calculateNow agree on the first selected value. +#[test] +fn multiselect_calculation_matches_explicit_script_and_saved_result() { + let (mut s, id) = session_with(1); + for (name, kind, y) in [ + ("choices", NewField::List { options: vec!["2".into(), "5".into()], multi: true }, 100.0), + ("sum", NewField::Text { multiline: false }, 50.0), + ] { + s.apply(id, Edit::AddField { page: 0, rect: [20.0, y, 120.0, y + 30.0], kind, name: Some(name.into()) }).unwrap(); + } + s.apply( + id, + Edit::SetFieldProps { + name: "sum".into(), + props: Box::new(pdfcraft_forms::FieldProps { + calculate: Some(pdfcraft_forms::af::Calculate::Simple { op: pdfcraft_forms::af::CalcOp::Sum, fields: vec!["choices".into()] }), + ..Default::default() + }), + }, + ) + .unwrap(); + s.apply(id, Edit::SetFieldValue { name: "choices".into(), value: pdfcraft_forms::FieldValue::Choice(vec!["2".into(), "5".into()]) }).unwrap(); + assert_eq!(s.get(id).unwrap().form.iter().find(|f| f.name == "sum").unwrap().value, ["2"]); + let out = s.run_javascript(id, "calculateNow();getField('sum').value", None).unwrap(); + assert_eq!(out.error, None); + assert_eq!(out.result.as_deref(), Some("2")); + assert_eq!(s.get(id).unwrap().form.iter().find(|f| f.name == "sum").unwrap().value, ["2"]); + let bytes = s.save_bytes(id).unwrap(); + let reopened = s.open("again.pdf", None, bytes, None).unwrap(); + assert_eq!(s.get(reopened).unwrap().form.iter().find(|f| f.name == "sum").unwrap().value, ["2"]); +} + +/// Real mixed widgets converge even when buttonSetIcon repeats widget zero's existing icon. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn mixed_widget_icons_are_normalized_with_history_and_save() { + for native in [false, true] { + for first_has_icon in [false, true] { + use pdfcraft_cos::{Dict, ObjRef, Object, Stream}; + let mut cos = pdfcraft_cos::Document::open(Arc::new(scripted_form())).unwrap(); + let go = pdfcraft_forms::fields(&cos).into_iter().find(|f| f.name == "go").unwrap().obj; + let mut dict = Dict::new(); + dict.set(b"Type".to_vec(), Object::name("XObject")); + dict.set(b"Subtype".to_vec(), Object::name("Form")); + dict.set(b"BBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 10.into(), 10.into()])); + let a = cos.add(Object::Stream(Stream::flate(dict.clone(), b""))); + let b = cos.add(Object::Stream(Stream::flate(dict, b""))); + let mut widgets = vec![]; + for (i, icon) in [first_has_icon.then_some(a), Some(b)].into_iter().enumerate() { + let mut w = Dict::new(); + w.set(b"Subtype".to_vec(), Object::name("Widget")); + w.set(b"Parent".to_vec(), go); + w.set(b"P".to_vec(), ObjRef::new(3, 0)); + w.set(b"Rect".to_vec(), Object::Array(vec![(20 + i as i64 * 80).into(), 20.into(), (80 + i as i64 * 80).into(), 60.into()])); + let mut mk = Dict::new(); + if let Some(icon) = icon { + mk.set(b"I".to_vec(), icon); + } + w.set(b"MK".to_vec(), Object::Dict(mk)); + widgets.push(cos.add(Object::Dict(w))); + } + cos.update_dict(go, |d| { + d.remove(b"Rect"); + d.set(b"Kids".to_vec(), Object::Array(widgets.iter().map(|r| (*r).into()).collect())); + }) + .unwrap(); + cos.update_dict(ObjRef::new(3, 0), |d| { + let mut annots = d.get(b"Annots").unwrap().as_array().unwrap().clone(); + annots.retain(|v| v.as_ref() != Some(go)); + annots.extend(widgets.iter().map(|r| Object::Ref(*r))); + d.set(b"Annots".to_vec(), Object::Array(annots)); + }) + .unwrap(); + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("mixed-icons.pdf", None, Arc::new(pdfcraft_cos::write_full(&cos, &Default::default()).unwrap()), None).unwrap(); + let script = "getField('go').buttonSetIcon(getField('go').buttonGetIcon());"; + if native { + let pending = s.start_javascript(id, script, Some("go")).unwrap(); + let crate::js::InteractiveEvent::Complete(out) = pending.receiver.recv_timeout(std::time::Duration::from_secs(5)).unwrap() else { + panic!("completion") + }; + assert_eq!(s.finish_javascript(pending, out).unwrap().error, None); + } else { + assert_eq!(s.run_javascript(id, script, Some("go")).unwrap().error, None); + } + let icons = |s: &mut Session, id| { + let cos = pdfcraft_cos::Document::open(s.save_bytes(id).unwrap()).unwrap(); + pdfcraft_forms::fields(&cos) + .iter() + .find(|f| f.name == "go") + .unwrap() + .widgets + .iter() + .map(|w| { + cos.get(w.obj) + .as_dict() + .and_then(|d| d.get(b"MK").map(|v| cos.resolve(v))) + .and_then(|v| v.as_dict().and_then(|d| d.reference(b"I"))) + }) + .collect::>() + }; + let after = icons(&mut s, id); + assert_eq!(after.len(), 2); + assert_eq!(after[0], after[1]); + assert_eq!(after[0].is_some(), first_has_icon); + s.undo(id).unwrap(); + let before = icons(&mut s, id); + assert_ne!(before[0], before[1]); + s.redo(id).unwrap(); + let after = icons(&mut s, id); + assert_eq!(after[0], after[1]); + let bytes = s.save_bytes(id).unwrap(); + let reopened = s.open("again.pdf", None, bytes, None).unwrap(); + let after = icons(&mut s, reopened); + assert_eq!(after[0], after[1]); + assert_eq!(after[0].is_some(), first_has_icon); + } + } +} /// A comment imported or pasted without the appearance its file carried (unreadable there) is /// said in the repair log, which the interface shows and the tools return. #[test] @@ -13822,6 +15307,96 @@ fn a_document_encrypting_only_its_embedded_files_opens_and_saves_without_a_passw assert!(reopened.embedded_files_locked(), "the attachment stays encrypted"); } +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn script_objects_survive_incremental_save_but_not_a_full_rewrite() { + let mut session = Session::new(); + let id = session.open("fields.pdf", None, Arc::new(scripted_form()), None).unwrap(); + session.set_javascript(true); + let stored = session.run_javascript(id, "var retainedPrice = getField('price'); retainedPrice.value = '17';", None).unwrap(); + assert!(stored.error.is_none(), "{stored:?}"); + let incremental = session.save_bytes(id).unwrap(); + session.mark_saved(id, incremental.clone(), None).unwrap(); + let after_incremental = session.run_javascript(id, "String(retainedPrice.value)", None).unwrap(); + assert_eq!(after_incremental.result.as_deref(), Some("17")); + let full = session.save_full_bytes(id).unwrap(); + assert!(!full.starts_with(incremental.as_slice())); + session.mark_saved(id, full, Some("rewritten.pdf".into())).unwrap(); + let after_full = session.run_javascript(id, "typeof retainedPrice + ':' + getField('price').value", None).unwrap(); + assert_eq!(after_full.result.as_deref(), Some("undefined:17")); +} + +#[test] +fn calculate_now_applies_format_script_to_updated_appearance() { + let mut session = Session::new().with_clock(|| 1_700_000_000); + let id = session.open("calculate-format.pdf", None, Arc::new(scripted_form()), None).unwrap(); + session.run_javascript(id, "getField('price').value = 3; getField('qty').value = 2; calculateNow();", None).unwrap(); + + assert_eq!(session.get(id).unwrap().form.iter().find(|field| field.name == "total").unwrap().value, ["6"]); + assert!(page_texts(&session, id)[0].contains("USD 6.00"), "the appearance uses the Format script"); +} + +#[test] +fn javascript_non_rgb_field_colours_convert_and_keep_the_scripts_other_changes() { + let fill_after = |colour: &str| { + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("order.pdf", None, Arc::new(scripted_form()), None).unwrap(); + s.run_javascript(id, "getField('price').fillColor = color.red;", None).unwrap(); + let out = s.run_javascript(id, &format!("var f = getField('price'); f.value = '9'; f.fillColor = {colour}; f.textColor = {colour};"), None); + assert!(out.is_ok(), "{colour}: {out:?}"); + let f = s.get(id).unwrap().form.iter().find(|f| f.name == "price").unwrap().clone(); + assert_eq!(f.value, ["9"], "{colour}: the value write is kept"); + let doc = pdfcraft_cos::Document::open(s.save_bytes(id).unwrap()).unwrap(); + let f = pdfcraft_forms::fields(&doc).into_iter().find(|f| f.name == "price").unwrap(); + let look = pdfcraft_forms::look(&doc, &f); + (look.fill, look.text) + }; + assert_eq!(fill_after("color.white"), (Some([1.0, 1.0, 1.0]), [1.0, 1.0, 1.0])); + assert_eq!(fill_after("color.gray"), (Some([0.5, 0.5, 0.5]), [0.5, 0.5, 0.5])); + assert_eq!(fill_after("color.cyan"), (Some([0.0, 1.0, 1.0]), [0.0, 1.0, 1.0])); + assert_eq!(fill_after("['CMYK', 0, 0, 0, 1]"), (Some([0.0, 0.0, 0.0]), [0.0, 0.0, 0.0])); + // Transparent clears the fill; text can't be transparent, so its colour stays. + let (fill, text) = fill_after("color.transparent"); + assert_eq!(fill, None); + assert_eq!(text, [0.0, 0.0, 0.0]); + + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("order.pdf", None, Arc::new(scripted_form()), None).unwrap(); + let before = s.save_bytes(id).unwrap(); + for bad in ["['CMYK', 1]", "['G']", "['T', 0]", "['Lab', 0, 0, 0]", "['G', 'x']"] { + let err = s.run_javascript(id, &format!("var f = getField('price'); f.value = '9'; f.fillColor = {bad};"), None).unwrap_err(); + assert!(err.to_string().contains("T, G, RGB or CMYK"), "{bad}: {err}"); + assert_eq!(s.save_bytes(id).unwrap(), before, "{bad}: a malformed colour still refuses the script"); + } +} + +#[test] +fn a_later_field_script_in_the_same_edit_reads_a_colour_an_earlier_one_wrote() { + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("order.pdf", None, Arc::new(scripted_form()), None).unwrap(); + for (name, script) in + [("qty", "getField('price').fillColor = color.green; event.value = 1;"), ("total", "event.value = getField('price').fillColor.join(',');")] + { + s.apply(id, Edit::SetFieldScript { name: name.into(), event: "calculate".into(), script: Some(script.into()) }).unwrap(); + } + s.apply(id, Edit::SetFieldScript { name: "total".into(), event: "format".into(), script: None }).unwrap(); + s.apply(id, Edit::SetCalculationOrder { names: vec!["qty".into(), "total".into()] }).unwrap(); + s.apply(id, Edit::SetFieldValue { name: "price".into(), value: FieldValue::Text("3".into()) }).unwrap(); + let total = s.get(id).unwrap().form.iter().find(|f| f.name == "total").unwrap().value.clone(); + assert_eq!(total, ["RGB,0,1,0"]); +} + +#[test] +fn a_document_script_helper_can_set_field_colours() { + let helpers = "function mark\\(name\\) { getField\\(name\\).fillColor = color.red; }"; + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("helpers.pdf", None, Arc::new(scripted_form_with(helpers)), None).unwrap(); + s.run_javascript(id, "mark('price');", None).unwrap(); + let doc = pdfcraft_cos::Document::open(s.save_bytes(id).unwrap()).unwrap(); + let f = pdfcraft_forms::fields(&doc).into_iter().find(|f| f.name == "price").unwrap(); + assert_eq!(pdfcraft_forms::look(&doc, &f).fill, Some([1.0, 0.0, 0.0])); +} + #[test] fn calculate_now_simple_calculation_reads_formatted_values_and_groups_as_the_native_path_does() { // amount (4), group b (5) with kids b.c (6) and b.d (7), total (8) = SUM(amount, b). @@ -13850,3 +15425,48 @@ fn calculate_now_simple_calculation_reads_formatted_values_and_groups_as_the_nat assert!(out.console.iter().any(|line| line == "3244.75"), "calculateNow agrees with the native path: {out:?}"); assert_eq!(total(&s), ["3244.75"]); } + +/// A Word document converts through LibreOffice when it is installed; without it, the error +/// says what is missing rather than calling the file a broken PDF. +#[test] +fn office_documents_convert_through_libreoffice_or_explain_why_not() { + let mut s = Session::new(); + let text = s.create_from_text("t", "Quarterly report").unwrap(); + let id = s.open("t.pdf", None, text, None).unwrap(); + let docx = Arc::new(s.get(id).unwrap().export_office(compare::OfficeFormat::Docx)); + assert_eq!(source_kind("Report.docx", &docx), Some(SourceKind::Office)); + match s.convert_to_pdf("Report.docx", &docx) { + Ok((kind, pdf)) => { + assert_eq!(kind, SourceKind::Office); + let converted = s.open_new("Report.pdf", pdf).unwrap(); + assert!(page_texts(&s, converted).concat().contains("Quarterly report")); + } + // Only "not installed" is an acceptable failure: a conversion that fails, times out or + // makes no PDF must fail this test. + Err(e) => assert!(e.to_string().contains("LibreOffice, which isn't installed"), "{e}"), + } + // Combine treats it as a file to convert, not as a PDF to pass through. + assert!( + s.as_pdf("Report.docx", &docx).map_or_else(|e| e.to_string().contains("LibreOffice, which isn't installed"), |pdf| pdf.starts_with(b"%PDF-")) + ); +} + +/// The frame thread never waits for LibreOffice: Insert, Combine and Create Multiple refuse an +/// Office document and point to File ▸ Open, while images and text still convert there. +#[test] +fn inline_conversions_refuse_office_documents_without_running_libreoffice() { + let s = Session::new(); + let docx = Arc::new(b"PK\x03\x04\x14\x00".to_vec()); + for err in + [s.convert_to_pdf_inline("Report.docx", &docx).map(|_| ()).unwrap_err(), s.as_pdf_inline("Report.docx", &docx).map(|_| ()).unwrap_err()] + { + assert_eq!( + err, + EditError::Source( + "Report.docx: Word, Excel and PowerPoint files can't be added here yet; open it with File ▸ Open, which converts it to a PDF".into() + ) + ); + } + assert_eq!(s.convert_to_pdf_inline("notes.txt", &Arc::new(b"hello".to_vec())).unwrap().0, SourceKind::Text); + assert!(s.as_pdf_inline("scan.jpg", &Arc::new(jpeg_bytes())).unwrap().starts_with(b"%PDF-")); +} diff --git a/crates/engine/src/xfa.rs b/crates/engine/src/xfa.rs index 9b28320a55..ee8c92cfdd 100644 --- a/crates/engine/src/xfa.rs +++ b/crates/engine/src/xfa.rs @@ -327,6 +327,28 @@ fn split_instance(tpl: &Template, som: &str) -> Option<(pdfcraft_xfa::DataPath, Some((path, parent_som, name, index)) } +/// The final layout index, which can differ from the index of the bound data node. +fn indexed_som_tail(som: &str) -> Option<(&str, usize)> { + let (prefix, index) = som.strip_suffix(']')?.rsplit_once('[')?; + Some((prefix, index.parse().ok()?)) +} + +/// Adjust a pending focus when a sibling is deleted and its later instances are renumbered. +fn shift_focus_after_removal(target: &mut Option, prefix: &str, removed: usize) { + let Some((index, suffix)) = target + .as_deref() + .and_then(|som| som.strip_prefix(prefix)?.strip_prefix('[')?.split_once(']')) + .and_then(|(i, tail)| Some((i.parse::().ok()?, tail))) + else { + return; + }; + if index == removed { + *target = None; + } else if index > removed { + *target = Some(format!("{prefix}[{}]{suffix}", index - 1)); + } +} + /// A SOM path without its indexes (`form.page1.qty`), for matching names scripts pass. fn plain_som(som: &str) -> String { let mut out = String::with_capacity(som.len()); @@ -451,6 +473,8 @@ struct Runner<'a, 'b> { /// Field values to write, by name (the last wins). field_changes: std::collections::BTreeMap>, by_som: Option>, + objects_by_som: HashMap, + focus_requests: Vec<(usize, Option)>, /// Widget names by the data node they bind to (built with `by_som`). by_path: Option>>, fields: Option>, @@ -498,6 +522,8 @@ impl<'a, 'b> Runner<'a, 'b> { pending: Vec::new(), field_changes: Default::default(), by_som: None, + objects_by_som: HashMap::new(), + focus_requests: Vec::new(), by_path: None, fields: None, cached: None, @@ -559,7 +585,12 @@ impl<'a, 'b> Runner<'a, 'b> { fn by_som(&mut self) -> &HashMap { if self.by_som.is_none() { - let by_som = pdfcraft_xfa::fields_by_som(self.doc); + let mut by_som = HashMap::new(); + self.objects_by_som.clear(); + for (som, (name, object)) in pdfcraft_xfa::field_objects_by_som(self.doc) { + self.objects_by_som.insert(som.clone(), object); + by_som.insert(som, name); + } let mut by_path: HashMap> = HashMap::new(); for (som, name) in &by_som { by_path.entry(pdfcraft_xfa::data_path(self.tpl, som)).or_default().push(name.clone()); @@ -891,6 +922,13 @@ impl<'a, 'b> Runner<'a, 'b> { // The data holds every shown instance before one is taken out. counts.insert(key, have.saturating_sub(1)); self.data_op(pdfcraft_xfa::DataOp::Instances { parent: parent.clone(), name: name.clone(), count: have }); + // SOM indexes after the removed instance shift down. A focus on + // the deleted instance itself must never reach its replacement. + if let Some((prefix, removed)) = indexed_som_tail(som) { + for (_, target) in &mut self.focus_requests { + shift_focus_after_removal(target, prefix, removed); + } + } let mut path = parent; path.push((name, index)); self.data_op(pdfcraft_xfa::DataOp::Remove(path)); @@ -914,9 +952,8 @@ impl<'a, 'b> Runner<'a, 'b> { XfaEffect::OpenAttachment { name, open } => self.run.out.requests.push(Request::OpenAttachment { name: name.clone(), open: *open }), XfaEffect::ShowPanel(p) => self.run.out.requests.push(Request::ShowPanel(*p)), XfaEffect::SetFocus(som) => { - if let Some(name) = self.by_som().get(som).cloned() { - self.run.out.requests.push(Request::Focus(name)); - } + self.focus_requests.push((self.run.out.requests.len(), Some(som.clone()))); + self.run.out.requests.push(Request::Focus(String::new())); } XfaEffect::Beep => self.run.out.requests.push(Request::Beep), XfaEffect::Highlight(on) => { @@ -1026,6 +1063,16 @@ impl<'a, 'b> Runner<'a, 'b> { self.relayout()?; } self.flush()?; + if !self.focus_requests.is_empty() { + self.by_som(); + for (index, som) in self.focus_requests.into_iter().rev() { + if let Some((name, object)) = som.as_ref().and_then(|som| self.by_som.as_ref()?.get(som).zip(self.objects_by_som.get(som))) { + self.run.out.requests[index] = Request::FocusField { name: name.clone(), object_id: crate::js::field_object_id(*object) }; + } else { + self.run.out.requests.remove(index); + } + } + } self.session = None; if self.skipped > 0 { let why = if self.run.ran_away { @@ -1442,6 +1489,30 @@ mod tests { assert!(started.elapsed() < std::time::Duration::from_secs(2), "took {:?}", started.elapsed()); } + /// Verify that a shared layout name cannot cause a data-binding index to redirect pending focus. + #[test] + fn focus_shift_uses_the_som_index_when_data_binding_omits_the_segment() { + let (tpl, _) = pdfcraft_xfa::parse( + r#""#, + ) + .unwrap(); + let removed = "form[0].row[1]"; + assert_eq!(split_instance(&tpl, removed).unwrap().3, 0); + let (prefix, index) = indexed_som_tail(removed).unwrap(); + for (before, after) in [ + ("form[0].row[0].amount[0]", Some("form[0].row[0].amount[0]")), + ("form[0].row[1].amount[0]", None), + ("form[0].row[2].amount[0]", Some("form[0].row[1].amount[0]")), + ] { + let mut target = Some(before.to_owned()); + shift_focus_after_removal(&mut target, prefix, index); + assert_eq!(target.as_deref(), after); + } + assert!(indexed_som_tail("form[0].row[bad]").is_none()); + } + #[test] fn script_names_match_indexed_som_paths() { let som_matches = |som: &str, name: &str| ResetTargets::new(&[name.to_string()]).matches(som); diff --git a/crates/engine/tests/form_date_language.rs b/crates/engine/tests/form_date_language.rs index 55a701b6a9..ba4193522e 100644 --- a/crates/engine/tests/form_date_language.rs +++ b/crates/engine/tests/form_date_language.rs @@ -21,7 +21,7 @@ fn names_lock() -> MutexGuard<'static, ()> { /// `util.printd(fmt)` of 18 October 2026, then of what `util.scand(fmt, typed)` reads. fn printd(fmt: &str, typed: &str) -> String { - let doc = DocInfo { file_name: "form.pdf".into(), num_pages: 1, page: 0, info: Vec::new(), xfa: false, highlight: false, ..Default::default() }; + let doc = DocInfo { file_name: "form.pdf".into(), num_pages: 1, page: 0, ..Default::default() }; let script = format!("event.value = util.printd('{fmt}', new Date(2026, 9, 18)) + '|' + util.printd('{fmt}', util.scand('{fmt}', '{typed}'));"); let o = run(&script, &Event::field("Format", "date", ""), &doc, &[], &[], Limits::default()); assert_eq!(o.error, None); @@ -75,7 +75,7 @@ fn form_dates_follow_the_date_language() { #[test] fn an_english_weekday_before_a_shared_month_name_reads_as_english() { let _names = names_lock(); - let doc = DocInfo { file_name: "form.pdf".into(), num_pages: 1, page: 0, info: Vec::new(), xfa: false, highlight: false, ..Default::default() }; + let doc = DocInfo { file_name: "form.pdf".into(), num_pages: 1, page: 0, ..Default::default() }; let mar17 = DateTime { y: 2026, m: 3, d: 17, hh: 0, mm: 0, ss: 0 }; let mut bad = Vec::new(); for language in ["it", "es", "ro", "sq"] { @@ -111,7 +111,7 @@ fn an_english_weekday_before_a_shared_month_name_reads_as_english() { /// letter that writes a name, through the form path and through `util.scand`. Called from the /// one test, which owns the process-wide names. fn every_date_language_reads_back_what_it_writes() { - let doc = DocInfo { file_name: "form.pdf".into(), num_pages: 1, page: 0, info: Vec::new(), xfa: false, highlight: false, ..Default::default() }; + let doc = DocInfo { file_name: "form.pdf".into(), num_pages: 1, page: 0, ..Default::default() }; let mut bad = Vec::new(); for l in pdfcraft_engine::dates::DATE_LANGUAGES.iter() { use_form_date_language(l.code); @@ -319,7 +319,7 @@ fn an_english_run_that_prints_the_stored_value_is_drawn() { /// What `util.scand(fmt, text)` reads, as `yyyy-mm-dd`, or `null`. fn scanned(fmt: &str, text: &str) -> String { - let doc = DocInfo { file_name: "form.pdf".into(), num_pages: 1, page: 0, info: Vec::new(), xfa: false, highlight: false, ..Default::default() }; + let doc = DocInfo { file_name: "form.pdf".into(), num_pages: 1, page: 0, ..Default::default() }; let script = format!("var d = util.scand('{fmt}', {text:?}); event.value = d ? util.printd('yyyy-mm-dd', d) : 'null';"); let o = run(&script, &Event::field("Format", "date", ""), &doc, &[], &[], Limits::default()); assert_eq!(o.error, None); diff --git a/crates/fonts/src/pdf.rs b/crates/fonts/src/pdf.rs index f68761e02e..7ae75ddf31 100644 --- a/crates/fonts/src/pdf.rs +++ b/crates/fonts/src/pdf.rs @@ -70,7 +70,10 @@ pub struct Metrics { /// A stream these metrics come from (a CMap, the program, `/CIDToGIDMap`, a Type 3 glyph /// description) was left unread because the decode allowance [`Metrics::from_dict_within`] /// was given was spent: what it would have decided (code lengths, CIDs, widths, meanings, a - /// Type 3 font's height) is a guess. + /// Type 3 font's height) is a guess. It is set exactly when the allowance counted a refusal + /// during the read ([`DecodeBudget::refused`]), which callers that share it may refuse on; a + /// read that fails without one, such as an empty stream behind a filter nothing decodes, + /// doesn't set it, with or without anything left. pub past_allowance: bool, } @@ -420,12 +423,16 @@ impl<'a> Reads<'a> { /// stage past `max` is unread. When what is left is more than what the stages before that one /// made and `max`, it is charged those two and isn't refused; otherwise, with more left than /// the stages' total (behind one filter, `max`), it is charged that total and isn't refused; - /// with no more left than that total, it spends all of it and counts as refused. + /// with no more left than that total, it spends all of it and counts as refused. Only what + /// the allowance counts as refused ([`DecodeBudget::refused`]) is noted: a read that fails + /// without one, such as an empty stream behind a filter nothing decodes, fails the same with + /// more left, and callers that refuse on that count must agree with [`Metrics::past_allowance`]. fn decode(&mut self, stream: &Stream, max: usize) -> Option> { + let refused = self.budget.refused(); match self.budget.decode_stages_within(self.doc, stream, max) { Ok(data) => Some(data), Err(_) => { - self.refused |= self.budget.remaining() == 0; + self.refused |= self.budget.refused() > refused; None } } @@ -491,10 +498,14 @@ fn type3_glyph_box(doc: &Document, font: &Dict, reads: &mut Reads<'_>) -> Option break; } let before = reads.budget.remaining(); + let refused = reads.budget.refused(); let read = reads.budget.decode_with_damage(doc, s, MAX_TYPE3_GLYPH_BYTES.min(left)).map(|(data, _)| data); left = left.saturating_sub(before.saturating_sub(reads.budget.remaining())); let Ok(data) = read else { - if reads.budget.remaining() == 0 { + // As in `Reads::decode`, only a refusal the allowance counts: a description that fails + // without one (an empty one nothing decodes, once the allowance is spent) leaves the + // rest to be read, or refused above. + if reads.budget.refused() > refused { // The rest go unread too, for want of the reading's allowance. reads.refused = true; complete = false; @@ -810,6 +821,20 @@ fn number(t: &Tok) -> Option { } } +/// The font's `/BaseFont` (a Type 3 font's descriptor `/FontName` when it has none), read without +/// building its metrics, so it decodes no CMap. +pub fn base_font_name(doc: &Document, font: &Dict) -> String { + font.name(b"BaseFont") + .map(|name| String::from_utf8_lossy(name).into_owned()) + .or_else(|| { + (font.name(b"Subtype") == Some(b"Type3")) + .then(|| dict(doc, font.get(b"FontDescriptor"))) + .flatten() + .and_then(|d| d.name(b"FontName").map(|name| String::from_utf8_lossy(name).into_owned())) + }) + .unwrap_or_default() +} + /// Codespace ranges and CID mappings from an embedded CMap stream, at most /// [`MAX_CMAP_RANGES`] of each (every glyph searches them). #[allow(clippy::type_complexity)] @@ -898,16 +923,7 @@ impl Metrics { pub fn from_dict_within(doc: &Document, font: &Dict, budget: &mut DecodeBudget) -> Self { let mut reads = Reads::new(doc, budget); let mut m = Self::read_metrics(doc, font, &mut reads); - let base = font - .name(b"BaseFont") - .map(|name| String::from_utf8_lossy(name).into_owned()) - .or_else(|| { - (font.name(b"Subtype") == Some(b"Type3")) - .then(|| dict(doc, font.get(b"FontDescriptor"))) - .flatten() - .and_then(|d| d.name(b"FontName").map(|name| String::from_utf8_lossy(name).into_owned())) - }) - .unwrap_or_default(); + let base = base_font_name(doc, font); m.subset = base.len() > 7 && base.as_bytes()[6] == b'+' && base[..6].bytes().all(|b| b.is_ascii_uppercase()); let (bold, italic) = style_from_name(&base); m.bold |= bold; diff --git a/crates/fonts/src/pdf/tests.rs b/crates/fonts/src/pdf/tests.rs index 975b71289e..74880c94a4 100644 --- a/crates/fonts/src/pdf/tests.rs +++ b/crates/fonts/src/pdf/tests.rs @@ -309,17 +309,27 @@ fn a_fonts_streams_are_paid_for_from_its_allowance() { } /// With nothing left, an empty stream still reads, as the allowance reads one, and is no reason to -/// call the font read past it; a stream with data is left unread, and is. +/// call the font read past it; a stream with data is left unread, and is. An empty stream behind a +/// filter nothing decodes is unread with any allowance, so it isn't either: the font is read past +/// its allowance exactly when the allowance counts a refusal, which is what callers refuse on. #[test] fn an_empty_stream_reads_with_nothing_left() { - let past = |data: &[u8]| { + let past = |filter: Option<&str>, data: &[u8]| { let mut doc = Document::new_empty(); - let unicode = Object::Ref(doc.add(Object::Stream(Stream::from_raw(Dict::new(), data.to_vec())))); + let mut dict = Dict::new(); + if let Some(filter) = filter { + dict.set(b"Filter".to_vec(), Object::name(filter)); + } + let unicode = Object::Ref(doc.add(Object::Stream(Stream::from_raw(dict, data.to_vec())))); let f = font(&mut doc, vec![("Subtype", Object::name("TrueType")), ("ToUnicode", unicode)]); - Metrics::from_dict_within(&doc, &f, &mut DecodeBudget::new(0, 0)).past_allowance + let mut budget = DecodeBudget::new(0, 0); + let past = Metrics::from_dict_within(&doc, &f, &mut budget).past_allowance; + assert_eq!(past, budget.refused() > 0, "{filter:?}"); + past }; - assert!(!past(b"")); - assert!(past(b"1 beginbfchar <41> <0058> endbfchar")); + assert!(!past(None, b"")); + assert!(!past(Some("NoSuchFilter"), b"")); + assert!(past(None, b"1 beginbfchar <41> <0058> endbfchar")); } /// A stream behind one filter past its own limit, with more left than its limit, is left unread as @@ -424,6 +434,45 @@ fn each_stage_of_a_metrics_stream_is_held_to_its_limit() { assert_eq!(read(MAX_CID_TO_GID / 4), (false, false)); } +/// The editor's program list reads a legacy Thai font's metrics after its program. With the program +/// spending the allowance to the byte, a ToUnicode that is an empty stream behind a filter nothing +/// decodes, unread with any allowance, doesn't put the metrics past the allowance: the program is +/// listed. A ToUnicode with data is refused by the allowance, and the program isn't. +#[test] +fn an_editors_program_is_listed_past_a_stream_unread_on_its_own() { + let bytes = include_bytes!("../../../../assets/fonts/Inter-Regular.ttf"); + let listed = |filter: Option<&str>, data: &[u8]| { + let mut doc = Document::new_empty(); + let mut dict = Dict::new(); + if let Some(filter) = filter { + dict.set(b"Filter".to_vec(), Object::name(filter)); + } + let unicode = Object::Ref(doc.add(Object::Stream(Stream::from_raw(dict, data.to_vec())))); + let program = Object::Ref(doc.add(Object::Stream(Stream::from_raw(Dict::new(), bytes.to_vec())))); + let mut descriptor = Dict::new(); + descriptor.set(b"FontFile2".to_vec(), program); + let f = font( + &mut doc, + vec![ + ("Subtype", Object::name("TrueType")), + ("BaseFont", Object::name("THSarabunPSK")), + ("FirstChar", Object::Int(65)), + ("LastChar", Object::Int(65)), + ("Widths", Object::Array(vec![Object::Int(500)])), + ("Encoding", Object::name("WinAnsiEncoding")), + ("FontDescriptor", Object::Dict(descriptor)), + ("ToUnicode", unicode), + ], + ); + let mut budget = DecodeBudget::new(bytes.len(), 0); + let listed = embedded_sfnt_within(&doc, &f, &mut budget).is_some(); + assert_eq!(budget.remaining(), 0, "the program spends the allowance to the byte"); + (listed, budget.refused()) + }; + assert_eq!(listed(Some("NoSuchFilter"), b""), (true, 0)); + assert_eq!(listed(None, b"1 beginbfchar <41> <0058> endbfchar"), (false, 1)); +} + /// The editor's program is held to its limit as the metrics reads' streams are: a 12 MiB program /// whose Flate data is about 8 MiB reads behind ASCIIHex and Flate as behind Flate alone, though /// the two stages make about 20 MiB together. A program of the limit reads behind either, and one a @@ -969,6 +1018,41 @@ fn type3_glyph_boxes_are_paid_for_from_the_allowance() { assert!(m.past_allowance, "a byte short, the second description is left unread"); } +/// A glyph description that fails on its own once the allowance is spent to the byte, an empty one +/// behind a filter nothing decodes, fails the same with more left: it doesn't make the metrics read +/// past the allowance, and the descriptions after it are read, or refused by the allowance. +#[test] +fn a_type3_description_unread_on_its_own_is_not_past_the_allowance() { + let mut doc = Document::new_empty(); + let procs = ["1 0 0 -20 1 300 d1", "", "1 0 0 0 1 400 d1"]; + let mut f = type3(&mut doc, [1.0, 0.0, 0.0, 1.0, 0.0, 0.0], [0, 0, 0, 0], &procs); + let mut dict = Dict::new(); + dict.set(b"Filter".to_vec(), Object::name("NoSuchFilter")); + let unread = Object::Ref(doc.add(Object::Stream(Stream::from_raw(dict, Vec::new())))); + let Some(Object::Dict(mut charprocs)) = f.get(b"CharProcs").cloned() else { panic!("CharProcs") }; + charprocs.set(b"g1".to_vec(), unread); + f.set(b"CharProcs".to_vec(), Object::Dict(charprocs)); + with_differences(&mut f, &["g0", "g1", "g2"].map(String::from)); + let first = procs[0].len(); + let whole = Metrics::from_dict_within(&doc, &f, &mut DecodeBudget::new(usize::MAX, 0)); + assert!(!whole.past_allowance); + assert_eq!((whole.ascent, whole.descent), (400.0, -20.0)); + // The first description spends it to the byte; the third, which has data, is refused. + let mut budget = DecodeBudget::new(first, 0); + let m = Metrics::from_dict_within(&doc, &f, &mut budget); + assert_eq!(budget.refused(), 1); + assert!(m.past_allowance); + // Without the third, nothing is refused, and the metrics aren't past the allowance. + let Some(Object::Dict(mut charprocs)) = f.get(b"CharProcs").cloned() else { panic!("CharProcs") }; + charprocs.remove(b"g2"); + f.set(b"CharProcs".to_vec(), Object::Dict(charprocs)); + let mut budget = DecodeBudget::new(first, 0); + let m = Metrics::from_dict_within(&doc, &f, &mut budget); + assert_eq!((budget.refused(), budget.remaining()), (0, 0)); + assert!(!m.past_allowance); + assert_eq!((m.ascent, m.descent), (300.0, -20.0)); +} + /// A Type 3 font measured from its glyphs' `d1` boxes keeps them over a descriptor's `/Ascent` /// and `/Descent`, which are optional for Type 3 (ISO 32000-2 Table 120). #[test] @@ -1739,6 +1823,16 @@ fn low_codes_resolve_to_the_covering_range() { assert_eq!((u.covering(0xFFFF).is_some(), u.covering(0x1_0001).is_some(), u.covering(0x1_0002).is_none()), (true, true, true)); } +#[test] +fn base_font_name_matches_the_metrics_name() { + let doc = Document::new_empty(); + let mut f = Dict::new(); + f.set(b"BaseFont".to_vec(), Object::name("ABCDEF+Body")); + assert_eq!(base_font_name(&doc, &f), "ABCDEF+Body"); + assert_eq!(base_font_name(&doc, &f), Metrics::from_dict(&doc, &f).base_font); + assert_eq!(base_font_name(&doc, &Dict::new()), ""); +} + #[test] fn refused_subset_reads_do_not_encode_unverified_glyphs() { let mut doc = Document::new_empty(); diff --git a/crates/forms/src/actions.rs b/crates/forms/src/actions.rs index 6a91220713..96d03e1869 100644 --- a/crates/forms/src/actions.rs +++ b/crates/forms/src/actions.rs @@ -128,6 +128,21 @@ fn read(doc: &Document, o: &Object, pages: &[pdfcraft_cos::ObjRef], scripts: &mu }) } +/// The JavaScript widget annotation `widget` runs for `trigger` (ISO 32000-2 Table 197: `/A` +/// or `/AA /U` for Mouse Up, the other `/AA` entries for the rest): that action's, when it is a +/// JavaScript action, then those of the actions chained after it with `/Next`, in the order +/// they run (bounded as [`crate::document_action_scripts`] is). Reads only the widget's own +/// dictionary, so a pointer moving over a large form costs nothing per field. +pub fn widget_scripts(doc: &Document, widget: pdfcraft_cos::ObjRef, trigger: Trigger) -> Vec { + let w = doc.get(widget); + let Some(wd) = w.as_dict() else { return Vec::new() }; + let action = match trigger.key() { + None => wd.get(b"A").cloned().or_else(|| wd.get(b"AA").map(|a| doc.resolve(a)).and_then(|a| a.as_dict()?.get(b"U").cloned())), + Some(k) => wd.get(b"AA").map(|a| doc.resolve(a)).and_then(|a| a.as_dict()?.get(k).cloned()), + }; + crate::scripting::chained_scripts(doc, action) +} + /// The actions of field `name` (its first widget), by trigger, in the tab's order. pub fn field_actions(doc: &Document, name: &str) -> Result, FormError> { let all = fields(doc); diff --git a/crates/forms/src/author.rs b/crates/forms/src/author.rs index ee6eef518b..f347909b58 100644 --- a/crates/forms/src/author.rs +++ b/crates/forms/src/author.rs @@ -19,9 +19,13 @@ mod tree; pub(crate) use tree::ancestor_sets; pub use edit::set_props; +pub(crate) use edit::set_props_object; pub use lifecycle::{add_field, delete_field, delete_field_object, delete_widget, duplicate_field}; pub(crate) use look::button_appearance; -pub use look::{BorderStyle, CheckStyle, FieldFont, Look, LookPatch, check_style, look, redraw_field, redraw_fields, set_button_icon}; +pub(crate) use look::redraw_field_object; +pub use look::{ + BorderStyle, CheckStyle, FieldFont, Look, LookPatch, check_style, look, redraw_field, redraw_fields, set_button_icon, set_button_icon_target, +}; 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. @@ -82,6 +86,8 @@ pub struct FieldProps { /// Normal push-button label (`/MK /CA`) of one widget (0-based index). /// An empty string clears the label; other widgets and icon layouts are preserved. pub caption: Option<(usize, String)>, + /// Normal push-button label for every widget in the field. + pub caption_all: Option, pub read_only: Option, pub required: Option, pub multiline: Option, diff --git a/crates/forms/src/author/edit.rs b/crates/forms/src/author/edit.rs index ea718369bf..b539bfc118 100644 --- a/crates/forms/src/author/edit.rs +++ b/crates/forms/src/author/edit.rs @@ -111,6 +111,15 @@ fn calculate_action(c: &crate::af::Calculate) -> Option { /// Change a field's properties (General and Options tabs) and redraw it. pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result { + set_props_target(doc, name, None, props) +} + +/// An exact script target can share its name with another field. +pub(crate) fn set_props_object(doc: &mut Document, name: &str, object: ObjRef, props: &FieldProps) -> Result { + set_props_target(doc, name, Some(object), props) +} + +fn set_props_target(doc: &mut Document, name: &str, object: Option, 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"); } @@ -127,7 +136,7 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result Result Result<() fn delete_found_field(doc: &mut Document, f: Field) -> Result<(), FormError> { let widgets: std::collections::HashSet = f.widgets.iter().map(|w| w.obj).collect(); drop_from_pages(doc, &widgets)?; + let mut parent = doc.get(f.obj).as_dict().and_then(|d| d.reference(b"Parent")); 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)) + drop_from_calculation_order(doc, |r| r == f.obj || widgets.contains(&r))?; + // An ancestor left with no kids would become a field of its own, and keep the `/V` and `/DV` + // the deleted field inherited from it: it goes too, up the chain. + let mut gone = std::collections::HashSet::new(); + while let Some(p) = parent.filter(|p| gone.len() < 64 && !gone.contains(p)) { + let Some(d) = doc.get(p).as_dict().cloned() else { break }; + let empty = d.get(b"Kids").map(|o| doc.resolve(o)).and_then(|o| o.as_array().map(Vec::is_empty)) == Some(true); + if !empty { + break; + } + parent = d.reference(b"Parent"); + gone.insert(p); + drop_from_pages(doc, &std::iter::once(p).collect())?; + detach(doc, p)?; + } + drop_from_calculation_order(doc, |r| gone.contains(&r)) } /// Take the annotations `widgets` off every page's `/Annots`, held directly or by reference. diff --git a/crates/forms/src/author/look.rs b/crates/forms/src/author/look.rs index 01e78f8dc1..ca352e9815 100644 --- a/crates/forms/src/author/look.rs +++ b/crates/forms/src/author/look.rs @@ -314,7 +314,18 @@ pub fn redraw_field(doc: &mut Document, name: &str) -> Result<(), FormError> { /// 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()))?; + redraw_widgets_target(doc, name, None, only) +} + +pub(crate) fn redraw_field_object(doc: &mut Document, name: &str, object: ObjRef) -> Result<(), FormError> { + redraw_widgets_target(doc, name, Some(object), None) +} + +pub(super) fn redraw_widgets_target(doc: &mut Document, name: &str, object: Option, only: Option) -> Result<(), FormError> { + let f = fields(doc) + .into_iter() + .find(|f| f.name == name && object.is_none_or(|obj| f.obj == obj)) + .ok_or_else(|| FormError::NoSuchField(name.into()))?; redraw_widgets_of(doc, &f, only) } @@ -436,8 +447,12 @@ pub(crate) fn button_appearance(doc: &Document, w: &Widget, field_da: &str) -> ( && 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); + // /IF /FB fits the icon to the whole widget, including its border area. + let fit_bounds = mk.get(b"IF").is_some_and(|fit| { + doc.resolve(fit).as_dict().and_then(|fit| fit.get(b"FB")).is_some_and(|flag| matches!(&*doc.resolve(flag), Object::Bool(true))) + }); + let inset = if fit_bounds { 0.0 } else { 2.0 }; + let k = ((width - inset) / (x1 - x0)).min((height - inset) / (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)); @@ -464,7 +479,15 @@ pub(crate) fn button_appearance(doc: &Document, w: &Widget, field_da: &str) -> ( /// 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()))?; + set_button_icon_target(doc, name, None, image, px) +} + +/// Give the exact field its image without changing another field with the same name. +pub fn set_button_icon_target(doc: &mut Document, name: &str, object: Option, image: ObjRef, px: (u32, u32)) -> Result<(), FormError> { + let f = fields(doc) + .into_iter() + .find(|f| f.name == name && object.is_none_or(|obj| f.obj == obj)) + .ok_or_else(|| FormError::NoSuchField(name.into()))?; if f.kind != FieldKind::PushButton { return invalid(format!("{name} is not a button or image field")); } @@ -485,7 +508,7 @@ pub fn set_button_icon(doc: &mut Document, name: &str, image: ObjRef, px: (u32, } doc.update_dict(wd.obj, |d| d.set(b"MK".to_vec(), Object::Dict(mk)))?; } - redraw_field(doc, name) + redraw_field_object(doc, name, f.obj) } fn empty_box(doc: &Document, w: &Widget) -> Stream { diff --git a/crates/forms/src/lib.rs b/crates/forms/src/lib.rs index c1c91f8872..414132b42b 100644 --- a/crates/forms/src/lib.rs +++ b/crates/forms/src/lib.rs @@ -24,15 +24,15 @@ mod calc_order; pub mod detect; mod scripting; pub mod table; -pub use actions::{FieldAction, Trigger, field_action_chains, field_actions, set_field_actions}; +pub use actions::{FieldAction, Trigger, field_action_chains, field_actions, set_field_actions, widget_scripts}; pub use author::{ BorderStyle, CheckStyle, FieldFont, FieldProps, Look, LookPatch, MIN_FIELD_SIDE, NewField, add_field, check_style, delete_field, - delete_field_object, delete_widget, duplicate_field, look, redraw_field, redraw_fields, set_button_icon, set_props, + delete_field_object, delete_widget, duplicate_field, look, redraw_field, redraw_fields, set_button_icon, set_button_icon_target, set_props, }; pub use calc_order::{CalculatedField, CalculationIssue, CalculationOrder, calculation_order, set_calculation_order}; pub use scripting::{ - FieldChange, FieldEvent, NoScripts, ScriptResult, Scripts, apply_script_changes, document_scripts, document_scripts_named, set_document_script, - set_field_script, + DocumentTrigger, FieldChange, FieldEvent, NoScripts, ScriptResult, Scripts, apply_script_changes, document_action_scripts, document_scripts, + document_scripts_named, set_document_script, set_field_script, }; mod appearances; @@ -53,8 +53,8 @@ use values::has_password_flag; use values::write_value; pub(crate) use values::{ask_viewers_to_redraw, merged_appearance, redraw_calculated_value}; 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, + calculations_changed, clear_password_values, default_value_known, has_password_values, password_values, recalculate, recalculate_with, reset, + reset_with_counts, reset_with_formatting, reset_with_scripts, restore_password_values, set_value, set_value_with, set_values, }; #[cfg(test)] @@ -138,6 +138,8 @@ pub struct Widget { pub rotation: i64, /// Push buttons: the normal label (`/MK /CA`), separate from the field's name. pub caption: Option, + /// Text widget JavaScript to run on a mouse-up click. + pub mouse_up_script: Option, /// List boxes: where the options are drawn, so a click can pick the one under the pointer. pub list: Option, /// Drawn round: a radio button, or a check box laid out for a round XFA check button diff --git a/crates/forms/src/read.rs b/crates/forms/src/read.rs index ddd71f0b05..4a7904f620 100644 --- a/crates/forms/src/read.rs +++ b/crates/forms/src/read.rs @@ -724,6 +724,15 @@ fn walk( value.as_string().map(|s| s.to_text()) }) .flatten(), + mouse_up_script: if kind == FieldKind::Text { + let aa = wd.get(b"AA").map(|a| doc.resolve(a)); + let action = aa.as_deref().and_then(Object::as_dict).and_then(|aa| aa.get(b"U")).or_else(|| wd.get(b"A")); + action + .filter(|a| doc.resolve(a).as_dict().and_then(|a| a.name(b"S")) == Some(b"JavaScript")) + .and_then(|a| script(doc, a, &mut seen.scripts)) + } else { + None + }, 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)), }) diff --git a/crates/forms/src/scripting.rs b/crates/forms/src/scripting.rs index 09fa1ea803..8ce3f1f68a 100644 --- a/crates/forms/src/scripting.rs +++ b/crates/forms/src/scripting.rs @@ -37,6 +37,8 @@ impl FieldEvent { #[derive(Clone, Debug, Default, PartialEq)] pub struct FieldChange { pub name: String, + /// Optional exact object target for malformed forms with duplicate field names. + pub object: Option, pub value: Option>, /// This value came from a Calculate script; redraw it through the field's Format script. pub formatted: bool, @@ -44,8 +46,22 @@ pub struct FieldChange { pub required: Option, /// `display.visible` 0, `hidden` 1, `noPrint` 2, `noView` 3. pub display: Option, - /// Move/resize the first widget, in PDF user space. + /// Normal push-button caption, applied to every widget of the field. + pub caption: Option, + pub text_color: Option>, + pub fill_color: Option>, pub rect: Option<[f64; 4]>, + pub stroke_color: Option>, + pub text_size: Option, + pub line_width: Option, + pub border_style: Option, + pub user_name: Option, + pub icon: Option>, + /// A combo box's or list box's new items (export value, text), from `setItems`. + pub options: Option>, + /// JavaScript actions the script installed with `setAction`, by trigger name (`MouseUp`, + /// `OnFocus`, `Keystroke`, …), each replacing what the field ran for that trigger. + pub actions: Vec<(String, String)>, } /// What a script did. @@ -64,8 +80,13 @@ pub struct ScriptResult { /// Runs field scripts (JavaScript). pub trait Scripts { + /// Whether the host stopped this edit's callbacks. + fn stopped(&self) -> bool { + false + } + /// Run `script` for `event` on `target`, whose value is `value`; `fields` is the whole form. - fn run(&mut self, event: FieldEvent, script: &str, target: &Field, value: &str, fields: &[Field]) -> ScriptResult; + fn run(&mut self, event: FieldEvent, script: &str, target: &Field, value: &str, fields: &[Field], doc: &Document) -> ScriptResult; /// [`Self::run`] again for an appearance, with dates written in English names (`util.printd`) /// because no font of the field can draw the date language's (#5303); what it logs was @@ -78,6 +99,7 @@ pub trait Scripts { _target: &Field, _value: &str, _fields: &[Field], + _doc: &Document, ) -> Option { None } @@ -87,72 +109,212 @@ pub trait Scripts { pub struct NoScripts; impl Scripts for NoScripts { - fn run(&mut self, _: FieldEvent, _: &str, _: &Field, value: &str, _: &[Field]) -> ScriptResult { + /// Provide the no-JavaScript fallback: accept the current field value without additional script mutations. + fn run(&mut self, _: FieldEvent, _: &str, _: &Field, value: &str, _: &[Field], _: &Document) -> ScriptResult { ScriptResult { rc: true, value: value.to_string(), value_set: false, changes: Vec::new(), message: None } } } /// Run the Keystroke (commit) and Validate scripts for a new text value. Returns the value to /// store, or why it is rejected. -pub(crate) fn accept(doc: &mut Document, f: &Field, value: &str, scripts: &mut dyn Scripts) -> Result { +pub(crate) fn accept(doc: &mut Document, f: &Field, value: &str, scripts: &mut dyn Scripts) -> Result<(String, bool), FormError> { let mut v = value.to_string(); + let mut refresh_field = false; for (event, script, default) in [ (FieldEvent::Keystroke, &f.actions.scripts.keystroke, "The value entered does not match the format of the field"), (FieldEvent::Validate, &f.actions.scripts.validate, "The value entered is not valid for the field"), ] { let Some(js) = script else { continue }; let all = fields(doc); - let r = scripts.run(event, js, f, &v, &all); - apply_changes(doc, &r.changes, &f.name, scripts)?; + let r = scripts.run(event, js, f, &v, &all, doc); + refresh_field |= r.changes.iter().any(|change| change.name == f.name && change.object.is_none_or(|id| id == f.obj)); + apply_changes(doc, &r.changes, &f.name, Some(f.obj), scripts)?; if !r.rc { return Err(FormError::Invalid(r.message.unwrap_or_else(|| format!("{default} [ {} ]", f.name)))); } v = r.value; } - Ok(v) + Ok((v, refresh_field)) } /// The text a custom Format script shows for `value`, if the field has one. pub(crate) fn formatted(doc: &Document, f: &Field, value: &str, scripts: &mut dyn Scripts) -> Option { let js = f.actions.scripts.format.as_ref()?; - let r = scripts.run(FieldEvent::Format, js, f, value, &fields(doc)); + let r = scripts.run(FieldEvent::Format, js, f, value, &fields(doc), doc); Some(r.value) } +/// Immediate and aliased MK references of one widget; cycles and depth are bounded. +fn widget_mk_refs(doc: &Document, widget: ObjRef) -> Vec { + let mut value = doc.get(widget).as_dict().and_then(|d| d.get(b"MK")).cloned(); + let mut seen = Vec::new(); + for _ in 0..64 { + let Some(Object::Ref(reference)) = value else { break }; + if seen.contains(&reference) { + break; + } + seen.push(reference); + value = Some(doc.get(reference).as_ref().clone()); + } + seen +} + +/// Keep target widgets' shared indirect MK in place, while outside widgets retain old colors. +fn protect_other_mk_owners(doc: &mut Document, field: &Field, owners: &std::collections::HashMap>) -> Result<(), FormError> { + let own: HashSet<_> = field.widgets.iter().map(|w| w.obj).collect(); + let refs: HashSet<_> = field.widgets.iter().filter_map(|w| doc.get(w.obj).as_dict().and_then(|d| d.reference(b"MK"))).collect(); + for reference in refs { + for widget in owners.get(&reference).into_iter().flatten().filter(|w| !own.contains(w)) { + if !widget_mk_refs(doc, *widget).contains(&reference) { + continue; + } + let mk = + doc.get(*widget).as_dict().and_then(|d| d.get(b"MK")).map(|m| doc.resolve(m)).and_then(|v| v.as_dict().cloned()).unwrap_or_default(); + doc.update_dict(*widget, |d| d.set(b"MK".to_vec(), Object::Dict(mk)))?; + } + } + Ok(()) +} + +/// Most items a script may give a choice field ([`FieldChange::options`]). +const MAX_SCRIPT_OPTIONS: usize = 10_000; + +/// Write a combo box's or list box's items (export value, text) as its `/Opt`: a plain text +/// where the two are the same, a pair where they differ (ISO 32000-2 Table 231). The selected +/// indices (`/I`) no longer apply and go; the value is written after, and the field redrawn. +fn write_options(doc: &mut Document, f: &Field, options: &[(String, String)]) -> Result<(), FormError> { + if !matches!(f.kind, FieldKind::Combo | FieldKind::List) { + return Err(FormError::Invalid(format!("{} is not a combo box or list box: it has no items to set", f.name))); + } + if options.len() > MAX_SCRIPT_OPTIONS { + return Err(FormError::Invalid(format!("a script may give a field at most {MAX_SCRIPT_OPTIONS} items"))); + } + let opt = options + .iter() + .map(|(export, text)| { + if export == text { + Object::String(PdfString::text(text)) + } else { + Object::Array(vec![Object::String(PdfString::text(export)), Object::String(PdfString::text(text))]) + } + }) + .collect(); + doc.update_dict(f.obj, |d| { + d.set(b"Opt".to_vec(), Object::Array(opt)); + d.remove(b"I"); + })?; + crate::author::redraw_field_object(doc, &f.name, f.obj) +} + +/// Install `script` as field `f`'s JavaScript for `trigger` (a `setAction` trigger name), +/// replacing the action it had there: the field's `/AA` for Keystroke, Validate, Calculate and +/// Format, each widget's `/A` (Mouse Up) or `/AA` for the rest. +fn set_action(doc: &mut Document, f: &Field, trigger: &str, script: &str) -> Result<(), FormError> { + let event = match trigger { + "Keystroke" => Some("keystroke"), + "Validate" => Some("validate"), + "Calculate" => Some("calculate"), + "Format" => Some("format"), + "MouseUp" => Some("mouse_up"), + _ => None, + }; + if let Some(event) = event { + // The field the script's object stands for, even when another shares its name. + return set_script_of(doc, f, event, Some(script)); + } + let key: &[u8] = match trigger { + "MouseDown" => b"D", + "MouseEnter" => b"E", + "MouseExit" => b"X", + "OnFocus" => b"Fo", + "OnBlur" => b"Bl", + other => return Err(FormError::Invalid(format!("unknown action trigger {other:?}"))), + }; + let mut action = pdfcraft_cos::Dict::new(); + action.set(b"S".to_vec(), Object::name("JavaScript")); + action.set(b"JS".to_vec(), PdfString::text(script)); + let action = doc.add(Object::Dict(action)); + for w in &f.widgets { + let mut aa = + doc.get(w.obj).as_dict().and_then(|d| d.get(b"AA").map(|a| doc.resolve(a))).and_then(|a| a.as_dict().cloned()).unwrap_or_default(); + aa.set(key.to_vec(), Object::Ref(action)); + doc.update_dict(w.obj, |d| d.set(b"AA".to_vec(), Object::Dict(aa)))?; + } + Ok(()) +} + /// [`formatted`] with dates in English names, for widgets whose fonts can't draw the date /// language's; `None` when the field has no Format script or the English run didn't happen. pub(crate) fn formatted_in_english(doc: &Document, f: &Field, value: &str, scripts: &mut dyn Scripts) -> Option { let js = f.actions.scripts.format.as_ref()?; - scripts.rerun_with_english_dates(FieldEvent::Format, js, f, value, &fields(doc)).map(|r| r.value) + scripts.rerun_with_english_dates(FieldEvent::Format, js, f, value, &fields(doc), doc).map(|r| r.value) } /// Apply script changes to other fields (values written without re-running their scripts; -/// read-only, required and visibility as flags). `except` is the event's own field, whose value -/// the event sets. -pub(crate) fn apply_changes(doc: &mut Document, changes: &[FieldChange], except: &str, scripts: &mut dyn Scripts) -> Result<(), FormError> { +/// read-only, required and visibility as flags; button captions). `except` is the event's own +/// field, whose value the event sets. +pub(crate) fn apply_changes( + doc: &mut Document, + changes: &[FieldChange], + except: &str, + except_object: Option, + scripts: &mut dyn Scripts, +) -> Result<(), FormError> { if changes.is_empty() { return Ok(()); } - let all = fields(doc); + // Validate every colour before applying any part of the script's changes. for c in changes { - let Some(f) = all.iter().find(|f| f.name == c.name) else { continue }; + for color in [&c.text_color, &c.fill_color, &c.stroke_color].into_iter().flatten() { + js_color(color)?; + } + } + let all = fields(doc); + let mut mk_owners: std::collections::HashMap> = std::collections::HashMap::new(); + if changes.iter().any(|c| c.fill_color.is_some() || c.stroke_color.is_some()) { + for field in &all { + for widget in &field.widgets { + for reference in widget_mk_refs(doc, widget.obj) { + mk_owners.entry(reference).or_default().push(widget.obj); + } + } + } + } + + for c in changes { + let Some(f) = all.iter().find(|f| f.name == c.name && c.object.is_none_or(|obj| f.obj == obj)) else { continue }; + // New items go first, so a value among them is written against them. + let reread; + let f = match &c.options { + Some(options) => { + write_options(doc, f, options)?; + reread = fields(doc).into_iter().find(|x| x.obj == f.obj).unwrap_or_else(|| f.clone()); + &reread + } + None => f, + }; + for (trigger, script) in &c.actions { + set_action(doc, f, trigger, script)?; + } if let Some(v) = &c.value - && c.name != except + && !except_object.map_or(c.name == except, |obj| f.obj == obj) && *v != f.value { let value = match f.kind { - FieldKind::Text => FieldValue::Text(v.first().cloned().unwrap_or_default()), - FieldKind::CheckBox => f.check_box_value(v.first().map(String::as_str)), - FieldKind::Radio => FieldValue::Radio(v.first().cloned()), - FieldKind::Combo | FieldKind::List => FieldValue::Choice(v.clone()), - _ => continue, + FieldKind::Text => Some(FieldValue::Text(v.first().cloned().unwrap_or_default())), + FieldKind::CheckBox => Some(f.check_box_value(v.first().map(String::as_str))), + FieldKind::Radio => Some(FieldValue::Radio(v.first().cloned())), + FieldKind::Combo | FieldKind::List => Some(FieldValue::Choice(v.clone())), + _ => None, }; - let mut tmp = f.clone(); - tmp.flags &= !flags::NO_TOGGLE_TO_OFF; - tmp.actions = Default::default(); - crate::write_value(doc, &tmp, &value, &mut NoScripts)?; - if c.formatted { - crate::redraw_calculated_value(doc, f, v, scripts)?; + if let Some(value) = value { + let mut tmp = f.clone(); + tmp.flags &= !flags::NO_TOGGLE_TO_OFF; + tmp.actions = Default::default(); + crate::write_value(doc, &tmp, &value, &mut NoScripts)?; + if c.formatted { + crate::redraw_calculated_value(doc, f, v, scripts)?; + } } } let mut ff = f.flags; @@ -182,18 +344,129 @@ pub(crate) fn apply_changes(doc: &mut Document, changes: &[FieldChange], except: } } } + if c.fill_color.is_some() || c.stroke_color.is_some() { + protect_other_mk_owners(doc, f, &mk_owners)?; + } + let appearance = crate::LookPatch { + text: c.text_color.as_deref().map(js_color).transpose()?.flatten(), + fill: c.fill_color.as_deref().map(js_color).transpose()?, + border: c.stroke_color.as_deref().map(js_color).transpose()?, + width: c.line_width, + style: c + .border_style + .as_deref() + .map(|style| match style { + "solid" => Ok(crate::BorderStyle::Solid), + "dashed" => Ok(crate::BorderStyle::Dashed), + "beveled" => Ok(crate::BorderStyle::Beveled), + "inset" => Ok(crate::BorderStyle::Inset), + "underline" => Ok(crate::BorderStyle::Underline), + _ => Err(FormError::Invalid("unsupported borderStyle".into())), + }) + .transpose()?, + ..Default::default() + }; + if appearance != crate::LookPatch::default() || c.text_size.is_some() || c.user_name.is_some() { + crate::author::set_props_object( + doc, + &c.name, + f.obj, + &crate::FieldProps { + appearance: (appearance != crate::LookPatch::default()).then_some(appearance), + font_size: c.text_size, + tooltip: c.user_name.clone(), + ..Default::default() + }, + )?; + } if let Some(rect) = c.rect { - crate::set_props(doc, &c.name, &crate::FieldProps { rect: Some((0, rect)), ..Default::default() })?; + if !rect.iter().all(|v| v.is_finite() && v.abs() <= 1e7) || rect[2] < rect[0] || rect[3] < rect[1] { + return Err(FormError::Invalid("invalid script rectangle".into())); + } + let widget = f.widgets.first().ok_or_else(|| FormError::Invalid("field has no widget rectangle".into()))?; + doc.update_dict(widget.obj, |d| d.set(b"Rect".to_vec(), Object::Array(rect.into_iter().map(Object::Real).collect())))?; + crate::author::redraw_field_object(doc, &c.name, f.obj)?; + } + if let Some(icon) = c.icon { + if f.kind != FieldKind::PushButton { + return Err(FormError::Invalid("icons require a push button".into())); + } + if icon.is_some_and(|id| id >> 48 != 0) { + return Err(FormError::Invalid("invalid button icon handle".into())); + } + let reference = icon.map(|id| pdfcraft_cos::ObjRef::new((id >> 16) as u32, id as u16)); + if let Some(reference) = reference { + let obj = doc.get(reference); + let valid = match &*obj { + Object::Stream(stream) => stream.dict.name(b"Subtype") == Some(b"Form".as_slice()), + _ => false, + }; + if !valid { + return Err(FormError::Invalid("button icon is not a form in this document".into())); + } + } + for widget in &f.widgets { + let mut mk = doc + .get(widget.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(); + match reference { + Some(r) => mk.set(b"I".to_vec(), Object::Ref(r)), + None => { + mk.remove(b"I"); + } + } + doc.update_dict(widget.obj, |d| d.set(b"MK".to_vec(), Object::Dict(mk)))?; + } + crate::author::redraw_field_object(doc, &c.name, f.obj)?; + } + if let Some(caption) = &c.caption { + if f.kind != FieldKind::PushButton { + return Err(FormError::Invalid(format!("{} is not a push button", f.name))); + } + crate::author::set_props_object(doc, &f.name, f.obj, &crate::FieldProps { caption_all: Some(caption.clone()), ..Default::default() })?; } } Ok(()) } +/// An Acrobat colour array as the RGB the look model stores, `None` for transparent (`["T"]`). +/// Gray and CMYK convert as Acrobat's `color.convert` does. Anything else (an unknown colour +/// space, the wrong number of components, or a component that is not a finite number between +/// 0 and 1) is refused, and with it the script's changes. +fn js_color(values: &[String]) -> Result, FormError> { + let invalid = || FormError::Invalid("JavaScript field colours must be T, G, RGB or CMYK arrays with finite components between 0 and 1".into()); + let (space, parts) = values.split_first().ok_or_else(invalid)?; + let mut numbers = [0.0; 4]; + if parts.len() > numbers.len() { + return Err(invalid()); + } + for (slot, part) in numbers.iter_mut().zip(parts) { + *slot = part.trim().parse::().ok().filter(|v| v.is_finite() && (0.0..=1.0).contains(v)).ok_or_else(invalid)?; + } + match (space.as_str(), parts.len()) { + ("T", 0) => Ok(None), + ("G", 1) => Ok(Some([numbers[0]; 3])), + ("RGB", 3) => Ok(Some([numbers[0], numbers[1], numbers[2]])), + ("CMYK", 4) => { + let [c, m, y, k] = numbers; + Ok(Some([1.0 - (c + k).min(1.0), 1.0 - (m + k).min(1.0), 1.0 - (y + k).min(1.0)])) + } + _ => Err(invalid()), + } +} + /// Apply what a button or console script changed (values, read-only, required, visibility), /// then recalculate. pub fn apply_script_changes(doc: &mut Document, changes: &[FieldChange], scripts: &mut dyn Scripts) -> Result<(), FormError> { - apply_changes(doc, changes, "", scripts)?; - crate::recalculate_with(doc, scripts)?; + apply_changes(doc, changes, "", None, scripts)?; + // Acrobat's automatic calculation is driven by value changes. A keypad caption, + // position or visibility update must not execute every calculated field again. + if changes.iter().any(|change| change.value.is_some()) { + crate::recalculate_with(doc, scripts)?; + } Ok(()) } @@ -266,6 +539,79 @@ pub fn document_scripts_named(doc: &Document) -> Vec<(String, String)> { out } +/// A document-level action trigger whose JavaScript the viewer runs (ISO 32000-2 §12.6.3, +/// Table 199): the catalog's `/OpenAction`, or its `/AA` entries. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum DocumentTrigger { + /// The catalog `/OpenAction`, once the document has opened. + Open, + /// `/AA /WS`, before the document is saved. + WillSave, + /// `/AA /WP`, before the document is printed. + WillPrint, +} + +/// Most actions read for one document trigger, along its `/Next` chain. A chain can be cyclic +/// or arbitrarily long; the rest are ignored. +const MAX_CHAINED_ACTIONS: usize = 256; + +/// The JavaScript of the catalog actions for `trigger`, in the order a viewer runs them: the +/// action, then its `/Next` actions depth first (§12.6.2). Actions of other types (a `/GoTo` +/// open action, say) are skipped; their `/Next` chains are still followed. Bounded: each object +/// is read once and at most [`MAX_CHAINED_ACTIONS`] actions are visited. +pub fn document_action_scripts(doc: &Document, trigger: DocumentTrigger) -> Vec { + let Some(root) = doc.root() else { return Vec::new() }; + let cat = doc.get(root); + let Some(cat) = cat.as_dict() else { return Vec::new() }; + let first = match trigger { + DocumentTrigger::Open => cat.get(b"OpenAction").cloned(), + DocumentTrigger::WillSave | DocumentTrigger::WillPrint => { + let key: &[u8] = if trigger == DocumentTrigger::WillSave { b"WS" } else { b"WP" }; + cat.get(b"AA").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().and_then(|d| d.get(key)).cloned()) + } + }; + chained_scripts(doc, first) +} + +/// The JavaScript of action `first` and of the actions chained after it, in the order they run: +/// the action, then its `/Next` actions depth first (ISO 32000-2 §12.6.2). Actions of other +/// types are skipped; their `/Next` chains are still followed. Bounded: each object is read +/// once and at most [`MAX_CHAINED_ACTIONS`] actions are visited. +pub(crate) fn chained_scripts(doc: &Document, first: Option) -> Vec { + let mut budget = crate::script_budget(); + let mut seen: HashSet = HashSet::new(); + let mut pending: Vec = first.into_iter().collect(); + let mut visited = 0usize; + let mut out = Vec::new(); + while let Some(raw) = pending.pop() { + visited += 1; + if visited > MAX_CHAINED_ACTIONS { + break; + } + if let Some(r) = raw.as_ref() + && !seen.insert(r.num) + { + continue; + } + let action = doc.resolve(&raw); + if let Some(list) = action.as_array() { + // Pushed in reverse so they run in the order listed. + pending.extend(list.iter().take(MAX_CHAINED_ACTIONS).rev().cloned()); + continue; + } + let Some(d) = action.as_dict() else { continue }; + if d.name(b"S") == Some(b"JavaScript".as_slice()) + && let Some(js) = crate::script(doc, &raw, &mut budget) + { + out.push(js); + } + if let Some(next) = d.get(b"Next") { + pending.push(next.clone()); + } + } + out +} + /// Call `f` on the array `o` is, or refers to, read in place: resolving a direct array would /// copy it. fn in_array(doc: &Document, o: &Object, f: impl FnOnce(&[Object])) { @@ -323,7 +669,12 @@ pub fn set_document_script(doc: &mut Document, name: &str, js: Option<&str>) -> /// (its widgets' `/A`). Calculated fields join the calculation order (`/CO`). pub fn set_field_script(doc: &mut Document, name: &str, event: &str, js: Option<&str>) -> Result<(), FormError> { let all = fields(doc); - let f = all.iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?.clone(); + let f = all.iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?; + set_script_of(doc, f, event, js) +} + +/// [`set_field_script`] for the field `f` itself, which may share its name with another. +fn set_script_of(doc: &mut Document, f: &Field, event: &str, js: Option<&str>) -> Result<(), FormError> { let action = |doc: &mut Document, js: &str| { let mut a = pdfcraft_cos::Dict::new(); a.set(b"S".to_vec(), Object::name("JavaScript")); diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index 55cb97c887..a2a87f3a73 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -137,6 +137,71 @@ fn a_button_caption_its_fonts_encoding_cant_hold_is_reported() { assert_eq!(undrawn_fields(&doc), [("go".to_string(), '\u{2000B}')]); } +/// A field-wide script caption must reach every widget, including shared dictionaries. +#[test] +fn script_caption_updates_every_widget_and_survives_save() { + let mut doc = one_page(); + let a = add_field(&mut doc, 0, [40.0, 600.0, 200.0, 630.0], &NewField::Button { caption: "Original".into() }, Some("first")).unwrap(); + let b = add_field(&mut doc, 0, [40.0, 500.0, 200.0, 530.0], &NewField::Button { caption: "Original".into() }, Some("second")).unwrap(); + let all = fields(&doc); + let (first, second) = (field(&all, &a).obj, field(&all, &b).obj); + let mut parent = Dict::new(); + parent.set(b"FT".to_vec(), Object::name("Btn")); + parent.set(b"Ff".to_vec(), Object::Int(flags::PUSH_BUTTON as i64)); + parent.set(b"T".to_vec(), PdfString::text("clear_button")); + parent.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(first), Object::Ref(second)])); + let mut action = Dict::new(); + action.set(b"S".to_vec(), Object::name("ResetForm")); + parent.set(b"A".to_vec(), Object::Dict(action.clone())); + let parent = doc.add(parent); + let mut mk = Dict::new(); + let ca = Object::String(PdfString::text("Original")); + let ca = Object::Ref(doc.add(ca)); + mk.set(b"CA".to_vec(), ca); + mk.set(b"TP".to_vec(), Object::Int(0)); + mk.set(b"R".to_vec(), Object::Int(90)); + mk.set(b"VendorNote".to_vec(), PdfString::text("kept")); + let mk = doc.add(mk); + let mut shared_ap = doc.get(first).as_dict().unwrap().get(b"AP").unwrap().as_dict().unwrap().clone(); + let normal = shared_ap.get(b"N").unwrap().clone(); + shared_ap.set(b"R".to_vec(), normal.clone()); + shared_ap.set(b"D".to_vec(), normal); + shared_ap.set(b"VendorAP".to_vec(), PdfString::text("kept")); + let shared_ap = doc.add(shared_ap); + for widget in [first, second] { + doc.update_dict(widget, |d| { + for key in [b"FT".as_slice(), b"Ff", b"T"] { + d.remove(key); + } + d.set(b"Parent".to_vec(), Object::Ref(parent)); + d.set(b"MK".to_vec(), Object::Ref(mk)); + d.set(b"AP".to_vec(), Object::Ref(shared_ap)); + }) + .unwrap(); + } + let af = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"AcroForm").unwrap(); + doc.update_dict(af, |d| d.set(b"Fields".to_vec(), Object::Array(vec![Object::Ref(parent)]))).unwrap(); + + for caption in ["Original", "", "Continue"] { + apply_script_changes( + &mut doc, + &[FieldChange { name: "clear_button".into(), caption: Some(caption.into()), ..Default::default() }], + &mut NoScripts, + ) + .unwrap(); + let bytes = pdfcraft_cos::write_full(&doc, &SaveOptions::default()).unwrap(); + doc = Document::open(Arc::new(bytes)).unwrap(); + let all = fields(&doc); + let button = field(&all, "clear_button"); + assert_eq!(button.widgets.len(), 2); + for widget in &button.widgets { + assert_eq!(widget.caption.as_deref(), Some(caption)); + let ap = doc.resolve(doc.get(widget.obj).as_dict().unwrap().get(b"AP").unwrap()); + assert!(ap.as_dict().unwrap().get(b"N").is_some()); + } + } +} + /// Changing one widget's caption on a push button with two widgets leaves the other widget, the /// shared `/MK` and the parent's action untouched, keeps the other `/MK` and `/AP` entries, and /// drops the stale rollover and down appearances, with a direct or indirect caption and either save. @@ -221,6 +286,24 @@ fn button_caption_preserves_other_widgets_and_shared_dictionaries() { assert!(!apd.contains(b"R") && !apd.contains(b"D"), "stale hover/pressed text must not survive"); assert!(ap(&doc, &f.widgets[1]).contains("(Clear Form) Tj")); } + let mut doc = original.clone(); + set_props(&mut doc, "clear_button", &FieldProps { caption_all: Some("Continue".into()), ..Default::default() }).unwrap(); + let doc = Document::open(Arc::new(pdfcraft_cos::write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + let f = field(&fields(&doc), "clear_button").clone(); + assert_eq!(f.widgets.iter().map(|w| w.caption.as_deref()).collect::>(), [Some("Continue"), Some("Continue")]); + assert!(f.widgets.iter().all(|w| ap(&doc, w).contains("(Continue) Tj"))); + + let mut doc = original.clone(); + apply_script_changes( + &mut doc, + &[FieldChange { name: "clear_button".into(), caption: Some("Proceed".into()), ..Default::default() }], + &mut NoScripts, + ) + .unwrap(); + let doc = Document::open(Arc::new(pdfcraft_cos::write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + let f = field(&fields(&doc), "clear_button").clone(); + assert_eq!(f.widgets.iter().map(|w| w.caption.as_deref()).collect::>(), [Some("Proceed"), Some("Proceed")]); + assert!(f.widgets.iter().all(|w| ap(&doc, w).contains("(Proceed) Tj"))); } } @@ -242,6 +325,26 @@ fn button_caption_rejects_wrong_type_and_index_before_mutation() { assert!(!ap(&doc, &field(&fields(&doc), "go").widgets[0]).contains(" Tj")); } +#[test] +fn field_wide_button_caption_rejects_a_field_without_widgets() { + let objs = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [] >>".into(), + "<< /Fields [5 0 R] >>".into(), + "<< /FT /Btn /T (empty) /Ff 65536 /Kids [] >>".into(), + ]; + let mut doc = document(&objs); + let all_fields = fields(&doc); + let button = field(&all_fields, "empty"); + assert_eq!(button.kind, FieldKind::PushButton); + assert!(button.widgets.is_empty()); + let before = pdfcraft_cos::write_full(&doc, &SaveOptions::default()).unwrap(); + let result = set_props(&mut doc, "empty", &FieldProps { caption_all: Some("Caption".into()), ..Default::default() }); + assert!(result.is_err()); + assert_eq!(pdfcraft_cos::write_full(&doc, &SaveOptions::default()).unwrap(), before); +} + /// Read the drawn strings together with the appearance's actual font after saving and reopening. fn appearance_text(doc: &Document, w: &Widget, font_name: &[u8]) -> (Object, Vec>) { let widget = doc.get(w.obj); @@ -1368,6 +1471,52 @@ fn reset_restores_defaults() { assert!(matches!(reset(&mut doc, Some(&["nope".to_string()])), Err(FormError::NoSuchField(_)))); } +/// Check saved values and appearances when the host stops callbacks during calculation or formatting. +#[test] +fn reset_redraws_remaining_widgets_after_callbacks_stop() { + struct StopOn { + event: FieldEvent, + stopped: bool, + calls: Vec, + } + impl Scripts for StopOn { + fn stopped(&self) -> bool { + self.stopped + } + + fn run(&mut self, event: FieldEvent, _: &str, _: &Field, value: &str, _: &[Field], _: &Document) -> ScriptResult { + assert!(!self.stopped, "callbacks must stop after refusal"); + self.calls.push(event); + self.stopped = event == self.event; + ScriptResult { rc: true, value: value.into(), value_set: false, changes: Vec::new(), message: None } + } + } + + for stop_event in [FieldEvent::Calculate, FieldEvent::Format] { + let mut doc = one_page(); + for (name, y) in [("a", 700.0), ("b", 650.0), ("total", 600.0)] { + add_field(&mut doc, 0, [40.0, y, 200.0, y + 30.0], &NewField::Text { multiline: false }, Some(name)).unwrap(); + } + for name in ["a", "b"] { + set_props(&mut doc, name, &FieldProps { default_value: Some(Some("Default".into())), ..Default::default() }).unwrap(); + set_value(&mut doc, name, &FieldValue::Text("Before".into())).unwrap(); + set_field_script(&mut doc, name, "format", Some("event.value = event.value;")).unwrap(); + } + set_field_script(&mut doc, "total", "calculate", Some("event.value = 1;")).unwrap(); + let mut host = StopOn { event: stop_event, stopped: false, calls: Vec::new() }; + assert_eq!(reset_with_formatting(&mut doc, Some(&["a".into(), "b".into()]), &mut host, true).unwrap(), (2, 0)); + assert_eq!(host.calls.last(), Some(&stop_event)); + let saved = reopen(&doc); + let all = fields(&saved); + for name in ["a", "b"] { + let f = field(&all, name); + assert_eq!(f.value, ["Default"]); + let drawn = ap(&saved, &f.widgets[0]); + assert!(drawn.contains("(Default) Tj") && !drawn.contains("(Before) Tj"), "{name}: {drawn}"); + } + } +} + #[test] fn documents_without_forms_say_so() { let mut doc = Document::new_empty(); @@ -1515,6 +1664,38 @@ fn properties_rename_and_delete() { assert_eq!(annots.as_array().unwrap().len(), 13 - 3, "the deleted widgets left the page"); } +/// A deleted field's ancestor left with no kids stays in `/Fields` as a field of its own, with the +/// `/V` the deleted field inherited from it: it leaves too. An ancestor a sibling still has stays. +#[test] +fn deleting_the_last_kid_removes_the_emptied_ancestors_and_their_values() { + let form = |kids: &str, annots: &str| { + document(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + format!("<< /Type /Page /Parent 2 0 R /Annots [{annots}] >>"), + "<< /Fields [5 0 R] >>".into(), + "<< /FT /Tx /T (outer) /V (SECRET) /DV (SECRET) /Kids [6 0 R] >>".into(), + format!("<< /T (inner) /Parent 5 0 R /Kids [{kids}] >>"), + "<< /Type /Annot /Subtype /Widget /T (a) /Parent 6 0 R /Rect [50 700 250 720] /P 3 0 R >>".into(), + "<< /Type /Annot /Subtype /Widget /T (b) /Parent 6 0 R /Rect [50 650 250 670] /P 3 0 R >>".into(), + ]) + }; + let top = |doc: &Document| { + let af = doc.get(doc.root().unwrap()).as_dict().unwrap().get(b"AcroForm").map(|o| doc.resolve(o)).unwrap().as_dict().unwrap().clone(); + doc.resolve(af.get(b"Fields").unwrap()).as_array().unwrap().len() + }; + let mut doc = form("7 0 R", "7 0 R"); + delete_field(&mut doc, "outer.inner.a").unwrap(); + assert_eq!(top(&doc), 0, "the emptied ancestors left /Fields"); + let saved = pdfcraft_cos::write_full(&doc, &SaveOptions::default()).unwrap(); + assert!(!saved.windows(6).any(|w| w == b"SECRET"), "the inherited value is gone from a full save"); + + let mut doc = form("7 0 R 8 0 R", "7 0 R 8 0 R"); + delete_field(&mut doc, "outer.inner.a").unwrap(); + assert_eq!(top(&doc), 1, "an ancestor with a surviving kid stays"); + assert!(fields(&doc).iter().any(|f| f.name == "outer.inner.b")); +} + /// A parent `MASTER` with kids `a` and `b` (each a field merged with its widget) and `e` (a field /// with two widget kids), and the top-level fields `c` and `d`. fn dotted_form() -> Document { @@ -1708,7 +1889,8 @@ fn formats_validation_and_calculations_run_like_acrobat() { fn a_script_that_spells_the_markers_in_a_string_runs_as_a_script() { struct Ran; impl Scripts for Ran { - fn run(&mut self, _event: FieldEvent, _script: &str, _target: &Field, _value: &str, _fields: &[Field]) -> ScriptResult { + /// Synthetic script hook that returns controlled values/side effects so the calculation-chain behavior is observable. + fn run(&mut self, _event: FieldEvent, _script: &str, _target: &Field, _value: &str, _fields: &[Field], _doc: &Document) -> ScriptResult { ScriptResult { rc: true, value: "ran".into(), value_set: true, changes: Vec::new(), message: None } } } @@ -1798,7 +1980,7 @@ fn unsupported_first_validation_call_preserves_custom_script_dispatch() { calls: usize, } impl Scripts for RejectingInterpreter { - fn run(&mut self, event: FieldEvent, script: &str, target: &Field, value: &str, _fields: &[Field]) -> ScriptResult { + fn run(&mut self, event: FieldEvent, script: &str, target: &Field, value: &str, _fields: &[Field], _doc: &Document) -> ScriptResult { assert_eq!(event, FieldEvent::Validate); assert_eq!(script, JS); assert_eq!(target.name, "amount"); @@ -3603,10 +3785,12 @@ fn check_box_redraw_keeps_down_states_it_still_draws() { } #[test] +/// A calculation side effect on a checkbox is visible to following calculated fields. fn recalc_chain_where_script_changes_checkbox() { struct TestScripts; impl Scripts for TestScripts { - fn run(&mut self, _event: FieldEvent, script: &str, _target: &Field, _value: &str, fields: &[Field]) -> ScriptResult { + /// Synthetic script hook that returns controlled values/side effects so the calculation-chain behavior is observable. + fn run(&mut self, _event: FieldEvent, script: &str, _target: &Field, _value: &str, fields: &[Field], _doc: &Document) -> ScriptResult { if script == "set_box" { ScriptResult { rc: true, @@ -3619,7 +3803,10 @@ fn recalc_chain_where_script_changes_checkbox() { read_only: None, required: None, display: None, - rect: None, + caption: None, + text_color: None, + fill_color: None, + ..Default::default() }], message: None, } @@ -3654,7 +3841,7 @@ fn recalc_chain_where_script_changes_checkbox() { fn a_calculated_combo_stores_the_export_value_of_the_option_it_names() { struct Pick; impl Scripts for Pick { - fn run(&mut self, _event: FieldEvent, script: &str, _target: &Field, _value: &str, _fields: &[Field]) -> ScriptResult { + fn run(&mut self, _event: FieldEvent, script: &str, _target: &Field, _value: &str, _fields: &[Field], _doc: &Document) -> ScriptResult { ScriptResult { rc: true, value: script.into(), value_set: true, changes: Vec::new(), message: None } } } @@ -3679,7 +3866,7 @@ fn a_calculated_combo_stores_the_export_value_of_the_option_it_names() { fn an_export_value_wins_over_an_earlier_options_display_text() { struct Pick; impl Scripts for Pick { - fn run(&mut self, _event: FieldEvent, script: &str, _target: &Field, _value: &str, _fields: &[Field]) -> ScriptResult { + fn run(&mut self, _event: FieldEvent, script: &str, _target: &Field, _value: &str, _fields: &[Field], _doc: &Document) -> ScriptResult { ScriptResult { rc: true, value: script.into(), value_set: true, changes: Vec::new(), message: None } } } @@ -3703,6 +3890,65 @@ fn an_export_value_wins_over_an_earlier_options_display_text() { } } +#[test] +fn validation_script_colour_is_used_when_the_field_redraws() { + struct TestScripts; + impl Scripts for TestScripts { + fn run(&mut self, _event: FieldEvent, _script: &str, _target: &Field, value: &str, _fields: &[Field], _doc: &Document) -> ScriptResult { + ScriptResult { + rc: true, + value: value.into(), + value_set: false, + changes: vec![FieldChange { + name: "name".into(), + text_color: Some(vec!["RGB".into(), "1".into(), "0".into(), "0".into()]), + ..Default::default() + }], + message: None, + } + } + } + + let mut doc = fixture(); + set_field_script(&mut doc, "name", "validate", Some("set_red")).unwrap(); + set_value_with(&mut doc, "name", &FieldValue::Text("Grace".into()), &mut TestScripts).unwrap(); + + let all = fields(&doc); + let f = field(&all, "name"); + assert!(f.da.contains("1 0 0 rg")); + assert!(ap(&doc, &f.widgets[0]).contains("1 0 0 rg"), "redraw uses the Validate script's updated appearance"); +} + +#[test] +fn calculation_script_colour_is_used_when_the_field_redraws() { + struct TestScripts; + impl Scripts for TestScripts { + fn run(&mut self, _event: FieldEvent, _script: &str, _target: &Field, _value: &str, _fields: &[Field], _doc: &Document) -> ScriptResult { + ScriptResult { + rc: true, + value: "Grace".into(), + value_set: true, + changes: vec![FieldChange { + name: "name".into(), + text_color: Some(vec!["RGB".into(), "1".into(), "0".into(), "0".into()]), + ..Default::default() + }], + message: None, + } + } + } + + let mut doc = fixture(); + set_field_script(&mut doc, "name", "calculate", Some("set_red")).unwrap(); + recalculate_with(&mut doc, &mut TestScripts).unwrap(); + + let all = fields(&doc); + let f = field(&all, "name"); + assert_eq!(f.value, ["Grace"]); + assert!(f.da.contains("1 0 0 rg")); + assert!(ap(&doc, &f.widgets[0]).contains("1 0 0 rg"), "redraw uses the Calculate script's updated appearance"); +} + #[test] fn partial_appearance_keeps_custom_default_appearance_and_font_resources() { let mut doc = fixture(); @@ -6556,6 +6802,77 @@ fn locked_button_export_edits_leave_the_document_unchanged() { } } +/// A radio button that is read-only on its own (its own `/Ff`) can't be picked, and a selected one +/// can't be switched away from, though the group as a whole is writable. +#[test] +fn a_read_only_radio_button_is_neither_picked_nor_left() { + let lock = |doc: &mut Document, num: u32| doc.update_dict(pdfcraft_cos::ObjRef::new(num, 0), |d| d.set(b"Ff".to_vec(), Object::Int(1))).unwrap(); + let mut doc = radio_group_form(); + lock(&mut doc, 7); // Medium + assert!(matches!(set_value(&mut doc, "size", &FieldValue::Radio(Some("M".into()))), Err(FormError::ReadOnly(_)))); + set_value(&mut doc, "size", &FieldValue::Radio(Some("L".into()))).expect("an unlocked button is picked"); + // Small (selected) locked: the group stays on it. + let mut doc = radio_group_form(); + lock(&mut doc, 6); + assert!(matches!(set_value(&mut doc, "size", &FieldValue::Radio(Some("L".into()))), Err(FormError::ReadOnly(_)))); + assert_eq!(field(&fields(&doc), "size").value, ["S"]); +} + +/// The group's `/V` names its selection even when the button's `/AS` says nothing: a read-only +/// button that `/V` selects is not switched away from either. +#[test] +fn a_read_only_radio_button_selected_by_the_group_value_is_not_left() { + let mut doc = radio_group_form(); + doc.update_dict(pdfcraft_cos::ObjRef::new(6, 0), |d| { + d.set(b"Ff".to_vec(), Object::Int(1)); + d.set(b"AS".to_vec(), Object::name("Off")); + }) + .unwrap(); + assert!(matches!(set_value(&mut doc, "size", &FieldValue::Radio(Some("L".into()))), Err(FormError::ReadOnly(_)))); + assert_eq!(field(&fields(&doc), "size").value, ["S"]); +} + +/// Clearing the form skips a group held by a read-only button and still clears the rest. +#[test] +fn clearing_the_form_goes_on_past_a_group_held_by_a_read_only_button() { + let mut doc = radio_group_form(); + doc.update_dict(pdfcraft_cos::ObjRef::new(6, 0), |d| d.set(b"Ff".to_vec(), Object::Int(1))).unwrap(); + doc.update_dict(pdfcraft_cos::ObjRef::new(9, 0), |d| d.set(b"DV".to_vec(), pdfcraft_cos::PdfString::text("Bob"))).unwrap(); + assert_eq!(reset(&mut doc, None).unwrap(), 1); + let all = fields(&doc); + assert_eq!(field(&all, "size").value, ["S"]); + assert_eq!(field(&all, "name").value, ["Bob"]); +} + +#[test] +/// Cosmetic field updates skip calculations, while a value write still triggers the chain. +fn script_cosmetic_mutations_do_not_run_the_calculation_chain() { + struct Counter(usize); + impl Scripts for Counter { + /// Synthetic script hook that returns controlled values/side effects so the calculation-chain behavior is observable. + fn run(&mut self, event: FieldEvent, _: &str, _: &Field, value: &str, _: &[Field], _: &Document) -> ScriptResult { + if event == FieldEvent::Calculate { + self.0 += 1; + } + ScriptResult { rc: true, value: value.into(), value_set: false, changes: Vec::new(), message: None } + } + } + let mut doc = one_page(); + add_field(&mut doc, 0, [40.0, 700.0, 200.0, 730.0], &NewField::Text { multiline: false }, Some("input")).unwrap(); + add_field(&mut doc, 0, [40.0, 650.0, 200.0, 680.0], &NewField::Text { multiline: false }, Some("total")).unwrap(); + set_field_script(&mut doc, "total", "calculate", Some("event.value = 1;")).unwrap(); + let mut count = Counter(0); + apply_script_changes( + &mut doc, + &[FieldChange { name: "input".into(), fill_color: Some(vec!["RGB".into(), "1".into(), "0".into(), "0".into()]), ..Default::default() }], + &mut count, + ) + .unwrap(); + assert_eq!(count.0, 0, "a visual change must not evaluate the whole form"); + apply_script_changes(&mut doc, &[FieldChange { name: "input".into(), value: Some(vec!["2".into()]), ..Default::default() }], &mut count).unwrap(); + assert!(count.0 > 0, "value changes still calculate dependent fields"); +} + /// Three radio groups whose buttons have the on states given (`S`, `M` … or a repeated `Yes`): /// `free` (Radio), `plain` (Radio | NoToggleToOff) and `unison` (… | RadiosInUnison). fn radio_groups() -> Document { @@ -6662,6 +6979,26 @@ fn radio_buttons_without_as_sharing_the_value_show_one_on() { assert_eq!(on_widgets(&doc, "r").0, [false, true, false]); } +/// Two buttons share the on state `Yes`; the first is on by its `/AS` and the second is read-only +/// and off. `/V` is `Yes` for both, but only the first is selected: picking another option switches +/// the first off, which is allowed, and the read-only one never changes. +#[test] +fn a_read_only_button_sharing_the_on_state_of_the_selected_one_does_not_block() { + let mut doc = radio_without_as(49152, "/Ff 1 /AS /Off"); + doc.update_dict(pdfcraft_cos::ObjRef::new(6, 0), |d| d.set(b"AS".to_vec(), Object::name("Yes"))).unwrap(); + set_value(&mut doc, "r", &FieldValue::Radio(Some("No".into()))).expect("the writable selected button may be switched off"); + assert_eq!(on_widgets(&doc, "r").0, [false, false, true]); +} + +/// Selecting another button through `FieldValue::Widget` can't switch a read-only selected one off. +#[test] +fn a_read_only_selected_radio_button_is_not_left_by_picking_a_widget() { + let mut doc = radio_group_form(); + doc.update_dict(pdfcraft_cos::ObjRef::new(6, 0), |d| d.set(b"Ff".to_vec(), Object::Int(1))).unwrap(); + assert!(matches!(set_value(&mut doc, "size", &FieldValue::Widget(2)), Err(FormError::ReadOnly(_)))); + assert_eq!(field(&fields(&doc), "size").value, ["S"]); +} + /// Table 227, ReadOnly: a processor "shall not allow a user to change the value". A button whose own /// dictionary sets it can't be turned on by number. #[test] @@ -7056,3 +7393,21 @@ fn a_four_entry_mk_colour_with_a_null_is_cmyk_with_zero() { let rgb = off(&mut doc, "rgb"); assert!(rgb.contains("0 0 1 rg") && rgb.contains("1 0 0 RG"), "three numbers stay RGB: {rgb}"); } + +/// A Simple calculation counts the first value of a multiple-selection list, as `main` always +/// has, so the native total and `AFSimple_Calculate` in a script agree. +#[test] +fn simple_calculation_counts_the_first_value_of_a_multiple_selection_list() { + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [5 0 R 6 0 R] >>".into(), + "<< /Fields [5 0 R 6 0 R] /CO [6 0 R] /DA (/Helv 0 Tf 0 g) >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Ch /Ff 2097152 /T (picks) /Opt [(4) (6)] /V [(4) (6)] /Rect [50 700 250 760] /P 3 0 R >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (total) /Rect [50 640 250 660] /P 3 0 R /AA << /C << /S /JavaScript /JS (AFSimple_Calculate\\(\"SUM\", new Array\\(\"picks\"\\)\\);) >> >> >>".into(), + ]; + let mut doc = document(&objs); + assert_eq!(field(&fields(&doc), "picks").value, ["4", "6"]); + recalculate(&mut doc).unwrap(); + assert_eq!(field(&fields(&doc), "total").value, ["4"]); +} diff --git a/crates/forms/src/values.rs b/crates/forms/src/values.rs index b1d50a87ae..b60d8aa140 100644 --- a/crates/forms/src/values.rs +++ b/crates/forms/src/values.rs @@ -203,6 +203,15 @@ pub fn recalculate(doc: &mut Document) -> Result { /// [`recalculate`], running custom Calculate (and Format) scripts through `scripts`. pub fn recalculate_with(doc: &mut Document, scripts: &mut dyn Scripts) -> Result { + recalculate_values(doc, scripts, None) +} + +/// Evaluate fields in calculation order. When a redraw set is supplied, record changed fields for formatting after the pass. +fn recalculate_values( + doc: &mut Document, + scripts: &mut dyn Scripts, + mut deferred: Option<&mut std::collections::HashSet>, +) -> Result { let mut all = fields(doc); if !all.iter().any(calc_order::is_calculated) { return Ok(0); @@ -211,6 +220,9 @@ pub fn recalculate_with(doc: &mut Document, scripts: &mut dyn Scripts) -> Result 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 { + if scripts.stopped() { + break; + } let Some(f) = all.iter().find(|f| f.obj == obj).cloned() else { continue }; let lookup = |n: &str| -> Vec { let prefix = format!("{n}."); @@ -218,13 +230,18 @@ pub fn recalculate_with(doc: &mut Document, scripts: &mut dyn Scripts) -> Result }; 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); + let r = scripts.run(FieldEvent::Calculate, js, &f, ¤t, &all, doc); // A script may set its own field through the field object (`getField("Total").value // = …`) rather than through `event.value`. `apply_changes` leaves the event's own // field to the event, so that value is the result unless the script also assigned // `event.value`, even the value it already had. - let own = r.changes.iter().find(|c| c.name == f.name).and_then(|c| c.value.as_ref()).map(|v| v.first().cloned().unwrap_or_default()); - scripting::apply_changes(doc, &r.changes, &f.name, scripts)?; + let own = r + .changes + .iter() + .find(|c| c.name == f.name && c.object.is_none_or(|obj| obj == f.obj)) + .and_then(|c| c.value.as_ref()) + .map(|v| v.first().cloned().unwrap_or_default()); + scripting::apply_changes(doc, &r.changes, &f.name, Some(f.obj), scripts)?; if !r.changes.is_empty() { all = fields(doc); } @@ -262,7 +279,12 @@ pub fn recalculate_with(doc: &mut Document, scripts: &mut dyn Scripts) -> Result _ => {} } })?; - redraw(doc, &f, std::slice::from_ref(&v), scripts)?; + if let Some(pending) = deferred.as_deref_mut() { + pending.insert(obj); + } else { + let current = all.iter().find(|field| field.obj == obj).unwrap_or(&f); + redraw(doc, current, std::slice::from_ref(&v), scripts)?; + } if let Some(target) = all.iter_mut().find(|x| x.obj == obj) { target.value = vec![v]; } @@ -290,6 +312,11 @@ pub fn calculations_changed(before: &[Field], after: &[Field]) -> Vec { } pub(crate) fn write_value(doc: &mut Document, f: &Field, value: &FieldValue, scripts: &mut dyn Scripts) -> Result<(), FormError> { + write_value_impl(doc, f, value, scripts, false) +} + +/// Persist a value according to the field kind. A reset bypasses custom input validation and defers text and choice appearances. +fn write_value_impl(doc: &mut Document, f: &Field, value: &FieldValue, scripts: &mut dyn Scripts, resetting: bool) -> Result<(), FormError> { match (f.kind, value) { (FieldKind::Text, FieldValue::Text(t)) => { if let Some(max) = f.max_len @@ -305,17 +332,21 @@ pub(crate) fn write_value(doc: &mut Document, f: &Field, value: &FieldValue, scr // Keystroke (on commit) and Validate, as Acrobat runs them before accepting a value. let t = &f.actions.keystroke(&f.name, t).map_err(FormError::Format)?; af::validate(&f.actions.validate, t).map_err(FormError::Invalid)?; - let t = &scripting::accept(doc, f, t, scripts)?; + let (t, refresh_field) = if resetting { (t.clone(), false) } else { scripting::accept(doc, f, t, scripts)? }; let shadow = t.is_empty() && crate::author::ancestor_sets(doc, f.obj, b"V"); doc.update_dict(f.obj, |d| { if t.is_empty() && !shadow { d.remove(b"V"); } else { - d.set(b"V".to_vec(), PdfString::text(t)); + d.set(b"V".to_vec(), PdfString::text(&t)); } d.remove(b"RV"); })?; - redraw(doc, f, std::slice::from_ref(t), scripts) + if resetting { + return Ok(()); + } + let current = refresh_field.then(|| fields(doc).into_iter().find(|current| current.obj == f.obj)).flatten(); + redraw(doc, current.as_ref().unwrap_or(f), std::slice::from_ref(&t), scripts) } (FieldKind::CheckBox | FieldKind::Radio, FieldValue::Widget(i)) => { // A button can be read-only on its own (its own `/Ff`), though the field isn't. @@ -325,6 +356,9 @@ pub(crate) fn write_value(doc: &mut Document, f: &Field, value: &FieldValue, scr let Some(state) = f.widgets.get(*i).and_then(|w| w.on_state.clone()) else { return invalid(format!("{:?} has no button {} with an on state", f.name, i.saturating_add(1))); }; + if f.kind == FieldKind::Radio && radio_change_blocked(f, Some(&state), Some(*i)) { + return Err(FormError::ReadOnly(f.name.clone())); + } set_states(doc, f, Some(&state), Some(*i)) } (FieldKind::CheckBox, v) => { @@ -375,6 +409,9 @@ pub(crate) fn write_value(doc: &mut Document, f: &Field, value: &FieldValue, scr 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)); } + if radio_change_blocked(f, choice.as_deref(), None) { + return Err(FormError::ReadOnly(f.name.clone())); + } set_states(doc, f, choice.as_deref(), None) } (FieldKind::Combo | FieldKind::List, FieldValue::Choice(_) | FieldValue::Text(_) | FieldValue::ChoiceIndices(_)) => { @@ -462,6 +499,9 @@ pub(crate) fn write_value(doc: &mut Document, f: &Field, value: &FieldValue, scr } })?; scroll_widgets(doc, f, &picked, f.options.len())?; + if resetting { + return Ok(()); + } let mut shown = f.clone(); shown.value = exports.clone(); shown.selected = picked; @@ -507,17 +547,49 @@ pub(crate) fn scroll_widgets(doc: &mut Document, f: &Field, picked: &[usize], co Ok(()) } +/// The one widget of an exclusive radio group that turns on when `on` is chosen (`pick` when it has +/// that state, else the one already on, else the first); `None` when `on` is `None` or the group +/// turns every widget with that state on together. +fn chosen_widget(f: &Field, on: Option<&str>, pick: Option) -> Option { + let exclusive = f.kind == FieldKind::Radio && !f.has(flags::RADIOS_IN_UNISON); + on.filter(|_| exclusive).and_then(|c| { + let has = |i: &usize| f.widgets.get(*i).is_some_and(|w| w.on_state.as_deref() == Some(c)); + let n = f.widgets.len(); + pick.filter(has).or_else(|| (0..n).filter(has).find(|&i| f.widget_on(i))).or_else(|| (0..n).find(has)) + }) +} + +/// Whether setting a radio group to `on` (through widget `pick`, when the caller chose a widget) +/// would turn a read-only button on or off: such a button can't be picked, and a selected one can't +/// be switched off by picking another. Judged per widget, by the state `set_states` would give it, +/// so buttons sharing an on-state name are told apart by their `/AS`; the group's `/V` stands for +/// the selection only when no button's `/AS` says which is on. +fn radio_change_blocked(f: &Field, on: Option<&str>, pick: Option) -> bool { + let chosen = chosen_widget(f, on, pick); + let before = |i: usize, w: &Widget| { + let Some(s) = w.on_state.as_deref() else { return false }; + if f.widget_on(i) { + return true; + } + // No button sharing this on state is on by its `/AS` and the group's `/V` names it: the + // first of them holds the selection, whatever its own `/AS` says (an `Off` among them). + let sharing = |x: &Widget| x.on_state.as_deref() == Some(s); + f.value.first().map(String::as_str) == Some(s) + && !f.widgets.iter().any(|x| sharing(x) && x.state.as_deref() == Some(s)) + && f.widgets.iter().position(sharing) == Some(i) + }; + f.widgets.iter().enumerate().any(|(i, w)| { + let after = on.is_some() && w.on_state.as_deref() == on && chosen.is_none_or(|k| k == i); + w.read_only && before(i, w) != after + }) +} + /// Check boxes and radio buttons: `/V` on the field, `/AS` on each widget. The widgets whose on /// state is `on` turn on together in a check box (ISO 32000-2 §12.7.5.2.3, EXAMPLE 2) and in a /// radio group with RadiosInUnison. Without it, at most one radio button is on (Table 229, /// §12.7.5.2.3): widget `pick` when it has that state, else the one already on, else the first. fn set_states(doc: &mut Document, f: &Field, on: Option<&str>, pick: Option) -> Result<(), FormError> { - let exclusive = f.kind == FieldKind::Radio && !f.has(flags::RADIOS_IN_UNISON); - let chosen = on.filter(|_| exclusive).and_then(|c| { - let has = |i: &usize| f.widgets.get(*i).is_some_and(|w| w.on_state.as_deref() == Some(c)); - let n = f.widgets.len(); - pick.filter(has).or_else(|| (0..n).filter(has).find(|&i| f.widget_on(i))).or_else(|| (0..n).find(has)) - }); + let chosen = chosen_widget(f, on, pick); // `/V` names the on state, the same for every widget that has it: which of them is on is // their `/AS` alone (§12.7.5.2.4). let v = on.unwrap_or("Off"); @@ -824,8 +896,35 @@ fn unread_stream(doc: &Document, field: ObjRef, key: &[u8]) -> bool { false } +/// Whether this snapshot contains a known default, including a genuinely absent /DV. +/// An unread nonempty default stream is unknown, not an instruction to clear the field. +/// Reuse this policy when exposing reset defaults to other execution layers. +pub fn default_value_known(doc: &Document, field: &Field) -> bool { + !field.default.is_empty() || !unread_stream(doc, field.obj, b"DV") +} + /// 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 { + reset_with_counts(doc, names).map(|(reset, _)| reset) +} + +/// Restore defaults and report direct resets and dependent calculation writes separately. +pub fn reset_with_counts(doc: &mut Document, names: Option<&[String]>) -> Result<(usize, usize), FormError> { + reset_with_scripts(doc, names, &mut NoScripts) +} + +/// Restore defaults and then run the active calculation callbacks once. +pub fn reset_with_scripts(doc: &mut Document, names: Option<&[String]>, scripts: &mut dyn Scripts) -> Result<(usize, usize), FormError> { + reset_with_formatting(doc, names, scripts, true) +} + +/// Restore defaults with their script formatting, optionally deferring custom calculations. +pub fn reset_with_formatting( + doc: &mut Document, + names: Option<&[String]>, + scripts: &mut dyn Scripts, + calculate_with_scripts: bool, +) -> Result<(usize, usize), FormError> { let all = fields(doc); if all.is_empty() { return Err(FormError::NoForm); @@ -839,12 +938,13 @@ pub fn reset(doc: &mut Document, names: Option<&[String]>) -> Result> = names.map(|n| n.iter().map(String::as_str).collect()); let mut changed = 0; + let mut redraw_fields = std::collections::HashSet::new(); for f in all.iter().filter(|f| named.as_ref().is_none_or(|n| n.contains(f.name.as_str()))) { // A default the reading didn't decode (its budget spent) is unknown, not empty: clearing // the field to nothing would lose the value it should go back to. A value it didn't decode // isn't known to be the default, so it is reset. let unread = |key: &[u8], read: &[String]| read.is_empty() && unread_stream(doc, f.obj, key); - if unread(b"DV", &f.default) || (f.value == f.default && !unread(b"V", &f.value)) { + if !default_value_known(doc, f) || (f.value == f.default && !unread(b"V", &f.value)) { continue; } let v = match f.kind { @@ -857,11 +957,33 @@ pub fn reset(doc: &mut Document, names: Option<&[String]>) -> Result { + changed += 1; + if matches!(f.kind, FieldKind::Text | FieldKind::Combo | FieldKind::List) { + redraw_fields.insert(f.obj); + } + } + // a read-only button of the group holds the value: this field is left as it is, and the + // rest of the form is still cleared + Err(FormError::ReadOnly(_)) => {} + Err(e) => return Err(e), + } } - recalculate(doc)?; - Ok(changed) + let calculation_runner: &mut dyn Scripts = if calculate_with_scripts { scripts } else { &mut NoScripts }; + let recalculated = recalculate_values(doc, calculation_runner, Some(&mut redraw_fields))?; + // Format scripts can read other fields. Draw from the final values once all + // selected defaults and calculated values have been written. + if !redraw_fields.is_empty() { + let mut no_scripts = NoScripts; + for f in fields(doc).into_iter().filter(|f| redraw_fields.contains(&f.obj)) { + // A host may stop callbacks and still commit the reset. Its widgets must + // continue to reflect the saved values. + let formatting: &mut dyn Scripts = if scripts.stopped() { &mut no_scripts } else { scripts }; + redraw(doc, &f, &f.value, formatting)?; + } + } + Ok((changed, recalculated)) } /// Marks option `i` picked; false when it already was, or there is no such option. diff --git a/crates/js/src/document.rs b/crates/js/src/document.rs index 54274bb140..cccf89a7a2 100644 --- a/crates/js/src/document.rs +++ b/crates/js/src/document.rs @@ -1,10 +1,12 @@ -use super::host::{arg, colour, error, function, host, read, s, set_value, strings, text, value_of, write}; +use super::host::{ + FieldKey, arg, charge, colour, error, function, host, kind_of, read, s, strings, text, text_bytes, text_line, value_for, value_of, write, +}; use super::model::{DISPLAY_HIDDEN, DISPLAY_VISIBLE, FieldType, Request}; use super::{Attribute, Context, JsArray, JsObject, JsResult, JsString, JsValue, NativeFunction, ObjectInitializer, js_string}; macro_rules! getter { ($ctx:expr, $name:expr, |$f:ident, $c:ident| $body:expr) => {{ - fn get(_: &JsValue, _: &[JsValue], name: &JsString, $c: &mut Context) -> JsResult { + fn get(_: &JsValue, _: &[JsValue], name: &FieldKey, $c: &mut Context) -> JsResult { let $f = read($c, name)?; Ok($body) } @@ -12,13 +14,38 @@ macro_rules! getter { }}; } +/// Resolve public name lookups to their first matching object, preserving ordinary getField behavior. pub(crate) fn field_object(ctx: &mut Context, name: &str) -> JsObject { - let n = JsString::from(name); + let id = host(ctx).ok().and_then(|h| h.borrow().fields.iter().find(|f| f.name == name).and_then(|f| f.object_id)); + field_object_by_id(ctx, name, id) +} +/// Cache by object identity as well as name so duplicate-named targets remain distinct. +pub(crate) fn field_object_by_id(ctx: &mut Context, name: &str, id: Option) -> JsObject { + let token = host(ctx) + .ok() + .and_then(|h| h.borrow().fields.iter().find(|f| f.object_id == id && (id.is_some() || f.name == name)).map(|f| f.instance_id)) + .unwrap_or(0); + let key = crate::host::field_cache_key(name, id, token); + if let Some(object) = host(ctx).ok().and_then(|h| h.borrow().objects.get(&key).cloned()) { + return object; + } + let object = field_object_new(ctx, name, id, token); + if let Ok(h) = host(ctx) + && h.borrow().field_keys.contains(&key) + { + h.borrow_mut().objects.insert(key, object.clone()); + } + object +} +/// Construct live accessors whose identity survives host snapshot refreshes. +fn field_object_new(ctx: &mut Context, name: &str, id: Option, token: u64) -> JsObject { + let n = (JsString::from(name), id, token); 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))?; + fn set(_: &JsValue, args: &[JsValue], name: &FieldKey, c: &mut Context) -> JsResult { + let vals = value_for(kind_of(c, name)?, strings(&arg(args, 0), c)?); + charge(c, name, |f| text_bytes(&f.value), text_bytes(&vals))?; + write(c, name, |f| f.value = vals)?; Ok(JsValue::undefined()) } function(ctx, NativeFunction::from_copy_closure_with_captures(set, n.clone())) @@ -32,7 +59,7 @@ pub(crate) fn field_object(ctx: &mut Context, name: &str) -> JsObject { 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 { + fn set(_: &JsValue, args: &[JsValue], name: &FieldKey, c: &mut Context) -> JsResult { let on = arg(args, 0).to_boolean(); write(c, name, |f| f.readonly = on)?; Ok(JsValue::undefined()) @@ -41,7 +68,7 @@ pub(crate) fn field_object(ctx: &mut Context, name: &str) -> JsObject { }; 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 { + fn set(_: &JsValue, args: &[JsValue], name: &FieldKey, c: &mut Context) -> JsResult { let on = arg(args, 0).to_boolean(); write(c, name, |f| f.required = on)?; Ok(JsValue::undefined()) @@ -50,7 +77,7 @@ pub(crate) fn field_object(ctx: &mut Context, name: &str) -> JsObject { }; 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 { + fn set(_: &JsValue, args: &[JsValue], name: &FieldKey, 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()) @@ -59,7 +86,7 @@ pub(crate) fn field_object(ctx: &mut Context, name: &str) -> JsObject { }; 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 { + fn set(_: &JsValue, args: &[JsValue], name: &FieldKey, 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()) @@ -76,56 +103,149 @@ pub(crate) fn field_object(ctx: &mut Context, name: &str) -> JsObject { 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_get = getter!(ctx, n, |f, c| colour(f.text_color.as_deref(), c)); let text_color_set = { - fn set(_: &JsValue, args: &[JsValue], name: &JsString, c: &mut Context) -> JsResult { + fn set(_: &JsValue, args: &[JsValue], name: &FieldKey, c: &mut Context) -> JsResult { let v = strings(&arg(args, 0), c)?; - write(c, name, |f| f.text_color = Some(v))?; + charge(c, name, |f| f.text_color.as_deref().map_or(0, text_bytes), text_bytes(&v))?; + write(c, name, |f| f.text_color = Some(v.into()))?; 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_get = getter!(ctx, n, |f, c| colour(f.fill_color.as_deref(), c)); let fill_color_set = { - fn set(_: &JsValue, args: &[JsValue], name: &JsString, c: &mut Context) -> JsResult { + fn set(_: &JsValue, args: &[JsValue], name: &FieldKey, c: &mut Context) -> JsResult { let v = strings(&arg(args, 0), c)?; - write(c, name, |f| f.fill_color = Some(v))?; + charge(c, name, |f| f.fill_color.as_deref().map_or(0, text_bytes), text_bytes(&v))?; + write(c, name, |f| f.fill_color = Some(v.into()))?; Ok(JsValue::undefined()) } function(ctx, NativeFunction::from_copy_closure_with_captures(set, n.clone())) }; - let rect_get = getter!(ctx, n, |f, c| match f.rect { - Some(rect) => JsArray::from_iter(rect.into_iter().map(JsValue::from).collect::>(), c).into(), - None => JsValue::null(), - }); + let text_size_get = getter!(ctx, n, |f, c| JsValue::from(f.text_size)); + let text_size_set = { + /// Validate a finite text size in 0..=300 points before staging the field appearance change. + fn set(_: &JsValue, args: &[JsValue], name: &FieldKey, c: &mut Context) -> JsResult { + let v = arg(args, 0).to_number(c)?; + if !v.is_finite() || !(0.0..=300.0).contains(&v) { + return Err(error("textSize must be between 0 and 300")); + } + write(c, name, |f| f.text_size = v)?; + Ok(JsValue::undefined()) + } + function(ctx, NativeFunction::from_copy_closure_with_captures(set, n.clone())) + }; + let line_width_get = getter!(ctx, n, |f, c| JsValue::from(f.line_width)); + let line_width_set = { + /// Validate a finite border width in 0..=12 points before staging the appearance change. + fn set(_: &JsValue, args: &[JsValue], name: &FieldKey, c: &mut Context) -> JsResult { + let v = arg(args, 0).to_number(c)?; + if !v.is_finite() || !(0.0..=12.0).contains(&v) { + return Err(error("lineWidth must be between 0 and 12")); + } + write(c, name, |f| f.line_width = v)?; + Ok(JsValue::undefined()) + } + function(ctx, NativeFunction::from_copy_closure_with_captures(set, n.clone())) + }; + let border_style_get = getter!(ctx, n, |f, c| s(&f.border_style)); + let border_style_set = { + /// Accept only supported border styles and stage the validated value for PDF writeback. + fn set(_: &JsValue, args: &[JsValue], name: &FieldKey, c: &mut Context) -> JsResult { + let v = text(&arg(args, 0), c)?; + if !["solid", "dashed", "beveled", "inset", "underline"].contains(&v.as_str()) { + return Err(error("unsupported borderStyle")); + } + write(c, name, |f| f.border_style = v)?; + Ok(JsValue::undefined()) + } + function(ctx, NativeFunction::from_copy_closure_with_captures(set, n.clone())) + }; + let user_name_get = getter!(ctx, n, |f, c| s(&f.user_name)); + let user_name_set = { + /// Bound the tooltip string to 4,096 bytes before staging the field user name. + fn set(_: &JsValue, args: &[JsValue], name: &FieldKey, c: &mut Context) -> JsResult { + let v = text(&arg(args, 0), c)?; + if v.len() > 4096 { + return Err(error("userName is too long")); + } + write(c, name, |f| f.user_name = v)?; + Ok(JsValue::undefined()) + } + function(ctx, NativeFunction::from_copy_closure_with_captures(set, n.clone())) + }; + let stroke_color_get = getter!(ctx, n, |f, c| colour(f.stroke_color.as_deref(), c)); + let stroke_color_set = { + /// Read the bounded stroke-colour array for validation at the form writeback boundary. + fn set(_: &JsValue, args: &[JsValue], name: &FieldKey, c: &mut Context) -> JsResult { + let colour = strings(&arg(args, 0), c)?; + write(c, name, |f| f.stroke_color = Some(colour.into()))?; + Ok(JsValue::undefined()) + } + function(ctx, NativeFunction::from_copy_closure_with_captures(set, n.clone())) + }; + let rect_get = getter!(ctx, n, |f, c| f.rect.map_or_else(JsValue::undefined, |r| JsArray::from_iter(r.map(JsValue::from), c).into())); let rect_set = { - fn set(_: &JsValue, args: &[JsValue], name: &JsString, c: &mut Context) -> JsResult { - let value = arg(args, 0); - let object = value.as_object().filter(|o| o.is_array()).ok_or_else(|| error("rect must be an array of four finite numbers"))?; - let array = JsArray::from_object(object.clone())?; - if array.length(c)? != 4 { - return Err(error("rect must be an array of four finite numbers")); + /// Validate finite ordered rectangle coordinates while permitting zero-sized script overlays. + fn set(_: &JsValue, args: &[JsValue], name: &FieldKey, c: &mut Context) -> JsResult { + let a = arg(args, 0).as_object().ok_or_else(|| error("rect needs an array"))?; + let a = JsArray::from_object(a)?; + if a.length(c)? != 4 { + return Err(error("rect needs four coordinates")); } - let mut rect = [0.0; 4]; - for (i, coordinate) in rect.iter_mut().enumerate() { - *coordinate = array.get(i as u64, c)?.to_number(c)?; + let mut coords = [0.0; 4]; + for (i, v) in coords.iter_mut().enumerate() { + *v = a.get(i as u32, c)?.to_number(c)?; + if !v.is_finite() || v.abs() > 1e7 { + return Err(error("rect coordinates are out of range")); + } } - if !rect.iter().all(|coordinate| coordinate.is_finite()) { - return Err(error("rect coordinates must be finite")); + if coords[2] < coords[0] || coords[1] < coords[3] { + return Err(error("rect must use left, top, right, bottom order")); } - let width = (rect[2] - rect[0]).abs(); - let height = (rect[3] - rect[1]).abs(); - if !width.is_finite() || !height.is_finite() || width < 4.0 || height < 4.0 { - return Err(error("rect must be at least 4 points wide and high")); - } - let rect = [rect[0].min(rect[2]), rect[1].min(rect[3]), rect[0].max(rect[2]), rect[1].max(rect[3])]; - write(c, name, |f| f.rect = Some(rect))?; + let v = Some(coords); + write(c, name, |f| f.rect = 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 { + /// Read the normal caption of the first button widget and refuse unsupported caption faces. + fn button_get_caption(_: &JsValue, args: &[JsValue], name: &FieldKey, c: &mut Context) -> JsResult { + let f = read(c, name)?; + if f.kind != FieldType::Button { + return Err(error(format!("{} is not a push button", f.name))); + } + let face = if args.is_empty() { 0.0 } else { arg(args, 0).to_number(c)? }; + if !face.is_finite() || face != 0.0 { + return Err(error("only the normal button face (0) is supported")); + } + Ok(s(f.caption.as_deref().unwrap_or(""))) + } + /// Record a normal-caption setter even when widget zero already matches, allowing all-widget writeback. + fn button_set_caption(_: &JsValue, args: &[JsValue], name: &FieldKey, c: &mut Context) -> JsResult { + let f = read(c, name)?; + if f.kind != FieldType::Button { + return Err(error("buttonSetCaption is only available on push buttons")); + } + let caption_arg = args.first().ok_or_else(|| error("buttonSetCaption requires a caption argument"))?; + let caption = text_line(caption_arg, c)?; + let face = if args.get(1).is_none() { 0.0 } else { arg(args, 1).to_number(c)? }; + if !face.is_finite() || face != 0.0 { + return Err(error("only the normal button face (0) is supported")); + } + if host(c)?.borrow().event_name == "Format" { + return Err(error("buttonSetCaption is not available in a Format event")); + } + write(c, name, |f| { + f.caption = Some(caption); + f.caption_written = true; + })?; + Ok(JsValue::undefined()) + } + + fn get_item_at(_: &JsValue, args: &[JsValue], name: &FieldKey, c: &mut Context) -> JsResult { let f = read(c, name)?; let i = arg(args, 0).to_number(c)?; // bExportValue defaults to true. @@ -134,13 +254,13 @@ pub(crate) fn field_object(ctx: &mut Context, name: &str) -> JsObject { 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 { + fn is_box_checked(_: &JsValue, args: &[JsValue], name: &FieldKey, 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 { + fn check_this_box(_: &JsValue, args: &[JsValue], name: &FieldKey, 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); @@ -154,16 +274,89 @@ pub(crate) fn field_object(ctx: &mut Context, name: &str) -> JsObject { })?; 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())); + fn set_focus(_: &JsValue, _: &[JsValue], name: &FieldKey, c: &mut Context) -> JsResult { + let field = read(c, name)?; + let request = match field.object_id { + Some(object_id) => Request::FocusField { name: field.name, object_id }, + None => Request::Focus(field.name), + }; + host(c)?.borrow_mut().request(request); + Ok(JsValue::undefined()) + } + + /// `setAction(cTrigger, cScript)`: install `cScript` as the field's JavaScript for + /// `cTrigger`, replacing what it ran before; saved with the document (#269). + fn set_action(_: &JsValue, args: &[JsValue], name: &FieldKey, c: &mut Context) -> JsResult { + let trigger = text(&arg(args, 0), c)?; + if !SET_ACTION_TRIGGERS.contains(&trigger.as_str()) { + return Err(error(format!("setAction: unknown trigger {trigger:?}; use one of {}", SET_ACTION_TRIGGERS.join(", ")))); + } + let script = text(&arg(args, 1), c)?; + if let Some(why) = crate::run::refuse(&script) { + return Err(error(format!("setAction: {why}"))); + } + write(c, name, |f| { + f.actions.retain(|(t, _)| *t != trigger); + f.actions.push((trigger, script)); + })?; + Ok(JsValue::undefined()) + } + /// `setItems(oArray)`: replace a combo box's or list box's items. Each item is its text, or + /// a `[text, exportValue]` pair. The value stays when an item still has it, and otherwise + /// becomes the first item's (#269). + fn set_items(_: &JsValue, args: &[JsValue], name: &FieldKey, c: &mut Context) -> JsResult { + let f = read(c, name)?; + if !matches!(f.kind, FieldType::ComboBox | FieldType::ListBox) { + return Err(error(format!("setItems: {} is not a combo box or list box", f.name))); + } + let list = arg(args, 0).as_object().filter(JsObject::is_array).ok_or_else(|| error("setItems needs an array of items"))?; + let list = JsArray::from_object(list)?; + let len = list.length(c)?; + if len > MAX_ITEMS as u64 { + return Err(error(format!("setItems: at most {MAX_ITEMS} items"))); + } + let mut items = Vec::new(); + let mut bytes = 0usize; + for i in 0..len { + let item = list.get(i, c)?; + let (label, export) = match item.as_object().filter(JsObject::is_array) { + Some(pair) => { + let pair = JsArray::from_object(pair.clone())?; + let label = text(&pair.get(0, c)?, c)?; + let export = pair.get(1, c)?; + let export = if export.is_undefined() { label.clone() } else { text(&export, c)? }; + (label, export) + } + None => { + let label = text(&item, c)?; + (label.clone(), label) + } + }; + bytes = bytes.saturating_add(label.len()).saturating_add(export.len()); + if bytes > MAX_ITEM_BYTES { + return Err(error("setItems: the items are too long")); + } + items.push((export, label)); + } + write(c, name, |f| { + let kept: Vec = f.value.iter().filter(|v| items.iter().any(|(export, _)| export == *v)).cloned().collect(); + f.value = if kept.is_empty() { items.first().map(|(export, _)| vec![export.clone()]).unwrap_or_default() } else { kept }; + f.options = items; + })?; Ok(JsValue::undefined()) } let rw = Attribute::CONFIGURABLE | Attribute::ENUMERABLE; + let set_action = NativeFunction::from_copy_closure_with_captures(set_action, n.clone()); + let set_items = NativeFunction::from_copy_closure_with_captures(set_items, n.clone()); 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()); + let button_get_caption = NativeFunction::from_copy_closure_with_captures(button_get_caption, n.clone()); + let button_set_caption = NativeFunction::from_copy_closure_with_captures(button_set_caption, n.clone()); + let button_get_icon = NativeFunction::from_copy_closure_with_captures(crate::icons::get, n.clone()); + let button_set_icon = NativeFunction::from_copy_closure_with_captures(crate::icons::set, 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) @@ -180,14 +373,33 @@ pub(crate) fn field_object(ctx: &mut Context, name: &str) -> JsObject { .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) + .accessor(js_string!("textSize"), Some(text_size_get), Some(text_size_set), rw) + .accessor(js_string!("lineWidth"), Some(line_width_get), Some(line_width_set), rw) + .accessor(js_string!("borderStyle"), Some(border_style_get), Some(border_style_set), rw) + .accessor(js_string!("userName"), Some(user_name_get), Some(user_name_set), rw) + .accessor(js_string!("strokeColor"), Some(stroke_color_get), Some(stroke_color_set), rw) .accessor(js_string!("rect"), Some(rect_get), Some(rect_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) + .function(set_action, js_string!("setAction"), 2) + .function(set_items, js_string!("setItems"), 1) + .function(button_get_icon, js_string!("buttonGetIcon"), 1) + .function(button_set_icon, js_string!("buttonSetIcon"), 2) + .function(button_get_caption, js_string!("buttonGetCaption"), 1) + .function(button_set_caption, js_string!("buttonSetCaption"), 2) .build() } +/// The triggers `field.setAction` takes (Acrobat JavaScript reference, Field.setAction). +const SET_ACTION_TRIGGERS: [&str; 10] = + ["MouseUp", "MouseDown", "MouseEnter", "MouseExit", "OnFocus", "OnBlur", "Keystroke", "Validate", "Calculate", "Format"]; +/// Most items one `setItems` call takes, and most bytes of their texts and export values: a +/// script must not grow a field without bound. +const MAX_ITEMS: usize = 10_000; +const MAX_ITEM_BYTES: usize = 1 << 20; + // ── the document (global object) ──────────────────────────────────────────────────────────── pub(crate) fn get_field(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { @@ -209,8 +421,31 @@ pub(crate) fn get_nth_field_name(_: &JsValue, args: &[JsValue], ctx: &mut Contex } pub(crate) fn reset_form(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + { + let mut h = host(ctx)?.borrow_mut(); + if h.reset_calls >= crate::MAX_RESET_CALLS { + h.reset_limit_exceeded = true; + return Err(error("reset call limit exceeded")); + } + h.reset_calls = h.reset_calls.saturating_add(1); + } let names = strings(&arg(args, 0), ctx)?; - host(ctx)?.borrow_mut().request(Request::Reset(names)); + let mut h = host(ctx)?.borrow_mut(); + let queued = h.requests.len(); + h.request(Request::Reset(names)); + // Keep the live shadow and the bounded host request in agreement, including clipped names. + let Some(Request::Reset(names)) = h.requests.get(queued).cloned() else { return Ok(JsValue::undefined()) }; + let super::host::Host { fields, changed, .. } = &mut *h; + for (i, field) in std::sync::Arc::make_mut(fields).iter_mut().enumerate() { + if field.default_known + && !matches!(field.kind, FieldType::Button | FieldType::Signature) + && (names.is_empty() || names.iter().any(|n| field.name == *n || field.name.strip_prefix(n).is_some_and(|rest| rest.starts_with('.')))) + { + field.value.clone_from(&field.default); + // Retain the final snapshot even when a later assignment restores the pre-reset value. + changed.insert(i); + } + } Ok(JsValue::undefined()) } diff --git a/crates/js/src/formcalc/access.rs b/crates/js/src/formcalc/access.rs index 26de7ba9eb..3c323ca088 100644 --- a/crates/js/src/formcalc/access.rs +++ b/crates/js/src/formcalc/access.rs @@ -33,9 +33,12 @@ impl Interp<'_> { Ok(()) } - /// `more` nodes into `out`, each once, never past [`MAX_LIST`]. + /// `more` nodes into `out`, each once, never past [`MAX_LIST`]. Every node visited costs a + /// step, kept or not, and the call itself one more: a search that matches nothing still + /// costs work when it is repeated over a long list. pub(super) fn gather(&mut self, out: &mut Vec, seen: &mut HashSet, more: impl IntoIterator) -> R<()> { for i in more { + self.step()?; if seen.insert(i) { out.push(i); if out.len() > MAX_LIST { @@ -43,7 +46,7 @@ impl Interp<'_> { } } } - self.charge(1) + self.step() } /// The nodes of `v`, each once. @@ -294,8 +297,10 @@ impl Interp<'_> { } } - /// `v` onto `out`: a node only once, never past [`MAX_LIST`]. + /// `v` onto `out`: a node only once, never past [`MAX_LIST`]. A duplicate costs its step + /// too: `all` on a list of same-named siblings makes one for every pair. fn list_push(&mut self, out: &mut Vec, seen: &mut HashSet, v: Val) -> R<()> { + self.step()?; if let Val::Node(i) = v && !seen.insert(i) { @@ -305,7 +310,7 @@ impl Interp<'_> { if out.len() > MAX_LIST { return Err(LIST_TOO_LONG.into()); } - self.charge(1) + Ok(()) } /// Property `name` of form object `i` as FormCalc reads it: its value, attributes, diff --git a/crates/js/src/formcalc/dates.rs b/crates/js/src/formcalc/dates.rs index 4d9befeab2..f201a23525 100644 --- a/crates/js/src/formcalc/dates.rs +++ b/crates/js/src/formcalc/dates.rs @@ -56,6 +56,9 @@ const CENTURY_SPLIT: i64 = 30; const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]; +/// Characters of the longest month name: all a month token needs to read of the text. +const LONGEST_MONTH: usize = 9; + /// 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(); @@ -71,8 +74,9 @@ pub(super) fn parse_date(text: &str, picture: &str) -> Option<(i64, u32, u32)> { } i += run; if c == 'M' && run >= 3 { - // Month name. - let rest: String = txt.get(j..).unwrap_or(&[]).iter().collect(); + // Month name: only as much of the text as a name can be, so a picture of many + // month tokens does not copy the rest of a long text for each of them. + let rest: String = txt.get(j..).unwrap_or(&[]).iter().take(LONGEST_MONTH).collect(); let lower = rest.to_ascii_lowercase(); let found = MONTHS .iter() diff --git a/crates/js/src/geometry.rs b/crates/js/src/geometry.rs new file mode 100644 index 0000000000..a2861ee5c1 --- /dev/null +++ b/crates/js/src/geometry.rs @@ -0,0 +1,33 @@ +//! Conversion between PDF default user space and Acrobat's widget rectangle convention. +/// Convert a PDF rectangle to rotated user-space order around the effective crop-box origin. +pub fn rotate_rect([x0, y0, x1, y1]: [f64; 4], [cx0, cy0, cx1, cy1]: [f64; 4], rotation: i64) -> [f64; 4] { + match rotation.rem_euclid(360) { + 90 => [y0 - cy0, cx1 - x0, y1 - cy0, cx1 - x1], + 180 => [cx1 - x1, cy1 - y0, cx1 - x0, cy1 - y1], + 270 => [cy1 - y1, x1 - cx0, cy1 - y0, x0 - cx0], + _ => [x0 - cx0, y1 - cy0, x1 - cx0, y0 - cy0], + } +} +/// Map a script rectangle back to default PDF user space using the inverse page rotation. +pub fn unrotate_rect([l, t, r, b]: [f64; 4], [cx0, cy0, cx1, cy1]: [f64; 4], rotation: i64) -> [f64; 4] { + match rotation.rem_euclid(360) { + 90 => [cx1 - t, cy0 + l, cx1 - b, cy0 + r], + 180 => [cx1 - r, cy1 - t, cx1 - l, cy1 - b], + 270 => [cx0 + b, cy1 - r, cx0 + t, cy1 - l], + _ => [l + cx0, b + cy0, r + cx0, t + cy0], + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + /// All quarter-turn rotations round-trip rectangle coordinates with a nonzero crop origin. + fn rotated_widget_rectangles_roundtrip_with_shifted_crop_boxes() { + let rect = [30.0, 50.0, 90.0, 100.0]; + let crop = [20.0, 40.0, 620.0, 840.0]; + for rotation in [0, 90, 180, 270] { + assert_eq!(unrotate_rect(rotate_rect(rect, crop, rotation), crop, rotation), rect); + } + } +} diff --git a/crates/js/src/globals.rs b/crates/js/src/globals.rs index 1acc9bb89f..ef7b95c8c7 100644 --- a/crates/js/src/globals.rs +++ b/crates/js/src/globals.rs @@ -1,10 +1,10 @@ -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::document::{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, strings, text, text_line}; +use super::host::{arg, error, function, host, s, strings, text, text_line}; use super::model::{DISPLAY_HIDDEN, DISPLAY_NO_PRINT, DISPLAY_NO_VIEW, DISPLAY_VISIBLE, Event, Panel, Request}; use super::{ Arc, Attribute, Context, JsArray, JsObject, JsResult, JsString, JsValue, Message, MessageButtons, MessageIcon, NativeFunction, ObjectInitializer, - Source, error, js_string, + Source, js_string, }; // ── app, util, console ────────────────────────────────────────────────────────────────────── @@ -78,10 +78,30 @@ pub(crate) fn message_box_message(args: &[JsValue], ctx: &mut Context) -> JsResu /// given in that order or as one object with those keys, and returns 1 (the OK button). fn alert(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { let m = alert_message(args, ctx)?; - host(ctx)?.borrow_mut().alert(m); - // Always the OK button for now: the viewer can't ask the user and run the script on with - // the answer yet (#4291). - Ok(JsValue::from(1)) + host(ctx)?.borrow_mut().alert(m.clone()); + if !crate::interaction::has_host() { + return Ok(JsValue::from(1)); + } + let answer = crate::interaction::ask(crate::interaction::Prompt::Alert(m.clone()))?; + let fallback = match m.buttons { + MessageButtons::Ok => "1", + MessageButtons::YesNo => "3", + _ => "2", + }; + let answer = answer.as_deref().unwrap_or(fallback); + let accepted = match m.buttons { + MessageButtons::Ok => ["1"].as_slice(), + MessageButtons::OkCancel => ["1", "2"].as_slice(), + MessageButtons::YesNo => ["3", "4"].as_slice(), + MessageButtons::YesNoCancel => ["2", "3", "4"].as_slice(), + }; + if answer == "1" && !accepted.contains(&"1") { + return Ok(JsValue::from(2)); + } + if !accepted.contains(&answer) { + return Err(error("invalid alert response")); + } + Ok(JsValue::from(answer.parse::().unwrap_or(2))) } /// `app.beep()`: queues a request to beep. @@ -92,7 +112,7 @@ fn beep(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { /// `app.runtimeHighlight`: whether the viewer highlights form fields. fn runtime_highlight(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { - Ok(JsValue::from(host(ctx)?.borrow().doc.highlight)) + Ok(JsValue::from(host(ctx)?.borrow().doc.runtime_highlight)) } /// `app.runtimeHighlight = on`: asks the viewer to highlight form fields, or to stop. Later reads @@ -100,13 +120,13 @@ fn runtime_highlight(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult< fn set_runtime_highlight(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { let on = arg(args, 0).to_boolean(); let mut h = host(ctx)?.borrow_mut(); - if h.doc.highlight != on { + if h.doc.runtime_highlight != on { // Only the setting the script ends with matters: one request, the latest, under the // host's request cap like any other. One the cap drops changes nothing, so the script // goes on reading the setting the viewer has. h.requests.retain(|r| !matches!(r, Request::Highlight(_))); if h.request(Request::Highlight(on)) { - h.doc.highlight = on; + h.doc.runtime_highlight = on; } } Ok(JsValue::undefined()) @@ -212,27 +232,14 @@ fn get_page_box(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult [y0 - cy0, cx1 - x0, y1 - cy0, cx1 - x1], - 180 => [cx1 - x1, cy1 - y0, cx1 - x0, cy1 - y1], - 270 => [cy1 - y1, x1 - cx0, cy1 - y0, x0 - cx0], - _ => [x0 - cx0, y1 - cy0, x1 - cx0, y0 - cy0], - }; + let rotated = crate::rotate_rect(rect, crop, rotation); if !rotated.iter().all(|value| value.is_finite()) { return Err(error("getPageBox returned non-finite coordinates")); } Ok(JsArray::from_iter(rotated.into_iter().map(JsValue::from), ctx).into()) } -/// `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()) -} - +/// Write a bounded line to the script console. fn println(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { let m = text_line(&arg(args, 0), ctx)?; host(ctx)?.borrow_mut().print(m); @@ -251,11 +258,15 @@ fn calculate_now(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult= MAX_CALCULATE_DEPTH { + h.calculation_limit_exceeded = true; return Err(error("calculateNow recursion limit exceeded")); } h.calculate_depth += 1; - Arc::clone(&h.calculations) + h.calculations.clone() }; let previous_event = match ctx.global_object().get(js_string!("event"), ctx) { Ok(event) => event, @@ -268,45 +279,60 @@ fn calculate_now(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult= super::run::MAX_CALCULATE_STEPS { - return Err(error("calculateNow evaluation limit exceeded")); + let shared = host(ctx)?; + let mut h = shared.borrow_mut(); + if h.calculation_limit_exceeded || h.calculation_evaluations >= super::run::MAX_CALCULATE_STEPS { + h.calculation_limit_exceeded = true; + return Err(error("script calculation work limit exceeded")); + } + h.calculation_evaluations += 1; + if let Some(why) = super::run::refuse(&script) { + h.calculation_limit_exceeded = true; + return Err(error(why)); } - h.calculate_steps = h.calculate_steps.saturating_add(1); } - let Some(field) = host(ctx)?.borrow().fields.iter().find(|f| f.name == *name).cloned() else { continue }; - if let Some(why) = super::run::refuse(script) { - host(ctx)?.borrow_mut().print(format!("calculation script for {name} failed: {why}")); - continue; - } - let event = Event::field("Calculate", name, &field.value.first().cloned().unwrap_or_default()); - let (object, _) = event_object(ctx, &event)?; + let mut event = Event::field("Calculate", &field.name, &field.value.first().cloned().unwrap_or_default()); + event.target_id = field.object_id; + let (object, value_store) = event_object(ctx, &event)?; ctx.global_object().set(js_string!("event"), object.clone(), true, ctx)?; let (fields_before, changed_before, formatted_before) = { - let h = host(ctx)?.borrow(); + let shared = host(ctx)?; + let h = shared.borrow(); (Arc::clone(&h.fields), h.changed.clone(), h.formatted.clone()) }; - if let Err(cause) = ctx.eval(Source::from_bytes(script)) { - let mut h = host(ctx)?.borrow_mut(); + if let Err(err) = ctx.eval(Source::from_bytes(&script)) { + let shared = host(ctx)?; + let mut h = shared.borrow_mut(); + if err.as_engine().is_some() || h.calculation_limit_exceeded || h.reset_limit_exceeded { + return Err(err); + } h.fields = fields_before; h.changed = changed_before; h.formatted = formatted_before; - h.print(format!("calculation script for {name} failed: {cause}")); + h.print(format!("Calculate {}: {err}", field.name)); continue; } + drop(fields_before); + if host(ctx)?.borrow().calculation_limit_exceeded { + return Err(error("script calculation work limit exceeded")); + } let rc = object.get(js_string!("rc"), ctx)?; if !rc.to_boolean() { continue; } + if !value_store.get(js_string!("set"), ctx)?.to_boolean() { + continue; + } let value = object.get(js_string!("value"), ctx)?; let value = text(&value, ctx)?; let shared = host(ctx)?; let mut h = shared.borrow_mut(); - if let Some((index, _)) = h.fields.iter().enumerate().find(|(_, f)| f.name == *name) { + if index < h.fields.len() { let target = Arc::make_mut(&mut h.fields).get_mut(index).ok_or_else(|| error("the field is no longer available"))?; - target.value = vec![value]; + super::host::set_value(target, vec![value]); h.changed.insert(index); h.formatted.insert(index); } @@ -328,35 +354,38 @@ fn calculate_now(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult JsResult { - const MAX_EXPANDED_VALUES: usize = 1 << 20; let op = text(&arg(args, 0), ctx)?; let names = strings(&arg(args, 1), ctx)?; - let values: Vec = { - let shared = host(ctx)?; - let h = shared.borrow(); - let mut values = Vec::new(); - for name in &names { - let prefix = format!("{name}."); - for f in h.fields.iter().filter(|f| f.name == *name || f.name.starts_with(&prefix)) { - if let Some(v) = f.value.first() { - if values.len() >= MAX_EXPANDED_VALUES { - return Ok(s("0")); - } - values.push(make_number(v).unwrap_or(0.0)); - } - } - } - values - }; + let fields = host(ctx)?.borrow().fields.clone(); + // Charge selector comparisons before group expansion; the iterator keeps memory independent + // of how often selectors overlap or duplicate the same field. + let selector_bytes = names.iter().fold(0usize, |n, s| n.saturating_add(s.len())); + let work = fields.len().saturating_mul(names.len().saturating_add(selector_bytes)); + let shared = host(ctx)?; + let mut h = shared.borrow_mut(); + h.calculation_work = h.calculation_work.saturating_add(work); + let exhausted = h.calculation_work > 1 << 24; + h.calculation_limit_exceeded |= exhausted; + drop(h); + if exhausted { + return Err(error("calculation field lookup exceeds its work limit")); + } + let mut values = names + .iter() + .flat_map(|name| fields.iter().filter(move |f| f.name == *name || f.name.strip_prefix(name).is_some_and(|rest| rest.starts_with('.')))) + .filter_map(|field| field.value.first()) + .map(|v| make_number(v).unwrap_or(0.0)) + .peekable(); let result = match op.as_str() { - "SUM" => values.iter().sum(), - // An empty product is 0, as the native calculation has it, not 1. - "PRD" if values.is_empty() => 0.0, - "PRD" => values.iter().product(), - "AVG" if values.is_empty() => 0.0, - "AVG" => values.iter().sum::() / values.len() as f64, - "MIN" => values.iter().copied().fold(f64::INFINITY, f64::min), - "MAX" => values.iter().copied().fold(f64::NEG_INFINITY, f64::max), + "SUM" => values.sum(), + "PRD" if values.peek().is_none() => 0.0, + "PRD" => values.product(), + "AVG" => { + let (count, total) = values.fold((0usize, 0.0), |(n, sum), v| (n.saturating_add(1), sum + v)); + if count == 0 { 0.0 } else { total / count as f64 } + } + "MIN" => values.reduce(f64::min).unwrap_or(0.0), + "MAX" => values.reduce(f64::max).unwrap_or(0.0), _ => 0.0, }; Ok(s(&if result.is_finite() { js_number(result) } else { "0".into() })) @@ -420,10 +449,45 @@ fn color_object(ctx: &mut Context) -> JsObject { /// 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`, `exportDataObject` and others) and properties (`numFields`, `pageNum`, -/// `numPages`, `documentFileName`, `info`, the XFA version), and the `app`, `util`, `console`, -/// `display` and `color` objects. +/// Read the current zoom percentage, defaulting unset snapshots to actual size. +fn zoom_get(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { + let z = host(ctx)?.borrow().doc.zoom; + Ok(JsValue::from(if z > 0.0 { z } else { 100.0 })) +} +/// Validate the requested percentage, update its live value and queue a native viewer zoom request. +fn zoom_set(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let z = arg(args, 0).to_number(ctx)?; + if !z.is_finite() || !(1.0..=6400.0).contains(&z) { + return Err(error("zoom must be between 1 and 6400")); + } + let shared = host(ctx)?; + let mut host = shared.borrow_mut(); + if host.request(Request::Zoom(z)) { + host.doc.zoom = z; + } + Ok(JsValue::undefined()) +} +/// Read the current page count after page insertion/deletion without resetting script globals. +fn num_pages_get(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { + Ok(JsValue::from(host(ctx)?.borrow().doc.num_pages as f64)) +} + +/// Read the current file name, including a completed Save As in this retained realm. +fn document_file_name_get(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { + Ok(s(&host(ctx)?.borrow().doc.file_name)) +} + +/// Build the information object from the current snapshot, omitting metadata removed by edits. +fn document_info_get(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { + let info = host(ctx)?.borrow().doc.info.clone(); + let mut object = ObjectInitializer::new(ctx); + for (key, value) in info { + object.property(JsString::from(key.as_str()), s(&value), Attribute::all()); + } + Ok(object.build().into()) +} + +/// Install the host API once; live document properties continue reading each job's snapshot. pub(crate) fn install(ctx: &mut Context) -> JsResult<()> { let globals: [(&str, usize, NativeFunction); 12] = [ ("getField", 1, NativeFunction::from_fn_ptr(get_field)), @@ -433,8 +497,8 @@ pub(crate) fn install(ctx: &mut Context) -> JsResult<()> { ("submitForm", 1, NativeFunction::from_fn_ptr(submit_form)), ("mailDoc", 1, NativeFunction::from_fn_ptr(noop)), ("calculateNow", 0, NativeFunction::from_fn_ptr(calculate_now)), - ("AFSimple_Calculate", 2, NativeFunction::from_fn_ptr(af_simple_calculate)), ("getPageBox", 2, NativeFunction::from_fn_ptr(get_page_box)), + ("AFSimple_Calculate", 2, NativeFunction::from_fn_ptr(af_simple_calculate)), ("syncAnnotScan", 0, NativeFunction::from_fn_ptr(noop)), ("dirty", 0, NativeFunction::from_fn_ptr(noop)), ("exportDataObject", 4, NativeFunction::from_fn_ptr(export_data_object)), @@ -442,26 +506,39 @@ pub(crate) fn install(ctx: &mut Context) -> JsResult<()> { for (name, len, f) in globals { ctx.register_global_callable(JsString::from(name), len, f)?; } + let zg = function(ctx, NativeFunction::from_fn_ptr(zoom_get)); + let zs = function(ctx, NativeFunction::from_fn_ptr(zoom_set)); + + let border = ObjectInitializer::new(ctx) + .property(js_string!("s"), s("solid"), Attribute::all()) + .property(js_string!("d"), s("dashed"), Attribute::all()) + .property(js_string!("b"), s("beveled"), Attribute::all()) + .property(js_string!("i"), s("inset"), Attribute::all()) + .property(js_string!("u"), s("underline"), Attribute::all()) + .build(); + ctx.register_global_property(js_string!("border"), border, Attribute::CONFIGURABLE)?; let global = ctx.global_object(); + global.define_property_or_throw( + js_string!("zoom"), + boa_engine::property::PropertyDescriptor::builder().get(zg).set(zs).configurable(true).build(), + ctx, + )?; 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(); + let xfa = host(ctx)?.borrow().doc.xfa; 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)?; + for (name, getter) in [ + ("numPages", num_pages_get as fn(&JsValue, &[JsValue], &mut Context) -> JsResult), + ("documentFileName", document_file_name_get), + ("info", document_info_get), + ] { + let getter = function(ctx, NativeFunction::from_fn_ptr(getter)); + global.define_property_or_throw(JsString::from(name), PropertyDescriptor::builder().get(getter).configurable(true), ctx)?; + } // 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)?; @@ -475,8 +552,9 @@ pub(crate) fn install(ctx: &mut Context) -> JsResult<()> { .accessor(js_string!("runtimeHighlight"), Some(highlight_get), Some(highlight_set), rw) .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(crate::interaction::response), js_string!("response"), 1) .function(NativeFunction::from_fn_ptr(launch_url), js_string!("launchURL"), 2) + .function(NativeFunction::from_fn_ptr(crate::interaction::popup_menu), js_string!("popUpMenuEx"), 0) .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) @@ -513,6 +591,7 @@ pub(crate) fn install(ctx: &mut Context) -> JsResult<()> { ctx.register_global_property(js_string!("display"), display, rw)?; let color = color_object(ctx); ctx.register_global_property(js_string!("color"), color, rw)?; + crate::source::install(ctx)?; Ok(()) } @@ -544,7 +623,13 @@ pub(crate) fn event_object(ctx: &mut Context, e: &Event) -> JsResult<(JsObject, .build(); let value_get = function(ctx, NativeFunction::from_copy_closure_with_captures(get, store.clone())); let value_set = function(ctx, NativeFunction::from_copy_closure_with_captures(set, store.clone())); - let target = e.target.as_ref().map_or(JsValue::null(), |t| field_object(ctx, t).into()); + let target = e.target.as_ref().map_or(JsValue::null(), |t| { + if e.target_id.is_some() { + crate::document::field_object_by_id(ctx, t, e.target_id).into() + } else { + crate::document::field_object(ctx, t).into() + } + }); let event = ObjectInitializer::new(ctx) .property(js_string!("name"), s(&e.name), Attribute::all()) .property(js_string!("type"), s(&e.kind), Attribute::all()) diff --git a/crates/js/src/host.rs b/crates/js/src/host.rs index d57cccbe74..d829041ce6 100644 --- a/crates/js/src/host.rs +++ b/crates/js/src/host.rs @@ -6,18 +6,31 @@ use super::{ }; pub(crate) struct Host { + pub(crate) event_name: String, pub(crate) fields: Arc>, + pub(crate) icons: Vec<(super::JsObject, u64)>, + pub(crate) objects: std::collections::HashMap<(String, Option, u64), super::JsObject>, + pub(crate) field_keys: std::collections::HashSet<(String, Option, u64)>, pub(crate) changed: BTreeSet, pub(crate) formatted: BTreeSet, pub(crate) alerts: Vec, pub(crate) console: Vec, pub(crate) requests: Vec, pub(crate) doc: DocInfo, - pub(crate) calculations: Arc>, + pub(crate) calculations: Vec<(usize, String)>, pub(crate) calculate_depth: usize, - pub(crate) calculate_steps: usize, + pub(crate) calculation_evaluations: usize, + pub(crate) calculation_limit_exceeded: bool, + pub(crate) calculation_work: usize, + pub(crate) current_event: JsValue, + pub(crate) reset_calls: usize, + pub(crate) reset_limit_exceeded: bool, /// Bytes of text kept in alerts, console lines and requests, against [`xfa::MAX_PAYLOAD`]. pub(crate) kept: usize, + /// Bytes of text the script has added to the fields' values and colours, net of what it + /// replaced, against [`xfa::MAX_PAYLOAD`]: each assignment alone is capped, so without this + /// many fields could each hold a maximal array. + pub(crate) retained: usize, /// Alerts, console lines and requests the limits left out. pub(crate) dropped: usize, } @@ -67,11 +80,13 @@ impl Host { Request::LaunchUrl(u) => Request::LaunchUrl(xfa::clip(u)), Request::Submit(u) => Request::Submit(xfa::clip(u)), Request::Focus(n) => Request::Focus(xfa::clip(n)), + Request::FocusField { name, object_id } => Request::FocusField { name: xfa::clip(name), object_id }, 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(), + Request::FocusField { name, .. } => name.len(), Request::OpenAttachment { name, .. } => name.len(), _ => 0, }; @@ -135,6 +150,38 @@ pub(crate) fn function(ctx: &mut Context, f: NativeFunction) -> JsFunction { FunctionObjectBuilder::new(ctx.realm(), f).build() } +/// Format exactly the escaped UTF-8 representation used by `text`, refusing a +/// write before it exceeds the remaining aggregate budget. No full Rust copy of +/// an oversized JavaScript string is made just to discover its byte length. +pub(crate) fn text_with_budget(v: &JsValue, ctx: &mut Context, remaining: usize, limit_error: &str) -> JsResult { + use std::fmt::Write; + if v.is_undefined() || v.is_null() { + return Ok(String::new()); + } + let js = v.to_string(ctx)?; + let exceeded = || error(limit_error); + // Every UTF-16 unit contributes at least one byte in the escaped UTF-8 output. + if js.len() > remaining { + return Err(exceeded()); + } + struct BoundedText { + text: String, + limit: usize, + } + impl std::fmt::Write for BoundedText { + fn write_str(&mut self, text: &str) -> std::fmt::Result { + if self.text.len().checked_add(text.len()).is_none_or(|len| len > self.limit) { + return Err(std::fmt::Error); + } + self.text.push_str(text); + Ok(()) + } + } + let mut out = BoundedText { text: String::new(), limit: remaining }; + write!(&mut out, "{}", js.display_escaped()).map_err(|_| exceeded())?; + Ok(out.text) +} + pub(crate) fn strings(v: &JsValue, ctx: &mut Context) -> JsResult> { if let Some(o) = v.as_object() && o.is_array() @@ -145,16 +192,23 @@ pub(crate) fn strings(v: &JsValue, ctx: &mut Context) -> JsResult> { return Err(error(format!("array length exceeds {MAX_ARRAY_LENGTH}"))); } let mut out = Vec::new(); + let mut remaining = xfa::MAX_PAYLOAD; for i in 0..n { let x = a.get(i, ctx)?; - out.push(text(&x, ctx)?); + let value = text_with_budget(&x, ctx, remaining, "string array byte limit exceeded")?; + remaining = remaining.saturating_sub(value.len()); + out.push(value); } return Ok(out); } - Ok(if v.is_undefined() || v.is_null() { Vec::new() } else { vec![text(v, ctx)?] }) + Ok(if v.is_undefined() || v.is_null() { + Vec::new() + } else { + vec![text_with_budget(v, ctx, xfa::MAX_PAYLOAD, "string array byte limit exceeded")?] + }) } -pub(crate) fn colour(c: &Option>, ctx: &mut Context) -> JsValue { +pub(crate) fn colour(c: Option<&[String]>, ctx: &mut Context) -> JsValue { match c { None => JsArray::from_iter([s("T")], ctx).into(), Some(parts) => { @@ -167,17 +221,29 @@ pub(crate) fn colour(c: &Option>, ctx: &mut Context) -> JsValue { // ── 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) type FieldKey = (JsString, Option, u64); + +/// PDF identities survive renames; name-only synthetic fields retain name lookup semantics. +pub(crate) fn field_cache_key(name: &str, id: Option, token: u64) -> (String, Option, u64) { + (if id.is_some() { String::new() } else { name.to_string() }, id, token) } -pub(crate) fn read(ctx: &Context, name: &JsString) -> JsResult { +fn field_index(ctx: &Context, name: &FieldKey) -> JsResult { + let n = name.0.to_std_string_escaped(); + host(ctx)? + .borrow() + .fields + .iter() + .position(|f| name.1.map_or(f.name == n, |id| f.object_id == Some(id) && f.instance_id == name.2)) + .ok_or_else(|| error(format!("no field named {n}"))) +} + +pub(crate) fn read(ctx: &Context, name: &FieldKey) -> 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<()> { +pub(crate) fn write(ctx: &Context, name: &FieldKey, 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"))?; @@ -200,10 +266,57 @@ pub(crate) fn value_of(f: &FieldState, ctx: &mut Context) -> JsValue { } } -pub(crate) fn set_value(f: &mut FieldState, vals: Vec) { - f.value = match f.kind { +/// Bytes of text in `v`, counting each string's heap size and its handle. +pub(crate) fn text_bytes(v: &[String]) -> usize { + v.iter().fold(0usize, |n, t| n.saturating_add(t.len()).saturating_add(std::mem::size_of::())) +} + +/// Charge the replacement of the text `old` selects from field `name` with `new_bytes` of text to +/// the run's retained-text budget; an assignment the budget has no room for is an error and +/// charges nothing. +pub(crate) fn charge(ctx: &Context, name: &FieldKey, old: impl FnOnce(&FieldState) -> usize, new_bytes: usize) -> JsResult<()> { + let i = field_index(ctx, name)?; + let mut h = host(ctx)?.borrow_mut(); + let freed = old(&h.fields[i]); + let total = h.retained.saturating_sub(freed).saturating_add(new_bytes); + if total > xfa::MAX_PAYLOAD { + return Err(error(format!("the fields' values and colours hold more than {} bytes of text", xfa::MAX_PAYLOAD))); + } + h.retained = total; + Ok(()) +} + +pub(crate) fn kind_of(ctx: &Context, name: &FieldKey) -> JsResult { + let i = field_index(ctx, name)?; + Ok(host(ctx)?.borrow().fields[i].kind) +} + +/// What a field of `kind` keeps of the values assigned to it. +pub(crate) fn value_for(kind: FieldType, vals: Vec) -> Vec { + match 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(), - }; + } +} + +pub(crate) fn set_value(f: &mut FieldState, vals: Vec) { + f.value = value_for(f.kind, vals); +} + +#[cfg(test)] +mod string_budget_review_tests { + use super::*; + #[test] + fn escaped_text_budget_counts_exact_utf8_bytes() { + let mut ctx = Context::default(); + for (script, bytes) in [("'ascii'", 5), ("'é'", 2), ("'😀'", 4), ("'\\uD800'", 6)] { + let value = ctx.eval(boa_engine::Source::from_bytes(script)).unwrap(); + let expected = text(&value, &mut ctx).unwrap(); + assert_eq!(expected.len(), bytes); + assert_eq!(text_with_budget(&value, &mut ctx, bytes, "text limit").unwrap(), expected); + assert!(text_with_budget(&value, &mut ctx, bytes - 1, "text limit").is_err()); + } + assert_eq!(text_with_budget(&JsValue::null(), &mut ctx, 0, "text limit").unwrap(), ""); + } } diff --git a/crates/js/src/icons.rs b/crates/js/src/icons.rs new file mode 100644 index 0000000000..f56ec9769f --- /dev/null +++ b/crates/js/src/icons.rs @@ -0,0 +1,59 @@ +//! Document-bound icon handles. Only object identities minted by this host are accepted. +use crate::host::{FieldKey, arg, error, host, read, write}; +use crate::{Attribute, Context, JsObject, JsResult, JsValue, ObjectInitializer}; + +/// Accept omitted/normal face zero only; reject unsupported icon states before touching a field. +fn normal_face(args: &[JsValue], index: usize, c: &mut Context) -> JsResult<()> { + if let Some(face) = args.get(index) + && !face.is_undefined() + && face.to_number(c)? != 0.0 + { + return Err(error("only normal icon face 0 is supported")); + } + Ok(()) +} +/// Return a context-minted normal-icon handle, reusing object identity and enforcing the handle limit. +pub(crate) fn get(_: &JsValue, args: &[JsValue], name: &FieldKey, c: &mut Context) -> JsResult { + normal_face(args, 0, c)?; + let f = read(c, name)?; + if f.kind != crate::FieldType::Button { + return Err(error("icons require a push button")); + } + let Some(id) = f.icon else { return Ok(JsValue::null()) }; + if let Some((obj, _)) = host(c)?.borrow().icons.iter().find(|(_, i)| *i == id) { + return Ok(obj.clone().into()); + } + if host(c)?.borrow().icons.len() >= 4096 { + return Err(error("too many icon handles")); + } + let obj = ObjectInitializer::new(c).property(crate::js_string!("name"), JsValue::null(), Attribute::READONLY).build(); + host(c)?.borrow_mut().icons.push((obj.clone(), id)); + Ok(obj.into()) +} +/// Stage a normal-icon copy/clear only for a handle minted in this context, refusing forged objects. +pub(crate) fn set(_: &JsValue, args: &[JsValue], name: &FieldKey, c: &mut Context) -> JsResult { + normal_face(args, 1, c)?; + if read(c, name)?.kind != crate::FieldType::Button { + return Err(error("icons require a push button")); + } + let value = arg(args, 0); + let id = if value.is_null() { + None + } else { + let object = value.as_object().ok_or_else(|| error("buttonSetIcon requires an icon from this document"))?; + Some( + host(c)? + .borrow() + .icons + .iter() + .find(|(o, _)| JsObject::equals(o, &object)) + .map(|(_, i)| *i) + .ok_or_else(|| error("buttonSetIcon requires an icon from this document"))?, + ) + }; + write(c, name, |f| { + f.icon = id; + f.icon_written = true; + })?; + Ok(JsValue::undefined()) +} diff --git a/crates/js/src/interaction.rs b/crates/js/src/interaction.rs new file mode 100644 index 0000000000..525acf930a --- /dev/null +++ b/crates/js/src/interaction.rs @@ -0,0 +1,203 @@ +//! Bounded synchronous script prompts. The worker waits; the UI never evaluates script text. +use crate::host::{arg, error, s, text_with_budget}; +use crate::{Context, JsArray, JsResult, JsValue, Outcome, js_string}; +use std::cell::RefCell; +use std::sync::mpsc::{self, Sender}; +#[derive(Clone, Debug, PartialEq)] +pub struct MenuItem { + pub name: String, + pub value: String, + pub enabled: bool, + pub marked: bool, + pub children: Vec, +} +#[derive(Clone, Debug, PartialEq)] +pub enum Prompt { + Menu(Vec), + Text { title: String, question: String, default: String, password: bool, label: String }, + Alert(crate::Message), +} +pub enum InteractiveEvent { + Prompt(Prompt, Sender>), + Complete(Outcome), +} +thread_local! { static HOST:RefCell,usize)>>=const {RefCell::new(None)}; } +/// Tell alert/menu helpers whether this worker has an interactive or deterministic reply channel. +pub(crate) fn has_host() -> bool { + HOST.with(|h| h.borrow().is_some()) +} +#[cfg(not(target_arch = "wasm32"))] +/// Install or clear the worker-local prompt channel and reset the per-invocation prompt budget. +pub(crate) fn set_host(host: Option>) { + HOST.with(|h| *h.borrow_mut() = host.map(|h| (h, 0))); +} +/// Send a bounded prompt and await its reply, treating disconnect/timeout as cancellation. +pub(crate) fn ask(prompt: Prompt) -> JsResult> { + let sender = HOST.with(|h| { + let mut h = h.borrow_mut(); + let Some((tx, n)) = h.as_mut() else { return Ok(None) }; + if *n >= 32 { + return Err(error("too many interactive prompts")); + } + *n += 1; + Ok(Some(tx.clone())) + })?; + let Some(sender) = sender else { return Ok(None) }; + let (tx, rx) = mpsc::channel(); + sender.send(InteractiveEvent::Prompt(prompt, tx)).map_err(|_| error("interactive host closed"))?; + let answer = rx.recv_timeout(std::time::Duration::from_secs(300)).ok().flatten(); + if answer.as_ref().is_some_and(|s| s.len() > 4096) { + return Err(error("interactive response exceeds its limit")); + } + Ok(answer) +} +/// Reject prompt text before copying beyond the public per-property byte limit. +fn menu_text(value: &JsValue, ctx: &mut Context) -> JsResult { + text_with_budget(value, ctx, 4096, "popup menu text exceeds its limit") +} +fn response_text(value: &JsValue, ctx: &mut Context) -> JsResult { + text_with_budget(value, ctx, 4096, "response dialog text exceeds its limit") +} +/// Parse one menu subtree with shared entry/depth/string budgets before exposing it to a host. +fn read_menu(value: &JsValue, ctx: &mut Context, depth: usize, remaining: &mut usize) -> JsResult { + if depth > 16 || *remaining == 0 { + return Err(error("popup menu exceeds its size or depth limit")); + } + *remaining -= 1; + let obj = value.as_object().ok_or_else(|| error("popup menu item must be an object"))?; + let name = menu_text(&obj.get(js_string!("cName"), ctx)?, ctx)?; + let ret = obj.get(js_string!("cReturn"), ctx)?; + let value = if ret.is_undefined() { name.clone() } else { menu_text(&ret, ctx)? }; + let enabled = obj.get(js_string!("bEnabled"), ctx)?; + let marked = obj.get(js_string!("bMarked"), ctx)?.to_boolean(); + let sub = obj.get(js_string!("oSubMenu"), ctx)?; + let mut children = Vec::new(); + if let Some(o) = sub.as_object() { + if o.is_array() { + let array = JsArray::from_object(o.clone())?; + let len = array.length(ctx)?; + if len > *remaining as u64 { + return Err(error("popup menu has too many entries")); + } + for i in 0..len { + children.push(read_menu(&array.get(i, ctx)?, ctx, depth + 1, remaining)?); + } + } else { + children.push(read_menu(&sub, ctx, depth + 1, remaining)?); + } + } else if !sub.is_undefined() && !sub.is_null() { + return Err(error("invalid popup submenu")); + } + Ok(MenuItem { name, value, enabled: enabled.is_undefined() || enabled.to_boolean(), marked, children }) +} + +/// Accept only a matching enabled leaf whose ancestors are also enabled, excluding separators. +fn allowed(items: &[MenuItem], value: &str) -> bool { + items.iter().any(|i| i.enabled && i.name != "-" && if i.children.is_empty() { i.value == value } else { allowed(&i.children, value) }) +} +/// Parse and display a bounded popup, returning null on cancel and refusing invalid host choices. +pub(crate) fn popup_menu(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let mut remaining = 1024; + let mut items = Vec::new(); + for a in args { + items.push(read_menu(a, ctx, 0, &mut remaining)?); + } + match ask(Prompt::Menu(items.clone()))? { + Some(answer) if allowed(&items, &answer) => Ok(s(&answer)), + Some(_) => Err(error("invalid popup menu selection")), + None => Ok(JsValue::null()), + } +} +/// Read named or positional input parameters and return bounded text or null on cancellation. +pub(crate) fn response(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let a = arg(args, 0); + let (question, title, default, password, label) = if let Some(o) = a.as_object() { + ( + response_text(&o.get(js_string!("cQuestion"), ctx)?, ctx)?, + response_text(&o.get(js_string!("cTitle"), ctx)?, ctx)?, + response_text(&o.get(js_string!("cDefault"), ctx)?, ctx)?, + o.get(js_string!("bPassword"), ctx)?.to_boolean(), + response_text(&o.get(js_string!("cLabel"), ctx)?, ctx)?, + ) + } else { + ( + response_text(&a, ctx)?, + response_text(&arg(args, 1), ctx)?, + response_text(&arg(args, 2), ctx)?, + arg(args, 3).to_boolean(), + response_text(&arg(args, 4), ctx)?, + ) + }; + Ok(ask(Prompt::Text { question, title, default, password, label })?.map_or_else(JsValue::null, |v| s(&v))) +} + +#[cfg(test)] +mod budget_tests { + use super::*; + /// Reject an oversized question before evaluating or copying any later property. + #[test] + fn response_refuses_text_before_reading_later_properties() { + let mut ctx = Context::default(); + let input = ctx.eval(crate::Source::from_bytes("({cQuestion:'x'.repeat(4097),get cTitle(){throw Error('later property read');}})")).unwrap(); + let error = response(&JsValue::undefined(), &[input], &mut ctx).unwrap_err().to_string(); + assert!(error.contains("response dialog text exceeds its limit"), "{error}"); + assert!(!error.contains("later property")); + } + /// Menu labels must fail before an oversized return value's getter is invoked. + #[test] + fn menu_refuses_name_before_reading_return_value() { + let mut ctx = Context::default(); + let input = ctx.eval(crate::Source::from_bytes("({cName:'x'.repeat(4097),get cReturn(){throw Error('later property read');}})")).unwrap(); + let mut remaining = 1024; + let error = read_menu(&input, &mut ctx, 0, &mut remaining).unwrap_err().to_string(); + assert!(error.contains("popup menu text exceeds its limit"), "{error}"); + assert!(!error.contains("later property")); + } +} + +#[cfg(test)] +mod text_boundary_tests { + use super::*; + #[test] + fn prompt_conversions_preserve_exact_unicode_and_user_exceptions() { + let mut ctx = Context::default(); + for (unit, count) in [("a", 4096), ("é", 2048), ("😀", 1024)] { + let text = unit.repeat(count); + let value = s(&text); + assert_eq!(menu_text(&value, &mut ctx).unwrap(), text); + assert_eq!(response_text(&value, &mut ctx).unwrap(), text); + assert!(menu_text(&s(&(text.clone() + "x")), &mut ctx).is_err()); + assert!(response_text(&s(&(text + "x")), &mut ctx).is_err()); + } + let surrogate = ctx.eval(crate::Source::from_bytes("'\\uD800'.repeat(682)+'xxxx'")).unwrap(); + assert_eq!(response_text(&surrogate, &mut ctx).unwrap().len(), 4096); + let too_many = ctx.eval(crate::Source::from_bytes("'\\uD800'.repeat(683)")).unwrap(); + assert!(response_text(&too_many, &mut ctx).is_err()); + let throwing = ctx.eval(crate::Source::from_bytes("({toString(){throw Error('original conversion failure');}})")).unwrap(); + assert!(response_text(&throwing, &mut ctx).unwrap_err().to_string().contains("original conversion failure")); + assert_eq!(response_text(&JsValue::null(), &mut ctx).unwrap(), ""); + } + #[test] + fn all_prompt_text_properties_and_positional_arguments_are_bounded() { + let mut ctx = Context::default(); + for key in ["cQuestion", "cTitle", "cDefault", "cLabel"] { + let script = format!("({{{key}:'x'.repeat(4097)}})"); + let input = ctx.eval(crate::Source::from_bytes(script.as_bytes())).unwrap(); + assert!(response(&JsValue::undefined(), &[input], &mut ctx).unwrap_err().to_string().contains("response dialog text exceeds its limit")); + } + for index in [0, 1, 2, 4] { + let mut args = vec![JsValue::undefined(); 5]; + args[index] = s(&"x".repeat(4097)); + assert!(response(&JsValue::undefined(), &args, &mut ctx).is_err()); + } + let input = ctx.eval(crate::Source::from_bytes("({cName:'ok',cReturn:'x'.repeat(4097)})")).unwrap(); + assert!(read_menu(&input, &mut ctx, 0, &mut 1024).is_err()); + // Four independently valid text properties remain supported; no new shared 4096-byte cap. + let input = ctx + .eval(crate::Source::from_bytes( + "({cQuestion:'x'.repeat(4096),cTitle:'x'.repeat(4096),cDefault:'x'.repeat(4096),cLabel:'x'.repeat(4096)})", + )) + .unwrap(); + assert!(response(&JsValue::undefined(), &[input], &mut ctx).unwrap().is_null()); + } +} diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs index 1c0fe24f7b..87bb881e13 100644 --- a/crates/js/src/lib.rs +++ b/crates/js/src/lib.rs @@ -12,11 +12,14 @@ //! - Field objects: `name`, `type`, `value`, `valueAsString`, `defaultValue`, `readonly`, //! `required`, `display`, `hidden`, `numItems`, `getItemAt`, `currentValueIndices`, //! `isBoxChecked`, `checkThisBox`, `exportValues`, `charLimit`, `textColor`, `fillColor`, -//! `setFocus`. +//! `setFocus`, `rect`, appearance properties, and normal button caption/icon methods. //! - `app` (`alert`, `beep`, `response`, `launchURL`, `execMenuItem`, `viewerType`, `viewerVersion`, //! `platform`, `setTimeOut`), `util` (`printf`, `printd`, `printx`, `scand`), `console` (`println`, //! `show`, `clear`), `display` and `color`. //! +//! The native engine uses [`session::ScriptSession`] to preserve document globals across events. +//! Stateless runners remain available for callers that need isolation per invocation. +//! //! The engine is a sandbox: no file, network or timer access exists, and loops and recursion //! are bounded. Side effects (alerts, field changes, resets, printing, navigation) are returned //! in an [`Outcome`] for the caller to apply. @@ -39,7 +42,9 @@ mod format; mod globals; mod host; mod model; +mod nesting; mod run; +mod source; #[cfg(test)] pub(crate) use format::PRINTD_SCANNED; @@ -56,7 +61,7 @@ pub use model::{ use run::SCRIPT_STACK; use run::refuse; pub(crate) use run::{CheckedCodeFromStrings, MAX_SCRIPT_BYTES}; -pub use run::{run, run_shared, run_shared_calculations, run_shared_calculations_shared}; +pub use run::{run, run_shared, run_shared_calculations}; pub mod formcalc; pub mod xfa; @@ -64,3 +69,14 @@ pub mod xfa_session; #[cfg(test)] mod tests; + +mod geometry; +pub use geometry::{rotate_rect, unrotate_rect}; + +mod icons; + +pub mod interaction; +pub mod session; + +/// Maximum reset calls admitted by one document invocation, before argument conversion or field scans. +pub const MAX_RESET_CALLS: usize = 64; diff --git a/crates/js/src/model.rs b/crates/js/src/model.rs index 6314adbfa2..b162007420 100644 --- a/crates/js/src/model.rs +++ b/crates/js/src/model.rs @@ -37,11 +37,17 @@ pub const DISPLAY_NO_VIEW: i32 = 3; #[derive(Clone, Debug, PartialEq)] pub struct FieldState { pub name: String, + /// Stable PDF object identity, absent for synthetic name-only callers. + pub object_id: Option, + /// Runtime instance token; retired field references cannot alias reused PDF numbers. + pub instance_id: u64, 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, + /// False when a PDF default stream was not decoded; reset must preserve the live value. + pub default_known: bool, pub readonly: bool, pub required: bool, pub display: i32, @@ -50,19 +56,42 @@ pub struct FieldState { 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>, - /// Rectangle of the first widget, in PDF user space. + pub text_color: Option>, + pub fill_color: Option>, + /// Normal push-button caption, when this is a button. + pub caption: Option, + /// A setter ran, even if its text equals widget 0: other widgets may differ. + pub caption_written: bool, pub rect: Option<[f64; 4]>, + pub page_rotation: i64, + pub page_crop: [f64; 4], + pub stroke_color: Option>, + pub text_size: f64, + pub line_width: f64, + pub border_style: String, + pub user_name: String, + pub icon: Option, + pub caption_mixed: bool, + pub icon_mixed: bool, + pub icon_written: bool, + /// JavaScript actions a script installed with `setAction` during this run, by trigger + /// (`MouseUp`, `MouseDown`, `MouseEnter`, `MouseExit`, `OnFocus`, `OnBlur`, `Keystroke`, + /// `Validate`, `Calculate`, `Format`), the last one for each trigger. Empty in a snapshot: + /// the host reads the document's own actions from the PDF. + pub actions: Vec<(String, String)>, } impl FieldState { + /// Construct a field snapshot with explicit defaults for optional appearance and interaction state. pub fn new(name: impl Into, kind: FieldType, value: Vec) -> FieldState { FieldState { name: name.into(), + object_id: None, + instance_id: 0, kind, value, default: Vec::new(), + default_known: true, readonly: false, required: false, display: DISPLAY_VISIBLE, @@ -70,7 +99,21 @@ impl FieldState { char_limit: None, text_color: None, fill_color: None, + caption: None, + caption_written: false, rect: None, + page_rotation: 0, + page_crop: [0.0; 4], + stroke_color: None, + text_size: 0.0, + line_width: 1.0, + border_style: "solid".into(), + user_name: String::new(), + icon: None, + caption_mixed: false, + icon_mixed: false, + icon_written: false, + actions: Vec::new(), } } @@ -88,6 +131,8 @@ pub struct Event { pub kind: String, /// The field the event is for. pub target: Option, + /// Identity for an unambiguous event.target, even with duplicate field names. + pub target_id: Option, pub value: String, pub change: String, pub will_commit: bool, @@ -96,18 +141,38 @@ pub struct Event { 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 } + Event { + name: name.into(), + kind: "Field".into(), + target: Some(target.into()), + target_id: None, + 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 } + Event { + name: name.into(), + kind: "Doc".into(), + target: None, + target_id: 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 { + /// Changes when history can replace an icon object under the same PDF reference. + pub icon_epoch: u64, + /// Revisions of referenced icons that can change independently across history. + pub icon_versions: std::sync::Arc>, pub file_name: String, pub num_pages: usize, /// The current page, 0-based. @@ -118,10 +183,10 @@ pub struct DocInfo { pub page_boxes: Vec<[[f64; 4]; 5]>, /// Clockwise page rotations, normalized to 0, 90, 180 or 270 degrees. pub page_rotations: Vec, - /// Whether the viewer highlights form fields (`app.runtimeHighlight`). - pub highlight: bool, /// The document has an XFA form, so scripts see `xfa_installed`. pub xfa: bool, + pub zoom: f64, + pub runtime_highlight: bool, } /// The icon a script's message shows: `app.alert`'s `nIcon` and `xfa.host.messageBox`'s @@ -242,7 +307,14 @@ pub enum Request { Submit(String), /// `field.setFocus()`. Focus(String), + /// Focus a specific AcroForm field when several fields share its name. + FocusField { + name: String, + object_id: u64, + }, Beep, + Zoom(f64), + Highlight(bool), /// `app.execMenuItem("SaveAs")` (XFA forms' Save buttons). SaveAs, /// `this.exportDataObject(cName, …, nLaunch)`: the attached file `name`, to open (`nLaunch` @@ -251,8 +323,6 @@ pub enum Request { name: String, open: bool, }, - /// `app.runtimeHighlight = on`: highlight the form's fields, or stop. - Highlight(bool), /// `app.execMenuItem("ShowHide…")`: show the side panel, or hide it when it is showing. ShowPanel(Panel), } @@ -288,6 +358,14 @@ impl Panel { /// What running a script did. #[derive(Clone, Debug, Default, PartialEq)] pub struct Outcome { + /// The runtime could not execute the callback safely. + pub execution_refused: bool, + /// Execution was refused before invoking any code in the retained realm. + pub realm_untouched: bool, + /// Sticky admission failure: callers must not commit a partial run, even if JS caught the error. + pub reset_limit_exceeded: bool, + /// Calculation exhaustion rejects script writeback even if the script catches the error. + pub calculation_limit_exceeded: bool, /// `event.rc`: `false` rejects the keystroke or value. pub rc: bool, /// `event.value` after the script. diff --git a/crates/js/src/nesting.rs b/crates/js/src/nesting.rs new file mode 100644 index 0000000000..9f99c5351a --- /dev/null +++ b/crates/js/src/nesting.rs @@ -0,0 +1,319 @@ +//! The nesting guard: whether a script nests deeper than boa's parser can take. +//! +//! boa parses by recursive descent with no depth limit of its own, and a stack overflow aborts +//! the process (it is not a panic, so `catch_unwind` can't stop it). So every script is scanned +//! before boa sees it, and one that nests too deeply is refused. +//! +//! A scan has to know which brackets are code, and that depends on where strings, templates, +//! comments and regular expressions begin and end. Most of that follows from the bytes, but a +//! `/` that isn't a comment is a division or the start of a regular expression depending on +//! where the parser is, which a scan can't know. A scan that guesses can be put out of phase: +//! after `/'/`, the quote in the regular expression made the real nesting that followed look +//! like string text (#5079). So the scan doesn't guess. It follows every reading at once, +//! splitting at each such `/` into a division and a regular expression, and counts nesting on +//! each reading. boa's reading is always one of them, so the deepest is at least as deep as +//! boa's. A reading boa would reject is dropped at the byte where boa stops with a syntax error +//! (a string or regular expression running into a line break, a regular expression's groups not +//! matching), which keeps divisions from leaving stray readings behind. + +/// Deepest nesting of `(`, `[`, `{` and template substitutions (`${`). +pub(crate) const MAX_BRACKET_DEPTH: usize = 64; + +/// Longest run of prefix operators (`!`, `~`, `+`, `-`). +pub(crate) const MAX_PREFIX_RUN: usize = 64; + +/// Most `?` (conditional expressions nest without brackets). +pub(crate) const MAX_CONDITIONALS: usize = 4096; + +/// Most readings followed at once. Readings that differ only in their counts are merged first, +/// keeping the larger counts; past this, the script is refused. +const MAX_READINGS: usize = 16; + +/// Where a reading is in the script. +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug)] +enum Mode { + Code, + /// In a string that this quote closes. + Str(u8), + /// In a template's text: after its backtick, or after a substitution's closing `}`. + Template, + /// In a regular expression literal: in a class (`[...]`), and the groups open outside one. + Regex { + class: bool, + groups: usize, + }, + /// In a `//`, `` comment, to the end of the line. + LineComment, + /// In a `/* */` comment. + BlockComment, +} + +/// One way of reading the script, and what it has counted so far. +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Debug)] +struct Reading { + mode: Mode, + /// Bytes to pass over: the rest of a two-byte token, or what a backslash escapes. + skip: usize, + /// For each open template substitution, the braces open inside it, so the `}` that closes + /// the substitution is told from one that closes a block. + substitutions: Vec, + depth: usize, + prefix: usize, + conditionals: usize, +} + +impl Reading { + fn in_code() -> Self { + Self { mode: Mode::Code, skip: 0, substitutions: Vec::new(), depth: 0, prefix: 0, conditionals: 0 } + } + + fn with_mode(&self, mode: Mode) -> Self { + Self { mode, ..self.clone() } + } + + fn over_limit(&self) -> bool { + self.depth > MAX_BRACKET_DEPTH || self.prefix > MAX_PREFIX_RUN || self.conditionals > MAX_CONDITIONALS + } + + fn open(&mut self) { + self.depth = self.depth.saturating_add(1); + self.prefix = 0; + } + + fn close(&mut self) { + self.depth = self.depth.saturating_sub(1); + self.prefix = 0; + } + + /// Read byte `i` of `b`. A second reading that splits off here goes to `forks`. Returns + /// false when boa would stop with a syntax error at this byte, ending this reading. + fn advance(&mut self, b: &[u8], i: usize, forks: &mut Vec) -> bool { + if self.skip > 0 { + self.skip -= 1; + return true; + } + let Some(&c) = b.get(i) else { return true }; + match self.mode { + Mode::Code => { + self.code(c, b, i, forks); + true + } + Mode::Str(quote) => self.string(quote, c, b, i), + Mode::Template => { + self.template(c, b, i); + true + } + Mode::Regex { class, groups } => self.regex(class, groups, c, b, i), + Mode::LineComment => { + let n = line_break(b, i); + if n > 0 { + self.mode = Mode::Code; + self.skip = n - 1; + } + true + } + Mode::BlockComment => { + if c == b'*' && at(b, i + 1, b"/") { + self.mode = Mode::Code; + self.skip = 1; + } + true + } + } + } + + fn code(&mut self, c: u8, b: &[u8], i: usize, forks: &mut Vec) { + match c { + b'\'' | b'"' => { + self.mode = Mode::Str(c); + self.prefix = 0; + } + b'`' => { + self.mode = Mode::Template; + self.prefix = 0; + } + b'/' if at(b, i + 1, b"/") => self.mode = Mode::LineComment, + b'/' if at(b, i + 1, b"*") => { + self.mode = Mode::BlockComment; + self.skip = 1; + } + b'/' => { + // A division, or the start of a regular expression: the parser decides, so + // follow both. + forks.push(self.with_mode(Mode::Regex { class: false, groups: 0 })); + self.prefix = 0; + } + b'(' | b'[' => self.open(), + b'{' => { + if let Some(open) = self.substitutions.last_mut() { + *open = open.saturating_add(1); + } + self.open(); + } + b')' | b']' => self.close(), + b'}' => { + self.close(); + if self.substitutions.last() == Some(&0) { + self.substitutions.pop(); + self.mode = Mode::Template; + } else if let Some(open) = self.substitutions.last_mut() { + *open = open.saturating_sub(1); + } + } + b'!' | b'~' | b'+' | b'-' => { + // `-->` begins a comment at the start of a line (Annex B); followed anywhere. + if c == b'-' && at(b, i + 1, b"->") { + forks.push(self.with_mode(Mode::LineComment)); + } + self.prefix = self.prefix.saturating_add(1); + } + b'?' => { + self.conditionals = self.conditionals.saturating_add(1); + self.prefix = 0; + } + // ` 0;