M5: a group restyle reaches the group the comment list shows: loops, missing primaries and deep chains are tested

group_members now follows the comment list's rules (pdfcraft_render::link_replies
and group_primaries), so a restyle never reaches an annotation listed on its own:
- a loop in /IRT, or a chain past MAX_GROUP_DEPTH links, leaves the annotation a
  comment of its own (it used to group a loop's members, and refused a deep chain);
- an /IRT to the annotation itself is no link;
- links, widgets and pop-ups are neither primaries nor subordinates (a locked link
  with /RT /Group used to refuse a restyle);
- subordinates are collected down to the depth the list follows.

The module comment no longer says moving and deleting go through group_members:
they act on the member asked for, as Acrobat desktop does.

Tests: a group of three, a loop (with an /IRT to itself and a loop through a plain
reply), a missing primary (a dangling /IRT, the page, a link), other /RT values, a
bounded chain, one undo step for a group restyle, and every annotation of a mixed
page checked against the comment list's groups.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
This commit is contained in:
mtlcl410andClaude Opus 5.5 committed 2026-11-01 15:36:16 +08:00
1 parent a3f5d3e3ba
commit 681eb48bf3
3 files changed
+378 -49

No files matched your search

+82 -49
View File
@@ -1,88 +1,121 @@
//! Annotation groups (ISO 32000-2 §12.5.6.2): a primary and the subordinates whose `/IRT` leads
//! to it with `/RT /Group`. "Operations that manipulate any annotation in a group, such as
//! movement, cut, and copy, shall be treated by interactive PDF processors as acting on the entire
//! group", so moving, restyling or deleting a member goes through [`group_members`].
//! group". PdfCraft follows Acrobat desktop instead, where the two differ: selecting any member
//! selects the group, and colour, opacity, width and fill restyle the whole group through
//! [`group_members`]; moving, resizing and deleting act only on the member asked for, the primary
//! included (`move_annotation`, `set_rect`, `set_points` and `delete_annotation` in `modify.rs`).
//! Replace Text's strikeout and caret are deleted together.
//!
//! A group is one page's, as Acrobat reads it: a subordinate whose primary is on another page is
//! a comment of its own. The standard doesn't limit a group to a page, but Acrobat (desktop and
//! iPad) and PDF-XChange all delete a primary without its subordinates on other pages, and
//! Acrobat desktop lists such a subordinate on its own. Keep in step with
//! `pdfcraft_render::link_replies` and `pdfcraft_annot::summaries`.
//! `pdfcraft_render::link_replies`, `pdfcraft_render::group_primaries` and
//! `pdfcraft_annot::summaries`: a restyle reaches exactly the members the comment list shows.
use std::collections::{HashMap, HashSet};
use pdfcraft_cos::{Document, ObjRef, Object};
use pdfcraft_cos::{Dict, Document, ObjRef, Object};
use crate::AnnotError;
use crate::info::{MAX_GROUP_DEPTH, final_ref, is_group_subordinate};
use crate::info::{MAX_GROUP_DEPTH, final_ref, is_comment_subtype, is_group_subordinate};
use crate::lookup::{annots, page_ref};
/// Whether `d` is listed as a comment (or a reply, or a group member), as the comment list reads
/// it: links, form widgets, pop-ups and other non-markup annotations are not, so an `/IRT` to one
/// of them leads nowhere.
fn listed(d: &Dict) -> bool {
d.name(b"Subtype").is_some_and(|s| is_comment_subtype(&String::from_utf8_lossy(s)))
}
/// Every member of the group the annotation at `(page, index)` belongs to, as `(page, index)`
/// positions on that page: the primary first, then its subordinates (and theirs) in `/Annots`
/// order. An annotation in no group is its own group of one. The primary is found up the `/IRT`
/// chain while it leads to another annotation on the same page; a loop, or an `/IRT` to an
/// annotation on another page or to nothing listed, ends it there. A chain still on a
/// subordinate after [`MAX_GROUP_DEPTH`] annotations is refused: acting on part of a group would
/// split it.
/// order. An annotation in no group is its own group of one.
///
/// The group is the one the comment list shows (`pdfcraft_render::link_replies` and
/// `group_primaries`), so a restyle never reaches an annotation listed on its own:
/// - The `/IRT` chain is followed up through the comments on the page, whatever their `/RT`. An
/// `/IRT` to the annotation itself, to one on another page, to a link, widget or pop-up, or to
/// nothing listed ends it there: that annotation has no primary above it.
/// - The primary is the first annotation up the chain that is not a subordinate (`/RT /Group`
/// with somewhere to lead).
/// - A chain that loops, or runs past [`MAX_GROUP_DEPTH`] links, leaves the annotation a comment
/// of its own, as the list shows it; so does a primary more than `MAX_GROUP_DEPTH - 1` links up.
/// - Subordinates are collected down from the primary to the same depths, each visited once.
pub fn group_members(doc: &Document, page: usize, index: usize) -> Result<Vec<(usize, usize)>, AnnotError> {
let list: Vec<Object> = annots(doc, page_ref(doc, page)?);
let entry = list.get(index).ok_or(AnnotError::NoSuchAnnotation { page, index })?;
// An inline annotation has no reference for an `/IRT` to name, and none of its own to follow
// from unless it is a subordinate; an inline subordinate is found from its primary below.
let start = match entry.as_ref() {
Some(r) => final_ref(doc, r),
None if entry.as_dict().is_some_and(is_group_subordinate) => match entry.as_dict().and_then(|d| d.reference(b"IRT")) {
Some(irt) => final_ref(doc, irt),
None => return Ok(vec![(page, index)]),
},
None => return Ok(vec![(page, index)]),
};
// Where each referenced annotation is first listed on the page.
let alone = vec![(page, index)];
// Where each referenced comment is first listed on the page, where its own `/IRT` leads, and
// whether it is a subordinate; and the subordinates hanging off each comment, in `/Annots`
// order. An inline entry has no reference to be found by, but it can hang off one.
let mut slots: HashMap<ObjRef, usize> = HashMap::new();
// The subordinates on the page hanging off each annotation by `/IRT`, in `/Annots` order.
let mut up: HashMap<ObjRef, (ObjRef, bool)> = HashMap::new();
let mut under: HashMap<ObjRef, Vec<(usize, Option<ObjRef>)>> = HashMap::new();
for (i, e) in list.iter().enumerate() {
let obj = doc.resolve(e);
let Some(d) = obj.as_dict().filter(|d| listed(d)) else { continue };
let own = e.as_ref().map(|r| final_ref(doc, r));
// An `/IRT` to itself is no link (`link_replies` drops it).
let irt = d.reference(b"IRT").map(|r| final_ref(doc, r)).filter(|t| Some(*t) != own);
if let Some(r) = own {
slots.entry(r).or_insert(i);
}
let obj = doc.resolve(e);
if let Some(irt) = obj.as_dict().filter(|d| is_group_subordinate(d)).and_then(|d| d.reference(b"IRT")) {
under.entry(final_ref(doc, irt)).or_default().push((i, own));
}
}
if !slots.contains_key(&start) {
return Ok(vec![(page, index)]);
}
let mut primary = start;
let mut seen = HashSet::from([start]);
let mut found = false;
for _ in 0..MAX_GROUP_DEPTH {
let obj = doc.get(primary);
let next = obj.as_dict().filter(|d| is_group_subordinate(d)).and_then(|d| d.reference(b"IRT")).map(|r| final_ref(doc, r));
match next {
Some(n) if slots.contains_key(&n) && seen.insert(n) => primary = n,
_ => {
found = true;
break;
if let Some(t) = irt {
up.entry(r).or_insert((t, is_group_subordinate(d)));
}
}
if let Some(t) = irt.filter(|_| is_group_subordinate(d)) {
under.entry(t).or_default().push((i, own));
}
}
if !found {
return Err(AnnotError::Invalid(format!(
"this comment's group can't be found: its primary is more than {MAX_GROUP_DEPTH} comments up its chain"
)));
let obj = doc.resolve(entry);
let Some(d) = obj.as_dict().filter(|d| listed(d)) else { return Ok(alone) };
let own = entry.as_ref().map(|r| final_ref(doc, r));
// The chain up from the entry: each link and whether the annotation it leaves is a
// subordinate. It settles when it reaches an annotation with no link.
let mut links: Vec<(ObjRef, bool)> = Vec::new();
let mut seen: HashSet<ObjRef> = own.into_iter().collect();
let mut next = d.reference(b"IRT").map(|r| final_ref(doc, r)).filter(|t| Some(*t) != own).map(|t| (t, is_group_subordinate(d)));
while let Some((n, grouped)) = next.filter(|(n, _)| slots.contains_key(n)) {
if !seen.insert(n) || links.len() >= MAX_GROUP_DEPTH {
// A loop, or a chain past the depth limit: the list leaves it a comment of its own.
return Ok(alone);
}
links.push((n, grouped));
next = up.get(&n).copied();
}
let Some(first) = slots.get(&primary).copied() else { return Ok(vec![(page, index)]) };
// The primary: the first annotation up the chain that is not a subordinate leading on.
let hops = links.iter().take_while(|(_, grouped)| *grouped).count();
if hops >= MAX_GROUP_DEPTH {
return Ok(alone);
}
let primary = match hops.checked_sub(1).and_then(|k| links.get(k)) {
Some(&(p, _)) => p,
// The entry is the primary. An inline one can't be named by an `/IRT`, so it has no
// subordinates.
None => match own {
Some(r) => r,
None => return Ok(alone),
},
};
let Some(&first) = slots.get(&primary) else { return Ok(alone) };
// How far below the primary a subordinate is still listed in its group: within the depth the
// list follows to a primary, and within the links the primary's own chain leaves.
let primary_links = links.len().saturating_sub(hops);
let deepest = (MAX_GROUP_DEPTH - 1).min(MAX_GROUP_DEPTH.saturating_sub(primary_links));
let mut members = vec![first];
let mut placed = HashSet::from([first]);
let mut visited = HashSet::from([primary]);
let mut todo = vec![primary];
while let Some(r) = todo.pop() {
let mut todo = vec![(primary, 0usize)];
while let Some((r, depth)) = todo.pop() {
if depth >= deepest {
continue;
}
for &(i, own) in under.get(&r).into_iter().flatten() {
match own {
Some(o) if !visited.insert(o) => continue,
Some(o) => todo.push(o),
Some(o) => todo.push((o, depth + 1)),
None => {}
}
if placed.insert(i) {
+174
View File
@@ -1150,6 +1150,180 @@ fn a_group_restyles_and_fills_as_one() {
}
}
/// An Oval on page 1 at `x`, with `/IRT` to `to` and `/RT rt` when given. Returns its index and
/// reference.
fn oval_to(doc: &mut Document, x: f64, to: Option<ObjRef>, rt: Option<&str>, id: &str) -> (usize, ObjRef) {
let i = add_annotation(doc, &new(0, Shape::Oval { rect: [x, 300.0, x + 40.0, 340.0] }), &meta(id)).unwrap();
let (_, r) = annot_ref(doc, 0, i).unwrap();
doc.update_dict(r, |d| {
if let Some(to) = to {
d.set(b"IRT".to_vec(), Object::Ref(to));
}
if let Some(rt) = rt {
d.set(b"RT".to_vec(), Object::name(rt));
}
})
.unwrap();
(i, r)
}
fn colour_of(doc: &Document, i: usize) -> Option<Object> {
list(doc, 0)[i].get(b"C").cloned()
}
fn blue(doc: &mut Document, i: usize) -> Result<(), AnnotError> {
set_style(doc, 0, i, StyleChange::new(Some([0.0, 0.0, 1.0]), None, None, None), &meta("s"))
}
/// A group of three, a subordinate of a subordinate (ISO 32000-2 §12.5.6.2: the primary is found
/// up the `/IRT` chain): every member finds the same group, primary first; restyling the last
/// recolours all three; moving the middle one moves only it, as Acrobat moves the member dragged.
#[test]
fn a_group_of_three_restyles_as_one_and_moves_by_member() {
let mut doc = fixture();
let square = add_annotation(&mut doc, &new(0, Shape::Rectangle { rect: [100.0, 100.0, 200.0, 200.0] }), &meta("sq")).unwrap();
let (_, sr) = annot_ref(&mut doc, 0, square).unwrap();
let (b, br) = oval_to(&mut doc, 300.0, Some(sr), Some("Group"), "b");
let (c, _) = oval_to(&mut doc, 400.0, Some(br), Some("Group"), "c");
let (other, _) = oval_to(&mut doc, 500.0, None, None, "other");
let mut doc = reopen(&doc);
for i in [square, b, c] {
assert_eq!(group_members(&doc, 0, i).unwrap(), [(0, square), (0, b), (0, c)], "from {i}");
}
assert_eq!(group_members(&doc, 0, other).unwrap(), [(0, other)], "a comment in no group is a group of one");
let unrelated = colour_of(&doc, other);
blue(&mut doc, c).unwrap();
for i in [square, b, c] {
assert_eq!(colour_of(&doc, i), Some(crate::create::rgb([0.0, 0.0, 1.0])), "{i}: recoloured from the last member");
}
assert_eq!(colour_of(&doc, other), unrelated, "the unrelated Oval keeps its colour");
let before = [rect_of(&doc, 0, square), rect_of(&doc, 0, b), rect_of(&doc, 0, c)];
move_annotation(&mut doc, 0, b, 10.0, 0.0, &meta("m")).unwrap();
assert_eq!([rect_of(&doc, 0, square), rect_of(&doc, 0, c)], [before[0].clone(), before[2].clone()], "the others stay");
assert_ne!(rect_of(&doc, 0, b), before[1], "the middle member moves");
}
/// A loop in `/IRT` ends, and leaves each annotation on it, and one hanging off it, a comment of
/// its own, as the comment list shows them (`pdfcraft_render::link_replies` drops a looping
/// parent): restyling one doesn't reach another. So does an `/IRT` to the annotation itself, and
/// a loop through a plain reply (`/RT /R`).
#[test]
fn a_looping_group_chain_leaves_each_member_on_its_own() {
let mut doc = fixture();
let (a, ar) = oval_to(&mut doc, 100.0, None, None, "a");
let (b, br) = oval_to(&mut doc, 150.0, Some(ar), Some("Group"), "b");
doc.update_dict(ar, |d| {
d.set(b"IRT".to_vec(), Object::Ref(br));
d.set(b"RT".to_vec(), Object::name("Group"));
})
.unwrap();
let (hanging, _) = oval_to(&mut doc, 200.0, Some(br), Some("Group"), "hanging");
let (selfish, sr) = oval_to(&mut doc, 250.0, None, None, "self");
doc.update_dict(sr, |d| {
d.set(b"IRT".to_vec(), Object::Ref(sr));
d.set(b"RT".to_vec(), Object::name("Group"));
})
.unwrap();
// A subordinate of the self-referencing one: that one has no link, so it is a primary.
let (under_self, _) = oval_to(&mut doc, 300.0, Some(sr), Some("Group"), "under-self");
// A plain reply loop (g and h reply to each other), with a subordinate of g.
let (g, gr) = oval_to(&mut doc, 350.0, None, None, "g");
let (_, hr) = oval_to(&mut doc, 400.0, Some(gr), Some("R"), "h");
doc.update_dict(gr, |d| d.set(b"IRT".to_vec(), Object::Ref(hr))).unwrap();
let (on_g, _) = oval_to(&mut doc, 450.0, Some(gr), Some("Group"), "on-g");
let mut doc = reopen(&doc);
for i in [a, b, hanging, on_g] {
assert_eq!(group_members(&doc, 0, i).unwrap(), [(0, i)], "{i}: a loop leaves it on its own");
}
assert_eq!(group_members(&doc, 0, selfish).unwrap(), [(0, selfish), (0, under_self)], "an /IRT to itself is no link");
assert_eq!(group_members(&doc, 0, under_self).unwrap(), [(0, selfish), (0, under_self)]);
let colours: Vec<_> = [b, hanging, g].iter().map(|&i| colour_of(&doc, i)).collect();
blue(&mut doc, a).unwrap();
blue(&mut doc, on_g).unwrap();
assert_eq!([b, hanging, g].iter().map(|&i| colour_of(&doc, i)).collect::<Vec<_>>(), colours, "nothing else on the loops is recoloured");
}
/// A subordinate whose primary is missing, an object that is no annotation, or an annotation the
/// comment list doesn't show (a link) is a comment of its own, and the head of its own
/// subordinates: a lock on the link holds back no restyle, and a link with `/RT /Group` to a
/// comment is not restyled with it.
#[test]
fn a_member_whose_primary_is_missing_is_a_comment_of_its_own() {
use crate::links::{self, LinkAction, LinkStyle};
let mut doc = fixture();
let page = page_refs(&doc).unwrap()[0];
let (orphan, or) = oval_to(&mut doc, 100.0, Some(ObjRef { num: 9999, generation: 0 }), Some("Group"), "orphan");
let (orphans, _) = oval_to(&mut doc, 150.0, Some(or), Some("Group"), "orphans");
let (on_page, _) = oval_to(&mut doc, 200.0, Some(page), Some("Group"), "on-page");
let link = links::add(&mut doc, 0, [10.0, 600.0, 110.0, 620.0], &LinkAction::Uri("https://example.org".into()), &LinkStyle::default()).unwrap();
let (_, lr) = annot_ref(&mut doc, 0, link).unwrap();
let (on_link, onr) = oval_to(&mut doc, 250.0, Some(lr), Some("Group"), "on-link");
doc.update_dict(lr, |d| {
d.set(b"F".to_vec(), Object::Int(4 | 128));
d.set(b"IRT".to_vec(), Object::Ref(onr));
d.set(b"RT".to_vec(), Object::name("Group"));
})
.unwrap();
let mut doc = reopen(&doc);
assert_eq!(group_members(&doc, 0, orphan).unwrap(), [(0, orphan), (0, orphans)], "the orphan heads its own subordinate");
assert_eq!(group_members(&doc, 0, orphans).unwrap(), [(0, orphan), (0, orphans)]);
assert_eq!(group_members(&doc, 0, on_page).unwrap(), [(0, on_page)], "an /IRT to the page leads nowhere");
assert_eq!(group_members(&doc, 0, on_link).unwrap(), [(0, on_link)], "the link is neither its primary nor its subordinate");
blue(&mut doc, on_link).expect("the locked link holds nothing back");
let link_colour = list(&doc, 0)[link].get(b"C").cloned();
blue(&mut doc, orphan).unwrap();
assert_eq!(colour_of(&doc, orphans), Some(crate::create::rgb([0.0, 0.0, 1.0])), "the orphan's subordinate follows it");
assert_eq!(list(&doc, 0)[link].get(b"C").cloned(), link_colour, "the link keeps its colour");
}
/// Only `/RT /Group` groups: a plain reply (`/RT /R`), an `/RT` of another value and a bare `/IRT`
/// read as replies, and a restyle of the comment they answer leaves them alone.
#[test]
fn only_rt_group_joins_a_group() {
let mut doc = fixture();
let (top, tr) = oval_to(&mut doc, 100.0, None, None, "top");
let replies: Vec<usize> = [Some("R"), Some("Bogus"), None]
.iter()
.enumerate()
.map(|(k, rt)| oval_to(&mut doc, 150.0 + 50.0 * k as f64, Some(tr), *rt, &format!("r{k}")).0)
.collect();
let mut doc = reopen(&doc);
assert_eq!(group_members(&doc, 0, top).unwrap(), [(0, top)]);
let colours: Vec<_> = replies.iter().map(|&i| colour_of(&doc, i)).collect();
blue(&mut doc, top).unwrap();
assert_eq!(replies.iter().map(|&i| colour_of(&doc, i)).collect::<Vec<_>>(), colours, "replies keep their colour");
for &i in &replies {
assert_eq!(group_members(&doc, 0, i).unwrap(), [(0, i)]);
}
}
/// A chain of subordinates is followed up to [`MAX_GROUP_DEPTH`] `- 1` links to its primary, as
/// the comment list follows it (`pdfcraft_render::group_primaries`); a member further down is a
/// group of its own, and a restyle from the primary stops there. The walk is bounded either way.
#[test]
fn a_group_chain_is_followed_to_a_bounded_depth() {
use crate::info::MAX_GROUP_DEPTH;
let mut doc = fixture();
let (top, mut up) = oval_to(&mut doc, 100.0, None, None, "top");
let mut chain = vec![top];
for k in 0..MAX_GROUP_DEPTH + 2 {
let (i, r) = oval_to(&mut doc, 100.0, Some(up), Some("Group"), &format!("s{k}"));
chain.push(i);
up = r;
}
let deepest = MAX_GROUP_DEPTH - 1;
let group: Vec<(usize, usize)> = chain.iter().take(deepest + 1).map(|&i| (0, i)).collect();
assert_eq!(group_members(&doc, 0, top).unwrap(), group, "the primary's group stops at {deepest} links down");
assert_eq!(group_members(&doc, 0, chain[deepest]).unwrap(), group, "the last member still finds its primary");
for k in [deepest + 1, deepest + 2, deepest + 3] {
assert_eq!(group_members(&doc, 0, chain[k]).unwrap(), [(0, chain[k])], "{k} links down: a group of its own");
}
let past = colour_of(&doc, chain[deepest + 1]);
blue(&mut doc, top).unwrap();
assert_eq!(colour_of(&doc, chain[deepest]), Some(crate::create::rgb([0.0, 0.0, 1.0])));
assert_eq!(colour_of(&doc, chain[deepest + 1]), past, "past the depth limit, not in the group");
}
#[test]
fn files_attach_as_comments() {
let mut doc = fixture();
+122
View File
@@ -4428,6 +4428,128 @@ 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}");
}
/// A one-page PDF whose page lists `annots` (objects 4 on, in order), so an annotation can name
/// another by number.
fn page_with_annots(annots: &[String]) -> Vec<u8> {
let list: Vec<String> = (0..annots.len()).map(|k| format!("{} 0 R", k + 4)).collect();
let page = format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Annots [{}] >>", list.join(" "));
let mut objs: Vec<String> = vec!["<< /Type /Catalog /Pages 2 0 R >>".into(), "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), page];
objs.extend(annots.iter().cloned());
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
out
}
/// A Circle at `x` with `/IRT` to object `to` and `/RT rt`.
fn circle_to(x: usize, to: Option<usize>, rt: Option<&str>) -> String {
let irt = to.map(|t| format!(" /IRT {t} 0 R")).unwrap_or_default();
let rt = rt.map(|r| format!(" /RT /{r}")).unwrap_or_default();
format!("<< /Type /Annot /Subtype /Circle /Rect [{x} 300 {} 340] /C [1 0 0] /T (Reviewer){irt}{rt} >>", x + 30)
}
/// Restyling one member of a group of three (a subordinate of a subordinate, ISO 32000-2
/// §12.5.6.2) recolours the whole group as one edit, and one undo puts every member back.
#[test]
fn a_group_restyle_is_one_undo_step() {
let annots = [
"<< /Type /Annot /Subtype /Square /Rect [20 20 100 80] /C [1 0 0] /T (Reviewer) /Contents (Primary) >>".to_string(),
circle_to(120, Some(4), Some("Group")),
circle_to(200, Some(5), Some("Group")),
circle_to(280, None, None),
];
let mut s = Session::new().with_clock(|| 1_700_000_000);
let id = s.open("group.pdf", None, Arc::new(page_with_annots(&annots)), None).unwrap();
let colours = |s: &Session| {
let mut c: Vec<(usize, Option<[f32; 3]>)> = s.get(id).unwrap().info.annotations.iter().map(|a| (a.index, a.color)).collect();
c.sort_by_key(|(i, _)| *i);
c
};
let before = colours(&s);
s.apply(id, Edit::StyleAnnotation { page: 0, index: 2, color: Some([0.0, 0.0, 1.0]), opacity: None, width: None, endings: None }).unwrap();
let after = colours(&s);
let blue = Some([0.0, 0.0, 1.0]);
assert_eq!(after.iter().take(3).map(|(_, c)| *c).collect::<Vec<_>>(), [blue; 3], "the three members: {after:?}");
assert_eq!(after.get(3), before.get(3), "the Circle in no group keeps its colour");
s.undo(id).unwrap();
assert_eq!(colours(&s), before, "one undo puts the whole group back");
assert!(s.get(id).unwrap().undo_label().is_none(), "the restyle was one step");
}
/// The members a restyle reaches (`pdfcraft_annot::group_members`) are the group the comment
/// list shows (`pdfcraft_render::group_primaries` after `link_replies`), for every annotation on a
/// page holding a group of three, a loop, an `/IRT` to itself, a loop through a plain reply, a
/// missing primary, a link with `/RT /Group`, other `/RT` values and a chain past the depth limit.
#[test]
fn a_restyle_reaches_the_group_the_comment_list_shows() {
let mut annots: Vec<String> = vec![
// 4: a group of three (5 under 4, 6 under 5).
"<< /Type /Annot /Subtype /Square /Rect [20 20 100 80] /C [1 0 0] >>".into(),
circle_to(20, Some(4), Some("Group")),
circle_to(20, Some(5), Some("Group")),
// 7 and 8 loop; 9 hangs off the loop.
circle_to(60, Some(8), Some("Group")),
circle_to(60, Some(7), Some("Group")),
circle_to(60, Some(8), Some("Group")),
// 10 names itself; 11 hangs off it.
circle_to(100, Some(10), Some("Group")),
circle_to(100, Some(10), Some("Group")),
// 12 and 13 reply to each other; 14 is grouped with 12.
circle_to(140, Some(13), None),
circle_to(140, Some(12), Some("R")),
circle_to(140, Some(12), Some("Group")),
// 15 names a missing object; 16 is grouped with 15; 17 names the page.
circle_to(180, Some(999), Some("Group")),
circle_to(180, Some(15), Some("Group")),
circle_to(180, Some(3), Some("Group")),
// 18 is a link grouped with 4; 19 is grouped with the link.
"<< /Type /Annot /Subtype /Link /Rect [10 700 100 720] /IRT 4 0 R /RT /Group >>".into(),
circle_to(220, Some(18), Some("Group")),
// 20 and 21: other /RT values under 4.
circle_to(260, Some(4), Some("R")),
circle_to(260, Some(4), Some("Bogus")),
];
// 22 on: a chain of 67 subordinates under a Square.
let top = annots.len() + 4;
annots.push("<< /Type /Annot /Subtype /Square /Rect [300 20 380 80] /C [1 0 0] >>".into());
for k in 0..67 {
annots.push(circle_to(300, Some(top + k), Some("Group")));
}
let bytes = page_with_annots(&annots);
let mut s = Session::new().with_clock(|| 1_700_000_000);
let id = s.open("groups.pdf", None, Arc::new(bytes.clone()), None).unwrap();
let all = s.get(id).unwrap().info.annotations.clone();
let cos = pdfcraft_cos::Document::open(Arc::new(bytes)).unwrap();
let primaries = pdfcraft_render::group_primaries(&all);
// The `/Annots` position of the group's head as the list shows it; `None` for a subordinate
// listed under no comment (one exactly at the depth limit).
let head_of = |i: usize| match primaries.get(i).copied().flatten() {
Some(p) => all.get(p).map(|p| p.index),
None if all.get(i).is_some_and(|a| a.is_group_member()) => None,
None => all.get(i).map(|a| a.index),
};
let mut checked = 0;
for (i, a) in all.iter().enumerate() {
let Some(head) = head_of(i) else { continue };
let mut shown: Vec<usize> = (0..all.len()).filter(|&j| head_of(j) == Some(head)).filter_map(|j| all.get(j).map(|m| m.index)).collect();
shown.sort_unstable();
let mut members: Vec<usize> = pdfcraft_annot::group_members(&cos, 0, a.index).unwrap().into_iter().map(|(_, m)| m).collect();
members.sort_unstable();
assert_eq!(members, shown, "annotation at /Annots {} (object {})", a.index, a.index + 4);
checked += 1;
}
assert!(checked > 80, "{checked}");
}
#[test]
fn reading_colors_set_and_invalidated() {
let (mut s, id) = session_with(1);