M6: a widget whose /Parent names a missing object is a field of its own (form.field-list) (#6445)

This commit is contained in:
mtlcl410 authored and GitHub committed 2026-11-09 08:49:56 +08:00
1 parent de7ed00a1a
commit 608ff62dae
5 files changed
+66 -6

No files matched your search

+9 -2
View File
@@ -106,12 +106,19 @@ fn qualified_name(doc: &Document, r: ObjRef) -> Option<String> {
if let Some(t) = d.get(b"T").and_then(|o| text_of(&doc.resolve(o))) {
parts.push(t);
}
cur = d.get(b"Parent").and_then(|p| p.as_ref());
cur = parent_of(doc, d);
}
parts.reverse();
(!parts.is_empty()).then(|| parts.join("."))
}
/// The field `d` names as its `/Parent`, when that is a dictionary. A reference to an object the
/// file doesn't hold reads as `null`, and a `null` entry is the same as an absent one (ISO
/// 32000-2 §7.3.9): the field is then a root, as if it named no parent.
fn parent_of(doc: &Document, d: &Dict) -> Option<ObjRef> {
d.reference(b"Parent").filter(|p| doc.get(*p).as_dict().is_some())
}
/// JavaScript streams one reading of the form (its fields, or its document scripts) may decode
/// in all, however many actions name the same stream.
pub(crate) const MAX_SCRIPT_BYTES: usize = 64 << 20;
@@ -413,7 +420,7 @@ fn unlisted_root(doc: &Document, r: ObjRef, seen: &std::collections::HashSet<Obj
let mut top = r;
let mut visited = std::collections::HashSet::from([r]);
loop {
let parent = doc.get(top).as_dict().and_then(|d| d.reference(b"Parent"));
let parent = doc.get(top).as_dict().and_then(|d| parent_of(doc, d));
let Some(p) = parent else {
return if seen.contains(&top) { None } else { Some(top) };
};
+31
View File
@@ -353,6 +353,37 @@ fn fields_listed_only_on_the_pages_are_adopted() {
assert_eq!(adopted_page_fields(&doc), 4);
}
/// A widget whose `/Parent` names an object the file doesn't hold, or one that isn't a dictionary,
/// has no parent: the reference reads as `null`, the same as an absent entry (ISO
/// 32000-2 §7.3.9). It is adopted as a field of its own, under its own name, and can be filled.
#[test]
fn a_widget_whose_parent_is_missing_is_a_field_of_its_own() {
let objs: Vec<String> = 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 [5 0 R 7 0 R 8 0 R] >>".into(), // 3
"<< /Fields [] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 6 0 R >> >> >>".into(), // 4
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (city) /V (Oslo) /Parent 40 0 R /Rect [50 700 250 720] /P 3 0 R >>".into(), // 5 parent not in the file
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), // 6
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (zip) /Parent 9 0 R /Rect [50 660 250 680] /P 3 0 R >>".into(), // 7 parent is no dictionary
"<< /Type /Annot /Subtype /Widget /Parent 40 0 R /Rect [400 700 415 715] /P 3 0 R >>".into(), // 8 no /FT or /T: not a field
"[1 2 3]".into(), // 9
];
let mut doc = document(&objs);
assert_combined_matches_legacy(&doc, 2);
let all = fields(&doc);
let names: Vec<&str> = all.iter().map(|f| f.name.as_str()).collect();
assert_eq!(names, ["city", "zip"]);
let city = field(&all, "city");
assert_eq!(
(city.kind, city.value.as_slice(), city.widgets.len(), city.widgets[0].page),
(FieldKind::Text, &["Oslo".to_string()][..], 1, Some(0))
);
set_value(&mut doc, "zip", &FieldValue::Text("0150".into())).unwrap();
let doc = reopen(&doc);
assert_eq!(field(&fields(&doc), "zip").value, ["0150"]);
}
/// pdf-lib and other writers give radio groups and check boxes an `/Opt` array and name the on
/// states by position (`/0`, `/1`): the export values select them, as in Acrobat.
#[test]
+4 -2
View File
@@ -458,7 +458,7 @@ impl<'a> Inspector<'a> {
pages
}
/// The topmost field above widget `id` (itself without a `/Parent`), or `None` when it or an
/// The topmost field above widget `id` (itself without a `/Parent` that is a field), or `None` when it or an
/// ancestor is already reachable from the `/Fields` tree, or a `/Parent` loop hides the top.
pub(super) fn unlisted_field(&self, id: ObjectId, seen: &HashSet<ObjectId>) -> Option<ObjectId> {
let mut top = id;
@@ -466,8 +466,10 @@ impl<'a> Inspector<'a> {
loop {
let o = Object::Reference(top);
let d = self.dict(&o)?;
// A `/Parent` naming an object the file doesn't hold, or no dictionary, reads as
// `null`, the same as no parent (ISO 32000-2 §7.3.9).
let parent = match d.get(b"Parent") {
Ok(Object::Reference(p)) => Some(*p),
Ok(Object::Reference(p)) if self.dict(&Object::Reference(*p)).is_some() => Some(*p),
_ => None,
};
let Some(p) = parent else {
+20
View File
@@ -786,6 +786,26 @@ fn fields_listed_only_on_the_pages_are_listed() {
assert_eq!(listed, [("alpha", Some("one"), Some(0)), ("beta", None, Some(0))], "{:?}", info.fields);
}
/// A widget whose `/Parent` names an object the file doesn't hold, or one that isn't a dictionary,
/// is listed as a field of its own: that parent reads as `null`, the same
/// as none (ISO 32000-2 §7.3.9).
#[test]
fn a_widget_whose_parent_is_missing_is_listed() {
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 [5 0 R 6 0 R] >> endobj
4 0 obj << /Fields [] >> endobj
5 0 obj << /Type /Annot /Subtype /Widget /FT /Tx /T (alpha) /V (one) /Parent 40 0 R /Rect [10 100 90 120] >> endobj
6 0 obj << /Type /Annot /Subtype /Widget /FT /Tx /T (beta) /Parent 7 0 R /Rect [10 60 90 80] >> endobj
7 0 obj [1 2 3] 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.value.as_deref(), f.page)).collect();
assert_eq!(listed, [("alpha", Some("one"), Some(0)), ("beta", None, Some(0))], "{:?}", info.fields);
}
/// A `Structure` that counts the objects read through it.
struct CountingReads<'a> {
doc: &'a Document,
+2 -2
View File
@@ -4590,8 +4590,8 @@ milestone = "M3"
status = "shipped"
commands = ["form.fields"]
tools = ["doc_info"]
evidence = ["crates/ui-egui/tests/it/view.rs::field_list_jumps_to_a_fields_page", "crates/render/src/inspect/tests.rs::fields_listed_only_on_the_pages_are_listed", "crates/ui-egui/tests/it/bulk_fields.rs::fields_panel_selects_multiple_fields_and_opens_their_shared_properties", "crates/ui-egui/tests/it/bulk_fields.rs::double_clicking_a_field_in_the_panel_opens_its_individual_properties"]
notes = "list with jump-to-field; in Prepare a form, Shift/Ctrl/Command-click selects several fields and Properties edits their shared properties; double-click opens field properties; fields a file lists only as page widgets (empty /Fields) are adopted too"
evidence = ["crates/ui-egui/tests/it/view.rs::field_list_jumps_to_a_fields_page", "crates/render/src/inspect/tests.rs::fields_listed_only_on_the_pages_are_listed", "crates/render/src/inspect/tests.rs::a_widget_whose_parent_is_missing_is_listed", "crates/forms/src/tests.rs::a_widget_whose_parent_is_missing_is_a_field_of_its_own", "crates/ui-egui/tests/it/bulk_fields.rs::fields_panel_selects_multiple_fields_and_opens_their_shared_properties", "crates/ui-egui/tests/it/bulk_fields.rs::double_clicking_a_field_in_the_panel_opens_its_individual_properties"]
notes = "list with jump-to-field; in Prepare a form, Shift/Ctrl/Command-click selects several fields and Properties edits their shared properties; double-click opens field properties; fields a file lists only as page widgets (empty /Fields) are adopted too, and a widget whose /Parent names a missing object is a field of its own"
[[feature]]
id = "form.highlight-fields"