1494 lines
69 KiB
Rust
1494 lines
69 KiB
Rust
//! Fill & Sign in the real shell (egui_kittest): text, marks, date and a drawn signature.
|
||
|
||
use egui::{Pos2, pos2};
|
||
use egui_kittest::Harness;
|
||
use egui_kittest::kittest::Queryable;
|
||
use pdfcraft_ui_egui::fill_sign::{FillTool, SavedSig};
|
||
use pdfcraft_ui_egui::{Dialog, PdfCraftApp, QuickTool};
|
||
|
||
const FIXTURE: &[u8] = b"%PDF-1.7
|
||
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
|
||
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >> endobj
|
||
3 0 obj << /Type /Page /Parent 2 0 R >> endobj
|
||
trailer << /Root 1 0 R >>
|
||
%%EOF";
|
||
|
||
/// Tests that render pixels (`h.render()`) take this first, so only one wgpu device compiles
|
||
/// shaders at a time: WARP's ARM64 pixel-shader JIT crashes when two do (see `tests/view.rs`).
|
||
/// Declared before the harness, so it is released after the device is dropped.
|
||
static GPU: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||
|
||
fn gpu() -> std::sync::MutexGuard<'static, ()> {
|
||
// A test that panicked while holding it leaves nothing to clean up.
|
||
GPU.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
|
||
}
|
||
|
||
fn harness() -> Harness<'static, PdfCraftApp> {
|
||
harness_bytes(FIXTURE)
|
||
}
|
||
|
||
fn harness_bytes(fixture: &[u8]) -> Harness<'static, PdfCraftApp> {
|
||
let fixture = fixture.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("form.pdf", None, fixture.clone()).unwrap();
|
||
app.set_option("left", "closed").unwrap();
|
||
app.set_option("zoom", "150").unwrap();
|
||
app.set_option("author", "Ada").unwrap();
|
||
app
|
||
});
|
||
h.run_steps(5);
|
||
h
|
||
}
|
||
|
||
fn at(h: &Harness<'static, PdfCraftApp>, x: f32, y: f32) -> Pos2 {
|
||
let s = h.state();
|
||
let page = &s.session.get(s.views[0].id).unwrap().info.pages[0];
|
||
let xf = pdfcraft_ui_egui::canvas::PageXform {
|
||
rect: s.views[0].page_screen_rect(0).expect("on screen"),
|
||
rot: s.views[0].rotation,
|
||
pw: page.width,
|
||
ph: page.height,
|
||
};
|
||
let p = page.user_to_view(x, y);
|
||
xf.norm_to_screen(p[0] / xf.pw, p[1] / xf.ph)
|
||
}
|
||
|
||
/// The screen point `dx` points right and `dy` points up, as displayed, from the user-space point
|
||
/// (`x`, `y`): unlike `at`, offsets don't turn with the page's /Rotate.
|
||
fn shown(h: &Harness<'static, PdfCraftApp>, x: f32, y: f32, dx: f32, dy: f32) -> Pos2 {
|
||
let s = h.state();
|
||
let page = &s.session.get(s.views[0].id).unwrap().info.pages[0];
|
||
let xf = pdfcraft_ui_egui::canvas::PageXform {
|
||
rect: s.views[0].page_screen_rect(0).expect("on screen"),
|
||
rot: s.views[0].rotation,
|
||
pw: page.width,
|
||
ph: page.height,
|
||
};
|
||
let p = page.user_to_view(x, y);
|
||
xf.norm_to_screen((p[0] + dx) / xf.pw, (p[1] - dy) / xf.ph)
|
||
}
|
||
|
||
fn click(h: &mut Harness<'static, PdfCraftApp>, x: f32, y: f32) {
|
||
let p = at(h, x, y);
|
||
h.hover_at(p);
|
||
h.run_steps(1);
|
||
h.drag_at(p);
|
||
h.run_steps(1);
|
||
h.drop_at(p);
|
||
h.run_steps(3);
|
||
}
|
||
|
||
fn items(h: &Harness<'static, PdfCraftApp>) -> Vec<(String, Option<String>)> {
|
||
let s = h.state();
|
||
let mut v: Vec<_> = s.session.get(s.views[0].id).unwrap().info.annotations.iter().map(|a| (a.subtype.clone(), a.contents.clone())).collect();
|
||
v.sort();
|
||
v
|
||
}
|
||
|
||
/// Page rasters arrive from the render worker independently of kittest's virtual frames.
|
||
fn rendered_ink(h: &mut Harness<'static, PdfCraftApp>, x: f32, y: f32) -> image::RgbaImage {
|
||
for _ in 0..50 {
|
||
h.run_steps(2);
|
||
let p = at(h, x, y);
|
||
let pixels = h.render().unwrap();
|
||
if pixels.get_pixel(p.x as u32, p.y as u32).0[..3].iter().all(|v| *v < 60) {
|
||
return pixels;
|
||
}
|
||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||
}
|
||
panic!("signature ink did not render at ({x}, {y})");
|
||
}
|
||
|
||
#[test]
|
||
fn text_marks_and_date() {
|
||
let mut h = harness();
|
||
assert!(h.state_mut().execute("sign.fill.check"));
|
||
assert!(matches!(h.state().quick_tool, QuickTool::Fill(_)));
|
||
click(&mut h, 50.0, 350.0);
|
||
click(&mut h, 80.0, 350.0);
|
||
assert_eq!(h.state().quick_tool, QuickTool::Fill(FillTool::Check), "checkmarks stay repeatable");
|
||
h.state_mut().execute("sign.fill.date");
|
||
click(&mut h, 50.0, 300.0);
|
||
h.state_mut().execute("sign.fill.text");
|
||
click(&mut h, 50.0, 250.0);
|
||
h.event(egui::Event::Text("Ada Lovelace".into()));
|
||
h.run_steps(1);
|
||
h.key_press(egui::Key::Enter);
|
||
h.run_steps(4);
|
||
let v = items(&h);
|
||
assert_eq!(v.len(), 4, "{v:?}");
|
||
assert!(v.iter().any(|(t, c)| t == "FreeText" && c.as_deref() == Some("Ada Lovelace")));
|
||
assert!(v.iter().any(|(t, c)| t == "FreeText" && c.as_deref().is_some_and(|c| c.matches('/').count() == 2)), "a date: {v:?}");
|
||
assert!(v.iter().any(|(t, _)| t == "Stamp"));
|
||
}
|
||
|
||
/// The open type box, found as the focused text input.
|
||
fn type_box_rect(h: &Harness<'static, PdfCraftApp>) -> egui::Rect {
|
||
h.query_all_by_role(egui::accesskit::Role::TextInput).find(|n| n.is_focused()).expect("an open type box").rect()
|
||
}
|
||
|
||
#[test]
|
||
fn clicking_elsewhere_with_add_text_keeps_the_typed_text() {
|
||
let mut h = harness();
|
||
h.state_mut().execute("sign.fill.text");
|
||
click(&mut h, 50.0, 250.0);
|
||
h.event(egui::Event::Text("Ada Lovelace".into()));
|
||
h.run_steps(1);
|
||
// Still on Add text: the next click starts a new box, and must keep the first one.
|
||
click(&mut h, 50.0, 150.0);
|
||
h.run_steps(2);
|
||
let v = items(&h);
|
||
assert_eq!(v, vec![("FreeText".to_string(), Some("Ada Lovelace".to_string()))]);
|
||
assert!(h.state().views[0].fill_text.as_ref().is_some_and(|t| t.text.is_empty()), "a new, empty box is open");
|
||
}
|
||
|
||
#[test]
|
||
fn fill_text_size_controls_new_text_and_survives_settings_restore() {
|
||
let mut h = harness();
|
||
h.state_mut().execute("sign.fill.text");
|
||
h.run_steps(2);
|
||
h.get_by_label("Font size:");
|
||
h.state_mut().fill_text_size = 24.0;
|
||
click(&mut h, 50.0, 250.0);
|
||
assert_eq!(h.state().views[0].fill_text.as_ref().map(|t| t.size), Some(24.0));
|
||
h.event(egui::Event::Text("Larger text".into()));
|
||
h.run_steps(1);
|
||
h.key_press(egui::Key::Enter);
|
||
h.run_steps(3);
|
||
let annotation =
|
||
h.state().session.get(h.state().views[0].id).unwrap().info.annotations.iter().find(|a| a.contents.as_deref() == Some("Larger text")).unwrap();
|
||
assert!(annotation.rect[3] - annotation.rect[1] > 24.0, "24 pt text has a larger annotation: {annotation:?}");
|
||
let settings = h.state().persist();
|
||
let mut restored = PdfCraftApp::new();
|
||
restored.restore(&settings);
|
||
assert_eq!(restored.fill_text_size, 24.0);
|
||
restored.restore(r#"{"fill_text_size":1000000}"#);
|
||
assert_eq!(restored.fill_text_size, 24.0, "invalid settings do not change the size");
|
||
}
|
||
|
||
#[test]
|
||
fn the_type_box_grows_as_text_is_typed() {
|
||
let mut h = harness();
|
||
h.state_mut().execute("sign.fill.text");
|
||
click(&mut h, 50.0, 250.0);
|
||
h.run_steps(2);
|
||
let empty = type_box_rect(&h).width();
|
||
h.event(egui::Event::Text("Ada Lovelace, Countess of Lovelace".into()));
|
||
h.run_steps(3);
|
||
let full = type_box_rect(&h).width();
|
||
assert!(full > empty * 2.0, "the box widens with its text: {empty} -> {full}");
|
||
}
|
||
|
||
#[test]
|
||
fn signing_draws_a_signature_once_and_places_it() {
|
||
let mut h = harness();
|
||
assert!(h.state_mut().execute("sign.fill.signature"));
|
||
h.run_steps(2);
|
||
assert_eq!(h.state().dialog, Some(Dialog::Signature), "no signature yet: the pad opens");
|
||
h.get_all_by_label("Draw").last().unwrap().click();
|
||
h.run_steps(2);
|
||
let pad = h.get_by_label("Draw your signature below.").rect();
|
||
let start = pos2(pad.left() + 40.0, pad.bottom() + 70.0);
|
||
h.hover_at(start);
|
||
h.run_steps(1);
|
||
h.drag_at(start);
|
||
h.run_steps(1);
|
||
for k in 1..=8 {
|
||
h.hover_at(start + egui::vec2(k as f32 * 30.0, if k % 2 == 0 { -20.0 } else { 20.0 }));
|
||
h.run_steps(1);
|
||
}
|
||
h.drop_at(start + egui::vec2(240.0, 0.0));
|
||
h.run_steps(2);
|
||
h.get_by_label("Apply").click();
|
||
h.run_steps(3);
|
||
assert!(h.state().signature.is_some());
|
||
assert_eq!(h.state().dialog, None);
|
||
click(&mut h, 60.0, 100.0);
|
||
assert!(items(&h).iter().any(|(t, _)| t == "Ink"));
|
||
assert_eq!(h.state().quick_tool, QuickTool::Select);
|
||
assert_eq!(h.state().views[0].comments.selected, Some((0, 0)));
|
||
// The signature is remembered (persisted with the app's settings).
|
||
let saved = h.state().persist();
|
||
let mut again = PdfCraftApp::new();
|
||
again.set_option("language", "en").unwrap();
|
||
again.restore(&saved);
|
||
assert!(again.signature.is_some());
|
||
}
|
||
|
||
#[test]
|
||
fn typed_signatures_and_initials() {
|
||
let mut h = harness();
|
||
h.state_mut().comment_prefs.author = "Grace Hopper".into();
|
||
assert!(h.state_mut().execute("sign.fill.signature"));
|
||
h.run_steps(2);
|
||
// Type is the default, with the author's name filled in.
|
||
h.get_by_label("Type your signature.");
|
||
assert_eq!(h.state().signature_draft.text, "Grace Hopper");
|
||
h.get_by_label("Apply").click();
|
||
h.run_steps(3);
|
||
assert_eq!(h.state().signature, Some(pdfcraft_ui_egui::fill_sign::SavedSig::Typed("Grace Hopper".into())));
|
||
click(&mut h, 60.0, 100.0);
|
||
assert!(items(&h).iter().any(|(t, _)| t == "Stamp"), "typed signatures are filled outlines");
|
||
assert_eq!(h.state().quick_tool, QuickTool::Select);
|
||
// Initials: their own pad (GH), then placed.
|
||
assert!(h.state_mut().execute("sign.fill.initials"));
|
||
h.run_steps(2);
|
||
h.get_by_label("Create initials");
|
||
assert_eq!(h.state().signature_draft.text, "GH");
|
||
h.get_by_label("Apply").click();
|
||
h.run_steps(3);
|
||
click(&mut h, 60.0, 160.0);
|
||
assert_eq!(items(&h).iter().filter(|(t, _)| t == "Stamp").count(), 2);
|
||
// Both are remembered.
|
||
let saved = h.state().persist();
|
||
let mut again = PdfCraftApp::new();
|
||
again.set_option("language", "en").unwrap();
|
||
again.restore(&saved);
|
||
assert_eq!(again.signature, h.state().signature);
|
||
assert_eq!(again.initials, Some(pdfcraft_ui_egui::fill_sign::SavedSig::Typed("GH".into())));
|
||
}
|
||
|
||
#[test]
|
||
fn changing_saved_signatures_and_initials_preserves_placed_marks() {
|
||
let mut h = harness();
|
||
h.state_mut().signature = Some(SavedSig::Typed("Ada Lovelace".into()));
|
||
h.state_mut().initials = Some(SavedSig::Typed("AL".into()));
|
||
h.state_mut().execute("sign.fill.signature");
|
||
h.run_steps(2);
|
||
assert_eq!(h.state().dialog, None, "Sign still reuses the saved signature");
|
||
click(&mut h, 40.0, 300.0);
|
||
let original = format!("{:?}", h.state().session.get(h.state().views[0].id).unwrap().info.annotations);
|
||
|
||
// The quick toolbar exposes replacement without changing the normal placement action.
|
||
h.get_by_label("Fill & Sign").click();
|
||
h.run_steps(2);
|
||
h.get_by_label("Fill & Sign").click();
|
||
h.run_steps(2);
|
||
h.get_by_label("Change signature").click();
|
||
h.run_steps(2);
|
||
assert_eq!(h.state().dialog, Some(Dialog::Signature));
|
||
assert_eq!(h.state().signature_draft.text, "Ada Lovelace");
|
||
h.get_by_label("Type your signature.").click();
|
||
h.run_steps(1);
|
||
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A);
|
||
h.event(egui::Event::Text("Grace Hopper".into()));
|
||
h.run_steps(2);
|
||
h.get_by_label("Apply").click();
|
||
h.run_steps(3);
|
||
assert_eq!(h.state().signature, Some(SavedSig::Typed("Grace Hopper".into())));
|
||
assert_eq!(h.state().initials, Some(SavedSig::Typed("AL".into())));
|
||
assert_eq!(h.state().quick_tool, QuickTool::Fill(FillTool::Signature));
|
||
assert_eq!(format!("{:?}", h.state().session.get(h.state().views[0].id).unwrap().info.annotations), original);
|
||
click(&mut h, 40.0, 200.0);
|
||
assert_eq!(items(&h).len(), 2);
|
||
|
||
h.get_by_label("Fill & Sign").click();
|
||
h.run_steps(2);
|
||
h.get_by_label("Fill & Sign").click();
|
||
h.run_steps(2);
|
||
h.get_by_label("Change initials").click();
|
||
h.run_steps(2);
|
||
assert!(h.state().signature_draft.initials);
|
||
assert_eq!(h.state().signature_draft.text, "AL");
|
||
h.state_mut().signature_draft.text = "GH".into();
|
||
h.get_by_label("Apply").click();
|
||
h.run_steps(3);
|
||
assert_eq!(h.state().quick_tool, QuickTool::Fill(FillTool::Initials));
|
||
click(&mut h, 40.0, 100.0);
|
||
assert_eq!(items(&h).len(), 3);
|
||
let mut again = PdfCraftApp::new();
|
||
again.set_option("language", "en").unwrap();
|
||
again.restore(&h.state().persist());
|
||
assert_eq!(again.signature, Some(SavedSig::Typed("Grace Hopper".into())));
|
||
assert_eq!(again.initials, Some(SavedSig::Typed("GH".into())));
|
||
}
|
||
|
||
#[test]
|
||
fn changing_drawn_signatures_can_be_cancelled_or_switched_to_type() {
|
||
let mut h = harness();
|
||
let drawn = SavedSig::Drawn(vec![vec![[0.1, 0.1], [0.5, 0.2], [0.9, 0.1]]]);
|
||
h.state_mut().signature = Some(drawn.clone());
|
||
h.state_mut().initials = Some(SavedSig::Typed("AL".into()));
|
||
h.state_mut().quick_tool = QuickTool::Fill(FillTool::Check);
|
||
assert!(h.state_mut().execute("sign.fill.signature.change"));
|
||
h.run_steps(2);
|
||
assert!(h.state().signature_draft.drawing);
|
||
assert_eq!(h.state().signature_draft.saved(), Some(drawn.clone()));
|
||
h.get_by_label("Clear").click();
|
||
h.run_steps(2);
|
||
assert!(h.state().signature_draft.strokes.is_empty());
|
||
h.get_by_label("Cancel").click();
|
||
h.run_steps(3);
|
||
assert_eq!(h.state().signature, Some(drawn.clone()));
|
||
assert_eq!(h.state().quick_tool, QuickTool::Fill(FillTool::Check));
|
||
|
||
assert!(h.state_mut().execute("sign.fill.signature.change"));
|
||
h.run_steps(2);
|
||
assert_eq!(h.state().signature_draft.saved(), Some(drawn.clone()));
|
||
h.get_all_by_label("Type").last().unwrap().click();
|
||
h.run_steps(2);
|
||
h.state_mut().signature_draft.text = "Grace Hopper".into();
|
||
h.get_by_label("Apply").click();
|
||
h.run_steps(3);
|
||
assert_eq!(h.state().signature, Some(SavedSig::Typed("Grace Hopper".into())));
|
||
assert_eq!(h.state().initials, Some(SavedSig::Typed("AL".into())));
|
||
}
|
||
|
||
#[test]
|
||
fn saved_signature_cards_remove_and_add_without_changing_the_document() {
|
||
let mut h = harness();
|
||
h.state_mut().signature = Some(SavedSig::Typed("Ada Lovelace".into()));
|
||
h.state_mut().initials = Some(SavedSig::Typed("AL".into()));
|
||
h.state_mut().execute("sign.fill.signature");
|
||
click(&mut h, 40.0, 300.0);
|
||
let original = format!("{:?}", h.state().session.get(h.state().views[0].id).unwrap().info.annotations);
|
||
h.state_mut().left = pdfcraft_ui_egui::LeftPanel::Tool("fill_sign");
|
||
h.state_mut().left_open = true;
|
||
h.run_steps(3);
|
||
h.get_by_label("Use signature").click();
|
||
h.run_steps(2);
|
||
assert_eq!(h.state().dialog, None, "the preview reuses the saved value");
|
||
h.get_by_label("Remove saved signature").click();
|
||
h.run_steps(3);
|
||
assert_eq!(h.state().signature, None);
|
||
assert_eq!(h.state().initials, Some(SavedSig::Typed("AL".into())));
|
||
let mut again = PdfCraftApp::new();
|
||
again.set_option("language", "en").unwrap();
|
||
again.restore(&h.state().persist());
|
||
assert_eq!(again.signature, None, "removal survives restart");
|
||
h.get_by_label("Add signature").click();
|
||
h.run_steps(3);
|
||
h.state_mut().signature_draft.text = "Grace Hopper".into();
|
||
h.get_by_label("Apply").click();
|
||
h.run_steps(3);
|
||
assert_eq!(h.state().signature, Some(SavedSig::Typed("Grace Hopper".into())));
|
||
h.get_by_label("Remove saved initials").click();
|
||
h.run_steps(3);
|
||
assert_eq!(h.state().initials, None);
|
||
h.get_by_label("Add initials").click();
|
||
h.run_steps(3);
|
||
h.get_by_label("Cancel").click();
|
||
h.run_steps(3);
|
||
assert_eq!(h.state().initials, None);
|
||
assert_eq!(format!("{:?}", h.state().session.get(h.state().views[0].id).unwrap().info.annotations), original);
|
||
}
|
||
|
||
#[test]
|
||
fn long_typed_names_fit_the_placed_signature_and_keep_every_outline() {
|
||
let text = "Alexandria Catherine Elizabeth Montgomery-Wellington";
|
||
let sig = SavedSig::Typed(text.into());
|
||
let page = pdfcraft_render::PageInfo { width: 300.0, height: 400.0, label: String::new(), crop: [0.0, 0.0, 300.0, 400.0], rotation: 0 };
|
||
let pdfcraft_engine::Edit::AddAnnotation(a) = pdfcraft_ui_egui::fill_sign::place(0, &page, [40.0, 200.0], &sig, false, "").unwrap() else {
|
||
panic!("expected annotation");
|
||
};
|
||
let pdfcraft_engine::Shape::TypedSignature { rect, contours } = a.shape else { panic!("expected typed signature") };
|
||
assert!((rect[2] - rect[0] - 150.0).abs() < 0.001, "long names shrink to fit: {rect:?}");
|
||
assert_eq!(contours.len(), pdfcraft_engine::script_outline(text).contours.len());
|
||
assert!(contours.iter().flatten().all(|p| p.iter().all(|v| (0.0..=1.0).contains(v))), "all ink stays inside the appearance bounds");
|
||
let mut h = harness();
|
||
h.state_mut().signature = Some(sig.clone());
|
||
h.state_mut().execute("sign.fill.signature.change");
|
||
h.run_steps(3);
|
||
assert_eq!(h.state().signature_draft.text, text);
|
||
h.get_by_label("Apply").click();
|
||
h.run_steps(3);
|
||
assert_eq!(h.state().signature, Some(sig));
|
||
click(&mut h, 40.0, 200.0);
|
||
let s = h.state();
|
||
let a = &s.session.get(s.views[0].id).unwrap().info.annotations[0];
|
||
assert!(a.rect[2] <= 190.001, "the full name fits on the page: {:?}", a.rect);
|
||
}
|
||
|
||
fn signature_file(test: &str) -> std::path::PathBuf {
|
||
signature_file_with_mark(test, 10..20)
|
||
}
|
||
|
||
/// A 120 x 40 signature: a stroke across the middle and a block above it at the columns `mark`
|
||
/// (the rest is transparent), so a turned or flipped picture shows.
|
||
fn signature_file_with_mark(test: &str, mark: std::ops::Range<u32>) -> std::path::PathBuf {
|
||
let path = std::env::temp_dir().join(format!("pdfcraft-signature-{test}-{}.png", std::process::id()));
|
||
let image = image::RgbaImage::from_fn(120, 40, |x, y| {
|
||
if (10..110).contains(&x) && (15..25).contains(&y) || mark.contains(&x) && (5..15).contains(&y) {
|
||
image::Rgba([20, 30, 40, 255])
|
||
} else {
|
||
image::Rgba([0, 0, 0, 0])
|
||
}
|
||
});
|
||
image.save(&path).unwrap();
|
||
path
|
||
}
|
||
|
||
fn browse_image(h: &mut Harness<'static, PdfCraftApp>, path: &std::path::Path) {
|
||
h.state_mut().pick_override = Some(vec![path.to_string_lossy().into_owned()]);
|
||
h.get_by_label("Browse…").click();
|
||
h.run_steps(3);
|
||
}
|
||
|
||
#[test]
|
||
fn image_signatures_and_initials_can_be_imported_placed_and_remembered() {
|
||
let _gpu = gpu();
|
||
let path = signature_file("import");
|
||
let mut h = harness();
|
||
for (command, y, initials) in [("sign.fill.signature", 250.0, false), ("sign.fill.initials", 150.0, true)] {
|
||
h.state_mut().execute(command);
|
||
h.run_steps(2);
|
||
h.get_by_label("Image").click();
|
||
h.run_steps(2);
|
||
assert!(h.state().signature_draft.saved().is_none(), "Apply needs an image");
|
||
browse_image(&mut h, &path);
|
||
assert!(h.state().signature_draft.image.is_some());
|
||
if !initials && let Ok(dir) = std::env::var("PDFCRAFT_SHOTS") {
|
||
h.render().unwrap().save(format!("{dir}/image-signature-dialog.png")).unwrap();
|
||
}
|
||
assert_eq!(items(&h).len(), usize::from(initials), "importing doesn't edit the PDF");
|
||
h.get_by_label("Apply").click();
|
||
h.run_steps(3);
|
||
click(&mut h, 40.0, y);
|
||
assert_eq!(h.state().quick_tool, QuickTool::Select);
|
||
assert_eq!(h.state().views[0].comments.selected, Some((0, usize::from(initials))));
|
||
}
|
||
assert_eq!(items(&h).len(), 2);
|
||
let state = h.state();
|
||
let doc = state.session.get(state.views[0].id).unwrap();
|
||
for a in &doc.info.annotations {
|
||
assert_eq!(a.subtype, "Stamp");
|
||
assert!(((a.rect[2] - a.rect[0]) / (a.rect[3] - a.rect[1]) - 3.0).abs() < 0.001);
|
||
assert!(a.rect[2] - a.rect[0] <= 150.0);
|
||
}
|
||
assert_eq!(doc.can_undo(), Some("Add initials"));
|
||
let settings = state.persist();
|
||
assert!(!settings.contains(&path.to_string_lossy().to_string()), "only the image is saved, never its source path");
|
||
let mut again = PdfCraftApp::new();
|
||
again.set_option("language", "en").unwrap();
|
||
again.restore(&settings);
|
||
assert_eq!(again.signature, state.signature);
|
||
assert_eq!(again.initials, state.initials);
|
||
let saved_image = state.signature.clone();
|
||
h.state_mut().execute("sign.fill.signature.change");
|
||
h.run_steps(2);
|
||
assert!(h.state().signature_draft.image_mode);
|
||
assert!(h.state().signature_draft.image.is_some());
|
||
h.get_by_label("Clear").click();
|
||
h.run_steps(2);
|
||
h.get_by_label("Cancel").click();
|
||
h.run_steps(3);
|
||
assert_eq!(h.state().signature, saved_image);
|
||
h.state_mut().execute("edit.undo");
|
||
h.run_steps(3);
|
||
assert_eq!(items(&h).len(), 1);
|
||
h.state_mut().execute("edit.redo");
|
||
h.run_steps(3);
|
||
assert_eq!(items(&h).len(), 2);
|
||
// Show the saved preview cards in the actual shell as well as the dialog.
|
||
h.state_mut().set_option("tool", "fill_sign").unwrap();
|
||
h.run_steps(3);
|
||
h.get_by_label("Use signature");
|
||
h.get_by_label("Use initials");
|
||
if let Ok(dir) = std::env::var("PDFCRAFT_SHOTS") {
|
||
h.render().unwrap().save(format!("{dir}/image-signatures.png")).unwrap();
|
||
}
|
||
std::fs::remove_file(path).unwrap();
|
||
}
|
||
|
||
#[test]
|
||
fn image_signature_preview_follows_pointer_at_page_size_with_zoom_and_rotation() {
|
||
let _gpu = gpu();
|
||
// The block above the stroke is at its right end, away from the pointer's own cursor.
|
||
let path = signature_file_with_mark("pointer", 100..110);
|
||
let image = pdfcraft_engine::SignatureImage::read(std::fs::File::open(&path).unwrap()).unwrap();
|
||
let mut h = harness();
|
||
// A link under the placement point must not replace the image with a hand cursor.
|
||
h.state_mut().apply_edit(pdfcraft_engine::Edit::AddLink {
|
||
page: 0,
|
||
rect: [30.0, 230.0, 60.0, 270.0],
|
||
action: pdfcraft_engine::LinkAction::Page(0),
|
||
style: pdfcraft_engine::LinkStyle::default(),
|
||
});
|
||
h.state_mut().signature = Some(SavedSig::Image(image));
|
||
h.state_mut().execute("sign.fill.signature");
|
||
// Exercise document rotation and view rotation separately and together. The image is upright
|
||
// as displayed, whatever the page's /Rotate; the view rotation turns the whole screen.
|
||
let mut previous_document_rotation = 0;
|
||
for (document_rotation, view_rotation, zoom) in [(0, 0, "150"), (90, 0, "100"), (0, 90, "100"), (90, 90, "75"), (0, 180, "75"), (0, 270, "100")] {
|
||
let delta = document_rotation - previous_document_rotation;
|
||
if delta != 0 {
|
||
assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::RotatePages { pages: vec![0], degrees: delta }));
|
||
}
|
||
previous_document_rotation = document_rotation;
|
||
while h.state().views[0].rotation != view_rotation {
|
||
h.state_mut().views[0].rotate_view(true);
|
||
}
|
||
h.state_mut().set_option("zoom", zoom).unwrap();
|
||
h.run_steps(3);
|
||
for y in [250.0, 150.0] {
|
||
h.hover_at(at(&h, 40.0, y));
|
||
h.run_steps(2);
|
||
assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::None, "the image replaces the crosshair");
|
||
// The picture is 96 x 32 pt with its left edge at the pointer: the stroke spans 8..88
|
||
// and the block above it 80..88. egui_kittest paints a mouse cursor, a 16 px triangle
|
||
// down and right of the pointer, over the render, so only probe far from the pointer.
|
||
let ink = shown(&h, 40.0, y, 50.0, 0.0);
|
||
let margin = shown(&h, 40.0, y, 92.0, 0.0);
|
||
let upper_stroke = shown(&h, 40.0, y, 84.0, 8.0);
|
||
let lower_margin = shown(&h, 40.0, y, 84.0, -8.0);
|
||
let pixels = h.render().unwrap();
|
||
assert!(pixels.get_pixel(ink.x as u32, ink.y as u32).0[..3].iter().all(|v| *v < 60), "ink follows the pointer at the placed size");
|
||
assert!(pixels.get_pixel(margin.x as u32, margin.y as u32).0[..3].iter().all(|v| *v > 240), "alpha exposes the page");
|
||
assert!(
|
||
pixels.get_pixel(upper_stroke.x as u32, upper_stroke.y as u32).0[..3].iter().all(|v| *v < 60),
|
||
"the asymmetric image stays upright as displayed"
|
||
);
|
||
assert!(pixels.get_pixel(lower_margin.x as u32, lower_margin.y as u32).0[..3].iter().all(|v| *v > 240), "the image isn't flipped");
|
||
if y == 150.0 {
|
||
let old = shown(&h, 40.0, 250.0, 50.0, 0.0);
|
||
assert!(pixels.get_pixel(old.x as u32, old.y as u32).0[..3].iter().all(|v| *v > 240), "moving the pointer removes the old preview");
|
||
}
|
||
if document_rotation == 0
|
||
&& view_rotation == 0
|
||
&& y == 250.0
|
||
&& let Ok(dir) = std::env::var("PDFCRAFT_SHOTS")
|
||
{
|
||
pixels.save(format!("{dir}/image-signature-pointer.png")).unwrap();
|
||
}
|
||
}
|
||
assert!(items(&h).is_empty(), "hovering never adds a signature");
|
||
assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().info.links.len(), 1, "the original link remains");
|
||
}
|
||
h.hover_at(pos2(5.0, 5.0));
|
||
h.run_steps(2);
|
||
assert_ne!(h.output().platform_output.cursor_icon, egui::CursorIcon::None, "restore the pointer off the page");
|
||
std::fs::remove_file(path).unwrap();
|
||
}
|
||
|
||
#[test]
|
||
fn image_signature_placement_selects_resize_handles_and_requires_reselecting_to_repeat() {
|
||
let _gpu = gpu();
|
||
let path = signature_file("resize");
|
||
let image = pdfcraft_engine::SignatureImage::read(std::fs::File::open(&path).unwrap()).unwrap();
|
||
let mut h = harness();
|
||
h.state_mut().signature = Some(SavedSig::Image(image.clone()));
|
||
h.state_mut().initials = Some(SavedSig::Image(image));
|
||
h.state_mut().comment_prefs.pinned = true;
|
||
h.state_mut().execute("sign.fill.signature");
|
||
click(&mut h, 40.0, 250.0);
|
||
assert_eq!(h.state().quick_tool, QuickTool::Select, "signatures place once even when comment tools are pinned");
|
||
assert_eq!(h.state().views[0].comments.selected, Some((0, 0)));
|
||
let placed = rendered_ink(&mut h, 90.0, 250.0);
|
||
if let Ok(dir) = std::env::var("PDFCRAFT_SHOTS") {
|
||
placed.save(format!("{dir}/image-signature-selected.png")).unwrap();
|
||
}
|
||
// The bottom-right handle is ready without another selection click.
|
||
let corner = at(&h, 136.0, 234.0);
|
||
h.hover_at(corner);
|
||
h.run_steps(2);
|
||
assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::ResizeNwSe);
|
||
h.drag_at(corner);
|
||
h.run_steps(1);
|
||
let end = corner + egui::vec2(96.0, 32.0);
|
||
h.hover_at(end);
|
||
h.run_steps(2);
|
||
h.drop_at(end);
|
||
h.run_steps(3);
|
||
let doc = h.state().session.get(h.state().views[0].id).unwrap();
|
||
assert!(doc.info.annotations[0].rect.iter().zip([40.0, 218.0, 184.0, 266.0]).all(|(a, b)| (*a - b).abs() < 0.001));
|
||
assert_eq!(doc.can_undo(), Some("Resize comment"));
|
||
rendered_ink(&mut h, 160.0, 242.0);
|
||
h.state_mut().undo();
|
||
h.run_steps(2);
|
||
assert!(
|
||
h.state().session.get(h.state().views[0].id).unwrap().info.annotations[0]
|
||
.rect
|
||
.iter()
|
||
.zip([40.0, 234.0, 136.0, 266.0])
|
||
.all(|(a, b)| (*a - b).abs() < 0.001)
|
||
);
|
||
h.state_mut().redo();
|
||
h.run_steps(2);
|
||
click(&mut h, 40.0, 150.0);
|
||
assert_eq!(items(&h).len(), 1, "a later page click doesn't add another signature");
|
||
h.state_mut().execute("sign.fill.signature");
|
||
click(&mut h, 40.0, 150.0);
|
||
assert_eq!(items(&h).len(), 2, "selecting the saved signature again places another");
|
||
h.state_mut().execute("sign.fill.initials");
|
||
click(&mut h, 40.0, 100.0);
|
||
assert_eq!(h.state().quick_tool, QuickTool::Select);
|
||
assert_eq!(h.state().views[0].comments.selected, Some((0, 2)));
|
||
std::fs::remove_file(path).unwrap();
|
||
}
|
||
|
||
fn assert_rect(actual: [f32; 4], expected: [f32; 4]) {
|
||
assert!(actual.iter().zip(expected).all(|(a, b)| (*a - b).abs() < 0.001), "{actual:?} != {expected:?}");
|
||
}
|
||
|
||
fn pixel_at(h: &Harness<'static, PdfCraftApp>, pixels: &image::RgbaImage, x: f32, y: f32) -> [u8; 4] {
|
||
let p = at(h, x, y);
|
||
pixels.get_pixel(p.x.round() as u32, p.y.round() as u32).0
|
||
}
|
||
|
||
fn has_blue_near(pixels: &image::RgbaImage, p: Pos2) -> bool {
|
||
(-5..=5).any(|dy| {
|
||
(-5..=5).any(|dx| {
|
||
let v = pixels.get_pixel((p.x as i32 + dx) as u32, (p.y as i32 + dy) as u32).0;
|
||
i32::from(v[2]) > i32::from(v[0]) + 40 && i32::from(v[2]) > i32::from(v[1]) + 35
|
||
})
|
||
})
|
||
}
|
||
|
||
#[test]
|
||
fn image_signature_live_resize_and_move_preserve_background_and_hide_moving_handles() {
|
||
let _gpu = gpu();
|
||
// Original synthetic page content; a white patch over the old image would fail this test.
|
||
const GREEN_PAGE: &[u8] = b"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /Contents 4 0 R >> endobj\n4 0 obj << /Length 36 >> stream\n0.8 1 0.7 rg 0 0 300 400 re f\nendstream\nendobj\ntrailer << /Root 1 0 R >>\n%%EOF";
|
||
let mut h = harness_bytes(GREEN_PAGE);
|
||
let path = signature_file("live");
|
||
let image = pdfcraft_engine::SignatureImage::read(std::fs::File::open(&path).unwrap()).unwrap();
|
||
h.state_mut().signature = Some(SavedSig::Image(image));
|
||
h.state_mut().execute("sign.fill.signature");
|
||
click(&mut h, 40.0, 250.0);
|
||
rendered_ink(&mut h, 90.0, 250.0);
|
||
let generation = h.state().session.get(h.state().views[0].id).unwrap().edit_generation();
|
||
let corner = at(&h, 136.0, 234.0);
|
||
h.drag_at(corner);
|
||
h.run_steps(1);
|
||
let end = at(&h, 184.0, 228.0);
|
||
h.hover_at(end);
|
||
// Wait only for the one-time background preparation. Later pointer frames are immediate.
|
||
rendered_ink(&mut h, 160.0, 242.0);
|
||
let end = at(&h, 220.0, 236.0);
|
||
h.hover_at(end);
|
||
h.run_steps(2);
|
||
let resized = h.render().unwrap();
|
||
assert!(pixel_at(&h, &resized, 200.0, 236.0)[..3].iter().all(|v| *v < 60), "ink resizes before release");
|
||
let assert_green = |v: [u8; 4]| {
|
||
assert!(v[1] > 230 && i32::from(v[1]) > i32::from(v[0]) + 10 && i32::from(v[1]) > i32::from(v[2]) + 10, "page content is preserved: {v:?}")
|
||
};
|
||
assert_green(pixel_at(&h, &resized, 90.0, 250.0));
|
||
assert!(has_blue_near(&resized, at(&h, 220.0, 266.0)), "resizing keeps its handles visible");
|
||
let doc = h.state().session.get(h.state().views[0].id).unwrap();
|
||
assert_eq!(doc.edit_generation(), generation, "pointer frames never edit the document");
|
||
assert_eq!(doc.can_undo(), Some("Add signature"));
|
||
assert_rect(doc.info.annotations[0].rect, [40.0, 234.0, 136.0, 266.0]);
|
||
if let Ok(dir) = std::env::var("PDFCRAFT_SHOTS") {
|
||
resized.save(format!("{dir}/image-signature-live-resize.png")).unwrap();
|
||
}
|
||
h.drop_at(end);
|
||
h.run_steps(3);
|
||
assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo(), Some("Resize comment"));
|
||
|
||
let start = at(&h, 130.0, 236.0);
|
||
h.drag_at(start);
|
||
h.run_steps(1);
|
||
for (x, y) in [(140.0, 180.0), (150.0, 140.0)] {
|
||
h.hover_at(at(&h, x, y));
|
||
h.run_steps(2);
|
||
let moving = h.render().unwrap();
|
||
assert!(pixel_at(&h, &moving, x, y)[..3].iter().all(|v| *v < 60), "ink follows each pointer position");
|
||
assert_green(pixel_at(&h, &moving, 130.0, 236.0));
|
||
assert_green(pixel_at(&h, &moving, x - 85.0, y));
|
||
for (hx, hy) in [
|
||
(x - 90.0, y + 30.0),
|
||
(x, y + 30.0),
|
||
(x + 90.0, y + 30.0),
|
||
(x + 90.0, y),
|
||
(x + 90.0, y - 30.0),
|
||
(x, y - 30.0),
|
||
(x - 90.0, y - 30.0),
|
||
(x - 90.0, y),
|
||
] {
|
||
assert!(!has_blue_near(&moving, at(&h, hx, hy)), "moving hides the frame and all eight handles");
|
||
}
|
||
assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo(), Some("Resize comment"));
|
||
if y == 140.0
|
||
&& let Ok(dir) = std::env::var("PDFCRAFT_SHOTS")
|
||
{
|
||
moving.save(format!("{dir}/image-signature-live-move.png")).unwrap();
|
||
}
|
||
}
|
||
let end = at(&h, 150.0, 140.0);
|
||
h.drop_at(end);
|
||
h.run_steps(2);
|
||
let released = h.render().unwrap();
|
||
assert!(has_blue_near(&released, at(&h, 240.0, 170.0)), "the frame returns immediately on release");
|
||
let doc = h.state().session.get(h.state().views[0].id).unwrap();
|
||
assert_eq!(doc.can_undo(), Some("Move comment"));
|
||
assert_eq!(doc.edit_generation(), generation + 2, "one edit for each completed gesture");
|
||
assert_rect(doc.info.annotations[0].rect, [60.0, 110.0, 240.0, 170.0]);
|
||
assert_eq!(h.state().views[0].comments.selected, Some((0, 0)));
|
||
if let Ok(dir) = std::env::var("PDFCRAFT_SHOTS") {
|
||
released.save(format!("{dir}/image-signature-move-released.png")).unwrap();
|
||
}
|
||
h.state_mut().undo();
|
||
h.state_mut().undo();
|
||
h.run_steps(3);
|
||
assert_rect(h.state().session.get(h.state().views[0].id).unwrap().info.annotations[0].rect, [40.0, 234.0, 136.0, 266.0]);
|
||
rendered_ink(&mut h, 90.0, 250.0);
|
||
click(&mut h, 90.0, 250.0);
|
||
h.drag_at(at(&h, 90.0, 250.0));
|
||
h.run_steps(1);
|
||
h.hover_at(at(&h, 150.0, 180.0));
|
||
rendered_ink(&mut h, 150.0, 180.0);
|
||
assert_eq!(
|
||
h.state().session.get(h.state().views[0].id).unwrap().can_undo(),
|
||
Some("Add signature"),
|
||
"moving preview is still read-only before Escape"
|
||
);
|
||
h.key_press(egui::Key::Escape);
|
||
h.run_steps(2);
|
||
assert!(h.state().views[0].comments.gesture.is_none(), "Escape must cancel before releasing the mouse");
|
||
assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo(), Some("Add signature"), "Escape must not commit");
|
||
h.drop_at(at(&h, 150.0, 180.0));
|
||
h.run_steps(2);
|
||
assert!(h.state().views[0].comments.gesture.is_none());
|
||
let doc = h.state().session.get(h.state().views[0].id).unwrap();
|
||
assert_eq!(doc.can_undo(), Some("Add signature"), "Escape cancels without an undo step");
|
||
assert_rect(doc.info.annotations[0].rect, [40.0, 234.0, 136.0, 266.0]);
|
||
let cancelled = h.render().unwrap();
|
||
assert_green(pixel_at(&h, &cancelled, 150.0, 180.0));
|
||
std::fs::remove_file(path).unwrap();
|
||
}
|
||
|
||
#[test]
|
||
fn embedded_image_signature_live_gestures_follow_document_and_view_rotation() {
|
||
let _gpu = gpu();
|
||
let path = signature_file("live-rotations");
|
||
let image = pdfcraft_engine::SignatureImage::read(std::fs::File::open(&path).unwrap()).unwrap();
|
||
for (document_rotation, view_rotation) in [(90, 0), (0, 90), (90, 270)] {
|
||
let mut h = harness();
|
||
h.state_mut().apply_edit(pdfcraft_engine::Edit::RotatePages { pages: vec![0], degrees: document_rotation });
|
||
while h.state().views[0].rotation != view_rotation {
|
||
h.state_mut().views[0].rotate_view(true);
|
||
}
|
||
h.state_mut().set_option("zoom", "100").unwrap();
|
||
h.state_mut().signature = Some(SavedSig::Image(image.clone()));
|
||
h.state_mut().execute("sign.fill.signature");
|
||
h.run_steps(3);
|
||
// Points as displayed (from the shown page's bottom-left, y up). The signature is upright
|
||
// as displayed, so the same points apply whatever the page's /Rotate.
|
||
let page = h.state().session.get(h.state().views[0].id).unwrap().info.pages[0].clone();
|
||
let u = |x: f32, y: f32| page.view_to_user(x, page.height - y);
|
||
let [x, y] = u(40.0, 250.0);
|
||
click(&mut h, x, y);
|
||
let [x, y] = u(90.0, 250.0);
|
||
rendered_ink(&mut h, x, y);
|
||
// Future placements may use a different signature: drag the PDF's embedded image.
|
||
h.state_mut().signature = None;
|
||
let [x, y] = u(136.0, 234.0);
|
||
h.drag_at(at(&h, x, y));
|
||
h.run_steps(1);
|
||
let [x, y] = u(184.0, 228.0);
|
||
let end = at(&h, x, y);
|
||
h.hover_at(end);
|
||
let [x, y] = u(160.0, 242.0);
|
||
rendered_ink(&mut h, x, y);
|
||
let pixels = h.render().unwrap();
|
||
let [x, y] = u(58.0, 254.0);
|
||
assert!(pixel_at(&h, &pixels, x, y)[..3].iter().all(|v| *v < 60), "asymmetric ink stays upright during resize");
|
||
let [x, y] = u(58.0, 230.0);
|
||
assert!(pixel_at(&h, &pixels, x, y)[..3].iter().all(|v| *v > 240), "alpha isn't flipped");
|
||
h.drop_at(end);
|
||
h.run_steps(3);
|
||
// The corner keeps the image's 3:1 as displayed: 144 x 48 from the top-left corner.
|
||
let ([x0, y0], [x1, y1]) = (u(40.0, 218.0), u(184.0, 266.0));
|
||
let rect = h.state().session.get(h.state().views[0].id).unwrap().info.annotations[0].rect;
|
||
let want = [x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1)];
|
||
assert!(rect.iter().zip(want).all(|(a, b)| (a - b).abs() < 0.01), "{rect:?} != {want:?}");
|
||
let [x, y] = u(112.0, 242.0);
|
||
h.drag_at(at(&h, x, y));
|
||
h.run_steps(1);
|
||
let [x, y] = u(132.0, 142.0);
|
||
let target = at(&h, x, y);
|
||
h.hover_at(target);
|
||
h.run_steps(2);
|
||
let pixels = h.render().unwrap();
|
||
assert!(pixel_at(&h, &pixels, x, y)[..3].iter().all(|v| *v < 60), "image moves before release");
|
||
let [x, y] = u(160.0, 242.0);
|
||
assert!(pixel_at(&h, &pixels, x, y)[..3].iter().all(|v| *v > 240), "old ink is removed during movement");
|
||
let [x, y] = u(204.0, 166.0);
|
||
let corner = at(&h, x, y);
|
||
assert!(!has_blue_near(&pixels, corner));
|
||
h.drop_at(target);
|
||
h.run_steps(2);
|
||
assert!(has_blue_near(&h.render().unwrap(), corner));
|
||
}
|
||
std::fs::remove_file(path).unwrap();
|
||
}
|
||
|
||
#[test]
|
||
fn image_signature_corners_restore_original_aspect_after_edge_resize_and_reopen() {
|
||
let _gpu = gpu();
|
||
let path = signature_file("aspect");
|
||
let image = pdfcraft_engine::SignatureImage::read(std::fs::File::open(&path).unwrap()).unwrap();
|
||
let mut h = harness();
|
||
h.state_mut().signature = Some(SavedSig::Image(image));
|
||
h.state_mut().execute("sign.fill.signature");
|
||
click(&mut h, 70.0, 210.0);
|
||
// Side handles stretch just their own axis; pointer movement along the other axis is ignored.
|
||
for (from, to, expected) in
|
||
[([166.0, 210.0], [214.0, 216.0], [70.0, 194.0, 214.0, 226.0]), ([142.0, 194.0], [150.0, 170.0], [70.0, 170.0, 214.0, 226.0])]
|
||
{
|
||
h.drag_at(at(&h, from[0], from[1]));
|
||
h.run_steps(1);
|
||
let end = at(&h, to[0], to[1]);
|
||
h.hover_at(end);
|
||
h.run_steps(2);
|
||
h.drop_at(end);
|
||
h.run_steps(3);
|
||
assert_rect(h.state().session.get(h.state().views[0].id).unwrap().info.annotations[0].rect, expected);
|
||
}
|
||
let bytes = h.state().session.get(h.state().views[0].id).unwrap().bytes.clone();
|
||
// Reopened PDFs have no saved signature setting: each corner must use the embedded 3:1 image,
|
||
// rather than the stretched 144:56 annotation rectangle.
|
||
for (hx, hy) in [(-1.0, -1.0), (1.0, -1.0), (-1.0, 1.0), (1.0, 1.0)] {
|
||
let mut h = harness_bytes(&bytes);
|
||
assert!(h.state().signature.is_none());
|
||
h.state_mut().views[0].comments.selected = Some((0, 0));
|
||
h.run_steps(3);
|
||
rendered_ink(&mut h, 142.0, 198.0);
|
||
let generation = h.state().session.get(h.state().views[0].id).unwrap().edit_generation();
|
||
let (x, y) = (if hx < 0.0 { 70.0 } else { 214.0 }, if hy < 0.0 { 170.0 } else { 226.0 });
|
||
h.drag_at(at(&h, x, y));
|
||
h.run_steps(1);
|
||
let end = at(&h, x + hx * 10.0, y + hy * 10.0);
|
||
h.hover_at(end);
|
||
let expected = [
|
||
if hx < 0.0 { 16.0 } else { 70.0 },
|
||
if hy < 0.0 { 160.0 } else { 170.0 },
|
||
if hx < 0.0 { 214.0 } else { 268.0 },
|
||
if hy < 0.0 { 226.0 } else { 236.0 },
|
||
];
|
||
let ink_x = expected[0] + 198.0 * if hx < 0.0 { 0.15 } else { 0.85 };
|
||
let pixels = rendered_ink(&mut h, ink_x, (expected[1] + expected[3]) / 2.0);
|
||
assert!(has_blue_near(&pixels, at(&h, if hx < 0.0 { expected[0] } else { expected[2] }, if hy < 0.0 { expected[1] } else { expected[3] })));
|
||
let doc = h.state().session.get(h.state().views[0].id).unwrap();
|
||
assert_eq!(doc.edit_generation(), generation, "corner previews remain read-only");
|
||
assert_rect(doc.info.annotations[0].rect, [70.0, 170.0, 214.0, 226.0]);
|
||
if hx > 0.0
|
||
&& hy < 0.0
|
||
&& let Ok(dir) = std::env::var("PDFCRAFT_SHOTS")
|
||
{
|
||
pixels.save(format!("{dir}/image-signature-aspect-corner.png")).unwrap();
|
||
}
|
||
h.drop_at(end);
|
||
h.run_steps(3);
|
||
let doc = h.state().session.get(h.state().views[0].id).unwrap();
|
||
assert_rect(doc.info.annotations[0].rect, expected);
|
||
assert_eq!(doc.edit_generation(), generation + 1);
|
||
assert_eq!(doc.can_undo(), Some("Resize comment"));
|
||
assert!(((expected[2] - expected[0]) / (expected[3] - expected[1]) - 3.0).abs() < 0.001);
|
||
h.state_mut().undo();
|
||
h.run_steps(3);
|
||
assert_rect(h.state().session.get(h.state().views[0].id).unwrap().info.annotations[0].rect, [70.0, 170.0, 214.0, 226.0]);
|
||
h.state_mut().redo();
|
||
h.run_steps(3);
|
||
assert_rect(h.state().session.get(h.state().views[0].id).unwrap().info.annotations[0].rect, expected);
|
||
}
|
||
std::fs::remove_file(path).unwrap();
|
||
}
|
||
|
||
#[test]
|
||
fn image_import_cancel_clear_and_errors_preserve_saved_signatures() {
|
||
let path = signature_file("cancel");
|
||
let mut h = harness();
|
||
let original = SavedSig::Typed("Ada Lovelace".into());
|
||
h.state_mut().signature = Some(original.clone());
|
||
h.state_mut().execute("sign.fill.signature.change");
|
||
h.run_steps(2);
|
||
h.get_by_label("Image").click();
|
||
h.run_steps(2);
|
||
h.state_mut().pick_override = Some(Vec::new());
|
||
h.get_by_label("Browse…").click();
|
||
h.run_steps(3);
|
||
assert!(h.state().signature_draft.image.is_none());
|
||
browse_image(&mut h, &path);
|
||
let loaded = h.state().signature_draft.image.clone();
|
||
std::fs::write(&path, b"broken PNG").unwrap();
|
||
browse_image(&mut h, &path);
|
||
assert_eq!(h.state().signature_draft.image, loaded, "an invalid replacement keeps the previous draft");
|
||
assert!(h.state().toast.as_ref().unwrap().0.contains("Couldn't import"));
|
||
h.get_by_label("Clear").click();
|
||
h.run_steps(2);
|
||
assert!(h.state().signature_draft.saved().is_none());
|
||
h.get_by_label("Cancel").click();
|
||
h.run_steps(3);
|
||
assert_eq!(h.state().signature, Some(original));
|
||
assert!(items(&h).is_empty());
|
||
// A picker answer from a closed dialog cannot populate a later draft.
|
||
h.state_mut().execute("sign.fill.signature.change");
|
||
h.run_steps(2);
|
||
h.get_by_label("Image").click();
|
||
h.run_steps(2);
|
||
assert_eq!(signature_file("cancel"), path, "replace the corrupt file with a valid image for the stale-answer check");
|
||
h.state_mut().pick_override = Some(vec![path.to_string_lossy().into_owned()]);
|
||
h.get_by_label("Browse…").click();
|
||
h.run_steps(1);
|
||
h.state_mut().dialog = None;
|
||
h.run_steps(3);
|
||
assert!(h.state().signature_draft.image.is_none());
|
||
// Malformed settings are ignored while legacy typed/drawn values still restore.
|
||
let mut again = PdfCraftApp::new();
|
||
again.set_option("language", "en").unwrap();
|
||
again.restore(r#"{"signature_text":"Ada","signature_image":{"Image":"bm90IGFuIGltYWdl"},"initials":{"Image":"%%%"}}"#);
|
||
assert_eq!(again.signature, Some(SavedSig::Typed("Ada".into())));
|
||
assert!(again.initials.is_none());
|
||
std::fs::remove_file(path).unwrap();
|
||
}
|
||
|
||
#[test]
|
||
fn flatten_on_save_is_off_by_default_and_round_trips() {
|
||
let mut app = PdfCraftApp::new();
|
||
assert!(!app.flatten_fill_sign_on_save);
|
||
app.set_option("flatten-fill-sign", "true").unwrap();
|
||
assert!(app.set_option("flatten-fill-sign", "yes").is_err());
|
||
let mut restored = PdfCraftApp::new();
|
||
restored.restore(&app.persist());
|
||
assert!(restored.flatten_fill_sign_on_save);
|
||
restored.set_option("flatten-fill-sign", "false").unwrap();
|
||
let mut again = PdfCraftApp::new();
|
||
again.restore(&restored.persist());
|
||
assert!(!again.flatten_fill_sign_on_save);
|
||
}
|
||
|
||
#[test]
|
||
fn saving_flattens_fill_and_sign_only_when_the_preference_is_on() {
|
||
use pdfcraft_engine::{Edit, FillMark, NewAnnotation, Shape, Style};
|
||
use pdfcraft_ui_egui::SaveTarget;
|
||
use pdfcraft_ui_egui::fill_sign::TypeBox;
|
||
|
||
let dir = std::env::temp_dir().join(format!("pdfcraft-fill-flatten-{}", std::process::id()));
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
std::fs::create_dir_all(&dir).unwrap();
|
||
let mark = Edit::AddAnnotation(NewAnnotation {
|
||
page: 0,
|
||
shape: Shape::Mark { rect: [40.0, 80.0, 56.0, 96.0], mark: FillMark::Check },
|
||
style: Style::default_for(&Shape::Mark { rect: [0.0, 0.0, 12.0, 12.0], mark: FillMark::Check }),
|
||
contents: String::new(),
|
||
author: "Ada".into(),
|
||
});
|
||
|
||
let mut app = PdfCraftApp::new();
|
||
app.open_bytes("form.pdf", None, FIXTURE.to_vec()).unwrap();
|
||
let id = app.views[0].id;
|
||
app.session.apply(id, mark).unwrap();
|
||
app.views[0].fill_text = Some(TypeBox { page: 0, at: [40.0, 200.0], text: "Typed".into(), size: 10.0, focus: false });
|
||
let kept = dir.join("kept.pdf");
|
||
app.save_override = Some(kept.to_string_lossy().into_owned());
|
||
assert!(app.save_active(SaveTarget::InPlace));
|
||
let mut reopened = PdfCraftApp::new();
|
||
reopened.open_bytes("kept.pdf", None, std::fs::read(&kept).unwrap()).unwrap();
|
||
let texts: Vec<String> =
|
||
reopened.session.get(reopened.views[0].id).unwrap().info.annotations.iter().map(|a| a.contents.clone().unwrap_or_default()).collect();
|
||
assert!(texts.iter().any(|t| t == "Typed"), "an open type box is saved: {texts:?}");
|
||
assert_eq!(reopened.session.get(reopened.views[0].id).unwrap().info.annotations.len(), 2, "the check mark stays too");
|
||
|
||
app.views[0].fill_text = Some(TypeBox { page: 0, at: [40.0, 160.0], text: "More".into(), size: 10.0, focus: false });
|
||
app.flatten_fill_sign_on_save = true;
|
||
let flat = dir.join("flat.pdf");
|
||
app.save_override = Some(flat.to_string_lossy().into_owned());
|
||
assert!(app.save_active(SaveTarget::InPlace));
|
||
let mut flattened = PdfCraftApp::new();
|
||
flattened.open_bytes("flat.pdf", None, std::fs::read(&flat).unwrap()).unwrap();
|
||
let left = &flattened.session.get(flattened.views[0].id).unwrap().info.annotations;
|
||
assert!(left.is_empty(), "text and the check mark are page content: {left:?}");
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
/// Issue #299: drawn and typed signatures and initials are upright as displayed on pages with
|
||
/// `/Rotate` 90, 180 and 270, anchored at the displayed point, exactly as on an unturned page.
|
||
#[test]
|
||
fn drawn_and_typed_signatures_stay_upright_on_rotated_pages() {
|
||
// An "L": a long stroke along the bottom and a tick up at its left end, so a turned or
|
||
// mirrored signature shows.
|
||
let drawn = SavedSig::Drawn(vec![vec![[0.0, 0.0], [1.0, 0.0]], vec![[0.0, 0.0], [0.0, 0.2]]]);
|
||
let typed = SavedSig::Typed("Ada Lovelace".into());
|
||
// (signature, initials, displayed left-centre point)
|
||
let cases = [(&drawn, false, [40.0_f32, 60.0]), (&typed, false, [40.0, 140.0]), (&typed, true, [40.0, 230.0])];
|
||
let ink_mask = |rotation: i64| {
|
||
let mut session = pdfcraft_engine::Session::new();
|
||
let blank = session.create_blank(300.0, 400.0, 1).unwrap();
|
||
let id = session.open_new("form.pdf", blank).unwrap();
|
||
if rotation != 0 {
|
||
session.apply(id, pdfcraft_engine::Edit::RotatePages { pages: vec![0], degrees: rotation }).unwrap();
|
||
}
|
||
let page = session.get(id).unwrap().info.pages[0].clone();
|
||
assert_eq!(i64::from(page.rotation), rotation);
|
||
for (sig, initials, shown) in cases {
|
||
let at = page.view_to_user(shown[0], shown[1]);
|
||
let edit = pdfcraft_ui_egui::fill_sign::place(0, &page, [f64::from(at[0]), f64::from(at[1])], sig, initials, "Ada").unwrap();
|
||
session.apply(id, edit).unwrap();
|
||
}
|
||
let annotations = &session.get(id).unwrap().info.annotations;
|
||
assert_eq!(annotations.len(), cases.len());
|
||
let bytes = session.get(id).unwrap().bytes.clone();
|
||
let mut renderer = pdfcraft_render::PageRenderer::new(bytes, Default::default());
|
||
let out = renderer.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() });
|
||
assert!(out.error.is_none(), "{:?}", out.error);
|
||
let shown_size = if rotation % 180 == 0 { (300, 400) } else { (400, 300) };
|
||
assert_eq!((out.width, out.height), shown_size, "rendered as displayed");
|
||
let dark: Vec<bool> = out.rgba.as_chunks::<4>().0.iter().map(|p| p[..3].iter().all(|v| *v < 128)).collect();
|
||
// The displayed ink box of each signature, in the band around its point.
|
||
let boxes: Vec<[u32; 4]> = cases
|
||
.iter()
|
||
.map(|(_, _, shown)| {
|
||
let (top, bottom) = ((shown[1] - 35.0) as u32, (shown[1] + 35.0) as u32);
|
||
let mut b = [u32::MAX, u32::MAX, 0, 0];
|
||
for y in top..bottom {
|
||
for x in 0..out.width {
|
||
if dark[(y * out.width + x) as usize] {
|
||
b = [b[0].min(x), b[1].min(y), b[2].max(x), b[3].max(y)];
|
||
}
|
||
}
|
||
}
|
||
assert!(b[0] <= b[2], "rotation {rotation}: no ink near {shown:?}");
|
||
b
|
||
})
|
||
.collect();
|
||
(dark, out.width, boxes)
|
||
};
|
||
let (upright, _, upright_boxes) = ink_mask(0);
|
||
for (b, (sig, _, shown)) in upright_boxes.iter().zip(cases) {
|
||
assert!(b[2] - b[0] > b[3] - b[1], "{sig:?} reads across: {b:?}");
|
||
assert!(b[0].abs_diff(shown[0] as u32) <= 2, "{sig:?} starts at the point: {b:?}");
|
||
assert!(b[1] < shown[1] as u32 && b[3] > shown[1] as u32, "{sig:?} is centred on the point: {b:?}");
|
||
}
|
||
// The drawn "L" has its tick at the top left, not mirrored or upside down.
|
||
let at = |x: u32, y: u32| upright[(y * 300 + x) as usize];
|
||
let b = upright_boxes[0];
|
||
assert!(at(b[0] + 1, b[1] + 2) && !at(b[2] - 2, b[1] + 2) && at(b[2] - 2, b[3] - 1), "the drawn L is upright: {b:?}");
|
||
for rotation in [90, 180, 270] {
|
||
let (dark, width, boxes) = ink_mask(rotation);
|
||
for (got, want) in boxes.iter().zip(&upright_boxes) {
|
||
assert!(got.iter().zip(want).all(|(g, w)| g.abs_diff(*w) <= 1), "rotation {rotation}: displayed box {got:?}, upright {want:?}");
|
||
}
|
||
// Compare where both pages are shown (the turned ones are 400 x 300).
|
||
let (mut differ, mut ink) = (0, 0);
|
||
for y in 0..300 {
|
||
for x in 0..300 {
|
||
let (want, got) = (upright[(y * 300 + x) as usize], dark[(y * width + x) as usize]);
|
||
ink += usize::from(want);
|
||
differ += usize::from(want != got);
|
||
}
|
||
}
|
||
assert!(differ * 10 < ink, "rotation {rotation}: {differ} of {ink} ink pixels differ from the unturned page");
|
||
}
|
||
}
|
||
|
||
/// Press, move in steps and release, from one user-space point to another.
|
||
fn drag(h: &mut Harness<'static, PdfCraftApp>, from: (f32, f32), to: (f32, f32)) {
|
||
let (a, b) = (at(h, from.0, from.1), at(h, to.0, to.1));
|
||
h.hover_at(a);
|
||
h.run_steps(1);
|
||
h.drag_at(a);
|
||
h.run_steps(1);
|
||
for k in 1..=5 {
|
||
h.hover_at(a + (b - a) * (k as f32 / 5.0));
|
||
h.run_steps(1);
|
||
}
|
||
h.drop_at(b);
|
||
h.run_steps(4);
|
||
}
|
||
|
||
fn rects(h: &Harness<'static, PdfCraftApp>) -> Vec<[f32; 4]> {
|
||
let s = h.state();
|
||
s.session.get(s.views[0].id).unwrap().info.annotations.iter().map(|a| a.rect).collect()
|
||
}
|
||
|
||
/// A placed signature moves while a Fill & Sign tool is still active (as in Acrobat), instead of
|
||
/// the drag doing nothing and a click stamping another mark on top of it. Elsewhere on the page
|
||
/// the tool still places its mark.
|
||
#[test]
|
||
fn fill_and_sign_tools_pick_up_placed_marks_to_move_and_resize_them() {
|
||
for tool in ["sign.fill.signature", "sign.fill.initials", "sign.fill.text", "sign.fill.check", "sign.fill.date"] {
|
||
let mut h = harness();
|
||
h.state_mut().signature = Some(SavedSig::Typed("Ada Lovelace".into()));
|
||
h.state_mut().initials = Some(SavedSig::Typed("AL".into()));
|
||
h.state_mut().execute("sign.fill.signature");
|
||
click(&mut h, 40.0, 250.0);
|
||
let [placed] = rects(&h)[..] else { panic!("one signature: {:?}", rects(&h)) };
|
||
assert!(h.state_mut().execute(tool));
|
||
h.run_steps(2);
|
||
let mid = ((placed[0] + placed[2]) / 2.0, (placed[1] + placed[3]) / 2.0);
|
||
h.hover_at(at(&h, mid.0, mid.1));
|
||
h.run_steps(2);
|
||
assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::Move, "{tool}: a placed mark can be picked up");
|
||
|
||
drag(&mut h, mid, (mid.0 + 40.0, mid.1 - 60.0));
|
||
assert!(matches!(h.state().quick_tool, QuickTool::Fill(_)), "{tool}: the tool stays");
|
||
let moved = [placed[0] + 40.0, placed[1] - 60.0, placed[2] + 40.0, placed[3] - 60.0];
|
||
let [now] = rects(&h)[..] else { panic!("{tool}: still one mark: {:?}", rects(&h)) };
|
||
assert_rect(now, moved);
|
||
let doc = h.state().session.get(h.state().views[0].id).unwrap();
|
||
assert_eq!(doc.can_undo(), Some("Move comment"), "{tool}");
|
||
assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "{tool}");
|
||
|
||
// The selected signature's handle resizes it.
|
||
let corner = at(&h, moved[2], moved[1]);
|
||
h.hover_at(corner);
|
||
h.run_steps(2);
|
||
h.drag_at(corner);
|
||
h.run_steps(1);
|
||
h.hover_at(corner + egui::vec2(30.0, 10.0));
|
||
h.run_steps(2);
|
||
h.drop_at(corner + egui::vec2(30.0, 10.0));
|
||
h.run_steps(3);
|
||
assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo(), Some("Resize comment"), "{tool}");
|
||
assert_eq!(rects(&h).len(), 1, "{tool}");
|
||
|
||
// A click on it selects it rather than placing another mark on top.
|
||
h.state_mut().views[0].comments.selected = None;
|
||
let r = rects(&h)[0];
|
||
click(&mut h, (r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0);
|
||
assert_eq!(rects(&h).len(), 1, "{tool}: no mark stamped on the signature");
|
||
assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "{tool}");
|
||
assert!(h.state().views[0].fill_text.is_none(), "{tool}: no text box opened on the signature");
|
||
}
|
||
// A drawn signature in a saved and reopened file (read by the viewer, not the editor).
|
||
let mut h = harness();
|
||
h.state_mut().signature = Some(SavedSig::Drawn(vec![vec![[0.05, 0.1], [0.3, 0.2], [0.55, 0.05], [0.95, 0.15]]]));
|
||
h.state_mut().execute("sign.fill.signature");
|
||
click(&mut h, 40.0, 250.0);
|
||
let saved = h.state().session.save_bytes(h.state().views[0].id).unwrap();
|
||
let mut h = harness_bytes(&saved);
|
||
let [placed] = rects(&h)[..] else { panic!("one signature: {:?}", rects(&h)) };
|
||
h.state_mut().execute("sign.fill.check");
|
||
let mid = ((placed[0] + placed[2]) / 2.0, (placed[1] + placed[3]) / 2.0);
|
||
drag(&mut h, mid, (mid.0 + 40.0, mid.1 - 60.0));
|
||
assert_rect(rects(&h)[0], [placed[0] + 40.0, placed[1] - 60.0, placed[2] + 40.0, placed[3] - 60.0]);
|
||
|
||
// Away from Fill & Sign marks, the tool still places (checks stay repeatable), also on other
|
||
// comments.
|
||
let mut h = harness();
|
||
h.state_mut().signature = Some(SavedSig::Typed("Ada Lovelace".into()));
|
||
h.state_mut().execute("sign.fill.signature");
|
||
click(&mut h, 40.0, 250.0);
|
||
let id = h.state().views[0].id;
|
||
let square = pdfcraft_engine::Shape::Rectangle { rect: [100.0, 80.0, 200.0, 140.0] };
|
||
let style = pdfcraft_engine::Style::default_for(&square);
|
||
let new = pdfcraft_engine::NewAnnotation { page: 0, shape: square, style, contents: String::new(), author: "Ada".into() };
|
||
h.state_mut().session.apply(id, pdfcraft_engine::Edit::AddAnnotation(new)).unwrap();
|
||
h.run_steps(2);
|
||
h.state_mut().execute("sign.fill.check");
|
||
click(&mut h, 60.0, 100.0);
|
||
click(&mut h, 150.0, 110.0);
|
||
assert_eq!(items(&h).len(), 4, "{:?}", items(&h));
|
||
assert_eq!(h.state().quick_tool, QuickTool::Fill(FillTool::Check));
|
||
}
|
||
|
||
/// A drawn signature resizes from its handles like typed and image ones: the corners keep its
|
||
/// shape, the strokes scale with it, and the size survives undo, redo and saving.
|
||
#[test]
|
||
fn drawn_signatures_resize_from_their_corners() {
|
||
let mut h = harness();
|
||
h.state_mut().signature = Some(SavedSig::Drawn(vec![vec![[0.05, 0.1], [0.3, 0.2], [0.55, 0.05], [0.95, 0.15]]]));
|
||
h.state_mut().execute("sign.fill.signature");
|
||
click(&mut h, 40.0, 250.0);
|
||
let [placed] = rects(&h)[..] else { panic!("one signature: {:?}", rects(&h)) };
|
||
assert_eq!(h.state().quick_tool, QuickTool::Select);
|
||
assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "the placed signature is selected");
|
||
|
||
// The top-right corner handle, dragged out and a little up.
|
||
let corner = at(&h, placed[2], placed[3]);
|
||
h.hover_at(corner);
|
||
h.run_steps(2);
|
||
assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::ResizeNeSw);
|
||
h.drag_at(corner);
|
||
h.run_steps(1);
|
||
for k in 1..=10 {
|
||
h.hover_at(corner + egui::vec2(6.0 * k as f32, -(k as f32)));
|
||
h.run_steps(1);
|
||
}
|
||
h.drop_at(corner + egui::vec2(60.0, -10.0));
|
||
h.run_steps(4);
|
||
let doc = h.state().session.get(h.state().views[0].id).unwrap();
|
||
assert_eq!(doc.can_undo(), Some("Resize comment"));
|
||
let [now] = rects(&h)[..] else { panic!("still one signature: {:?}", rects(&h)) };
|
||
let ratio = |r: [f32; 4]| (r[2] - r[0]) / (r[3] - r[1]);
|
||
assert!(now[2] - now[0] > placed[2] - placed[0] + 20.0, "wider: {placed:?} -> {now:?}");
|
||
assert!((ratio(now) - ratio(placed)).abs() < 0.02 * ratio(placed), "same shape: {placed:?} -> {now:?}");
|
||
assert!((now[0] - placed[0]).abs() < 0.01 && (now[1] - placed[1]).abs() < 0.01, "the opposite corner stays put");
|
||
|
||
let id = h.state().views[0].id;
|
||
h.state_mut().session.undo(id).unwrap();
|
||
assert_rect(rects(&h)[0], placed);
|
||
h.state_mut().session.redo(id).unwrap();
|
||
assert_rect(rects(&h)[0], now);
|
||
let saved = h.state().session.save_bytes(id).unwrap();
|
||
let h = harness_bytes(&saved);
|
||
assert_rect(rects(&h)[0], now);
|
||
}
|
||
|
||
/// A signature placed over a form field is picked up by the Select tool: the field under it
|
||
/// doesn't take the press. Away from the signature, a click still fills the field.
|
||
#[test]
|
||
fn select_tool_picks_up_a_signature_over_a_form_field() {
|
||
let mut h = harness_bytes(include_bytes!("data/form.pdf"));
|
||
h.state_mut().signature = Some(SavedSig::Typed("Ada Lovelace".into()));
|
||
h.state_mut().execute("sign.fill.signature");
|
||
click(&mut h, 150.0, 270.0);
|
||
let [placed] = rects(&h)[..] else { panic!("one signature: {:?}", rects(&h)) };
|
||
// Onto the `city` text field (user space y 180..200).
|
||
let mid_y = (placed[1] + placed[3]) / 2.0;
|
||
let id = h.state().views[0].id;
|
||
let index = h.state().session.get(id).unwrap().info.annotations[0].index;
|
||
let dy = (190.0 - mid_y) as f64;
|
||
h.state_mut().session.apply(id, pdfcraft_engine::Edit::MoveAnnotation { page: 0, index, dx: 0.0, dy }).unwrap();
|
||
h.state_mut().views[0].comments.selected = None;
|
||
h.state_mut().quick_tool = QuickTool::Select;
|
||
h.run_steps(3);
|
||
let [over] = rects(&h)[..] else { panic!("one signature: {:?}", rects(&h)) };
|
||
let mid = ((over[0] + over[2]) / 2.0, (over[1] + over[3]) / 2.0);
|
||
|
||
h.hover_at(at(&h, mid.0, mid.1));
|
||
h.run_steps(2);
|
||
assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::Move);
|
||
// A hand's drag: a few points per frame, so it starts while still over the field.
|
||
let (a, b) = (at(&h, mid.0, mid.1), at(&h, mid.0, mid.1 + 80.0));
|
||
h.drag_at(a);
|
||
h.run_steps(1);
|
||
for k in 1..=40 {
|
||
h.hover_at(a + (b - a) * (k as f32 / 40.0));
|
||
h.run_steps(1);
|
||
}
|
||
h.drop_at(b);
|
||
h.run_steps(4);
|
||
assert_rect(rects(&h)[0], [over[0], over[1] + 80.0, over[2], over[3] + 80.0]);
|
||
assert!(h.state().views[0].forms.focus.is_none(), "the field under the signature isn't focused");
|
||
|
||
// Clear of the signature, the field still takes the click.
|
||
click(&mut h, 270.0, 190.0);
|
||
assert_eq!(h.state().views[0].forms.focus.as_ref().map(|f| f.name.as_str()), Some("city"));
|
||
}
|
||
|
||
/// A synthetic phone photo of a signature on paper: a dark table along the left edge, a sheet
|
||
/// with a lighting gradient and a soft shadow, and a ballpoint-blue signature in two words.
|
||
fn signature_photo(test: &str) -> std::path::PathBuf {
|
||
let path = std::env::temp_dir().join(format!("pdfcraft-signature-photo-{test}-{}.png", std::process::id()));
|
||
let image = image::RgbImage::from_fn(800, 600, |x, y| {
|
||
if x < 60 {
|
||
return image::Rgb([70, 55, 45]);
|
||
}
|
||
let light = 235.0 - x as f32 * 0.04 - if (500..700).contains(&x) && y > 380 { 45.0 } else { 0.0 };
|
||
let (fx, fy) = (x as f32, y as f32);
|
||
let wave = |x0: f32, x1: f32| (x0..x1).contains(&fx) && (fy - (300.0 + 25.0 * ((fx - x0) / 18.0).sin())).abs() < 3.0;
|
||
if wave(180.0, 380.0) || wave(420.0, 640.0) {
|
||
image::Rgb([30, 40, 110])
|
||
} else {
|
||
let v = light.clamp(0.0, 255.0) as u8;
|
||
image::Rgb([v, v, v.saturating_sub(6)])
|
||
}
|
||
});
|
||
image.save(&path).unwrap();
|
||
path
|
||
}
|
||
|
||
fn choose_photo(h: &mut Harness<'static, PdfCraftApp>, path: &std::path::Path) {
|
||
h.state_mut().pick_override = Some(vec![path.to_string_lossy().into_owned()]);
|
||
h.get_by_label("Choose photo…").click();
|
||
h.run_steps(3);
|
||
settle_scan(h);
|
||
}
|
||
|
||
/// Scans run on a worker thread: step frames until the answer has been picked up.
|
||
fn settle_scan(h: &mut Harness<'static, PdfCraftApp>) {
|
||
for _ in 0..1000 {
|
||
if !h.state().signature_draft.scan.as_ref().is_some_and(|s| s.scanning()) {
|
||
h.run_steps(1);
|
||
return;
|
||
}
|
||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||
h.run_steps(1);
|
||
}
|
||
panic!("the signature scan did not finish");
|
||
}
|
||
|
||
/// A transparent signature in the ink's colour: no paper, table or shadow is left.
|
||
fn assert_clean_signature(image: &pdfcraft_engine::SignatureImage) {
|
||
let [w, h] = image.size();
|
||
assert!(w > 200 && w > h * 2, "both words are framed and trimmed: {w}×{h}");
|
||
let rgba = image.rgba();
|
||
let opaque: Vec<&[u8; 4]> = rgba.as_chunks::<4>().0.iter().filter(|p| p[3] > 128).collect();
|
||
assert!(!opaque.is_empty());
|
||
assert!(opaque.iter().all(|p| p[2] > p[0]), "strokes keep their blue ink");
|
||
assert!(rgba.as_chunks::<4>().0.iter().filter(|p| p[3] == 0).count() > rgba.len() / 8, "the paper is transparent");
|
||
}
|
||
|
||
#[test]
|
||
fn scanned_signatures_are_framed_cleaned_adjusted_placed_and_remembered() {
|
||
let _gpu = gpu();
|
||
let path = signature_photo("scan");
|
||
let mut h = harness();
|
||
h.state_mut().execute("sign.fill.signature");
|
||
h.run_steps(2);
|
||
h.get_by_label("Scan").click();
|
||
h.run_steps(2);
|
||
assert!(h.state().signature_draft.scan_mode);
|
||
assert!(h.state().signature_draft.saved().is_none(), "Apply needs a photo");
|
||
choose_photo(&mut h, &path);
|
||
let scan = h.state().signature_draft.scan.clone().expect("the photo is loaded");
|
||
assert_eq!(scan.photo.size(), [800, 600]);
|
||
let frame = scan.frame;
|
||
assert!(frame.x > 60 && frame.x < 190 && frame.x + frame.width > 630, "the frame holds both words, not the table: {frame:?}");
|
||
assert_clean_signature(h.state().signature_draft.image.as_ref().expect("a cleaned signature"));
|
||
if let Ok(dir) = std::env::var("PDFCRAFT_SHOTS") {
|
||
h.render().unwrap().save(format!("{dir}/scan-signature-dialog.png")).unwrap();
|
||
}
|
||
|
||
// Drawing a frame around the first word only leaves the second out.
|
||
let area = h.get_by_label("Photo").rect();
|
||
let to_screen = |x: f32, y: f32| {
|
||
let fit = area.shrink(8.0);
|
||
let scale = (fit.width() / 800.0).min(fit.height() / 600.0);
|
||
let origin = fit.center() - egui::vec2(400.0, 300.0) * scale;
|
||
origin + egui::vec2(x, y) * scale
|
||
};
|
||
let (a, b) = (to_screen(110.0, 190.0), to_screen(400.0, 370.0));
|
||
h.hover_at(a);
|
||
h.run_steps(1);
|
||
h.drag_at(a);
|
||
h.run_steps(1);
|
||
for k in 1..=5 {
|
||
h.hover_at(a + (b - a) * (k as f32 / 5.0));
|
||
h.run_steps(1);
|
||
}
|
||
h.drop_at(b);
|
||
h.run_steps(3);
|
||
settle_scan(&mut h);
|
||
let one_word = h.state().signature_draft.scan.as_ref().unwrap().frame;
|
||
assert!(one_word.x + one_word.width < 420, "{one_word:?}");
|
||
let [w, _] = h.state().signature_draft.image.as_ref().unwrap().size();
|
||
assert!(w < 300, "only the first word is left: {w}");
|
||
assert!(h.state().signature_draft.saved().is_some(), "the full result is ready after the drag");
|
||
|
||
// Turning the photo turns the frame with it; turning back restores both.
|
||
h.get_by_label("Rotate clockwise").click();
|
||
h.run_steps(2);
|
||
assert_eq!(h.state().signature_draft.scan.as_ref().unwrap().photo.size(), [600, 800]);
|
||
h.get_by_label("Rotate counterclockwise").click();
|
||
h.run_steps(2);
|
||
let scan = h.state().signature_draft.scan.clone().unwrap();
|
||
assert_eq!((scan.photo.size(), scan.frame), ([800, 600], one_word));
|
||
|
||
// A photo with nothing in the frame says so instead of offering an empty signature.
|
||
{
|
||
let d = &mut h.state_mut().signature_draft;
|
||
let scan = d.scan.as_mut().unwrap();
|
||
scan.frame = pdfcraft_engine::PhotoRect { x: 650, y: 40, width: 120, height: 100 };
|
||
scan.rescan(&mut d.image, true);
|
||
}
|
||
h.run_steps(2);
|
||
assert!(h.state().signature_draft.saved().is_none());
|
||
assert!(h.state().signature_draft.scan.as_ref().unwrap().problem.as_ref().unwrap().contains("No signature was found"));
|
||
h.get_by_label("No signature was found in the frame. Move the frame over the signature or raise the ink sensitivity.");
|
||
{
|
||
let d = &mut h.state_mut().signature_draft;
|
||
let scan = d.scan.as_mut().unwrap();
|
||
scan.frame = frame;
|
||
scan.rescan(&mut d.image, true);
|
||
}
|
||
h.run_steps(2);
|
||
|
||
h.get_by_label("Apply").click();
|
||
h.run_steps(3);
|
||
let Some(SavedSig::Image(saved)) = h.state().signature.clone() else { panic!("the scan is saved as an image signature") };
|
||
assert_clean_signature(&saved);
|
||
click(&mut h, 40.0, 250.0);
|
||
assert_eq!(items(&h).len(), 1);
|
||
assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().info.annotations[0].subtype, "Stamp");
|
||
|
||
// It is remembered like any image signature, without the photo or its path.
|
||
let settings = h.state().persist();
|
||
assert!(!settings.contains(&path.to_string_lossy().to_string()));
|
||
assert!(settings.len() < 200_000, "only the trimmed signature is kept: {} bytes", settings.len());
|
||
let mut again = PdfCraftApp::new();
|
||
again.set_option("language", "en").unwrap();
|
||
again.restore(&settings);
|
||
assert_eq!(again.signature, h.state().signature);
|
||
std::fs::remove_file(path).unwrap();
|
||
}
|
||
|
||
#[test]
|
||
fn scan_cancel_errors_and_stale_answers_preserve_saved_signatures() {
|
||
let path = signature_photo("scan-cancel");
|
||
let mut h = harness();
|
||
let original = SavedSig::Typed("Ada Lovelace".into());
|
||
h.state_mut().signature = Some(original.clone());
|
||
h.state_mut().execute("sign.fill.signature.change");
|
||
h.run_steps(2);
|
||
h.get_by_label("Scan").click();
|
||
h.run_steps(2);
|
||
h.state_mut().pick_override = Some(Vec::new());
|
||
h.get_by_label("Choose photo…").click();
|
||
h.run_steps(3);
|
||
assert!(h.state().signature_draft.scan.is_none(), "a cancelled pick loads nothing");
|
||
choose_photo(&mut h, &path);
|
||
let loaded = h.state().signature_draft.clone();
|
||
assert!(loaded.image.is_some());
|
||
let broken = std::env::temp_dir().join(format!("pdfcraft-signature-photo-broken-{}.jpg", std::process::id()));
|
||
std::fs::write(&broken, b"\xFF\xD8\xFF not a JPEG").unwrap();
|
||
choose_photo(&mut h, &broken);
|
||
assert_eq!(h.state().signature_draft, loaded, "an invalid photo keeps the previous one");
|
||
assert!(h.state().toast.as_ref().unwrap().0.contains("Couldn't use the photo"));
|
||
// A photo picked for the Scan tab does not land in the Image tab.
|
||
h.state_mut().pick_override = Some(vec![path.to_string_lossy().into_owned()]);
|
||
h.get_by_label("Choose photo…").click();
|
||
h.run_steps(1);
|
||
h.state_mut().signature_draft.scan = None;
|
||
h.state_mut().signature_draft.image = None;
|
||
h.state_mut().signature_draft.scan_mode = false;
|
||
h.state_mut().signature_draft.image_mode = true;
|
||
h.run_steps(3);
|
||
assert!(h.state().signature_draft.image.is_none() && h.state().signature_draft.scan.is_none());
|
||
h.get_by_label("Cancel").click();
|
||
h.run_steps(3);
|
||
assert_eq!(h.state().signature, Some(original));
|
||
assert!(items(&h).is_empty());
|
||
std::fs::remove_file(path).unwrap();
|
||
std::fs::remove_file(broken).unwrap();
|
||
}
|
||
|
||
/// A file dropped on the window, as the windowing layer hands it over.
|
||
#[derive(Debug)]
|
||
struct Dropped(std::path::PathBuf, Vec<u8>);
|
||
|
||
impl egui::DroppedFile for Dropped {
|
||
fn path(&self) -> &std::path::Path {
|
||
&self.0
|
||
}
|
||
fn bytes(&self) -> Result<Vec<u8>, String> {
|
||
Ok(self.1.clone())
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn photos_and_images_dropped_on_the_signature_pad_are_imported_not_opened() {
|
||
let photo = signature_photo("drop");
|
||
let mut h = harness();
|
||
let tabs = h.state().views.len();
|
||
h.state_mut().execute("sign.fill.signature");
|
||
h.run_steps(2);
|
||
h.get_by_label("Scan").click();
|
||
h.run_steps(2);
|
||
// From a file manager (an absolute path) and from a browser-like source (bytes only).
|
||
for file in [Dropped(photo.clone(), Vec::new()), Dropped("photo.png".into(), std::fs::read(&photo).unwrap())] {
|
||
h.state_mut().signature_draft.scan = None;
|
||
h.input_mut().dropped_files.push(std::sync::Arc::new(file));
|
||
h.run_steps(3);
|
||
settle_scan(&mut h);
|
||
assert!(h.state().signature_draft.scan.is_some(), "the photo is scanned");
|
||
assert!(h.state().signature_draft.image.is_some());
|
||
}
|
||
h.get_by_label("Image").click();
|
||
h.run_steps(2);
|
||
h.state_mut().signature_draft.image = None;
|
||
h.input_mut().dropped_files.push(std::sync::Arc::new(Dropped("photo.png".into(), std::fs::read(&photo).unwrap())));
|
||
h.run_steps(3);
|
||
assert!(h.state().signature_draft.image.is_some(), "the Image tab takes the picture as it is");
|
||
h.input_mut().dropped_files.push(std::sync::Arc::new(Dropped("broken.png".into(), b"broken".to_vec())));
|
||
h.run_steps(3);
|
||
assert!(h.state().signature_draft.image.is_some(), "an invalid file keeps the previous draft");
|
||
assert!(h.state().toast.as_ref().unwrap().0.contains("Couldn't import"));
|
||
assert_eq!(h.state().views.len(), tabs, "nothing opened behind the dialog");
|
||
std::fs::remove_file(photo).unwrap();
|
||
}
|