M8: the apply notification says where the removed text is still held (#2448)

After "Applied N redaction marks…", the same notification names the bookmark titles,
attachments, comments and other metadata streams that still hold the redacted text, and points
to Remove hidden information. New English templates; untranslated text shows in English. The
protect.apply-redactions parity note and evidence say what is reported and what is still left.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
This commit is contained in:
mtlcl410andClaude Opus 5.5 committed 2026-11-01 18:31:52 +08:00
1 parent d37bde8c3b
commit 4dbc4fac69
3 files changed
+100 -5

No files matched your search

+38 -3
View File
@@ -1711,10 +1711,17 @@ fn finish_redactions_and_fields(app: &mut PdfCraftApp, req: &mut Pending) {
Some(Dialog::RedactApply) => {
let marks = app.active_ids().and_then(|(_, id)| app.session.get(id)).map_or(0, |d| d.redaction_marks());
if app.apply_edit(Edit::ApplyRedactions { pages: None }) {
if marks == 1 {
app.notify_tr("Applied 1 redaction mark. Save to remove the content from the file.");
let applied = if marks == 1 {
tl!("Applied 1 redaction mark. Save to remove the content from the file.").to_string()
} else {
app.notify_fmt("Applied {n} redaction marks. Save to remove the content from the file.", &[("n", &marks.to_string())]);
crate::i18n::fmt(tl!("Applied {n} redaction marks. Save to remove the content from the file."), &[("n", &marks.to_string())])
};
// Where the removed text is still held that applying leaves as it is: said in the
// same notification, so it can't replace the first sentence.
let left = app.active_ids().and_then(|(_, id)| app.session.get(id)).and_then(|d| d.redaction_elsewhere);
match left.and_then(|e| still_holding_note(&e)) {
Some(note) => app.notify(format!("{applied} {note}")),
None => app.notify(applied),
}
}
}
@@ -1760,6 +1767,34 @@ fn finish_redactions_and_fields(app: &mut PdfCraftApp, req: &mut Pending) {
}
}
/// The sentence telling the user where the removed text is still held that applying redactions
/// leaves as it is, or `None` when it is nowhere else. When the text couldn't be searched for,
/// the places are those holding any text, and the sentence says they may hold it.
fn still_holding_note(e: &pdfcraft_engine::RedactionElsewhere) -> Option<String> {
let count = |n: usize, one: &str, many: &str| match n {
0 => None,
1 => Some(tl!(one).to_string()),
n => Some(crate::i18n::fmt(tl!(many), &[("n", &n.to_string())])),
};
let places: Vec<String> = [
count(e.bookmarks, "1 bookmark title", "{n} bookmark titles"),
count(e.attachments, "1 attachment", "{n} attachments"),
count(e.comments, "1 comment", "{n} comments"),
count(e.xmp, "1 other metadata stream", "{n} other metadata streams"),
]
.into_iter()
.flatten()
.collect();
let (last, rest) = places.split_last()?;
let list = if rest.is_empty() { last.clone() } else { crate::i18n::fmt(tl!("{list} and {last}"), &[("list", &rest.join(", ")), ("last", last)]) };
let template = if e.unsearched {
"The redacted text couldn't be searched for, so {places} may still hold it. Remove hidden information removes them."
} else {
"The redacted text also appears in {places}, which redaction doesn't change. Remove hidden information removes them."
};
Some(crate::i18n::fmt(tl!(template), &[("places", &list)]))
}
/// Edits built from the dialog's drafts.
fn finish_edits(app: &mut PdfCraftApp, dialog: Dialog, req: &mut Pending) {
if req.props_now
+60
View File
@@ -265,3 +265,63 @@ fn removing_hidden_information_and_sanitizing() {
let s = h.state();
assert_eq!(s.session.get(s.views[0].id).unwrap().can_undo(), Some("Sanitize document"));
}
/// A one-page PDF showing "Codename BLUEFIN report", with bookmarks "Bluefin plans" and
/// "Annual figures".
fn bookmarked_pdf() -> Vec<u8> {
let content = "BT /F1 18 Tf 20 150 Td (Codename BLUEFIN report) Tj ET";
let objs = [
"<< /Type /Catalog /Pages 2 0 R /Outlines 6 0 R >>".to_string(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>".to_string(),
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".to_string(),
format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()),
"<< /Type /Outlines /First 7 0 R /Last 8 0 R /Count 2 >>".to_string(),
"<< /Title (Bluefin plans) /Parent 6 0 R /Next 8 0 R /Dest [3 0 R /Fit] >>".to_string(),
"<< /Title (Annual figures) /Parent 6 0 R /Prev 7 0 R /Dest [3 0 R /Fit] >>".to_string(),
];
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
out
}
/// #2539 review: applying redactions says when the removed text is still in a place it leaves
/// as it is (here a bookmark title), in the same notification as the count of marks applied.
#[test]
fn applying_says_where_the_removed_text_is_still_held() {
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("bluefin.pdf", None, bookmarked_pdf()).unwrap();
app
});
crate::support::settle(&mut h);
assert!(h.state_mut().execute("redact.mark"));
h.run_steps(2);
// Redact pages ▸ current page, then Redact all ▸ Apply.
h.state_mut().execute("redact.pages");
h.run_steps(2);
h.get_by_label("OK").click();
h.run_steps(3);
assert_eq!(marks(&h), 1);
h.get_by_label("Redact all").click();
h.run_steps(3);
h.get_by_label("Apply").click();
h.run_steps(4);
assert_eq!(marks(&h), 0);
let toast = h.state().toast.as_ref().map(|(m, _)| m.clone()).unwrap_or_default();
assert_eq!(
toast,
"Applied 1 redaction mark. Save to remove the content from the file. The redacted text also appears in 1 bookmark title, which redaction doesn't change. Remove hidden information removes them."
);
}
+2 -2
View File
@@ -5768,8 +5768,8 @@ milestone = "M8"
status = "shipped"
commands = ["redact.apply"]
tools = ["redact_apply"]
evidence = ["crates/ui-egui/tests/it/redact.rs::marking_applying_and_clearing", "crates/engine/src/tests.rs::redaction_marks_apply_for_good_and_undo", "crates/automation/tests/it/automation.rs::redacting_through_tools", "crates/engine/src/tests.rs::the_renderer_check_refuses_text_still_shown_under_a_mark", "crates/redact/src/tests.rs::redacted_xobjects_do_not_survive_through_inherited_or_shared_resources", "crates/redact/src/tests.rs::redacted_text_does_not_survive_in_info_or_xmp", "crates/redact/src/tests.rs::metadata_is_cleared_when_the_removed_text_was_cut_short"]
notes = "confirmation; one undo step; next save is a full rewrite. Document information entries that contain the removed text, and the document's and pages' XMP streams that do, are removed (#2448; matched without regard to case or spacing, by the whole run of removed text and by its words of three letters or digits or more, and a value wholly part of a run as whole words; fail-closed when the removed text was too much to collect or the XMP can't be decoded); other objects' XMP, bookmarks and attachments are not searched. Refused, with nothing changed, when the page still shows text under a mark afterwards: text in some predefined CMaps and rotated Type 3 fonts is not removed yet, so those documents cannot be redacted. Also refused on a page with an inline image the renderer reads no image for, since the operators after it can't be known. Removed images and forms are unlinked from page-tree resources (own, inherited or shared) and from surviving forms' resources, not yet from annotation appearances or patterns"
evidence = ["crates/ui-egui/tests/it/redact.rs::marking_applying_and_clearing", "crates/engine/src/tests.rs::redaction_marks_apply_for_good_and_undo", "crates/automation/tests/it/automation.rs::redacting_through_tools", "crates/engine/src/tests.rs::the_renderer_check_refuses_text_still_shown_under_a_mark", "crates/redact/src/tests.rs::redacted_xobjects_do_not_survive_through_inherited_or_shared_resources", "crates/redact/src/tests.rs::redacted_text_does_not_survive_in_info_or_xmp", "crates/redact/src/tests.rs::metadata_is_cleared_when_the_removed_text_was_cut_short", "crates/redact/src/tests/metadata_fail_closed.rs::an_xmp_stream_whose_filter_data_is_corrupt_goes", "crates/redact/src/tests/elsewhere.rs::places_apply_leaves_that_still_hold_the_removed_text_are_counted_and_kept", "crates/engine/src/tests.rs::applied_redactions_report_a_bookmark_that_still_holds_the_removed_text", "crates/automation/tests/it/automation.rs::redact_apply_reports_places_that_still_hold_the_removed_text", "crates/ui-egui/tests/it/redact.rs::applying_says_where_the_removed_text_is_still_held"]
notes = "confirmation; one undo step; next save is a full rewrite. Document information entries that contain the removed text, and the document's and pages' XMP streams that do, are removed (#2448; matched without regard to case or spacing, by the whole run of removed text and by its words of three letters or digits or more, and a value wholly part of a run as whole words; fail-closed: all of it goes when the removed text can't be known in full (too much to collect or search for, a glyph whose text isn't known, a form removed unread), and an XMP packet goes when it can't be decoded strictly, is over 16 MiB or isn't UTF-8 or UTF-16). Bookmark titles, attachments (names, file names, descriptions), the comments that remain and other objects' XMP are not changed: applying counts those that still hold the removed text, and the notification and redact_apply's still_holding say where (Remove hidden information removes them); when the removed text couldn't be searched for, every such place holding any text is counted. Refused, with nothing changed, when the page still shows text under a mark afterwards: text in some predefined CMaps and rotated Type 3 fonts is not removed yet, so those documents cannot be redacted. Also refused on a page with an inline image the renderer reads no image for, since the operators after it can't be known. Removed images and forms are unlinked from page-tree resources (own, inherited or shared) and from surviving forms' resources, not yet from annotation appearances or patterns"
[[feature]]
id = "protect.redact-glyph-removal"