From a9e92d3e3d1d9a62cafb31ce02874c41857ccd3a Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 06:05:13 -0700 Subject: [PATCH 1/9] M8: after Apply redactions, Save asks where and proposes the name with _Redacted After Apply redactions, Save wrote the redacted document over the file it was opened from. The reference app shows Save As at that point with "_Redacted" appended to the file name. The Apply redactions dialog now marks the view as redacted. While the mark is set, Save asks where, as Save As does, and the dialog proposes _Redacted.pdf in the document's folder (contract.pdf gives contract_Redacted.pdf). The .pdf extension is matched in any letter case. The web build downloads under that name and keeps the mark, so later downloads carry it too. A successful desktop save clears the mark, so the next Save writes the copy in place. A name that already ends in _Redacted keeps one suffix. The naming helper, redacted_file_name, is in pdfcraft-redact. The redact_apply tool returns the same name in save_as, for doc_save's path. doc_save without a path still writes in place. Tests: the redact UI test applies a page redaction, checks that Save asks first, that the opened file is unchanged and that the copy is written where the user chose. An automation test applies redactions through the tools, saves to save_as and checks that the opened file is unchanged. A unit test checks the proposed names. --- crates/automation/src/redact.rs | 4 ++- crates/automation/src/tools.rs | 2 +- crates/automation/tests/it/automation.rs | 22 ++++++++++++ crates/engine/src/lib.rs | 1 + crates/redact/src/lib.rs | 12 +++++++ crates/redact/src/tests.rs | 11 ++++++ crates/ui-egui/src/canvas.rs | 6 ++++ crates/ui-egui/src/dialogs.rs | 3 ++ crates/ui-egui/src/editing.rs | 7 ++++ crates/ui-egui/tests/it/redact.rs | 46 +++++++++++++++++++++++- parity/acrobat-features.toml | 10 +++--- 11 files changed, 116 insertions(+), 8 deletions(-) diff --git a/crates/automation/src/redact.rs b/crates/automation/src/redact.rs index e662bcac19..5c1b562e81 100644 --- a/crates/automation/src/redact.rs +++ b/crates/automation/src/redact.rs @@ -3,7 +3,7 @@ use pdfcraft_engine::{ Edit, Hidden, NewAnnotation, REDACT_MAX_WORDS, REDACTION_CODE_SETS, RedactPattern, RedactionCodeSet, Shape, Style, find_pattern, rect_quad, - redact_word_list, + redact_word_list, redacted_file_name, }; use serde_json::{Value, json}; @@ -165,6 +165,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) } diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 9441d7e0c5..83aa7768b1 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -508,7 +508,7 @@ pub fn tools() -> Vec { 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"])), diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index c2eb4b71c1..32f1f97965 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -4012,6 +4012,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] /// Mark, apply and save remove the complete international number while retaining labels and controls. fn redacting_italian_international_slash_numbers_through_tools() { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index ceaaa79523..091f380b5c 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -160,6 +160,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_sign as sign; pub use pdfcraft_sign::{SignOptions, SignatureInfo, Status as SignatureStatus, TrustStore}; diff --git a/crates/redact/src/lib.rs b/crates/redact/src/lib.rs index 29f23d3c96..bf12c5fd1f 100644 --- a/crates/redact/src/lib.rs +++ b/crates/redact/src/lib.rs @@ -723,3 +723,15 @@ pub fn clear_marks(doc: &mut Document, pages: Option<&[usize]>) -> Result 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") } +} diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 9ffb07e6f8..d63d860400 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -1145,6 +1145,17 @@ fn nothing_to_apply_and_clearing_marks() { assert!(content(&doc, 0).contains("(AB) Tj")); } +#[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![]); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index a0e80e0aab..4f8fcdcb4e 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -562,6 +562,11 @@ 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, /// A crop rectangle being dragged (Crop tool). pub crop_drag: crate::crop::CropDrag, /// Enhance ▸ Camera image: the quadrilateral being placed. @@ -854,6 +859,7 @@ impl DocView { links: Default::default(), redact_drag: None, pending_redaction: None, + redacted: false, crop_drag: None, camera: None, camera_done: None, diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 1099030ea7..7378fbe0f2 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1353,6 +1353,9 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { 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)) { + view.redacted = true; + } if marks == 1 { app.notify_tr("Applied 1 redaction mark. Save to remove the content from the file."); } else { diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index 7ed6e07959..4eefa4f0a7 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -666,9 +666,13 @@ 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`. + let redacted = self.views.get(index).is_some_and(|v| v.redacted); + let name = if redacted { pdfcraft_engine::redacted_file_name(&name) } else { name }; #[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 @@ -714,6 +718,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. } // The host reports where the file went (and any failure) itself. if !to_host { @@ -816,6 +822,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; } self.notify_fmt("Saved {name}", &[("name", &short_name(dest))]); #[cfg(target_os = "macos")] diff --git a/crates/ui-egui/tests/it/redact.rs b/crates/ui-egui/tests/it/redact.rs index 3e11861cbc..a71f09f3cc 100644 --- a/crates/ui-egui/tests/it/redact.rs +++ b/crates/ui-egui/tests/it/redact.rs @@ -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,50 @@ 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!(h.state().views[0].redacted); + 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!(!s.views[0].redacted); + 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); +} + #[test] fn redaction_codes_become_the_overlay_text() { let mut h = harness(); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 511bb7589f..161d3de6ea 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5825,11 +5825,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" From a69a78e0e43d0bcb151cfbc7fd81650e49a6b4f3 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 27 Oct 2026 20:27:02 -0700 Subject: [PATCH 2/9] 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. --- crates/ui-egui/src/canvas.rs | 13 +++-- crates/ui-egui/src/dialogs.rs | 2 +- crates/ui-egui/src/editing.rs | 4 ++ crates/ui-egui/src/editing/close.rs | 2 + crates/ui-egui/src/editing/save.rs | 10 ++-- crates/ui-egui/tests/it/redact.rs | 89 ++++++++++++++++++++++++++++- 6 files changed, 106 insertions(+), 14 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index da95171a3b..f8eeccb18e 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -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, diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index f2870f73ca..f927e00fc6 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -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."); diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index 4d09b5cf4b..4e1f123969 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -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)]); diff --git a/crates/ui-egui/src/editing/close.rs b/crates/ui-egui/src/editing/close.rs index 55d884cd9a..e93d457a0f 100644 --- a/crates/ui-egui/src/editing/close.rs +++ b/crates/ui-egui/src/editing/close.rs @@ -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"); } diff --git a/crates/ui-egui/src/editing/save.rs b/crates/ui-egui/src/editing/save.rs index 6a4ff2ec5d..02093188e8 100644 --- a/crates/ui-egui/src/editing/save.rs +++ b/crates/ui-egui/src/editing/save.rs @@ -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")] diff --git a/crates/ui-egui/tests/it/redact.rs b/crates/ui-egui/tests/it/redact.rs index a71f09f3cc..514259b918 100644 --- a/crates/ui-egui/tests/it/redact.rs +++ b/crates/ui-egui/tests/it/redact.rs @@ -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(); From e09fcba9180e842c3eba0da0c079fb165d0a3a58 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 28 Oct 2026 06:24:26 -0700 Subject: [PATCH 3/9] Redact: Undo past a saved Apply redactions makes Save ask where Undo of an Apply redactions step that was already saved brought back the redacted content, and Save then wrote it in place over the _Redacted file. The view now counts the saved steps that Undo takes back, and Save asks where while any are undone. A new Apply redactions made while saved steps are undone clears Redo, so Save keeps asking until the next save or Revert. --- crates/ui-egui/src/canvas.rs | 9 +++ crates/ui-egui/src/dialogs.rs | 5 ++ crates/ui-egui/src/editing.rs | 11 ++- crates/ui-egui/src/editing/close.rs | 2 + crates/ui-egui/src/editing/save.rs | 7 +- crates/ui-egui/tests/it/redact.rs | 111 ++++++++++++++++++++++++++++ 6 files changed, 142 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index f8eeccb18e..695ca7621b 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -455,6 +455,13 @@ pub struct DocView { /// 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, + /// 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, /// A crop rectangle being dragged (Crop tool). pub crop_drag: crate::crop::CropDrag, /// Enhance ▸ Camera image: the quadrilateral being placed. @@ -718,6 +725,8 @@ impl DocView { redact_drag: None, pending_redaction: None, redactions_applied: 0, + redactions_undone_after_save: 0, + saved_redactions_cut_off: false, crop_drag: None, camera: None, camera_done: None, diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index f927e00fc6..7a5bdda2d7 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1685,6 +1685,11 @@ fn finish_redactions_and_fields(app: &mut PdfCraftApp, req: &mut Pending) { if app.apply_edit(Edit::ApplyRedactions { pages: None }) { if let Some(view) = app.active.and_then(|i| app.views.get_mut(i)) { 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 { + view.redactions_undone_after_save = 0; + view.saved_redactions_cut_off = true; + } } if marks == 1 { app.notify_tr("Applied 1 redaction mark. Save to remove the content from the file."); diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index 4e1f123969..17bb319c2c 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -221,8 +221,15 @@ impl PdfCraftApp { } 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 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 { diff --git a/crates/ui-egui/src/editing/close.rs b/crates/ui-egui/src/editing/close.rs index e93d457a0f..af8040b07e 100644 --- a/crates/ui-egui/src/editing/close.rs +++ b/crates/ui-egui/src/editing/close.rs @@ -56,6 +56,8 @@ impl PdfCraftApp { view.pending_edit = None; // The redactions applied since the last save are discarded too. view.redactions_applied = 0; + view.redactions_undone_after_save = 0; + view.saved_redactions_cut_off = false; } self.notify_tr("Reverted to the last saved version"); } diff --git a/crates/ui-egui/src/editing/save.rs b/crates/ui-egui/src/editing/save.rs index 02093188e8..690619c689 100644 --- a/crates/ui-egui/src/editing/save.rs +++ b/crates/ui-egui/src/editing/save.rs @@ -51,7 +51,10 @@ impl PdfCraftApp { // After Apply redactions, Save asks where and proposes the name with `_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 }; + // 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. + let unredacted = self.views.get(index).is_some_and(|v| v.redactions_undone_after_save > 0 || v.saved_redactions_cut_off); + let target = if redacted || unredacted { SaveTarget::As } else { target }; #[cfg(not(target_arch = "wasm32"))] { let doc_path = path.clone(); @@ -205,6 +208,8 @@ impl PdfCraftApp { // Saving over a version that changed on disk keeps this one: nothing to ask. 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")] diff --git a/crates/ui-egui/tests/it/redact.rs b/crates/ui-egui/tests/it/redact.rs index 514259b918..747a97cb9a 100644 --- a/crates/ui-egui/tests/it/redact.rs +++ b/crates/ui-egui/tests/it/redact.rs @@ -118,6 +118,117 @@ fn saving_after_applying_redactions_asks_where() { 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(©).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(©).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(©).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(©).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); +} + /// Undo of Apply redactions brings back Save in place; Redo makes Save ask where again. #[test] fn undoing_apply_redactions_saves_in_place_again() { From 32124be357704d6654e8999705c7a7bb48ce5c35 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 28 Oct 2026 06:31:27 -0700 Subject: [PATCH 4/9] Redact: Revert on the web keeps the _Redacted name of the last download A web download keeps the document's name, so the view's count of applied redactions is what names later downloads after the redacted copy. Revert set that count to 0 even though it restores the downloaded, redacted version. Revert now restores the count from the last download. --- crates/ui-egui/src/canvas.rs | 8 ++++++-- crates/ui-egui/src/editing/close.rs | 5 +++-- crates/ui-egui/src/editing/save.rs | 1 + 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 695ca7621b..8626dd865b 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -451,8 +451,8 @@ pub struct DocView { 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 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 + /// 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. @@ -462,6 +462,9 @@ pub struct DocView { /// 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. 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. @@ -727,6 +730,7 @@ impl DocView { redactions_applied: 0, redactions_undone_after_save: 0, saved_redactions_cut_off: false, + redactions_downloaded: 0, crop_drag: None, camera: None, camera_done: None, diff --git a/crates/ui-egui/src/editing/close.rs b/crates/ui-egui/src/editing/close.rs index af8040b07e..b623209f8e 100644 --- a/crates/ui-egui/src/editing/close.rs +++ b/crates/ui-egui/src/editing/close.rs @@ -54,8 +54,9 @@ 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; + // 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; } diff --git a/crates/ui-egui/src/editing/save.rs b/crates/ui-egui/src/editing/save.rs index 690619c689..7a82b2ba39 100644 --- a/crates/ui-egui/src/editing/save.rs +++ b/crates/ui-egui/src/editing/save.rs @@ -105,6 +105,7 @@ impl PdfCraftApp { 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 { From 0eceb8d2b257a9332d2837f275e3b3449e0a0778 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 30 Oct 2026 21:56:47 -0700 Subject: [PATCH 5/9] Redact: Undo past a delivered web save makes Save ask where In the web build a delivered save keeps Apply redactions steps counted, so Undo of a delivered step left Save writing to the host in place. Save now asks where while fewer steps are in effect than were delivered, and a new Apply after such an Undo marks the delivered steps as cut off. --- crates/ui-egui/src/canvas.rs | 5 +-- crates/ui-egui/src/dialogs.rs | 3 +- crates/ui-egui/src/editing/save.rs | 9 ++++-- crates/ui-egui/tests/it/redact.rs | 51 ++++++++++++++++++++++++++++++ 4 files changed, 63 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 4210e238ed..705766005c 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -464,8 +464,9 @@ pub struct DocView { /// 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. A desktop save - /// leaves it at 0. + /// `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, diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 6609f75efe..fbdf1db57e 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1720,9 +1720,10 @@ 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)) { + 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 { + if view.redactions_undone_after_save > 0 || delivered_undone { view.redactions_undone_after_save = 0; view.saved_redactions_cut_off = true; } diff --git a/crates/ui-egui/src/editing/save.rs b/crates/ui-egui/src/editing/save.rs index 7a82b2ba39..81d1ff115e 100644 --- a/crates/ui-egui/src/editing/save.rs +++ b/crates/ui-egui/src/editing/save.rs @@ -52,8 +52,13 @@ impl PdfCraftApp { 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 }; // 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. - let unredacted = self.views.get(index).is_some_and(|v| v.redactions_undone_after_save > 0 || v.saved_redactions_cut_off); + // 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(|v| v.redactions_undone_after_save > 0 || v.saved_redactions_cut_off || v.redactions_applied < v.redactions_downloaded); let target = if redacted || unredacted { SaveTarget::As } else { target }; #[cfg(not(target_arch = "wasm32"))] { diff --git a/crates/ui-egui/tests/it/redact.rs b/crates/ui-egui/tests/it/redact.rs index 747a97cb9a..ff97c49b9e 100644 --- a/crates/ui-egui/tests/it/redact.rs +++ b/crates/ui-egui/tests/it/redact.rs @@ -229,6 +229,57 @@ fn redoing_a_new_apply_after_undoing_saved_redactions_still_asks_where() { 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); +} + /// Undo of Apply redactions brings back Save in place; Redo makes Save ask where again. #[test] fn undoing_apply_redactions_saves_in_place_again() { From ebafc30ed1cd82f2ba2ad1d8124ba6f43dbb40c7 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 30 Oct 2026 22:04:03 -0700 Subject: [PATCH 6/9] Redact: Save writes in place once the web build delivered the redactions In the web build Save kept sending Save As to the host after the redacted file was delivered, because the delivered steps stay counted for the _Redacted name. Save now asks where only for steps applied since the last delivery. --- crates/ui-egui/src/canvas.rs | 5 +++-- crates/ui-egui/src/editing/save.rs | 5 ++++- crates/ui-egui/tests/it/redact.rs | 31 ++++++++++++++++++++++++++++++ 3 files changed, 38 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 705766005c..17219bb8ac 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -451,8 +451,9 @@ 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 asks - /// where and proposes the name with `_Redacted` ([`pdfcraft_engine::redacted_file_name`]). + /// 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. diff --git a/crates/ui-egui/src/editing/save.rs b/crates/ui-egui/src/editing/save.rs index 81d1ff115e..2e9e8e72ab 100644 --- a/crates/ui-egui/src/editing/save.rs +++ b/crates/ui-egui/src/editing/save.rs @@ -49,7 +49,10 @@ 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`. + // 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 @@ -59,7 +62,7 @@ impl PdfCraftApp { .views .get(index) .is_some_and(|v| v.redactions_undone_after_save > 0 || v.saved_redactions_cut_off || v.redactions_applied < v.redactions_downloaded); - let target = if redacted || unredacted { SaveTarget::As } else { target }; + let target = if not_delivered || unredacted { SaveTarget::As } else { target }; #[cfg(not(target_arch = "wasm32"))] { let doc_path = path.clone(); diff --git a/crates/ui-egui/tests/it/redact.rs b/crates/ui-egui/tests/it/redact.rs index ff97c49b9e..9a7f637722 100644 --- a/crates/ui-egui/tests/it/redact.rs +++ b/crates/ui-egui/tests/it/redact.rs @@ -280,6 +280,37 @@ fn undoing_delivered_redactions_asks_where() { 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 of Apply redactions brings back Save in place; Redo makes Save ask where again. #[test] fn undoing_apply_redactions_saves_in_place_again() { From 3dae8491750f77db601a6db296f78deb360460bd Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 30 Oct 2026 22:10:53 -0700 Subject: [PATCH 7/9] Redact: Save As after Undo past saved redactions proposes the name without _Redacted After Undo past a saved Apply redactions, Save asks where but proposed the redacted copy's own name, so the dialog's default was the redacted file. It now proposes the name without _Redacted. --- crates/ui-egui/src/editing.rs | 2 +- crates/ui-egui/src/editing/save.rs | 17 +++++++++++++++++ crates/ui-egui/src/editing/tests.rs | 13 ++++++++++++- 3 files changed, 30 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index 2e7c11b935..600f5e2c47 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -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 { diff --git a/crates/ui-egui/src/editing/save.rs b/crates/ui-egui/src/editing/save.rs index 2e9e8e72ab..41f647ff92 100644 --- a/crates/ui-egui/src/editing/save.rs +++ b/crates/ui-egui/src/editing/save.rs @@ -62,6 +62,9 @@ impl PdfCraftApp { .views .get(index) .is_some_and(|v| v.redactions_undone_after_save > 0 || v.saved_redactions_cut_off || v.redactions_applied < v.redactions_downloaded); + // 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"))] { @@ -257,6 +260,20 @@ pub(crate) fn is_mail_temp_copy(path: &str) -> bool { || p.contains("/library/containers/com.apple.mail/data/library/mail downloads/") } +/// `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 diff --git a/crates/ui-egui/src/editing/tests.rs b/crates/ui-egui/src/editing/tests.rs index 76886ff54f..d848e841c5 100644 --- a/crates/ui-egui/src/editing/tests.rs +++ b/crates/ui-egui/src/editing/tests.rs @@ -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() { From b4dc90b2ced9163bb08818bee2a04c9f536f1cc3 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 30 Oct 2026 22:19:37 -0700 Subject: [PATCH 8/9] Redact: Undo past saved redactions while the Save dialog is open asks again The Save dialog proposes its name when it opens, and the document's bytes are taken once the user chooses. An Undo past a saved Apply redactions in between wrote the restored content to the redacted copy the dialog had proposed. Save now asks again, proposing the name for the restored content. --- crates/ui-egui/src/editing/save.rs | 18 ++++++++--- crates/ui-egui/tests/it/redact.rs | 50 ++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/editing/save.rs b/crates/ui-egui/src/editing/save.rs index 41f647ff92..855c2faea9 100644 --- a/crates/ui-egui/src/editing/save.rs +++ b/crates/ui-egui/src/editing/save.rs @@ -58,10 +58,7 @@ impl PdfCraftApp { // 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(|v| v.redactions_undone_after_save > 0 || v.saved_redactions_cut_off || v.redactions_applied < v.redactions_downloaded); + 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 }; @@ -80,6 +77,12 @@ 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 Undo past saved redactions while the dialog was open makes the name + // it proposed wrong: ask again with the name for the restored content. + if !unredacted && let Some(index) = app.views.iter().position(|v| v.id == id && restores_saved_redactions(v)) { + app.save_then(index, SaveTarget::As, after); + return; + } app.save_doc_to_then(id, &dest.to_string_lossy(), target, Box::new(after)); }); return false; @@ -260,6 +263,13 @@ 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 { diff --git a/crates/ui-egui/tests/it/redact.rs b/crates/ui-egui/tests/it/redact.rs index 9a7f637722..62611f9001 100644 --- a/crates/ui-egui/tests/it/redact.rs +++ b/crates/ui-egui/tests/it/redact.rs @@ -311,6 +311,56 @@ fn delivered_redactions_save_in_place() { 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(©).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(©).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 brings back Save in place; Redo makes Save ask where again. #[test] fn undoing_apply_redactions_saves_in_place_again() { From 1ffce03566bb79fb6663e691e0dbedbbbbb022d9 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 30 Oct 2026 22:23:28 -0700 Subject: [PATCH 9/9] Redact: Undo of Apply redactions while the Save dialog is open asks again An Undo of Apply redactions while the dialog that proposed the _Redacted name was open saved the unredacted content under that name. Save now asks again whenever an Apply redactions or an Undo of one changes what the dialog's proposed name was based on. --- crates/ui-egui/src/editing/save.rs | 8 +++--- crates/ui-egui/tests/it/redact.rs | 39 ++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/editing/save.rs b/crates/ui-egui/src/editing/save.rs index 855c2faea9..e3e9b108d6 100644 --- a/crates/ui-egui/src/editing/save.rs +++ b/crates/ui-egui/src/editing/save.rs @@ -77,9 +77,11 @@ 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 Undo past saved redactions while the dialog was open makes the name - // it proposed wrong: ask again with the name for the restored content. - if !unredacted && let Some(index) = app.views.iter().position(|v| v.id == id && restores_saved_redactions(v)) { + // 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; } diff --git a/crates/ui-egui/tests/it/redact.rs b/crates/ui-egui/tests/it/redact.rs index 62611f9001..ccb3a267ef 100644 --- a/crates/ui-egui/tests/it/redact.rs +++ b/crates/ui-egui/tests/it/redact.rs @@ -361,6 +361,45 @@ fn undoing_saved_redactions_while_the_save_dialog_is_open_asks_again() { 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() {