use lopdf::{dictionary, Document, Object, Stream};
use pdfluent::prelude::*;
fn dev_doc(bytes: &[u8]) -> PdfDocument {
PdfDocument::from_bytes_with(bytes, OpenOptions::new().with_license_key("tier:developer"))
.expect("parse fixture")
}
fn build_full_form_pdf() -> Vec<u8> {
let mut doc = Document::with_version("1.4");
let content_id = doc.add_object(Stream::new(dictionary! {}, Vec::new()));
let pages_id = doc.new_object_id();
let text_field = doc.add_object(dictionary! {
"FT" => "Tx",
"T" => Object::string_literal("first_name"),
"V" => Object::string_literal(""),
});
let checkbox_field = doc.add_object(dictionary! {
"FT" => "Btn",
"T" => Object::string_literal("agree_terms"),
"V" => Object::Name(b"Off".to_vec()),
});
let radio_field = doc.add_object(dictionary! {
"FT" => "Btn",
"Ff" => 0x8000i64,
"T" => Object::string_literal("preferred_color"),
"V" => Object::Name(b"Off".to_vec()),
});
let dropdown_field = doc.add_object(dictionary! {
"FT" => "Ch",
"Ff" => 0x20000i64,
"T" => Object::string_literal("country"),
"V" => Object::string_literal(""),
"Opt" => vec![
Object::string_literal("US"),
Object::string_literal("NL"),
Object::string_literal("DE"),
],
});
let page_id = doc.add_object(dictionary! {
"Type" => "Page",
"Parent" => pages_id,
"MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
"Contents" => content_id,
"Resources" => dictionary! {},
});
doc.objects.insert(
pages_id,
Object::Dictionary(dictionary! {
"Type" => "Pages",
"Kids" => vec![page_id.into()],
"Count" => 1,
}),
);
let acroform_id = doc.add_object(dictionary! {
"Fields" => vec![
text_field.into(),
checkbox_field.into(),
radio_field.into(),
dropdown_field.into(),
],
});
let catalog_id = doc.add_object(dictionary! {
"Type" => "Catalog",
"Pages" => pages_id,
"AcroForm" => acroform_id,
});
doc.trailer.set("Root", catalog_id);
let mut buf = Vec::new();
doc.save_to(&mut buf).expect("serialise fixture");
buf
}
fn field_value(doc: &PdfDocument, name: &str) -> Option<String> {
doc.form_fields()
.ok()?
.into_iter()
.find(|f| f.name == name)
.map(|f| f.value)
}
#[test]
fn set_text_updates_field_value() {
let bytes = build_full_form_pdf();
let mut doc = dev_doc(&bytes);
doc.form_mut()
.set_text("first_name", "Jane")
.expect("set_text");
assert_eq!(field_value(&doc, "first_name").as_deref(), Some("Jane"));
}
#[test]
fn set_checkbox_updates_field_value() {
let bytes = build_full_form_pdf();
let mut doc = dev_doc(&bytes);
doc.form_mut()
.set_checkbox("agree_terms", true)
.expect("set_checkbox on");
assert_eq!(field_value(&doc, "agree_terms").as_deref(), Some("Yes"));
doc.form_mut()
.set_checkbox("agree_terms", false)
.expect("set_checkbox off");
assert_eq!(field_value(&doc, "agree_terms").as_deref(), Some("Off"));
}
#[test]
fn set_radio_updates_field_value() {
let bytes = build_full_form_pdf();
let mut doc = dev_doc(&bytes);
doc.form_mut()
.set_radio("preferred_color", "Blue")
.expect("set_radio");
assert_eq!(
field_value(&doc, "preferred_color").as_deref(),
Some("Blue"),
);
}
#[test]
fn set_dropdown_updates_field_value() {
let bytes = build_full_form_pdf();
let mut doc = dev_doc(&bytes);
doc.form_mut()
.set_dropdown("country", "NL")
.expect("set_dropdown");
assert_eq!(field_value(&doc, "country").as_deref(), Some("NL"));
}
#[test]
fn setters_chain_via_mut_self_return() {
let bytes = build_full_form_pdf();
let mut doc = dev_doc(&bytes);
doc.form_mut()
.set_text("first_name", "Jane")
.and_then(|f| f.set_checkbox("agree_terms", true))
.and_then(|f| f.set_radio("preferred_color", "Green"))
.and_then(|f| f.set_dropdown("country", "DE"))
.expect("chain");
assert_eq!(field_value(&doc, "first_name").as_deref(), Some("Jane"));
assert_eq!(field_value(&doc, "agree_terms").as_deref(), Some("Yes"));
assert_eq!(
field_value(&doc, "preferred_color").as_deref(),
Some("Green"),
);
assert_eq!(field_value(&doc, "country").as_deref(), Some("DE"));
}
#[test]
fn setters_try_chain_via_question_mark_propagates_errors() {
fn apply_with_question_mark(form: &mut pdfluent::PdfFormMut<'_>) -> pdfluent::Result<()> {
form.set_text("first_name", "Jane")?
.set_checkbox("agree_terms", true)?
.set_radio("preferred_color", "Green")?
.set_dropdown("does_not_exist", "DE")?;
Ok(())
}
let bytes = build_full_form_pdf();
let mut doc = dev_doc(&bytes);
let original_country = field_value(&doc, "country");
let err = {
let mut form = doc.form_mut();
apply_with_question_mark(&mut form).unwrap_err()
};
assert!(
matches!(err, pdfluent::Error::Internal { .. }),
"expected the `?` try-chain to return the setter error, got {err:?}",
);
assert_eq!(field_value(&doc, "first_name").as_deref(), Some("Jane"));
assert_eq!(field_value(&doc, "agree_terms").as_deref(), Some("Yes"));
assert_eq!(
field_value(&doc, "preferred_color").as_deref(),
Some("Green"),
);
assert_eq!(field_value(&doc, "country"), original_country);
}
#[test]
fn save_and_reopen_preserves_all_mutations() {
let bytes = build_full_form_pdf();
let mut doc = dev_doc(&bytes);
doc.form_mut()
.set_text("first_name", "Jane")
.and_then(|f| f.set_checkbox("agree_terms", true))
.and_then(|f| f.set_radio("preferred_color", "Blue"))
.and_then(|f| f.set_dropdown("country", "NL"))
.expect("chain");
let serialised = doc.to_bytes().expect("to_bytes");
let reopened = dev_doc(&serialised);
assert_eq!(
field_value(&reopened, "first_name").as_deref(),
Some("Jane"),
);
assert_eq!(
field_value(&reopened, "agree_terms").as_deref(),
Some("Yes"),
);
assert_eq!(
field_value(&reopened, "preferred_color").as_deref(),
Some("Blue"),
);
assert_eq!(field_value(&reopened, "country").as_deref(), Some("NL"));
}
#[test]
fn set_on_unknown_field_errors() {
let bytes = build_full_form_pdf();
let mut doc = dev_doc(&bytes);
let err = doc
.form_mut()
.set_text("does_not_exist", "x")
.expect_err("unknown field must error");
let msg = format!("{err}");
assert!(
msg.contains("does_not_exist"),
"error message should mention the missing field name, got: {msg}",
);
assert_eq!(err.code(), "E-INTERNAL");
}
#[test]
fn set_text_on_checkbox_errors() {
let bytes = build_full_form_pdf();
let mut doc = dev_doc(&bytes);
let err = doc
.form_mut()
.set_text("agree_terms", "x")
.expect_err("wrong type must error");
let msg = format!("{err}");
assert!(
msg.contains("agree_terms") && msg.contains("text"),
"error should mention field name and requested operation, got: {msg}",
);
}
#[test]
fn set_checkbox_on_text_errors() {
let bytes = build_full_form_pdf();
let mut doc = dev_doc(&bytes);
let err = doc
.form_mut()
.set_checkbox("first_name", true)
.expect_err("wrong type must error");
let msg = format!("{err}");
assert!(msg.contains("first_name"));
assert!(msg.contains("checkbox"));
}
#[test]
fn set_radio_on_dropdown_errors() {
let bytes = build_full_form_pdf();
let mut doc = dev_doc(&bytes);
let err = doc
.form_mut()
.set_radio("country", "US")
.expect_err("wrong type must error");
let msg = format!("{err}");
assert!(msg.contains("country"));
assert!(msg.contains("radio"));
}
#[test]
fn form_mut_is_always_constructable() {
let mut doc = PdfDocument::open("tests/fixtures/sample.pdf").expect("open sample");
let _handle = doc.form_mut();
}
#[test]
fn setter_on_formless_document_errors_cleanly() {
let mut doc = PdfDocument::open("tests/fixtures/sample.pdf").expect("open sample");
let err = doc
.form_mut()
.set_text("anything", "x")
.expect_err("no form ⇒ error");
let msg = format!("{err}");
assert!(
msg.to_lowercase().contains("acroform") || msg.contains("not found"),
"should explain the document has no form, got: {msg}",
);
}
#[test]
fn unicode_text_values_roundtrip() {
let bytes = build_full_form_pdf();
let mut doc = dev_doc(&bytes);
doc.form_mut()
.set_text("first_name", "Renée 北京")
.and_then(|f| f.set_dropdown("country", "日本"))
.expect("fill unicode");
let serialised = doc.to_bytes().expect("to_bytes");
let reopened = dev_doc(&serialised);
assert_eq!(
field_value(&reopened, "first_name").as_deref(),
Some("Renée 北京"),
);
assert_eq!(field_value(&reopened, "country").as_deref(), Some("日本"));
}
fn build_kid_widget_checkbox_pdf() -> Vec<u8> {
let mut doc = Document::with_version("1.4");
let content_id = doc.add_object(Stream::new(dictionary! {}, Vec::new()));
let pages_id = doc.new_object_id();
let kid_widget = doc.add_object(dictionary! {
"Type" => "Annot",
"Subtype" => "Widget",
"AP" => dictionary! {
"N" => dictionary! {
"Off" => Object::Null,
"On1" => Object::Null,
},
},
});
let checkbox_field = doc.add_object(dictionary! {
"FT" => "Btn",
"T" => Object::string_literal("subscribe"),
"V" => Object::Name(b"Off".to_vec()),
"Kids" => vec![kid_widget.into()],
});
let page_id = doc.add_object(dictionary! {
"Type" => "Page",
"Parent" => pages_id,
"MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
"Contents" => content_id,
"Resources" => dictionary! {},
"Annots" => vec![kid_widget.into()],
});
doc.objects.insert(
pages_id,
Object::Dictionary(dictionary! {
"Type" => "Pages",
"Kids" => vec![page_id.into()],
"Count" => 1,
}),
);
let acroform_id = doc.add_object(dictionary! {
"Fields" => vec![checkbox_field.into()],
});
let catalog_id = doc.add_object(dictionary! {
"Type" => "Catalog",
"Pages" => pages_id,
"AcroForm" => acroform_id,
});
doc.trailer.set("Root", catalog_id);
let mut buf = Vec::new();
doc.save_to(&mut buf).expect("serialise fixture");
buf
}
#[test]
fn checkbox_on_state_resolves_from_kid_widget() {
let bytes = build_kid_widget_checkbox_pdf();
let mut doc = dev_doc(&bytes);
doc.form_mut()
.set_checkbox("subscribe", true)
.expect("set_checkbox");
assert_eq!(
field_value(&doc, "subscribe").as_deref(),
Some("On1"),
"resolver should have picked up the kid's /AP/N on-state instead of defaulting to Yes",
);
}
#[test]
fn flatten_forms_returns_missing_dependency_not_panic() {
let bytes = build_full_form_pdf();
let mut doc = dev_doc(&bytes);
let err = doc.flatten_forms().expect_err("flatten deferred");
assert_eq!(err.code(), "E-ENV-MISSING-DEPENDENCY");
let msg = format!("{err}");
assert!(
msg.contains("flatten") || msg.contains("1223"),
"error should explain the deferred-runtime state, got: {msg}",
);
}