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)

- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA
This commit is contained in:
mtlcl410andClaude Opus 5.5 committed 2026-11-08 11:21:59 +08:00
1 parent 916ca60e80
commit eee81da38b
5 files changed
+194 -14

No files matched your search

+14 -5
View File
@@ -393,10 +393,19 @@ pub(crate) fn enumerate(doc: &Document) -> (Vec<Field>, 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<ObjRef> = if d.contains(b"Rect") && !has_widget_kids { vec![r] } else { kids };
let widget_refs: Vec<ObjRef> = widget_refs.into_iter().filter(|w| seen.widgets.insert(*w)).collect();
let actions = actions_of(doc, d, &widget_refs, &mut seen.scripts);
+108
View File
@@ -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<String> = 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<String> = 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<String> = 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<String> = 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::<Vec<_>>(), [("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<String> = 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<u32> = 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<String> = 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();
+4 -1
View File
@@ -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)?;
+20 -8
View File
@@ -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<Object> = 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 });
+48
View File
@@ -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<u8>| inspect(Arc::new(bytes), None).expect("opens").fields.iter().map(|f| f.name.clone()).collect::<Vec<_>>();
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]))]
);
}