diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index e57299fdcb..046f6a87af 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -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 { + 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 = [ + 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 diff --git a/crates/ui-egui/tests/it/redact.rs b/crates/ui-egui/tests/it/redact.rs index 3e11861cbc..f32341adf6 100644 --- a/crates/ui-egui/tests/it/redact.rs +++ b/crates/ui-egui/tests/it/redact.rs @@ -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 { + 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." + ); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 4fb222a7e0..cedd69b876 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -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"