mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-10 13:01:29 +00:00
Merge #4791: M12: a dynamic XFA form runs all its opening scripts and is laid out once, so a save no longer keeps a half-initialized form (#4790)
# Conflicts: # crates/engine/src/xfa.rs # crates/forms/src/tests.rs # crates/xfa/src/data.rs # crates/xfa/src/lib.rs # crates/xfa/src/tests.rs # parity/acrobat-features.toml
This commit is contained in:
commit
c1d9dd1db1
21 files changed
+874
-105
No files matched your search
@@ -8139,6 +8139,119 @@ fn the_xfa_open_budget_stops_the_calculations_it_has_no_time_for() {
|
||||
assert_eq!(pdfcraft_js::xfa_session::engines_started(), engines, "no script ran, so no engine started");
|
||||
}
|
||||
|
||||
/// Opening lays the form out once after its initialize scripts, not after each one that
|
||||
/// hides a subform (XFA 3.3 pp. 388, 390: initialize fires once data binding is complete;
|
||||
/// the layout process has not run yet). Laying it out after every script spent the opening's
|
||||
/// time on the first few, and a form whose initialize scripts hide its optional pages was
|
||||
/// saved half initialized: the next opening hid more of it, and reset the data typed into
|
||||
/// what it hid.
|
||||
#[test]
|
||||
fn xfa_initialize_scripts_that_hide_subforms_lay_the_form_out_once() {
|
||||
// Shaped like a form with optional extra pages: a check box, then more sections than one
|
||||
// event may lay the form out again for, each hiding itself while the box is clear.
|
||||
let sections = 60;
|
||||
let mut body = String::from(
|
||||
r#"<field name="wanted" w="0.3in" h="0.3in"><ui><checkButton/></ui><items><integer>1</integer><integer>0</integer></items><value><integer>0</integer></value></field>"#,
|
||||
);
|
||||
for n in 0..sections {
|
||||
body.push_str(&format!(
|
||||
r#"<subform name="extra{n}" layout="tb"><event activity="initialize"><script contentType="application/x-javascript">if (wanted.rawValue == 0) this.presence = "hidden";</script></event><field name="note" w="3in" h="0.3in"><ui><textEdit/></ui></field></subform>"#
|
||||
));
|
||||
}
|
||||
let tpl = 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>
|
||||
"#
|
||||
);
|
||||
let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&tpl));
|
||||
let mut s = Session::new().with_clock(|| 1_700_000_000).with_xfa_time_scale(100);
|
||||
let id = s.open("extras.pdf", None, bytes, None).expect("opens");
|
||||
let out = s.take_js_output(id);
|
||||
assert!(out.errors.is_empty(), "{:?}", out.errors);
|
||||
let names = |s: &Session, id| s.get(id).unwrap().form.iter().map(|f| f.name.clone()).collect::<Vec<_>>();
|
||||
let opened = names(&s, id);
|
||||
assert!(opened.iter().all(|n| !n.contains("note")), "every extra section is hidden on opening: {opened:?}");
|
||||
assert!(opened.iter().any(|n| n.contains("wanted")), "{opened:?}");
|
||||
// User input survives repeated saves and openings, with stable field and page counts
|
||||
// (strengthened by wireless89 in the review of #4791).
|
||||
let wanted = opened.iter().find(|n| n.contains("wanted")).unwrap().clone();
|
||||
s.apply(id, Edit::SetFieldValue { name: wanted.clone(), value: FieldValue::Check(true) }).unwrap();
|
||||
let pages = s.get(id).unwrap().info.pages.len();
|
||||
let mut saved = s.save_bytes(id).unwrap();
|
||||
for cycle in 0..3 {
|
||||
let again = s.open("again.pdf", None, saved, None).expect("reopens");
|
||||
let out = s.take_js_output(again);
|
||||
assert!(out.errors.is_empty(), "cycle {cycle}: {out:?}");
|
||||
assert_eq!(names(&s, again), opened, "cycle {cycle}: stable fields");
|
||||
let doc = s.get(again).unwrap();
|
||||
assert_eq!(doc.info.pages.len(), pages, "cycle {cycle}: stable pages");
|
||||
assert_eq!(doc.form.iter().find(|f| f.name == wanted).unwrap().value, ["1"], "cycle {cycle}: input survives");
|
||||
saved = s.save_bytes(again).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// A script that resets several subforms one after another (`xfa.host.resetData` once for
|
||||
/// each, as forms with optional pages do) resets every one of them, and nothing else; resets
|
||||
/// in a row are applied together.
|
||||
#[test]
|
||||
fn xfa_resets_in_a_row_reset_every_named_subform_and_nothing_else() {
|
||||
let sections = 5;
|
||||
let mut body = String::from(
|
||||
r#"<field name="wanted" w="0.3in" h="0.3in"><ui><checkButton/></ui><items><integer>1</integer><integer>0</integer></items><value><integer>0</integer></value>"#,
|
||||
);
|
||||
let resets: String = (0..sections).map(|n| format!("xfa.host.resetData('form.extra{n}');")).collect();
|
||||
body.push_str(&format!(
|
||||
r#"<event activity="initialize"><script contentType="application/x-javascript">{resets} keep.rawValue = "kept";</script></event></field>"#
|
||||
));
|
||||
body.push_str(r#"<field name="keep" w="3in" h="0.3in"><ui><textEdit/></ui></field>"#);
|
||||
// A field no script touches: the resets must leave what was typed in it.
|
||||
body.push_str(r#"<field name="other" w="3in" h="0.3in"><ui><textEdit/></ui><value><text>default</text></value></field>"#);
|
||||
for n in 0..sections {
|
||||
body.push_str(&format!(
|
||||
r#"<subform name="extra{n}" layout="tb"><field name="note" w="3in" h="0.3in"><ui><textEdit/></ui><value><text>default</text></value></field></subform>"#
|
||||
));
|
||||
}
|
||||
let notes: String = (0..sections).map(|n| format!("<extra{n}><note>typed {n}</note></extra{n}>")).collect();
|
||||
let tpl = 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>
|
||||
<xfa:datasets xmlns:xfa="http://www.xfa.org/schema/xfa-data/1.0/"><xfa:data><form><keep>typed</keep><other>typed other</other>{notes}</form></xfa:data></xfa:datasets>
|
||||
</xdp:xdp>
|
||||
"#
|
||||
);
|
||||
let mut s = Session::new().with_clock(|| 1_700_000_000).with_xfa_time_scale(100);
|
||||
// Without the resets the sections show what was typed.
|
||||
let untouched = Arc::new(pdfcraft_xfa::fixtures::shell(&tpl.replace(&resets, "")));
|
||||
let plain = s.open("plain.pdf", None, untouched, None).expect("opens");
|
||||
let typed: Vec<_> = s.get(plain).unwrap().form.iter().filter(|f| f.name.contains("note")).map(|f| f.value.clone()).collect();
|
||||
assert_eq!(typed, (0..sections).map(|n| vec![format!("typed {n}")]).collect::<Vec<_>>());
|
||||
let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&tpl));
|
||||
let id = s.open("resets.pdf", None, bytes, None).expect("opens");
|
||||
let out = s.take_js_output(id);
|
||||
assert!(out.errors.is_empty(), "{:?}", out.errors);
|
||||
let form = &s.get(id).unwrap().form;
|
||||
let notes: Vec<_> = form.iter().filter(|f| f.name.contains("note")).map(|f| f.value.clone()).collect();
|
||||
assert_eq!(notes.len(), sections, "{form:?}");
|
||||
assert!(notes.iter().all(|v| v == &vec!["default".to_string()]), "every section is reset: {notes:?}");
|
||||
let keep = form.iter().find(|f| f.name.contains("keep")).map(|f| f.value.clone());
|
||||
assert_eq!(keep, Some(vec!["kept".to_string()]), "the script's later write stands");
|
||||
let other = form.iter().find(|f| f.name.contains("other")).map(|f| f.value.clone());
|
||||
assert_eq!(other, Some(vec!["typed other".to_string()]), "a field outside the named subforms keeps its value");
|
||||
}
|
||||
|
||||
/// The scripts of one event share the form as the engine holds it: a failed script's
|
||||
/// half-done writes reach nobody, and a script reads the saved value a choice list was given
|
||||
/// by its shown text.
|
||||
@@ -12341,6 +12454,179 @@ fn image_transform_engine_refuses_singular_matrices_without_mutation() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A reset in an opening script reaches a field of a row an earlier opening script added, before
|
||||
/// the form is laid out again with the row's widgets: its data goes back to its default and stays
|
||||
/// so when the form is saved.
|
||||
#[test]
|
||||
fn an_opening_reset_reaches_a_row_added_while_opening() {
|
||||
let tpl = pdfcraft_xfa::fixtures::scripted_template().replace(
|
||||
"if (qty.rawValue === null) qty.rawValue = 2;",
|
||||
r#"if (qty.rawValue === null) qty.rawValue = 2; table._row.addInstance(1);"#,
|
||||
)
|
||||
.replace(
|
||||
r#"<field name="grand" w="2in" h="0.3in" access="readOnly">"#,
|
||||
r#"<field name="grand" w="2in" h="0.3in" access="readOnly"><event activity="initialize"><script contentType="application/x-javascript">xfa.resolveNode("form.page1.table.row[1].item").rawValue = "X"; xfa.host.resetData("form.page1.table.row[1].item");</script></event>"#,
|
||||
);
|
||||
let mut s = Session::new().with_clock(|| 1_700_000_000);
|
||||
let id = s.open("rows.pdf", None, Arc::new(pdfcraft_xfa::fixtures::shell(&tpl)), None).unwrap();
|
||||
let out = s.take_js_output(id);
|
||||
assert!(out.errors.is_empty(), "{:?}", out.errors);
|
||||
let cos = pdfcraft_cos::Document::open(s.save_bytes(id).unwrap()).unwrap();
|
||||
let data = pdfcraft_xfa::data_of(&cos).unwrap();
|
||||
assert_eq!(data.count(&pdfcraft_xfa::som_to_path("form[0].page1[0].table[0]"), "row"), 2, "the row was added");
|
||||
assert_eq!(data.text_at(&pdfcraft_xfa::som_to_path("form[0].page1[0].table[0].row[1].item[0]")).unwrap_or(""), "", "reset in the data");
|
||||
}
|
||||
|
||||
/// A reset in an opening script reaches an exclusion group of a row an earlier opening script
|
||||
/// added, before the row has widgets: the group's data goes back to the on value of its default
|
||||
/// button, and stays so when the form is saved.
|
||||
#[test]
|
||||
fn an_opening_reset_reaches_an_exclusion_group_of_a_row_added_while_opening() {
|
||||
let group = r#"<exclGroup name="pick" w="1in" h="0.3in"><field name="a" w="0.5in" h="0.3in"><ui><checkButton/></ui><items><text>A</text></items><value><text>A</text></value></field><field name="b" w="0.5in" h="0.3in"><ui><checkButton/></ui><items><text>B</text></items></field></exclGroup>"#;
|
||||
let tpl = pdfcraft_xfa::fixtures::scripted_template()
|
||||
.replace(
|
||||
"if (qty.rawValue === null) qty.rawValue = 2;",
|
||||
r#"if (qty.rawValue === null) qty.rawValue = 2; table._row.addInstance(1);"#,
|
||||
)
|
||||
.replace(r#"<field name="amount" h="0.3in"><ui><numericEdit/></ui></field>"#, &format!(r#"<field name="amount" h="0.3in"><ui><numericEdit/></ui></field>{group}"#))
|
||||
.replace(
|
||||
r#"<field name="grand" w="2in" h="0.3in" access="readOnly">"#,
|
||||
r#"<field name="grand" w="2in" h="0.3in" access="readOnly"><event activity="initialize"><script contentType="application/x-javascript">xfa.resolveNode("form.page1.table.row[1].pick").rawValue = "B"; xfa.host.resetData("form.page1.table.row[1].pick");</script></event>"#,
|
||||
);
|
||||
let mut s = Session::new().with_clock(|| 1_700_000_000);
|
||||
let id = s.open("rows.pdf", None, Arc::new(pdfcraft_xfa::fixtures::shell(&tpl)), None).unwrap();
|
||||
let out = s.take_js_output(id);
|
||||
assert!(out.errors.is_empty(), "{:?}", out.errors);
|
||||
let cos = pdfcraft_cos::Document::open(s.save_bytes(id).unwrap()).unwrap();
|
||||
let data = pdfcraft_xfa::data_of(&cos).unwrap();
|
||||
assert_eq!(data.count(&pdfcraft_xfa::som_to_path("form[0].page1[0].table[0]"), "row"), 2, "the row was added");
|
||||
assert_eq!(data.text_at(&pdfcraft_xfa::som_to_path("form[0].page1[0].table[0].row[1].pick[0]")).unwrap_or(""), "A", "group reset in the data");
|
||||
}
|
||||
|
||||
/// A reset in an opening script clears a multi-select list of a hidden subform (no widget)
|
||||
/// whose picks the data holds as `value` elements: its empty default leaves no pick in the
|
||||
/// saved data, and the rest of the data stays as it was.
|
||||
#[test]
|
||||
fn an_opening_reset_clears_structured_picks_of_a_field_without_a_widget() {
|
||||
let tpl = pdfcraft_xfa::fixtures::scripted_template()
|
||||
.replace(
|
||||
r#"<field name="note" w="4in" h="0.3in">"#,
|
||||
r#"<field name="tags" w="2in" h="1in"><ui><choiceList open="multiSelect"/></ui><items><text>a</text><text>b</text><text>c</text></items></field><field name="note" w="4in" h="0.3in">"#,
|
||||
)
|
||||
.replace(
|
||||
"if (qty.rawValue === null) qty.rawValue = 2;",
|
||||
r#"if (qty.rawValue === null) qty.rawValue = 2; xfa.host.resetData("form.page1.details.tags");"#,
|
||||
)
|
||||
.replace(
|
||||
"</xdp:xdp>",
|
||||
r#"<xfa:datasets xmlns:xfa="http://www.xfa.org/schema/xfa-data/1.0/"><xfa:data><form><page1><qty>3</qty><details><tags><value>a</value><value>c</value></tags><note>kept</note></details></page1></form></xfa:data></xfa:datasets>
|
||||
</xdp:xdp>"#,
|
||||
);
|
||||
let mut s = Session::new().with_clock(|| 1_700_000_000);
|
||||
let id = s.open("tags.pdf", None, Arc::new(pdfcraft_xfa::fixtures::shell(&tpl)), None).unwrap();
|
||||
let out = s.take_js_output(id);
|
||||
assert!(out.errors.is_empty(), "{:?}", out.errors);
|
||||
let cos = pdfcraft_cos::Document::open(s.save_bytes(id).unwrap()).unwrap();
|
||||
let data = pdfcraft_xfa::data_of(&cos).unwrap();
|
||||
assert_eq!(data.value_at(&pdfcraft_xfa::som_to_path("form[0].page1[0].details[0].tags[0]")).unwrap_or_default(), "", "picks reset in the data");
|
||||
assert_eq!(data.text_at(&pdfcraft_xfa::som_to_path("form[0].page1[0].details[0].note[0]")), Some("kept"));
|
||||
assert_eq!(data.text_at(&pdfcraft_xfa::som_to_path("form[0].page1[0].qty[0]")), Some("3"));
|
||||
}
|
||||
|
||||
/// A later opening script resets a section shown by an earlier one, including its datasets
|
||||
/// value: the shown field and the saved form hold the template default, not the pre-reset
|
||||
/// value, through three save/reopen cycles. Test by wireless89 (review of #4791, 2026-10-27).
|
||||
#[test]
|
||||
fn xfa_opening_reset_reaches_a_section_shown_by_an_earlier_script() {
|
||||
let tpl = 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>
|
||||
<field name="show" w="1in" h="0.3in"><ui><textEdit/></ui>
|
||||
<event activity="initialize"><script contentType="application/x-javascript">extra.presence = "visible";</script></event>
|
||||
</field>
|
||||
<field name="reset" w="1in" h="0.3in"><ui><textEdit/></ui>
|
||||
<event activity="initialize"><script contentType="application/x-javascript">xfa.host.resetData("form.extra");</script></event>
|
||||
</field>
|
||||
<subform name="extra" layout="tb" presence="hidden">
|
||||
<field name="note" w="3in" h="0.3in"><ui><textEdit/></ui><value><text>default</text></value></field>
|
||||
</subform>
|
||||
<field name="keep" w="1in" h="0.3in"><ui><textEdit/></ui></field>
|
||||
</subform>
|
||||
</template>
|
||||
<xfa:datasets xmlns:xfa="http://www.xfa.org/schema/xfa-data/1.0/"><xfa:data><form><extra><note>previous input</note></extra><keep>unrelated input</keep></form></xfa:data></xfa:datasets>
|
||||
</xdp:xdp>"#;
|
||||
let mut s = Session::new().with_clock(|| 1_700_000_000).with_xfa_time_scale(100);
|
||||
let mut bytes = Arc::new(pdfcraft_xfa::fixtures::shell(tpl));
|
||||
for opening in 0..3 {
|
||||
let id = s.open("reset.pdf", None, bytes, None).unwrap();
|
||||
let out = s.take_js_output(id);
|
||||
assert!(out.errors.is_empty(), "opening {opening}: {out:?}");
|
||||
let doc = s.get(id).unwrap();
|
||||
let note = doc.form.iter().find(|f| f.name == "note").expect("shown section has a widget");
|
||||
let shown = note.value.clone();
|
||||
assert_eq!(doc.form.iter().find(|f| f.name == "keep").unwrap().value, ["unrelated input"]);
|
||||
assert_eq!(doc.info.pages.len(), 1);
|
||||
bytes = s.save_bytes(id).unwrap();
|
||||
let cos = pdfcraft_cos::Document::open(bytes.clone()).unwrap();
|
||||
let data = pdfcraft_xfa::data_of(&cos).unwrap();
|
||||
let saved = data.text_at(&pdfcraft_xfa::som_to_path("form[0].extra[0].note[0]"));
|
||||
assert_eq!((shown, saved), (vec!["default".to_string()], Some("default")), "opening {opening}: displayed and saved values reset");
|
||||
}
|
||||
}
|
||||
|
||||
/// A reset of a choice list in a section an earlier opening script showed (so it has no widget
|
||||
/// yet when the reset runs) puts the template default back in the shown widget, the open
|
||||
/// document's datasets and the saved datasets alike, through three openings: a multi-select
|
||||
/// with structured `<value>` picks and an empty or set default, and a single choice whose
|
||||
/// default is a display label saved as its export value. Test by wireless89 (review of #4791,
|
||||
/// 2026-10-30).
|
||||
#[test]
|
||||
fn xfa_deferred_choice_resets_preserve_the_reset_value_through_reopen() {
|
||||
let cases = [
|
||||
("multi_empty", r#"<choiceList open="multiSelect"/>"#, "", "<value>A</value><value>B</value>", ""),
|
||||
("multi_default", r#"<choiceList open="multiSelect"/>"#, "A", "<value>B</value>", "A"),
|
||||
("choice_export", "<choiceList/>", "Alpha", "B", "A"),
|
||||
];
|
||||
let mut failures = Vec::new();
|
||||
for (case, ui, default, initial, expected) in cases {
|
||||
let xml = format!(
|
||||
r#"<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"><contentArea w="540pt" h="700pt"/><medium short="612pt" long="792pt"/></pageArea></pageSet>
|
||||
<field name="show" w="30pt" h="20pt"><ui><textEdit/></ui><event activity="initialize"><script contentType="application/x-javascript">extra.presence = "visible";</script></event></field>
|
||||
<field name="reset" w="30pt" h="20pt"><ui><textEdit/></ui><event activity="initialize"><script contentType="application/x-javascript">xfa.host.resetData("form.extra");</script></event></field>
|
||||
<subform name="extra" presence="hidden" layout="tb"><field name="choice" w="120pt" h="40pt"><ui>{ui}</ui>
|
||||
<items><text>Alpha</text><text>Beta</text></items><items save="1" presence="hidden"><text>A</text><text>B</text></items><value><text>{default}</text></value></field></subform>
|
||||
<field name="keep" w="100pt" h="20pt"><ui><textEdit/></ui></field>
|
||||
</subform></template><xfa:datasets xmlns:xfa="http://www.xfa.org/schema/xfa-data/1.0/"><xfa:data><form><extra><choice>{initial}</choice></extra><keep>KEEP</keep></form></xfa:data></xfa:datasets></xdp:xdp>"#
|
||||
);
|
||||
let mut s = Session::new().with_clock(|| 1_700_000_000).with_xfa_time_scale(100);
|
||||
let mut bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&xml));
|
||||
for cycle in 0..3 {
|
||||
let id = s.open("choices.pdf", None, bytes, None).unwrap();
|
||||
assert!(s.take_js_output(id).errors.is_empty(), "{case}, cycle {cycle}: reset script must execute");
|
||||
let doc = s.get(id).unwrap();
|
||||
let shown = doc.form.iter().find(|f| f.name == "choice").expect("shown choice has a widget").value.clone();
|
||||
assert_eq!(doc.form.iter().find(|f| f.name == "keep").unwrap().value, ["KEEP"]);
|
||||
let opened_data = pdfcraft_xfa::data_of(&doc.editor.as_ref().unwrap().cos).unwrap();
|
||||
let path = pdfcraft_xfa::som_to_path("form[0].extra[0].choice[0]");
|
||||
let opened = opened_data.value_at(&path).unwrap_or_default();
|
||||
bytes = s.save_bytes(id).unwrap();
|
||||
let cos = pdfcraft_cos::Document::open(bytes.clone()).unwrap();
|
||||
let data = pdfcraft_xfa::data_of(&cos).unwrap();
|
||||
let stored = data.value_at(&path).unwrap_or_default();
|
||||
assert_eq!(data.text_at(&pdfcraft_xfa::som_to_path("form[0].keep[0]")), Some("KEEP"));
|
||||
let expected_shown: Vec<String> = if expected.is_empty() { Vec::new() } else { vec![expected.into()] };
|
||||
if shown != expected_shown || opened != expected || stored != expected {
|
||||
failures.push(format!("{case}, cycle {cycle}: shown={shown:?}, opened={opened:?}, stored={stored:?}, expected={expected:?}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(failures.is_empty(), "{}", failures.join("\n"));
|
||||
}
|
||||
|
||||
/// Optimize has JBIG2 images share a symbol dictionary only where redaction can store each again
|
||||
/// without it. Of four pages of the same scan, the two in DeviceGray and the image mask share one;
|
||||
/// the one in a DeviceN of one colourant, which redaction doesn't store again, is coded alone.
|
||||
|
||||
+227
-25
@@ -161,6 +161,41 @@ fn to_js(n: &FormNode) -> XfaNode {
|
||||
}
|
||||
}
|
||||
|
||||
/// The names of a run of `resetData` calls, applied together: each name once, in the order
|
||||
/// first given. A call without names resets the whole form, which takes in every other.
|
||||
#[derive(Default)]
|
||||
struct ResetRun {
|
||||
names: Vec<String>,
|
||||
/// `names` as a set, so adding a name costs the same however many there are (a script
|
||||
/// may pass hundreds of thousands).
|
||||
seen: HashSet<String>,
|
||||
whole: bool,
|
||||
}
|
||||
|
||||
impl ResetRun {
|
||||
fn add(&mut self, names: &[String]) {
|
||||
if self.whole {
|
||||
return;
|
||||
}
|
||||
if names.is_empty() {
|
||||
self.whole = true;
|
||||
self.names.clear();
|
||||
self.seen.clear();
|
||||
return;
|
||||
}
|
||||
for n in names {
|
||||
if self.seen.insert(n.clone()) {
|
||||
self.names.push(n.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The names to reset; none for the whole form.
|
||||
fn names(&self) -> &[String] {
|
||||
if self.whole { &[] } else { &self.names }
|
||||
}
|
||||
}
|
||||
|
||||
/// The SOM paths of the live form's fields under `n`, at most 64 levels deep, into `out`.
|
||||
fn field_soms(n: &FormNode, out: &mut HashSet<String>, depth: usize) {
|
||||
if depth > 64 {
|
||||
@@ -215,15 +250,52 @@ fn plain_som(som: &str) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
/// Does `name` from a script (`qty`, `page1.qty`, `xfa.form.form.page1.qty`, or a container
|
||||
/// such as `table`) name this SOM or one of its ancestors?
|
||||
fn som_matches(som: &str, name: &str) -> bool {
|
||||
let plain = plain_som(som);
|
||||
let n = plain_som(name.trim());
|
||||
let n = n.strip_prefix("xfa.form.").or_else(|| n.strip_prefix("$form.")).unwrap_or(&n);
|
||||
let want: Vec<&str> = n.split('.').filter(|s| !s.is_empty()).collect();
|
||||
let have: Vec<&str> = plain.split('.').filter(|s| !s.is_empty()).collect();
|
||||
!want.is_empty() && have.windows(want.len()).any(|w| w == want.as_slice())
|
||||
/// The names a script passed to a reset (`qty`, `page1.qty`, `xfa.form.form.page1.qty`, or a
|
||||
/// container such as `table`), indexed, so matching an object against them costs the same
|
||||
/// however many names there are.
|
||||
struct ResetTargets {
|
||||
/// Each name's steps without indexes, joined by `.`.
|
||||
wants: HashSet<String>,
|
||||
/// The most steps of a name.
|
||||
longest: usize,
|
||||
}
|
||||
|
||||
impl ResetTargets {
|
||||
fn new(names: &[String]) -> Self {
|
||||
let mut wants = HashSet::new();
|
||||
let mut longest = 0;
|
||||
for name in names {
|
||||
let n = plain_som(name.trim());
|
||||
let n = n.strip_prefix("xfa.form.").or_else(|| n.strip_prefix("$form.")).unwrap_or(&n);
|
||||
let steps: Vec<&str> = n.split('.').filter(|s| !s.is_empty()).collect();
|
||||
if !steps.is_empty() {
|
||||
longest = longest.max(steps.len());
|
||||
wants.insert(steps.join("."));
|
||||
}
|
||||
}
|
||||
ResetTargets { wants, longest }
|
||||
}
|
||||
|
||||
/// Does a name name this SOM or one of its ancestors: is it a run of the SOM's steps? The
|
||||
/// runs are looked up, at most [`Self::longest`] steps each, so the work depends on the
|
||||
/// SOM's depth and not on how many names there are.
|
||||
fn matches(&self, som: &str) -> bool {
|
||||
let plain = plain_som(som);
|
||||
let have: Vec<&str> = plain.split('.').filter(|s| !s.is_empty()).collect();
|
||||
for start in 0..have.len() {
|
||||
let mut run = String::new();
|
||||
for (k, step) in have.iter().skip(start).take(self.longest).enumerate() {
|
||||
if k > 0 {
|
||||
run.push('.');
|
||||
}
|
||||
run.push_str(step);
|
||||
if self.wants.contains(&run) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// A generated field as the runner tracks it: kind, value and on state.
|
||||
@@ -273,6 +345,11 @@ struct Runner<'a, 'b> {
|
||||
skipped: usize,
|
||||
relayouts: usize,
|
||||
relayouts_skipped: bool,
|
||||
/// Lay the form out once, when the event ends, rather than after every script that changed
|
||||
/// its structure (opening: see [`on_open`]).
|
||||
defer_relayout: bool,
|
||||
/// A deferred layout is owed.
|
||||
relayout_owed: bool,
|
||||
dropped_output: usize,
|
||||
truncated_warned: bool,
|
||||
data: Option<pdfcraft_xfa::DataNode>,
|
||||
@@ -287,6 +364,9 @@ 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>,
|
||||
/// `cached` as the scripts get it, made once and sent to each script until the live form
|
||||
/// changes in any way (a value too): the script engine reuses what it built from it.
|
||||
js_root: Option<Arc<XfaNode>>,
|
||||
/// 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).
|
||||
@@ -316,6 +396,8 @@ impl<'a, 'b> Runner<'a, 'b> {
|
||||
skipped: 0,
|
||||
relayouts: 0,
|
||||
relayouts_skipped: false,
|
||||
defer_relayout: false,
|
||||
relayout_owed: false,
|
||||
dropped_output: 0,
|
||||
truncated_warned: false,
|
||||
data,
|
||||
@@ -327,6 +409,7 @@ impl<'a, 'b> Runner<'a, 'b> {
|
||||
by_path: None,
|
||||
fields: None,
|
||||
cached: None,
|
||||
js_root: None,
|
||||
session: None,
|
||||
changes: Changes::default(),
|
||||
numbered: HashSet::new(),
|
||||
@@ -338,6 +421,7 @@ impl<'a, 'b> Runner<'a, 'b> {
|
||||
/// script.
|
||||
fn invalidate(&mut self) {
|
||||
self.cached = None;
|
||||
self.js_root = None;
|
||||
}
|
||||
|
||||
/// The script engine for this event.
|
||||
@@ -354,6 +438,8 @@ impl<'a, 'b> Runner<'a, 'b> {
|
||||
let op = pdfcraft_xfa::DataOp::Value { path, text };
|
||||
self.data.get_or_insert_with(Default::default).apply(&op);
|
||||
self.pending.push(op);
|
||||
// The value changes in the live form below: the scripts' copy is made again.
|
||||
self.js_root = None;
|
||||
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,
|
||||
@@ -568,11 +654,37 @@ impl<'a, 'b> Runner<'a, 'b> {
|
||||
fn reset(&mut self, names: &[String]) -> Result<(), String> {
|
||||
self.flush()?;
|
||||
let by_som = self.by_som().clone();
|
||||
let listed: Option<Vec<String>> = if names.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(by_som.iter().filter(|(som, _)| names.iter().any(|n| som_matches(som, n))).map(|(_, f)| f.clone()).collect())
|
||||
let targets = (!names.is_empty()).then(|| ResetTargets::new(names));
|
||||
let wanted = |som: &str| targets.as_ref().is_none_or(|t| t.matches(som));
|
||||
let listed: Option<Vec<String>> = targets.as_ref().map(|_| by_som.iter().filter(|(som, _)| wanted(som)).map(|(_, f)| f.clone()).collect());
|
||||
// Fields and exclusion groups of the live form with no widget yet (a row an opening
|
||||
// script added, before the form is laid out again): their data goes back to the
|
||||
// template's default too, or the value would stay and be laid out and saved.
|
||||
let unshown: Vec<String> = {
|
||||
let live = self.live();
|
||||
let mut out = Vec::new();
|
||||
let mut stack: Vec<&pdfcraft_xfa::FormNode> = vec![&live.root];
|
||||
while let Some(n) = stack.pop() {
|
||||
if matches!(n.kind, Some(pdfcraft_xfa::NodeKind::Field | pdfcraft_xfa::NodeKind::ExclGroup))
|
||||
&& !by_som.contains_key(&n.som)
|
||||
&& wanted(&n.som)
|
||||
{
|
||||
out.push(n.som.clone());
|
||||
}
|
||||
stack.extend(n.children.iter());
|
||||
}
|
||||
out
|
||||
};
|
||||
for som in unshown {
|
||||
// A floating field rich text embeds goes back to its default with every row that
|
||||
// holds it (below).
|
||||
if pdfcraft_xfa::field_is_embedded(self.tpl, &som) {
|
||||
continue;
|
||||
}
|
||||
let Some(text) = pdfcraft_xfa::reset_default(self.tpl, &som) else { continue };
|
||||
let path = pdfcraft_xfa::data_path(self.tpl, &som);
|
||||
self.reset_data(path, text);
|
||||
}
|
||||
// Names no widget answers to may still name a floating field (below).
|
||||
let widgets = !listed.as_ref().is_some_and(Vec::is_empty);
|
||||
if widgets {
|
||||
@@ -580,6 +692,7 @@ impl<'a, 'b> Runner<'a, 'b> {
|
||||
}
|
||||
self.fields = None;
|
||||
let infos = self.fields().clone();
|
||||
let listed: Option<HashSet<&String>> = listed.as_ref().map(|l| l.iter().collect());
|
||||
for (som, name) in by_som.iter().filter(|_| widgets) {
|
||||
if listed.as_ref().is_some_and(|l| !l.contains(name)) {
|
||||
continue;
|
||||
@@ -589,17 +702,12 @@ impl<'a, 'b> Runner<'a, 'b> {
|
||||
continue;
|
||||
}
|
||||
let path = pdfcraft_xfa::data_path(self.tpl, som);
|
||||
let text = data_text(info);
|
||||
if self.data.as_ref().and_then(|d| d.text_at(&path)).unwrap_or("") != text {
|
||||
self.embedded_changed |= pdfcraft_xfa::path_is_embedded(self.tpl, &path);
|
||||
self.data_op(pdfcraft_xfa::DataOp::Value { path, text });
|
||||
self.changes.values += 1;
|
||||
}
|
||||
self.reset_data(path, data_text(info));
|
||||
}
|
||||
// A floating field without a widget (a hidden one, as forms place them) goes back to
|
||||
// its default too, and the text that embeds it shows that.
|
||||
for (som, default) in pdfcraft_xfa::embedded_fields(self.tpl) {
|
||||
if by_som.contains_key(&som) || (!names.is_empty() && !names.iter().any(|n| som_matches(&som, n))) {
|
||||
if by_som.contains_key(&som) || !wanted(&som) {
|
||||
continue;
|
||||
}
|
||||
// Every row that holds it, not only the first.
|
||||
@@ -617,6 +725,16 @@ impl<'a, 'b> Runner<'a, 'b> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Put a reset's `text` in the data at `path`, unless it already holds it: a multi-select
|
||||
/// list's picks (`value` elements) are compared as its lines (the write replaces them).
|
||||
fn reset_data(&mut self, path: pdfcraft_xfa::DataPath, text: String) {
|
||||
if self.data.as_ref().and_then(|d| d.value_at(&path)).unwrap_or_default() != text {
|
||||
self.embedded_changed |= pdfcraft_xfa::path_is_embedded(self.tpl, &path);
|
||||
self.data_op(pdfcraft_xfa::DataOp::Value { path, text });
|
||||
self.changes.values += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply what a script of `ev` did. Returns whether the form has to be laid out again.
|
||||
fn apply_effects(&mut self, ev: &ScriptEvent, effects: &[XfaEffect]) -> Result<bool, String> {
|
||||
let mut relayout = false;
|
||||
@@ -635,7 +753,9 @@ impl<'a, 'b> Runner<'a, 'b> {
|
||||
}
|
||||
}
|
||||
}
|
||||
for e in effects {
|
||||
// Resets in a row, applied together when the run of them ends.
|
||||
let mut pending_reset: Option<ResetRun> = None;
|
||||
for (at, e) in effects.iter().enumerate() {
|
||||
// Only a click script may print, save, open links or move the focus (not the
|
||||
// calculations a click sets off, nor anything run on open or on a change).
|
||||
if let Some(what) = click_only(e)
|
||||
@@ -686,7 +806,15 @@ impl<'a, 'b> Runner<'a, 'b> {
|
||||
relayout = true;
|
||||
}
|
||||
XfaEffect::MessageBox(m) => self.alert(m.clone()),
|
||||
XfaEffect::ResetData(names) => self.reset(names)?,
|
||||
XfaEffect::ResetData(names) => {
|
||||
// A script resetting several subforms in a row (`resetData` once for each)
|
||||
// resets them together: the same fields end up at their defaults.
|
||||
pending_reset.get_or_insert_with(ResetRun::default).add(names);
|
||||
let next_is_reset = effects.get(at.saturating_add(1)).is_some_and(|e| matches!(e, XfaEffect::ResetData(_)));
|
||||
if !next_is_reset && let Some(run) = pending_reset.take() {
|
||||
self.reset(run.names())?;
|
||||
}
|
||||
}
|
||||
XfaEffect::Print => self.run.out.requests.push(Request::Print),
|
||||
XfaEffect::SaveAs => self.run.out.requests.push(Request::SaveAs),
|
||||
XfaEffect::LaunchUrl(u) => self.run.out.requests.push(Request::LaunchUrl(u.clone())),
|
||||
@@ -722,7 +850,14 @@ impl<'a, 'b> Runner<'a, 'b> {
|
||||
#[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 root = to_js(&self.live().root);
|
||||
let root = match &self.js_root {
|
||||
Some(r) => Arc::clone(r),
|
||||
None => {
|
||||
let r = Arc::new(to_js(&self.live().root));
|
||||
self.js_root = Some(Arc::clone(&r));
|
||||
r
|
||||
}
|
||||
};
|
||||
let script = Script {
|
||||
source: ev.script.clone(),
|
||||
formcalc: ev.formcalc,
|
||||
@@ -749,7 +884,11 @@ impl<'a, 'b> Runner<'a, 'b> {
|
||||
self.error(format!("{} ({}): {e}", ev.som, ev.activity));
|
||||
}
|
||||
if self.apply_effects(ev, &o.effects)? {
|
||||
self.relayout()?;
|
||||
if self.defer_relayout {
|
||||
self.relayout_owed = true;
|
||||
} else {
|
||||
self.relayout()?;
|
||||
}
|
||||
}
|
||||
Ok(Some(o))
|
||||
}
|
||||
@@ -779,9 +918,10 @@ impl<'a, 'b> Runner<'a, 'b> {
|
||||
fn finish(mut self) -> Result<Changes, String> {
|
||||
#[cfg(test)]
|
||||
self.check_live();
|
||||
if self.relayouts_skipped || self.embedded_changed {
|
||||
if self.relayouts_skipped || self.relayout_owed || self.embedded_changed {
|
||||
self.relayouts = 0;
|
||||
self.relayouts_skipped = false;
|
||||
self.relayout_owed = false;
|
||||
self.relayout()?;
|
||||
}
|
||||
self.flush()?;
|
||||
@@ -949,6 +1089,12 @@ fn value_unchanged(doc: &pdfcraft_cos::Document, f: &pdfcraft_forms::Field, v: O
|
||||
/// dropped (one value would put the same number on every page).
|
||||
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);
|
||||
// The scripts read the live form (template, data and overrides), not the pages, so the
|
||||
// pages are laid out once, after the last of them. Laying a large form out after every
|
||||
// script that hides a subform spent the time limit on the first few, and the form was kept
|
||||
// half initialized: a page its initialize scripts hide stayed open to typing until the
|
||||
// next opening hid it and reset its data.
|
||||
r.defer_relayout = true;
|
||||
let events: Vec<ScriptEvent> = r.live().events.iter().filter(|e| e.activity == "initialize" || e.activity == "docReady").cloned().collect();
|
||||
for ev in &events {
|
||||
r.run(ev, "")?;
|
||||
@@ -1144,14 +1290,70 @@ pub(crate) fn mark_script_buttons(doc: &pdfcraft_cos::Document, fields: &mut [pd
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A run of resets keeps each name once, in the order first given, and a reset of the whole
|
||||
/// form takes in the rest, before or after it.
|
||||
#[test]
|
||||
fn resets_in_a_row_keep_each_name_once_and_a_whole_form_reset_wins() {
|
||||
let v = |xs: &[&str]| xs.iter().map(|x| x.to_string()).collect::<Vec<_>>();
|
||||
let mut run = ResetRun::default();
|
||||
run.add(&v(&["a", "b", "a"]));
|
||||
run.add(&v(&["c", "b"]));
|
||||
assert_eq!(run.names(), v(&["a", "b", "c"]).as_slice());
|
||||
run.add(&[]);
|
||||
assert!(run.names().is_empty());
|
||||
run.add(&v(&["d"]));
|
||||
assert!(run.names().is_empty(), "still the whole form");
|
||||
let mut first = ResetRun::default();
|
||||
first.add(&[]);
|
||||
first.add(&v(&["a"]));
|
||||
assert!(first.names().is_empty());
|
||||
}
|
||||
|
||||
/// Adding a name to a run costs the same however many it holds: two runs of 60,000
|
||||
/// distinct names merge at once (comparing each name with every kept one is 3.6 billion
|
||||
/// comparisons).
|
||||
#[test]
|
||||
fn a_long_run_of_reset_names_merges_in_linear_time() {
|
||||
let a: Vec<String> = (0..60_000).map(|i| format!("form1[0].a{i}")).collect();
|
||||
let b: Vec<String> = (0..60_000).map(|i| format!("form1[0].b{i}")).collect();
|
||||
let started = std::time::Instant::now();
|
||||
let mut run = ResetRun::default();
|
||||
run.add(&a);
|
||||
run.add(&b);
|
||||
assert_eq!(run.names().len(), 120_000);
|
||||
assert!(started.elapsed() < std::time::Duration::from_secs(2), "took {:?}", started.elapsed());
|
||||
}
|
||||
|
||||
/// Matching objects against a reset's names costs the same however many names there are:
|
||||
/// 5,000 objects against 50,000 names that miss them all is 250 million comparisons when
|
||||
/// each object is compared with every name.
|
||||
#[test]
|
||||
fn matching_many_objects_against_many_reset_names_is_bounded() {
|
||||
let names: Vec<String> = (0..50_000).map(|i| format!("form1.page1.other{i}")).collect();
|
||||
let started = std::time::Instant::now();
|
||||
let targets = ResetTargets::new(&names);
|
||||
let hits = (0..5_000).filter(|i| targets.matches(&format!("form1[0].page1[0].table[0].row[{i}].item[0]"))).count();
|
||||
assert_eq!(hits, 0);
|
||||
assert!(targets.matches("form1[0].page1[0].other49999[0]"));
|
||||
assert!(started.elapsed() < std::time::Duration::from_secs(2), "took {:?}", started.elapsed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_names_match_indexed_som_paths() {
|
||||
let som_matches = |som: &str, name: &str| ResetTargets::new(&[name.to_string()]).matches(som);
|
||||
assert!(som_matches("form[0].page1[0].qty[0]", "qty"));
|
||||
assert!(som_matches("form[0].page1[0].qty[0]", "page1.qty"));
|
||||
assert!(som_matches("form[0].page1[0].qty[0]", "xfa.form.form.page1.qty"));
|
||||
assert!(som_matches("form[0].page1[0].qty[0]", "$form.form.page1.qty"));
|
||||
assert!(som_matches("form[0].page1[0].table[0].row[2].amount[0]", "table"));
|
||||
assert!(som_matches("form[0].page1[0].table[0].row[2].amount[0]", "table.row[0].amount"));
|
||||
assert!(!som_matches("form[0].page1[0].qty[0]", "qt"));
|
||||
assert!(!som_matches("form[0].page1[0].price[0]", "qty"));
|
||||
assert!(!som_matches("form[0].page1[0].qty[0]", "page1.page1.qty"));
|
||||
assert!(!som_matches("form[0].page1[0].qty[0]", " . "));
|
||||
assert!(!ResetTargets::new(&[]).matches("form[0].qty[0]"));
|
||||
let many = ResetTargets::new(&["price".into(), "page1.qty".into()]);
|
||||
assert!(many.matches("form[0].page1[0].qty[0]") && many.matches("form[0].page2[0].price[0]"));
|
||||
assert_eq!(
|
||||
split_instance(&Template::default(), "form[0].table[0].row[2]").map(|(p, ps, n, i)| (p.len(), ps, n, i)),
|
||||
Some((2, "form[0].table[0]".into(), "row".into(), 2))
|
||||
|
||||
@@ -6572,6 +6572,26 @@ fn a_read_only_button_is_not_turned_on_by_number() {
|
||||
assert_eq!(on_widgets(&doc, "r").0, [false, false, true]);
|
||||
}
|
||||
|
||||
/// A reset of named fields costs the same per field however many are named: resetting 60,000
|
||||
/// fields by name is not 3.6 billion name comparisons.
|
||||
#[test]
|
||||
fn resetting_many_named_fields_is_not_quadratic() {
|
||||
const N: usize = 60_000;
|
||||
let kids: String = (0..N).map(|i| format!("{} 0 R ", i + 4)).collect();
|
||||
let mut objs: Vec<String> = vec![
|
||||
"<< /Type /Catalog /Pages 2 0 R /AcroForm 3 0 R >>".into(),
|
||||
"<< /Type /Pages /Kids [] /Count 0 >>".into(),
|
||||
format!("<< /Fields [{kids}] >>"),
|
||||
];
|
||||
objs.extend((0..N).map(|i| format!("<< /FT /Tx /T (f{i}) /V (a) /DV (a) >>")));
|
||||
let mut doc = document(&objs);
|
||||
let names: Vec<String> = (0..N).map(|i| format!("f{i}")).collect();
|
||||
let started = std::time::Instant::now();
|
||||
assert_eq!(reset(&mut doc, Some(&names)).unwrap(), 0);
|
||||
assert!(started.elapsed() < std::time::Duration::from_secs(5), "took {:?}", started.elapsed());
|
||||
assert!(matches!(reset(&mut doc, Some(&["f0".to_string(), "nope".to_string()])), Err(FormError::NoSuchField(n)) if n == "nope"));
|
||||
}
|
||||
|
||||
/// A `/MK /BC` or `/BG` written `[1 0 0 null]` is CMYK with the null as 0, as annotation `/C`
|
||||
/// colours are read (PDFBOX-2699), not RGB from the three numbers left; three numbers stay RGB.
|
||||
#[test]
|
||||
|
||||
@@ -758,13 +758,16 @@ pub fn reset(doc: &mut Document, names: Option<&[String]>) -> Result<usize, Form
|
||||
if all.is_empty() {
|
||||
return Err(FormError::NoForm);
|
||||
}
|
||||
// Names looked up through sets, so a long list of names costs no more per field.
|
||||
let have: std::collections::HashSet<&str> = names.map(|_| all.iter().map(|f| f.name.as_str()).collect()).unwrap_or_default();
|
||||
if let Some(n) = names
|
||||
&& let Some(missing) = n.iter().find(|n| !all.iter().any(|f| &f.name == *n))
|
||||
&& let Some(missing) = n.iter().find(|n| !have.contains(n.as_str()))
|
||||
{
|
||||
return Err(FormError::NoSuchField(missing.clone()));
|
||||
}
|
||||
let named: Option<std::collections::HashSet<&str>> = names.map(|n| n.iter().map(String::as_str).collect());
|
||||
let mut changed = 0;
|
||||
for f in all.iter().filter(|f| names.is_none_or(|n| n.contains(&f.name))) {
|
||||
for f in all.iter().filter(|f| named.as_ref().is_none_or(|n| n.contains(f.name.as_str()))) {
|
||||
// A default the reading didn't decode (its budget spent) is unknown, not empty: clearing
|
||||
// the field to nothing would lose the value it should go back to. A value it didn't decode
|
||||
// isn't known to be the default, so it is reset.
|
||||
|
||||
@@ -109,7 +109,7 @@ type R<T> = Result<T, String>;
|
||||
/// Run a FormCalc `script` for `event` on the form `root`, as [`crate::xfa::run_xfa`] runs a
|
||||
/// JavaScript one.
|
||||
pub fn run_formcalc(script: &str, event: &XfaEvent, doc: &XfaDoc, root: &XfaNode, limits: Limits) -> XfaOutcome {
|
||||
run_formcalc_at(script, event, doc, root.clone(), limits, None)
|
||||
run_formcalc_at(script, event, doc, std::sync::Arc::new(root.clone()), false, limits, None)
|
||||
}
|
||||
|
||||
/// [`run_formcalc`] on an owned form, giving up at `deadline` (the clock is read every
|
||||
@@ -118,7 +118,8 @@ pub(crate) fn run_formcalc_at(
|
||||
script: &str,
|
||||
event: &XfaEvent,
|
||||
doc: &XfaDoc,
|
||||
root: XfaNode,
|
||||
root: std::sync::Arc<XfaNode>,
|
||||
reuse: bool,
|
||||
limits: Limits,
|
||||
deadline: Option<std::time::Duration>,
|
||||
) -> XfaOutcome {
|
||||
@@ -133,7 +134,7 @@ pub(crate) fn run_formcalc_at(
|
||||
Ok(b) => b,
|
||||
Err(e) => return XfaOutcome { error: Some(e), ..Default::default() },
|
||||
};
|
||||
let mut host = XHost::new(root, &event.target, doc);
|
||||
let mut host = XHost::new(root, reuse, &event.target, doc);
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let _ = deadline;
|
||||
let mut interp = Interp {
|
||||
@@ -177,7 +178,7 @@ pub fn run_formcalc_within(script: &str, event: &XfaEvent, doc: &XfaDoc, root: X
|
||||
// FormCalc has no engine to start: its time (and its own deadline) begins at once.
|
||||
run_within("pdfcraft-formcalc", timeout, move |started| {
|
||||
started();
|
||||
run_formcalc_at(&script, &event, &doc, root, limits, Some(timeout))
|
||||
run_formcalc_at(&script, &event, &doc, std::sync::Arc::new(root), false, limits, Some(timeout))
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -313,16 +313,16 @@ impl Interp<'_> {
|
||||
let Some(n) = self.node(i) else { return Ok(Val::Null) };
|
||||
Ok(match name {
|
||||
"formattedValue" => match &n.formatted {
|
||||
Some(f) => Val::Str(f.clone()),
|
||||
Some(f) => Val::Str(f.to_string()),
|
||||
None => self.node_value(i),
|
||||
},
|
||||
"rawValue" | "value" => self.node_value(i),
|
||||
"name" => Val::Str(n.name.clone()),
|
||||
"somExpression" => Val::Str(n.som.clone()),
|
||||
"name" => Val::Str(n.name.to_string()),
|
||||
"somExpression" => Val::Str(n.som.to_string()),
|
||||
"className" => Val::Str(n.kind.class_name().into()),
|
||||
"index" | "instanceIndex" => Val::Num(n.index as f64),
|
||||
"presence" => Val::Str(n.presence.clone()),
|
||||
"access" => Val::Str(n.access.clone()),
|
||||
"presence" => Val::Str(n.presence.to_string()),
|
||||
"access" => Val::Str(n.access.to_string()),
|
||||
"isNull" => Val::Num(if n.value.is_empty() { 1.0 } else { 0.0 }),
|
||||
"mandatory" => Val::Str("disabled".into()),
|
||||
"parent" => n.parent.map_or(Val::Null, Val::Node),
|
||||
@@ -332,7 +332,7 @@ impl Interp<'_> {
|
||||
None => Val::Node(i),
|
||||
},
|
||||
"instanceManager" => match n.parent {
|
||||
Some(p) => Val::Im(p, n.name.clone()),
|
||||
Some(p) => Val::Im(p, n.name.to_string()),
|
||||
None => Val::Null,
|
||||
},
|
||||
"border" | "ui" | "font" | "caption" | "fillColor" | "borderColor" | "fontColor" | "margin" | "para" | "assist" | "validate"
|
||||
|
||||
@@ -277,11 +277,11 @@ impl Interp<'_> {
|
||||
Some(e) => {
|
||||
let raw = self.eval(e)?;
|
||||
match self.nodes_of(&raw).first() {
|
||||
Some(i) => self.node(*i).map(|n| n.som.clone()).unwrap_or_default(),
|
||||
Some(i) => self.node(*i).map(|n| n.som.to_string()).unwrap_or_default(),
|
||||
None => {
|
||||
let s = self.to_str(&raw);
|
||||
let found = resolve_som(self.h, self.h.current, &s);
|
||||
found.first().and_then(|i| self.node(*i)).map(|n| n.som.clone()).unwrap_or(s)
|
||||
found.first().and_then(|i| self.node(*i)).map(|n| n.som.to_string()).unwrap_or(s)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -404,7 +404,7 @@ impl Interp<'_> {
|
||||
match target {
|
||||
Val::Node(i) => {
|
||||
let Some(n) = self.node(i) else { return Ok(()) };
|
||||
let som = n.som.clone();
|
||||
let som = n.som.to_string();
|
||||
match prop {
|
||||
"rawValue" | "value" | "formattedValue" => {
|
||||
let text = self.to_str(&v);
|
||||
|
||||
+45
-3
@@ -1632,7 +1632,8 @@ $host.gotoURL("https://example.org")"#,
|
||||
"for i = 1 upto 100000 do Eval(\"1\") endfor",
|
||||
&ev,
|
||||
&doc,
|
||||
form(),
|
||||
std::sync::Arc::new(form()),
|
||||
false,
|
||||
Limits { loop_iterations: u64::MAX, recursion: 32 },
|
||||
Some(std::time::Duration::ZERO),
|
||||
);
|
||||
@@ -1750,12 +1751,53 @@ mod session {
|
||||
source: source.into(),
|
||||
formcalc,
|
||||
event: XfaEvent { activity: activity.into(), target: target.into(), ..Default::default() },
|
||||
root: form(),
|
||||
root: std::sync::Arc::new(form()),
|
||||
limits: Limits::default(),
|
||||
timeout: std::time::Duration::from_secs(3),
|
||||
}
|
||||
}
|
||||
|
||||
/// The scripts of an event given the same form start from one flattened copy of it: the
|
||||
/// second host shares the first one's text, and each host changes only its own copy.
|
||||
#[test]
|
||||
fn hosts_given_the_same_form_reuse_its_flattened_copy_and_change_only_their_own() {
|
||||
let root = std::sync::Arc::new(form());
|
||||
let doc = XfaDoc::default();
|
||||
let mut first = crate::xfa::XHost::new(std::sync::Arc::clone(&root), true, "form1[0].page1[0].qty[0]", &doc);
|
||||
let second = crate::xfa::XHost::new(std::sync::Arc::clone(&root), true, "form1[0].page1[0].qty[0]", &doc);
|
||||
let shared = std::sync::Arc::clone(&first.nodes.get(1).expect("form1").som);
|
||||
assert!(std::sync::Arc::ptr_eq(&shared, &second.nodes.get(1).expect("form1").som), "the second host starts from the first one's copy");
|
||||
let qty = first.current;
|
||||
let before = second.nodes.get(qty).map(|n| n.value.clone());
|
||||
first.set_state(qty, XfaEffect::SetPresence { som: "form1[0].page1[0].qty[0]".into(), presence: "hidden".into() });
|
||||
if let Some(n) = first.nodes.get_mut(qty) {
|
||||
n.value = "99".into();
|
||||
}
|
||||
let third = crate::xfa::XHost::new(std::sync::Arc::clone(&root), true, "form1[0].page1[0].qty[0]", &doc);
|
||||
for h in [&second, &third] {
|
||||
assert_eq!(h.nodes.get(qty).map(|n| n.value.clone()), before);
|
||||
assert_eq!(h.nodes.get(qty).map(|n| n.presence.to_string()).as_deref(), Some("visible"));
|
||||
}
|
||||
// Another form (a new `Arc`, even with the same content) is flattened again.
|
||||
let other = crate::xfa::XHost::new(std::sync::Arc::new(form()), true, "form1[0].page1[0].qty[0]", &doc);
|
||||
assert!(!std::sync::Arc::ptr_eq(&other.nodes.get(1).expect("form1").som, &shared));
|
||||
}
|
||||
|
||||
/// One session given the same form for two scripts: what the first changed on its copy is
|
||||
/// not in the second's.
|
||||
#[test]
|
||||
fn a_form_sent_again_does_not_carry_the_last_scripts_changes() {
|
||||
let mut s = XfaSession::new(XfaDoc::default());
|
||||
let root = std::sync::Arc::new(form());
|
||||
let mut a = script("qty.rawValue = 10; details.presence = 'visible'; qty.rawValue", false, "form1[0].page1[0].total[0]", "calculate");
|
||||
a.root = std::sync::Arc::clone(&root);
|
||||
assert_eq!(s.run(a).result.as_deref(), Some("10"));
|
||||
let mut b = script("qty.rawValue + ':' + details.presence", false, "form1[0].page1[0].total[0]", "calculate");
|
||||
b.root = std::sync::Arc::clone(&root);
|
||||
let o = s.run(b);
|
||||
assert_eq!(o.result.as_deref(), Some("4:hidden"), "{o:?}");
|
||||
}
|
||||
|
||||
/// JavaScript and FormCalc scripts run in turn in one session, each on the form the caller
|
||||
/// sends: nothing an earlier script changed or defined leaks into the next, a failing script
|
||||
/// doesn't end the session, and hundreds of scripts run on its one thread.
|
||||
@@ -1773,7 +1815,7 @@ mod session {
|
||||
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();
|
||||
std::sync::Arc::make_mut(&mut 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"));
|
||||
|
||||
@@ -24,6 +24,8 @@ mod model;
|
||||
mod nodes;
|
||||
mod run;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub(crate) use host::forget_base;
|
||||
pub(crate) use host::{HNode, XHost};
|
||||
pub use model::{MAX_ALERTS, MAX_CONSOLE, MAX_EFFECTS, XfaDoc, XfaEffect, XfaEvent, XfaKind, XfaNode, XfaOutcome};
|
||||
pub(crate) use model::{MAX_LINE, MAX_PAYLOAD, clip, clip_result, reset_names};
|
||||
|
||||
@@ -88,13 +88,13 @@ fn set_focus(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsVal
|
||||
let som = match target.as_object() {
|
||||
Some(_) if node_index(&target, ctx).is_ok() => {
|
||||
let i = node_index(&target, ctx)?;
|
||||
host(ctx)?.borrow().nodes.get(i).map(|n| n.som.clone()).unwrap_or_default()
|
||||
host(ctx)?.borrow().nodes.get(i).map(|n| n.som.to_string()).unwrap_or_default()
|
||||
}
|
||||
_ => {
|
||||
let expr = text(&target, ctx)?;
|
||||
let h = host(ctx)?.borrow();
|
||||
let found = resolve_som(&h, h.current, &expr);
|
||||
found.first().and_then(|i| h.nodes.get(*i)).map(|n| n.som.clone()).unwrap_or(expr)
|
||||
found.first().and_then(|i| h.nodes.get(*i)).map(|n| n.som.to_string()).unwrap_or(expr)
|
||||
}
|
||||
};
|
||||
effect(ctx, XfaEffect::SetFocus(som))?;
|
||||
@@ -356,7 +356,7 @@ pub(super) fn install(ctx: &mut Context, event: &XfaEvent, doc: &XfaDoc, current
|
||||
let h = host(ctx)?.borrow();
|
||||
h.nodes
|
||||
.get(scope)
|
||||
.map(|n| n.children.iter().filter_map(|&c| h.nodes.get(c).map(|k| (c, k.name.clone(), k.repeatable && !k.gone))).collect())
|
||||
.map(|n| n.children.iter().filter_map(|&c| h.nodes.get(c).map(|k| (c, k.name.to_string(), k.repeatable && !k.gone))).collect())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
for (c, name, repeatable) in kids {
|
||||
|
||||
+80
-22
@@ -2,27 +2,71 @@ use super::model::{
|
||||
MAX_ALERTS, MAX_CONSOLE, MAX_DEPTH, MAX_EFFECTS, MAX_NODES, MAX_PAYLOAD, MAX_VALUE, XfaDoc, XfaEffect, XfaKind, XfaNode, clip, clip_value,
|
||||
effect_key, payload,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::{Context, HashMap, JsResult, Message, RefCell, error};
|
||||
|
||||
/// One node of the flattened form, shared by the JavaScript and FormCalc engines.
|
||||
/// One node of the flattened form, shared by the JavaScript and FormCalc engines. Its text is
|
||||
/// shared (`Arc<str>`), so the copy each script starts from costs little: the scripts of one
|
||||
/// event start from the same flattened form until it changes (see [`XHost::new`]).
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct HNode {
|
||||
pub(crate) name: String,
|
||||
pub(crate) som: String,
|
||||
pub(crate) name: Arc<str>,
|
||||
pub(crate) som: Arc<str>,
|
||||
pub(crate) kind: XfaKind,
|
||||
pub(crate) value: String,
|
||||
pub(crate) numeric: bool,
|
||||
pub(crate) presence: String,
|
||||
pub(crate) access: String,
|
||||
pub(crate) presence: Arc<str>,
|
||||
pub(crate) access: Arc<str>,
|
||||
pub(crate) repeatable: bool,
|
||||
pub(crate) occur_min: usize,
|
||||
pub(crate) occur_max: Option<usize>,
|
||||
pub(crate) index: usize,
|
||||
pub(crate) parent: Option<usize>,
|
||||
pub(crate) children: Vec<usize>,
|
||||
pub(crate) children: Arc<[usize]>,
|
||||
/// Removed by `removeInstance`: skipped everywhere.
|
||||
pub(crate) gone: bool,
|
||||
/// See [`XfaNode::formatted`]; dropped when a script sets the value, which it no longer shows.
|
||||
pub(crate) formatted: Option<String>,
|
||||
pub(crate) formatted: Option<Arc<str>>,
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
/// The form the last script on this thread started from, flattened: the next script of the
|
||||
/// event starts from a copy of it while the engine sends the same form.
|
||||
static BASE: std::cell::RefCell<Option<(Arc<XfaNode>, Vec<HNode>)>> = const { std::cell::RefCell::new(None) };
|
||||
}
|
||||
|
||||
/// Let go of the form the scripts on this thread started from.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub(crate) fn forget_base() {
|
||||
BASE.with(|b| b.borrow_mut().take());
|
||||
}
|
||||
|
||||
/// `root` flattened under a synthetic `form` node (index 0).
|
||||
fn flattened(root: &XfaNode) -> Vec<HNode> {
|
||||
let mut nodes = vec![HNode {
|
||||
name: "form".into(),
|
||||
som: "".into(),
|
||||
kind: XfaKind::Form,
|
||||
value: String::new(),
|
||||
numeric: false,
|
||||
presence: "visible".into(),
|
||||
access: "open".into(),
|
||||
repeatable: false,
|
||||
occur_min: 0,
|
||||
occur_max: None,
|
||||
index: 0,
|
||||
parent: None,
|
||||
children: Arc::from([]),
|
||||
gone: false,
|
||||
formatted: None,
|
||||
}];
|
||||
if let Some(ci) = flatten(root, Some(0), &mut nodes, 1)
|
||||
&& let Some(f) = nodes.get_mut(0)
|
||||
{
|
||||
f.children = Arc::from([ci]);
|
||||
}
|
||||
nodes
|
||||
}
|
||||
|
||||
/// The form as a script sees and changes it, with the effects it records.
|
||||
@@ -53,11 +97,25 @@ pub(crate) struct XHost {
|
||||
impl XHost {
|
||||
/// The host for a script on `root` (the template's root subform), with `this` at the node
|
||||
/// named by `target`. `xfa.form` is a synthetic root whose child is the root subform.
|
||||
pub(crate) fn new(root: XfaNode, target: &str, doc: &XfaDoc) -> XHost {
|
||||
let form = XfaNode { name: "form".into(), kind: Some(XfaKind::Form), children: vec![root], ..Default::default() };
|
||||
let mut nodes = Vec::new();
|
||||
flatten(&form, None, &mut nodes, 0);
|
||||
let current = nodes.iter().position(|n| n.som == target && n.kind != XfaKind::Form).unwrap_or(1.min(nodes.len().saturating_sub(1)));
|
||||
/// With `reuse`, the flattened form is kept on this thread for the next script given the
|
||||
/// same `root` (the scripts of one event, [`crate::xfa_session`]).
|
||||
pub(crate) fn new(root: Arc<XfaNode>, reuse: bool, target: &str, doc: &XfaDoc) -> XHost {
|
||||
let nodes = if !reuse {
|
||||
flattened(&root)
|
||||
} else {
|
||||
BASE.with(|b| {
|
||||
let mut b = b.borrow_mut();
|
||||
match b.as_ref() {
|
||||
Some((r, nodes)) if Arc::ptr_eq(r, &root) => nodes.clone(),
|
||||
_ => {
|
||||
let nodes = flattened(&root);
|
||||
*b = Some((root, nodes.clone()));
|
||||
nodes
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
let current = nodes.iter().position(|n| &*n.som == target && n.kind != XfaKind::Form).unwrap_or(1.min(nodes.len().saturating_sub(1)));
|
||||
XHost {
|
||||
nodes,
|
||||
effects: Vec::new(),
|
||||
@@ -122,8 +180,8 @@ impl XHost {
|
||||
}
|
||||
if let Some(n) = self.nodes.get_mut(i) {
|
||||
match &e {
|
||||
XfaEffect::SetPresence { presence, .. } => n.presence = presence.clone(),
|
||||
XfaEffect::SetAccess { access, .. } => n.access = access.clone(),
|
||||
XfaEffect::SetPresence { presence, .. } => n.presence = presence.as_str().into(),
|
||||
XfaEffect::SetAccess { access, .. } => n.access = access.as_str().into(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -220,7 +278,7 @@ impl XHost {
|
||||
if n.gone || !matches!(n.kind, XfaKind::Field | XfaKind::ExclGroup) {
|
||||
return false;
|
||||
}
|
||||
let som = n.som.clone();
|
||||
let som = n.som.to_string();
|
||||
// Cut here, not only in the effect: later reads in the script see what is saved, and
|
||||
// the snapshot stays bounded however often a long value is written.
|
||||
let (text, cut) = clip_value(text);
|
||||
@@ -257,21 +315,21 @@ fn flatten(n: &XfaNode, parent: Option<usize>, out: &mut Vec<HNode>, depth: usiz
|
||||
}
|
||||
let idx = out.len();
|
||||
out.push(HNode {
|
||||
name: n.name.clone(),
|
||||
som: n.som.clone(),
|
||||
name: n.name.as_str().into(),
|
||||
som: n.som.as_str().into(),
|
||||
kind: n.kind.unwrap_or(XfaKind::Subform),
|
||||
value: n.value.clone(),
|
||||
numeric: n.numeric,
|
||||
presence: if n.presence.is_empty() { "visible".into() } else { n.presence.clone() },
|
||||
access: if n.access.is_empty() { "open".into() } else { n.access.clone() },
|
||||
presence: if n.presence.is_empty() { "visible".into() } else { n.presence.as_str().into() },
|
||||
access: if n.access.is_empty() { "open".into() } else { n.access.as_str().into() },
|
||||
repeatable: n.repeatable,
|
||||
occur_min: n.occur_min,
|
||||
occur_max: n.occur_max,
|
||||
index: n.index,
|
||||
parent,
|
||||
children: Vec::new(),
|
||||
children: Arc::from([]),
|
||||
gone: false,
|
||||
formatted: n.formatted.clone(),
|
||||
formatted: n.formatted.as_deref().map(Arc::from),
|
||||
});
|
||||
let mut kids = Vec::new();
|
||||
for c in &n.children {
|
||||
@@ -280,7 +338,7 @@ fn flatten(n: &XfaNode, parent: Option<usize>, out: &mut Vec<HNode>, depth: usiz
|
||||
}
|
||||
}
|
||||
if let Some(h) = out.get_mut(idx) {
|
||||
h.children = kids;
|
||||
h.children = kids.into();
|
||||
}
|
||||
Some(idx)
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::host::{HNode, XHost, host};
|
||||
use super::model::{MAX_ADDED, MAX_DEPTH, MAX_NODES, XfaEffect};
|
||||
use super::nodes::{instances, node_object};
|
||||
@@ -112,14 +114,14 @@ fn renumber(h: &mut XHost, idx: usize, depth: usize) {
|
||||
}
|
||||
let (parent_som, name, index, kids) = {
|
||||
let Some(n) = h.nodes.get(idx) else { return };
|
||||
let parent_som = n.parent.and_then(|p| h.nodes.get(p)).map(|p| p.som.clone()).unwrap_or_default();
|
||||
let parent_som = n.parent.and_then(|p| h.nodes.get(p)).map(|p| p.som.to_string()).unwrap_or_default();
|
||||
(parent_som, n.name.clone(), n.index, n.children.clone())
|
||||
};
|
||||
let som = child_som(parent_som, &name, index);
|
||||
if let Some(n) = h.nodes.get_mut(idx) {
|
||||
n.som = som;
|
||||
n.som = som.into();
|
||||
}
|
||||
for k in kids {
|
||||
for k in kids.iter().copied() {
|
||||
renumber(h, k, depth + 1);
|
||||
}
|
||||
}
|
||||
@@ -145,7 +147,7 @@ fn clone_subtree(h: &mut XHost, src: usize, parent: Option<usize>, depth: usize)
|
||||
(
|
||||
HNode {
|
||||
name: n.name.clone(),
|
||||
som: String::new(),
|
||||
som: "".into(),
|
||||
kind: n.kind,
|
||||
value: String::new(),
|
||||
numeric: n.numeric,
|
||||
@@ -156,7 +158,7 @@ fn clone_subtree(h: &mut XHost, src: usize, parent: Option<usize>, depth: usize)
|
||||
occur_max: n.occur_max,
|
||||
index: n.index,
|
||||
parent,
|
||||
children: Vec::new(),
|
||||
children: Arc::from([]),
|
||||
gone: false,
|
||||
// The copy holds no value, so nothing is shown in its picture either.
|
||||
formatted: None,
|
||||
@@ -167,7 +169,7 @@ fn clone_subtree(h: &mut XHost, src: usize, parent: Option<usize>, depth: usize)
|
||||
let idx = h.nodes.len();
|
||||
h.nodes.push(template);
|
||||
let mut copied = Vec::new();
|
||||
for k in kids {
|
||||
for k in kids.iter().copied() {
|
||||
// Nested repeating subforms copy their first instance only.
|
||||
let skip = h.nodes.get(k).is_some_and(|c| c.gone || c.repeatable && c.index > 0);
|
||||
if skip {
|
||||
@@ -178,7 +180,7 @@ fn clone_subtree(h: &mut XHost, src: usize, parent: Option<usize>, depth: usize)
|
||||
}
|
||||
}
|
||||
if let Some(n) = h.nodes.get_mut(idx) {
|
||||
n.children = copied;
|
||||
n.children = copied.into();
|
||||
}
|
||||
Some(idx)
|
||||
}
|
||||
@@ -206,8 +208,8 @@ impl XHost {
|
||||
return None;
|
||||
}
|
||||
// The copy's SOM, as `renumber` will give it, so its effect is reserved before the copy.
|
||||
let name = h.nodes.get(first).map(|n| n.name.clone()).unwrap_or_default();
|
||||
let parent_som = h.nodes.get(parent).map(|p| p.som.clone()).unwrap_or_default();
|
||||
let name = h.nodes.get(first).map(|n| n.name.to_string()).unwrap_or_default();
|
||||
let parent_som = h.nodes.get(parent).map(|p| p.som.to_string()).unwrap_or_default();
|
||||
let som = child_som(parent_som, &name, live.len());
|
||||
if !h.reserve(&XfaEffect::AddInstance { som: String::new() }, som.len()) {
|
||||
return None;
|
||||
@@ -219,8 +221,10 @@ impl XHost {
|
||||
}
|
||||
// Keep instances together: right after the last one.
|
||||
if let Some(p) = h.nodes.get_mut(parent) {
|
||||
let pos = p.children.iter().position(|&c| c == last).map_or(p.children.len(), |i| i + 1);
|
||||
p.children.insert(pos, new);
|
||||
let mut kids = p.children.to_vec();
|
||||
let pos = kids.iter().position(|&c| c == last).map_or(kids.len(), |i| i + 1);
|
||||
kids.insert(pos, new);
|
||||
p.children = kids.into();
|
||||
}
|
||||
renumber(h, new, 0);
|
||||
h.added += 1;
|
||||
@@ -260,7 +264,7 @@ impl XHost {
|
||||
if live.len() <= min {
|
||||
return;
|
||||
}
|
||||
let som = h.nodes.get(gone).map(|n| n.som.clone()).unwrap_or_default();
|
||||
let som = h.nodes.get(gone).map(|n| n.som.to_string()).unwrap_or_default();
|
||||
if !h.reserve(&XfaEffect::RemoveInstance { som: String::new() }, som.len()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -70,14 +70,14 @@ fn value_js(h: &HNode) -> JsValue {
|
||||
/// `(idx, name)` of the child of `parent` named `name`, first instance.
|
||||
pub(crate) fn child_named(h: &XHost, parent: usize, name: &str) -> Option<usize> {
|
||||
let p = h.nodes.get(parent)?;
|
||||
p.children.iter().copied().find(|&c| h.nodes.get(c).is_some_and(|n| !n.gone && n.name == name))
|
||||
p.children.iter().copied().find(|&c| h.nodes.get(c).is_some_and(|n| !n.gone && &*n.name == name))
|
||||
}
|
||||
|
||||
/// Every live instance named `name` under `parent`, in order.
|
||||
pub(crate) fn instances(h: &XHost, parent: usize, name: &str) -> Vec<usize> {
|
||||
h.nodes
|
||||
.get(parent)
|
||||
.map(|p| p.children.iter().copied().filter(|&c| h.nodes.get(c).is_some_and(|n| !n.gone && n.name == name)).collect())
|
||||
.map(|p| p.children.iter().copied().filter(|&c| h.nodes.get(c).is_some_and(|n| !n.gone && &*n.name == name)).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
@@ -179,11 +179,11 @@ pub(crate) fn descendants_named(h: &XHost, from: usize, name: &str, depth: usize
|
||||
return out;
|
||||
}
|
||||
let Some(n) = h.nodes.get(from) else { return out };
|
||||
for &c in &n.children {
|
||||
for &c in n.children.iter() {
|
||||
if let Some(k) = h.nodes.get(c)
|
||||
&& !k.gone
|
||||
{
|
||||
if k.name == name {
|
||||
if &*k.name == name {
|
||||
out.push(c);
|
||||
} else {
|
||||
out.extend(descendants_named(h, c, name, depth + 1));
|
||||
@@ -211,7 +211,7 @@ fn node_get(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValu
|
||||
Ok(match key.as_str() {
|
||||
IDX => JsValue::from(idx as f64),
|
||||
"rawValue" | "value" => host(ctx)?.borrow().nodes.get(idx).map_or(JsValue::null(), value_js),
|
||||
"formattedValue" => s(&host(ctx)?.borrow().nodes.get(idx).and_then(|n| n.formatted.clone()).unwrap_or(value)),
|
||||
"formattedValue" => s(&host(ctx)?.borrow().nodes.get(idx).and_then(|n| n.formatted.as_deref().map(str::to_string)).unwrap_or(value)),
|
||||
"name" => s(&name),
|
||||
"somExpression" => s(&som),
|
||||
"className" => s(kind.class_name()),
|
||||
@@ -289,7 +289,7 @@ fn node_set(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValu
|
||||
let value = if value.is_null() || value.is_undefined() { String::new() } else { text(&value, ctx)? };
|
||||
let mut h = host(ctx)?.borrow_mut();
|
||||
let Some(n) = h.nodes.get(idx) else { return Ok(JsValue::from(true)) };
|
||||
let som = n.som.clone();
|
||||
let som = n.som.to_string();
|
||||
match key.as_str() {
|
||||
"rawValue" | "value" | "formattedValue" => {
|
||||
// Not a field: the property doesn't exist, as in Acrobat, and nothing is recorded.
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::globals::install;
|
||||
use super::host::{Shared, XHost};
|
||||
use super::model::{XfaDoc, XfaEvent, XfaNode, XfaOutcome, clip_result};
|
||||
@@ -15,7 +17,7 @@ pub fn run_xfa(script: &str, event: &XfaEvent, doc: &XfaDoc, root: &XfaNode, lim
|
||||
let spawned = std::thread::Builder::new()
|
||||
.name("pdfcraft-xfa-js".into())
|
||||
.stack_size(crate::SCRIPT_STACK)
|
||||
.spawn_scoped(scope, || run_here(script, event, doc, root.clone(), limits, &|| {}));
|
||||
.spawn_scoped(scope, || run_here(script, event, doc, Arc::new(root.clone()), false, 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}")),
|
||||
@@ -23,7 +25,7 @@ pub fn run_xfa(script: &str, event: &XfaEvent, doc: &XfaDoc, root: &XfaNode, lim
|
||||
})
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
run_here(script, event, doc, root.clone(), limits, &|| {})
|
||||
run_here(script, event, doc, Arc::new(root.clone()), false, limits, &|| {})
|
||||
}
|
||||
|
||||
/// [`run_xfa`] with a time limit: a script still running `timeout` after the engine is ready
|
||||
@@ -36,7 +38,7 @@ pub fn run_xfa_within(script: &str, event: &XfaEvent, doc: &XfaDoc, root: XfaNod
|
||||
return XfaOutcome { error: Some(why), ..Default::default() };
|
||||
}
|
||||
let (script, event, doc) = (script.to_string(), event.clone(), doc.clone());
|
||||
run_within("pdfcraft-xfa-js", timeout, move |started| run_here(&script, &event, &doc, root, limits, started))
|
||||
run_within("pdfcraft-xfa-js", timeout, move |started| run_here(&script, &event, &doc, Arc::new(root), false, limits, started))
|
||||
}
|
||||
|
||||
/// Longest the script engine may take to be ready for a script (its thread and context, and
|
||||
@@ -125,7 +127,15 @@ pub(crate) fn warm_up() {
|
||||
}
|
||||
|
||||
/// `started` is called once the engine is ready: a time limit on the script runs from then.
|
||||
pub(crate) 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: Arc<XfaNode>,
|
||||
reuse: bool,
|
||||
limits: Limits,
|
||||
started: &dyn Fn(),
|
||||
) -> XfaOutcome {
|
||||
warm_up();
|
||||
let mut ctx = match Context::builder().host_hooks(std::rc::Rc::new(crate::CheckedCodeFromStrings)).build() {
|
||||
Ok(ctx) => ctx,
|
||||
@@ -133,7 +143,7 @@ pub(crate) fn run_here(script: &str, event: &XfaEvent, doc: &XfaDoc, root: XfaNo
|
||||
};
|
||||
ctx.runtime_limits_mut().set_loop_iteration_limit(limits.loop_iterations);
|
||||
ctx.runtime_limits_mut().set_recursion_limit(limits.recursion);
|
||||
let host_state = XHost::new(root, &event.target, doc);
|
||||
let host_state = XHost::new(root, reuse, &event.target, doc);
|
||||
let current = host_state.current;
|
||||
ctx.insert_data(RefCell::new(host_state));
|
||||
let mut out = XfaOutcome::default();
|
||||
|
||||
@@ -36,8 +36,10 @@ pub struct Script {
|
||||
pub source: String,
|
||||
pub formcalc: bool,
|
||||
pub event: XfaEvent,
|
||||
/// The form as the caller holds it now.
|
||||
pub root: XfaNode,
|
||||
/// The form as the caller holds it now. Send the same `Arc` while the form is unchanged:
|
||||
/// the next script then starts from the copy the last one was given, without flattening the
|
||||
/// form again.
|
||||
pub root: std::sync::Arc<XfaNode>,
|
||||
pub limits: Limits,
|
||||
pub timeout: std::time::Duration,
|
||||
}
|
||||
@@ -58,13 +60,13 @@ fn run_script(s: Script, doc: &XfaDoc, started: &dyn Fn()) -> XfaOutcome {
|
||||
if s.formcalc {
|
||||
// FormCalc has no engine to start: its time (and its own deadline) begins at once.
|
||||
started();
|
||||
return crate::formcalc::run_formcalc_at(&s.source, &s.event, doc, s.root, s.limits, Some(s.timeout));
|
||||
return crate::formcalc::run_formcalc_at(&s.source, &s.event, doc, s.root, true, s.limits, Some(s.timeout));
|
||||
}
|
||||
if let Some(why) = crate::refuse(&s.source) {
|
||||
started();
|
||||
return XfaOutcome { error: Some(why), ..Default::default() };
|
||||
}
|
||||
crate::xfa::run_here(&s.source, &s.event, doc, s.root, s.limits, started)
|
||||
crate::xfa::run_here(&s.source, &s.event, doc, s.root, true, s.limits, started)
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -236,6 +238,14 @@ mod native {
|
||||
run_script(script, &self.doc, &|| {})
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for XfaSession {
|
||||
/// The scripts ran on this thread: the form they started from is let go of with the
|
||||
/// event (on other targets it goes with the session's thread).
|
||||
fn drop(&mut self) {
|
||||
crate::xfa::forget_base();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub use native::XfaSession;
|
||||
+17
-3
@@ -35,7 +35,7 @@ pub(crate) use import::MAX_IMPORT_BYTES;
|
||||
pub use import::{DataImport, ImportNode, export_data, import_data, parse_data_file, write_data_image};
|
||||
pub use passwords::{clear_password_values, has_password_values};
|
||||
pub use path::{data_path, parse_datasets, som_to_path};
|
||||
pub(crate) use path::{fields_with_ids, template_field};
|
||||
pub(crate) use path::{fields_with_ids, template_field, template_object};
|
||||
pub use values::{FieldData, FieldDatum, is_on, name_text, radio_dataset_value, radio_state_for_item, read_values, read_values_with};
|
||||
#[cfg(test)]
|
||||
pub(crate) use write::ENSURED;
|
||||
@@ -112,11 +112,25 @@ impl DataNode {
|
||||
/// elements (one way a multi-select list's picks are stored), those on separate lines.
|
||||
pub fn value_at(&self, path: &[(String, usize)]) -> Option<String> {
|
||||
let n = self.get(path)?;
|
||||
if n.children.is_empty() || !n.children.iter().all(|c| c.name == "value") {
|
||||
if !n.holds_picks() {
|
||||
return Some(n.text.clone());
|
||||
}
|
||||
Some(n.children.iter().map(|c| c.text.trim()).filter(|t| !t.is_empty()).take(1000).collect::<Vec<_>>().join("\n"))
|
||||
Some(picks_of(n.children.iter().map(|c| c.text.as_str())).collect::<Vec<_>>().join("\n"))
|
||||
}
|
||||
|
||||
/// Whether this node holds a multi-select list's picks: children, all `value` elements.
|
||||
pub(crate) fn holds_picks(&self) -> bool {
|
||||
!self.children.is_empty() && self.children.iter().all(|c| c.name == "value")
|
||||
}
|
||||
}
|
||||
|
||||
/// Most picks a multi-select value is read or written with.
|
||||
pub(crate) const MAX_PICKS: usize = 1000;
|
||||
|
||||
/// The picks among `texts` (`value` elements, or the lines of a value): non-empty, trimmed, at
|
||||
/// most [`MAX_PICKS`].
|
||||
pub(crate) fn picks_of<'a>(texts: impl Iterator<Item = &'a str>) -> impl Iterator<Item = &'a str> {
|
||||
texts.map(str::trim).filter(|t| !t.is_empty()).take(MAX_PICKS)
|
||||
}
|
||||
|
||||
/// One change to the data, for [`write_data_ops`].
|
||||
|
||||
@@ -133,6 +133,16 @@ fn named_at<'a>(nodes: &'a [Node], name: &str, index: usize, depth: usize) -> Op
|
||||
/// as the form counts it ([`named_at`]). `None` when the template has no such field, or it is
|
||||
/// a button of an exclusion group (whose buttons take their value from the group).
|
||||
pub(crate) fn template_field<'a>(tpl: &'a Template, som: &str) -> Option<&'a Field> {
|
||||
match template_object(tpl, som)? {
|
||||
Node::Field(f) => Some(f),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The template object (field, exclusion group, subform...) the object at a SOM path is an
|
||||
/// instance of, found as [`template_field`] finds a field. `None` for the root and for a button
|
||||
/// of an exclusion group.
|
||||
pub(crate) fn template_object<'a>(tpl: &'a Template, som: &str) -> Option<&'a Node> {
|
||||
let path = som_to_path(som);
|
||||
let ((root, _), rest) = path.split_first()?;
|
||||
if tpl.root.common.name.as_deref() != Some(root.as_str()) {
|
||||
@@ -147,10 +157,7 @@ pub(crate) fn template_field<'a>(tpl: &'a Template, som: &str) -> Option<&'a Fie
|
||||
_ => return None,
|
||||
};
|
||||
}
|
||||
match named_at(children, last, *last_index, 0)? {
|
||||
Node::Field(f) => Some(f),
|
||||
_ => None,
|
||||
}
|
||||
named_at(children, last, *last_index, 0)
|
||||
}
|
||||
|
||||
/// Every field with an `id`, with the SOM path the layout gives it: the root subform's
|
||||
|
||||
@@ -106,7 +106,7 @@ pub use picture::typed_date;
|
||||
pub use script::{
|
||||
FormNode, LiveForm, MAX_FORM_NODES, NodeKind, OVERRIDES_KEY, Overrides, ScriptEvent, apply_overrides, data_of, embedded_fields,
|
||||
field_grows_with_text, field_is_embedded, fields_by_som, form_tree, has_focus_scripts, has_scripts, overrides, path_is_embedded, refresh_value,
|
||||
rerender, set_overrides, template_of,
|
||||
rerender, reset_default, set_overrides, template_of,
|
||||
};
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
|
||||
@@ -599,6 +599,32 @@ pub fn fields_by_som(doc: &Document) -> HashMap<String, String> {
|
||||
out
|
||||
}
|
||||
|
||||
/// What a reset gives the field or exclusion group at `som` in the data: its template default
|
||||
/// as saved (a choice list's item value, not its shown text, as [`Field::saved_value`] gives
|
||||
/// it), or nothing when it has none. A group's default is the on value of the first of its
|
||||
/// buttons whose own default is that on value. `None` for an object a reset restores no value
|
||||
/// of: not a field or group of the template, or a button, signature or picture field.
|
||||
pub fn reset_default(tpl: &Template, som: &str) -> Option<String> {
|
||||
match crate::data::template_object(tpl, som)? {
|
||||
Node::Field(f) => {
|
||||
if matches!(f.ui, crate::model::Ui::Button | crate::model::Ui::Signature | crate::model::Ui::ImageEdit) {
|
||||
return None;
|
||||
}
|
||||
Some(f.value.plain().map(|v| f.saved_value(v.trim())).unwrap_or_default())
|
||||
}
|
||||
Node::ExclGroup(g) => Some(
|
||||
g.fields
|
||||
.iter()
|
||||
.find_map(|f| {
|
||||
let on = f.items.first().map(|s| s.trim()).filter(|s| !s.is_empty())?;
|
||||
(f.value.plain()?.trim() == on).then(|| on.to_string())
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Lay the form out again from its template, data and overrides, replacing the pages and the
|
||||
/// generated fields (a script added a row or hid a subform).
|
||||
pub fn rerender(doc: &mut Document, tpl: &Template) -> Result<Report, XfaError> {
|
||||
|
||||
@@ -6269,6 +6269,90 @@ fn a_field_split_into_areas_smaller_than_its_sections_margins_still_moves_on() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A template for the reset defaults: a choice list showing `Label` and saving `code`, with
|
||||
/// `Label` its default, and an exclusion group whose second button is on by default.
|
||||
fn reset_defaults_template() -> Template {
|
||||
let xml = r#"<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/"><subform name="form"><subform name="page1">
|
||||
<field name="kind"><ui><choiceList/></ui><items><text>Label</text><text>Other</text></items><items save="1"><text>code</text><text>other</text></items><value><text>Label</text></value></field>
|
||||
<exclGroup name="pick"><field name="a"><ui><checkButton/></ui><items><text>A</text></items></field><field name="b"><ui><checkButton/></ui><items><text>B</text></items><value><text>B</text></value></field></exclGroup>
|
||||
<exclGroup name="none"><field name="a"><ui><checkButton/></ui><items><text>A</text></items></field></exclGroup>
|
||||
<field name="go"><ui><button/></ui></field>
|
||||
</subform></subform></template>"#;
|
||||
crate::parse::parse(xml).unwrap().0
|
||||
}
|
||||
|
||||
/// A reset gives a choice list the default it saves (the item value), not the text it shows,
|
||||
/// as the live form reads it.
|
||||
#[test]
|
||||
fn a_choice_list_resets_to_its_saved_default() {
|
||||
let tpl = reset_defaults_template();
|
||||
assert_eq!(reset_default(&tpl, "form[0].page1[0].kind[0]").as_deref(), Some("code"));
|
||||
}
|
||||
|
||||
/// An exclusion group resets to the on value of the button that is on by default, or to
|
||||
/// nothing; its buttons, and push buttons, restore no value of their own.
|
||||
#[test]
|
||||
fn an_exclusion_group_resets_to_its_default_button() {
|
||||
let tpl = reset_defaults_template();
|
||||
assert_eq!(reset_default(&tpl, "form[0].page1[0].pick[0]").as_deref(), Some("B"));
|
||||
assert_eq!(reset_default(&tpl, "form[0].page1[0].none[0]").as_deref(), Some(""));
|
||||
assert_eq!(reset_default(&tpl, "form[0].page1[0].pick[0].b[0]"), None);
|
||||
assert_eq!(reset_default(&tpl, "form[0].page1[0].go[0]"), None);
|
||||
assert_eq!(reset_default(&tpl, "form[0].page1[0]"), None);
|
||||
assert_eq!(reset_default(&tpl, "form[0].page1[0].missing[0]"), None);
|
||||
}
|
||||
|
||||
/// A reset writes a multi-select list's default picks as `value` elements, in the packet and in
|
||||
/// the copy scripts read, as any value write does; other structured content (rich text) is left
|
||||
/// as it is, with a warning.
|
||||
#[test]
|
||||
fn a_reset_replaces_structured_multi_select_picks() {
|
||||
let xdp = template_with_data(1, "<form><tags><value>a</value><value>b</value></tags><body><p>rich</p></body></form>");
|
||||
let path = som_to_path("form[0].tags[0]");
|
||||
let value_at = |doc: &Document| data_of(doc).and_then(|d| d.value_at(&path)).unwrap_or_default();
|
||||
|
||||
let mut doc = Document::open(Arc::new(shell(&xdp))).unwrap();
|
||||
write_data_ops(
|
||||
&mut doc,
|
||||
&[DataOp::Value {
|
||||
path: path.clone(),
|
||||
text: "c
|
||||
d&e"
|
||||
.into(),
|
||||
}],
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
value_at(&doc),
|
||||
"c
|
||||
d&e"
|
||||
);
|
||||
let packets = read_packets(&doc).unwrap().unwrap();
|
||||
assert!(packets.xdp.contains("<value>c</value><value>d&e</value></tags>"), "{}", packets.xdp);
|
||||
let mut copy = data_of(&Document::open(Arc::new(shell(&xdp))).unwrap()).unwrap();
|
||||
copy.apply(&DataOp::Value {
|
||||
path: path.clone(),
|
||||
text: "c
|
||||
d&e"
|
||||
.into(),
|
||||
});
|
||||
assert_eq!(
|
||||
copy.value_at(&path).as_deref(),
|
||||
Some(
|
||||
"c
|
||||
d&e"
|
||||
)
|
||||
);
|
||||
|
||||
let body = som_to_path("form[0].body[0]");
|
||||
let r = write_data_ops(&mut doc, &[DataOp::Value { path: body.clone(), text: String::new() }], None).unwrap();
|
||||
assert!(!r.warnings.is_empty());
|
||||
assert_eq!(data_of(&doc).unwrap().get(&body).map(|n| n.children.len()), Some(1));
|
||||
copy.apply(&DataOp::Value { path: body.clone(), text: String::new() });
|
||||
assert_eq!(copy.get(&body).map(|n| n.children.len()), Some(1));
|
||||
}
|
||||
|
||||
/// Only a template that is a packet of the XDP is the form's: one inside imported data (a
|
||||
/// dataset holding XML that looks like a template) is not read, and neither are its scripts.
|
||||
#[test]
|
||||
|
||||
@@ -5447,8 +5447,8 @@ milestone = "M12"
|
||||
status = "partial"
|
||||
commands = ["file.open"]
|
||||
tools = ["doc_open", "doc_info", "form_fields", "form_fill", "js_run", "form_focus"]
|
||||
evidence = ["crates/xfa/src/tests.rs::a_draw_turns_counter_clockwise_about_its_anchor", "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/it/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/it/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/engine/src/tests.rs::xfa_date_arithmetic_reads_the_date_as_its_display_picture_shows_it", "crates/automation/tests/it/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/it/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/it/xfa.rs::a_form_whose_scripts_were_turned_off_at_open_says_so", "crates/ui-egui/tests/it/xfa.rs::scripts_turned_off_by_a_click_bring_a_closed_notice_bar_back", "crates/automation/tests/it/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", "crates/xfa/src/tests.rs::page_area_without_occur_serves_every_body_page", "apps/pdfcraft-cli/tests/it/xfa.rs::info_reports_the_laid_out_pages_and_fields", "apps/pdfcraft-cli/tests/it/xfa.rs::text_reads_the_layout_including_a_page_absent_from_the_placeholder", "apps/pdfcraft-cli/tests/it/xfa.rs::render_matches_the_engine_layout_on_the_second_page", "apps/pdfcraft-cli/tests/it/xfa.rs::a_laid_out_form_keeps_the_original_copy_permissions", "crates/engine/src/tests.rs::filling_growable_xfa_text_reflows_and_undo_restores_the_layout", "crates/engine/src/tests.rs::xfa_text_growth_refreshes_widget_geometry_but_fixed_height_fields_keep_their_widgets", "crates/automation/tests/it/automation.rs::growable_xfa_text_paginates_after_fill_save_and_print", "crates/engine/src/tests.rs::an_xfa_script_that_fails_partway_keeps_what_it_changed_before_the_error", "crates/engine/src/tests.rs::repeated_xfa_print_buttons_run_their_click_script", "crates/automation/tests/it/automation.rs::repeated_xfa_print_buttons_work_through_tools_and_after_reopen", "crates/xfa/src/tests.rs::rich_text_max_length_reaches_the_field_and_layout", "crates/automation/tests/it/automation.rs::xfa_rich_text_max_length_is_reported_and_enforced_through_tools", "crates/xfa/src/tests.rs::bmp_and_tiff_pictures_are_decoded_and_drawn", "crates/engine/src/tests.rs::active_xfa_does_not_run_pdf_container_scripts_but_keeps_requested_alerts", "crates/engine/src/tests.rs::active_xfa_without_template_scripts_does_not_initialize_pdf_fallback_scripts", "crates/engine/src/tests.rs::inactive_xfa_and_ordinary_pdfs_keep_document_helpers_and_alerts", "crates/automation/tests/it/automation.rs::active_xfa_skips_container_fallback_scripts_after_save_and_reopen", "crates/xfa/src/tests.rs::laying_a_form_out_takes_off_the_usage_rights_it_voids", "crates/engine/src/tests.rs::a_dynamic_xfa_form_is_saved_without_the_usage_rights_its_layout_voided", "crates/xfa/src/tests.rs::faces_measure_text_at_their_standard_14_widths", "crates/xfa/src/tests.rs::rich_text_links_keep_their_address", "crates/xfa/src/tests.rs::links_in_rich_text_become_link_annotations_one_per_line", "crates/xfa/src/tests.rs::a_links_pieces_share_one_address", "crates/xfa/src/tests.rs::a_rule_between_two_pieces_of_a_link_keeps_them_apart", "crates/xfa/src/tests.rs::links_to_anything_but_web_and_email_addresses_stay_plain_text", "crates/automation/tests/it/automation.rs::links_in_xfa_rich_text_are_listed_through_tools", "crates/xfa/src/testsLine truncated
|
||||
notes = "A draw placed by position turns about its anchor (rotate); fields, subforms and draws in a flow don't. Active XFA layout skips PDF-container document-script initialization for the Acrobat console/field context, so fallback-viewer scripts do not run there. Scripts remain in the file/listing; explicit console/field and XFA alerts still run. Ordinary PDF, static XFA and unlaid-out shells keep document scripts; static-conversion undo restores the XFA policy. Changed growable multiline text (no fixed h) reflows from final datasets after edits, with or without scripts; pages/widgets refresh and undo/redo, save/reopen and print retain all text. Oversized text still uses the existing read-only page-text fallback with a warning; fixed-height fields do not reflow. Repeated page-master buttons resolve their own PDF field to the shared XFA click script; the numPages-based print script requests printing from every copy, including after save/reopen (#2572). Rich-text exData/maxLength is carried into AcroForm MaxLen, reported by form_fields and enforced by form_fill (Unicode characters); limits and accepted values survive save/reopen (#2570). laid out from the template on open by the xfa crate (prototypes first: use/usehref references to #id or #som(...) in the same template, as Designer style sheets write them, are merged in by the XFA 3.3 chapter 6 rules, the element's own attributes and children winning, with loops cut and chain length, nesting, look-up and merge work and growth capped, each with a warning; prototypes in other documents are not followed, and SOM references cover names, #class and [n] only; positioned, tb, lr-tb, table/row, page masters (a page area with no occur repeats without limit, one with occur max=1 is used once, one with occur min=1 the body never reached gets a page after the body, as Acrobat Pro does on a synthetic form), breaks, repeated header rows, borders and fills of flowed subforms (a page break between their children gives each page's part a closed border, the margins repeated at the cut, as measured on a synthetic form in another viewer, and a child's own page break breaks them the same way; one holding an item that doesn't fit on its page, cut across pages or placed past the content area's bottom, is drawn without its border, with a layout warning), captions (in their field's font unless they name their own), objects shown only on screen or only in print (relevant -print and +print, inside containers too: fields by their annotation flags, draws in optional content groups whose view and print usage say so; PdfCraft's own printing doesn't yet apply a layer's print usage, so a screen-only or print-only draw prints as it shows on screen, and a hidden object relevant prints isn't laid out), rounded and inverted border corners, rich text (its http, https and mailto links clickable), JPEG/PNG/GIF/BMP/TIFF images) into real pages and AcroForm fields (text, password, date with AFDate formats (a date one of the edit picture's patterns reads is kept in its first, and in the data as an ISO date; text none of them reads, ISO text included, is kept and shown as typed rather than refused, as another viewer keeps it on a synthetic form; a display picture that differs from the edit picture isn't used to show the date yet), check boxes and radio groups (each box placed by its field's margins, caption and paragraph alignment as measured on two synthetic forms in another viewer: with no caption at the right of the content area, beside a reserved caption where the alignment puts it, and a caption without a reserve taking the rest of the field; a check box's own margin larger than the box draws a bigger box there, which isn't copied), drop-downs and list boxes with shown/saved item pairs and multi-select, signature fields (each named by its SOM path, under a generated field for each step above it, so that another viewer, which lays the form out again, finds a signature made here, as seen on a synthetic form, and a box signed here keeps its signature, and that name, when a script lays the form out again; a box whose path, or a part of it, the file already gives another field keeps its own name, and its signature isn't found there yet), 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; a field or exclusion group that must be filled in, validate nullTest=\"error\", is Required, so field highlighting outlines it as another viewer does on a real form; a push button, which keeps no value, is not), 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; usage rights the form carried (/Perms /UR3) are taken off with a note, as the layout voids them and a form with void usage rights opens with every field locked; doc_info reports xfa_layout. CLI info reports the laid-out pageLine truncated
|
||||
evidence = ["crates/xfa/src/tests.rs::a_draw_turns_counter_clockwise_about_its_anchor", "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/it/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/it/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/engine/src/tests.rs::xfa_date_arithmetic_reads_the_date_as_its_display_picture_shows_it", "crates/automation/tests/it/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/it/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/it/xfa.rs::a_form_whose_scripts_were_turned_off_at_open_says_so", "crates/ui-egui/tests/it/xfa.rs::scripts_turned_off_by_a_click_bring_a_closed_notice_bar_back", "crates/automation/tests/it/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", "crates/xfa/src/tests.rs::page_area_without_occur_serves_every_body_page", "apps/pdfcraft-cli/tests/it/xfa.rs::info_reports_the_laid_out_pages_and_fields", "apps/pdfcraft-cli/tests/it/xfa.rs::text_reads_the_layout_including_a_page_absent_from_the_placeholder", "apps/pdfcraft-cli/tests/it/xfa.rs::render_matches_the_engine_layout_on_the_second_page", "apps/pdfcraft-cli/tests/it/xfa.rs::a_laid_out_form_keeps_the_original_copy_permissions", "crates/engine/src/tests.rs::filling_growable_xfa_text_reflows_and_undo_restores_the_layout", "crates/engine/src/tests.rs::xfa_text_growth_refreshes_widget_geometry_but_fixed_height_fields_keep_their_widgets", "crates/automation/tests/it/automation.rs::growable_xfa_text_paginates_after_fill_save_and_print", "crates/engine/src/tests.rs::an_xfa_script_that_fails_partway_keeps_what_it_changed_before_the_error", "crates/engine/src/tests.rs::repeated_xfa_print_buttons_run_their_click_script", "crates/automation/tests/it/automation.rs::repeated_xfa_print_buttons_work_through_tools_and_after_reopen", "crates/xfa/src/tests.rs::rich_text_max_length_reaches_the_field_and_layout", "crates/automation/tests/it/automation.rs::xfa_rich_text_max_length_is_reported_and_enforced_through_tools", "crates/xfa/src/tests.rs::bmp_and_tiff_pictures_are_decoded_and_drawn", "crates/engine/src/tests.rs::active_xfa_does_not_run_pdf_container_scripts_but_keeps_requested_alerts", "crates/engine/src/tests.rs::active_xfa_without_template_scripts_does_not_initialize_pdf_fallback_scripts", "crates/engine/src/tests.rs::inactive_xfa_and_ordinary_pdfs_keep_document_helpers_and_alerts", "crates/automation/tests/it/automation.rs::active_xfa_skips_container_fallback_scripts_after_save_and_reopen", "crates/xfa/src/tests.rs::laying_a_form_out_takes_off_the_usage_rights_it_voids", "crates/engine/src/tests.rs::a_dynamic_xfa_form_is_saved_without_the_usage_rights_its_layout_voided", "crates/xfa/src/tests.rs::faces_measure_text_at_their_standard_14_widths", "crates/xfa/src/tests.rs::rich_text_links_keep_their_address", "crates/xfa/src/tests.rs::links_in_rich_text_become_link_annotations_one_per_line", "crates/xfa/src/tests.rs::a_links_pieces_share_one_address", "crates/xfa/src/tests.rs::a_rule_between_two_pieces_of_a_link_keeps_them_apart", "crates/xfa/src/tests.rs::links_to_anything_but_web_and_email_addresses_stay_plain_text", "crates/automation/tests/it/automation.rs::links_in_xfa_rich_text_are_listed_through_tools", "crates/xfa/src/testsLine truncated
|
||||
notes = "A draw placed by position turns about its anchor (rotate); fields, subforms and draws in a flow don't. Active XFA layout skips PDF-container document-script initialization for the Acrobat console/field context, so fallback-viewer scripts do not run there. Scripts remain in the file/listing; explicit console/field and XFA alerts still run. Ordinary PDF, static XFA and unlaid-out shells keep document scripts; static-conversion undo restores the XFA policy. Changed growable multiline text (no fixed h) reflows from final datasets after edits, with or without scripts; pages/widgets refresh and undo/redo, save/reopen and print retain all text. Oversized text still uses the existing read-only page-text fallback with a warning; fixed-height fields do not reflow. Repeated page-master buttons resolve their own PDF field to the shared XFA click script; the numPages-based print script requests printing from every copy, including after save/reopen (#2572). Rich-text exData/maxLength is carried into AcroForm MaxLen, reported by form_fields and enforced by form_fill (Unicode characters); limits and accepted values survive save/reopen (#2570). laid out from the template on open by the xfa crate (prototypes first: use/usehref references to #id or #som(...) in the same template, as Designer style sheets write them, are merged in by the XFA 3.3 chapter 6 rules, the element's own attributes and children winning, with loops cut and chain length, nesting, look-up and merge work and growth capped, each with a warning; prototypes in other documents are not followed, and SOM references cover names, #class and [n] only; positioned, tb, lr-tb, table/row, page masters (a page area with no occur repeats without limit, one with occur max=1 is used once, one with occur min=1 the body never reached gets a page after the body, as Acrobat Pro does on a synthetic form), breaks, repeated header rows, borders and fills of flowed subforms (a page break between their children gives each page's part a closed border, the margins repeated at the cut, as measured on a synthetic form in another viewer, and a child's own page break breaks them the same way; one holding an item that doesn't fit on its page, cut across pages or placed past the content area's bottom, is drawn without its border, with a layout warning), captions (in their field's font unless they name their own), objects shown only on screen or only in print (relevant -print and +print, inside containers too: fields by their annotation flags, draws in optional content groups whose view and print usage say so; PdfCraft's own printing doesn't yet apply a layer's print usage, so a screen-only or print-only draw prints as it shows on screen, and a hidden object relevant prints isn't laid out), rounded and inverted border corners, rich text (its http, https and mailto links clickable), JPEG/PNG/GIF/BMP/TIFF images) into real pages and AcroForm fields (text, password, date with AFDate formats (a date one of the edit picture's patterns reads is kept in its first, and in the data as an ISO date; text none of them reads, ISO text included, is kept and shown as typed rather than refused, as another viewer keeps it on a synthetic form; a display picture that differs from the edit picture isn't used to show the date yet), check boxes and radio groups (each box placed by its field's margins, caption and paragraph alignment as measured on two synthetic forms in another viewer: with no caption at the right of the content area, beside a reserved caption where the alignment puts it, and a caption without a reserve taking the rest of the field; a check box's own margin larger than the box draws a bigger box there, which isn't copied), drop-downs and list boxes with shown/saved item pairs and multi-select, signature fields (each named by its SOM path, under a generated field for each step above it, so that another viewer, which lays the form out again, finds a signature made here, as seen on a synthetic form, and a box signed here keeps its signature, and that name, when a script lays the form out again; a box whose path, or a part of it, the file already gives another field keeps its own name, and its signature isn't found there yet), 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; a field or exclusion group that must be filled in, validate nullTest=\"error\", is Required, so field highlighting outlines it as another viewer does on a real form; a push button, which keeps no value, is not), 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; usage rights the form carried (/Perms /UR3) are taken off with a note, as the layout voids them and a form with void usage rights opens with every field locked; doc_info reports xfa_layout. CLI info reports the laid-out pageLine truncated
|
||||
|
||||
[[feature]]
|
||||
id = "form.xfa-formcalc"
|
||||
|
||||
Reference in new issue
Block a user