diff --git a/crates/forms/src/read.rs b/crates/forms/src/read.rs index f693280086..16bdae314d 100644 --- a/crates/forms/src/read.rs +++ b/crates/forms/src/read.rs @@ -338,13 +338,19 @@ pub(crate) fn enumerate(doc: &Document) -> (Vec, usize) { let af = acroform(doc); let mut page_of = std::collections::HashMap::new(); let mut annot_index = std::collections::HashMap::new(); + let mut by_parent: std::collections::HashMap> = std::collections::HashMap::new(); let pages = page_refs(doc); for (i, p) in pages.iter().enumerate() { if let Some(a) = doc.get(*p).as_dict().and_then(|d| d.get(b"Annots").cloned()) { for (k, e) in doc.resolve(&a).as_array().into_iter().flatten().enumerate() { - if let Some(r) = e.as_ref() { - page_of.entry(r).or_insert(i); - annot_index.entry(r).or_insert(k); + if let Some(r) = e.as_ref() + && let std::collections::hash_map::Entry::Vacant(slot) = page_of.entry(r) + { + slot.insert(i); + annot_index.insert(r, k); + if let Some(parent) = widget_parent(doc, r) { + by_parent.entry(parent).or_default().push(r); + } } } } @@ -358,18 +364,24 @@ pub(crate) fn enumerate(doc: &Document) -> (Vec, usize) { None => Inherited::default(), }; let mut out = Vec::new(); + let listed_roots: Vec = af + .as_ref() + .and_then(|af| af.get(b"Fields").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned())) + .unwrap_or_default() + .iter() + .filter_map(Object::as_ref) + .collect(); let mut seen = Seen { fields: std::collections::HashSet::new(), widgets: std::collections::HashSet::new(), + roots: listed_roots.iter().copied().collect(), + path: Vec::new(), + by_parent, scripts: script_budget(), values: DecodeBudget::new(MAX_VALUE_BYTES, 0), }; - if let Some(af) = &af { - 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() { - walk(doc, r, &base, &page_of, &mut seen, &mut out, 0); - } - } + for r in listed_roots { + walk(doc, r, &base, &page_of, &mut seen, &mut out, 0); } let listed = out.len(); adopt_page_fields(doc, &pages, &base, &page_of, &mut seen, &mut out); @@ -440,10 +452,36 @@ struct Seen { fields: std::collections::HashSet, /// A widget belongs to the first field that lists it, however often it is listed. widgets: std::collections::HashSet, + /// The fields `/Fields` lists: root fields, with no ancestors (ISO 32000-2 §12.7.3, Table 224). + roots: std::collections::HashSet, + /// The fields being walked, outermost first, whose `/Kids` are being read. + path: Vec, + /// The page widgets (no `/T`) that name each field as their `/Parent`, in page-annotation order. + by_parent: std::collections::HashMap>, scripts: DecodeBudget, values: DecodeBudget, } +/// The field a page widget `r` names as its `/Parent`, when `r` is a widget and not a field of its +/// own (no `/T`). +fn widget_parent(doc: &Document, r: ObjRef) -> Option { + let obj = doc.get(r); + let d = obj.as_dict()?; + if d.name(b"Subtype") != Some(b"Widget") || d.contains(b"T") { + return None; + } + d.reference(b"Parent") +} + +/// Whether `k`, listed in the `/Kids` of field `r`, is not a child of `r` after all: `r` itself or +/// a field `r` descends from (a loop), or a root field (one `/Fields` lists) that doesn't name `r` +/// as its `/Parent`. Root fields have no ancestors (ISO 32000-2 §12.7.3, Table 224), and a +/// child names its parent (§12.7.4.1, Table 226): only the `/Kids` entry says otherwise, so the +/// field is read as the root it is, and `r` keeps its other kids. +fn not_a_child(doc: &Document, seen: &Seen, r: ObjRef, k: ObjRef) -> bool { + k == r || seen.path.contains(&k) || (seen.roots.contains(&k) && doc.get(k).as_dict().and_then(|d| d.reference(b"Parent")) != Some(r)) +} + /// Read the field at `r` and the fields under it into `out`, with what they inherit from /// `parent`: kids with a name are fields of their own, kids without are this field's widgets. fn walk( @@ -490,11 +528,14 @@ fn walk( let kids: Vec = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_default().iter().filter_map(|k| k.as_ref()).collect(); // Kids with /T are fields; kids without are this field's widgets. - let field_kids: Vec = kids.iter().copied().filter(|k| doc.get(*k).as_dict().is_some_and(|kd| kd.contains(b"T"))).collect(); + let (field_kids, kids): (Vec, Vec) = kids.into_iter().partition(|k| doc.get(*k).as_dict().is_some_and(|kd| kd.contains(b"T"))); + let field_kids: Vec = field_kids.into_iter().filter(|k| !not_a_child(doc, seen, r, *k)).collect(); if !field_kids.is_empty() { + seen.path.push(r); for k in field_kids { walk(doc, k, &inh, page_of, seen, out, depth + 1); } + seen.path.pop(); return; } // A field of no kind this reads is skipped before it claims widgets or scripts, which a later @@ -510,7 +551,10 @@ fn walk( Some(b"Sig") => FieldKind::Signature, _ => return, }; - let widget_refs: Vec = if d.contains(b"Rect") { vec![r] } else { kids }; + // A child names its parent (ISO 32000-2 §12.7.4.1, Table 226): a page widget naming this field + // is one of its widgets even when its `/Kids` leave it out, as MuPDF and PDFium read it. + let widget_refs: Vec = + if d.contains(b"Rect") { vec![r] } else { kids.into_iter().chain(seen.by_parent.get(&r).into_iter().flatten().copied()).collect() }; 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); let button = (inh.ft.as_deref() == Some(b"Btn") && inh.ff.unwrap_or(0) & flags::PUSH_BUTTON != 0) diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index 3ffc253877..a96a519d8c 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -305,6 +305,85 @@ fn field_flags_reject_values_outside_the_unsigned_32_bit_domain() { } } +/// A `/Kids` entry that loops back (the field itself, or a field it descends from), or names a root +/// field that doesn't name this one as its `/Parent`, is not a child (ISO 32000-2 §12.7.3, +/// Table 224; §12.7.4.1, Table 226). The field keeps its other kids: here its radio buttons. +#[test] +fn kids_that_loop_back_or_are_roots_are_not_children() { + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 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 [6 0 R 9 0 R 12 0 R 15 0 R] >>".into(), // 3 + "<< /Fields [5 0 R 7 0 R 8 0 R 10 0 R] /DA (/Helv 0 Tf 0 g) >>".into(), // 4 + "<< /FT /Btn /Ff 49152 /T (size) /V /Off /Kids [5 0 R 6 0 R] >>".into(), // 5 lists itself + "<< /Type /Annot /Subtype /Widget /Parent 5 0 R /Rect [50 700 65 715] /P 3 0 R /AP << /N << /S 11 0 R /Off 11 0 R >> >> >>".into(), // 6 + "<< /FT /Btn /Ff 49152 /T (left) /V /Off /Kids [8 0 R 9 0 R] >>".into(), // 7 lists the root 8 + "<< /FT /Btn /Ff 49152 /T (right) /V /Off /Kids [7 0 R 12 0 R] >>".into(), // 8 lists the root 7 + "<< /Type /Annot /Subtype /Widget /Parent 7 0 R /Rect [50 650 65 665] /P 3 0 R /AP << /N << /A 11 0 R /Off 11 0 R >> >> >>".into(), // 9 + "<< /T (group) /Kids [13 0 R] >>".into(), // 10 + "<< /Length 0 >> +stream + +endstream" + .into(), // 11 + "<< /Type /Annot /Subtype /Widget /Parent 8 0 R /Rect [50 600 65 615] /P 3 0 R /AP << /N << /B 11 0 R /Off 11 0 R >> >> >>".into(), // 12 + "<< /FT /Tx /T (inner) /Parent 10 0 R /Kids [14 0 R] >>".into(), // 13 + "<< /FT /Tx /T (deep) /Parent 13 0 R /Kids [13 0 R 15 0 R] >>".into(), // 14 lists its parent + "<< /Type /Annot /Subtype /Widget /Parent 14 0 R /Rect [50 550 250 570] /P 3 0 R >>".into(), // 15 + ]; + let doc = document(&objs); + let all = fields(&doc); + let listed: Vec<(&str, FieldKind, Vec)> = + all.iter().map(|f| (f.name.as_str(), f.kind, f.widgets.iter().map(|w| w.obj).collect())).collect(); + let r = |num| ObjRef::new(num, 0); + assert_eq!( + listed, + [ + ("size", FieldKind::Radio, vec![r(6)]), + ("left", FieldKind::Radio, vec![r(9)]), + ("right", FieldKind::Radio, vec![r(12)]), + ("group.inner.deep", FieldKind::Text, vec![r(15)]), + ] + ); +} + +/// A page widget whose `/Parent` names a field is one of that field's widgets even when the +/// field's `/Kids` leave it out: a child names its parent (ISO 32000-2 §12.7.4.1, Table 226). Here +/// `radio` lists itself and one of its two widgets, and `pair` and `other` list each other (roots, +/// so not children) and none of their widgets. Each field gets every widget naming it, once. +#[test] +fn widgets_naming_a_field_that_does_not_list_them_join_it() { + let w = |parent: u32, state: &str, y: u32| { + format!( + "<< /Type /Annot /Subtype /Widget /Parent {parent} 0 R /Rect [50 {y} 65 {}] /P 3 0 R /AP << /N << /{state} 10 0 R /Off 10 0 R >> >> >>", + y + 15 + ) + }; + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 6 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 7 0 R 11 0 R 12 0 R 13 0 R] >>".into(), // 3 + w(5, "A", 700), // 4: not in 5's /Kids + "<< /FT /Btn /Ff 49152 /T (radio) /V /Off /Kids [5 0 R 7 0 R] >>".into(), // 5 + "<< /Fields [5 0 R 8 0 R 9 0 R] /DA (/Helv 0 Tf 0 g) >>".into(), // 6 + w(5, "B", 650), // 7: in 5's /Kids + "<< /FT /Btn /Ff 49152 /T (pair) /V /Off /Kids [9 0 R 11 0 R] >>".into(), // 8 lists the root 9 + "<< /FT /Btn /Ff 49152 /T (other) /V /Off /Kids [8 0 R] >>".into(), // 9 lists the root 8 + "<< /Length 0 >>\nstream\n\nendstream".into(), // 10 + w(8, "C", 600), // 11: in 8's /Kids + w(8, "D", 550), // 12: not in 8's /Kids + w(9, "E", 500), // 13: not in 9's /Kids + ]; + let doc = document(&objs); + let (all, adopted) = enumerate(&doc); + let listed: Vec<(&str, Vec)> = all.iter().map(|f| (f.name.as_str(), f.widgets.iter().map(|w| w.obj).collect())).collect(); + let r = |num| ObjRef::new(num, 0); + assert_eq!(listed, [("radio", vec![r(7), r(4)]), ("pair", vec![r(11), r(12)]), ("other", vec![r(13)])]); + assert_eq!(adopted, 0, "no widget is read as a field of its own"); + let states: Vec<_> = field(&all, "radio").widgets.iter().filter_map(|w| w.on_state.as_deref()).collect(); + assert_eq!(states, ["B", "A"]); +} + /// Some writers leave every field out of `/Fields` and put the widgets only in the page /// annotations: the tree walk alone finds nothing, and Acrobat and the browsers fill them anyway. /// The pages' widgets are adopted as fields, after the listed tree fields. diff --git a/crates/render/src/inspect/annotations.rs b/crates/render/src/inspect/annotations.rs index 351d2f8b64..bcf84ee07b 100644 --- a/crates/render/src/inspect/annotations.rs +++ b/crates/render/src/inspect/annotations.rs @@ -370,14 +370,14 @@ impl<'a> Inspector<'a> { o: &'a Object, parent: Option<(&str, Option, Option)>, out: &mut Vec, - seen: &mut HashSet, + walk: &mut FieldWalk, depth: u32, ) { if depth > 32 { return; } if let Object::Reference(id) = o - && !seen.insert(*id) + && !walk.seen.insert(*id) { return; } @@ -396,10 +396,16 @@ impl<'a> Inspector<'a> { Ok(Object::Array(k)) => k.as_slice(), _ => &[], }; - let field_kids: Vec<_> = kids.iter().filter(|k| self.dict(k).is_some_and(|kd| kd.get(b"T").is_ok())).collect(); + let (field_kids, widget_kids): (Vec<&Object>, Vec<&Object>) = kids.iter().partition(|k| self.dict(k).is_some_and(|kd| kd.get(b"T").is_ok())); + let field_kids: Vec<_> = field_kids.into_iter().filter(|k| !self.not_a_child(walk, o, k)).collect(); if !field_kids.is_empty() { + let id = if let Object::Reference(id) = o { Some(*id) } else { None }; + walk.path.extend(id); for k in field_kids { - self.field(k, Some((&full, ft.clone(), ff)), out, seen, depth + 1); + self.field(k, Some((&full, ft.clone(), ff)), out, walk, depth + 1); + } + if id.is_some() { + walk.path.pop(); } return; } @@ -429,14 +435,67 @@ 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 either this dict (merged field/widget) or its first kid. With no widget + // among its kids, it is the first page widget naming this field as its `/Parent`: a child + // names its parent (ISO 32000-2 §12.7.4.1, Table 226), as MuPDF and PDFium read it. + let merged = d.get(b"Rect").is_ok(); + let named = if merged || !widget_kids.is_empty() { None } else { self.widget_naming(o) }; + let widget = if merged { + Some(d) + } else { + widget_kids.first().and_then(|k| self.dict(k)).or_else(|| named.and_then(|id| self.doc.get_dictionary(id).ok())) + }; + 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)) + .or_else(|| named.and_then(|id| self.annotation_pages.get_or_init(|| self.annotation_pages()).get(&id).copied())); 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 }); } + /// Whether `k`, listed in the `/Kids` of field `o`, is not a child of `o` after all: `o` itself + /// or a field `o` descends from (a loop), or a root field (one `/Fields` lists) that doesn't + /// name `o` as its `/Parent`. Root fields have no ancestors (ISO 32000-2 §12.7.3, Table 224), + /// and a child names its parent (§12.7.4.1, Table 226). + fn not_a_child(&self, walk: &FieldWalk, o: &Object, k: &Object) -> bool { + let Object::Reference(k) = k else { return false }; + let parent = self.dict(&Object::Reference(*k)).and_then(|d| match d.get(b"Parent") { + Ok(Object::Reference(p)) => Some(*p), + _ => None, + }); + let o = if let Object::Reference(o) = o { Some(*o) } else { None }; + o == Some(*k) || walk.path.contains(k) || (walk.roots.contains(k) && (o.is_none() || parent != o)) + } + + /// The first page widget that names field `o` as its `/Parent` and is not a field of its own + /// (no `/T`), in page-annotation order. + fn widget_naming(&self, o: &Object) -> Option { + let Object::Reference(id) = o else { return None }; + self.parent_widgets.get_or_init(|| self.parent_widgets()).get(id).copied() + } + + /// The first page widget (no `/T`) naming each field as its `/Parent`, in page-annotation order. + fn parent_widgets(&self) -> HashMap { + let mut first = HashMap::new(); + for &pid in &self.page_ids { + let Some(annots) = self.doc.get_dictionary(pid).ok().and_then(|d| d.get(b"Annots").ok()) else { continue }; + let Ok(annots) = self.resolve(annots).as_array() else { continue }; + for a in annots { + let Object::Reference(id) = a else { continue }; + let Ok(w) = self.doc.get_dictionary(*id) else { continue }; + if self.name(w, b"Subtype").as_deref() != Some("Widget") || w.get(b"T").is_ok() { + continue; + } + if let Ok(Object::Reference(parent)) = w.get(b"Parent") { + first.entry(*parent).or_insert(*id); + } + } + } + first + } + /// The first page whose `/Annots` lists the field or one of its kids. fn find_widget_page(&self, field: &Object, kids: &[Object]) -> Option { let pages = self.annotation_pages.get_or_init(|| self.annotation_pages()); @@ -486,6 +545,15 @@ impl<'a> Inspector<'a> { } } +/// What one reading of the form's fields keeps: the fields read, the root fields `/Fields` lists, +/// and the fields whose `/Kids` are being read, outermost first. +#[derive(Default)] +pub(super) struct FieldWalk { + pub(super) seen: HashSet, + pub(super) roots: HashSet, + path: Vec, +} + /// An annotation's `/F` as the renderer reads it: a real is truncated, and a value that isn't a /// `u32` counts as no flags. fn draw_flags(o: &Object) -> u32 { diff --git a/crates/render/src/inspect/core.rs b/crates/render/src/inspect/core.rs index 924bfcc51d..a7a1d5ba7d 100644 --- a/crates/render/src/inspect/core.rs +++ b/crates/render/src/inspect/core.rs @@ -1,4 +1,4 @@ -use super::annotations::MAX_LINK_ATTACHMENT_TEXT_BYTES; +use super::annotations::{FieldWalk, MAX_LINK_ATTACHMENT_TEXT_BYTES}; use super::model::{AttachmentSource, PageInfo, Xfa}; use super::navigation::text_string; use super::{ @@ -144,13 +144,16 @@ pub(super) struct Inspector<'a> { /// has no `/P`. Searching every page's `/Annots` for each such field was O(fields × /// annotations): a second on a form of 8,000 fields. pub(super) annotation_pages: OnceCell>, + /// The first page widget naming each field as its `/Parent`, built when a field has no widget + /// among its `/Kids`. + pub(super) parent_widgets: OnceCell>, } impl<'a> Inspector<'a> { pub(super) fn new(doc: &'a dyn Structure) -> Self { let page_ids = doc.page_ids(); let page_index = page_ids.iter().copied().enumerate().map(|(index, id)| (id, index)).collect(); - let mut me = Self { doc, page_ids, page_index, named: HashMap::new(), annotation_pages: OnceCell::new() }; + let mut me = Self { doc, page_ids, page_index, named: HashMap::new(), annotation_pages: OnceCell::new(), parent_widgets: OnceCell::new() }; let mut named = HashMap::new(); if let Some(tree) = doc.catalog().ok().and_then(|c| c.get(b"Names").ok()).and_then(|o| me.dict(o)).and_then(|n| n.get(b"Dests").ok()).and_then(|o| me.dict(o)) @@ -210,12 +213,13 @@ impl<'a> Inspector<'a> { let mut text_budget = MAX_LINK_ATTACHMENT_TEXT_BYTES; self.annotations(info, &mut text_budget); self.transitions(info); - let mut field_seen = HashSet::new(); + let mut walk = FieldWalk::default(); if let Some(form) = catalog.get(b"AcroForm").ok().and_then(|o| self.dict(o)) && let Ok(fields) = form.get(b"Fields").and_then(|o| self.resolve(o).as_array()) { + walk.roots = fields.iter().filter_map(|f| if let Object::Reference(id) = f { Some(*id) } else { None }).collect(); for f in fields { - self.field(f, None, &mut info.fields, &mut field_seen, 0); + self.field(f, None, &mut info.fields, &mut walk, 0); } } // Widgets no `/Fields` entry reaches are fields too (Acrobat and the browsers fill them); @@ -231,9 +235,9 @@ impl<'a> Inspector<'a> { continue; } if let Object::Reference(id) = a - && let Some(root) = self.unlisted_field(*id, &field_seen) + && let Some(root) = self.unlisted_field(*id, &walk.seen) { - self.field(&Object::Reference(root), None, &mut info.fields, &mut field_seen, 0); + self.field(&Object::Reference(root), None, &mut info.fields, &mut walk, 0); } } } diff --git a/crates/render/src/inspect/tests.rs b/crates/render/src/inspect/tests.rs index c7b8fd572e..6b4ee43378 100644 --- a/crates/render/src/inspect/tests.rs +++ b/crates/render/src/inspect/tests.rs @@ -1986,3 +1986,64 @@ fn inspection_keeps_no_private_page_data() { }; assert_eq!(report(with), report(without), "what inspection reports does not change"); } + +/// A `/Kids` entry that loops back, or names a root field that doesn't name this one as its +/// `/Parent`, is not a child (ISO 32000-2 §12.7.3, Table 224; §12.7.4.1, Table 226): the field +/// keeps its other kids and is listed with them. +#[test] +fn kids_that_loop_back_or_are_roots_are_not_children() { + let pdf = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm 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 [6 0 R 9 0 R 12 0 R 15 0 R] >> endobj +4 0 obj << /Fields [5 0 R 7 0 R 8 0 R 10 0 R] >> endobj +5 0 obj << /FT /Btn /Ff 49152 /T (size) /Kids [5 0 R 6 0 R] >> endobj +6 0 obj << /Type /Annot /Subtype /Widget /Parent 5 0 R /Rect [10 150 20 160] >> endobj +7 0 obj << /FT /Btn /Ff 49152 /T (left) /Kids [8 0 R 9 0 R] >> endobj +8 0 obj << /FT /Btn /Ff 49152 /T (right) /Kids [7 0 R 12 0 R] >> endobj +9 0 obj << /Type /Annot /Subtype /Widget /Parent 7 0 R /Rect [10 120 20 130] >> endobj +10 0 obj << /T (group) /Kids [13 0 R] >> endobj +12 0 obj << /Type /Annot /Subtype /Widget /Parent 8 0 R /Rect [10 90 20 100] >> endobj +13 0 obj << /FT /Tx /T (inner) /Parent 10 0 R /Kids [14 0 R] >> endobj +14 0 obj << /FT /Tx /T (deep) /Parent 13 0 R /Kids [13 0 R 15 0 R] >> endobj +15 0 obj << /Type /Annot /Subtype /Widget /Parent 14 0 R /Rect [10 60 90 80] >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + let info = inspect(Arc::new(pdf.to_vec()), None).expect("opens"); + let listed: Vec<_> = info.fields.iter().map(|f| (f.name.as_str(), f.kind, f.page, f.rect.map(|r| r[1]))).collect(); + assert_eq!( + listed, + [ + ("size", FieldKind::Radio, Some(0), Some(150.0)), + ("left", FieldKind::Radio, Some(0), Some(120.0)), + ("right", FieldKind::Radio, Some(0), Some(90.0)), + ("group.inner.deep", FieldKind::Text, Some(0), Some(60.0)), + ], + "{:?}", + info.fields + ); +} + +/// A field with no widget among its `/Kids` takes the page widget that names it as its +/// `/Parent`: a child names its parent (ISO 32000-2 §12.7.4.1, Table 226). Here `pair` and `other` +/// list each other (roots, so not children), and only a page widget says it belongs to `other`. +#[test] +fn a_field_takes_the_page_widget_naming_it_when_its_kids_list_none() { + let pdf = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm 6 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 [4 0 R 7 0 R 11 0 R 13 0 R] >> endobj +4 0 obj << /Type /Annot /Subtype /Widget /Parent 5 0 R /Rect [10 150 20 160] >> endobj +5 0 obj << /FT /Btn /Ff 49152 /T (radio) /Kids [5 0 R 7 0 R] >> endobj +6 0 obj << /Fields [5 0 R 8 0 R 9 0 R] >> endobj +7 0 obj << /Type /Annot /Subtype /Widget /Parent 5 0 R /Rect [10 120 20 130] >> endobj +8 0 obj << /FT /Btn /Ff 49152 /T (pair) /Kids [9 0 R 11 0 R] >> endobj +9 0 obj << /FT /Btn /Ff 49152 /T (other) /Kids [8 0 R] >> endobj +11 0 obj << /Type /Annot /Subtype /Widget /Parent 8 0 R /Rect [10 90 20 100] >> endobj +13 0 obj << /Type /Annot /Subtype /Widget /Parent 9 0 R /Rect [10 60 20 70] >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + let info = inspect(Arc::new(pdf.to_vec()), None).expect("opens"); + let listed: Vec<_> = info.fields.iter().map(|f| (f.name.as_str(), f.page, f.rect.map(|r| r[1]))).collect(); + assert_eq!(listed, [("radio", Some(0), Some(120.0)), ("pair", Some(0), Some(90.0)), ("other", Some(0), Some(60.0))], "{:?}", info.fields); +}