Redact: Undo or Revert of Apply redactions saves in place again, and web hosts get Save As

The view counts the Apply redactions steps in effect instead of keeping a
flag: Undo of an Apply redactions step takes one off, Redo adds it back,
and Revert, Reload and a desktop save set it to 0. Save after undoing every
Apply redactions step, or after Revert, writes the opened file in place.

The Save As choice after Apply redactions is made before the desktop and
web paths split, so an embedding page (`?host=parent`) receives Save As
with the `_Redacted` name.
This commit is contained in:
dotCooCoo
2026-10-27 20:34:16 -07:00
parent a62b22094d
commit a69a78e0e4
6 changed files with 106 additions and 14 deletions
+7 -6
View File
@@ -449,11 +449,12 @@ 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]>)>,
/// Apply redactions ran in this view: Save asks where and proposes the name with `_Redacted`
/// ([`pdfcraft_engine::redacted_file_name`]). A desktop save clears it, and the document then
/// has the name it was saved under. The web build keeps it, so every download carries the
/// `_Redacted` name.
pub redacted: bool,
/// How many Apply redactions steps are in effect in this view. While it is above 0, Save asks
/// where and proposes the name with `_Redacted` ([`pdfcraft_engine::redacted_file_name`]).
/// Undo of an Apply redactions step takes one off and Redo adds it back. Revert, Reload and a
/// desktop save set it to 0; 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,
/// A crop rectangle being dragged (Crop tool).
pub crop_drag: crate::crop::CropDrag,
/// Enhance ▸ Camera image: the quadrilateral being placed.
@@ -716,7 +717,7 @@ impl DocView {
links: Default::default(),
redact_drag: None,
pending_redaction: None,
redacted: false,
redactions_applied: 0,
crop_drag: None,
camera: None,
camera_done: None,
+1 -1
View File
@@ -1684,7 +1684,7 @@ fn finish_redactions_and_fields(app: &mut PdfCraftApp, req: &mut Pending) {
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)) {
view.redacted = true;
view.redactions_applied = view.redactions_applied.saturating_add(1);
}
if marks == 1 {
app.notify_tr("Applied 1 redaction mark. Save to remove the content from the file.");
+4
View File
@@ -220,6 +220,10 @@ impl PdfCraftApp {
self.views[i].document_changed(&doc.info);
}
self.views[i].comments.selected = None;
if label == (Edit::ApplyRedactions { pages: None }).label() {
let applied = &mut self.views[i].redactions_applied;
*applied = if undo { applied.saturating_sub(1) } else { applied.saturating_add(1) };
}
let label = crate::i18n::edit_label(&label);
if undo {
self.notify_fmt("Undid {label}", &[("label", &label)]);
+2
View File
@@ -54,6 +54,8 @@ impl PdfCraftApp {
view.forms.committed = None;
view.forms.unbaked.clear();
view.pending_edit = None;
// The redactions applied since the last save are discarded too.
view.redactions_applied = 0;
}
self.notify_tr("Reverted to the last saved version");
}
+5 -5
View File
@@ -49,12 +49,12 @@ impl PdfCraftApp {
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`.
let redacted = self.views.get(index).is_some_and(|v| v.redacted);
let redacted = self.views.get(index).is_some_and(|v| v.redactions_applied > 0);
let name = if redacted { pdfcraft_engine::redacted_file_name(&name) } else { name };
let target = if redacted { SaveTarget::As } else { target };
#[cfg(not(target_arch = "wasm32"))]
{
let doc_path = path.clone();
let target = if redacted { SaveTarget::As } else { target };
let destination = match (target, path, &self.save_override) {
(_, _, Some(p)) => p.clone(),
// An email attachment's temporary copy is no place to keep work: ask, as for a new
@@ -100,8 +100,8 @@ impl PdfCraftApp {
&& let Some(view) = self.views.get_mut(index)
{
view.document_changed(&doc.info);
// `redacted` stays set: the document keeps its name here, so later
// downloads are named after the redacted copy too.
// `redactions_applied` stays set: the document keeps its name here, so
// later downloads are named after the redacted copy too.
}
// The host reports where the file went (and any failure) itself.
if !to_host {
@@ -204,7 +204,7 @@ impl PdfCraftApp {
view.document_changed(&doc.info);
// Saving over a version that changed on disk keeps this one: nothing to ask.
view.disk_changed = false;
view.redacted = false;
view.redactions_applied = 0;
}
self.notify_fmt("Saved {name}", &[("name", &short_name(dest))]);
#[cfg(target_os = "macos")]
+87 -2
View File
@@ -103,7 +103,7 @@ fn saving_after_applying_redactions_asks_where() {
h.get_by_label("Apply").click();
h.run_steps(4);
assert_eq!(marks(&h), 0);
assert!(h.state().views[0].redacted);
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");
@@ -112,12 +112,97 @@ fn saving_after_applying_redactions_asks_where() {
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!(!s.views[0].redacted);
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 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();