diff --git a/crates/automation/src/forms.rs b/crates/automation/src/forms.rs index 15c8975dd6..fba6d40b6f 100644 --- a/crates/automation/src/forms.rs +++ b/crates/automation/src/forms.rs @@ -208,6 +208,24 @@ 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)); @@ -224,7 +242,24 @@ fn value_for(f: &FormField, v: &Value) -> Result { FieldValue::Widget(i) } (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. 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`). + // 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)) => { + 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/src/tools.rs b/crates/automation/src/tools.rs index 7ac8e69f3e..5096de9f3d 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -514,7 +514,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; {\"widget\": n} turns on button n (1-based) of a check box or radio group and never turns it off (to clear, use null for a radio group that allows it, false for a check box); of radio buttons sharing an option only that one is on unless the group has RadiosInUnison. Appearances are regenerated so every viewer shows the values. Thai the field's font can't hold is drawn from PdfCraft's built-in Thai face (a build without one refuses it). Any other 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; {\"widget\": n} turns on button n (1-based) of a check box or radio group and never turns it off (to clear, use null for a radio group that allows it, false for a check box); of radio buttons sharing an option only that one is on unless the group has RadiosInUnison. 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. Thai the field's font can't hold is drawn from PdfCraft's built-in Thai face (a build without one refuses it). Any other 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( diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 0cce08a1fe..d6c52f47fa 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -3066,6 +3066,166 @@ 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}"); + } + // 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"); +} + +/// 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"); + } +} + +/// 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}"); +} + +/// 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"); + } +} + /// `form_fill` with {"widget": n} turns on that radio button alone when others share its on /// state (ISO 32000-2 Table 229, RadiosInUnison clear), and `form_fields` says which is on. #[test] @@ -3091,6 +3251,7 @@ fn form_fill_picks_one_of_radio_buttons_sharing_an_on_state() { assert!(matches!(a.call("form_fill", &json!({ "doc": doc, "values": { "pick": bad } })), Err(ToolError::InvalidArgs(_))), "{bad}"); } } + /// 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 cb7871fcf8..aa3505d4b9 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -7563,6 +7563,160 @@ 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}"); + } +} + +/// 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. +#[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()); + // 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 +/// `/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 +/// 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. diff --git a/crates/engine/src/xfa_form.rs b/crates/engine/src/xfa_form.rs index 10036db285..5739c57160 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). @@ -133,7 +145,16 @@ 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 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 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 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, } }) @@ -293,7 +314,13 @@ 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 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 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, }; if let Some(v) = new { diff --git a/crates/forms/src/lib.rs b/crates/forms/src/lib.rs index 59d040f3df..5f459738d0 100644 --- a/crates/forms/src/lib.rs +++ b/crates/forms/src/lib.rs @@ -214,6 +214,37 @@ 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`. 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)?; + self.widgets.get(i)?.on_state.as_deref() + }); + 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`, 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> { + 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()?; + 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 { diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index 0f3189cd2c..2e66f7ffda 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -497,6 +497,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.rs b/crates/xfa/src/data.rs index 09c8252fa8..4c51ee2d95 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 use path::{data_path, parse_datasets, som_to_path}; pub(crate) use path::{fields_with_ids, template_field}; -pub use values::{FieldData, FieldDatum, is_on, 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 8042e774fe..3fb8b3ca97 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, @@ -33,28 +34,90 @@ 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()) } +/// 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). + 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()); + 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, _)| 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`. +/// 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"), + 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. -fn radio_dataset_value(doc: &Document, f: &FieldDatum, sel: &Option) -> String { +/// 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 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 }; - 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) { - return original.unwrap_or_else(|| selected.to_string()); - } + 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. + 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-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. diff --git a/crates/xfa/src/fixtures.rs b/crates/xfa/src/fixtures.rs index 02df5030bd..8acd193ae3 100644 --- a/crates/xfa/src/fixtures.rs +++ b/crates/xfa/src/fixtures.rs @@ -144,6 +144,43 @@ 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 { + 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}" + ); + 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(), + 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) +} + /// 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/crates/xfa/src/lib.rs b/crates/xfa/src/lib.rs index 7954b5001e..e815aea194 100644 --- a/crates/xfa/src/lib.rs +++ b/crates/xfa/src/lib.rs @@ -95,8 +95,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, 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}; diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index b018dc8aec..fe95d4cae1 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -5153,6 +5153,49 @@ fn clear_password_values_clears_static_xfa_password_fields() { 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), ""); +} + +/// 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:?}"); + } +} + /// The pages of `form[0].last[0].f[0]` and of the field `after` that follows `last`, when `last` /// holds `before` ahead of `f` and `after` behind it. A breakAfter acts after its subform, so the /// page of `after` shows whether it broke. diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index b40be19d27..770e1e912c 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -4650,8 +4650,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", "crates/forms/src/tests.rs::radio_buttons_sharing_an_on_state_are_exclusive_unless_in_unison", "crates/ui-egui/tests/it/forms.rs::clicking_a_radio_button_that_shares_its_on_state", "crates/ui-egui/tests/it/forms.rs::a_read_only_radio_button_is_focused_but_not_selected", "crates/automation/tests/it/automation.rs::form_fill_picks_one_of_radio_buttons_sharing_an_on_state"] -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. Radio buttons sharing an on state follow RadiosInUnison (ISO 32000-2 Table 229): with it they turn on together, without it only the clicked one does (its /AS; /V names the shared state), which survives a save; form_fill takes {\"widget\": n} for the same and form_fields lists on_widgets. Without NoToggleToOff a second click deselects the button. Not yet: telling such buttons apart in exported form data (FDF/XFDF carry /V only)" +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", "crates/forms/src/tests.rs::radio_buttons_sharing_an_on_state_are_exclusive_unless_in_unison", "crates/ui-egui/tests/it/forms.rs::clicking_a_radio_button_that_shares_its_on_state", "crates/ui-egui/tests/it/forms.rs::a_read_only_radio_button_is_focused_but_not_selected", "crates/automation/tests/it/automation.rs::form_fill_picks_one_of_radio_buttons_sharing_an_on_state"] +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. Radio buttons sharing an on state follow RadiosInUnison (ISO 32000-2 Table 229): with it they turn on together, without it only the clicked one does (its /AS; /V names the shared state), which survives a save; form_fill takes {\"widget\": n} for the same and form_fields lists on_widgets. Without NoToggleToOff a second click deselects the button. Not yet: telling such buttons apart in exported form data (FDF/XFDF carry /V only)" [[feature]] id = "form.fill-choice"