Add case and whole-word options to redaction search (#1258)

This commit is contained in:
Earl Co 2026-10-12 07:32:53 +02:00 committed by GitHub
parent d0975b974d
commit 9c8cf1a701
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 74 additions and 6 deletions

View File

@ -54,6 +54,11 @@ impl Automation {
if chosen.iter().filter(|c| **c).count() != 1 {
return Err(ToolError::InvalidArgs("pass exactly one of rect (with page), find, words, pattern or whole_pages: true".into()));
}
let case_sensitive = a.opt_bool("case_sensitive")?.unwrap_or(false);
let whole_words = a.opt_bool("whole_words")?.unwrap_or(false);
if (case_sensitive || whole_words) && a.get("find").is_none() && a.get("words").is_none() {
return Err(ToolError::InvalidArgs("case_sensitive and whole_words apply only to find or words".into()));
}
// Per searched word or phrase: how many marks it made.
let mut matched_words: Vec<(String, usize)> = Vec::new();
if a.get("rect").is_some() {
@ -95,7 +100,11 @@ impl Automation {
for (&p, text) in pages.iter().zip(&texts) {
let hits: Vec<(Option<usize>, _)> = match pattern {
Some(pat) => text.find_with(|chars| find_pattern(pat, chars)).into_iter().map(|h| (None, h)).collect(),
None => needles.iter().enumerate().flat_map(|(i, w)| text.find(w).into_iter().map(move |h| (Some(i), h))).collect(),
None => needles
.iter()
.enumerate()
.flat_map(|(i, w)| text.find_opts(w, case_sensitive, whole_words).into_iter().map(move |h| (Some(i), h)))
.collect(),
};
for (word, h) in hits {
let quads: Vec<[f64; 8]> = text.line_rects(h).into_iter().map(|r| info.pages[p].view_rect_to_quad(r)).collect();

View File

@ -402,7 +402,7 @@ pub fn tools() -> Vec<ToolDef> {
t(
"redact_mark",
"Mark for redaction",
"Mark content for redaction (nothing is removed until redact_apply). One of: rect [x0, y0, x1, y1] (points from the top-left of the displayed page) with page; find (text, every match); words (a list of words or phrases, every match of each; the result's matched_words says how many marks each made); pattern (phone, email, credit-card, ssn, date: every match, Acrobat's Search & Redact patterns); whole_pages: true. find, words, pattern and whole_pages work on pages (default all). overlay: text shown on the box once applied, or code_set (foia: U.S. FOIA (b)(1)(A)…(b)(9); privacy-act: U.S. Privacy Act (d)(5), (j)(1)…(k)(7)) with codes, shown as \"(b)(6), (b)(7)(C)\"; fill: box colour (default black). Undoable.",
"Mark content for redaction (nothing is removed until redact_apply). One of: rect [x0, y0, x1, y1] (points from the top-left of the displayed page) with page; find (text, every match); words (a list of words or phrases, every match of each; the result's matched_words says how many marks each made); pattern (phone, email, credit-card, ssn, date: every match, Acrobat's Search & Redact patterns); whole_pages: true. find and words accept case_sensitive and whole_words (both default false). find, words, pattern and whole_pages work on pages (default all). overlay: text shown on the box once applied, or code_set (foia: U.S. FOIA (b)(1)(A)…(b)(9); privacy-act: U.S. Privacy Act (d)(5), (j)(1)…(k)(7)) with codes, shown as \"(b)(6), (b)(7)(C)\"; fill: box colour (default black). Undoable.",
)
.with(schema(
json!({
@ -411,6 +411,8 @@ pub fn tools() -> Vec<ToolDef> {
"rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 },
"find": { "type": "string", "minLength": 1 },
"words": { "type": "array", "items": { "type": "string", "minLength": 1 }, "minItems": 1, "maxItems": 1000 },
"case_sensitive": { "type": "boolean", "description": "Match letter case for find or words (default false)." },
"whole_words": { "type": "boolean", "description": "Match whole words for find or words (default false)." },
"pattern": { "type": "string", "enum": ["phone", "email", "credit-card", "ssn", "date"] },
"whole_pages": { "type": "boolean" },
"pages": pages("to search or mark (default: all)"),

View File

@ -2645,6 +2645,27 @@ fn redacting_word_lists_through_tools() {
assert!(matches!(a.call("redact_mark", &json!({ "doc": doc, "words": ["absent", "nowhere"] })), Err(ToolError::Failed(_))));
}
#[test]
fn redaction_search_respects_case_and_word_boundaries() {
let dir = workdir("redact-find-options");
std::fs::write(dir.join("memo.txt"), "Ada ada Adaline\nAda beta betamax").unwrap();
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_create", json!({ "from": "text", "path": "memo.txt" }))["doc"].as_u64().unwrap();
let mark = |a: &mut Automation, args: Value| ok(a, "redact_mark", args)["marked"].as_u64().unwrap();
assert_eq!(mark(&mut a, json!({ "doc": doc, "find": "Ada", "case_sensitive": true, "whole_words": true })), 2);
ok(&mut a, "edit_undo", json!({ "doc": doc }));
assert_eq!(mark(&mut a, json!({ "doc": doc, "find": "Ada", "whole_words": true })), 3);
ok(&mut a, "edit_undo", json!({ "doc": doc }));
let result = ok(&mut a, "redact_mark", json!({ "doc": doc, "words": ["Ada", "beta"], "case_sensitive": true, "whole_words": true }));
assert_eq!(result["matched_words"], json!([{ "word": "Ada", "marked": 2 }, { "word": "beta", "marked": 1 }]));
ok(&mut a, "edit_undo", json!({ "doc": doc }));
assert!(matches!(a.call("redact_mark", &json!({ "doc": doc, "pattern": "email", "whole_words": true })), Err(ToolError::InvalidArgs(_))));
assert_eq!(mark(&mut a, json!({ "doc": doc, "find": "Ada", "case_sensitive": true, "whole_words": true })), 2);
ok(&mut a, "redact_apply", json!({ "doc": doc }));
let text = &page_text(&mut a, doc)[0];
assert!(text.contains("ada") && text.contains("Adaline") && text.contains("betamax"), "{text}");
}
#[test]
fn removing_hidden_information_through_tools() {
let dir = workdir("hidden");

View File

@ -92,6 +92,8 @@ pub struct SearchDraft {
pub text: String,
/// The word list, one word or phrase per line.
pub words: String,
pub case_sensitive: bool,
pub whole_words: bool,
pub pattern: RedactPattern,
/// The result of the last search (matches marked), shown in the dialog.
pub found: Option<usize>,
@ -99,7 +101,15 @@ pub struct SearchDraft {
impl Default for SearchDraft {
fn default() -> Self {
Self { mode: SearchMode::Phrase, text: String::new(), words: String::new(), pattern: RedactPattern::Phone, found: None }
Self {
mode: SearchMode::Phrase,
text: String::new(),
words: String::new(),
case_sensitive: false,
whole_words: false,
pattern: RedactPattern::Phone,
found: None,
}
}
}
@ -201,7 +211,7 @@ impl PdfCraftApp {
let Some(text) = out.text else { continue };
let hits = match d.mode {
SearchMode::Patterns => text.find_with(|c| pdfcraft_engine::find_pattern(d.pattern, c)),
_ => needles.iter().flat_map(|w| text.find(w)).collect(),
_ => needles.iter().flat_map(|w| text.find_opts(w, d.case_sensitive, d.whole_words)).collect(),
};
for h in hits {
let quads: Vec<[f64; 8]> = text.line_rects(h).into_iter().map(|r| page_info.view_rect_to_quad(r)).collect();
@ -285,6 +295,10 @@ pub(crate) fn search_body(ui: &mut egui::Ui, d: &mut SearchDraft, t: &Tokens) ->
ui.label(egui::RichText::new(crate::i18n::fmt(tl!("{n} word(s) or phrase(s)"), &[("n", &n.to_string())])).small().color(t.text_faint));
});
});
ui.add_enabled_ui(d.mode != SearchMode::Patterns, |ui| {
ui.add(widgets::checkbox(&mut d.whole_words, tl!("Whole words only")));
ui.add(widgets::checkbox(&mut d.case_sensitive, tl!("Case-sensitive")));
});
ui.add_space(6.0);
widgets::radio_value(ui, &mut d.mode, SearchMode::Patterns, tl!("Patterns"));
ui.add_enabled_ui(d.mode == SearchMode::Patterns, |ui| {

View File

@ -134,6 +134,28 @@ fn redacting_an_imported_word_list() {
assert_eq!(h.state().redact_search.words, "Ada\nprivate\nabsent", "an oversized list changes nothing");
}
#[test]
fn redaction_search_can_match_case_and_whole_words() {
let memo = pdfcraft_engine::Session::new().create_from_text("memo", "Ada ada Adaline\nAda beta betamax").unwrap().to_vec();
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("memo.pdf", None, memo.clone()).unwrap();
app
});
h.run_steps(4);
h.state_mut().execute("redact.search");
h.state_mut().redact_search.text = "Ada".into();
h.run_steps(2);
h.get_by_label("Whole words only").click();
h.get_by_label("Case-sensitive").click();
h.run_steps(2);
assert!(h.state().redact_search.whole_words && h.state().redact_search.case_sensitive);
h.get_by_label("Mark all").click();
h.run_steps(3);
assert_eq!((marks(&h), h.state().redact_search.found), (2, Some(2)));
}
#[test]
fn removing_hidden_information_and_sanitizing() {
let mut h = harness();

View File

@ -5554,8 +5554,8 @@ milestone = "M8"
status = "shipped"
commands = ["redact.search"]
tools = ["redact_mark"]
evidence = ["crates/redact/src/patterns.rs::word_lists_are_trimmed_unique_and_bounded", "crates/automation/tests/automation.rs::redacting_word_lists_through_tools", "crates/ui-egui/tests/ui_group/redact.rs::redacting_an_imported_word_list"]
notes = "Find text and redact ▸ Multiple words or phrases: one per line, typed or imported from a .txt file (at most 1 MB, 1,000 entries of 256 characters); every match of each is marked in one undo step; redact_mark words reports marks per word. Not yet: whole-words-only and case-sensitive options, exporting the list"
evidence = ["crates/redact/src/patterns.rs::word_lists_are_trimmed_unique_and_bounded", "crates/automation/tests/automation.rs::redacting_word_lists_through_tools", "crates/automation/tests/automation.rs::redaction_search_respects_case_and_word_boundaries", "crates/ui-egui/tests/ui_group/redact.rs::redacting_an_imported_word_list", "crates/ui-egui/tests/ui_group/redact.rs::redaction_search_can_match_case_and_whole_words"]
notes = "Find text and redact ▸ Multiple words or phrases: one per line, typed or imported from a .txt file (at most 1 MB, 1,000 entries of 256 characters); every match of each is marked in one undo step; redact_mark words reports marks per word. Single phrase and word-list searches offer case-sensitive and whole-words-only options in the UI and headless tool. Not yet: exporting the list"
[[feature]]
id = "protect.redact-patterns"