From d9047d4d5b8cf6db63b68f2ed7e75be3acf287e7 Mon Sep 17 00:00:00 2001 From: martinjohuber <106869689+martinjohuber@users.noreply.github.com> Date: Fri, 9 Oct 2026 18:19:36 +0200 Subject: [PATCH] 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 --- crates/automation/src/forms.rs | 3 ++- crates/forms/src/lib.rs | 35 ++++++++++++++++++++++++++++-- crates/forms/src/tests.rs | 39 ++++++++++++++++++++++++++++++++++ 3 files changed, 74 insertions(+), 3 deletions(-) diff --git a/crates/automation/src/forms.rs b/crates/automation/src/forms.rs index 0203934b..4775bed2 100644 --- a/crates/automation/src/forms.rs +++ b/crates/automation/src/forms.rs @@ -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::>())); + o.insert("options".into(), json!((0..f.widgets.len()).filter_map(|i| f.export_of(i)).collect::>())); } FormFieldKind::Combo | FormFieldKind::List => { o.insert("options".into(), f.options.iter().map(|(e, d)| json!({ "value": e, "label": d })).collect()); diff --git a/crates/forms/src/lib.rs b/crates/forms/src/lib.rs index 41ce2198..8d99b62c 100644 --- a/crates/forms/src/lib.rs +++ b/crates/forms/src/lib.rs @@ -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() { diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index 8cdc8d37..38fcfd8d 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -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 = 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::>(), [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::>(), [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();