Merge #4497: M8: after Apply redactions, Save asks where and proposes the name with _Redacted (protect.redact-filename-suffix)

This commit is contained in:
bflatastic committed 2026-11-09 18:21:20 +07:00
commit 117f3b1102
14 files changed
+577 -10

No files matched your search

+3 -1
View File
@@ -3,7 +3,7 @@
use pdfcraft_engine::{
Edit, HIDDEN, Hidden, NewAnnotation, REDACT_MAX_WORDS, REDACT_VALIDATORS, REDACTION_CODE_SETS, RedactPattern, RedactRegex, RedactValidator,
RedactionCodeSet, Shape, Style, find_pattern, rect_quad, redact_word_list,
RedactionCodeSet, Shape, Style, find_pattern, rect_quad, redact_word_list, redacted_file_name,
};
use serde_json::{Value, json};
@@ -200,6 +200,8 @@ impl Automation {
let after = self.doc(a)?.redaction_marks();
out["applied"] = json!(before - after);
out["marks_pending"] = json!(after);
// The name to save the redacted copy under, as Save does in the app.
out["save_as"] = json!(redacted_file_name(&self.doc(a)?.name));
Ok(out)
}
+1 -1
View File
@@ -678,7 +678,7 @@ pub fn tools() -> Vec<ToolDef> {
t(
"redact_apply",
"Apply redactions",
"Apply the redaction marks (all, or those on pages): text, images, vectors, comments and form fields under them are removed for good and boxes are drawn in their place. A verification pass fails the operation if anything readable remains. Undoable until saved; the saved file no longer contains the content.",
"Apply the redaction marks (all, or those on pages): text, images, vectors, comments and form fields under them are removed for good and boxes are drawn in their place. A verification pass fails the operation if anything readable remains. Undoable until saved; the saved file no longer contains the content. The result's save_as is the document's name with _Redacted before the extension (contract.pdf gives contract_Redacted.pdf; a name that already ends in _Redacted is unchanged); pass it to doc_save as path to save under that name.",
)
.destructive()
.with(schema(json!({ "doc": doc(), "pages": pages("whose marks to apply (default: all)") }), &["doc"])),
+22
View File
@@ -5356,6 +5356,28 @@ fn redacting_through_tools() {
assert!(matches!(a.call("redact_mark", &json!({ "doc": doc })), Err(ToolError::InvalidArgs(_))));
}
#[test]
fn applying_redactions_proposes_the_name_with_redacted() {
let dir = workdir("redact-save-as");
std::fs::write(dir.join("memo.txt"), "Call 555-123-4567 today").unwrap();
let mut a = auto(&dir);
let made = ok(&mut a, "doc_create", json!({ "from": "text", "path": "memo.txt" }))["doc"].as_u64().unwrap();
ok(&mut a, "doc_save", json!({ "doc": made, "path": "contract.pdf" }));
let original = std::fs::read(dir.join("contract.pdf")).unwrap();
let doc = ok(&mut a, "doc_open", json!({ "path": "contract.pdf" }))["doc"].as_u64().unwrap();
ok(&mut a, "redact_mark", json!({ "doc": doc, "pattern": "phone" }));
let r = ok(&mut a, "redact_apply", json!({ "doc": doc }));
assert_eq!(r["save_as"], "contract_Redacted.pdf");
ok(&mut a, "doc_save", json!({ "doc": doc, "path": r["save_as"] }));
assert_eq!(std::fs::read(dir.join("contract.pdf")).unwrap(), original, "the opened file is left as it was");
let bytes = std::fs::read(dir.join("contract_Redacted.pdf")).unwrap();
assert!(!bytes.windows(4).any(|w| w == b"4567"), "the copy has no trace of the number");
// Redacting the redacted copy again proposes the same name, with one suffix.
let copy = ok(&mut a, "doc_open", json!({ "path": "contract_Redacted.pdf" }))["doc"].as_u64().unwrap();
ok(&mut a, "redact_mark", json!({ "doc": copy, "find": "today" }));
assert_eq!(ok(&mut a, "redact_apply", json!({ "doc": copy }))["save_as"], "contract_Redacted.pdf");
}
#[test]
fn redacting_with_a_caller_regex_through_tools() {
let dir = workdir("redact-regex");
+1
View File
@@ -132,6 +132,7 @@ pub use pdfcraft_redact::codes::{CODE_SETS as REDACTION_CODE_SETS, CodeSet as Re
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::redacted_file_name;
pub use pdfcraft_redact::sanitize::{HIDDEN, Hidden};
pub use pdfcraft_redact::user_regex::{UserRegex as RedactRegex, VALIDATORS as REDACT_VALIDATORS, Validator as RedactValidator};
pub use pdfcraft_sign as sign;
+12
View File
@@ -1009,3 +1009,15 @@ pub fn clear_marks(doc: &mut Document, pages: Option<&[usize]>) -> Result<usize,
}
Ok(targets.len())
}
/// The name to save a document under after [`apply`]: `_Redacted` before the extension, as
/// Acrobat appends it ("contract.pdf" gives "contract_Redacted.pdf"). The `.pdf` extension is
/// matched in any letter case. A name that already ends in `_Redacted` keeps it once.
pub fn redacted_file_name(name: &str) -> String {
let cut = name.len().saturating_sub(4);
let stem = match (name.get(..cut), name.get(cut..)) {
(Some(stem), Some(ext)) if ext.eq_ignore_ascii_case(".pdf") => stem,
_ => name,
};
if stem.ends_with("_Redacted") { format!("{stem}.pdf") } else { format!("{stem}_Redacted.pdf") }
}
+11
View File
@@ -2319,6 +2319,17 @@ fn clearing_marks_leaves_the_annotation_list_as_it_was() {
assert_eq!((doc.get(page), doc.get(list)), before, "the page points at its array, which lists the link alone");
}
#[test]
fn redacted_copies_are_named_with_redacted_before_the_extension() {
assert_eq!(redacted_file_name("contract.pdf"), "contract_Redacted.pdf");
assert_eq!(redacted_file_name("SCAN.PDF"), "SCAN_Redacted.pdf");
assert_eq!(redacted_file_name("Report.Pdf"), "Report_Redacted.pdf");
// A document without the extension (opened from bytes) gets it.
assert_eq!(redacted_file_name("Untitled"), "Untitled_Redacted.pdf");
// Redacting a redacted copy again does not add a second suffix.
assert_eq!(redacted_file_name("contract_Redacted.pdf"), "contract_Redacted.pdf");
}
#[test]
fn unreadable_content_fails_closed() {
let mut doc = one_page(b"", "", vec![]);
+22
View File
@@ -715,6 +715,24 @@ pub struct DocView {
/// Redact tool: the box being drawn, and a mark to add (page, quads) for the app to style.
pub redact_drag: crate::redact_ui::AreaDrag,
pub pending_redaction: Option<(usize, Vec<[f64; 8]>)>,
/// How many Apply redactions steps are in effect in this view. While it is above 0, Save
/// proposes the name with `_Redacted` ([`pdfcraft_engine::redacted_file_name`]), and while it is
/// above `redactions_downloaded`, Save asks where.
/// Undo of an Apply redactions step takes one off and Redo adds it back. Reload and a desktop
/// save set it to 0, and Revert sets it to `redactions_downloaded`; after a desktop save the document has the name it was saved
/// under. A web download keeps it, so later downloads carry the `_Redacted` name.
pub redactions_applied: usize,
/// How many Apply redactions steps saved into the document's file are undone in this view.
/// While it is above 0, Save asks where, so the restored content is not written over the
/// redacted file. Redo takes one off. Revert, Reload and a desktop save set it to 0.
pub redactions_undone_after_save: usize,
/// Set when Apply redactions runs while saved Apply redactions steps are undone: Redo can no
/// longer bring those steps back, so Save asks where until a desktop save or Revert clears it.
pub saved_redactions_cut_off: bool,
/// `redactions_applied` at the last web download, which Revert restores. While
/// `redactions_applied` is below it, a delivered step is undone and Save asks where. A desktop
/// save leaves it at 0.
pub redactions_downloaded: usize,
/// A crop rectangle being dragged (Crop tool).
pub crop_drag: crate::crop::CropDrag,
/// Enhance ▸ Camera image: the quadrilateral being placed.
@@ -1130,6 +1148,10 @@ impl DocView {
links: Default::default(),
redact_drag: None,
pending_redaction: None,
redactions_applied: 0,
redactions_undone_after_save: 0,
saved_redactions_cut_off: false,
redactions_downloaded: 0,
crop_drag: None,
camera: None,
camera_done: None,
+9
View File
@@ -1885,6 +1885,15 @@ 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 let Some(view) = app.active.and_then(|i| app.views.get_mut(i)) {
let delivered_undone = view.redactions_applied < view.redactions_downloaded;
view.redactions_applied = view.redactions_applied.saturating_add(1);
// A new step clears the redo history, so the undone saved steps can't come back.
if view.redactions_undone_after_save > 0 || delivered_undone {
view.redactions_undone_after_save = 0;
view.saved_redactions_cut_off = true;
}
}
if let Some((_, id)) = app.active_ids() {
app.renew_recovery(id);
}
+12 -1
View File
@@ -20,7 +20,7 @@ pub use save::{SaveTarget, write_atomically};
#[cfg(test)]
pub(crate) use save::{is_mail_temp_copy, save_dialog_folder};
#[cfg(test)]
use save::{write_atomically_syncing, write_atomically_with};
use save::{unredacted_file_name, write_atomically_syncing, write_atomically_with};
/// Text typed into a new text box that isn't added to the page yet.
fn new_text_typed(v: &crate::DocView) -> bool {
@@ -380,6 +380,17 @@ impl PdfCraftApp {
view.comments.selected = None;
view.comments.history_stepped();
}
if label == (Edit::ApplyRedactions { pages: None }).label() {
let view = &mut self.views[i];
let (applied, undone) = (view.redactions_applied, view.redactions_undone_after_save);
// Undo past the last save counts as undone; Redo first takes those back.
match (undo, applied, undone) {
(true, 0, _) => view.redactions_undone_after_save = undone.saturating_add(1),
(true, _, _) => view.redactions_applied = applied.saturating_sub(1),
(false, _, 0) => view.redactions_applied = applied.saturating_add(1),
(false, _, _) => view.redactions_undone_after_save = undone.saturating_sub(1),
}
}
let label = crate::i18n::edit_label(&label);
if undo {
self.notify_fmt("Undid {label}", &[("label", &label)]);
+5
View File
@@ -94,6 +94,11 @@ impl PdfCraftApp {
view.forms.committed = None;
view.forms.unbaked.clear();
view.pending_edit = None;
// The redactions applied since the last save are discarded too. Those in the
// last web download stay, so later downloads keep the `_Redacted` name.
view.redactions_applied = view.redactions_downloaded;
view.redactions_undone_after_save = 0;
view.saved_redactions_cut_off = false;
}
self.notify_tr("Reverted to the last saved version");
}
+50
View File
@@ -50,6 +50,21 @@ impl PdfCraftApp {
let Some(id) = self.views.get(index).map(|v| v.id) else { return false };
let Some(doc) = self.session.get(id) else { return false };
let (name, path) = (doc.name.clone(), doc.path.clone());
// After Apply redactions, Save asks where and proposes the name with `_Redacted`.
// In the web build a delivered step stays counted, so only the steps applied since the
// last delivery make Save ask where.
let redacted = self.views.get(index).is_some_and(|v| v.redactions_applied > 0);
let not_delivered = self.views.get(index).is_some_and(|v| v.redactions_applied > v.redactions_downloaded);
let name = if redacted { pdfcraft_engine::redacted_file_name(&name) } else { name };
// Undo past a saved Apply redactions brings back content that the file on disk has
// redacted: ask where instead of writing it over that file. In the web build a delivered
// step stays counted in `redactions_applied`, so fewer steps than were delivered means one
// was undone.
let unredacted = self.views.get(index).is_some_and(restores_saved_redactions);
// The restored content is not a redacted copy: propose the name without `_Redacted`, so
// the dialog's default is not the redacted file.
let name = if unredacted && !redacted { unredacted_file_name(&name) } else { name };
let target = if not_delivered || unredacted { SaveTarget::As } else { target };
#[cfg(not(target_arch = "wasm32"))]
{
let doc_path = path.clone();
@@ -66,6 +81,14 @@ impl PdfCraftApp {
let dialog = in_document_folder(dialog, doc_path.as_deref());
// The bytes are taken once the user has chosen, so edits made meanwhile are saved too.
self.ask_one(crate::pickers::Ask::Save(dialog), None, move |app, dest| {
// An Apply redactions or an Undo of one while the dialog was open makes the
// name it proposed wrong: ask again with the name for the current content.
let asked = (redacted, unredacted);
let changed = |v: &crate::DocView| v.id == id && (v.redactions_applied > 0, restores_saved_redactions(v)) != asked;
if let Some(index) = app.views.iter().position(changed) {
app.save_then(index, SaveTarget::As, after);
return;
}
app.save_doc_to_then(id, &dest.to_string_lossy(), target, Box::new(after));
});
return false;
@@ -100,6 +123,9 @@ impl PdfCraftApp {
&& let Some(view) = self.views.get_mut(index)
{
view.document_changed(&doc.info);
// `redactions_applied` stays set: the document keeps its name here, so
// later downloads are named after the redacted copy too.
view.redactions_downloaded = view.redactions_applied;
}
// The host reports where the file went (and any failure) itself.
if !to_host {
@@ -206,6 +232,9 @@ impl PdfCraftApp {
// Saving over a version that changed on disk keeps this one: nothing to ask.
for view in self.views.iter_mut().filter(|v| v.id == id) {
view.disk_changed = false;
view.redactions_applied = 0;
view.redactions_undone_after_save = 0;
view.saved_redactions_cut_off = false;
}
self.notify_fmt("Saved {name}", &[("name", &short_name(dest))]);
#[cfg(target_os = "macos")]
@@ -244,6 +273,27 @@ pub(crate) fn is_mail_temp_copy(path: &str) -> bool {
|| p.contains("/library/containers/com.apple.mail/data/library/mail downloads/")
}
/// Whether `view` holds content that a saved or delivered Apply redactions removed: a saved step
/// is undone, cannot be redone, or (in the web build) fewer steps are in effect than were
/// delivered.
fn restores_saved_redactions(view: &crate::DocView) -> bool {
view.redactions_undone_after_save > 0 || view.saved_redactions_cut_off || view.redactions_applied < view.redactions_downloaded
}
/// `name` without the `_Redacted` that [`pdfcraft_engine::redacted_file_name`] adds before the
/// extension ("contract_Redacted.pdf" gives "contract.pdf"). Any other name is returned as it is.
pub(crate) fn unredacted_file_name(name: &str) -> String {
let cut = name.len().saturating_sub(4);
let (stem, ext) = match (name.get(..cut), name.get(cut..)) {
(Some(stem), Some(ext)) if ext.eq_ignore_ascii_case(".pdf") => (stem, ext),
_ => (name, ""),
};
match stem.strip_suffix("_Redacted") {
Some(base) if !base.is_empty() => format!("{base}{ext}"),
_ => name.to_string(),
}
}
/// The folder a Save As style dialog should open in for a document stored at `path`: the
/// document's own folder, as Acrobat and Foxit do, instead of whichever folder the system picks.
/// `None` (keep the system's choice) for a new document, a path with no parent, or an email
+12 -1
View File
@@ -1,4 +1,15 @@
use super::{SaveTarget, is_mail_temp_copy, save_dialog_folder, write_atomically, write_atomically_syncing, write_atomically_with};
use super::{
SaveTarget, is_mail_temp_copy, save_dialog_folder, unredacted_file_name, write_atomically, write_atomically_syncing, write_atomically_with,
};
#[test]
fn restored_content_is_not_proposed_under_the_redacted_name() {
assert_eq!(unredacted_file_name("contract_Redacted.pdf"), "contract.pdf");
assert_eq!(unredacted_file_name("SCAN_Redacted.PDF"), "SCAN.PDF");
assert_eq!(unredacted_file_name("Untitled_Redacted"), "Untitled");
assert_eq!(unredacted_file_name("contract.pdf"), "contract.pdf");
assert_eq!(unredacted_file_name("_Redacted.pdf"), "_Redacted.pdf");
}
#[test]
fn save_dialogs_open_in_the_documents_folder() {
+412 -1
View File
@@ -4,7 +4,7 @@
use egui::Pos2;
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use pdfcraft_ui_egui::{Dialog, PdfCraftApp};
use pdfcraft_ui_egui::{Dialog, PdfCraftApp, SaveTarget};
fn harness() -> Harness<'static, PdfCraftApp> {
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
@@ -74,6 +74,417 @@ fn marking_applying_and_clearing() {
assert!(doc.form.is_empty(), "the whole page was redacted, fields included");
}
/// After Apply redactions, Save asks where instead of writing over the opened file, as Acrobat
/// shows Save As with `_Redacted` appended to the name. Once that copy is written, Save writes
/// it in place again.
#[test]
fn saving_after_applying_redactions_asks_where() {
let dir = std::env::temp_dir().join(format!("pdfcraft-redacted-save-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let original = dir.join("contract.pdf");
let source = include_bytes!("../data/form.pdf").to_vec();
std::fs::write(&original, &source).unwrap();
let path = original.to_string_lossy().into_owned();
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_path(&path);
app
});
crate::support::settle(&mut h);
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);
assert!(h.state_mut().execute("redact.apply"));
h.run_steps(2);
h.get_by_label("Apply").click();
h.run_steps(4);
assert_eq!(marks(&h), 0);
assert_eq!(h.state().views[0].redactions_applied, 1);
let copy = dir.join("contract_Redacted.pdf");
h.state_mut().pick_override = Some(vec![copy.to_string_lossy().into_owned()]);
assert!(!h.state_mut().save_active(SaveTarget::InPlace), "Save asks where first");
h.run_steps(3);
assert_eq!(std::fs::read(&original).unwrap(), source, "the opened file is left as it was");
assert!(copy.exists(), "the redacted copy is written where the user chose");
let s = h.state();
assert_eq!(s.session.get(s.views[0].id).unwrap().name, "contract_Redacted.pdf");
assert_eq!(s.views[0].redactions_applied, 0);
h.state_mut().pick_override = None;
assert!(h.state_mut().save_active(SaveTarget::InPlace), "the next Save writes the copy in place");
let _ = std::fs::remove_dir_all(&dir);
}
/// Undo past an Apply redactions that is already saved in the `_Redacted` copy makes Save ask
/// where, so the restored content is not written over the copy. Redo makes Save write in place.
#[test]
fn undoing_saved_redactions_does_not_save_over_the_redacted_copy() {
let dir = std::env::temp_dir().join(format!("pdfcraft-redacted-saved-undo-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let original = dir.join("contract.pdf");
std::fs::write(&original, include_bytes!("../data/form.pdf")).unwrap();
let path = original.to_string_lossy().into_owned();
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_path(&path);
app
});
crate::support::settle(&mut h);
h.state_mut().execute("redact.pages");
h.run_steps(2);
h.get_by_label("OK").click();
h.run_steps(3);
assert!(h.state_mut().execute("redact.apply"));
h.run_steps(2);
h.get_by_label("Apply").click();
h.run_steps(4);
let copy = dir.join("contract_Redacted.pdf");
h.state_mut().pick_override = Some(vec![copy.to_string_lossy().into_owned()]);
assert!(!h.state_mut().save_active(SaveTarget::InPlace));
h.run_steps(3);
h.state_mut().pick_override = None;
assert!(copy.exists());
// Undo and Redo leave the saved redactions in effect: Save writes the copy in place.
h.state_mut().execute("edit.undo");
h.run_steps(3);
h.state_mut().execute("edit.redo");
h.run_steps(3);
assert_eq!((h.state().views[0].redactions_applied, h.state().views[0].redactions_undone_after_save), (0, 0));
assert!(h.state_mut().save_active(SaveTarget::InPlace), "with the redactions back, Save writes the copy in place");
let saved = std::fs::read(&copy).unwrap();
h.state_mut().execute("edit.undo");
h.run_steps(3);
assert_eq!(h.state().views[0].redactions_undone_after_save, 1);
let elsewhere = dir.join("contract_restored.pdf");
h.state_mut().pick_override = Some(vec![elsewhere.to_string_lossy().into_owned()]);
assert!(!h.state_mut().save_active(SaveTarget::InPlace), "Save asks where instead of writing over the copy");
h.run_steps(3);
h.state_mut().pick_override = None;
assert_eq!(std::fs::read(&copy).unwrap(), saved, "the redacted copy is left as it was");
assert!(elsewhere.exists(), "the restored version is written where the user chose");
assert_eq!(h.state().views[0].redactions_undone_after_save, 0);
// The restored version is now the saved one: Redo applies the redactions to it again.
h.state_mut().execute("edit.redo");
h.run_steps(3);
assert_eq!(h.state().views[0].redactions_applied, 1);
let _ = std::fs::remove_dir_all(&dir);
}
/// A new Apply redactions after Undo past a saved one clears Redo, so Undo and Redo of the new
/// step do not bring Save in place back over the `_Redacted` copy.
#[test]
fn redoing_a_new_apply_after_undoing_saved_redactions_still_asks_where() {
let dir = std::env::temp_dir().join(format!("pdfcraft-redacted-branch-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let original = dir.join("contract.pdf");
std::fs::write(&original, include_bytes!("../data/form.pdf")).unwrap();
let path = original.to_string_lossy().into_owned();
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_path(&path);
app
});
crate::support::settle(&mut h);
h.state_mut().execute("redact.pages");
h.run_steps(2);
h.get_by_label("OK").click();
h.run_steps(3);
assert!(h.state_mut().execute("redact.apply"));
h.run_steps(2);
h.get_by_label("Apply").click();
h.run_steps(4);
let copy = dir.join("contract_Redacted.pdf");
h.state_mut().pick_override = Some(vec![copy.to_string_lossy().into_owned()]);
assert!(!h.state_mut().save_active(SaveTarget::InPlace));
h.run_steps(3);
h.state_mut().pick_override = None;
let saved = std::fs::read(&copy).unwrap();
h.state_mut().execute("edit.undo");
h.run_steps(3);
// Apply the marks again as a new step, then Undo and Redo it.
assert!(h.state_mut().execute("redact.apply"));
h.run_steps(2);
h.get_by_label("Apply").click();
h.run_steps(4);
h.state_mut().execute("edit.undo");
h.run_steps(3);
let view = &h.state().views[0];
assert_eq!((view.redactions_applied, view.redactions_undone_after_save, view.saved_redactions_cut_off), (0, 0, true));
h.state_mut().execute("edit.redo");
h.run_steps(3);
let elsewhere = dir.join("contract_again.pdf");
h.state_mut().pick_override = Some(vec![elsewhere.to_string_lossy().into_owned()]);
assert!(!h.state_mut().save_active(SaveTarget::InPlace), "Save asks where instead of writing over the copy");
h.run_steps(3);
h.state_mut().pick_override = None;
assert_eq!(std::fs::read(&copy).unwrap(), saved, "the redacted copy is left as it was");
assert!(!h.state().views[0].saved_redactions_cut_off);
let _ = std::fs::remove_dir_all(&dir);
}
/// In the web build a delivered save keeps `redactions_applied` and records it in
/// `redactions_downloaded`. Undo past a delivered Apply redactions, or a new Apply after it, makes
/// Save ask where, so a host does not write the restored content over the redacted copy.
#[test]
fn undoing_delivered_redactions_asks_where() {
let dir = std::env::temp_dir().join(format!("pdfcraft-redacted-delivered-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let original = dir.join("contract.pdf");
std::fs::write(&original, include_bytes!("../data/form.pdf")).unwrap();
let path = original.to_string_lossy().into_owned();
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_path(&path);
app
});
crate::support::settle(&mut h);
h.state_mut().execute("redact.pages");
h.run_steps(2);
h.get_by_label("OK").click();
h.run_steps(3);
assert!(h.state_mut().execute("redact.apply"));
h.run_steps(2);
h.get_by_label("Apply").click();
h.run_steps(4);
// The state a web save leaves: the step is delivered and stays counted.
h.state_mut().views[0].redactions_downloaded = 1;
h.state_mut().execute("edit.undo");
h.run_steps(3);
let elsewhere = dir.join("contract_restored.pdf");
h.state_mut().pick_override = Some(vec![elsewhere.to_string_lossy().into_owned()]);
assert!(!h.state_mut().save_active(SaveTarget::InPlace), "Save asks where after Undo past a delivered step");
h.run_steps(3);
h.state_mut().pick_override = None;
assert!(elsewhere.exists());
// A new Apply after Undo past a delivered step cannot be redone back to the delivered one.
h.state_mut().views[0].redactions_downloaded = 1;
h.state_mut().views[0].redactions_applied = 0;
h.state_mut().execute("redact.pages");
h.run_steps(2);
h.get_by_label("OK").click();
h.run_steps(3);
assert!(h.state_mut().execute("redact.apply"));
h.run_steps(2);
h.get_by_label("Apply").click();
h.run_steps(4);
assert!(h.state().views[0].saved_redactions_cut_off);
let _ = std::fs::remove_dir_all(&dir);
}
/// Once every Apply redactions step is delivered, Save writes in place again even though the web
/// build keeps the steps counted for the `_Redacted` name.
#[test]
fn delivered_redactions_save_in_place() {
let dir = std::env::temp_dir().join(format!("pdfcraft-redacted-delivered-save-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let original = dir.join("contract.pdf");
std::fs::write(&original, include_bytes!("../data/form.pdf")).unwrap();
let path = original.to_string_lossy().into_owned();
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_path(&path);
app
});
crate::support::settle(&mut h);
h.state_mut().execute("redact.pages");
h.run_steps(2);
h.get_by_label("OK").click();
h.run_steps(3);
assert!(h.state_mut().execute("redact.apply"));
h.run_steps(2);
h.get_by_label("Apply").click();
h.run_steps(4);
// The state a web save leaves: the step is delivered and stays counted.
h.state_mut().views[0].redactions_downloaded = 1;
assert!(h.state_mut().save_active(SaveTarget::InPlace), "Save writes in place after the step is delivered");
let _ = std::fs::remove_dir_all(&dir);
}
/// Undo past a saved Apply redactions while the Save dialog is open makes Save ask again, so the
/// restored content is not written to the redacted copy the dialog proposed.
#[test]
fn undoing_saved_redactions_while_the_save_dialog_is_open_asks_again() {
let dir = std::env::temp_dir().join(format!("pdfcraft-redacted-dialog-undo-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let original = dir.join("contract.pdf");
std::fs::write(&original, include_bytes!("../data/form.pdf")).unwrap();
let path = original.to_string_lossy().into_owned();
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_path(&path);
app
});
crate::support::settle(&mut h);
let apply = |h: &mut Harness<'static, PdfCraftApp>| {
h.state_mut().execute("redact.pages");
h.run_steps(2);
h.get_by_label("OK").click();
h.run_steps(3);
assert!(h.state_mut().execute("redact.apply"));
h.run_steps(2);
h.get_by_label("Apply").click();
h.run_steps(4);
};
apply(&mut h);
let copy = dir.join("contract_Redacted.pdf");
h.state_mut().pick_override = Some(vec![copy.to_string_lossy().into_owned()]);
assert!(!h.state_mut().save_active(SaveTarget::InPlace));
h.run_steps(3);
let saved = std::fs::read(&copy).unwrap();
// A second Apply redactions makes Save ask where. Before the answer, Undo takes back the
// second Apply, its marks and the saved Apply.
apply(&mut h);
assert!(!h.state_mut().save_active(SaveTarget::InPlace));
for _ in 0..3 {
h.state_mut().execute("edit.undo");
}
assert_eq!(h.state().views[0].redactions_undone_after_save, 1);
let elsewhere = dir.join("contract_restored.pdf");
h.state_mut().pick_override = Some(vec![elsewhere.to_string_lossy().into_owned()]);
h.run_steps(4);
h.state_mut().pick_override = None;
assert_eq!(std::fs::read(&copy).unwrap(), saved, "the redacted copy is left as it was");
assert!(elsewhere.exists(), "Save asked again and wrote where the user chose");
let _ = std::fs::remove_dir_all(&dir);
}
/// Undo of Apply redactions while the Save dialog that proposed the `_Redacted` name is open
/// makes Save ask again, so the unredacted content is not saved under that name.
#[test]
fn undoing_apply_redactions_while_the_save_dialog_is_open_asks_again() {
let dir = std::env::temp_dir().join(format!("pdfcraft-redacted-dialog-unapply-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let original = dir.join("contract.pdf");
std::fs::write(&original, include_bytes!("../data/form.pdf")).unwrap();
let path = original.to_string_lossy().into_owned();
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_path(&path);
app
});
crate::support::settle(&mut h);
h.state_mut().execute("redact.pages");
h.run_steps(2);
h.get_by_label("OK").click();
h.run_steps(3);
assert!(h.state_mut().execute("redact.apply"));
h.run_steps(2);
h.get_by_label("Apply").click();
h.run_steps(4);
let copy = dir.join("contract_Redacted.pdf");
h.state_mut().pick_override = Some(vec![copy.to_string_lossy().into_owned()]);
assert!(!h.state_mut().save_active(SaveTarget::InPlace));
h.state_mut().execute("edit.undo");
assert_eq!(h.state().views[0].redactions_applied, 0);
let elsewhere = dir.join("contract_copy.pdf");
h.state_mut().pick_override = Some(vec![elsewhere.to_string_lossy().into_owned()]);
h.run_steps(4);
h.state_mut().pick_override = None;
assert!(!copy.exists(), "the unredacted content is not saved under the _Redacted name");
assert!(elsewhere.exists(), "Save asked again and wrote where the user chose");
let _ = std::fs::remove_dir_all(&dir);
}
/// Undo of Apply redactions brings back Save in place; Redo makes Save ask where again.
#[test]
fn undoing_apply_redactions_saves_in_place_again() {
let dir = std::env::temp_dir().join(format!("pdfcraft-redacted-undo-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let original = dir.join("contract.pdf");
std::fs::write(&original, include_bytes!("../data/form.pdf")).unwrap();
let path = original.to_string_lossy().into_owned();
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_path(&path);
app
});
crate::support::settle(&mut h);
for _ in 0..2 {
h.state_mut().execute("redact.pages");
h.run_steps(2);
h.get_by_label("OK").click();
h.run_steps(3);
assert!(h.state_mut().execute("redact.apply"));
h.run_steps(2);
h.get_by_label("Apply").click();
h.run_steps(4);
}
assert_eq!(h.state().views[0].redactions_applied, 2);
h.state_mut().execute("edit.undo");
h.run_steps(3);
assert_eq!(h.state().views[0].redactions_applied, 1, "the first Apply redactions is still in effect");
// Undoing the second mark leaves the first Apply redactions in effect.
h.state_mut().execute("edit.undo");
h.run_steps(3);
assert_eq!(h.state().views[0].redactions_applied, 1);
h.state_mut().execute("edit.undo");
h.run_steps(3);
assert_eq!(h.state().views[0].redactions_applied, 0);
h.state_mut().execute("edit.redo");
h.run_steps(3);
assert_eq!(h.state().views[0].redactions_applied, 1);
h.state_mut().execute("edit.undo");
h.run_steps(3);
assert!(h.state_mut().save_active(SaveTarget::InPlace), "with the redactions undone, Save writes in place");
let s = h.state();
assert_eq!(s.session.get(s.views[0].id).unwrap().name, "contract.pdf");
let _ = std::fs::remove_dir_all(&dir);
}
/// File ▸ Revert discards the applied redactions, so Save writes the opened file in place again.
#[test]
fn reverting_after_apply_redactions_saves_in_place_again() {
let dir = std::env::temp_dir().join(format!("pdfcraft-redacted-revert-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let original = dir.join("contract.pdf");
std::fs::write(&original, include_bytes!("../data/form.pdf")).unwrap();
let path = original.to_string_lossy().into_owned();
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_path(&path);
app
});
crate::support::settle(&mut h);
h.state_mut().execute("redact.pages");
h.run_steps(2);
h.get_by_label("OK").click();
h.run_steps(3);
assert!(h.state_mut().execute("redact.apply"));
h.run_steps(2);
h.get_by_label("Apply").click();
h.run_steps(4);
assert_eq!(h.state().views[0].redactions_applied, 1);
assert!(h.state_mut().execute("file.revert"));
h.run_steps(2);
assert_eq!(h.state().dialog, Some(Dialog::Revert));
h.get_all_by_label("Revert").last().expect("the button").click();
h.run_steps(3);
assert_eq!(h.state().views[0].redactions_applied, 0);
assert!(h.state_mut().save_active(SaveTarget::InPlace), "after Revert, Save writes in place");
let s = h.state();
assert_eq!(s.session.get(s.views[0].id).unwrap().name, "contract.pdf");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn redaction_codes_become_the_overlay_text() {
let mut h = harness();
+5 -5
View File
@@ -6000,11 +6000,11 @@ title = "Add a suffix to redacted file names"
area = "G"
tier = "P3"
milestone = "M8"
status = "planned"
commands = []
tools = []
evidence = []
notes = ""
status = "shipped"
commands = ["redact.apply", "file.save"]
tools = ["redact_apply"]
evidence = ["crates/ui-egui/tests/it/redact.rs::saving_after_applying_redactions_asks_where", "crates/automation/tests/it/automation.rs::applying_redactions_proposes_the_name_with_redacted", "crates/redact/src/tests.rs::redacted_copies_are_named_with_redacted_before_the_extension"]
notes = "After Apply redactions, Save asks where and proposes the name with _Redacted before the extension (contract.pdf gives contract_Redacted.pdf; a name already ending in _Redacted keeps one); the web build downloads under that name. redact_apply returns the same name in save_as, for doc_save's path; doc_save without a path still writes in place. The suffix is fixed: there is no preference to change it or to use a prefix."
[[feature]]
id = "protect.redact-ai-suggestions"