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:
martinjohuber 2026-10-09 18:19:36 +02:00 committed by GitHub
parent 5deb42199c
commit d9047d4d5b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 74 additions and 3 deletions

View File

@ -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());

View File

@ -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() {

View File

@ -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();