mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-10 21:53:07 +00:00
M8: Apply redactions passes on where the removed text is still held; redact_apply returns it (#2448)
The document keeps the last apply's Report::still_holding as redaction_elsewhere (re-exported as RedactionElsewhere), cleared by the next edit, undo or redo as edit_notices is. redact_apply returns it as still_holding with the four counts, their total and unsearched. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
1fd2ff31bb
commit
d37bde8c3b
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -544,7 +544,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
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"])),
|
||||
|
||||
@@ -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<String> = 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() {
|
||||
|
||||
@@ -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<TextNotice>,
|
||||
/// 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<pdfcraft_redact::Elsewhere>,
|
||||
}
|
||||
|
||||
/// Text an edit stored as typed but drew with `?` in place of `ch`, because the font it is drawn
|
||||
|
||||
@@ -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<pdfcraft_cos::ObjRef>,
|
||||
/// What the edit's applied redactions left as it was that still holds the removed text.
|
||||
pub(crate) redaction_elsewhere: Option<pdfcraft_redact::Elsewhere>,
|
||||
}
|
||||
|
||||
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)?;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -352,6 +352,7 @@ impl Session {
|
||||
fonts_stale: false,
|
||||
xfa_warnings: Vec::new(),
|
||||
edit_notices: Vec::new(),
|
||||
redaction_elsewhere: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String> = 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)
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user