Forms: radio groups and check boxes accept their /Opt export values (#532)
pdf-lib and other writers give radio groups and check boxes an /Opt array
(PDF 1.4, one export value per widget) and name the on states by position
(/0, /1 ...). Filling such a field by its export value ("Pick-up") failed with
"has no option", and form_fields listed the options as 0, 1.
- Field::export_of / state_for / export_for_state map between a widget, its
/Opt export value and its on-state name; fields without /Opt are unchanged
- set_value accepts an export value for radio groups and check boxes; the
"has no option" error lists the export values
- form_fields reports a radio group's options and selected value as export
values
- Test: opt_export_values_select_button_states
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
5deb42199c
commit
d9047d4d5b
|
|
@ -189,6 +189,7 @@ impl Automation {
|
|||
"value": match f.kind {
|
||||
FormFieldKind::CheckBox => json!(!f.value.is_empty()),
|
||||
FormFieldKind::List if f.has(field_flags::MULTI_SELECT) => json!(f.value),
|
||||
FormFieldKind::Radio => json!(f.value.first().map(|v| f.export_for_state(v))),
|
||||
_ => json!(f.value.first()),
|
||||
},
|
||||
"page": w.and_then(|w| w.page).map(|p| p + 1),
|
||||
|
|
@ -225,7 +226,7 @@ impl Automation {
|
|||
}
|
||||
match f.kind {
|
||||
FormFieldKind::Radio => {
|
||||
o.insert("options".into(), json!(f.widgets.iter().filter_map(|w| w.on_state.clone()).collect::<Vec<_>>()));
|
||||
o.insert("options".into(), json!((0..f.widgets.len()).filter_map(|i| f.export_of(i)).collect::<Vec<_>>()));
|
||||
}
|
||||
FormFieldKind::Combo | FormFieldKind::List => {
|
||||
o.insert("options".into(), f.options.iter().map(|(e, d)| json!({ "value": e, "label": d })).collect());
|
||||
|
|
|
|||
|
|
@ -144,6 +144,35 @@ impl Field {
|
|||
self.widgets.iter().any(|w| w.locked)
|
||||
}
|
||||
|
||||
/// Check boxes and radio groups with `/Opt` (PDF 1.4): one export value per widget, in widget
|
||||
/// order. Their on states are then often just positions (`/0`, `/1` …), as pdf-lib writes them.
|
||||
fn button_exports(&self) -> Option<&[(String, String)]> {
|
||||
(matches!(self.kind, FieldKind::CheckBox | FieldKind::Radio) && !self.options.is_empty() && self.options.len() == self.widgets.len())
|
||||
.then_some(self.options.as_slice())
|
||||
}
|
||||
|
||||
/// The export value of widget `i` of a check box or radio group: its `/Opt` entry when the
|
||||
/// field has one per widget, otherwise the widget's on-state name.
|
||||
pub fn export_of(&self, i: usize) -> Option<&str> {
|
||||
let on = self.widgets.get(i)?.on_state.as_deref()?;
|
||||
Some(self.button_exports().and_then(|o| o.get(i)).map_or(on, |(e, _)| e.as_str()))
|
||||
}
|
||||
|
||||
/// The on-state name that selects `choice` in a check box or radio group, where `choice` is an
|
||||
/// on-state name or an export value from `/Opt`.
|
||||
pub fn state_for(&self, choice: &str) -> Option<&str> {
|
||||
self.widgets.iter().filter_map(|w| w.on_state.as_deref()).find(|s| *s == choice).or_else(|| {
|
||||
let i = (0..self.widgets.len()).find(|&i| self.export_of(i) == Some(choice))?;
|
||||
self.widgets.get(i)?.on_state.as_deref()
|
||||
})
|
||||
}
|
||||
|
||||
/// The export value for an on-state name such as the field's value; the name itself when the
|
||||
/// field has no `/Opt`.
|
||||
pub fn export_for_state<'a>(&'a self, state: &'a str) -> &'a str {
|
||||
self.widgets.iter().position(|w| w.on_state.as_deref() == Some(state)).and_then(|i| self.export_of(i)).unwrap_or(state)
|
||||
}
|
||||
|
||||
/// The value as one string: text, the state name, or the selected display texts.
|
||||
pub fn display_value(&self) -> String {
|
||||
match self.kind {
|
||||
|
|
@ -780,7 +809,7 @@ fn write_value(doc: &mut Document, f: &Field, value: &FieldValue, scripts: &mut
|
|||
// The on-state name or a yes/no word also work (agents, FDF-style data).
|
||||
FieldValue::Text(t) | FieldValue::Radio(Some(t)) => {
|
||||
let t = t.trim();
|
||||
if t == state || ["yes", "true", "on", "1", "x", "checked"].contains(&t.to_lowercase().as_str()) {
|
||||
if t == state || f.state_for(t).is_some() || ["yes", "true", "on", "1", "x", "checked"].contains(&t.to_lowercase().as_str()) {
|
||||
true
|
||||
} else if t.is_empty() || ["no", "false", "off", "0", "unchecked"].contains(&t.to_lowercase().as_str()) {
|
||||
false
|
||||
|
|
@ -801,10 +830,12 @@ fn write_value(doc: &mut Document, f: &Field, value: &FieldValue, scripts: &mut
|
|||
FieldValue::Check(false) => None,
|
||||
_ => return invalid(format!("{:?} is a radio group: choose one of its options", f.name)),
|
||||
};
|
||||
// An export value from /Opt selects its widget's on state.
|
||||
let choice = choice.map(|c| f.state_for(&c).map(str::to_owned).unwrap_or(c));
|
||||
if let Some(c) = &choice
|
||||
&& !f.widgets.iter().any(|w| w.on_state.as_deref() == Some(c.as_str()))
|
||||
{
|
||||
let opts: Vec<&str> = f.widgets.iter().filter_map(|w| w.on_state.as_deref()).collect();
|
||||
let opts: Vec<&str> = (0..f.widgets.len()).filter_map(|i| f.export_of(i)).collect();
|
||||
return invalid(format!("{:?} has no option {c:?} (options: {})", f.name, opts.join(", ")));
|
||||
}
|
||||
if choice.is_none() && f.has(flags::NO_TOGGLE_TO_OFF) && !f.value.is_empty() {
|
||||
|
|
|
|||
|
|
@ -36,6 +36,11 @@ fn fixture() -> Document {
|
|||
"<< /Type /Annot /Subtype /Widget /FT /Btn /T (go) /Ff 65536 /Rect [300 450 380 470] /P 3 0 R >>".into(), // 25
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Btn /T (bare) /Rect [400 700 415 715] /P 3 0 R /Foo (kept) >>".into(), // 26 check box without AP
|
||||
];
|
||||
document(&objs)
|
||||
}
|
||||
|
||||
/// A PDF of the given objects (numbered from 1).
|
||||
fn document(objs: &[String]) -> Document {
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, o) in objs.iter().enumerate() {
|
||||
|
|
@ -90,6 +95,40 @@ fn the_field_tree_is_read_with_inheritance() {
|
|||
assert_eq!(field(&all, "address.city").quadding, 1);
|
||||
}
|
||||
|
||||
/// pdf-lib and other writers give radio groups and check boxes an `/Opt` array and name the on
|
||||
/// states by position (`/0`, `/1`): the export values select them, as in Acrobat.
|
||||
#[test]
|
||||
fn opt_export_values_select_button_states() {
|
||||
let objs: Vec<String> = vec![
|
||||
"<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), // 1
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), // 2
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [6 0 R 7 0 R 8 0 R] >>".into(), // 3
|
||||
"<< /Fields [5 0 R 8 0 R] /DA (/Helv 0 Tf 0 g) >>".into(), // 4
|
||||
"<< /FT /Btn /T (ship) /Ff 49152 /Opt [(Post) (Pick-up)] /V /Off /Kids [6 0 R 7 0 R] >>".into(), // 5 radio
|
||||
"<< /Type /Annot /Subtype /Widget /Parent 5 0 R /AS /Off /Rect [100 700 115 715] /P 3 0 R /AP << /N << /0 9 0 R /Off 10 0 R >> >> >>".into(), // 6
|
||||
"<< /Type /Annot /Subtype /Widget /Parent 5 0 R /AS /Off /Rect [200 700 215 715] /P 3 0 R /AP << /N << /1 9 0 R /Off 10 0 R >> >> >>".into(), // 7
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Btn /T (terms) /Opt [(Accepted)] /V /Off /AS /Off /Rect [100 650 115 665] /P 3 0 R /AP << /N << /0 9 0 R /Off 10 0 R >> >> >>".into(), // 8 check box
|
||||
"<< /Length 0 >>\nstream\n\nendstream".into(), // 9
|
||||
"<< /Length 0 >>\nstream\n\nendstream".into(), // 10
|
||||
];
|
||||
let mut doc = document(&objs);
|
||||
let all = fields(&doc);
|
||||
let ship = field(&all, "ship");
|
||||
assert_eq!((0..2).map(|i| ship.export_of(i)).collect::<Vec<_>>(), [Some("Post"), Some("Pick-up")]);
|
||||
assert_eq!((ship.state_for("Pick-up"), ship.state_for("1"), ship.state_for("Courier")), (Some("1"), Some("1"), None));
|
||||
set_value(&mut doc, "ship", &FieldValue::Radio(Some("Pick-up".into()))).unwrap();
|
||||
set_value(&mut doc, "terms", &FieldValue::Text("Accepted".into())).unwrap();
|
||||
let mut doc = reopen(&doc);
|
||||
let all = fields(&doc);
|
||||
let ship = field(&all, "ship");
|
||||
assert_eq!(ship.value, ["1"]);
|
||||
assert_eq!(ship.export_for_state("1"), "Pick-up");
|
||||
assert_eq!(ship.widgets.iter().map(|w| w.state.as_deref()).collect::<Vec<_>>(), [Some("Off"), Some("1")]);
|
||||
assert_eq!(field(&all, "terms").value, ["0"]);
|
||||
let err = set_value(&mut doc, "ship", &FieldValue::Radio(Some("Courier".into()))).unwrap_err();
|
||||
assert!(err.to_string().contains("options: Post, Pick-up"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_fields_get_new_appearances() {
|
||||
let mut doc = fixture();
|
||||
|
|
|
|||
Loading…
Reference in New Issue