diff --git a/crates/redact/src/lib.rs b/crates/redact/src/lib.rs index 5938f20a68..7fd34dae12 100644 --- a/crates/redact/src/lib.rs +++ b/crates/redact/src/lib.rs @@ -247,6 +247,19 @@ pub(crate) fn annots_of(doc: &Document, page: &Dict) -> Vec { page.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default() } +/// An annotation's `/Subtype`, resolved: it may be an indirect reference (ISO 32000-2 §7.3.10), +/// and the audit reads it that way, so a mark it reports must not be one [`apply`] passes over. +pub(crate) fn subtype(doc: &Document, d: &Dict) -> Option> { + d.get(b"Subtype").and_then(|s| doc.resolve(s).as_name().map(<[u8]>::to_vec)) +} + +/// The pop-up `d` names (`/Popup`), when it is one: it goes with `d`. Anything else it might name +/// (a mark on another page, say) is left alone. +fn named_popup(doc: &Document, d: &Dict) -> Option { + let r = d.reference(b"Popup")?; + doc.get(r).as_dict().is_some_and(|p| subtype(doc, p).as_deref() == Some(b"Popup")).then_some(r) +} + /// Every annotation the pages list. Compared before and after a removal, what is no longer listed /// leaves the structure tree too ([`tags::drop_object_refs`]). pub(crate) fn listed_annotations(doc: &Document) -> HashSet { @@ -267,14 +280,14 @@ pub(crate) fn marks_checked(doc: &Document) -> (Vec, Vec) { let Some(r) = a.as_ref() else { // A mark written in place has no object to find, apply or remove: refuse it // rather than apply the others as if it were not there. - if doc.resolve(&a).as_dict().is_some_and(|d| d.name(b"Subtype") == Some(b"Redact")) { + if doc.resolve(&a).as_dict().is_some_and(|d| subtype(doc, d).as_deref() == Some(b"Redact")) { unreadable.push(pi); } continue; }; let obj = doc.get(r); let Some(d) = obj.as_dict() else { continue }; - if d.name(b"Subtype") != Some(b"Redact") { + if subtype(doc, d).as_deref() != Some(b"Redact") { continue; } let mut rects: Vec<[f64; 4]> = quad_points(doc, d) @@ -580,18 +593,41 @@ fn apply_within(doc: &mut Document, pages: Option<&[usize]>, images: pdfcraft_co 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); + // An entry written in place is read as one listed by reference: its `/IRT` or + // `/Parent` keeps a removed comment reachable all the same, and it has its own text. + let obj = doc.resolve(a); 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)); + let is_reply = d.reference(b"IRT").is_some_and(|x| removed_everywhere.contains(&x)) && subtype(doc, d).as_deref() != Some(b"Widget"); + let is_popup = d.reference(b"Parent").is_some_and(|x| removed_everywhere.contains(&x)); + let Some(r) = a.as_ref() else { + if is_reply || is_popup { + grew = true; + if is_reply { + report.annotations += 1; + } + // Its appearance can be shared with an annotation that stays elsewhere. + if !appearance_scan_limit { + match appearance_refs(doc, d) { + Some(references) => removed_appearances.extend(references), + None => appearance_scan_limit = true, + } + if removed_appearances.len() > 16_384 { + appearance_scan_limit = true; + removed_appearances.clear(); + } + } + // It has no reference to remove elsewhere; the pop-up it names has. + if let Some(p) = named_popup(doc, d) { + removed_everywhere.insert(p); + } + } else { + kept.push(a.clone()); + } + continue; + }; if removed_everywhere.contains(&r) || is_reply || is_popup { // A reply or pop-up removed here can share its appearance with an annotation // that stays on an unselected page, like one removed under a mark: the @@ -638,6 +674,30 @@ fn apply_within(doc: &mut Document, pages: Option<&[usize]>, images: pdfcraft_co report.fields += 1; } } + // A widget written in place on another page names its deleted field through `/Parent`, which + // would keep the field and its value in the file: it goes with the field. + if !doomed_fields.is_empty() { + for p in pdfcraft_model::pages(doc) { + let annots = annots_of(doc, &p.dict); + let kept: Vec = annots + .iter() + .filter(|a| { + a.as_ref().is_some() || !matches!(&**a, Object::Dict(d) if d.reference(b"Parent").is_some_and(|f| doomed_fields.contains(&f))) + }) + .cloned() + .collect(); + if kept.len() != annots.len() { + report.annotations += annots.len() - kept.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)); + } + })?; + } + } + } // 3a. A tagged PDF's structure tree still points at the removed comments and widgets // (`/OBJR`, #2874): drop those references, or a full rewrite keeps them reachable, the @@ -885,30 +945,66 @@ fn remove_page_annotations( page_marks: &[&Mark], ) -> Result<(), RedactError> { let Applying { report, doomed_fields, widget_owner, removed_everywhere, removed_appearances, appearance_scan_limit, .. } = applying; - // 2. Annotations: the marks, and whatever lies under them (with their pop-ups). + // 2. Annotations: the marks, and whatever lies under them (with their pop-ups). An entry + // written in place (a dictionary in `/Annots`, not a reference) is judged like any other: its + // `/Contents` and `/IRT` stay in the file for as long as the page lists it. let annots = annots_of(doc, &doc.get(page.obj).as_dict().cloned().unwrap_or_default()); + /// What decides an entry's fate, read once. + struct Entry { + r: Option, + widget: bool, + irt: Option, + parent: Option, + popup: Option, + } + let entries: Vec> = annots + .iter() + .map(|a| { + let obj = doc.resolve(a); + let d = obj.as_dict()?; + Some(Entry { + r: a.as_ref(), + widget: subtype(doc, d).as_deref() == Some(b"Widget"), + irt: d.reference(b"IRT"), + parent: d.reference(b"Parent"), + popup: named_popup(doc, d), + }) + }) + .collect(); let mut removed: Vec = Vec::new(); - for a in &annots { - let Some(r) = a.as_ref() else { continue }; - let obj = doc.get(r); + // Entries written in place that go, by position in `/Annots`. + let mut inline_gone = vec![false; annots.len()]; + for (i, a) in annots.iter().enumerate() { + let obj = doc.resolve(a); let Some(d) = obj.as_dict() else { continue }; - let subtype = d.name(b"Subtype").unwrap_or(b""); - if subtype == b"Redact" { - if page_marks.iter().any(|m| m.obj == r) { + let r = a.as_ref(); + let kind = subtype(doc, d).unwrap_or_default(); + if kind == b"Redact" { + // A mark written in place was refused before anything changed. + if let Some(r) = r + && page_marks.iter().any(|m| m.obj == r) + { removed.push(r); } continue; } - if subtype == b"Popup" { + if kind == b"Popup" { 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); - if subtype == b"Widget" { - if let Some((_, field)) = widget_owner.iter().find(|(w, _)| *w == r) - && !doomed_fields.contains(field) + match r { + Some(r) => removed.push(r), + None => inline_gone[i] = true, + } + if kind == b"Widget" { + // A widget written in place has no reference of its own; its `/Parent` is the field, + // even one no referenced widget belongs to (it then has no entry in `widget_owner`). + // Step 3 deletes only what the form lists as a field, so any `/Parent` is safe here. + let owner = r.and_then(|r| widget_owner.iter().find(|(w, _)| *w == r).map(|(_, f)| *f)).or_else(|| d.reference(b"Parent")); + if let Some(field) = owner + && !doomed_fields.contains(&field) { - doomed_fields.push(*field); + doomed_fields.push(field); } } else { report.annotations += 1; @@ -919,29 +1015,25 @@ fn remove_page_annotations( // 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); - report.annotations += 1; + let mut grew = false; + for (i, e) in entries.iter().enumerate() { + let Some(e) = e else { continue }; + let gone = e.r.map_or(inline_gone[i], |r| removed.contains(&r)); + if gone || e.widget || !e.irt.is_some_and(|p| removed.contains(&p)) { + continue; } + match e.r { + Some(r) => removed.push(r), + None => inline_gone[i] = true, + } + report.annotations += 1; + grew = true; } - if removed.len() == before { + if !grew { break; } } - removed_everywhere.extend(removed.iter().copied()); // A removed annotation's appearance streams can be shared with a kept one on another page, // which would keep a pruned pattern's cell reachable: remember them for the pattern guard. for reference in &removed { @@ -961,24 +1053,53 @@ fn remove_page_annotations( } } } - // Pop-ups dropped with their parent below count too: a reply on another page may answer one. - for a in &annots { - let Some(r) = a.as_ref() else { continue }; - if doc.get(r).as_dict().and_then(|d| d.get(b"Parent")).and_then(Object::as_ref).is_some_and(|p| removed.contains(&p)) { - removed_everywhere.insert(r); + // An entry written in place that goes keeps no reference, but its appearances count as well. + for (i, a) in annots.iter().enumerate() { + if *appearance_scan_limit { + break; + } + if let (true, Some(annotation)) = (inline_gone[i], doc.resolve(a).as_dict()) { + match appearance_refs(doc, annotation) { + Some(references) => removed_appearances.extend(references), + None => *appearance_scan_limit = true, + } + if removed_appearances.len() > 16_384 { + *appearance_scan_limit = true; + removed_appearances.clear(); + } } } + // Pop-ups go with their annotation: those that name it (`/Parent`), and those it names + // (`/Popup`), the only link an annotation written in place can have to its pop-up. + let mut going: HashSet = removed.iter().copied().collect(); + for (i, e) in entries.iter().enumerate() { + let Some(e) = e else { continue }; + let gone = e.r.map_or(inline_gone[i], |r| removed.contains(&r)); + if gone { + going.extend(e.popup); + } + } + for (i, e) in entries.iter().enumerate() { + let Some(e) = e else { continue }; + if e.parent.is_some_and(|p| removed.contains(&p)) { + match e.r { + Some(r) => { + going.insert(r); + } + None => inline_gone[i] = true, + } + } + } + // Pop-ups dropped with their parent count too: a reply on another page may answer one. + removed_everywhere.extend(going.iter().copied()); let kept: Vec = annots .into_iter() - .filter(|a| { - let Some(r) = a.as_ref() else { return true }; - if removed.contains(&r) { - return false; - } - // Pop-ups of removed annotations go too. - let obj = doc.get(r); - !obj.as_dict().and_then(|d| d.get(b"Parent")).and_then(Object::as_ref).is_some_and(|p| removed.contains(&p)) + .enumerate() + .filter(|(i, a)| match a.as_ref() { + Some(r) => !going.contains(&r), + None => !inline_gone[*i], }) + .map(|(_, a)| a) .collect(); doc.update_dict(page.obj, |d| { if kept.is_empty() { diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index e8ff3cd788..4ad4103e10 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -6691,6 +6691,78 @@ fn a_type3_text_layer_is_redacted_like_any_other() { assert_eq!(under(&mut doc, 0, &[[98.0, 96.0, 112.0, 110.0]]), 0, "nothing is left under the mark"); } +#[test] +fn a_mark_whose_subtype_is_an_indirect_name_is_applied() { + // `/Subtype` may be an indirect reference (ISO 32000-2 §7.3.10); the audit reads it that way. + // Passing over the mark would leave the text under it while the others report success. + let objs: Vec> = 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] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Annots [7 0 R] >>".to_vec(), + stream("", b"BT /F1 10 Tf 20 20 Td (SECRET) Tj ET"), + FONT.replace("95 0 R", "6 0 R").into_bytes(), + widths(), + b"<< /Type /Annot /Subtype 8 0 R /Rect [10 10 90 40] >>".to_vec(), + b"/Redact".to_vec(), + ]; + let mut doc = pdf(objs); + assert_eq!(marks(&doc).len(), 1, "the mark is found"); + let r = apply(&mut doc, None).unwrap(); + assert_eq!(r.marks, 1); + assert!(r.glyphs >= 6, "the text under the mark went: {r:?}"); + assert!(marks(&doc).is_empty(), "the mark itself went"); +} + +#[test] +fn a_mark_written_in_place_with_an_indirect_subtype_is_refused() { + let objs: Vec> = 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] /Contents 4 0 R /Annots [<< /Type /Annot /Subtype 5 0 R /Rect [10 10 70 40] >>] >>" + .to_vec(), + stream("", b"BT ET"), + b"/Redact".to_vec(), + ]; + let mut doc = pdf(objs); + mark(&mut doc, 0, &[[100.0, 100.0, 120.0, 120.0]], ""); + assert!(matches!(apply(&mut doc, None), Err(RedactError::UnreadableMark(1)))); +} + +#[test] +fn annotations_written_in_place_go_when_covered_or_hanging_off_a_removed_one() { + // `/Annots` may hold dictionaries rather than references. One under the mark, a reply to a + // removed highlight on the same page and on another, and a reply to that inline comment's + // pop-up must all go; an inline comment elsewhere stays. + let objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 5 0 R /Annots [6 0 R \ + << /Type /Annot /Subtype /Text /Rect [30 15 40 25] /Contents (INLINE-UNDER) /Popup 7 0 R >> \ + << /Type /Annot /Subtype /Text /Rect [250 250 270 270] /IRT 6 0 R /Contents (INLINE-REPLY) >> \ + << /Type /Annot /Subtype /Text /Rect [250 100 270 120] /Contents (INLINE-KEPT) >> 7 0 R] >>" + .to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 5 0 R /Annots [\ + << /Type /Annot /Subtype /Text /Rect [250 250 270 270] /IRT 6 0 R /Contents (OTHER-PAGE-INLINE-REPLY) >> \ + << /Type /Annot /Subtype /Text /Rect [200 200 220 220] /IRT 7 0 R /Contents (POPUP-INLINE-REPLY) >>] >>" + .to_vec(), + stream("", b"BT ET"), + b"<< /Type /Annot /Subtype /Highlight /Rect [20 20 60 30] /QuadPoints [20 30 60 30 20 20 60 20] /Contents (HIGHLIGHT-NOTE) >>".to_vec(), + b"<< /Type /Annot /Subtype /Popup /Rect [200 150 290 190] /Contents (INLINE-POPUP) >>".to_vec(), + ]; + let mut doc = pdf(objs); + mark(&mut doc, 0, &[[10.0, 10.0, 70.0, 40.0]], ""); + let r = apply(&mut doc, None).unwrap(); + assert_eq!(r.annotations, 5, "the highlight, the inline comment and the three replies: {r:?}"); + let pages = pdfcraft_model::pages(&doc); + assert_eq!(annots_of(&doc, &pages[0].dict).len(), 1, "only the inline comment elsewhere stays"); + assert!(annots_of(&doc, &pages[1].dict).is_empty(), "the replies leave page 2"); + let bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap(); + for gone in [&b"HIGHLIGHT-NOTE"[..], b"INLINE-UNDER", b"INLINE-REPLY", b"OTHER-PAGE-INLINE-REPLY", b"POPUP-INLINE-REPLY", b"INLINE-POPUP"] { + assert!(!bytes.windows(gone.len()).any(|w| w == gone), "{} survived the save", String::from_utf8_lossy(gone)); + } + assert!(bytes.windows(11).any(|w| w == b"INLINE-KEPT")); +} + #[test] fn a_rewritten_form_xobject_drops_the_source_text_of_added_content() { // A form drawing invisible text whose dictionary repeats it in /PCAdded. @@ -6717,6 +6789,49 @@ fn a_redacted_form_xobject_drops_the_source_text_of_added_content() { assert!(!bytes.windows(4).any(|w| w == b"1234"), "redacted text left in the saved file"); } +#[test] +fn an_inline_widget_takes_its_field_even_with_no_referenced_widget() { + // The field's only widget is written in place in `/Annots`, so no referenced widget names it: + // the field (and its value) must still go with the widget under the mark. + let objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [6 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] /Contents 4 0 R /Annots [<< /Type /Annot /Subtype /Widget /Rect [20 20 60 30] /Parent 6 0 R >>] >>" + .to_vec(), + stream("", b"BT ET"), + b"<< >>".to_vec(), + b"<< /FT /Tx /T (name) /V (FIELD-SECRET) >>".to_vec(), + ]; + let mut doc = pdf(objs); + mark(&mut doc, 0, &[[10.0, 10.0, 70.0, 40.0]], ""); + let r = apply(&mut doc, None).unwrap(); + assert_eq!(r.fields, 1, "{r:?}"); + let bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap(); + assert!(!bytes.windows(12).any(|w| w == b"FIELD-SECRET"), "the field value survived the save"); +} + +#[test] +fn an_inline_widget_of_the_field_on_another_page_goes_with_it() { + // Two pages: the widget under the mark, and a sibling widget in place on the second page with the + // same `/Parent`. Deleting the field must not leave the sibling naming it. + let objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [7 0 R] >> >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R 5 0 R] /Count 2 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Annots [<< /Type /Annot /Subtype /Widget /Rect [20 20 60 30] /Parent 7 0 R >>] >>" + .to_vec(), + stream("", b"BT ET"), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Annots [<< /Type /Annot /Subtype /Widget /Rect [200 200 260 230] /Parent 7 0 R >>] >>" + .to_vec(), + b"<< >>".to_vec(), + b"<< /FT /Tx /T (name) /V (SIBLING-SECRET) >>".to_vec(), + ]; + let mut doc = pdf(objs); + mark(&mut doc, 0, &[[10.0, 10.0, 70.0, 40.0]], ""); + apply(&mut doc, None).unwrap(); + let bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap(); + assert!(!bytes.windows(14).any(|w| w == b"SIBLING-SECRET"), "the field value survived through the sibling widget"); +} + /// Private data takes an image's prepress data too: its OPI link to the original file and its /// alternate images, which can be the unedited original. #[test]