diff --git a/crates/automation/src/a11y.rs b/crates/automation/src/a11y.rs index aa9ad55d..2a853083 100644 --- a/crates/automation/src/a11y.rs +++ b/crates/automation/src/a11y.rs @@ -211,6 +211,7 @@ fn request_json(r: &pdfcraft_engine::js::Request) -> Value { R::Submit(u) => json!({ "submit": u }), R::Focus(f) => json!({ "focus": f }), R::Beep => json!({ "beep": true }), + R::SaveAs => json!({ "save_as": true }), } } diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index ab062272..312b84cf 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -771,7 +771,7 @@ 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). The form object model is available: this/getField, event, app, util, console, display, color, and the document-level scripts. Field changes and resetForm are applied as one undoable step; returns the script's alerts, console output, requests (print, page, url, submit) and error.") + 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, 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. Field changes and resetForm are applied as one undoable step; returns the script's alerts, console output, requests (print, page, url, submit) and error.") .cmd("tools.js_console") .with(schema( json!({ "doc": doc(), "script": { "type": "string" }, "field": { "type": "string", "description": "Run as this button's Mouse Up event." } }), diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 08ede137..3dfce4a9 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -2023,3 +2023,32 @@ fn measurement_snap_tool_covers_all_targets() { assert!(ok(&mut a, "measure_snap", json!({"doc":doc,"page":1,"at":[180,180]}))["snap"].is_null()); let _ = std::fs::remove_dir_all(dir); } + +#[test] +fn xfa_scripts_run_for_buttons_and_field_changes_through_tools() { + let dir = workdir("xfa-scripts"); + std::fs::write(dir.join("scripted.pdf"), pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::scripted_template())).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "scripted.pdf" }))["doc"].as_u64().unwrap(); + let field = |a: &mut Automation, n: &str| { + let f = ok(a, "form_fields", json!({ "doc": doc })); + f["fields"].as_array().unwrap().iter().find(|f| f["name"] == n).cloned() + }; + // Opening ran the initialize and calculate scripts. + assert_eq!(field(&mut a, "qty").unwrap()["value"], "2"); + assert_eq!(field(&mut a, "total").unwrap()["value"], "10"); + // Filling recalculates; a bad value shows its message (the tool reports alerts in js output? no: it is applied, the value stays). + ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "qty": "4" } })); + assert_eq!(field(&mut a, "total").unwrap()["value"], "20"); + // A button's XFA click script runs through js_run, like any button. + let before = ok(&mut a, "doc_info", json!({ "doc": doc }))["xfa_layout"]["fields"].as_u64().unwrap(); + let r = ok(&mut a, "js_run", json!({ "doc": doc, "script": "", "field": "addRow" })); + assert!(r["error"].is_null(), "{r}"); + assert!(field(&mut a, "amount_2").is_some()); + assert_eq!(ok(&mut a, "doc_info", json!({ "doc": doc }))["xfa_layout"]["fields"].as_u64().unwrap(), before + 2); + // Undo takes the row away again. + ok(&mut a, "edit_undo", json!({ "doc": doc })); + assert!(field(&mut a, "amount_2").is_none()); + let r = ok(&mut a, "js_run", json!({ "doc": doc, "script": "", "field": "hello" })); + assert_eq!(r["alerts"], json!(["Hello 4"])); +} diff --git a/crates/engine/src/js.rs b/crates/engine/src/js.rs index 809c44cc..d636c303 100644 --- a/crates/engine/src/js.rs +++ b/crates/engine/src/js.rs @@ -189,6 +189,22 @@ impl Session { } 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. + if let Some(t) = target + && let Some(tpl) = doc.xfa_template.clone() + && let Some(som) = crate::xfa::clickable_som(&cos, &tpl, t) + { + self.apply(id, Edit::XfaEvent { som, activity: "click".into() })?; + let out = self.take_js_output(id); + return Ok(Outcome { + rc: true, + alerts: out.alerts, + console: out.console, + requests: out.requests, + error: out.errors.first().cloned(), + ..Default::default() + }); + } let runner = JsRunner::new(&cos, &doc.name); let fields = pdfcraft_forms::fields(&cos); let states: Vec<_> = fields.iter().map(|f| field_state(&cos, f)).collect(); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index a9f41dde..cfc5d040 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -20,6 +20,7 @@ pub mod export; pub mod js; pub mod links; pub mod ocr; +pub mod xfa; pub use pdfcraft_organize::{BoxSpec, PageBox, SplitBy, split_ranges}; @@ -222,6 +223,8 @@ pub struct Document { /// 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, + /// The parsed template of a laid-out XFA form, for its scripts. + xfa_template: Option>, /// XFA forms: what was approximated, rewritten or could not be written to the XFA data /// (also in `info.warnings`, kept there when the document is re-read). pub xfa_warnings: Vec, @@ -876,6 +879,12 @@ pub enum Edit { ApplyScriptChanges { changes: Vec, }, + /// An XFA form's scripted event (a button's `click`, say) on the object at `som`: what the + /// script changes (values, rows, visibility) is applied and the form laid out again. + XfaEvent { + som: String, + activity: String, + }, /// Scan & OCR ▸ Recognize text: put recognised words on `page` as invisible text over the /// image (from [`ocr::OcrJob::run`]). AddOcrText { @@ -1074,6 +1083,7 @@ impl Edit { Edit::MarkDecorative { .. } => "Mark figure as decorative".into(), Edit::AddOcrText { .. } => "Recognize text".into(), Edit::ApplyScriptChanges { .. } => "Run JavaScript".into(), + Edit::XfaEvent { .. } => "Run form script".into(), Edit::ConvertPdfA { level } => format!("Save as {}", level.label()), Edit::SetFieldScript { name, .. } => format!("Edit script of {name}"), Edit::SetDocumentScript { script: None, .. } => "Delete document JavaScript".into(), @@ -1204,7 +1214,7 @@ fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Result<(), Ed Err(EditError::NotPermitted("comments")) } } - Edit::SetFieldValue { .. } | Edit::ResetForm { .. } | Edit::SetFieldImage { .. } | Edit::ApplyScriptChanges { .. } => { + Edit::SetFieldValue { .. } | Edit::ResetForm { .. } | Edit::SetFieldImage { .. } | Edit::ApplyScriptChanges { .. } | Edit::XfaEvent { .. } => { if p.fill_forms() { Ok(()) } else { @@ -1269,6 +1279,16 @@ fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Result<(), Ed struct EditCtx { /// Field scripts run (`None`: JavaScript is off) and what they produced. js: Option, + /// A laid-out XFA form's template (`None`: not an XFA form, or JavaScript is off) and what + /// its scripts produced. + xfa: Option>, + xfa_out: js::JsOutput, + /// The datasets stream this edit wrote (later writes in the edit replace it in place). + xfa_datasets: Option, + /// A form script ran too long and was abandoned: the document's scripts go off. + xfa_ran_away: bool, + /// Pages in the document, for `xfa.layout.pageCount()`. + pages: usize, date: Option, /// Today in local time, for date tokens. today: (i64, u32, u32), @@ -1287,7 +1307,18 @@ impl EditCtx { // Real clock: mix in sub-second time so ids from two sessions don't collide. seed ^= std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.subsec_nanos() as u64).unwrap_or(0) << 32; } - Self { js: None, date: now.map(pdfcraft_cos::pdf_date), today: (1970, 1, 1), seed, count: 0 } + Self { + js: None, + xfa: None, + xfa_out: Default::default(), + xfa_datasets: None, + xfa_ran_away: false, + pages: 0, + date: now.map(pdfcraft_cos::pdf_date), + today: (1970, 1, 1), + seed, + count: 0, + } } /// 32 bytes of entropy for new encryption keys and salts. `RandomState` is seeded by the @@ -1417,6 +1448,14 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> Some(js) => pdfcraft_forms::apply_script_changes(doc, changes, js)?, None => pdfcraft_forms::apply_script_changes(doc, changes, &mut pdfcraft_forms::NoScripts)?, }, + Edit::XfaEvent { som, activity } => { + let tpl = cx.xfa.clone().ok_or_else(|| EditError::Invalid("this is not a laid-out XFA form, or JavaScript is off".into()))?; + let mut run = xfa::XfaRun { page_count: cx.pages, out: &mut cx.xfa_out, datasets: cx.xfa_datasets, ran_away: false }; + let ran = xfa::on_event(doc, &tpl, som, activity, &mut run); + cx.xfa_datasets = run.datasets; + cx.xfa_ran_away |= run.ran_away; + ran.map_err(EditError::Invalid)?; + } Edit::SetFieldImage { name, image } => { let (img, _) = pdfcraft_create::image_xobject(doc, name, image)?; let px = match &*doc.get(img) { @@ -1778,11 +1817,21 @@ fn xfa_field_data(doc: &pdfcraft_cos::Document) -> Vec /// Keep the XFA datasets packet in step with the fields after an edit. Returns what could not /// be written. -fn xfa_sync_datasets(doc: &mut pdfcraft_cos::Document) -> Result, String> { +/// `datasets`: the stream this edit already wrote, replaced in place rather than added again +/// (and set to the one written). +fn xfa_sync_datasets(doc: &mut pdfcraft_cos::Document, datasets: &mut Option) -> Result, String> { let data = xfa_field_data(doc); - pdfcraft_xfa::write_datasets(doc, &data).map(|r| r.warnings).map_err(|e| e.to_string()) + let r = pdfcraft_xfa::write_datasets_reusing(doc, &data, *datasets).map_err(|e| e.to_string())?; + if r.stream.is_some() { + *datasets = r.stream; + } + Ok(r.warnings) } +/// What a document whose form script ran away is told. +const XFA_SCRIPTS_OFF: &str = + "A script of this form ran too long and was abandoned; the form's scripts are off for this document (reopen it to run them again)"; + /// Most XFA warnings kept per document. const MAX_XFA_WARNINGS: usize = 50; @@ -1967,10 +2016,66 @@ impl Session { } (_, cos) => (bytes, info, cos, None), }; + // A laid-out XFA form: its initialize and calculate scripts run now, as on opening in + // Acrobat; what they change is one more revision, still nothing to save. + let mut script_output = js::JsOutput::default(); + let mut ran_away = false; + let xfa_template = match (&xfa, &cos) { + (Some(_), Ok(Ok(c))) => xfa::template(c), + _ => None, + }; + let (bytes, info, cos) = match (&xfa, cos) { + (Some(_), Ok(Ok(cos))) if !self.js_off => match xfa_template.clone() { + Some(tpl) => { + let mut work = cos.clone(); + let pages = info.pages.len(); + let ran = guard(|| { + let mut run = xfa::XfaRun { page_count: pages, out: &mut script_output, datasets: None, ran_away: false }; + let changes = xfa::on_open(&mut work, &tpl, &mut run); + if run.ran_away { + ran_away = true; + } + changes + }) + .unwrap_or_else(|m| Err(format!("its scripts failed unexpectedly ({m})"))); + match ran { + Ok(changes) if work.is_modified() => match rebase(&work) { + Ok((b, mut i, c)) => { + // The file as saved differs from what opening shows: say so. + if !changes.is_empty() { + i.warnings.push(format!( + "This form's scripts changed it on opening ({}); saving keeps those changes", + changes.describe() + )); + } + (b, i, Ok(Ok(c))) + } + Err(e) => { + script_output.errors.push(format!("The form's scripts could not be applied: {e}")); + (bytes, info, Ok(Ok(cos))) + } + }, + Ok(_) => (bytes, info, Ok(Ok(cos))), + Err(e) => { + script_output.errors.push(format!("The form's scripts could not run: {e}")); + (bytes, info, Ok(Ok(cos))) + } + } + } + None => (bytes, info, Ok(Ok(cos))), + }, + (_, cos) => (bytes, info, cos), + }; // XFA notes survive the document being re-read after edits. let xfa_warnings: Vec = if info.xfa.is_some() { info.warnings.clone() } else { Vec::new() }; let id = self.push_document(name, path, bytes, info, cos, render_password, password, xfa)?; if let Some(d) = self.docs.iter_mut().find(|d| d.id == id) { + // A script that ran away on open turns the form's scripts off for this document. + d.xfa_template = if ran_away { None } else { xfa_template }; + d.js_output.append(script_output); + if ran_away { + d.js_output.errors.push(XFA_SCRIPTS_OFF.into()); + } note_warnings(&mut d.xfa_warnings, &xfa_warnings); } Ok(id) @@ -1999,7 +2104,10 @@ impl Session { Ok(Err(e)) => (None, Some(e.to_string())), Err(_) => (None, Some("the document structure could not be read for editing".into())), }; - let form = editor.as_ref().map(|e| pdfcraft_forms::fields(&e.cos)).unwrap_or_default(); + let mut form = editor.as_ref().map(|e| pdfcraft_forms::fields(&e.cos)).unwrap_or_default(); + if let Some(e) = editor.as_ref() { + xfa::mark_script_buttons(&e.cos, &mut form); + } let marks = editor.as_ref().map(|e| pdfcraft_edit::marks_present(&e.cos)).unwrap_or_default(); let added = editor.as_ref().map(|e| pdfcraft_edit::list_added(&e.cos)).unwrap_or_default(); let links = editor.as_ref().map(|e| pdfcraft_annot::links::list(&e.cos)).unwrap_or_default(); @@ -2030,6 +2138,7 @@ impl Session { config, js_output: Default::default(), xfa, + xfa_template: None, xfa_warnings: Vec::new(), }); Ok(id) @@ -2048,6 +2157,10 @@ impl Session { let name = doc.name.clone(); let is_xfa = doc.info.xfa.is_some(); let mut cx = EditCtx::new(now, doc.generation ^ (id.0 << 48)); + if !js_off { + cx.xfa = doc.xfa_template.clone(); + } + cx.pages = doc.info.pages.len(); cx.today = today; let reason = doc.read_only_reason.clone().unwrap_or_default(); let signed = doc.is_signed(); @@ -2062,10 +2175,46 @@ impl Session { // `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()))))?; + // An XFA form's scripts answer the change: exit and validate scripts of the field, then + // every calculation (and a reset recalculates). They read the data, so it is brought up + // to date first. + if let Some(tpl) = cx.xfa.clone() { + if is_xfa && matches!(scope_of(&edit), Scope::Form | Scope::Full) { + let datasets = &mut cx.xfa_datasets; + let notes = guard(|| xfa_sync_datasets(&mut next, datasets)) + .unwrap_or_else(|m| Err(format!("writing the XFA data failed unexpectedly ({m})"))) + .map_err(EditError::Write)?; + cx.xfa_out.errors.extend(notes); + } + fn collect(e: &Edit, changed: &mut Vec, reset: &mut bool) { + match e { + Edit::SetFieldValue { name, .. } => changed.push(name.clone()), + Edit::ResetForm { .. } => *reset = true, + Edit::Batch { edits, .. } => edits.iter().for_each(|e| collect(e, changed, reset)), + _ => {} + } + } + let mut changed: Vec = Vec::new(); + let mut reset = false; + collect(&edit, &mut changed, &mut reset); + if !changed.is_empty() || reset { + let mut run = xfa::XfaRun { page_count: cx.pages, out: &mut cx.xfa_out, datasets: cx.xfa_datasets, ran_away: false }; + // Every changed field's scripts, then the calculations once (after a reset, + // only those). + let ran = guard(|| xfa::on_changes(&mut next, &tpl, &changed, &mut run)) + .unwrap_or_else(|m| Err(format!("the form's scripts failed unexpectedly ({m})"))); + cx.xfa_datasets = run.datasets; + cx.xfa_ran_away |= run.ran_away; + if let Err(e) = ran { + cx.xfa_out.errors.push(e); + } + } + } // XFA forms keep their values in the datasets packet too, for Adobe's viewers. let mut xfa_notes = Vec::new(); if is_xfa && matches!(scope_of(&edit), Scope::Form | Scope::Full) { - xfa_notes = guard(|| xfa_sync_datasets(&mut next)) + let datasets = &mut cx.xfa_datasets; + xfa_notes = guard(|| xfa_sync_datasets(&mut next, datasets)) .unwrap_or_else(|m| Err(format!("writing the XFA data failed unexpectedly ({m})"))) .map_err(EditError::Write)?; } @@ -2106,6 +2255,11 @@ impl Session { if let Some(js) = cx.js { doc.js_output.append(js.output); } + doc.js_output.append(cx.xfa_out); + if cx.xfa_ran_away { + doc.xfa_template = None; + doc.js_output.errors.push(XFA_SCRIPTS_OFF.into()); + } Ok(()) } @@ -2158,7 +2312,9 @@ impl Session { } else { editor.cos.bytes().clone() }; - let form = Arc::new(pdfcraft_forms::fields(&editor.cos)); + let mut form = pdfcraft_forms::fields(&editor.cos); + xfa::mark_script_buttons(&editor.cos, &mut form); + let form = Arc::new(form); match scope { Scope::Comments => doc.info.annotations = comment_list(&editor.cos), Scope::Form => { @@ -2190,6 +2346,12 @@ impl Session { /// Rebuild working bytes, inspection and renderer from the current edit state. fn refresh(doc: &mut Document) -> Result<(), EditError> { let Some(editor) = doc.editor.as_ref() else { return Ok(()) }; + // A script may have laid an XFA form out again (rows added, subforms shown). + if doc.xfa.is_some() + && let Some(report) = pdfcraft_xfa::existing_layout(&editor.cos) + { + doc.xfa = Some(report); + } let bytes = if editor.cos.is_modified() { Arc::new(write_incremental(&editor.cos, &SaveOptions::default()).map_err(|e| EditError::Write(e.to_string()))?) } else { @@ -2205,7 +2367,9 @@ impl Session { } note_warnings(&mut info.warnings, &doc.xfa_warnings); doc.info = info; - doc.form = Arc::new(pdfcraft_forms::fields(&editor.cos)); + let mut form = pdfcraft_forms::fields(&editor.cos); + xfa::mark_script_buttons(&editor.cos, &mut form); + doc.form = Arc::new(form); doc.marks = pdfcraft_edit::marks_present(&editor.cos); doc.added = pdfcraft_edit::list_added(&editor.cos); doc.links = pdfcraft_annot::links::list(&editor.cos); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 081eed2d..7019de53 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1572,3 +1572,203 @@ fn xfa_data_no_field_binds_to_survives_an_edit_and_save() { assert!(doc.info.warnings.iter().any(|w| w.contains("structured content")), "{:?}", doc.info.warnings); assert!(doc.xfa_warnings.iter().any(|w| w.contains("structured content"))); } + +#[test] +fn xfa_scripts_initialize_calculate_validate_toggle_and_add_rows() { + let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::scripted_template())); + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("scripted.pdf", None, bytes, None).expect("opens"); + let value = |s: &Session, n: &str| { + s.get(id).unwrap().form.iter().find(|f| f.name == n).map(|f| f.value.clone()).unwrap_or_else(|| panic!("no field {n}")) + }; + let names = |s: &Session| s.get(id).unwrap().form.iter().map(|f| f.name.clone()).collect::>(); + // On open: the initialize script set qty, the calculations ran, nothing is dirty. + assert_eq!(value(&s, "qty"), vec!["2".to_string()]); + assert_eq!(value(&s, "total"), vec!["10".to_string()], "{:?}", s.take_js_output(id)); + assert_eq!(value(&s, "grand"), vec!["0".to_string()]); + assert!(!s.get(id).unwrap().dirty); + assert!(!names(&s).iter().any(|n| n == "note"), "details is hidden"); + // A change recalculates. + s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("3".into()) }).unwrap(); + assert_eq!(value(&s, "total"), vec!["15".to_string()], "{:?}", s.take_js_output(id)); + // A failing validate script shows its message; the value stays, as in Acrobat. + s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("500".into()) }).unwrap(); + let out = s.take_js_output(id); + assert!(out.alerts.iter().any(|a| a == "Quantity must be 100 or less"), "{out:?}"); + assert_eq!(value(&s, "total"), vec!["2500".to_string()]); + // A button adds a row: new fields, same page, one undo step. + let before = names(&s).len(); + let o = s.run_javascript(id, "", Some("addRow")).unwrap(); + assert_eq!(o.error, None); + let after = names(&s); + assert_eq!(after.len(), before + 2, "{after:?}"); + assert!(after.iter().any(|n| n == "amount_2")); + assert_eq!(s.get(id).unwrap().can_undo(), Some("Run form script")); + s.apply(id, Edit::SetFieldValue { name: "amount".into(), value: FieldValue::Text("10".into()) }).unwrap(); + s.apply(id, Edit::SetFieldValue { name: "amount_2".into(), value: FieldValue::Text("32".into()) }).unwrap(); + assert_eq!(value(&s, "grand"), vec!["42".to_string()], "{:?}", s.take_js_output(id)); + // Message boxes reach the viewer. + let o = s.run_javascript(id, "", Some("hello")).unwrap(); + assert_eq!(o.alerts, vec!["Hello 500".to_string()]); + // The check box shows the hidden subform; undo hides it again. + s.apply(id, Edit::SetFieldValue { name: "more".into(), value: FieldValue::Check(true) }).unwrap(); + assert!(names(&s).iter().any(|n| n == "note"), "{:?} {:?}", names(&s), s.take_js_output(id)); + s.undo(id).unwrap(); + assert!(!names(&s).iter().any(|n| n == "note")); + s.redo(id).unwrap(); + assert!(names(&s).iter().any(|n| n == "note")); + // Removing the row takes its amount out of the sum. + s.run_javascript(id, "", Some("removeRow")).unwrap(); + assert!(!names(&s).iter().any(|n| n == "amount_2")); + assert_eq!(value(&s, "grand"), vec!["10".to_string()]); + // FormCalc is reported, not run. + let o = s.run_javascript(id, "", Some("legacy")).unwrap(); + assert!(o.error.as_deref().is_some_and(|e| e.contains("FormCalc")), "{o:?}"); + assert!(o.alerts.is_empty()); + // Saved and reopened: the visible subform, the values and the rows are what they were. + let saved = s.save_bytes(id).unwrap(); + let id2 = s.open("again.pdf", None, saved, None).unwrap(); + let d2 = s.get(id2).unwrap(); + let n2: Vec<&str> = d2.form.iter().map(|f| f.name.as_str()).collect(); + assert!(n2.contains(&"note") && !n2.contains(&"amount_2"), "{n2:?}"); + assert_eq!(d2.form.iter().find(|f| f.name == "grand").unwrap().value, vec!["10".to_string()]); + assert_eq!(d2.form.iter().find(|f| f.name == "qty").unwrap().value, vec!["500".to_string()]); + assert!(!d2.dirty); +} + +#[test] +fn xfa_scripts_stay_off_with_javascript_off() { + let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::scripted_template())); + let mut s = Session::new().with_clock(|| 1_700_000_000); + s.set_javascript(false); + let id = s.open("scripted.pdf", None, bytes, None).expect("opens"); + let qty = s.get(id).unwrap().form.iter().find(|f| f.name == "qty").unwrap().value.clone(); + assert!(qty.is_empty(), "no initialize script ran"); + assert!(s.run_javascript(id, "", Some("addRow")).is_err()); +} + +#[test] +fn hostile_xfa_scripts_cannot_hang_or_bloat_the_document() { + // A calculate script that adds a row every time it runs, a click script that loops on + // addInstance, and one that nests setInstances: each ends quickly with a bounded form. + let tpl = pdfcraft_xfa::fixtures::scripted_template() + .replace( + r#""#, + r#""#, + ) + .replace( + r#"table._row.addInstance(1);"#, + r#"for (var i = 0; i < 100000; i++) { table._row.addInstance(1); table._row.setInstances(0); }"#, + ); + let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&tpl)); + let mut s = Session::new().with_clock(|| 1_700_000_000); + let started = std::time::Instant::now(); + let id = s.open("hostile.pdf", None, bytes, None).expect("opens"); + let rows = |s: &Session| s.get(id).unwrap().form.iter().filter(|f| f.name.starts_with("amount")).count(); + assert!(rows(&s) <= 50, "{} rows", rows(&s)); + let o = s.run_javascript(id, "", Some("addRow")).unwrap(); + assert!(o.error.is_some() || rows(&s) <= 50, "{o:?}"); + assert!(started.elapsed() < std::time::Duration::from_secs(60), "{:?}", started.elapsed()); +} + +#[test] +fn xfa_scripts_can_remove_rows_twice_and_reset_fields_by_name() { + let tpl = pdfcraft_xfa::fixtures::scripted_template() + .replace(r#"if (table._row.count > 1) { table._row.removeInstance(table._row.count - 1); }"#, r#"table._row.setInstances(1);"#) + .replace(r#"xfa.host.messageBox("Hello " + qty.rawValue);"#, r#"xfa.host.resetData("qty, table");"#); + let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&tpl)); + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("rows.pdf", None, bytes, None).expect("opens"); + let rows = |s: &Session| s.get(id).unwrap().form.iter().filter(|f| f.name.starts_with("amount")).count(); + s.run_javascript(id, "", Some("addRow")).unwrap(); + s.run_javascript(id, "", Some("addRow")).unwrap(); + assert_eq!(rows(&s), 3); + s.apply(id, Edit::SetFieldValue { name: "amount".into(), value: FieldValue::Text("7".into()) }).unwrap(); + // setInstances(1) removes two rows in one script: both go, nothing comes back. + s.run_javascript(id, "", Some("removeRow")).unwrap(); + assert_eq!(rows(&s), 1, "{:?}", s.get(id).unwrap().form.iter().map(|f| &f.name).collect::>()); + let cos = pdfcraft_cos::Document::open(s.get(id).unwrap().bytes.clone()).unwrap(); + let data = pdfcraft_xfa::data_of(&cos).unwrap(); + assert_eq!(data.count(&pdfcraft_xfa::som_to_path("form[0].page1[0].table[0]"), "row"), 1); + // resetData with names clears those fields (the row's amount too) and nothing else. + s.apply(id, Edit::SetFieldValue { name: "price".into(), value: FieldValue::Text("9".into()) }).unwrap(); + let o = s.run_javascript(id, "", Some("hello")).unwrap(); + let value = |s: &Session, n: &str| s.get(id).unwrap().form.iter().find(|f| f.name == n).unwrap().value.clone(); + assert!(value(&s, "qty").is_empty(), "{:?} {o:?}", value(&s, "qty")); + assert!(value(&s, "amount").is_empty()); + assert_eq!(value(&s, "price"), vec!["9".to_string()]); +} + +/// Streams in `bytes` (every revision) that hold an XFA datasets packet. +fn datasets_streams(bytes: &Arc>) -> usize { + let cos = pdfcraft_cos::Document::open(bytes.clone()).unwrap(); + cos.object_numbers() + .into_iter() + .filter(|&n| { + cos.try_get(n).ok().is_some_and(|o| match &*o { + pdfcraft_cos::Object::Stream(s) => s.decoded_within(1 << 24).is_ok_and(|b| b.windows(9).any(|w| w == b"datasets>")), + _ => false, + }) + }) + .count() +} + +#[test] +fn hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted() { + // On open: a calculate setting its own value 50 000 times, an initialize script showing a + // thousand messages and asking to print, open a link and save. + let tpl = pdfcraft_xfa::fixtures::scripted_template() + .replace( + r#""#, + r#""#, + ) + .replace( + r#"if (qty.rawValue === null) qty.rawValue = 2;"#, + r#"for (var i = 0; i < 1000; i++) xfa.host.messageBox("m" + i); xfa.host.print(); xfa.host.gotoURL("https://example.com/"); app.execMenuItem("SaveAs"); if (qty.rawValue === null) qty.rawValue = 2;"#, + ); + let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&tpl)); + let mut s = Session::new().with_clock(|| 1_700_000_000); + let started = std::time::Instant::now(); + let id = s.open("hostile.pdf", None, bytes, None).expect("opens"); + assert!(started.elapsed() < std::time::Duration::from_secs(5), "{:?}", started.elapsed()); + let doc = s.get(id).unwrap(); + assert_eq!(doc.form.iter().find(|f| f.name == "total").unwrap().value, vec!["49999".to_string()]); + // One datasets stream for the scripts' revision, not one per value set. + assert!(datasets_streams(&doc.bytes) <= 3, "{} datasets streams", datasets_streams(&doc.bytes)); + // What opening changed is noted with the form's other XFA notes. + assert!(doc.xfa_warnings.iter().any(|w| w.contains("scripts changed it on opening")), "{:?}", doc.xfa_warnings); + let out = s.take_js_output(id); + assert_eq!(out.alerts.len(), 100, "message boxes are capped"); + assert!(out.errors.iter().any(|e| e.contains("more than 100 messages")), "{:?}", out.errors); + assert!(out.requests.is_empty(), "only a click may print, save or open links: {:?}", out.requests); + assert!(out.console.iter().any(|c| c.contains("only print when a button is clicked")), "{:?}", out.console); +} + +#[test] +fn runaway_xfa_calculations_at_open_end_quickly_with_a_report() { + // Nested loops in one frame stop at the loop limit; loops in nested calls (that limit is per + // call) are abandoned at the time limit and turn the form's scripts off. + let tpl = pdfcraft_xfa::fixtures::scripted_template() + .replace( + r#""#, + r#""#, + ) + .replace( + r#"var t = 0; var rows"#, + r#"function f() { for (var j = 0; j < 99999; j++) {} } for (var k = 0; k < 5000; k++) f(); var t = 0; var rows"#, + ); + let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&tpl)); + let mut s = Session::new().with_clock(|| 1_700_000_000); + let started = std::time::Instant::now(); + let id = s.open("runaway.pdf", None, bytes, None).expect("opens"); + assert!(started.elapsed() < std::time::Duration::from_secs(4), "{:?}", started.elapsed()); + let out = s.take_js_output(id); + assert!(out.errors.iter().any(|e| e.contains("total") && e.contains("maximum number of iteration")), "{:?}", out.errors); + assert!(out.errors.iter().any(|e| e.contains("abandoned")), "{:?}", out.errors); + assert!(out.errors.iter().any(|e| e.contains("scripts are off")), "{:?}", out.errors); + // The scripts stay off: a change runs nothing more. + let started = std::time::Instant::now(); + s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("3".into()) }).unwrap(); + assert!(started.elapsed() < std::time::Duration::from_secs(2)); + assert!(s.take_js_output(id).errors.is_empty()); +} diff --git a/crates/engine/src/xfa.rs b/crates/engine/src/xfa.rs new file mode 100644 index 00000000..58042f46 --- /dev/null +++ b/crates/engine/src/xfa.rs @@ -0,0 +1,755 @@ +//! XFA scripting in the engine: a form's scripted events (initialize and calculate on open, +//! exit, validate and calculate after a field changes, click for buttons) run through +//! [`pdfcraft_js::xfa`] over the live form tree [`pdfcraft_xfa::form_tree`] builds, and what +//! they change or ask for is applied here: values go to the fields (and so to the datasets), +//! presence and access changes become overrides kept in the PDF, added or removed rows change +//! the datasets, and any of those lays the form out again. + +use std::collections::HashMap; +use std::sync::Arc; + +use pdfcraft_js::xfa::{XfaDoc, XfaEffect, XfaEvent, XfaKind, XfaNode, run_xfa_within}; +use pdfcraft_xfa::model::Template; +use pdfcraft_xfa::{FormNode, LiveForm, NodeKind, ScriptEvent}; + +use crate::js::JsOutput; +use crate::js::Request; + +/// Most scripts run for one event (calculates of a whole form, say). +const MAX_SCRIPTS_PER_EVENT: usize = 2_000; +/// Most times the form is laid out again for one event (a script adding rows in a loop). +const MAX_RELAYOUTS_PER_EVENT: usize = 50; +/// Most message boxes, console lines and errors kept for one event. +const MAX_ALERTS_PER_EVENT: usize = 100; +const MAX_CONSOLE_PER_EVENT: usize = 1_000; +const MAX_ERRORS_PER_EVENT: usize = 100; +/// Time the scripts of one event may take in all before the rest are skipped: opening (and +/// the calculations every change runs) is held tighter than a click. +const OPEN_BUDGET_MS: u64 = 3_000; +const EVENT_BUDGET_MS: u64 = 5_000; + +/// Engine limits for one script: initialize, calculate and validate scripts run without the +/// user asking (on open, on every change), so they get fewer loop iterations than a click. +fn limits_for(activity: &str) -> pdfcraft_js::Limits { + match activity { + "click" | "change" | "exit" | "enter" => pdfcraft_js::Limits::default(), + _ => pdfcraft_js::Limits { loop_iterations: 100_000, recursion: 64 }, + } +} + +/// Longest one script may run before it is abandoned (it can't be interrupted, only left +/// behind): scripts that run unasked are held tighter than a click's. +fn timeout_for(activity: &str) -> std::time::Duration { + std::time::Duration::from_millis(match activity { + "click" | "change" | "exit" | "enter" => 3_000, + _ => 1_000, + }) +} + +/// Requests only a click may make (anything else asking is noted and ignored). +fn click_only(e: &XfaEffect) -> Option<&'static str> { + match e { + XfaEffect::Print => Some("print"), + XfaEffect::SaveAs => Some("save"), + XfaEffect::LaunchUrl(_) => Some("open a link"), + XfaEffect::SetFocus(_) => Some("move the focus"), + _ => None, + } +} + +/// A wall clock where there is one (not in the browser build, where the script and relayout +/// caps alone bound an event). +#[derive(Clone, Copy)] +struct Clock { + #[cfg(not(target_arch = "wasm32"))] + started: std::time::Instant, + budget_ms: u64, +} + +impl Clock { + fn start(budget_ms: u64) -> Self { + Clock { + #[cfg(not(target_arch = "wasm32"))] + started: std::time::Instant::now(), + budget_ms, + } + } + + fn spent(&self) -> bool { + #[cfg(not(target_arch = "wasm32"))] + { + self.started.elapsed() >= std::time::Duration::from_millis(self.budget_ms) + } + #[cfg(target_arch = "wasm32")] + { + let _ = self.budget_ms; + false + } + } +} + +/// What the scripts of an event changed in the form (for the note opening leaves). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) struct Changes { + pub values: usize, + pub rows: usize, + pub visibility: usize, +} + +impl Changes { + pub fn is_empty(&self) -> bool { + self.values == 0 && self.rows == 0 && self.visibility == 0 + } + + /// "3 values, 1 row" — what changed, for a note. + pub fn describe(&self) -> String { + let mut parts = Vec::new(); + let n = |k: usize, one: &str, many: &str| format!("{k} {}", if k == 1 { one } else { many }); + if self.values > 0 { + parts.push(n(self.values, "field value", "field values")); + } + if self.rows > 0 { + parts.push(n(self.rows, "added or removed row", "added or removed rows")); + } + if self.visibility > 0 { + parts.push(n(self.visibility, "shown, hidden or locked object", "shown, hidden or locked objects")); + } + parts.join(", ") + } +} + +/// What a run of the form's scripts shares with the edit around it. +pub(crate) struct XfaRun<'a> { + pub page_count: usize, + pub out: &'a mut JsOutput, + /// The datasets stream this edit already wrote, replaced in place by later writes. + pub datasets: Option, + /// A script ran past its time limit and was abandoned: the form's scripts should stay off. + pub ran_away: bool, +} + +fn to_js(n: &FormNode) -> XfaNode { + XfaNode { + name: n.name.clone(), + som: n.som.clone(), + kind: n.kind.map(|k| match k { + NodeKind::Subform => XfaKind::Subform, + NodeKind::Field => XfaKind::Field, + NodeKind::ExclGroup => XfaKind::ExclGroup, + NodeKind::Draw => XfaKind::Draw, + NodeKind::Area => XfaKind::Area, + }), + value: n.value.clone(), + numeric: n.numeric, + presence: n.presence.clone(), + access: n.access.clone(), + repeatable: n.repeatable, + occur_min: n.occur_min, + occur_max: n.occur_max, + index: n.index, + children: n.children.iter().map(to_js).collect(), + } +} + +fn find_container<'a>(n: &'a FormNode, som: &str, depth: usize) -> Option<&'a FormNode> { + if depth > 64 { + return None; + } + if n.som == som && n.kind != Some(NodeKind::Field) { + return Some(n); + } + n.children.iter().find_map(|c| find_container(c, som, depth + 1)) +} + +fn count_instances(root: &FormNode, parent_som: &str, name: &str) -> usize { + let parent = if parent_som.is_empty() { Some(root) } else { find_container(root, parent_som, 0) }; + parent.map_or(0, |p| p.children.iter().filter(|c| c.name == name).count()) +} + +/// An instance SOM (`…table[0].row[2]`) split into its parent's data path and SOM, its name +/// and its index. +fn split_instance(som: &str) -> Option<(pdfcraft_xfa::DataPath, String, String, usize)> { + let mut path = pdfcraft_xfa::som_to_path(som); + let (name, index) = path.pop()?; + let parent_som = som.rsplit_once('.').map(|(p, _)| p).unwrap_or("").to_string(); + Some((path, parent_som, name, index)) +} + +/// 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()); + let mut skip = false; + for c in som.chars() { + match c { + '[' => skip = true, + ']' => skip = false, + _ if !skip => out.push(c), + _ => {} + } + } + out +} + +/// Does `name` from a script (`qty`, `page1.qty`, `xfa.form.form.page1.qty`, or a container +/// such as `table`) name this SOM or one of its ancestors? +fn som_matches(som: &str, name: &str) -> bool { + let plain = plain_som(som); + let n = plain_som(name.trim()); + let n = n.strip_prefix("xfa.form.").or_else(|| n.strip_prefix("$form.")).unwrap_or(&n); + let want: Vec<&str> = n.split('.').filter(|s| !s.is_empty()).collect(); + let have: Vec<&str> = plain.split('.').filter(|s| !s.is_empty()).collect(); + !want.is_empty() && have.windows(want.len()).any(|w| w == want.as_slice()) +} + +/// A generated field as the runner tracks it: kind, value and on state. +#[derive(Clone)] +struct FieldInfo { + kind: pdfcraft_forms::FieldKind, + value: Vec, + on_state: String, +} + +fn field_infos(doc: &pdfcraft_cos::Document) -> HashMap { + pdfcraft_forms::fields(doc) + .into_iter() + .map(|f| { + let on_state = f.widgets.first().and_then(|w| w.on_state.clone()).unwrap_or_else(|| "1".into()); + (f.name, FieldInfo { kind: f.kind, value: f.value, on_state }) + }) + .collect() +} + +/// The data text of a field's value. +fn data_text(info: &FieldInfo) -> String { + match info.kind { + pdfcraft_forms::FieldKind::CheckBox => { + if info.value.is_empty() { + String::new() + } else { + info.on_state.clone() + } + } + _ => info.value.join("\n"), + } +} + +/// Runs the scripts of one event (opening, a change, a click) against one document: what +/// they change is kept in memory (data, field values, overrides) and written once, when the +/// event ends or the form has to be laid out again. +struct Runner<'a, 'b> { + doc: &'a mut pdfcraft_cos::Document, + tpl: &'a Template, + run: &'a mut XfaRun<'b>, + clock: Clock, + formcalc_warned: bool, + runs: usize, + skipped: usize, + relayouts: usize, + relayouts_skipped: bool, + dropped_output: usize, + truncated_warned: bool, + data: Option, + ov: pdfcraft_xfa::Overrides, + ov_dirty: bool, + pending: Vec, + /// Field values to write, by name (the last wins). + field_changes: std::collections::BTreeMap>, + by_som: Option>, + fields: Option>, + /// The live form as last built; dropped when a script changed the form. + cached: Option, + changes: Changes, +} + +impl<'a, 'b> Runner<'a, 'b> { + fn new(doc: &'a mut pdfcraft_cos::Document, tpl: &'a Template, run: &'a mut XfaRun<'b>, budget_ms: u64) -> Self { + let data = pdfcraft_xfa::data_of(doc); + let ov = pdfcraft_xfa::overrides(doc); + Runner { + doc, + tpl, + run, + clock: Clock::start(budget_ms), + formcalc_warned: false, + runs: 0, + skipped: 0, + relayouts: 0, + relayouts_skipped: false, + dropped_output: 0, + truncated_warned: false, + data, + ov, + ov_dirty: false, + pending: Vec::new(), + field_changes: Default::default(), + by_som: None, + fields: None, + cached: None, + changes: Changes::default(), + } + } + + fn live(&mut self) -> &LiveForm { + if self.cached.is_none() { + let live = pdfcraft_xfa::form_tree(self.tpl, self.data.as_ref(), &self.ov); + if live.truncated && !self.truncated_warned { + self.truncated_warned = true; + self.error(format!( + "The form has more than {} objects or 20000 scripted events; its scripts see only the first ones", + pdfcraft_xfa::MAX_FORM_NODES + )); + } + self.cached = Some(live); + } + self.cached.get_or_insert_with(Default::default) + } + + fn by_som(&mut self) -> &HashMap { + if self.by_som.is_none() { + self.by_som = Some(pdfcraft_xfa::fields_by_som(self.doc)); + } + self.by_som.get_or_insert_with(Default::default) + } + + fn fields(&mut self) -> &mut HashMap { + if self.fields.is_none() { + self.fields = Some(field_infos(self.doc)); + } + self.fields.get_or_insert_with(Default::default) + } + + fn error(&mut self, e: String) { + if self.run.out.errors.len() < MAX_ERRORS_PER_EVENT { + self.run.out.errors.push(e); + } else { + self.dropped_output += 1; + } + } + + fn console(&mut self, lines: impl IntoIterator) { + for l in lines { + if self.run.out.console.len() < MAX_CONSOLE_PER_EVENT { + self.run.out.console.push(l); + } else { + self.dropped_output += 1; + } + } + } + + fn alert(&mut self, m: String) { + if self.run.out.alerts.len() < MAX_ALERTS_PER_EVENT { + self.run.out.alerts.push(m); + } else { + self.dropped_output += 1; + } + } + + /// Change the data (and the in-memory copy scripts read). + fn data_op(&mut self, op: pdfcraft_xfa::DataOp) { + self.data.get_or_insert_with(Default::default).apply(&op); + self.pending.push(op); + self.cached = None; + } + + /// Give the object at `som` the value `value` (field `name` when it has a widget). Returns + /// whether it changed. + fn set_value(&mut self, som: &str, value: &str) -> bool { + let path = pdfcraft_xfa::som_to_path(som); + let name = self.by_som().get(som).cloned(); + let Some(name) = name else { + // No widget (a hidden field, a draw, a row not laid out yet): the data alone. + if self.data.as_ref().and_then(|d| d.text_at(&path)) == Some(value) { + return false; + } + self.data_op(pdfcraft_xfa::DataOp::Value { path, text: value.to_string() }); + self.changes.values += 1; + return true; + }; + let Some(info) = self.fields().get(&name).cloned() else { return false }; + use pdfcraft_forms::FieldKind as K; + let new_value: Vec = match info.kind { + K::CheckBox => { + if pdfcraft_xfa::data::is_on(value, &info.on_state) || value == info.on_state { + vec![info.on_state.clone()] + } else { + Vec::new() + } + } + K::Radio => { + let v = value.trim(); + if v.is_empty() { Vec::new() } else { vec![v.to_string()] } + } + // Read-only fields still take values from scripts (calculated totals are read-only). + K::PushButton | K::Signature => return false, + _ => vec![value.to_string()], + }; + let same = match info.kind { + K::CheckBox => info.value.is_empty() == new_value.is_empty(), + K::Radio => info.value.first() == new_value.first(), + _ => info.value.first().map_or(value.is_empty(), |v| v == value), + }; + if same { + return false; + } + let updated = FieldInfo { value: new_value.clone(), ..info }; + let text = data_text(&updated); + self.fields().insert(name.clone(), updated); + self.field_changes.insert(name, new_value); + self.data_op(pdfcraft_xfa::DataOp::Value { path, text }); + self.changes.values += 1; + true + } + + /// Write what the scripts changed so far: the data as one rewrite, the field values, the + /// overrides. + fn flush(&mut self) -> Result<(), String> { + let mut wrote = false; + if !self.pending.is_empty() { + let ops = std::mem::take(&mut self.pending); + let r = pdfcraft_xfa::write_data_ops(self.doc, &ops, self.run.datasets).map_err(|e| e.to_string())?; + if r.stream.is_some() { + self.run.datasets = r.stream; + } + self.console(r.warnings); + wrote = true; + } + if !self.field_changes.is_empty() { + let changes: Vec = std::mem::take(&mut self.field_changes) + .into_iter() + .map(|(name, value)| pdfcraft_forms::FieldChange { name, value: Some(value), ..Default::default() }) + .collect(); + pdfcraft_forms::apply_script_changes(self.doc, &changes, &mut pdfcraft_forms::NoScripts).map_err(|e| e.to_string())?; + self.fields = None; + } + if self.ov_dirty { + pdfcraft_xfa::set_overrides(self.doc, &self.ov).map_err(|e| e.to_string())?; + self.ov_dirty = false; + } + if wrote { + // What the packet now holds (a value it could not take is reported above). + self.data = pdfcraft_xfa::data_of(self.doc); + self.cached = None; + } + Ok(()) + } + + /// Lay the form out again and give the new widgets their appearances. + fn relayout(&mut self) -> Result<(), String> { + if self.relayouts >= MAX_RELAYOUTS_PER_EVENT { + if !self.relayouts_skipped { + self.relayouts_skipped = true; + self.error(format!( + "The form's scripts asked to lay it out again more than {MAX_RELAYOUTS_PER_EVENT} times; the form is laid out once more when they finish" + )); + } + return Ok(()); + } + self.relayouts += 1; + self.flush()?; + let report = pdfcraft_xfa::rerender(self.doc, self.tpl).map_err(|e| e.to_string())?; + self.console(report.warnings); + for f in pdfcraft_forms::fields(self.doc) { + pdfcraft_forms::redraw_field(self.doc, &f.name).map_err(|e| format!("{}: {e}", f.name))?; + } + self.by_som = None; + self.fields = None; + self.cached = None; + Ok(()) + } + + /// `xfa.host.resetData`: the fields back to their defaults, in the form and the data. + fn reset(&mut self, names: &[String]) -> Result<(), String> { + self.flush()?; + let by_som = self.by_som().clone(); + let listed: Option> = if names.is_empty() { + None + } else { + Some(by_som.iter().filter(|(som, _)| names.iter().any(|n| som_matches(som, n))).map(|(_, f)| f.clone()).collect()) + }; + if listed.as_ref().is_some_and(|l| l.is_empty()) { + return Ok(()); + } + pdfcraft_forms::reset(self.doc, listed.as_deref()).map_err(|e| e.to_string())?; + self.fields = None; + let infos = self.fields().clone(); + for (som, name) in &by_som { + if listed.as_ref().is_some_and(|l| !l.contains(name)) { + continue; + } + let Some(info) = infos.get(name) else { continue }; + if matches!(info.kind, pdfcraft_forms::FieldKind::PushButton | pdfcraft_forms::FieldKind::Signature) { + continue; + } + let path = pdfcraft_xfa::som_to_path(som); + let text = data_text(info); + if self.data.as_ref().and_then(|d| d.text_at(&path)).unwrap_or("") != text { + self.data_op(pdfcraft_xfa::DataOp::Value { path, text }); + self.changes.values += 1; + } + } + self.cached = None; + Ok(()) + } + + /// Apply what a script of `ev` did. Returns whether the form has to be laid out again. + fn apply_effects(&mut self, ev: &ScriptEvent, effects: &[XfaEffect]) -> Result { + let mut relayout = false; + // Instances of each repeating subform before the script ran, as the effects leave them. + let mut counts: HashMap<(String, String), usize> = HashMap::new(); + { + let live = self.live(); + for e in effects { + if let XfaEffect::AddInstance { som } | XfaEffect::RemoveInstance { som } = e + && let Some((_, parent_som, name, _)) = split_instance(som) + { + let have = count_instances(&live.root, &parent_som, &name); + counts.entry((parent_som, name)).or_insert(have); + } + } + } + for e in effects { + // Only a click script may print, save, open links or move the focus (not the + // calculations a click sets off, nor anything run on open or on a change). + if let Some(what) = click_only(e) + && ev.activity != "click" + { + let note = format!("{} ({}): a script may only {what} when a button is clicked; ignored", ev.som, ev.activity); + self.console([note]); + continue; + } + match e { + XfaEffect::SetValue { som, value } => { + self.set_value(som, value); + } + XfaEffect::SetPresence { som, presence } => { + if self.ov.presence.insert(som.clone(), presence.clone()).as_ref() != Some(presence) { + self.ov_dirty = true; + self.changes.visibility += 1; + self.cached = None; + relayout = true; + } + } + XfaEffect::SetAccess { som, access } => { + if self.ov.access.insert(som.clone(), access.clone()).as_ref() != Some(access) { + self.ov_dirty = true; + self.changes.visibility += 1; + self.cached = None; + relayout = true; + } + } + XfaEffect::AddInstance { som } | XfaEffect::RemoveInstance { som } => { + let Some((parent, parent_som, name, index)) = split_instance(som) else { continue }; + let key = (parent_som, name.clone()); + let have = counts.get(&key).copied().unwrap_or(0); + if matches!(e, XfaEffect::AddInstance { .. }) { + counts.insert(key, have + 1); + self.data_op(pdfcraft_xfa::DataOp::Instances { parent, name, count: (index + 1).max(have + 1) }); + } else { + // 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 }); + let mut path = parent; + path.push((name, index)); + self.data_op(pdfcraft_xfa::DataOp::Remove(path)); + } + self.changes.rows += 1; + relayout = true; + } + XfaEffect::MessageBox(m) => self.alert(m.clone()), + XfaEffect::ResetData(names) => self.reset(names)?, + XfaEffect::Print => self.run.out.requests.push(Request::Print), + XfaEffect::SaveAs => self.run.out.requests.push(Request::SaveAs), + XfaEffect::LaunchUrl(u) => self.run.out.requests.push(Request::LaunchUrl(u.clone())), + XfaEffect::SetFocus(som) => { + if let Some(name) = self.by_som().get(som).cloned() { + self.run.out.requests.push(Request::Focus(name)); + } + } + XfaEffect::Beep => self.run.out.requests.push(Request::Beep), + XfaEffect::Recalculate => {} + XfaEffect::Relayout => relayout = true, + } + } + Ok(relayout) + } + + /// Run one event script against the form as it is now. `None`: it was skipped (the + /// event's budget is spent). + fn run(&mut self, ev: &ScriptEvent, new_text: &str) -> Result, String> { + if self.runs >= MAX_SCRIPTS_PER_EVENT || self.clock.spent() || self.run.ran_away { + self.skipped += 1; + return Ok(None); + } + self.runs += 1; + if ev.formcalc { + if !self.formcalc_warned { + self.error(format!("{}: FormCalc scripts don't run yet (only JavaScript ones)", ev.som)); + self.formcalc_warned = true; + } + return Ok(Some(Default::default())); + } + let event = XfaEvent { activity: ev.activity.clone(), target: ev.som.clone(), new_text: new_text.to_string(), prev_text: String::new() }; + let doc_info = XfaDoc { file_name: String::new(), page: 0, page_count: self.run.page_count }; + let root_js = to_js(&self.live().root); + let o = run_xfa_within(&ev.script, &event, &doc_info, root_js, limits_for(&ev.activity), timeout_for(&ev.activity)); + if o.abandoned { + self.run.ran_away = true; + } + self.console(o.console.iter().cloned()); + for n in &o.notes { + self.error(format!("{} ({}): {n}", ev.som, ev.activity)); + } + if let Some(e) = &o.error { + self.error(format!("{} ({}): {e}", ev.som, ev.activity)); + return Ok(Some(o)); + } + if self.apply_effects(ev, &o.effects)? { + self.relayout()?; + } + Ok(Some(o)) + } + + /// Every calculate script, in document order: the field takes the script's result unless + /// the script set a value itself. + fn calculates(&mut self) -> Result<(), String> { + let events: Vec = self.live().events.iter().filter(|e| e.activity == "calculate").cloned().collect(); + for ev in &events { + let Some(o) = self.run(ev, "")? else { continue }; + if o.error.is_some() || o.effects.iter().any(|e| matches!(e, XfaEffect::SetValue { som, .. } if *som == ev.som)) { + continue; + } + if let Some(result) = o.result { + self.set_value(&ev.som, &result); + } + } + Ok(()) + } + + fn events_of(&mut self, som: &str, activity: &str) -> Vec { + self.live().events.iter().filter(|e| e.som == som && e.activity == activity).cloned().collect() + } + + /// End the event: write what is left, lay the form out if a capped relayout is owed, and + /// report what the limits cut. + fn finish(mut self) -> Result { + if self.relayouts_skipped { + self.relayouts = 0; + self.relayouts_skipped = false; + self.relayout()?; + } + self.flush()?; + if self.skipped > 0 { + let why = if self.run.ran_away { + "one ran too long".to_string() + } else if self.runs >= MAX_SCRIPTS_PER_EVENT { + format!("more than {MAX_SCRIPTS_PER_EVENT} scripts ran") + } else { + format!("the scripts took more than {} seconds", self.clock.budget_ms / 1000) + }; + self.run.out.errors.push(format!("The form's scripts were stopped: {why}; {} more were skipped", self.skipped)); + } + if self.dropped_output > 0 { + self.run.out.errors.push(format!("The form's scripts produced too much output; {} more messages were left out", self.dropped_output)); + } + Ok(self.changes) + } +} + +/// On open: initialize scripts, then calculations. Returns what they changed. +pub(crate) fn on_open(doc: &mut pdfcraft_cos::Document, tpl: &Template, run: &mut XfaRun) -> Result { + let mut r = Runner::new(doc, tpl, run, OPEN_BUDGET_MS); + let events: Vec = r.live().events.iter().filter(|e| e.activity == "initialize" || e.activity == "docReady").cloned().collect(); + for ev in &events { + r.run(ev, "")?; + } + r.calculates()?; + r.finish() +} + +/// After fields `names` took new values: each one's change, exit and validate scripts, then +/// the calculations once (or only those, after a reset). A validate script that answers false, +/// or fails, shows its message; the value stays, as Acrobat marks the field invalid rather +/// than reverting it. +pub(crate) fn on_changes(doc: &mut pdfcraft_cos::Document, tpl: &Template, names: &[String], run: &mut XfaRun) -> Result<(), String> { + let mut r = Runner::new(doc, tpl, run, OPEN_BUDGET_MS); + for name in names { + let som = r.by_som().iter().find(|(_, f)| *f == name).map(|(s, _)| s.clone()); + let Some(som) = som else { continue }; + let value = r.fields().get(name).map(|f| f.value.join("\n")).unwrap_or_default(); + for ev in r.events_of(&som, "change").into_iter().chain(r.events_of(&som, "exit")) { + r.run(&ev, &value)?; + } + for ev in r.events_of(&som, "validate") { + let Some(o) = r.run(&ev, &value)? else { continue }; + if (o.result_bool == Some(false) || o.error.is_some()) && !value.trim().is_empty() { + r.alert(ev.message.clone().unwrap_or_else(|| format!("{name}: the value is not valid"))); + } + } + } + r.calculates()?; + r.finish().map(|_| ()) +} + +/// A button's click script (or any event, by SOM path and activity), then calculations. +pub(crate) fn on_event(doc: &mut pdfcraft_cos::Document, tpl: &Template, som: &str, activity: &str, run: &mut XfaRun) -> Result<(), String> { + let mut r = Runner::new(doc, tpl, run, EVENT_BUDGET_MS); + let events = r.events_of(som, activity); + if events.is_empty() { + return Err(format!("{som} has no {activity} script")); + } + for ev in events { + r.run(&ev, "")?; + } + // The calculations that follow get a budget of their own. + r.clock = Clock::start(OPEN_BUDGET_MS); + r.calculates()?; + r.finish().map(|_| ()) +} + +/// The SOM path of field `name` when PdfCraft generated it from an XFA template and the +/// template has a click script for it (a button with a native action has none). +pub(crate) fn clickable_som(doc: &pdfcraft_cos::Document, tpl: &Template, name: &str) -> Option { + let som = pdfcraft_xfa::fields_by_som(doc).into_iter().find(|(_, f)| f == name).map(|(s, _)| s)?; + let live = pdfcraft_xfa::form_tree(tpl, pdfcraft_xfa::data_of(doc).as_ref(), &pdfcraft_xfa::overrides(doc)); + live.events.iter().any(|e| e.som == som && e.activity == "click").then_some(som) +} + +/// The parsed template of a laid-out XFA form, kept for the document's life, when it has +/// scripts to run (`None` otherwise: nothing about the form is scripted). +pub(crate) fn template(doc: &pdfcraft_cos::Document) -> Option> { + pdfcraft_xfa::template_of(doc).ok().filter(pdfcraft_xfa::has_scripts).map(Arc::new) +} + +/// Push buttons PdfCraft generated for XFA click scripts carry no PDF action (the script is +/// XFA's, not Acrobat JavaScript): give them one that runs the script by the field's SOM path. +pub(crate) fn mark_script_buttons(doc: &pdfcraft_cos::Document, fields: &mut [pdfcraft_forms::Field]) { + for f in fields.iter_mut() { + if f.kind == pdfcraft_forms::FieldKind::PushButton + && f.button.is_none() + && std::iter::once(f.obj) + .chain(f.widgets.iter().map(|w| w.obj)) + .any(|r| doc.get(r).as_dict().is_some_and(|d| d.contains(pdfcraft_xfa::CLICK_KEY))) + { + f.button = Some(pdfcraft_forms::af::ButtonAction::Script(String::new())); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn script_names_match_indexed_som_paths() { + assert!(som_matches("form[0].page1[0].qty[0]", "qty")); + assert!(som_matches("form[0].page1[0].qty[0]", "page1.qty")); + assert!(som_matches("form[0].page1[0].qty[0]", "xfa.form.form.page1.qty")); + assert!(som_matches("form[0].page1[0].table[0].row[2].amount[0]", "table")); + assert!(!som_matches("form[0].page1[0].qty[0]", "qt")); + assert!(!som_matches("form[0].page1[0].price[0]", "qty")); + assert_eq!( + split_instance("form[0].table[0].row[2]").map(|(p, ps, n, i)| (p.len(), ps, n, i)), + Some((2, "form[0].table[0]".into(), "row".into(), 2)) + ); + } +} diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs index 574b7888..560eec83 100644 --- a/crates/js/src/lib.rs +++ b/crates/js/src/lib.rs @@ -157,6 +157,8 @@ pub enum Request { /// `field.setFocus()`. Focus(String), Beep, + /// `app.execMenuItem("SaveAs")` (XFA forms' Save buttons). + SaveAs, } /// What running a script did. @@ -1138,5 +1140,7 @@ fn run_here(script: &str, event: &Event, doc: &DocInfo, fields: &[FieldState], d out } +pub mod xfa; + #[cfg(test)] mod tests; diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs index 74be1642..ddbd6b7b 100644 --- a/crates/js/src/tests.rs +++ b/crates/js/src/tests.rs @@ -183,3 +183,234 @@ fn ordinary_nesting_still_runs() { let s = format!("// {}\nevent.value = '{}'.length;", "(".repeat(1000), "[".repeat(1000)); assert_eq!(go(&s, &Event::field("Calculate", "a", "")).value, "1000"); } + +mod xfa_model { + use crate::Limits; + use crate::xfa::*; + + fn field(name: &str, som: &str, value: &str, numeric: bool) -> XfaNode { + XfaNode { + name: name.into(), + som: som.into(), + kind: Some(XfaKind::Field), + value: value.into(), + numeric, + presence: "visible".into(), + ..Default::default() + } + } + + /// form1 > page1 > { qty, price, total, details (subform, hidden) > note, table > row[0..2] > { what, amount }, go (button) } + fn form() -> XfaNode { + let row = |i: usize| XfaNode { + name: "row".into(), + som: format!("form1[0].page1[0].table[0].row[{i}]"), + kind: Some(XfaKind::Subform), + repeatable: true, + occur_min: 1, + occur_max: None, + index: i, + children: vec![ + field("what", &format!("form1[0].page1[0].table[0].row[{i}].what[0]"), if i == 0 { "Rent" } else { "" }, false), + field("amount", &format!("form1[0].page1[0].table[0].row[{i}].amount[0]"), if i == 0 { "10" } else { "5" }, true), + ], + ..Default::default() + }; + XfaNode { + name: "form1".into(), + som: "form1[0]".into(), + kind: Some(XfaKind::Subform), + children: vec![XfaNode { + name: "page1".into(), + som: "form1[0].page1[0]".into(), + kind: Some(XfaKind::Subform), + children: vec![ + field("qty", "form1[0].page1[0].qty[0]", "4", true), + field("price", "form1[0].page1[0].price[0]", "2.5", true), + field("total", "form1[0].page1[0].total[0]", "", true), + XfaNode { + name: "details".into(), + som: "form1[0].page1[0].details[0]".into(), + kind: Some(XfaKind::Subform), + presence: "hidden".into(), + children: vec![field("note", "form1[0].page1[0].details[0].note[0]", "", false)], + ..Default::default() + }, + XfaNode { + name: "table".into(), + som: "form1[0].page1[0].table[0]".into(), + kind: Some(XfaKind::Subform), + children: vec![row(0), row(1)], + ..Default::default() + }, + field("go", "form1[0].page1[0].go[0]", "", false), + ], + ..Default::default() + }], + ..Default::default() + } + } + + fn go(script: &str, target: &str, activity: &str) -> XfaOutcome { + let ev = XfaEvent { activity: activity.into(), target: target.into(), ..Default::default() }; + let doc = XfaDoc { file_name: "f.pdf".into(), page: 1, page_count: 3 }; + run_xfa(script, &ev, &doc, &form(), Limits::default()) + } + + #[test] + fn calculate_scripts_see_siblings_by_name_and_yield_their_last_expression() { + let o = go("qty.rawValue * price.rawValue", "form1[0].page1[0].total[0]", "calculate"); + assert_eq!(o.error, None, "{o:?}"); + assert_eq!(o.result.as_deref(), Some("10")); + let o = go("this.rawValue = xfa.form.form1.page1.qty.rawValue + 1;", "form1[0].page1[0].total[0]", "calculate"); + assert_eq!(o.effects, vec![XfaEffect::SetValue { som: "form1[0].page1[0].total[0]".into(), value: "5".into() }]); + } + + #[test] + fn values_read_as_numbers_or_strings_and_null_when_empty() { + let o = go( + "typeof qty.rawValue + ':' + typeof this.rawValue + ':' + (total.rawValue === null) + ':' + typeof table.row.what.rawValue", + "form1[0].page1[0].price[0]", + "click", + ); + assert_eq!(o.result.as_deref(), Some("number:number:true:string")); + } + + #[test] + fn navigation_parent_resolve_node_and_nodes() { + let o = go( + "this.parent.name + '/' + this.parent.parent.className + '/' + this.resolveNode('qty').rawValue + '/' + xfa.resolveNode('$form.form1.page1.table.row[1].amount').rawValue + '/' + this.parent.nodes.length + '/' + xfa.resolveNodes('form1.page1.table.row[*]').length + '/' + xfa.resolveNode('$form..amount').somExpression", + "form1[0].page1[0].price[0]", + "click", + ); + assert_eq!(o.error, None, "{o:?}"); + assert_eq!(o.result.as_deref(), Some("page1/subform/4/5/6/2/form1[0].page1[0].table[0].row[0].amount[0]")); + } + + #[test] + fn presence_and_access_changes_are_effects() { + let o = go("details.presence = 'visible'; details.note.access = 'readOnly'; this.presence = 'nonsense';", "form1[0].page1[0].go[0]", "click"); + assert_eq!(o.error, None, "{o:?}"); + assert_eq!( + o.effects, + vec![ + XfaEffect::SetPresence { som: "form1[0].page1[0].details[0]".into(), presence: "visible".into() }, + XfaEffect::SetAccess { som: "form1[0].page1[0].details[0].note[0]".into(), access: "readOnly".into() }, + ] + ); + } + + #[test] + fn instance_managers_add_remove_and_count() { + let o = go( + "var n = table._row.count; var r = table._row.addInstance(1); r.what.rawValue = 'New'; n + '/' + table._row.count + '/' + r.somExpression + '/' + r.index", + "form1[0].page1[0].go[0]", + "click", + ); + assert_eq!(o.error, None, "{o:?}"); + assert_eq!(o.result.as_deref(), Some("2/3/form1[0].page1[0].table[0].row[2]/2")); + assert_eq!( + o.effects, + vec![ + XfaEffect::AddInstance { som: "form1[0].page1[0].table[0].row[2]".into() }, + XfaEffect::SetValue { som: "form1[0].page1[0].table[0].row[2].what[0]".into(), value: "New".into() }, + ] + ); + // Through the instance manager property, from inside a row; removing renumbers. + let o = go( + "this.parent.instanceManager.removeInstance(0); xfa.resolveNode('table.row[0]').amount.rawValue", + "form1[0].page1[0].table[0].row[1].amount[0]", + "click", + ); + assert_eq!(o.error, None, "{o:?}"); + assert_eq!(o.result.as_deref(), Some("5"), "the second row is now the first"); + assert_eq!(o.effects, vec![XfaEffect::RemoveInstance { som: "form1[0].page1[0].table[0].row[0]".into() }]); + // setInstances, and the floor of one instance. + let o = go( + "table._row.setInstances(4); var a = table._row.count; table._row.setInstances(0); a + '/' + table._row.count", + "form1[0].page1[0].go[0]", + "click", + ); + assert_eq!(o.result.as_deref(), Some("4/1")); + } + + #[test] + fn host_layout_event_and_app_calls_become_effects() { + let o = go( + "xfa.host.messageBox('Hi', 'T', 3, 1); xfa.host.resetData('qty, price'); xfa.host.print(1, '0', '2', 0, 0, 0, 0, 0); app.execMenuItem('SaveAs'); app.launchURL('https://x.test'); xfa.host.setFocus('qty'); xfa.form.recalculate(1); console.println('p' + xfa.layout.page(this) + '/' + xfa.layout.pageCount() + '/' + xfa.host.currentPage + '/' + xfa.event.name); this.border.fill.color.value = '255,0,0'; this.fillColor = 'x'; util.printf('%d', 7)", + "form1[0].page1[0].go[0]", + "click", + ); + assert_eq!(o.error, None, "{o:?}"); + assert_eq!( + o.effects, + vec![ + XfaEffect::MessageBox("Hi".into()), + XfaEffect::ResetData(vec!["qty".into(), "price".into()]), + XfaEffect::Print, + XfaEffect::SaveAs, + XfaEffect::LaunchUrl("https://x.test".into()), + XfaEffect::SetFocus("form1[0].page1[0].qty[0]".into()), + XfaEffect::Recalculate, + ] + ); + assert_eq!(o.console, vec!["p2/3/1/click".to_string()]); + assert_eq!(o.result.as_deref(), Some("7")); + } + + #[test] + fn field_names_never_shadow_javascript_builtins() { + let mut f = form(); + f.children[0].children.push(field("Date", "form1[0].page1[0].Date[0]", "x", false)); + f.children[0].children.push(field("eval", "form1[0].page1[0].eval[0]", "y", false)); + let ev = XfaEvent { activity: "click".into(), target: "form1[0].page1[0].go[0]".into(), ..Default::default() }; + let o = run_xfa( + "typeof new Date().getTime() + ':' + this.parent.Date.rawValue + ':' + this.parent.eval.rawValue", + &ev, + &XfaDoc::default(), + &f, + Limits::default(), + ); + assert_eq!(o.error, None, "{o:?}"); + assert_eq!(o.result.as_deref(), Some("number:x:y")); + } + + #[test] + fn validate_scripts_give_a_verdict_and_errors_are_reported() { + let o = go("this.rawValue === null || this.rawValue <= 3", "form1[0].page1[0].qty[0]", "validate"); + assert_eq!(o.result_bool, Some(false)); + let o = go("nosuch.rawValue = 1", "form1[0].page1[0].qty[0]", "click"); + assert!(o.error.is_some() && o.effects.is_empty()); + let o = go("while (true) {}", "form1[0].page1[0].qty[0]", "click"); + assert!(o.error.is_some(), "the loop limit stops it"); + let o = go(&"(".repeat(200), "form1[0].page1[0].qty[0]", "click"); + assert!(o.error.is_some()); + // Adding instances is capped; a runaway loop can't blow the tree up. + let o = go("for (var i = 0; i < 5000; i++) table._row.addInstance(1); table._row.count", "form1[0].page1[0].go[0]", "click"); + assert_eq!(o.result.as_deref(), Some("1002")); + } + + #[test] + fn values_set_again_merge_and_hostile_loops_are_capped() { + // A calculate that sets its own value in a loop leaves one effect: the last value. + let o = go("for (var i = 0; i < 50000; i++) this.rawValue = i;", "form1[0].page1[0].total[0]", "calculate"); + assert_eq!(o.effects, vec![XfaEffect::SetValue { som: "form1[0].page1[0].total[0]".into(), value: "49999".into() }], "{:?}", o.error); + // Order-dependent effects in between keep the values on either side. + let o = go("qty.rawValue = 1; xfa.host.resetData(); qty.rawValue = 2; qty.rawValue = 3;", "form1[0].page1[0]", "click"); + assert_eq!(o.effects.len(), 3, "{:?}", o.effects); + assert_eq!(o.effects.last(), Some(&XfaEffect::SetValue { som: "form1[0].page1[0].qty[0]".into(), value: "3".into() })); + // Message boxes and console lines in a loop stop at their caps, and say so. + let o = go("for (var i = 0; i < 100000; i++) { xfa.host.messageBox('m' + i); console.println('c' + i); }", "form1[0].page1[0]", "initialize"); + let alerts = o.effects.iter().filter(|e| matches!(e, XfaEffect::MessageBox(_))).count(); + assert_eq!(alerts, MAX_ALERTS); + assert_eq!(o.console.len(), MAX_CONSOLE); + assert!(o.notes.iter().any(|n| n.contains("messages")) && o.notes.iter().any(|n| n.contains("console")), "{:?}", o.notes); + // Distinct effects stop at the effect cap. + let o = go("for (var i = 0; i < 30000; i++) xfa.host.beep();", "form1[0].page1[0]", "click"); + assert_eq!(o.effects.len(), MAX_EFFECTS); + assert!(o.notes.iter().any(|n| n.contains("changes")), "{:?}", o.notes); + // Long messages are cut. + let o = go("xfa.host.messageBox(new Array(100000).join('x'));", "form1[0].page1[0]", "click"); + assert!(matches!(&o.effects[0], XfaEffect::MessageBox(m) if m.chars().count() <= 4_097)); + } +} diff --git a/crates/js/src/xfa.rs b/crates/js/src/xfa.rs new file mode 100644 index 00000000..94801f32 --- /dev/null +++ b/crates/js/src/xfa.rs @@ -0,0 +1,1341 @@ +//! The XFA scripting object model (XFA 3.3 part 6, "Scripting"): `xfa.form`, `this`, `xfa.host`, +//! `xfa.layout`, `xfa.event`, `xfa.datasets`, instance managers and SOM resolution, as native +//! objects over a snapshot of the form tree the caller hands in. Scripts work on the snapshot +//! and everything they change or ask for comes back as [`XfaEffect`]s for the engine to apply. +//! +//! Form nodes are Proxies, because XFA scripts address children by name (`page1.familyName`) +//! and instance managers by `_name`. Unqualified names resolve the XFA way: the current node's +//! children, then its siblings, then each ancestor's children. + +use std::cell::RefCell; +use std::collections::HashMap; + +use boa_engine::object::ObjectInitializer; +use boa_engine::object::builtins::{JsArray, JsProxy}; +use boa_engine::property::Attribute; +use boa_engine::{Context, JsObject, JsResult, JsString, JsValue, NativeFunction, Source, js_string}; + +use crate::{Limits, arg, as_number, error, function, platform, printd, printf, printx, refuse, s, text}; + +/// What kind of template object a node is. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum XfaKind { + /// `xfa.form` itself. + Form, + Subform, + Field, + ExclGroup, + Draw, + Area, +} + +impl XfaKind { + fn class_name(self) -> &'static str { + match self { + XfaKind::Form => "form", + XfaKind::Subform => "subform", + XfaKind::Field => "field", + XfaKind::ExclGroup => "exclGroup", + XfaKind::Draw => "draw", + XfaKind::Area => "area", + } + } +} + +/// One object of the form, as the caller snapshots it. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct XfaNode { + pub name: String, + /// The SOM expression naming this instance (`form[0].head[0].familyName[0]`). + pub som: String, + pub kind: Option, + /// Fields: the raw value (ISO dates, on values for buttons); empty when null. + pub value: String, + /// Numeric fields give numbers to scripts. + pub numeric: bool, + /// `visible`, `invisible`, `hidden` or `inactive`. + pub presence: String, + /// `open`, `readOnly`, `protected` or `nonInteractive`. + pub access: String, + /// Subforms: may have several instances (`occur max` other than 1). + pub repeatable: bool, + pub occur_min: usize, + pub occur_max: Option, + /// This instance's index among its same-named siblings. + pub index: usize, + pub children: Vec, +} + +/// The event a script runs for. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct XfaEvent { + /// `click`, `calculate`, `validate`, `initialize`, `change`, `exit`, … + pub activity: String, + /// The SOM of `this`. + pub target: String, + pub new_text: String, + pub prev_text: String, +} + +/// About the document. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct XfaDoc { + pub file_name: String, + /// The current page, 0-based. + pub page: usize, + pub page_count: usize, +} + +/// Something a script changed or asked for. +#[derive(Clone, Debug, PartialEq)] +pub enum XfaEffect { + SetValue { + som: String, + value: String, + }, + SetPresence { + som: String, + presence: String, + }, + SetAccess { + som: String, + access: String, + }, + /// A new instance of a repeating subform, named by its own SOM (`…row[3]`). + AddInstance { + som: String, + }, + /// The instance at this SOM is removed. + RemoveInstance { + som: String, + }, + MessageBox(String), + /// `xfa.host.resetData()`: the named fields, or every field when empty. + ResetData(Vec), + Print, + SaveAs, + LaunchUrl(String), + SetFocus(String), + Beep, + /// `xfa.form.recalculate(true)`. + Recalculate, + /// `xfa.form.remerge()`, `xfa.layout.relayout()`. + Relayout, +} + +/// What running a script did. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct XfaOutcome { + pub effects: Vec, + pub console: Vec, + /// The script's error, if it failed. + pub error: Option, + /// The script's completion value (a calculate script's result; a validate script's verdict). + pub result: Option, + /// The completion value as a boolean, when it was one. + pub result_bool: Option, + /// Limits the script ran into (too many effects, message boxes or console lines): what + /// was left out, for the caller to report. + pub notes: Vec, + /// The script ran past its time limit and was left running on its own thread (the engine + /// can't interrupt it): the caller should run no more of this form's scripts. + pub abandoned: bool, +} + +/// Most nodes a snapshot may hold. +const MAX_NODES: usize = 200_000; +/// Most instances a script may add in one run. +const MAX_ADDED: usize = 1_000; +/// Deepest tree followed. +const MAX_DEPTH: usize = 64; +/// Most effects one run may produce (after values set again on the same object are merged). +pub const MAX_EFFECTS: usize = 10_000; +/// Most message boxes one run may show. +pub const MAX_ALERTS: usize = 100; +/// Most console lines one run may print. +pub const MAX_CONSOLE: usize = 1_000; +/// Longest message or console line kept, in characters. +const MAX_LINE: usize = 4_096; + +/// `m` cut to [`MAX_LINE`] characters. +fn clip(m: String) -> String { + match m.char_indices().nth(MAX_LINE) { + Some((at, _)) => format!("{}…", m.get(..at).unwrap_or_default()), + None => m, + } +} + +struct HNode { + name: String, + som: String, + kind: XfaKind, + value: String, + numeric: bool, + presence: String, + access: String, + repeatable: bool, + occur_min: usize, + occur_max: Option, + index: usize, + parent: Option, + children: Vec, + /// Removed by `removeInstance`: skipped everywhere. + gone: bool, +} + +struct XHost { + nodes: Vec, + effects: Vec, + console: Vec, + doc: XfaDoc, + added: usize, + /// Index of `this`. + current: usize, + /// Where the effect setting a property of an object is in `effects`, by (property, SOM): + /// setting it again replaces that effect (the last value wins) unless something that + /// depends on order (rows added or removed, a reset) came after it. + set_at: HashMap<(u8, String), usize>, + /// Effects before this index are behind an order-dependent effect. + barrier: usize, + alerts: usize, + dropped_effects: usize, + dropped_alerts: usize, + dropped_console: usize, +} + +impl XHost { + /// Record an effect: values, presence and access set again on the same object replace the + /// earlier one; past [`MAX_EFFECTS`] (or [`MAX_ALERTS`] message boxes) effects are counted + /// and dropped. + fn push(&mut self, e: XfaEffect) { + let key = match &e { + XfaEffect::SetValue { som, .. } => Some((0u8, som.clone())), + XfaEffect::SetPresence { som, .. } => Some((1, som.clone())), + XfaEffect::SetAccess { som, .. } => Some((2, som.clone())), + _ => None, + }; + if let Some(key) = key { + if let Some(&at) = self.set_at.get(&key) + && at >= self.barrier + && let Some(slot) = self.effects.get_mut(at) + { + *slot = e; + return; + } + if self.effects.len() >= MAX_EFFECTS { + self.dropped_effects += 1; + return; + } + self.set_at.insert(key, self.effects.len()); + self.effects.push(e); + return; + } + if matches!(e, XfaEffect::MessageBox(_)) { + if self.alerts >= MAX_ALERTS { + self.dropped_alerts += 1; + return; + } + self.alerts += 1; + } + if self.effects.len() >= MAX_EFFECTS { + self.dropped_effects += 1; + return; + } + if matches!(e, XfaEffect::AddInstance { .. } | XfaEffect::RemoveInstance { .. } | XfaEffect::ResetData(_)) { + self.barrier = self.effects.len() + 1; + } + self.effects.push(e); + } + + fn print(&mut self, line: String) { + if self.console.len() >= MAX_CONSOLE { + self.dropped_console += 1; + } else { + self.console.push(line); + } + } + + /// What the limits left out. + fn notes(&self) -> Vec { + let mut out = Vec::new(); + if self.dropped_effects > 0 { + out.push(format!("the script made more than {MAX_EFFECTS} changes; {} more were left out", self.dropped_effects)); + } + if self.dropped_alerts > 0 { + out.push(format!("the script showed more than {MAX_ALERTS} messages; {} more were left out", self.dropped_alerts)); + } + if self.dropped_console > 0 { + out.push(format!("the script printed more than {MAX_CONSOLE} console lines; {} more were left out", self.dropped_console)); + } + out + } +} + +type Shared = RefCell; + +fn host(ctx: &Context) -> JsResult<&Shared> { + ctx.get_data::().ok_or_else(|| error("the XFA script host is not set up")) +} + +fn flatten(n: &XfaNode, parent: Option, out: &mut Vec, depth: usize) -> Option { + if depth > MAX_DEPTH || out.len() >= MAX_NODES { + return None; + } + let idx = out.len(); + out.push(HNode { + name: n.name.clone(), + som: n.som.clone(), + kind: n.kind.unwrap_or(XfaKind::Subform), + value: n.value.clone(), + numeric: n.numeric, + presence: if n.presence.is_empty() { "visible".into() } else { n.presence.clone() }, + access: if n.access.is_empty() { "open".into() } else { n.access.clone() }, + repeatable: n.repeatable, + occur_min: n.occur_min, + occur_max: n.occur_max, + index: n.index, + parent, + children: Vec::new(), + gone: false, + }); + let mut kids = Vec::new(); + for c in &n.children { + if let Some(ci) = flatten(c, Some(idx), out, depth + 1) { + kids.push(ci); + } + } + if let Some(h) = out.get_mut(idx) { + h.children = kids; + } + Some(idx) +} + +// ── node objects ──────────────────────────────────────────────────────────────────────────── + +const IDX: &str = "__xfaNode"; + +fn node_index(target: &JsValue, ctx: &mut Context) -> JsResult { + let o = target.as_object().ok_or_else(|| error("not an XFA node"))?; + let v = o.get(JsString::from(IDX), ctx)?; + let n = v.to_number(ctx)?; + if !n.is_finite() || n < 0.0 { + return Err(error("not an XFA node")); + } + Ok(n as usize) +} + +/// A Proxy standing for node `idx`. +fn node_object(ctx: &mut Context, idx: usize) -> JsResult { + let target = ObjectInitializer::new(ctx).property(JsString::from(IDX), JsValue::from(idx as f64), Attribute::READONLY).build(); + let proxy = JsProxy::builder(target).get(node_get).set(node_set).has(node_has).build(ctx)?; + Ok(proxy.into()) +} + +/// An object that takes any property read or write without complaint (`this.border.fill…`, +/// `this.fillColor = …`): what a script sets there changes nothing here. +fn sink(ctx: &mut Context) -> JsResult { + let target = ObjectInitializer::new(ctx).build(); + let proxy = JsProxy::builder(target).get(sink_get).set(sink_set).build(ctx)?; + Ok(proxy.into()) +} + +fn sink_get(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let key = arg(args, 1); + if key.as_symbol().is_some() { + return Ok(JsValue::undefined()); + } + match text(&key, ctx)?.as_str() { + "value" | "toString" | "valueOf" => Ok(s("")), + _ => sink(ctx), + } +} + +fn sink_set(_: &JsValue, _: &[JsValue], _: &mut Context) -> JsResult { + Ok(JsValue::from(true)) +} + +fn node_has(_: &JsValue, _: &[JsValue], _: &mut Context) -> JsResult { + Ok(JsValue::from(true)) +} + +fn value_js(h: &HNode) -> JsValue { + if h.value.is_empty() { + return JsValue::null(); + } + if h.numeric + && let Some(n) = as_number(&h.value) + { + return JsValue::from(n); + } + s(&h.value) +} + +/// `(idx, name)` of the child of `parent` named `name`, first instance. +fn child_named(h: &XHost, parent: usize, name: &str) -> Option { + let p = h.nodes.get(parent)?; + p.children.iter().copied().find(|&c| h.nodes.get(c).is_some_and(|n| !n.gone && n.name == name)) +} + +/// Every live instance named `name` under `parent`, in order. +fn instances(h: &XHost, parent: usize, name: &str) -> Vec { + h.nodes + .get(parent) + .map(|p| p.children.iter().copied().filter(|&c| h.nodes.get(c).is_some_and(|n| !n.gone && n.name == name)).collect()) + .unwrap_or_default() +} + +fn list_object(ctx: &mut Context, items: &[usize]) -> JsResult { + let vals: Vec = items.iter().map(|i| JsValue::from(*i as f64)).collect(); + let arr = JsArray::from_iter(vals, ctx); + let arr_obj: JsObject = arr.into(); + fn item(_: &JsValue, args: &[JsValue], arr: &JsObject, ctx: &mut Context) -> JsResult { + let i = arg(args, 0).to_number(ctx)?; + if !i.is_finite() || i < 0.0 { + return Ok(JsValue::null()); + } + let v = JsArray::from_object(arr.clone())?.get(i as u64, ctx)?; + if v.is_undefined() { + return Ok(JsValue::null()); + } + let idx = v.to_number(ctx)? as usize; + node_object(ctx, idx) + } + let item_fn = function(ctx, NativeFunction::from_copy_closure_with_captures(item, arr_obj)); + Ok(ObjectInitializer::new(ctx) + .property(js_string!("length"), JsValue::from(items.len() as f64), Attribute::READONLY) + .property(js_string!("item"), item_fn, Attribute::all()) + .build() + .into()) +} + +/// The instance manager of the subform named `name` under `parent`. +fn instance_manager(ctx: &mut Context, parent: usize, name: &str) -> JsResult { + let (count, min, max) = { + let h = host(ctx)?.borrow(); + let live = instances(&h, parent, name); + let first = live.first().and_then(|i| h.nodes.get(*i)); + (live.len(), first.map_or(0, |n| n.occur_min), first.and_then(|n| n.occur_max)) + }; + let key = ObjectInitializer::new(ctx) + .property(js_string!("parent"), JsValue::from(parent as f64), Attribute::READONLY) + .property(js_string!("name"), s(name), Attribute::READONLY) + .build(); + fn target_of(key: &JsObject, ctx: &mut Context) -> JsResult<(usize, String)> { + let p = key.get(js_string!("parent"), ctx)?.to_number(ctx)? as usize; + let n = text(&key.get(js_string!("name"), ctx)?, ctx)?; + Ok((p, n)) + } + fn add_instance(_: &JsValue, _: &[JsValue], key: &JsObject, ctx: &mut Context) -> JsResult { + let (p, n) = target_of(key, ctx)?; + match add_instance_of(ctx, p, &n)? { + Some(i) => node_object(ctx, i), + None => Ok(JsValue::null()), + } + } + fn insert_instance(this: &JsValue, args: &[JsValue], key: &JsObject, ctx: &mut Context) -> JsResult { + add_instance(this, args, key, ctx) + } + fn remove_instance(_: &JsValue, args: &[JsValue], key: &JsObject, ctx: &mut Context) -> JsResult { + let (p, n) = target_of(key, ctx)?; + let i = arg(args, 0).to_number(ctx)?; + if i.is_finite() && i >= 0.0 { + remove_instance_of(ctx, p, &n, i as usize)?; + } + Ok(JsValue::undefined()) + } + fn set_instances(_: &JsValue, args: &[JsValue], key: &JsObject, ctx: &mut Context) -> JsResult { + let (p, n) = target_of(key, ctx)?; + let want = arg(args, 0).to_number(ctx)?; + if !want.is_finite() || want < 0.0 { + return Ok(JsValue::undefined()); + } + let want = (want as usize).min(MAX_ADDED); + // Bounded: adding stops at the cap or the maximum, removing at the floor, and a step + // that changes nothing ends the loop. + for _ in 0..=MAX_ADDED { + let have = instances(&host(ctx)?.borrow(), p, &n).len(); + if have < want { + if add_instance_of(ctx, p, &n)?.is_none() { + break; + } + } else if have > want && have > 0 { + remove_instance_of(ctx, p, &n, have - 1)?; + if instances(&host(ctx)?.borrow(), p, &n).len() == have { + break; + } + } else { + break; + } + } + Ok(JsValue::undefined()) + } + fn move_instance(_: &JsValue, _: &[JsValue], _: &JsObject, _: &mut Context) -> JsResult { + Ok(JsValue::undefined()) + } + fn count_get(_: &JsValue, _: &[JsValue], key: &JsObject, ctx: &mut Context) -> JsResult { + let (p, n) = target_of(key, ctx)?; + let c = instances(&host(ctx)?.borrow(), p, &n).len(); + Ok(JsValue::from(c as f64)) + } + fn count_set(_: &JsValue, args: &[JsValue], key: &JsObject, ctx: &mut Context) -> JsResult { + set_instances(&JsValue::undefined(), args, key, ctx) + } + let count_g = function(ctx, NativeFunction::from_copy_closure_with_captures(count_get, key.clone())); + let count_s = function(ctx, NativeFunction::from_copy_closure_with_captures(count_set, key.clone())); + let add = function(ctx, NativeFunction::from_copy_closure_with_captures(add_instance, key.clone())); + let insert = function(ctx, NativeFunction::from_copy_closure_with_captures(insert_instance, key.clone())); + let remove = function(ctx, NativeFunction::from_copy_closure_with_captures(remove_instance, key.clone())); + let set = function(ctx, NativeFunction::from_copy_closure_with_captures(set_instances, key.clone())); + let mv = function(ctx, NativeFunction::from_copy_closure_with_captures(move_instance, key)); + let _ = count; + let o = ObjectInitializer::new(ctx) + .property(js_string!("min"), JsValue::from(min as f64), Attribute::READONLY) + .property(js_string!("max"), max.map_or(JsValue::from(-1.0), |m| JsValue::from(m as f64)), Attribute::READONLY) + .property(js_string!("addInstance"), add, Attribute::all()) + .property(js_string!("insertInstance"), insert, Attribute::all()) + .property(js_string!("removeInstance"), remove, Attribute::all()) + .property(js_string!("setInstances"), set, Attribute::all()) + .property(js_string!("moveInstance"), mv, Attribute::all()) + .property(js_string!("className"), s("instanceManager"), Attribute::READONLY) + .build(); + use boa_engine::property::PropertyDescriptor; + o.define_property_or_throw(js_string!("count"), PropertyDescriptor::builder().get(count_g).set(count_s).configurable(true), ctx)?; + Ok(o.into()) +} + +/// Re-derive the SOM of `idx` and everything under it from its parent and index. +fn renumber(h: &mut XHost, idx: usize, depth: usize) { + if depth > MAX_DEPTH { + return; + } + let (parent_som, name, index, kids) = { + let Some(n) = h.nodes.get(idx) else { return }; + let parent_som = n.parent.and_then(|p| h.nodes.get(p)).map(|p| p.som.clone()).unwrap_or_default(); + (parent_som, n.name.clone(), n.index, n.children.clone()) + }; + let som = if name.starts_with('#') || name.is_empty() { + parent_som + } else if parent_som.is_empty() { + format!("{name}[{index}]") + } else { + format!("{parent_som}.{name}[{index}]") + }; + if let Some(n) = h.nodes.get_mut(idx) { + n.som = som; + } + for k in kids { + renumber(h, k, depth + 1); + } +} + +/// A copy of `src`'s subtree (values cleared) under `parent`, returning the copy's index. +fn clone_subtree(h: &mut XHost, src: usize, parent: Option, depth: usize) -> Option { + if depth > MAX_DEPTH || h.nodes.len() >= MAX_NODES { + return None; + } + let (template, kids) = { + let n = h.nodes.get(src)?; + ( + HNode { + name: n.name.clone(), + som: String::new(), + kind: n.kind, + value: String::new(), + numeric: n.numeric, + presence: n.presence.clone(), + access: n.access.clone(), + repeatable: n.repeatable, + occur_min: n.occur_min, + occur_max: n.occur_max, + index: n.index, + parent, + children: Vec::new(), + gone: false, + }, + n.children.clone(), + ) + }; + let idx = h.nodes.len(); + h.nodes.push(template); + let mut copied = Vec::new(); + for k in kids { + // Nested repeating subforms copy their first instance only. + let skip = h.nodes.get(k).is_some_and(|c| c.gone || c.repeatable && c.index > 0); + if skip { + continue; + } + if let Some(ci) = clone_subtree(h, k, Some(idx), depth + 1) { + copied.push(ci); + } + } + if let Some(n) = h.nodes.get_mut(idx) { + n.children = copied; + } + Some(idx) +} + +fn add_instance_of(ctx: &mut Context, parent: usize, name: &str) -> JsResult> { + let mut h = host(ctx)?.borrow_mut(); + let live = instances(&h, parent, name); + let Some(&first) = live.first() else { return Ok(None) }; + let (repeatable, max) = h.nodes.get(first).map(|n| (n.repeatable, n.occur_max)).unwrap_or((false, Some(1))); + if !repeatable || max.is_some_and(|m| live.len() >= m) || h.added >= MAX_ADDED { + return Ok(None); + } + let Some(new) = clone_subtree(&mut h, first, Some(parent), 0) else { return Ok(None) }; + let last = live.last().copied().unwrap_or(first); + if let Some(n) = h.nodes.get_mut(new) { + n.index = live.len(); + } + // Keep instances together: right after the last one. + if let Some(p) = h.nodes.get_mut(parent) { + let pos = p.children.iter().position(|&c| c == last).map_or(p.children.len(), |i| i + 1); + p.children.insert(pos, new); + } + renumber(&mut h, new, 0); + h.added += 1; + let som = h.nodes.get(new).map(|n| n.som.clone()).unwrap_or_default(); + h.push(XfaEffect::AddInstance { som }); + Ok(Some(new)) +} + +fn remove_instance_of(ctx: &mut Context, parent: usize, name: &str, i: usize) -> JsResult<()> { + let mut h = host(ctx)?.borrow_mut(); + let live = instances(&h, parent, name); + let Some(&gone) = live.get(i) else { return Ok(()) }; + let min = h.nodes.get(gone).map_or(0, |n| n.occur_min).max(1); + if live.len() <= min { + return Ok(()); + } + let som = h.nodes.get(gone).map(|n| n.som.clone()).unwrap_or_default(); + if let Some(n) = h.nodes.get_mut(gone) { + n.gone = true; + } + // Later instances move down one. + for (k, &inst) in live.iter().enumerate().skip(i + 1) { + if let Some(n) = h.nodes.get_mut(inst) { + n.index = k - 1; + } + renumber(&mut h, inst, 0); + } + h.push(XfaEffect::RemoveInstance { som }); + Ok(()) +} + +/// Resolve a SOM expression from node `from`: `$`, `$form`, `$record`, `$host`, `xfa.form…`, +/// `parent`, `..name` (descendant search), `name[n]`, `name[*]`, `_name`. +fn resolve_som(h: &XHost, from: usize, expr: &str) -> Vec { + let expr = expr.trim(); + if expr.is_empty() { + return vec![from]; + } + let root = 0usize; + let mut cur: Vec = vec![from]; + let mut rest = expr; + let starts = + [("$form", root), ("xfa.form", root), ("$record", root), ("xfa.datasets.data", root), ("xfa.record", root), ("$", from), ("this", from)]; + for (prefix, at) in starts { + if let Some(r) = rest.strip_prefix(prefix) + && (r.is_empty() || r.starts_with('.') || r.starts_with('[')) + { + cur = vec![at]; + rest = r; + break; + } + } + let mut depth = 0; + while !rest.is_empty() && depth < MAX_DEPTH { + depth += 1; + let descend = rest.starts_with(".."); + rest = rest.trim_start_matches('.'); + let end = rest.find(['.', '[']).unwrap_or(rest.len()); + let (name, after) = rest.split_at(end); + let name = name.trim(); + let (index, after) = match after.strip_prefix('[') { + Some(a) => { + let close = a.find(']').unwrap_or(a.len()); + let idx = a.get(..close).map(str::trim).unwrap_or(""); + (Some(idx.to_string()), a.get(close + 1..).unwrap_or("")) + } + None => (None, after), + }; + rest = after; + let mut next = Vec::new(); + for &c in &cur { + match name { + "" => {} + "parent" => { + if let Some(p) = h.nodes.get(c).and_then(|n| n.parent) { + next.push(p); + } + } + _ => { + let mut found: Vec = if descend { descendants_named(h, c, name, 0) } else { instances(h, c, name) }; + // A relative name a node has no child for is looked for among its siblings + // and in each enclosing container, nearest first (so a field's script can + // say `resolveNode("otherField")`). + if found.is_empty() && depth == 1 && !descend { + let mut at = h.nodes.get(c).and_then(|n| n.parent); + let mut guard = 0; + while let Some(p) = at + && guard < MAX_DEPTH + { + found = instances(h, p, name); + if !found.is_empty() { + break; + } + at = h.nodes.get(p).and_then(|n| n.parent); + guard += 1; + } + } + match index.as_deref() { + Some("*") => next.extend(found), + Some(i) => { + if let Some(k) = i.parse::().ok().and_then(|i| found.get(i)) { + next.push(*k); + } + } + None => next.extend(found.first()), + } + } + } + } + cur = next; + if cur.is_empty() { + break; + } + } + cur +} + +fn descendants_named(h: &XHost, from: usize, name: &str, depth: usize) -> Vec { + let mut out = Vec::new(); + if depth > MAX_DEPTH { + return out; + } + let Some(n) = h.nodes.get(from) else { return out }; + for &c in &n.children { + if let Some(k) = h.nodes.get(c) + && !k.gone + { + if k.name == name { + out.push(c); + } else { + out.extend(descendants_named(h, c, name, depth + 1)); + } + } + } + out +} + +fn node_get(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let idx = node_index(&arg(args, 0), ctx)?; + let key = arg(args, 1); + if key.as_symbol().is_some() { + return Ok(JsValue::undefined()); + } + let key = text(&key, ctx)?; + let snapshot = { + let h = host(ctx)?.borrow(); + let Some(n) = h.nodes.get(idx) else { return Ok(JsValue::undefined()) }; + (n.name.clone(), n.som.clone(), n.kind, n.value.clone(), n.presence.clone(), n.access.clone(), n.index, n.parent, n.children.clone()) + }; + let (name, som, kind, value, presence, access, index, parent, children) = snapshot; + Ok(match key.as_str() { + IDX => JsValue::from(idx as f64), + "rawValue" | "value" => host(ctx)?.borrow().nodes.get(idx).map_or(JsValue::null(), value_js), + "formattedValue" => s(&value), + "name" => s(&name), + "somExpression" => s(&som), + "className" => s(kind.class_name()), + "index" | "instanceIndex" => JsValue::from(index as f64), + "presence" => s(&presence), + "access" => s(&access), + "isNull" => JsValue::from(value.is_empty()), + "mandatory" => s("disabled"), + "parent" => match parent { + Some(p) => node_object(ctx, p)?, + None => JsValue::null(), + }, + "nodes" => { + let live: Vec = { + let h = host(ctx)?.borrow(); + children.iter().copied().filter(|&c| h.nodes.get(c).is_some_and(|n| !n.gone)).collect() + }; + list_object(ctx, &live)? + } + "all" => { + let live = match parent { + Some(p) => instances(&host(ctx)?.borrow(), p, &name), + None => vec![idx], + }; + list_object(ctx, &live)? + } + "instanceManager" => match parent { + Some(p) => instance_manager(ctx, p, &name)?, + None => JsValue::undefined(), + }, + "resolveNode" => resolver(ctx, idx, false)?, + "resolveNodes" => resolver(ctx, idx, true)?, + "recalculate" => function(ctx, NativeFunction::from_fn_ptr(recalculate)).into(), + "remerge" => function(ctx, NativeFunction::from_fn_ptr(relayout)).into(), + "getAttribute" + | "setAttribute" + | "execEvent" + | "execInitialize" + | "execCalculate" + | "execValidate" + | "getElement" + | "setElement" + | "isPropertySpecified" + | "loadXML" + | "saveXML" + | "clone" => function(ctx, NativeFunction::from_fn_ptr(noop)).into(), + "toString" | "valueOf" | "toJSON" => function(ctx, NativeFunction::from_fn_ptr(node_to_string)).into(), + "length" => JsValue::undefined(), + "border" | "ui" | "font" | "caption" | "fillColor" | "borderColor" | "fontColor" | "margin" | "para" | "assist" | "validate" | "format" + | "items" | "bind" | "keep" | "h" | "w" | "x" | "y" | "minH" | "maxH" | "minW" | "maxW" | "layout" | "model" | "oneOfChild" | "locale" + | "relevant" | "colSpan" | "dataNode" | "defaultValue" | "editValue" | "selectedIndex" | "event" | "calculate" | "traversal" | "extras" + | "desc" => sink(ctx)?, + other => { + if let Some(child) = other.strip_prefix('_') { + let exists = child_named(&host(ctx)?.borrow(), idx, child).is_some(); + return if exists { instance_manager(ctx, idx, child) } else { Ok(JsValue::undefined()) }; + } + let found = child_named(&host(ctx)?.borrow(), idx, other); + match found { + Some(c) => node_object(ctx, c)?, + None => JsValue::undefined(), + } + } + }) +} + +fn node_set(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let idx = node_index(&arg(args, 0), ctx)?; + let key = arg(args, 1); + if key.as_symbol().is_some() { + return Ok(JsValue::from(true)); + } + let key = text(&key, ctx)?; + let value = arg(args, 2); + let value = if value.is_null() || value.is_undefined() { String::new() } else { text(&value, ctx)? }; + let mut h = host(ctx)?.borrow_mut(); + let Some(n) = h.nodes.get(idx) else { return Ok(JsValue::from(true)) }; + let som = n.som.clone(); + match key.as_str() { + "rawValue" | "value" | "formattedValue" => { + if let Some(n) = h.nodes.get_mut(idx) { + n.value = value.clone(); + } + h.push(XfaEffect::SetValue { som, value }); + } + "presence" => { + let p = match value.as_str() { + "hidden" | "invisible" | "inactive" | "visible" => value.clone(), + _ => return Ok(JsValue::from(true)), + }; + if let Some(n) = h.nodes.get_mut(idx) { + n.presence = p.clone(); + } + h.push(XfaEffect::SetPresence { som, presence: p }); + } + "access" => { + let a = match value.as_str() { + "open" | "readOnly" | "protected" | "nonInteractive" => value.clone(), + _ => return Ok(JsValue::from(true)), + }; + if let Some(n) = h.nodes.get_mut(idx) { + n.access = a.clone(); + } + h.push(XfaEffect::SetAccess { som, access: a }); + } + _ => {} + } + Ok(JsValue::from(true)) +} + +fn node_to_string(this: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { + let idx = node_index(this, ctx)?; + Ok(s(&host(ctx)?.borrow().nodes.get(idx).map(|n| n.value.clone()).unwrap_or_default())) +} + +fn noop(_: &JsValue, _: &[JsValue], _: &mut Context) -> JsResult { + Ok(JsValue::undefined()) +} + +/// `resolveNode(som)` / `resolveNodes(som)` bound to node `from`. +fn resolver(ctx: &mut Context, from: usize, many: bool) -> JsResult { + let key = ObjectInitializer::new(ctx) + .property(js_string!("from"), JsValue::from(from as f64), Attribute::READONLY) + .property(js_string!("many"), JsValue::from(many), Attribute::READONLY) + .build(); + fn resolve(_: &JsValue, args: &[JsValue], key: &JsObject, ctx: &mut Context) -> JsResult { + let from = key.get(js_string!("from"), ctx)?.to_number(ctx)? as usize; + let many = key.get(js_string!("many"), ctx)?.to_boolean(); + let expr = text(&arg(args, 0), ctx)?; + let found = resolve_som(&host(ctx)?.borrow(), from, &expr); + if many { + return list_object(ctx, &found); + } + match found.first() { + Some(i) => node_object(ctx, *i), + None => Ok(JsValue::null()), + } + } + Ok(function(ctx, NativeFunction::from_copy_closure_with_captures(resolve, key)).into()) +} + +// ── xfa.host, xfa.layout, xfa.event, app, console ─────────────────────────────────────────── + +fn effect(ctx: &mut Context, e: XfaEffect) -> JsResult<()> { + host(ctx)?.borrow_mut().push(e); + Ok(()) +} + +fn message_box(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let m = clip(text(&arg(args, 0), ctx)?); + effect(ctx, XfaEffect::MessageBox(m))?; + Ok(JsValue::from(1.0)) +} + +fn reset_data(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let list = text(&arg(args, 0), ctx)?; + let names: Vec = list.split(',').map(str::trim).filter(|s| !s.is_empty()).map(str::to_string).collect(); + effect(ctx, XfaEffect::ResetData(names))?; + Ok(JsValue::undefined()) +} + +fn print(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { + effect(ctx, XfaEffect::Print)?; + Ok(JsValue::undefined()) +} + +fn beep(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { + effect(ctx, XfaEffect::Beep)?; + Ok(JsValue::undefined()) +} + +fn goto_url(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let u = text(&arg(args, 0), ctx)?; + effect(ctx, XfaEffect::LaunchUrl(u))?; + Ok(JsValue::undefined()) +} + +fn set_focus(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let target = arg(args, 0); + let som = match target.as_object() { + Some(_) if node_index(&target, ctx).is_ok() => { + let i = node_index(&target, ctx)?; + host(ctx)?.borrow().nodes.get(i).map(|n| n.som.clone()).unwrap_or_default() + } + _ => { + let expr = text(&target, ctx)?; + let h = host(ctx)?.borrow(); + let found = resolve_som(&h, h.current, &expr); + found.first().and_then(|i| h.nodes.get(*i)).map(|n| n.som.clone()).unwrap_or(expr) + } + }; + effect(ctx, XfaEffect::SetFocus(som))?; + Ok(JsValue::undefined()) +} + +fn exec_menu_item(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let item = text(&arg(args, 0), ctx)?; + match item.as_str() { + "SaveAs" | "Save" => effect(ctx, XfaEffect::SaveAs)?, + "Print" => effect(ctx, XfaEffect::Print)?, + _ => {} + } + Ok(JsValue::undefined()) +} + +fn recalculate(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { + effect(ctx, XfaEffect::Recalculate)?; + Ok(JsValue::undefined()) +} + +fn relayout(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { + effect(ctx, XfaEffect::Relayout)?; + Ok(JsValue::undefined()) +} + +fn console_println(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let m = clip(text(&arg(args, 0), ctx)?); + host(ctx)?.borrow_mut().print(m); + Ok(JsValue::undefined()) +} + +fn response(_: &JsValue, _: &[JsValue], _: &mut Context) -> JsResult { + Ok(JsValue::null()) +} + +fn layout_page(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { + Ok(JsValue::from(host(ctx)?.borrow().doc.page as f64 + 1.0)) +} + +fn layout_page0(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { + Ok(JsValue::from(host(ctx)?.borrow().doc.page as f64)) +} + +fn layout_page_count(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { + Ok(JsValue::from(host(ctx)?.borrow().doc.page_count as f64)) +} + +fn zero(_: &JsValue, _: &[JsValue], _: &mut Context) -> JsResult { + Ok(JsValue::from(0.0)) +} + +fn xfa_resolve(ctx: &mut Context, many: bool) -> JsResult { + let current = host(ctx)?.borrow().current; + resolver(ctx, current, many) +} + +fn install(ctx: &mut Context, event: &XfaEvent, doc: &XfaDoc, current: usize) -> JsResult<()> { + let rw = Attribute::CONFIGURABLE; + let form = node_object(ctx, 0)?; + let this_node = node_object(ctx, current)?; + let host_obj = ObjectInitializer::new(ctx) + .function(NativeFunction::from_fn_ptr(message_box), js_string!("messageBox"), 4) + .function(NativeFunction::from_fn_ptr(reset_data), js_string!("resetData"), 1) + .function(NativeFunction::from_fn_ptr(print), js_string!("print"), 8) + .function(NativeFunction::from_fn_ptr(beep), js_string!("beep"), 1) + .function(NativeFunction::from_fn_ptr(goto_url), js_string!("gotoURL"), 2) + .function(NativeFunction::from_fn_ptr(set_focus), js_string!("setFocus"), 1) + .function(NativeFunction::from_fn_ptr(response), js_string!("response"), 4) + .function(NativeFunction::from_fn_ptr(noop), js_string!("openList"), 1) + .function(NativeFunction::from_fn_ptr(noop), js_string!("exportData"), 2) + .function(NativeFunction::from_fn_ptr(noop), js_string!("importData"), 1) + .function(NativeFunction::from_fn_ptr(noop), js_string!("pageDown"), 0) + .function(NativeFunction::from_fn_ptr(noop), js_string!("pageUp"), 0) + .function(NativeFunction::from_fn_ptr(noop), js_string!("getFocus"), 0) + .property(js_string!("currentPage"), JsValue::from(doc.page as f64), rw) + .property(js_string!("numPages"), JsValue::from(doc.page_count as f64), rw) + .property(js_string!("name"), s("Acrobat"), Attribute::READONLY) + .property(js_string!("appType"), s("Exchange-Pro"), Attribute::READONLY) + .property(js_string!("version"), s("11"), Attribute::READONLY) + .property(js_string!("variation"), s("Full"), Attribute::READONLY) + .property(js_string!("language"), s("en"), Attribute::READONLY) + .property(js_string!("platform"), s(platform()), Attribute::READONLY) + .property(js_string!("calculationsEnabled"), JsValue::from(true), rw) + .property(js_string!("validationsEnabled"), JsValue::from(true), rw) + .property(js_string!("title"), s(&doc.file_name), rw) + .build(); + let layout = ObjectInitializer::new(ctx) + .function(NativeFunction::from_fn_ptr(layout_page), js_string!("page"), 1) + .function(NativeFunction::from_fn_ptr(layout_page0), js_string!("absPage"), 1) + .function(NativeFunction::from_fn_ptr(layout_page_count), js_string!("pageCount"), 0) + .function(NativeFunction::from_fn_ptr(layout_page_count), js_string!("absPageCount"), 0) + .function(NativeFunction::from_fn_ptr(relayout), js_string!("relayout"), 0) + .function(NativeFunction::from_fn_ptr(relayout), js_string!("relayoutPageArea"), 0) + .function(NativeFunction::from_fn_ptr(zero), js_string!("h"), 2) + .function(NativeFunction::from_fn_ptr(zero), js_string!("w"), 2) + .function(NativeFunction::from_fn_ptr(zero), js_string!("x"), 2) + .function(NativeFunction::from_fn_ptr(zero), js_string!("y"), 2) + .function(NativeFunction::from_fn_ptr(zero), js_string!("pageSpan"), 1) + .property(js_string!("ready"), JsValue::from(true), Attribute::READONLY) + .build(); + let ev = ObjectInitializer::new(ctx) + .property(js_string!("name"), s(&event.activity), Attribute::all()) + .property(js_string!("newText"), s(&event.new_text), Attribute::all()) + .property(js_string!("prevText"), s(&event.prev_text), Attribute::all()) + .property(js_string!("fullText"), s(&event.new_text), Attribute::all()) + .property(js_string!("change"), s(""), Attribute::all()) + .property(js_string!("target"), this_node.clone(), Attribute::all()) + .property(js_string!("selStart"), JsValue::from(0.0), Attribute::all()) + .property(js_string!("selEnd"), JsValue::from(0.0), Attribute::all()) + .property(js_string!("cancelAction"), JsValue::from(false), Attribute::all()) + .property(js_string!("reenter"), JsValue::from(false), Attribute::all()) + .property(js_string!("shift"), JsValue::from(false), Attribute::all()) + .property(js_string!("modifier"), JsValue::from(false), Attribute::all()) + .property(js_string!("keyDown"), JsValue::from(false), Attribute::all()) + .property(js_string!("commitKey"), JsValue::from(0.0), Attribute::all()) + .function(NativeFunction::from_fn_ptr(noop), js_string!("emit"), 0) + .function(NativeFunction::from_fn_ptr(noop), js_string!("reset"), 0) + .build(); + let datasets = ObjectInitializer::new(ctx).property(js_string!("data"), form.clone(), rw).build(); + let template = sink(ctx)?; + let resolve_one = xfa_resolve(ctx, false)?; + let resolve_many = xfa_resolve(ctx, true)?; + let xfa = ObjectInitializer::new(ctx) + .property(js_string!("form"), form.clone(), rw) + .property(js_string!("host"), host_obj, rw) + .property(js_string!("layout"), layout, rw) + .property(js_string!("event"), ev.clone(), rw) + .property(js_string!("datasets"), datasets, rw) + .property(js_string!("record"), form.clone(), rw) + .property(js_string!("template"), template, rw) + .property(js_string!("resolveNode"), resolve_one, rw) + .property(js_string!("resolveNodes"), resolve_many, rw) + .function(NativeFunction::from_fn_ptr(recalculate), js_string!("recalculate"), 1) + .function(NativeFunction::from_fn_ptr(relayout), js_string!("remerge"), 0) + .build(); + ctx.register_global_property(js_string!("xfa"), xfa, rw)?; + ctx.register_global_property(js_string!("event"), ev, rw)?; + ctx.register_global_property(js_string!("__xfa_this"), this_node, rw)?; + let app = ObjectInitializer::new(ctx) + .function(NativeFunction::from_fn_ptr(message_box), js_string!("alert"), 4) + .function(NativeFunction::from_fn_ptr(beep), js_string!("beep"), 1) + .function(NativeFunction::from_fn_ptr(response), js_string!("response"), 1) + .function(NativeFunction::from_fn_ptr(goto_url), js_string!("launchURL"), 2) + .function(NativeFunction::from_fn_ptr(exec_menu_item), js_string!("execMenuItem"), 1) + .function(NativeFunction::from_fn_ptr(noop), js_string!("setTimeOut"), 2) + .function(NativeFunction::from_fn_ptr(noop), js_string!("setInterval"), 2) + .function(NativeFunction::from_fn_ptr(noop), js_string!("clearTimeOut"), 1) + .function(NativeFunction::from_fn_ptr(noop), js_string!("clearInterval"), 1) + .property(js_string!("viewerType"), s("Exchange-Pro"), Attribute::READONLY) + .property(js_string!("viewerVariation"), s("Full"), Attribute::READONLY) + .property(js_string!("viewerVersion"), JsValue::from(24.0), Attribute::READONLY) + .property(js_string!("platform"), s(platform()), Attribute::READONLY) + .property(js_string!("language"), s("ENU"), Attribute::READONLY) + .build(); + ctx.register_global_property(js_string!("app"), app, rw)?; + let console = ObjectInitializer::new(ctx) + .function(NativeFunction::from_fn_ptr(console_println), js_string!("println"), 1) + .function(NativeFunction::from_fn_ptr(noop), js_string!("show"), 0) + .function(NativeFunction::from_fn_ptr(noop), js_string!("hide"), 0) + .function(NativeFunction::from_fn_ptr(noop), js_string!("clear"), 0) + .build(); + ctx.register_global_property(js_string!("console"), console, rw)?; + let util = ObjectInitializer::new(ctx) + .function(NativeFunction::from_fn_ptr(printf), js_string!("printf"), 1) + .function(NativeFunction::from_fn_ptr(printd), js_string!("printd"), 2) + .function(NativeFunction::from_fn_ptr(printx), js_string!("printx"), 2) + .build(); + ctx.register_global_property(js_string!("util"), util, rw)?; + // Unqualified names: the current node's children, its siblings, then each ancestor's + // children, the nearest first. Instance managers as `_name`. + let scopes: Vec = { + let h = host(ctx)?.borrow(); + let mut out = vec![current]; + let mut at = h.nodes.get(current).and_then(|n| n.parent); + let mut guard = 0; + while let Some(p) = at + && guard < MAX_DEPTH + { + out.push(p); + at = h.nodes.get(p).and_then(|n| n.parent); + guard += 1; + } + out + }; + // Names that stay what JavaScript (or this host) means by them: a field called `Date` + // is reached as `this.parent.Date` or by resolveNode. + const RESERVED: &[&str] = &[ + "xfa", + "event", + "app", + "console", + "util", + "this", + "__xfa_this", + "__xfa_src", + "eval", + "arguments", + "globalThis", + "window", + "undefined", + "NaN", + "Infinity", + "Object", + "Function", + "Array", + "Number", + "String", + "Boolean", + "Symbol", + "Date", + "RegExp", + "Error", + "TypeError", + "RangeError", + "SyntaxError", + "Math", + "JSON", + "Map", + "Set", + "WeakMap", + "WeakSet", + "Promise", + "Proxy", + "Reflect", + "parseInt", + "parseFloat", + "isNaN", + "isFinite", + "encodeURIComponent", + "decodeURIComponent", + "encodeURI", + "decodeURI", + "escape", + "unescape", + "BigInt", + "ArrayBuffer", + "DataView", + "Intl", + ]; + let mut defined: Vec = RESERVED.iter().map(|s| s.to_string()).collect(); + for scope in scopes { + let kids: Vec<(usize, String, bool)> = { + let h = host(ctx)?.borrow(); + h.nodes + .get(scope) + .map(|n| n.children.iter().filter_map(|&c| h.nodes.get(c).map(|k| (c, k.name.clone(), k.repeatable && !k.gone))).collect()) + .unwrap_or_default() + }; + for (c, name, repeatable) in kids { + if !is_identifier(&name) || defined.contains(&name) { + continue; + } + let obj = node_object(ctx, c)?; + ctx.register_global_property(JsString::from(name.as_str()), obj, rw)?; + if repeatable { + let im = instance_manager(ctx, scope, &name)?; + ctx.register_global_property(JsString::from(format!("_{name}").as_str()), im, rw)?; + } + defined.push(name); + } + } + Ok(()) +} + +fn is_identifier(name: &str) -> bool { + let mut chars = name.chars(); + match chars.next() { + Some(c) if c.is_ascii_alphabetic() || c == '_' || c == '$' => {} + _ => return false, + } + chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$') + && !matches!( + name, + "var" + | "let" + | "const" + | "function" + | "return" + | "if" + | "else" + | "for" + | "while" + | "new" + | "null" + | "true" + | "false" + | "undefined" + | "typeof" + ) +} + +/// Run `script` for `event` on the form `root` (the root subform), from node `event.target`. +pub fn run_xfa(script: &str, event: &XfaEvent, doc: &XfaDoc, root: &XfaNode, limits: Limits) -> XfaOutcome { + let failed = |why: String| XfaOutcome { error: Some(why), ..Default::default() }; + if let Some(why) = refuse(script) { + return failed(why); + } + #[cfg(not(target_arch = "wasm32"))] + { + std::thread::scope(|scope| { + let spawned = std::thread::Builder::new() + .name("pdfcraft-xfa-js".into()) + .stack_size(crate::SCRIPT_STACK) + .spawn_scoped(scope, || run_here(script, event, doc, root, 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, root, limits) +} + +/// [`run_xfa`] with a time limit: a script still running after `timeout` (loops inside +/// nested function calls can run for hours within the engine's per-frame loop limit) is left +/// on its thread, which ends when the engine's own limits stop it, and an outcome with +/// `abandoned` set comes back at once. In the browser build there are no threads: the script +/// runs to its limits. +pub fn run_xfa_within(script: &str, event: &XfaEvent, doc: &XfaDoc, root: XfaNode, limits: Limits, timeout: std::time::Duration) -> XfaOutcome { + let failed = |why: String| XfaOutcome { error: Some(why), ..Default::default() }; + if let Some(why) = refuse(script) { + return failed(why); + } + #[cfg(not(target_arch = "wasm32"))] + { + let (tx, rx) = std::sync::mpsc::channel(); + let (script, event, doc) = (script.to_string(), event.clone(), doc.clone()); + let spawned = std::thread::Builder::new().name("pdfcraft-xfa-js".into()).stack_size(crate::SCRIPT_STACK).spawn(move || { + // The receiver is gone when the caller stopped waiting: nothing to report then. + let _ = tx.send(run_here(&script, &event, &doc, &root, limits)); + }); + if let Err(e) = spawned { + return failed(format!("the script engine could not start: {e}")); + } + match rx.recv_timeout(timeout) { + Ok(o) => o, + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => XfaOutcome { + error: Some(format!("the script ran longer than {:.1} s and was abandoned", timeout.as_secs_f64())), + abandoned: true, + ..Default::default() + }, + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => failed("the script stopped with an internal error".into()), + } + } + #[cfg(target_arch = "wasm32")] + { + let _ = timeout; + run_here(script, event, doc, &root, limits) + } +} + +fn run_here(script: &str, event: &XfaEvent, doc: &XfaDoc, root: &XfaNode, limits: Limits) -> XfaOutcome { + let mut ctx = Context::default(); + ctx.runtime_limits_mut().set_loop_iteration_limit(limits.loop_iterations); + ctx.runtime_limits_mut().set_recursion_limit(limits.recursion); + // `xfa.form` is a synthetic root whose child is the template's root subform. + let form = XfaNode { name: "form".into(), kind: Some(XfaKind::Form), children: vec![root.clone()], ..Default::default() }; + let mut nodes = Vec::new(); + flatten(&form, None, &mut nodes, 0); + let current = nodes.iter().position(|n| n.som == event.target && n.kind != XfaKind::Form).unwrap_or(1.min(nodes.len().saturating_sub(1))); + ctx.insert_data(RefCell::new(XHost { + nodes, + effects: Vec::new(), + console: Vec::new(), + doc: doc.clone(), + added: 0, + current, + set_at: HashMap::new(), + barrier: 0, + alerts: 0, + dropped_effects: 0, + dropped_alerts: 0, + dropped_console: 0, + })); + let mut out = XfaOutcome::default(); + let result = (|| -> JsResult<()> { + install(&mut ctx, event, doc, current)?; + ctx.register_global_property(js_string!("__xfa_src"), s(script), Attribute::CONFIGURABLE)?; + // Direct eval inside a function called on the node: `this` is the node and the + // completion value is the script's last expression (what a calculate script yields). + let r = ctx.eval(Source::from_bytes(b"(function () { return eval(__xfa_src); }).call(__xfa_this)"))?; + if !r.is_undefined() { + // Best effort: a value that can't be made text (a symbol, say) is no failure. + out.result = Some(text(&r, &mut ctx).unwrap_or_default()); + if r.is_boolean() { + out.result_bool = Some(r.to_boolean()); + } else if r.is_null() { + out.result = Some(String::new()); + } + } + Ok(()) + })(); + if let Err(e) = result { + out.error = Some(e.to_string()); + } + if let Some(h) = ctx.remove_data::() { + let h = h.into_inner(); + out.notes = h.notes(); + out.effects = h.effects; + out.console = h.console; + } + out +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 4e227452..f511e8ed 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1757,6 +1757,9 @@ fn run_button(app: &mut PdfCraftApp, index: usize, name: &str, action: pdfcraft_ } B::Named(n) => match n.as_str() { "Print" => app.open_print(), + // What PdfCraft's XFA buttons use, and what a script's `execMenuItem("Save")` + // does too: the Save As dialog, so a click never overwrites the file unasked. + "SaveAs" => app.run_command("file.save_as"), "NextPage" => app.views[index].step_page(true), "PrevPage" => app.views[index].step_page(false), "FirstPage" => app.views[index].go_to_page(0), diff --git a/crates/ui-egui/src/js_ui.rs b/crates/ui-egui/src/js_ui.rs index e666e401..34210b39 100644 --- a/crates/ui-egui/src/js_ui.rs +++ b/crates/ui-egui/src/js_ui.rs @@ -48,6 +48,7 @@ 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::Focus(_) | Request::Beep | Request::Reset(_) => {} } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 701d73e1..1213d86a 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -744,6 +744,10 @@ impl PdfCraftApp { self.recent.insert(0, RecentFile { name: name.to_string(), path: p, pages, size }); self.recent.truncate(12); } + // What the form's scripts said while it opened (messages, errors) shows now, not with + // the next edit. + let out = self.session.take_js_output(id); + self.handle_js(id, out); Ok(()) } diff --git a/crates/ui-egui/tests/buttons.rs b/crates/ui-egui/tests/buttons.rs index f7b2c613..9c9481d6 100644 --- a/crates/ui-egui/tests/buttons.rs +++ b/crates/ui-egui/tests/buttons.rs @@ -1,20 +1,22 @@ //! Push buttons run their actions (Reset form, Named, …) without a JavaScript engine. use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; use pdfcraft_ui_egui::{Dialog, PdfCraftApp}; /// Two pages; page 1 has a text field "name" (filled), a Reset button, a Next page button and a /// Print button (a JavaScript one-liner). fn fixture() -> Vec { let objs = [ - "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 0 R 6 0 R 7 0 R 8 0 R] /DA (/Helv 0 Tf 0 g) >> >>", + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 0 R 6 0 R 7 0 R 8 0 R 9 0 R] /DA (/Helv 0 Tf 0 g) >> >>", "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 300 400] >>", - "<< /Type /Page /Parent 2 0 R /Annots [5 0 R 6 0 R 7 0 R 8 0 R] >>", + "<< /Type /Page /Parent 2 0 R /Annots [5 0 R 6 0 R 7 0 R 8 0 R 9 0 R] >>", "<< /Type /Page /Parent 2 0 R >>", "<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /V (Ada) /Rect [20 340 280 360] /P 3 0 R >>", "<< /Type /Annot /Subtype /Widget /FT /Btn /Ff 65536 /T (reset) /Rect [20 300 100 320] /P 3 0 R /A << /S /ResetForm >> >>", "<< /Type /Annot /Subtype /Widget /FT /Btn /Ff 65536 /T (next) /Rect [120 300 200 320] /P 3 0 R /A << /S /Named /N /NextPage >> >>", "<< /Type /Annot /Subtype /Widget /FT /Btn /Ff 65536 /T (print) /Rect [220 300 280 320] /P 3 0 R /AA << /U << /S /JavaScript /JS (this.print\\({bUI: true}\\);) >> >> >>", + "<< /Type /Annot /Subtype /Widget /FT /Btn /Ff 65536 /T (save) /Rect [20 260 100 280] /P 3 0 R /A << /S /Named /N /Save >> >>", ]; let mut out = b"%PDF-1.7\n".to_vec(); let mut offs = Vec::new(); @@ -48,9 +50,14 @@ fn click_field(h: &mut Harness<'static, PdfCraftApp>, name: &str) { #[test] fn buttons_reset_navigate_and_print() { - let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { + let dir = std::env::temp_dir().join(format!("pdfcraft-buttons-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("buttons.pdf"); + std::fs::write(&path, fixture()).unwrap(); + let path_str = path.to_string_lossy().into_owned(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { let mut app = PdfCraftApp::new(); - app.open_bytes("buttons.pdf", None, fixture()).unwrap(); + app.open_bytes("buttons.pdf", Some(path_str), fixture()).unwrap(); app.set_option("zoom", "150").unwrap(); app }); @@ -65,6 +72,15 @@ fn buttons_reset_navigate_and_print() { assert_eq!(h.state().dialog, Some(Dialog::Print), "this.print() opens the Print dialog"); h.state_mut().dialog = None; h.run_steps(2); + // A document's Save named action never writes the file unasked (PdfCraft's XFA buttons + // use SaveAs, which opens the Save As dialog, as a script's `execMenuItem` does): the + // reset above made the document dirty, and the click leaves it so. + let before = std::fs::read(&path).unwrap(); + click_field(&mut h, "save"); + h.run_steps(4); + h.get_by_label_contains("isn't supported"); + assert!(h.state().session.get(h.state().views[0].id).unwrap().dirty, "not saved"); + assert_eq!(std::fs::read(&path).unwrap(), before, "the file is untouched"); click_field(&mut h, "next"); h.run_steps(4); assert_eq!(h.state().views[0].current, 1, "the NextPage action"); diff --git a/crates/ui-egui/tests/xfa.rs b/crates/ui-egui/tests/xfa.rs index 12e55f9c..f03d12c4 100644 --- a/crates/ui-egui/tests/xfa.rs +++ b/crates/ui-egui/tests/xfa.rs @@ -101,3 +101,53 @@ fn ordinary_forms_and_documents_have_no_xfa_notice() { let h = open(pdf("/AcroForm << /Fields [] /XFA 42 >>", "", &[])); assert_eq!(xfa(&h), Some(pdfcraft_render::Xfa::Dynamic)); } + +/// Click the centre of a field's widget. +fn click_field(h: &mut Harness<'static, PdfCraftApp>, name: &str) { + let p = { + let s = h.state(); + let doc = s.session.get(s.views[0].id).unwrap(); + let f = doc.form.iter().find(|f| f.name == name).unwrap_or_else(|| panic!("no field {name}")); + pdfcraft_ui_egui::forms_ui::field_screen_rect(&s.views[0], &doc.info, f, 0).expect("on screen").center() + }; + h.hover_at(p); + h.run_steps(1); + h.drag_at(p); + h.run_steps(1); + h.drop_at(p); + h.run_steps(4); +} + +#[test] +fn xfa_buttons_run_their_scripts_in_the_app() { + let mut h = open(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::scripted_template())); + for _ in 0..40 { + h.run_steps(2); + if !h.state().render_pending() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + let names = |h: &Harness<'static, PdfCraftApp>| { + let s = h.state(); + s.session.get(s.views[0].id).unwrap().form.iter().map(|f| f.name.clone()).collect::>() + }; + assert!(!names(&h).iter().any(|n| n == "amount_2")); + click_field(&mut h, "addRow"); + assert!(names(&h).iter().any(|n| n == "amount_2"), "{:?}", names(&h)); + // The message box shows as a notice. + click_field(&mut h, "hello"); + h.get_by_label_contains("Hello 2"); +} + +#[test] +fn messages_from_scripts_run_on_open_show_right_away() { + let tpl = pdfcraft_xfa::fixtures::scripted_template() + .replace("if (qty.rawValue === null) qty.rawValue = 2;", r#"xfa.host.messageBox("Welcome to the form"); xfa.host.print();"#); + let mut h = open(pdfcraft_xfa::fixtures::shell(&tpl)); + h.run_steps(2); + h.get_by_label_contains("Welcome to the form"); + assert_eq!(h.state().dialog, None, "an initialize script can't open the Print dialog"); + let id = h.state().views[0].id; + assert!(h.state_mut().session.take_js_output(id).is_empty(), "nothing waits for the next edit"); +} diff --git a/crates/xfa/README.md b/crates/xfa/README.md index 1df66fb8..32bea94f 100644 --- a/crates/xfa/README.md +++ b/crates/xfa/README.md @@ -48,6 +48,26 @@ the automation tools) works on it unchanged. The engine does this when it opens Layout measures each container once per (node, width): width-less containers would otherwise be measured exponentially often in their nesting. A measurement budget backs this up. +- **Scripts** (`script`): the live form as scripts see it (`form_tree`: the instance tree with + values, presence and access, and the `ScriptEvent`s in document order, built within + `MAX_FORM_NODES` objects and cut short, with `truncated` set, past that; `has_scripts` says + whether a template has any, and the engine skips all of this for forms without), the presence + and access overrides scripts make, rows added and removed in the data (`DataOp`s, applied to + the in-memory `DataNode` while an event runs and written by `write_data_ops` as one rewrite of + the datasets packet per event), and `rerender`, which lays the form out again from template, + data and overrides. The scripts themselves run in the `js` crate's XFA object model; the + engine runs initialize and calculate on open, change, exit, validate and calculate after a + field changes, and click for buttons. + + Private keys PdfCraft writes (other viewers ignore them): + + | Key | Where | Holds | + | --- | --- | --- | + | `/PCSom` | generated fields | the field's SOM path, so values go back to the data | + | `/PCXfaLayout` | AcroForm | what laying out produced, so a saved form is not laid out again | + | `/PCXfaOverrides` | AcroForm | `<< /Presence << /som (hidden) … >> /Access << /som (readOnly) … >> >>`: presence and access scripts set, by SOM path (instance 0 of a repeating subform stands for all); applied to the template before every layout, so undo, save and reopen keep them. Adobe's viewers draw from the XFA packets and don't read it; at most 10 000 entries are read or written | + | `/PCXfaClick` | generated push buttons | `true`: the template has a click script for this button. Such buttons carry no `/A`: XFA JavaScript is not Acrobat JavaScript, so it is never written as a `/S /JavaScript` action other viewers would run. PdfCraft finds the script by the field's `/PCSom` | + ## API sketch ```rust @@ -63,9 +83,30 @@ let form = pdfcraft_xfa::layout_xml(template_xml)?; // pages of items, for - **Data binding** is the default one only: explicit `bind ref` expressions, global binding and data descriptions are not followed, and no standalone XML or XDP data file is imported or exported. -- **Scripting.** No FormCalc or XFA JavaScript (`xfa.host`, `xfa.layout`, `instanceManager`); - rows are not added by button, validations and calculations don't run. Buttons map only the - common idioms (reset, print, save as, launchURL). +- **Scripting.** JavaScript only: FormCalc scripts (the default language of older forms) are + detected and reported, not run. The object model covers what forms commonly use (`xfa.form` + navigation and SOM resolution, `rawValue`, `presence`, `access`, instance managers, + `xfa.host` messages, reset, print, focus and URLs, `xfa.layout` page numbers, `xfa.event`); + not `xfa.template`, data descriptions, `xfa.connectionSet`, `border`/`font`/`ui` properties + (reads give a sink that accepts writes), `execEvent`, or the change event per keystroke. + Validate failures show their message and keep the value, as Acrobat does for scripts. + A hidden instance of a repeating subform hides every instance (overrides apply to the template). + A repeating subform shows what its data holds once the data holds any instance, else its + `initial` count, and never fewer than one (`occur min="0"` still shows one row). + Each script runs in a fresh engine on its own thread; a form with hundreds of calculate + scripts pays that on every field change (a shared engine per event is the obvious next step). +- **Script limits.** Forms run their scripts on open without being asked, so the engine bounds + each event: a script's effects (values set again on the same object merge, the last wins; + 10 000 effects, 100 message boxes, 1 000 console lines per script and per event), its loop + iterations (100 000 per call frame for initialize, calculate and validate, 1 000 000 for + click and change), 2 000 scripts and 50 relayouts per event, and 3 s (open, changes) or 5 s + (click) of scripts per event, after which the rest are skipped and reported. Only a click may + print, save, open a link or move the focus; other events asking are noted in the console. + What open-time scripts change is noted in the document's XFA warnings. Known gaps: the + engine's loop limit is per call frame, so loops inside nested calls can run far longer; such + a script is abandoned after 1 s (3 s for a click) on its thread, which keeps running until the + engine's own limits end it, and the document's scripts are turned off. Memory a script + allocates is not capped (the same holds for AcroForm JavaScript). - **Static XFA forms** (`/NeedsRendering` absent, AcroForm fields present) keep their AcroForm; only their data is read and written. - Choice lists are text fields; signature, image, barcode and password fields are left blank; diff --git a/crates/xfa/src/data.rs b/crates/xfa/src/data.rs index a1ef847c..ba1ebc89 100644 --- a/crates/xfa/src/data.rs +++ b/crates/xfa/src/data.rs @@ -328,6 +328,8 @@ struct Want { som: String, path: DataPath, text: String, + /// Create the node even for an empty value (an instance of a repeating subform). + ensure: bool, } fn wanted(doc: &Document, fields: &[FieldDatum]) -> Vec { @@ -350,11 +352,20 @@ fn wanted(doc: &Document, fields: &[FieldDatum]) -> Vec { FieldData::Radio(sel) => sel.clone().unwrap_or_default(), FieldData::None => continue, }; - out.push(Want { som, path, text }); + out.push(Want { som, path, text, ensure: false }); } out } +/// What a rewrite of the datasets packet does. +#[derive(Clone, Copy)] +enum Op<'a> { + /// Put these values in (adding nodes as needed). + Values(&'a [Want]), + /// Take the node at this path out. + Remove(&'a DataPath), +} + /// Note that a value could not be written (once per kind of reason, naming a few fields). fn skipped(warnings: &mut Vec, reason: &str, som: &str) { const MAX_WARNINGS: usize = 20; @@ -480,7 +491,11 @@ const WRAP_CLOSE: &str = ""; /// change: values go into the data nodes they bind to, missing nodes are added, and everything /// else (data no field binds to, other namespaces, attributes, comments, layout) stays as /// written. -fn merge(text: &str, within: Within, wants: &[Want], warnings: &mut Vec) -> Merged { +fn merge(text: &str, within: Within, op: Op, warnings: &mut Vec) -> Merged { + let wants: &[Want] = match op { + Op::Values(w) => w, + Op::Remove(_) => &[], + }; let opts = || roxmltree::ParsingOptions { allow_dtd: false, nodes_limit: MAX_NODES }; // A packet of an array may use the prefixes the XDP envelope declares: read it inside // an element declaring the usual ones. @@ -506,6 +521,7 @@ fn merge(text: &str, within: Within, wants: &[Want], warnings: &mut Vec) let mut edits = Vec::new(); let mut data = None; match datasets { + None if matches!(op, Op::Remove(_)) => return Merged::Unchanged, None if within == Within::Xdp => { let root = parsed.root_element(); match append_edit(src, root, &fresh_datasets(&build_children(wants, warnings))) { @@ -522,6 +538,7 @@ fn merge(text: &str, within: Within, wants: &[Want], warnings: &mut Vec) } Some(ds) => match ds.children().find(|n| is(n, "data")) { Some(d) => data = Some(d), + None if matches!(op, Op::Remove(_)) => return Merged::Unchanged, None => { let prefix = qname(src, &ds.range()).and_then(|q| q.split_once(':')).map(|(p, _)| format!("{p}:")).unwrap_or_default(); let element = format!("<{prefix}data>{}", build_children(wants, warnings)); @@ -536,7 +553,21 @@ fn merge(text: &str, within: Within, wants: &[Want], warnings: &mut Vec) }, } if let Some(data) = data { - merge_values(src, data, wants, &mut edits, warnings); + match op { + Op::Values(w) => merge_values(src, data, w, &mut edits, warnings), + Op::Remove(path) => { + // The element at `path`, with the whitespace before it. + let mut node = Some(data); + for (name, idx) in path { + node = node.and_then(|n| n.children().filter(|c| c.is_element() && c.tag_name().name() == name).nth(*idx)); + } + if let Some(n) = node { + let r = n.range(); + let start = src.get(..r.start).map_or(r.start, |before| r.start - before.len() + before.trim_end().len()); + edits.push(Edit { start, end: r.end, text: String::new() }); + } + } + } } if edits.is_empty() { return Merged::Unchanged; @@ -605,7 +636,11 @@ fn merge_values(src: &str, data: roxmltree::Node, wants: &[Want], edits: &mut Ve } } if depth == w.path.len() { - // The data node exists: give it the value, unless it already holds it. + // The data node exists: give it the value, unless it already holds it. A node + // only wanted to exist (a row instance) is left as it is. + if w.ensure && w.text.is_empty() { + continue; + } let current: String = node.children().filter(|c| c.is_text()).filter_map(|c| c.text()).collect(); if node.children().any(|c| c.is_element()) { let all: String = node.descendants().filter(|c| c.is_text()).filter_map(|c| c.text()).collect(); @@ -623,8 +658,8 @@ fn merge_values(src: &str, data: roxmltree::Node, wants: &[Want], edits: &mut Ve } } else { // Missing: add the rest of the path below the deepest node that exists. An empty - // value needs no node. - if w.text.is_empty() { + // value needs no node, unless the node itself is wanted (a new instance). + if w.text.is_empty() && !w.ensure { continue; } let mut rest: DataPath = w.path.get(depth..).unwrap_or_default().to_vec(); @@ -669,6 +704,8 @@ pub struct DatasetsWrite { pub written: bool, /// Values that could not be written, and why. pub warnings: Vec, + /// The datasets stream written, when one was. + pub stream: Option, } /// A packet stream's text and encoding; `None` (with a warning) when it can't be read exactly. @@ -696,14 +733,189 @@ fn packet_text(doc: &Document, o: &Object, warnings: &mut Vec) -> Option /// Nothing is written when the document has no XFA entry or the data already holds the values; /// values that can't be written are reported, never silently dropped. pub fn write_datasets(doc: &mut Document, fields: &[FieldDatum]) -> Result { + write_datasets_reusing(doc, fields, None) +} + +/// [`write_datasets`], replacing the stream `reuse` in place when the packet is that stream +/// (written earlier in the same edit) rather than adding another. +pub fn write_datasets_reusing(doc: &mut Document, fields: &[FieldDatum], reuse: Option) -> Result { + let wants = wanted(doc, fields); + rewrite(doc, &[Op::Values(&wants)], reuse) +} + +/// One change to the data, for [`write_data_ops`]. +#[derive(Clone, Debug, PartialEq)] +pub enum DataOp { + /// Put `text` at the node `path`, adding the node (and the ones above it) as needed. + Value { path: DataPath, text: String }, + /// Make sure the node at `parent` has at least `count` children named `name` (a script + /// added rows: the layout repeats the subform once per data instance). + Instances { parent: DataPath, name: String, count: usize }, + /// Take the node at `path` out (a script removed a row). + Remove(DataPath), +} + +impl DataNode { + /// The node at `path`, made (with empty siblings before it) where missing; `None` past + /// the index cap. + fn ensure(&mut self, path: &[(String, usize)]) -> Option<&mut DataNode> { + let mut node = self; + for (name, idx) in path { + if *idx >= MAX_INDEX { + return None; + } + let have = node.children.iter().filter(|c| &c.name == name).count(); + for _ in have..=*idx { + node.children.push(DataNode { name: name.clone(), ..Default::default() }); + } + node = node.children.iter_mut().filter(|c| &c.name == name).nth(*idx)?; + } + Some(node) + } + + /// Apply `op` to this tree (the root is the `xfa:data` element), as [`write_data_ops`] + /// will to the packet, so scripts that run before it is written see the change. + pub fn apply(&mut self, op: &DataOp) { + match op { + DataOp::Value { path, text } => { + if let Some(n) = self.ensure(path) + && n.children.is_empty() + { + n.text = text.clone(); + } + } + DataOp::Instances { parent, name, count } => { + if let Some(&last) = count.checked_sub(1).as_ref() { + let mut path = parent.clone(); + path.push((name.clone(), last.min(MAX_INDEX.saturating_sub(1)))); + let _ = self.ensure(&path); + } + } + DataOp::Remove(path) => { + let Some(((name, idx), parent)) = path.split_last() else { return }; + let mut node = self; + for (n, i) in parent { + let Some(next) = node.children.iter_mut().filter(|c| &c.name == n).nth(*i) else { return }; + node = next; + } + if let Some(at) = node.children.iter().enumerate().filter(|(_, c)| &c.name == name).nth(*idx).map(|(at, _)| at) { + node.children.remove(at); + } + } + } + } +} + +fn path_som(path: &DataPath) -> String { + path.iter().map(|(n, i)| format!("{n}[{i}]")).collect::>().join(".") +} + +/// Make sure the data has at least `count` instances of `name` under `parent` (a script added +/// rows: the layout repeats the subform once per data instance). +pub fn add_data_instances(doc: &mut Document, parent: &DataPath, name: &str, count: usize) -> Result { + write_data_ops(doc, &[DataOp::Instances { parent: parent.clone(), name: name.to_string(), count }], None) +} + +/// Put `text` at the data node `path` (a field with no widget, a hidden one or a draw, that a +/// script gave a value), adding the node as needed. +pub fn write_data_value(doc: &mut Document, path: &DataPath, text: &str) -> Result { + write_data_ops(doc, &[DataOp::Value { path: path.clone(), text: text.to_string() }], None) +} + +/// Remove the data node at `path` (a script removed a row). +pub fn remove_data_instance(doc: &mut Document, path: &DataPath) -> Result { + write_data_ops(doc, &[DataOp::Remove(path.clone())], None) +} + +/// Apply `ops` to the datasets packet, in order, as one rewrite of it: everything one script +/// event changed becomes a single new datasets stream. When the packet is already the stream +/// `reuse` (written earlier for the same event), that stream is replaced in place instead of +/// a new one being added. The stream written is in the report. +pub fn write_data_ops(doc: &mut Document, ops: &[DataOp], reuse: Option) -> Result { + // Runs of values (and instances) merge in one pass; a value set twice keeps the last. + let mut steps: Vec> = Vec::new(); + let mut removes: Vec> = Vec::new(); + let mut run: Vec = Vec::new(); + let flush = |run: &mut Vec, steps: &mut Vec>, removes: &mut Vec>| { + if !run.is_empty() { + let mut seen = std::collections::HashSet::new(); + let mut kept: Vec = std::mem::take(run).into_iter().rev().filter(|w| seen.insert((w.ensure, w.path.clone()))).collect(); + kept.reverse(); + steps.push(kept); + removes.push(None); + } + }; + for op in ops { + match op { + DataOp::Value { path, text } if !path.is_empty() => { + // An empty value clears an existing node and needs no new one. + run.push(Want { som: path_som(path), path: path.clone(), text: text.clone(), ensure: false }); + } + DataOp::Instances { parent, name, count } => { + for k in 0..(*count).min(MAX_INDEX) { + let mut path = parent.clone(); + path.push((name.clone(), k)); + run.push(Want { som: format!("{name}[{k}]"), path, text: String::new(), ensure: true }); + } + } + DataOp::Remove(path) if !path.is_empty() => { + flush(&mut run, &mut steps, &mut removes); + steps.push(Vec::new()); + removes.push(Some(path.clone())); + } + _ => {} + } + } + flush(&mut run, &mut steps, &mut removes); + let plan: Vec = steps + .iter() + .zip(&removes) + .map(|(w, r)| match r { + Some(p) => Op::Remove(p), + None => Op::Values(w), + }) + .collect(); + rewrite(doc, &plan, reuse) +} + +/// Put `bytes` in the document as a datasets stream: in place of `old` when that is the stream +/// `reuse`, else as a new object. +fn put_stream(doc: &mut Document, old: Option<&Object>, reuse: Option, bytes: &[u8]) -> ObjRef { + let stream = Object::Stream(Stream::flate(Dict::new(), bytes)); + match (old.and_then(Object::as_ref), reuse) { + (Some(r), Some(keep)) if r == keep => { + doc.set(r, stream); + r + } + _ => doc.add(stream), + } +} + +/// Merge `ops` into `text` in order. `None`: nothing changed (or nothing could be merged). +fn merge_all(text: &str, within: Within, ops: &[Op], warnings: &mut Vec) -> Option { + let mut current: Option = None; + for op in ops { + match merge(current.as_deref().unwrap_or(text), within, *op, warnings) { + Merged::Changed(t) => current = Some(t), + Merged::Unchanged => {} + // Later steps still apply to what merged so far; the reason is in the warnings. + Merged::Failed => {} + } + } + current +} + +fn rewrite(doc: &mut Document, ops: &[Op], reuse: Option) -> Result { let mut report = DatasetsWrite::default(); + if ops.is_empty() { + return Ok(report); + } let Some(root) = doc.root() else { return Ok(report) }; let catalog = doc.get(root); let Some(acro_obj) = catalog.as_dict().and_then(|c| c.get(b"AcroForm")).cloned() else { return Ok(report) }; let acro_ref = acro_obj.as_ref(); let Some(mut acro) = doc.resolve(&acro_obj).as_dict().cloned() else { return Ok(report) }; let Some(xfa) = acro.get(b"XFA").cloned() else { return Ok(report) }; - let wants = wanted(doc, fields); let warnings = &mut report.warnings; match &*doc.resolve(&xfa) { Object::Array(items) => { @@ -711,29 +923,26 @@ pub fn write_datasets(doc: &mut Document, fields: &[FieldDatum]) -> Result { let Some(existing) = items.get(i + 1).cloned() else { return Ok(report) }; let Some((text, encoding)) = packet_text(doc, &existing, warnings) else { return Ok(report) }; - match merge(&text, Within::Packet, &wants, warnings) { - Merged::Changed(t) => Some((i, encode(&t, encoding))), - Merged::Unchanged | Merged::Failed => None, - } - } - None => None, - }; - match (at, packet) { - (Some(i), Some((_, bytes))) => { - let r = doc.add(Object::Stream(Stream::flate(Dict::new(), &bytes))); + let Some(t) = merge_all(&text, Within::Packet, ops, warnings) else { return Ok(report) }; + let r = put_stream(doc, Some(&existing), reuse, &encode(&t, encoding)); + report.stream = Some(r); if let Some(slot) = items.get_mut(i + 1) { *slot = Object::Ref(r); } } - (Some(_), None) => return Ok(report), - (None, _) => { - // No datasets packet yet: before the postamble, or last. - let text = fresh_datasets(&build_children(&wants, warnings)); + None => { + // No datasets packet yet: made from the first values, then the rest merged + // in; before the postamble, or last. + let Some(first) = ops.iter().position(|op| matches!(op, Op::Values(_))) else { return Ok(report) }; + let Some(Op::Values(wants)) = ops.get(first) else { return Ok(report) }; + let fresh = fresh_datasets(&build_children(wants, warnings)); + let text = merge_all(&fresh, Within::Packet, ops.get(first + 1..).unwrap_or_default(), warnings).unwrap_or(fresh); let r = doc.add(Object::Stream(Stream::flate(Dict::new(), text.as_bytes()))); + report.stream = Some(r); let pos = items.iter().position(|n| n.as_string().is_some_and(|s| s.to_text() == "postamble")).unwrap_or(items.len()); items.insert(pos, Object::Ref(r)); items.insert(pos, Object::String(PdfString::text("datasets"))); @@ -743,8 +952,9 @@ pub fn write_datasets(doc: &mut Document, fields: &[FieldDatum]) -> Result { let Some((text, encoding)) = packet_text(doc, &xfa, warnings) else { return Ok(report) }; - let Merged::Changed(new) = merge(&text, Within::Xdp, &wants, warnings) else { return Ok(report) }; - let r = doc.add(Object::Stream(Stream::flate(Dict::new(), &encode(&new, encoding)))); + let Some(new) = merge_all(&text, Within::Xdp, ops, warnings) else { return Ok(report) }; + let r = put_stream(doc, Some(&xfa), reuse, &encode(&new, encoding)); + report.stream = Some(r); acro.set(b"XFA".to_vec(), Object::Ref(r)); } _ => return Ok(report), diff --git a/crates/xfa/src/fixtures.rs b/crates/xfa/src/fixtures.rs index f05f766d..199f2632 100644 --- a/crates/xfa/src/fixtures.rs +++ b/crates/xfa/src/fixtures.rs @@ -114,6 +114,52 @@ fn assemble(objs: Vec>) -> Vec { out } +/// A scripted form (JavaScript events): `qty` (initialised to 2, validated ≤ 100) × `price` +/// (5) = `total` (calculated); a check box `more` that shows the hidden `details` subform; +/// a table whose `row` (item, amount) repeats, with `addRow`, `removeRow` and `hello` buttons; +/// `grand`, the calculated sum of the amounts. +pub fn scripted_template() -> String { + r##" + + + +"## + .to_string() +} + /// A PDF shell around `xdp`: one placeholder page, `/NeedsRendering true`, no fields. pub fn shell(xdp: &str) -> Vec { let objs: Vec> = vec![ diff --git a/crates/xfa/src/layout.rs b/crates/xfa/src/layout.rs index c9db6997..19b3143a 100644 --- a/crates/xfa/src/layout.rs +++ b/crates/xfa/src/layout.rs @@ -78,6 +78,8 @@ pub enum Action { Print, SaveAs, Url(String), + /// Any other click script: run by the XFA scripting engine. + Script(String), } /// An interactive field to create as an AcroForm widget. @@ -97,6 +99,8 @@ pub struct Widget { pub read_only: bool, pub border: WidgetBorder, pub value: Option, + /// The template's default value (what a reset restores); data values are not defaults. + pub default: Option, pub action: Option, /// Check boxes: the on and off values of the data; radio buttons: the on value. pub items: Vec, @@ -182,6 +186,14 @@ struct Layouter<'t> { measures: Cell, } +/// How many instances of a repeating subform to lay out: what the data holds once it holds +/// any (rows added or removed), else the template's initial count; never below one, never +/// above the maximum. +pub fn instance_count(occur: &Occur, from_data: usize) -> usize { + let n = if from_data > 0 { from_data } else { occur.initial }; + n.min(occur.max.unwrap_or(usize::MAX)).clamp(1, MAX_INSTANCES) +} + /// Index of child `i` among the earlier siblings with the same name (the SOM index). fn sib_index(children: &[Node], i: usize) -> usize { let name = children.get(i).and_then(|c| c.common().name.as_deref()); @@ -302,6 +314,13 @@ fn date_pattern(picture: Option<&str>) -> String { fn button_action(scripts: &[Script]) -> Option { let s = scripts.iter().find(|s| s.activity == "click")?; let t = &s.text; + // Only a single-statement idiom becomes a native PDF action; anything else is run by the + // XFA scripting engine. + let one = t.trim().trim_end_matches(';'); + let single = !one.contains(';') && !one.contains('\n'); + if !single { + return (!t.trim().is_empty()).then(|| Action::Script(t.clone())); + } if t.contains("resetData") { Some(Action::Reset) } else if t.contains("host.print") || t.contains("execMenuItem(\"Print\")") { @@ -315,8 +334,10 @@ fn button_action(scripts: &[Script]) -> Option { let body = &rest[q + 1..]; let end = body.find(quote)?; Some(Action::Url(body[..end].to_string())) - } else { + } else if t.trim().is_empty() { None + } else { + Some(Action::Script(t.clone())) } } @@ -475,7 +496,7 @@ impl Layouter<'_> { (Some(name), Some(d)) if sf.occur.max != Some(1) => d.count(&som_to_path(&ctx.som), name), _ => 0, }; - let instances = sf.occur.initial.max(from_data).min(sf.occur.max.unwrap_or(usize::MAX)).clamp(1, MAX_INSTANCES); + let instances = instance_count(&sf.occur, from_data); let ctx_parent = ctx; for i in 0..instances { let ctx = ctx_parent.with_som(&sf.common, sib + i); @@ -550,7 +571,7 @@ impl Layouter<'_> { (Some(name), Some(d)) if row.occur.max != Some(1) => d.count(&som_to_path(&ctx.som), name), _ => 0, }; - let instances = row.occur.initial.max(from_data).min(row.occur.max.unwrap_or(usize::MAX)).clamp(1, MAX_INSTANCES); + let instances = instance_count(&row.occur, from_data); for inst in 0..instances { let h = self.row_height(row, &cols, inner_w, ctx, depth + 1); if self.make_room(h)? @@ -1155,7 +1176,8 @@ impl Layouter<'_> { // The data's value wins over the template's default. let data_value: Option = self.data.and_then(|d| d.text_at(&som_to_path(&som))).map(|t| t.trim().to_string()).filter(|t| !t.is_empty()); - let plain_value = data_value.clone().or_else(|| f.value.plain().map(|v| v.trim().to_string()).filter(|v| !v.is_empty())); + let template_default = f.value.plain().map(|v| v.trim().to_string()).filter(|v| !v.is_empty()); + let plain_value = data_value.clone().or_else(|| template_default.clone()); let (kind, rect, border, value, tooltip) = match f.ui { Ui::CheckButton => { let s = f.check_size.unwrap_or(10.0).min(ui.w.max(1.0)).min(ui.h.max(1.0)); @@ -1227,6 +1249,7 @@ impl Layouter<'_> { read_only, border, value, + default: template_default, action, items, }))) diff --git a/crates/xfa/src/lib.rs b/crates/xfa/src/lib.rs index b1612d8f..115fc50f 100644 --- a/crates/xfa/src/lib.rs +++ b/crates/xfa/src/lib.rs @@ -14,18 +14,23 @@ pub mod model; pub mod packets; pub mod parse; pub mod pdf; +pub mod script; pub mod text; use pdfcraft_cos::Document; pub use data::{ - DataNode, DatasetsWrite, FieldData, FieldDatum, build_data, iso_to_pattern, parse_datasets, pattern_to_iso, read_values, som_to_path, - write_datasets, + DataNode, DataOp, DataPath, DatasetsWrite, FieldData, FieldDatum, add_data_instances, build_data, iso_to_pattern, parse_datasets, pattern_to_iso, + read_values, remove_data_instance, som_to_path, write_data_ops, write_data_value, write_datasets, write_datasets_reusing, }; pub use layout::{Action, BorderShape, Form, Item, MAX_PAGES, Page, Widget, WidgetKind, layout}; pub use packets::{Encoding, Packets, decode as decode_packet, encode as encode_packet, read_packets}; pub use parse::{measure, parse}; -pub use pdf::{LAYOUT_KEY, SOM_KEY, existing_layout}; +pub use pdf::{CLICK_KEY, LAYOUT_KEY, SOM_KEY, existing_layout}; +pub use script::{ + FormNode, LiveForm, MAX_FORM_NODES, NodeKind, OVERRIDES_KEY, Overrides, ScriptEvent, apply_overrides, data_of, fields_by_som, form_tree, + has_scripts, overrides, rerender, set_overrides, template_of, +}; #[derive(Debug, thiserror::Error)] pub enum XfaError { @@ -77,7 +82,11 @@ pub fn render_into(doc: &mut Document) -> Result { if packets.xdp.trim().is_empty() { return Err(XfaError::Malformed("the XFA packets hold no template".into())); } - let form = layout_xml(&packets.xdp)?; + let (tpl, parse_warnings) = parse(&packets.xdp)?; + let data = parse_datasets(&packets.xdp); + let laid = apply_overrides(&tpl, &overrides(doc)); + let mut form = layout(&laid, data.as_ref())?; + form.warnings.splice(0..0, parse_warnings); if form.pages.is_empty() { return Err(XfaError::Malformed("the template laid out to no pages".into())); } diff --git a/crates/xfa/src/model.rs b/crates/xfa/src/model.rs index 1746e82a..d4332faa 100644 --- a/crates/xfa/src/model.rs +++ b/crates/xfa/src/model.rs @@ -332,6 +332,8 @@ pub struct Subform { pub common: Common, pub layout: Layout, pub children: Vec, + /// `` children. +fn parse_events(n: XmlNode) -> Vec", "") + .replace("xfa.host.messageBox(\"Hello \" + qty.rawValue);", "if (qty.rawValue > 1) { xfa.host.resetData(); }"); + let form = layout_xml(&xml).unwrap(); + let w: Vec<&Widget> = form.pages.iter().flat_map(widgets).collect(); + let by = |n: &str| w.iter().find(|w| w.name == n).unwrap_or_else(|| panic!("no {n}")); + assert_eq!(by("legacy").action, Some(Action::Reset), "a one-statement FormCalc idiom is a native action"); + assert!(matches!(by("hello").action, Some(Action::Script(_))), "anything else runs as a script: {:?}", by("hello").action); + assert!(matches!(by("addRow").action, Some(Action::Script(_)))); +} + +/// Five levels of subforms that each repeat 50 times, with a scripted field at the bottom: +/// 50⁵ instances if nothing stopped the walk. +fn nested_repeats_template() -> String { + let mut body = r#""#.to_string(); + for level in (0..5).rev() { + body = format!(r#"{body}"#); + } + format!( + r#""# + ) +} + +#[test] +fn the_live_form_of_nested_repeating_subforms_stops_at_its_node_budget() { + fn count(n: &crate::script::FormNode) -> usize { + 1 + n.children.iter().map(count).sum::() + } + let (tpl, _) = parse(&nested_repeats_template()).unwrap(); + assert!(crate::script::has_scripts(&tpl)); + let started = std::time::Instant::now(); + let live = form_tree(&tpl, None, &Overrides::default()); + assert!(live.truncated, "the walk was cut short"); + assert!(count(&live.root) <= crate::script::MAX_FORM_NODES + 1, "{} nodes", count(&live.root)); + assert!(live.events.len() <= 20_000); + assert!(started.elapsed() < std::time::Duration::from_secs(5), "{:?}", started.elapsed()); + // A template without scripts has nothing to run. + let plain_xml = nested_repeats_template().replace(r#""#, ""); + let (plain, _) = parse(&plain_xml).unwrap(); + assert!(!crate::script::has_scripts(&plain)); +} + +#[test] +fn data_ops_of_one_event_are_one_datasets_rewrite() { + use crate::data::{DataOp, write_data_ops}; + let mut doc = Document::open(Arc::new(shell(&scripted_template()))).unwrap(); + render_into(&mut doc).unwrap(); + let before = doc.object_numbers().len(); + let ops = vec![ + DataOp::Value { path: som_to_path("form[0].page1[0].qty[0]"), text: "1".into() }, + DataOp::Value { path: som_to_path("form[0].page1[0].qty[0]"), text: "7".into() }, + DataOp::Instances { parent: som_to_path("form[0].page1[0].table[0]"), name: "row".into(), count: 3 }, + DataOp::Value { path: som_to_path("form[0].page1[0].table[0].row[2].amount[0]"), text: "5".into() }, + DataOp::Remove(som_to_path("form[0].page1[0].table[0].row[0]")), + ]; + let r = write_data_ops(&mut doc, &ops, None).unwrap(); + assert!(r.written && r.stream.is_some(), "{r:?}"); + assert_eq!(doc.object_numbers().len(), before + 1, "one new datasets stream"); + let data = crate::data_of(&doc).unwrap(); + assert_eq!(data.text_at(&som_to_path("form[0].page1[0].qty[0]")), Some("7"), "the last value wins"); + assert_eq!(data.count(&som_to_path("form[0].page1[0].table[0]"), "row"), 2); + assert_eq!(data.text_at(&som_to_path("form[0].page1[0].table[0].row[1].amount[0]")), Some("5")); + // The in-memory model agrees with what was written. + let mut model = DataNode::default(); + model.children.push(DataNode { name: "form".into(), ..Default::default() }); + for op in &ops { + model.apply(op); + } + assert_eq!(model.text_at(&som_to_path("form[0].page1[0].qty[0]")), Some("7")); + assert_eq!(model.count(&som_to_path("form[0].page1[0].table[0]"), "row"), 2); + assert_eq!(model.text_at(&som_to_path("form[0].page1[0].table[0].row[1].amount[0]")), Some("5")); + // A second write for the same event replaces that stream instead of adding another; an + // empty value clears the node. + let again = write_data_ops(&mut doc, &[DataOp::Value { path: som_to_path("form[0].page1[0].qty[0]"), text: String::new() }], r.stream).unwrap(); + assert_eq!(again.stream, r.stream); + assert_eq!(doc.object_numbers().len(), before + 1); + assert_eq!(crate::data_of(&doc).unwrap().text_at(&som_to_path("form[0].page1[0].qty[0]")), Some("")); +} + +#[test] +fn script_buttons_carry_no_javascript_action_for_other_viewers() { + let mut doc = Document::open(Arc::new(shell(&scripted_template()))).unwrap(); + render_into(&mut doc).unwrap(); + let fields = top_fields(&doc); + let add_row = fields.iter().find(|(name, _)| name == "addRow").map(|(_, d)| d.clone()).expect("addRow"); + assert!(add_row.contains(crate::CLICK_KEY), "found by its SOM path"); + assert!(add_row.get(b"A").is_none() && add_row.get(b"AA").is_none(), "no XFA source as a PDF JavaScript action"); +} + +/// The generated fields' dictionaries by name (top-level fields only). +fn top_fields(doc: &Document) -> Vec<(String, pdfcraft_cos::Dict)> { + let root = doc.root().unwrap(); + let catalog = doc.get(root); + let acro = doc.resolve(catalog.as_dict().unwrap().get(b"AcroForm").unwrap()); + let fields = doc.resolve(acro.as_dict().unwrap().get(b"Fields").unwrap()); + fields + .as_array() + .unwrap() + .iter() + .filter_map(|f| { + let d = doc.resolve(f).as_dict().cloned()?; + let t = d.get(b"T").and_then(|t| t.as_string()).map(|t| t.to_text())?; + Some((t, d)) + }) + .collect() +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 1a509e35..7f696fa2 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5129,9 +5129,9 @@ tier = "P1" milestone = "M12" status = "partial" commands = ["file.open"] -tools = ["doc_open", "doc_info", "form_fields", "form_fill"] -evidence = ["crates/xfa/src/tests.rs::layout_places_captions_fields_and_sizes_containers_to_their_content", "crates/xfa/src/tests.rs::tables_break_across_pages_and_repeat_their_header_row", "crates/xfa/src/tests.rs::rendering_into_the_pdf_replaces_the_placeholder_page_and_adds_fields", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/automation/tests/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/xfa.rs::a_dynamic_xfa_form_is_laid_out_and_filled", "crates/xfa/src/tests.rs::nested_width_less_subforms_lay_out_in_polynomial_time"] -notes = "laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters, breaks, repeated header rows, captions, rich text, JPEG images) into real pages and AcroForm fields (text, date with AFDate formats, check boxes, radio groups, buttons with reset/print/save/URL), filled from the datasets packet (including rows another viewer added), saved as an incremental revision with the values merged into the datasets so Adobe's viewers show them; doc_info reports xfa_layout. Missing: no FormCalc or XFA JavaScript, so rows don't grow by button and calculations don't run; choice lists are text fields; signature, image and barcode fields are blank; PNG images; keep rules; metrics are approximate and Acrobat fidelity is eyeballed on real forms, not measured (#60)" +tools = ["doc_open", "doc_info", "form_fields", "form_fill", "js_run"] +evidence = ["crates/xfa/src/tests.rs::layout_places_captions_fields_and_sizes_containers_to_their_content", "crates/xfa/src/tests.rs::tables_break_across_pages_and_repeat_their_header_row", "crates/xfa/src/tests.rs::rendering_into_the_pdf_replaces_the_placeholder_page_and_adds_fields", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/automation/tests/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/xfa.rs::a_dynamic_xfa_form_is_laid_out_and_filled", "crates/xfa/src/tests.rs::nested_width_less_subforms_lay_out_in_polynomial_time", "crates/js/src/tests.rs::instance_managers_add_remove_and_count", "crates/xfa/src/tests.rs::the_form_tree_carries_values_presence_instances_and_the_events", "crates/engine/src/tests.rs::xfa_scripts_initialize_calculate_validate_toggle_and_add_rows", "crates/automation/tests/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/xfa.rs::xfa_buttons_run_their_scripts_in_the_app", "crates/engine/src/tests.rs::hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted", "crates/engine/src/tests.rs::runaway_xfa_calculations_at_open_end_quickly_with_a_report", "crates/xfa/src/tests.rs::the_live_form_of_nested_repeating_subforms_stops_at_its_node_budget", "crates/xfa/src/tests.rs::data_ops_of_one_event_are_one_datasets_rewrite"] +notes = "laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters, breaks, repeated header rows, captions, rich text, JPEG images) into real pages and AcroForm fields (text, date with AFDate formats, check boxes, radio groups, buttons with reset/print/save/URL), filled from the datasets packet (including rows another viewer added), saved as an incremental revision with the values merged into the datasets so Adobe's viewers show them; doc_info reports xfa_layout. XFA JavaScript runs in the js crate's XFA object model (xfa.form, SOM resolution, rawValue, presence, access, instance managers, xfa.host, xfa.layout, xfa.event): initialize and calculate on open, exit, validate and calculate after a field changes, click for buttons (through js_run and the app), with rows added and removed, subforms shown and hidden, and the form laid out again, all as one undo step and kept in the file. Hostile scripts are bounded per event (merged and capped effects, messages and console lines, lower loop limits and a time budget on open and calculate, one datasets rewrite per event, print/save/links/focus from clicks only, a runaway script abandoned and the form's scripts turned off) and what open-time scripts changed is noted. Missing: loops inside nested calls are only stopped by abandoning their thread, and script memory is not capped; FormCalc (reported, not run); the change event per keystroke; xfa.template, connections and data descriptions; a hidden instance of a repeating subform hides them all; choice lists are text fields; signature, image and barcode fields are blank; PNG images; keep rules; metrics are approximate and Acrobat fidelity is eyeballed on real forms, not measured (#60)" [[feature]] id = "form.xfa-formcalc"