Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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