mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-14 19:27:04 -04:00
M8: redact lists of words or phrases (protect.redact-word-lists) (#757)
* M8: redact lists of words or phrases Find text and redact gains "Multiple words or phrases": a list with one word or phrase per line, typed or imported from a .txt file (Import list…), whose every match is marked in one undo step. Lists are trimmed, deduplicated and bounded (1,000 entries of 256 characters; files up to 1 MB are read). redact_mark takes words and reports matched_words, the marks each entry made. protect.redact-word-lists is shipped; whole-word and case-sensitive matching and exporting the list are still missing. * M8: translate word-list labels into Arabic The Arabic catalog arrived on main after this branch, so the new Find text and redact strings were still English and failed arabic_covers_ui_literals. * Tests: drop sharp grid renders when the thumbnail will cover the cell Zooming the organize grid back out left the sharp page raster in place until a thumbnail texture existed, so the zoom test failed when that texture had not arrived yet. * M8: Czech rows for the word-list redaction labels Co-Authored-By: Claude Opus 5.5 <[email protected]> --------- Co-authored-by: Brandon Thomas <[email protected]> Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
30 files changed
+314
-56
No files matched your search
+15
-15
@@ -19,7 +19,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "c405d31d9b5a372f60f12cfb1192006b91d3056609216c1b93c0da25577be253"
|
||||
sha256 = "2692f1dfc21b9d7284ba69316085fb9a2f30dcb6d6f336401d7b9aa4b3fcf67a"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/zh-hant.tsv"
|
||||
@@ -30,7 +30,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "a0994ddd964023f1bdf813a5ee613d2964e5b055eaaa23950ed5f85d6258fad1"
|
||||
sha256 = "242e4c748a66ee6ff18d657ee3e1ef0808be0d5adc005e45723f93c835d142f8"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/cs.tsv"
|
||||
@@ -41,7 +41,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Czech UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "36f02d47c9c669efaf164514ccd68a6821247487d7febc27abfae33312ce88ce"
|
||||
sha256 = "c6c0dec458b50a15fd5a7b38ce9ec5772e38231c5db584ff9aecceb4955ce132"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/pt-br.tsv"
|
||||
@@ -52,7 +52,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "1406c6cc959145e2e374b8ed4cd27b996729b0074da7ed850da8d5b0e0b0069b"
|
||||
sha256 = "ef28211bc332cc811a786952e4b60ba7dafb8e9a8553d75e11c4f800ad71832e"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/es.tsv"
|
||||
@@ -63,7 +63,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "52eb0c536f99645cc06768177b4a9c3a949ceb92c30b2e2a5ac65a119b855ac2"
|
||||
sha256 = "ca6d842ff9568adc5f8d8223f7791f075faf94845369bc2dfc8e52fe727f8b23"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/fr.tsv"
|
||||
@@ -74,7 +74,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "381d04db4a7e4acf52cf90245e7293711e7de0be397098eb713ffa8fb8367044"
|
||||
sha256 = "7c791c69d517bcb16c487217861428b7f6a53b96e3c496e2cc6a8710e7c18365"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/de.tsv"
|
||||
@@ -85,7 +85,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "c2700d35ac8b339875c15d381fc4c7415d58edad3bf27fd076ff489dccafdf07"
|
||||
sha256 = "9030aef3c2790bb4dd1dda022c0d527fdce73a29bd4d1875edcbbaa95a3bf49e"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/te.tsv"
|
||||
@@ -96,7 +96,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "0bb7f55dcb9c5e196ea40cb7fa7ac25654f324148215e26ec6121d4f22b73fed"
|
||||
sha256 = "bd15f74a2630e97216f56ff329e8e8d7cea2eae341f875dd7804908ad13e20f8"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/it.tsv"
|
||||
@@ -107,7 +107,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Italian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "00b40b6f2e746961d9ebe3ff19b05e280f123dac9999a98ba596cf4c0bae19b4"
|
||||
sha256 = "da28f8082b39ee9cff48217f69b920edde7107d140d18d212d1d1f110c5178df"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/ru.tsv"
|
||||
@@ -118,7 +118,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "1dd0d60e7a4dc3261f5f50f34d60fcc290d8084ec9db5a1f88ed33213841a6f5"
|
||||
sha256 = "cbb0332f3e85a5267fc0af033aa77430a35e83171c3e2e5dfe02bf103e4fc0ce"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/hu.tsv"
|
||||
@@ -129,7 +129,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "832e71b4c59c324c60be5bf133a09c9072db69e0398dc6226963b3f5addbac0d"
|
||||
sha256 = "b4b1cef29030b23e8a3b10dc743a4a6394c0353aaddfc783bde79f6cfe8d81c5"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/bg.tsv"
|
||||
@@ -140,7 +140,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "8b476bc0d6d03ae02e544c540b9af61f0e903f1f0f6400987a6d42399ab2a8a9"
|
||||
sha256 = "efba2c1d7652d730c716eded3972982b5f6c1f797be48f4232a0d2efb7b19451"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/ja.tsv"
|
||||
@@ -151,7 +151,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "6033c48ff56b21ffe53400740f1e7989b93c8b6d40d4bdf0ea7b1ccd1b134c3e"
|
||||
sha256 = "d6043524e622f7e00f272566f779530ff3dc5f4e5be93f442be79172df7d2f5f"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/uk.tsv"
|
||||
@@ -162,7 +162,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "d5f8d6f6b0c4cd0447464f9a83509e6d854f67968be910af3a14994cb532851c"
|
||||
sha256 = "86512da6fab7b0860336437398b7ff57e03cee9db78e13dfd28b8aa8fee957a2"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/ar.tsv"
|
||||
@@ -173,7 +173,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "cef4cbb555852b5cb06de74d8e5a57995adfd304677dd7478758d89ccdb37e9b"
|
||||
sha256 = "0dbeebeebfa940260e4355aa2741ba68a3aacba7a3d24d721981035fa0bef254"
|
||||
|
||||
[[asset]]
|
||||
path = "assets/icons/accessibility.svg"
|
||||
|
||||
+2
-1
@@ -57,7 +57,7 @@ Read this before choosing work. The feature table above counts what exists; this
|
||||
| M Accessibility | 62% | Checker (all 32 rules). Missing: autotag, Tags/Order/Content panels, Reading Order tool, keyboard-only operation |
|
||||
| B View | 55% | Shell, find, panels, tiles, web build. **Rendering is borrowed (`hayro`)**, so the 7 rendering P0s are only partial |
|
||||
| D Organize | 55% | Pages, combine, split, bookmarks, labels. Missing: replace pages, transitions |
|
||||
| G Protect | 54% | Passwords, permissions, redaction, sanitize. Redaction codes (built-in FOIA and Privacy Act sets). Missing: certificate security, custom redaction code sets |
|
||||
| G Protect | 54% | Passwords, permissions, redaction (including word lists), sanitize. Redaction codes (built-in FOIA and Privacy Act sets). Missing: certificate security, custom redaction code sets |
|
||||
| H Sign | 48% | PAdES B-B signing and validation; macOS Keychain and Windows CNG certificate store signing. Missing: timestamps (B-T), LTV (DSS/OCSP/CRL), FieldMDP, smart cards, PKCS #11 |
|
||||
| C Edit | 46% | Added text and images stay editable; header/footer/watermark. Missing: robust editing of existing text and images (fonts, subsets, reflow) |
|
||||
| L Print | 36% | Acrobat-style sizing, n-up, booklet, CUPS. Missing: Windows and web printing, production options |
|
||||
@@ -131,6 +131,7 @@ M0 → M1 → M2 → M3 → M4 must happen in order. After M4, M5–M12 can run
|
||||
|
||||
## Log
|
||||
|
||||
- **2026-10-10 (M8, redact word lists):** Find text and redact gains "Multiple words or phrases": a list with one word or phrase per line, typed or imported from a `.txt` file, whose every match is marked in one undo step. Lists are trimmed, deduplicated and bounded (1,000 entries of 256 characters, files up to 1 MB). `redact_mark` takes `words` and reports how many marks each made, so an agent sees which found nothing. Tests cover the list parsing, the tool end to end (mark, undo, apply, invalid lists) and the dialog with an imported list; the new labels are translated in all 13 UI catalogs. `protect.redact-word-lists` is shipped; whole-word and case-sensitive matching and exporting the list are still missing. Estimates unchanged (about 30–35%).
|
||||
- **2026-10-10 (M4, bookmarks from structure):** Bookmark options ▸ New bookmarks from structure (command `bookmark.from_structure`, tool `bookmark_from_structure`) turns a tagged document's headings into bookmarks: H1–H6 through the role map, a plain H by its section nesting, nested by level under a new first "Untitled" bookmark as in Acrobat, each linked to its structure element (/SE). Titles come from /ActualText, the marked text on the page (decoded through the font's ToUnicode or encoding), /Alt or /T. The walk is bounded (depth, visits, heading and title caps, cycle-safe), an untagged document gets a clear error, and the edit is one undo step. Tests cover role maps, nesting, ToUnicode text, a self-referencing heading, the tool, undo and the panel. Still missing: Acrobat's element-type choice and destinations at the heading's position. The M4 row no longer lists Replace pages as missing (it shipped earlier). Overall estimate unchanged (about 30–35%).
|
||||
- **2026-10-10 (M9, signing encrypted documents):** Digital signatures, certification and document timestamps work on password-protected PDFs instead of failing with "not supported: signing encrypted documents". `pdfcraft-cos` no longer encrypts or decrypts a signature dictionary's `/Contents` (§7.6.2; by `/Type /Sig|/DocTimeStamp` or `/Contents` + `/ByteRange`), so the placeholder is patched after hashing as in a plain file and existing signatures in encrypted files read their real CMS (which also fixes their DSS `/VRI` keys and keeps them intact in a full rewrite). Opened with the user password, the signer applies the document's permissions (fill-forms for existing fields and timestamps, annotate + modify for new fields, modify for certifying) and names the permissions password when it refuses. Synthetic tests cover all four algorithms, counter-signing, tampering, permissions and the automation tools. Overall estimate unchanged (about 30–35%).
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
//! Redaction tools: mark areas, text, patterns or whole pages for redaction, apply the marks
|
||||
//! (removing what they cover for good) or clear them.
|
||||
|
||||
use pdfcraft_engine::{Edit, Hidden, NewAnnotation, REDACTION_CODE_SETS, RedactPattern, RedactionCodeSet, Shape, Style, find_pattern, rect_quad};
|
||||
use pdfcraft_engine::{
|
||||
Edit, Hidden, NewAnnotation, REDACT_MAX_WORDS, REDACTION_CODE_SETS, RedactPattern, RedactionCodeSet, Shape, Style, find_pattern, rect_quad,
|
||||
redact_word_list,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::comments::{DEFAULT_AUTHOR, parse_color};
|
||||
@@ -41,10 +44,18 @@ impl Automation {
|
||||
}
|
||||
// (page, quads) per mark.
|
||||
let mut marks: Vec<(usize, Vec<[f64; 8]>)> = Vec::new();
|
||||
let chosen = [a.get("rect").is_some(), a.get("find").is_some(), a.get("pattern").is_some(), a.opt_bool("whole_pages")?.unwrap_or(false)];
|
||||
let chosen = [
|
||||
a.get("rect").is_some(),
|
||||
a.get("find").is_some(),
|
||||
a.get("words").is_some(),
|
||||
a.get("pattern").is_some(),
|
||||
a.opt_bool("whole_pages")?.unwrap_or(false),
|
||||
];
|
||||
if chosen.iter().filter(|c| **c).count() != 1 {
|
||||
return Err(ToolError::InvalidArgs("pass exactly one of rect (with page), find, pattern or whole_pages: true".into()));
|
||||
return Err(ToolError::InvalidArgs("pass exactly one of rect (with page), find, words, pattern or whole_pages: true".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() {
|
||||
let page = self.page(a)?;
|
||||
let info = &self.doc(a)?.info.pages[page];
|
||||
@@ -59,23 +70,40 @@ impl Automation {
|
||||
marks.push((p, vec![rect_quad([c[0] as f64, c[1] as f64, c[2] as f64, c[3] as f64])]));
|
||||
}
|
||||
} else {
|
||||
let find = a.opt_str("find")?.map(str::to_owned);
|
||||
let pattern = match a.opt_str("pattern")? {
|
||||
Some(p) => Some(RedactPattern::from_id(p).ok_or_else(|| ToolError::InvalidArgs(format!("unknown pattern {p:?}")))?),
|
||||
None => None,
|
||||
};
|
||||
let needles: Vec<String> = match (a.opt_str("find")?, a.get("words")) {
|
||||
(Some(f), _) => vec![f.to_owned()],
|
||||
(None, Some(_)) => {
|
||||
let raw = a.strs("words")?;
|
||||
if raw.len() > REDACT_MAX_WORDS {
|
||||
return Err(ToolError::InvalidArgs(format!("words has {} entries; at most {REDACT_MAX_WORDS} are allowed", raw.len())));
|
||||
}
|
||||
let list = redact_word_list(&raw.join("\n"));
|
||||
if list.is_empty() {
|
||||
return Err(ToolError::InvalidArgs("words must hold at least one non-empty word or phrase".into()));
|
||||
}
|
||||
list
|
||||
}
|
||||
(None, None) => Vec::new(),
|
||||
};
|
||||
matched_words = needles.iter().map(|w| (w.clone(), 0)).collect();
|
||||
let texts = self.page_texts(id, &pages)?;
|
||||
let info = self.doc(a)?.info.clone();
|
||||
for (&p, text) in pages.iter().zip(&texts) {
|
||||
let hits = match (&find, pattern) {
|
||||
(Some(f), _) => text.find(f),
|
||||
(None, Some(pat)) => text.find_with(|chars| find_pattern(pat, chars)),
|
||||
_ => Vec::new(),
|
||||
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(),
|
||||
};
|
||||
for h in hits {
|
||||
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();
|
||||
if !quads.is_empty() {
|
||||
marks.push((p, quads));
|
||||
if let Some(m) = word.and_then(|i| matched_words.get_mut(i)) {
|
||||
m.1 += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -99,6 +127,9 @@ impl Automation {
|
||||
out["marked"] = json!(count);
|
||||
out["pages_marked"] = json!(marks.iter().map(|m| m.0 + 1).collect::<std::collections::BTreeSet<_>>());
|
||||
out["marks_pending"] = json!(self.doc(a)?.redaction_marks());
|
||||
if a.get("words").is_some() {
|
||||
out["matched_words"] = json!(matched_words.iter().map(|(w, n)| json!({ "word": w, "marked": n })).collect::<Vec<_>>());
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
|
||||
@@ -358,7 +358,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); pattern (phone, email, credit-card, ssn, date: every match, Acrobat's Search & Redact patterns); whole_pages: true. find, 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, 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!({
|
||||
@@ -366,6 +366,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
"page": { "type": "integer", "minimum": 1 },
|
||||
"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 },
|
||||
"pattern": { "type": "string", "enum": ["phone", "email", "credit-card", "ssn", "date"] },
|
||||
"whole_pages": { "type": "boolean" },
|
||||
"pages": pages("to search or mark (default: all)"),
|
||||
|
||||
@@ -2047,6 +2047,27 @@ fn redacting_with_codes_through_tools() {
|
||||
assert!(!text.contains("Jane") && text.contains("(b)(6), (b)(7)(C)"), "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redacting_word_lists_through_tools() {
|
||||
let dir = workdir("redact-words");
|
||||
std::fs::write(dir.join("memo.txt"), "Call Ada today\nAda is private\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();
|
||||
let r = ok(&mut a, "redact_mark", json!({ "doc": doc, "words": ["Ada", " private ", "absent", "Ada"] }));
|
||||
assert_eq!(r["marked"].as_u64(), Some(3), "{r}");
|
||||
assert_eq!(r["matched_words"], json!([{ "word": "Ada", "marked": 2 }, { "word": "private", "marked": 1 }, { "word": "absent", "marked": 0 }]));
|
||||
assert_eq!(ok(&mut a, "edit_undo", json!({ "doc": doc }))["undone"], "Mark for redaction");
|
||||
assert_eq!(ok(&mut a, "redact_mark", json!({ "doc": doc, "words": ["Ada", "private"] }))["marks_pending"].as_u64(), Some(3));
|
||||
ok(&mut a, "redact_apply", json!({ "doc": doc }));
|
||||
let text = page_text(&mut a, doc)[0].clone();
|
||||
assert!(!text.contains("Ada") && !text.contains("private"), "{text}");
|
||||
assert!(text.contains("Call") && text.contains("Public line"), "{text}");
|
||||
for bad in [json!({ "doc": doc, "words": [] }), json!({ "doc": doc, "words": [" "] }), json!({ "doc": doc, "words": ["x"], "find": "x" })] {
|
||||
assert!(matches!(a.call("redact_mark", &bad), Err(ToolError::InvalidArgs(_))), "{bad}");
|
||||
}
|
||||
assert!(matches!(a.call("redact_mark", &json!({ "doc": doc, "words": ["absent", "nowhere"] })), Err(ToolError::Failed(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removing_hidden_information_through_tools() {
|
||||
let dir = workdir("hidden");
|
||||
|
||||
@@ -104,7 +104,9 @@ pub use pdfcraft_optimize as optimize;
|
||||
pub use pdfcraft_preflight as pdfa;
|
||||
pub use pdfcraft_print as print;
|
||||
pub use pdfcraft_redact::codes::{CODE_SETS as REDACTION_CODE_SETS, CodeSet as RedactionCodeSet};
|
||||
pub use pdfcraft_redact::patterns::{PATTERNS as REDACT_PATTERNS, Pattern as RedactPattern, find as find_pattern};
|
||||
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,
|
||||
};
|
||||
pub use pdfcraft_redact::sanitize::{HIDDEN, Hidden};
|
||||
pub use pdfcraft_sign as sign;
|
||||
pub use pdfcraft_sign::{SignOptions, SignatureInfo, Status as SignatureStatus, TrustStore};
|
||||
|
||||
@@ -257,10 +257,37 @@ pub fn find(pattern: Pattern, text: &[char]) -> Vec<Range<usize>> {
|
||||
out
|
||||
}
|
||||
|
||||
/// The most entries a word list keeps (Find Text ▸ Multiple words or phrases).
|
||||
pub const MAX_WORDS: usize = 1000;
|
||||
/// The most characters an entry keeps.
|
||||
pub const MAX_WORD_CHARS: usize = 256;
|
||||
|
||||
/// A word list from text with one word or phrase per line: trimmed, without empty lines or
|
||||
/// repeats, in order, at most [`MAX_WORDS`] entries of at most [`MAX_WORD_CHARS`] characters.
|
||||
pub fn word_list(text: &str) -> Vec<String> {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
text.lines()
|
||||
.map(|l| l.trim().chars().take(MAX_WORD_CHARS).collect::<String>())
|
||||
.filter(|w| !w.is_empty() && seen.insert(w.clone()))
|
||||
.take(MAX_WORDS)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn word_lists_are_trimmed_unique_and_bounded() {
|
||||
assert_eq!(word_list(" John Smith \n\n\tACME\r\nJohn Smith\n \nő ű"), ["John Smith", "ACME", "ő ű"]);
|
||||
let long = "é".repeat(MAX_WORD_CHARS + 10);
|
||||
assert_eq!(word_list(&long)[0].chars().count(), MAX_WORD_CHARS);
|
||||
let many: String = (0..MAX_WORDS + 50).map(|i| format!("w{i}\n")).collect();
|
||||
let list = word_list(&many);
|
||||
assert_eq!((list.len(), list.last().map(String::as_str)), (MAX_WORDS, Some("w999")));
|
||||
assert!(word_list("").is_empty());
|
||||
}
|
||||
|
||||
fn found(p: Pattern, s: &str) -> Vec<String> {
|
||||
let c: Vec<char> = s.chars().collect();
|
||||
find(p, &c).into_iter().map(|r| c[r].iter().collect()).collect()
|
||||
|
||||
@@ -385,6 +385,16 @@ fn sharp_enough(have: u32, want: f32) -> bool {
|
||||
want > 0.0 && GRID_SHARP.contains(&(have as f32 / want))
|
||||
}
|
||||
|
||||
/// Thumbnail device-pixel density when `demand` pages share the thumbnail allowance.
|
||||
fn thumbnail_density(demand: usize, ppp: f32) -> f32 {
|
||||
let per_thumb = THUMB_BYTES / demand.max(1).next_power_of_two();
|
||||
ppp.clamp(0.25, 4.0).min((per_thumb as f32 / (4.0 * THUMB_W * THUMB_H)).sqrt() * 0.98)
|
||||
}
|
||||
|
||||
fn thumbnail_scale(width: f32, height: f32, density: f32) -> f32 {
|
||||
(THUMB_W / width.max(1.0)).min(THUMB_H / height.max(1.0)) * density
|
||||
}
|
||||
|
||||
/// Organize-toolbar actions that need the app (file pickers, new tabs, dialogs).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ViewAction {
|
||||
@@ -1116,13 +1126,12 @@ impl DocView {
|
||||
// byte allowance. Both dimensions are capped, including tall/wide page boxes.
|
||||
// The count is rounded up to a power of two: the scale is part of each request's tag,
|
||||
// so a density that followed every row scrolling in or out would re-render them all.
|
||||
let per_thumb = THUMB_BYTES / pages.len().max(1).next_power_of_two();
|
||||
let density = ppp.clamp(0.25, 4.0).min((per_thumb as f32 / (4.0 * THUMB_W * THUMB_H)).sqrt() * 0.98);
|
||||
let density = thumbnail_density(pages.len(), ppp);
|
||||
pages
|
||||
.into_iter()
|
||||
.filter_map(|(page, _)| {
|
||||
let p = info.pages.get(page)?;
|
||||
let scale = (THUMB_W / p.width.max(1.0)).min(THUMB_H / p.height.max(1.0)) * density;
|
||||
let scale = thumbnail_scale(p.width, p.height, density);
|
||||
Some(RenderRequest { page, kind: RequestKind::Pixels, tile: None, scale, tag: THUMB_TAG | u64::from(scale.to_bits()) })
|
||||
})
|
||||
.collect()
|
||||
@@ -3303,8 +3312,8 @@ fn organize_grid(view: &mut DocView, info: &DocInfo, editable: bool, dirty: bool
|
||||
let middle_gesture = view.auto_scroll.blocks_input();
|
||||
let mut cells: Vec<(usize, Rect)> = Vec::new();
|
||||
// The pages in view that are drawn larger than their thumbnails, and those of them that
|
||||
// need a sharper render than they have.
|
||||
let (mut in_view, mut sharper): (Vec<usize>, Vec<RenderRequest>) = (Vec::new(), Vec::new());
|
||||
// need a sharper render than they have. The width is the size drawn, in device pixels.
|
||||
let (mut in_view, mut sharper): (Vec<(usize, f32)>, Vec<RenderRequest>) = (Vec::new(), Vec::new());
|
||||
// The source cell can scroll out of the virtualized rows during a drag.
|
||||
let mut drop = view.org_drag.is_some() && ui.input(|i| i.pointer.primary_released());
|
||||
egui::ScrollArea::vertical().auto_shrink([false, false]).show_viewport(ui, |ui, clip| {
|
||||
@@ -3367,7 +3376,7 @@ fn organize_grid(view: &mut DocView, info: &DocInfo, editable: bool, dirty: bool
|
||||
let thumb_ok = thumb.is_some_and(|t| t.size()[0] as f32 >= want * GRID_SHARP.start());
|
||||
let sharp = view.grid_pages.get(&i).filter(|_| !thumb_ok);
|
||||
if !thumb_ok && ui.is_rect_visible(c) {
|
||||
in_view.push(i);
|
||||
in_view.push((i, want));
|
||||
if !sharp.is_some_and(|(w, _)| sharp_enough(*w, want)) && !view.errors.contains_key(&i) {
|
||||
sharper.push(RenderRequest { page: i, kind: RequestKind::Pixels, tile: None, scale: want / p.width.max(1.0), tag: GRID_TAG });
|
||||
}
|
||||
@@ -3498,9 +3507,14 @@ fn organize_grid(view: &mut DocView, info: &DocInfo, editable: bool, dirty: bool
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
}
|
||||
// Sharp renders are kept only for the pages in view, so their memory stays small however
|
||||
// long the document is. They come first; the thumbnails of the other pages follow.
|
||||
view.grid_pages.retain(|page, _| in_view.contains(page));
|
||||
// When this frame's thumbnail will cover the cell, drop the sharp render without waiting
|
||||
// for that texture to arrive.
|
||||
let density = thumbnail_density(view.thumb_demand.len(), ppp);
|
||||
in_view.retain(|&(page, want)| {
|
||||
info.pages.get(page).is_none_or(|p| (device_pixels(p.width, thumbnail_scale(p.width, p.height, density)) as f32) < want * GRID_SHARP.start())
|
||||
});
|
||||
sharper.retain(|req| in_view.iter().any(|(page, _)| *page == req.page));
|
||||
view.grid_pages.retain(|page, _| in_view.iter().any(|(kept, _)| kept == page));
|
||||
// Sharp renders of the pages in view join this frame's demand ahead of the thumbnails.
|
||||
view.frame_queue.extend(sharper);
|
||||
if let Some(p) = open_page {
|
||||
|
||||
@@ -45,6 +45,7 @@ pub fn show(app: &mut PdfCraftApp, ctx: &egui::Context) {
|
||||
let mut field_props_now = false;
|
||||
let mut bulk_field_props_now = false;
|
||||
let mut redact_now: Option<Dialog> = None;
|
||||
let mut import_words = false;
|
||||
let mut print_go = false;
|
||||
let mut revert_now = false;
|
||||
let mut summarize_now = false;
|
||||
@@ -515,10 +516,11 @@ pub fn show(app: &mut PdfCraftApp, ctx: &egui::Context) {
|
||||
return;
|
||||
}
|
||||
Dialog::RedactSearch => {
|
||||
let (go, cancel) = crate::redact_ui::search_body(ui, &mut app.redact_search, &t);
|
||||
let (go, cancel, import) = crate::redact_ui::search_body(ui, &mut app.redact_search, &t);
|
||||
if go {
|
||||
redact_now = Some(dialog);
|
||||
}
|
||||
import_words = import;
|
||||
close = cancel;
|
||||
return;
|
||||
}
|
||||
@@ -1214,6 +1216,9 @@ pub fn show(app: &mut PdfCraftApp, ctx: &egui::Context) {
|
||||
if rotate_now {
|
||||
app.rotate_with_draft();
|
||||
}
|
||||
if import_words {
|
||||
app.pick_files(crate::files::FilePurpose::RedactWords, false);
|
||||
}
|
||||
match redact_now {
|
||||
Some(Dialog::RedactPages) => app.redact_pages(),
|
||||
Some(Dialog::RedactSearch) => {
|
||||
|
||||
@@ -19,11 +19,16 @@ pub enum FilePurpose {
|
||||
Ocr,
|
||||
/// Create a PDF ▸ Multiple files (PDFs, images and text).
|
||||
CreateMultiple,
|
||||
/// Find text and redact ▸ Import list: a text file with one word or phrase per line.
|
||||
RedactWords,
|
||||
}
|
||||
|
||||
/// The picker for `purpose`: PDFs, and for Create and Insert also what they convert.
|
||||
fn files_picker(purpose: FilePurpose) -> rfd::AsyncFileDialog {
|
||||
let dialog = rfd::AsyncFileDialog::new();
|
||||
if purpose == FilePurpose::RedactWords {
|
||||
return dialog.add_filter(tl!("Text"), &["txt"]);
|
||||
}
|
||||
if !matches!(purpose, FilePurpose::CreateMultiple | FilePurpose::InsertPages) {
|
||||
return dialog.add_filter("PDF", &["pdf"]);
|
||||
}
|
||||
@@ -201,7 +206,7 @@ impl PdfCraftApp {
|
||||
self.pick_files(FilePurpose::InsertPages, true);
|
||||
}
|
||||
|
||||
fn pick_files(&mut self, purpose: FilePurpose, multiple: bool) {
|
||||
pub(crate) fn pick_files(&mut self, purpose: FilePurpose, multiple: bool) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
self.pick(crate::pickers::PickFor::Files(purpose), files_picker(purpose), multiple);
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
@@ -238,6 +243,10 @@ impl PdfCraftApp {
|
||||
let mut modified = Vec::new();
|
||||
for p in paths {
|
||||
let name = p.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "file.pdf".into());
|
||||
if purpose == FilePurpose::RedactWords && std::fs::metadata(p).is_ok_and(|m| m.len() > crate::redact_ui::MAX_WORD_LIST_BYTES as u64) {
|
||||
self.notify_fmt("{name} is too large for a word list (at most 1 MB).", &[("name", &name)]);
|
||||
return;
|
||||
}
|
||||
match std::fs::read(p) {
|
||||
Ok(b) => files.push((name, b)),
|
||||
Err(e) => {
|
||||
@@ -351,6 +360,15 @@ impl PdfCraftApp {
|
||||
}
|
||||
FilePurpose::Ocr => self.ocr_files(files),
|
||||
FilePurpose::CreateMultiple => self.stage_create_multiple(files),
|
||||
FilePurpose::RedactWords => {
|
||||
let Some((name, bytes)) = files.into_iter().next() else { return };
|
||||
if bytes.len() > crate::redact_ui::MAX_WORD_LIST_BYTES {
|
||||
self.notify_fmt("{name} is too large for a word list (at most 1 MB).", &[("name", &name)]);
|
||||
return;
|
||||
}
|
||||
self.redact_search.words = pdfcraft_engine::redact_word_list(&String::from_utf8_lossy(&bytes)).join("\n");
|
||||
self.redact_search.mode = crate::redact_ui::SearchMode::Words;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1095,6 +1095,11 @@
|
||||
Text in images isn't found; recognise text (OCR) first. النص داخل الصور لا يُعثر عليه؛ استخدم التعرف على النص (OCR) أولًا.
|
||||
No matches. لا توجد تطابقات.
|
||||
{n} match(es) marked for redaction. عُلِّم {n} تطابق للحجب.
|
||||
Multiple words or phrases كلمات أو عبارات متعددة
|
||||
One word or phrase per line كلمة أو عبارة واحدة في كل سطر
|
||||
Import list… استيراد قائمة…
|
||||
{n} word(s) or phrase(s) {n} كلمة أو عبارة
|
||||
{name} is too large for a word list (at most 1 MB). {name} أكبر من أن يكون قائمة كلمات (1 ميغابايت على الأكثر).
|
||||
Redaction Tool Properties خصائص أداة الحجب
|
||||
Redacted area fill colour: لون تعبئة المنطقة المحجوبة:
|
||||
Use overlay text استخدام نص تراكب
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -787,6 +787,11 @@
|
||||
Text in images isn't found; recognise text (OCR) first. Текстът в изображения не се открива; първо разпознайте текста (OCR).
|
||||
No matches. Няма съвпадения.
|
||||
{n} match(es) marked for redaction. {n} съвпадение(я) маркирани за заличаване.
|
||||
Multiple words or phrases Няколко думи или фрази
|
||||
One word or phrase per line По една дума или фраза на ред
|
||||
Import list… Импортиране на списък…
|
||||
{n} word(s) or phrase(s) {n} дума(и) или фраза(и)
|
||||
{name} is too large for a word list (at most 1 MB). {name} е твърде голям за списък с думи (най-много 1 MB).
|
||||
Redaction Tool Properties Свойства на инструмента за заличаване
|
||||
Redacted area fill colour: Цвят на запълване на заличената област:
|
||||
Use overlay text Използвай текст върху заличаването
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 8 and column 16.
|
@@ -140,3 +140,8 @@
|
||||
Character limit: Limit znaků:
|
||||
Character limit 0 means no limit. A comb of characters needs a limit. Limit znaků 0 znamená bez omezení. Rozdělení na znaky vyžaduje limit.
|
||||
To change locked fields, also set Locked to No. Chcete-li změnit zamčená pole, nastavte také „Zamčeno“ na „Ne“.
|
||||
Multiple words or phrases Více slov nebo frází
|
||||
One word or phrase per line Jedno slovo nebo fráze na řádek
|
||||
Import list… Importovat seznam…
|
||||
{n} word(s) or phrase(s) Slova nebo fráze: {n}
|
||||
{name} is too large for a word list (at most 1 MB). {name} je pro seznam slov příliš velký (nejvýše 1 MB).
|
||||
|
Can't render this file because it contains an unexpected character in line 7 and column 81.
|
@@ -1076,6 +1076,11 @@
|
||||
Text in images isn't found; recognise text (OCR) first. Text in Bildern wird nicht gefunden; führe zuerst Text erkennen (OCR) aus.
|
||||
No matches. Keine Treffer.
|
||||
{n} match(es) marked for redaction. {n} Treffer zum Schwärzen markiert.
|
||||
Multiple words or phrases Mehrere Wörter oder Ausdrücke
|
||||
One word or phrase per line Ein Wort oder Ausdruck pro Zeile
|
||||
Import list… Liste importieren…
|
||||
{n} word(s) or phrase(s) Wörter oder Ausdrücke: {n}
|
||||
{name} is too large for a word list (at most 1 MB). {name} ist zu groß für eine Wortliste (höchstens 1 MB).
|
||||
Redaction Tool Properties Eigenschaften des Schwärzungswerkzeugs
|
||||
Redacted area fill colour: Füllfarbe geschwärzter Bereiche:
|
||||
Use overlay text Überlagerungstext verwenden
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 51.
|
@@ -1089,6 +1089,11 @@
|
||||
Text in images isn't found; recognise text (OCR) first. El texto de las imágenes no se encuentra; reconoce antes el texto (OCR).
|
||||
No matches. Sin coincidencias.
|
||||
{n} match(es) marked for redaction. {n} coincidencia(s) marcada(s) para censurar.
|
||||
Multiple words or phrases Varias palabras o frases
|
||||
One word or phrase per line Una palabra o frase por línea
|
||||
Import list… Importar lista…
|
||||
{n} word(s) or phrase(s) {n} palabra(s) o frase(s)
|
||||
{name} is too large for a word list (at most 1 MB). {name} es demasiado grande para una lista de palabras (máximo 1 MB).
|
||||
Redaction Tool Properties Propiedades de la herramienta de censura
|
||||
Redacted area fill colour: Color de relleno del área censurada:
|
||||
Use overlay text Usar texto superpuesto
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 60.
|
@@ -1075,6 +1075,11 @@
|
||||
Text in images isn't found; recognise text (OCR) first. Le texte dans les images n'est pas trouvé ; reconnaissez d'abord le texte (OCR).
|
||||
No matches. Aucune correspondance.
|
||||
{n} match(es) marked for redaction. {n} correspondance(s) marquée(s) pour biffure.
|
||||
Multiple words or phrases Plusieurs mots ou expressions
|
||||
One word or phrase per line Un mot ou une expression par ligne
|
||||
Import list… Importer une liste…
|
||||
{n} word(s) or phrase(s) {n} mot(s) ou expression(s)
|
||||
{name} is too large for a word list (at most 1 MB). {name} est trop volumineux pour une liste de mots (1 Mo au maximum).
|
||||
Redaction Tool Properties Propriétés de l'outil de biffure
|
||||
Redacted area fill colour: Couleur de remplissage de la zone biffée :
|
||||
Use overlay text Utiliser un texte en superposition
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 39.
|
@@ -1065,6 +1065,11 @@
|
||||
Text in images isn't found; recognise text (OCR) first. A képekben lévő szöveg nem található meg; előbb futtass szövegfelismerést (OCR).
|
||||
No matches. Nincs találat.
|
||||
{n} match(es) marked for redaction. {n} találat megjelölve kitakarásra.
|
||||
Multiple words or phrases Több szó vagy kifejezés
|
||||
One word or phrase per line Soronként egy szó vagy kifejezés
|
||||
Import list… Lista importálása…
|
||||
{n} word(s) or phrase(s) {n} szó vagy kifejezés
|
||||
{name} is too large for a word list (at most 1 MB). A(z) {name} túl nagy egy szólistához (legfeljebb 1 MB).
|
||||
Redaction Tool Properties Kitakaróeszköz tulajdonságai
|
||||
Redacted area fill colour: Kitakart terület kitöltőszíne:
|
||||
Use overlay text Fedőszöveg használata
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 7 and column 44.
|
@@ -1091,6 +1091,11 @@
|
||||
Text in images isn't found; recognise text (OCR) first. Il testo nelle immagini non viene trovato; esegui prima il riconoscimento del testo (OCR).
|
||||
No matches. Nessuna corrispondenza.
|
||||
{n} match(es) marked for redaction. Corrispondenze contrassegnate per l'oscuramento: {n}.
|
||||
Multiple words or phrases Più parole o frasi
|
||||
One word or phrase per line Una parola o frase per riga
|
||||
Import list… Importa elenco…
|
||||
{n} word(s) or phrase(s) Parole o frasi: {n}
|
||||
{name} is too large for a word list (at most 1 MB). {name} è troppo grande per un elenco di parole (al massimo 1 MB).
|
||||
Redaction Tool Properties Proprietà strumento di oscuramento
|
||||
Redacted area fill colour: Colore di riempimento dell'area oscurata:
|
||||
Use overlay text Usa testo sovrapposto
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -1095,6 +1095,11 @@
|
||||
Text in images isn't found; recognise text (OCR) first. 画像内のテキストは検索できません。先に OCR でテキストを認識してください。
|
||||
No matches. 一致する項目はありません。
|
||||
{n} match(es) marked for redaction. {n} 件の一致項目に墨消しマークを付けました。
|
||||
Multiple words or phrases 複数の単語または語句
|
||||
One word or phrase per line 1 行に 1 つの単語または語句
|
||||
Import list… リストを読み込み…
|
||||
{n} word(s) or phrase(s) {n} 件の単語または語句
|
||||
{name} is too large for a word list (at most 1 MB). {name} は単語リストとして大きすぎます (最大 1 MB)。
|
||||
Redaction Tool Properties 墨消しツールのプロパティ
|
||||
Redacted area fill colour: 墨消し領域の塗りつぶし色:
|
||||
Use overlay text オーバーレイテキストを使用
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -731,6 +731,11 @@
|
||||
Text in images isn't found; recognise text (OCR) first. Texto em imagens não é encontrado; reconheça o texto (OCR) primeiro.
|
||||
No matches. Nenhuma correspondência.
|
||||
{n} match(es) marked for redaction. {n} correspondência(s) marcada(s) para ocultação.
|
||||
Multiple words or phrases Várias palavras ou frases
|
||||
One word or phrase per line Uma palavra ou frase por linha
|
||||
Import list… Importar lista…
|
||||
{n} word(s) or phrase(s) {n} palavra(s) ou frase(s)
|
||||
{name} is too large for a word list (at most 1 MB). {name} é grande demais para uma lista de palavras (no máximo 1 MB).
|
||||
Redaction Tool Properties Propriedades da ferramenta de ocultação
|
||||
Redacted area fill colour: Cor de preenchimento da área ocultada:
|
||||
Use overlay text Usar texto de sobreposição
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 9 and column 16.
|
@@ -1096,6 +1096,11 @@
|
||||
Text in images isn't found; recognise text (OCR) first. Текст в изображениях не находится; сначала распознайте текст (OCR).
|
||||
No matches. Совпадений нет.
|
||||
{n} match(es) marked for redaction. Отмечено для сокрытия: {n} совпад.
|
||||
Multiple words or phrases Несколько слов или фраз
|
||||
One word or phrase per line По одному слову или фразе в строке
|
||||
Import list… Импортировать список…
|
||||
{n} word(s) or phrase(s) Слов или фраз: {n}
|
||||
{name} is too large for a word list (at most 1 MB). Файл {name} слишком велик для списка слов (не более 1 МБ).
|
||||
Redaction Tool Properties Свойства инструмента сокрытия
|
||||
Redacted area fill colour: Цвет заливки скрытой области:
|
||||
Use overlay text Использовать текст поверх
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 9 and column 16.
|
@@ -1070,6 +1070,11 @@ comment menu Edit సవరించు
|
||||
Text in images isn't found; recognise text (OCR) first. చిత్రాలలోని టెక్స్ట్ కనుగొనబడదు; ముందుగా టెక్స్ట్ను గుర్తించండి (OCR).
|
||||
No matches. సరిపోలికలు ఏవీ లేవు.
|
||||
{n} match(es) marked for redaction. రెడాక్షన్ కోసం గుర్తించిన సరిపోలికలు: {n}.
|
||||
Multiple words or phrases బహుళ పదాలు లేదా పదబంధాలు
|
||||
One word or phrase per line ఒక్కో పంక్తికి ఒక పదం లేదా పదబంధం
|
||||
Import list… జాబితాను దిగుమతి చేయండి…
|
||||
{n} word(s) or phrase(s) పదాలు లేదా పదబంధాలు: {n}
|
||||
{name} is too large for a word list (at most 1 MB). పద జాబితాకు {name} చాలా పెద్దది (గరిష్ఠంగా 1 MB).
|
||||
Redaction Tool Properties రెడాక్షన్ సాధనం లక్షణాలు
|
||||
Redacted area fill colour: రెడాక్ట్ చేసిన ప్రాంతం నింపు రంగు:
|
||||
Use overlay text ఓవర్లే టెక్స్ట్ను ఉపయోగించు
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 13 and column 16.
|
@@ -1093,6 +1093,11 @@
|
||||
Text in images isn't found; recognise text (OCR) first. Текст у зображеннях не знаходиться; спочатку розпізнайте текст (OCR).
|
||||
No matches. Збігів немає.
|
||||
{n} match(es) marked for redaction. Збігів, позначених для вилучення: {n}.
|
||||
Multiple words or phrases Кілька слів або фраз
|
||||
One word or phrase per line По одному слову або фразі в рядку
|
||||
Import list… Імпортувати список…
|
||||
{n} word(s) or phrase(s) Слів або фраз: {n}
|
||||
{name} is too large for a word list (at most 1 MB). Файл {name} завеликий для списку слів (не більше 1 МБ).
|
||||
Redaction Tool Properties Властивості інструмента вилучення вмісту
|
||||
Redacted area fill colour: Колір заливки вилученої області:
|
||||
Use overlay text Використовувати накладений текст
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -794,6 +794,11 @@
|
||||
Text in images isn't found; recognise text (OCR) first. 图像中的文本无法找到;请先识别文本(OCR)。
|
||||
No matches. 无匹配。
|
||||
{n} match(es) marked for redaction. 已标记 {n} 个匹配以待密文。
|
||||
Multiple words or phrases 多个单词或短语
|
||||
One word or phrase per line 每行一个单词或短语
|
||||
Import list… 导入列表…
|
||||
{n} word(s) or phrase(s) {n} 个单词或短语
|
||||
{name} is too large for a word list (at most 1 MB). {name} 太大,无法用作单词列表(最大 1 MB)。
|
||||
Redaction Tool Properties 密文工具属性
|
||||
Redacted area fill colour: 密文区域填充颜色:
|
||||
Use overlay text 使用覆盖文本
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 9 and column 16.
|
@@ -743,6 +743,11 @@
|
||||
Text in images isn't found; recognise text (OCR) first. 圖片中的文字無法找到;請先識別文字(OCR)。
|
||||
No matches. 無匹配。
|
||||
{n} match(es) marked for redaction. 已標記 {n} 個匹配以待遮蔽內容。
|
||||
Multiple words or phrases 多個單字或片語
|
||||
One word or phrase per line 每行一個單字或片語
|
||||
Import list… 匯入清單…
|
||||
{n} word(s) or phrase(s) {n} 個單字或片語
|
||||
{name} is too large for a word list (at most 1 MB). {name} 太大,無法用作單字清單(最多 1 MB)。
|
||||
Redaction Tool Properties 遮蔽內容工具屬性
|
||||
Redacted area fill colour: 遮蔽內容區域填充顏色:
|
||||
Use overlay text 使用覆蓋文字
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 9 and column 16.
|
@@ -84,7 +84,7 @@ mod print_ui;
|
||||
mod signature_drag;
|
||||
pub use print_ui::{Handling as PrintHandling, PrintDraft, Which as PrintWhich};
|
||||
mod redact_ui;
|
||||
pub use redact_ui::{HiddenDraft, PagesDraft as RedactPagesDraft, RedactPrefs, SearchDraft as RedactSearchDraft};
|
||||
pub use redact_ui::{HiddenDraft, PagesDraft as RedactPagesDraft, RedactPrefs, SearchDraft as RedactSearchDraft, SearchMode as RedactSearchMode};
|
||||
pub mod i18n;
|
||||
|
||||
/// [`PdfCraftApp::date_text`] for callers that already borrow other parts of the app.
|
||||
|
||||
@@ -75,11 +75,23 @@ impl Default for PagesDraft {
|
||||
}
|
||||
}
|
||||
|
||||
/// What Find text and redact looks for.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum SearchMode {
|
||||
#[default]
|
||||
Phrase,
|
||||
/// Every word or phrase of a list, one per line.
|
||||
Words,
|
||||
Patterns,
|
||||
}
|
||||
|
||||
/// Find text and redact.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct SearchDraft {
|
||||
pub patterns: bool,
|
||||
pub mode: SearchMode,
|
||||
pub text: String,
|
||||
/// The word list, one word or phrase per line.
|
||||
pub words: String,
|
||||
pub pattern: RedactPattern,
|
||||
/// The result of the last search (matches marked), shown in the dialog.
|
||||
pub found: Option<usize>,
|
||||
@@ -87,10 +99,13 @@ pub struct SearchDraft {
|
||||
|
||||
impl Default for SearchDraft {
|
||||
fn default() -> Self {
|
||||
Self { patterns: false, text: String::new(), pattern: RedactPattern::Phone, found: None }
|
||||
Self { mode: SearchMode::Phrase, text: String::new(), words: String::new(), pattern: RedactPattern::Phone, found: None }
|
||||
}
|
||||
}
|
||||
|
||||
/// The largest word list file Import list accepts.
|
||||
pub(crate) const MAX_WORD_LIST_BYTES: usize = 1 << 20;
|
||||
|
||||
/// A box being drawn with the Redact tool: (page, start).
|
||||
pub type AreaDrag = Option<(usize, Pos2)>;
|
||||
|
||||
@@ -169,7 +184,12 @@ impl PdfCraftApp {
|
||||
let Some((_, id)) = self.active_ids() else { return 0 };
|
||||
let Some(doc) = self.session.get(id) else { return 0 };
|
||||
let d = self.redact_search.clone();
|
||||
if !d.patterns && d.text.trim().is_empty() {
|
||||
let needles = match d.mode {
|
||||
SearchMode::Phrase if !d.text.trim().is_empty() => vec![d.text.clone()],
|
||||
SearchMode::Words => pdfcraft_engine::redact_word_list(&d.words),
|
||||
SearchMode::Phrase | SearchMode::Patterns => Vec::new(),
|
||||
};
|
||||
if d.mode != SearchMode::Patterns && needles.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
let config = pdfcraft_render::RenderConfig { password: doc.password.as_deref().map(std::sync::Arc::from), ..Default::default() };
|
||||
@@ -179,7 +199,10 @@ impl PdfCraftApp {
|
||||
for page in 0..doc.info.pages.len() {
|
||||
let out = r.render(pdfcraft_render::RenderRequest { page, kind: pdfcraft_render::RequestKind::Text, scale: 1.0, ..Default::default() });
|
||||
let Some(text) = out.text else { continue };
|
||||
let hits = if d.patterns { text.find_with(|c| pdfcraft_engine::find_pattern(d.pattern, c)) } else { text.find(&d.text) };
|
||||
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(),
|
||||
};
|
||||
for h in hits {
|
||||
let quads: Vec<[f64; 8]> = text.line_rects(h).into_iter().map(|r| doc.info.pages[page].view_rect_to_quad(r)).collect();
|
||||
if !quads.is_empty() {
|
||||
@@ -238,20 +261,33 @@ pub(crate) fn pages_body(ui: &mut egui::Ui, d: &mut PagesDraft, pages: usize, _t
|
||||
buttons(ui, "OK", true)
|
||||
}
|
||||
|
||||
/// Find text and redact. Returns (search, cancel).
|
||||
pub(crate) fn search_body(ui: &mut egui::Ui, d: &mut SearchDraft, t: &Tokens) -> (bool, bool) {
|
||||
/// Find text and redact. Returns (search, cancel, import a word list).
|
||||
pub(crate) fn search_body(ui: &mut egui::Ui, d: &mut SearchDraft, t: &Tokens) -> (bool, bool, bool) {
|
||||
ui.set_width(420.0);
|
||||
ui.label(egui::RichText::new(tl!("Find text and redact")).font(crate::theme::semibold(18.0)));
|
||||
ui.add_space(8.0);
|
||||
ui.radio_value(&mut d.patterns, false, tl!("Single word or phrase"));
|
||||
ui.radio_value(&mut d.mode, SearchMode::Phrase, tl!("Single word or phrase"));
|
||||
let mut enter = false;
|
||||
ui.add_enabled_ui(!d.patterns, |ui| {
|
||||
ui.add_enabled_ui(d.mode == SearchMode::Phrase, |ui| {
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut d.text).hint_text(tl!("Text to find")).desired_width(380.0));
|
||||
enter = r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
ui.radio_value(&mut d.patterns, true, tl!("Patterns"));
|
||||
ui.add_enabled_ui(d.patterns, |ui| {
|
||||
ui.radio_value(&mut d.mode, SearchMode::Words, tl!("Multiple words or phrases"));
|
||||
let mut import = false;
|
||||
ui.add_enabled_ui(d.mode == SearchMode::Words, |ui| {
|
||||
egui::ScrollArea::vertical().id_salt("redact-words").max_height(120.0).show(ui, |ui| {
|
||||
ui.add(egui::TextEdit::multiline(&mut d.words).hint_text(tl!("One word or phrase per line")).desired_width(380.0).desired_rows(4));
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
import = ui.button(tl!("Import list…")).clicked();
|
||||
let n = pdfcraft_engine::redact_word_list(&d.words).len();
|
||||
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_space(6.0);
|
||||
ui.radio_value(&mut d.mode, SearchMode::Patterns, tl!("Patterns"));
|
||||
ui.add_enabled_ui(d.mode == SearchMode::Patterns, |ui| {
|
||||
egui::ComboBox::from_id_salt("redact-pattern").selected_text(tl!(d.pattern.label())).width(240.0).show_ui(ui, |ui| {
|
||||
for p in REDACT_PATTERNS {
|
||||
ui.selectable_value(&mut d.pattern, p, tl!(p.label()));
|
||||
@@ -272,7 +308,7 @@ pub(crate) fn search_body(ui: &mut egui::Ui, d: &mut SearchDraft, t: &Tokens) ->
|
||||
}
|
||||
ui.add_space(12.0);
|
||||
let (go, cancel) = buttons(ui, "Mark all", true);
|
||||
(go || enter, cancel)
|
||||
(go || enter, cancel, import)
|
||||
}
|
||||
|
||||
/// Redaction Tool Properties. Returns (apply, cancel).
|
||||
|
||||
@@ -1309,7 +1309,7 @@ fn zoomed_pages_in_view_are_rendered_at_the_size_drawn() {
|
||||
// Zoomed out again, thumbnails are enough and the sharp renders are dropped.
|
||||
h.state_mut().set_option("grid-zoom", "50").unwrap();
|
||||
h.run_steps(3);
|
||||
assert!(sharp(&h, 0).is_none());
|
||||
assert!(sharp(&h, 0).is_none(), "page 0 still has a sharp render: {:?}", sharp(&h, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -107,6 +107,33 @@ fn redaction_codes_become_the_overlay_text() {
|
||||
assert_eq!(h.state().redact_prefs.overlay_text(), "CONFIDENTIAL", "custom text is kept while codes are used");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redacting_an_imported_word_list() {
|
||||
let memo = pdfcraft_engine::Session::new().create_from_text("memo", "Call Ada today\nAda is private\nPublic line").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.run_steps(2);
|
||||
h.get_by_label("Multiple words or phrases");
|
||||
let list = b" Ada \n\nprivate\nAda\nabsent\n".to_vec();
|
||||
h.state_mut().use_files(pdfcraft_ui_egui::FilePurpose::RedactWords, vec![("names.txt".into(), list)]);
|
||||
let d = &h.state().redact_search;
|
||||
assert_eq!((d.mode, d.words.as_str()), (pdfcraft_ui_egui::RedactSearchMode::Words, "Ada\nprivate\nabsent"));
|
||||
h.run_steps(2);
|
||||
h.get_by_label("3 word(s) or phrase(s)");
|
||||
h.get_by_label("Mark all").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!((marks(&h), h.state().redact_search.found), (3, Some(3)));
|
||||
let too_big = vec![b'a'; 2 << 20];
|
||||
h.state_mut().use_files(pdfcraft_ui_egui::FilePurpose::RedactWords, vec![("huge.txt".into(), too_big)]);
|
||||
assert_eq!(h.state().redact_search.words, "Ada\nprivate\nabsent", "an oversized list changes nothing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removing_hidden_information_and_sanitizing() {
|
||||
let mut h = harness();
|
||||
|
||||
@@ -5515,11 +5515,11 @@ title = "Redact lists of words or phrases"
|
||||
area = "G"
|
||||
tier = "P1"
|
||||
milestone = "M8"
|
||||
status = "planned"
|
||||
commands = []
|
||||
tools = []
|
||||
evidence = []
|
||||
notes = ""
|
||||
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/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"
|
||||
|
||||
[[feature]]
|
||||
id = "protect.redact-patterns"
|
||||
|
||||
Reference in new issue
Block a user