diff --git a/crates/automation/src/forms.rs b/crates/automation/src/forms.rs index aea6a7f05b..164a0e21be 100644 --- a/crates/automation/src/forms.rs +++ b/crates/automation/src/forms.rs @@ -208,17 +208,39 @@ fn kind_name(k: FormFieldKind) -> &'static str { } } +/// A JSON number as the radio option it names: its integer spelling when it is a whole number +/// (`1`, `1.0` and `1e0` are all `1`; `-0` is `0`), otherwise JSON's own spelling (`1.5`), which +/// names only an option exported as exactly that text. A whole number at or past 2^53, where a +/// float no longer holds every integer, keeps JSON's spelling too (`1e300`), so it names no option +/// by accident. +fn number_option(n: &serde_json::Number) -> String { + // Integers as parsed (u64, i64) print exactly. + if n.is_u64() || n.is_i64() { + return n.to_string(); + } + const EXACT: f64 = 9_007_199_254_740_992.0; // 2^53 + match n.as_f64() { + // Finite, whole and below 2^53 in magnitude, so the cast is exact and in range. + Some(x) if x.is_finite() && x.fract() == 0.0 && x.abs() < EXACT => (x as i64).to_string(), + _ => n.to_string(), + } +} + /// A JSON value → the value for this field (booleans for check boxes, arrays for lists…). fn value_for(f: &FormField, v: &Value) -> Result { let wrong = |what: &str| ToolError::InvalidArgs(format!("{}: {what}", f.name)); Ok(match (f.kind, v) { (FormFieldKind::CheckBox, Value::Bool(b)) => FieldValue::Check(*b), (FormFieldKind::Radio, Value::Null) => FieldValue::Radio(None), - // An option as `form_fields` lists it (an export value) names its button's on state. + // An option as `form_fields` lists it (an export value) names its button's on state. The + // export values are matched first and the on-state names after them + // (`Field::state_for_export`): with `/Opt [(1) (2)]` and states `/0` `/1`, `1` is the first + // 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`). (FormFieldKind::Radio, Value::Number(n)) => { - let s = n.to_string(); + 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( diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index c6c200cece..21ce34940a 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -397,7 +397,7 @@ pub fn tools() -> Vec { t( "form_fill", "Fill in form fields", - "Set several fields at once (one undo step). values maps field names (from form_fields) to: a string for text fields, combo boxes and radio groups (an option), true/false for check boxes, an array of strings for multi-select lists. Appearances are regenerated so every viewer shows the values. A value whose characters the field's font can't draw (Chinese in a Helvetica field) is still saved; `warnings` in the result names the field and character: PdfCraft draws `?` there and asks other viewers to redraw the field from its value.", + "Set several fields at once (one undo step). values maps field names (from form_fields) to: a string for text fields, combo boxes and radio groups (an option), true/false for check boxes, an array of strings for multi-select lists. A radio option is an export value as form_fields lists it in options; where one button's on-state name equals another button's export value, the export value wins, and an on-state name that no export value matches still selects its button. A radio option may also be a JSON number: a whole number names the option spelled as that integer (1, 1.0 and 1e0 all name \"1\"), any other number only an option spelled exactly as JSON prints it (1.5). A value that names no option is refused with the options listed. Appearances are regenerated so every viewer shows the values. A value whose characters the field's font can't draw (Chinese in a Helvetica field) is still saved; `warnings` in the result names the field and character: PdfCraft draws `?` there and asks other viewers to redraw the field from its value.", ) .with(schema(json!({ "doc": doc(), "values": { "type": "object", "description": "Field name → value." } }), &["doc", "values"])), t("form_reset", "Clear form", "Reset fields to their default values: all of them, or only those listed. Undoable.") diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 2dc7d903f9..72e8a7d3a6 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -2669,6 +2669,48 @@ fn form_fill_picks_a_radio_option_by_its_export_value() { assert_eq!(ok(&mut a, "form_fields", json!({ "doc": doc }))["fields"][0]["value"], "1"); } +/// The ambiguous pair: the second button's on state is named `1` and the first button exports +/// `1`. The export value wins, however the number is written in JSON (`1`, `1.0`, `1e0`, `0.1e1`); +/// `-0` is `0`, the first button's state. A string is matched as written, so `"1.0"` names no +/// option, and neither does a number that is not whole (`1.5`) or past 2^53 (`1e300`): each is +/// refused with the options listed and the selection kept. +#[test] +fn form_fill_reads_a_radio_number_by_its_integer_spelling() { + 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 8 0 R] >>", + "<< /Fields [5 0 R] /DA (/Helv 0 Tf 0 g) >>", + "<< /FT /Btn /T (title) /Ff 49152 /Opt [(1) (2) (3)] /V /Off /Kids [6 0 R 7 0 R 8 0 R] >>", + "<< /Type /Annot /Subtype /Widget /Parent 5 0 R /AS /Off /Rect [10 250 22 262] /P 3 0 R /AP << /N << /0 9 0 R /Off 9 0 R >> >> >>", + "<< /Type /Annot /Subtype /Widget /Parent 5 0 R /AS /Off /Rect [40 250 52 262] /P 3 0 R /AP << /N << /1 9 0 R /Off 9 0 R >> >> >>", + "<< /Type /Annot /Subtype /Widget /Parent 5 0 R /AS /Off /Rect [70 250 82 262] /P 3 0 R /AP << /N << /2 9 0 R /Off 9 0 R >> >> >>", + "<< /Type /XObject /Subtype /Form /BBox [0 0 12 12] /Length 0 >>\nstream\n\nendstream", + ]; + let dir = workdir("radio-number-spelling"); + 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(); + // The values as JSON text, so each number keeps the spelling a client sends. + let fill = |lit: &str| -> Value { serde_json::from_str(&format!(r#"{{"doc": {doc}, "values": {{"title": {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 [("2", 1), ("1", 0), ("3.0", 2), ("1.0", 0), ("2e0", 1), ("1e0", 0), ("0.1e1", 0), ("-0", 0), ("\"1\"", 0), ("2.000", 1)] { + // From another button, so a fill that does nothing would show. + ok(&mut a, "form_fill", fill(if button == 2 { "1" } else { "3" })); + ok(&mut a, "form_fill", fill(lit)); + assert_eq!(on(&a), [button], "{lit} selects button {}", button + 1); + } + ok(&mut a, "form_fill", fill("2")); + for lit in ["\"1.0\"", "1.5", "-1", "1e300", "18446744073709551615", "9007199254740993.0"] { + let e = err(&mut a, "form_fill", fill(lit)).to_string(); + assert!(e.contains("has no option") && e.contains("options: 1, 2, 3"), "{lit}: {e}"); + assert_eq!(on(&a), [1], "{lit} keeps the selection"); + } +} + /// 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 5776e6d3e1..568e13f541 100644 --- a/crates/forms/src/lib.rs +++ b/crates/forms/src/lib.rs @@ -218,7 +218,9 @@ impl Field { /// options [`Field::export_of`] lists. A field with one `/Opt` entry per widget matches those /// entries first and its on-state names only after them: the names may be positions (`/0`, /// `/1`; ISO 32000-2 Table 230), so the export value `1` is the first widget, not the second - /// one's state `/1`. Elsewhere this is [`Field::state_for`]. + /// one's state `/1`. The order is (1) an `/Opt` export value, (2) an on-state name: when one + /// button's on state is named `1` and another button exports `1`, `1` selects the one that + /// exports it. Elsewhere this is [`Field::state_for`]. pub fn state_for_export(&self, value: &str) -> Option<&str> { let by_export = self.button_exports().and_then(|exports| { let i = exports.iter().position(|(e, _)| e == value)?;