From eee81da38bb47007f15977b38a52607d23bb9b97 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Sun, 8 Nov 2026 11:21:59 +0800 Subject: [PATCH] M6: a radio choice by the name is refused when any radio field of it is read-only, a claimed widget is read once, and inspection lists widgets beside named kids (form.field-list) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fill: the first same-named field's read-only flag still applies when a later one draws the choice, and no radio field of the name is written when any of them is read-only. - enumerate: a nameless /Fields widget its parent already claimed is not read again as a nameless field; one its parent's /Kids omits is still read on its own. - walk: only kids whose /Subtype is /Widget replace a merged field's own widget. - inspection: a kid whose /Parent is another field is named from that chain even under a direct listing field, and widget annotations beside named kids are listed as a field of the parent's name (ISO 32000-2 §12.7.4), as the form reader lists them. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA --- crates/forms/src/read.rs | 19 ++-- crates/forms/src/tests.rs | 108 +++++++++++++++++++++++ crates/forms/src/values.rs | 5 +- crates/render/src/inspect/annotations.rs | 28 ++++-- crates/render/src/inspect/tests.rs | 48 ++++++++++ 5 files changed, 194 insertions(+), 14 deletions(-) diff --git a/crates/forms/src/read.rs b/crates/forms/src/read.rs index faca4140c3..4137a6f4d0 100644 --- a/crates/forms/src/read.rs +++ b/crates/forms/src/read.rs @@ -393,10 +393,19 @@ pub(crate) fn enumerate(doc: &Document) -> (Vec, usize) { for f in af.get(b"Fields").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default() { if let Some(r) = f.as_ref() { // A `/Fields` entry that is a nameless kid of a field `/Fields` doesn't list is - // read through that field, which names it (ISO 32000-2 §12.7.4.2). + // read through that field, which names it (ISO 32000-2 §12.7.4.2). A kid a field + // already read has claimed as its widget is not read again as a nameless field; + // one its parent's `/Kids` omits is still read on its own, as before. let nameless_kid = doc.get(r).as_dict().is_some_and(|d| !d.contains(b"T") && d.contains(b"Parent")); - let start = if nameless_kid { unlisted_root(doc, r, &seen.fields).unwrap_or(r) } else { r }; - walk(doc, start, &base, &page_of, &mut seen, &mut out, 0); + if nameless_kid { + if let Some(root) = unlisted_root(doc, r, &seen.fields) { + walk(doc, root, &base, &page_of, &mut seen, &mut out, 0); + } + if seen.widgets.contains(&r) { + continue; + } + } + walk(doc, r, &base, &page_of, &mut seen, &mut out, 0); } } } @@ -557,10 +566,10 @@ fn walk( _ => return, }; // A field's widgets are its widget kids. The field dictionary is its own widget only when it - // has none: field and widget are merged "when a field has only a single associated widget + // has none (no kid whose `/Subtype` is `/Widget`): field and widget are merged "when a field has only a single associated widget // annotation" (ISO 32000-2 §12.7.4), so a merged dictionary that also lists widget kids is a // field whose buttons are those kids, not one more (often zero-area) button. - let has_widget_kids = kids.iter().any(|k| doc.get(*k).as_dict().is_some()); + let has_widget_kids = kids.iter().any(|k| doc.get(*k).as_dict().is_some_and(|kd| kd.name(b"Subtype") == Some(b"Widget"))); let widget_refs: Vec = if d.contains(b"Rect") && !has_widget_kids { vec![r] } else { kids }; let widget_refs: Vec = widget_refs.into_iter().filter(|w| seen.widgets.insert(*w)).collect(); let actions = actions_of(doc, d, &widget_refs, &mut seen.scripts); diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index 3566e86dfe..cea2845faa 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -2476,6 +2476,114 @@ fn nameless_widgets_of_an_unlisted_field_are_options_of_its_name() { } } +/// A kid with `/T` that a field's `/Kids` lists but whose `/Parent` names a different field is +/// named up that field's chain (ISO 32000-2 §12.7.4.2), not under the field listing it. +#[test] +fn a_named_kid_whose_parent_is_another_field_is_named_under_it() { + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R] >> >>".into(), // 1 + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), // 2 + "<< /Type /Page /Parent 2 0 R /Annots [5 0 R] >>".into(), // 3 + "<< /FT /Tx /T (Listing) /Kids [5 0 R] >>".into(), // 4 + "<< /Type /Annot /Subtype /Widget /T (k) /Parent 6 0 R /Rect [50 700 150 715] /P 3 0 R >>".into(), // 5 + "<< /T (Other) /Kids [5 0 R] >>".into(), // 6 unlisted + ]; + let names: Vec = fields(&document(&objs)).into_iter().map(|f| f.name).collect(); + assert_eq!(names, ["Other.k"]); +} + +/// The two kids of [`radio_kids_with_names`] (`Group1`, no `/Parent`), with the one drawing +/// `Choice2` (6, read first) or the one drawing `Choice1` (5) read-only. +fn radio_kids_one_read_only(read_only: u32) -> Document { + let kid = |n: u32, state: &str, x: u32| { + let ff = if n == read_only { 49153 } else { 49152 }; + format!( + "<< /Type /Annot /Subtype /Widget /FT /Btn /Ff {ff} /T (Group1) /V /Off /AS /Off /Rect [{x} 700 {} 715] /P 3 0 R /AP << /N << /Off 7 0 R /{state} 7 0 R >> >> >>", + x + 15 + ) + }; + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [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 [5 0 R 6 0 R] >>".into(), + "<< /FT /Btn /Ff 49152 /T (Group1) /Kids [6 0 R 5 0 R] >>".into(), + kid(5, "Choice1", 50), + kid(6, "Choice2", 100), + "<< /Length 0 >> +stream + +endstream" + .into(), + ]; + document(&objs) +} + +/// A radio choice by the name reaches every radio field of that name (ISO 32000-2 §12.7.4.2), so +/// it is refused when any of them is read-only: the first one, even when a later one draws the +/// choice, or a later one, even when the first draws it. Nothing is written. +#[test] +fn a_radio_choice_by_the_name_is_refused_when_a_field_of_the_name_is_read_only() { + for (read_only, choice) in [(6, "Choice1"), (5, "Choice2")] { + let mut doc = radio_kids_one_read_only(read_only); + let state = |doc: &Document| { + [4, 5, 6].map(|n| { + doc.get(ObjRef { num: n, generation: 0 }).as_dict().map(|d| (d.name(b"V").map(<[u8]>::to_vec), d.name(b"AS").map(<[u8]>::to_vec))) + }) + }; + let before = state(&doc); + let err = set_value(&mut doc, "Group1", &FieldValue::Radio(Some(choice.into()))).unwrap_err(); + assert!(matches!(err, FormError::ReadOnly(_)), "{read_only} read-only, {choice}: {err}"); + assert_eq!(state(&doc), before, "{read_only} read-only, {choice}: nothing written"); + } +} + +/// A nameless widget listed in `/Fields` that the field before it already claimed is not read a +/// second time as a nameless field with no buttons. +#[test] +fn a_listed_widget_its_parent_claimed_is_read_once() { + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 5 0 R] >> >>".into(), // 1 + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), // 2 + "<< /Type /Page /Parent 2 0 R /Annots [5 0 R] >>".into(), // 3 + "<< /FT /Tx /T (p) /Kids [5 0 R] >>".into(), // 4 + "<< /Type /Annot /Subtype /Widget /FT /Tx /Parent 4 0 R /Rect [50 700 150 715] /P 3 0 R >>".into(), // 5 + ]; + let all = fields(&document(&objs)); + assert_eq!(all.iter().map(|f| (f.name.as_str(), f.widgets.len())).collect::>(), [("p", 1)]); +} + +/// A nameless widget listed in `/Fields` whose `/Parent` doesn't list it in its `/Kids` is still +/// read, as it was before such widgets were read through their parent. +#[test] +fn a_listed_widget_its_parent_omits_is_still_read() { + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 0 R] >> >>".into(), // 1 + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), // 2 + "<< /Type /Page /Parent 2 0 R /Annots [5 0 R] >>".into(), // 3 + "<< /FT /Tx /T (p) /Kids [] >>".into(), // 4 unlisted + "<< /Type /Annot /Subtype /Widget /FT /Tx /Parent 4 0 R /Rect [50 700 150 715] /P 3 0 R >>".into(), // 5 + ]; + let all = fields(&document(&objs)); + let widgets: Vec = all.iter().flat_map(|f| &f.widgets).map(|w| w.obj.num).collect(); + assert_eq!(widgets, [5]); +} + +/// A field merged with its widget whose `/Kids` holds a dictionary that isn't a widget keeps +/// itself as its one widget: only widget kids replace it (ISO 32000-2 §12.7.4). +#[test] +fn a_merged_field_with_a_kid_that_is_not_a_widget_keeps_itself_as_widget() { + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R] >> >>".into(), // 1 + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), // 2 + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>".into(), // 3 + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (t) /Rect [50 700 150 715] /P 3 0 R /Kids [5 0 R] >>".into(), // 4 + "<< /Foo 1 >>".into(), // 5 + ]; + let all = fields(&document(&objs)); + let widgets: Vec<(u32, [f64; 4])> = all.iter().flat_map(|f| &f.widgets).map(|w| (w.obj.num, w.rect)).collect(); + assert_eq!(widgets, [(4, [50.0, 700.0, 150.0, 715.0])]); +} + #[test] fn locked_fields_only_take_unlocking() { let mut doc = one_page(); diff --git a/crates/forms/src/values.rs b/crates/forms/src/values.rs index 7e42f9bf8d..94c637fd52 100644 --- a/crates/forms/src/values.rs +++ b/crates/forms/src/values.rs @@ -102,7 +102,10 @@ fn fill(doc: &mut Document, all: &[Field], named: &[usize], name: &str, value: & Some(c) if first.kind == FieldKind::Radio => same.find(|t| t.kind == FieldKind::Radio && t.state_for(c).is_some()).unwrap_or(first), _ => first, }; - if f.read_only() { + // The name is read-only when the first field of it is, whichever field draws the choice; and + // a radio choice reaches every radio field of the name, so none of them may be read-only. + let radio_locked = f.kind == FieldKind::Radio && named.iter().filter_map(|&i| all.get(i)).any(|t| t.kind == FieldKind::Radio && t.read_only()); + if f.read_only() || first.read_only() || radio_locked { return Err(FormError::ReadOnly(name.into())); } write_value(doc, f, value, scripts)?; diff --git a/crates/render/src/inspect/annotations.rs b/crates/render/src/inspect/annotations.rs index d8b756aa23..6004e43c2a 100644 --- a/crates/render/src/inspect/annotations.rs +++ b/crates/render/src/inspect/annotations.rs @@ -360,9 +360,9 @@ impl<'a> Inspector<'a> { let ft = self.name(d, b"FT").or(parent_ft); let ff = d.get(b"Ff").ok().and_then(|x| x.as_i64().ok()).or(parent_ff); // Non-terminal: has kids that are fields (they carry /T). Widgets-only kids are terminal. - let kids = match d.get(b"Kids").map(|k| self.resolve(k)) { - Ok(Object::Array(k)) => k.as_slice(), - _ => &[], + let mut kids: Vec = match d.get(b"Kids").map(|k| self.resolve(k)) { + Ok(Object::Array(k)) => k.clone(), + _ => Vec::new(), }; let field_kids: Vec<_> = kids.iter().filter(|k| self.dict(k).is_some_and(|kd| kd.get(b"T").is_ok())).collect(); if !field_kids.is_empty() { @@ -378,14 +378,20 @@ impl<'a> Inspector<'a> { Some(Ok(Object::Reference(p))) => Some(*p), _ => None, }; - if here.is_none() || parent == here { + if parent == here { self.field(k, Some((&full, ft.clone(), ff)), out, seen, depth + 1); } else { let own = parent.map(|p| self.qualified_name(p)).unwrap_or_default(); self.field(k, Some((&own, ft.clone(), ff)), out, seen, depth + 1); } } - return; + // "A field's children in the hierarchy may also include widget annotations" (ISO + // 32000-2 §12.7.4): widget annotations listed beside named kids are still this + // field's widgets, listed as a field of this name, as the form reader reads them. + kids.retain(|k| self.dict(k).is_some_and(|kd| kd.get(b"T").is_err() && self.name(kd, b"Subtype").as_deref() == Some("Widget"))); + if kids.is_empty() { + return; + } } let flags = ff.unwrap_or(0); let kind = match ft.as_deref() { @@ -413,9 +419,15 @@ impl<'a> Inspector<'a> { Ok(Object::Dictionary(_)) if kind == FieldKind::Signature => Some("signed".into()), _ => None, }; - // The widget is either this dict (merged field/widget) or its first kid. - let widget = if d.get(b"Rect").is_ok() { Some(d) } else { kids.first().and_then(|k| self.dict(k)) }; - let page = widget.and_then(|w| w.get(b"P").ok()).and_then(|p| self.page_of(p)).or_else(|| self.find_widget_page(o, kids)); + // The widget is this dict (merged field/widget) or its first kid. A merged dict that also + // lists widget kids has them as its widgets (ISO 32000-2 §12.7.4), as the form reader has it. + let widget_kid = kids.iter().filter_map(|k| self.dict(k)).find(|kd| self.name(kd, b"Subtype").as_deref() == Some("Widget")); + let widget = match widget_kid { + Some(w) => Some(w), + None if d.get(b"Rect").is_ok() => Some(d), + None => kids.first().and_then(|k| self.dict(k)), + }; + let page = widget.and_then(|w| w.get(b"P").ok()).and_then(|p| self.page_of(p)).or_else(|| self.find_widget_page(o, &kids)); let has_actions = d.get(b"AA").is_ok() || widget.is_some_and(|w| w.get(b"AA").is_ok() || w.get(b"A").is_ok()); let rect = widget.and_then(|w| w.get(b"Rect").ok()).map(|r| rect4(self.resolve(r))); out.push(Field { name: full, kind, value, page, tooltip: self.text(d, b"TU"), has_actions, rect }); diff --git a/crates/render/src/inspect/tests.rs b/crates/render/src/inspect/tests.rs index 03357a34fe..4088eec98b 100644 --- a/crates/render/src/inspect/tests.rs +++ b/crates/render/src/inspect/tests.rs @@ -1935,3 +1935,51 @@ trailer << /Root 1 0 R >> assert_eq!(names(pdf("")), [("Group1".into(), FieldKind::Radio), ("Group1".into(), FieldKind::Radio)]); assert_eq!(names(pdf(" /Parent 4 0 R")), [("Group1.Group1".into(), FieldKind::Radio), ("Group1.Group1".into(), FieldKind::Radio)]); } + +/// A kid named from its own `/Parent` chain (ISO 32000-2 §12.7.4.2) when that `/Parent` is a field +/// other than the one listing it: an indirect one, and one listed as a direct dictionary in +/// `/Fields` (which no `/Parent` can name). +#[test] +fn a_named_kid_whose_parent_is_another_field_is_named_under_it() { + let pdf = |listing: &str, fields: &str| { + format!( + "%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [{fields}] >> >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [5 0 R] >> endobj +4 0 obj {listing} endobj +5 0 obj << /Type /Annot /Subtype /Widget /T (k) /Parent 6 0 R /Rect [10 100 30 120] >> endobj +6 0 obj << /T (Other) /Kids [5 0 R] >> endobj +trailer << /Root 1 0 R >> +%%EOF" + ) + .into_bytes() + }; + let names = |bytes: Vec| inspect(Arc::new(bytes), None).expect("opens").fields.iter().map(|f| f.name.clone()).collect::>(); + let listing = "<< /FT /Tx /T (Listing) /Kids [5 0 R] >>"; + assert_eq!(names(pdf(listing, "4 0 R")), ["Other.k"]); + assert_eq!(names(pdf("null", listing)), ["Other.k"], "listed as a direct dictionary"); +} + +/// "A field's children in the hierarchy may also include widget annotations" (ISO 32000-2 +/// §12.7.4): a nameless widget beside a named kid is listed as a field of the parent's name, at +/// the widget's place, as the form reader lists it. +#[test] +fn a_widget_beside_a_named_kid_is_a_field_of_the_parents_name() { + let pdf = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R] >> >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [5 0 R 6 0 R] >> endobj +4 0 obj << /FT /Btn /Ff 49152 /T (2) /Kids [5 0 R 6 0 R] >> endobj +5 0 obj << /Type /Annot /Subtype /Widget /T (2) /Rect [10 100 30 120] >> endobj +6 0 obj << /Type /Annot /Subtype /Widget /Parent 4 0 R /Rect [40 100 60 120] >> endobj +trailer << /Root 1 0 R >> +%%EOF" + .to_vec(); + let info = inspect(Arc::new(pdf), None).expect("opens"); + let got: Vec<_> = info.fields.iter().map(|f| (f.name.clone(), f.kind, f.rect)).collect(); + assert_eq!( + got, + [("2".into(), FieldKind::Radio, Some([10.0, 100.0, 30.0, 120.0])), ("2".into(), FieldKind::Radio, Some([40.0, 100.0, 60.0, 120.0]))] + ); +}