Add case and whole-word options to redaction search (#1258)
This commit is contained in:
parent
d0975b974d
commit
9c8cf1a701
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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)"),
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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| {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue