Files
pdfcraft/crates/xfa/src/data.rs
T
bflatastic c1d9dd1db1 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
2026-11-09 18:26:29 +07:00

209 lines
8.7 KiB
Rust

//! The data model (`datasets` packet, XFA 3.3 part 1, "Data Binding"): reading the values a
//! form was saved with, and writing the values of its fields back, so Adobe's viewers (which
//! lay the form out from the XFA packets) show what was filled in here.
//!
//! Binding is the default "normal" one: a field's data node is named after the field and nested
//! under nodes named after its named ancestor subforms, repeated instances as repeated sibling
//! elements. Areas are named in SOM expressions but are not data scopes, so their contents bind
//! under the area's parent ([`data_path`]). Explicit `bind ref` expressions are not followed.
use std::collections::HashMap;
use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream};
use crate::XfaError;
use crate::model::{Field, Node, SomIndices, Template, Ui};
use crate::packets::{Encoding, encode};
mod build;
mod dates;
mod import;
mod merge;
mod passwords;
mod path;
mod values;
mod write;
use build::MAX_INDEX;
pub use build::build_data;
#[cfg(test)]
pub(crate) use dates::iso_to_data;
pub(crate) use dates::{data_date_letters, data_to_iso, date_letters, date_tokens};
pub use dates::{iso_to_pattern, pattern_to_iso};
#[cfg(test)]
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, 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;
pub use write::{
DatasetsWrite, add_data_instances, remove_data_instance, write_data_ops, write_data_value, write_datasets, write_datasets_reusing,
write_datasets_with,
};
const XFA_DATA_NS: &str = "http://www.xfa.org/schema/xfa-data/1.0/";
/// Most data nodes read.
const MAX_NODES: u32 = 1_000_000;
const MAX_DEPTH: usize = 64;
/// One element of the data document.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct DataNode {
pub name: String,
pub text: String,
pub children: Vec<DataNode>,
/// Whether the element is a data group even with no children: marked
/// `xfa:dataNode="dataGroup"` (XFA 3.3 p. 150), or a multi-select list's group emptied of its
/// picks here, which the packet writer marks so.
pub group: bool,
}
/// A path into the data: `(name, index among same-named siblings)` per level.
pub type DataPath = Vec<(String, usize)>;
impl DataNode {
/// The node at `path` below this one.
pub fn get(&self, path: &[(String, usize)]) -> Option<&DataNode> {
let mut node = self;
for (name, idx) in path {
node = node.children.iter().filter(|c| &c.name == name).nth(*idx)?;
}
Some(node)
}
/// Every path to a node whose names, from here, are `names`: each instance of each step (a
/// repeating subform's rows), at most `cap` of them, in document order.
pub fn paths_named(&self, names: &[String], cap: usize) -> Vec<DataPath> {
fn walk(n: &DataNode, names: &[String], path: &mut DataPath, out: &mut Vec<DataPath>, cap: usize) {
let Some((first, rest)) = names.split_first() else {
if out.len() < cap {
out.push(path.clone());
}
return;
};
for (i, c) in n.children.iter().filter(|c| &c.name == first).enumerate() {
if out.len() >= cap {
return;
}
path.push((first.clone(), i));
walk(c, rest, path, out, cap);
path.pop();
}
}
let mut out = Vec::new();
// The depth is the length of `names`, a template's own path.
walk(self, names, &mut DataPath::new(), &mut out, cap);
out
}
/// How many children of the node at `parent` are named `name`.
pub fn count(&self, parent: &[(String, usize)], name: &str) -> usize {
self.get(parent).map_or(0, |n| n.children.iter().filter(|c| c.name == name).count())
}
pub fn text_at(&self, path: &[(String, usize)]) -> Option<&str> {
self.get(path).map(|n| n.text.as_str())
}
/// The value at `path`: its text, or, for a container whose children are all `value`
/// 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.holds_picks() {
return Some(n.text.clone());
}
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`].
#[derive(Clone, Debug, PartialEq)]
pub enum DataOp {
/// Put `text` at the node `path`, adding the node (and the ones above it) as needed.
Value { path: DataPath, text: String },
/// Make sure the node at `parent` has at least `count` children named `name` (a script
/// added rows: the layout repeats the subform once per data instance).
Instances { parent: DataPath, name: String, count: usize },
/// Take the node at `path` out (a script removed a row).
Remove(DataPath),
}
impl DataNode {
/// The node at `path`, made (with empty siblings before it) where missing; `None` past
/// the index cap.
fn ensure(&mut self, path: &[(String, usize)]) -> Option<&mut DataNode> {
let mut node = self;
for (name, idx) in path {
if *idx >= MAX_INDEX {
return None;
}
let have = node.children.iter().filter(|c| &c.name == name).count();
for _ in have..=*idx {
node.children.push(DataNode { name: name.clone(), ..Default::default() });
}
node = node.children.iter_mut().filter(|c| &c.name == name).nth(*idx)?;
}
Some(node)
}
/// Apply `op` to this tree (the root is the `xfa:data` element), as [`write_data_ops`]
/// will to the packet, so scripts that run before it is written see the change.
pub fn apply(&mut self, op: &DataOp) {
match op {
DataOp::Value { path, text } => {
if let Some(n) = self.ensure(path) {
// A multi-select list's picks, one `value` each (see `value_at`), told apart
// as the packet writer does: `value` leaves only, or a group holding none.
let picks = if n.children.is_empty() { n.group } else { n.children.iter().all(|c| c.name == "value" && c.children.is_empty()) };
if picks {
n.children = text
.split('\n')
.filter(|v| !v.is_empty())
.map(|v| DataNode { name: "value".into(), text: v.to_string(), ..Default::default() })
.collect();
// Emptied, it is still a group, and takes picks again.
n.group = true;
} else if n.children.is_empty() {
n.text = text.clone();
}
}
}
DataOp::Instances { parent, name, count } => {
if let Some(&last) = count.checked_sub(1).as_ref() {
let mut path = parent.clone();
path.push((name.clone(), last.min(MAX_INDEX.saturating_sub(1))));
let _ = self.ensure(&path);
}
}
DataOp::Remove(path) => {
let Some(((name, idx), parent)) = path.split_last() else { return };
let mut node = self;
for (n, i) in parent {
let Some(next) = node.children.iter_mut().filter(|c| &c.name == n).nth(*i) else { return };
node = next;
}
if let Some(at) = node.children.iter().enumerate().filter(|(_, c)| &c.name == name).nth(*idx).map(|(at, _)| at) {
node.children.remove(at);
}
}
}
}
}