mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-08 09:40:22 +00:00
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:
+61
-63
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user