Merge #5122: fix(xfa): AcroForm field walks read each field once and bound what they queue

# Conflicts:
#	crates/xfa/src/pdf.rs
#	crates/xfa/src/tests.rs
This commit is contained in:
bflatastic
2026-11-09 18:33:53 +07:00
3 changed files with 356 additions and 32 deletions
+223 -15
View File
@@ -110,10 +110,12 @@ struct Signed {
appearance: Option<Object>,
}
/// Most fields looked at for signatures.
/// Most fields looked at for signatures, counted as they are queued.
const MAX_FIELDS_SEARCHED: usize = 20_000;
/// Deepest field nesting followed.
const MAX_FIELD_DEPTH: usize = 32;
/// Most `/Fields` and `/Kids` entries read while looking, repeats included.
const MAX_KIDS_READ: usize = 1 << 20;
/// The fields the document came with: the full name of each, and the signed signature fields by
/// SOM path. Signing applications name a dynamic form's fields by their SOM path
@@ -125,22 +127,18 @@ fn existing_fields(doc: &Document, catalog: &Dict) -> (HashMap<String, Signed>,
let mut names = HashSet::new();
let Some(acro) = catalog.get(b"AcroForm").map(|a| doc.resolve(a)) else { return (out, names) };
let Some(fields) = acro.as_dict().and_then(|a| a.get(b"Fields")).map(|f| doc.resolve(f)) else { return (out, names) };
let mut walk = FieldQueue::default();
// The accumulated name is shared by the children it is queued for, not copied into each.
let mut stack: Vec<(Object, std::rc::Rc<str>, bool, usize)> =
fields.as_array().into_iter().flatten().map(|f| (f.clone(), "".into(), false, 0)).collect();
let mut seen = 0usize;
while let Some((field, prefix, parent_is_sig, depth)) = stack.pop() {
seen += 1;
if seen > MAX_FIELDS_SEARCHED {
break;
}
let field = doc.resolve(&field);
let top: std::rc::Rc<str> = "".into();
walk.queue(fields.as_array().map(Vec::as_slice).unwrap_or_default(), &top, false, 0);
while let Some((field, prefix, parent_is_sig, depth)) = walk.stack.pop() {
let field = doc.get(field);
let Some(d) = field.as_dict() else { continue };
if d.contains(NODE_KEY) {
// Generated to name a signature field: only what it holds is looked at.
if depth < MAX_FIELD_DEPTH {
let kids = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_default();
stack.extend(kids.into_iter().map(|k| (k, "".into(), false, depth + 1)));
let kids = d.get(b"Kids").map(|k| doc.resolve(k));
walk.queue(kids.as_deref().and_then(Object::as_array).map(Vec::as_slice).unwrap_or_default(), &top, false, depth + 1);
}
continue;
}
@@ -166,14 +164,15 @@ fn existing_fields(doc: &Document, catalog: &Dict) -> (HashMap<String, Signed>,
Some(ft) => ft == b"Sig",
None => parent_is_sig,
};
let kids: Vec<Object> = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_default();
let kids = d.get(b"Kids").map(|k| doc.resolve(k));
let kids = kids.as_deref().and_then(Object::as_array).map(Vec::as_slice).unwrap_or_default();
if is_sig && let Some(value) = d.get(b"V").filter(|v| doc.resolve(v).as_dict().is_some()) {
// The appearance is on the field when it is its own widget, or on its one widget.
let appearance = d.get(b"AP").cloned().or_else(|| kids.iter().find_map(|k| doc.resolve(k).as_dict().and_then(|k| k.get(b"AP").cloned())));
let appearance = d.get(b"AP").cloned().or_else(|| walk.find_map(kids, |k| doc.resolve(k).as_dict().and_then(|k| k.get(b"AP").cloned())));
out.entry(name.to_string()).or_insert(Signed { value: value.clone(), appearance });
}
if depth < MAX_FIELD_DEPTH {
stack.extend(kids.into_iter().map(|k| (k, name.clone(), is_sig, depth + 1)));
walk.queue(kids, &name, is_sig, depth + 1);
}
}
(out, names)
@@ -187,6 +186,64 @@ fn required_flag(w: &Widget) -> i64 {
if w.required && !matches!(w.kind, WidgetKind::Radio { .. } | WidgetKind::Button { .. }) { 1 << 1 } else { 0 }
}
/// The fields [`existing_fields`] still has to look at: each with its parent's full name,
/// whether it inherits a signature type, and its depth.
#[derive(Default)]
struct FieldQueue {
stack: Vec<(ObjRef, std::rc::Rc<str>, bool, usize)>,
/// The shallowest depth each object number was queued at: [`Document::get`] finds a field
/// by number alone, so `5 0 R` and `5 1 R` are the same field.
seen: HashMap<u32, usize>,
/// Entries pushed onto `stack`, a field queued again at a shallower depth included.
pushed: usize,
/// `/Fields` and `/Kids` entries read while queuing.
read: usize,
/// `/Kids` entries read while searching for a signed field's appearance.
searched: usize,
}
impl FieldQueue {
/// The first of `kids` for which `f` gives a value. At most [`MAX_KIDS_READ`] entries are
/// looked at in all: signed fields sharing one long `/Kids` would otherwise each search all
/// of it. The searches have a budget of their own, apart from the one for queuing, so a
/// search can never leave a field's kids unqueued. Past the limit nothing more is looked at.
fn find_map<T>(&mut self, kids: &[Object], mut f: impl FnMut(&Object) -> Option<T>) -> Option<T> {
for k in kids {
self.searched += 1;
if self.searched > MAX_KIDS_READ {
return None;
}
if let Some(v) = f(k) {
return Some(v);
}
}
None
}
/// Queue the fields `kids` lists, each the first time it is listed. Fields and kids are
/// indirect references (ISO 32000-2 §12.7.3.1); anything else is skipped. A field listed
/// again, by this list or another, under any generation, takes no room, unless it is now
/// shallower than where it was queued: first reached at [`MAX_FIELD_DEPTH`], its kids would
/// otherwise never be read. So a field is queued at most once per depth, and at most
/// [`MAX_FIELDS_SEARCHED`] entries are ever queued: a field listing one kid many times, or
/// fields sharing one long `/Kids`, would otherwise queue it all on every visit. Reading the
/// repeats costs too, so at most [`MAX_KIDS_READ`] entries are read in all.
fn queue(&mut self, kids: &[Object], prefix: &std::rc::Rc<str>, is_sig: bool, depth: usize) {
for k in kids {
self.read += 1;
if self.pushed >= MAX_FIELDS_SEARCHED || self.read > MAX_KIDS_READ {
return;
}
let Some(r) = k.as_ref() else { continue };
if self.seen.get(&r.num).is_none_or(|&queued| depth < queued) {
self.seen.insert(r.num, depth);
self.pushed += 1;
self.stack.push((r, std::rc::Rc::clone(prefix), is_sig, depth));
}
}
}
}
impl Emitter<'_> {
fn da(face: &Face) -> Vec<u8> {
format!("/{} {} Tf {} rg", face.resource_name(), n(face.size), rgb(face.color)).into_bytes()
@@ -1286,6 +1343,157 @@ pub fn pdf_rect(r: Rect, page_height: f64) -> [f64; 4] {
mod tests {
use super::*;
/// The fields `existing_fields` finds under an AcroForm listing `fields`.
fn names_found(doc: &Document, fields: Vec<Object>) -> Vec<String> {
let (signed, names) = existing(doc, fields);
assert!(signed.is_empty());
let mut names: Vec<String> = names.into_iter().collect();
names.sort();
names
}
fn field(name: &str, kids: Object) -> Object {
let mut d = Dict::new();
d.set(b"T".to_vec(), Object::String(PdfString::literal(name)));
d.set(b"Kids".to_vec(), kids);
Object::Dict(d)
}
#[test]
fn fields_listed_many_times_queue_a_bounded_number_of_kids() {
// A field lists itself 5,000 times: queuing every kid on each of 20,000 visits would
// hold a hundred million entries. It is read once.
let mut doc = Document::new_empty();
let r = doc.add(Object::Null);
doc.set(r, field("a", Object::Array(vec![Object::Ref(r); 5_000])));
assert_eq!(names_found(&doc, vec![Object::Ref(r)]), ["a"]);
// 5,000 fields share one /Kids array listing each of them 200 times. Read once each,
// the fields down one branch still queue the whole array at every level: 32 million
// entries, unless the queue itself is bounded.
let mut doc = Document::new_empty();
let kids = doc.add(Object::Null);
let fields: Vec<Object> = (0..5_000).map(|i| Object::Ref(doc.add(field(&format!("f{i}"), Object::Ref(kids))))).collect();
doc.set(kids, Object::Array(fields.iter().cycle().take(1_000_000).cloned().collect()));
let names = names_found(&doc, fields[..1].to_vec());
assert!(names.contains(&"f0".to_string()) && names.len() <= MAX_FIELDS_SEARCHED, "{} names", names.len());
}
#[test]
fn a_field_listed_many_times_leaves_room_for_the_fields_under_it() {
// The AcroForm lists one field 19,999 times. The copies take no room, so its kids are
// still queued, and the signed one among them is found.
let mut doc = Document::new_empty();
let parent = signed_parent(&mut doc);
let (signed, names) = existing(&doc, vec![Object::Ref(parent); MAX_FIELDS_SEARCHED - 1]);
assert!(signed.contains_key("c.sig"), "{:?}", signed.keys().collect::<Vec<_>>());
assert!(names.contains("c.plain"));
}
#[test]
fn a_field_listed_under_many_generations_is_queued_once() {
// `Document::get` finds a field by object number alone, so `5 0 R` and `5 1 R` are one
// field. Listed 19,999 times, each under another generation, it is still queued once,
// and the signed field under it is found.
let mut doc = Document::new_empty();
let parent = signed_parent(&mut doc);
let fields = (0..MAX_FIELDS_SEARCHED - 1).map(|g| Object::Ref(ObjRef::new(parent.num, u16::try_from(g).unwrap()))).collect();
let (signed, names) = existing(&doc, fields);
assert!(signed.contains_key("c.sig"), "{:?}", signed.keys().collect::<Vec<_>>());
assert!(names.contains("c.plain"));
}
#[test]
fn signed_fields_sharing_one_long_kids_array_search_it_within_the_read_limit() {
// 1,000 signed fields share one `/Kids` array of 100,000 nulls and a widget with an
// appearance at its end. Each searched the whole array for that appearance: with a
// million nulls, a billion lookups (39 s). The search counts against `MAX_KIDS_READ`, so
// the fields read once it is spent keep their value but get no appearance.
let mut doc = Document::new_empty();
let mut widget = Dict::new();
widget.set(b"AP".to_vec(), Object::Dict(Dict::new()));
let mut shared = vec![Object::Null; 100_000];
shared.push(Object::Ref(doc.add(Object::Dict(widget))));
let kids = doc.add(Object::Array(shared));
let fields = (0..1_000)
.map(|i| {
let mut d = Dict::new();
d.set(b"T".to_vec(), Object::String(PdfString::literal(format!("s{i}").as_str())));
d.set(b"FT".to_vec(), Object::Name(b"Sig".to_vec()));
d.set(b"V".to_vec(), Object::Dict(Dict::new()));
d.set(b"Kids".to_vec(), Object::Ref(kids));
Object::Ref(doc.add(Object::Dict(d)))
})
.collect();
let (signed, _) = existing(&doc, fields);
assert_eq!(signed.len(), 1_000);
let with_appearance = signed.values().filter(|s| s.appearance.is_some()).count();
assert!((1..=10).contains(&with_appearance), "{with_appearance} appearances found");
}
#[test]
fn an_appearance_search_leaves_the_kids_of_a_signed_field_queued() {
// A signed field with no appearance of its own lists a signed kid and then
// `MAX_KIDS_READ` nulls. Searching them all for an appearance must not spend what
// queuing its kids has to read, or the signed kid is never found.
let mut doc = Document::new_empty();
let mut kid = Dict::new();
kid.set(b"T".to_vec(), Object::String(PdfString::literal("c")));
kid.set(b"V".to_vec(), Object::Dict(Dict::new()));
let mut kids = vec![Object::Ref(doc.add(Object::Dict(kid)))];
kids.extend(std::iter::repeat_n(Object::Null, MAX_KIDS_READ));
let mut parent = Dict::new();
parent.set(b"T".to_vec(), Object::String(PdfString::literal("p")));
parent.set(b"FT".to_vec(), Object::Name(b"Sig".to_vec()));
parent.set(b"V".to_vec(), Object::Dict(Dict::new()));
parent.set(b"Kids".to_vec(), Object::Array(kids));
let parent = doc.add(Object::Dict(parent));
let (signed, _) = existing(&doc, vec![Object::Ref(parent)]);
assert!(signed.contains_key("p.c"), "{:?}", signed.keys().collect::<Vec<_>>());
}
#[test]
fn a_field_reached_first_at_the_depth_limit_is_read_again_when_reached_shallower() {
// Field `s` is listed at the bottom of a chain `MAX_FIELD_DEPTH` deep, which is walked
// first, and again under `b`. Reached first where its kids are not read, it is read
// again under `b`, and the signed field under it is found.
let mut doc = Document::new_empty();
let mut sig = Dict::new();
sig.set(b"T".to_vec(), Object::String(PdfString::literal("sig")));
sig.set(b"FT".to_vec(), Object::Name(b"Sig".to_vec()));
sig.set(b"V".to_vec(), Object::Dict(Dict::new()));
let sig = doc.add(Object::Dict(sig));
let shared = doc.add(field("s", Object::Array(vec![Object::Ref(sig)])));
let mut deep = shared;
for i in (0..MAX_FIELD_DEPTH).rev() {
deep = doc.add(field(&format!("d{i}"), Object::Array(vec![Object::Ref(deep)])));
}
let b = doc.add(field("b", Object::Array(vec![Object::Ref(shared)])));
let (signed, names) = existing(&doc, vec![Object::Ref(b), Object::Ref(deep)]);
assert!(signed.contains_key("b.s.sig"), "{:?}", signed.keys().collect::<Vec<_>>());
assert!(names.contains("b.s"));
}
/// Field `c` with two kids: `plain`, and `sig`, a signed signature field.
fn signed_parent(doc: &mut Document) -> ObjRef {
let plain = doc.add(field("plain", Object::Array(Vec::new())));
let mut sig = Dict::new();
sig.set(b"T".to_vec(), Object::String(PdfString::literal("sig")));
sig.set(b"FT".to_vec(), Object::Name(b"Sig".to_vec()));
sig.set(b"V".to_vec(), Object::Dict(Dict::new()));
let sig = doc.add(Object::Dict(sig));
doc.add(field("c", Object::Array(vec![Object::Ref(plain), Object::Ref(sig)])))
}
/// What `existing_fields` finds under an AcroForm listing `fields`.
fn existing(doc: &Document, fields: Vec<Object>) -> (HashMap<String, Signed>, HashSet<String>) {
let mut acro = Dict::new();
acro.set(b"Fields".to_vec(), Object::Array(fields));
let mut catalog = Dict::new();
catalog.set(b"AcroForm".to_vec(), Object::Dict(acro));
existing_fields(doc, &catalog)
}
#[test]
fn parse_hex32_rejects_non_hex_digits() {
for prefix in ["+f", "-f", " f"] {
+39 -17
View File
@@ -16,6 +16,9 @@ pub const OVERRIDES_KEY: &[u8] = b"PCXfaOverrides";
const MAX_DEPTH: usize = 64;
const MAX_EVENTS: usize = 20_000;
/// Most `/Fields` and `/Kids` entries [`fields_by_som`] looks at in all: fields already read, and
/// entries that are not references, included.
const MAX_FIELD_STEPS: usize = 1 << 20;
/// Most bytes of script the events hold together: each instance of a repeated subform holds its
/// own copy, so a long script repeated many times stops here (as much as the XFA packets hold).
pub(crate) const MAX_EVENT_SCRIPT_BYTES: usize = 64 << 20;
@@ -576,12 +579,35 @@ pub fn apply_overrides(tpl: &Template, ov: &Overrides) -> Template {
/// The generated fields of the AcroForm by SOM path: `som → (field name, is a radio group)`.
pub fn fields_by_som(doc: &Document) -> HashMap<String, String> {
fn walk(doc: &Document, r: pdfcraft_cos::ObjRef, prefix: &str, out: &mut HashMap<String, String>, depth: usize) {
if depth > MAX_DEPTH || out.len() > 100_000 {
return;
// Each field is read once: a field listed twice in `/Kids` (or among its own kids) would
// otherwise be walked again for every path to it, doubling the work at each level. Fields
// are told apart by object number, as `Document::get` finds them, so `5 0 R` and `5 1 R`
// are one. A field is read again only when it is reached shallower than before: first reached
// at `MAX_DEPTH`, its kids would otherwise never be read. `steps` counts every entry looked at, references or not, so fields sharing one
// long `/Kids` array stay bounded too; `walk_all` returns false once it is spent, and every
// loop then stops.
struct Walk {
out: HashMap<String, String>,
/// The shallowest depth each object number was read at.
seen: HashMap<u32, usize>,
steps: usize,
}
fn walk_all(doc: &Document, kids: &[Object], prefix: &str, w: &mut Walk, depth: usize) -> bool {
kids.iter().all(|k| {
w.steps += 1;
if w.steps > MAX_FIELD_STEPS || w.out.len() > 100_000 {
return false;
}
k.as_ref().is_none_or(|r| walk(doc, r, prefix, w, depth))
})
}
fn walk(doc: &Document, r: pdfcraft_cos::ObjRef, prefix: &str, w: &mut Walk, depth: usize) -> bool {
if depth > MAX_DEPTH || w.seen.get(&r.num).is_some_and(|&read| read <= depth) {
return true;
}
w.seen.insert(r.num, depth);
let o = doc.get(r);
let Some(d) = o.as_dict() else { return };
let Some(d) = o.as_dict() else { return true };
let t = d.get(b"T").map(|t| doc.resolve(t)).and_then(|t| t.as_string().map(|s| s.to_text()));
let name = match (&t, prefix.is_empty()) {
(Some(t), true) => t.clone(),
@@ -591,21 +617,17 @@ pub fn fields_by_som(doc: &Document) -> HashMap<String, String> {
if let Some(som) = d.get(crate::pdf::SOM_KEY).map(|s| doc.resolve(s)).and_then(|s| s.as_string().map(|s| s.to_text()))
&& t.is_some()
{
out.insert(som, name.clone());
}
if let Some(kids) = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()) {
for k in kids.iter().filter_map(Object::as_ref) {
walk(doc, k, &name, out, depth + 1);
}
w.out.insert(som, name.clone());
}
// Read in place: a copy of the array at every level would cost its length each time.
let kids = d.get(b"Kids").map(|k| doc.resolve(k));
walk_all(doc, kids.as_deref().and_then(Object::as_array).map(Vec::as_slice).unwrap_or_default(), &name, w, depth + 1)
}
let mut out = HashMap::new();
let Some((_, acro)) = acroform(doc) else { return out };
let fields = acro.get(b"Fields").map(|f| doc.resolve(f)).and_then(|f| f.as_array().cloned()).unwrap_or_default();
for f in fields.iter().filter_map(Object::as_ref) {
walk(doc, f, "", &mut out, 0);
}
out
let Some((_, acro)) = acroform(doc) else { return HashMap::new() };
let fields = acro.get(b"Fields").map(|f| doc.resolve(f));
let mut w = Walk { out: HashMap::new(), seen: HashMap::new(), steps: 0 };
walk_all(doc, fields.as_deref().and_then(Object::as_array).map(Vec::as_slice).unwrap_or_default(), "", &mut w, 0);
w.out
}
/// What a reset gives the field or exclusion group at `som` in the data: its template default
+94
View File
@@ -5313,6 +5313,100 @@ fn packets_whose_text_grows_past_the_template_limit_are_refused() {
assert!(matches!(read_packets(&doc), Err(XfaError::TooLarge(_))));
}
#[test]
fn fields_by_som_reads_a_field_listed_many_times_once() {
// A field lists itself twice and its one widget twice: walked once per path to it, that is
// 2^64 visits at the depth limit.
let mut doc = Document::new_empty();
let widget = Object::Ref(som_field(&mut doc, "name", Some("form1[0].name[0]"), None));
let parent = doc.add(Object::Null);
let kids = Object::Array(vec![Object::Ref(parent), widget.clone(), Object::Ref(parent), widget]);
doc.set(parent, field_named("form1", None, Some(kids)));
let by_som = fields_by_som_of(doc, vec![Object::Ref(parent)]);
assert_eq!(by_som.len(), 1, "{by_som:?}");
assert_eq!(by_som.get("form1[0].name[0]").map(String::as_str), Some("form1.name"));
}
#[test]
fn fields_by_som_reads_a_field_listed_under_many_generations_once() {
// `Document::get` finds a field by object number alone, so `5 0 R` and `5 1 R` are one field.
// The AcroForm lists one under 2,000 generations: read again for each, its 1,000 kids would
// spend the walk's 2^20 steps before the field listed after it.
let mut doc = Document::new_empty();
let leaf = som_field(&mut doc, "leaf", None, None);
let a = som_field(&mut doc, "a", None, Some(Object::Array(vec![Object::Ref(leaf); 1_000])));
let b = som_field(&mut doc, "b", Some("form1[0].b[0]"), None);
let mut fields: Vec<Object> = (0..2_000).map(|g| Object::Ref(pdfcraft_cos::ObjRef::new(a.num, g))).collect();
fields.push(Object::Ref(b));
let by_som = fields_by_som_of(doc, fields);
assert_eq!(by_som.get("form1[0].b[0]").map(String::as_str), Some("b"), "{by_som:?}");
}
#[test]
fn fields_by_som_counts_kids_that_are_not_references() {
// 2,000 fields share one `/Kids` array of 1,000 nulls. Skipped uncounted, the nulls would
// cost nothing against the walk's 2^20 steps, however many fields share however long an
// array. Counted, they spend the steps here, and the walk stops before the last field.
let mut doc = Document::new_empty();
let nulls = doc.add(Object::Array(vec![Object::Null; 1_000]));
let first = som_field(&mut doc, "first", Some("form1[0].first[0]"), None);
let last = som_field(&mut doc, "last", Some("form1[0].last[0]"), None);
let mut fields = vec![Object::Ref(first)];
fields.extend((0..2_000).map(|i| Object::Ref(som_field(&mut doc, &format!("f{i}"), None, Some(Object::Ref(nulls))))));
fields.push(Object::Ref(last));
let by_som = fields_by_som_of(doc, fields);
assert!(by_som.contains_key("form1[0].first[0]"), "{by_som:?}");
assert!(!by_som.contains_key("form1[0].last[0]"), "{by_som:?}");
}
#[test]
fn fields_by_som_reads_a_field_again_when_it_is_reached_shallower() {
// Field `s` is listed at the bottom of a chain 64 deep, which is walked first, and again
// under `b`. Reached first where its kids are past the depth limit, it is read again under
// `b`, and the field under it is found by its SOM path.
let mut doc = Document::new_empty();
let leaf = som_field(&mut doc, "leaf", Some("form1[0].leaf[0]"), None);
let shared = som_field(&mut doc, "s", None, Some(Object::Array(vec![Object::Ref(leaf)])));
let mut deep = shared;
for i in (0..64).rev() {
deep = som_field(&mut doc, &format!("d{i}"), None, Some(Object::Array(vec![Object::Ref(deep)])));
}
let b = som_field(&mut doc, "b", None, Some(Object::Array(vec![Object::Ref(shared)])));
let by_som = fields_by_som_of(doc, vec![Object::Ref(deep), Object::Ref(b)]);
assert_eq!(by_som.get("form1[0].leaf[0]").map(String::as_str), Some("b.s.leaf"), "{by_som:?}");
}
/// An AcroForm field named `t`, with SOM path `som` and `kids` when given.
fn field_named(t: &str, som: Option<&str>, kids: Option<Object>) -> Object {
let s = |t: &str| Object::String(pdfcraft_cos::PdfString::literal(t));
let mut d = Dict::new();
d.set(b"T".to_vec(), s(t));
if let Some(som) = som {
d.set(crate::pdf::SOM_KEY.to_vec(), s(som));
}
if let Some(kids) = kids {
d.set(b"Kids".to_vec(), kids);
}
Object::Dict(d)
}
/// [`field_named`], added to `doc`.
fn som_field(doc: &mut Document, t: &str, som: Option<&str>, kids: Option<Object>) -> pdfcraft_cos::ObjRef {
doc.add(field_named(t, som, kids))
}
/// [`fields_by_som`] of `doc`, its catalog's AcroForm listing `fields`.
fn fields_by_som_of(mut doc: Document, fields: Vec<Object>) -> std::collections::HashMap<String, String> {
let mut acro = Dict::new();
acro.set(b"Fields".to_vec(), Object::Array(fields));
let mut cat = Dict::new();
cat.set(b"Type".to_vec(), Object::Name(b"Catalog".to_vec()));
cat.set(b"AcroForm".to_vec(), Object::Dict(acro));
let cat = doc.add(Object::Dict(cat));
doc.trailer_mut().set(b"Root".to_vec(), Object::Ref(cat));
fields_by_som(&doc)
}
/// A form with `draws` in a subform placed by position, on a letter page whose content area is a
/// quarter inch in, with `master` on the page itself.
fn form_of_draws(master: &str, draws: &str) -> String {