diff --git a/crates/annot/src/lib.rs b/crates/annot/src/lib.rs index 720d39cd8..d05c2ca41 100644 --- a/crates/annot/src/lib.rs +++ b/crates/annot/src/lib.rs @@ -14,6 +14,8 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +use std::collections::{HashMap, HashSet}; + use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream}; pub mod appearance; @@ -774,8 +776,9 @@ thread_local! { static ANNOTS_ENTRIES_COPIED: std::cell::Cell = const { std::cell::Cell::new(0) }; } -/// Replace a page's `/Annots`, writing into the shared array object when it is indirect. -fn set_annots(doc: &mut Document, page: ObjRef, list: Vec) -> Result<(), AnnotError> { +/// Replace a page's `/Annots`, writing into the shared array object when it is indirect. An empty +/// list removes a direct `/Annots`. +pub fn set_annots(doc: &mut Document, page: ObjRef, list: Vec) -> Result<(), pdfcraft_cos::CosError> { let existing = doc.get(page).as_dict().and_then(|d| d.reference(b"Annots")); match existing { Some(r) if doc.get(r).as_array().is_some() => doc.set(r, Object::Array(list)), @@ -1647,36 +1650,35 @@ pub fn delete_annotation(doc: &mut Document, page: usize, index: usize) -> Resul if let Some(r) = target.as_ref() { unlocked(doc, r)?; } - let mut doomed: Vec = target.as_ref().into_iter().collect(); - // Replies (`/IRT`) and pop-ups (`/Parent`) of anything doomed, to a fixpoint. - loop { - let before = doomed.len(); - for e in &list { - let Some(r) = e.as_ref() else { continue }; - if doomed.contains(&r) { - continue; - } - let obj = doc.get(r); - let Some(d) = obj.as_dict() else { continue }; - let points_at = |k: &[u8]| d.reference(k).is_some_and(|t| doomed.contains(&t)); - if points_at(b"IRT") || points_at(b"Parent") { - doomed.push(r); - } - } - for r in doomed.clone() { - if let Some(pop) = doc.get(r).as_dict().and_then(|d| d.reference(b"Popup")) - && !doomed.contains(&pop) - { - doomed.push(pop); - } - } - if doomed.len() == before { - break; - } - } + let doomed = with_replies_and_popups(doc, &list, target.as_ref()); let kept: Vec = list.into_iter().enumerate().filter(|(i, e)| *i != index && !e.as_ref().is_some_and(|r| doomed.contains(&r))).map(|(_, e)| e).collect(); - set_annots(doc, p, kept) + Ok(set_annots(doc, p, kept)?) +} + +/// `doomed` and what goes with it, as Acrobat deletes a comment: the entries of `list` (one or +/// more pages' `/Annots`) that reply to (`/IRT`) or pop up for (`/Parent`) anything going, their +/// own replies, and the pop-ups going annotations name (`/Popup`). Each annotation is visited +/// once, so a long or cyclic chain costs no more than its length. +pub fn with_replies_and_popups(doc: &Document, list: &[Object], doomed: impl IntoIterator) -> HashSet { + let mut hanging: HashMap> = HashMap::new(); + for r in list.iter().filter_map(Object::as_ref) { + let obj = doc.get(r); + let Some(d) = obj.as_dict() else { continue }; + for on in [d.reference(b"IRT"), d.reference(b"Parent")].into_iter().flatten() { + hanging.entry(on).or_default().push(r); + } + } + let mut going = HashSet::new(); + let mut todo: Vec = doomed.into_iter().collect(); + while let Some(r) = todo.pop() { + if !going.insert(r) { + continue; + } + todo.extend(hanging.get(&r).into_iter().flatten()); + todo.extend(doc.get(r).as_dict().and_then(|d| d.reference(b"Popup"))); + } + going } /// Change a comment's text. Text boxes are redrawn to show it, and their rectangle follows the diff --git a/crates/annot/src/links.rs b/crates/annot/src/links.rs index 53c31d83a..7cf4328c7 100644 --- a/crates/annot/src/links.rs +++ b/crates/annot/src/links.rs @@ -320,7 +320,7 @@ pub fn delete(doc: &mut Document, page: usize, index: usize) -> Result<(), Annot let (p, _) = link_at(doc, page, index)?; let mut list = annots(doc, p); list.remove(index); - set_annots(doc, p, list) + Ok(set_annots(doc, p, list)?) } /// Remove every link on `pages` (all when `None`). Returns how many went. diff --git a/crates/redact/src/lib.rs b/crates/redact/src/lib.rs index 29f23d3c9..f4e22d6b3 100644 --- a/crates/redact/src/lib.rs +++ b/crates/redact/src/lib.rs @@ -699,27 +699,24 @@ fn apply_to(doc: &mut Document, pages: Option<&[usize]>) -> Result) -> Result { - let all = marks(doc); - let doomed: Vec = all.iter().filter(|m| pages.is_none_or(|p| p.contains(&m.page))).map(|m| m.obj).collect(); - if doomed.is_empty() { + let targets: HashSet = marks(doc).iter().filter(|m| pages.is_none_or(|p| p.contains(&m.page))).map(|m| m.obj).collect(); + if targets.is_empty() { return Err(RedactError::NothingToApply); } + let listed: Vec = pdfcraft_model::pages(doc).iter().flat_map(|p| annots_of(doc, &p.dict)).collect(); + let doomed = pdfcraft_annot::with_replies_and_popups(doc, &listed, targets.iter().copied()); for p in pdfcraft_model::pages(doc) { let annots = annots_of(doc, &p.dict); - let kept: Vec = annots - .iter() - .filter(|a| { - let Some(r) = a.as_ref() else { return true }; - let parent = doc.get(r).as_dict().and_then(|d| d.get(b"Parent")).and_then(Object::as_ref); - !doomed.contains(&r) && !parent.is_some_and(|p| doomed.contains(&p)) - }) - .cloned() - .collect(); + let kept: Vec = annots.iter().filter(|a| !a.as_ref().is_some_and(|r| doomed.contains(&r))).cloned().collect(); + // 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() { - doc.update_dict(p.obj, |d| d.set(b"Annots".to_vec(), Object::Array(kept)))?; + pdfcraft_annot::set_annots(doc, p.obj, kept)?; } } - Ok(doomed.len()) + Ok(targets.len()) } diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index b95237710..1424656c9 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -1041,6 +1041,34 @@ fn nothing_to_apply_and_clearing_marks() { assert!(content(&doc, 0).contains("(AB) Tj")); } +/// Clearing marks puts a page's annotation list back as it was: a page that had none has no +/// `/Annots` again, replies to the marks included, and one listed through an indirect array keeps +/// pointing at that array. +#[test] +fn clearing_marks_leaves_the_annotation_list_as_it_was() { + let mut doc = one_page(b"BT /F1 10 Tf 10 100 Td (AB) Tj ET", "", vec![]); + let page = pdfcraft_model::pages(&doc)[0].obj; + let before = doc.get(page); + mark(&mut doc, 0, &[[0.0, 0.0, 50.0, 50.0]], ""); + pdfcraft_annot::add_reply(&mut doc, 0, 0, "a reply", "Tester", &Meta::default()).unwrap(); + assert_eq!(clear_marks(&mut doc, None), Ok(1), "one mark"); + assert_eq!(doc.get(page), before, "the page has no /Annots again: the reply went with its mark"); + + let mut doc = pdf(vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Annots 4 0 R >>".to_vec(), + b"[5 0 R]".to_vec(), + b"<< /Type /Annot /Subtype /Link /Rect [0 0 10 10] >>".to_vec(), + ]); + let (page, list) = (ObjRef::new(3, 0), ObjRef::new(4, 0)); + let before = (doc.get(page), doc.get(list)); + mark(&mut doc, 0, &[[0.0, 0.0, 50.0, 50.0]], ""); + assert_eq!(doc.get(list).as_array().map(|a| a.len()), Some(2), "the mark is listed in the shared array"); + clear_marks(&mut doc, None).unwrap(); + assert_eq!((doc.get(page), doc.get(list)), before, "the page points at its array, which lists the link alone"); +} + #[test] fn unreadable_content_fails_closed() { let mut doc = one_page(b"", "", vec![]);