//! Incremental save acceptance tests, against the real corpus. //! //! These are the merge criteria from //! `REVIEWS/adr/0003-pdf-incremental-save.md`. The central one is the round //! trip: edit, save, reparse **from the written bytes**, and observe the //! edit. Anything less tests the writer against itself. use std::path::PathBuf; use nigig_pdf_cos::incremental::{find_last_startxref, SaveError}; use nigig_pdf_cos::{PdfDict, PdfObj}; use nigig_pdf_document::form::{DocumentFormEditor, FieldType, FieldValue}; use nigig_pdf_document::save::save_form_edits; use nigig_pdf_document::PdfDocument; fn corpus(relative: &str) -> Vec { let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("../tests/corpus") .join(relative); std::fs::read(&path).unwrap_or_else(|e| panic!("missing {}: {e}", path.display())) } /// Parse, returning the pieces a save needs. fn open(data: &[u8]) -> (PdfDict, nigig_pdf_document::form::AcroForm, u32) { let mut doc = PdfDocument::parse(data).expect("parses"); let form = doc .acroform() .expect("acroform lookup") .expect("fixture has a form"); (doc.trailer().clone(), form, doc.max_object_number()) } /// Every form fixture survives edit, save and reparse. #[test] fn every_form_fixture_round_trips() { for fixture in ["forms/all_types.pdf", "forms/inherited.pdf"] { let data = corpus(fixture); let (trailer, mut form, max) = open(&data); // Edit the first writable text field the fixture has. let Some(target) = form .fields() .iter() .find(|f| f.field_type == FieldType::Text && !f.is_read_only()) .map(|f| f.obj_ref) else { continue; }; DocumentFormEditor::new(&mut form) .set_text(target, "round tripped") .unwrap_or_else(|e| panic!("{fixture}: edit refused: {e}")); let (out, report) = save_form_edits(&data, &trailer, &form, max) .unwrap_or_else(|e| panic!("{fixture}: save failed: {e}")); assert!(report.wrote_anything(), "{fixture}: nothing was written"); assert!( out.starts_with(&data), "{fixture}: the original bytes must be a prefix" ); let mut reloaded = PdfDocument::parse(&out) .unwrap_or_else(|e| panic!("{fixture}: saved file does not reparse: {e}")); let reloaded_form = reloaded .acroform() .expect("acroform") .unwrap_or_else(|| panic!("{fixture}: form lost on save")); let value = reloaded_form .field(target) .unwrap_or_else(|| panic!("{fixture}: field lost on save")) .value .clone(); assert_eq!( value, FieldValue::Text("round tripped".into()), "{fixture}: the edit did not survive" ); } } #[test] fn a_read_only_fixture_field_cannot_be_edited_so_nothing_is_saved() { // forms/inherited.pdf's leaf inherits /Ff 1 (read-only) from its parent. let data = corpus("forms/inherited.pdf"); let (trailer, mut form, max) = open(&data); let field = form.find_field("address.city").expect("field").obj_ref; assert!( DocumentFormEditor::new(&mut form) .set_text(field, "nope") .is_err(), "the field is read-only" ); let (out, report) = save_form_edits(&data, &trailer, &form, max).expect("save"); assert!(report.unchanged); assert_eq!(out, data, "a refused edit must not produce a revision"); } #[test] fn the_saved_file_gains_exactly_one_new_xref_section() { let data = corpus("forms/all_types.pdf"); let (trailer, mut form, max) = open(&data); let field = form.find_field("fullName").expect("field").obj_ref; DocumentFormEditor::new(&mut form) .set_text(field, "Grace Hopper") .expect("edit"); let before = find_last_startxref(&data).expect("source startxref"); let (out, _) = save_form_edits(&data, &trailer, &form, max).expect("save"); let after = find_last_startxref(&out).expect("saved startxref"); assert!( after > before, "the new xref must be appended after the old" ); assert_eq!( &out[after as usize..after as usize + 4], b"xref", "startxref must point at a real section" ); let text = String::from_utf8_lossy(&out); assert!( text.contains(&format!("/Prev {before}")), "the revision must chain to the previous xref" ); } #[test] fn a_checkbox_round_trips_with_its_declared_on_state() { let data = corpus("forms/all_types.pdf"); let (trailer, mut form, max) = open(&data); let field = form.find_field("subscribe").expect("checkbox").obj_ref; DocumentFormEditor::new(&mut form) .set_check(field, true) .expect("check"); let (out, _) = save_form_edits(&data, &trailer, &form, max).expect("save"); let mut reloaded = PdfDocument::parse(&out).expect("reparses"); let reloaded_form = reloaded.acroform().expect("acroform").expect("present"); let checkbox = reloaded_form.field(field).expect("checkbox"); assert!(checkbox.is_checked(), "got {:?}", checkbox.value); assert_eq!( checkbox.value, FieldValue::State("On".into()), "the fixture declares /On, not /Yes" ); } #[test] fn a_combo_choice_round_trips() { let data = corpus("forms/all_types.pdf"); let (trailer, mut form, max) = open(&data); let field = form.find_field("country").expect("combo").obj_ref; DocumentFormEditor::new(&mut form) .set_choice(field, vec!["Tanzania".into()]) .expect("choose"); let (out, _) = save_form_edits(&data, &trailer, &form, max).expect("save"); let mut reloaded = PdfDocument::parse(&out).expect("reparses"); let reloaded_form = reloaded.acroform().expect("acroform").expect("present"); assert_eq!( reloaded_form.field(field).expect("combo").value, FieldValue::Text("Tanzania".into()) ); } #[test] fn three_revisions_still_reparse() { let mut data = corpus("forms/all_types.pdf"); for value in ["one", "two", "three"] { let (trailer, mut form, max) = open(&data); let field = form.find_field("fullName").expect("field").obj_ref; DocumentFormEditor::new(&mut form) .set_text(field, value) .expect("edit"); let (out, _) = save_form_edits(&data, &trailer, &form, max).expect("save"); assert!(out.starts_with(&data), "each revision appends"); data = out; } let mut reloaded = PdfDocument::parse(&data).expect("reparses"); let form = reloaded.acroform().expect("acroform").expect("present"); assert_eq!( form.find_field("fullName").expect("field").value, FieldValue::Text("three".into()), "the newest revision must win after three saves" ); // Other fields must survive the chain untouched. assert!(form.find_field("subscribe").is_some()); assert!(form.find_field("country").is_some()); } #[test] fn the_page_tree_survives_a_save() { // A revision that writes only form fields must not disturb the pages. let data = corpus("forms/all_types.pdf"); let before = PdfDocument::parse(&data).expect("parses").page_count(); let (trailer, mut form, max) = open(&data); let field = form.find_field("fullName").expect("field").obj_ref; DocumentFormEditor::new(&mut form) .set_text(field, "x") .expect("edit"); let (out, _) = save_form_edits(&data, &trailer, &form, max).expect("save"); let mut reloaded = PdfDocument::parse(&out).expect("reparses"); assert_eq!(reloaded.page_count(), before); assert!( !reloaded.page(0).expect("page 0").content_data.is_empty(), "page content must still resolve through the chain" ); } #[test] fn annotations_survive_a_save() { let data = corpus("forms/all_types.pdf"); let (trailer, mut form, max) = open(&data); let field = form.find_field("fullName").expect("field").obj_ref; DocumentFormEditor::new(&mut form) .set_text(field, "x") .expect("edit"); let (out, _) = save_form_edits(&data, &trailer, &form, max).expect("save"); let mut reloaded = PdfDocument::parse(&out).expect("reparses"); let annots = reloaded.page_annotations(0).expect("annotations"); assert!( !annots.is_empty(), "the fixture's widget annotations must survive" ); } #[test] fn saving_an_encrypted_document_is_refused() { let data = corpus("forms/all_types.pdf"); let (mut trailer, mut form, max) = open(&data); trailer.set( "Encrypt", PdfObj::Ref(nigig_pdf_cos::ObjRef { num: 99, gen: 0 }), ); let field = form.find_field("fullName").expect("field").obj_ref; DocumentFormEditor::new(&mut form) .set_text(field, "x") .expect("edit"); assert_eq!( save_form_edits(&data, &trailer, &form, max), Err(SaveError::Encrypted), "writing plaintext into an encrypted file would corrupt it" ); } /// Saving must not become a new way to crash on hostile input. #[test] fn malformed_fixtures_never_panic_a_save() { let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../tests/corpus/malformed"); let entries = std::fs::read_dir(&dir).expect("malformed corpus"); for entry in entries.flatten() { let path = entry.path(); if path.extension().is_none_or(|e| e != "pdf") { continue; } let Ok(data) = std::fs::read(&path) else { continue; }; let Ok(mut doc) = PdfDocument::parse(&data) else { continue; }; let trailer = doc.trailer().clone(); let max = doc.max_object_number(); let Ok(Some(form)) = doc.acroform() else { continue; }; // Whatever happens, it returns. let _ = save_form_edits(&data, &trailer, &form, max); } }