From 9e51a1f4a6a9ae4efbbafab0a6eb3328371d681c Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 5 Nov 2026 20:58:42 +0800 Subject: [PATCH] M6: a radio number is compared with decimal export values as a number first Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn --- crates/automation/src/forms.rs | 13 ++++++-- crates/automation/tests/it/automation.rs | 38 ++++++++++++++++++++++++ crates/forms/src/lib.rs | 13 ++++++++ 3 files changed, 61 insertions(+), 3 deletions(-) diff --git a/crates/automation/src/forms.rs b/crates/automation/src/forms.rs index 164a0e21be..4268e00e79 100644 --- a/crates/automation/src/forms.rs +++ b/crates/automation/src/forms.rs @@ -238,10 +238,17 @@ fn value_for(f: &FormField, v: &Value) -> Result { // button, not the second one's state. A value matching neither is refused when written, // with the options listed. (FormFieldKind::Radio, Value::String(s)) => FieldValue::Radio(Some(f.state_for_export(s).unwrap_or(s).to_owned())), - // A number is the same option written without quotes (`1` for the export value `1`). + // A number is the same option written without quotes (`1` for the export value `1`). It + // is compared as a number with the export values first, so `1.0` names the button + // exported as `1.0` (`/Opt [(1.0) (2.0)]`, states `/0 /1`) and not the one whose state is + // `/1`; then as text, as a string would be. (FormFieldKind::Radio, Value::Number(n)) => { - let s = number_option(n); - FieldValue::Radio(Some(f.state_for_export(&s).map_or_else(|| s.clone(), str::to_owned))) + if let Some(state) = n.as_f64().and_then(|x| f.state_for_number(x)) { + FieldValue::Radio(Some(state.to_owned())) + } else { + let s = number_option(n); + FieldValue::Radio(Some(f.state_for_export(&s).map_or_else(|| s.clone(), str::to_owned))) + } } (FormFieldKind::Combo | FormFieldKind::List, Value::Array(a)) => FieldValue::Choice( a.iter().map(|x| x.as_str().map(str::to_owned).ok_or_else(|| wrong("list values must be strings"))).collect::>()?, diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index afca837654..7e0d582bca 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -2770,6 +2770,44 @@ fn form_fill_reads_a_radio_number_by_its_integer_spelling() { } } +/// Export values written as decimals (`/Opt [(1.0) (2.0)]`, states `/0 /1`): a number is compared +/// with them as a number first, so `1`, `1.0` and `1e0` all pick the first button and `2.0` the +/// second, where `1.0` had become `"1"`, matched no export value and picked the button whose state +/// is `/1`. A string is still matched as written, and a number no export value equals is refused. +#[test] +fn form_fill_matches_a_radio_number_to_decimal_export_values() { + let objs = [ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 400 300] >>", + "<< /Type /Page /Parent 2 0 R /Annots [6 0 R 7 0 R] >>", + "<< /Fields [5 0 R] /DA (/Helv 0 Tf 0 g) >>", + "<< /FT /Btn /T (rate) /Ff 49152 /Opt [(1.0) (2.0)] /V /Off /Kids [6 0 R 7 0 R] >>", + "<< /Type /Annot /Subtype /Widget /Parent 5 0 R /AS /Off /Rect [10 250 22 262] /P 3 0 R /AP << /N << /0 8 0 R /Off 8 0 R >> >> >>", + "<< /Type /Annot /Subtype /Widget /Parent 5 0 R /AS /Off /Rect [40 250 52 262] /P 3 0 R /AP << /N << /1 8 0 R /Off 8 0 R >> >> >>", + "<< /Type /XObject /Subtype /Form /BBox [0 0 12 12] /Length 0 >> +stream + +endstream", + ]; + let dir = workdir("radio-decimal-exports"); + std::fs::write(dir.join("form.pdf"), pdf_of(&objs)).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "form.pdf" }))["doc"].as_u64().unwrap(); + let fill = |lit: &str| -> Value { serde_json::from_str(&format!(r#"{{"doc": {doc}, "values": {{"rate": {lit}}}}}"#)).unwrap() }; + let on = |a: &Automation| -> Vec { + let f = a.session().get(pdfcraft_engine::DocId(doc)).unwrap().form[0].clone(); + f.widgets.iter().enumerate().filter(|(_, w)| w.state.as_deref().is_some_and(|s| s != "Off")).map(|(i, _)| i).collect() + }; + for (lit, button) in [("1.0", 0), ("2.0", 1), ("1", 0), ("2", 1), ("1e0", 0), ("\"1.0\"", 0), ("\"2.0\"", 1)] { + // From the other button, so a fill that does nothing would show. + ok(&mut a, "form_fill", fill(if button == 0 { "2.0" } else { "1.0" })); + ok(&mut a, "form_fill", fill(lit)); + assert_eq!(on(&a), [button], "{lit} selects button {}", button + 1); + } + let e = err(&mut a, "form_fill", fill("3")).to_string(); + assert!(e.contains("has no option"), "{e}"); +} + /// A form whose fields are only page widgets (the `/Fields` list is empty) still lists, fills and /// resets through the tools, and the leniency shows up as a repair note. #[test] diff --git a/crates/forms/src/lib.rs b/crates/forms/src/lib.rs index 568e13f541..f5450ba10c 100644 --- a/crates/forms/src/lib.rs +++ b/crates/forms/src/lib.rs @@ -229,6 +229,19 @@ impl Field { by_export.or_else(|| self.state_for(value)) } + /// The on-state name of the button whose export value is the number `value` (`/Opt [(1.0) + /// (2.0)]`: `1` and `1.0` both name the first button), compared as numbers, so the spelling a + /// caller writes a number in doesn't matter. `None` when no export value is that number, or + /// the field has no `/Opt`. + pub fn state_for_number(&self, value: f64) -> Option<&str> { + if !value.is_finite() { + return None; + } + let exports = self.button_exports()?; + let i = exports.iter().position(|(e, _)| e.trim().parse::().is_ok_and(|x| x == value))?; + 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 {