nigig-org/crates/apps/pdf/pdf-document/tests/annotation_roundtrip.rs

726 lines
25 KiB
Rust

//! 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<u8> {
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<ObjRef> = 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<ObjRef> = 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<ObjRef> = 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");
}
// -------------------------------------------------------------- Phase 10: add
use nigig_pdf_document::annotation_edit::{NewAnnotation, NewAnnotationKind};
/// Phase 10: a freshly created annotation is written, appears in the page's
/// `/Annots`, and reparses with the fields the host supplied.
#[test]
fn an_added_annotation_survives_a_save_and_reparse() {
let data = corpus("annotations/links.pdf");
let (trailer, mut page, page_ref, page_dict) = open(&data);
let before = page.len();
// Allocate past the source file so the new object never collides.
let mut doc_probe = PdfDocument::parse(&data).expect("probe");
let next_num = doc_probe.max_object_number() + 1;
let next = ObjRef {
num: next_num,
gen: 0,
};
page.add(
&NewAnnotation {
kind: NewAnnotationKind::Text,
rect: [20.0, 20.0, 80.0, 60.0],
contents: Some("A new note".into()),
..Default::default()
},
next,
)
.expect("add");
let (out, report) =
save_annotation_edits(&data, &trailer, &page, page_ref, &page_dict).expect("save");
assert!(
report.annotations_written.contains(&next.num),
"a new annotation's object must be written, got {report:?}"
);
assert!(
report.pages_written.contains(&page_ref.num),
"the page's /Annots must be rewritten to include the new reference"
);
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");
assert_eq!(annots.len(), before + 1, "one annotation was added");
let added = annots
.iter()
.find(|a| a.obj_ref == Some(next))
.expect("the new annotation is in the reparsed page");
assert_eq!(added.rect, [20.0, 20.0, 80.0, 60.0]);
assert_eq!(
added
.raw_dict
.get_str("Contents")
.map(String::from_utf8_lossy),
Some(std::borrow::Cow::Borrowed("A new note"))
);
assert!(
added.is_visible(),
"a created annotation is visible by default"
);
// And the page's /Annots did gain the new reference.
let page_obj = reloaded.resolve_ref(page_ref).expect("page object");
let annots_refs: Vec<ObjRef> = 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!(
annots_refs.contains(&next),
"the page's Annots must contain the new reference, got {annots_refs:?}"
);
}
/// Adding then immediately deleting in the same session must not leave a
/// dangling reference in `/Annots` and must write nothing for the annotation.
#[test]
fn an_added_then_deleted_annotation_leaves_no_trace() {
let data = corpus("annotations/links.pdf");
let (trailer, mut page, page_ref, page_dict) = open(&data);
let before = page.len();
let mut probe = PdfDocument::parse(&data).expect("probe");
let next = ObjRef {
num: probe.max_object_number() + 1,
gen: 0,
};
page.add(
&NewAnnotation {
kind: NewAnnotationKind::Square,
rect: [10.0, 10.0, 50.0, 50.0],
..Default::default()
},
next,
)
.expect("add");
assert_eq!(page.len(), before + 1);
AnnotationEditor::new(&mut page)
.delete(next)
.expect("delete the just-added annotation");
assert_eq!(
page.len(),
before,
"deleting the new annotation restores the count"
);
assert!(page.get(next).is_none());
let (out, report) =
save_annotation_edits(&data, &trailer, &page, page_ref, &page_dict).expect("save");
// The added annotation's object is marked deleted, so `save` skips
// rewriting it and does not count it as a written annotation; the page
// rewrite (if any) removes the reference that was briefly appended.
assert!(
!report.annotations_written.contains(&next.num),
"a deleted add must not count as written"
);
let mut reloaded = PdfDocument::parse(&out).expect("reparses");
let annots = reloaded.page_annotations(PAGE).expect("annotations");
assert_eq!(annots.len(), before);
assert!(annots.iter().all(|a| a.obj_ref != Some(next)));
}
/// A new highlight annotation with optional style fields survives the round
/// trip, proving the builder actually serialises the fields a UI colour
/// picker and opacity slider produce.
#[test]
fn a_styled_added_annotation_round_trips_its_fields() {
let data = corpus("annotations/links.pdf");
let (trailer, mut page, page_ref, page_dict) = open(&data);
let mut probe = PdfDocument::parse(&data).expect("probe");
let next = ObjRef {
num: probe.max_object_number() + 1,
gen: 0,
};
page.add(
&NewAnnotation {
kind: NewAnnotationKind::Highlight,
rect: [100.0, 400.0, 200.0, 420.0],
contents: Some("Look here".into()),
color: Some(nigig_pdf_document::annotation_edit::AnnotationColor::new(
1.0, 1.0, 0.0,
)),
opacity: Some(0.5),
..Default::default()
},
next,
)
.expect("add");
let (out, _) =
save_annotation_edits(&data, &trailer, &page, page_ref, &page_dict).expect("save");
let mut reloaded = PdfDocument::parse(&out).expect("reparses");
let added = reloaded
.page_annotations(PAGE)
.expect("annotations")
.into_iter()
.find(|a| a.obj_ref == Some(next))
.expect("the new annotation is in the reparsed page");
assert!(
matches!(added.annot_type, AnnotationType::Highlight),
"a highlight round-trips as a highlight"
);
assert_eq!(added.raw_dict.get_f64("CA"), Some(0.5));
assert!(added.raw_dict.get_array("C").is_some());
}