From e0c319d2730aba8d9d28aee1af5b746b1ebc86cb Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 23:18:59 +0000 Subject: [PATCH] Integration: a comment listed through a reference chain keeps its replies after an edit (#4061) --- crates/annot/src/lib.rs | 21 +++++++++++++++++---- crates/engine/src/tests.rs | 31 +++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 4 deletions(-) diff --git a/crates/annot/src/lib.rs b/crates/annot/src/lib.rs index bd0d85cf23..6c9fc5dce8 100644 --- a/crates/annot/src/lib.rs +++ b/crates/annot/src/lib.rs @@ -2328,6 +2328,18 @@ pub fn is_comment_subtype(subtype: &str) -> bool { !matches!(subtype, "Link" | "Widget" | "Popup" | "Screen" | "Movie" | "RichMedia" | "3D" | "PrinterMark" | "TrapNet" | "Watermark") } +/// Where a reference to a reference (`5 0 obj 6 0 R endobj`) ends, as `pdfcraft_render::inspect` +/// follows it, within a few steps (a cycle stops there). +fn final_ref(doc: &Document, mut r: ObjRef) -> ObjRef { + for _ in 0..8 { + match &*doc.get(r) { + Object::Ref(next) => r = *next, + _ => break, + } + } + r +} + /// Every comment (see [`is_comment_subtype`]), ordered by page and then top edge. pub fn summaries(doc: &Document) -> Vec { let mut out = Vec::new(); @@ -2338,13 +2350,13 @@ pub fn summaries(doc: &Document) -> Vec { for (page, list) in lists.iter().enumerate() { for (index, entry) in list.iter().enumerate() { if let Some(r) = entry.as_ref() { - slots.entry(r).or_insert((page, index)); + slots.entry(final_ref(doc, r)).or_insert((page, index)); } } } for (page, list) in lists.iter().enumerate() { for (index, entry) in list.iter().enumerate() { - let obj = doc.resolve(entry); + let obj = entry.as_ref().map_or_else(|| doc.resolve(entry), |r| doc.get(final_ref(doc, r))); let Some(d) = obj.as_dict() else { continue }; let Some(subtype) = d.name(b"Subtype").map(|s| String::from_utf8_lossy(s).into_owned()) else { continue }; if !is_comment_subtype(&subtype) { @@ -2378,8 +2390,9 @@ pub fn summaries(doc: &Document) -> Vec { "PolyLine" | "Polygon" => nums(b"Vertices").as_chunks::<2>().0.to_vec(), _ => Vec::new(), }; - let in_reply_to = d.get(b"IRT").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()).and_then(|p| text_value(doc, &p, b"NM")); - let parent = d.get(b"IRT").and_then(Object::as_ref).and_then(|r| slots.get(&r).copied()); + let irt = d.get(b"IRT").map(|o| o.as_ref().map_or_else(|| doc.resolve(o), |r| doc.get(final_ref(doc, r)))); + let in_reply_to = irt.and_then(|o| o.as_dict().cloned()).and_then(|p| text_value(doc, &p, b"NM")); + let parent = d.get(b"IRT").and_then(Object::as_ref).and_then(|r| slots.get(&final_ref(doc, r)).copied()); out.push(Summary { page, index, diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 9be485ba97..c87b7cbc7b 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -3839,6 +3839,37 @@ trailer << /Root 1 0 R >> check(&s, 2); } +/// A comment listed through a reference chain (`/Annots [5 0 R]`, `5 0 obj 6 0 R`) keeps its +/// replies after an edit, whether their `/IRT` names the comment or the chain, as on open. +#[test] +fn replies_thread_through_a_reference_chain_after_an_edit() { + let pdf = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [5 0 R 7 0 R 8 0 R 9 0 R] >> endobj +5 0 obj 6 0 R endobj +6 0 obj << /Type /Annot /Subtype /Text /Rect [20 140 40 160] /NM (one) /Contents (CHAINED) >> endobj +7 0 obj << /Type /Annot /Subtype /Text /Rect [20 120 40 130] /IRT 6 0 R /Contents (TO THE COMMENT) >> endobj +8 0 obj << /Type /Annot /Subtype /Text /Rect [20 100 40 110] /IRT 5 0 R /Contents (THROUGH THE CHAIN) >> endobj +9 0 obj << /Type /Annot /Subtype /Text /Rect [20 60 40 80] /NM (two) /Contents (PLAIN) >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("chain.pdf", None, Arc::new(pdf.to_vec()), None).expect("opens"); + let check = |s: &Session| { + let all = &s.get(id).unwrap().info.annotations; + let comment = all.iter().find(|a| a.contents.as_deref() == Some("CHAINED")).expect("listed"); + assert_eq!((comment.page, comment.index, comment.is_reply()), (0, 0, false), "{all:?}"); + for reply in ["TO THE COMMENT", "THROUGH THE CHAIN"] { + let a = all.iter().find(|a| a.contents.as_deref() == Some(reply)).expect("listed"); + assert_eq!((a.parent, a.in_reply_to.as_deref()), (Some((0, 0)), Some("one")), "{reply}: {all:?}"); + } + }; + check(&s); + s.apply(id, Edit::ReplyToAnnotation { page: 0, index: 3, text: "LATER".into(), author: "Delta".into() }).unwrap(); + check(&s); +} + /// #820: the generated summary includes replies to replies, indented one level deeper. #[test] fn comment_summary_includes_replies_to_replies() {