mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-09 12:32:52 +00:00
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:
1 parent
916ca60e80
commit
eee81da38b
5 files changed
+194
-14
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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]))]
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user