Files
pdfcraft/crates/forms/src/tests.rs
T
martinjohuberandClaude Opus 5.5 d9047d4d5b Forms: radio groups and check boxes accept their /Opt export values (#532)
pdf-lib and other writers give radio groups and check boxes an /Opt array
(PDF 1.4, one export value per widget) and name the on states by position
(/0, /1 ...). Filling such a field by its export value ("Pick-up") failed with
"has no option", and form_fields listed the options as 0, 1.

- Field::export_of / state_for / export_for_state map between a widget, its
  /Opt export value and its on-state name; fields without /Opt are unchanged
- set_value accepts an export value for radio groups and check boxes; the
  "has no option" error lists the export values
- form_fields reports a radio group's options and selected value as export
  values
- Test: opt_export_values_select_button_states

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:19:36 -04:00

869 lines
46 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use std::sync::Arc;
use pdfcraft_cos::{Document, Object, SaveOptions, write_incremental};
use super::*;
/// One page with a form: text fields (plain, multiline, comb, password, read-only, nested),
/// a check box with appearances, one without, a radio group, a combo and a multi-select list,
/// and a push button.
fn fixture() -> Document {
let objs: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), // 1
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), // 2
"<< /Type /Page /Parent 2 0 R /Annots [10 0 R 11 0 R 12 0 R 13 0 R 14 0 R 16 0 R 17 0 R 21 0 R 22 0 R 23 0 R 24 0 R 25 0 R 26 0 R] >>".into(), // 3
"<< /Fields [10 0 R 11 0 R 12 0 R 13 0 R 14 0 R 15 0 R 17 0 R 20 0 R 23 0 R 24 0 R 25 0 R 26 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R /ZaDb 6 0 R >> >> >>".into(), // 4
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), // 5
"<< /Type /Font /Subtype /Type1 /BaseFont /ZapfDingbats >>".into(), // 6
"<< /Length 0 >>\nstream\n\nendstream".into(), // 7 (empty appearance)
"<< /Length 0 >>\nstream\n\nendstream".into(), // 8
"null".into(), // 9
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /Rect [50 700 250 720] /P 3 0 R /V (Ada) /DV (Ada) /MK << /BG [1 1 0.9] /BC [0 0 0] >> >>".into(), // 10
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (notes) /Ff 4096 /DA (/Helv 10 Tf 0 0 1 rg) /Rect [50 600 250 680] /P 3 0 R >>".into(), // 11
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (zip) /Ff 16777216 /MaxLen 5 /Rect [50 560 150 580] /P 3 0 R >>".into(), // 12
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (pin) /Ff 8192 /Rect [50 530 150 550] /P 3 0 R >>".into(), // 13
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (id) /Ff 1 /V (A-1) /Rect [50 500 150 520] /P 3 0 R >>".into(), // 14
"<< /T (address) /Kids [16 0 R] >>".into(), // 15
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (city) /Parent 15 0 R /Q 1 /Rect [50 470 250 490] /P 3 0 R >>".into(), // 16
"<< /Type /Annot /Subtype /Widget /FT /Btn /T (agree) /V /Off /AS /Off /Rect [300 700 315 715] /P 3 0 R /AP << /N << /Yes 7 0 R /Off 8 0 R >> >> >>".into(), // 17
"null".into(), // 18
"null".into(), // 19
"<< /FT /Btn /T (size) /Ff 49152 /V /Off /DV /S /Kids [21 0 R 22 0 R] >>".into(), // 20 radio, NoToggleToOff
"<< /Type /Annot /Subtype /Widget /Parent 20 0 R /AS /Off /Rect [300 650 315 665] /P 3 0 R /AP << /N << /S 7 0 R /Off 8 0 R >> >> >>".into(), // 21
"<< /Type /Annot /Subtype /Widget /Parent 20 0 R /AS /Off /Rect [330 650 345 665] /P 3 0 R /AP << /N << /L 7 0 R /Off 8 0 R >> >> >>".into(), // 22
"<< /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(), // 23
"<< /Type /Annot /Subtype /Widget /FT /Ch /T (toppings) /Ff 2097152 /Opt [(Cheese) (Ham) (Olives)] /Rect [300 500 450 580] /P 3 0 R >>".into(), // 24
"<< /Type /Annot /Subtype /Widget /FT /Btn /T (go) /Ff 65536 /Rect [300 450 380 470] /P 3 0 R >>".into(), // 25
"<< /Type /Annot /Subtype /Widget /FT /Btn /T (bare) /Rect [400 700 415 715] /P 3 0 R /Foo (kept) >>".into(), // 26 check box without AP
];
document(&objs)
}
/// A PDF of the given objects (numbered from 1).
fn document(objs: &[String]) -> Document {
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
Document::open(Arc::new(out)).expect("opens")
}
fn reopen(doc: &Document) -> Document {
let bytes = write_incremental(doc, &SaveOptions::default()).expect("writes");
hayro_syntax::Pdf::new(bytes.clone()).expect("hayro-syntax parses the output");
Document::open(Arc::new(bytes)).expect("reopens")
}
fn field<'a>(all: &'a [Field], name: &str) -> &'a Field {
all.iter().find(|f| f.name == name).unwrap_or_else(|| panic!("no field {name}"))
}
fn ap(doc: &Document, w: &Widget) -> String {
let wd = doc.get(w.obj).as_dict().cloned().unwrap();
let n = wd.get(b"AP").unwrap().as_dict().unwrap().reference(b"N").expect("normal appearance stream");
let Object::Stream(s) = &*doc.get(n) else { panic!() };
String::from_utf8_lossy(&s.decoded().unwrap()).into_owned()
}
#[test]
fn the_field_tree_is_read_with_inheritance() {
let doc = fixture();
let all = fields(&doc);
let names: Vec<&str> = all.iter().map(|f| f.name.as_str()).collect();
assert_eq!(names, ["name", "notes", "zip", "pin", "id", "address.city", "agree", "size", "country", "toppings", "go", "bare"]);
let name = field(&all, "name");
assert_eq!((name.kind, name.value.as_slice(), name.da.as_str()), (FieldKind::Text, &["Ada".to_string()][..], "/Helv 0 Tf 0 g"));
assert_eq!(name.widgets[0].page, Some(0));
assert_eq!(field(&all, "zip").max_len, Some(5));
assert!(field(&all, "id").read_only());
let size = field(&all, "size");
assert_eq!(size.kind, FieldKind::Radio);
assert_eq!(size.widgets.iter().map(|w| w.on_state.clone().unwrap()).collect::<Vec<_>>(), ["S", "L"]);
assert!(size.value.is_empty(), "/Off is no value");
assert_eq!(size.default, ["S"]);
assert_eq!(field(&all, "country").options, [("ca".into(), "Canada".into()), ("fr".into(), "France".into())]);
assert_eq!(field(&all, "toppings").kind, FieldKind::List);
assert_eq!(field(&all, "go").kind, FieldKind::PushButton);
assert_eq!(field(&all, "address.city").quadding, 1);
}
/// pdf-lib and other writers give radio groups and check boxes an `/Opt` array and name the on
/// states by position (`/0`, `/1`): the export values select them, as in Acrobat.
#[test]
fn opt_export_values_select_button_states() {
let objs: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), // 1
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), // 2
"<< /Type /Page /Parent 2 0 R /Annots [6 0 R 7 0 R 8 0 R] >>".into(), // 3
"<< /Fields [5 0 R 8 0 R] /DA (/Helv 0 Tf 0 g) >>".into(), // 4
"<< /FT /Btn /T (ship) /Ff 49152 /Opt [(Post) (Pick-up)] /V /Off /Kids [6 0 R 7 0 R] >>".into(), // 5 radio
"<< /Type /Annot /Subtype /Widget /Parent 5 0 R /AS /Off /Rect [100 700 115 715] /P 3 0 R /AP << /N << /0 9 0 R /Off 10 0 R >> >> >>".into(), // 6
"<< /Type /Annot /Subtype /Widget /Parent 5 0 R /AS /Off /Rect [200 700 215 715] /P 3 0 R /AP << /N << /1 9 0 R /Off 10 0 R >> >> >>".into(), // 7
"<< /Type /Annot /Subtype /Widget /FT /Btn /T (terms) /Opt [(Accepted)] /V /Off /AS /Off /Rect [100 650 115 665] /P 3 0 R /AP << /N << /0 9 0 R /Off 10 0 R >> >> >>".into(), // 8 check box
"<< /Length 0 >>\nstream\n\nendstream".into(), // 9
"<< /Length 0 >>\nstream\n\nendstream".into(), // 10
];
let mut doc = document(&objs);
let all = fields(&doc);
let ship = field(&all, "ship");
assert_eq!((0..2).map(|i| ship.export_of(i)).collect::<Vec<_>>(), [Some("Post"), Some("Pick-up")]);
assert_eq!((ship.state_for("Pick-up"), ship.state_for("1"), ship.state_for("Courier")), (Some("1"), Some("1"), None));
set_value(&mut doc, "ship", &FieldValue::Radio(Some("Pick-up".into()))).unwrap();
set_value(&mut doc, "terms", &FieldValue::Text("Accepted".into())).unwrap();
let mut doc = reopen(&doc);
let all = fields(&doc);
let ship = field(&all, "ship");
assert_eq!(ship.value, ["1"]);
assert_eq!(ship.export_for_state("1"), "Pick-up");
assert_eq!(ship.widgets.iter().map(|w| w.state.as_deref()).collect::<Vec<_>>(), [Some("Off"), Some("1")]);
assert_eq!(field(&all, "terms").value, ["0"]);
let err = set_value(&mut doc, "ship", &FieldValue::Radio(Some("Courier".into()))).unwrap_err();
assert!(err.to_string().contains("options: Post, Pick-up"), "{err}");
}
#[test]
fn text_fields_get_new_appearances() {
let mut doc = fixture();
set_value(&mut doc, "name", &FieldValue::Text("Grace (Hopper)".into())).unwrap();
set_value(&mut doc, "notes", &FieldValue::Text("A long note that has to wrap over several lines of the box".into())).unwrap();
set_value(&mut doc, "zip", &FieldValue::Text("12345".into())).unwrap();
set_value(&mut doc, "pin", &FieldValue::Text("1234".into())).unwrap();
set_value(&mut doc, "address.city", &FieldValue::Text("Zürich".into())).unwrap();
let doc = reopen(&doc);
let all = fields(&doc);
assert_eq!(field(&all, "name").value, ["Grace (Hopper)"]);
let name_ap = ap(&doc, &field(&all, "name").widgets[0]);
assert!(name_ap.contains("/Tx BMC") && name_ap.contains("EMC"), "{name_ap}");
assert!(name_ap.contains("(Grace \\(Hopper\\)) Tj"), "{name_ap}");
assert!(name_ap.contains("1 1 0.9 rg"), "background from /MK: {name_ap}");
let notes = ap(&doc, &field(&all, "notes").widgets[0]);
assert!(notes.matches(" Tj").count() >= 2, "wrapped: {notes}");
assert!(notes.contains("/Helv 10 Tf") && notes.contains("0 0 1 rg"));
let zip = ap(&doc, &field(&all, "zip").widgets[0]);
assert_eq!(zip.matches(" Tj").count(), 5, "one cell per character: {zip}");
let pin = ap(&doc, &field(&all, "pin").widgets[0]);
assert!(pin.contains("(****) Tj") && !pin.contains("1234"), "{pin}");
// WinAnsi bytes for non-ASCII text.
let w = &field(&all, "address.city").widgets[0];
let n = doc.get(w.obj).as_dict().unwrap().get(b"AP").unwrap().as_dict().unwrap().reference(b"N").unwrap();
let Object::Stream(s) = &*doc.get(n) else { panic!() };
let raw = s.decoded().unwrap();
assert!(raw.windows(8).any(|x| x == b"(Z\xfcrich)"));
}
#[test]
fn check_boxes_and_radios_switch_states() {
let mut doc = fixture();
set_value(&mut doc, "agree", &FieldValue::Check(true)).unwrap();
set_value(&mut doc, "size", &FieldValue::Radio(Some("L".into()))).unwrap();
set_value(&mut doc, "bare", &FieldValue::Text("yes".into())).unwrap();
let doc2 = reopen(&doc);
let all = fields(&doc2);
assert_eq!(field(&all, "agree").value, ["Yes"]);
assert_eq!(field(&all, "agree").widgets[0].state.as_deref(), Some("Yes"));
let size = field(&all, "size");
assert_eq!(size.value, ["L"]);
assert_eq!(size.widgets.iter().map(|w| w.state.clone().unwrap()).collect::<Vec<_>>(), ["Off", "L"]);
// The bare check box got PdfCraft's own appearances and kept its other keys.
let bare = field(&all, "bare");
assert_eq!(bare.value, ["Yes"]);
assert_eq!(bare.widgets[0].on_state.as_deref(), Some("Yes"));
assert!(doc2.get(bare.widgets[0].obj).as_dict().unwrap().contains(b"Foo"));
let mut doc = doc2;
// Radio validation and NoToggleToOff.
assert!(matches!(set_value(&mut doc, "size", &FieldValue::Radio(Some("XL".into()))), Err(FormError::Invalid(_))));
assert!(matches!(set_value(&mut doc, "size", &FieldValue::Radio(None)), Err(FormError::Invalid(_))));
set_value(&mut doc, "agree", &FieldValue::Check(false)).unwrap();
assert!(field(&fields(&doc), "agree").value.is_empty());
}
#[test]
fn choices_accept_exports_or_display_text() {
let mut doc = fixture();
set_value(&mut doc, "country", &FieldValue::Text("France".into())).unwrap();
set_value(&mut doc, "toppings", &FieldValue::Choice(vec!["Ham".into(), "Olives".into()])).unwrap();
let doc = reopen(&doc);
let all = fields(&doc);
let country = field(&all, "country");
assert_eq!(country.value, ["fr"]);
assert_eq!(country.display_value(), "France");
assert!(ap(&doc, &country.widgets[0]).contains("(France) Tj"));
let toppings = field(&all, "toppings");
assert_eq!(toppings.value, ["Ham", "Olives"]);
let list = ap(&doc, &toppings.widgets[0]);
assert_eq!(list.matches("0.6 0.75 0.86 rg").count(), 2, "two highlighted rows: {list}");
assert_eq!(doc.get(toppings.obj).as_dict().unwrap().get(b"I").unwrap().as_array().unwrap().len(), 2);
let mut doc = doc;
assert!(matches!(set_value(&mut doc, "country", &FieldValue::Text("Spain".into())), Err(FormError::Invalid(_))));
assert!(matches!(set_value(&mut doc, "country", &FieldValue::Choice(vec!["ca".into(), "fr".into()])), Err(FormError::Invalid(_))));
}
#[test]
fn errors_are_specific_and_change_nothing() {
let mut doc = fixture();
assert_eq!(set_value(&mut doc, "nope", &FieldValue::Text("x".into())), Err(FormError::NoSuchField("nope".into())));
assert_eq!(set_value(&mut doc, "id", &FieldValue::Text("x".into())), Err(FormError::ReadOnly("id".into())));
assert!(matches!(set_value(&mut doc, "zip", &FieldValue::Text("123456".into())), Err(FormError::Invalid(_))));
assert!(matches!(set_value(&mut doc, "go", &FieldValue::Text("x".into())), Err(FormError::Invalid(_))));
assert!(matches!(set_value(&mut doc, "agree", &FieldValue::Text("maybe".into())), Err(FormError::Invalid(_))));
assert!(!doc.is_modified());
}
#[test]
fn reset_restores_defaults() {
let mut doc = fixture();
set_value(&mut doc, "name", &FieldValue::Text("Grace".into())).unwrap();
set_value(&mut doc, "agree", &FieldValue::Check(true)).unwrap();
set_value(&mut doc, "size", &FieldValue::Radio(Some("L".into()))).unwrap();
assert_eq!(reset(&mut doc, Some(&["agree".to_string()])).unwrap(), 1);
assert!(field(&fields(&doc), "agree").value.is_empty());
assert_eq!(field(&fields(&doc), "name").value, ["Grace"], "only the named field");
let n = reset(&mut doc, None).unwrap();
assert!(n >= 2);
let all = fields(&reopen(&doc));
assert_eq!(field(&all, "name").value, ["Ada"]);
assert_eq!(field(&all, "size").value, ["S"], "back to /DV");
assert!(matches!(reset(&mut doc, Some(&["nope".to_string()])), Err(FormError::NoSuchField(_))));
}
#[test]
fn documents_without_forms_say_so() {
let mut doc = Document::new_empty();
assert!(fields(&doc).is_empty());
assert_eq!(set_value(&mut doc, "x", &FieldValue::Text("y".into())), Err(FormError::NoForm));
}
#[test]
fn every_field_type_can_be_added_named_and_drawn() {
let mut doc = Document::new_empty();
{
// One page.
let pages = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"Pages").unwrap();
let mut p = pdfcraft_cos::Dict::new();
p.set(b"Type".to_vec(), Object::name("Page"));
p.set(b"Parent".to_vec(), Object::Ref(pages));
p.set(b"MediaBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 600.into(), 800.into()]));
let r = doc.add(p);
doc.update_dict(pages, |d| {
d.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(r)]));
d.set(b"Count".to_vec(), Object::Int(1));
})
.unwrap();
}
let r = |y: f64| [50.0, y, 250.0, y + 20.0];
let names: Vec<String> = [
(NewField::Text { multiline: false }, r(700.0)),
(NewField::Text { multiline: true }, [50.0, 600.0, 250.0, 680.0]),
(NewField::Date, r(560.0)),
(NewField::CheckBox, [50.0, 520.0, 64.0, 534.0]),
(NewField::Radio { group: None, export: "Small".into() }, [50.0, 480.0, 64.0, 494.0]),
(NewField::Combo { options: vec!["Red".into(), "Blue".into()], editable: false }, r(440.0)),
(NewField::List { options: vec!["A".into(), "B".into(), "C".into()], multi: true }, [50.0, 340.0, 250.0, 420.0]),
(NewField::Button { caption: "Submit".into() }, r(300.0)),
(NewField::Signature, [50.0, 220.0, 250.0, 270.0]),
]
.iter()
.map(|(k, rect)| add_field(&mut doc, 0, *rect, k, None).unwrap())
.collect();
assert_eq!(names, ["Text1", "Text2", "Date1", "Check Box1", "Group1", "Dropdown1", "List Box1", "Button1", "Signature1"]);
// A second radio button joins the group.
assert_eq!(
add_field(&mut doc, 0, [80.0, 480.0, 94.0, 494.0], &NewField::Radio { group: Some("Group1".into()), export: "Large".into() }, None).unwrap(),
"Group1"
);
let doc = reopen(&doc);
let all = fields(&doc);
assert_eq!(all.len(), 9);
let group = field(&all, "Group1");
assert_eq!(group.widgets.iter().filter_map(|w| w.on_state.clone()).collect::<Vec<_>>(), ["Small", "Large"]);
assert!(field(&all, "Text2").has(flags::MULTILINE));
assert_eq!(field(&all, "Dropdown1").options.len(), 2);
assert!(field(&all, "List Box1").has(flags::MULTI_SELECT));
assert_eq!(field(&all, "Button1").kind, FieldKind::PushButton);
assert!(ap(&doc, &field(&all, "Button1").widgets[0]).contains("(Submit) Tj"));
// Every widget is on the page and has an appearance.
for f in &all {
for w in &f.widgets {
assert_eq!(w.page, Some(0), "{}", f.name);
assert!(doc.get(w.obj).as_dict().unwrap().contains(b"AP"), "{} has an appearance", f.name);
}
}
// The new fields can be filled.
let mut doc = doc;
set_value(&mut doc, "Text1", &FieldValue::Text("hello".into())).unwrap();
set_value(&mut doc, "Group1", &FieldValue::Radio(Some("Large".into()))).unwrap();
assert!(add_field(&mut doc, 0, r(100.0), &NewField::CheckBox, Some("Text1")).is_err(), "names are unique");
assert!(add_field(&mut doc, 0, [0.0, 0.0, 2.0, 2.0], &NewField::CheckBox, None).is_err(), "too small");
}
#[test]
fn properties_rename_and_delete() {
let mut doc = fixture();
let renamed = set_props(
&mut doc,
"name",
&FieldProps {
name: Some("full_name".into()),
tooltip: Some("Your name".into()),
required: Some(true),
max_len: Some(Some(20)),
font_size: Some(9.0),
..FieldProps::default()
},
)
.unwrap();
assert_eq!(renamed, "full_name");
let all = fields(&doc);
let f = field(&all, "full_name");
assert!(f.has(flags::REQUIRED) && f.max_len == Some(20) && f.tooltip.as_deref() == Some("Your name"));
assert!(ap(&doc, &f.widgets[0]).contains("/Helv 9 Tf"), "redrawn with the new size");
let nested = set_props(&mut doc, "address.city", &FieldProps { name: Some("town".into()), ..FieldProps::default() }).unwrap();
assert_eq!(nested, "address.town", "only the last part changes");
set_props(&mut doc, "country", &FieldProps { options: Some(vec!["Japan".into()]), ..FieldProps::default() }).unwrap();
assert_eq!(field(&fields(&doc), "country").options, [("Japan".to_string(), "Japan".to_string())]);
assert!(set_props(&mut doc, "zip", &FieldProps { name: Some("pin".into()), ..FieldProps::default() }).is_err(), "taken");
set_props(&mut doc, "full_name", &FieldProps { rect: Some((0, [10.0, 10.0, 110.0, 30.0])), ..FieldProps::default() }).unwrap();
let f = field(&fields(&doc), "full_name").clone();
assert_eq!(f.widgets[0].rect, [10.0, 10.0, 110.0, 30.0]);
assert!(ap(&doc, &f.widgets[0]).contains("100"), "the appearance takes the new size");
assert!(set_props(&mut doc, "full_name", &FieldProps { rect: Some((0, [0.0, 0.0, 2.0, 2.0])), ..FieldProps::default() }).is_err());
let before = fields(&doc).len();
delete_field(&mut doc, "size").unwrap();
delete_field(&mut doc, "address.town").unwrap();
let doc = reopen(&doc);
let all = fields(&doc);
assert_eq!(all.len(), before - 2);
assert!(!all.iter().any(|f| f.name == "size" || f.name == "address.town"));
let p = &page_refs(&doc)[0];
let annots = doc.resolve(doc.get(*p).as_dict().unwrap().get(b"Annots").unwrap());
assert_eq!(annots.as_array().unwrap().len(), 13 - 3, "the deleted widgets left the page");
}
#[test]
fn deleting_works_with_a_form_dictionary_inside_the_catalog() {
let mut doc = fixture();
let root = doc.root().unwrap();
let af = doc.get(pdfcraft_cos::ObjRef::new(4, 0)).as_dict().cloned().unwrap();
doc.update_dict(root, |d| d.set(b"AcroForm".to_vec(), Object::Dict(af))).unwrap();
let before = fields(&doc).len();
delete_field(&mut doc, "name").unwrap();
let all = fields(&reopen(&doc));
assert_eq!(all.len(), before - 1);
assert!(!all.iter().any(|f| f.name == "name"));
}
fn one_page() -> Document {
let mut doc = Document::new_empty();
let pages = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"Pages").unwrap();
let mut p = pdfcraft_cos::Dict::new();
p.set(b"Type".to_vec(), Object::name("Page"));
p.set(b"Parent".to_vec(), Object::Ref(pages));
p.set(b"MediaBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 600.into(), 800.into()]));
let r = doc.add(p);
doc.update_dict(pages, |d| {
d.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(r)]));
d.set(b"Count".to_vec(), Object::Int(1));
})
.unwrap();
doc
}
#[test]
fn formats_validation_and_calculations_run_like_acrobat() {
use crate::af::{CalcOp, Calculate, Format, Validate};
let mut doc = one_page();
let text = NewField::Text { multiline: false };
for (name, y) in [("Price", 700.0), ("Qty", 660.0), ("Total", 620.0), ("Count", 580.0), ("Due", 540.0)] {
add_field(&mut doc, 0, [50.0, y, 250.0, y + 20.0], &text, Some(name)).unwrap();
}
let money = Format::Number { decimals: 2, sep: 0, neg: 0, currency: "$".into(), prepend: true };
set_props(
&mut doc,
"Price",
&FieldProps { format: Some(money.clone()), validate: Some(Validate::Range { min: Some(0.0), max: None }), ..FieldProps::default() },
)
.unwrap();
set_props(
&mut doc,
"Qty",
&FieldProps {
format: Some(Format::Number { decimals: 0, sep: 0, neg: 0, currency: String::new(), prepend: true }),
validate: Some(Validate::Range { min: Some(1.0), max: Some(99.0) }),
..FieldProps::default()
},
)
.unwrap();
set_props(
&mut doc,
"Total",
&FieldProps {
format: Some(money),
calculate: Some(Calculate::Notation("Price * Qty".into())),
read_only: Some(true),
..FieldProps::default()
},
)
.unwrap();
set_props(
&mut doc,
"Count",
&FieldProps { calculate: Some(Calculate::Simple { op: CalcOp::Sum, fields: vec!["Qty".into()] }), ..FieldProps::default() },
)
.unwrap();
set_props(&mut doc, "Due", &FieldProps { format: Some(Format::Date("mmm d, yyyy".into())), ..FieldProps::default() }).unwrap();
// The scripts are Acrobat's, so Acrobat (and we, after a save) read them back.
let doc2 = reopen(&doc);
let total = field(&fields(&doc2), "Total").clone();
assert_eq!(total.actions.calculate, Calculate::Notation("Price * Qty".into()));
assert!(matches!(total.actions.format, Format::Number { decimals: 2, .. }));
let co =
doc2.get(doc2.root().unwrap()).as_dict().unwrap().get(b"AcroForm").cloned().map(|a| doc2.resolve(&a).as_dict().cloned().unwrap()).unwrap();
assert_eq!(doc2.resolve(co.get(b"CO").unwrap()).as_array().unwrap().len(), 2, "both calculated fields are in the calculation order");
let mut doc = doc2;
// Typing a formatted number stores the number and shows it formatted.
set_value(&mut doc, "Price", &FieldValue::Text("$1,234.5".into())).unwrap();
set_value(&mut doc, "Qty", &FieldValue::Text("2".into())).unwrap();
let all = fields(&doc);
assert_eq!(field(&all, "Price").value, ["1234.5"]);
assert!(ap(&doc, &field(&all, "Price").widgets[0]).contains("($1,234.50) Tj"));
// Calculations ran in order.
assert_eq!(field(&all, "Total").value, ["2469"]);
assert!(ap(&doc, &field(&all, "Total").widgets[0]).contains("($2,469.00) Tj"));
assert_eq!(field(&all, "Count").value, ["2"]);
// Keystroke and validation errors use Acrobat's messages and change nothing.
assert_eq!(
set_value(&mut doc, "Qty", &FieldValue::Text("lots".into())),
Err(FormError::Invalid("The value entered does not match the format of the field [ Qty ]".into()))
);
assert_eq!(
set_value(&mut doc, "Qty", &FieldValue::Text("120".into())),
Err(FormError::Invalid("Invalid value: must be greater than or equal to 1 and less than or equal to 99.".into()))
);
assert_eq!(field(&fields(&doc), "Qty").value, ["2"]);
// Dates: stored as typed, shown in the format; impossible dates are refused.
set_value(&mut doc, "Due", &FieldValue::Text("10/1/2026".into())).unwrap();
assert!(ap(&doc, &field(&fields(&doc), "Due").widgets[0]).contains("(Oct 1, 2026) Tj"));
assert!(set_value(&mut doc, "Due", &FieldValue::Text("2/30/2026".into())).is_err());
// Clear form recalculates.
reset(&mut doc, None).unwrap();
assert_eq!(field(&fields(&doc), "Total").value, ["0"]);
// Removing a format.
set_props(&mut doc, "Price", &FieldProps { format: Some(Format::None), ..FieldProps::default() }).unwrap();
assert_eq!(field(&fields(&doc), "Price").actions.format, Format::None);
}
#[test]
fn appearance_properties_restyle_every_widget() {
let mut doc = one_page();
add_field(&mut doc, 0, [50.0, 700.0, 250.0, 720.0], &NewField::Text { multiline: false }, Some("name")).unwrap();
set_value(&mut doc, "name", &FieldValue::Text("Ada".into())).unwrap();
let f = field(&fields(&doc), "name").clone();
let before = look(&doc, &f);
assert_eq!((before.font, before.style, before.text), (FieldFont::Helvetica, BorderStyle::Solid, [0.0; 3]));
let new = Look {
border: Some([1.0, 0.0, 0.0]),
fill: Some([1.0, 1.0, 0.8]),
width: 2.0,
style: BorderStyle::Dashed,
text: [0.0, 0.0, 1.0],
font: FieldFont::Times,
};
set_props(&mut doc, "name", &FieldProps { look: Some(new), font_size: Some(14.0), ..FieldProps::default() }).unwrap();
let doc = reopen(&doc);
let f = field(&fields(&doc), "name").clone();
assert_eq!(look(&doc, &f), new);
let a = ap(&doc, &f.widgets[0]);
assert!(a.contains("/TiRo 14 Tf") && a.contains("0 0 1 rg") && a.contains("[3] 0 d") && a.contains("(Ada) Tj"), "{a}");
let mut doc = doc;
set_props(&mut doc, "name", &FieldProps { look: Some(Look { style: BorderStyle::Underline, fill: None, ..new }), ..FieldProps::default() })
.unwrap();
let a = ap(&doc, &field(&fields(&doc), "name").widgets[0]);
assert!(!a.contains(" re S") && a.contains(" l S"), "underline only: {a}");
}
#[test]
fn tab_order_follows_the_page_setting() {
let mut doc = one_page();
let text = NewField::Text { multiline: false };
// Added in a scrambled order: annotation order is C, A, D, B.
// Layout: A (50,700) B (300,700)
// C (50,600) D (300,600)
for (name, x, y) in [("C", 50.0, 600.0), ("A", 50.0, 700.0), ("D", 300.0, 600.0), ("B", 300.0, 700.0)] {
add_field(&mut doc, 0, [x, y, x + 200.0, y + 20.0], &text, Some(name)).unwrap();
}
let order = |doc: &Document| -> String {
let mut all: Vec<(usize, String)> = fields(doc).into_iter().map(|f| (f.widgets[0].tab, f.name)).collect();
all.sort();
all.into_iter().map(|x| x.1).collect()
};
assert_eq!(order(&doc), "CADB", "unspecified: annotation order");
set_tab_order(&mut doc, &[0], TabOrder::Row).unwrap();
assert_eq!(order(&doc), "ABCD");
set_tab_order(&mut doc, &[0], TabOrder::Column).unwrap();
assert_eq!(order(&doc), "ACBD");
let doc = reopen(&doc);
assert_eq!(order(&doc), "ACBD", "saved as /Tabs");
assert!(set_tab_order(&mut doc.clone(), &[3], TabOrder::Row).is_err());
}
#[test]
fn options_tab_flags_alignment_and_defaults() {
let mut doc = one_page();
let text = add_field(&mut doc, 0, [50.0, 700.0, 250.0, 720.0], &NewField::Text { multiline: false }, None).unwrap();
let combo = add_field(
&mut doc,
0,
[50.0, 600.0, 250.0, 620.0],
&NewField::Combo { options: vec!["Pear".into(), "apple".into(), "Fig".into()], editable: false },
None,
)
.unwrap();
let check = add_field(&mut doc, 0, [50.0, 500.0, 64.0, 514.0], &NewField::CheckBox, None).unwrap();
// A comb needs a limit.
let comb = FieldProps { flags: vec![(flags::COMB, true)], ..FieldProps::default() };
assert!(matches!(set_props(&mut doc, &text, &comb), Err(FormError::Invalid(_))));
set_props(
&mut doc,
&text,
&FieldProps {
flags: vec![(flags::COMB, true), (flags::DO_NOT_SPELL_CHECK, true)],
max_len: Some(Some(6)),
quadding: Some(1),
default_value: Some(Some("ABC123".into())),
..FieldProps::default()
},
)
.unwrap();
let f = fields(&doc).into_iter().find(|f| f.name == text).unwrap();
assert!(f.has(flags::COMB) && f.has(flags::DO_NOT_SPELL_CHECK) && !f.has(flags::MULTILINE));
assert_eq!((f.max_len, f.quadding, f.default.clone()), (Some(6), 1, vec!["ABC123".to_string()]));
// Reset form restores the default.
set_value(&mut doc, &text, &FieldValue::Text("XYZ".into())).unwrap();
reset(&mut doc, None).unwrap();
assert_eq!(fields(&doc).into_iter().find(|f| f.name == text).unwrap().value, vec!["ABC123".to_string()]);
// Sort items orders the list; custom text allowed.
set_props(&mut doc, &combo, &FieldProps { flags: vec![(flags::SORT, true), (flags::EDIT, true)], ..FieldProps::default() }).unwrap();
let c = fields(&doc).into_iter().find(|f| f.name == combo).unwrap();
assert_eq!(c.options.iter().map(|(_, d)| d.as_str()).collect::<Vec<_>>(), vec!["apple", "Fig", "Pear"]);
assert!(c.has(flags::EDIT));
// Checked by default: the on state is the default.
let on = fields(&doc).into_iter().find(|f| f.name == check).unwrap().widgets[0].on_state.clone().unwrap();
set_props(&mut doc, &check, &FieldProps { default_value: Some(Some(on.clone())), ..FieldProps::default() }).unwrap();
reset(&mut doc, None).unwrap();
assert_eq!(fields(&doc).into_iter().find(|f| f.name == check).unwrap().value, vec![on]);
assert!(set_props(&mut doc, &text, &FieldProps { quadding: Some(5), ..FieldProps::default() }).is_err());
}
#[test]
fn check_box_styles_change_the_mark() {
// #94: Field Properties ▸ Options ▸ Check Box Style (stored as /MK /CA).
let mut doc = one_page();
let check = add_field(&mut doc, 0, [50.0, 700.0, 70.0, 720.0], &NewField::CheckBox, None).unwrap();
let radio = add_field(&mut doc, 0, [50.0, 600.0, 70.0, 620.0], &NewField::Radio { group: None, export: "A".into() }, None).unwrap();
let text = add_field(&mut doc, 0, [50.0, 500.0, 250.0, 520.0], &NewField::Text { multiline: false }, None).unwrap();
let style = |doc: &Document, name: &str| check_style(doc, field(&fields(doc), name));
assert_eq!((style(&doc, &check), style(&doc, &radio)), (CheckStyle::Check, CheckStyle::Circle), "the defaults");
// The "on" appearance: the widget's /AP /N entry for its on state.
let on_ap = |doc: &Document, name: &str| {
let all = fields(doc);
let w = &field(&all, name).widgets[0];
let on = w.on_state.clone().unwrap();
let n = doc
.get(w.obj)
.as_dict()
.unwrap()
.get(b"AP")
.unwrap()
.as_dict()
.unwrap()
.get(b"N")
.unwrap()
.as_dict()
.unwrap()
.reference(on.as_bytes())
.unwrap();
let Object::Stream(s) = &*doc.get(n) else { panic!() };
String::from_utf8_lossy(&s.decoded().unwrap()).into_owned()
};
let before = on_ap(&doc, &check);
for (s, code) in
[(CheckStyle::Cross, "8"), (CheckStyle::Square, "n"), (CheckStyle::Star, "H"), (CheckStyle::Diamond, "u"), (CheckStyle::Circle, "l")]
{
set_props(&mut doc, &check, &FieldProps { check_style: Some(s), ..FieldProps::default() }).unwrap();
let doc2 = reopen(&doc);
assert_eq!(style(&doc2, &check), s, "saved and read back");
let mk = doc2.get(field(&fields(&doc2), &check).widgets[0].obj).as_dict().unwrap().get(b"MK").unwrap().as_dict().cloned().unwrap();
assert_eq!(mk.get(b"CA").and_then(|c| c.as_string()).map(|c| c.to_text()).as_deref(), Some(code));
assert!(mk.get(b"BC").is_some(), "the border colour is kept");
assert_ne!(on_ap(&doc, &check), before, "{s:?} draws a different mark");
}
assert!(on_ap(&doc, &check).contains(" c f"), "a circle is a filled curve");
set_props(&mut doc, &radio, &FieldProps { check_style: Some(CheckStyle::Square), ..FieldProps::default() }).unwrap();
assert!(on_ap(&doc, &radio).contains(" re f"), "a square");
assert!(
set_props(&mut doc, &text, &FieldProps { check_style: Some(CheckStyle::Star), ..FieldProps::default() }).is_err(),
"text fields have no style"
);
}
#[test]
fn ordering_tabs_manually() {
let mut doc = one_page();
let text = NewField::Text { multiline: false };
for (name, x, y) in [("A", 50.0, 700.0), ("B", 300.0, 700.0), ("C", 50.0, 600.0)] {
add_field(&mut doc, 0, [x, y, x + 200.0, y + 20.0], &text, Some(name)).unwrap();
}
// A comment between the widgets keeps its place.
let page = page_refs(&doc)[0];
let mut note = pdfcraft_cos::Dict::new();
note.set(b"Type".to_vec(), Object::name("Annot"));
note.set(b"Subtype".to_vec(), Object::name("Text"));
note.set(b"Rect".to_vec(), Object::Array(vec![0.into(), 0.into(), 10.into(), 10.into()]));
let note = doc.add(Object::Dict(note));
doc.update_dict(page, |d| {
if let Some(Object::Array(a)) = d.get_mut(b"Annots") {
a.insert(1, Object::Ref(note));
}
})
.unwrap();
set_tab_order(&mut doc, &[0], TabOrder::Row).unwrap();
let order = |doc: &Document| -> String {
let mut all: Vec<(usize, String)> = fields(doc).into_iter().map(|f| (f.widgets[0].tab, f.name)).collect();
all.sort();
all.into_iter().map(|x| x.1).collect()
};
assert_eq!(order(&doc), "ABC");
move_in_tab_order(&mut doc, "C", true).unwrap();
assert_eq!(order(&doc), "ACB", "row order became manual");
move_in_tab_order(&mut doc, "A", false).unwrap();
assert_eq!(order(&doc), "CAB");
move_in_tab_order(&mut doc, "B", false).unwrap();
assert_eq!(order(&doc), "CAB", "already last");
let annots = doc.get(page).as_dict().unwrap().get(b"Annots").unwrap().as_array().unwrap().clone();
assert_eq!(annots[1].as_ref(), Some(note), "the comment stays where it was");
assert!(!doc.get(page).as_dict().unwrap().contains(b"Tabs"));
let doc = reopen(&doc);
assert_eq!(order(&doc), "CAB", "saved");
}
#[test]
fn duplicating_a_field_across_pages_shares_its_value() {
let mut doc = one_page();
// A second and third page.
let pages = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"Pages").unwrap();
for _ in 0..2 {
let mut p = pdfcraft_cos::Dict::new();
p.set(b"Type".to_vec(), Object::name("Page"));
p.set(b"Parent".to_vec(), Object::Ref(pages));
p.set(b"MediaBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 600.into(), 800.into()]));
let r = doc.add(p);
doc.update_dict(pages, |d| {
let mut kids = d.get(b"Kids").unwrap().as_array().unwrap().clone();
kids.push(Object::Ref(r));
d.set(b"Count".to_vec(), Object::Int(kids.len() as i64));
d.set(b"Kids".to_vec(), Object::Array(kids));
})
.unwrap();
}
let name = add_field(&mut doc, 0, [50.0, 750.0, 250.0, 770.0], &NewField::Text { multiline: false }, Some("Initials")).unwrap();
assert_eq!(duplicate_field(&mut doc, &name, &[0, 1, 2]).unwrap(), 2, "page 1 already has it");
let f = fields(&doc).into_iter().find(|f| f.name == name).unwrap();
assert_eq!(f.widgets.iter().filter_map(|w| w.page).collect::<Vec<_>>(), vec![0, 1, 2]);
assert!(f.widgets.iter().all(|w| w.rect == [50.0, 750.0, 250.0, 770.0]));
set_value(&mut doc, &name, &FieldValue::Text("AL".into())).unwrap();
let doc = reopen(&doc);
let f = fields(&doc).into_iter().find(|f| f.name == name).unwrap();
assert_eq!((f.value.clone(), f.widgets.len()), (vec!["AL".to_string()], 3), "one field, one value, three widgets");
assert_eq!(fields(&doc).len(), 1);
assert!(duplicate_field(&mut doc.clone(), &name, &[7]).is_err());
}
#[test]
fn locked_fields_only_take_unlocking() {
let mut doc = one_page();
let name = add_field(&mut doc, 0, [50.0, 700.0, 250.0, 720.0], &NewField::Text { multiline: false }, Some("ID")).unwrap();
set_props(&mut doc, &name, &FieldProps { locked: Some(true), ..FieldProps::default() }).unwrap();
assert!(fields(&doc)[0].locked());
assert!(set_props(&mut doc, &name, &FieldProps { required: Some(true), ..FieldProps::default() }).is_err(), "locked");
// Values can still be filled in.
set_value(&mut doc, &name, &FieldValue::Text("42".into())).unwrap();
set_props(&mut doc, &name, &FieldProps { locked: Some(false), required: Some(true), ..FieldProps::default() }).unwrap();
let f = &fields(&doc)[0];
assert!(!f.locked() && f.has(flags::REQUIRED));
}
#[test]
fn image_fields_ask_for_a_picture_and_show_it() {
let mut doc = one_page();
let name = add_field(&mut doc, 0, [50.0, 600.0, 250.0, 700.0], &NewField::Image, None).unwrap();
assert_eq!(name, "Image1");
let f = fields(&doc).into_iter().find(|f| f.name == name).unwrap();
assert_eq!((f.kind, f.button.clone()), (FieldKind::PushButton, Some(af::ButtonAction::ImportIcon)));
// A 4×2 image: the icon keeps its shape and is centred in the 200×100 box.
let mut d = Dict::new();
for (k, v) in
[(&b"Type"[..], Object::name("XObject")), (b"Subtype", Object::name("Image")), (b"Width", Object::Int(4)), (b"Height", Object::Int(2))]
{
d.set(k.to_vec(), v);
}
let img = doc.add(Object::Stream(pdfcraft_cos::Stream::from_raw(d, vec![0; 24])));
set_button_icon(&mut doc, &name, img, (4, 2)).unwrap();
let w = doc.get(f.widgets[0].obj).as_dict().cloned().unwrap();
let mk = w.get(b"MK").unwrap().as_dict().unwrap().clone();
assert_eq!(mk.int(b"TP"), Some(1));
let ap = w.get(b"AP").unwrap().as_dict().unwrap().reference(b"N").unwrap();
let Object::Stream(s) = &*doc.get(ap) else { panic!("appearance") };
let text = String::from_utf8(s.decoded().unwrap()).unwrap();
assert!(text.contains("/Icon Do") && text.contains("49.000000 0 0 49.000000 2.000 1.000 cm"), "{text}");
assert!(set_button_icon(&mut doc, "nope", img, (1, 1)).is_err());
}
#[test]
fn field_actions_round_trip_on_every_trigger() {
let mut doc = fixture();
let name = "go".to_string();
let acts = vec![
(Trigger::MouseUp, FieldAction::JavaScript("app.alert('hi')".into())),
(Trigger::MouseEnter, FieldAction::ShowHide { fields: vec!["other".into()], hide: false }),
(Trigger::OnFocus, FieldAction::Uri("https://example.org".into())),
(Trigger::OnBlur, FieldAction::Reset(vec![])),
(Trigger::MouseDown, FieldAction::GoTo(0)),
];
set_field_actions(&mut doc, &name, &acts).unwrap();
let mut got = field_actions(&doc, &name).unwrap();
let mut want = acts.clone();
let key = |x: &(Trigger, FieldAction)| Trigger::ALL.iter().position(|t| *t == x.0);
got.sort_by_key(key);
want.sort_by_key(key);
assert_eq!(got, want);
set_field_actions(&mut doc, &name, &[(Trigger::MouseExit, FieldAction::Named("NextPage".into()))]).unwrap();
assert_eq!(field_actions(&doc, &name).unwrap(), [(Trigger::MouseExit, FieldAction::Named("NextPage".into()))]);
// The Mouse Up script is what the button runs.
set_field_actions(&mut doc, &name, &[(Trigger::MouseUp, FieldAction::JavaScript("this.print();".into()))]).unwrap();
assert_eq!(fields(&doc).iter().find(|f| f.name == "go").unwrap().button, Some(af::ButtonAction::Named("Print".into())));
}
#[test]
fn push_buttons_read_hide_actions() {
let mut doc = fixture();
let go = field(&fields(&doc), "go").obj;
let s = |t: &str| Object::String(PdfString::text(t));
let city = ObjRef::new(16, 0);
let null = ObjRef::new(9, 0);
let button = |doc: &mut Document, t: Object, h: Option<bool>| {
let mut a = Dict::new();
a.set(b"S".to_vec(), Object::name("Hide"));
a.set(b"T".to_vec(), t);
if let Some(h) = h {
a.set(b"H".to_vec(), Object::Bool(h));
}
doc.update_dict(go, |d| d.set(b"A".to_vec(), Object::Dict(a))).unwrap();
field(&fields(doc), "go").button.clone()
};
let hide = |names: &[&str], hide: bool| Some(af::ButtonAction::ShowHide { fields: names.iter().map(|n| n.to_string()).collect(), hide });
// A name; /H defaults to true (hide).
assert_eq!(button(&mut doc, s("name"), None), hide(&["name"], true));
// A widget reference names its field by its fully qualified name; /H false shows.
assert_eq!(button(&mut doc, Object::Ref(city), Some(false)), hide(&["address.city"], false));
// An array of both; a reference to something that isn't a field is skipped.
assert_eq!(button(&mut doc, Object::Array(vec![s("name"), Object::Ref(city), Object::Ref(null)]), None), hide(&["name", "address.city"], true));
// A /Parent cycle ends instead of looping.
let looped = doc.add(Object::Dict(Dict::new()));
doc.update_dict(looped, |d| {
d.set(b"T".to_vec(), s("loop"));
d.set(b"Parent".to_vec(), Object::Ref(looped));
})
.unwrap();
assert_eq!(button(&mut doc, Object::Ref(looped), None), hide(&["loop"], true));
}
#[test]
fn push_buttons_read_set_layer_actions() {
use af::LayerOp::{Off, On, Toggle};
let mut doc = fixture();
let go = field(&fields(&doc), "go").obj;
let ocg = |n: u32| Object::Ref(ObjRef::new(n, 0));
let button = |doc: &mut Document, state: Option<Object>, preserve_rb: Option<Object>| {
let mut a = Dict::new();
a.set(b"S".to_vec(), Object::name("SetOCGState"));
if let Some(state) = state {
a.set(b"State".to_vec(), state);
}
if let Some(p) = preserve_rb {
a.set(b"PreserveRB".to_vec(), p);
}
doc.update_dict(go, |d| d.set(b"A".to_vec(), Object::Dict(a))).unwrap();
field(&fields(doc), "go").button.clone()
};
let layers = |changes: &[(af::LayerOp, u32)], preserve_rb: bool| {
Some(af::ButtonAction::SetLayers { changes: changes.iter().map(|&(op, n)| (op, (n, 0))).collect(), preserve_rb })
};
// Each name applies to the groups after it; /PreserveRB false is read.
let state = Object::Array(vec![Object::name("ON"), ocg(30), ocg(31), Object::name("OFF"), ocg(32), Object::name("Toggle"), ocg(30)]);
assert_eq!(button(&mut doc, Some(state), Some(Object::Bool(false))), layers(&[(On, 30), (On, 31), (Off, 32), (Toggle, 30)], false));
// An indirect /State; groups before the first name or after an unknown one, and entries that
// aren't groups, are skipped. /PreserveRB defaults to true, also when it isn't a boolean.
let state = doc.add(Object::Array(vec![
ocg(29),
Object::name("OFF"),
ocg(30),
Object::Int(7),
Object::name("Hide"),
ocg(31),
Object::name("Toggle"),
ocg(32),
]));
assert_eq!(button(&mut doc, Some(Object::Ref(state)), Some(Object::Int(0))), layers(&[(Off, 30), (Toggle, 32)], true));
// No /State: an action that changes nothing (the button still has an action).
assert_eq!(button(&mut doc, None, None), layers(&[], true));
}
#[test]
fn detection_finds_blanks_rules_boxes_and_names_them() {
use crate::detect::*;
let w = |t: &str, x0: f64, y0: f64, x1: f64| Word { text: t.into(), rect: [x0, y0, x1, y0 + 10.0] };
let words = vec![
w("Name:", 50.0, 700.0, 80.0),
w("______________", 85.0, 699.0, 250.0),
w("Date", 300.0, 700.0, 325.0),
w("of", 328.0, 700.0, 338.0),
w("birth", 341.0, 700.0, 365.0),
w("I", 72.0, 602.0, 75.0),
w("agree", 78.0, 602.0, 105.0),
w("Comments", 50.0, 560.0, 100.0),
w("Heading", 50.0, 750.0, 100.0),
];
let shapes = Shapes {
boxes: vec![[56.0, 600.0, 68.0, 612.0], [50.0, 500.0, 300.0, 540.0], [40.0, 20.0, 560.0, 780.0]],
rules: vec![[370.0, 699.0, 500.0, 699.0], [50.0, 748.0, 100.0, 748.0]],
};
let found = detect(&words, &shapes, &[], &["Comments".into()]);
let names: Vec<(&str, &Kind)> = found.iter().map(|c| (c.name.as_str(), &c.kind)).collect();
assert_eq!(names, [("Name", &Kind::Text), ("Date of birth", &Kind::Text), ("I agree", &Kind::CheckBox), ("Comments2", &Kind::Text)], "{found:?}");
// An existing field there: nothing new.
let again = detect(&words, &shapes, &found.iter().map(|c| c.rect).collect::<Vec<_>>(), &[]);
assert!(again.is_empty(), "{again:?}");
}
#[test]
fn page_shapes_reads_boxes_and_rules() {
let mut doc = fixture();
let page = pdfcraft_model::pages(&doc)[0].obj;
let s = doc.add(pdfcraft_cos::Object::Stream(pdfcraft_cos::Stream::flate(
Default::default(),
b"q 2 0 0 2 0 0 cm 10 10 6 6 re S 20 50 m 120 50 l S 0 0 m 5 5 l S 30 300 100 0.5 re f Q",
)));
doc.update_dict(page, |d| d.set(b"Contents".to_vec(), pdfcraft_cos::Object::Ref(s))).unwrap();
let sh = crate::detect::page_shapes(&doc, 0);
assert_eq!(sh.boxes, [[20.0, 20.0, 32.0, 32.0]]);
assert_eq!(sh.rules, [[40.0, 100.0, 240.0, 100.0], [60.0, 600.5, 260.0, 600.5]]);
}