726 lines
25 KiB
Rust
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());
|
|
}
|