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 <noreply@anthropic.com>
This commit is contained in:
mtlcl410
2026-10-25 10:02:35 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 737aeb4359
commit 6918d0be21
6 changed files with 350 additions and 11 deletions
+13 -7
View File
@@ -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<Vec<String>, 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<Vec<Stri
pdfcraft_xfa::FieldData::Radio(sel) => (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`).
+68
View File
@@ -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>, 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 (`<bind><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.
+2
View File
@@ -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<Result<(), FormError>> = set_values(&mut doc, &[("name".into(), FieldValue::Text("Ada".into()))])?;
reset(&mut doc, None)?; // Clear form (back to /DV)
// Prepare a form
+88 -4
View File
@@ -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<usize> = 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<Field>, 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<usize> = 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<Vec<Result<(), FormError>>, 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<Field>,
by_name: std::collections::HashMap<String, Vec<usize>>,
}
impl NamedFields {
fn read(doc: &Document) -> Self {
let all = fields(doc);
let mut by_name: std::collections::HashMap<String, Vec<usize>> = 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<Item = ObjRef> + '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(())
}
+142
View File
@@ -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<String> = 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<Field>, Vec<String>) {
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<Result<(), FormError>> = 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<String> =
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<String> = (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");
}
+37
View File
@@ -144,6 +144,43 @@ pub fn static_shell(data: &str) -> Vec<u8> {
assemble(objs)
}
/// A static XFA form of `n` text fields `form1[0].page1[0].f<i>[0]` on one page, whose values
/// (`v<i>`) 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<u8> {
let data: String = (0..n).map(|i| format!("<f{i}>v{i}</f{i}>")).collect();
let xdp = format!(
"<xdp:xdp xmlns:xdp=\"http://ns.adobe.com/xdp/\"><template xmlns=\"http://www.xfa.org/schema/xfa-template/3.3/\"><subform name=\"form1\"/></template><xfa:datasets xmlns:xfa=\"http://www.xfa.org/schema/xfa-data/1.0/\"><xfa:data><form1><page1>{data}</page1></form1></xfa:data></xfa:datasets></xdp:xdp>"
);
// Objects 1–7, then the widgets from 8.
let kids: Vec<String> = (0..n).map(|i| format!("{} 0 R", i + 8)).collect();
let kids = kids.join(" ");
let mut objs: Vec<Vec<u8>> = 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<u8>>) -> Vec<u8> {