From 7c3dfe0a2aabbbfc9dcf4cac1f2bbf158f7d89fe Mon Sep 17 00:00:00 2001 From: alehel Date: Fri, 23 Oct 2026 09:41:21 +0200 Subject: [PATCH] test(render): a reply finds its comment through a reference chain and a shared /Annots array Rebasing onto main met its annotation listing fixes (c82dc91a, dfde9078): inspect now lists each annotation once, at the first page and position that lists it, told apart by the object its reference chain ends at, and reads a page-shared /Annots array under its first page only. The rebase collects reply slots in that same pass and matches /IRT by the object it resolves to, so a reply finds its comment wherever the listing keeps it. The test fails with the slots keyed by the raw /Annots reference (as before the rebase) and with /IRT matched by its raw reference. Co-Authored-By: Claude Opus 5.5 --- crates/render/src/inspect.rs | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/crates/render/src/inspect.rs b/crates/render/src/inspect.rs index 15e10898c..0840e8938 100644 --- a/crates/render/src/inspect.rs +++ b/crates/render/src/inspect.rs @@ -2940,6 +2940,39 @@ trailer << /Root 1 0 R >> } } + /// A reply finds its comment where the listing keeps it: on the first page of a page-shared + /// `/Annots` array, whether its `/IRT` names the comment or an object holding a reference to it. + #[test] + fn a_reply_finds_its_comment_through_a_reference_chain_and_a_shared_array() { + 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 7 0 R >> endobj\n\ + 5 0 obj 6 0 R endobj\n\ + 6 0 obj << /Type /Annot /Subtype /Text /Rect [0 0 1 1] /Contents (comment) >> endobj\n\ + 7 0 obj [5 0 R 8 0 R 9 0 R] endobj\n\ + 8 0 obj << /Type /Annot /Subtype /Text /Rect [0 0 1 1] /Contents (to the comment) /IRT 6 0 R >> endobj\n\ + 9 0 obj << /Type /Annot /Subtype /Text /Rect [0 0 1 1] /Contents (through the chain) /IRT 5 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); + let notes = &info.annotations; + assert_eq!(notes.len(), 3, "{notes:?}"); + let comment = notes.iter().find(|a| a.contents.as_deref() == Some("comment")).expect("listed"); + assert_eq!((comment.page, comment.index, comment.is_reply()), (0, 0, false)); + for reply in ["to the comment", "through the chain"] { + let a = notes.iter().find(|a| a.contents.as_deref() == Some(reply)).expect("listed"); + assert_eq!((a.page, a.parent), (0, Some((0, 0))), "{reply}"); + } + } + } + /// A conversion rejected as too long, or trimmed short or to nothing, still spends the raw /// length, so a string many entries share is converted only a few times. #[test]