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 = 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 legacy_two_call_enumeration(doc: &Document) -> (Vec, usize) { (enumerate(doc).0, enumerate(doc).1) } fn assert_combined_matches_legacy(doc: &Document, adopted: usize) { let (old_fields, old_adopted) = legacy_two_call_enumeration(doc); let combined = fields_with_adopted(doc); assert_eq!(combined.items, old_fields); assert_eq!(combined.adopted_page_fields, old_adopted); assert_eq!(combined.adopted_page_fields, adopted); assert_eq!(fields(doc), old_fields, "the compatibility wrapper keeps the old fields"); assert_eq!(adopted_page_fields(doc), old_adopted, "the compatibility wrapper keeps the old count"); } fn field<'a>(all: &'a [Field], name: &str) -> &'a Field { all.iter().find(|f| f.name == name).unwrap_or_else(|| panic!("no field {name}")) } /// The decoded text of widget `w`'s normal (`/N`) appearance stream. 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() } /// A push button's caption is drawn in the font, size and color its `/DA` asks for; with an /// automatic size (0) it is sized to fit the button, in the form's default font and color. #[test] fn a_button_caption_is_drawn_in_its_fields_font_size_and_color() { let objs: Vec = vec![ "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), "<< /Type /Page /Parent 2 0 R /Annots [7 0 R 8 0 R] >>".into(), "<< /Fields [7 0 R 8 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R /HeBo 6 0 R >> >> >>".into(), "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>".into(), // Its /DA asks for 9pt Helvetica Bold in blue. "<< /Type /Annot /Subtype /Widget /FT /Btn /T (menu) /Ff 65536 /DA (/HeBo 9 Tf 0 0 1 rg) /MK << /CA (Menu) >> /Rect [50 700 140 711] /P 3 0 R >>".into(), // An automatic size (0): sized to the button, as before. "<< /Type /Annot /Subtype /Widget /FT /Btn /T (auto) /Ff 65536 /MK << /CA (Auto) >> /Rect [50 600 140 630] /P 3 0 R >>".into(), ]; let mut doc = document(&objs); redraw_field(&mut doc, "menu").unwrap(); redraw_field(&mut doc, "auto").unwrap(); let all = fields(&doc); let menu = ap(&doc, &field(&all, "menu").widgets[0]); assert!(menu.contains("/HeBo 9.00 Tf") && menu.contains("0 0 1 rg"), "{menu}"); let auto = ap(&doc, &field(&all, "auto").widgets[0]); assert!(auto.contains("/Helv 14.00 Tf") && auto.contains(" 0 g"), "{auto}"); } /// A button caption in a font whose UCS-2 CMap holds 签 is not reported; once `set_props` adds a /// character beyond U+FFFF, the caption is still drawn in that font and `undrawn_fields` reports /// the character. #[test] fn a_button_caption_its_fonts_encoding_cant_hold_is_reported() { let objs: Vec = vec![ "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), "<< /Type /Page /Parent 2 0 R /Annots [7 0 R] >>".into(), "<< /Fields [7 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R /CN 6 0 R >> >> >>".into(), "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), "<< /Type /Font /Subtype /Type0 /BaseFont /STSong-Light /Encoding /UniGB-UCS2-H /DescendantFonts [<< /Type /Font /Subtype /CIDFontType0 /BaseFont /STSong-Light /CIDSystemInfo << /Registry (Adobe) /Ordering (GB1) /Supplement 4 >> >>] >>".into(), // A UCS-2 CMap holds 签 but not a character beyond U+FFFF, which is drawn as `?`. "<< /Type /Annot /Subtype /Widget /FT /Btn /T (go) /Ff 65536 /DA (/CN 9 Tf 0 g) /MK << /CA > >> /Rect [50 700 140 711] /P 3 0 R >>".into(), ]; let mut doc = document(&objs); redraw_field(&mut doc, "go").unwrap(); assert!(undrawn_fields(&doc).is_empty(), "签 is drawn"); set_props(&mut doc, "go", &FieldProps { caption: Some((0, "签\u{2000B}".into())), ..Default::default() }).unwrap(); assert!(ap(&doc, &field(&fields(&doc), "go").widgets[0]).contains("/CN 9.00 Tf")); assert_eq!(undrawn_fields(&doc), [("go".to_string(), '\u{2000B}')]); } /// Changing one widget's caption on a push button with two widgets leaves the other widget, the /// shared `/MK` and the parent's action untouched, keeps the other `/MK` and `/AP` entries, and /// drops the stale rollover and down appearances, with a direct or indirect caption and either save. #[test] fn button_caption_preserves_other_widgets_and_shared_dictionaries() { for indirect_caption in [false, true] { let mut doc = one_page(); let a = add_field(&mut doc, 0, [40.0, 600.0, 200.0, 630.0], &NewField::Button { caption: "Original".into() }, Some("first")).unwrap(); let b = add_field(&mut doc, 0, [40.0, 500.0, 200.0, 530.0], &NewField::Button { caption: "Original".into() }, Some("second")).unwrap(); let all = fields(&doc); let (first, second) = (field(&all, &a).obj, field(&all, &b).obj); let mut parent = Dict::new(); parent.set(b"FT".to_vec(), Object::name("Btn")); parent.set(b"Ff".to_vec(), Object::Int(flags::PUSH_BUTTON as i64)); parent.set(b"T".to_vec(), PdfString::text("clear_button")); parent.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(first), Object::Ref(second)])); let mut action = Dict::new(); action.set(b"S".to_vec(), Object::name("ResetForm")); parent.set(b"A".to_vec(), Object::Dict(action.clone())); let parent = doc.add(parent); let mut mk = Dict::new(); let ca = Object::String(PdfString::text("Original")); let ca = if indirect_caption { Object::Ref(doc.add(ca)) } else { ca }; mk.set(b"CA".to_vec(), ca); mk.set(b"TP".to_vec(), Object::Int(0)); mk.set(b"R".to_vec(), Object::Int(90)); mk.set(b"VendorNote".to_vec(), PdfString::text("kept")); let mk = doc.add(mk); let mut shared_ap = doc.get(first).as_dict().unwrap().get(b"AP").unwrap().as_dict().unwrap().clone(); let normal = shared_ap.get(b"N").unwrap().clone(); shared_ap.set(b"R".to_vec(), normal.clone()); shared_ap.set(b"D".to_vec(), normal); shared_ap.set(b"VendorAP".to_vec(), PdfString::text("kept")); let shared_ap = doc.add(shared_ap); for widget in [first, second] { doc.update_dict(widget, |d| { for key in [b"FT".as_slice(), b"Ff", b"T"] { d.remove(key); } d.set(b"Parent".to_vec(), Object::Ref(parent)); d.set(b"MK".to_vec(), Object::Ref(mk)); d.set(b"AP".to_vec(), Object::Ref(shared_ap)); }) .unwrap(); } let af = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"AcroForm").unwrap(); doc.update_dict(af, |d| d.set(b"Fields".to_vec(), Object::Array(vec![Object::Ref(parent)]))).unwrap(); let original = pdfcraft_cos::write_full(&doc, &SaveOptions::default()).unwrap(); let original = Document::open(Arc::new(original)).unwrap(); // Full saves can renumber objects: reacquire references from the reopened model. let f = field(&fields(&original), "clear_button").clone(); let first = f.widgets[0].obj; let mk = original.get(first).as_dict().unwrap().reference(b"MK").unwrap(); for full in [false, true] { let mut doc = original.clone(); let untouched = doc.get(first).clone(); let shared_mk = doc.get(mk).clone(); assert_eq!(field(&fields(&doc), "clear_button").widgets[1].caption.as_deref(), Some("Original")); set_props(&mut doc, "clear_button", &FieldProps { caption: Some((1, "Clear Form".into())), ..Default::default() }).unwrap(); assert_eq!(doc.get(first), untouched, "the other widget and all its appearances are untouched"); assert_eq!(doc.get(mk), shared_mk, "shared MK is not patched in place"); let bytes = if full { pdfcraft_cos::write_full(&doc, &SaveOptions::default()).unwrap() } else { let bytes = write_incremental(&doc, &SaveOptions::default()).unwrap(); assert!(bytes.starts_with(original.bytes().as_slice())); assert!(bytes.len() > original.bytes().len()); bytes }; let doc = Document::open(Arc::new(bytes)).unwrap(); let f = field(&fields(&doc), "clear_button").clone(); assert_eq!(f.widgets.iter().map(|w| w.caption.as_deref()).collect::>(), [Some("Original"), Some("Clear Form")]); assert_eq!(f.button, Some(af::ButtonAction::Reset { fields: Vec::new(), exclude: false })); assert_eq!(doc.get(f.obj).as_dict().unwrap().get(b"A"), Some(&Object::Dict(action.clone()))); let w = doc.get(f.widgets[1].obj); let mk = w.as_dict().unwrap().get(b"MK").unwrap().as_dict().unwrap(); assert_eq!(mk.get(b"VendorNote").unwrap().as_string().unwrap().to_text(), "kept"); assert_eq!(mk.get(b"R").unwrap().as_int(), Some(90)); assert_eq!(mk.get(b"TP").unwrap().as_int(), Some(0)); let apd = w.as_dict().unwrap().get(b"AP").unwrap().as_dict().unwrap(); assert!(apd.contains(b"VendorAP")); assert!(!apd.contains(b"R") && !apd.contains(b"D"), "stale hover/pressed text must not survive"); assert!(ap(&doc, &f.widgets[1]).contains("(Clear Form) Tj")); } } } #[test] fn button_caption_rejects_wrong_type_and_index_before_mutation() { let mut doc = fixture(); for (name, wi) in [("name", 0), ("agree", 0), ("go", usize::MAX)] { let before = pdfcraft_cos::write_full(&doc, &SaveOptions::default()).unwrap(); let props = FieldProps { caption: Some((wi, "Clear Form".into())), name: Some("renamed".into()), ..Default::default() }; assert!(set_props(&mut doc, name, &props).is_err()); assert_eq!(pdfcraft_cos::write_full(&doc, &SaveOptions::default()).unwrap(), before); } set_props(&mut doc, "go", &FieldProps { caption: Some((0, "提交".into())), ..Default::default() }).unwrap(); assert_eq!(field(&fields(&doc), "go").widgets[0].caption.as_deref(), Some("提交")); assert_eq!(undrawn_fields(&doc), [("go".to_string(), '提')]); set_props(&mut doc, "go", &FieldProps { caption: Some((0, String::new())), ..Default::default() }).unwrap(); assert_eq!(field(&fields(&doc), "go").widgets[0].caption.as_deref(), Some("")); assert!(undrawn_fields(&doc).is_empty()); assert!(!ap(&doc, &field(&fields(&doc), "go").widgets[0]).contains(" Tj")); } /// Read the drawn strings together with the appearance's actual font after saving and reopening. fn appearance_text(doc: &Document, w: &Widget, font_name: &[u8]) -> (Object, Vec>) { let widget = doc.get(w.obj); let normal = widget.as_dict().unwrap().get(b"AP").unwrap().as_dict().unwrap().reference(b"N").unwrap(); let appearance = doc.get(normal); let Object::Stream(stream) = appearance.as_ref() else { panic!("appearance stream") }; let resources = doc.resolve(stream.dict.get(b"Resources").unwrap()); let fonts = doc.resolve(resources.as_dict().unwrap().get(b"Font").unwrap()); let font = doc.resolve(fonts.as_dict().unwrap().get(font_name).unwrap()); let shown = pdfcraft_content::parse(&stream.decoded().unwrap()) .ops .into_iter() .filter(|op| op.is("Tj")) .map(|op| op.operands[0].as_string().unwrap().bytes.clone()) .collect(); (font.as_ref().clone(), shown) } #[test] fn the_field_tree_is_read_with_inheritance() { let doc = fixture(); assert_combined_matches_legacy(&doc, 0); 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::>(), ["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); } #[test] fn field_flags_reject_values_outside_the_unsigned_32_bit_domain() { let objs: Vec = vec![ "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), "<< /Type /Page /Parent 2 0 R /Annots [] >>".into(), "<< /Fields [5 0 R 6 0 R 7 0 R 8 0 R 9 0 R] >>".into(), "<< /FT /Btn /T (valid) /Ff 65536 >>".into(), "<< /FT /Btn /T (negative) /Ff -1 >>".into(), "<< /FT /Btn /T (overflow) /Ff 4295032832 >>".into(), "<< /FT /Btn /T (wrong_type) /Ff (65536) >>".into(), "<< /FT /Btn /T (missing) >>".into(), ]; let all = fields(&document(&objs)); assert_eq!(field(&all, "valid").kind, FieldKind::PushButton); assert_eq!(field(&all, "valid").flags, flags::PUSH_BUTTON); for name in ["negative", "overflow", "wrong_type", "missing"] { assert_eq!(field(&all, name).kind, FieldKind::CheckBox, "{name}"); assert_eq!(field(&all, name).flags, 0, "{name}"); } } /// Some writers leave every field out of `/Fields` and put the widgets only in the page /// annotations: the tree walk alone finds nothing, and Acrobat and the browsers fill them anyway. /// The pages' widgets are adopted as fields, after the listed tree fields. #[test] fn fields_listed_only_on_the_pages_are_adopted() { let objs: Vec = 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 [5 0 R 10 0 R 11 0 R 13 0 R 14 0 R 15 0 R] >>".into(), // 3 "<< /Fields [5 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 6 0 R >> >> >>".into(), // 4 "<< /Type /Annot /Subtype /Widget /FT /Tx /T (listed) /V (keep) /Rect [50 700 250 720] /P 3 0 R >>".into(), // 5 "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), // 6 "null".into(), // 7 "null".into(), // 8 "null".into(), // 9 "<< /Type /Annot /Subtype /Widget /FT /Tx /T (alpha) /V (one) /Rect [50 600 250 620] /P 3 0 R >>".into(), // 10 "<< /Type /Annot /Subtype /Widget /FT /Tx /T (beta) /DA (/Helv 9 Tf 0 g) /Rect [50 560 250 580] /P 3 0 R >>".into(), // 11 "<< /FT /Tx /T (pair) /Kids [13 0 R 14 0 R] >>".into(), // 12 unlisted parent field "<< /Type /Annot /Subtype /Widget /Parent 12 0 R /Rect [50 500 150 520] /P 3 0 R >>".into(), // 13 its widget "<< /Type /Annot /Subtype /Widget /Parent 12 0 R /Rect [50 460 150 480] /P 3 0 R >>".into(), // 14 its other widget "<< /Type /Annot /Subtype /Widget /Rect [400 700 415 715] /P 3 0 R >>".into(), // 15 no /FT, /T or /Parent: not a field ]; let mut doc = document(&objs); assert_combined_matches_legacy(&doc, 3); let all = fields(&doc); let names: Vec<&str> = all.iter().map(|f| f.name.as_str()).collect(); assert_eq!(names, ["listed", "alpha", "beta", "pair"]); assert_eq!(adopted_page_fields(&doc), 3); let alpha = field(&all, "alpha"); assert_eq!((alpha.kind, alpha.value.as_slice(), alpha.widgets[0].page), (FieldKind::Text, &["one".to_string()][..], Some(0))); assert_eq!(field(&all, "beta").da, "/Helv 9 Tf 0 g"); assert_eq!(field(&all, "pair").widgets.len(), 2, "one field carries both widgets"); // They are filled and keep the value across a save. set_value(&mut doc, "beta", &FieldValue::Text("typed".into())).unwrap(); assert!(ap(&doc, &field(&fields(&doc), "beta").widgets[0]).contains("typed")); let doc = reopen(&doc); assert_eq!(field(&fields(&doc), "beta").value, ["typed"]); // Without a /Fields list at all, everything on the pages is adopted. let mut objs = objs; objs[3] = "<< /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 6 0 R >> >> >>".into(); let doc = document(&objs); assert_combined_matches_legacy(&doc, 4); let all = fields(&doc); let names: Vec<&str> = all.iter().map(|f| f.name.as_str()).collect(); assert_eq!(names, ["listed", "alpha", "beta", "pair"]); assert_eq!(adopted_page_fields(&doc), 4); } /// 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 = 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::>(), [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::>(), [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}"); } /// What fits a text field is measured as its appearance draws it: in the field's own font (its /// `/DR` font, Helvetica where that can't show the text), inside the border and padding, at its /// size, wrapped for multi-line, at the smallest automatic size, as asterisks for a password, /// and without a limit for a comb field. #[test] fn a_text_field_says_what_fits_it_as_its_appearance_draws_it() { // Each widget is 100 x 20 pt (multi-line: 100 x 40 pt) with no border: 96 pt inside the 2 pt // padding (36 pt high for the multi-line one). let tx = |name: &str, da: &str, ff: u32, extra: &str, top: i32, h: i32| { format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T ({name}) /DA ({da}) /Ff {ff}{extra} /Rect [50 {} 150 {top}] /P 3 0 R >>", top - h) }; let objs: Vec = vec![ "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), "<< /Type /Page /Parent 2 0 R /Annots [7 0 R 8 0 R 9 0 R 10 0 R 11 0 R 12 0 R] >>".into(), "<< /Fields [7 0 R 8 0 R 9 0 R 10 0 R 11 0 R 12 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R /Cour 6 0 R >> >> >>".into(), "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), "<< /Type /Font /Subtype /Type1 /BaseFont /Courier /Encoding /WinAnsiEncoding >>".into(), tx("one", "/Helv 12 Tf 0 g", 0, "", 780, 20), tx("auto", "/Helv 0 Tf 0 g", 0, "", 750, 20), tx("many", "/Helv 12 Tf 0 g", flags::MULTILINE, "", 720, 40), tx("cour", "/Cour 12 Tf 0 g", 0, "", 670, 20), tx("pin", "/Helv 12 Tf 0 g", flags::PASSWORD, "", 640, 20), tx("comb", "/Helv 12 Tf 0 g", flags::COMB, " /MaxLen 5", 610, 20), ]; let doc = document(&objs); let all = fields(&doc); let tb = |name: &str| { let f = field(&all, name); appearance::text_box(&doc, f, &f.widgets[0]) }; let helv = |s: &str, size: f64| pdfcraft_fonts::helvetica_width(s, size); // Single line at 12 pt: as wide as the 96 pt inside, and no wider. let fits_12: String = std::iter::repeat_n('W', (96.0 / helv("W", 12.0)) as usize).collect(); assert!(tb("one").fits(&fits_12) && !tb("one").fits(&format!("{fits_12}W")), "{}", fits_12.len()); // An automatic size shrinks first: full only when 4 pt no longer fits. let fits_4: String = std::iter::repeat_n('W', (96.0 / helv("W", 4.0)) as usize).collect(); assert!(!tb("one").fits(&fits_4) && tb("auto").fits(&fits_4) && !tb("auto").fits(&format!("{fits_4}W"))); // Multi-line: lines of 12 x 1.15 pt each in 36 pt, so two lines fit and three don't. assert!(tb("many").fits("one\ntwo") && !tb("many").fits("one\ntwo\nthree")); let two_lines_of_words = "word ".repeat(((96.0 / helv("word ", 12.0)) as usize) * 2); assert!(tb("many").fits(two_lines_of_words.trim_end()) && !tb("many").fits(&format!("{two_lines_of_words}word"))); // The field's own font: narrow i's that Helvetica fits are too wide in Courier (600/1000 em). let i_s: String = std::iter::repeat_n('i', 14).collect(); assert!(tb("one").fits(&i_s) && !tb("cour").fits(&i_s), "Courier is 0.6 em a character: {}", 14.0 * 0.6 * 12.0); // A password is drawn, so measured, as asterisks. let thirty_i: String = std::iter::repeat_n('i', 30).collect(); assert!(tb("one").fits(&thirty_i) && !tb("pin").fits(&thirty_i), "{} pt of asterisks", helv(&"*".repeat(30), 12.0)); // A comb field's cells bound it, not its width. assert!(tb("comb").fits(&"W".repeat(50))); } /// #4339 review: a multi-line field is full when its appearance stops drawing lines (a line is /// drawn while its descent stays inside the border), not when `lines x size x 1.15` outgrows the /// padded height: a 16 pt field at 12 pt draws one line. #[test] fn a_short_multiline_field_takes_the_one_line_it_draws() { let tx = |name: &str, extra: &str, h: i32| { format!( "<< /Type /Annot /Subtype /Widget /FT /Tx /T ({name}) /DA (/Helv 12 Tf 0 g) /Ff {}{extra} /Rect [50 {} 150 700] /P 3 0 R >>", flags::MULTILINE, 700 - h ) }; let objs: Vec = vec![ "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), "<< /Type /Page /Parent 2 0 R /Annots [6 0 R 7 0 R] >>".into(), "<< /Fields [6 0 R 7 0 R] /DA (/Helv 12 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >>".into(), "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), tx("plain", "", 16), tx("boxed", " /MK << /BC [0] >>", 16), ]; let doc = document(&objs); let all = fields(&doc); let tb = |name: &str| { let f = field(&all, name); appearance::text_box(&doc, f, &f.widgets[0]) }; assert!(tb("plain").fits("one line") && !tb("plain").fits("one\ntwo")); // A 1 pt border takes 1 pt from each side: the line's descent then falls outside it. assert!(!tb("boxed").fits("one line")); } /// An edit to a field that doesn't scroll keeps as much of what it inserted as fits, wherever the /// caret was, and always keeps a deletion. #[test] fn an_edit_to_a_full_field_keeps_what_fits() { let objs: Vec = vec![ "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), "<< /Type /Page /Parent 2 0 R /Annots [5 0 R] >>".into(), "<< /Fields [5 0 R] /DA (/Helv 12 Tf 0 g) >>".into(), "<< /Type /Annot /Subtype /Widget /FT /Tx /T (one) /DA (/Helv 12 Tf 0 g) /Rect [50 760 150 780] /P 3 0 R >>".into(), ]; let doc = document(&objs); let all = fields(&doc); let f = field(&all, "one"); let tb = appearance::text_box(&doc, f, &f.widgets[0]); // Typing in the middle of "Short": the X's go in until the field is full, the end stays. let typed = format!("Sh{}ort", "X".repeat(40)); let (kept, caret) = tb.fitted_edit("Short", &typed); let xs = kept.chars().filter(|c| *c == 'X').count(); assert!(kept.starts_with("ShX") && kept.ends_with("Xort") && xs < 40, "{kept}"); assert!(tb.fits(&kept) && !tb.fits(&format!("Sh{}ort", "X".repeat(xs + 1))), "as many as fit: {xs}"); assert_eq!(caret, 2 + xs, "the caret after the last X kept"); // What fits is kept whole. assert_eq!(tb.fitted_edit("Short", "Shorter"), ("Shorter".to_string(), 7)); // A deletion is kept even from text that already overflows (set by a script, say). let long = "W".repeat(20); assert!(!tb.fits(&long)); assert_eq!(tb.fitted_edit(&long, &"W".repeat(19)), ("W".repeat(19), 19)); // Replacing a selection with too much keeps the removal and what fits of the replacement. let (kept, _) = tb.fitted_edit("Short", &format!("S{}t", "W".repeat(30))); assert!(kept.starts_with("SW") && kept.ends_with('t') && tb.fits(&kept), "{kept}"); } /// When the field's own font can't show a character of a paste, the text from there on is drawn /// in Helvetica, which can be narrower: the longest part that fits may come after that point, and /// is still the one kept. #[test] fn an_edit_keeps_the_longest_part_that_fits_across_a_change_of_font() { // A subset font holding only A, 1 em wide: 12 pt an A at 12 pt, against Helvetica's 8 pt. let objs: Vec = vec![ "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), "<< /Type /Page /Parent 2 0 R /Annots [6 0 R] >>".into(), "<< /Fields [6 0 R] /DA (/Wide 12 Tf 0 g) /DR << /Font << /Wide 5 0 R >> >> >>".into(), "<< /Type /Font /Subtype /Type1 /BaseFont /ABCDEF+Wide /FirstChar 65 /LastChar 65 /Widths [1000] /Encoding /WinAnsiEncoding >>".into(), "<< /Type /Annot /Subtype /Widget /FT /Tx /T (one) /DA (/Wide 12 Tf 0 g) /Rect [50 760 150 780] /P 3 0 R >>".into(), ]; let doc = document(&objs); let all = fields(&doc); let f = field(&all, "one"); let tb = appearance::text_box(&doc, f, &f.widgets[0]); // 96 pt inside: 8 A's in the subset font, or 11 characters (8.004 pt each) once a B brings // Helvetica in. assert!(tb.fits(&"A".repeat(8)) && !tb.fits(&"A".repeat(9))); let pasted = format!("{}B{}", "A".repeat(10), "A".repeat(30)); let (kept, caret) = tb.fitted_edit("", &pasted); assert_eq!(kept, format!("{}B", "A".repeat(10)), "the 11 characters that fit in Helvetica, not the 8 before the B"); assert!(!tb.fits(&format!("{}BA", "A".repeat(10))), "and no more"); assert_eq!(caret, 11); } /// A check box laid out for a round XFA check button (`/PCXfaRound true`) draws its background /// and border as a circle inside the widget, inset by half the border so the stroke stays in, /// or a disc when the border is as wide as the circle (an underline stays one); one without the /// key keeps its rectangle. #[test] fn a_round_xfa_check_button_draws_a_round_background_and_border() { let widget = |name: &str, extra: &str| { format!( "<< /Type /Annot /Subtype /Widget /FT /Btn /T ({name}) /Rect [50 700 70 720] /P 3 0 R /MK << /BG [0.8 0.85 1] /BC [1 0 0] >> /BS << /W 1 /S /S >>{extra} >>" ) }; let objs: Vec = vec![ "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), "<< /Type /Page /Parent 2 0 R /Annots [5 0 R 6 0 R 7 0 R 8 0 R 9 0 R] >>".into(), "<< /Fields [5 0 R 6 0 R 7 0 R 8 0 R 9 0 R] >>".into(), widget("round", " /PCXfaRound true"), widget("square", ""), widget("dashed", " /PCXfaRound true /BS << /W 2 /S /D >>").replace("/BS << /W 1 /S /S >>", ""), widget("thick", " /PCXfaRound true /BS << /W 30 /S /S >>").replace("/BS << /W 1 /S /S >>", ""), widget("underlined", " /PCXfaRound true /BS << /W 1 /S /U >>").replace("/BS << /W 1 /S /S >>", ""), ]; let mut doc = document(&objs); for name in ["round", "square", "dashed", "thick", "underlined"] { redraw_field(&mut doc, name).unwrap(); } // The Off state: only the frame. let off = |name: &str| { let all = fields(&doc); let w = field(&all, name).widgets[0].obj; let wd = doc.get(w).as_dict().cloned().unwrap(); let n = doc.resolve(wd.get(b"AP").unwrap().as_dict().unwrap().get(b"N").unwrap()); let r = n.as_dict().unwrap().reference(b"Off").expect("an Off appearance"); let Object::Stream(s) = &*doc.get(r) else { panic!("Off is a stream") }; String::from_utf8_lossy(&s.decoded().unwrap()).into_owned() }; let round = off("round"); assert!(!round.contains(" re "), "no rectangle: {round}"); assert!(round.contains("0.8 0.85 1 rg\n20 10 m"), "the background is a disc as wide as the box: {round}"); assert!(round.contains("1 0 0 RG\n1 w\n19.5 10 m") && round.contains(" c S"), "the border is a circle inset by half its width: {round}"); let square = off("square"); assert!(square.contains("0 0 20 20 re f") && square.contains("re S"), "a square box keeps its rectangle: {square}"); let dashed = off("dashed"); assert!(dashed.contains("[3] 0 d\n19 10 m"), "a dashed round border stays dashed: {dashed}"); // A border wider than the 20pt circle fills it: a disc in the border's colour, no stroke. let thick = off("thick"); assert!(thick.contains("1 0 0 rg\n20 10 m") && !thick.contains(" S\n"), "a border as wide as the circle is a disc: {thick}"); // An underline stays the bottom edge, under a round background. let underlined = off("underlined"); assert!(underlined.contains("1 0 0 RG\n1 w\n0 0.5 m 20 0.5 l S"), "an underline stays an underline: {underlined}"); } /// Issue #1323: a text field without a format keeps a whitespace-only value as typed; only an /// empty value clears it. #[test] fn whitespace_only_text_is_kept_in_a_field_without_a_format() { for value in [" ", "\t", "\u{a0}"] { let mut doc = fixture(); set_value(&mut doc, "name", &FieldValue::Text(value.into())).unwrap(); let doc = reopen(&doc); assert_eq!(field(&fields(&doc), "name").value, [value], "{value:?}"); } let mut doc = fixture(); set_value(&mut doc, "name", &FieldValue::Text(String::new())).unwrap(); assert!(field(&fields(&reopen(&doc)), "name").value.is_empty()); } #[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)")); } /// Acrobat regenerates only the `/Tx BMC` block when it redraws a text field, so background and /// border must live inside it rather than immediately before it (#1613). fn marked_content_borders_field(appearance: &str, label: &str) { let start = appearance.find("/Tx BMC").expect("no /Tx BMC"); let marked = appearance[start..].split_once("EMC").expect("no matching EMC").0; assert!(marked.contains("1 1 0.9 rg"), "background inside /Tx BMC: {appearance} {label}"); assert!(marked.contains("0 0 0 RG\n1 w\n0.5 0.5 199 19 re S"), "border inside /Tx BMC: {appearance} {label}"); } #[test] fn text_and_date_fields_keep_background_and_border_inside_marked_content() { 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(); add_field(&mut doc, 0, [50.0, 660.0, 250.0, 680.0], &NewField::Date, Some("due")).unwrap(); let props = || FieldProps { look: Some(Look { border: Some([0.0; 3]), fill: Some([1.0, 1.0, 0.9]), width: 1.0, style: BorderStyle::Solid, text: [0.0, 0.0, 0.0], font: FieldFont::Helvetica, }), ..FieldProps::default() }; set_props(&mut doc, "name", &props()).unwrap(); set_props(&mut doc, "due", &props()).unwrap(); set_value(&mut doc, "name", &FieldValue::Text("Ada".into())).unwrap(); set_value(&mut doc, "due", &FieldValue::Text("10/14/2026".into())).unwrap(); let name_ap = ap(&doc, &field(&fields(&doc), "name").widgets[0]); let due_ap = ap(&doc, &field(&fields(&doc), "due").widgets[0]); marked_content_borders_field(&name_ap, "text field"); marked_content_borders_field(&due_ap, "date field"); assert!(name_ap.contains("(Ada) Tj"), "{name_ap}"); assert!(due_ap.contains("(10/14/2026) Tj"), "{due_ap}"); assert!(!need_appearances(&doc), "drawable ASCII fields do not ask other viewers to redraw"); } #[test] fn existing_field_font_encoding_keeps_italian_punctuation_visible() { // ReportLab-style Helvetica: an indirect encoding dictionary with PDFDoc-like Differences. // WinAnsi 0x92/0x97 would show trademark/Scaron in this font, not quoteright/emdash. let objs: Vec = vec![ "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), "<< /Type /Page /Parent 2 0 R /Annots [6 0 R] >>".into(), "<< /Fields [6 0 R] /DA (/Helv 12 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >>".into(), "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding 7 0 R >>".into(), "<< /Type /Annot /Subtype /Widget /FT /Tx /T (localita) /Rect [50 700 450 730] /P 3 0 R >>".into(), "<< /Type /Encoding /Differences [39 /quotesingle 96 /grave 132 /emdash 144 /quoteright 146 /trademark 151 /Scaron 224 /agrave] >>".into(), ]; let mut doc = document(&objs); let original_font = doc.get(ObjRef::new(5, 0)).as_ref().clone(); let value = "Città Sant’Angelo — qualità"; set_value(&mut doc, "localita", &FieldValue::Text(value.into())).unwrap(); let doc = reopen(&doc); let all = fields(&doc); let f = field(&all, "localita"); assert_eq!(f.value, [value], "the stored value and the drawn text must agree"); let (font, lines) = appearance_text(&doc, &f.widgets[0], b"Helv"); assert_eq!(font, original_font, "keep the existing field's font and encoding"); let metrics = pdfcraft_fonts::pdf::Metrics::from_dict(&doc, font.as_dict().unwrap()); let shown = lines.concat(); assert_eq!(metrics.decode(&shown), value, "decode the appearance with its actual font"); assert_eq!(shown, b"Citt\xe0 Sant\x90Angelo \x84 qualit\xe0"); } #[test] fn unavailable_glyph_uses_an_appearance_font_without_changing_the_source_font() { // A subset font with only A cannot draw the newly entered accented text. Replacing its // encoding would corrupt every existing use of that font, so only the appearance falls back. let objs: Vec = vec![ "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), "<< /Type /Page /Parent 2 0 R /Annots [6 0 R] >>".into(), "<< /Fields [6 0 R] /DA (/Original 12 Tf 0 g) /DR << /Font << /Original 5 0 R >> >> >>".into(), "<< /Type /Font /Subtype /Type1 /BaseFont /ABCDEF+Custom /FirstChar 65 /LastChar 65 /Widths [600] /Encoding /WinAnsiEncoding >>".into(), "<< /Type /Annot /Subtype /Widget /FT /Tx /T (localita) /Rect [50 700 450 730] /P 3 0 R >>".into(), ]; let mut doc = document(&objs); let original_font = doc.get(ObjRef::new(5, 0)).as_ref().clone(); let value = "Città Sant’Angelo — qualità"; set_value(&mut doc, "localita", &FieldValue::Text(value.into())).unwrap(); let doc = reopen(&doc); assert_eq!(doc.get(ObjRef::new(5, 0)).as_ref(), &original_font); let all = fields(&doc); let f = field(&all, "localita"); assert_eq!(f.value, [value]); let (font, lines) = appearance_text(&doc, &f.widgets[0], b"Helv"); let font = font.as_dict().unwrap(); assert_eq!(font.name(b"BaseFont"), Some(&b"Helvetica"[..])); assert_eq!(font.name(b"Encoding"), Some(&b"WinAnsiEncoding"[..])); let metrics = pdfcraft_fonts::pdf::Metrics::from_dict(&doc, font); let shown = lines.concat(); assert_eq!(metrics.decode(&shown), value); assert_eq!(shown, b"Citt\xe0 Sant\x92Angelo \x97 qualit\xe0"); } #[test] fn multiline_wrapping_keeps_encodable_text_without_requiring_ascii_space() { let objs: Vec = vec![ "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), "<< /Type /Page /Parent 2 0 R /Annots [6 0 R] >>".into(), "<< /Fields [6 0 R] /DA (/Helv 12 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >>".into(), "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding 7 0 R >>".into(), "<< /Type /Annot /Subtype /Widget /FT /Tx /T (localita) /Ff 4096 /Rect [50 500 130 550] /P 3 0 R >>".into(), "<< /Type /Encoding /Differences [32 /.notdef 144 /quoteright 160 /nbspace 224 /agrave] >>".into(), ]; let mut doc = document(&objs); let original_font = doc.get(ObjRef::new(5, 0)).as_ref().clone(); // NBSP remains part of the word; wrapping splits it by character without adding spaces. let value = "Città\u{a0}Sant’Angelo\nqualità"; set_value(&mut doc, "localita", &FieldValue::Text(value.into())).unwrap(); let doc = reopen(&doc); let all = fields(&doc); let f = field(&all, "localita"); assert_eq!(f.value, [value]); let (font, lines) = appearance_text(&doc, &f.widgets[0], b"Helv"); assert_eq!(font, original_font); assert!(lines.len() >= 3 && lines.iter().all(|line| !line.is_empty())); let metrics = pdfcraft_fonts::pdf::Metrics::from_dict(&doc, font.as_dict().unwrap()); assert_eq!(metrics.decode(&lines.concat()), value.replace('\n', "")); } /// A field drawn in its `/DR` font is laid out with that font's widths, not Helvetica's (#2894): /// Courier is 0.6 em a character, so 10 pt text holds 94 to a line in a 564 pt box, and a /// right-aligned value ends at the padding in Courier and in Times. #[test] fn reused_standard_fonts_wrap_and_align_with_their_own_widths() { let objs: Vec = vec![ "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>".into(), "<< /Type /Page /Parent 2 0 R /Annots [7 0 R 8 0 R 9 0 R] >>".into(), "<< /Fields [7 0 R 8 0 R 9 0 R] /DA (/Cour 10 Tf 0 g) /DR << /Font << /Cour 5 0 R /TiRo 6 0 R >> >> >>".into(), "<< /Type /Font /Subtype /Type1 /BaseFont /Courier /Encoding /WinAnsiEncoding >>".into(), "<< /Type /Font /Subtype /Type1 /BaseFont /Times-Roman /Encoding /WinAnsiEncoding >>".into(), "<< /Type /Annot /Subtype /Widget /FT /Tx /T (notes) /Rect [21 537 591 722] /Ff 4096 /DA (/Cour 10 Tf 0 g) /MK << /BC [0 0 0] >> /P 3 0 R >>" .into(), "<< /Type /Annot /Subtype /Widget /FT /Tx /T (code) /Q 2 /Rect [21 500 221 520] /DA (/Cour 10 Tf 0 g) /P 3 0 R >>".into(), "<< /Type /Annot /Subtype /Widget /FT /Tx /T (initials) /Q 2 /Rect [21 470 221 490] /DA (/TiRo 10 Tf 0 g) /P 3 0 R >>".into(), ]; let mut doc = document(&objs); let value = "The quick brown fox jumps over the lazy dog, then it runs into the field and keeps on going well past the right edge of the box. Every line here is plain mixed-case ASCII text in Courier at 10 points."; set_value(&mut doc, "notes", &FieldValue::Text(value.into())).unwrap(); set_value(&mut doc, "code", &FieldValue::Text("iiii".into())).unwrap(); set_value(&mut doc, "initials", &FieldValue::Text("iiii".into())).unwrap(); let doc = reopen(&doc); let all = fields(&doc); let (_, lines) = appearance_text(&doc, &field(&all, "notes").widgets[0], b"Cour"); let lengths: Vec = lines.iter().map(Vec::len).collect(); assert!(lengths.iter().all(|&l| l <= 94), "every line fits the box in Courier: {lengths:?}"); assert!(lengths[0] > 85, "and the first is not wrapped early: {lengths:?}"); // Where each right-aligned value starts: the box is 200 pt with 2 pt padding, so `iiii` ends // at 198 and starts 24 pt (Courier) or 11.12 pt (Times) before it, against 8.88 in Helvetica. let start = |name: &str| { let w = &field(&all, name).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 tm = pdfcraft_content::parse(&s.decoded().unwrap()).ops.into_iter().find(|op| op.is("Tm")).unwrap(); tm.operands[4].as_f64().unwrap() }; assert!((start("code") - 174.0).abs() < 1e-3, "Courier: {}", start("code")); assert!((start("initials") - 186.88).abs() < 1e-3, "Times: {}", start("initials")); } /// A standard-14 font whose `/Differences` swap A and W draws `AAAA` with code 87, so it is /// measured as four A's (0.667 em), not as the W that code has in WinAnsi. The same remap with /// `/Widths` takes the width the font gives code 87, here 0.5 em. #[test] fn remapped_standard_fonts_are_measured_by_the_glyph_each_code_draws() { let objs: Vec = vec![ "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>".into(), "<< /Type /Page /Parent 2 0 R /Annots [7 0 R 8 0 R] >>".into(), "<< /Fields [7 0 R 8 0 R] /DA (/Dif 10 Tf 0 g) /DR << /Font << /Dif 5 0 R /DifW 6 0 R >> >> >>".into(), "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding << /BaseEncoding /WinAnsiEncoding /Differences [65 /W 87 /A] >> >>" .into(), "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /FirstChar 87 /LastChar 87 /Widths [500] /Encoding << /BaseEncoding /WinAnsiEncoding /Differences [65 /W 87 /A] >> >>" .into(), "<< /Type /Annot /Subtype /Widget /FT /Tx /T (std) /Q 2 /Rect [21 500 221 520] /DA (/Dif 10 Tf 0 g) /P 3 0 R >>".into(), "<< /Type /Annot /Subtype /Widget /FT /Tx /T (widths) /Q 2 /Rect [21 470 221 490] /DA (/DifW 10 Tf 0 g) /P 3 0 R >>".into(), ]; let mut doc = document(&objs); set_value(&mut doc, "std", &FieldValue::Text("AAAA".into())).unwrap(); set_value(&mut doc, "widths", &FieldValue::Text("AAAA".into())).unwrap(); let doc = reopen(&doc); let all = fields(&doc); assert_eq!(appearance_text(&doc, &field(&all, "std").widgets[0], b"Dif").1, vec![b"WWWW".to_vec()], "A is drawn by code 87"); let start = |name: &str| { let w = &field(&all, name).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 tm = pdfcraft_content::parse(&s.decoded().unwrap()).ops.into_iter().find(|op| op.is("Tm")).unwrap(); tm.operands[4].as_f64().unwrap() }; // The text ends at 198 (200 pt box, 2 pt padding): 26.68 pt of A's, or 20 pt by /Widths. assert!((start("std") - 171.32).abs() < 1e-3, "no /Widths: {}", start("std")); assert!((start("widths") - 178.0).abs() < 1e-3, "/Widths: {}", start("widths")); } #[test] fn japanese_choices_use_the_unicode_cid_font() { // A choice field whose /DA names a non-embedded CID font with a predefined Unicode CMap // (UniJIS-UTF16-H). Selecting 令 must draw it in that font, not "?" in Helvetica. let objs: Vec = vec![ "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), "<< /Type /Page /Parent 2 0 R /Annots [8 0 R 9 0 R 10 0 R] >>".into(), "<< /Fields [8 0 R 9 0 R 10 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R /HeiseiMin-W3 6 0 R /Emb 7 0 R >> >> >>".into(), "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), "<< /Type /Font /Subtype /Type0 /BaseFont /HeiseiMin-W3 /Encoding /UniJIS-UTF16-H /DescendantFonts [<< /Type /Font /Subtype /CIDFontType0 /BaseFont /HeiseiMin-W3 /CIDSystemInfo << /Registry (Adobe) /Ordering (Japan1) /Supplement 6 >> >>] >>".into(), "<< /Type /Font /Subtype /Type0 /BaseFont /ABCDEF+Subset /Encoding /Identity-H /DescendantFonts [] >>".into(), "<< /Type /Annot /Subtype /Widget /FT /Ch /T (era) /Ff 131072 /DA (/HeiseiMin-W3 10 Tf 0 g) /Q 1 /Opt [ ] /V /Rect [50 700 80 715] /P 3 0 R >>".into(), "<< /Type /Annot /Subtype /Widget /FT /Tx /T (address) /Ff 4096 /DA (/HeiseiMin-W3 10 Tf 0 g) /Rect [50 600 100 680] /P 3 0 R >>".into(), "<< /Type /Annot /Subtype /Widget /FT /Tx /T (subset) /DA (/Emb 10 Tf 0 g) /Rect [50 500 100 520] /P 3 0 R >>".into(), ]; let mut doc = document(&objs); assert_eq!(field(&fields(&doc), "era").options.get(2), Some(&("令".to_string(), "令".to_string()))); set_value(&mut doc, "era", &FieldValue::Text("令".into())).unwrap(); set_value(&mut doc, "address", &FieldValue::Text("日本語のテキスト入力欄です".into())).unwrap(); // An Identity-H subset can't be addressed by Unicode: Helvetica stands in, and can't draw 令. // The value is kept, drawn as `??`, marked, and other viewers are asked to redraw the form. set_value(&mut doc, "subset", &FieldValue::Text("令和".into())).unwrap(); let doc = reopen(&doc); let all = fields(&doc); assert_eq!(field(&all, "era").value, ["令"]); let raw = |name: &str| { let w = &field(&all, name).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 fonts = doc.resolve(s.dict.get(b"Resources").unwrap()).as_dict().unwrap().get(b"Font").map(|f| doc.resolve(f)).unwrap(); let fonts: Vec = fonts.as_dict().unwrap().iter().map(|(k, _)| String::from_utf8_lossy(k).into_owned()).collect(); (s.decoded().unwrap(), fonts) }; let utf16 = |t: &str| t.encode_utf16().flat_map(u16::to_be_bytes).collect::>(); let (era, era_fonts) = raw("era"); assert!(era.windows(4).any(|x| x == b"(N\xe4)"), "令 as UTF-16BE: {}", String::from_utf8_lossy(&era)); assert!(String::from_utf8_lossy(&era).contains("/HeiseiMin-W3 10 Tf")); assert!(!era.windows(3).any(|x| x == b"(?)"), "no WinAnsi fallback"); assert_eq!(era_fonts, ["HeiseiMin-W3"]); // Multiline Japanese wraps by character at about one em each (50 pt wide, 10 pt type). let (addr, _) = raw("address"); let addr_text = String::from_utf8_lossy(&addr); assert!(addr_text.matches(" Tj").count() >= 4, "{addr_text}"); assert!(addr.windows(4).any(|x| x == utf16("日本").as_slice())); let (sub, sub_fonts) = raw("subset"); assert!(String::from_utf8_lossy(&sub).contains("/Helv "), "{}", String::from_utf8_lossy(&sub)); assert!(sub.windows(4).any(|x| x == b"(??)"), "{}", String::from_utf8_lossy(&sub)); assert_eq!(sub_fonts, ["Helv"]); assert_eq!(field(&all, "subset").value, ["令和"]); // Only that widget is marked: the CID fonts drew theirs. assert_eq!(undrawn_fields(&doc), [("subset".to_string(), '令')]); assert!(need_appearances(&doc)); } /// Text a field's font can't hold is kept, as Acrobat keeps it: the value is stored as typed, the /// appearance draws `?` for those characters, the widget is marked with the first one, and the /// form asks other viewers to redraw it (`/NeedAppearances`). Drawable text clears the mark. #[test] fn text_a_field_font_cannot_hold_is_kept_marked_and_redrawn_elsewhere() { let objs: Vec = vec![ "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), "<< /Type /Page /Parent 2 0 R /Annots [7 0 R 8 0 R 9 0 R] >>".into(), "<< /Fields [7 0 R 8 0 R 9 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R /CN 6 0 R >> >> >>".into(), "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), "<< /Type /Font /Subtype /Type0 /BaseFont /STSong-Light /Encoding /UniGB-UCS2-H /DescendantFonts [<< /Type /Font /Subtype /CIDFontType0 /BaseFont /STSong-Light /CIDSystemInfo << /Registry (Adobe) /Ordering (GB1) /Supplement 4 >> >>] >>".into(), "<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /DA (/Helv 10 Tf 0 g) /V (Ada) /Rect [50 700 200 715] /P 3 0 R >>".into(), "<< /Type /Annot /Subtype /Widget /FT /Tx /T (cn) /DA (/CN 10 Tf 0 g) /Rect [50 600 200 615] /P 3 0 R >>".into(), "<< /Type /Annot /Subtype /Widget /FT /Ch /T (city) /Ff 131072 /DA (/Helv 10 Tf 0 g) /Opt [(Zurich) ] /Rect [50 500 200 515] /P 3 0 R >>".into(), ]; let mut doc = document(&objs); let shown = |doc: &Document, name: &str| { let all = fields(doc); let w = &field(&all, name).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 mark = doc.get(w.obj).as_dict().and_then(pdfcraft_fonts::undrawn_mark); (String::from_utf8_lossy(&s.decoded().unwrap()).into_owned(), mark) }; // A WinAnsi field: the Chinese value is kept and marked; Helvetica draws `?` for it. assert!(!need_appearances(&doc)); set_value(&mut doc, "name", &FieldValue::Text("签名 Ada".into())).unwrap(); assert_eq!(field(&fields(&doc), "name").value, ["签名 Ada"]); let (ap, mark) = shown(&doc, "name"); assert!(ap.contains("(?? Ada) Tj"), "{ap}"); assert_eq!(mark, Some('签')); assert!(need_appearances(&doc), "other viewers are asked to redraw the form"); // A choice whose label Helvetica can't draw, the same way. set_value(&mut doc, "city", &FieldValue::Text("北N京".into())).unwrap(); assert_eq!(shown(&doc, "city").1, Some('北')); // A UCS-2 CMap holds 签, so nothing is marked, but not a character beyond U+FFFF. set_value(&mut doc, "cn", &FieldValue::Text("签名".into())).unwrap(); assert_eq!(shown(&doc, "cn").1, None); set_value(&mut doc, "cn", &FieldValue::Text("𠀋".into())).unwrap(); assert_eq!(field(&fields(&doc), "cn").value, ["𠀋"]); assert_eq!(shown(&doc, "cn").1, Some('𠀋')); let mut undrawn = undrawn_fields(&doc); undrawn.sort(); assert_eq!(undrawn, [("city".to_string(), '北'), ("cn".to_string(), '𠀋'), ("name".to_string(), '签')]); // Drawable text clears the mark; a line break in a single-line value is a space, not `?`. set_value(&mut doc, "name", &FieldValue::Text("Ada\nLovelace".into())).unwrap(); let (ap, mark) = shown(&doc, "name"); assert!(ap.contains("(Ada Lovelace) Tj") && !ap.contains('?'), "{ap}"); assert_eq!(mark, None); // Kept through a save. let doc = reopen(&doc); assert_eq!(field(&fields(&doc), "city").value, ["北N京"]); assert!(need_appearances(&doc)); } /// A document that declares PDF/A can't ask other viewers to redraw its fields (PDF/A forbids /// `/NeedAppearances`): the value is still kept and its widget marked, and the flag stays off. #[test] fn a_pdfa_form_keeps_the_value_without_asking_viewers_to_redraw() { let xmp = r#""#; let objs: Vec = vec![ "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R /Metadata 7 0 R >>".into(), "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), "<< /Type /Page /Parent 2 0 R /Annots [6 0 R] >>".into(), "<< /Fields [6 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >>".into(), "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), "<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /DA (/Helv 10 Tf 0 g) /Rect [50 700 200 715] /P 3 0 R >>".into(), format!("<< /Type /Metadata /Subtype /XML /Length {} >>\nstream\n{xmp}\nendstream", xmp.len() + 1), ]; let mut doc = document(&objs); assert!(declares_pdfa(&doc)); set_value(&mut doc, "name", &FieldValue::Text("张三".into())).unwrap(); assert_eq!(field(&fields(&doc), "name").value, ["张三"]); assert_eq!(undrawn_fields(&doc), [("name".to_string(), '张')]); assert!(!need_appearances(&doc), "PDF/A forbids /NeedAppearances"); } /// A button caption Helvetica can't draw is kept and marked like a value; redrawing a value another /// program stored (a dynamic form's layout, a property change) draws and marks it rather than /// failing, and an edit to an unrelated field never fails because of it. #[test] fn captions_and_redrawn_values_are_kept_and_marked() { let mut doc = fixture(); let go = add_field(&mut doc, 0, [50.0, 50.0, 150.0, 80.0], &NewField::Button { caption: "提交".into() }, Some("go2")).unwrap(); assert_eq!(undrawn_fields(&doc), [(go.clone(), '提')]); assert!(need_appearances(&doc)); // A value stored by another program that Helvetica can't draw. let name = field(&fields(&doc), "name").clone(); doc.update_dict(name.obj, |d| d.set(b"V".to_vec(), PdfString::text("张三"))).unwrap(); redraw_field(&mut doc, "name").unwrap(); let mut undrawn = undrawn_fields(&doc); undrawn.sort(); assert_eq!(undrawn, [(go, '提'), ("name".to_string(), '张')]); set_value(&mut doc, "notes", &FieldValue::Text("Unrelated".into())).unwrap(); // Drawable again: the mark goes. doc.update_dict(name.obj, |d| d.set(b"V".to_vec(), PdfString::text("Ada"))).unwrap(); redraw_field(&mut doc, "name").unwrap(); assert!(!undrawn_fields(&doc).iter().any(|(n, _)| n == "name")); } #[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::>(), ["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 check_boxes_keep_non_utf8_state_names() { // Japanese forms often name a check box's on state 「はい」 in Shift-JIS: ticking it has to // write those exact bytes to /AS and /V, or no appearance matches and no mark shows. let objs: Vec = vec![ "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), "<< /Type /Page /Parent 2 0 R /Annots [5 0 R] >>".into(), "<< /Fields [5 0 R] >>".into(), "<< /Type /Annot /Subtype /Widget /FT /Btn /T (agree) /AS /Off /Rect [50 700 56 706] /P 3 0 R /AP << /N << /Off 6 0 R /#82#CD#82#A2 7 0 R >> >> >>".into(), "<< /Length 0 >>\nstream\n\nendstream".into(), "<< /Length 0 >>\nstream\n\nendstream".into(), ]; let sjis_hai: &[u8] = b"\x82\xcd\x82\xa2"; let mut doc = document(&objs); let all = fields(&doc); assert_eq!(field(&all, "agree").widgets[0].on_state.as_deref(), Some("#82#CD#82#A2")); set_value(&mut doc, "agree", &FieldValue::Check(true)).unwrap(); let doc = reopen(&doc); let f = field(&fields(&doc), "agree").clone(); let wd = doc.get(f.widgets[0].obj).as_dict().cloned().unwrap(); assert_eq!(wd.name(b"AS"), Some(sjis_hai)); assert_eq!(doc.get(f.obj).as_dict().unwrap().name(b"V"), Some(sjis_hai)); assert_eq!(f.value, ["#82#CD#82#A2"]); let mut doc = doc; set_value(&mut doc, "agree", &FieldValue::Check(false)).unwrap(); assert_eq!(doc.get(f.widgets[0].obj).as_dict().unwrap().name(b"AS"), Some(&b"Off"[..])); } #[test] fn name_text_round_trips() { for bytes in [&b"Yes"[..], b"\x82\xcd\x82\xa2", "はい".as_bytes(), b"A#1", b"a b", b"", b"\xff#\x00"] { assert_eq!(name_bytes(&name_text(bytes)), bytes, "{bytes:?}"); } assert_eq!(name_text("はい".as_bytes()), "はい"); assert_eq!(name_text(b"A#1"), "A#231"); // Malformed escapes stay as they are. assert_eq!(name_bytes("#G1#4"), b"#G1#4"); } #[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 list_box_selection_scrolls_to_show_selected_options() { let objs: Vec = vec![ "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), "<< /Type /Page /Parent 2 0 R /Annots [6 0 R] >>".into(), "<< /Fields [6 0 R] /DA (/Helv 10 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >>".into(), "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), "<< /Type /Annot /Subtype /Widget /FT /Ch /T (days) /Ff 2097152 /Opt [(Mon) (Tue) (Wed) (Thu) (Fri)] /Rect [50 700 200 746] /P 3 0 R >>" .into(), ]; let mut doc = document(&objs); // Setting value to Tue and Thu (indices 1 and 3) in a 46 pt high box (fits 3 rows). // It should scroll /TI to 1 so Tue (1), Wed (2), Thu (3) are drawn and highlighted. set_value(&mut doc, "days", &FieldValue::Choice(vec!["Tue".into(), "Thu".into()])).unwrap(); let doc = reopen(&doc); let all = fields(&doc); let days = field(&all, "days"); assert_eq!(days.value, ["Tue", "Thu"]); let ti = doc.get(days.obj).as_dict().unwrap().get(b"TI").and_then(|o| o.as_int()); assert_eq!(ti, Some(1), "TI scrolled to 1 so both selected options are visible"); let appearance = ap(&doc, &days.widgets[0]); assert!(appearance.contains("(Tue) Tj"), "Tue is drawn: {appearance}"); assert!(appearance.contains("(Thu) Tj"), "Thu is drawn: {appearance}"); assert_eq!(appearance.matches("0.6 0.75 0.86 rg").count(), 2, "both Tue and Thu highlighted"); } #[test] fn multiselect_choice_indices_are_sorted_without_reordering_values() { let mut forward_appearance = None; for values in [["Ham", "Olives"], ["Olives", "Ham"]] { let mut doc = fixture(); let chosen: Vec = values.iter().map(|value| (*value).into()).collect(); set_value(&mut doc, "toppings", &FieldValue::Choice(chosen.clone())).unwrap(); let doc = reopen(&doc); let all = fields(&doc); let list = field(&all, "toppings"); assert_eq!(list.value, chosen, "saving preserves the caller's /V order"); let object = doc.get(list.obj); let indices = object.as_dict().unwrap().get(b"I").unwrap().as_array().unwrap(); assert_eq!(indices.iter().map(|index| index.as_int().unwrap()).collect::>(), [1, 2], "saved option indices are ascending"); let appearance = ap(&doc, &list.widgets[0]); assert_eq!(appearance.matches("0.6 0.75 0.86 rg").count(), 2, "both selected rows stay highlighted"); if let Some(forward) = &forward_appearance { assert_eq!(&appearance, forward, "the same selected rows have the same appearance in either selection order"); } else { forward_appearance = Some(appearance); } } } #[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_combined_matches_legacy(&doc, 0); assert!(fields(&doc).is_empty()); assert_eq!(set_value(&mut doc, "x", &FieldValue::Text("y".into())), Err(FormError::NoForm)); } #[test] fn combined_enumeration_keeps_page_order_across_pages() { let objs: Vec = vec![ "<< /Type /Catalog /Pages 2 0 R /AcroForm 5 0 R >>".into(), "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 600 800] >>".into(), "<< /Type /Page /Parent 2 0 R /Annots [6 0 R] >>".into(), "<< /Type /Page /Parent 2 0 R /Annots [7 0 R] >>".into(), "<< /Fields [6 0 R] >>".into(), "<< /Type /Annot /Subtype /Widget /FT /Tx /T (listed) /Rect [50 700 250 720] /P 3 0 R >>".into(), "<< /Type /Annot /Subtype /Widget /FT /Tx /T (adopted) /Rect [50 700 250 720] /P 4 0 R >>".into(), ]; let doc = document(&objs); assert_combined_matches_legacy(&doc, 1); let combined = fields_with_adopted(&doc); assert_eq!(combined.items.iter().map(|f| f.name.as_str()).collect::>(), ["listed", "adopted"]); assert_eq!(combined.items.iter().map(|f| f.widgets[0].page).collect::>(), [Some(0), Some(1)]); } #[test] fn combined_enumeration_keeps_leniency_for_dangling_field_entries() { let objs: Vec = vec![ "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), "<< /Type /Page /Parent 2 0 R /Annots [] >>".into(), "<< /Fields [99 0 R null 5 0 R] >>".into(), "<< /FT /Tx /T (valid) >>".into(), ]; let doc = document(&objs); assert_combined_matches_legacy(&doc, 0); assert_eq!(fields_with_adopted(&doc).items.iter().map(|f| f.name.as_str()).collect::>(), ["valid"]); } #[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 = [ (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::>(), ["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"); } /// A parent `MASTER` with kids `a` and `b` (each a field merged with its widget) and `e` (a field /// with two widget kids), and the top-level fields `c` and `d`. fn dotted_form() -> Document { let objs: Vec = 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 9 0 R 11 0 R 12 0 R] >>".into(), // 3 "<< /Fields [5 0 R 8 0 R 9 0 R] /DA (/Helv 0 Tf 0 g) >>".into(), // 4 "<< /T (MASTER) /Kids [6 0 R 7 0 R 10 0 R] >>".into(), // 5 "<< /Type /Annot /Subtype /Widget /FT /Tx /T (a) /Parent 5 0 R /V (one) /Rect [50 700 250 720] /P 3 0 R >>".into(), // 6 "<< /Type /Annot /Subtype /Widget /FT /Tx /T (b) /Parent 5 0 R /V (two) /Rect [50 650 250 670] /P 3 0 R >>".into(), // 7 "<< /Type /Annot /Subtype /Widget /FT /Tx /T (c) /V (three) /Rect [50 600 250 620] /P 3 0 R >>".into(), // 8 "<< /Type /Annot /Subtype /Widget /FT /Tx /T (d) /Rect [50 550 250 570] /P 3 0 R >>".into(), // 9 "<< /FT /Tx /T (e) /Parent 5 0 R /V (four) /Kids [11 0 R 12 0 R] >>".into(), // 10 "<< /Type /Annot /Subtype /Widget /Parent 10 0 R /Rect [50 500 250 520] /P 3 0 R >>".into(), // 11 "<< /Type /Annot /Subtype /Widget /Parent 10 0 R /Rect [300 500 500 520] /P 3 0 R >>".into(), // 12 ]; document(&objs) } fn rename(doc: &mut Document, name: &str, to: &str) -> Result { set_props(doc, name, &FieldProps { name: Some(to.into()), ..FieldProps::default() }) } /// The partial name (`/T`) of object `num`. fn own_name(doc: &Document, num: u32) -> Option { doc.get(ObjRef::new(num, 0)).as_dict().unwrap().get(b"T").and_then(|t| text_of(&doc.resolve(t))) } #[test] fn renaming_a_kid_is_checked_against_the_full_name_it_takes() { let mut doc = dotted_form(); // MASTER.b is taken, though no field's full name is the bare "b". Two fields of one fully // qualified name are one field and share its value (ISO 32000-2 §12.7.4.2). let err = rename(&mut doc, "MASTER.a", "b").unwrap_err(); assert_eq!(err, FormError::Invalid("a field named \"MASTER.b\" already exists".into())); assert_eq!(own_name(&doc, 6).as_deref(), Some("a"), "a refused rename changes nothing"); // The same between a field merged with its widget and a field with widget kids. assert!(rename(&mut doc, "MASTER.e", "a").is_err()); assert!(rename(&mut doc, "MASTER.a", "e").is_err()); // A kid keeping its own name is no clash with itself. assert_eq!(rename(&mut doc, "MASTER.b", "b").unwrap(), "MASTER.b"); // The top-level "c" does not take MASTER.c. assert_eq!(rename(&mut doc, "MASTER.a", "c").unwrap(), "MASTER.c"); assert_eq!(rename(&mut doc, "MASTER.e", "f").unwrap(), "MASTER.f"); let doc = reopen(&doc); let all = fields(&doc); let names: Vec<&str> = all.iter().map(|f| f.name.as_str()).collect(); assert_eq!(names, ["MASTER.c", "MASTER.b", "MASTER.f", "c", "d"]); for (name, value) in [("MASTER.c", "one"), ("MASTER.b", "two"), ("MASTER.f", "four"), ("c", "three")] { assert_eq!(field(&all, name).value, [value], "{name} keeps its own value"); } // Only the field's own /T changes: the kids stay under MASTER, a merged kid stays its own // widget, and the widget kids get no /T (which would make them fields). let widgets = |name: &str| field(&all, name).widgets.iter().map(|w| w.obj.num).collect::>(); assert_eq!((field(&all, "MASTER.c").obj, widgets("MASTER.c")), (ObjRef::new(6, 0), vec![6])); assert_eq!((field(&all, "MASTER.f").obj, widgets("MASTER.f")), (ObjRef::new(10, 0), vec![11, 12])); let partial: Vec> = [5, 6, 10, 11, 12].into_iter().map(|n| own_name(&doc, n)).collect(); assert_eq!(partial.iter().map(Option::as_deref).collect::>(), [Some("MASTER"), Some("c"), Some("f"), None, None]); } #[test] fn renaming_a_top_level_field_is_still_checked_against_top_level_names() { let mut doc = dotted_form(); let err = rename(&mut doc, "c", "d").unwrap_err(); assert_eq!(err, FormError::Invalid("a field named \"d\" already exists".into())); assert_eq!(own_name(&doc, 8).as_deref(), Some("c"), "a refused rename changes nothing"); // A kid's partial name is no top-level name: MASTER.a leaves "a" free. assert_eq!(rename(&mut doc, "c", "a").unwrap(), "a"); let doc = reopen(&doc); let names: Vec = fields(&doc).into_iter().map(|f| f.name).collect(); assert_eq!(names, ["MASTER.a", "MASTER.b", "MASTER.e", "a", "d"]); } #[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::Format("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); } /// A Calculate script that spells the notation's markers in a string, not in a comment, is kept /// as a script and runs: the form used to calculate the string's notation instead. #[test] fn a_script_that_spells_the_markers_in_a_string_runs_as_a_script() { struct Ran; impl Scripts for Ran { fn run(&mut self, _event: FieldEvent, _script: &str, _target: &Field, _value: &str, _fields: &[Field]) -> ScriptResult { ScriptResult { rc: true, value: "ran".into(), changes: Vec::new(), message: None } } } let mut doc = one_page(); let text = NewField::Text { multiline: false }; add_field(&mut doc, 0, [50.0, 700.0, 250.0, 720.0], &text, Some("a")).unwrap(); add_field(&mut doc, 0, [50.0, 660.0, 250.0, 680.0], &text, Some("total")).unwrap(); let js = "var note = \"BVCALC a EVCALC\"; event.value = 7;"; set_field_script(&mut doc, "total", "calculate", Some(js)).unwrap(); set_value(&mut doc, "a", &FieldValue::Text("5".into())).unwrap(); let all = fields(&doc); let total = &field(&all, "total").actions; assert_eq!((&total.calculate, total.scripts.calculate.as_deref()), (&af::Calculate::None, Some(js))); recalculate_with(&mut doc, &mut Ran).unwrap(); assert_eq!(field(&fields(&doc), "total").value, ["ran"]); } /// Renaming a field renames it in the calculations that read it: a sum dropped it, notation /// read it as 0 and a script read a field that no longer existed. #[test] fn renaming_a_field_renames_it_in_the_calculations_that_read_it() { use crate::af::{CalcOp, Calculate}; let mut doc = one_page(); let text = NewField::Text { multiline: false }; for (i, name) in ["a", "b", "sum", "product", "script"].into_iter().enumerate() { let y = 700.0 - 40.0 * i as f64; add_field(&mut doc, 0, [50.0, y, 250.0, y + 20.0], &text, Some(name)).unwrap(); } let calculate = |c: Calculate| FieldProps { calculate: Some(c), ..FieldProps::default() }; set_props(&mut doc, "sum", &calculate(Calculate::Simple { op: CalcOp::Sum, fields: vec!["a".into(), "b".into()] })).unwrap(); set_props(&mut doc, "product", &calculate(Calculate::Notation("a * b + ab".into()))).unwrap(); set_field_script(&mut doc, "script", "calculate", Some("event.value = getField(\"a\").value + getField(\"b\").value;")).unwrap(); set_props(&mut doc, "a", &FieldProps { name: Some("qty".into()), ..FieldProps::default() }).unwrap(); let check = |doc: &Document| { let all = fields(doc); assert_eq!(field(&all, "sum").actions.calculate, Calculate::Simple { op: CalcOp::Sum, fields: vec!["qty".into(), "b".into()] }); assert_eq!(field(&all, "product").actions.calculate, Calculate::Notation("qty * b + ab".into())); let script = field(&all, "script").actions.scripts.calculate.clone(); assert_eq!(script.as_deref(), Some("event.value = getField(\"qty\").value + getField(\"b\").value;")); }; check(&doc); let mut doc = reopen(&doc); check(&doc); set_value(&mut doc, "qty", &FieldValue::Text("10".into())).unwrap(); set_value(&mut doc, "b", &FieldValue::Text("2".into())).unwrap(); let all = fields(&doc); assert_eq!((field(&all, "sum").value.clone(), field(&all, "product").value.clone()), (vec!["12".to_string()], vec!["20".to_string()])); } /// A dotted name moves the field under new parent fields (#2696), and the calculations that read /// it follow it to its full new name, as they follow a plain rename. #[test] fn a_dotted_rename_moves_the_field_and_its_calculations_follow() { use crate::af::{CalcOp, Calculate}; let mut doc = one_page(); let text = NewField::Text { multiline: false }; for (i, name) in ["a", "b", "sum", "script"].into_iter().enumerate() { let y = 700.0 - 40.0 * i as f64; add_field(&mut doc, 0, [50.0, y, 250.0, y + 20.0], &text, Some(name)).unwrap(); } let calculate = FieldProps { calculate: Some(Calculate::Simple { op: CalcOp::Sum, fields: vec!["a".into(), "b".into()] }), ..FieldProps::default() }; set_props(&mut doc, "sum", &calculate).unwrap(); set_field_script(&mut doc, "script", "calculate", Some("event.value = getField(\"a\").value;")).unwrap(); set_props(&mut doc, "a", &FieldProps { name: Some("Group.a".into()), ..FieldProps::default() }).unwrap(); let mut doc = reopen(&doc); let all = fields(&doc); assert!(all.iter().any(|f| f.name == "Group.a") && all.iter().all(|f| f.name != "a")); assert_eq!(field(&all, "sum").actions.calculate, Calculate::Simple { op: CalcOp::Sum, fields: vec!["Group.a".into(), "b".into()] }); assert_eq!(field(&all, "script").actions.scripts.calculate.as_deref(), Some("event.value = getField(\"Group.a\").value;")); set_value(&mut doc, "Group.a", &FieldValue::Text("10".into())).unwrap(); set_value(&mut doc, "b", &FieldValue::Text("2".into())).unwrap(); assert_eq!(field(&fields(&doc), "sum").value, ["12"]); } /// The rename reaches a script stored as a stream, an indirect action and the only widget's /// actions; it leaves a script that isn't UTF-8 as it is, and a name another field still has. #[test] fn a_rename_reaches_every_stored_form_of_a_calculation() { let widget = |name: &str, y: u32, rest: &str| { format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T ({name}) /Rect [50 {y} 250 {}] /P 3 0 R {rest} >>", y + 20) }; let objs: Vec = vec![ "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), "<< /Type /Page /Parent 2 0 R /Annots [5 0 R 6 0 R 7 0 R 11 0 R 12 0 R 13 0 R 15 0 R] >>".into(), "<< /Fields [5 0 R 6 0 R 7 0 R 11 0 R 12 0 R 13 0 R 14 0 R] /DA (/Helv 0 Tf 0 g) >>".into(), widget("a", 700, ""), widget("stream", 660, "/AA << /C 8 0 R >>"), widget("bytes", 620, "/AA << /C << /S /JavaScript /JS 10 0 R >> >>"), "<< /S /JavaScript /JS 9 0 R >>".into(), "<< /Length 34 >>\nstream\nevent.value = getField(\"a\").value;\nendstream".into(), "<< /Length 85 /Filter /ASCIIHexDecode >>\nstream\n2F2A20FF202A2F206576656E742E76616C7565203D206765744669656C6428226122292E76616C75653B>\nendstream".into(), widget("dup", 580, ""), widget("dup", 540, ""), widget("both", 500, "/AA << /C << /S /JavaScript /JS (AFSimple_Calculate\\(\"SUM\", new Array\\(\"dup\", \"a\"\\)\\);) >> >>"), "<< /FT /Tx /T (kid) /Kids [15 0 R] >>".into(), "<< /Type /Annot /Subtype /Widget /Parent 14 0 R /Rect [50 460 250 480] /P 3 0 R /AA << /C << /S /JavaScript /JS (event.value = getField\\('a'\\).value;) >> >> >>".into(), ]; let mut doc = document(&objs); let calc = |doc: &Document, name: &str| { let all = fields(doc); let f = field(&all, name); (f.actions.calculate.clone(), f.actions.scripts.calculate.clone().unwrap_or_default()) }; assert!(calc(&doc, "stream").1.contains("getField(\"a\")"), "{:?}", calc(&doc, "stream")); set_props(&mut doc, "a", &FieldProps { name: Some("qty".into()), ..FieldProps::default() }).unwrap(); let doc = reopen(&doc); assert_eq!(calc(&doc, "stream").1, "event.value = getField(\"qty\").value;"); assert_eq!(calc(&doc, "kid").1, "event.value = getField(\"qty\").value;"); assert!(calc(&doc, "bytes").1.contains("getField(\"a\")"), "a script that isn't UTF-8 is not written back"); assert_eq!(calc(&doc, "both").0, af::Calculate::Simple { op: af::CalcOp::Sum, fields: vec!["dup".into(), "qty".into()] }); let mut doc = doc; set_props(&mut doc, "dup", &FieldProps { name: Some("dup2".into()), ..FieldProps::default() }).unwrap(); assert_eq!( calc(&doc, "both").0, af::Calculate::Simple { op: af::CalcOp::Sum, fields: vec!["dup".into(), "qty".into()] }, "the other dup keeps the name" ); } /// The calculation order is read with what each formula reads and what is wrong with the order; /// Fix order moves only what must move, and the order it writes is kept across a save. #[test] fn the_calculation_order_is_checked_fixed_and_kept() { use crate::af::{CalcOp, Calculate}; use crate::calc_order::CalculationIssue::{Cycle, RunsTooEarly}; let mut doc = one_page(); let text = NewField::Text { multiline: false }; for (i, name) in ["qty", "price", "shipping", "sub", "total", "grand", "tax"].into_iter().enumerate() { let y = 700.0 - 40.0 * i as f64; add_field(&mut doc, 0, [50.0, y, 250.0, y + 20.0], &text, Some(name)).unwrap(); } let calculate = |c: Calculate| FieldProps { calculate: Some(c), ..FieldProps::default() }; let sum = |names: &[&str]| calculate(Calculate::Simple { op: CalcOp::Sum, fields: names.iter().map(|n| n.to_string()).collect() }); // Each calculation joins the end of the order, so this chain is written backwards. set_props(&mut doc, "grand", &sum(&["total"])).unwrap(); set_props(&mut doc, "total", &sum(&["sub", "shipping"])).unwrap(); set_props(&mut doc, "sub", &calculate(Calculate::Notation("qty * price".into()))).unwrap(); set_field_script(&mut doc, "tax", "calculate", Some("event.value = getField(\"total\").value * 0.2; // getField(\"grand\")")).unwrap(); for (name, v) in [("qty", "2"), ("price", "10"), ("shipping", "5")] { set_value(&mut doc, name, &FieldValue::Text(v.into())).unwrap(); } let value = |doc: &Document, name: &str| field(&fields(doc), name).value.clone(); assert_eq!((value(&doc, "total"), value(&doc, "grand")), (vec!["25".to_string()], vec!["0".to_string()]), "grand runs too early: stale"); let order = calculation_order(&doc, &fields(&doc)); let names: Vec<&str> = order.fields.iter().map(|f| f.name.as_str()).collect(); assert_eq!(names, ["grand", "total", "sub", "tax"]); let issues: Vec<_> = order.fields.iter().map(|f| f.issues.clone()).collect(); assert_eq!(issues, [vec![RunsTooEarly { field: "total".into() }], vec![RunsTooEarly { field: "sub".into() }], vec![], vec![]]); assert_eq!(order.fields[1].reads, ["sub", "shipping"]); assert_eq!(order.fields[3].reads, ["total"], "a name in a comment isn't read"); assert!(order.fields.iter().all(|f| f.listed) && order.extra.is_empty()); assert!(order.fixable()); assert_eq!(order.fixed(), ["sub", "total", "grand", "tax"]); set_calculation_order(&mut doc, &order.fixed()).unwrap(); recalculate(&mut doc).unwrap(); assert_eq!((value(&doc, "total"), value(&doc, "grand")), (vec!["25".to_string()], vec!["25".to_string()])); let mut doc = reopen(&doc); let order = calculation_order(&doc, &fields(&doc)); assert_eq!(order.fields.iter().map(|f| f.name.as_str()).collect::>(), ["sub", "total", "grand", "tax"], "kept across a save"); assert!(order.fields.iter().all(|f| f.issues.is_empty()) && !order.fixable()); // Names given first, the others after them in their order. set_calculation_order(&mut doc, &["tax".into()]).unwrap(); let names = |doc: &Document| calculation_order(doc, &fields(doc)).fields.into_iter().map(|f| f.name).collect::>(); assert_eq!(names(&doc), ["tax", "sub", "total", "grand"]); for (bad, error) in [ (vec!["qty"], FormError::Invalid("qty has no calculation".into())), (vec!["nope"], FormError::NoSuchField("nope".into())), (vec!["sub", "sub"], FormError::Invalid("sub is in the order twice".into())), ] { let bad: Vec = bad.into_iter().map(String::from).collect(); assert_eq!(set_calculation_order(&mut doc, &bad), Err(error)); } assert_eq!(names(&doc), ["tax", "sub", "total", "grand"], "a refused order changes nothing"); // A loop: sub now reads grand, which reads total, which reads sub. Its new formula keeps its // place in the order. Fix order can't fix a loop, and leaves it in its order. set_props(&mut doc, "sub", &calculate(Calculate::Notation("qty * price + grand".into()))).unwrap(); let order = calculation_order(&doc, &fields(&doc)); let issues: Vec<(&str, &[_])> = order.fields.iter().map(|f| (f.name.as_str(), f.issues.as_slice())).collect(); assert_eq!(issues[0], ("tax", &[RunsTooEarly { field: "total".into() }][..])); assert_eq!( issues[1..], [ ("sub", &[Cycle { fields: vec!["grand".into()] }][..]), ("total", &[Cycle { fields: vec!["sub".into()] }][..]), ("grand", &[Cycle { fields: vec!["total".into()] }][..]), ] ); assert_eq!(order.fixed(), ["sub", "total", "tax", "grand"], "only tax moves, to just after total"); } /// `/CO` as other tools leave it: a calculated field missing, a field without a calculation, an /// entry repeated, a reference to nothing and a number. The order reads around them, and setting /// the order writes `/CO` afresh. #[test] fn a_calculation_order_with_gaps_and_extra_entries() { let widget = |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 sum = |names: &str| format!("/AA << /C << /S /JavaScript /JS (AFSimple_Calculate\\(\"SUM\", \"{names}\"\\);) >> >>"); let objs: Vec = vec![ "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), "<< /Type /Page /Parent 2 0 R /Annots [5 0 R 6 0 R 7 0 R 8 0 R] >>".into(), "<< /Fields [5 0 R 6 0 R 7 0 R 8 0 R] /DA (/Helv 0 Tf 0 g) /CO [6 0 R 5 0 R 6 0 R 99 0 R 12] >>".into(), widget("a", 700, "/V (3)"), widget("listed", 660, &sum("a")), widget("unlisted", 620, &sum("listed")), widget("b", 580, "/V (4)"), ]; let mut doc = document(&objs); let order = calculation_order(&doc, &fields(&doc)); let rows: Vec<(&str, bool, &[_])> = order.fields.iter().map(|f| (f.name.as_str(), f.listed, f.issues.as_slice())).collect(); assert_eq!(rows, [("listed", true, &[][..]), ("unlisted", false, &[crate::calc_order::CalculationIssue::NotInOrder][..])]); assert_eq!(order.extra, ["a", "listed", "99 0 R", "not a reference"]); assert!(order.fixable()); assert_eq!(order.fixed(), ["listed", "unlisted"]); assert_eq!(recalculate(&mut doc), Ok(2), "the unlisted field calculates too, after the listed one"); assert_eq!(field(&fields(&doc), "unlisted").value, ["3"]); set_calculation_order(&mut doc, &[]).unwrap(); let doc = reopen(&doc); let co = acroform(&doc).and_then(|af| af.get(b"CO").cloned()).unwrap(); let refs: Vec<_> = doc.resolve(&co).as_array().unwrap().iter().filter_map(Object::as_ref).collect(); assert_eq!(refs, [field(&fields(&doc), "listed").obj, field(&fields(&doc), "unlisted").obj], "every calculated field once, nothing else"); assert!(!calculation_order(&doc, &fields(&doc)).fixable()); } /// Two calculated fields with one full name (a malformed form: a full name is one field). The /// order names them both, so a repeated name stands for the next of them in the current order: /// the current order sets back unchanged, and Fix order and moves work around them. #[test] fn two_fields_with_one_name_keep_their_places_in_the_order() { let widget = |name: &str, y: u32, reads: &str| { format!( "<< /Type /Annot /Subtype /Widget /FT /Tx /T ({name}) /Rect [50 {y} 250 {}] /P 3 0 R /AA << /C << /S /JavaScript /JS (AFSimple_Calculate\\(\"SUM\", \"{reads}\"\\);) >> >> >>", y + 20 ) }; let objs: Vec = vec![ "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), "<< /Type /Page /Parent 2 0 R /Annots [5 0 R 6 0 R 7 0 R 8 0 R] >>".into(), "<< /Fields [5 0 R 6 0 R 7 0 R 8 0 R] /DA (/Helv 0 Tf 0 g) /CO [6 0 R 7 0 R 5 0 R] >>".into(), widget("dup", 700, "x"), widget("dup", 660, "src"), widget("src", 620, "x"), "<< /Type /Annot /Subtype /Widget /FT /Tx /T (x) /Rect [50 580 250 600] /P 3 0 R /V (3) >>".into(), ]; let mut doc = document(&objs); // Each dup told apart by what it reads. let rows = |doc: &Document| -> Vec<(String, Vec, bool)> { calculation_order(doc, &fields(doc)).fields.into_iter().map(|f| (f.name, f.reads, f.issues.is_empty())).collect() }; let row = |name: &str, reads: &str, ok: bool| (name.to_owned(), vec![reads.to_owned()], ok); assert_eq!(rows(&doc), [row("dup", "src", false), row("src", "x", true), row("dup", "x", true)]); let current: Vec = ["dup", "src", "dup"].map(String::from).into(); set_calculation_order(&mut doc, ¤t).unwrap(); assert_eq!(rows(&doc), [row("dup", "src", false), row("src", "x", true), row("dup", "x", true)], "the current order sets back unchanged"); let order = calculation_order(&doc, &fields(&doc)); assert_eq!(order.fixed(), ["src", "dup", "dup"]); set_calculation_order(&mut doc, &order.fixed()).unwrap(); assert_eq!(rows(&doc), [row("src", "x", true), row("dup", "src", true), row("dup", "x", true)], "the dup that reads src moves after it"); // Each dup calculates, not the first of them twice. assert_eq!(recalculate(&mut doc), Ok(3)); let values: Vec> = fields(&doc).into_iter().filter(|f| f.name == "dup").map(|f| f.value).collect(); assert_eq!(values, [["3"], ["3"]]); // A name given alone stands for the first of its fields in the current order. set_calculation_order(&mut doc, &["dup".into()]).unwrap(); assert_eq!(rows(&doc)[0], row("dup", "src", false)); let thrice: Vec = ["dup", "dup", "dup"].map(String::from).into(); assert_eq!(set_calculation_order(&mut doc, &thrice), Err(FormError::Invalid("dup is in the order twice".into()))); } #[test] fn new_text_fields_fit_their_box() { // #1888: a fixed 12 pt was clipped in a short box. New text and date fields take Acrobat's // Auto size: 12 pt where there is room, smaller where there isn't. let mut doc = one_page(); let tf_size = |ap: &str| { ap.split("/Helv ") .nth(1) .and_then(|s| s.split(' ').next()) .and_then(|n| n.parse::().ok()) .unwrap_or_else(|| panic!("no /Helv size in {ap}")) }; for (kind, name, value) in [(NewField::Text { multiline: false }, "short", "Hello world"), (NewField::Date, "date", "10/13/2026")] { add_field(&mut doc, 0, [20.0, 20.0, 200.0, 28.0], &kind, Some(name)).unwrap(); set_value(&mut doc, name, &FieldValue::Text(value.into())).unwrap(); let f = field(&fields(&doc), name).clone(); assert_eq!(f.da, "/Helv 0 Tf 0 g", "{name}"); let size = tf_size(&ap(&doc, &f.widgets[0])); assert!(size > 0.0 && size <= 8.0, "{name}: {size} pt in an 8 pt box"); } add_field(&mut doc, 0, [20.0, 50.0, 200.0, 72.0], &NewField::Text { multiline: false }, Some("tall")).unwrap(); set_value(&mut doc, "tall", &FieldValue::Text("Hello world".into())).unwrap(); assert_eq!(tf_size(&ap(&doc, &field(&fields(&doc), "tall").widgets[0])), 12.0, "a box with room keeps the 12 pt look"); // An explicit size still wins. set_props(&mut doc, "short", &FieldProps { font_size: Some(12.0), ..FieldProps::default() }).unwrap(); assert_eq!(tf_size(&ap(&doc, &field(&fields(&doc), "short").widgets[0])), 12.0); } #[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!["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 check_mark_takes_the_fields_text_colour() { // #4261: Appearance ▸ Text Color is written to /DA; the mark of a check box or radio button // is drawn in it (Acrobat does the same), black when the /DA has no colour. 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 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() }; assert!(on_ap(&doc, &check).contains("0 G"), "black by default"); let blue = FieldProps { appearance: Some(LookPatch { text: Some([0.0, 0.0, 1.0]), ..Default::default() }), ..Default::default() }; for name in [&check, &radio] { set_props(&mut doc, name, &blue).unwrap(); let ap = on_ap(&reopen(&doc), name); // The check strokes, the radio's default circle fills. let op = if name == &check { "0 0 1 RG" } else { "0 0 1 rg" }; assert!(ap.contains(op), "{name}: the mark is blue: {ap}"); assert!(!ap.contains("0 G\n") && !ap.contains("0 g\n"), "{name}: no black left: {ap}"); } // A style change redraws too, and keeps the colour. set_props(&mut doc, &check, &FieldProps { check_style: Some(CheckStyle::Square), ..FieldProps::default() }).unwrap(); assert!(on_ap(&doc, &check).contains("0 0 1 rg"), "a square is blue as well"); } #[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![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()); } /// Fields with the same fully qualified name are one field (§12.7.3.1): a parent whose widgets are /// its kids, as Duplicate Across Pages writes it, and separate field dictionaries that repeat the /// same `/T`, as other writers do. Filling the name fills every one of them. #[test] fn fields_sharing_a_name_take_the_same_value() { let objs: Vec = vec![ "<< /Type /Catalog /Pages 2 0 R /AcroForm 5 0 R >>".into(), // 1 "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 600 800] >>".into(), // 2 "<< /Type /Page /Parent 2 0 R /Annots [7 0 R 11 0 R] >>".into(), // 3 "<< /Type /Page /Parent 2 0 R /Annots [8 0 R 9 0 R 12 0 R] >>".into(), // 4 "<< /Fields [6 0 R 9 0 R 11 0 R 12 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 10 0 R >> >> >>".into(), // 5 "<< /FT /Tx /T (ClientName) /Kids [7 0 R 8 0 R] >>".into(), // 6 parent "<< /Type /Annot /Subtype /Widget /Parent 6 0 R /Rect [50 700 250 720] /P 3 0 R >>".into(), // 7 its widget, page 1 "<< /Type /Annot /Subtype /Widget /Parent 6 0 R /Rect [50 700 250 720] /P 4 0 R >>".into(), // 8 its widget, page 2 "<< /Type /Annot /Subtype /Widget /FT /Tx /T (ClientName) /Rect [50 600 250 620] /P 4 0 R >>".into(), // 9 same name, own field "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), // 10 "<< /Type /Annot /Subtype /Widget /FT /Btn /T (Agree) /V /Off /AS /Off /Rect [300 700 315 715] /P 3 0 R /AP << /N << /Yes 13 0 R /Off 14 0 R >> >> >>".into(), // 11 "<< /Type /Annot /Subtype /Widget /FT /Btn /T (Agree) /V /Off /AS /Off /Rect [300 700 315 715] /P 4 0 R /AP << /N << /Yes 13 0 R /Off 14 0 R >> >> >>".into(), // 12 "<< /Length 0 >>\nstream\n\nendstream".into(), // 13 "<< /Length 0 >>\nstream\n\nendstream".into(), // 14 ]; let mut doc = document(&objs); set_value(&mut doc, "ClientName", &FieldValue::Text("Ada Lovelace".into())).unwrap(); set_value(&mut doc, "Agree", &FieldValue::Check(true)).unwrap(); let doc = reopen(&doc); let v = |n: u32| doc.get(ObjRef { num: n, generation: 0 }).as_dict().unwrap().get(b"V").cloned(); let typed = Some(Object::String(PdfString::text("Ada Lovelace"))); assert_eq!((v(6), v(9)), (typed.clone(), typed), "the value sits on the parent and on the field that repeats its name"); assert_eq!((v(7), v(8)), (None, None), "the parent's widgets carry no value of their own"); let all = fields(&doc); let named: Vec<&Field> = all.iter().filter(|f| f.name == "ClientName").collect(); let widgets: Vec<&Widget> = named.iter().flat_map(|f| &f.widgets).collect(); assert_eq!(widgets.iter().map(|w| w.obj.num).collect::>(), [7, 8, 9]); for w in widgets { assert!(ap(&doc, w).contains("Ada Lovelace"), "widget {} shows the value", w.obj.num); } let agree: Vec<&Field> = all.iter().filter(|f| f.name == "Agree").collect(); assert_eq!(agree.len(), 2); for f in agree { assert_eq!((f.value.as_slice(), f.widgets[0].state.as_deref()), (&["Yes".to_string()][..], Some("Yes")), "check box {}", f.obj.num); } } #[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_combined_matches_legacy(&doc, 0); assert_eq!(fields(&doc).iter().find(|f| f.name == "go").unwrap().button, Some(af::ButtonAction::Named("Print".into()))); } #[test] fn editing_one_script_keeps_the_other_actions_and_the_chain() { let mut doc = fixture(); let go = field(&fields(&doc), "go").obj; let action = |doc: &mut Document, entries: Vec<(&[u8], Object)>| { let mut a = Dict::new(); for (k, v) in entries { a.set(k.to_vec(), v); } doc.add(Object::Dict(a)) }; let next = action(&mut doc, vec![(b"S", Object::name("URI")), (b"URI", Object::String(PdfString::literal(b"https://example.org".to_vec())))]); let up = action( &mut doc, vec![(b"S", Object::name("JavaScript")), (b"JS", Object::String(PdfString::text("app.alert(1);"))), (b"Next", Object::Ref(next))], ); let blur = action( &mut doc, vec![(b"S", Object::name("JavaScript")), (b"JS", Object::String(PdfString::text("this.dirty = true;"))), (b"Foo", Object::Int(7))], ); let mut aa = Dict::new(); aa.set(b"Bl".to_vec(), Object::Ref(blur)); doc.update_dict(go, |d| { d.set(b"A".to_vec(), Object::Ref(up)); d.set(b"AA".to_vec(), Object::Dict(aa)); }) .unwrap(); let mut acts = field_actions(&doc, "go").unwrap(); assert_eq!(acts[0], (Trigger::MouseUp, FieldAction::JavaScript("app.alert(1);".into()))); acts[0].1 = FieldAction::JavaScript("app.alert(2);".into()); set_field_actions(&mut doc, "go", &acts).unwrap(); let w = doc.get(go).as_dict().cloned().unwrap(); // The action that did not change is the same object, with its keys this crate doesn't read. let aa = w.get(b"AA").unwrap().as_dict().unwrap().clone(); assert_eq!(aa.get(b"Bl"), Some(&Object::Ref(blur))); // The edited script keeps the actions chained after it. let a = doc.resolve(w.get(b"A").unwrap()).as_dict().cloned().unwrap(); assert_eq!(a.get(b"Next"), Some(&Object::Ref(next))); assert_eq!(field_actions(&doc, "go").unwrap()[0], (Trigger::MouseUp, FieldAction::JavaScript("app.alert(2);".into()))); } #[test] fn replacing_a_chained_action_with_another_type_keeps_the_chain() { let mut doc = fixture(); let go = field(&fields(&doc), "go").obj; let s = |t: &str| Object::String(PdfString::text(t)); let mut named = Dict::new(); named.set(b"S".to_vec(), Object::name("Named")); named.set(b"N".to_vec(), Object::name("NextPage")); let named = doc.add(Object::Dict(named)); // A chain as an array: a link, then a menu item that points back at the link (a loop). let mut uri = Dict::new(); uri.set(b"S".to_vec(), Object::name("URI")); uri.set(b"URI".to_vec(), s("https://example.org")); let uri = doc.add(Object::Dict(uri)); doc.update_dict(named, |d| d.set(b"Next".to_vec(), Object::Ref(uri))).unwrap(); let chain = Object::Array(vec![Object::Ref(uri), Object::Ref(named)]); let mut up = Dict::new(); up.set(b"S".to_vec(), Object::name("JavaScript")); up.set(b"JS".to_vec(), s("app.alert(1);")); up.set(b"Next".to_vec(), chain.clone()); doc.update_dict(go, |d| d.set(b"A".to_vec(), Object::Dict(up))).unwrap(); assert_eq!(field_action_chains(&doc, "go").unwrap(), [(Trigger::MouseUp, 2)]); set_field_actions(&mut doc, "go", &[(Trigger::MouseUp, FieldAction::Uri("https://example.com".into()))]).unwrap(); let w = doc.get(go).as_dict().cloned().unwrap(); let a = doc.resolve(w.get(b"A").unwrap()).as_dict().cloned().unwrap(); assert_eq!(a.get(b"Next"), Some(&chain)); assert_eq!(field_actions(&doc, "go").unwrap(), [(Trigger::MouseUp, FieldAction::Uri("https://example.com".into()))]); assert_eq!(field_action_chains(&doc, "go").unwrap(), [(Trigger::MouseUp, 2)]); // Deleting the trigger's action deletes its chain with it. set_field_actions(&mut doc, "go", &[]).unwrap(); assert_eq!(field_action_chains(&doc, "go").unwrap(), []); } #[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| { 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, preserve_rb: Option| { 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::>(), &[]); assert!(again.is_empty(), "{again:?}"); } #[test] fn detection_makes_a_row_of_touching_cells_one_comb_field() { 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("Postcode", 40.0, 205.0, 85.0), w("A", 88.0, 205.0, 95.0), w("Postcode", 40.0, 105.0, 85.0), w("B", 88.0, 105.0, 95.0), w("Yes", 70.0, 41.0, 90.0), ]; let row = |n: usize, x: f64, y: f64, cw: f64, ch: f64| (0..n).map(move |i| [x + i as f64 * cw, y, x + (i + 1) as f64 * cw, y + ch]); // 18 x 22 cells (too tall for a check box), 16 x 20 cells (each one check-box sized) drawn // right to left, and a lone square. let mut boxes: Vec<[f64; 4]> = row(5, 120.0, 200.0, 18.0, 22.0).collect(); boxes.extend(row(10, 120.0, 100.0, 16.0, 20.0).rev()); boxes.push([50.0, 40.0, 62.0, 52.0]); let found = detect(&words, &Shapes { boxes, rules: Vec::new() }, &[], &[]); let got: Vec<(&str, &Kind, [f64; 4])> = found.iter().map(|c| (c.name.as_str(), &c.kind, c.rect)).collect(); assert_eq!( got, [ ("Postcode A", &Kind::Comb { cells: 5 }, [120.0, 200.0, 210.0, 222.0]), ("Postcode B", &Kind::Comb { cells: 10 }, [120.0, 100.0, 280.0, 120.0]), ("Yes", &Kind::CheckBox, [50.0, 40.0, 62.0, 52.0]), ], "{found:?}" ); // Squares with a gap between them stay check boxes, and so do touching squares of // different sizes. let apart = Shapes { boxes: vec![[50.0, 40.0, 62.0, 52.0], [66.0, 40.0, 78.0, 52.0], [80.0, 40.0, 92.0, 52.0], [92.0, 40.0, 102.0, 52.0]], rules: Vec::new(), }; let kinds: Vec = detect(&[], &apart, &[], &[]).into_iter().map(|c| c.kind).collect(); assert_eq!(kinds, [Kind::CheckBox, Kind::CheckBox, Kind::CheckBox, Kind::CheckBox]); // An empty table of cells wider than tall (an attendance sheet) is not a comb. let grid = |cw: f64, ch: f64| { (0..3).flat_map(move |r| { (0..3).map(move |c| [100.0 + c as f64 * cw, 300.0 + r as f64 * ch, 100.0 + (c + 1) as f64 * cw, 300.0 + (r + 1) as f64 * ch]) }) }; for (cw, ch) in [(24.0, 18.0), (28.0, 20.0)] { let table = detect(&[], &Shapes { boxes: grid(cw, ch).collect(), rules: Vec::new() }, &[], &[]); assert!(table.is_empty(), "{cw} x {ch}: {table:?}"); } } #[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]]); } #[test] fn page_shapes_preserves_content_piece_boundaries() { use crate::detect::{Candidate, Kind, Shapes, detect, page_shapes}; let read = |pieces: &[&[u8]]| { let mut doc = one_page(); let page = pdfcraft_model::pages(&doc)[0].obj; let contents = pieces.iter().map(|bytes| Object::Ref(doc.add(Object::Stream(pdfcraft_cos::Stream::flate(Default::default(), bytes))))).collect(); doc.update_dict(page, |d| d.set(b"Contents".to_vec(), Object::Array(contents))).unwrap(); page_shapes(&doc, 0) }; let empty = Shapes::default(); for pieces in [vec![b"% trailing".as_slice(), b"40 120 300 30 re S\n"], vec![b"% trailing".as_slice(), b"", b"", b"40 120 300 30 re S\n"]] { let shapes = read(&pieces); assert_eq!(shapes, empty, "an unterminated comment hides the next piece's first line"); assert_eq!(detect(&[], &shapes, &[], &[]), Vec::::new()); } let visible = Shapes { boxes: vec![[40.0, 120.0, 340.0, 150.0]], rules: vec![] }; for ending in [b"% trailing\n".as_slice(), b"% trailing\r", b"% trailing\r\n"] { let shapes = read(&[ending, b"40 120 300 30 re S\n"]); assert_eq!(shapes, visible); assert_eq!(detect(&[], &shapes, &[], &[]), vec![Candidate { kind: Kind::Text, rect: [41.0, 121.0, 339.0, 149.0], name: "Text1".into() }]); } assert_eq!(read(&[b"40 120", b"", b"300 30", b"re", b"S"]), visible); assert_eq!( read(&[b"q 2 0 0 2 0 0 cm 10 10", b"60 20 re", b"S Q 10 20 m 100 20 l S"]), Shapes { boxes: vec![[20.0, 20.0, 140.0, 60.0]], rules: vec![[10.0, 20.0, 100.0, 20.0]] } ); assert_eq!(read(&[]), empty); assert_eq!(read(&[b""]), empty); } fn close(a: f64, b: f64) -> bool { (a - b).abs() < 0.05 } fn nums(o: &Object) -> Vec { o.as_array().unwrap().iter().filter_map(Object::as_f64).collect() } /// Normal appearance `/BBox` and `/Matrix` (absent when the widget is not rotated). fn appearance_box(doc: &Document, w: &Widget) -> (Vec, Option>) { 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!("appearance") }; (nums(s.dict.get(b"BBox").unwrap()), s.dict.get(b"Matrix").map(nums)) } #[test] fn rotated_fields_draw_in_their_quadrant() { let mut doc = fixture(); // No /R: the appearance is the widget rectangle and carries no /Matrix. set_value(&mut doc, "name", &FieldValue::Text("Ada".into())).unwrap(); let (bbox, matrix) = appearance_box(&doc, &field(&fields(&doc), "name").widgets[0]); assert!(close(bbox[2], 200.0) && close(bbox[3], 20.0), "{bbox:?}"); assert!(matrix.is_none(), "{matrix:?}"); let set_r = |doc: &mut Document, degrees: i64| { let obj = field(&fields(doc), "name").widgets[0].obj; doc.update_dict(obj, |d| { let mut mk = d.get(b"MK").and_then(|m| m.as_dict().cloned()).unwrap_or_default(); mk.set(b"R".to_vec(), Object::Int(degrees)); d.set(b"MK".to_vec(), Object::Dict(mk)); }) .unwrap(); set_value(doc, "name", &FieldValue::Text("Ada".into())).unwrap(); }; // 90 and 270 lay the text out in the swapped box. 180 keeps the box and turns it over. set_r(&mut doc, 90); let (bbox, matrix) = appearance_box(&doc, &field(&fields(&doc), "name").widgets[0]); assert!(close(bbox[2], 20.0) && close(bbox[3], 200.0), "{bbox:?}"); let m = matrix.unwrap(); assert!(close(m[0], 0.0) && close(m[1], 1.0) && close(m[2], -1.0) && close(m[4], 200.0), "{m:?}"); set_r(&mut doc, 180); let (bbox, matrix) = appearance_box(&doc, &field(&fields(&doc), "name").widgets[0]); assert!(close(bbox[2], 200.0) && close(bbox[3], 20.0), "{bbox:?}"); let m = matrix.unwrap(); assert!(close(m[0], -1.0) && close(m[3], -1.0) && close(m[4], 200.0) && close(m[5], 20.0), "{m:?}"); set_r(&mut doc, 270); let (bbox, matrix) = appearance_box(&doc, &field(&fields(&doc), "name").widgets[0]); assert!(close(bbox[2], 20.0) && close(bbox[3], 200.0), "{bbox:?}"); let m = matrix.unwrap(); assert!(close(m[1], -1.0) && close(m[2], 1.0) && close(m[5], 20.0), "{m:?}"); // A non-quadrant /R is drawn upright. set_r(&mut doc, 45); let (bbox, matrix) = appearance_box(&doc, &field(&fields(&doc), "name").widgets[0]); assert!(matrix.is_none() && close(bbox[2], 200.0) && close(bbox[3], 20.0), "{bbox:?} {matrix:?}"); assert_eq!(field(&fields(&doc), "name").widgets[0].rotation, 0); let before = field(&fields(&doc), "name").widgets[0].rect; assert!(set_props(&mut doc, "name", &FieldProps { rotation: Some((0, 45)), ..FieldProps::default() }).is_err()); assert!(field(&fields(&doc), "name").widgets[0].rect.iter().zip(before).all(|(a, b)| close(*a, b))); set_props(&mut doc, "name", &FieldProps { rotation: Some((0, 90)), ..FieldProps::default() }).unwrap(); let turned = field(&fields(&doc), "name").widgets[0].clone(); assert_eq!(turned.rotation, 90); let r = turned.rect; // [50 700 250 720] around its center becomes 20 wide and 200 tall. assert!(close(r[0], 140.0) && close(r[1], 610.0) && close(r[2], 160.0) && close(r[3], 810.0), "{r:?}"); let widget = doc.get(turned.obj); let mk = widget.as_dict().unwrap().get(b"MK").unwrap().as_dict().cloned().unwrap(); assert!(mk.contains(b"BG") && mk.contains(b"BC") && mk.int(b"R") == Some(90)); let (_, matrix) = appearance_box(&doc, &turned); assert!(matrix.is_some()); // 270 stays on the vertical axis, so the rectangle is not swapped again. set_props(&mut doc, "name", &FieldProps { rotation: Some((0, 270)), ..FieldProps::default() }).unwrap(); let r270 = field(&fields(&doc), "name").widgets[0].rect; assert!(r270.iter().zip(r).all(|(a, b)| close(*a, b)), "{r270:?}"); // A check box and a push button use the same matrix. let mut doc = one_page(); add_field(&mut doc, 0, [20.0, 40.0, 40.0, 60.0], &NewField::CheckBox, Some("agree")).unwrap(); add_field(&mut doc, 0, [20.0, 80.0, 120.0, 110.0], &NewField::Button { caption: "Go".into() }, Some("go")).unwrap(); set_props(&mut doc, "agree", &FieldProps { rotation: Some((0, 90)), ..FieldProps::default() }).unwrap(); set_props(&mut doc, "go", &FieldProps { rotation: Some((0, 90)), ..FieldProps::default() }).unwrap(); let agree = field(&fields(&doc), "agree").widgets[0].clone(); let widget = doc.get(agree.obj); let n = widget.as_dict().unwrap().get(b"AP").unwrap().as_dict().unwrap().get(b"N").cloned().unwrap(); let states = doc.resolve(&n); let yes = states.as_dict().unwrap().reference(b"Yes").unwrap(); let Object::Stream(s) = &*doc.get(yes) else { panic!("check") }; assert!(s.dict.contains(b"Matrix"), "the check mark is rotated"); let go = field(&fields(&doc), "go").widgets[0].clone(); let (text, matrix) = (ap(&doc, &go), appearance_box(&doc, &go).1); assert!(text.contains("(Go) Tj") && matrix.is_some(), "{text}"); } #[test] fn a_rotation_refused_as_too_small_changes_nothing_else() { let mut doc = one_page(); add_field(&mut doc, 0, [50.0, 700.0, 250.0, 720.0], &NewField::Text { multiline: false }, Some("thin")).unwrap(); // 200 x 3: turned a quarter it would be 3 wide, too small to use. let obj = field(&fields(&doc), "thin").widgets[0].obj; doc.update_dict(obj, |d| d.set(b"Rect".to_vec(), Object::Array([50.0, 700.0, 250.0, 703.0].iter().map(|v| Object::Real(*v)).collect()))).unwrap(); let props = FieldProps { tooltip: Some("changed".into()), rotation: Some((0, 90)), ..FieldProps::default() }; assert!(set_props(&mut doc, "thin", &props).is_err()); let f = field(&fields(&doc), "thin").clone(); assert_eq!(f.tooltip, None, "the tooltip was written before the rotation was refused"); assert_eq!(f.widgets[0].rotation, 0); } #[test] fn filling_and_authoring_keep_shared_appearances_and_drop_stale_alternates() { let alternates: [(&[u8], &[u8]); 2] = [(b"R", b"0 0 20 10 re f\n"), (b"D", b"1 1 18 8 re S\n")]; let cases = [ (NewField::Text { multiline: false }, true), (NewField::Text { multiline: false }, false), (NewField::CheckBox, false), (NewField::Button { caption: "Button".into() }, false), (NewField::Signature, false), ]; for (kind, fill) in cases { for indirect in [false, true] { let mut doc = one_page(); let first = add_field(&mut doc, 0, [50.0, 600.0, 250.0, 630.0], &kind, Some("First")).unwrap(); let other = add_field(&mut doc, 0, [50.0, 550.0, 250.0, 580.0], &kind, Some("Other")).unwrap(); let first_widget = field(&fields(&doc), &first).widgets[0].obj; let other_widget = field(&fields(&doc), &other).widgets[0].obj; let mut entries = doc.get(first_widget).as_dict().unwrap().get(b"AP").unwrap().as_dict().unwrap().clone(); for (key, bytes) in alternates { let mut d = Dict::new(); d.set(b"Type".to_vec(), Object::name("XObject")); d.set(b"Subtype".to_vec(), Object::name("Form")); d.set(b"BBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 20.into(), 10.into()])); let r = doc.add(Object::Stream(pdfcraft_cos::Stream::from_raw(d, bytes.to_vec()))); entries.set(key.to_vec(), Object::Ref(r)); } entries.set(b"VendorState".to_vec(), Object::name("Retained")); let shared = if indirect { Object::Ref(doc.add(Object::Dict(entries))) } else { Object::Dict(entries) }; for widget in [first_widget, other_widget] { doc.update_dict(widget, |d| d.set(b"AP".to_vec(), shared.clone())).unwrap(); } let original = reopen(&doc); for full in [false, true] { let mut doc = original.clone(); let other_widget = field(&fields(&doc), &other).widgets[0].obj; let source = doc.get(other_widget).as_dict().unwrap().get(b"AP").unwrap().clone(); let before = doc.resolve(&source); if fill { set_value(&mut doc, &first, &FieldValue::Text("Updated value".into())).unwrap(); } else { set_props(&mut doc, &first, &FieldProps { tooltip: Some("Updated help".into()), ..FieldProps::default() }).unwrap(); } assert_eq!(doc.resolve(&source), before, "a shared AP must not change"); let bytes = if full { pdfcraft_cos::write_full(&doc, &SaveOptions::default()).unwrap() } else { assert!(!doc.revisions().is_empty(), "incremental save needs an existing revision"); assert!(!doc.encryption_changed() && !doc.full_save_required(), "this edit must not require a full rewrite"); let original = doc.bytes(); let bytes = write_incremental(&doc, &SaveOptions::default()).unwrap(); assert!(bytes.len() > original.len(), "incremental save appends the changed appearance"); assert!(bytes.starts_with(original.as_slice()), "incremental save preserves the complete original prefix"); bytes }; hayro_syntax::Pdf::new(bytes.clone()).unwrap(); let doc = Document::open(Arc::new(bytes)).unwrap(); let all = fields(&doc); let first = field(&all, &first); let other = field(&all, &other); let first_ap = doc.resolve(doc.get(first.widgets[0].obj).as_dict().unwrap().get(b"AP").unwrap()); let other_ap = doc.resolve(doc.get(other.widgets[0].obj).as_dict().unwrap().get(b"AP").unwrap()); let first_ap = first_ap.as_dict().unwrap(); let other_ap = other_ap.as_dict().unwrap(); assert_ne!(first_ap.get(b"N"), other_ap.get(b"N"), "normal appearance was regenerated"); assert_eq!(other_ap.len(), 4); // The redrawn widget keeps unknown entries, but not down/rollover looks that // would show its old value or caption on press or hover. assert_eq!( first_ap.len(), 2, "{kind:?} fill={fill} indirect={indirect} full={full}: {:?}", first_ap.iter().map(|(k, _)| String::from_utf8_lossy(k).into_owned()).collect::>() ); for (key, bytes) in alternates { assert!(first_ap.get(key).is_none(), "stale /{}", String::from_utf8_lossy(key)); let object = doc.resolve(other_ap.get(key).unwrap()); let Object::Stream(stream) = &*object else { panic!("alternate appearance") }; assert_eq!(stream.decoded().unwrap(), bytes); } assert_eq!(first_ap.name(b"VendorState"), Some(&b"Retained"[..])); assert_eq!(other_ap.name(b"VendorState"), Some(&b"Retained"[..])); if fill { assert_eq!(first.value, ["Updated value"]); assert!(ap(&doc, &first.widgets[0]).contains("(Updated value) Tj")); assert!(other.value.is_empty()); } else { assert_eq!(first.tooltip.as_deref(), Some("Updated help")); assert_eq!(other.tooltip, None); } } } } } #[test] fn pushbutton_redraw_resolves_caption_and_layout_after_save_and_reopen() { fn saved(doc: &Document, full: bool) -> Document { let bytes = if full { pdfcraft_cos::write_full(doc, &SaveOptions::default()).unwrap() } else { assert!(!doc.revisions().is_empty(), "incremental save needs an existing revision"); assert!(!doc.encryption_changed() && !doc.full_save_required(), "this edit must not require a full rewrite"); let original = doc.bytes(); let bytes = write_incremental(doc, &SaveOptions::default()).unwrap(); assert!(bytes.len() > original.len(), "incremental save appends the changed appearance"); assert!(bytes.starts_with(original.as_slice()), "incremental save preserves the complete original prefix"); bytes }; hayro_syntax::Pdf::new(bytes.clone()).unwrap(); Document::open(Arc::new(bytes)).unwrap() } for indirect_caption in [false, true] { for indirect_layout in [false, true] { for icon_only in [false, true] { let mut doc = one_page(); let name = add_field(&mut doc, 0, [50.0, 600.0, 250.0, 650.0], &NewField::Button { caption: "Original caption".into() }, Some("Picture")) .unwrap(); let widget = field(&fields(&doc), &name).widgets[0].obj; let caption = Object::String(pdfcraft_cos::PdfString::literal(b"Original caption".to_vec())); let caption = if indirect_caption { Object::Ref(doc.add(caption)) } else { caption }; let layout = Object::Int(i64::from(icon_only)); let layout = if indirect_layout { Object::Ref(doc.add(layout)) } else { layout }; let mut icon_dict = Dict::new(); icon_dict.set(b"Type".to_vec(), Object::name("XObject")); icon_dict.set(b"Subtype".to_vec(), Object::name("Form")); icon_dict.set(b"BBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 4.into(), 2.into()])); let icon_bytes = b"0 0 4 2 re f\n"; let icon = doc.add(Object::Stream(pdfcraft_cos::Stream::from_raw(icon_dict, icon_bytes.to_vec()))); let mut mk = Dict::new(); mk.set(b"CA".to_vec(), caption); mk.set(b"TP".to_vec(), layout); mk.set(b"I".to_vec(), Object::Ref(icon)); mk.set(b"VendorNote".to_vec(), Object::name("Retained")); let mk = doc.add(Object::Dict(mk)); doc.update_dict(widget, |d| d.set(b"MK".to_vec(), Object::Ref(mk))).unwrap(); // Import the operands from a real saved file before an unrelated public edit. let original = saved(&doc, true); for full in [false, true] { let mut doc = original.clone(); let widget = field(&fields(&doc), &name).widgets[0].obj; let mk_before = doc.get(widget).as_dict().unwrap().get(b"MK").unwrap().clone(); let original_mk = doc.resolve(&mk_before); set_props(&mut doc, &name, &FieldProps { tooltip: Some("Picture help".into()), ..FieldProps::default() }).unwrap(); assert_eq!(doc.get(widget).as_dict().unwrap().get(b"MK"), Some(&mk_before)); assert_eq!(doc.resolve(&mk_before), original_mk, "redraw must not rewrite the source MK"); let doc = saved(&doc, full); let f = field(&fields(&doc), &name).clone(); assert_eq!(f.tooltip.as_deref(), Some("Picture help")); let w = &f.widgets[0]; let wd = doc.get(w.obj); let mk_object = doc.resolve(wd.as_dict().unwrap().get(b"MK").unwrap()); let mk = mk_object.as_dict().unwrap(); assert_eq!(mk.get(b"CA").unwrap().as_ref().is_some(), indirect_caption); assert_eq!(doc.resolve(mk.get(b"CA").unwrap()).as_string().unwrap().bytes, b"Original caption"); assert_eq!(mk.get(b"TP").unwrap().as_ref().is_some(), indirect_layout); assert_eq!(doc.resolve(mk.get(b"TP").unwrap()).as_int(), Some(i64::from(icon_only))); assert_eq!(mk.name(b"VendorNote"), Some(&b"Retained"[..])); let appearance = ap(&doc, w); assert_eq!(appearance.contains("(Original caption) Tj"), !icon_only, "{appearance}"); assert_eq!(appearance.matches("/Icon Do").count(), 1, "{appearance}"); assert!(appearance.contains("24.000000 0 0 24.000000 52.000 1.000 cm"), "{appearance}"); let n = wd.as_dict().unwrap().get(b"AP").unwrap().as_dict().unwrap().reference(b"N").unwrap(); let appearance_object = doc.get(n); let Object::Stream(stream) = &*appearance_object else { panic!("appearance") }; let resource_icon = stream.dict.get(b"Resources").unwrap().as_dict().unwrap().get(b"XObject").unwrap().as_dict().unwrap().get(b"Icon").unwrap(); assert_eq!(resource_icon, mk.get(b"I").unwrap()); let icon_object = doc.resolve(resource_icon); let Object::Stream(icon) = &*icon_object else { panic!("icon") }; assert_eq!(icon.decoded().unwrap(), icon_bytes); } } } } } /// A check box's down appearances survive a redraw for the states it still draws (same names), /// and a down appearance for a state it no longer has is dropped rather than left stale. #[test] fn check_box_redraw_keeps_down_states_it_still_draws() { let mut doc = one_page(); let name = add_field(&mut doc, 0, [50.0, 600.0, 70.0, 620.0], &NewField::CheckBox, Some("agree")).unwrap(); let widget = field(&fields(&doc), &name).widgets[0].obj; let on = field(&fields(&doc), &name).widgets[0].on_state.clone().unwrap_or_else(|| "Yes".into()); let mut ap = doc.get(widget).as_dict().unwrap().get(b"AP").unwrap().as_dict().unwrap().clone(); let mut down = Dict::new(); for state in [on.as_bytes(), b"Off".as_slice(), b"Gone".as_slice()] { let mut d = Dict::new(); d.set(b"Subtype".to_vec(), Object::name("Form")); d.set(b"BBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 20.into(), 20.into()])); let r = doc.add(Object::Stream(pdfcraft_cos::Stream::from_raw(d, b"0 0 20 20 re f\n".to_vec()))); down.set(state.to_vec(), Object::Ref(r)); } ap.set(b"D".to_vec(), Object::Dict(down)); doc.update_dict(widget, |d| d.set(b"AP".to_vec(), Object::Dict(ap))).unwrap(); set_props(&mut doc, &name, &FieldProps { tooltip: Some("Tick to agree".into()), ..FieldProps::default() }).unwrap(); let ap = doc.get(widget).as_dict().unwrap().get(b"AP").map(|a| doc.resolve(a)).unwrap(); let d = ap.as_dict().unwrap().get(b"D").map(|d| doc.resolve(d)).expect("down states kept"); let d = d.as_dict().unwrap(); assert!(d.contains(on.as_bytes()) && d.contains(b"Off")); assert!(!d.contains(b"Gone"), "a state the box no longer draws"); } #[test] fn recalc_chain_where_script_changes_checkbox() { struct TestScripts; impl Scripts for TestScripts { fn run(&mut self, _event: FieldEvent, script: &str, _target: &Field, _value: &str, fields: &[Field]) -> ScriptResult { if script == "set_box" { ScriptResult { rc: true, value: "calc1".into(), changes: vec![FieldChange { name: "agree".into(), value: Some(vec!["Yes".into()]), read_only: None, required: None, display: None, }], message: None, } } else if script == "read_box" { let box_val = fields.iter().find(|f| f.name == "agree").and_then(|f| f.value.first()).cloned().unwrap_or_default(); ScriptResult { rc: true, value: format!("box_was_{box_val}"), changes: Vec::new(), message: None } } else { ScriptResult { rc: true, value: String::new(), changes: Vec::new(), message: None } } } } let mut doc = one_page(); add_field(&mut doc, 0, [10.0, 10.0, 100.0, 30.0], &NewField::Text { multiline: false }, Some("trigger")).unwrap(); add_field(&mut doc, 0, [10.0, 40.0, 30.0, 60.0], &NewField::CheckBox, Some("agree")).unwrap(); add_field(&mut doc, 0, [10.0, 70.0, 100.0, 90.0], &NewField::Text { multiline: false }, Some("result")).unwrap(); set_field_script(&mut doc, "trigger", "calculate", Some("set_box")).unwrap(); set_field_script(&mut doc, "result", "calculate", Some("read_box")).unwrap(); let changed = recalculate_with(&mut doc, &mut TestScripts).unwrap(); assert!(changed >= 2); let all = fields(&doc); assert_eq!(field(&all, "agree").value, ["Yes"], "checkbox was set by script in recalc chain"); assert_eq!(field(&all, "result").value, ["box_was_Yes"], "second recalc saw the normalized checkbox value"); } #[test] fn partial_appearance_keeps_custom_default_appearance_and_font_resources() { let mut doc = fixture(); let obj = field(&fields(&doc), "name").obj; let af = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"AcroForm").unwrap(); let mut dr = doc.get(af).as_dict().unwrap().get(b"DR").map(|v| doc.resolve(v)).unwrap().as_dict().cloned().unwrap(); let mut fonts = dr.get(b"Font").map(|v| doc.resolve(v)).unwrap().as_dict().cloned().unwrap(); let resource = fonts.get(b"Helv").cloned().unwrap(); fonts.set(b"ProjectFont".to_vec(), resource.clone()); dr.set(b"Font".to_vec(), Object::Dict(fonts)); doc.update_dict(af, |d| d.set(b"DR".to_vec(), Object::Dict(dr))).unwrap(); let da = "/ProjectFont 13 Tf 0.15 g 2 Tc"; doc.update_dict(obj, |d| d.set(b"DA".to_vec(), pdfcraft_cos::PdfString::literal(da.as_bytes().to_vec()))).unwrap(); let patch = LookPatch { border: Some(Some([1.0, 0.0, 0.0])), width: Some(3.0), ..Default::default() }; set_props(&mut doc, "name", &FieldProps { appearance: Some(patch), ..Default::default() }).unwrap(); let f = field(&fields(&doc), "name").clone(); assert_eq!(f.da, da, "a border edit must not replace the custom font or colour space"); assert_eq!(f.value, ["Ada"]); assert_eq!(look(&doc, &f).fill, Some([1.0, 1.0, 0.9])); set_props(&mut doc, "name", &FieldProps { font_size: Some(18.0), ..Default::default() }).unwrap(); let f = field(&fields(&doc), "name").clone(); // The size is rewritten in place: other operators survive and the string doesn't grow. assert_eq!(f.da, "/ProjectFont 18 Tf 0.15 g 2 Tc", "other default-appearance operators survive"); assert_eq!(appearance::parse_da(&f.da).font, "ProjectFont"); assert_eq!(appearance::parse_da(&f.da).size, 18.0); set_props( &mut doc, "name", &FieldProps { appearance: Some(LookPatch { text: Some([0.0, 0.0, 1.0]), ..Default::default() }), ..Default::default() }, ) .unwrap(); let doc = reopen(&doc); let f = field(&fields(&doc), "name").clone(); assert_eq!(f.da, "/ProjectFont 18 Tf 0 0 1 rg 2 Tc", "the colour is replaced where it was"); assert_eq!(appearance::parse_da(&f.da).font, "ProjectFont"); assert_eq!(appearance::parse_da(&f.da).color, "0 0 1 rg"); let dr = doc.get(af).as_dict().unwrap().get(b"DR").map(|v| doc.resolve(v)).unwrap(); let fonts = dr.as_dict().unwrap().get(b"Font").map(|v| doc.resolve(v)).unwrap(); assert_eq!(fonts.as_dict().unwrap().get(b"ProjectFont"), Some(&resource)); } #[test] fn repeated_partial_appearance_edits_keep_the_default_appearance_one_operator_each() { let mut doc = fixture(); for size in [9.0, 11.0, 14.0, 250.0, 400.0] { let props = FieldProps { appearance: Some(LookPatch { text: Some([0.0, 0.5, 0.0]), ..Default::default() }), font_size: Some(size), ..Default::default() }; set_props(&mut doc, "name", &props).unwrap(); } let da = field(&fields(&doc), "name").da.clone(); assert_eq!(da.matches("Tf").count(), 1, "{da}"); assert_eq!(da.matches(" rg").count() + da.matches(" g").count(), 1, "{da}"); // Sizes up to the 300 points `parse_da` reads are kept, not cut to 100. assert_eq!(appearance::parse_da(&da).size, 300.0, "{da}"); } #[test] fn a_font_only_edit_leaves_the_widget_and_a_shared_indirect_border_untouched() { let mut doc = fixture(); let widgets = field(&fields(&doc), "size").widgets.clone(); let mut mk = pdfcraft_cos::Dict::new(); mk.set(b"BC".to_vec(), Object::Array(vec![0.into(), 0.into(), 1.into()])); let shared = doc.add(Object::Dict(mk)); for w in &widgets { doc.update_dict(w.obj, |d| d.set(b"MK".to_vec(), Object::Ref(shared))).unwrap(); } let border = |doc: &Document| -> Vec<_> { widgets.iter().map(|w| doc.get(w.obj).as_dict().map(|d| (d.get(b"MK").cloned(), d.get(b"BS").cloned()))).collect() }; let before = border(&doc); set_props(&mut doc, "size", &FieldProps { font_size: Some(12.0), ..Default::default() }).unwrap(); assert_eq!(border(&doc), before, "a size-only edit leaves each widget's /MK and /BS alone"); // A border edit updates the shared /MK where it lives instead of copying it into each widget. let patch = LookPatch { border: Some(Some([1.0, 0.0, 0.0])), ..Default::default() }; set_props(&mut doc, "size", &FieldProps { appearance: Some(patch), ..Default::default() }).unwrap(); for w in &widgets { assert_eq!(doc.get(w.obj).as_dict().unwrap().get(b"MK"), Some(&Object::Ref(shared)), "the widget still points at the shared /MK"); } let shared_mk = doc.get(shared).as_dict().cloned().unwrap(); assert_eq!(shared_mk.get(b"BC"), Some(&Object::Array(vec![Object::Real(1.0), Object::Real(0.0), Object::Real(0.0)]))); } #[test] fn partial_appearance_preserves_each_widgets_indirect_colours_and_border_extensions() { let mut doc = fixture(); let widgets = field(&fields(&doc), "size").widgets.clone(); let mut refs = Vec::new(); for (i, widget) in widgets.iter().enumerate() { let mut mk = pdfcraft_cos::Dict::new(); mk.set(b"BG".to_vec(), Object::Array(vec![Object::Real(i as f64), 0.into(), 1.into()])); mk.set(b"BC".to_vec(), Object::Array(vec![0.into(), Object::Real(i as f64), 0.into()])); mk.set(b"CA".to_vec(), pdfcraft_cos::PdfString::text("l")); mk.set(b"R".to_vec(), Object::Int(90)); mk.set(b"VendorKey".to_vec(), Object::Int(71)); let mk_ref = doc.add(Object::Dict(mk)); let mut bs = pdfcraft_cos::Dict::new(); bs.set(b"S".to_vec(), Object::name("D")); bs.set(b"W".to_vec(), Object::Real(1.0)); bs.set(b"D".to_vec(), Object::Array(vec![4.into(), 2.into()])); bs.set(b"VendorKey".to_vec(), Object::Int(83)); let bs_ref = doc.add(Object::Dict(bs)); let da = if i == 0 { "/Helv 8 Tf 0.1 g" } else { "/Cour 19 Tf 0.3 g" }; doc.update_dict(widget.obj, |d| { d.set(b"MK".to_vec(), Object::Ref(mk_ref)); d.set(b"BS".to_vec(), Object::Ref(bs_ref)); d.set(b"DA".to_vec(), pdfcraft_cos::PdfString::text(da)); }) .unwrap(); refs.push(mk_ref); } let patch = LookPatch { width: Some(5.0), ..Default::default() }; set_props(&mut doc, "size", &FieldProps { font_size: Some(16.0), appearance: Some(patch), ..Default::default() }).unwrap(); for (i, widget) in widgets.iter().enumerate() { let wd = doc.get(widget.obj); let wd = wd.as_dict().unwrap(); assert_eq!(wd.reference(b"MK"), Some(refs[i]), "untouched MK stays indirect"); let bs = wd.get(b"BS").map(|v| doc.resolve(v)).unwrap(); let bs = bs.as_dict().unwrap(); assert_eq!(bs.get(b"W").and_then(Object::as_f64), Some(5.0)); assert_eq!(bs.int(b"VendorKey"), Some(83)); assert_eq!(bs.get(b"D").unwrap().as_array().unwrap().len(), 2); let da = wd.get(b"DA").map(|v| doc.resolve(v)).unwrap().as_string().unwrap().to_text(); let parsed = appearance::parse_da(&da); assert_eq!(parsed.font, if i == 0 { "Helv" } else { "Cour" }); assert_eq!(parsed.color, if i == 0 { "0.1 g" } else { "0.3 g" }); assert_eq!(parsed.size, 16.0); } set_props(&mut doc, "size", &FieldProps { appearance: Some(LookPatch { fill: Some(None), ..Default::default() }), ..Default::default() }) .unwrap(); let doc = reopen(&doc); for widget in &widgets { let mk = doc.get(widget.obj).as_dict().unwrap().get(b"MK").map(|v| doc.resolve(v)).unwrap(); let mk = mk.as_dict().unwrap(); assert!(!mk.contains(b"BG")); assert!(mk.contains(b"BC") && mk.contains(b"CA")); assert_eq!(mk.int(b"R"), Some(90)); assert_eq!(mk.int(b"VendorKey"), Some(71)); } } /// A whole look sets the colours of an indirect /MK and keeps its mark, rotation and unknown /// entries (#1322), as it already did for a direct one. #[test] fn a_whole_look_keeps_the_rest_of_an_indirect_mk() { let mut doc = fixture(); let widget = field(&fields(&doc), "agree").widgets[0].obj; let mut mk = pdfcraft_cos::Dict::new(); mk.set(b"CA".to_vec(), pdfcraft_cos::PdfString::text("8")); mk.set(b"R".to_vec(), Object::Int(90)); mk.set(b"VendorKey".to_vec(), Object::Int(71)); let mk_ref = doc.add(Object::Dict(mk)); doc.update_dict(widget, |d| d.set(b"MK".to_vec(), Object::Ref(mk_ref))).unwrap(); let new = Look { border: Some([1.0, 0.0, 0.0]), fill: Some([1.0, 1.0, 0.8]), width: 2.0, style: BorderStyle::Solid, text: [0.0; 3], font: FieldFont::Helvetica, }; set_props(&mut doc, "agree", &FieldProps { look: Some(new), ..FieldProps::default() }).unwrap(); let doc = reopen(&doc); let f = field(&fields(&doc), "agree").clone(); assert_eq!((check_style(&doc, &f), f.widgets[0].rotation), (CheckStyle::Cross, 90)); let mk = doc.get(widget).as_dict().unwrap().get(b"MK").map(|v| doc.resolve(v)).unwrap(); let mk = mk.as_dict().unwrap(); assert_eq!(mk.int(b"VendorKey"), Some(71)); assert_eq!(look(&doc, &f).border, Some([1.0, 0.0, 0.0])); assert_eq!(look(&doc, &f).fill, Some([1.0, 1.0, 0.8])); } #[test] fn invalid_appearance_patches_are_refused_before_anything_is_written() { let mut doc = fixture(); let before = write_incremental(&doc, &SaveOptions::default()).unwrap(); for size in [f64::NAN, f64::INFINITY, -1.0] { assert!( set_props(&mut doc, "name", &FieldProps { font_size: Some(size), tooltip: Some("Not applied".into()), ..Default::default() }).is_err() ); assert_eq!(write_incremental(&doc, &SaveOptions::default()).unwrap(), before); } for patch in [ LookPatch { width: Some(f64::NAN), ..Default::default() }, LookPatch { width: Some(-1.0), ..Default::default() }, LookPatch { fill: Some(Some([f64::INFINITY, 0.0, 0.0])), ..Default::default() }, LookPatch { text: Some([1.1, 0.0, 0.0]), ..Default::default() }, ] { assert!( set_props(&mut doc, "name", &FieldProps { appearance: Some(patch), tooltip: Some("Not applied".into()), ..Default::default() }).is_err() ); assert_eq!(write_incremental(&doc, &SaveOptions::default()).unwrap(), before); } let props = FieldProps { appearance: Some(LookPatch { width: Some(2.0), ..Default::default() }), look: Some(look(&doc, &field(&fields(&doc), "name").clone())), ..Default::default() }; assert!(set_props(&mut doc, "name", &props).is_err()); assert_eq!(write_incremental(&doc, &SaveOptions::default()).unwrap(), before); } #[test] fn page_shapes_preserves_closed_path_rectangle_height() { let shapes = |content: &[u8]| { let mut doc = fixture(); let page = pdfcraft_model::pages(&doc)[0].obj; let stream = doc.add(Object::Stream(pdfcraft_cos::Stream::flate(Default::default(), content))); doc.update_dict(page, |d| d.set(b"Contents".to_vec(), Object::Ref(stream))).unwrap(); crate::detect::page_shapes(&doc, 0) }; let shorthand = shapes(b"50 100 120 40 re S"); let outlined = shapes(b"50 100 m 170 100 l 170 140 l 50 140 l h S"); assert_eq!(outlined, shorthand, "equivalent rectangle encodings must have equal detection geometry"); let fields = crate::detect::detect(&[], &outlined, &[], &[]); assert_eq!(fields.len(), 1); assert_eq!(fields[0].rect, [51.0, 101.0, 169.0, 139.0]); } fn detection_test_shapes(content: &[u8]) -> crate::detect::Shapes { let mut doc = fixture(); let page = pdfcraft_model::pages(&doc)[0].obj; let stream = doc.add(Object::Stream(pdfcraft_cos::Stream::flate(Default::default(), content))); doc.update_dict(page, |d| d.set(b"Contents".to_vec(), Object::Ref(stream))).unwrap(); crate::detect::page_shapes(&doc, 0) } #[test] fn page_shapes_accepts_rectangle_closure_and_paint_variants() { for end in ["h S", "s", "50 100 l S", "f", "F", "f*", "B", "B*", "b", "b*"] { let content = format!("50 100 m 170 100 l 170 140 l 50 140 l {end}"); let shapes = detection_test_shapes(content.as_bytes()); assert_eq!(shapes.boxes, [[50.0, 100.0, 170.0, 140.0]], "{end}"); assert!(shapes.rules.is_empty(), "rectangle edges must not become extra fields: {end}"); } } /// #2942: a cell drawn with a point in the middle of each vertical side (the French 2042 form's /// letter cells), or on every side, is the rectangle it looks like. A point that turns back /// along a side is not on the way between its neighbours, so such a path is still not a box. #[test] fn page_shapes_reads_rectangles_with_points_along_their_sides() { for content in [ "100 100 m 111.1 100 l 111.1 107.35 l 111.1 114.7 l 100 114.7 l 100 107.35 l h f", "100 100 m 105 100 l 111.1 100 l 111.1 107.35 l 111.1 114.7 l 105 114.7 l 100 114.7 l 100 107.35 l h S", "100 107.35 m 100 100 l 111.1 100 l 111.1 114.7 l 100 114.7 l h f", ] { let shapes = detection_test_shapes(content.as_bytes()); assert_eq!(shapes.boxes, [[100.0, 100.0, 111.1, 114.7]], "{content}"); } for content in [ // Out past the corner and back: a spike, not a side. "100 100 m 130 100 l 111.1 100 l 111.1 114.7 l 100 114.7 l h S", // A sixth point off the sides. "100 100 m 111.1 100 l 115 107 l 111.1 114.7 l 100 114.7 l 100 107.35 l h S", ] { assert!(detection_test_shapes(content.as_bytes()).boxes.is_empty(), "{content}"); } } #[test] fn page_shapes_rejects_open_diagonal_curved_and_complex_rectangles() { for content in [ "50 100 m 170 100 l 170 140 l 50 140 l S", "50 100 m 170 110 l 170 150 l 50 140 l h S", "50 100 m 170 100 l 170 140 l 100 150 80 150 50 140 c h S", "50 100 m 170 100 l 170 140 l h 50 140 l f", "50 100 m 170 100 l 50 100 l 50 140 l h S", ] { assert!(detection_test_shapes(content.as_bytes()).boxes.is_empty(), "{content}"); } } #[test] fn page_shapes_preserves_multiple_subpaths_and_discards_unpainted_paths() { let shapes = detection_test_shapes(b"50 100 120 40 re 200 100 m 320 100 l 320 140 l 200 140 l h 50 400 m 170 400 l S"); assert_eq!(shapes.boxes, [[50.0, 100.0, 170.0, 140.0], [200.0, 100.0, 320.0, 140.0]]); assert_eq!(shapes.rules, [[50.0, 400.0, 170.0, 400.0]]); assert_eq!(detection_test_shapes(b"50 100 m 170 100 l 170 140 l 50 140 l h W n"), crate::detect::Shapes::default()); let continued = detection_test_shapes(b"50 100 120 40 re 170 100 l 170 140 l 50 140 l h S"); assert_eq!(continued.boxes, [[50.0, 100.0, 170.0, 140.0]], "continuing re must not invent a second box"); } #[test] fn page_shapes_transforms_path_rectangles_and_preserves_rules() { let scaled = detection_test_shapes(b"q 2 0 0 2 0 0 cm 50 100 m 170 100 l 170 140 l 50 140 l h S Q 10 20 m 100 20 l S"); assert_eq!(scaled.boxes, [[100.0, 200.0, 340.0, 280.0]]); assert_eq!(scaled.rules, [[10.0, 20.0, 100.0, 20.0]]); let rotated = detection_test_shapes(b"0 1 -1 0 300 0 cm 50 100 m 170 100 l 170 140 l 50 140 l h S"); assert_eq!(rotated.boxes, [[160.0, 50.0, 200.0, 170.0]]); } #[test] fn page_shapes_closes_only_last_stroked_subpath_but_all_filled_subpaths() { let paths = "50 100 m 170 100 l 170 140 l 50 140 l 200 100 m 320 100 l 320 140 l 200 140 l"; let stroked = detection_test_shapes(format!("{paths} s").as_bytes()); assert_eq!(stroked.boxes, [[200.0, 100.0, 320.0, 140.0]]); assert_eq!(stroked.rules, [[50.0, 100.0, 170.0, 100.0], [50.0, 140.0, 170.0, 140.0]]); let filled = detection_test_shapes(format!("{paths} f").as_bytes()); assert_eq!(filled.boxes, [[50.0, 100.0, 170.0, 140.0], [200.0, 100.0, 320.0, 140.0]]); assert!(filled.rules.is_empty()); } #[test] fn zero_max_len_is_unlimited_and_overrides_inherited_limit() { let mut doc = fixture(); let city = fields(&doc).into_iter().find(|f| f.name == "address.city").unwrap(); let parent = doc.get(city.obj).as_dict().unwrap().get(b"Parent").unwrap().as_ref().unwrap(); doc.update_dict(parent, |d| d.set(b"MaxLen".to_vec(), Object::Int(3))).unwrap(); assert_eq!(fields(&doc).into_iter().find(|f| f.name == "address.city").unwrap().max_len, Some(3)); doc.update_dict(city.obj, |d| d.set(b"MaxLen".to_vec(), Object::Int(0))).unwrap(); assert_eq!(fields(&doc).into_iter().find(|f| f.name == "address.city").unwrap().max_len, None); set_value(&mut doc, "address.city", &FieldValue::Text("Absalom".into())).unwrap(); let again = reopen(&doc); assert_eq!(field(&fields(&again), "address.city").value, ["Absalom"]); assert_eq!(again.get(city.obj).as_dict().unwrap().int(b"MaxLen"), Some(0), "preserve the original entry"); assert!(set_value(&mut doc, "zip", &FieldValue::Text("123456".into())).is_err(), "positive limits still apply"); } #[test] fn auto_text_size_fits_single_and_multiline_text() { use crate::appearance::auto_text_size; use pdfcraft_fonts::{helvetica_width, wrap_with}; let long = "Weekly progress and diligence: CoBank refi, IL and Redwood Falls"; // Single line: the height's size for short text, shrunk to the width for long text. assert_eq!(auto_text_size("Hi", 94.0, 8.0, false, helvetica_width), 8.0 / 1.15); let s = auto_text_size(long, 194.0, 8.0, false, helvetica_width); assert!(s > 4.0 && s < 8.0 / 1.15 && helvetica_width(long, s) <= 194.0 + 1e-9, "{s}"); // Never past 12 pt or under 4 pt. assert_eq!(auto_text_size("Hi", 500.0, 500.0, false, helvetica_width), 12.0); assert_eq!(auto_text_size(&long.repeat(20), 20.0, 8.0, false, helvetica_width), 4.0); // Multiline: the largest size whose wrapped lines fit the height. let m = auto_text_size(long, 114.0, 44.0, true, helvetica_width); assert!(wrap_with(long, m, 114.0, helvetica_width).len() as f64 * m * 1.15 <= 44.0, "{m}"); assert!(wrap_with(long, m + 0.5, 114.0, helvetica_width).len() as f64 * (m + 0.5) * 1.15 > 44.0, "{m} is the largest that fits"); // Hostile boxes and measurements don't panic or return nonsense. for (w, h) in [(f64::NAN, 10.0), (10.0, f64::INFINITY), (-5.0, -5.0), (0.0, 0.0)] { for multiline in [false, true] { let s = auto_text_size(long, w, h, multiline, helvetica_width); assert!((4.0..=12.0).contains(&s), "{w}×{h}: {s}"); } } assert_eq!(auto_text_size(long, 50.0, 10.0, false, |_, _| f64::NAN), 10.0 / 1.15); } #[test] fn document_scripts_read_each_name_tree_node_once() { // The root lists itself and the leaf twice each. let doc = document(&[ "<< /Type /Catalog /Pages 2 0 R /Names << /JavaScript 3 0 R >> >>".into(), "<< /Type /Pages /Kids [] /Count 0 >>".into(), "<< /Kids [3 0 R 4 0 R 3 0 R 4 0 R] >>".into(), "<< /Names [(init) 5 0 R] >>".into(), "<< /S /JavaScript /JS (var ready = true;) >>".into(), ]); assert_eq!(document_scripts_named(&doc), [("init".to_string(), "var ready = true;".to_string())]); } /// #3470: a form whose writer left the appearances to the viewer. Field A has an empty one, /// B none, C its own (`(old C)`), D is a combo without one, E a check box with states, F a list /// box without one, and G only a down appearance (`/D`, no `/N`). fn left_to_the_viewer(need_appearances: bool) -> Document { let flag = if need_appearances { "/NeedAppearances true " } else { "" }; document(&[ "<< /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 [6 0 R 7 0 R 8 0 R 9 0 R 10 0 R 13 0 R 14 0 R] >>".into(), format!("<< /Fields [6 0 R 7 0 R 8 0 R 9 0 R 10 0 R 13 0 R 14 0 R] {flag}/DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >>"), "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), "<< /Type /Annot /Subtype /Widget /FT /Tx /T (a) /V (Value A) /DA (/Helv 12 Tf 0 g) /Rect [50 700 350 720] /P 3 0 R /AP << /N 11 0 R >> >>".into(), "<< /Type /Annot /Subtype /Widget /FT /Tx /T (b) /V (Value B) /DA (/Helv 12 Tf 0 g) /Rect [50 650 350 670] /P 3 0 R >>".into(), "<< /Type /Annot /Subtype /Widget /FT /Tx /T (c) /V (Value C) /DA (/Helv 12 Tf 0 g) /Rect [50 600 350 620] /P 3 0 R /AP << /N 12 0 R >> >>".into(), "<< /Type /Annot /Subtype /Widget /FT /Ch /Ff 131072 /T (d) /Opt [(Red) (Blue)] /V (Blue) /DA (/Helv 12 Tf 0 g) /Rect [50 550 350 570] /P 3 0 R >>".into(), "<< /Type /Annot /Subtype /Widget /FT /Btn /T (e) /V /Yes /AS /Yes /Rect [400 700 415 715] /P 3 0 R /AP << /N << /Yes 11 0 R /Off 11 0 R >> >> >>".into(), "<< /Length 4 >>\nstream\nq\nQ\nendstream".into(), "<< /Length 18 >>\nstream\nBT (old C) Tj ET\nendstream".into(), "<< /Type /Annot /Subtype /Widget /FT /Ch /T (f) /Opt [(Green) (Grey)] /V (Green) /DA (/Helv 12 Tf 0 g) /Rect [50 450 350 520] /P 3 0 R >>".into(), "<< /Type /Annot /Subtype /Widget /FT /Tx /T (g) /V (Value G) /DA (/Helv 12 Tf 0 g) /Rect [50 400 350 420] /P 3 0 R /AP << /D 11 0 R >> >>".into(), ]) } /// #3470: with `/NeedAppearances`, every text and choice widget of the display copy is drawn /// from its value, its own appearance or not; buttons keep their states, and the document /// itself is untouched. #[test] fn need_appearances_draws_every_text_and_choice_field_in_the_display_copy() { let doc = left_to_the_viewer(true); let copy = with_field_appearances(&doc).expect("fields to draw"); let all = fields(©); for (name, value) in [("a", "(Value A) Tj"), ("b", "(Value B) Tj"), ("c", "(Value C) Tj"), ("d", "(Blue) Tj"), ("f", "(Green) Tj"), ("g", "(Value G) Tj")] { let shown = ap(©, &field(&all, name).widgets[0]); assert!(shown.contains(value), "{name}: {shown}"); } let e = |d: &Document| d.get(field(&fields(d), "e").widgets[0].obj).as_dict().and_then(|w| w.get(b"AP").cloned()); assert_eq!(e(©), e(&doc), "the check box keeps its states"); let original = fields(&doc); assert_eq!(ap(&doc, &field(&original, "a").widgets[0]).trim_end(), "q\nQ", "the document keeps its own appearances"); assert!(doc.get(field(&original, "b").widgets[0].obj).as_dict().is_some_and(|w| !w.contains(b"AP"))); } /// #3470: without `/NeedAppearances`, only widgets with no normal appearance are drawn; an /// appearance the file has, even an empty one, is the file's. A form with all of them needs no /// copy. #[test] fn without_need_appearances_only_missing_appearances_are_drawn() { let doc = left_to_the_viewer(false); let copy = with_field_appearances(&doc).expect("b, d, f and g have none"); let all = fields(©); assert_eq!(ap(©, &field(&all, "a").widgets[0]).trim_end(), "q\nQ"); assert!(ap(©, &field(&all, "b").widgets[0]).contains("(Value B) Tj")); assert_eq!(ap(©, &field(&all, "c").widgets[0]).trim_end(), "BT (old C) Tj ET"); assert!(ap(©, &field(&all, "d").widgets[0]).contains("(Blue) Tj")); assert!(ap(©, &field(&all, "f").widgets[0]).contains("(Green) Tj")); assert!(ap(©, &field(&all, "g").widgets[0]).contains("(Value G) Tj"), "a down appearance alone is no normal one"); assert!(with_field_appearances(©).is_none(), "every widget has one now"); } /// #3470: refreshing the display copy redraws only the widgets whose inputs changed. Typing in /// one field of a form that leaves every appearance to the viewer otherwise redrew all of them. #[test] fn the_field_appearance_cache_redraws_only_what_changed() { let mut doc = left_to_the_viewer(true); let mut cache = FieldAppearanceCache::default(); with_field_appearances_cached(&doc, &mut cache).expect("fields to draw"); let drawn = cache.builds(); assert_eq!(drawn, 6, "a, b, c, d, f and g"); with_field_appearances_cached(&doc, &mut cache).expect("fields to draw"); assert_eq!((cache.builds(), cache.hits()), (drawn, drawn), "nothing changed, nothing redrawn"); set_value(&mut doc, "b", &FieldValue::Text("Typed".into())).unwrap(); let copy = with_field_appearances_cached(&doc, &mut cache).expect("fields to draw"); assert_eq!(cache.builds(), drawn + 1, "only b is redrawn"); assert!(ap(©, &field(&fields(©), "b").widgets[0]).contains("(Typed) Tj"), "and shows its new value"); assert!(ap(©, &field(&fields(©), "a").widgets[0]).contains("(Value A) Tj"), "a reuses its drawing"); } /// #3470: each kind of input a field's drawing depends on invalidates its cached appearance when /// it changes, wherever it lives: the parent field's value, the form's `/DA`, an indirect `/MK` /// and a `/DR` font. Field H is a parent with one kid widget; I is a plain field. #[test] fn the_field_appearance_cache_sees_every_input() { let mut doc = document(&[ "<< /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 [6 0 R 7 0 R] >>".into(), "<< /Fields [5 0 R 7 0 R] /NeedAppearances true /DA (/Helv 12 Tf 0 g) /DR << /Font << /Helv 8 0 R >> >> >>".into(), "<< /FT /Tx /T (h) /V (Kid value) /Kids [6 0 R] >>".into(), "<< /Type /Annot /Subtype /Widget /Parent 5 0 R /Rect [50 700 350 720] /P 3 0 R /MK 9 0 R >>".into(), "<< /Type /Annot /Subtype /Widget /FT /Tx /T (i) /V (Plain) /Rect [50 650 350 670] /P 3 0 R >>".into(), "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), "<< /BG [1 1 1] >>".into(), ]); let r = |n: u32| pdfcraft_cos::ObjRef::new(n, 0); let mut cache = FieldAppearanceCache::default(); let mut redraw = |doc: &Document| { let before = cache.builds(); let copy = with_field_appearances_cached(doc, &mut cache).expect("fields to draw"); (cache.builds() - before, copy) }; assert_eq!(redraw(&doc).0, 2); assert_eq!(redraw(&doc).0, 0, "unchanged"); doc.update_dict(r(5), |d| d.set(b"V".to_vec(), Object::String(PdfString::text("New value")))).unwrap(); let (n, copy) = redraw(&doc); assert_eq!(n, 1, "the parent's value: only its kid"); assert!(ap(©, &field(&fields(©), "h").widgets[0]).contains("(New value) Tj")); doc.update_dict(r(9), |d| d.set(b"BG".to_vec(), Object::Array(vec![Object::Int(1), Object::Int(0), Object::Int(0)]))).unwrap(); let (n, copy) = redraw(&doc); assert_eq!(n, 1, "the kid's indirect /MK"); assert!(ap(©, &field(&fields(©), "h").widgets[0]).contains("1 0 0 rg"), "the new background"); doc.update_dict(r(4), |d| d.set(b"DA".to_vec(), Object::String(PdfString::text("/Helv 9 Tf 0 g")))).unwrap(); let (n, copy) = redraw(&doc); assert_eq!(n, 2, "the form's /DA: both fields"); assert!(ap(©, &field(&fields(©), "i").widgets[0]).contains("/Helv 9 Tf")); doc.update_dict(r(8), |d| d.set(b"BaseFont".to_vec(), Object::Name(b"Courier".to_vec()))).unwrap(); assert_eq!(redraw(&doc).0, 2, "a /DR font: both fields"); } /// #3470: an indirect `/DR` and an indirect `/Font` inside it are inputs too, with a font written /// directly into the `/Font` dictionary, and so is the form's `/DA` when it is an indirect string. #[test] fn the_field_appearance_cache_sees_indirect_resources() { let mut doc = document(&[ "<< /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] >>".into(), "<< /Fields [5 0 R] /NeedAppearances true /DA 8 0 R /DR 6 0 R >>".into(), "<< /Type /Annot /Subtype /Widget /FT /Tx /T (i) /V (Plain) /Rect [50 650 350 670] /P 3 0 R >>".into(), "<< /Font 7 0 R >>".into(), "<< /Helv << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> >>".into(), "(/Helv 12 Tf 0 g)".into(), ]); let r = |n: u32| pdfcraft_cos::ObjRef::new(n, 0); let mut cache = FieldAppearanceCache::default(); let mut redraw = |doc: &Document| { let before = cache.builds(); with_field_appearances_cached(doc, &mut cache).expect("a field to draw"); cache.builds() - before }; assert_eq!(redraw(&doc), 1); assert_eq!(redraw(&doc), 0); let courier = || { let mut f = Dict::new(); f.set(b"Type".to_vec(), Object::Name(b"Font".to_vec())); f.set(b"Subtype".to_vec(), Object::Name(b"Type1".to_vec())); f.set(b"BaseFont".to_vec(), Object::Name(b"Courier".to_vec())); Object::Dict(f) }; doc.update_dict(r(7), |d| d.set(b"Helv".to_vec(), courier())).unwrap(); assert_eq!(redraw(&doc), 1, "the indirect /Font dictionary"); doc.update_dict(r(6), |d| { let mut fonts = Dict::new(); fonts.set(b"Helv".to_vec(), courier()); d.set(b"Font".to_vec(), Object::Dict(fonts)); }) .unwrap(); assert_eq!(redraw(&doc), 1, "the indirect /DR"); doc.set(r(8), Object::String(PdfString::text("/Helv 9 Tf 0 g"))); assert_eq!(redraw(&doc), 1, "the form's indirect /DA"); } #[test] fn page_walk_skips_repeated_and_cyclic_kids_without_queueing_them() { let mut doc = fixture(); let root = ObjRef { num: 2, generation: 0 }; let page = ObjRef { num: 3, generation: 0 }; // A cycle back to the root, then the page 128 times over. let mut kids = vec![Object::Ref(root)]; kids.extend(std::iter::repeat_n(Object::Ref(page), 128)); doc.update_dict(root, |d| d.set(b"Kids".to_vec(), Object::Array(kids))).unwrap(); let mut walk = PageWalk::new(&doc, root); assert_eq!(walk.next(), Some(page)); assert!(walk.pending.len() <= 2, "repeated kids retained {} pending entries", walk.pending.len()); assert_eq!(walk.next(), None); assert_eq!(page_refs(&doc), [page]); } /// A Calculate script naming thousands of fields is merged with the field's other reads in one /// pass, not by searching the names so far for each one. #[test] fn a_script_reading_many_fields_merges_its_reads_in_one_pass() { use crate::calc_order::NAMES_COMPARED; let mut doc = one_page(); add_field(&mut doc, 0, [50.0, 700.0, 250.0, 720.0], &NewField::Text { multiline: false }, Some("total")).unwrap(); let n = 2_000; let script: String = (0..n).map(|i| format!("getField(\"f{i}\").value + ")).collect::() + "0"; set_field_script(&mut doc, "total", "calculate", Some(&format!("event.value = {script};"))).unwrap(); NAMES_COMPARED.with(|c| c.set(0)); let order = calculation_order(&doc, &fields(&doc)); assert_eq!(order.fields[0].reads.len(), n); let compared = NAMES_COMPARED.with(std::cell::Cell::get); assert!(compared <= 4 * n, "{compared} names compared for {n} reads"); } /// 300 fields and 300 document scripts that all name one 1 MiB JavaScript stream. fn repeated_script_references() -> Document { use pdfcraft_cos::Stream; let dict = |entries: Vec<(&[u8], Object)>| -> Object { Object::Dict(entries.into_iter().map(|(k, v)| (k.to_vec(), v)).collect()) }; let mut doc = Document::new_empty(); let js = doc.add(Stream::flate(pdfcraft_cos::Dict::new(), &vec![b'x'; 1 << 20])); let action = doc.add(dict(vec![(b"S", Object::Name(b"JavaScript".to_vec())), (b"JS", Object::Ref(js))])); let fields: Vec = (0..300) .map(|i| { Object::Ref(doc.add(dict(vec![ (b"FT", Object::Name(b"Tx".to_vec())), (b"T", Object::String(pdfcraft_cos::PdfString::literal(format!("f{i}")))), (b"Rect", Object::Array([0, 0, 10, 10].map(Object::from).to_vec())), (b"AA", dict(vec![(b"C", Object::Ref(action))])), ]))) }) .collect(); let names: Vec = (0..300).flat_map(|i| [Object::String(pdfcraft_cos::PdfString::literal(format!("s{i:03}"))), Object::Ref(action)]).collect(); let catalog = doc.add(dict(vec![ (b"Type", Object::Name(b"Catalog".to_vec())), (b"AcroForm", dict(vec![(b"Fields", Object::Array(fields))])), (b"Names", dict(vec![(b"JavaScript", dict(vec![(b"Names", Object::Array(names))]))])), ])); doc.trailer_mut().set(b"Root".to_vec(), Object::Ref(catalog)); doc } #[test] fn repeated_script_references_share_one_decode_budget() { let doc = repeated_script_references(); let limit = MAX_SCRIPT_BYTES; let named: usize = document_scripts_named(&doc).iter().map(|(_, js)| js.len()).sum(); assert!(named > 0 && named <= limit, "{named} bytes of document scripts"); let all = fields(&doc); assert_eq!(all.len(), 300); let calculate: usize = all.iter().filter_map(|f| f.actions.scripts.calculate.as_ref()).map(String::len).sum(); assert!(calculate > 0 && calculate <= limit, "{calculate} bytes of field scripts"); } /// A widget listed many times, by one field's `/Kids` or by a `/Kids` array several fields share, /// is read once: the first field to list it owns it. #[test] fn a_widget_listed_repeatedly_is_read_once() { let kids = "7 0 R ".repeat(500); let doc = document(&[ "<< /Type /Catalog /Pages 4 0 R /AcroForm << /Fields [2 0 R 3 0 R] >> >>".into(), "<< /FT /Tx /T (a) /Kids 6 0 R >>".into(), "<< /FT /Tx /T (b) /Kids 6 0 R >>".into(), "<< /Type /Pages /Kids [5 0 R] /Count 1 >>".into(), "<< /Type /Page /Parent 4 0 R /MediaBox [0 0 100 100] /Annots [7 0 R] >>".into(), format!("[{kids}]"), "<< /Type /Annot /Subtype /Widget /Parent 2 0 R /Rect [0 0 10 10] >>".into(), ]); let all = fields(&doc); let widgets: Vec = all.iter().map(|f| f.widgets.len()).collect(); assert_eq!(widgets, [1, 0], "{:?}", all.iter().map(|f| &f.name).collect::>()); } /// A field of a kind that isn't read doesn't claim the widget it shares with a field that is. #[test] fn a_skipped_field_leaves_its_widget_to_the_next() { let doc = document(&[ "<< /Type /Catalog /Pages 4 0 R /AcroForm << /Fields [2 0 R 3 0 R] >> >>".into(), "<< /FT /Zz /T (odd) /Kids [6 0 R] >>".into(), "<< /FT /Tx /T (b) /Kids [6 0 R] >>".into(), "<< /Type /Pages /Kids [5 0 R] /Count 1 >>".into(), "<< /Type /Page /Parent 4 0 R /MediaBox [0 0 100 100] /Annots [6 0 R] >>".into(), "<< /Type /Annot /Subtype /Widget /Parent 3 0 R /Rect [0 0 10 10] >>".into(), ]); let all = fields(&doc); let widgets: Vec<(&str, usize)> = all.iter().map(|f| (f.name.as_str(), f.widgets.len())).collect(); assert_eq!(widgets, [("b", 1)]); } /// #2696: a dotted name is a full name. `MASTER.first` is a field `first` under a parent field /// `MASTER` (a partial name can't contain a period, ISO 32000-2 §12.7.4.2); a second `MASTER.` /// name joins that parent; renaming to a dotted name moves the field, keeping what it inherited; /// and a name that would make one field both hold a value and hold fields is refused. #[test] fn dotted_field_names_are_made_with_parent_fields() { let mut doc = fixture(); let top = |doc: &Document| { let root = doc.root().unwrap(); let af = doc.get(root).as_dict().and_then(|d| d.reference(b"AcroForm")).unwrap(); doc.get(af).as_dict().and_then(|d| d.get(b"Fields").and_then(|o| o.as_array().map(|a| a.len()))).unwrap() }; let own_name = |doc: &Document, f: &Field| doc.get(f.obj).as_dict().and_then(|d| d.get(b"T").and_then(text_of)); let parent_of = |doc: &Document, f: &Field| doc.get(f.obj).as_dict().and_then(|d| d.reference(b"Parent")).unwrap(); let text = NewField::Text { multiline: false }; let before = top(&doc); assert_eq!(add_field(&mut doc, 0, [50.0, 300.0, 200.0, 320.0], &text, Some("MASTER.account_firstname")).unwrap(), "MASTER.account_firstname"); add_field(&mut doc, 0, [50.0, 260.0, 200.0, 280.0], &text, Some("MASTER.street1")).unwrap(); assert_eq!(top(&doc), before + 1, "one parent field at the top"); let saved = reopen(&doc); let all = fields(&saved); let (a, b) = (field(&all, "MASTER.account_firstname"), field(&all, "MASTER.street1")); assert_eq!(own_name(&saved, b).as_deref(), Some("street1"), "the field's own name has no period"); let master = parent_of(&saved, a); assert_eq!(parent_of(&saved, b), master, "both under one MASTER"); assert_eq!(saved.get(master).as_dict().and_then(|d| d.get(b"T").and_then(text_of)).as_deref(), Some("MASTER")); // Renaming to a dotted name moves the field: zip joins the existing address parent. assert_eq!(set_props(&mut doc, "zip", &FieldProps { name: Some("address.zip".into()), ..FieldProps::default() }).unwrap(), "address.zip"); assert_eq!(top(&doc), before, "zip left the top"); let all = fields(&doc); let (zip, city) = (field(&all, "address.zip"), field(&all, "address.city")); assert_eq!(parent_of(&doc, zip), parent_of(&doc, city)); assert_eq!(zip.max_len, Some(5), "its own settings move with it"); // What a moved field inherited stays; what a new field would inherit from a parent it // joins doesn't apply to it. let address = parent_of(&doc, city); doc.update_dict(address, |d| { d.set(b"Ff".to_vec(), Object::Int(flags::READ_ONLY as i64)); d.set(b"V".to_vec(), Object::String(PdfString::literal(b"Springfield".to_vec()))); }) .unwrap(); let city = field(&fields(&doc), "address.city").clone(); assert!(city.read_only() && city.value == ["Springfield"]); set_props(&mut doc, "address.city", &FieldProps { name: Some("billing.city".into()), ..FieldProps::default() }).unwrap(); let moved = field(&fields(&doc), "billing.city").clone(); assert!(moved.read_only() && moved.value == ["Springfield"], "the moved field keeps what it inherited"); // A parent's value, reset value or length limit would pass to a new field under it (review // on #2957), and a field can't turn them off: such a parent takes no new field. let street = |doc: &mut Document| add_field(doc, 0, [300.0, 300.0, 450.0, 320.0], &text, Some("address.street")); for (key, value) in [ (&b"V"[..], Object::String(PdfString::literal(b"x".to_vec()))), (b"DV", Object::String(PdfString::literal(b"x".to_vec()))), (b"MaxLen", Object::Int(3)), ] { doc.update_dict(address, |d| { for k in [&b"V"[..], b"DV", b"MaxLen"] { d.remove(k); } d.set(key.to_vec(), value); }) .unwrap(); let mut copy = doc.clone(); assert!(street(&mut copy).is_err(), "address holds /{}", String::from_utf8_lossy(key)); } doc.update_dict(address, |d| { d.remove(b"MaxLen"); }) .unwrap(); street(&mut doc).unwrap(); let new = field(&fields(&doc), "address.street").clone(); assert!(!new.read_only(), "a new field doesn't take its parent's flags"); assert!(new.value.iter().all(|v| v.is_empty()) && new.max_len.is_none(), "nor a value or length limit: {:?} {:?}", new.value, new.max_len); // A parent left empty by a move isn't a terminal field, but its name is still taken. add_field(&mut doc, 0, [300.0, 340.0, 450.0, 360.0], &text, Some("box.only")).unwrap(); set_props(&mut doc, "box.only", &FieldProps { name: Some("elsewhere.only".into()), ..FieldProps::default() }).unwrap(); assert!(fields(&doc).iter().all(|f| !f.name.starts_with("box")), "box holds no field now"); let mut copy = doc.clone(); assert!(add_field(&mut copy, 0, [300.0, 380.0, 450.0, 400.0], &text, Some("box")).is_err(), "a new field named box"); let mut copy = doc.clone(); assert!(set_props(&mut copy, "name", &FieldProps { name: Some("box".into()), ..FieldProps::default() }).is_err(), "a rename to box"); // Under it, the name is free: the empty parent takes a field again. add_field(&mut doc, 0, [300.0, 380.0, 450.0, 400.0], &text, Some("box.again")).unwrap(); // Only a whole path that exists can be taken: under box.sub, made new, "again" is free. add_field(&mut doc, 0, [300.0, 420.0, 450.0, 440.0], &text, Some("box.sub.again")).unwrap(); // A field holding its own value may join a parent holding one; and the parents a new path // makes are new, even where an existing field has the name of a later part. doc.update_dict(address, |d| d.set(b"V".to_vec(), Object::String(PdfString::literal(b"x".to_vec())))).unwrap(); set_props(&mut doc, "billing.city", &FieldProps { name: Some("address.city".into()), ..FieldProps::default() }).unwrap(); assert_eq!(field(&fields(&doc), "address.city").value, ["Springfield"]); add_field(&mut doc, 0, [300.0, 460.0, 450.0, 480.0], &text, Some("nope.address.x")).unwrap(); // Renaming the last part: to its own name is no change; to an empty parent's name beside it // is taken. assert_eq!(set_props(&mut doc, "address.city", &FieldProps { name: Some("city".into()), ..FieldProps::default() }).unwrap(), "address.city"); doc.update_dict(address, |d| { d.remove(b"V"); }) .unwrap(); add_field(&mut doc, 0, [300.0, 500.0, 450.0, 520.0], &text, Some("address.grp.x")).unwrap(); set_props(&mut doc, "address.grp.x", &FieldProps { name: Some("elsewhere.x".into()), ..FieldProps::default() }).unwrap(); assert!( set_props(&mut doc.clone(), "address.city", &FieldProps { name: Some("grp".into()), ..FieldProps::default() }).is_err(), "address.grp is taken" ); // A move the destination refuses leaves the field where it was (review on #2957): it was // taken out of the tree before the destination was checked, and stayed out. doc.update_dict(address, |d| d.set(b"MaxLen".to_vec(), Object::Int(3))).unwrap(); let at_top = top(&doc); assert!(set_props(&mut doc, "name", &FieldProps { name: Some("address.name".into()), ..FieldProps::default() }).is_err()); assert_eq!(top(&doc), at_top, "name is still in the form's /Fields"); // Nor is what it inherits copied onto it first (review on #2957): src.f inherits /V from src. add_field(&mut doc, 0, [300.0, 540.0, 450.0, 560.0], &text, Some("src.f")).unwrap(); let f = field(&fields(&doc), "src.f").obj; let src = doc.get(f).as_dict().and_then(|d| d.reference(b"Parent")).unwrap(); doc.update_dict(src, |d| d.set(b"V".to_vec(), Object::String(PdfString::literal(b"inh".to_vec())))).unwrap(); assert!(set_props(&mut doc, "src.f", &FieldProps { name: Some("address.f".into()), ..FieldProps::default() }).is_err()); assert!(!doc.get(f).as_dict().unwrap().contains(b"V"), "src.f still only inherits its value"); // What it inherits counts as its own where it goes: with /V on address and no /MaxLen, // src.f moves, keeping its value. doc.update_dict(address, |d| { d.remove(b"MaxLen"); d.set(b"V".to_vec(), Object::String(PdfString::literal(b"x".to_vec()))); }) .unwrap(); set_props(&mut doc, "src.f", &FieldProps { name: Some("address.f".into()), ..FieldProps::default() }).unwrap(); assert_eq!(field(&fields(&doc), "address.f").value, ["inh"]); doc.update_dict(address, |d| { d.remove(b"V"); }) .unwrap(); doc.update_dict(address, |d| { d.remove(b"MaxLen"); }) .unwrap(); for bad in ["MASTER", "MASTER.street1.x", "MASTER..x", ".x", "x.", "MASTER. "] { assert!(add_field(&mut doc, 0, [300.0, 200.0, 450.0, 220.0], &text, Some(bad)).is_err(), "{bad:?}"); assert!(set_props(&mut doc, "name", &FieldProps { name: Some(bad.into()), ..FieldProps::default() }).is_err(), "{bad:?}"); } let deep = vec!["p"; 33].join("."); assert!(add_field(&mut doc, 0, [300.0, 200.0, 450.0, 220.0], &text, Some(&deep)).is_err(), "too deep"); assert!(set_props(&mut doc, "name", &FieldProps { name: Some("MASTER.street1".into()), ..FieldProps::default() }).is_err(), "taken"); } /// Review on #2957: a parent left empty by a move keeps its name from the next default name /// (`Text1` after `Text1.x` moved away); and a full name that keeps the parents is a plain /// rename, so the field stays in its place among its parent's /Kids and keeps inheriting. #[test] fn empty_parents_and_same_parent_renames() { let mut doc = fixture(); let text = NewField::Text { multiline: false }; let auto = |doc: &mut Document, y: f64| add_field(doc, 0, [50.0, y, 200.0, y + 20.0], &text, None); let mut copy = doc.clone(); let first = auto(&mut copy, 100.0).unwrap(); add_field(&mut doc, 0, [50.0, 140.0, 200.0, 160.0], &text, Some(&format!("{first}.x"))).unwrap(); set_props(&mut doc, &format!("{first}.x"), &FieldProps { name: Some("other.x".into()), ..FieldProps::default() }).unwrap(); assert!(fields(&doc).iter().all(|f| !f.name.starts_with(&first)), "{first} is an empty parent now"); let next = auto(&mut doc, 100.0).expect("a default name the empty parent doesn't hold"); assert_ne!(next, first); let all = fields(&doc); let city = field(&all, "address.city").obj; let address = doc.get(city).as_dict().and_then(|d| d.reference(b"Parent")).unwrap(); doc.update_dict(address, |d| d.set(b"V".to_vec(), Object::String(PdfString::literal(b"Springfield".to_vec())))).unwrap(); let kids = |doc: &Document| doc.get(address).as_dict().and_then(|d| d.get(b"Kids").and_then(|o| o.as_array().cloned())).unwrap(); let before = kids(&doc); assert_eq!( set_props(&mut doc, "address.city", &FieldProps { name: Some("address.town".into()), ..FieldProps::default() }).unwrap(), "address.town" ); assert_eq!(kids(&doc), before, "same place among the kids"); assert!(!doc.get(city).as_dict().unwrap().contains(b"V"), "still inherits its value rather than owning a copy"); assert_eq!(field(&fields(&doc), "address.town").value, ["Springfield"]); } /// Review on #2957: spaces in a typed name. The ends of the whole name are dropped, as Acrobat /// does when a field is renamed; a space beside a period stays in the part it belongs to (`a. b` /// is `b` with a leading space under `a`), as Acrobat keeps it and as ISO 32000-2 §12.7.4.2 /// allows: only a period is barred from a partial name. #[test] fn a_typed_name_loses_its_ends_but_keeps_spaces_beside_periods() { let mut doc = fixture(); let own_name = |doc: &Document, full: &str| { let all = fields(doc); doc.get(field(&all, full).obj).as_dict().and_then(|d| d.get(b"T").and_then(text_of)) }; let text = NewField::Text { multiline: false }; assert_eq!(add_field(&mut doc, 0, [50.0, 300.0, 200.0, 320.0], &text, Some(" plain ")).unwrap(), "plain"); assert_eq!(add_field(&mut doc, 0, [50.0, 260.0, 200.0, 280.0], &text, Some("a. b")).unwrap(), "a. b"); assert_eq!(add_field(&mut doc, 0, [50.0, 220.0, 200.0, 240.0], &text, Some("c .d")).unwrap(), "c .d"); assert_eq!(set_props(&mut doc, "zip", &FieldProps { name: Some(" e. f ".into()), ..FieldProps::default() }).unwrap(), "e. f"); let saved = reopen(&doc); assert_eq!(own_name(&saved, "plain").as_deref(), Some("plain")); assert_eq!(own_name(&saved, "a. b").as_deref(), Some(" b")); assert_eq!(own_name(&saved, "c .d").as_deref(), Some("d")); assert_eq!(own_name(&saved, "e. f").as_deref(), Some(" f")); let parent = |full: &str| { let all = fields(&saved); let p = saved.get(field(&all, full).obj).as_dict().and_then(|d| d.reference(b"Parent")).unwrap(); saved.get(p).as_dict().and_then(|d| d.get(b"T").and_then(text_of)) }; assert_eq!(parent("c .d").as_deref(), Some("c "), "the space before the period is the parent's"); assert_eq!(parent("e. f").as_deref(), Some("e")); // A part that is only spaces is still empty. assert!(add_field(&mut doc, 0, [300.0, 300.0, 450.0, 320.0], &text, Some("g. .h")).is_err()); } /// The form's `/DR /Font` entries, resolved through an indirect `/DR` or `/Font`. fn dr_fonts(doc: &Document) -> Dict { let af = acroform(doc).unwrap(); let dr = doc.resolve(af.get(b"DR").unwrap()); doc.resolve(dr.as_dict().unwrap().get(b"Font").unwrap()).as_dict().cloned().unwrap() } /// #3441: Acrobat shows nothing in a text field it redraws after typing when the field's `/DR` /// font is a dictionary written inline. A form PdfCraft makes holds each font as its own object. #[test] fn a_new_form_holds_its_fonts_as_objects() { 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(), ]); add_field(&mut doc, 0, [40.0, 600.0, 300.0, 630.0], &NewField::Text { multiline: false }, Some("name")).unwrap(); let doc = reopen(&doc); let fonts = dr_fonts(&doc); for (name, base) in [("Helv", "Helvetica"), ("TiRo", "Times-Roman"), ("Cour", "Courier")] { let r = fonts.reference(name.as_bytes()).unwrap_or_else(|| panic!("{name} is an object: {fonts:?}")); assert_eq!(doc.get(r).as_dict().and_then(|f| f.name(b"BaseFont")), Some(base.as_bytes())); } } /// #3441: a form saved earlier with inline `/DR` fonts gets them as objects once a field is /// added, saying the same; an indirect `/Font` dictionary stays the object it was. #[test] fn inline_form_fonts_become_objects_and_an_indirect_font_dictionary_stays_one() { for indirect in [false, true] { let font = "/Helv << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> /Mine << /Type /Font /Subtype /Type1 /BaseFont /Symbol >>"; let (fonts_entry, extra) = if indirect { ("6 0 R".to_string(), vec![format!("<< {font} >>")]) } else { (format!("<< {font} >>"), Vec::new()) }; let mut objs: Vec = vec![ "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), "<< /Type /Page /Parent 2 0 R >>".into(), format!("<< /Fields [] /DA (/Helv 0 Tf 0 g) /DR << /Font {fonts_entry} >> >>"), "null".into(), ]; objs.extend(extra); let mut doc = document(&objs); add_field(&mut doc, 0, [40.0, 600.0, 300.0, 630.0], &NewField::Text { multiline: false }, Some("name")).unwrap(); let doc = reopen(&doc); let fonts = dr_fonts(&doc); for (name, base) in [("Helv", "Helvetica"), ("Mine", "Symbol"), ("TiRo", "Times-Roman")] { let r = fonts.reference(name.as_bytes()).unwrap_or_else(|| panic!("{name} is an object (indirect {indirect}): {fonts:?}")); assert_eq!(doc.get(r).as_dict().and_then(|f| f.name(b"BaseFont")), Some(base.as_bytes()), "{name} says the same"); } let dr = doc.resolve(acroform(&doc).unwrap().get(b"DR").unwrap()); assert_eq!(dr.as_dict().unwrap().reference(b"Font").is_some(), indirect, "the /Font dictionary keeps its form"); } } /// #3441: an indirect `/DR` stays the object it is, and a form whose three standard fonts are /// all already there (inline) still gets them as objects, though none is added. #[test] fn an_indirect_resource_dictionary_and_a_complete_inline_set_are_handled() { let inline = "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>"; let all = format!( "/Helv {inline} /TiRo << /Type /Font /Subtype /Type1 /BaseFont /Times-Roman >> /Cour << /Type /Font /Subtype /Type1 /BaseFont /Courier >>" ); for indirect_dr in [false, true] { let dr = if indirect_dr { "5 0 R".to_string() } else { format!("<< /Font << {all} >> >>") }; let doc = document(&[ "<< /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 >>".into(), format!("<< /Fields [] /DA (/Helv 0 Tf 0 g) /DR {dr} >>"), format!("<< /Font << {all} >> >>"), ]); let mut doc = doc; add_field(&mut doc, 0, [40.0, 600.0, 300.0, 630.0], &NewField::Text { multiline: false }, Some("name")).unwrap(); let doc = reopen(&doc); let fonts = dr_fonts(&doc); for name in ["Helv", "TiRo", "Cour"] { assert!(fonts.reference(name.as_bytes()).is_some(), "{name} is an object (indirect /DR {indirect_dr}): {fonts:?}"); } assert_eq!(acroform(&doc).unwrap().reference(b"DR").is_some(), indirect_dr, "/DR keeps its form"); } } /// #3441: fonts the form already has are its own, not replaced by the standard ones: an inline /// `/Helv` that is Arial becomes an object that is still Arial, a `/TiRo` object stays that /// object, and a form needing no change leaves its `/DR` object unwritten. The fonts PdfCraft /// adds use WinAnsi, as Acrobat's do. #[test] fn existing_form_fonts_are_kept_and_an_unchanged_resource_dictionary_is_not_rewritten() { let mut doc = document(&[ "<< /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 >>".into(), "<< /Fields [] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv << /Type /Font /Subtype /Type1 /BaseFont /Arial >> /TiRo 5 0 R >> >> >>".into(), "<< /Type /Font /Subtype /Type1 /BaseFont /Times-Bold >>".into(), ]); add_field(&mut doc, 0, [40.0, 600.0, 300.0, 630.0], &NewField::Text { multiline: false }, Some("name")).unwrap(); let doc = reopen(&doc); let fonts = dr_fonts(&doc); let font = |name: &str| doc.get(fonts.reference(name.as_bytes()).unwrap()).as_dict().cloned().unwrap(); assert_eq!(font("Helv").name(b"BaseFont"), Some(&b"Arial"[..]), "the form's own Helv"); assert_eq!(fonts.reference(b"TiRo"), Some(pdfcraft_cos::ObjRef::new(5, 0)), "the same object"); assert_eq!(font("Cour").name(b"Encoding"), Some(&b"WinAnsiEncoding"[..])); // Every font an object already, /DR an object: adding a field leaves /DR as it was. let mut doc = document(&[ "<< /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 >>".into(), "<< /Fields [] /DA (/Helv 0 Tf 0 g) /DR 5 0 R >>".into(), "<< /Font << /Helv 6 0 R /TiRo 6 0 R /Cour 6 0 R >> >>".into(), "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), ]); add_field(&mut doc, 0, [40.0, 600.0, 300.0, 630.0], &NewField::Text { multiline: false }, Some("name")).unwrap(); assert!(!doc.modified_objects().contains(&5), "/DR unchanged: {:?}", doc.modified_objects()); } /// #3441, exhaustively: every way a form's `/DR` may hold its fonts. `/DR` missing, inline or an /// object; its `/Font` missing, inline, an object or not a dictionary; `/Helv` missing, inline, /// an object, a reference to nothing, `null` or a number; and a font of the form's own, inline or /// an object. After a field is added, whatever the shape: /// - no font is a dictionary written inline; /// - a font that was an object is the same object, and one that was inline says the same as an /// object; /// - a `/DR` or `/Font` that was an object (and a dictionary) still is one; /// - an entry that is not a dictionary (`null`, a number, a reference to nothing) is left alone; /// - Helv, TiRo and Cour are there. #[test] fn every_shape_of_form_fonts_ends_as_objects() { let font = |base: &str| format!("<< /Type /Font /Subtype /Type1 /BaseFont /{base} >>"); let mut cases = 0; for dr_shape in ["missing", "inline", "object"] { for font_shape in ["missing", "inline", "object", "number"] { for helv in ["missing", "inline", "object", "dangling", "null", "number"] { for mine in ["missing", "inline", "object"] { if dr_shape == "missing" && (font_shape != "missing" || helv != "missing" || mine != "missing") { continue; } if matches!(font_shape, "missing" | "number") && (helv != "missing" || mine != "missing") { continue; } cases += 1; let what = format!("/DR {dr_shape}, /Font {font_shape}, /Helv {helv}, /Mine {mine}"); let mut entries = String::new(); match helv { "inline" => entries += &format!("/Helv {} ", font("Arial")), "object" => entries += "/Helv 7 0 R ", "dangling" => entries += "/Helv 99 0 R ", "null" => entries += "/Helv null ", "number" => entries += "/Helv 3 ", _ => {} } match mine { "inline" => entries += &format!("/Mine {} ", font("Symbol")), "object" => entries += "/Mine 8 0 R ", _ => {} } let fonts_value = match font_shape { "inline" => format!("<< {entries}>>"), "object" => "6 0 R".into(), "number" => "7".into(), _ => String::new(), }; let dr_dict = if fonts_value.is_empty() { "<< >>".to_string() } else { format!("<< /Font {fonts_value} >>") }; let dr_value = match dr_shape { "inline" => dr_dict.clone(), "object" => "5 0 R".into(), _ => String::new(), }; let af = if dr_value.is_empty() { "<< /Fields [] >>".to_string() } else { format!("<< /Fields [] /DR {dr_value} >>") }; let mut doc = document(&[ "<< /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 >>".into(), af, if dr_shape == "object" { dr_dict } else { "null".into() }, if font_shape == "object" { format!("<< {entries}>>") } else { "null".into() }, font("Helvetica-Oblique"), font("ZapfDingbats"), ]); add_field(&mut doc, 0, [40.0, 600.0, 300.0, 630.0], &NewField::Text { multiline: false }, Some("name")) .unwrap_or_else(|e| panic!("{what}: {e}")); let doc = reopen(&doc); let af = acroform(&doc).unwrap(); let dr = doc.resolve(af.get(b"DR").unwrap_or_else(|| panic!("{what}: no /DR"))); let fonts = dr_fonts(&doc); for (name, value) in fonts.iter() { assert!(value.as_dict().is_none(), "{what}: {} is inline", String::from_utf8_lossy(name)); } for name in ["Helv", "TiRo", "Cour"] { assert!(fonts.contains(name.as_bytes()), "{what}: no {name}"); } let base = |name: &[u8]| fonts.reference(name).and_then(|r| doc.get(r).as_dict().and_then(|f| f.name(b"BaseFont").map(<[u8]>::to_vec))); match helv { "inline" => assert_eq!(base(b"Helv").as_deref(), Some(&b"Arial"[..]), "{what}"), "object" => assert_eq!(fonts.reference(b"Helv"), Some(pdfcraft_cos::ObjRef::new(7, 0)), "{what}"), "dangling" => assert_eq!(fonts.reference(b"Helv"), Some(pdfcraft_cos::ObjRef::new(99, 0)), "{what}"), "null" => assert_eq!(fonts.get(b"Helv"), Some(&Object::Null), "{what}"), "number" => assert_eq!(fonts.get(b"Helv"), Some(&Object::Int(3)), "{what}"), _ => assert_eq!(base(b"Helv").as_deref(), Some(&b"Helvetica"[..]), "{what}"), } match mine { "inline" => assert_eq!(base(b"Mine").as_deref(), Some(&b"Symbol"[..]), "{what}"), "object" => assert_eq!(fonts.reference(b"Mine"), Some(pdfcraft_cos::ObjRef::new(8, 0)), "{what}"), _ => assert!(!fonts.contains(b"Mine"), "{what}"), } if dr_shape == "object" { assert_eq!(af.reference(b"DR"), Some(pdfcraft_cos::ObjRef::new(5, 0)), "{what}: /DR stays its object"); } if font_shape == "object" { assert_eq!(dr.as_dict().unwrap().reference(b"Font"), Some(pdfcraft_cos::ObjRef::new(6, 0)), "{what}: /Font stays its object"); } } } } } assert_eq!(cases, 1 + 2 * (2 + 2 * 6 * 3), "every shape was tried"); } /// #3441 (review follow-up): filling a field converts the form's inline `/DR` fonts, so a form /// an earlier PdfCraft made works in Acrobat once it has been filled here. Nothing is added. A /// form a signature covers keeps `/DR` as the signer saw it, and a form whose fonts are objects /// already leaves its `/DR` object unwritten. #[test] fn filling_a_field_makes_inline_form_fonts_objects_unless_the_form_is_signed() { let inline = "/Helv << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>"; let form = |signed: bool, dr: &str| { let sig = if signed { "<< /Type /Annot /Subtype /Widget /FT /Sig /T (sig) /V 7 0 R /Rect [0 0 0 0] /P 3 0 R >>" } else { "null" }; let fields = if signed { "[5 0 R 6 0 R]" } else { "[5 0 R]" }; document(&[ "<< /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] >>".into(), format!("<< /Fields {fields} /DA (/Helv 0 Tf 0 g) /DR {dr} >>"), "<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /DA (/Helv 12 Tf 0 g) /Rect [50 700 300 720] /P 3 0 R >>".into(), sig.into(), "<< /Type /Sig /Filter /Adobe.PPKLite /Contents <00> >>".into(), "<< /Font << /Helv 9 0 R >> >>".into(), "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), ]) }; let mut doc = form(false, &format!("<< /Font << {inline} >> >>")); set_value(&mut doc, "name", &FieldValue::Text("Ada".into())).unwrap(); let doc = reopen(&doc); let fonts = dr_fonts(&doc); assert!(fonts.reference(b"Helv").is_some(), "Helv became an object: {fonts:?}"); assert_eq!(fonts.len(), 1, "nothing added: {fonts:?}"); assert_eq!(field(&fields(&doc), "name").value, ["Ada"]); let mut doc = form(true, &format!("<< /Font << {inline} >> >>")); set_value(&mut doc, "name", &FieldValue::Text("Ada".into())).unwrap(); let doc = reopen(&doc); assert!(dr_fonts(&doc).get(b"Helv").is_some_and(|h| h.as_dict().is_some()), "a signed form keeps /DR as it was"); // An empty signature field signs nothing: the form is converted as an unsigned one. let mut doc = form(true, &format!("<< /Font << {inline} >> >>")); doc.update_dict(pdfcraft_cos::ObjRef::new(6, 0), |d| { d.remove(b"V"); }) .unwrap(); set_value(&mut doc, "name", &FieldValue::Text("Ada".into())).unwrap(); assert!(dr_fonts(&reopen(&doc)).reference(b"Helv").is_some(), "an unsigned signature field doesn't hold /DR back"); let mut doc = form(false, "8 0 R"); set_value(&mut doc, "name", &FieldValue::Text("Ada".into())).unwrap(); assert!(!doc.modified_objects().contains(&8), "/DR unchanged: {:?}", doc.modified_objects()); } /// #3441 (review follow-up): a signature value held by a parent field signs its kid too (`/V` is /// inheritable, ISO 32000-2 Table 226), so the form still counts as signed and keeps its `/DR`. #[test] fn a_signature_value_on_a_parent_field_keeps_the_form_fonts_as_signed() { let mut doc = document(&[ "<< /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 8 0 R] >>".into(), "<< /Fields [5 0 R 6 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> >> >> >>".into(), "<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /DA (/Helv 12 Tf 0 g) /Rect [50 700 300 720] /P 3 0 R >>".into(), "<< /FT /Sig /T (sig) /V 7 0 R /Kids [8 0 R] >>".into(), "<< /Type /Sig /Filter /Adobe.PPKLite /Contents <00> >>".into(), "<< /Type /Annot /Subtype /Widget /T (s1) /Parent 6 0 R /Rect [0 0 0 0] /P 3 0 R >>".into(), ]); let kid = pdfcraft_cos::ObjRef::new(8, 0); assert!(fields(&doc).iter().any(|f| f.obj == kid && f.kind == FieldKind::Signature), "the kid is the signature field"); set_value(&mut doc, "name", &FieldValue::Text("Ada".into())).unwrap(); let doc = reopen(&doc); assert!(dr_fonts(&doc).get(b"Helv").is_some_and(|h| h.as_dict().is_some()), "an inherited signature value keeps /DR as it was"); assert_eq!(field(&fields(&doc), "name").value, ["Ada"]); } /// #3441 (review follow-up): a catalog that holds `/AcroForm` as a direct dictionary gets its /// inline `/DR` fonts converted too when a field is filled. #[test] fn filling_a_field_converts_inline_fonts_of_a_direct_form_dictionary() { let mut doc = document(&[ "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> >> >> >> >>".into(), "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), "<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>".into(), "<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /DA (/Helv 12 Tf 0 g) /Rect [50 700 300 720] /P 3 0 R >>".into(), ]); set_value(&mut doc, "name", &FieldValue::Text("Ada".into())).unwrap(); let doc = reopen(&doc); let fonts = dr_fonts(&doc); assert!(fonts.reference(b"Helv").is_some(), "Helv became an object: {fonts:?}"); assert_eq!(fonts.len(), 1, "nothing added: {fonts:?}"); assert_eq!(field(&fields(&doc), "name").value, ["Ada"]); } /// #3926 / #3943 review: a password value can be inherited from an ancestor field or sit on a /// separate widget. Clearing removes both; an ancestor's value shared with a non-password field /// stays for that field, and the password field gets an empty value of its own instead. #[test] fn inherited_and_widget_password_values_are_cleared() { let objs: Vec = 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 9 0 R 11 0 R] >>".into(), // 3 "<< /Fields [5 0 R 8 0 R 10 0 R] /DA (/Helv 0 Tf 0 g) >>".into(), // 4 "<< /T (acct) /FT /Tx /Ff 8192 /V (secret1) /Kids [6 0 R] >>".into(), // 5 flag and value inherited "<< /Type /Annot /Subtype /Widget /T (pin) /Parent 5 0 R /Rect [50 700 150 720] /P 3 0 R >>".into(), // 6 "<< /Type /Annot /Subtype /Widget /Parent 8 0 R /V (secret3) /Rect [50 650 150 670] /P 3 0 R >>".into(), // 7 value on the widget "<< /FT /Tx /T (code) /Ff 8192 /Kids [7 0 R] >>".into(), // 8 "<< /Type /Annot /Subtype /Widget /FT /Tx /T (pw) /Ff 8192 /Parent 10 0 R /Rect [50 600 150 620] /P 3 0 R >>".into(), // 9 "<< /T (grp) /V (shared2) /Kids [9 0 R 11 0 R] >>".into(), // 10 shared with a plain field "<< /Type /Annot /Subtype /Widget /FT /Tx /T (plain) /Parent 10 0 R /Rect [50 550 150 570] /P 3 0 R >>".into(), // 11 ]; let mut doc = document(&objs); assert!(has_password_values(&doc)); let value = |doc: &Document, n: &str| fields(doc).into_iter().find(|f| f.name == n).map(|f| f.value).unwrap_or_default(); assert_eq!(value(&doc, "acct.pin"), ["secret1"], "inherited before"); assert_eq!(clear_password_values(&mut doc).unwrap(), 3); assert!(!has_password_values(&doc)); for n in ["acct.pin", "code", "grp.pw"] { assert!(value(&doc, n).iter().all(|v| v.is_empty()), "{n} cleared"); } assert_eq!(value(&doc, "grp.plain"), ["shared2"], "the shared value stays for the plain field"); let has_v = |doc: &Document, n: u32| doc.get(pdfcraft_cos::ObjRef { num: n, generation: 0 }).as_dict().is_some_and(|d| d.get(b"V").is_some()); assert!(!has_v(&doc, 5), "an ancestor only password fields inherit from loses its value"); assert!(!has_v(&doc, 7), "a widget's value is removed"); assert!(has_v(&doc, 10), "a shared ancestor keeps its value"); } /// #3943 review: a cleared password field saves as Acrobat saves it (black-box test). One with a /// default value gets it back, and its appearance shows as many masking characters as the /// default has, not as the typed password had; one without is left empty and draws none. The /// default value and the scripts stay, and a field already at its default needs no clearing. #[test] fn a_cleared_password_field_returns_to_its_default_value() { let objs: Vec = 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 [6 0 R 7 0 R 8 0 R] /DA (/Helv 12 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >>".into(), // 4 "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), // 5 "<< /Type /Annot /Subtype /Widget /FT /Tx /T (pw) /Ff 8192 /DV (DEF-1) /V (NEWPASS-77777) /AA << /K << /S /JavaScript /JS (1;) >> >> /Rect [50 700 250 720] /P 3 0 R >>".into(), // 6 "<< /Type /Annot /Subtype /Widget /FT /Tx /T (pw3) /Ff 8192 /V (PW3-TYPED-9) /Rect [50 650 250 670] /P 3 0 R >>".into(), // 7 "<< /Type /Annot /Subtype /Widget /FT /Tx /T (blank) /Ff 8192 /V [()] /Rect [50 600 250 620] /P 3 0 R >>".into(), // 8 holds nothing (an empty string in an array) ]; let mut doc = document(&objs); assert_eq!(clear_password_values(&mut doc).unwrap(), 2); let all = fields(&doc); let (pw, pw3) = (field(&all, "pw"), field(&all, "pw3")); assert_eq!(pw.value, ["DEF-1"], "back to its default"); assert_eq!(pw.default, ["DEF-1"], "the default stays"); assert!(pw.actions.scripts.keystroke.is_some(), "the scripts stay"); assert!(pw3.value.iter().all(|v| v.is_empty()), "no default: empty"); let shown = ap(&doc, &pw.widgets[0]); assert!(shown.contains("(*****)") && !shown.contains("NEWPASS") && !shown.contains("******"), "{shown}"); let shown3 = ap(&doc, &pw3.widgets[0]); assert!(!shown3.contains('*') && !shown3.contains("PW3"), "{shown3}"); assert!(!has_password_values(&doc), "a field at its default holds no typed password"); assert_eq!(clear_password_values(&mut doc).unwrap(), 0); } /// #3943 review: the quick check that skips forms without a Password field still sees one whose /// widget is only on the page, not listed in `/Fields`; one that inherits the flag from a parent /// that is not listed either; and one deeper in the field tree whose widget is on no page. Each /// is alone in its own document, so each path of the check is needed. #[test] fn a_password_field_missing_from_the_field_list_is_still_cleared() { let form = |fields: &str, annots: &str, rest: &[&str]| { let mut objs: Vec = vec![ "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), format!("<< /Type /Page /Parent 2 0 R /Annots [{annots}] >>"), format!("<< /Fields [{fields}] /DA (/Helv 0 Tf 0 g) >>"), ]; objs.extend(rest.iter().map(|o| o.to_string())); document(&objs) }; let page_only = form("", "5 0 R", &["<< /Type /Annot /Subtype /Widget /FT /Tx /T (pin) /Ff 8192 /V (secret5) /Rect [50 700 150 720] /P 3 0 R >>"]); let inherited = form( "", "5 0 R", &[ "<< /Type /Annot /Subtype /Widget /T (kid) /Parent 6 0 R /Rect [50 650 150 670] /P 3 0 R >>", "<< /FT /Tx /T (acct) /Ff 8192 /V (secret6) /Kids [5 0 R] >>", ], ); let off_page = form( "5 0 R", "", &[ "<< /T (grp) /Kids [6 0 R] >>", "<< /Type /Annot /Subtype /Widget /FT /Tx /T (hidden) /Ff 8192 /V (secret7) /Parent 5 0 R /Rect [0 0 0 0] >>", ], ); for (what, mut doc) in [("page only", page_only), ("inherited", inherited), ("on no page", off_page)] { assert!(has_password_values(&doc), "{what}"); assert_eq!(clear_password_values(&mut doc).unwrap(), 1, "{what}"); assert!(fields(&doc).iter().all(|f| f.value.iter().all(|v| v.is_empty())), "{what}: {:?}", fields(&doc)); } } /// #4018: a value that doesn't fit its box at the field's font size is reported (it is drawn cut /// at the box). An auto-sized field shrinks to fit, and a short or empty value fits. #[test] fn a_value_wider_or_taller_than_its_box_is_reported() { let long = "Doe, Jane (represented by John Doe)"; let field = |name: &str, rect: &str, extra: &str, value: &str| { format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T ({name}) /V ({value}) /Rect [{rect}] /P 3 0 R {extra} >>") }; let doc = document(&[ "<< /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 [6 0 R 7 0 R 8 0 R 9 0 R 10 0 R] >>".into(), "<< /Fields [6 0 R 7 0 R 8 0 R 9 0 R 10 0 R] /DA (/Helv 12 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >>".into(), "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), field("single", "50 700 190 718", "", long), field("multi", "50 650 190 668", "/Ff 4096", long), field("auto", "50 600 190 618", "/DA (/Helv 0 Tf 0 g)", long), field("wide", "50 550 350 568", "", long), field("short", "50 500 190 518", "/Ff 4096", "Ada"), ]); let overflow = |name: &str| { let f = fields(&doc).into_iter().find(|f| f.name == name).unwrap(); appearance::value_overflow(&doc, &f, &f.widgets[0]) }; match overflow("single") { Some(appearance::Overflow::Wide { width, room, size }) => { assert_eq!(size, 12.0); assert!((width - 201.4).abs() < 1.0 && room < 140.0, "{width} in {room}"); } other => panic!("single: {other:?}"), } assert_eq!(overflow("multi"), Some(appearance::Overflow::Tall { lines: 2, room: 1, size: 12.0 })); assert_eq!(overflow("auto"), None, "auto size shrinks to fit"); assert_eq!(overflow("wide"), None); assert_eq!(overflow("short"), None); } /// #4018: a single line taller than its box is cut at the top and bottom, and a character wider /// than a multiline box is a line of its own that is still cut at the sides; both are reported. /// An empty value draws nothing, so nothing is cut however small the box. #[test] fn a_line_taller_than_its_box_or_a_character_wider_than_it_is_reported() { let field = |name: &str, rect: &str, extra: &str, value: &str| { format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T ({name}) /V ({value}) /Rect [{rect}] /P 3 0 R {extra} >>") }; let doc = document(&[ "<< /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 [6 0 R 7 0 R 8 0 R 9 0 R 10 0 R 11 0 R] >>".into(), "<< /Fields [6 0 R 7 0 R 8 0 R 9 0 R 10 0 R 11 0 R] /DA (/Helv 12 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >>".into(), "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), field("low", "50 700 190 708", "", "Ada"), field("comb", "50 650 190 658", "/Ff 16777216 /MaxLen 5", "Ada"), field("narrow", "50 500 56 560", "/Ff 4096", "WW"), field("roomy", "50 400 190 418", "", "Ada"), field("empty", "50 350 190 358", "", ""), field("empty-multi", "50 300 190 304", "/Ff 4096", ""), ]); let overflow = |name: &str| { let f = fields(&doc).into_iter().find(|f| f.name == name).unwrap(); appearance::value_overflow(&doc, &f, &f.widgets[0]) }; for name in ["low", "comb"] { match overflow(name) { Some(o @ appearance::Overflow::High { height, room, size }) => { assert_eq!(size, 12.0); assert!((height - 11.1).abs() < 0.01 && room <= 8.0, "{name}: {height} in {room}"); assert!(o.to_string().starts_with("the value does not fit: 11.1 pt high at 12 pt"), "{o}"); } other => panic!("{name}: {other:?}"), } } match overflow("narrow") { Some(appearance::Overflow::Wide { width, room, size }) => { assert_eq!(size, 12.0); assert!(width > room && (width - 11.32).abs() < 0.1, "{width} in {room}"); } other => panic!("narrow: {other:?}"), } assert_eq!(overflow("roomy"), None); assert_eq!(overflow("empty"), None); assert_eq!(overflow("empty-multi"), None); } /// Check boxes, radio buttons and push buttons whose file gives them no appearance, a comb field, /// a combo box and a list box, for the display copy. fn left_buttons() -> Document { let widget = |n: &str, rest: &str| format!("<< /Type /Annot /Subtype /Widget /T ({n}) /F 4 /P 3 0 R {rest} >>"); document(&[ "<< /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 [6 0 R 7 0 R 8 0 R 9 0 R 10 0 R 11 0 R 12 0 R 13 0 R 14 0 R 15 0 R 16 0 R 17 0 R 18 0 R] >>".into(), "<< /Fields [6 0 R 7 0 R 8 0 R 9 0 R 10 0 R 11 0 R 12 0 R 13 0 R 14 0 R 15 0 R 16 0 R 17 0 R 18 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >>".into(), "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), widget("i", "/FT /Btn /V /Yes /AS /Yes /DA (/ZaDb 0 Tf 0 g) /MK << /CA (4) /BC [0 0 0] >> /Rect [50 700 74 724]"), // 6 widget("j", "/FT /Btn /V /Yes /AS /Yes /DA (/ZaDb 0 Tf 0 0 1 rg) /MK << /CA (l) /BC [0 0 0] >> /Rect [100 700 124 724]"), widget("k", "/FT /Btn /V /Yes /AS /Yes /DA (/ZaDb 0 Tf 1 0 0 rg) /MK << /CA (8) /BC [0 0 0] >> /Rect [150 700 174 724]"), widget("l", "/FT /Btn /V /Yes /AS /Yes /DA (/ZaDb 9 Tf 0 g) /MK << /CA (H) /BC [0 0 0] /BG [1 1 0] >> /Rect [200 700 224 724]"), widget("off", "/FT /Btn /V /Off /AS /Off /DA (/ZaDb 0 Tf 0 g) /MK << /CA (4) /BC [0 0 0] >> /Rect [250 700 274 724]"), // 10 widget("radio", "/FT /Btn /Ff 32768 /V /A /AS /A /DA (/ZaDb 0 Tf 0 g) /MK << /BC [0 0 0] >> /Rect [300 700 324 724]"), widget( "o", "/FT /Btn /Ff 65536 /DA (/Helv 12 Tf 0 g) /MK << /CA (Press me) /BG [0.8 0.8 0.8] /BC [0 0 0] >> /BS << /W 2 /S /B >> /Rect [50 600 170 624]", ), widget("own", "/FT /Btn /V /Yes /AS /Yes /MK << /CA (4) /BC [0 0 0] >> /Rect [350 700 374 724] /AP << /N << /Yes 19 0 R /Off 19 0 R >> >>"), widget("half", "/FT /Btn /V /Yes /AS /Yes /DA (/ZaDb 0 Tf 0 g) /MK << /CA (8) >> /Rect [400 700 424 724] /AP << /N << /Off 19 0 R >> >>"), widget("bad", "/FT /Btn /V /Yes /AS /Yes /DA (/ZaDb nan Tf 1 x rg) /MK << /CA 42 /BC [(no) /Name] /BG 7 >> /BS << /W -3 >> /Rect [450 700 474 724]"), // 15 widget("d", "/FT /Tx /Ff 16777216 /MaxLen 6 /V (ABC123) /DA (/Helv 12 Tf 0 g) /MK << /BC [0 0 1] >> /Rect [50 500 170 524]"), widget("m", "/FT /Ch /Ff 131072 /Opt [(Apple) (Banana)] /V (Banana) /Q 2 /DA (/Helv 12 Tf 0 g) /MK << /BC [0 0 0] >> /Rect [50 400 170 424]"), widget("n", "/FT /Ch /Opt [(One) (Two)] /V (Two) /DA (/Helv 11 Tf 0 g) /MK << /BC [0 0 0] >> /Rect [200 400 320 460]"), "<< /Length 4 >>\nstream\nq\nQ\nendstream".into(), // 19 ]) } /// The appearance stream of widget `w` in `state` (a check box's `/AP /N /`). fn state_ap(doc: &Document, w: &Widget, state: &str) -> String { let wd = doc.get(w.obj).as_dict().cloned().unwrap(); let ap = doc.resolve(wd.get(b"AP").unwrap()).as_dict().cloned().unwrap(); let n = doc.resolve(ap.get(b"N").unwrap()).as_dict().cloned().unwrap(); let r = n.reference(state.as_bytes()).expect("the state's appearance"); let Object::Stream(s) = &*doc.get(r) else { panic!("a stream") }; String::from_utf8_lossy(&s.decoded().unwrap()).into_owned() } /// The decoded appearance PdfCraft would save for text or choice field `name` of `doc`. fn saved_look(doc: &Document, name: &str) -> String { let all = fields(doc); let f = field(&all, name); String::from_utf8_lossy(&appearance::field_appearance(doc, f, &f.widgets[0], &f.value).decoded().unwrap()).into_owned() } /// ISO 32000-2 Table 192 and 12.7.5.2.3: a check box with no appearance shows its `/MK /BC` /// border and, when on, its caption `/MK /CA` as a ZapfDingbats character in the `/DA` colour, /// sized to the box when the `/DA` size is 0. #[test] fn check_boxes_without_an_appearance_show_their_caption_symbol() { let doc = left_buttons(); let copy = with_field_appearances(&doc).expect("buttons to draw"); let all = fields(©); for (name, symbol, colour) in [("i", "(4) Tj", "0 g"), ("j", "(l) Tj", "0 0 1 rg"), ("k", "(8) Tj", "1 0 0 rg"), ("l", "(H) Tj", "0 g")] { let shown = state_ap(©, &field(&all, name).widgets[0], "Yes"); assert!(shown.contains(symbol), "{name}: {shown}"); assert!(shown.contains(&format!("{colour}\n")), "{name} in its colour: {shown}"); assert!(shown.contains("0 0 0 RG\n1 w\n0.5 0.5 23 23 re S"), "{name} has its border: {shown}"); } // A /DA size is kept; the automatic one fills the box inside the border. let l = state_ap(©, &field(&all, "l").widgets[0], "Yes"); assert!(l.contains("/ZaDb 9 Tf") && l.contains("1 1 0 rg\n0 0 24 24 re f"), "{l}"); let i = state_ap(©, &field(&all, "i").widgets[0], "Yes"); let size: f64 = i.split("/ZaDb ").nth(1).and_then(|s| s.split(' ').next()).and_then(|s| s.parse().ok()).unwrap(); assert!((18.0..=26.0).contains(&size), "{size}"); let w = copy.get(field(&all, "i").widgets[0].obj); let ap = copy.resolve(w.as_dict().unwrap().get(b"AP").unwrap()); let n = copy.resolve(ap.as_dict().unwrap().get(b"N").unwrap()); let Object::Stream(s) = &*copy.get(n.as_dict().unwrap().reference(b"Yes").unwrap()) else { panic!() }; let font = s.dict.get(b"Resources").and_then(|r| r.as_dict()).and_then(|r| r.get(b"Font")).and_then(|f| f.as_dict()).and_then(|f| f.get(b"ZaDb")); assert_eq!(font.and_then(|f| f.as_dict()).and_then(|f| f.name(b"BaseFont")), Some(&b"ZapfDingbats"[..]), "a standard-14 font, no program"); } /// An Off check box shows only its border, a radio button without a caption a dot (`l`), a box /// whose file has only an Off look gets an on look, and one with its own look keeps it. #[test] fn button_states_without_an_appearance() { let doc = left_buttons(); let copy = with_field_appearances(&doc).expect("buttons to draw"); let all = fields(©); let off = state_ap(©, &field(&all, "off").widgets[0], "Off"); assert!(off.contains("re S") && !off.contains("Tj"), "{off}"); assert!(state_ap(©, &field(&all, "radio").widgets[0], "A").contains("(l) Tj")); assert!(state_ap(©, &field(&all, "half").widgets[0], "Yes").contains("(8) Tj")); let own = |d: &Document| d.get(field(&fields(d), "own").widgets[0].obj).as_dict().and_then(|w| w.get(b"AP").cloned()); assert_eq!(own(©), own(&doc), "a check box with its own look keeps it"); // Nonsense in /MK, /DA and /BS: the default symbol, no border, and no panic. let bad = state_ap(©, &field(&all, "bad").widgets[0], "Yes"); assert!(bad.contains("(4) Tj") && !bad.contains("re S"), "{bad}"); // The document itself is untouched. assert!(doc.get(field(&fields(&doc), "i").widgets[0].obj).as_dict().is_some_and(|w| !w.contains(b"AP"))); } /// Table 192 and Table 168: a push button with no appearance shows its `/MK /BG` background, its /// beveled `/MK /BC` border in the `/BS` width, and its caption `/MK /CA` centred. #[test] fn push_buttons_without_an_appearance_show_their_caption_and_frame() { let copy = with_field_appearances(&left_buttons()).expect("buttons to draw"); let shown = ap(©, &field(&fields(©), "o").widgets[0]); for part in ["0.8 0.8 0.8 rg\n0 0 120 24 re f", "0 0 0 RG\n2 w\n1 1 118 22 re S", "1 g\n", "0.5 g\n", "(Press me) Tj", "/Helv 12.00 Tf"] { assert!(shown.contains(part), "{part}: {shown}"); } } /// ISO 32000-2 Table 228, Comb: the field is divided into MaxLen equally spaced positions, and a /// bordered comb field shows them: MaxLen - 1 lines in the border colour, in its saved look too. #[test] fn comb_fields_show_their_cells() { let mut doc = left_buttons(); let shown = saved_look(&doc, "d"); let lines: Vec<&str> = shown.lines().filter(|l| l.ends_with(" l") && l.contains(" 1 m ")).collect(); assert_eq!(lines, ["20 1 m 20 23 l", "40 1 m 40 23 l", "60 1 m 60 23 l", "80 1 m 80 23 l", "100 1 m 100 23 l"], "{shown}"); assert!(shown.contains("0 0 1 RG\n1 w\n20 1 m"), "in the border colour: {shown}"); // Past the cap no lines, and a huge MaxLen is no trouble; without a border, none either. let r = field(&fields(&doc), "d").widgets[0].obj; doc.update_dict(r, |w| w.set(b"MaxLen".to_vec(), Object::Int(1 << 40))).unwrap(); let huge = saved_look(&doc, "d"); assert!(!huge.contains(" 1 m ") && huge.contains("(A) Tj"), "{huge}"); doc.update_dict(r, |w| { w.set(b"MaxLen".to_vec(), Object::Int(6)); w.remove(b"MK"); }) .unwrap(); let plain = saved_look(&doc, "d"); assert!(!plain.contains(" m ") && plain.contains("(A) Tj"), "{plain}"); }