M5: a group is one page's, as Acrobat reads it

A subordinate (/RT /Group) whose /IRT primary is on another page is now a
comment of its own, listed with its own author and text, rather than part
of that group. ISO 32000-2 §12.5.6.2 doesn't limit a group to a page, but
Acrobat (desktop and iPad) and PDF-XChange all keep such a subordinate when
the primary is deleted, and Acrobat desktop lists it on its own; tested on
a synthetic two-page file. pdfcraft_render::link_replies and
pdfcraft_annot::summaries both apply the rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
This commit is contained in:
mtlcl410
2026-10-31 17:04:57 +08:00
co-authored by Claude Opus 5.5
parent 87a1778c82
commit 665b361d5c
3 changed files with 59 additions and 1 deletions
+7 -1
View File
@@ -165,6 +165,12 @@ pub fn summaries(doc: &Document) -> Vec<Summary> {
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());
// A group is one page's, as Acrobat reads it: a subordinate whose primary is on
// another page is a comment of its own (keep in step with
// `pdfcraft_render::link_replies`).
let grouped = d.name(b"RT") == Some(b"Group");
let cross_page = grouped && parent.is_some_and(|(p, _)| p != page);
let (parent, in_reply_to) = if cross_page { (None, None) } else { (parent, in_reply_to) };
out.push(Summary {
page,
index,
@@ -177,7 +183,7 @@ pub fn summaries(doc: &Document) -> Vec<Summary> {
name: text_value(doc, d, b"NM"),
in_reply_to,
parent,
grouped: d.name(b"RT") == Some(b"Group"),
grouped,
rect,
color,
state: text_value(doc, d, b"State"),
+40
View File
@@ -10361,3 +10361,43 @@ fn simple_rule_scan_drawn(content: &[u8]) -> Vec<u8> {
}
write_full(&doc, &SaveOptions::default()).unwrap()
}
// Contributor-original PDF: a Square on page 1 and, on page 2, a Circle with `/IRT` to it and
// `/RT /Group`.
const SPREAD_GROUP_FIXTURE: &[u8] = b"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [5 0 R] >> endobj
4 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [6 0 R] >> endobj
5 0 obj << /Type /Annot /Subtype /Square /Rect [20 20 100 80] /NM (sq-primary) /T (Alice) /Contents (Primary) /C [1 0 0] >> endobj
6 0 obj << /Type /Annot /Subtype /Circle /Rect [180 120 260 180] /NM (circ-other-page) /T (Bob) /Contents (Member on page 2) /C [0 0 1] /IRT 5 0 R /RT /Group >> endobj
trailer << /Root 1 0 R >>
%%EOF";
/// A group is one page's, as Acrobat reads it: a subordinate whose primary is on another page
/// lists as a comment of its own, with its own author and text, not as part of the group or a
/// reply, and deleting the primary leaves it. ISO 32000-2 §12.5.6.2 doesn't limit a group to a
/// page; Acrobat (desktop and iPad) and PDF-XChange all delete the primary without such a
/// subordinate, and Acrobat desktop lists it on its own.
#[test]
fn a_group_member_on_another_page_is_a_comment_of_its_own() {
let dir = workdir("rt-group-spread");
std::fs::write(dir.join("group.pdf"), SPREAD_GROUP_FIXTURE).unwrap();
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({ "path": "group.pdf" }))["doc"].as_u64().unwrap();
let check = |list: &Value, when: &str| {
assert_eq!(list["count"], 2, "{when}: the Square and the Circle, each a comment: {list}");
let circle = list["comments"].as_array().unwrap().iter().find(|c| c["id"] == "circ-other-page").cloned().unwrap();
assert_eq!((circle["author"].as_str(), circle["contents"].as_str()), (Some("Bob"), Some("Member on page 2")), "{when}");
let square = list["comments"].as_array().unwrap().iter().find(|c| c["id"] == "sq-primary").cloned().unwrap();
assert!(square["group"].as_array().unwrap().is_empty() && square["replies"].as_array().unwrap().is_empty(), "{when}: {square}");
};
check(&ok(&mut a, "comment_list", json!({ "doc": doc })), "opened");
ok(&mut a, "comment_add", json!({ "doc": doc, "page": 1, "type": "note", "at": [280, 20], "contents": "Other" }));
let after = ok(&mut a, "comment_list", json!({ "doc": doc }));
let kept: Vec<Value> = after["comments"].as_array().unwrap().iter().filter(|c| c["contents"] != "Other").cloned().collect();
check(&json!({ "count": kept.len(), "comments": kept }), "edited");
ok(&mut a, "comment_delete", json!({ "doc": doc, "id": "sq-primary" }));
let left = ok(&mut a, "comment_list", json!({ "doc": doc }));
assert!(left.to_string().contains("circ-other-page"), "deleting the primary leaves the Circle on page 2: {left}");
}
+12
View File
@@ -517,6 +517,18 @@ pub fn link_replies(annotations: &mut [Annotation]) {
a.parent = None;
}
}
// A group is one page's (Acrobat reads it so): a subordinate whose primary is on another page
// is a comment of its own, with its own author and text, rather than part of that group or a
// reply to it. ISO 32000-2 §12.5.6.2 doesn't limit a group to a page, but Acrobat (desktop and
// iPad) and PDF-XChange all delete the primary without such a subordinate, and Acrobat desktop
// lists it on its own. Keep in step with
// `pdfcraft_annot::summaries`.
for a in annotations.iter_mut() {
if a.grouped && a.parent.is_some_and(|(page, _)| page != a.page) {
a.parent = None;
a.in_reply_to = None;
}
}
}
/// The comment each reply's thread starts from, by the reply's page and `/Annots` position: a