fix(redact): clearing marks leaves the page as it was and takes replies with them

Clear all wrote `/Annots []` on a page that had no annotations and replaced
an indirect `/Annots` array with an inline copy. It also left replies to a
mark in place, so their `/IRT` kept the cleared mark in the document (and in
the comment list, as a reply to nothing).

`clear_marks` now writes the list through the annot crate's `set_annots`
(made public), as deleting a comment does, and removes each mark's replies
and pop-ups with it. The "what goes with a deleted comment" walk moves from
`delete_annotation` into `with_replies_and_popups`, which both use; it visits
each annotation once instead of sweeping the list until nothing changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
alehel
2026-10-23 17:54:52 +02:00
co-authored by Claude Opus 5.5
parent 57e531ccca
commit d9bc4b17a5
4 changed files with 73 additions and 46 deletions
+32 -30
View File
@@ -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<usize> = 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<Object>) -> 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<Object>) -> 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<ObjRef> = 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<Object> =
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<Item = ObjRef>) -> HashSet<ObjRef> {
let mut hanging: HashMap<ObjRef, Vec<ObjRef>> = 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<ObjRef> = 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
+1 -1
View File
@@ -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.