diff --git a/crates/xfa/src/data.rs b/crates/xfa/src/data.rs index eac2f20bf..7c387c44e 100644 --- a/crates/xfa/src/data.rs +++ b/crates/xfa/src/data.rs @@ -15,6 +15,35 @@ 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(crate) use path::template_field; +pub use path::{data_path, parse_datasets, som_to_path}; +pub use values::{FieldData, FieldDatum, is_on, 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; @@ -61,1199 +90,6 @@ impl DataNode { } } -fn to_node(n: roxmltree::Node, depth: usize) -> DataNode { - let name = n.tag_name().name().to_string(); - let children: Vec = - if depth < MAX_DEPTH { n.children().filter(|c| c.is_element()).map(|c| to_node(c, depth + 1)).collect() } else { Vec::new() }; - // The text of a leaf is its value; a container's own text is whitespace. - let text = if children.is_empty() { n.children().filter_map(|c| c.text()).collect::() } else { String::new() }; - DataNode { name, text, children } -} - -/// The `xfa:data` element of the datasets packet in `xdp` (or a bare datasets document), as a -/// tree whose root is that element. -pub fn parse_datasets(xdp: &str) -> Option { - if crate::xml_nesting_depth(xdp) > crate::MAX_XML_DEPTH { - return None; - } - let opts = roxmltree::ParsingOptions { allow_dtd: false, nodes_limit: MAX_NODES }; - let doc = roxmltree::Document::parse_with_options(xdp, opts).ok()?; - let datasets = doc.descendants().find(|n| n.is_element() && n.tag_name().name() == "datasets")?; - let data = datasets.children().find(|n| n.is_element() && n.tag_name().name() == "data")?; - Some(to_node(data, 0)) -} - -/// The data path a SOM expression names: `form[0].#subform[0].name[1]` → `[(form, 0), (name, 1)]`. -/// Unnamed containers (`#subform`) have no data node. -pub fn som_to_path(som: &str) -> DataPath { - som.split('.') - .filter_map(|seg| { - let seg = seg.trim(); - if seg.is_empty() || seg.starts_with('#') || seg.starts_with('$') { - return None; - } - let (name, idx) = match seg.split_once('[') { - Some((n, rest)) => (n, rest.trim_end_matches(']').parse::().unwrap_or(0)), - None => (seg, 0), - }; - Some((name.to_string(), idx.min(100_000))) - }) - .collect() -} - -/// The data path the object at `som` binds to: [`som_to_path`] without the areas on the way. -/// An area is a named container in the form, so it is part of SOM expressions, but only -/// subforms, exclusion groups and fields are data scopes: what an area holds binds under the -/// area's parent, as Adobe's viewers write it. The template tells which segments are areas; -/// segments it doesn't have (page-set objects, or a SOM from another template) are kept. -pub fn data_path(tpl: &Template, som: &str) -> DataPath { - let mut out = DataPath::new(); - // The template children the next segment is looked up in; `None` until the root matched, - // and again once a segment is not a container the template has. - let mut level: Option<&[Node]> = None; - for (i, (name, index)) in som_to_path(som).into_iter().enumerate() { - let found = match level { - None if i == 0 && tpl.root.common.name.as_deref() == Some(name.as_str()) => Some(Container::Children(&tpl.root.children)), - None => None, - Some(children) => match named(children, &name, 0) { - Some(Node::Subform(sf)) => Some(Container::Children(&sf.children)), - Some(Node::Area(a)) => Some(Container::Area(&a.children)), - _ => None, - }, - }; - match found { - Some(Container::Area(children)) => level = Some(children), - Some(Container::Children(children)) => { - level = Some(children); - out.push((name, index)); - } - None => { - level = None; - out.push((name, index)); - } - } - } - out -} - -enum Container<'a> { - Children(&'a [Node]), - /// Contents bind under the area's parent. - Area(&'a [Node]), -} - -/// The child of `nodes` named `name`, looking through unnamed subforms and areas (which have no -/// SOM segment of their own). -fn named<'a>(nodes: &'a [Node], name: &str, depth: usize) -> Option<&'a Node> { - if depth > MAX_DEPTH { - return None; - } - nodes.iter().find(|n| n.common().name.as_deref() == Some(name)).or_else(|| { - nodes.iter().find_map(|n| match n { - Node::Subform(sf) if sf.common.name.is_none() => named(&sf.children, name, depth + 1), - Node::Area(a) if a.common.name.is_none() => named(&a.children, name, depth + 1), - _ => None, - }) - }) -} - -/// The date letters (`yyyy-mm-dd`, `yyyymmdd`) of an XFA date picture clause such as -/// `date{YYYY-MM-DD}`, or `None` when it has none this reads (empty, or with symbols beyond -/// years, months, days and separators). -pub(crate) fn date_letters(picture: &str) -> Option { - let inner = picture - .find('{') - .and_then(|a| { - let rest = picture.get(a + 1..)?; - rest.find('}').and_then(|b| rest.get(..b)) - }) - .unwrap_or(picture) - .trim(); - if inner.is_empty() || inner.chars().any(|c| !"YMDEJ-/., :".contains(c)) { - return None; - } - Some(inner.replace('E', "d").to_ascii_lowercase()) -} - -/// The date letters of `f`'s data picture, when it holds a whole date. A data picture without a -/// year, a month or a day can't hold the date the field shows, so such a field's data keeps -/// ISO dates. -pub(crate) fn data_date_letters(f: &Field) -> Option { - let letters = f.data_picture.as_deref().and_then(date_letters)?; - let tokens = date_tokens(&letters); - let has = |any: &[&str]| tokens.iter().any(|t| any.contains(&t.as_str())); - (has(&["yyyy", "yy"]) && has(&["mm", "m"]) && has(&["dd", "d"])).then_some(letters) -} - -/// A date as the data holds it, written in `data_pattern` (the field's data picture), read as -/// an ISO date. A value that doesn't match, or a field without a data picture, is taken as it -/// is: the data holds ISO dates by default. -pub(crate) fn data_to_iso(value: &str, data_pattern: Option<&str>) -> String { - data_pattern.and_then(|p| pattern_to_iso(value, p)).unwrap_or_else(|| value.to_string()) -} - -/// An ISO date as the data should hold it: in `data_pattern` when the field has one and the -/// date reads back from it unchanged (a two-digit year holds only 1950 to 2049), else as ISO. -pub(crate) fn iso_to_data(iso: String, data_pattern: Option<&str>) -> String { - match data_pattern { - Some(p) => { - let written = iso_to_pattern(&iso, p); - if pattern_to_iso(&written, p).as_deref() == Some(iso.as_str()) { written } else { iso } - } - None => iso, - } -} - -/// The child of `nodes` the SOM segment `name[index]` names, numbered as the form numbers its -/// objects ([`SomIndices`]): same-named siblings count up, and the instances of a repeating -/// subform take the numbers from its own on. Unnamed subforms and areas are looked through, -/// as [`named`] does. -fn named_at<'a>(nodes: &'a [Node], name: &str, index: usize, depth: usize) -> Option<&'a Node> { - if depth > MAX_DEPTH { - return None; - } - let sibs = SomIndices::of(nodes); - let mut repeating = None; - for (i, n) in nodes.iter().enumerate() { - if n.common().name.as_deref() != Some(name) { - continue; - } - let sib = sibs.at(i); - if sib == index { - return Some(n); - } - if sib < index && matches!(n, Node::Subform(sf) if sf.occur.max != Some(1)) { - repeating = Some(n); - } - } - repeating.or_else(|| { - nodes.iter().find_map(|n| match n { - Node::Subform(sf) if sf.common.name.is_none() => named_at(&sf.children, name, index, depth + 1), - Node::Area(a) if a.common.name.is_none() => named_at(&a.children, name, index, depth + 1), - _ => None, - }) - }) -} - -/// The template field the object at a SOM path is an instance of, each segment's number counted -/// 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> { - let path = som_to_path(som); - let ((root, _), rest) = path.split_first()?; - if tpl.root.common.name.as_deref() != Some(root.as_str()) { - return None; - } - let ((last, last_index), inner) = rest.split_last()?; - let mut children: &[Node] = &tpl.root.children; - for (name, index) in inner { - children = match named_at(children, name, *index, 0)? { - Node::Subform(sf) => &sf.children, - Node::Area(a) => &a.children, - _ => return None, - }; - } - match named_at(children, last, *last_index, 0)? { - Node::Field(f) => Some(f), - _ => None, - } -} - -/// The date letters of the data picture of the field `som` names, when it has one. -fn data_date_pattern(tpl: Option<&Template>, som: &str) -> Option { - tpl.and_then(|t| template_field(t, som)).and_then(data_date_letters) -} - -/// What a field holds, for writing the datasets. -#[derive(Clone, Debug, PartialEq)] -pub enum FieldData { - Text(String), - /// Checked or not. - Check(bool), - /// The selected button's on value, if any. - Radio(Option), - /// Nothing to write (buttons, signatures). - None, -} - -/// One field of the AcroForm, as the engine sees it. -#[derive(Clone, Debug, PartialEq)] -pub struct FieldDatum { - /// The terminal field dictionary (where `/PCSom` and `/PCItems` live). - pub obj: ObjRef, - /// The fully qualified field name (a static form's SOM path). - pub name: String, - pub data: FieldData, -} - -fn text_of(doc: &Document, d: &Dict, key: &[u8]) -> Option { - d.get(key).map(|o| doc.resolve(o)).and_then(|o| o.as_string().map(|s| s.to_text())) -} - -/// The SOM path of a field: its `/PCSom` when PdfCraft generated it, else its name. -fn som_of(doc: &Document, f: &FieldDatum) -> String { - doc.get(f.obj).as_dict().and_then(|d| text_of(doc, d, crate::pdf::SOM_KEY)).unwrap_or_else(|| f.name.clone()) -} - -/// The datasets text for a radio group. The AcroForm value is an appearance-state name -/// (`New York` is stored as `New_York`); `/PCOn` on the selected widget is the original item. -fn radio_dataset_value(doc: &Document, f: &FieldDatum, sel: &Option) -> String { - let Some(selected) = sel.as_deref().filter(|s| !s.is_empty() && *s != "Off") else { - return String::new(); - }; - let Some(group) = doc.get(f.obj).as_dict().cloned() else { return selected.to_string() }; - let kids = group.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_else(|| vec![Object::Ref(f.obj)]); - for kid in kids { - let Some(widget) = doc.resolve(&kid).as_dict().cloned() else { continue }; - let original = text_of(doc, &widget, b"PCOn"); - let state = widget - .get(b"AP") - .map(|ap| doc.resolve(ap)) - .and_then(|ap| ap.as_dict().and_then(|a| a.get(b"N").cloned())) - .and_then(|n| doc.resolve(&n).as_dict().cloned()) - .and_then(|n| n.iter().map(|(k, _)| String::from_utf8_lossy(k).into_owned()).find(|k| k != "Off")); - if state.as_deref() == Some(selected) || original.as_deref() == Some(selected) { - return original.unwrap_or_else(|| selected.to_string()); - } - } - selected.to_string() -} - -/// The on and off item values of a check box (`/PCItems`), or the widget's on state. -fn items_of(doc: &Document, f: &FieldDatum) -> (String, String) { - let d = doc.get(f.obj); - let items: Vec = d - .as_dict() - .and_then(|d| d.get(b"PCItems").map(|o| doc.resolve(o))) - .and_then(|a| a.as_array().map(|a| a.iter().filter_map(|x| x.as_string().map(|s| s.to_text())).collect())) - .unwrap_or_default(); - let on = items.first().cloned().unwrap_or_else(|| "1".into()); - let off = items.get(1).cloned().unwrap_or_default(); - (on, off) -} - -/// The Acrobat date pattern a field's format action names (`AFDate_FormatEx("yyyy-mm-dd")`). -fn date_pattern_of(doc: &Document, f: &FieldDatum) -> Option { - let d = doc.get(f.obj); - let aa = doc.resolve(d.as_dict()?.get(b"AA")?); - let fa = doc.resolve(aa.as_dict()?.get(b"F")?); - let js = text_of(doc, fa.as_dict()?, b"JS")?; - let i = js.find("AFDate_FormatEx(")?; - let rest = &js[i + "AFDate_FormatEx(".len()..]; - let q = rest.find('"')?; - let body = &rest[q + 1..]; - Some(body[..body.find('"')?].to_string()) -} - -const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]; -const WEEKDAYS: [&str; 7] = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; - -/// The weekday (0 = Sunday) of a proleptic Gregorian date. -fn weekday(y: u32, m: u32, d: u32) -> usize { - const T: [i64; 12] = [0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4]; - let y = i64::from(y) - i64::from(m < 3); - let t = T.get((m as usize).wrapping_sub(1)).copied().unwrap_or(0); - ((y + y / 4 - y / 100 + y / 400 + t + i64::from(d)).rem_euclid(7)) as usize -} - -/// The index and length of the name in `names` that `text` starts with (ignoring case), -/// cut to `abbr` letters when that is set. -fn name_at(text: &str, names: &[&str], abbr: bool) -> Option<(usize, usize)> { - names.iter().enumerate().find_map(|(i, n)| { - let n = if abbr { n.get(..3)? } else { n }; - text.get(..n.len()).filter(|t| t.eq_ignore_ascii_case(n)).map(|_| (i, n.len())) - }) -} - -/// Whether `token` is a run of the date letters (`y`, `m`, `d`) rather than a literal. -fn is_date_run(token: &str) -> bool { - token.starts_with(['y', 'm', 'd']) -} - -/// The tokens of a date pattern: runs of `y`, `m` and `d` (`yyyy`, `yy`, `mmmm`, `mmm`, `mm`, -/// `m`, `dddd`, `ddd`, `dd`, `d` are understood), or a literal character. -pub(crate) fn date_tokens(pattern: &str) -> Vec { - let mut out = Vec::new(); - let chars: Vec = pattern.chars().collect(); - let mut i = 0; - while i < chars.len() { - let c = chars[i].to_ascii_lowercase(); - if "ymd".contains(c) { - let mut j = i; - while j < chars.len() && chars[j].to_ascii_lowercase() == c { - j += 1; - } - out.push(std::iter::repeat_n(c, j - i).collect()); - i = j; - } else { - out.push(chars[i].to_string()); - i += 1; - } - } - out -} - -/// `2001-02-03` shown in `pattern` (`mm/dd/yyyy` → `02/03/2001`, `mmm d, yyyy` → -/// `Feb 3, 2001`). Patterns with a token this doesn't know and non-dates come back as they are. -pub fn iso_to_pattern(iso: &str, pattern: &str) -> String { - let parts: Vec<&str> = iso.trim().splitn(3, '-').collect(); - let [y, m, d] = parts[..] else { return iso.to_string() }; - let (Ok(y), Ok(m), Ok(d)) = (y.parse::(), m.parse::(), d.parse::()) else { return iso.to_string() }; - let mut out = String::new(); - for t in date_tokens(pattern) { - match t.as_str() { - "yyyy" => out.push_str(&format!("{y:04}")), - "yy" => out.push_str(&format!("{:02}", y % 100)), - "mm" => out.push_str(&format!("{m:02}")), - "m" => out.push_str(&m.to_string()), - "dd" => out.push_str(&format!("{d:02}")), - "d" => out.push_str(&d.to_string()), - "mmm" | "mmmm" => { - let Some(name) = m.checked_sub(1).and_then(|i| MONTHS.get(i as usize)) else { return iso.to_string() }; - match t.len() { - 3 => out.push_str(name.get(..3).unwrap_or(name)), - _ => out.push_str(name), - } - } - "ddd" | "dddd" => { - let name = WEEKDAYS.get(weekday(y, m, d)).copied().unwrap_or_default(); - match t.len() { - 3 => out.push_str(name.get(..3).unwrap_or(name)), - _ => out.push_str(name), - } - } - other if is_date_run(other) => return iso.to_string(), - other => out.push_str(other), - } - } - out -} - -/// A date typed as `pattern` back to `yyyy-mm-dd`. `None` when it doesn't match. -pub fn pattern_to_iso(text: &str, pattern: &str) -> Option { - let text = text.trim(); - let (mut y, mut m, mut d) = (None, None, None); - let mut day_name = None; - let mut rest = text; - let tokens = date_tokens(pattern); - for (i, t) in tokens.iter().enumerate() { - match t.as_str() { - "yyyy" | "yy" | "mm" | "m" | "dd" | "d" => { - // Digits up to the next literal (or a fixed width). - let fixed = matches!(t.as_str(), "yyyy" | "yy" | "mm" | "dd"); - let width = - if fixed { t.len() } else { rest.chars().take_while(char::is_ascii_digit).count().min(if t == "d" || t == "m" { 2 } else { 4 }) }; - let digits: String = rest.chars().take(width).collect(); - if digits.is_empty() || !digits.chars().all(|c| c.is_ascii_digit()) { - return None; - } - let v: u32 = digits.parse().ok()?; - rest = &rest[digits.len()..]; - match t.as_str() { - "yyyy" => y = Some(v), - "yy" => y = Some(if v < 50 { 2000 + v } else { 1900 + v }), - "mm" | "m" => m = Some(v), - _ => d = Some(v), - } - } - "mmm" | "mmmm" => { - let (i, len) = name_at(rest, &MONTHS, t.len() == 3)?; - m = Some(i as u32 + 1); - rest = rest.get(len..)?; - } - "ddd" | "dddd" => { - let (i, len) = name_at(rest, &WEEKDAYS, t.len() == 3)?; - day_name = Some(i); - rest = rest.get(len..)?; - } - run if is_date_run(run) => return None, - lit => { - rest = rest.strip_prefix(lit)?; - } - } - let _ = i; - } - if !rest.is_empty() { - return None; - } - let (y, m, d) = (y?, m?, d?); - if day_name.is_some_and(|w| w != weekday(y, m, d)) { - return None; - } - (1..=12).contains(&m).then(|| format!("{y:04}-{m:02}-{d:02}")).filter(|_| (1..=31).contains(&d)) -} - -fn xml_name(s: &str) -> String { - let mut out: String = s.chars().map(|c| if c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.' { c } else { '_' }).collect(); - if out.is_empty() || !(out.starts_with(|c: char| c.is_ascii_alphabetic() || c == '_')) { - out.insert(0, '_'); - } - out -} - -/// `s` as XML character data. Characters XML 1.0 cannot hold are left out; the flag says -/// whether any were. -fn escape(s: &str) -> (String, bool) { - let mut out = String::with_capacity(s.len()); - let mut dropped = false; - for c in s.chars() { - match c { - '&' => out.push_str("&"), - '<' => out.push_str("<"), - '>' => out.push_str(">"), - '"' => out.push_str("""), - // Parsers turn a raw carriage return into a line feed; a reference keeps it. - '\r' => out.push_str(" "), - '\u{FFFE}' | '\u{FFFF}' => dropped = true, - c if c.is_control() && c != '\n' && c != '\t' => dropped = true, - c => out.push(c), - } - } - (out, dropped) -} - -/// Most data nodes one write may create (instances skipped by a SOM index count too). -const MAX_NEW_NODES: usize = 20_000; -/// Highest same-name sibling index a write follows. -const MAX_INDEX: usize = 10_000; -/// Most bytes of names the nodes one write creates hold together: every sibling an index -/// skips over gets its own copy of the name. -const MAX_NEW_NAME_BYTES: usize = 4 << 20; - -/// What one write may still create: nodes and bytes of their names. -struct NewNodes { - nodes: usize, - name_bytes: usize, -} - -impl NewNodes { - fn new() -> NewNodes { - NewNodes { nodes: MAX_NEW_NODES, name_bytes: MAX_NEW_NAME_BYTES } - } -} - -/// A data tree being built for writing. -#[derive(Default)] -struct Build { - name: String, - text: String, - children: Vec, - /// Positions in `children` by name, in order. - by_name: HashMap>, -} - -impl Build { - /// The node at `path` below this one, created as needed. `None` when the path is deeper - /// than [`MAX_DEPTH`], an index is above [`MAX_INDEX`], or creating it would spend more - /// new nodes or name bytes than `budget` holds. - fn at(&mut self, path: &[(String, usize)], budget: &mut NewNodes) -> Option<&mut Build> { - if path.len() > MAX_DEPTH { - return None; - } - let mut node = self; - for (name, idx) in path { - if *idx > MAX_INDEX { - return None; - } - let name = xml_name(name); - let have = node.by_name.get(&name).map_or(0, Vec::len); - if *idx >= have { - let need = idx - have + 1; - let bytes = need.saturating_mul(name.len()); - if need > budget.nodes || bytes > budget.name_bytes { - return None; - } - budget.nodes -= need; - budget.name_bytes -= bytes; - let start = node.children.len(); - node.children.extend((0..need).map(|_| Build { name: name.clone(), ..Default::default() })); - node.by_name.entry(name.clone()).or_default().extend(start..start + need); - } - let pos = *node.by_name.get(&name)?.get(*idx)?; - node = node.children.get_mut(pos)?; - } - Some(node) - } - - /// The tree as XML. Depth is bounded by [`Build::at`]. - fn write(&self, out: &mut String) { - if self.children.is_empty() { - out.push_str(&format!("<{}>{}", self.name, escape(&self.text).0, self.name)); - return; - } - out.push_str(&format!("<{}>", self.name)); - self.write_children(out); - out.push_str(&format!("", self.name)); - } - - fn write_children(&self, out: &mut String) { - for c in &self.children { - c.write(out); - } - } -} - -/// A value to write: where (the field's SOM path, and the data path it binds to) and what. -struct Want { - som: String, - path: DataPath, - text: String, - /// Create the node even for an empty value (an instance of a repeating subform). - ensure: bool, -} - -/// `tpl`, or the document's own template when none is given (parsed once). -fn with_template(doc: &Document, tpl: Option<&Template>, f: impl FnOnce(Option<&Template>) -> T) -> T { - match tpl { - Some(t) => f(Some(t)), - None => { - let parsed = crate::script::template_of(doc).ok(); - f(parsed.as_ref()) - } - } -} - -/// [`data_path`] when the template is known, else [`som_to_path`]. -fn path_of(tpl: Option<&Template>, som: &str) -> DataPath { - match tpl { - Some(t) => data_path(t, som), - None => som_to_path(som), - } -} - -/// The values the datasets should hold for `fields`, by data path: dates in each field's data -/// picture (or ISO), check and radio states, choice values as saved. -fn wanted(doc: &Document, fields: &[FieldDatum], tpl: Option<&Template>) -> Vec { - let mut out = Vec::new(); - for f in fields { - let som = som_of(doc, f); - let path = path_of(tpl, &som); - if path.is_empty() { - continue; - } - let text = match &f.data { - FieldData::Text(t) => match date_pattern_of(doc, f) { - Some(p) => match pattern_to_iso(t, &p) { - // The date goes into the data in the field's data picture, or as an ISO date. - Some(iso) => iso_to_data(iso, data_date_pattern(tpl, &som).as_deref()), - None => t.clone(), - }, - None => t.clone(), - }, - FieldData::Check(on) => { - let (on_item, off_item) = items_of(doc, f); - if *on { on_item } else { off_item } - } - FieldData::Radio(sel) => radio_dataset_value(doc, f, sel), - FieldData::None => continue, - }; - out.push(Want { som, path, text, ensure: false }); - } - out -} - -/// What a rewrite of the datasets packet does. -#[derive(Clone, Copy)] -enum Op<'a> { - /// Put these values in (adding nodes as needed). - Values(&'a [Want]), - /// Take the node at this path out. - Remove(&'a DataPath), - /// Make these nodes the whole content of the `xfa:data` element (an import). - ReplaceData(&'a [ImportNode]), - /// Make the leaf at this path a picture: base64 text with its content type. - Picture(&'a DataPath, &'a str, &'a str), -} - -/// Note that a value could not be written (once per kind of reason, naming a few fields). -fn skipped(warnings: &mut Vec, reason: &str, som: &str) { - const MAX_WARNINGS: usize = 20; - if let Some(w) = warnings.iter_mut().find(|w| w.starts_with(reason)) { - if w.matches(", ").count() < 4 { - w.push_str(&format!(", {som}")); - } else if !w.ends_with('…') { - w.push_str(", …"); - } - } else if warnings.len() < MAX_WARNINGS { - warnings.push(format!("{reason}: {som}")); - } -} - -const TOO_MANY: &str = "XFA data not written for fields whose data path is too deep or repeats too often"; -const STRUCTURED: &str = "XFA data not written for fields whose data node holds structured content"; -const CONFLICT: &str = "XFA data not written for fields bound to the same data node as another field"; -const CONTROL: &str = "Characters XML can't hold were left out of the XFA data of"; - -/// Fresh data for `wants`: the children of an `xfa:data` element. -fn build_children(wants: &[Want], warnings: &mut Vec) -> String { - let mut root = Build::default(); - let mut budget = NewNodes::new(); - for w in wants { - match root.at(&w.path, &mut budget) { - Some(node) => node.text = w.text.clone(), - None => skipped(warnings, TOO_MANY, &w.som), - } - if escape(&w.text).1 { - skipped(warnings, CONTROL, &w.som); - } - } - let mut out = String::new(); - root.write_children(&mut out); - out -} - -/// The `xfa:data` element for `fields`, built afresh. -pub fn build_data(doc: &Document, fields: &[FieldDatum]) -> String { - let wants = with_template(doc, None, |t| wanted(doc, fields, t)); - format!("{}", build_children(&wants, &mut Vec::new())) -} - -fn fresh_datasets(data_children: &str) -> String { - format!("{data_children}") -} - -/// Where an element's start tag ends (the index of its `>`), skipping quoted attribute values. -fn start_tag_end(src: &str, range: &std::ops::Range) -> Option { - let bytes = src.as_bytes(); - let mut quote = None; - for i in range.clone() { - let b = *bytes.get(i)?; - match (quote, b) { - (Some(q), _) if b == q => quote = None, - (Some(_), _) => {} - (None, b'"' | b'\'') => quote = Some(b), - (None, b'>') => return Some(i), - _ => {} - } - } - None -} - -/// An element's qualified name as written. -fn qname<'s>(src: &'s str, range: &std::ops::Range) -> Option<&'s str> { - let rest = src.get(range.start.checked_add(1)?..range.end)?; - let end = rest.find(|c: char| c.is_whitespace() || c == '/' || c == '>')?; - rest.get(..end).filter(|n| !n.is_empty()) -} - -/// One change to the packet text: replace `start..end` with `text`. -struct Edit { - start: usize, - end: usize, - text: String, -} - -/// The edit that puts `inner` (markup) at the end of `node`'s content. -fn append_edit(src: &str, node: roxmltree::Node, inner: &str) -> Option { - let range = node.range(); - let tag_end = start_tag_end(src, &range)?; - if tag_end.checked_add(1)? == range.end && src.get(..tag_end)?.ends_with('/') { - // `` becomes `inner`. - let name = qname(src, &range)?; - return Some(Edit { start: tag_end.checked_sub(1)?, end: range.end, text: format!(">{inner}") }); - } - let close = range.start + src.get(range.clone())?.rfind(" Option { - let range = node.range(); - let tag_end = start_tag_end(src, &range)?; - if tag_end.checked_add(1)? == range.end && src.get(..tag_end)?.ends_with('/') { - let name = qname(src, &range)?; - return Some(Edit { start: tag_end.checked_sub(1)?, end: range.end, text: format!(">{text}") }); - } - let close = range.start + src.get(range.clone())?.rfind(" tag_end).then(|| Edit { start: tag_end + 1, end: close, text: text.to_string() }) -} - -/// What merging the values into a packet's text came to. -enum Merged { - /// The data already holds every value. - Unchanged, - Changed(String), - /// The text could not be merged into (the reason is in the warnings). - Failed, -} - -/// Where a datasets packet sits: one packet of an array (`Packet`), or inside the whole XDP. -#[derive(Clone, Copy, PartialEq)] -enum Within { - Packet, - Xdp, -} - -const WRAP_OPEN: &str = ""; -const WRAP_CLOSE: &str = ""; - -/// Write `wants` into the data of the datasets packet in `text`, changing only what has to -/// change: values go into the data nodes they bind to, missing nodes are added, and everything -/// else (data no field binds to, other namespaces, attributes, comments, layout) stays as -/// written. -/// The prefix bound to the XFA data namespace where `n` is (`None`: unbound or the default -/// namespace, which attributes can't use). -fn xfa_prefix(n: roxmltree::Node) -> Option { - n.namespaces().find(|ns| ns.uri() == XFA_DATA_NS).and_then(|ns| ns.name()).filter(|p| !p.is_empty()).map(str::to_string) -} - -/// Data read from a file, held as a tree so it can be written into any packet with the -/// prefix that packet uses, never as the file's own markup. -#[derive(Clone, Debug, Default, PartialEq, Eq)] -pub struct ImportNode { - pub name: String, - /// The element's namespace (`None`: none). - pub ns: Option, - /// `(namespace, name, value)`. - pub attrs: Vec<(Option, String, String)>, - pub text: String, - pub children: Vec, -} - -/// Deepest data an import keeps. -const MAX_IMPORT_DEPTH: usize = MAX_DEPTH; - -/// Most bytes of names, namespaces, attributes and text an import copies out of its file: a -/// namespace declared once is copied into every element and attribute that inherits it. -pub(crate) const MAX_IMPORT_BYTES: usize = 4 * MAX_IMPORT; - -/// What an import may still copy: nodes and bytes. -struct ImportBudget { - nodes: usize, - bytes: usize, -} - -impl ImportBudget { - fn new() -> ImportBudget { - ImportBudget { nodes: MAX_NODES as usize, bytes: MAX_IMPORT_BYTES } - } - - fn take_bytes(&mut self, n: usize) -> Result<(), XfaError> { - self.bytes = self - .bytes - .checked_sub(n) - .ok_or_else(|| XfaError::TooLarge(format!("the data file expands to more than {} MB", MAX_IMPORT_BYTES >> 20)))?; - Ok(()) - } -} - -/// `n` as an [`ImportNode`], counting nodes and copied bytes against `budget`; `TooLarge` past -/// the depth or either budget. -fn import_node(n: roxmltree::Node, depth: usize, budget: &mut ImportBudget) -> Result { - if depth > MAX_IMPORT_DEPTH { - return Err(XfaError::TooLarge(format!("the data file nests more than {MAX_IMPORT_DEPTH} levels deep"))); - } - budget.nodes = budget.nodes.checked_sub(1).ok_or_else(|| XfaError::TooLarge("the data file holds too many nodes".into()))?; - let name = n.tag_name().name(); - let ns = n.tag_name().namespace(); - budget.take_bytes(name.len().saturating_add(ns.map_or(0, str::len)))?; - let mut attrs = Vec::new(); - for a in n.attributes().filter(|a| a.name() != "xmlns" && a.namespace() != Some("http://www.w3.org/2000/xmlns/")) { - budget.take_bytes(a.namespace().map_or(0, str::len).saturating_add(a.name().len()).saturating_add(a.value().len()))?; - attrs.push((a.namespace().map(str::to_string), a.name().to_string(), a.value().to_string())); - } - let mut children = Vec::new(); - let mut text = String::new(); - for c in n.children() { - if c.is_element() { - children.push(import_node(c, depth + 1, budget)?); - } else if let Some(t) = c.text() { - budget.take_bytes(t.len())?; - text.push_str(t); - } - } - // A group's own text is layout whitespace. - if !children.is_empty() && text.trim().is_empty() { - text.clear(); - } - Ok(ImportNode { name: name.to_string(), ns: ns.map(str::to_string), attrs, text, children }) -} - -/// `nodes` as markup for a packet whose XFA data namespace prefix is `xfa` (declared where -/// they go); other namespaces are declared on the elements and attributes that use them. -fn serialize_nodes(nodes: &[ImportNode], xfa: &str) -> String { - let mut out = String::new(); - for n in nodes { - serialize_node(n, xfa, &mut out, 0); - } - out -} - -fn serialize_node(n: &ImportNode, xfa: &str, out: &mut String, depth: usize) { - if depth > MAX_IMPORT_DEPTH { - return; - } - let mut decls: Vec<(String, String)> = Vec::new(); - let mut qualify = |ns: &Option, name: &str, attr: bool| -> String { - match ns.as_deref() { - None => xml_name(name), - Some(uri) if uri == XFA_DATA_NS => format!("{xfa}:{}", xml_name(name)), - Some("http://www.w3.org/XML/1998/namespace") => format!("xml:{}", xml_name(name)), - Some(uri) => { - let p = match decls.iter().position(|(_, u)| u == uri) { - Some(i) => format!("n{i}"), - None => { - decls.push((format!("n{}", decls.len()), uri.to_string())); - format!("n{}", decls.len() - 1) - } - }; - let _ = attr; - format!("{p}:{}", xml_name(name)) - } - } - }; - let tag = qualify(&n.ns, &n.name, false); - let attrs: Vec = n - .attrs - .iter() - .take(256) - .map(|(ns, name, value)| { - let q = qualify(ns, name, true); - format!(" {q}=\"{}\"", escape(value).0) - }) - .collect(); - out.push('<'); - out.push_str(&tag); - for (p, uri) in &decls { - out.push_str(&format!(" xmlns:{p}=\"{}\"", escape(uri).0)); - } - for a in &attrs { - out.push_str(a); - } - if n.text.is_empty() && n.children.is_empty() { - out.push_str("/>"); - return; - } - out.push('>'); - out.push_str(&escape(&n.text).0); - for c in &n.children { - serialize_node(c, xfa, out, depth + 1); - } - out.push_str(&format!("")); -} - -fn merge(text: &str, within: Within, op: Op, warnings: &mut Vec) -> Merged { - let wants: &[Want] = match op { - Op::Values(w) => w, - Op::Remove(_) | Op::ReplaceData(_) | Op::Picture(..) => &[], - }; - // Fresh data, where none exists yet (declared with the `xfa` prefix). - let fresh_children = |warnings: &mut Vec| match op { - Op::ReplaceData(nodes) => serialize_nodes(nodes, "xfa"), - _ => build_children(wants, warnings), - }; - if crate::xml_nesting_depth(text) > crate::MAX_XML_DEPTH { - warnings.push("The XFA data nests XML elements deeper than the reader accepts; the field values were not written to it".into()); - return Merged::Failed; - } - let opts = || roxmltree::ParsingOptions { allow_dtd: false, nodes_limit: MAX_NODES }; - // A packet of an array may use the prefixes the XDP envelope declares: read it inside - // an element declaring the usual ones. - let wrapped; - let (src, shift, parsed) = match roxmltree::Document::parse_with_options(text, opts()) { - Ok(d) => (text, 0, d), - Err(first) => { - wrapped = format!("{WRAP_OPEN}{text}{WRAP_CLOSE}"); - match (within, roxmltree::Document::parse_with_options(&wrapped, opts())) { - (Within::Packet, Ok(d)) => (wrapped.as_str(), WRAP_OPEN.len(), d), - _ => { - warnings.push(format!("The XFA data could not be read ({first}); the field values were not written to it")); - return Merged::Failed; - } - } - } - }; - let is = |n: &roxmltree::Node, name: &str| n.is_element() && n.tag_name().name() == name; - let datasets = parsed - .descendants() - .find(|n| is(n, "datasets") && n.tag_name().namespace() == Some(XFA_DATA_NS)) - .or_else(|| parsed.descendants().find(|n| is(n, "datasets"))); - let mut edits = Vec::new(); - let mut data = None; - match datasets { - None if matches!(op, Op::Remove(_) | Op::Picture(..)) => return Merged::Unchanged, - None if within == Within::Xdp => { - let root = parsed.root_element(); - let children = fresh_children(warnings); - match append_edit(src, root, &fresh_datasets(&children)) { - Some(e) => edits.push(e), - None => { - warnings.push("The XFA packets could not be extended with data; the field values were not written".into()); - return Merged::Failed; - } - } - } - None => { - warnings.push("The XFA datasets packet holds no datasets element; the field values were not written to it".into()); - return Merged::Failed; - } - Some(ds) => match ds.children().find(|n| is(n, "data")) { - Some(d) => data = Some(d), - None if matches!(op, Op::Remove(_) | Op::Picture(..)) => return Merged::Unchanged, - None => { - let prefix = qname(src, &ds.range()).and_then(|q| q.split_once(':')).map(|(p, _)| format!("{p}:")).unwrap_or_default(); - let children = fresh_children(warnings); - let element = format!("<{prefix}data>{}", children); - match append_edit(src, ds, &element) { - Some(e) => edits.push(e), - None => { - warnings.push("The XFA datasets could not be extended with data; the field values were not written".into()); - return Merged::Failed; - } - } - } - }, - } - if let Some(data) = data { - match op { - Op::Values(w) => merge_values(src, data, w, &mut edits, warnings), - Op::ReplaceData(nodes) => { - // Everything between the start and end tags; `` opens up. Written - // with the prefix the packet binds to the data namespace. - let prefix = xfa_prefix(data); - let markup = serialize_nodes(nodes, prefix.as_deref().unwrap_or("xfa")); - let range = data.range(); - match (start_tag_end(src, &range), qname(src, &range)) { - (Some(tag_end), Some(name)) if tag_end + 1 == range.end && src.get(..tag_end).is_some_and(|s| s.ends_with('/')) => { - edits.push(Edit { start: range.start, end: range.end, text: format!("<{name}>{markup}") }); - } - (Some(tag_end), _) => { - let close = src.get(range.clone()).and_then(|s| s.rfind(" edits.push(Edit { start: tag_end + 1, end: close, text: markup }), - _ => { - warnings.push("The XFA data element could not be rewritten; the data was not imported".into()); - return Merged::Failed; - } - } - } - _ => { - warnings.push("The XFA data element could not be rewritten; the data was not imported".into()); - return Merged::Failed; - } - } - } - Op::Picture(path, content_type, base64) => { - let mut node = Some(data); - for (name, idx) in path.iter() { - let want = xml_name(name); - node = node.and_then(|n| n.children().filter(|c| c.is_element() && c.tag_name().name() == want).nth(*idx)); - } - let Some(n) = node else { return Merged::Unchanged }; - if n.children().any(|c| c.is_element()) { - // A data group, not a leaf: a picture would wipe what's under it. - skipped(warnings, STRUCTURED, &path.iter().map(|(n, i)| format!("{n}[{i}]")).collect::>().join(".")); - return Merged::Failed; - } - let name = qname(src, &n.range()).unwrap_or("value").to_string(); - let (ct, _) = escape(content_type); - let attr = match xfa_prefix(n) { - Some(p) => format!("{p}:contentType=\"{ct}\""), - None => format!("xmlns:xfa=\"{XFA_DATA_NS}\" xfa:contentType=\"{ct}\""), - }; - edits.push(Edit { start: n.range().start, end: n.range().end, text: format!("<{name} {attr}>{base64}") }); - } - Op::Remove(path) => { - // The element at `path`, with the whitespace before it. - let mut node = Some(data); - for (name, idx) in path { - node = node.and_then(|n| n.children().filter(|c| c.is_element() && c.tag_name().name() == name).nth(*idx)); - } - if let Some(n) = node { - let r = n.range(); - let start = src.get(..r.start).map_or(r.start, |before| r.start - before.len() + before.trim_end().len()); - edits.push(Edit { start, end: r.end, text: String::new() }); - } - } - } - } - if edits.is_empty() { - return Merged::Unchanged; - } - // Apply from the end, so earlier positions stay valid; overlapping edits (two fields bound - // to one node) keep the first. - edits.sort_by_key(|e| (e.start, e.end)); - let mut kept: Vec = Vec::with_capacity(edits.len()); - for e in edits { - if kept.last().is_some_and(|k| e.start < k.end) { - if kept.last().is_some_and(|k| k.text != e.text) && !warnings.iter().any(|w| w == CONFLICT) { - warnings.push(CONFLICT.into()); - } - continue; - } - kept.push(e); - } - let mut out = src.to_string(); - for e in kept.iter().rev() { - if out.get(e.start..e.end).is_none() { - warnings.push("The XFA data could not be updated; the field values were not written to it".into()); - return Merged::Failed; - } - out.replace_range(e.start..e.end, &e.text); - } - if shift > 0 { - match out.get(shift..out.len().saturating_sub(WRAP_CLOSE.len())) { - Some(inner) => out = inner.to_string(), - None => return Merged::Failed, - } - } - Merged::Changed(out) -} - -/// The edits that put `wants` into the `data` element. -fn merge_values(src: &str, data: roxmltree::Node, wants: &[Want], edits: &mut Vec, warnings: &mut Vec) { - // Element children by local name, per element visited (built once each). - let mut index: HashMap>> = HashMap::new(); - let tree = data.document(); - // Nodes to add, per existing element they go into. - let mut added: Vec<(roxmltree::NodeId, Build)> = Vec::new(); - let mut budget = NewNodes::new(); - for w in wants { - let (escaped, dropped) = escape(&w.text); - let mut node = data; - let mut depth = 0; - let mut have = 0; - for (name, idx) in &w.path { - let kids = index.entry(node.id()).or_insert_with(|| { - let mut by: HashMap> = HashMap::new(); - for c in node.children().filter(|c| c.is_element()) { - by.entry(c.tag_name().name().to_string()).or_default().push(c.id()); - } - by - }); - let same = kids.get(name.as_str()); - match same.and_then(|v| v.get(*idx)).and_then(|id| tree.get_node(*id)) { - Some(next) => { - node = next; - depth += 1; - } - None => { - have = same.map_or(0, Vec::len); - break; - } - } - } - if depth == w.path.len() { - // The data node exists: give it the value, unless it already holds it. A node - // only wanted to exist (a row instance) is left as it is. - if w.ensure && w.text.is_empty() { - continue; - } - let current: String = node.children().filter(|c| c.is_text()).filter_map(|c| c.text()).collect(); - if node.children().any(|c| c.is_element()) { - let all: String = node.descendants().filter(|c| c.is_text()).filter_map(|c| c.text()).collect(); - // Ignore existing XML indentation, but not whitespace in the accepted value. - // A skipped mismatch must warn: relayout would otherwise use stale data. - if all.trim() != w.text { - skipped(warnings, STRUCTURED, &w.som); - } - continue; - } - if current == w.text { - continue; - } - match replace_edit(src, node, &escaped) { - Some(e) => edits.push(e), - None => skipped(warnings, STRUCTURED, &w.som), - } - } else { - // Missing: add the rest of the path below the deepest node that exists. An empty - // value needs no node, unless the node itself is wanted (a new instance). - if w.text.is_empty() && !w.ensure { - continue; - } - let mut rest: DataPath = w.path.get(depth..).unwrap_or_default().to_vec(); - if let Some(first) = rest.first_mut() { - first.1 = first.1.saturating_sub(have); - } - let pos = match added.iter().position(|(id, _)| *id == node.id()) { - Some(p) => p, - None => { - added.push((node.id(), Build::default())); - added.len() - 1 - } - }; - let built = added.get_mut(pos).and_then(|(_, b)| b.at(&rest, &mut budget)); - match built { - Some(b) => b.text = w.text.clone(), - None => { - skipped(warnings, TOO_MANY, &w.som); - continue; - } - } - } - if dropped { - skipped(warnings, CONTROL, &w.som); - } - } - for (id, build) in added { - let Some(node) = tree.get_node(id) else { continue }; - let mut inner = String::new(); - build.write_children(&mut inner); - match append_edit(src, node, &inner) { - Some(e) => edits.push(e), - None => warnings.push("Some XFA data nodes could not be added; their field values were not written".into()), - } - } -} - -/// What [`write_datasets`] did. -#[derive(Clone, Debug, Default, PartialEq)] -pub struct DatasetsWrite { - /// The datasets packet changed (a new revision of it was added to the document). - pub written: bool, - /// Values that could not be written, and why. - pub warnings: Vec, - /// The datasets stream written, when one was. - pub stream: Option, -} - -/// A packet stream's text and encoding; `None` (with a warning) when it can't be read exactly. -fn packet_text(doc: &Document, o: &Object, warnings: &mut Vec) -> Option<(String, Encoding)> { - let s = doc.resolve(o); - let Object::Stream(s) = &*s else { - warnings.push("The XFA data packet is not a stream; the field values were not written to it".into()); - return None; - }; - let bytes = match s.decoded_within(64 << 20) { - Ok(b) => b, - Err(e) => { - warnings.push(format!("The XFA data could not be decoded ({e}); the field values were not written to it")); - return None; - } - }; - let decoded = crate::packets::decode(&bytes); - if decoded.is_none() { - warnings.push("The XFA data is neither UTF-8 nor UTF-16; the field values were not written to it, so it stays as it was".into()); - } - decoded -} - -/// Write the fields' values into the datasets packet, keeping all the data no field binds to. -/// Nothing is written when the document has no XFA entry or the data already holds the values; -/// values that can't be written are reported, never silently dropped. -pub fn write_datasets(doc: &mut Document, fields: &[FieldDatum]) -> Result { - write_datasets_reusing(doc, fields, None) -} - -/// [`write_datasets`], replacing the stream `reuse` in place when the packet is that stream -/// (written earlier in the same edit) rather than adding another. -pub fn write_datasets_reusing(doc: &mut Document, fields: &[FieldDatum], reuse: Option) -> Result { - write_datasets_with(doc, None, fields, reuse) -} - -/// [`write_datasets_reusing`] with the form's parsed template when the caller has it (it says -/// which SOM segments are areas, see [`data_path`]); `None` parses the document's. -pub fn write_datasets_with( - doc: &mut Document, - tpl: Option<&Template>, - fields: &[FieldDatum], - reuse: Option, -) -> Result { - let wants = with_template(doc, tpl, |t| wanted(doc, fields, t)); - rewrite(doc, &[Op::Values(&wants)], reuse) -} - /// One change to the data, for [`write_data_ops`]. #[derive(Clone, Debug, PartialEq)] pub enum DataOp { @@ -1316,688 +152,3 @@ impl DataNode { } } } - -fn path_som(path: &DataPath) -> String { - path.iter().map(|(n, i)| format!("{n}[{i}]")).collect::>().join(".") -} - -/// Make sure the data has at least `count` instances of `name` under `parent` (a script added -/// rows: the layout repeats the subform once per data instance). -pub fn add_data_instances(doc: &mut Document, parent: &DataPath, name: &str, count: usize) -> Result { - write_data_ops(doc, &[DataOp::Instances { parent: parent.clone(), name: name.to_string(), count }], None) -} - -/// Put `text` at the data node `path` (a field with no widget, a hidden one or a draw, that a -/// script gave a value), adding the node as needed. -pub fn write_data_value(doc: &mut Document, path: &DataPath, text: &str) -> Result { - write_data_ops(doc, &[DataOp::Value { path: path.clone(), text: text.to_string() }], None) -} - -/// Remove the data node at `path` (a script removed a row). -pub fn remove_data_instance(doc: &mut Document, path: &DataPath) -> Result { - write_data_ops(doc, &[DataOp::Remove(path.clone())], None) -} - -/// Largest picture written into the data (its bytes, before base64): with the 64 MB cap on -/// all packets together, several pictures still fit. -const MAX_DATA_IMAGE: usize = 8 << 20; -/// Longest data file imported. -const MAX_IMPORT: usize = 16 << 20; - -/// Put a picture into the data node at `path` as Designer saves one a user chose: base64, with -/// its content type (`image/png`, …) as `xfa:contentType` (in the packet's own prefix). The -/// node is made when missing; a data group is refused rather than wiped. `reuse`: the stream -/// this edit already wrote. -pub fn write_data_image( - doc: &mut Document, - path: &DataPath, - content_type: &str, - bytes: &[u8], - reuse: Option, -) -> Result { - use base64::Engine; - if path.is_empty() { - return Err(XfaError::Malformed("a picture needs a data path".into())); - } - if bytes.len() > MAX_DATA_IMAGE { - return Err(XfaError::TooLarge(format!("the picture is larger than {} MB", MAX_DATA_IMAGE >> 20))); - } - let ct: String = content_type.chars().filter(|c| c.is_ascii_alphanumeric() || matches!(c, '/' | '-' | '.' | '+')).take(64).collect(); - let b64 = base64::engine::general_purpose::STANDARD.encode(bytes); - let ensure = [Want { som: String::new(), path: path.clone(), text: String::new(), ensure: true }]; - rewrite(doc, &[Op::Values(&ensure), Op::Picture(path, &ct, &b64)], reuse) -} - -/// What an import of a data file did. -#[derive(Clone, Debug, Default, PartialEq, Eq)] -pub struct DataImport { - /// Element nodes the new data holds. - pub nodes: usize, - pub warnings: Vec, - /// The datasets stream written. - pub stream: Option, -} - -/// The data a file holds, as the children of `xfa:data`: an XDP or datasets document (Export -/// Data's output, Designer's), a bare data element, or a data document whose root is the -/// form's root data node (named `expect_root`, when the caller knows it). Anything else is -/// refused: a form's data is never replaced by what is not its data. -pub fn parse_data_file(text: &str, expect_root: Option<&str>) -> Result, XfaError> { - if text.len() > MAX_IMPORT { - return Err(XfaError::TooLarge(format!("the data file is larger than {} MB", MAX_IMPORT >> 20))); - } - let text = text.trim_start_matches('\u{feff}'); - if crate::xml_nesting_depth(text) > MAX_IMPORT_DEPTH { - return Err(XfaError::TooLarge(format!("the data file nests more than {MAX_IMPORT_DEPTH} levels deep"))); - } - let opts = roxmltree::ParsingOptions { allow_dtd: false, nodes_limit: MAX_NODES }; - let parsed = roxmltree::Document::parse_with_options(text, opts).map_err(|e| XfaError::Malformed(format!("the data file is not XML: {e}")))?; - let root = parsed.root_element(); - let local = root.tag_name().name(); - let in_xfa = |n: &roxmltree::Node| n.tag_name().namespace().is_none_or(|ns| ns == XFA_DATA_NS); - let is = |n: &roxmltree::Node, name: &str| n.is_element() && n.tag_name().name() == name && in_xfa(n); - let data = match local { - "xdp" => { - let ds = root.children().find(|n| is(n, "datasets")).ok_or_else(|| XfaError::Malformed("the XDP holds no datasets".into()))?; - ds.children().find(|n| is(n, "data")).ok_or_else(|| XfaError::Malformed("the datasets hold no data element".into()))? - } - "datasets" if in_xfa(&root) => { - root.children().find(|n| is(n, "data")).ok_or_else(|| XfaError::Malformed("the datasets hold no data element".into()))? - } - "data" if in_xfa(&root) => root, - _ => { - if root.tag_name().namespace().is_some() { - return Err(XfaError::Malformed(format!("<{local}> is not XFA form data"))); - } - if let Some(want) = expect_root - && local != want - { - return Err(XfaError::Malformed(format!("the data file's root is <{local}>; this form's data root is <{want}>"))); - } - let node = import_node(root, 0, &mut ImportBudget::new())?; - return Ok(vec![node]); - } - }; - let mut budget = ImportBudget::new(); - let mut out = Vec::new(); - for c in data.children().filter(|c| c.is_element()) { - out.push(import_node(c, 1, &mut budget)?); - } - Ok(out) -} - -/// Replace the form's data with what the file `text` holds (see [`parse_data_file`]), keeping -/// the rest of the datasets (data descriptions, say). `reuse`: the stream this edit already -/// wrote. -pub fn import_data(doc: &mut Document, text: &str, expect_root: Option<&str>, reuse: Option) -> Result { - let nodes = parse_data_file(text, expect_root)?; - fn count(n: &[ImportNode]) -> usize { - n.iter().map(|c| 1 + count(&c.children)).sum() - } - let r = rewrite(doc, &[Op::ReplaceData(&nodes)], reuse)?; - if !r.written { - return Err(XfaError::Malformed(r.warnings.first().cloned().unwrap_or_else(|| "the data could not be written".into()))); - } - Ok(DataImport { nodes: count(&nodes), warnings: r.warnings, stream: r.stream }) -} - -/// The datasets packet's text: from the XFA array's `datasets` item, or the whole XDP. -fn datasets_packet(doc: &Document) -> Result, XfaError> { - let Some(root) = doc.root() else { return Ok(None) }; - let catalog = doc.get(root); - let Some(acro) = catalog.as_dict().and_then(|c| c.get(b"AcroForm")).map(|a| doc.resolve(a)) else { return Ok(None) }; - let Some(xfa) = acro.as_dict().and_then(|a| a.get(b"XFA")).map(|x| doc.resolve(x)) else { return Ok(None) }; - let text_of = |o: &Object| -> Result, XfaError> { - match &*doc.resolve(o) { - Object::Stream(s) => Ok(crate::packets::decode(&s.decoded_within(64 << 20)?).map(|(t, _)| t)), - _ => Ok(None), - } - }; - match &*xfa { - Object::Array(items) => { - let at = (0..items.len()) - .step_by(2) - .find(|&i| items.get(i).and_then(|n| n.as_string()).is_some_and(|s| s.to_text() == "datasets") && i + 1 < items.len()); - match at.and_then(|i| items.get(i + 1)) { - Some(o) => text_of(o), - None => Ok(None), - } - } - Object::Stream(_) => text_of(&xfa), - _ => Ok(None), - } -} - -/// The data to export, as Acrobat's Export Data writes it: `xdp` false gives an XML document -/// holding the data alone (the form's root data node at the root, or the data element when -/// there are several); true gives an XDP holding the whole datasets and a reference to the -/// PDF, named `pdf_name`. A document whose packets can't be read is an error, not empty data. -pub fn export_data(doc: &Document, xdp: bool, pdf_name: &str) -> Result { - if crate::read_packets(doc)?.is_none() { - return Err(XfaError::NotXfa); - } - let packet = datasets_packet(doc)?; - let (datasets, data) = match &packet { - Some(text) => match datasets_markup(text) { - Ok(Some(m)) => m, - Ok(None) => (fresh_datasets(""), String::new()), - Err(()) => return Err(XfaError::Malformed("the XFA data could not be read".into())), - }, - None => (fresh_datasets(""), String::new()), - }; - let mut out = String::from("\n"); - if xdp { - let (name, _) = escape(pdf_name); - out.push_str("\n"); - out.push_str(&datasets); - out.push_str(&format!("\n\n\n")); - } else { - out.push_str(if data.is_empty() { &datasets } else { &data }); - out.push('\n'); - } - Ok(out) -} - -/// The `xfa:datasets` element of `text` as written, and the data alone (its one child, or the -/// data element), each with the namespaces it uses declared on it. `Ok(None)`: no datasets -/// element; `Err`: the text can't be read. -fn datasets_markup(text: &str) -> Result, ()> { - let opts = roxmltree::ParsingOptions { allow_dtd: false, nodes_limit: MAX_NODES }; - if crate::xml_nesting_depth(text) > crate::MAX_XML_DEPTH { - return Err(()); - } - let wrapped = format!("{WRAP_OPEN}{text}{WRAP_CLOSE}"); - let (src, parsed) = match roxmltree::Document::parse_with_options(text, opts) { - Ok(d) => (text, d), - Err(_) => (wrapped.as_str(), roxmltree::Document::parse_with_options(&wrapped, opts).map_err(|_| ())?), - }; - let Some(ds) = parsed.descendants().find(|n| n.is_element() && n.tag_name().name() == "datasets") else { return Ok(None) }; - let data = ds.children().find(|n| n.is_element() && n.tag_name().name() == "data"); - let data_alone = match data { - Some(d) => { - let kids: Vec = d.children().filter(|c| c.is_element()).collect(); - match kids.as_slice() { - [one] => element_markup(src, *one).ok_or(())?, - _ => element_markup(src, d).ok_or(())?, - } - } - None => String::new(), - }; - Ok(Some((element_markup(src, ds).ok_or(())?, data_alone))) -} - -/// `n` as written in `src`, with the namespace declarations in scope that its start tag lacks -/// added to it, so it stands alone. -fn element_markup(src: &str, n: roxmltree::Node) -> Option { - let range = n.range(); - let text = src.get(range.clone())?; - let tag_end = start_tag_end(src, &range)?; - let start_tag = src.get(range.start..tag_end)?; - let mut decls = String::new(); - for ns in n.namespaces() { - let attr = match ns.name() { - Some(p) => format!("xmlns:{p}="), - None => "xmlns=".to_string(), - }; - if !start_tag.contains(&attr) { - decls.push_str(&format!(" {attr}\"{}\"", escape(ns.uri()).0)); - } - } - if decls.is_empty() { - return Some(text.to_string()); - } - let name_end = qname(src, &range)?.len() + 1; - Some(format!("{}{decls}{}", text.get(..name_end)?, text.get(name_end..)?)) -} - -/// Most instance nodes one [`write_data_ops`] queues over all its passes (a removal starts a new -/// pass, which asks for the instances again). -const MAX_ENSURED: usize = 200_000; - -// Instance nodes write_data_ops has queued on this thread, for tests that bound its work. -#[cfg(test)] -thread_local! { - pub(crate) static ENSURED: std::cell::Cell = const { std::cell::Cell::new(0) }; -} - -/// Apply `ops` to the datasets packet, in order, as one rewrite of it: everything one script -/// event changed becomes a single new datasets stream. When the packet is already the stream -/// `reuse` (written earlier for the same event), that stream is replaced in place instead of -/// a new one being added. The stream written is in the report. -pub fn write_data_ops(doc: &mut Document, ops: &[DataOp], reuse: Option) -> Result { - // Runs of values (and instances) merge in one pass; a value set twice keeps the last. - let mut steps: Vec> = Vec::new(); - let mut removes: Vec> = Vec::new(); - let mut run: Vec = Vec::new(); - // Instances queued in this run, by parent and name: asking for more only queues the rest. - let mut queued: HashMap<(&DataPath, &str), usize> = HashMap::new(); - let (mut ensured, mut capped) = (0usize, false); - let flush = |run: &mut Vec, steps: &mut Vec>, removes: &mut Vec>| { - if !run.is_empty() { - let mut seen = std::collections::HashSet::new(); - let mut kept: Vec = std::mem::take(run).into_iter().rev().filter(|w| seen.insert((w.ensure, w.path.clone()))).collect(); - kept.reverse(); - steps.push(kept); - removes.push(None); - } - }; - for op in ops { - match op { - DataOp::Value { path, text } if !path.is_empty() => { - // An empty value clears an existing node and needs no new one. - run.push(Want { som: path_som(path), path: path.clone(), text: text.clone(), ensure: false }); - } - DataOp::Instances { parent, name, count } => { - let done = queued.entry((parent, name.as_str())).or_insert(0); - while *done < (*count).min(MAX_INDEX) { - if ensured >= MAX_ENSURED { - capped = true; - break; - } - ensured += 1; - #[cfg(test)] - ENSURED.with(|n| n.set(n.get() + 1)); - let k = *done; - *done += 1; - let mut path = parent.clone(); - path.push((name.clone(), k)); - run.push(Want { som: format!("{name}[{k}]"), path, text: String::new(), ensure: true }); - } - } - DataOp::Remove(path) if !path.is_empty() => { - flush(&mut run, &mut steps, &mut removes); - queued.clear(); - steps.push(Vec::new()); - removes.push(Some(path.clone())); - } - _ => {} - } - } - flush(&mut run, &mut steps, &mut removes); - let plan: Vec = steps - .iter() - .zip(&removes) - .map(|(w, r)| match r { - Some(p) => Op::Remove(p), - None => Op::Values(w), - }) - .collect(); - let mut written = rewrite(doc, &plan, reuse)?; - if capped { - written.warnings.push(format!("XFA data instances not written past {MAX_ENSURED}: the rows were added and removed too often")); - } - Ok(written) -} - -/// Put `bytes` in the document as a datasets stream: in place of `old` when that is the stream -/// `reuse` or already an unsaved overlay stream of `doc`, else as a new object. -fn put_stream(doc: &mut Document, old: Option<&Object>, reuse: Option, bytes: &[u8]) -> ObjRef { - let stream = Object::Stream(Stream::flate(Dict::new(), bytes)); - match old.and_then(Object::as_ref) { - Some(r) if reuse == Some(r) || doc.is_edited(r.num) => { - doc.set(r, stream); - r - } - _ => doc.add(stream), - } -} - -/// Merge `ops` into `text` in order. `None`: nothing changed (or nothing could be merged). -fn merge_all(text: &str, within: Within, ops: &[Op], warnings: &mut Vec) -> Option { - let mut current: Option = None; - for op in ops { - match merge(current.as_deref().unwrap_or(text), within, *op, warnings) { - Merged::Changed(t) => current = Some(t), - Merged::Unchanged => {} - // Later steps still apply to what merged so far; the reason is in the warnings. - Merged::Failed => {} - } - } - current -} - -/// Longest packet text written (the packets together are read up to 64 MB). -const MAX_PACKET_WRITE: usize = 48 << 20; - -/// Is `text` a packet the reader will take back (well-formed, within size)? A rewrite that -/// isn't is not written: better the old data than an unreadable packet. -fn readable(text: &str, within: Within, warnings: &mut Vec) -> bool { - if text.len() > MAX_PACKET_WRITE { - warnings.push(format!("The XFA data would be larger than {} MB; it was not written", MAX_PACKET_WRITE >> 20)); - return false; - } - let opts = roxmltree::ParsingOptions { allow_dtd: false, nodes_limit: MAX_NODES }; - let ok = roxmltree::Document::parse_with_options(text, opts).is_ok() - || (within == Within::Packet && roxmltree::Document::parse_with_options(&format!("{WRAP_OPEN}{text}{WRAP_CLOSE}"), opts).is_ok()); - if !ok { - warnings.push("The XFA data would not be well-formed XML after this change; it was not written".into()); - } - ok -} - -fn rewrite(doc: &mut Document, ops: &[Op], reuse: Option) -> Result { - let mut report = DatasetsWrite::default(); - if ops.is_empty() { - return Ok(report); - } - let Some(root) = doc.root() else { return Ok(report) }; - let catalog = doc.get(root); - let Some(acro_obj) = catalog.as_dict().and_then(|c| c.get(b"AcroForm")).cloned() else { return Ok(report) }; - let acro_ref = acro_obj.as_ref(); - let Some(mut acro) = doc.resolve(&acro_obj).as_dict().cloned() else { return Ok(report) }; - let Some(xfa) = acro.get(b"XFA").cloned() else { return Ok(report) }; - let warnings = &mut report.warnings; - match &*doc.resolve(&xfa) { - Object::Array(items) => { - let mut items = items.clone(); - let at = (0..items.len()) - .step_by(2) - .find(|&i| items.get(i).and_then(|n| n.as_string()).is_some_and(|s| s.to_text() == "datasets") && i + 1 < items.len()); - match at { - Some(i) => { - let Some(existing) = items.get(i + 1).cloned() else { return Ok(report) }; - let Some((text, encoding)) = packet_text(doc, &existing, warnings) else { return Ok(report) }; - let Some(t) = merge_all(&text, Within::Packet, ops, warnings) else { return Ok(report) }; - if !readable(&t, Within::Packet, warnings) { - return Ok(report); - } - let r = put_stream(doc, Some(&existing), reuse, &encode(&t, encoding)); - report.stream = Some(r); - if let Some(slot) = items.get_mut(i + 1) { - *slot = Object::Ref(r); - } - } - None => { - // No datasets packet yet: made from the first values, then the rest merged - // in; before the postamble, or last. - let Some(first) = ops.iter().position(|op| matches!(op, Op::Values(_))) else { return Ok(report) }; - let Some(Op::Values(wants)) = ops.get(first) else { return Ok(report) }; - let fresh = fresh_datasets(&build_children(wants, warnings)); - let text = merge_all(&fresh, Within::Packet, ops.get(first + 1..).unwrap_or_default(), warnings).unwrap_or(fresh); - if !readable(&text, Within::Packet, warnings) { - return Ok(report); - } - let r = doc.add(Object::Stream(Stream::flate(Dict::new(), text.as_bytes()))); - report.stream = Some(r); - let pos = items.iter().position(|n| n.as_string().is_some_and(|s| s.to_text() == "postamble")).unwrap_or(items.len()); - items.insert(pos, Object::Ref(r)); - items.insert(pos, Object::String(PdfString::text("datasets"))); - } - } - acro.set(b"XFA".to_vec(), Object::Array(items)); - } - Object::Stream(_) => { - let Some((text, encoding)) = packet_text(doc, &xfa, warnings) else { return Ok(report) }; - let Some(new) = merge_all(&text, Within::Xdp, ops, warnings) else { return Ok(report) }; - if !readable(&new, Within::Xdp, warnings) { - return Ok(report); - } - let r = put_stream(doc, Some(&xfa), reuse, &encode(&new, encoding)); - report.stream = Some(r); - acro.set(b"XFA".to_vec(), Object::Ref(r)); - } - _ => return Ok(report), - } - match acro_ref { - Some(ar) => doc.set(ar, Object::Dict(acro)), - None => doc.update_dict(root, |c| c.set(b"AcroForm".to_vec(), Object::Dict(acro)))?, - } - report.written = true; - Ok(report) -} - -/// The values the datasets hold for `fields`: `(field name, what it should hold)`, only for -/// fields the data mentions. -pub fn read_values(doc: &Document, fields: &[FieldDatum]) -> Vec<(String, FieldData)> { - read_values_with(doc, None, fields) -} - -/// [`read_values`] with the form's parsed template when the caller has it; `None` parses the -/// document's. -pub fn read_values_with(doc: &Document, tpl: Option<&Template>, fields: &[FieldDatum]) -> Vec<(String, FieldData)> { - with_template(doc, tpl, |t| values_at(doc, t, fields)) -} - -/// The values the document's datasets hold for `fields`, read in each field's data picture and -/// shown in its own pattern; only fields the data mentions. -fn values_at(doc: &Document, tpl: Option<&Template>, fields: &[FieldDatum]) -> Vec<(String, FieldData)> { - let Ok(Some(p)) = crate::read_packets(doc) else { return Vec::new() }; - let Some(data) = parse_datasets(&p.xdp) else { return Vec::new() }; - let mut out = Vec::new(); - for f in fields { - let som = som_of(doc, f); - let path = path_of(tpl, &som); - let Some(text) = data.value_at(&path) else { continue }; - let text = text.as_str(); - let value = match &f.data { - FieldData::Text(_) => FieldData::Text(match date_pattern_of(doc, f) { - Some(pat) => iso_to_pattern(&data_to_iso(text, data_date_pattern(tpl, &som).as_deref()), &pat), - None => text.to_string(), - }), - FieldData::Check(_) => { - let (on, _) = items_of(doc, f); - FieldData::Check(is_on(text, &on)) - } - FieldData::Radio(_) => FieldData::Radio((!text.trim().is_empty()).then(|| text.trim().to_string())), - FieldData::None => continue, - }; - out.push((f.name.clone(), value)); - } - out -} - -/// Does a data value mean "checked" for a button whose on value is `on`? -pub fn is_on(text: &str, on: &str) -> bool { - let t = text.trim(); - if t == on { - return true; - } - !matches!(t.to_ascii_lowercase().as_str(), "" | "0" | "off" | "false" | "no") && on.trim().is_empty() -} - -/// The datasets stream's object reference in `doc` (the `datasets` item in the `/XFA` array, or -/// the single `/XFA` stream), when `doc` has one. -fn datasets_stream_ref(doc: &Document) -> Option { - let root = doc.root()?; - let catalog = doc.get(root); - let acro = catalog.as_dict()?.get(b"AcroForm").map(|a| doc.resolve(a))?; - let xfa = acro.as_dict()?.get(b"XFA")?; - match &*doc.resolve(xfa) { - Object::Array(items) => { - let at = (0..items.len()) - .step_by(2) - .find(|&i| items.get(i).and_then(|n| n.as_string()).is_some_and(|s| s.to_text() == "datasets") && i + 1 < items.len())?; - items.get(at + 1)?.as_ref() - } - Object::Stream(_) => xfa.as_ref(), - _ => None, - } -} - -/// The SOM path and default value of every AcroForm text field with the Password flag (bit 14, -/// `1 << 13`) in `doc`, for static XFA forms whose password fields set the flag in the AcroForm -/// dictionary. -fn acro_password_fields(doc: &Document) -> Vec<(String, String)> { - const PASSWORD_FLAG: i64 = 1 << 13; - let Some(root) = doc.root() else { return Vec::new() }; - let catalog = doc.get(root); - let Some(acro) = catalog.as_dict().and_then(|c| c.get(b"AcroForm")).map(|a| doc.resolve(a)) else { return Vec::new() }; - let Some(top) = acro.as_dict().and_then(|a| a.get(b"Fields")).map(|f| doc.resolve(f)).and_then(|f| f.as_array().cloned()) else { - return Vec::new(); - }; - struct AcroWalk<'a> { - doc: &'a Document, - seen: std::collections::HashSet, - out: Vec<(String, String)>, - } - impl AcroWalk<'_> { - fn walk( - &mut self, - items: &[Object], - parent_name: &str, - inherited_ft: Option<&[u8]>, - inherited_ff: i64, - inherited_dv: Option<&str>, - depth: usize, - ) { - if depth > 32 { - return; - } - for item in items { - let Some(r) = item.as_ref() else { continue }; - if !self.seen.insert(r) { - continue; - } - let obj = self.doc.get(r); - let Some(d) = obj.as_dict() else { continue }; - let ft = d.name(b"FT").or(inherited_ft); - let ff = d.int(b"Ff").unwrap_or(inherited_ff); - let dv_own = text_of(self.doc, d, b"DV"); - let dv = dv_own.as_deref().or(inherited_dv); - let t = text_of(self.doc, d, b"T"); - let full_name = match (&t, parent_name.is_empty()) { - (Some(seg), true) => seg.clone(), - (Some(seg), false) => format!("{parent_name}.{seg}"), - (None, _) => parent_name.to_string(), - }; - let kids = d.get(b"Kids").map(|k| self.doc.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_default(); - let has_named_kid = kids.iter().filter_map(Object::as_ref).any(|kr| self.doc.get(kr).as_dict().is_some_and(|kd| kd.contains(b"T"))); - if !has_named_kid && ft == Some(b"Tx") && (ff & PASSWORD_FLAG) != 0 { - let som = text_of(self.doc, d, crate::pdf::SOM_KEY).unwrap_or_else(|| full_name.clone()); - if !som.is_empty() { - self.out.push((som, dv.unwrap_or_default().to_string())); - } - } - if !kids.is_empty() { - self.walk(&kids, &full_name, ft, ff, dv, depth + 1); - } - } - } - } - let mut w = AcroWalk { doc, seen: std::collections::HashSet::new(), out: Vec::new() }; - w.walk(&top, "", None, 0, None, 0); - w.out -} - -/// The `Want` entries needed to restore password fields in `doc`'s datasets packet to their -/// defaults (or empty when they have no default). -fn password_dataset_wants(doc: &Document) -> Vec { - if datasets_stream_ref(doc).is_none() { - return Vec::new(); - } - let Ok(Some(packet)) = datasets_packet(doc) else { return Vec::new() }; - let Some(data) = parse_datasets(&packet) else { return Vec::new() }; - with_template(doc, None, |tpl| { - let mut candidates: Vec<(String, DataPath, String)> = Vec::new(); - let mut seen = std::collections::HashSet::new(); - if let Some(t) = tpl { - struct Walk<'a> { - tpl: &'a Template, - data: &'a DataNode, - nodes: usize, - seen: &'a mut std::collections::HashSet, - out: &'a mut Vec<(String, DataPath, String)>, - } - impl Walk<'_> { - fn child_som(parent: &str, name: Option<&str>, index: usize) -> String { - match name { - Some(n) if !parent.is_empty() => format!("{parent}.{n}[{index}]"), - Some(n) => format!("{n}[{index}]"), - None => parent.to_string(), - } - } - fn walk(&mut self, children: &[Node], parent_som: &str, depth: usize) { - if depth > MAX_DEPTH || self.nodes >= crate::script::MAX_FORM_NODES { - return; - } - let sibs = SomIndices::of(children); - for (i, n) in children.iter().enumerate() { - if self.nodes >= crate::script::MAX_FORM_NODES { - break; - } - self.nodes += 1; - let sib = sibs.at(i); - match n { - Node::Subform(sf) => { - let from_data = match &sf.common.name { - Some(name) if sf.occur.max != Some(1) => self.data.count(&data_path(self.tpl, parent_som), name), - _ => 0, - }; - let instances = crate::layout::instance_count(&sf.occur, from_data).max(from_data); - for k in 0..instances { - if k > 0 { - if self.nodes >= crate::script::MAX_FORM_NODES { - break; - } - self.nodes += 1; - } - let som = Self::child_som(parent_som, sf.common.name.as_deref(), sib + k); - self.walk(&sf.children, &som, depth + 1); - } - } - Node::Area(a) => { - let som = Self::child_som(parent_som, a.common.name.as_deref(), sib); - self.walk(&a.children, &som, depth + 1); - } - Node::Field(f) if f.ui == Ui::PasswordEdit => { - let som = Self::child_som(parent_som, f.common.name.as_deref().or(Some("field")), sib); - let path = data_path(self.tpl, &som); - if !path.is_empty() && self.seen.insert(path.clone()) { - let default = f.value.plain().map(|v| f.saved_value(v.trim())).unwrap_or_default(); - self.out.push((som, path, default)); - } - } - _ => {} - } - } - } - } - let root = Node::Subform(Box::new(t.root.clone())); - let mut w = Walk { tpl: t, data: &data, nodes: 0, seen: &mut seen, out: &mut candidates }; - w.walk(std::slice::from_ref(&root), "", 0); - if let Some(ps) = &t.root.page_set { - for (i, area) in ps.areas.iter().enumerate() { - let som = area.name.clone().map(|n| format!("{n}[0]")).unwrap_or_else(|| format!("#pageArea[{i}]")); - w.walk(&area.items, &som, 1); - } - } - } - for (som, default) in acro_password_fields(doc) { - let path = path_of(tpl, &som); - if !path.is_empty() && seen.insert(path.clone()) { - candidates.push((som, path, default)); - } - } - candidates - .into_iter() - .filter_map(|(som, path, default)| { - let cur = data.value_at(&path)?; - (!cur.is_empty() && cur != default).then_some(Want { som, path, text: default, ensure: false }) - }) - .collect() - }) -} - -/// Whether any XFA password field (`` or an AcroForm text field with the Password -/// flag) holds a value in `doc`'s datasets packet beyond its default (#3944). -pub fn has_password_values(doc: &Document) -> bool { - !password_dataset_wants(doc).is_empty() -} - -/// Clear typed password values from `doc`'s XFA datasets packet (restoring a template default -/// where the field has one, else emptying the element), replacing the datasets stream in place -/// (#3944). Returns how many password fields were cleared in the datasets. -pub fn clear_password_values(doc: &mut Document) -> Result { - let wants = password_dataset_wants(doc); - if wants.is_empty() { - return Ok(0); - } - let count = wants.len(); - let reuse = datasets_stream_ref(doc); - let report = rewrite(doc, &[Op::Values(&wants)], reuse)?; - if !report.written || !password_dataset_wants(doc).is_empty() { - let reason = report.warnings.first().map_or("the XFA datasets password values could not be cleared", String::as_str); - return Err(XfaError::Malformed(reason.to_string())); - } - Ok(count) -} diff --git a/crates/xfa/src/data/build.rs b/crates/xfa/src/data/build.rs new file mode 100644 index 000000000..68e8a2a36 --- /dev/null +++ b/crates/xfa/src/data/build.rs @@ -0,0 +1,164 @@ +use super::values::{FieldDatum, Want, wanted, with_template}; +use super::*; + +pub(super) fn xml_name(s: &str) -> String { + let mut out: String = s.chars().map(|c| if c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.' { c } else { '_' }).collect(); + if out.is_empty() || !(out.starts_with(|c: char| c.is_ascii_alphabetic() || c == '_')) { + out.insert(0, '_'); + } + out +} + +/// `s` as XML character data. Characters XML 1.0 cannot hold are left out; the flag says +/// whether any were. +pub(super) fn escape(s: &str) -> (String, bool) { + let mut out = String::with_capacity(s.len()); + let mut dropped = false; + for c in s.chars() { + match c { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '"' => out.push_str("""), + // Parsers turn a raw carriage return into a line feed; a reference keeps it. + '\r' => out.push_str(" "), + '\u{FFFE}' | '\u{FFFF}' => dropped = true, + c if c.is_control() && c != '\n' && c != '\t' => dropped = true, + c => out.push(c), + } + } + (out, dropped) +} + +/// Most data nodes one write may create (instances skipped by a SOM index count too). +const MAX_NEW_NODES: usize = 20_000; + +/// Highest same-name sibling index a write follows. +pub(super) const MAX_INDEX: usize = 10_000; + +/// Most bytes of names the nodes one write creates hold together: every sibling an index +/// skips over gets its own copy of the name. +const MAX_NEW_NAME_BYTES: usize = 4 << 20; + +/// What one write may still create: nodes and bytes of their names. +pub(super) struct NewNodes { + nodes: usize, + name_bytes: usize, +} + +impl NewNodes { + pub(super) fn new() -> NewNodes { + NewNodes { nodes: MAX_NEW_NODES, name_bytes: MAX_NEW_NAME_BYTES } + } +} + +/// A data tree being built for writing. +#[derive(Default)] +pub(super) struct Build { + name: String, + pub(super) text: String, + children: Vec, + /// Positions in `children` by name, in order. + by_name: HashMap>, +} + +impl Build { + /// The node at `path` below this one, created as needed. `None` when the path is deeper + /// than [`MAX_DEPTH`], an index is above [`MAX_INDEX`], or creating it would spend more + /// new nodes or name bytes than `budget` holds. + pub(super) fn at(&mut self, path: &[(String, usize)], budget: &mut NewNodes) -> Option<&mut Build> { + if path.len() > MAX_DEPTH { + return None; + } + let mut node = self; + for (name, idx) in path { + if *idx > MAX_INDEX { + return None; + } + let name = xml_name(name); + let have = node.by_name.get(&name).map_or(0, Vec::len); + if *idx >= have { + let need = idx - have + 1; + let bytes = need.saturating_mul(name.len()); + if need > budget.nodes || bytes > budget.name_bytes { + return None; + } + budget.nodes -= need; + budget.name_bytes -= bytes; + let start = node.children.len(); + node.children.extend((0..need).map(|_| Build { name: name.clone(), ..Default::default() })); + node.by_name.entry(name.clone()).or_default().extend(start..start + need); + } + let pos = *node.by_name.get(&name)?.get(*idx)?; + node = node.children.get_mut(pos)?; + } + Some(node) + } + + /// The tree as XML. Depth is bounded by [`Build::at`]. + fn write(&self, out: &mut String) { + if self.children.is_empty() { + out.push_str(&format!("<{}>{}", self.name, escape(&self.text).0, self.name)); + return; + } + out.push_str(&format!("<{}>", self.name)); + self.write_children(out); + out.push_str(&format!("", self.name)); + } + + pub(super) fn write_children(&self, out: &mut String) { + for c in &self.children { + c.write(out); + } + } +} + +/// Note that a value could not be written (once per kind of reason, naming a few fields). +pub(super) fn skipped(warnings: &mut Vec, reason: &str, som: &str) { + const MAX_WARNINGS: usize = 20; + if let Some(w) = warnings.iter_mut().find(|w| w.starts_with(reason)) { + if w.matches(", ").count() < 4 { + w.push_str(&format!(", {som}")); + } else if !w.ends_with('…') { + w.push_str(", …"); + } + } else if warnings.len() < MAX_WARNINGS { + warnings.push(format!("{reason}: {som}")); + } +} + +pub(super) const TOO_MANY: &str = "XFA data not written for fields whose data path is too deep or repeats too often"; + +pub(super) const STRUCTURED: &str = "XFA data not written for fields whose data node holds structured content"; + +pub(super) const CONFLICT: &str = "XFA data not written for fields bound to the same data node as another field"; + +pub(super) const CONTROL: &str = "Characters XML can't hold were left out of the XFA data of"; + +/// Fresh data for `wants`: the children of an `xfa:data` element. +pub(super) fn build_children(wants: &[Want], warnings: &mut Vec) -> String { + let mut root = Build::default(); + let mut budget = NewNodes::new(); + for w in wants { + match root.at(&w.path, &mut budget) { + Some(node) => node.text = w.text.clone(), + None => skipped(warnings, TOO_MANY, &w.som), + } + if escape(&w.text).1 { + skipped(warnings, CONTROL, &w.som); + } + } + let mut out = String::new(); + root.write_children(&mut out); + out +} + +/// The `xfa:data` element for `fields`, built afresh. +pub fn build_data(doc: &Document, fields: &[FieldDatum]) -> String { + let wants = with_template(doc, None, |t| wanted(doc, fields, t)); + format!("{}", build_children(&wants, &mut Vec::new())) +} + +pub(super) fn fresh_datasets(data_children: &str) -> String { + format!("{data_children}") +} diff --git a/crates/xfa/src/data/dates.rs b/crates/xfa/src/data/dates.rs new file mode 100644 index 000000000..029d91b27 --- /dev/null +++ b/crates/xfa/src/data/dates.rs @@ -0,0 +1,193 @@ +use super::path::template_field; +use super::*; + +/// The date letters (`yyyy-mm-dd`, `yyyymmdd`) of an XFA date picture clause such as +/// `date{YYYY-MM-DD}`, or `None` when it has none this reads (empty, or with symbols beyond +/// years, months, days and separators). +pub(crate) fn date_letters(picture: &str) -> Option { + let inner = picture + .find('{') + .and_then(|a| { + let rest = picture.get(a + 1..)?; + rest.find('}').and_then(|b| rest.get(..b)) + }) + .unwrap_or(picture) + .trim(); + if inner.is_empty() || inner.chars().any(|c| !"YMDEJ-/., :".contains(c)) { + return None; + } + Some(inner.replace('E', "d").to_ascii_lowercase()) +} + +/// The date letters of `f`'s data picture, when it holds a whole date. A data picture without a +/// year, a month or a day can't hold the date the field shows, so such a field's data keeps +/// ISO dates. +pub(crate) fn data_date_letters(f: &Field) -> Option { + let letters = f.data_picture.as_deref().and_then(date_letters)?; + let tokens = date_tokens(&letters); + let has = |any: &[&str]| tokens.iter().any(|t| any.contains(&t.as_str())); + (has(&["yyyy", "yy"]) && has(&["mm", "m"]) && has(&["dd", "d"])).then_some(letters) +} + +/// A date as the data holds it, written in `data_pattern` (the field's data picture), read as +/// an ISO date. A value that doesn't match, or a field without a data picture, is taken as it +/// is: the data holds ISO dates by default. +pub(crate) fn data_to_iso(value: &str, data_pattern: Option<&str>) -> String { + data_pattern.and_then(|p| pattern_to_iso(value, p)).unwrap_or_else(|| value.to_string()) +} + +/// An ISO date as the data should hold it: in `data_pattern` when the field has one and the +/// date reads back from it unchanged (a two-digit year holds only 1950 to 2049), else as ISO. +pub(crate) fn iso_to_data(iso: String, data_pattern: Option<&str>) -> String { + match data_pattern { + Some(p) => { + let written = iso_to_pattern(&iso, p); + if pattern_to_iso(&written, p).as_deref() == Some(iso.as_str()) { written } else { iso } + } + None => iso, + } +} + +/// The date letters of the data picture of the field `som` names, when it has one. +pub(super) fn data_date_pattern(tpl: Option<&Template>, som: &str) -> Option { + tpl.and_then(|t| template_field(t, som)).and_then(data_date_letters) +} + +const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]; + +const WEEKDAYS: [&str; 7] = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; + +/// The weekday (0 = Sunday) of a proleptic Gregorian date. +fn weekday(y: u32, m: u32, d: u32) -> usize { + const T: [i64; 12] = [0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4]; + let y = i64::from(y) - i64::from(m < 3); + let t = T.get((m as usize).wrapping_sub(1)).copied().unwrap_or(0); + ((y + y / 4 - y / 100 + y / 400 + t + i64::from(d)).rem_euclid(7)) as usize +} + +/// The index and length of the name in `names` that `text` starts with (ignoring case), +/// cut to `abbr` letters when that is set. +fn name_at(text: &str, names: &[&str], abbr: bool) -> Option<(usize, usize)> { + names.iter().enumerate().find_map(|(i, n)| { + let n = if abbr { n.get(..3)? } else { n }; + text.get(..n.len()).filter(|t| t.eq_ignore_ascii_case(n)).map(|_| (i, n.len())) + }) +} + +/// Whether `token` is a run of the date letters (`y`, `m`, `d`) rather than a literal. +fn is_date_run(token: &str) -> bool { + token.starts_with(['y', 'm', 'd']) +} + +/// The tokens of a date pattern: runs of `y`, `m` and `d` (`yyyy`, `yy`, `mmmm`, `mmm`, `mm`, +/// `m`, `dddd`, `ddd`, `dd`, `d` are understood), or a literal character. +pub(crate) fn date_tokens(pattern: &str) -> Vec { + let mut out = Vec::new(); + let chars: Vec = pattern.chars().collect(); + let mut i = 0; + while i < chars.len() { + let c = chars[i].to_ascii_lowercase(); + if "ymd".contains(c) { + let mut j = i; + while j < chars.len() && chars[j].to_ascii_lowercase() == c { + j += 1; + } + out.push(std::iter::repeat_n(c, j - i).collect()); + i = j; + } else { + out.push(chars[i].to_string()); + i += 1; + } + } + out +} + +/// `2001-02-03` shown in `pattern` (`mm/dd/yyyy` → `02/03/2001`, `mmm d, yyyy` → +/// `Feb 3, 2001`). Patterns with a token this doesn't know and non-dates come back as they are. +pub fn iso_to_pattern(iso: &str, pattern: &str) -> String { + let parts: Vec<&str> = iso.trim().splitn(3, '-').collect(); + let [y, m, d] = parts[..] else { return iso.to_string() }; + let (Ok(y), Ok(m), Ok(d)) = (y.parse::(), m.parse::(), d.parse::()) else { return iso.to_string() }; + let mut out = String::new(); + for t in date_tokens(pattern) { + match t.as_str() { + "yyyy" => out.push_str(&format!("{y:04}")), + "yy" => out.push_str(&format!("{:02}", y % 100)), + "mm" => out.push_str(&format!("{m:02}")), + "m" => out.push_str(&m.to_string()), + "dd" => out.push_str(&format!("{d:02}")), + "d" => out.push_str(&d.to_string()), + "mmm" | "mmmm" => { + let Some(name) = m.checked_sub(1).and_then(|i| MONTHS.get(i as usize)) else { return iso.to_string() }; + match t.len() { + 3 => out.push_str(name.get(..3).unwrap_or(name)), + _ => out.push_str(name), + } + } + "ddd" | "dddd" => { + let name = WEEKDAYS.get(weekday(y, m, d)).copied().unwrap_or_default(); + match t.len() { + 3 => out.push_str(name.get(..3).unwrap_or(name)), + _ => out.push_str(name), + } + } + other if is_date_run(other) => return iso.to_string(), + other => out.push_str(other), + } + } + out +} + +/// A date typed as `pattern` back to `yyyy-mm-dd`. `None` when it doesn't match. +pub fn pattern_to_iso(text: &str, pattern: &str) -> Option { + let text = text.trim(); + let (mut y, mut m, mut d) = (None, None, None); + let mut day_name = None; + let mut rest = text; + let tokens = date_tokens(pattern); + for (i, t) in tokens.iter().enumerate() { + match t.as_str() { + "yyyy" | "yy" | "mm" | "m" | "dd" | "d" => { + // Digits up to the next literal (or a fixed width). + let fixed = matches!(t.as_str(), "yyyy" | "yy" | "mm" | "dd"); + let width = + if fixed { t.len() } else { rest.chars().take_while(char::is_ascii_digit).count().min(if t == "d" || t == "m" { 2 } else { 4 }) }; + let digits: String = rest.chars().take(width).collect(); + if digits.is_empty() || !digits.chars().all(|c| c.is_ascii_digit()) { + return None; + } + let v: u32 = digits.parse().ok()?; + rest = &rest[digits.len()..]; + match t.as_str() { + "yyyy" => y = Some(v), + "yy" => y = Some(if v < 50 { 2000 + v } else { 1900 + v }), + "mm" | "m" => m = Some(v), + _ => d = Some(v), + } + } + "mmm" | "mmmm" => { + let (i, len) = name_at(rest, &MONTHS, t.len() == 3)?; + m = Some(i as u32 + 1); + rest = rest.get(len..)?; + } + "ddd" | "dddd" => { + let (i, len) = name_at(rest, &WEEKDAYS, t.len() == 3)?; + day_name = Some(i); + rest = rest.get(len..)?; + } + run if is_date_run(run) => return None, + lit => { + rest = rest.strip_prefix(lit)?; + } + } + let _ = i; + } + if !rest.is_empty() { + return None; + } + let (y, m, d) = (y?, m?, d?); + if day_name.is_some_and(|w| w != weekday(y, m, d)) { + return None; + } + (1..=12).contains(&m).then(|| format!("{y:04}-{m:02}-{d:02}")).filter(|_| (1..=31).contains(&d)) +} diff --git a/crates/xfa/src/data/import.rs b/crates/xfa/src/data/import.rs new file mode 100644 index 000000000..cec30b11b --- /dev/null +++ b/crates/xfa/src/data/import.rs @@ -0,0 +1,353 @@ +use super::build::{escape, fresh_datasets, xml_name}; +use super::merge::{Op, WRAP_CLOSE, WRAP_OPEN, qname, start_tag_end}; +use super::values::Want; +use super::write::{DatasetsWrite, rewrite}; +use super::*; + +/// Data read from a file, held as a tree so it can be written into any packet with the +/// prefix that packet uses, never as the file's own markup. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct ImportNode { + pub name: String, + /// The element's namespace (`None`: none). + pub ns: Option, + /// `(namespace, name, value)`. + pub attrs: Vec<(Option, String, String)>, + pub text: String, + pub children: Vec, +} + +/// Deepest data an import keeps. +const MAX_IMPORT_DEPTH: usize = MAX_DEPTH; + +/// Most bytes of names, namespaces, attributes and text an import copies out of its file: a +/// namespace declared once is copied into every element and attribute that inherits it. +pub(crate) const MAX_IMPORT_BYTES: usize = 4 * MAX_IMPORT; + +/// What an import may still copy: nodes and bytes. +struct ImportBudget { + nodes: usize, + bytes: usize, +} + +impl ImportBudget { + fn new() -> ImportBudget { + ImportBudget { nodes: MAX_NODES as usize, bytes: MAX_IMPORT_BYTES } + } + + fn take_bytes(&mut self, n: usize) -> Result<(), XfaError> { + self.bytes = self + .bytes + .checked_sub(n) + .ok_or_else(|| XfaError::TooLarge(format!("the data file expands to more than {} MB", MAX_IMPORT_BYTES >> 20)))?; + Ok(()) + } +} + +/// `n` as an [`ImportNode`], counting nodes and copied bytes against `budget`; `TooLarge` past +/// the depth or either budget. +fn import_node(n: roxmltree::Node, depth: usize, budget: &mut ImportBudget) -> Result { + if depth > MAX_IMPORT_DEPTH { + return Err(XfaError::TooLarge(format!("the data file nests more than {MAX_IMPORT_DEPTH} levels deep"))); + } + budget.nodes = budget.nodes.checked_sub(1).ok_or_else(|| XfaError::TooLarge("the data file holds too many nodes".into()))?; + let name = n.tag_name().name(); + let ns = n.tag_name().namespace(); + budget.take_bytes(name.len().saturating_add(ns.map_or(0, str::len)))?; + let mut attrs = Vec::new(); + for a in n.attributes().filter(|a| a.name() != "xmlns" && a.namespace() != Some("http://www.w3.org/2000/xmlns/")) { + budget.take_bytes(a.namespace().map_or(0, str::len).saturating_add(a.name().len()).saturating_add(a.value().len()))?; + attrs.push((a.namespace().map(str::to_string), a.name().to_string(), a.value().to_string())); + } + let mut children = Vec::new(); + let mut text = String::new(); + for c in n.children() { + if c.is_element() { + children.push(import_node(c, depth + 1, budget)?); + } else if let Some(t) = c.text() { + budget.take_bytes(t.len())?; + text.push_str(t); + } + } + // A group's own text is layout whitespace. + if !children.is_empty() && text.trim().is_empty() { + text.clear(); + } + Ok(ImportNode { name: name.to_string(), ns: ns.map(str::to_string), attrs, text, children }) +} + +/// `nodes` as markup for a packet whose XFA data namespace prefix is `xfa` (declared where +/// they go); other namespaces are declared on the elements and attributes that use them. +pub(super) fn serialize_nodes(nodes: &[ImportNode], xfa: &str) -> String { + let mut out = String::new(); + for n in nodes { + serialize_node(n, xfa, &mut out, 0); + } + out +} + +fn serialize_node(n: &ImportNode, xfa: &str, out: &mut String, depth: usize) { + if depth > MAX_IMPORT_DEPTH { + return; + } + let mut decls: Vec<(String, String)> = Vec::new(); + let mut qualify = |ns: &Option, name: &str, attr: bool| -> String { + match ns.as_deref() { + None => xml_name(name), + Some(uri) if uri == XFA_DATA_NS => format!("{xfa}:{}", xml_name(name)), + Some("http://www.w3.org/XML/1998/namespace") => format!("xml:{}", xml_name(name)), + Some(uri) => { + let p = match decls.iter().position(|(_, u)| u == uri) { + Some(i) => format!("n{i}"), + None => { + decls.push((format!("n{}", decls.len()), uri.to_string())); + format!("n{}", decls.len() - 1) + } + }; + let _ = attr; + format!("{p}:{}", xml_name(name)) + } + } + }; + let tag = qualify(&n.ns, &n.name, false); + let attrs: Vec = n + .attrs + .iter() + .take(256) + .map(|(ns, name, value)| { + let q = qualify(ns, name, true); + format!(" {q}=\"{}\"", escape(value).0) + }) + .collect(); + out.push('<'); + out.push_str(&tag); + for (p, uri) in &decls { + out.push_str(&format!(" xmlns:{p}=\"{}\"", escape(uri).0)); + } + for a in &attrs { + out.push_str(a); + } + if n.text.is_empty() && n.children.is_empty() { + out.push_str("/>"); + return; + } + out.push('>'); + out.push_str(&escape(&n.text).0); + for c in &n.children { + serialize_node(c, xfa, out, depth + 1); + } + out.push_str(&format!("")); +} + +/// Largest picture written into the data (its bytes, before base64): with the 64 MB cap on +/// all packets together, several pictures still fit. +const MAX_DATA_IMAGE: usize = 8 << 20; + +/// Longest data file imported. +const MAX_IMPORT: usize = 16 << 20; + +/// Put a picture into the data node at `path` as Designer saves one a user chose: base64, with +/// its content type (`image/png`, …) as `xfa:contentType` (in the packet's own prefix). The +/// node is made when missing; a data group is refused rather than wiped. `reuse`: the stream +/// this edit already wrote. +pub fn write_data_image( + doc: &mut Document, + path: &DataPath, + content_type: &str, + bytes: &[u8], + reuse: Option, +) -> Result { + use base64::Engine; + if path.is_empty() { + return Err(XfaError::Malformed("a picture needs a data path".into())); + } + if bytes.len() > MAX_DATA_IMAGE { + return Err(XfaError::TooLarge(format!("the picture is larger than {} MB", MAX_DATA_IMAGE >> 20))); + } + let ct: String = content_type.chars().filter(|c| c.is_ascii_alphanumeric() || matches!(c, '/' | '-' | '.' | '+')).take(64).collect(); + let b64 = base64::engine::general_purpose::STANDARD.encode(bytes); + let ensure = [Want { som: String::new(), path: path.clone(), text: String::new(), ensure: true }]; + rewrite(doc, &[Op::Values(&ensure), Op::Picture(path, &ct, &b64)], reuse) +} + +/// What an import of a data file did. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct DataImport { + /// Element nodes the new data holds. + pub nodes: usize, + pub warnings: Vec, + /// The datasets stream written. + pub stream: Option, +} + +/// The data a file holds, as the children of `xfa:data`: an XDP or datasets document (Export +/// Data's output, Designer's), a bare data element, or a data document whose root is the +/// form's root data node (named `expect_root`, when the caller knows it). Anything else is +/// refused: a form's data is never replaced by what is not its data. +pub fn parse_data_file(text: &str, expect_root: Option<&str>) -> Result, XfaError> { + if text.len() > MAX_IMPORT { + return Err(XfaError::TooLarge(format!("the data file is larger than {} MB", MAX_IMPORT >> 20))); + } + let text = text.trim_start_matches('\u{feff}'); + if crate::xml_nesting_depth(text) > MAX_IMPORT_DEPTH { + return Err(XfaError::TooLarge(format!("the data file nests more than {MAX_IMPORT_DEPTH} levels deep"))); + } + let opts = roxmltree::ParsingOptions { allow_dtd: false, nodes_limit: MAX_NODES }; + let parsed = roxmltree::Document::parse_with_options(text, opts).map_err(|e| XfaError::Malformed(format!("the data file is not XML: {e}")))?; + let root = parsed.root_element(); + let local = root.tag_name().name(); + let in_xfa = |n: &roxmltree::Node| n.tag_name().namespace().is_none_or(|ns| ns == XFA_DATA_NS); + let is = |n: &roxmltree::Node, name: &str| n.is_element() && n.tag_name().name() == name && in_xfa(n); + let data = match local { + "xdp" => { + let ds = root.children().find(|n| is(n, "datasets")).ok_or_else(|| XfaError::Malformed("the XDP holds no datasets".into()))?; + ds.children().find(|n| is(n, "data")).ok_or_else(|| XfaError::Malformed("the datasets hold no data element".into()))? + } + "datasets" if in_xfa(&root) => { + root.children().find(|n| is(n, "data")).ok_or_else(|| XfaError::Malformed("the datasets hold no data element".into()))? + } + "data" if in_xfa(&root) => root, + _ => { + if root.tag_name().namespace().is_some() { + return Err(XfaError::Malformed(format!("<{local}> is not XFA form data"))); + } + if let Some(want) = expect_root + && local != want + { + return Err(XfaError::Malformed(format!("the data file's root is <{local}>; this form's data root is <{want}>"))); + } + let node = import_node(root, 0, &mut ImportBudget::new())?; + return Ok(vec![node]); + } + }; + let mut budget = ImportBudget::new(); + let mut out = Vec::new(); + for c in data.children().filter(|c| c.is_element()) { + out.push(import_node(c, 1, &mut budget)?); + } + Ok(out) +} + +/// Replace the form's data with what the file `text` holds (see [`parse_data_file`]), keeping +/// the rest of the datasets (data descriptions, say). `reuse`: the stream this edit already +/// wrote. +pub fn import_data(doc: &mut Document, text: &str, expect_root: Option<&str>, reuse: Option) -> Result { + let nodes = parse_data_file(text, expect_root)?; + fn count(n: &[ImportNode]) -> usize { + n.iter().map(|c| 1 + count(&c.children)).sum() + } + let r = rewrite(doc, &[Op::ReplaceData(&nodes)], reuse)?; + if !r.written { + return Err(XfaError::Malformed(r.warnings.first().cloned().unwrap_or_else(|| "the data could not be written".into()))); + } + Ok(DataImport { nodes: count(&nodes), warnings: r.warnings, stream: r.stream }) +} + +/// The datasets packet's text: from the XFA array's `datasets` item, or the whole XDP. +pub(super) fn datasets_packet(doc: &Document) -> Result, XfaError> { + let Some(root) = doc.root() else { return Ok(None) }; + let catalog = doc.get(root); + let Some(acro) = catalog.as_dict().and_then(|c| c.get(b"AcroForm")).map(|a| doc.resolve(a)) else { return Ok(None) }; + let Some(xfa) = acro.as_dict().and_then(|a| a.get(b"XFA")).map(|x| doc.resolve(x)) else { return Ok(None) }; + let text_of = |o: &Object| -> Result, XfaError> { + match &*doc.resolve(o) { + Object::Stream(s) => Ok(crate::packets::decode(&s.decoded_within(64 << 20)?).map(|(t, _)| t)), + _ => Ok(None), + } + }; + match &*xfa { + Object::Array(items) => { + let at = (0..items.len()) + .step_by(2) + .find(|&i| items.get(i).and_then(|n| n.as_string()).is_some_and(|s| s.to_text() == "datasets") && i + 1 < items.len()); + match at.and_then(|i| items.get(i + 1)) { + Some(o) => text_of(o), + None => Ok(None), + } + } + Object::Stream(_) => text_of(&xfa), + _ => Ok(None), + } +} + +/// The data to export, as Acrobat's Export Data writes it: `xdp` false gives an XML document +/// holding the data alone (the form's root data node at the root, or the data element when +/// there are several); true gives an XDP holding the whole datasets and a reference to the +/// PDF, named `pdf_name`. A document whose packets can't be read is an error, not empty data. +pub fn export_data(doc: &Document, xdp: bool, pdf_name: &str) -> Result { + if crate::read_packets(doc)?.is_none() { + return Err(XfaError::NotXfa); + } + let packet = datasets_packet(doc)?; + let (datasets, data) = match &packet { + Some(text) => match datasets_markup(text) { + Ok(Some(m)) => m, + Ok(None) => (fresh_datasets(""), String::new()), + Err(()) => return Err(XfaError::Malformed("the XFA data could not be read".into())), + }, + None => (fresh_datasets(""), String::new()), + }; + let mut out = String::from("\n"); + if xdp { + let (name, _) = escape(pdf_name); + out.push_str("\n"); + out.push_str(&datasets); + out.push_str(&format!("\n\n\n")); + } else { + out.push_str(if data.is_empty() { &datasets } else { &data }); + out.push('\n'); + } + Ok(out) +} + +/// The `xfa:datasets` element of `text` as written, and the data alone (its one child, or the +/// data element), each with the namespaces it uses declared on it. `Ok(None)`: no datasets +/// element; `Err`: the text can't be read. +fn datasets_markup(text: &str) -> Result, ()> { + let opts = roxmltree::ParsingOptions { allow_dtd: false, nodes_limit: MAX_NODES }; + if crate::xml_nesting_depth(text) > crate::MAX_XML_DEPTH { + return Err(()); + } + let wrapped = format!("{WRAP_OPEN}{text}{WRAP_CLOSE}"); + let (src, parsed) = match roxmltree::Document::parse_with_options(text, opts) { + Ok(d) => (text, d), + Err(_) => (wrapped.as_str(), roxmltree::Document::parse_with_options(&wrapped, opts).map_err(|_| ())?), + }; + let Some(ds) = parsed.descendants().find(|n| n.is_element() && n.tag_name().name() == "datasets") else { return Ok(None) }; + let data = ds.children().find(|n| n.is_element() && n.tag_name().name() == "data"); + let data_alone = match data { + Some(d) => { + let kids: Vec = d.children().filter(|c| c.is_element()).collect(); + match kids.as_slice() { + [one] => element_markup(src, *one).ok_or(())?, + _ => element_markup(src, d).ok_or(())?, + } + } + None => String::new(), + }; + Ok(Some((element_markup(src, ds).ok_or(())?, data_alone))) +} + +/// `n` as written in `src`, with the namespace declarations in scope that its start tag lacks +/// added to it, so it stands alone. +fn element_markup(src: &str, n: roxmltree::Node) -> Option { + let range = n.range(); + let text = src.get(range.clone())?; + let tag_end = start_tag_end(src, &range)?; + let start_tag = src.get(range.start..tag_end)?; + let mut decls = String::new(); + for ns in n.namespaces() { + let attr = match ns.name() { + Some(p) => format!("xmlns:{p}="), + None => "xmlns=".to_string(), + }; + if !start_tag.contains(&attr) { + decls.push_str(&format!(" {attr}\"{}\"", escape(ns.uri()).0)); + } + } + if decls.is_empty() { + return Some(text.to_string()); + } + let name_end = qname(src, &range)?.len() + 1; + Some(format!("{}{decls}{}", text.get(..name_end)?, text.get(name_end..)?)) +} diff --git a/crates/xfa/src/data/merge.rs b/crates/xfa/src/data/merge.rs new file mode 100644 index 000000000..6888b1439 --- /dev/null +++ b/crates/xfa/src/data/merge.rs @@ -0,0 +1,399 @@ +use super::build::{Build, CONFLICT, CONTROL, NewNodes, STRUCTURED, TOO_MANY, build_children, escape, fresh_datasets, skipped, xml_name}; +use super::import::{ImportNode, serialize_nodes}; +use super::values::Want; +use super::write::MAX_PACKET_WRITE; +use super::*; + +/// What a rewrite of the datasets packet does. +#[derive(Clone, Copy)] +pub(super) enum Op<'a> { + /// Put these values in (adding nodes as needed). + Values(&'a [Want]), + /// Take the node at this path out. + Remove(&'a DataPath), + /// Make these nodes the whole content of the `xfa:data` element (an import). + ReplaceData(&'a [ImportNode]), + /// Make the leaf at this path a picture: base64 text with its content type. + Picture(&'a DataPath, &'a str, &'a str), +} + +/// Where an element's start tag ends (the index of its `>`), skipping quoted attribute values. +pub(super) fn start_tag_end(src: &str, range: &std::ops::Range) -> Option { + let bytes = src.as_bytes(); + let mut quote = None; + for i in range.clone() { + let b = *bytes.get(i)?; + match (quote, b) { + (Some(q), _) if b == q => quote = None, + (Some(_), _) => {} + (None, b'"' | b'\'') => quote = Some(b), + (None, b'>') => return Some(i), + _ => {} + } + } + None +} + +/// An element's qualified name as written. +pub(super) fn qname<'s>(src: &'s str, range: &std::ops::Range) -> Option<&'s str> { + let rest = src.get(range.start.checked_add(1)?..range.end)?; + let end = rest.find(|c: char| c.is_whitespace() || c == '/' || c == '>')?; + rest.get(..end).filter(|n| !n.is_empty()) +} + +/// One change to the packet text: replace `start..end` with `text`. +struct Edit { + start: usize, + end: usize, + text: String, +} + +/// The edit that puts `inner` (markup) at the end of `node`'s content. +fn append_edit(src: &str, node: roxmltree::Node, inner: &str) -> Option { + let range = node.range(); + let tag_end = start_tag_end(src, &range)?; + if tag_end.checked_add(1)? == range.end && src.get(..tag_end)?.ends_with('/') { + // `` becomes `inner`. + let name = qname(src, &range)?; + return Some(Edit { start: tag_end.checked_sub(1)?, end: range.end, text: format!(">{inner}") }); + } + let close = range.start + src.get(range.clone())?.rfind(" Option { + let range = node.range(); + let tag_end = start_tag_end(src, &range)?; + if tag_end.checked_add(1)? == range.end && src.get(..tag_end)?.ends_with('/') { + let name = qname(src, &range)?; + return Some(Edit { start: tag_end.checked_sub(1)?, end: range.end, text: format!(">{text}") }); + } + let close = range.start + src.get(range.clone())?.rfind(" tag_end).then(|| Edit { start: tag_end + 1, end: close, text: text.to_string() }) +} + +/// What merging the values into a packet's text came to. +enum Merged { + /// The data already holds every value. + Unchanged, + Changed(String), + /// The text could not be merged into (the reason is in the warnings). + Failed, +} + +/// Where a datasets packet sits: one packet of an array (`Packet`), or inside the whole XDP. +#[derive(Clone, Copy, PartialEq)] +pub(super) enum Within { + Packet, + Xdp, +} + +pub(super) const WRAP_OPEN: &str = ""; + +pub(super) const WRAP_CLOSE: &str = ""; + +/// Write `wants` into the data of the datasets packet in `text`, changing only what has to +/// change: values go into the data nodes they bind to, missing nodes are added, and everything +/// else (data no field binds to, other namespaces, attributes, comments, layout) stays as +/// written. +/// The prefix bound to the XFA data namespace where `n` is (`None`: unbound or the default +/// namespace, which attributes can't use). +fn xfa_prefix(n: roxmltree::Node) -> Option { + n.namespaces().find(|ns| ns.uri() == XFA_DATA_NS).and_then(|ns| ns.name()).filter(|p| !p.is_empty()).map(str::to_string) +} + +fn merge(text: &str, within: Within, op: Op, warnings: &mut Vec) -> Merged { + let wants: &[Want] = match op { + Op::Values(w) => w, + Op::Remove(_) | Op::ReplaceData(_) | Op::Picture(..) => &[], + }; + // Fresh data, where none exists yet (declared with the `xfa` prefix). + let fresh_children = |warnings: &mut Vec| match op { + Op::ReplaceData(nodes) => serialize_nodes(nodes, "xfa"), + _ => build_children(wants, warnings), + }; + if crate::xml_nesting_depth(text) > crate::MAX_XML_DEPTH { + warnings.push("The XFA data nests XML elements deeper than the reader accepts; the field values were not written to it".into()); + return Merged::Failed; + } + let opts = || roxmltree::ParsingOptions { allow_dtd: false, nodes_limit: MAX_NODES }; + // A packet of an array may use the prefixes the XDP envelope declares: read it inside + // an element declaring the usual ones. + let wrapped; + let (src, shift, parsed) = match roxmltree::Document::parse_with_options(text, opts()) { + Ok(d) => (text, 0, d), + Err(first) => { + wrapped = format!("{WRAP_OPEN}{text}{WRAP_CLOSE}"); + match (within, roxmltree::Document::parse_with_options(&wrapped, opts())) { + (Within::Packet, Ok(d)) => (wrapped.as_str(), WRAP_OPEN.len(), d), + _ => { + warnings.push(format!("The XFA data could not be read ({first}); the field values were not written to it")); + return Merged::Failed; + } + } + } + }; + let is = |n: &roxmltree::Node, name: &str| n.is_element() && n.tag_name().name() == name; + let datasets = parsed + .descendants() + .find(|n| is(n, "datasets") && n.tag_name().namespace() == Some(XFA_DATA_NS)) + .or_else(|| parsed.descendants().find(|n| is(n, "datasets"))); + let mut edits = Vec::new(); + let mut data = None; + match datasets { + None if matches!(op, Op::Remove(_) | Op::Picture(..)) => return Merged::Unchanged, + None if within == Within::Xdp => { + let root = parsed.root_element(); + let children = fresh_children(warnings); + match append_edit(src, root, &fresh_datasets(&children)) { + Some(e) => edits.push(e), + None => { + warnings.push("The XFA packets could not be extended with data; the field values were not written".into()); + return Merged::Failed; + } + } + } + None => { + warnings.push("The XFA datasets packet holds no datasets element; the field values were not written to it".into()); + return Merged::Failed; + } + Some(ds) => match ds.children().find(|n| is(n, "data")) { + Some(d) => data = Some(d), + None if matches!(op, Op::Remove(_) | Op::Picture(..)) => return Merged::Unchanged, + None => { + let prefix = qname(src, &ds.range()).and_then(|q| q.split_once(':')).map(|(p, _)| format!("{p}:")).unwrap_or_default(); + let children = fresh_children(warnings); + let element = format!("<{prefix}data>{}", children); + match append_edit(src, ds, &element) { + Some(e) => edits.push(e), + None => { + warnings.push("The XFA datasets could not be extended with data; the field values were not written".into()); + return Merged::Failed; + } + } + } + }, + } + if let Some(data) = data { + match op { + Op::Values(w) => merge_values(src, data, w, &mut edits, warnings), + Op::ReplaceData(nodes) => { + // Everything between the start and end tags; `` opens up. Written + // with the prefix the packet binds to the data namespace. + let prefix = xfa_prefix(data); + let markup = serialize_nodes(nodes, prefix.as_deref().unwrap_or("xfa")); + let range = data.range(); + match (start_tag_end(src, &range), qname(src, &range)) { + (Some(tag_end), Some(name)) if tag_end + 1 == range.end && src.get(..tag_end).is_some_and(|s| s.ends_with('/')) => { + edits.push(Edit { start: range.start, end: range.end, text: format!("<{name}>{markup}") }); + } + (Some(tag_end), _) => { + let close = src.get(range.clone()).and_then(|s| s.rfind(" edits.push(Edit { start: tag_end + 1, end: close, text: markup }), + _ => { + warnings.push("The XFA data element could not be rewritten; the data was not imported".into()); + return Merged::Failed; + } + } + } + _ => { + warnings.push("The XFA data element could not be rewritten; the data was not imported".into()); + return Merged::Failed; + } + } + } + Op::Picture(path, content_type, base64) => { + let mut node = Some(data); + for (name, idx) in path.iter() { + let want = xml_name(name); + node = node.and_then(|n| n.children().filter(|c| c.is_element() && c.tag_name().name() == want).nth(*idx)); + } + let Some(n) = node else { return Merged::Unchanged }; + if n.children().any(|c| c.is_element()) { + // A data group, not a leaf: a picture would wipe what's under it. + skipped(warnings, STRUCTURED, &path.iter().map(|(n, i)| format!("{n}[{i}]")).collect::>().join(".")); + return Merged::Failed; + } + let name = qname(src, &n.range()).unwrap_or("value").to_string(); + let (ct, _) = escape(content_type); + let attr = match xfa_prefix(n) { + Some(p) => format!("{p}:contentType=\"{ct}\""), + None => format!("xmlns:xfa=\"{XFA_DATA_NS}\" xfa:contentType=\"{ct}\""), + }; + edits.push(Edit { start: n.range().start, end: n.range().end, text: format!("<{name} {attr}>{base64}") }); + } + Op::Remove(path) => { + // The element at `path`, with the whitespace before it. + let mut node = Some(data); + for (name, idx) in path { + node = node.and_then(|n| n.children().filter(|c| c.is_element() && c.tag_name().name() == name).nth(*idx)); + } + if let Some(n) = node { + let r = n.range(); + let start = src.get(..r.start).map_or(r.start, |before| r.start - before.len() + before.trim_end().len()); + edits.push(Edit { start, end: r.end, text: String::new() }); + } + } + } + } + if edits.is_empty() { + return Merged::Unchanged; + } + // Apply from the end, so earlier positions stay valid; overlapping edits (two fields bound + // to one node) keep the first. + edits.sort_by_key(|e| (e.start, e.end)); + let mut kept: Vec = Vec::with_capacity(edits.len()); + for e in edits { + if kept.last().is_some_and(|k| e.start < k.end) { + if kept.last().is_some_and(|k| k.text != e.text) && !warnings.iter().any(|w| w == CONFLICT) { + warnings.push(CONFLICT.into()); + } + continue; + } + kept.push(e); + } + let mut out = src.to_string(); + for e in kept.iter().rev() { + if out.get(e.start..e.end).is_none() { + warnings.push("The XFA data could not be updated; the field values were not written to it".into()); + return Merged::Failed; + } + out.replace_range(e.start..e.end, &e.text); + } + if shift > 0 { + match out.get(shift..out.len().saturating_sub(WRAP_CLOSE.len())) { + Some(inner) => out = inner.to_string(), + None => return Merged::Failed, + } + } + Merged::Changed(out) +} + +/// The edits that put `wants` into the `data` element. +fn merge_values(src: &str, data: roxmltree::Node, wants: &[Want], edits: &mut Vec, warnings: &mut Vec) { + // Element children by local name, per element visited (built once each). + let mut index: HashMap>> = HashMap::new(); + let tree = data.document(); + // Nodes to add, per existing element they go into. + let mut added: Vec<(roxmltree::NodeId, Build)> = Vec::new(); + let mut budget = NewNodes::new(); + for w in wants { + let (escaped, dropped) = escape(&w.text); + let mut node = data; + let mut depth = 0; + let mut have = 0; + for (name, idx) in &w.path { + let kids = index.entry(node.id()).or_insert_with(|| { + let mut by: HashMap> = HashMap::new(); + for c in node.children().filter(|c| c.is_element()) { + by.entry(c.tag_name().name().to_string()).or_default().push(c.id()); + } + by + }); + let same = kids.get(name.as_str()); + match same.and_then(|v| v.get(*idx)).and_then(|id| tree.get_node(*id)) { + Some(next) => { + node = next; + depth += 1; + } + None => { + have = same.map_or(0, Vec::len); + break; + } + } + } + if depth == w.path.len() { + // The data node exists: give it the value, unless it already holds it. A node + // only wanted to exist (a row instance) is left as it is. + if w.ensure && w.text.is_empty() { + continue; + } + let current: String = node.children().filter(|c| c.is_text()).filter_map(|c| c.text()).collect(); + if node.children().any(|c| c.is_element()) { + let all: String = node.descendants().filter(|c| c.is_text()).filter_map(|c| c.text()).collect(); + // Ignore existing XML indentation, but not whitespace in the accepted value. + // A skipped mismatch must warn: relayout would otherwise use stale data. + if all.trim() != w.text { + skipped(warnings, STRUCTURED, &w.som); + } + continue; + } + if current == w.text { + continue; + } + match replace_edit(src, node, &escaped) { + Some(e) => edits.push(e), + None => skipped(warnings, STRUCTURED, &w.som), + } + } else { + // Missing: add the rest of the path below the deepest node that exists. An empty + // value needs no node, unless the node itself is wanted (a new instance). + if w.text.is_empty() && !w.ensure { + continue; + } + let mut rest: DataPath = w.path.get(depth..).unwrap_or_default().to_vec(); + if let Some(first) = rest.first_mut() { + first.1 = first.1.saturating_sub(have); + } + let pos = match added.iter().position(|(id, _)| *id == node.id()) { + Some(p) => p, + None => { + added.push((node.id(), Build::default())); + added.len() - 1 + } + }; + let built = added.get_mut(pos).and_then(|(_, b)| b.at(&rest, &mut budget)); + match built { + Some(b) => b.text = w.text.clone(), + None => { + skipped(warnings, TOO_MANY, &w.som); + continue; + } + } + } + if dropped { + skipped(warnings, CONTROL, &w.som); + } + } + for (id, build) in added { + let Some(node) = tree.get_node(id) else { continue }; + let mut inner = String::new(); + build.write_children(&mut inner); + match append_edit(src, node, &inner) { + Some(e) => edits.push(e), + None => warnings.push("Some XFA data nodes could not be added; their field values were not written".into()), + } + } +} + +/// Merge `ops` into `text` in order. `None`: nothing changed (or nothing could be merged). +pub(super) fn merge_all(text: &str, within: Within, ops: &[Op], warnings: &mut Vec) -> Option { + let mut current: Option = None; + for op in ops { + match merge(current.as_deref().unwrap_or(text), within, *op, warnings) { + Merged::Changed(t) => current = Some(t), + Merged::Unchanged => {} + // Later steps still apply to what merged so far; the reason is in the warnings. + Merged::Failed => {} + } + } + current +} + +/// Is `text` a packet the reader will take back (well-formed, within size)? A rewrite that +/// isn't is not written: better the old data than an unreadable packet. +pub(super) fn readable(text: &str, within: Within, warnings: &mut Vec) -> bool { + if text.len() > MAX_PACKET_WRITE { + warnings.push(format!("The XFA data would be larger than {} MB; it was not written", MAX_PACKET_WRITE >> 20)); + return false; + } + let opts = roxmltree::ParsingOptions { allow_dtd: false, nodes_limit: MAX_NODES }; + let ok = roxmltree::Document::parse_with_options(text, opts).is_ok() + || (within == Within::Packet && roxmltree::Document::parse_with_options(&format!("{WRAP_OPEN}{text}{WRAP_CLOSE}"), opts).is_ok()); + if !ok { + warnings.push("The XFA data would not be well-formed XML after this change; it was not written".into()); + } + ok +} diff --git a/crates/xfa/src/data/passwords.rs b/crates/xfa/src/data/passwords.rs new file mode 100644 index 000000000..78503bb53 --- /dev/null +++ b/crates/xfa/src/data/passwords.rs @@ -0,0 +1,194 @@ +use super::import::datasets_packet; +use super::merge::Op; +use super::path::{data_path, parse_datasets}; +use super::values::{Want, path_of, text_of, with_template}; +use super::write::{datasets_stream_ref, rewrite}; +use super::*; + +/// The SOM path and default value of every AcroForm text field with the Password flag (bit 14, +/// `1 << 13`) in `doc`, for static XFA forms whose password fields set the flag in the AcroForm +/// dictionary. +fn acro_password_fields(doc: &Document) -> Vec<(String, String)> { + const PASSWORD_FLAG: i64 = 1 << 13; + let Some(root) = doc.root() else { return Vec::new() }; + let catalog = doc.get(root); + let Some(acro) = catalog.as_dict().and_then(|c| c.get(b"AcroForm")).map(|a| doc.resolve(a)) else { return Vec::new() }; + let Some(top) = acro.as_dict().and_then(|a| a.get(b"Fields")).map(|f| doc.resolve(f)).and_then(|f| f.as_array().cloned()) else { + return Vec::new(); + }; + struct AcroWalk<'a> { + doc: &'a Document, + seen: std::collections::HashSet, + out: Vec<(String, String)>, + } + impl AcroWalk<'_> { + fn walk( + &mut self, + items: &[Object], + parent_name: &str, + inherited_ft: Option<&[u8]>, + inherited_ff: i64, + inherited_dv: Option<&str>, + depth: usize, + ) { + if depth > 32 { + return; + } + for item in items { + let Some(r) = item.as_ref() else { continue }; + if !self.seen.insert(r) { + continue; + } + let obj = self.doc.get(r); + let Some(d) = obj.as_dict() else { continue }; + let ft = d.name(b"FT").or(inherited_ft); + let ff = d.int(b"Ff").unwrap_or(inherited_ff); + let dv_own = text_of(self.doc, d, b"DV"); + let dv = dv_own.as_deref().or(inherited_dv); + let t = text_of(self.doc, d, b"T"); + let full_name = match (&t, parent_name.is_empty()) { + (Some(seg), true) => seg.clone(), + (Some(seg), false) => format!("{parent_name}.{seg}"), + (None, _) => parent_name.to_string(), + }; + let kids = d.get(b"Kids").map(|k| self.doc.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_default(); + let has_named_kid = kids.iter().filter_map(Object::as_ref).any(|kr| self.doc.get(kr).as_dict().is_some_and(|kd| kd.contains(b"T"))); + if !has_named_kid && ft == Some(b"Tx") && (ff & PASSWORD_FLAG) != 0 { + let som = text_of(self.doc, d, crate::pdf::SOM_KEY).unwrap_or_else(|| full_name.clone()); + if !som.is_empty() { + self.out.push((som, dv.unwrap_or_default().to_string())); + } + } + if !kids.is_empty() { + self.walk(&kids, &full_name, ft, ff, dv, depth + 1); + } + } + } + } + let mut w = AcroWalk { doc, seen: std::collections::HashSet::new(), out: Vec::new() }; + w.walk(&top, "", None, 0, None, 0); + w.out +} + +/// The `Want` entries needed to restore password fields in `doc`'s datasets packet to their +/// defaults (or empty when they have no default). +fn password_dataset_wants(doc: &Document) -> Vec { + if datasets_stream_ref(doc).is_none() { + return Vec::new(); + } + let Ok(Some(packet)) = datasets_packet(doc) else { return Vec::new() }; + let Some(data) = parse_datasets(&packet) else { return Vec::new() }; + with_template(doc, None, |tpl| { + let mut candidates: Vec<(String, DataPath, String)> = Vec::new(); + let mut seen = std::collections::HashSet::new(); + if let Some(t) = tpl { + struct Walk<'a> { + tpl: &'a Template, + data: &'a DataNode, + nodes: usize, + seen: &'a mut std::collections::HashSet, + out: &'a mut Vec<(String, DataPath, String)>, + } + impl Walk<'_> { + fn child_som(parent: &str, name: Option<&str>, index: usize) -> String { + match name { + Some(n) if !parent.is_empty() => format!("{parent}.{n}[{index}]"), + Some(n) => format!("{n}[{index}]"), + None => parent.to_string(), + } + } + fn walk(&mut self, children: &[Node], parent_som: &str, depth: usize) { + if depth > MAX_DEPTH || self.nodes >= crate::script::MAX_FORM_NODES { + return; + } + let sibs = SomIndices::of(children); + for (i, n) in children.iter().enumerate() { + if self.nodes >= crate::script::MAX_FORM_NODES { + break; + } + self.nodes += 1; + let sib = sibs.at(i); + match n { + Node::Subform(sf) => { + let from_data = match &sf.common.name { + Some(name) if sf.occur.max != Some(1) => self.data.count(&data_path(self.tpl, parent_som), name), + _ => 0, + }; + let instances = crate::layout::instance_count(&sf.occur, from_data).max(from_data); + for k in 0..instances { + if k > 0 { + if self.nodes >= crate::script::MAX_FORM_NODES { + break; + } + self.nodes += 1; + } + let som = Self::child_som(parent_som, sf.common.name.as_deref(), sib + k); + self.walk(&sf.children, &som, depth + 1); + } + } + Node::Area(a) => { + let som = Self::child_som(parent_som, a.common.name.as_deref(), sib); + self.walk(&a.children, &som, depth + 1); + } + Node::Field(f) if f.ui == Ui::PasswordEdit => { + let som = Self::child_som(parent_som, f.common.name.as_deref().or(Some("field")), sib); + let path = data_path(self.tpl, &som); + if !path.is_empty() && self.seen.insert(path.clone()) { + let default = f.value.plain().map(|v| f.saved_value(v.trim())).unwrap_or_default(); + self.out.push((som, path, default)); + } + } + _ => {} + } + } + } + } + let root = Node::Subform(Box::new(t.root.clone())); + let mut w = Walk { tpl: t, data: &data, nodes: 0, seen: &mut seen, out: &mut candidates }; + w.walk(std::slice::from_ref(&root), "", 0); + if let Some(ps) = &t.root.page_set { + for (i, area) in ps.areas.iter().enumerate() { + let som = area.name.clone().map(|n| format!("{n}[0]")).unwrap_or_else(|| format!("#pageArea[{i}]")); + w.walk(&area.items, &som, 1); + } + } + } + for (som, default) in acro_password_fields(doc) { + let path = path_of(tpl, &som); + if !path.is_empty() && seen.insert(path.clone()) { + candidates.push((som, path, default)); + } + } + candidates + .into_iter() + .filter_map(|(som, path, default)| { + let cur = data.value_at(&path)?; + (!cur.is_empty() && cur != default).then_some(Want { som, path, text: default, ensure: false }) + }) + .collect() + }) +} + +/// Whether any XFA password field (`` or an AcroForm text field with the Password +/// flag) holds a value in `doc`'s datasets packet beyond its default (#3944). +pub fn has_password_values(doc: &Document) -> bool { + !password_dataset_wants(doc).is_empty() +} + +/// Clear typed password values from `doc`'s XFA datasets packet (restoring a template default +/// where the field has one, else emptying the element), replacing the datasets stream in place +/// (#3944). Returns how many password fields were cleared in the datasets. +pub fn clear_password_values(doc: &mut Document) -> Result { + let wants = password_dataset_wants(doc); + if wants.is_empty() { + return Ok(0); + } + let count = wants.len(); + let reuse = datasets_stream_ref(doc); + let report = rewrite(doc, &[Op::Values(&wants)], reuse)?; + if !report.written || !password_dataset_wants(doc).is_empty() { + let reason = report.warnings.first().map_or("the XFA datasets password values could not be cleared", String::as_str); + return Err(XfaError::Malformed(reason.to_string())); + } + Ok(count) +} diff --git a/crates/xfa/src/data/path.rs b/crates/xfa/src/data/path.rs new file mode 100644 index 000000000..90cc4346f --- /dev/null +++ b/crates/xfa/src/data/path.rs @@ -0,0 +1,156 @@ +use super::*; + +fn to_node(n: roxmltree::Node, depth: usize) -> DataNode { + let name = n.tag_name().name().to_string(); + let children: Vec = + if depth < MAX_DEPTH { n.children().filter(|c| c.is_element()).map(|c| to_node(c, depth + 1)).collect() } else { Vec::new() }; + // The text of a leaf is its value; a container's own text is whitespace. + let text = if children.is_empty() { n.children().filter_map(|c| c.text()).collect::() } else { String::new() }; + DataNode { name, text, children } +} + +/// The `xfa:data` element of the datasets packet in `xdp` (or a bare datasets document), as a +/// tree whose root is that element. +pub fn parse_datasets(xdp: &str) -> Option { + if crate::xml_nesting_depth(xdp) > crate::MAX_XML_DEPTH { + return None; + } + let opts = roxmltree::ParsingOptions { allow_dtd: false, nodes_limit: MAX_NODES }; + let doc = roxmltree::Document::parse_with_options(xdp, opts).ok()?; + let datasets = doc.descendants().find(|n| n.is_element() && n.tag_name().name() == "datasets")?; + let data = datasets.children().find(|n| n.is_element() && n.tag_name().name() == "data")?; + Some(to_node(data, 0)) +} + +/// The data path a SOM expression names: `form[0].#subform[0].name[1]` → `[(form, 0), (name, 1)]`. +/// Unnamed containers (`#subform`) have no data node. +pub fn som_to_path(som: &str) -> DataPath { + som.split('.') + .filter_map(|seg| { + let seg = seg.trim(); + if seg.is_empty() || seg.starts_with('#') || seg.starts_with('$') { + return None; + } + let (name, idx) = match seg.split_once('[') { + Some((n, rest)) => (n, rest.trim_end_matches(']').parse::().unwrap_or(0)), + None => (seg, 0), + }; + Some((name.to_string(), idx.min(100_000))) + }) + .collect() +} + +/// The data path the object at `som` binds to: [`som_to_path`] without the areas on the way. +/// An area is a named container in the form, so it is part of SOM expressions, but only +/// subforms, exclusion groups and fields are data scopes: what an area holds binds under the +/// area's parent, as Adobe's viewers write it. The template tells which segments are areas; +/// segments it doesn't have (page-set objects, or a SOM from another template) are kept. +pub fn data_path(tpl: &Template, som: &str) -> DataPath { + let mut out = DataPath::new(); + // The template children the next segment is looked up in; `None` until the root matched, + // and again once a segment is not a container the template has. + let mut level: Option<&[Node]> = None; + for (i, (name, index)) in som_to_path(som).into_iter().enumerate() { + let found = match level { + None if i == 0 && tpl.root.common.name.as_deref() == Some(name.as_str()) => Some(Container::Children(&tpl.root.children)), + None => None, + Some(children) => match named(children, &name, 0) { + Some(Node::Subform(sf)) => Some(Container::Children(&sf.children)), + Some(Node::Area(a)) => Some(Container::Area(&a.children)), + _ => None, + }, + }; + match found { + Some(Container::Area(children)) => level = Some(children), + Some(Container::Children(children)) => { + level = Some(children); + out.push((name, index)); + } + None => { + level = None; + out.push((name, index)); + } + } + } + out +} + +enum Container<'a> { + Children(&'a [Node]), + /// Contents bind under the area's parent. + Area(&'a [Node]), +} + +/// The child of `nodes` named `name`, looking through unnamed subforms and areas (which have no +/// SOM segment of their own). +fn named<'a>(nodes: &'a [Node], name: &str, depth: usize) -> Option<&'a Node> { + if depth > MAX_DEPTH { + return None; + } + nodes.iter().find(|n| n.common().name.as_deref() == Some(name)).or_else(|| { + nodes.iter().find_map(|n| match n { + Node::Subform(sf) if sf.common.name.is_none() => named(&sf.children, name, depth + 1), + Node::Area(a) if a.common.name.is_none() => named(&a.children, name, depth + 1), + _ => None, + }) + }) +} + +/// The child of `nodes` the SOM segment `name[index]` names, numbered as the form numbers its +/// objects ([`SomIndices`]): same-named siblings count up, and the instances of a repeating +/// subform take the numbers from its own on. Unnamed subforms and areas are looked through, +/// as [`named`] does. +fn named_at<'a>(nodes: &'a [Node], name: &str, index: usize, depth: usize) -> Option<&'a Node> { + if depth > MAX_DEPTH { + return None; + } + let sibs = SomIndices::of(nodes); + let mut repeating = None; + for (i, n) in nodes.iter().enumerate() { + if n.common().name.as_deref() != Some(name) { + continue; + } + let sib = sibs.at(i); + if sib == index { + return Some(n); + } + if sib < index && matches!(n, Node::Subform(sf) if sf.occur.max != Some(1)) { + repeating = Some(n); + } + } + repeating.or_else(|| { + nodes.iter().find_map(|n| match n { + Node::Subform(sf) if sf.common.name.is_none() => named_at(&sf.children, name, index, depth + 1), + Node::Area(a) if a.common.name.is_none() => named_at(&a.children, name, index, depth + 1), + _ => None, + }) + }) +} + +/// The template field the object at a SOM path is an instance of, each segment's number counted +/// 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> { + let path = som_to_path(som); + let ((root, _), rest) = path.split_first()?; + if tpl.root.common.name.as_deref() != Some(root.as_str()) { + return None; + } + let ((last, last_index), inner) = rest.split_last()?; + let mut children: &[Node] = &tpl.root.children; + for (name, index) in inner { + children = match named_at(children, name, *index, 0)? { + Node::Subform(sf) => &sf.children, + Node::Area(a) => &a.children, + _ => return None, + }; + } + match named_at(children, last, *last_index, 0)? { + Node::Field(f) => Some(f), + _ => None, + } +} + +pub(super) fn path_som(path: &DataPath) -> String { + path.iter().map(|(n, i)| format!("{n}[{i}]")).collect::>().join(".") +} diff --git a/crates/xfa/src/data/values.rs b/crates/xfa/src/data/values.rs new file mode 100644 index 000000000..41fb6644e --- /dev/null +++ b/crates/xfa/src/data/values.rs @@ -0,0 +1,192 @@ +use super::dates::{data_date_pattern, data_to_iso, iso_to_data, iso_to_pattern, pattern_to_iso}; +use super::path::{data_path, parse_datasets, som_to_path}; +use super::*; + +/// What a field holds, for writing the datasets. +#[derive(Clone, Debug, PartialEq)] +pub enum FieldData { + Text(String), + /// Checked or not. + Check(bool), + /// The selected button's on value, if any. + Radio(Option), + /// Nothing to write (buttons, signatures). + None, +} + +/// One field of the AcroForm, as the engine sees it. +#[derive(Clone, Debug, PartialEq)] +pub struct FieldDatum { + /// The terminal field dictionary (where `/PCSom` and `/PCItems` live). + pub obj: ObjRef, + /// The fully qualified field name (a static form's SOM path). + pub name: String, + pub data: FieldData, +} + +pub(super) fn text_of(doc: &Document, d: &Dict, key: &[u8]) -> Option { + d.get(key).map(|o| doc.resolve(o)).and_then(|o| o.as_string().map(|s| s.to_text())) +} + +/// The SOM path of a field: its `/PCSom` when PdfCraft generated it, else its name. +fn som_of(doc: &Document, f: &FieldDatum) -> String { + doc.get(f.obj).as_dict().and_then(|d| text_of(doc, d, crate::pdf::SOM_KEY)).unwrap_or_else(|| f.name.clone()) +} + +/// The datasets text for a radio group. The AcroForm value is an appearance-state name +/// (`New York` is stored as `New_York`); `/PCOn` on the selected widget is the original item. +fn radio_dataset_value(doc: &Document, f: &FieldDatum, sel: &Option) -> String { + let Some(selected) = sel.as_deref().filter(|s| !s.is_empty() && *s != "Off") else { + return String::new(); + }; + let Some(group) = doc.get(f.obj).as_dict().cloned() else { return selected.to_string() }; + let kids = group.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_else(|| vec![Object::Ref(f.obj)]); + for kid in kids { + let Some(widget) = doc.resolve(&kid).as_dict().cloned() else { continue }; + let original = text_of(doc, &widget, b"PCOn"); + let state = widget + .get(b"AP") + .map(|ap| doc.resolve(ap)) + .and_then(|ap| ap.as_dict().and_then(|a| a.get(b"N").cloned())) + .and_then(|n| doc.resolve(&n).as_dict().cloned()) + .and_then(|n| n.iter().map(|(k, _)| String::from_utf8_lossy(k).into_owned()).find(|k| k != "Off")); + if state.as_deref() == Some(selected) || original.as_deref() == Some(selected) { + return original.unwrap_or_else(|| selected.to_string()); + } + } + selected.to_string() +} + +/// The on and off item values of a check box (`/PCItems`), or the widget's on state. +fn items_of(doc: &Document, f: &FieldDatum) -> (String, String) { + let d = doc.get(f.obj); + let items: Vec = d + .as_dict() + .and_then(|d| d.get(b"PCItems").map(|o| doc.resolve(o))) + .and_then(|a| a.as_array().map(|a| a.iter().filter_map(|x| x.as_string().map(|s| s.to_text())).collect())) + .unwrap_or_default(); + let on = items.first().cloned().unwrap_or_else(|| "1".into()); + let off = items.get(1).cloned().unwrap_or_default(); + (on, off) +} + +/// The Acrobat date pattern a field's format action names (`AFDate_FormatEx("yyyy-mm-dd")`). +fn date_pattern_of(doc: &Document, f: &FieldDatum) -> Option { + let d = doc.get(f.obj); + let aa = doc.resolve(d.as_dict()?.get(b"AA")?); + let fa = doc.resolve(aa.as_dict()?.get(b"F")?); + let js = text_of(doc, fa.as_dict()?, b"JS")?; + let i = js.find("AFDate_FormatEx(")?; + let rest = &js[i + "AFDate_FormatEx(".len()..]; + let q = rest.find('"')?; + let body = &rest[q + 1..]; + Some(body[..body.find('"')?].to_string()) +} + +/// A value to write: where (the field's SOM path, and the data path it binds to) and what. +pub(super) struct Want { + pub(super) som: String, + pub(super) path: DataPath, + pub(super) text: String, + /// Create the node even for an empty value (an instance of a repeating subform). + pub(super) ensure: bool, +} + +/// `tpl`, or the document's own template when none is given (parsed once). +pub(super) fn with_template(doc: &Document, tpl: Option<&Template>, f: impl FnOnce(Option<&Template>) -> T) -> T { + match tpl { + Some(t) => f(Some(t)), + None => { + let parsed = crate::script::template_of(doc).ok(); + f(parsed.as_ref()) + } + } +} + +/// [`data_path`] when the template is known, else [`som_to_path`]. +pub(super) fn path_of(tpl: Option<&Template>, som: &str) -> DataPath { + match tpl { + Some(t) => data_path(t, som), + None => som_to_path(som), + } +} + +/// The values the datasets should hold for `fields`, by data path: dates in each field's data +/// picture (or ISO), check and radio states, choice values as saved. +pub(super) fn wanted(doc: &Document, fields: &[FieldDatum], tpl: Option<&Template>) -> Vec { + let mut out = Vec::new(); + for f in fields { + let som = som_of(doc, f); + let path = path_of(tpl, &som); + if path.is_empty() { + continue; + } + let text = match &f.data { + FieldData::Text(t) => match date_pattern_of(doc, f) { + Some(p) => match pattern_to_iso(t, &p) { + // The date goes into the data in the field's data picture, or as an ISO date. + Some(iso) => iso_to_data(iso, data_date_pattern(tpl, &som).as_deref()), + None => t.clone(), + }, + None => t.clone(), + }, + FieldData::Check(on) => { + let (on_item, off_item) = items_of(doc, f); + if *on { on_item } else { off_item } + } + FieldData::Radio(sel) => radio_dataset_value(doc, f, sel), + FieldData::None => continue, + }; + out.push(Want { som, path, text, ensure: false }); + } + out +} + +/// The values the datasets hold for `fields`: `(field name, what it should hold)`, only for +/// fields the data mentions. +pub fn read_values(doc: &Document, fields: &[FieldDatum]) -> Vec<(String, FieldData)> { + read_values_with(doc, None, fields) +} + +/// [`read_values`] with the form's parsed template when the caller has it; `None` parses the +/// document's. +pub fn read_values_with(doc: &Document, tpl: Option<&Template>, fields: &[FieldDatum]) -> Vec<(String, FieldData)> { + with_template(doc, tpl, |t| values_at(doc, t, fields)) +} + +/// The values the document's datasets hold for `fields`, read in each field's data picture and +/// shown in its own pattern; only fields the data mentions. +fn values_at(doc: &Document, tpl: Option<&Template>, fields: &[FieldDatum]) -> Vec<(String, FieldData)> { + let Ok(Some(p)) = crate::read_packets(doc) else { return Vec::new() }; + let Some(data) = parse_datasets(&p.xdp) else { return Vec::new() }; + let mut out = Vec::new(); + for f in fields { + let som = som_of(doc, f); + let path = path_of(tpl, &som); + let Some(text) = data.value_at(&path) else { continue }; + let text = text.as_str(); + let value = match &f.data { + FieldData::Text(_) => FieldData::Text(match date_pattern_of(doc, f) { + Some(pat) => iso_to_pattern(&data_to_iso(text, data_date_pattern(tpl, &som).as_deref()), &pat), + None => text.to_string(), + }), + FieldData::Check(_) => { + let (on, _) = items_of(doc, f); + FieldData::Check(is_on(text, &on)) + } + FieldData::Radio(_) => FieldData::Radio((!text.trim().is_empty()).then(|| text.trim().to_string())), + FieldData::None => continue, + }; + out.push((f.name.clone(), value)); + } + out +} + +/// Does a data value mean "checked" for a button whose on value is `on`? +pub fn is_on(text: &str, on: &str) -> bool { + let t = text.trim(); + if t == on { + return true; + } + !matches!(t.to_ascii_lowercase().as_str(), "" | "0" | "off" | "false" | "no") && on.trim().is_empty() +} diff --git a/crates/xfa/src/data/write.rs b/crates/xfa/src/data/write.rs new file mode 100644 index 000000000..282dd33ef --- /dev/null +++ b/crates/xfa/src/data/write.rs @@ -0,0 +1,264 @@ +use super::build::{MAX_INDEX, build_children, fresh_datasets}; +use super::merge::{Op, Within, merge_all, readable}; +use super::path::path_som; +use super::values::{FieldDatum, Want, wanted, with_template}; +use super::*; + +/// What [`write_datasets`] did. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct DatasetsWrite { + /// The datasets packet changed (a new revision of it was added to the document). + pub written: bool, + /// Values that could not be written, and why. + pub warnings: Vec, + /// The datasets stream written, when one was. + pub stream: Option, +} + +/// A packet stream's text and encoding; `None` (with a warning) when it can't be read exactly. +fn packet_text(doc: &Document, o: &Object, warnings: &mut Vec) -> Option<(String, Encoding)> { + let s = doc.resolve(o); + let Object::Stream(s) = &*s else { + warnings.push("The XFA data packet is not a stream; the field values were not written to it".into()); + return None; + }; + let bytes = match s.decoded_within(64 << 20) { + Ok(b) => b, + Err(e) => { + warnings.push(format!("The XFA data could not be decoded ({e}); the field values were not written to it")); + return None; + } + }; + let decoded = crate::packets::decode(&bytes); + if decoded.is_none() { + warnings.push("The XFA data is neither UTF-8 nor UTF-16; the field values were not written to it, so it stays as it was".into()); + } + decoded +} + +/// Write the fields' values into the datasets packet, keeping all the data no field binds to. +/// Nothing is written when the document has no XFA entry or the data already holds the values; +/// values that can't be written are reported, never silently dropped. +pub fn write_datasets(doc: &mut Document, fields: &[FieldDatum]) -> Result { + write_datasets_reusing(doc, fields, None) +} + +/// [`write_datasets`], replacing the stream `reuse` in place when the packet is that stream +/// (written earlier in the same edit) rather than adding another. +pub fn write_datasets_reusing(doc: &mut Document, fields: &[FieldDatum], reuse: Option) -> Result { + write_datasets_with(doc, None, fields, reuse) +} + +/// [`write_datasets_reusing`] with the form's parsed template when the caller has it (it says +/// which SOM segments are areas, see [`data_path`]); `None` parses the document's. +pub fn write_datasets_with( + doc: &mut Document, + tpl: Option<&Template>, + fields: &[FieldDatum], + reuse: Option, +) -> Result { + let wants = with_template(doc, tpl, |t| wanted(doc, fields, t)); + rewrite(doc, &[Op::Values(&wants)], reuse) +} + +/// Make sure the data has at least `count` instances of `name` under `parent` (a script added +/// rows: the layout repeats the subform once per data instance). +pub fn add_data_instances(doc: &mut Document, parent: &DataPath, name: &str, count: usize) -> Result { + write_data_ops(doc, &[DataOp::Instances { parent: parent.clone(), name: name.to_string(), count }], None) +} + +/// Put `text` at the data node `path` (a field with no widget, a hidden one or a draw, that a +/// script gave a value), adding the node as needed. +pub fn write_data_value(doc: &mut Document, path: &DataPath, text: &str) -> Result { + write_data_ops(doc, &[DataOp::Value { path: path.clone(), text: text.to_string() }], None) +} + +/// Remove the data node at `path` (a script removed a row). +pub fn remove_data_instance(doc: &mut Document, path: &DataPath) -> Result { + write_data_ops(doc, &[DataOp::Remove(path.clone())], None) +} + +/// Most instance nodes one [`write_data_ops`] queues over all its passes (a removal starts a new +/// pass, which asks for the instances again). +const MAX_ENSURED: usize = 200_000; + +// Instance nodes write_data_ops has queued on this thread, for tests that bound its work. +#[cfg(test)] +thread_local! { + pub(crate) static ENSURED: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// Apply `ops` to the datasets packet, in order, as one rewrite of it: everything one script +/// event changed becomes a single new datasets stream. When the packet is already the stream +/// `reuse` (written earlier for the same event), that stream is replaced in place instead of +/// a new one being added. The stream written is in the report. +pub fn write_data_ops(doc: &mut Document, ops: &[DataOp], reuse: Option) -> Result { + // Runs of values (and instances) merge in one pass; a value set twice keeps the last. + let mut steps: Vec> = Vec::new(); + let mut removes: Vec> = Vec::new(); + let mut run: Vec = Vec::new(); + // Instances queued in this run, by parent and name: asking for more only queues the rest. + let mut queued: HashMap<(&DataPath, &str), usize> = HashMap::new(); + let (mut ensured, mut capped) = (0usize, false); + let flush = |run: &mut Vec, steps: &mut Vec>, removes: &mut Vec>| { + if !run.is_empty() { + let mut seen = std::collections::HashSet::new(); + let mut kept: Vec = std::mem::take(run).into_iter().rev().filter(|w| seen.insert((w.ensure, w.path.clone()))).collect(); + kept.reverse(); + steps.push(kept); + removes.push(None); + } + }; + for op in ops { + match op { + DataOp::Value { path, text } if !path.is_empty() => { + // An empty value clears an existing node and needs no new one. + run.push(Want { som: path_som(path), path: path.clone(), text: text.clone(), ensure: false }); + } + DataOp::Instances { parent, name, count } => { + let done = queued.entry((parent, name.as_str())).or_insert(0); + while *done < (*count).min(MAX_INDEX) { + if ensured >= MAX_ENSURED { + capped = true; + break; + } + ensured += 1; + #[cfg(test)] + ENSURED.with(|n| n.set(n.get() + 1)); + let k = *done; + *done += 1; + let mut path = parent.clone(); + path.push((name.clone(), k)); + run.push(Want { som: format!("{name}[{k}]"), path, text: String::new(), ensure: true }); + } + } + DataOp::Remove(path) if !path.is_empty() => { + flush(&mut run, &mut steps, &mut removes); + queued.clear(); + steps.push(Vec::new()); + removes.push(Some(path.clone())); + } + _ => {} + } + } + flush(&mut run, &mut steps, &mut removes); + let plan: Vec = steps + .iter() + .zip(&removes) + .map(|(w, r)| match r { + Some(p) => Op::Remove(p), + None => Op::Values(w), + }) + .collect(); + let mut written = rewrite(doc, &plan, reuse)?; + if capped { + written.warnings.push(format!("XFA data instances not written past {MAX_ENSURED}: the rows were added and removed too often")); + } + Ok(written) +} + +/// Put `bytes` in the document as a datasets stream: in place of `old` when that is the stream +/// `reuse` or already an unsaved overlay stream of `doc`, else as a new object. +fn put_stream(doc: &mut Document, old: Option<&Object>, reuse: Option, bytes: &[u8]) -> ObjRef { + let stream = Object::Stream(Stream::flate(Dict::new(), bytes)); + match old.and_then(Object::as_ref) { + Some(r) if reuse == Some(r) || doc.is_edited(r.num) => { + doc.set(r, stream); + r + } + _ => doc.add(stream), + } +} + +/// Longest packet text written (the packets together are read up to 64 MB). +pub(super) const MAX_PACKET_WRITE: usize = 48 << 20; + +pub(super) fn rewrite(doc: &mut Document, ops: &[Op], reuse: Option) -> Result { + let mut report = DatasetsWrite::default(); + if ops.is_empty() { + return Ok(report); + } + let Some(root) = doc.root() else { return Ok(report) }; + let catalog = doc.get(root); + let Some(acro_obj) = catalog.as_dict().and_then(|c| c.get(b"AcroForm")).cloned() else { return Ok(report) }; + let acro_ref = acro_obj.as_ref(); + let Some(mut acro) = doc.resolve(&acro_obj).as_dict().cloned() else { return Ok(report) }; + let Some(xfa) = acro.get(b"XFA").cloned() else { return Ok(report) }; + let warnings = &mut report.warnings; + match &*doc.resolve(&xfa) { + Object::Array(items) => { + let mut items = items.clone(); + let at = (0..items.len()) + .step_by(2) + .find(|&i| items.get(i).and_then(|n| n.as_string()).is_some_and(|s| s.to_text() == "datasets") && i + 1 < items.len()); + match at { + Some(i) => { + let Some(existing) = items.get(i + 1).cloned() else { return Ok(report) }; + let Some((text, encoding)) = packet_text(doc, &existing, warnings) else { return Ok(report) }; + let Some(t) = merge_all(&text, Within::Packet, ops, warnings) else { return Ok(report) }; + if !readable(&t, Within::Packet, warnings) { + return Ok(report); + } + let r = put_stream(doc, Some(&existing), reuse, &encode(&t, encoding)); + report.stream = Some(r); + if let Some(slot) = items.get_mut(i + 1) { + *slot = Object::Ref(r); + } + } + None => { + // No datasets packet yet: made from the first values, then the rest merged + // in; before the postamble, or last. + let Some(first) = ops.iter().position(|op| matches!(op, Op::Values(_))) else { return Ok(report) }; + let Some(Op::Values(wants)) = ops.get(first) else { return Ok(report) }; + let fresh = fresh_datasets(&build_children(wants, warnings)); + let text = merge_all(&fresh, Within::Packet, ops.get(first + 1..).unwrap_or_default(), warnings).unwrap_or(fresh); + if !readable(&text, Within::Packet, warnings) { + return Ok(report); + } + let r = doc.add(Object::Stream(Stream::flate(Dict::new(), text.as_bytes()))); + report.stream = Some(r); + let pos = items.iter().position(|n| n.as_string().is_some_and(|s| s.to_text() == "postamble")).unwrap_or(items.len()); + items.insert(pos, Object::Ref(r)); + items.insert(pos, Object::String(PdfString::text("datasets"))); + } + } + acro.set(b"XFA".to_vec(), Object::Array(items)); + } + Object::Stream(_) => { + let Some((text, encoding)) = packet_text(doc, &xfa, warnings) else { return Ok(report) }; + let Some(new) = merge_all(&text, Within::Xdp, ops, warnings) else { return Ok(report) }; + if !readable(&new, Within::Xdp, warnings) { + return Ok(report); + } + let r = put_stream(doc, Some(&xfa), reuse, &encode(&new, encoding)); + report.stream = Some(r); + acro.set(b"XFA".to_vec(), Object::Ref(r)); + } + _ => return Ok(report), + } + match acro_ref { + Some(ar) => doc.set(ar, Object::Dict(acro)), + None => doc.update_dict(root, |c| c.set(b"AcroForm".to_vec(), Object::Dict(acro)))?, + } + report.written = true; + Ok(report) +} + +/// The datasets stream's object reference in `doc` (the `datasets` item in the `/XFA` array, or +/// the single `/XFA` stream), when `doc` has one. +pub(super) fn datasets_stream_ref(doc: &Document) -> Option { + let root = doc.root()?; + let catalog = doc.get(root); + let acro = catalog.as_dict()?.get(b"AcroForm").map(|a| doc.resolve(a))?; + let xfa = acro.as_dict()?.get(b"XFA")?; + match &*doc.resolve(xfa) { + Object::Array(items) => { + let at = (0..items.len()) + .step_by(2) + .find(|&i| items.get(i).and_then(|n| n.as_string()).is_some_and(|s| s.to_text() == "datasets") && i + 1 < items.len())?; + items.get(at + 1)?.as_ref() + } + Object::Stream(_) => xfa.as_ref(), + _ => None, + } +}