mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-27 11:37:59 -06:00
* XFA: forms in one wrapped block lay out across pages, with their rows and growing fields A dynamic form whose body is a single block that can't be flowed (left-to-right wrapped, positioned or fixed-height) laid out to one page, the rest drawn past the bottom edge with no warning. - A block taller than a content area is spread over the following ones. Breaks fall between fields, draws and lines of text; fills and lines are cut at the break; a field or draw moves whole, border and all. - Repeating subforms in a left-to-right wrapped container get the instances the data holds, as in the other layouts (only the first was drawn). - A multi-line text field without a fixed height grows to hold its text. Past the height of a content area it is drawn as text that continues across pages (not editable; reported as a warning) instead of one widget cut off. - Text too long for the fixed height it was given is drawn smaller, down to 70%, rather than over what lies below (the standard faces are wider than the template's). - Nested measurements carry their SOM path, so instance counts and field values are found below the first level. Synthetic tests cover each. Run: fmt, clippy and tests for pdfcraft-xfa, and the pdfcraft-engine tests; the workspace-wide gates were not run. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * XFA: a form opens in the state it was saved in, and wrapped sections are as tall as their content The form packet's saved presence, access and captions are applied to the template, so rows a script showed before saving appear, locked fields stay locked and a button keeps the label it was saved with. A flowed subform no longer stretches to a leftover minH: a public form whose own form packet records 3 pages laid out as 4 and now lays out as 3. Also: a text field without a border of its own has no box, and a CR LF pair is one line break. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * XFA: a signed signature box shows the signature the file holds A laid-out signature box takes the value and the appearance of the signed field of the same SOM name that the signing application left in the AcroForm, and is read-only. The engine no longer redraws a signed box as an empty one, on open or on a script's re-layout. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
753 lines
32 KiB
Rust
753 lines
32 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::*;
|
|
|
|
/// 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;
|
|
|
|
/// 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))
|
|
}
|
|
|
|
fn attr_measure(n: XmlNode, name: &str) -> Option<f64> {
|
|
n.attribute(name).and_then(measure)
|
|
}
|
|
|
|
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 in XFA order. One edge applies to all four; two: top/bottom then
|
|
/// left/right; three: top, left/right, bottom; four: top, right, bottom, left.
|
|
fn parse_border(n: Option<XmlNode>) -> Option<Border> {
|
|
let n = n?;
|
|
let edges: Vec<Edge> = children(n, "edge").map(parse_edge).collect();
|
|
let d = Edge::default();
|
|
let e = |i: usize| edges.get(i).copied().unwrap_or(d);
|
|
let four = match edges.len() {
|
|
0 => [d; 4],
|
|
1 => [e(0); 4],
|
|
2 => [e(0), e(1), e(0), e(1)],
|
|
3 => [e(0), e(1), e(2), e(1)],
|
|
_ => [e(0), e(1), e(2), e(3)],
|
|
};
|
|
Some(Border { presence: Presence::parse(n.attribute("presence")), edges: four, fill: parse_fill(child(n, "fill")) })
|
|
}
|
|
|
|
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"))),
|
|
}
|
|
}
|
|
|
|
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"),
|
|
}
|
|
}
|
|
|
|
fn parse_common(n: XmlNode) -> Common {
|
|
Common {
|
|
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"),
|
|
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")),
|
|
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),
|
|
}
|
|
}
|
|
|
|
/// Rich text: XHTML `body > p > span…`. `br` starts a new paragraph; `xfa:embed` spans become
|
|
/// embedded fields.
|
|
fn parse_rich(body: XmlNode) -> RichText {
|
|
#[derive(Clone, Default)]
|
|
struct Style {
|
|
bold: Option<bool>,
|
|
italic: Option<bool>,
|
|
underline: Option<bool>,
|
|
size: Option<f64>,
|
|
}
|
|
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),
|
|
_ => {}
|
|
}
|
|
}
|
|
s
|
|
}
|
|
// Paragraph boundaries from `p` are implicit (dropped when empty); `br` makes an explicit
|
|
// line break that stays even when the line is blank.
|
|
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,
|
|
embed: None,
|
|
},
|
|
);
|
|
}
|
|
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),
|
|
_ => {}
|
|
}
|
|
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, embed: Some(e) },
|
|
);
|
|
}
|
|
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;
|
|
}
|
|
return Value::Image { content_type: c.attribute("contentType").unwrap_or("").to_string(), data };
|
|
}
|
|
"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(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,
|
|
},
|
|
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)
|
|
}
|
|
|
|
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()).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 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),
|
|
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: ui_el.is_some_and(|e| e.attribute("multiLine").is_some_and(|m| m == "1" || m == "true")),
|
|
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()),
|
|
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,
|
|
numeric,
|
|
}
|
|
}
|
|
|
|
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");
|
|
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),
|
|
)
|
|
})
|
|
.collect();
|
|
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)).collect();
|
|
let occur = parse_occur(child(pa, "occur"));
|
|
areas.push(PageArea { name: pa.attribute("name").map(str::to_string), width, height, content, items, occur_max: occur.max });
|
|
}
|
|
// 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 }
|
|
}
|
|
|
|
fn parse_subform(n: XmlNode, warnings: &mut Vec<String>, depth: usize) -> Subform {
|
|
let common = parse_common(n);
|
|
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) {
|
|
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();
|
|
let break_to_page = |name: &str| children(n, name).any(|b| b.attribute("targetType").is_none_or(|t| t == "pageArea" || t == "contentArea"));
|
|
Subform {
|
|
common,
|
|
layout: Layout::parse(n.attribute("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"),
|
|
column_widths,
|
|
overflow_leader: child(n, "overflow").and_then(|o| o.attribute("leader")).map(str::to_string).filter(|s| !s.is_empty()),
|
|
}
|
|
}
|
|
|
|
fn parse_node(c: XmlNode, warnings: &mut Vec<String>, depth: usize) -> Option<Node> {
|
|
if depth > MAX_DEPTH {
|
|
return None;
|
|
}
|
|
Some(match c.tag_name().name() {
|
|
"subform" => Node::Subform(Box::new(parse_subform(c, warnings, depth))),
|
|
"subformSet" => {
|
|
// A set of alternative subforms: lay out its members in order.
|
|
let mut sf = parse_subform(c, warnings, depth);
|
|
sf.layout = Layout::Tb;
|
|
Node::Subform(Box::new(sf))
|
|
}
|
|
"area" => Node::Area(Box::new(Area {
|
|
common: parse_common(c),
|
|
children: c.children().filter(|k| k.is_element()).filter_map(|k| parse_node(k, warnings, depth + 1)).collect(),
|
|
})),
|
|
"field" => Node::Field(Box::new(parse_field(c, warnings))),
|
|
"draw" => Node::Draw(Box::new(Draw { common: parse_common(c), value: parse_value(child(c, "value"), warnings) })),
|
|
"exclGroup" => Node::ExclGroup(Box::new(ExclGroup {
|
|
common: parse_common(c),
|
|
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),
|
|
})),
|
|
_ => return None,
|
|
})
|
|
}
|
|
|
|
/// Which page-number role a field with a layout:ready script plays, if any.
|
|
fn page_role(f: &Field) -> Option<PageRole> {
|
|
let script = f.scripts.iter().find(|s| s.activity == "ready" || s.activity == "layoutReady")?;
|
|
if script.text.contains("pageCount") {
|
|
Some(PageRole::Count)
|
|
} else if script.text.contains("layout.page(") || script.text.contains("layout.absPage(") {
|
|
Some(PageRole::Number)
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
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> {
|
|
let opts = roxmltree::ParsingOptions { allow_dtd: false, nodes_limit: MAX_NODES };
|
|
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())
|
|
};
|
|
let template = if is_template(&root) {
|
|
root
|
|
} else {
|
|
root.descendants().find(is_template).ok_or_else(|| XfaError::Malformed("no <template> element".into()))?
|
|
};
|
|
let mut warnings = Vec::new();
|
|
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);
|
|
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
|
|
.descendants()
|
|
.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();
|
|
collect_page_roles(&[Node::Subform(Box::new(root_sf.clone()))], &mut page_roles, 0);
|
|
Ok((Template { root: root_sf, page_roles }, 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;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|