diff --git a/crates/annot/src/group.rs b/crates/annot/src/group.rs index 2de1fd50bb..c4e4f76317 100644 --- a/crates/annot/src/group.rs +++ b/crates/annot/src/group.rs @@ -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, AnnotError> { let list: Vec = 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 = HashMap::new(); - // The subordinates on the page hanging off each annotation by `/IRT`, in `/Annots` order. + let mut up: HashMap = HashMap::new(); let mut under: HashMap)>> = 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 = 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) { diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index 065afb1d9b..385a68e22a 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -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, 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 { + 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::>(), 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 = [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::>(), 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(); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index a6ced87376..269a67d26d 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -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 { + let list: Vec = (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 = 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, 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::>(), [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 = 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 = (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 = 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);