diff --git a/crates/automation/src/redact.rs b/crates/automation/src/redact.rs index e662bcac19..baea3d75c5 100644 --- a/crates/automation/src/redact.rs +++ b/crates/automation/src/redact.rs @@ -165,6 +165,16 @@ impl Automation { let after = self.doc(a)?.redaction_marks(); out["applied"] = json!(before - after); out["marks_pending"] = json!(after); + // Where the removed text is still held that applying leaves as it is. + let left = self.doc(a)?.redaction_elsewhere.unwrap_or_default(); + out["still_holding"] = json!({ + "bookmarks": left.bookmarks, + "attachments": left.attachments, + "comments": left.comments, + "xmp": left.xmp, + "total": left.total(), + "unsearched": left.unsearched, + }); Ok(out) } diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 53c4582af9..2d4d597a75 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -544,7 +544,7 @@ pub fn tools() -> Vec { t( "redact_apply", "Apply redactions", - "Apply the redaction marks (all, or those on pages): text, images, vectors, comments and form fields under them are removed for good and boxes are drawn in their place. A verification pass fails the operation if anything readable remains. Undoable until saved; the saved file no longer contains the content.", + "Apply the redaction marks (all, or those on pages): text, images, vectors, comments and form fields under them are removed for good and boxes are drawn in their place. A verification pass fails the operation if anything readable remains. Document metadata (Info entries, the document's and pages' XMP) that repeats the removed text is removed too. still_holding counts the places applying leaves as they are that still hold the removed text: bookmarks (titles), attachments (names, file names, descriptions), comments (text, rich text, subject or author of those that remain) and xmp (XMP on images, fonts, forms, annotations and other objects), with their total; doc_remove_hidden removes them. unsearched: true means the removed text couldn't be searched for, so every such place holding any text is counted. Undoable until saved; the saved file no longer contains the content.", ) .destructive() .with(schema(json!({ "doc": doc(), "pages": pages("whose marks to apply (default: all)") }), &["doc"])), diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 01d0d1eb24..412bc9f80e 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -4289,6 +4289,31 @@ fn redacting_through_tools() { assert!(matches!(a.call("redact_mark", &json!({ "doc": doc })), Err(ToolError::InvalidArgs(_)))); } +/// #2539 review: `redact_apply` says where the removed text is still held that applying leaves as +/// it is (a bookmark title and a note's text here), and changes neither. +#[test] +fn redact_apply_reports_places_that_still_hold_the_removed_text() { + let dir = workdir("redact-still-holding"); + std::fs::write(dir.join("memo.txt"), "Codename Bluefin report\nPublic line").unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_create", json!({ "from": "text", "path": "memo.txt" }))["doc"].as_u64().unwrap(); + ok(&mut a, "bookmark_add", json!({ "doc": doc, "title": "Bluefin plans", "page": 1 })); + ok(&mut a, "bookmark_add", json!({ "doc": doc, "title": "Annual figures", "page": 1 })); + ok(&mut a, "comment_add", json!({ "doc": doc, "page": 1, "type": "note", "at": [300, 400], "contents": "Ask about bluefin" })); + ok(&mut a, "comment_add", json!({ "doc": doc, "page": 1, "type": "note", "at": [300, 500], "contents": "Looks good" })); + ok(&mut a, "redact_mark", json!({ "doc": doc, "find": "Bluefin" })); + let r = ok(&mut a, "redact_apply", json!({ "doc": doc })); + assert_eq!(r["still_holding"], json!({ "bookmarks": 1, "attachments": 0, "comments": 1, "xmp": 0, "total": 2, "unsearched": false }), "{r}"); + assert!(!page_text(&mut a, doc)[0].contains("Bluefin")); + let titles: Vec = ok(&mut a, "bookmark_list", json!({ "doc": doc }))["bookmarks"] + .as_array() + .unwrap() + .iter() + .map(|b| b["title"].as_str().unwrap().to_string()) + .collect(); + assert_eq!(titles, ["Bluefin plans", "Annual figures"], "reported, not changed"); +} + #[test] /// Mark, apply and save remove the complete international number while retaining labels and controls. fn redacting_italian_international_slash_numbers_through_tools() { diff --git a/crates/engine/src/document.rs b/crates/engine/src/document.rs index eaa8f70807..7ad7fffec9 100644 --- a/crates/engine/src/document.rs +++ b/crates/engine/src/document.rs @@ -62,6 +62,11 @@ pub struct Document { /// What the last successful [`Session::apply`](crate::Session::apply) wrote that its fonts can't draw: kept as typed, /// drawn with `?` in its place (see [`TextNotice`]). The interface shows it; tools return it. pub edit_notices: Vec, + /// What the last successful [`Session::apply`](crate::Session::apply), when it applied redactions, + /// found still holding the removed text in places applying leaves as they are (bookmark titles, + /// attachments, remaining comments, other XMP; see [`RedactionElsewhere`](crate::RedactionElsewhere)). + /// `None` after any other edit, an undo or a redo. The interface says so; tools return it. + pub redaction_elsewhere: Option, } /// Text an edit stored as typed but drew with `?` in place of `ch`, because the font it is drawn diff --git a/crates/engine/src/edit/run.rs b/crates/engine/src/edit/run.rs index 32dc7942ab..af49c1db00 100644 --- a/crates/engine/src/edit/run.rs +++ b/crates/engine/src/edit/run.rs @@ -183,6 +183,8 @@ pub(crate) struct EditCtx { /// The overlay streams the edit's applied redactions added, which alone the renderer's check /// leaves out. pub(crate) redaction_overlays: Vec, + /// What the edit's applied redactions left as it was that still holds the removed text. + pub(crate) redaction_elsewhere: Option, } impl EditCtx { @@ -211,6 +213,7 @@ impl EditCtx { count: 0, renamed_fields: Vec::new(), redaction_overlays: Vec::new(), + redaction_elsewhere: None, } } @@ -648,7 +651,9 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E pdfcraft_edit::add_content(doc, *page, &content)?; } Edit::ApplyRedactions { pages } => { - cx.redaction_overlays = pdfcraft_redact::apply(doc, pages.as_deref())?.overlays; + let report = pdfcraft_redact::apply(doc, pages.as_deref())?; + cx.redaction_overlays = report.overlays; + cx.redaction_elsewhere = Some(report.still_holding); } Edit::ClearRedactions => { pdfcraft_redact::clear_marks(doc, None)?; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index c41c0466c4..c168cd2bf9 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -112,6 +112,7 @@ pub use pdfcraft_forms::detect; pub use pdfcraft_optimize as optimize; pub use pdfcraft_preflight as pdfa; pub use pdfcraft_print as print; +pub use pdfcraft_redact::Elsewhere as RedactionElsewhere; pub use pdfcraft_redact::codes::{CODE_SETS as REDACTION_CODE_SETS, CodeSet as RedactionCodeSet}; pub use pdfcraft_redact::patterns::{ MAX_WORDS as REDACT_MAX_WORDS, PATTERNS as REDACT_PATTERNS, Pattern as RedactPattern, find as find_pattern, word_list as redact_word_list, diff --git a/crates/engine/src/open.rs b/crates/engine/src/open.rs index 58bc8327e2..84c9472a20 100644 --- a/crates/engine/src/open.rs +++ b/crates/engine/src/open.rs @@ -352,6 +352,7 @@ impl Session { fonts_stale: false, xfa_warnings: Vec::new(), edit_notices: Vec::new(), + redaction_elsewhere: None, } } } diff --git a/crates/engine/src/session/editing.rs b/crates/engine/src/session/editing.rs index f8d5648a2d..675597a565 100644 --- a/crates/engine/src/session/editing.rs +++ b/crates/engine/src/session/editing.rs @@ -16,6 +16,7 @@ impl Session { let xfa_time_scale = self.xfa_time_scale.unwrap_or(1); let doc = self.doc_mut(id)?; doc.edit_notices.clear(); + doc.redaction_elsewhere = None; let name = doc.name.clone(); let is_xfa = doc.info.xfa.is_some(); let mut scope = scope_of(&edit); @@ -201,6 +202,7 @@ impl Session { doc.dirty = true; doc.generation += 1; doc.edit_notices = notices; + doc.redaction_elsewhere = cx.redaction_elsewhere; if !cx.xfa_removed.is_empty() { let removed: Vec = cx.xfa_removed.iter().map(|n| format!("{XFA_REMOVED_PREFIX}{n}")).collect(); doc.xfa_warnings.retain(|w| w.starts_with(XFA_REMOVED_PREFIX)); @@ -246,6 +248,7 @@ impl Session { return Err(e); } doc.edit_notices.clear(); + doc.redaction_elsewhere = None; Self::sync_dirty(doc); doc.generation += 1; Ok(label) @@ -275,6 +278,7 @@ impl Session { return Err(e); } doc.edit_notices.clear(); + doc.redaction_elsewhere = None; Self::sync_dirty(doc); doc.generation += 1; Ok(label) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 6a68916dff..16a612339c 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2829,6 +2829,25 @@ fn text_after_an_inline_image_is_redacted_where_the_page_shows_it() { ); } +/// #2539 review: Apply redactions tells the caller where the removed text is still held that +/// it leaves as it is (here a bookmark title), and the next edit, or an undo, forgets it. +#[test] +fn applied_redactions_report_a_bookmark_that_still_holds_the_removed_text() { + let mut s = Session::new().with_clock(|| 1_700_000_000); + let page = one_line_page("/Font << /F1 4 0 R >>", "BT /F1 10 Tf 20 150 Td (Codename BLUEFIN report) Tj ET"); + let id = s.open("bookmark.pdf", None, Arc::new(page), None).unwrap(); + s.apply(id, Edit::AddBookmark { parent: vec![], index: 0, title: "Bluefin plans".into(), page: 0, left: None, top: None }).unwrap(); + s.apply(id, Edit::AddBookmark { parent: vec![], index: 1, title: "Annual figures".into(), page: 0, left: None, top: None }).unwrap(); + mark_shown_text(&mut s, id, "BLUEFIN"); + assert_eq!(s.get(id).unwrap().redaction_elsewhere, None, "only applying redactions reports it"); + s.apply(id, Edit::ApplyRedactions { pages: None }).unwrap(); + let found = s.get(id).unwrap().redaction_elsewhere.expect("applying reports what it left"); + assert_eq!(found, crate::RedactionElsewhere { bookmarks: 1, ..Default::default() }); + assert_eq!(outline_titles(&s.get(id).unwrap().info.outline), ["Bluefin plans→1", "Annual figures→1"], "the bookmark is reported, not changed"); + s.undo(id).unwrap(); + assert_eq!(s.get(id).unwrap().redaction_elsewhere, None, "an undo forgets it"); +} + /// A word whose font and size an ExtGState /Font entry (`gs`) sets is placed where the renderer /// draws it, so a mark drawn over it as displayed takes it out of the file. #[test]