Files
pdfcraft/docs/acroform-script-workflows.md
T
Erkan DogruandFredApps f6c59c0b93 fix: integrate persistent AcroForm script workflows with native prompts and appearance writeback (#5699)
* AcroForm: integrate persistent timesheet script workflows

* AcroForm: address review caching, worker stacks and live metadata

* M6: address second review of AcroForm script workflows

* M6: handle top-level ASI boundaries and verify cyclic calculation results

* M6: bound script workers and isolate pending completions across edits

* M6: make form resets visible before subsequent script reads

* M6: include calculated dependents in reset comparison baselines

* M6: bound reset writeback work before native completion

* M6: charge expanded reset selections and field name bytes

* M6: bound string conversion bytes and charge reset widgets

* M6: preserve unread defaults and bound prompt text conversion

* M6: bound reset calls early and preserve calculated field identity

* AcroForm: address grouped calculations, field identity and widget ownership

* AcroForm: retire field instances and address native review edge cases

* AcroForm: route comparison Save As and Print to the source document

* M6: dispatch text widget mouse-up scripts

* M6: honor fit bounds for form button icons

* AcroForm: bound calculation work and preserve reset and save integrity

* Harden AcroForm script requests and worker recovery

* Keep form callbacks and focus consistent across edits

* AcroForm: preserve reset ordering and bound edit callbacks

* Fix reset appearance ordering and XFA focus after row removal

* Keep XFA focus indexes and stopped reset appearances consistent

* Document form script execution and reset helpers

* Keep script workers bounded and reject partial callback edits

* Keep form events available after a document script reaches VM limits

* Keep main's first-value Simple calculation and restore the calculateNow helper test

AFSimple_Calculate (JavaScript) and the native Simple calculation count each field's first
value again, as #5304 landed them; summing every selection of a multiple-selection list is
a separate behaviour change. Restores the document-helper calculateNow test and the
AFSimple_Calculate doc comment, and adds native and JavaScript multiple-selection cases.

* Patch the callback field snapshot in place instead of rebuilding it

Each field callback rebuilt all field states (3,129 on the benchmark form), and the
retained realm held the previous snapshot, so stamping instance tokens copied it again.
The runner now keeps its last snapshot and patches only the values and flags that
changed; ScriptSession::stamp_fields stamps it before dispatch, the worker copies only
when a token changes, and the realm drops the job's snapshot when the job ends (field
identity is checked against the cached keys instead).

* An oversized job script is that script's error, not a refused edit

Realm::run refused a script too long or too deep to run as an execution refusal, which
made the engine reject the whole edit (setting a 300 KiB calculation script failed). As on
main it is now a plain script error that keeps the value; the realm is not touched.

* Read a leading #! line as a comment in the chain scan; follow main's refusal and conditional limits in tests

The nesting guard from #5079 now checks brackets, prefixes and conditionals; the document
worker's preflight still bounds arrow-function and right-associative chains and skips a
leading #! line as the guard does. A refused script is an error, not a refused edit, so
the preflight test now checks the edit goes through with the error reported.

---------

Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
2026-11-11 08:42:04 +01:00

166 lines
17 KiB
Markdown

