fix(render): list an annotation reached through a reference chain once

The annotation listing skipped a repeated /Annots entry by its raw
reference, so an indirect object holding only a reference to a link
listed that link a second time. Entries and page arrays are now told
apart by the object their reference chain ends at. The lopdf reader
reported the first object of a chain, because lopdf's get_object
already follows it; it now reports the last, as the lazy reader does.
This commit is contained in:
aiongg
2026-10-22 21:33:20 +00:00
parent 668d8f777e
commit c82dc91abf
2 changed files with 45 additions and 6 deletions
+40 -6
View File
@@ -827,6 +827,12 @@ impl<'a> Inspector<'a> {
self.doc.dereference(o).map(|(_, o)| o).unwrap_or(o)
}
/// The object a reference ends at after following any chain of references; `None` for a
/// direct object or a broken reference.
fn resolved_id(&self, o: &Object) -> Option<ObjectId> {
self.doc.dereference(o).ok().and_then(|(id, _)| id)
}
fn dict(&self, o: &'a Object) -> Option<&'a Dictionary> {
match self.resolve(o) {
Object::Dictionary(d) => Some(d),
@@ -1163,23 +1169,26 @@ impl<'a> Inspector<'a> {
let mut pages: Vec<(ObjectId, usize)> = self.page_index.iter().map(|(&id, &page)| (id, page)).collect();
pages.sort_unstable_by_key(|&(_, page)| page);
// An annotation, or a whole `/Annots` array, that several entries or pages list is on the
// first of them (§12.5.2 allows one page per annotation).
let mut seen = HashSet::new();
// first of them (§12.5.2 allows one page per annotation). Both are told apart by the object
// a reference resolves to, since an indirect object may itself hold a reference.
let mut seen_arrays = HashSet::new();
let mut seen_annotations = HashSet::new();
let mut kept = 0usize;
// Once the comment budget is spent, comments are no longer built, but links and attachments
// are still listed.
let mut comments_full = false;
for (id, page) in pages {
let Ok(page_dict) = self.doc.get_dictionary(id) else { continue };
if let Ok(Object::Reference(r)) = page_dict.get(b"Annots")
&& !seen.insert(*r)
if let Ok(annots) = page_dict.get(b"Annots")
&& let Some(r) = self.resolved_id(annots)
&& !seen_arrays.insert(r)
{
continue;
}
let Ok(Object::Array(annots)) = page_dict.get(b"Annots").map(|o| self.resolve(o)) else { continue };
for (index, a) in annots.iter().enumerate() {
if let Object::Reference(r) = a
&& !seen.insert(*r)
if let Some(r) = self.resolved_id(a)
&& !seen_annotations.insert(r)
{
continue;
}
@@ -2656,6 +2665,31 @@ trailer << /Root 1 0 R >>
assert!(notes.iter().map(retained).sum::<usize>() <= MAX_ANNOTATION_BYTES);
}
/// A link reached through an indirect object that holds only a reference to it is the same
/// annotation as the link itself, and so is a page array reached the same way: each is listed once.
#[test]
fn an_annotation_reached_through_a_reference_chain_is_listed_once() {
let pdf = "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\
2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >> endobj\n\
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots 7 0 R >> endobj\n\
4 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots 8 0 R >> endobj\n\
5 0 obj 6 0 R endobj\n\
6 0 obj << /Type /Annot /Subtype /Link /Rect [0 0 1 1] /A << /S /URI /URI (https://example.com) >> >> endobj\n\
7 0 obj [5 0 R 6 0 R] endobj\n\
8 0 obj 7 0 R endobj\n\
trailer << /Root 1 0 R >>\n%%EOF";
let mut doc = Document::load_mem(pdf.as_bytes()).expect("loads");
let mut saved = Vec::new();
doc.save_to(&mut saved).expect("saves");
let lazy = LazyStructure::new(Arc::new(saved), None).expect("opens");
let page = PageInfo { width: 200.0, height: 200.0, crop: [0.0, 0.0, 200.0, 200.0], rotation: 0, label: String::new() };
for reader in [&doc as &dyn Structure, &lazy] {
let mut info = DocInfo { pages: vec![page.clone(), page.clone()], ..Default::default() };
Inspector::new(reader).fill(&mut info);
assert_eq!(info.links.len(), 1, "{:?}", info.links);
}
}
/// Links and attachments after the comment budget fills are still listed, and a JavaScript link
/// among them still marks the document.
#[test]
+5
View File
@@ -114,6 +114,11 @@ impl Structure for lopdf::Document {
fn get_object(&self, id: ObjectId) -> Result<&Object> {
self.get_object(id)
}
// lopdf's `get_object` already follows an indirect object that holds a reference, so the
// trait's walk would report the first object of a chain. lopdf's own walk reports the last.
fn dereference<'a>(&'a self, object: &'a Object) -> Result<(Option<ObjectId>, &'a Object)> {
lopdf::Document::dereference(self, object)
}
fn encrypted(&self) -> bool {
self.is_encrypted() || self.was_encrypted() || self.trailer.has(b"Encrypt")
}