mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-12 09:21:17 +00:00
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:
1 parent
a3f5d3e3ba
commit
681eb48bf3
3 files changed
+378
-49
No files matched your search
+82
-49
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user