From fdd4e50e8000d3886ff6531519b6ffbc0a36369f Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 4 Nov 2026 04:16:38 +0000 Subject: [PATCH] fix(annot): document-wide annotation scans copy a shared Annots array once --- crates/annot/src/draw.rs | 4 ++-- crates/annot/src/lookup.rs | 10 +++++++++- crates/annot/src/tests.rs | 28 ++++++++++++++++++++++++++++ 3 files changed, 39 insertions(+), 3 deletions(-) diff --git a/crates/annot/src/draw.rs b/crates/annot/src/draw.rs index 5e698332da..46d2b09ea8 100644 --- a/crates/annot/src/draw.rs +++ b/crates/annot/src/draw.rs @@ -1,5 +1,5 @@ use super::create::{finite, num_array}; -use super::lookup::{annot_dict, annots, page_refs}; +use super::lookup::{annot_dict, annots, page_refs, with_distinct_annots}; use super::modify::refuse_foreign_text; use super::types::NOTE_SIZE; use super::{AnnotError, Dict, Document, ObjRef, Object, Stream, appearance}; @@ -163,7 +163,7 @@ pub fn with_missing_appearances_cached(doc: &Document, cache: &mut AppearanceCac // reads `doc`, which never gets the appearance, so every repeat used to add another stream to // the copy, and the display bytes grew with the number of listings. let mut seen = std::collections::HashSet::new(); - for page in page_refs(doc).ok()? { + for page in with_distinct_annots(doc, page_refs(doc).ok()?) { for r in annots(doc, page).iter().filter_map(Object::as_ref) { if !seen.insert(r) { continue; diff --git a/crates/annot/src/lookup.rs b/crates/annot/src/lookup.rs index 9af83b0542..3d38127deb 100644 --- a/crates/annot/src/lookup.rs +++ b/crates/annot/src/lookup.rs @@ -73,6 +73,14 @@ pub(crate) fn page_ref(doc: &Document, page: usize) -> Result) -> Vec { + let mut seen = std::collections::HashSet::new(); + pages.into_iter().filter(|p| doc.get(*p).as_dict().and_then(|d| d.reference(b"Annots")).is_none_or(|r| seen.insert(r))).collect() +} + /// The entries of a page's `/Annots` (references or inline dictionaries). pub(crate) fn annots(doc: &Document, page: ObjRef) -> Vec { let obj = doc.get(page); @@ -172,7 +180,7 @@ pub(crate) fn drawn_upright(doc: &Document, d: &Dict) -> bool { /// Whether any page has a Fill & Sign annotation that flatten would bake in (not hidden). pub fn has_visible_fill_sign(doc: &Document) -> bool { let Ok(pages) = page_refs(doc) else { return false }; - for page in pages { + for page in with_distinct_annots(doc, pages) { for entry in annots(doc, page) { let obj = doc.resolve(&entry); let Some(d) = obj.as_dict() else { continue }; diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index 32d28fc6ec..b6e090f8e8 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -2274,3 +2274,31 @@ fn indirect_typewriter_names_keep_outer_whitespace_in_summaries() { check(&doc); check(&reopen(&doc)); } + +/// Pages sharing one indirect `/Annots` array have it copied once by the consumers that visit each +/// annotation once, not once per page. +#[test] +fn document_wide_scans_copy_a_shared_annots_array_once() { + let mut doc = fixture(); + let root = ObjRef { num: 2, generation: 0 }; + let mut note = Dict::new(); + note.set(b"Type".to_vec(), Object::name("Annot")); + note.set(b"Subtype".to_vec(), Object::name("Text")); + note.set(b"Rect".to_vec(), Object::Array([10, 10, 30, 30].map(Object::Int).to_vec())); + let note = doc.add(note); + let shared = doc.add(Object::Array(std::iter::repeat_n(Object::Ref(note), 100).collect())); + let pages: Vec = (0..500) + .map(|_| { + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("Page")); + d.set(b"Annots".to_vec(), Object::Ref(shared)); + Object::Ref(doc.add(d)) + }) + .collect(); + doc.update_dict(root, |d| d.set(b"Kids".to_vec(), Object::Array(pages))).unwrap(); + ANNOTS_ENTRIES_COPIED.with(|c| c.set(0)); + assert!(!has_visible_fill_sign(&doc)); + assert!(with_missing_appearances(&doc).is_some()); + let copied = ANNOTS_ENTRIES_COPIED.with(std::cell::Cell::get); + assert!(copied <= 200, "{copied} annotation entries copied"); +}