diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 6b4999a8..bbb07e68 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -4,13 +4,15 @@ Every asset PrintCraft includes, bundles or uses to build its published material, with its author, source and licence. The policy is in [AGENTS.md](AGENTS.md) §1. The machine-readable list, with SHA-256 hashes, is [ATTRIBUTION.toml](ATTRIBUTION.toml). Licence texts are kept beside the assets and summarised in [NOTICE](NOTICE). -## In this repository (239) +## In this repository (241) | Asset | Title | Author | Licence | Source | Used for | |---|---|---|---|---|---| | `assets/icons/accessibility.svg` | Lucide icon "accessibility" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/accessibility.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/align-left.svg` | Lucide icon "align-left" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-left.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/align-center.svg` | Lucide icon "align-center" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-center.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/square-terminal.svg` | Lucide icon "square-terminal" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/square-terminal.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/file-code.svg` | Lucide icon "file-code" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-code.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/align-justify.svg` | Lucide icon "align-justify" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-justify.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/align-right.svg` | Lucide icon "align-right" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-right.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/columns-3.svg` | Lucide icon "columns-3" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/columns-3.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index e997e074..1055960c 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -41,6 +41,28 @@ kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" sha256 = "bc70fba645343be0760d1a87f9e0975a10ead2b8d95883314f4cea007376e79f" +[[asset]] +path = "assets/icons/square-terminal.svg" +title = "Lucide icon \"square-terminal\"" +author = "Lucide Contributors (portions Cole Bemis, Feather)" +source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/square-terminal.svg)" +licence = "ISC" +licence_file = "assets/icons/LICENSE-lucide.txt" +kind = "icon" +usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" +sha256 = "023f88e71f9e3efad5904bf35d59657aece6b7e48bf77051e04dd8a3192b8278" + +[[asset]] +path = "assets/icons/file-code.svg" +title = "Lucide icon \"file-code\"" +author = "Lucide Contributors (portions Cole Bemis, Feather)" +source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-code.svg)" +licence = "ISC" +licence_file = "assets/icons/LICENSE-lucide.txt" +kind = "icon" +usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" +sha256 = "a52ebec6ffe56b062b3a7bc11a844be424555694736da11a4654481fb5668493" + [[asset]] path = "assets/icons/align-justify.svg" title = "Lucide icon \"align-justify\"" diff --git a/Cargo.lock b/Cargo.lock index 9a250ec1..e1b826ff 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -22,7 +22,7 @@ dependencies = [ "accesskit", "accesskit_consumer 0.36.0", "atspi-common", - "phf", + "phf 0.13.1", "serde", "zvariant", ] @@ -148,6 +148,15 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "aligned-vec" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" +dependencies = [ + "equator", +] + [[package]] name = "alloc-no-stdlib" version = "2.0.4" @@ -571,6 +580,150 @@ dependencies = [ "piper", ] +[[package]] +name = "boa_ast" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9221b0a090572b4a118b46c5874c0781fe4bc9546b85f0a615b8bfb609ca4ed7" +dependencies = [ + "bitflags 2.13.2", + "boa_interner", + "boa_macros", + "boa_string", + "indexmap", + "num-bigint", + "rustc-hash 2.1.3", + "strum", +] + +[[package]] +name = "boa_engine" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d6aaf7fdc4299df68adb67e1a5440a51d34630a1f5271b9698fd74bc97b60d9" +dependencies = [ + "aligned-vec", + "arrayvec", + "async-channel", + "bitflags 2.13.2", + "boa_ast", + "boa_gc", + "boa_interner", + "boa_macros", + "boa_parser", + "boa_string", + "bytemuck", + "cfg-if", + "cow-utils", + "dashmap", + "dynify", + "fast-float2", + "futures-concurrency", + "futures-lite", + "getrandom 0.4.3", + "hashbrown 0.17.1", + "icu_normalizer", + "indexmap", + "intrusive-collections", + "itertools", + "num-bigint", + "num-integer", + "num-traits", + "num_enum", + "oneshot", + "pastey", + "portable-atomic", + "rand 0.10.3", + "regress", + "rustc-hash 2.1.3", + "ryu-js", + "serde", + "serde_json", + "small_btree", + "static_assertions", + "tag_ptr", + "tap", + "thin-vec", + "thiserror 2.0.21", + "time", + "web-time", +] + +[[package]] +name = "boa_gc" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af82119558ab1002d1978c8459b53a3ebb506177d29a3aca37c07c1777b1d966" +dependencies = [ + "arrayvec", + "boa_macros", + "boa_string", + "hashbrown 0.17.1", + "thin-vec", +] + +[[package]] +name = "boa_interner" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "317c631e791f5cb7d0017e43c0a758b0eaffd40c34c4c360e2a6be7752b78cd1" +dependencies = [ + "boa_gc", + "boa_macros", + "hashbrown 0.17.1", + "indexmap", + "once_cell", + "phf 0.14.0", + "rustc-hash 2.1.3", + "static_assertions", +] + +[[package]] +name = "boa_macros" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b882fd440dc0f4b7441367cf89567bb077920356750c9a76a16c002d8ec2fa5c" +dependencies = [ + "cfg-if", + "cow-utils", + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + +[[package]] +name = "boa_parser" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "641e81b3c8cbb7a2140547fb3ea534e9edf210b4e8c920b5fa864a919e11fbcf" +dependencies = [ + "bitflags 2.13.2", + "boa_ast", + "boa_interner", + "boa_macros", + "fast-float2", + "icu_properties", + "num-bigint", + "num-traits", + "regress", + "rustc-hash 2.1.3", +] + +[[package]] +name = "boa_string" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30bf86c2bcff5bf2547acbfc516c5d3108ca527cb131511d749bf6fbef762c0e" +dependencies = [ + "fast-float2", + "itoa", + "pastey", + "rustc-hash 2.1.3", + "ryu-js", + "static_assertions", +] + [[package]] name = "brotli" version = "8.0.4" @@ -886,6 +1039,12 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" +[[package]] +name = "cow-utils" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "417bef24afe1460300965a25ff4a24b8b45ad011948302ec221e8a0a81eb2c79" + [[package]] name = "cpubits" version = "0.1.1" @@ -995,6 +1154,20 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" +[[package]] +name = "dashmap" +version = "6.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + [[package]] name = "data-url" version = "0.3.2" @@ -1012,6 +1185,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + [[package]] name = "des" version = "0.9.0" @@ -1098,6 +1277,26 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "dynify" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81acb15628a3e22358bf73de5e7e62360b8a777dbcb5fc9ac7dfa9ae73723747" +dependencies = [ + "dynify-macros", +] + +[[package]] +name = "dynify-macros" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ec431cd708430d5029356535259c5d645d60edd3d39c54e5eea9782d46caa7d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "ecb" version = "0.2.1" @@ -1423,6 +1622,26 @@ version = "0.36.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773fa9c96dd0dbef887e39d0ed6177f141cce4d68e6041df77570aa3702dfa13" +[[package]] +name = "equator" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" +dependencies = [ + "equator-macro", +] + +[[package]] +name = "equator-macro" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -1474,6 +1693,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fast-float2" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6e8948ce679d00a02a94739ea185595dca7118ed04feb991127e443bd3d761f" + [[package]] name = "fastrand" version = "2.5.0" @@ -1523,6 +1748,12 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + [[package]] name = "flatbuffers" version = "24.12.23" @@ -1623,6 +1854,19 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" +[[package]] +name = "futures-concurrency" +version = "7.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175cd8cca9e1d45b87f18ffa75088f2099e3c4fe5e2f83e42de112560bea8ea6" +dependencies = [ + "fixedbitset", + "futures-core", + "futures-lite", + "pin-project", + "smallvec", +] + [[package]] name = "futures-core" version = "0.3.34" @@ -1904,6 +2148,12 @@ dependencies = [ "smallvec", ] +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + [[package]] name = "hashbrown" version = "0.16.1" @@ -1921,6 +2171,8 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ + "allocator-api2", + "equivalent", "foldhash", ] @@ -1961,7 +2213,7 @@ dependencies = [ "hayro-syntax", "kurbo 0.13.1", "moxcms", - "phf", + "phf 0.13.1", "rustc-hash 2.1.3", "siphasher", "skrifa 0.42.1", @@ -2008,6 +2260,12 @@ dependencies = [ "zune-jpeg", ] +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "hermit-abi" version = "0.5.3" @@ -2102,6 +2360,8 @@ dependencies = [ "icu_properties", "icu_provider", "smallvec", + "utf16_iter", + "write16", "zerovec", ] @@ -2212,6 +2472,12 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "intrusive-collections" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4275b20e6057cd7733fd8df8a5a31701e4fe44497dad0f3fa0e1c4fb971506be" + [[package]] name = "itertools" version = "0.15.0" @@ -2617,6 +2883,32 @@ dependencies = [ "memchr", ] +[[package]] +name = "num-bigint" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93e7820bc0a80a0238e650327316f929ba18d5be054b647490a3a6a339f3e7c0" +dependencies = [ + "num-integer", + "num-traits", + "serde", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2659,6 +2951,15 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + [[package]] name = "objc-sys" version = "0.3.5" @@ -2991,6 +3292,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "oneshot" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe21416a02c693fb9f980befcb230ecc70b0b3d1cc4abf88b9675c4c1457f0c" + [[package]] name = "orbclient" version = "0.3.55" @@ -3076,6 +3383,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "pastey" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" + [[package]] name = "pbkdf2" version = "0.13.0" @@ -3120,11 +3433,21 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" dependencies = [ - "phf_macros", - "phf_shared", + "phf_macros 0.13.1", + "phf_shared 0.13.1", "serde", ] +[[package]] +name = "phf" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6" +dependencies = [ + "phf_macros 0.14.0", + "phf_shared 0.14.0", +] + [[package]] name = "phf_generator" version = "0.13.1" @@ -3132,7 +3455,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" dependencies = [ "fastrand", - "phf_shared", + "phf_shared 0.13.1", +] + +[[package]] +name = "phf_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100" +dependencies = [ + "fastrand", + "phf_shared 0.14.0", ] [[package]] @@ -3141,8 +3474,21 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" dependencies = [ - "phf_generator", - "phf_shared", + "phf_generator 0.13.1", + "phf_shared 0.13.1", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_macros" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fa8d0ca26d424d27630da600c6624696e7dec8bf7b3b492b383c5dc49e5e085" +dependencies = [ + "phf_generator 0.14.0", + "phf_shared 0.14.0", "proc-macro2", "quote", "syn 2.0.119", @@ -3157,6 +3503,15 @@ dependencies = [ "siphasher", ] +[[package]] +name = "phf_shared" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6fd9027e2d9319be6349febd1db4e8d02aa544921200c9b777720ac34a3aa89" +dependencies = [ + "siphasher", +] + [[package]] name = "pic-scale" version = "0.7.12" @@ -3327,6 +3682,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -3497,6 +3858,7 @@ dependencies = [ "printcraft-edit", "printcraft-fonts", "printcraft-forms", + "printcraft-js", "printcraft-model", "printcraft-ocr", "printcraft-optimize", @@ -3541,6 +3903,13 @@ dependencies = [ name = "printcraft-geom" version = "0.1.0" +[[package]] +name = "printcraft-js" +version = "0.1.0" +dependencies = [ + "boa_engine", +] + [[package]] name = "printcraft-model" version = "0.1.0" @@ -3951,6 +4320,15 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "regress" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32eef8b209c3c1c15dbad02c1f30f9539f00dc7253e0cbcdaae442a50a09d7c1" +dependencies = [ + "memchr", +] + [[package]] name = "renderdoc-sys" version = "1.1.0" @@ -4229,6 +4607,12 @@ dependencies = [ "wait-timeout", ] +[[package]] +name = "ryu-js" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04d056b875a9d2e6cb9a61d127afee9ac5999b9f87bcb32079d1318e505be714" + [[package]] name = "same-file" version = "1.0.6" @@ -4492,6 +4876,15 @@ dependencies = [ "version_check", ] +[[package]] +name = "small_btree" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ba60d2df92ba73864714808ca68c059734853e6ab722b40e1cf543ebb3a057a" +dependencies = [ + "arrayvec", +] + [[package]] name = "smallvec" version = "1.16.2" @@ -4624,6 +5017,27 @@ dependencies = [ "unicode-properties", ] +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "subtle" version = "2.6.1" @@ -4673,6 +5087,18 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "tag_ptr" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0e973b34477b7823833469eb0f5a3a60370fef7a453e02d751b59180d0a5a05" + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + [[package]] name = "tempfile" version = "3.27.0" @@ -4695,6 +5121,12 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "thin-vec" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4568d7e143ec86d2021c338bae2afa88699e84b8e0af523626654fe6f03a1748" + [[package]] name = "thiserror" version = "1.0.69" @@ -4749,6 +5181,39 @@ dependencies = [ "zune-jpeg", ] +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "js-sys", + "libc", + "num-conv", + "num_threads", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tiny-skia" version = "0.11.4" @@ -5000,6 +5465,12 @@ dependencies = [ "xmlwriter", ] +[[package]] +name = "utf16_iter" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -5919,6 +6390,12 @@ dependencies = [ "primefield", ] +[[package]] +name = "write16" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" + [[package]] name = "writeable" version = "0.6.4" diff --git a/Cargo.toml b/Cargo.toml index da90424a..c1c7a318 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,6 +29,7 @@ printcraft-sign = { path = "crates/sign", version = "0.1.0" } printcraft-optimize = { path = "crates/optimize", version = "0.1.0" } printcraft-a11y = { path = "crates/a11y", version = "0.1.0" } printcraft-ocr = { path = "crates/ocr", version = "0.1.0" } +printcraft-js = { path = "crates/js", version = "0.1.0" } printcraft-edit = { path = "crates/edit", version = "0.1.0" } printcraft-create = { path = "crates/create", version = "0.1.0" } printcraft-forms = { path = "crates/forms", version = "0.1.0" } diff --git a/assets/icons/file-code.svg b/assets/icons/file-code.svg new file mode 100644 index 00000000..a45a145e --- /dev/null +++ b/assets/icons/file-code.svg @@ -0,0 +1,18 @@ + + + + + + + diff --git a/assets/icons/square-terminal.svg b/assets/icons/square-terminal.svg new file mode 100644 index 00000000..d5a21ef7 --- /dev/null +++ b/assets/icons/square-terminal.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/crates/automation/src/a11y.rs b/crates/automation/src/a11y.rs index 8254f519..6a7f1009 100644 --- a/crates/automation/src/a11y.rs +++ b/crates/automation/src/a11y.rs @@ -200,3 +200,56 @@ impl Automation { Ok(json!({ "available": ocr::available(), "search_dirs": dirs, "languages": langs })) } } + +fn request_json(r: &printcraft_engine::js::Request) -> Value { + use printcraft_engine::js::Request as R; + match r { + R::Reset(n) => json!({ "reset": n }), + R::Print => json!({ "print": true }), + R::GoToPage(p) => json!({ "page": p + 1 }), + R::LaunchUrl(u) => json!({ "url": u }), + R::Submit(u) => json!({ "submit": u }), + R::Focus(f) => json!({ "focus": f }), + R::Beep => json!({ "beep": true }), + } +} + +impl Automation { + pub(crate) fn js_run(&mut self, a: &Args) -> Result { + let id = self.doc(a)?.id; + let o = self.session.run_javascript(id, a.str("script")?, a.opt_str("field")?).map_err(failed)?; + let reqs: Vec = o.requests.iter().map(request_json).collect(); + Ok(json!({ "alerts": o.alerts, "console": o.console, "requests": reqs, "error": o.error, "result": o.result })) + } + + pub(crate) fn js_document_scripts(&self, a: &Args) -> Result { + let list: Vec = self.doc(a)?.document_scripts().into_iter().map(|(n, s)| json!({ "name": n, "script": s })).collect(); + Ok(json!({ "scripts": list })) + } + + pub(crate) fn js_set_document_script(&mut self, a: &Args) -> Result { + let edit = printcraft_engine::Edit::SetDocumentScript { name: a.str("name")?.into(), script: a.opt_str("script")?.map(str::to_string) }; + let id = self.doc(a)?.id; + self.session.apply(id, edit).map_err(failed)?; + self.js_document_scripts(a) + } + + pub(crate) fn form_set_script(&mut self, a: &Args) -> Result { + let edit = printcraft_engine::Edit::SetFieldScript { + name: a.str("field")?.into(), + event: a.str("event")?.into(), + script: a.opt_str("script")?.map(str::to_string), + }; + let id = self.doc(a)?.id; + self.session.apply(id, edit).map_err(failed)?; + let out = self.session.take_js_output(id); + Ok(json!({ "field": a.str("field")?, "event": a.str("event")?, "console": out.console, "errors": out.errors })) + } + + pub(crate) fn js_enabled(&mut self, a: &Args) -> Result { + if let Some(on) = a.opt_bool("enabled")? { + self.session.set_javascript(on); + } + Ok(json!({ "enabled": self.session.javascript() })) + } +} diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 9148be83..00572fec 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -503,6 +503,11 @@ impl Automation { "sign_list" => self.sign_list(&a)?, "accessibility_check" => self.a11y_check(&a)?, "ocr_recognize" => self.ocr_recognize(&a)?, + "js_run" => self.js_run(&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)?, + "form_set_script" => self.form_set_script(&a)?, "ocr_status" => self.ocr_status()?, "ocr_recognize_files" => self.ocr_recognize_files(&a)?, "accessibility_report" => self.a11y_report(&a)?, diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index ba7c288d..c19e943c 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -737,6 +737,27 @@ pub fn tools() -> Vec { t("accessibility_set_alt", "Set alternate text", "Set a figure's alternate text (alt; empty or omitted clears it), or mark it decorative (decorative: true: its content becomes an artifact and the figure leaves the tags). figure is a number from accessibility_figures. Returns the figures. Undoable.") .cmd("a11y.alt_text") .with(schema(json!({ "doc": doc(), "figure": { "type": "integer", "minimum": 1 }, "alt": { "type": "string" }, "decorative": { "type": "boolean" } }), &["doc", "figure"])), + 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). The form object model is available: this/getField, event, app, util, console, display, color, and the document-level scripts. Field changes and resetForm are applied as one undoable step; returns the script's alerts, console output, requests (print, page, url, submit) and error.") + .cmd("tools.js_console") + .with(schema( + json!({ "doc": doc(), "script": { "type": "string" }, "field": { "type": "string", "description": "Run as this button's Mouse Up event." } }), + &["doc", "script"], + )), + 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") + .with(schema(json!({ "doc": doc() }), &["doc"])), + t("js_set_document_script", "Edit a document JavaScript", "Add or replace the document-level JavaScript `name` with `script`, or delete it (script omitted). Undoable.") + .cmd("tools.document_js") + .with(schema(json!({ "doc": doc(), "name": { "type": "string", "minLength": 1 }, "script": { "type": "string" } }), &["doc", "name"])), + t("form_set_script", "Set a field's JavaScript", "Field Properties ▸ Run custom script: set field's JavaScript for event keystroke, format, validate or calculate (the field's actions; calculated fields join the calculation order) or mouse_up (a button's action). Omit script to remove it. The scripts use Acrobat's object model (event.value, event.rc, getField, util.printf, …) and run when the field changes. Undoable.") + .cmd("form.prepare") + .with(schema( + json!({ "doc": doc(), "field": { "type": "string" }, "event": { "type": "string", "enum": ["keystroke", "format", "validate", "calculate", "mouse_up"] }, "script": { "type": "string" } }), + &["doc", "field", "event"], + )), + t("js_enabled", "JavaScript on or off", "Preferences ▸ JavaScript ▸ Enable Acrobat JavaScript: set it with `enabled`, or read it. With JavaScript off, field scripts other than Acrobat's AF calls don't run.") + .with(schema(json!({ "enabled": { "type": "boolean" } }), &[])), t("ocr_recognize", "Recognize text (OCR)", "Scan & OCR ▸ Recognize text: render pages, read the words in them and add them as invisible text over the page image, so scanned pages become searchable and selectable (a searchable image; the image is not changed). Pages that already have text are skipped unless skip_text_pages is false. Returns each page's recognised text, word count or why it was skipped. Needs the OCR models (ocr_status). Undoable as one step.") .cmd("ocr.recognize") .with(schema( diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index fca02228..bc26ae62 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -1329,3 +1329,50 @@ fn ocr_recognize_files_writes_searchable_copies() { let found = ok(&mut a, "text_find", json!({ "doc": out, "query": "recognition" })); assert!(found.to_string().contains("\"page\""), "{found}"); } + +#[test] +fn javascript_through_tools() { + let dir = workdir("js"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + for (name, y) in [("Price", 20), ("Qty", 60), ("Total", 100)] { + ok(&mut a, "form_add_field", json!({ "doc": doc, "page": 1, "type": "text", "rect": [20, y, 180, y + 22], "name": name })); + } + ok( + &mut a, + "js_set_document_script", + json!({ "doc": doc, "name": "lib", "script": "function money(v) { return util.printf('EUR %,2.2f', v); }" }), + ); + assert_eq!(ok(&mut a, "js_document_scripts", json!({ "doc": doc }))["scripts"][0]["name"], "lib"); + ok( + &mut a, + "form_set_script", + json!({ "doc": doc, "field": "Total", "event": "calculate", "script": "event.value = getField('Price').value * getField('Qty').value;" }), + ); + ok(&mut a, "form_set_script", json!({ "doc": doc, "field": "Total", "event": "format", "script": "event.value = money(event.value);" })); + ok( + &mut a, + "form_set_script", + json!({ "doc": doc, "field": "Qty", "event": "validate", "script": "if (event.value < 1) { app.alert('Order at least one'); event.rc = false; }" }), + ); + ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "Price": "1250", "Qty": "2" } })); + assert!(page_text(&mut a, doc)[0].contains("EUR 2.500,00"), "{:?}", page_text(&mut a, doc)); + let err = a.call("form_fill", &json!({ "doc": doc, "values": { "Qty": "0" } })).unwrap_err(); + assert!(err.to_string().contains("Order at least one"), "{err}"); + + let r = ok( + &mut a, + "js_run", + json!({ "doc": doc, "script": "console.println(getField('Total').value); getField('Qty').value = 3; this.pageNum = 1; app.alert('ok');" }), + ); + assert_eq!(r["console"][0], "2500"); + assert_eq!(r["alerts"][0], "ok"); + assert_eq!(r["requests"][0]["page"], 2); + 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"); + let r = ok(&mut a, "js_run", json!({ "doc": doc, "script": "nope()" })); + assert!(r["error"].as_str().unwrap().contains("nope")); + assert_eq!(ok(&mut a, "js_enabled", json!({ "enabled": false }))["enabled"], false); + assert!(a.call("js_run", &json!({ "doc": doc, "script": "1" })).is_err()); +} diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 41be5145..52db62f2 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -23,6 +23,7 @@ printcraft-sign = { workspace = true } printcraft-optimize = { workspace = true } printcraft-a11y = { workspace = true } printcraft-ocr = { workspace = true } +printcraft-js = { workspace = true } printcraft-fonts = { workspace = true } printcraft-create = { workspace = true } printcraft-model = { workspace = true } diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 5970fc99..7bb61eda 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -248,6 +248,9 @@ pub const COMMANDS: &[CommandSpec] = &[ c("optimize.reduce", "Reduce file size…", FILE, None, Document, "file-down"), c("optimize.advanced", "Optimize PDF…", FILE, None, Document, "settings-2"), c("export.text", "Export to text…", FILE, None, Document, "type"), + c("app.preferences", "Preferences…", None, None, Nothing, "settings"), + c("tools.js_console", "JavaScript console…", None, Some(Shortcut::cmd("J")), Document, "square-terminal"), + c("tools.document_js", "Document JavaScripts…", None, None, Modification, "file-code"), c("ocr.recognize", "Recognize text…", None, None, Modification, "scan-text"), c("ocr.recognize_batch", "Recognize text in multiple files…", None, None, Nothing, "files"), c("a11y.check", "Check for accessibility…", None, None, Document, "accessibility"), diff --git a/crates/engine/src/js.rs b/crates/engine/src/js.rs new file mode 100644 index 00000000..13bf0be3 --- /dev/null +++ b/crates/engine/src/js.rs @@ -0,0 +1,222 @@ +//! Acrobat JavaScript in documents: field scripts run during form edits ([`JsRunner`], wired +//! into `forms` through its [`printcraft_forms::Scripts`] hook), and button actions and console +//! input run on request ([`Session::run_javascript`]). What scripts ask of the viewer (alerts, +//! printing, navigation, links) is kept for the front end in [`JsOutput`]. +//! +//! Preferences ▸ JavaScript ▸ Enable Acrobat JavaScript is [`Session::set_javascript`]; with it +//! off, scripts don't run and AF calls keep working natively. + +use printcraft_forms::{Field, FieldChange, FieldEvent, FieldKind, ScriptResult, Scripts}; +pub use printcraft_js::{Limits, Outcome, Request}; + +use crate::{DocId, Edit, EditError, Session}; + +/// What scripts produced for the viewer since it last looked. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct JsOutput { + pub alerts: Vec, + pub console: Vec, + pub requests: Vec, + /// Script errors (as Acrobat's console shows them). + pub errors: Vec, +} + +impl JsOutput { + pub fn is_empty(&self) -> bool { + self.alerts.is_empty() && self.console.is_empty() && self.requests.is_empty() && self.errors.is_empty() + } + + fn absorb(&mut self, o: &Outcome) { + self.alerts.extend(o.alerts.iter().cloned()); + self.console.extend(o.console.iter().cloned()); + self.requests.extend(o.requests.iter().cloned()); + self.errors.extend(o.error.iter().cloned()); + } + + pub(crate) fn append(&mut self, other: JsOutput) { + self.alerts.extend(other.alerts); + self.console.extend(other.console); + self.requests.extend(other.requests); + self.errors.extend(other.errors); + } +} + +fn kind(k: FieldKind) -> printcraft_js::FieldType { + use printcraft_js::FieldType as T; + match k { + FieldKind::Text => T::Text, + FieldKind::CheckBox => T::CheckBox, + FieldKind::Radio => T::RadioButton, + FieldKind::PushButton => T::Button, + FieldKind::Combo => T::ComboBox, + FieldKind::List => T::ListBox, + FieldKind::Signature => T::Signature, + } +} + +/// `field.display` from the first widget's annotation flags. +fn display(doc: &printcraft_cos::Document, f: &Field) -> i32 { + let flags = f.widgets.first().and_then(|w| doc.get(w.obj).as_dict().and_then(|d| d.int(b"F"))).unwrap_or(4); + if flags & 2 != 0 { + printcraft_js::DISPLAY_HIDDEN + } else if flags & 32 != 0 { + printcraft_js::DISPLAY_NO_VIEW + } else if flags & 4 == 0 { + printcraft_js::DISPLAY_NO_PRINT + } else { + printcraft_js::DISPLAY_VISIBLE + } +} + +/// A form field as scripts see it. +pub fn field_state(doc: &printcraft_cos::Document, f: &Field) -> printcraft_js::FieldState { + let mut s = printcraft_js::FieldState::new(f.name.clone(), kind(f.kind), f.value.clone()); + s.default = f.default.clone(); + s.readonly = f.read_only(); + s.required = f.has(printcraft_forms::flags::REQUIRED); + s.display = display(doc, f); + 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 +} + +/// The changes a script made, against the fields as they were. +fn changes(before: &[printcraft_js::FieldState], after: &[printcraft_js::FieldState]) -> Vec { + after + .iter() + .filter_map(|a| { + let b = before.iter().find(|b| b.name == a.name)?; + let c = FieldChange { + name: a.name.clone(), + value: (a.value != b.value).then(|| a.value.clone()), + 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), + }; + (c.value.is_some() || c.read_only.is_some() || c.required.is_some() || c.display.is_some()).then_some(c) + }) + .collect() +} + +/// Runs field scripts with the document's object model and keeps what they print or ask for. +pub struct JsRunner { + pub doc: printcraft_js::DocInfo, + pub doc_scripts: Vec, + pub output: JsOutput, + /// The fields in the document as the forms code last read them (for `display`). + cos: printcraft_cos::Document, +} + +impl JsRunner { + pub fn new(cos: &printcraft_cos::Document, file_name: &str) -> JsRunner { + let doc = + printcraft_js::DocInfo { file_name: file_name.to_string(), num_pages: printcraft_model::pages(cos).len(), page: 0, info: info(cos) }; + JsRunner { doc, doc_scripts: printcraft_forms::document_scripts(cos), output: JsOutput::default(), cos: cos.clone() } + } +} + +/// The Info dictionary as `this.info` keys (lower-case). +fn info(cos: &printcraft_cos::Document) -> Vec<(String, String)> { + let Some(d) = cos.trailer().get(b"Info").map(|i| cos.resolve(i)).and_then(|i| i.as_dict().cloned()) else { return Vec::new() }; + d.iter() + .filter_map(|(k, v)| match &*cos.resolve(v) { + printcraft_cos::Object::String(s) => Some((String::from_utf8_lossy(k).to_lowercase(), s.to_text())), + _ => None, + }) + .collect() +} + +impl Scripts for JsRunner { + fn run(&mut self, event: FieldEvent, script: &str, target: &Field, value: &str, fields: &[Field]) -> ScriptResult { + let states: Vec<_> = fields.iter().map(|f| field_state(&self.cos, f)).collect(); + let ev = printcraft_js::Event::field(event.name(), &target.name, value); + let o = printcraft_js::run(script, &ev, &self.doc, &states, &self.doc_scripts, Limits::default()); + self.output.absorb(&o); + if o.error.is_some() { + // As in Acrobat, a failing script leaves the value alone (the error goes to the console). + return ScriptResult { rc: true, value: value.to_string(), changes: Vec::new(), message: None }; + } + let after: Vec<_> = states.iter().map(|s| o.changed.iter().find(|c| c.name == s.name).unwrap_or(s).clone()).collect(); + ScriptResult { + rc: o.rc, + value: o.value.clone(), + changes: changes(&states, &after), + message: (!o.rc).then(|| o.alerts.last().cloned()).flatten(), + } + } +} + +/// A document-level JavaScript: its name and source. +pub fn document_scripts(cos: &printcraft_cos::Document) -> Vec<(String, String)> { + printcraft_forms::document_scripts_named(cos) +} + +impl crate::Document { + /// Document JavaScripts (name, source), in name order. + pub fn document_scripts(&self) -> Vec<(String, String)> { + self.editor.as_ref().map(|e| document_scripts(&e.cos)).unwrap_or_default() + } +} + +impl Session { + /// Preferences ▸ JavaScript ▸ Enable Acrobat JavaScript (on by default). + pub fn set_javascript(&mut self, on: bool) { + self.js_off = !on; + } + + pub fn javascript(&self) -> bool { + !self.js_off + } + + /// What scripts printed or asked for since the last call (alerts, console, print, …). + pub fn take_js_output(&mut self, id: DocId) -> JsOutput { + self.docs.iter_mut().find(|d| d.id == id).map(|d| std::mem::take(&mut d.js_output)).unwrap_or_default() + } + + /// Run `script` in document `id`, for a button (`target`: the field, event "Mouse Up") or + /// 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 { + 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()))?; + let runner = JsRunner::new(&cos, &doc.name); + let fields = printcraft_forms::fields(&cos); + let states: Vec<_> = fields.iter().map(|f| field_state(&cos, f)).collect(); + let event = match target { + Some(t) => printcraft_js::Event { will_commit: false, ..printcraft_js::Event::field("Mouse Up", t, "") }, + None => printcraft_js::Event::doc("Console"), + }; + let mut o = printcraft_js::run(script, &event, &runner.doc, &states, &runner.doc_scripts, Limits::default()); + let after: Vec<_> = states.iter().map(|s| o.changed.iter().find(|c| c.name == s.name).unwrap_or(s).clone()).collect(); + let changes = changes(&states, &after); + let resets: Vec> = o + .requests + .iter() + .filter_map(|r| match r { + Request::Reset(n) => Some(n.clone()), + _ => None, + }) + .collect(); + o.requests.retain(|r| !matches!(r, Request::Reset(_))); + let mut edits = Vec::new(); + if !changes.is_empty() { + edits.push(Edit::ApplyScriptChanges { changes }); + } + for names in resets { + let all: Vec = fields.iter().map(|f| f.name.clone()).collect(); + let names = (!names.is_empty()) + .then(|| all.iter().filter(|n| names.iter().any(|x| *n == x || n.starts_with(&format!("{x}.")))).cloned().collect()); + edits.push(Edit::ResetForm { names }); + } + if !edits.is_empty() { + self.apply(id, Edit::Batch { label: "Run JavaScript".into(), edits })?; + } + Ok(o) + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 04b20d14..a22a189d 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -13,6 +13,7 @@ pub mod catalog; pub mod commands; pub mod export; +pub mod js; pub mod links; pub mod ocr; @@ -115,6 +116,15 @@ enum Scope { Form, } +/// Edits during which field scripts run. +fn uses_scripts(edit: &Edit) -> bool { + match edit { + Edit::SetFieldValue { .. } | Edit::ApplyScriptChanges { .. } | Edit::SetFieldScript { .. } => true, + Edit::Batch { edits, .. } => edits.iter().any(uses_scripts), + _ => false, + } +} + fn scope_of(edit: &Edit) -> Scope { match edit { // A file attachment also changes the Attachments list. @@ -133,7 +143,7 @@ fn scope_of(edit: &Edit) -> Scope { | Edit::ResizeAnnotation { .. } | Edit::StyleAnnotation { .. } | Edit::SetAnnotationInfo { .. } => Scope::Comments, - Edit::SetFieldValue { .. } | Edit::ResetForm { .. } | Edit::SetFieldImage { .. } => Scope::Form, + Edit::SetFieldValue { .. } | Edit::ResetForm { .. } | Edit::SetFieldImage { .. } | Edit::ApplyScriptChanges { .. } => Scope::Form, Edit::Batch { edits, .. } => { let mut scopes = edits.iter().map(scope_of); let first = scopes.next().unwrap_or(Scope::Full); @@ -187,6 +197,8 @@ pub struct Document { sig_cache: Arc, editor: Option, config: RenderConfig, + /// What field scripts printed or asked for (see [`Session::take_js_output`]). + js_output: js::JsOutput, } impl Document { @@ -776,6 +788,24 @@ pub enum Edit { MarkDecorative { figure: u32, }, + /// Document JavaScripts: add, replace (`script`) or remove (`None`) the document-level + /// script `name`. + SetDocumentScript { + name: String, + script: Option, + }, + /// Field Properties ▸ a custom script: set or remove field `name`'s JavaScript for `event` + /// (keystroke, format, validate, calculate or mouse_up). + SetFieldScript { + name: String, + event: String, + script: Option, + }, + /// What a script (button or console) changed in form fields: values, read-only, required + /// and visibility. + ApplyScriptChanges { + changes: Vec, + }, /// Scan & OCR ▸ Recognize text: put recognised words on `page` as invisible text over the /// image (from [`ocr::OcrJob::run`]). AddOcrText { @@ -971,6 +1001,10 @@ impl Edit { Edit::SetAltText { .. } => "Set alternate text".into(), Edit::MarkDecorative { .. } => "Mark figure as decorative".into(), Edit::AddOcrText { .. } => "Recognize text".into(), + Edit::ApplyScriptChanges { .. } => "Run JavaScript".into(), + Edit::SetFieldScript { name, .. } => format!("Edit script of {name}"), + Edit::SetDocumentScript { script: None, .. } => "Delete document JavaScript".into(), + Edit::SetDocumentScript { .. } => "Edit document JavaScript".into(), Edit::EditTextLine { .. } | Edit::EditTextBlock { .. } => "Edit text".into(), Edit::EditPageImage { change, .. } => match change { ImageEdit::Move(_) => "Move image".into(), @@ -1095,7 +1129,7 @@ fn check_permission(edit: &Edit, p: &printcraft_cos::Permissions) -> Result<(), Err(EditError::NotPermitted("comments")) } } - Edit::SetFieldValue { .. } | Edit::ResetForm { .. } | Edit::SetFieldImage { .. } => { + Edit::SetFieldValue { .. } | Edit::ResetForm { .. } | Edit::SetFieldImage { .. } | Edit::ApplyScriptChanges { .. } => { if p.fill_forms() { Ok(()) } else { @@ -1139,6 +1173,8 @@ fn check_permission(edit: &Edit, p: &printcraft_cos::Permissions) -> Result<(), | Edit::SetAltText { .. } | Edit::MarkDecorative { .. } | Edit::AddOcrText { .. } + | Edit::SetDocumentScript { .. } + | Edit::SetFieldScript { .. } | Edit::EditTextLine { .. } | Edit::EditTextBlock { .. } | Edit::EditPageImage { .. } @@ -1155,6 +1191,8 @@ fn check_permission(edit: &Edit, p: &printcraft_cos::Permissions) -> Result<(), /// Dates and unique ids stamped onto what an edit creates. struct EditCtx { + /// Field scripts run (`None`: JavaScript is off) and what they produced. + js: Option, date: Option, /// Today in local time, for date tokens. today: (i64, u32, u32), @@ -1173,7 +1211,7 @@ impl EditCtx { // Real clock: mix in sub-second time so ids from two sessions don't collide. seed ^= std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.subsec_nanos() as u64).unwrap_or(0) << 32; } - Self { date: now.map(printcraft_cos::pdf_date), today: (1970, 1, 1), seed, count: 0 } + Self { js: None, date: now.map(printcraft_cos::pdf_date), today: (1970, 1, 1), seed, count: 0 } } /// 32 bytes of entropy for new encryption keys and salts. `RandomState` is seeded by the @@ -1291,7 +1329,14 @@ fn run_edit(doc: &mut printcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) - Edit::SetAnnotationInfo { page, index, author, subject, icon } => { printcraft_annot::set_info(doc, *page, *index, author.as_deref(), subject.as_deref(), *icon, &cx.meta())?; } - Edit::SetFieldValue { name, value } => printcraft_forms::set_value(doc, name, value)?, + Edit::SetFieldValue { name, value } => match cx.js.as_mut() { + Some(js) => printcraft_forms::set_value_with(doc, name, value, js)?, + None => printcraft_forms::set_value(doc, name, value)?, + }, + Edit::ApplyScriptChanges { changes } => match cx.js.as_mut() { + Some(js) => printcraft_forms::apply_script_changes(doc, changes, js)?, + None => printcraft_forms::apply_script_changes(doc, changes, &mut printcraft_forms::NoScripts)?, + }, Edit::SetFieldImage { name, image } => { let (img, _) = printcraft_create::image_xobject(doc, name, image)?; let px = match &*doc.get(img) { @@ -1325,6 +1370,14 @@ fn run_edit(doc: &mut printcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) - Edit::EditTextLine { page, line, text } => { printcraft_edit::replace_line(doc, *page, *line, text)?; } + Edit::SetFieldScript { name, event, script } => { + printcraft_forms::set_field_script(doc, name, event, script.as_deref())?; + match cx.js.as_mut() { + Some(js) => printcraft_forms::recalculate_with(doc, js)?, + None => printcraft_forms::recalculate(doc)?, + }; + } + Edit::SetDocumentScript { name, script } => printcraft_forms::set_document_script(doc, name, script.as_deref())?, Edit::AddOcrText { page, words } => { printcraft_edit::stamp(doc, *page, "OCR", printcraft_ocr::text_layer(words))?; } @@ -1583,6 +1636,8 @@ pub enum EditError { SignedRewrite(String), #[error("this document is signed: rewriting it would invalidate its signatures (save it incrementally instead)")] Signed, + #[error("{0}")] + Invalid(String), } impl From for EditError { @@ -1599,6 +1654,8 @@ pub struct Session { clock: Option i64>, /// Certificates trusted for signing (Acrobat: Trusted Certificates). trust: Arc, + /// Preferences ▸ JavaScript ▸ Enable Acrobat JavaScript, inverted (on by default). + js_off: bool, } /// Validate the signature fields of `cos` (written as `bytes`). @@ -1720,6 +1777,7 @@ impl Session { sig_cache, editor, config, + js_output: Default::default(), }); Ok(id) } @@ -1732,7 +1790,9 @@ impl Session { pub fn apply(&mut self, id: DocId, edit: Edit) -> Result<(), EditError> { let now = self.now(); let today = self.today(); + let js_off = self.js_off; let doc = self.doc_mut(id)?; + let name = doc.name.clone(); let mut cx = EditCtx::new(now, doc.generation ^ (id.0 << 48)); cx.today = today; let reason = doc.read_only_reason.clone().unwrap_or_default(); @@ -1742,6 +1802,9 @@ impl Session { check_permission(&edit, &p)?; } let mut next = editor.cos.clone(); + if !js_off && uses_scripts(&edit) { + cx.js = Some(js::JsRunner::new(&next, &name)); + } run_edit(&mut next, &edit, &mut cx)?; // Signed documents are only ever saved incrementally: an edit that needs a full rewrite // (applying redactions, changing security, sanitizing) would invalidate the signatures. @@ -1773,6 +1836,9 @@ impl Session { } doc.dirty = true; doc.generation += 1; + if let Some(js) = cx.js { + doc.js_output.append(js.output); + } Ok(()) } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index e537dbea..c0d13394 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1235,3 +1235,67 @@ fn recognize_text_makes_a_scanned_page_searchable() { let again = s.recognize_text(id, &[], ocr::OcrSettings::default()).unwrap(); assert!(again[0].skipped.is_some()); } + +/// A form whose scripts are custom JavaScript: total = price × qty (calculate, through a +/// document-level function), shown with a custom format; qty is validated; a button script. +fn scripted_form() -> Vec { + let text = |name: &str, y: u32, aa: &str| { + format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T ({name}) /Rect [20 {y} 180 {}] /P 3 0 R /F 4 {aa} >>", y + 20) + }; + let objs = [ + "<< /Type /Catalog /Pages 2 0 R /Names << /JavaScript << /Names [(helpers) 10 0 R] >> >> /AcroForm << /Fields [4 0 R 5 0 R 6 0 R 7 0 R] /CO [6 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 8 0 R >> >> >> >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>".to_string(), + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R 6 0 R 7 0 R] >>".to_string(), + text("price", 250, "/V (2.5)"), + text("qty", 220, "/AA << /V << /S /JavaScript /JS (if (event.value > 100) { app.alert('At most 100'); event.rc = false; }) >> >>"), + text("total", 190, "/AA << /C << /S /JavaScript /JS (event.value = times\\(getField('price').value, getField('qty').value\\);) >> /F << /S /JavaScript /JS (event.value = 'USD ' + util.printf('%.2f', event.value);) >> >>"), + "<< /Type /Annot /Subtype /Widget /FT /Btn /Ff 65536 /T (go) /Rect [20 150 80 170] /P 3 0 R /F 4 >>".to_string(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".to_string(), + "<< >>".to_string(), + "<< /S /JavaScript /JS (function times\\(a, b\\) { console.println('times'); return a * b; }) >>".to_string(), + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +#[test] +fn form_javascript_validates_calculates_and_formats() { + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("order.pdf", None, Arc::new(scripted_form()), None).unwrap(); + let value = |s: &Session, n: &str| s.get(id).unwrap().form.iter().find(|f| f.name == n).unwrap().value.clone(); + s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("4".into()) }).unwrap(); + assert_eq!(value(&s, "total"), ["10"], "calculated by the script"); + assert!(page_texts(&s, id)[0].contains("USD 10.00"), "{:?}", page_texts(&s, id)); + let out = s.take_js_output(id); + assert!(out.console.contains(&"times".to_string()), "{out:?}"); + + let err = s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("500".into()) }).unwrap_err(); + assert_eq!(err.to_string(), "At most 100"); + assert_eq!(value(&s, "qty"), ["4"]); + + // A button script changes fields in one undoable step, and asks for a page. + let o = + s.run_javascript(id, "getField('price').value = 3; getField('total').readonly = true; app.alert('Done'); this.print();", Some("go")).unwrap(); + assert_eq!(o.alerts, ["Done"]); + assert_eq!(o.requests, [js::Request::Print]); + assert_eq!(value(&s, "total"), ["12"], "recalculated after the change"); + assert!(s.get(id).unwrap().form.iter().find(|f| f.name == "total").unwrap().read_only()); + assert_eq!(s.get(id).unwrap().can_undo(), Some("Run JavaScript")); + + // JavaScript off: scripts don't run. + s.set_javascript(false); + s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("500".into()) }).unwrap(); + assert_eq!(value(&s, "total"), ["12"]); + assert!(s.run_javascript(id, "1", None).is_err()); +} diff --git a/crates/forms/src/af.rs b/crates/forms/src/af.rs index d799f01a..bcaaaacb 100644 --- a/crates/forms/src/af.rs +++ b/crates/forms/src/af.rs @@ -91,6 +91,17 @@ pub struct Actions { pub calculate: Calculate, /// Events whose script isn't one of the AF calls (they need the JavaScript engine). pub unsupported: Vec<&'static str>, + /// Those scripts' JavaScript, run through [`crate::Scripts`]. + pub scripts: Scripts, +} + +/// Field scripts that aren't AF calls, by event. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Scripts { + pub format: Option, + pub keystroke: Option, + pub validate: Option, + pub calculate: Option, } impl Actions { diff --git a/crates/forms/src/appearance.rs b/crates/forms/src/appearance.rs index 201f5e61..7adca416 100644 --- a/crates/forms/src/appearance.rs +++ b/crates/forms/src/appearance.rs @@ -172,15 +172,24 @@ fn frame(doc: &Document, wd: &Dict, w: f64, h: f64) -> (String, f64) { /// The appearance of a text or choice field's widget showing `values`. pub fn field_appearance(doc: &Document, f: &Field, w: &Widget, values: &[String]) -> Stream { + field_appearance_as(doc, f, w, values, true) +} + +/// [`field_appearance`], where `format` false means `values` are already what to show (a +/// custom Format script ran). +pub fn field_appearance_as(doc: &Document, f: &Field, w: &Widget, values: &[String], format: bool) -> Stream { // The Format event: what is shown, not what is stored. let formatted: Vec; - let values = - if matches!(f.kind, crate::FieldKind::Text | crate::FieldKind::Combo) && values.len() == 1 && f.actions.format != crate::af::Format::None { - formatted = vec![crate::af::format_value(&f.actions.format, &values[0])]; - &formatted[..] - } else { - values - }; + let values = if format + && matches!(f.kind, crate::FieldKind::Text | crate::FieldKind::Combo) + && values.len() == 1 + && f.actions.format != crate::af::Format::None + { + formatted = vec![crate::af::format_value(&f.actions.format, &values[0])]; + &formatted[..] + } else { + values + }; let wobj = doc.get(w.obj); let wd = wobj.as_dict().cloned().unwrap_or_default(); let (width, height) = ((w.rect[2] - w.rect[0]).max(1.0), (w.rect[3] - w.rect[1]).max(1.0)); diff --git a/crates/forms/src/author.rs b/crates/forms/src/author.rs index 748d2ef5..8aabb2bb 100644 --- a/crates/forms/src/author.rs +++ b/crates/forms/src/author.rs @@ -98,7 +98,7 @@ fn rgb(c: [f64; 3]) -> Object { } /// The AcroForm dictionary's reference, creating the form (with `/Helv` in `/DR`) if needed. -fn ensure_form(doc: &mut Document) -> Result { +pub(crate) fn ensure_form(doc: &mut Document) -> Result { let root = doc.root().ok_or(FormError::NoForm)?; let existing = doc.get(root).as_dict().and_then(|d| d.get(b"AcroForm").cloned()); let r = match existing { diff --git a/crates/forms/src/lib.rs b/crates/forms/src/lib.rs index a0071fd1..bb2aedb6 100644 --- a/crates/forms/src/lib.rs +++ b/crates/forms/src/lib.rs @@ -16,9 +16,14 @@ use printcraft_cos::{Dict, Document, ObjRef, Object, PdfString}; pub mod af; pub mod appearance; mod author; +mod scripting; pub use author::{ BorderStyle, FieldFont, FieldProps, Look, NewField, add_field, delete_field, duplicate_field, look, redraw_field, set_button_icon, set_props, }; +pub use scripting::{ + FieldChange, FieldEvent, NoScripts, ScriptResult, Scripts, apply_script_changes, document_scripts, document_scripts_named, set_document_script, + set_field_script, +}; #[cfg(test)] mod tests; @@ -236,7 +241,10 @@ fn actions_of(doc: &Document, d: &Dict, widgets: &[ObjRef]) -> af::Actions { if let Some(f) = js(b"F") { match af::parse_format(&f) { Some(fm) => out.format = fm, - None if !f.trim().is_empty() => out.unsupported.push("format"), + None if !f.trim().is_empty() => { + out.unsupported.push("format"); + out.scripts.format = Some(f); + } None => {} } } @@ -247,21 +255,30 @@ fn actions_of(doc: &Document, d: &Dict, widgets: &[ObjRef]) -> af::Actions { match af::parse_format(&k) { Some(fm @ af::Format::Mask(_)) => out.format = fm, Some(_) => {} - None if !k.trim().is_empty() => out.unsupported.push("keystroke"), + None if !k.trim().is_empty() => { + out.unsupported.push("keystroke"); + out.scripts.keystroke = Some(k); + } None => {} } } if let Some(v) = js(b"V") { match af::parse_validate(&v) { Some(vv) => out.validate = vv, - None if !v.trim().is_empty() => out.unsupported.push("validate"), + None if !v.trim().is_empty() => { + out.unsupported.push("validate"); + out.scripts.validate = Some(v); + } None => {} } } if let Some(c) = js(b"C") { match af::parse_calculate(&c) { Some(cc) => out.calculate = cc, - None if !c.trim().is_empty() => out.unsupported.push("calculate"), + None if !c.trim().is_empty() => { + out.unsupported.push("calculate"); + out.scripts.calculate = Some(c); + } None => {} } } @@ -581,6 +598,11 @@ fn walk( /// Fill the field `name`. pub fn set_value(doc: &mut Document, name: &str, value: &FieldValue) -> Result<(), FormError> { + set_value_with(doc, name, value, &mut NoScripts) +} + +/// [`set_value`], running the fields' JavaScript through `scripts`. +pub fn set_value_with(doc: &mut Document, name: &str, value: &FieldValue, scripts: &mut dyn Scripts) -> Result<(), FormError> { let all = fields(doc); if all.is_empty() { return Err(FormError::NoForm); @@ -589,16 +611,22 @@ pub fn set_value(doc: &mut Document, name: &str, value: &FieldValue) -> Result<( if f.read_only() { return Err(FormError::ReadOnly(name.into())); } - write_value(doc, f, value)?; - recalculate(doc)?; + write_value(doc, f, value, scripts)?; + recalculate_with(doc, scripts)?; Ok(()) } /// Run every field's Calculate script in the form's calculation order (`/CO`, then the other /// calculated fields), as Acrobat does after any value changes. Returns how many changed. pub fn recalculate(doc: &mut Document) -> Result { + recalculate_with(doc, &mut NoScripts) +} + +/// [`recalculate`], running custom Calculate (and Format) scripts through `scripts`. +pub fn recalculate_with(doc: &mut Document, scripts: &mut dyn Scripts) -> Result { let all = fields(doc); - if !all.iter().any(|f| f.actions.calculate != af::Calculate::None) { + let calculated = |f: &Field| f.actions.calculate != af::Calculate::None || f.actions.scripts.calculate.is_some(); + if !all.iter().any(calculated) { return Ok(0); } let co: Vec = acroform(doc) @@ -609,7 +637,7 @@ pub fn recalculate(doc: &mut Document) -> Result { .collect(); let mut order: Vec = co.iter().filter_map(|r| all.iter().find(|f| f.obj == *r)).map(|f| f.name.clone()).collect(); for f in &all { - if f.actions.calculate != af::Calculate::None && !order.contains(&f.name) { + if calculated(f) && !order.contains(&f.name) { order.push(f.name.clone()); } } @@ -621,11 +649,25 @@ pub fn recalculate(doc: &mut Document) -> Result { let prefix = format!("{n}."); now.iter().filter(|x| x.name == n || x.name.starts_with(&prefix)).flat_map(|x| x.value.first().cloned()).collect() }; - let Some(v) = af::calculate(&f.actions.calculate, &lookup) else { continue }; - if f.value.first().map(String::as_str).unwrap_or("") != v && f.kind == FieldKind::Text { + let current = f.value.first().cloned().unwrap_or_default(); + let v = if let Some(js) = &f.actions.scripts.calculate { + let f = f.clone(); + let r = scripts.run(FieldEvent::Calculate, js, &f, ¤t, &now); + scripting::apply_changes(doc, &r.changes, &f.name)?; + if !r.rc { + continue; + } + r.value + } else { + let Some(v) = af::calculate(&f.actions.calculate, &lookup) else { continue }; + v + }; + let now = fields(doc); + let Some(f) = now.iter().find(|f| f.name == name) else { continue }; + if current != v && matches!(f.kind, FieldKind::Text | FieldKind::Combo) { let f = f.clone(); doc.update_dict(f.obj, |d| d.set(b"V".to_vec(), PdfString::text(&v)))?; - redraw(doc, &f, std::slice::from_ref(&v))?; + redraw(doc, &f, std::slice::from_ref(&v), scripts)?; changed += 1; } } @@ -636,7 +678,7 @@ fn invalid(m: impl Into) -> Result { Err(FormError::Invalid(m.into())) } -fn write_value(doc: &mut Document, f: &Field, value: &FieldValue) -> Result<(), FormError> { +fn write_value(doc: &mut Document, f: &Field, value: &FieldValue, scripts: &mut dyn Scripts) -> Result<(), FormError> { match (f.kind, value) { (FieldKind::Text, FieldValue::Text(t)) => { if let Some(max) = f.max_len @@ -647,6 +689,7 @@ fn write_value(doc: &mut Document, f: &Field, value: &FieldValue) -> Result<(), // Keystroke (on commit) and Validate, as Acrobat runs them before accepting a value. let t = &af::keystroke(&f.actions.format, &f.name, t).map_err(FormError::Invalid)?; af::validate(&f.actions.validate, t).map_err(FormError::Invalid)?; + let t = &scripting::accept(doc, f, t, scripts)?; doc.update_dict(f.obj, |d| { if t.is_empty() { d.remove(b"V"); @@ -655,7 +698,7 @@ fn write_value(doc: &mut Document, f: &Field, value: &FieldValue) -> Result<(), } d.remove(b"RV"); })?; - redraw(doc, f, std::slice::from_ref(t)) + redraw(doc, f, std::slice::from_ref(t), scripts) } (FieldKind::CheckBox, v) => { let state = f.widgets.iter().find_map(|w| w.on_state.clone()).unwrap_or_else(|| "Yes".into()); @@ -735,7 +778,7 @@ fn write_value(doc: &mut Document, f: &Field, value: &FieldValue) -> Result<(), d.set(b"I".to_vec(), Object::Array(indices)); } })?; - redraw(doc, f, &exports) + redraw(doc, f, &exports, scripts) } (FieldKind::PushButton, _) => invalid(format!("{:?} is a button; it has no value", f.name)), (FieldKind::Signature, _) => invalid(format!("{:?} is a signature field; sign it with Fill & Sign or a digital ID", f.name)), @@ -765,9 +808,16 @@ fn set_states(doc: &mut Document, f: &Field, on: Option<&str>) -> Result<(), For } /// Regenerate the normal appearance of every widget of a text or choice field. -fn redraw(doc: &mut Document, f: &Field, values: &[String]) -> Result<(), FormError> { +fn redraw(doc: &mut Document, f: &Field, values: &[String], scripts: &mut dyn Scripts) -> Result<(), FormError> { + let shown = match values { + [one] if matches!(f.kind, FieldKind::Text | FieldKind::Combo) => scripting::formatted(doc, f, one, scripts), + _ => None, + }; for w in &f.widgets { - let stream = appearance::field_appearance(doc, f, w, values); + let stream = match &shown { + Some(s) => appearance::field_appearance_as(doc, f, w, std::slice::from_ref(s), false), + None => appearance::field_appearance(doc, f, w, values), + }; let ap = doc.add(Object::Stream(stream)); let mut apd = Dict::new(); apd.set(b"N".to_vec(), Object::Ref(ap)); @@ -805,7 +855,7 @@ pub fn reset(doc: &mut Document, names: Option<&[String]>) -> Result &'static str { + match self { + FieldEvent::Keystroke => "Keystroke", + FieldEvent::Validate => "Validate", + FieldEvent::Calculate => "Calculate", + FieldEvent::Format => "Format", + } + } +} + +/// A change a script made to a field (`None`: unchanged). +#[derive(Clone, Debug, Default, PartialEq)] +pub struct FieldChange { + pub name: String, + pub value: Option>, + pub read_only: Option, + pub required: Option, + /// `display.visible` 0, `hidden` 1, `noPrint` 2, `noView` 3. + pub display: Option, +} + +/// What a script did. +#[derive(Clone, Debug, PartialEq)] +pub struct ScriptResult { + /// `event.rc`. + pub rc: bool, + /// `event.value` afterwards. + pub value: String, + pub changes: Vec, + /// Why it rejected the value (its alert), for the error shown. + pub message: Option, +} + +/// Runs field scripts (JavaScript). +pub trait Scripts { + /// 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; +} + +/// No script engine (or JavaScript turned off): scripts change nothing. +pub struct NoScripts; + +impl Scripts for NoScripts { + fn run(&mut self, _: FieldEvent, _: &str, _: &Field, value: &str, _: &[Field]) -> ScriptResult { + ScriptResult { rc: true, value: value.to_string(), 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 { + let mut v = value.to_string(); + 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)?; + if !r.rc { + return Err(FormError::Invalid(r.message.unwrap_or_else(|| format!("{default} [ {} ]", f.name)))); + } + v = r.value; + } + Ok(v) +} + +/// 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)); + Some(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) -> Result<(), FormError> { + for c in changes { + let all = fields(doc); + let Some(f) = all.iter().find(|f| f.name == c.name) else { continue }; + if let Some(v) = &c.value + && c.name != except + && *v != f.value + { + let value = match f.kind { + FieldKind::Text => FieldValue::Text(v.first().cloned().unwrap_or_default()), + FieldKind::CheckBox => FieldValue::Check(!v.is_empty()), + FieldKind::Radio => FieldValue::Radio(v.first().cloned()), + FieldKind::Combo | FieldKind::List => FieldValue::Choice(v.clone()), + _ => continue, + }; + let mut tmp = f.clone(); + tmp.flags &= !flags::NO_TOGGLE_TO_OFF; + tmp.actions = Default::default(); + crate::write_value(doc, &tmp, &value, &mut NoScripts)?; + } + let mut ff = f.flags; + for (on, bit) in [(c.read_only, flags::READ_ONLY), (c.required, flags::REQUIRED)] { + match on { + Some(true) => ff |= bit, + Some(false) => ff &= !bit, + None => {} + } + } + if ff != f.flags { + doc.update_dict(f.obj, |d| d.set(b"Ff".to_vec(), Object::Int(ff as i64)))?; + } + if let Some(display) = c.display { + for w in &f.widgets { + let old = doc.get(w.obj).as_dict().and_then(|d| d.int(b"F")).unwrap_or(0); + // Hidden 2, Print 4, NoView 32 (Table 167). + let base = old & !(2 | 4 | 32); + let new = match display { + 1 => base | 2 | 4, + 2 => base, + 3 => base | 4 | 32, + _ => base | 4, + }; + if new != old { + doc.update_dict(w.obj, |d| d.set(b"F".to_vec(), Object::Int(new)))?; + } + } + } + } + Ok(()) +} + +/// 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, "")?; + crate::recalculate_with(doc, scripts)?; + Ok(()) +} + +/// Document-level JavaScripts (the catalog's `/Names /JavaScript` tree), in name order: they +/// define the functions field scripts call. +pub fn document_scripts(doc: &Document) -> Vec { + document_scripts_named(doc).into_iter().map(|(_, js)| js).collect() +} + +/// [`document_scripts`] with their names. +pub fn document_scripts_named(doc: &Document) -> Vec<(String, String)> { + let Some(root) = doc.root() else { return Vec::new() }; + let cat = doc.get(root); + let Some(names) = cat.as_dict().and_then(|c| c.get(b"Names")).map(|n| doc.resolve(n)) else { return Vec::new() }; + let Some(tree) = names.as_dict().and_then(|n| n.get(b"JavaScript")).map(|t| doc.resolve(t)) else { return Vec::new() }; + let mut out: Vec<(String, String)> = Vec::new(); + let mut stack = vec![(*tree).clone()]; + let mut seen = 0; + while let Some(node) = stack.pop() { + seen += 1; + if seen > 10_000 { + break; + } + let Some(d) = node.as_dict() else { continue }; + if let Some(kids) = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()) { + stack.extend(kids.iter().map(|k| (*doc.resolve(k)).clone())); + } + if let Some(pairs) = d.get(b"Names").map(|n| doc.resolve(n)).and_then(|n| n.as_array().cloned()) { + for pair in pairs.chunks(2) { + let [k, v] = pair else { continue }; + let key = match &*doc.resolve(k) { + Object::String(s) => s.to_text(), + _ => continue, + }; + if let Some(js) = crate::script(doc, v) { + out.push((key, js)); + } + } + } + } + out.sort(); + out +} + +/// Add (or replace) a document-level JavaScript named `name`. +pub fn set_document_script(doc: &mut Document, name: &str, js: Option<&str>) -> Result<(), FormError> { + let root = doc.root().ok_or(FormError::NoForm)?; + let cat = doc.get(root).as_dict().cloned().unwrap_or_default(); + let mut names = cat.get(b"Names").map(|n| doc.resolve(n)).and_then(|n| n.as_dict().cloned()).unwrap_or_default(); + let tree = names.get(b"JavaScript").map(|t| doc.resolve(t)).and_then(|t| t.as_dict().cloned()).unwrap_or_default(); + if tree.contains(b"Kids") { + return Err(FormError::Invalid("the document's JavaScript name tree has kids; edit it with a full editor".into())); + } + let mut pairs: Vec = tree.get(b"Names").map(|n| doc.resolve(n)).and_then(|n| n.as_array().cloned()).unwrap_or_default(); + let mut kept = Vec::new(); + for pair in pairs.chunks(2) { + if let [k, v] = pair + && !matches!(&*doc.resolve(k), Object::String(s) if s.to_text() == name) + { + kept.push((k.clone(), v.clone())); + } + } + if let Some(js) = js { + let mut action = printcraft_cos::Dict::new(); + action.set(b"S".to_vec(), Object::name("JavaScript")); + action.set(b"JS".to_vec(), PdfString::text(js)); + let a = doc.add(Object::Dict(action)); + kept.push((Object::String(PdfString::text(name)), Object::Ref(a))); + } + kept.sort_by_key(|(k, _)| match k { + Object::String(s) => s.to_text(), + _ => String::new(), + }); + pairs = kept.into_iter().flat_map(|(k, v)| [k, v]).collect(); + let mut t = printcraft_cos::Dict::new(); + t.set(b"Names".to_vec(), Object::Array(pairs)); + names.set(b"JavaScript".to_vec(), Object::Dict(t)); + doc.update_dict(root, |c| c.set(b"Names".to_vec(), Object::Dict(names)))?; + Ok(()) +} + +/// Field Properties ▸ Format / Validate / Calculate ▸ custom script, or Actions ▸ Mouse Up ▸ +/// Run a JavaScript: set (or remove, with `None`) field `name`'s script for `event` — one of +/// `keystroke`, `format`, `validate`, `calculate` (the field's `/AA` K, F, V, C) or `mouse_up` +/// (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 action = |doc: &mut Document, js: &str| { + let mut a = printcraft_cos::Dict::new(); + a.set(b"S".to_vec(), Object::name("JavaScript")); + a.set(b"JS".to_vec(), PdfString::text(js)); + Object::Ref(doc.add(Object::Dict(a))) + }; + if event == "mouse_up" { + for w in &f.widgets { + let a = js.map(|j| action(doc, j)); + doc.update_dict(w.obj, |d| match a { + Some(a) => d.set(b"A".to_vec(), a), + None => { + d.remove(b"A"); + } + })?; + } + return Ok(()); + } + let key: &[u8] = match event { + "keystroke" => b"K", + "format" => b"F", + "validate" => b"V", + "calculate" => b"C", + other => return Err(FormError::Invalid(format!("unknown field event {other:?} (keystroke, format, validate, calculate, mouse_up)"))), + }; + let mut aa = doc.get(f.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(); + match js { + Some(j) => { + let a = action(doc, j); + aa.set(key.to_vec(), a); + } + None => { + aa.remove(key); + } + } + doc.update_dict(f.obj, |d| { + if aa.is_empty() { + d.remove(b"AA"); + } else { + d.set(b"AA".to_vec(), Object::Dict(aa)); + } + })?; + if key == b"C" { + let af_ref = crate::author::ensure_form(doc)?; + let mut co: Vec = doc + .get(af_ref) + .as_dict() + .and_then(|d| d.get(b"CO").cloned()) + .map(|c| doc.resolve(&c)) + .and_then(|c| c.as_array().cloned()) + .unwrap_or_default(); + co.retain(|o| o.as_ref() != Some(f.obj)); + if js.is_some() { + co.push(Object::Ref(f.obj)); + } + doc.update_dict(af_ref, |d| { + if co.is_empty() { + d.remove(b"CO"); + } else { + d.set(b"CO".to_vec(), Object::Array(co)); + } + })?; + } + Ok(()) +} diff --git a/crates/js/Cargo.toml b/crates/js/Cargo.toml new file mode 100644 index 00000000..b62d6647 --- /dev/null +++ b/crates/js/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "printcraft-js" +description = "Acrobat JavaScript: a sandboxed interpreter (boa) with the form object model subset (event, getField, app, util, console)" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +repository.workspace = true + +[lints] +workspace = true + +[dependencies] +boa_engine = { version = "0.22.0", default-features = false, features = ["annex-b"] } + +[target.'cfg(target_arch = "wasm32")'.dependencies] +boa_engine = { version = "0.22.0", default-features = false, features = ["annex-b", "js"] } diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs new file mode 100644 index 00000000..eedb26e9 --- /dev/null +++ b/crates/js/src/lib.rs @@ -0,0 +1,1027 @@ +//! printcraft-js — Acrobat JavaScript for forms (L3). +//! +//! Field scripts (format, keystroke, validate, calculate), button actions and document-level +//! scripts run in [boa](https://boajs.dev), a JavaScript engine written in Rust, with the subset +//! of Acrobat's object model that forms use, as the public *JavaScript for Acrobat API Reference* +//! describes it: +//! +//! - `event`: `value`, `change`, `willCommit`, `rc`, `name`, `type`, `target`, `targetName`. +//! - the document (`this`, also the global object): `getField`, `numFields`, +//! `getNthFieldName`, `numPages`, `pageNum`, `documentFileName`, `info`, `resetForm`, +//! `calculateNow`, `print`, `submitForm`, `mailDoc`. +//! - Field objects: `name`, `type`, `value`, `valueAsString`, `defaultValue`, `readonly`, +//! `required`, `display`, `hidden`, `numItems`, `getItemAt`, `currentValueIndices`, +//! `isBoxChecked`, `checkThisBox`, `exportValues`, `charLimit`, `textColor`, `fillColor`, +//! `setFocus`. +//! - `app` (`alert`, `beep`, `response`, `launchURL`, `viewerType`, `viewerVersion`, +//! `platform`, `setTimeOut`), `util` (`printf`, `printd`, `printx`), `console` (`println`, +//! `show`, `clear`), `display` and `color`. +//! +//! 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. + +use std::cell::RefCell; +use std::collections::BTreeSet; + +use boa_engine::object::builtins::{JsArray, JsFunction}; +use boa_engine::object::{FunctionObjectBuilder, ObjectInitializer}; +use boa_engine::property::Attribute; +use boa_engine::{Context, JsError, JsNativeError, JsObject, JsResult, JsString, JsValue, NativeFunction, Source, js_string}; + +/// The kinds of field, as `field.type` names them. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum FieldType { + Text, + CheckBox, + RadioButton, + ComboBox, + ListBox, + Button, + Signature, +} + +impl FieldType { + pub fn name(self) -> &'static str { + match self { + FieldType::Text => "text", + FieldType::CheckBox => "checkbox", + FieldType::RadioButton => "radiobutton", + FieldType::ComboBox => "combobox", + FieldType::ListBox => "listbox", + FieldType::Button => "button", + FieldType::Signature => "signature", + } + } +} + +/// `display.*`: visible, hidden, noPrint, noView. +pub const DISPLAY_VISIBLE: i32 = 0; +pub const DISPLAY_HIDDEN: i32 = 1; +pub const DISPLAY_NO_PRINT: i32 = 2; +pub const DISPLAY_NO_VIEW: i32 = 3; + +/// A field as scripts see it. +#[derive(Clone, Debug, PartialEq)] +pub struct FieldState { + pub name: String, + pub kind: FieldType, + /// Text: the text. Check box / radio: the selected export value (empty when off). Choice: + /// the selected export values. + pub value: Vec, + pub default: Vec, + pub readonly: bool, + pub required: bool, + pub display: i32, + /// Choice options (export value, display text); check boxes and radios: their export + /// values, one per widget. + pub options: Vec<(String, String)>, + pub char_limit: Option, + /// `textColor` / `fillColor` as Acrobat colour arrays (`["RGB", r, g, b]` etc.), when set. + pub text_color: Option>, + pub fill_color: Option>, +} + +impl FieldState { + pub fn new(name: impl Into, kind: FieldType, value: Vec) -> FieldState { + FieldState { + name: name.into(), + kind, + value, + default: Vec::new(), + readonly: false, + required: false, + display: DISPLAY_VISIBLE, + options: Vec::new(), + char_limit: None, + text_color: None, + fill_color: None, + } + } + + fn single(&self) -> String { + self.value.first().cloned().unwrap_or_default() + } +} + +/// The event a script runs for. +#[derive(Clone, Debug, PartialEq)] +pub struct Event { + /// `Keystroke`, `Validate`, `Calculate`, `Format`, `Mouse Up`, `Open`, … + pub name: String, + /// `Field`, `Doc`, `Page`, … + pub kind: String, + /// The field the event is for. + pub target: Option, + pub value: String, + pub change: String, + pub will_commit: bool, +} + +impl Event { + /// A field event (`event.type` = "Field"). + pub fn field(name: &str, target: &str, value: &str) -> Event { + Event { name: name.into(), kind: "Field".into(), target: Some(target.into()), value: value.into(), change: String::new(), will_commit: true } + } + + /// A document event (`event.type` = "Doc"), e.g. Open or WillSave. + pub fn doc(name: &str) -> Event { + Event { name: name.into(), kind: "Doc".into(), target: None, value: String::new(), change: String::new(), will_commit: false } + } +} + +/// About the document, for `this.numPages`, `this.info` and the like. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct DocInfo { + pub file_name: String, + pub num_pages: usize, + /// The current page, 0-based. + pub page: usize, + /// Document information (`this.info.title` etc.): lower-case key, value. + pub info: Vec<(String, String)>, +} + +/// Something a script asked the viewer to do. +#[derive(Clone, Debug, PartialEq)] +pub enum Request { + /// `this.resetForm([names])` (empty: every field). + Reset(Vec), + Print, + /// `this.pageNum = n` (0-based). + GoToPage(usize), + LaunchUrl(String), + /// `this.submitForm(url)`: never sent on PrintCraft's own. + Submit(String), + /// `field.setFocus()`. + Focus(String), + Beep, +} + +/// What running a script did. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Outcome { + /// `event.rc`: `false` rejects the keystroke or value. + pub rc: bool, + /// `event.value` after the script. + pub value: String, + pub change: String, + /// Fields whose value or properties the script changed, as they are now. + pub changed: Vec, + pub alerts: Vec, + pub console: Vec, + pub requests: Vec, + /// The script's error (syntax error, exception, or a limit reached), if it failed. + pub error: Option, + /// The script's completion value (what the console prints), unless undefined. + pub result: Option, +} + +/// Bounds that keep a script from hanging the viewer. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Limits { + pub loop_iterations: u64, + pub recursion: usize, +} + +impl Default for Limits { + fn default() -> Self { + Limits { loop_iterations: 1_000_000, recursion: 256 } + } +} + +struct Host { + fields: Vec, + changed: BTreeSet, + alerts: Vec, + console: Vec, + requests: Vec, + doc: DocInfo, +} + +type Shared = RefCell; + +fn host(ctx: &Context) -> &Shared { + ctx.get_data::().expect("host state is installed before scripts run") +} + +fn s(v: &str) -> JsValue { + JsValue::from(JsString::from(v)) +} + +fn text(v: &JsValue, ctx: &mut Context) -> JsResult { + if v.is_undefined() || v.is_null() { + return Ok(String::new()); + } + Ok(v.to_string(ctx)?.to_std_string_escaped()) +} + +fn arg(args: &[JsValue], i: usize) -> JsValue { + args.get(i).cloned().unwrap_or_default() +} + +fn error(msg: impl Into) -> JsError { + JsNativeError::typ().with_message(msg.into()).into() +} + +/// A JavaScript number for a text value that reads as one, as Acrobat's `field.value` gives. +fn as_number(t: &str) -> Option { + let t = t.trim(); + if t.is_empty() || t.starts_with('+') || t.len() > 1 && t.starts_with('0') && !t.starts_with("0.") { + return None; + } + t.parse::().ok().filter(|v| v.is_finite()) +} + +fn function(ctx: &mut Context, f: NativeFunction) -> JsFunction { + FunctionObjectBuilder::new(ctx.realm(), f).build() +} + +fn strings(v: &JsValue, ctx: &mut Context) -> JsResult> { + if let Some(o) = v.as_object() + && o.is_array() + { + let a = JsArray::from_object(o.clone())?; + let n = a.length(ctx)?; + let mut out = Vec::new(); + for i in 0..n { + let x = a.get(i, ctx)?; + out.push(text(&x, ctx)?); + } + return Ok(out); + } + Ok(if v.is_undefined() || v.is_null() { Vec::new() } else { vec![text(v, ctx)?] }) +} + +fn colour(c: &Option>, ctx: &mut Context) -> JsValue { + match c { + None => JsArray::from_iter([s("T")], ctx).into(), + Some(parts) => { + let vals: Vec = + parts.iter().enumerate().map(|(i, p)| if i == 0 { s(p) } else { JsValue::from(p.parse::().unwrap_or(0.0)) }).collect(); + JsArray::from_iter(vals, ctx).into() + } + } +} + +// ── field objects ─────────────────────────────────────────────────────────────────────────── + +fn field_index(ctx: &Context, name: &JsString) -> JsResult { + let n = name.to_std_string_escaped(); + host(ctx).borrow().fields.iter().position(|f| f.name == n).ok_or_else(|| error(format!("no field named {n}"))) +} + +fn read(ctx: &Context, name: &JsString) -> JsResult { + let i = field_index(ctx, name)?; + Ok(host(ctx).borrow().fields[i].clone()) +} + +fn write(ctx: &Context, name: &JsString, change: impl FnOnce(&mut FieldState)) -> JsResult<()> { + let i = field_index(ctx, name)?; + let mut h = host(ctx).borrow_mut(); + change(&mut h.fields[i]); + h.changed.insert(i); + Ok(()) +} + +fn value_of(f: &FieldState, ctx: &mut Context) -> JsValue { + match f.kind { + FieldType::CheckBox | FieldType::RadioButton => s(f.value.first().map_or("Off", String::as_str)), + FieldType::ListBox if f.value.len() > 1 => { + let vals: Vec = f.value.iter().map(|v| s(v)).collect(); + JsArray::from_iter(vals, ctx).into() + } + _ => { + let v = f.single(); + as_number(&v).map_or_else(|| s(&v), JsValue::from) + } + } +} + +fn set_value(f: &mut FieldState, vals: Vec) { + f.value = match f.kind { + FieldType::CheckBox | FieldType::RadioButton => vals.into_iter().filter(|v| v != "Off" && !v.is_empty()).take(1).collect(), + FieldType::ListBox => vals, + _ => vals.into_iter().take(1).collect(), + }; +} + +macro_rules! getter { + ($ctx:expr, $name:expr, |$f:ident, $c:ident| $body:expr) => {{ + fn get(_: &JsValue, _: &[JsValue], name: &JsString, $c: &mut Context) -> JsResult { + let $f = read($c, name)?; + Ok($body) + } + function($ctx, NativeFunction::from_copy_closure_with_captures(get, $name.clone())) + }}; +} + +fn field_object(ctx: &mut Context, name: &str) -> JsObject { + let n = JsString::from(name); + let value_get = getter!(ctx, n, |f, c| value_of(&f, c)); + let value_set = { + fn set(_: &JsValue, args: &[JsValue], name: &JsString, c: &mut Context) -> JsResult { + let vals = strings(&arg(args, 0), c)?; + write(c, name, |f| set_value(f, vals))?; + Ok(JsValue::undefined()) + } + function(ctx, NativeFunction::from_copy_closure_with_captures(set, n.clone())) + }; + let as_string = getter!(ctx, n, |f, _c| s(&match f.kind { + FieldType::CheckBox | FieldType::RadioButton => f.value.first().cloned().unwrap_or_else(|| "Off".into()), + _ => f.value.join(","), + })); + let default = getter!(ctx, n, |f, _c| s(&f.default.first().cloned().unwrap_or_default())); + let kind = getter!(ctx, n, |f, _c| s(f.kind.name())); + let fname = getter!(ctx, n, |f, _c| s(&f.name)); + let readonly_get = getter!(ctx, n, |f, _c| JsValue::from(f.readonly)); + let readonly_set = { + fn set(_: &JsValue, args: &[JsValue], name: &JsString, c: &mut Context) -> JsResult { + let on = arg(args, 0).to_boolean(); + write(c, name, |f| f.readonly = on)?; + Ok(JsValue::undefined()) + } + function(ctx, NativeFunction::from_copy_closure_with_captures(set, n.clone())) + }; + let required_get = getter!(ctx, n, |f, _c| JsValue::from(f.required)); + let required_set = { + fn set(_: &JsValue, args: &[JsValue], name: &JsString, c: &mut Context) -> JsResult { + let on = arg(args, 0).to_boolean(); + write(c, name, |f| f.required = on)?; + Ok(JsValue::undefined()) + } + function(ctx, NativeFunction::from_copy_closure_with_captures(set, n.clone())) + }; + let display_get = getter!(ctx, n, |f, _c| JsValue::from(f.display)); + let display_set = { + fn set(_: &JsValue, args: &[JsValue], name: &JsString, c: &mut Context) -> JsResult { + let d = arg(args, 0).to_number(c)? as i32; + write(c, name, |f| f.display = d.clamp(0, 3))?; + Ok(JsValue::undefined()) + } + function(ctx, NativeFunction::from_copy_closure_with_captures(set, n.clone())) + }; + let hidden_get = getter!(ctx, n, |f, _c| JsValue::from(f.display == DISPLAY_HIDDEN)); + let hidden_set = { + fn set(_: &JsValue, args: &[JsValue], name: &JsString, c: &mut Context) -> JsResult { + let on = arg(args, 0).to_boolean(); + write(c, name, |f| f.display = if on { DISPLAY_HIDDEN } else { DISPLAY_VISIBLE })?; + Ok(JsValue::undefined()) + } + function(ctx, NativeFunction::from_copy_closure_with_captures(set, n.clone())) + }; + let num_items = getter!(ctx, n, |f, _c| JsValue::from(f.options.len() as f64)); + let char_limit = getter!(ctx, n, |f, _c| JsValue::from(f.char_limit.unwrap_or(0) as f64)); + let export_values = getter!(ctx, n, |f, c| { + let vals: Vec = f.options.iter().map(|o| s(&o.0)).collect(); + JsArray::from_iter(vals, c).into() + }); + let indices = getter!(ctx, n, |f, c| { + let idx: Vec = f.options.iter().enumerate().filter(|(_, o)| f.value.contains(&o.0)).map(|(i, _)| JsValue::from(i as f64)).collect(); + if idx.len() == 1 { idx[0].clone() } else { JsArray::from_iter(idx, c).into() } + }); + let text_color_get = getter!(ctx, n, |f, c| colour(&f.text_color, c)); + let text_color_set = { + fn set(_: &JsValue, args: &[JsValue], name: &JsString, c: &mut Context) -> JsResult { + let v = strings(&arg(args, 0), c)?; + write(c, name, |f| f.text_color = Some(v))?; + Ok(JsValue::undefined()) + } + function(ctx, NativeFunction::from_copy_closure_with_captures(set, n.clone())) + }; + let fill_color_get = getter!(ctx, n, |f, c| colour(&f.fill_color, c)); + let fill_color_set = { + fn set(_: &JsValue, args: &[JsValue], name: &JsString, c: &mut Context) -> JsResult { + let v = strings(&arg(args, 0), c)?; + write(c, name, |f| f.fill_color = Some(v))?; + Ok(JsValue::undefined()) + } + function(ctx, NativeFunction::from_copy_closure_with_captures(set, n.clone())) + }; + + fn get_item_at(_: &JsValue, args: &[JsValue], name: &JsString, c: &mut Context) -> JsResult { + let f = read(c, name)?; + let i = arg(args, 0).to_number(c)?; + // bExportValue defaults to true. + let export = args.get(1).is_none_or(JsValue::to_boolean); + let i = if i < 0.0 { f.options.len().saturating_sub(1) } else { i as usize }; + let o = f.options.get(i).ok_or_else(|| error(format!("{} has no item {i}", f.name)))?; + Ok(s(if export { &o.0 } else { &o.1 })) + } + fn is_box_checked(_: &JsValue, args: &[JsValue], name: &JsString, c: &mut Context) -> JsResult { + let f = read(c, name)?; + let i = arg(args, 0).to_number(c)? as usize; + let on = f.options.get(i).is_some_and(|o| f.value.first() == Some(&o.0)); + Ok(JsValue::from(on)) + } + fn check_this_box(_: &JsValue, args: &[JsValue], name: &JsString, c: &mut Context) -> JsResult { + let f = read(c, name)?; + let i = arg(args, 0).to_number(c)? as usize; + let on = args.get(1).is_none_or(JsValue::to_boolean); + let export = f.options.get(i).map(|o| o.0.clone()).ok_or_else(|| error(format!("{} has no widget {i}", f.name)))?; + write(c, name, |f| { + if on { + f.value = vec![export]; + } else if f.value.first() == Some(&export) { + f.value.clear(); + } + })?; + Ok(JsValue::undefined()) + } + fn set_focus(_: &JsValue, _: &[JsValue], name: &JsString, c: &mut Context) -> JsResult { + host(c).borrow_mut().requests.push(Request::Focus(name.to_std_string_escaped())); + Ok(JsValue::undefined()) + } + + let rw = Attribute::CONFIGURABLE | Attribute::ENUMERABLE; + let get_item_at = NativeFunction::from_copy_closure_with_captures(get_item_at, n.clone()); + let is_box_checked = NativeFunction::from_copy_closure_with_captures(is_box_checked, n.clone()); + let check_this_box = NativeFunction::from_copy_closure_with_captures(check_this_box, n.clone()); + let set_focus = NativeFunction::from_copy_closure_with_captures(set_focus, n.clone()); + ObjectInitializer::new(ctx) + .accessor(js_string!("value"), Some(value_get), Some(value_set), rw) + .accessor(js_string!("valueAsString"), Some(as_string), None, rw) + .accessor(js_string!("defaultValue"), Some(default), None, rw) + .accessor(js_string!("type"), Some(kind), None, rw) + .accessor(js_string!("name"), Some(fname), None, rw) + .accessor(js_string!("readonly"), Some(readonly_get), Some(readonly_set), rw) + .accessor(js_string!("required"), Some(required_get), Some(required_set), rw) + .accessor(js_string!("display"), Some(display_get), Some(display_set), rw) + .accessor(js_string!("hidden"), Some(hidden_get), Some(hidden_set), rw) + .accessor(js_string!("numItems"), Some(num_items), None, rw) + .accessor(js_string!("charLimit"), Some(char_limit), None, rw) + .accessor(js_string!("exportValues"), Some(export_values), None, rw) + .accessor(js_string!("currentValueIndices"), Some(indices), None, rw) + .accessor(js_string!("textColor"), Some(text_color_get), Some(text_color_set), rw) + .accessor(js_string!("fillColor"), Some(fill_color_get), Some(fill_color_set), rw) + .function(get_item_at, js_string!("getItemAt"), 2) + .function(is_box_checked, js_string!("isBoxChecked"), 1) + .function(check_this_box, js_string!("checkThisBox"), 2) + .function(set_focus, js_string!("setFocus"), 0) + .build() +} + +// ── the document (global object) ──────────────────────────────────────────────────────────── + +fn get_field(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let name = text(&arg(args, 0), ctx)?; + let exists = host(ctx).borrow().fields.iter().any(|f| f.name == name); + if exists { + return Ok(field_object(ctx, &name).into()); + } + // A parent name (e.g. "total" for "total.0"): its first kid stands in, as for groups. + let prefix = format!("{name}."); + let kid = host(ctx).borrow().fields.iter().find(|f| f.name.starts_with(&prefix)).map(|f| f.name.clone()); + Ok(kid.map_or(JsValue::null(), |k| field_object(ctx, &k).into())) +} + +fn get_nth_field_name(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let i = arg(args, 0).to_number(ctx)?; + let name = host(ctx).borrow().fields.get(i.max(0.0) as usize).map(|f| f.name.clone()); + Ok(name.map_or(JsValue::null(), |n| s(&n))) +} + +fn reset_form(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let names = strings(&arg(args, 0), ctx)?; + host(ctx).borrow_mut().requests.push(Request::Reset(names)); + Ok(JsValue::undefined()) +} + +fn print(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { + host(ctx).borrow_mut().requests.push(Request::Print); + Ok(JsValue::undefined()) +} + +fn submit_form(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let a = arg(args, 0); + let url = match a.as_object() { + Some(o) if !o.is_array() => { + let u = o.get(js_string!("cURL"), ctx)?; + text(&u, ctx)? + } + _ => text(&a, ctx)?, + }; + host(ctx).borrow_mut().requests.push(Request::Submit(url)); + Ok(JsValue::undefined()) +} + +fn noop(_: &JsValue, _: &[JsValue], _: &mut Context) -> JsResult { + Ok(JsValue::undefined()) +} + +fn num_fields(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { + Ok(JsValue::from(host(ctx).borrow().fields.len() as f64)) +} + +fn page_num_get(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { + Ok(JsValue::from(host(ctx).borrow().doc.page as f64)) +} + +fn page_num_set(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let n = arg(args, 0).to_number(ctx)?; + let mut h = host(ctx).borrow_mut(); + let last = h.doc.num_pages.saturating_sub(1); + let p = (n.max(0.0) as usize).min(last); + h.doc.page = p; + h.requests.push(Request::GoToPage(p)); + Ok(JsValue::undefined()) +} + +// ── app, util, console ────────────────────────────────────────────────────────────────────── + +/// A message argument: a string, or an object with `cMsg`. +fn message(v: &JsValue, key: &str, ctx: &mut Context) -> JsResult { + match v.as_object() { + Some(o) if !o.is_array() && !o.is_callable() => { + let m = o.get(JsString::from(key), ctx)?; + text(&m, ctx) + } + _ => text(v, ctx), + } +} + +fn alert(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let m = message(&arg(args, 0), "cMsg", ctx)?; + host(ctx).borrow_mut().alerts.push(m); + // The OK button. + Ok(JsValue::from(1)) +} + +fn beep(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { + host(ctx).borrow_mut().requests.push(Request::Beep); + Ok(JsValue::undefined()) +} + +fn launch_url(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let u = message(&arg(args, 0), "cURL", ctx)?; + host(ctx).borrow_mut().requests.push(Request::LaunchUrl(u)); + Ok(JsValue::undefined()) +} + +fn response(_: &JsValue, _: &[JsValue], _: &mut Context) -> JsResult { + // No one to answer: as if the dialog was cancelled. + Ok(JsValue::null()) +} + +fn println(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let m = text(&arg(args, 0), ctx)?; + host(ctx).borrow_mut().console.push(m); + Ok(JsValue::undefined()) +} + +fn console_clear(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { + host(ctx).borrow_mut().console.clear(); + Ok(JsValue::undefined()) +} + +fn timer(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { + // Timers never fire in the sandbox; scripts get an object they can pass to clear*. + Ok(ObjectInitializer::new(ctx).build().into()) +} + +/// `util.printf(cFormat, …)`: `%[,n][+ 0#][width][.prec](d|f|s|x)`, where `,0` groups +/// thousands with commas (the default), `,1` doesn't, `,2` uses `.` and `,`, `,3` uses `.` +/// without grouping. +fn printf(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let fmt = text(&arg(args, 0), ctx)?; + let mut out = String::new(); + let mut next = 1; + let mut chars = fmt.chars().peekable(); + while let Some(c) = chars.next() { + if c != '%' { + out.push(c); + continue; + } + if chars.peek() == Some(&'%') { + chars.next(); + out.push('%'); + continue; + } + let mut sep = 1u8; + if chars.peek() == Some(&',') { + chars.next(); + sep = chars.next().and_then(|d| d.to_digit(10)).unwrap_or(0) as u8; + } + let (mut plus, mut space, mut zero) = (false, false, false); + while let Some(&f) = chars.peek() { + match f { + '+' => plus = true, + ' ' => space = true, + '0' => zero = true, + '#' => {} + _ => break, + } + chars.next(); + } + let mut width = 0usize; + while let Some(d) = chars.peek().and_then(|d| d.to_digit(10)) { + width = width * 10 + d as usize; + chars.next(); + } + let mut prec = None; + if chars.peek() == Some(&'.') { + chars.next(); + let mut p = 0usize; + while let Some(d) = chars.peek().and_then(|d| d.to_digit(10)) { + p = p * 10 + d as usize; + chars.next(); + } + prec = Some(p); + } + let conv = chars.next().unwrap_or('s'); + let v = arg(args, next); + next += 1; + let body = match conv { + 'd' | 'f' => { + let n = v.to_number(ctx)?; + let p = if conv == 'd' { 0 } else { prec.unwrap_or(6) }; + let n = if conv == 'd' { n.trunc() } else { n }; + let mut t = group(&format!("{:.*}", p, n.abs()), sep); + if n < 0.0 { + t.insert(0, '-'); + } else if plus { + t.insert(0, '+'); + } else if space { + t.insert(0, ' '); + } + t + } + 'x' => format!("{:X}", v.to_number(ctx)? as i64), + _ => { + let t = text(&v, ctx)?; + match prec { + Some(p) => t.chars().take(p).collect(), + None => t, + } + } + }; + let pad = width.saturating_sub(body.chars().count()); + if zero && matches!(conv, 'd' | 'f') { + let (sign, digits) = if body.starts_with(['-', '+', ' ']) { body.split_at(1) } else { ("", body.as_str()) }; + out.push_str(sign); + out.push_str(&"0".repeat(pad)); + out.push_str(digits); + } else { + out.push_str(&" ".repeat(pad)); + out.push_str(&body); + } + } + Ok(s(&out)) +} + +/// Group the integer part of a plain decimal number per `util.printf`'s `,n` flag. +fn group(t: &str, sep: u8) -> String { + let (int, frac) = t.split_once('.').map_or((t, None), |(a, b)| (a, Some(b))); + let (thousands, point) = match sep { + 0 => (Some(','), '.'), + 2 => (Some('.'), ','), + 3 => (None, ','), + _ => (None, '.'), + }; + let mut g = String::new(); + for (i, ch) in int.chars().enumerate() { + if let Some(th) = thousands + && i > 0 + && (int.len() - i).is_multiple_of(3) + { + g.push(th); + } + g.push(ch); + } + if let Some(f) = frac { + g.push(point); + g.push_str(f); + } + g +} + +const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]; +const DAYS: [&str; 7] = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; + +/// `util.printd(cFormat, oDate)`: yyyy yy mmmm mmm mm m dddd ddd dd d HH H hh h MM M ss s tt, +/// and the numeric formats 0 (D:yyyymmddHHMMss), 1 (yyyy.mm.dd HH:MM:ss) and 2 (m/d/yy h:MM:ss tt). +fn printd(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let f = arg(args, 0); + let fmt = if f.is_number() { + match f.to_number(ctx)? as i32 { + 0 => "D:yyyymmddHHMMss".to_string(), + 1 => "yyyy.mm.dd HH:MM:ss".to_string(), + _ => "m/d/yy h:MM:ss tt".to_string(), + } + } else { + text(&f, ctx)? + }; + let d = arg(args, 1); + let Some(date) = d.as_object() else { return Err(error("util.printd needs a Date")) }; + let mut part = |m: &str| -> JsResult { + let func = date.get(JsString::from(m), ctx)?; + let func = func.as_callable().ok_or_else(|| error("util.printd needs a Date"))?; + let v = func.call(&d, &[], ctx)?.to_number(ctx)?; + if v.is_nan() { + return Err(error("invalid date")); + } + Ok(v as i64) + }; + let (y, mo, day, wd, h, mi, sec) = + (part("getFullYear")?, part("getMonth")?, part("getDate")?, part("getDay")?, part("getHours")?, part("getMinutes")?, part("getSeconds")?); + let mut out = String::new(); + let b = fmt.as_bytes(); + let mut i = 0; + while i < b.len() { + let c = b[i]; + let run = b[i..].iter().take_while(|x| **x == c).count(); + let push_num = |out: &mut String, v: i64, n: usize| { + if n >= 2 { + out.push_str(&format!("{v:02}")); + } else { + out.push_str(&v.to_string()); + } + }; + match c { + b'y' => { + if run >= 4 { + out.push_str(&format!("{y:04}")); + } else { + out.push_str(&format!("{:02}", y % 100)); + } + } + b'm' => match run { + r if r >= 4 => out.push_str(MONTHS[mo as usize % 12]), + 3 => out.push_str(&MONTHS[mo as usize % 12][..3]), + n => push_num(&mut out, mo + 1, n), + }, + b'd' => match run { + r if r >= 4 => out.push_str(DAYS[wd as usize % 7]), + 3 => out.push_str(&DAYS[wd as usize % 7][..3]), + n => push_num(&mut out, day, n), + }, + b'H' => push_num(&mut out, h, run), + b'h' => push_num(&mut out, if h % 12 == 0 { 12 } else { h % 12 }, run), + b'M' => push_num(&mut out, mi, run), + b's' => push_num(&mut out, sec, run), + b't' => { + let ampm = if h < 12 { "am" } else { "pm" }; + out.push_str(if run >= 2 { ampm } else { &m[..1] }); + } + b'\\' if i + 1 < b.len() => { + out.push(b[i + 1] as char); + i += 2; + continue; + } + _ => { + // Copy the rest of a multi-byte character too. + let ch = fmt[i..].chars().next().unwrap_or(' '); + out.push(ch); + i += ch.len_utf8(); + continue; + } + } + i += run; + } + Ok(s(&out)) +} + +/// `util.printx(cFormat, cSource)`: `?` any character, `X` letter or digit, `A` letter, `9` +/// digit, `*` the rest, `\` escape, `>` / `<` / `=` upper / lower / as is. +fn printx(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let fmt: Vec = text(&arg(args, 0), ctx)?.chars().collect(); + let src: Vec = text(&arg(args, 1), ctx)?.chars().collect(); + let (mut out, mut j, mut case) = (String::new(), 0, 0); + let mut i = 0; + let push = |out: &mut String, c: char, case: i32| match case { + 1 => out.extend(c.to_uppercase()), + -1 => out.extend(c.to_lowercase()), + _ => out.push(c), + }; + while i < fmt.len() { + let f = fmt[i]; + i += 1; + let take = |j: &mut usize, ok: &dyn Fn(char) -> bool| -> Option { + while *j < src.len() { + let c = src[*j]; + *j += 1; + if ok(c) { + return Some(c); + } + } + None + }; + match f { + '?' => { + if let Some(c) = take(&mut j, &|_| true) { + push(&mut out, c, case) + } + } + 'X' => { + if let Some(c) = take(&mut j, &|c| c.is_alphanumeric()) { + push(&mut out, c, case) + } + } + 'A' => { + if let Some(c) = take(&mut j, &|c| c.is_alphabetic()) { + push(&mut out, c, case) + } + } + '9' => { + if let Some(c) = take(&mut j, &|c| c.is_ascii_digit()) { + push(&mut out, c, case) + } + } + '*' => { + for c in src[j.min(src.len())..].iter().copied() { + push(&mut out, c, case); + } + j = src.len(); + } + '\\' if i < fmt.len() => { + out.push(fmt[i]); + i += 1; + } + '>' => case = 1, + '<' => case = -1, + '=' => case = 0, + c => out.push(c), + } + } + Ok(s(&out)) +} + +fn color_object(ctx: &mut Context) -> JsObject { + let mut entries: Vec<(&str, Vec)> = vec![ + ("transparent", vec![s("T")]), + ("black", vec![s("G"), 0.into()]), + ("white", vec![s("G"), 1.into()]), + ("red", vec![s("RGB"), 1.into(), 0.into(), 0.into()]), + ("green", vec![s("RGB"), 0.into(), 1.into(), 0.into()]), + ("blue", vec![s("RGB"), 0.into(), 0.into(), 1.into()]), + ("cyan", vec![s("CMYK"), 1.into(), 0.into(), 0.into(), 0.into()]), + ("magenta", vec![s("CMYK"), 0.into(), 1.into(), 0.into(), 0.into()]), + ("yellow", vec![s("CMYK"), 0.into(), 0.into(), 1.into(), 0.into()]), + ("dkGray", vec![s("G"), JsValue::from(0.25)]), + ("gray", vec![s("G"), JsValue::from(0.5)]), + ("ltGray", vec![s("G"), JsValue::from(0.75)]), + ]; + let arrays: Vec<(&str, JsValue)> = entries.drain(..).map(|(k, v)| (k, JsArray::from_iter(v, ctx).into())).collect(); + let mut o = ObjectInitializer::new(ctx); + for (k, v) in arrays { + o.property(JsString::from(k), v, Attribute::all()); + } + o.build() +} + +fn install(ctx: &mut Context) -> JsResult<()> { + let globals: [(&str, usize, NativeFunction); 9] = [ + ("getField", 1, NativeFunction::from_fn_ptr(get_field)), + ("getNthFieldName", 1, NativeFunction::from_fn_ptr(get_nth_field_name)), + ("resetForm", 1, NativeFunction::from_fn_ptr(reset_form)), + ("print", 1, NativeFunction::from_fn_ptr(print)), + ("submitForm", 1, NativeFunction::from_fn_ptr(submit_form)), + ("mailDoc", 1, NativeFunction::from_fn_ptr(noop)), + ("calculateNow", 0, NativeFunction::from_fn_ptr(noop)), + ("syncAnnotScan", 0, NativeFunction::from_fn_ptr(noop)), + ("dirty", 0, NativeFunction::from_fn_ptr(noop)), + ]; + for (name, len, f) in globals { + ctx.register_global_callable(JsString::from(name), len, f)?; + } + let global = ctx.global_object(); + let rw = Attribute::CONFIGURABLE; + let nf = function(ctx, NativeFunction::from_fn_ptr(num_fields)); + let pg = function(ctx, NativeFunction::from_fn_ptr(page_num_get)); + let ps = function(ctx, NativeFunction::from_fn_ptr(page_num_set)); + let (file, pages, info) = { + let h = host(ctx).borrow(); + (h.doc.file_name.clone(), h.doc.num_pages, h.doc.info.clone()) + }; + let mut io = ObjectInitializer::new(ctx); + for (k, v) in &info { + io.property(JsString::from(k.as_str()), s(v), Attribute::all()); + } + let info = io.build(); + use boa_engine::property::PropertyDescriptor; + global.define_property_or_throw(js_string!("numFields"), PropertyDescriptor::builder().get(nf).configurable(true), ctx)?; + global.define_property_or_throw(js_string!("pageNum"), PropertyDescriptor::builder().get(pg).set(ps).configurable(true), ctx)?; + ctx.register_global_property(js_string!("numPages"), JsValue::from(pages as f64), rw)?; + ctx.register_global_property(js_string!("documentFileName"), s(&file), rw)?; + ctx.register_global_property(js_string!("info"), info, rw)?; + + let app = ObjectInitializer::new(ctx) + .function(NativeFunction::from_fn_ptr(alert), js_string!("alert"), 1) + .function(NativeFunction::from_fn_ptr(beep), js_string!("beep"), 0) + .function(NativeFunction::from_fn_ptr(response), js_string!("response"), 1) + .function(NativeFunction::from_fn_ptr(launch_url), js_string!("launchURL"), 2) + .function(NativeFunction::from_fn_ptr(timer), js_string!("setTimeOut"), 2) + .function(NativeFunction::from_fn_ptr(timer), js_string!("setInterval"), 2) + .function(NativeFunction::from_fn_ptr(noop), js_string!("clearTimeOut"), 1) + .function(NativeFunction::from_fn_ptr(noop), js_string!("clearInterval"), 1) + .property(js_string!("viewerType"), s("Exchange-Pro"), Attribute::READONLY) + .property(js_string!("viewerVariation"), s("Full"), Attribute::READONLY) + .property(js_string!("viewerVersion"), JsValue::from(24.0), Attribute::READONLY) + .property(js_string!("formsVersion"), JsValue::from(24.0), Attribute::READONLY) + .property(js_string!("platform"), s(platform()), Attribute::READONLY) + .property(js_string!("language"), s("ENU"), Attribute::READONLY) + .build(); + ctx.register_global_property(js_string!("app"), app, rw)?; + let util = ObjectInitializer::new(ctx) + .function(NativeFunction::from_fn_ptr(printf), js_string!("printf"), 1) + .function(NativeFunction::from_fn_ptr(printd), js_string!("printd"), 2) + .function(NativeFunction::from_fn_ptr(printx), js_string!("printx"), 2) + .build(); + ctx.register_global_property(js_string!("util"), util, rw)?; + let console = ObjectInitializer::new(ctx) + .function(NativeFunction::from_fn_ptr(println), js_string!("println"), 1) + .function(NativeFunction::from_fn_ptr(noop), js_string!("show"), 0) + .function(NativeFunction::from_fn_ptr(noop), js_string!("hide"), 0) + .function(NativeFunction::from_fn_ptr(console_clear), js_string!("clear"), 0) + .build(); + ctx.register_global_property(js_string!("console"), console, rw)?; + let display = ObjectInitializer::new(ctx) + .property(js_string!("visible"), DISPLAY_VISIBLE, Attribute::READONLY) + .property(js_string!("hidden"), DISPLAY_HIDDEN, Attribute::READONLY) + .property(js_string!("noPrint"), DISPLAY_NO_PRINT, Attribute::READONLY) + .property(js_string!("noView"), DISPLAY_NO_VIEW, Attribute::READONLY) + .build(); + ctx.register_global_property(js_string!("display"), display, rw)?; + let color = color_object(ctx); + ctx.register_global_property(js_string!("color"), color, rw)?; + Ok(()) +} + +fn platform() -> &'static str { + if cfg!(target_os = "macos") { + "MAC" + } else if cfg!(target_os = "windows") { + "WIN" + } else { + "UNIX" + } +} + +fn event_object(ctx: &mut Context, e: &Event) -> JsObject { + let target = e.target.as_ref().map_or(JsValue::null(), |t| field_object(ctx, t).into()); + ObjectInitializer::new(ctx) + .property(js_string!("name"), s(&e.name), Attribute::all()) + .property(js_string!("type"), s(&e.kind), Attribute::all()) + .property(js_string!("value"), s(&e.value), Attribute::all()) + .property(js_string!("change"), s(&e.change), Attribute::all()) + .property(js_string!("changeEx"), s(&e.change), Attribute::all()) + .property(js_string!("willCommit"), e.will_commit, Attribute::all()) + .property(js_string!("rc"), true, Attribute::all()) + .property(js_string!("target"), target, Attribute::all()) + .property(js_string!("targetName"), s(e.target.as_deref().unwrap_or("")), Attribute::all()) + .property(js_string!("selStart"), 0, Attribute::all()) + .property(js_string!("selEnd"), 0, Attribute::all()) + .property(js_string!("commitKey"), 0, Attribute::all()) + .property(js_string!("modifier"), false, Attribute::all()) + .property(js_string!("shift"), false, Attribute::all()) + .build() +} + +/// Run `script` for `event`, after the document-level scripts (`doc_scripts`, which define the +/// functions field scripts call), with the form's `fields`. +pub fn run(script: &str, event: &Event, doc: &DocInfo, fields: &[FieldState], doc_scripts: &[String], limits: Limits) -> Outcome { + let mut ctx = Context::default(); + ctx.runtime_limits_mut().set_loop_iteration_limit(limits.loop_iterations); + ctx.runtime_limits_mut().set_recursion_limit(limits.recursion); + ctx.insert_data(RefCell::new(Host { + fields: fields.to_vec(), + changed: BTreeSet::new(), + alerts: Vec::new(), + console: Vec::new(), + requests: Vec::new(), + doc: doc.clone(), + })); + let mut out = Outcome { rc: true, value: event.value.clone(), change: event.change.clone(), ..Default::default() }; + let result = (|| -> JsResult<()> { + install(&mut ctx)?; + let ev = event_object(&mut ctx, event); + ctx.register_global_property(js_string!("event"), ev.clone(), Attribute::all())?; + for d in doc_scripts { + ctx.eval(Source::from_bytes(d))?; + } + let r = ctx.eval(Source::from_bytes(script))?; + if !r.is_undefined() { + out.result = Some(text(&r, &mut ctx)?); + } + let rc = ev.get(js_string!("rc"), &mut ctx)?; + out.rc = rc.to_boolean(); + let v = ev.get(js_string!("value"), &mut ctx)?; + out.value = text(&v, &mut ctx)?; + let c = ev.get(js_string!("change"), &mut ctx)?; + out.change = text(&c, &mut ctx)?; + Ok(()) + })(); + if let Err(e) = result { + out.error = Some(e.to_string()); + } + if let Some(h) = ctx.remove_data::() { + let h = h.into_inner(); + out.changed = h.changed.iter().map(|i| h.fields[*i].clone()).collect(); + out.alerts = h.alerts; + out.console = h.console; + out.requests = h.requests; + } + out +} + +#[cfg(test)] +mod tests; diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs new file mode 100644 index 00000000..274b68a1 --- /dev/null +++ b/crates/js/src/tests.rs @@ -0,0 +1,150 @@ +use super::*; + +fn fields() -> Vec { + let mut agree = FieldState::new("agree", FieldType::CheckBox, vec![]); + agree.options = vec![("Yes".into(), "Yes".into())]; + let mut size = FieldState::new("size", FieldType::ComboBox, vec!["m".into()]); + size.options = vec![("s".into(), "Small".into()), ("m".into(), "Medium".into()), ("l".into(), "Large".into())]; + vec![ + FieldState::new("price", FieldType::Text, vec!["12.5".into()]), + FieldState::new("qty", FieldType::Text, vec!["4".into()]), + FieldState::new("total", FieldType::Text, vec![]), + FieldState::new("zip", FieldType::Text, vec!["02134".into()]), + agree, + size, + ] +} + +fn doc() -> DocInfo { + DocInfo { file_name: "order.pdf".into(), num_pages: 3, page: 0, info: vec![("title".into(), "Order".into())] } +} + +fn go(script: &str, event: &Event) -> Outcome { + run(script, event, &doc(), &fields(), &[], Limits::default()) +} + +#[test] +fn calculate_scripts_read_fields_and_set_the_value() { + let o = go("event.value = this.getField('price').value * getField('qty').value;", &Event::field("Calculate", "total", "")); + assert_eq!(o.error, None); + assert_eq!(o.value, "50"); + assert!(o.rc); +} + +#[test] +fn numbers_with_leading_zeros_stay_text() { + let o = go("event.value = typeof getField('zip').value + ':' + typeof getField('qty').value;", &Event::field("Calculate", "total", "")); + assert_eq!(o.value, "string:number"); +} + +#[test] +fn validate_scripts_reject_with_rc_and_alert() { + let o = go("if (event.value > 10) { app.alert('Too many'); event.rc = false; }", &Event::field("Validate", "qty", "11")); + assert!(!o.rc); + assert_eq!(o.alerts, ["Too many"]); + let o = go("if (event.value > 10) { event.rc = false; }", &Event::field("Validate", "qty", "3")); + assert!(o.rc); +} + +#[test] +fn keystroke_scripts_see_the_change() { + let mut e = Event::field("Keystroke", "qty", "1"); + e.change = "x".into(); + e.will_commit = false; + let o = go("event.rc = /^[0-9]*$/.test(event.change);", &e); + assert!(!o.rc); +} + +#[test] +fn scripts_change_other_fields_and_their_properties() { + let o = go( + "var t = getField('total'); t.value = 99; t.readonly = true; t.display = display.hidden; \ + getField('agree').checkThisBox(0, true); getField('size').value = 'l'; t.textColor = color.red;", + &Event::field("Mouse Up", "agree", ""), + ); + assert_eq!(o.error, None); + let total = o.changed.iter().find(|f| f.name == "total").unwrap(); + assert_eq!(total.value, ["99"]); + assert!(total.readonly); + assert_eq!(total.display, DISPLAY_HIDDEN); + assert_eq!(total.text_color.as_deref(), Some(&["RGB".to_string(), "1".into(), "0".into(), "0".into()][..])); + assert_eq!(o.changed.iter().find(|f| f.name == "agree").unwrap().value, ["Yes"]); + assert_eq!(o.changed.iter().find(|f| f.name == "size").unwrap().value, ["l"]); +} + +#[test] +fn choice_and_check_box_object_model() { + let o = go( + "var s = getField('size'); event.value = [s.numItems, s.getItemAt(2, false), s.currentValueIndices, s.valueAsString, \ + getField('agree').value, getField('agree').isBoxChecked(0), s.type].join('|');", + &Event::field("Calculate", "total", ""), + ); + assert_eq!(o.value, "3|Large|1|m|Off|false|combobox"); +} + +#[test] +fn util_printf_printd_and_printx() { + let o = go( + "event.value = [util.printf('%,0.2f', 1234567.891), util.printf('%05d|%s|%x', 42, 'hi', 255), util.printf('%,2.2f', 1234.5), \ + util.printd('mmm d, yyyy HH:MM', new Date(2024, 0, 5, 9, 7)), util.printd('dddd', new Date(2024, 0, 5)), \ + util.printx('(999) 999-9999', '5551234567'), util.printx('>AAA', 'abc')].join('|');", + &Event::field("Calculate", "total", ""), + ); + assert_eq!(o.error, None); + assert_eq!(o.value, "1,234,567.89|00042|hi|FF|1.234,50|Jan 5, 2024 09:07|Friday|(555) 123-4567|ABC"); +} + +#[test] +fn document_requests_and_console() { + let o = go( + "console.println('hello ' + this.documentFileName + ' ' + numPages + ' ' + info.title); this.pageNum = 2; \ + this.resetForm(['qty']); this.print(); app.launchURL('https://example.org'); this.submitForm({cURL: 'https://example.org/f'});", + &Event::field("Mouse Up", "agree", ""), + ); + assert_eq!(o.error, None); + assert_eq!(o.console, ["hello order.pdf 3 Order"]); + assert_eq!( + o.requests, + [ + Request::GoToPage(2), + Request::Reset(vec!["qty".into()]), + Request::Print, + Request::LaunchUrl("https://example.org".into()), + Request::Submit("https://example.org/f".into()) + ] + ); +} + +#[test] +fn document_level_functions_are_available() { + let o = run( + "event.value = double(getField('qty').value);", + &Event::field("Calculate", "total", ""), + &doc(), + &fields(), + &["function double(x) { return x * 2; }".into()], + Limits::default(), + ); + assert_eq!(o.value, "8"); +} + +#[test] +fn errors_and_runaway_scripts_are_contained() { + let o = go("event.value = nosuch.thing;", &Event::field("Calculate", "total", "keep")); + assert!(o.error.is_some()); + let o = run("while (true) {}", &Event::doc("Open"), &doc(), &fields(), &[], Limits { loop_iterations: 10_000, recursion: 64 }); + assert!(o.error.is_some(), "the loop limit stops it"); + let o = go("function f() { return f(); } f();", &Event::doc("Open")); + assert!(o.error.is_some(), "the recursion limit stops it"); + let o = go("getField('nope').value", &Event::doc("Open")); + assert!(o.error.as_deref().unwrap_or("").contains("null") || o.error.is_some()); +} + +#[test] +fn the_sandbox_has_no_host_access() { + let o = go( + "event.value = [typeof require, typeof process, typeof fetch, typeof XMLHttpRequest, typeof importScripts].join(',');", + &Event::doc("Open"), + ); + assert_eq!(o.value, "undefined,undefined,undefined,undefined,undefined"); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 986e2362..3b4a0e0a 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1694,7 +1694,10 @@ fn run_button(app: &mut PrintCraftApp, index: usize, ctx: &egui::Context, name: app.notify(format!("{name} submits the form to {url}; PrintCraft doesn't send form data. Save the document to keep your entries.")) } B::ImportIcon => app.choose_field_image(name), - B::Script(_) => app.notify(format!("{name} runs a script that needs the JavaScript engine (M6)")), + B::Script(js) => { + let id = app.views[index].id; + app.run_button_script(id, name, &js); + } } } diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index 3c47b8dc..b53379f5 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -224,6 +224,12 @@ impl PrintCraftApp { "export.text" => self.dialog = Some(Dialog::Export(crate::export_ui::ExportKind::Text)), "a11y.check" => self.start_accessibility_check(), "ocr.recognize" => self.dialog = Some(Dialog::RecognizeText), + "tools.js_console" => self.dialog = Some(Dialog::JsConsole), + "tools.document_js" => { + self.doc_js = Default::default(); + self.dialog = Some(Dialog::DocumentJs); + } + "app.preferences" => self.dialog = Some(Dialog::Preferences), "ocr.recognize_batch" => self.ocr_files_dialog(), "edit.edit_text" => { self.quick_tool = crate::QuickTool::EditText; diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index f3a64ec2..5d561682 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -819,6 +819,20 @@ pub fn show(app: &mut PrintCraftApp, ctx: &egui::Context) { close = go || cancel; return; } + Dialog::JsConsole => { + ui.set_width(640.0); + close = crate::js_ui::console_body(ui, app, &t); + return; + } + Dialog::DocumentJs => { + ui.set_width(640.0); + close = crate::js_ui::document_js_body(ui, app, &t); + return; + } + Dialog::Preferences => { + close = crate::js_ui::preferences_body(ui, app, &t); + return; + } Dialog::RecognizeText => { let (go, cancel) = crate::ocr_ui::body(ui, app, &t); ocr_now = go; diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index ccdfca5c..49de89dd 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -60,6 +60,8 @@ impl PrintCraftApp { if std::mem::take(&mut view.comments.tool_done) && !self.comment_prefs.pinned { self.quick_tool = crate::QuickTool::Select; } + let out = self.session.take_js_output(id); + self.handle_js(id, out); true } Err(e) => { diff --git a/crates/ui-egui/src/icon_data.rs b/crates/ui-egui/src/icon_data.rs index 5f9e0993..97ee8aac 100644 --- a/crates/ui-egui/src/icon_data.rs +++ b/crates/ui-egui/src/icon_data.rs @@ -5,6 +5,8 @@ pub static ICONS: &[(&str, &[u8])] = &[ ("accessibility", include_bytes!("../../../assets/icons/accessibility.svg")), ("align-center", include_bytes!("../../../assets/icons/align-center.svg")), ("align-justify", include_bytes!("../../../assets/icons/align-justify.svg")), + ("file-code", include_bytes!("../../../assets/icons/file-code.svg")), + ("square-terminal", include_bytes!("../../../assets/icons/square-terminal.svg")), ("align-left", include_bytes!("../../../assets/icons/align-left.svg")), ("align-right", include_bytes!("../../../assets/icons/align-right.svg")), ("app-window", include_bytes!("../../../assets/icons/app-window.svg")), diff --git a/crates/ui-egui/src/js_ui.rs b/crates/ui-egui/src/js_ui.rs new file mode 100644 index 00000000..cacdc09f --- /dev/null +++ b/crates/ui-egui/src/js_ui.rs @@ -0,0 +1,230 @@ +//! Acrobat JavaScript in the shell: what scripts ask for (alerts, printing, navigation, links), +//! the JavaScript console (⌘J), Document JavaScripts, and Preferences ▸ JavaScript. + +use egui::{Align, Layout}; +use printcraft_engine::js::{JsOutput, Request}; +use printcraft_engine::{DocId, Edit}; + +use crate::theme::{self, Tokens}; +use crate::{PrintCraftApp, widgets}; + +/// The JavaScript console: the input and the output so far. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct JsConsole { + pub input: String, + pub log: Vec, +} + +/// Document JavaScripts: the script being edited. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct DocJsDraft { + pub name: String, + pub script: String, +} + +impl PrintCraftApp { + /// Act on what scripts produced in document `id`: alerts are shown, console output goes to + /// the console, and print / page / link requests are carried out (form submissions are + /// reported, never sent). + pub fn handle_js(&mut self, id: DocId, out: JsOutput) { + if out.is_empty() { + return; + } + self.js_console.log.extend(out.console.iter().cloned()); + for e in &out.errors { + self.js_console.log.push(format!("Error: {e}")); + } + let view = self.views.iter().position(|v| v.id == id); + for r in out.requests { + match r { + Request::Print => self.open_print(), + Request::GoToPage(p) => { + if let Some(i) = view { + self.views[i].go_to_page(p); + } + } + Request::LaunchUrl(u) => { + if let Some(ctx) = &self.ctx { + ctx.open_url(egui::OpenUrl::new_tab(u)); + } + } + Request::Submit(u) => self.notify(format!( + "The form asks to be submitted to {u}; PrintCraft doesn't send form data. Save the document to keep your entries." + )), + Request::Focus(_) | Request::Beep | Request::Reset(_) => {} + } + } + if let Some(a) = out.alerts.last() { + self.notify(a.clone()); + } + } + + /// Run a push button's JavaScript (its Mouse Up action). + pub fn run_button_script(&mut self, id: DocId, field: &str, script: &str) { + match self.session.run_javascript(id, script, Some(field)) { + Ok(o) => { + if let Some(i) = self.views.iter().position(|v| v.id == id) + && let Some(info) = self.session.get(id).map(|d| d.info.clone()) + { + self.views[i].document_changed(&info); + } + let out = JsOutput { alerts: o.alerts, console: o.console, requests: o.requests, errors: o.error.into_iter().collect() }; + self.handle_js(id, out); + } + Err(e) => self.notify(format!("{field}: {e}")), + } + } + + /// The console's Run: evaluate the input in the active document. + pub fn run_console(&mut self) { + let Some((i, id)) = self.active_ids() else { return }; + let script = self.js_console.input.clone(); + if script.trim().is_empty() { + return; + } + self.js_console.log.push(format!("> {}", script.trim())); + match self.session.run_javascript(id, &script, None) { + Ok(o) => { + if let Some(info) = self.session.get(id).map(|d| d.info.clone()) { + self.views[i].document_changed(&info); + } + let (error, result) = (o.error.clone(), o.result.clone()); + let out = JsOutput { alerts: o.alerts, console: o.console, requests: o.requests, errors: Vec::new() }; + self.handle_js(id, out); + match error { + Some(e) => self.js_console.log.push(format!("Error: {e}")), + None => self.js_console.log.extend(result), + } + } + Err(e) => self.js_console.log.push(format!("Error: {e}")), + } + } +} + +fn buttons(ui: &mut egui::Ui, primary: &str, others: &[&str]) -> Option { + let mut clicked = None; + ui.horizontal(|ui| { + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + // Stable ids: the console's output above changes how many widgets come first. + if ui.push_id(primary, |ui| widgets::pill_button(ui, primary, true)).inner.clicked() { + clicked = Some(primary.to_string()); + } + for o in others { + if ui.push_id(o, |ui| widgets::pill_button(ui, o, false)).inner.clicked() { + clicked = Some(o.to_string()); + } + } + }) + }); + clicked +} + +/// The JavaScript console. Returns `true` to close. +pub(crate) fn console_body(ui: &mut egui::Ui, app: &mut PrintCraftApp, t: &Tokens) -> bool { + ui.label(egui::RichText::new("JavaScript Console").font(theme::semibold(18.0))); + ui.add_space(6.0); + if !app.session.javascript() { + ui.label(egui::RichText::new("JavaScript is turned off (Preferences ▸ JavaScript).").small().color(t.text_muted)); + } + egui::Frame::new().fill(t.hover).corner_radius(egui::CornerRadius::same(6)).inner_margin(egui::Margin::same(8)).show(ui, |ui| { + ui.set_width(ui.available_width()); + egui::ScrollArea::vertical().max_height(220.0).stick_to_bottom(true).show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.set_min_height(160.0); + if app.js_console.log.is_empty() { + ui.label(egui::RichText::new("Output appears here.").color(t.text_muted)); + } + for line in &app.js_console.log { + ui.label(egui::RichText::new(line).monospace()); + } + }); + }); + ui.add_space(6.0); + let input = ui.add( + egui::TextEdit::multiline(&mut app.js_console.input) + .code_editor() + .desired_rows(4) + .desired_width(f32::INFINITY) + .hint_text("JavaScript, e.g. getField(\"total\").value") + .id_salt("js-console-input"), + ); + let run_key = input.has_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter) && i.modifiers.command); + ui.add_space(8.0); + match buttons(ui, "Run", &["Close", "Clear"]).as_deref() { + Some("Run") => app.run_console(), + Some("Clear") => app.js_console.log.clear(), + Some("Close") => return true, + _ if run_key => app.run_console(), + _ => {} + } + false +} + +/// Document JavaScripts: list, edit, add and delete. Returns `true` to close. +pub(crate) fn document_js_body(ui: &mut egui::Ui, app: &mut PrintCraftApp, t: &Tokens) -> bool { + ui.label(egui::RichText::new("Document JavaScripts").font(theme::semibold(18.0))); + ui.add_space(6.0); + let scripts = app.active_ids().and_then(|(_, id)| app.session.get(id)).map(|d| d.document_scripts()).unwrap_or_default(); + let mut edit: Option = None; + ui.horizontal(|ui| { + ui.label("Script Name:"); + ui.add(egui::TextEdit::singleline(&mut app.doc_js.name).desired_width(240.0).id_salt("doc-js-name")); + }); + ui.add_space(4.0); + egui::Frame::new().fill(t.hover).corner_radius(egui::CornerRadius::same(6)).inner_margin(egui::Margin::same(8)).show(ui, |ui| { + ui.set_width(ui.available_width()); + egui::ScrollArea::vertical().max_height(120.0).id_salt("doc-js-list").show(ui, |ui| { + ui.set_width(ui.available_width()); + if scripts.is_empty() { + ui.label(egui::RichText::new("This document has no document-level scripts.").color(t.text_muted)); + } + for (name, js) in &scripts { + if ui.selectable_label(app.doc_js.name == *name, name).clicked() { + app.doc_js = DocJsDraft { name: name.clone(), script: js.clone() }; + } + } + }); + }); + ui.add_space(6.0); + ui.add(egui::TextEdit::multiline(&mut app.doc_js.script).code_editor().desired_rows(8).desired_width(f32::INFINITY).id_salt("doc-js-script")); + ui.add_space(8.0); + let name = app.doc_js.name.trim().to_string(); + let close = match buttons(ui, "Save", &["Close", "Delete"]).as_deref() { + Some("Save") if !name.is_empty() => { + edit = Some(Edit::SetDocumentScript { name, script: Some(app.doc_js.script.clone()) }); + false + } + Some("Delete") if scripts.iter().any(|(n, _)| *n == name) => { + edit = Some(Edit::SetDocumentScript { name, script: None }); + app.doc_js = DocJsDraft::default(); + false + } + Some("Close") => true, + _ => false, + }; + if let Some(e) = edit { + app.apply_edit(e); + } + close +} + +/// Preferences (the JavaScript category for now). Returns `true` to close. +pub(crate) fn preferences_body(ui: &mut egui::Ui, app: &mut PrintCraftApp, t: &Tokens) -> bool { + ui.label(egui::RichText::new("Preferences").font(theme::semibold(18.0))); + ui.add_space(8.0); + ui.label(egui::RichText::new("JavaScript").font(theme::semibold(13.0))); + egui::Frame::new().fill(t.hover).corner_radius(egui::CornerRadius::same(6)).inner_margin(egui::Margin::same(10)).show(ui, |ui| { + ui.set_width(ui.available_width()); + let mut on = app.session.javascript(); + if ui.checkbox(&mut on, "Enable Acrobat JavaScript").changed() { + app.session.set_javascript(on); + } + ui.label( + egui::RichText::new("Scripts run in a sandbox without file or network access. With JavaScript off, Acrobat's standard format, validate and calculate functions still work.") + .small() + .color(t.text_muted), + ); + }); + ui.add_space(10.0); + buttons(ui, "OK", &[]).is_some() +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e2063c2a..a9c9a44b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -17,6 +17,7 @@ pub mod control; mod create_ui; mod crop; mod export_ui; +mod js_ui; mod marks_ui; mod ocr_ui; mod optimize_ui; @@ -187,6 +188,12 @@ pub enum Dialog { AccessibilityOptions, /// Scan & OCR ▸ Recognize text. RecognizeText, + /// The JavaScript console (⌘J). + JsConsole, + /// Document JavaScripts. + DocumentJs, + /// Preferences. + Preferences, /// Combine files: the files, their order and pages. Combine, /// Custom stamps ▸ Create. @@ -302,6 +309,9 @@ pub struct PrintCraftApp { pub ocr_run: Option, pub ocr_batch: Option>>, pub ocr_sync: bool, + /// The JavaScript console and the Document JavaScripts draft. + pub js_console: js_ui::JsConsole, + pub doc_js: js_ui::DocJsDraft, pub a11y: a11y_ui::A11yState, pub a11y_skipped: std::collections::BTreeSet, pub alt_draft: a11y_ui::AltDraft, @@ -441,6 +451,8 @@ impl PrintCraftApp { ocr_run: None, ocr_batch: None, ocr_sync: false, + js_console: Default::default(), + doc_js: Default::default(), a11y: a11y_ui::A11yState::default(), a11y_skipped: Default::default(), alt_draft: Default::default(), @@ -778,6 +790,7 @@ impl PrintCraftApp { "digital_ids": self.digital_ids.iter().filter(|e| !e.path.starts_with("keychain:")).collect::>(), "trusted": trusted, "custom_stamps": stamps_ui::encode(&self.custom_stamps), + "javascript": self.session.javascript(), }) .to_string() } @@ -809,6 +822,9 @@ impl PrintCraftApp { self.digital_ids = ids; } self.custom_stamps = stamps_ui::decode(&v["custom_stamps"]); + if let Some(on) = v["javascript"].as_bool() { + self.session.set_javascript(on); + } if let Ok(pems) = serde_json::from_value::>(v["trusted"].clone()) { let certs = pems.iter().filter_map(|p| printcraft_engine::sign::x509::load_certificates(p.as_bytes()).ok()).flatten().collect(); self.session.set_trusted_certificates(certs); @@ -879,6 +895,9 @@ impl PrintCraftApp { "export-all-images" => Some(Dialog::Export(export_ui::ExportKind::AllImages)), "accessibility-options" => Some(Dialog::AccessibilityOptions), "recognize-text" => Some(Dialog::RecognizeText), + "js-console" => Some(Dialog::JsConsole), + "document-js" => Some(Dialog::DocumentJs), + "preferences" => Some(Dialog::Preferences), "signature" => Some(Dialog::Signature), "optimize" => Some(Dialog::Optimize), "sign" | "certify" => { diff --git a/crates/ui-egui/tests/javascript.rs b/crates/ui-egui/tests/javascript.rs new file mode 100644 index 00000000..fc2c8aed --- /dev/null +++ b/crates/ui-egui/tests/javascript.rs @@ -0,0 +1,103 @@ +//! Acrobat JavaScript in the shell (egui_kittest): the console, Document JavaScripts, a button +//! script and Preferences ▸ JavaScript. + +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use printcraft_ui_egui::{Dialog, PrintCraftApp}; + +/// A form with a text field and a push button whose Mouse Up script fills it in. +fn form() -> Vec { + let objs = [ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 5 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 6 0 R >> >> >> >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 300] >>", + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R] >>", + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (greeting) /Rect [20 250 280 270] /P 3 0 R /F 4 >>", + "<< /Type /Annot /Subtype /Widget /FT /Btn /Ff 65536 /T (hello) /Rect [20 200 120 230] /P 3 0 R /F 4 /A << /S /JavaScript /JS (getField\\('greeting'\\).value = 'Hello from JavaScript'; app.alert\\('Filled in'\\);) >> >>", + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>", + ]; + 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 +} + +fn harness() -> Harness<'static, PrintCraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { + let mut app = PrintCraftApp::new(); + app.open_bytes("form.pdf", None, form()).unwrap(); + app + }); + h.run_steps(4); + h +} + +fn value(h: &Harness<'static, PrintCraftApp>, name: &str) -> Vec { + let app = h.state(); + let id = app.active_ids().unwrap().1; + app.session.get(id).unwrap().form.iter().find(|f| f.name == name).unwrap().value.clone() +} + +#[test] +fn console_runs_scripts_against_the_document() { + let mut h = harness(); + assert!(h.state_mut().execute("tools.js_console")); + h.run_steps(2); + h.get_by_label("JavaScript Console"); + h.state_mut().js_console.input = "getField('greeting').value = 'typed'; console.println(numFields); 6 * 7".into(); + h.get_by_label("Run").click(); + h.run_steps(2); + assert_eq!(value(&h, "greeting"), ["typed"]); + let log = h.state().js_console.log.clone(); + assert!(log.contains(&"2".to_string()) && log.contains(&"42".to_string()), "{log:?}"); + h.state_mut().js_console.input = "throw new Error('boom')".into(); + h.run_steps(4); + h.get_by_label("Run").click(); + h.run_steps(2); + assert!(h.state().js_console.log.last().unwrap().contains("boom")); + h.run_steps(4); + h.get_by_label("Clear").click(); + h.run_steps(2); + assert!(h.state().js_console.log.is_empty(), "{:?} {:?}", h.state().js_console.log, h.state().dialog); +} + +#[test] +fn button_scripts_run_and_alert() { + let mut h = harness(); + let id = h.state().active_ids().unwrap().1; + h.state_mut().run_button_script(id, "hello", "getField('greeting').value = 'Hello from JavaScript'; app.alert('Filled in');"); + h.run_steps(2); + assert_eq!(value(&h, "greeting"), ["Hello from JavaScript"]); + assert_eq!(h.state().toast.clone().unwrap().0, "Filled in"); +} + +#[test] +fn document_scripts_and_preferences() { + let mut h = harness(); + assert!(h.state_mut().execute("tools.document_js")); + h.run_steps(2); + h.get_by_label("Document JavaScripts"); + h.state_mut().doc_js.name = "init".into(); + h.state_mut().doc_js.script = "function hi() { return 'hi'; }".into(); + h.get_by_label("Save").click(); + h.run_steps(2); + let id = h.state().active_ids().unwrap().1; + assert_eq!(h.state().session.get(id).unwrap().document_scripts()[0].0, "init"); + h.state_mut().dialog = Some(Dialog::Preferences); + h.run_steps(2); + h.get_by_label("Enable Acrobat JavaScript").click(); + h.run_steps(2); + assert!(!h.state().session.javascript()); + let saved = h.state().persist(); + let mut fresh = PrintCraftApp::new(); + fresh.restore(&saved); + assert!(!fresh.session.javascript(), "the preference is remembered"); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 2a9ddb8d..b7bbec0b 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -4338,8 +4338,8 @@ milestone = "M6" status = "partial" commands = ["form.fields"] tools = [] -evidence = ["crates/ui-egui/tests/buttons.rs::buttons_reset_navigate_and_print", "crates/forms/src/af.rs::common_button_scripts_are_recognised"] -notes = "Reset form, Named (print, page navigation), URI, Go to, app.alert, this.print/resetForm/pageNum scripts; submit explained, not sent; other scripts need the JS engine" +evidence = ["crates/ui-egui/tests/buttons.rs::buttons_reset_navigate_and_print", "crates/forms/src/af.rs::common_button_scripts_are_recognised", "crates/ui-egui/tests/javascript.rs::button_scripts_run_and_alert"] +notes = "Reset form, Named (print, page navigation), URI, Go to, and any JavaScript (run in the sandboxed engine); submit explained, not sent" [[feature]] id = "form.field-navigation" @@ -4467,11 +4467,11 @@ title = "Custom format, keystroke, validate and calculate scripts" area = "F" tier = "P0" milestone = "M6" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["form.prepare"] +tools = ["form_set_script"] +evidence = ["crates/engine/src/tests.rs::form_javascript_validates_calculates_and_formats", "crates/automation/tests/automation.rs::javascript_through_tools"] +notes = "Custom keystroke (commit), validate, calculate and format scripts run in Acrobat's event order; AF calls still run natively." [[feature]] id = "form.calculation-order" @@ -4491,11 +4491,11 @@ title = "Keystroke, validate, calculate, format event order" area = "F" tier = "P0" milestone = "M6" -status = "partial" -commands = ["form.field.properties"] -tools = ["form_set_props", "form_fill"] -evidence = ["crates/forms/src/tests.rs::formats_validation_and_calculations_run_like_acrobat"] -notes = "keystroke (on commit) → validate → calculate → format, natively for the AF functions; custom scripts need the JavaScript engine" +status = "shipped" +commands = ["form.prepare"] +tools = ["form_fill"] +evidence = ["crates/engine/src/tests.rs::form_javascript_validates_calculates_and_formats"] +notes = "Keystroke (commit) \u2192 Validate \u2192 value \u2192 Calculate (in /CO order) \u2192 Format, for AF calls and custom scripts." [[feature]] id = "form.date-picker" @@ -4983,11 +4983,11 @@ title = "Document-level JavaScripts" area = "F" tier = "P1" milestone = "M6" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["tools.document_js"] +tools = ["js_document_scripts", "js_set_document_script"] +evidence = ["crates/ui-egui/tests/javascript.rs::document_scripts_and_preferences", "crates/automation/tests/automation.rs::javascript_through_tools", "crates/engine/src/tests.rs::form_javascript_validates_calculates_and_formats"] +notes = "Names /JavaScript tree: list, add, edit, delete; functions are available to field scripts." [[feature]] id = "form.document-actions" @@ -5007,11 +5007,11 @@ title = "JavaScript engine with the Acrobat object model subset" area = "F" tier = "P0" milestone = "M6" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "partial" +commands = ["tools.js_console"] +tools = ["js_run"] +evidence = ["crates/js/src/tests.rs::calculate_scripts_read_fields_and_set_the_value", "crates/engine/src/tests.rs::form_javascript_validates_calculates_and_formats", "crates/automation/tests/automation.rs::javascript_through_tools"] +notes = "boa (pure Rust) with the forms object model: event, getField/Field (value, readonly, display, colours, items, check boxes), app, util.printf/printd/printx, console, display, color; no annotations, layers or dialogs (app.response returns null) yet." [[feature]] id = "form.js-console" @@ -5019,11 +5019,11 @@ title = "JavaScript console" area = "F" tier = "P1" milestone = "M6" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["tools.js_console"] +tools = ["js_run"] +evidence = ["crates/ui-egui/tests/javascript.rs::console_runs_scripts_against_the_document", "crates/automation/tests/automation.rs::javascript_through_tools"] +notes = "\u2318J: run JavaScript against the document, with console output, results and errors." [[feature]] id = "form.js-debugger" @@ -5055,11 +5055,11 @@ title = "JavaScript enable switch and sandboxing" area = "F" tier = "P0" milestone = "M6" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["app.preferences"] +tools = ["js_enabled"] +evidence = ["crates/js/src/tests.rs::the_sandbox_has_no_host_access", "crates/js/src/tests.rs::errors_and_runaway_scripts_are_contained", "crates/ui-egui/tests/javascript.rs::document_scripts_and_preferences"] +notes = "Preferences \u25b8 JavaScript \u25b8 Enable Acrobat JavaScript (remembered); no host access; loop and recursion limits." [[feature]] id = "form.folder-level-js"