From 6918d0be21317b971b45f1aff44d4f2958d7adf3 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sun, 25 Oct 2026 22:02:35 +0800 Subject: [PATCH] M6: a static XFA form whose data fills thousands of fields opens in seconds (#4638) Opening a static XFA form gives its fields the values in its datasets (a form filled in by another application). Each value went through forms::set_value, which reads the whole field tree to find the field, writes it, and recalculates, reading the tree again. With n values that is about 2n readings of n fields: quadratic. A public 136-page form with about 6,000 fields took 261 s to open, a 95-page one with about 4,000 took 112 s; 8,000 synthetic fields took over ten minutes. forms::set_values fills a batch as set_value fills each in turn, reading the form once and recalculating once after the last value (the calculation order, ISO 32000-2 Table 224 /CO, runs once over the final values). Fields with the same fully qualified name (12.7.4.2) still share the value. A name the batch already wrote, or a twin of it, makes the form be read again first, so each write starts from what the earlier one left, as one by one. Each value reports its own result; a failed recalculation fails the batch, as it fails set_value. The engine collects the changed values and fills them with set_values, finding fields by name through a map rather than a scan. The two forms now open in 4 s and 3 s, with the same fields, values, warnings and document information as before; 8,000 synthetic fields open in 1.5 s. Tests: set_values matches set_value one by one (values, twins, the calculated total, every appearance, saved; refusals for an unknown name, a read-only field, an unknown option and clearing a no-toggle-off radio group the batch selected); it reads the form a fixed number of times; the engine takes 59 data values on a 60-field static form exactly as filling them one by one does (the read-only one refused); and opening 800 fields takes under 16 times as long as 100 (about 4x now, about 48x before). Claude-Session: https://claude.ai/code/session_01CBVyQEwtmEw3mS1MoAiwrK Co-authored-by: Claude Opus 5.5 --- crates/engine/src/lib.rs | 20 ++++-- crates/engine/src/tests.rs | 68 ++++++++++++++++++ crates/forms/README.md | 2 + crates/forms/src/lib.rs | 92 ++++++++++++++++++++++-- crates/forms/src/tests.rs | 142 +++++++++++++++++++++++++++++++++++++ crates/xfa/src/fixtures.rs | 37 ++++++++++ 6 files changed, 350 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 3b075d2d97..224318f1d7 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -3533,9 +3533,14 @@ fn xfa_after_data_change(doc: &mut pdfcraft_cos::Document, cx: &mut EditCtx, cal fn xfa_values_from_datasets(doc: &mut pdfcraft_cos::Document) -> Result, String> { let data = xfa_field_data(doc); let fields = pdfcraft_forms::fields(doc); - let mut changed = Vec::new(); + // The first field of each name, as a lookup by name would find it. + let mut by_name = std::collections::HashMap::new(); + for f in &fields { + by_name.entry(f.name.as_str()).or_insert(f); + } + let mut updates = Vec::new(); for (name, value) in pdfcraft_xfa::read_values(doc, &data) { - let Some(f) = fields.iter().find(|f| f.name == name) else { continue }; + let Some(f) = by_name.get(name.as_str()) else { continue }; let new = match value { pdfcraft_xfa::FieldData::Text(t) if matches!(f.kind, pdfcraft_forms::FieldKind::Combo | pdfcraft_forms::FieldKind::List) => { // Saved values, one per line for a multi-select list; shown text stands for @@ -3553,13 +3558,14 @@ fn xfa_values_from_datasets(doc: &mut pdfcraft_cos::Document) -> Result (f.value.first() != sel.as_ref()).then_some(FieldValue::Radio(sel)), pdfcraft_xfa::FieldData::None => None, }; - if let Some(v) = new - && pdfcraft_forms::set_value(doc, &name, &v).is_ok() - { - changed.push(name); + if let Some(v) = new { + updates.push((name, v)); } } - Ok(changed) + // All at once: one by one, each value would read the whole form again, which took minutes + // on a form with thousands of fields. + let Ok(results) = pdfcraft_forms::set_values(doc, &updates) else { return Ok(Vec::new()) }; + Ok(updates.into_iter().zip(results).filter(|(_, r)| r.is_ok()).map(|((name, _), _)| name).collect()) } /// Validate the signature fields of `cos` (written as `bytes`). diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 368ecf0dc0..097a934f8e 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -5584,6 +5584,74 @@ fn static_xfa_forms_take_their_values_from_the_datasets_and_write_them_back() { assert!(!d2.info.warnings.iter().any(|w| w.contains("taken from this form's XFA data")), "nothing was rewritten"); } +/// A static XFA form whose values are only in its datasets takes every one on opening, as +/// filling the fields one at a time would leave it: values, appearances and the note; a +/// read-only field keeps its own value. +#[test] +fn a_static_xfa_form_takes_many_data_values_as_one_by_one() { + let n = 60; + let original = Arc::new(pdfcraft_xfa::fixtures::static_shell_many(n)); + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("many.pdf", None, original.clone(), None).expect("opens"); + let doc = s.get(id).unwrap(); + assert_eq!(doc.info.xfa, Some(pdfcraft_render::Xfa::Static)); + assert!(!doc.dirty && doc.bytes.starts_with(&original[..]), "the values are an appended revision"); + // The read-only first field keeps its own (empty) value, and is not named. + let note = doc.info.warnings.iter().find(|w| w.contains("taken from this form's XFA data")).expect("a warning"); + assert!(note.starts_with("59 form field values") && note.contains("form1[0].page1[0].f1[0]") && note.ends_with("and 54 more"), "{note}"); + assert!(!note.contains("f0[0]"), "{note}"); + // The same values filled one at a time. + let mut one_by_one = pdfcraft_cos::Document::open(original).unwrap(); + for i in 0..n { + let r = pdfcraft_forms::set_value(&mut one_by_one, &format!("form1[0].page1[0].f{i}[0]"), &FieldValue::Text(format!("v{i}"))); + assert_eq!(r.is_err(), i == 0, "field {i}: {r:?}"); + } + let opened = &doc.editor.as_ref().unwrap().cos; + let shown = |d: &pdfcraft_cos::Document| -> Vec<(String, Vec, String)> { + pdfcraft_forms::fields(d) + .into_iter() + .map(|f| { + let w = f.widgets.first().map(|w| w.obj).unwrap(); + let ap = d.get(w).as_dict().and_then(|w| w.get(b"AP").cloned()).map(|a| d.resolve(&a)); + let n = ap.and_then(|ap| ap.as_dict().and_then(|a| a.get(b"N").cloned())).map(|n| d.resolve(&n)); + let look = match n.as_deref() { + Some(pdfcraft_cos::Object::Stream(st)) => String::from_utf8_lossy(&st.decoded().unwrap()).into_owned(), + _ => String::new(), + }; + (f.name, f.value, look) + }) + .collect() + }; + let got = shown(opened); + assert_eq!(got.len(), n); + assert_eq!(got, shown(&one_by_one)); + assert!(got[0].1.is_empty()); + assert!(got.iter().enumerate().skip(1).all(|(i, (_, v, ap))| *v == [format!("v{i}")] && ap.contains(&format!("(v{i})")))); +} + +/// Opening a static XFA form whose datasets fill thousands of fields took minutes: each value +/// read the whole form again (twice), so the time grew with the square of the fields. Eight +/// times the fields now takes at most about eight times as long (it took about fifty). +#[test] +fn opening_a_static_xfa_form_grows_linearly_with_its_data_values() { + let time = |n: usize| { + let bytes = Arc::new(pdfcraft_xfa::fixtures::static_shell_many(n)); + (0..3) + .map(|_| { + let mut s = Session::new(); + let start = std::time::Instant::now(); + s.open("many.pdf", None, bytes.clone(), None).expect("opens"); + start.elapsed() + }) + .min() + .unwrap_or_default() + }; + let small = time(100); + let large = time(800); + let ratio = large.as_secs_f64() / small.as_secs_f64().max(1e-6); + assert!(ratio < 16.0, "100 fields: {small:?}, 800 fields: {large:?} ({ratio:.1}x)"); +} + /// A date goes into the XFA data in the field's data picture (``), as other /// viewers write and read it, and comes back from there when the form is opened again. It used /// to be written as an ISO date whatever the picture said. diff --git a/crates/forms/README.md b/crates/forms/README.md index 4aec223ee9..2c24ddc6cf 100644 --- a/crates/forms/README.md +++ b/crates/forms/README.md @@ -14,6 +14,8 @@ set_value(&mut doc, "name", &FieldValue::Text("Ada".into()))?; set_value(&mut doc, "agree", &FieldValue::Check(true))?; set_value(&mut doc, "size", &FieldValue::Radio(Some("L".into())))?; set_value(&mut doc, "toppings", &FieldValue::Choice(vec!["Ham".into()]))?; +// Many values: as set_value one by one, but reading the form once and recalculating once. +let results: Vec> = set_values(&mut doc, &[("name".into(), FieldValue::Text("Ada".into()))])?; reset(&mut doc, None)?; // Clear form (back to /DV) // Prepare a form diff --git a/crates/forms/src/lib.rs b/crates/forms/src/lib.rs index 48e96168b8..ffd67899cc 100644 --- a/crates/forms/src/lib.rs +++ b/crates/forms/src/lib.rs @@ -573,7 +573,21 @@ pub fn fields_with_adopted(doc: &Document) -> Fields { Fields { items, adopted_page_fields } } +#[cfg(test)] +thread_local! { + /// Whole-form readings ([`enumerate`]) on this thread. + static FORM_READS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// How many times this thread has read the whole form. +#[cfg(test)] +fn form_reads() -> usize { + FORM_READS.with(std::cell::Cell::get) +} + fn enumerate(doc: &Document) -> (Vec, usize) { + #[cfg(test)] + FORM_READS.with(|n| n.set(n.get() + 1)); let af = acroform(doc); let mut page_of = std::collections::HashMap::new(); let mut annot_index = std::collections::HashMap::new(); @@ -935,23 +949,93 @@ pub fn set_value_with(doc: &mut Document, name: &str, value: &FieldValue, script if all.is_empty() { return Err(FormError::NoForm); } - let f = all.iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?; + let named: Vec = all.iter().enumerate().filter(|(_, f)| f.name == name).map(|(i, _)| i).collect(); + fill(doc, &all, &named, name, value, scripts)?; + recalculate_with(doc, scripts)?; + Ok(()) +} + +/// Fill several fields, as [`set_value`] fills each in turn, but reading the form once and +/// recalculating once, after the last value. Filling fields one by one reads the whole form twice +/// per field, which takes minutes on a form with thousands of fields. +/// +/// Returns each value's result, in order. The outer `Err` is the recalculation failing, after +/// which, as with [`set_value`], no value counts as filled (though each was written). +pub fn set_values(doc: &mut Document, values: &[(String, FieldValue)]) -> Result>, FormError> { + let mut scripts = NoScripts; + let mut form = NamedFields::read(doc); + // The field objects this batch wrote since the form was last read. + let mut written = std::collections::HashSet::new(); + let mut results = Vec::with_capacity(values.len()); + let mut filled = false; + for (name, value) in values { + if form.all.is_empty() { + results.push(Err(FormError::NoForm)); + continue; + } + // A field written earlier in the batch (named twice, or a twin's name) is read again, + // so this write starts from what the earlier one left, as it would one by one. + if form.objs(name).any(|o| written.contains(&o)) { + form = NamedFields::read(doc); + written.clear(); + } + let named = form.named(name); + let result = fill(doc, &form.all, named, name, value, &mut scripts); + written.extend(form.objs(name)); + filled |= result.is_ok(); + results.push(result); + } + if filled { + recalculate_with(doc, &mut scripts)?; + } + Ok(results) +} + +/// The form's fields, with the positions of each fully qualified name in them (in field order). +struct NamedFields { + all: Vec, + by_name: std::collections::HashMap>, +} + +impl NamedFields { + fn read(doc: &Document) -> Self { + let all = fields(doc); + let mut by_name: std::collections::HashMap> = std::collections::HashMap::new(); + for (i, f) in all.iter().enumerate() { + by_name.entry(f.name.clone()).or_default().push(i); + } + NamedFields { all, by_name } + } + + fn named(&self, name: &str) -> &[usize] { + self.by_name.get(name).map_or(&[], Vec::as_slice) + } + + /// The field objects named `name`. + fn objs<'a>(&'a self, name: &str) -> impl Iterator + 'a { + self.named(name).iter().filter_map(|&i| self.all.get(i)).map(|f| f.obj) + } +} + +/// Write `value` into the field `name`, without recalculating: `named` are the positions in +/// `all` (the form as read before this write) of the fields with that name, in field order. +fn fill(doc: &mut Document, all: &[Field], named: &[usize], name: &str, value: &FieldValue, scripts: &mut dyn Scripts) -> Result<(), FormError> { + let f = named.first().and_then(|&i| all.get(i)).ok_or_else(|| FormError::NoSuchField(name.into()))?; if f.read_only() { return Err(FormError::ReadOnly(name.into())); } write_value(doc, f, value, scripts)?; - author::form_fonts_as_objects(doc, &all)?; + author::form_fonts_as_objects(doc, all)?; // Fields with the same fully qualified name are one field (§12.7.3.1). Writers that repeat a // name as separate field dictionaries, instead of widgets under one parent, get the value // the first one took after its scripts, without running them again. - let twins: Vec<&Field> = all.iter().filter(|t| t.name == name && t.obj != f.obj && t.kind == f.kind).collect(); + let twins: Vec<&Field> = named.iter().filter_map(|&i| all.get(i)).filter(|t| t.obj != f.obj && t.kind == f.kind).collect(); if !twins.is_empty() { let taken = fields(doc).into_iter().find(|x| x.obj == f.obj).map(|x| x.value).unwrap_or_default(); for t in twins { share_value(doc, f.obj, t, &taken, scripts)?; } } - recalculate_with(doc, scripts)?; Ok(()) } diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index 09c6e39b7a..903e8b6fcd 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -4359,3 +4359,145 @@ fn comb_fields_show_their_cells() { let plain = saved_look(&doc, "d"); assert!(!plain.contains(" m ") && plain.contains("(A) Tj"), "{plain}"); } + +/// A form for filling many values at once: two summed fields and their calculated total, a +/// name two field dictionaries share, a check box, a radio group, a list and a read-only field. +fn batch_form() -> Document { + let tx = |name: &str, y: u32, rest: &str| { + format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T ({name}) /Rect [50 {y} 250 {}] /P 3 0 R {rest} >>", y + 20) + }; + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 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 7 0 R 8 0 R 9 0 R 10 0 R 12 0 R 13 0 R 14 0 R 17 0 R] >>".into(), + "<< /Fields [5 0 R 6 0 R 7 0 R 8 0 R 9 0 R 10 0 R 11 0 R 14 0 R 17 0 R] /CO [7 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 15 0 R >> >> >>" + .into(), + tx("a", 700, "/V (1)"), + tx("b", 660, ""), + tx("total", 620, r#"/AA << /C << /S /JavaScript /JS (AFSimple_Calculate\("SUM", new Array\("a", "b"\)\);) >> >>"#), + tx("dup", 580, ""), + tx("dup", 540, ""), + "<< /Type /Annot /Subtype /Widget /FT /Btn /T (agree) /V /Off /AS /Off /Rect [300 700 315 715] /P 3 0 R >>".into(), + "<< /FT /Btn /T (size) /Ff 49152 /Kids [12 0 R 13 0 R] >>".into(), + "<< /Type /Annot /Subtype /Widget /Parent 11 0 R /AS /Off /Rect [300 650 315 665] /P 3 0 R /AP << /N << /S 16 0 R /Off 16 0 R >> >> >>" + .into(), + "<< /Type /Annot /Subtype /Widget /Parent 11 0 R /AS /Off /Rect [330 650 345 665] /P 3 0 R /AP << /N << /L 16 0 R /Off 16 0 R >> >> >>" + .into(), + "<< /Type /Annot /Subtype /Widget /FT /Ch /T (country) /Ff 131072 /Opt [[(ca) (Canada)] [(fr) (France)]] /Rect [300 600 450 620] /P 3 0 R >>" + .into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + "<< /Length 0 >>\nstream\n\nendstream".into(), + tx("locked", 500, "/Ff 1 /V (kept)"), + ]; + document(&objs) +} + +/// What a filled form shows: every field as read, and each widget's drawn appearance. +fn filled(doc: &Document) -> (Vec, Vec) { + let all = fields(doc); + let looks = all + .iter() + .flat_map(|f| &f.widgets) + .map(|w| { + let wd = doc.get(w.obj).as_dict().cloned().unwrap_or_default(); + let n = wd.get(b"AP").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().and_then(|a| a.get(b"N").cloned())); + match n.map(|n| doc.resolve(&n)) { + Some(n) => match &*n { + Object::Stream(s) => String::from_utf8_lossy(&s.decoded().unwrap_or_default()).into_owned(), + other => format!("{other:?}").chars().filter(|c| !c.is_ascii_digit()).collect(), + }, + None => String::new(), + } + }) + .collect(); + (all, looks) +} + +/// Filling values in one batch leaves the form as filling them one by one does: the values, +/// the twin of a shared name, the calculated total and every widget's appearance; a name given +/// twice ends with its last value, and is checked against the value it took earlier in the batch; +/// and each value fails as it would on its own. +#[test] +fn set_values_fills_as_set_value_does_one_by_one() { + let values: Vec<(String, FieldValue)> = vec![ + ("a".into(), FieldValue::Text("2".into())), + ("b".into(), FieldValue::Text("3".into())), + ("dup".into(), FieldValue::Text("shared".into())), + ("agree".into(), FieldValue::Check(true)), + ("size".into(), FieldValue::Radio(Some("L".into()))), + ("country".into(), FieldValue::Choice(vec!["fr".into()])), + ("nowhere".into(), FieldValue::Text("x".into())), + ("locked".into(), FieldValue::Text("changed".into())), + ("size".into(), FieldValue::Radio(Some("XL".into()))), + ("dup".into(), FieldValue::Text("again".into())), + ("a".into(), FieldValue::Text("10".into())), + // Refused only because the batch already selected L: the group keeps one selected. + ("size".into(), FieldValue::Radio(None)), + ]; + let mut one_by_one = batch_form(); + let expected: Vec> = values.iter().map(|(n, v)| set_value(&mut one_by_one, n, v)).collect(); + let mut batch = batch_form(); + let results = set_values(&mut batch, &values).expect("recalculates"); + assert_eq!(results, expected); + assert_eq!( + results.iter().filter(|r| r.is_err()).count(), + 4, + "the unknown name, the read-only field, the unknown option and clearing the group fail: {results:?}" + ); + let (all, looks) = filled(&batch); + assert_eq!((all.clone(), looks), filled(&one_by_one)); + assert_eq!(field(&all, "total").value, ["13"], "recalculated once, after the last value"); + assert!(all.iter().filter(|f| f.name == "dup").all(|f| f.value == ["again"]), "both dup fields take the last value"); + // And as saved. + assert_eq!(filled(&reopen(&batch)), filled(&reopen(&one_by_one))); +} + +/// A form without fields refuses each value, and an empty batch changes nothing. +#[test] +fn set_values_without_a_form_or_values() { + let mut doc = document(&[ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R >>".into(), + ]); + let r = set_values(&mut doc, &[("a".into(), FieldValue::Text("1".into()))]).unwrap(); + assert_eq!(r, vec![Err(FormError::NoForm)]); + let mut form = batch_form(); + assert_eq!(set_values(&mut form, &[]).unwrap(), Vec::new()); + assert!(!form.is_modified()); + // Nothing filled, nothing recalculated: the total stays as the file has it. + let r = set_values(&mut form, &[("nowhere".into(), FieldValue::Text("1".into()))]).unwrap(); + assert_eq!(r, vec![Err(FormError::NoSuchField("nowhere".into()))]); + assert!(field(&fields(&form), "total").value.is_empty() && !form.is_modified()); +} + +/// Filling many fields reads the form a fixed number of times, not once or twice per field +/// (which took minutes to open a form with thousands of fields whose XFA data was filled in +/// elsewhere). +#[test] +fn set_values_reads_the_form_once_not_per_value() { + let n: u32 = 60; + let mut objs: Vec = + vec!["<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into()]; + let kids: Vec = (0..n).map(|i| format!("{} 0 R", i + 5)).collect(); + objs.push(format!("<< /Type /Page /Parent 2 0 R /Annots [{}] >>", kids.join(" "))); + objs.push(format!("<< /Fields [{}] /DA (/Helv 10 Tf 0 g) >>", kids.join(" "))); + for i in 0..n { + objs.push(format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T (f{i}) /Rect [50 {y} 250 {top}] /P 3 0 R >>", y = 10 * i, top = 10 * i + 10)); + } + let mut doc = document(&objs); + let values: Vec<(String, FieldValue)> = (0..n).map(|i| (format!("f{i}"), FieldValue::Text(format!("v{i}")))).collect(); + let before = form_reads(); + let results = set_values(&mut doc, &values).unwrap(); + let reads = form_reads() - before; + assert!(results.iter().all(Result::is_ok)); + // One reading to fill, one to recalculate. + assert!(reads <= 2, "{reads} readings of the form for {n} values"); + assert!(fields(&doc).iter().all(|f| f.value == [format!("v{}", &f.name[1..])])); + // A name filled again is read again, once: f0 and f1 again cost one more reading. + let again: Vec<(String, FieldValue)> = ["f0", "f1", "f0", "f1"].iter().map(|n| (n.to_string(), FieldValue::Text("w".into()))).collect(); + let before = form_reads(); + assert!(set_values(&mut doc, &again).unwrap().iter().all(Result::is_ok)); + let reads = form_reads() - before; + assert_eq!(reads, 3, "one to fill, one when f0 comes again, one to recalculate"); +} diff --git a/crates/xfa/src/fixtures.rs b/crates/xfa/src/fixtures.rs index b76b3efa1a..8f6e0f7ee8 100644 --- a/crates/xfa/src/fixtures.rs +++ b/crates/xfa/src/fixtures.rs @@ -144,6 +144,43 @@ pub fn static_shell(data: &str) -> Vec { assemble(objs) } +/// A static XFA form of `n` text fields `form1[0].page1[0].f[0]` on one page, whose values +/// (`v`) are only in its datasets, as when another viewer filled it in. The first field is +/// read-only. +pub fn static_shell_many(n: usize) -> Vec { + let data: String = (0..n).map(|i| format!("v{i}")).collect(); + let xdp = format!( + "{data}" + ); + // Objects 1–7, then the widgets from 8. + let kids: Vec = (0..n).map(|i| format!("{} 0 R", i + 8)).collect(); + let kids = kids.join(" "); + let mut objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R /AcroForm << /XFA 5 0 R /Fields [6 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> >> >> >> >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Annots [{kids}] >>").into_bytes(), + b"<< /Length 0 >>\nstream\n\nendstream".to_vec(), + format!("<< /Length {} >>\nstream\n{xdp}\nendstream", xdp.len()).into_bytes(), + b"<< /T (form1[0]) /Kids [7 0 R] >>".to_vec(), + format!("<< /T (page1[0]) /Parent 6 0 R /Kids [{kids}] >>").into_bytes(), + ]; + for i in 0..n { + let y = 760 - (i % 50) * 14; + let x = 36 + (i / 50) * 110; + // The first field is read-only: its data value is not taken. + let flags = if i == 0 { " /Ff 1" } else { "" }; + objs.push( + format!( + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (f{i}[0]){flags} /Parent 7 0 R /Rect [{x} {y} {} {}] /P 3 0 R /DA (/Helv 9 Tf 0 g) >>", + x + 100, + y + 12 + ) + .into_bytes(), + ); + } + assemble(objs) +} + /// A PDF file holding `objs` as objects 1, 2, … in order, with a cross-reference table and a /// trailer whose root is object 1. fn assemble(objs: Vec>) -> Vec {