diff --git a/crates/forms/src/read.rs b/crates/forms/src/read.rs index 34775ee75e..f693280086 100644 --- a/crates/forms/src/read.rs +++ b/crates/forms/src/read.rs @@ -106,12 +106,19 @@ fn qualified_name(doc: &Document, r: ObjRef) -> Option { 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 { + 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 = 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] diff --git a/crates/render/src/inspect/annotations.rs b/crates/render/src/inspect/annotations.rs index 5ed635fdcb..351d2f8b64 100644 --- a/crates/render/src/inspect/annotations.rs +++ b/crates/render/src/inspect/annotations.rs @@ -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) -> Option { 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 { diff --git a/crates/render/src/inspect/tests.rs b/crates/render/src/inspect/tests.rs index 012e40f79a..c7b8fd572e 100644 --- a/crates/render/src/inspect/tests.rs +++ b/crates/render/src/inspect/tests.rs @@ -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, diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index a3b6c0a198..967a424d24 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -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"