From 928c082e6b442c836901f4fe3156c0b0952af274 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Sat, 7 Nov 2026 09:28:03 +0800 Subject: [PATCH] M6: a radio number at or past 2^53 matches an export value only by its text (form.fill-checkbox-radio) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn --- crates/automation/src/forms.rs | 4 +-- crates/automation/tests/it/automation.rs | 41 ++++++++++++++++++++++++ crates/forms/src/lib.rs | 7 ++-- 3 files changed, 48 insertions(+), 4 deletions(-) diff --git a/crates/automation/src/forms.rs b/crates/automation/src/forms.rs index 4268e00e79..63f509a94b 100644 --- a/crates/automation/src/forms.rs +++ b/crates/automation/src/forms.rs @@ -238,8 +238,8 @@ 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`). It - // is compared as a number with the export values first, so `1.0` names the button + // A number is the same option written without quotes (`1` for the export value `1`). + // Below 2^53 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)) => { diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 8b76ec22eb..a6e1385964 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -2936,6 +2936,47 @@ endstream", assert!(e.contains("has no option"), "{e}"); } +/// Past 2^53 a float no longer holds every integer, so `9007199254740992` and `9007199254740993` +/// are the same `f64`. A number there is matched only by its exact text, never compared as a +/// number with the export values: with `/Opt [(9007199254740992) (2)]` the number +/// `9007199254740993` names no option, nor does `9007199254740992` with +/// `/Opt [(9007199254740993) (2)]`. Each is refused and the selection kept, while the number +/// spelled as its export value still picks that button. +#[test] +fn form_fill_matches_a_radio_number_past_2_53_only_by_its_text() { + for (export, other) in [("9007199254740992", "9007199254740993"), ("9007199254740993", "9007199254740992")] { + let field = format!("<< /FT /Btn /T (big) /Ff 49152 /Opt [({export}) (2)] /V /Off /Kids [6 0 R 7 0 R] >>"); + 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) >>", + field.as_str(), + "<< /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(&format!("radio-past-2-53-{export}")); + 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": {{"big": {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() + }; + ok(&mut a, "form_fill", fill(export)); + assert_eq!(on(&a), [0], "{export} selects the button exported as {export}"); + ok(&mut a, "form_fill", fill("2")); + let e = err(&mut a, "form_fill", fill(other)).to_string(); + assert!(e.contains("has no option"), "{other} with /Opt [({export}) (2)]: {e}"); + assert_eq!(on(&a), [1], "{other} 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 f5450ba10c..cdbf444def 100644 --- a/crates/forms/src/lib.rs +++ b/crates/forms/src/lib.rs @@ -232,9 +232,12 @@ impl Field { /// 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`. + /// the field has no `/Opt`, and for a number at or past 2^53 in magnitude: there a float no + /// longer holds every integer (`9007199254740992` and `9007199254740993` are the same `f64`), + /// so such a number is left to match an export value only by its text. pub fn state_for_number(&self, value: f64) -> Option<&str> { - if !value.is_finite() { + const EXACT: f64 = 9_007_199_254_740_992.0; // 2^53 + if !value.is_finite() || value.abs() >= EXACT { return None; } let exports = self.button_exports()?;