From f1c47db0328dafdd6f544ea9f23332167ded4b55 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Mon, 26 Oct 2026 15:56:05 +0800 Subject: [PATCH 01/11] M6: a radio option whose export value is a number selects its own button (ISO 32000-2 Table 230) A radio group with one /Opt export value per button and on states named by position (/0, /1, ...; ISO 32000-2 Table 230) took the export value "1" as the state /1, so it turned on the second button. Static XFA forms build every exclusion group this way, with items 1, 2, 3. Asking for option 1 selected option 2, 2 selected 3, and only the last option came out right. Clicks wrote the button's position (0, 1) into the XFA data instead of its item (1, 2), and XFA 3.3 ch. 4 and 17 say that data holds the member's "on" item. - Field::state_for_export resolves an export value: the per-widget /Opt first, then the on-state names. state_for, which callers holding a state (a click) use, is unchanged. - form_fill resolves a radio option (the export value form_fields lists) with it, and so does opening a static XFA form for the data's item. The pre-strip comparison against the data compares export values. - radio_dataset_value writes the selected button's /PCOn, else its /Opt entry, else its state name. On 24 public XFA forms (benefit, disability, pension and visa applications) a fill test flipped 329 radio groups on main and none with this change. Tests (each fails with the fix reverted): a forms unit test, an automation form_fill test on a pure AcroForm group, and an engine test on a synthetic static XFA exclusion group (open from data, click, save and reopen). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CBVyQEwtmEw3mS1MoAiwrK --- crates/automation/src/forms.rs | 3 +- crates/automation/tests/it/automation.rs | 34 ++++++++++++++++++++++ crates/engine/src/tests.rs | 36 ++++++++++++++++++++++++ crates/engine/src/xfa_form.rs | 10 +++++-- crates/forms/src/lib.rs | 13 +++++++++ crates/forms/src/tests.rs | 25 ++++++++++++++++ crates/xfa/src/data/values.rs | 27 +++++++++++++++--- crates/xfa/src/fixtures.rs | 24 ++++++++++++++++ parity/acrobat-features.toml | 4 +-- 9 files changed, 167 insertions(+), 9 deletions(-) diff --git a/crates/automation/src/forms.rs b/crates/automation/src/forms.rs index adf0bd0884..136b85ab5d 100644 --- a/crates/automation/src/forms.rs +++ b/crates/automation/src/forms.rs @@ -214,7 +214,8 @@ fn value_for(f: &FormField, v: &Value) -> Result { Ok(match (f.kind, v) { (FormFieldKind::CheckBox, Value::Bool(b)) => FieldValue::Check(*b), (FormFieldKind::Radio, Value::Null) => FieldValue::Radio(None), - (FormFieldKind::Radio, Value::String(s)) => FieldValue::Radio(Some(s.clone())), + // An option as `form_fields` lists it (an export value) names its button's on state. + (FormFieldKind::Radio, Value::String(s)) => FieldValue::Radio(Some(f.state_for_export(s).unwrap_or(s).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 6a5765b911..39ef2bd44e 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -2630,6 +2630,40 @@ fn form_fill_warns_when_a_value_does_not_fit() { assert!(r.get("warnings").is_none(), "{r}"); } +/// `form_fill` takes a radio option as `form_fields` lists it: the export value. With `/Opt [(1) +/// (2) (3)]` and on states named by position (`/0`, `/1`, `/2`; ISO 32000-2 Table 230), `1` is the +/// first button. It used to be read as the state `/1` and turn the second one on. +#[test] +fn form_fill_picks_a_radio_option_by_its_export_value() { + 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-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(); + assert_eq!(ok(&mut a, "form_fields", json!({ "doc": doc }))["fields"][0]["options"], json!(["1", "2", "3"])); + for pick in ["1", "2", "3"] { + ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "title": pick } })); + assert_eq!(ok(&mut a, "form_fields", json!({ "doc": doc }))["fields"][0]["value"], pick); + // The button drawn on is the one picked. + let f = a.session().get(pdfcraft_engine::DocId(doc)).unwrap().form[0].clone(); + let on: Vec = f.widgets.iter().enumerate().filter(|(_, w)| w.state.as_deref().is_some_and(|s| s != "Off")).map(|(i, _)| i).collect(); + assert_eq!(on, [pick.parse::().unwrap() - 1], "picked {pick}"); + } + // An on-state name the options do not list still selects its button. + ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "title": "0" } })); + assert_eq!(ok(&mut a, "form_fields", json!({ "doc": doc }))["fields"][0]["value"], "1"); +} + /// 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/engine/src/tests.rs b/crates/engine/src/tests.rs index 333d0fd498..50d46e1215 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -6110,6 +6110,42 @@ fn static_xfa_forms_take_their_values_from_the_datasets_and_write_them_back() { assert!(!d2.info.warnings.iter().any(|w| w.contains("taken from this form's XFA data")), "nothing was rewritten"); } +/// A static XFA exclusion group laid out as a radio group with `/Opt [(1) (2)]` and on states +/// named by position (`/0`, `/1`; ISO 32000-2 Table 230): the data's item `1` is the first +/// button, not the second one's state `/1`, and a click stores the clicked button's item. +/// Public benefit forms are built this way; their "English" came back as "French". +#[test] +fn a_static_xfa_radio_group_with_numeric_items_keeps_the_picked_button() { + let lang = "form1[0].page1[0].lang[0]"; + let data_of = |bytes: Arc>| { + let cos = pdfcraft_cos::Document::open(bytes).unwrap(); + let data = pdfcraft_xfa::parse_datasets(&pdfcraft_xfa::read_packets(&cos).unwrap().unwrap().xdp).unwrap(); + data.text_at(&pdfcraft_xfa::som_to_path(lang)).map(str::to_owned) + }; + let states = |s: &Session, id: DocId| { + let f = s.get(id).unwrap().form.iter().find(|f| f.name == lang).unwrap().clone(); + (f.value.clone(), f.widgets.iter().map(|w| w.state.clone().unwrap_or_default()).collect::>()) + }; + let form = |data: &str| Arc::new(pdfcraft_xfa::fixtures::static_radio_shell(&format!("{data}"))); + let mut s = Session::new().with_clock(|| 1_700_000_000); + // Data filled elsewhere: item 1 (English) turns the first button on, item 2 the second. + let id = s.open("one.pdf", None, form("1"), None).unwrap(); + assert_eq!(states(&s, id), (vec!["0".to_string()], vec!["0".to_string(), "Off".to_string()])); + let id = s.open("two.pdf", None, form("2"), None).unwrap(); + assert_eq!(states(&s, id), (vec!["1".to_string()], vec!["Off".to_string(), "1".to_string()])); + // A click on a button (its on state, as the page sends it) stores that button's item. + let id = s.open("blank.pdf", None, form(""), None).unwrap(); + for (state, item) in [("0", "1"), ("1", "2")] { + s.apply(id, Edit::SetFieldValue { name: lang.into(), value: FieldValue::Radio(Some(state.into())) }).unwrap(); + let saved = s.save_bytes(id).unwrap(); + assert_eq!(data_of(saved.clone()).as_deref(), Some(item), "clicked {state}"); + // Reopened, the same button is on and nothing is rewritten. + let again = s.open("again.pdf", None, saved, None).unwrap(); + assert_eq!(states(&s, again).0, vec![state.to_string()], "clicked {state}"); + assert!(!s.get(again).unwrap().info.warnings.iter().any(|w| w.contains("taken from this form's XFA data")), "clicked {state}"); + } +} + /// A static XFA form whose values are only in its datasets takes every one on opening, as /// filling the fields one at a time would leave it: values, appearances and the note; a /// read-only field keeps its own value. diff --git a/crates/engine/src/xfa_form.rs b/crates/engine/src/xfa_form.rs index 5ceaba1cd4..debd45d5e2 100644 --- a/crates/engine/src/xfa_form.rs +++ b/crates/engine/src/xfa_form.rs @@ -133,7 +133,8 @@ pub(crate) fn strip_xfa(doc: &mut pdfcraft_cos::Document, cx: &mut EditCtx) -> R match value { pdfcraft_xfa::FieldData::Text(t) => f.value.join("\n") != *t && !t.trim().is_empty(), pdfcraft_xfa::FieldData::Check(on) => f.value.is_empty() == *on, - pdfcraft_xfa::FieldData::Radio(sel) => f.value.first() != sel.as_ref(), + // The data holds the export value, the field the on-state name. + pdfcraft_xfa::FieldData::Radio(sel) => f.value.first().map(|v| f.export_for_state(v)) != sel.as_deref(), pdfcraft_xfa::FieldData::None => false, } }) @@ -283,7 +284,12 @@ pub(crate) fn xfa_values_from_datasets(doc: &mut pdfcraft_cos::Document) -> Resu } pdfcraft_xfa::FieldData::Text(t) => (f.value.join("\n") != t).then_some(FieldValue::Text(t)), pdfcraft_xfa::FieldData::Check(on) => (f.value.is_empty() == on).then_some(FieldValue::Check(on)), - pdfcraft_xfa::FieldData::Radio(sel) => (f.value.first() != sel.as_ref()).then_some(FieldValue::Radio(sel)), + // The data holds the item (the export value); the field takes the on state that + // exports it, which with `/Opt` may be a position (`1` is the state `/0`). + pdfcraft_xfa::FieldData::Radio(sel) => { + let state = sel.map(|v| f.state_for_export(&v).map_or(v.clone(), str::to_owned)); + (f.value.first() != state.as_ref()).then_some(FieldValue::Radio(state)) + } pdfcraft_xfa::FieldData::None => None, }; if let Some(v) = new { diff --git a/crates/forms/src/lib.rs b/crates/forms/src/lib.rs index 775306d865..5776e6d3e1 100644 --- a/crates/forms/src/lib.rs +++ b/crates/forms/src/lib.rs @@ -214,6 +214,19 @@ impl Field { }) } + /// The on-state name that selects `value` read as an export value: form data, or one of the + /// 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`]. + 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)?; + self.widgets.get(i)?.on_state.as_deref() + }); + by_export.or_else(|| self.state_for(value)) + } + /// 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 { diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index cca4cb4881..fb568532f9 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -387,6 +387,31 @@ fn opt_export_values_select_button_states() { assert!(err.to_string().contains("options: Post, Pick-up"), "{err}"); } +/// Export values that are themselves numbers (`/Opt [(1) (2)]`, as static XFA forms write their +/// items) name the buttons in `/Opt` order when read as export values, though `1` is also the +/// second button's on-state name (`/0`, `/1` by position; ISO 32000-2 Table 230). +#[test] +fn numeric_opt_export_values_name_their_own_buttons() { + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [6 0 R 7 0 R] >>".into(), + "<< /Fields [5 0 R] /DA (/Helv 0 Tf 0 g) >>".into(), + "<< /FT /Btn /T (lang) /Ff 49152 /Opt [(1) (2)] /V /Off /Kids [6 0 R 7 0 R] >>".into(), + "<< /Type /Annot /Subtype /Widget /Parent 5 0 R /AS /Off /Rect [100 700 115 715] /P 3 0 R /AP << /N << /0 8 0 R /Off 8 0 R >> >> >>".into(), + "<< /Type /Annot /Subtype /Widget /Parent 5 0 R /AS /Off /Rect [200 700 215 715] /P 3 0 R /AP << /N << /1 8 0 R /Off 8 0 R >> >> >>".into(), + "<< /Length 0 >>\nstream\n\nendstream".into(), + ]; + let doc = document(&objs); + let all = fields(&doc); + let lang = field(&all, "lang"); + assert_eq!((lang.state_for_export("1"), lang.state_for_export("2")), (Some("0"), Some("1"))); + // A state name no export value matches still names its button; anything else names none. + assert_eq!((lang.state_for_export("0"), lang.state_for_export("3")), (Some("0"), None)); + // On-state names keep their meaning where the caller has one (a click sends the state). + assert_eq!(lang.state_for("1"), Some("1")); +} + /// What fits a text field is measured as its appearance draws it: in the field's own font (its /// `/DR` font, Helvetica where that can't show the text), inside the border and padding, at its /// size, wrapped for multi-line, at the smallest automatic size, as asterisks for a password, diff --git a/crates/xfa/src/data/values.rs b/crates/xfa/src/data/values.rs index 8042e774fe..55cf864645 100644 --- a/crates/xfa/src/data/values.rs +++ b/crates/xfa/src/data/values.rs @@ -8,7 +8,8 @@ pub enum FieldData { Text(String), /// Checked or not. Check(bool), - /// The selected button's on value, if any. + /// The selected button, if any: written from its on-state name (the data gets its item, see + /// `radio_dataset_value`), read as the data's item, which is its export value. Radio(Option), /// Nothing to write (buttons, signatures). None, @@ -35,14 +36,31 @@ fn som_of(doc: &Document, f: &FieldDatum) -> String { /// The datasets text for a radio group. The AcroForm value is an appearance-state name /// (`New York` is stored as `New_York`); `/PCOn` on the selected widget is the original item. +/// Without it, a group with one `/Opt` entry per widget exports that entry: its state names may +/// be positions (`/0`, `/1`; ISO 32000-2 Table 230), and the data holds the item (`1`, `2`). fn radio_dataset_value(doc: &Document, f: &FieldDatum, sel: &Option) -> String { let Some(selected) = sel.as_deref().filter(|s| !s.is_empty() && *s != "Off") else { return String::new(); }; let Some(group) = doc.get(f.obj).as_dict().cloned() else { return selected.to_string() }; let kids = group.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_else(|| vec![Object::Ref(f.obj)]); - for kid in kids { - let Some(widget) = doc.resolve(&kid).as_dict().cloned() else { continue }; + // One export value per widget, as the field's `/Opt` lists them (an entry may be an + // `[export display]` pair, as a choice field's are). + let exports: Option>> = group + .get(b"Opt") + .map(|o| doc.resolve(o)) + .and_then(|o| o.as_array().cloned()) + .map(|opt| { + opt.iter() + .map(|o| match &*doc.resolve(o) { + Object::Array(pair) => pair.first().and_then(|e| doc.resolve(e).as_string().map(|s| s.to_text())), + other => other.as_string().map(|s| s.to_text()), + }) + .collect() + }) + .filter(|opt: &Vec>| opt.len() == kids.len()); + for (i, kid) in kids.iter().enumerate() { + let Some(widget) = doc.resolve(kid).as_dict().cloned() else { continue }; let original = text_of(doc, &widget, b"PCOn"); let state = widget .get(b"AP") @@ -51,7 +69,8 @@ fn radio_dataset_value(doc: &Document, f: &FieldDatum, sel: &Option) -> .and_then(|n| doc.resolve(&n).as_dict().cloned()) .and_then(|n| n.iter().map(|(k, _)| String::from_utf8_lossy(k).into_owned()).find(|k| k != "Off")); if state.as_deref() == Some(selected) || original.as_deref() == Some(selected) { - return original.unwrap_or_else(|| selected.to_string()); + let export = exports.as_ref().and_then(|e| e.get(i).cloned().flatten()); + return original.or(export).unwrap_or_else(|| selected.to_string()); } } selected.to_string() diff --git a/crates/xfa/src/fixtures.rs b/crates/xfa/src/fixtures.rs index 8f6e0f7ee8..c1fd0d9c44 100644 --- a/crates/xfa/src/fixtures.rs +++ b/crates/xfa/src/fixtures.rs @@ -144,6 +144,30 @@ pub fn static_shell(data: &str) -> Vec { assemble(objs) } +/// A static XFA form with one exclusion group `form1[0].page1[0].lang[0]` (items `1` English, +/// `2` French) bound to the data by name, laid out as an AcroForm radio group whose `/Opt` holds +/// the items and whose on states are the buttons' positions `/0` and `/1` (ISO 32000-2 Table 230), +/// as public static forms write it. Its datasets hold `data` (the children of `xfa:data`). +pub fn static_radio_shell(data: &str) -> Vec { + let xdp = format!( + "{data}" + ); + let objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R /AcroForm << /XFA 5 0 R /Fields [6 0 R] /DA (/Helv 0 Tf 0 g) >> >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Annots [9 0 R 10 0 R] >>".to_vec(), + b"<< /Length 0 >>\nstream\n\nendstream".to_vec(), + format!("<< /Length {} >>\nstream\n{xdp}\nendstream", xdp.len()).into_bytes(), + b"<< /T (form1[0]) /Kids [7 0 R] >>".to_vec(), + b"<< /T (page1[0]) /Parent 6 0 R /Kids [8 0 R] >>".to_vec(), + b"<< /FT /Btn /Ff 49152 /T (lang[0]) /Parent 7 0 R /Opt [(1) (2)] /V /Off /Kids [9 0 R 10 0 R] >>".to_vec(), + b"<< /Type /Annot /Subtype /Widget /Parent 8 0 R /Rect [72 700 84 712] /P 3 0 R /AS /Off /MK << /BC [0 0 0] >> /AP << /N << /0 11 0 R /Off 11 0 R >> >> >>".to_vec(), + b"<< /Type /Annot /Subtype /Widget /Parent 8 0 R /Rect [144 700 156 712] /P 3 0 R /AS /Off /MK << /BC [0 0 0] >> /AP << /N << /1 11 0 R /Off 11 0 R >> >> >>".to_vec(), + b"<< /Type /XObject /Subtype /Form /BBox [0 0 12 12] /Length 0 >>\nstream\n\nendstream".to_vec(), + ]; + assemble(objs) +} + /// A static XFA form of `n` text fields `form1[0].page1[0].f[0]` on one page, whose values /// (`v`) are only in its datasets, as when another viewer filled it in. The first field is /// read-only. diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 0b6f658dd6..975f75e022 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -4523,8 +4523,8 @@ milestone = "M6" status = "shipped" commands = ["form.fields"] tools = ["form_fill"] -evidence = ["crates/ui-egui/tests/it/forms.rs::check_boxes_radios_and_choices", "crates/forms/src/tests.rs::check_boxes_and_radios_switch_states", "crates/ui-egui/tests/it/forms.rs::tab_and_space_fill_in_a_check_box_between_text_fields", "crates/ui-egui/tests/it/forms.rs::clicking_a_check_box_while_typing_keeps_the_text_and_focuses_the_box", "crates/ui-egui/tests/it/forms.rs::radio_buttons_take_tab_space_and_arrows", "crates/forms/src/tests.rs::a_round_xfa_check_button_draws_a_round_background_and_border", "crates/xfa/src/tests.rs::round_check_buttons_are_marked_for_a_round_appearance", "crates/engine/src/tests.rs::round_xfa_check_buttons_open_drawn_round"] -notes = "NoToggleToOff honoured; widgets without appearances get our own, drawn round for an XFA check button with shape=\"round\" (its background and border a circle inside the widget, as another viewer draws them on a synthetic form). Tab and Shift+Tab stop on check boxes and on each radio button, Space toggles the focused one, and the arrows move within a radio group (#2223); a click focuses the box too" +evidence = ["crates/ui-egui/tests/it/forms.rs::check_boxes_radios_and_choices", "crates/forms/src/tests.rs::check_boxes_and_radios_switch_states", "crates/ui-egui/tests/it/forms.rs::tab_and_space_fill_in_a_check_box_between_text_fields", "crates/ui-egui/tests/it/forms.rs::clicking_a_check_box_while_typing_keeps_the_text_and_focuses_the_box", "crates/ui-egui/tests/it/forms.rs::radio_buttons_take_tab_space_and_arrows", "crates/forms/src/tests.rs::a_round_xfa_check_button_draws_a_round_background_and_border", "crates/xfa/src/tests.rs::round_check_buttons_are_marked_for_a_round_appearance", "crates/engine/src/tests.rs::round_xfa_check_buttons_open_drawn_round", "crates/forms/src/tests.rs::numeric_opt_export_values_name_their_own_buttons", "crates/automation/tests/it/automation.rs::form_fill_picks_a_radio_option_by_its_export_value", "crates/engine/src/tests.rs::a_static_xfa_radio_group_with_numeric_items_keeps_the_picked_button"] +notes = "NoToggleToOff honoured; widgets without appearances get our own, drawn round for an XFA check button with shape=\"round\" (its background and border a circle inside the widget, as another viewer draws them on a synthetic form). Tab and Shift+Tab stop on check boxes and on each radio button, Space toggles the focused one, and the arrows move within a radio group (#2223); a click focuses the box too. A radio group with one /Opt export value per button and on states named by position (/0, /1; ISO 32000-2 Table 230) takes an export value from form_fill and from a static XFA form's data as that button, also when the export values are numbers (1 is the first button, not the state /1), and a click writes the button's /Opt value to the XFA data" [[feature]] id = "form.fill-choice" From 5d2332107a9450c906cc7dd0d3df4144e6c97e73 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sat, 31 Oct 2026 16:09:37 +0800 Subject: [PATCH 02/11] M6: a numeric radio option fills its own button, and stripping XFA reads /PCOn as the field's item - form_fill took a JSON number for a radio group as text, so {"title": 1} on /Opt [(1) (2)] with on states /0 /1 turned on the second button. A number now resolves through state_for_export as the same option written as a string does. - strip_xfa compared the XFA data with the selected button's /Opt entry only, but saving writes the button's /PCOn ahead of it. A group with /PCOn then reported a lost value on Flatten and XfaToStatic. The data now agrees with the field when it holds what saving writes (radio_dataset_value, made public) or a value that selects the same button (state_for_export). Tests (each fails with its fix reverted): the automation form_fill radio test now also fills 1, 2 and 3 as numbers; an engine test strips a group whose /PCOn equals the state, and one whose /PCOn is neither the state nor the /Opt entry. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv --- crates/automation/src/forms.rs | 5 +++++ crates/automation/tests/it/automation.rs | 5 +++++ crates/engine/src/tests.rs | 24 ++++++++++++++++++++++++ crates/engine/src/xfa_form.rs | 11 +++++++++-- crates/xfa/src/data.rs | 2 +- crates/xfa/src/data/values.rs | 3 ++- crates/xfa/src/fixtures.rs | 17 +++++++++++++++-- crates/xfa/src/lib.rs | 4 ++-- 8 files changed, 63 insertions(+), 8 deletions(-) diff --git a/crates/automation/src/forms.rs b/crates/automation/src/forms.rs index 136b85ab5d..aea6a7f05b 100644 --- a/crates/automation/src/forms.rs +++ b/crates/automation/src/forms.rs @@ -216,6 +216,11 @@ fn value_for(f: &FormField, v: &Value) -> Result { (FormFieldKind::Radio, Value::Null) => FieldValue::Radio(None), // An option as `form_fields` lists it (an export value) names its button's on state. (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(); + 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 39ef2bd44e..2dc7d903f9 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -2659,6 +2659,11 @@ fn form_fill_picks_a_radio_option_by_its_export_value() { let on: Vec = f.widgets.iter().enumerate().filter(|(_, w)| w.state.as_deref().is_some_and(|s| s != "Off")).map(|(i, _)| i).collect(); assert_eq!(on, [pick.parse::().unwrap() - 1], "picked {pick}"); } + // A number is the same option: `1` is the first button, not the state `/1`. + for pick in [1, 2, 3] { + ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "title": pick } })); + assert_eq!(ok(&mut a, "form_fields", json!({ "doc": doc }))["fields"][0]["value"], pick.to_string(), "picked {pick}"); + } // An on-state name the options do not list still selects its button. ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "title": "0" } })); assert_eq!(ok(&mut a, "form_fields", json!({ "doc": doc }))["fields"][0]["value"], "1"); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 50d46e1215..cc1062fe65 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -6146,6 +6146,30 @@ fn a_static_xfa_radio_group_with_numeric_items_keeps_the_picked_button() { } } +/// Taking the XFA out of a radio group whose buttons carry `/PCOn` as well as `/Opt` (the item +/// saving writes ahead of the `/Opt` entry) notes no lost data when the data holds that item: +/// one equal to the on state, and one equal to neither the state nor the `/Opt` entry. +#[test] +fn stripping_xfa_takes_a_radio_items_pcon_as_the_fields_value() { + let lang = "form1[0].page1[0].lang[0]"; + let lost = |s: &Session, id: DocId| { + s.get(id).unwrap().info.warnings.iter().filter(|w| w.contains("held a value other than the form's XFA data")).cloned().collect::>() + }; + let mut s = Session::new().with_clock(|| 1_700_000_000); + // `/PCOn` equals the state: the data's `0` is the first button, whose `/Opt` entry is `1`. + let form = pdfcraft_xfa::fixtures::static_radio_shell_with_pcon("0", ["0", "1"]); + let id = s.open("pcon-state.pdf", None, Arc::new(form), None).unwrap(); + assert_eq!(s.get(id).unwrap().form.iter().find(|f| f.name == lang).unwrap().value, ["0"]); + s.apply(id, Edit::XfaToStatic).unwrap(); + assert_eq!(lost(&s, id), Vec::::new()); + // `/PCOn` is neither: a click writes `en` to the data for the first button. + let form = pdfcraft_xfa::fixtures::static_radio_shell_with_pcon("", ["en", "fr"]); + let id = s.open("pcon-item.pdf", None, Arc::new(form), None).unwrap(); + s.apply(id, Edit::SetFieldValue { name: lang.into(), value: FieldValue::Radio(Some("0".into())) }).unwrap(); + s.apply(id, Edit::XfaToStatic).unwrap(); + assert_eq!(lost(&s, id), Vec::::new()); +} + /// A static XFA form whose values are only in its datasets takes every one on opening, as /// filling the fields one at a time would leave it: values, appearances and the note; a /// read-only field keeps its own value. diff --git a/crates/engine/src/xfa_form.rs b/crates/engine/src/xfa_form.rs index debd45d5e2..96ab91e6c6 100644 --- a/crates/engine/src/xfa_form.rs +++ b/crates/engine/src/xfa_form.rs @@ -133,8 +133,15 @@ pub(crate) fn strip_xfa(doc: &mut pdfcraft_cos::Document, cx: &mut EditCtx) -> R match value { pdfcraft_xfa::FieldData::Text(t) => f.value.join("\n") != *t && !t.trim().is_empty(), pdfcraft_xfa::FieldData::Check(on) => f.value.is_empty() == *on, - // The data holds the export value, the field the on-state name. - pdfcraft_xfa::FieldData::Radio(sel) => f.value.first().map(|v| f.export_for_state(v)) != sel.as_deref(), + // The field holds an on-state name; the data names a button by its item. They agree + // when the data holds what saving writes (`/PCOn`, the `/Opt` entry or the state + // name), or an export value or state that selects the same button. + pdfcraft_xfa::FieldData::Radio(sel) => { + let state = f.value.first().map(String::as_str); + let same_button = sel.as_deref().map(|s| f.state_for_export(s).unwrap_or(s)) == state; + let written = data.iter().find(|d| d.name == *name).map(|d| pdfcraft_xfa::radio_dataset_value(doc, d, &f.value.first().cloned())); + !same_button && written.as_deref() != Some(sel.as_deref().unwrap_or_default()) + } pdfcraft_xfa::FieldData::None => false, } }) diff --git a/crates/xfa/src/data.rs b/crates/xfa/src/data.rs index 7c387c44ef..64b3272e18 100644 --- a/crates/xfa/src/data.rs +++ b/crates/xfa/src/data.rs @@ -36,7 +36,7 @@ pub use import::{DataImport, ImportNode, export_data, import_data, parse_data_fi pub use passwords::{clear_password_values, has_password_values}; pub(crate) use path::template_field; pub use path::{data_path, parse_datasets, som_to_path}; -pub use values::{FieldData, FieldDatum, is_on, read_values, read_values_with}; +pub use values::{FieldData, FieldDatum, is_on, radio_dataset_value, read_values, read_values_with}; #[cfg(test)] pub(crate) use write::ENSURED; pub use write::{ diff --git a/crates/xfa/src/data/values.rs b/crates/xfa/src/data/values.rs index 55cf864645..1a476e1ae1 100644 --- a/crates/xfa/src/data/values.rs +++ b/crates/xfa/src/data/values.rs @@ -38,7 +38,8 @@ fn som_of(doc: &Document, f: &FieldDatum) -> String { /// (`New York` is stored as `New_York`); `/PCOn` on the selected widget is the original item. /// Without it, a group with one `/Opt` entry per widget exports that entry: its state names may /// be positions (`/0`, `/1`; ISO 32000-2 Table 230), and the data holds the item (`1`, `2`). -fn radio_dataset_value(doc: &Document, f: &FieldDatum, sel: &Option) -> String { +/// `sel` is the selected on-state name; with none selected the text is empty. +pub fn radio_dataset_value(doc: &Document, f: &FieldDatum, sel: &Option) -> String { let Some(selected) = sel.as_deref().filter(|s| !s.is_empty() && *s != "Off") else { return String::new(); }; diff --git a/crates/xfa/src/fixtures.rs b/crates/xfa/src/fixtures.rs index c1fd0d9c44..0242dd083c 100644 --- a/crates/xfa/src/fixtures.rs +++ b/crates/xfa/src/fixtures.rs @@ -149,6 +149,17 @@ pub fn static_shell(data: &str) -> Vec { /// the items and whose on states are the buttons' positions `/0` and `/1` (ISO 32000-2 Table 230), /// as public static forms write it. Its datasets hold `data` (the children of `xfa:data`). pub fn static_radio_shell(data: &str) -> Vec { + radio_shell(data, None) +} + +/// [`static_radio_shell`] whose buttons also carry `/PCOn`, the item PdfCraft writes to the data +/// for the selected button ahead of its `/Opt` entry. +pub fn static_radio_shell_with_pcon(data: &str, pcon: [&str; 2]) -> Vec { + radio_shell(data, Some(pcon)) +} + +fn radio_shell(data: &str, pcon: Option<[&str; 2]>) -> Vec { + let pc = |i: usize| pcon.and_then(|p| p.get(i).map(|v| format!(" /PCOn ({v})"))).unwrap_or_default(); let xdp = format!( "{data}" ); @@ -161,8 +172,10 @@ pub fn static_radio_shell(data: &str) -> Vec { b"<< /T (form1[0]) /Kids [7 0 R] >>".to_vec(), b"<< /T (page1[0]) /Parent 6 0 R /Kids [8 0 R] >>".to_vec(), b"<< /FT /Btn /Ff 49152 /T (lang[0]) /Parent 7 0 R /Opt [(1) (2)] /V /Off /Kids [9 0 R 10 0 R] >>".to_vec(), - b"<< /Type /Annot /Subtype /Widget /Parent 8 0 R /Rect [72 700 84 712] /P 3 0 R /AS /Off /MK << /BC [0 0 0] >> /AP << /N << /0 11 0 R /Off 11 0 R >> >> >>".to_vec(), - b"<< /Type /Annot /Subtype /Widget /Parent 8 0 R /Rect [144 700 156 712] /P 3 0 R /AS /Off /MK << /BC [0 0 0] >> /AP << /N << /1 11 0 R /Off 11 0 R >> >> >>".to_vec(), + format!("<< /Type /Annot /Subtype /Widget /Parent 8 0 R /Rect [72 700 84 712] /P 3 0 R /AS /Off /MK << /BC [0 0 0] >>{} /AP << /N << /0 11 0 R /Off 11 0 R >> >> >>", pc(0)) + .into_bytes(), + format!("<< /Type /Annot /Subtype /Widget /Parent 8 0 R /Rect [144 700 156 712] /P 3 0 R /AS /Off /MK << /BC [0 0 0] >>{} /AP << /N << /1 11 0 R /Off 11 0 R >> >> >>", pc(1)) + .into_bytes(), b"<< /Type /XObject /Subtype /Form /BBox [0 0 12 12] /Length 0 >>\nstream\n\nendstream".to_vec(), ]; assemble(objs) diff --git a/crates/xfa/src/lib.rs b/crates/xfa/src/lib.rs index 7e63218c90..28e8dd09e7 100644 --- a/crates/xfa/src/lib.rs +++ b/crates/xfa/src/lib.rs @@ -93,8 +93,8 @@ pub(crate) fn xml_nesting_depth(text: &str) -> usize { pub use data::{DataImport, ImportNode, export_data, import_data, parse_data_file, write_data_image}; pub use data::{ DataNode, DataOp, DataPath, DatasetsWrite, FieldData, FieldDatum, add_data_instances, build_data, clear_password_values, data_path, - has_password_values, iso_to_pattern, parse_datasets, pattern_to_iso, read_values, read_values_with, remove_data_instance, som_to_path, - write_data_ops, write_data_value, write_datasets, write_datasets_reusing, write_datasets_with, + has_password_values, iso_to_pattern, parse_datasets, pattern_to_iso, radio_dataset_value, read_values, read_values_with, remove_data_instance, + som_to_path, write_data_ops, write_data_value, write_datasets, write_datasets_reusing, write_datasets_with, }; pub use layout::{Action, BorderShape, Form, Item, MAX_PAGES, Page, Widget, WidgetKind, formatted_value, layout}; pub use packets::{Encoding, Packets, decode as decode_packet, encode as encode_packet, read_packets}; From c42851da1edabc8566b4df2f841927d5b2dc14b4 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sun, 1 Nov 2026 19:24:24 +0800 Subject: [PATCH 03/11] M6: a radio option given as a JSON number names the option its integer spelling exports, and the export-value-first precedence is stated form_fill turned a JSON number for a radio group into text with Number::to_string, which spells a float with a fraction part: 1.0, 1e0 and 0.1e1 became "1.0", and serde_json parses -0 as the float -0.0 ("-0.0"). None names the option "1". number_option now spells a whole number below 2^53 as that integer (1.0 -> "1", -0 -> "0"); integers keep their exact spelling; any other number keeps JSON's spelling (1.5, 1e300), so it names only an option exported as exactly that text, and is otherwise refused with the options listed. The precedence when an on-state name equals another button's export value (export value first, then on-state names; ISO 32000-2 Table 230 lets state names be positions) is now stated in state_for_export, at the call site and in form_fill's tool description, with the number rules. Test (fails with Number::to_string restored): the ambiguous pair (the first button exports "1", the second's state is /1) filled with 1, 1.0, 1e0, 0.1e1, "1", -0, 2e0, 2.000 and 3.0 selects the right button; "1.0", 1.5, -1, 1e300, u64::MAX and 9007199254740993.0 are refused with the options listed and the selection kept. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv --- crates/automation/src/forms.rs | 26 +++++++++++++-- crates/automation/src/tools.rs | 2 +- crates/automation/tests/it/automation.rs | 42 ++++++++++++++++++++++++ crates/forms/src/lib.rs | 4 ++- 4 files changed, 70 insertions(+), 4 deletions(-) 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)?; From dbea0793e8b50445585cd876854e05a334089b80 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sun, 1 Nov 2026 19:24:24 +0800 Subject: [PATCH 04/11] M6: a radio group's XFA data compares every button's on state before any /PCOn radio_dataset_value returned the first widget whose on state or /PCOn equalled the selected state. With states /0 /1 and /PCOn items 1 2, selecting /1 matched the first widget's /PCOn "1" and wrote 1 instead of 2, and strip_xfa's comparison, which uses the same function, could then miss a lost value. The selection is an on-state name, so every widget's state is now compared first and /PCOn only after. Test (fails with the one-pass match restored, writing "1" for /1): the static radio fixture with /PCOn 1 2 writes 1 for /0, 2 for /1, 2 for the item 2, the value itself when nothing matches, and nothing with no selection. This changes the XFA data saved for such groups. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv --- crates/xfa/src/data/values.rs | 37 +++++++++++++++++++++++------------ crates/xfa/src/tests.rs | 18 +++++++++++++++++ 2 files changed, 42 insertions(+), 13 deletions(-) diff --git a/crates/xfa/src/data/values.rs b/crates/xfa/src/data/values.rs index 1a476e1ae1..2fef49e85c 100644 --- a/crates/xfa/src/data/values.rs +++ b/crates/xfa/src/data/values.rs @@ -60,21 +60,32 @@ pub fn radio_dataset_value(doc: &Document, f: &FieldDatum, sel: &Option) .collect() }) .filter(|opt: &Vec>| opt.len() == kids.len()); - for (i, kid) in kids.iter().enumerate() { - let Some(widget) = doc.resolve(kid).as_dict().cloned() else { continue }; - let original = text_of(doc, &widget, b"PCOn"); - let state = widget - .get(b"AP") - .map(|ap| doc.resolve(ap)) - .and_then(|ap| ap.as_dict().and_then(|a| a.get(b"N").cloned())) - .and_then(|n| doc.resolve(&n).as_dict().cloned()) - .and_then(|n| n.iter().map(|(k, _)| String::from_utf8_lossy(k).into_owned()).find(|k| k != "Off")); - if state.as_deref() == Some(selected) || original.as_deref() == Some(selected) { + // Each widget's (`/PCOn` item, on-state name, `/Opt` entry). + let widgets: Vec<(Option, Option, Option)> = kids + .iter() + .enumerate() + .filter_map(|(i, kid)| { + let widget = doc.resolve(kid).as_dict().cloned()?; + let original = text_of(doc, &widget, b"PCOn"); + let state = widget + .get(b"AP") + .map(|ap| doc.resolve(ap)) + .and_then(|ap| ap.as_dict().and_then(|a| a.get(b"N").cloned())) + .and_then(|n| doc.resolve(&n).as_dict().cloned()) + .and_then(|n| n.iter().map(|(k, _)| String::from_utf8_lossy(k).into_owned()).find(|k| k != "Off")); let export = exports.as_ref().and_then(|e| e.get(i).cloned().flatten()); - return original.or(export).unwrap_or_else(|| selected.to_string()); - } + Some((original, state, export)) + }) + .collect(); + // `sel` is an on-state name, so every widget's state is compared before any `/PCOn`: with + // states `/0` `/1` and items `1` `2`, the state `1` is the second widget, not the first + // one's item. + let by_state = widgets.iter().find(|(_, state, _)| state.as_deref() == Some(selected)); + let by_item = || widgets.iter().find(|(original, _, _)| original.as_deref() == Some(selected)); + match by_state.or_else(by_item) { + Some((original, _, export)) => original.clone().or_else(|| export.clone()).unwrap_or_else(|| selected.to_string()), + None => selected.to_string(), } - selected.to_string() } /// The on and off item values of a check box (`/PCItems`), or the widget's on state. diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index e424be5441..829bb76853 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -4145,3 +4145,21 @@ fn clear_password_values_clears_static_xfa_password_fields() { let packets = read_packets(&doc).unwrap().unwrap(); assert!(!packets.xdp.contains("hunter2"), "{}", packets.xdp); } + +/// The data a radio group writes for its selected on state compares every button's state before +/// any `/PCOn`: with states `/0` `/1` and `/PCOn` items `1` `2`, the state `1` is the second +/// button, whose item is `2` (it used to match the first button's `/PCOn` `1` and write `1`). +/// A selection that is only an item still finds its button. +#[test] +fn a_radio_state_is_matched_before_any_pcon_item() { + let doc = Document::open(Arc::new(crate::fixtures::static_radio_shell_with_pcon("", ["1", "2"]))).unwrap(); + let group = FieldDatum { obj: pdfcraft_cos::ObjRef::new(8, 0), name: "form1[0].page1[0].lang[0]".into(), data: FieldData::None }; + let write = |sel: &str| crate::data::radio_dataset_value(&doc, &group, &Some(sel.to_string())); + assert_eq!(write("0"), "1"); + assert_eq!(write("1"), "2"); + // `2` is no state: it is the second button's item. + assert_eq!(write("2"), "2"); + // Neither: written as given; nothing selected: empty. + assert_eq!(write("9"), "9"); + assert_eq!(crate::data::radio_dataset_value(&doc, &group, &None), ""); +} From c9bec299e66720631310c8a6532cbf3675049ca8 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sun, 1 Nov 2026 22:36:56 +0800 Subject: [PATCH 05/11] M6: test that a radio group with /PCOn items saves the clicked button's item and reopens on it Round trip through the session for the static radio fixture with /PCOn items 1 2 next to /Opt [(1) (2)] and position states /0 /1: a click on either button saves that button's own item to the datasets, reopening turns the same button on with nothing rewritten, and taking the XFA out notes no lost data. With radio_dataset_value as before dbea0793e the second button saves "1" (left: Some("1"), right: Some("2")). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv --- crates/engine/src/tests.rs | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index cc1062fe65..6b793458d7 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -6170,6 +6170,35 @@ fn stripping_xfa_takes_a_radio_items_pcon_as_the_fields_value() { assert_eq!(lost(&s, id), Vec::::new()); } +/// A radio group whose buttons carry `/PCOn` items `1` `2` next to `/Opt [(1) (2)]` and on +/// states named by position (`/0`, `/1`): a click on either button saves that button's own item +/// to the datasets, reopening turns the same button on with nothing rewritten, and taking the XFA +/// out notes no lost data. The state `/1` used to match the first button's item `1`, so the +/// second button saved `1` and came back as the first. +#[test] +fn a_radio_group_with_pcon_items_saves_and_reopens_the_clicked_button() { + let lang = "form1[0].page1[0].lang[0]"; + let data_of = |bytes: Arc>| { + let cos = pdfcraft_cos::Document::open(bytes).unwrap(); + let data = pdfcraft_xfa::parse_datasets(&pdfcraft_xfa::read_packets(&cos).unwrap().unwrap().xdp).unwrap(); + data.text_at(&pdfcraft_xfa::som_to_path(lang)).map(str::to_owned) + }; + let value = |s: &Session, id: DocId| s.get(id).unwrap().form.iter().find(|f| f.name == lang).unwrap().value.clone(); + let form = Arc::new(pdfcraft_xfa::fixtures::static_radio_shell_with_pcon("", ["1", "2"])); + let mut s = Session::new().with_clock(|| 1_700_000_000); + for (state, item) in [("0", "1"), ("1", "2")] { + let id = s.open("blank.pdf", None, form.clone(), None).unwrap(); + s.apply(id, Edit::SetFieldValue { name: lang.into(), value: FieldValue::Radio(Some(state.into())) }).unwrap(); + let saved = s.save_bytes(id).unwrap(); + assert_eq!(data_of(saved.clone()).as_deref(), Some(item), "clicked {state}"); + let again = s.open("again.pdf", None, saved, None).unwrap(); + assert_eq!(value(&s, again), [state], "clicked {state}"); + assert!(!s.get(again).unwrap().info.warnings.iter().any(|w| w.contains("taken from this form's XFA data")), "clicked {state}"); + s.apply(again, Edit::XfaToStatic).unwrap(); + assert!(!s.get(again).unwrap().info.warnings.iter().any(|w| w.contains("held a value other than the form's XFA data")), "clicked {state}"); + } +} + /// A static XFA form whose values are only in its datasets takes every one on opening, as /// filling the fields one at a time would leave it: values, appearances and the note; a /// read-only field keeps its own value. From e777332b2d86fb27d63c952643709d2a6c1e94b3 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sun, 1 Nov 2026 22:39:45 +0800 Subject: [PATCH 06/11] M6: opening reads a radio group's XFA data as saving writes it, /PCOn first Saving writes the selected button's /PCOn, else its /Opt entry, else its state name (radio_dataset_value). Opening read the data as an /Opt export value first, so with /PCOn items 4 1 next to /Opt [(1) (2)] the second button saved 1 and reopened as the first, whose /Opt entry is 1, and taking the XFA out compared against that reading. radio_state_for_item now inverts the writer: the button whose written item equals the data. Data no button writes (from another viewer) is read as before, as an export value, then a state name. The widget walk is shared with the writer. Test (fails with the reader as before: the second button reopens as ["0"]): the /PCOn 4 1 fixture saves 1 for the second button and 4 for the first, each reopens on the clicked button and strips with no lost data note; data 2 and 0 from elsewhere still select /1 and /0. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv --- crates/engine/src/tests.rs | 46 +++++++++++++++++++++++++++++++++++ crates/engine/src/xfa_form.rs | 26 +++++++++++++++----- crates/xfa/src/data.rs | 2 +- crates/xfa/src/data/values.rs | 46 ++++++++++++++++++++++++----------- crates/xfa/src/lib.rs | 4 +-- 5 files changed, 101 insertions(+), 23 deletions(-) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 6b793458d7..fb5cba9d8f 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -6170,6 +6170,52 @@ fn stripping_xfa_takes_a_radio_items_pcon_as_the_fields_value() { assert_eq!(lost(&s, id), Vec::::new()); } +/// Opening reads a radio group's data as saving writes it: with `/PCOn` items `4` `1` next to +/// `/Opt [(1) (2)]` and states `/0` `/1`, a click on the second button saves its item `1`, and +/// reopening turns the second button on again, not the first one whose `/Opt` entry is `1`; the +/// first button saves `4` and comes back too. Taking the XFA out then notes no lost data. +#[test] +fn a_radio_item_that_is_another_buttons_export_reopens_on_its_own_button() { + let lang = "form1[0].page1[0].lang[0]"; + let data_of = |bytes: Arc>| { + let cos = pdfcraft_cos::Document::open(bytes).unwrap(); + let data = pdfcraft_xfa::parse_datasets(&pdfcraft_xfa::read_packets(&cos).unwrap().unwrap().xdp).unwrap(); + data.text_at(&pdfcraft_xfa::som_to_path(lang)).map(str::to_owned) + }; + let value = |s: &Session, id: DocId| s.get(id).unwrap().form.iter().find(|f| f.name == lang).unwrap().value.clone(); + let form = Arc::new(pdfcraft_xfa::fixtures::static_radio_shell_with_pcon("", ["4", "1"])); + let mut s = Session::new().with_clock(|| 1_700_000_000); + for (state, item) in [("1", "1"), ("0", "4")] { + let id = s.open("blank.pdf", None, form.clone(), None).unwrap(); + s.apply(id, Edit::SetFieldValue { name: lang.into(), value: FieldValue::Radio(Some(state.into())) }).unwrap(); + let saved = s.save_bytes(id).unwrap(); + assert_eq!(data_of(saved.clone()).as_deref(), Some(item), "clicked {state}"); + let again = s.open("again.pdf", None, saved, None).unwrap(); + assert_eq!(value(&s, again), [state], "clicked {state}"); + s.apply(again, Edit::XfaToStatic).unwrap(); + assert!(!s.get(again).unwrap().info.warnings.iter().any(|w| w.contains("held a value other than the form's XFA data")), "clicked {state}"); + } + // Data from elsewhere that no button saves is still read as an export value, then a state. + let id = s + .open( + "two.pdf", + None, + Arc::new(pdfcraft_xfa::fixtures::static_radio_shell_with_pcon("2", ["4", "1"])), + None, + ) + .unwrap(); + assert_eq!(value(&s, id), ["1"]); + let id = s + .open( + "zero.pdf", + None, + Arc::new(pdfcraft_xfa::fixtures::static_radio_shell_with_pcon("0", ["4", "1"])), + None, + ) + .unwrap(); + assert_eq!(value(&s, id), ["0"]); +} + /// A radio group whose buttons carry `/PCOn` items `1` `2` next to `/Opt [(1) (2)]` and on /// states named by position (`/0`, `/1`): a click on either button saves that button's own item /// to the datasets, reopening turns the same button on with nothing rewritten, and taking the XFA diff --git a/crates/engine/src/xfa_form.rs b/crates/engine/src/xfa_form.rs index 96ab91e6c6..6b8f918982 100644 --- a/crates/engine/src/xfa_form.rs +++ b/crates/engine/src/xfa_form.rs @@ -120,6 +120,18 @@ pub(crate) fn decode_data_file(bytes: &[u8]) -> Option> { } } +/// The on state a radio group's XFA data `item` selects: the button saving writes `item` for +/// (`/PCOn`, else its `/Opt` entry, else its state; `pdfcraft_xfa::radio_state_for_item`), so a +/// saved selection reopens on the same button; else `item` read as an export value, then as an +/// on-state name ([`pdfcraft_forms::Field::state_for_export`]), as data from elsewhere is; else +/// `item` itself. +fn radio_button(doc: &pdfcraft_cos::Document, f: &pdfcraft_forms::Field, datum: Option<&pdfcraft_xfa::FieldDatum>, item: &str) -> String { + datum + .and_then(|d| pdfcraft_xfa::radio_state_for_item(doc, d, item)) + .or_else(|| f.state_for_export(item).map(str::to_owned)) + .unwrap_or_else(|| item.to_owned()) +} + /// Take the XFA out of `doc` and note what went with it: the packets, data no field showed, /// buttons that did nothing but run scripts, and fields whose value differs from the form's /// data (data PdfCraft could not read into them, now lost). @@ -135,11 +147,12 @@ pub(crate) fn strip_xfa(doc: &mut pdfcraft_cos::Document, cx: &mut EditCtx) -> R pdfcraft_xfa::FieldData::Check(on) => f.value.is_empty() == *on, // The field holds an on-state name; the data names a button by its item. They agree // when the data holds what saving writes (`/PCOn`, the `/Opt` entry or the state - // name), or an export value or state that selects the same button. + // name), or names the same button as opening reads it ([`radio_button`]). pdfcraft_xfa::FieldData::Radio(sel) => { let state = f.value.first().map(String::as_str); - let same_button = sel.as_deref().map(|s| f.state_for_export(s).unwrap_or(s)) == state; - let written = data.iter().find(|d| d.name == *name).map(|d| pdfcraft_xfa::radio_dataset_value(doc, d, &f.value.first().cloned())); + let datum = data.iter().find(|d| d.name == *name); + let same_button = sel.as_deref().map(|s| radio_button(doc, f, datum, s)).as_deref() == state; + let written = datum.map(|d| pdfcraft_xfa::radio_dataset_value(doc, d, &f.value.first().cloned())); !same_button && written.as_deref() != Some(sel.as_deref().unwrap_or_default()) } pdfcraft_xfa::FieldData::None => false, @@ -291,10 +304,11 @@ pub(crate) fn xfa_values_from_datasets(doc: &mut pdfcraft_cos::Document) -> Resu } pdfcraft_xfa::FieldData::Text(t) => (f.value.join("\n") != t).then_some(FieldValue::Text(t)), pdfcraft_xfa::FieldData::Check(on) => (f.value.is_empty() == on).then_some(FieldValue::Check(on)), - // The data holds the item (the export value); the field takes the on state that - // exports it, which with `/Opt` may be a position (`1` is the state `/0`). + // The data holds the item; the field takes the on state of the button saving writes it + // for ([`radio_button`]), which with `/Opt` may be a position (`1` is the state `/0`). pdfcraft_xfa::FieldData::Radio(sel) => { - let state = sel.map(|v| f.state_for_export(&v).map_or(v.clone(), str::to_owned)); + let datum = data.iter().find(|d| d.name == name); + let state = sel.map(|v| radio_button(doc, f, datum, &v)); (f.value.first() != state.as_ref()).then_some(FieldValue::Radio(state)) } pdfcraft_xfa::FieldData::None => None, diff --git a/crates/xfa/src/data.rs b/crates/xfa/src/data.rs index 64b3272e18..d8a636a7ce 100644 --- a/crates/xfa/src/data.rs +++ b/crates/xfa/src/data.rs @@ -36,7 +36,7 @@ pub use import::{DataImport, ImportNode, export_data, import_data, parse_data_fi pub use passwords::{clear_password_values, has_password_values}; pub(crate) use path::template_field; pub use path::{data_path, parse_datasets, som_to_path}; -pub use values::{FieldData, FieldDatum, is_on, radio_dataset_value, read_values, read_values_with}; +pub use values::{FieldData, FieldDatum, is_on, radio_dataset_value, radio_state_for_item, read_values, read_values_with}; #[cfg(test)] pub(crate) use write::ENSURED; pub use write::{ diff --git a/crates/xfa/src/data/values.rs b/crates/xfa/src/data/values.rs index 2fef49e85c..82fd83e80c 100644 --- a/crates/xfa/src/data/values.rs +++ b/crates/xfa/src/data/values.rs @@ -34,16 +34,10 @@ fn som_of(doc: &Document, f: &FieldDatum) -> String { doc.get(f.obj).as_dict().and_then(|d| text_of(doc, d, crate::pdf::SOM_KEY)).unwrap_or_else(|| f.name.clone()) } -/// The datasets text for a radio group. The AcroForm value is an appearance-state name -/// (`New York` is stored as `New_York`); `/PCOn` on the selected widget is the original item. -/// Without it, a group with one `/Opt` entry per widget exports that entry: its state names may -/// be positions (`/0`, `/1`; ISO 32000-2 Table 230), and the data holds the item (`1`, `2`). -/// `sel` is the selected on-state name; with none selected the text is empty. -pub fn radio_dataset_value(doc: &Document, f: &FieldDatum, sel: &Option) -> String { - let Some(selected) = sel.as_deref().filter(|s| !s.is_empty() && *s != "Off") else { - return String::new(); - }; - let Some(group) = doc.get(f.obj).as_dict().cloned() else { return selected.to_string() }; +/// Each button of a radio group, in `/Kids` order: its (`/PCOn` item, on-state name, `/Opt` +/// entry). A group without `/Kids` is its own one widget. +fn radio_widgets(doc: &Document, f: &FieldDatum) -> Vec<(Option, Option, Option)> { + let Some(group) = doc.get(f.obj).as_dict().cloned() else { return Vec::new() }; let kids = group.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_else(|| vec![Object::Ref(f.obj)]); // One export value per widget, as the field's `/Opt` lists them (an entry may be an // `[export display]` pair, as a choice field's are). @@ -60,9 +54,7 @@ pub fn radio_dataset_value(doc: &Document, f: &FieldDatum, sel: &Option) .collect() }) .filter(|opt: &Vec>| opt.len() == kids.len()); - // Each widget's (`/PCOn` item, on-state name, `/Opt` entry). - let widgets: Vec<(Option, Option, Option)> = kids - .iter() + kids.iter() .enumerate() .filter_map(|(i, kid)| { let widget = doc.resolve(kid).as_dict().cloned()?; @@ -76,7 +68,20 @@ pub fn radio_dataset_value(doc: &Document, f: &FieldDatum, sel: &Option) let export = exports.as_ref().and_then(|e| e.get(i).cloned().flatten()); Some((original, state, export)) }) - .collect(); + .collect() +} + +/// The datasets text for a radio group. The AcroForm value is an appearance-state name +/// (`New York` is stored as `New_York`); `/PCOn` on the selected widget is the original item. +/// Without it, a group with one `/Opt` entry per widget exports that entry: its state names may +/// be positions (`/0`, `/1`; ISO 32000-2 Table 230), and the data holds the item (`1`, `2`). +/// `sel` is the selected on-state name; with none selected the text is empty. +/// [`radio_state_for_item`] reads it back. +pub fn radio_dataset_value(doc: &Document, f: &FieldDatum, sel: &Option) -> String { + let Some(selected) = sel.as_deref().filter(|s| !s.is_empty() && *s != "Off") else { + return String::new(); + }; + let widgets = radio_widgets(doc, f); // `sel` is an on-state name, so every widget's state is compared before any `/PCOn`: with // states `/0` `/1` and items `1` `2`, the state `1` is the second widget, not the first // one's item. @@ -88,6 +93,19 @@ pub fn radio_dataset_value(doc: &Document, f: &FieldDatum, sel: &Option) } } +/// The on-state name of the radio button whose item is `item` in the datasets, as +/// [`radio_dataset_value`] writes it: the first button whose `/PCOn`, else its `/Opt` entry, +/// else its on-state name, equals `item`. So a saved selection reopens on the same button even +/// when one button's `/PCOn` equals another's `/Opt` entry. `None` when no button writes `item` +/// (data from elsewhere); the caller then reads it as an export value or state name. +pub fn radio_state_for_item(doc: &Document, f: &FieldDatum, item: &str) -> Option { + radio_widgets(doc, f).into_iter().find_map(|(original, state, export)| { + let state = state?; + let written = original.or(export).unwrap_or_else(|| state.clone()); + (written == item).then_some(state) + }) +} + /// The on and off item values of a check box (`/PCItems`), or the widget's on state. fn items_of(doc: &Document, f: &FieldDatum) -> (String, String) { let d = doc.get(f.obj); diff --git a/crates/xfa/src/lib.rs b/crates/xfa/src/lib.rs index 28e8dd09e7..efdc6d8808 100644 --- a/crates/xfa/src/lib.rs +++ b/crates/xfa/src/lib.rs @@ -93,8 +93,8 @@ pub(crate) fn xml_nesting_depth(text: &str) -> usize { pub use data::{DataImport, ImportNode, export_data, import_data, parse_data_file, write_data_image}; pub use data::{ DataNode, DataOp, DataPath, DatasetsWrite, FieldData, FieldDatum, add_data_instances, build_data, clear_password_values, data_path, - has_password_values, iso_to_pattern, parse_datasets, pattern_to_iso, radio_dataset_value, read_values, read_values_with, remove_data_instance, - som_to_path, write_data_ops, write_data_value, write_datasets, write_datasets_reusing, write_datasets_with, + has_password_values, iso_to_pattern, parse_datasets, pattern_to_iso, radio_dataset_value, radio_state_for_item, read_values, read_values_with, + remove_data_instance, som_to_path, write_data_ops, write_data_value, write_datasets, write_datasets_reusing, write_datasets_with, }; pub use layout::{Action, BorderShape, Form, Item, MAX_PAGES, Page, Widget, WidgetKind, formatted_value, layout}; pub use packets::{Encoding, Packets, decode as decode_packet, encode as encode_packet, read_packets}; From 0e70564cecdf3278a9a39944d569e4369f993a2c Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Mon, 2 Nov 2026 08:18:59 +0800 Subject: [PATCH 07/11] M6: radio states are spelled as the AcroForm reads them, # and non-UTF-8 bytes too radio_widgets read each button's on-state name with from_utf8_lossy, while the group's value it is compared with comes from pdfcraft_forms, which writes '#' as #23 and bytes that are not UTF-8 as #XX (name_text). A state /A#23B (the bytes A#B) or a Shift-JIS /#82#CD then matched no button: saving wrote the state's text as the item instead of the button's /Opt entry, and opening returned a state no button has. The states are now spelled with the same rules (a copy of name_text: this crate can't depend on pdfcraft-forms). Also pins that taking the XFA out notes no lost data when the data names the shown button by its /Opt entry while saving would write its /PCOn. Review on #4784 (CodeRabbit, crates/xfa/src/data/values.rs and crates/engine/src/xfa_form.rs). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv --- crates/engine/src/tests.rs | 8 ++++++++ crates/xfa/src/data.rs | 2 ++ crates/xfa/src/data/values.rs | 15 ++++++++++++++- crates/xfa/src/tests.rs | 25 +++++++++++++++++++++++++ 4 files changed, 49 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index fb5cba9d8f..0759685ff6 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -6168,6 +6168,14 @@ fn stripping_xfa_takes_a_radio_items_pcon_as_the_fields_value() { s.apply(id, Edit::SetFieldValue { name: lang.into(), value: FieldValue::Radio(Some("0".into())) }).unwrap(); s.apply(id, Edit::XfaToStatic).unwrap(); assert_eq!(lost(&s, id), Vec::::new()); + // The data names a button by its `/Opt` entry (`2`, as data from elsewhere does), while + // saving would write that button's `/PCOn` (`fr`). Opening turned that button on, so the + // field shows what the data selects and nothing is lost, although the spellings differ. + let form = pdfcraft_xfa::fixtures::static_radio_shell_with_pcon("2", ["en", "fr"]); + let id = s.open("opt-item.pdf", None, Arc::new(form), None).unwrap(); + assert_eq!(s.get(id).unwrap().form.iter().find(|f| f.name == lang).unwrap().value, ["1"]); + s.apply(id, Edit::XfaToStatic).unwrap(); + assert_eq!(lost(&s, id), Vec::::new()); } /// Opening reads a radio group's data as saving writes it: with `/PCOn` items `4` `1` next to diff --git a/crates/xfa/src/data.rs b/crates/xfa/src/data.rs index d8a636a7ce..1455586383 100644 --- a/crates/xfa/src/data.rs +++ b/crates/xfa/src/data.rs @@ -36,6 +36,8 @@ pub use import::{DataImport, ImportNode, export_data, import_data, parse_data_fi pub use passwords::{clear_password_values, has_password_values}; pub(crate) use path::template_field; pub use path::{data_path, parse_datasets, som_to_path}; +#[cfg(test)] +pub(crate) use values::name_text; pub use values::{FieldData, FieldDatum, is_on, radio_dataset_value, radio_state_for_item, read_values, read_values_with}; #[cfg(test)] pub(crate) use write::ENSURED; diff --git a/crates/xfa/src/data/values.rs b/crates/xfa/src/data/values.rs index 82fd83e80c..294b407540 100644 --- a/crates/xfa/src/data/values.rs +++ b/crates/xfa/src/data/values.rs @@ -64,13 +64,26 @@ fn radio_widgets(doc: &Document, f: &FieldDatum) -> Vec<(Option, Option< .map(|ap| doc.resolve(ap)) .and_then(|ap| ap.as_dict().and_then(|a| a.get(b"N").cloned())) .and_then(|n| doc.resolve(&n).as_dict().cloned()) - .and_then(|n| n.iter().map(|(k, _)| String::from_utf8_lossy(k).into_owned()).find(|k| k != "Off")); + .and_then(|n| n.iter().map(|(k, _)| name_text(k)).find(|k| k != "Off")); let export = exports.as_ref().and_then(|e| e.get(i).cloned().flatten()); Some((original, state, export)) }) .collect() } +/// An on-state name's bytes in the spelling a radio group's AcroForm value has, so the two can +/// be compared: UTF-8 names read as themselves, other bytes (Shift-JIS states) and `#` are +/// written `#XX`, as in PDF name syntax. This is `pdfcraft_forms::name_text`, which this crate +/// can't depend on; the value saving gets is read through it, and the state opening returns is +/// written back through its inverse, `pdfcraft_forms::name_bytes`. +pub(crate) fn name_text(bytes: &[u8]) -> String { + let escape = |b: u8| format!("#{b:02X}"); + match std::str::from_utf8(bytes) { + Ok(s) => s.replace('#', "#23"), + Err(_) => bytes.iter().map(|&b| if (0x21..=0x7e).contains(&b) && b != b'#' { char::from(b).to_string() } else { escape(b) }).collect(), + } +} + /// The datasets text for a radio group. The AcroForm value is an appearance-state name /// (`New York` is stored as `New_York`); `/PCOn` on the selected widget is the original item. /// Without it, a group with one `/Opt` entry per widget exports that entry: its state names may diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index 829bb76853..0a928281ed 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -4163,3 +4163,28 @@ fn a_radio_state_is_matched_before_any_pcon_item() { assert_eq!(write("9"), "9"); assert_eq!(crate::data::radio_dataset_value(&doc, &group, &None), ""); } + +/// A radio group's value is an on-state name as the AcroForm reads it: `#` and bytes that are +/// not UTF-8 (a Shift-JIS state) written `#XX`. Saving and opening spell the buttons' states the +/// same way, so a state `/A#23B` (the bytes `A#B`) or `/#82#CD` still finds its button and its +/// `/Opt` item, both ways. Read lossily, neither state matched: saving wrote the state's text as +/// the item, and opening returned a state no button has. +#[test] +fn radio_states_with_hash_or_non_utf8_bytes_find_their_buttons() { + let pdf = String::from_utf8(crate::fixtures::static_radio_shell("")) + .unwrap() + .replace("/N << /0 ", "/N << /A#23B ") + .replace("/N << /1 ", "/N << /#82#CD "); + let doc = Document::open(Arc::new(pdf.into_bytes())).unwrap(); + let group = FieldDatum { obj: pdfcraft_cos::ObjRef::new(8, 0), name: "form1[0].page1[0].lang[0]".into(), data: FieldData::None }; + let write = |sel: &str| crate::data::radio_dataset_value(&doc, &group, &Some(sel.to_string())); + assert_eq!(write("A#23B"), "1", "the first button writes its /Opt item"); + assert_eq!(write("#82#CD"), "2", "the second button writes its /Opt item"); + assert_eq!(crate::data::radio_state_for_item(&doc, &group, "1").as_deref(), Some("A#23B")); + assert_eq!(crate::data::radio_state_for_item(&doc, &group, "2").as_deref(), Some("#82#CD")); + // The spelling is the forms crate's `name_text`. + for (bytes, text) in [(&b"Yes"[..], "Yes"), ("はい".as_bytes(), "はい"), (b"A#1", "A#231"), (b"\x82\xcd", "#82#CD"), (b"\xff#\x00", "#FF#23#00")] + { + assert_eq!(crate::data::name_text(bytes), text, "{bytes:?}"); + } +} From 2b74366c64cdfb504d6c52689d54b0343253cf01 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Mon, 2 Nov 2026 10:53:08 +0800 Subject: [PATCH 08/11] M6: the engine checks that the XFA data layer and forms spell state names alike pdfcraft-xfa keeps its own copy of pdfcraft_forms::name_text (it can't depend on forms), and a radio group's value only finds its button while the two agree. xfa_and_forms_spell_names_alike compares them, and checks that forms' name_bytes reads the copy's spelling back, for UTF-8, '#', Shift-JIS and stray bytes. With the copy writing lowercase hex it fails at the Shift-JIS case. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv --- crates/engine/src/tests.rs | 11 +++++++++++ crates/xfa/src/data.rs | 4 +--- crates/xfa/src/data/values.rs | 3 ++- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 0759685ff6..111696b731 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -6146,6 +6146,17 @@ fn a_static_xfa_radio_group_with_numeric_items_keeps_the_picked_button() { } } +/// The XFA data layer keeps its own copy of the forms crate's name spelling (it can't depend on +/// forms), and a radio group's value only finds its button while the two agree: UTF-8 as +/// itself, `#` and bytes that aren't UTF-8 as `#XX`. +#[test] +fn xfa_and_forms_spell_names_alike() { + for bytes in [&b"Yes"[..], b"", b"a b", b"A#1", "はい".as_bytes(), b"\x82\xcd\x82\xa2", b"\xff#\x00", b"\x7f\x20~"] { + assert_eq!(pdfcraft_xfa::data::name_text(bytes), pdfcraft_forms::name_text(bytes), "{bytes:?}"); + assert_eq!(pdfcraft_forms::name_bytes(&pdfcraft_xfa::data::name_text(bytes)), bytes, "{bytes:?}"); + } +} + /// Taking the XFA out of a radio group whose buttons carry `/PCOn` as well as `/Opt` (the item /// saving writes ahead of the `/Opt` entry) notes no lost data when the data holds that item: /// one equal to the on state, and one equal to neither the state nor the `/Opt` entry. diff --git a/crates/xfa/src/data.rs b/crates/xfa/src/data.rs index 1455586383..ba17c65ca6 100644 --- a/crates/xfa/src/data.rs +++ b/crates/xfa/src/data.rs @@ -36,9 +36,7 @@ pub use import::{DataImport, ImportNode, export_data, import_data, parse_data_fi pub use passwords::{clear_password_values, has_password_values}; pub(crate) use path::template_field; pub use path::{data_path, parse_datasets, som_to_path}; -#[cfg(test)] -pub(crate) use values::name_text; -pub use values::{FieldData, FieldDatum, is_on, radio_dataset_value, radio_state_for_item, read_values, read_values_with}; +pub use values::{FieldData, FieldDatum, is_on, name_text, radio_dataset_value, radio_state_for_item, read_values, read_values_with}; #[cfg(test)] pub(crate) use write::ENSURED; pub use write::{ diff --git a/crates/xfa/src/data/values.rs b/crates/xfa/src/data/values.rs index 294b407540..3fb8b3ca97 100644 --- a/crates/xfa/src/data/values.rs +++ b/crates/xfa/src/data/values.rs @@ -76,7 +76,8 @@ fn radio_widgets(doc: &Document, f: &FieldDatum) -> Vec<(Option, Option< /// written `#XX`, as in PDF name syntax. This is `pdfcraft_forms::name_text`, which this crate /// can't depend on; the value saving gets is read through it, and the state opening returns is /// written back through its inverse, `pdfcraft_forms::name_bytes`. -pub(crate) fn name_text(bytes: &[u8]) -> String { +/// The engine checks that the two spell names alike (`xfa_and_forms_spell_names_alike`). +pub fn name_text(bytes: &[u8]) -> String { let escape = |b: u8| format!("#{b:02X}"); match std::str::from_utf8(bytes) { Ok(s) => s.replace('#', "#23"), From 9e51a1f4a6a9ae4efbbafab0a6eb3328371d681c Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 5 Nov 2026 20:58:42 +0800 Subject: [PATCH 09/11] 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 { From 928c082e6b442c836901f4fe3156c0b0952af274 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Sat, 7 Nov 2026 09:28:03 +0800 Subject: [PATCH 10/11] 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()?; From f6021e3441b6561d02db1c1e42b2f1d65e26e832 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Sat, 7 Nov 2026 09:57:49 +0800 Subject: [PATCH 11/11] M6: re-run CI (fill_sign Escape test failed once, passes 20 of 20 locally) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn