diff --git a/ROADMAP.md b/ROADMAP.md index 8889a8f368..7838ac2ff4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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. diff --git a/crates/automation/README.md b/crates/automation/README.md index cf9e82982b..82a00c509a 100644 --- a/crates/automation/README.md +++ b/crates/automation/README.md @@ -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 diff --git a/crates/automation/src/a11y/form_tools.rs b/crates/automation/src/a11y/form_tools.rs index dee8eff646..31234a28ed 100644 --- a/crates/automation/src/a11y/form_tools.rs +++ b/crates/automation/src/a11y/form_tools.rs @@ -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 { 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::>>()?, + _ => 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); diff --git a/crates/automation/src/dispatch.rs b/crates/automation/src/dispatch.rs index 3b40832b83..9644684772 100644 --- a/crates/automation/src/dispatch.rs +++ b/crates/automation/src/dispatch.rs @@ -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)?, diff --git a/crates/automation/src/forms.rs b/crates/automation/src/forms.rs index 76eef490a2..f1f34836ad 100644 --- a/crates/automation/src/forms.rs +++ b/crates/automation/src/forms.rs @@ -272,6 +272,27 @@ fn value_for(f: &FormField, v: &Value) -> Result { } 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 { + let name = a.str("field")?.to_owned(); + let object = a + .0 + .get("field_id") + .map(|value| -> Result { + 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 { let doc = self.doc(a)?; let out: Vec = 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()), diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 30d0c1b696..83362aa019 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -631,7 +631,7 @@ pub fn tools() -> Vec { &["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 { 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.") diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index be0510c5d7..39f8ab7a16 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -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::>(), ["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(); diff --git a/crates/automation/tests/it/button_caption.rs b/crates/automation/tests/it/button_caption.rs index bb53504a43..0290df7343 100644 --- a/crates/automation/tests/it/button_caption.rs +++ b/crates/automation/tests/it/button_caption.rs @@ -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(); +} diff --git a/crates/automation/tests/it/main.rs b/crates/automation/tests/it/main.rs index cdb072a888..7a5a6c1bae 100644 --- a/crates/automation/tests/it/main.rs +++ b/crates/automation/tests/it/main.rs @@ -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; diff --git a/crates/automation/tests/it/timesheet_compat.rs b/crates/automation/tests/it/timesheet_compat.rs new file mode 100644 index 0000000000..f6910543b6 --- /dev/null +++ b/crates/automation/tests/it/timesheet_compat.rs @@ -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 { + 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::>() + }; + 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(); +} diff --git a/crates/engine/src/document.rs b/crates/engine/src/document.rs index 530f8787af..937fd84bf9 100644 --- a/crates/engine/src/document.rs +++ b/crates/engine/src/document.rs @@ -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, diff --git a/crates/engine/src/edit.rs b/crates/engine/src/edit.rs index a6b1f0b2bf..b00acfb8a8 100644 --- a/crates/engine/src/edit.rs +++ b/crates/engine/src/edit.rs @@ -338,6 +338,12 @@ pub enum Edit { name: String, image: Arc>, }, + /// Set the picture of one exact push-button field; legacy name-only calls remain available. + SetFieldImageTarget { + name: String, + object: pdfcraft_cos::ObjRef, + image: Arc>, + }, /// Duplicate a field onto other pages (same field, shared value). DuplicateField { name: String, diff --git a/crates/engine/src/edit/labels.rs b/crates/engine/src/edit/labels.rs index bfbdc5f7e5..93045146ae 100644 --- a/crates/engine/src/edit/labels.rs +++ b/crates/engine/src/edit/labels.rs @@ -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"), diff --git a/crates/engine/src/edit/run.rs b/crates/engine/src/edit/run.rs index ebb8a5f323..067f64929d 100644 --- a/crates/engine/src/edit/run.rs +++ b/crates/engine/src/edit/run.rs @@ -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, + 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>, @@ -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(()) diff --git a/crates/engine/src/history.rs b/crates/engine/src/history.rs index 4259e6c721..393c233297 100644 --- a/crates/engine/src/history.rs +++ b/crates/engine/src/history.rs @@ -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 { .. } diff --git a/crates/engine/src/js.rs b/crates/engine/src/js.rs index f73be5ec03..cb4c0caf65 100644 --- a/crates/engine/src/js.rs +++ b/crates/engine/src/js.rs @@ -11,6 +11,77 @@ pub use pdfcraft_js::{Limits, Message, MessageButtons, MessageIcon, Outcome, Pan use crate::{DocId, Edit, EditError, Session}; +/// Reset writeback does two full passes (preview and commit); reject amplification before either. +const MAX_SCRIPT_RESETS: usize = pdfcraft_js::MAX_RESET_CALLS; +const MAX_SCRIPT_RESET_SELECTORS: usize = 4096; +const MAX_SCRIPT_RESET_WORK: usize = 1 << 24; + +/// Bound request count, selector storage/scans and estimated form/calculation visits before cloning a PDF. +fn validate_reset_work(fields: &[Field], states: &[pdfcraft_js::FieldState], requests: &[Request]) -> Result<(), EditError> { + let invalid = || EditError::Invalid("script reset work limit exceeded; use fewer reset calls or combine field selections".into()); + let (mut resets, mut selectors, mut bytes) = (0usize, 0usize, 0usize); + for request in requests { + if let Request::Reset(names) = request { + resets = resets.saturating_add(1); + selectors = selectors.saturating_add(names.len()); + if resets > MAX_SCRIPT_RESETS || selectors > MAX_SCRIPT_RESET_SELECTORS { + return Err(invalid()); + } + bytes = names.iter().fold(bytes, |n, name| n.saturating_add(name.len())); + } + } + if resets == 0 { + return Ok(()); + } + let count = fields.len().max(states.len()); + let (mut calculations, mut references, mut source_bytes) = (0usize, 0usize, 0usize); + for field in fields { + use pdfcraft_forms::af::Calculate; + if field.actions.calculate != Calculate::None || field.actions.scripts.calculate.is_some() { + calculations = calculations.saturating_add(1); + } + references = references.saturating_add(match &field.actions.calculate { + Calculate::Simple { fields, .. } => fields.len(), + Calculate::Notation(source) | Calculate::Custom(source) => source.len(), + Calculate::None => 0, + }); + source_bytes = source_bytes.saturating_add(field.actions.scripts.calculate.as_ref().map_or(0, String::len)); + } + let name_bytes = + fields.iter().fold(0usize, |n, f| n.saturating_add(f.name.len())).max(states.iter().fold(0usize, |n, f| n.saturating_add(f.name.len()))); + let widgets = fields.iter().fold(0usize, |n, f| n.saturating_add(f.widgets.len())); + let per_pass = count + .saturating_mul(1usize.saturating_add(calculations).saturating_add(references)) + .saturating_add(source_bytes) + .saturating_add(name_bytes) + .saturating_add(widgets); + // Bound the expansion scan before doing it, then charge only the actual selections + // for the forms layer's membership and validation scans in both reset passes. + let comparisons = count.saturating_mul(selectors).saturating_add(count.saturating_mul(bytes)); + let mut work = resets.saturating_mul(per_pass).saturating_add(comparisons).saturating_add(bytes); + if work > MAX_SCRIPT_RESET_WORK / 2 { + return Err(invalid()); + } + for request in requests { + let Request::Reset(names) = request else { continue }; + if names.is_empty() { + continue; + } + for field in states.iter().filter(|f| reset_name_matches(&f.name, names)) { + work = work.saturating_add(count.saturating_mul(field.name.len().saturating_add(1)).saturating_mul(2)); + if work > MAX_SCRIPT_RESET_WORK / 2 { + return Err(invalid()); + } + } + } + Ok(()) +} + +/// Match an exact reset selector or one of its dotted descendants. +fn reset_name_matches(name: &str, selectors: &[String]) -> bool { + selectors.iter().any(|selector| name == selector || name.strip_prefix(selector).is_some_and(|rest| rest.starts_with('.'))) +} + /// A field gaining or losing focus ([`Session::field_focus`]). #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum FieldFocus { @@ -206,37 +277,90 @@ fn display(doc: &pdfcraft_cos::Document, f: &Field) -> i32 { } } -/// A form field as scripts see it. -pub fn field_state(doc: &pdfcraft_cos::Document, f: &Field) -> pdfcraft_js::FieldState { - let mut s = field_state_with_display(f, display(doc, f)); - (s.text_color, s.fill_color) = colours(doc, f); - s +/// Document-local PDF field identity used by snapshots, tools and native hit tests. +pub fn field_object_id(object: pdfcraft_cos::ObjRef) -> u64 { + (u64::from(object.num) << 16) | u64::from(object.generation) } -/// `field.textColor` and `field.fillColor` as Acrobat colour arrays, from the field's look -/// (`None` for a transparent fill, which scripts read as `["T"]`). -fn colours(doc: &pdfcraft_cos::Document, f: &Field) -> Colours { - let rgb = |c: [f64; 3]| std::iter::once("RGB".to_string()).chain(c.map(|v| v.to_string())).collect::>(); - let look = pdfcraft_forms::look(doc, f); - (Some(rgb(look.text)), look.fill.map(rgb)) +/// Check that a supplied field identity belongs to the requested field name. +fn validate_script_target(target: Option<&str>, identity: Option, fields: &[Field]) -> Result<(), EditError> { + if let Some(id) = identity + && !fields.iter().any(|f| Some(f.name.as_str()) == target && field_object_id(f.obj) == id) + { + return Err(EditError::Invalid("script field identity does not match the named field in this document".into())); + } + Ok(()) } -/// A field's text and fill colours as scripts read them. -type Colours = (Option>, Option>); - -fn field_state_with_display(f: &Field, display: i32) -> pdfcraft_js::FieldState { +/// Snapshot a current form field using page geometry already read once for this runner. +/// Values/appearances come from `doc` and `f`, never from the cached calculation plan. +pub fn field_state(doc: &pdfcraft_cos::Document, f: &Field, geometry: &pdfcraft_js::DocInfo) -> pdfcraft_js::FieldState { let mut s = pdfcraft_js::FieldState::new(f.name.clone(), kind(f.kind), f.value.clone()); + s.object_id = Some(field_object_id(f.obj)); s.default = f.default.clone(); + s.default_known = pdfcraft_forms::default_value_known(doc, f); s.readonly = f.read_only(); s.required = f.has(pdfcraft_forms::flags::REQUIRED); - s.display = display; - s.rect = f.widgets.first().map(|w| w.rect); + s.display = display(doc, f); + let look = pdfcraft_forms::look(doc, f); + s.text_color = Some(std::iter::once("RGB".to_string()).chain(look.text.map(|v| v.to_string())).collect()); s.char_limit = f.max_len; s.options = match f.kind { FieldKind::CheckBox | FieldKind::Radio => f.widgets.iter().filter_map(|w| w.on_state.clone()).map(|o| (o.clone(), o)).collect(), _ => f.options.clone(), }; s.caption = f.widgets.first().and_then(|w| w.caption.clone()); + s.caption_mixed = f.widgets.iter().any(|w| w.caption != s.caption); + s.line_width = look.width; + s.border_style = match look.style { + pdfcraft_forms::BorderStyle::Solid => "solid", + pdfcraft_forms::BorderStyle::Dashed => "dashed", + pdfcraft_forms::BorderStyle::Beveled => "beveled", + pdfcraft_forms::BorderStyle::Inset => "inset", + pdfcraft_forms::BorderStyle::Underline => "underline", + } + .into(); + s.stroke_color = Some( + look.border + .map_or_else(|| std::sync::Arc::from(["T".into()]), |rgb| std::iter::once("RGB".into()).chain(rgb.map(|v| v.to_string())).collect()), + ); + s.fill_color = Some( + look.fill.map_or_else(|| std::sync::Arc::from(["T".into()]), |rgb| std::iter::once("RGB".into()).chain(rgb.map(|v| v.to_string())).collect()), + ); + s.user_name = doc + .get(f.obj) + .as_dict() + .and_then(|d| d.get(b"TU").map(|v| doc.resolve(v))) + .and_then(|v| v.as_string().map(|v| v.to_text())) + .unwrap_or_default(); + s.text_size = + f.da.split_whitespace() + .collect::>() + .windows(2) + .find_map(|p| (p.get(1) == Some(&"Tf")).then(|| p.first()?.parse::().ok()).flatten()) + .unwrap_or(0.0); + if let Some(w) = f.widgets.first() { + let page = w.page.unwrap_or(0); + s.page_rotation = geometry.page_rotations.get(page).copied().unwrap_or(0); + s.page_crop = geometry.page_boxes.get(page).map_or([0.0; 4], |boxes| boxes[1]); + s.rect = Some(pdfcraft_js::rotate_rect(w.rect, s.page_crop, s.page_rotation)); + s.icon = doc + .get(w.obj) + .as_dict() + .and_then(|d| d.get(b"MK").map(|v| doc.resolve(v))) + .and_then(|v| v.as_dict().and_then(|d| d.get(b"I")).and_then(pdfcraft_cos::Object::as_ref)) + .map(|r| ((r.num as u64) << 16) | r.generation as u64); + } + + s.icon_mixed = f.widgets.iter().any(|w| { + let icon = doc + .get(w.obj) + .as_dict() + .and_then(|d| d.get(b"MK").map(|v| doc.resolve(v))) + .and_then(|v| v.as_dict().and_then(|d| d.get(b"I")).and_then(pdfcraft_cos::Object::as_ref)) + .map(field_object_id); + icon != s.icon + }); s } @@ -255,74 +379,187 @@ fn changes( for field in before { by_name.entry(field.name.as_str()).or_insert(field); } + let by_id: std::collections::HashMap<_, _> = before.iter().filter_map(|f| f.object_id.map(|id| (id, f))).collect(); + let mut names = std::collections::HashSet::new(); + let duplicates: std::collections::HashSet<_> = + before.iter().filter_map(|f| (!names.insert(f.name.as_str())).then_some(f.name.as_str())).collect(); after .iter() .filter_map(|a| { - let b = by_name.get(a.name.as_str())?; + let b = if let Some(id) = a.object_id { by_id.get(&id)? } else { by_name.get(a.name.as_str())? }; let c = FieldChange { name: a.name.clone(), + object: a + .object_id + .filter(|_| duplicates.contains(a.name.as_str())) + .map(|id| pdfcraft_cos::ObjRef::new((id >> 16) as u32, id as u16)), value: (a.value != b.value).then(|| a.value.clone()), formatted: a.value != b.value && formatted.contains(&a.name), read_only: (a.readonly != b.readonly).then_some(a.readonly), required: (a.required != b.required).then_some(a.required), display: (a.display != b.display).then_some(a.display), - caption: a.caption_was_set.then(|| a.caption.clone()).flatten(), - rect: (a.rect != b.rect).then_some(a.rect).flatten(), - text_color: (a.text_color != b.text_color).then(|| a.text_color.clone()).flatten(), - fill_color: (a.fill_color != b.fill_color).then(|| a.fill_color.clone()).flatten(), + rect: (a.rect != b.rect).then(|| a.rect.map(|r| pdfcraft_js::unrotate_rect(r, b.page_crop, b.page_rotation))).flatten(), + stroke_color: a.stroke_color.as_deref().filter(|_| a.stroke_color != b.stroke_color).map(<[_]>::to_vec), + text_size: (a.text_size != b.text_size).then_some(a.text_size), + line_width: (a.line_width != b.line_width).then_some(a.line_width), + border_style: (a.border_style != b.border_style).then(|| a.border_style.clone()), + user_name: (a.user_name != b.user_name).then(|| a.user_name.clone()), + icon: (a.icon != b.icon || (a.icon_written && b.icon_mixed)).then_some(a.icon), + text_color: a.text_color.as_deref().filter(|_| a.text_color != b.text_color).map(<[_]>::to_vec), + fill_color: a.fill_color.as_deref().filter(|_| a.fill_color != b.fill_color).map(<[_]>::to_vec), + caption: (a.caption != b.caption || (a.caption_written && b.caption_mixed)).then(|| a.caption.clone()).flatten(), }; (c.value.is_some() || c.read_only.is_some() || c.required.is_some() || c.display.is_some() || c.caption.is_some() - || c.rect.is_some() || c.text_color.is_some() - || c.fill_color.is_some()) + || c.fill_color.is_some() + || c.rect.is_some() + || c.stroke_color.is_some() + || c.text_size.is_some() + || c.line_width.is_some() + || c.border_style.is_some() + || c.user_name.is_some() + || c.icon.is_some()) .then_some(c) }) .collect() } /// Runs field scripts with the document's object model and keeps what they print or ask for. +pub type Runtime = std::sync::Arc>; + +/// Invalidate pending results while retaining the document's native worker reservation. +pub(crate) fn replace_runtime(runtime: &mut Runtime) { + let Ok(mut old) = runtime.lock() else { return }; + let next = old.fresh_realm(); + drop(old); + *runtime = std::sync::Arc::new(std::sync::Mutex::new(next)); +} + pub struct JsRunner { + pub runtime: Runtime, pub doc: pdfcraft_js::DocInfo, pub doc_scripts: Vec, pub output: JsOutput, - /// Immutable document snapshot from the start of the edit (for widget `display`). - cos: pdfcraft_cos::Document, - // `cos` is the same immutable pre-edit snapshot for the runner's lifetime. Cache only - // its scalar display readings, not COS objects: large forms exceed the object cache and - // otherwise reparse every widget for every calculation. Values/flags still come from - // the live `fields` snapshot on each call. A new edit gets a new runner and cache. - displays: std::collections::HashMap, - /// Field colours read from `cos`, cached for the same reason as `displays`. - colours: std::collections::HashMap, - /// Colours this runner's scripts wrote, by field name: they reach the document only once - /// the edit applies each script's changes, after `cos` was taken, so a later script in the - /// same edit reads them from here. - written_colours: std::collections::HashMap, - /// Whether a document-level script names a colour property (`fillColor`, `textColor`, …). - doc_scripts_use_colours: bool, - calculations: Option>>, + pub(crate) refused: Option, + pub(crate) callbacks_touched_realm: bool, + #[cfg(not(target_arch = "wasm32"))] + callback_started: Option, + /// Source/order cache for this edit only; each callback still snapshots current field values. + calculations: Option>, + // Cache at most 4,096 field identities in the current edit. Entries only survive value-only + // callbacks. Appearance and structural edits clear them before the next snapshot. + snapshot_fields: std::collections::HashMap, + // The last callback's snapshot. A calculation chain changes one or two values per callback, + // so the next callback patches those entries instead of rebuilding every field. + last_snapshot: Option>>, } impl JsRunner { + pub(crate) fn invalidate_field_snapshots(&mut self) { + self.snapshot_fields.clear(); + self.last_snapshot = None; + } + + fn callback_fields(&mut self, doc: &pdfcraft_cos::Document, fields: &[Field]) -> std::sync::Arc> { + if let Some(snapshot) = self.last_snapshot.as_mut() + && snapshot.len() == fields.len() + && snapshot.iter().zip(fields).all(|(state, field)| state.object_id == Some(field_object_id(field.obj))) + { + let stale = |state: &pdfcraft_js::FieldState, field: &Field| { + state.name != field.name + || state.value != field.value + || state.readonly != field.read_only() + || state.required != field.has(pdfcraft_forms::flags::REQUIRED) + }; + if snapshot.iter().zip(fields).any(|(state, field)| stale(state, field)) { + for (state, field) in std::sync::Arc::make_mut(snapshot).iter_mut().zip(fields) { + if stale(state, field) { + state.name.clone_from(&field.name); + state.value.clone_from(&field.value); + state.readonly = field.read_only(); + state.required = field.has(pdfcraft_forms::flags::REQUIRED); + } + } + } + if let Ok(mut runtime) = self.runtime.lock() { + runtime.stamp_fields(snapshot); + } + return std::sync::Arc::clone(snapshot); + } + let states = fields + .iter() + .map(|field| { + if self.snapshot_fields.len() >= 4096 && !self.snapshot_fields.contains_key(&field.obj) { + return field_state(doc, field, &self.doc); + } + let template = self.snapshot_fields.entry(field.obj).or_insert_with(|| { + let mut state = field_state(doc, field, &self.doc); + state.value.clear(); + state + }); + let mut state = template.clone(); + state.name.clone_from(&field.name); + state.value.clone_from(&field.value); + state.readonly = field.read_only(); + state.required = field.has(pdfcraft_forms::flags::REQUIRED); + state + }) + .collect(); + let mut states = std::sync::Arc::new(states); + if let Ok(mut runtime) = self.runtime.lock() { + runtime.stamp_fields(&mut states); + } + self.last_snapshot = Some(std::sync::Arc::clone(&states)); + states + } + + /// Forget the per-edit calculation plan after a calculation source is changed. + pub(crate) fn invalidate_calculations(&mut self) { + self.calculations = None; + } + + /// Refresh structural/document sources between batch children, retaining the realm, + /// accumulated output and the current viewer snapshot. Ordinary value edits reuse caches. + pub(crate) fn refresh_after_batch_edit(&mut self, cos: &pdfcraft_cos::Document, edit: &Edit) { + if matches!(edit, Edit::SetFieldValue { .. } | Edit::ResetForm { .. } | Edit::ApplyScriptChanges { .. } | Edit::Batch { .. }) { + return; + } + let mut current = Self::new(cos, &self.doc.file_name, self.doc.runtime_highlight); + current.doc.zoom = self.doc.zoom; + current.doc.page = self.doc.page; + self.doc = current.doc; + self.doc_scripts = current.doc_scripts; + self.calculations = None; + self.invalidate_field_snapshots(); + } + /// A runner for `cos`: what scripts read about the document (pages, info, whether it is /// an XFA form) and its document-level scripts, read once. `highlight`: whether the viewer /// highlights form fields (`app.runtimeHighlight`). pub fn new(cos: &pdfcraft_cos::Document, file_name: &str, highlight: bool) -> JsRunner { let xfa = pdfcraft_xfa::xfa_shape(cos).is_some(); + #[cfg(test)] + SNAPSHOT_WORK.with(|c| { + let (g, p) = c.get(); + c.set((g + 1, p)); + }); let pages = pdfcraft_model::pages(cos); let doc = pdfcraft_js::DocInfo { file_name: file_name.to_string(), num_pages: pages.len(), page: 0, info: info(cos), - highlight, page_boxes: pdfcraft_organize::page_boxes(cos).unwrap_or_default(), page_rotations: pages.iter().map(|page| page.rotation(cos)).collect(), xfa, + zoom: 100.0, + runtime_highlight: highlight, + icon_epoch: 0, + icon_versions: Default::default(), }; // Once XFA owns the form, the PDF container's scripts belong to its fallback view. // Keep them in the file and in Document JavaScripts, but do not initialize an @@ -330,46 +567,40 @@ impl JsRunner { // Packets alone are not enough: static XFA and an unlaid-out shell keep their scripts. let xfa_active = xfa && pdfcraft_xfa::existing_layout(cos).is_some(); let doc_scripts = if xfa_active { Vec::new() } else { pdfcraft_forms::document_scripts(cos) }; - let doc_scripts_use_colours = doc_scripts.iter().any(|source| source.contains("Color")); JsRunner { doc, doc_scripts, output: JsOutput::default(), - cos: cos.clone(), - displays: std::collections::HashMap::new(), - colours: std::collections::HashMap::new(), - written_colours: std::collections::HashMap::new(), - doc_scripts_use_colours, + refused: None, + callbacks_touched_realm: false, + #[cfg(not(target_arch = "wasm32"))] + callback_started: None, + runtime: Default::default(), calculations: None, + snapshot_fields: Default::default(), + last_snapshot: None, } } - fn calculations(&mut self, fields: &[Field]) -> std::sync::Arc> { - if let Some(calculations) = &self.calculations { - return std::sync::Arc::clone(calculations); - } - let calculations = std::sync::Arc::new(calculation_scripts(&self.cos, fields)); - self.calculations = Some(std::sync::Arc::clone(&calculations)); - calculations - } - - pub(crate) fn invalidate_calculations(&mut self, cos: &pdfcraft_cos::Document) { - self.cos = cos.clone(); - self.displays.clear(); - self.colours.clear(); - self.written_colours.clear(); - self.calculations = None; + #[cfg(not(target_arch = "wasm32"))] + fn callback_time_left(&mut self) -> std::time::Duration { + std::time::Duration::from_secs(10).saturating_sub(self.callback_started.get_or_insert_with(std::time::Instant::now).elapsed()) } } -fn calculation_scripts(cos: &pdfcraft_cos::Document, fields: &[Field]) -> Vec<(String, String)> { +/// Index calculation sources by PDF object identity, retaining their matching snapshot positions. +fn calculation_scripts(cos: &pdfcraft_cos::Document, fields: &[Field]) -> Vec<(usize, String)> { #[cfg(test)] - CALCULATION_SCRIPT_BUILDS.with(|calls| calls.set(calls.get().saturating_add(1))); + SNAPSHOT_WORK.with(|c| { + let (g, p) = c.get(); + c.set((g, p + 1)); + }); + let by_object: std::collections::HashMap<_, _> = fields.iter().enumerate().map(|(i, f)| (f.obj, (i, f))).collect(); pdfcraft_forms::calculation_order(cos, fields) .fields .iter() .filter_map(|ordered| { - let field = fields.iter().find(|f| f.name == ordered.name)?; + let (index, field) = by_object.get(&ordered.obj)?; let script = field.actions.scripts.calculate.clone().or_else(|| { let source = pdfcraft_forms::af::calculate_js(&field.actions.calculate)?; Some(match &field.actions.calculate { @@ -377,29 +608,11 @@ fn calculation_scripts(cos: &pdfcraft_cos::Document, fields: &[Field]) -> Vec<(S _ => source, }) })?; - Some((field.name.clone(), script)) + Some((*index, script)) }) .collect() } -#[cfg(test)] -thread_local! { - pub(crate) static CALCULATION_SCRIPT_BUILDS: std::cell::Cell = const { std::cell::Cell::new(0) }; -} - -fn calls_calculate_now(script: &str, doc_scripts: &[String]) -> bool { - script.contains("calculateNow") || doc_scripts.iter().any(|source| source.contains("calculateNow")) -} - -fn calculations_for_script( - cos: &pdfcraft_cos::Document, - fields: &[Field], - script: &str, - doc_scripts: &[String], -) -> std::sync::Arc> { - if calls_calculate_now(script, doc_scripts) { std::sync::Arc::new(calculation_scripts(cos, fields)) } else { std::sync::Arc::new(Vec::new()) } -} - /// The field highlight the last of `requests` that sets it asks for (`app.runtimeHighlight`). fn last_highlight(requests: &[Request]) -> Option { requests.iter().rev().find_map(|r| match r { @@ -420,86 +633,140 @@ fn info(cos: &pdfcraft_cos::Document) -> Vec<(String, String)> { } impl Scripts for JsRunner { + fn stopped(&self) -> bool { + self.refused.is_some() + } + /// Run field `target`'s script for `event` over the form's fields, keeping what it showed and /// asked for; a refusal carries its last message. - fn run(&mut self, event: FieldEvent, script: &str, target: &Field, value: &str, fields: &[Field]) -> ScriptResult { - // As in Acrobat, a failing script leaves the value alone (the error goes to the console). - self.run_logged(event, script, target, value, fields, true).unwrap_or_else(|| ScriptResult { - rc: true, - value: value.to_string(), - value_set: false, - changes: Vec::new(), - message: None, - }) - } - - fn rerun_with_english_dates(&mut self, event: FieldEvent, script: &str, target: &Field, value: &str, fields: &[Field]) -> Option { - pdfcraft_js::with_english_printd(true, || self.run_logged(event, script, target, value, fields, false)) - } -} - -impl JsRunner { - /// [`Scripts::run`], keeping what the script showed and asked for only when `log`; `None` - /// when the script failed. - fn run_logged(&mut self, event: FieldEvent, script: &str, target: &Field, value: &str, fields: &[Field], log: bool) -> Option { - // Field colours are read (and their arrays built for every field) only for a script - // that can reach them: one that names a colour property, or any script when a document - // script does. A large form's calculations then cost what they did before colours were - // readable. A property name built at run time (`f["fill" + "Color"]`) reads as - // transparent in a script that names no colour. - let reads_colours = self.doc_scripts_use_colours || script.contains("Color"); - let states: Vec<_> = fields - .iter() - .map(|f| { - let display = match f.widgets.first() { - Some(w) => *self.displays.entry(w.obj).or_insert_with(|| display(&self.cos, f)), - None => pdfcraft_js::DISPLAY_VISIBLE, - }; - let mut state = field_state_with_display(f, display); - if reads_colours { - (state.text_color, state.fill_color) = match self.written_colours.get(&f.name) { - Some(written) => written.clone(), - None => self.colours.entry(f.obj).or_insert_with(|| colours(&self.cos, f)).clone(), - }; - } - state - }) - .collect(); - let states = std::sync::Arc::new(states); - let ev = pdfcraft_js::Event::field(event.name(), &target.name, value); - let calculations = if calls_calculate_now(script, &self.doc_scripts) { self.calculations(fields) } else { std::sync::Arc::new(Vec::new()) }; - let o = pdfcraft_js::run_shared_calculations_shared( - script, - &ev, - &self.doc, - std::sync::Arc::clone(&states), - &self.doc_scripts, - &calculations, - Limits::default(), - ); - if log { - self.output.absorb(&o); + fn run(&mut self, event: FieldEvent, script: &str, target: &Field, value: &str, fields: &[Field], doc: &pdfcraft_cos::Document) -> ScriptResult { + let unchanged = || ScriptResult { rc: true, value: value.to_string(), value_set: false, changes: Vec::new(), message: None }; + if self.stopped() { + return unchanged(); + } + #[cfg(not(target_arch = "wasm32"))] + if self.callback_time_left().is_zero() { + self.refused = Some("form script callbacks exceeded their shared execution budget".into()); + return unchanged(); + } + let states = self.callback_fields(doc, fields); + let mut ev = pdfcraft_js::Event::field(event.name(), &target.name, value); + ev.target_id = Some((u64::from(target.obj.num) << 16) | u64::from(target.obj.generation)); + let calculations = self.calculations.get_or_insert_with(|| calculation_scripts(doc, fields)).clone(); + let job = pdfcraft_js::session::Job { + script: script.into(), + event: ev, + doc: self.doc.clone(), + fields: states, + doc_scripts: self.doc_scripts.clone(), + calculations, + }; + #[cfg(not(target_arch = "wasm32"))] + let remaining = self.callback_time_left(); + let o = match self.runtime.lock() { + #[cfg(not(target_arch = "wasm32"))] + Ok(mut runtime) if !remaining.is_zero() => runtime.run_with_limit(job, &[], remaining), + #[cfg(not(target_arch = "wasm32"))] + Ok(_) => Outcome { + execution_refused: true, + realm_untouched: true, + error: Some("form script callbacks exceeded their shared execution budget".into()), + ..Default::default() + }, + #[cfg(target_arch = "wasm32")] + Ok(mut runtime) => runtime.run(job, &[]), + Err(_) => Outcome { execution_refused: true, error: Some("document script runtime is unavailable".into()), ..Default::default() }, + }; + self.callbacks_touched_realm |= !o.realm_untouched; + self.output.absorb(&o); + if o.execution_refused || o.reset_limit_exceeded || o.calculation_limit_exceeded { + self.refused = Some(o.error.clone().unwrap_or_else(|| "form script exceeded its execution budget".into())); + } + if o.error.is_some() || o.reset_limit_exceeded || o.calculation_limit_exceeded { + // As in Acrobat, a failing script leaves the value alone (the error goes to the console). + return unchanged(); } // The edit's later scripts read the field highlight this one set. if let Some(on) = last_highlight(&o.requests) { - self.doc.highlight = on; + self.doc.runtime_highlight = on; } - if o.error.is_some() { - return None; + // Most field callbacks only set event.value. Hand the unique snapshot to the + // session so stamping field identities does not clone every field. Reconstruct + // the baseline only for a callback that also wrote to Field objects. + let changes = if o.changed.is_empty() { + Vec::new() + } else { + let baseline = self.callback_fields(doc, fields); + changes(&baseline, &o.changed, &o.formatted) + }; + if changes.iter().any(|change| { + *change != FieldChange { name: change.name.clone(), object: change.object, value: change.value.clone(), ..Default::default() } + }) { + self.invalidate_field_snapshots(); } - let changes = changes(&states, &o.changed, &o.formatted); - for c in changes.iter().filter(|c| c.text_color.is_some() || c.fill_color.is_some()) { - if let Some(state) = o.changed.iter().find(|s| s.name == c.name) { - self.written_colours.insert(c.name.clone(), (state.text_color.clone(), state.fill_color.clone())); - } - } - Some(ScriptResult { + ScriptResult { rc: o.rc, value: o.value.clone(), value_set: o.value_set, changes, message: (!o.rc).then(|| o.alerts.last().map(|m| m.text.clone())).flatten(), - }) + } + } + + /// Retry appearance formatting in isolation so the font fallback cannot replay persistent side effects. + fn rerun_with_english_dates( + &mut self, + event: FieldEvent, + script: &str, + target: &Field, + value: &str, + fields: &[Field], + doc: &pdfcraft_cos::Document, + ) -> Option { + if self.stopped() { + return None; + } + #[cfg(not(target_arch = "wasm32"))] + if self.callback_time_left().is_zero() { + self.refused = Some("form script callbacks exceeded their shared execution budget".into()); + return None; + } + let states = self.callback_fields(doc, fields); + let mut ev = pdfcraft_js::Event::field(event.name(), &target.name, value); + ev.target_id = Some(field_object_id(target.obj)); + let calculations = self.calculations.get_or_insert_with(|| calculation_scripts(doc, fields)); + #[cfg(target_arch = "wasm32")] + let o = pdfcraft_js::with_english_printd(true, || { + pdfcraft_js::run_shared_calculations(script, &ev, &self.doc, states, &self.doc_scripts, calculations, Limits::default()) + }); + #[cfg(not(target_arch = "wasm32"))] + let o = { + let job = pdfcraft_js::session::Job { + script: script.into(), + event: ev, + doc: self.doc.clone(), + fields: states, + doc_scripts: self.doc_scripts.clone(), + calculations: calculations.clone(), + }; + let remaining = self.callback_time_left(); + let outcome = if remaining.is_zero() { + Outcome { execution_refused: true, ..Default::default() } + } else if let Ok(mut runtime) = self.runtime.lock() { + runtime.run_english_retry(job, remaining) + } else { + Outcome { execution_refused: true, error: Some("document script runtime is unavailable".into()), ..Default::default() } + }; + if outcome.execution_refused { + self.refused = Some(outcome.error.unwrap_or_else(|| "form script callbacks exceeded their shared execution budget".into())); + return None; + } + outcome + }; + if o.error.is_some() || o.reset_limit_exceeded || o.calculation_limit_exceeded { + return None; + } + Some(ScriptResult { rc: o.rc, value: o.value.clone(), value_set: o.value_set, changes: Vec::new(), message: None }) } } @@ -544,10 +811,33 @@ impl SignEvent { } } +/// A PDF document action dispatched by the host through its existing AcroForm session. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum DocumentScriptEvent { + CatalogOpen, + WillSave, + WillPrint, +} + +impl DocumentScriptEvent { + fn name(self) -> &'static str { + match self { + Self::CatalogOpen => "Open", + Self::WillSave => "WillSave", + Self::WillPrint => "WillPrint", + } + } +} + impl Session { /// Preferences ▸ JavaScript ▸ Enable Acrobat JavaScript (on by default). pub fn set_javascript(&mut self, on: bool) { self.js_off = !on; + if !on { + for doc in &mut self.docs { + replace_runtime(&mut doc.js_runtime); + } + } } /// Whether JavaScript is on (Preferences ▸ JavaScript ▸ Enable Acrobat JavaScript). @@ -647,16 +937,64 @@ impl Session { /// the console (no target). Field changes and `resetForm` are applied as one undoable step; /// everything else the script asked for is returned. pub fn run_javascript(&mut self, id: DocId, script: &str, target: Option<&str>) -> Result { + self.run_javascript_answers(id, script, target, &[]) + } + + /// Run a button/console event with ordered headless prompt answers and commit field changes as one undo step. + pub fn run_javascript_answers( + &mut self, + id: DocId, + script: &str, + target: Option<&str>, + answers: &[Option], + ) -> Result { + self.run_javascript_answers_target(id, script, target, None, answers) + } + /// Run with an optional exact PDF field identity; name-only callers keep first-match behavior. + pub fn run_javascript_target(&mut self, id: DocId, script: &str, target: Option<&str>, target_id: Option) -> Result { + self.run_javascript_answers_target(id, script, target, target_id, &[]) + } + /// Exact-target counterpart of run_javascript_answers, including deterministic prompt replies. + pub fn run_javascript_answers_target( + &mut self, + id: DocId, + script: &str, + target: Option<&str>, + target_id: Option, + answers: &[Option], + ) -> Result { + self.run_javascript_job(id, script, (target, target_id), answers, None, false) + } + + pub(crate) fn run_opening_javascript(&mut self, id: DocId) -> Result { + self.run_javascript_job(id, "", (None, None), &[], None, true) + } + + /// Run an explicitly dispatched PDF document action in the document's retained runtime. + pub fn run_document_script_event(&mut self, id: DocId, event: DocumentScriptEvent, script: &str) -> Result { + self.run_javascript_job(id, script, (None, None), &[], Some(event), false) + } + + /// Route generated XFA clicks to the XFA engine, or run a synchronous AcroForm job and apply its outcome. + fn run_javascript_job( + &mut self, + id: DocId, + script: &str, + (target, target_id): (Option<&str>, Option), + answers: &[Option], + document_event: Option, + opening: bool, + ) -> Result { if self.js_off { return Err(EditError::Invalid("JavaScript is turned off (Preferences ▸ JavaScript)".into())); } let doc = self.get(id).ok_or(EditError::NoDocument)?; let cos = doc.editor.as_ref().map(|e| e.cos.clone()).ok_or_else(|| EditError::ReadOnly(doc.read_only_reason.clone().unwrap_or_default()))?; - // A button PdfCraft generated from an XFA template runs its XFA click script (the app runs - // click scripts for buttons only, so other fields' click events don't run here either). + validate_script_target(target, target_id, &doc.form)?; + // A button PdfCraft generated from an XFA template runs its XFA click script. if let Some(t) = target && let Some(tpl) = doc.xfa_template.clone() - && let Some(field) = doc.form.iter().find(|f| f.name == t) + && let Some(field) = doc.form.iter().find(|f| f.name == t && target_id.is_none_or(|id| field_object_id(f.obj) == id)) && field.kind == crate::FormFieldKind::PushButton && let Some(som) = crate::xfa::clickable_som(&cos, &tpl, field) { @@ -671,24 +1009,53 @@ impl Session { ..Default::default() }); } - let runner = JsRunner::new(&cos, &doc.name, self.field_highlight); + let mut runner = JsRunner::new(&cos, &doc.name, self.field_highlight); + runner.doc.zoom = doc.js_zoom; + runner.doc.page = doc.js_page; let fields = pdfcraft_forms::fields(&cos); - let states = std::sync::Arc::new(fields.iter().map(|f| field_state(&cos, f)).collect::>()); + let states = std::sync::Arc::new(fields.iter().map(|f| field_state(&cos, f, &runner.doc)).collect::>()); let event = match target { - Some(t) => pdfcraft_js::Event { will_commit: false, ..pdfcraft_js::Event::field("Mouse Up", t, "") }, - None => pdfcraft_js::Event::doc("Console"), + Some(t) => pdfcraft_js::Event { will_commit: false, target_id, ..pdfcraft_js::Event::field("Mouse Up", t, "") }, + None => pdfcraft_js::Event::doc(document_event.map_or("Console", DocumentScriptEvent::name)), }; - let calculations = calculations_for_script(&cos, &fields, script, &runner.doc_scripts); - let mut o = pdfcraft_js::run_shared_calculations_shared( - script, - &event, - &runner.doc, - std::sync::Arc::clone(&states), - &runner.doc_scripts, - &calculations, - Limits::default(), + let calculations = calculation_scripts(&cos, &fields); + let runtime = doc.js_runtime.clone(); + let o = runtime.lock().map_err(|_| EditError::Invalid("document script runtime is unavailable".into()))?.run( + pdfcraft_js::session::Job { + script: script.into(), + event, + doc: runner.doc, + fields: states.clone(), + doc_scripts: runner.doc_scripts, + calculations, + }, + answers, ); - let changes = changes(&states, &o.changed, &o.formatted); + let discard = o.execution_refused && !o.realm_untouched; + let result = self.apply_script_outcome(id, &states, o, opening); + if discard || result.is_err() { + self.invalidate_javascript(id); + } + result + } + + /// Convert the worker snapshot delta and reset requests into permission-checked atomic document edits. + fn apply_script_outcome(&mut self, id: DocId, states: &[pdfcraft_js::FieldState], mut o: Outcome, opening: bool) -> Result { + if o.execution_refused { + o.changed.clear(); + o.requests.clear(); + return Ok(o); + } + if o.calculation_limit_exceeded { + return Err(EditError::Invalid(o.error.unwrap_or_else(|| "script calculation work limit exceeded".into()))); + } + if o.reset_limit_exceeded { + return Err(EditError::Invalid("script reset call limit exceeded; no script edits were committed".into())); + } + if o.requests.iter().any(|r| matches!(r, Request::Reset(_))) { + let doc = self.get(id).ok_or(EditError::NoDocument)?; + validate_reset_work(&doc.form, states, &o.requests)?; + } let resets: Vec> = o .requests .iter() @@ -698,18 +1065,47 @@ impl Session { }) .collect(); o.requests.retain(|r| !matches!(r, Request::Reset(_))); + // Reset the PDF first, then apply the final live shadow. Compare against the + // reset baseline so writes restoring an original value are not mistaken for no-ops. + let mut baseline = std::borrow::Cow::Borrowed(states); + let mut reset_document = if resets.is_empty() { + None + } else { + let doc = self.get(id).ok_or(EditError::NoDocument)?; + let editor = doc.editor.as_ref().ok_or_else(|| EditError::ReadOnly(doc.read_only_reason.clone().unwrap_or_default()))?; + Some(editor.cos.clone()) + }; // The calculations the script's changes set off read the field highlight it asked for. if let Some(on) = last_highlight(&o.requests) { self.field_highlight = on; } let mut edits = Vec::new(); + for names in resets { + let names = (!names.is_empty()) + .then(|| states.iter().filter(|field| reset_name_matches(&field.name, &names)).map(|field| field.name.clone()).collect::>()); + if let Some(doc) = reset_document.as_mut() { + // The preview includes dependent calculation writes as well as direct resets. + let (reset, recalculated) = pdfcraft_forms::reset_with_counts(doc, names.as_deref())?; + if !opening || reset != 0 || recalculated != 0 { + edits.push(Edit::ResetForm { names }); + } + } + } + if let Some(doc) = reset_document { + let mut values = std::collections::HashMap::new(); + for field in pdfcraft_forms::fields(&doc) { + values.entry((u64::from(field.obj.num) << 16) | u64::from(field.obj.generation)).or_insert(field.value); + } + for field in baseline.to_mut() { + if let Some(value) = field.object_id.and_then(|id| values.get(&id)) { + field.value.clone_from(value); + } + } + } + let changes = changes(&baseline, &o.changed, &o.formatted); if !changes.is_empty() { edits.push(Edit::ApplyScriptChanges { changes }); } - if !resets.is_empty() { - let all: Vec<&str> = fields.iter().map(|f| f.name.as_str()).collect(); - edits.push(Edit::ResetForm { names: reset_targets(&all, &resets) }); - } if !edits.is_empty() { self.apply(id, Edit::Batch { label: "Run JavaScript".into(), edits })?; } @@ -717,21 +1113,6 @@ impl Session { } } -/// The fields every `resetForm` call of one script asked for, as one reset: a reset is to the -/// defaults, so resetting the union once does what each call in turn would, and recalculates -/// once. `None` is the whole form (a call with no names). A field is named by a selector that -/// is its name or the name of a field above it, looked up in a set of the selectors, so the -/// work is the fields times their depth, not the fields times the selectors, which a script -/// controls. -fn reset_targets(fields: &[&str], requests: &[Vec]) -> Option> { - if requests.iter().any(Vec::is_empty) { - return None; - } - let wanted: std::collections::HashSet<&str> = requests.iter().flatten().map(String::as_str).collect(); - let matched = |name: &str| name.match_indices('.').map(|(i, _)| &name[..i]).chain(std::iter::once(name)).any(|p| wanted.contains(p)); - Some(fields.iter().filter(|n| matched(n)).map(|n| n.to_string()).collect()) -} - /// A page's words for form-field detection, in user space (underscore runs split from the /// text around them). fn detection_words(text: &pdfcraft_render::PageText, info: &pdfcraft_render::PageInfo) -> Vec { @@ -881,26 +1262,6 @@ impl Session { mod review_tests { use super::*; - /// Many `resetForm` calls with many selectors over a big form: one reset of the union, found - /// by looking each field's name and parents up, not by comparing every field with every - /// selector. - #[test] - fn reset_requests_are_coalesced_and_matched_through_an_index() { - let fields: Vec = (0..20_000).map(|i| format!("group{}.row{i}.cell", i % 100)).collect(); - let names: Vec<&str> = fields.iter().map(String::as_str).collect(); - let requests: Vec> = (0..1_000).map(|k| (0..20).map(|j| format!("nothing{}", k * 20 + j)).collect()).collect(); - let started = std::time::Instant::now(); - assert_eq!(reset_targets(&names, &requests), Some(Vec::new()), "no selector names a field"); - let mut requests = requests; - requests.push(vec!["group7".into(), "group7.row7.cell".into(), "group8.row8".into()]); - let hit = reset_targets(&names, &requests).unwrap(); - assert_eq!(hit.len(), 200 + 1, "group7 has 200 fields, one more by its exact name"); - assert!(hit.iter().all(|n| n.starts_with("group7.") || n == "group8.row8.cell")); - assert!(started.elapsed() < std::time::Duration::from_secs(5), "{:?}", started.elapsed()); - requests.push(Vec::new()); - assert_eq!(reset_targets(&names, &requests), None, "a call without names resets the whole form"); - } - /// A click returns its own alert when the output waiting in the document is full, and the /// waiting output stays. #[test] @@ -1060,3 +1421,467 @@ mod review_tests { assert!(changes(&before, &[missing], &formatted).is_empty()); } } + +#[cfg(test)] +mod caption_tests { + use super::*; + + #[test] + /// A setter matching widget zero still updates other widgets whose normal captions differ. + fn setting_the_first_widgets_caption_still_emits_a_field_wide_change() { + let mut button = pdfcraft_js::FieldState::new("go", pdfcraft_js::FieldType::Button, vec![]); + button.caption = Some("First".into()); + button.caption_mixed = true; + let before = vec![button]; + let outcome = pdfcraft_js::run( + "getField('go').buttonSetCaption('First');", + &pdfcraft_js::Event::field("Mouse Up", "go", ""), + &pdfcraft_js::DocInfo { file_name: "buttons.pdf".into(), num_pages: 1, page: 0, info: vec![], xfa: false, ..Default::default() }, + &before, + &[], + Limits::default(), + ); + assert_eq!(outcome.error, None); + let changed = changes(&before, &outcome.changed, &outcome.formatted); + assert_eq!(changed.len(), 1, "other widgets may have different captions"); + assert_eq!(changed[0].caption.as_deref(), Some("First")); + } +} + +#[cfg(not(target_arch = "wasm32"))] +pub struct PendingScript { + pub id: DocId, + generation: u64, + runtime: Runtime, + states: std::sync::Arc>, + pub receiver: std::sync::mpsc::Receiver, +} +#[cfg(not(target_arch = "wasm32"))] +impl Session { + /// Check document identity/generation and the JavaScript preference before accepting a worker completion. + pub fn script_pending_current(&self, p: &PendingScript) -> bool { + !self.js_off && self.get(p.id).is_some_and(|d| d.generation == p.generation && std::sync::Arc::ptr_eq(&d.js_runtime, &p.runtime)) + } + /// Capture the current fields/view and start a native script without blocking the caller on its prompts. + pub fn start_javascript(&mut self, id: DocId, script: &str, target: Option<&str>) -> Result { + self.start_javascript_target(id, script, target, None) + } + /// Start an asynchronous button event retaining the clicked field's exact identity. + pub fn start_javascript_target( + &mut self, + id: DocId, + script: &str, + target: Option<&str>, + target_id: Option, + ) -> Result { + if self.js_off { + return Err(EditError::Invalid("JavaScript is disabled".into())); + } + let doc = self.get(id).ok_or(EditError::NoDocument)?; + let cos = &doc.editor.as_ref().ok_or_else(|| EditError::ReadOnly(doc.read_only_reason.clone().unwrap_or_default()))?.cos; + let mut runner = JsRunner::new(cos, &doc.name, self.field_highlight); + runner.doc.zoom = doc.js_zoom; + runner.doc.page = doc.js_page; + let fields = pdfcraft_forms::fields(cos); + validate_script_target(target, target_id, &fields)?; + let states = std::sync::Arc::new(fields.iter().map(|f| field_state(cos, f, &runner.doc)).collect::>()); + let job = pdfcraft_js::session::Job { + script: script.into(), + event: target.map_or_else( + || pdfcraft_js::Event::doc("Console"), + |t| pdfcraft_js::Event { will_commit: false, target_id, ..pdfcraft_js::Event::field("Mouse Up", t, "") }, + ), + doc: runner.doc, + fields: states.clone(), + doc_scripts: runner.doc_scripts, + calculations: calculation_scripts(cos, &fields), + }; + let receiver = + doc.js_runtime.lock().map_err(|_| EditError::Invalid("script runtime unavailable".into()))?.start(job).map_err(EditError::Invalid)?; + Ok(PendingScript { id, generation: doc.generation, runtime: doc.js_runtime.clone(), states, receiver }) + } + /// Discard only the captured runtime, preserving a newer realm created by an intervening edit. + pub fn discard_javascript(&mut self, p: &PendingScript) { + if let Some(doc) = self.docs.iter_mut().find(|d| d.id == p.id) + && std::sync::Arc::ptr_eq(&doc.js_runtime, &p.runtime) + { + replace_runtime(&mut doc.js_runtime); + } + } + /// Discard a timed-out invocation and retain the worker slot until its thread exits. + pub fn timeout_javascript(&mut self, p: PendingScript) { + if self.script_pending_current(&p) + && let Ok(mut runtime) = p.runtime.lock() + { + runtime.retire_timed_out_worker(); + } else { + self.discard_javascript(&p); + } + } + + /// Apply a current worker result, or discard it and reset the realm when the document changed. + pub fn finish_javascript(&mut self, p: PendingScript, out: Outcome) -> Result { + if !self.script_pending_current(&p) { + self.discard_javascript(&p); + return Err(EditError::Invalid("document changed while the script was running".into())); + } + p.runtime.lock().map_err(|_| EditError::Invalid("script runtime unavailable".into()))?.acknowledge_completion(); + let discard = out.execution_refused && !out.realm_untouched; + let result = self.apply_script_outcome(p.id, &p.states, out, false); + if discard || result.is_err() { + self.invalidate_javascript(p.id); + } + result + } +} +pub use pdfcraft_js::interaction::{InteractiveEvent, MenuItem, Prompt}; +impl Session { + /// Supply current viewer state for script getters, clamping invalid zoom to the supported range. + pub fn set_javascript_view(&mut self, id: DocId, zoom: f64, highlight: bool, page: usize) { + self.set_field_highlight(highlight); + if let Some(doc) = self.docs.iter_mut().find(|d| d.id == id) { + doc.js_zoom = if zoom.is_finite() { zoom.clamp(1.0, 6400.0) } else { 100.0 }; + + doc.js_page = page; + } + } +} + +impl Session { + /// Release this document runtime after cancellation or lifecycle changes; the next event initializes anew. + pub fn invalidate_javascript(&mut self, id: DocId) { + if let Some(doc) = self.docs.iter_mut().find(|d| d.id == id) { + replace_runtime(&mut doc.js_runtime); + } + } +} + +#[cfg(test)] +thread_local! { + /// Page-tree snapshots and calculation-plan builds performed on this test thread. + static SNAPSHOT_WORK: std::cell::Cell<(usize,usize)> = const { std::cell::Cell::new((0,0)) }; +} + +#[cfg(test)] +mod snapshot_review_tests { + use super::*; + + #[test] + fn document_actions_use_the_document_realm_and_obey_javascript_setting() { + let mut session = Session::new(); + let bytes = session.create_blank(200.0, 300.0, 1).unwrap(); + let first = session.open("first.pdf", None, bytes.clone(), None).unwrap(); + let second = session.open("second.pdf", None, bytes, None).unwrap(); + session.run_javascript(first, "var counter = 4;", None).unwrap(); + + for (event, name) in + [(DocumentScriptEvent::CatalogOpen, "Open"), (DocumentScriptEvent::WillSave, "WillSave"), (DocumentScriptEvent::WillPrint, "WillPrint")] + { + let result = session.run_document_script_event(first, event, "[event.name,counter].join('/');").unwrap(); + assert_eq!(result.result, Some(format!("{name}/4"))); + } + let result = session.run_document_script_event(second, DocumentScriptEvent::WillSave, "typeof counter;").unwrap(); + assert_eq!(result.result.as_deref(), Some("undefined")); + session.set_javascript(false); + assert!(session.run_document_script_event(first, DocumentScriptEvent::WillPrint, "counter++;").is_err()); + } + + /// Real page/info edits and completed Save As refresh metadata without losing script state. + #[test] + fn retained_document_metadata_tracks_edits_undo_and_save_as() { + let mut session = Session::new(); + let bytes = session.create_blank(200.0, 300.0, 1).unwrap(); + let id = session.open("before.pdf", None, bytes, None).unwrap(); + assert_eq!(session.run_javascript(id, "var retained=41;numPages;", None).unwrap().result.as_deref(), Some("1")); + session.apply(id, Edit::InsertBlankPage { at: 1, width: 200.0, height: 300.0 }).unwrap(); + session.apply(id, Edit::SetInfo { key: "Title".into(), value: "After".into() }).unwrap(); + let out = session.run_javascript(id, "[numPages,info.title,retained].join('/');", None).unwrap(); + assert_eq!(out.result.as_deref(), Some("2/After/41")); + session.undo(id).unwrap(); + assert_eq!(session.run_javascript(id, "typeof info.title;", None).unwrap().result.as_deref(), Some("undefined")); + session.redo(id).unwrap(); + let saved = session.save_bytes(id).unwrap(); + session.mark_saved(id, saved, Some("after.pdf".into())).unwrap(); + assert_eq!( + session.run_javascript(id, "[documentFileName,numPages,info.title,retained].join('/');", None).unwrap().result.as_deref(), + Some("after.pdf/2/After/41") + ); + session.apply(id, Edit::DeletePages { pages: vec![1] }).unwrap(); + assert_eq!(session.run_javascript(id, "[numPages,retained].join('/');", None).unwrap().result.as_deref(), Some("1/41")); + session.undo(id).unwrap(); + assert_eq!(session.run_javascript(id, "numPages;", None).unwrap().result.as_deref(), Some("2")); + session.redo(id).unwrap(); + assert_eq!(session.run_javascript(id, "numPages;", None).unwrap().result.as_deref(), Some("1")); + } + + /// A batch child that changes page structure or calculation sources invalidates cached plans. + #[test] + fn batch_children_refresh_geometry_metadata_and_calculation_sources() { + let mut session = Session::new(); + let bytes = session.create_blank(200.0, 300.0, 1).unwrap(); + let id = session.open("batch.pdf", None, bytes, None).unwrap(); + for (i, name) in ["input", "total", "seen"].into_iter().enumerate() { + session + .apply( + id, + Edit::AddField { + page: 0, + rect: [20.0, 20.0 + i as f64 * 40.0, 120.0, 40.0 + i as f64 * 40.0], + kind: crate::NewField::Text { multiline: false }, + name: Some(name.into()), + }, + ) + .unwrap(); + } + session.apply(id, Edit::SetFieldValue { name: "input".into(), value: crate::FieldValue::Text("2".into()) }).unwrap(); + session + .apply( + id, + Edit::SetFieldScript { + name: "total".into(), + event: "calculate".into(), + script: Some("event.value=Number(getField('input').value)+1;".into()), + }, + ) + .unwrap(); + session + .apply( + id, + Edit::SetFieldScript { + name: "input".into(), + event: "validate".into(), + script: Some("calculateNow();getField('seen').value=numPages+'/'+getField('total').value;".into()), + }, + ) + .unwrap(); + session + .apply( + id, + Edit::Batch { + label: "Structural batch".into(), + edits: vec![ + Edit::SetFieldValue { name: "input".into(), value: crate::FieldValue::Text("3".into()) }, + Edit::InsertBlankPage { at: 1, width: 200.0, height: 300.0 }, + Edit::SetFieldScript { + name: "total".into(), + event: "calculate".into(), + script: Some("event.value=Number(getField('input').value)+10;".into()), + }, + Edit::SetFieldValue { name: "input".into(), value: crate::FieldValue::Text("4".into()) }, + ], + }, + ) + .unwrap(); + let out = session.run_javascript(id, "getField('seen').value;", None).unwrap(); + assert_eq!(out.error, None); + assert_eq!(out.result.as_deref(), Some("2/13")); + } + + #[test] + fn callback_field_cache_keeps_live_values_and_uses_object_identity() { + let mut session = Session::new(); + let bytes = session.create_blank(200.0, 300.0, 1).unwrap(); + let id = session.open("cache.pdf", None, bytes, None).unwrap(); + for (name, rect) in [("first", [20.0, 20.0, 100.0, 40.0]), ("second", [20.0, 60.0, 100.0, 80.0])] { + session.apply(id, Edit::AddField { page: 0, rect, kind: crate::NewField::Text { multiline: false }, name: Some(name.into()) }).unwrap(); + } + + let cos = &session.get(id).unwrap().editor.as_ref().unwrap().cos; + let mut fields = pdfcraft_forms::fields(cos); + fields[1].name = fields[0].name.clone(); + let mut runner = JsRunner::new(cos, "cache.pdf", false); + let initial = runner.callback_fields(cos, &fields); + assert_ne!(initial[0].object_id, initial[1].object_id); + assert_ne!(initial[0].rect, initial[1].rect); + fields[0].value = vec!["fresh".into()]; + let updated = runner.callback_fields(cos, &fields); + assert_eq!(updated[0].value, ["fresh"]); + assert_eq!(updated[1].value, initial[1].value); + assert_eq!(runner.snapshot_fields.len(), 2); + + fields[0].widgets[0].rect = [20.0, 100.0, 100.0, 120.0]; + runner.invalidate_field_snapshots(); + let moved = runner.callback_fields(cos, &fields[..1]); + assert_ne!(moved[0].rect, updated[0].rect); + assert_eq!(runner.snapshot_fields.len(), 1); + } + + /// A callback reuses the previous callback's snapshot, patching only values that changed, + /// and a snapshot a caller still holds is never changed under it. + #[test] + fn callback_snapshots_are_patched_in_place_and_copied_only_when_shared() { + let mut session = Session::new(); + let bytes = session.create_blank(200.0, 300.0, 1).unwrap(); + let id = session.open("patch.pdf", None, bytes, None).unwrap(); + for (name, rect) in [("first", [20.0, 20.0, 100.0, 40.0]), ("second", [20.0, 60.0, 100.0, 80.0])] { + session.apply(id, Edit::AddField { page: 0, rect, kind: crate::NewField::Text { multiline: false }, name: Some(name.into()) }).unwrap(); + } + let cos = &session.get(id).unwrap().editor.as_ref().unwrap().cos; + let mut fields = pdfcraft_forms::fields(cos); + let mut runner = JsRunner::new(cos, "patch.pdf", false); + let first = runner.callback_fields(cos, &fields); + let first_ptr = std::sync::Arc::as_ptr(&first); + drop(first); + // Nothing changed: the same snapshot comes back. + let same = runner.callback_fields(cos, &fields); + assert_eq!(std::sync::Arc::as_ptr(&same), first_ptr); + drop(same); + // A value changed and no one else holds the snapshot: it is patched in place. + fields[1].value = vec!["7".into()]; + let patched = runner.callback_fields(cos, &fields); + assert_eq!(std::sync::Arc::as_ptr(&patched), first_ptr); + assert_eq!(patched[1].value, ["7"]); + // A held snapshot keeps its values; the next callback gets a copy. + fields[0].value = vec!["held".into()]; + let copied = runner.callback_fields(cos, &fields); + assert_eq!(patched[0].value, Vec::::new()); + assert_eq!(copied[0].value, ["held"]); + assert_eq!(copied[1].value, ["7"]); + // Instance tokens are stamped before dispatch, so the worker does not copy it again. + assert!(copied.iter().all(|f| f.instance_id != 0)); + } + + /// Operation counts, rather than wall time, pin the absence of per-field tree/plan walks. + #[test] + fn field_events_reuse_page_geometry_and_calculation_plans() { + let mut session = Session::new(); + let bytes = session.create_blank(600.0, 800.0, 4).unwrap(); + let id = session.open("synthetic.pdf", None, bytes, None).unwrap(); + for i in 0..12 { + session + .apply( + id, + Edit::AddField { + page: i % 4, + rect: [20.0, 20.0, 100.0, 40.0], + kind: crate::NewField::Text { multiline: false }, + name: Some(format!("f{i}")), + }, + ) + .unwrap(); + } + let cos = &session.get(id).unwrap().editor.as_ref().unwrap().cos; + let mut fields = pdfcraft_forms::fields(cos); + fields[1].actions.scripts.calculate = Some("event.value=Number(getField('f0').value)+1;".into()); + SNAPSHOT_WORK.with(|c| c.set((0, 0))); + let mut runner = JsRunner::new(cos, "synthetic.pdf", false); + for i in 0..8 { + fields[0].value = vec![i.to_string()]; + let out = runner.run(FieldEvent::Calculate, "calculateNow();event.value=getField('f1').value;", &fields[1], "", &fields, cos); + assert_eq!(out.value, (i + 1).to_string()); + assert!(runner.output.errors.is_empty(), "{:?}", runner.output.errors); + } + let work = SNAPSHOT_WORK.with(std::cell::Cell::get); + eprintln!("geometry snapshots={}, calculation plan builds={}", work.0, work.1); + assert_eq!(work, (1, 1), "one geometry snapshot and one calculation plan per runner"); + } +} + +#[cfg(test)] +mod reset_budget_review_tests { + use super::*; + fn state(name: String) -> pdfcraft_js::FieldState { + pdfcraft_js::FieldState::new(name, pdfcraft_js::FieldType::Text, Vec::new()) + } + #[test] + fn reset_budget_accepts_one_selected_field_in_a_large_form() { + let states: Vec<_> = (0..1000).map(|i| state(format!("parent.field_{i:04}"))).collect(); + assert!(validate_reset_work(&[], &states, &[Request::Reset(vec!["parent.field_0001".into()])]).is_ok()); + assert!(validate_reset_work(&[], &states, &[Request::Reset(vec!["parent".into()])]).is_err()); + } + + #[test] + fn reset_budget_charges_expanded_descendants() { + let states: Vec<_> = (0..3000).map(|i| state(format!("parent.{i}"))).collect(); + assert!(validate_reset_work(&[], &states, &[Request::Reset(vec!["parent".into()])]).is_err()); + assert!(validate_reset_work(&[], &states, &[Request::Reset(Vec::new())]).is_ok()); + } + #[test] + fn reset_budget_charges_shared_prefix_comparisons() { + let prefix = "p".repeat(4096); + let states: Vec<_> = (0..128).map(|i| state(format!("{prefix}.{i}"))).collect(); + let names = (0..128).map(|i| format!("{prefix}.missing{i}")).collect(); + assert!(validate_reset_work(&[], &states, &[Request::Reset(names)]).is_err()); + } + #[test] + fn reset_budget_charges_full_form_name_copies() { + let states = vec![state("n".repeat(140_000))]; + let resets = vec![Request::Reset(Vec::new()); 64]; + assert!(validate_reset_work(&[], &states, &resets).is_err()); + assert!(validate_reset_work(&[], &states, &[Request::Reset(Vec::new())]).is_ok()); + } +} + +#[cfg(test)] +mod widget_budget_review_tests { + use super::*; + #[test] + fn reset_budget_charges_all_widgets() { + let widget = pdfcraft_forms::Widget { + obj: pdfcraft_cos::ObjRef { num: 1, generation: 0 }, + page: None, + rect: [0.0; 4], + on_state: None, + state: None, + tab: 0, + locked: false, + hidden: false, + read_only: false, + rotation: 0, + caption: None, + mouse_up_script: None, + list: None, + round: false, + }; + let mut field = Field { + name: "x".into(), + obj: widget.obj, + kind: FieldKind::Text, + value: vec![], + default: vec![], + flags: 0, + max_len: None, + options: vec![], + da: String::new(), + quadding: 0, + tooltip: None, + selected: vec![], + single_value_is_array: false, + widgets: vec![widget; 140_000], + actions: Default::default(), + button: None, + }; + let resets = vec![Request::Reset(Vec::new()); 64]; + assert!(validate_reset_work(std::slice::from_ref(&field), &[], &resets).is_err()); + field.widgets.truncate(1); + assert!(validate_reset_work(&[field], &[], &resets).is_ok()); + } +} + +#[cfg(test)] +mod icon_mixed_review_tests { + use super::*; + /// Mixed widgets need a field-wide icon delta even when the first icon is already correct. + #[test] + fn mixed_icons_emit_only_after_an_explicit_setter() { + for icon in [None, Some(7)] { + let mut before = pdfcraft_js::FieldState::new("go", pdfcraft_js::FieldType::Button, vec![]); + before.icon = icon; + before.icon_mixed = true; + let fields = vec![before.clone()]; + let script = + if icon.is_some() { "getField('go').buttonSetIcon(getField('go').buttonGetIcon());" } else { "getField('go').buttonSetIcon(null);" }; + let out = pdfcraft_js::run( + script, + &pdfcraft_js::Event::doc("Test"), + &pdfcraft_js::DocInfo::default(), + &fields, + &[], + pdfcraft_js::Limits::default(), + ); + assert_eq!(out.error, None); + assert_eq!(changes(&[before.clone()], &out.changed, &out.formatted)[0].icon, Some(icon)); + assert!(changes(&[before.clone()], &[before], &Default::default()).is_empty()); + } + } +} diff --git a/crates/engine/src/open.rs b/crates/engine/src/open.rs index 28dd235928..8fd5cd992e 100644 --- a/crates/engine/src/open.rs +++ b/crates/engine/src/open.rs @@ -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, diff --git a/crates/engine/src/session/editing.rs b/crates/engine/src/session/editing.rs index 1584fb5209..4a6f372fb4 100644 --- a/crates/engine/src/session/editing.rs +++ b/crates/engine/src/session/editing.rs @@ -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 { + // 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, 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; diff --git a/crates/engine/src/session/save.rs b/crates/engine/src/session/save.rs index dd32cdbc5c..d8193ba084 100644 --- a/crates/engine/src/session/save.rs +++ b/crates/engine/src/session/save.rs @@ -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>) -> 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())?; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 81c98d05ef..8bad98eea4 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -6724,7 +6724,7 @@ fn javascript_invalid_field_colour_array_is_refused_without_partial_changes() { let id = s.open("order.pdf", None, Arc::new(scripted_form()), None).unwrap(); let before = s.save_bytes(id).unwrap(); let err = s.run_javascript(id, "var f = getField('price'); f.value = '9'; f.fillColor = ['RGB', 2, 0, 0];", None).unwrap_err(); - assert!(err.to_string().contains("finite values between 0 and 1"), "{err}"); + assert!(err.to_string().contains("finite components between 0 and 1"), "{err}"); assert_eq!(s.save_bytes(id).unwrap(), before, "a refused script changes nothing"); } @@ -6753,9 +6753,13 @@ fn javascript_field_colours_respect_document_fill_permissions() { } #[test] +/// Synchronous calculation exposes pending values immediately and persists the invocation as one undo transaction. fn calculate_now_updates_live_fields_in_one_undo_step() { let mut s = Session::new().with_clock(|| 1_700_000_000); let id = s.open("calculate-now.pdf", None, Arc::new(scripted_form()), None).unwrap(); + let value = |s: &Session, name: &str| s.get(id).unwrap().form.iter().find(|f| f.name == name).unwrap().value.clone(); + let before_price = value(&s, "price"); + let before_qty = value(&s, "qty"); let out = s .run_javascript( id, @@ -6768,52 +6772,18 @@ fn calculate_now_updates_live_fields_in_one_undo_step() { assert_eq!(total, ["6"]); assert_eq!(s.get(id).unwrap().can_undo(), Some("Run JavaScript")); s.undo(id).unwrap(); + assert_eq!(value(&s, "price"), before_price); + assert_eq!(value(&s, "qty"), before_qty); assert!(s.get(id).unwrap().form.iter().find(|f| f.name == "total").unwrap().value.is_empty()); s.redo(id).unwrap(); + assert_eq!(value(&s, "price"), ["3"]); + assert_eq!(value(&s, "qty"), ["2"]); assert_eq!(s.get(id).unwrap().form.iter().find(|f| f.name == "total").unwrap().value, ["6"]); let bytes = s.save_bytes(id).unwrap(); let reopened = s.open("reopened.pdf", None, bytes, None).unwrap(); assert_eq!(s.get(reopened).unwrap().form.iter().find(|f| f.name == "total").unwrap().value, ["6"]); } -#[test] -fn calculate_now_applies_format_script_to_updated_appearance() { - let mut session = Session::new().with_clock(|| 1_700_000_000); - let id = session.open("calculate-format.pdf", None, Arc::new(scripted_form()), None).unwrap(); - session.run_javascript(id, "getField('price').value = 3; getField('qty').value = 2; calculateNow();", None).unwrap(); - - assert_eq!(session.get(id).unwrap().form.iter().find(|field| field.name == "total").unwrap().value, ["6"]); - assert!(page_texts(&session, id)[0].contains("USD 6.00"), "the appearance uses the Format script"); -} - -#[test] -fn calculation_scripts_are_built_only_for_scripts_that_call_calculate_now() { - let mut s = Session::new().with_clock(|| 1_700_000_000); - let id = s.open("calculate-list.pdf", None, Arc::new(scripted_form()), None).unwrap(); - crate::js::CALCULATION_SCRIPT_BUILDS.with(|calls| calls.set(0)); - - s.run_javascript(id, "1 + 1", None).unwrap(); - assert_eq!(crate::js::CALCULATION_SCRIPT_BUILDS.with(std::cell::Cell::get), 0); - - s.run_javascript(id, "calculateNow();", None).unwrap(); - assert_eq!(crate::js::CALCULATION_SCRIPT_BUILDS.with(std::cell::Cell::get), 1); - - use pdfcraft_forms::{FieldEvent, Scripts}; - let cos = pdfcraft_cos::Document::open(Arc::new(scripted_form())).unwrap(); - let fields = pdfcraft_forms::fields(&cos); - let target = fields.iter().find(|f| f.name == "go").unwrap(); - let mut runner = crate::js::JsRunner::new(&cos, "calculate-list.pdf", false); - crate::js::CALCULATION_SCRIPT_BUILDS.with(|calls| calls.set(0)); - - runner.run(FieldEvent::Calculate, "1 + 1", target, "", &fields); - assert_eq!(crate::js::CALCULATION_SCRIPT_BUILDS.with(std::cell::Cell::get), 0); - - runner.run(FieldEvent::Calculate, "calculateNow();", target, "", &fields); - assert_eq!(crate::js::CALCULATION_SCRIPT_BUILDS.with(std::cell::Cell::get), 1); - runner.run(FieldEvent::Calculate, "calculateNow();", target, "", &fields); - assert_eq!(crate::js::CALCULATION_SCRIPT_BUILDS.with(std::cell::Cell::get), 1); -} - #[test] fn calculate_now_in_a_document_script_helper_gets_the_calculation_list() { let helpers = "function times\\(a, b\\) { return a * b; } function recalc\\(\\) { calculateNow\\(\\); }"; @@ -6828,6 +6798,7 @@ fn calculate_now_in_a_document_script_helper_gets_the_calculation_list() { } #[test] +/// Respect explicit calculation order, restore the outer event and stop recursive calculation requests. fn calculate_now_uses_calculation_order_and_bounds_recursion() { let mut s = Session::new().with_clock(|| 1_700_000_000); let id = s.open("calculate-chain.pdf", None, Arc::new(scripted_form()), None).unwrap(); @@ -6841,18 +6812,34 @@ fn calculate_now_uses_calculation_order_and_bounds_recursion() { assert!(out.error.is_none(), "{out:?}"); assert!(out.console.iter().any(|line| line == "8"), "{out:?}"); + s.set_javascript(false); // Installing scripts must not change the 4/8 starting values. for (name, script) in [("qty", "event.value = Number(getField('total').value) + 1;"), ("total", "event.value = Number(getField('qty').value) + 1;")] { s.apply(id, Edit::SetFieldScript { name: name.into(), event: "calculate".into(), script: Some(script.into()) }).unwrap(); } - let cyclic = s.run_javascript(id, "calculateNow();", None).unwrap(); + s.set_javascript(true); + let starting = |name: &str| s.get(id).unwrap().form.iter().find(|f| f.name == name).unwrap().value.clone(); + assert_eq!(starting("qty"), ["4"]); + assert_eq!(starting("total"), ["8"]); + let cyclic = + s.run_javascript(id, "calculateNow(); console.println(getField('qty').value); console.println(getField('total').value);", None).unwrap(); assert!(cyclic.error.is_none(), "a cyclic calculation runs once in order: {cyclic:?}"); + let field = |name: &str| s.get(id).unwrap().form.iter().find(|f| f.name == name).unwrap().value.clone(); + assert_eq!(cyclic.console, ["9", "10"], "the explicit ordered pass runs exactly once"); + // Applying the value delta invokes the existing automatic form-recalculation pass. + assert_eq!(field("qty"), ["11"]); + assert_eq!(field("total"), ["12"]); - s.apply(id, Edit::SetFieldScript { name: "total".into(), event: "calculate".into(), script: Some("calculateNow(); event.value = 12;".into()) }) - .unwrap(); - let out = s.run_javascript(id, "calculateNow();", None).unwrap(); - assert!(out.console.iter().any(|line| line.contains("calculateNow recursion limit exceeded")), "{out:?}"); + let recursive = + Edit::SetFieldScript { name: "total".into(), event: "calculate".into(), script: Some("calculateNow(); event.value = 12;".into()) }; + let refused = s.apply(id, recursive.clone()).unwrap_err(); + assert!(refused.to_string().contains("calculateNow recursion limit exceeded"), "{refused:?}"); + s.set_javascript(false); + s.apply(id, recursive).unwrap(); + s.set_javascript(true); + let error = s.run_javascript(id, "calculateNow();", None).unwrap_err(); + assert!(error.to_string().contains("calculateNow recursion limit exceeded"), "{error:?}"); s.set_javascript(false); assert!(s.run_javascript(id, "calculateNow();", None).is_err()); s.set_javascript(true); @@ -6935,7 +6922,7 @@ fn calculation_order_cache_refreshes_after_inserting_pages() { let fields = pdfcraft_forms::fields(&doc); let qty = fields.iter().find(|field| field.name == "qty").unwrap(); let mut js = crate::js::JsRunner::new(&doc, "destination.pdf", false); - js.run(FieldEvent::Format, "calculateNow();", qty, "", &fields); + js.run(FieldEvent::Format, "calculateNow();", qty, "", &fields, &doc); let source = pdfcraft_testkit::Pdf::new() .obj("<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R] /CO [4 0 R] >> >>") @@ -6961,7 +6948,7 @@ fn calculation_order_cache_refreshes_after_inserting_pages() { let fields = pdfcraft_forms::fields(&doc); let qty = fields.iter().find(|field| field.name == "qty").unwrap(); let js = cx.js.as_mut().unwrap(); - let result = js.run(FieldEvent::Format, "calculateNow();", qty, "", &fields); + let result = js.run(FieldEvent::Format, "calculateNow();", qty, "", &fields, &doc); assert!( result.changes.iter().any(|change| { change.name == "importedTotal" && change.value.as_ref().is_some_and(|value| value.len() == 1 && value.first().is_some_and(|v| v == "9")) @@ -6977,7 +6964,7 @@ fn javascript_field_rect_moves_first_widget_and_undoes() { let mut s = Session::new(); let id = s.open("rect.pdf", None, Arc::new(scripted_form()), None).unwrap(); let initial = s.get(id).unwrap().form.iter().find(|f| f.name == "price").unwrap().widgets[0].rect; - s.run_javascript(id, "getField('price').rect = [30, 40, 130, 60];", Some("go")).unwrap(); + s.run_javascript(id, "getField('price').rect = [30, 60, 130, 40];", Some("go")).unwrap(); assert_eq!(s.get(id).unwrap().form.iter().find(|f| f.name == "price").unwrap().widgets[0].rect, [30.0, 40.0, 130.0, 60.0]); assert_eq!(s.get(id).unwrap().can_undo(), Some("Run JavaScript")); s.undo(id).unwrap(); @@ -7005,20 +6992,39 @@ fn scripts_in_an_xfa_form_see_the_xfa_plugin_installed() { /// helper stays defined, the form calculates, and the error is reported once. #[test] fn a_document_script_that_throws_still_lets_the_form_calculate() { - let helpers = - "function times\\(a, b\\) { return a * b; } this.getField\\('go'\\).buttonSetIcon\\(this.getField\\('go'\\).buttonGetIcon\\(\\)\\);"; + let helpers = "function times\\(a, b\\) { return a * b; } this.getField\\('go'\\).missingTestMethod\\(\\);"; let mut s = Session::new().with_clock(|| 1_700_000_000); let id = s.open("order.pdf", None, Arc::new(scripted_form_with(helpers)), None).unwrap(); - let _ = s.take_js_output(id); + let initial = s.take_js_output(id); + assert_eq!(initial.doc_errors.len(), 1, "the initializer error is reported at open"); s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("4".into()) }).unwrap(); let total = s.get(id).unwrap().form.iter().find(|f| f.name == "total").unwrap().value.clone(); assert_eq!(total, ["10"], "calculated through the helper the failing script defined"); let out = s.take_js_output(id); - assert_eq!(out.errors.len(), 1, "reported once, not once per script: {out:?}"); - assert!(out.errors[0].starts_with("document script 1: TypeError"), "{out:?}"); - // The console runs too, and says which document script failed. + assert!(out.errors.is_empty(), "the initializer is not replayed: {out:?}"); + // The console runs too, without replaying the failed initializer. let o = s.run_javascript(id, "times(2, 3)", None).unwrap(); - assert_eq!((o.error, o.result.as_deref(), o.doc_errors.len()), (None, Some("6"), 1)); + assert_eq!((o.error, o.result.as_deref(), o.doc_errors.len()), (None, Some("6"), 0)); +} + +/// A looping initializer is reported once and later initializers and field events still run. +#[test] +fn a_document_script_at_the_vm_limit_does_not_block_field_edits() { + let mut cos = pdfcraft_cos::Document::open(Arc::new(scripted_form_with(r"while \(true\) {}"))).unwrap(); + pdfcraft_forms::set_document_script(&mut cos, "later", Some("function times(a, b) { return a * b; }")).unwrap(); + let bytes = Arc::new(pdfcraft_cos::write_full(&cos, &Default::default()).unwrap()); + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("order.pdf", None, bytes, None).unwrap(); + let initial = s.take_js_output(id); + assert_eq!(initial.doc_errors.len(), 1, "{initial:?}"); + assert!(initial.doc_errors[0].contains("RuntimeLimitError"), "{initial:?}"); + + for (qty, total) in [("4", "10"), ("6", "15")] { + s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text(qty.into()) }).unwrap(); + assert_eq!(s.get(id).unwrap().form.iter().find(|f| f.name == "total").unwrap().value, [total]); + let out = s.take_js_output(id); + assert!(out.errors.is_empty() && out.doc_errors.is_empty(), "{out:?}"); + } } #[test] @@ -7094,14 +7100,13 @@ fn inactive_xfa_and_ordinary_pdfs_keep_document_helpers_and_alerts() { } #[test] +/// Read inherited page boxes without dirtying the document and reject invalid box/page requests. fn js_get_page_box_reads_effective_boxes_without_editing_the_document() { let (mut s, id) = session_with(1); let before = s.get(id).expect("opened document").can_undo().map(str::to_owned); - let out = s - .run_javascript(id, "JSON.stringify([this.getPageBox(), this.getPageBox('Media', 0), this.getPageBox('Trim', 0)])", None) - .expect("script runs"); + let out = s.run_javascript(id, "JSON.stringify([this.getPageBox(), this.getPageBox('Media', 0)])", None).expect("script runs"); assert_eq!(out.error, None, "{out:?}"); - assert_eq!(out.result.as_deref(), Some("[[0,300,200,0],[0,300,200,0],[0,300,200,0]]")); + assert_eq!(out.result.as_deref(), Some("[[0,300,200,0],[0,300,200,0]]")); assert_eq!(s.get(id).expect("document remains open").can_undo(), before.as_deref()); s.apply(id, Edit::RotatePages { pages: vec![0], degrees: 90 }).expect("rotate page"); @@ -7127,6 +7132,7 @@ fn automatic_field_scripts_cannot_print() { target, "2", &fields, + &cos, ); assert!(result.rc, "{event:?}: {result:?}"); assert_eq!(result.value, "4"); @@ -7138,7 +7144,7 @@ fn automatic_field_scripts_cannot_print() { assert!(runner.output.errors.is_empty()); runner.output = js::JsOutput::default(); - runner.run(event, "this.print(); throw new Error('broken script');", target, "2", &fields); + runner.run(event, "this.print(); throw new Error('broken script');", target, "2", &fields, &cos); assert!(runner.output.requests.is_empty(), "{event:?}: {:?}", runner.output); assert!(runner.output.errors.iter().any(|e| e.contains("broken script"))); } @@ -7153,7 +7159,7 @@ fn automatic_print_suppression_preserves_document_errors() { let target = fields.iter().find(|f| f.name == "qty").unwrap(); let mut runner = js::JsRunner::new(&cos, "order.pdf", false); for event in [FieldEvent::Keystroke, FieldEvent::Validate, FieldEvent::Calculate, FieldEvent::Format] { - runner.run(event, "this.print(); throw new Error('field failure');", target, "2", &fields); + runner.run(event, "this.print(); throw new Error('field failure');", target, "2", &fields, &cos); } let out = runner.output; assert!(out.requests.is_empty(), "{out:?}"); @@ -9523,6 +9529,26 @@ fn hostile_xfa_scripts_cannot_hang_or_bloat_the_document() { assert!(started.elapsed() < std::time::Duration::from_secs(60), "{:?}", started.elapsed()); } +#[test] +fn xfa_focus_follows_the_surviving_row_after_removal() { + let tpl = pdfcraft_xfa::fixtures::scripted_template().replace( + r#"if (table._row.count > 1) { table._row.removeInstance(table._row.count - 1); }"#, + r#"xfa.host.setFocus("table.row[0].amount"); xfa.host.setFocus("table.row[1].amount"); table._row.removeInstance(0);"#, + ); + let mut s = Session::new(); + let id = s.open("focus-row.pdf", None, Arc::new(pdfcraft_xfa::fixtures::shell(&tpl)), None).unwrap(); + for _ in 0..2 { + s.run_javascript(id, "", Some("addRow")).unwrap(); + } + let out = s.run_javascript(id, "", Some("removeRow")).unwrap(); + let doc = s.get(id).unwrap(); + assert_eq!(doc.form.iter().filter(|f| f.name.starts_with("amount")).count(), 2); + let cos = &doc.editor.as_ref().unwrap().cos; + let by_som = pdfcraft_xfa::field_objects_by_som(cos); + let (name, object) = &by_som["form[0].page1[0].table[0].row[0].amount[0]"]; + assert_eq!(out.requests, [pdfcraft_js::Request::FocusField { name: name.clone(), object_id: crate::js::field_object_id(*object) }], "{out:?}"); +} + #[test] fn xfa_scripts_can_remove_rows_twice_and_reset_fields_by_name() { let tpl = pdfcraft_xfa::fixtures::scripted_template() @@ -11732,39 +11758,51 @@ fn dynamic_calculation_reads_and_side_effects_reach_later_fields() { assert!(s.take_js_output(id).errors.is_empty()); } -/// Within one edit, a field's `display` reads as the file had it before the edit, even after a -/// script set it with the value cached; the next edit's runner reads what the script wrote. #[test] -fn scripted_display_changes_survive_warm_cache_and_new_edit() { +/// Later field callbacks see current COS appearance/flags rather than the runner creation snapshot. +fn field_scripts_read_current_appearance_during_an_edit() { use pdfcraft_forms::{FieldEvent, Scripts}; let mut cos = pdfcraft_cos::Document::open(Arc::new(synthetic_acroform(2, 0))).unwrap(); for (display, flags) in [(1, 6), (3, 36), (2, 0), (0, 4)] { let fields = pdfcraft_forms::fields(&cos); - let target = fields.iter().find(|f| f.name == "ranks").unwrap(); - let other = fields.iter().find(|f| f.name == "trained").unwrap(); - let old_display = crate::js::field_state(&cos, other).display; + let target = fields.iter().find(|f| f.name == "trained").unwrap(); + let other = fields.iter().find(|f| f.name == "ranks").unwrap(); let mut runner = crate::js::JsRunner::new(&cos, "synthetic.pdf", false); - // Populate the display cache before a script writes the same field. - let warm = runner.run(FieldEvent::Calculate, "event.value = getField('trained').display;", target, "", &fields); + let old_display = crate::js::field_state(&cos, other, &runner.doc).display; + // Populate script state before a script writes the same field. + let warm = runner.run(FieldEvent::Calculate, "event.value = getField('ranks').display;", target, "", &fields, &cos); assert_eq!(warm.value, old_display.to_string()); let result = runner.run( FieldEvent::Calculate, - &format!("getField('trained').display = {display}; event.value = getField('trained').display;"), + &format!("getField('ranks').display = {display}; event.value = getField('ranks').display;"), target, "", &fields, + &cos, ); assert_eq!(result.value, display.to_string()); assert_eq!(result.changes.len(), 1); assert_eq!(result.changes.first().unwrap().display, Some(display)); pdfcraft_forms::apply_script_changes(&mut cos, &result.changes, &mut pdfcraft_forms::NoScripts).unwrap(); assert_eq!(cos.get(other.widgets.first().unwrap().obj).as_dict().unwrap().int(b"F").unwrap() & 38, flags); - // Within an edit the original implementation reads an immutable pre-edit COS snapshot. - let same_edit = runner.run(FieldEvent::Calculate, "event.value = getField('trained').display;", target, "", &fields); - assert_eq!(same_edit.value, old_display.to_string()); - // The next edit must rebuild its cache from the newly written annotation flags. + pdfcraft_forms::apply_script_changes( + &mut cos, + &[pdfcraft_forms::FieldChange { + name: "ranks".into(), + fill_color: Some(vec!["RGB".into(), "0".into(), "1".into(), "0".into()]), + ..Default::default() + }], + &mut pdfcraft_forms::NoScripts, + ) + .unwrap(); + // A later script in this edit sees the document after the earlier appearance change. + let same_edit = runner.run(FieldEvent::Calculate, "event.value = getField('ranks').display;", target, "", &fields, &cos); + assert_eq!(same_edit.value, display.to_string()); + let same_edit_color = runner.run(FieldEvent::Calculate, "event.value = getField('ranks').fillColor.join(',');", target, "", &fields, &cos); + assert_eq!(same_edit_color.value, "RGB,0,1,0"); + // A new runner sees the same current appearance. let mut next = crate::js::JsRunner::new(&cos, "synthetic.pdf", false); - let read = next.run(FieldEvent::Calculate, "event.value = getField('trained').display;", target, "", &pdfcraft_forms::fields(&cos)); + let read = next.run(FieldEvent::Calculate, "event.value = getField('ranks').display;", target, "", &pdfcraft_forms::fields(&cos), &cos); assert_eq!(read.value, display.to_string()); assert!(read.changes.is_empty()); assert!(runner.output.errors.is_empty()); @@ -11925,9 +11963,9 @@ fn an_edits_xfa_and_field_scripts_share_the_field_highlight() { let mut cx = EditCtx::new(None, 0); cx.js = Some(js::JsRunner::new(&cos, "synthetic.pdf", false)); cx.set_highlight(true); - assert!(cx.js.as_ref().is_some_and(|js| js.doc.highlight), "an XFA script's change reaches the field scripts"); + assert!(cx.js.as_ref().is_some_and(|js| js.doc.runtime_highlight), "an XFA script's change reaches the field scripts"); if let Some(js) = cx.js.as_mut() { - js.doc.highlight = false; + js.doc.runtime_highlight = false; } assert!(!cx.highlight(), "the next XFA script reads the field scripts' change"); } @@ -12221,6 +12259,36 @@ fn a_reset_clears_a_floating_field_in_every_row() { /// An XFA button's click script may show the attachments panel; a text field's exit script /// asking the same is noted on the console and ignored. +#[test] +fn xfa_focus_targets_the_widget_after_relayout() { + let xdp = r##" + + + +"##; + let mut s = Session::new(); + let id = s.open("focus.pdf", None, Arc::new(pdfcraft_xfa::fixtures::shell(xdp)), None).unwrap(); + let note = |s: &Session| s.get(id).unwrap().form.iter().find(|f| f.name == "note").unwrap().obj; + let before = note(&s); + s.take_js_output(id); + s.apply(id, Edit::XfaEvent { som: "form[0].go[0]".into(), activity: "click".into() }).unwrap(); + let after = note(&s); + assert_ne!(before, after); + let out = s.take_js_output(id); + assert_eq!(out.requests, [pdfcraft_js::Request::FocusField { name: "note".into(), object_id: crate::js::field_object_id(after) }], "{out:?}"); +} + #[test] fn an_xfa_button_may_show_a_side_panel_and_other_events_may_not() { let xdp = r##" @@ -13821,6 +13889,803 @@ fn fill_sign_multiline_text_survives_engine_save_reopen_and_render() { assert_eq!(before.rgba, render_page(&reopened, again).rgba, "the saved appearance is identical"); } +/// An intervening edit gets a fresh realm before calculation, even after the worker has completed. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn pending_button_globals_cannot_leak_into_intervening_edits() { + use crate::js::InteractiveEvent; + for valid in [true, false] { + let mut s = Session::new(); + let id = s.open("pending.pdf", None, Arc::new(scripted_form()), None).unwrap(); + let value = |s: &Session, name: &str| s.get(id).unwrap().form.iter().find(|f| f.name == name).unwrap().value.clone(); + s.apply(id, Edit::SetDocumentScript { name: "lib".into(), script: Some("var retained='clean';".into()) }).unwrap(); + s.apply(id, Edit::SetFieldScript { name: "total".into(), event: "calculate".into(), script: Some("event.value=retained;".into()) }).unwrap(); + let pending = s.start_javascript(id, "retained='stale';getField('price').value=99;", Some("go")).unwrap(); + let InteractiveEvent::Complete(out) = pending.receiver.recv_timeout(std::time::Duration::from_secs(2)).unwrap() else { panic!("completion") }; + let edited = s.apply(id, Edit::SetFieldValue { name: if valid { "qty" } else { "missing" }.into(), value: FieldValue::Text("3".into()) }); + assert_eq!(edited.is_ok(), valid); + assert_eq!(value(&s, "total"), ["clean"]); + assert_eq!(s.run_javascript(id, "retained='fresh'", None).unwrap().error, None); + assert!(s.finish_javascript(pending, out).is_err()); + assert_eq!(s.run_javascript(id, "retained", None).unwrap().result.as_deref(), Some("fresh")); + assert_ne!(value(&s, "price"), ["99"]); + } +} + +/// Shared synthetic reset fixture; scripts are disabled while the document and actions are installed. +fn reset_order_session(reset: &str) -> (Session, DocId) { + let mut cos = pdfcraft_cos::Document::open(Arc::new(scripted_form())).unwrap(); + let source = pdfcraft_forms::fields(&cos).into_iter().find(|f| f.name == "price").unwrap(); + cos.update_dict(source.obj, |d| { + d.set(b"DV".to_vec(), pdfcraft_cos::PdfString::text("7")); + d.set(b"V".to_vec(), pdfcraft_cos::PdfString::text("99")); + }) + .unwrap(); + let bytes = Arc::new(pdfcraft_cos::write_full(&cos, &Default::default()).unwrap()); + let mut s = Session::new(); + s.set_javascript(false); + let id = s.open("reset-order.pdf", None, bytes, None).unwrap(); + s.apply( + id, + Edit::SetDocumentScript { name: "reset".into(), script: Some(format!("var held=getField('price');{reset}var openedValue=held.value;")) }, + ) + .unwrap(); + (s, id) +} + +#[test] +fn an_unavailable_validation_runtime_rejects_the_field_edit() { + let mut s = Session::new(); + let script = " ".repeat((1 << 20) + 1); + let id = s.open("oversized-script-library.pdf", None, Arc::new(scripted_form_with(&script)), None).unwrap(); + let result = s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: pdfcraft_forms::FieldValue::Text("101".into()) }); + assert!(matches!(result, Err(EditError::Invalid(reason)) if reason.contains("document scripts exceed"))); + assert!(s.get(id).unwrap().can_undo().is_none()); +} + +#[test] +fn opening_reset_recalculates_custom_dependents() { + let (mut s, id) = reset_order_session("resetForm(['price']);"); + s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: pdfcraft_forms::FieldValue::Text("2".into()) }).unwrap(); + let bytes = s.save_bytes(id).unwrap(); + let mut s = Session::new(); + let id = s.open("opening-calculation.pdf", None, bytes, None).unwrap(); + let doc = s.get(id).unwrap(); + assert_eq!(doc.form.iter().find(|f| f.name == "total").unwrap().value, ["14"]); +} + +#[test] +fn batch_reset_recalculates_before_later_validation() { + let (mut s, id) = reset_order_session(""); + for (name, event, script) in [ + ("total", "calculate", "event.value = Number(getField('price').value);"), + ("qty", "validate", "event.rc = Number(getField('total').value) === 7;"), + ] { + s.apply(id, Edit::SetFieldScript { name: name.into(), event: event.into(), script: Some(script.into()) }).unwrap(); + } + s.set_javascript(true); + s.run_javascript(id, "calculateNow();", None).unwrap(); + let value = |s: &Session, name: &str| s.get(id).unwrap().form.iter().find(|f| f.name == name).unwrap().value.clone(); + assert_eq!(value(&s, "total"), ["99"]); + s.apply( + id, + Edit::Batch { + label: "Reset and edit".into(), + edits: vec![ + Edit::ResetForm { names: Some(vec!["price".into()]) }, + Edit::SetFieldValue { name: "qty".into(), value: pdfcraft_forms::FieldValue::Text("10".into()) }, + ], + }, + ) + .unwrap(); + assert_eq!(value(&s, "qty"), ["10"]); + assert_eq!(value(&s, "total"), ["7"]); +} + +#[test] +fn reset_draws_the_formatted_default() { + let (mut s, id) = reset_order_session(""); + s.apply( + id, + Edit::SetFieldProps { name: "qty".into(), props: Box::new(FieldProps { default_value: Some(Some("3".into())), ..Default::default() }) }, + ) + .unwrap(); + s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("9".into()) }).unwrap(); + for (name, event, script) in [ + ("price", "format", "event.value = 'USD ' + Number(event.value).toFixed(2) + ' ' + getField('qty').value + '/' + getField('total').value;"), + ("price", "validate", "var resetValidated = true; event.rc = false;"), + ("price", "keystroke", "var resetKeystroked = true;"), + ("total", "calculate", "event.value = Number(getField('price').value) * Number(getField('qty').value);"), + ] { + s.apply(id, Edit::SetFieldScript { name: name.into(), event: event.into(), script: Some(script.into()) }).unwrap(); + } + s.set_javascript(true); + s.apply(id, Edit::ResetForm { names: Some(vec!["price".into(), "qty".into()]) }).unwrap(); + let form = &s.get(id).unwrap().form; + for (name, value) in [("price", "7"), ("qty", "3"), ("total", "21")] { + assert_eq!(form.iter().find(|f| f.name == name).unwrap().value, [value]); + } + assert!(page_texts(&s, id)[0].contains("USD 7.00 3/21"), "{:?}", page_texts(&s, id)); + let scripts = s.run_javascript(id, "typeof resetValidated + '/' + typeof resetKeystroked;", None).unwrap(); + assert_eq!(scripts.result.as_deref(), Some("undefined/undefined")); +} + +/// VM limits reject earlier writes in the same script and clear its uncommitted globals. +#[test] +fn vm_limit_refuses_partial_script_writes() { + let (mut s, id) = reset_order_session(""); + s.set_javascript(true); + let before = s.save_bytes(id).unwrap(); + let script = "var rejected = 42; getField('price').value = 1; function recurse(){ return recurse(); } recurse();"; + let out = s.run_javascript(id, script, None).unwrap(); + assert!(out.execution_refused, "{out:?}"); + assert!(out.changed.is_empty() && out.requests.is_empty()); + assert_eq!(s.save_bytes(id).unwrap(), before); + assert_eq!(s.run_javascript(id, "typeof rejected;", None).unwrap().result.as_deref(), Some("undefined")); +} + +/// A script refused before it runs fails on its own, as on `main`: the edit goes through, the +/// error is reported, and the committed realm is untouched. +#[test] +fn preflight_refusal_preserves_committed_callback_state() { + let (mut s, id) = reset_order_session(""); + let nested = format!("{}1{}", "(".repeat(1_000), ")".repeat(1_000)); + s.apply(id, Edit::SetFieldScript { name: "qty".into(), event: "validate".into(), script: Some(nested) }).unwrap(); + s.set_javascript(true); + s.run_javascript(id, "var committed = 42;", None).unwrap(); + let _ = s.take_js_output(id); + let edit = Edit::SetFieldValue { name: "qty".into(), value: pdfcraft_forms::FieldValue::Text("10".into()) }; + s.apply(id, edit).unwrap(); + assert!(s.take_js_output(id).errors.iter().any(|e| e.contains("nested too deeply"))); + assert_eq!(s.run_javascript(id, "committed;", None).unwrap().result.as_deref(), Some("42")); +} + +/// Validation can reject an edit without hitting a runtime limit. +#[test] +fn validation_rejection_discards_callback_side_effects() { + let (mut s, id) = reset_order_session(""); + let script = "var rejectedValue = event.value; event.rc = false;"; + s.apply(id, Edit::SetFieldScript { name: "qty".into(), event: "validate".into(), script: Some(script.into()) }).unwrap(); + s.set_javascript(true); + let before = s.save_bytes(id).unwrap(); + let edit = Edit::SetFieldValue { name: "qty".into(), value: pdfcraft_forms::FieldValue::Text("10".into()) }; + assert!(s.apply(id, edit).is_err()); + assert_eq!(s.save_bytes(id).unwrap(), before); + assert_eq!(s.run_javascript(id, "typeof rejectedValue;", None).unwrap().result.as_deref(), Some("undefined")); +} + +/// Replacing a document realm while a prompt is outstanding cannot spawn a second worker. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn invalidated_prompt_retains_the_documents_worker_until_completion() { + use crate::js::InteractiveEvent; + let (mut s, id) = reset_order_session(""); + s.set_javascript(true); + let pending = s.start_javascript(id, "var discarded = app.response('wait');", None).unwrap(); + let InteractiveEvent::Prompt(_, reply) = pending.receiver.recv_timeout(std::time::Duration::from_secs(2)).unwrap() else { + panic!("expected prompt") + }; + let same_file = s.get(id).unwrap().bytes.clone(); + assert!(matches!(s.reload(id, same_file), Err(OpenError::Invalid(reason)) if reason.contains("a script is still running"))); + for _ in 0..3 { + s.invalidate_javascript(id); + assert!(matches!(s.start_javascript(id, "1;", None), Err(EditError::Invalid(reason)) if reason.contains("another script is active"))); + } + reply.send(Some("discarded".into())).unwrap(); + let InteractiveEvent::Complete(out) = pending.receiver.recv_timeout(std::time::Duration::from_secs(2)).unwrap() else { + panic!("expected completion") + }; + assert!(s.finish_javascript(pending, out).is_err()); + assert_eq!(s.run_javascript(id, "typeof discarded;", None).unwrap().result.as_deref(), Some("undefined")); +} + +#[test] +fn a_budget_refusal_discards_pdf_edits_and_callback_globals() { + let (mut s, id) = reset_order_session(""); + for (event, script) in [("keystroke", "var fromRejectedEdit = 99;"), ("validate", "for (var i = 0; i < 65; i++) resetForm();")] { + s.apply(id, Edit::SetFieldScript { name: "qty".into(), event: event.into(), script: Some(script.into()) }).unwrap(); + } + s.set_javascript(true); + let before = s.save_bytes(id).unwrap(); + let edit = Edit::SetFieldValue { name: "qty".into(), value: pdfcraft_forms::FieldValue::Text("10".into()) }; + assert!(matches!(s.apply(id, edit), Err(EditError::Invalid(_)))); + assert_eq!(s.save_bytes(id).unwrap(), before); + assert_eq!(s.run_javascript(id, "typeof fromRejectedEdit;", None).unwrap().result.as_deref(), Some("undefined")); +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn timeout_after_undo_releases_the_stale_script_runtime() { + let (mut s, id) = reset_order_session(""); + s.set_javascript(true); + let pending = s.start_javascript(id, "1+1", None).unwrap(); + assert!(matches!(pending.receiver.recv_timeout(std::time::Duration::from_secs(2)).unwrap(), crate::js::InteractiveEvent::Complete(_))); + s.undo(id).unwrap(); + s.timeout_javascript(pending); + assert_eq!(s.run_javascript(id, "2+2", None).unwrap().result.as_deref(), Some("4")); +} + +#[test] +fn a_no_op_opening_reset_leaves_the_document_clean() { + let (mut s, id) = reset_order_session("resetForm(['price']);"); + s.apply(id, Edit::SetFieldValue { name: "price".into(), value: pdfcraft_forms::FieldValue::Text("7".into()) }).unwrap(); + let bytes = s.save_bytes(id).unwrap(); + let mut s = Session::new(); + let id = s.open("already-reset.pdf", None, bytes, None).unwrap(); + assert!(s.get(id).unwrap().can_undo().is_none()); + assert_eq!(s.run_javascript(id, "openedValue;", None).unwrap().result.as_deref(), Some("7")); +} + +/// Reinitialization resets before the first event reads, while later writes survive save and Undo/Redo. +#[test] +fn doc_open_reset_and_first_event_persist_in_script_order() { + for reset in ["resetForm();", "resetForm(['price']);"] { + for (target, restore) in [(None, false), (None, true), (Some("go"), false), (Some("go"), true)] { + let (mut s, id) = reset_order_session(reset); + let value = |s: &Session, id, name: &str| s.get(id).unwrap().form.iter().find(|f| f.name == name).unwrap().value.clone(); + let before = [value(&s, id, "price"), value(&s, id, "qty")]; + s.set_javascript(true); + let script = + format!("getField('qty').value=held.value;{}openedValue+'/'+getField('qty').value", if restore { "held.value='99';" } else { "" }); + let out = s.run_javascript(id, &script, target).unwrap(); + let expected = if restore { "99" } else { "7" }; + assert_eq!(out.error, None); + assert_eq!(out.result.as_deref(), Some("7/7")); + assert_eq!(value(&s, id, "price"), [expected]); + assert_eq!(value(&s, id, "qty"), ["7"]); + s.undo(id).unwrap(); + assert_eq!([value(&s, id, "price"), value(&s, id, "qty")], before); + s.redo(id).unwrap(); + assert_eq!(value(&s, id, "price"), [expected]); + assert_eq!(value(&s, id, "qty"), ["7"]); + let saved = s.save_bytes(id).unwrap(); + s.set_javascript(false); + let reopened = s.open("saved-reset.pdf", None, saved, None).unwrap(); + assert_eq!(value(&s, reopened, "price"), [expected]); + assert_eq!(value(&s, reopened, "qty"), ["7"]); + } + } +} + +/// Reset comparison includes unnamed built-in dependents and never runs custom calculations in the preview. +#[test] +fn reset_baseline_includes_builtin_dependents_without_custom_preview_runs() { + use form_scripts::{CalcOp, Calculate}; + for combo in [false, true] { + let mut cos = pdfcraft_cos::Document::open(Arc::new(scripted_form())).unwrap(); + for f in pdfcraft_forms::fields(&cos) { + match f.name.as_str() { + "price" => cos + .update_dict(f.obj, |d| { + d.set(b"V".to_vec(), pdfcraft_cos::PdfString::text("99")); + d.set(b"DV".to_vec(), pdfcraft_cos::PdfString::text("7")); + }) + .unwrap(), + "qty" => cos.update_dict(f.obj, |d| d.set(b"V".to_vec(), pdfcraft_cos::PdfString::text("2"))).unwrap(), + "total" if combo => cos + .update_dict(f.obj, |d| { + d.set(b"FT".to_vec(), pdfcraft_cos::Object::Name(b"Ch".to_vec())); + d.set(b"Ff".to_vec(), pdfcraft_cos::Object::Int((1 << 17) | (1 << 18))); + d.set( + b"Opt".to_vec(), + pdfcraft_cos::Object::Array(vec![ + pdfcraft_cos::PdfString::text("14").into(), + pdfcraft_cos::PdfString::text("198").into(), + ]), + ); + }) + .unwrap(), + _ => (), + } + } + let mut s = Session::new(); + s.set_javascript(false); + let id = s.open("reset-dependent.pdf", None, Arc::new(pdfcraft_cos::write_full(&cos, &Default::default()).unwrap()), None).unwrap(); + s.apply(id, Edit::SetDocumentScript { name: "counter".into(), script: Some("var calculationCalls=0;".into()) }).unwrap(); + s.apply(id, Edit::SetFieldScript { name: "qty".into(), event: "calculate".into(), script: Some("calculationCalls++;event.value=2;".into()) }) + .unwrap(); + s.apply( + id, + Edit::SetFieldProps { + name: "total".into(), + props: Box::new(FieldProps { + calculate: Some(Calculate::Simple { op: CalcOp::Product, fields: vec!["price".into(), "qty".into()] }), + ..Default::default() + }), + }, + ) + .unwrap(); + let value = |s: &Session, n: &str| s.get(id).unwrap().form.iter().find(|f| f.name == n).unwrap().value.clone(); + assert_eq!(value(&s, "total"), ["198"]); + s.set_javascript(true); + let out = s.run_javascript(id, "resetForm(['price']);getField('total').value=198;", None).unwrap(); + assert_eq!(out.error, None); + // The explicit dependent-field delta must be emitted even though it restores its original value. + // Its normal writeback recalculation runs the custom callback exactly once; preview/reset run none. + assert_eq!(s.run_javascript(id, "calculationCalls", None).unwrap().result.as_deref(), Some("1")); + assert_eq!(value(&s, "price"), ["7"]); + assert_eq!(value(&s, "total"), ["14"], "existing automatic calculation after a value write is preserved"); + } +} + +/// Native completions preserve reset/default/restore ordering and persist the resulting values. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn native_reset_completion_preserves_values_and_atomic_undo() { + use crate::js::InteractiveEvent; + for repetitions in [1, 64] { + for restore in [false, true] { + let (mut s, id) = reset_order_session(&"resetForm(['price']);".repeat(repetitions)); + s.set_javascript(true); + let script = + format!("getField('qty').value=held.value;{}openedValue+'/'+getField('qty').value", if restore { "held.value='99';" } else { "" }); + let pending = s.start_javascript(id, &script, Some("go")).unwrap(); + let InteractiveEvent::Complete(out) = pending.receiver.recv_timeout(std::time::Duration::from_secs(3)).unwrap() else { + panic!("completion") + }; + let out = s.finish_javascript(pending, out).unwrap(); + assert_eq!(out.error, None); + assert_eq!(out.result.as_deref(), Some("7/7")); + let value = |s: &Session, id, n: &str| s.get(id).unwrap().form.iter().find(|f| f.name == n).unwrap().value.clone(); + let expected = if restore { "99" } else { "7" }; + assert_eq!(value(&s, id, "price"), [expected]); + assert_eq!(value(&s, id, "qty"), ["7"]); + assert_eq!(s.get(id).unwrap().can_undo(), Some("Run JavaScript")); + s.undo(id).unwrap(); + assert_eq!(value(&s, id, "price"), ["99"]); + s.redo(id).unwrap(); + assert_eq!(value(&s, id, "price"), [expected]); + let bytes = s.save_bytes(id).unwrap(); + s.set_javascript(false); + let reopened = s.open("native-reset-saved.pdf", None, bytes, None).unwrap(); + assert_eq!(value(&s, reopened, "price"), [expected]); + assert_eq!(value(&s, reopened, "qty"), ["7"]); + } + } +} + +/// Over-budget native results are rejected before resetting or committing any PDF edits. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn native_reset_flood_is_rejected_without_changing_the_document() { + use crate::js::InteractiveEvent; + for (repetitions, caught) in [(65, false), (10_000, false), (10_000, true)] { + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("reset-flood.pdf", None, Arc::new(scripted_form()), None).unwrap(); + let before = s.save_bytes(id).unwrap(); + let generation = s.get(id).unwrap().generation; + let reset = if caught { "try{resetForm(['price']);}catch(e){}" } else { "resetForm(['price']);" }; + let script = format!("for(var i=0;i<{repetitions};i++){{{reset}}}getField('qty').value='uncommitted';"); + let pending = s.start_javascript(id, &script, Some("go")).unwrap(); + let InteractiveEvent::Complete(out) = pending.receiver.recv_timeout(std::time::Duration::from_secs(3)).unwrap() else { panic!("completion") }; + assert!(out.reset_limit_exceeded); + assert_eq!(out.error.is_none(), caught); + assert_eq!(out.requests.iter().filter(|r| matches!(r, crate::js::Request::Reset(_))).count(), pdfcraft_js::MAX_RESET_CALLS); + let error = s.finish_javascript(pending, out).expect_err("the reset flood must be rejected before applying it"); + assert!(error.to_string().contains("reset"), "{error}"); + assert_eq!(s.get(id).unwrap().generation, generation); + assert_eq!(s.get(id).unwrap().can_undo(), None); + assert_eq!(s.save_bytes(id).unwrap(), before); + assert!(s.run_javascript(id, "1+1", None).unwrap().error.is_none()); + } +} + +/// The aggregate budget also rejects expensive batches below the reset-count cap. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn native_reset_aggregate_work_is_bounded() { + use crate::js::InteractiveEvent; + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("large-reset.pdf", None, Arc::new(synthetic_acroform(1200, 500)), None).unwrap(); + let before = s.save_bytes(id).unwrap(); + let generation = s.get(id).unwrap().generation; + let pending = s.start_javascript(id, "for(var i=0;i<32;i++)resetForm();", None).unwrap(); + let InteractiveEvent::Complete(out) = pending.receiver.recv_timeout(std::time::Duration::from_secs(3)).unwrap() else { panic!("completion") }; + assert_eq!(out.error, None); + let error = s.finish_javascript(pending, out).expect_err("aggregate reset work must be refused"); + assert!(error.to_string().contains("reset work limit"), "{error}"); + assert_eq!(s.get(id).unwrap().generation, generation); + assert_eq!(s.get(id).unwrap().can_undo(), None); + assert_eq!(s.save_bytes(id).unwrap(), before); +} + +/// A huge selector list must be rejected before copying requests or expanding names against fields. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn native_reset_selector_count_is_bounded() { + use crate::js::InteractiveEvent; + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("selectors.pdf", None, Arc::new(scripted_form()), None).unwrap(); + let before = s.save_bytes(id).unwrap(); + let generation = s.get(id).unwrap().generation; + let pending = s.start_javascript(id, "resetForm(new Array(4097).fill('price'));", Some("go")).unwrap(); + let InteractiveEvent::Complete(out) = pending.receiver.recv_timeout(std::time::Duration::from_secs(3)).unwrap() else { panic!("completion") }; + assert_eq!(out.error, None); + assert!(s.finish_javascript(pending, out).unwrap_err().to_string().contains("reset work limit")); + assert_eq!(s.get(id).unwrap().generation, generation); + assert_eq!(s.save_bytes(id).unwrap(), before); +} + +/// An unread default is preserved by both reset paths; explicit script assignments still win. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn javascript_reset_preserves_unknown_default_like_native_reset() { + for inherited in [false, true] { + for native in [false, true] { + for reset in ["resetForm(['price']);", "resetForm();"] { + for (before, after, expected) in + [("", "", "99"), ("getField('price').value=42;", "", "42"), ("", "getField('price').value=43;", "43")] + { + let mut cos = pdfcraft_cos::Document::open(Arc::new(scripted_form())).unwrap(); + let price = pdfcraft_forms::fields(&cos).into_iter().find(|f| f.name == "price").unwrap(); + let mut dict = pdfcraft_cos::Dict::new(); + dict.set(b"Filter".to_vec(), pdfcraft_cos::Object::name("UnknownReviewFilter")); + let dv = cos.add(pdfcraft_cos::Object::Stream(pdfcraft_cos::Stream::from_raw(dict, vec![1]))); + let parent = if inherited { + let mut parent = pdfcraft_cos::Dict::new(); + parent.set(b"DV".to_vec(), pdfcraft_cos::Object::Ref(dv)); + parent.set(b"Kids".to_vec(), pdfcraft_cos::Object::Array(vec![pdfcraft_cos::Object::Ref(price.obj)])); + Some(cos.add(pdfcraft_cos::Object::Dict(parent))) + } else { + None + }; + cos.update_dict(price.obj, |d| { + d.set(b"V".to_vec(), pdfcraft_cos::PdfString::text("99")); + if let Some(parent) = parent { + d.set(b"Parent".to_vec(), pdfcraft_cos::Object::Ref(parent)); + } else { + d.set(b"DV".to_vec(), pdfcraft_cos::Object::Ref(dv)); + } + }) + .unwrap(); + if let Some(parent) = parent { + let root = cos.root().unwrap(); + cos.update_dict(root, |catalog| { + let mut form = catalog.get(b"AcroForm").unwrap().as_dict().unwrap().clone(); + let mut refs = form.get(b"Fields").unwrap().as_array().unwrap().clone(); + for reference in &mut refs { + if reference.as_ref() == Some(price.obj) { + *reference = pdfcraft_cos::Object::Ref(parent); + } + } + form.set(b"Fields".to_vec(), pdfcraft_cos::Object::Array(refs)); + catalog.set(b"AcroForm".to_vec(), pdfcraft_cos::Object::Dict(form)); + }) + .unwrap(); + } + let mut direct = cos.clone(); + pdfcraft_forms::reset(&mut direct, Some(&["price".into()])).unwrap(); + assert_eq!(pdfcraft_forms::fields(&direct).into_iter().find(|f| f.name == "price").unwrap().value, ["99"]); + let mut s = Session::new().with_clock(|| 1_700_000_000); + s.set_javascript(false); + let id = + s.open("unknown-default.pdf", None, Arc::new(pdfcraft_cos::write_full(&cos, &Default::default()).unwrap()), None).unwrap(); + s.set_javascript(true); + let script = format!("{before}{reset}{after}String(getField('price').value);"); + let out = if native { + let pending = s.start_javascript(id, &script, Some("go")).unwrap(); + let crate::js::InteractiveEvent::Complete(out) = pending.receiver.recv_timeout(std::time::Duration::from_secs(3)).unwrap() + else { + panic!("completion") + }; + s.finish_javascript(pending, out).unwrap() + } else { + s.run_javascript(id, &script, None).unwrap() + }; + assert_eq!(out.error, None); + assert_eq!(out.result.as_deref(), Some(expected)); + let value = |s: &Session, id| s.get(id).unwrap().form.iter().find(|f| f.name == "price").unwrap().value.clone(); + assert_eq!(value(&s, id), [expected]); + assert_eq!(s.get(id).unwrap().can_undo(), Some("Run JavaScript")); + s.undo(id).unwrap(); + assert_eq!(value(&s, id), ["99"]); + s.redo(id).unwrap(); + assert_eq!(value(&s, id), [expected]); + let saved = s.save_bytes(id).unwrap(); + s.set_javascript(false); + let reopened = s.open("saved-unknown.pdf", None, saved, None).unwrap(); + assert_eq!(value(&s, reopened), [expected]); + } + } + } + } +} + +/// Exhausting the shared default decode budget must also reach the JavaScript snapshot as unknown. +#[test] +fn javascript_snapshot_preserves_default_decode_budget_exhaustion() { + let mut cos = pdfcraft_cos::Document::open(Arc::new(scripted_form())).unwrap(); + let stream = cos.add(pdfcraft_cos::Object::Stream(pdfcraft_cos::Stream::from_raw(pdfcraft_cos::Dict::new(), vec![b'd'; 1 << 20]))); + let mut refs = Vec::new(); + for i in 0..70 { + let mut field = pdfcraft_cos::Dict::new(); + field.set(b"FT".to_vec(), pdfcraft_cos::Object::name("Tx")); + field.set(b"T".to_vec(), pdfcraft_cos::PdfString::text(&format!("budget{i}"))); + field.set(b"V".to_vec(), pdfcraft_cos::PdfString::text("typed")); + field.set(b"DV".to_vec(), pdfcraft_cos::Object::Ref(stream)); + refs.push(pdfcraft_cos::Object::Ref(cos.add(pdfcraft_cos::Object::Dict(field)))); + } + let root = cos.root().unwrap(); + cos.update_dict(root, |catalog| { + let mut form = pdfcraft_cos::Dict::new(); + form.set(b"Fields".to_vec(), pdfcraft_cos::Object::Array(refs)); + catalog.set(b"AcroForm".to_vec(), pdfcraft_cos::Object::Dict(form)); + }) + .unwrap(); + let fields = pdfcraft_forms::fields(&cos); + assert_eq!(fields.first().unwrap().default.first().unwrap().len(), 1 << 20); + let last = fields.iter().find(|f| f.name == "budget69").unwrap(); + assert!(last.default.is_empty()); + let snapshot = crate::js::field_state(&cos, last, &pdfcraft_js::DocInfo::default()); + assert!(!snapshot.default_known); + let out = pdfcraft_js::run( + "resetForm();String(getField('budget69').value);", + &pdfcraft_js::Event::doc("Console"), + &Default::default(), + &[snapshot], + &[], + Default::default(), + ); + assert_eq!(out.error, None); + assert_eq!(out.result.as_deref(), Some("typed")); + assert!(out.changed.is_empty()); +} + +/// Immediate dependent reads can explicitly request calculation without changing reset callback ordering. +#[test] +fn calculate_now_after_reset_reads_and_persists_recalculated_dependents() { + use form_scripts::{CalcOp, Calculate}; + let (mut s, id) = reset_order_session(""); + s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("2".into()) }).unwrap(); + s.apply( + id, + Edit::SetFieldProps { + name: "total".into(), + props: Box::new(FieldProps { + calculate: Some(Calculate::Simple { op: CalcOp::Product, fields: vec!["price".into(), "qty".into()] }), + ..Default::default() + }), + }, + ) + .unwrap(); + s.set_javascript(true); + let out = s.run_javascript(id, "resetForm(['price']);calculateNow();String(getField('total').value);", None).unwrap(); + assert_eq!(out.error, None); + assert_eq!(out.result.as_deref(), Some("14")); + assert_eq!(s.get(id).unwrap().form.iter().find(|f| f.name == "total").unwrap().value, ["14"]); +} + +/// Duplicate names retain distinct Calculate scripts, targets and persisted values. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn calculate_now_preserves_duplicate_field_object_identity() { + for (native, reverse) in [(false, false), (false, true), (true, false), (true, true)] { + let mut cos = pdfcraft_cos::Document::open(Arc::new(scripted_form())).unwrap(); + let fields = pdfcraft_forms::fields(&cos); + let a = fields.iter().find(|f| f.name == "price").unwrap().obj; + let b = fields.iter().find(|f| f.name == "qty").unwrap().obj; + for (obj, tag, color) in [(a, "A", "color.red"), (b, "B", "color.blue")] { + cos.update_dict(obj, |d| { + d.set(b"T".to_vec(), pdfcraft_cos::PdfString::text("duplicate")); + d.set(b"V".to_vec(), pdfcraft_cos::PdfString::text(tag)); + let mut action = pdfcraft_cos::Dict::new(); + action.set(b"S".to_vec(), pdfcraft_cos::Object::name("JavaScript")); + action.set( + b"JS".to_vec(), + pdfcraft_cos::PdfString::text(&format!( + "trace+='{tag}'+event.target.value;event.target.userName='{tag}';event.target.fillColor={color};event.target.value='{tag}-temporary';event.value='{tag}-done';" + )), + ); + let mut aa = pdfcraft_cos::Dict::new(); + aa.set(b"C".to_vec(), pdfcraft_cos::Object::Dict(action)); + d.set(b"AA".to_vec(), pdfcraft_cos::Object::Dict(aa)); + }) + .unwrap(); + } + let root = cos.root().unwrap(); + cos.update_dict(root, |d| { + let mut af = d.get(b"AcroForm").unwrap().as_dict().unwrap().clone(); + af.set(b"CO".to_vec(), pdfcraft_cos::Object::Array(if reverse { vec![b.into(), a.into()] } else { vec![a.into(), b.into()] })); + d.set(b"AcroForm".to_vec(), pdfcraft_cos::Object::Dict(af)); + }) + .unwrap(); + // Disable the unrelated original total calculation. + let total = fields.iter().find(|f| f.name == "total").unwrap().obj; + cos.update_dict(total, |d| { + d.remove(b"AA"); + }) + .unwrap(); + let mut s = Session::new(); + s.set_javascript(false); + let id = s.open("duplicate-calculations.pdf", None, Arc::new(pdfcraft_cos::write_full(&cos, &Default::default()).unwrap()), None).unwrap(); + s.set_javascript(true); + let script = "var trace='';calculateNow();trace;"; + let out = if native { + let p = s.start_javascript(id, script, Some("go")).unwrap(); + let crate::js::InteractiveEvent::Complete(out) = p.receiver.recv_timeout(std::time::Duration::from_secs(3)).unwrap() else { + panic!("completion") + }; + s.finish_javascript(p, out).unwrap() + } else { + s.run_javascript(id, script, None).unwrap() + }; + assert_eq!(out.error, None); + let duplicate_fields: Vec<_> = s.get(id).unwrap().form.iter().filter(|f| f.name == "duplicate").collect(); + assert_eq!(duplicate_fields.iter().map(|f| f.tooltip.as_deref()).collect::>(), [Some("A"), Some("B")]); + let cos = &s.get(id).unwrap().editor.as_ref().unwrap().cos; + assert_eq!(pdfcraft_forms::look(cos, duplicate_fields[0]).fill, Some([1.0, 0.0, 0.0])); + assert_eq!(pdfcraft_forms::look(cos, duplicate_fields[1]).fill, Some([0.0, 0.0, 1.0])); + + assert_eq!(out.result.as_deref(), Some(if reverse { "BBAA" } else { "AABB" })); + let values = |s: &Session| s.get(id).unwrap().form.iter().filter(|f| f.name == "duplicate").map(|f| f.value.clone()).collect::>(); + assert_eq!(values(&s), vec![vec!["A-done"], vec!["B-done"]]); + s.undo(id).unwrap(); + assert_eq!(values(&s), vec![vec!["A"], vec!["B"]]); + s.redo(id).unwrap(); + assert_eq!(values(&s), vec![vec!["A-done"], vec!["B-done"]]); + let bytes = s.save_bytes(id).unwrap(); + s.set_javascript(false); + let reopened = s.open("saved-duplicates.pdf", None, bytes, None).unwrap(); + assert_eq!( + s.get(reopened).unwrap().form.iter().filter(|f| f.name == "duplicate").map(|f| f.value.clone()).collect::>(), + vec![vec!["A-done"], vec!["B-done"]] + ); + } +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn script_colors_do_not_mutate_another_fields_shared_mk() { + for native in [false, true] { + for (script, fill, border) in [ + ("getField('price').fillColor=color.red;", Some([1., 0., 0.]), Some([0., 0., 0.])), + ("getField('price').strokeColor=color.blue;", Some([1., 1., 1.]), Some([0., 0., 1.])), + ("getField('price').fillColor=color.red;getField('price').strokeColor=color.blue;", Some([1., 0., 0.]), Some([0., 0., 1.])), + ("getField('price').fillColor=color.transparent;", None, Some([0., 0., 0.])), + ] { + let mut cos = pdfcraft_cos::Document::open(Arc::new(scripted_form())).unwrap(); + let all = pdfcraft_forms::fields(&cos); + let price = all.iter().find(|f| f.name == "price").unwrap().obj; + let qty = all.iter().find(|f| f.name == "qty").unwrap().obj; + let total = all.iter().find(|f| f.name == "total").unwrap().obj; + let mut mk = pdfcraft_cos::Dict::new(); + mk.set(b"BG".to_vec(), pdfcraft_cos::Object::Array(vec![1.into(), 1.into(), 1.into()])); + mk.set(b"BC".to_vec(), pdfcraft_cos::Object::Array(vec![0.into(), 0.into(), 0.into()])); + mk.set(b"CA".to_vec(), pdfcraft_cos::PdfString::text("keep")); + let shared = cos.add(pdfcraft_cos::Object::Dict(mk)); + let alias = cos.add(pdfcraft_cos::Object::Ref(shared)); + for (object, reference) in [(qty, shared), (total, alias)] { + cos.update_dict(object, |d| d.set(b"MK".to_vec(), reference)).unwrap(); + } + let mut widgets = Vec::new(); + for x in [20, 90] { + let mut w = pdfcraft_cos::Dict::new(); + w.set(b"Subtype".to_vec(), pdfcraft_cos::Object::name("Widget")); + w.set(b"Parent".to_vec(), price); + w.set(b"P".to_vec(), pdfcraft_cos::ObjRef::new(3, 0)); + w.set(b"Rect".to_vec(), pdfcraft_cos::Object::Array(vec![x.into(), 250.into(), (x + 50).into(), 270.into()])); + w.set(b"MK".to_vec(), shared); + widgets.push(cos.add(pdfcraft_cos::Object::Dict(w))); + } + cos.update_dict(price, |d| { + d.remove(b"Rect"); + d.set(b"Kids".to_vec(), pdfcraft_cos::Object::Array(widgets.iter().map(|r| (*r).into()).collect())); + }) + .unwrap(); + cos.update_dict(pdfcraft_cos::ObjRef::new(3, 0), |d| { + let mut a = d.get(b"Annots").unwrap().as_array().unwrap().clone(); + a.retain(|o| o.as_ref() != Some(price)); + a.extend(widgets.iter().map(|r| (*r).into())); + d.set(b"Annots".to_vec(), pdfcraft_cos::Object::Array(a)); + }) + .unwrap(); + let mut alias_probe = cos.clone(); + pdfcraft_forms::apply_script_changes( + &mut alias_probe, + &[pdfcraft_forms::FieldChange { + name: "price".into(), + fill_color: Some(vec!["RGB".into(), "1".into(), "0".into(), "0".into()]), + ..Default::default() + }], + &mut pdfcraft_forms::NoScripts, + ) + .unwrap(); + for name in ["qty", "total"] { + let field = pdfcraft_forms::fields(&alias_probe).into_iter().find(|f| f.name == name).unwrap(); + assert_eq!(pdfcraft_forms::look(&alias_probe, &field).fill, Some([1., 1., 1.])); + } + let mut s = Session::new(); + s.set_javascript(false); + let id = s.open("shared-colors.pdf", None, Arc::new(pdfcraft_cos::write_full(&cos, &Default::default()).unwrap()), None).unwrap(); + s.set_javascript(true); + let original_id = id; + let owned_reference = { + let doc = s.get(id).unwrap(); + let widget = doc.form.iter().find(|f| f.name == "price").unwrap().widgets.first().unwrap().obj; + doc.editor.as_ref().unwrap().cos.get(widget).as_dict().unwrap().reference(b"MK").unwrap() + }; + let out = if native { + let p = s.start_javascript(id, script, Some("go")).unwrap(); + let crate::js::InteractiveEvent::Complete(out) = p.receiver.recv_timeout(std::time::Duration::from_secs(3)).unwrap() else { + panic!("completion") + }; + s.finish_javascript(p, out).unwrap() + } else { + s.run_javascript(id, script, None).unwrap() + }; + assert_eq!(out.error, None); + let verify = |s: &Session, id, expected_fill, expected_border| { + let doc = s.get(id).unwrap(); + let cos = &doc.editor.as_ref().unwrap().cos; + let f = doc.form.iter().find(|f| f.name == "price").unwrap(); + assert_eq!(f.widgets.len(), 2); + let look = pdfcraft_forms::look(cos, f); + assert_eq!(look.fill, expected_fill); + assert_eq!(look.border, expected_border); + let actual_reference = cos.get(f.widgets.first().unwrap().obj).as_dict().unwrap().reference(b"MK").unwrap(); + if id == original_id { + assert_eq!(actual_reference, owned_reference); + } + for w in &f.widgets { + assert_eq!(cos.get(w.obj).as_dict().unwrap().reference(b"MK"), Some(actual_reference)); + } + for name in ["qty", "total"] { + let f = doc.form.iter().find(|f| f.name == name).unwrap(); + let look = pdfcraft_forms::look(cos, f); + assert_eq!(look.fill, Some([1., 1., 1.])); + assert_eq!(look.border, Some([0., 0., 0.])); + let mk = cos.get(f.widgets.first().unwrap().obj).as_dict().unwrap().get(b"MK").cloned().unwrap(); + assert_eq!(cos.resolve(&mk).as_dict().unwrap().get(b"CA").unwrap().as_string().unwrap().to_text(), "keep"); + } + }; + verify(&s, id, fill, border); + s.undo(id).unwrap(); + verify(&s, id, Some([1., 1., 1.]), Some([0., 0., 0.])); + s.redo(id).unwrap(); + verify(&s, id, fill, border); + let bytes = s.save_bytes(id).unwrap(); + s.set_javascript(false); + let reopened = s.open("saved-colors.pdf", None, bytes, None).unwrap(); + verify(&s, reopened, fill, border); + } + } +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn exact_button_target_ids_are_validated_before_script_execution() { + for native in [false, true] { + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("buttons.pdf", None, Arc::new(scripted_form()), None).unwrap(); + let fields = s.get(id).unwrap().form.clone(); + let go = fields.iter().find(|f| f.name == "go").unwrap(); + let identity = crate::js::field_object_id(go.obj); + let price = crate::js::field_object_id(fields.iter().find(|f| f.name == "price").unwrap().obj); + let before = s.save_bytes(id).unwrap(); + for (name, key) in [(Some("go"), price), (None, identity), (Some("missing"), identity), (Some("go"), u64::MAX)] { + if native { + assert!(s.start_javascript_target(id, "var shouldNotRun=1;", name, Some(key)).is_err()); + } else { + assert!(s.run_javascript_target(id, "var shouldNotRun=1;", name, Some(key)).is_err()); + } + } + assert_eq!(s.save_bytes(id).unwrap(), before); + assert_eq!(s.run_javascript(id, "typeof shouldNotRun", None).unwrap().result.as_deref(), Some("undefined")); + let out = s + .run_javascript_answers_target( + id, + "event.target.buttonSetCaption(app.response({cQuestion:'Caption'}));event.target.name;", + Some("go"), + Some(identity), + &[Some("answered".into())], + ) + .unwrap(); + assert_eq!(out.error, None); + assert_eq!(out.result.as_deref(), Some("go")); + assert_eq!(s.get(id).unwrap().form.iter().find(|f| f.name == "go").unwrap().widgets.first().unwrap().caption.as_deref(), Some("answered")); + } +} + /// A whole tree goes in as one undoable step, and a refused tree changes nothing. #[test] fn imported_bookmarks_undo_in_one_step() { @@ -13853,6 +14718,214 @@ fn imported_bookmarks_undo_in_one_step() { assert!(s.get(id).unwrap().info.outline.is_empty()); } +/// Rejected host writeback must invalidate globals for both synchronous and native completion. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn refused_script_writeback_retires_the_runtime() { + for native in [false, true] { + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("bounded.pdf", None, Arc::new(synthetic_acroform(1200, 500)), None).unwrap(); + let before = s.save_bytes(id).unwrap(); + let script = "var refusedGlobal=42;for(var i=0;i<32;i++)resetForm();"; + if native { + let pending = s.start_javascript(id, script, None).unwrap(); + let crate::js::InteractiveEvent::Complete(out) = pending.receiver.recv_timeout(std::time::Duration::from_secs(5)).unwrap() else { + panic!("completion") + }; + assert!(s.finish_javascript(pending, out).is_err()); + } else { + assert!(s.run_javascript(id, script, None).is_err()); + } + assert_eq!(s.save_bytes(id).unwrap(), before); + assert_eq!(s.run_javascript(id, "typeof refusedGlobal", None).unwrap().result.as_deref(), Some("undefined")); + } +} +/// History retires removed fields without discarding document globals or aliasing reused numbers. +#[test] +fn undo_removed_field_references_cannot_write_replacement_fields() { + let (mut s, id) = session_with(1); + let add = + |name: &str| Edit::AddField { page: 0, rect: [20.0, 20.0, 120.0, 40.0], kind: NewField::Text { multiline: false }, name: Some(name.into()) }; + s.apply(id, add("old")).unwrap(); + let original = s.get(id).unwrap().form.iter().find(|f| f.name == "old").unwrap().obj; + s.run_javascript(id, "var kept=99;var held=getField('old');held.custom=42;", None).unwrap(); + s.undo(id).unwrap(); + s.apply(id, add("replacement")).unwrap(); + let replacement = s.get(id).unwrap().form.iter().find(|f| f.name == "replacement").unwrap().obj; + assert_eq!(original, replacement, "exercise the reused PDF number"); + assert_eq!( + s.run_javascript(id, "kept===99 && getField('replacement')!==held && getField('replacement').custom===undefined;", None) + .unwrap() + .result + .as_deref(), + Some("true") + ); + let out = s.run_javascript(id, "held.value='wrong';", None).unwrap(); + assert!(out.error.is_some()); + assert!(s.get(id).unwrap().form.iter().find(|f| f.name == "replacement").unwrap().value.is_empty()); + s.undo(id).unwrap(); + s.redo(id).unwrap(); + assert_eq!(s.run_javascript(id, "kept", None).unwrap().result.as_deref(), Some("99")); +} +/// An exact image target changes only the selected duplicate-name field, with history and persistence. +#[test] +fn exact_image_import_targets_only_the_selected_duplicate_button() { + let (mut s, id) = session_with(1); + for (name, x) in [("first", 20.0), ("second", 160.0)] { + s.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 = pdfcraft_cos::Document::open(s.save_bytes(id).unwrap()).unwrap(); + for field in pdfcraft_forms::fields(&cos) { + 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 id = s.open("duplicate-image.pdf", None, Arc::new(bytes), None).unwrap(); + let target = s.get(id).unwrap().form[1].obj; + let edit = Edit::SetFieldImageTarget { name: "same".into(), object: target, image: Arc::new(pdfcraft_xfa::fixtures::TINY_GIF.to_vec()) }; + let has_icons = |s: &Session, id| { + let doc = s.get(id).unwrap(); + let cos = &doc.editor.as_ref().unwrap().cos; + doc.form + .iter() + .map(|f| { + cos.get(f.widgets[0].obj) + .as_dict() + .and_then(|d| d.get(b"MK").map(|m| cos.resolve(m))) + .is_some_and(|m| m.as_dict().is_some_and(|d| d.contains(b"I"))) + }) + .collect::>() + }; + s.apply(id, edit).unwrap(); + assert_eq!(has_icons(&s, id), [false, true]); + s.undo(id).unwrap(); + assert_eq!(has_icons(&s, id), [false, false]); + s.redo(id).unwrap(); + assert_eq!(has_icons(&s, id), [false, true]); + let bytes = s.save_bytes(id).unwrap(); + let reopened = s.open("again.pdf", None, bytes, None).unwrap(); + s.apply(reopened, Edit::SetInfo { key: "Title".into(), value: "check".into() }).unwrap(); + assert_eq!(has_icons(&s, reopened), [false, true]); +} + +/// Native automatic calculations and explicit calculateNow agree on the first selected value. +#[test] +fn multiselect_calculation_matches_explicit_script_and_saved_result() { + let (mut s, id) = session_with(1); + for (name, kind, y) in [ + ("choices", NewField::List { options: vec!["2".into(), "5".into()], multi: true }, 100.0), + ("sum", NewField::Text { multiline: false }, 50.0), + ] { + s.apply(id, Edit::AddField { page: 0, rect: [20.0, y, 120.0, y + 30.0], kind, name: Some(name.into()) }).unwrap(); + } + s.apply( + id, + Edit::SetFieldProps { + name: "sum".into(), + props: Box::new(pdfcraft_forms::FieldProps { + calculate: Some(pdfcraft_forms::af::Calculate::Simple { op: pdfcraft_forms::af::CalcOp::Sum, fields: vec!["choices".into()] }), + ..Default::default() + }), + }, + ) + .unwrap(); + s.apply(id, Edit::SetFieldValue { name: "choices".into(), value: pdfcraft_forms::FieldValue::Choice(vec!["2".into(), "5".into()]) }).unwrap(); + assert_eq!(s.get(id).unwrap().form.iter().find(|f| f.name == "sum").unwrap().value, ["2"]); + let out = s.run_javascript(id, "calculateNow();getField('sum').value", None).unwrap(); + assert_eq!(out.error, None); + assert_eq!(out.result.as_deref(), Some("2")); + assert_eq!(s.get(id).unwrap().form.iter().find(|f| f.name == "sum").unwrap().value, ["2"]); + let bytes = s.save_bytes(id).unwrap(); + let reopened = s.open("again.pdf", None, bytes, None).unwrap(); + assert_eq!(s.get(reopened).unwrap().form.iter().find(|f| f.name == "sum").unwrap().value, ["2"]); +} + +/// Real mixed widgets converge even when buttonSetIcon repeats widget zero's existing icon. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn mixed_widget_icons_are_normalized_with_history_and_save() { + for native in [false, true] { + for first_has_icon in [false, true] { + use pdfcraft_cos::{Dict, ObjRef, Object, Stream}; + let mut cos = pdfcraft_cos::Document::open(Arc::new(scripted_form())).unwrap(); + let go = pdfcraft_forms::fields(&cos).into_iter().find(|f| f.name == "go").unwrap().obj; + 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(vec![0.into(), 0.into(), 10.into(), 10.into()])); + let a = cos.add(Object::Stream(Stream::flate(dict.clone(), b""))); + let b = cos.add(Object::Stream(Stream::flate(dict, b""))); + let mut widgets = vec![]; + for (i, icon) in [first_has_icon.then_some(a), Some(b)].into_iter().enumerate() { + let mut w = Dict::new(); + w.set(b"Subtype".to_vec(), Object::name("Widget")); + w.set(b"Parent".to_vec(), go); + w.set(b"P".to_vec(), ObjRef::new(3, 0)); + w.set(b"Rect".to_vec(), Object::Array(vec![(20 + i as i64 * 80).into(), 20.into(), (80 + i as i64 * 80).into(), 60.into()])); + let mut mk = Dict::new(); + if let Some(icon) = icon { + mk.set(b"I".to_vec(), icon); + } + w.set(b"MK".to_vec(), Object::Dict(mk)); + widgets.push(cos.add(Object::Dict(w))); + } + cos.update_dict(go, |d| { + d.remove(b"Rect"); + d.set(b"Kids".to_vec(), Object::Array(widgets.iter().map(|r| (*r).into()).collect())); + }) + .unwrap(); + cos.update_dict(ObjRef::new(3, 0), |d| { + let mut annots = d.get(b"Annots").unwrap().as_array().unwrap().clone(); + annots.retain(|v| v.as_ref() != Some(go)); + annots.extend(widgets.iter().map(|r| Object::Ref(*r))); + d.set(b"Annots".to_vec(), Object::Array(annots)); + }) + .unwrap(); + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("mixed-icons.pdf", None, Arc::new(pdfcraft_cos::write_full(&cos, &Default::default()).unwrap()), None).unwrap(); + let script = "getField('go').buttonSetIcon(getField('go').buttonGetIcon());"; + if native { + let pending = s.start_javascript(id, script, Some("go")).unwrap(); + let crate::js::InteractiveEvent::Complete(out) = pending.receiver.recv_timeout(std::time::Duration::from_secs(5)).unwrap() else { + panic!("completion") + }; + assert_eq!(s.finish_javascript(pending, out).unwrap().error, None); + } else { + assert_eq!(s.run_javascript(id, script, Some("go")).unwrap().error, None); + } + let icons = |s: &mut Session, id| { + let cos = pdfcraft_cos::Document::open(s.save_bytes(id).unwrap()).unwrap(); + pdfcraft_forms::fields(&cos) + .iter() + .find(|f| f.name == "go") + .unwrap() + .widgets + .iter() + .map(|w| { + cos.get(w.obj) + .as_dict() + .and_then(|d| d.get(b"MK").map(|v| cos.resolve(v))) + .and_then(|v| v.as_dict().and_then(|d| d.reference(b"I"))) + }) + .collect::>() + }; + let after = icons(&mut s, id); + assert_eq!(after.len(), 2); + assert_eq!(after[0], after[1]); + assert_eq!(after[0].is_some(), first_has_icon); + s.undo(id).unwrap(); + let before = icons(&mut s, id); + assert_ne!(before[0], before[1]); + s.redo(id).unwrap(); + let after = icons(&mut s, id); + assert_eq!(after[0], after[1]); + let bytes = s.save_bytes(id).unwrap(); + let reopened = s.open("again.pdf", None, bytes, None).unwrap(); + let after = icons(&mut s, reopened); + assert_eq!(after[0], after[1]); + assert_eq!(after[0].is_some(), first_has_icon); + } + } +} /// A comment imported or pasted without the appearance its file carried (unreadable there) is /// said in the repair log, which the interface shows and the tools return. #[test] @@ -13968,6 +15041,35 @@ fn a_document_encrypting_only_its_embedded_files_opens_and_saves_without_a_passw assert!(reopened.embedded_files_locked(), "the attachment stays encrypted"); } +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn script_objects_survive_incremental_save_but_not_a_full_rewrite() { + let mut session = Session::new(); + let id = session.open("fields.pdf", None, Arc::new(scripted_form()), None).unwrap(); + session.set_javascript(true); + let stored = session.run_javascript(id, "var retainedPrice = getField('price'); retainedPrice.value = '17';", None).unwrap(); + assert!(stored.error.is_none(), "{stored:?}"); + let incremental = session.save_bytes(id).unwrap(); + session.mark_saved(id, incremental.clone(), None).unwrap(); + let after_incremental = session.run_javascript(id, "String(retainedPrice.value)", None).unwrap(); + assert_eq!(after_incremental.result.as_deref(), Some("17")); + let full = session.save_full_bytes(id).unwrap(); + assert!(!full.starts_with(incremental.as_slice())); + session.mark_saved(id, full, Some("rewritten.pdf".into())).unwrap(); + let after_full = session.run_javascript(id, "typeof retainedPrice + ':' + getField('price').value", None).unwrap(); + assert_eq!(after_full.result.as_deref(), Some("undefined:17")); +} + +#[test] +fn calculate_now_applies_format_script_to_updated_appearance() { + let mut session = Session::new().with_clock(|| 1_700_000_000); + let id = session.open("calculate-format.pdf", None, Arc::new(scripted_form()), None).unwrap(); + session.run_javascript(id, "getField('price').value = 3; getField('qty').value = 2; calculateNow();", None).unwrap(); + + assert_eq!(session.get(id).unwrap().form.iter().find(|field| field.name == "total").unwrap().value, ["6"]); + assert!(page_texts(&session, id)[0].contains("USD 6.00"), "the appearance uses the Format script"); +} + #[test] fn javascript_non_rgb_field_colours_convert_and_keep_the_scripts_other_changes() { let fill_after = |colour: &str| { diff --git a/crates/engine/src/xfa.rs b/crates/engine/src/xfa.rs index 9b28320a55..ee8c92cfdd 100644 --- a/crates/engine/src/xfa.rs +++ b/crates/engine/src/xfa.rs @@ -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, 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::().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>, by_som: Option>, + objects_by_som: HashMap, + focus_requests: Vec<(usize, Option)>, /// Widget names by the data node they bind to (built with `by_som`). by_path: Option>>, fields: Option>, @@ -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 { 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> = 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#""#, + ) + .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); diff --git a/crates/engine/tests/form_date_language.rs b/crates/engine/tests/form_date_language.rs index 55a701b6a9..ba4193522e 100644 --- a/crates/engine/tests/form_date_language.rs +++ b/crates/engine/tests/form_date_language.rs @@ -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); diff --git a/crates/forms/src/author.rs b/crates/forms/src/author.rs index c8aba5b057..f347909b58 100644 --- a/crates/forms/src/author.rs +++ b/crates/forms/src/author.rs @@ -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. diff --git a/crates/forms/src/author/edit.rs b/crates/forms/src/author/edit.rs index 4836ce4138..b539bfc118 100644 --- a/crates/forms/src/author/edit.rs +++ b/crates/forms/src/author/edit.rs @@ -111,6 +111,15 @@ fn calculate_action(c: &crate::af::Calculate) -> Option { /// Change a field's properties (General and Options tabs) and redraw it. pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result { + 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 { + set_props_target(doc, name, Some(object), props) +} + +fn set_props_target(doc: &mut Document, name: &str, object: Option, props: &FieldProps) -> Result { 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 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) -> 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, only: Option) -> 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, 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 { diff --git a/crates/forms/src/lib.rs b/crates/forms/src/lib.rs index c1c91f8872..89d7e2998f 100644 --- a/crates/forms/src/lib.rs +++ b/crates/forms/src/lib.rs @@ -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, + /// Text widget JavaScript to run on a mouse-up click. + pub mouse_up_script: Option, /// List boxes: where the options are drawn, so a click can pick the one under the pointer. pub list: Option, /// Drawn round: a radio button, or a check box laid out for a round XFA check button diff --git a/crates/forms/src/read.rs b/crates/forms/src/read.rs index ddd71f0b05..4a7904f620 100644 --- a/crates/forms/src/read.rs +++ b/crates/forms/src/read.rs @@ -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)), }) diff --git a/crates/forms/src/scripting.rs b/crates/forms/src/scripting.rs index c01b1f8842..3d66dd0918 100644 --- a/crates/forms/src/scripting.rs +++ b/crates/forms/src/scripting.rs @@ -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, pub value: Option>, /// 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, /// `display.visible` 0, `hidden` 1, `noPrint` 2, `noView` 3. pub display: Option, - /// 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, pub text_color: Option>, pub fill_color: Option>, - pub caption: Option, - /// Move/resize the first widget, in PDF user space. pub rect: Option<[f64; 4]>, + pub stroke_color: Option>, + pub text_size: Option, + pub line_width: Option, + pub border_style: Option, + pub user_name: Option, + pub icon: Option>, } /// 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 { 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 { 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 { + 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>) -> 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 { 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, + 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> = 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, 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, 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(()) } diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index 859467744e..caeba9491d 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -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, + } + 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 = 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"]); +} diff --git a/crates/forms/src/values.rs b/crates/forms/src/values.rs index bbf1467415..af2c5e1aca 100644 --- a/crates/forms/src/values.rs +++ b/crates/forms/src/values.rs @@ -203,6 +203,15 @@ pub fn recalculate(doc: &mut Document) -> Result { /// [`recalculate`], running custom Calculate (and Format) scripts through `scripts`. pub fn recalculate_with(doc: &mut Document, scripts: &mut dyn Scripts) -> Result { + 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>, +) -> Result { 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 = 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 { 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 { } 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 { + 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> = 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 {{ - fn get(_: &JsValue, _: &[JsValue], name: &JsString, $c: &mut Context) -> JsResult { + fn get(_: &JsValue, _: &[JsValue], name: &FieldKey, $c: &mut Context) -> JsResult { 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) -> 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, 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 { + fn set(_: &JsValue, args: &[JsValue], name: &FieldKey, c: &mut Context) -> JsResult { 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 { + fn set(_: &JsValue, args: &[JsValue], name: &FieldKey, c: &mut Context) -> JsResult { 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 { + fn set(_: &JsValue, args: &[JsValue], name: &FieldKey, c: &mut Context) -> JsResult { 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 { + fn set(_: &JsValue, args: &[JsValue], name: &FieldKey, c: &mut Context) -> JsResult { let d = arg(args, 0).to_number(c)? as i32; write(c, name, |f| f.display = d.clamp(0, 3))?; Ok(JsValue::undefined()) @@ -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 { + fn set(_: &JsValue, args: &[JsValue], name: &FieldKey, c: &mut Context) -> JsResult { let on = arg(args, 0).to_boolean(); write(c, name, |f| f.display = if on { DISPLAY_HIDDEN } else { DISPLAY_VISIBLE })?; Ok(JsValue::undefined()) @@ -76,56 +100,114 @@ pub(crate) fn field_object(ctx: &mut Context, name: &str) -> JsObject { let idx: Vec = f.options.iter().enumerate().filter(|(_, o)| f.value.contains(&o.0)).map(|(i, _)| JsValue::from(i as f64)).collect(); if idx.len() == 1 { idx[0].clone() } else { JsArray::from_iter(idx, c).into() } }); - let text_color_get = getter!(ctx, n, |f, c| colour(&f.text_color, c)); + let text_color_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 { + fn set(_: &JsValue, args: &[JsValue], name: &FieldKey, c: &mut Context) -> JsResult { 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 { + fn set(_: &JsValue, args: &[JsValue], name: &FieldKey, c: &mut Context) -> JsResult { 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::>(), 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 { + 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 { + 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 { + 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 { + 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 { + 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 { - 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 { + 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 { + /// 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 { 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 { + /// 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 { 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 { + fn get_item_at(_: &JsValue, args: &[JsValue], name: &FieldKey, c: &mut Context) -> JsResult { 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 { + fn is_box_checked(_: &JsValue, args: &[JsValue], name: &FieldKey, c: &mut Context) -> JsResult { let f = read(c, name)?; let i = arg(args, 0).to_number(c)? as usize; let on = f.options.get(i).is_some_and(|o| f.value.first() == Some(&o.0)); Ok(JsValue::from(on)) } - fn check_this_box(_: &JsValue, args: &[JsValue], name: &JsString, c: &mut Context) -> JsResult { + fn check_this_box(_: &JsValue, args: &[JsValue], name: &FieldKey, c: &mut Context) -> JsResult { let f = read(c, name)?; let i = arg(args, 0).to_number(c)? as usize; let on = args.get(1).is_none_or(JsValue::to_boolean); @@ -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 { - host(c)?.borrow_mut().request(Request::Focus(name.to_std_string_escaped())); + fn set_focus(_: &JsValue, _: &[JsValue], name: &FieldKey, c: &mut Context) -> JsResult { + 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 { + { + 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()) } diff --git a/crates/js/src/geometry.rs b/crates/js/src/geometry.rs new file mode 100644 index 0000000000..a2861ee5c1 --- /dev/null +++ b/crates/js/src/geometry.rs @@ -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); + } + } +} diff --git a/crates/js/src/globals.rs b/crates/js/src/globals.rs index 1acc9bb89f..3dfbf3924e 100644 --- a/crates/js/src/globals.rs +++ b/crates/js/src/globals.rs @@ -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 { 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::().unwrap_or(2))) } /// `app.beep()`: queues a request to beep. @@ -92,7 +112,7 @@ fn beep(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { /// `app.runtimeHighlight`: whether the viewer highlights form fields. fn runtime_highlight(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { - 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 { 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 [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 { - // 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 { 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= 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= 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 JsResult { - const MAX_EXPANDED_VALUES: usize = 1 << 20; let op = text(&arg(args, 0), ctx)?; let names = strings(&arg(args, 1), ctx)?; - let values: Vec = { - 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::() / 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 { + 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 { + 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 { + 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 { + 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 { + 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), + ("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()) diff --git a/crates/js/src/host.rs b/crates/js/src/host.rs index 828a0726b0..5966e63545 100644 --- a/crates/js/src/host.rs +++ b/crates/js/src/host.rs @@ -8,15 +8,23 @@ use super::{ pub(crate) struct Host { pub(crate) event_name: String, pub(crate) fields: Arc>, + pub(crate) icons: Vec<(super::JsObject, u64)>, + pub(crate) objects: std::collections::HashMap<(String, Option, u64), super::JsObject>, + pub(crate) field_keys: std::collections::HashSet<(String, Option, u64)>, pub(crate) changed: BTreeSet, pub(crate) formatted: BTreeSet, pub(crate) alerts: Vec, pub(crate) console: Vec, pub(crate) requests: Vec, pub(crate) doc: DocInfo, - pub(crate) calculations: Arc>, + 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::())), 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 { + 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> { if let Some(o) = v.as_object() && o.is_array() @@ -146,16 +188,23 @@ pub(crate) fn strings(v: &JsValue, ctx: &mut Context) -> JsResult> { 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>, 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>, ctx: &mut Context) -> JsValue { // ── field objects ─────────────────────────────────────────────────────────────────────────── -fn field_index(ctx: &Context, name: &JsString) -> JsResult { - let n = name.to_std_string_escaped(); - host(ctx)?.borrow().fields.iter().position(|f| f.name == n).ok_or_else(|| error(format!("no field named {n}"))) +pub(crate) type FieldKey = (JsString, Option, u64); + +/// PDF identities survive renames; name-only synthetic fields retain name lookup semantics. +pub(crate) fn field_cache_key(name: &str, id: Option, token: u64) -> (String, Option, u64) { + (if id.is_some() { String::new() } else { name.to_string() }, id, token) } -pub(crate) fn read(ctx: &Context, name: &JsString) -> JsResult { +fn field_index(ctx: &Context, name: &FieldKey) -> JsResult { + 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 { 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) { _ => 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(), ""); + } +} diff --git a/crates/js/src/icons.rs b/crates/js/src/icons.rs new file mode 100644 index 0000000000..f56ec9769f --- /dev/null +++ b/crates/js/src/icons.rs @@ -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 { + 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 { + 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()) +} diff --git a/crates/js/src/interaction.rs b/crates/js/src/interaction.rs new file mode 100644 index 0000000000..525acf930a --- /dev/null +++ b/crates/js/src/interaction.rs @@ -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, +} +#[derive(Clone, Debug, PartialEq)] +pub enum Prompt { + Menu(Vec), + Text { title: String, question: String, default: String, password: bool, label: String }, + Alert(crate::Message), +} +pub enum InteractiveEvent { + Prompt(Prompt, Sender>), + Complete(Outcome), +} +thread_local! { static HOST:RefCell,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>) { + 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> { + 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 { + text_with_budget(value, ctx, 4096, "popup menu text exceeds its limit") +} +fn response_text(value: &JsValue, ctx: &mut Context) -> JsResult { + 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 { + 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 { + 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 { + 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()); + } +} diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs index 2e9dba0c6d..6d722c00ae 100644 --- a/crates/js/src/lib.rs +++ b/crates/js/src/lib.rs @@ -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; diff --git a/crates/js/src/model.rs b/crates/js/src/model.rs index eabe3ecd57..70bb677d8a 100644 --- a/crates/js/src/model.rs +++ b/crates/js/src/model.rs @@ -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, + /// 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, pub default: Vec, + /// 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, /// `textColor` / `fillColor` as Acrobat colour arrays (`["RGB", r, g, b]` etc.), when set. - pub text_color: Option>, - pub fill_color: Option>, + pub text_color: Option>, + pub fill_color: Option>, /// Normal push-button caption, when this is a button. pub caption: Option, - /// `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>, + pub text_size: f64, + pub line_width: f64, + pub border_style: String, + pub user_name: String, + pub icon: Option, + 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, kind: FieldType, value: Vec) -> 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, + /// Identity for an unambiguous event.target, even with duplicate field names. + pub target_id: Option, 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>, 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, - /// 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. diff --git a/crates/js/src/run.rs b/crates/js/src/run.rs index dd6e3fccb8..ececa107a3 100644 --- a/crates/js/src/run.rs +++ b/crates/js/src/run.rs @@ -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 { 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>, 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>, 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>, doc_scripts: &[String], - calculations: &Arc>, + 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>, +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>, 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>, + 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::() + .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() { - 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::(); + 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 { + 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 { + host(ctx)?.borrow_mut().current_event = args.first().cloned().unwrap_or_default(); + Ok(JsValue::undefined()) +} diff --git a/crates/js/src/run/preflight.rs b/crates/js/src/run/preflight.rs new file mode 100644 index 0000000000..636bbf331d --- /dev/null +++ b/crates/js/src/run/preflight.rs @@ -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 { + 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 +} diff --git a/crates/js/src/session.rs b/crates/js/src/session.rs new file mode 100644 index 0000000000..1e01674df1 --- /dev/null +++ b/crates/js/src/session.rs @@ -0,0 +1,1185 @@ +//! One sandboxed realm per open document. No context crosses a document boundary. +#[cfg(not(target_arch = "wasm32"))] +use crate::interaction::InteractiveEvent; +use crate::{DocInfo, Event, FieldState, Limits, Outcome}; +use std::sync::Arc; +#[cfg(not(target_arch = "wasm32"))] +use std::sync::mpsc; + +/// One event snapshot; the worker owns its context and refreshes this data between invocations. +#[derive(Clone)] +pub struct Job { + pub script: String, + pub event: Event, + pub doc: DocInfo, + pub fields: Arc>, + pub doc_scripts: Vec, + /// Target positions in this job's field snapshot and their ordered script sources. + pub calculations: Vec<(usize, String)>, +} +/// Live field instances are independent of restorable PDF object-number allocation. +#[derive(Default)] +struct FieldInstances { + current: std::collections::HashMap, + last_ids: Vec, + next: u64, +} +impl FieldInstances { + /// Retire absent instances immediately, including history changes between two script jobs. + fn sync(&mut self, ids: impl IntoIterator) { + let ids: Vec<_> = ids.into_iter().collect(); + if ids == self.last_ids { + return; + } + let present: std::collections::BTreeSet<_> = ids.iter().copied().collect(); + self.last_ids = ids; + self.current.retain(|id, _| present.contains(id)); + for id in present { + if !self.current.contains_key(&id) { + self.next = self.next.saturating_add(1); + self.current.insert(id, self.next); + } + } + } + /// Stamp the immutable job snapshot with current instance tokens before worker dispatch. + fn stamp(&mut self, job: &mut Job) { + self.stamp_fields(&mut job.fields); + } + /// Stamp a snapshot in place, copying it only when a token actually changes: a caller that + /// keeps its snapshot between callbacks (the engine does) then shares it with the worker. + fn stamp_fields(&mut self, fields: &mut Arc>) { + self.sync(fields.iter().filter_map(|f| f.object_id)); + let token = + |current: &std::collections::HashMap, field: &FieldState| field.object_id.and_then(|id| current.get(&id).copied()).unwrap_or(0); + if fields.iter().all(|field| field.instance_id == token(&self.current, field)) { + return; + } + for field in Arc::make_mut(fields) { + field.instance_id = token(&self.current, field); + } + } +} +impl ScriptSession { + /// Stamp the caller's retained snapshot with the current instance tokens before it builds a + /// job, so the worker's own stamping finds nothing to change and does not copy it. + pub fn stamp_fields(&mut self, fields: &mut Arc>) { + self.instances.stamp_fields(fields); + } + /// Notify the retained runtime of live PDF fields after an edit or history step. + pub fn sync_field_ids(&mut self, ids: impl IntoIterator) { + self.instances.sync(ids); + } + + /// Invalidate icon handles after restoring an earlier PDF object graph. + pub fn retire_icon_handles(&mut self) { + self.icon_epoch = self.icon_epoch.wrapping_add(1); + self.icon_versions = Default::default(); + self.icon_tracking_overflowed = false; + } + + /// Invalidate only the recorded references that changed in a history step. + pub fn retire_changed_icons(&mut self, mut changed: impl FnMut(u64) -> bool) { + if self.icon_tracking_overflowed { + self.retire_icon_handles(); + return; + } + for (id, version) in Arc::make_mut(&mut self.icon_versions) { + if changed(*id) { + *version = version.wrapping_add(1); + } + } + } + + /// Attach icon versions to the snapshot and record when history must invalidate all handles because tracking filled up. + fn stamp_icons(&mut self, job: &mut Job) { + const MAX_TRACKED_ICONS: usize = 16_384; + for id in job.fields.iter().filter_map(|field| field.icon) { + if self.icon_versions.contains_key(&id) { + continue; + } + if self.icon_versions.len() == MAX_TRACKED_ICONS { + self.icon_tracking_overflowed = true; + break; + } + Arc::make_mut(&mut self.icon_versions).insert(id, 0); + } + job.doc.icon_epoch = self.icon_epoch; + job.doc.icon_versions = self.icon_versions.clone(); + } +} +/// Boa context and initialization sources retained by exactly one open-document runtime. +pub(crate) struct Realm { + context: Option, + scripts: Vec, +} +impl Realm { + /// Create an empty isolated realm; the first job initializes its document scripts. + fn new() -> Self { + Self { context: None, scripts: Vec::new() } + } + /// Validate sources, initialize once when needed, and execute this job in the retained document realm. + fn run(&mut self, job: Job) -> Outcome { + let out = self.run_job(job); + // The realm keeps its globals and field objects, not the job's field snapshot: the engine + // keeps that snapshot and patches it in place for the next callback. + if let Some(ctx) = self.context.as_ref() { + crate::run::release_fields(ctx); + } + out + } + fn run_job(&mut self, job: Job) -> Outcome { + let failed = |why: String| Outcome { rc: true, execution_refused: true, realm_untouched: true, error: Some(why), ..Default::default() }; + // A script too long or too deeply nested to run is that script's error, as on `main`: the + // value stays and the edit's other scripts still run. The realm is not touched. + if let Some(why) = crate::refuse(&job.script) { + return Outcome { + rc: true, + realm_untouched: true, + value: job.event.value.clone(), + change: job.event.change.clone(), + error: Some(why), + ..Default::default() + }; + } + if job.doc_scripts.len() > 1024 || job.doc_scripts.iter().try_fold(0usize, |n, s| n.checked_add(s.len())).is_none_or(|n| n > 1 << 20) { + return failed("document scripts exceed their budget".into()); + } + let initialize = self.context.is_none() || self.scripts != job.doc_scripts; + if initialize { + self.context = match crate::Context::builder().host_hooks(std::rc::Rc::new(crate::run::NoCodeFromStrings)).build() { + Ok(c) => Some(c), + Err(e) => return failed(e.to_string()), + }; + self.scripts = job.doc_scripts.clone(); + } + let mut skipped = Vec::new(); + let scripts: Vec<_> = job + .doc_scripts + .iter() + .enumerate() + .filter_map(|(i, s)| { + if !initialize { + return None; + } + match crate::refuse(s) { + Some(e) => { + skipped.push((i, e)); + None + } + None => Some((i, s.as_str())), + } + }) + .collect(); + let Some(ctx) = self.context.as_mut() else { return failed("script realm unavailable".into()) }; + if initialize { + let mut initial = crate::run::run_in_context( + ctx, + "", + &Event::doc("Open"), + &job.doc, + job.fields.clone(), + crate::run::ScriptSetup { + doc_scripts: &scripts, + skipped: &skipped, + calculations: &job.calculations, + initialize: true, + reset_calls: 0, + calculation_evaluations: 0, + calculation_limit_exceeded: false, + }, + Limits::default(), + ); + if initial.execution_refused { + return initial; + } + let (fields, reset_calls, calculation_evaluations) = crate::host::host(ctx) + .map(|h| { + let h = h.borrow(); + (h.fields.clone(), h.reset_calls, h.calculation_evaluations) + }) + .unwrap_or((job.fields, 0, 0)); + let mut out = crate::run::run_in_context( + ctx, + &job.script, + &job.event, + &job.doc, + fields, + crate::run::ScriptSetup { + doc_scripts: &[], + skipped: &[], + calculations: &job.calculations, + initialize: false, + reset_calls, + calculation_evaluations, + calculation_limit_exceeded: initial.calculation_limit_exceeded, + }, + Limits::default(), + ); + out.reset_limit_exceeded |= initial.reset_limit_exceeded; + out.calculation_limit_exceeded |= initial.calculation_limit_exceeded; + for field in initial.changed { + if !out.changed.iter().any(|f| match (f.object_id, field.object_id) { + (Some(a), Some(b)) => a == b, + _ => f.name == field.name, + }) { + out.changed.push(field); + } + } + initial.alerts.append(&mut out.alerts); + out.alerts = initial.alerts; + initial.console.append(&mut out.console); + out.console = initial.console; + initial.requests.append(&mut out.requests); + out.requests = initial.requests; + out.doc_errors = initial.doc_errors; + if out.error.is_none() { + out.error = initial.error; + } + out + } else { + crate::run::run_in_context( + ctx, + &job.script, + &job.event, + &job.doc, + job.fields, + crate::run::ScriptSetup { + doc_scripts: &[], + skipped: &[], + calculations: &job.calculations, + initialize: false, + reset_calls: 0, + calculation_evaluations: 0, + calculation_limit_exceeded: false, + }, + Limits::default(), + ) + } + } +} + +#[cfg(not(target_arch = "wasm32"))] +static LIVE_WORKERS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); +#[cfg(not(target_arch = "wasm32"))] +const MAX_DOCUMENT_WORKERS: usize = if usize::BITS == 32 { 4 } else { 16 }; +/// A reservation is released only when its worker exits, including detached timed-out workers. +#[cfg(not(target_arch = "wasm32"))] +struct WorkerPermit; +#[cfg(not(target_arch = "wasm32"))] +impl WorkerPermit { + /// Reserve bounded address space without evicting another document's retained state. + fn acquire() -> Result { + use std::sync::atomic::Ordering::SeqCst; + let mut live = LIVE_WORKERS.load(SeqCst); + loop { + if live >= MAX_DOCUMENT_WORKERS { + return Err("document script worker limit reached; close a scripted document and retry".into()); + } + match LIVE_WORKERS.compare_exchange(live, live.saturating_add(1), SeqCst, SeqCst) { + Ok(_) => return Ok(Self), + Err(current) => live = current, + } + } + } +} +#[cfg(not(target_arch = "wasm32"))] +impl Drop for WorkerPermit { + fn drop(&mut self) { + LIVE_WORKERS.fetch_sub(1, std::sync::atomic::Ordering::SeqCst); + } +} + +#[cfg(not(target_arch = "wasm32"))] +type HostTask = Box Outcome + Send>; + +#[cfg(not(target_arch = "wasm32"))] +enum WorkerRequest { + Script { job: Box, english_retry: bool }, + Host(HostTask), +} + +#[cfg(not(target_arch = "wasm32"))] +struct WorkerJob { + request: WorkerRequest, + reset_realm: bool, + reply: mpsc::Sender, +} + +#[cfg(not(target_arch = "wasm32"))] +/// Native document runtime with one bounded-stack worker and at most one active invocation. +/// Dropping it releases the sender; after any active job finishes the worker exits and drops its realm. +#[derive(Default)] +pub struct ScriptSession { + sender: Option>, + executing: Arc, + reset_realm: bool, + busy: Arc, + worker_lifetime: std::sync::Weak<()>, + instances: FieldInstances, + icon_epoch: u64, + icon_versions: Arc>, + icon_tracking_overflowed: bool, +} +#[cfg(not(target_arch = "wasm32"))] +impl ScriptSession { + /// Start a bounded-stack native worker lazily and reject a second active job, including during prompts. + pub fn start(&mut self, job: Job) -> Result, String> { + self.start_job(WorkerRequest::Script { job: Box::new(job), english_retry: false }) + } + + /// Reserve the document worker and enqueue a job. An English-date retry uses a fresh context and leaves the retained realm alone. + fn start_job(&mut self, mut request: WorkerRequest) -> Result, String> { + use std::sync::atomic::Ordering::SeqCst; + if self.is_executing() { + return Err("another script is active in this document".into()); + } + if self.sender.is_none() { + if self.worker_lifetime.upgrade().is_some() { + return Err("the timed-out script is still stopping; retry once it exits".into()); + } + let (tx, rx) = mpsc::sync_channel::(1); + let permit = WorkerPermit::acquire()?; + let lifetime = Arc::new(()); + self.worker_lifetime = Arc::downgrade(&lifetime); + let executing = self.executing.clone(); + std::thread::Builder::new() + .name("pdfcraft-document-js".into()) + .stack_size(crate::run::DOCUMENT_WORKER_STACK) + .spawn(move || { + let _lifetime = lifetime; + let _permit = permit; + let mut realm = Realm::new(); + while let Ok(WorkerJob { request, reset_realm, reply }) = rx.recv() { + if reset_realm { + realm = Realm::new(); + } + let interactive = matches!(&request, WorkerRequest::Script { english_retry: false, .. }); + crate::interaction::set_host(interactive.then(|| reply.clone())); + let out = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| match request { + WorkerRequest::Script { job, english_retry: true } => crate::with_english_printd(true, || { + crate::run::run_shared_here( + &job.script, + &job.event, + &job.doc, + job.fields, + &job.doc_scripts, + &job.calculations, + Limits::default(), + ) + }), + WorkerRequest::Script { job, english_retry: false } => realm.run(*job), + WorkerRequest::Host(task) => match realm.context.as_mut() { + Some(context) => task(context), + None => Outcome { + execution_refused: true, + realm_untouched: true, + error: Some("the document script realm has not been initialized".into()), + ..Default::default() + }, + }, + })); + crate::interaction::set_host(None); + let out = out.unwrap_or_else(|_| { + realm = Realm::new(); + Outcome { + rc: true, + execution_refused: true, + error: Some("script engine stopped unexpectedly".into()), + ..Default::default() + } + }); + executing.store(false, SeqCst); + let _ = reply.send(InteractiveEvent::Complete(out)); + } + }) + .map_err(|e| e.to_string())?; + self.sender = Some(tx); + } + if self.busy.compare_exchange(false, true, std::sync::atomic::Ordering::SeqCst, std::sync::atomic::Ordering::SeqCst).is_err() { + return Err("another script is active in this document".into()); + } + if let WorkerRequest::Script { job, english_retry: false } = &mut request { + self.instances.stamp(job); + self.stamp_icons(job); + } + let (tx, rx) = mpsc::channel(); + let Some(sender) = self.sender.as_ref() else { + self.busy.store(false, std::sync::atomic::Ordering::SeqCst); + return Err("script worker unavailable".into()); + }; + self.executing.store(true, SeqCst); + sender.try_send(WorkerJob { request, reset_realm: self.reset_realm, reply: tx }).map_err(|_| { + self.executing.store(false, SeqCst); + self.busy.store(false, SeqCst); + "script worker disconnected" + })?; + self.reset_realm = false; + Ok(rx) + } + /// Whether the native worker still owns an invocation, including one waiting for a prompt. + pub fn is_executing(&self) -> bool { + self.executing.load(std::sync::atomic::Ordering::SeqCst) + } + + /// An asynchronous completion remains pending until its document outcome is accepted or discarded. + pub fn is_pending(&self) -> bool { + self.busy.load(std::sync::atomic::Ordering::SeqCst) + } + /// Acknowledge a received completion before applying its edits on the same document realm. + pub fn acknowledge_completion(&mut self) { + self.busy.store(false, std::sync::atomic::Ordering::SeqCst); + } + /// Detach the old worker and retain its lifetime so it still occupies this document's slot. + pub fn retire_timed_out_worker(&mut self) { + self.sender = None; + self.acknowledge_completion(); + } + + /// Fork the realm while keeping this document's worker reservation and execution gate. + /// Old invocation handles cannot submit work through the successor's channel. + pub fn fresh_realm(&mut self) -> Self { + Self { + sender: self.sender.take(), + executing: self.executing.clone(), + worker_lifetime: self.worker_lifetime.clone(), + reset_realm: true, + ..Self::default() + } + } + + /// Drop state from callbacks belonging to a rejected PDF edit. + pub fn reset_after_refusal(&mut self) { + *self = self.fresh_realm(); + } + + /// Run a job with one total deadline, discarding its entire realm when the host times out. + pub fn run(&mut self, job: Job, answers: &[Option]) -> Outcome { + self.run_with_limit(job, answers, std::time::Duration::from_secs(10)) + } + /// Run a callback within the remaining time of a larger operation. + pub fn run_with_limit(&mut self, job: Job, answers: &[Option], limit: std::time::Duration) -> Outcome { + self.run_timed(WorkerRequest::Script { job: Box::new(job), english_retry: false }, answers, limit) + } + + /// Retry formatting in a fresh context on the document worker, within the caller's remaining time. + pub fn run_english_retry(&mut self, job: Job, limit: std::time::Duration) -> Outcome { + self.run_timed(WorkerRequest::Script { job: Box::new(job), english_retry: true }, &[], limit) + } + + /// Dispatch trusted host work to the document's already initialized realm. + /// The callback stays on the document worker and must return cooperatively on its own. + pub fn run_host_task(&mut self, limit: std::time::Duration, task: impl FnOnce(&mut crate::Context) -> Outcome + Send + 'static) -> Outcome { + self.run_timed(WorkerRequest::Host(Box::new(task)), &[], limit) + } + + /// Wait within one deadline for startup, prompt replies and completion. Detach a worker that exceeds the deadline. + fn run_timed(&mut self, request: WorkerRequest, answers: &[Option], limit: std::time::Duration) -> Outcome { + if limit.is_zero() { + return Outcome { + execution_refused: true, + realm_untouched: true, + error: Some("script worker had no time remaining".into()), + ..Default::default() + }; + } + let started = std::time::Instant::now(); + let rx = match self.start_job(request) { + Ok(rx) => rx, + Err(e) => return Outcome { execution_refused: true, realm_untouched: true, error: Some(e), ..Default::default() }, + }; + match Self::receive(&rx, answers, started, limit) { + Some(out) => { + self.acknowledge_completion(); + out + } + None => { + // The old worker has no route into this replacement realm. Its reservation + // remains charged until cooperative execution finishes and the thread exits. + self.retire_timed_out_worker(); + Outcome { execution_refused: true, error: Some("script worker did not complete within its limit".into()), ..Default::default() } + } + } + } + /// Prompt replies consume the original deadline instead of restarting it. + fn receive( + rx: &mpsc::Receiver, + answers: &[Option], + started: std::time::Instant, + limit: std::time::Duration, + ) -> Option { + let mut answers = answers.iter(); + loop { + let remaining = limit.saturating_sub(started.elapsed()); + if remaining.is_zero() { + return None; + } + match rx.recv_timeout(remaining) { + Ok(InteractiveEvent::Complete(out)) => return Some(out), + Ok(InteractiveEvent::Prompt(_, reply)) => { + let _ = reply.send(answers.next().cloned().flatten()); + } + Err(_) => return None, + } + } + } +} +/// Web-local retained realm; native worker/dialog hosting is deliberately unavailable. +#[cfg(target_arch = "wasm32")] +pub struct ScriptSession { + realm: Realm, + instances: FieldInstances, + icon_epoch: u64, + icon_versions: Arc>, + icon_tracking_overflowed: bool, +} +#[cfg(target_arch = "wasm32")] +impl Default for ScriptSession { + /// Create the web runtime without native worker threads or a native prompt host. + fn default() -> Self { + Self { + realm: Realm::new(), + instances: FieldInstances::default(), + icon_epoch: 0, + icon_versions: Default::default(), + icon_tracking_overflowed: false, + } + } +} +#[cfg(target_arch = "wasm32")] +impl ScriptSession { + /// Replace the web-local realm and its document bindings. + pub fn fresh_realm(&mut self) -> Self { + Self::default() + } + + /// Drop state from callbacks belonging to a rejected PDF edit. + pub fn reset_after_refusal(&mut self) { + *self = Self::default(); + } + + /// Run in the web-local realm; native prompt replies are unavailable on this host. + pub fn run(&mut self, mut job: Job, _answers: &[Option]) -> Outcome { + self.instances.stamp(&mut job); + self.stamp_icons(&mut job); + self.realm.run(job) + } +} + +#[cfg(test)] +mod tests { + use super::*; + /// Create a small button-event job with document globals used by persistent-session regressions. + fn job(source: &str) -> Job { + Job { + script: source.into(), + event: Event::field("Mouse Up", "go", ""), + doc: DocInfo::default(), + fields: Arc::new(vec![FieldState::new("go", crate::FieldType::Button, vec!["old".into()])]), + doc_scripts: vec!["var buffer='', init=1, opened=event.name;var held=getField('go');function digit(v){buffer+=v;return buffer;}".into()], + calculations: Vec::new(), + } + } + #[test] + /// Retained Field identity, custom properties and keypad buffers persist locally while separate documents stay isolated. + fn document_realm_keeps_keypad_state_and_isolates_documents() { + let mut a = ScriptSession::default(); + let mut b = ScriptSession::default(); + assert_eq!(a.run(job("digit('1');"), &[]).result.as_deref(), Some("1")); + assert_eq!(a.run(job("getField('go').custom=42;getField('go')===held;"), &[]).result.as_deref(), Some("true")); + assert_eq!(a.run(job("getField('go').custom;"), &[]).result.as_deref(), Some("42")); + assert_eq!(a.run(job("[digit('2'),init,opened].join('/');"), &[]).result.as_deref(), Some("12/1/Open")); + assert_eq!(b.run(job("digit('3');"), &[]).result.as_deref(), Some("3")); + let mut changed = job("held.value;"); + changed.fields = Arc::new(vec![FieldState::new("go", crate::FieldType::Button, vec!["live".into()])]); + assert_eq!(a.run(changed, &[]).result.as_deref(), Some("live")); + let mut edited = job("buffer;"); + edited.doc_scripts = vec!["var buffer='changed';".into()]; + assert_eq!(a.run(edited, &[]).result.as_deref(), Some("changed")); + } + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn host_work_reads_globals_in_the_correct_document() { + let read_counter = |context: &mut crate::Context| { + let result = context + .global_object() + .get(boa_engine::js_string!("counter"), context) + .ok() + .and_then(|value| value.as_number()) + .map(|number| number.to_string()); + Outcome { result, ..Default::default() } + }; + let mut first = ScriptSession::default(); + let mut second = ScriptSession::default(); + assert!(first.run(job("var counter = 17;"), &[]).error.is_none()); + assert!(second.run(job("var counter = 31;"), &[]).error.is_none()); + let timeout = std::time::Duration::from_secs(5); + assert_eq!(first.run_host_task(timeout, read_counter).result.as_deref(), Some("17")); + assert_eq!(second.run_host_task(timeout, read_counter).result.as_deref(), Some("31")); + } + + #[test] + /// A script too large to run fails on its own, like a script that throws, without refusing the edit. + fn an_oversized_script_is_an_error_not_a_refusal() { + let mut session = ScriptSession::default(); + let long = format!("event.value = 1;/*{}*/", "x".repeat(300 * 1024)); + let out = session.run(job(&long), &[]); + assert!(out.error.is_some()); + assert!(!out.execution_refused); + assert!(session.run(job("1 + 1"), &[]).error.is_none()); + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + /// A waiting prompt refuses concurrent document runs immediately instead of blocking the caller behind it. + fn a_waiting_prompt_refuses_reentrant_host_runs_without_blocking_the_ui() { + let mut session = ScriptSession::default(); + let receiver = session.start(job("app.response({cQuestion:'Wait'});")).unwrap(); + let InteractiveEvent::Prompt(_, reply) = receiver.recv_timeout(std::time::Duration::from_secs(2)).unwrap() else { panic!("expected prompt") }; + let started = std::time::Instant::now(); + let refused = session.run(job("buffer;"), &[]); + assert!(refused.error.as_deref().unwrap().contains("another script is active")); + let refused = session.run_host_task(std::time::Duration::from_secs(1), |_| panic!("host task entered a busy realm")); + assert!(refused.execution_refused && refused.realm_untouched); + assert!(started.elapsed() < std::time::Duration::from_secs(1)); + reply.send(None).unwrap(); + assert!(matches!(receiver.recv_timeout(std::time::Duration::from_secs(2)).unwrap(), InteractiveEvent::Complete(_))); + } + /// A retained realm must expose current metadata without replaying document initialization. + #[test] + fn persistent_document_properties_follow_the_current_snapshot() { + let mut session = ScriptSession::default(); + let source = "[numPages,documentFileName,info.title,init,buffer].join('/');"; + let mut first = job(source); + first.doc = DocInfo { num_pages: 1, file_name: "before.pdf".into(), info: vec![("title".into(), "Before".into())], ..Default::default() }; + assert_eq!(session.run(first, &[]).result.as_deref(), Some("1/before.pdf/Before/1/")); + assert_eq!(session.run(job("buffer='kept';"), &[]).error, None); + let mut next = job(source); + next.doc = DocInfo { num_pages: 7, file_name: "after.pdf".into(), info: vec![("title".into(), "After".into())], ..Default::default() }; + assert_eq!(session.run(next, &[]).result.as_deref(), Some("7/after.pdf/After/1/kept")); + let mut removed = job("numPages===0 && documentFileName==='' && typeof info.title==='undefined' && buffer==='kept';"); + removed.doc = DocInfo::default(); + assert_eq!(session.run(removed, &[]).result.as_deref(), Some("true")); + } + + /// Observe real address-space growth for four simultaneously live document workers. + #[cfg(target_os = "linux")] + #[test] + fn persistent_workers_do_not_reserve_a_gibibyte_per_document() { + const CHILD: &str = "PDFCRAFT_WORKER_RESERVATION_TEST_CHILD"; + if std::env::var_os(CHILD).is_none() { + let result = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "session::tests::persistent_workers_do_not_reserve_a_gibibyte_per_document", "--nocapture"]) + .env(CHILD, "1") + .output() + .unwrap(); + assert!(result.status.success(), "reservation child failed: {}", String::from_utf8_lossy(&result.stderr)); + eprintln!("{}", String::from_utf8_lossy(&result.stderr)); + return; + } + /// Read the kernel's virtual-size metric, in bytes, for this isolated test process. + fn virtual_bytes() -> u64 { + let status = std::fs::read_to_string("/proc/self/status").unwrap(); + status.lines().find_map(|line| line.strip_prefix("VmSize:")?.split_whitespace().next()?.parse::().ok()).unwrap() * 1024 + } + let before = virtual_bytes(); + let mut workers: Vec<_> = (0..4).map(|_| ScriptSession::default()).collect(); + for worker in &mut workers { + assert_eq!(worker.run(job("digit('1');"), &[]).result.as_deref(), Some("1")); + } + let growth = virtual_bytes().saturating_sub(before); + eprintln!("four live document workers: virtual address-space growth {growth} bytes"); + assert!(growth < 768 * 1024 * 1024, "four workers reserved {growth} bytes"); + for worker in &mut workers { + assert_eq!(worker.run(job("digit('2');"), &[]).result.as_deref(), Some("12")); + } + } + + /// Run parser/compiler boundary cases in a child process so a stack abort is observable. + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn document_worker_contains_hostile_and_boundary_scripts() { + const CHILD: &str = "PDFCRAFT_WORKER_STACK_TEST_CHILD"; + if std::env::var_os(CHILD).is_none() { + let result = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "session::tests::document_worker_contains_hostile_and_boundary_scripts", "--nocapture"]) + .env(CHILD, "1") + .output() + .unwrap(); + assert!(result.status.success(), "worker child failed: {}", String::from_utf8_lossy(&result.stderr)); + return; + } + let mut worker = ScriptSession::default(); + for script in [ + format!("{}1{}", "(".repeat(100_000), ")".repeat(100_000)), + format!("{}1", "!".repeat(100_000)), + format!("{}1", "1?1:".repeat(10_000)), + format!("{}1", "a=>".repeat(100_000)), + format!("{}1", "a=>".repeat(70_000)), + format!("{}1", "a=>".repeat(257)), + "function recursive(){return recursive();} recursive();".into(), + "while(true){}".into(), + ] { + assert!(worker.run(job(&script), &[]).error.is_some()); + } + for (script, expected) in [ + (format!("{}1{}", "(".repeat(64), ")".repeat(64)), "1"), + (format!("1{}", "+1".repeat(50_000)), "50001"), + (format!("var a;{}1;true;", "a=".repeat(4096)), "true"), + (format!("{}1;true;", "1**".repeat(4096)), "true"), + (format!("{}1;true;", "void ".repeat(64)), "true"), + (format!("{}1;true;", "typeof ".repeat(64)), "true"), + (format!("{}1", "false?1:".repeat(4096)), "1"), + (format!("{}1", "!".repeat(64)), "true"), + (format!("typeof ({}1)", "a=>".repeat(256)), "function"), + (format!("[{}].length", vec!["()=>1"; 256].join(",")), "256"), + ("function bounded(n){return n?bounded(n-1)+1:0;} bounded(32);".into(), "32"), + ] { + let result = worker.run(job(&script), &[]); + assert_eq!(result.error, None); + assert_eq!(result.result.as_deref(), Some(expected)); + } + assert_eq!(worker.run(job("digit('7');"), &[]).result.as_deref(), Some("7")); + } + + /// Isolate unbracketed parser recursion families, which evade bracket-depth checks. + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn document_worker_bounds_unbracketed_expression_chains() { + const CHILD: &str = "PDFCRAFT_EXPRESSION_CHAIN_CHILD"; + let Ok(case) = std::env::var(CHILD) else { + for case in 0..5 { + let result = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "session::tests::document_worker_bounds_unbracketed_expression_chains", "--nocapture"]) + .env(CHILD, case.to_string()) + .output() + .unwrap(); + assert!(result.status.success(), "expression family {case}: {}", String::from_utf8_lossy(&result.stderr)); + } + return; + }; + let script = match case.as_str() { + "0" => format!("var a;{}1", "a=".repeat(70_000)), + "1" => format!("{}1", "1**".repeat(60_000)), + "2" => format!("{}1", "void ".repeat(40_000)), + "3" => format!("{}Object", "new ".repeat(50_000)), + _ => format!("var a;{}{}{}1", "a=".repeat(4096), "false?1:".repeat(4096), "1**".repeat(4096)), + }; + let mut worker = ScriptSession::default(); + let outcome = worker.run(job(&script), &[]); + if case == "4" { + // Combining individually permitted boundary chains must also fit the worker. + assert_eq!(outcome.error, None); + assert_eq!(outcome.result.as_deref(), Some("1")); + } else { + assert!(outcome.error.is_some(), "over-budget chain was admitted"); + } + assert_eq!(worker.run(job("digit('8');"), &[]).result.as_deref(), Some("8")); + } + + /// Independent statements and comparisons must not consume a source-wide nesting budget. + #[test] + fn document_worker_accepts_large_flat_libraries() { + let scripts = [ + format!("var a=1,b=0;{}b;", "if(a===1){b=2;}".repeat(2000)), + format!("{}f299();", (0..300).map(|i| format!("const f{i}=()=>1;")).collect::()), + format!("{}f299()", (0..300).map(|i| format!("const f{i}=()=>1\n")).collect::()), + format!("var a=0\n{}a", "a=2**3\n".repeat(6000)), + format!("var a=0;{}a;", "a+=1;".repeat(6000)), + format!("var a=0;{}a;", "a=2**3;".repeat(6000)), + // Within the nesting guard's 4,096 conditionals. + format!("var a=0;{}a;", "a=(1===1)?1:2;".repeat(4000)), + ]; + for (script, expected) in scripts.into_iter().zip(["2", "1", "1", "8", "6000", "8", "1"]) { + let mut worker = ScriptSession::default(); + let result = worker.run(job(&script), &[]); + assert_eq!(result.error, None); + assert_eq!(result.result.as_deref(), Some(expected)); + } + let block = format!("function library(){{var x=0\n{}return x}}\nlibrary()", "x=1\n".repeat(6000)); + assert_eq!(ScriptSession::default().run(job(&block), &[]).result.as_deref(), Some("1")); + let mut document = job("f299();"); + document.doc_scripts.push((0..300).map(|i| format!("const f{i}=()=>1\n")).collect::()); + let result = ScriptSession::default().run(document, &[]); + assert_eq!(result.error, None); + assert!(result.doc_errors.is_empty()); + assert_eq!(result.result.as_deref(), Some("1")); + } + + #[test] + /// Retaining the realm must not re-enable eval or dynamic Function construction. + fn persistent_context_keeps_dynamic_compilation_blocked() { + let mut a = ScriptSession::default(); + assert!(a.run(job("eval('1')"), &[]).error.is_some()); + assert!(a.run(job("new Function('return 1')()"), &[]).error.is_some()); + } + /// The completion itself must not release the realm before its caller accepts the outcome. + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn completion_requires_acknowledgement_before_the_next_job() { + let mut session = ScriptSession::default(); + let rx = session.start(job("buffer='pending';")).unwrap(); + assert!(matches!(rx.recv_timeout(std::time::Duration::from_secs(2)).unwrap(), InteractiveEvent::Complete(_))); + assert!(session.run(job("buffer"), &[]).error.is_some()); + session.acknowledge_completion(); + assert_eq!(session.run(job("buffer"), &[]).result.as_deref(), Some("pending")); + } + /// Multiple prompt round trips share one wall-clock budget. + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn synchronous_prompt_waits_share_one_deadline() { + use std::time::{Duration, Instant}; + let (tx, rx) = mpsc::channel(); + let producer = std::thread::spawn(move || { + for _ in 0..3 { + std::thread::sleep(Duration::from_millis(50)); + let (answer, reply) = mpsc::channel(); + if tx.send(InteractiveEvent::Prompt(crate::interaction::Prompt::Menu(Vec::new()), answer)).is_err() { + return; + } + if reply.recv_timeout(Duration::from_secs(1)).is_err() { + return; + } + } + let _ = tx.send(InteractiveEvent::Complete(Outcome::default())); + }); + assert!(ScriptSession::receive(&rx, &[], Instant::now(), Duration::from_millis(80)).is_none()); + drop(rx); + producer.join().unwrap(); + } + /// A timed-out invocation cannot seed globals/custom Field properties in its replacement realm. + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn timed_out_jobs_never_share_the_next_realm() { + let mut session = ScriptSession::default(); + use std::time::{Duration, Instant}; + let out = session.run_with_limit(job("buffer='late';held.custom=99;"), &[], Duration::ZERO); + assert!(out.execution_refused); + let deadline = Instant::now() + Duration::from_secs(2); + while session.worker_lifetime.upgrade().is_some() { + assert!(Instant::now() < deadline); + std::thread::sleep(Duration::from_millis(1)); + } + assert_eq!(session.run(job("buffer+'/'+typeof held.custom"), &[]).result.as_deref(), Some("/undefined")); + } + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn a_detached_worker_holds_the_documents_slot_until_it_exits() { + let worker = Arc::new(()); + let mut session = ScriptSession { worker_lifetime: Arc::downgrade(&worker), ..Default::default() }; + let out = session.run(job("1+1"), &[]); + assert!(out.execution_refused); + assert!(session.sender.is_none()); + drop(worker); + assert_eq!(session.run(job("1+1"), &[]).result.as_deref(), Some("2")); + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn english_retry_is_isolated_and_obeys_the_remaining_time() { + use std::time::Duration; + let mut session = ScriptSession::default(); + assert_eq!(session.run(job("var retained = 17; retained;"), &[]).result.as_deref(), Some("17")); + let retry = session.run_english_retry(job("var retryOnly = 1; typeof retained;"), Duration::from_secs(2)); + assert_eq!(retry.result.as_deref(), Some("undefined")); + assert_eq!(session.run(job("retained + '/' + typeof retryOnly;"), &[]).result.as_deref(), Some("17/undefined")); + let expired = session.run_english_retry(job("1;"), Duration::ZERO); + assert!(expired.execution_refused); + } + + #[test] + fn icon_tracking_overflow_preserves_handles_until_history_changes() { + let mut session = ScriptSession::default(); + Arc::make_mut(&mut session.icon_versions).extend((1..=16_384).map(|id| (id, 0))); + let mut capture = job("var oldIcon = getField('go').buttonGetIcon();"); + Arc::make_mut(&mut capture.fields)[0].icon = Some(16_385); + assert_eq!(session.run(capture.clone(), &[]).error, None); + capture.script = "getField('go').buttonSetIcon(oldIcon);".into(); + assert_eq!(session.run(capture.clone(), &[]).error, None); + session.retire_changed_icons(|_| false); + assert!(session.run(capture, &[]).error.is_some()); + } + + #[test] + fn history_retires_icons_without_discarding_the_document_globals() { + let mut session = ScriptSession::default(); + let mut capture = job("var oldIcon = getField('go').buttonGetIcon(); buffer='retained';"); + Arc::make_mut(&mut capture.fields)[0].icon = Some(17); + assert_eq!(session.run(capture.clone(), &[]).error, None); + capture.script = "getField('go').buttonSetIcon(oldIcon);".into(); + assert_eq!(session.run(capture.clone(), &[]).error, None); + session.retire_changed_icons(|id| id == 18); + assert_eq!(session.run(capture.clone(), &[]).error, None); + session.retire_changed_icons(|id| id == 17); + assert!(session.run(capture.clone(), &[]).error.is_some()); + capture.script = "buffer;".into(); + assert_eq!(session.run(capture, &[]).result.as_deref(), Some("retained")); + } + + /// Exercise the actual process-wide cap in isolation, then recover a slot without resetting other documents. + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn document_worker_capacity_is_bounded_and_reusable() { + const CHILD: &str = "PDFCRAFT_WORKER_CAPACITY_CHILD"; + if std::env::var_os(CHILD).is_none() { + let out = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "session::tests::document_worker_capacity_is_bounded_and_reusable"]) + .env(CHILD, "1") + .output() + .unwrap(); + assert!(out.status.success(), "{} {}", String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr)); + return; + } + let mut workers = Vec::new(); + for _ in 0..MAX_DOCUMENT_WORKERS { + let mut worker = ScriptSession::default(); + assert_eq!(worker.run(job("digit('1')"), &[]).result.as_deref(), Some("1")); + workers.push(worker); + } + let mut extra = ScriptSession::default(); + assert!(extra.start(job("1")).unwrap_err().contains("worker limit")); + drop(workers.pop()); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + while LIVE_WORKERS.load(std::sync::atomic::Ordering::SeqCst) == MAX_DOCUMENT_WORKERS { + assert!(std::time::Instant::now() < deadline); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert_eq!(extra.run(job("digit('2')"), &[]).result.as_deref(), Some("2")); + for worker in &mut workers { + assert_eq!(worker.run(job("digit('3')"), &[]).result.as_deref(), Some("13")); + } + } + /// Reset effects are visible during Doc Open and to the first event, including retained Field objects. + #[test] + fn doc_open_reset_precedes_the_first_event_and_later_writes_win() { + for reset in ["resetForm();", "resetForm(['group']);"] { + let mut source = FieldState::new("group.source", crate::FieldType::Text, vec!["old".into()]); + source.default = vec!["new".into()]; + let dest = FieldState::new("dest", crate::FieldType::Text, vec!["before".into()]); + let mut task = job("getField('dest').value=held.value;held.value='old';openedValue+'/'+getField('dest').value"); + task.fields = Arc::new(vec![source, dest]); + task.doc_scripts = vec![format!("var held=getField('group.source');{reset}var openedValue=held.value;")]; + let out = ScriptSession::default().run(task, &[]); + assert_eq!(out.error, None); + assert_eq!(out.result.as_deref(), Some("new/new")); + assert_eq!(out.changed.iter().find(|f| f.name == "group.source").unwrap().value, ["old"]); + assert_eq!(out.changed.iter().find(|f| f.name == "dest").unwrap().value, ["new"]); + } + } +} + +#[cfg(all(test, not(target_arch = "wasm32")))] +mod reset_budget_tests { + use super::*; + #[test] + fn open_and_first_event_share_reset_budget_but_later_jobs_reset_it() { + let mut runtime = ScriptSession::default(); + let mut job = Job { + script: "resetForm();".into(), + event: Event::doc("Console"), + doc: Default::default(), + fields: Arc::new(vec![]), + doc_scripts: vec!["for(var i=0;i<64;i++)resetForm();".into()], + calculations: vec![], + }; + let out = runtime.run(job.clone(), &[]); + assert!(out.reset_limit_exceeded); + assert_eq!(out.requests.iter().filter(|r| matches!(r, crate::Request::Reset(_))).count(), 64); + job.script = "resetForm();true;".into(); + let out = runtime.run(job, &[]); + assert!(!out.reset_limit_exceeded); + assert_eq!(out.error, None); + assert_eq!(out.result.as_deref(), Some("true")); + } +} + +#[cfg(all(test, not(target_arch = "wasm32")))] +mod event_identity_tests { + use super::*; + #[test] + fn ordinary_button_event_target_reuses_public_field_object() { + let mut field = FieldState::new("go", crate::FieldType::Button, vec![]); + field.object_id = Some(17); + let job = Job { + script: "event.target===getField('go');".into(), + event: Event::field("Mouse Up", "go", ""), + doc: Default::default(), + fields: Arc::new(vec![field]), + doc_scripts: vec![], + calculations: vec![], + }; + let out = ScriptSession::default().run(job, &[]); + assert_eq!(out.error, None); + assert_eq!(out.result.as_deref(), Some("true")); + } +} + +#[cfg(all(test, not(target_arch = "wasm32")))] +mod cache_capacity_tests { + use super::*; + #[test] + fn every_exposed_field_keeps_identity_beyond_4096_entries() { + let fields = Arc::new( + (0..4100) + .map(|i| { + let mut f = FieldState::new(format!("f{i}"), crate::FieldType::Text, vec![]); + f.object_id = Some(i); + f + }) + .collect(), + ); + let job=Job{script:"for(var i=0;i<4100;i++)getField('f'+i);var held=getField('f4099');held.custom=42;held===getField('f4099') && getField('f4099').custom===42;".into(),event:Event::doc("Console"),doc:Default::default(),fields,doc_scripts:vec![],calculations:vec![]}; + let mut session = ScriptSession::default(); + let out = session.run(job.clone(), &[]); + assert_eq!(out.error, None); + assert_eq!(out.result.as_deref(), Some("true")); + let out = session.run(Job { script: "held===getField('f4099') && held.custom===42;".into(), ..job }, &[]); + assert_eq!(out.result.as_deref(), Some("true")); + } +} + +#[cfg(test)] +mod cache_lifecycle_tests { + use super::*; + #[test] + fn cache_follows_object_renames_and_prunes_removed_fields() { + let mut field = FieldState::new("old", crate::FieldType::Text, vec![]); + field.object_id = Some(17); + let mut job = Job { + script: "var held=getField('old');held.custom=42;".into(), + event: Event::doc("Console"), + doc: Default::default(), + fields: Arc::new(vec![field.clone()]), + doc_scripts: vec![], + calculations: vec![], + }; + let mut realm = Realm::new(); + assert_eq!(realm.run(job.clone()).error, None); + for i in 0..16 { + field.name = format!("renamed{i}"); + job.fields = Arc::new(vec![field.clone()]); + job.script = format!("held.setFocus();held===getField('{}') && held.name==='{}' && held.custom===42;", field.name, field.name); + let out = realm.run(job.clone()); + assert_eq!(out.error, None); + assert_eq!(out.result.as_deref(), Some("true")); + assert_eq!(out.requests, [crate::Request::FocusField { name: field.name.clone(), object_id: 17 }]); + assert_eq!(crate::host::host(realm.context.as_ref().unwrap()).unwrap().borrow().objects.len(), 1); + } + field.object_id = Some(18); + field.name = "replacement".into(); + job.fields = Arc::new(vec![field]); + job.script = "getField('replacement');".into(); + assert_eq!(realm.run(job.clone()).error, None); + assert_eq!(crate::host::host(realm.context.as_ref().unwrap()).unwrap().borrow().objects.len(), 1); + job.script = "held.value;".into(); + assert!(realm.run(job).error.is_some()); + } +} + +#[cfg(test)] +mod review13_tests { + use super::*; + use crate::FieldType; + /// Build isolated jobs without document initialization side effects. + fn job(script: &str, fields: Vec) -> Job { + Job { + script: script.into(), + event: Event::doc("Test"), + doc: DocInfo::default(), + fields: Arc::new(fields), + doc_scripts: vec![], + calculations: vec![], + } + } + /// A retired accessor must not resolve a replacement even if no job observed the deletion. + #[test] + fn field_instances_retire_between_jobs_without_losing_globals() { + let mut session = ScriptSession::default(); + let mut f = FieldState::new("old", FieldType::Text, vec!["original".into()]); + f.object_id = Some(7); + assert_eq!(session.run(job("var held=getField('old');held.custom=42;var kept=99;", vec![f.clone()]), &[]).error, None); + session.sync_field_ids([]); + session.sync_field_ids([7]); + f.name = "replacement".into(); + let out = + session.run(job("kept===99 && getField('replacement')!==held && getField('replacement').custom===undefined;", vec![f.clone()]), &[]); + assert_eq!(out.result.as_deref(), Some("true")); + for script in ["held.value;", "held.value='wrong';", "held.setFocus();"] { + let out = session.run(job(script, vec![f.clone()]), &[]); + assert!(out.error.is_some()); + assert!(out.changed.is_empty()); + } + } + /// Frozen globals still expose each new event and permit nested calculation event restoration. + #[test] + fn frozen_global_keeps_live_event_binding() { + let mut session = ScriptSession::default(); + let f = FieldState::new("amount", FieldType::Text, vec!["2".into()]); + let mut first = job("event.name", vec![f.clone()]); + first.doc_scripts = vec!["Object.freeze(globalThis);".into()]; + assert_eq!(session.run(first.clone(), &[]).result.as_deref(), Some("Test")); + first.event = Event::doc("Next"); + first.script = "calculateNow();event.name".into(); + first.calculations = vec![(0, "event.value='3';".into())]; + let out = session.run(first, &[]); + assert_eq!(out.error, None); + assert_eq!(out.result.as_deref(), Some("Next")); + assert_eq!(out.changed[0].value, ["3"]); + } + /// Calculation values follow the existing setter normalization, including unchecked empty boxes. + #[test] + fn calculate_now_normalizes_checkbox_but_preserves_text_and_combo_empty_values() { + let mut session = ScriptSession::default(); + let fields = vec![ + FieldState::new("box", FieldType::CheckBox, vec!["Yes".into()]), + FieldState::new("text", FieldType::Text, vec!["old".into()]), + FieldState::new("combo", FieldType::ComboBox, vec!["old".into()]), + ]; + let mut j = job("calculateNow();getField('box').value", fields); + j.calculations = (0..3).map(|i| (i, "event.value='';".into())).collect(); + let out = session.run(j, &[]); + assert_eq!(out.error, None); + assert_eq!(out.result.as_deref(), Some("Off")); + assert!(out.changed[0].value.is_empty()); + assert_eq!(out.changed[1].value, [""]); + assert_eq!(out.changed[2].value, [""]); + } + /// Small individual lookups cannot multiply unbounded work across calculation sources. + #[test] + fn calculation_sources_share_one_job_budget_and_later_jobs_reset_it() { + let mut session = ScriptSession::default(); + let fields: Vec<_> = (0..6).map(|i| FieldState::new(format!("f{i}"), FieldType::Text, vec!["1".into()])).collect(); + let mut j = job("calculateNow();", fields.clone()); + j.calculations = (0..6).map(|i| (i, "for(var q=0;q<3;q++)AFSimple_Calculate('SUM',new Array(1024).fill('x'.repeat(256)));".into())).collect(); + let out = session.run(j, &[]); + assert!(out.error.as_deref().is_some_and(|e| e.contains("work limit")), "{:?}", out.error); + assert_eq!(session.run(job("AFSimple_Calculate('SUM',['f0']);", fields), &[]).result.as_deref(), Some("1")); + } + #[test] + fn calculate_now_continues_after_an_ordinary_field_error() { + let mut session = ScriptSession::default(); + let fields = ["bad", "good"].map(|name| FieldState::new(name, FieldType::Text, vec!["0".into()])).to_vec(); + let mut j = job("calculateNow(); event.name + ':' + getField('good').value", fields); + j.calculations = vec![(0, "throw new Error('broken formula');".into()), (1, "event.value='42';".into())]; + let out = session.run(j, &[]); + assert_eq!(out.error, None); + assert_eq!(out.result.as_deref(), Some("Test:42")); + assert!(out.console.iter().any(|line| line.contains("broken formula"))); + } + + #[test] + fn caught_calculation_exhaustion_stays_sticky_until_the_next_job() { + let mut session = ScriptSession::default(); + let fields = vec![FieldState::new("amount", FieldType::Text, vec!["0".into()])]; + let mut j = job("for(var i=0;i<4097;i++){try{calculateNow()}catch(e){}} try{calculateNow()}catch(e){} count;", fields.clone()); + j.calculations = vec![(0, "count += 1;".into())]; + j.doc_scripts = vec!["var count = 0;".into()]; + let out = session.run(j.clone(), &[]); + assert!(out.calculation_limit_exceeded); + assert!(out.error.as_deref().is_some_and(|e| e.contains("calculation work limit"))); + assert_eq!(out.result.as_deref(), Some("4096")); + j.script = "calculateNow();count".into(); + let next = session.run(j, &[]); + assert!(!next.calculation_limit_exceeded); + assert_eq!(next.result.as_deref(), Some("4097")); + } +} diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs index 8f887ce5f9..c79e609967 100644 --- a/crates/js/src/tests.rs +++ b/crates/js/src/tests.rs @@ -17,7 +17,7 @@ fn fields() -> Vec { ] } -/// 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::().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"))); +} diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 0e3313ce74..3a1d678d83 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -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. diff --git a/crates/ui-egui/src/app/keys.rs b/crates/ui-egui/src/app/keys.rs index 7894643a4b..7471c2f69f 100644 --- a/crates/ui-egui/src/app/keys.rs +++ b/crates/ui-egui/src/app/keys.rs @@ -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() diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index f6afc0327c..90840225ef 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -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"); diff --git a/crates/ui-egui/src/canvas/area.rs b/crates/ui-egui/src/canvas/area.rs index 724e5270a5..daaf88a9c6 100644 --- a/crates/ui-egui/src/canvas/area.rs +++ b/crates/ui-egui/src/canvas/area.rs @@ -161,7 +161,7 @@ pub(super) fn page_scroll( } /// 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); } } } diff --git a/crates/ui-egui/src/canvas/sign_tab_tests.rs b/crates/ui-egui/src/canvas/sign_tab_tests.rs index 60a57f8137..c2d5c20748 100644 --- a/crates/ui-egui/src/canvas/sign_tab_tests.rs +++ b/crates/ui-egui/src/canvas/sign_tab_tests.rs @@ -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, diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 1d768e72a2..8316b8bf3d 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -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. diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index 29180aeab7..df8f77741c 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -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 { .. } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index deca4e34a5..3153575ba1 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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, /// The JavaScript console and the Document JavaScripts draft. pub js_console: js_ui::JsConsole, + #[cfg(not(target_arch = "wasm32"))] + pub(crate) script_pending: Option, pub doc_js: js_ui::DocJsDraft, pub a11y: a11y_ui::A11yState, pub(crate) tags_state: tags_ui::State, diff --git a/crates/ui-egui/src/script_dialog.rs b/crates/ui-egui/src/script_dialog.rs new file mode 100644 index 0000000000..2249f7bfd7 --- /dev/null +++ b/crates/ui-egui/src/script_dialog.rs @@ -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>)>, + 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) { + 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, 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) { + 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>>, + ) { + 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"); + } +} diff --git a/crates/ui-egui/src/tools/content_ui.rs b/crates/ui-egui/src/tools/content_ui.rs index 9393ca8bde..884b32ec98 100644 --- a/crates/ui-egui/src/tools/content_ui.rs +++ b/crates/ui-egui/src/tools/content_ui.rs @@ -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) { + 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)]); } diff --git a/crates/ui-egui/src/tools/forms_ui.rs b/crates/ui-egui/src/tools/forms_ui.rs index ec6d85150c..50d4cba0bc 100644 --- a/crates/ui-egui/src/tools/forms_ui.rs +++ b/crates/ui-egui/src/tools/forms_ui.rs @@ -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, - /// 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) -> 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 { 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 { } } +/// 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, bool, Option) { + 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 = 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 { diff --git a/crates/ui-egui/src/workflows/js_ui.rs b/crates/ui-egui/src/workflows/js_ui.rs index 2ac0d7b127..3547c511f2 100644 --- a/crates/ui-egui/src/workflows/js_ui.rs +++ b/crates/ui-egui/src/workflows/js_ui.rs @@ -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, name: &str, object_id: Option) { + 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, 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()) diff --git a/crates/ui-egui/tests/it/control.rs b/crates/ui-egui/tests/it/control.rs index dcb795f130..5911276708 100644 --- a/crates/ui-egui/tests/it/control.rs +++ b/crates/ui-egui/tests/it/control.rs @@ -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] diff --git a/crates/ui-egui/tests/it/document_links.rs b/crates/ui-egui/tests/it/document_links.rs index 341a07526d..3690abcea9 100644 --- a/crates/ui-egui/tests/it/document_links.rs +++ b/crates/ui-egui/tests/it/document_links.rs @@ -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 } diff --git a/crates/ui-egui/tests/it/forms.rs b/crates/ui-egui/tests/it/forms.rs index 31595def46..3318e1332f 100644 --- a/crates/ui-egui/tests/it/forms.rs +++ b/crates/ui-egui/tests/it/forms.rs @@ -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). diff --git a/crates/ui-egui/tests/it/javascript.rs b/crates/ui-egui/tests/it/javascript.rs index b4aaa4224f..7ed9c784f7 100644 --- a/crates/ui-egui/tests/it/javascript.rs +++ b/crates/ui-egui/tests/it/javascript.rs @@ -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 { + form_with_text_action("") +} + +fn form_with_text_action(action: &str) -> Vec { + 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::>() + }; + 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::>(), + [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::>() + }; + 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(); +} diff --git a/crates/xfa/src/lib.rs b/crates/xfa/src/lib.rs index 4e99093bc8..31c59351ed 100644 --- a/crates/xfa/src/lib.rs +++ b/crates/xfa/src/lib.rs @@ -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)] diff --git a/crates/xfa/src/script.rs b/crates/xfa/src/script.rs index b2cfbab74c..eaaf3aad14 100644 --- a/crates/xfa/src/script.rs +++ b/crates/xfa/src/script.rs @@ -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 { + 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 { // 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 { // long `/Kids` array stay bounded too; `walk_all` returns false once it is spent, and every // loop then stops. struct Walk { - out: HashMap, + out: HashMap, /// The shallowest depth each object number was read at. seen: HashMap, steps: usize, @@ -636,7 +641,7 @@ pub fn fields_by_som(doc: &Document) -> HashMap { 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)); diff --git a/docs/acroform-script-workflows.md b/docs/acroform-script-workflows.md new file mode 100644 index 0000000000..200a59966a --- /dev/null +++ b/docs/acroform-script-workflows.md @@ -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. diff --git a/docs/roadmap.md b/docs/roadmap.md index b41ba771e2..e276a51f44 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -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. diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index a25e77d178..1019a4a811 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -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"