From 9c8cf1a701664080a9e7d2195f1e93c3733059d3 Mon Sep 17 00:00:00 2001 From: Earl Co Date: Mon, 12 Oct 2026 07:32:53 +0200 Subject: [PATCH] Add case and whole-word options to redaction search (#1258) --- crates/automation/src/redact.rs | 11 ++++++++++- crates/automation/src/tools.rs | 4 +++- crates/automation/tests/automation.rs | 21 +++++++++++++++++++++ crates/ui-egui/src/redact_ui.rs | 18 ++++++++++++++++-- crates/ui-egui/tests/ui_group/redact.rs | 22 ++++++++++++++++++++++ parity/acrobat-features.toml | 4 ++-- 6 files changed, 74 insertions(+), 6 deletions(-) diff --git a/crates/automation/src/redact.rs b/crates/automation/src/redact.rs index 44baf5e6..60d8c8b7 100644 --- a/crates/automation/src/redact.rs +++ b/crates/automation/src/redact.rs @@ -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, _)> = 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(); diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 68bceb97..e01cc06b 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -402,7 +402,7 @@ pub fn tools() -> Vec { 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 { "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)"), diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 5f76908a..d56f7529 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -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"); diff --git a/crates/ui-egui/src/redact_ui.rs b/crates/ui-egui/src/redact_ui.rs index a0dc247b..8a431a73 100644 --- a/crates/ui-egui/src/redact_ui.rs +++ b/crates/ui-egui/src/redact_ui.rs @@ -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, @@ -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| { diff --git a/crates/ui-egui/tests/ui_group/redact.rs b/crates/ui-egui/tests/ui_group/redact.rs index 2de4cc85..76e075d0 100644 --- a/crates/ui-egui/tests/ui_group/redact.rs +++ b/crates/ui-egui/tests/ui_group/redact.rs @@ -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(); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 7c85c7cc..faef35b1 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -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"