Fix #5211: make calculateNow synchronous (#5304)

* Fix #5211: make calculateNow synchronous

* Bound and cache calculation execution

* fix(engine): invalidate field calculation cache

* perf(js): build calculation scripts lazily

* Refresh calculation cache after page insertion

* Load calculations for document script helpers

* Assert imported calculation result

* Initialize formatted field-change state

* Update formatted calculation test expectations

* Include the referenced field in calculation test

---------

Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
This commit is contained in:
Harrison BoothandFredApps authored and GitHub committed 2026-11-10 22:48:50 +01:00
1 parent 1be1cfa6ed
commit c73a53705b
16 files changed
+651 -43

No files matched your search

+8
View File
@@ -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!([]));
+22 -2
View File
@@ -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)?,
+90 -12
View File
@@ -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<FieldChange> {
fn changes(
before: &[pdfcraft_js::FieldState],
after: &[pdfcraft_js::FieldState],
formatted: &std::collections::BTreeSet<String>,
) -> Vec<FieldChange> {
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<pdfcraft_cos::ObjRef, i32>,
calculations: Option<std::sync::Arc<Vec<(String, String)>>>,
}
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<Vec<(String, String)>> {
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<usize> = 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<Vec<(String, String)>> {
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::<Vec<_>>());
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<Vec<String>> = 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());
}
}
+249
View File
@@ -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"]);
}
+1 -1
View File
@@ -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,
+8 -3
View File
@@ -38,6 +38,8 @@ impl FieldEvent {
pub struct FieldChange {
pub name: String,
pub value: Option<Vec<String>>,
/// This value came from a Calculate script; redraw it through the field's Format script.
pub formatted: bool,
pub read_only: Option<bool>,
pub required: Option<bool>,
/// `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(())
}
+1
View File
@@ -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,
+6 -1
View File
@@ -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.
+149 -5
View File
@@ -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<JsVa
Ok(JsValue::undefined())
}
/// Recalculate fields in the document's `/CO` order against the writes already made by this
/// script. A small depth cap stops calculate scripts from recursing forever.
fn calculate_now(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
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<JsValue> {
const MAX_EXPANDED_VALUES: usize = 1 << 20;
let op = text(&arg(args, 0), ctx)?;
let names = strings(&arg(args, 1), ctx)?;
let values: Vec<f64> = {
let shared = host(ctx)?;
let h = shared.borrow();
let mut values = Vec::new();
for name in &names {
let prefix = format!("{name}.");
for f in h.fields.iter().filter(|f| f.name == *name || f.name.starts_with(&prefix)) {
if let Some(v) = f.value.first() {
if values.len() >= MAX_EXPANDED_VALUES {
return Ok(s("0"));
}
values.push(make_number(v).unwrap_or(0.0));
}
}
}
values
};
let result = match op.as_str() {
"SUM" => values.iter().sum(),
// An empty product is 0, as the native calculation has it, not 1.
"PRD" if values.is_empty() => 0.0,
"PRD" => values.iter().product(),
"AVG" if values.is_empty() => 0.0,
"AVG" => values.iter().sum::<f64>() / values.len() as f64,
"MIN" => values.iter().copied().fold(f64::INFINITY, f64::min),
"MAX" => values.iter().copied().fold(f64::NEG_INFINITY, f64::max),
_ => 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<f64> {
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<JsValue> {
// 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)),
+4
View File
@@ -8,10 +8,14 @@ use super::{
pub(crate) struct Host {
pub(crate) fields: Arc<Vec<FieldState>>,
pub(crate) changed: BTreeSet<usize>,
pub(crate) formatted: BTreeSet<usize>,
pub(crate) alerts: Vec<Message>,
pub(crate) console: Vec<String>,
pub(crate) requests: Vec<Request>,
pub(crate) doc: DocInfo,
pub(crate) calculations: Arc<Vec<(String, String)>>,
pub(crate) 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.
+1 -1
View File
@@ -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;
+2
View File
@@ -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<FieldState>,
/// Fields updated by Calculate scripts; their Format script must redraw the appearance.
pub formatted: std::collections::BTreeSet<String>,
/// The messages the script showed, in order.
pub alerts: Vec<Message>,
pub console: Vec<String>,
+50 -16
View File
@@ -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<Vec<FieldState>>, 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<Vec<FieldState>>,
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<Vec<FieldState>>,
doc_scripts: &[String],
calculations: &Arc<Vec<(String, String)>>,
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<Vec<Fi
// The script's thread writes dates as this one asked (`with_english_printd`).
let english = crate::format::printd_english();
std::thread::scope(|scope| {
let spawned = std::thread::Builder::new()
.name("pdfcraft-js".into())
.stack_size(SCRIPT_STACK)
.spawn_scoped(scope, || crate::format::with_english_printd(english, || run_here(script, event, doc, fields, kept, skipped, limits)));
let spawned = std::thread::Builder::new().name("pdfcraft-js".into()).stack_size(SCRIPT_STACK).spawn_scoped(scope, || {
crate::format::with_english_printd(english, || {
run_here(script, event, doc, fields, ScriptSetup { doc_scripts: kept, skipped, calculations }, limits)
})
});
match spawned {
Ok(thread) => thread.join().unwrap_or_else(|_| failed("the script stopped with an internal error".into())),
Err(e) => failed(format!("the script engine could not start: {e}")),
@@ -143,7 +174,7 @@ pub fn run_shared(script: &str, event: &Event, doc: &DocInfo, fields: Arc<Vec<Fi
})
}
#[cfg(target_arch = "wasm32")]
run_here(script, event, doc, fields, kept, skipped, limits)
run_here(script, event, doc, fields, ScriptSetup { doc_scripts: kept, skipped, calculations }, limits)
}
/// Host hooks that refuse to compile code from strings (`eval`, `Function` and its generator
@@ -185,15 +216,13 @@ fn doc_error(index: usize, error: &dyn std::fmt::Display) -> String {
/// `doc_scripts` are the document scripts to run with their places among all of them;
/// `skipped` the ones refused before running, with the reason.
fn run_here(
script: &str,
event: &Event,
doc: &DocInfo,
fields: Arc<Vec<FieldState>>,
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<Vec<(String, String)>>,
}
fn run_here(script: &str, event: &Event, doc: &DocInfo, fields: Arc<Vec<FieldState>>, setup: ScriptSetup<'_>, limits: Limits) -> Outcome {
let mut ctx = match Context::builder().host_hooks(std::rc::Rc::new(NoCodeFromStrings)).build() {
Ok(ctx) => ctx,
Err(e) => {
@@ -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::<Shared>() {
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;
+57
View File
@@ -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,
};
+1
View File
@@ -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 }),
+2 -2
View File
@@ -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"