mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-10 13:01:29 +00:00
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:
1 parent
87a1778c82
commit
665b361d5c
3 files changed
+59
-1
No files matched your search
@@ -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"),
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user