mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-10 21:11:52 +00:00
1409 lines
64 KiB
Rust
1409 lines
64 KiB
Rust
//! Template XML → [`model`](crate::model). Only what the layout engine uses is read; everything
|
|
//! else in the template is ignored, never rejected.
|
|
|
|
use base64::Engine;
|
|
use roxmltree::Node as XmlNode;
|
|
|
|
use crate::XfaError;
|
|
use crate::model::{
|
|
Access, Anchor, Area, Border, Caption, ChoiceList, Color, Common, Corner, Draw, Edge, ExclGroup, Field, FontSpec, HAlign, Layout, Margin, Node,
|
|
Occur, PageArea, PageRole, PageSet, ParaSpec, Paragraph, Placement, Presence, Prop, Props, Rect, RichText, Run, Script, Stroke, Subform,
|
|
Template, Traversal, Ui, VAlign, Value,
|
|
};
|
|
|
|
/// Deepest element nesting followed (templates are shallow; hostile ones are not).
|
|
const MAX_DEPTH: usize = 64;
|
|
/// Most XML nodes a template may have.
|
|
const MAX_NODES: u32 = 2_000_000;
|
|
/// Largest decoded inline image.
|
|
const MAX_IMAGE: usize = 32 << 20;
|
|
/// Most items a choice list keeps.
|
|
const MAX_ITEMS: usize = 1000;
|
|
/// Longest text run kept.
|
|
const MAX_TEXT: usize = 1 << 20;
|
|
/// Most content areas kept on one page area. A master page has a handful (the most in the
|
|
/// 500-form corpus is nine); laying a splittable subform out across tens of thousands of them,
|
|
/// which `next_area` walks before starting a new page, costs time quadratic in the paragraphs.
|
|
const MAX_CONTENT_AREAS: usize = 128;
|
|
|
|
/// Parse a measurement such as `12.7mm`, `72pt`, `1in`, `2.54cm` into points. Bare numbers are
|
|
/// points. `None` for anything else (including `em`).
|
|
pub fn measure(s: &str) -> Option<f64> {
|
|
let s = s.trim();
|
|
let split = s.find(|c: char| c.is_ascii_alphabetic() || c == '%').unwrap_or(s.len());
|
|
let (num, unit) = s.split_at(split);
|
|
let v: f64 = num.trim().parse().ok()?;
|
|
if !v.is_finite() {
|
|
return None;
|
|
}
|
|
let k = match unit.trim() {
|
|
"" | "pt" => 1.0,
|
|
"mm" => 72.0 / 25.4,
|
|
"cm" => 72.0 / 2.54,
|
|
"in" => 72.0,
|
|
"px" => 0.75,
|
|
"pc" => 12.0,
|
|
_ => return None,
|
|
};
|
|
Some((v * k).clamp(-100_000.0, 100_000.0))
|
|
}
|
|
|
|
/// The attribute `name` of `n` read as a measurement in points (see [`measure`]); `None`
|
|
/// when it is missing or unreadable.
|
|
fn attr_measure(n: XmlNode, name: &str) -> Option<f64> {
|
|
n.attribute(name).and_then(measure)
|
|
}
|
|
|
|
/// A horizontal scale such as `90%` (a bare number is a percentage too), as a fraction, within
|
|
/// 1% and 1000%; `None` when it isn't a positive number.
|
|
fn scale(v: &str) -> Option<f64> {
|
|
let v: f64 = v.trim().trim_end_matches('%').trim().parse().ok()?;
|
|
(v.is_finite() && v > 0.0).then(|| (v / 100.0).clamp(0.01, 10.0))
|
|
}
|
|
|
|
/// The first child element of `n` named `name` (by local name, in any namespace).
|
|
fn child<'a, 'd>(n: XmlNode<'a, 'd>, name: &str) -> Option<XmlNode<'a, 'd>> {
|
|
n.children().find(|c| c.is_element() && c.tag_name().name() == name)
|
|
}
|
|
|
|
fn children<'a, 'd>(n: XmlNode<'a, 'd>, name: &'a str) -> impl Iterator<Item = XmlNode<'a, 'd>> + 'a {
|
|
n.children().filter(move |c| c.is_element() && c.tag_name().name() == name)
|
|
}
|
|
|
|
/// The text of an image element: base64 is read whole up to the image cap (`text_of` stops at
|
|
/// 1 MiB, which would cut a photo short).
|
|
fn image_text_of(n: XmlNode) -> Option<Vec<u8>> {
|
|
let mut out = Vec::new();
|
|
for d in n.descendants() {
|
|
if d.is_text()
|
|
&& let Some(t) = d.text()
|
|
{
|
|
out.extend(t.bytes().filter(|b| !b.is_ascii_whitespace()));
|
|
if out.len() > MAX_IMAGE / 3 * 4 + 4 {
|
|
return None;
|
|
}
|
|
}
|
|
}
|
|
Some(out)
|
|
}
|
|
|
|
fn text_of(n: XmlNode) -> String {
|
|
let mut s = String::new();
|
|
for d in n.descendants() {
|
|
if d.is_text()
|
|
&& let Some(t) = d.text()
|
|
{
|
|
s.push_str(t);
|
|
if s.len() > MAX_TEXT {
|
|
s.truncate(s.char_indices().take_while(|(i, _)| *i < MAX_TEXT).last().map_or(0, |(i, c)| i + c.len_utf8()));
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
s
|
|
}
|
|
|
|
fn parse_margin(n: Option<XmlNode>) -> Margin {
|
|
let Some(n) = n else { return Margin::default() };
|
|
Margin {
|
|
top: attr_measure(n, "topInset").unwrap_or(0.0),
|
|
right: attr_measure(n, "rightInset").unwrap_or(0.0),
|
|
bottom: attr_measure(n, "bottomInset").unwrap_or(0.0),
|
|
left: attr_measure(n, "leftInset").unwrap_or(0.0),
|
|
}
|
|
}
|
|
|
|
fn parse_color(n: Option<XmlNode>) -> Option<Color> {
|
|
n.and_then(|c| c.attribute("value")).and_then(Color::parse)
|
|
}
|
|
|
|
fn parse_edge(n: XmlNode) -> Edge {
|
|
Edge {
|
|
presence: Presence::parse(n.attribute("presence")),
|
|
thickness: attr_measure(n, "thickness").unwrap_or(0.5).max(0.0),
|
|
color: parse_color(child(n, "color")).unwrap_or(Color::BLACK),
|
|
stroke: match n.attribute("stroke") {
|
|
Some("dashed" | "dashDot" | "dashDotDot") => Stroke::Dashed,
|
|
Some("dotted") => Stroke::Dotted,
|
|
Some("raised" | "lowered" | "etched" | "embossed") => Stroke::Relief,
|
|
_ => Stroke::Solid,
|
|
},
|
|
}
|
|
}
|
|
|
|
/// `<fill>`: a colour, or white when the element is present without one.
|
|
fn parse_fill(n: Option<XmlNode>) -> Option<Color> {
|
|
let n = n?;
|
|
if Presence::parse(n.attribute("presence")) != Presence::Visible {
|
|
return None;
|
|
}
|
|
Some(parse_color(child(n, "color")).unwrap_or(Color::WHITE))
|
|
}
|
|
|
|
/// `<border>`: edges top, right, bottom, left (XFA 3.3 p. 38), the sides not listed drawn as
|
|
/// readers draw them ([`drawn_side`]), not as p. 38's text says.
|
|
fn parse_border(n: Option<XmlNode>) -> Option<Border> {
|
|
let n = n?;
|
|
let edges: Vec<Edge> = children(n, "edge").take(4).map(parse_edge).collect();
|
|
let four = std::array::from_fn(|i| drawn_side(edges.len(), i).and_then(|k| edges.get(k).copied()).unwrap_or_default());
|
|
Some(Border { presence: Presence::parse(n.attribute("presence")), edges: four, corners: parse_corners(n), fill: parse_fill(child(n, "fill")) })
|
|
}
|
|
|
|
/// Which of the `listed` edges (or corners) of a border is drawn on side `index`: edges go
|
|
/// top, right, bottom, left and corners top-left, top-right, bottom-right, bottom-left (XFA 3.3
|
|
/// p. 38). For a border that lists fewer than four, p. 38 says the last one listed is reused
|
|
/// for the rest; readers draw a side not listed from the edge or corner on the same side
|
|
/// instead, and so does this: one serves every side, two alternate (0, 1, 0, 1), three reuse
|
|
/// the second for the fourth (0, 1, 2, 1). Edges already followed the readers; corners now do
|
|
/// too. The scripting host's reads mirror the same pattern so a script reads what is drawn.
|
|
/// `None` when none is listed.
|
|
pub(crate) fn drawn_side(listed: usize, index: usize) -> Option<usize> {
|
|
match listed {
|
|
0 => None,
|
|
_ if index < listed => Some(index),
|
|
1 => Some(0),
|
|
2 => Some(index % 2),
|
|
_ => Some(1),
|
|
}
|
|
}
|
|
|
|
/// The `<corner>` children of a border or rectangle, top-left, top-right, bottom-right then
|
|
/// bottom-left, the ones not listed drawn as [`drawn_side`] picks, as edges are. Without any,
|
|
/// the corners are square.
|
|
fn parse_corners(n: XmlNode) -> [Corner; 4] {
|
|
let given: Vec<Corner> = children(n, "corner")
|
|
.take(4)
|
|
.map(|c| Corner {
|
|
presence: Presence::parse(c.attribute("presence")),
|
|
radius: attr_measure(c, "radius").filter(|r| r.is_finite()).unwrap_or(0.0).max(0.0),
|
|
inverted: c.attribute("inverted") == Some("1"),
|
|
})
|
|
.collect();
|
|
std::array::from_fn(|i| drawn_side(given.len(), i).and_then(|k| given.get(k).copied()).unwrap_or_default())
|
|
}
|
|
|
|
/// `<font>`: typeface, size, weight, posture, underline, fill color and horizontal scale.
|
|
/// What the element doesn't set, or a missing element, is left `None` to inherit.
|
|
fn parse_font(n: Option<XmlNode>) -> FontSpec {
|
|
let Some(n) = n else { return FontSpec::default() };
|
|
FontSpec {
|
|
typeface: n.attribute("typeface").map(|t| t.trim().to_string()).filter(|t| !t.is_empty()),
|
|
size: attr_measure(n, "size").filter(|s| *s > 0.0),
|
|
bold: n.attribute("weight").map(|w| w == "bold"),
|
|
italic: n.attribute("posture").map(|p| p == "italic"),
|
|
underline: n.attribute("underline").map(|u| u != "0"),
|
|
color: parse_color(child(n, "fill").and_then(|f| child(f, "color"))),
|
|
h_scale: n.attribute("fontHorizontalScale").and_then(scale),
|
|
}
|
|
}
|
|
|
|
fn parse_para(n: Option<XmlNode>) -> ParaSpec {
|
|
let Some(n) = n else { return ParaSpec::default() };
|
|
ParaSpec {
|
|
h_align: HAlign::parse(n.attribute("hAlign")),
|
|
v_align: VAlign::parse(n.attribute("vAlign")),
|
|
line_height: attr_measure(n, "lineHeight").filter(|v| *v > 0.0),
|
|
margin_left: attr_measure(n, "marginLeft"),
|
|
margin_right: attr_measure(n, "marginRight"),
|
|
space_above: attr_measure(n, "spaceAbove"),
|
|
space_below: attr_measure(n, "spaceBelow"),
|
|
text_indent: attr_measure(n, "textIndent"),
|
|
}
|
|
}
|
|
|
|
/// `relevant`: a space-separated list of views, each `+name` (or `name`) or `-name`. What it says
|
|
/// about printing (`Some(true)` for `+print`, `Some(false)` for `-print`; the last one counts), and
|
|
/// the first token about any other view, which is left aside.
|
|
fn parse_relevant(s: Option<&str>) -> (Option<bool>, Option<String>) {
|
|
let (mut print, mut other) = (None, None);
|
|
for token in s.unwrap_or_default().split_whitespace().take(64) {
|
|
match token {
|
|
"+print" | "print" => print = Some(true),
|
|
"-print" => print = Some(false),
|
|
_ => {
|
|
if other.is_none() {
|
|
other = Some(token.chars().take(64).collect());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
(print, other)
|
|
}
|
|
|
|
/// `rotate` as quarter turns (XFA 3.3: degrees counter-clockwise, a multiple of 90, `deg` optional):
|
|
/// anything else leaves the object as it stands.
|
|
fn quarter_turns(rotate: Option<&str>) -> u8 {
|
|
let degrees = rotate.map(str::trim).map(|v| v.strip_suffix("deg").unwrap_or(v).trim_end());
|
|
let turns = degrees.and_then(|v| v.parse::<f64>().ok()).map(|v| v.rem_euclid(360.0) / 90.0);
|
|
// Whole, which NaN and infinities aren't, and in range, so the cast is exact; a tiny negative
|
|
// angle rounds to a full turn.
|
|
turns.filter(|t| t.fract() == 0.0).map_or(0, |t| t as u8 % 4)
|
|
}
|
|
|
|
/// What element `n` shares with every container and leaf ([`Common`]).
|
|
fn parse_common(n: XmlNode, warnings: &mut Vec<String>) -> Common {
|
|
let (relevant, relevant_other) = parse_relevant(n.attribute("relevant"));
|
|
Common {
|
|
props: std::sync::Arc::new(parse_props(n, warnings)),
|
|
name: n.attribute("name").map(str::to_string).filter(|s| !s.is_empty()),
|
|
id: n.attribute("id").map(str::to_string),
|
|
x: attr_measure(n, "x"),
|
|
y: attr_measure(n, "y"),
|
|
anchor: Anchor::parse(n.attribute("anchorType")),
|
|
rotate: quarter_turns(n.attribute("rotate")),
|
|
w: attr_measure(n, "w"),
|
|
h: attr_measure(n, "h"),
|
|
min_w: attr_measure(n, "minW"),
|
|
max_w: attr_measure(n, "maxW"),
|
|
min_h: attr_measure(n, "minH"),
|
|
max_h: attr_measure(n, "maxH"),
|
|
presence: Presence::parse(n.attribute("presence")),
|
|
relevant,
|
|
relevant_other,
|
|
margin: parse_margin(child(n, "margin")),
|
|
border: parse_border(child(n, "border")),
|
|
font: parse_font(child(n, "font")),
|
|
para: parse_para(child(n, "para")),
|
|
col_span: n.attribute("colSpan").and_then(|c| c.trim().parse::<usize>().ok()).unwrap_or(1).clamp(1, 64),
|
|
traversal: parse_traversal(child(n, "traversal")),
|
|
}
|
|
}
|
|
|
|
/// Longest traversal reference kept (a SOM path); longer ones are ignored.
|
|
const MAX_TRAVERSE_REF: usize = 1024;
|
|
|
|
/// `<traversal><traverse operation="first|next" ref="…"/>`: the first of each kept. A
|
|
/// `<traverse>` that computes its target with a script, or names none, is ignored.
|
|
fn parse_traversal(n: Option<XmlNode>) -> Traversal {
|
|
let mut out = Traversal::default();
|
|
for t in n.into_iter().flat_map(|n| children(n, "traverse")) {
|
|
let Some(r) = t.attribute("ref").map(str::trim).filter(|r| !r.is_empty() && r.len() <= MAX_TRAVERSE_REF) else { continue };
|
|
let slot = match t.attribute("operation").map(str::trim).unwrap_or("next") {
|
|
"first" => &mut out.first,
|
|
"next" => &mut out.next,
|
|
_ => continue,
|
|
};
|
|
slot.get_or_insert_with(|| r.to_string());
|
|
}
|
|
out
|
|
}
|
|
|
|
/// The property elements of an object that scripts read through the object model (XFA 3.3
|
|
/// ch. 17: border p. 629, font p. 741, para p. 800, margin p. 776, ui p. 877, validate p. 880,
|
|
/// occur p. 786, break p. 632, breakBefore p. 637, breakAfter p. 635, assist p. 609, format
|
|
/// p. 745, value p. 882, bind p. 623, desc p. 674, extras p. 724, keep p. 762, caption p. 643,
|
|
/// items p. 758, calculate p. 641).
|
|
const PROP_TAGS: &[&str] = &[
|
|
"border",
|
|
"font",
|
|
"para",
|
|
"margin",
|
|
"ui",
|
|
"validate",
|
|
"occur",
|
|
"break",
|
|
"breakBefore",
|
|
"breakAfter",
|
|
"assist",
|
|
"format",
|
|
"value",
|
|
"bind",
|
|
"desc",
|
|
"extras",
|
|
"keep",
|
|
"caption",
|
|
"items",
|
|
"calculate",
|
|
];
|
|
/// Most property elements kept for one object, all levels together. A field written by a form
|
|
/// designer holds about 40 (four edges and corners with their colours, a fill, a font, a widget
|
|
/// with its own border); past this the rest are left out and a warning says so.
|
|
pub const MAX_OBJECT_PROPS: usize = 256;
|
|
/// Most bytes of tags, attribute names, values and text kept for one object's properties: a
|
|
/// designer's field holds well under 2 KiB.
|
|
pub const MAX_OBJECT_PROP_BYTES: usize = 16 << 10;
|
|
/// Deepest property element kept below an object (`ui > textEdit > border > edge > color` is 5).
|
|
const MAX_PROP_DEPTH: usize = 8;
|
|
/// Most attributes kept on one element.
|
|
const MAX_PROP_ATTRS: usize = 64;
|
|
/// Longest validation or calculation script kept for scripts to read, in bytes: the script
|
|
/// engine's own limit on a script (`pdfcraft_js`'s `MAX_SCRIPT_BYTES`), past which it would not run anyway.
|
|
const MAX_VALIDATE_SCRIPT_BYTES: usize = 256 * 1024;
|
|
/// The warning when an object's properties passed a limit.
|
|
pub const PROPS_CUT_WARNING: &str = "an object has more property elements, attributes or text than scripts are given; the rest are left out";
|
|
|
|
/// What is left of one object's property budget.
|
|
struct PropBudget {
|
|
elements: usize,
|
|
bytes: usize,
|
|
cut: bool,
|
|
}
|
|
|
|
impl PropBudget {
|
|
/// `s` when the bytes are left for it (charged), else `None` and the cut noted.
|
|
fn text(&mut self, s: &str) -> Option<String> {
|
|
match self.bytes.checked_sub(s.len()) {
|
|
Some(left) => {
|
|
self.bytes = left;
|
|
Some(s.to_string())
|
|
}
|
|
None => {
|
|
self.cut = true;
|
|
None
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The attributes of `n`, to [`MAX_PROP_ATTRS`] and while the bytes last.
|
|
fn attrs(&mut self, n: XmlNode) -> Vec<(String, String)> {
|
|
let mut out = Vec::new();
|
|
for a in n.attributes() {
|
|
if out.len() >= MAX_PROP_ATTRS {
|
|
self.cut = true;
|
|
break;
|
|
}
|
|
let Some(name) = self.text(a.name()) else { break };
|
|
let Some(value) = self.text(a.value()) else { break };
|
|
out.push((name, value));
|
|
}
|
|
out
|
|
}
|
|
}
|
|
|
|
/// Most `<items>` of one object kept as lists ([`items_prop`]): a field has two at most, the
|
|
/// shown list and the saved values (XFA 3.3 p. 726, `<items/> [0..2]`), the two the field's own
|
|
/// lists are read from. Forms do carry more (one public form has a third, empty, saved list);
|
|
/// any further one is kept as an ordinary property, every entry charged to the object's budget.
|
|
const MAX_ITEM_LISTS: usize = 2;
|
|
|
|
/// An `<items>` element (XFA 3.3 p. 758) as scripts read it: its attributes and its entries,
|
|
/// each with its tag and text, as the choice list or check button shows them ([`parse_items`]:
|
|
/// the same [`MAX_ITEMS`] and the same text), so a script reads what the list holds. The
|
|
/// element and its attributes are charged to the object's budget like any property; its
|
|
/// entries are not (a list of a few hundred countries would otherwise crowd out every other
|
|
/// property), so they are bounded by the list's own limits instead: [`MAX_ITEMS`] entries of
|
|
/// at most [`MAX_TEXT`] each, in at most [`MAX_ITEM_LISTS`] lists, no more than the field's
|
|
/// own lists hold. An entry's attributes are not kept: scripts read an entry's value.
|
|
fn items_prop(n: XmlNode, b: &mut PropBudget) -> Option<Prop> {
|
|
if b.elements == 0 {
|
|
b.cut = true;
|
|
return None;
|
|
}
|
|
b.elements -= 1;
|
|
let tag = b.text(n.tag_name().name())?;
|
|
let attrs = b.attrs(n);
|
|
let children = n
|
|
.children()
|
|
.filter(|c| c.is_element())
|
|
.take(MAX_ITEMS)
|
|
.map(|c| Prop { tag: c.tag_name().name().to_string(), attrs: Vec::new(), text: Some(std::sync::Arc::from(text_of(c))), children: Vec::new() })
|
|
.collect();
|
|
Some(Prop { tag, attrs, text: None, children })
|
|
}
|
|
|
|
/// The property elements of `n` scripts read: its own attributes and the [`PROP_TAGS`]
|
|
/// elements with what they hold, within [`MAX_OBJECT_PROPS`] elements,
|
|
/// [`MAX_OBJECT_PROP_BYTES`] of text, [`MAX_PROP_DEPTH`] levels and [`MAX_PROP_ATTRS`]
|
|
/// attributes an element; past them the rest is left out and [`PROPS_CUT_WARNING`] is added
|
|
/// once. Only elements in the object's namespace are properties (XFA 3.3 p. 598); a `script` is
|
|
/// read only in a `validate` or a `calculate`, and only its first (forms read its text, which
|
|
/// is not charged to the budget but kept within [`MAX_VALIDATE_SCRIPT_BYTES`]), the first
|
|
/// [`MAX_ITEM_LISTS`] `items` as lists (see [`items_prop`]), `extras` only as the object's own, the XHTML
|
|
/// of `exData` not at all, and of `value` only its content element's tag and attributes (the
|
|
/// data is the object's value, read elsewhere).
|
|
fn parse_props(n: XmlNode, warnings: &mut Vec<String>) -> Props {
|
|
/// The property elements under `n`, whose tag is `within` (empty for the object itself):
|
|
/// `extras` in the object and inside one (forms keep named values there), `script` in a
|
|
/// `validate` or a `calculate` (XFA 3.3 p. 880, p. 641, p. 827).
|
|
fn elements<'a, 'd>(n: XmlNode<'a, 'd>, within: &str) -> impl Iterator<Item = XmlNode<'a, 'd>> + use<'a, 'd> {
|
|
let (extras, script) = (within.is_empty() || within == "extras", matches!(within, "validate" | "calculate"));
|
|
let ns = n.tag_name().namespace();
|
|
n.children().filter(move |c| {
|
|
c.is_element()
|
|
&& c.tag_name().namespace() == ns
|
|
&& match c.tag_name().name() {
|
|
"script" => script,
|
|
"extras" => extras,
|
|
_ => true,
|
|
}
|
|
})
|
|
}
|
|
/// `n` as a property, `depth` below the object; `bare`: its tag and attributes only.
|
|
fn prop(n: XmlNode, depth: usize, bare: bool, b: &mut PropBudget) -> Option<Prop> {
|
|
if depth > MAX_PROP_DEPTH || b.elements == 0 {
|
|
b.cut = true;
|
|
return None;
|
|
}
|
|
b.elements -= 1;
|
|
let tag = b.text(n.tag_name().name())?;
|
|
let mut p = Prop { attrs: b.attrs(n), tag, text: None, children: Vec::new() };
|
|
if bare {
|
|
return Some(p);
|
|
}
|
|
if p.tag == "exData" {
|
|
// Rich text: its words, as scripts read a caption's rich text (`exData.value`), not
|
|
// its XHTML elements.
|
|
let t = text_of(n);
|
|
let t = t.trim();
|
|
if !t.is_empty() {
|
|
p.text = b.text(t).map(std::sync::Arc::from);
|
|
}
|
|
return Some(p);
|
|
}
|
|
if p.tag == "script" {
|
|
// A validation's or calculation's script: its text is not charged to the budget, so a long script
|
|
// leaves the other properties their room, but it is kept only within the script
|
|
// engine's own limit on a script, past which it would not run anyway.
|
|
let t = text_of(n);
|
|
let t = t.trim();
|
|
if t.len() > MAX_VALIDATE_SCRIPT_BYTES {
|
|
b.cut = true;
|
|
} else if !t.is_empty() {
|
|
p.text = Some(std::sync::Arc::from(t));
|
|
}
|
|
return Some(p);
|
|
}
|
|
let mut kids = elements(n, p.tag.as_str()).peekable();
|
|
if kids.peek().is_none() {
|
|
// The element's own text only: not that of children left out (an `extras`, a
|
|
// `script`), and no more than the budget holds.
|
|
let mut own = String::new();
|
|
for t in n.children().filter(|c| c.is_text()).filter_map(|c| c.text()) {
|
|
if own.len().saturating_add(t.len()) > b.bytes {
|
|
b.cut = true;
|
|
own.clear();
|
|
break;
|
|
}
|
|
own.push_str(t);
|
|
}
|
|
let t = own.trim();
|
|
if !t.is_empty() {
|
|
p.text = b.text(t).map(std::sync::Arc::from);
|
|
}
|
|
return Some(p);
|
|
}
|
|
// The object's own value holds its data, read elsewhere: of it only the content
|
|
// element's tag. A caption's value is its label, and is read whole.
|
|
let content_only = p.tag == "value" && depth == 1;
|
|
// A validation or a calculation has one script (XFA 3.3 p. 880, p. 641: [0..1]); any
|
|
// more are not read.
|
|
let mut scripts = 0usize;
|
|
for k in kids {
|
|
if k.tag_name().name() == "script" {
|
|
scripts = scripts.saturating_add(1);
|
|
if scripts > 1 {
|
|
continue;
|
|
}
|
|
}
|
|
match prop(k, depth + 1, content_only, b) {
|
|
Some(c) => p.children.push(c),
|
|
None => break,
|
|
}
|
|
}
|
|
Some(p)
|
|
}
|
|
let mut b = PropBudget { elements: MAX_OBJECT_PROPS, bytes: MAX_OBJECT_PROP_BYTES, cut: false };
|
|
let attrs = b.attrs(n);
|
|
let mut out = Vec::new();
|
|
// `desc` and `extras` last: they can hold the most (forms keep data there), so a cut falls
|
|
// on them before the appearance and validation properties (their order means nothing,
|
|
// XFA 3.3 p. 78).
|
|
let bulky = |c: &XmlNode| matches!(c.tag_name().name(), "desc" | "extras");
|
|
let first = elements(n, "").filter(|c| PROP_TAGS.contains(&c.tag_name().name()) && !bulky(c));
|
|
let mut lists = 0usize;
|
|
for c in first.chain(elements(n, "").filter(bulky)) {
|
|
let p = if c.tag_name().name() == "items" && lists < MAX_ITEM_LISTS {
|
|
lists += 1;
|
|
items_prop(c, &mut b)
|
|
} else {
|
|
prop(c, 1, false, &mut b)
|
|
};
|
|
match p {
|
|
Some(p) => out.push(p),
|
|
None => break,
|
|
}
|
|
}
|
|
if b.cut && !warnings.iter().any(|w| w == PROPS_CUT_WARNING) {
|
|
warnings.push(PROPS_CUT_WARNING.into());
|
|
}
|
|
Props { attrs, elements: out }
|
|
}
|
|
|
|
/// Rich text: XHTML `body > p > span…`. `br` starts a new paragraph; `xfa:embed` spans become
|
|
/// embedded fields; the text of an `<a href>` keeps where it links to.
|
|
fn parse_rich(body: XmlNode) -> RichText {
|
|
#[derive(Clone, Default)]
|
|
struct Style {
|
|
bold: Option<bool>,
|
|
italic: Option<bool>,
|
|
underline: Option<bool>,
|
|
size: Option<f64>,
|
|
h_scale: Option<f64>,
|
|
/// The `href` of the `<a>` the text is in.
|
|
link: Option<std::sync::Arc<str>>,
|
|
}
|
|
/// `base` with the CSS declarations in `css` applied: weight, style, underline, size and
|
|
/// `xfa-font-horizontal-scale`. Anything else is ignored.
|
|
fn styled(base: &Style, css: Option<&str>) -> Style {
|
|
let mut s = base.clone();
|
|
for decl in css.unwrap_or("").split(';') {
|
|
let Some((k, v)) = decl.split_once(':') else { continue };
|
|
let v = v.trim();
|
|
match k.trim() {
|
|
"font-weight" => s.bold = Some(v == "bold" || v.parse::<u32>().is_ok_and(|w| w >= 600)),
|
|
"font-style" => s.italic = Some(v == "italic" || v == "oblique"),
|
|
"text-decoration" => s.underline = Some(v.contains("underline")),
|
|
"font-size" => s.size = measure(v).filter(|x| *x > 0.0),
|
|
"xfa-font-horizontal-scale" => s.h_scale = scale(v),
|
|
_ => {}
|
|
}
|
|
}
|
|
s
|
|
}
|
|
// Paragraph boundaries from `p` are implicit (dropped when empty); `br` makes an explicit
|
|
// line break that stays even when the line is blank.
|
|
/// Add the text under `n` to `out` as runs in `style`, as the elements it meets restyle it;
|
|
/// `p` and `br` start paragraphs (`explicit` marks the `br` ones) and `embed` spans add
|
|
/// embedded fields. Nothing deeper than `MAX_DEPTH` is read.
|
|
fn walk(n: XmlNode, style: &Style, out: &mut RichText, explicit: &mut Vec<bool>, depth: usize) {
|
|
if depth > MAX_DEPTH {
|
|
return;
|
|
}
|
|
for c in n.children() {
|
|
if c.is_text() {
|
|
if let Some(t) = c.text()
|
|
&& !t.is_empty()
|
|
{
|
|
push_run(
|
|
out,
|
|
explicit,
|
|
Run {
|
|
text: t.to_string(),
|
|
bold: style.bold,
|
|
italic: style.italic,
|
|
underline: style.underline,
|
|
size: style.size,
|
|
h_scale: style.h_scale,
|
|
embed: None,
|
|
link: style.link.clone(),
|
|
},
|
|
);
|
|
}
|
|
continue;
|
|
}
|
|
if !c.is_element() {
|
|
continue;
|
|
}
|
|
match c.tag_name().name() {
|
|
"p" => {
|
|
let align = c.attribute("style").and_then(|s| {
|
|
s.split(';')
|
|
.find_map(|d| d.split_once(':').filter(|(k, _)| k.trim() == "text-align").and_then(|(_, v)| HAlign::parse(Some(v))))
|
|
});
|
|
out.paragraphs.push(Paragraph { runs: Vec::new(), h_align: align });
|
|
explicit.push(false);
|
|
walk(c, &styled(style, c.attribute("style")), out, explicit, depth + 1);
|
|
// The next text starts a new paragraph.
|
|
out.paragraphs.push(Paragraph::default());
|
|
explicit.push(false);
|
|
}
|
|
"br" => {
|
|
out.paragraphs.push(Paragraph::default());
|
|
explicit.push(true);
|
|
}
|
|
"span" | "b" | "i" | "u" | "a" | "div" | "font" => {
|
|
let mut st = styled(style, c.attribute("style"));
|
|
match c.tag_name().name() {
|
|
"b" => st.bold = Some(true),
|
|
"i" => st.italic = Some(true),
|
|
"u" => st.underline = Some(true),
|
|
// A link's text keeps where it points; an `<a>` without one (a named
|
|
// anchor) leaves the text as it was.
|
|
"a" => {
|
|
if let Some(href) = c.attribute("href").map(str::trim).filter(|h| !h.is_empty()) {
|
|
st.link = Some(std::sync::Arc::from(href));
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
let embed = c.attributes().find(|a| a.name() == "embed").map(|a| a.value().trim_start_matches('#').to_string());
|
|
if let Some(e) = embed {
|
|
push_run(
|
|
out,
|
|
explicit,
|
|
Run {
|
|
text: String::new(),
|
|
bold: st.bold,
|
|
italic: st.italic,
|
|
underline: st.underline,
|
|
size: st.size,
|
|
h_scale: st.h_scale,
|
|
embed: Some(e),
|
|
link: st.link.clone(),
|
|
},
|
|
);
|
|
}
|
|
walk(c, &st, out, explicit, depth + 1);
|
|
}
|
|
_ => walk(c, style, out, explicit, depth + 1),
|
|
}
|
|
}
|
|
}
|
|
fn push_run(out: &mut RichText, explicit: &mut Vec<bool>, run: Run) {
|
|
if out.paragraphs.is_empty() {
|
|
out.paragraphs.push(Paragraph::default());
|
|
explicit.push(false);
|
|
}
|
|
if let Some(p) = out.paragraphs.last_mut() {
|
|
p.runs.push(run);
|
|
}
|
|
}
|
|
let mut out = RichText::default();
|
|
let mut explicit = Vec::new();
|
|
walk(body, &Style::default(), &mut out, &mut explicit, 0);
|
|
// Drop the empty paragraphs `p` boundaries leave behind; blank lines from `br` stay.
|
|
let mut cleaned: Vec<Paragraph> = Vec::new();
|
|
for (i, p) in out.paragraphs.into_iter().enumerate() {
|
|
let blank = p.runs.iter().all(|r| r.text.trim().is_empty() && r.embed.is_none());
|
|
if blank && !explicit.get(i).copied().unwrap_or(false) {
|
|
continue;
|
|
}
|
|
cleaned.push(p);
|
|
}
|
|
while cleaned.last().is_some_and(|p| p.runs.iter().all(|r| r.text.trim().is_empty() && r.embed.is_none())) {
|
|
cleaned.pop();
|
|
}
|
|
RichText { paragraphs: cleaned }
|
|
}
|
|
|
|
/// `<value>`: text, rich text, an image, a line or a rectangle.
|
|
fn parse_value(n: Option<XmlNode>, warnings: &mut Vec<String>) -> Value {
|
|
let Some(n) = n else { return Value::Empty };
|
|
for c in n.children().filter(|c| c.is_element()) {
|
|
match c.tag_name().name() {
|
|
"text" => return Value::Text(text_of(c)),
|
|
"exData" => {
|
|
let html = c.attribute("contentType").is_some_and(|t| t.contains("html")) || child(c, "body").is_some();
|
|
if html {
|
|
let body = child(c, "body").unwrap_or(c);
|
|
return Value::Rich(parse_rich(body));
|
|
}
|
|
return Value::Text(text_of(c));
|
|
}
|
|
"image" => {
|
|
let Some(raw) = image_text_of(c) else {
|
|
warnings.push("an image is larger than 32 MB; it is left out".into());
|
|
return Value::Empty;
|
|
};
|
|
let data = if c.attribute("transferEncoding").is_none_or(|e| e == "base64") {
|
|
match base64::engine::general_purpose::STANDARD.decode(&raw) {
|
|
Ok(d) => d,
|
|
Err(_) => {
|
|
warnings.push("an image's base64 data could not be decoded; it is left out".into());
|
|
return Value::Empty;
|
|
}
|
|
}
|
|
} else {
|
|
raw
|
|
};
|
|
if data.len() > MAX_IMAGE {
|
|
warnings.push("an image is larger than 32 MB; it is left out".into());
|
|
return Value::Empty;
|
|
}
|
|
let href = c.attribute("href").map(str::trim).filter(|h| !h.is_empty()).map(str::to_string);
|
|
return Value::Image { content_type: c.attribute("contentType").unwrap_or("").to_string(), data, href };
|
|
}
|
|
"line" => {
|
|
return Value::Line { slope_up: c.attribute("slope") == Some("/"), edge: child(c, "edge").map(parse_edge).unwrap_or_default() };
|
|
}
|
|
"rectangle" => {
|
|
let mut b = parse_border(Some(c)).unwrap_or_default();
|
|
b.fill = parse_fill(child(c, "fill"));
|
|
return Value::Rectangle(Box::new(b));
|
|
}
|
|
"date" | "time" | "dateTime" | "integer" | "decimal" | "float" | "boolean" => return Value::Text(text_of(c)),
|
|
_ => {}
|
|
}
|
|
}
|
|
Value::Empty
|
|
}
|
|
|
|
fn parse_caption(n: Option<XmlNode>, warnings: &mut Vec<String>) -> Option<Caption> {
|
|
let n = n?;
|
|
Some(Caption {
|
|
placement: match n.attribute("placement") {
|
|
Some("right") => Placement::Right,
|
|
Some("top") => Placement::Top,
|
|
Some("bottom") => Placement::Bottom,
|
|
Some("inline") => Placement::Inline,
|
|
_ => Placement::Left,
|
|
},
|
|
// A reserve of zero or less sizes the caption to its content, as none does (XFA 3.3,
|
|
// the caption's reserve).
|
|
reserve: attr_measure(n, "reserve").filter(|r| *r > 0.0),
|
|
presence: Presence::parse(n.attribute("presence")),
|
|
value: parse_value(child(n, "value"), warnings),
|
|
font: parse_font(child(n, "font")),
|
|
para: parse_para(child(n, "para")),
|
|
})
|
|
}
|
|
|
|
/// The field's item lists: what is shown (the first `<items>` that is not the saved values,
|
|
/// also a check button's on and off values) and, for choice lists, the values saved to the
|
|
/// data (`<items save="1">`, usually hidden), empty when the shown text is the value.
|
|
fn parse_items(n: XmlNode, warnings: &mut Vec<String>) -> (Vec<String>, Vec<String>) {
|
|
let lists: Vec<XmlNode> = children(n, "items").collect();
|
|
let is_saved = |i: &XmlNode| i.attribute("save") == Some("1") || i.attribute("presence") == Some("hidden");
|
|
let mut over = false;
|
|
let mut texts = |i: &XmlNode| -> Vec<String> {
|
|
let all: Vec<String> = i.children().filter(|c| c.is_element()).map(text_of).take(MAX_ITEMS + 1).collect();
|
|
if all.len() > MAX_ITEMS {
|
|
over = true;
|
|
}
|
|
all.into_iter().take(MAX_ITEMS).collect()
|
|
};
|
|
let shown = lists.iter().find(|i| !is_saved(i)).or(lists.first());
|
|
let saved = lists.iter().find(|i| is_saved(i) && shown.is_none_or(|s| s != *i));
|
|
let shown: Vec<String> = shown.map(&mut texts).unwrap_or_default();
|
|
let mut saved: Vec<String> = saved.map(&mut texts).unwrap_or_default();
|
|
if over && warnings.len() < 100 {
|
|
warnings.push(format!(
|
|
"{} has more than {MAX_ITEMS} items; the rest are left out",
|
|
n.attribute("name").map_or_else(|| "a choice list".to_string(), |name| format!("the choice list {name}"))
|
|
));
|
|
}
|
|
// A saved list shorter than the shown one is padded with the shown text.
|
|
if !saved.is_empty() && saved.len() < shown.len() {
|
|
saved.extend(shown.iter().skip(saved.len()).cloned());
|
|
}
|
|
(shown, saved)
|
|
}
|
|
|
|
/// A `<field>` element as the model's [`Field`]: its widget kind, caption, value, items,
|
|
/// pictures (edit and data), scripts and access.
|
|
fn parse_field(n: XmlNode, warnings: &mut Vec<String>) -> Field {
|
|
let ui = child(n, "ui");
|
|
let ui_el = ui.and_then(|u| u.children().find(|c| c.is_element() && c.tag_name().name() != "extras" && c.tag_name().name() != "picture"));
|
|
let kind = match ui_el.map(|e| e.tag_name().name()) {
|
|
None | Some("textEdit") => Ui::TextEdit,
|
|
Some("numericEdit") => Ui::NumericEdit,
|
|
Some("dateTimeEdit") => Ui::DateTimeEdit,
|
|
Some("checkButton") => Ui::CheckButton,
|
|
Some("choiceList") => Ui::ChoiceList,
|
|
Some("button") => Ui::Button,
|
|
Some("signature") => Ui::Signature,
|
|
Some("imageEdit") => Ui::ImageEdit,
|
|
Some("barcode") => Ui::Barcode,
|
|
Some("passwordEdit") => Ui::PasswordEdit,
|
|
Some(_) => Ui::Unknown,
|
|
};
|
|
let value_el = child(n, "value");
|
|
let max_chars = value_el
|
|
.and_then(|v| child(v, "text"))
|
|
.and_then(|t| t.attribute("maxChars"))
|
|
.and_then(|m| m.trim().parse::<usize>().ok())
|
|
// XFA 3.3, exData/maxLength: a character limit excluding markup; -1 is unlimited.
|
|
.or_else(|| value_el.and_then(|v| child(v, "exData")).and_then(|e| e.attribute("maxLength")).and_then(|m| m.trim().parse::<usize>().ok()))
|
|
// Preserve the existing zero/unbounded convention of generated form fields.
|
|
.filter(|m| *m > 0);
|
|
let scripts = parse_events(n);
|
|
let numeric = matches!(kind, Ui::NumericEdit)
|
|
|| value_el.is_some_and(|v| v.children().any(|c| c.is_element() && matches!(c.tag_name().name(), "decimal" | "integer" | "float")));
|
|
let (items, item_values) = parse_items(n, warnings);
|
|
let choice = match (kind == Ui::ChoiceList, ui_el) {
|
|
(true, Some(e)) => {
|
|
let open = e.attribute("open").unwrap_or("onEntry");
|
|
ChoiceList {
|
|
list_box: matches!(open, "always" | "multiSelect"),
|
|
multi: open == "multiSelect",
|
|
editable: e.attribute("textEntry").is_some_and(|t| t == "1" || t == "true"),
|
|
}
|
|
}
|
|
_ => ChoiceList::default(),
|
|
};
|
|
let multiline = ui_el.is_some_and(|e| e.attribute("multiLine").is_some_and(|m| m == "1" || m == "true"));
|
|
let validate_el = child(n, "validate");
|
|
let validate_message = validate_el
|
|
.and_then(|v| child(v, "message"))
|
|
.and_then(|m| children(m, "text").find(|t| t.attribute("name") == Some("scriptTest")).or_else(|| child(m, "text")))
|
|
.map(text_of)
|
|
.map(|t| t.trim().to_string())
|
|
.filter(|t| !t.is_empty());
|
|
Field {
|
|
common: parse_common(n, warnings),
|
|
ui: kind,
|
|
ui_border: ui_el.and_then(|e| parse_border(child(e, "border"))),
|
|
ui_margin: parse_margin(ui_el.and_then(|e| child(e, "margin"))),
|
|
check_size: ui_el.and_then(|e| attr_measure(e, "size")).filter(|s| *s > 0.0),
|
|
check_round: ui_el.is_some_and(|e| e.attribute("shape") == Some("round")),
|
|
multiline,
|
|
no_scroll: ui_el.is_some_and(|e| e.attribute(if multiline { "vScrollPolicy" } else { "hScrollPolicy" }) == Some("off")),
|
|
caption: parse_caption(child(n, "caption"), warnings),
|
|
value: parse_value(value_el, warnings),
|
|
items,
|
|
item_values,
|
|
choice,
|
|
max_chars,
|
|
tooltip: child(n, "assist").and_then(|a| child(a, "toolTip")).map(text_of).map(|t| t.trim().to_string()).filter(|t| !t.is_empty()),
|
|
access: match n.attribute("access") {
|
|
Some("readOnly") => Access::ReadOnly,
|
|
Some("protected") => Access::Protected,
|
|
Some("nonInteractive") => Access::NonInteractive,
|
|
_ => Access::Open,
|
|
},
|
|
picture: ui.and_then(|u| child(u, "picture")).map(text_of).map(|p| p.trim().to_string()).filter(|p| !p.is_empty()),
|
|
data_picture: child(n, "bind").and_then(|b| child(b, "picture")).map(text_of).map(|p| p.trim().to_string()).filter(|p| !p.is_empty()),
|
|
data_shared: child(n, "bind")
|
|
.is_some_and(|b| b.attribute("match").is_some_and(|m| m.trim() == "global") || b.attribute("ref").is_some_and(|r| !r.trim().is_empty())),
|
|
scripts,
|
|
calculate: child(n, "calculate").and_then(|c| child(c, "script")).map(|s| parse_script(s, "calculate")),
|
|
validate: validate_el.and_then(|v| child(v, "script")).map(|s| parse_script(s, "validate")),
|
|
validate_message,
|
|
required: must_be_filled(validate_el),
|
|
numeric,
|
|
display_picture: child(n, "format").and_then(|f| child(f, "picture")).map(text_of).map(|p| p.trim().to_string()).filter(|p| !p.is_empty()),
|
|
keep_intact: child(n, "keep").and_then(|k| k.attribute("intact")).is_some_and(|v| v == "contentArea" || v == "pageArea"),
|
|
}
|
|
}
|
|
|
|
/// Whether a `<validate>` element makes its object one that must be filled in: `nullTest="error"`
|
|
/// (`warning` only recommends it, and `disabled`, the default, leaves it optional).
|
|
fn must_be_filled(validate: Option<XmlNode>) -> bool {
|
|
validate.is_some_and(|v| v.attribute("nullTest").is_some_and(|t| t.trim() == "error"))
|
|
}
|
|
|
|
fn parse_script(s: XmlNode, activity: &str) -> Script {
|
|
let ct = s.attribute("contentType").unwrap_or("");
|
|
Script { activity: activity.to_string(), text: text_of(s).trim().to_string(), formcalc: !ct.to_ascii_lowercase().contains("javascript") }
|
|
}
|
|
|
|
/// `<event activity="…"><script>…</script></event>` children.
|
|
fn parse_events(n: XmlNode) -> Vec<Script> {
|
|
let mut scripts = Vec::new();
|
|
for ev in children(n, "event").take(64) {
|
|
if let Some(s) = child(ev, "script") {
|
|
let activity = ev.attribute("activity").unwrap_or("click");
|
|
// `ready` is two events, told apart by `ref`: layout:ready (`$layout`, once the form
|
|
// is laid out) and form:ready (`$form`).
|
|
let activity = if activity == "ready" && ev.attribute("ref").is_some_and(|r| r.trim() == "$layout") { "layoutReady" } else { activity };
|
|
scripts.push(parse_script(s, activity));
|
|
}
|
|
}
|
|
scripts
|
|
}
|
|
|
|
fn parse_occur(n: Option<XmlNode>) -> Occur {
|
|
let Some(n) = n else { return Occur::default() };
|
|
let int = |a: &str| n.attribute(a).and_then(|v| v.trim().parse::<i64>().ok());
|
|
let min = int("min").unwrap_or(1).clamp(0, 1000) as usize;
|
|
let max = match int("max") {
|
|
Some(m) if m < 0 => None,
|
|
Some(m) => Some((m.clamp(0, 1000) as usize).max(min)),
|
|
None => Some(min.max(1)),
|
|
};
|
|
let initial = int("initial").map_or(min, |i| i.clamp(0, 1000) as usize).max(min);
|
|
Occur { min, max, initial: max.map_or(initial, |m| initial.min(m)) }
|
|
}
|
|
|
|
fn parse_page_set(n: XmlNode, warnings: &mut Vec<String>, depth: usize) -> PageSet {
|
|
let mut areas = Vec::new();
|
|
for pa in children(n, "pageArea") {
|
|
let medium = child(pa, "medium");
|
|
let short = medium.and_then(|m| attr_measure(m, "short")).unwrap_or(612.0).clamp(1.0, 14_400.0);
|
|
let long = medium.and_then(|m| attr_measure(m, "long")).unwrap_or(792.0).clamp(1.0, 14_400.0);
|
|
let (width, height) = if medium.and_then(|m| m.attribute("orientation")) == Some("landscape") { (long, short) } else { (short, long) };
|
|
let mut content: Vec<Rect> = children(pa, "contentArea")
|
|
.map(|c| {
|
|
Rect::new(
|
|
attr_measure(c, "x").unwrap_or(0.0),
|
|
attr_measure(c, "y").unwrap_or(0.0),
|
|
attr_measure(c, "w").unwrap_or(width).clamp(1.0, width),
|
|
attr_measure(c, "h").unwrap_or(height).clamp(1.0, height),
|
|
)
|
|
})
|
|
.take(MAX_CONTENT_AREAS + 1)
|
|
.collect();
|
|
if content.len() > MAX_CONTENT_AREAS {
|
|
content.truncate(MAX_CONTENT_AREAS);
|
|
// Said once however many warnings came before: other warnings fill the shared cap of
|
|
// 100 without limit of their own, and this one must not go silent behind them. It is
|
|
// kept first, so whether it was said is one comparison, not a scan of every warning
|
|
// for every page area (nothing else is put first).
|
|
let said = format!("a page area has more than {MAX_CONTENT_AREAS} content areas; the rest are left out");
|
|
if warnings.first() != Some(&said) {
|
|
warnings.insert(0, said);
|
|
}
|
|
}
|
|
if content.is_empty() {
|
|
content.push(Rect::new(0.0, 0.0, width, height));
|
|
}
|
|
let items = pa.children().filter(|c| c.is_element()).filter_map(|c| parse_node(c, warnings, depth + 1, Layout::Positioned)).collect();
|
|
// A page area with no `<occur>` has no limit and no minimum (a subform's default of one
|
|
// would exhaust it, and demand a page for every master).
|
|
let occur = child(pa, "occur").map(|o| parse_occur(Some(o)));
|
|
let (occur_min, occur_max) = occur.map_or((0, None), |o| (o.min, o.max));
|
|
areas.push(PageArea { name: pa.attribute("name").map(str::to_string), width, height, content, items, occur_max, occur_min });
|
|
}
|
|
// Nested page sets: take their areas too.
|
|
for ps in children(n, "pageSet") {
|
|
if depth < MAX_DEPTH {
|
|
areas.extend(parse_page_set(ps, warnings, depth + 1).areas);
|
|
}
|
|
}
|
|
PageSet { areas }
|
|
}
|
|
|
|
/// A `subform` element: its own attributes, its page set if it has one, and the nodes it holds,
|
|
/// `depth` levels down (for the nesting limit). `set_layout` is the layout a `subformSet` takes
|
|
/// on from its container, in place of its own.
|
|
fn parse_subform(n: XmlNode, warnings: &mut Vec<String>, depth: usize, set_layout: Option<Layout>) -> Subform {
|
|
let common = parse_common(n, warnings);
|
|
let layout = set_layout.unwrap_or_else(|| Layout::parse(n.attribute("layout")));
|
|
let mut children_out = Vec::new();
|
|
let mut page_set = None;
|
|
for c in n.children().filter(|c| c.is_element()) {
|
|
match c.tag_name().name() {
|
|
"pageSet" => page_set = Some(parse_page_set(c, warnings, depth + 1)),
|
|
_ => {
|
|
if let Some(node) = parse_node(c, warnings, depth + 1, layout) {
|
|
children_out.push(node);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
let column_widths: Vec<f64> =
|
|
n.attribute("columnWidths").map(|s| s.split_whitespace().filter_map(measure).map(|w| w.max(0.0)).take(256).collect()).unwrap_or_default();
|
|
// A break to a page or content area. With no targetType, the default is `auto`: "layout
|
|
// continues using the current layout container" (XFA 3.3 p. 638), so no break.
|
|
let break_to_page = |name: &str| children(n, name).any(|b| b.attribute("targetType").is_some_and(|t| t == "pageArea" || t == "contentArea"));
|
|
Subform {
|
|
common,
|
|
layout,
|
|
children: children_out,
|
|
scripts: parse_events(n),
|
|
page_set,
|
|
occur: parse_occur(child(n, "occur")),
|
|
break_before_page: break_to_page("breakBefore")
|
|
|| child(n, "break").is_some_and(|b| b.attribute("before").is_some_and(|v| v == "pageArea" || v == "contentArea")),
|
|
break_after_page: break_to_page("breakAfter"),
|
|
set: false,
|
|
column_widths,
|
|
overflow_leader: child(n, "overflow").and_then(|o| o.attribute("leader")).map(str::to_string).filter(|s| !s.is_empty()),
|
|
}
|
|
}
|
|
|
|
/// The template object element `c` stands for (a subform, area, field, draw or exclusion group,
|
|
/// with what it holds), or `None` for anything else and past [`MAX_DEPTH`] levels. What can't be
|
|
/// read faithfully is noted in `warnings`. `parent`: the layout of the container `c` is in, which
|
|
/// a `subformSet` takes on.
|
|
fn parse_node(c: XmlNode, warnings: &mut Vec<String>, depth: usize, parent: Layout) -> Option<Node> {
|
|
if depth > MAX_DEPTH {
|
|
return None;
|
|
}
|
|
Some(match c.tag_name().name() {
|
|
"subform" => Node::Subform(Box::new(parse_subform(c, warnings, depth, None))),
|
|
// A set of related subforms is transparent to layout (XFA 3.3 p. 357): its members are laid out
|
|
// as its container lays out its own children, at their own positions in a positioned one. In a
|
|
// table or a row they go one under another.
|
|
"subformSet" => {
|
|
let layout = if matches!(parent, Layout::Table | Layout::Row) { Layout::Tb } else { parent };
|
|
let mut set = parse_subform(c, warnings, depth, Some(layout));
|
|
set.set = true;
|
|
Node::Subform(Box::new(set))
|
|
}
|
|
"area" => Node::Area(Box::new(Area {
|
|
common: parse_common(c, warnings),
|
|
children: c.children().filter(|k| k.is_element()).filter_map(|k| parse_node(k, warnings, depth + 1, Layout::Positioned)).collect(),
|
|
})),
|
|
"field" => Node::Field(Box::new(parse_field(c, warnings))),
|
|
"draw" => Node::Draw(Box::new(Draw { common: parse_common(c, warnings), value: parse_value(child(c, "value"), warnings) })),
|
|
"exclGroup" => Node::ExclGroup(Box::new(ExclGroup {
|
|
common: parse_common(c, warnings),
|
|
layout: Layout::parse(c.attribute("layout")),
|
|
fields: children(c, "field").map(|f| parse_field(f, warnings)).collect(),
|
|
tooltip: child(c, "assist").and_then(|a| child(a, "toolTip")).map(text_of).map(|t| t.trim().to_string()).filter(|t| !t.is_empty()),
|
|
scripts: parse_events(c),
|
|
required: must_be_filled(child(c, "validate")),
|
|
})),
|
|
_ => return None,
|
|
})
|
|
}
|
|
|
|
/// Whether a `layout:ready` script numbers its own field: assigns it (`this.rawValue = …`, and in
|
|
/// FormCalc `$ = …` or `$.rawValue = …`) an expression that calls `xfa.layout.page(…)`,
|
|
/// `absPage(…)` or `pageCount()`. Such a field is one the layout numbers itself, page by page.
|
|
/// Its code is read (JavaScript, or FormCalc when `formcalc`), not its comments and strings, and
|
|
/// the calls are matched as whole names, with or without spaces before the parenthesis.
|
|
pub fn numbers_pages(script: &str, formcalc: bool) -> bool {
|
|
own_page_role(script, formcalc).is_some()
|
|
}
|
|
|
|
/// The page-number role a `layout:ready` script gives its own field ([`numbers_pages`]): the page
|
|
/// count when what it assigns calls `pageCount()`, else the page number.
|
|
fn own_page_role(script: &str, formcalc: bool) -> Option<PageRole> {
|
|
let code = code_of(script, formcalc);
|
|
// FormCalc names methods case-insensitively; JavaScript does not.
|
|
let own: Vec<String> =
|
|
own_assignments(&code, formcalc).into_iter().map(|rhs| if formcalc { rhs.to_ascii_lowercase() } else { rhs.to_string() }).collect();
|
|
let name = |n: &str| if formcalc { n.to_ascii_lowercase() } else { n.to_string() };
|
|
// On screen every page is a sheet of its own, so the sheet count is the page count, and the
|
|
// sheet number a form shows (`sheet(this) + 1`, the sheet counted from 0) is the page number.
|
|
let sheet_number = |rhs: &str| {
|
|
let compact: String = rhs.chars().filter(|c| !c.is_whitespace()).collect();
|
|
compact.contains("layout.sheet(this)+1") || compact.contains("layout.sheet($)+1")
|
|
};
|
|
if own.iter().any(|rhs| calls_layout(rhs, &name("pageCount")) || calls_layout(rhs, &name("sheetCount"))) {
|
|
Some(PageRole::Count)
|
|
} else if own.iter().any(|rhs| calls_layout(rhs, &name("page")) || calls_layout(rhs, &name("absPage")) || sheet_number(rhs)) {
|
|
Some(PageRole::Number)
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
/// The ids rich text in `nodes` embeds (draws' values, fields' values and captions), down to
|
|
/// [`MAX_DEPTH`].
|
|
fn collect_embedded(nodes: &[Node], out: &mut std::collections::BTreeSet<String>, depth: usize) {
|
|
fn of(value: &Value, out: &mut std::collections::BTreeSet<String>) {
|
|
if let Value::Rich(r) = value {
|
|
out.extend(r.paragraphs.iter().flat_map(|p| p.runs.iter()).filter_map(|r| r.embed.clone()));
|
|
}
|
|
}
|
|
fn field(f: &Field, out: &mut std::collections::BTreeSet<String>) {
|
|
of(&f.value, out);
|
|
if let Some(c) = &f.caption {
|
|
of(&c.value, out);
|
|
}
|
|
}
|
|
if depth > MAX_DEPTH {
|
|
return;
|
|
}
|
|
for n in nodes {
|
|
match n {
|
|
Node::Draw(d) => of(&d.value, out),
|
|
Node::Field(f) => field(f, out),
|
|
Node::ExclGroup(g) => g.fields.iter().for_each(|f| field(f, out)),
|
|
Node::Subform(s) => {
|
|
collect_embedded(&s.children, out, depth + 1);
|
|
if let Some(ps) = &s.page_set {
|
|
for a in &ps.areas {
|
|
collect_embedded(&a.items, out, depth + 1);
|
|
}
|
|
}
|
|
}
|
|
Node::Area(a) => collect_embedded(&a.children, out, depth + 1),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Most places a name is looked at as an assignment to the script's own field ([`own_assignments`]).
|
|
const MAX_ASSIGNMENTS: usize = 256;
|
|
/// Most bytes of an assignment's expression read ([`expression`]); a page-number expression is short.
|
|
const MAX_EXPRESSION: usize = 4096;
|
|
|
|
/// What the statements of `code` assign to the script's own field: the expression after `=` of
|
|
/// each `this.rawValue = …` (in FormCalc also `$.rawValue = …` and `$ = …`) that starts a
|
|
/// statement. The expression runs to a `;` (JavaScript) or to the end of a line that completes it:
|
|
/// brackets closed and not ending in an operator, so `this.rawValue =` with its value on the next
|
|
/// line, or a sum broken over lines, is read whole.
|
|
fn own_assignments(code: &str, formcalc: bool) -> Vec<&str> {
|
|
let targets: &[&str] = if formcalc { &["this.rawValue", "$.rawValue", "$"] } else { &["this.rawValue"] };
|
|
let ident = |c: char| c.is_alphanumeric() || c == '_' || c == '$' || c == '.';
|
|
let mut out = Vec::new();
|
|
for t in targets {
|
|
// At most MAX_ASSIGNMENTS places looked at per name, each expression read at most
|
|
// MAX_EXPRESSION bytes: a hostile script can't make this scan quadratic.
|
|
for (i, _) in code.match_indices(t).take(MAX_ASSIGNMENTS) {
|
|
// A statement's start: nothing before it on its line but spaces, or a `;`, `{` or `}`.
|
|
let before = code.get(..i).unwrap_or_default().trim_end_matches([' ', '\t']);
|
|
if !(before.is_empty() || before.ends_with(['\n', '\r', ';', '{', '}'])) {
|
|
continue;
|
|
}
|
|
let Some(rest) = code.get(i + t.len()..) else { continue };
|
|
// `$` is the whole name (not `$record`, not `$.rawValue`, a target of its own).
|
|
if rest.chars().next().is_some_and(ident) {
|
|
continue;
|
|
}
|
|
let Some(rhs) = rest.trim_start().strip_prefix('=').filter(|r| !r.starts_with('=')) else { continue };
|
|
out.push(expression(rhs, formcalc));
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
/// The expression at the start of `rhs` (what follows an `=`): to a `;` outside brackets in
|
|
/// JavaScript, or to a line break once something is written, the brackets are closed and the
|
|
/// line doesn't end in an operator that continues it.
|
|
fn expression(rhs: &str, formcalc: bool) -> &str {
|
|
// Read no further than MAX_EXPRESSION bytes (cut at a character boundary).
|
|
let cap = (0..=MAX_EXPRESSION.min(rhs.len())).rev().find(|&n| rhs.is_char_boundary(n)).unwrap_or(0);
|
|
let rhs = rhs.get(..cap).unwrap_or_default();
|
|
let mut depth = 0i32;
|
|
let mut last: Option<char> = None;
|
|
for (j, c) in rhs.char_indices() {
|
|
match c {
|
|
'(' | '[' | '{' => depth = depth.saturating_add(1),
|
|
')' | ']' | '}' => depth = depth.saturating_sub(1),
|
|
';' if !formcalc && depth <= 0 => return rhs.get(..j).unwrap_or(rhs),
|
|
'\n' if depth <= 0 && last.is_some_and(|l| !"+-*/%=,(&|?:.<>!".contains(l)) => return rhs.get(..j).unwrap_or(rhs),
|
|
_ => {}
|
|
}
|
|
if !c.is_whitespace() {
|
|
last = Some(c);
|
|
}
|
|
}
|
|
rhs
|
|
}
|
|
|
|
/// Whether `code` calls `layout.<method>(…)`: the method's whole name after `layout.` (spaces
|
|
/// allowed around the dot), then `(` after any spaces.
|
|
fn calls_layout(code: &str, method: &str) -> bool {
|
|
let ident = |c: char| c.is_alphanumeric() || c == '_' || c == '$';
|
|
code.match_indices(method).any(|(i, _)| {
|
|
let before = code.get(..i).unwrap_or_default().trim_end();
|
|
let after = code.get(i + method.len()..).unwrap_or_default();
|
|
!after.chars().next().is_some_and(ident)
|
|
&& after.trim_start().starts_with('(')
|
|
&& before
|
|
.strip_suffix('.')
|
|
.map(str::trim_end)
|
|
.and_then(|b| b.strip_suffix("layout"))
|
|
.is_some_and(|rest| !rest.chars().next_back().is_some_and(ident))
|
|
})
|
|
}
|
|
|
|
/// `script` with its comments blanked out and each string literal left as a bare `"`: `//` and
|
|
/// `/* */` comments and `'…'`, `"…"` and `` `…` `` strings (with `\` escapes) in JavaScript; `;`
|
|
/// and `//` comments and `"…"` strings (a doubled `""` inside) in FormCalc. Line breaks stay. An
|
|
/// unclosed comment or string runs to the end.
|
|
fn code_of(script: &str, formcalc: bool) -> String {
|
|
let mut out = String::with_capacity(script.len());
|
|
let mut chars = script.chars().peekable();
|
|
while let Some(c) = chars.next() {
|
|
let line_comment = (c == '/' && chars.peek() == Some(&'/')) || (formcalc && c == ';');
|
|
if line_comment {
|
|
for n in chars.by_ref() {
|
|
if n == '\n' {
|
|
out.push('\n');
|
|
break;
|
|
}
|
|
}
|
|
} else if !formcalc && c == '/' && chars.peek() == Some(&'*') {
|
|
chars.next();
|
|
let mut star = false;
|
|
for n in chars.by_ref() {
|
|
if star && n == '/' {
|
|
break;
|
|
}
|
|
star = n == '*';
|
|
}
|
|
out.push(' ');
|
|
} else if c == '"' || (!formcalc && (c == '\'' || c == '`')) {
|
|
while let Some(n) = chars.next() {
|
|
if !formcalc && n == '\\' {
|
|
chars.next();
|
|
} else if n == c {
|
|
if formcalc && chars.peek() == Some(&c) {
|
|
chars.next();
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
// A mark where the string was: the expression it ends is still seen to end there.
|
|
out.push('"');
|
|
} else {
|
|
out.push(c);
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Which page-number role a field with a layout:ready script plays, if any.
|
|
fn page_role(f: &Field) -> Option<PageRole> {
|
|
// Any of its layout:ready scripts, past a form:ready one.
|
|
f.scripts.iter().filter(|s| s.activity == "layoutReady").find_map(|s| own_page_role(&s.text, s.formcalc))
|
|
}
|
|
|
|
fn collect_page_roles(nodes: &[Node], out: &mut Vec<(String, PageRole)>, depth: usize) {
|
|
if depth > MAX_DEPTH {
|
|
return;
|
|
}
|
|
for n in nodes {
|
|
match n {
|
|
Node::Field(f) => {
|
|
if let (Some(id), Some(role)) = (&f.common.id, page_role(f)) {
|
|
out.push((id.clone(), role));
|
|
}
|
|
}
|
|
Node::Subform(s) => {
|
|
collect_page_roles(&s.children, out, depth + 1);
|
|
if let Some(ps) = &s.page_set {
|
|
for a in &ps.areas {
|
|
collect_page_roles(&a.items, out, depth + 1);
|
|
}
|
|
}
|
|
}
|
|
Node::Area(a) => collect_page_roles(&a.children, out, depth + 1),
|
|
Node::ExclGroup(_) | Node::Draw(_) => {}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Parse a template packet. Returns the template and the warnings met on the way.
|
|
pub fn parse(xml: &str) -> Result<(Template, Vec<String>), XfaError> {
|
|
// No bigger than a packet the reader accepts can come to as text ([`crate::read_packets`]), so
|
|
// what parsing keeps of it (item lists are held by the field and its properties) stays within
|
|
// a few times that, whoever calls it.
|
|
let limit = crate::packets::MAX_TEMPLATE_TEXT;
|
|
if xml.len() > limit {
|
|
return Err(XfaError::TooLarge(format!("the template is {} bytes, past the {limit} bytes the reader accepts", xml.len())));
|
|
}
|
|
let depth = crate::xml_nesting_depth(xml);
|
|
if depth > crate::MAX_XML_DEPTH {
|
|
return Err(XfaError::Malformed(format!(
|
|
"the template nests XML elements {depth} deep, past the {} the reader accepts",
|
|
crate::MAX_XML_DEPTH
|
|
)));
|
|
}
|
|
let opts = || roxmltree::ParsingOptions { allow_dtd: false, nodes_limit: MAX_NODES, ..Default::default() };
|
|
let doc = roxmltree::Document::parse_with_options(xml, opts()).map_err(|e| XfaError::Malformed(format!("template XML: {e}")))?;
|
|
let root = doc.root_element();
|
|
// The XDP's config packet has a <template> element too: the one we want is in the
|
|
// xfa-template namespace, or at least has a subform in it.
|
|
let is_template = |n: &XmlNode| {
|
|
n.is_element()
|
|
&& n.tag_name().name() == "template"
|
|
&& (n.tag_name().namespace().is_some_and(|ns| ns.contains("xfa-template")) || children(*n, "subform").next().is_some())
|
|
};
|
|
// The template is a packet of the XDP itself, a child of its root: one nested deeper is
|
|
// data (an imported dataset, say), which must not supply the form's scripts.
|
|
let template = if is_template(&root) {
|
|
root
|
|
} else {
|
|
root.children().find(is_template).ok_or_else(|| XfaError::Malformed("no <template> element".into()))?
|
|
};
|
|
// Prototype references (`use`, `usehref`) are resolved first: the parser reads the template
|
|
// written again with them merged in. The saved state below still comes from `doc`.
|
|
let (expanded, mut warnings) = crate::proto::expand(template);
|
|
let expanded_doc;
|
|
let template = match expanded.as_deref().map(|x| roxmltree::Document::parse_with_options(x, opts())) {
|
|
Some(Ok(d)) => {
|
|
expanded_doc = d;
|
|
expanded_doc.root_element()
|
|
}
|
|
Some(Err(e)) => {
|
|
warnings.push(format!("the template with its prototypes resolved could not be read again ({e}); it is read without them"));
|
|
template
|
|
}
|
|
None => template,
|
|
};
|
|
let sf = children(template, "subform").next().ok_or_else(|| XfaError::Malformed("the template has no root subform".into()))?;
|
|
let mut root_sf = parse_subform(sf, &mut warnings, 0, None);
|
|
if root_sf.page_set.is_none() {
|
|
// A page set may sit beside the root subform.
|
|
root_sf.page_set = child(template, "pageSet").map(|ps| parse_page_set(ps, &mut warnings, 0));
|
|
}
|
|
// The form packet beside the template holds the state the form was saved in.
|
|
let saved = root
|
|
.children()
|
|
.find(|n| n.is_element() && n.tag_name().name() == "form" && n.tag_name().namespace().is_some_and(|ns| ns.contains("xfa-form")));
|
|
if let Some(saved_root) = saved.and_then(|f| children(f, "subform").next()) {
|
|
if let Some(p) = saved_root.attribute("presence") {
|
|
root_sf.common.presence = Presence::parse(Some(p));
|
|
}
|
|
apply_saved_state(&mut root_sf.children, saved_root, 0);
|
|
}
|
|
let mut page_roles = Vec::new();
|
|
let root_node = [Node::Subform(Box::new(root_sf.clone()))];
|
|
collect_page_roles(&root_node, &mut page_roles, 0);
|
|
let mut embedded = std::collections::BTreeSet::new();
|
|
collect_embedded(&root_node, &mut embedded, 0);
|
|
let mut tpl = Template { root: root_sf, page_roles, embedded, embedded_paths: Default::default(), cache: Default::default() };
|
|
let paths = crate::data::fields_with_ids(&tpl)
|
|
.into_iter()
|
|
.filter(|(id, _, _)| tpl.embedded.contains(*id))
|
|
.map(|(_, som, _)| crate::data::data_path(&tpl, &som).into_iter().map(|(name, _)| name).collect())
|
|
.collect();
|
|
tpl.embedded_paths = paths;
|
|
Ok((tpl, warnings))
|
|
}
|
|
|
|
fn parse_access(a: Option<&str>) -> Option<Access> {
|
|
Some(match a? {
|
|
"readOnly" => Access::ReadOnly,
|
|
"protected" => Access::Protected,
|
|
"nonInteractive" => Access::NonInteractive,
|
|
"open" => Access::Open,
|
|
_ => return None,
|
|
})
|
|
}
|
|
|
|
/// Apply the saved state of a form packet (`saved`: the element matching the container whose
|
|
/// children `nodes` are) to the template: the `presence` and `access` a script gave an object
|
|
/// before the form was saved, which is restored on opening. Objects are matched by
|
|
/// name and position among the same-named; of the instances of a repeating subform only the
|
|
/// first is read, as the template has it once.
|
|
fn apply_saved_state(nodes: &mut [Node], saved: XmlNode, depth: usize) {
|
|
if depth > MAX_DEPTH {
|
|
return;
|
|
}
|
|
let mut seen: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
|
|
for c in saved.children().filter(|c| c.is_element()) {
|
|
if !matches!(c.tag_name().name(), "subform" | "subformSet" | "area" | "field" | "draw" | "exclGroup") {
|
|
continue;
|
|
}
|
|
let Some(name) = c.attribute("name").filter(|n| !n.is_empty()) else { continue };
|
|
let nth = {
|
|
let k = seen.entry(name).or_insert(0);
|
|
*k += 1;
|
|
*k - 1
|
|
};
|
|
let same: Vec<usize> = nodes.iter().enumerate().filter(|(_, n)| n.common().name.as_deref() == Some(name)).map(|(i, _)| i).collect();
|
|
// More saved objects of the name than the template has: further instances.
|
|
let Some(target) = same.get(nth).copied().and_then(|i| nodes.get_mut(i)) else { continue };
|
|
let presence = c.attribute("presence").map(|p| Presence::parse(Some(p)));
|
|
match target {
|
|
Node::Subform(s) => {
|
|
if let Some(p) = presence {
|
|
s.common.presence = p;
|
|
}
|
|
apply_saved_state(&mut s.children, c, depth + 1);
|
|
}
|
|
Node::Area(a) => {
|
|
if let Some(p) = presence {
|
|
a.common.presence = p;
|
|
}
|
|
apply_saved_state(&mut a.children, c, depth + 1);
|
|
}
|
|
Node::Field(f) => {
|
|
if let Some(p) = presence {
|
|
f.common.presence = p;
|
|
}
|
|
if let Some(a) = parse_access(c.attribute("access")) {
|
|
f.access = a;
|
|
}
|
|
// A caption a script changed (a button's label, say) is saved as plain text.
|
|
let text = child(c, "caption").and_then(|k| child(k, "value")).and_then(|v| child(v, "text")).and_then(|t| t.text());
|
|
if let (Some(caption), Some(text)) = (f.caption.as_mut(), text) {
|
|
caption.value = Value::Text(text.to_string());
|
|
}
|
|
}
|
|
Node::Draw(d) => {
|
|
if let Some(p) = presence {
|
|
d.common.presence = p;
|
|
}
|
|
}
|
|
Node::ExclGroup(g) => {
|
|
if let Some(p) = presence {
|
|
g.common.presence = p;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[path = "props_tests.rs"]
|
|
mod props_tests;
|