mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-10 14:41:51 +00:00
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:
@@ -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.
|
||||
|
||||
|
||||
@@ -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,
|
||||
})))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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))));
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user