mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-24 10:08:00 -06:00
* M8: redaction removes the replies of the annotations it removes (#2659) A reply (/IRT) placed away from the mark survived, and its /IRT kept the removed annotation - its comment text and the QuadPoints of the redacted words - in the saved file. Replies to a removed annotation, and replies to those, now go with it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz * M8: test replies listed before their parent, with a pop-up on a reply (#2659) Review of #2697: the replies precede the annotation they answer in /Annots, deepest first, and a reply has a pop-up. A single pass in /Annots order would keep them; the test fails with the loop cut to one pass, which the first test does not notice. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
5377a223e9
commit
2c4b84f6af
@@ -25,7 +25,7 @@ clear_marks(&mut doc, None)?; // remove marks without app
|
||||
- **vectors**: covered paths are removed; partly covered paths and shadings are clipped so
|
||||
nothing paints inside the areas;
|
||||
- **form XObjects**: rewritten recursively into new objects (pages sharing the original keep it);
|
||||
- **annotations** whose rectangle overlaps (with their pop-ups), and **form fields** with a
|
||||
- **annotations** whose rectangle overlaps (with their pop-ups and their replies, `/IRT`), and **form fields** with a
|
||||
widget under a mark;
|
||||
- the marks themselves, replaced by boxes in their fill colour (and overlay text) drawn into the
|
||||
page as a `/PCMark /Redaction` stream.
|
||||
|
||||
@@ -455,6 +455,32 @@ pub fn apply(doc: &mut Document, pages: Option<&[usize]>) -> Result<Report, Reda
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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);
|
||||
report.annotations += 1;
|
||||
}
|
||||
}
|
||||
if removed.len() == before {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let kept: Vec<Object> = annots
|
||||
.into_iter()
|
||||
.filter(|a| {
|
||||
|
||||
@@ -490,6 +490,59 @@ fn redacting_an_xfa_field_drops_the_xfa_packet_so_its_value_cannot_survive() {
|
||||
assert!(cat.get(b"NeedsRendering").is_none(), "/NeedsRendering is cleared");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replies_to_a_redacted_comment_go_with_it() {
|
||||
// #2659: a highlight under the mark has a reply placed elsewhere, and that reply a reply of
|
||||
// its own. The replies' /IRT would keep the highlight (its comment and QuadPoints) in the file.
|
||||
let objs: Vec<Vec<u8>> = 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 [5 0 R 6 0 R 7 0 R 8 0 R] >>".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 /Text /Rect [250 250 270 270] /IRT 5 0 R /Contents (FIRST-REPLY) >>".to_vec(),
|
||||
b"<< /Type /Annot /Subtype /Text /Rect [250 200 270 220] /IRT 6 0 R /Contents (SECOND-REPLY) >>".to_vec(),
|
||||
b"<< /Type /Annot /Subtype /Text /Rect [250 100 270 120] /Contents (UNRELATED) >>".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, 3, "the highlight and both replies: {r:?}");
|
||||
let p = pdfcraft_model::pages(&doc).swap_remove(0);
|
||||
assert_eq!(annots_of(&doc, &p.dict).len(), 1, "only the unrelated note stays");
|
||||
// A classic table, so the objects' strings are plain in the file.
|
||||
let bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap();
|
||||
for gone in [&b"HIGHLIGHT-NOTE"[..], b"FIRST-REPLY", b"SECOND-REPLY"] {
|
||||
assert!(!bytes.windows(gone.len()).any(|w| w == gone), "{} survived the save", String::from_utf8_lossy(gone));
|
||||
}
|
||||
assert!(bytes.windows(9).any(|w| w == b"UNRELATED"));
|
||||
}
|
||||
|
||||
/// The replies listed before the annotations they answer, deepest first, and a pop-up on a
|
||||
/// reply: one pass in `/Annots` order would keep them, the repeated passes don't (review of
|
||||
/// #2697).
|
||||
#[test]
|
||||
fn replies_listed_before_their_parent_and_their_pop_ups_go_too() {
|
||||
let objs: Vec<Vec<u8>> = 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 [7 0 R 9 0 R 6 0 R 5 0 R 8 0 R] >>".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 /Text /Rect [250 250 270 270] /IRT 5 0 R /Contents (FIRST-REPLY) /Popup 9 0 R >>".to_vec(),
|
||||
b"<< /Type /Annot /Subtype /Text /Rect [250 200 270 220] /IRT 6 0 R /Contents (SECOND-REPLY) >>".to_vec(),
|
||||
b"<< /Type /Annot /Subtype /Text /Rect [250 100 270 120] /Contents (UNRELATED) >>".to_vec(),
|
||||
b"<< /Type /Annot /Subtype /Popup /Rect [200 150 290 190] /Parent 6 0 R >>".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, 3, "the highlight and both replies: {r:?}");
|
||||
let p = pdfcraft_model::pages(&doc).swap_remove(0);
|
||||
let left = annots_of(&doc, &p.dict);
|
||||
assert_eq!(left, vec![Object::Ref(ObjRef::new(8, 0))], "only the unrelated note stays, the reply's pop-up goes too");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comments_links_and_fields_under_a_mark_go() {
|
||||
let mut doc = one_page(b"", "", vec![]);
|
||||
|
||||
@@ -5728,8 +5728,8 @@ milestone = "M8"
|
||||
status = "shipped"
|
||||
commands = ["redact.apply"]
|
||||
tools = ["redact_apply"]
|
||||
evidence = ["crates/redact/src/tests.rs::comments_links_and_fields_under_a_mark_go", "crates/ui-egui/tests/ui_group/redact.rs::marking_applying_and_clearing"]
|
||||
notes = "with pop-ups; fields lose all widgets"
|
||||
evidence = ["crates/redact/src/tests.rs::comments_links_and_fields_under_a_mark_go", "crates/redact/src/tests.rs::replies_to_a_redacted_comment_go_with_it", "crates/ui-egui/tests/ui_group/redact.rs::marking_applying_and_clearing"]
|
||||
notes = "with pop-ups and replies (/IRT, transitively; replies on other pages are not followed); fields lose all widgets"
|
||||
|
||||
[[feature]]
|
||||
id = "protect.redact-struct-tree"
|
||||
|
||||
Reference in New Issue
Block a user