diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md
index 6b4999a8..bbb07e68 100644
--- a/ATTRIBUTION.md
+++ b/ATTRIBUTION.md
@@ -4,13 +4,15 @@
Every asset PrintCraft includes, bundles or uses to build its published material, with its author, source and licence. The policy is in [AGENTS.md](AGENTS.md) §1. The machine-readable list, with SHA-256 hashes, is [ATTRIBUTION.toml](ATTRIBUTION.toml). Licence texts are kept beside the assets and summarised in [NOTICE](NOTICE).
-## In this repository (239)
+## In this repository (241)
| Asset | Title | Author | Licence | Source | Used for |
|---|---|---|---|---|---|
| `assets/icons/accessibility.svg` | Lucide icon "accessibility" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/accessibility.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/align-left.svg` | Lucide icon "align-left" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-left.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/align-center.svg` | Lucide icon "align-center" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-center.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
+| `assets/icons/square-terminal.svg` | Lucide icon "square-terminal" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/square-terminal.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
+| `assets/icons/file-code.svg` | Lucide icon "file-code" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-code.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/align-justify.svg` | Lucide icon "align-justify" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-justify.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/align-right.svg` | Lucide icon "align-right" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-right.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/columns-3.svg` | Lucide icon "columns-3" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/columns-3.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml
index e997e074..1055960c 100644
--- a/ATTRIBUTION.toml
+++ b/ATTRIBUTION.toml
@@ -41,6 +41,28 @@ kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
sha256 = "bc70fba645343be0760d1a87f9e0975a10ead2b8d95883314f4cea007376e79f"
+[[asset]]
+path = "assets/icons/square-terminal.svg"
+title = "Lucide icon \"square-terminal\""
+author = "Lucide Contributors (portions Cole Bemis, Feather)"
+source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/square-terminal.svg)"
+licence = "ISC"
+licence_file = "assets/icons/LICENSE-lucide.txt"
+kind = "icon"
+usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
+sha256 = "023f88e71f9e3efad5904bf35d59657aece6b7e48bf77051e04dd8a3192b8278"
+
+[[asset]]
+path = "assets/icons/file-code.svg"
+title = "Lucide icon \"file-code\""
+author = "Lucide Contributors (portions Cole Bemis, Feather)"
+source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-code.svg)"
+licence = "ISC"
+licence_file = "assets/icons/LICENSE-lucide.txt"
+kind = "icon"
+usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
+sha256 = "a52ebec6ffe56b062b3a7bc11a844be424555694736da11a4654481fb5668493"
+
[[asset]]
path = "assets/icons/align-justify.svg"
title = "Lucide icon \"align-justify\""
diff --git a/Cargo.lock b/Cargo.lock
index 9a250ec1..e1b826ff 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -22,7 +22,7 @@ dependencies = [
"accesskit",
"accesskit_consumer 0.36.0",
"atspi-common",
- "phf",
+ "phf 0.13.1",
"serde",
"zvariant",
]
@@ -148,6 +148,15 @@ dependencies = [
"zerocopy",
]
+[[package]]
+name = "aligned-vec"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b"
+dependencies = [
+ "equator",
+]
+
[[package]]
name = "alloc-no-stdlib"
version = "2.0.4"
@@ -571,6 +580,150 @@ dependencies = [
"piper",
]
+[[package]]
+name = "boa_ast"
+version = "0.22.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9221b0a090572b4a118b46c5874c0781fe4bc9546b85f0a615b8bfb609ca4ed7"
+dependencies = [
+ "bitflags 2.13.2",
+ "boa_interner",
+ "boa_macros",
+ "boa_string",
+ "indexmap",
+ "num-bigint",
+ "rustc-hash 2.1.3",
+ "strum",
+]
+
+[[package]]
+name = "boa_engine"
+version = "0.22.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d6aaf7fdc4299df68adb67e1a5440a51d34630a1f5271b9698fd74bc97b60d9"
+dependencies = [
+ "aligned-vec",
+ "arrayvec",
+ "async-channel",
+ "bitflags 2.13.2",
+ "boa_ast",
+ "boa_gc",
+ "boa_interner",
+ "boa_macros",
+ "boa_parser",
+ "boa_string",
+ "bytemuck",
+ "cfg-if",
+ "cow-utils",
+ "dashmap",
+ "dynify",
+ "fast-float2",
+ "futures-concurrency",
+ "futures-lite",
+ "getrandom 0.4.3",
+ "hashbrown 0.17.1",
+ "icu_normalizer",
+ "indexmap",
+ "intrusive-collections",
+ "itertools",
+ "num-bigint",
+ "num-integer",
+ "num-traits",
+ "num_enum",
+ "oneshot",
+ "pastey",
+ "portable-atomic",
+ "rand 0.10.3",
+ "regress",
+ "rustc-hash 2.1.3",
+ "ryu-js",
+ "serde",
+ "serde_json",
+ "small_btree",
+ "static_assertions",
+ "tag_ptr",
+ "tap",
+ "thin-vec",
+ "thiserror 2.0.21",
+ "time",
+ "web-time",
+]
+
+[[package]]
+name = "boa_gc"
+version = "0.22.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "af82119558ab1002d1978c8459b53a3ebb506177d29a3aca37c07c1777b1d966"
+dependencies = [
+ "arrayvec",
+ "boa_macros",
+ "boa_string",
+ "hashbrown 0.17.1",
+ "thin-vec",
+]
+
+[[package]]
+name = "boa_interner"
+version = "0.22.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "317c631e791f5cb7d0017e43c0a758b0eaffd40c34c4c360e2a6be7752b78cd1"
+dependencies = [
+ "boa_gc",
+ "boa_macros",
+ "hashbrown 0.17.1",
+ "indexmap",
+ "once_cell",
+ "phf 0.14.0",
+ "rustc-hash 2.1.3",
+ "static_assertions",
+]
+
+[[package]]
+name = "boa_macros"
+version = "0.22.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b882fd440dc0f4b7441367cf89567bb077920356750c9a76a16c002d8ec2fa5c"
+dependencies = [
+ "cfg-if",
+ "cow-utils",
+ "proc-macro2",
+ "quote",
+ "syn 3.0.6",
+ "synstructure",
+]
+
+[[package]]
+name = "boa_parser"
+version = "0.22.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "641e81b3c8cbb7a2140547fb3ea534e9edf210b4e8c920b5fa864a919e11fbcf"
+dependencies = [
+ "bitflags 2.13.2",
+ "boa_ast",
+ "boa_interner",
+ "boa_macros",
+ "fast-float2",
+ "icu_properties",
+ "num-bigint",
+ "num-traits",
+ "regress",
+ "rustc-hash 2.1.3",
+]
+
+[[package]]
+name = "boa_string"
+version = "0.22.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "30bf86c2bcff5bf2547acbfc516c5d3108ca527cb131511d749bf6fbef762c0e"
+dependencies = [
+ "fast-float2",
+ "itoa",
+ "pastey",
+ "rustc-hash 2.1.3",
+ "ryu-js",
+ "static_assertions",
+]
+
[[package]]
name = "brotli"
version = "8.0.4"
@@ -886,6 +1039,12 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48"
+[[package]]
+name = "cow-utils"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "417bef24afe1460300965a25ff4a24b8b45ad011948302ec221e8a0a81eb2c79"
+
[[package]]
name = "cpubits"
version = "0.1.1"
@@ -995,6 +1154,20 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
+[[package]]
+name = "dashmap"
+version = "6.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c"
+dependencies = [
+ "cfg-if",
+ "crossbeam-utils",
+ "hashbrown 0.14.5",
+ "lock_api",
+ "once_cell",
+ "parking_lot_core",
+]
+
[[package]]
name = "data-url"
version = "0.3.2"
@@ -1012,6 +1185,12 @@ dependencies = [
"zeroize",
]
+[[package]]
+name = "deranged"
+version = "0.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
+
[[package]]
name = "des"
version = "0.9.0"
@@ -1098,6 +1277,26 @@ version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
+[[package]]
+name = "dynify"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "81acb15628a3e22358bf73de5e7e62360b8a777dbcb5fc9ac7dfa9ae73723747"
+dependencies = [
+ "dynify-macros",
+]
+
+[[package]]
+name = "dynify-macros"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ec431cd708430d5029356535259c5d645d60edd3d39c54e5eea9782d46caa7d"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
[[package]]
name = "ecb"
version = "0.2.1"
@@ -1423,6 +1622,26 @@ version = "0.36.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773fa9c96dd0dbef887e39d0ed6177f141cce4d68e6041df77570aa3702dfa13"
+[[package]]
+name = "equator"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc"
+dependencies = [
+ "equator-macro",
+]
+
+[[package]]
+name = "equator-macro"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
[[package]]
name = "equivalent"
version = "1.0.2"
@@ -1474,6 +1693,12 @@ dependencies = [
"pin-project-lite",
]
+[[package]]
+name = "fast-float2"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6e8948ce679d00a02a94739ea185595dca7118ed04feb991127e443bd3d761f"
+
[[package]]
name = "fastrand"
version = "2.5.0"
@@ -1523,6 +1748,12 @@ version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
+[[package]]
+name = "fixedbitset"
+version = "0.5.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
+
[[package]]
name = "flatbuffers"
version = "24.12.23"
@@ -1623,6 +1854,19 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
+[[package]]
+name = "futures-concurrency"
+version = "7.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "175cd8cca9e1d45b87f18ffa75088f2099e3c4fe5e2f83e42de112560bea8ea6"
+dependencies = [
+ "fixedbitset",
+ "futures-core",
+ "futures-lite",
+ "pin-project",
+ "smallvec",
+]
+
[[package]]
name = "futures-core"
version = "0.3.34"
@@ -1904,6 +2148,12 @@ dependencies = [
"smallvec",
]
+[[package]]
+name = "hashbrown"
+version = "0.14.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
+
[[package]]
name = "hashbrown"
version = "0.16.1"
@@ -1921,6 +2171,8 @@ version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
+ "allocator-api2",
+ "equivalent",
"foldhash",
]
@@ -1961,7 +2213,7 @@ dependencies = [
"hayro-syntax",
"kurbo 0.13.1",
"moxcms",
- "phf",
+ "phf 0.13.1",
"rustc-hash 2.1.3",
"siphasher",
"skrifa 0.42.1",
@@ -2008,6 +2260,12 @@ dependencies = [
"zune-jpeg",
]
+[[package]]
+name = "heck"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+
[[package]]
name = "hermit-abi"
version = "0.5.3"
@@ -2102,6 +2360,8 @@ dependencies = [
"icu_properties",
"icu_provider",
"smallvec",
+ "utf16_iter",
+ "write16",
"zerovec",
]
@@ -2212,6 +2472,12 @@ dependencies = [
"hybrid-array",
]
+[[package]]
+name = "intrusive-collections"
+version = "0.10.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4275b20e6057cd7733fd8df8a5a31701e4fe44497dad0f3fa0e1c4fb971506be"
+
[[package]]
name = "itertools"
version = "0.15.0"
@@ -2617,6 +2883,32 @@ dependencies = [
"memchr",
]
+[[package]]
+name = "num-bigint"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93e7820bc0a80a0238e650327316f929ba18d5be054b647490a3a6a339f3e7c0"
+dependencies = [
+ "num-integer",
+ "num-traits",
+ "serde",
+]
+
+[[package]]
+name = "num-conv"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
+
+[[package]]
+name = "num-integer"
+version = "0.1.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b"
+dependencies = [
+ "num-traits",
+]
+
[[package]]
name = "num-traits"
version = "0.2.19"
@@ -2659,6 +2951,15 @@ dependencies = [
"syn 2.0.119",
]
+[[package]]
+name = "num_threads"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9"
+dependencies = [
+ "libc",
+]
+
[[package]]
name = "objc-sys"
version = "0.3.5"
@@ -2991,6 +3292,12 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+[[package]]
+name = "oneshot"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cfe21416a02c693fb9f980befcb230ecc70b0b3d1cc4abf88b9675c4c1457f0c"
+
[[package]]
name = "orbclient"
version = "0.3.55"
@@ -3076,6 +3383,12 @@ dependencies = [
"windows-link",
]
+[[package]]
+name = "pastey"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
+
[[package]]
name = "pbkdf2"
version = "0.13.0"
@@ -3120,11 +3433,21 @@ version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
dependencies = [
- "phf_macros",
- "phf_shared",
+ "phf_macros 0.13.1",
+ "phf_shared 0.13.1",
"serde",
]
+[[package]]
+name = "phf"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6"
+dependencies = [
+ "phf_macros 0.14.0",
+ "phf_shared 0.14.0",
+]
+
[[package]]
name = "phf_generator"
version = "0.13.1"
@@ -3132,7 +3455,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
dependencies = [
"fastrand",
- "phf_shared",
+ "phf_shared 0.13.1",
+]
+
+[[package]]
+name = "phf_generator"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100"
+dependencies = [
+ "fastrand",
+ "phf_shared 0.14.0",
]
[[package]]
@@ -3141,8 +3474,21 @@ version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef"
dependencies = [
- "phf_generator",
- "phf_shared",
+ "phf_generator 0.13.1",
+ "phf_shared 0.13.1",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "phf_macros"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5fa8d0ca26d424d27630da600c6624696e7dec8bf7b3b492b383c5dc49e5e085"
+dependencies = [
+ "phf_generator 0.14.0",
+ "phf_shared 0.14.0",
"proc-macro2",
"quote",
"syn 2.0.119",
@@ -3157,6 +3503,15 @@ dependencies = [
"siphasher",
]
+[[package]]
+name = "phf_shared"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6fd9027e2d9319be6349febd1db4e8d02aa544921200c9b777720ac34a3aa89"
+dependencies = [
+ "siphasher",
+]
+
[[package]]
name = "pic-scale"
version = "0.7.12"
@@ -3327,6 +3682,12 @@ dependencies = [
"zerovec",
]
+[[package]]
+name = "powerfmt"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
+
[[package]]
name = "ppv-lite86"
version = "0.2.21"
@@ -3497,6 +3858,7 @@ dependencies = [
"printcraft-edit",
"printcraft-fonts",
"printcraft-forms",
+ "printcraft-js",
"printcraft-model",
"printcraft-ocr",
"printcraft-optimize",
@@ -3541,6 +3903,13 @@ dependencies = [
name = "printcraft-geom"
version = "0.1.0"
+[[package]]
+name = "printcraft-js"
+version = "0.1.0"
+dependencies = [
+ "boa_engine",
+]
+
[[package]]
name = "printcraft-model"
version = "0.1.0"
@@ -3951,6 +4320,15 @@ version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
+[[package]]
+name = "regress"
+version = "0.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32eef8b209c3c1c15dbad02c1f30f9539f00dc7253e0cbcdaae442a50a09d7c1"
+dependencies = [
+ "memchr",
+]
+
[[package]]
name = "renderdoc-sys"
version = "1.1.0"
@@ -4229,6 +4607,12 @@ dependencies = [
"wait-timeout",
]
+[[package]]
+name = "ryu-js"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "04d056b875a9d2e6cb9a61d127afee9ac5999b9f87bcb32079d1318e505be714"
+
[[package]]
name = "same-file"
version = "1.0.6"
@@ -4492,6 +4876,15 @@ dependencies = [
"version_check",
]
+[[package]]
+name = "small_btree"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ba60d2df92ba73864714808ca68c059734853e6ab722b40e1cf543ebb3a057a"
+dependencies = [
+ "arrayvec",
+]
+
[[package]]
name = "smallvec"
version = "1.16.2"
@@ -4624,6 +5017,27 @@ dependencies = [
"unicode-properties",
]
+[[package]]
+name = "strum"
+version = "0.28.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd"
+dependencies = [
+ "strum_macros",
+]
+
+[[package]]
+name = "strum_macros"
+version = "0.28.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
[[package]]
name = "subtle"
version = "2.6.1"
@@ -4673,6 +5087,18 @@ dependencies = [
"syn 3.0.6",
]
+[[package]]
+name = "tag_ptr"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c0e973b34477b7823833469eb0f5a3a60370fef7a453e02d751b59180d0a5a05"
+
+[[package]]
+name = "tap"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
+
[[package]]
name = "tempfile"
version = "3.27.0"
@@ -4695,6 +5121,12 @@ dependencies = [
"winapi-util",
]
+[[package]]
+name = "thin-vec"
+version = "0.2.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4568d7e143ec86d2021c338bae2afa88699e84b8e0af523626654fe6f03a1748"
+
[[package]]
name = "thiserror"
version = "1.0.69"
@@ -4749,6 +5181,39 @@ dependencies = [
"zune-jpeg",
]
+[[package]]
+name = "time"
+version = "0.3.55"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
+dependencies = [
+ "deranged",
+ "js-sys",
+ "libc",
+ "num-conv",
+ "num_threads",
+ "powerfmt",
+ "serde_core",
+ "time-core",
+ "time-macros",
+]
+
+[[package]]
+name = "time-core"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
+
+[[package]]
+name = "time-macros"
+version = "0.2.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85"
+dependencies = [
+ "num-conv",
+ "time-core",
+]
+
[[package]]
name = "tiny-skia"
version = "0.11.4"
@@ -5000,6 +5465,12 @@ dependencies = [
"xmlwriter",
]
+[[package]]
+name = "utf16_iter"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246"
+
[[package]]
name = "utf8_iter"
version = "1.0.4"
@@ -5919,6 +6390,12 @@ dependencies = [
"primefield",
]
+[[package]]
+name = "write16"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936"
+
[[package]]
name = "writeable"
version = "0.6.4"
diff --git a/Cargo.toml b/Cargo.toml
index da90424a..c1c7a318 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -29,6 +29,7 @@ printcraft-sign = { path = "crates/sign", version = "0.1.0" }
printcraft-optimize = { path = "crates/optimize", version = "0.1.0" }
printcraft-a11y = { path = "crates/a11y", version = "0.1.0" }
printcraft-ocr = { path = "crates/ocr", version = "0.1.0" }
+printcraft-js = { path = "crates/js", version = "0.1.0" }
printcraft-edit = { path = "crates/edit", version = "0.1.0" }
printcraft-create = { path = "crates/create", version = "0.1.0" }
printcraft-forms = { path = "crates/forms", version = "0.1.0" }
diff --git a/assets/icons/file-code.svg b/assets/icons/file-code.svg
new file mode 100644
index 00000000..a45a145e
--- /dev/null
+++ b/assets/icons/file-code.svg
@@ -0,0 +1,18 @@
+
+
diff --git a/assets/icons/square-terminal.svg b/assets/icons/square-terminal.svg
new file mode 100644
index 00000000..d5a21ef7
--- /dev/null
+++ b/assets/icons/square-terminal.svg
@@ -0,0 +1,17 @@
+
+
diff --git a/crates/automation/src/a11y.rs b/crates/automation/src/a11y.rs
index 8254f519..6a7f1009 100644
--- a/crates/automation/src/a11y.rs
+++ b/crates/automation/src/a11y.rs
@@ -200,3 +200,56 @@ impl Automation {
Ok(json!({ "available": ocr::available(), "search_dirs": dirs, "languages": langs }))
}
}
+
+fn request_json(r: &printcraft_engine::js::Request) -> Value {
+ use printcraft_engine::js::Request as R;
+ match r {
+ R::Reset(n) => json!({ "reset": n }),
+ R::Print => json!({ "print": true }),
+ R::GoToPage(p) => json!({ "page": p + 1 }),
+ R::LaunchUrl(u) => json!({ "url": u }),
+ R::Submit(u) => json!({ "submit": u }),
+ R::Focus(f) => json!({ "focus": f }),
+ R::Beep => json!({ "beep": true }),
+ }
+}
+
+impl Automation {
+ pub(crate) fn js_run(&mut self, a: &Args) -> Result {
+ let id = self.doc(a)?.id;
+ let o = self.session.run_javascript(id, a.str("script")?, a.opt_str("field")?).map_err(failed)?;
+ let reqs: Vec = o.requests.iter().map(request_json).collect();
+ Ok(json!({ "alerts": o.alerts, "console": o.console, "requests": reqs, "error": o.error, "result": o.result }))
+ }
+
+ pub(crate) fn js_document_scripts(&self, a: &Args) -> Result {
+ let list: Vec = self.doc(a)?.document_scripts().into_iter().map(|(n, s)| json!({ "name": n, "script": s })).collect();
+ Ok(json!({ "scripts": list }))
+ }
+
+ pub(crate) fn js_set_document_script(&mut self, a: &Args) -> Result {
+ let edit = printcraft_engine::Edit::SetDocumentScript { name: a.str("name")?.into(), script: a.opt_str("script")?.map(str::to_string) };
+ let id = self.doc(a)?.id;
+ self.session.apply(id, edit).map_err(failed)?;
+ self.js_document_scripts(a)
+ }
+
+ pub(crate) fn form_set_script(&mut self, a: &Args) -> Result {
+ let edit = printcraft_engine::Edit::SetFieldScript {
+ name: a.str("field")?.into(),
+ event: a.str("event")?.into(),
+ script: a.opt_str("script")?.map(str::to_string),
+ };
+ let id = self.doc(a)?.id;
+ self.session.apply(id, edit).map_err(failed)?;
+ let out = self.session.take_js_output(id);
+ Ok(json!({ "field": a.str("field")?, "event": a.str("event")?, "console": out.console, "errors": out.errors }))
+ }
+
+ pub(crate) fn js_enabled(&mut self, a: &Args) -> Result {
+ if let Some(on) = a.opt_bool("enabled")? {
+ self.session.set_javascript(on);
+ }
+ Ok(json!({ "enabled": self.session.javascript() }))
+ }
+}
diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs
index 9148be83..00572fec 100644
--- a/crates/automation/src/lib.rs
+++ b/crates/automation/src/lib.rs
@@ -503,6 +503,11 @@ impl Automation {
"sign_list" => self.sign_list(&a)?,
"accessibility_check" => self.a11y_check(&a)?,
"ocr_recognize" => self.ocr_recognize(&a)?,
+ "js_run" => self.js_run(&a)?,
+ "js_document_scripts" => self.js_document_scripts(&a)?,
+ "js_set_document_script" => self.js_set_document_script(&a)?,
+ "js_enabled" => self.js_enabled(&a)?,
+ "form_set_script" => self.form_set_script(&a)?,
"ocr_status" => self.ocr_status()?,
"ocr_recognize_files" => self.ocr_recognize_files(&a)?,
"accessibility_report" => self.a11y_report(&a)?,
diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs
index ba7c288d..c19e943c 100644
--- a/crates/automation/src/tools.rs
+++ b/crates/automation/src/tools.rs
@@ -737,6 +737,27 @@ pub fn tools() -> Vec {
t("accessibility_set_alt", "Set alternate text", "Set a figure's alternate text (alt; empty or omitted clears it), or mark it decorative (decorative: true: its content becomes an artifact and the figure leaves the tags). figure is a number from accessibility_figures. Returns the figures. Undoable.")
.cmd("a11y.alt_text")
.with(schema(json!({ "doc": doc(), "figure": { "type": "integer", "minimum": 1 }, "alt": { "type": "string" }, "decorative": { "type": "boolean" } }), &["doc", "figure"])),
+ t("js_run", "Run JavaScript", "Run Acrobat JavaScript in the document, as the JavaScript console does (or as push button `field`'s Mouse Up script when field is given). The form object model is available: this/getField, event, app, util, console, display, color, and the document-level scripts. Field changes and resetForm are applied as one undoable step; returns the script's alerts, console output, requests (print, page, url, submit) and error.")
+ .cmd("tools.js_console")
+ .with(schema(
+ json!({ "doc": doc(), "script": { "type": "string" }, "field": { "type": "string", "description": "Run as this button's Mouse Up event." } }),
+ &["doc", "script"],
+ )),
+ t("js_document_scripts", "Document JavaScripts", "List the document-level JavaScripts (name and source), which define functions field scripts use.")
+ .ro()
+ .cmd("tools.document_js")
+ .with(schema(json!({ "doc": doc() }), &["doc"])),
+ t("js_set_document_script", "Edit a document JavaScript", "Add or replace the document-level JavaScript `name` with `script`, or delete it (script omitted). Undoable.")
+ .cmd("tools.document_js")
+ .with(schema(json!({ "doc": doc(), "name": { "type": "string", "minLength": 1 }, "script": { "type": "string" } }), &["doc", "name"])),
+ t("form_set_script", "Set a field's JavaScript", "Field Properties ▸ Run custom script: set field's JavaScript for event keystroke, format, validate or calculate (the field's actions; calculated fields join the calculation order) or mouse_up (a button's action). Omit script to remove it. The scripts use Acrobat's object model (event.value, event.rc, getField, util.printf, …) and run when the field changes. Undoable.")
+ .cmd("form.prepare")
+ .with(schema(
+ json!({ "doc": doc(), "field": { "type": "string" }, "event": { "type": "string", "enum": ["keystroke", "format", "validate", "calculate", "mouse_up"] }, "script": { "type": "string" } }),
+ &["doc", "field", "event"],
+ )),
+ t("js_enabled", "JavaScript on or off", "Preferences ▸ JavaScript ▸ Enable Acrobat JavaScript: set it with `enabled`, or read it. With JavaScript off, field scripts other than Acrobat's AF calls don't run.")
+ .with(schema(json!({ "enabled": { "type": "boolean" } }), &[])),
t("ocr_recognize", "Recognize text (OCR)", "Scan & OCR ▸ Recognize text: render pages, read the words in them and add them as invisible text over the page image, so scanned pages become searchable and selectable (a searchable image; the image is not changed). Pages that already have text are skipped unless skip_text_pages is false. Returns each page's recognised text, word count or why it was skipped. Needs the OCR models (ocr_status). Undoable as one step.")
.cmd("ocr.recognize")
.with(schema(
diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs
index fca02228..bc26ae62 100644
--- a/crates/automation/tests/automation.rs
+++ b/crates/automation/tests/automation.rs
@@ -1329,3 +1329,50 @@ fn ocr_recognize_files_writes_searchable_copies() {
let found = ok(&mut a, "text_find", json!({ "doc": out, "query": "recognition" }));
assert!(found.to_string().contains("\"page\""), "{found}");
}
+
+#[test]
+fn javascript_through_tools() {
+ let dir = workdir("js");
+ let mut a = auto(&dir);
+ let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
+ for (name, y) in [("Price", 20), ("Qty", 60), ("Total", 100)] {
+ ok(&mut a, "form_add_field", json!({ "doc": doc, "page": 1, "type": "text", "rect": [20, y, 180, y + 22], "name": name }));
+ }
+ ok(
+ &mut a,
+ "js_set_document_script",
+ json!({ "doc": doc, "name": "lib", "script": "function money(v) { return util.printf('EUR %,2.2f', v); }" }),
+ );
+ assert_eq!(ok(&mut a, "js_document_scripts", json!({ "doc": doc }))["scripts"][0]["name"], "lib");
+ ok(
+ &mut a,
+ "form_set_script",
+ json!({ "doc": doc, "field": "Total", "event": "calculate", "script": "event.value = getField('Price').value * getField('Qty').value;" }),
+ );
+ ok(&mut a, "form_set_script", json!({ "doc": doc, "field": "Total", "event": "format", "script": "event.value = money(event.value);" }));
+ ok(
+ &mut a,
+ "form_set_script",
+ json!({ "doc": doc, "field": "Qty", "event": "validate", "script": "if (event.value < 1) { app.alert('Order at least one'); event.rc = false; }" }),
+ );
+ ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "Price": "1250", "Qty": "2" } }));
+ assert!(page_text(&mut a, doc)[0].contains("EUR 2.500,00"), "{:?}", page_text(&mut a, doc));
+ let err = a.call("form_fill", &json!({ "doc": doc, "values": { "Qty": "0" } })).unwrap_err();
+ assert!(err.to_string().contains("Order at least one"), "{err}");
+
+ let r = ok(
+ &mut a,
+ "js_run",
+ json!({ "doc": doc, "script": "console.println(getField('Total').value); getField('Qty').value = 3; this.pageNum = 1; app.alert('ok');" }),
+ );
+ assert_eq!(r["console"][0], "2500");
+ assert_eq!(r["alerts"][0], "ok");
+ assert_eq!(r["requests"][0]["page"], 2);
+ let f = ok(&mut a, "form_fields", json!({ "doc": doc }));
+ let total = f["fields"].as_array().unwrap().iter().find(|x| x["name"] == "Total").unwrap().clone();
+ assert_eq!(total["value"], "3750");
+ let r = ok(&mut a, "js_run", json!({ "doc": doc, "script": "nope()" }));
+ assert!(r["error"].as_str().unwrap().contains("nope"));
+ assert_eq!(ok(&mut a, "js_enabled", json!({ "enabled": false }))["enabled"], false);
+ assert!(a.call("js_run", &json!({ "doc": doc, "script": "1" })).is_err());
+}
diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml
index 41be5145..52db62f2 100644
--- a/crates/engine/Cargo.toml
+++ b/crates/engine/Cargo.toml
@@ -23,6 +23,7 @@ printcraft-sign = { workspace = true }
printcraft-optimize = { workspace = true }
printcraft-a11y = { workspace = true }
printcraft-ocr = { workspace = true }
+printcraft-js = { workspace = true }
printcraft-fonts = { workspace = true }
printcraft-create = { workspace = true }
printcraft-model = { workspace = true }
diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs
index 5970fc99..7bb61eda 100644
--- a/crates/engine/src/commands.rs
+++ b/crates/engine/src/commands.rs
@@ -248,6 +248,9 @@ pub const COMMANDS: &[CommandSpec] = &[
c("optimize.reduce", "Reduce file size…", FILE, None, Document, "file-down"),
c("optimize.advanced", "Optimize PDF…", FILE, None, Document, "settings-2"),
c("export.text", "Export to text…", FILE, None, Document, "type"),
+ c("app.preferences", "Preferences…", None, None, Nothing, "settings"),
+ c("tools.js_console", "JavaScript console…", None, Some(Shortcut::cmd("J")), Document, "square-terminal"),
+ c("tools.document_js", "Document JavaScripts…", None, None, Modification, "file-code"),
c("ocr.recognize", "Recognize text…", None, None, Modification, "scan-text"),
c("ocr.recognize_batch", "Recognize text in multiple files…", None, None, Nothing, "files"),
c("a11y.check", "Check for accessibility…", None, None, Document, "accessibility"),
diff --git a/crates/engine/src/js.rs b/crates/engine/src/js.rs
new file mode 100644
index 00000000..13bf0be3
--- /dev/null
+++ b/crates/engine/src/js.rs
@@ -0,0 +1,222 @@
+//! Acrobat JavaScript in documents: field scripts run during form edits ([`JsRunner`], wired
+//! into `forms` through its [`printcraft_forms::Scripts`] hook), and button actions and console
+//! input run on request ([`Session::run_javascript`]). What scripts ask of the viewer (alerts,
+//! printing, navigation, links) is kept for the front end in [`JsOutput`].
+//!
+//! Preferences ▸ JavaScript ▸ Enable Acrobat JavaScript is [`Session::set_javascript`]; with it
+//! off, scripts don't run and AF calls keep working natively.
+
+use printcraft_forms::{Field, FieldChange, FieldEvent, FieldKind, ScriptResult, Scripts};
+pub use printcraft_js::{Limits, Outcome, Request};
+
+use crate::{DocId, Edit, EditError, Session};
+
+/// What scripts produced for the viewer since it last looked.
+#[derive(Clone, Debug, Default, PartialEq)]
+pub struct JsOutput {
+ pub alerts: Vec,
+ pub console: Vec,
+ pub requests: Vec,
+ /// Script errors (as Acrobat's console shows them).
+ pub errors: Vec,
+}
+
+impl JsOutput {
+ pub fn is_empty(&self) -> bool {
+ self.alerts.is_empty() && self.console.is_empty() && self.requests.is_empty() && self.errors.is_empty()
+ }
+
+ fn absorb(&mut self, o: &Outcome) {
+ self.alerts.extend(o.alerts.iter().cloned());
+ self.console.extend(o.console.iter().cloned());
+ self.requests.extend(o.requests.iter().cloned());
+ self.errors.extend(o.error.iter().cloned());
+ }
+
+ pub(crate) fn append(&mut self, other: JsOutput) {
+ self.alerts.extend(other.alerts);
+ self.console.extend(other.console);
+ self.requests.extend(other.requests);
+ self.errors.extend(other.errors);
+ }
+}
+
+fn kind(k: FieldKind) -> printcraft_js::FieldType {
+ use printcraft_js::FieldType as T;
+ match k {
+ FieldKind::Text => T::Text,
+ FieldKind::CheckBox => T::CheckBox,
+ FieldKind::Radio => T::RadioButton,
+ FieldKind::PushButton => T::Button,
+ FieldKind::Combo => T::ComboBox,
+ FieldKind::List => T::ListBox,
+ FieldKind::Signature => T::Signature,
+ }
+}
+
+/// `field.display` from the first widget's annotation flags.
+fn display(doc: &printcraft_cos::Document, f: &Field) -> i32 {
+ let flags = f.widgets.first().and_then(|w| doc.get(w.obj).as_dict().and_then(|d| d.int(b"F"))).unwrap_or(4);
+ if flags & 2 != 0 {
+ printcraft_js::DISPLAY_HIDDEN
+ } else if flags & 32 != 0 {
+ printcraft_js::DISPLAY_NO_VIEW
+ } else if flags & 4 == 0 {
+ printcraft_js::DISPLAY_NO_PRINT
+ } else {
+ printcraft_js::DISPLAY_VISIBLE
+ }
+}
+
+/// A form field as scripts see it.
+pub fn field_state(doc: &printcraft_cos::Document, f: &Field) -> printcraft_js::FieldState {
+ let mut s = printcraft_js::FieldState::new(f.name.clone(), kind(f.kind), f.value.clone());
+ s.default = f.default.clone();
+ s.readonly = f.read_only();
+ s.required = f.has(printcraft_forms::flags::REQUIRED);
+ s.display = display(doc, f);
+ s.char_limit = f.max_len;
+ s.options = match f.kind {
+ FieldKind::CheckBox | FieldKind::Radio => f.widgets.iter().filter_map(|w| w.on_state.clone()).map(|o| (o.clone(), o)).collect(),
+ _ => f.options.clone(),
+ };
+ s
+}
+
+/// The changes a script made, against the fields as they were.
+fn changes(before: &[printcraft_js::FieldState], after: &[printcraft_js::FieldState]) -> Vec {
+ after
+ .iter()
+ .filter_map(|a| {
+ let b = before.iter().find(|b| b.name == a.name)?;
+ let c = FieldChange {
+ name: a.name.clone(),
+ value: (a.value != b.value).then(|| a.value.clone()),
+ read_only: (a.readonly != b.readonly).then_some(a.readonly),
+ required: (a.required != b.required).then_some(a.required),
+ display: (a.display != b.display).then_some(a.display),
+ };
+ (c.value.is_some() || c.read_only.is_some() || c.required.is_some() || c.display.is_some()).then_some(c)
+ })
+ .collect()
+}
+
+/// Runs field scripts with the document's object model and keeps what they print or ask for.
+pub struct JsRunner {
+ pub doc: printcraft_js::DocInfo,
+ pub doc_scripts: Vec,
+ pub output: JsOutput,
+ /// The fields in the document as the forms code last read them (for `display`).
+ cos: printcraft_cos::Document,
+}
+
+impl JsRunner {
+ pub fn new(cos: &printcraft_cos::Document, file_name: &str) -> JsRunner {
+ let doc =
+ printcraft_js::DocInfo { file_name: file_name.to_string(), num_pages: printcraft_model::pages(cos).len(), page: 0, info: info(cos) };
+ JsRunner { doc, doc_scripts: printcraft_forms::document_scripts(cos), output: JsOutput::default(), cos: cos.clone() }
+ }
+}
+
+/// The Info dictionary as `this.info` keys (lower-case).
+fn info(cos: &printcraft_cos::Document) -> Vec<(String, String)> {
+ let Some(d) = cos.trailer().get(b"Info").map(|i| cos.resolve(i)).and_then(|i| i.as_dict().cloned()) else { return Vec::new() };
+ d.iter()
+ .filter_map(|(k, v)| match &*cos.resolve(v) {
+ printcraft_cos::Object::String(s) => Some((String::from_utf8_lossy(k).to_lowercase(), s.to_text())),
+ _ => None,
+ })
+ .collect()
+}
+
+impl Scripts for JsRunner {
+ fn run(&mut self, event: FieldEvent, script: &str, target: &Field, value: &str, fields: &[Field]) -> ScriptResult {
+ let states: Vec<_> = fields.iter().map(|f| field_state(&self.cos, f)).collect();
+ let ev = printcraft_js::Event::field(event.name(), &target.name, value);
+ let o = printcraft_js::run(script, &ev, &self.doc, &states, &self.doc_scripts, Limits::default());
+ self.output.absorb(&o);
+ if o.error.is_some() {
+ // As in Acrobat, a failing script leaves the value alone (the error goes to the console).
+ return ScriptResult { rc: true, value: value.to_string(), changes: Vec::new(), message: None };
+ }
+ let after: Vec<_> = states.iter().map(|s| o.changed.iter().find(|c| c.name == s.name).unwrap_or(s).clone()).collect();
+ ScriptResult {
+ rc: o.rc,
+ value: o.value.clone(),
+ changes: changes(&states, &after),
+ message: (!o.rc).then(|| o.alerts.last().cloned()).flatten(),
+ }
+ }
+}
+
+/// A document-level JavaScript: its name and source.
+pub fn document_scripts(cos: &printcraft_cos::Document) -> Vec<(String, String)> {
+ printcraft_forms::document_scripts_named(cos)
+}
+
+impl crate::Document {
+ /// Document JavaScripts (name, source), in name order.
+ pub fn document_scripts(&self) -> Vec<(String, String)> {
+ self.editor.as_ref().map(|e| document_scripts(&e.cos)).unwrap_or_default()
+ }
+}
+
+impl Session {
+ /// Preferences ▸ JavaScript ▸ Enable Acrobat JavaScript (on by default).
+ pub fn set_javascript(&mut self, on: bool) {
+ self.js_off = !on;
+ }
+
+ pub fn javascript(&self) -> bool {
+ !self.js_off
+ }
+
+ /// What scripts printed or asked for since the last call (alerts, console, print, …).
+ pub fn take_js_output(&mut self, id: DocId) -> JsOutput {
+ self.docs.iter_mut().find(|d| d.id == id).map(|d| std::mem::take(&mut d.js_output)).unwrap_or_default()
+ }
+
+ /// Run `script` in document `id`, for a button (`target`: the field, event "Mouse Up") or
+ /// the console (no target). Field changes and `resetForm` are applied as one undoable step;
+ /// everything else the script asked for is returned.
+ pub fn run_javascript(&mut self, id: DocId, script: &str, target: Option<&str>) -> Result {
+ if self.js_off {
+ return Err(EditError::Invalid("JavaScript is turned off (Preferences ▸ JavaScript)".into()));
+ }
+ let doc = self.get(id).ok_or(EditError::NoDocument)?;
+ let cos = doc.editor.as_ref().map(|e| e.cos.clone()).ok_or_else(|| EditError::ReadOnly(doc.read_only_reason.clone().unwrap_or_default()))?;
+ let runner = JsRunner::new(&cos, &doc.name);
+ let fields = printcraft_forms::fields(&cos);
+ let states: Vec<_> = fields.iter().map(|f| field_state(&cos, f)).collect();
+ let event = match target {
+ Some(t) => printcraft_js::Event { will_commit: false, ..printcraft_js::Event::field("Mouse Up", t, "") },
+ None => printcraft_js::Event::doc("Console"),
+ };
+ let mut o = printcraft_js::run(script, &event, &runner.doc, &states, &runner.doc_scripts, Limits::default());
+ let after: Vec<_> = states.iter().map(|s| o.changed.iter().find(|c| c.name == s.name).unwrap_or(s).clone()).collect();
+ let changes = changes(&states, &after);
+ let resets: Vec> = o
+ .requests
+ .iter()
+ .filter_map(|r| match r {
+ Request::Reset(n) => Some(n.clone()),
+ _ => None,
+ })
+ .collect();
+ o.requests.retain(|r| !matches!(r, Request::Reset(_)));
+ let mut edits = Vec::new();
+ if !changes.is_empty() {
+ edits.push(Edit::ApplyScriptChanges { changes });
+ }
+ for names in resets {
+ let all: Vec = fields.iter().map(|f| f.name.clone()).collect();
+ let names = (!names.is_empty())
+ .then(|| all.iter().filter(|n| names.iter().any(|x| *n == x || n.starts_with(&format!("{x}.")))).cloned().collect());
+ edits.push(Edit::ResetForm { names });
+ }
+ if !edits.is_empty() {
+ self.apply(id, Edit::Batch { label: "Run JavaScript".into(), edits })?;
+ }
+ Ok(o)
+ }
+}
diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs
index 04b20d14..a22a189d 100644
--- a/crates/engine/src/lib.rs
+++ b/crates/engine/src/lib.rs
@@ -13,6 +13,7 @@
pub mod catalog;
pub mod commands;
pub mod export;
+pub mod js;
pub mod links;
pub mod ocr;
@@ -115,6 +116,15 @@ enum Scope {
Form,
}
+/// Edits during which field scripts run.
+fn uses_scripts(edit: &Edit) -> bool {
+ match edit {
+ Edit::SetFieldValue { .. } | Edit::ApplyScriptChanges { .. } | Edit::SetFieldScript { .. } => true,
+ Edit::Batch { edits, .. } => edits.iter().any(uses_scripts),
+ _ => false,
+ }
+}
+
fn scope_of(edit: &Edit) -> Scope {
match edit {
// A file attachment also changes the Attachments list.
@@ -133,7 +143,7 @@ fn scope_of(edit: &Edit) -> Scope {
| Edit::ResizeAnnotation { .. }
| Edit::StyleAnnotation { .. }
| Edit::SetAnnotationInfo { .. } => Scope::Comments,
- Edit::SetFieldValue { .. } | Edit::ResetForm { .. } | Edit::SetFieldImage { .. } => Scope::Form,
+ Edit::SetFieldValue { .. } | Edit::ResetForm { .. } | Edit::SetFieldImage { .. } | Edit::ApplyScriptChanges { .. } => Scope::Form,
Edit::Batch { edits, .. } => {
let mut scopes = edits.iter().map(scope_of);
let first = scopes.next().unwrap_or(Scope::Full);
@@ -187,6 +197,8 @@ pub struct Document {
sig_cache: Arc,
editor: Option,
config: RenderConfig,
+ /// What field scripts printed or asked for (see [`Session::take_js_output`]).
+ js_output: js::JsOutput,
}
impl Document {
@@ -776,6 +788,24 @@ pub enum Edit {
MarkDecorative {
figure: u32,
},
+ /// Document JavaScripts: add, replace (`script`) or remove (`None`) the document-level
+ /// script `name`.
+ SetDocumentScript {
+ name: String,
+ script: Option,
+ },
+ /// Field Properties ▸ a custom script: set or remove field `name`'s JavaScript for `event`
+ /// (keystroke, format, validate, calculate or mouse_up).
+ SetFieldScript {
+ name: String,
+ event: String,
+ script: Option,
+ },
+ /// What a script (button or console) changed in form fields: values, read-only, required
+ /// and visibility.
+ ApplyScriptChanges {
+ changes: Vec,
+ },
/// Scan & OCR ▸ Recognize text: put recognised words on `page` as invisible text over the
/// image (from [`ocr::OcrJob::run`]).
AddOcrText {
@@ -971,6 +1001,10 @@ impl Edit {
Edit::SetAltText { .. } => "Set alternate text".into(),
Edit::MarkDecorative { .. } => "Mark figure as decorative".into(),
Edit::AddOcrText { .. } => "Recognize text".into(),
+ Edit::ApplyScriptChanges { .. } => "Run JavaScript".into(),
+ Edit::SetFieldScript { name, .. } => format!("Edit script of {name}"),
+ Edit::SetDocumentScript { script: None, .. } => "Delete document JavaScript".into(),
+ Edit::SetDocumentScript { .. } => "Edit document JavaScript".into(),
Edit::EditTextLine { .. } | Edit::EditTextBlock { .. } => "Edit text".into(),
Edit::EditPageImage { change, .. } => match change {
ImageEdit::Move(_) => "Move image".into(),
@@ -1095,7 +1129,7 @@ fn check_permission(edit: &Edit, p: &printcraft_cos::Permissions) -> Result<(),
Err(EditError::NotPermitted("comments"))
}
}
- Edit::SetFieldValue { .. } | Edit::ResetForm { .. } | Edit::SetFieldImage { .. } => {
+ Edit::SetFieldValue { .. } | Edit::ResetForm { .. } | Edit::SetFieldImage { .. } | Edit::ApplyScriptChanges { .. } => {
if p.fill_forms() {
Ok(())
} else {
@@ -1139,6 +1173,8 @@ fn check_permission(edit: &Edit, p: &printcraft_cos::Permissions) -> Result<(),
| Edit::SetAltText { .. }
| Edit::MarkDecorative { .. }
| Edit::AddOcrText { .. }
+ | Edit::SetDocumentScript { .. }
+ | Edit::SetFieldScript { .. }
| Edit::EditTextLine { .. }
| Edit::EditTextBlock { .. }
| Edit::EditPageImage { .. }
@@ -1155,6 +1191,8 @@ fn check_permission(edit: &Edit, p: &printcraft_cos::Permissions) -> Result<(),
/// Dates and unique ids stamped onto what an edit creates.
struct EditCtx {
+ /// Field scripts run (`None`: JavaScript is off) and what they produced.
+ js: Option,
date: Option,
/// Today in local time, for date tokens.
today: (i64, u32, u32),
@@ -1173,7 +1211,7 @@ impl EditCtx {
// Real clock: mix in sub-second time so ids from two sessions don't collide.
seed ^= std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.subsec_nanos() as u64).unwrap_or(0) << 32;
}
- Self { date: now.map(printcraft_cos::pdf_date), today: (1970, 1, 1), seed, count: 0 }
+ Self { js: None, date: now.map(printcraft_cos::pdf_date), today: (1970, 1, 1), seed, count: 0 }
}
/// 32 bytes of entropy for new encryption keys and salts. `RandomState` is seeded by the
@@ -1291,7 +1329,14 @@ fn run_edit(doc: &mut printcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -
Edit::SetAnnotationInfo { page, index, author, subject, icon } => {
printcraft_annot::set_info(doc, *page, *index, author.as_deref(), subject.as_deref(), *icon, &cx.meta())?;
}
- Edit::SetFieldValue { name, value } => printcraft_forms::set_value(doc, name, value)?,
+ Edit::SetFieldValue { name, value } => match cx.js.as_mut() {
+ Some(js) => printcraft_forms::set_value_with(doc, name, value, js)?,
+ None => printcraft_forms::set_value(doc, name, value)?,
+ },
+ Edit::ApplyScriptChanges { changes } => match cx.js.as_mut() {
+ Some(js) => printcraft_forms::apply_script_changes(doc, changes, js)?,
+ None => printcraft_forms::apply_script_changes(doc, changes, &mut printcraft_forms::NoScripts)?,
+ },
Edit::SetFieldImage { name, image } => {
let (img, _) = printcraft_create::image_xobject(doc, name, image)?;
let px = match &*doc.get(img) {
@@ -1325,6 +1370,14 @@ fn run_edit(doc: &mut printcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -
Edit::EditTextLine { page, line, text } => {
printcraft_edit::replace_line(doc, *page, *line, text)?;
}
+ Edit::SetFieldScript { name, event, script } => {
+ printcraft_forms::set_field_script(doc, name, event, script.as_deref())?;
+ match cx.js.as_mut() {
+ Some(js) => printcraft_forms::recalculate_with(doc, js)?,
+ None => printcraft_forms::recalculate(doc)?,
+ };
+ }
+ Edit::SetDocumentScript { name, script } => printcraft_forms::set_document_script(doc, name, script.as_deref())?,
Edit::AddOcrText { page, words } => {
printcraft_edit::stamp(doc, *page, "OCR", printcraft_ocr::text_layer(words))?;
}
@@ -1583,6 +1636,8 @@ pub enum EditError {
SignedRewrite(String),
#[error("this document is signed: rewriting it would invalidate its signatures (save it incrementally instead)")]
Signed,
+ #[error("{0}")]
+ Invalid(String),
}
impl From for EditError {
@@ -1599,6 +1654,8 @@ pub struct Session {
clock: Option i64>,
/// Certificates trusted for signing (Acrobat: Trusted Certificates).
trust: Arc,
+ /// Preferences ▸ JavaScript ▸ Enable Acrobat JavaScript, inverted (on by default).
+ js_off: bool,
}
/// Validate the signature fields of `cos` (written as `bytes`).
@@ -1720,6 +1777,7 @@ impl Session {
sig_cache,
editor,
config,
+ js_output: Default::default(),
});
Ok(id)
}
@@ -1732,7 +1790,9 @@ impl Session {
pub fn apply(&mut self, id: DocId, edit: Edit) -> Result<(), EditError> {
let now = self.now();
let today = self.today();
+ let js_off = self.js_off;
let doc = self.doc_mut(id)?;
+ let name = doc.name.clone();
let mut cx = EditCtx::new(now, doc.generation ^ (id.0 << 48));
cx.today = today;
let reason = doc.read_only_reason.clone().unwrap_or_default();
@@ -1742,6 +1802,9 @@ impl Session {
check_permission(&edit, &p)?;
}
let mut next = editor.cos.clone();
+ if !js_off && uses_scripts(&edit) {
+ cx.js = Some(js::JsRunner::new(&next, &name));
+ }
run_edit(&mut next, &edit, &mut cx)?;
// Signed documents are only ever saved incrementally: an edit that needs a full rewrite
// (applying redactions, changing security, sanitizing) would invalidate the signatures.
@@ -1773,6 +1836,9 @@ impl Session {
}
doc.dirty = true;
doc.generation += 1;
+ if let Some(js) = cx.js {
+ doc.js_output.append(js.output);
+ }
Ok(())
}
diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs
index e537dbea..c0d13394 100644
--- a/crates/engine/src/tests.rs
+++ b/crates/engine/src/tests.rs
@@ -1235,3 +1235,67 @@ fn recognize_text_makes_a_scanned_page_searchable() {
let again = s.recognize_text(id, &[], ocr::OcrSettings::default()).unwrap();
assert!(again[0].skipped.is_some());
}
+
+/// A form whose scripts are custom JavaScript: total = price × qty (calculate, through a
+/// document-level function), shown with a custom format; qty is validated; a button script.
+fn scripted_form() -> Vec {
+ let text = |name: &str, y: u32, aa: &str| {
+ format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T ({name}) /Rect [20 {y} 180 {}] /P 3 0 R /F 4 {aa} >>", y + 20)
+ };
+ let objs = [
+ "<< /Type /Catalog /Pages 2 0 R /Names << /JavaScript << /Names [(helpers) 10 0 R] >> >> /AcroForm << /Fields [4 0 R 5 0 R 6 0 R 7 0 R] /CO [6 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 8 0 R >> >> >> >>".to_string(),
+ "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>".to_string(),
+ "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R 6 0 R 7 0 R] >>".to_string(),
+ text("price", 250, "/V (2.5)"),
+ text("qty", 220, "/AA << /V << /S /JavaScript /JS (if (event.value > 100) { app.alert('At most 100'); event.rc = false; }) >> >>"),
+ text("total", 190, "/AA << /C << /S /JavaScript /JS (event.value = times\\(getField('price').value, getField('qty').value\\);) >> /F << /S /JavaScript /JS (event.value = 'USD ' + util.printf('%.2f', event.value);) >> >>"),
+ "<< /Type /Annot /Subtype /Widget /FT /Btn /Ff 65536 /T (go) /Rect [20 150 80 170] /P 3 0 R /F 4 >>".to_string(),
+ "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".to_string(),
+ "<< >>".to_string(),
+ "<< /S /JavaScript /JS (function times\\(a, b\\) { console.println('times'); return a * b; }) >>".to_string(),
+ ];
+ let mut out = b"%PDF-1.7\n".to_vec();
+ let mut offsets = Vec::new();
+ for (i, o) in objs.iter().enumerate() {
+ offsets.push(out.len());
+ out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
+ }
+ let xref = out.len();
+ out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
+ for o in offsets {
+ out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
+ }
+ out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
+ out
+}
+
+#[test]
+fn form_javascript_validates_calculates_and_formats() {
+ let mut s = Session::new().with_clock(|| 1_700_000_000);
+ let id = s.open("order.pdf", None, Arc::new(scripted_form()), None).unwrap();
+ let value = |s: &Session, n: &str| s.get(id).unwrap().form.iter().find(|f| f.name == n).unwrap().value.clone();
+ s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("4".into()) }).unwrap();
+ assert_eq!(value(&s, "total"), ["10"], "calculated by the script");
+ assert!(page_texts(&s, id)[0].contains("USD 10.00"), "{:?}", page_texts(&s, id));
+ let out = s.take_js_output(id);
+ assert!(out.console.contains(&"times".to_string()), "{out:?}");
+
+ let err = s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("500".into()) }).unwrap_err();
+ assert_eq!(err.to_string(), "At most 100");
+ assert_eq!(value(&s, "qty"), ["4"]);
+
+ // A button script changes fields in one undoable step, and asks for a page.
+ let o =
+ s.run_javascript(id, "getField('price').value = 3; getField('total').readonly = true; app.alert('Done'); this.print();", Some("go")).unwrap();
+ assert_eq!(o.alerts, ["Done"]);
+ assert_eq!(o.requests, [js::Request::Print]);
+ assert_eq!(value(&s, "total"), ["12"], "recalculated after the change");
+ assert!(s.get(id).unwrap().form.iter().find(|f| f.name == "total").unwrap().read_only());
+ assert_eq!(s.get(id).unwrap().can_undo(), Some("Run JavaScript"));
+
+ // JavaScript off: scripts don't run.
+ s.set_javascript(false);
+ s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("500".into()) }).unwrap();
+ assert_eq!(value(&s, "total"), ["12"]);
+ assert!(s.run_javascript(id, "1", None).is_err());
+}
diff --git a/crates/forms/src/af.rs b/crates/forms/src/af.rs
index d799f01a..bcaaaacb 100644
--- a/crates/forms/src/af.rs
+++ b/crates/forms/src/af.rs
@@ -91,6 +91,17 @@ pub struct Actions {
pub calculate: Calculate,
/// Events whose script isn't one of the AF calls (they need the JavaScript engine).
pub unsupported: Vec<&'static str>,
+ /// Those scripts' JavaScript, run through [`crate::Scripts`].
+ pub scripts: Scripts,
+}
+
+/// Field scripts that aren't AF calls, by event.
+#[derive(Clone, Debug, Default, PartialEq)]
+pub struct Scripts {
+ pub format: Option,
+ pub keystroke: Option,
+ pub validate: Option,
+ pub calculate: Option,
}
impl Actions {
diff --git a/crates/forms/src/appearance.rs b/crates/forms/src/appearance.rs
index 201f5e61..7adca416 100644
--- a/crates/forms/src/appearance.rs
+++ b/crates/forms/src/appearance.rs
@@ -172,15 +172,24 @@ fn frame(doc: &Document, wd: &Dict, w: f64, h: f64) -> (String, f64) {
/// The appearance of a text or choice field's widget showing `values`.
pub fn field_appearance(doc: &Document, f: &Field, w: &Widget, values: &[String]) -> Stream {
+ field_appearance_as(doc, f, w, values, true)
+}
+
+/// [`field_appearance`], where `format` false means `values` are already what to show (a
+/// custom Format script ran).
+pub fn field_appearance_as(doc: &Document, f: &Field, w: &Widget, values: &[String], format: bool) -> Stream {
// The Format event: what is shown, not what is stored.
let formatted: Vec;
- let values =
- if matches!(f.kind, crate::FieldKind::Text | crate::FieldKind::Combo) && values.len() == 1 && f.actions.format != crate::af::Format::None {
- formatted = vec![crate::af::format_value(&f.actions.format, &values[0])];
- &formatted[..]
- } else {
- values
- };
+ let values = if format
+ && matches!(f.kind, crate::FieldKind::Text | crate::FieldKind::Combo)
+ && values.len() == 1
+ && f.actions.format != crate::af::Format::None
+ {
+ formatted = vec![crate::af::format_value(&f.actions.format, &values[0])];
+ &formatted[..]
+ } else {
+ values
+ };
let wobj = doc.get(w.obj);
let wd = wobj.as_dict().cloned().unwrap_or_default();
let (width, height) = ((w.rect[2] - w.rect[0]).max(1.0), (w.rect[3] - w.rect[1]).max(1.0));
diff --git a/crates/forms/src/author.rs b/crates/forms/src/author.rs
index 748d2ef5..8aabb2bb 100644
--- a/crates/forms/src/author.rs
+++ b/crates/forms/src/author.rs
@@ -98,7 +98,7 @@ fn rgb(c: [f64; 3]) -> Object {
}
/// The AcroForm dictionary's reference, creating the form (with `/Helv` in `/DR`) if needed.
-fn ensure_form(doc: &mut Document) -> Result {
+pub(crate) fn ensure_form(doc: &mut Document) -> Result {
let root = doc.root().ok_or(FormError::NoForm)?;
let existing = doc.get(root).as_dict().and_then(|d| d.get(b"AcroForm").cloned());
let r = match existing {
diff --git a/crates/forms/src/lib.rs b/crates/forms/src/lib.rs
index a0071fd1..bb2aedb6 100644
--- a/crates/forms/src/lib.rs
+++ b/crates/forms/src/lib.rs
@@ -16,9 +16,14 @@ use printcraft_cos::{Dict, Document, ObjRef, Object, PdfString};
pub mod af;
pub mod appearance;
mod author;
+mod scripting;
pub use author::{
BorderStyle, FieldFont, FieldProps, Look, NewField, add_field, delete_field, duplicate_field, look, redraw_field, set_button_icon, set_props,
};
+pub use scripting::{
+ FieldChange, FieldEvent, NoScripts, ScriptResult, Scripts, apply_script_changes, document_scripts, document_scripts_named, set_document_script,
+ set_field_script,
+};
#[cfg(test)]
mod tests;
@@ -236,7 +241,10 @@ fn actions_of(doc: &Document, d: &Dict, widgets: &[ObjRef]) -> af::Actions {
if let Some(f) = js(b"F") {
match af::parse_format(&f) {
Some(fm) => out.format = fm,
- None if !f.trim().is_empty() => out.unsupported.push("format"),
+ None if !f.trim().is_empty() => {
+ out.unsupported.push("format");
+ out.scripts.format = Some(f);
+ }
None => {}
}
}
@@ -247,21 +255,30 @@ fn actions_of(doc: &Document, d: &Dict, widgets: &[ObjRef]) -> af::Actions {
match af::parse_format(&k) {
Some(fm @ af::Format::Mask(_)) => out.format = fm,
Some(_) => {}
- None if !k.trim().is_empty() => out.unsupported.push("keystroke"),
+ None if !k.trim().is_empty() => {
+ out.unsupported.push("keystroke");
+ out.scripts.keystroke = Some(k);
+ }
None => {}
}
}
if let Some(v) = js(b"V") {
match af::parse_validate(&v) {
Some(vv) => out.validate = vv,
- None if !v.trim().is_empty() => out.unsupported.push("validate"),
+ None if !v.trim().is_empty() => {
+ out.unsupported.push("validate");
+ out.scripts.validate = Some(v);
+ }
None => {}
}
}
if let Some(c) = js(b"C") {
match af::parse_calculate(&c) {
Some(cc) => out.calculate = cc,
- None if !c.trim().is_empty() => out.unsupported.push("calculate"),
+ None if !c.trim().is_empty() => {
+ out.unsupported.push("calculate");
+ out.scripts.calculate = Some(c);
+ }
None => {}
}
}
@@ -581,6 +598,11 @@ fn walk(
/// Fill the field `name`.
pub fn set_value(doc: &mut Document, name: &str, value: &FieldValue) -> Result<(), FormError> {
+ set_value_with(doc, name, value, &mut NoScripts)
+}
+
+/// [`set_value`], running the fields' JavaScript through `scripts`.
+pub fn set_value_with(doc: &mut Document, name: &str, value: &FieldValue, scripts: &mut dyn Scripts) -> Result<(), FormError> {
let all = fields(doc);
if all.is_empty() {
return Err(FormError::NoForm);
@@ -589,16 +611,22 @@ pub fn set_value(doc: &mut Document, name: &str, value: &FieldValue) -> Result<(
if f.read_only() {
return Err(FormError::ReadOnly(name.into()));
}
- write_value(doc, f, value)?;
- recalculate(doc)?;
+ write_value(doc, f, value, scripts)?;
+ recalculate_with(doc, scripts)?;
Ok(())
}
/// Run every field's Calculate script in the form's calculation order (`/CO`, then the other
/// calculated fields), as Acrobat does after any value changes. Returns how many changed.
pub fn recalculate(doc: &mut Document) -> Result {
+ recalculate_with(doc, &mut NoScripts)
+}
+
+/// [`recalculate`], running custom Calculate (and Format) scripts through `scripts`.
+pub fn recalculate_with(doc: &mut Document, scripts: &mut dyn Scripts) -> Result {
let all = fields(doc);
- if !all.iter().any(|f| f.actions.calculate != af::Calculate::None) {
+ let calculated = |f: &Field| f.actions.calculate != af::Calculate::None || f.actions.scripts.calculate.is_some();
+ if !all.iter().any(calculated) {
return Ok(0);
}
let co: Vec = acroform(doc)
@@ -609,7 +637,7 @@ pub fn recalculate(doc: &mut Document) -> Result {
.collect();
let mut order: Vec = co.iter().filter_map(|r| all.iter().find(|f| f.obj == *r)).map(|f| f.name.clone()).collect();
for f in &all {
- if f.actions.calculate != af::Calculate::None && !order.contains(&f.name) {
+ if calculated(f) && !order.contains(&f.name) {
order.push(f.name.clone());
}
}
@@ -621,11 +649,25 @@ pub fn recalculate(doc: &mut Document) -> Result {
let prefix = format!("{n}.");
now.iter().filter(|x| x.name == n || x.name.starts_with(&prefix)).flat_map(|x| x.value.first().cloned()).collect()
};
- let Some(v) = af::calculate(&f.actions.calculate, &lookup) else { continue };
- if f.value.first().map(String::as_str).unwrap_or("") != v && f.kind == FieldKind::Text {
+ let current = f.value.first().cloned().unwrap_or_default();
+ let v = if let Some(js) = &f.actions.scripts.calculate {
+ let f = f.clone();
+ let r = scripts.run(FieldEvent::Calculate, js, &f, ¤t, &now);
+ scripting::apply_changes(doc, &r.changes, &f.name)?;
+ if !r.rc {
+ continue;
+ }
+ r.value
+ } else {
+ let Some(v) = af::calculate(&f.actions.calculate, &lookup) else { continue };
+ v
+ };
+ let now = fields(doc);
+ let Some(f) = now.iter().find(|f| f.name == name) else { continue };
+ if current != v && matches!(f.kind, FieldKind::Text | FieldKind::Combo) {
let f = f.clone();
doc.update_dict(f.obj, |d| d.set(b"V".to_vec(), PdfString::text(&v)))?;
- redraw(doc, &f, std::slice::from_ref(&v))?;
+ redraw(doc, &f, std::slice::from_ref(&v), scripts)?;
changed += 1;
}
}
@@ -636,7 +678,7 @@ fn invalid(m: impl Into) -> Result {
Err(FormError::Invalid(m.into()))
}
-fn write_value(doc: &mut Document, f: &Field, value: &FieldValue) -> Result<(), FormError> {
+fn write_value(doc: &mut Document, f: &Field, value: &FieldValue, scripts: &mut dyn Scripts) -> Result<(), FormError> {
match (f.kind, value) {
(FieldKind::Text, FieldValue::Text(t)) => {
if let Some(max) = f.max_len
@@ -647,6 +689,7 @@ fn write_value(doc: &mut Document, f: &Field, value: &FieldValue) -> Result<(),
// Keystroke (on commit) and Validate, as Acrobat runs them before accepting a value.
let t = &af::keystroke(&f.actions.format, &f.name, t).map_err(FormError::Invalid)?;
af::validate(&f.actions.validate, t).map_err(FormError::Invalid)?;
+ let t = &scripting::accept(doc, f, t, scripts)?;
doc.update_dict(f.obj, |d| {
if t.is_empty() {
d.remove(b"V");
@@ -655,7 +698,7 @@ fn write_value(doc: &mut Document, f: &Field, value: &FieldValue) -> Result<(),
}
d.remove(b"RV");
})?;
- redraw(doc, f, std::slice::from_ref(t))
+ redraw(doc, f, std::slice::from_ref(t), scripts)
}
(FieldKind::CheckBox, v) => {
let state = f.widgets.iter().find_map(|w| w.on_state.clone()).unwrap_or_else(|| "Yes".into());
@@ -735,7 +778,7 @@ fn write_value(doc: &mut Document, f: &Field, value: &FieldValue) -> Result<(),
d.set(b"I".to_vec(), Object::Array(indices));
}
})?;
- redraw(doc, f, &exports)
+ redraw(doc, f, &exports, scripts)
}
(FieldKind::PushButton, _) => invalid(format!("{:?} is a button; it has no value", f.name)),
(FieldKind::Signature, _) => invalid(format!("{:?} is a signature field; sign it with Fill & Sign or a digital ID", f.name)),
@@ -765,9 +808,16 @@ fn set_states(doc: &mut Document, f: &Field, on: Option<&str>) -> Result<(), For
}
/// Regenerate the normal appearance of every widget of a text or choice field.
-fn redraw(doc: &mut Document, f: &Field, values: &[String]) -> Result<(), FormError> {
+fn redraw(doc: &mut Document, f: &Field, values: &[String], scripts: &mut dyn Scripts) -> Result<(), FormError> {
+ let shown = match values {
+ [one] if matches!(f.kind, FieldKind::Text | FieldKind::Combo) => scripting::formatted(doc, f, one, scripts),
+ _ => None,
+ };
for w in &f.widgets {
- let stream = appearance::field_appearance(doc, f, w, values);
+ let stream = match &shown {
+ Some(s) => appearance::field_appearance_as(doc, f, w, std::slice::from_ref(s), false),
+ None => appearance::field_appearance(doc, f, w, values),
+ };
let ap = doc.add(Object::Stream(stream));
let mut apd = Dict::new();
apd.set(b"N".to_vec(), Object::Ref(ap));
@@ -805,7 +855,7 @@ pub fn reset(doc: &mut Document, names: Option<&[String]>) -> Result &'static str {
+ match self {
+ FieldEvent::Keystroke => "Keystroke",
+ FieldEvent::Validate => "Validate",
+ FieldEvent::Calculate => "Calculate",
+ FieldEvent::Format => "Format",
+ }
+ }
+}
+
+/// A change a script made to a field (`None`: unchanged).
+#[derive(Clone, Debug, Default, PartialEq)]
+pub struct FieldChange {
+ pub name: String,
+ pub value: Option>,
+ pub read_only: Option,
+ pub required: Option,
+ /// `display.visible` 0, `hidden` 1, `noPrint` 2, `noView` 3.
+ pub display: Option,
+}
+
+/// What a script did.
+#[derive(Clone, Debug, PartialEq)]
+pub struct ScriptResult {
+ /// `event.rc`.
+ pub rc: bool,
+ /// `event.value` afterwards.
+ pub value: String,
+ pub changes: Vec,
+ /// Why it rejected the value (its alert), for the error shown.
+ pub message: Option,
+}
+
+/// Runs field scripts (JavaScript).
+pub trait Scripts {
+ /// Run `script` for `event` on `target`, whose value is `value`; `fields` is the whole form.
+ fn run(&mut self, event: FieldEvent, script: &str, target: &Field, value: &str, fields: &[Field]) -> ScriptResult;
+}
+
+/// No script engine (or JavaScript turned off): scripts change nothing.
+pub struct NoScripts;
+
+impl Scripts for NoScripts {
+ fn run(&mut self, _: FieldEvent, _: &str, _: &Field, value: &str, _: &[Field]) -> ScriptResult {
+ ScriptResult { rc: true, value: value.to_string(), changes: Vec::new(), message: None }
+ }
+}
+
+/// Run the Keystroke (commit) and Validate scripts for a new text value. Returns the value to
+/// store, or why it is rejected.
+pub(crate) fn accept(doc: &mut Document, f: &Field, value: &str, scripts: &mut dyn Scripts) -> Result {
+ let mut v = value.to_string();
+ for (event, script, default) in [
+ (FieldEvent::Keystroke, &f.actions.scripts.keystroke, "The value entered does not match the format of the field"),
+ (FieldEvent::Validate, &f.actions.scripts.validate, "The value entered is not valid for the field"),
+ ] {
+ let Some(js) = script else { continue };
+ let all = fields(doc);
+ let r = scripts.run(event, js, f, &v, &all);
+ apply_changes(doc, &r.changes, &f.name)?;
+ if !r.rc {
+ return Err(FormError::Invalid(r.message.unwrap_or_else(|| format!("{default} [ {} ]", f.name))));
+ }
+ v = r.value;
+ }
+ Ok(v)
+}
+
+/// The text a custom Format script shows for `value`, if the field has one.
+pub(crate) fn formatted(doc: &Document, f: &Field, value: &str, scripts: &mut dyn Scripts) -> Option {
+ let js = f.actions.scripts.format.as_ref()?;
+ let r = scripts.run(FieldEvent::Format, js, f, value, &fields(doc));
+ Some(r.value)
+}
+
+/// Apply script changes to other fields (values written without re-running their scripts;
+/// read-only, required and visibility as flags). `except` is the event's own field, whose value
+/// the event sets.
+pub(crate) fn apply_changes(doc: &mut Document, changes: &[FieldChange], except: &str) -> Result<(), FormError> {
+ for c in changes {
+ let all = fields(doc);
+ let Some(f) = all.iter().find(|f| f.name == c.name) else { continue };
+ if let Some(v) = &c.value
+ && c.name != except
+ && *v != f.value
+ {
+ let value = match f.kind {
+ FieldKind::Text => FieldValue::Text(v.first().cloned().unwrap_or_default()),
+ FieldKind::CheckBox => FieldValue::Check(!v.is_empty()),
+ FieldKind::Radio => FieldValue::Radio(v.first().cloned()),
+ FieldKind::Combo | FieldKind::List => FieldValue::Choice(v.clone()),
+ _ => continue,
+ };
+ let mut tmp = f.clone();
+ tmp.flags &= !flags::NO_TOGGLE_TO_OFF;
+ tmp.actions = Default::default();
+ crate::write_value(doc, &tmp, &value, &mut NoScripts)?;
+ }
+ let mut ff = f.flags;
+ for (on, bit) in [(c.read_only, flags::READ_ONLY), (c.required, flags::REQUIRED)] {
+ match on {
+ Some(true) => ff |= bit,
+ Some(false) => ff &= !bit,
+ None => {}
+ }
+ }
+ if ff != f.flags {
+ doc.update_dict(f.obj, |d| d.set(b"Ff".to_vec(), Object::Int(ff as i64)))?;
+ }
+ if let Some(display) = c.display {
+ for w in &f.widgets {
+ let old = doc.get(w.obj).as_dict().and_then(|d| d.int(b"F")).unwrap_or(0);
+ // Hidden 2, Print 4, NoView 32 (Table 167).
+ let base = old & !(2 | 4 | 32);
+ let new = match display {
+ 1 => base | 2 | 4,
+ 2 => base,
+ 3 => base | 4 | 32,
+ _ => base | 4,
+ };
+ if new != old {
+ doc.update_dict(w.obj, |d| d.set(b"F".to_vec(), Object::Int(new)))?;
+ }
+ }
+ }
+ }
+ Ok(())
+}
+
+/// Apply what a button or console script changed (values, read-only, required, visibility),
+/// then recalculate.
+pub fn apply_script_changes(doc: &mut Document, changes: &[FieldChange], scripts: &mut dyn Scripts) -> Result<(), FormError> {
+ apply_changes(doc, changes, "")?;
+ crate::recalculate_with(doc, scripts)?;
+ Ok(())
+}
+
+/// Document-level JavaScripts (the catalog's `/Names /JavaScript` tree), in name order: they
+/// define the functions field scripts call.
+pub fn document_scripts(doc: &Document) -> Vec {
+ document_scripts_named(doc).into_iter().map(|(_, js)| js).collect()
+}
+
+/// [`document_scripts`] with their names.
+pub fn document_scripts_named(doc: &Document) -> Vec<(String, String)> {
+ let Some(root) = doc.root() else { return Vec::new() };
+ let cat = doc.get(root);
+ let Some(names) = cat.as_dict().and_then(|c| c.get(b"Names")).map(|n| doc.resolve(n)) else { return Vec::new() };
+ let Some(tree) = names.as_dict().and_then(|n| n.get(b"JavaScript")).map(|t| doc.resolve(t)) else { return Vec::new() };
+ let mut out: Vec<(String, String)> = Vec::new();
+ let mut stack = vec![(*tree).clone()];
+ let mut seen = 0;
+ while let Some(node) = stack.pop() {
+ seen += 1;
+ if seen > 10_000 {
+ break;
+ }
+ let Some(d) = node.as_dict() else { continue };
+ if let Some(kids) = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()) {
+ stack.extend(kids.iter().map(|k| (*doc.resolve(k)).clone()));
+ }
+ if let Some(pairs) = d.get(b"Names").map(|n| doc.resolve(n)).and_then(|n| n.as_array().cloned()) {
+ for pair in pairs.chunks(2) {
+ let [k, v] = pair else { continue };
+ let key = match &*doc.resolve(k) {
+ Object::String(s) => s.to_text(),
+ _ => continue,
+ };
+ if let Some(js) = crate::script(doc, v) {
+ out.push((key, js));
+ }
+ }
+ }
+ }
+ out.sort();
+ out
+}
+
+/// Add (or replace) a document-level JavaScript named `name`.
+pub fn set_document_script(doc: &mut Document, name: &str, js: Option<&str>) -> Result<(), FormError> {
+ let root = doc.root().ok_or(FormError::NoForm)?;
+ let cat = doc.get(root).as_dict().cloned().unwrap_or_default();
+ let mut names = cat.get(b"Names").map(|n| doc.resolve(n)).and_then(|n| n.as_dict().cloned()).unwrap_or_default();
+ let tree = names.get(b"JavaScript").map(|t| doc.resolve(t)).and_then(|t| t.as_dict().cloned()).unwrap_or_default();
+ if tree.contains(b"Kids") {
+ return Err(FormError::Invalid("the document's JavaScript name tree has kids; edit it with a full editor".into()));
+ }
+ let mut pairs: Vec