mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-11 00:41:21 +00:00
* 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:
1 parent
1be1cfa6ed
commit
c73a53705b
16 files changed
+651
-43
No files matched your search
@@ -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!([]));
|
||||
|
||||
@@ -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
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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"]);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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)),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user