M6: Acrobat JavaScript: sandboxed boa engine with the forms object model (printcraft-js); custom keystroke/validate/calculate/format scripts, button scripts, document JavaScripts, JavaScript console (⌘J), Preferences ▸ Enable JavaScript; js_run, js_document_scripts, js_set_document_script, form_set_script, js_enabled tools

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-03 04:41:47 -04:00
parent a7ffee01b5
commit 06f8d73ec7
32 changed files with 3042 additions and 70 deletions

View File

@ -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 |

View File

@ -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\""

491
Cargo.lock generated
View File

@ -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"

View File

@ -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" }

View File

@ -0,0 +1,18 @@
<!-- @license lucide-static v1.49.0 - ISC -->
<svg
class="lucide lucide-file-code"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z" />
<path d="M14 2v5a1 1 0 0 0 1 1h5" />
<path d="M10 12.5 8 15l2 2.5" />
<path d="m14 12.5 2 2.5-2 2.5" />
</svg>

After

Width:  |  Height:  |  Size: 524 B

View File

@ -0,0 +1,17 @@
<!-- @license lucide-static v1.49.0 - ISC -->
<svg
class="lucide lucide-square-terminal"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m7 11 2-2-2-2" />
<path d="M11 13h4" />
<rect width="18" height="18" x="3" y="3" rx="2" ry="2" />
</svg>

After

Width:  |  Height:  |  Size: 407 B

View File

@ -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<Value> {
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<Value> = 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<Value> {
let list: Vec<Value> = 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<Value> {
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<Value> {
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<Value> {
if let Some(on) = a.opt_bool("enabled")? {
self.session.set_javascript(on);
}
Ok(json!({ "enabled": self.session.javascript() }))
}
}

View File

@ -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)?,

View File

@ -737,6 +737,27 @@ pub fn tools() -> Vec<ToolDef> {
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(

View File

@ -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());
}

View File

@ -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 }

View File

@ -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"),

222
crates/engine/src/js.rs Normal file
View File

@ -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<String>,
pub console: Vec<String>,
pub requests: Vec<Request>,
/// Script errors (as Acrobat's console shows them).
pub errors: Vec<String>,
}
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<FieldChange> {
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<String>,
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<Outcome, EditError> {
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<Vec<String>> = 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<String> = 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)
}
}

View File

@ -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<printcraft_sign::DigestCache>,
editor: Option<Editor>,
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<String>,
},
/// 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<String>,
},
/// What a script (button or console) changed in form fields: values, read-only, required
/// and visibility.
ApplyScriptChanges {
changes: Vec<printcraft_forms::FieldChange>,
},
/// 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<js::JsRunner>,
date: Option<String>,
/// 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<printcraft_sign::SignError> for EditError {
@ -1599,6 +1654,8 @@ pub struct Session {
clock: Option<fn() -> i64>,
/// Certificates trusted for signing (Acrobat: Trusted Certificates).
trust: Arc<TrustStore>,
/// 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(())
}

View File

@ -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<u8> {
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());
}

View File

@ -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<String>,
pub keystroke: Option<String>,
pub validate: Option<String>,
pub calculate: Option<String>,
}
impl Actions {

View File

@ -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<String>;
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));

View File

@ -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<ObjRef, FormError> {
pub(crate) fn ensure_form(doc: &mut Document) -> Result<ObjRef, FormError> {
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 {

View File

@ -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<usize, FormError> {
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<usize, FormError> {
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<ObjRef> = acroform(doc)
@ -609,7 +637,7 @@ pub fn recalculate(doc: &mut Document) -> Result<usize, FormError> {
.collect();
let mut order: Vec<String> = 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<usize, FormError> {
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, &current, &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<T>(m: impl Into<String>) -> Result<T, FormError> {
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<usize, Form
// A reset is not blocked by NoToggleToOff: go through the writer directly.
let mut tmp = f.clone();
tmp.flags &= !flags::NO_TOGGLE_TO_OFF;
write_value(doc, &tmp, &v)?;
write_value(doc, &tmp, &v, &mut NoScripts)?;
changed += 1;
}
recalculate(doc)?;

View File

@ -0,0 +1,310 @@
//! Field scripts beyond the AF calls: the hook through which the engine runs JavaScript.
//!
//! `forms` does not depend on a JavaScript engine (architecture §3: `forms → js` is not an
//! allowed edge). It calls a [`Scripts`] implementation at the points Acrobat runs field
//! events — Keystroke (on commit) and Validate before a value is accepted, Calculate in the
//! calculation order, Format when the appearance is drawn — and applies what the script asks
//! for: the event's value, `rc`, and changes to other fields.
use printcraft_cos::{Document, Object, PdfString};
use crate::{Field, FieldKind, FieldValue, FormError, fields, flags};
/// The field events forms run scripts for.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FieldEvent {
Keystroke,
Validate,
Calculate,
Format,
}
impl FieldEvent {
/// `event.name`.
pub fn name(self) -> &'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<Vec<String>>,
pub read_only: Option<bool>,
pub required: Option<bool>,
/// `display.visible` 0, `hidden` 1, `noPrint` 2, `noView` 3.
pub display: Option<i32>,
}
/// 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<FieldChange>,
/// Why it rejected the value (its alert), for the error shown.
pub message: Option<String>,
}
/// 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<String, FormError> {
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<String> {
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<String> {
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<Object> = 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<Object> = 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(())
}

17
crates/js/Cargo.toml Normal file
View File

@ -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"] }

1027
crates/js/src/lib.rs Normal file

File diff suppressed because it is too large Load Diff

150
crates/js/src/tests.rs Normal file
View File

@ -0,0 +1,150 @@
use super::*;
fn fields() -> Vec<FieldState> {
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");
}

View File

@ -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);
}
}
}

View File

@ -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;

View File

@ -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;

View File

@ -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) => {

View File

@ -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")),

230
crates/ui-egui/src/js_ui.rs Normal file
View File

@ -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<String>,
}
/// 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<String> {
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<Edit> = 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()
}

View File

@ -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<ocr_ui::OcrRun>,
pub ocr_batch: Option<std::sync::Arc<std::sync::Mutex<ocr_ui::BatchProgress>>>,
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<printcraft_engine::a11y::Rule>,
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::<Vec<_>>(),
"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::<Vec<String>>(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" => {

View File

@ -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<u8> {
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<String> {
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");
}

View File

@ -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"