mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-28 03:58:45 -06:00
refactor(xfa): split data.rs into data/
New modules: path, dates, values, build, merge, write, import, passwords.
This commit is contained in:
+29
-1878
File diff suppressed because it is too large
Load Diff
@@ -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<Build>,
|
||||
/// Positions in `children` by name, in order.
|
||||
by_name: HashMap<String, Vec<usize>>,
|
||||
}
|
||||
|
||||
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<String>, 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>) -> 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!("<xfa:data>{}</xfa:data>", build_children(&wants, &mut Vec::new()))
|
||||
}
|
||||
|
||||
pub(super) fn fresh_datasets(data_children: &str) -> String {
|
||||
format!("<xfa:datasets xmlns:xfa=\"{XFA_DATA_NS}\"><xfa:data>{data_children}</xfa:data></xfa:datasets>")
|
||||
}
|
||||
@@ -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<String> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
let mut out = Vec::new();
|
||||
let chars: Vec<char> = 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::<u32>(), m.parse::<u32>(), d.parse::<u32>()) 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<String> {
|
||||
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))
|
||||
}
|
||||
@@ -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<String>,
|
||||
/// `(namespace, name, value)`.
|
||||
pub attrs: Vec<(Option<String>, String, String)>,
|
||||
pub text: String,
|
||||
pub children: Vec<ImportNode>,
|
||||
}
|
||||
|
||||
/// 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<ImportNode, XfaError> {
|
||||
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<String>, 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<String> = 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!("</{tag}>"));
|
||||
}
|
||||
|
||||
/// 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<ObjRef>,
|
||||
) -> Result<DatasetsWrite, XfaError> {
|
||||
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<String>,
|
||||
/// The datasets stream written.
|
||||
pub stream: Option<ObjRef>,
|
||||
}
|
||||
|
||||
/// 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<Vec<ImportNode>, 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<ObjRef>) -> Result<DataImport, XfaError> {
|
||||
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<Option<String>, 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<Option<String>, 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<String, XfaError> {
|
||||
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("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
|
||||
if xdp {
|
||||
let (name, _) = escape(pdf_name);
|
||||
out.push_str("<xdp:xdp xmlns:xdp=\"http://ns.adobe.com/xdp/\">\n");
|
||||
out.push_str(&datasets);
|
||||
out.push_str(&format!("\n<pdf href=\"{name}\" xmlns=\"http://ns.adobe.com/xdp/pdf/\"/>\n</xdp:xdp>\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<Option<(String, String)>, ()> {
|
||||
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<roxmltree::Node> = 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<String> {
|
||||
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..)?))
|
||||
}
|
||||
@@ -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<usize>) -> Option<usize> {
|
||||
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<usize>) -> 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<Edit> {
|
||||
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('/') {
|
||||
// `<a/>` becomes `<a>inner</a>`.
|
||||
let name = qname(src, &range)?;
|
||||
return Some(Edit { start: tag_end.checked_sub(1)?, end: range.end, text: format!(">{inner}</{name}>") });
|
||||
}
|
||||
let close = range.start + src.get(range.clone())?.rfind("</")?;
|
||||
Some(Edit { start: close, end: close, text: inner.to_string() })
|
||||
}
|
||||
|
||||
/// The edit that makes `text` (escaped) the whole content of the leaf `node`.
|
||||
fn replace_edit(src: &str, node: roxmltree::Node, text: &str) -> Option<Edit> {
|
||||
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}</{name}>") });
|
||||
}
|
||||
let close = range.start + src.get(range.clone())?.rfind("</")?;
|
||||
(close > 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 = "<pdfcraft-wrap xmlns:xfa=\"http://www.xfa.org/schema/xfa-data/1.0/\" xmlns:xdp=\"http://ns.adobe.com/xdp/\">";
|
||||
|
||||
pub(super) const WRAP_CLOSE: &str = "</pdfcraft-wrap>";
|
||||
|
||||
/// 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<String> {
|
||||
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<String>) -> 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<String>| 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>{}</{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; `<xfa:data/>` 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}</{name}>") });
|
||||
}
|
||||
(Some(tag_end), _) => {
|
||||
let close = src.get(range.clone()).and_then(|s| s.rfind("</")).map(|c| range.start + c);
|
||||
match close {
|
||||
Some(close) if tag_end < close => 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::<Vec<_>>().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}</{name}>") });
|
||||
}
|
||||
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<Edit> = 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<Edit>, warnings: &mut Vec<String>) {
|
||||
// Element children by local name, per element visited (built once each).
|
||||
let mut index: HashMap<roxmltree::NodeId, HashMap<String, Vec<roxmltree::NodeId>>> = 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<String, Vec<roxmltree::NodeId>> = 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<String>) -> Option<String> {
|
||||
let mut current: Option<String> = 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<String>) -> 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
|
||||
}
|
||||
@@ -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<ObjRef>,
|
||||
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<Want> {
|
||||
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<DataPath>,
|
||||
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 (`<passwordEdit/>` 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<usize, XfaError> {
|
||||
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)
|
||||
}
|
||||
@@ -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<DataNode> =
|
||||
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::<String>() } 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<DataNode> {
|
||||
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::<usize>().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::<Vec<_>>().join(".")
|
||||
}
|
||||
@@ -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<String>),
|
||||
/// 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<String> {
|
||||
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>) -> 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<String> = 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<String> {
|
||||
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<T>(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<Want> {
|
||||
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()
|
||||
}
|
||||
@@ -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<String>,
|
||||
/// The datasets stream written, when one was.
|
||||
pub stream: Option<ObjRef>,
|
||||
}
|
||||
|
||||
/// 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<String>) -> 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<DatasetsWrite, XfaError> {
|
||||
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<ObjRef>) -> Result<DatasetsWrite, XfaError> {
|
||||
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<ObjRef>,
|
||||
) -> Result<DatasetsWrite, XfaError> {
|
||||
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<DatasetsWrite, XfaError> {
|
||||
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<DatasetsWrite, XfaError> {
|
||||
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<DatasetsWrite, XfaError> {
|
||||
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<usize> = 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<ObjRef>) -> Result<DatasetsWrite, XfaError> {
|
||||
// Runs of values (and instances) merge in one pass; a value set twice keeps the last.
|
||||
let mut steps: Vec<Vec<Want>> = Vec::new();
|
||||
let mut removes: Vec<Option<DataPath>> = Vec::new();
|
||||
let mut run: Vec<Want> = 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<Want>, steps: &mut Vec<Vec<Want>>, removes: &mut Vec<Option<DataPath>>| {
|
||||
if !run.is_empty() {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut kept: Vec<Want> = 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<Op> = 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<ObjRef>, 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<ObjRef>) -> Result<DatasetsWrite, XfaError> {
|
||||
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<ObjRef> {
|
||||
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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user