Skip to content
Open
179 changes: 173 additions & 6 deletions crates/engine/src/xfa.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@
use std::collections::HashMap;
use std::sync::Arc;

use pdfcraft_js::xfa::{XfaDoc, XfaEffect, XfaEvent, XfaKind, XfaNode};
use pdfcraft_js::xfa::{XfaDoc, XfaEffect, XfaEvent, XfaKind, XfaNode, XfaProp, XfaProps};
use pdfcraft_js::xfa_session::{Script, XfaSession};
use pdfcraft_xfa::model::Template;
use pdfcraft_xfa::model::{Prop, Props, Template};
use pdfcraft_xfa::{FormNode, LiveForm, NodeKind, ScriptEvent};

use crate::js::JsOutput;
Expand Down Expand Up @@ -134,8 +134,94 @@ pub(crate) struct XfaRun<'a> {
pub time_scale: u32,
}

/// The live form node `n`, with its descendants, as the script engine's snapshot of it.
fn to_js(n: &FormNode) -> XfaNode {
/// The script engine's copies of a form's property trees, by the address of the tree each was
/// made from: made the first time a script of the form needs one, then shared by every later
/// script and event of that form. They are kept in the form's template
/// ([`pdfcraft_xfa::model::TemplateCache`]), so they go with it when the form is closed, and
/// the lock is the form's own. Each entry holds a weak reference to its tree, which keeps the
/// address from being reused while the entry exists; entries whose tree is gone (a tree of a
/// copy of the template, such as one with a script's overrides applied) are dropped each time
/// the map is used.
type PropsCopies = HashMap<usize, (std::sync::Weak<Props>, Arc<XfaProps>)>;
type PropsCache = std::sync::Mutex<PropsCopies>;

/// The form's map of copies, with the entries of trees that are gone dropped; `None` only when
/// the template's cache holds something else. A poisoned lock only means a panic elsewhere
/// while it was held: the map is still a valid memo.
fn props_copies(tpl: &Template) -> Option<std::sync::MutexGuard<'_, PropsCopies>> {
let mut copies = tpl.cache.get_or_init(PropsCache::default)?.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
copies.retain(|_, (tree, _)| tree.strong_count() > 0);
Some(copies)
}

/// The copy made of `p`, if there is one.
fn copy_of(copies: &PropsCopies, p: &Arc<Props>) -> Option<Arc<XfaProps>> {
let (tree, copy) = copies.get(&(Arc::as_ptr(p) as usize))?;
(std::sync::Weak::as_ptr(tree) == Arc::as_ptr(p) && tree.strong_count() > 0).then(|| Arc::clone(copy))
}

/// The script engine's copy of the template's property tree `p`.
fn convert_props(p: &Props) -> XfaProps {
fn prop(p: &Prop, depth: usize) -> XfaProp {
XfaProp {
tag: p.tag.clone(),
attrs: p.attrs.clone(),
// Shared, not copied: a validation's script can be long.
text: p.text.clone(),
// The parser keeps 8 levels; this bounds the walk whatever builds the tree.
children: if depth > 64 { Vec::new() } else { p.children.iter().map(|c| prop(c, depth + 1)).collect() },
}
}
XfaProps { attrs: p.attrs.clone(), elements: p.elements.iter().map(|e| prop(e, 0)).collect() }
}

