Forms: fields listed only as page widgets join the form (#649)

A PDF whose AcroForm /Fields array is empty or missing listed no
fields, so none were fillable even though every widget sat on a page
with its name and value: Acrobat and the browsers fill those. The page
widgets are now adopted as fields — after the tree-listed ones, through
each widget's topmost unlisted /Parent so a field split across several
widgets stays one — and the leniency is recorded, never silent.

- forms: fields() walks /Fields as before, then adopts the unlisted
  page widgets; adopted_page_fields() counts them for the note.
- render: the inspector's field list adopts them the same way, so the
  Fields panel, the field-count notice and menu gating follow.
- engine: opening such a file notes "the form's /Fields list omits N
  fields; they were read from the page annotations" in the repair log
  (Document Properties ▸ Advanced).

Co-authored-by: GLM-5.3 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-10-09 21:43:09 -04:00
1 parent 94752c09c4
commit a2f0fb7363
8 files changed
+305 -16

No files matched your search

+51
View File
@@ -1127,6 +1127,57 @@ fn forms_through_tools() {
assert_ne!(v, "Filled by an agent");
}
/// A form whose fields are only page widgets (the `/Fields` list is empty) still lists, fills and
/// resets through the tools, and the leniency shows up as a repair note.
#[test]
fn orphan_form_fields_through_tools() {
/// One page with two text fields that appear only as page widgets: the AcroForm lists none.
fn orphan_form() -> Vec<u8> {
let objs: Vec<&str> = vec![
"<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>", // 1
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>", // 2
"<< /Type /Page /Parent 2 0 R /Annots [5 0 R 6 0 R] >>", // 3
"<< /Fields [] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 7 0 R >> >> >>", // 4
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (alpha) /Rect [10 200 90 220] /P 3 0 R >>", // 5
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (beta) /Rect [10 150 90 170] /P 3 0 R >>", // 6
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", // 7
];
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
out
}
let dir = workdir("orphan-forms");
std::fs::write(dir.join("orphan.pdf"), orphan_form()).unwrap();
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({ "path": "orphan.pdf" }))["doc"].as_u64().unwrap();
let list = ok(&mut a, "form_fields", json!({ "doc": doc }));
assert_eq!(list["count"], 2, "{list}");
let r = ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "alpha": "Filled by an agent" } }));
assert_eq!(r["undo"], "Fill in alpha");
let after = ok(&mut a, "form_fields", json!({ "doc": doc }));
let alpha = after["fields"].as_array().unwrap().iter().find(|f| f["name"] == "alpha").unwrap();
assert_eq!(alpha["value"], "Filled by an agent");
// The page shows it.
assert_eq!(ok(&mut a, "text_find", json!({ "doc": doc, "query": "Filled by an agent" }))["count"], 1);
// The leniency is not silent: the repair note names the adopted fields.
let info = ok(&mut a, "doc_info", json!({ "doc": doc }));
assert!(info["repairs"].as_array().unwrap().iter().any(|r| r.as_str().unwrap_or("").contains("page annotations")), "{}", info["repairs"]);
ok(&mut a, "form_reset", json!({ "doc": doc }));
let reset = ok(&mut a, "form_fields", json!({ "doc": doc }));
let v = reset["fields"].as_array().unwrap().iter().find(|f| f["name"] == "alpha").unwrap()["value"].clone();
assert_ne!(v, "Filled by an agent");
}
#[test]
fn duplicating_and_cropping_through_tools() {
let dir = workdir("boxes");
+8
View File
@@ -540,6 +540,14 @@ impl Document {
&self.repair_log
}
/// Add a line to [`Self::repair_log`]: a leniency applied while reading that the user should
/// know about (fidelity: never silent).
pub fn note_repair(&mut self, line: String) {
let mut log = self.repair_log.as_ref().clone();
log.push(line);
self.repair_log = Arc::new(log);
}
/// `true` when there are unsaved edits.
pub fn is_modified(&self) -> bool {
!self.overlay.is_empty()
+14 -1
View File
@@ -2242,7 +2242,7 @@ impl Session {
xfa: Option<XfaLayout>,
) -> Result<DocId, OpenError> {
let config = RenderConfig { password: render_password.as_deref().map(Arc::from), ..Default::default() };
let (editor, read_only_reason) = match cos {
let (mut editor, read_only_reason) = match cos {
Ok(Ok(cos)) => {
let keys = Keys { render: render_password.clone(), reopen: password.map(str::to_owned) };
(Some(Editor { cos, undo: Vec::new(), redo: Vec::new(), keys }), None)
@@ -2253,8 +2253,21 @@ impl Session {
let display = display_bytes(editor.as_ref(), &bytes);
let renderer = RenderPool::new(display.clone(), render_threads(), config.clone());
let mut form = editor.as_ref().map(|e| pdfcraft_forms::fields(&e.cos)).unwrap_or_default();
let mut adopted = 0;
if let Some(e) = editor.as_ref() {
xfa::mark_script_buttons(&e.cos, &mut form);
adopted = pdfcraft_forms::adopted_page_fields(&e.cos);
}
// Fields the file lists only as page widgets are adopted leniently; the file says so.
if adopted > 0
&& let Some(e) = &mut editor
{
let line = if adopted == 1 {
"the form's /Fields list omits 1 field; it was read from the page annotations".to_string()
} else {
format!("the form's /Fields list omits {adopted} fields; they were read from the page annotations")
};
e.cos.note_repair(line);
}
let marks = editor.as_ref().map(|e| pdfcraft_edit::marks_present(&e.cos)).unwrap_or_default();
let added = editor.as_ref().map(|e| pdfcraft_edit::list_added(&e.cos)).unwrap_or_default();
+33
View File
@@ -2416,3 +2416,36 @@ fn placeholder_titles_are_recognised() {
assert!(!is_placeholder_title(t), "{t:?}");
}
}
/// A form whose fields are only page widgets (the `/Fields` list is empty): they are adopted, and
/// the leniency is recorded in the repair log rather than applied silently.
#[test]
fn adopting_page_only_fields_is_noted_as_a_repair() {
let objs: Vec<&str> = vec![
"<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>", // 1
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>", // 2
"<< /Type /Page /Parent 2 0 R /Annots [5 0 R 6 0 R] >>", // 3
"<< /Fields [] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 7 0 R >> >> >>", // 4
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (alpha) /Rect [10 200 90 220] /P 3 0 R >>", // 5
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (beta) /Rect [10 150 90 170] /P 3 0 R >>", // 6
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", // 7
];
let mut bytes = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(bytes.len());
bytes.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let xref = bytes.len();
bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
bytes.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
let mut s = Session::new().with_clock(|| 1_700_000_000);
let id = s.open("orphan.pdf", None, Arc::new(bytes), None).expect("opens");
let doc = s.get(id).unwrap();
let names: Vec<&str> = doc.form.iter().map(|f| f.name.as_str()).collect();
assert_eq!(names, ["alpha", "beta"]);
assert!(doc.repair_log().iter().any(|l| l.contains("2 fields") && l.contains("page annotations")), "{:?}", doc.repair_log());
}
+85 -10
View File
@@ -484,9 +484,20 @@ struct Inherited {
max_len: Option<usize>,
}
/// Every terminal field, in tree order.
/// Every terminal field: the `/Fields` tree in order, then the fields reachable only through the
/// page annotations (see [`adopt_page_fields`]).
pub fn fields(doc: &Document) -> Vec<Field> {
let Some(af) = acroform(doc) else { return Vec::new() };
enumerate(doc).0
}
/// How many of [`fields`] were reachable only through the page annotations: the form's `/Fields`
/// list names none or only some of them, so the leniency that adopts them is worth recording.
pub fn adopted_page_fields(doc: &Document) -> usize {
enumerate(doc).1
}
fn enumerate(doc: &Document) -> (Vec<Field>, usize) {
let af = acroform(doc);
let mut page_of = std::collections::HashMap::new();
let mut annot_index = std::collections::HashMap::new();
let pages = page_refs(doc);
@@ -500,20 +511,84 @@ pub fn fields(doc: &Document) -> Vec<Field> {
}
}
}
let base = Inherited {
da: af.get(b"DA").and_then(|o| text_of(&doc.resolve(o))),
q: af.get(b"Q").and_then(|o| doc.resolve(o).as_int()),
..Default::default()
let base = match &af {
Some(af) => Inherited {
da: af.get(b"DA").and_then(|o| text_of(&doc.resolve(o))),
q: af.get(b"Q").and_then(|o| doc.resolve(o).as_int()),
..Default::default()
},
None => Inherited::default(),
};
let mut out = Vec::new();
let mut seen = std::collections::HashSet::new();
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);
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);
}
}
}
let listed = out.len();
adopt_page_fields(doc, &pages, &base, &page_of, &mut seen, &mut out);
let adopted = out.len() - listed;
rank_tabs(doc, &pages, &annot_index, &mut out);
out
(out, adopted)
}
/// Widget annotations no `/Fields` entry reaches are adopted as fields, as Acrobat and the
/// browsers do: some writers list only some of the fields, or none at all. Each widget is adopted
/// through its topmost unlisted `/Parent`, so a field split across several widgets stays one
/// field. Tree-listed fields keep their order first; the rest follow in page-annotation order.
fn adopt_page_fields(
doc: &Document,
pages: &[ObjRef],
base: &Inherited,
page_of: &std::collections::HashMap<ObjRef, usize>,
seen: &mut std::collections::HashSet<ObjRef>,
out: &mut Vec<Field>,
) {
for p in pages {
let Some(a) = doc.get(*p).as_dict().and_then(|d| d.get(b"Annots").cloned()) else { continue };
let annots = doc.resolve(&a);
let Some(annots) = annots.as_array() else { continue };
for e in annots {
let Some(r) = e.as_ref() else { continue };
if seen.contains(&r) {
continue;
}
let obj = doc.get(r);
let Some(d) = obj.as_dict() else { continue };
if d.name(b"Subtype") != Some(b"Widget") {
continue;
}
if !d.contains(b"FT") && !d.contains(b"T") && !d.contains(b"Parent") {
continue;
}
if let Some(root) = unlisted_root(doc, r, seen) {
walk(doc, root, base, page_of, seen, out, 0);
}
}
}
}
/// The topmost field above `r` (itself without a `/Parent`), or `None` when `r` or an ancestor is
/// already reachable from the `/Fields` tree, or a `/Parent` loop hides the top.
fn unlisted_root(doc: &Document, r: ObjRef, seen: &std::collections::HashSet<ObjRef>) -> Option<ObjRef> {
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 Some(p) = parent else {
return if seen.contains(&top) { None } else { Some(top) };
};
if !visited.insert(p) || visited.len() > 64 {
return None;
}
if seen.contains(&p) {
return None;
}
top = p;
}
}
/// A page's tab order (`/Tabs`).
+46
View File
@@ -95,6 +95,52 @@ fn the_field_tree_is_read_with_inheritance() {
assert_eq!(field(&all, "address.city").quadding, 1);
}
/// 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.
#[test]
fn fields_listed_only_on_the_pages_are_adopted() {
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 10 0 R 11 0 R 13 0 R 14 0 R 15 0 R] >>".into(), // 3
"<< /Fields [5 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 6 0 R >> >> >>".into(), // 4
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (listed) /V (keep) /Rect [50 700 250 720] /P 3 0 R >>".into(), // 5
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), // 6
"null".into(), // 7
"null".into(), // 8
"null".into(), // 9
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (alpha) /V (one) /Rect [50 600 250 620] /P 3 0 R >>".into(), // 10
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (beta) /DA (/Helv 9 Tf 0 g) /Rect [50 560 250 580] /P 3 0 R >>".into(), // 11
"<< /FT /Tx /T (pair) /Kids [13 0 R 14 0 R] >>".into(), // 12 unlisted parent field
"<< /Type /Annot /Subtype /Widget /Parent 12 0 R /Rect [50 500 150 520] /P 3 0 R >>".into(), // 13 its widget
"<< /Type /Annot /Subtype /Widget /Parent 12 0 R /Rect [50 460 150 480] /P 3 0 R >>".into(), // 14 its other widget
"<< /Type /Annot /Subtype /Widget /Rect [400 700 415 715] /P 3 0 R >>".into(), // 15 no /FT, /T or /Parent: not a field
];
let mut doc = document(&objs);
let all = fields(&doc);
let names: Vec<&str> = all.iter().map(|f| f.name.as_str()).collect();
assert_eq!(names, ["listed", "alpha", "beta", "pair"]);
assert_eq!(adopted_page_fields(&doc), 3);
let alpha = field(&all, "alpha");
assert_eq!((alpha.kind, alpha.value.as_slice(), alpha.widgets[0].page), (FieldKind::Text, &["one".to_string()][..], Some(0)));
assert_eq!(field(&all, "beta").da, "/Helv 9 Tf 0 g");
assert_eq!(field(&all, "pair").widgets.len(), 2, "one field carries both widgets");
// They are filled and keep the value across a save.
set_value(&mut doc, "beta", &FieldValue::Text("typed".into())).unwrap();
assert!(ap(&doc, &field(&fields(&doc), "beta").widgets[0]).contains("typed"));
let doc = reopen(&doc);
assert_eq!(field(&fields(&doc), "beta").value, ["typed"]);
// Without a /Fields list at all, everything on the pages is adopted.
let mut objs = objs;
objs[3] = "<< /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 6 0 R >> >> >>".into();
let doc = document(&objs);
let all = fields(&doc);
let names: Vec<&str> = all.iter().map(|f| f.name.as_str()).collect();
assert_eq!(names, ["listed", "alpha", "beta", "pair"]);
assert_eq!(adopted_page_fields(&doc), 4);
}
/// 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]
+65 -2
View File
@@ -476,12 +476,33 @@ impl<'a> Inspector<'a> {
info.outline = self.outline_siblings(first, &mut seen, 0);
}
self.annotations(info);
let mut field_seen = HashSet::new();
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())
{
let mut seen = HashSet::new();
for f in fields {
self.field(f, None, &mut info.fields, &mut seen, 0);
self.field(f, None, &mut info.fields, &mut field_seen, 0);
}
}
// Widgets no `/Fields` entry reaches are fields too (Acrobat and the browsers fill them);
// each is adopted through its topmost unlisted `/Parent`, so a split field stays one.
let mut pages: Vec<_> = self.page_index.iter().collect();
pages.sort_by_key(|(_, i)| **i);
for (&pid, _) in pages {
let Ok(page_dict) = self.doc.get_dictionary(pid) else { continue };
let Ok(Object::Array(annots)) = page_dict.get(b"Annots").map(|o| self.resolve(o)) else { continue };
for a in annots {
let Some(d) = self.dict(a) else { continue };
if self.name(d, b"Subtype").as_deref() != Some("Widget")
|| (d.get(b"FT").is_err() && d.get(b"T").is_err() && d.get(b"Parent").is_err())
{
continue;
}
if let Object::Reference(id) = a
&& let Some(root) = self.unlisted_field(*id, &field_seen)
{
self.field(&Object::Reference(root), None, &mut info.fields, &mut field_seen, 0);
}
}
}
info.has_javascript |= info.fields.iter().any(|f| f.has_actions);
@@ -1006,6 +1027,31 @@ impl<'a> Inspector<'a> {
})
}
/// The topmost field above widget `id` (itself without a `/Parent`), or `None` when it or an
/// ancestor is already reachable from the `/Fields` tree, or a `/Parent` loop hides the top.
fn unlisted_field(&self, id: ObjectId, seen: &HashSet<ObjectId>) -> Option<ObjectId> {
let mut top = id;
let mut visited = HashSet::from([id]);
loop {
let o = Object::Reference(top);
let d = self.dict(&o)?;
let parent = match d.get(b"Parent") {
Ok(Object::Reference(p)) => Some(*p),
_ => None,
};
let Some(p) = parent else {
return if seen.contains(&top) { None } else { Some(top) };
};
if !visited.insert(p) || visited.len() > 64 {
return None;
}
if seen.contains(&p) {
return None;
}
top = p;
}
}
// ── optional content ────────────────────────────────────────────────────────────────────
fn layers(&self, catalog: &Dictionary, out: &mut Vec<Layer>) {
@@ -1537,6 +1583,23 @@ trailer << /Root 1 0 R >>
assert_eq!(info.layer_groups, [vec![(5, 0), (6, 0)], vec![(6, 0), (7, 0)]]);
}
const ORPHAN_FIELDS: &[u8] = 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) /Rect [10 100 90 120] >> endobj
6 0 obj << /Type /Annot /Subtype /Widget /FT /Tx /T (beta) /Rect [10 60 90 80] >> endobj
trailer << /Root 1 0 R >>
%%EOF";
#[test]
fn fields_listed_only_on_the_pages_are_listed() {
let info = inspect(Arc::new(ORPHAN_FIELDS.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);
}
#[test]
fn lazy_structure_matches_the_compatibility_inspector() {
// Each fixture as lopdf writes it, and with its objects in object streams.
+3 -3
View File
@@ -4302,8 +4302,8 @@ milestone = "M3"
status = "shipped"
commands = ["form.fields"]
tools = ["doc_info"]
evidence = ["crates/ui-egui/tests/view.rs::field_list_jumps_to_a_fields_page"]
notes = "read-only list with jump-to-field; filling and editing are separate features (M6)"
evidence = ["crates/ui-egui/tests/view.rs::field_list_jumps_to_a_fields_page", "crates/render/src/inspect.rs::fields_listed_only_on_the_pages_are_listed"]
notes = "read-only list with jump-to-field; filling and editing are separate features (M6); fields a file lists only as page widgets (empty /Fields) are adopted too"
[[feature]]
id = "form.highlight-fields"
@@ -4338,7 +4338,7 @@ milestone = "M6"
status = "shipped"
commands = ["form.fields"]
tools = ["form_fill", "form_fields"]
evidence = ["crates/ui-egui/tests/forms.rs::typing_into_a_text_field_and_tabbing_on", "crates/forms/src/tests.rs::text_fields_get_new_appearances", "crates/engine/src/tests.rs::filling_a_form_shows_saves_and_undoes"]
evidence = ["crates/ui-egui/tests/forms.rs::typing_into_a_text_field_and_tabbing_on", "crates/forms/src/tests.rs::text_fields_get_new_appearances", "crates/engine/src/tests.rs::filling_a_form_shows_saves_and_undoes", "crates/automation/tests/automation.rs::orphan_form_fields_through_tools"]
notes = "in-place editor; Enter or clicking away commits, Escape cancels; no format/keystroke JavaScript yet (M6.4)"
[[feature]]