diff --git a/crates/redact/src/lib.rs b/crates/redact/src/lib.rs index 41ac47357e..2bf9819c46 100644 --- a/crates/redact/src/lib.rs +++ b/crates/redact/src/lib.rs @@ -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 = 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 = 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 = Vec::new(); + let mut removed: HashSet = 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> = 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 = 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) -> HashMap> { + let mut hanging: HashMap> = 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. diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 3c170aba20..52e276bbcf 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -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| range.rev().map(|k| format!("{} 0 R ", first + k)).collect::(); + 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"); + } +}