XFA: one script thread per event, and the live form kept up to date in place (#1355)
* XFA: one script thread per event, and the live form kept up to date in place A form with hundreds of calculate scripts used to spend its open-time budget on set-up: every script got a thread of its own, and the engine dropped its live form after every value a script settled, rebuilding the whole tree for the next script. The js crate gains xfa_session::XfaSession: the scripts of one event run on one thread (with the script stack), each in a fresh JavaScript context or FormCalc interpreter on a copy of the form as the engine holds it then, so scripts share nothing, a script that fails halfway leaves nothing behind, and what a script changed reaches the next one only through the engine. A script past its time limit is abandoned on the thread and the session is dead from then on; a fault in the engine (a panic) loses only that script, the thread is replaced. On wasm the scripts run inline, as before. The engine's runner keeps one session per event and, for a value, now refreshes the one field in its live form in place (xfa::refresh_value, computing the value as the tree builder would: saved values, template defaults) instead of rebuilding the tree; rows, presence, access, resets, relayouts and data rewrites still rebuild it. Under test, the live form is compared with a fresh build before every script and at the end of every event, and a difference ends the test binary (a panic would be caught by the engine's guard and turned into a script error). 210 calculate scripts (one per row of 10 sections x 20 rows, plus a sum per section), debug build, instrumented inside the engine: the script event on open 495 ms -> 204 ms (of which live-form rebuilds 217 ms -> 0), on a change 300 ms -> 172 ms; through the tools, layout included, open 1.27 s -> 0.97 s and a change 0.44 s -> 0.22 s. The rebuild cost removed grew with form size times script count. * CI: the GoToR tests' case lists are only grown on Unix, so Windows must not warn on their mut
This commit is contained in:
parent
0e55d07b5f
commit
4b45fc9cb4
|
|
@ -3179,6 +3179,8 @@ fn local_gotor_link_open_refuses_directories_devices_and_non_pdf_targets() {
|
|||
std::fs::write(root.join("notes.txt"), fixture(1)).unwrap();
|
||||
std::fs::write(root.join("dir-source.pdf"), remote_link_pdf("folder.pdf", None)).unwrap();
|
||||
std::fs::write(root.join("txt-source.pdf"), remote_link_pdf("notes.txt", None)).unwrap();
|
||||
// Only Unix adds a case (a FIFO), so elsewhere the list is never changed.
|
||||
#[cfg_attr(not(unix), allow(unused_mut))]
|
||||
let mut cases = vec![("dir-source.pdf", "not a regular file"), ("txt-source.pdf", "not a .pdf file")];
|
||||
#[cfg(unix)]
|
||||
{
|
||||
|
|
@ -4169,6 +4171,33 @@ BT /F1 11 Tf 20 20 Td (Total due: 150.00) Tj ET";
|
|||
assert!(rendered.iter().any(|c| matches!(c, Content::Png { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hundreds_of_xfa_calculations_open_and_recalculate_within_their_budgets() {
|
||||
let dir = workdir("xfa-many");
|
||||
let (sections, rows) = (10, 20);
|
||||
std::fs::write(dir.join("many.pdf"), pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::many_calculations_template(sections, rows))).unwrap();
|
||||
let mut a = auto(&dir);
|
||||
let started = std::time::Instant::now();
|
||||
let opened = ok(&mut a, "doc_open", json!({ "path": "many.pdf" }));
|
||||
let open_took = started.elapsed();
|
||||
let doc = opened["doc"].as_u64().unwrap();
|
||||
let warnings = opened["warnings"].to_string();
|
||||
assert!(!warnings.contains("were skipped") && !warnings.contains("stopped"), "{warnings}");
|
||||
let fields = ok(&mut a, "form_fields", json!({ "doc": doc }));
|
||||
let totals: Vec<&serde_json::Value> =
|
||||
fields["fields"].as_array().unwrap().iter().filter(|f| f["name"].as_str().is_some_and(|n| n.starts_with("total"))).collect();
|
||||
assert_eq!(totals.len(), sections * rows);
|
||||
assert!(totals.iter().all(|f| !f["value"].as_str().unwrap_or("").is_empty()), "every total calculated");
|
||||
let started = std::time::Instant::now();
|
||||
let r = ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "qty": "100" } }));
|
||||
let change_took = started.elapsed();
|
||||
assert!(!r.to_string().contains("were skipped"), "{r}");
|
||||
let fields = ok(&mut a, "form_fields", json!({ "doc": doc }));
|
||||
let total = fields["fields"].as_array().unwrap().iter().find(|f| f["name"] == "total").unwrap();
|
||||
assert_eq!(total["value"], "200");
|
||||
eprintln!("{} calculate scripts: open {open_took:?}, recalculation after a change {change_took:?}", sections * rows + sections);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xfa_data_files_export_and_import_through_tools() {
|
||||
let dir = workdir("xfa-data");
|
||||
|
|
|
|||
|
|
@ -3082,6 +3082,63 @@ fn xfa_data_import_adds_and_removes_rows_of_a_dynamic_form() {
|
|||
assert!(String::from_utf8_lossy(&out).contains("<item>only</item>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hundreds_of_xfa_calculations_run_in_one_engine_within_the_open_budget() {
|
||||
let (sections, rows) = (10, 20);
|
||||
let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::many_calculations_template(sections, rows)));
|
||||
let mut s = Session::new().with_clock(|| 1_700_000_000);
|
||||
let started = std::time::Instant::now();
|
||||
let id = s.open("many.pdf", None, bytes, None).expect("opens");
|
||||
let elapsed = started.elapsed();
|
||||
let out = s.take_js_output(id);
|
||||
assert!(!out.errors.iter().any(|e| e.contains("were skipped") || e.contains("stopped")), "{:?}", out.errors);
|
||||
let form = &s.get(id).unwrap().form;
|
||||
let totals: Vec<&pdfcraft_forms::Field> = form.iter().filter(|f| f.name.starts_with("total")).collect();
|
||||
assert_eq!(totals.len(), sections * rows);
|
||||
assert!(totals.iter().all(|f| !f.value.is_empty()), "every row's total was calculated");
|
||||
let sums: Vec<&pdfcraft_forms::Field> = form.iter().filter(|f| f.name.starts_with("sum")).collect();
|
||||
assert_eq!(sums.len(), sections);
|
||||
// 2 × (1 + 2 + … + rows)
|
||||
let expect = (rows * (rows + 1)).to_string();
|
||||
assert!(sums.iter().all(|f| f.value == vec![expect.clone()]), "{:?}", sums.iter().map(|f| f.value.clone()).collect::<Vec<_>>());
|
||||
eprintln!("{} calculate scripts on open in {elapsed:?}", sections * rows + sections);
|
||||
// A change recalculates them all again, well within the event budget.
|
||||
let started = std::time::Instant::now();
|
||||
s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("100".into()) }).unwrap();
|
||||
let out = s.take_js_output(id);
|
||||
assert!(!out.errors.iter().any(|e| e.contains("were skipped")), "{:?}", out.errors);
|
||||
assert_eq!(s.get(id).unwrap().form.iter().find(|f| f.name == "total").unwrap().value, vec!["200".to_string()]);
|
||||
assert_eq!(s.get(id).unwrap().form.iter().find(|f| f.name == "sum").unwrap().value, vec![(rows * (rows + 1) - 2 + 200).to_string()]);
|
||||
eprintln!("recalculated after a change in {:?}", started.elapsed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xfa_scripts_of_one_event_see_the_form_as_the_engine_holds_it() {
|
||||
// In document order: `a` holds 1; `b`'s calculate writes `a` then fails; `c`'s calculate
|
||||
// reads `a`. A failed script's half-done changes reach nobody. And `d` sets a choice list
|
||||
// by its shown text: the next script reads the saved value the engine kept.
|
||||
let tpl = pdfcraft_xfa::fixtures::fields_template("<form><page1><country>JP</country></page1></form>").replace(
|
||||
r#"<field name="summary" w="3in" h="0.3in" access="readOnly"><ui><textEdit/></ui>"#,
|
||||
r#"<field name="a" w="1in" h="0.3in"><ui><numericEdit/></ui><value><integer>1</integer></value></field>
|
||||
<field name="b" w="1in" h="0.3in"><ui><numericEdit/></ui><calculate><script contentType="application/x-javascript">a.rawValue = 99; throw new Error("boom");</script></calculate></field>
|
||||
<field name="c" w="1in" h="0.3in"><ui><numericEdit/></ui><calculate><script contentType="application/x-javascript">a.rawValue + 1</script></calculate></field>
|
||||
<field name="d" w="1in" h="0.3in"><ui><textEdit/></ui><calculate><script contentType="application/x-javascript">country.rawValue = "Canada"; "set"</script></calculate></field>
|
||||
<field name="e" w="1in" h="0.3in"><ui><textEdit/></ui><calculate><script>country</script></calculate></field>
|
||||
<field name="summary" w="3in" h="0.3in" access="readOnly"><ui><textEdit/></ui>"#,
|
||||
);
|
||||
let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&tpl));
|
||||
let mut s = Session::new().with_clock(|| 1_700_000_000);
|
||||
let id = s.open("iso.pdf", None, bytes, None).expect("opens");
|
||||
let value = |s: &Session, n: &str| s.get(id).unwrap().form.iter().find(|f| f.name == n).map(|f| f.value.clone()).unwrap_or_default();
|
||||
let out = s.take_js_output(id);
|
||||
assert!(out.errors.iter().any(|e| e.contains("boom")), "{out:?}");
|
||||
assert_eq!(value(&s, "a"), vec!["1".to_string()], "the failed script's write reached nothing");
|
||||
assert_eq!(value(&s, "c"), vec!["2".to_string()]);
|
||||
assert_eq!(value(&s, "country"), vec!["CA".to_string()], "shown text mapped to the saved value");
|
||||
assert_eq!(value(&s, "e"), vec!["CA".to_string()], "the next script reads the saved value");
|
||||
assert_eq!(value(&s, "summary"), vec!["Country: CA".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xfa_scripts_initialize_calculate_validate_toggle_and_add_rows() {
|
||||
let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::scripted_template()));
|
||||
|
|
|
|||
|
|
@ -8,8 +8,8 @@
|
|||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use pdfcraft_js::formcalc::run_formcalc_within;
|
||||
use pdfcraft_js::xfa::{XfaDoc, XfaEffect, XfaEvent, XfaKind, XfaNode, run_xfa_within};
|
||||
use pdfcraft_js::xfa::{XfaDoc, XfaEffect, XfaEvent, XfaKind, XfaNode};
|
||||
use pdfcraft_js::xfa_session::{Script, XfaSession};
|
||||
use pdfcraft_xfa::model::Template;
|
||||
use pdfcraft_xfa::{FormNode, LiveForm, NodeKind, ScriptEvent};
|
||||
|
||||
|
|
@ -261,6 +261,10 @@ struct Runner<'a, 'b> {
|
|||
fields: Option<HashMap<String, FieldInfo>>,
|
||||
/// The live form as last built; dropped when a script changed the form.
|
||||
cached: Option<LiveForm>,
|
||||
/// The script engine for this event: one thread for all its scripts, each run in a fresh
|
||||
/// context on the form as it is then (`cached`, kept up to date in place for values and
|
||||
/// rebuilt when the structure changed).
|
||||
session: Option<XfaSession>,
|
||||
changes: Changes,
|
||||
}
|
||||
|
||||
|
|
@ -287,10 +291,39 @@ impl<'a, 'b> Runner<'a, 'b> {
|
|||
by_som: None,
|
||||
fields: None,
|
||||
cached: None,
|
||||
session: None,
|
||||
changes: Changes::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The live form no longer matches the data (its structure changed): rebuilt for the next
|
||||
/// script.
|
||||
fn invalidate(&mut self) {
|
||||
self.cached = None;
|
||||
}
|
||||
|
||||
/// The script engine for this event.
|
||||
fn session(&mut self) -> &mut XfaSession {
|
||||
let doc_info = XfaDoc { file_name: String::new(), page: 0, page_count: self.run.page_count };
|
||||
self.session.get_or_insert_with(|| XfaSession::new(doc_info))
|
||||
}
|
||||
|
||||
/// Give the object at `som` the value `text` in the data and in the live form (refreshed
|
||||
/// in place, as the tree builder would set it; a value changes nothing else, so the tree
|
||||
/// is not rebuilt for it).
|
||||
fn value_op(&mut self, som: &str, path: pdfcraft_xfa::DataPath, text: String) {
|
||||
let op = pdfcraft_xfa::DataOp::Value { path, text };
|
||||
self.data.get_or_insert_with(Default::default).apply(&op);
|
||||
self.pending.push(op);
|
||||
let refreshed = match self.cached.as_mut() {
|
||||
Some(live) => pdfcraft_xfa::refresh_value(&mut live.root, self.tpl, self.data.as_ref(), som).is_some(),
|
||||
None => true,
|
||||
};
|
||||
if !refreshed {
|
||||
self.invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
fn live(&mut self) -> &LiveForm {
|
||||
if self.cached.is_none() {
|
||||
let live = pdfcraft_xfa::form_tree(self.tpl, self.data.as_ref(), &self.ov);
|
||||
|
|
@ -346,11 +379,12 @@ impl<'a, 'b> Runner<'a, 'b> {
|
|||
}
|
||||
}
|
||||
|
||||
/// Change the data (and the in-memory copy scripts read).
|
||||
/// Change the data's structure (and the in-memory copy scripts read): the form is rebuilt
|
||||
/// for the next script.
|
||||
fn data_op(&mut self, op: pdfcraft_xfa::DataOp) {
|
||||
self.data.get_or_insert_with(Default::default).apply(&op);
|
||||
self.pending.push(op);
|
||||
self.cached = None;
|
||||
self.invalidate();
|
||||
}
|
||||
|
||||
/// Give the object at `som` the value `value` (field `name` when it has a widget). Returns
|
||||
|
|
@ -363,7 +397,7 @@ impl<'a, 'b> Runner<'a, 'b> {
|
|||
if self.data.as_ref().and_then(|d| d.text_at(&path)) == Some(value) {
|
||||
return false;
|
||||
}
|
||||
self.data_op(pdfcraft_xfa::DataOp::Value { path, text: value.to_string() });
|
||||
self.value_op(som, path, value.to_string());
|
||||
self.changes.values += 1;
|
||||
return true;
|
||||
};
|
||||
|
|
@ -399,7 +433,7 @@ impl<'a, 'b> Runner<'a, 'b> {
|
|||
let text = data_text(&updated);
|
||||
self.fields().insert(name.clone(), updated);
|
||||
self.field_changes.insert(name, new_value);
|
||||
self.data_op(pdfcraft_xfa::DataOp::Value { path, text });
|
||||
self.value_op(som, path, text);
|
||||
self.changes.values += 1;
|
||||
true
|
||||
}
|
||||
|
|
@ -432,7 +466,7 @@ impl<'a, 'b> Runner<'a, 'b> {
|
|||
if wrote {
|
||||
// What the packet now holds (a value it could not take is reported above).
|
||||
self.data = pdfcraft_xfa::data_of(self.doc);
|
||||
self.cached = None;
|
||||
self.invalidate();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -456,7 +490,7 @@ impl<'a, 'b> Runner<'a, 'b> {
|
|||
self.console(crate::undrawn_field_warnings(self.doc));
|
||||
self.by_som = None;
|
||||
self.fields = None;
|
||||
self.cached = None;
|
||||
self.invalidate();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -490,7 +524,7 @@ impl<'a, 'b> Runner<'a, 'b> {
|
|||
self.changes.values += 1;
|
||||
}
|
||||
}
|
||||
self.cached = None;
|
||||
self.invalidate();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -530,7 +564,7 @@ impl<'a, 'b> Runner<'a, 'b> {
|
|||
if self.ov.presence.insert(som.clone(), presence.clone()).as_ref() != Some(presence) {
|
||||
self.ov_dirty = true;
|
||||
self.changes.visibility += 1;
|
||||
self.cached = None;
|
||||
self.invalidate();
|
||||
relayout = true;
|
||||
}
|
||||
}
|
||||
|
|
@ -538,7 +572,7 @@ impl<'a, 'b> Runner<'a, 'b> {
|
|||
if self.ov.access.insert(som.clone(), access.clone()).as_ref() != Some(access) {
|
||||
self.ov_dirty = true;
|
||||
self.changes.visibility += 1;
|
||||
self.cached = None;
|
||||
self.invalidate();
|
||||
relayout = true;
|
||||
}
|
||||
}
|
||||
|
|
@ -586,17 +620,23 @@ impl<'a, 'b> Runner<'a, 'b> {
|
|||
return Ok(None);
|
||||
}
|
||||
self.runs += 1;
|
||||
#[cfg(test)]
|
||||
self.check_live();
|
||||
let event = XfaEvent { activity: ev.activity.clone(), target: ev.som.clone(), new_text: new_text.to_string(), prev_text: String::new() };
|
||||
let doc_info = XfaDoc { file_name: String::new(), page: 0, page_count: self.run.page_count };
|
||||
let root_js = to_js(&self.live().root);
|
||||
let (limits, timeout) = (limits_for(&ev.activity), timeout_for(&ev.activity));
|
||||
let o = if ev.formcalc {
|
||||
run_formcalc_within(&ev.script, &event, &doc_info, root_js, limits, timeout)
|
||||
} else {
|
||||
run_xfa_within(&ev.script, &event, &doc_info, root_js, limits, timeout)
|
||||
let root = to_js(&self.live().root);
|
||||
let script = Script {
|
||||
source: ev.script.clone(),
|
||||
formcalc: ev.formcalc,
|
||||
event,
|
||||
root,
|
||||
limits: limits_for(&ev.activity),
|
||||
timeout: timeout_for(&ev.activity),
|
||||
};
|
||||
let o = self.session().run(script);
|
||||
if o.abandoned {
|
||||
self.run.ran_away = true;
|
||||
// The thread is left to the runaway script: nothing more is sent to it.
|
||||
self.session = None;
|
||||
}
|
||||
self.console(o.console.iter().cloned());
|
||||
for n in &o.notes {
|
||||
|
|
@ -635,12 +675,15 @@ impl<'a, 'b> Runner<'a, 'b> {
|
|||
/// End the event: write what is left, lay the form out if a capped relayout is owed, and
|
||||
/// report what the limits cut.
|
||||
fn finish(mut self) -> Result<Changes, String> {
|
||||
#[cfg(test)]
|
||||
self.check_live();
|
||||
if self.relayouts_skipped {
|
||||
self.relayouts = 0;
|
||||
self.relayouts_skipped = false;
|
||||
self.relayout()?;
|
||||
}
|
||||
self.flush()?;
|
||||
self.session = None;
|
||||
if self.skipped > 0 {
|
||||
let why = if self.run.ran_away {
|
||||
"one ran too long".to_string()
|
||||
|
|
@ -658,6 +701,42 @@ impl<'a, 'b> Runner<'a, 'b> {
|
|||
}
|
||||
}
|
||||
|
||||
impl Runner<'_, '_> {
|
||||
/// The live form kept up to date in place must be the form a fresh build gives: every
|
||||
/// field's SOM, value, presence and access (tests run this before every script and at the
|
||||
/// end of every event). A difference is a bug in the in-place refresh, so the test binary
|
||||
/// is ended at once: a panic here would be caught by the engine's guard and turned into a
|
||||
/// script error the test might not look at.
|
||||
#[cfg(test)]
|
||||
fn check_live(&mut self) {
|
||||
let Some(live) = self.cached.as_ref() else { return };
|
||||
let fresh = pdfcraft_xfa::form_tree(self.tpl, self.data.as_ref(), &self.ov);
|
||||
let (mut have, mut want) = (Vec::new(), Vec::new());
|
||||
collect_fields(&live.root, &mut have, 0);
|
||||
collect_fields(&fresh.root, &mut want, 0);
|
||||
if have != want {
|
||||
let differ: Vec<String> =
|
||||
have.iter().zip(want.iter()).filter(|(a, b)| a != b).take(5).map(|(a, b)| format!("live {a:?} vs fresh {b:?}")).collect();
|
||||
eprintln!("the live form diverged from a fresh build ({} vs {} fields): {}", have.len(), want.len(), differ.join("; "));
|
||||
std::process::abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Every field's (SOM, value, presence, access), in document order.
|
||||
#[cfg(test)]
|
||||
fn collect_fields(n: &FormNode, out: &mut Vec<(String, String, String, String)>, depth: usize) {
|
||||
if depth > 64 {
|
||||
return;
|
||||
}
|
||||
if n.kind == Some(NodeKind::Field) {
|
||||
out.push((n.som.clone(), n.value.clone(), n.presence.clone(), n.access.clone()));
|
||||
}
|
||||
for c in &n.children {
|
||||
collect_fields(c, out, depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
/// On open: initialize scripts, then calculations. Returns what they changed.
|
||||
pub(crate) fn on_open(doc: &mut pdfcraft_cos::Document, tpl: &Template, run: &mut XfaRun) -> Result<Changes, String> {
|
||||
let mut r = Runner::new(doc, tpl, run, OPEN_BUDGET_MS);
|
||||
|
|
|
|||
|
|
@ -1164,6 +1164,7 @@ fn run_here(script: &str, event: &Event, doc: &DocInfo, fields: &[FieldState], d
|
|||
|
||||
pub mod formcalc;
|
||||
pub mod xfa;
|
||||
pub mod xfa_session;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
|
|
|||
|
|
@ -980,3 +980,82 @@ $host.gotoURL("https://example.org")"#,
|
|||
assert!(o.abandoned || o.error.is_some(), "{o:?}");
|
||||
}
|
||||
}
|
||||
|
||||
mod session {
|
||||
use super::xfa_model::form;
|
||||
use crate::Limits;
|
||||
use crate::xfa::*;
|
||||
use crate::xfa_session::{Script, XfaSession};
|
||||
|
||||
fn script(source: &str, formcalc: bool, target: &str, activity: &str) -> Script {
|
||||
Script {
|
||||
source: source.into(),
|
||||
formcalc,
|
||||
event: XfaEvent { activity: activity.into(), target: target.into(), ..Default::default() },
|
||||
root: form(),
|
||||
limits: Limits::default(),
|
||||
timeout: std::time::Duration::from_secs(3),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scripts_of_one_event_run_on_one_thread_each_on_the_form_the_caller_sends() {
|
||||
let mut s = XfaSession::new(XfaDoc { page_count: 3, ..Default::default() });
|
||||
assert_eq!(s.dead(), None);
|
||||
// JavaScript, then FormCalc, on the same thread.
|
||||
let o = s.run(script("qty.rawValue = 10; qty.rawValue", false, "form1[0].page1[0].total[0]", "calculate"));
|
||||
assert_eq!(o.error, None, "{o:?}");
|
||||
assert_eq!(o.effects.len(), 1);
|
||||
assert_eq!(o.result.as_deref(), Some("10"));
|
||||
// The next script sees the form the caller sends, not what the last script did on
|
||||
// its copy: nothing a script changed leaks past the caller.
|
||||
let o = s.run(script("qty * price", true, "form1[0].page1[0].total[0]", "calculate"));
|
||||
assert_eq!(o.result.as_deref(), Some("10"), "{o:?}");
|
||||
let mut with = script("qty * price", true, "form1[0].page1[0].total[0]", "calculate");
|
||||
with.root.children[0].children[0].value = "10".into();
|
||||
assert_eq!(s.run(with).result.as_deref(), Some("25"));
|
||||
// Nor do variables, globals, prototype changes or kept objects.
|
||||
s.run(script("leak = 5; String.prototype.trim = function () { return 'P' }; kept = qty; 1", false, "form1[0].page1[0].qty[0]", "calculate"));
|
||||
let o = s.run(script("typeof leak + ':' + ' x '.trim() + ':' + typeof kept", false, "form1[0].page1[0].qty[0]", "calculate"));
|
||||
assert_eq!(o.result.as_deref(), Some("undefined:x:undefined"));
|
||||
s.run(script("Object.freeze(globalThis); 1", false, "form1[0].page1[0].qty[0]", "calculate"));
|
||||
let o = s.run(script("xfa.host.numPages", false, "form1[0].page1[0].qty[0]", "calculate"));
|
||||
assert_eq!(o.result.as_deref(), Some("3"), "{o:?}");
|
||||
// `this` and `event` follow each script's target.
|
||||
let o = s.run(script("this.name + ':' + xfa.event.name", false, "form1[0].page1[0].price[0]", "change"));
|
||||
assert_eq!(o.result.as_deref(), Some("price:change"));
|
||||
// A failing script doesn't end the session.
|
||||
let o = s.run(script("throw new Error('no')", false, "form1[0].page1[0].go[0]", "click"));
|
||||
assert!(o.error.is_some() && !o.abandoned);
|
||||
assert_eq!(s.run(script("1 + 1", true, "form1[0].page1[0].go[0]", "click")).result.as_deref(), Some("2"));
|
||||
// Hundreds of scripts on the one thread.
|
||||
let started = std::time::Instant::now();
|
||||
for i in 0..300 {
|
||||
let o = s.run(script(&format!("{i} + qty.rawValue"), i % 2 == 0, "form1[0].page1[0].total[0]", "calculate"));
|
||||
assert_eq!(o.error, None, "{o:?}");
|
||||
}
|
||||
assert!(started.elapsed() < std::time::Duration::from_secs(10), "{:?}", started.elapsed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_script_past_its_time_is_abandoned_and_the_session_is_dead_after_it() {
|
||||
let mut s = XfaSession::new(XfaDoc::default());
|
||||
let mut slow = script("while (true) {}", false, "form1[0].page1[0].go[0]", "click");
|
||||
slow.limits = Limits { loop_iterations: u64::MAX, recursion: 32 };
|
||||
slow.timeout = std::time::Duration::from_millis(200);
|
||||
let started = std::time::Instant::now();
|
||||
let o = s.run(slow);
|
||||
assert!(o.abandoned, "{o:?}");
|
||||
assert!(started.elapsed() < std::time::Duration::from_secs(2));
|
||||
assert!(s.dead().is_some_and(|w| w.contains("abandoned")));
|
||||
let o = s.run(script("1", false, "form1[0].page1[0].go[0]", "click"));
|
||||
assert!(o.abandoned && o.result.is_none(), "nothing more runs: {o:?}");
|
||||
// FormCalc past its time too (it stops itself at the deadline and is reported).
|
||||
let mut s = XfaSession::new(XfaDoc::default());
|
||||
let mut slow = script("while (1) do endwhile", true, "form1[0].page1[0].go[0]", "click");
|
||||
slow.limits = Limits { loop_iterations: u64::MAX, recursion: 32 };
|
||||
slow.timeout = std::time::Duration::from_millis(200);
|
||||
let o = s.run(slow);
|
||||
assert!(o.abandoned || o.error.as_deref().is_some_and(|e| e.contains("too long")), "{o:?}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1373,7 +1373,7 @@ pub fn run_xfa_within(script: &str, event: &XfaEvent, doc: &XfaDoc, root: XfaNod
|
|||
/// for the first script in a process the engine's own set-up) before the script is abandoned.
|
||||
/// It doesn't count against the script's time: on a busy machine the start alone can take
|
||||
/// longer than the tightest limit, and abandoning a script turns the form's scripts off.
|
||||
const ENGINE_START_LIMIT: std::time::Duration = std::time::Duration::from_secs(10);
|
||||
pub(crate) const ENGINE_START_LIMIT: std::time::Duration = std::time::Duration::from_secs(10);
|
||||
|
||||
/// Run `script` on its own thread (with the script stack) and wait at most `timeout` for its
|
||||
/// outcome, counted from when it calls `started` (once the engine is ready); past that the
|
||||
|
|
@ -1445,7 +1445,7 @@ pub(crate) fn run_within_limits(
|
|||
|
||||
/// The first script in a process sets the engine up (its built-ins, parser and compiler come
|
||||
/// into use): a trivial script takes that on, so a form's script is never timed with it.
|
||||
fn warm_up() {
|
||||
pub(crate) fn warm_up() {
|
||||
static WARM: std::sync::Once = std::sync::Once::new();
|
||||
// `call_once_force`: a poisoned `Once` (a panic while warming up) only means warming up again.
|
||||
WARM.call_once_force(|_| {
|
||||
|
|
@ -1455,7 +1455,7 @@ fn warm_up() {
|
|||
}
|
||||
|
||||
/// `started` is called once the engine is ready: a time limit on the script runs from then.
|
||||
fn run_here(script: &str, event: &XfaEvent, doc: &XfaDoc, root: XfaNode, limits: Limits, started: &dyn Fn()) -> XfaOutcome {
|
||||
pub(crate) fn run_here(script: &str, event: &XfaEvent, doc: &XfaDoc, root: XfaNode, limits: Limits, started: &dyn Fn()) -> XfaOutcome {
|
||||
warm_up();
|
||||
let mut ctx = Context::default();
|
||||
started();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,173 @@
|
|||
//! One script engine for a whole form event. Every script of the event runs on one thread
|
||||
//! (with the script stack), each in a fresh JavaScript context on a fresh copy of the form as
|
||||
//! the caller holds it, so scripts can't see each other's variables or objects and a script
|
||||
//! that failed halfway leaves nothing behind; what a script changed reaches the next one only
|
||||
//! through the caller, which applies the effects and sends the form as it then is. Before,
|
||||
//! every script also got a thread of its own and the caller rebuilt the form for each: a form
|
||||
//! with hundreds of calculations spent its open-time budget on that.
|
||||
//!
|
||||
//! A script that runs past its time limit is abandoned on the thread and the session is dead
|
||||
//! from then on. A script that fails from a fault in the engine (a panic) loses only itself:
|
||||
//! the thread is replaced for the next script. On wasm there are no threads: scripts run here.
|
||||
|
||||
use crate::Limits;
|
||||
use crate::xfa::{XfaDoc, XfaEvent, XfaNode, XfaOutcome};
|
||||
|
||||
/// What one script run needs.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Script {
|
||||
pub source: String,
|
||||
pub formcalc: bool,
|
||||
pub event: XfaEvent,
|
||||
/// The form as the caller holds it now.
|
||||
pub root: XfaNode,
|
||||
pub limits: Limits,
|
||||
pub timeout: std::time::Duration,
|
||||
}
|
||||
|
||||
/// Run `s` here: a fresh context (JavaScript) or interpreter (FormCalc) on its copy of the form.
|
||||
fn run_script(s: Script, doc: &XfaDoc) -> XfaOutcome {
|
||||
if s.formcalc {
|
||||
return crate::formcalc::run_formcalc_at(&s.source, &s.event, doc, s.root, s.limits, Some(s.timeout));
|
||||
}
|
||||
if let Some(why) = crate::refuse(&s.source) {
|
||||
return XfaOutcome { error: Some(why), ..Default::default() };
|
||||
}
|
||||
crate::xfa::run_here(&s.source, &s.event, doc, s.root, s.limits, &|| {})
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod native {
|
||||
use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender, channel};
|
||||
use std::time::Instant;
|
||||
|
||||
use super::{Script, run_script};
|
||||
use crate::xfa::{ENGINE_START_LIMIT, XfaDoc, XfaOutcome};
|
||||
|
||||
enum Reply {
|
||||
Ready,
|
||||
Outcome(Box<XfaOutcome>),
|
||||
}
|
||||
|
||||
/// The engine's thread, started for the first script and kept for the event.
|
||||
struct Worker {
|
||||
to: Sender<Box<Script>>,
|
||||
from: Receiver<Reply>,
|
||||
}
|
||||
|
||||
/// The scripts of one event, run on one thread.
|
||||
pub struct XfaSession {
|
||||
doc: XfaDoc,
|
||||
worker: Option<Worker>,
|
||||
/// A script ran past its limit: its thread is left to it and nothing more runs.
|
||||
dead: Option<String>,
|
||||
}
|
||||
|
||||
impl XfaSession {
|
||||
pub fn new(doc: XfaDoc) -> XfaSession {
|
||||
XfaSession { doc, worker: None, dead: None }
|
||||
}
|
||||
|
||||
/// Why nothing more runs, once a script was abandoned.
|
||||
pub fn dead(&self) -> Option<&str> {
|
||||
self.dead.as_deref()
|
||||
}
|
||||
|
||||
/// The thread, started now when there is none (the first script, or after one failed
|
||||
/// with a fault in the engine). `Err`: it could not start.
|
||||
fn worker(&mut self) -> Result<&mut Worker, String> {
|
||||
if self.worker.is_none() {
|
||||
let (to, inbox) = channel::<Box<Script>>();
|
||||
let (reply, from) = channel::<Reply>();
|
||||
let doc = self.doc.clone();
|
||||
let spawned = std::thread::Builder::new().name("pdfcraft-xfa-session".into()).stack_size(crate::SCRIPT_STACK).spawn(move || {
|
||||
crate::xfa::warm_up();
|
||||
// The caller is gone when a send fails: nothing to report then.
|
||||
let _ = reply.send(Reply::Ready);
|
||||
while let Ok(s) = inbox.recv() {
|
||||
let o = run_script(*s, &doc);
|
||||
let _ = reply.send(Reply::Outcome(Box::new(o)));
|
||||
}
|
||||
});
|
||||
spawned.map_err(|e| format!("the script engine could not start: {e}"))?;
|
||||
match from.recv_timeout(ENGINE_START_LIMIT) {
|
||||
Ok(Reply::Ready) => {}
|
||||
Ok(_) => return Err("the script engine answered out of turn".into()),
|
||||
Err(RecvTimeoutError::Timeout) => {
|
||||
return Err(format!("the script engine took longer than {:.0} s to start", ENGINE_START_LIMIT.as_secs_f64()));
|
||||
}
|
||||
Err(RecvTimeoutError::Disconnected) => return Err("the script engine stopped with an internal error".into()),
|
||||
}
|
||||
self.worker = Some(Worker { to, from });
|
||||
}
|
||||
self.worker.as_mut().ok_or_else(|| "the script engine is not running".into())
|
||||
}
|
||||
|
||||
/// Run one script, waiting at most its `timeout`; past that it is abandoned on its
|
||||
/// thread and the session is dead.
|
||||
pub fn run(&mut self, script: Script) -> XfaOutcome {
|
||||
let abandoned = |why: String| XfaOutcome { error: Some(why), abandoned: true, ..Default::default() };
|
||||
let failed = |why: String| XfaOutcome { error: Some(why), ..Default::default() };
|
||||
if let Some(why) = &self.dead {
|
||||
return abandoned(why.clone());
|
||||
}
|
||||
let timeout = script.timeout;
|
||||
let worker = match self.worker() {
|
||||
Ok(w) => w,
|
||||
Err(why) => return failed(why),
|
||||
};
|
||||
if worker.to.send(Box::new(script)).is_err() {
|
||||
// The thread is gone (a fault in the engine): the next script gets a new one.
|
||||
self.worker = None;
|
||||
return failed("the script engine stopped with an internal error".into());
|
||||
}
|
||||
let deadline = Instant::now().checked_add(timeout);
|
||||
loop {
|
||||
let left = deadline.map_or(timeout, |d| d.saturating_duration_since(Instant::now()));
|
||||
match worker.from.recv_timeout(left) {
|
||||
Ok(Reply::Outcome(o)) => return *o,
|
||||
// Not expected here: wait on for the outcome.
|
||||
Ok(Reply::Ready) => continue,
|
||||
Err(RecvTimeoutError::Timeout) => {
|
||||
let why = format!("the script ran longer than {:.1} s and was abandoned", timeout.as_secs_f64());
|
||||
self.dead = Some(why.clone());
|
||||
// Dropping the sender ends the thread's loop once the script is done.
|
||||
self.worker = None;
|
||||
return abandoned(why);
|
||||
}
|
||||
Err(RecvTimeoutError::Disconnected) => {
|
||||
self.worker = None;
|
||||
return failed("the script stopped with an internal error".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod native {
|
||||
use super::{Script, run_script};
|
||||
use crate::xfa::{XfaDoc, XfaOutcome};
|
||||
|
||||
/// The scripts of one event, run here (no threads on wasm: a script's own limits bound it).
|
||||
pub struct XfaSession {
|
||||
doc: XfaDoc,
|
||||
}
|
||||
|
||||
impl XfaSession {
|
||||
pub fn new(doc: XfaDoc) -> XfaSession {
|
||||
XfaSession { doc }
|
||||
}
|
||||
|
||||
pub fn dead(&self) -> Option<&str> {
|
||||
None
|
||||
}
|
||||
|
||||
pub fn run(&mut self, script: Script) -> XfaOutcome {
|
||||
run_script(script, &self.doc)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub use native::XfaSession;
|
||||
|
|
@ -208,6 +208,8 @@ fn a_gotor_link_to_a_directory_or_device_is_refused_without_reading_it() {
|
|||
let dir = std::env::temp_dir().join(format!("pdfcraft-ui-gotor-not-a-file-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(dir.join("folder.pdf")).unwrap();
|
||||
// Only Unix adds a target (a device), so elsewhere the list is never changed.
|
||||
#[cfg_attr(not(unix), allow(unused_mut))]
|
||||
let mut targets = vec!["folder.pdf"];
|
||||
#[cfg(unix)]
|
||||
{
|
||||
|
|
|
|||
|
|
@ -97,8 +97,6 @@ let form = pdfcraft_xfa::layout_xml(template_xml)?; // pages of items, for
|
|||
A hidden instance of a repeating subform hides every instance (overrides apply to the template).
|
||||
A repeating subform shows what its data holds once the data holds any instance, else its
|
||||
`initial` count, and never fewer than one (`occur min="0"` still shows one row).
|
||||
Each script runs in a fresh engine on its own thread; a form with hundreds of calculate
|
||||
scripts pays that on every field change (a shared engine per event is the obvious next step).
|
||||
- **Script limits.** Forms run their scripts on open without being asked, so the engine bounds
|
||||
each event: a script's effects (values set again on the same object merge, the last wins;
|
||||
10 000 effects, 100 message boxes, 1 000 console lines per script and per event), its loop
|
||||
|
|
@ -106,6 +104,13 @@ let form = pdfcraft_xfa::layout_xml(template_xml)?; // pages of items, for
|
|||
click and change), 2 000 scripts and 50 relayouts per event, and 3 s (open, changes) or 5 s
|
||||
(click) of scripts per event, after which the rest are skipped and reported. Only a click may
|
||||
print, save, open a link or move the focus; other events asking are noted in the console.
|
||||
All the scripts of one event (open, a change, a click) run on one thread, each in a fresh
|
||||
JavaScript context (or FormCalc interpreter) on a copy of the form as the engine holds it
|
||||
then: scripts share nothing, a script that fails halfway leaves nothing behind, and what a
|
||||
script changed reaches the next one only through the engine, which applies the effects and
|
||||
keeps its live form up to date in place for values (rebuilding it only when the structure
|
||||
changed). A form with hundreds of calculations runs them all well within the open budget,
|
||||
where each used to pay for a thread of its own and a rebuild of the whole form.
|
||||
What open-time scripts change is noted in the document's XFA warnings. Known gaps: the
|
||||
engine's loop limit is per call frame, so loops inside nested calls can run far longer; such
|
||||
a script is abandoned after 1 s (3 s for a click) on its thread, which keeps running until the
|
||||
|
|
|
|||
|
|
@ -313,6 +313,45 @@ pub fn fields_template(data: &str) -> String {
|
|||
)
|
||||
}
|
||||
|
||||
/// A form of `sections` sections of `rows` rows, each row with a calculated total (half in
|
||||
/// JavaScript, half in FormCalc) and a section sum over its rows: 2 × sections × rows + sections
|
||||
/// calculate scripts, every one reading values the earlier ones settled.
|
||||
pub fn many_calculations_template(sections: usize, rows: usize) -> String {
|
||||
let mut body = String::new();
|
||||
for sct in 0..sections {
|
||||
body.push_str(&format!("<subform name=\"s{sct}\" layout=\"tb\"><subform name=\"table\" layout=\"table\" columnWidths=\"1in 1in 1in\">"));
|
||||
for r in 0..rows {
|
||||
let calc = if (sct + r) % 2 == 0 {
|
||||
"<script contentType=\"application/x-javascript\">qty.rawValue * price.rawValue</script>"
|
||||
} else {
|
||||
"<script>qty * price</script>"
|
||||
};
|
||||
body.push_str(&format!(
|
||||
"<subform name=\"r{r}\" layout=\"row\"><field name=\"qty\" h=\"0.2in\"><ui><numericEdit/></ui><value><integer>{}</integer></value></field><field name=\"price\" h=\"0.2in\"><ui><numericEdit/></ui><value><integer>2</integer></value></field><field name=\"total\" h=\"0.2in\"><ui><numericEdit/></ui><calculate>{calc}</calculate></field></subform>",
|
||||
r + 1
|
||||
));
|
||||
}
|
||||
body.push_str("</subform>");
|
||||
let terms: Vec<String> = (0..rows).map(|r| format!("table.r{r}.total")).collect();
|
||||
body.push_str(&format!(
|
||||
"<field name=\"sum\" w=\"2in\" h=\"0.2in\"><ui><numericEdit/></ui><calculate><script>Sum({})</script></calculate></field></subform>",
|
||||
terms.join(", ")
|
||||
));
|
||||
}
|
||||
format!(
|
||||
r##"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/">
|
||||
<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
|
||||
<subform name="form" layout="tb">
|
||||
<pageSet><pageArea name="front"><occur max="-1"/><contentArea x="0.5in" y="0.5in" w="7.5in" h="10in"/><medium short="8.5in" long="11in"/></pageArea></pageSet>
|
||||
{body}
|
||||
</subform>
|
||||
</template>
|
||||
</xdp:xdp>
|
||||
"##
|
||||
)
|
||||
}
|
||||
|
||||
/// A PDF shell around `xdp`: one placeholder page, `/NeedsRendering true`, no fields.
|
||||
pub fn shell(xdp: &str) -> Vec<u8> {
|
||||
let objs: Vec<Vec<u8>> = vec![
|
||||
|
|
|
|||
|
|
@ -101,7 +101,7 @@ pub use parse::{measure, parse};
|
|||
pub use pdf::{CLICK_KEY, LAYOUT_KEY, SOM_KEY, existing_layout};
|
||||
pub use script::{
|
||||
FormNode, LiveForm, MAX_FORM_NODES, NodeKind, OVERRIDES_KEY, Overrides, ScriptEvent, apply_overrides, data_of, fields_by_som, form_tree,
|
||||
has_scripts, overrides, rerender, set_overrides, template_of,
|
||||
has_scripts, overrides, refresh_value, rerender, set_overrides, template_of,
|
||||
};
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
|
|
|
|||
|
|
@ -270,6 +270,16 @@ impl Walker<'_> {
|
|||
}
|
||||
}
|
||||
|
||||
/// What [`Self::field`] gives a field outside an exclusion group: its data value (as saved)
|
||||
/// or its template default.
|
||||
fn plain_field_value(tpl: &Template, data: Option<&DataNode>, f: &Field, som: &str) -> String {
|
||||
let data_value = data.and_then(|d| d.value_at(&data_path(tpl, som))).map(|t| t.trim().to_string()).filter(|t| !t.is_empty());
|
||||
match data_value {
|
||||
Some(v) => f.saved_value(&v),
|
||||
None => f.value.plain().map(|v| f.saved_value(v.trim())).unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn children(&mut self, nodes: &[Node], parent_som: &str, depth: usize) -> Vec<FormNode> {
|
||||
let mut out = Vec::new();
|
||||
if depth > MAX_DEPTH {
|
||||
|
|
@ -582,6 +592,70 @@ pub fn template_of(doc: &Document) -> Result<Template, XfaError> {
|
|||
crate::parse(&packets.xdp).map(|(t, _)| t)
|
||||
}
|
||||
|
||||
/// The template field the object at `som` is an instance of, when it is a field outside an
|
||||
/// exclusion group (whose buttons take their value from the group).
|
||||
fn template_field<'a>(tpl: &'a Template, som: &str) -> Option<&'a Field> {
|
||||
let path = crate::data::som_to_path(som);
|
||||
let (first, rest) = path.split_first()?;
|
||||
if tpl.root.common.name.as_deref() != Some(first.0.as_str()) {
|
||||
return None;
|
||||
}
|
||||
let mut level: &[Node] = &tpl.root.children;
|
||||
let mut found: Option<&Node> = None;
|
||||
for (name, _) in rest {
|
||||
let next = named_in(level, name, 0)?;
|
||||
level = match next {
|
||||
Node::Subform(sf) => &sf.children,
|
||||
Node::Area(a) => &a.children,
|
||||
_ => &[],
|
||||
};
|
||||
found = Some(next);
|
||||
}
|
||||
match found {
|
||||
Some(Node::Field(f)) => Some(f),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The child of `nodes` named `name`, looking through unnamed subforms and areas (as data
|
||||
/// paths do); exclusion groups are not looked into.
|
||||
fn named_in<'a>(nodes: &'a [Node], name: &str, depth: usize) -> Option<&'a Node> {
|
||||
if depth > MAX_DEPTH {
|
||||
return None;
|
||||
}
|
||||
nodes.iter().find(|n| n.common().name.as_deref() == Some(name)).or_else(|| {
|
||||
nodes.iter().find_map(|n| match n {
|
||||
Node::Subform(sf) if sf.common.name.is_none() => named_in(&sf.children, name, depth + 1),
|
||||
Node::Area(a) if a.common.name.is_none() => named_in(&a.children, name, depth + 1),
|
||||
_ => None,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Give the field at `som` in `live` the value [`form_tree`] would give it from `data` now,
|
||||
/// so a value change needs no rebuild of the tree. `None`: not a plain field the tree can
|
||||
/// refresh alone (a radio button, an object the template doesn't have): rebuild the tree.
|
||||
pub fn refresh_value(live: &mut FormNode, tpl: &Template, data: Option<&DataNode>, som: &str) -> Option<bool> {
|
||||
let f = template_field(tpl, som)?;
|
||||
let value = Walker::plain_field_value(tpl, data, f, som);
|
||||
fn set(n: &mut FormNode, som: &str, value: &str, depth: usize) -> Option<bool> {
|
||||
if depth > MAX_DEPTH {
|
||||
return None;
|
||||
}
|
||||
if n.som == som {
|
||||
if n.kind != Some(NodeKind::Field) {
|
||||
return None;
|
||||
}
|
||||
let changed = n.value != value;
|
||||
n.value = value.to_string();
|
||||
return Some(changed);
|
||||
}
|
||||
n.children.iter_mut().find_map(|c| set(c, som, value, depth + 1))
|
||||
}
|
||||
// Not in the live tree (hidden, or past the node budget): nothing to refresh.
|
||||
Some(set(live, som, &value, 0).unwrap_or(false))
|
||||
}
|
||||
|
||||
/// The data tree of a document's datasets packet.
|
||||
pub fn data_of(doc: &Document) -> Option<DataNode> {
|
||||
crate::read_packets(doc).ok().flatten().and_then(|p| parse_datasets(&p.xdp))
|
||||
|
|
|
|||
|
|
@ -5185,7 +5185,7 @@ status = "partial"
|
|||
commands = ["file.open"]
|
||||
tools = ["doc_open", "doc_info", "form_fields", "form_fill", "js_run"]
|
||||
evidence = ["crates/xfa/src/tests.rs::layout_places_captions_fields_and_sizes_containers_to_their_content", "crates/xfa/src/tests.rs::tables_break_across_pages_and_repeat_their_header_row", "crates/xfa/src/tests.rs::rendering_into_the_pdf_replaces_the_placeholder_page_and_adds_fields", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/automation/tests/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/ui_group/xfa.rs::a_dynamic_xfa_form_is_laid_out_and_filled", "crates/xfa/src/tests.rs::nested_width_less_subforms_lay_out_in_polynomial_time", "crates/js/src/tests.rs::instance_managers_add_remove_and_count", "crates/xfa/src/tests.rs::the_form_tree_carries_values_presence_instances_and_the_events", "crates/engine/src/tests.rs::xfa_scripts_initialize_calculate_validate_toggle_and_add_rows", "crates/automation/tests/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/ui_group/xfa.rs::xfa_buttons_run_their_scripts_in_the_app", "crates/engine/src/tests.rs::hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted", "crates/engine/src/tests.rs::runaway_xfa_calculations_at_open_end_quickly_with_a_report", "crates/ui-egui/tests/ui_group/xfa.rs::a_form_whose_scripts_were_turned_off_at_open_says_so", "crates/ui-egui/tests/ui_group/xfa.rs::scripts_turned_off_by_a_click_bring_a_closed_notice_bar_back", "crates/automation/tests/automation.rs::doc_info_says_when_a_runaway_script_turned_the_forms_scripts_off", "crates/xfa/src/tests.rs::the_live_form_of_nested_repeating_subforms_stops_at_its_node_budget", "crates/xfa/src/tests.rs::data_ops_of_one_event_are_one_datasets_rewrite", "crates/xfa/src/tests.rs::choice_lists_signatures_passwords_and_pictures_become_fields_and_images", "crates/xfa/src/tests.rs::choice_values_come_from_the_data_as_saved_values_and_several_for_lists", "crates/xfa/src/tests.rs::pictures_are_sized_from_their_headers_and_hostile_ones_are_refused_unallocated", "crates/engine/src/tests.rs::xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save", "crates/xfa/src/tests.rs::repeating_subforms_inside_positioned_content_get_their_instances", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them"]
|
||||
notes = "laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters, breaks, repeated header rows, captions, rich text, JPEG/PNG/GIF images) into real pages and AcroForm fields (text, password, date with AFDate formats, check boxes, radio groups, drop-downs and list boxes with shown/saved item pairs and multi-select, signature fields, image fields showing their picture (pictures decoded once per document, at most 20 million pixels, and reused across re-layouts), buttons with reset/print/save/URL), filled from the datasets packet (including rows another viewer added), saved as an incremental revision with the values merged into the datasets so Adobe's viewers show them; doc_info reports xfa_layout. XFA JavaScript and FormCalc (form.xfa-formcalc) run in the js crate's XFA object model (xfa.form, SOM resolution, rawValue, presence, access, instance managers, xfa.host, xfa.layout, xfa.event): initialize and calculate on open, exit, validate and calculate after a field changes, click for buttons (through js_run and the app), with rows added and removed, subforms shown and hidden, and the form laid out again, all as one undo step and kept in the file. Hostile scripts are bounded per event (merged and capped effects, messages and console lines, lower loop limits and a time budget on open and calculate, one datasets rewrite per event, print/save/links/focus from clicks only, a runaway script abandoned and the form's scripts turned off, which the app says in a toast and on the notice bar, with a button to the JavaScript console, and doc_info reports as xfa_scripts_off) and what open-time scripts changed is noted. Missing: loops inside nested calls are only stopped by abandoning their thread, and script memory is not capped; the change event per keystroke; xfa.template, connections and data descriptions; a hidden instance of a repeating subform hides them all; choosing a new picture for an image field; barcodes are drawn as their text, not bars; keep rules; metrics are approximate and Acrobat fidelity is eyeballed on real forms, not measured (#60)"
|
||||
notes = "laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters, breaks, repeated header rows, captions, rich text, JPEG/PNG/GIF images) into real pages and AcroForm fields (text, password, date with AFDate formats, check boxes, radio groups, drop-downs and list boxes with shown/saved item pairs and multi-select, signature fields, image fields showing their picture (pictures decoded once per document, at most 20 million pixels, and reused across re-layouts), buttons with reset/print/save/URL), filled from the datasets packet (including rows another viewer added), saved as an incremental revision with the values merged into the datasets so Adobe's viewers show them; doc_info reports xfa_layout. XFA JavaScript and FormCalc (form.xfa-formcalc) run in the js crate's XFA object model (xfa.form, SOM resolution, rawValue, presence, access, instance managers, xfa.host, xfa.layout, xfa.event): initialize and calculate on open, exit, validate and calculate after a field changes, click for buttons (through js_run and the app), with rows added and removed, subforms shown and hidden, and the form laid out again, all as one undo step and kept in the file. All the scripts of one event run on one thread, each in a fresh context on the form as the engine holds it then (nothing shared between scripts; the live form kept up to date in place for values, rebuilt only when the structure changed), so hundreds of calculations run within the open budget. Hostile scripts are bounded per event (merged and capped effects, messages and console lines, lower loop limits and a time budget on open and calculate, one datasets rewrite per event, print/save/links/focus from clicks only, a runaway script abandoned and the form's scripts turned off, which the app says in a toast and on the notice bar, with a button to the JavaScript console, and doc_info reports as xfa_scripts_off) and what open-time scripts changed is noted. Missing: loops inside nested calls are only stopped by abandoning their thread, and script memory is not capped; the change event per keystroke; xfa.template, connections and data descriptions; a hidden instance of a repeating subform hides them all; choosing a new picture for an image field; barcodes are drawn as their text, not bars; keep rules; metrics are approximate and Acrobat fidelity is eyeballed on real forms, not measured (#60)"
|
||||
|
||||
[[feature]]
|
||||
id = "form.xfa-formcalc"
|
||||
|
|
|
|||
Loading…
Reference in New Issue