M12: XFA fields and exclusion groups that must be filled in are Required

Co-authored-by: EULA <90616501+EULArule@users.noreply.github.com>
This commit is contained in:
Eric Kaden
2026-10-24 15:21:11 -04:00
co-authored by EULA
parent a7801dc84e
commit c86b33985d
7 changed files with 80 additions and 5 deletions
+4 -2
View File
@@ -25,7 +25,7 @@ that warning and keeps the existing layout and accepted widget values.
margins, borders (edge order rules), captions with reserves, check-button items, pictures,
rich text (`exData` XHTML: paragraphs, `br`, bold/italic/underline/size spans, embedded fields),
text-field limits (`text/maxChars` and `exData/maxLength`, carried into AcroForm `/MaxLen`),
inline JPEG, PNG and GIF images, lines, rectangles, occurrences, `breakBefore`/`breakAfter`, overflow
required fields and exclusion groups (`<validate nullTest="error">`), inline JPEG, PNG and GIF images, lines, rectangles, occurrences, `breakBefore`/`breakAfter`, overflow
leaders, `columnWidths` and `colSpan`.
- **Layout** (`layout`): positioned, `tb`, `lr-tb`/`rl-tb`, `table` and `row` layouts; a
container without a width is as wide as its content; flow across content areas and pages with
@@ -38,7 +38,9 @@ that warning and keeps the existing layout and accepted widget values.
XObjects, and widgets: text (multi-line, max length, alignment), date (Acrobat's `AFDate`
format and keystroke actions from the picture clause), check boxes and radio groups (from
`exclGroup`), push buttons with Reset / Print / Save As / URI actions recognised from their
scripts. Appearance streams are left empty for the forms layer to generate. Each field keeps
scripts. A field or exclusion group that must be filled in gets the `Required` flag (a radio
group on its group field), which viewers outline when they highlight fields. Appearance
streams are left empty for the forms layer to generate. Each field keeps
its SOM path in `/PCSom`; the AcroForm gets `/PCXfaLayout` so a saved form is not laid out
twice. The XFA packets stay, so Adobe's viewers keep rendering the form from them.
+12
View File
@@ -168,6 +168,8 @@ pub struct Widget {
pub items: Vec<String>,
/// A text field that doesn't scroll: it takes no more text than it shows (`DoNotScroll`).
pub no_scroll: bool,
/// The field must be filled in (`Required`); a radio button, when its group must be.
pub required: bool,
}
#[derive(Clone, Debug, Default, PartialEq)]
@@ -1799,6 +1801,15 @@ impl Layouter<'_> {
let on = f.items.first().cloned().filter(|s| !s.trim().is_empty()).unwrap_or_else(|| (i + 1).to_string());
let tip = f.tooltip.clone().or_else(|| g.tooltip.clone());
let chosen = selected.as_deref() == Some(on.as_str());
// A group that must be answered makes each of its buttons required, as the group
// field they make is.
let required;
let f = if g.required && !f.required {
required = Field { required: true, ..f.clone() };
&required
} else {
f
};
self.draw_field(f, Rect::new(inner.x + r.x, inner.y + r.y, r.w, r.h), &ctx, Some((&group, &on, tip, chosen)), sibs.at(i))?;
}
}
@@ -2087,6 +2098,7 @@ impl Layouter<'_> {
action,
items,
no_scroll: f.no_scroll,
required: f.required,
})))
}
}
+4
View File
@@ -449,6 +449,8 @@ pub struct ExclGroup {
pub fields: Vec<Field>,
pub tooltip: Option<String>,
pub scripts: Vec<Script>,
/// One of the buttons must be chosen (`<validate nullTest="error">` on the group).
pub required: bool,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
@@ -509,6 +511,8 @@ pub struct Field {
/// The field doesn't scroll in the direction its text runs out (`hScrollPolicy="off"` for one
/// line, `vScrollPolicy="off"` for several): it takes no more text than it shows.
pub no_scroll: bool,
/// The field must be filled in (`<validate nullTest="error">`).
pub required: bool,
pub caption: Option<Caption>,
pub value: Value,
/// Check buttons: the on value then the off value; choice lists: the items as shown.
+8
View File
@@ -547,11 +547,18 @@ fn parse_field(n: XmlNode, warnings: &mut Vec<String>) -> Field {
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()),
}
}
/// 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") }
@@ -674,6 +681,7 @@ fn parse_node(c: XmlNode, warnings: &mut Vec<String>, depth: usize) -> Option<No
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,
})
+7 -1
View File
@@ -362,6 +362,11 @@ impl Emitter<'_> {
if w.read_only {
ff |= 1;
}
// Required, which viewers outline when they highlight a form's fields. A radio button's
// flags are its group's: the group takes it below.
if w.required && !matches!(w.kind, WidgetKind::Radio { .. }) {
ff |= 1 << 1;
}
let mut top_level = true;
match &w.kind {
WidgetKind::Text | WidgetKind::Date(_) | WidgetKind::Password => {
@@ -496,7 +501,8 @@ impl Emitter<'_> {
None => {
let mut g = Dict::new();
g.set(b"FT".to_vec(), Object::name("Btn"));
g.set(b"Ff".to_vec(), Object::Int((1 << 15) | (1 << 14)));
let required = if w.required { 1 << 1 } else { 0 };
g.set(b"Ff".to_vec(), Object::Int((1 << 15) | (1 << 14) | required));
g.set(b"T".to_vec(), Object::String(PdfString::text(group)));
g.set(b"V".to_vec(), Object::name("Off"));
g.set(b"DA".to_vec(), Object::String(PdfString::literal(Self::da(&w.face))));
+43
View File
@@ -337,6 +337,49 @@ fn rendering_into_the_pdf_replaces_the_placeholder_page_and_adds_fields() {
assert!(!is_dynamic(&doc));
}
/// A field or exclusion group that must be filled in (`<validate nullTest="error">`) becomes a
/// Required field, which viewers outline when they highlight a form's fields: a radio group on the
/// group field, its buttons' flags being the group's. `nullTest="warning"` only recommends a value,
/// so it, like no test, leaves the field optional.
#[test]
fn fields_that_must_be_filled_in_are_required() {
let xdp = r##"<?xml version="1.0" encoding="UTF-8"?>
<xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/"><template xmlns="http://www.xfa.org/schema/xfa-template/3.3/"><subform name="form" layout="tb">
<pageSet><pageArea name="p"><contentArea x="0.5in" y="0.5in" w="7.5in" h="10in"/><medium short="8.5in" long="11in"/></pageArea></pageSet>
<field name="must" w="2in" h="0.3in"><ui><textEdit/></ui><validate nullTest="error"/></field>
<field name="should" w="2in" h="0.3in"><ui><textEdit/></ui><validate nullTest="warning"/></field>
<field name="may" w="2in" h="0.3in"><ui><textEdit/></ui></field>
<field name="agree" w="2in" h="0.3in"><ui><checkButton/></ui><items><integer>1</integer><integer>0</integer></items><validate nullTest="error"/></field>
<exclGroup name="answer" layout="lr-tb"><validate nullTest="error"/>
<field name="yes" w="1in" h="0.3in"><ui><checkButton shape="round"/></ui><items><text>Y</text></items></field>
<field name="no" w="1in" h="0.3in"><ui><checkButton shape="round"/></ui><items><text>N</text></items></field>
</exclGroup>
<exclGroup name="optional" layout="lr-tb">
<field name="a" w="1in" h="0.3in"><ui><checkButton shape="round"/></ui><items><text>A</text></items></field>
</exclGroup>
</subform></template></xdp:xdp>"##;
let mut doc = Document::open(Arc::new(shell(xdp))).unwrap();
render_into(&mut doc).unwrap();
let catalog = doc.get(doc.root().unwrap());
let acro = doc.resolve(catalog.as_dict().unwrap().get(b"AcroForm").unwrap());
let fields = doc.resolve(acro.as_dict().unwrap().get(b"Fields").unwrap()).as_array().unwrap().clone();
let required: std::collections::BTreeMap<String, bool> = fields
.iter()
.map(|f| {
let d = doc.resolve(f).as_dict().unwrap().clone();
let name = d.get(b"T").and_then(|t| doc.resolve(t).as_string().map(|s| s.to_text())).unwrap_or_default();
(name, d.int(b"Ff").unwrap_or(0) & 2 != 0)
})
.collect();
let leaf = |n: &str| required.iter().find(|(k, _)| k.contains(n)).map(|(_, r)| *r);
assert_eq!(leaf("must"), Some(true), "{required:?}");
assert_eq!(leaf("should"), Some(false), "a warning only recommends a value");
assert_eq!(leaf("may"), Some(false));
assert_eq!(leaf("agree"), Some(true), "a check box too");
assert_eq!(leaf("answer"), Some(true), "the radio group field");
assert_eq!(leaf("optional"), Some(false));
}
/// A dynamic form's own text that the standard fonts can't draw is laid out anyway and reported
/// once, never written as `?` silently.
#[test]
File diff suppressed because one or more lines are too long