fix(redact): remove reply chains by walking a parent index once

Reply removal ran fixed-point loops: per page, every pass scanned all
replies and searched a growing Vec of removed annotations; across pages,
every pass re-read every page's /Annots. A chain listed deepest first
added one reply per pass, so the work was cubic on a page and quadratic
across pages.

Replies are now indexed by parent (/IRT, and /Parent for pop-ups across
pages) once and walked from the removed annotations with a HashSet, so
each link is followed once. What is removed is unchanged.
This commit is contained in:
Dylan M. Taylor
2026-11-03 12:55:44 +00:00
parent 14ae839a28
commit db862cf4d0
2 changed files with 90 additions and 63 deletions
+61 -63
View File
@@ -452,48 +452,31 @@ fn apply_within(doc: &mut Document, pages: Option<&[usize]>, images: pdfcraft_co
let Applying { mut report, doomed_fields, doomed_xobjects, retired_properties, mut removed_everywhere, mut images, .. } = applying;
// 2b. Replies and pop-ups of a removed annotation can be listed on another page than their
// parent: their `/IRT` or `/Parent` would keep the removed comment reachable.
loop {
let mut grew = false;
for p in pdfcraft_model::pages(doc) {
let annots = annots_of(doc, &p.dict);
let mut kept = Vec::with_capacity(annots.len());
for a in &annots {
let Some(r) = a.as_ref() else {
kept.push(a.clone());
continue;
};
let obj = doc.get(r);
let Some(d) = obj.as_dict() else {
kept.push(a.clone());
continue;
};
let is_reply =
d.get(b"IRT").and_then(Object::as_ref).is_some_and(|x| removed_everywhere.contains(&x)) && d.name(b"Subtype") != Some(b"Widget");
let is_popup = d.get(b"Parent").and_then(Object::as_ref).is_some_and(|x| removed_everywhere.contains(&x));
if removed_everywhere.contains(&r) || is_reply || is_popup {
if removed_everywhere.insert(r) {
grew = true;
if is_reply {
report.annotations += 1;
}
}
} else {
kept.push(a.clone());
}
}
if kept.len() != annots.len() {
doc.update_dict(p.obj, |d| {
if kept.is_empty() {
d.remove(b"Annots");
} else {
d.set(b"Annots".to_vec(), Object::Array(kept));
}
})?;
// parent: their `/IRT` or `/Parent` would keep the removed comment reachable. What hangs off
// each annotation is indexed once and walked from the removed ones, so every link is followed
// once, however the pages order a chain of replies.
let pages = pdfcraft_model::pages(doc);
let hanging = hanging_annotations(doc, pages.iter().flat_map(|p| annots_of(doc, &p.dict)));
let mut todo: Vec<ObjRef> = removed_everywhere.iter().copied().collect();
while let Some(parent) = todo.pop() {
for &(r, reply) in hanging.get(&parent).into_iter().flatten() {
if removed_everywhere.insert(r) {
report.annotations += usize::from(reply);
todo.push(r);
}
}
if !grew {
break;
}
for p in &pages {
let annots = annots_of(doc, &p.dict);
let kept: Vec<Object> = annots.iter().filter(|a| !a.as_ref().is_some_and(|r| removed_everywhere.contains(&r))).cloned().collect();
if kept.len() != annots.len() {
doc.update_dict(p.obj, |d| {
if kept.is_empty() {
d.remove(b"Annots");
} else {
d.set(b"Annots".to_vec(), Object::Array(kept));
}
})?;
}
}
@@ -754,7 +737,7 @@ fn remove_page_annotations(
let Applying { report, doomed_fields, widget_owner, removed_everywhere, .. } = applying;
// 2. Annotations: the marks, and whatever lies under them (with their pop-ups).
let annots = annots_of(doc, &doc.get(page.obj).as_dict().cloned().unwrap_or_default());
let mut removed: Vec<ObjRef> = Vec::new();
let mut removed: HashSet<ObjRef> = HashSet::new();
for a in &annots {
let Some(r) = a.as_ref() else { continue };
let obj = doc.get(r);
@@ -762,7 +745,7 @@ fn remove_page_annotations(
let subtype = d.name(b"Subtype").unwrap_or(b"");
if subtype == b"Redact" {
if page_marks.iter().any(|m| m.obj == r) {
removed.push(r);
removed.insert(r);
}
continue;
}
@@ -770,7 +753,7 @@ fn remove_page_annotations(
continue;
}
if rect_of(doc, d.get(b"Rect")).is_some_and(|b| rects.iter().any(|x| pdfcraft_content::overlaps(*x, b, 0.0))) {
removed.push(r);
removed.insert(r);
if subtype == b"Widget" {
if let Some((_, field)) = widget_owner.iter().find(|(w, _)| *w == r)
&& !doomed_fields.contains(field)
@@ -784,29 +767,25 @@ fn remove_page_annotations(
}
// Replies (`/IRT`, ISO 32000-2 §12.5.6.2) to a removed annotation go too, and replies to
// those: a reply placed elsewhere would keep its parent — the comment and the QuadPoints
// of the redacted words — in the file through its `/IRT` (#2659). Each pass removes at
// least one more annotation of this page or stops, so it ends.
let replies: Vec<(ObjRef, ObjRef)> = annots
.iter()
.filter_map(Object::as_ref)
.filter_map(|r| {
let obj = doc.get(r);
let d = obj.as_dict()?;
(d.name(b"Subtype") != Some(b"Widget")).then_some(())?;
Some((r, d.get(b"IRT").and_then(Object::as_ref)?))
})
.collect();
loop {
let before = removed.len();
for (r, parent) in &replies {
if !removed.contains(r) && removed.contains(parent) {
removed.push(*r);
// of the redacted words — in the file through its `/IRT` (#2659). The replies are indexed by
// parent once and walked from the removed annotations, each reply visited once.
let mut replies: HashMap<ObjRef, Vec<ObjRef>> = HashMap::new();
for (r, parent) in annots.iter().filter_map(Object::as_ref).filter_map(|r| {
let obj = doc.get(r);
let d = obj.as_dict()?;
(d.name(b"Subtype") != Some(b"Widget")).then_some(())?;
Some((r, d.get(b"IRT").and_then(Object::as_ref)?))
}) {
replies.entry(parent).or_default().push(r);
}
let mut todo: Vec<ObjRef> = removed.iter().copied().collect();
while let Some(parent) = todo.pop() {
for &r in replies.get(&parent).into_iter().flatten() {
if removed.insert(r) {
report.annotations += 1;
todo.push(r);
}
}
if removed.len() == before {
break;
}
}
removed_everywhere.extend(removed.iter().copied());
// Pop-ups dropped with their parent below count too: a reply on another page may answer one.
@@ -838,6 +817,25 @@ fn remove_page_annotations(
Ok(())
}
/// What hangs off each annotation among the listed `annots`: its replies (`/IRT`, not widgets),
/// marked `true`, and its pop-ups (`/Parent`), marked `false`.
fn hanging_annotations(doc: &Document, annots: impl Iterator<Item = Object>) -> HashMap<ObjRef, Vec<(ObjRef, bool)>> {
let mut hanging: HashMap<ObjRef, Vec<(ObjRef, bool)>> = HashMap::new();
for r in annots.filter_map(|a| a.as_ref()) {
let obj = doc.get(r);
let Some(d) = obj.as_dict() else { continue };
if d.name(b"Subtype") != Some(b"Widget")
&& let Some(parent) = d.get(b"IRT").and_then(Object::as_ref)
{
hanging.entry(parent).or_default().push((r, true));
}
if let Some(parent) = d.get(b"Parent").and_then(Object::as_ref) {
hanging.entry(parent).or_default().push((r, false));
}
}
hanging
}
/// Remove redaction marks without applying them (`None` = all), with their replies and pop-ups as
/// deleting a comment does: a reply left behind would keep its mark in the file through `/IRT`.
/// Returns how many marks went.
+29
View File
@@ -3897,3 +3897,32 @@ fn a_redacted_form_xobject_drops_the_source_text_of_added_content() {
let bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap();
assert!(!bytes.windows(4).any(|w| w == b"1234"), "redacted text left in the saved file");
}
/// A chain of replies listed deepest first, across two pages: each pass of the old fixed-point
/// loops removed one reply and searched a growing list for every reply, cubic work in the chain.
/// The replies are indexed by parent and walked once now, and the whole chain still goes.
#[test]
fn a_reverse_ordered_reply_chain_is_removed_in_linear_work() {
const N: usize = 6_000;
let first = 7;
let half = N / 2;
let refs = |range: std::ops::Range<usize>| range.rev().map(|k| format!("{} 0 R ", first + k)).collect::<String>();
let mut objs = vec![
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
b"<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >>".to_vec(),
format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 5 0 R /Annots [{}] >>", refs(0..half + 1)).into_bytes(),
format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 6 0 R /Annots [{}] >>", refs(half + 1..N + 1)).into_bytes(),
stream("", b""),
stream("", b""),
b"<< /Type /Annot /Subtype /Text /Rect [10 10 20 20] /Contents (SECRET_COMMENT) >>".to_vec(),
];
objs.extend((1..=N).map(|k| format!("<< /Type /Annot /Subtype /Text /Rect [200 200 210 210] /IRT {} 0 R >>", first + k - 1).into_bytes()));
let mut doc = pdf(objs);
mark(&mut doc, 0, &[[5.0, 5.0, 25.0, 25.0]], "");
let started = std::time::Instant::now();
assert_eq!(apply(&mut doc, None).unwrap().annotations, N + 1);
assert!(started.elapsed() < std::time::Duration::from_secs(60), "{:?}", started.elapsed());
for p in pdfcraft_model::pages(&doc) {
assert!(annots_of(&doc, &p.dict).is_empty(), "a reply stayed listed");
}
}