/// The live form node `n` of the form whose template is `tpl`, with its descendants, as the
/// script engine's snapshot of it; the property trees are the form's shared copies
/// ([`PropsCopies`]), not copied again. The map is locked only to look copies up and to add the
/// ones made (outside the lock) for trees met for the first time.
fn to_js(n: &FormNode, tpl: &Template) -> XfaNode {
fn trees<'a>(n: &'a FormNode, out: &mut HashMap<usize, &'a Arc<Props>>, depth: usize) {
out.entry(Arc::as_ptr(&n.props) as usize).or_insert(&n.props);
if depth <= 256 {
n.children.iter().for_each(|c| trees(c, out, depth + 1));
}
}
let mut needed = HashMap::new();
trees(n, &mut needed, 0);
let mut found: HashMap<usize, Arc<XfaProps>> = HashMap::with_capacity(needed.len());
let missing: Vec<&Arc<Props>> = {
let copies = props_copies(tpl);
needed
.iter()
.filter_map(|(&key, p)| match copies.as_ref().and_then(|c| copy_of(c, p)) {
Some(copy) => {
found.insert(key, copy);
None
}
None => Some(*p),
})
.collect()
};
if !missing.is_empty() {
let made: Vec<(&Arc<Props>, Arc<XfaProps>)> = missing.into_iter().map(|p| (p, Arc::new(convert_props(p)))).collect();
let mut copies = props_copies(tpl);
for (p, copy) in made {
let key = Arc::as_ptr(p) as usize;
// Another thread may have made one meanwhile: keep the first.
let copy = match copies.as_mut() {
Some(c) => copy_of(c, p).unwrap_or_else(|| {
c.insert(key, (Arc::downgrade(p), Arc::clone(&copy)));
copy
}),
None => copy,
};
found.insert(key, copy);
}
}
to_js_with(n, &found, 0)
}

