mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-25 18:47:22 -06:00
* Redaction Tool Properties: the mark's appearance, and Make properties default Acrobat's third group in the Redaction Tool Properties dialog: the mark's outline colour (/C), a fill colour shown while the pointer is over a mark (a viewing preference, never written), and the mark's opacity (/CA). Make properties default: ticked, OK keeps the values for the next run (saved with the settings as redact_prefs); unticked, they hold for this run only and the saved defaults stay; Cancel puts back what the dialog opened with. The dialog is laid out in two columns so it fits a laptop screen. Rows in every language, their checksums in ATTRIBUTION.toml, parity protect.redaction-properties shipped. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Refresh catalog hashes after merging main --------- Co-authored-by: Osvaldo Duran <338149330+regendorf-ux@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Brandon Thomas <echelon@gmail.com>
274 lines
11 KiB
Rust
274 lines
11 KiB
Rust
//! Redact a PDF in the real shell (egui_kittest): the Redact tool, Redact pages, the apply
|
|
//! confirmation and Clear all.
|
|
|
|
use egui::Pos2;
|
|
use egui_kittest::Harness;
|
|
use egui_kittest::kittest::Queryable;
|
|
use pdfcraft_ui_egui::{Dialog, PdfCraftApp};
|
|
|
|
fn harness() -> Harness<'static, PdfCraftApp> {
|
|
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
|
|
let mut app = PdfCraftApp::new();
|
|
app.set_option("language", "en").unwrap();
|
|
app.open_bytes("form.pdf", None, include_bytes!("../data/form.pdf").to_vec()).unwrap();
|
|
app.set_option("zoom", "150").unwrap();
|
|
app
|
|
});
|
|
for _ in 0..60 {
|
|
h.run_steps(2);
|
|
if !h.state().render_pending() {
|
|
break;
|
|
}
|
|
std::thread::sleep(std::time::Duration::from_millis(5));
|
|
}
|
|
h
|
|
}
|
|
|
|
fn at(h: &Harness<'static, PdfCraftApp>, x: f32, y: f32) -> Pos2 {
|
|
let r = h.state().views[0].page_screen_rect(0).expect("on screen");
|
|
let k = r.width() / 300.0;
|
|
r.min + egui::vec2(x * k, y * k)
|
|
}
|
|
|
|
fn marks(h: &Harness<'static, PdfCraftApp>) -> usize {
|
|
let s = h.state();
|
|
s.session.get(s.views[0].id).unwrap().redaction_marks()
|
|
}
|
|
|
|
#[test]
|
|
fn marking_applying_and_clearing() {
|
|
let mut h = harness();
|
|
assert!(h.state_mut().execute("redact.mark"));
|
|
h.run_steps(2);
|
|
h.get_by_label("Redact a PDF");
|
|
// Draw a box in an empty part of the page.
|
|
let (a, b) = (at(&h, 40.0, 280.0), at(&h, 200.0, 320.0));
|
|
h.hover_at(a);
|
|
h.run_steps(1);
|
|
h.drag_at(a);
|
|
h.run_steps(1);
|
|
for k in 1..=4 {
|
|
h.hover_at(a + (b - a) * (k as f32 / 4.0));
|
|
h.run_steps(1);
|
|
}
|
|
h.drop_at(b);
|
|
h.run_steps(4);
|
|
assert_eq!(marks(&h), 1);
|
|
// Redact pages ▸ current page.
|
|
h.state_mut().execute("redact.pages");
|
|
h.run_steps(2);
|
|
h.get_by_label("Mark Page Range");
|
|
h.get_by_label("OK").click();
|
|
h.run_steps(3);
|
|
assert_eq!(marks(&h), 2);
|
|
// Clear all, then undo it.
|
|
h.get_by_label("Clear all").click();
|
|
h.run_steps(3);
|
|
assert_eq!(marks(&h), 0);
|
|
h.state_mut().execute("edit.undo");
|
|
h.run_steps(3);
|
|
assert_eq!(marks(&h), 2);
|
|
// Redact all asks first; Apply removes the marks (and the page's fields under them).
|
|
h.get_by_label("Redact all").click();
|
|
h.run_steps(3);
|
|
assert_eq!(h.state().dialog, Some(Dialog::RedactApply));
|
|
h.get_by_label("Apply").click();
|
|
h.run_steps(4);
|
|
let s = h.state();
|
|
let doc = s.session.get(s.views[0].id).unwrap();
|
|
assert_eq!((doc.redaction_marks(), doc.can_undo()), (0, Some("Apply redactions")));
|
|
assert!(doc.form.is_empty(), "the whole page was redacted, fields included");
|
|
}
|
|
|
|
#[test]
|
|
fn redaction_codes_become_the_overlay_text() {
|
|
let mut h = harness();
|
|
assert!(h.state_mut().execute("redact.properties"));
|
|
h.run_steps(2);
|
|
h.get_by_label("Redaction Tool Properties");
|
|
h.get_by_label("Use overlay text").click();
|
|
h.run_steps(2);
|
|
h.state_mut().redact_prefs.overlay = "CONFIDENTIAL".into();
|
|
h.get_by_label("Redaction code:").click();
|
|
h.run_steps(2);
|
|
h.get_by_label("(b)(7)(C)").click();
|
|
h.run_steps(2);
|
|
h.get_by_label("(b)(6)").click();
|
|
h.run_steps(2);
|
|
h.get_by_label("OK").click();
|
|
h.run_steps(2);
|
|
let prefs = &h.state().redact_prefs;
|
|
assert_eq!(prefs.overlay_text(), "(b)(6), (b)(7)(C)", "codes in the set's order");
|
|
let pdfcraft_engine::Edit::AddAnnotation(a) = prefs.mark(0, vec![pdfcraft_engine::rect_quad([10.0, 10.0, 50.0, 30.0])], "T") else {
|
|
panic!("a mark adds an annotation")
|
|
};
|
|
assert!(matches!(a.shape, pdfcraft_engine::Shape::Redact { ref overlay, .. } if overlay == "(b)(6), (b)(7)(C)"));
|
|
h.state_mut().redact_prefs.use_code = false;
|
|
assert_eq!(h.state().redact_prefs.overlay_text(), "CONFIDENTIAL", "custom text is kept while codes are used");
|
|
}
|
|
|
|
/// The Redaction Tool Properties are the next run's too, like every other tool's defaults
|
|
/// (Acrobat's "Make Properties Default" without the box): fill, overlay text or codes, the
|
|
/// text's look, and the mark's outline colour and opacity all come back from the settings.
|
|
#[test]
|
|
fn redaction_properties_survive_a_restart() {
|
|
let mut h = harness();
|
|
{
|
|
let p = &mut h.state_mut().redact_prefs;
|
|
p.fill = None;
|
|
p.use_overlay = true;
|
|
p.use_code = true;
|
|
p.code_set = pdfcraft_engine::REDACTION_CODE_SETS[0];
|
|
p.codes = vec!["(b)(6)", "(b)(7)(C)"];
|
|
p.overlay = "CONFIDENTIAL".into();
|
|
p.look.font = pdfcraft_engine::OverlayFont::Courier;
|
|
p.look.size = 12.0;
|
|
p.look.color = [0.0, 0.0, 1.0];
|
|
p.look.align = 2;
|
|
p.look.repeat = true;
|
|
p.outline = [0.0, 0.5, 0.0];
|
|
p.hover_fill = [1.0, 1.0, 0.0];
|
|
p.opacity = 0.4;
|
|
}
|
|
// The defaults are what OK with "Make properties default" ticked leaves: go through the
|
|
// dialog.
|
|
assert!(h.state_mut().execute("redact.properties"));
|
|
h.run_steps(6);
|
|
h.get_by_label("Make properties default");
|
|
h.get_by_label("OK").click();
|
|
h.run_steps(2);
|
|
let json = h.state().persist();
|
|
let mut fresh = PdfCraftApp::new();
|
|
fresh.restore(&json);
|
|
let p = &fresh.redact_prefs;
|
|
assert_eq!(p.fill, None, "no colour is a saved choice, not a missing one");
|
|
assert_eq!(p.overlay_text(), "(b)(6), (b)(7)(C)");
|
|
assert_eq!(p.overlay, "CONFIDENTIAL");
|
|
assert_eq!(p.look.font, pdfcraft_engine::OverlayFont::Courier);
|
|
assert_eq!((p.look.size, p.look.color, p.look.align, p.look.repeat), (12.0, [0.0, 0.0, 1.0], 2, true));
|
|
assert_eq!((p.outline, p.hover_fill, p.opacity), ([0.0, 0.5, 0.0], [1.0, 1.0, 0.0], 0.4));
|
|
// The next mark takes the outline and opacity.
|
|
let pdfcraft_engine::Edit::AddAnnotation(a) = p.mark(0, vec![pdfcraft_engine::rect_quad([10.0, 10.0, 50.0, 30.0])], "T") else {
|
|
panic!("a mark adds an annotation")
|
|
};
|
|
assert_eq!((a.style.color, a.style.opacity, a.style.fill), ([0.0, 0.5, 0.0], 0.4, None));
|
|
// Settings are untrusted: a bad colour, an unknown code set and a code outside the set are
|
|
// dropped, the rest kept.
|
|
let mut odd = PdfCraftApp::new();
|
|
odd.restore(
|
|
r#"{"redact_prefs":{"outline":[2,0,0],"opacity":0.7,"code_set":"nope","codes":["(b)(6)","bogus"],"use_overlay":true,"use_code":true}}"#,
|
|
);
|
|
let p = &odd.redact_prefs;
|
|
assert_eq!((p.outline, p.opacity), ([0.89, 0.13, 0.13], 0.7));
|
|
assert_eq!(p.code_set.id, pdfcraft_engine::REDACTION_CODE_SETS[0].id);
|
|
assert_eq!(p.overlay_text(), "(b)(6)");
|
|
}
|
|
|
|
/// "Make properties default" off: OK keeps the values for this run only, the saved defaults
|
|
/// stay what they were; Cancel puts back what the dialog opened with.
|
|
#[test]
|
|
fn redaction_properties_for_this_run_only_and_cancel() {
|
|
let mut h = harness();
|
|
assert!(h.state_mut().execute("redact.properties"));
|
|
h.run_steps(6);
|
|
h.get_by_label("Redaction mark appearance");
|
|
h.get_by_label("Outline colour:");
|
|
h.get_by_label("Fill colour:");
|
|
h.get_by_label("Opacity:");
|
|
h.get_by_label("Make properties default").click();
|
|
h.run_steps(3);
|
|
assert!(!h.state().redact_prefs.remember);
|
|
h.state_mut().redact_prefs.outline = [0.0, 0.0, 1.0];
|
|
h.get_by_label("OK").click();
|
|
h.run_steps(2);
|
|
assert_eq!(h.state().redact_prefs.outline, [0.0, 0.0, 1.0], "used for this run");
|
|
assert_eq!(h.state().redact_defaults.outline, [0.89, 0.13, 0.13], "not made the default");
|
|
let mut fresh = PdfCraftApp::new();
|
|
fresh.restore(&h.state().persist());
|
|
assert_eq!(fresh.redact_prefs.outline, [0.89, 0.13, 0.13], "the next run starts from the saved defaults");
|
|
// Cancel discards the edits made while the dialog was open.
|
|
assert!(h.state_mut().execute("redact.properties"));
|
|
h.run_steps(2);
|
|
h.state_mut().redact_prefs.opacity = 0.2;
|
|
h.get_by_label("Cancel").click();
|
|
h.run_steps(2);
|
|
assert_eq!(h.state().redact_prefs.opacity, 1.0, "cancelled");
|
|
assert_eq!(h.state().redact_prefs.outline, [0.0, 0.0, 1.0], "the run's values stay");
|
|
}
|
|
|
|
#[test]
|
|
fn redacting_an_imported_word_list() {
|
|
let memo = pdfcraft_engine::Session::new().create_from_text("memo", "Call Ada today\nAda is private\nPublic line").unwrap().to_vec();
|
|
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_bytes("memo.pdf", None, memo.clone()).unwrap();
|
|
app
|
|
});
|
|
h.run_steps(4);
|
|
h.state_mut().execute("redact.search");
|
|
h.run_steps(2);
|
|
h.get_by_label("Multiple words or phrases");
|
|
let list = b" Ada \n\nprivate\nAda\nabsent\n".to_vec();
|
|
h.state_mut().use_files(pdfcraft_ui_egui::FilePurpose::RedactWords, vec![("names.txt".into(), list)]);
|
|
let d = &h.state().redact_search;
|
|
assert_eq!((d.mode, d.words.as_str()), (pdfcraft_ui_egui::RedactSearchMode::Words, "Ada\nprivate\nabsent"));
|
|
h.run_steps(2);
|
|
h.get_by_label("3 word(s) or phrase(s)");
|
|
h.get_by_label("Mark all").click();
|
|
h.run_steps(3);
|
|
assert_eq!((marks(&h), h.state().redact_search.found), (3, Some(3)));
|
|
let too_big = vec![b'a'; 2 << 20];
|
|
h.state_mut().use_files(pdfcraft_ui_egui::FilePurpose::RedactWords, vec![("huge.txt".into(), too_big)]);
|
|
assert_eq!(h.state().redact_search.words, "Ada\nprivate\nabsent", "an oversized list changes nothing");
|
|
}
|
|
|
|
#[test]
|
|
fn redaction_search_can_match_case_and_whole_words() {
|
|
let memo = pdfcraft_engine::Session::new().create_from_text("memo", "Ada ada Adaline\nAda beta betamax").unwrap().to_vec();
|
|
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_bytes("memo.pdf", None, memo.clone()).unwrap();
|
|
app
|
|
});
|
|
h.run_steps(4);
|
|
h.state_mut().execute("redact.search");
|
|
h.state_mut().redact_search.text = "Ada".into();
|
|
h.run_steps(2);
|
|
h.get_by_label("Whole words only").click();
|
|
h.get_by_label("Case-sensitive").click();
|
|
h.run_steps(2);
|
|
assert!(h.state().redact_search.whole_words && h.state().redact_search.case_sensitive);
|
|
h.get_by_label("Mark all").click();
|
|
h.run_steps(3);
|
|
assert_eq!((marks(&h), h.state().redact_search.found), (2, Some(2)));
|
|
}
|
|
|
|
#[test]
|
|
fn removing_hidden_information_and_sanitizing() {
|
|
let mut h = harness();
|
|
h.state_mut().execute("protect.remove_hidden");
|
|
h.run_steps(2);
|
|
h.get_by_label("Remove hidden information");
|
|
{
|
|
let d = &h.state().hidden_draft;
|
|
let fields = d.found.iter().find(|f| f.0 == pdfcraft_engine::Hidden::FormFields).unwrap();
|
|
assert_eq!((fields.1, fields.2), (5, true), "five fields, checked");
|
|
}
|
|
h.get_by_label("Remove").click();
|
|
h.run_steps(3);
|
|
{
|
|
let s = h.state();
|
|
let doc = s.session.get(s.views[0].id).unwrap();
|
|
assert!(doc.form.is_empty());
|
|
assert_eq!(doc.can_undo(), Some("Remove hidden information"));
|
|
}
|
|
h.state_mut().execute("redact.sanitize");
|
|
h.run_steps(2);
|
|
h.get_by_label("Sanitize").click();
|
|
h.run_steps(3);
|
|
let s = h.state();
|
|
assert_eq!(s.session.get(s.views[0].id).unwrap().can_undo(), Some("Sanitize document"));
|
|
}
|