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.
This commit is contained in:
Unhumanje
2026-10-21 11:24:48 +02:00
parent 4164951453
commit cf565db3fe
2 changed files with 29 additions and 0 deletions
+7
View File
@@ -1433,8 +1433,15 @@ pub fn with_missing_appearances(doc: &Document) -> Option<Document> {
/// redrawing it. See [`AppearanceCache`] for when entries stay valid.
pub fn with_missing_appearances_cached(doc: &Document, cache: &mut AppearanceCache) -> Option<Document> {
let mut copy: Option<Document> = 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;
+22
View File
@@ -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]