//! Annotation editing acceptance tests. //! //! The merge criteria from `REVIEWS/adr/0004-pdf-annotation-editing.md`. //! As with form saving, the central assertion reparses **from the written //! bytes**: an edit that only survives in memory has not been saved. use std::path::PathBuf; use nigig_pdf_cos::{ObjRef, PdfDict, PdfObj}; use nigig_pdf_document::annotation_edit::{ AnnotationColor, AnnotationEdit, AnnotationEditor, AnnotationError, PageAnnotations, }; use nigig_pdf_document::annotations::{AnnotationFlags, AnnotationType}; use nigig_pdf_document::save::save_annotation_edits; use nigig_pdf_document::PdfDocument; const PAGE: usize = 0; 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())) } /// Everything a save needs: the page's annotations, its reference and dict. fn open(data: &[u8]) -> (PdfDict, PageAnnotations, ObjRef, PdfDict) { let mut doc = PdfDocument::parse(data).expect("parses"); let annots = doc.page_annotations(PAGE).expect("annotations"); let trailer = doc.trailer().clone(); let page_ref = doc.page_object_ref(PAGE).expect("page ref"); let page_dict = doc .resolve_ref(page_ref) .expect("page object") .as_dict() .cloned() .expect("page is a dict"); ( trailer, PageAnnotations::new(PAGE, annots), page_ref, page_dict, ) } /// The reference of the first annotation carrying a URI action. fn link_ref(page: &PageAnnotations) -> ObjRef { page.live() .find(|a| a.annotation.action().is_some()) .and_then(|a| a.annotation.obj_ref) .expect("the fixture has a link") } #[test] fn corpus_annotations_carry_their_object_reference() { // Without this an edit has nothing stable to address. let (_, page, _, _) = open(&corpus("annotations/links.pdf")); assert!(!page.is_empty()); for item in page.live() { assert!( item.annotation.obj_ref.is_some(), "every corpus annotation is an indirect object" ); } } /// The core round trip: move an annotation, save, reparse, see it moved. #[test] fn a_moved_annotation_survives_a_save_and_reparse() { let data = corpus("annotations/links.pdf"); let (trailer, mut page, page_ref, page_dict) = open(&data); let target = link_ref(&page); let before = page.get(target).expect("annotation").annotation.rect; AnnotationEditor::new(&mut page) .move_to(target, 42.0, 84.0) .expect("move"); let (out, report) = save_annotation_edits(&data, &trailer, &page, page_ref, &page_dict).expect("save"); assert!(report.annotations_written.contains(&target.num)); assert!(out.starts_with(&data), "an incremental save appends"); let mut reloaded = PdfDocument::parse(&out).expect("saved file reparses"); let annots = reloaded.page_annotations(PAGE).expect("annotations"); let moved = annots .iter() .find(|a| a.obj_ref == Some(target)) .expect("annotation survives"); assert_ne!(moved.rect, before, "the rectangle must have changed"); assert_eq!(moved.rect[0], 42.0); assert_eq!(moved.rect[1], 84.0); // Size is preserved by a move. assert_eq!(moved.width(), (before[2] - before[0]).abs()); assert_eq!(moved.height(), (before[3] - before[1]).abs()); } #[test] fn a_resize_survives_a_save_and_reparse() { let data = corpus("annotations/links.pdf"); let (trailer, mut page, page_ref, page_dict) = open(&data); let target = link_ref(&page); AnnotationEditor::new(&mut page) .resize(target, [10.0, 20.0, 110.0, 70.0]) .expect("resize"); let (out, _) = save_annotation_edits(&data, &trailer, &page, page_ref, &page_dict).expect("save"); let mut reloaded = PdfDocument::parse(&out).expect("reparses"); let annots = reloaded.page_annotations(PAGE).expect("annotations"); let resized = annots .iter() .find(|a| a.obj_ref == Some(target)) .expect("annotation"); assert_eq!(resized.rect, [10.0, 20.0, 110.0, 70.0]); } #[test] fn restyling_survives_a_save_and_reparse() { let data = corpus("annotations/links.pdf"); let (trailer, mut page, page_ref, page_dict) = open(&data); let target = link_ref(&page); { let mut editor = AnnotationEditor::new(&mut page); editor .apply( target, AnnotationEdit::SetColor(AnnotationColor::new(1.0, 0.0, 0.0)), ) .expect("colour"); editor .apply(target, AnnotationEdit::SetBorderWidth(2.5)) .expect("border"); editor .apply(target, AnnotationEdit::SetContents("edited note".into())) .expect("contents"); } let (out, _) = save_annotation_edits(&data, &trailer, &page, page_ref, &page_dict).expect("save"); let mut reloaded = PdfDocument::parse(&out).expect("reparses"); let annots = reloaded.page_annotations(PAGE).expect("annotations"); let styled = annots .iter() .find(|a| a.obj_ref == Some(target)) .expect("annotation"); let colour = styled.raw_dict.get_array("C").expect("/C survived"); assert_eq!(colour[0].as_f64(), Some(1.0)); assert_eq!( styled .raw_dict .get_dict("BS") .and_then(|bs| bs.get_f64("W")), Some(2.5) ); assert_eq!( styled.raw_dict.get_str("Contents"), Some(&b"edited note"[..]) ); } /// ADR 0004 rule 2: a deleted annotation must leave the page's `/Annots`, /// not just the in-memory model. #[test] fn a_deleted_annotation_is_gone_from_the_reparsed_page() { let data = corpus("annotations/links.pdf"); let (trailer, mut page, page_ref, page_dict) = open(&data); let before = page.len(); let target = link_ref(&page); AnnotationEditor::new(&mut page) .delete(target) .expect("delete"); let (out, report) = save_annotation_edits(&data, &trailer, &page, page_ref, &page_dict).expect("save"); assert!(report.annotations_deleted.contains(&target.num)); assert!( report.pages_written.contains(&page_ref.num), "the page must be rewritten so /Annots loses the reference" ); let mut reloaded = PdfDocument::parse(&out).expect("reparses"); let annots = reloaded.page_annotations(PAGE).expect("annotations"); assert_eq!(annots.len(), before - 1); assert!( !annots.iter().any(|a| a.obj_ref == Some(target)), "the deleted annotation must not come back" ); // And no dangling reference is left behind in the array. let page_obj = reloaded.resolve_ref(page_ref).expect("page"); let refs: Vec = page_obj .as_dict() .and_then(|d| d.get_array("Annots")) .map(|arr| arr.iter().filter_map(|o| o.as_ref().copied()).collect()) .unwrap_or_default(); assert!(!refs.contains(&target), "/Annots still references it"); } #[test] fn the_other_annotations_survive_a_deletion() { let data = corpus("annotations/links.pdf"); let (trailer, mut page, page_ref, page_dict) = open(&data); let target = link_ref(&page); let survivors: Vec = page .live() .filter_map(|a| a.annotation.obj_ref) .filter(|r| *r != target) .collect(); assert!(!survivors.is_empty(), "the fixture has several annotations"); AnnotationEditor::new(&mut page) .delete(target) .expect("delete"); let (out, _) = save_annotation_edits(&data, &trailer, &page, page_ref, &page_dict).expect("save"); let mut reloaded = PdfDocument::parse(&out).expect("reparses"); let annots = reloaded.page_annotations(PAGE).expect("annotations"); for survivor in survivors { assert!( annots.iter().any(|a| a.obj_ref == Some(survivor)), "annotation {survivor} must survive an unrelated deletion" ); } } #[test] fn saving_with_no_edits_returns_the_document_unchanged() { let data = corpus("annotations/links.pdf"); let (trailer, page, page_ref, page_dict) = open(&data); let (out, report) = save_annotation_edits(&data, &trailer, &page, page_ref, &page_dict).expect("save"); assert!(report.unchanged); assert!(!report.wrote_anything()); assert_eq!(out, data); } #[test] fn a_hidden_annotation_can_be_unhidden_and_saved() { let data = corpus("annotations/hidden.pdf"); let (trailer, mut page, page_ref, page_dict) = open(&data); let target = page .live() .filter_map(|a| a.annotation.obj_ref) .next() .expect("a hidden annotation"); assert!(!page .get(target) .expect("annotation") .annotation .is_visible()); AnnotationEditor::new(&mut page) .apply( target, AnnotationEdit::SetFlags(AnnotationFlags { print: true, ..Default::default() }), ) .expect("unhide"); let (out, _) = save_annotation_edits(&data, &trailer, &page, page_ref, &page_dict).expect("save"); let mut reloaded = PdfDocument::parse(&out).expect("reparses"); let annots = reloaded.page_annotations(PAGE).expect("annotations"); let shown = annots .iter() .find(|a| a.obj_ref == Some(target)) .expect("annotation"); assert!(shown.is_visible(), "the annotation should now be visible"); } #[test] fn several_edits_to_one_annotation_produce_one_object() { let data = corpus("annotations/links.pdf"); let (trailer, mut page, page_ref, page_dict) = open(&data); let target = link_ref(&page); { let mut editor = AnnotationEditor::new(&mut page); editor.move_to(target, 1.0, 2.0).expect("move"); editor .resize(target, [1.0, 2.0, 51.0, 22.0]) .expect("resize"); editor .apply(target, AnnotationEdit::SetOpacity(0.5)) .expect("opacity"); } let (_, report) = save_annotation_edits(&data, &trailer, &page, page_ref, &page_dict).expect("save"); assert_eq!( report.annotations_written, vec![target.num], "one annotation edited means one object written" ); } #[test] fn two_successive_annotation_saves_chain_correctly() { let mut data = corpus("annotations/links.pdf"); for (x, y) in [(10.0, 10.0), (20.0, 20.0)] { let (trailer, mut page, page_ref, page_dict) = open(&data); let target = link_ref(&page); AnnotationEditor::new(&mut page) .move_to(target, x, y) .expect("move"); let (out, _) = save_annotation_edits(&data, &trailer, &page, page_ref, &page_dict).expect("save"); assert!(out.starts_with(&data), "each revision appends"); data = out; } let mut reloaded = PdfDocument::parse(&data).expect("reparses"); let annots = reloaded.page_annotations(PAGE).expect("annotations"); let moved = annots .iter() .find(|a| a.action().is_some()) .expect("the link"); assert_eq!( (moved.rect[0], moved.rect[1]), (20.0, 20.0), "the newest revision must win" ); } #[test] fn saving_an_encrypted_document_is_refused() { let data = corpus("annotations/links.pdf"); let (mut trailer, mut page, page_ref, page_dict) = open(&data); trailer.set("Encrypt", PdfObj::Ref(ObjRef { num: 99, gen: 0 })); let target = link_ref(&page); AnnotationEditor::new(&mut page) .move_to(target, 1.0, 1.0) .expect("move"); assert!( save_annotation_edits(&data, &trailer, &page, page_ref, &page_dict).is_err(), "an encrypted document must not be written" ); } #[test] fn a_refused_edit_leaves_the_document_savable_and_unchanged() { let data = corpus("annotations/links.pdf"); let (trailer, mut page, page_ref, page_dict) = open(&data); let target = link_ref(&page); let err = AnnotationEditor::new(&mut page) .resize(target, [0.0, 0.0, 0.0, 0.0]) .expect_err("degenerate"); assert!(matches!(err, AnnotationError::DegenerateRect { .. })); let (out, report) = save_annotation_edits(&data, &trailer, &page, page_ref, &page_dict).expect("save"); assert!(report.unchanged); assert_eq!(out, data); } /// Editing and saving must not become a new way to crash on hostile input. #[test] fn malformed_fixtures_never_panic_an_annotation_save() { let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../tests/corpus/malformed"); for entry in std::fs::read_dir(&dir).expect("malformed corpus").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 Ok(annots) = doc.page_annotations(0) else { continue; }; let trailer = doc.trailer().clone(); let Some(page_ref) = doc.page_object_ref(0) else { continue; }; let page_dict = doc .resolve_ref(page_ref) .ok() .and_then(|o| o.as_dict().cloned()) .unwrap_or_default(); let mut page = PageAnnotations::new(0, annots); let targets: Vec = page.live().filter_map(|a| a.annotation.obj_ref).collect(); for target in targets { let mut editor = AnnotationEditor::new(&mut page); let _ = editor.move_to(target, 5.0, 5.0); let _ = editor.delete(target); } // Whatever happened, saving returns. let _ = save_annotation_edits(&data, &trailer, &page, page_ref, &page_dict); } } // --------------------------------------------------------------------- // ADR 0004 criteria that the original commit left unverified: the // contents/flags edits, and the "kept its /AP and said so" reporting. // --------------------------------------------------------------------- /// The `/AP` of a type this crate cannot draw must survive an edit *and* /// be reported, so a caller knows the artwork still shows the old state. #[test] fn a_stamp_keeps_its_appearance_and_is_reported_as_skipped() { let data = corpus("annotations/stamp.pdf"); let (trailer, mut annots, page_ref, page_dict) = open(&data); let stamp = annots .live() .find(|a| matches!(a.annotation.annot_type, AnnotationType::Stamp { .. })) .and_then(|a| a.annotation.obj_ref) .expect("the fixture has a stamp"); // Capture the artwork reference before the edit. let ap_before = annots .get(stamp) .and_then(|a| a.annotation.raw_dict.get("AP").cloned()) .expect("the stamp has an /AP"); AnnotationEditor::new(&mut annots) .move_to(stamp, 100.0, 500.0) .expect("a stamp can be moved"); let (out, report) = save_annotation_edits(&data, &trailer, &annots, page_ref, &page_dict).expect("save"); assert!( report.annotation_appearances_skipped.contains(&stamp.num), "the stamp's appearance was kept but not reported: {report:?}" ); // Reparse from the written bytes: the artwork must still be there. let mut doc = PdfDocument::parse(&out).expect("reparses"); let reparsed = doc.page_annotations(PAGE).expect("annotations"); let saved = reparsed .iter() .find(|a| a.obj_ref == Some(stamp)) .expect("the stamp survived"); assert_eq!( saved.raw_dict.get("AP"), Some(&ap_before), "ADR 0004 rule 3: a stamp's artwork must never be replaced" ); assert_eq!(saved.rect[0], 100.0, "and the move still applied"); } /// The control: a type this crate *can* draw is not reported as skipped. /// Without this, the test above would pass if everything were reported. #[test] fn a_square_is_not_reported_as_skipped() { let data = corpus("annotations/stamp.pdf"); let (trailer, mut annots, page_ref, page_dict) = open(&data); let square = annots .live() .find(|a| matches!(a.annotation.annot_type, AnnotationType::Square)) .and_then(|a| a.annotation.obj_ref) .expect("the fixture has a square"); AnnotationEditor::new(&mut annots) .move_to(square, 50.0, 50.0) .expect("a square can be moved"); let (_, report) = save_annotation_edits(&data, &trailer, &annots, page_ref, &page_dict).expect("save"); assert!( !report.annotation_appearances_skipped.contains(&square.num), "a drawable type must not be reported as skipped: {report:?}" ); } /// `SetContents` is one of the six edits the ADR promises; it had no /// round-trip test. #[test] fn edited_contents_survive_a_save_and_reparse() { let data = corpus("annotations/links.pdf"); let (trailer, mut annots, page_ref, page_dict) = open(&data); let target = annots .live() .find(|a| matches!(a.annotation.annot_type, AnnotationType::FreeText { .. })) .and_then(|a| a.annotation.obj_ref) .expect("the fixture has a free-text note"); AnnotationEditor::new(&mut annots) .apply(target, AnnotationEdit::SetContents("Revised note".into())) .expect("contents can be set"); let (out, _) = save_annotation_edits(&data, &trailer, &annots, page_ref, &page_dict).expect("save"); let mut doc = PdfDocument::parse(&out).expect("reparses"); let reparsed = doc.page_annotations(PAGE).expect("annotations"); let saved = reparsed .iter() .find(|a| a.obj_ref == Some(target)) .expect("the annotation survived"); assert_eq!( saved .raw_dict .get_str("Contents") .map(String::from_utf8_lossy), Some(std::borrow::Cow::Borrowed("Revised note")), "the new contents must be readable from the written bytes" ); } /// `SetFlags` likewise: written as `/F` bits and readable after a reparse. #[test] fn edited_flags_survive_a_save_and_reparse() { let data = corpus("annotations/links.pdf"); let (trailer, mut annots, page_ref, page_dict) = open(&data); let target = annots .live() .next() .and_then(|a| a.annotation.obj_ref) .expect("a reference"); let flags = AnnotationFlags { print: true, no_zoom: true, ..AnnotationFlags::default() }; AnnotationEditor::new(&mut annots) .apply(target, AnnotationEdit::SetFlags(flags)) .expect("flags can be set"); let (out, _) = save_annotation_edits(&data, &trailer, &annots, page_ref, &page_dict).expect("save"); let mut doc = PdfDocument::parse(&out).expect("reparses"); let reparsed = doc.page_annotations(PAGE).expect("annotations"); let saved = reparsed .iter() .find(|a| a.obj_ref == Some(target)) .expect("the annotation survived"); assert!(saved.flags.print, "Print must round-trip"); assert!(saved.flags.no_zoom, "NoZoom must round-trip"); assert!(!saved.flags.hidden, "Hidden must not have been invented"); }