From c4e95d3af3259b05151b4172e0dda740fbca71b6 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <103679419+marcoarnulfo@users.noreply.github.com> Date: Thu, 15 Oct 2026 08:49:17 +0200 Subject: [PATCH] Forms: fields that share a name take the same value (fixes #2214) (#2217) Co-authored-by: Brandon Thomas --- crates/forms/src/lib.rs | 41 ++++++++++++++++++++++++++++++++++ crates/forms/src/tests.rs | 43 ++++++++++++++++++++++++++++++++++++ parity/acrobat-features.toml | 2 ++ 3 files changed, 86 insertions(+) diff --git a/crates/forms/src/lib.rs b/crates/forms/src/lib.rs index 7aa1ae7a5..e023ea209 100644 --- a/crates/forms/src/lib.rs +++ b/crates/forms/src/lib.rs @@ -854,10 +854,51 @@ pub fn set_value_with(doc: &mut Document, name: &str, value: &FieldValue, script return Err(FormError::ReadOnly(name.into())); } write_value(doc, f, value, scripts)?; + // Fields with the same fully qualified name are one field (§12.7.3.1). Writers that repeat a + // name as separate field dictionaries, instead of widgets under one parent, get the value + // the first one took after its scripts, without running them again. + let twins: Vec<&Field> = all.iter().filter(|t| t.name == name && t.obj != f.obj && t.kind == f.kind).collect(); + if !twins.is_empty() { + let taken = fields(doc).into_iter().find(|x| x.obj == f.obj).map(|x| x.value).unwrap_or_default(); + for t in twins { + share_value(doc, f.obj, t, &taken, scripts)?; + } + } recalculate_with(doc, scripts)?; Ok(()) } +/// Give `twin` the value `source` holds: its `/V` (and a choice's `/I`), drawn in the twin's own +/// widgets. +fn share_value(doc: &mut Document, source: ObjRef, twin: &Field, value: &[String], scripts: &mut dyn Scripts) -> Result<(), FormError> { + match twin.kind { + FieldKind::CheckBox => { + let state = twin.widgets.iter().find_map(|w| w.on_state.clone()).unwrap_or_else(|| "Yes".into()); + set_states(doc, twin, (!value.is_empty()).then_some(state.as_str())) + } + FieldKind::Radio => { + let choice = value.first().filter(|c| twin.widgets.iter().any(|w| w.on_state.as_deref() == Some(c.as_str()))); + set_states(doc, twin, choice.map(String::as_str)) + } + FieldKind::Text | FieldKind::Combo | FieldKind::List => { + let src = doc.get(source).as_dict().cloned().unwrap_or_default(); + doc.update_dict(twin.obj, |d| { + for key in [&b"V"[..], b"I"] { + match src.get(key) { + Some(v) => d.set(key.to_vec(), v.clone()), + None => { + d.remove(key); + } + } + } + d.remove(b"RV"); + })?; + redraw(doc, twin, value, scripts) + } + FieldKind::PushButton | FieldKind::Signature => Ok(()), + } +} + /// Run every field's Calculate script in the form's calculation order (`/CO`, then the other /// calculated fields), as Acrobat does after any value changes. Returns how many changed. pub fn recalculate(doc: &mut Document) -> Result { diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index f52440cae..b99ca0c8a 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -1173,6 +1173,49 @@ fn duplicating_a_field_across_pages_shares_its_value() { assert!(duplicate_field(&mut doc.clone(), &name, &[7]).is_err()); } +/// Fields with the same fully qualified name are one field (§12.7.3.1): a parent whose widgets are +/// its kids, as Duplicate Across Pages writes it, and separate field dictionaries that repeat the +/// same `/T`, as other writers do. Filling the name fills every one of them. +#[test] +fn fields_sharing_a_name_take_the_same_value() { + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 5 0 R >>".into(), // 1 + "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 600 800] >>".into(), // 2 + "<< /Type /Page /Parent 2 0 R /Annots [7 0 R 11 0 R] >>".into(), // 3 + "<< /Type /Page /Parent 2 0 R /Annots [8 0 R 9 0 R 12 0 R] >>".into(), // 4 + "<< /Fields [6 0 R 9 0 R 11 0 R 12 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 10 0 R >> >> >>".into(), // 5 + "<< /FT /Tx /T (ClientName) /Kids [7 0 R 8 0 R] >>".into(), // 6 parent + "<< /Type /Annot /Subtype /Widget /Parent 6 0 R /Rect [50 700 250 720] /P 3 0 R >>".into(), // 7 its widget, page 1 + "<< /Type /Annot /Subtype /Widget /Parent 6 0 R /Rect [50 700 250 720] /P 4 0 R >>".into(), // 8 its widget, page 2 + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (ClientName) /Rect [50 600 250 620] /P 4 0 R >>".into(), // 9 same name, own field + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), // 10 + "<< /Type /Annot /Subtype /Widget /FT /Btn /T (Agree) /V /Off /AS /Off /Rect [300 700 315 715] /P 3 0 R /AP << /N << /Yes 13 0 R /Off 14 0 R >> >> >>".into(), // 11 + "<< /Type /Annot /Subtype /Widget /FT /Btn /T (Agree) /V /Off /AS /Off /Rect [300 700 315 715] /P 4 0 R /AP << /N << /Yes 13 0 R /Off 14 0 R >> >> >>".into(), // 12 + "<< /Length 0 >>\nstream\n\nendstream".into(), // 13 + "<< /Length 0 >>\nstream\n\nendstream".into(), // 14 + ]; + let mut doc = document(&objs); + set_value(&mut doc, "ClientName", &FieldValue::Text("Ada Lovelace".into())).unwrap(); + set_value(&mut doc, "Agree", &FieldValue::Check(true)).unwrap(); + let doc = reopen(&doc); + let v = |n: u32| doc.get(ObjRef { num: n, generation: 0 }).as_dict().unwrap().get(b"V").cloned(); + let typed = Some(Object::String(PdfString::text("Ada Lovelace"))); + assert_eq!((v(6), v(9)), (typed.clone(), typed), "the value sits on the parent and on the field that repeats its name"); + assert_eq!((v(7), v(8)), (None, None), "the parent's widgets carry no value of their own"); + let all = fields(&doc); + let named: Vec<&Field> = all.iter().filter(|f| f.name == "ClientName").collect(); + let widgets: Vec<&Widget> = named.iter().flat_map(|f| &f.widgets).collect(); + assert_eq!(widgets.iter().map(|w| w.obj.num).collect::>(), [7, 8, 9]); + for w in widgets { + assert!(ap(&doc, w).contains("Ada Lovelace"), "widget {} shows the value", w.obj.num); + } + let agree: Vec<&Field> = all.iter().filter(|f| f.name == "Agree").collect(); + assert_eq!(agree.len(), 2); + for f in agree { + assert_eq!((f.value.as_slice(), f.widgets[0].state.as_deref()), (&["Yes".to_string()][..], Some("Yes")), "check box {}", f.obj.num); + } +} + #[test] fn locked_fields_only_take_unlocking() { let mut doc = one_page(); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index a25ff43fa..ebf6ef439 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -4421,6 +4421,8 @@ commands = ["form.fields"] tools = ["form_fill", "form_fields"] evidence = ["crates/ui-egui/tests/forms.rs::typing_into_a_text_field_and_tabbing_on", "crates/ui-egui/tests/forms.rs::auto_size_fields_shrink_and_wrap_typed_text_to_fit_their_box", "crates/forms/src/tests.rs::text_fields_get_new_appearances", "crates/engine/src/tests.rs::filling_a_form_shows_saves_and_undoes", "crates/automation/tests/automation.rs::orphan_form_fields_through_tools"] notes = "in-place editor; Enter or clicking away commits, Escape cancels; auto-size (0 Tf) fields shrink, and multiline ones wrap, the text being typed to fit the box, on screen as on the page (the screen measures in the interface font, so its size can be a little smaller than the page's Helvetica fit); fixed sizes are kept and clip, as in Acrobat; no format/keystroke JavaScript yet (M6.4)" +evidence = ["crates/ui-egui/tests/forms.rs::typing_into_a_text_field_and_tabbing_on", "crates/forms/src/tests.rs::text_fields_get_new_appearances", "crates/engine/src/tests.rs::filling_a_form_shows_saves_and_undoes", "crates/automation/tests/automation.rs::orphan_form_fields_through_tools", "crates/forms/src/tests.rs::fields_sharing_a_name_take_the_same_value"] +notes = "in-place editor; Enter or clicking away commits, Escape cancels; no format/keystroke JavaScript yet (M6.4)" [[feature]] id = "form.fill-checkbox-radio"