# AcroForm script workflows
This contribution connects the existing AcroForm object model to the native viewer and the
shared automation tools. The regression fixture is generated in Rust; it contains no private
document, employee, signature image or real PIN.
## Runtime and lifecycle
Each open document owns a `ScriptSession`. On native targets its Boa context lives on one
worker thread with a dedicated 64 MiB stack reservation (the transient stateless runner retains
its separate stack policy). Document scripts initialize once with `Doc/Open`, before the requested event;
button, console, format, validate and calculate scripts then share that context. Field objects
read the latest snapshot supplied by the engine, including objects retained by earlier scripts.
Exposed Field objects are cached by PDF object identity; synthetic fields use name keys. Separate documents cannot share globals or
icon objects. `eval` and dynamic Function construction remain disabled.
Changing the document-script sources recreates the context. Closing, reopening, reloading,
Revert, disabling JavaScript, and saving a full rewrite release/reset the context. Incremental saves retain it. A rewrite can reuse PDF object numbers, so old Field and icon objects must not be carried across it. PDF Undo/Redo restores PDF state;
it does not serialize or rewind arbitrary JavaScript variables. Globals are not saved into the
PDF. A worker fault resets its context; the existing cooperative source/loop/recursion bounds
remain in force. The source preflight additionally caps active arrow chains at 256 and right-associative
assignment/exponentiation chains at 4,096 across open bracket levels. Independent statements
and comma-separated sibling expressions reset only their own level; comparisons do not count.
Keyword unary/new chains share the prefix
depth bound of 64. These conservative limits protect recursive parser/compiler paths before
execution on the smaller worker stack. Boundary and refusal cases run in subprocess regressions.
These bounds do not establish a hard JavaScript heap quota.
`calculateNow()` evaluates the current calculation list synchronously, in `/CO` order, using
pending writes. Later statements in the same invocation see the new values. The caller's
event is restored after calculation, including failure. Reentrancy is capped at eight levels.
Field writes and their dependent recalculation use the existing single undo transaction.
Pure caption, geometry, style and visibility updates do not run the calculation chain.
Page geometry is read once per edit runner. Calculation sources/order are analyzed once on its
first callback and reused; field values and appearance properties still refresh on every callback.
Structural/document changes between batch children refresh the snapshot, and editing a field's
calculation script or calculation order invalidates the cached plan before recalculating.
`numPages`, `documentFileName` and `info` read the current event snapshot, including page/info edits,
Undo/Redo and completed Save As, without reinitializing script globals.
## Fields, appearances and icons
- `buttonGetCaption()` reads the first widget. An unindexed `buttonSetCaption()` updates every
child widget, including when the first already has the requested text. An unchanged uniform
caption does not create an edit. Only normal face `0` is implemented.
- `rect` reads/writes rotated user-space `[left, top, right, bottom]`. Writes affect widget 0,
as specified for an unindexed Field. Conversion uses its page rotation and effective Crop
box. Small/zero-area rectangles remain representable, rather than applying the interactive
field-authoring minimum size to a script's overlay.
- `textColor`, `fillColor`, `strokeColor`, `textSize`, `lineWidth`, `borderStyle`, `userName`,
`readonly` and `display` flow to the form model and saved appearances. Colour input supports
`T`, `G`, `RGB` and `CMYK`; text cannot be transparent. The existing appearance editor writes
its canonical RGB representation. Partial changes preserve unrelated fonts and properties.
- `buttonGetIcon()` returns a host-minted object for a normal Form XObject icon. `buttonSetIcon`
accepts only a handle minted in the same context, or null to clear it. It reuses the form and
its resources, including transparency, instead of rasterizing a signature/background. The
existing button appearance generator draws it. Other icon faces are refused.
- `getPageBox` reads effective/inherited page boxes in rotated user space. `zoom`,
`app.runtimeHighlight` and `setFocus()` communicate with the native viewer. Automation
reports viewer requests and does not open an external application or change a real viewer.
## Native menus and input
`app.popUpMenuEx` supports nested menus, separators, marked/disabled items, explicit return
values and cancellation. `app.response` supports named and positional parameters, defaults,
password masking and null cancellation. Alert replies use the documented numeric button ids.
Only the worker waits for a reply; egui continues drawing/input. The central modal gate blocks
document shortcuts and page input while a prompt is visible. Idle prompt polling uses a
one-second repaint interval; replying wakes the UI immediately and worker polling remains 16 ms. A reply resumes the same
invocation, without replaying the script or its side effects.
An explicit button captures document identity, generation and view geometry. Closing/switching
the document, editing it, disabling scripts or changing the relevant view discards a stale
completion and invalidates the old runtime. A reply is checked against an enabled menu leaf
and its enabled ancestors; invented/disabled return values fail with a script error. Prompts
have at most 1,024 entries, 16 submenu levels, 4,096-byte strings and 32 calls per invocation.
The native desktop admits one pending button interaction. A document worker refuses a second
run while the first is active, including while waiting for a prompt; it never queues a
synchronous field callback behind an unanswered dialog.
The native prompt UI is not implemented for the web build. The existing headless/unsupported
host path cancels unanswered popups and response dialogs; no web-interaction parity is claimed.
This work does not add timer execution, PDF annotations/layers to the JS object model, or the
remaining document action triggers.
## Automation
The existing `js_run` accepts `answers`, an optional array of up to 32 strings or null values:
```json
{
"doc": 1,
"script": "var t=app.popUpMenuEx({cName:'Manual',cReturn:'manual'}); if(t==='manual') getField('time').value=app.response({cQuestion:'Time'});",
"answers": ["manual", "07:45"]
}
```
The schema advertises a 4,096-character maximum per answer; the handler additionally enforces
a 4,096-byte UTF-8 budget, so multi-byte strings can reach the byte limit earlier.
Replies are consumed in prompt order. Null/missing replies cancel a menu/response; alert
defaults choose acknowledgement, cancellation or No according to the button set. No direct
file, process or network API is exposed by a prompt. Root confinement and permission-checked
document edits continue through the existing automation/engine boundary.
## Related contributions
The persistent runtime uses the upstream work from #5304 for synchronous calculations,
#5501 for page boxes, #5499 for colour parsing and writeback, and #5505 for normal captions.
The integration supplies the live document state, exact field identity, and grouped field
selection needed by the persistent context.
The bounded `popUpMenuEx` parser in `crates/js/src/interaction.rs` was adapted from #2081.
The prompt channel and default menu renderer support interactive form scripts. #2081 can
supply an anchored renderer through the UI menu callback when it adds link actions. Once
the shared parser lands upstream, this integration can use it directly.
`ScriptSession::run_host_task` gives future timer and page actions (#4751) access to the
retained realm on its own worker. `DocumentScriptEvent` and `run_document_script_event`
provide explicit entry points for catalog OpenAction, WillSave and WillPrint (#344).
The callers for those document actions are part of #344.
The lexical preflight recognizes unambiguous top-level and block-body ASI boundaries before identifiers, including declarations and assignments separated by line breaks or comments. It preserves counters after operators and member access, and across binary keyword operators. It distinguishes control-statement parentheses for regexp literals; inside expression parentheses, array/object literals and template interpolations it remains conservative; this is not a complete ECMAScript parser or ASI implementation.
Native persistent workers are capped process-wide at four on 32-bit targets and sixteen on 64-bit targets. Each reserves 64 MiB of stack; closing a document releases its slot after the worker exits. Capacity errors ask the caller to close a scripted document and retry; other documents are never silently evicted. The transient stateless runner has its separate existing stack policy.
Synchronous runs have one ten-second host deadline covering startup and every prompt round trip. A timeout replaces the document realm; late globals and custom Field properties cannot reach later runs. This is isolation, not forcible termination of an OS thread: cooperative engine limits still govern the detached worker, whose capacity slot stays charged until it exits. No hard CPU or heap quota is claimed.
An asynchronous completion stays reserved until the engine accepts it. An intervening edit forks a fresh realm before any field calculation, including when the edit later fails. Pending results carry runtime identity as well as generation, so discarding an old result never clears a newer realm. Script prompts render last and explicitly raise their retained egui layer, including when another app dialog opens later. Null prompt answers cancel popup/response input; alert answers instead select the button set's default (OK, Cancel or No).
`resetForm()` updates the live field shadow synchronously, including retained Field references and named parent groups. Reset requests still reach the PDF engine for default-value and appearance semantics; the atomic writeback performs resets before applying the final script delta. Delta comparison uses the reset baseline, so a later script assignment restoring the original pre-reset value is preserved. Doc Open and the first button/console event share this ordering, including after JavaScript is disabled and re-enabled. PDF Undo/Redo and save/reopen preserve the final values.
The reset comparison baseline is obtained by running the existing forms reset path on an isolated document copy. It therefore includes unnamed built-in Calculate text/combo dependents, without executing custom Calculate scripts during the preview. Normal automatic calculation after script value writes remains in force; this does not establish a policy that explicit formula-field writes override their formulas. Tests cover saved defaults left untouched by the event and a non-default typed native response as well as existing default/cancellation paths.
Reset writeback is admitted before request copies, preview cloning and PDF mutation only when it fits 64 reset calls, 4,096 aggregate selectors, and 2^24 estimated work units. Named resets pay for the bounded selector scan and the fields actually matched. The estimate charges both preview/commit passes, field/calculation/dependency scans, calculation source lengths and selector scans/bytes. Excess work returns an explicit error without committing PDF edits or an undo entry. Under-budget requests retain their order; duplicate resets are not silently merged because repeated recalculation can be observable. This is an operation/amplification bound, not a hard UI wall-clock deadline. Native start/completion tests cover the 64-call boundary, 65/10,000-call refusal, selector overflow, aggregate large-form refusal, reset/restore, Undo/Redo and save/reopen.
The reset admission estimate also charges field-name copies, selector comparison bytes, and the worst-case expansion of each named reset to all fields (including both validation and membership scans). This conservative bound can refuse a named reset even if its eventual selection would be small; full-form resets avoid that named-selection cost. Refusal tests compare saved bytes as well as generation and undo state.
Reset admission includes the total widget count for preview and commit. The shared JavaScript string-array converter also enforces a 32 MiB aggregate escaped UTF-8 text budget before copying beyond it (including scalar input), independently of later request clipping. Exact Unicode and unpaired-surrogate output bytes are charged; the existing array-item limit remains. JavaScript ToString/getters may still execute script code before conversion; this is not a general JavaScript heap limit.
Unknown PDF defaults (failed stream decoding or a spent shared value budget) are marked in the JavaScript snapshot. A reset preserves their live values, matching the forms reset policy; explicit assignments before/after reset remain effective. The policy follows inherited defaults too. Menu and response properties now use the bounded escaped UTF-8 conversion at 4096 bytes per property before copying past that limit; user conversion exceptions are preserved. Existing item/depth/prompt/reply limits remain separate. Immediate dependent reads can use resetForm(...); calculateNow(); the reset callback timing itself has not been changed without an Acrobat parity oracle.
The 64-reset call budget is now enforced in the worker before argument conversion and field scanning, shared by document initialization and its first event, and reset for later jobs. A sticky outcome flag rejects PDF writeback even if JavaScript catches the exception. The existing engine admission/work guard remains. Calculations are resolved by PDF object reference to the matching snapshot position; event targets and duplicate-name value and property deltas retain object identity through the host, authoring helpers and redraw. Public getField(name) retains first-match behavior. Tests cover both calculation orders, target reads/writes, native/console completion, Undo/Redo and save/reopen.
AFSimple_Calculate expands exact/group/duplicate field matches without materializing their cross-product; numeric conversion and empty-result fallbacks remain unchanged. Selector comparison work is admitted up to 2^24 estimated units. The Field cache uses canonical PDF identities (name-only keys for synthetic callers), prunes removed fields between jobs and covers every exposed field, including more than 4096; renames preserve retained references/custom properties. Script color changes detach outside widgets that share the target group's indirect MK, including bounded reference aliases, while keeping target widgets' indirect object and unrelated MK entries. Native button clicks carry exact identities through hit testing, action dispatch and event construction. Legacy name-only engine/UI entry points remain wrappers. Headless form_fields returns field_id; js_run accepts that optional 48-bit identity with a matching field name, validates it before execution, and retains deterministic prompt replies. IDs are document-local and must be rediscovered after structural changes.
Field instance tokens live outside PDF undo snapshots. Successful edits and undo/redo retire
removed field instances immediately, even when no script job observes the gap. Reusing the
same PDF object number does not revive an old Field accessor; document globals survive history.
A refused synchronous/native writeback invalidates its document realm before returning an error.
The host-backed event accessor remains usable when globalThis is frozen. AFSimple_Calculate
shares a 2^24 selector-work budget across all calculation sources in an event job; later jobs
receive a fresh budget. Native simple calculations include every selected list export value,
and calculateNow uses the regular value setter's checkbox/radio normalization.
Native UI feedback distinguishes a refused busy click, a stale interaction and a timeout.
Timeouts report discarded results without claiming worker termination. Visible comparison halves
may complete; switching away, changing the view or editing still invalidates stale interactions.
Focus reveals a laid-out widget's page, and image import carries exact clicked identity through
the picker and SetFieldImageTarget. The headless form_set_image tool accepts the same optional
field_id and validates exact name/identity pairs; omitted IDs preserve legacy lookup. Mixed widget icons emit field-wide changes only when an
icon setter runs. UI waiting helpers use elapsed deadlines rather than machine-speed retry counts.
Comparison script document requests use their source view. Save As captures the source document without changing the comparison tab. Print activates and retains the source tab because the existing print dialog reads the active document throughout its lifetime. Missing source views never fall back to another active document. Regression coverage distinguishes a two-page source from a one-page comparison file and checks saved bytes, print-dialog page state and untouched comparison content.
Explicit calculation logs ordinary field script errors and continues in calculation order. Each job can evaluate at most 4,096 calculation scripts, including nested calls and document initialization on the first event. Exhaustion remains in effect after a JavaScript catch and prevents PDF writeback.