fn to_js_with(n: &FormNode, copies: &HashMap<usize, Arc<XfaProps>>, depth: usize) -> XfaNode {
XfaNode {
name: n.name.clone(),
som: n.som.clone(),
Expand All @@ -154,7 +240,8 @@ fn to_js(n: &FormNode) -> XfaNode {
occur_min: n.occur_min,
occur_max: n.occur_max,
index: n.index,
children: n.children.iter().map(to_js).collect(),
children: if depth > 256 { Vec::new() } else { n.children.iter().map(|c| to_js_with(c, copies, depth + 1)).collect() },
props: copies.get(&(Arc::as_ptr(&n.props) as usize)).cloned().unwrap_or_else(|| Arc::new(convert_props(&n.props))),
formatted: n.display_picture.as_deref().and_then(|p| pdfcraft_xfa::formatted_value(p, &n.value)),
}
}
Expand Down Expand Up @@ -662,7 +749,8 @@ 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 tpl = self.tpl;
let root = to_js(&self.live().root, tpl);
let script = Script {
source: ev.script.clone(),
formcalc: ev.formcalc,
Expand Down Expand Up @@ -954,4 +1042,83 @@ mod tests {
Some((2, "form[0].table[0]".into(), "row".into(), 2))
);
}

/// The scripts' snapshot shares the template's property trees: the next script's, and the
/// next event's (a live form built again, from a copy of the template as overrides make
/// one), point at the trees the first converted rather than converting them again.
#[test]
fn snapshots_share_property_trees_across_runs_and_events() {
let fields: String = (0..2_000)
.map(|i| format!(r#"<field name="f{i}"><ui><choiceList/></ui><border><edge/><fill><color value="1,2,3"/></fill></border></field>"#))
.collect();
let xml = format!(
r#"<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/"><subform name="form" layout="tb">{fields}</subform></template>"#
);
let (tpl, _) = pdfcraft_xfa::parse(&xml).unwrap();
let live = pdfcraft_xfa::form_tree(&tpl, None, &Default::default());
let a = to_js(&live.root, &tpl);
let b = to_js(&live.root, &tpl);
// A tree built again (with a script's overrides) is still the form's: same copies.
let copy = pdfcraft_xfa::apply_overrides(&tpl, &Default::default());
let c = to_js(&pdfcraft_xfa::form_tree(&copy, None, &Default::default()).root, &tpl);
assert_eq!(a.children.len(), 2_000);
for ((x, y), z) in a.children.iter().zip(&b.children).zip(&c.children) {
assert!(Arc::ptr_eq(&x.props, &y.props) && Arc::ptr_eq(&x.props, &z.props));
}
assert_eq!(a.children[0].props.elements[1].children[1].children[0].attrs, [("value".to_string(), "1,2,3".to_string())]);
}

/// A form's copies of its property trees are its own: kept for its later snapshots, and
/// dropped with its template when the form is closed, whatever other forms do.
#[test]
fn a_closed_forms_property_copies_go_with_its_template() {
let xml = |name: &str| {
format!(
r#"<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/"><subform name="form" layout="tb"><field name="{name}"><border><edge/></border></field></subform></template>"#
)
};
let (closed, _) = pdfcraft_xfa::parse(&xml("a")).unwrap();
let (open, _) = pdfcraft_xfa::parse(&xml("b")).unwrap();
let live = pdfcraft_xfa::form_tree(&closed, None, &Default::default());
let snapshot = to_js(&live.root, &closed);
let copy = Arc::downgrade(&snapshot.children[0].props);
drop(snapshot);
assert!(copy.upgrade().is_some(), "kept for the form's next snapshot");
// Another form's snapshot neither keeps nor needs this form's copies.
let other = to_js(&pdfcraft_xfa::form_tree(&open, None, &Default::default()).root, &open);
assert!(!Arc::ptr_eq(&other.children[0].props, &copy.upgrade().unwrap_or_default()));
drop(live);
drop(closed);
assert!(copy.upgrade().is_none(), "closing the form drops its copies at once");
}

/// A field's click script reads its own and other objects' properties through a laid-out
/// dynamic form: template values, the spec's defaults, and a write read back, which changes
/// nothing on the form.
#[test]
fn a_click_script_reads_field_properties() {
let template = pdfcraft_xfa::fixtures::scripted_template()
.replace(
r#"<field name="hello" w="1in" h="0.3in"><ui><button/></ui>"#,
r#"<field name="hello" w="1in" h="0.3in"><ui><button/></ui><border><fill><color value="200,200,255"/></fill></border>"#,
)
.replace(
r#"xfa.host.messageBox("Hello " + qty.rawValue);"#,
r#"qty.validate.nullTest = "error"; xfa.host.messageBox([this.ui.oneOfChild.className, this.border.fill.color.value, this.border.presence, qty.ui.oneOfChild.className, qty.validate.message.text.value, qty.validate.nullTest, table.row.occur.max, more.ui.oneOfChild.className, this.isPropertySpecified("border"), qty.isPropertySpecified("border")].join("|"));"#,
);
let mut s = crate::Session::new();
let id = s.open("props.pdf", None, Arc::new(pdfcraft_xfa::fixtures::shell(&template)), None).unwrap();
let _ = s.take_js_output(id);
let fields =
|s: &crate::Session| s.get(id).unwrap().form.iter().map(|f| (f.name.clone(), f.value.clone(), f.read_only())).collect::<Vec<_>>();
let before = fields(&s);
let out = s.run_javascript(id, "", Some("hello")).unwrap();
assert!(out.error.is_none(), "{out:?}");
assert_eq!(
out.alerts,
[pdfcraft_js::Message::plain("button|200,200,255|visible|numericEdit|Quantity must be 100 or less|error|-1|checkButton|true|false")]
);
assert!(s.take_js_output(id).errors.is_empty());
assert_eq!(fields(&s), before, "a property write changes nothing yet");
}
}
2 changes: 2 additions & 0 deletions crates/js/src/xfa.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,14 @@ mod host;
mod instances;
mod model;
mod nodes;
mod props;
mod run;

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};
pub(crate) use nodes::{child_named, descendants_named, instances, resolve_som};
pub use props::{XfaProp, XfaProps};
#[cfg(test)]
pub(crate) use run::run_within_limits;
#[cfg(not(target_arch = "wasm32"))]
Expand Down
30 changes: 29 additions & 1 deletion crates/js/src/xfa/host.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,14 @@ pub(crate) struct HNode {
pub(crate) gone: bool,
/// See [`XfaNode::formatted`]; dropped when a script sets the value, which it no longer shows.
pub(crate) formatted: Option<String>,
/// See [`XfaNode::props`]; instances a script adds share their first instance's.
pub(crate) props: std::sync::Arc<super::XfaProps>,
/// A choice list's entries as a script's list methods leave them, one list per `<items>`;
/// `None` while they are as the template lists them. The hook for PdfCraft's choice-list
/// methods (`addItem`, `clearItems`; the Series 1 work, not on main yet), which will set it so
/// that the `items` property reads (see [`super::props::list_entries`]) agree with them.
/// Nothing sets it yet, so reads follow the template.
pub(crate) items: Option<Vec<super::props::ItemList>>,
}

/// The form as a script sees and changes it, with the effects it records.
Expand All @@ -48,6 +56,14 @@ pub(crate) struct XHost {
/// Bytes of text kept in effects, console lines and values written, against [`MAX_PAYLOAD`].
kept: usize,
dropped_bytes: usize,
/// What the script wrote to property objects, by (node, property path): read back later in
/// the run, applied nowhere yet. Charged against [`MAX_PAYLOAD`].
pub(crate) prop_writes: super::props::PropWrites,
/// Attributes written to a property past [`super::props::MAX_WRITTEN_ATTRS`], left out.
pub(super) dropped_prop_writes: usize,
/// Bytes of edges and corners copied when a script reached sides a border doesn't list,
/// against [`super::props::MAX_COPIED`].
pub(super) copied: usize,
}

impl XHost {
Expand All @@ -74,11 +90,14 @@ impl XHost {
dropped_console: 0,
kept: 0,
dropped_bytes: 0,
prop_writes: super::props::PropWrites::new(),
dropped_prop_writes: 0,
copied: 0,
}
}

/// Room for `bytes` more bytes of kept text: charged when there is, a drop counted when not.
fn room(&mut self, bytes: usize) -> bool {
pub(super) fn room(&mut self, bytes: usize) -> bool {
match self.kept.checked_add(bytes).filter(|k| *k <= MAX_PAYLOAD) {
Some(k) => {
self.kept = k;
Expand Down Expand Up @@ -209,6 +228,13 @@ impl XHost {
if self.clipped_values > 0 {
out.push(format!("the script set {} value(s) longer than {MAX_VALUE} characters; they were cut", self.clipped_values));
}
if self.dropped_prop_writes > 0 {
out.push(format!(
"the script wrote more than {} attributes to one property; {} more were left out",
super::props::MAX_WRITTEN_ATTRS,
self.dropped_prop_writes
));
}
out
}

Expand Down Expand Up @@ -272,6 +298,8 @@ fn flatten(n: &XfaNode, parent: Option<usize>, out: &mut Vec<HNode>, depth: usiz
children: Vec::new(),
gone: false,
formatted: n.formatted.clone(),
props: std::sync::Arc::clone(&n.props),
items: None,
});
let mut kids = Vec::new();
for c in &n.children {
Expand Down
3 changes: 3 additions & 0 deletions crates/js/src/xfa/instances.rs
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,9 @@ fn clone_subtree(h: &mut XHost, src: usize, parent: Option<usize>, depth: usize)
gone: false,
// The copy holds no value, so nothing is shown in its picture either.
formatted: None,
props: std::sync::Arc::clone(&n.props),
// A new instance lists what the template lists.
items: None,
},
n.children.clone(),
)
Expand Down
3 changes: 3 additions & 0 deletions crates/js/src/xfa/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ pub struct XfaNode {
/// Fields: the value shown in the field's display picture, what `formattedValue` reads;
/// `None` when the picture doesn't change it (the raw value is read then).
pub formatted: Option<String>,
/// The template's attributes and property elements of the object (border, ui, validate, …),
/// shared: snapshots taken for each script point at the same tree.
pub props: std::sync::Arc<super::XfaProps>,
}

/// The event a script runs for.
Expand Down
Loading
Loading