diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 095cfde63e..9516a76542 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -9485,6 +9485,14 @@ fn javascript_through_tools() { let f = ok(&mut a, "form_fields", json!({ "doc": doc })); let total = f["fields"].as_array().unwrap().iter().find(|x| x["name"] == "Total").unwrap().clone(); assert_eq!(total["value"], "3750"); + let calculated = ok( + &mut a, + "js_run", + json!({ "doc": doc, "script": "getField('Price').value = 3; getField('Qty').value = 2; calculateNow(); console.println(getField('Total').value);" }), + ); + assert!(calculated["console"].as_array().unwrap().iter().any(|line| line == "6"), "{calculated}"); + let f = ok(&mut a, "form_fields", json!({ "doc": doc })); + assert_eq!(f["fields"].as_array().unwrap().iter().find(|x| x["name"] == "Total").unwrap()["value"], "6"); let r = ok(&mut a, "js_run", json!({ "doc": doc, "script": "nope()" })); assert!(r["error"].as_str().unwrap().contains("nope")); assert_eq!(r["document_errors"], json!([])); diff --git a/crates/engine/src/edit/run.rs b/crates/engine/src/edit/run.rs index 2694e90492..ebb8a5f323 100644 --- a/crates/engine/src/edit/run.rs +++ b/crates/engine/src/edit/run.rs @@ -508,6 +508,14 @@ fn run_edit_page_image(doc: &mut pdfcraft_cos::Document, page: usize, index: usi Ok(()) } +/// After an edit that changes fields, their scripts or the calculation order: the edit's +/// script runner reads the document as it now is, and builds its `calculateNow()` list again. +fn invalidate_calculations(cx: &mut EditCtx, doc: &pdfcraft_cos::Document) { + if let Some(js) = cx.js.as_mut() { + js.invalidate_calculations(doc); + } +} + /// Perform an edit on a working copy (the caller discards it on error). pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> Result<(), EditError> { match edit { @@ -536,6 +544,7 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E let title = std::path::Path::new(name).file_stem().map_or_else(|| name.clone(), |s| s.to_string_lossy().into_owned()); let outcome = pdfcraft_organize::insert_file_pages(doc, &src, &title, &pages, *at, *field_conflicts)?; cx.renamed_fields = outcome.renamed_fields; + invalidate_calculations(cx, doc); } Edit::AddBookmark { parent, index, title, page, left, top } => { pdfcraft_organize::add_bookmark_at(doc, parent, *index, title, *page, *left, *top)?; @@ -788,22 +797,32 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E } Edit::AddField { page, rect, kind, name } => { pdfcraft_forms::add_field(doc, *page, *rect, kind, name.as_deref())?; + invalidate_calculations(cx, doc); } Edit::SetFieldProps { name, props } => { pdfcraft_forms::set_props(doc, name, props)?; + invalidate_calculations(cx, doc); } Edit::DuplicateField { name, pages } => { if pdfcraft_forms::duplicate_field(doc, name, pages)? == 0 { return Err(EditError::Form(pdfcraft_forms::FormError::Invalid(format!("{name} is already on those pages")))); } + invalidate_calculations(cx, doc); + } + Edit::DeleteField { name } => { + pdfcraft_forms::delete_field(doc, name)?; + invalidate_calculations(cx, doc); + } + Edit::DeleteWidget { name, widget } => { + pdfcraft_forms::delete_widget(doc, name, *widget)?; + invalidate_calculations(cx, doc); } - Edit::DeleteField { name } => pdfcraft_forms::delete_field(doc, name)?, - Edit::DeleteWidget { name, widget } => pdfcraft_forms::delete_widget(doc, name, *widget)?, Edit::SetTabOrder { pages, order } => pdfcraft_forms::set_tab_order(doc, pages, *order)?, Edit::MoveInTabOrder { name, earlier } => pdfcraft_forms::move_in_tab_order(doc, name, *earlier)?, // As after a script edit: the values the new order gives show at once. Edit::SetCalculationOrder { names } => { pdfcraft_forms::set_calculation_order(doc, names)?; + invalidate_calculations(cx, doc); match cx.js.as_mut() { Some(js) => pdfcraft_forms::recalculate_with(doc, js)?, None => pdfcraft_forms::recalculate(doc)?, @@ -831,6 +850,7 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E } Edit::SetFieldScript { name, event, script } => { pdfcraft_forms::set_field_script(doc, name, event, script.as_deref())?; + invalidate_calculations(cx, doc); match cx.js.as_mut() { Some(js) => pdfcraft_forms::recalculate_with(doc, js)?, None => pdfcraft_forms::recalculate(doc)?, diff --git a/crates/engine/src/js.rs b/crates/engine/src/js.rs index c3f94b4a19..13939aad54 100644 --- a/crates/engine/src/js.rs +++ b/crates/engine/src/js.rs @@ -227,7 +227,11 @@ fn field_state_with_display(f: &Field, display: i32) -> pdfcraft_js::FieldState } /// The changes a script made, against the fields as they were. -fn changes(before: &[pdfcraft_js::FieldState], after: &[pdfcraft_js::FieldState]) -> Vec { +fn changes( + before: &[pdfcraft_js::FieldState], + after: &[pdfcraft_js::FieldState], + formatted: &std::collections::BTreeSet, +) -> Vec { if after.is_empty() { return Vec::new(); } @@ -244,6 +248,7 @@ fn changes(before: &[pdfcraft_js::FieldState], after: &[pdfcraft_js::FieldState] let c = FieldChange { name: a.name.clone(), value: (a.value != b.value).then(|| a.value.clone()), + formatted: a.value != b.value && formatted.contains(&a.name), read_only: (a.readonly != b.readonly).then_some(a.readonly), required: (a.required != b.required).then_some(a.required), display: (a.display != b.display).then_some(a.display), @@ -266,6 +271,7 @@ pub struct JsRunner { // otherwise reparse every widget for every calculation. Values/flags still come from // the live `fields` snapshot on each call. A new edit gets a new runner and cache. displays: std::collections::HashMap, + calculations: Option>>, } impl JsRunner { @@ -291,8 +297,61 @@ impl JsRunner { // Packets alone are not enough: static XFA and an unlaid-out shell keep their scripts. let xfa_active = xfa && pdfcraft_xfa::existing_layout(cos).is_some(); let doc_scripts = if xfa_active { Vec::new() } else { pdfcraft_forms::document_scripts(cos) }; - JsRunner { doc, doc_scripts, output: JsOutput::default(), cos: cos.clone(), displays: std::collections::HashMap::new() } + JsRunner { doc, doc_scripts, output: JsOutput::default(), cos: cos.clone(), displays: std::collections::HashMap::new(), calculations: None } } + + fn calculations(&mut self, fields: &[Field]) -> std::sync::Arc> { + if let Some(calculations) = &self.calculations { + return std::sync::Arc::clone(calculations); + } + let calculations = std::sync::Arc::new(calculation_scripts(&self.cos, fields)); + self.calculations = Some(std::sync::Arc::clone(&calculations)); + calculations + } + + pub(crate) fn invalidate_calculations(&mut self, cos: &pdfcraft_cos::Document) { + self.cos = cos.clone(); + self.displays.clear(); + self.calculations = None; + } +} + +fn calculation_scripts(cos: &pdfcraft_cos::Document, fields: &[Field]) -> Vec<(String, String)> { + #[cfg(test)] + CALCULATION_SCRIPT_BUILDS.with(|calls| calls.set(calls.get().saturating_add(1))); + pdfcraft_forms::calculation_order(cos, fields) + .fields + .iter() + .filter_map(|ordered| { + let field = fields.iter().find(|f| f.name == ordered.name)?; + let script = field.actions.scripts.calculate.clone().or_else(|| { + let source = pdfcraft_forms::af::calculate_js(&field.actions.calculate)?; + Some(match &field.actions.calculate { + pdfcraft_forms::af::Calculate::Simple { .. } => format!("event.value = {source}"), + _ => source, + }) + })?; + Some((field.name.clone(), script)) + }) + .collect() +} + +#[cfg(test)] +thread_local! { + pub(crate) static CALCULATION_SCRIPT_BUILDS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +fn calls_calculate_now(script: &str, doc_scripts: &[String]) -> bool { + script.contains("calculateNow") || doc_scripts.iter().any(|source| source.contains("calculateNow")) +} + +fn calculations_for_script( + cos: &pdfcraft_cos::Document, + fields: &[Field], + script: &str, + doc_scripts: &[String], +) -> std::sync::Arc> { + if calls_calculate_now(script, doc_scripts) { std::sync::Arc::new(calculation_scripts(cos, fields)) } else { std::sync::Arc::new(Vec::new()) } } /// The field highlight the last of `requests` that sets it asks for (`app.runtimeHighlight`). @@ -349,7 +408,16 @@ impl JsRunner { .collect(); let states = std::sync::Arc::new(states); let ev = pdfcraft_js::Event::field(event.name(), &target.name, value); - let o = pdfcraft_js::run_shared(script, &ev, &self.doc, std::sync::Arc::clone(&states), &self.doc_scripts, Limits::default()); + let calculations = if calls_calculate_now(script, &self.doc_scripts) { self.calculations(fields) } else { std::sync::Arc::new(Vec::new()) }; + let o = pdfcraft_js::run_shared_calculations_shared( + script, + &ev, + &self.doc, + std::sync::Arc::clone(&states), + &self.doc_scripts, + &calculations, + Limits::default(), + ); if log { self.output.absorb(&o); } @@ -364,7 +432,7 @@ impl JsRunner { rc: o.rc, value: o.value.clone(), value_set: o.value_set, - changes: changes(&states, &o.changed), + changes: changes(&states, &o.changed, &o.formatted), message: (!o.rc).then(|| o.alerts.last().map(|m| m.text.clone())).flatten(), }) } @@ -540,13 +608,22 @@ impl Session { } let runner = JsRunner::new(&cos, &doc.name, self.field_highlight); let fields = pdfcraft_forms::fields(&cos); - let states: Vec<_> = fields.iter().map(|f| field_state(&cos, f)).collect(); + let states = std::sync::Arc::new(fields.iter().map(|f| field_state(&cos, f)).collect::>()); let event = match target { Some(t) => pdfcraft_js::Event { will_commit: false, ..pdfcraft_js::Event::field("Mouse Up", t, "") }, None => pdfcraft_js::Event::doc("Console"), }; - let mut o = pdfcraft_js::run(script, &event, &runner.doc, &states, &runner.doc_scripts, Limits::default()); - let changes = changes(&states, &o.changed); + let calculations = calculations_for_script(&cos, &fields, script, &runner.doc_scripts); + let mut o = pdfcraft_js::run_shared_calculations_shared( + script, + &event, + &runner.doc, + std::sync::Arc::clone(&states), + &runner.doc_scripts, + &calculations, + Limits::default(), + ); + let changes = changes(&states, &o.changed, &o.formatted); let resets: Vec> = o .requests .iter() @@ -885,8 +962,9 @@ mod review_tests { second.value = vec!["second".into()]; second.display = pdfcraft_js::DISPLAY_HIDDEN; let before = vec![first.clone(), second.clone()]; - assert!(changes(&before, std::slice::from_ref(&first)).is_empty()); - let actual = changes(&before, std::slice::from_ref(&second)); + let formatted = std::collections::BTreeSet::new(); + assert!(changes(&before, std::slice::from_ref(&first), &formatted).is_empty()); + let actual = changes(&before, std::slice::from_ref(&second), &formatted); assert_eq!( actual, vec![FieldChange { @@ -908,12 +986,12 @@ mod review_tests { assert_eq!(outcome.value, "first"); assert_eq!(outcome.changed.len(), 1); assert_eq!( - changes(&before, &outcome.changed), + changes(&before, &outcome.changed, &outcome.formatted), vec![FieldChange { name: "duplicate".into(), value: Some(vec!["updated".into()]), ..Default::default() }] ); - assert!(changes(&before, &[]).is_empty()); + assert!(changes(&before, &[], &formatted).is_empty()); let mut missing = first; missing.name = "missing".into(); - assert!(changes(&before, &[missing]).is_empty()); + assert!(changes(&before, &[missing], &formatted).is_empty()); } } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 3c6ccb26b3..56972abaa6 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -6632,6 +6632,226 @@ fn form_javascript_validates_calculates_and_formats() { assert!(s.run_javascript(id, "1", None).is_err()); } +#[test] +fn calculate_now_updates_live_fields_in_one_undo_step() { + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("calculate-now.pdf", None, Arc::new(scripted_form()), None).unwrap(); + let out = s + .run_javascript( + id, + "getField('price').value = 3; getField('qty').value = 2; calculateNow(); var seen = getField('total').value; console.println(seen);", + Some("go"), + ) + .unwrap(); + assert!(out.console.iter().any(|line| line == "6"), "next statement sees calculation result: {out:?}"); + let total = s.get(id).unwrap().form.iter().find(|f| f.name == "total").unwrap().value.clone(); + assert_eq!(total, ["6"]); + assert_eq!(s.get(id).unwrap().can_undo(), Some("Run JavaScript")); + s.undo(id).unwrap(); + assert!(s.get(id).unwrap().form.iter().find(|f| f.name == "total").unwrap().value.is_empty()); + s.redo(id).unwrap(); + assert_eq!(s.get(id).unwrap().form.iter().find(|f| f.name == "total").unwrap().value, ["6"]); + let bytes = s.save_bytes(id).unwrap(); + let reopened = s.open("reopened.pdf", None, bytes, None).unwrap(); + assert_eq!(s.get(reopened).unwrap().form.iter().find(|f| f.name == "total").unwrap().value, ["6"]); +} + +#[test] +fn calculate_now_applies_format_script_to_updated_appearance() { + let mut session = Session::new().with_clock(|| 1_700_000_000); + let id = session.open("calculate-format.pdf", None, Arc::new(scripted_form()), None).unwrap(); + session.run_javascript(id, "getField('price').value = 3; getField('qty').value = 2; calculateNow();", None).unwrap(); + + assert_eq!(session.get(id).unwrap().form.iter().find(|field| field.name == "total").unwrap().value, ["6"]); + assert!(page_texts(&session, id)[0].contains("USD 6.00"), "the appearance uses the Format script"); +} + +#[test] +fn calculation_scripts_are_built_only_for_scripts_that_call_calculate_now() { + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("calculate-list.pdf", None, Arc::new(scripted_form()), None).unwrap(); + crate::js::CALCULATION_SCRIPT_BUILDS.with(|calls| calls.set(0)); + + s.run_javascript(id, "1 + 1", None).unwrap(); + assert_eq!(crate::js::CALCULATION_SCRIPT_BUILDS.with(std::cell::Cell::get), 0); + + s.run_javascript(id, "calculateNow();", None).unwrap(); + assert_eq!(crate::js::CALCULATION_SCRIPT_BUILDS.with(std::cell::Cell::get), 1); + + use pdfcraft_forms::{FieldEvent, Scripts}; + let cos = pdfcraft_cos::Document::open(Arc::new(scripted_form())).unwrap(); + let fields = pdfcraft_forms::fields(&cos); + let target = fields.iter().find(|f| f.name == "go").unwrap(); + let mut runner = crate::js::JsRunner::new(&cos, "calculate-list.pdf", false); + crate::js::CALCULATION_SCRIPT_BUILDS.with(|calls| calls.set(0)); + + runner.run(FieldEvent::Calculate, "1 + 1", target, "", &fields); + assert_eq!(crate::js::CALCULATION_SCRIPT_BUILDS.with(std::cell::Cell::get), 0); + + runner.run(FieldEvent::Calculate, "calculateNow();", target, "", &fields); + assert_eq!(crate::js::CALCULATION_SCRIPT_BUILDS.with(std::cell::Cell::get), 1); + runner.run(FieldEvent::Calculate, "calculateNow();", target, "", &fields); + assert_eq!(crate::js::CALCULATION_SCRIPT_BUILDS.with(std::cell::Cell::get), 1); +} + +#[test] +fn calculate_now_in_a_document_script_helper_gets_the_calculation_list() { + let helpers = "function times\\(a, b\\) { return a * b; } function recalc\\(\\) { calculateNow\\(\\); }"; + let mut session = Session::new().with_clock(|| 1_700_000_000); + let id = session.open("calculate-helper.pdf", None, Arc::new(scripted_form_with(helpers)), None).unwrap(); + let out = session + .run_javascript(id, "getField('price').value = 3; getField('qty').value = 2; recalc(); console.println(getField('total').value);", None) + .unwrap(); + + assert!(out.error.is_none(), "{out:?}"); + assert!(out.console.iter().any(|line| line == "6"), "the document helper recalculated total: {out:?}"); +} + +#[test] +fn calculate_now_uses_calculation_order_and_bounds_recursion() { + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("calculate-chain.pdf", None, Arc::new(scripted_form()), None).unwrap(); + for (name, script) in + [("qty", "event.value = Number(getField('price').value) + 1;"), ("total", "event.value = Number(getField('qty').value) * 2;")] + { + s.apply(id, Edit::SetFieldScript { name: name.into(), event: "calculate".into(), script: Some(script.into()) }).unwrap(); + } + s.apply(id, Edit::SetCalculationOrder { names: vec!["qty".into(), "total".into()] }).unwrap(); + let out = s.run_javascript(id, "getField('price').value = 3; calculateNow(); console.println(getField('total').value);", None).unwrap(); + assert!(out.error.is_none(), "{out:?}"); + assert!(out.console.iter().any(|line| line == "8"), "{out:?}"); + + for (name, script) in + [("qty", "event.value = Number(getField('total').value) + 1;"), ("total", "event.value = Number(getField('qty').value) + 1;")] + { + s.apply(id, Edit::SetFieldScript { name: name.into(), event: "calculate".into(), script: Some(script.into()) }).unwrap(); + } + let cyclic = s.run_javascript(id, "calculateNow();", None).unwrap(); + assert!(cyclic.error.is_none(), "a cyclic calculation runs once in order: {cyclic:?}"); + + s.apply(id, Edit::SetFieldScript { name: "total".into(), event: "calculate".into(), script: Some("calculateNow(); event.value = 12;".into()) }) + .unwrap(); + let out = s.run_javascript(id, "calculateNow();", None).unwrap(); + assert!(out.console.iter().any(|line| line.contains("calculateNow recursion limit exceeded")), "{out:?}"); + s.set_javascript(false); + assert!(s.run_javascript(id, "calculateNow();", None).is_err()); + s.set_javascript(true); + assert!(s.run_javascript(id, "1 + 1", None).unwrap().error.is_none()); +} + +#[test] +fn calculation_order_cache_tracks_script_edits_in_a_batch() { + let mut session = Session::new().with_clock(|| 1_700_000_000); + let id = session.open("calculate-batch.pdf", None, Arc::new(scripted_form()), None).unwrap(); + session + .apply( + id, + Edit::Batch { + label: "Set calculations".into(), + edits: vec![ + Edit::SetFieldScript { + name: "qty".into(), + event: "calculate".into(), + script: Some("event.value = Number(getField('price').value) + 1;".into()), + }, + Edit::SetFieldScript { + name: "total".into(), + event: "calculate".into(), + script: Some("event.value = Number(getField('qty').value) * 2;".into()), + }, + Edit::SetCalculationOrder { names: vec!["qty".into(), "total".into()] }, + Edit::SetFieldValue { name: "price".into(), value: FieldValue::Text("3".into()) }, + ], + }, + ) + .unwrap(); + let total = session.get(id).unwrap().form.iter().find(|field| field.name == "total").unwrap().value.clone(); + assert_eq!(total, ["8"]); +} + +#[test] +fn calculation_order_cache_tracks_field_property_edits_in_a_batch() { + use form_scripts::{CalcOp, Calculate}; + + let mut session = Session::new().with_clock(|| 1_700_000_000); + let id = session.open("calculate-props-batch.pdf", None, Arc::new(scripted_form()), None).unwrap(); + session + .apply( + id, + Edit::Batch { + label: "Change calculation".into(), + edits: vec![ + Edit::SetFieldScript { + name: "qty".into(), + event: "format".into(), + script: Some("calculateNow(); console.println(getField('total').value);".into()), + }, + Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("3".into()) }, + Edit::SetFieldProps { + name: "total".into(), + props: Box::new(FieldProps { + calculate: Some(Calculate::Simple { op: CalcOp::Sum, fields: vec!["qty".into()] }), + ..FieldProps::default() + }), + }, + Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("4".into()) }, + ], + }, + ) + .unwrap(); + + let total = session.get(id).unwrap().form.iter().find(|field| field.name == "total").unwrap().value.clone(); + assert_eq!(total, ["4"]); + let output = session.take_js_output(id); + assert!(output.console.iter().any(|line| line == "4"), "calculateNow observed the refreshed Sum: {output:?}"); +} + +#[test] +fn calculation_order_cache_refreshes_after_inserting_pages() { + use pdfcraft_forms::{FieldEvent, Scripts}; + + let mut doc = pdfcraft_cos::Document::open(Arc::new(scripted_form())).unwrap(); + pdfcraft_forms::set_value(&mut doc, "qty", &FieldValue::Text("3".into())).unwrap(); + let fields = pdfcraft_forms::fields(&doc); + let qty = fields.iter().find(|field| field.name == "qty").unwrap(); + let mut js = crate::js::JsRunner::new(&doc, "destination.pdf", false); + js.run(FieldEvent::Format, "calculateNow();", qty, "", &fields); + + let source = pdfcraft_testkit::Pdf::new() + .obj("<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R] /CO [4 0 R] >> >>") + .obj("<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>") + .obj("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 300] /Annots [4 0 R] >>") + .obj("<< /Type /Annot /Subtype /Widget /FT /Tx /T (importedTotal) /Rect [20 200 180 220] /V (0) /AA << /C << /S /JavaScript /JS (event.value = 9; console.println('inserted field calculated');) >> >> >>") + .build(); + let mut cx = EditCtx::new(None, 0); + cx.js = Some(js); + run_edit( + &mut doc, + &Edit::InsertPagesFrom { + name: "source.pdf".into(), + bytes: Arc::new(source), + pages: None, + at: 1, + field_conflicts: pdfcraft_organize::FieldConflicts::Rename, + }, + &mut cx, + ) + .unwrap(); + + let fields = pdfcraft_forms::fields(&doc); + let qty = fields.iter().find(|field| field.name == "qty").unwrap(); + let js = cx.js.as_mut().unwrap(); + let result = js.run(FieldEvent::Format, "calculateNow();", qty, "", &fields); + assert!( + result.changes.iter().any(|change| { + change.name == "importedTotal" && change.value.as_ref().is_some_and(|value| value.len() == 1 && value.first().is_some_and(|v| v == "9")) + }), + "imported calculation result: {:?}", + result.changes + ); + assert!(js.output.console.iter().any(|line| line == "inserted field calculated"), "{:#?}", js.output); +} + #[test] fn javascript_field_rect_moves_first_widget_and_undoes() { let mut s = Session::new(); @@ -13601,3 +13821,32 @@ fn a_document_encrypting_only_its_embedded_files_opens_and_saves_without_a_passw let reopened = pdfcraft_cos::Document::open(saved).unwrap(); assert!(reopened.embedded_files_locked(), "the attachment stays encrypted"); } + +#[test] +fn calculate_now_simple_calculation_reads_formatted_values_and_groups_as_the_native_path_does() { + // amount (4), group b (5) with kids b.c (6) and b.d (7), total (8) = SUM(amount, b). + let widget = |rest: &str, y: u32| format!("<< /Type /Annot /Subtype /Widget /Rect [20 {y} 180 {}] /P 3 0 R /F 4 {rest} >>", y + 20); + let pdf = pdfcraft_testkit::Pdf::new() + .obj("<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 5 0 R 8 0 R] /CO [8 0 R] /DA (/Helv 0 Tf 0 g) >> >>") + .obj("<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>") + .obj("<< /Type /Page /Parent 2 0 R /Annots [4 0 R 6 0 R 7 0 R 8 0 R] >>") + .obj(widget("/FT /Tx /T (amount) /V (1,234.50)", 250)) + .obj("<< /FT /Tx /T (b) /Kids [6 0 R 7 0 R] >>") + .obj(widget("/Parent 5 0 R /T (c) /V ($10)", 220)) + .obj(widget("/Parent 5 0 R /T (d) /V (1.234,5)", 190)) + .obj(widget( + "/FT /Tx /T (total) /AA << /C << /S /JavaScript /JS (AFSimple_Calculate\\(\"SUM\", new Array\\(\"amount\", \"b\"\\)\\);) >> >>", + 160, + )) + .build(); + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("simple.pdf", None, Arc::new(pdf), None).unwrap(); + let total = |s: &Session| s.get(id).unwrap().form.iter().find(|f| f.name == "total").unwrap().value.clone(); + // The commit path (native Simple calculation) on an edit elsewhere. + s.apply(id, Edit::SetFieldValue { name: "b.c".into(), value: FieldValue::Text("$10".into()) }).unwrap(); + assert_eq!(total(&s), ["2479"], "native Simple calculation"); + let out = s.run_javascript(id, "getField('amount').value = '2,000.25'; calculateNow(); console.println(getField('total').value);", None).unwrap(); + assert!(out.error.is_none(), "{out:?}"); + assert!(out.console.iter().any(|line| line == "3244.75"), "calculateNow agrees with the native path: {out:?}"); + assert_eq!(total(&s), ["3244.75"]); +} diff --git a/crates/forms/src/lib.rs b/crates/forms/src/lib.rs index 6a35956ed0..c1c91f8872 100644 --- a/crates/forms/src/lib.rs +++ b/crates/forms/src/lib.rs @@ -51,7 +51,7 @@ pub use tab_order::{TabOrder, move_in_tab_order, set_tab_order, tab_orders}; #[cfg(test)] use values::has_password_flag; use values::write_value; -pub(crate) use values::{ask_viewers_to_redraw, merged_appearance}; +pub(crate) use values::{ask_viewers_to_redraw, merged_appearance, redraw_calculated_value}; pub use values::{ calculations_changed, clear_password_values, has_password_values, password_values, recalculate, recalculate_with, reset, restore_password_values, set_value, set_value_with, set_values, diff --git a/crates/forms/src/scripting.rs b/crates/forms/src/scripting.rs index eda371c0a3..09fa1ea803 100644 --- a/crates/forms/src/scripting.rs +++ b/crates/forms/src/scripting.rs @@ -38,6 +38,8 @@ impl FieldEvent { pub struct FieldChange { pub name: String, pub value: Option>, + /// This value came from a Calculate script; redraw it through the field's Format script. + pub formatted: bool, pub read_only: Option, pub required: Option, /// `display.visible` 0, `hidden` 1, `noPrint` 2, `noView` 3. @@ -101,7 +103,7 @@ pub(crate) fn accept(doc: &mut Document, f: &Field, value: &str, scripts: &mut d let Some(js) = script else { continue }; let all = fields(doc); let r = scripts.run(event, js, f, &v, &all); - apply_changes(doc, &r.changes, &f.name)?; + apply_changes(doc, &r.changes, &f.name, scripts)?; if !r.rc { return Err(FormError::Invalid(r.message.unwrap_or_else(|| format!("{default} [ {} ]", f.name)))); } @@ -127,7 +129,7 @@ pub(crate) fn formatted_in_english(doc: &Document, f: &Field, value: &str, scrip /// Apply script changes to other fields (values written without re-running their scripts; /// read-only, required and visibility as flags). `except` is the event's own field, whose value /// the event sets. -pub(crate) fn apply_changes(doc: &mut Document, changes: &[FieldChange], except: &str) -> Result<(), FormError> { +pub(crate) fn apply_changes(doc: &mut Document, changes: &[FieldChange], except: &str, scripts: &mut dyn Scripts) -> Result<(), FormError> { if changes.is_empty() { return Ok(()); } @@ -149,6 +151,9 @@ pub(crate) fn apply_changes(doc: &mut Document, changes: &[FieldChange], except: tmp.flags &= !flags::NO_TOGGLE_TO_OFF; tmp.actions = Default::default(); crate::write_value(doc, &tmp, &value, &mut NoScripts)?; + if c.formatted { + crate::redraw_calculated_value(doc, f, v, scripts)?; + } } let mut ff = f.flags; for (on, bit) in [(c.read_only, flags::READ_ONLY), (c.required, flags::REQUIRED)] { @@ -187,7 +192,7 @@ pub(crate) fn apply_changes(doc: &mut Document, changes: &[FieldChange], except: /// Apply what a button or console script changed (values, read-only, required, visibility), /// then recalculate. pub fn apply_script_changes(doc: &mut Document, changes: &[FieldChange], scripts: &mut dyn Scripts) -> Result<(), FormError> { - apply_changes(doc, changes, "")?; + apply_changes(doc, changes, "", scripts)?; crate::recalculate_with(doc, scripts)?; Ok(()) } diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index 6f3eda2be4..55cb97c887 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -3615,6 +3615,7 @@ fn recalc_chain_where_script_changes_checkbox() { changes: vec![FieldChange { name: "agree".into(), value: Some(vec!["Yes".into()]), + formatted: false, read_only: None, required: None, display: None, diff --git a/crates/forms/src/values.rs b/crates/forms/src/values.rs index a479e412eb..b1d50a87ae 100644 --- a/crates/forms/src/values.rs +++ b/crates/forms/src/values.rs @@ -224,7 +224,7 @@ pub fn recalculate_with(doc: &mut Document, scripts: &mut dyn Scripts) -> Result // field to the event, so that value is the result unless the script also assigned // `event.value`, even the value it already had. let own = r.changes.iter().find(|c| c.name == f.name).and_then(|c| c.value.as_ref()).map(|v| v.first().cloned().unwrap_or_default()); - scripting::apply_changes(doc, &r.changes, &f.name)?; + scripting::apply_changes(doc, &r.changes, &f.name, scripts)?; if !r.changes.is_empty() { all = fields(doc); } @@ -473,6 +473,11 @@ pub(crate) fn write_value(doc: &mut Document, f: &Field, value: &FieldValue, scr } } +/// Redraw a calculated value without running input validation or calculation again. +pub(crate) fn redraw_calculated_value(doc: &mut Document, f: &Field, values: &[String], scripts: &mut dyn Scripts) -> Result<(), FormError> { + redraw(doc, f, values, scripts) +} + /// A list box's first visible row (`/TI`) before and after scrolling so the `picked` positions of /// its `count` options are among the rows it draws, as in other viewers: a pick below the last /// visible row would otherwise not show (#1524). `None` for any other field. diff --git a/crates/js/src/globals.rs b/crates/js/src/globals.rs index 5562c7ad4f..1acc9bb89f 100644 --- a/crates/js/src/globals.rs +++ b/crates/js/src/globals.rs @@ -1,10 +1,10 @@ use super::document::{field_object, get_field, get_nth_field_name, noop, num_fields, page_num_get, page_num_set, print, reset_form, submit_form}; use super::format::{printd, printf, printx, scand}; -use super::host::{arg, function, host, s, text, text_line}; +use super::host::{arg, function, host, s, strings, text, text_line}; use super::model::{DISPLAY_HIDDEN, DISPLAY_NO_PRINT, DISPLAY_NO_VIEW, DISPLAY_VISIBLE, Event, Panel, Request}; use super::{ - Attribute, Context, JsArray, JsObject, JsResult, JsString, JsValue, Message, MessageButtons, MessageIcon, NativeFunction, ObjectInitializer, - error, js_string, + Arc, Attribute, Context, JsArray, JsObject, JsResult, JsString, JsValue, Message, MessageButtons, MessageIcon, NativeFunction, ObjectInitializer, + Source, error, js_string, }; // ── app, util, console ────────────────────────────────────────────────────────────────────── @@ -244,6 +244,149 @@ fn console_clear(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult JsResult { + const MAX_CALCULATE_DEPTH: usize = 8; + let calculations = { + let shared = host(ctx)?; + let mut h = shared.borrow_mut(); + if h.calculate_depth >= MAX_CALCULATE_DEPTH { + return Err(error("calculateNow recursion limit exceeded")); + } + h.calculate_depth += 1; + Arc::clone(&h.calculations) + }; + let previous_event = match ctx.global_object().get(js_string!("event"), ctx) { + Ok(event) => event, + Err(error) => { + if let Ok(shared) = host(ctx) { + let mut h = shared.borrow_mut(); + h.calculate_depth = h.calculate_depth.saturating_sub(1); + } + return Err(error); + } + }; + let result = (|| { + for (name, script) in calculations.iter() { + { + let mut h = host(ctx)?.borrow_mut(); + if h.calculate_steps >= super::run::MAX_CALCULATE_STEPS { + return Err(error("calculateNow evaluation limit exceeded")); + } + h.calculate_steps = h.calculate_steps.saturating_add(1); + } + let Some(field) = host(ctx)?.borrow().fields.iter().find(|f| f.name == *name).cloned() else { continue }; + if let Some(why) = super::run::refuse(script) { + host(ctx)?.borrow_mut().print(format!("calculation script for {name} failed: {why}")); + continue; + } + let event = Event::field("Calculate", name, &field.value.first().cloned().unwrap_or_default()); + let (object, _) = event_object(ctx, &event)?; + ctx.global_object().set(js_string!("event"), object.clone(), true, ctx)?; + let (fields_before, changed_before, formatted_before) = { + let h = host(ctx)?.borrow(); + (Arc::clone(&h.fields), h.changed.clone(), h.formatted.clone()) + }; + if let Err(cause) = ctx.eval(Source::from_bytes(script)) { + let mut h = host(ctx)?.borrow_mut(); + h.fields = fields_before; + h.changed = changed_before; + h.formatted = formatted_before; + h.print(format!("calculation script for {name} failed: {cause}")); + continue; + } + let rc = object.get(js_string!("rc"), ctx)?; + if !rc.to_boolean() { + continue; + } + let value = object.get(js_string!("value"), ctx)?; + let value = text(&value, ctx)?; + let shared = host(ctx)?; + let mut h = shared.borrow_mut(); + if let Some((index, _)) = h.fields.iter().enumerate().find(|(_, f)| f.name == *name) { + let target = Arc::make_mut(&mut h.fields).get_mut(index).ok_or_else(|| error("the field is no longer available"))?; + target.value = vec![value]; + h.changed.insert(index); + h.formatted.insert(index); + } + } + Ok(JsValue::undefined()) + })(); + let restored = ctx.global_object().set(js_string!("event"), previous_event, true, ctx).map(|_| JsValue::undefined()); + if let Ok(shared) = host(ctx) { + let mut h = shared.borrow_mut(); + h.calculate_depth = h.calculate_depth.saturating_sub(1); + } + result.and(restored) +} + +/// Acrobat's built-in calculation helper, used by the scripts written by its Calculate tab. +/// `AFSimple_Calculate(op, names)`, with the semantics of the native Simple calculation +/// (`pdfcraft_forms::af::calculate`), so a total read right after `calculateNow()` is the one the +/// commit path writes: a name also stands for every field under it (`b` sums `b.c` and `b.d`), +/// each field counts its first value, and values are read as `AFMakeNumber` reads typed text +/// (currency, `%`, group separators, a comma decimal separator), anything else counting as 0. +fn af_simple_calculate(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + const MAX_EXPANDED_VALUES: usize = 1 << 20; + let op = text(&arg(args, 0), ctx)?; + let names = strings(&arg(args, 1), ctx)?; + let values: Vec = { + let shared = host(ctx)?; + let h = shared.borrow(); + let mut values = Vec::new(); + for name in &names { + let prefix = format!("{name}."); + for f in h.fields.iter().filter(|f| f.name == *name || f.name.starts_with(&prefix)) { + if let Some(v) = f.value.first() { + if values.len() >= MAX_EXPANDED_VALUES { + return Ok(s("0")); + } + values.push(make_number(v).unwrap_or(0.0)); + } + } + } + values + }; + let result = match op.as_str() { + "SUM" => values.iter().sum(), + // An empty product is 0, as the native calculation has it, not 1. + "PRD" if values.is_empty() => 0.0, + "PRD" => values.iter().product(), + "AVG" if values.is_empty() => 0.0, + "AVG" => values.iter().sum::() / values.len() as f64, + "MIN" => values.iter().copied().fold(f64::INFINITY, f64::min), + "MAX" => values.iter().copied().fold(f64::NEG_INFINITY, f64::max), + _ => 0.0, + }; + Ok(s(&if result.is_finite() { js_number(result) } else { "0".into() })) +} + +/// `AFMakeNumber`'s reading of typed text, as `pdfcraft_forms::af::make_number` has it: currency +/// symbols, spaces, `'` and `%` ignored, `(7)` negative, and a comma the decimal separator when +/// it comes last (`1.234,56`) or is not followed by exactly three digits. +fn make_number(s: &str) -> Option { + let t: String = s.trim().chars().filter(|c| !c.is_whitespace() && !matches!(c, '$' | '€' | '£' | '¥' | '\'' | '%')).collect(); + if t.is_empty() { + return None; + } + let neg = t.starts_with('(') && t.ends_with(')'); + let t = t.trim_start_matches('(').trim_end_matches(')'); + let normalized = match (t.rfind(','), t.rfind('.')) { + (Some(c), Some(d)) if c > d => t.replace('.', "").replace(',', "."), + (Some(c), None) if t.len() - c - 1 != 3 => t.replace(',', "."), + _ => t.replace(',', ""), + }; + let v: f64 = normalized.parse().ok()?; + v.is_finite().then_some(if neg { -v } else { v }) +} + +/// A number as JavaScript prints it (integers without a fraction), as the native calculation +/// writes it. +fn js_number(v: f64) -> String { + if v.fract() == 0.0 && v.abs() < 1e15 { format!("{}", v as i64) } else { format!("{v}") } +} + fn timer(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { // Timers never fire in the sandbox; scripts get an object they can pass to clear*. Ok(ObjectInitializer::new(ctx).build().into()) @@ -282,14 +425,15 @@ const XFA_VERSION: f64 = 3.5; /// `numPages`, `documentFileName`, `info`, the XFA version), and the `app`, `util`, `console`, /// `display` and `color` objects. pub(crate) fn install(ctx: &mut Context) -> JsResult<()> { - let globals: [(&str, usize, NativeFunction); 11] = [ + let globals: [(&str, usize, NativeFunction); 12] = [ ("getField", 1, NativeFunction::from_fn_ptr(get_field)), ("getNthFieldName", 1, NativeFunction::from_fn_ptr(get_nth_field_name)), ("resetForm", 1, NativeFunction::from_fn_ptr(reset_form)), ("print", 1, NativeFunction::from_fn_ptr(print)), ("submitForm", 1, NativeFunction::from_fn_ptr(submit_form)), ("mailDoc", 1, NativeFunction::from_fn_ptr(noop)), - ("calculateNow", 0, NativeFunction::from_fn_ptr(noop)), + ("calculateNow", 0, NativeFunction::from_fn_ptr(calculate_now)), + ("AFSimple_Calculate", 2, NativeFunction::from_fn_ptr(af_simple_calculate)), ("getPageBox", 2, NativeFunction::from_fn_ptr(get_page_box)), ("syncAnnotScan", 0, NativeFunction::from_fn_ptr(noop)), ("dirty", 0, NativeFunction::from_fn_ptr(noop)), diff --git a/crates/js/src/host.rs b/crates/js/src/host.rs index 68d581f093..d57cccbe74 100644 --- a/crates/js/src/host.rs +++ b/crates/js/src/host.rs @@ -8,10 +8,14 @@ use super::{ pub(crate) struct Host { pub(crate) fields: Arc>, pub(crate) changed: BTreeSet, + pub(crate) formatted: BTreeSet, pub(crate) alerts: Vec, pub(crate) console: Vec, pub(crate) requests: Vec, pub(crate) doc: DocInfo, + pub(crate) calculations: Arc>, + pub(crate) calculate_depth: usize, + pub(crate) calculate_steps: usize, /// Bytes of text kept in alerts, console lines and requests, against [`xfa::MAX_PAYLOAD`]. pub(crate) kept: usize, /// Alerts, console lines and requests the limits left out. diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs index b04813cdc4..1c0fe24f7b 100644 --- a/crates/js/src/lib.rs +++ b/crates/js/src/lib.rs @@ -56,7 +56,7 @@ pub use model::{ use run::SCRIPT_STACK; use run::refuse; pub(crate) use run::{CheckedCodeFromStrings, MAX_SCRIPT_BYTES}; -pub use run::{run, run_shared}; +pub use run::{run, run_shared, run_shared_calculations, run_shared_calculations_shared}; pub mod formcalc; pub mod xfa; diff --git a/crates/js/src/model.rs b/crates/js/src/model.rs index 4419f6b77b..6314adbfa2 100644 --- a/crates/js/src/model.rs +++ b/crates/js/src/model.rs @@ -297,6 +297,8 @@ pub struct Outcome { pub change: String, /// Fields whose value or properties the script changed, as they are now. pub changed: Vec, + /// Fields updated by Calculate scripts; their Format script must redraw the appearance. + pub formatted: std::collections::BTreeSet, /// The messages the script showed, in order. pub alerts: Vec, pub console: Vec, diff --git a/crates/js/src/run.rs b/crates/js/src/run.rs index 9435e7c8e4..8f760f8130 100644 --- a/crates/js/src/run.rs +++ b/crates/js/src/run.rs @@ -16,6 +16,9 @@ pub(crate) const MAX_PRINTF_OUTPUT_BYTES: usize = 1 << 20; /// length costs the script nothing but the host one string per item; real lists are tiny. pub(crate) const MAX_ARRAY_LENGTH: u64 = 1 << 20; +/// Maximum calculated field scripts evaluated by `calculateNow()` during one JavaScript run. +pub(crate) const MAX_CALCULATE_STEPS: usize = 4096; + /// Deepest nesting of `(`, `[` and `{`. const MAX_BRACKET_DEPTH: usize = 64; @@ -108,6 +111,33 @@ pub fn run(script: &str, event: &Event, doc: &DocInfo, fields: &[FieldState], do /// context; the first field write copies the snapshot, so neither the caller nor another /// script can observe uncommitted writes. Read-only calculations avoid copying the whole form. pub fn run_shared(script: &str, event: &Event, doc: &DocInfo, fields: Arc>, doc_scripts: &[String], limits: Limits) -> Outcome { + run_shared_calculations(script, event, doc, fields, doc_scripts, &Arc::new(Vec::new()), limits) +} + +/// Like [`run_shared`], with the document's ordered calculated fields available to +/// `calculateNow()` while this context is running. +pub fn run_shared_calculations( + script: &str, + event: &Event, + doc: &DocInfo, + fields: Arc>, + doc_scripts: &[String], + calculations: &[(String, String)], + limits: Limits, +) -> Outcome { + run_shared_calculations_shared(script, event, doc, fields, doc_scripts, &Arc::new(calculations.to_vec()), limits) +} + +/// Like [`run_shared_calculations`], sharing its ordered calculation scripts with the context. +pub fn run_shared_calculations_shared( + script: &str, + event: &Event, + doc: &DocInfo, + fields: Arc>, + doc_scripts: &[String], + calculations: &Arc>, + limits: Limits, +) -> Outcome { let failed = |why: String| Outcome { rc: true, value: event.value.clone(), change: event.change.clone(), error: Some(why), ..Default::default() }; if let Some(why) = refuse(script) { return failed(why); @@ -132,10 +162,11 @@ pub fn run_shared(script: &str, event: &Event, doc: &DocInfo, fields: Arc 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}")), @@ -143,7 +174,7 @@ pub fn run_shared(script: &str, event: &Event, doc: &DocInfo, fields: Arc String { /// `doc_scripts` are the document scripts to run with their places among all of them; /// `skipped` the ones refused before running, with the reason. -fn run_here( - script: &str, - event: &Event, - doc: &DocInfo, - fields: Arc>, - doc_scripts: &[(usize, &str)], - skipped: &[(usize, String)], - limits: Limits, -) -> Outcome { +struct ScriptSetup<'a> { + doc_scripts: &'a [(usize, &'a str)], + skipped: &'a [(usize, String)], + calculations: &'a Arc>, +} + +fn run_here(script: &str, event: &Event, doc: &DocInfo, fields: Arc>, setup: ScriptSetup<'_>, limits: Limits) -> Outcome { let mut ctx = match Context::builder().host_hooks(std::rc::Rc::new(NoCodeFromStrings)).build() { Ok(ctx) => ctx, Err(e) => { @@ -206,15 +235,19 @@ fn run_here( ctx.insert_data(RefCell::new(Host { fields, changed: BTreeSet::new(), + formatted: BTreeSet::new(), alerts: Vec::new(), console: Vec::new(), requests: Vec::new(), doc: doc.clone(), + calculations: Arc::clone(setup.calculations), + calculate_depth: 0, + calculate_steps: 0, kept: 0, dropped: 0, })); let mut out = Outcome { rc: true, value: event.value.clone(), change: event.change.clone(), ..Default::default() }; - let mut doc_errors: Vec<(usize, String)> = skipped.iter().map(|(i, why)| (*i, doc_error(*i, why))).collect(); + let mut doc_errors: Vec<(usize, String)> = setup.skipped.iter().map(|(i, why)| (*i, doc_error(*i, why))).collect(); let result = (|| -> JsResult<()> { install(&mut ctx)?; let (ev, value_store) = event_object(&mut ctx, event)?; @@ -222,7 +255,7 @@ fn run_here( // One document script that throws (calling a method PdfCraft lacks, say) must not keep // every field script in the form from running: what it defined before the error, and // the other scripts, stay available (#3940). - for &(i, d) in doc_scripts { + for &(i, d) in setup.doc_scripts { if let Err(e) = ctx.eval(Source::from_bytes(d)) { doc_errors.push((i, doc_error(i, &e))); } @@ -248,6 +281,7 @@ fn run_here( if let Some(h) = ctx.remove_data::() { let h = h.into_inner(); out.changed = h.changed.iter().map(|i| h.fields[*i].clone()).collect(); + out.formatted = h.formatted.iter().filter_map(|&i| h.fields.get(i).map(|f| f.name.clone())).collect(); out.alerts = h.alerts; out.console = h.console; out.requests = h.requests; diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs index 4dd00c69e4..fb49c06532 100644 --- a/crates/js/src/tests.rs +++ b/crates/js/src/tests.rs @@ -27,6 +27,59 @@ fn go(script: &str, event: &Event) -> Outcome { run(script, event, &doc(), &fields(), &[], Limits::default()) } +#[test] +fn calculate_now_reports_errors_and_continues() { + let calculations = + vec![("bad".into(), "getField('price').value = 'partial'; throw new Error('broken');".into()), ("good".into(), "event.value = 'ok';".into())]; + let state = Arc::new(vec![ + FieldState::new("bad", FieldType::Text, vec![]), + FieldState::new("good", FieldType::Text, vec![]), + FieldState::new("price", FieldType::Text, vec![]), + ]); + let outcome = run_shared_calculations( + "calculateNow(); console.println('continued');", + &Event::doc("Console"), + &doc(), + state, + &[], + &calculations, + Limits::default(), + ); + assert_eq!(outcome.error, None); + assert!(outcome.console.iter().any(|line| line.contains("calculation script for bad failed: Error: broken")), "{outcome:?}"); + assert!(outcome.console.iter().any(|line| line == "continued"), "{outcome:?}"); + assert_eq!(outcome.changed.iter().find(|field| field.name == "good").unwrap().value, ["ok"]); + assert!(outcome.changed.iter().all(|field| field.name != "price"), "{outcome:?}"); + assert_eq!(outcome.formatted, BTreeSet::from(["good".into()])); +} + +#[test] +fn calculate_now_sets_read_only_fields_and_handles_hundreds() { + let mut states = Vec::new(); + let mut calculations = Vec::new(); + for index in 0..300 { + let name = format!("field{index}"); + let mut state = FieldState::new(name.clone(), FieldType::Text, vec![]); + state.readonly = true; + states.push(state); + calculations.push((name, "event.value = 'calculated';".into())); + } + let state = Arc::new(states); + let outcome = + run_shared_calculations("calculateNow();", &Event::doc("Console"), &doc(), Arc::clone(&state), &[], &calculations, Limits::default()); + assert_eq!(outcome.error, None, "{outcome:?}"); + assert_eq!(outcome.changed.len(), 300); + assert!(outcome.changed.iter().all(|field| field.value == ["calculated"] && field.readonly)); +} + +#[test] +fn caught_calculate_now_recursion_has_a_step_limit() { + let calculations: Vec<_> = (0..600).map(|index| (format!("loop{index}"), "try { calculateNow(); } catch (e) {}".into())).collect(); + let state = Arc::new(calculations.iter().map(|(name, _)| FieldState::new(name.clone(), FieldType::Text, vec![])).collect()); + let outcome = run_shared_calculations("calculateNow();", &Event::doc("Console"), &doc(), state, &[], &calculations, Limits::default()); + assert!(outcome.error.as_deref().is_some_and(|error| error.contains("calculateNow evaluation limit exceeded")), "{outcome:?}"); +} + #[test] fn calculate_scripts_read_fields_and_set_the_value() { let o = go("event.value = this.getField('price').value * getField('qty').value;", &Event::field("Calculate", "total", "")); @@ -435,10 +488,14 @@ fn app_alert_titles_are_clipped_and_charged() { let mut h = Host { fields: Arc::new(Vec::new()), changed: BTreeSet::new(), + formatted: BTreeSet::new(), alerts: Vec::new(), console: Vec::new(), requests: Vec::new(), doc: doc(), + calculations: Arc::new(Vec::new()), + calculate_depth: 0, + calculate_steps: 0, kept: 0, dropped: 0, }; diff --git a/crates/ui-egui/src/canvas/area.rs b/crates/ui-egui/src/canvas/area.rs index 53b1edd328..2e3b63b5f8 100644 --- a/crates/ui-egui/src/canvas/area.rs +++ b/crates/ui-egui/src/canvas/area.rs @@ -201,6 +201,7 @@ pub(super) fn run_button(app: &mut PdfCraftApp, index: usize, name: &str, action .map(|name| pdfcraft_engine::FieldChange { name, value: None, + formatted: false, read_only: None, required: None, display: Some(if hide { 1 } else { 0 }), diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 6f95898c04..3ea69e1bf7 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5375,8 +5375,8 @@ milestone = "M6" status = "shipped" commands = ["tools.js_console"] tools = ["js_run"] -evidence = ["crates/ui-egui/tests/it/javascript.rs::console_runs_scripts_against_the_document", "crates/automation/tests/it/automation.rs::javascript_through_tools"] -notes = "\u2318J: run JavaScript against the document, with console output, results and errors." +evidence = ["crates/ui-egui/tests/it/javascript.rs::console_runs_scripts_against_the_document", "crates/automation/tests/it/automation.rs::javascript_through_tools", "crates/engine/src/tests.rs::calculate_now_updates_live_fields_in_one_undo_step"] +notes = "\u2318J: run JavaScript against the document, with console output, results and errors. calculateNow() runs calculated fields synchronously in /CO order against writes already made by the script." [[feature]] id = "form.js-debugger"