use lopdf::{dictionary, Document, Object, ObjectId, Stream, StringFormat};
use pdfluent_forms::{apply_choice_multi, apply_field_value, WriteValue, WritebackError};
struct FixtureBuilder {
doc: Document,
pages_id: ObjectId,
page_id: ObjectId,
field_ids: Vec<ObjectId>,
}
impl FixtureBuilder {
fn new() -> Self {
let mut doc = Document::with_version("1.7");
let pages_id = doc.new_object_id();
let content_id = doc.add_object(Object::Stream(Stream::new(dictionary! {}, Vec::new())));
let page_id = doc.add_object(Object::Dictionary(dictionary! {
"Type" => Object::Name(b"Page".to_vec()),
"Parent" => Object::Reference(pages_id),
"MediaBox" => Object::Array(vec![0.into(), 0.into(), 612.into(), 792.into()]),
"Contents" => Object::Reference(content_id),
}));
doc.objects.insert(
pages_id,
Object::Dictionary(dictionary! {
"Type" => Object::Name(b"Pages".to_vec()),
"Kids" => Object::Array(vec![Object::Reference(page_id)]),
"Count" => Object::Integer(1),
}),
);
Self {
doc,
pages_id,
page_id,
field_ids: Vec::new(),
}
}
fn add_field(&mut self, dict: lopdf::Dictionary) -> ObjectId {
let id = self.doc.add_object(Object::Dictionary(dict));
self.field_ids.push(id);
id
}
fn button_ap(&mut self, on_state: &[u8]) -> Object {
let on = self
.doc
.add_object(Object::Stream(Stream::new(dictionary! {}, b"q Q".to_vec())));
let off = self
.doc
.add_object(Object::Stream(Stream::new(dictionary! {}, b"q Q".to_vec())));
let mut n = lopdf::Dictionary::new();
n.set(on_state.to_vec(), Object::Reference(on));
n.set("Off".as_bytes().to_vec(), Object::Reference(off));
Object::Dictionary(dictionary! { "N" => Object::Dictionary(n) })
}
fn finish(mut self) -> Vec<u8> {
let fields: Vec<Object> = self
.field_ids
.iter()
.map(|&id| Object::Reference(id))
.collect();
let annots: Vec<Object> = self
.field_ids
.iter()
.map(|&id| Object::Reference(id))
.collect();
if let Ok(Object::Dictionary(page)) = self.doc.get_object_mut(self.page_id) {
page.set("Annots", Object::Array(annots));
}
let acroform_id = self.doc.add_object(Object::Dictionary(dictionary! {
"Fields" => Object::Array(fields),
"DA" => Object::String(b"/Helv 0 Tf 0 g".to_vec(), StringFormat::Literal),
}));
let catalog_id = self.doc.add_object(Object::Dictionary(dictionary! {
"Type" => Object::Name(b"Catalog".to_vec()),
"Pages" => Object::Reference(self.pages_id),
"AcroForm" => Object::Reference(acroform_id),
}));
self.doc.trailer.set("Root", Object::Reference(catalog_id));
let mut buf = Vec::new();
self.doc.save_to(&mut buf).expect("save fixture");
buf
}
}
fn text_field(name: &str, flags: i64, max_len: Option<i64>) -> lopdf::Dictionary {
let mut d = dictionary! {
"Type" => Object::Name(b"Annot".to_vec()),
"Subtype" => Object::Name(b"Widget".to_vec()),
"FT" => Object::Name(b"Tx".to_vec()),
"T" => Object::String(name.as_bytes().to_vec(), StringFormat::Literal),
"Rect" => Object::Array(vec![100.into(), 700.into(), 300.into(), 720.into()]),
};
if flags != 0 {
d.set("Ff", Object::Integer(flags));
}
if let Some(ml) = max_len {
d.set("MaxLen", Object::Integer(ml));
}
d
}
fn save_and_reload(doc: &mut Document) -> Document {
let mut buf = Vec::new();
doc.save_to(&mut buf).expect("save");
Document::load_mem(&buf).expect("reload")
}
fn field_by_name<'a>(doc: &'a Document, name: &str) -> &'a lopdf::Dictionary {
fn walk<'a>(
doc: &'a Document,
id: ObjectId,
prefix: &str,
target: &str,
) -> Option<&'a lopdf::Dictionary> {
let dict = doc.get_object(id).ok()?.as_dict().ok()?;
let partial = dict
.get(b"T")
.ok()
.and_then(|o| lopdf::decode_text_string(o).ok())
.unwrap_or_default();
let fqn = if prefix.is_empty() {
partial.clone()
} else if partial.is_empty() {
prefix.to_string()
} else {
format!("{prefix}.{partial}")
};
if fqn == target && dict.get(b"FT").is_ok() {
return Some(dict);
}
if let Ok(Object::Array(kids)) = dict.get(b"Kids") {
for kid in kids {
if let Object::Reference(kid_id) = kid {
if let Some(found) = walk(doc, *kid_id, &fqn, target) {
return Some(found);
}
}
}
}
None
}
let catalog = doc.catalog().expect("catalog");
let af = match catalog.get(b"AcroForm").expect("acroform") {
Object::Reference(id) => doc.get_object(*id).unwrap().as_dict().unwrap(),
Object::Dictionary(d) => d,
_ => panic!("bad acroform"),
};
let fields = match af.get(b"Fields").expect("fields") {
Object::Array(a) => a.clone(),
Object::Reference(id) => doc.get_object(*id).unwrap().as_array().unwrap().clone(),
_ => panic!("bad fields"),
};
for f in &fields {
if let Object::Reference(id) = f {
if let Some(found) = walk(doc, *id, "", name) {
return found;
}
}
}
panic!("field '{name}' not found in saved document");
}
fn v_bytes(dict: &lopdf::Dictionary) -> Vec<u8> {
match dict.get(b"V").expect("/V present") {
Object::String(bytes, _) => bytes.clone(),
Object::Name(bytes) => bytes.clone(),
other => panic!("unexpected /V type: {other:?}"),
}
}
#[test]
fn ascii_text_roundtrips_as_literal_with_appearance() {
let mut b = FixtureBuilder::new();
b.add_field(text_field("naam", 0, None));
let bytes = b.finish();
let mut doc = Document::load_mem(&bytes).unwrap();
let outcome = apply_field_value(&mut doc, "naam", WriteValue::Text("Jan Jansen")).unwrap();
assert_eq!(outcome.appearances_generated, 1);
assert!(!outcome.need_appearances_fallback);
let saved = save_and_reload(&mut doc);
let fld = field_by_name(&saved, "naam");
assert_eq!(v_bytes(fld), b"Jan Jansen".to_vec(), "ASCII stays literal");
let ap = fld.get(b"AP").expect("AP generated").as_dict().unwrap();
let n_ref = ap.get(b"N").unwrap().as_reference().unwrap();
let n = saved.get_object(n_ref).unwrap().as_stream().unwrap();
assert!(n.dict.get(b"Resources").is_ok(), "AP must carry /Resources");
let content = String::from_utf8_lossy(&n.content).into_owned();
assert!(
content.contains("/Tx BMC"),
"marked-content wrapper present"
);
assert!(content.contains("(Jan Jansen) Tj"), "value drawn");
}
#[test]
fn non_ascii_text_becomes_utf16be_with_winansi_appearance() {
let mut b = FixtureBuilder::new();
b.add_field(text_field("plaats", 0, None));
let bytes = b.finish();
let mut doc = Document::load_mem(&bytes).unwrap();
apply_field_value(&mut doc, "plaats", WriteValue::Text("Café Zürich € ñ")).unwrap();
let saved = save_and_reload(&mut doc);
let fld = field_by_name(&saved, "plaats");
let v = v_bytes(fld);
assert!(
v.starts_with(b"\xFE\xFF"),
"non-ASCII /V must be UTF-16BE with BOM, got {v:?}"
);
let decoded = lopdf::decode_text_string(fld.get(b"V").unwrap()).unwrap();
assert_eq!(decoded, "Café Zürich € ñ");
let ap = fld.get(b"AP").unwrap().as_dict().unwrap();
let n_ref = ap.get(b"N").unwrap().as_reference().unwrap();
let content = &saved
.get_object(n_ref)
.unwrap()
.as_stream()
.unwrap()
.content;
let hay = content.as_slice();
assert!(
hay.windows(4).any(|w| w == [b'C', b'a', b'f', 0xE9]),
"appearance must contain WinAnsi 'Café'"
);
assert!(
hay.contains(&0x80),
"Euro must be WinAnsi 0x80 in the appearance stream"
);
assert!(
!hay.windows(2).any(|w| w == [0xC3, 0xA9]),
"no raw UTF-8 'é' bytes in the appearance stream"
);
}
#[test]
fn unencodable_text_sets_need_appearances_and_drops_stale_ap() {
let mut b = FixtureBuilder::new();
b.add_field(text_field("cyrillic", 0, None));
let bytes = b.finish();
let mut doc = Document::load_mem(&bytes).unwrap();
let outcome = apply_field_value(&mut doc, "cyrillic", WriteValue::Text("Привет")).unwrap();
assert!(outcome.need_appearances_fallback);
assert_eq!(outcome.appearances_generated, 0);
let saved = save_and_reload(&mut doc);
let fld = field_by_name(&saved, "cyrillic");
let decoded = lopdf::decode_text_string(fld.get(b"V").unwrap()).unwrap();
assert_eq!(decoded, "Привет");
assert!(fld.get(b"AP").is_err(), "stale /AP must be removed");
let catalog = saved.catalog().unwrap();
let af = match catalog.get(b"AcroForm").unwrap() {
Object::Reference(id) => saved.get_object(*id).unwrap().as_dict().unwrap(),
Object::Dictionary(d) => d,
_ => panic!(),
};
assert_eq!(
af.get(b"NeedAppearances").ok(),
Some(&Object::Boolean(true)),
"fallback must set the CORRECTLY SPELLED NeedAppearances key"
);
}
#[test]
fn maxlen_truncates_and_comb_renders_per_cell() {
let mut b = FixtureBuilder::new();
b.add_field(text_field("bsn", 0x100_0000, Some(9)));
let bytes = b.finish();
let mut doc = Document::load_mem(&bytes).unwrap();
apply_field_value(&mut doc, "bsn", WriteValue::Text("1234567890123")).unwrap();
let saved = save_and_reload(&mut doc);
let fld = field_by_name(&saved, "bsn");
let decoded = lopdf::decode_text_string(fld.get(b"V").unwrap()).unwrap();
assert_eq!(decoded, "123456789", "/MaxLen must truncate");
let ap = fld.get(b"AP").unwrap().as_dict().unwrap();
let n_ref = ap.get(b"N").unwrap().as_reference().unwrap();
let content = String::from_utf8_lossy(
&saved
.get_object(n_ref)
.unwrap()
.as_stream()
.unwrap()
.content,
)
.into_owned();
assert_eq!(
content.matches(") Tj").count(),
9,
"comb appearance draws one glyph per cell"
);
}
#[test]
fn multiline_wraps_long_text() {
let mut b = FixtureBuilder::new();
let mut f = text_field("toelichting", 0x1000, None);
f.set(
"Rect",
Object::Array(vec![100.into(), 600.into(), 220.into(), 700.into()]),
);
b.add_field(f);
let bytes = b.finish();
let mut doc = Document::load_mem(&bytes).unwrap();
apply_field_value(
&mut doc,
"toelichting",
WriteValue::Text(
"Dit is een behoorlijk lange toelichting die over meerdere regels moet lopen",
),
)
.unwrap();
let saved = save_and_reload(&mut doc);
let fld = field_by_name(&saved, "toelichting");
let ap = fld.get(b"AP").unwrap().as_dict().unwrap();
let n_ref = ap.get(b"N").unwrap().as_reference().unwrap();
let content = String::from_utf8_lossy(
&saved
.get_object(n_ref)
.unwrap()
.as_stream()
.unwrap()
.content,
)
.into_owned();
assert!(
content.matches("Tj").count() >= 3,
"long text must wrap to multiple lines, got: {content}"
);
}
#[test]
fn checkbox_check_and_uncheck_sync_v_and_as() {
let mut b = FixtureBuilder::new();
let ap = b.button_ap(b"Akkoord");
let mut f = dictionary! {
"Type" => Object::Name(b"Annot".to_vec()),
"Subtype" => Object::Name(b"Widget".to_vec()),
"FT" => Object::Name(b"Btn".to_vec()),
"T" => Object::String(b"akkoord".to_vec(), StringFormat::Literal),
"Rect" => Object::Array(vec![100.into(), 650.into(), 112.into(), 662.into()]),
"AS" => Object::Name(b"Off".to_vec()),
};
f.set("AP", ap);
b.add_field(f);
let bytes = b.finish();
let mut doc = Document::load_mem(&bytes).unwrap();
apply_field_value(&mut doc, "akkoord", WriteValue::Checkbox(true)).unwrap();
let saved = save_and_reload(&mut doc);
let fld = field_by_name(&saved, "akkoord");
assert_eq!(v_bytes(fld), b"Akkoord".to_vec(), "/V = on-state NAME");
assert!(
matches!(fld.get(b"V").unwrap(), Object::Name(_)),
"/V must be a Name"
);
assert_eq!(
fld.get(b"AS").unwrap().as_name().unwrap(),
b"Akkoord",
"/AS synced to on-state"
);
let mut doc2 = {
let mut buf = Vec::new();
doc.save_to(&mut buf).unwrap();
Document::load_mem(&buf).unwrap()
};
apply_field_value(&mut doc2, "akkoord", WriteValue::Checkbox(false)).unwrap();
let saved2 = save_and_reload(&mut doc2);
let fld2 = field_by_name(&saved2, "akkoord");
assert_eq!(v_bytes(fld2), b"Off".to_vec());
assert_eq!(fld2.get(b"AS").unwrap().as_name().unwrap(), b"Off");
}
#[test]
fn radio_group_selects_one_widget_and_offs_siblings() {
let mut b = FixtureBuilder::new();
let ap1 = b.button_ap(b"Eenmanszaak");
let ap2 = b.button_ap(b"Co\xF6peratie");
let ap3 = b.button_ap(b"Anders");
let group_id = b.doc.new_object_id();
let mk_kid = |b: &mut FixtureBuilder, ap: Object, y: i64| -> ObjectId {
let mut k = dictionary! {
"Type" => Object::Name(b"Annot".to_vec()),
"Subtype" => Object::Name(b"Widget".to_vec()),
"Parent" => Object::Reference(group_id),
"Rect" => Object::Array(vec![100.into(), y.into(), 112.into(), (y+12).into()]),
"AS" => Object::Name(b"Off".to_vec()),
};
k.set("AP", ap);
b.doc.add_object(Object::Dictionary(k))
};
let k1 = mk_kid(&mut b, ap1, 600);
let k2 = mk_kid(&mut b, ap2, 580);
let k3 = mk_kid(&mut b, ap3, 560);
b.doc.objects.insert(
group_id,
Object::Dictionary(dictionary! {
"FT" => Object::Name(b"Btn".to_vec()),
"T" => Object::String(b"rechtsvorm".to_vec(), StringFormat::Literal),
"Ff" => Object::Integer(1 << 15), "Kids" => Object::Array(vec![
Object::Reference(k1), Object::Reference(k2), Object::Reference(k3),
]),
}),
);
b.field_ids.push(group_id);
let bytes = b.finish();
let mut doc = Document::load_mem(&bytes).unwrap();
let outcome =
apply_field_value(&mut doc, "rechtsvorm", WriteValue::Radio("Coöperatie")).unwrap();
assert_eq!(outcome.appearance_states_set, 3, "every widget gets /AS");
let saved = save_and_reload(&mut doc);
let fld = field_by_name(&saved, "rechtsvorm");
assert_eq!(
v_bytes(fld),
b"Co\xF6peratie".to_vec(),
"group /V = byte-exact on-state name"
);
let kids = match fld.get(b"Kids").unwrap() {
Object::Array(a) => a.clone(),
_ => panic!(),
};
let mut as_values = Vec::new();
for k in &kids {
if let Object::Reference(id) = k {
let kd = saved.get_object(*id).unwrap().as_dict().unwrap();
as_values.push(kd.get(b"AS").unwrap().as_name().unwrap().to_vec());
}
}
assert_eq!(
as_values,
vec![b"Off".to_vec(), b"Co\xF6peratie".to_vec(), b"Off".to_vec()],
"exactly the matching widget is on"
);
let err = apply_field_value(&mut doc, "rechtsvorm", WriteValue::Radio("Bestaat niet"));
assert!(matches!(err, Err(WritebackError::InvalidOption { .. })));
}
#[test]
fn choice_validates_options_and_clears_index_cache() {
let mut b = FixtureBuilder::new();
let mut f = dictionary! {
"Type" => Object::Name(b"Annot".to_vec()),
"Subtype" => Object::Name(b"Widget".to_vec()),
"FT" => Object::Name(b"Ch".to_vec()),
"T" => Object::String(b"provincie".to_vec(), StringFormat::Literal),
"Ff" => Object::Integer(1 << 17), "Rect" => Object::Array(vec![100.into(), 500.into(), 250.into(), 520.into()]),
"I" => Object::Array(vec![Object::Integer(0)]),
};
f.set(
"Opt",
Object::Array(vec![
Object::String(b"Utrecht".to_vec(), StringFormat::Literal),
Object::String(b"Friesland".to_vec(), StringFormat::Literal),
]),
);
b.add_field(f);
let bytes = b.finish();
let mut doc = Document::load_mem(&bytes).unwrap();
let err = apply_field_value(&mut doc, "provincie", WriteValue::Choice("Mars"));
assert!(matches!(err, Err(WritebackError::InvalidOption { .. })));
let outcome =
apply_field_value(&mut doc, "provincie", WriteValue::Choice("Friesland")).unwrap();
assert_eq!(outcome.appearances_generated, 1);
let saved = save_and_reload(&mut doc);
let fld = field_by_name(&saved, "provincie");
assert_eq!(v_bytes(fld), b"Friesland".to_vec());
assert!(fld.get(b"I").is_err(), "stale /I cache must be cleared");
}
fn multiselect_listbox(name: &str, opts: &[&str], extra_flags: i64) -> lopdf::Dictionary {
let opt_arr: Vec<Object> = opts
.iter()
.map(|o| Object::String(o.as_bytes().to_vec(), StringFormat::Literal))
.collect();
dictionary! {
"Type" => Object::Name(b"Annot".to_vec()),
"Subtype" => Object::Name(b"Widget".to_vec()),
"FT" => Object::Name(b"Ch".to_vec()),
"T" => Object::String(name.as_bytes().to_vec(), StringFormat::Literal),
"Ff" => Object::Integer(0x200000 | extra_flags), "Rect" => Object::Array(vec![100.into(), 400.into(), 300.into(), 520.into()]),
"Opt" => Object::Array(opt_arr),
"I" => Object::Array(vec![Object::Integer(0)]),
}
}
fn i_indices(dict: &lopdf::Dictionary) -> Vec<i64> {
match dict.get(b"I") {
Ok(Object::Array(a)) => a
.iter()
.filter_map(|o| match o {
Object::Integer(i) => Some(*i),
_ => None,
})
.collect(),
_ => Vec::new(),
}
}
fn v_array_strings(dict: &lopdf::Dictionary) -> Vec<String> {
match dict.get(b"V") {
Ok(Object::Array(a)) => a
.iter()
.filter_map(|o| lopdf::decode_text_string(o).ok())
.collect(),
Ok(other) => lopdf::decode_text_string(other).into_iter().collect(),
_ => Vec::new(),
}
}
#[test]
fn multiselect_writes_v_array_and_rebuilds_sorted_index_cache() {
let mut b = FixtureBuilder::new();
b.add_field(multiselect_listbox("talen", &["NL", "EN", "DE", "FR"], 0));
let bytes = b.finish();
let mut doc = Document::load_mem(&bytes).unwrap();
let outcome =
apply_choice_multi(&mut doc, "talen", &["FR".to_string(), "NL".to_string()]).unwrap();
assert!(outcome.need_appearances_fallback);
let saved = save_and_reload(&mut doc);
let fld = field_by_name(&saved, "talen");
assert_eq!(
v_array_strings(fld),
vec!["FR".to_string(), "NL".to_string()]
);
assert_eq!(i_indices(fld), vec![0, 3], "/I must be sorted /Opt indices");
}
#[test]
fn multiselect_resolves_indices_for_export_display_pairs() {
let mut b = FixtureBuilder::new();
let opt_arr = vec![
Object::Array(vec![
Object::String(b"nl".to_vec(), StringFormat::Literal),
Object::String(b"Nederlands".to_vec(), StringFormat::Literal),
]),
Object::Array(vec![
Object::String(b"en".to_vec(), StringFormat::Literal),
Object::String(b"English".to_vec(), StringFormat::Literal),
]),
Object::Array(vec![
Object::String(b"de".to_vec(), StringFormat::Literal),
Object::String(b"Deutsch".to_vec(), StringFormat::Literal),
]),
];
b.add_field(dictionary! {
"Type" => Object::Name(b"Annot".to_vec()),
"Subtype" => Object::Name(b"Widget".to_vec()),
"FT" => Object::Name(b"Ch".to_vec()),
"T" => Object::String(b"taal".to_vec(), StringFormat::Literal),
"Ff" => Object::Integer(0x200000),
"Rect" => Object::Array(vec![100.into(), 400.into(), 300.into(), 520.into()]),
"Opt" => Object::Array(opt_arr),
});
let bytes = b.finish();
let mut doc = Document::load_mem(&bytes).unwrap();
apply_choice_multi(&mut doc, "taal", &["Deutsch".to_string(), "nl".to_string()]).unwrap();
let saved = save_and_reload(&mut doc);
let fld = field_by_name(&saved, "taal");
assert_eq!(i_indices(fld), vec![0, 2]);
}
#[test]
fn multiselect_empty_clears_selection() {
let mut b = FixtureBuilder::new();
b.add_field(multiselect_listbox("talen", &["NL", "EN"], 0));
let bytes = b.finish();
let mut doc = Document::load_mem(&bytes).unwrap();
apply_choice_multi(&mut doc, "talen", &[]).unwrap();
let saved = save_and_reload(&mut doc);
let fld = field_by_name(&saved, "talen");
assert!(v_array_strings(fld).is_empty(), "/V cleared to empty array");
assert!(fld.get(b"I").is_err(), "/I removed when nothing selected");
}
#[test]
fn multiselect_rejects_unknown_option_when_not_editable() {
let mut b = FixtureBuilder::new();
b.add_field(multiselect_listbox("talen", &["NL", "EN"], 0));
let bytes = b.finish();
let mut doc = Document::load_mem(&bytes).unwrap();
let err = apply_choice_multi(&mut doc, "talen", &["KL".to_string()]);
assert!(matches!(err, Err(WritebackError::InvalidOption { .. })));
}
#[test]
fn multiselect_editable_accepts_freetext_value() {
let mut b = FixtureBuilder::new();
b.add_field(multiselect_listbox("talen", &["NL", "EN"], 0x40000));
let bytes = b.finish();
let mut doc = Document::load_mem(&bytes).unwrap();
apply_choice_multi(
&mut doc,
"talen",
&["EN".to_string(), "Klingon".to_string()],
)
.unwrap();
let saved = save_and_reload(&mut doc);
let fld = field_by_name(&saved, "talen");
assert_eq!(
v_array_strings(fld),
vec!["EN".to_string(), "Klingon".to_string()]
);
assert_eq!(i_indices(fld), vec![1]);
}
#[test]
fn multiselect_rejects_single_select_listbox() {
let mut b = FixtureBuilder::new();
let mut f = multiselect_listbox("talen", &["NL", "EN"], 0);
f.set("Ff", Object::Integer(0)); b.add_field(f);
let bytes = b.finish();
let mut doc = Document::load_mem(&bytes).unwrap();
let err = apply_choice_multi(&mut doc, "talen", &["NL".to_string()]);
assert!(matches!(err, Err(WritebackError::WrongType { .. })));
}
#[test]
fn multiselect_rejects_readonly() {
let mut b = FixtureBuilder::new();
b.add_field(multiselect_listbox("talen", &["NL", "EN"], 0x1)); let bytes = b.finish();
let mut doc = Document::load_mem(&bytes).unwrap();
let err = apply_choice_multi(&mut doc, "talen", &["NL".to_string()]);
assert!(matches!(err, Err(WritebackError::ReadOnly(_))));
}
#[test]
fn readonly_field_is_rejected_including_inherited() {
let mut b = FixtureBuilder::new();
b.add_field(text_field("vergrendeld", 0x1, None));
let bytes = b.finish();
let mut doc = Document::load_mem(&bytes).unwrap();
let err = apply_field_value(&mut doc, "vergrendeld", WriteValue::Text("x"));
assert!(matches!(err, Err(WritebackError::ReadOnly(_))));
}
#[test]
fn unknown_field_is_field_not_found() {
let mut b = FixtureBuilder::new();
b.add_field(text_field("naam", 0, None));
let bytes = b.finish();
let mut doc = Document::load_mem(&bytes).unwrap();
let err = apply_field_value(&mut doc, "bestaat.niet", WriteValue::Text("x"));
assert!(matches!(err, Err(WritebackError::FieldNotFound(_))));
}
fn reference_pdf() -> Vec<u8> {
let path = concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../tests/fixtures/belastingdienst_betalingsregeling.pdf"
);
std::fs::read(path).expect("reference fixture present")
}
#[test]
fn reference_form_text_fill_roundtrips_in_acrobat_encoding() {
let mut doc = Document::load_mem(&reference_pdf()).unwrap();
let outcome =
apply_field_value(&mut doc, "1.1", WriteValue::Text("Café Zürich € 15,00")).unwrap();
assert!(outcome.appearances_generated >= 1);
assert!(!outcome.need_appearances_fallback);
let saved = save_and_reload(&mut doc);
let fld = field_by_name(&saved, "1.1");
let v = v_bytes(fld);
assert!(v.starts_with(b"\xFE\xFF"), "UTF-16BE BOM");
assert_eq!(
lopdf::decode_text_string(fld.get(b"V").unwrap()).unwrap(),
"Café Zürich € 15,00"
);
}
#[test]
fn reference_form_radio_with_latin1_sentence_state() {
let mut doc = Document::load_mem(&reference_pdf()).unwrap();
let option1 =
"1. Nv, bv, vereniging, stichting, coöperatie. U hoeft de vragen 9 tot en met 22 niet in te vullen.";
let outcome = apply_field_value(&mut doc, "3e.0", WriteValue::Radio(option1)).unwrap();
assert_eq!(outcome.appearance_states_set, 3);
let saved = save_and_reload(&mut doc);
let fld = field_by_name(&saved, "3e.0");
let v = v_bytes(fld);
assert!(
v.contains(&0xF6),
"byte-exact Latin-1 'ö' preserved in /V name"
);
let kids = match fld.get(b"Kids").unwrap() {
Object::Array(a) => a.clone(),
_ => panic!(),
};
let on_count = kids
.iter()
.filter_map(|k| match k {
Object::Reference(id) => saved.get_object(*id).ok()?.as_dict().ok(),
_ => None,
})
.filter(|kd| {
kd.get(b"AS")
.ok()
.and_then(|o| o.as_name().ok())
.is_some_and(|n| n != b"Off")
})
.count();
assert_eq!(on_count, 1, "exactly one radio widget on");
}
#[test]
fn reference_form_readonly_admin_lock_is_guarded() {
let mut doc = Document::load_mem(&reference_pdf()).unwrap();
{
let catalog = doc.catalog().unwrap();
let af_id = match catalog.get(b"AcroForm").unwrap() {
Object::Reference(id) => *id,
_ => panic!(),
};
let af = doc.get_object(af_id).unwrap().as_dict().unwrap();
let fields = match af.get(b"Fields").unwrap() {
Object::Array(a) => a.clone(),
Object::Reference(id) => doc.get_object(*id).unwrap().as_array().unwrap().clone(),
_ => panic!(),
};
fn find_id(doc: &Document, id: ObjectId, prefix: &str, target: &str) -> Option<ObjectId> {
let dict = doc.get_object(id).ok()?.as_dict().ok()?;
let partial = dict
.get(b"T")
.ok()
.and_then(|o| lopdf::decode_text_string(o).ok())
.unwrap_or_default();
let fqn = if prefix.is_empty() {
partial.clone()
} else if partial.is_empty() {
prefix.to_string()
} else {
format!("{prefix}.{partial}")
};
if fqn == target && dict.get(b"FT").is_ok() {
return Some(id);
}
if let Ok(Object::Array(kids)) = dict.get(b"Kids") {
for kid in kids {
if let Object::Reference(kid_id) = kid {
if let Some(f) = find_id(doc, *kid_id, &fqn, target) {
return Some(f);
}
}
}
}
None
}
let mut target = None;
for f in &fields {
if let Object::Reference(id) = f {
if let Some(t) = find_id(&doc, *id, "", "1.1") {
target = Some(t);
break;
}
}
}
let target = target.expect("field 1.1");
if let Ok(Object::Dictionary(d)) = doc.get_object_mut(target) {
d.set("Ff", Object::Integer(0x1));
}
}
let err = apply_field_value(&mut doc, "1.1", WriteValue::Text("mag niet"));
assert!(matches!(err, Err(WritebackError::ReadOnly(_))));
}
#[test]
fn reference_form_model_exposes_radio_options_and_widgets() {
use pdfluent_forms::{build_form_model, parse_acroform, FormFieldKind};
let bytes = reference_pdf();
let pdf = pdf_syntax::Pdf::new(std::sync::Arc::new(bytes)).expect("parse");
let tree = parse_acroform(&pdf).expect("acroform");
let model = build_form_model(&tree);
assert!(
model.len() >= 280,
"reference form has 289 logical fields, model has {}",
model.len()
);
let radio = model
.iter()
.find(|f| f.name == "3e.0")
.expect("radio group 3e.0 in model");
let FormFieldKind::RadioGroup { options } = &radio.kind else {
panic!("3e.0 must be a RadioGroup, got {:?}", radio.kind);
};
assert_eq!(options.len(), 3, "three options");
assert!(
options[0].contains("coöperatie"),
"Latin-1 on-state decoded to Unicode for display: {:?}",
options[0]
);
assert_eq!(radio.widgets.len(), 3, "three widgets with geometry");
assert!(
radio.widgets.iter().all(|w| w.page_index == Some(1)),
"all radio widgets on page 2 (index 1)"
);
let text = model.iter().find(|f| f.name == "1.1").expect("field 1.1");
assert!(matches!(text.kind, FormFieldKind::Text { .. }));
assert_eq!(text.da.font_name.as_deref(), Some("Helv"));
assert!(!text.widgets.is_empty());
}