From cf565db3fedae414300818330108fd9dcfd644bf Mon Sep 17 00:00:00 2001 From: Unhumanje <54317404+petarlaf@users.noreply.github.com> Date: Wed, 21 Oct 2026 11:24:48 +0200 Subject: [PATCH] fix(annot): a comment listed many times gets one display appearance with_missing_appearances_cached checked each /Annots entry against the source document, which never gets the appearance, so an annotation listed K times added K appearance streams to the display copy. The engine writes that copy on open and after every edit, so the display bytes grew with the number of listings: 20,000 listings of one Ink comment in a 136 KB file came to 428 MB. Each annotation reference is now handled once per call, across all pages. --- crates/annot/src/lib.rs | 7 +++++++ crates/annot/src/tests.rs | 22 ++++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/crates/annot/src/lib.rs b/crates/annot/src/lib.rs index 587cd4466..0b8a14490 100644 --- a/crates/annot/src/lib.rs +++ b/crates/annot/src/lib.rs @@ -1433,8 +1433,15 @@ pub fn with_missing_appearances(doc: &Document) -> Option { /// redrawing it. See [`AppearanceCache`] for when entries stay valid. pub fn with_missing_appearances_cached(doc: &Document, cache: &mut AppearanceCache) -> Option { let mut copy: Option = None; + // An annotation listed more than once, on one page or several, is drawn once. `has_normal` + // 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 r in annots(doc, page).iter().filter_map(Object::as_ref) { + if !seen.insert(r) { + continue; + } let d = annot_dict(doc, r); if matches!(d.name(b"Subtype"), None | Some(b"Link" | b"Widget" | b"Popup")) { continue; diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index 4a4dfb106..899c6ea75 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -1301,6 +1301,28 @@ fn cached_appearances_are_reused_while_annotations_are_unchanged() { assert_eq!(a, b, "the reused appearance is byte-identical"); } +/// A comment that `/Annots` lists many times, and that a second page lists too, is drawn once in +/// the display copy. Every listing used to add another appearance stream, so a short array of +/// repeated references multiplied the display bytes the engine writes on each refresh. +#[test] +fn a_comment_listed_many_times_is_drawn_once_for_display() { + let first = format!("<< /Type /Page /Parent 2 0 R /Annots [{}] >>", vec!["5 0 R"; 1_000].join(" ")); + let doc = document_of(&[ + b"<< /Type /Catalog /Pages 2 0 R >>", + b"<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 612 792] >>", + first.as_bytes(), + b"<< /Type /Page /Parent 2 0 R /Annots [5 0 R] >>", + b"<< /Type /Annot /Subtype /Square /Rect [10 10 50 50] /C [1 0 0] >>", + ]); + let mut cache = AppearanceCache::default(); + let display = with_missing_appearances_cached(&doc, &mut cache).expect("the square needs an appearance"); + assert_eq!(display.object_numbers().len(), doc.object_numbers().len() + 1, "one appearance stream, not one per listing"); + assert_eq!((cache.builds(), cache.hits()), (1, 0)); + assert!(list(&display, 0).iter().chain(&list(&display, 1)).all(|d| d.contains(b"AP")), "every listing shows it"); + with_missing_appearances_cached(&doc, &mut cache).expect("a refresh"); + assert_eq!((cache.builds(), cache.hits()), (1, 1), "a refresh reuses the cached stream once"); +} + /// #1908: a text box's font size changes in `/DA` (colour kept), the rich text follows, the box /// grows to hold the bigger text but never shrinks, and bad sizes and other subtypes are refused. #[test]