mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-10 13:01:29 +00:00
M5: a group member outlives its deleted primary without /IRT; /IRT walks tested past their bounds
Deleting a group's primary (delete_annotation, lift_annotation) or clearing a redaction mark with a group member (redact::clear_marks) now removes /IRT from each subordinate that stays and keeps its /RT /Group, as Acrobat desktop does. The kept member no longer refers to the deleted annotation, so a full save leaves the deleted one's content out. It is part of the same edit: one undo restores the group, /IRT included. lift_annotation's copy-only uses (the page beneath a dragged stamp or signature) only render the copy, so the change there is harmless. Each /IRT walk this PR relies on gets a test past its bound and a loop: group_primaries on an unsettled loop (render), comment_summary under a 100-deep chain of subordinates and a self-listed loop (engine), and deletion over a 200-deep thread and reply and group loops (annot). Review on #4358. 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
8845066ab7
commit
3602d9f2d9
8 files changed
+258
-8
No files matched your search
@@ -38,8 +38,8 @@ pub use lookup::{
|
||||
};
|
||||
pub(crate) use modify::n;
|
||||
pub use modify::{
|
||||
add_reply, add_text_replacement, deletable, delete_annotation, erase_ink, fillable, goes_with, lift_annotation, move_annotation, set_contents,
|
||||
set_fill, set_font_size, set_locked, set_marked, set_points, set_rect, set_review_state, set_style, with_replies_and_popups,
|
||||
add_reply, add_text_replacement, deletable, delete_annotation, erase_ink, fillable, goes_with, lift_annotation, move_annotation, release_members,
|
||||
set_contents, set_fill, set_font_size, set_locked, set_marked, set_points, set_rect, set_review_state, set_style, with_replies_and_popups,
|
||||
};
|
||||
pub use types::{
|
||||
AttachIcon, FillMark, LineEnding, Markup, Meta, NOTE_SIZE, NewAnnotation, NoteIcon, OverlayFont, OverlayLook, ReviewState, Rgb, Shape,
|
||||
|
||||
@@ -73,8 +73,9 @@ pub(crate) fn touch(d: &mut Dict, meta: &Meta) {
|
||||
/// Refused while it, or a reply that would go with it, is locked. A member of a group (`/RT
|
||||
/// /Group`), its primary included, goes alone, as Acrobat deletes the member clicked and keeps the
|
||||
/// rest of its group, although ISO 32000-2 §12.5.6.2 lists deletion among the operations that act
|
||||
/// on the entire group; a subordinate left without its primary is a comment of its own. Replace
|
||||
/// Text's strikeout and caret are one comment and go together from either ([`own_deletion`]).
|
||||
/// on the entire group; a subordinate left without its primary is a comment of its own and
|
||||
/// loses its `/IRT` ([`release_members`]). Replace Text's strikeout and caret are one comment and
|
||||
/// go together from either ([`own_deletion`]).
|
||||
pub fn delete_annotation(doc: &mut Document, page: usize, index: usize) -> Result<(), AnnotError> {
|
||||
let index = deleted_index(doc, page, index)?;
|
||||
lift_annotation(doc, page, index)
|
||||
@@ -133,9 +134,41 @@ pub fn lift_annotation(doc: &mut Document, page: usize, index: usize) -> Result<
|
||||
let target = list.get(index).ok_or(AnnotError::NoSuchAnnotation { page, index })?;
|
||||
let doomed = with_replies_and_popups(doc, &list, seed(target));
|
||||
let kept: Vec<Object> = list.iter().enumerate().filter(|(i, e)| *i != index && !goes_with(doc, e, &doomed)).map(|(_, e)| e.clone()).collect();
|
||||
let kept = release_members(doc, kept, &doomed)?;
|
||||
Ok(set_annots(doc, p, kept)?)
|
||||
}
|
||||
|
||||
/// The `/Annots` entries `kept` after deleting `going`, with every group subordinate that stays
|
||||
/// ([`own_deletion`]) but whose `/IRT` names an annotation going made a comment of its own: its
|
||||
/// `/IRT` is removed and its `/RT /Group` kept, as Acrobat does when a group's primary is deleted.
|
||||
/// A deleted primary then stays reachable from nothing, so a full save leaves its text out. An
|
||||
/// inline entry is rewritten in the list; a referenced one is updated in place. Each entry is
|
||||
/// read once, following no chain.
|
||||
pub fn release_members(doc: &mut Document, kept: Vec<Object>, going: &HashSet<ObjRef>) -> Result<Vec<Object>, pdfcraft_cos::CosError> {
|
||||
let orphaned = |d: &Dict| is_group_subordinate(d) && d.reference(b"IRT").is_some_and(|r| going.contains(&r));
|
||||
let mut out = Vec::with_capacity(kept.len());
|
||||
for e in kept {
|
||||
match e.as_ref() {
|
||||
Some(r) => {
|
||||
if doc.get(r).as_dict().is_some_and(orphaned) {
|
||||
doc.update_dict(r, |d| {
|
||||
d.remove(b"IRT");
|
||||
})?;
|
||||
}
|
||||
out.push(e);
|
||||
}
|
||||
None => match e {
|
||||
Object::Dict(mut d) if orphaned(&d) => {
|
||||
d.remove(b"IRT");
|
||||
out.push(Object::Dict(d));
|
||||
}
|
||||
other => out.push(other),
|
||||
},
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// What deleting the `/Annots` entry `target` starts from: its reference, or for an inline
|
||||
/// comment (which has none to collect) the pop-up it names, which still goes.
|
||||
fn seed(target: &Object) -> Option<ObjRef> {
|
||||
|
||||
@@ -987,6 +987,118 @@ fn a_group_reads_as_one_and_deletes_by_member() {
|
||||
assert!(member.parent.is_none() && !member.is_group_member(), "left without its primary, it is a comment of its own: {member:?}");
|
||||
}
|
||||
|
||||
/// A primary with one subordinate and one reply, all on page 0 of a reopened file. Returns the
|
||||
/// primary's and the subordinate's `/Annots` positions and references.
|
||||
fn group_of_two(text: &str) -> (Document, (usize, ObjRef), (usize, ObjRef)) {
|
||||
let mut doc = fixture();
|
||||
let primary = add_annotation(&mut doc, &new(0, Shape::Rectangle { rect: [100.0, 100.0, 200.0, 200.0] }), &meta("primary")).unwrap();
|
||||
let (_, pr) = annot_ref(&mut doc, 0, primary).unwrap();
|
||||
doc.update_dict(pr, |d| d.set(b"Contents".to_vec(), pdfcraft_cos::PdfString::text(text))).unwrap();
|
||||
let member = add_annotation(&mut doc, &new(0, Shape::Oval { rect: [300.0, 300.0, 350.0, 350.0] }), &meta("member")).unwrap();
|
||||
let (_, mr) = annot_ref(&mut doc, 0, member).unwrap();
|
||||
doc.update_dict(mr, |d| {
|
||||
d.set(b"IRT".to_vec(), Object::Ref(pr));
|
||||
d.set(b"RT".to_vec(), Object::name("Group"));
|
||||
})
|
||||
.unwrap();
|
||||
add_reply(&mut doc, 0, primary, "a reply", "Ada", &meta("reply")).unwrap();
|
||||
(reopen(&doc), (primary, pr), (member, mr))
|
||||
}
|
||||
|
||||
/// Deleting a group's primary leaves its subordinate a comment of its own, as Acrobat does: the
|
||||
/// subordinate loses its `/IRT` and keeps `/RT /Group`, so nothing refers to the deleted primary
|
||||
/// and a full save leaves its text out.
|
||||
#[test]
|
||||
fn deleting_a_primary_releases_its_subordinate() {
|
||||
let (mut doc, (primary, _), (_, mr)) = group_of_two("primary secret text");
|
||||
delete_annotation(&mut doc, 0, primary).unwrap();
|
||||
let member = doc.get(mr).as_dict().cloned().unwrap();
|
||||
assert!(member.get(b"IRT").is_none(), "the subordinate no longer names the deleted primary: {member:?}");
|
||||
assert_eq!(member.name(b"RT"), Some(&b"Group"[..]), "its /RT stays, as Acrobat leaves it");
|
||||
let s = summaries(&doc);
|
||||
let listed = s.iter().find(|s| s.name.as_deref() == Some("member")).unwrap();
|
||||
assert!(listed.parent.is_none() && listed.in_reply_to.is_none() && !listed.is_group_member(), "a comment of its own: {listed:?}");
|
||||
assert!(!s.iter().any(|s| s.name.as_deref() == Some("reply")), "the primary's reply went with it");
|
||||
let full = pdfcraft_cos::write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap();
|
||||
assert!(!full.windows(19).any(|w| w == b"primary secret text"), "the deleted primary's text is not in a full save");
|
||||
}
|
||||
|
||||
/// Deleting a subordinate leaves its primary as it was.
|
||||
#[test]
|
||||
fn deleting_a_subordinate_leaves_its_primary_untouched() {
|
||||
let (mut doc, (_, pr), (member, _)) = group_of_two("primary text");
|
||||
let before = doc.get(pr).as_dict().cloned();
|
||||
delete_annotation(&mut doc, 0, member).unwrap();
|
||||
assert_eq!(doc.get(pr).as_dict().cloned(), before);
|
||||
assert!(summaries(&doc).iter().any(|s| s.name.as_deref() == Some("reply")), "the primary keeps its reply");
|
||||
}
|
||||
|
||||
/// An inline subordinate (`/Annots` may hold a dictionary, though the standard asks for a
|
||||
/// reference) whose primary is deleted loses its `/IRT` in the page's list.
|
||||
#[test]
|
||||
fn an_inline_subordinate_is_released_too() {
|
||||
let mut doc = fixture();
|
||||
let primary = add_annotation(&mut doc, &new(0, Shape::Rectangle { rect: [100.0, 100.0, 200.0, 200.0] }), &meta("primary")).unwrap();
|
||||
let (_, pr) = annot_ref(&mut doc, 0, primary).unwrap();
|
||||
let p = page_refs(&doc).unwrap()[0];
|
||||
let mut entries = annots(&doc, p);
|
||||
let mut inline = Dict::new();
|
||||
inline.set(b"Type".to_vec(), Object::name("Annot"));
|
||||
inline.set(b"Subtype".to_vec(), Object::name("Square"));
|
||||
inline.set(b"Rect".to_vec(), Object::Array([300, 300, 350, 350].map(Object::Int).to_vec()));
|
||||
inline.set(b"IRT".to_vec(), Object::Ref(pr));
|
||||
inline.set(b"RT".to_vec(), Object::name("Group"));
|
||||
entries.push(Object::Dict(inline));
|
||||
set_annots(&mut doc, p, entries).unwrap();
|
||||
delete_annotation(&mut doc, 0, primary).unwrap();
|
||||
let left = list(&doc, 0);
|
||||
let inline = left.iter().find(|d| d.name(b"RT") == Some(b"Group")).expect("the inline subordinate stays");
|
||||
assert!(inline.get(b"IRT").is_none());
|
||||
}
|
||||
|
||||
/// Deleting follows `/IRT` replies by visiting each annotation once, with no step limit to cut a
|
||||
/// thread short: a thread of 200 replies (past the 64 steps the listings follow) goes whole, and
|
||||
/// replies or group members in a loop (`/IRT` back to each other) end the walk. A loop of group
|
||||
/// members deletes only the one picked; the other is released.
|
||||
#[test]
|
||||
fn deleting_follows_a_long_or_looping_irt_chain_once_each() {
|
||||
let mut doc = fixture();
|
||||
let top = add_annotation(&mut doc, &new(0, Shape::Rectangle { rect: [100.0, 100.0, 200.0, 200.0] }), &meta("top")).unwrap();
|
||||
let mut on = top;
|
||||
for k in 0..200 {
|
||||
on = add_reply(&mut doc, 0, on, "reply", "Ada", &meta(&format!("r{k}"))).unwrap();
|
||||
}
|
||||
let before = list(&doc, 0).len();
|
||||
delete_annotation(&mut doc, 0, top).unwrap();
|
||||
assert_eq!(list(&doc, 0).len(), before - 201, "the comment and its 200-deep thread go together");
|
||||
for rt in [None, Some("Group")] {
|
||||
let mut doc = fixture();
|
||||
let a = add_annotation(&mut doc, &new(0, Shape::Rectangle { rect: [100.0, 100.0, 200.0, 200.0] }), &meta("a")).unwrap();
|
||||
let b = add_annotation(&mut doc, &new(0, Shape::Oval { rect: [300.0, 300.0, 350.0, 350.0] }), &meta("b")).unwrap();
|
||||
let (_, ar) = annot_ref(&mut doc, 0, a).unwrap();
|
||||
let (_, br) = annot_ref(&mut doc, 0, b).unwrap();
|
||||
for (r, to) in [(ar, br), (br, ar)] {
|
||||
doc.update_dict(r, |d| {
|
||||
d.set(b"IRT".to_vec(), Object::Ref(to));
|
||||
if let Some(rt) = rt {
|
||||
d.set(b"RT".to_vec(), Object::name(rt));
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
let before = list(&doc, 0).len();
|
||||
delete_annotation(&mut doc, 0, a).unwrap();
|
||||
let left = list(&doc, 0);
|
||||
match rt {
|
||||
None => assert_eq!(left.len(), before - 2, "a reply loop goes whole"),
|
||||
Some(_) => {
|
||||
assert_eq!(left.len(), before - 1, "a group member goes alone");
|
||||
assert!(doc.get(br).as_dict().is_some_and(|d| d.get(b"IRT").is_none()), "the other member is released");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A subordinate deep in a chain of subordinates, within the 64 annotations the listing follows
|
||||
/// up a chain or past them, is deleted alone: the chain above it stays.
|
||||
#[test]
|
||||
|
||||
@@ -4428,6 +4428,70 @@ fn comment_summary_leaves_group_members_out_and_keeps_their_replies() {
|
||||
assert!(text.lines().any(|l| l == " REPLY_TO_MEMBER"), "the reply is the comment's, one level in: {text}");
|
||||
}
|
||||
|
||||
/// The summary follows `/IRT` down from a comment at most 64 steps, group subordinates included:
|
||||
/// a reply under a longer chain of subordinates is left out, one under a short chain is written,
|
||||
/// and a subordinate listed at its own primary's position (a loop, from a malformed listing)
|
||||
/// ends the walk.
|
||||
#[test]
|
||||
fn comment_summary_follows_a_group_chain_a_bounded_number_of_steps() {
|
||||
let (mut s, id) = session_with(1);
|
||||
s.apply(id, rect_comment(0, [40.0, 40.0, 90.0, 90.0])).unwrap();
|
||||
let top = s.get(id).unwrap().info.annotations.iter().find(|a| a.is_comment()).unwrap().clone();
|
||||
let at = |a: &pdfcraft_render::Annotation| (a.page, a.index);
|
||||
let under = |index: usize, parent: (usize, usize), grouped: bool, text: &str| pdfcraft_render::Annotation {
|
||||
index,
|
||||
parent: Some(parent),
|
||||
grouped,
|
||||
contents: Some(text.into()),
|
||||
..top.clone()
|
||||
};
|
||||
for (members, written) in [(10, true), (100, false)] {
|
||||
let mut all = vec![top.clone()];
|
||||
let mut up = at(&top);
|
||||
for k in 0..members {
|
||||
let m = under(1000 + k, up, true, "MEMBER");
|
||||
up = at(&m);
|
||||
all.push(m);
|
||||
}
|
||||
all.push(under(5000, up, false, "DEEP_REPLY"));
|
||||
let text = comment_summary("doc.pdf", &all, SummarySort::Page);
|
||||
assert_eq!(text.contains("DEEP_REPLY"), written, "{members}: {text}");
|
||||
assert!(!text.contains("MEMBER"), "{members}: {text}");
|
||||
}
|
||||
let looped = vec![top.clone(), under(top.index, at(&top), true, "LOOP")];
|
||||
let text = comment_summary("doc.pdf", &looped, SummarySort::Page);
|
||||
assert!(!text.contains("LOOP"), "{text}");
|
||||
}
|
||||
|
||||
/// Deleting a group's primary removes the subordinate's `/IRT` in the same edit, as Acrobat does:
|
||||
/// the subordinate lists as a comment of its own and keeps `/RT /Group`. One undo puts the group
|
||||
/// back, `/IRT` included.
|
||||
#[test]
|
||||
fn deleting_a_group_primary_releases_its_member_in_one_undo_step() {
|
||||
let bytes = pdf_with_annots(&[
|
||||
b"<< /Type /Annot /Subtype /Square /Rect [10 10 50 50] /NM (primary) /Contents (PRIMARY_TEXT) >>",
|
||||
b"<< /Type /Annot /Subtype /Circle /Rect [60 60 90 90] /NM (member) /IRT 4 0 R /RT /Group >>",
|
||||
]);
|
||||
let mut s = Session::new().with_clock(|| 1_700_000_000);
|
||||
let id = s.open("group.pdf", None, Arc::new(bytes), None).unwrap();
|
||||
let names = |s: &Session| -> Vec<(Option<String>, bool, bool)> {
|
||||
s.get(id).unwrap().info.annotations.iter().map(|a| (a.name.clone(), a.is_comment(), a.is_group_member())).collect()
|
||||
};
|
||||
// Listed by top edge: the Circle is the higher one.
|
||||
let grouped = vec![(Some("member".into()), false, true), (Some("primary".into()), true, false)];
|
||||
assert_eq!(names(&s), grouped);
|
||||
s.apply(id, Edit::DeleteAnnotation { page: 0, index: 0 }).unwrap();
|
||||
assert_eq!(names(&s), [(Some("member".into()), true, false)], "a comment of its own");
|
||||
let member = |s: &Session| {
|
||||
let cos = &s.get(id).unwrap().editor.as_ref().unwrap().cos;
|
||||
cos.get(pdfcraft_cos::ObjRef::new(5, 0)).as_dict().cloned().unwrap()
|
||||
};
|
||||
assert!(member(&s).get(b"IRT").is_none() && member(&s).name(b"RT") == Some(b"Group".as_slice()), "{:?}", member(&s));
|
||||
s.undo(id).unwrap();
|
||||
assert_eq!(names(&s), grouped, "one undo puts the group back");
|
||||
assert!(member(&s).reference(b"IRT").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reading_colors_set_and_invalidated() {
|
||||
let (mut s, id) = session_with(1);
|
||||
|
||||
@@ -753,9 +753,13 @@ pub fn clear_marks(doc: &mut Document, pages: Option<&[usize]>) -> Result<usize,
|
||||
for p in pdfcraft_model::pages(doc) {
|
||||
let annots = annots_of(doc, &p.dict);
|
||||
let kept: Vec<Object> = annots.iter().filter(|a| !pdfcraft_annot::goes_with(doc, a, &doomed)).cloned().collect();
|
||||
// A group subordinate of a mark stays, as when a comment's group primary is deleted, and
|
||||
// loses its `/IRT`: through it the mark would stay in the file.
|
||||
let kept = pdfcraft_annot::release_members(doc, kept, &doomed)?;
|
||||
// As deleting a comment does: a page whose marks were all it had loses `/Annots` again,
|
||||
// and an indirect array stays the one object the page points to.
|
||||
if kept.len() != annots.len() {
|
||||
// and an indirect array stays the one object the page points to. A page whose inline
|
||||
// member lost its `/IRT` is written too.
|
||||
if kept != annots {
|
||||
pdfcraft_annot::set_annots(doc, p.obj, kept)?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1393,6 +1393,34 @@ fn clearing_marks_leaves_the_annotation_list_as_it_was() {
|
||||
assert_eq!((doc.get(page), doc.get(list)), before, "the page points at its array, which lists the link alone");
|
||||
}
|
||||
|
||||
/// A group subordinate (`/RT /Group`) of a mark stays when the marks are cleared, as one stays
|
||||
/// when its primary comment is deleted, and loses its `/IRT`: nothing then refers to the mark,
|
||||
/// so a full save leaves the mark's text out.
|
||||
#[test]
|
||||
fn clearing_marks_releases_a_group_member_of_a_mark() {
|
||||
let mut doc = one_page(b"BT /F1 10 Tf 10 100 Td (AB) Tj ET", "", vec![]);
|
||||
mark(&mut doc, 0, &[[0.0, 0.0, 50.0, 50.0]], "");
|
||||
let m = marks(&doc)[0].obj;
|
||||
doc.update_dict(m, |d| d.set(b"Contents".to_vec(), pdfcraft_cos::PdfString::text("MARK_SECRET_TEXT"))).unwrap();
|
||||
let mut member = Dict::new();
|
||||
member.set(b"Type".to_vec(), Object::name("Annot"));
|
||||
member.set(b"Subtype".to_vec(), Object::name("Square"));
|
||||
member.set(b"Rect".to_vec(), Object::Array([60, 60, 90, 90].map(Object::Int).to_vec()));
|
||||
member.set(b"IRT".to_vec(), Object::Ref(m));
|
||||
member.set(b"RT".to_vec(), Object::name("Group"));
|
||||
let member = doc.add(Object::Dict(member));
|
||||
let page = pdfcraft_model::pages(&doc)[0].obj;
|
||||
let mut listed = annots_of(&doc, doc.get(page).as_dict().unwrap());
|
||||
listed.push(Object::Ref(member));
|
||||
doc.update_dict(page, |d| d.set(b"Annots".to_vec(), Object::Array(listed))).unwrap();
|
||||
assert_eq!(clear_marks(&mut doc, None), Ok(1));
|
||||
assert_eq!(annots_of(&doc, doc.get(page).as_dict().unwrap()), [Object::Ref(member)], "the member stays");
|
||||
let d = doc.get(member).as_dict().cloned().unwrap();
|
||||
assert!(d.get(b"IRT").is_none() && d.name(b"RT") == Some(b"Group".as_slice()), "{d:?}");
|
||||
let full = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap();
|
||||
assert!(!full.windows(16).any(|w| w == b"MARK_SECRET_TEXT"), "the cleared mark is not in a full save");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unreadable_content_fails_closed() {
|
||||
let mut doc = one_page(b"", "", vec![]);
|
||||
|
||||
@@ -1935,6 +1935,15 @@ mod group_tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// `group_primaries` is public and may be handed links `link_replies` has not settled: a loop
|
||||
/// of subordinates (each other's `/IRT`, or its own) still ends, within `MAX_GROUP_DEPTH`
|
||||
/// steps, with no primary.
|
||||
#[test]
|
||||
fn group_primaries_end_on_an_unsettled_loop() {
|
||||
let all = vec![annot(0, Some(1), true), annot(1, Some(0), true), annot(2, Some(2), true), annot(3, None, false)];
|
||||
assert_eq!(group_primaries(&all), [None, None, None, None]);
|
||||
}
|
||||
|
||||
fn with(mut a: Annotation, subtype: &str, t: Option<&str>) -> Annotation {
|
||||
a.subtype = subtype.into();
|
||||
a.contents = t.map(Into::into);
|
||||
|
||||
@@ -3595,8 +3595,8 @@ milestone = "M3"
|
||||
status = "shipped"
|
||||
commands = ["comment.list"]
|
||||
tools = ["comment_list"]
|
||||
evidence = ["crates/ui-egui/tests/it/ui.rs::opening_a_pdf_shows_comments_and_bookmarks", "crates/ui-egui/tests/it/comments.rs::the_panel_posts_comments_and_replies", "crates/ui-egui/tests/it/comments.rs::page_groups_collapse_and_expand", "crates/ui-egui/tests/it/comments.rs::replies_follow_irt_references_when_names_repeat_on_every_page", "crates/engine/src/tests.rs::replies_thread_by_reference_when_names_repeat_across_pages", "crates/automation/tests/it/automation.rs::replies_thread_by_reference_when_ids_repeat_across_pages", "crates/ui-egui/tests/it/comments.rs::a_group_is_one_comment_in_the_panel_and_on_the_page", "crates/automation/tests/it/automation.rs::a_group_lists_as_its_primary", "crates/automation/tests/it/automation.rs::a_group_member_on_another_page_is_a_comment_of_its_own", "crates/automation/tests/it/automation.rs::looping_groups_end_and_chained_groups_reach_their_primary", "crates/render/src/inspect/tests.rs::group_primaries_follow_irt_to_the_first_non_subordinate"]
|
||||
notes = "cards grouped by page with replies (threaded by the `/IRT` reference, so files whose `/NM` repeats per page, as PyMuPDF writes them, thread correctly; the header counts comments without replies, as Acrobat does), status badges, selection, reply box and an Add a comment box; page groups fold and unfold from their chevron or with Collapse/Expand all pages in the panel's \"…\" menu (a comment selected on a folded page opens its group). Groups by author, type or colour don't fold yet. Annotation groups (`/RT /Group`, ISO 32000-2 §12.5.6.2) list as one comment with the primary's author, text and colour (the subordinate's own are ignored), a reply to a subordinate joins the primary's thread, `comment_list` names the members in `group`, clicking a subordinate on the page selects the group, and Delete deletes the member clicked alone, the primary included, as Acrobat desktop does (one undo step; a subordinate left without its primary is a comment of its own; Replace Text's strikeout and caret delete together). ISO 32000-2 §12.5.6.2 has deletion act on the whole group, as PDF-XChange and Foxit do on one page; Acrobat's is followed as the more practical. A group is one page's: a subordinate whose primary is on another page is a comment of its own, listed, selected, moved and deleted on its own, as Acrobat (desktop and iPad), PDF-XChange and Foxit read it. Missing: restyling a member does not restyle its group as Acrobat does, a subordinate can't be dragged, resized or reshaped on its own, and it has no hover tooltip"
|
||||
evidence = ["crates/ui-egui/tests/it/ui.rs::opening_a_pdf_shows_comments_and_bookmarks", "crates/ui-egui/tests/it/comments.rs::the_panel_posts_comments_and_replies", "crates/ui-egui/tests/it/comments.rs::page_groups_collapse_and_expand", "crates/ui-egui/tests/it/comments.rs::replies_follow_irt_references_when_names_repeat_on_every_page", "crates/engine/src/tests.rs::replies_thread_by_reference_when_names_repeat_across_pages", "crates/automation/tests/it/automation.rs::replies_thread_by_reference_when_ids_repeat_across_pages", "crates/ui-egui/tests/it/comments.rs::a_group_is_one_comment_in_the_panel_and_on_the_page", "crates/automation/tests/it/automation.rs::a_group_lists_as_its_primary", "crates/automation/tests/it/automation.rs::a_group_member_on_another_page_is_a_comment_of_its_own", "crates/automation/tests/it/automation.rs::looping_groups_end_and_chained_groups_reach_their_primary", "crates/render/src/inspect/tests.rs::group_primaries_follow_irt_to_the_first_non_subordinate", "crates/annot/src/tests.rs::deleting_a_primary_releases_its_subordinate", "crates/engine/src/tests.rs::deleting_a_group_primary_releases_its_member_in_one_undo_step"]
|
||||
notes = "cards grouped by page with replies (threaded by the `/IRT` reference, so files whose `/NM` repeats per page, as PyMuPDF writes them, thread correctly; the header counts comments without replies, as Acrobat does), status badges, selection, reply box and an Add a comment box; page groups fold and unfold from their chevron or with Collapse/Expand all pages in the panel's \"…\" menu (a comment selected on a folded page opens its group). Groups by author, type or colour don't fold yet. Annotation groups (`/RT /Group`, ISO 32000-2 §12.5.6.2) list as one comment with the primary's author, text and colour (the subordinate's own are ignored), a reply to a subordinate joins the primary's thread, `comment_list` names the members in `group`, clicking a subordinate on the page selects the group, and Delete deletes the member clicked alone, the primary included, as Acrobat desktop does (one undo step; a subordinate left without its primary is a comment of its own and loses its `/IRT`, keeping `/RT /Group` as Acrobat does, so a full save leaves the deleted primary out; clearing redaction marks does the same for a mark's group member; Replace Text's strikeout and caret delete together). ISO 32000-2 §12.5.6.2 has deletion act on the whole group, as PDF-XChange and Foxit do on one page; Acrobat's is followed as the more practical. A group is one page's: a subordinate whose primary is on another page is a comment of its own, listed, selected, moved and deleted on its own, as Acrobat (desktop and iPad), PDF-XChange and Foxit read it. Missing: restyling a member does not restyle its group as Acrobat does, a subordinate can't be dragged, resized or reshaped on its own, and it has no hover tooltip"
|
||||
|
||||
[[feature]]
|
||||
id = "comment.appearance-generation"
|
||||
|
||||
Reference in new issue
Block a user