mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-12 05:42:52 +00:00
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>
This commit is contained in:
1 parent
235ede4896
commit
f6c59c0b93
64 files changed
+7389
-768
No files matched your search
+15
-5
@@ -1,3 +1,8 @@
|
||||
- 2026-11-04 (M6, PR #5699 group/cache/MK/button review): Expand calculation groups/duplicates with streaming bounded lookup; retain one canonical Field object per exposed identity and prune stale roots. Protect outside shared-MK owners and carry exact button IDs through native clicks and js_run, with regression and persistence coverage.
|
||||
- 2026-11-04 (M6, PR #5699 early reset/duplicate identities): Enforce the shared 64-call reset budget before argument conversion/scanning, with sticky atomic rejection and Open/event budget sharing. Preserve PDF object identity in Calculate order, live targets and value writeback; regress duplicate names, reversed order, native completion and persistence.
|
||||
- 2026-11-04 (M6, PR #5699 independent review): Preserve unknown defaults through the JS reset shadow; bound menu/response text before copies. Cover native/console reset persistence, inherited defaults, decode-budget exhaustion and exact Unicode dialog boundaries.
|
||||
- 2026-11-04 (M6, PR #5699 memory/widget bounds): Count reset widgets and bound escaped UTF-8 text conversion before copies; add negative and Unicode boundary regressions.
|
||||
- 2026-11-04 (M6, PR #5699 reset estimate): Charge expanded-name scans and field-name copy/comparison bytes; add three negative regressions and byte-exact refusal checks.
|
||||
# PdfCraft roadmap
|
||||
|
||||
> **Last reviewed:** 2026-10-31 | **Last updated:** 2026-10-31 | **Change:** patch (M6, PR #4166/#4088: integrate main f137a289 at 0.16.1; retain automatic-field Print suppression, explicit button/console printing and document-error provenance; 761 scoped tests and verification gates passed; 13-step CLI exercise and render inspection passed; existing warnings retained; estimates unchanged)
|
||||
@@ -6,15 +11,18 @@
|
||||
> **Last reviewed:** 2026-11-08 | **Last updated:** 2026-11-08 | **Change:** patch (M10 #6514: refresh craft-print to 7ee35a9f, PDF loader to 51934db3 and public GDI kept at 64e7afd4 after #605 merged; preserve crash fix and stream-test repairs; new-graph CI pending; estimates unchanged)
|
||||
|
||||
> **Last reviewed:** 2026-11-07 | **Last updated:** 2026-11-07 | **Change:** patch (M10: file-backed Windows PDF loading without whole-job buffering or per-chunk async waits; approved dependency chain retains #5006; physical output still unqualified; estimates unchanged)
|
||||
|
||||
> **Last reviewed:** 2026-11-05 | **Last updated:** 2026-11-05 | **Change:** patch (M7 #2964: directly edit Thai source text inside supported Form XObjects; estimates unchanged)
|
||||
|
||||
> **Last reviewed:** 2026-11-03 | **Last updated:** 2026-11-03 | **Change:** patch (M5 #5850 review: indirect typewriter intent and composer edits preserve outer whitespace; movement regression commits by outside click; estimates unchanged)
|
||||
|
||||
> **Last reviewed:** 2026-11-03 | **Last updated:** 2026-11-03 | **Change:** patch (M5 #5850: Enter adds Fill & Sign line breaks; outside clicks commit, Escape cancels, and saved multiline appearances retain their text and height; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-31 | **Last updated:** 2026-10-31 | **Change:** minor (M3/M4, #5330: Crop pages is directly available in the document quick toolbar; estimates unchanged)
|
||||
|
||||
|
||||
> **2026-11-04 (M6, PR #5699 reset budget):** Reject reset amplification before request copies, preview and PDF mutation; cap reset/selector counts and aggregate estimated work. Add native completion, boundary, flood and large-form regressions; preserve ordered under-budget resets.
|
||||
> **2026-11-04 (M6, PR #5699 dependent reset):** Reuse form reset on an isolated comparison document so unnamed built-in calculated text/combo fields are included; retain normal post-write calculation. Extend persisted-default and typed-response regressions.
|
||||
> **2026-11-04 (M6, PR #5699 reset ordering):** Reset live field values synchronously, commit resets before final script deltas using a reset baseline, and cover Doc Open reinitialization, save/reopen, Undo/Redo and replay-sensitive UI counters.
|
||||
> **2026-11-04 (M6, PR #5699 review):** Bound worker capacity and synchronous deadlines, isolate expired/pending realms before edits, keep script prompts above other dialogs, and cover block ASI, control-position regex literals, event routing and complete undo state. Validation is reported in the PR; parity remains partial.
|
||||
> **2026-11-03 (M6, PR #5699 follow-up):** Accept independent top-level statements separated by automatic semicolon insertion while retaining continuation-chain limits; assert cyclic calculation results and preserve upstream multiline Fill & Sign coverage.
|
||||
> **2026-11-02 (M6, PR #5699 second review):** Count active expression chains instead of whole-source tokens; align native button events and automation schemas, block document input under script prompts, and validate prompt dismissal and idle repaint scheduling.
|
||||
> **2026-11-02 (M6, PR #5699 review):** Cache per-edit script geometry/calculation plans, expose live document metadata, and bound persistent worker stacks to 64 MiB; add operation-count, lifecycle and isolated hostile-input/address-space regressions.
|
||||
> **2026-11-02 (M6, #5211/#269):** Integrate persistent native AcroForm realms, synchronous calculation, page boxes, appearance/geometry/icon writeback and native button prompt continuation. Synthetic workflow and cancellation tests added; validation details accompany the PR. Remaining JavaScript parity stays partial.
|
||||
> **Last reviewed:** 2026-11-01 | **Last updated:** 2026-11-01 | **Change:** patch (M7, PR #2155 further review: inherited drawing/text state, affine image gestures, paste/selection ownership, no-op scripts and menu permissions; estimates unchanged)
|
||||
|
||||
> **Last reviewed:** 2026-11-01 | **Last updated:** 2026-11-01 | **Change:** patch (M7, PR #2155 latest comments: explicit image-paste events, mixed-size field refresh, and documented mixed-script normalization with Undo; estimates unchanged)
|
||||
@@ -989,3 +997,5 @@ PR #2133 retains document-local Pages sidebar focus for Ctrl/Cmd+A and Escape af
|
||||
PR #1952 retains per-widget checkbox/radio export-value editing in the split form-authoring and Prepare modules. Caption editing remains supported. Tests cover preserved appearances and selection, invalid/locked refusals, data-format round trips, and Apply/Cancel/Undo. Current-head hosted validation is pending; feature estimates are unchanged.
|
||||
|
||||
- **2026-10-31 (#1577):** Reconciled the page-shadow preference with split viewer, organizer, settings and control modules, preserving upstream page-gap shadow suppression. CI validation pending.
|
||||
|
||||
- 2026-11-04: AcroForm review follow-up covers field-instance retirement across history, rejected-writeback realm invalidation, mixed widget icons, shared calculation budgets and multi-select/checkbox consistency, frozen-global events, exact image imports, comparison-half scripts, focus reveal and explicit busy/timeout feedback. Synthetic regressions and elapsed UI deadlines accompany the fixes.
|
||||
@@ -333,6 +333,14 @@ navigation-history tool; use the opt-in UI control channel to navigate the displ
|
||||
FreeText appearance and survives reopening. Other mark/signature types reject
|
||||
`color`; their appearance controls are separate.
|
||||
|
||||
## AcroForm script interactions
|
||||
|
||||
`js_run` accepts optional `answers` (strings or null, at most 32), consumed in popup, response and alert order. Missing responses cancel menus/input; explicit viewer requests remain data in automation. See [the runtime contract](../../docs/acroform-script-workflows.md).
|
||||
|
||||
For distinct PDF fields sharing a name, form_fields exposes a document-local integer field_id. Pass it with field to js_run to bind event.target to that exact field (including ordered answers); omit it for existing first-match behavior. Mismatched names/IDs, missing names and non-integer/out-of-range IDs are refused before execution.
|
||||
|
||||
`form_set_image` accepts the optional document-local `field_id` from `form_fields` alongside the matching `field` name, selecting an exact duplicate-name image button. Omit it for the legacy first-name match. IDs are unsigned 48-bit integers and must be rediscovered after structural edits or reopening.
|
||||
|
||||
## Reviewing OCR headlessly
|
||||
|
||||
`ocr_words {doc, retain_review: true}` reads words without editing the document and returns
|
||||
|
||||
@@ -11,6 +11,8 @@ fn request_json(r: &pdfcraft_engine::js::Request) -> Value {
|
||||
R::LaunchUrl(u) => json!({ "url": u }),
|
||||
R::Submit(u) => json!({ "submit": u }),
|
||||
R::Focus(f) => json!({ "focus": f }),
|
||||
R::FocusField { name, object_id } => json!({ "focus": name, "field_id": object_id }),
|
||||
R::Zoom(z) => json!({"zoom":z}),
|
||||
R::Beep => json!({ "beep": true }),
|
||||
R::Highlight(on) => json!({ "highlight": on }),
|
||||
R::SaveAs => json!({ "save_as": true }),
|
||||
@@ -30,9 +32,29 @@ impl Automation {
|
||||
/// messages, console lines, requests, error, the failing document scripts' errors and result.
|
||||
pub(crate) fn js_run(&mut self, a: &Args) -> Result<Value> {
|
||||
let id = self.doc(a)?.id;
|
||||
let answers = match a.0.get("answers") {
|
||||
None => Vec::new(),
|
||||
Some(Value::Array(values)) if values.len() <= 32 => values
|
||||
.iter()
|
||||
.map(|v| match v {
|
||||
Value::Null => Ok(None),
|
||||
Value::String(s) if s.len() <= 4096 => Ok(Some(s.clone())),
|
||||
_ => Err(ToolError::InvalidArgs("answers must contain strings or null".into())),
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?,
|
||||
_ => return Err(ToolError::InvalidArgs("answers must be an array of at most 32 strings or null".into())),
|
||||
};
|
||||
let identity =
|
||||
a.0.get("field_id")
|
||||
.map(|v| {
|
||||
v.as_u64()
|
||||
.filter(|id| *id < 1 << 48)
|
||||
.ok_or_else(|| ToolError::InvalidArgs("field_id must be an integer between 0 and 281474976710655".into()))
|
||||
})
|
||||
.transpose()?;
|
||||
let (script, field) = (a.str("script")?, a.opt_str("field")?);
|
||||
// Applying the script's field changes runs the field scripts again; their output is reported with the script's.
|
||||
let (mut o, edit) = self.session.with_own_js_output(id, |s| s.run_javascript(id, script, field)).map_err(failed)?;
|
||||
let (mut o, edit) =
|
||||
self.session.with_own_js_output(id, |s| s.run_javascript_answers_target(id, script, field, identity, &answers)).map_err(failed)?;
|
||||
o.alerts.extend(edit.alerts);
|
||||
o.console.extend(edit.console);
|
||||
o.requests.extend(edit.requests);
|
||||
|
||||
@@ -711,11 +711,7 @@ impl Automation {
|
||||
"form_fields" => self.form_fields(&a)?,
|
||||
"form_fill" => self.form_fill(&a)?,
|
||||
"form_focus" => self.form_focus(&a)?,
|
||||
"form_set_image" => {
|
||||
let path = self.resolve(a.str("path")?, false)?;
|
||||
let bytes = read_input(&path)?;
|
||||
self.apply(&a, Edit::SetFieldImage { name: a.str("field")?.to_owned(), image: Arc::new(bytes) })?
|
||||
}
|
||||
"form_set_image" => self.form_set_image(&a)?,
|
||||
"form_reset" => self.form_reset(&a)?,
|
||||
"form_add_field" => self.form_add_field(&a)?,
|
||||
"form_add_field_table" => self.form_add_field_table(&a)?,
|
||||
|
||||
@@ -272,6 +272,27 @@ fn value_for(f: &FormField, v: &Value) -> Result<FieldValue> {
|
||||
}
|
||||
|
||||
impl Automation {
|
||||
/// Import an image using an optional exact field ID, preserving the legacy name-only tool.
|
||||
pub(crate) fn form_set_image(&mut self, a: &Args) -> Result<Value> {
|
||||
let name = a.str("field")?.to_owned();
|
||||
let object = a
|
||||
.0
|
||||
.get("field_id")
|
||||
.map(|value| -> Result<pdfcraft_cos::ObjRef> {
|
||||
let key =
|
||||
value.as_u64().filter(|key| *key < 1u64 << 48).ok_or_else(|| bad("field_id must be an integer between 0 and 281474976710655"))?;
|
||||
Ok(pdfcraft_cos::ObjRef::new((key >> 16) as u32, key as u16))
|
||||
})
|
||||
.transpose()?;
|
||||
let path = self.resolve(a.str("path")?, false)?;
|
||||
let image = std::sync::Arc::new(crate::paths::read_input(&path)?);
|
||||
let edit = match object {
|
||||
Some(object) => Edit::SetFieldImageTarget { name, object, image },
|
||||
None => Edit::SetFieldImage { name, image },
|
||||
};
|
||||
self.apply(a, edit)
|
||||
}
|
||||
|
||||
pub(crate) fn form_fields(&self, a: &Args) -> Result<Value> {
|
||||
let doc = self.doc(a)?;
|
||||
let out: Vec<Value> = doc
|
||||
@@ -286,6 +307,7 @@ impl Automation {
|
||||
});
|
||||
let mut v = json!({
|
||||
"name": f.name,
|
||||
"field_id": pdfcraft_engine::js::field_object_id(f.obj),
|
||||
"type": kind_name(f.kind),
|
||||
"value": match f.kind {
|
||||
FormFieldKind::CheckBox => json!(!f.value.is_empty()),
|
||||
|
||||
@@ -631,7 +631,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
&["doc", "page", "path", "rect"],
|
||||
)),
|
||||
t("form_set_image", "Set an image field's picture", "Show an image file (PNG, JPEG, TIFF, GIF or BMP) in an image field or button, scaled to fit and centred (what clicking an image field does). On an XFA form the picture is kept in its data too (base64, as Designer saves it) and the form shows it from there. Undoable.")
|
||||
.with(schema(json!({ "doc": doc(), "field": { "type": "string" }, "path": { "type": "string" } }), &["doc", "field", "path"])),
|
||||
.with(schema(json!({ "doc": doc(), "field": { "type": "string" }, "field_id": {"type":"integer","minimum":0,"maximum":281474976710655u64,"description":"Optional exact document-local identity from form_fields; requires matching field name."}, "path": { "type": "string" } }), &["doc", "field", "path"])),
|
||||
t(
|
||||
"form_set_props",
|
||||
"Field properties",
|
||||
@@ -1415,10 +1415,10 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
t("form_merge_data", "Merge data files into spreadsheet", "Collect the field values of form data files (FDF, XFDF) or filled-in PDF forms into one CSV file at path: a column per field name, a row per file. Returns the row and column counts.")
|
||||
.cmd("form.merge_data")
|
||||
.with(schema(json!({ "paths": { "type": "array", "items": { "type": "string" }, "minItems": 1 }, "path": { "type": "string" } }), &["paths", "path"])),
|
||||
t("js_run", "Run JavaScript", "Run Acrobat JavaScript in the document, as the JavaScript console does (or as push button `field`'s Mouse Up script when field is given; on a laid-out XFA form, `field` runs that button's XFA click script instead, JavaScript or FormCalc, which can add and remove rows and show or hide subforms). The form object model is available: this/getField, event, app, util, console, display, color, and the document-level scripts (except the PDF container scripts when XFA layout is active; they remain available through js_document_scripts). Field changes and resetForm are applied as one undoable step; returns the alerts (each with its message, title, icon and buttons, in order; every question is still answered OK), console output and requests (print, page, url, submit, attachment) of the script and of the field scripts its changes ran, the script's error, the errors of document-level scripts that failed (the rest still ran), and in warnings the errors of applying the changes, such as values of an XFA form that could not be written to its data.")
|
||||
t("js_run", "Run JavaScript", "Run Acrobat JavaScript in the document, as the JavaScript console does (or as push button `field`'s Mouse Up script when field is given; on a laid-out XFA form, `field` runs that button's XFA click script instead, JavaScript or FormCalc, which can add and remove rows and show or hide subforms). The form object model is available: this/getField, event, app, util, console, display, color, and the document-level scripts (except the PDF container scripts when XFA layout is active; they remain available through js_document_scripts). Field changes and resetForm are applied as one undoable step; returns the script's and subsequent field scripts' alerts (each with its message, title, icon and buttons, in order; AcroForm replies consume ordered answers; missing replies cancel input or use the alert button set's safe default), console output, requests (print, page, url, submit, attachment), error, and the errors of document-level scripts that failed (the rest still ran), plus warnings from applying the changes.")
|
||||
.cmd("tools.js_console")
|
||||
.with(schema(
|
||||
json!({ "doc": doc(), "script": { "type": "string" }, "field": { "type": "string", "description": "Run as this button's Mouse Up event." } }),
|
||||
json!({ "doc": doc(), "script": { "type": "string" }, "field": { "type": "string", "description": "Run as this button's Mouse Up event." }, "field_id": {"type":"integer","minimum":0,"maximum":281474976710655u64,"description":"Optional exact field identity from form_fields; requires matching field name and is scoped to this document."}, "answers": {"type":"array","maxItems":32,"items":{"type":["string","null"],"maxLength":4096},"description":"Ordered popup, response and alert answers; strings are limited to 4096 UTF-8 bytes at runtime; null cancels a popup or response, and selects an alert default (OK, Cancel or No, by button set)."} }),
|
||||
&["doc", "script"],
|
||||
)),
|
||||
t("js_document_scripts", "Document JavaScripts", "List the document-level JavaScripts (name and source), which define functions field scripts use.")
|
||||
|
||||
@@ -9482,6 +9482,13 @@ fn javascript_through_tools() {
|
||||
assert_eq!(r["requests"][0]["page"], 2);
|
||||
let r = ok(&mut a, "js_run", json!({ "doc": doc, "script": "JSON.stringify(this.getPageBox())" }));
|
||||
assert_eq!(r["result"], "[0,300,200,0]");
|
||||
ok(&mut a, "page_set_box", json!({ "doc":doc, "pages":[1], "box":"crop", "margins":[10,20,30,40] }));
|
||||
let cropped = ok(&mut a, "js_run", json!({ "doc":doc, "script":"JSON.stringify(getPageBox('Crop',0))" }));
|
||||
assert_eq!(cropped["result"], "[0,240,160,0]");
|
||||
ok(&mut a, "page_set_box", json!({ "doc":doc, "pages":[2], "box":"media", "margins":[0,0,40,50] }));
|
||||
let other_boxes = ok(&mut a, "js_run", json!({ "doc":doc, "script":"JSON.stringify([getPageBox('Media',0),getPageBox('Crop',1)])" }));
|
||||
assert_eq!(other_boxes["result"], "[[-10,280,190,-20],[0,250,160,0]]");
|
||||
ok(&mut a, "page_set_box", json!({ "doc":doc, "pages":[1], "box":"crop" }));
|
||||
let f = ok(&mut a, "form_fields", json!({ "doc": doc }));
|
||||
let total = f["fields"].as_array().unwrap().iter().find(|x| x["name"] == "Total").unwrap().clone();
|
||||
assert_eq!(total["value"], "3750");
|
||||
@@ -9512,6 +9519,8 @@ fn javascript_through_tools() {
|
||||
json!({ "doc": doc, "field": "CaptionButton", "script": "getField('CaptionButton').buttonSetCaption('Continue'); getField('CaptionButton').buttonGetCaption();" }),
|
||||
);
|
||||
assert_eq!(r["result"], "Continue");
|
||||
let routed = ok(&mut a, "js_run", json!({ "doc":doc, "field":"CaptionButton", "script":"event.name+'|'+event.targetName" }));
|
||||
assert_eq!(routed["result"], "Mouse Up|CaptionButton");
|
||||
assert_eq!(
|
||||
ok(&mut a, "form_fields", json!({ "doc": doc }))["fields"].as_array().unwrap().iter().find(|f| f["name"] == "CaptionButton").unwrap()["caption"],
|
||||
"Continue"
|
||||
@@ -9607,6 +9616,14 @@ fn detecting_form_fields_preserves_content_piece_comments() {
|
||||
} else {
|
||||
json!([])
|
||||
};
|
||||
let check_inventory = |a: &mut Automation, doc| {
|
||||
let mut fields = ok(a, "form_fields", json!({"doc": doc}))["fields"].clone();
|
||||
for field in fields.as_array_mut().unwrap() {
|
||||
assert!(field["field_id"].as_u64().is_some());
|
||||
field.as_object_mut().unwrap().remove("field_id");
|
||||
}
|
||||
assert_eq!(fields, inventory);
|
||||
};
|
||||
let check_text = |a: &mut Automation, doc| {
|
||||
let text = page_text(a, doc).join(" ");
|
||||
assert_eq!(text.split_whitespace().collect::<Vec<_>>(), ["FIRST", "LAST"]);
|
||||
@@ -9627,12 +9644,12 @@ fn detecting_form_fields_preserves_content_piece_comments() {
|
||||
assert_eq!(ok(&mut a, "form_detect_fields", json!({"doc": doc, "add": false}))["fields"], candidates);
|
||||
assert_eq!(ok(&mut a, "form_fields", json!({"doc": doc}))["fields"], json!([]));
|
||||
assert_eq!(ok(&mut a, "form_detect_fields", json!({"doc": doc}))["fields"], candidates);
|
||||
assert_eq!(ok(&mut a, "form_fields", json!({"doc": doc}))["fields"], inventory);
|
||||
check_inventory(&mut a, doc);
|
||||
assert_eq!(ok(&mut a, "form_detect_fields", json!({"doc": doc}))["fields"], json!([]));
|
||||
ok(&mut a, "doc_save", json!({"doc": doc, "path": "saved.pdf"}));
|
||||
let mut reopened = auto(&dir);
|
||||
let again = ok(&mut reopened, "doc_open", json!({"path": "saved.pdf"}))["doc"].as_u64().unwrap();
|
||||
assert_eq!(ok(&mut reopened, "form_fields", json!({"doc": again}))["fields"], inventory);
|
||||
check_inventory(&mut reopened, again);
|
||||
check_text(&mut reopened, again);
|
||||
check_render(&mut reopened, again);
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
|
||||
@@ -12,6 +12,7 @@ fn tool(a: &mut Automation, name: &str, args: Value) -> Value {
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Changing an existing caption regenerates the saved appearance and preserves form values.
|
||||
fn button_caption_can_be_changed_after_creation() {
|
||||
let dir = std::env::temp_dir().join(format!("pdfcraft-button-caption-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
@@ -94,3 +95,69 @@ fn button_caption_can_be_changed_after_creation() {
|
||||
assert_eq!(pixels, empty_pixels);
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
/// An unindexed setter reaches both widgets even when the first caption already matches.
|
||||
#[test]
|
||||
fn javascript_caption_updates_all_widgets_with_undo_and_save() {
|
||||
use pdfcraft_cos::{Dict, Document, Object, PdfString, SaveOptions, write_full};
|
||||
|
||||
let dir = std::env::temp_dir().join(format!("pdfcraft-js-multi-caption-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let mut session = Session::new();
|
||||
let bytes = session.create_blank(300.0, 400.0, 1).unwrap();
|
||||
let id = session.open("buttons.pdf", None, bytes, None).unwrap();
|
||||
for (name, caption, y) in [("first", "First", 300.0), ("second", "Second", 240.0)] {
|
||||
session
|
||||
.apply(
|
||||
id,
|
||||
Edit::AddField {
|
||||
page: 0,
|
||||
rect: [40.0, y, 200.0, y + 30.0],
|
||||
kind: NewField::Button { caption: caption.into() },
|
||||
name: Some(name.into()),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let mut doc = Document::open(session.save_bytes(id).unwrap()).unwrap();
|
||||
let af = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"AcroForm").unwrap();
|
||||
let form = doc.get(af);
|
||||
let fields = doc.resolve(form.as_dict().unwrap().get(b"Fields").unwrap());
|
||||
let widgets: Vec<_> = fields.as_array().unwrap().iter().map(|w| w.as_ref().unwrap()).collect();
|
||||
let mut parent = Dict::new();
|
||||
parent.set(b"FT".to_vec(), Object::name("Btn"));
|
||||
parent.set(b"Ff".to_vec(), Object::Int(1 << 16));
|
||||
parent.set(b"T".to_vec(), PdfString::text("go"));
|
||||
parent.set(b"Kids".to_vec(), Object::Array(widgets.iter().copied().map(Object::Ref).collect()));
|
||||
let parent = doc.add(parent);
|
||||
for w in widgets {
|
||||
doc.update_dict(w, |d| {
|
||||
for key in [b"FT".as_slice(), b"Ff", b"T"] {
|
||||
d.remove(key);
|
||||
}
|
||||
d.set(b"Parent".to_vec(), Object::Ref(parent));
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
doc.update_dict(af, |d| d.set(b"Fields".to_vec(), Object::Array(vec![Object::Ref(parent)]))).unwrap();
|
||||
std::fs::write(dir.join("buttons.pdf"), write_full(&doc, &SaveOptions::default()).unwrap()).unwrap();
|
||||
let mut a = Automation::new().with_root(&dir).unwrap();
|
||||
let id = tool(&mut a, "doc_open", json!({"path": "buttons.pdf"}))["doc"].as_u64().unwrap();
|
||||
let (_, _, before) = a.render_pixels(id, 1, 1.0).unwrap();
|
||||
let result = tool(&mut a, "js_run", json!({"doc": id, "script": "getField('go').buttonSetCaption('First'); getField('go').buttonGetCaption();"}));
|
||||
assert_eq!(result["error"], Value::Null);
|
||||
assert_eq!(result["result"], "First");
|
||||
let expected = json!([{"widget": 1, "caption": "First"}, {"widget": 2, "caption": "First"}]);
|
||||
assert_eq!(tool(&mut a, "form_fields", json!({"doc": id}))["fields"][0]["captions"], expected);
|
||||
let (_, _, after) = a.render_pixels(id, 1, 1.0).unwrap();
|
||||
assert_ne!(before, after, "the second widget must visibly change");
|
||||
tool(&mut a, "edit_undo", json!({"doc": id}));
|
||||
assert_eq!(a.render_pixels(id, 1, 1.0).unwrap().2, before);
|
||||
tool(&mut a, "edit_redo", json!({"doc": id}));
|
||||
assert_eq!(a.render_pixels(id, 1, 1.0).unwrap().2, after);
|
||||
tool(&mut a, "doc_save", json!({"doc": id, "path": "saved.pdf"}));
|
||||
let reopened = tool(&mut a, "doc_open", json!({"path": "saved.pdf"}))["doc"].as_u64().unwrap();
|
||||
assert_eq!(tool(&mut a, "form_fields", json!({"doc": reopened}))["fields"][0]["captions"], expected);
|
||||
assert_eq!(a.render_pixels(reopened, 1, 1.0).unwrap().2, after);
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
@@ -21,4 +21,5 @@ mod redact_after_stray_q_form;
|
||||
mod redact_text_after_q;
|
||||
mod render_warnings;
|
||||
mod text_copy;
|
||||
mod timesheet_compat;
|
||||
mod unreadable_objects;
|
||||
@@ -0,0 +1,243 @@
|
||||
//! Original synthetic workflow: no private timesheet, employee, PIN or signature assets.
|
||||
use pdfcraft_automation::{Automation, Content};
|
||||
use pdfcraft_cos::{Dict, Document, Object, SaveOptions, Stream, write_full};
|
||||
use pdfcraft_engine::{Edit, NewField, Session};
|
||||
use serde_json::{Value, json};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Dispatch a synthetic automation step and fail with the returned tool error on refusal.
|
||||
fn call(a: &mut Automation, name: &str, args: Value) -> Value {
|
||||
match a.call(name, &args).unwrap().remove(0) {
|
||||
Content::Json(v) => v,
|
||||
_ => panic!("expected JSON"),
|
||||
}
|
||||
}
|
||||
/// Construct a synthetic form and transparent Form XObject without private files or font assets.
|
||||
fn fixture() -> Vec<u8> {
|
||||
let mut session = Session::new();
|
||||
let bytes = session.create_blank(600.0, 800.0, 1).unwrap();
|
||||
let id = session.open("synthetic.pdf", None, bytes, None).unwrap();
|
||||
for (i, name) in ["input", "double", "seen", "choice", "go", "source", "target"].into_iter().enumerate() {
|
||||
session
|
||||
.apply(
|
||||
id,
|
||||
Edit::AddField {
|
||||
page: 0,
|
||||
rect: [40.0, 700.0 - i as f64 * 45.0, 200.0, 730.0 - i as f64 * 45.0],
|
||||
kind: if i >= 4 { NewField::Button { caption: name.into() } } else { NewField::Text { multiline: false } },
|
||||
name: Some(name.into()),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let mut doc = Document::open(session.save_bytes(id).unwrap()).unwrap();
|
||||
let source = session.get(id).unwrap().form.iter().find(|f| f.name == "source").unwrap().obj;
|
||||
let mut gs = Dict::new();
|
||||
gs.set(b"ca".to_vec(), Object::Real(0.5));
|
||||
let mut states = Dict::new();
|
||||
states.set(b"Half".to_vec(), Object::Dict(gs));
|
||||
let mut resources = Dict::new();
|
||||
resources.set(b"ExtGState".to_vec(), Object::Dict(states));
|
||||
let mut dict = Dict::new();
|
||||
dict.set(b"Type".to_vec(), Object::name("XObject"));
|
||||
dict.set(b"Subtype".to_vec(), Object::name("Form"));
|
||||
dict.set(b"BBox".to_vec(), Object::Array([0, 0, 60, 30].map(Object::Int).to_vec()));
|
||||
dict.set(b"Resources".to_vec(), Object::Dict(resources));
|
||||
let icon = doc.add(Object::Stream(Stream::from_raw(dict, b"q /Half gs 1 0 0 rg 0 0 60 30 re f Q".to_vec())));
|
||||
let mut mk = Dict::new();
|
||||
mk.set(b"I".to_vec(), Object::Ref(icon));
|
||||
mk.set(b"TP".to_vec(), Object::Int(1));
|
||||
doc.update_dict(source, |d| d.set(b"MK".to_vec(), Object::Dict(mk))).unwrap();
|
||||
write_full(&doc, &SaveOptions::default()).unwrap()
|
||||
}
|
||||
const HELPERS: &str = r#"
|
||||
var initialized=1, buffer='', savedIcon=null;
|
||||
function openPad(){var b=getPageBox('Crop',0);var f=getField('go');f.rect=[b[0]+40,b[1]-400,b[0]+200,b[1]-450];f.textSize=16;f.fillColor=['RGB',0.9,0.8,0.7];f.strokeColor=['G',0.4];f.lineWidth=2;f.borderStyle='dashed';f.textColor=color.blue;f.userName='Synthetic keypad';f.display=display.noPrint;}
|
||||
function digit(d){buffer+=d;getField('go').buttonSetCaption(buffer);if(buffer==='1234'){getField('target').buttonSetIcon(savedIcon);getField('go').display=display.hidden;}}
|
||||
function selectTime(){var v=app.popUpMenuEx({cName:'Unavailable',cReturn:'bad',bEnabled:false},{cName:'Times',oSubMenu:[{cName:'Seven',cReturn:'07:00',bMarked:true},{cName:'Manual',cReturn:'manual'}]});if(v==='manual')v=app.response({cQuestion:'Time',cDefault:'07:00'});if(v!==null)getField('choice').value=v;}
|
||||
"#;
|
||||
|
||||
#[test]
|
||||
/// Exercise retained keypad state, geometry, synchronous calculation and transparent icons through save/reopen and Undo/Redo.
|
||||
fn timesheet_keypad_geometry_icons_calculation_and_persistence() {
|
||||
let dir = std::env::temp_dir().join(format!("pdfcraft-timesheet-compat-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("synthetic.pdf"), fixture()).unwrap();
|
||||
let mut a = Automation::new().with_root(&dir).unwrap();
|
||||
let id = call(&mut a, "doc_open", json!({"path":"synthetic.pdf"}))["doc"].as_u64().unwrap();
|
||||
call(&mut a, "form_set_script", json!({"doc":id,"field":"double","event":"calculate","script":"event.value=Number(getField('input').value)*2;"}));
|
||||
call(&mut a, "js_set_document_script", json!({"doc":id,"name":"helpers","script":HELPERS}));
|
||||
let out = call(
|
||||
&mut a,
|
||||
"js_run",
|
||||
json!({"doc":id,"script":"getField('input').value=3;calculateNow();getField('seen').value=getField('double').value;getField('seen').value;"}),
|
||||
);
|
||||
assert_eq!(out["error"], Value::Null);
|
||||
assert_eq!(out["result"], "6");
|
||||
let out = call(&mut a, "js_run", json!({"doc":id,"script":"openPad();savedIcon=getField('source').buttonGetIcon();initialized;"}));
|
||||
assert_eq!(out["error"], Value::Null);
|
||||
assert_eq!(out["result"], "1");
|
||||
let fields = call(&mut a, "form_fields", json!({"doc":id}));
|
||||
let go = fields["fields"].as_array().unwrap().iter().find(|f| f["name"] == "go").unwrap();
|
||||
assert_eq!(go["tooltip"], "Synthetic keypad");
|
||||
let model = a.session().get(pdfcraft_engine::DocId(id)).unwrap().form.iter().find(|f| f.name == "go").unwrap();
|
||||
assert_eq!(model.widgets[0].rect, [40.0, 350.0, 200.0, 400.0]);
|
||||
assert!(model.da.contains("16 Tf"), "{}", model.da);
|
||||
for (key, expected) in [("1", "1"), ("2", "12"), ("3", "123")] {
|
||||
let out = call(&mut a, "js_run", json!({"doc":id,"field":"go","script":format!("digit('{key}');buffer;")}));
|
||||
assert_eq!(out["error"], Value::Null);
|
||||
assert_eq!(out["result"], expected);
|
||||
}
|
||||
let (_, _, before) = a.render_pixels(id, 1, 1.0).unwrap();
|
||||
let out = call(&mut a, "js_run", json!({"doc":id,"field":"go","script":"digit('4');buffer;"}));
|
||||
assert_eq!(out["error"], Value::Null);
|
||||
assert_eq!(out["result"], "1234");
|
||||
let (_, _, after) = a.render_pixels(id, 1, 1.0).unwrap();
|
||||
assert_ne!(before, after);
|
||||
let blended = after
|
||||
.as_chunks::<4>()
|
||||
.0
|
||||
.iter()
|
||||
.filter(|p| p[0] > p[1].saturating_add(50) && (50..180).contains(&p[1]) && (p[1] as i16 - p[2] as i16).abs() <= 2)
|
||||
.count();
|
||||
assert!(blended > 500, "half-opacity red icon must blend across its area with the button's grey background, got {blended} pixels");
|
||||
call(&mut a, "edit_undo", json!({"doc":id}));
|
||||
assert_eq!(a.render_pixels(id, 1, 1.0).unwrap().2, before);
|
||||
call(&mut a, "edit_redo", json!({"doc":id}));
|
||||
assert_eq!(a.render_pixels(id, 1, 1.0).unwrap().2, after);
|
||||
let rejected = call(&mut a, "js_run", json!({"doc":id,"script":"getField('target').buttonSetIcon({});"}));
|
||||
assert!(rejected["error"].as_str().unwrap().contains("icon"));
|
||||
call(&mut a, "doc_save", json!({"doc":id,"path":"saved.pdf"}));
|
||||
let saved = Document::open(Arc::new(std::fs::read(dir.join("saved.pdf")).unwrap())).unwrap();
|
||||
let root = saved.get(saved.root().unwrap());
|
||||
let af = saved.resolve(root.as_dict().unwrap().get(b"AcroForm").unwrap());
|
||||
let fields = saved.resolve(af.as_dict().unwrap().get(b"Fields").unwrap());
|
||||
let target = fields
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|f| saved.resolve(f))
|
||||
.find(|f| f.as_dict().unwrap().get(b"T").and_then(Object::as_string).is_some_and(|s| s.to_text() == "target"))
|
||||
.unwrap();
|
||||
let mk = saved.resolve(target.as_dict().unwrap().get(b"MK").unwrap());
|
||||
let icon = saved.resolve(mk.as_dict().unwrap().get(b"I").unwrap());
|
||||
let Object::Stream(icon) = &*icon else { panic!("icon stream") };
|
||||
assert!(String::from_utf8_lossy(&icon.decoded().unwrap()).contains("/Half gs"));
|
||||
let reopened = call(&mut a, "doc_open", json!({"path":"saved.pdf"}))["doc"].as_u64().unwrap();
|
||||
assert_eq!(a.render_pixels(reopened, 1, 1.0).unwrap().2, after);
|
||||
let reset = call(&mut a, "js_run", json!({"doc":reopened,"script":"buffer;"}));
|
||||
assert_eq!(reset["result"], "");
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Resume one invocation across popup/text prompts and reject disabled or invented choices.
|
||||
fn popup_and_manual_response_resume_the_same_script_and_validate_choices() {
|
||||
let dir = std::env::temp_dir().join(format!("pdfcraft-timesheet-menu-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("synthetic.pdf"), fixture()).unwrap();
|
||||
let mut a = Automation::new().with_root(&dir).unwrap();
|
||||
let id = call(&mut a, "doc_open", json!({"path":"synthetic.pdf"}))["doc"].as_u64().unwrap();
|
||||
call(&mut a, "js_set_document_script", json!({"doc":id,"name":"helpers","script":HELPERS}));
|
||||
let out = call(&mut a, "js_run", json!({"doc":id,"script":"selectTime();getField('choice').value;","answers":["manual","07:45"]}));
|
||||
assert_eq!(out["error"], Value::Null);
|
||||
assert_eq!(out["result"], "07:45");
|
||||
let cancel = call(&mut a, "js_run", json!({"doc":id,"script":"selectTime();getField('choice').value;","answers":[null]}));
|
||||
assert_eq!(cancel["result"], "07:45");
|
||||
let invalid = call(&mut a, "js_run", json!({"doc":id,"script":"selectTime();","answers":["bad"]}));
|
||||
assert!(invalid["error"].as_str().unwrap().contains("invalid popup"));
|
||||
let alert = call(&mut a, "js_run", json!({"doc":id,"script":"app.alert({cMsg:'Continue?',nType:2});","answers":["4"]}));
|
||||
assert_eq!(alert["result"], "4");
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
/// The advertised schema must bound prompt strings before clients dispatch them.
|
||||
#[test]
|
||||
fn prompt_answer_schema_advertises_the_length_budget() {
|
||||
let tool = pdfcraft_automation::tools().into_iter().find(|t| t.name == "js_run").unwrap();
|
||||
assert_eq!(tool.input_schema["properties"]["answers"]["items"]["maxLength"], 4096);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn js_run_exact_field_ids_are_discoverable_and_strictly_validated() {
|
||||
let dir = std::env::temp_dir().join(format!("pdfcraft-field-identity-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("synthetic.pdf"), fixture()).unwrap();
|
||||
let mut a = Automation::new().with_root(&dir).unwrap();
|
||||
let id = call(&mut a, "doc_open", json!({"path":"synthetic.pdf"}))["doc"].as_u64().unwrap();
|
||||
let fields = call(&mut a, "form_fields", json!({"doc":id}));
|
||||
let entries = fields["fields"].as_array().unwrap();
|
||||
let go = entries.iter().find(|f| f["name"] == "go").unwrap()["field_id"].as_u64().unwrap();
|
||||
let other = entries.iter().find(|f| f["name"] == "input").unwrap()["field_id"].as_u64().unwrap();
|
||||
let out = call(
|
||||
&mut a,
|
||||
"js_run",
|
||||
json!({"doc":id,"field":"go","field_id":go,"script":"event.target.buttonSetCaption(app.response({cQuestion:'Caption'}));event.target.name;","answers":["exact"]}),
|
||||
);
|
||||
assert_eq!(out["error"], Value::Null);
|
||||
assert_eq!(out["result"], "go");
|
||||
for invalid in [json!(-1), json!(1.5), json!("bad"), json!(281474976710656u64), json!(other)] {
|
||||
assert!(a.call("js_run", &json!({"doc":id,"field":"go","field_id":invalid,"script":"getField('input').value='bad';"})).is_err());
|
||||
}
|
||||
assert!(a.call("js_run", &json!({"doc":id,"field_id":go,"script":"true;"})).is_err());
|
||||
let schema = pdfcraft_automation::tools().into_iter().find(|t| t.name == "js_run").unwrap();
|
||||
assert_eq!(schema.input_schema["properties"]["field_id"]["maximum"], 281474976710655u64);
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
/// The headless image picker counterpart accepts exact duplicate identities and keeps legacy lookup.
|
||||
#[test]
|
||||
fn exact_image_field_tool_targets_duplicates_and_rejects_invalid_ids() {
|
||||
let dir = std::env::temp_dir().join(format!("pdfcraft-exact-image-tool-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let mut session = Session::new();
|
||||
let bytes = session.create_blank(300.0, 300.0, 1).unwrap();
|
||||
let id = session.open("synthetic.pdf", None, bytes, None).unwrap();
|
||||
for (name, x) in [("first", 20.0), ("second", 160.0)] {
|
||||
session.apply(id, Edit::AddField { page: 0, rect: [x, 20.0, x + 100.0, 80.0], kind: NewField::Image, name: Some(name.into()) }).unwrap();
|
||||
}
|
||||
let mut cos = Document::open(session.save_bytes(id).unwrap()).unwrap();
|
||||
for field in session.get(id).unwrap().form.iter() {
|
||||
cos.update_dict(field.obj, |d| d.set(b"T".to_vec(), pdfcraft_cos::PdfString::text("same"))).unwrap();
|
||||
}
|
||||
std::fs::write(dir.join("synthetic.pdf"), write_full(&cos, &Default::default()).unwrap()).unwrap();
|
||||
std::fs::write(dir.join("picture.gif"), pdfcraft_xfa::fixtures::TINY_GIF).unwrap();
|
||||
let mut a = Automation::new().with_root(&dir).unwrap();
|
||||
let id = call(&mut a, "doc_open", json!({"path":"synthetic.pdf"}))["doc"].as_u64().unwrap();
|
||||
let fields = call(&mut a, "form_fields", json!({"doc":id}));
|
||||
let second = fields["fields"][1]["field_id"].as_u64().unwrap();
|
||||
call(&mut a, "form_set_image", json!({"doc":id,"field":"same","field_id":second,"path":"picture.gif"}));
|
||||
let icons = |a: &mut Automation| {
|
||||
call(a, "doc_save", json!({"doc":id,"path":"saved.pdf"}));
|
||||
let cos = Document::open(Arc::new(std::fs::read(dir.join("saved.pdf")).unwrap())).unwrap();
|
||||
let root = cos.get(cos.root().unwrap());
|
||||
let form = cos.resolve(root.as_dict().unwrap().get(b"AcroForm").unwrap());
|
||||
form.as_dict()
|
||||
.unwrap()
|
||||
.get(b"Fields")
|
||||
.unwrap()
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|r| {
|
||||
cos.resolve(r)
|
||||
.as_dict()
|
||||
.and_then(|d| d.get(b"MK").map(|v| cos.resolve(v)))
|
||||
.is_some_and(|m| m.as_dict().is_some_and(|d| d.contains(b"I")))
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(icons(&mut a), [false, true]);
|
||||
for invalid in [json!(-1), json!(1.5), json!("bad"), json!(281474976710656u64)] {
|
||||
assert!(a.call("form_set_image", &json!({"doc":id,"field":"same","field_id":invalid,"path":"picture.gif"})).is_err());
|
||||
}
|
||||
assert!(a.call("form_set_image", &json!({"doc":id,"field":"missing","field_id":second,"path":"picture.gif"})).is_err());
|
||||
assert_eq!(icons(&mut a), [false, true]);
|
||||
call(&mut a, "edit_undo", json!({"doc":id}));
|
||||
assert_eq!(icons(&mut a), [false, false]);
|
||||
call(&mut a, "form_set_image", json!({"doc":id,"field":"same","path":"picture.gif"}));
|
||||
assert_eq!(icons(&mut a), [true, false]);
|
||||
let tool = pdfcraft_automation::tools().into_iter().find(|t| t.name == "form_set_image").unwrap();
|
||||
assert_eq!(tool.input_schema["properties"]["field_id"]["maximum"], 281474976710655u64);
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
@@ -52,6 +52,9 @@ pub struct Document {
|
||||
pub(crate) config: RenderConfig,
|
||||
/// What field scripts printed or asked for (see [`Session::take_js_output`]).
|
||||
pub(crate) js_output: js::JsOutput,
|
||||
pub(crate) js_runtime: js::Runtime,
|
||||
pub(crate) js_zoom: f64,
|
||||
pub(crate) js_page: usize,
|
||||
/// Dynamic XFA forms: what laying the template out produced (pages and fields are
|
||||
/// PdfCraft's; Adobe's viewers draw the form from the XFA packets themselves).
|
||||
pub xfa: Option<XfaLayout>,
|
||||
|
||||
@@ -338,6 +338,12 @@ pub enum Edit {
|
||||
name: String,
|
||||
image: Arc<Vec<u8>>,
|
||||
},
|
||||
/// Set the picture of one exact push-button field; legacy name-only calls remain available.
|
||||
SetFieldImageTarget {
|
||||
name: String,
|
||||
object: pdfcraft_cos::ObjRef,
|
||||
image: Arc<Vec<u8>>,
|
||||
},
|
||||
/// Duplicate a field onto other pages (same field, shared value).
|
||||
DuplicateField {
|
||||
name: String,
|
||||
|
||||
@@ -256,7 +256,7 @@ impl Edit {
|
||||
| Edit::BorderShapeAnnotation { .. }
|
||||
| Edit::SetAnnotationInfo { .. } => Label::of("Change comment properties"),
|
||||
Edit::SetFieldValue { name, .. } => Label::with("Fill in {name}", name.as_str()),
|
||||
Edit::SetFieldImage { name, .. } => Label::with("Set the image of {name}", name.as_str()),
|
||||
Edit::SetFieldImage { name, .. } | Edit::SetFieldImageTarget { name, .. } => Label::with("Set the image of {name}", name.as_str()),
|
||||
Edit::ResetForm { .. } => Label::of("Clear form"),
|
||||
Edit::AddField { .. } => Label::of("Add field"),
|
||||
Edit::SetFieldProps { .. } => Label::of("Change field properties"),
|
||||
|
||||
@@ -78,7 +78,7 @@ pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Re
|
||||
Err(EditError::NotPermitted("comments"))
|
||||
}
|
||||
}
|
||||
Edit::SetFieldValue { .. } | Edit::ResetForm { .. } | Edit::SetFieldImage { .. } | Edit::ApplyScriptChanges { .. } | Edit::XfaEvent { .. } => {
|
||||
Edit::SetFieldValue { .. } | Edit::ResetForm { .. } | Edit::SetFieldImage { .. } | Edit::SetFieldImageTarget { .. } | Edit::ApplyScriptChanges { .. } | Edit::XfaEvent { .. } => {
|
||||
if p.fill_forms() {
|
||||
Ok(())
|
||||
} else {
|
||||
@@ -221,6 +221,7 @@ fn skipped_text(s: &str) -> String {
|
||||
pub(crate) struct EditCtx {
|
||||
/// Field scripts run (`None`: JavaScript is off) and what they produced.
|
||||
pub(crate) js: Option<js::JsRunner>,
|
||||
defer_reset_callbacks: bool,
|
||||
/// A laid-out XFA form's template (`None`: not an XFA form, or JavaScript is off) and what
|
||||
/// its scripts produced.
|
||||
pub(crate) xfa: Option<Arc<pdfcraft_xfa::model::Template>>,
|
||||
@@ -266,7 +267,7 @@ impl EditCtx {
|
||||
/// The field highlight (`app.runtimeHighlight`) the edit's next script reads: the field
|
||||
/// scripts' runner holds it while there is one (its scripts change it there), else the edit.
|
||||
pub(crate) fn highlight(&self) -> bool {
|
||||
self.js.as_ref().map_or(self.highlight, |js| js.doc.highlight)
|
||||
self.js.as_ref().map_or(self.highlight, |js| js.doc.runtime_highlight)
|
||||
}
|
||||
|
||||
/// A script changed the field highlight: both script runtimes of the edit read the new one,
|
||||
@@ -274,7 +275,7 @@ impl EditCtx {
|
||||
pub(crate) fn set_highlight(&mut self, on: bool) {
|
||||
self.highlight = on;
|
||||
if let Some(js) = self.js.as_mut() {
|
||||
js.doc.highlight = on;
|
||||
js.doc.runtime_highlight = on;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -292,6 +293,7 @@ impl EditCtx {
|
||||
}
|
||||
Self {
|
||||
js: None,
|
||||
defer_reset_callbacks: false,
|
||||
xfa: None,
|
||||
xfa_out: Default::default(),
|
||||
xfa_datasets: None,
|
||||
@@ -508,11 +510,11 @@ fn run_edit_page_image(doc: &mut pdfcraft_cos::Document, page: usize, index: usi
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// After an edit that changes fields, their scripts or the calculation order: the edit's
|
||||
/// script runner reads the document as it now is, and builds its `calculateNow()` list again.
|
||||
fn invalidate_calculations(cx: &mut EditCtx, doc: &pdfcraft_cos::Document) {
|
||||
/// Forget cached calculation order and field metadata after changing the form structure.
|
||||
fn invalidate_form_script_snapshot(cx: &mut EditCtx) {
|
||||
if let Some(js) = cx.js.as_mut() {
|
||||
js.invalidate_calculations(doc);
|
||||
js.invalidate_calculations();
|
||||
js.invalidate_field_snapshots();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -544,7 +546,7 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E
|
||||
let title = std::path::Path::new(name).file_stem().map_or_else(|| name.clone(), |s| s.to_string_lossy().into_owned());
|
||||
let outcome = pdfcraft_organize::insert_file_pages(doc, &src, &title, &pages, *at, *field_conflicts)?;
|
||||
cx.renamed_fields = outcome.renamed_fields;
|
||||
invalidate_calculations(cx, doc);
|
||||
invalidate_form_script_snapshot(cx);
|
||||
}
|
||||
Edit::AddBookmark { parent, index, title, page, left, top } => {
|
||||
pdfcraft_organize::add_bookmark_at(doc, parent, *index, title, *page, *left, *top)?;
|
||||
@@ -731,7 +733,10 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E
|
||||
set(doc, typed.as_ref().unwrap_or(value))?;
|
||||
}
|
||||
Edit::ApplyScriptChanges { changes } => match cx.js.as_mut() {
|
||||
Some(js) => pdfcraft_forms::apply_script_changes(doc, changes, js)?,
|
||||
Some(js) => {
|
||||
js.invalidate_field_snapshots();
|
||||
pdfcraft_forms::apply_script_changes(doc, changes, js)?;
|
||||
}
|
||||
None => pdfcraft_forms::apply_script_changes(doc, changes, &mut pdfcraft_forms::NoScripts)?,
|
||||
},
|
||||
Edit::XfaEvent { som, activity } => {
|
||||
@@ -757,7 +762,14 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E
|
||||
cx.set_highlight(on);
|
||||
cx.xfa_unchanged = ran.map_err(EditError::Invalid)?;
|
||||
}
|
||||
Edit::SetFieldImage { name, image } => {
|
||||
Edit::SetFieldImage { name, image } | Edit::SetFieldImageTarget { name, image, .. } => {
|
||||
let object = match edit {
|
||||
Edit::SetFieldImageTarget { object, .. } => Some(*object),
|
||||
_ => None,
|
||||
};
|
||||
if object.is_some_and(|object| !pdfcraft_forms::fields(doc).iter().any(|f| f.name == *name && f.obj == object)) {
|
||||
return Err(EditError::Invalid("the selected image field is no longer available".into()));
|
||||
}
|
||||
let (img, _) = embed_image(doc, name, image)?;
|
||||
let px = match &*doc.get(img) {
|
||||
pdfcraft_cos::Object::Stream(s) => (s.dict.int(b"Width").unwrap_or(1) as u32, s.dict.int(b"Height").unwrap_or(1) as u32),
|
||||
@@ -774,7 +786,7 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E
|
||||
let Some(content_type) = image_content_type(image) else {
|
||||
return Err(EditError::Invalid(format!("{name}: an XFA form keeps a PNG, JPEG, GIF, BMP or TIFF picture; choose one of those")));
|
||||
};
|
||||
pdfcraft_forms::set_button_icon(doc, name, img, px)?;
|
||||
pdfcraft_forms::set_button_icon_target(doc, name, object, img, px)?;
|
||||
let som = pdfcraft_xfa::fields_by_som(doc).into_iter().find(|(_, n)| n == name).map(|(s, _)| s).unwrap_or_else(|| name.clone());
|
||||
let path = match cx.xfa.as_deref() {
|
||||
Some(tpl) => pdfcraft_xfa::data_path(tpl, &som),
|
||||
@@ -789,40 +801,46 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E
|
||||
cx.xfa_out.errors.extend(r.warnings);
|
||||
xfa_after_data_change(doc, cx, false)?;
|
||||
} else {
|
||||
pdfcraft_forms::set_button_icon(doc, name, img, px)?;
|
||||
pdfcraft_forms::set_button_icon_target(doc, name, object, img, px)?;
|
||||
}
|
||||
}
|
||||
Edit::ResetForm { names } => {
|
||||
pdfcraft_forms::reset(doc, names.as_deref())?;
|
||||
if let Some(js) = cx.js.as_mut() {
|
||||
pdfcraft_forms::reset_with_formatting(doc, names.as_deref(), js, !cx.defer_reset_callbacks)?;
|
||||
} else {
|
||||
pdfcraft_forms::reset(doc, names.as_deref())?;
|
||||
}
|
||||
}
|
||||
Edit::AddField { page, rect, kind, name } => {
|
||||
pdfcraft_forms::add_field(doc, *page, *rect, kind, name.as_deref())?;
|
||||
invalidate_calculations(cx, doc);
|
||||
invalidate_form_script_snapshot(cx);
|
||||
}
|
||||
Edit::SetFieldProps { name, props } => {
|
||||
pdfcraft_forms::set_props(doc, name, props)?;
|
||||
invalidate_calculations(cx, doc);
|
||||
invalidate_form_script_snapshot(cx);
|
||||
}
|
||||
Edit::DuplicateField { name, pages } => {
|
||||
if pdfcraft_forms::duplicate_field(doc, name, pages)? == 0 {
|
||||
return Err(EditError::Form(pdfcraft_forms::FormError::Invalid(format!("{name} is already on those pages"))));
|
||||
}
|
||||
invalidate_calculations(cx, doc);
|
||||
invalidate_form_script_snapshot(cx);
|
||||
}
|
||||
Edit::DeleteField { name } => {
|
||||
pdfcraft_forms::delete_field(doc, name)?;
|
||||
invalidate_calculations(cx, doc);
|
||||
invalidate_form_script_snapshot(cx);
|
||||
}
|
||||
Edit::DeleteWidget { name, widget } => {
|
||||
pdfcraft_forms::delete_widget(doc, name, *widget)?;
|
||||
invalidate_calculations(cx, doc);
|
||||
invalidate_form_script_snapshot(cx);
|
||||
}
|
||||
Edit::SetTabOrder { pages, order } => pdfcraft_forms::set_tab_order(doc, pages, *order)?,
|
||||
Edit::MoveInTabOrder { name, earlier } => pdfcraft_forms::move_in_tab_order(doc, name, *earlier)?,
|
||||
// As after a script edit: the values the new order gives show at once.
|
||||
Edit::SetCalculationOrder { names } => {
|
||||
pdfcraft_forms::set_calculation_order(doc, names)?;
|
||||
invalidate_calculations(cx, doc);
|
||||
if let Some(js) = cx.js.as_mut() {
|
||||
js.invalidate_calculations();
|
||||
}
|
||||
match cx.js.as_mut() {
|
||||
Some(js) => pdfcraft_forms::recalculate_with(doc, js)?,
|
||||
None => pdfcraft_forms::recalculate(doc)?,
|
||||
@@ -850,7 +868,9 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E
|
||||
}
|
||||
Edit::SetFieldScript { name, event, script } => {
|
||||
pdfcraft_forms::set_field_script(doc, name, event, script.as_deref())?;
|
||||
invalidate_calculations(cx, doc);
|
||||
if let Some(js) = cx.js.as_mut() {
|
||||
js.invalidate_calculations();
|
||||
}
|
||||
match cx.js.as_mut() {
|
||||
Some(js) => pdfcraft_forms::recalculate_with(doc, js)?,
|
||||
None => pdfcraft_forms::recalculate(doc)?,
|
||||
@@ -1090,15 +1110,40 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E
|
||||
}
|
||||
doc.remove_encryption();
|
||||
}
|
||||
Edit::Batch { edits, .. } => {
|
||||
// A form-script event runs on its own: in a batch, its scripts and those the batch's
|
||||
// value edits run afterwards (change, exit) would interleave, an exit running twice.
|
||||
if edits.iter().any(|e| matches!(e, Edit::XfaEvent { .. })) {
|
||||
return Err(EditError::Invalid("a form script event can't be part of a batch of edits".into()));
|
||||
}
|
||||
for e in edits {
|
||||
run_edit(doc, e, cx)?;
|
||||
Edit::Batch { edits, .. } => run_batch(doc, edits, cx)?,
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Apply an edit batch while keeping reset deferral scoped to adjacent script writeback.
|
||||
fn run_batch(doc: &mut pdfcraft_cos::Document, edits: &[Edit], cx: &mut EditCtx) -> Result<(), EditError> {
|
||||
// A form-script event runs on its own: in a batch, its scripts and those the batch's
|
||||
// value edits run afterwards (change, exit) would interleave, an exit running twice.
|
||||
if edits.iter().any(|e| matches!(e, Edit::XfaEvent { .. })) {
|
||||
return Err(EditError::Invalid("a form script event can't be part of a batch of edits".into()));
|
||||
}
|
||||
// Only contiguous resets immediately preceding script value writeback can
|
||||
// defer callbacks. Other edits may read calculated values during validation.
|
||||
let mut script_writeback = vec![false; edits.len()];
|
||||
let mut writes_values = false;
|
||||
for (index, edit) in edits.iter().enumerate().rev() {
|
||||
match edit {
|
||||
Edit::ResetForm { .. } => script_writeback[index] = writes_values,
|
||||
Edit::ApplyScriptChanges { changes } => writes_values = changes.iter().any(|change| change.value.is_some()),
|
||||
_ => writes_values = false,
|
||||
}
|
||||
}
|
||||
let deferred = cx.defer_reset_callbacks;
|
||||
for (index, e) in edits.iter().enumerate() {
|
||||
cx.defer_reset_callbacks = script_writeback[index];
|
||||
let result = run_edit(doc, e, cx);
|
||||
cx.defer_reset_callbacks = deferred;
|
||||
result?;
|
||||
if let Some(js) = cx.js.as_mut() {
|
||||
if js.refused.is_some() {
|
||||
break;
|
||||
}
|
||||
js.refresh_after_batch_edit(doc, e);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
|
||||
@@ -38,7 +38,11 @@ pub(crate) enum Scope {
|
||||
/// Edits during which field scripts run.
|
||||
pub(crate) fn uses_scripts(edit: &Edit) -> bool {
|
||||
match edit {
|
||||
Edit::SetFieldValue { .. } | Edit::ApplyScriptChanges { .. } | Edit::SetFieldScript { .. } | Edit::SetCalculationOrder { .. } => true,
|
||||
Edit::SetFieldValue { .. }
|
||||
| Edit::ResetForm { .. }
|
||||
| Edit::ApplyScriptChanges { .. }
|
||||
| Edit::SetFieldScript { .. }
|
||||
| Edit::SetCalculationOrder { .. } => true,
|
||||
Edit::Batch { edits, .. } => edits.iter().any(uses_scripts),
|
||||
_ => false,
|
||||
}
|
||||
@@ -77,7 +81,11 @@ pub(crate) fn scope_of(edit: &Edit) -> Scope {
|
||||
| Edit::BorderShapeAnnotation { .. }
|
||||
| Edit::SetAnnotationInfo { .. } => Scope::Comments,
|
||||
Edit::AddLink { .. } | Edit::SetLink { .. } | Edit::DeleteLink { .. } | Edit::RemoveLinks { .. } | Edit::AddLinks { .. } => Scope::Links,
|
||||
Edit::SetFieldValue { .. } | Edit::ResetForm { .. } | Edit::SetFieldImage { .. } | Edit::ApplyScriptChanges { .. } => Scope::Form,
|
||||
Edit::SetFieldValue { .. }
|
||||
| Edit::ResetForm { .. }
|
||||
| Edit::SetFieldImage { .. }
|
||||
| Edit::SetFieldImageTarget { .. }
|
||||
| Edit::ApplyScriptChanges { .. } => Scope::Form,
|
||||
// Page content, resources and added content/marks only: the page tree, annotations,
|
||||
// fields, links and document metadata are untouched (see `refresh_content`).
|
||||
Edit::MoveObjects { .. }
|
||||
|
||||
+1033
-208
File diff suppressed because it is too large.
Load diff
@@ -101,6 +101,35 @@ impl Session {
|
||||
Self::draw_captions(&mut document, self.captions.clone());
|
||||
}
|
||||
self.docs.push(document);
|
||||
if !self.js_off && self.get(id).is_some_and(|d| d.info.xfa.is_none() && !d.document_scripts().is_empty()) {
|
||||
match self.run_opening_javascript(id) {
|
||||
Ok(mut out) => {
|
||||
out.requests.retain(|r| {
|
||||
matches!(
|
||||
r,
|
||||
js::Request::Highlight(_)
|
||||
| js::Request::Zoom(_)
|
||||
| js::Request::Focus(_)
|
||||
| js::Request::FocusField { .. }
|
||||
| js::Request::GoToPage(_)
|
||||
)
|
||||
});
|
||||
if let Some(doc) = self.docs.iter_mut().find(|d| d.id == id) {
|
||||
doc.js_output.requests.extend(out.requests);
|
||||
doc.js_output.alerts.extend(out.alerts);
|
||||
doc.js_output.console.extend(out.console);
|
||||
doc.js_output.errors.extend(out.error);
|
||||
doc.js_output.doc_errors.extend(out.doc_errors.iter().cloned());
|
||||
doc.js_output.errors.extend(out.doc_errors);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
if let Some(doc) = self.docs.iter_mut().find(|d| d.id == id) {
|
||||
doc.js_output.errors.push(e.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
id
|
||||
}
|
||||
|
||||
@@ -381,6 +410,9 @@ impl Session {
|
||||
editor,
|
||||
config,
|
||||
js_output: Default::default(),
|
||||
js_runtime: Default::default(),
|
||||
js_zoom: 100.0,
|
||||
js_page: 0,
|
||||
xfa,
|
||||
xfa_template: None,
|
||||
xfa_scripts_off: false,
|
||||
|
||||
@@ -8,6 +8,34 @@ use super::{
|
||||
};
|
||||
use crate::{EditNotice, MarkKind, print_preview_layers};
|
||||
|
||||
/// Add scan cover and ink to edits of invisible OCR text, returning no edit for an unchanged draft.
|
||||
fn prepare_ocr_text_edit(editor: &mut crate::history::Editor, info: &pdfcraft_render::DocInfo, edit: Edit) -> Option<Edit> {
|
||||
// Rewriting an OCR word (invisible text over a scan) also hides the scanned word: cover it
|
||||
// in the paper's colour and draw the new text in the ink's.
|
||||
Some(match edit {
|
||||
Edit::EditTextBlock { page, block, text, mut style } => {
|
||||
let plain = style == pdfcraft_edit::BlockStyle::default();
|
||||
// Only invisible text is sampled; the blocks are usually cached from opening it.
|
||||
if style.cover.is_none()
|
||||
&& let Some(info) = info.pages.get(page)
|
||||
&& let Some(b) = editor.text_blocks(page).ok().and_then(|b| b.get(block).filter(|b| b.invisible).cloned())
|
||||
&& let Some((paper, ink)) = guard(|| scan_colours::sample(&editor.cos, info, page, &b)).ok().flatten()
|
||||
{
|
||||
style.cover = Some(paper);
|
||||
style.color = style.color.or(Some(ink));
|
||||
// A text edit alone leaves the words it doesn't change as the scan shows them.
|
||||
style.changed_words_only = plain;
|
||||
// Nothing to redraw: leave undo history and the dirty flag alone.
|
||||
if plain && b.same_words(&text) {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Edit::EditTextBlock { page, block, text, style }
|
||||
}
|
||||
other => other,
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether the edit, or any edit inside a batch, deletes pages.
|
||||
fn deletes_pages(edit: &Edit) -> bool {
|
||||
match edit {
|
||||
@@ -17,6 +45,83 @@ fn deletes_pages(edit: &Edit) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep edits to the working copy until both the operation and its script callbacks succeed.
|
||||
fn run_checked_edit(next: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> Result<(), EditError> {
|
||||
let result = guard(|| run_edit(next, edit, cx))
|
||||
.unwrap_or_else(|m| Err(EditError::Invalid(format!("{} failed unexpectedly ({m}); the document was not changed", edit.label()))));
|
||||
let refusal = cx.js.as_mut().and_then(|runner| runner.refused.take());
|
||||
if refusal.is_some() || result.is_err() {
|
||||
discard_edit_callbacks(cx);
|
||||
}
|
||||
refusal.map_or(result, |reason| Err(EditError::Invalid(reason)))
|
||||
}
|
||||
|
||||
/// Discard callback effects if PDF commit fails after any code entered the retained realm.
|
||||
fn discard_edit_callbacks(cx: &EditCtx) {
|
||||
if let Some(runner) = cx.js.as_ref()
|
||||
&& runner.callbacks_touched_realm
|
||||
&& let Ok(mut runtime) = runner.runtime.lock()
|
||||
{
|
||||
runtime.reset_after_refusal();
|
||||
}
|
||||
}
|
||||
|
||||
/// Compare the icon and all of its indirect resources. Conservatively retire on budget exhaustion.
|
||||
fn icon_changed(previous: &pdfcraft_cos::Document, restored: &pdfcraft_cos::Document, root: pdfcraft_cos::ObjRef, budget: &mut usize) -> bool {
|
||||
use pdfcraft_cos::Object;
|
||||
|
||||
fn visit(object: &Object, refs: &mut Vec<pdfcraft_cos::ObjRef>, budget: &mut usize, depth: usize) -> Option<()> {
|
||||
*budget = budget.checked_sub(1)?;
|
||||
if depth == 128 {
|
||||
return None;
|
||||
}
|
||||
match object {
|
||||
Object::Ref(reference) => refs.push(*reference),
|
||||
Object::Array(values) => {
|
||||
for value in values {
|
||||
visit(value, refs, budget, depth + 1)?;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if let Some(dict) = object.as_dict() {
|
||||
for (_, value) in dict.iter() {
|
||||
visit(value, refs, budget, depth + 1)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(())
|
||||
}
|
||||
|
||||
let mut pending = vec![root];
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
while let Some(reference) = pending.pop() {
|
||||
if !seen.insert(reference) {
|
||||
continue;
|
||||
}
|
||||
if *budget == 0 {
|
||||
return true;
|
||||
}
|
||||
let before = previous.get(reference);
|
||||
let after = restored.get(reference);
|
||||
if (!Arc::ptr_eq(&before, &after) && before != after) || visit(&before, &mut pending, budget, 0).is_none() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn sync_script_history(doc: &Document, previous: &pdfcraft_cos::Document, restored: &pdfcraft_cos::Document) {
|
||||
if let Ok(mut runtime) = doc.js_runtime.lock() {
|
||||
let mut budget = 250_000;
|
||||
runtime.retire_changed_icons(|id| {
|
||||
let object = pdfcraft_cos::ObjRef::new((id >> 16) as u32, id as u16);
|
||||
icon_changed(previous, restored, object, &mut budget)
|
||||
});
|
||||
runtime.sync_field_ids(doc.form.iter().map(|f| crate::js::field_object_id(f.obj)));
|
||||
}
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// Apply an edit. On success the previous state is undoable and the view data is refreshed.
|
||||
pub fn apply(&mut self, id: DocId, edit: Edit) -> Result<(), EditError> {
|
||||
@@ -39,6 +144,12 @@ impl Session {
|
||||
let highlight = self.field_highlight;
|
||||
let xfa_time_scale = self.xfa_time_scale.unwrap_or(1);
|
||||
let doc = self.doc_mut(id)?;
|
||||
// An edit must never read globals from a completed-but-unapplied button invocation.
|
||||
// Fork its realm before any edit-triggered calculation, even if the edit later fails.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if doc.js_runtime.lock().map_err(|_| EditError::Invalid("script runtime unavailable".into()))?.is_pending() {
|
||||
js::replace_runtime(&mut doc.js_runtime);
|
||||
}
|
||||
doc.edit_notices.clear();
|
||||
let name = doc.name.clone();
|
||||
let is_xfa = doc.info.xfa.is_some();
|
||||
@@ -57,29 +168,8 @@ impl Session {
|
||||
if let Some(p) = editor.cos.permissions() {
|
||||
check_permission(&edit, &p)?;
|
||||
}
|
||||
// Rewriting an OCR word (invisible text over a scan) also hides the scanned word: cover it
|
||||
// in the paper's colour and draw the new text in the ink's.
|
||||
let edit = match edit {
|
||||
Edit::EditTextBlock { page, block, text, mut style } => {
|
||||
let plain = style == pdfcraft_edit::BlockStyle::default();
|
||||
// Only invisible text is sampled; the blocks are usually cached from opening it.
|
||||
if style.cover.is_none()
|
||||
&& let Some(info) = doc.info.pages.get(page)
|
||||
&& let Some(b) = editor.text_blocks(page).ok().and_then(|b| b.get(block).filter(|b| b.invisible).cloned())
|
||||
&& let Some((paper, ink)) = guard(|| scan_colours::sample(&editor.cos, info, page, &b)).ok().flatten()
|
||||
{
|
||||
style.cover = Some(paper);
|
||||
style.color = style.color.or(Some(ink));
|
||||
// A text edit alone leaves the words it doesn't change as the scan shows them.
|
||||
style.changed_words_only = plain;
|
||||
// Nothing to redraw: leave undo history and the dirty flag alone.
|
||||
if plain && b.same_words(&text) {
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
Edit::EditTextBlock { page, block, text, style }
|
||||
}
|
||||
other => other,
|
||||
let Some(edit) = prepare_ocr_text_edit(editor, &doc.info, edit) else {
|
||||
return Ok(None);
|
||||
};
|
||||
// Nothing to bake: leave undo history and the dirty flag alone.
|
||||
if matches!(&edit, Edit::FlattenFillSign) && !pdfcraft_annot::has_visible_fill_sign(&editor.cos) {
|
||||
@@ -87,13 +177,16 @@ impl Session {
|
||||
}
|
||||
let mut next = editor.cos.clone();
|
||||
if !js_off && uses_scripts(&edit) {
|
||||
cx.js = Some(js::JsRunner::new(&next, &name, highlight));
|
||||
let mut runner = js::JsRunner::new(&next, &name, highlight);
|
||||
runner.runtime = doc.js_runtime.clone();
|
||||
runner.doc.zoom = doc.js_zoom;
|
||||
runner.doc.page = doc.js_page;
|
||||
cx.js = Some(runner);
|
||||
}
|
||||
cx.renamed_fields.clear();
|
||||
cx.redaction_overlays.clear();
|
||||
// `next` is a copy: if the edit fails or crashes, the document is unchanged.
|
||||
guard(|| run_edit(&mut next, &edit, &mut cx))
|
||||
.unwrap_or_else(|m| Err(EditError::Invalid(format!("{} failed unexpectedly ({m}); the document was not changed", edit.label()))))?;
|
||||
run_checked_edit(&mut next, &edit, &mut cx)?;
|
||||
// A field's enter or exit scripts that changed nothing leave no edit behind (no undo step,
|
||||
// nothing unsaved); what they showed or asked for still goes out. Only an event on its own:
|
||||
// a batch keeps the other edits it holds.
|
||||
@@ -192,12 +285,14 @@ impl Session {
|
||||
let (tpl, datasets) = (cx.xfa.as_deref(), &mut cx.xfa_datasets);
|
||||
xfa_notes = guard(|| xfa_sync_datasets(&mut next, tpl, datasets))
|
||||
.unwrap_or_else(|m| Err(format!("writing the XFA data failed unexpectedly ({m})")))
|
||||
.map_err(EditError::Write)?;
|
||||
.map_err(EditError::Write)
|
||||
.inspect_err(|_| discard_edit_callbacks(&cx))?;
|
||||
// Rebuilding from stale data would discard accepted widget values.
|
||||
if xfa_notes.is_empty() {
|
||||
let reflow = guard(|| xfa::relayout_changed_text(&mut next, tpl, &doc.form))
|
||||
.unwrap_or_else(|m| Err(format!("laying out the XFA text failed unexpectedly ({m})")))
|
||||
.map_err(EditError::Write)?;
|
||||
.map_err(EditError::Write)
|
||||
.inspect_err(|_| discard_edit_callbacks(&cx))?;
|
||||
if let Some(warnings) = reflow {
|
||||
xfa_notes.extend(warnings);
|
||||
// Layout replaces pages and widgets, even if the page count is unchanged.
|
||||
@@ -216,6 +311,7 @@ impl Session {
|
||||
}
|
||||
let rewrites = |c: &pdfcraft_cos::Document| c.full_save_required() || c.encryption_changed();
|
||||
if signed && ((rewrites(&next) && !rewrites(&editor.cos)) || (next.is_modified() && next.revisions().is_empty())) {
|
||||
discard_edit_callbacks(&cx);
|
||||
return Err(EditError::SignedRewrite(edit.label().to_string()));
|
||||
}
|
||||
let mut notices = edit_notices(&edit, &editor.cos, &next);
|
||||
@@ -237,6 +333,7 @@ impl Session {
|
||||
let refreshed = guard(|| if scope == Scope::Content { Self::refresh_content(doc, Some(&edit)) } else { Self::refresh_scoped(doc, scope) })
|
||||
.unwrap_or_else(|m| Err(EditError::Reopen(m)));
|
||||
if let Err(e) = refreshed {
|
||||
discard_edit_callbacks(&cx);
|
||||
// Roll back: the edit produced something we cannot display.
|
||||
if let Some(ed) = doc.editor.as_mut()
|
||||
&& let Some((_, prev, keys, _)) = ed.undo.pop()
|
||||
@@ -251,6 +348,9 @@ impl Session {
|
||||
return Err(e);
|
||||
}
|
||||
doc.dirty = true;
|
||||
if let Ok(mut runtime) = doc.js_runtime.lock() {
|
||||
runtime.sync_field_ids(doc.form.iter().map(|f| crate::js::field_object_id(f.obj)));
|
||||
}
|
||||
doc.generation += 1;
|
||||
doc.edit_notices = notices;
|
||||
// Only this edit's exits are skipped on the next focus loss: one an earlier change ran is
|
||||
@@ -299,6 +399,11 @@ impl Session {
|
||||
}
|
||||
doc.edit_notices.clear();
|
||||
Self::sync_dirty(doc);
|
||||
if let Some(editor) = doc.editor.as_ref()
|
||||
&& let Some((_, previous, _, _)) = editor.redo.last()
|
||||
{
|
||||
sync_script_history(doc, previous, &editor.cos);
|
||||
}
|
||||
// The change whose exit ran is undone (or redone without its scripts): skip no exit.
|
||||
doc.xfa_exited.clear();
|
||||
doc.generation += 1;
|
||||
@@ -331,6 +436,11 @@ impl Session {
|
||||
}
|
||||
doc.edit_notices.clear();
|
||||
Self::sync_dirty(doc);
|
||||
if let Some(editor) = doc.editor.as_ref()
|
||||
&& let Some((_, previous, _, _)) = editor.undo.last()
|
||||
{
|
||||
sync_script_history(doc, previous, &editor.cos);
|
||||
}
|
||||
// The change whose exit ran is undone (or redone without its scripts): skip no exit.
|
||||
doc.xfa_exited.clear();
|
||||
doc.generation += 1;
|
||||
|
||||
@@ -71,8 +71,15 @@ impl Session {
|
||||
let keys = editor.keys.clone();
|
||||
// The saved file carries no Password-field values (#3943, #3944); the open document keeps them.
|
||||
let typed = pdfcraft_forms::password_values(&editor.cos);
|
||||
editor.cos =
|
||||
// An incremental save keeps the old byte prefix and its object identities. A
|
||||
// rewrite can reuse the same numbers for unrelated fields and icons.
|
||||
let preserves_objects = bytes.starts_with(editor.saved.as_slice()) || bytes.starts_with(editor.cos.bytes().as_slice());
|
||||
let reopened =
|
||||
pdfcraft_cos::Document::open_with_password(bytes.clone(), keys.reopen.as_deref()).map_err(|e| EditError::Reopen(e.to_string()))?;
|
||||
if !preserves_objects {
|
||||
crate::js::replace_runtime(&mut doc.js_runtime);
|
||||
}
|
||||
editor.cos = reopened;
|
||||
pdfcraft_forms::restore_password_values(&mut editor.cos, &typed).map_err(|e| EditError::Reopen(e.to_string()))?;
|
||||
if !typed.is_empty() && doc.info.xfa.is_some() {
|
||||
let mut datasets = None;
|
||||
@@ -127,6 +134,7 @@ impl Session {
|
||||
/// File ▸ Revert: back to the last saved version (or the file as opened). Undo history is
|
||||
/// cleared, as in Acrobat.
|
||||
pub fn revert(&mut self, id: DocId) -> Result<(), EditError> {
|
||||
self.invalidate_javascript(id);
|
||||
let doc = self.doc_mut(id)?;
|
||||
let editor = doc.editor.as_mut().ok_or_else(|| EditError::ReadOnly(doc.read_only_reason.clone().unwrap_or_default()))?;
|
||||
let base = editor.saved.clone();
|
||||
@@ -159,6 +167,12 @@ impl Session {
|
||||
pub fn reload(&mut self, id: DocId, bytes: Arc<Vec<u8>>) -> Result<(), OpenError> {
|
||||
let old = self.docs.iter().position(|d| d.id == id).ok_or_else(|| OpenError::Invalid("the document is not open".into()))?;
|
||||
let doc = &self.docs[old];
|
||||
// Opening the replacement can initialize a new worker. Wait for the old
|
||||
// invocation to finish before allowing repeated reloads of this document.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if doc.js_runtime.lock().map_err(|_| OpenError::Invalid("script runtime unavailable".into()))?.is_executing() {
|
||||
return Err(OpenError::Invalid("a script is still running in this document; retry the reload once it finishes".into()));
|
||||
}
|
||||
let (name, path, generation) = (doc.name.clone(), doc.path.clone(), doc.generation);
|
||||
let password = doc.editor.as_ref().and_then(|e| e.keys.reopen.clone()).or_else(|| doc.password.clone());
|
||||
let new_id = self.open(name, path, bytes, password.as_deref())?;
|
||||
|
||||
+1178
-76
File diff suppressed because it is too large.
Load diff
@@ -327,6 +327,28 @@ fn split_instance(tpl: &Template, som: &str) -> Option<(pdfcraft_xfa::DataPath,
|
||||
Some((path, parent_som, name, index))
|
||||
}
|
||||
|
||||
/// The final layout index, which can differ from the index of the bound data node.
|
||||
fn indexed_som_tail(som: &str) -> Option<(&str, usize)> {
|
||||
let (prefix, index) = som.strip_suffix(']')?.rsplit_once('[')?;
|
||||
Some((prefix, index.parse().ok()?))
|
||||
}
|
||||
|
||||
/// Adjust a pending focus when a sibling is deleted and its later instances are renumbered.
|
||||
fn shift_focus_after_removal(target: &mut Option<String>, prefix: &str, removed: usize) {
|
||||
let Some((index, suffix)) = target
|
||||
.as_deref()
|
||||
.and_then(|som| som.strip_prefix(prefix)?.strip_prefix('[')?.split_once(']'))
|
||||
.and_then(|(i, tail)| Some((i.parse::<usize>().ok()?, tail)))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if index == removed {
|
||||
*target = None;
|
||||
} else if index > removed {
|
||||
*target = Some(format!("{prefix}[{}]{suffix}", index - 1));
|
||||
}
|
||||
}
|
||||
|
||||
/// A SOM path without its indexes (`form.page1.qty`), for matching names scripts pass.
|
||||
fn plain_som(som: &str) -> String {
|
||||
let mut out = String::with_capacity(som.len());
|
||||
@@ -451,6 +473,8 @@ struct Runner<'a, 'b> {
|
||||
/// Field values to write, by name (the last wins).
|
||||
field_changes: std::collections::BTreeMap<String, Vec<String>>,
|
||||
by_som: Option<HashMap<String, String>>,
|
||||
objects_by_som: HashMap<String, pdfcraft_cos::ObjRef>,
|
||||
focus_requests: Vec<(usize, Option<String>)>,
|
||||
/// Widget names by the data node they bind to (built with `by_som`).
|
||||
by_path: Option<HashMap<pdfcraft_xfa::DataPath, Vec<String>>>,
|
||||
fields: Option<HashMap<String, FieldInfo>>,
|
||||
@@ -498,6 +522,8 @@ impl<'a, 'b> Runner<'a, 'b> {
|
||||
pending: Vec::new(),
|
||||
field_changes: Default::default(),
|
||||
by_som: None,
|
||||
objects_by_som: HashMap::new(),
|
||||
focus_requests: Vec::new(),
|
||||
by_path: None,
|
||||
fields: None,
|
||||
cached: None,
|
||||
@@ -559,7 +585,12 @@ impl<'a, 'b> Runner<'a, 'b> {
|
||||
|
||||
fn by_som(&mut self) -> &HashMap<String, String> {
|
||||
if self.by_som.is_none() {
|
||||
let by_som = pdfcraft_xfa::fields_by_som(self.doc);
|
||||
let mut by_som = HashMap::new();
|
||||
self.objects_by_som.clear();
|
||||
for (som, (name, object)) in pdfcraft_xfa::field_objects_by_som(self.doc) {
|
||||
self.objects_by_som.insert(som.clone(), object);
|
||||
by_som.insert(som, name);
|
||||
}
|
||||
let mut by_path: HashMap<pdfcraft_xfa::DataPath, Vec<String>> = HashMap::new();
|
||||
for (som, name) in &by_som {
|
||||
by_path.entry(pdfcraft_xfa::data_path(self.tpl, som)).or_default().push(name.clone());
|
||||
@@ -891,6 +922,13 @@ impl<'a, 'b> Runner<'a, 'b> {
|
||||
// The data holds every shown instance before one is taken out.
|
||||
counts.insert(key, have.saturating_sub(1));
|
||||
self.data_op(pdfcraft_xfa::DataOp::Instances { parent: parent.clone(), name: name.clone(), count: have });
|
||||
// SOM indexes after the removed instance shift down. A focus on
|
||||
// the deleted instance itself must never reach its replacement.
|
||||
if let Some((prefix, removed)) = indexed_som_tail(som) {
|
||||
for (_, target) in &mut self.focus_requests {
|
||||
shift_focus_after_removal(target, prefix, removed);
|
||||
}
|
||||
}
|
||||
let mut path = parent;
|
||||
path.push((name, index));
|
||||
self.data_op(pdfcraft_xfa::DataOp::Remove(path));
|
||||
@@ -914,9 +952,8 @@ impl<'a, 'b> Runner<'a, 'b> {
|
||||
XfaEffect::OpenAttachment { name, open } => self.run.out.requests.push(Request::OpenAttachment { name: name.clone(), open: *open }),
|
||||
XfaEffect::ShowPanel(p) => self.run.out.requests.push(Request::ShowPanel(*p)),
|
||||
XfaEffect::SetFocus(som) => {
|
||||
if let Some(name) = self.by_som().get(som).cloned() {
|
||||
self.run.out.requests.push(Request::Focus(name));
|
||||
}
|
||||
self.focus_requests.push((self.run.out.requests.len(), Some(som.clone())));
|
||||
self.run.out.requests.push(Request::Focus(String::new()));
|
||||
}
|
||||
XfaEffect::Beep => self.run.out.requests.push(Request::Beep),
|
||||
XfaEffect::Highlight(on) => {
|
||||
@@ -1026,6 +1063,16 @@ impl<'a, 'b> Runner<'a, 'b> {
|
||||
self.relayout()?;
|
||||
}
|
||||
self.flush()?;
|
||||
if !self.focus_requests.is_empty() {
|
||||
self.by_som();
|
||||
for (index, som) in self.focus_requests.into_iter().rev() {
|
||||
if let Some((name, object)) = som.as_ref().and_then(|som| self.by_som.as_ref()?.get(som).zip(self.objects_by_som.get(som))) {
|
||||
self.run.out.requests[index] = Request::FocusField { name: name.clone(), object_id: crate::js::field_object_id(*object) };
|
||||
} else {
|
||||
self.run.out.requests.remove(index);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.session = None;
|
||||
if self.skipped > 0 {
|
||||
let why = if self.run.ran_away {
|
||||
@@ -1442,6 +1489,30 @@ mod tests {
|
||||
assert!(started.elapsed() < std::time::Duration::from_secs(2), "took {:?}", started.elapsed());
|
||||
}
|
||||
|
||||
/// Verify that a shared layout name cannot cause a data-binding index to redirect pending focus.
|
||||
#[test]
|
||||
fn focus_shift_uses_the_som_index_when_data_binding_omits_the_segment() {
|
||||
let (tpl, _) = pdfcraft_xfa::parse(
|
||||
r#"<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
|
||||
<subform name="form"><area name="row"/><subform name="row"><occur min="1" max="4"/></subform></subform>
|
||||
</template>"#,
|
||||
)
|
||||
.unwrap();
|
||||
let removed = "form[0].row[1]";
|
||||
assert_eq!(split_instance(&tpl, removed).unwrap().3, 0);
|
||||
let (prefix, index) = indexed_som_tail(removed).unwrap();
|
||||
for (before, after) in [
|
||||
("form[0].row[0].amount[0]", Some("form[0].row[0].amount[0]")),
|
||||
("form[0].row[1].amount[0]", None),
|
||||
("form[0].row[2].amount[0]", Some("form[0].row[1].amount[0]")),
|
||||
] {
|
||||
let mut target = Some(before.to_owned());
|
||||
shift_focus_after_removal(&mut target, prefix, index);
|
||||
assert_eq!(target.as_deref(), after);
|
||||
}
|
||||
assert!(indexed_som_tail("form[0].row[bad]").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_names_match_indexed_som_paths() {
|
||||
let som_matches = |som: &str, name: &str| ResetTargets::new(&[name.to_string()]).matches(som);
|
||||
|
||||
@@ -21,7 +21,7 @@ fn names_lock() -> MutexGuard<'static, ()> {
|
||||
|
||||
/// `util.printd(fmt)` of 18 October 2026, then of what `util.scand(fmt, typed)` reads.
|
||||
fn printd(fmt: &str, typed: &str) -> String {
|
||||
let doc = DocInfo { file_name: "form.pdf".into(), num_pages: 1, page: 0, info: Vec::new(), xfa: false, highlight: false, ..Default::default() };
|
||||
let doc = DocInfo { file_name: "form.pdf".into(), num_pages: 1, page: 0, ..Default::default() };
|
||||
let script = format!("event.value = util.printd('{fmt}', new Date(2026, 9, 18)) + '|' + util.printd('{fmt}', util.scand('{fmt}', '{typed}'));");
|
||||
let o = run(&script, &Event::field("Format", "date", ""), &doc, &[], &[], Limits::default());
|
||||
assert_eq!(o.error, None);
|
||||
@@ -75,7 +75,7 @@ fn form_dates_follow_the_date_language() {
|
||||
#[test]
|
||||
fn an_english_weekday_before_a_shared_month_name_reads_as_english() {
|
||||
let _names = names_lock();
|
||||
let doc = DocInfo { file_name: "form.pdf".into(), num_pages: 1, page: 0, info: Vec::new(), xfa: false, highlight: false, ..Default::default() };
|
||||
let doc = DocInfo { file_name: "form.pdf".into(), num_pages: 1, page: 0, ..Default::default() };
|
||||
let mar17 = DateTime { y: 2026, m: 3, d: 17, hh: 0, mm: 0, ss: 0 };
|
||||
let mut bad = Vec::new();
|
||||
for language in ["it", "es", "ro", "sq"] {
|
||||
@@ -111,7 +111,7 @@ fn an_english_weekday_before_a_shared_month_name_reads_as_english() {
|
||||
/// letter that writes a name, through the form path and through `util.scand`. Called from the
|
||||
/// one test, which owns the process-wide names.
|
||||
fn every_date_language_reads_back_what_it_writes() {
|
||||
let doc = DocInfo { file_name: "form.pdf".into(), num_pages: 1, page: 0, info: Vec::new(), xfa: false, highlight: false, ..Default::default() };
|
||||
let doc = DocInfo { file_name: "form.pdf".into(), num_pages: 1, page: 0, ..Default::default() };
|
||||
let mut bad = Vec::new();
|
||||
for l in pdfcraft_engine::dates::DATE_LANGUAGES.iter() {
|
||||
use_form_date_language(l.code);
|
||||
@@ -319,7 +319,7 @@ fn an_english_run_that_prints_the_stored_value_is_drawn() {
|
||||
|
||||
/// What `util.scand(fmt, text)` reads, as `yyyy-mm-dd`, or `null`.
|
||||
fn scanned(fmt: &str, text: &str) -> String {
|
||||
let doc = DocInfo { file_name: "form.pdf".into(), num_pages: 1, page: 0, info: Vec::new(), xfa: false, highlight: false, ..Default::default() };
|
||||
let doc = DocInfo { file_name: "form.pdf".into(), num_pages: 1, page: 0, ..Default::default() };
|
||||
let script = format!("var d = util.scand('{fmt}', {text:?}); event.value = d ? util.printd('yyyy-mm-dd', d) : 'null';");
|
||||
let o = run(&script, &Event::field("Format", "date", ""), &doc, &[], &[], Limits::default());
|
||||
assert_eq!(o.error, None);
|
||||
|
||||
@@ -19,9 +19,13 @@ mod tree;
|
||||
pub(crate) use tree::ancestor_sets;
|
||||
|
||||
pub use edit::set_props;
|
||||
pub(crate) use edit::set_props_object;
|
||||
pub use lifecycle::{add_field, delete_field, delete_field_object, delete_widget, duplicate_field};
|
||||
pub(crate) use look::button_appearance;
|
||||
pub use look::{BorderStyle, CheckStyle, FieldFont, Look, LookPatch, check_style, look, redraw_field, redraw_fields, set_button_icon};
|
||||
pub(crate) use look::redraw_field_object;
|
||||
pub use look::{
|
||||
BorderStyle, CheckStyle, FieldFont, Look, LookPatch, check_style, look, redraw_field, redraw_fields, set_button_icon, set_button_icon_target,
|
||||
};
|
||||
pub(crate) use tree::{ensure_form, form_fonts_as_objects};
|
||||
|
||||
/// The smallest width and height of a field's widget, in points: a smaller one is refused.
|
||||
|
||||
@@ -111,6 +111,15 @@ fn calculate_action(c: &crate::af::Calculate) -> Option<Object> {
|
||||
|
||||
/// Change a field's properties (General and Options tabs) and redraw it.
|
||||
pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result<String, FormError> {
|
||||
set_props_target(doc, name, None, props)
|
||||
}
|
||||
|
||||
/// An exact script target can share its name with another field.
|
||||
pub(crate) fn set_props_object(doc: &mut Document, name: &str, object: ObjRef, props: &FieldProps) -> Result<String, FormError> {
|
||||
set_props_target(doc, name, Some(object), props)
|
||||
}
|
||||
|
||||
fn set_props_target(doc: &mut Document, name: &str, object: Option<ObjRef>, props: &FieldProps) -> Result<String, FormError> {
|
||||
if props.font_size.is_some_and(|size| !size.is_finite() || size < 0.0) {
|
||||
return invalid("font size must be a finite, non-negative number");
|
||||
}
|
||||
@@ -127,7 +136,7 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result<S
|
||||
return invalid(crate::af::NOTATION_NOT_STORABLE);
|
||||
}
|
||||
let all = fields(doc);
|
||||
let f = all.iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?.clone();
|
||||
let f = all.iter().find(|f| f.name == name && object.is_none_or(|obj| f.obj == obj)).ok_or_else(|| FormError::NoSuchField(name.into()))?.clone();
|
||||
let export_options = props.export_value.as_ref().map(|(index, value)| button_export_options(&f, *index, value)).transpose()?;
|
||||
if props.export_value.is_some() && props.options.is_some() {
|
||||
return invalid("give export_value or options, not both");
|
||||
@@ -512,7 +521,7 @@ fn redraw_or_join(doc: &mut Document, f: &Field, name: &str, new_name: &str, joi
|
||||
}
|
||||
Ok(())
|
||||
} else {
|
||||
redraw_widgets(doc, new_name, only)
|
||||
super::look::redraw_widgets_target(doc, new_name, Some(f.obj), only)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -172,6 +172,7 @@ pub fn add_field(doc: &mut Document, page: usize, rect: [f64; 4], kind: &NewFiel
|
||||
read_only: false,
|
||||
rotation: 0,
|
||||
caption: None,
|
||||
mouse_up_script: None,
|
||||
list: None,
|
||||
round: true,
|
||||
};
|
||||
|
||||
@@ -314,7 +314,18 @@ pub fn redraw_field(doc: &mut Document, name: &str) -> Result<(), FormError> {
|
||||
/// other widgets' appearances as they are), or for every widget when `only` is `None`. Fails with
|
||||
/// `FormError::NoSuchField` when no field is named `name`.
|
||||
pub(super) fn redraw_widgets(doc: &mut Document, name: &str, only: Option<usize>) -> Result<(), FormError> {
|
||||
let f = fields(doc).into_iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?;
|
||||
redraw_widgets_target(doc, name, None, only)
|
||||
}
|
||||
|
||||
pub(crate) fn redraw_field_object(doc: &mut Document, name: &str, object: ObjRef) -> Result<(), FormError> {
|
||||
redraw_widgets_target(doc, name, Some(object), None)
|
||||
}
|
||||
|
||||
pub(super) fn redraw_widgets_target(doc: &mut Document, name: &str, object: Option<ObjRef>, only: Option<usize>) -> Result<(), FormError> {
|
||||
let f = fields(doc)
|
||||
.into_iter()
|
||||
.find(|f| f.name == name && object.is_none_or(|obj| f.obj == obj))
|
||||
.ok_or_else(|| FormError::NoSuchField(name.into()))?;
|
||||
redraw_widgets_of(doc, &f, only)
|
||||
}
|
||||
|
||||
@@ -436,8 +447,12 @@ pub(crate) fn button_appearance(doc: &Document, w: &Widget, field_da: &str) -> (
|
||||
&& x1 > x0
|
||||
&& y1 > y0
|
||||
{
|
||||
// Proportional, as large as fits (Acrobat's default icon placement), centred.
|
||||
let k = ((width - 2.0) / (x1 - x0)).min((height - 2.0) / (y1 - y0)).max(0.0);
|
||||
// /IF /FB fits the icon to the whole widget, including its border area.
|
||||
let fit_bounds = mk.get(b"IF").is_some_and(|fit| {
|
||||
doc.resolve(fit).as_dict().and_then(|fit| fit.get(b"FB")).is_some_and(|flag| matches!(&*doc.resolve(flag), Object::Bool(true)))
|
||||
});
|
||||
let inset = if fit_bounds { 0.0 } else { 2.0 };
|
||||
let k = ((width - inset) / (x1 - x0)).min((height - inset) / (y1 - y0)).max(0.0);
|
||||
let (dx, dy) = ((width - (x1 - x0) * k) / 2.0 - x0 * k, (height - (y1 - y0) * k) / 2.0 - y0 * k);
|
||||
content.extend(format!("q {k:.6} 0 0 {k:.6} {dx:.3} {dy:.3} cm /Icon Do Q\n").bytes());
|
||||
xobjects.set(b"Icon".to_vec(), Object::Ref(icon));
|
||||
@@ -464,7 +479,15 @@ pub(crate) fn button_appearance(doc: &Document, w: &Widget, field_da: &str) -> (
|
||||
/// Give a push button (an image field) the picture `image` (an image XObject of `px` pixels):
|
||||
/// it becomes the button's icon (`/MK /I`) and its appearance.
|
||||
pub fn set_button_icon(doc: &mut Document, name: &str, image: ObjRef, px: (u32, u32)) -> Result<(), FormError> {
|
||||
let f = fields(doc).into_iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?;
|
||||
set_button_icon_target(doc, name, None, image, px)
|
||||
}
|
||||
|
||||
/// Give the exact field its image without changing another field with the same name.
|
||||
pub fn set_button_icon_target(doc: &mut Document, name: &str, object: Option<ObjRef>, image: ObjRef, px: (u32, u32)) -> Result<(), FormError> {
|
||||
let f = fields(doc)
|
||||
.into_iter()
|
||||
.find(|f| f.name == name && object.is_none_or(|obj| f.obj == obj))
|
||||
.ok_or_else(|| FormError::NoSuchField(name.into()))?;
|
||||
if f.kind != FieldKind::PushButton {
|
||||
return invalid(format!("{name} is not a button or image field"));
|
||||
}
|
||||
@@ -485,7 +508,7 @@ pub fn set_button_icon(doc: &mut Document, name: &str, image: ObjRef, px: (u32,
|
||||
}
|
||||
doc.update_dict(wd.obj, |d| d.set(b"MK".to_vec(), Object::Dict(mk)))?;
|
||||
}
|
||||
redraw_field(doc, name)
|
||||
redraw_field_object(doc, name, f.obj)
|
||||
}
|
||||
|
||||
fn empty_box(doc: &Document, w: &Widget) -> Stream {
|
||||
|
||||
@@ -27,7 +27,7 @@ pub mod table;
|
||||
pub use actions::{FieldAction, Trigger, field_action_chains, field_actions, set_field_actions};
|
||||
pub use author::{
|
||||
BorderStyle, CheckStyle, FieldFont, FieldProps, Look, LookPatch, MIN_FIELD_SIDE, NewField, add_field, check_style, delete_field,
|
||||
delete_field_object, delete_widget, duplicate_field, look, redraw_field, redraw_fields, set_button_icon, set_props,
|
||||
delete_field_object, delete_widget, duplicate_field, look, redraw_field, redraw_fields, set_button_icon, set_button_icon_target, set_props,
|
||||
};
|
||||
pub use calc_order::{CalculatedField, CalculationIssue, CalculationOrder, calculation_order, set_calculation_order};
|
||||
pub use scripting::{
|
||||
@@ -53,8 +53,8 @@ use values::has_password_flag;
|
||||
use values::write_value;
|
||||
pub(crate) use values::{ask_viewers_to_redraw, merged_appearance, redraw_calculated_value};
|
||||
pub use values::{
|
||||
calculations_changed, clear_password_values, has_password_values, password_values, recalculate, recalculate_with, reset, restore_password_values,
|
||||
set_value, set_value_with, set_values,
|
||||
calculations_changed, clear_password_values, default_value_known, has_password_values, password_values, recalculate, recalculate_with, reset,
|
||||
reset_with_counts, reset_with_formatting, reset_with_scripts, restore_password_values, set_value, set_value_with, set_values,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -138,6 +138,8 @@ pub struct Widget {
|
||||
pub rotation: i64,
|
||||
/// Push buttons: the normal label (`/MK /CA`), separate from the field's name.
|
||||
pub caption: Option<String>,
|
||||
/// Text widget JavaScript to run on a mouse-up click.
|
||||
pub mouse_up_script: Option<String>,
|
||||
/// List boxes: where the options are drawn, so a click can pick the one under the pointer.
|
||||
pub list: Option<appearance::ListRows>,
|
||||
/// Drawn round: a radio button, or a check box laid out for a round XFA check button
|
||||
|
||||
@@ -724,6 +724,15 @@ fn walk(
|
||||
value.as_string().map(|s| s.to_text())
|
||||
})
|
||||
.flatten(),
|
||||
mouse_up_script: if kind == FieldKind::Text {
|
||||
let aa = wd.get(b"AA").map(|a| doc.resolve(a));
|
||||
let action = aa.as_deref().and_then(Object::as_dict).and_then(|aa| aa.get(b"U")).or_else(|| wd.get(b"A"));
|
||||
action
|
||||
.filter(|a| doc.resolve(a).as_dict().and_then(|a| a.name(b"S")) == Some(b"JavaScript"))
|
||||
.and_then(|a| script(doc, a, &mut seen.scripts))
|
||||
} else {
|
||||
None
|
||||
},
|
||||
list: (kind == FieldKind::List).then(|| appearance::list_rows_of(doc, inh.da.as_deref().unwrap_or("/Helv 0 Tf 0 g"), Some(d), wd)),
|
||||
round: kind == FieldKind::Radio || (kind == FieldKind::CheckBox && appearance::is_round(doc, wd)),
|
||||
})
|
||||
|
||||
+159
-29
@@ -37,6 +37,8 @@ impl FieldEvent {
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct FieldChange {
|
||||
pub name: String,
|
||||
/// Optional exact object target for malformed forms with duplicate field names.
|
||||
pub object: Option<ObjRef>,
|
||||
pub value: Option<Vec<String>>,
|
||||
/// This value came from a Calculate script; redraw it through the field's Format script.
|
||||
pub formatted: bool,
|
||||
@@ -44,13 +46,17 @@ pub struct FieldChange {
|
||||
pub required: Option<bool>,
|
||||
/// `display.visible` 0, `hidden` 1, `noPrint` 2, `noView` 3.
|
||||
pub display: Option<i32>,
|
||||
/// Acrobat `textColor` / `fillColor` colour arrays: transparent `["T"]`, gray `["G", g]`,
|
||||
/// `["RGB", r, g, b]` or `["CMYK", c, m, y, k]`, components between 0 and 1.
|
||||
/// Normal push-button caption, applied to every widget of the field.
|
||||
pub caption: Option<String>,
|
||||
pub text_color: Option<Vec<String>>,
|
||||
pub fill_color: Option<Vec<String>>,
|
||||
pub caption: Option<String>,
|
||||
/// Move/resize the first widget, in PDF user space.
|
||||
pub rect: Option<[f64; 4]>,
|
||||
pub stroke_color: Option<Vec<String>>,
|
||||
pub text_size: Option<f64>,
|
||||
pub line_width: Option<f64>,
|
||||
pub border_style: Option<String>,
|
||||
pub user_name: Option<String>,
|
||||
pub icon: Option<Option<u64>>,
|
||||
}
|
||||
|
||||
/// What a script did.
|
||||
@@ -69,8 +75,13 @@ pub struct ScriptResult {
|
||||
|
||||
/// Runs field scripts (JavaScript).
|
||||
pub trait Scripts {
|
||||
/// Whether the host stopped this edit's callbacks.
|
||||
fn stopped(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Run `script` for `event` on `target`, whose value is `value`; `fields` is the whole form.
|
||||
fn run(&mut self, event: FieldEvent, script: &str, target: &Field, value: &str, fields: &[Field]) -> ScriptResult;
|
||||
fn run(&mut self, event: FieldEvent, script: &str, target: &Field, value: &str, fields: &[Field], doc: &Document) -> ScriptResult;
|
||||
|
||||
/// [`Self::run`] again for an appearance, with dates written in English names (`util.printd`)
|
||||
/// because no font of the field can draw the date language's (#5303); what it logs was
|
||||
@@ -83,6 +94,7 @@ pub trait Scripts {
|
||||
_target: &Field,
|
||||
_value: &str,
|
||||
_fields: &[Field],
|
||||
_doc: &Document,
|
||||
) -> Option<ScriptResult> {
|
||||
None
|
||||
}
|
||||
@@ -92,7 +104,8 @@ pub trait Scripts {
|
||||
pub struct NoScripts;
|
||||
|
||||
impl Scripts for NoScripts {
|
||||
fn run(&mut self, _: FieldEvent, _: &str, _: &Field, value: &str, _: &[Field]) -> ScriptResult {
|
||||
/// Provide the no-JavaScript fallback: accept the current field value without additional script mutations.
|
||||
fn run(&mut self, _: FieldEvent, _: &str, _: &Field, value: &str, _: &[Field], _: &Document) -> ScriptResult {
|
||||
ScriptResult { rc: true, value: value.to_string(), value_set: false, changes: Vec::new(), message: None }
|
||||
}
|
||||
}
|
||||
@@ -108,9 +121,9 @@ pub(crate) fn accept(doc: &mut Document, f: &Field, value: &str, scripts: &mut d
|
||||
] {
|
||||
let Some(js) = script else { continue };
|
||||
let all = fields(doc);
|
||||
let r = scripts.run(event, js, f, &v, &all);
|
||||
refresh_field |= r.changes.iter().any(|change| change.name == f.name);
|
||||
apply_changes(doc, &r.changes, &f.name, scripts)?;
|
||||
let r = scripts.run(event, js, f, &v, &all, doc);
|
||||
refresh_field |= r.changes.iter().any(|change| change.name == f.name && change.object.is_none_or(|id| id == f.obj));
|
||||
apply_changes(doc, &r.changes, &f.name, Some(f.obj), scripts)?;
|
||||
if !r.rc {
|
||||
return Err(FormError::Invalid(r.message.unwrap_or_else(|| format!("{default} [ {} ]", f.name))));
|
||||
}
|
||||
@@ -122,35 +135,84 @@ pub(crate) fn accept(doc: &mut Document, f: &Field, value: &str, scripts: &mut d
|
||||
/// The text a custom Format script shows for `value`, if the field has one.
|
||||
pub(crate) fn formatted(doc: &Document, f: &Field, value: &str, scripts: &mut dyn Scripts) -> Option<String> {
|
||||
let js = f.actions.scripts.format.as_ref()?;
|
||||
let r = scripts.run(FieldEvent::Format, js, f, value, &fields(doc));
|
||||
let r = scripts.run(FieldEvent::Format, js, f, value, &fields(doc), doc);
|
||||
Some(r.value)
|
||||
}
|
||||
|
||||
/// Immediate and aliased MK references of one widget; cycles and depth are bounded.
|
||||
fn widget_mk_refs(doc: &Document, widget: ObjRef) -> Vec<ObjRef> {
|
||||
let mut value = doc.get(widget).as_dict().and_then(|d| d.get(b"MK")).cloned();
|
||||
let mut seen = Vec::new();
|
||||
for _ in 0..64 {
|
||||
let Some(Object::Ref(reference)) = value else { break };
|
||||
if seen.contains(&reference) {
|
||||
break;
|
||||
}
|
||||
seen.push(reference);
|
||||
value = Some(doc.get(reference).as_ref().clone());
|
||||
}
|
||||
seen
|
||||
}
|
||||
|
||||
/// Keep target widgets' shared indirect MK in place, while outside widgets retain old colors.
|
||||
fn protect_other_mk_owners(doc: &mut Document, field: &Field, owners: &std::collections::HashMap<ObjRef, Vec<ObjRef>>) -> Result<(), FormError> {
|
||||
let own: HashSet<_> = field.widgets.iter().map(|w| w.obj).collect();
|
||||
let refs: HashSet<_> = field.widgets.iter().filter_map(|w| doc.get(w.obj).as_dict().and_then(|d| d.reference(b"MK"))).collect();
|
||||
for reference in refs {
|
||||
for widget in owners.get(&reference).into_iter().flatten().filter(|w| !own.contains(w)) {
|
||||
if !widget_mk_refs(doc, *widget).contains(&reference) {
|
||||
continue;
|
||||
}
|
||||
let mk =
|
||||
doc.get(*widget).as_dict().and_then(|d| d.get(b"MK")).map(|m| doc.resolve(m)).and_then(|v| v.as_dict().cloned()).unwrap_or_default();
|
||||
doc.update_dict(*widget, |d| d.set(b"MK".to_vec(), Object::Dict(mk)))?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// [`formatted`] with dates in English names, for widgets whose fonts can't draw the date
|
||||
/// language's; `None` when the field has no Format script or the English run didn't happen.
|
||||
pub(crate) fn formatted_in_english(doc: &Document, f: &Field, value: &str, scripts: &mut dyn Scripts) -> Option<String> {
|
||||
let js = f.actions.scripts.format.as_ref()?;
|
||||
scripts.rerun_with_english_dates(FieldEvent::Format, js, f, value, &fields(doc)).map(|r| r.value)
|
||||
scripts.rerun_with_english_dates(FieldEvent::Format, js, f, value, &fields(doc), doc).map(|r| r.value)
|
||||
}
|
||||
|
||||
/// Apply script changes to other fields (values written without re-running their scripts;
|
||||
/// read-only, required and visibility as flags; button captions). `except` is the event's own
|
||||
/// field, whose value the event sets.
|
||||
pub(crate) fn apply_changes(doc: &mut Document, changes: &[FieldChange], except: &str, scripts: &mut dyn Scripts) -> Result<(), FormError> {
|
||||
pub(crate) fn apply_changes(
|
||||
doc: &mut Document,
|
||||
changes: &[FieldChange],
|
||||
except: &str,
|
||||
except_object: Option<ObjRef>,
|
||||
scripts: &mut dyn Scripts,
|
||||
) -> Result<(), FormError> {
|
||||
if changes.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
// Validate every colour before applying any part of the script's changes.
|
||||
for c in changes {
|
||||
for color in [&c.text_color, &c.fill_color].into_iter().flatten() {
|
||||
for color in [&c.text_color, &c.fill_color, &c.stroke_color].into_iter().flatten() {
|
||||
js_color(color)?;
|
||||
}
|
||||
}
|
||||
let all = fields(doc);
|
||||
let mut mk_owners: std::collections::HashMap<ObjRef, Vec<ObjRef>> = std::collections::HashMap::new();
|
||||
if changes.iter().any(|c| c.fill_color.is_some() || c.stroke_color.is_some()) {
|
||||
for field in &all {
|
||||
for widget in &field.widgets {
|
||||
for reference in widget_mk_refs(doc, widget.obj) {
|
||||
mk_owners.entry(reference).or_default().push(widget.obj);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for c in changes {
|
||||
let Some(f) = all.iter().find(|f| f.name == c.name) else { continue };
|
||||
let Some(f) = all.iter().find(|f| f.name == c.name && c.object.is_none_or(|obj| f.obj == obj)) else { continue };
|
||||
if let Some(v) = &c.value
|
||||
&& c.name != except
|
||||
&& !except_object.map_or(c.name == except, |obj| f.obj == obj)
|
||||
&& *v != f.value
|
||||
{
|
||||
let value = match f.kind {
|
||||
@@ -197,25 +259,89 @@ pub(crate) fn apply_changes(doc: &mut Document, changes: &[FieldChange], except:
|
||||
}
|
||||
}
|
||||
}
|
||||
// Text can't be transparent: `["T"]` leaves its colour as it is. A transparent fill
|
||||
// clears the background.
|
||||
let text = c.text_color.as_deref().map(js_color).transpose()?.flatten();
|
||||
let fill = c.fill_color.as_deref().map(js_color).transpose()?;
|
||||
if text.is_some() || fill.is_some() {
|
||||
crate::set_props(
|
||||
if c.fill_color.is_some() || c.stroke_color.is_some() {
|
||||
protect_other_mk_owners(doc, f, &mk_owners)?;
|
||||
}
|
||||
let appearance = crate::LookPatch {
|
||||
text: c.text_color.as_deref().map(js_color).transpose()?.flatten(),
|
||||
fill: c.fill_color.as_deref().map(js_color).transpose()?,
|
||||
border: c.stroke_color.as_deref().map(js_color).transpose()?,
|
||||
width: c.line_width,
|
||||
style: c
|
||||
.border_style
|
||||
.as_deref()
|
||||
.map(|style| match style {
|
||||
"solid" => Ok(crate::BorderStyle::Solid),
|
||||
"dashed" => Ok(crate::BorderStyle::Dashed),
|
||||
"beveled" => Ok(crate::BorderStyle::Beveled),
|
||||
"inset" => Ok(crate::BorderStyle::Inset),
|
||||
"underline" => Ok(crate::BorderStyle::Underline),
|
||||
_ => Err(FormError::Invalid("unsupported borderStyle".into())),
|
||||
})
|
||||
.transpose()?,
|
||||
..Default::default()
|
||||
};
|
||||
if appearance != crate::LookPatch::default() || c.text_size.is_some() || c.user_name.is_some() {
|
||||
crate::author::set_props_object(
|
||||
doc,
|
||||
&c.name,
|
||||
&crate::FieldProps { appearance: Some(crate::LookPatch { text, fill, ..Default::default() }), ..Default::default() },
|
||||
f.obj,
|
||||
&crate::FieldProps {
|
||||
appearance: (appearance != crate::LookPatch::default()).then_some(appearance),
|
||||
font_size: c.text_size,
|
||||
tooltip: c.user_name.clone(),
|
||||
..Default::default()
|
||||
},
|
||||
)?;
|
||||
}
|
||||
if let Some(rect) = c.rect {
|
||||
if !rect.iter().all(|v| v.is_finite() && v.abs() <= 1e7) || rect[2] < rect[0] || rect[3] < rect[1] {
|
||||
return Err(FormError::Invalid("invalid script rectangle".into()));
|
||||
}
|
||||
let widget = f.widgets.first().ok_or_else(|| FormError::Invalid("field has no widget rectangle".into()))?;
|
||||
doc.update_dict(widget.obj, |d| d.set(b"Rect".to_vec(), Object::Array(rect.into_iter().map(Object::Real).collect())))?;
|
||||
crate::author::redraw_field_object(doc, &c.name, f.obj)?;
|
||||
}
|
||||
if let Some(icon) = c.icon {
|
||||
if f.kind != FieldKind::PushButton {
|
||||
return Err(FormError::Invalid("icons require a push button".into()));
|
||||
}
|
||||
if icon.is_some_and(|id| id >> 48 != 0) {
|
||||
return Err(FormError::Invalid("invalid button icon handle".into()));
|
||||
}
|
||||
let reference = icon.map(|id| pdfcraft_cos::ObjRef::new((id >> 16) as u32, id as u16));
|
||||
if let Some(reference) = reference {
|
||||
let obj = doc.get(reference);
|
||||
let valid = match &*obj {
|
||||
Object::Stream(stream) => stream.dict.name(b"Subtype") == Some(b"Form".as_slice()),
|
||||
_ => false,
|
||||
};
|
||||
if !valid {
|
||||
return Err(FormError::Invalid("button icon is not a form in this document".into()));
|
||||
}
|
||||
}
|
||||
for widget in &f.widgets {
|
||||
let mut mk = doc
|
||||
.get(widget.obj)
|
||||
.as_dict()
|
||||
.and_then(|d| d.get(b"MK").map(|m| doc.resolve(m)))
|
||||
.and_then(|m| m.as_dict().cloned())
|
||||
.unwrap_or_default();
|
||||
match reference {
|
||||
Some(r) => mk.set(b"I".to_vec(), Object::Ref(r)),
|
||||
None => {
|
||||
mk.remove(b"I");
|
||||
}
|
||||
}
|
||||
doc.update_dict(widget.obj, |d| d.set(b"MK".to_vec(), Object::Dict(mk)))?;
|
||||
}
|
||||
crate::author::redraw_field_object(doc, &c.name, f.obj)?;
|
||||
}
|
||||
if let Some(caption) = &c.caption {
|
||||
if f.kind != FieldKind::PushButton {
|
||||
return Err(FormError::Invalid(format!("{} is not a push button", f.name)));
|
||||
}
|
||||
crate::set_props(doc, &f.name, &crate::FieldProps { caption_all: Some(caption.clone()), ..Default::default() })?;
|
||||
}
|
||||
if let Some(rect) = c.rect {
|
||||
crate::set_props(doc, &c.name, &crate::FieldProps { rect: Some((0, rect)), ..Default::default() })?;
|
||||
crate::author::set_props_object(doc, &f.name, f.obj, &crate::FieldProps { caption_all: Some(caption.clone()), ..Default::default() })?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -226,7 +352,7 @@ pub(crate) fn apply_changes(doc: &mut Document, changes: &[FieldChange], except:
|
||||
/// space, the wrong number of components, or a component that is not a finite number between
|
||||
/// 0 and 1) is refused, and with it the script's changes.
|
||||
fn js_color(values: &[String]) -> Result<Option<[f64; 3]>, FormError> {
|
||||
let invalid = || FormError::Invalid("JavaScript field colours must be T, G, RGB or CMYK arrays with finite values between 0 and 1".into());
|
||||
let invalid = || FormError::Invalid("JavaScript field colours must be T, G, RGB or CMYK arrays with finite components between 0 and 1".into());
|
||||
let (space, parts) = values.split_first().ok_or_else(invalid)?;
|
||||
let mut numbers = [0.0; 4];
|
||||
if parts.len() > numbers.len() {
|
||||
@@ -250,8 +376,12 @@ fn js_color(values: &[String]) -> Result<Option<[f64; 3]>, FormError> {
|
||||
/// Apply what a button or console script changed (values, read-only, required, visibility),
|
||||
/// then recalculate.
|
||||
pub fn apply_script_changes(doc: &mut Document, changes: &[FieldChange], scripts: &mut dyn Scripts) -> Result<(), FormError> {
|
||||
apply_changes(doc, changes, "", scripts)?;
|
||||
crate::recalculate_with(doc, scripts)?;
|
||||
apply_changes(doc, changes, "", None, scripts)?;
|
||||
// Acrobat's automatic calculation is driven by value changes. A keypad caption,
|
||||
// position or visibility update must not execute every calculated field again.
|
||||
if changes.iter().any(|change| change.value.is_some()) {
|
||||
crate::recalculate_with(doc, scripts)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
+171
-7
@@ -137,6 +137,71 @@ fn a_button_caption_its_fonts_encoding_cant_hold_is_reported() {
|
||||
assert_eq!(undrawn_fields(&doc), [("go".to_string(), '\u{2000B}')]);
|
||||
}
|
||||
|
||||
/// A field-wide script caption must reach every widget, including shared dictionaries.
|
||||
#[test]
|
||||
fn script_caption_updates_every_widget_and_survives_save() {
|
||||
let mut doc = one_page();
|
||||
let a = add_field(&mut doc, 0, [40.0, 600.0, 200.0, 630.0], &NewField::Button { caption: "Original".into() }, Some("first")).unwrap();
|
||||
let b = add_field(&mut doc, 0, [40.0, 500.0, 200.0, 530.0], &NewField::Button { caption: "Original".into() }, Some("second")).unwrap();
|
||||
let all = fields(&doc);
|
||||
let (first, second) = (field(&all, &a).obj, field(&all, &b).obj);
|
||||
let mut parent = Dict::new();
|
||||
parent.set(b"FT".to_vec(), Object::name("Btn"));
|
||||
parent.set(b"Ff".to_vec(), Object::Int(flags::PUSH_BUTTON as i64));
|
||||
parent.set(b"T".to_vec(), PdfString::text("clear_button"));
|
||||
parent.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(first), Object::Ref(second)]));
|
||||
let mut action = Dict::new();
|
||||
action.set(b"S".to_vec(), Object::name("ResetForm"));
|
||||
parent.set(b"A".to_vec(), Object::Dict(action.clone()));
|
||||
let parent = doc.add(parent);
|
||||
let mut mk = Dict::new();
|
||||
let ca = Object::String(PdfString::text("Original"));
|
||||
let ca = Object::Ref(doc.add(ca));
|
||||
mk.set(b"CA".to_vec(), ca);
|
||||
mk.set(b"TP".to_vec(), Object::Int(0));
|
||||
mk.set(b"R".to_vec(), Object::Int(90));
|
||||
mk.set(b"VendorNote".to_vec(), PdfString::text("kept"));
|
||||
let mk = doc.add(mk);
|
||||
let mut shared_ap = doc.get(first).as_dict().unwrap().get(b"AP").unwrap().as_dict().unwrap().clone();
|
||||
let normal = shared_ap.get(b"N").unwrap().clone();
|
||||
shared_ap.set(b"R".to_vec(), normal.clone());
|
||||
shared_ap.set(b"D".to_vec(), normal);
|
||||
shared_ap.set(b"VendorAP".to_vec(), PdfString::text("kept"));
|
||||
let shared_ap = doc.add(shared_ap);
|
||||
for widget in [first, second] {
|
||||
doc.update_dict(widget, |d| {
|
||||
for key in [b"FT".as_slice(), b"Ff", b"T"] {
|
||||
d.remove(key);
|
||||
}
|
||||
d.set(b"Parent".to_vec(), Object::Ref(parent));
|
||||
d.set(b"MK".to_vec(), Object::Ref(mk));
|
||||
d.set(b"AP".to_vec(), Object::Ref(shared_ap));
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
let af = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"AcroForm").unwrap();
|
||||
doc.update_dict(af, |d| d.set(b"Fields".to_vec(), Object::Array(vec![Object::Ref(parent)]))).unwrap();
|
||||
|
||||
for caption in ["Original", "", "Continue"] {
|
||||
apply_script_changes(
|
||||
&mut doc,
|
||||
&[FieldChange { name: "clear_button".into(), caption: Some(caption.into()), ..Default::default() }],
|
||||
&mut NoScripts,
|
||||
)
|
||||
.unwrap();
|
||||
let bytes = pdfcraft_cos::write_full(&doc, &SaveOptions::default()).unwrap();
|
||||
doc = Document::open(Arc::new(bytes)).unwrap();
|
||||
let all = fields(&doc);
|
||||
let button = field(&all, "clear_button");
|
||||
assert_eq!(button.widgets.len(), 2);
|
||||
for widget in &button.widgets {
|
||||
assert_eq!(widget.caption.as_deref(), Some(caption));
|
||||
let ap = doc.resolve(doc.get(widget.obj).as_dict().unwrap().get(b"AP").unwrap());
|
||||
assert!(ap.as_dict().unwrap().get(b"N").is_some());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Changing one widget's caption on a push button with two widgets leaves the other widget, the
|
||||
/// shared `/MK` and the parent's action untouched, keeps the other `/MK` and `/AP` entries, and
|
||||
/// drops the stale rollover and down appearances, with a direct or indirect caption and either save.
|
||||
@@ -1406,6 +1471,52 @@ fn reset_restores_defaults() {
|
||||
assert!(matches!(reset(&mut doc, Some(&["nope".to_string()])), Err(FormError::NoSuchField(_))));
|
||||
}
|
||||
|
||||
/// Check saved values and appearances when the host stops callbacks during calculation or formatting.
|
||||
#[test]
|
||||
fn reset_redraws_remaining_widgets_after_callbacks_stop() {
|
||||
struct StopOn {
|
||||
event: FieldEvent,
|
||||
stopped: bool,
|
||||
calls: Vec<FieldEvent>,
|
||||
}
|
||||
impl Scripts for StopOn {
|
||||
fn stopped(&self) -> bool {
|
||||
self.stopped
|
||||
}
|
||||
|
||||
fn run(&mut self, event: FieldEvent, _: &str, _: &Field, value: &str, _: &[Field], _: &Document) -> ScriptResult {
|
||||
assert!(!self.stopped, "callbacks must stop after refusal");
|
||||
self.calls.push(event);
|
||||
self.stopped = event == self.event;
|
||||
ScriptResult { rc: true, value: value.into(), value_set: false, changes: Vec::new(), message: None }
|
||||
}
|
||||
}
|
||||
|
||||
for stop_event in [FieldEvent::Calculate, FieldEvent::Format] {
|
||||
let mut doc = one_page();
|
||||
for (name, y) in [("a", 700.0), ("b", 650.0), ("total", 600.0)] {
|
||||
add_field(&mut doc, 0, [40.0, y, 200.0, y + 30.0], &NewField::Text { multiline: false }, Some(name)).unwrap();
|
||||
}
|
||||
for name in ["a", "b"] {
|
||||
set_props(&mut doc, name, &FieldProps { default_value: Some(Some("Default".into())), ..Default::default() }).unwrap();
|
||||
set_value(&mut doc, name, &FieldValue::Text("Before".into())).unwrap();
|
||||
set_field_script(&mut doc, name, "format", Some("event.value = event.value;")).unwrap();
|
||||
}
|
||||
set_field_script(&mut doc, "total", "calculate", Some("event.value = 1;")).unwrap();
|
||||
let mut host = StopOn { event: stop_event, stopped: false, calls: Vec::new() };
|
||||
assert_eq!(reset_with_formatting(&mut doc, Some(&["a".into(), "b".into()]), &mut host, true).unwrap(), (2, 0));
|
||||
assert_eq!(host.calls.last(), Some(&stop_event));
|
||||
let saved = reopen(&doc);
|
||||
let all = fields(&saved);
|
||||
for name in ["a", "b"] {
|
||||
let f = field(&all, name);
|
||||
assert_eq!(f.value, ["Default"]);
|
||||
let drawn = ap(&saved, &f.widgets[0]);
|
||||
assert!(drawn.contains("(Default) Tj") && !drawn.contains("(Before) Tj"), "{name}: {drawn}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn documents_without_forms_say_so() {
|
||||
let mut doc = Document::new_empty();
|
||||
@@ -1746,7 +1857,8 @@ fn formats_validation_and_calculations_run_like_acrobat() {
|
||||
fn a_script_that_spells_the_markers_in_a_string_runs_as_a_script() {
|
||||
struct Ran;
|
||||
impl Scripts for Ran {
|
||||
fn run(&mut self, _event: FieldEvent, _script: &str, _target: &Field, _value: &str, _fields: &[Field]) -> ScriptResult {
|
||||
/// Synthetic script hook that returns controlled values/side effects so the calculation-chain behavior is observable.
|
||||
fn run(&mut self, _event: FieldEvent, _script: &str, _target: &Field, _value: &str, _fields: &[Field], _doc: &Document) -> ScriptResult {
|
||||
ScriptResult { rc: true, value: "ran".into(), value_set: true, changes: Vec::new(), message: None }
|
||||
}
|
||||
}
|
||||
@@ -1836,7 +1948,7 @@ fn unsupported_first_validation_call_preserves_custom_script_dispatch() {
|
||||
calls: usize,
|
||||
}
|
||||
impl Scripts for RejectingInterpreter {
|
||||
fn run(&mut self, event: FieldEvent, script: &str, target: &Field, value: &str, _fields: &[Field]) -> ScriptResult {
|
||||
fn run(&mut self, event: FieldEvent, script: &str, target: &Field, value: &str, _fields: &[Field], _doc: &Document) -> ScriptResult {
|
||||
assert_eq!(event, FieldEvent::Validate);
|
||||
assert_eq!(script, JS);
|
||||
assert_eq!(target.name, "amount");
|
||||
@@ -3641,10 +3753,12 @@ fn check_box_redraw_keeps_down_states_it_still_draws() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// A calculation side effect on a checkbox is visible to following calculated fields.
|
||||
fn recalc_chain_where_script_changes_checkbox() {
|
||||
struct TestScripts;
|
||||
impl Scripts for TestScripts {
|
||||
fn run(&mut self, _event: FieldEvent, script: &str, _target: &Field, _value: &str, fields: &[Field]) -> ScriptResult {
|
||||
/// Synthetic script hook that returns controlled values/side effects so the calculation-chain behavior is observable.
|
||||
fn run(&mut self, _event: FieldEvent, script: &str, _target: &Field, _value: &str, fields: &[Field], _doc: &Document) -> ScriptResult {
|
||||
if script == "set_box" {
|
||||
ScriptResult {
|
||||
rc: true,
|
||||
@@ -3657,6 +3771,9 @@ fn recalc_chain_where_script_changes_checkbox() {
|
||||
read_only: None,
|
||||
required: None,
|
||||
display: None,
|
||||
caption: None,
|
||||
text_color: None,
|
||||
fill_color: None,
|
||||
..Default::default()
|
||||
}],
|
||||
message: None,
|
||||
@@ -3692,7 +3809,7 @@ fn recalc_chain_where_script_changes_checkbox() {
|
||||
fn a_calculated_combo_stores_the_export_value_of_the_option_it_names() {
|
||||
struct Pick;
|
||||
impl Scripts for Pick {
|
||||
fn run(&mut self, _event: FieldEvent, script: &str, _target: &Field, _value: &str, _fields: &[Field]) -> ScriptResult {
|
||||
fn run(&mut self, _event: FieldEvent, script: &str, _target: &Field, _value: &str, _fields: &[Field], _doc: &Document) -> ScriptResult {
|
||||
ScriptResult { rc: true, value: script.into(), value_set: true, changes: Vec::new(), message: None }
|
||||
}
|
||||
}
|
||||
@@ -3717,7 +3834,7 @@ fn a_calculated_combo_stores_the_export_value_of_the_option_it_names() {
|
||||
fn an_export_value_wins_over_an_earlier_options_display_text() {
|
||||
struct Pick;
|
||||
impl Scripts for Pick {
|
||||
fn run(&mut self, _event: FieldEvent, script: &str, _target: &Field, _value: &str, _fields: &[Field]) -> ScriptResult {
|
||||
fn run(&mut self, _event: FieldEvent, script: &str, _target: &Field, _value: &str, _fields: &[Field], _doc: &Document) -> ScriptResult {
|
||||
ScriptResult { rc: true, value: script.into(), value_set: true, changes: Vec::new(), message: None }
|
||||
}
|
||||
}
|
||||
@@ -3745,7 +3862,7 @@ fn an_export_value_wins_over_an_earlier_options_display_text() {
|
||||
fn validation_script_colour_is_used_when_the_field_redraws() {
|
||||
struct TestScripts;
|
||||
impl Scripts for TestScripts {
|
||||
fn run(&mut self, _event: FieldEvent, _script: &str, _target: &Field, value: &str, _fields: &[Field]) -> ScriptResult {
|
||||
fn run(&mut self, _event: FieldEvent, _script: &str, _target: &Field, value: &str, _fields: &[Field], _doc: &Document) -> ScriptResult {
|
||||
ScriptResult {
|
||||
rc: true,
|
||||
value: value.into(),
|
||||
@@ -3774,7 +3891,7 @@ fn validation_script_colour_is_used_when_the_field_redraws() {
|
||||
fn calculation_script_colour_is_used_when_the_field_redraws() {
|
||||
struct TestScripts;
|
||||
impl Scripts for TestScripts {
|
||||
fn run(&mut self, _event: FieldEvent, _script: &str, _target: &Field, _value: &str, _fields: &[Field]) -> ScriptResult {
|
||||
fn run(&mut self, _event: FieldEvent, _script: &str, _target: &Field, _value: &str, _fields: &[Field], _doc: &Document) -> ScriptResult {
|
||||
ScriptResult {
|
||||
rc: true,
|
||||
value: "Grace".into(),
|
||||
@@ -6653,6 +6770,35 @@ fn locked_button_export_edits_leave_the_document_unchanged() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Cosmetic field updates skip calculations, while a value write still triggers the chain.
|
||||
fn script_cosmetic_mutations_do_not_run_the_calculation_chain() {
|
||||
struct Counter(usize);
|
||||
impl Scripts for Counter {
|
||||
/// Synthetic script hook that returns controlled values/side effects so the calculation-chain behavior is observable.
|
||||
fn run(&mut self, event: FieldEvent, _: &str, _: &Field, value: &str, _: &[Field], _: &Document) -> ScriptResult {
|
||||
if event == FieldEvent::Calculate {
|
||||
self.0 += 1;
|
||||
}
|
||||
ScriptResult { rc: true, value: value.into(), value_set: false, changes: Vec::new(), message: None }
|
||||
}
|
||||
}
|
||||
let mut doc = one_page();
|
||||
add_field(&mut doc, 0, [40.0, 700.0, 200.0, 730.0], &NewField::Text { multiline: false }, Some("input")).unwrap();
|
||||
add_field(&mut doc, 0, [40.0, 650.0, 200.0, 680.0], &NewField::Text { multiline: false }, Some("total")).unwrap();
|
||||
set_field_script(&mut doc, "total", "calculate", Some("event.value = 1;")).unwrap();
|
||||
let mut count = Counter(0);
|
||||
apply_script_changes(
|
||||
&mut doc,
|
||||
&[FieldChange { name: "input".into(), fill_color: Some(vec!["RGB".into(), "1".into(), "0".into(), "0".into()]), ..Default::default() }],
|
||||
&mut count,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(count.0, 0, "a visual change must not evaluate the whole form");
|
||||
apply_script_changes(&mut doc, &[FieldChange { name: "input".into(), value: Some(vec!["2".into()]), ..Default::default() }], &mut count).unwrap();
|
||||
assert!(count.0 > 0, "value changes still calculate dependent fields");
|
||||
}
|
||||
|
||||
/// Three radio groups whose buttons have the on states given (`S`, `M` … or a repeated `Yes`):
|
||||
/// `free` (Radio), `plain` (Radio | NoToggleToOff) and `unison` (… | RadiosInUnison).
|
||||
fn radio_groups() -> Document {
|
||||
@@ -7153,3 +7299,21 @@ fn a_four_entry_mk_colour_with_a_null_is_cmyk_with_zero() {
|
||||
let rgb = off(&mut doc, "rgb");
|
||||
assert!(rgb.contains("0 0 1 rg") && rgb.contains("1 0 0 RG"), "three numbers stay RGB: {rgb}");
|
||||
}
|
||||
|
||||
/// A Simple calculation counts the first value of a multiple-selection list, as `main` always
|
||||
/// has, so the native total and `AFSimple_Calculate` in a script agree.
|
||||
#[test]
|
||||
fn simple_calculation_counts_the_first_value_of_a_multiple_selection_list() {
|
||||
let objs: Vec<String> = vec![
|
||||
"<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [5 0 R 6 0 R] >>".into(),
|
||||
"<< /Fields [5 0 R 6 0 R] /CO [6 0 R] /DA (/Helv 0 Tf 0 g) >>".into(),
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Ch /Ff 2097152 /T (picks) /Opt [(4) (6)] /V [(4) (6)] /Rect [50 700 250 760] /P 3 0 R >>".into(),
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (total) /Rect [50 640 250 660] /P 3 0 R /AA << /C << /S /JavaScript /JS (AFSimple_Calculate\\(\"SUM\", new Array\\(\"picks\"\\)\\);) >> >> >>".into(),
|
||||
];
|
||||
let mut doc = document(&objs);
|
||||
assert_eq!(field(&fields(&doc), "picks").value, ["4", "6"]);
|
||||
recalculate(&mut doc).unwrap();
|
||||
assert_eq!(field(&fields(&doc), "total").value, ["4"]);
|
||||
}
|
||||
+87
-13
@@ -203,6 +203,15 @@ pub fn recalculate(doc: &mut Document) -> Result<usize, FormError> {
|
||||
|
||||
/// [`recalculate`], running custom Calculate (and Format) scripts through `scripts`.
|
||||
pub fn recalculate_with(doc: &mut Document, scripts: &mut dyn Scripts) -> Result<usize, FormError> {
|
||||
recalculate_values(doc, scripts, None)
|
||||
}
|
||||
|
||||
/// Evaluate fields in calculation order. When a redraw set is supplied, record changed fields for formatting after the pass.
|
||||
fn recalculate_values(
|
||||
doc: &mut Document,
|
||||
scripts: &mut dyn Scripts,
|
||||
mut deferred: Option<&mut std::collections::HashSet<ObjRef>>,
|
||||
) -> Result<usize, FormError> {
|
||||
let mut all = fields(doc);
|
||||
if !all.iter().any(calc_order::is_calculated) {
|
||||
return Ok(0);
|
||||
@@ -211,6 +220,9 @@ pub fn recalculate_with(doc: &mut Document, scripts: &mut dyn Scripts) -> Result
|
||||
let order: Vec<ObjRef> = calc_order::run_order(doc, &all).0.into_iter().filter_map(|(i, _)| all.get(i)).map(|f| f.obj).collect();
|
||||
let mut changed = 0;
|
||||
for obj in order {
|
||||
if scripts.stopped() {
|
||||
break;
|
||||
}
|
||||
let Some(f) = all.iter().find(|f| f.obj == obj).cloned() else { continue };
|
||||
let lookup = |n: &str| -> Vec<String> {
|
||||
let prefix = format!("{n}.");
|
||||
@@ -218,13 +230,18 @@ pub fn recalculate_with(doc: &mut Document, scripts: &mut dyn Scripts) -> Result
|
||||
};
|
||||
let current = f.value.first().cloned().unwrap_or_default();
|
||||
let v = if let Some(js) = &f.actions.scripts.calculate {
|
||||
let r = scripts.run(FieldEvent::Calculate, js, &f, ¤t, &all);
|
||||
let r = scripts.run(FieldEvent::Calculate, js, &f, ¤t, &all, doc);
|
||||
// A script may set its own field through the field object (`getField("Total").value
|
||||
// = …`) rather than through `event.value`. `apply_changes` leaves the event's own
|
||||
// field to the event, so that value is the result unless the script also assigned
|
||||
// `event.value`, even the value it already had.
|
||||
let own = r.changes.iter().find(|c| c.name == f.name).and_then(|c| c.value.as_ref()).map(|v| v.first().cloned().unwrap_or_default());
|
||||
scripting::apply_changes(doc, &r.changes, &f.name, scripts)?;
|
||||
let own = r
|
||||
.changes
|
||||
.iter()
|
||||
.find(|c| c.name == f.name && c.object.is_none_or(|obj| obj == f.obj))
|
||||
.and_then(|c| c.value.as_ref())
|
||||
.map(|v| v.first().cloned().unwrap_or_default());
|
||||
scripting::apply_changes(doc, &r.changes, &f.name, Some(f.obj), scripts)?;
|
||||
if !r.changes.is_empty() {
|
||||
all = fields(doc);
|
||||
}
|
||||
@@ -262,8 +279,12 @@ pub fn recalculate_with(doc: &mut Document, scripts: &mut dyn Scripts) -> Result
|
||||
_ => {}
|
||||
}
|
||||
})?;
|
||||
let fresh = all.iter().find(|field| field.obj == obj).cloned().unwrap_or_else(|| f.clone());
|
||||
redraw(doc, &fresh, std::slice::from_ref(&v), scripts)?;
|
||||
if let Some(pending) = deferred.as_deref_mut() {
|
||||
pending.insert(obj);
|
||||
} else {
|
||||
let current = all.iter().find(|field| field.obj == obj).unwrap_or(&f);
|
||||
redraw(doc, current, std::slice::from_ref(&v), scripts)?;
|
||||
}
|
||||
if let Some(target) = all.iter_mut().find(|x| x.obj == obj) {
|
||||
target.value = vec![v];
|
||||
}
|
||||
@@ -291,6 +312,11 @@ pub fn calculations_changed(before: &[Field], after: &[Field]) -> Vec<String> {
|
||||
}
|
||||
|
||||
pub(crate) fn write_value(doc: &mut Document, f: &Field, value: &FieldValue, scripts: &mut dyn Scripts) -> Result<(), FormError> {
|
||||
write_value_impl(doc, f, value, scripts, false)
|
||||
}
|
||||
|
||||
/// Persist a value according to the field kind. A reset bypasses custom input validation and defers text and choice appearances.
|
||||
fn write_value_impl(doc: &mut Document, f: &Field, value: &FieldValue, scripts: &mut dyn Scripts, resetting: bool) -> Result<(), FormError> {
|
||||
match (f.kind, value) {
|
||||
(FieldKind::Text, FieldValue::Text(t)) => {
|
||||
if let Some(max) = f.max_len
|
||||
@@ -306,7 +332,7 @@ pub(crate) fn write_value(doc: &mut Document, f: &Field, value: &FieldValue, scr
|
||||
// Keystroke (on commit) and Validate, as Acrobat runs them before accepting a value.
|
||||
let t = &f.actions.keystroke(&f.name, t).map_err(FormError::Format)?;
|
||||
af::validate(&f.actions.validate, t).map_err(FormError::Invalid)?;
|
||||
let (t, refresh_field) = scripting::accept(doc, f, t, scripts)?;
|
||||
let (t, refresh_field) = if resetting { (t.clone(), false) } else { scripting::accept(doc, f, t, scripts)? };
|
||||
let shadow = t.is_empty() && crate::author::ancestor_sets(doc, f.obj, b"V");
|
||||
doc.update_dict(f.obj, |d| {
|
||||
if t.is_empty() && !shadow {
|
||||
@@ -316,9 +342,11 @@ pub(crate) fn write_value(doc: &mut Document, f: &Field, value: &FieldValue, scr
|
||||
}
|
||||
d.remove(b"RV");
|
||||
})?;
|
||||
let current =
|
||||
if refresh_field { fields(doc).into_iter().find(|current| current.obj == f.obj).unwrap_or_else(|| f.clone()) } else { f.clone() };
|
||||
redraw(doc, ¤t, std::slice::from_ref(&t), scripts)
|
||||
if resetting {
|
||||
return Ok(());
|
||||
}
|
||||
let current = refresh_field.then(|| fields(doc).into_iter().find(|current| current.obj == f.obj)).flatten();
|
||||
redraw(doc, current.as_ref().unwrap_or(f), std::slice::from_ref(&t), scripts)
|
||||
}
|
||||
(FieldKind::CheckBox | FieldKind::Radio, FieldValue::Widget(i)) => {
|
||||
// A button can be read-only on its own (its own `/Ff`), though the field isn't.
|
||||
@@ -465,6 +493,9 @@ pub(crate) fn write_value(doc: &mut Document, f: &Field, value: &FieldValue, scr
|
||||
}
|
||||
})?;
|
||||
scroll_widgets(doc, f, &picked, f.options.len())?;
|
||||
if resetting {
|
||||
return Ok(());
|
||||
}
|
||||
let mut shown = f.clone();
|
||||
shown.value = exports.clone();
|
||||
shown.selected = picked;
|
||||
@@ -827,8 +858,35 @@ fn unread_stream(doc: &Document, field: ObjRef, key: &[u8]) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Whether this snapshot contains a known default, including a genuinely absent /DV.
|
||||
/// An unread nonempty default stream is unknown, not an instruction to clear the field.
|
||||
/// Reuse this policy when exposing reset defaults to other execution layers.
|
||||
pub fn default_value_known(doc: &Document, field: &Field) -> bool {
|
||||
!field.default.is_empty() || !unread_stream(doc, field.obj, b"DV")
|
||||
}
|
||||
|
||||
/// Acrobat's Clear form: every field (or the named ones) back to its default value.
|
||||
pub fn reset(doc: &mut Document, names: Option<&[String]>) -> Result<usize, FormError> {
|
||||
reset_with_counts(doc, names).map(|(reset, _)| reset)
|
||||
}
|
||||
|
||||
/// Restore defaults and report direct resets and dependent calculation writes separately.
|
||||
pub fn reset_with_counts(doc: &mut Document, names: Option<&[String]>) -> Result<(usize, usize), FormError> {
|
||||
reset_with_scripts(doc, names, &mut NoScripts)
|
||||
}
|
||||
|
||||
/// Restore defaults and then run the active calculation callbacks once.
|
||||
pub fn reset_with_scripts(doc: &mut Document, names: Option<&[String]>, scripts: &mut dyn Scripts) -> Result<(usize, usize), FormError> {
|
||||
reset_with_formatting(doc, names, scripts, true)
|
||||
}
|
||||
|
||||
/// Restore defaults with their script formatting, optionally deferring custom calculations.
|
||||
pub fn reset_with_formatting(
|
||||
doc: &mut Document,
|
||||
names: Option<&[String]>,
|
||||
scripts: &mut dyn Scripts,
|
||||
calculate_with_scripts: bool,
|
||||
) -> Result<(usize, usize), FormError> {
|
||||
let all = fields(doc);
|
||||
if all.is_empty() {
|
||||
return Err(FormError::NoForm);
|
||||
@@ -842,12 +900,13 @@ pub fn reset(doc: &mut Document, names: Option<&[String]>) -> Result<usize, Form
|
||||
}
|
||||
let named: Option<std::collections::HashSet<&str>> = names.map(|n| n.iter().map(String::as_str).collect());
|
||||
let mut changed = 0;
|
||||
let mut redraw_fields = std::collections::HashSet::new();
|
||||
for f in all.iter().filter(|f| named.as_ref().is_none_or(|n| n.contains(f.name.as_str()))) {
|
||||
// A default the reading didn't decode (its budget spent) is unknown, not empty: clearing
|
||||
// the field to nothing would lose the value it should go back to. A value it didn't decode
|
||||
// isn't known to be the default, so it is reset.
|
||||
let unread = |key: &[u8], read: &[String]| read.is_empty() && unread_stream(doc, f.obj, key);
|
||||
if unread(b"DV", &f.default) || (f.value == f.default && !unread(b"V", &f.value)) {
|
||||
if !default_value_known(doc, f) || (f.value == f.default && !unread(b"V", &f.value)) {
|
||||
continue;
|
||||
}
|
||||
let v = match f.kind {
|
||||
@@ -860,11 +919,26 @@ pub fn reset(doc: &mut Document, names: Option<&[String]>) -> Result<usize, Form
|
||||
// A reset is not blocked by NoToggleToOff: go through the writer directly.
|
||||
let mut tmp = f.clone();
|
||||
tmp.flags &= !flags::NO_TOGGLE_TO_OFF;
|
||||
write_value(doc, &tmp, &v, &mut NoScripts)?;
|
||||
write_value_impl(doc, &tmp, &v, scripts, true)?;
|
||||
changed += 1;
|
||||
if matches!(f.kind, FieldKind::Text | FieldKind::Combo | FieldKind::List) {
|
||||
redraw_fields.insert(f.obj);
|
||||
}
|
||||
}
|
||||
recalculate(doc)?;
|
||||
Ok(changed)
|
||||
let calculation_runner: &mut dyn Scripts = if calculate_with_scripts { scripts } else { &mut NoScripts };
|
||||
let recalculated = recalculate_values(doc, calculation_runner, Some(&mut redraw_fields))?;
|
||||
// Format scripts can read other fields. Draw from the final values once all
|
||||
// selected defaults and calculated values have been written.
|
||||
if !redraw_fields.is_empty() {
|
||||
let mut no_scripts = NoScripts;
|
||||
for f in fields(doc).into_iter().filter(|f| redraw_fields.contains(&f.obj)) {
|
||||
// A host may stop callbacks and still commit the reset. Its widgets must
|
||||
// continue to reflect the saved values.
|
||||
let formatting: &mut dyn Scripts = if scripts.stopped() { &mut no_scripts } else { scripts };
|
||||
redraw(doc, &f, &f.value, formatting)?;
|
||||
}
|
||||
}
|
||||
Ok((changed, recalculated))
|
||||
}
|
||||
|
||||
/// Marks option `i` picked; false when it already was, or there is no such option.
|
||||
|
||||
+165
-45
@@ -1,10 +1,10 @@
|
||||
use super::host::{arg, colour, error, function, host, read, s, set_value, strings, text, text_line, value_of, write};
|
||||
use super::host::{FieldKey, arg, colour, error, function, host, read, s, set_value, strings, text, text_line, value_of, write};
|
||||
use super::model::{DISPLAY_HIDDEN, DISPLAY_VISIBLE, FieldType, Request};
|
||||
use super::{Attribute, Context, JsArray, JsObject, JsResult, JsString, JsValue, NativeFunction, ObjectInitializer, js_string};
|
||||
|
||||
macro_rules! getter {
|
||||
($ctx:expr, $name:expr, |$f:ident, $c:ident| $body:expr) => {{
|
||||
fn get(_: &JsValue, _: &[JsValue], name: &JsString, $c: &mut Context) -> JsResult<JsValue> {
|
||||
fn get(_: &JsValue, _: &[JsValue], name: &FieldKey, $c: &mut Context) -> JsResult<JsValue> {
|
||||
let $f = read($c, name)?;
|
||||
Ok($body)
|
||||
}
|
||||
@@ -12,11 +12,35 @@ macro_rules! getter {
|
||||
}};
|
||||
}
|
||||
|
||||
/// Resolve public name lookups to their first matching object, preserving ordinary getField behavior.
|
||||
pub(crate) fn field_object(ctx: &mut Context, name: &str) -> JsObject {
|
||||
let n = JsString::from(name);
|
||||
let id = host(ctx).ok().and_then(|h| h.borrow().fields.iter().find(|f| f.name == name).and_then(|f| f.object_id));
|
||||
field_object_by_id(ctx, name, id)
|
||||
}
|
||||
/// Cache by object identity as well as name so duplicate-named targets remain distinct.
|
||||
pub(crate) fn field_object_by_id(ctx: &mut Context, name: &str, id: Option<u64>) -> JsObject {
|
||||
let token = host(ctx)
|
||||
.ok()
|
||||
.and_then(|h| h.borrow().fields.iter().find(|f| f.object_id == id && (id.is_some() || f.name == name)).map(|f| f.instance_id))
|
||||
.unwrap_or(0);
|
||||
let key = crate::host::field_cache_key(name, id, token);
|
||||
if let Some(object) = host(ctx).ok().and_then(|h| h.borrow().objects.get(&key).cloned()) {
|
||||
return object;
|
||||
}
|
||||
let object = field_object_new(ctx, name, id, token);
|
||||
if let Ok(h) = host(ctx)
|
||||
&& h.borrow().field_keys.contains(&key)
|
||||
{
|
||||
h.borrow_mut().objects.insert(key, object.clone());
|
||||
}
|
||||
object
|
||||
}
|
||||
/// Construct live accessors whose identity survives host snapshot refreshes.
|
||||
fn field_object_new(ctx: &mut Context, name: &str, id: Option<u64>, token: u64) -> JsObject {
|
||||
let n = (JsString::from(name), id, token);
|
||||
let value_get = getter!(ctx, n, |f, c| value_of(&f, c));
|
||||
let value_set = {
|
||||
fn set(_: &JsValue, args: &[JsValue], name: &JsString, c: &mut Context) -> JsResult<JsValue> {
|
||||
fn set(_: &JsValue, args: &[JsValue], name: &FieldKey, c: &mut Context) -> JsResult<JsValue> {
|
||||
let vals = strings(&arg(args, 0), c)?;
|
||||
write(c, name, |f| set_value(f, vals))?;
|
||||
Ok(JsValue::undefined())
|
||||
@@ -32,7 +56,7 @@ pub(crate) fn field_object(ctx: &mut Context, name: &str) -> JsObject {
|
||||
let fname = getter!(ctx, n, |f, _c| s(&f.name));
|
||||
let readonly_get = getter!(ctx, n, |f, _c| JsValue::from(f.readonly));
|
||||
let readonly_set = {
|
||||
fn set(_: &JsValue, args: &[JsValue], name: &JsString, c: &mut Context) -> JsResult<JsValue> {
|
||||
fn set(_: &JsValue, args: &[JsValue], name: &FieldKey, c: &mut Context) -> JsResult<JsValue> {
|
||||
let on = arg(args, 0).to_boolean();
|
||||
write(c, name, |f| f.readonly = on)?;
|
||||
Ok(JsValue::undefined())
|
||||
@@ -41,7 +65,7 @@ pub(crate) fn field_object(ctx: &mut Context, name: &str) -> JsObject {
|
||||
};
|
||||
let required_get = getter!(ctx, n, |f, _c| JsValue::from(f.required));
|
||||
let required_set = {
|
||||
fn set(_: &JsValue, args: &[JsValue], name: &JsString, c: &mut Context) -> JsResult<JsValue> {
|
||||
fn set(_: &JsValue, args: &[JsValue], name: &FieldKey, c: &mut Context) -> JsResult<JsValue> {
|
||||
let on = arg(args, 0).to_boolean();
|
||||
write(c, name, |f| f.required = on)?;
|
||||
Ok(JsValue::undefined())
|
||||
@@ -50,7 +74,7 @@ pub(crate) fn field_object(ctx: &mut Context, name: &str) -> JsObject {
|
||||
};
|
||||
let display_get = getter!(ctx, n, |f, _c| JsValue::from(f.display));
|
||||
let display_set = {
|
||||
fn set(_: &JsValue, args: &[JsValue], name: &JsString, c: &mut Context) -> JsResult<JsValue> {
|
||||
fn set(_: &JsValue, args: &[JsValue], name: &FieldKey, c: &mut Context) -> JsResult<JsValue> {
|
||||
let d = arg(args, 0).to_number(c)? as i32;
|
||||
write(c, name, |f| f.display = d.clamp(0, 3))?;
|
||||
Ok(JsValue::undefined())
|
||||
@@ -59,7 +83,7 @@ pub(crate) fn field_object(ctx: &mut Context, name: &str) -> JsObject {
|
||||
};
|
||||
let hidden_get = getter!(ctx, n, |f, _c| JsValue::from(f.display == DISPLAY_HIDDEN));
|
||||
let hidden_set = {
|
||||
fn set(_: &JsValue, args: &[JsValue], name: &JsString, c: &mut Context) -> JsResult<JsValue> {
|
||||
fn set(_: &JsValue, args: &[JsValue], name: &FieldKey, c: &mut Context) -> JsResult<JsValue> {
|
||||
let on = arg(args, 0).to_boolean();
|
||||
write(c, name, |f| f.display = if on { DISPLAY_HIDDEN } else { DISPLAY_VISIBLE })?;
|
||||
Ok(JsValue::undefined())
|
||||
@@ -76,56 +100,114 @@ pub(crate) fn field_object(ctx: &mut Context, name: &str) -> JsObject {
|
||||
let idx: Vec<JsValue> = f.options.iter().enumerate().filter(|(_, o)| f.value.contains(&o.0)).map(|(i, _)| JsValue::from(i as f64)).collect();
|
||||
if idx.len() == 1 { idx[0].clone() } else { JsArray::from_iter(idx, c).into() }
|
||||
});
|
||||
let text_color_get = getter!(ctx, n, |f, c| colour(&f.text_color, c));
|
||||
let text_color_get = getter!(ctx, n, |f, c| colour(f.text_color.as_deref(), c));
|
||||
let text_color_set = {
|
||||
fn set(_: &JsValue, args: &[JsValue], name: &JsString, c: &mut Context) -> JsResult<JsValue> {
|
||||
fn set(_: &JsValue, args: &[JsValue], name: &FieldKey, c: &mut Context) -> JsResult<JsValue> {
|
||||
let v = strings(&arg(args, 0), c)?;
|
||||
write(c, name, |f| f.text_color = Some(v))?;
|
||||
write(c, name, |f| f.text_color = Some(v.into()))?;
|
||||
Ok(JsValue::undefined())
|
||||
}
|
||||
function(ctx, NativeFunction::from_copy_closure_with_captures(set, n.clone()))
|
||||
};
|
||||
let fill_color_get = getter!(ctx, n, |f, c| colour(&f.fill_color, c));
|
||||
let fill_color_get = getter!(ctx, n, |f, c| colour(f.fill_color.as_deref(), c));
|
||||
let fill_color_set = {
|
||||
fn set(_: &JsValue, args: &[JsValue], name: &JsString, c: &mut Context) -> JsResult<JsValue> {
|
||||
fn set(_: &JsValue, args: &[JsValue], name: &FieldKey, c: &mut Context) -> JsResult<JsValue> {
|
||||
let v = strings(&arg(args, 0), c)?;
|
||||
write(c, name, |f| f.fill_color = Some(v))?;
|
||||
write(c, name, |f| f.fill_color = Some(v.into()))?;
|
||||
Ok(JsValue::undefined())
|
||||
}
|
||||
function(ctx, NativeFunction::from_copy_closure_with_captures(set, n.clone()))
|
||||
};
|
||||
let rect_get = getter!(ctx, n, |f, c| match f.rect {
|
||||
Some(rect) => JsArray::from_iter(rect.into_iter().map(JsValue::from).collect::<Vec<_>>(), c).into(),
|
||||
None => JsValue::null(),
|
||||
});
|
||||
let text_size_get = getter!(ctx, n, |f, c| JsValue::from(f.text_size));
|
||||
let text_size_set = {
|
||||
/// Validate a finite text size in 0..=300 points before staging the field appearance change.
|
||||
fn set(_: &JsValue, args: &[JsValue], name: &FieldKey, c: &mut Context) -> JsResult<JsValue> {
|
||||
let v = arg(args, 0).to_number(c)?;
|
||||
if !v.is_finite() || !(0.0..=300.0).contains(&v) {
|
||||
return Err(error("textSize must be between 0 and 300"));
|
||||
}
|
||||
write(c, name, |f| f.text_size = v)?;
|
||||
Ok(JsValue::undefined())
|
||||
}
|
||||
function(ctx, NativeFunction::from_copy_closure_with_captures(set, n.clone()))
|
||||
};
|
||||
let line_width_get = getter!(ctx, n, |f, c| JsValue::from(f.line_width));
|
||||
let line_width_set = {
|
||||
/// Validate a finite border width in 0..=12 points before staging the appearance change.
|
||||
fn set(_: &JsValue, args: &[JsValue], name: &FieldKey, c: &mut Context) -> JsResult<JsValue> {
|
||||
let v = arg(args, 0).to_number(c)?;
|
||||
if !v.is_finite() || !(0.0..=12.0).contains(&v) {
|
||||
return Err(error("lineWidth must be between 0 and 12"));
|
||||
}
|
||||
write(c, name, |f| f.line_width = v)?;
|
||||
Ok(JsValue::undefined())
|
||||
}
|
||||
function(ctx, NativeFunction::from_copy_closure_with_captures(set, n.clone()))
|
||||
};
|
||||
let border_style_get = getter!(ctx, n, |f, c| s(&f.border_style));
|
||||
let border_style_set = {
|
||||
/// Accept only supported border styles and stage the validated value for PDF writeback.
|
||||
fn set(_: &JsValue, args: &[JsValue], name: &FieldKey, c: &mut Context) -> JsResult<JsValue> {
|
||||
let v = text(&arg(args, 0), c)?;
|
||||
if !["solid", "dashed", "beveled", "inset", "underline"].contains(&v.as_str()) {
|
||||
return Err(error("unsupported borderStyle"));
|
||||
}
|
||||
write(c, name, |f| f.border_style = v)?;
|
||||
Ok(JsValue::undefined())
|
||||
}
|
||||
function(ctx, NativeFunction::from_copy_closure_with_captures(set, n.clone()))
|
||||
};
|
||||
let user_name_get = getter!(ctx, n, |f, c| s(&f.user_name));
|
||||
let user_name_set = {
|
||||
/// Bound the tooltip string to 4,096 bytes before staging the field user name.
|
||||
fn set(_: &JsValue, args: &[JsValue], name: &FieldKey, c: &mut Context) -> JsResult<JsValue> {
|
||||
let v = text(&arg(args, 0), c)?;
|
||||
if v.len() > 4096 {
|
||||
return Err(error("userName is too long"));
|
||||
}
|
||||
write(c, name, |f| f.user_name = v)?;
|
||||
Ok(JsValue::undefined())
|
||||
}
|
||||
function(ctx, NativeFunction::from_copy_closure_with_captures(set, n.clone()))
|
||||
};
|
||||
let stroke_color_get = getter!(ctx, n, |f, c| colour(f.stroke_color.as_deref(), c));
|
||||
let stroke_color_set = {
|
||||
/// Read the bounded stroke-colour array for validation at the form writeback boundary.
|
||||
fn set(_: &JsValue, args: &[JsValue], name: &FieldKey, c: &mut Context) -> JsResult<JsValue> {
|
||||
let colour = strings(&arg(args, 0), c)?;
|
||||
write(c, name, |f| f.stroke_color = Some(colour.into()))?;
|
||||
Ok(JsValue::undefined())
|
||||
}
|
||||
function(ctx, NativeFunction::from_copy_closure_with_captures(set, n.clone()))
|
||||
};
|
||||
let rect_get = getter!(ctx, n, |f, c| f.rect.map_or_else(JsValue::undefined, |r| JsArray::from_iter(r.map(JsValue::from), c).into()));
|
||||
let rect_set = {
|
||||
fn set(_: &JsValue, args: &[JsValue], name: &JsString, c: &mut Context) -> JsResult<JsValue> {
|
||||
let value = arg(args, 0);
|
||||
let object = value.as_object().filter(|o| o.is_array()).ok_or_else(|| error("rect must be an array of four finite numbers"))?;
|
||||
let array = JsArray::from_object(object.clone())?;
|
||||
if array.length(c)? != 4 {
|
||||
return Err(error("rect must be an array of four finite numbers"));
|
||||
/// Validate finite ordered rectangle coordinates while permitting zero-sized script overlays.
|
||||
fn set(_: &JsValue, args: &[JsValue], name: &FieldKey, c: &mut Context) -> JsResult<JsValue> {
|
||||
let a = arg(args, 0).as_object().ok_or_else(|| error("rect needs an array"))?;
|
||||
let a = JsArray::from_object(a)?;
|
||||
if a.length(c)? != 4 {
|
||||
return Err(error("rect needs four coordinates"));
|
||||
}
|
||||
let mut rect = [0.0; 4];
|
||||
for (i, coordinate) in rect.iter_mut().enumerate() {
|
||||
*coordinate = array.get(i as u64, c)?.to_number(c)?;
|
||||
let mut coords = [0.0; 4];
|
||||
for (i, v) in coords.iter_mut().enumerate() {
|
||||
*v = a.get(i as u32, c)?.to_number(c)?;
|
||||
if !v.is_finite() || v.abs() > 1e7 {
|
||||
return Err(error("rect coordinates are out of range"));
|
||||
}
|
||||
}
|
||||
if !rect.iter().all(|coordinate| coordinate.is_finite()) {
|
||||
return Err(error("rect coordinates must be finite"));
|
||||
if coords[2] < coords[0] || coords[1] < coords[3] {
|
||||
return Err(error("rect must use left, top, right, bottom order"));
|
||||
}
|
||||
let width = (rect[2] - rect[0]).abs();
|
||||
let height = (rect[3] - rect[1]).abs();
|
||||
if !width.is_finite() || !height.is_finite() || width < 4.0 || height < 4.0 {
|
||||
return Err(error("rect must be at least 4 points wide and high"));
|
||||
}
|
||||
let rect = [rect[0].min(rect[2]), rect[1].min(rect[3]), rect[0].max(rect[2]), rect[1].max(rect[3])];
|
||||
write(c, name, |f| f.rect = Some(rect))?;
|
||||
let v = Some(coords);
|
||||
write(c, name, |f| f.rect = v)?;
|
||||
Ok(JsValue::undefined())
|
||||
}
|
||||
function(ctx, NativeFunction::from_copy_closure_with_captures(set, n.clone()))
|
||||
};
|
||||
|
||||
fn button_get_caption(_: &JsValue, args: &[JsValue], name: &JsString, c: &mut Context) -> JsResult<JsValue> {
|
||||
/// Read the normal caption of the first button widget and refuse unsupported caption faces.
|
||||
fn button_get_caption(_: &JsValue, args: &[JsValue], name: &FieldKey, c: &mut Context) -> JsResult<JsValue> {
|
||||
let f = read(c, name)?;
|
||||
if f.kind != FieldType::Button {
|
||||
return Err(error(format!("{} is not a push button", f.name)));
|
||||
@@ -136,7 +218,8 @@ pub(crate) fn field_object(ctx: &mut Context, name: &str) -> JsObject {
|
||||
}
|
||||
Ok(s(f.caption.as_deref().unwrap_or("")))
|
||||
}
|
||||
fn button_set_caption(_: &JsValue, args: &[JsValue], name: &JsString, c: &mut Context) -> JsResult<JsValue> {
|
||||
/// Record a normal-caption setter even when widget zero already matches, allowing all-widget writeback.
|
||||
fn button_set_caption(_: &JsValue, args: &[JsValue], name: &FieldKey, c: &mut Context) -> JsResult<JsValue> {
|
||||
let f = read(c, name)?;
|
||||
if f.kind != FieldType::Button {
|
||||
return Err(error("buttonSetCaption is only available on push buttons"));
|
||||
@@ -152,12 +235,12 @@ pub(crate) fn field_object(ctx: &mut Context, name: &str) -> JsObject {
|
||||
}
|
||||
write(c, name, |f| {
|
||||
f.caption = Some(caption);
|
||||
f.caption_was_set = true;
|
||||
f.caption_written = true;
|
||||
})?;
|
||||
Ok(JsValue::undefined())
|
||||
}
|
||||
|
||||
fn get_item_at(_: &JsValue, args: &[JsValue], name: &JsString, c: &mut Context) -> JsResult<JsValue> {
|
||||
fn get_item_at(_: &JsValue, args: &[JsValue], name: &FieldKey, c: &mut Context) -> JsResult<JsValue> {
|
||||
let f = read(c, name)?;
|
||||
let i = arg(args, 0).to_number(c)?;
|
||||
// bExportValue defaults to true.
|
||||
@@ -166,13 +249,13 @@ pub(crate) fn field_object(ctx: &mut Context, name: &str) -> JsObject {
|
||||
let o = f.options.get(i).ok_or_else(|| error(format!("{} has no item {i}", f.name)))?;
|
||||
Ok(s(if export { &o.0 } else { &o.1 }))
|
||||
}
|
||||
fn is_box_checked(_: &JsValue, args: &[JsValue], name: &JsString, c: &mut Context) -> JsResult<JsValue> {
|
||||
fn is_box_checked(_: &JsValue, args: &[JsValue], name: &FieldKey, c: &mut Context) -> JsResult<JsValue> {
|
||||
let f = read(c, name)?;
|
||||
let i = arg(args, 0).to_number(c)? as usize;
|
||||
let on = f.options.get(i).is_some_and(|o| f.value.first() == Some(&o.0));
|
||||
Ok(JsValue::from(on))
|
||||
}
|
||||
fn check_this_box(_: &JsValue, args: &[JsValue], name: &JsString, c: &mut Context) -> JsResult<JsValue> {
|
||||
fn check_this_box(_: &JsValue, args: &[JsValue], name: &FieldKey, c: &mut Context) -> JsResult<JsValue> {
|
||||
let f = read(c, name)?;
|
||||
let i = arg(args, 0).to_number(c)? as usize;
|
||||
let on = args.get(1).is_none_or(JsValue::to_boolean);
|
||||
@@ -186,8 +269,13 @@ pub(crate) fn field_object(ctx: &mut Context, name: &str) -> JsObject {
|
||||
})?;
|
||||
Ok(JsValue::undefined())
|
||||
}
|
||||
fn set_focus(_: &JsValue, _: &[JsValue], name: &JsString, c: &mut Context) -> JsResult<JsValue> {
|
||||
host(c)?.borrow_mut().request(Request::Focus(name.to_std_string_escaped()));
|
||||
fn set_focus(_: &JsValue, _: &[JsValue], name: &FieldKey, c: &mut Context) -> JsResult<JsValue> {
|
||||
let field = read(c, name)?;
|
||||
let request = match field.object_id {
|
||||
Some(object_id) => Request::FocusField { name: field.name, object_id },
|
||||
None => Request::Focus(field.name),
|
||||
};
|
||||
host(c)?.borrow_mut().request(request);
|
||||
Ok(JsValue::undefined())
|
||||
}
|
||||
|
||||
@@ -198,6 +286,8 @@ pub(crate) fn field_object(ctx: &mut Context, name: &str) -> JsObject {
|
||||
let set_focus = NativeFunction::from_copy_closure_with_captures(set_focus, n.clone());
|
||||
let button_get_caption = NativeFunction::from_copy_closure_with_captures(button_get_caption, n.clone());
|
||||
let button_set_caption = NativeFunction::from_copy_closure_with_captures(button_set_caption, n.clone());
|
||||
let button_get_icon = NativeFunction::from_copy_closure_with_captures(crate::icons::get, n.clone());
|
||||
let button_set_icon = NativeFunction::from_copy_closure_with_captures(crate::icons::set, n.clone());
|
||||
ObjectInitializer::new(ctx)
|
||||
.accessor(js_string!("value"), Some(value_get), Some(value_set), rw)
|
||||
.accessor(js_string!("valueAsString"), Some(as_string), None, rw)
|
||||
@@ -214,11 +304,18 @@ pub(crate) fn field_object(ctx: &mut Context, name: &str) -> JsObject {
|
||||
.accessor(js_string!("currentValueIndices"), Some(indices), None, rw)
|
||||
.accessor(js_string!("textColor"), Some(text_color_get), Some(text_color_set), rw)
|
||||
.accessor(js_string!("fillColor"), Some(fill_color_get), Some(fill_color_set), rw)
|
||||
.accessor(js_string!("textSize"), Some(text_size_get), Some(text_size_set), rw)
|
||||
.accessor(js_string!("lineWidth"), Some(line_width_get), Some(line_width_set), rw)
|
||||
.accessor(js_string!("borderStyle"), Some(border_style_get), Some(border_style_set), rw)
|
||||
.accessor(js_string!("userName"), Some(user_name_get), Some(user_name_set), rw)
|
||||
.accessor(js_string!("strokeColor"), Some(stroke_color_get), Some(stroke_color_set), rw)
|
||||
.accessor(js_string!("rect"), Some(rect_get), Some(rect_set), rw)
|
||||
.function(get_item_at, js_string!("getItemAt"), 2)
|
||||
.function(is_box_checked, js_string!("isBoxChecked"), 1)
|
||||
.function(check_this_box, js_string!("checkThisBox"), 2)
|
||||
.function(set_focus, js_string!("setFocus"), 0)
|
||||
.function(button_get_icon, js_string!("buttonGetIcon"), 1)
|
||||
.function(button_set_icon, js_string!("buttonSetIcon"), 2)
|
||||
.function(button_get_caption, js_string!("buttonGetCaption"), 1)
|
||||
.function(button_set_caption, js_string!("buttonSetCaption"), 2)
|
||||
.build()
|
||||
@@ -245,8 +342,31 @@ pub(crate) fn get_nth_field_name(_: &JsValue, args: &[JsValue], ctx: &mut Contex
|
||||
}
|
||||
|
||||
pub(crate) fn reset_form(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
||||
{
|
||||
let mut h = host(ctx)?.borrow_mut();
|
||||
if h.reset_calls >= crate::MAX_RESET_CALLS {
|
||||
h.reset_limit_exceeded = true;
|
||||
return Err(error("reset call limit exceeded"));
|
||||
}
|
||||
h.reset_calls = h.reset_calls.saturating_add(1);
|
||||
}
|
||||
let names = strings(&arg(args, 0), ctx)?;
|
||||
host(ctx)?.borrow_mut().request(Request::Reset(names));
|
||||
let mut h = host(ctx)?.borrow_mut();
|
||||
let queued = h.requests.len();
|
||||
h.request(Request::Reset(names));
|
||||
// Keep the live shadow and the bounded host request in agreement, including clipped names.
|
||||
let Some(Request::Reset(names)) = h.requests.get(queued).cloned() else { return Ok(JsValue::undefined()) };
|
||||
let super::host::Host { fields, changed, .. } = &mut *h;
|
||||
for (i, field) in std::sync::Arc::make_mut(fields).iter_mut().enumerate() {
|
||||
if field.default_known
|
||||
&& !matches!(field.kind, FieldType::Button | FieldType::Signature)
|
||||
&& (names.is_empty() || names.iter().any(|n| field.name == *n || field.name.strip_prefix(n).is_some_and(|rest| rest.starts_with('.'))))
|
||||
{
|
||||
field.value.clone_from(&field.default);
|
||||
// Retain the final snapshot even when a later assignment restores the pre-reset value.
|
||||
changed.insert(i);
|
||||
}
|
||||
}
|
||||
Ok(JsValue::undefined())
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
//! Conversion between PDF default user space and Acrobat's widget rectangle convention.
|
||||
/// Convert a PDF rectangle to rotated user-space order around the effective crop-box origin.
|
||||
pub fn rotate_rect([x0, y0, x1, y1]: [f64; 4], [cx0, cy0, cx1, cy1]: [f64; 4], rotation: i64) -> [f64; 4] {
|
||||
match rotation.rem_euclid(360) {
|
||||
90 => [y0 - cy0, cx1 - x0, y1 - cy0, cx1 - x1],
|
||||
180 => [cx1 - x1, cy1 - y0, cx1 - x0, cy1 - y1],
|
||||
270 => [cy1 - y1, x1 - cx0, cy1 - y0, x0 - cx0],
|
||||
_ => [x0 - cx0, y1 - cy0, x1 - cx0, y0 - cy0],
|
||||
}
|
||||
}
|
||||
/// Map a script rectangle back to default PDF user space using the inverse page rotation.
|
||||
pub fn unrotate_rect([l, t, r, b]: [f64; 4], [cx0, cy0, cx1, cy1]: [f64; 4], rotation: i64) -> [f64; 4] {
|
||||
match rotation.rem_euclid(360) {
|
||||
90 => [cx1 - t, cy0 + l, cx1 - b, cy0 + r],
|
||||
180 => [cx1 - r, cy1 - t, cx1 - l, cy1 - b],
|
||||
270 => [cx0 + b, cy1 - r, cx0 + t, cy1 - l],
|
||||
_ => [l + cx0, b + cy0, r + cx0, t + cy0],
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
/// All quarter-turn rotations round-trip rectangle coordinates with a nonzero crop origin.
|
||||
fn rotated_widget_rectangles_roundtrip_with_shifted_crop_boxes() {
|
||||
let rect = [30.0, 50.0, 90.0, 100.0];
|
||||
let crop = [20.0, 40.0, 620.0, 840.0];
|
||||
for rotation in [0, 90, 180, 270] {
|
||||
assert_eq!(unrotate_rect(rotate_rect(rect, crop, rotation), crop, rotation), rect);
|
||||
}
|
||||
}
|
||||
}
|
||||
+173
-89
@@ -1,10 +1,10 @@
|
||||
use super::document::{field_object, get_field, get_nth_field_name, noop, num_fields, page_num_get, page_num_set, print, reset_form, submit_form};
|
||||
use super::document::{get_field, get_nth_field_name, noop, num_fields, page_num_get, page_num_set, print, reset_form, submit_form};
|
||||
use super::format::{printd, printf, printx, scand};
|
||||
use super::host::{arg, function, host, s, strings, text, text_line};
|
||||
use super::host::{arg, error, function, host, s, strings, text, text_line};
|
||||
use super::model::{DISPLAY_HIDDEN, DISPLAY_NO_PRINT, DISPLAY_NO_VIEW, DISPLAY_VISIBLE, Event, Panel, Request};
|
||||
use super::{
|
||||
Arc, Attribute, Context, JsArray, JsObject, JsResult, JsString, JsValue, Message, MessageButtons, MessageIcon, NativeFunction, ObjectInitializer,
|
||||
Source, error, js_string,
|
||||
Source, js_string,
|
||||
};
|
||||
|
||||
// ── app, util, console ──────────────────────────────────────────────────────────────────────
|
||||
@@ -78,10 +78,30 @@ pub(crate) fn message_box_message(args: &[JsValue], ctx: &mut Context) -> JsResu
|
||||
/// given in that order or as one object with those keys, and returns 1 (the OK button).
|
||||
fn alert(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
||||
let m = alert_message(args, ctx)?;
|
||||
host(ctx)?.borrow_mut().alert(m);
|
||||
// Always the OK button for now: the viewer can't ask the user and run the script on with
|
||||
// the answer yet (#4291).
|
||||
Ok(JsValue::from(1))
|
||||
host(ctx)?.borrow_mut().alert(m.clone());
|
||||
if !crate::interaction::has_host() {
|
||||
return Ok(JsValue::from(1));
|
||||
}
|
||||
let answer = crate::interaction::ask(crate::interaction::Prompt::Alert(m.clone()))?;
|
||||
let fallback = match m.buttons {
|
||||
MessageButtons::Ok => "1",
|
||||
MessageButtons::YesNo => "3",
|
||||
_ => "2",
|
||||
};
|
||||
let answer = answer.as_deref().unwrap_or(fallback);
|
||||
let accepted = match m.buttons {
|
||||
MessageButtons::Ok => ["1"].as_slice(),
|
||||
MessageButtons::OkCancel => ["1", "2"].as_slice(),
|
||||
MessageButtons::YesNo => ["3", "4"].as_slice(),
|
||||
MessageButtons::YesNoCancel => ["2", "3", "4"].as_slice(),
|
||||
};
|
||||
if answer == "1" && !accepted.contains(&"1") {
|
||||
return Ok(JsValue::from(2));
|
||||
}
|
||||
if !accepted.contains(&answer) {
|
||||
return Err(error("invalid alert response"));
|
||||
}
|
||||
Ok(JsValue::from(answer.parse::<i32>().unwrap_or(2)))
|
||||
}
|
||||
|
||||
/// `app.beep()`: queues a request to beep.
|
||||
@@ -92,7 +112,7 @@ fn beep(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
||||
|
||||
/// `app.runtimeHighlight`: whether the viewer highlights form fields.
|
||||
fn runtime_highlight(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
||||
Ok(JsValue::from(host(ctx)?.borrow().doc.highlight))
|
||||
Ok(JsValue::from(host(ctx)?.borrow().doc.runtime_highlight))
|
||||
}
|
||||
|
||||
/// `app.runtimeHighlight = on`: asks the viewer to highlight form fields, or to stop. Later reads
|
||||
@@ -100,13 +120,13 @@ fn runtime_highlight(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult<
|
||||
fn set_runtime_highlight(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
||||
let on = arg(args, 0).to_boolean();
|
||||
let mut h = host(ctx)?.borrow_mut();
|
||||
if h.doc.highlight != on {
|
||||
if h.doc.runtime_highlight != on {
|
||||
// Only the setting the script ends with matters: one request, the latest, under the
|
||||
// host's request cap like any other. One the cap drops changes nothing, so the script
|
||||
// goes on reading the setting the viewer has.
|
||||
h.requests.retain(|r| !matches!(r, Request::Highlight(_)));
|
||||
if h.request(Request::Highlight(on)) {
|
||||
h.doc.highlight = on;
|
||||
h.doc.runtime_highlight = on;
|
||||
}
|
||||
}
|
||||
Ok(JsValue::undefined())
|
||||
@@ -212,27 +232,14 @@ fn get_page_box(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<Js
|
||||
let rotation = h.doc.page_rotations.get(page).copied().unwrap_or(0);
|
||||
(boxes[which], rotation, boxes[1])
|
||||
};
|
||||
let [x0, y0, x1, y1] = rect;
|
||||
let [cx0, cy0, cx1, cy1] = crop;
|
||||
let rotated = match rotation {
|
||||
90 => [y0 - cy0, cx1 - x0, y1 - cy0, cx1 - x1],
|
||||
180 => [cx1 - x1, cy1 - y0, cx1 - x0, cy1 - y1],
|
||||
270 => [cy1 - y1, x1 - cx0, cy1 - y0, x0 - cx0],
|
||||
_ => [x0 - cx0, y1 - cy0, x1 - cx0, y0 - cy0],
|
||||
};
|
||||
let rotated = crate::rotate_rect(rect, crop, rotation);
|
||||
if !rotated.iter().all(|value| value.is_finite()) {
|
||||
return Err(error("getPageBox returned non-finite coordinates"));
|
||||
}
|
||||
Ok(JsArray::from_iter(rotated.into_iter().map(JsValue::from), ctx).into())
|
||||
}
|
||||
|
||||
/// `app.response(…)`: always `null`, the answer for a canceled dialog, since no one is there to
|
||||
/// type one.
|
||||
fn response(_: &JsValue, _: &[JsValue], _: &mut Context) -> JsResult<JsValue> {
|
||||
// No one to answer: as if the dialog was cancelled.
|
||||
Ok(JsValue::null())
|
||||
}
|
||||
|
||||
/// Write a bounded line to the script console.
|
||||
fn println(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
||||
let m = text_line(&arg(args, 0), ctx)?;
|
||||
host(ctx)?.borrow_mut().print(m);
|
||||
@@ -251,11 +258,15 @@ fn calculate_now(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult<JsVa
|
||||
let calculations = {
|
||||
let shared = host(ctx)?;
|
||||
let mut h = shared.borrow_mut();
|
||||
if h.calculation_limit_exceeded {
|
||||
return Err(error("script calculation work limit exceeded"));
|
||||
}
|
||||
if h.calculate_depth >= MAX_CALCULATE_DEPTH {
|
||||
h.calculation_limit_exceeded = true;
|
||||
return Err(error("calculateNow recursion limit exceeded"));
|
||||
}
|
||||
h.calculate_depth += 1;
|
||||
Arc::clone(&h.calculations)
|
||||
h.calculations.clone()
|
||||
};
|
||||
let previous_event = match ctx.global_object().get(js_string!("event"), ctx) {
|
||||
Ok(event) => event,
|
||||
@@ -268,45 +279,60 @@ fn calculate_now(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult<JsVa
|
||||
}
|
||||
};
|
||||
let result = (|| {
|
||||
for (name, script) in calculations.iter() {
|
||||
for (index, script) in calculations {
|
||||
let Some(field) = host(ctx)?.borrow().fields.get(index).cloned() else { continue };
|
||||
{
|
||||
let mut h = host(ctx)?.borrow_mut();
|
||||
if h.calculate_steps >= super::run::MAX_CALCULATE_STEPS {
|
||||
return Err(error("calculateNow evaluation limit exceeded"));
|
||||
let shared = host(ctx)?;
|
||||
let mut h = shared.borrow_mut();
|
||||
if h.calculation_limit_exceeded || h.calculation_evaluations >= super::run::MAX_CALCULATE_STEPS {
|
||||
h.calculation_limit_exceeded = true;
|
||||
return Err(error("script calculation work limit exceeded"));
|
||||
}
|
||||
h.calculation_evaluations += 1;
|
||||
if let Some(why) = super::run::refuse(&script) {
|
||||
h.calculation_limit_exceeded = true;
|
||||
return Err(error(why));
|
||||
}
|
||||
h.calculate_steps = h.calculate_steps.saturating_add(1);
|
||||
}
|
||||
let Some(field) = host(ctx)?.borrow().fields.iter().find(|f| f.name == *name).cloned() else { continue };
|
||||
if let Some(why) = super::run::refuse(script) {
|
||||
host(ctx)?.borrow_mut().print(format!("calculation script for {name} failed: {why}"));
|
||||
continue;
|
||||
}
|
||||
let event = Event::field("Calculate", name, &field.value.first().cloned().unwrap_or_default());
|
||||
let (object, _) = event_object(ctx, &event)?;
|
||||
let mut event = Event::field("Calculate", &field.name, &field.value.first().cloned().unwrap_or_default());
|
||||
event.target_id = field.object_id;
|
||||
let (object, value_store) = event_object(ctx, &event)?;
|
||||
ctx.global_object().set(js_string!("event"), object.clone(), true, ctx)?;
|
||||
let (fields_before, changed_before, formatted_before) = {
|
||||
let h = host(ctx)?.borrow();
|
||||
let shared = host(ctx)?;
|
||||
let h = shared.borrow();
|
||||
(Arc::clone(&h.fields), h.changed.clone(), h.formatted.clone())
|
||||
};
|
||||
if let Err(cause) = ctx.eval(Source::from_bytes(script)) {
|
||||
let mut h = host(ctx)?.borrow_mut();
|
||||
if let Err(err) = ctx.eval(Source::from_bytes(&script)) {
|
||||
let shared = host(ctx)?;
|
||||
let mut h = shared.borrow_mut();
|
||||
if err.as_engine().is_some() || h.calculation_limit_exceeded || h.reset_limit_exceeded {
|
||||
return Err(err);
|
||||
}
|
||||
h.fields = fields_before;
|
||||
h.changed = changed_before;
|
||||
h.formatted = formatted_before;
|
||||
h.print(format!("calculation script for {name} failed: {cause}"));
|
||||
h.print(format!("Calculate {}: {err}", field.name));
|
||||
continue;
|
||||
}
|
||||
drop(fields_before);
|
||||
if host(ctx)?.borrow().calculation_limit_exceeded {
|
||||
return Err(error("script calculation work limit exceeded"));
|
||||
}
|
||||
let rc = object.get(js_string!("rc"), ctx)?;
|
||||
if !rc.to_boolean() {
|
||||
continue;
|
||||
}
|
||||
if !value_store.get(js_string!("set"), ctx)?.to_boolean() {
|
||||
continue;
|
||||
}
|
||||
let value = object.get(js_string!("value"), ctx)?;
|
||||
let value = text(&value, ctx)?;
|
||||
let shared = host(ctx)?;
|
||||
let mut h = shared.borrow_mut();
|
||||
if let Some((index, _)) = h.fields.iter().enumerate().find(|(_, f)| f.name == *name) {
|
||||
if index < h.fields.len() {
|
||||
let target = Arc::make_mut(&mut h.fields).get_mut(index).ok_or_else(|| error("the field is no longer available"))?;
|
||||
target.value = vec![value];
|
||||
super::host::set_value(target, vec![value]);
|
||||
h.changed.insert(index);
|
||||
h.formatted.insert(index);
|
||||
}
|
||||
@@ -328,35 +354,38 @@ fn calculate_now(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult<JsVa
|
||||
/// each field counts its first value, and values are read as `AFMakeNumber` reads typed text
|
||||
/// (currency, `%`, group separators, a comma decimal separator), anything else counting as 0.
|
||||
fn af_simple_calculate(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
||||
const MAX_EXPANDED_VALUES: usize = 1 << 20;
|
||||
let op = text(&arg(args, 0), ctx)?;
|
||||
let names = strings(&arg(args, 1), ctx)?;
|
||||
let values: Vec<f64> = {
|
||||
let shared = host(ctx)?;
|
||||
let h = shared.borrow();
|
||||
let mut values = Vec::new();
|
||||
for name in &names {
|
||||
let prefix = format!("{name}.");
|
||||
for f in h.fields.iter().filter(|f| f.name == *name || f.name.starts_with(&prefix)) {
|
||||
if let Some(v) = f.value.first() {
|
||||
if values.len() >= MAX_EXPANDED_VALUES {
|
||||
return Ok(s("0"));
|
||||
}
|
||||
values.push(make_number(v).unwrap_or(0.0));
|
||||
}
|
||||
}
|
||||
}
|
||||
values
|
||||
};
|
||||
let fields = host(ctx)?.borrow().fields.clone();
|
||||
// Charge selector comparisons before group expansion; the iterator keeps memory independent
|
||||
// of how often selectors overlap or duplicate the same field.
|
||||
let selector_bytes = names.iter().fold(0usize, |n, s| n.saturating_add(s.len()));
|
||||
let work = fields.len().saturating_mul(names.len().saturating_add(selector_bytes));
|
||||
let shared = host(ctx)?;
|
||||
let mut h = shared.borrow_mut();
|
||||
h.calculation_work = h.calculation_work.saturating_add(work);
|
||||
let exhausted = h.calculation_work > 1 << 24;
|
||||
h.calculation_limit_exceeded |= exhausted;
|
||||
drop(h);
|
||||
if exhausted {
|
||||
return Err(error("calculation field lookup exceeds its work limit"));
|
||||
}
|
||||
let mut values = names
|
||||
.iter()
|
||||
.flat_map(|name| fields.iter().filter(move |f| f.name == *name || f.name.strip_prefix(name).is_some_and(|rest| rest.starts_with('.'))))
|
||||
.filter_map(|field| field.value.first())
|
||||
.map(|v| make_number(v).unwrap_or(0.0))
|
||||
.peekable();
|
||||
let result = match op.as_str() {
|
||||
"SUM" => values.iter().sum(),
|
||||
// An empty product is 0, as the native calculation has it, not 1.
|
||||
"PRD" if values.is_empty() => 0.0,
|
||||
"PRD" => values.iter().product(),
|
||||
"AVG" if values.is_empty() => 0.0,
|
||||
"AVG" => values.iter().sum::<f64>() / values.len() as f64,
|
||||
"MIN" => values.iter().copied().fold(f64::INFINITY, f64::min),
|
||||
"MAX" => values.iter().copied().fold(f64::NEG_INFINITY, f64::max),
|
||||
"SUM" => values.sum(),
|
||||
"PRD" if values.peek().is_none() => 0.0,
|
||||
"PRD" => values.product(),
|
||||
"AVG" => {
|
||||
let (count, total) = values.fold((0usize, 0.0), |(n, sum), v| (n.saturating_add(1), sum + v));
|
||||
if count == 0 { 0.0 } else { total / count as f64 }
|
||||
}
|
||||
"MIN" => values.reduce(f64::min).unwrap_or(0.0),
|
||||
"MAX" => values.reduce(f64::max).unwrap_or(0.0),
|
||||
_ => 0.0,
|
||||
};
|
||||
Ok(s(&if result.is_finite() { js_number(result) } else { "0".into() }))
|
||||
@@ -420,10 +449,45 @@ fn color_object(ctx: &mut Context) -> JsObject {
|
||||
/// The XFA version scripts see as `xfa_version`.
|
||||
const XFA_VERSION: f64 = 3.5;
|
||||
|
||||
/// Defines the form object model's globals: the document's functions (`getField`, `resetForm`,
|
||||
/// `print`, `submitForm`, `exportDataObject` and others) and properties (`numFields`, `pageNum`,
|
||||
/// `numPages`, `documentFileName`, `info`, the XFA version), and the `app`, `util`, `console`,
|
||||
/// `display` and `color` objects.
|
||||
/// Read the current zoom percentage, defaulting unset snapshots to actual size.
|
||||
fn zoom_get(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
||||
let z = host(ctx)?.borrow().doc.zoom;
|
||||
Ok(JsValue::from(if z > 0.0 { z } else { 100.0 }))
|
||||
}
|
||||
/// Validate the requested percentage, update its live value and queue a native viewer zoom request.
|
||||
fn zoom_set(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
||||
let z = arg(args, 0).to_number(ctx)?;
|
||||
if !z.is_finite() || !(1.0..=6400.0).contains(&z) {
|
||||
return Err(error("zoom must be between 1 and 6400"));
|
||||
}
|
||||
let shared = host(ctx)?;
|
||||
let mut host = shared.borrow_mut();
|
||||
if host.request(Request::Zoom(z)) {
|
||||
host.doc.zoom = z;
|
||||
}
|
||||
Ok(JsValue::undefined())
|
||||
}
|
||||
/// Read the current page count after page insertion/deletion without resetting script globals.
|
||||
fn num_pages_get(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
||||
Ok(JsValue::from(host(ctx)?.borrow().doc.num_pages as f64))
|
||||
}
|
||||
|
||||
/// Read the current file name, including a completed Save As in this retained realm.
|
||||
fn document_file_name_get(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
||||
Ok(s(&host(ctx)?.borrow().doc.file_name))
|
||||
}
|
||||
|
||||
/// Build the information object from the current snapshot, omitting metadata removed by edits.
|
||||
fn document_info_get(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
||||
let info = host(ctx)?.borrow().doc.info.clone();
|
||||
let mut object = ObjectInitializer::new(ctx);
|
||||
for (key, value) in info {
|
||||
object.property(JsString::from(key.as_str()), s(&value), Attribute::all());
|
||||
}
|
||||
Ok(object.build().into())
|
||||
}
|
||||
|
||||
/// Install the host API once; live document properties continue reading each job's snapshot.
|
||||
pub(crate) fn install(ctx: &mut Context) -> JsResult<()> {
|
||||
let globals: [(&str, usize, NativeFunction); 12] = [
|
||||
("getField", 1, NativeFunction::from_fn_ptr(get_field)),
|
||||
@@ -433,8 +497,8 @@ pub(crate) fn install(ctx: &mut Context) -> JsResult<()> {
|
||||
("submitForm", 1, NativeFunction::from_fn_ptr(submit_form)),
|
||||
("mailDoc", 1, NativeFunction::from_fn_ptr(noop)),
|
||||
("calculateNow", 0, NativeFunction::from_fn_ptr(calculate_now)),
|
||||
("AFSimple_Calculate", 2, NativeFunction::from_fn_ptr(af_simple_calculate)),
|
||||
("getPageBox", 2, NativeFunction::from_fn_ptr(get_page_box)),
|
||||
("AFSimple_Calculate", 2, NativeFunction::from_fn_ptr(af_simple_calculate)),
|
||||
("syncAnnotScan", 0, NativeFunction::from_fn_ptr(noop)),
|
||||
("dirty", 0, NativeFunction::from_fn_ptr(noop)),
|
||||
("exportDataObject", 4, NativeFunction::from_fn_ptr(export_data_object)),
|
||||
@@ -442,26 +506,39 @@ pub(crate) fn install(ctx: &mut Context) -> JsResult<()> {
|
||||
for (name, len, f) in globals {
|
||||
ctx.register_global_callable(JsString::from(name), len, f)?;
|
||||
}
|
||||
let zg = function(ctx, NativeFunction::from_fn_ptr(zoom_get));
|
||||
let zs = function(ctx, NativeFunction::from_fn_ptr(zoom_set));
|
||||
|
||||
let border = ObjectInitializer::new(ctx)
|
||||
.property(js_string!("s"), s("solid"), Attribute::all())
|
||||
.property(js_string!("d"), s("dashed"), Attribute::all())
|
||||
.property(js_string!("b"), s("beveled"), Attribute::all())
|
||||
.property(js_string!("i"), s("inset"), Attribute::all())
|
||||
.property(js_string!("u"), s("underline"), Attribute::all())
|
||||
.build();
|
||||
ctx.register_global_property(js_string!("border"), border, Attribute::CONFIGURABLE)?;
|
||||
let global = ctx.global_object();
|
||||
global.define_property_or_throw(
|
||||
js_string!("zoom"),
|
||||
boa_engine::property::PropertyDescriptor::builder().get(zg).set(zs).configurable(true).build(),
|
||||
ctx,
|
||||
)?;
|
||||
let rw = Attribute::CONFIGURABLE;
|
||||
let nf = function(ctx, NativeFunction::from_fn_ptr(num_fields));
|
||||
let pg = function(ctx, NativeFunction::from_fn_ptr(page_num_get));
|
||||
let ps = function(ctx, NativeFunction::from_fn_ptr(page_num_set));
|
||||
let (file, pages, info, xfa) = {
|
||||
let h = host(ctx)?.borrow();
|
||||
(h.doc.file_name.clone(), h.doc.num_pages, h.doc.info.clone(), h.doc.xfa)
|
||||
};
|
||||
let mut io = ObjectInitializer::new(ctx);
|
||||
for (k, v) in &info {
|
||||
io.property(JsString::from(k.as_str()), s(v), Attribute::all());
|
||||
}
|
||||
let info = io.build();
|
||||
let xfa = host(ctx)?.borrow().doc.xfa;
|
||||
use boa_engine::property::PropertyDescriptor;
|
||||
global.define_property_or_throw(js_string!("numFields"), PropertyDescriptor::builder().get(nf).configurable(true), ctx)?;
|
||||
global.define_property_or_throw(js_string!("pageNum"), PropertyDescriptor::builder().get(pg).set(ps).configurable(true), ctx)?;
|
||||
ctx.register_global_property(js_string!("numPages"), JsValue::from(pages as f64), rw)?;
|
||||
ctx.register_global_property(js_string!("documentFileName"), s(&file), rw)?;
|
||||
ctx.register_global_property(js_string!("info"), info, rw)?;
|
||||
for (name, getter) in [
|
||||
("numPages", num_pages_get as fn(&JsValue, &[JsValue], &mut Context) -> JsResult<JsValue>),
|
||||
("documentFileName", document_file_name_get),
|
||||
("info", document_info_get),
|
||||
] {
|
||||
let getter = function(ctx, NativeFunction::from_fn_ptr(getter));
|
||||
global.define_property_or_throw(JsString::from(name), PropertyDescriptor::builder().get(getter).configurable(true), ctx)?;
|
||||
}
|
||||
// XFA forms check both before they run: every document sees the XFA version, and only an
|
||||
// XFA form sees `xfa_installed`.
|
||||
ctx.register_global_property(js_string!("xfa_version"), JsValue::from(XFA_VERSION), rw)?;
|
||||
@@ -475,8 +552,9 @@ pub(crate) fn install(ctx: &mut Context) -> JsResult<()> {
|
||||
.accessor(js_string!("runtimeHighlight"), Some(highlight_get), Some(highlight_set), rw)
|
||||
.function(NativeFunction::from_fn_ptr(alert), js_string!("alert"), 1)
|
||||
.function(NativeFunction::from_fn_ptr(beep), js_string!("beep"), 0)
|
||||
.function(NativeFunction::from_fn_ptr(response), js_string!("response"), 1)
|
||||
.function(NativeFunction::from_fn_ptr(crate::interaction::response), js_string!("response"), 1)
|
||||
.function(NativeFunction::from_fn_ptr(launch_url), js_string!("launchURL"), 2)
|
||||
.function(NativeFunction::from_fn_ptr(crate::interaction::popup_menu), js_string!("popUpMenuEx"), 0)
|
||||
.function(NativeFunction::from_fn_ptr(exec_menu_item), js_string!("execMenuItem"), 1)
|
||||
.function(NativeFunction::from_fn_ptr(timer), js_string!("setTimeOut"), 2)
|
||||
.function(NativeFunction::from_fn_ptr(timer), js_string!("setInterval"), 2)
|
||||
@@ -544,7 +622,13 @@ pub(crate) fn event_object(ctx: &mut Context, e: &Event) -> JsResult<(JsObject,
|
||||
.build();
|
||||
let value_get = function(ctx, NativeFunction::from_copy_closure_with_captures(get, store.clone()));
|
||||
let value_set = function(ctx, NativeFunction::from_copy_closure_with_captures(set, store.clone()));
|
||||
let target = e.target.as_ref().map_or(JsValue::null(), |t| field_object(ctx, t).into());
|
||||
let target = e.target.as_ref().map_or(JsValue::null(), |t| {
|
||||
if e.target_id.is_some() {
|
||||
crate::document::field_object_by_id(ctx, t, e.target_id).into()
|
||||
} else {
|
||||
crate::document::field_object(ctx, t).into()
|
||||
}
|
||||
});
|
||||
let event = ObjectInitializer::new(ctx)
|
||||
.property(js_string!("name"), s(&e.name), Attribute::all())
|
||||
.property(js_string!("type"), s(&e.kind), Attribute::all())
|
||||
|
||||
+88
-10
@@ -8,15 +8,23 @@ use super::{
|
||||
pub(crate) struct Host {
|
||||
pub(crate) event_name: String,
|
||||
pub(crate) fields: Arc<Vec<FieldState>>,
|
||||
pub(crate) icons: Vec<(super::JsObject, u64)>,
|
||||
pub(crate) objects: std::collections::HashMap<(String, Option<u64>, u64), super::JsObject>,
|
||||
pub(crate) field_keys: std::collections::HashSet<(String, Option<u64>, u64)>,
|
||||
pub(crate) changed: BTreeSet<usize>,
|
||||
pub(crate) formatted: BTreeSet<usize>,
|
||||
pub(crate) alerts: Vec<Message>,
|
||||
pub(crate) console: Vec<String>,
|
||||
pub(crate) requests: Vec<Request>,
|
||||
pub(crate) doc: DocInfo,
|
||||
pub(crate) calculations: Arc<Vec<(String, String)>>,
|
||||
pub(crate) calculations: Vec<(usize, String)>,
|
||||
pub(crate) calculate_depth: usize,
|
||||
pub(crate) calculate_steps: usize,
|
||||
pub(crate) calculation_evaluations: usize,
|
||||
pub(crate) calculation_limit_exceeded: bool,
|
||||
pub(crate) calculation_work: usize,
|
||||
pub(crate) current_event: JsValue,
|
||||
pub(crate) reset_calls: usize,
|
||||
pub(crate) reset_limit_exceeded: bool,
|
||||
/// Bytes of text kept in alerts, console lines and requests, against [`xfa::MAX_PAYLOAD`].
|
||||
pub(crate) kept: usize,
|
||||
/// Alerts, console lines and requests the limits left out.
|
||||
@@ -68,11 +76,13 @@ impl Host {
|
||||
Request::LaunchUrl(u) => Request::LaunchUrl(xfa::clip(u)),
|
||||
Request::Submit(u) => Request::Submit(xfa::clip(u)),
|
||||
Request::Focus(n) => Request::Focus(xfa::clip(n)),
|
||||
Request::FocusField { name, object_id } => Request::FocusField { name: xfa::clip(name), object_id },
|
||||
other => other,
|
||||
};
|
||||
let bytes = match &r {
|
||||
Request::Reset(names) => names.iter().fold(0usize, |n, s| n.saturating_add(s.len()).saturating_add(std::mem::size_of::<String>())),
|
||||
Request::LaunchUrl(u) | Request::Submit(u) | Request::Focus(u) => u.len(),
|
||||
Request::FocusField { name, .. } => name.len(),
|
||||
Request::OpenAttachment { name, .. } => name.len(),
|
||||
_ => 0,
|
||||
};
|
||||
@@ -136,6 +146,38 @@ pub(crate) fn function(ctx: &mut Context, f: NativeFunction) -> JsFunction {
|
||||
FunctionObjectBuilder::new(ctx.realm(), f).build()
|
||||
}
|
||||
|
||||
/// Format exactly the escaped UTF-8 representation used by `text`, refusing a
|
||||
/// write before it exceeds the remaining aggregate budget. No full Rust copy of
|
||||
/// an oversized JavaScript string is made just to discover its byte length.
|
||||
pub(crate) fn text_with_budget(v: &JsValue, ctx: &mut Context, remaining: usize, limit_error: &str) -> JsResult<String> {
|
||||
use std::fmt::Write;
|
||||
if v.is_undefined() || v.is_null() {
|
||||
return Ok(String::new());
|
||||
}
|
||||
let js = v.to_string(ctx)?;
|
||||
let exceeded = || error(limit_error);
|
||||
// Every UTF-16 unit contributes at least one byte in the escaped UTF-8 output.
|
||||
if js.len() > remaining {
|
||||
return Err(exceeded());
|
||||
}
|
||||
struct BoundedText {
|
||||
text: String,
|
||||
limit: usize,
|
||||
}
|
||||
impl std::fmt::Write for BoundedText {
|
||||
fn write_str(&mut self, text: &str) -> std::fmt::Result {
|
||||
if self.text.len().checked_add(text.len()).is_none_or(|len| len > self.limit) {
|
||||
return Err(std::fmt::Error);
|
||||
}
|
||||
self.text.push_str(text);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
let mut out = BoundedText { text: String::new(), limit: remaining };
|
||||
write!(&mut out, "{}", js.display_escaped()).map_err(|_| exceeded())?;
|
||||
Ok(out.text)
|
||||
}
|
||||
|
||||
pub(crate) fn strings(v: &JsValue, ctx: &mut Context) -> JsResult<Vec<String>> {
|
||||
if let Some(o) = v.as_object()
|
||||
&& o.is_array()
|
||||
@@ -146,16 +188,23 @@ pub(crate) fn strings(v: &JsValue, ctx: &mut Context) -> JsResult<Vec<String>> {
|
||||
return Err(error(format!("array length exceeds {MAX_ARRAY_LENGTH}")));
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
let mut remaining = xfa::MAX_PAYLOAD;
|
||||
for i in 0..n {
|
||||
let x = a.get(i, ctx)?;
|
||||
out.push(text(&x, ctx)?);
|
||||
let value = text_with_budget(&x, ctx, remaining, "string array byte limit exceeded")?;
|
||||
remaining = remaining.saturating_sub(value.len());
|
||||
out.push(value);
|
||||
}
|
||||
return Ok(out);
|
||||
}
|
||||
Ok(if v.is_undefined() || v.is_null() { Vec::new() } else { vec![text(v, ctx)?] })
|
||||
Ok(if v.is_undefined() || v.is_null() {
|
||||
Vec::new()
|
||||
} else {
|
||||
vec![text_with_budget(v, ctx, xfa::MAX_PAYLOAD, "string array byte limit exceeded")?]
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn colour(c: &Option<Vec<String>>, ctx: &mut Context) -> JsValue {
|
||||
pub(crate) fn colour(c: Option<&[String]>, ctx: &mut Context) -> JsValue {
|
||||
match c {
|
||||
None => JsArray::from_iter([s("T")], ctx).into(),
|
||||
Some(parts) => {
|
||||
@@ -168,17 +217,29 @@ pub(crate) fn colour(c: &Option<Vec<String>>, ctx: &mut Context) -> JsValue {
|
||||
|
||||
// ── field objects ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
fn field_index(ctx: &Context, name: &JsString) -> JsResult<usize> {
|
||||
let n = name.to_std_string_escaped();
|
||||
host(ctx)?.borrow().fields.iter().position(|f| f.name == n).ok_or_else(|| error(format!("no field named {n}")))
|
||||
pub(crate) type FieldKey = (JsString, Option<u64>, u64);
|
||||
|
||||
/// PDF identities survive renames; name-only synthetic fields retain name lookup semantics.
|
||||
pub(crate) fn field_cache_key(name: &str, id: Option<u64>, token: u64) -> (String, Option<u64>, u64) {
|
||||
(if id.is_some() { String::new() } else { name.to_string() }, id, token)
|
||||
}
|
||||
|
||||
pub(crate) fn read(ctx: &Context, name: &JsString) -> JsResult<FieldState> {
|
||||
fn field_index(ctx: &Context, name: &FieldKey) -> JsResult<usize> {
|
||||
let n = name.0.to_std_string_escaped();
|
||||
host(ctx)?
|
||||
.borrow()
|
||||
.fields
|
||||
.iter()
|
||||
.position(|f| name.1.map_or(f.name == n, |id| f.object_id == Some(id) && f.instance_id == name.2))
|
||||
.ok_or_else(|| error(format!("no field named {n}")))
|
||||
}
|
||||
|
||||
pub(crate) fn read(ctx: &Context, name: &FieldKey) -> JsResult<FieldState> {
|
||||
let i = field_index(ctx, name)?;
|
||||
Ok(host(ctx)?.borrow().fields[i].clone())
|
||||
}
|
||||
|
||||
pub(crate) fn write(ctx: &Context, name: &JsString, change: impl FnOnce(&mut FieldState)) -> JsResult<()> {
|
||||
pub(crate) fn write(ctx: &Context, name: &FieldKey, change: impl FnOnce(&mut FieldState)) -> JsResult<()> {
|
||||
let i = field_index(ctx, name)?;
|
||||
let mut h = host(ctx)?.borrow_mut();
|
||||
let field = Arc::make_mut(&mut h.fields).get_mut(i).ok_or_else(|| error("the field is no longer available"))?;
|
||||
@@ -208,3 +269,20 @@ pub(crate) fn set_value(f: &mut FieldState, vals: Vec<String>) {
|
||||
_ => vals.into_iter().take(1).collect(),
|
||||
};
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod string_budget_review_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn escaped_text_budget_counts_exact_utf8_bytes() {
|
||||
let mut ctx = Context::default();
|
||||
for (script, bytes) in [("'ascii'", 5), ("'é'", 2), ("'😀'", 4), ("'\\uD800'", 6)] {
|
||||
let value = ctx.eval(boa_engine::Source::from_bytes(script)).unwrap();
|
||||
let expected = text(&value, &mut ctx).unwrap();
|
||||
assert_eq!(expected.len(), bytes);
|
||||
assert_eq!(text_with_budget(&value, &mut ctx, bytes, "text limit").unwrap(), expected);
|
||||
assert!(text_with_budget(&value, &mut ctx, bytes - 1, "text limit").is_err());
|
||||
}
|
||||
assert_eq!(text_with_budget(&JsValue::null(), &mut ctx, 0, "text limit").unwrap(), "");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
//! Document-bound icon handles. Only object identities minted by this host are accepted.
|
||||
use crate::host::{FieldKey, arg, error, host, read, write};
|
||||
use crate::{Attribute, Context, JsObject, JsResult, JsValue, ObjectInitializer};
|
||||
|
||||
/// Accept omitted/normal face zero only; reject unsupported icon states before touching a field.
|
||||
fn normal_face(args: &[JsValue], index: usize, c: &mut Context) -> JsResult<()> {
|
||||
if let Some(face) = args.get(index)
|
||||
&& !face.is_undefined()
|
||||
&& face.to_number(c)? != 0.0
|
||||
{
|
||||
return Err(error("only normal icon face 0 is supported"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// Return a context-minted normal-icon handle, reusing object identity and enforcing the handle limit.
|
||||
pub(crate) fn get(_: &JsValue, args: &[JsValue], name: &FieldKey, c: &mut Context) -> JsResult<JsValue> {
|
||||
normal_face(args, 0, c)?;
|
||||
let f = read(c, name)?;
|
||||
if f.kind != crate::FieldType::Button {
|
||||
return Err(error("icons require a push button"));
|
||||
}
|
||||
let Some(id) = f.icon else { return Ok(JsValue::null()) };
|
||||
if let Some((obj, _)) = host(c)?.borrow().icons.iter().find(|(_, i)| *i == id) {
|
||||
return Ok(obj.clone().into());
|
||||
}
|
||||
if host(c)?.borrow().icons.len() >= 4096 {
|
||||
return Err(error("too many icon handles"));
|
||||
}
|
||||
let obj = ObjectInitializer::new(c).property(crate::js_string!("name"), JsValue::null(), Attribute::READONLY).build();
|
||||
host(c)?.borrow_mut().icons.push((obj.clone(), id));
|
||||
Ok(obj.into())
|
||||
}
|
||||
/// Stage a normal-icon copy/clear only for a handle minted in this context, refusing forged objects.
|
||||
pub(crate) fn set(_: &JsValue, args: &[JsValue], name: &FieldKey, c: &mut Context) -> JsResult<JsValue> {
|
||||
normal_face(args, 1, c)?;
|
||||
if read(c, name)?.kind != crate::FieldType::Button {
|
||||
return Err(error("icons require a push button"));
|
||||
}
|
||||
let value = arg(args, 0);
|
||||
let id = if value.is_null() {
|
||||
None
|
||||
} else {
|
||||
let object = value.as_object().ok_or_else(|| error("buttonSetIcon requires an icon from this document"))?;
|
||||
Some(
|
||||
host(c)?
|
||||
.borrow()
|
||||
.icons
|
||||
.iter()
|
||||
.find(|(o, _)| JsObject::equals(o, &object))
|
||||
.map(|(_, i)| *i)
|
||||
.ok_or_else(|| error("buttonSetIcon requires an icon from this document"))?,
|
||||
)
|
||||
};
|
||||
write(c, name, |f| {
|
||||
f.icon = id;
|
||||
f.icon_written = true;
|
||||
})?;
|
||||
Ok(JsValue::undefined())
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
//! Bounded synchronous script prompts. The worker waits; the UI never evaluates script text.
|
||||
use crate::host::{arg, error, s, text_with_budget};
|
||||
use crate::{Context, JsArray, JsResult, JsValue, Outcome, js_string};
|
||||
use std::cell::RefCell;
|
||||
use std::sync::mpsc::{self, Sender};
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct MenuItem {
|
||||
pub name: String,
|
||||
pub value: String,
|
||||
pub enabled: bool,
|
||||
pub marked: bool,
|
||||
pub children: Vec<MenuItem>,
|
||||
}
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Prompt {
|
||||
Menu(Vec<MenuItem>),
|
||||
Text { title: String, question: String, default: String, password: bool, label: String },
|
||||
Alert(crate::Message),
|
||||
}
|
||||
pub enum InteractiveEvent {
|
||||
Prompt(Prompt, Sender<Option<String>>),
|
||||
Complete(Outcome),
|
||||
}
|
||||
thread_local! { static HOST:RefCell<Option<(Sender<InteractiveEvent>,usize)>>=const {RefCell::new(None)}; }
|
||||
/// Tell alert/menu helpers whether this worker has an interactive or deterministic reply channel.
|
||||
pub(crate) fn has_host() -> bool {
|
||||
HOST.with(|h| h.borrow().is_some())
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
/// Install or clear the worker-local prompt channel and reset the per-invocation prompt budget.
|
||||
pub(crate) fn set_host(host: Option<Sender<InteractiveEvent>>) {
|
||||
HOST.with(|h| *h.borrow_mut() = host.map(|h| (h, 0)));
|
||||
}
|
||||
/// Send a bounded prompt and await its reply, treating disconnect/timeout as cancellation.
|
||||
pub(crate) fn ask(prompt: Prompt) -> JsResult<Option<String>> {
|
||||
let sender = HOST.with(|h| {
|
||||
let mut h = h.borrow_mut();
|
||||
let Some((tx, n)) = h.as_mut() else { return Ok(None) };
|
||||
if *n >= 32 {
|
||||
return Err(error("too many interactive prompts"));
|
||||
}
|
||||
*n += 1;
|
||||
Ok(Some(tx.clone()))
|
||||
})?;
|
||||
let Some(sender) = sender else { return Ok(None) };
|
||||
let (tx, rx) = mpsc::channel();
|
||||
sender.send(InteractiveEvent::Prompt(prompt, tx)).map_err(|_| error("interactive host closed"))?;
|
||||
let answer = rx.recv_timeout(std::time::Duration::from_secs(300)).ok().flatten();
|
||||
if answer.as_ref().is_some_and(|s| s.len() > 4096) {
|
||||
return Err(error("interactive response exceeds its limit"));
|
||||
}
|
||||
Ok(answer)
|
||||
}
|
||||
/// Reject prompt text before copying beyond the public per-property byte limit.
|
||||
fn menu_text(value: &JsValue, ctx: &mut Context) -> JsResult<String> {
|
||||
text_with_budget(value, ctx, 4096, "popup menu text exceeds its limit")
|
||||
}
|
||||
fn response_text(value: &JsValue, ctx: &mut Context) -> JsResult<String> {
|
||||
text_with_budget(value, ctx, 4096, "response dialog text exceeds its limit")
|
||||
}
|
||||
/// Parse one menu subtree with shared entry/depth/string budgets before exposing it to a host.
|
||||
fn read_menu(value: &JsValue, ctx: &mut Context, depth: usize, remaining: &mut usize) -> JsResult<MenuItem> {
|
||||
if depth > 16 || *remaining == 0 {
|
||||
return Err(error("popup menu exceeds its size or depth limit"));
|
||||
}
|
||||
*remaining -= 1;
|
||||
let obj = value.as_object().ok_or_else(|| error("popup menu item must be an object"))?;
|
||||
let name = menu_text(&obj.get(js_string!("cName"), ctx)?, ctx)?;
|
||||
let ret = obj.get(js_string!("cReturn"), ctx)?;
|
||||
let value = if ret.is_undefined() { name.clone() } else { menu_text(&ret, ctx)? };
|
||||
let enabled = obj.get(js_string!("bEnabled"), ctx)?;
|
||||
let marked = obj.get(js_string!("bMarked"), ctx)?.to_boolean();
|
||||
let sub = obj.get(js_string!("oSubMenu"), ctx)?;
|
||||
let mut children = Vec::new();
|
||||
if let Some(o) = sub.as_object() {
|
||||
if o.is_array() {
|
||||
let array = JsArray::from_object(o.clone())?;
|
||||
let len = array.length(ctx)?;
|
||||
if len > *remaining as u64 {
|
||||
return Err(error("popup menu has too many entries"));
|
||||
}
|
||||
for i in 0..len {
|
||||
children.push(read_menu(&array.get(i, ctx)?, ctx, depth + 1, remaining)?);
|
||||
}
|
||||
} else {
|
||||
children.push(read_menu(&sub, ctx, depth + 1, remaining)?);
|
||||
}
|
||||
} else if !sub.is_undefined() && !sub.is_null() {
|
||||
return Err(error("invalid popup submenu"));
|
||||
}
|
||||
Ok(MenuItem { name, value, enabled: enabled.is_undefined() || enabled.to_boolean(), marked, children })
|
||||
}
|
||||
|
||||
/// Accept only a matching enabled leaf whose ancestors are also enabled, excluding separators.
|
||||
fn allowed(items: &[MenuItem], value: &str) -> bool {
|
||||
items.iter().any(|i| i.enabled && i.name != "-" && if i.children.is_empty() { i.value == value } else { allowed(&i.children, value) })
|
||||
}
|
||||
/// Parse and display a bounded popup, returning null on cancel and refusing invalid host choices.
|
||||
pub(crate) fn popup_menu(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
||||
let mut remaining = 1024;
|
||||
let mut items = Vec::new();
|
||||
for a in args {
|
||||
items.push(read_menu(a, ctx, 0, &mut remaining)?);
|
||||
}
|
||||
match ask(Prompt::Menu(items.clone()))? {
|
||||
Some(answer) if allowed(&items, &answer) => Ok(s(&answer)),
|
||||
Some(_) => Err(error("invalid popup menu selection")),
|
||||
None => Ok(JsValue::null()),
|
||||
}
|
||||
}
|
||||
/// Read named or positional input parameters and return bounded text or null on cancellation.
|
||||
pub(crate) fn response(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
||||
let a = arg(args, 0);
|
||||
let (question, title, default, password, label) = if let Some(o) = a.as_object() {
|
||||
(
|
||||
response_text(&o.get(js_string!("cQuestion"), ctx)?, ctx)?,
|
||||
response_text(&o.get(js_string!("cTitle"), ctx)?, ctx)?,
|
||||
response_text(&o.get(js_string!("cDefault"), ctx)?, ctx)?,
|
||||
o.get(js_string!("bPassword"), ctx)?.to_boolean(),
|
||||
response_text(&o.get(js_string!("cLabel"), ctx)?, ctx)?,
|
||||
)
|
||||
} else {
|
||||
(
|
||||
response_text(&a, ctx)?,
|
||||
response_text(&arg(args, 1), ctx)?,
|
||||
response_text(&arg(args, 2), ctx)?,
|
||||
arg(args, 3).to_boolean(),
|
||||
response_text(&arg(args, 4), ctx)?,
|
||||
)
|
||||
};
|
||||
Ok(ask(Prompt::Text { question, title, default, password, label })?.map_or_else(JsValue::null, |v| s(&v)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod budget_tests {
|
||||
use super::*;
|
||||
/// Reject an oversized question before evaluating or copying any later property.
|
||||
#[test]
|
||||
fn response_refuses_text_before_reading_later_properties() {
|
||||
let mut ctx = Context::default();
|
||||
let input = ctx.eval(crate::Source::from_bytes("({cQuestion:'x'.repeat(4097),get cTitle(){throw Error('later property read');}})")).unwrap();
|
||||
let error = response(&JsValue::undefined(), &[input], &mut ctx).unwrap_err().to_string();
|
||||
assert!(error.contains("response dialog text exceeds its limit"), "{error}");
|
||||
assert!(!error.contains("later property"));
|
||||
}
|
||||
/// Menu labels must fail before an oversized return value's getter is invoked.
|
||||
#[test]
|
||||
fn menu_refuses_name_before_reading_return_value() {
|
||||
let mut ctx = Context::default();
|
||||
let input = ctx.eval(crate::Source::from_bytes("({cName:'x'.repeat(4097),get cReturn(){throw Error('later property read');}})")).unwrap();
|
||||
let mut remaining = 1024;
|
||||
let error = read_menu(&input, &mut ctx, 0, &mut remaining).unwrap_err().to_string();
|
||||
assert!(error.contains("popup menu text exceeds its limit"), "{error}");
|
||||
assert!(!error.contains("later property"));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod text_boundary_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn prompt_conversions_preserve_exact_unicode_and_user_exceptions() {
|
||||
let mut ctx = Context::default();
|
||||
for (unit, count) in [("a", 4096), ("é", 2048), ("😀", 1024)] {
|
||||
let text = unit.repeat(count);
|
||||
let value = s(&text);
|
||||
assert_eq!(menu_text(&value, &mut ctx).unwrap(), text);
|
||||
assert_eq!(response_text(&value, &mut ctx).unwrap(), text);
|
||||
assert!(menu_text(&s(&(text.clone() + "x")), &mut ctx).is_err());
|
||||
assert!(response_text(&s(&(text + "x")), &mut ctx).is_err());
|
||||
}
|
||||
let surrogate = ctx.eval(crate::Source::from_bytes("'\\uD800'.repeat(682)+'xxxx'")).unwrap();
|
||||
assert_eq!(response_text(&surrogate, &mut ctx).unwrap().len(), 4096);
|
||||
let too_many = ctx.eval(crate::Source::from_bytes("'\\uD800'.repeat(683)")).unwrap();
|
||||
assert!(response_text(&too_many, &mut ctx).is_err());
|
||||
let throwing = ctx.eval(crate::Source::from_bytes("({toString(){throw Error('original conversion failure');}})")).unwrap();
|
||||
assert!(response_text(&throwing, &mut ctx).unwrap_err().to_string().contains("original conversion failure"));
|
||||
assert_eq!(response_text(&JsValue::null(), &mut ctx).unwrap(), "");
|
||||
}
|
||||
#[test]
|
||||
fn all_prompt_text_properties_and_positional_arguments_are_bounded() {
|
||||
let mut ctx = Context::default();
|
||||
for key in ["cQuestion", "cTitle", "cDefault", "cLabel"] {
|
||||
let script = format!("({{{key}:'x'.repeat(4097)}})");
|
||||
let input = ctx.eval(crate::Source::from_bytes(script.as_bytes())).unwrap();
|
||||
assert!(response(&JsValue::undefined(), &[input], &mut ctx).unwrap_err().to_string().contains("response dialog text exceeds its limit"));
|
||||
}
|
||||
for index in [0, 1, 2, 4] {
|
||||
let mut args = vec![JsValue::undefined(); 5];
|
||||
args[index] = s(&"x".repeat(4097));
|
||||
assert!(response(&JsValue::undefined(), &args, &mut ctx).is_err());
|
||||
}
|
||||
let input = ctx.eval(crate::Source::from_bytes("({cName:'ok',cReturn:'x'.repeat(4097)})")).unwrap();
|
||||
assert!(read_menu(&input, &mut ctx, 0, &mut 1024).is_err());
|
||||
// Four independently valid text properties remain supported; no new shared 4096-byte cap.
|
||||
let input = ctx
|
||||
.eval(crate::Source::from_bytes(
|
||||
"({cQuestion:'x'.repeat(4096),cTitle:'x'.repeat(4096),cDefault:'x'.repeat(4096),cLabel:'x'.repeat(4096)})",
|
||||
))
|
||||
.unwrap();
|
||||
assert!(response(&JsValue::undefined(), &[input], &mut ctx).unwrap().is_null());
|
||||
}
|
||||
}
|
||||
+16
-2
@@ -12,11 +12,14 @@
|
||||
//! - Field objects: `name`, `type`, `value`, `valueAsString`, `defaultValue`, `readonly`,
|
||||
//! `required`, `display`, `hidden`, `numItems`, `getItemAt`, `currentValueIndices`,
|
||||
//! `isBoxChecked`, `checkThisBox`, `exportValues`, `charLimit`, `textColor`, `fillColor`,
|
||||
//! `setFocus`.
|
||||
//! `setFocus`, `rect`, appearance properties, and normal button caption/icon methods.
|
||||
//! - `app` (`alert`, `beep`, `response`, `launchURL`, `execMenuItem`, `viewerType`, `viewerVersion`,
|
||||
//! `platform`, `setTimeOut`), `util` (`printf`, `printd`, `printx`, `scand`), `console` (`println`,
|
||||
//! `show`, `clear`), `display` and `color`.
|
||||
//!
|
||||
//! The native engine uses [`session::ScriptSession`] to preserve document globals across events.
|
||||
//! Stateless runners remain available for callers that need isolation per invocation.
|
||||
//!
|
||||
//! The engine is a sandbox: no file, network or timer access exists, and loops and recursion
|
||||
//! are bounded. Side effects (alerts, field changes, resets, printing, navigation) are returned
|
||||
//! in an [`Outcome`] for the caller to apply.
|
||||
@@ -57,7 +60,7 @@ pub use model::{
|
||||
use run::SCRIPT_STACK;
|
||||
use run::refuse;
|
||||
pub(crate) use run::{CheckedCodeFromStrings, MAX_SCRIPT_BYTES};
|
||||
pub use run::{run, run_shared, run_shared_calculations, run_shared_calculations_shared};
|
||||
pub use run::{run, run_shared, run_shared_calculations};
|
||||
|
||||
pub mod formcalc;
|
||||
pub mod xfa;
|
||||
@@ -65,3 +68,14 @@ pub mod xfa_session;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
mod geometry;
|
||||
pub use geometry::{rotate_rect, unrotate_rect};
|
||||
|
||||
mod icons;
|
||||
|
||||
pub mod interaction;
|
||||
pub mod session;
|
||||
|
||||
/// Maximum reset calls admitted by one document invocation, before argument conversion or field scans.
|
||||
pub const MAX_RESET_CALLS: usize = 64;
|
||||
+78
-12
@@ -37,11 +37,17 @@ pub const DISPLAY_NO_VIEW: i32 = 3;
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct FieldState {
|
||||
pub name: String,
|
||||
/// Stable PDF object identity, absent for synthetic name-only callers.
|
||||
pub object_id: Option<u64>,
|
||||
/// Runtime instance token; retired field references cannot alias reused PDF numbers.
|
||||
pub instance_id: u64,
|
||||
pub kind: FieldType,
|
||||
/// Text: the text. Check box / radio: the selected export value (empty when off). Choice:
|
||||
/// the selected export values.
|
||||
pub value: Vec<String>,
|
||||
pub default: Vec<String>,
|
||||
/// False when a PDF default stream was not decoded; reset must preserve the live value.
|
||||
pub default_known: bool,
|
||||
pub readonly: bool,
|
||||
pub required: bool,
|
||||
pub display: i32,
|
||||
@@ -50,23 +56,37 @@ pub struct FieldState {
|
||||
pub options: Vec<(String, String)>,
|
||||
pub char_limit: Option<usize>,
|
||||
/// `textColor` / `fillColor` as Acrobat colour arrays (`["RGB", r, g, b]` etc.), when set.
|
||||
pub text_color: Option<Vec<String>>,
|
||||
pub fill_color: Option<Vec<String>>,
|
||||
pub text_color: Option<std::sync::Arc<[String]>>,
|
||||
pub fill_color: Option<std::sync::Arc<[String]>>,
|
||||
/// Normal push-button caption, when this is a button.
|
||||
pub caption: Option<String>,
|
||||
/// `buttonSetCaption` was called, even when it set the current text again.
|
||||
pub caption_was_set: bool,
|
||||
/// Rectangle of the first widget, in PDF user space.
|
||||
/// A setter ran, even if its text equals widget 0: other widgets may differ.
|
||||
pub caption_written: bool,
|
||||
pub rect: Option<[f64; 4]>,
|
||||
pub page_rotation: i64,
|
||||
pub page_crop: [f64; 4],
|
||||
pub stroke_color: Option<std::sync::Arc<[String]>>,
|
||||
pub text_size: f64,
|
||||
pub line_width: f64,
|
||||
pub border_style: String,
|
||||
pub user_name: String,
|
||||
pub icon: Option<u64>,
|
||||
pub caption_mixed: bool,
|
||||
pub icon_mixed: bool,
|
||||
pub icon_written: bool,
|
||||
}
|
||||
|
||||
impl FieldState {
|
||||
/// Construct a field snapshot with explicit defaults for optional appearance and interaction state.
|
||||
pub fn new(name: impl Into<String>, kind: FieldType, value: Vec<String>) -> FieldState {
|
||||
FieldState {
|
||||
name: name.into(),
|
||||
object_id: None,
|
||||
instance_id: 0,
|
||||
kind,
|
||||
value,
|
||||
default: Vec::new(),
|
||||
default_known: true,
|
||||
readonly: false,
|
||||
required: false,
|
||||
display: DISPLAY_VISIBLE,
|
||||
@@ -75,8 +95,19 @@ impl FieldState {
|
||||
text_color: None,
|
||||
fill_color: None,
|
||||
caption: None,
|
||||
caption_was_set: false,
|
||||
caption_written: false,
|
||||
rect: None,
|
||||
page_rotation: 0,
|
||||
page_crop: [0.0; 4],
|
||||
stroke_color: None,
|
||||
text_size: 0.0,
|
||||
line_width: 1.0,
|
||||
border_style: "solid".into(),
|
||||
user_name: String::new(),
|
||||
icon: None,
|
||||
caption_mixed: false,
|
||||
icon_mixed: false,
|
||||
icon_written: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,6 +125,8 @@ pub struct Event {
|
||||
pub kind: String,
|
||||
/// The field the event is for.
|
||||
pub target: Option<String>,
|
||||
/// Identity for an unambiguous event.target, even with duplicate field names.
|
||||
pub target_id: Option<u64>,
|
||||
pub value: String,
|
||||
pub change: String,
|
||||
pub will_commit: bool,
|
||||
@@ -102,18 +135,38 @@ pub struct Event {
|
||||
impl Event {
|
||||
/// A field event (`event.type` = "Field").
|
||||
pub fn field(name: &str, target: &str, value: &str) -> Event {
|
||||
Event { name: name.into(), kind: "Field".into(), target: Some(target.into()), value: value.into(), change: String::new(), will_commit: true }
|
||||
Event {
|
||||
name: name.into(),
|
||||
kind: "Field".into(),
|
||||
target: Some(target.into()),
|
||||
target_id: None,
|
||||
value: value.into(),
|
||||
change: String::new(),
|
||||
will_commit: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// A document event (`event.type` = "Doc"), e.g. Open or WillSave.
|
||||
pub fn doc(name: &str) -> Event {
|
||||
Event { name: name.into(), kind: "Doc".into(), target: None, value: String::new(), change: String::new(), will_commit: false }
|
||||
Event {
|
||||
name: name.into(),
|
||||
kind: "Doc".into(),
|
||||
target: None,
|
||||
target_id: None,
|
||||
value: String::new(),
|
||||
change: String::new(),
|
||||
will_commit: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// About the document, for `this.numPages`, `this.info` and the like.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct DocInfo {
|
||||
/// Changes when history can replace an icon object under the same PDF reference.
|
||||
pub icon_epoch: u64,
|
||||
/// Revisions of referenced icons that can change independently across history.
|
||||
pub icon_versions: std::sync::Arc<std::collections::BTreeMap<u64, u64>>,
|
||||
pub file_name: String,
|
||||
pub num_pages: usize,
|
||||
/// The current page, 0-based.
|
||||
@@ -124,10 +177,10 @@ pub struct DocInfo {
|
||||
pub page_boxes: Vec<[[f64; 4]; 5]>,
|
||||
/// Clockwise page rotations, normalized to 0, 90, 180 or 270 degrees.
|
||||
pub page_rotations: Vec<i64>,
|
||||
/// Whether the viewer highlights form fields (`app.runtimeHighlight`).
|
||||
pub highlight: bool,
|
||||
/// The document has an XFA form, so scripts see `xfa_installed`.
|
||||
pub xfa: bool,
|
||||
pub zoom: f64,
|
||||
pub runtime_highlight: bool,
|
||||
}
|
||||
|
||||
/// The icon a script's message shows: `app.alert`'s `nIcon` and `xfa.host.messageBox`'s
|
||||
@@ -248,7 +301,14 @@ pub enum Request {
|
||||
Submit(String),
|
||||
/// `field.setFocus()`.
|
||||
Focus(String),
|
||||
/// Focus a specific AcroForm field when several fields share its name.
|
||||
FocusField {
|
||||
name: String,
|
||||
object_id: u64,
|
||||
},
|
||||
Beep,
|
||||
Zoom(f64),
|
||||
Highlight(bool),
|
||||
/// `app.execMenuItem("SaveAs")` (XFA forms' Save buttons).
|
||||
SaveAs,
|
||||
/// `this.exportDataObject(cName, …, nLaunch)`: the attached file `name`, to open (`nLaunch`
|
||||
@@ -257,8 +317,6 @@ pub enum Request {
|
||||
name: String,
|
||||
open: bool,
|
||||
},
|
||||
/// `app.runtimeHighlight = on`: highlight the form's fields, or stop.
|
||||
Highlight(bool),
|
||||
/// `app.execMenuItem("ShowHide…")`: show the side panel, or hide it when it is showing.
|
||||
ShowPanel(Panel),
|
||||
}
|
||||
@@ -294,6 +352,14 @@ impl Panel {
|
||||
/// What running a script did.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Outcome {
|
||||
/// The runtime could not execute the callback safely.
|
||||
pub execution_refused: bool,
|
||||
/// Execution was refused before invoking any code in the retained realm.
|
||||
pub realm_untouched: bool,
|
||||
/// Sticky admission failure: callers must not commit a partial run, even if JS caught the error.
|
||||
pub reset_limit_exceeded: bool,
|
||||
/// Calculation exhaustion rejects script writeback even if the script catches the error.
|
||||
pub calculation_limit_exceeded: bool,
|
||||
/// `event.rc`: `false` rejects the keystroke or value.
|
||||
pub rc: bool,
|
||||
/// `event.value` after the script.
|
||||
|
||||
+248
-62
@@ -1,7 +1,7 @@
|
||||
use super::globals::{event_object, install};
|
||||
use super::host::{Host, Shared, text};
|
||||
use super::host::{Host, Shared, host, text};
|
||||
use super::model::{DocInfo, Event, FieldState, Limits, Outcome};
|
||||
use super::{Arc, Attribute, BTreeSet, Context, HostHooks, JsNativeError, JsResult, JsString, Realm, RefCell, Source, js_string};
|
||||
use super::{Arc, BTreeSet, Context, HostHooks, JsNativeError, JsResult, JsString, JsValue, NativeFunction, Realm, RefCell, Source, js_string};
|
||||
|
||||
/// Longest script, in bytes.
|
||||
pub(crate) const MAX_SCRIPT_BYTES: usize = 256 * 1024;
|
||||
@@ -19,17 +19,48 @@ pub(crate) const MAX_ARRAY_LENGTH: u64 = 1 << 20;
|
||||
/// Maximum calculated field scripts evaluated by `calculateNow()` during one JavaScript run.
|
||||
pub(crate) const MAX_CALCULATE_STEPS: usize = 4096;
|
||||
|
||||
// The retained document realm also bounds the chains boa's parser descends without brackets
|
||||
// (`preflight`); bracket, prefix and conditional limits are the nesting guard's.
|
||||
use crate::nesting::{MAX_BRACKET_DEPTH, MAX_CONDITIONALS, MAX_PREFIX_RUN};
|
||||
|
||||
/// Longest active arrow-function chain across the currently open bracket levels.
|
||||
const MAX_ARROW_FUNCTIONS: usize = 256;
|
||||
|
||||
/// Assignment and exponentiation associate right-to-left without increasing bracket depth.
|
||||
/// Bound active chains, rather than the total operators in independent statements.
|
||||
const MAX_RIGHT_ASSOCIATIVE_OPERATORS: usize = 4096;
|
||||
|
||||
/// The script thread's stack.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) const SCRIPT_STACK: usize = 1 << 30;
|
||||
|
||||
/// Persistent document-worker stack, bounded separately from transient legacy runners.
|
||||
/// Parser/compiler boundary and hostile-input regressions run through the real worker.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) const DOCUMENT_WORKER_STACK: usize = 64 << 20;
|
||||
|
||||
mod preflight;
|
||||
|
||||
/// Why `source` is refused, if it is: too long, or nested deeper than any form needs, which
|
||||
/// would overflow the stack in boa's parser ([`crate::nesting`]).
|
||||
/// would overflow the stack in boa's parser ([`crate::nesting`]), or with an arrow-function or
|
||||
/// right-associative chain longer than the document worker takes ([`preflight`]).
|
||||
pub(crate) fn refuse(source: &str) -> Option<String> {
|
||||
if source.len() > MAX_SCRIPT_BYTES {
|
||||
return Some(format!("the script is too long ({} KiB; the limit is {} KiB)", source.len() / 1024, MAX_SCRIPT_BYTES / 1024));
|
||||
}
|
||||
crate::nesting::too_deep(source).then(|| "the script is nested too deeply to run safely".to_string())
|
||||
if crate::nesting::too_deep(source) {
|
||||
return Some("the script is nested too deeply to run safely".to_string());
|
||||
}
|
||||
// A leading `#!` line is a comment (the nesting guard reads it so); blank it for the chain
|
||||
// scan, keeping its length.
|
||||
match source.strip_prefix("#!") {
|
||||
Some(rest) => {
|
||||
let end = rest.find(['\n', '\r', '\u{2028}', '\u{2029}']).map_or(source.len(), |i| i + 2);
|
||||
let blanked: String = source[..end].chars().map(|_| ' ').chain(source[end..].chars()).collect();
|
||||
preflight::refuse(&blanked)
|
||||
}
|
||||
None => preflight::refuse(source),
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `script` for `event`, after the document-level scripts (`doc_scripts`, which define the
|
||||
@@ -42,31 +73,48 @@ pub fn run(script: &str, event: &Event, doc: &DocInfo, fields: &[FieldState], do
|
||||
/// context; the first field write copies the snapshot, so neither the caller nor another
|
||||
/// script can observe uncommitted writes. Read-only calculations avoid copying the whole form.
|
||||
pub fn run_shared(script: &str, event: &Event, doc: &DocInfo, fields: Arc<Vec<FieldState>>, doc_scripts: &[String], limits: Limits) -> Outcome {
|
||||
run_shared_calculations(script, event, doc, fields, doc_scripts, &Arc::new(Vec::new()), limits)
|
||||
run_shared_calculations(script, event, doc, fields, doc_scripts, &[], limits)
|
||||
}
|
||||
|
||||
/// Like [`run_shared`], with the document's ordered calculated fields available to
|
||||
/// `calculateNow()` while this context is running.
|
||||
/// `calculateNow()` while this context is running. Each tuple contains the target
|
||||
/// position in `fields` and its source; the engine resolves positions by PDF object identity.
|
||||
pub fn run_shared_calculations(
|
||||
script: &str,
|
||||
event: &Event,
|
||||
doc: &DocInfo,
|
||||
fields: Arc<Vec<FieldState>>,
|
||||
doc_scripts: &[String],
|
||||
calculations: &[(String, String)],
|
||||
calculations: &[(usize, String)],
|
||||
limits: Limits,
|
||||
) -> Outcome {
|
||||
run_shared_calculations_shared(script, event, doc, fields, doc_scripts, &Arc::new(calculations.to_vec()), limits)
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let english = crate::format::printd_english();
|
||||
let failed =
|
||||
|why: String| Outcome { rc: true, value: event.value.clone(), change: event.change.clone(), error: Some(why), ..Default::default() };
|
||||
std::thread::scope(|scope| {
|
||||
let spawned = std::thread::Builder::new().name("pdfcraft-js".into()).stack_size(SCRIPT_STACK).spawn_scoped(scope, || {
|
||||
crate::format::with_english_printd(english, || run_shared_here(script, event, doc, fields, doc_scripts, calculations, limits))
|
||||
});
|
||||
match spawned {
|
||||
Ok(thread) => thread.join().unwrap_or_else(|_| failed("the script stopped with an internal error".into())),
|
||||
Err(e) => failed(format!("the script engine could not start: {e}")),
|
||||
}
|
||||
})
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
run_shared_here(script, event, doc, fields, doc_scripts, calculations, limits)
|
||||
}
|
||||
|
||||
/// Like [`run_shared_calculations`], sharing its ordered calculation scripts with the context.
|
||||
pub fn run_shared_calculations_shared(
|
||||
/// Run a snapshot in a fresh context on the calling thread, skipping document scripts that fail source preflight.
|
||||
pub(crate) fn run_shared_here(
|
||||
script: &str,
|
||||
event: &Event,
|
||||
doc: &DocInfo,
|
||||
fields: Arc<Vec<FieldState>>,
|
||||
doc_scripts: &[String],
|
||||
calculations: &Arc<Vec<(String, String)>>,
|
||||
calculations: &[(usize, String)],
|
||||
limits: Limits,
|
||||
) -> Outcome {
|
||||
let failed = |why: String| Outcome { rc: true, value: event.value.clone(), change: event.change.clone(), error: Some(why), ..Default::default() };
|
||||
@@ -87,31 +135,28 @@ pub fn run_shared_calculations_shared(
|
||||
None => Some((i, d.as_str())),
|
||||
})
|
||||
.collect();
|
||||
let (kept, skipped) = (&kept, &skipped);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
// The script's thread writes dates as this one asked (`with_english_printd`).
|
||||
let english = crate::format::printd_english();
|
||||
std::thread::scope(|scope| {
|
||||
let spawned = std::thread::Builder::new().name("pdfcraft-js".into()).stack_size(SCRIPT_STACK).spawn_scoped(scope, || {
|
||||
crate::format::with_english_printd(english, || {
|
||||
run_here(script, event, doc, fields, ScriptSetup { doc_scripts: kept, skipped, calculations }, limits)
|
||||
})
|
||||
});
|
||||
match spawned {
|
||||
Ok(thread) => thread.join().unwrap_or_else(|_| failed("the script stopped with an internal error".into())),
|
||||
Err(e) => failed(format!("the script engine could not start: {e}")),
|
||||
}
|
||||
})
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
run_here(script, event, doc, fields, ScriptSetup { doc_scripts: kept, skipped, calculations }, limits)
|
||||
run_here(
|
||||
script,
|
||||
event,
|
||||
doc,
|
||||
fields,
|
||||
ScriptSetup {
|
||||
doc_scripts: &kept,
|
||||
skipped: &skipped,
|
||||
calculations,
|
||||
initialize: true,
|
||||
reset_calls: 0,
|
||||
calculation_evaluations: 0,
|
||||
calculation_limit_exceeded: false,
|
||||
},
|
||||
limits,
|
||||
)
|
||||
}
|
||||
|
||||
/// Host hooks that refuse to compile code from strings (`eval`, `Function` and its generator
|
||||
/// and async kin). Such code is built at run time, so it never passed [`refuse`], and boa's
|
||||
/// recursive parser would overflow the stack on a deeply nested one.
|
||||
struct NoCodeFromStrings;
|
||||
pub(crate) struct NoCodeFromStrings;
|
||||
|
||||
impl HostHooks for NoCodeFromStrings {
|
||||
fn ensure_can_compile_strings(&self, _: Realm, _: &[JsString], _: &JsString, _: bool, _: &mut Context) -> JsResult<()> {
|
||||
@@ -147,46 +192,105 @@ fn doc_error(index: usize, error: &dyn std::fmt::Display) -> String {
|
||||
|
||||
/// `doc_scripts` are the document scripts to run with their places among all of them;
|
||||
/// `skipped` the ones refused before running, with the reason.
|
||||
struct ScriptSetup<'a> {
|
||||
doc_scripts: &'a [(usize, &'a str)],
|
||||
skipped: &'a [(usize, String)],
|
||||
calculations: &'a Arc<Vec<(String, String)>>,
|
||||
pub(crate) struct ScriptSetup<'a> {
|
||||
pub(crate) doc_scripts: &'a [(usize, &'a str)],
|
||||
pub(crate) skipped: &'a [(usize, String)],
|
||||
pub(crate) calculations: &'a [(usize, String)],
|
||||
pub(crate) initialize: bool,
|
||||
pub(crate) reset_calls: usize,
|
||||
pub(crate) calculation_evaluations: usize,
|
||||
pub(crate) calculation_limit_exceeded: bool,
|
||||
}
|
||||
|
||||
/// Create a restricted temporary Boa context and convert initialization failures into script outcomes.
|
||||
fn run_here(script: &str, event: &Event, doc: &DocInfo, fields: Arc<Vec<FieldState>>, setup: ScriptSetup<'_>, limits: Limits) -> Outcome {
|
||||
let mut ctx = match Context::builder().host_hooks(std::rc::Rc::new(NoCodeFromStrings)).build() {
|
||||
Ok(ctx) => ctx,
|
||||
Err(e) => {
|
||||
let error = Some(format!("the script engine could not start: {e}"));
|
||||
return Outcome { rc: true, value: event.value.clone(), change: event.change.clone(), error, ..Default::default() };
|
||||
}
|
||||
};
|
||||
match Context::builder().host_hooks(std::rc::Rc::new(NoCodeFromStrings)).build() {
|
||||
Ok(mut ctx) => run_in_context(&mut ctx, script, event, doc, fields, setup, limits),
|
||||
Err(e) => Outcome { rc: true, error: Some(format!("the script engine could not start: {e}")), ..Default::default() },
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace per-event host state, optionally initialize document scripts, and collect results within runtime limits.
|
||||
/// Drop the host's field snapshot once a job is over, keeping the rest of the retained host.
|
||||
pub(crate) fn release_fields(ctx: &Context) {
|
||||
if let Ok(h) = host(ctx) {
|
||||
h.borrow_mut().fields = Arc::new(Vec::new());
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn run_in_context(
|
||||
ctx: &mut Context,
|
||||
script: &str,
|
||||
event: &Event,
|
||||
doc: &DocInfo,
|
||||
fields: Arc<Vec<FieldState>>,
|
||||
setup: ScriptSetup<'_>,
|
||||
limits: Limits,
|
||||
) -> Outcome {
|
||||
ctx.runtime_limits_mut().set_loop_iteration_limit(limits.loop_iterations);
|
||||
ctx.runtime_limits_mut().set_recursion_limit(limits.recursion);
|
||||
let (icons, mut objects, cached_keys) = ctx
|
||||
.remove_data::<Shared>()
|
||||
.map(|h| {
|
||||
let h = h.into_inner();
|
||||
let mut icons = h.icons;
|
||||
if h.doc.icon_epoch == doc.icon_epoch {
|
||||
icons.retain(|(_, id)| h.doc.icon_versions.get(id) == doc.icon_versions.get(id));
|
||||
} else {
|
||||
icons.clear();
|
||||
}
|
||||
// The field identities are those of the previous job when every current key is one
|
||||
// of its keys and there are as many (the previous snapshot itself is not retained).
|
||||
let same_fields = h.field_keys.len() == fields.len()
|
||||
&& fields.iter().all(|f| h.field_keys.contains(&crate::host::field_cache_key(&f.name, f.object_id, f.instance_id)));
|
||||
(icons, h.objects, same_fields.then_some(h.field_keys))
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let field_keys =
|
||||
cached_keys.unwrap_or_else(|| fields.iter().map(|f| crate::host::field_cache_key(&f.name, f.object_id, f.instance_id)).collect());
|
||||
objects.retain(|key, _| field_keys.contains(key));
|
||||
ctx.insert_data(RefCell::new(Host {
|
||||
event_name: event.name.clone(),
|
||||
fields,
|
||||
icons,
|
||||
objects,
|
||||
field_keys,
|
||||
changed: BTreeSet::new(),
|
||||
formatted: BTreeSet::new(),
|
||||
alerts: Vec::new(),
|
||||
console: Vec::new(),
|
||||
requests: Vec::new(),
|
||||
doc: doc.clone(),
|
||||
calculations: Arc::clone(setup.calculations),
|
||||
calculations: setup.calculations.to_vec(),
|
||||
calculate_depth: 0,
|
||||
calculate_steps: 0,
|
||||
calculation_evaluations: setup.calculation_evaluations,
|
||||
calculation_limit_exceeded: setup.calculation_limit_exceeded,
|
||||
calculation_work: 0,
|
||||
current_event: JsValue::undefined(),
|
||||
reset_calls: setup.reset_calls,
|
||||
reset_limit_exceeded: false,
|
||||
kept: 0,
|
||||
dropped: 0,
|
||||
}));
|
||||
let mut out = Outcome { rc: true, value: event.value.clone(), change: event.change.clone(), ..Default::default() };
|
||||
let mut doc_errors: Vec<(usize, String)> = setup.skipped.iter().map(|(i, why)| (*i, doc_error(*i, why))).collect();
|
||||
let result = (|| -> JsResult<()> {
|
||||
install(&mut ctx)?;
|
||||
let (ev, value_store) = event_object(&mut ctx, event)?;
|
||||
ctx.register_global_property(js_string!("event"), ev.clone(), Attribute::all())?;
|
||||
// One document script that throws (calling a method PdfCraft lacks, say) must not keep
|
||||
// every field script in the form from running: what it defined before the error, and
|
||||
// the other scripts, stay available (#3940).
|
||||
if setup.initialize {
|
||||
install(ctx)?;
|
||||
}
|
||||
let (ev, value_store) = event_object(ctx, event)?;
|
||||
host(ctx)?.borrow_mut().current_event = ev.clone().into();
|
||||
if setup.initialize {
|
||||
let get = crate::host::function(ctx, NativeFunction::from_fn_ptr(get_event));
|
||||
let set = crate::host::function(ctx, NativeFunction::from_fn_ptr(set_event));
|
||||
ctx.global_object().define_property_or_throw(
|
||||
js_string!("event"),
|
||||
boa_engine::property::PropertyDescriptor::builder().get(get).set(set).enumerable(true).configurable(false),
|
||||
ctx,
|
||||
)?;
|
||||
}
|
||||
// An initializer that throws or reaches a VM limit must not block later scripts.
|
||||
// Definitions it made before the error stay available to field scripts (#3940).
|
||||
for &(i, d) in setup.doc_scripts {
|
||||
if let Err(e) = ctx.eval(Source::from_bytes(d)) {
|
||||
doc_errors.push((i, doc_error(i, &e)));
|
||||
@@ -194,32 +298,114 @@ fn run_here(script: &str, event: &Event, doc: &DocInfo, fields: Arc<Vec<FieldSta
|
||||
}
|
||||
let r = ctx.eval(Source::from_bytes(script))?;
|
||||
if !r.is_undefined() {
|
||||
out.result = Some(text(&r, &mut ctx)?);
|
||||
out.result = Some(text(&r, ctx)?);
|
||||
}
|
||||
let rc = ev.get(js_string!("rc"), &mut ctx)?;
|
||||
let rc = ev.get(js_string!("rc"), ctx)?;
|
||||
out.rc = rc.to_boolean();
|
||||
let v = ev.get(js_string!("value"), &mut ctx)?;
|
||||
out.value = text(&v, &mut ctx)?;
|
||||
out.value_set = value_store.get(js_string!("set"), &mut ctx)?.to_boolean();
|
||||
let c = ev.get(js_string!("change"), &mut ctx)?;
|
||||
out.change = text(&c, &mut ctx)?;
|
||||
let v = ev.get(js_string!("value"), ctx)?;
|
||||
out.value = text(&v, ctx)?;
|
||||
out.value_set = value_store.get(js_string!("set"), ctx)?.to_boolean();
|
||||
let c = ev.get(js_string!("change"), ctx)?;
|
||||
out.change = text(&c, ctx)?;
|
||||
Ok(())
|
||||
})();
|
||||
if let Err(e) = result {
|
||||
out.execution_refused = matches!(e.as_engine(), Some(boa_engine::error::EngineError::RuntimeLimit(_)));
|
||||
out.error = Some(e.to_string());
|
||||
}
|
||||
doc_errors.sort_by_key(|(i, _)| *i);
|
||||
out.doc_errors = doc_errors.into_iter().map(|(_, e)| e).collect();
|
||||
if let Some(h) = ctx.remove_data::<Shared>() {
|
||||
let h = h.into_inner();
|
||||
out.changed = h.changed.iter().map(|i| h.fields[*i].clone()).collect();
|
||||
let mut h = h.into_inner();
|
||||
out.reset_limit_exceeded = h.reset_limit_exceeded;
|
||||
out.calculation_limit_exceeded = h.calculation_limit_exceeded;
|
||||
if h.calculation_limit_exceeded && out.error.is_none() {
|
||||
out.error = Some("script calculation work limit exceeded".into());
|
||||
}
|
||||
out.changed = h.changed.iter().filter_map(|i| h.fields.get(*i).cloned()).collect();
|
||||
out.formatted = h.formatted.iter().filter_map(|&i| h.fields.get(i).map(|f| f.name.clone())).collect();
|
||||
out.alerts = h.alerts;
|
||||
out.console = h.console;
|
||||
out.requests = h.requests;
|
||||
out.alerts = std::mem::take(&mut h.alerts);
|
||||
out.console = std::mem::take(&mut h.console);
|
||||
out.requests = std::mem::take(&mut h.requests);
|
||||
if h.dropped > 0 {
|
||||
out.console.push(format!("the script's messages, console lines and requests passed their limits; {} more were left out", h.dropped));
|
||||
}
|
||||
ctx.insert_data(RefCell::new(h));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod chain_budget_tests {
|
||||
use super::refuse;
|
||||
/// Shift assignments remain right-associative while comparison tokens consume no chain budget.
|
||||
#[test]
|
||||
fn comparisons_and_shift_assignments_are_distinguished() {
|
||||
for token in ["==", "===", "!=", "!==", "<=", ">="] {
|
||||
assert_eq!(refuse(&format!("var a=1;{}true;", format!("if(a{token}1){{;}}").repeat(5000))), None);
|
||||
}
|
||||
for token in ["=", ">>=", ">>>=", "<<=", "**=", "&&=", "||=", "??="] {
|
||||
assert!(refuse(&format!("var a;{}1", format!("a{token}").repeat(5000))).is_some(), "{token}");
|
||||
}
|
||||
}
|
||||
/// Newline boundaries admit flat libraries but never erase a continued expression.
|
||||
#[test]
|
||||
fn newline_continuations_keep_their_chain_budget() {
|
||||
for part in ["a=>\n", "a=\n", "a=function(){}.\nx=", "a=1\n**", "a=1\nin x=", "a=1\ninstanceof x="] {
|
||||
assert!(refuse(&format!("{}1", part.repeat(6000))).is_some(), "{part}");
|
||||
}
|
||||
for newline in ["\n", "\r\n", "/*\n*/", "// comment\n"] {
|
||||
let flat = (0..300).map(|i| format!("const f{i}=()=>1{newline}")).collect::<String>();
|
||||
assert_eq!(refuse(&flat), None, "{newline:?}");
|
||||
}
|
||||
}
|
||||
/// Inner statement separators must not reset a surrounding expression's active chain.
|
||||
#[test]
|
||||
fn inner_separators_do_not_erase_enclosing_chains() {
|
||||
assert!(refuse(&format!("{}1", "a=function(){;}.x=".repeat(3000))).is_some());
|
||||
assert!(refuse(&format!("{}({}1)", "a=>".repeat(200), "b=>".repeat(100))).is_some());
|
||||
assert_eq!(refuse(&format!("var s='{}'; /* {} */ true;", "=>=".repeat(5000), "a=".repeat(5000))), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod literal_chain_tests {
|
||||
use super::refuse;
|
||||
/// Literal semicolons cannot erase a surrounding assignment chain; interpolations remain code.
|
||||
#[test]
|
||||
fn literal_separators_cannot_bypass_chain_guards() {
|
||||
for part in ["a=`;`.x=", "a=/;/.x=", "a=/[;,]/.x="] {
|
||||
assert!(refuse(&format!("{}1", part.repeat(6000))).is_some(), "{part}");
|
||||
}
|
||||
assert!(refuse(&format!("`${{{}1}}`", "a=".repeat(6000))).is_some());
|
||||
assert_eq!(refuse(&format!("var x=`{}${{1+1}}`; true;", "=>;=".repeat(6000))), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod review4_lexical_tests {
|
||||
use super::refuse;
|
||||
/// Function/control blocks may separate statements; array/object/expression contexts may not.
|
||||
#[test]
|
||||
fn block_statements_and_control_regex_literals_are_not_expression_chains() {
|
||||
for head in ["function f(){", "const f=()=>{", "if(true){"] {
|
||||
assert_eq!(refuse(&format!("{head}{} }}", "x=1\n".repeat(6000))), None);
|
||||
}
|
||||
for head in ["if(true)", "while(false)", "for(;false;)", "with({})", "if(false);else", "do"] {
|
||||
assert_eq!(refuse(&format!("{head} /{}/.test('x');", "a=".repeat(6000))), None, "{head}");
|
||||
}
|
||||
for fragment in ["a=({x:1}).x=\n", "a=()=>{x=1\n}.x=\n", "a=(x\n/2).x=\n"] {
|
||||
assert!(refuse(&format!("{}1", fragment.repeat(6000))).is_some(), "{fragment}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the host-owned event binding without redefining a retained global property.
|
||||
fn get_event(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
||||
Ok(host(ctx)?.borrow().current_event.clone())
|
||||
}
|
||||
/// Keep script event assignments and nested calculation restoration compatible with frozen globals.
|
||||
fn set_event(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
||||
host(ctx)?.borrow_mut().current_event = args.first().cloned().unwrap_or_default();
|
||||
Ok(JsValue::undefined())
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
//! Bounded lexical preflight before entering Boa's recursive parser/compiler.
|
||||
use super::{MAX_ARROW_FUNCTIONS, MAX_BRACKET_DEPTH, MAX_CONDITIONALS, MAX_PREFIX_RUN, MAX_RIGHT_ASSOCIATIVE_OPERATORS, MAX_SCRIPT_BYTES};
|
||||
|
||||
/// Active chains live at bracket depths; literal contents never act as statement boundaries.
|
||||
struct Guard<'a> {
|
||||
bytes: &'a [u8],
|
||||
i: usize,
|
||||
depth: usize,
|
||||
levels: [[usize; 4]; MAX_BRACKET_DEPTH + 1],
|
||||
template_returns: [bool; MAX_BRACKET_DEPTH + 1],
|
||||
template_text: bool,
|
||||
operand: bool,
|
||||
property: bool,
|
||||
prefix: usize,
|
||||
line_break: bool,
|
||||
blocks: [bool; MAX_BRACKET_DEPTH + 1],
|
||||
controls: [bool; MAX_BRACKET_DEPTH + 1],
|
||||
control: bool,
|
||||
block_expected: bool,
|
||||
}
|
||||
impl<'a> Guard<'a> {
|
||||
/// Allocate fixed-size nesting state; no input controls an allocation size here.
|
||||
fn new(bytes: &'a [u8]) -> Self {
|
||||
Self {
|
||||
bytes,
|
||||
i: 0,
|
||||
depth: 0,
|
||||
levels: [[0; 4]; MAX_BRACKET_DEPTH + 1],
|
||||
template_returns: [false; MAX_BRACKET_DEPTH + 1],
|
||||
template_text: false,
|
||||
operand: true,
|
||||
property: false,
|
||||
prefix: 0,
|
||||
line_break: false,
|
||||
blocks: [false; MAX_BRACKET_DEPTH + 1],
|
||||
controls: [false; MAX_BRACKET_DEPTH + 1],
|
||||
control: false,
|
||||
block_expected: true,
|
||||
}
|
||||
}
|
||||
/// Consume one exact ASCII byte when present.
|
||||
fn eat(&mut self, c: u8) -> bool {
|
||||
if self.bytes.get(self.i) == Some(&c) {
|
||||
self.i = self.i.saturating_add(1);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
/// Reset token context after a value or an operator; comments and whitespace preserve it.
|
||||
fn context(&mut self, operand: bool) {
|
||||
self.prefix = 0;
|
||||
self.property = false;
|
||||
self.operand = operand;
|
||||
self.control = false;
|
||||
self.block_expected = false;
|
||||
}
|
||||
/// Count active operators across enclosing depths without charging completed sibling statements.
|
||||
fn count(&mut self, kind: usize, limit: usize) -> bool {
|
||||
let Some(n) = self.levels.get_mut(self.depth).and_then(|d| d.get_mut(kind)) else { return false };
|
||||
*n = n.saturating_add(1);
|
||||
self.levels.iter().fold(0usize, |sum, d| sum.saturating_add(d.get(kind).copied().unwrap_or(0))) <= limit
|
||||
}
|
||||
/// Enter a bracket or template interpolation while preserving the enclosing expression's counters.
|
||||
fn open(&mut self, c: u8, template: bool) -> bool {
|
||||
let control = c == b'(' && self.control;
|
||||
let block = c == b'{' && !template && (self.block_expected || !self.operand);
|
||||
self.depth = self.depth.saturating_add(1);
|
||||
let Some(level) = self.levels.get_mut(self.depth) else { return false };
|
||||
*level = [0; 4];
|
||||
if let Some(b) = self.blocks.get_mut(self.depth) {
|
||||
*b = block;
|
||||
}
|
||||
if let Some(p) = self.controls.get_mut(self.depth) {
|
||||
*p = control;
|
||||
}
|
||||
if let Some(back) = self.template_returns.get_mut(self.depth) {
|
||||
*back = template;
|
||||
}
|
||||
self.context(true);
|
||||
self.block_expected = block;
|
||||
true
|
||||
}
|
||||
/// Complete a bracketed expression and resume template text at its interpolation boundary.
|
||||
fn close(&mut self, c: u8) {
|
||||
let control = c == b')' && self.controls.get(self.depth).copied().unwrap_or(false);
|
||||
self.template_text = c == b'}' && self.template_returns.get(self.depth).copied().unwrap_or(false);
|
||||
if let Some(level) = self.levels.get_mut(self.depth) {
|
||||
*level = [0; 4];
|
||||
}
|
||||
if let Some(back) = self.template_returns.get_mut(self.depth) {
|
||||
*back = false;
|
||||
}
|
||||
self.depth = self.depth.saturating_sub(1);
|
||||
self.context(!self.template_text && control);
|
||||
self.block_expected = control;
|
||||
}
|
||||
/// A semicolon/comma ends only this depth's chain, never a surrounding function assignment.
|
||||
fn separator(&mut self) {
|
||||
if let Some(level) = self.levels.get_mut(self.depth) {
|
||||
*level = [0; 4];
|
||||
}
|
||||
self.context(true);
|
||||
self.block_expected = true;
|
||||
}
|
||||
/// Skip an ordinary quoted token, including escaped quote bytes.
|
||||
fn quoted(&mut self, quote: u8) {
|
||||
while let Some(&c) = self.bytes.get(self.i) {
|
||||
self.i = self.i.saturating_add(1);
|
||||
if c == b'\\' {
|
||||
self.i = self.i.saturating_add(1);
|
||||
} else if c == quote || c == b'\n' {
|
||||
break;
|
||||
}
|
||||
}
|
||||
self.context(false);
|
||||
}
|
||||
/// Skip a line comment without changing the preceding token context.
|
||||
fn line_comment(&mut self) {
|
||||
while self.bytes.get(self.i).is_some_and(|c| *c != b'\n') {
|
||||
self.i = self.i.saturating_add(1);
|
||||
}
|
||||
}
|
||||
/// Skip a block comment without treating its delimiters as code.
|
||||
fn block_comment(&mut self) {
|
||||
self.i = self.i.saturating_add(1);
|
||||
while self.bytes.get(self.i..).is_some_and(|rest| !rest.is_empty() && !rest.starts_with(b"*/")) {
|
||||
self.line_break |= matches!(self.bytes.get(self.i), Some(b'\n' | b'\r'));
|
||||
self.i = self.i.saturating_add(1);
|
||||
}
|
||||
self.i = self.i.saturating_add(2);
|
||||
}
|
||||
/// In operand position, a regexp is one literal token; escaped slashes/classes stay inside it.
|
||||
fn regexp(&mut self) {
|
||||
let mut class = false;
|
||||
while let Some(&c) = self.bytes.get(self.i) {
|
||||
self.i = self.i.saturating_add(1);
|
||||
match c {
|
||||
b'\\' => self.i += 1,
|
||||
b'[' => class = true,
|
||||
b']' => class = false,
|
||||
b'/' if !class => break,
|
||||
b'\n' | b'\r' => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
while self.bytes.get(self.i).is_some_and(u8::is_ascii_alphabetic) {
|
||||
self.i = self.i.saturating_add(1);
|
||||
}
|
||||
self.context(false);
|
||||
}
|
||||
/// Template text is literal, but each interpolation re-enters the bounded code scanner.
|
||||
fn template(&mut self, c: u8) -> bool {
|
||||
match c {
|
||||
b'\\' => self.i += 1,
|
||||
b'`' => {
|
||||
self.template_text = false;
|
||||
self.context(false);
|
||||
}
|
||||
b'$' if self.eat(b'{') => {
|
||||
self.template_text = false;
|
||||
return self.open(b'{', true);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
true
|
||||
}
|
||||
/// Prefix recursion is independent from binary expression chains; postfix ++/-- ends a value.
|
||||
fn unary(&mut self, c: u8) -> bool {
|
||||
if matches!(c, b'+' | b'-') && !self.operand && self.eat(c) {
|
||||
self.context(false);
|
||||
return true;
|
||||
}
|
||||
self.prefix = self.prefix.saturating_add(1);
|
||||
self.operand = true;
|
||||
self.property = false;
|
||||
self.prefix <= MAX_PREFIX_RUN
|
||||
}
|
||||
/// Scan a keyword/identifier without treating property names such as .return as keywords.
|
||||
fn word(&mut self) -> bool {
|
||||
let start = self.i.saturating_sub(1);
|
||||
while self.bytes.get(self.i).is_some_and(|c| c.is_ascii_alphanumeric() || matches!(c, b'_' | b'$')) {
|
||||
self.i = self.i.saturating_add(1);
|
||||
}
|
||||
let token = self.bytes.get(start..self.i);
|
||||
// At source level, an identifier after a completed value cannot continue that
|
||||
// expression (except the two binary keyword operators). ASI separates it.
|
||||
// Stay conservative inside brackets and after operators/member access.
|
||||
if (self.depth == 0 || self.blocks.get(self.depth).copied().unwrap_or(false))
|
||||
&& self.line_break
|
||||
&& !self.operand
|
||||
&& !self.property
|
||||
&& !matches!(token, Some(b"in" | b"instanceof"))
|
||||
{
|
||||
self.separator();
|
||||
}
|
||||
self.line_break = false;
|
||||
let unary = !self.property && matches!(token, Some(b"void" | b"typeof" | b"delete" | b"new"));
|
||||
self.operand =
|
||||
!self.property && (unary || matches!(token, Some(b"return" | b"throw" | b"case" | b"yield" | b"await" | b"in" | b"instanceof")));
|
||||
self.control = !self.property && matches!(token, Some(b"if" | b"while" | b"for" | b"with" | b"switch" | b"catch"));
|
||||
self.block_expected = !self.property && matches!(token, Some(b"else" | b"do" | b"try" | b"finally"));
|
||||
self.operand |= self.block_expected;
|
||||
self.property = false;
|
||||
if unary {
|
||||
self.prefix = self.prefix.saturating_add(1);
|
||||
} else {
|
||||
self.prefix = 0;
|
||||
}
|
||||
self.prefix <= MAX_PREFIX_RUN
|
||||
}
|
||||
/// Distinguish assignment, arrow, power and ternary tokens from comparisons and optional chaining.
|
||||
fn operator(&mut self, c: u8) -> bool {
|
||||
self.context(true);
|
||||
let op = match c {
|
||||
b'=' if self.eat(b'>') => {
|
||||
self.block_expected = true;
|
||||
Some((2, MAX_ARROW_FUNCTIONS))
|
||||
}
|
||||
b'=' if self.eat(b'=') => {
|
||||
self.eat(b'=');
|
||||
None
|
||||
}
|
||||
b'=' => Some((0, MAX_RIGHT_ASSOCIATIVE_OPERATORS)),
|
||||
b'!' => {
|
||||
self.eat(b'=');
|
||||
self.eat(b'=');
|
||||
None
|
||||
}
|
||||
b'<' | b'>' => {
|
||||
if self.eat(c) {
|
||||
if c == b'>' {
|
||||
self.eat(c);
|
||||
}
|
||||
self.eat(b'=').then_some((0, MAX_RIGHT_ASSOCIATIVE_OPERATORS))
|
||||
} else {
|
||||
self.eat(b'=');
|
||||
None
|
||||
}
|
||||
}
|
||||
b'*' if self.eat(b'*') => Some((if self.eat(b'=') { 0 } else { 1 }, MAX_RIGHT_ASSOCIATIVE_OPERATORS)),
|
||||
b'?' if self.eat(b'?') => self.eat(b'=').then_some((0, MAX_RIGHT_ASSOCIATIVE_OPERATORS)),
|
||||
b'?' if self.eat(b'.') => {
|
||||
self.property = true;
|
||||
self.operand = false;
|
||||
None
|
||||
}
|
||||
b'?' => Some((3, MAX_CONDITIONALS)),
|
||||
_ => None,
|
||||
};
|
||||
op.is_none_or(|(kind, limit)| self.count(kind, limit))
|
||||
}
|
||||
/// Scan one code token without entering the JavaScript parser.
|
||||
fn token(&mut self, c: u8) -> bool {
|
||||
let comment = c == b'/' && matches!(self.bytes.get(self.i), Some(b'/' | b'*'));
|
||||
if matches!(c, b'\n' | b'\r') {
|
||||
self.line_break = true;
|
||||
} else if !(c.is_ascii_whitespace() || comment || c.is_ascii_alphabetic() || matches!(c, b'_' | b'$')) {
|
||||
self.line_break = false;
|
||||
}
|
||||
match c {
|
||||
b'`' => {
|
||||
self.template_text = true;
|
||||
self.prefix = 0;
|
||||
self.property = false;
|
||||
}
|
||||
b'\'' | b'"' => self.quoted(c),
|
||||
b'/' if self.bytes.get(self.i) == Some(&b'/') => self.line_comment(),
|
||||
b'/' if self.bytes.get(self.i) == Some(&b'*') => self.block_comment(),
|
||||
b'/' if self.operand => self.regexp(),
|
||||
b'(' | b'[' | b'{' => return self.open(c, false),
|
||||
b')' | b']' | b'}' => self.close(c),
|
||||
b';' | b',' => self.separator(),
|
||||
b'!' if self.bytes.get(self.i) == Some(&b'=') => return self.operator(c),
|
||||
b'!' | b'~' | b'+' | b'-' => return self.unary(c),
|
||||
b'=' | b'<' | b'>' | b'*' | b'?' => return self.operator(c),
|
||||
c if c.is_ascii_alphabetic() || matches!(c, b'_' | b'$') => return self.word(),
|
||||
c if c.is_ascii_whitespace() => {}
|
||||
b'.' => {
|
||||
self.context(false);
|
||||
self.property = true;
|
||||
}
|
||||
_ => self.context(matches!(c, b'/' | b'%' | b'&' | b'|' | b'^' | b':')),
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// Enforce source and active nesting budgets before any parser/compiler work is attempted.
|
||||
pub(super) fn refuse(source: &str) -> Option<String> {
|
||||
if source.len() > MAX_SCRIPT_BYTES {
|
||||
return Some(format!("the script is too long ({} KiB; the limit is {} KiB)", source.len() / 1024, MAX_SCRIPT_BYTES / 1024));
|
||||
}
|
||||
let mut guard = Guard::new(source.as_bytes());
|
||||
while let Some(&c) = guard.bytes.get(guard.i) {
|
||||
guard.i = guard.i.saturating_add(1);
|
||||
let safe = if guard.template_text { guard.template(c) } else { guard.token(c) };
|
||||
if !safe {
|
||||
return Some("the script is nested too deeply to run safely".into());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
+115
-48
@@ -17,7 +17,7 @@ fn fields() -> Vec<FieldState> {
|
||||
]
|
||||
}
|
||||
|
||||
/// The document these tests' scripts run in.
|
||||
/// Provide synthetic document metadata and page boxes for isolated JavaScript API tests.
|
||||
fn doc() -> DocInfo {
|
||||
DocInfo { file_name: "order.pdf".into(), num_pages: 3, page: 0, info: vec![("title".into(), "Order".into())], ..Default::default() }
|
||||
}
|
||||
@@ -29,8 +29,7 @@ fn go(script: &str, event: &Event) -> Outcome {
|
||||
|
||||
#[test]
|
||||
fn calculate_now_reports_errors_and_continues() {
|
||||
let calculations =
|
||||
vec![("bad".into(), "getField('price').value = 'partial'; throw new Error('broken');".into()), ("good".into(), "event.value = 'ok';".into())];
|
||||
let calculations = vec![(0, "getField('price').value = 'partial'; throw new Error('broken');".into()), (1, "event.value = 'ok';".into())];
|
||||
let state = Arc::new(vec![
|
||||
FieldState::new("bad", FieldType::Text, vec![]),
|
||||
FieldState::new("good", FieldType::Text, vec![]),
|
||||
@@ -46,7 +45,7 @@ fn calculate_now_reports_errors_and_continues() {
|
||||
Limits::default(),
|
||||
);
|
||||
assert_eq!(outcome.error, None);
|
||||
assert!(outcome.console.iter().any(|line| line.contains("calculation script for bad failed: Error: broken")), "{outcome:?}");
|
||||
assert!(outcome.console.iter().any(|line| line.contains("Calculate bad: Error: broken")), "{outcome:?}");
|
||||
assert!(outcome.console.iter().any(|line| line == "continued"), "{outcome:?}");
|
||||
assert_eq!(outcome.changed.iter().find(|field| field.name == "good").unwrap().value, ["ok"]);
|
||||
assert!(outcome.changed.iter().all(|field| field.name != "price"), "{outcome:?}");
|
||||
@@ -62,7 +61,7 @@ fn calculate_now_sets_read_only_fields_and_handles_hundreds() {
|
||||
let mut state = FieldState::new(name.clone(), FieldType::Text, vec![]);
|
||||
state.readonly = true;
|
||||
states.push(state);
|
||||
calculations.push((name, "event.value = 'calculated';".into()));
|
||||
calculations.push((index, "event.value = 'calculated';".into()));
|
||||
}
|
||||
let state = Arc::new(states);
|
||||
let outcome =
|
||||
@@ -74,10 +73,10 @@ fn calculate_now_sets_read_only_fields_and_handles_hundreds() {
|
||||
|
||||
#[test]
|
||||
fn caught_calculate_now_recursion_has_a_step_limit() {
|
||||
let calculations: Vec<_> = (0..600).map(|index| (format!("loop{index}"), "try { calculateNow(); } catch (e) {}".into())).collect();
|
||||
let state = Arc::new(calculations.iter().map(|(name, _)| FieldState::new(name.clone(), FieldType::Text, vec![])).collect());
|
||||
let calculations: Vec<_> = (0..600).map(|index| (index, "try { calculateNow(); } catch (e) {}".into())).collect();
|
||||
let state = Arc::new(calculations.iter().map(|(name, _)| FieldState::new(format!("loop{name}"), FieldType::Text, vec![])).collect());
|
||||
let outcome = run_shared_calculations("calculateNow();", &Event::doc("Console"), &doc(), state, &[], &calculations, Limits::default());
|
||||
assert!(outcome.error.as_deref().is_some_and(|error| error.contains("calculateNow evaluation limit exceeded")), "{outcome:?}");
|
||||
assert!(outcome.calculation_limit_exceeded && outcome.error.is_some(), "{outcome:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -104,6 +103,7 @@ fn the_outcome_says_whether_the_script_assigned_event_value() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Expose all supported page boxes in rotated-user-space order using only supplied document geometry.
|
||||
fn get_page_box_returns_supported_boxes_in_rotated_user_space() {
|
||||
let mut document = doc();
|
||||
document.page_boxes = vec![
|
||||
@@ -118,7 +118,7 @@ fn get_page_box_returns_supported_boxes_in_rotated_user_space() {
|
||||
];
|
||||
document.page_rotations = vec![0, 90, 270];
|
||||
let out = run(
|
||||
"JSON.stringify([this.getPageBox(), this.getPageBox('Media', 1), this.getPageBox('Bleed', 0), this.getPageBox('Trim', 0), this.getPageBox('Art', 2)])",
|
||||
"JSON.stringify([this.getPageBox(), this.getPageBox('Media', 1), this.getPageBox('Art', 2)])",
|
||||
&Event::doc("Console"),
|
||||
&document,
|
||||
&[],
|
||||
@@ -126,21 +126,15 @@ fn get_page_box_returns_supported_boxes_in_rotated_user_space() {
|
||||
Limits::default(),
|
||||
);
|
||||
assert_eq!(out.error, None);
|
||||
assert_eq!(out.result.as_deref(), Some("[[0,280,180,0],[-10,190,290,-10],[5,275,175,5],[10,270,170,10],[15,165,265,15]]"));
|
||||
assert_eq!(out.result.as_deref(), Some("[[0,280,180,0],[-10,190,290,-10],[15,165,265,15]]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Unknown box names, malformed page indices and pages outside the snapshot produce script errors.
|
||||
fn get_page_box_rejects_unknown_boxes_and_invalid_pages() {
|
||||
for script in [
|
||||
"this.getPageBox('BBox')",
|
||||
"this.getPageBox(1)",
|
||||
"this.getPageBox('Crop', -1)",
|
||||
"this.getPageBox('Crop', 1.5)",
|
||||
"this.getPageBox('Crop', 3)",
|
||||
"this.getPageBox('Crop', null)",
|
||||
"this.getPageBox('Crop', true)",
|
||||
"this.getPageBox('Crop', '0')",
|
||||
] {
|
||||
for script in
|
||||
["this.getPageBox('BBox')", "this.getPageBox(1)", "this.getPageBox('Crop', -1)", "this.getPageBox('Crop', 1.5)", "this.getPageBox('Crop', 3)"]
|
||||
{
|
||||
let out = go(script, &Event::doc("Console"));
|
||||
assert!(out.error.is_some(), "{script}");
|
||||
}
|
||||
@@ -189,50 +183,44 @@ fn scripts_change_other_fields_and_their_properties() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Normal captions read/write correctly; unsupported faces cannot partially mutate a button.
|
||||
fn push_button_caption_methods_read_write_and_reject_other_faces() {
|
||||
let mut button = FieldState::new("go", FieldType::Button, vec![]);
|
||||
button.caption = Some("Old".into());
|
||||
let fields = vec![button, FieldState::new("blank", FieldType::Button, vec![]), FieldState::new("text", FieldType::Text, vec![])];
|
||||
let fields = vec![button, FieldState::new("text", FieldType::Text, vec![])];
|
||||
let run = |script: &str| run(script, &Event::field("Mouse Up", "go", ""), &doc(), &fields, &[], Limits::default());
|
||||
let got = run("event.value = getField('go').buttonGetCaption();");
|
||||
assert_eq!(got.value, "Old");
|
||||
let set = run("getField('go').buttonSetCaption('New'); event.value = getField('go').buttonGetCaption();");
|
||||
assert_eq!(set.value, "New");
|
||||
assert_eq!(set.changed[0].caption.as_deref(), Some("New"));
|
||||
let same = run("getField('go').buttonSetCaption('Old');");
|
||||
assert_eq!(same.changed[0].caption.as_deref(), Some("Old"), "same-text writes still update every widget");
|
||||
assert!(run("getField('go').buttonSetCaption();").error.is_some());
|
||||
let long_caption = "x".repeat(crate::xfa::MAX_LINE + 10);
|
||||
let capped = run(&format!("getField('go').buttonSetCaption('{long_caption}');"));
|
||||
assert_eq!(capped.error, None);
|
||||
let caption = capped.changed[0].caption.as_deref().unwrap_or_default();
|
||||
assert_eq!(caption.chars().count(), crate::xfa::MAX_LINE + 1);
|
||||
assert!(caption.ends_with('…'));
|
||||
let explicit = run("event.value = getField('go').buttonGetCaption(0);");
|
||||
assert_eq!(explicit.value, "Old");
|
||||
let format =
|
||||
super::run("getField('go').buttonSetCaption('Discarded');", &Event::field("Format", "go", "Old"), &doc(), &fields, &[], Limits::default());
|
||||
assert!(format.error.as_deref().is_some_and(|e| e.contains("buttonSetCaption is not available in a Format event")));
|
||||
assert!(run("getField('go').buttonSetCaption('Bad', 1);").error.is_some());
|
||||
assert!(got.changed.is_empty(), "a getter must not change the form");
|
||||
for face in ["1", "2", "-1", "0.5", "NaN", "Infinity"] {
|
||||
for method in [format!("getField('go').buttonGetCaption({face});"), format!("getField('go').buttonSetCaption('Bad', {face});")] {
|
||||
let rejected = run(&method);
|
||||
assert!(rejected.error.is_some(), "{method}");
|
||||
assert!(rejected.changed.is_empty(), "an invalid face must not mutate the caption");
|
||||
}
|
||||
}
|
||||
assert!(run("getField('text').buttonSetCaption('Bad');").error.is_some());
|
||||
assert!(run("getField('go').buttonGetCaption(1);").error.is_some());
|
||||
let empty = run("event.value = getField('blank').buttonGetCaption();");
|
||||
assert_eq!(empty.error, None);
|
||||
assert_eq!(empty.value, "");
|
||||
assert!(run("getField('text').buttonGetCaption();").error.is_some());
|
||||
let cleared = run("getField('go').buttonSetCaption('', 0); event.value = getField('go').buttonGetCaption(0);");
|
||||
assert_eq!(cleared.error, None);
|
||||
assert_eq!(cleared.value, "");
|
||||
assert_eq!(cleared.changed[0].caption.as_deref(), Some(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn widget_rect_reads_and_writes_a_four_number_array() {
|
||||
let o = go(
|
||||
"var t = getField('total'); t.rect = [10, 20, 110, 50]; event.value = getField('total').rect.join(',');",
|
||||
"var t = getField('total'); t.rect = [10, 50, 110, 20]; event.value = getField('total').rect.join(',');",
|
||||
&Event::field("Mouse Up", "agree", ""),
|
||||
);
|
||||
assert_eq!(o.error, None);
|
||||
assert_eq!(o.value, "10,20,110,50");
|
||||
assert_eq!(o.changed.iter().find(|f| f.name == "total").and_then(|f| f.rect), Some([10.0, 20.0, 110.0, 50.0]));
|
||||
let o = go("getField('total').rect = [110, 50, 10, 20];", &Event::field("Mouse Up", "agree", ""));
|
||||
assert_eq!(o.changed.iter().find(|f| f.name == "total").and_then(|f| f.rect), Some([10.0, 20.0, 110.0, 50.0]));
|
||||
assert_eq!(o.value, "10,50,110,20");
|
||||
assert_eq!(o.changed.iter().find(|f| f.name == "total").and_then(|f| f.rect), Some([10.0, 50.0, 110.0, 20.0]));
|
||||
let o = go("getField('total').rect = [110, 20, 10, 50];", &Event::field("Mouse Up", "agree", ""));
|
||||
assert!(o.error.is_some());
|
||||
let o = go("getField('total').rect = [0, 0, Infinity, 10];", &Event::field("Mouse Up", "agree", ""));
|
||||
assert!(o.error.is_some());
|
||||
assert!(o.changed.iter().all(|f| f.name != "total"));
|
||||
@@ -521,6 +509,9 @@ fn app_alert_titles_are_clipped_and_charged() {
|
||||
// has no room for is left out and counted.
|
||||
let mut h = Host {
|
||||
event_name: "Open".into(),
|
||||
icons: Vec::new(),
|
||||
objects: Default::default(),
|
||||
field_keys: Default::default(),
|
||||
fields: Arc::new(Vec::new()),
|
||||
changed: BTreeSet::new(),
|
||||
formatted: BTreeSet::new(),
|
||||
@@ -528,9 +519,14 @@ fn app_alert_titles_are_clipped_and_charged() {
|
||||
console: Vec::new(),
|
||||
requests: Vec::new(),
|
||||
doc: doc(),
|
||||
calculations: Arc::new(Vec::new()),
|
||||
calculations: Vec::new(),
|
||||
calculate_depth: 0,
|
||||
calculate_steps: 0,
|
||||
calculation_evaluations: 0,
|
||||
calculation_limit_exceeded: false,
|
||||
calculation_work: 0,
|
||||
current_event: JsValue::undefined(),
|
||||
reset_calls: 0,
|
||||
reset_limit_exceeded: false,
|
||||
kept: 0,
|
||||
dropped: 0,
|
||||
};
|
||||
@@ -2318,7 +2314,7 @@ mod runtime_highlight {
|
||||
#[test]
|
||||
fn app_runtime_highlight_reads_the_viewers_setting_and_asks_to_change_it() {
|
||||
let ev = Event::field("Calculate", "total", "");
|
||||
let on = DocInfo { highlight: true, ..doc() };
|
||||
let on = DocInfo { runtime_highlight: true, ..doc() };
|
||||
let o = run("event.value = String(app.runtimeHighlight);", &ev, &on, &fields(), &[], Limits::default());
|
||||
assert_eq!((o.value.as_str(), o.requests.len()), ("true", 0));
|
||||
let o = go("var was = app.runtimeHighlight; app.runtimeHighlight = true; event.value = was + ',' + app.runtimeHighlight;", &ev);
|
||||
@@ -2341,6 +2337,15 @@ mod runtime_highlight {
|
||||
assert!(!o.requests.iter().any(|r| matches!(r, Request::Highlight(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dropped_zoom_request_does_not_change_the_script_value() {
|
||||
let document = DocInfo { zoom: 100.0, ..doc() };
|
||||
let script = format!("for (var i = 0; i < {}; i++) app.execMenuItem('Print'); this.zoom = 200; this.zoom;", crate::xfa::MAX_EFFECTS);
|
||||
let out = run(&script, &Event::doc("Console"), &document, &[], &[], Limits::default());
|
||||
assert_eq!(out.error, None);
|
||||
assert_eq!(out.result.as_deref(), Some("100"));
|
||||
}
|
||||
|
||||
/// The same in XFA scripts, where the change is an effect; nothing is asked for when the
|
||||
/// setting is already what the script wants.
|
||||
#[test]
|
||||
@@ -2635,3 +2640,65 @@ fn a_plain_form_sees_the_xfa_version_but_not_installed() {
|
||||
assert_eq!(o.value, "undefined number 3.5");
|
||||
assert_eq!(o.alerts, [Message::plain("old viewer")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn string_arrays_refuse_aggregate_text_before_request_clipping() {
|
||||
let o = go("resetForm(new Array(65).fill('x'.repeat(1 << 19)));", &Event::doc("Console"));
|
||||
assert!(o.error.as_deref().is_some_and(|e| e.contains("string array byte limit")), "{:?}", o.error);
|
||||
assert!(o.requests.is_empty() && o.changed.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_call_limit_precedes_argument_conversion_and_survives_catches() {
|
||||
let o = go("for(var i=0;i<64;i++)resetForm();resetForm([{toString(){throw Error('argument read');}}]);", &Event::doc("Console"));
|
||||
assert!(o.error.as_deref().is_some_and(|e| e.contains("reset call limit")), "{:?}", o.error);
|
||||
assert_eq!(o.requests.iter().filter(|r| matches!(r, Request::Reset(_))).count(), 64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caught_reset_limit_cannot_admit_additional_resets() {
|
||||
let out = go("for(var i=0;i<10000;i++){try{resetForm();}catch(e){}} getField('price').value=99;", &Event::doc("Console"));
|
||||
assert_eq!(out.error, None);
|
||||
assert!(out.reset_limit_exceeded);
|
||||
assert_eq!(out.requests.iter().filter(|r| matches!(r, Request::Reset(_))).count(), 64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn af_simple_calculate_expands_groups_and_duplicate_names() {
|
||||
let fields = vec![
|
||||
FieldState::new("row.0", FieldType::Text, vec!["2".into()]),
|
||||
FieldState::new("row.1", FieldType::Text, vec!["3".into()]),
|
||||
FieldState::new("row.1", FieldType::Text, vec!["5".into()]),
|
||||
FieldState::new("rowExtra", FieldType::Text, vec!["100".into()]),
|
||||
// A multiple-selection list counts its first value, as the native Simple calculation does.
|
||||
FieldState::new("picks", FieldType::ListBox, vec!["4".into(), "6".into()]),
|
||||
];
|
||||
for (op, names, expected) in [
|
||||
("SUM", "['row']", 10.0),
|
||||
("SUM", "['row.1']", 8.0),
|
||||
("SUM", "['row','row.1']", 18.0),
|
||||
("PRD", "['row']", 30.0),
|
||||
("AVG", "['row']", 10.0 / 3.0),
|
||||
("MIN", "['row']", 2.0),
|
||||
("MAX", "['row']", 5.0),
|
||||
("SUM", "['picks']", 4.0),
|
||||
("MAX", "['picks']", 4.0),
|
||||
("SUM", "['missing']", 0.0),
|
||||
("PRD", "['missing']", 0.0),
|
||||
("AVG", "['missing']", 0.0),
|
||||
("MIN", "[]", 0.0),
|
||||
("MAX", "[]", 0.0),
|
||||
] {
|
||||
let script = format!("event.value=AFSimple_Calculate('{op}',{names});event.value;");
|
||||
let out = run(&script, &Event::field("Calculate", "out", ""), &Default::default(), &fields, &[], Default::default());
|
||||
assert_eq!(out.error, None, "{script}");
|
||||
assert!((out.result.unwrap().parse::<f64>().unwrap() - expected).abs() < 1e-10, "{script}");
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn af_simple_calculate_bounds_selector_work_before_expansion() {
|
||||
let fields = vec![FieldState::new("n", FieldType::Text, vec!["1".into()]); 4096];
|
||||
let out =
|
||||
run("AFSimple_Calculate('SUM',new Array(4096).fill('n'));", &Event::doc("Console"), &Default::default(), &fields, &[], Default::default());
|
||||
assert!(out.error.as_deref().is_some_and(|e| e.contains("calculation field lookup exceeds")));
|
||||
}
|
||||
@@ -250,6 +250,8 @@ impl PdfCraftApp {
|
||||
compare_pages: String::new(),
|
||||
compare: None,
|
||||
js_console: Default::default(),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
script_pending: None,
|
||||
doc_js: Default::default(),
|
||||
a11y: a11y_ui::A11yState::default(),
|
||||
tags_state: Default::default(),
|
||||
@@ -443,6 +445,10 @@ impl PdfCraftApp {
|
||||
/// A dialog or prompt is open over the window: the keyboard is its own, and nothing may act
|
||||
/// on the document underneath it.
|
||||
pub(crate) fn modal_open(&self) -> bool {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if self.script_pending.as_ref().is_some_and(|pending| pending.has_prompt()) {
|
||||
return true;
|
||||
}
|
||||
self.dialog.is_some()
|
||||
|| self.close_request.is_some()
|
||||
|| self.password_prompt.is_some()
|
||||
@@ -585,6 +591,8 @@ impl PdfCraftApp {
|
||||
self.injected_panic(crate::frame_guard::FramePart::Logic);
|
||||
self.ctx = Some(ctx.clone());
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
self.poll_script_dialog(ctx);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
self.window_state.repair(ctx);
|
||||
self.dialog_epoch();
|
||||
// Notices raised outside `ui` (opened files, OS events, the control channel) translate too.
|
||||
|
||||
@@ -10,6 +10,10 @@ impl PdfCraftApp {
|
||||
if self.close_request.is_some() {
|
||||
// These dialogs are drawn after the save prompt and own the keyboard while open.
|
||||
// Don't let Escape or Return resolve unsaved work underneath one.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if self.script_pending.as_ref().is_some_and(|pending| pending.has_prompt()) {
|
||||
return;
|
||||
}
|
||||
let covered = self.pending_link.is_some()
|
||||
|| self.pending_remote_link.is_some()
|
||||
|| self.pending_full_screen.is_some()
|
||||
|
||||
@@ -3824,8 +3824,8 @@ fn apply_frame_requests(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui,
|
||||
crate::forms_ui::FormNotice::NoAction(name) => app.notify_fmt("{name} has no action", &[("name", &name)]),
|
||||
}
|
||||
}
|
||||
if let Some((name, action)) = app.views[index].forms.button.take() {
|
||||
run_button(app, index, &name, action);
|
||||
if let Some((name, identity, action)) = app.views[index].forms.button.take() {
|
||||
run_button(app, index, &name, identity, action);
|
||||
}
|
||||
if copy_selected {
|
||||
app.execute("edit.copy");
|
||||
|
||||
@@ -161,7 +161,7 @@ pub(super) fn page_scroll<R>(
|
||||
}
|
||||
|
||||
/// Run a push button's action (the ones that need no JavaScript engine).
|
||||
pub(super) fn run_button(app: &mut PdfCraftApp, index: usize, name: &str, action: pdfcraft_engine::form_scripts::ButtonAction) {
|
||||
pub(super) fn run_button(app: &mut PdfCraftApp, index: usize, name: &str, identity: u64, action: pdfcraft_engine::form_scripts::ButtonAction) {
|
||||
use pdfcraft_engine::form_scripts::ButtonAction as B;
|
||||
let pages = app.session.get(app.views[index].id).map_or(0, |d| d.info.pages.len());
|
||||
match action {
|
||||
@@ -205,6 +205,9 @@ pub(super) fn run_button(app: &mut PdfCraftApp, index: usize, name: &str, action
|
||||
read_only: None,
|
||||
required: None,
|
||||
display: Some(if hide { 1 } else { 0 }),
|
||||
caption: None,
|
||||
text_color: None,
|
||||
fill_color: None,
|
||||
..Default::default()
|
||||
})
|
||||
.collect();
|
||||
@@ -234,10 +237,10 @@ pub(super) fn run_button(app: &mut PdfCraftApp, index: usize, name: &str, action
|
||||
"{name} submits the form to {url}; PdfCraft doesn't send form data. Save the document to keep your entries.",
|
||||
&[("name", name), ("url", &url)],
|
||||
),
|
||||
B::ImportIcon => app.choose_field_image(name),
|
||||
B::ImportIcon => app.choose_field_image_target(app.views[index].id, name, Some(identity)),
|
||||
B::Script(js) => {
|
||||
let id = app.views[index].id;
|
||||
app.run_button_script(id, name, &js);
|
||||
app.run_button_script_target(id, name, Some(identity), &js);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +57,7 @@ fn field(name: &str, page: usize, rect: [f64; 4]) -> FormField {
|
||||
quadding: 0,
|
||||
tooltip: None,
|
||||
widgets: vec![FormWidget {
|
||||
mouse_up_script: None,
|
||||
obj: pdfcraft_cos::ObjRef::new(4, 0),
|
||||
page: Some(page),
|
||||
rect,
|
||||
|
||||
@@ -99,8 +99,10 @@ pub fn show(app: &mut PdfCraftApp, ctx: &egui::Context) {
|
||||
ctx.set_cursor_icon(egui::CursorIcon::Default);
|
||||
}
|
||||
dialogs(app, ctx);
|
||||
// Last, so it sits above Document Properties when Security's Unlock… asked for it.
|
||||
// Above Document Properties when Security's Unlock… asked for it; script prompts stay on top.
|
||||
unlock(app, ctx);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
app.show_script_dialog(ctx);
|
||||
}
|
||||
|
||||
/// Draw the active modal dialog and apply the action the user chose.
|
||||
|
||||
@@ -1202,6 +1202,7 @@ fn is_form_edit(edit: &Edit) -> bool {
|
||||
| Edit::DuplicateField { .. }
|
||||
| Edit::SetFieldProps { .. }
|
||||
| Edit::SetFieldImage { .. }
|
||||
| Edit::SetFieldImageTarget { .. }
|
||||
| Edit::SetFieldScript { .. }
|
||||
| Edit::SetCalculationOrder { .. }
|
||||
| Edit::ApplyScriptChanges { .. }
|
||||
|
||||
@@ -92,6 +92,8 @@ mod frame_guard;
|
||||
mod hooks;
|
||||
mod persist;
|
||||
mod platform;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod script_dialog;
|
||||
mod state;
|
||||
mod tools;
|
||||
mod workflows;
|
||||
@@ -655,6 +657,8 @@ pub struct PdfCraftApp {
|
||||
pub compare: Option<compare_ui::CompareState>,
|
||||
/// The JavaScript console and the Document JavaScripts draft.
|
||||
pub js_console: js_ui::JsConsole,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) script_pending: Option<script_dialog::Pending>,
|
||||
pub doc_js: js_ui::DocJsDraft,
|
||||
pub a11y: a11y_ui::A11yState,
|
||||
pub(crate) tags_state: tags_ui::State,
|
||||
|
||||
@@ -0,0 +1,412 @@
|
||||
//! Script prompts suspend the document worker, never the egui event loop.
|
||||
use crate::PdfCraftApp;
|
||||
use pdfcraft_engine::{
|
||||
DocId,
|
||||
js::{InteractiveEvent, JsOutput, MenuItem, PendingScript, Prompt},
|
||||
};
|
||||
use std::sync::mpsc::{Sender, TryRecvError};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
pub(crate) struct Pending {
|
||||
run: PendingScript,
|
||||
prompt: Option<(Prompt, Sender<Option<String>>)>,
|
||||
text: String,
|
||||
focus: bool,
|
||||
started: Instant,
|
||||
zoom: f32,
|
||||
page: usize,
|
||||
serial: u32,
|
||||
}
|
||||
impl Pending {
|
||||
/// Whether this invocation is waiting for an explicit user reply rather than worker execution.
|
||||
pub(crate) fn has_prompt(&self) -> bool {
|
||||
self.prompt.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw the bounded menu tree and return only the selected enabled leaf value.
|
||||
fn menu(ui: &mut egui::Ui, items: &[MenuItem], answer: &mut Option<String>) {
|
||||
for item in items {
|
||||
if item.name == "-" {
|
||||
ui.separator();
|
||||
continue;
|
||||
}
|
||||
ui.add_enabled_ui(item.enabled, |ui| {
|
||||
if item.children.is_empty() {
|
||||
if ui.selectable_label(item.marked, &item.name).clicked() {
|
||||
*answer = Some(item.value.clone());
|
||||
}
|
||||
} else {
|
||||
egui::CollapsingHeader::new(&item.name).show(ui, |ui| menu(ui, &item.children, answer));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
/// Consume the current prompt before replying so two UI events cannot resume it twice.
|
||||
fn reply_to_prompt(ctx: &egui::Context, pending: &mut Pending, answer: Option<String>, cancel: bool) {
|
||||
if let Some((prompt, reply)) = pending.prompt.take() {
|
||||
let answer = if cancel {
|
||||
match prompt {
|
||||
Prompt::Alert(m) => Some(
|
||||
match m.buttons {
|
||||
pdfcraft_engine::js::MessageButtons::Ok => "1",
|
||||
pdfcraft_engine::js::MessageButtons::YesNo => "3",
|
||||
_ => "2",
|
||||
}
|
||||
.into(),
|
||||
),
|
||||
_ => None,
|
||||
}
|
||||
} else {
|
||||
answer
|
||||
};
|
||||
let _ = reply.send(answer);
|
||||
}
|
||||
pending.started = Instant::now();
|
||||
ctx.request_repaint();
|
||||
}
|
||||
|
||||
impl PdfCraftApp {
|
||||
/// Start one explicit button interaction with captured document/view state and leave egui responsive.
|
||||
pub(crate) fn start_ui_script(&mut self, id: DocId, script: &str, field: Option<&str>, identity: Option<u64>) {
|
||||
if self.script_pending.is_some() {
|
||||
self.notify("Another form script is active; this click was not run");
|
||||
return;
|
||||
}
|
||||
let Some(view) = self.views.iter().find(|v| v.id == id) else { return };
|
||||
let (zoom, page, highlight) = (view.zoom, view.current, view.highlight_fields);
|
||||
self.session.set_javascript_view(id, zoom as f64 * 100.0, highlight, page);
|
||||
match self.session.start_javascript_target(id, script, field, identity) {
|
||||
Ok(run) => {
|
||||
self.script_pending =
|
||||
Some(Pending { run, prompt: None, text: String::new(), focus: false, started: Instant::now(), zoom, page, serial: 0 })
|
||||
}
|
||||
Err(e) => self.notify(e.to_string()),
|
||||
}
|
||||
}
|
||||
/// Receive worker prompts/results and invalidate stale or timed-out interactions before applying their edits.
|
||||
pub(crate) fn poll_script_dialog(&mut self, ctx: &egui::Context) {
|
||||
let Some(mut pending) = self.script_pending.take() else { return };
|
||||
let current = (self.active_ids().is_some_and(|(_, id)| id == pending.run.id)
|
||||
|| self.compare.as_ref().is_some_and(|c| {
|
||||
c.side_by_side
|
||||
&& (c.old == pending.run.id || c.new == pending.run.id)
|
||||
&& self.active_ids().is_some_and(|(_, active)| active == c.old || active == c.new)
|
||||
}))
|
||||
&& self.session.script_pending_current(&pending.run)
|
||||
&& self.views.iter().find(|v| v.id == pending.run.id).is_some_and(|v| v.zoom == pending.zoom && v.current == pending.page);
|
||||
if !current {
|
||||
self.session.discard_javascript(&pending.run);
|
||||
return;
|
||||
}
|
||||
if (pending.prompt.is_none() && pending.started.elapsed() > Duration::from_secs(10))
|
||||
|| (pending.prompt.is_some() && pending.started.elapsed() > Duration::from_secs(300))
|
||||
{
|
||||
self.session.timeout_javascript(pending.run);
|
||||
self.notify("The form script timed out; its result was discarded");
|
||||
return;
|
||||
}
|
||||
if pending.prompt.is_none() {
|
||||
match pending.run.receiver.try_recv() {
|
||||
Ok(InteractiveEvent::Prompt(prompt, reply)) => {
|
||||
pending.text = match &prompt {
|
||||
Prompt::Text { default, .. } => default.clone(),
|
||||
_ => String::new(),
|
||||
};
|
||||
pending.serial = pending.serial.saturating_add(1);
|
||||
pending.focus = true;
|
||||
pending.started = Instant::now();
|
||||
pending.prompt = Some((prompt, reply));
|
||||
}
|
||||
Ok(InteractiveEvent::Complete(out)) => {
|
||||
let id = pending.run.id;
|
||||
match self.session.finish_javascript(pending.run, out) {
|
||||
Ok(out) => {
|
||||
if let Some(i) = self.views.iter().position(|v| v.id == id)
|
||||
&& let Some(info) = self.session.get(id).map(|d| d.info.clone())
|
||||
{
|
||||
self.views[i].document_changed(&info);
|
||||
}
|
||||
self.handle_js(
|
||||
id,
|
||||
JsOutput {
|
||||
alerts: Vec::new(),
|
||||
console: out.console,
|
||||
requests: out.requests,
|
||||
errors: out.error.into_iter().chain(out.doc_errors).collect(),
|
||||
doc_errors: Vec::new(),
|
||||
},
|
||||
);
|
||||
}
|
||||
Err(e) => self.notify(e.to_string()),
|
||||
}
|
||||
return;
|
||||
}
|
||||
Err(TryRecvError::Disconnected) => {
|
||||
self.session.discard_javascript(&pending.run);
|
||||
self.notify("The form script stopped unexpectedly");
|
||||
return;
|
||||
}
|
||||
Err(TryRecvError::Empty) => {}
|
||||
}
|
||||
}
|
||||
// Input events wake egui immediately; idle prompts only need a coarse timeout check.
|
||||
ctx.request_repaint_after(if pending.has_prompt() { Duration::from_secs(1) } else { Duration::from_millis(16) });
|
||||
self.script_pending = Some(pending);
|
||||
}
|
||||
/// Display the current typed prompt and send one answer/cancellation without replaying script side effects.
|
||||
pub(crate) fn show_script_dialog(&mut self, ctx: &egui::Context) {
|
||||
self.show_script_dialog_with_menu(ctx, |_, _, _| None);
|
||||
}
|
||||
|
||||
/// Let an alternative renderer own a menu until it returns a selection or cancellation.
|
||||
/// Returning `None` uses the built-in modal; the renderer never receives the reply sender.
|
||||
pub fn show_script_dialog_with_menu(
|
||||
&mut self,
|
||||
ctx: &egui::Context,
|
||||
render_menu: impl FnOnce(&egui::Context, DocId, &[MenuItem]) -> Option<std::task::Poll<Option<String>>>,
|
||||
) {
|
||||
let Some(pending) = self.script_pending.as_mut() else { return };
|
||||
let Some((prompt, _)) = pending.prompt.as_ref() else { return };
|
||||
let Some(doc) = self.session.get(pending.run.id) else { return };
|
||||
if let Prompt::Menu(items) = prompt
|
||||
&& let Some(reply) = render_menu(ctx, pending.run.id, items)
|
||||
{
|
||||
if let std::task::Poll::Ready(answer) = reply {
|
||||
let cancel = answer.is_none();
|
||||
reply_to_prompt(ctx, pending, answer, cancel);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Keep the filename in a separate, single-line label. It is document data too.
|
||||
let document_name: String = doc
|
||||
.name
|
||||
.chars()
|
||||
.filter(|c| !c.is_control() && !matches!(c, '\u{061c}' | '\u{200e}'..='\u{200f}' | '\u{2028}'..='\u{202e}' | '\u{2066}'..='\u{2069}'))
|
||||
.take(160)
|
||||
.collect();
|
||||
let mut answer = None;
|
||||
let mut cancel = ctx.input(|i| i.key_pressed(egui::Key::Escape));
|
||||
let prompt_modal = crate::widgets::modal(ctx, ("document-script-prompt", pending.run.id.0, pending.serial));
|
||||
// A different dialog may have been opened after this prompt. Drawing last alone
|
||||
// does not reorder retained egui areas, so explicitly retain modal input priority.
|
||||
ctx.move_to_top(prompt_modal.area.layer());
|
||||
let modal = prompt_modal.show(ctx, |ui| {
|
||||
ui.set_width(360.0);
|
||||
ui.heading(tl!("This document's script is asking"));
|
||||
ui.add(egui::Label::new(crate::i18n::fmt(tl!("Document: {name}"), &[("name", &document_name)])).truncate());
|
||||
ui.separator();
|
||||
match prompt {
|
||||
Prompt::Menu(items) => {
|
||||
egui::ScrollArea::vertical().max_height(400.0).show(ui, |ui| menu(ui, items, &mut answer));
|
||||
}
|
||||
Prompt::Text { title, question, password, label, .. } => {
|
||||
if *password {
|
||||
ui.label(tl!("This request comes from the document, not PdfCraft. Only enter a password if you trust this document."));
|
||||
}
|
||||
if !title.is_empty() {
|
||||
ui.label(title);
|
||||
}
|
||||
ui.label(question);
|
||||
if !label.is_empty() {
|
||||
ui.label(label);
|
||||
}
|
||||
let field = ui.add(egui::TextEdit::singleline(&mut pending.text).password(*password).char_limit(4096));
|
||||
if pending.focus {
|
||||
field.request_focus();
|
||||
pending.focus = false;
|
||||
}
|
||||
if ui.add_enabled(pending.text.len() <= 4096, egui::Button::new(tl!("OK"))).clicked() {
|
||||
answer = Some(pending.text.clone());
|
||||
}
|
||||
}
|
||||
Prompt::Alert(message) => {
|
||||
if let Some(title) = &message.title {
|
||||
ui.label(title);
|
||||
}
|
||||
ui.label(&message.text);
|
||||
use pdfcraft_engine::js::MessageButtons as B;
|
||||
ui.horizontal(|ui| match message.buttons {
|
||||
B::Ok | B::OkCancel => {
|
||||
if ui.button(tl!("OK")).clicked() {
|
||||
answer = Some("1".into());
|
||||
}
|
||||
}
|
||||
B::YesNo | B::YesNoCancel => {
|
||||
if ui.button(tl!("Yes")).clicked() {
|
||||
answer = Some("4".into());
|
||||
}
|
||||
if ui.button(tl!("No")).clicked() {
|
||||
answer = Some("3".into());
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
if ui.button(tl!("Cancel")).clicked() {
|
||||
cancel = true;
|
||||
}
|
||||
});
|
||||
cancel |= answer.is_none() && modal.should_close();
|
||||
if answer.is_some() || cancel {
|
||||
reply_to_prompt(ctx, pending, answer, cancel);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod review_tests {
|
||||
use super::*;
|
||||
/// The central shortcut/page-input gate must see a real pending script prompt.
|
||||
#[test]
|
||||
fn a_script_prompt_blocks_document_input_until_answered() {
|
||||
let mut app = PdfCraftApp::new();
|
||||
let bytes = app.session.create_blank(200.0, 300.0, 1).unwrap();
|
||||
app.open_bytes("synthetic.pdf", None, bytes.to_vec()).unwrap();
|
||||
let id = app.active_ids().unwrap().1;
|
||||
app.start_ui_script(id, "app.response({cQuestion:'Wait'});", None, None);
|
||||
let ctx = egui::Context::default();
|
||||
for _ in 0..300 {
|
||||
app.poll_script_dialog(&ctx);
|
||||
if app.script_pending.as_ref().is_some_and(|p| p.prompt.is_some()) {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
assert!(app.script_pending.as_ref().unwrap().prompt.is_some());
|
||||
assert!(app.modal_open(), "an unanswered script prompt must block document input");
|
||||
let (_, reply) = app.script_pending.as_mut().unwrap().prompt.take().unwrap();
|
||||
reply.send(None).unwrap();
|
||||
for _ in 0..300 {
|
||||
app.poll_script_dialog(&ctx);
|
||||
if app.script_pending.is_none() {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
assert!(app.script_pending.is_none());
|
||||
assert!(!app.modal_open());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod review13_ui_tests {
|
||||
use super::*;
|
||||
/// Create a native app with a visible synthetic blank document and no renderer.
|
||||
fn app() -> (PdfCraftApp, DocId) {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.set_option("language", "en").unwrap();
|
||||
let bytes = app.session.create_blank(200.0, 300.0, 2).unwrap();
|
||||
app.open_bytes("synthetic.pdf", None, bytes.to_vec()).unwrap();
|
||||
let id = app.active_ids().unwrap().1;
|
||||
(app, id)
|
||||
}
|
||||
/// Refused clicks and execution/prompt timeouts are visible, while stale runs stay silent.
|
||||
#[test]
|
||||
fn busy_and_timeout_notices_do_not_claim_worker_termination() {
|
||||
let ctx = egui::Context::default();
|
||||
for (timeout, prompt) in [(true, false), (true, true), (false, false)] {
|
||||
let (mut app, id) = app();
|
||||
app.start_ui_script(id, "app.response({cQuestion:'Wait'});", None, None);
|
||||
app.start_ui_script(id, "var shouldNotRun=1;", None, None);
|
||||
assert!(app.toast.as_ref().unwrap().0.contains("not run"));
|
||||
app.toast = None;
|
||||
if prompt {
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
while app.script_pending.as_ref().unwrap().prompt.is_none() && Instant::now() < deadline {
|
||||
app.poll_script_dialog(&ctx);
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
assert!(app.script_pending.as_ref().unwrap().prompt.is_some());
|
||||
}
|
||||
if timeout {
|
||||
app.script_pending.as_mut().unwrap().started = Instant::now() - Duration::from_secs(if prompt { 301 } else { 11 });
|
||||
} else {
|
||||
app.views.iter_mut().find(|v| v.id == id).unwrap().zoom += 1.0;
|
||||
}
|
||||
app.poll_script_dialog(&ctx);
|
||||
assert!(app.script_pending.is_none());
|
||||
if timeout {
|
||||
assert_eq!(app.toast.as_ref().unwrap().0, "The form script timed out; its result was discarded");
|
||||
} else {
|
||||
assert!(app.toast.is_none());
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Comparison completion preserves globals and sends Save As/Print to its source document.
|
||||
#[test]
|
||||
fn non_active_comparison_document_completes_native_script() {
|
||||
for request in ["", "app.execMenuItem('SaveAs');", "print();"] {
|
||||
let (mut app, old) = app();
|
||||
app.views.iter_mut().find(|v| v.id == old).unwrap().current = 1;
|
||||
let bytes = app.session.create_blank(200.0, 300.0, 1).unwrap();
|
||||
app.open_bytes("new.pdf", None, bytes.to_vec()).unwrap();
|
||||
let new = app.active_ids().unwrap().1;
|
||||
assert_ne!(new, old);
|
||||
let new_before = app.session.save_bytes(new).unwrap();
|
||||
app.compare_old = Some(old);
|
||||
app.run_compare();
|
||||
assert!(app.compare.as_ref().unwrap().side_by_side);
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"pdfcraft-comparison-request-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
|
||||
));
|
||||
std::fs::create_dir(&dir).unwrap();
|
||||
let saved = dir.join("source.pdf");
|
||||
app.save_override = Some(saved.to_string_lossy().into_owned());
|
||||
app.printer_source = Some(std::sync::Arc::new(Vec::new));
|
||||
app.start_ui_script(old, &format!("var comparisonKept=42;{request}"), None, None);
|
||||
let ctx = egui::Context::default();
|
||||
let deadline = Instant::now() + Duration::from_secs(12);
|
||||
while app.script_pending.is_some() && Instant::now() < deadline {
|
||||
app.poll_script_dialog(&ctx);
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
assert!(app.script_pending.is_none());
|
||||
assert_eq!(app.session.run_javascript(old, "comparisonKept", None).unwrap().result.as_deref(), Some("42"));
|
||||
assert_eq!(app.session.save_bytes(new).unwrap(), new_before);
|
||||
assert!(app.session.get(new).unwrap().path.is_none());
|
||||
if request.contains("SaveAs") {
|
||||
assert_eq!(app.active_ids().unwrap().1, new, "Save As need not change the comparison tab");
|
||||
assert_eq!(app.session.get(old).unwrap().path.as_deref(), saved.to_str());
|
||||
let mut reopened = pdfcraft_engine::Session::new();
|
||||
let id = reopened.open("saved.pdf", None, std::sync::Arc::new(std::fs::read(&saved).unwrap()), None).unwrap();
|
||||
assert_eq!(reopened.get(id).unwrap().info.pages.len(), 2, "the saved file is the two-page source, not the one-page active file");
|
||||
} else if request == "print();" {
|
||||
assert_eq!(app.active_ids().unwrap().1, old, "Print must retain its source as the active document");
|
||||
assert!(matches!(app.dialog, Some(crate::Dialog::Print)));
|
||||
assert_eq!(app.print_draft.current_page, 1);
|
||||
assert!(!saved.exists());
|
||||
} else {
|
||||
assert_eq!(app.active_ids().unwrap().1, new);
|
||||
assert!(!saved.exists());
|
||||
}
|
||||
drop(app);
|
||||
if saved.exists() {
|
||||
std::fs::remove_file(saved).unwrap();
|
||||
}
|
||||
std::fs::remove_dir(dir).unwrap();
|
||||
}
|
||||
}
|
||||
/// Script focus reveals the requested field page before opening its editor.
|
||||
#[test]
|
||||
fn script_focus_reveals_an_offscreen_widget() {
|
||||
let (mut app, id) = app();
|
||||
app.session
|
||||
.apply(
|
||||
id,
|
||||
pdfcraft_engine::Edit::AddField {
|
||||
page: 1,
|
||||
rect: [20.0, 20.0, 120.0, 40.0],
|
||||
kind: pdfcraft_engine::NewField::Text { multiline: false },
|
||||
name: Some("later".into()),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
app.handle_js(id, JsOutput { requests: vec![pdfcraft_engine::js::Request::Focus("later".into())], ..Default::default() });
|
||||
let view = app.views.iter().find(|v| v.id == id).unwrap();
|
||||
assert_eq!(view.current, 1);
|
||||
assert_eq!(view.forms.focus.as_ref().unwrap().name, "later");
|
||||
}
|
||||
}
|
||||
@@ -1091,14 +1091,46 @@ impl crate::PdfCraftApp {
|
||||
|
||||
/// Click an image field: pick a picture for it.
|
||||
pub fn choose_field_image(&mut self, name: &str) {
|
||||
let Some((_, id)) = self.active_ids() else { return };
|
||||
self.choose_field_image_target(id, name, None);
|
||||
}
|
||||
/// Keep the clicked document and field identity through the asynchronous image picker.
|
||||
pub fn choose_field_image_target(&mut self, id: pdfcraft_engine::DocId, name: &str, identity: Option<u64>) {
|
||||
if identity.is_some_and(|key| key >= 1u64 << 48) {
|
||||
self.notify("The selected image field identity is invalid");
|
||||
return;
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let name = name.to_string();
|
||||
let Some(generation) = self.session.get(id).map(|d| d.edit_generation()) else { return };
|
||||
self.pick_image(tl!("Select Icon"), move |app, _, bytes| {
|
||||
app.apply_edit(Edit::SetFieldImage { name, image: std::sync::Arc::new(bytes) });
|
||||
if app.session.get(id).is_none_or(|d| d.edit_generation() != generation) {
|
||||
app.notify("The document changed while choosing the image; the picture was not applied");
|
||||
return;
|
||||
}
|
||||
let image = std::sync::Arc::new(bytes);
|
||||
let edit = match identity {
|
||||
Some(key) => {
|
||||
Edit::SetFieldImageTarget { name, object: pdfcraft_cos::ObjRef { num: (key >> 16) as u32, generation: key as u16 }, image }
|
||||
}
|
||||
None => Edit::SetFieldImage { name, image },
|
||||
};
|
||||
match app.session.apply(id, edit) {
|
||||
Ok(_) => {
|
||||
if let Some(view) = app.views.iter_mut().find(|v| v.id == id)
|
||||
&& let Some(doc) = app.session.get(id)
|
||||
{
|
||||
view.document_changed(&doc.info);
|
||||
}
|
||||
}
|
||||
Err(e) => app.notify(e.to_string()),
|
||||
}
|
||||
});
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let _ = (id, identity);
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
self.notify_fmt("{name}: choosing images arrives on the web with file pickers for images", &[("name", &name)]);
|
||||
}
|
||||
|
||||
|
||||
@@ -89,8 +89,8 @@ pub struct FormView {
|
||||
pub unbaked: HashSet<(String, usize)>,
|
||||
/// A message for the app to show (e.g. "buttons run JavaScript").
|
||||
pub notice: Option<FormNotice>,
|
||||
/// A push button was clicked: (its field name, what it does).
|
||||
pub button: Option<(String, pdfcraft_engine::form_scripts::ButtonAction)>,
|
||||
/// A push button was clicked: (its field name, exact PDF identity, what it does).
|
||||
pub button: Option<(String, u64, pdfcraft_engine::form_scripts::ButtonAction)>,
|
||||
/// The focused check box or radio button was clicked. It toggles once no other edit is
|
||||
/// queued: the text the click committed goes first, and the toggle is dropped if that text
|
||||
/// is refused and its field reopens.
|
||||
@@ -349,7 +349,7 @@ fn list_pick_edit(form: &[FormField], f: &FormField, picked: Vec<usize>) -> Edit
|
||||
|
||||
/// Editor state for opening widget `widget` of `f`: it starts from the field's value, asks
|
||||
/// for keyboard focus, and gives a drop-down a fresh list that places itself from that value.
|
||||
fn open_focus(f: &FormField, widget: usize) -> Focus {
|
||||
pub(crate) fn open_focus(f: &FormField, widget: usize) -> Focus {
|
||||
Focus {
|
||||
name: f.name.clone(),
|
||||
obj: Some(f.obj),
|
||||
@@ -507,9 +507,18 @@ pub(crate) fn page_input(
|
||||
match f.kind {
|
||||
_ if f.read_only() || w.read_only => view.forms.notice = Some(FormNotice::ReadOnly(f.name.clone())),
|
||||
FormFieldKind::PushButton => match &f.button {
|
||||
Some(a) => view.forms.button = Some((f.name.clone(), a.clone())),
|
||||
Some(a) => view.forms.button = Some((f.name.clone(), pdfcraft_engine::js::field_object_id(f.obj), a.clone())),
|
||||
None => view.forms.notice = Some(FormNotice::NoAction(f.name.clone())),
|
||||
},
|
||||
FormFieldKind::Text if let Some(script) = &w.mouse_up_script => {
|
||||
// Close the editor so the script can replace its value without a stale draft.
|
||||
commit(view, form);
|
||||
view.forms.button = Some((
|
||||
f.name.clone(),
|
||||
pdfcraft_engine::js::field_object_id(f.obj),
|
||||
pdfcraft_engine::form_scripts::ButtonAction::Script(script.clone()),
|
||||
));
|
||||
}
|
||||
FormFieldKind::Signature => view.sign.field = Some(f.name.clone()),
|
||||
FormFieldKind::CheckBox | FormFieldKind::Radio => {
|
||||
// The box takes the focus, as in Acrobat, so Tab goes on from it (#2223). The overlay
|
||||
@@ -588,6 +597,9 @@ pub(crate) fn page_input(
|
||||
/// The edit the focused field's draft makes, if it differs from the field's value.
|
||||
pub(crate) fn draft_edit(focus: &Focus, form: &[FormField]) -> Option<Edit> {
|
||||
let f = focused_field(focus, form)?;
|
||||
if f.read_only() || f.widgets.get(focus.widget).is_some_and(|widget| widget.read_only) {
|
||||
return None;
|
||||
}
|
||||
let value = match f.kind {
|
||||
FormFieldKind::Text if f.value.first().map(String::as_str).unwrap_or("") != focus.text => FieldValue::Text(focus.text.clone()),
|
||||
// Typed into an editable combo box: a shown text picks its option, anything else is the
|
||||
@@ -1126,6 +1138,40 @@ fn arrow_step(ctx: &egui::Context, keys: PageKeys) -> Option<bool> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply keyboard navigation for a focused list without consuming page navigation keys elsewhere.
|
||||
fn list_overlay_keys(
|
||||
ctx: &egui::Context,
|
||||
keys: PageKeys,
|
||||
form: &[FormField],
|
||||
f: &FormField,
|
||||
clicked_outside: bool,
|
||||
) -> (Option<Edit>, bool, Option<bool>) {
|
||||
let mut edit = None;
|
||||
let mut close = false;
|
||||
let mut next = None;
|
||||
// Taken, so the arrows move the selection without also scrolling the page.
|
||||
let (up, down) = (keys.take(ctx, egui::Key::ArrowUp), keys.take(ctx, egui::Key::ArrowDown));
|
||||
let (esc, tab) = (keys.pressed(ctx, egui::Key::Escape), keys.pressed(ctx, egui::Key::Tab));
|
||||
let shift = ctx.input(|i| i.modifiers.shift);
|
||||
if esc {
|
||||
close = true;
|
||||
} else if tab {
|
||||
next = Some(!shift);
|
||||
} else if (up || down) && !f.options.is_empty() {
|
||||
let current_idx = f.picked_in_order().last().copied().unwrap_or(0);
|
||||
let next_idx = if up { current_idx.saturating_sub(1) } else { current_idx.saturating_add(1).min(f.options.len().saturating_sub(1)) };
|
||||
if next_idx != current_idx && next_idx < f.options.len() {
|
||||
// By position, so the arrows step through options that share an export value,
|
||||
// in the focused twin's own order.
|
||||
edit = Some(list_pick_edit(form, f, vec![next_idx]));
|
||||
}
|
||||
}
|
||||
if clicked_outside {
|
||||
close = true;
|
||||
}
|
||||
(edit, close, next)
|
||||
}
|
||||
|
||||
/// The in-place editor or option list for the focused field. Returns an edit to apply.
|
||||
/// `text_box`, for a text field that doesn't scroll, is how its widget lays text out: the editor
|
||||
/// then takes no more than fits.
|
||||
@@ -1171,8 +1217,9 @@ pub(crate) fn overlay(
|
||||
let mut edit = None;
|
||||
let mut close = false;
|
||||
let mut next: Option<bool> = None; // Tab / Shift+Tab
|
||||
let read_only = f.read_only() || w.read_only;
|
||||
match f.kind {
|
||||
FormFieldKind::Text => {
|
||||
FormFieldKind::Text if !read_only => {
|
||||
let multiline = f.has(field_flags::MULTILINE);
|
||||
// Shown at once, like the paragraph editor: a fade-in shows the field's value through it.
|
||||
// Over the page, so only where the document shows: a field scrolled under the mode bar
|
||||
@@ -1260,7 +1307,7 @@ pub(crate) fn overlay(
|
||||
return view.pending_edit.take();
|
||||
}
|
||||
}
|
||||
FormFieldKind::Combo => {
|
||||
FormFieldKind::Combo if !read_only => {
|
||||
// An editable combo box has a text box over the field as well as its list (ISO
|
||||
// 32000-2 Table 233, Edit); the keys are the text box's then, and the list takes
|
||||
// the pointer.
|
||||
@@ -1292,30 +1339,11 @@ pub(crate) fn overlay(
|
||||
close = true;
|
||||
}
|
||||
}
|
||||
FormFieldKind::List => {
|
||||
// Taken, so the arrows move the selection without also scrolling the page.
|
||||
let (up, down) = (keys.take(ctx, egui::Key::ArrowUp), keys.take(ctx, egui::Key::ArrowDown));
|
||||
let (esc, tab) = (keys.pressed(ctx, egui::Key::Escape), keys.pressed(ctx, egui::Key::Tab));
|
||||
let shift = ctx.input(|i| i.modifiers.shift);
|
||||
if esc {
|
||||
close = true;
|
||||
} else if tab {
|
||||
next = Some(!shift);
|
||||
} else if (up || down) && !f.options.is_empty() {
|
||||
let current_idx = f.picked_in_order().last().copied().unwrap_or(0);
|
||||
let next_idx = if up { current_idx.saturating_sub(1) } else { current_idx.saturating_add(1).min(f.options.len().saturating_sub(1)) };
|
||||
if next_idx != current_idx && next_idx < f.options.len() {
|
||||
// By position, so the arrows step through options that share an export value,
|
||||
// in the focused twin's own order.
|
||||
edit = Some(list_pick_edit(form, f, vec![next_idx]));
|
||||
}
|
||||
}
|
||||
if clicked_outside() {
|
||||
close = true;
|
||||
}
|
||||
FormFieldKind::List if !read_only => {
|
||||
(edit, close, next) = list_overlay_keys(ctx, keys, form, f, clicked_outside());
|
||||
}
|
||||
FormFieldKind::CheckBox | FormFieldKind::Radio => {
|
||||
// Nothing is drawn over the box, but a widget there holds egui's keyboard focus, so
|
||||
kind if read_only || matches!(kind, FormFieldKind::CheckBox | FormFieldKind::Radio) => {
|
||||
// Nothing is drawn over the field, but a widget there holds egui's keyboard focus, so
|
||||
// Space, Tab and the arrows come here instead of turning or scrolling the page.
|
||||
let id = egui::Id::unique(("form-field", view.pane.key, &f.name, focus.widget));
|
||||
let mut has_focus = false;
|
||||
@@ -1342,6 +1370,8 @@ pub(crate) fn overlay(
|
||||
close = true;
|
||||
} else if tab {
|
||||
next = Some(!shift);
|
||||
} else if read_only {
|
||||
close = !has_focus && !focus.request_focus;
|
||||
} else if toggle && view.pending_edit.is_some() {
|
||||
view.forms.toggle = true;
|
||||
} else if toggle {
|
||||
|
||||
@@ -70,7 +70,14 @@ impl PdfCraftApp {
|
||||
}
|
||||
for r in out.requests {
|
||||
match r {
|
||||
Request::Print => self.open_print(),
|
||||
Request::Print => {
|
||||
if let Some(i) = view {
|
||||
// The print dialog reads the active document throughout its lifetime.
|
||||
// Keep the source selected; restoring the comparison tab would retarget it.
|
||||
self.active = Some(i);
|
||||
self.open_print();
|
||||
}
|
||||
}
|
||||
Request::GoToPage(p) => {
|
||||
if let Some(i) = view {
|
||||
self.views[i].go_to_page(p);
|
||||
@@ -81,9 +88,12 @@ impl PdfCraftApp {
|
||||
"The form asks to be submitted to {u}; PdfCraft doesn't send form data. Save the document to keep your entries.",
|
||||
&[("u", &u)],
|
||||
),
|
||||
Request::SaveAs => self.run_command("file.save_as"),
|
||||
Request::SaveAs => {
|
||||
if let Some(i) = view {
|
||||
self.save_view(i, crate::SaveTarget::As);
|
||||
}
|
||||
}
|
||||
Request::OpenAttachment { name, open } => self.open_script_attachment(id, &name, open),
|
||||
Request::Highlight(on) => self.script_highlight(id, on),
|
||||
Request::ShowPanel(p) => {
|
||||
let panel = match p {
|
||||
Panel::Bookmarks => crate::RightPanel::Bookmarks,
|
||||
@@ -95,12 +105,36 @@ impl PdfCraftApp {
|
||||
// Show/Hide: a panel already showing closes.
|
||||
self.right = (self.right != Some(panel)).then_some(panel);
|
||||
}
|
||||
Request::Focus(_) | Request::Beep | Request::Reset(_) => {}
|
||||
Request::Zoom(z) => {
|
||||
if let Some(i) = view {
|
||||
self.views[i].zoom = (z / 100.0) as f32;
|
||||
self.views[i].fit = crate::canvas::Fit::None;
|
||||
}
|
||||
}
|
||||
Request::Highlight(on) => self.script_highlight(id, on),
|
||||
Request::Focus(name) => self.focus_script_field(id, view, &name, None),
|
||||
Request::FocusField { name, object_id } => self.focus_script_field(id, view, &name, Some(object_id)),
|
||||
Request::Beep | Request::Reset(_) => {}
|
||||
}
|
||||
}
|
||||
self.show_script_messages(out.alerts);
|
||||
}
|
||||
|
||||
fn focus_script_field(&mut self, id: DocId, view: Option<usize>, name: &str, object_id: Option<u64>) {
|
||||
if let Some(i) = view
|
||||
&& let Some(f) = self.session.get(id).and_then(|d| {
|
||||
d.form.iter().find(|f| object_id.map_or_else(|| f.name == name, |key| pdfcraft_engine::js::field_object_id(f.obj) == key))
|
||||
})
|
||||
&& let Some((wi, widget)) = f.widgets.iter().enumerate().find(|(_, w)| !w.hidden && w.page.is_some())
|
||||
{
|
||||
let page = widget.page.unwrap_or(0);
|
||||
if self.views[i].page_screen_rect(page).is_none() {
|
||||
self.views[i].go_to_page(page);
|
||||
}
|
||||
self.views[i].forms.focus = Some(crate::forms_ui::open_focus(f, wi));
|
||||
}
|
||||
}
|
||||
|
||||
/// Show form script messages `messages` in order, each in the toast with its title and icon.
|
||||
/// The first takes the toast at once when it is free; otherwise it waits, like the rest, for
|
||||
/// what the toast shows (another message, or a notice such as the run's own submit request)
|
||||
@@ -168,11 +202,20 @@ impl PdfCraftApp {
|
||||
|
||||
/// Run a push button's JavaScript (its Mouse Up action).
|
||||
pub fn run_button_script(&mut self, id: DocId, field: &str, script: &str) {
|
||||
self.run_button_script_target(id, field, None, script);
|
||||
}
|
||||
/// Run the action of the exact clicked button, preserving legacy name-only entry points.
|
||||
pub fn run_button_script_target(&mut self, id: DocId, field: &str, identity: Option<u64>, script: &str) {
|
||||
// A script reads the fields: include what's still being typed in one (#166).
|
||||
if !self.commit_form_typing() {
|
||||
return;
|
||||
}
|
||||
match self.session.run_javascript(id, script, Some(field)) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if self.session.get(id).is_some_and(|d| d.info.xfa.is_none()) {
|
||||
self.start_ui_script(id, script, Some(field), identity);
|
||||
return;
|
||||
}
|
||||
match self.session.run_javascript_target(id, script, Some(field), identity) {
|
||||
Ok(o) => {
|
||||
if let Some(i) = self.views.iter().position(|v| v.id == id)
|
||||
&& let Some(info) = self.session.get(id).map(|d| d.info.clone())
|
||||
|
||||
@@ -1788,8 +1788,7 @@ fn a_script_opened_panel_shows() {
|
||||
assert!(h.query_by_label("This document has no bookmarks.").is_none());
|
||||
let id = h.state().active_ids().unwrap().1;
|
||||
h.state_mut().run_button_script(id, "", "app.execMenuItem('ShowHideBookmarks')");
|
||||
h.run_steps(4);
|
||||
h.get_by_label("This document has no bookmarks.");
|
||||
assert!(crate::support::step_until(&mut h, |h| h.query_by_label("This document has no bookmarks.").is_some()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -490,6 +490,7 @@ fn a_button_script_cannot_open_a_local_program() {
|
||||
let _gpu = gpu();
|
||||
let mut h = harness();
|
||||
click_field(&mut h, "sneaky");
|
||||
support::wait_until(&mut h, "script link refusal", |app| app.pending_link.is_some() || app.toast.is_some());
|
||||
assert_eq!(h.state().pending_link, None, "a file: address is never offered");
|
||||
assert_eq!(h.state().last_opened_url, None);
|
||||
let toast = h.state().toast.clone().expect("the user is told").0;
|
||||
@@ -503,7 +504,7 @@ fn a_script_asks_first_and_only_once() {
|
||||
let id = h.state().views[0].id;
|
||||
let script = "app.launchURL('https://example.org/?q=' + 'first'); app.launchURL('https://example.org/second');";
|
||||
h.state_mut().run_button_script(id, "web", script);
|
||||
h.run_steps(3);
|
||||
support::wait_until(&mut h, "script link request", |app| app.pending_link.is_some());
|
||||
assert_eq!(h.state().pending_link, pending("https://example.org/?q=first", LinkOrigin::Script), "one dialog, for the first request");
|
||||
assert_eq!(h.state().last_opened_url, None);
|
||||
}
|
||||
@@ -526,7 +527,7 @@ fn a_script_cannot_open_other_kinds_of_address() {
|
||||
let id = h.state().views[0].id;
|
||||
let script = format!("app.launchURL({});", serde_json::to_string(url).unwrap());
|
||||
h.state_mut().run_button_script(id, "web", &script);
|
||||
h.run_steps(3);
|
||||
support::wait_until(&mut h, "script link decision", |app| app.pending_link.is_some() || app.toast.is_some());
|
||||
assert_eq!(h.state().pending_link, None, "{url}");
|
||||
assert_eq!(h.state().last_opened_url, None, "{url}");
|
||||
assert!(h.state().toast.clone().is_some_and(|t| t.0.contains("won't open")), "{url}: {:?}", h.state().toast);
|
||||
@@ -541,7 +542,7 @@ fn an_email_link_cannot_attach_a_local_file() {
|
||||
let id = h.state().views[0].id;
|
||||
let script = format!("app.launchURL({});", serde_json::to_string(url).unwrap());
|
||||
h.state_mut().run_button_script(id, "web", &script);
|
||||
h.run_steps(3);
|
||||
support::wait_until(&mut h, "script link decision", |app| app.pending_link.is_some() || app.toast.is_some());
|
||||
assert_eq!(h.state().pending_link, None, "{url}");
|
||||
assert_eq!(h.state().last_opened_url, None, "{url}");
|
||||
let toast = h.state().toast.clone().expect("the user is told").0;
|
||||
@@ -555,7 +556,7 @@ fn prompt_for(url: &str) -> Harness<'static, PdfCraftApp> {
|
||||
let id = h.state().views[0].id;
|
||||
let script = format!("app.launchURL({});", serde_json::to_string(url).unwrap());
|
||||
h.state_mut().run_button_script(id, "web", &script);
|
||||
h.run_steps(3);
|
||||
assert!(support::step_until(&mut h, |h| h.query_by_label("Open link").is_some()), "script link prompt rendered");
|
||||
assert_eq!(h.state().pending_link, pending(url, LinkOrigin::Script));
|
||||
h
|
||||
}
|
||||
|
||||
@@ -1823,20 +1823,20 @@ fn a_form_button_opens_only_a_real_pdf_and_returns_to_the_tab_it_opened() {
|
||||
let _ = std::fs::remove_file(&saved);
|
||||
h.state_mut().pick_override = Some(vec![saved.to_string_lossy().into_owned()]);
|
||||
h.state_mut().run_button_script(id, "go", "this.exportDataObject({cName: 'fake.pdf', nLaunch: 2});");
|
||||
h.run_steps(6);
|
||||
wait_for_file(&mut h, &saved);
|
||||
assert_eq!(tabs(&h), 2, "no tab for a file that isn't a PDF: {:?}", h.state().toast);
|
||||
assert_eq!(std::fs::read(&saved).ok().as_deref(), Some(&b"not a PDF at"[..]), "offered to save instead");
|
||||
let _ = std::fs::remove_file(&saved);
|
||||
// The real attached PDF opens in a tab of its own, though another inner.pdf starts with it.
|
||||
h.state_mut().active = Some(0);
|
||||
h.state_mut().run_button_script(id, "go", "this.exportDataObject({cName: 'Guide.pdf', nLaunch: 2});");
|
||||
h.run_steps(4);
|
||||
crate::support::wait_until(&mut h, "the attachment to open", |app| app.active == Some(2));
|
||||
assert_eq!(tabs(&h), 3, "a tab of its own");
|
||||
assert_eq!(h.state().active, Some(2));
|
||||
// Asked again, it goes back to the tab it opened.
|
||||
h.state_mut().active = Some(0);
|
||||
h.state_mut().run_button_script(id, "go", "this.exportDataObject({cName: 'Guide.pdf', nLaunch: 2});");
|
||||
h.run_steps(4);
|
||||
crate::support::wait_until(&mut h, "the attachment tab to activate", |app| app.active == Some(2));
|
||||
assert_eq!((tabs(&h), h.state().active), (3, Some(2)), "the tab it opened, not the other inner.pdf");
|
||||
}
|
||||
|
||||
@@ -1858,23 +1858,24 @@ fn a_form_button_opens_its_attached_instructions_in_a_new_tab() {
|
||||
let id = h.state().views[0].id;
|
||||
// By its key in the document's attached files, as forms name it.
|
||||
h.state_mut().run_button_script(id, "go", "this.exportDataObject({cName: 'Guide.pdf', nLaunch: 2});");
|
||||
h.run_steps(4);
|
||||
crate::support::wait_until(&mut h, "the attachment to open", |app| app.active == Some(1));
|
||||
assert_eq!(tabs(&h), ["form.pdf", "inner.pdf"], "the attached PDF opens in its own tab");
|
||||
// Asked again from the form, it goes back to that tab rather than opening another.
|
||||
h.state_mut().active = Some(0);
|
||||
h.state_mut().run_button_script(id, "go", "this.exportDataObject({cName: 'Guide.pdf', nLaunch: 2});");
|
||||
h.run_steps(4);
|
||||
crate::support::wait_until(&mut h, "the attachment tab to activate", |app| app.active == Some(1));
|
||||
assert_eq!(tabs(&h), ["form.pdf", "inner.pdf"], "no second copy");
|
||||
assert_eq!(h.state().active, Some(1), "its tab is shown");
|
||||
// A file the document doesn't have is said so.
|
||||
h.state_mut().active = Some(0);
|
||||
h.state_mut().run_button_script(id, "go", "this.exportDataObject({cName: 'nope.pdf', nLaunch: 2});");
|
||||
h.run_steps(2);
|
||||
crate::support::wait_until(&mut h, "the missing attachment notice", |app| app.toast.as_ref().is_some_and(|t| t.0.contains("nope.pdf")));
|
||||
assert!(h.state().toast.as_ref().is_some_and(|t| t.0.contains("nope.pdf")), "{:?}", h.state().toast);
|
||||
assert_eq!(tabs(&h).len(), 2);
|
||||
// A name of the form's own is shortened in the message, keeping its end. (Past a line's
|
||||
// length, the script engine cuts it first, as it cuts every name a script passes.)
|
||||
h.state_mut().run_button_script(id, "go", "this.exportDataObject({cName: new Array(3001).join('x') + '.pdf', nLaunch: 2});");
|
||||
h.run_steps(2);
|
||||
crate::support::wait_until(&mut h, "the shortened attachment notice", |app| app.toast.as_ref().is_some_and(|t| t.0.contains("x.pdf")));
|
||||
let toast = h.state().toast.as_ref().map(|t| t.0.clone()).unwrap_or_default();
|
||||
assert!(toast.contains('…') && toast.contains("x.pdf"), "{toast}");
|
||||
assert!(toast.chars().count() < 200, "{} characters", toast.chars().count());
|
||||
@@ -2017,12 +2018,10 @@ fn a_form_button_shows_and_hides_the_attachments_panel() {
|
||||
h.run_steps(2);
|
||||
let id = h.state().views[0].id;
|
||||
h.state_mut().run_button_script(id, "size", "app.execMenuItem('ShowHideFileAttachment');");
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().right, Some(pdfcraft_ui_egui::RightPanel::Attachments));
|
||||
crate::support::wait_until(&mut h, "attachments panel to open", |app| app.right == Some(pdfcraft_ui_egui::RightPanel::Attachments));
|
||||
// Show/Hide, as its name says: asked again, it closes.
|
||||
h.state_mut().run_button_script(id, "size", "app.execMenuItem('ShowHideFileAttachment');");
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().right, None);
|
||||
crate::support::wait_until(&mut h, "attachments panel to close", |app| app.right.is_none());
|
||||
}
|
||||
|
||||
/// Which widgets of field `name` are on (their `/AS` is their on state).
|
||||
|
||||
@@ -7,11 +7,16 @@ use pdfcraft_ui_egui::{Dialog, PdfCraftApp};
|
||||
|
||||
/// A form with a text field and a push button whose Mouse Up script fills it in.
|
||||
fn form() -> Vec<u8> {
|
||||
form_with_text_action("")
|
||||
}
|
||||
|
||||
fn form_with_text_action(action: &str) -> Vec<u8> {
|
||||
let greeting = format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T (greeting) /Rect [20 250 280 270] /P 3 0 R /F 4 {action} >>");
|
||||
let objs = [
|
||||
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 5 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 6 0 R >> >> >> >>",
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 300] >>",
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R] >>",
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (greeting) /Rect [20 250 280 270] /P 3 0 R /F 4 >>",
|
||||
&greeting,
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Btn /Ff 65536 /T (hello) /Rect [20 200 120 230] /P 3 0 R /F 4 /A << /S /JavaScript /JS (getField\\('greeting'\\).value = 'Hello from JavaScript'; app.alert\\('Filled in'\\);) >> >>",
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>",
|
||||
];
|
||||
@@ -76,9 +81,43 @@ fn button_scripts_run_and_alert() {
|
||||
let mut h = harness();
|
||||
let id = h.state().active_ids().unwrap().1;
|
||||
h.state_mut().run_button_script(id, "hello", "getField('greeting').value = 'Hello from JavaScript'; app.alert('Filled in');");
|
||||
h.run_steps(2);
|
||||
assert_eq!(value(&h, "greeting"), ["Hello from JavaScript"]);
|
||||
assert_eq!(h.state().toast.clone().unwrap().0, "Filled in");
|
||||
wait_for(&mut h, "Filled in");
|
||||
h.get_by_label("OK").click();
|
||||
wait_value(&mut h, "greeting", "Hello from JavaScript");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_widget_mouse_up_runs_its_menu_and_keeps_the_result() {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.set_option("language", "en").unwrap();
|
||||
app.set_option("left", "closed").unwrap();
|
||||
let bytes = form_with_text_action(
|
||||
r"/AA << /U << /S /JavaScript /JS (var choice = app.popUpMenuEx\({cName:'Choose test value',cReturn:'selected'}\); if \(choice\) getField\('greeting'\).value = choice;) >> >>",
|
||||
);
|
||||
app.open_bytes("text-action.pdf", None, bytes).unwrap();
|
||||
app
|
||||
});
|
||||
crate::support::settle(&mut h);
|
||||
let point = {
|
||||
let app = h.state();
|
||||
let (index, id) = app.active_ids().unwrap();
|
||||
let doc = app.session.get(id).unwrap();
|
||||
let field = doc.form.iter().find(|field| field.name == "greeting").unwrap();
|
||||
assert!(field.widgets[0].mouse_up_script.is_some());
|
||||
pdfcraft_ui_egui::forms_ui::field_screen_rect(&app.views[index], &doc.info, field, 0).unwrap().center()
|
||||
};
|
||||
for _ in 0..2 {
|
||||
h.hover_at(point);
|
||||
h.run_steps(1);
|
||||
h.drag_at(point);
|
||||
h.run_steps(1);
|
||||
h.drop_at(point);
|
||||
wait_for(&mut h, "Choose test value");
|
||||
h.get_by_label("Choose test value").click();
|
||||
wait_value(&mut h, "greeting", "selected");
|
||||
h.run_steps(8);
|
||||
}
|
||||
}
|
||||
|
||||
/// One run's messages are all shown, in order (#4294): the first takes the toast with its title
|
||||
@@ -90,7 +129,12 @@ fn every_message_a_script_shows_takes_the_toast_in_turn() {
|
||||
use pdfcraft_engine::js::{MessageButtons, MessageIcon};
|
||||
let mut h = harness();
|
||||
let id = h.state().active_ids().unwrap().1;
|
||||
h.state_mut().run_button_script(id, "hello", "app.alert('First', 1, 0, 'Order form'); app.alert('Second', 3); app.alert('Third');");
|
||||
let out = h
|
||||
.state_mut()
|
||||
.session
|
||||
.run_javascript(id, "app.alert('First', 1, 0, 'Order form'); app.alert('Second', 3); app.alert('Third');", None)
|
||||
.unwrap();
|
||||
h.state_mut().handle_js(id, pdfcraft_engine::js::JsOutput { alerts: out.alerts, ..Default::default() });
|
||||
h.run_steps(2);
|
||||
let shown = |h: &Harness<'static, PdfCraftApp>| h.state().toast.clone().map(|t| t.0);
|
||||
assert_eq!(shown(&h).as_deref(), Some("First"));
|
||||
@@ -114,7 +158,8 @@ fn every_message_a_script_shows_takes_the_toast_in_turn() {
|
||||
// follows it.
|
||||
h.state_mut().toast = None;
|
||||
h.run_steps(2);
|
||||
h.state_mut().run_button_script(id, "hello", "this.submitForm('https://example.com/form'); app.alert('Fourth');");
|
||||
let out = h.state_mut().session.run_javascript(id, "this.submitForm('https://example.com/form'); app.alert('Fourth');", None).unwrap();
|
||||
h.state_mut().handle_js(id, pdfcraft_engine::js::JsOutput { alerts: out.alerts, requests: out.requests, ..Default::default() });
|
||||
h.run_steps(2);
|
||||
let notice = shown(&h).unwrap_or_default();
|
||||
assert!(notice.contains("submitted"), "the submit notice stays: {notice}");
|
||||
@@ -215,3 +260,439 @@ fn prepare_a_form_detects_fields_on_a_paper_form() {
|
||||
assert_eq!(names, ["Name", "Phone"]);
|
||||
assert_eq!(app.toast.clone().unwrap().0, "Detected 2 form fields");
|
||||
}
|
||||
|
||||
/// Advance bounded UI frames until the synthetic prompt label becomes available.
|
||||
fn wait_for(h: &mut Harness<'static, PdfCraftApp>, label: &str) {
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(12);
|
||||
while std::time::Instant::now() < deadline {
|
||||
h.run_steps(1);
|
||||
if h.query_by_label(label).is_some() {
|
||||
return;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
panic!("script dialog did not show {label}");
|
||||
}
|
||||
/// Advance bounded UI frames until the worker completion updates the expected synthetic field value.
|
||||
fn wait_value(h: &mut Harness<'static, PdfCraftApp>, name: &str, expected: &str) {
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(12);
|
||||
while std::time::Instant::now() < deadline {
|
||||
h.run_steps(1);
|
||||
if value(h, name) == [expected] {
|
||||
return;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
assert_eq!(value(h, name), [expected]);
|
||||
}
|
||||
#[test]
|
||||
/// Every native script prompt identifies its document, even with misleading or empty titles.
|
||||
fn native_script_prompts_show_an_app_owned_caption() {
|
||||
for (script, body, title) in [
|
||||
("app.response({cQuestion:'Type a value',cTitle:'PdfCraft security',bPassword:true});", "Type a value", Some("PdfCraft security")),
|
||||
("app.response({cQuestion:'Empty title prompt',cTitle:''});", "Empty title prompt", None),
|
||||
("app.alert({cMsg:'Script message',cTitle:'PdfCraft security'});", "Script message", Some("PdfCraft security")),
|
||||
("app.alert({cMsg:'Untitled alert',cTitle:''});", "Untitled alert", None),
|
||||
("app.popUpMenuEx({cName:'Pick a value',cReturn:'yes'});", "Pick a value", None),
|
||||
] {
|
||||
let mut h = harness();
|
||||
let id = h.state().active_ids().unwrap().1;
|
||||
h.state_mut().run_button_script(id, "hello", script);
|
||||
wait_for(&mut h, body);
|
||||
assert!(h.query_by_label("This document's script is asking").is_some(), "{script}");
|
||||
assert_eq!(
|
||||
h.query_by_label("This request comes from the document, not PdfCraft. Only enter a password if you trust this document.").is_some(),
|
||||
script.contains("bPassword:true"),
|
||||
"{script}"
|
||||
);
|
||||
assert!(h.query_by_label("Document: form.pdf").is_some(), "{script}");
|
||||
if let Some(title) = title {
|
||||
assert!(h.query_by_label(title).is_some(), "{script}");
|
||||
}
|
||||
h.get_by_label("Cancel").click();
|
||||
h.run_steps(2);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_script_prompt_uses_the_german_catalog_when_available() {
|
||||
use pdfcraft_ui_egui::i18n::{Lang, tr};
|
||||
|
||||
let Some(german) = Lang::from_code("de") else { return };
|
||||
let source = "This document's script is asking";
|
||||
let heading = "Das Skript dieses Dokuments fragt";
|
||||
// Older catalog pins can still build the application with the English fallback.
|
||||
if tr(german, source) == source && std::env::var_os("PDFCRAFT_REQUIRE_SCRIPT_PROMPT_TRANSLATIONS").is_none() {
|
||||
return;
|
||||
}
|
||||
assert_eq!(tr(german, source), heading);
|
||||
let mut h = harness();
|
||||
h.state_mut().set_option("language", "de").unwrap();
|
||||
let id = h.state().active_ids().unwrap().1;
|
||||
h.state_mut().run_button_script(id, "hello", "app.response({cQuestion:'Test',bPassword:true});");
|
||||
wait_for(&mut h, heading);
|
||||
assert!(h.query_by_label("Dokument: form.pdf").is_some());
|
||||
assert!(h.query_by_label("Diese Aufforderung stammt aus dem Dokument und wird von dessen Skript angezeigt. Gib ein Passwort nur ein, wenn du diesem Dokument vertraust.").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// A real UI menu followed by input resumes once and does not leak the menu click into the next modal.
|
||||
fn native_button_popup_and_response_resume_without_replaying_the_script() {
|
||||
for typed in [false, true] {
|
||||
let mut h = harness();
|
||||
let id = h.state().active_ids().unwrap().1;
|
||||
h.state_mut().run_button_script(id,"hello","var calls=typeof calls==='number'?calls:0;calls++;var choice=app.popUpMenuEx({cName:'Morning',cReturn:'08:00'});var extra=app.response({cTitle:'Manual time',cQuestion:'Suffix',cDefault:'x'});getField('greeting').value=choice+'/'+extra+'/'+calls;");
|
||||
wait_for(&mut h, "Morning");
|
||||
h.get_by_label("Morning").click();
|
||||
wait_for(&mut h, "Suffix");
|
||||
if typed {
|
||||
h.get_by(|n| n.role() == egui::accesskit::Role::TextInput && n.value().as_deref() == Some("x")).focus();
|
||||
h.run_steps(2);
|
||||
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A);
|
||||
h.run_steps(1);
|
||||
h.get_by(|n| n.role() == egui::accesskit::Role::TextInput && n.is_focused()).type_text("typed answer");
|
||||
h.run_steps(2);
|
||||
}
|
||||
if let Ok(path) = std::env::var("PDFCRAFT_COMPAT_SCREENSHOT") {
|
||||
let image = h.render().unwrap();
|
||||
let crop = image::imageops::crop_imm(&image, 400, 220, 600, 460).to_image();
|
||||
crop.save(path).unwrap();
|
||||
}
|
||||
h.get_by_label("OK").click();
|
||||
wait_value(&mut h, "greeting", if typed { "08:00/typed answer/1" } else { "08:00/x/1" });
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
/// Cancelling a native menu returns null to the same invocation without duplicating preceding work.
|
||||
fn native_popup_cancellation_returns_null_and_preserves_the_single_invocation() {
|
||||
let mut h = harness();
|
||||
let id = h.state().active_ids().unwrap().1;
|
||||
let script = "var calls=typeof calls==='number'?calls:0;calls++;var choice=app.popUpMenuEx({cName:'Choose',cReturn:'yes'});getField('greeting').value=String(choice)+'/'+calls;";
|
||||
h.state_mut().run_button_script(id, "hello", script);
|
||||
wait_for(&mut h, "Choose");
|
||||
h.get_by_label("Cancel").click();
|
||||
wait_value(&mut h, "greeting", "null/1");
|
||||
h.state_mut().run_button_script(id, "hello", script);
|
||||
wait_for(&mut h, "Choose");
|
||||
h.get_by_label("Cancel").click();
|
||||
wait_value(&mut h, "greeting", "null/2");
|
||||
}
|
||||
|
||||
/// A nested menu exposes only enabled leaves as valid replies to the waiting script.
|
||||
#[test]
|
||||
fn native_popup_submenu_keeps_disabled_items_unselectable() {
|
||||
let mut h = harness();
|
||||
let id = h.state().active_ids().unwrap().1;
|
||||
let script = "var calls=typeof calls==='number'?calls:0; calls++; \
|
||||
var choice=app.popUpMenuEx({cName:'Group',oSubMenu:[{cName:'Disabled',bEnabled:false}, \
|
||||
{cName:'Enabled',cReturn:'chosen'}]}); getField('greeting').value=choice+'/'+calls;";
|
||||
h.state_mut().run_button_script(id, "hello", script);
|
||||
wait_for(&mut h, "Group");
|
||||
h.get_by_label("Group").click();
|
||||
wait_for(&mut h, "Enabled");
|
||||
// The modal recentres while the submenu expands. Settle the layout before sending pointer input.
|
||||
h.run_steps(30);
|
||||
assert!(h.query_by(|node| node.label().as_deref() == Some("Disabled") && node.is_disabled()).is_some());
|
||||
h.get_by_label("Enabled").click();
|
||||
wait_value(&mut h, "greeting", "chosen/1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// An intervening document edit prevents an older pending script result from replacing current values.
|
||||
fn native_popup_completion_is_discarded_after_the_document_is_edited() {
|
||||
let mut h = harness();
|
||||
let id = h.state().active_ids().unwrap().1;
|
||||
h.state_mut().run_button_script(id, "hello", "app.popUpMenuEx({cName:'Wait',cReturn:'yes'});getField('greeting').value='stale';");
|
||||
wait_for(&mut h, "Wait");
|
||||
h.state_mut()
|
||||
.session
|
||||
.apply(id, pdfcraft_engine::Edit::SetFieldValue { name: "greeting".into(), value: pdfcraft_engine::FieldValue::Text("current".into()) })
|
||||
.unwrap();
|
||||
h.run_steps(5);
|
||||
assert!(h.query_by_label("Wait").is_none(), "the stale prompt must be dismissed");
|
||||
assert_eq!(value(&h, "greeting"), ["current"]);
|
||||
// The document's worker remains reserved until the abandoned prompt exits.
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
|
||||
loop {
|
||||
let out = h.state_mut().session.run_javascript(id, "var afterEdit='kept';", None).unwrap();
|
||||
let Some(error) = out.error else { break };
|
||||
assert!(out.execution_refused && error.contains("another script is active"), "{error}");
|
||||
assert!(std::time::Instant::now() < deadline, "the abandoned worker did not finish");
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
assert_eq!(h.state_mut().session.run_javascript(id, "afterEdit", None).unwrap().result.as_deref(), Some("kept"));
|
||||
}
|
||||
|
||||
/// Native Mouse Up must expose the same willCommit flag as the headless button path.
|
||||
#[test]
|
||||
fn native_button_events_match_headless_will_commit() {
|
||||
let mut h = harness();
|
||||
let id = h.state().active_ids().unwrap().1;
|
||||
let headless = h.state_mut().session.run_javascript(id, "String(event.willCommit);", Some("hello")).unwrap();
|
||||
assert_eq!(headless.result.as_deref(), Some("false"));
|
||||
h.state_mut().run_button_script(id, "hello", "getField('greeting').value=String(event.willCommit);");
|
||||
wait_value(&mut h, "greeting", "false");
|
||||
}
|
||||
|
||||
/// A popup owns keyboard shortcuts; Undo must not edit the document below it.
|
||||
#[test]
|
||||
fn native_popup_keeps_undo_shortcuts_out_of_the_document() {
|
||||
let mut h = harness();
|
||||
let id = h.state().active_ids().unwrap().1;
|
||||
h.state_mut()
|
||||
.session
|
||||
.apply(id, pdfcraft_engine::Edit::SetFieldValue { name: "greeting".into(), value: pdfcraft_engine::FieldValue::Text("current".into()) })
|
||||
.unwrap();
|
||||
h.state_mut().run_button_script(id, "hello", "app.popUpMenuEx({cName:'Wait',cReturn:'yes'});");
|
||||
wait_for(&mut h, "Wait");
|
||||
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::Z);
|
||||
h.run_steps(3);
|
||||
assert_eq!(value(&h, "greeting"), ["current"]);
|
||||
assert!(h.query_by_label("Wait").is_some());
|
||||
h.get_by_label("Cancel").click();
|
||||
h.run_steps(5);
|
||||
}
|
||||
|
||||
/// A script response stays clickable while another modal remains open underneath it.
|
||||
#[test]
|
||||
fn native_script_prompt_is_above_an_existing_dialog() {
|
||||
let _gpu = crate::gpu();
|
||||
for open_first in [true, false] {
|
||||
let mut h = harness();
|
||||
let id = h.state().active_ids().unwrap().1;
|
||||
if open_first {
|
||||
h.state_mut().dialog = Some(Dialog::JsConsole);
|
||||
h.run_steps(3);
|
||||
}
|
||||
h.state_mut().run_button_script(
|
||||
id,
|
||||
"hello",
|
||||
"getField('greeting').value=app.response({cTitle:'Top prompt',cQuestion:'Answer here',cDefault:'visible'});",
|
||||
);
|
||||
wait_for(&mut h, "Answer here");
|
||||
if !open_first {
|
||||
h.state_mut().dialog = Some(Dialog::JsConsole);
|
||||
}
|
||||
h.run_steps(4);
|
||||
let expected = egui::Id::unique(("document-script-prompt", id.0, 1u32));
|
||||
assert_eq!(h.ctx.memory(|m| m.top_modal_layer()).map(|l| l.id), Some(expected), "the prompt owns modal input, open_first={open_first}");
|
||||
if let Ok(path) = std::env::var("PDFCRAFT_REVIEW4_MODAL_SCREENSHOT") {
|
||||
h.render().unwrap().save(path).unwrap();
|
||||
}
|
||||
h.get_by_label("OK").click();
|
||||
wait_value(&mut h, "greeting", "visible");
|
||||
assert_eq!(h.state().dialog, Some(Dialog::JsConsole));
|
||||
h.state_mut().run_button_script(id, "hello", "getField('greeting').value=String(app.response({cQuestion:'Cancel here'}));");
|
||||
wait_for(&mut h, "Cancel here");
|
||||
h.key_press(egui::Key::Escape);
|
||||
wait_value(&mut h, "greeting", "null");
|
||||
assert_eq!(h.state().dialog, Some(Dialog::JsConsole));
|
||||
}
|
||||
}
|
||||
|
||||
/// A real pointer click must retain the second same-named button through native completion.
|
||||
#[test]
|
||||
fn clicking_duplicate_named_buttons_targets_the_clicked_object() {
|
||||
let _gpu = crate::gpu();
|
||||
use pdfcraft_cos::{Dict, ObjRef, Object, PdfString};
|
||||
use std::sync::Arc;
|
||||
let mut cos = pdfcraft_cos::Document::open(Arc::new(form())).unwrap();
|
||||
let first = ObjRef::new(5, 0);
|
||||
let action = |caption: &str| {
|
||||
let mut a = Dict::new();
|
||||
a.set(b"S".to_vec(), Object::name("JavaScript"));
|
||||
a.set(b"JS".to_vec(), PdfString::text(&format!("event.target.buttonSetCaption('{caption}');")));
|
||||
Object::Dict(a)
|
||||
};
|
||||
cos.update_dict(first, |d| {
|
||||
d.set(b"A".to_vec(), action("first"));
|
||||
let mut mk = Dict::new();
|
||||
mk.set(b"CA".to_vec(), PdfString::text("idle"));
|
||||
d.set(b"MK".to_vec(), Object::Dict(mk));
|
||||
})
|
||||
.unwrap();
|
||||
let mut second = cos.get(first).as_dict().unwrap().clone();
|
||||
second.set(b"Rect".to_vec(), Object::Array(vec![160.into(), 200.into(), 280.into(), 230.into()]));
|
||||
second.set(b"A".to_vec(), action("second"));
|
||||
let second = cos.add(Object::Dict(second));
|
||||
let root = cos.root().unwrap();
|
||||
cos.update_dict(root, |d| {
|
||||
let mut af = d.get(b"AcroForm").unwrap().as_dict().unwrap().clone();
|
||||
let mut refs = af.get(b"Fields").unwrap().as_array().unwrap().clone();
|
||||
refs.push(second.into());
|
||||
af.set(b"Fields".to_vec(), Object::Array(refs));
|
||||
d.set(b"AcroForm".to_vec(), Object::Dict(af));
|
||||
})
|
||||
.unwrap();
|
||||
cos.update_dict(ObjRef::new(3, 0), |d| {
|
||||
let mut a = d.get(b"Annots").unwrap().as_array().unwrap().clone();
|
||||
a.push(second.into());
|
||||
d.set(b"Annots".to_vec(), Object::Array(a));
|
||||
})
|
||||
.unwrap();
|
||||
let bytes = pdfcraft_cos::write_full(&cos, &Default::default()).unwrap();
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.set_option("language", "en").unwrap();
|
||||
app.open_bytes("duplicate-buttons.pdf", None, bytes).unwrap();
|
||||
app
|
||||
});
|
||||
h.run_steps(6);
|
||||
let captions = |h: &Harness<'_, PdfCraftApp>| {
|
||||
let id = h.state().active_ids().unwrap().1;
|
||||
h.state()
|
||||
.session
|
||||
.get(id)
|
||||
.unwrap()
|
||||
.form
|
||||
.iter()
|
||||
.filter(|f| f.name == "hello")
|
||||
.map(|f| f.widgets.first().and_then(|w| w.caption.clone()))
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
let r = h.state().views[0].page_screen_rect(0).unwrap();
|
||||
let at = r.min + egui::vec2(r.width() * 220.0 / 300.0, r.height() * 85.0 / 300.0);
|
||||
h.hover_at(at);
|
||||
h.run_steps(1);
|
||||
h.drag_at(at);
|
||||
h.run_steps(1);
|
||||
h.drop_at(at);
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(12);
|
||||
while std::time::Instant::now() < deadline {
|
||||
h.run_steps(2);
|
||||
if captions(&h).iter().any(|s| s.as_deref() == Some("second")) {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
assert_eq!(captions(&h), [Some("idle".into()), Some("second".into())]);
|
||||
assert!(h.state_mut().execute("edit.undo"));
|
||||
h.run_steps(3);
|
||||
assert_eq!(captions(&h), [Some("idle".into()), Some("idle".into())]);
|
||||
assert!(h.state_mut().execute("edit.redo"));
|
||||
h.run_steps(3);
|
||||
assert_eq!(captions(&h), [Some("idle".into()), Some("second".into())]);
|
||||
let r = h.state().views[0].page_screen_rect(0).unwrap();
|
||||
let at = r.min + egui::vec2(r.width() * 70.0 / 300.0, r.height() * 85.0 / 300.0);
|
||||
h.hover_at(at);
|
||||
h.run_steps(1);
|
||||
h.drag_at(at);
|
||||
h.run_steps(1);
|
||||
h.drop_at(at);
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(12);
|
||||
while std::time::Instant::now() < deadline {
|
||||
h.run_steps(2);
|
||||
if captions(&h).first().and_then(|s| s.as_deref()) == Some("first") {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
assert_eq!(captions(&h), [Some("first".into()), Some("second".into())]);
|
||||
let id = h.state().active_ids().unwrap().1;
|
||||
let bytes = h.state().session.save_bytes(id).unwrap();
|
||||
let mut reopened = pdfcraft_engine::Session::new();
|
||||
reopened.set_javascript(false);
|
||||
let reopened_id = reopened.open("saved-buttons.pdf", None, bytes, None).unwrap();
|
||||
assert_eq!(
|
||||
reopened
|
||||
.get(reopened_id)
|
||||
.unwrap()
|
||||
.form
|
||||
.iter()
|
||||
.filter(|f| f.name == "hello")
|
||||
.map(|f| f.widgets.first().and_then(|w| w.caption.clone()))
|
||||
.collect::<Vec<_>>(),
|
||||
[Some("first".into()), Some("second".into())]
|
||||
);
|
||||
if let Ok(path) = std::env::var("PDFCRAFT_REVIEW12_BUTTON_SCREENSHOT") {
|
||||
// Let the asynchronous page raster catch up with the final accepted script edit.
|
||||
for _ in 0..60 {
|
||||
h.run_steps(2);
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
h.render().unwrap().save(path).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// The real image-button click and picker callback must preserve the second duplicate's identity.
|
||||
#[test]
|
||||
fn clicking_duplicate_image_buttons_imports_only_into_the_clicked_field() {
|
||||
let _gpu = crate::gpu();
|
||||
let mut session = pdfcraft_engine::Session::new();
|
||||
let bytes = session.create_blank(300.0, 300.0, 1).unwrap();
|
||||
let id = session.open("images.pdf", None, bytes, None).unwrap();
|
||||
for (name, x) in [("first", 20.0), ("second", 160.0)] {
|
||||
session
|
||||
.apply(
|
||||
id,
|
||||
pdfcraft_engine::Edit::AddField {
|
||||
page: 0,
|
||||
rect: [x, 200.0, x + 100.0, 230.0],
|
||||
kind: pdfcraft_engine::NewField::Image,
|
||||
name: Some(name.into()),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let mut cos = pdfcraft_cos::Document::open(session.save_bytes(id).unwrap()).unwrap();
|
||||
for field in session.get(id).unwrap().form.iter() {
|
||||
cos.update_dict(field.obj, |d| d.set(b"T".to_vec(), pdfcraft_cos::PdfString::text("same"))).unwrap();
|
||||
}
|
||||
let bytes = pdfcraft_cos::write_full(&cos, &Default::default()).unwrap();
|
||||
let dir = std::env::temp_dir().join(format!("pdfcraft-exact-image-click-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("synthetic.png");
|
||||
let mut png = vec![];
|
||||
{
|
||||
let mut encoder = png::Encoder::new(&mut png, 2, 2);
|
||||
encoder.set_color(png::ColorType::Rgb);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
encoder.write_header().unwrap().write_image_data(&[120u8; 12]).unwrap();
|
||||
}
|
||||
std::fs::write(&path, png).unwrap();
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.set_option("language", "en").unwrap();
|
||||
app.open_bytes("images.pdf", None, bytes).unwrap();
|
||||
app.save_override = Some(path.to_string_lossy().into_owned());
|
||||
app
|
||||
});
|
||||
h.run_steps(6);
|
||||
let rect = h.state().views[0].page_screen_rect(0).unwrap();
|
||||
let at = rect.min + egui::vec2(rect.width() * 210.0 / 300.0, rect.height() * 85.0 / 300.0);
|
||||
h.hover_at(at);
|
||||
h.run_steps(1);
|
||||
h.drag_at(at);
|
||||
h.run_steps(1);
|
||||
h.drop_at(at);
|
||||
h.run_steps(3);
|
||||
let icons = |h: &mut Harness<'_, PdfCraftApp>| {
|
||||
let id = h.state().active_ids().unwrap().1;
|
||||
let cos = pdfcraft_cos::Document::open(h.state_mut().session.save_bytes(id).unwrap()).unwrap();
|
||||
let root = cos.root().unwrap();
|
||||
let form = cos.get(root).as_dict().unwrap().get(b"AcroForm").map(|v| cos.resolve(v)).unwrap();
|
||||
form.as_dict()
|
||||
.unwrap()
|
||||
.get(b"Fields")
|
||||
.unwrap()
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|r| {
|
||||
cos.resolve(r)
|
||||
.as_dict()
|
||||
.and_then(|d| d.get(b"MK").map(|v| cos.resolve(v)))
|
||||
.is_some_and(|m| m.as_dict().is_some_and(|d| d.contains(b"I")))
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(icons(&mut h), [false, true]);
|
||||
assert!(h.state_mut().execute("edit.undo"));
|
||||
h.run_steps(2);
|
||||
assert_eq!(icons(&mut h), [false, false]);
|
||||
assert!(h.state_mut().execute("edit.redo"));
|
||||
h.run_steps(2);
|
||||
assert_eq!(icons(&mut h), [false, true]);
|
||||
std::fs::remove_file(path).unwrap();
|
||||
std::fs::remove_dir(dir).unwrap();
|
||||
}
|
||||
@@ -107,8 +107,8 @@ pub use pdf::{AREA_KEY, CLICK_KEY, DATE_KEY, LAYOUT_KEY, NODE_KEY, SOM_KEY, Stri
|
||||
pub use picture::typed_date;
|
||||
pub use script::{
|
||||
FormNode, LiveForm, MAX_FORM_NODES, NodeKind, OVERRIDES_KEY, Overrides, ScriptEvent, apply_overrides, data_of, embedded_fields,
|
||||
field_grows_with_text, field_is_embedded, fields_by_som, form_tree, form_tree_paged, has_focus_scripts, has_scripts, overrides, page_area_uses,
|
||||
path_is_embedded, refresh_value, rerender, reset_default, set_overrides, template_of,
|
||||
field_grows_with_text, field_is_embedded, field_objects_by_som, fields_by_som, form_tree, form_tree_paged, has_focus_scripts, has_scripts,
|
||||
overrides, page_area_uses, path_is_embedded, refresh_value, rerender, reset_default, set_overrides, template_of,
|
||||
};
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
|
||||
@@ -598,6 +598,11 @@ pub fn apply_overrides(tpl: &Template, ov: &Overrides) -> Template {
|
||||
|
||||
/// The generated fields of the AcroForm by SOM path: `som → (field name, is a radio group)`.
|
||||
pub fn fields_by_som(doc: &Document) -> HashMap<String, String> {
|
||||
field_objects_by_som(doc).into_iter().map(|(som, (name, _))| (som, name)).collect()
|
||||
}
|
||||
|
||||
/// Resolve each generated SOM path to its AcroForm name and exact field dictionary.
|
||||
pub fn field_objects_by_som(doc: &Document) -> HashMap<String, (String, pdfcraft_cos::ObjRef)> {
|
||||
// Each field is read once: a field listed twice in `/Kids` (or among its own kids) would
|
||||
// otherwise be walked again for every path to it, doubling the work at each level. Fields
|
||||
// are told apart by object number, as `Document::get` finds them, so `5 0 R` and `5 1 R`
|
||||
@@ -606,7 +611,7 @@ pub fn fields_by_som(doc: &Document) -> HashMap<String, String> {
|
||||
// long `/Kids` array stay bounded too; `walk_all` returns false once it is spent, and every
|
||||
// loop then stops.
|
||||
struct Walk {
|
||||
out: HashMap<String, String>,
|
||||
out: HashMap<String, (String, pdfcraft_cos::ObjRef)>,
|
||||
/// The shallowest depth each object number was read at.
|
||||
seen: HashMap<u32, usize>,
|
||||
steps: usize,
|
||||
@@ -636,7 +641,7 @@ pub fn fields_by_som(doc: &Document) -> HashMap<String, String> {
|
||||
if let Some(som) = d.get(crate::pdf::SOM_KEY).map(|s| doc.resolve(s)).and_then(|s| s.as_string().map(|s| s.to_text()))
|
||||
&& t.is_some()
|
||||
{
|
||||
w.out.insert(som, name.clone());
|
||||
w.out.insert(som, (name.clone(), r));
|
||||
}
|
||||
// Read in place: a copy of the array at every level would cost its length each time.
|
||||
let kids = d.get(b"Kids").map(|k| doc.resolve(k));
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
# 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.
|
||||
@@ -1,3 +1,8 @@
|
||||
- 2026-11-04 (M6, PR #5699 group/cache/MK/button review): Expand calculation groups/duplicates with streaming bounded lookup; retain one canonical Field object per exposed identity and prune stale roots. Protect outside shared-MK owners and carry exact button IDs through native clicks and js_run, with regression and persistence coverage.
|
||||
- 2026-11-04 (M6, PR #5699 early reset/duplicate identities): Enforce the shared 64-call reset budget before argument conversion/scanning, with sticky atomic rejection and Open/event budget sharing. Preserve PDF object identity in Calculate order, live targets and value writeback; regress duplicate names, reversed order, native completion and persistence.
|
||||
- 2026-11-04 (M6, PR #5699 independent review): Preserve unknown defaults through the JS reset shadow; bound menu/response text before copies. Cover native/console reset persistence, inherited defaults, decode-budget exhaustion and exact Unicode dialog boundaries.
|
||||
- 2026-11-04 (M6, PR #5699 memory/widget bounds): Count reset widgets and bound escaped UTF-8 text conversion before copies; add negative and Unicode boundary regressions.
|
||||
- 2026-11-04 (M6, PR #5699 reset estimate): Charge expanded-name scans and field-name copy/comparison bytes; add three negative regressions and byte-exact refusal checks.
|
||||
# PdfCraft roadmap: milestones and what's next
|
||||
|
||||
> **Last reviewed:** 2026-10-27 | **Last updated:** 2026-10-27 | **Change:** minor (M10: vector printing preserves annotation optional-content membership; estimates unchanged)
|
||||
@@ -12,6 +17,11 @@
|
||||
> **Last reviewed:** 2026-11-09 | **Last updated:** 2026-11-09 | **Change:** patch (M10 #5130: preserve manual duplex defaults; validate DocumentDuplex review fix and CI formatting; estimates unchanged)
|
||||
>
|
||||
> **Last reviewed:** 2026-11-07 | **Last updated:** 2026-11-07 | **Change:** patch (M7 #2964 / #3185: grouped source text and Thai font/style preservation, with reported deep or unsafe groups and a per-glyph check of the Thai map repair; estimates unchanged)
|
||||
> **2026-11-04 (M6, PR #5699 reset budget):** Reject reset amplification before request copies, preview and PDF mutation; cap reset/selector counts and aggregate estimated work. Add native completion, boundary, flood and large-form regressions; preserve ordered under-budget resets.
|
||||
> **2026-11-04 (M6, PR #5699 dependent reset):** Reuse form reset on an isolated comparison document so unnamed built-in calculated text/combo fields are included; retain normal post-write calculation. Extend persisted-default and typed-response regressions.
|
||||
> **2026-11-04 (M6, PR #5699 reset ordering):** Reset live field values synchronously, commit resets before final script deltas using a reset baseline, and cover Doc Open reinitialization, save/reopen, Undo/Redo and replay-sensitive UI counters.
|
||||
> **2026-11-04 (M6, PR #5699 review):** Bound worker capacity and synchronous deadlines, isolate expired/pending realms before edits, keep script prompts above other dialogs, and cover block ASI, control-position regex literals, event routing and complete undo state. Validation is reported in the PR; parity remains partial.
|
||||
> **2026-11-03 (M6, PR #5699 follow-up):** Accept independent top-level statements separated by automatic semicolon insertion while retaining continuation-chain limits; assert cyclic calculation results and preserve upstream multiline Fill & Sign coverage.
|
||||
|
||||
> **Last reviewed:** 2026-10-28 | **Last updated:** 2026-10-28 | **Change:** minor (M10, #4119 partial: Enhance scanned file cleans scanned pages in place: background removal, edge shadow removal, despeckle; estimates unchanged)
|
||||
> **Last reviewed:** 2026-11-03 | **Last updated:** 2026-11-03 | **Change:** patch (M5 #5850 review: indirect typewriter intent and composer edits preserve outer whitespace; movement regression commits by outside click; estimates unchanged)
|
||||
@@ -23,7 +33,10 @@
|
||||
> **Last reviewed:** 2026-10-31 | **Last updated:** 2026-10-31 | **Change:** minor (M3/M4, #5330: Crop pages is directly available in the document quick toolbar; estimates unchanged)
|
||||
|
||||
|
||||
> **2026-11-02 (M6, PR #5699 second review):** Count active expression chains instead of whole-source tokens; align native button events and automation schemas, block document input under script prompts, and validate prompt dismissal and idle repaint scheduling.
|
||||
> **Last reviewed:** 2026-11-01 | **Last updated:** 2026-11-01 | **Change:** patch (M7 #5657: header/footer, watermark and background operations commit all selected pages together; later-page errors preserve marks, content, shared/inherited resources and pending edits; estimates unchanged)
|
||||
> **2026-11-02 (M6, PR #5699 review):** Cache per-edit script geometry/calculation plans, expose live document metadata, and bound persistent worker stacks to 64 MiB; add operation-count, lifecycle and isolated hostile-input/address-space regressions.
|
||||
> **2026-11-02 (M6, #5211/#269):** Integrate persistent native AcroForm realms, synchronous calculation, page boxes, appearance/geometry/icon writeback and native button prompt continuation. Synthetic workflow and cancellation tests added; validation details accompany the PR. Remaining JavaScript parity stays partial.
|
||||
|
||||
> **Last reviewed:** 2026-11-01 | **Last updated:** 2026-11-01 | **Change:** patch (M7, PR #2155 further review: inherited drawing/text state, affine image gestures, paste/selection ownership, no-op scripts and menu permissions; estimates unchanged)
|
||||
|
||||
@@ -506,3 +519,5 @@ Estimates unchanged.
|
||||
## Pages sidebar keyboard refresh (2026-10-30)
|
||||
|
||||
PR #2133 retains document-local Pages sidebar focus for Ctrl/Cmd+A and Escape after the UI module split. The selection does not navigate. The focused regression is in `crates/ui-egui/tests/it/view.rs`; current-head hosted validation is pending. Feature counts and estimates are unchanged.
|
||||
|
||||
- 2026-11-04: AcroForm review follow-up covers field-instance retirement across history, rejected-writeback realm invalidation, mixed widget icons, shared calculation budgets and multi-select/checkbox consistency, frozen-global events, exact image imports, comparison-half scripts, focus reveal and explicit busy/timeout feedback. Synthetic regressions and elapsed UI deadlines accompany the fixes.
|
||||
@@ -5340,7 +5340,7 @@ status = "shipped"
|
||||
commands = ["tools.document_js"]
|
||||
tools = ["js_document_scripts", "js_set_document_script"]
|
||||
evidence = ["crates/ui-egui/tests/it/javascript.rs::document_scripts_and_preferences", "crates/automation/tests/it/automation.rs::javascript_through_tools", "crates/engine/src/tests.rs::form_javascript_validates_calculates_and_formats", "crates/engine/src/tests.rs::a_document_script_that_throws_still_lets_the_form_calculate"]
|
||||
notes = "Names /JavaScript tree: list, add, edit, delete; functions are available to field scripts. A script that throws (calling an Acrobat method PdfCraft lacks, say) stops where it failed, but the others and the field scripts still run and the error is reported once (js_run's document_errors, the console) (#3940). Not like Acrobat: the scripts run again before every field script instead of once when the document opens, so what they do at top level repeats."
|
||||
notes = "Names /JavaScript tree: list, add, edit, delete; functions are available to field scripts. A script that throws (calling an Acrobat method PdfCraft lacks, say) stops where it failed, but the others and the field scripts still run and the error is reported once (js_run's document_errors, the console) (#3940). Native engine realms initialize document scripts once with Doc/Open and share state across field, button and console events. Source edits, reopen, Revert and JavaScript disable reset the realm. Stateless low-level runners still replay initialization by design. Remaining document action triggers are tracked separately."
|
||||
|
||||
[[feature]]
|
||||
id = "form.document-actions"
|
||||
@@ -5363,8 +5363,8 @@ milestone = "M6"
|
||||
status = "partial"
|
||||
commands = ["tools.js_console"]
|
||||
tools = ["js_run"]
|
||||
evidence = ["crates/js/src/tests.rs::calculate_scripts_read_fields_and_set_the_value", "crates/js/src/tests.rs::push_button_caption_methods_read_write_and_reject_other_faces", "crates/engine/src/tests.rs::form_javascript_validates_calculates_and_formats", "crates/automation/tests/it/automation.rs::javascript_through_tools", "crates/js/src/tests.rs::an_xfa_form_sees_the_xfa_plugin_installed", "crates/js/src/tests.rs::a_plain_form_sees_the_xfa_version_but_not_installed", "crates/engine/src/tests.rs::scripts_in_an_xfa_form_see_the_xfa_plugin_installed", "crates/js/src/tests.rs::scand_allows_separators_between_fields_but_nothing_before_or_after_them", "crates/ui-egui/tests/it/forms.rs::a_form_button_opens_its_attached_instructions_in_a_new_tab", "crates/js/src/tests.rs::app_alert_keeps_its_title_icon_and_buttons", "crates/js/src/tests.rs::app_alert_reads_the_object_form", "crates/js/src/tests.rs::app_alert_falls_back_on_bad_icon_and_button_numbers", "crates/js/src/tests.rs::app_alert_titles_are_clipped_and_charged", "crates/ui-egui/tests/it/javascript.rs::every_message_a_script_shows_takes_the_toast_in_turn", "crates/js/src/tests.rs::widget_rect_reads_and_writes_a_four_number_array", "crates/engine/src/tests.rs::javascript_field_rect_moves_first_widget_and_undoes", "crates/engine/src/tests.rs::js_get_page_box_reads_effective_boxes_without_editing_the_document", "crates/js/src/tests.rs::get_page_box_returns_supported_boxes_in_rotated_user_space", "crates/automation/tests/it/button_caption.rs::button_caption_can_be_changed_after_creation"]
|
||||
notes = "boa (pure Rust) with the forms object model: event, getField/Field (value, readonly, display, colours, items, check boxes, rect, and normal push-button captions through buttonGetCaption/buttonSetCaption), this.getPageBox(cBox, nPage) for effective Media, Crop (default), Trim, Bleed and Art boxes in rotated user space, app (with execMenuItem for the side panels and Save As, from a button only, and Print where this.print() works), util.printf/printd/printx/scand (scand reads dates as another viewer was observed to, in AcroForm and XFA scripts), console, display, color, exportDataObject (from a button only: an attached PDF opens in a new tab, other files are offered to save, also as event.target in XFA scripts); xfa_version (3.5) in every document and xfa_installed in XFA forms, so their version check passes. field.rect reads and writes the first widget in PDF user space. no annotations, layers or dialogs (app.response returns null) yet, and no app.findComponent. app.alert keeps its title, icon (error, warning, question, status) and buttons (OK, OK/Cancel, Yes/No, Yes/No/Cancel), given in order or as one object; js_run reports them with each message, and the app shows every message in turn in the toast with its title and icon. Still missing: answers. Every app.alert returns 1 (OK), so a script is never told No or Cancel (#4291)."
|
||||
evidence = ["crates/automation/tests/it/timesheet_compat.rs::timesheet_keypad_geometry_icons_calculation_and_persistence", "crates/automation/tests/it/timesheet_compat.rs::popup_and_manual_response_resume_the_same_script_and_validate_choices", "crates/js/src/session.rs::document_realm_keeps_keypad_state_and_isolates_documents", "crates/js/src/tests.rs::calculate_scripts_read_fields_and_set_the_value", "crates/engine/src/tests.rs::form_javascript_validates_calculates_and_formats", "crates/automation/tests/it/automation.rs::javascript_through_tools", "crates/js/src/tests.rs::an_xfa_form_sees_the_xfa_plugin_installed", "crates/js/src/tests.rs::a_plain_form_sees_the_xfa_version_but_not_installed", "crates/engine/src/tests.rs::scripts_in_an_xfa_form_see_the_xfa_plugin_installed", "crates/js/src/tests.rs::scand_allows_separators_between_fields_but_nothing_before_or_after_them", "crates/js/src/tests.rs::app_alert_keeps_its_title_icon_and_buttons", "crates/js/src/tests.rs::app_alert_reads_the_object_form", "crates/js/src/tests.rs::app_alert_falls_back_on_bad_icon_and_button_numbers", "crates/js/src/tests.rs::app_alert_titles_are_clipped_and_charged", "crates/ui-egui/tests/it/javascript.rs::every_message_a_script_shows_takes_the_toast_in_turn", "crates/ui-egui/tests/it/forms.rs::a_form_button_opens_its_attached_instructions_in_a_new_tab", "crates/js/src/tests.rs::widget_rect_reads_and_writes_a_four_number_array", "crates/engine/src/tests.rs::javascript_field_rect_moves_first_widget_and_undoes"]
|
||||
notes = "Document-scoped native Boa realms shared by button, console and field events; synchronous calculateNow in CO order; normal captions/icons; rect and appearance/tooltip writeback; effective page boxes; native explicit-button popup/response/alert continuation and viewer requests, including opening or saving attachments from form buttons. Headless js_run has deterministic ordered answers. Partial: web prompt UI, timers, annotation/layer APIs, non-normal button faces, widget-indexed Field access and remaining document action triggers are not implemented. See docs/acroform-script-workflows.md for lifecycle, limits and validation scope."
|
||||
|
||||
[[feature]]
|
||||
id = "form.js-console"
|
||||
|
||||
Reference in new issue
Block a user