mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-09 12:32:52 +00:00
M6: a field merged with a widget reads its widget kids as its buttons, and nameless widgets of an unlisted field are options of its name (form.field-list)
A field dictionary that also carries a zero-area widget and lists widget kids now has the kids as its buttons (ISO 32000-2 §12.7.4: field and widget merge only for a single widget). Widget annotations listed beside named kids, and nameless /Fields entries whose /Parent /Fields doesn't list, are read as buttons of that parent's name. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA
This commit is contained in:
1 parent
0ddcc60015
commit
916ca60e80
3 files changed
+124
-7
No files matched your search
@@ -392,7 +392,11 @@ pub(crate) fn enumerate(doc: &Document) -> (Vec<Field>, usize) {
|
||||
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);
|
||||
// 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).
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -512,10 +516,11 @@ fn walk(
|
||||
// An explicit zero also clears an inherited limit rather than inheriting it again.
|
||||
inh.max_len = usize::try_from(m).ok().filter(|limit| *limit > 0);
|
||||
}
|
||||
let kids: Vec<ObjRef> =
|
||||
let mut kids: Vec<ObjRef> =
|
||||
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<ObjRef> = kids.iter().copied().filter(|k| doc.get(*k).as_dict().is_some_and(|kd| kd.contains(b"T"))).collect();
|
||||
let (field_kids, widget_kids): (Vec<ObjRef>, Vec<ObjRef>) =
|
||||
kids.iter().copied().partition(|k| doc.get(*k).as_dict().is_some_and(|kd| kd.contains(b"T")));
|
||||
if !field_kids.is_empty() {
|
||||
for k in field_kids {
|
||||
// A fully qualified name is built up the field's own `/Parent` chain (ISO 32000-2
|
||||
@@ -530,7 +535,13 @@ fn walk(
|
||||
walk(doc, k, &own, page_of, seen, out, 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,
|
||||
// read as a field of this name next to the named kids.
|
||||
kids = widget_kids.into_iter().filter(|k| doc.get(*k).as_dict().is_some_and(|kd| kd.name(b"Subtype") == Some(b"Widget"))).collect();
|
||||
if kids.is_empty() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
// A field of no kind this reads is skipped before it claims widgets or scripts, which a later
|
||||
// field listing them then reads.
|
||||
@@ -545,7 +556,12 @@ fn walk(
|
||||
Some(b"Sig") => FieldKind::Signature,
|
||||
_ => return,
|
||||
};
|
||||
let widget_refs: Vec<ObjRef> = if d.contains(b"Rect") { vec![r] } else { kids };
|
||||
// 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
|
||||
// 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 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);
|
||||
let button = (inh.ft.as_deref() == Some(b"Btn") && inh.ff.unwrap_or(0) & flags::PUSH_BUTTON != 0)
|
||||
|
||||
@@ -2375,6 +2375,107 @@ fn a_radio_choice_by_the_name_turns_one_button_on_in_kids_without_a_page_that_di
|
||||
}
|
||||
}
|
||||
|
||||
/// A radio group whose field dictionary is merged with a zero-area widget and also lists four
|
||||
/// widget kids, as some form exporters write it: field and widget may be merged only "when a field
|
||||
/// has only a single associated widget annotation" (ISO 32000-2 §12.7.4), so the kids are the
|
||||
/// buttons. Two such groups: each lists its four options, and choosing one by the name turns on
|
||||
/// exactly that button, across a save, and leaves the other group alone.
|
||||
#[test]
|
||||
fn a_field_merged_with_a_widget_that_lists_widget_kids_has_the_kids_as_buttons() {
|
||||
const OPTIONS: [&str; 4] = ["Not#20Satisfied", "Somewhat", "Satisfied", "Very#20Satisfied"];
|
||||
let group = |t: &str, first: u32| {
|
||||
let kids: Vec<String> = (first..first + 4).map(|n| format!("{n} 0 R")).collect();
|
||||
format!("<< /Type /Annot /Subtype /Widget /Rect [0 0 0 0] /FT /Btn /Ff 49152 /Kids [{}] /V /Off /T ({t}) >>", kids.join(" "))
|
||||
};
|
||||
let kid = |parent: u32, i: usize, y: u32| {
|
||||
let state = OPTIONS.get(i).copied().unwrap_or("X");
|
||||
let x = 100 + 100 * u32::try_from(i).unwrap_or(0);
|
||||
format!(
|
||||
"<< /Type /Annot /Subtype /Widget /F 4 /FT /Btn /Ff 49152 /Parent {parent} 0 R /Rect [{x} {y} {} {}] /P 3 0 R /AS /Off /AP << /N << /{state} 14 0 R /Off 14 0 R >> >> >>",
|
||||
x + 12,
|
||||
y + 12
|
||||
)
|
||||
};
|
||||
let mut 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 [6 0 R 7 0 R 8 0 R 9 0 R 10 0 R 11 0 R 12 0 R 13 0 R] >>".into(), // 3
|
||||
group("typeA15[0]", 6), // 4
|
||||
group("typeA15[1]", 10), // 5
|
||||
];
|
||||
objs.extend((0..4).map(|i| kid(4, i, 600))); // 6-9
|
||||
objs.extend((0..4).map(|i| kid(5, i, 560))); // 10-13
|
||||
objs.push("<< /Length 0 >>\nstream\n\nendstream".into()); // 14
|
||||
let mut doc = document(&objs);
|
||||
let all = fields(&doc);
|
||||
assert_eq!(all.iter().map(|f| f.name.as_str()).collect::<Vec<_>>(), ["typeA15[0]", "typeA15[1]"]);
|
||||
let want: Vec<String> = ["Not Satisfied", "Somewhat", "Satisfied", "Very Satisfied"].map(String::from).to_vec();
|
||||
for f in &all {
|
||||
assert_eq!(f.widgets.iter().filter_map(|w| w.on_state.clone()).collect::<Vec<_>>(), want, "{}", f.name);
|
||||
assert!(f.widgets.iter().all(|w| w.page == Some(0) && w.rect[2] > w.rect[0]), "{}: the kids, on the page", f.name);
|
||||
}
|
||||
let st = |doc: &Document, n: u32| doc.get(ObjRef { num: n, generation: 0 }).as_dict().and_then(|d| d.name(b"AS").map(<[u8]>::to_vec));
|
||||
for (name, choice, on) in [("typeA15[0]", "Satisfied", 8), ("typeA15[1]", "Very Satisfied", 13), ("typeA15[0]", "Not Satisfied", 6)] {
|
||||
set_value(&mut doc, name, &FieldValue::Radio(Some(choice.into()))).unwrap_or_else(|e| panic!("{name} = {choice}: {e}"));
|
||||
doc = reopen(&doc);
|
||||
let first = if name == "typeA15[0]" { 6 } else { 10 };
|
||||
for n in first..first + 4 {
|
||||
let want = if n == on { choice.as_bytes().to_vec() } else { b"Off".to_vec() };
|
||||
assert_eq!(st(&doc, n), Some(want), "{name} = {choice}: widget {n}");
|
||||
}
|
||||
}
|
||||
assert_eq!(st(&doc, 13), Some(b"Very Satisfied".to_vec()), "the other group keeps its choice");
|
||||
let values: Vec<Vec<String>> = fields(&doc).into_iter().map(|f| f.value).collect();
|
||||
assert_eq!(values, [vec!["Not Satisfied".to_string()], vec!["Very Satisfied".to_string()]]);
|
||||
}
|
||||
|
||||
/// Radio buttons named `2` listed straight in `/Fields`, some fields of their own (`/T (2)`, no
|
||||
/// `/Parent`) and some nameless widgets of an unlisted field `2` whose `/Kids` mix both: "A
|
||||
/// field's children in the hierarchy may also include widget annotations" (ISO 32000-2 §12.7.4).
|
||||
/// Every option is listed under the name `2`, and each, including the nameless widgets', can be
|
||||
/// chosen by the name, leaving exactly that button on across a save.
|
||||
#[test]
|
||||
fn nameless_widgets_of_an_unlisted_field_are_options_of_its_name() {
|
||||
let named = |state: &str, x: u32| {
|
||||
format!(
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Btn /Ff 49152 /T (2) /V /Off /AS /Off /Rect [{x} 700 {} 712] /P 3 0 R /AP << /N << /{state} 9 0 R /Off 9 0 R >> >> >>",
|
||||
x + 12
|
||||
)
|
||||
};
|
||||
let nameless = |state: &str, x: u32| {
|
||||
format!(
|
||||
"<< /Type /Annot /Subtype /Widget /Parent 4 0 R /AS /Off /Rect [{x} 700 {} 712] /P 3 0 R /AP << /N << /{state} 9 0 R /Off 9 0 R >> >> >>",
|
||||
x + 12
|
||||
)
|
||||
};
|
||||
let objs: Vec<String> = vec![
|
||||
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 0 R 6 0 R 7 0 R 8 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 6 0 R 7 0 R 8 0 R] >>".into(), // 3
|
||||
"<< /FT /Btn /Ff 49152 /T (2) /Kids [5 0 R 6 0 R 7 0 R 8 0 R] >>".into(), // 4 unlisted
|
||||
named("0", 50), // 5
|
||||
named("1", 100), // 6
|
||||
nameless("2", 150), // 7
|
||||
nameless("6", 200), // 8
|
||||
"<< /Length 0 >>\nstream\n\nendstream".into(), // 9
|
||||
];
|
||||
let mut doc = document(&objs);
|
||||
let all = fields(&doc);
|
||||
assert!(all.iter().all(|f| f.name == "2"), "{:?}", all.iter().map(|f| &f.name).collect::<Vec<_>>());
|
||||
let mut options: Vec<String> = all.iter().flat_map(|f| &f.widgets).filter_map(|w| w.on_state.clone()).collect();
|
||||
options.sort();
|
||||
assert_eq!(options, ["0", "1", "2", "6"]);
|
||||
let st = |doc: &Document, n: u32| doc.get(ObjRef { num: n, generation: 0 }).as_dict().and_then(|d| d.name(b"AS").map(<[u8]>::to_vec));
|
||||
for (choice, on) in [("6", 8), ("1", 6), ("2", 7), ("0", 5)] {
|
||||
set_value(&mut doc, "2", &FieldValue::Radio(Some(choice.into()))).unwrap_or_else(|e| panic!("2 = {choice}: {e}"));
|
||||
doc = reopen(&doc);
|
||||
for n in 5..=8 {
|
||||
let want = if n == on { choice.as_bytes().to_vec() } else { b"Off".to_vec() };
|
||||
assert_eq!(st(&doc, n), Some(want), "2 = {choice}: widget {n}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locked_fields_only_take_unlocking() {
|
||||
let mut doc = one_page();
|
||||
|
||||
@@ -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", "crates/render/src/inspect/tests.rs::a_named_kid_without_a_parent_keeps_its_own_name", "crates/forms/src/tests.rs::a_named_kid_without_a_parent_keeps_its_own_name", "crates/forms/src/tests.rs::a_radio_option_of_any_field_sharing_the_name_can_be_chosen", "crates/forms/src/tests.rs::a_radio_choice_by_the_name_turns_one_button_on_in_kids_without_a_page_that_disagree"]
|
||||
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; a named kid whose /Parent does not name the field listing it is named up its own /Parent chain; fields sharing a name are listed apart, and a radio option any of them draws is chosen by the name, leaving one button on even when their kids have no /P and were saved both on; every field of the name, and a field listing them that shares it, takes the chosen /V"
|
||||
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", "crates/render/src/inspect/tests.rs::a_named_kid_without_a_parent_keeps_its_own_name", "crates/forms/src/tests.rs::a_named_kid_without_a_parent_keeps_its_own_name", "crates/forms/src/tests.rs::a_radio_option_of_any_field_sharing_the_name_can_be_chosen", "crates/forms/src/tests.rs::a_radio_choice_by_the_name_turns_one_button_on_in_kids_without_a_page_that_disagree", "crates/forms/src/tests.rs::a_field_merged_with_a_widget_that_lists_widget_kids_has_the_kids_as_buttons", "crates/forms/src/tests.rs::nameless_widgets_of_an_unlisted_field_are_options_of_its_name"]
|
||||
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; a named kid whose /Parent does not name the field listing it is named up its own /Parent chain; fields sharing a name are listed apart, and a radio option any of them draws is chosen by the name, leaving one button on even when their kids have no /P and were saved both on; every field of the name, and a field listing them that shares it, takes the chosen /V; a field dictionary merged with a widget that also lists widget kids has those kids as its buttons; widget annotations listed beside named kids, or listed in /Fields under an unlisted /Parent, are read as buttons of that parent's name"
|
||||
|
||||
[[feature]]
|
||||
id = "form.highlight-fields"
|
||||
|
||||
Reference in new issue
Block a user