Files
pdfcraft/crates/ui-egui/tests/editing.rs
T
134b98b0a8 M4: open Organize Pages as a selectable thumbnail overview (#1004)
* M4: document the directly accessible page overview

Part of the organize-page-overview change.

Co-authored-by: Codex <[email protected]>

* M4: open Organize in the page grid with selection and per-page actions

Show the overview from the tool catalogue, Home, palette and control channel. Add checkbox selection and local rotate/delete actions; preserve thumbnail aspect ratios.

Co-authored-by: Codex <[email protected]>

* M4: translate page selection and local actions

Co-authored-by: Codex <[email protected]>

* M4: cover overview entry, selection and page actions

Co-authored-by: Codex <[email protected]>

* M4: record the organizer workflow improvement

Co-authored-by: Codex <[email protected]>

* M4: link page overview regression evidence

Co-authored-by: Codex <[email protected]>

---------

Co-authored-by: Codex <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 13:58:58 -04:00

2502 lines
106 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Headless UI tests for editing and saving: the organize toolbar, selection, undo/redo keys,
//! save/save-as, the unsaved-changes prompt and editable document properties.
use egui::accesskit::Role;
use egui::{Key, Modifiers};
use egui_kittest::Harness;
use egui_kittest::kittest::{NodeT, Queryable};
use pdfcraft_render::{PageRenderer, RenderRequest, RequestKind};
use pdfcraft_ui_egui::{CloseRequest, Dialog, PdfCraftApp};
/// An `n`-page document with a proper xref table; page `i` shows "Page i+1".
fn fixture(n: usize) -> Vec<u8> {
let mut objs: Vec<String> = vec!["<< /Type /Catalog /Pages 2 0 R >>".into()];
let kids: Vec<String> = (0..n).map(|i| format!("{} 0 R", 4 + 2 * i)).collect();
objs.push(format!("<< /Type /Pages /Kids [{}] /Count {n} /MediaBox [0 0 200 300] >>", kids.join(" ")));
objs.push("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into());
for i in 0..n {
objs.push(format!("<< /Type /Page /Parent 2 0 R /Contents {} 0 R /Resources << /Font << /F1 3 0 R >> >> >>", 5 + 2 * i));
let body = format!("BT /F1 24 Tf 20 150 Td (Page {}) Tj ET", i + 1);
objs.push(format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()));
}
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
out
}
fn harness(pages: usize, setup: impl FnOnce(&mut PdfCraftApp) + 'static) -> Harness<'static, PdfCraftApp> {
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("doc.pdf", None, fixture(pages)).expect("fixture opens");
setup(&mut app);
app
});
h.run_steps(4);
h
}
fn organize(pages: usize) -> Harness<'static, PdfCraftApp> {
harness(pages, |app| app.set_option("organize", "on").unwrap())
}
#[test]
fn organize_tool_opens_the_overview_directly_and_can_be_reentered() {
let mut h = harness(12, |_| {});
assert!(!h.state().views[0].organize);
h.get_by_label("Organize pages").click();
h.run_steps(3);
assert!(h.state().views[0].organize);
h.get_by_label("Page 1");
h.get_by_label("Page 5");
h.get_by_label("Select page 2").click();
h.run_steps(2);
h.get_by_label("Close").click();
h.run_steps(2);
assert!(!h.state().views[0].organize);
h.state_mut().set_option("tool", "organize").unwrap();
h.state_mut().set_option("tool", "organize").unwrap();
h.run_steps(2);
assert!(h.state().views[0].organize, "entering the tool is idempotent");
assert_eq!(h.state().views[0].target_pages(), [1]);
h.state_mut().set_option("tool", "edit").unwrap();
h.run_steps(2);
assert!(!h.state().views[0].organize, "another tool returns to the document");
}
#[test]
fn organize_tool_chosen_before_opening_a_document_shows_its_pages() {
let mut app = PdfCraftApp::new();
app.set_option("tool", "organize").unwrap();
app.open_bytes("doc.pdf", None, fixture(3)).unwrap();
assert!(app.views[0].organize);
}
#[test]
fn organize_checkboxes_toggle_multiple_pages_without_modifiers() {
let mut h = organize(4);
h.get_by_label("Select page 1").click();
h.run_steps(2);
h.get_by_label("Select page 3").click();
h.run_steps(2);
assert_eq!(h.state().views[0].target_pages(), [0, 2]);
h.get_by_label("Select page 1").click();
h.run_steps(2);
assert_eq!(h.state().views[0].target_pages(), [2]);
h.get_by_label("Page 4").click_modifiers(Modifiers::SHIFT);
h.run_steps(2);
assert_eq!(h.state().views[0].target_pages(), [0, 1, 2, 3], "checkbox toggles update the range anchor");
}
#[test]
fn organize_page_actions_rotate_only_the_named_page_and_undo() {
let mut h = organize(3);
h.get_by_label("Select page 1").click();
h.run_steps(2);
h.get_by_label("Select page 3").click();
h.run_steps(2);
h.get_by_label("Rotate page 1 clockwise").click();
h.run_steps(3);
let rotations = |h: &Harness<'static, PdfCraftApp>| {
h.state().session.get(h.state().views[0].id).unwrap().info.pages.iter().map(|p| p.rotation).collect::<Vec<_>>()
};
assert_eq!(rotations(&h), [90, 0, 0]);
assert_eq!(h.state().views[0].target_pages(), [0, 2], "a local action preserves the multi-selection");
h.get_by_label("Rotate counterclockwise").click();
h.run_steps(3);
assert_eq!(rotations(&h), [0, 0, 270], "toolbar actions apply to the selection");
h.key_press_modifiers(Modifiers::COMMAND, Key::Z);
h.run_steps(3);
assert_eq!(rotations(&h), [90, 0, 0]);
h.get_by_label("Rotate page 1 counterclockwise").click();
h.run_steps(3);
assert_eq!(rotations(&h), [0, 0, 0]);
}
#[test]
fn organize_hover_actions_delete_only_the_named_page_and_undo() {
let mut h = organize(3);
h.get_by_label("Page 2").hover();
h.run_steps(2);
h.get_by_label("Delete page 2").click();
h.run_steps(3);
assert_eq!(page_texts(h.state()), ["Page 1", "Page 3"]);
h.key_press_modifiers(Modifiers::COMMAND, Key::Z);
h.run_steps(3);
assert_eq!(page_texts(h.state()), ["Page 1", "Page 2", "Page 3"]);
let single = organize(1);
assert!(single.get_by_label("Delete page 1").accesskit_node().is_disabled());
}
#[test]
fn organize_local_actions_respect_document_permissions() {
let mut h = harness(3, |app| {
app.apply_edit(pdfcraft_engine::Edit::Protect(pdfcraft_engine::Protection {
permissions_password: Some("owner".into()),
changes: pdfcraft_engine::Changes::None,
..Default::default()
}));
let bytes = app.session.save_bytes(app.views[0].id).unwrap();
app.open_bytes("restricted.pdf", None, bytes.as_ref().clone()).unwrap();
app.set_option("tool", "organize").unwrap();
});
h.get_by_label("Select page 2").click();
h.run_steps(2);
assert!(h.get_by_label("Rotate page 2 clockwise").accesskit_node().is_disabled());
assert!(h.get_by_label("Rotate page 2 counterclockwise").accesskit_node().is_disabled());
assert!(h.get_by_label("Delete page 2").accesskit_node().is_disabled());
let app = h.state();
let view = &app.views[app.active.unwrap()];
assert_eq!(view.target_pages(), [1]);
assert!(!app.session.get(view.id).unwrap().dirty);
}
/// Page labels of the active document, read back from its current bytes.
fn page_texts(app: &PdfCraftApp) -> Vec<String> {
let doc = app.session.get(app.views[0].id).unwrap();
let mut r = PageRenderer::new(doc.bytes.clone(), Default::default());
(0..r.page_count())
.map(|p| {
let out = r.render(RenderRequest { page: p, kind: RequestKind::Text, scale: 1.0, ..Default::default() });
out.text.map(|t| t.plain_text().trim().to_string()).unwrap_or_default()
})
.collect()
}
fn dirty(h: &Harness<'static, PdfCraftApp>) -> bool {
let app = h.state();
app.session.get(app.views[0].id).is_some_and(|d| d.dirty)
}
fn temp_path(name: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("pdfcraft-ui-tests-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
dir.join(name)
}
#[test]
fn organize_delete_then_undo_and_redo_with_keys() {
let mut h = organize(3);
h.get_by_label("Page 2").click();
h.run_steps(2);
h.get_by_label_contains("1 page selected");
h.get_by_label("Delete pages (Delete)").click();
h.run_steps(3);
assert_eq!(page_texts(h.state()), ["Page 1", "Page 3"]);
assert!(dirty(&h));
h.get_by_label("doc.pdf (edited)"); // the tab shows unsaved changes
h.key_press_modifiers(Modifiers::COMMAND, Key::Z);
h.run_steps(3);
assert_eq!(page_texts(h.state()), ["Page 1", "Page 2", "Page 3"]);
h.key_press_modifiers(Modifiers::COMMAND | Modifiers::SHIFT, Key::Z);
h.run_steps(3);
assert_eq!(page_texts(h.state()), ["Page 1", "Page 3"]);
}
#[test]
fn shift_click_selects_a_range_and_moves_it() {
let mut h = organize(4);
h.get_by_label("Page 1").click();
h.run_steps(1);
h.get_by_label("Page 2").click_modifiers(Modifiers::SHIFT);
h.run_steps(2);
h.get_by_label_contains("2 pages selected");
h.get_by_label("Move later").click();
h.run_steps(3);
assert_eq!(page_texts(h.state()), ["Page 3", "Page 1", "Page 2", "Page 4"]);
// The moved pages stay selected at their new position, so repeated clicks keep moving them.
assert_eq!(h.state().views[0].selected.iter().copied().collect::<Vec<_>>(), [1, 2]);
h.get_by_label("Move later").click();
h.run_steps(3);
assert_eq!(page_texts(h.state()), ["Page 3", "Page 4", "Page 1", "Page 2"]);
}
#[test]
fn command_click_toggles_and_rotation_applies_to_selection() {
let mut h = organize(3);
h.get_by_label("Page 1").click();
h.run_steps(1);
h.get_by_label("Page 3").click_modifiers(Modifiers::COMMAND);
h.run_steps(2);
h.get_by_label("Rotate clockwise").click();
h.run_steps(3);
let rot: Vec<u16> = {
let app = h.state();
app.session.get(app.views[0].id).unwrap().info.pages.iter().map(|p| p.rotation).collect()
};
assert_eq!(rot, [90, 0, 90]);
}
#[test]
fn organize_select_all_shortcut_preserves_current_page_and_sets_range_anchor() {
let mut h = organize(4);
h.get_by_label("Page 3").click();
h.run_steps(1);
// Automation may move the current selection without a click's range anchor.
h.state_mut().views[0].select_pages(&[1]);
h.key_press_modifiers(Modifiers::COMMAND, Key::A);
h.run_steps(2);
h.get_by_label_contains("4 pages selected");
assert_eq!(h.state().views[0].target_pages(), [0, 1, 2, 3]);
assert_eq!(h.state().views[0].current, 1, "selecting all keeps the reader's place");
assert!(!dirty(&h), "selection doesn't edit the document");
// Repeating Select all is harmless; deleting all pages still keeps the document intact.
h.key_press_modifiers(Modifiers::COMMAND, Key::A);
h.run_steps(1);
h.key_press(Key::Delete);
h.run_steps(2);
assert_eq!(page_texts(h.state()).len(), 4);
assert!(!dirty(&h));
h.get_by_label("Page 4").click_modifiers(Modifiers::SHIFT);
h.run_steps(2);
assert_eq!(h.state().views[0].target_pages(), [1, 2, 3], "Shift-click extends from the current page");
h.get_by_label("Page 2").click_modifiers(Modifiers::COMMAND);
h.run_steps(2);
assert_eq!(h.state().views[0].target_pages(), [2, 3], "command-click still toggles a page");
h.key_press(Key::Escape);
h.run_steps(2);
assert!(h.state().views[0].selected.is_empty());
}
#[test]
fn organize_select_all_applies_operations_to_every_page() {
let mut h = organize(3);
h.key_press_modifiers(Modifiers::COMMAND, Key::A);
h.run_steps(2);
assert_eq!(h.state().views[0].target_pages(), [0, 1, 2]);
h.get_by_label("Rotate clockwise").click();
h.run_steps(3);
let app = h.state();
let rotations: Vec<_> = app.session.get(app.views[0].id).unwrap().info.pages.iter().map(|p| p.rotation).collect();
assert_eq!(rotations, [90, 90, 90]);
}
#[test]
fn organize_select_all_works_without_page_editing_permission() {
let mut h = harness(3, |app| {
app.apply_edit(pdfcraft_engine::Edit::Protect(pdfcraft_engine::Protection {
permissions_password: Some("owner".into()),
changes: pdfcraft_engine::Changes::None,
..Default::default()
}));
let bytes = app.session.save_bytes(app.views[0].id).unwrap();
app.open_bytes("restricted.pdf", None, bytes.as_ref().clone()).unwrap();
app.set_option("organize", "on").unwrap();
});
let index = h.state().active.unwrap();
assert!(!h.state().session.get(h.state().views[index].id).unwrap().allows_assembly());
h.key_press_modifiers(Modifiers::COMMAND, Key::A);
h.run_steps(2);
assert_eq!(h.state().views[index].target_pages(), [0, 1, 2]);
assert!(!h.state().session.get(h.state().views[index].id).unwrap().dirty);
let mut h = organize(1);
h.key_press_modifiers(Modifiers::COMMAND, Key::A);
h.run_steps(2);
assert_eq!(h.state().views[0].target_pages(), [0]);
assert_eq!(h.state().views[0].selected.len(), 1);
// The opener refuses zero-page PDFs, but the view method also handles empty geometry.
let mut empty = pdfcraft_ui_egui::canvas::DocView::new(pdfcraft_engine::DocId(0), &Default::default(), Default::default());
empty.organize = true;
assert!(!empty.select_all());
assert!(empty.selected.is_empty());
}
#[test]
fn organize_select_all_leaves_focused_text_input_and_dialogs_alone() {
let mut h = organize(3);
h.query_all_by_value("1").next().expect("current page input").focus();
h.run_steps(1);
h.key_press_modifiers(Modifiers::COMMAND, Key::A);
h.run_steps(1);
h.query_all_by_value("1").next().expect("current page input").type_text("2");
h.run_steps(2);
assert_eq!(h.state().views[0].page_input, "2", "Ctrl/Cmd+A selected the field's text");
assert!(h.state().views[0].selected.is_empty());
h.key_press(Key::Enter);
h.run_steps(2);
assert_eq!(h.state().views[0].current, 1);
h.state_mut().execute("help.about");
h.run_steps(3);
h.key_press_modifiers(Modifiers::COMMAND, Key::A);
h.run_steps(2);
assert!(h.state().views[0].selected.is_empty(), "a modal dialog isolates the page selection");
h.state_mut().dialog = None;
h.state_mut().execute("view.palette");
h.run_steps(3);
h.key_press_modifiers(Modifiers::COMMAND, Key::A);
h.run_steps(2);
assert!(h.state().views[0].selected.is_empty(), "the palette keeps its own keyboard input");
}
#[test]
fn delete_is_refused_when_it_would_remove_every_page() {
let mut h = organize(2);
h.state_mut().views[0].select_pages(&[0, 1]);
h.run_steps(2);
h.key_press(Key::Delete);
h.run_steps(3);
assert_eq!(page_texts(h.state()).len(), 2, "a document keeps at least one page");
assert!(!dirty(&h));
}
#[test]
fn insert_blank_page_after_selection() {
let mut h = organize(2);
h.get_by_label("Page 1").click();
h.run_steps(1);
h.get_by_label("Insert a blank page after the selection").click();
h.run_steps(3);
assert_eq!(page_texts(h.state()), ["Page 1", "", "Page 2"]);
let app = h.state();
let info = &app.session.get(app.views[0].id).unwrap().info;
assert_eq!((info.pages[1].width, info.pages[1].height), (200.0, 300.0), "matches the neighbouring page");
}
#[test]
fn save_writes_an_incremental_update_and_clears_dirty() {
let out = temp_path("saved.pdf");
let original = fixture(3);
let target = out.to_string_lossy().into_owned();
let mut h = organize(3);
h.state_mut().save_override = Some(target.clone());
h.get_by_label("Page 3").click();
h.run_steps(1);
h.get_by_label("Rotate counterclockwise").click();
h.run_steps(3);
h.key_press_modifiers(Modifiers::COMMAND, Key::S);
h.run_steps(3);
let saved = std::fs::read(&out).expect("file written");
assert_eq!(&saved[..original.len()], &original[..], "original bytes untouched");
assert!(!dirty(&h));
h.get_by_label("saved.pdf"); // the tab takes the new name, no edited marker
// What was written is what the app now shows.
assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().bytes.as_slice(), saved.as_slice());
let reopened = pdfcraft_render::inspect(std::sync::Arc::new(saved), None).unwrap();
assert_eq!(reopened.pages[2].rotation, 270);
let _ = std::fs::remove_file(out);
}
#[test]
fn closing_a_dirty_tab_asks_and_cancel_keeps_it() {
let mut h = organize(2);
h.get_by_label("Rotate clockwise").click();
h.run_steps(3);
h.key_press_modifiers(Modifiers::COMMAND, Key::W);
h.run_steps(3);
h.get_by_label_contains("Save changes to “doc.pdf”");
h.get_by_label("Cancel").click();
h.run_steps(3);
assert_eq!(h.state().views.len(), 1, "cancel keeps the document open");
assert!(dirty(&h));
assert!(h.state().close_request.is_none());
h.key_press_modifiers(Modifiers::COMMAND, Key::W);
h.run_steps(3);
h.get_by_label("Don't save").click();
h.run_steps(3);
assert!(h.state().views.is_empty(), "discarding closes the tab");
}
#[test]
fn closing_a_dirty_tab_can_save_first() {
let out = temp_path("closed.pdf");
let mut h = organize(2);
h.state_mut().save_override = Some(out.to_string_lossy().into_owned());
h.get_by_label("Rotate clockwise").click();
h.run_steps(3);
h.state_mut().request_close_tab(0);
h.run_steps(2);
h.get_by_label("Save").click();
h.run_steps(3);
assert!(h.state().views.is_empty());
let saved = pdfcraft_render::inspect(std::sync::Arc::new(std::fs::read(&out).unwrap()), None).unwrap();
assert_eq!(saved.pages[0].rotation, 90);
let _ = std::fs::remove_file(out);
}
#[test]
fn the_save_prompt_answers_to_the_keyboard() {
// Issue #8: Enter saves; ⌘D / Ctrl+D, Alt+D and Alt+N don't save; Escape cancels. While the
// prompt is open, ⌘D is not Document properties.
for (m, key) in [(Modifiers::COMMAND, Key::D), (Modifiers::ALT, Key::D), (Modifiers::ALT, Key::N)] {
let mut h = organize(2);
h.get_by_label("Rotate clockwise").click();
h.run_steps(3);
h.key_press_modifiers(Modifiers::COMMAND, Key::W);
h.run_steps(3);
h.get_by_label_contains("Save changes to “doc.pdf”");
h.key_press_modifiers(m, key);
h.run_steps(3);
assert!(h.state().views.is_empty(), "{m:?}+{key:?} doesn't save and closes");
assert!(h.state().dialog.is_none(), "{m:?}+{key:?} ran no command underneath");
}
let mut h = organize(2);
h.get_by_label("Rotate clockwise").click();
h.run_steps(3);
h.key_press_modifiers(Modifiers::COMMAND, Key::W);
h.run_steps(3);
h.key_press(Key::Escape);
h.run_steps(3);
assert_eq!(h.state().views.len(), 1, "Escape cancels");
assert!(h.state().close_request.is_none());
let out = temp_path("enter-saves.pdf");
h.state_mut().save_override = Some(out.to_string_lossy().into_owned());
h.key_press_modifiers(Modifiers::COMMAND, Key::W);
h.run_steps(3);
h.key_press(Key::Enter);
h.run_steps(3);
assert!(h.state().views.is_empty(), "Enter saves and closes");
let saved = pdfcraft_render::inspect(std::sync::Arc::new(std::fs::read(&out).unwrap()), None).unwrap();
assert_eq!(saved.pages[0].rotation, 90);
let _ = std::fs::remove_file(out);
}
#[test]
fn keys_leave_the_document_under_a_dialog_alone() {
// Issue #870: ⌘W with Preferences open closed the file underneath it, and in the page grid
// Delete deleted the selected page. While a dialog is open its keys are its own.
let mut h = organize(3);
h.get_by_label("Page 2").click();
h.run_steps(2);
h.get_by_label("Rotate clockwise").click();
h.run_steps(3);
assert!(h.state_mut().execute("app.preferences"));
h.run_steps(3);
let shift = Modifiers::COMMAND | Modifiers::SHIFT;
for (m, key) in [
(Modifiers::COMMAND, Key::W),
(shift, Key::W),
(Modifiers::COMMAND, Key::Z),
(Modifiers::COMMAND, Key::D),
(Modifiers::COMMAND, Key::F),
(Modifiers::NONE, Key::Delete),
(Modifiers::NONE, Key::Backspace),
] {
h.key_press_modifiers(m, key);
h.run_steps(3);
let app = h.state();
assert_eq!(app.views.len(), 1, "{m:?}+{key:?} closed the file");
assert!(app.close_request.is_none(), "{m:?}+{key:?} asked to close the file");
assert!(app.session.get(app.views[0].id).unwrap().can_undo().is_some(), "{m:?}+{key:?} undid the rotation");
assert!(app.views[0].find.is_none(), "{m:?}+{key:?} opened Find");
assert_eq!(app.dialog, Some(Dialog::Preferences), "{m:?}+{key:?} replaced Preferences");
}
assert_eq!(page_texts(h.state()), ["Page 1", "Page 2", "Page 3"], "a page was deleted under the dialog");
h.key_press(Key::Escape);
h.run_steps(3);
assert!(h.state().dialog.is_none(), "Escape closes Preferences");
assert_eq!(h.state().views[0].target_pages(), [1], "Escape went to the dialog, not the page selection");
h.key_press_modifiers(Modifiers::COMMAND, Key::W);
h.run_steps(3);
assert!(h.state().close_request.is_some(), "with the dialog closed, ⌘W closes the file again");
}
#[test]
fn clean_tabs_close_without_asking() {
let mut h = harness(1, |_| {});
h.key_press_modifiers(Modifiers::COMMAND, Key::W);
h.run_steps(3);
assert!(h.state().views.is_empty());
assert!(h.query_by_label("Don't save").is_none());
}
#[test]
fn quitting_with_unsaved_changes_asks_for_each_document() {
let mut h = harness(2, |app| {
app.open_bytes("second.pdf", None, fixture(1)).unwrap();
});
// Edit both documents.
for tab in 0..2 {
h.state_mut().active = Some(tab);
h.state_mut().views[tab].select_pages(&[0]);
h.state_mut().apply_edit(pdfcraft_engine::Edit::RotatePages { pages: vec![0], degrees: 90 });
}
h.state_mut().close_request = Some(CloseRequest::Quit);
h.run_steps(3);
h.get_by_label_contains("Save changes to “doc.pdf”");
h.get_by_label("Don't save").click();
h.run_steps(3);
h.get_by_label_contains("Save changes to “second.pdf”");
h.get_by_label("Don't save").click();
h.run_steps(3);
assert!(h.state().views.is_empty());
assert!(h.state().close_request.is_none());
}
/// PDFs written to a fresh temp folder, removed when the test ends.
struct TempPdfs(std::path::PathBuf);
impl TempPdfs {
fn new(test: &str) -> Self {
let dir = std::env::temp_dir().join(format!("pdfcraft-session-{test}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
Self(dir)
}
fn file(&self, name: &str, pages: usize) -> String {
let path = self.0.join(name);
std::fs::write(&path, fixture(pages)).unwrap();
path.to_string_lossy().into_owned()
}
}
impl Drop for TempPdfs {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
/// The app with Preferences ▸ Reopen the files that were open when PdfCraft last closed on.
fn session_harness(setup: impl FnOnce(&mut PdfCraftApp) + 'static) -> Harness<'static, PdfCraftApp> {
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.reopen_last_session = true;
setup(&mut app);
app
});
h.run_steps(4);
h
}
fn open_paths(app: &PdfCraftApp) -> Vec<String> {
app.views.iter().map(|v| app.session.get(v.id).and_then(|d| d.path.clone()).unwrap_or_default()).collect()
}
#[test]
fn the_last_session_reopens_its_files_pages_and_active_tab() {
let dir = TempPdfs::new("reopen");
let files = [dir.file("a.pdf", 1), dir.file("b.pdf", 5), dir.file("c.pdf", 2)];
let opened = files.clone();
let mut h = session_harness(move |app| opened.iter().for_each(|f| app.open_path(f)));
h.state_mut().views[1].go_to_page(3);
h.state_mut().active = Some(1);
h.run_steps(4);
assert_eq!(h.state().views[1].current, 3, "b.pdf shows page 4");
let settings = h.state().persist();
let mut h = session_harness(move |app| {
app.restore(&settings);
app.reopen_last_files(&[]);
});
h.run_steps(4);
let app = h.state();
assert_eq!(open_paths(app), files, "the same files, in the same order");
assert_eq!(app.active, Some(1), "b.pdf is in front again");
assert_eq!(app.views[1].current, 3, "at the page it showed");
}
#[test]
fn quitting_with_unsaved_changes_still_remembers_every_file() {
let dir = TempPdfs::new("quit");
let files = [dir.file("a.pdf", 1), dir.file("b.pdf", 1)];
let opened = files.clone();
let mut h = session_harness(move |app| opened.iter().for_each(|f| app.open_path(f)));
for tab in 0..2 {
h.state_mut().active = Some(tab);
h.state_mut().views[tab].select_pages(&[0]);
h.state_mut().apply_edit(pdfcraft_engine::Edit::RotatePages { pages: vec![0], degrees: 90 });
}
h.state_mut().active = Some(1);
h.state_mut().close_request = Some(CloseRequest::Quit);
h.run_steps(3);
// Each unsaved tab closes as it is answered, before PdfCraft quits.
for _ in 0..2 {
h.get_by_label("Don't save").click();
h.run_steps(3);
}
assert!(h.state().views.is_empty());
let mut app = PdfCraftApp::new();
app.restore(&h.state().persist());
app.reopen_last_files(&[]);
assert_eq!(open_paths(&app), files, "both files come back");
assert_eq!(app.active, Some(1), "with b.pdf in front, as when the quit began");
}
#[test]
fn the_last_session_is_off_by_default_and_skips_missing_and_duplicate_files() {
let dir = TempPdfs::new("skip");
let (a, b) = (dir.file("a.pdf", 1), dir.file("b.pdf", 1));
let mut app = PdfCraftApp::new();
app.open_path(&a);
app.open_path(&b);
let off: serde_json::Value = serde_json::from_str(&app.persist()).unwrap();
assert!(off["last_session"].is_null(), "nothing is kept while the preference is off");
app.reopen_last_session = true;
let settings = app.persist();
let mut later = PdfCraftApp::new();
later.restore(&settings);
later.reopen_last_session = false;
later.reopen_last_files(&[]);
assert!(later.views.is_empty(), "turned off again: nothing reopens");
// b.pdf is gone, and a.pdf is about to open from the command line.
std::fs::remove_file(&b).unwrap();
let mut later = PdfCraftApp::new();
later.restore(&settings);
later.reopen_last_files(&[a]);
assert!(later.views.is_empty(), "neither opens from the session");
}
#[test]
fn save_prompt_stays_inside_the_screen_for_a_long_filename() {
// Issue #161: an unwrapped title carrying a long filename widened the centered modal past
// the viewport, clipping the message and pushing the Save/Cancel buttons off-screen.
let name = "Psychology_ The Science of Mind and Behaviour, -- Nigel Holt, Andy Bremner, Michael \
Vliek, Ed Sutherland, -- 5, 2024 -- McGraw-Hill Education (UK) Ltd -- isbn13 97815268.pdf";
let mut h = Harness::builder().with_size(egui::vec2(1365.0, 719.0)).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes(name, None, fixture(1)).expect("fixture opens");
app.close_request = Some(CloseRequest::Tab(app.views[0].id));
app
});
h.run_steps(4);
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1365.0, 719.0));
let inside = |r: egui::Rect| screen.contains(r.min) && screen.contains(r.max);
let title = h.get_by_label_contains("Save changes to");
let title_rect = title.rect();
assert!(inside(title_rect), "the title rect {title_rect:?} leaves the screen");
for button in ["Save", "Cancel", "Don't save"] {
let rect = h.get_by_label(button).rect();
assert!(inside(rect), "the {button} button rect {rect:?} leaves the screen");
}
}
#[test]
fn save_prompt_fits_the_smallest_window_whatever_the_name() {
// Issue #236: wrapping (#161) kept the prompt narrow, but a long enough name still grew it
// taller than the window and pushed the buttons off-screen. On the web a `?file=` URL names
// the document, so the name has no length limit. Long names now give way in the middle.
let names = [
format!("{}.pdf", "a".repeat(400)),
format!("{}.pdf", "Quarterly_Report_FY2026_Final_v3_".repeat(12)),
format!("{}.pdf", "รายงานประจำปีงบประมาณ".repeat(15)),
format!("{}.pdf", "年".repeat(251)),
format!("{}.pdf", "W".repeat(2000)),
];
// The desktop window's minimum inner size (apps/pdfcraft/src/main.rs).
let size = egui::vec2(820.0, 520.0);
for name in names {
let start: String = name.chars().take(10).collect();
let mut h = Harness::builder().with_size(size).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes(&name, None, fixture(1)).expect("fixture opens");
app.close_request = Some(CloseRequest::Tab(app.views[0].id));
app
});
h.run_steps(4);
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, size);
let inside = |r: egui::Rect| screen.contains(r.min) && screen.contains(r.max);
let title = h.get_by_label_contains("Save changes to");
let title_rect = title.rect();
assert!(inside(title_rect), "{start}…: the title rect {title_rect:?} leaves the screen");
let opening = format!("Save changes to “{start}");
assert!(
h.query_by(|n| n.value().is_some_and(|l| l.starts_with(&opening) && l.contains('…') && l.contains(".pdf” before closing?"))).is_some(),
"{start}…: the title keeps the start and the extension"
);
for button in ["Save", "Cancel", "Don't save"] {
let rect = h.get_by_label(button).rect();
assert!(inside(rect), "{start}…: the {button} button rect {rect:?} leaves the screen");
}
}
}
#[test]
fn document_properties_edit_is_one_undoable_step() {
let mut h = harness(1, |app| app.set_option("dialog", "properties").unwrap());
let title = h.get_by_role_and_label(Role::TextInput, "Title");
title.focus();
title.type_text("Annual report");
h.run_steps(2);
let author = h.get_by_role_and_label(Role::TextInput, "Author");
author.focus();
author.type_text("Finance team");
h.run_steps(2);
h.get_by_label("OK").click();
h.run_steps(3);
let app = h.state();
let doc = app.session.get(app.views[0].id).unwrap();
assert_eq!(doc.info.title.as_deref(), Some("Annual report"), "inspection sees the new title");
assert_eq!(doc.info_value("Author").as_deref(), Some("Finance team"));
assert_eq!(doc.can_undo(), Some("Change document properties"));
assert!(app.dialog.is_none());
h.key_press_modifiers(Modifiers::COMMAND, Key::Z);
h.run_steps(3);
let app = h.state();
let doc = app.session.get(app.views[0].id).unwrap();
assert_eq!(doc.info.title, None);
assert_eq!(doc.info_value("Author"), None);
}
#[test]
fn cancelling_properties_discards_the_draft() {
let mut h = harness(1, |app| app.set_option("dialog", "properties").unwrap());
let title = h.get_by_role_and_label(Role::TextInput, "Title");
title.focus();
title.type_text("Draft");
h.run_steps(2);
h.get_by_label("Cancel").click();
h.run_steps(3);
assert!(!dirty(&h));
assert!(h.state().props_draft.is_none());
}
#[test]
fn edit_menu_names_the_step_to_undo() {
let mut h = harness(2, |app| {
app.apply_edit(pdfcraft_engine::Edit::RotatePages { pages: vec![0], degrees: 90 });
});
h.get_by_label("Menu").click();
h.run_steps(2);
h.get_by_label("Edit ⏵").hover(); // submenus open on hover; their labels carry the arrow
h.run_steps(3);
h.get_by_label_contains("Undo Rotate page").click(); // the label includes the shortcut
h.run_steps(3);
let app = h.state();
let doc = app.session.get(app.views[0].id).unwrap();
assert_eq!(doc.can_redo(), Some("Rotate page"));
assert_eq!(doc.info.pages[0].rotation, 0);
}
// ── Combine / insert from file / extract / split ──────────────────────────────────────────────
fn texts_of(app: &PdfCraftApp, tab: usize) -> Vec<String> {
let doc = app.session.get(app.views[tab].id).unwrap();
let mut r = PageRenderer::new(doc.bytes.clone(), Default::default());
(0..r.page_count())
.map(|p| {
let out = r.render(RenderRequest { page: p, kind: RequestKind::Text, scale: 1.0, ..Default::default() });
out.text.map(|t| t.plain_text().trim().to_string()).unwrap_or_default()
})
.collect()
}
#[test]
fn combining_files_opens_a_new_unsaved_tab() {
let mut h = harness(1, |app| {
app.use_files(pdfcraft_ui_egui::FilePurpose::Combine, vec![("one.pdf".into(), fixture(2)), ("two.pdf".into(), fixture(1))]);
});
h.run_steps(3);
h.get_by_label("Combine").click();
h.run_steps(3);
let app = h.state();
assert_eq!(app.views.len(), 2);
assert_eq!(app.active, Some(1));
assert_eq!(texts_of(app, 1), ["Page 1", "Page 2", "Page 1"]);
let doc = app.session.get(app.views[1].id).unwrap();
assert_eq!(doc.name, "Combined.pdf");
assert!(doc.dirty && doc.path.is_none(), "unsaved until the user saves it");
assert_eq!(doc.info.outline.iter().map(|o| o.title.as_str()).collect::<Vec<_>>(), ["one", "two"]);
h.get_by_label("Combined.pdf (edited)");
}
#[test]
fn combine_files_takes_chosen_pages_in_the_order_listed() {
let mut h = harness(1, |app| {
app.set_option("combine-view", "list").unwrap();
app.use_files(pdfcraft_ui_egui::FilePurpose::Combine, vec![("one.pdf".into(), fixture(3)), ("two.pdf".into(), fixture(2))]);
});
h.run_steps(3);
h.get_by_label_contains("Files are combined from top to bottom");
// Nothing is selected yet: the toolbar's Move up is there but disabled.
assert!(h.get_by_label("Move up").accesskit_node().is_disabled());
// Select two.pdf with the keyboard and move it up: two.pdf first; one.pdf's pages 3 and 1.
h.key_press(Key::ArrowUp);
h.run_steps(2);
assert_eq!(h.state().combine_selection(), [1]);
h.get_by_label("Move up").click();
h.run_steps(2);
assert_eq!(h.state().combine_draft[0].name, "two.pdf");
assert_eq!(h.state().combine_selection(), [0], "the selection follows the moved file");
h.state_mut().combine_draft[1].range = "3, 1".into();
h.run_steps(2);
h.get_by_label("2 of 3");
h.get_by_label("Combine").click();
h.run_steps(3);
let app = h.state();
assert_eq!(texts_of(app, 1), ["Page 1", "Page 2", "Page 3", "Page 1"]);
let doc = app.session.get(app.views[1].id).unwrap();
assert_eq!(doc.info.outline.iter().map(|o| o.title.as_str()).collect::<Vec<_>>(), ["two", "one"]);
assert!(app.combine_draft.is_empty());
assert!(!app.combine_tab.open, "the tab makes way for the result");
}
#[test]
fn combine_files_opens_in_its_own_tab() {
let mut h = harness(1, |_| {});
h.state_mut().execute("page.combine");
h.run_steps(3);
assert!(h.state().combine_showing() && h.state().active.is_none());
h.get_by_label("Add the files to combine");
// Back to the document, then to Combine through its tab.
h.get_by_label("doc.pdf").click();
h.run_steps(3);
assert_eq!(h.state().active, Some(0));
assert!(!h.state().combine_showing() && h.state().combine_tab.open);
// The tab, in the tab strip (All tools lists Combine files too).
h.get_all_by_label("Combine files").find(|n| n.rect().top() < 40.0).unwrap().click();
h.run_steps(3);
assert!(h.state().combine_showing());
// Home hides it too.
h.get_by_label("Home").click();
h.run_steps(3);
assert!(!h.state().combine_showing() && h.state().combine_tab.open);
}
#[test]
fn closing_the_combine_tab_forgets_its_list() {
let mut h = harness(1, |app| {
app.use_files(pdfcraft_ui_egui::FilePurpose::Combine, vec![("one.pdf".into(), fixture(1))]);
});
h.run_steps(3);
assert!(h.state().combine_showing());
h.state_mut().close_combine_tab();
h.run_steps(3);
let app = h.state();
assert!(app.combine_draft.is_empty() && !app.combine_tab.open);
assert_eq!(app.active, Some(0), "the document next to it shows");
}
#[test]
fn combine_lists_size_and_warns_before_combining() {
let mut h = harness(1, |app| {
app.set_option("combine-view", "list").unwrap();
app.use_files(
pdfcraft_ui_egui::FilePurpose::Combine,
vec![
("one.pdf".into(), fixture(3)),
("locked.pdf".into(), protected("", "owner", -1 ^ 1024 ^ 8)),
("secret.pdf".into(), protected("pw", "owner", -1)),
],
);
});
h.run_steps(3);
h.get_by_label("File name");
h.get_by_label("Warnings");
h.get_by_label("Its security settings don't allow copying pages");
h.get_by_label("Password-protected");
h.get_by_label("2 files need attention");
assert!(h.get_by_label("Combine").accesskit_node().is_disabled());
// Removing them (Delete on the selection) lets Combine run.
for _ in 0..2 {
h.state_mut().select_combine_rows(&[1]);
h.key_press(Key::Delete);
h.run_steps(2);
}
assert_eq!(h.state().combine_draft.len(), 1);
h.get_by_label_contains("1 file · 3 pages");
}
#[test]
fn a_bad_range_stops_combine_until_fixed() {
let mut h = harness(1, |app| {
app.use_files(pdfcraft_ui_egui::FilePurpose::Combine, vec![("one.pdf".into(), fixture(3)), ("two.pdf".into(), fixture(2))]);
});
h.run_steps(3);
h.get_by_label_contains("2 files · 5 pages");
h.state_mut().combine_draft[0].range = "1-99".into();
h.run_steps(2);
h.get_by_label("1 file needs attention");
assert!(h.get_by_label("Combine").accesskit_node().is_disabled());
h.state_mut().combine_draft[0].range = "2-3".into();
h.run_steps(2);
h.get_by_label_contains("2 files · 4 pages");
assert!(!h.get_by_label("Combine").accesskit_node().is_disabled());
}
#[test]
fn alt_arrows_move_the_selected_file() {
let mut h = harness(1, |app| {
app.use_files(
pdfcraft_ui_egui::FilePurpose::Combine,
vec![("a.pdf".into(), fixture(1)), ("b.pdf".into(), fixture(1)), ("c.pdf".into(), fixture(1))],
);
});
h.run_steps(3);
h.key_press(Key::ArrowDown);
h.run_steps(1);
h.key_press_modifiers(Modifiers::ALT, Key::ArrowDown);
h.run_steps(2);
let names: Vec<_> = h.state().combine_draft.iter().map(|f| f.name.clone()).collect();
assert_eq!(names, ["b.pdf", "a.pdf", "c.pdf"]);
assert_eq!(h.state().combine_selection(), [1]);
}
fn combine_names(h: &Harness<'static, PdfCraftApp>) -> Vec<String> {
h.state().combine_draft.iter().map(|f| f.name.clone()).collect()
}
/// Files in the Combine tab's list view (the table: headings, page ranges, row links).
fn combine_of(names: &[(&str, usize)]) -> Harness<'static, PdfCraftApp> {
let files: Vec<(String, Vec<u8>)> = names.iter().map(|(n, p)| (n.to_string(), fixture(*p))).collect();
let mut h = harness(1, move |app| {
app.set_option("combine-view", "list").unwrap();
app.use_files(pdfcraft_ui_egui::FilePurpose::Combine, files);
});
h.run_steps(3);
h
}
#[test]
fn column_headings_sort_the_list_and_flip_on_the_second_click() {
let mut h = combine_of(&[("scan10.pdf", 1), ("Scan2.pdf", 3), ("a.pdf", 2)]);
h.get_by_label("File name").click();
h.run_steps(2);
assert_eq!(combine_names(&h), ["a.pdf", "Scan2.pdf", "scan10.pdf"], "case-insensitive, numbers by value");
h.get_by_label("File name, sorted ascending").click();
h.run_steps(2);
assert_eq!(combine_names(&h), ["scan10.pdf", "Scan2.pdf", "a.pdf"]);
h.get_by_label("Pages").click();
h.run_steps(2);
assert_eq!(combine_names(&h), ["scan10.pdf", "a.pdf", "Scan2.pdf"]);
// Moving a file by hand ends the sorted order.
h.state_mut().select_combine_rows(&[2]);
h.get_by_label("Move up").click();
h.run_steps(2);
assert_eq!(h.state().combine_tab.sort, None);
h.get_by_label("Pages");
}
#[test]
fn ctrl_and_shift_clicks_select_several_files_that_move_and_go_together() {
let mut h = combine_of(&[("a.pdf", 1), ("b.pdf", 1), ("c.pdf", 1), ("d.pdf", 1), ("e.pdf", 1)]);
h.get_by_label("b.pdf").click();
h.run_steps(1);
h.get_by_label("d.pdf").click_modifiers(Modifiers::SHIFT);
h.run_steps(1);
assert_eq!(h.state().combine_selection(), [1, 2, 3]);
h.get_by_label("c.pdf").click_modifiers(Modifiers::COMMAND);
h.run_steps(1);
assert_eq!(h.state().combine_selection(), [1, 3], "Ctrl/⌘ toggles one file");
h.get_by_label("2 selected");
h.get_by_label("Move up").click();
h.run_steps(2);
assert_eq!(combine_names(&h), ["b.pdf", "a.pdf", "d.pdf", "c.pdf", "e.pdf"]);
h.get_by_label("Remove 2 files").click();
h.run_steps(2);
assert_eq!(combine_names(&h), ["a.pdf", "c.pdf", "e.pdf"]);
h.key_press_modifiers(Modifiers::COMMAND, Key::A);
h.run_steps(1);
assert_eq!(h.state().combine_selection(), [0, 1, 2]);
}
#[test]
fn undo_and_redo_restore_the_combine_list() {
let mut h = combine_of(&[("a.pdf", 1), ("b.pdf", 1), ("c.pdf", 1)]);
h.state_mut().select_combine_rows(&[0, 2]);
h.key_press(Key::Delete);
h.run_steps(2);
assert_eq!(combine_names(&h), ["b.pdf"]);
h.key_press_modifiers(Modifiers::COMMAND, Key::Z);
h.run_steps(2);
assert_eq!(combine_names(&h), ["a.pdf", "b.pdf", "c.pdf"], "⌘Z brings the removed files back");
assert_eq!(h.state().combine_selection(), [0, 2], "…selected as they were");
h.key_press_modifiers(Modifiers::COMMAND | Modifiers::SHIFT, Key::Z);
h.run_steps(2);
assert_eq!(combine_names(&h), ["b.pdf"]);
// The toolbar's buttons do the same; undo also reverses a sort and the files' addition.
h.get_by_label("Undo").click();
h.run_steps(2);
h.get_by_label("File name").click();
h.run_steps(2);
h.get_by_label("File name, sorted ascending").click();
h.run_steps(2);
assert_eq!(combine_names(&h), ["c.pdf", "b.pdf", "a.pdf"]);
h.get_by_label("Undo").click();
h.run_steps(2);
assert_eq!(combine_names(&h), ["a.pdf", "b.pdf", "c.pdf"]);
// A page range typed in is one step.
h.state_mut().combine_draft[0].range.clear();
let field = h.get_all_by_role(Role::TextInput).next().unwrap();
field.focus();
h.run_steps(1);
h.get_all_by_role(Role::TextInput).next().unwrap().type_text("1");
h.run_steps(2);
assert_eq!(h.state().combine_draft[0].range, "1");
h.key_press_modifiers(Modifiers::COMMAND, Key::Z);
h.run_steps(2);
assert_eq!(h.state().combine_draft[0].range, "");
// The document's own history is untouched: it has nothing to undo.
h.get_by_label("doc.pdf").click();
h.run_steps(2);
assert!(h.state().session.get(h.state().views[0].id).unwrap().can_undo().is_none());
}
#[test]
fn protected_files_that_can_be_combined_are_flagged() {
let mut h = harness(1, |app| {
app.set_option("combine-view", "list").unwrap();
app.use_files(
pdfcraft_ui_egui::FilePurpose::Combine,
vec![("one.pdf".into(), fixture(1)), ("no-copy.pdf".into(), protected("", "owner", -1 ^ 16))],
);
});
h.run_steps(3);
let app = h.state();
assert!(app.combine_draft[1].problem.is_none(), "copying text is withheld, assembling pages isn't");
h.get_by_label("Protected: the combined file won't keep its security settings");
assert!(!h.get_by_label("Combine").accesskit_node().is_disabled(), "a warning, not a blocker");
}
#[test]
fn the_whole_heading_sorts_and_dragging_it_moves_the_column() {
let mut h = combine_of(&[("b.pdf", 1), ("a.pdf", 3)]);
// A click in the empty right part of the heading cell, away from its text.
let cell = h.get_by_label("File name").rect();
let spot = egui::pos2(cell.right() - 30.0, cell.center().y);
h.hover_at(spot);
h.run_steps(1);
h.drag_at(spot);
h.run_steps(1);
h.drop_at(spot);
h.run_steps(2);
assert_eq!(combine_names(&h), ["a.pdf", "b.pdf"]);
// Drag Size before File name.
let size = h.get_by_label("Size").rect().center();
let name = h.get_by_label("File name, sorted ascending").rect();
drag(&mut h, size, egui::pos2(name.left() + 10.0, name.center().y));
use pdfcraft_ui_egui::SortKey::*;
assert_eq!(h.state().combine_columns.order, [Size, Name, Pages, Modified, Warnings]);
assert_eq!(combine_names(&h), ["a.pdf", "b.pdf"], "moving a column doesn't sort");
let (size, name) = (h.get_by_label("Size").rect(), h.get_by_label("File name, sorted ascending").rect());
assert!(size.left() < name.left(), "Size now shows first");
}
#[test]
fn columns_resize_and_the_layout_is_kept_in_the_settings() {
let mut h = combine_of(&[("a.pdf", 1), ("b.pdf", 1)]);
let size = h.get_by_label("Size").rect();
// The separator after Size: drag it right by 60 points.
let edge = egui::pos2(size.right() + 2.0, size.center().y);
drag(&mut h, edge, edge + egui::vec2(60.0, 0.0));
let widths = h.state().combine_columns.widths.expect("a resized column keeps its width");
let size_index = pdfcraft_ui_egui::SortKey::Size as usize;
assert!(widths[size_index] > 120.0, "{widths:?}");
let saved = h.state().persist();
let mut fresh = PdfCraftApp::new();
fresh.set_option("language", "en").unwrap();
fresh.restore(&saved);
assert_eq!(fresh.combine_columns, h.state().combine_columns);
// Malformed settings give the default layout.
fresh.restore(r#"{"combine_columns":{"order":["size","size"],"widths":[1,2]}}"#);
assert_eq!(fresh.combine_columns, Default::default());
}
#[test]
fn a_folder_adds_its_pdfs_images_and_text_in_order_and_optionally_its_subfolders() {
let dir = std::env::temp_dir().join(format!("pdfcraft-combine-folder-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("sub")).unwrap();
for (name, pages) in [("page10.pdf", 1), ("page2.PDF", 2), ("sub/inner.pdf", 3)] {
std::fs::write(dir.join(name), fixture(pages)).unwrap();
}
// #569: images and text files join too; other files are left out.
std::fs::write(dir.join("notes.txt"), b"Some notes").unwrap();
std::fs::write(dir.join("readme.md"), b"# not combinable").unwrap();
let mut h = harness(1, |_| {});
h.state_mut().execute("page.combine");
h.run_steps(2);
h.state_mut().pick_override = Some(vec![dir.to_string_lossy().into_owned()]);
h.get_by_label("Add folder…").click();
h.run_steps(4);
assert_eq!(combine_names(&h), ["notes.txt", "page2.PDF", "page10.pdf"], "numbers by value");
// The toolbar's menu: the folder and its subfolders, as one undo step.
h.get_by_label("More ways to add files").click();
h.run_steps(2);
h.get_by_label("Add folder and subfolders…").click();
h.run_steps(4);
assert_eq!(combine_names(&h), ["notes.txt", "page2.PDF", "page10.pdf", "notes.txt", "page2.PDF", "page10.pdf", "inner.pdf"]);
h.key_press_modifiers(Modifiers::COMMAND, Key::Z);
h.run_steps(2);
assert_eq!(combine_names(&h).len(), 3);
let _ = std::fs::remove_dir_all(&dir);
}
/// #569: an image or a text file added to Combine files becomes a PDF as it joins the list, an
/// ordinary row with its pages.
#[test]
fn images_and_text_files_join_the_combine_list_as_pdfs() {
let mut png = std::io::Cursor::new(Vec::new());
image::RgbImage::from_pixel(40, 20, image::Rgb([90, 120, 200])).write_to(&mut png, image::ImageFormat::Png).unwrap();
let mut h = combine_of(&[("one.pdf", 2)]);
h.state_mut()
.use_files(pdfcraft_ui_egui::FilePurpose::Combine, vec![("scan.png".into(), png.into_inner()), ("notes.txt".into(), b"Some notes".to_vec())]);
h.run_steps(3);
assert_eq!(combine_names(&h), ["one.pdf", "scan.png", "notes.txt"]);
let draft = &h.state().combine_draft;
assert!(draft.iter().all(|f| f.bytes.starts_with(b"%PDF-") && f.problem.is_none()), "made into PDFs");
assert_eq!(draft.iter().map(|f| f.pages).collect::<Vec<_>>(), [2, 1, 1]);
h.get_by_label("Combine").click();
h.run_steps(3);
assert_eq!(texts_of(h.state(), 1), ["Page 1", "Page 2", "", "Some notes"]);
}
/// A file that can't be read (a damaged image, a type that can't be made into a PDF) joins the
/// list all the same, with why; Combine asks before leaving it out, and Cancel changes nothing.
#[test]
fn files_that_cannot_be_read_are_listed_and_left_out_once_the_user_agrees() {
let mut h = combine_of(&[("one.pdf", 2)]);
let bad = vec![("broken.jpg".to_string(), b"\xff\xd8\xff\xe0 not a JPEG".to_vec()), ("report.docx".to_string(), b"PK\x03\x04".to_vec())];
h.state_mut().use_files(pdfcraft_ui_egui::FilePurpose::Combine, bad);
h.run_steps(3);
assert_eq!(combine_names(&h), ["one.pdf", "broken.jpg", "report.docx"]);
for f in &h.state().combine_draft[1..] {
let why = f.problem.as_deref().expect("why it can't be combined");
assert!(why.starts_with("Can't be read: ") && !why.contains(&f.name), "{why}");
assert!(f.lock.is_none() && f.pages == 0);
}
h.get_by_label_contains("this file type can't be converted");
// One readable file is not enough to combine.
assert!(h.get_by_label("Combine").accesskit_node().is_disabled());
h.state_mut().use_files(pdfcraft_ui_egui::FilePurpose::Combine, vec![("two.pdf".into(), fixture(1))]);
h.run_steps(3);
assert!(!h.get_by_label("Combine").accesskit_node().is_disabled());
// Combine asks first, naming the files left out; Cancel leaves everything as it was.
h.get_by_label("Combine").click();
h.run_steps(3);
h.get_by_label("Leave out files that can't be read?");
h.get_by_label("• broken.jpg");
h.get_by_label("• report.docx");
h.get_by_label("Cancel").click();
h.run_steps(3);
assert!(h.query_by_label("Leave out files that can't be read?").is_none());
assert_eq!(h.state().views.len(), 1, "nothing combined");
assert_eq!(combine_names(&h).len(), 4);
// Asked again: Combine the rest.
h.get_by_label("Combine").click();
h.run_steps(3);
h.get_by_label("Combine the rest").click();
h.run_steps(3);
assert_eq!(texts_of(h.state(), 1), ["Page 1", "Page 2", "Page 1"]);
}
fn type_password(h: &mut Harness<'static, PdfCraftApp>, password: &str) {
let field = h.get_by_role(Role::PasswordInput);
field.focus();
field.type_text(password);
h.run_steps(1);
h.key_press(Key::Enter);
h.run_steps(3);
}
#[test]
fn a_password_protected_file_is_unlocked_with_its_password_and_combined() {
let mut h = combine_of(&[("one.pdf", 1)]);
h.state_mut().use_files(pdfcraft_ui_egui::FilePurpose::Combine, vec![("secret.pdf".into(), protected("pw", "owner", -1))]);
h.run_steps(3);
h.get_by_label("Password-protected");
assert!(h.get_by_label("Combine").accesskit_node().is_disabled());
// The row's link (the toolbar's Unlock… comes first).
h.get_all_by_label("Unlock…").last().unwrap().click();
h.run_steps(3);
h.get_by_label("Unlock secret.pdf");
type_password(&mut h, "nope");
h.get_by_label("Wrong password");
assert!(h.state().combine_draft[1].lock.is_some());
// The box stays open for another try.
let field = h.get_by_role(Role::PasswordInput);
field.focus();
h.key_press_modifiers(Modifiers::COMMAND, Key::A);
h.run_steps(1);
type_password(&mut h, "pw");
let f = &h.state().combine_draft[1];
assert!(f.lock.is_none() && f.problem.is_none());
assert_eq!(f.pages, 2);
h.get_by_label("Unlocked: the combined file won't be password-protected");
// Never printed: debug output ends up in logs.
assert!(!format!("{:?} {:?}", h.state().combine_draft, h.state().combine_tab).contains("pw\""));
h.get_by_label("Combine").click();
h.run_steps(3);
assert_eq!(texts_of(h.state(), 1), ["Page 1", "Page 1", "Page 2"]);
}
#[test]
fn one_password_unlocks_several_selected_files_and_undo_locks_them_again() {
let mut h = combine_of(&[("one.pdf", 1)]);
h.state_mut().use_files(
pdfcraft_ui_egui::FilePurpose::Combine,
vec![
("a.pdf".into(), protected("same", "o1", -1)),
("b.pdf".into(), protected("same", "o2", -1)),
("c.pdf".into(), protected("other", "o3", -1)),
],
);
h.run_steps(3);
h.state_mut().select_combine_rows(&[1, 2, 3]);
h.run_steps(1);
h.get_all_by_label("Unlock…").next().unwrap().click(); // the toolbar's (the rows' links come after it)
h.run_steps(3);
h.get_by_label("Unlock 3 files");
type_password(&mut h, "same");
let locked: Vec<bool> = h.state().combine_draft.iter().map(|f| f.lock.is_some()).collect();
assert_eq!(locked, [false, false, false, true]);
h.get_by_label_contains("Unlocked 2 of 3 files");
h.key_press_modifiers(Modifiers::COMMAND, Key::Z);
h.run_steps(2);
assert!(h.state().combine_draft[1..].iter().all(|f| f.lock.is_some()), "undo locks them again");
}
#[test]
fn a_file_that_opens_but_forbids_combining_takes_the_permissions_password() {
let mut h = combine_of(&[("one.pdf", 1)]);
// Opens with "pw"; only the owner may assemble pages.
h.state_mut().use_files(pdfcraft_ui_egui::FilePurpose::Combine, vec![("both.pdf".into(), protected("pw", "owner", -1 ^ 1024 ^ 8))]);
h.run_steps(3);
assert!(h.state_mut().combine_unlock_rows(&[1], "pw"));
h.run_steps(2);
assert_eq!(h.state().combine_draft[1].lock, Some(pdfcraft_ui_egui::CombineLock::Permissions));
h.get_by_label_contains("Enter the permissions password");
h.get_all_by_label("Unlock…").last().unwrap().click();
h.run_steps(3);
h.get_by_label("Enter the permissions password to allow combining");
type_password(&mut h, "owner");
assert!(h.state().combine_draft[1].lock.is_none());
assert!(!h.get_by_label("Combine").accesskit_node().is_disabled());
}
#[test]
fn an_rc4_file_unlocks_with_its_owner_password_too() {
// The engine reads RC4 files with their owner password; the inspector may not, and the
// engine's answer is the one that counts.
let mut h = combine_of(&[("one.pdf", 1)]);
h.state_mut().use_files(
pdfcraft_ui_egui::FilePurpose::Combine,
vec![("rc4.pdf".into(), protected_with(pdfcraft_cos::Algorithm::Rc4_128, "user", "boss", -1))],
);
h.run_steps(3);
assert!(h.state_mut().combine_unlock_rows(&[1], "boss"));
let f = &h.state().combine_draft[1];
assert!(f.lock.is_none());
assert_eq!(f.pages, 2);
}
#[test]
fn open_documents_can_be_added_to_combine() {
let mut h = harness(2, |_| {});
h.state_mut().execute("page.combine");
h.run_steps(3);
h.get_by_label("Add open documents").click();
h.run_steps(2);
// The menu's entry, after the tab of the same name.
h.get_all_by_label("doc.pdf").last().unwrap().click();
h.run_steps(3);
let app = h.state();
assert_eq!(app.combine_draft.len(), 1);
assert_eq!(app.combine_draft[0].pages, 2);
assert!(app.combine_showing());
}
#[test]
fn extract_button_copies_selected_pages_to_a_new_tab() {
let mut h = organize(3);
h.get_by_label("Page 2").click();
h.run_steps(1);
h.get_by_label("Page 3").click_modifiers(Modifiers::COMMAND);
h.run_steps(2);
h.get_by_label("Extract pages to a new document").click();
h.run_steps(3);
h.get_by_label("2 pages selected.");
h.get_all_by_label("Extract").last().unwrap().click();
h.run_steps(3);
let app = h.state();
assert_eq!(app.views.len(), 2);
assert_eq!(texts_of(app, 1), ["Page 2", "Page 3"]);
assert_eq!(texts_of(app, 0).len(), 3, "the original is unchanged");
assert!(!app.session.get(app.views[0].id).unwrap().dirty);
}
#[test]
fn inserting_a_file_goes_after_the_selection_and_undoes() {
let mut h = organize(2);
h.get_by_label("Page 1").click();
h.run_steps(2);
h.state_mut().use_files(pdfcraft_ui_egui::FilePurpose::InsertPages, vec![("extra.pdf".into(), fixture(2))]);
h.run_steps(3);
assert_eq!(texts_of(h.state(), 0), ["Page 1", "Page 1", "Page 2", "Page 2"]);
h.key_press_modifiers(Modifiers::COMMAND, Key::Z);
h.run_steps(3);
assert_eq!(texts_of(h.state(), 0), ["Page 1", "Page 2"]);
}
#[test]
fn plus_between_pages_inserts_picked_files_there_in_order() {
let dir = temp_path("insert-gap");
std::fs::create_dir_all(&dir).unwrap();
let (extra, note) = (dir.join("extra.pdf"), dir.join("note.txt"));
std::fs::write(&extra, fixture(2)).unwrap();
std::fs::write(&note, "a note").unwrap();
let mut h = organize(3);
// Page 3 is selected, yet the files go where the "+" is: between pages 1 and 2.
h.get_by_label("Page 3").click();
h.run_steps(2);
h.state_mut().pick_override = Some(vec![extra.to_string_lossy().into_owned(), note.to_string_lossy().into_owned()]);
h.get_by_label("Insert a file before page 2").click();
h.run_steps(4);
assert_eq!(texts_of(h.state(), 0), ["Page 1", "Page 1", "Page 2", "a note", "Page 2", "Page 3"]);
assert_eq!(picked(&h), [1, 2, 3], "the inserted pages are selected");
// Each file is one undo step.
h.key_press_modifiers(Modifiers::COMMAND, Key::Z);
h.run_steps(3);
h.key_press_modifiers(Modifiers::COMMAND, Key::Z);
h.run_steps(3);
assert_eq!(texts_of(h.state(), 0), ["Page 1", "Page 2", "Page 3"]);
// Both ends.
h.state_mut().pick_override = Some(vec![note.to_string_lossy().into_owned()]);
h.get_by_label("Insert a file before page 1").click();
h.run_steps(4);
h.get_by_label("Insert a file at the end").click();
h.run_steps(4);
assert_eq!(texts_of(h.state(), 0), ["a note", "Page 1", "Page 2", "Page 3", "a note"]);
// The toolbar button still inserts after the selection, not at the last "+" used.
h.get_by_label("Page 2").click();
h.run_steps(2);
h.get_by_label("Insert pages from a file…").click();
h.run_steps(4);
assert_eq!(texts_of(h.state(), 0), ["a note", "Page 1", "a note", "Page 2", "Page 3", "a note"]);
// A file that can't be converted changes nothing.
let before = texts_of(h.state(), 0);
h.state_mut().use_files(pdfcraft_ui_egui::FilePurpose::InsertPages, vec![("report.docx".into(), b"PK\x03\x04".to_vec())]);
h.run_steps(2);
assert_eq!(texts_of(h.state(), 0), before);
}
/// A file dropped on the window, as the windowing layer hands it over.
#[derive(Debug)]
struct Dropped(&'static str, Vec<u8>);
impl egui::DroppedFile for Dropped {
fn path(&self) -> &std::path::Path {
std::path::Path::new(self.0)
}
fn bytes(&self) -> Result<Vec<u8>, String> {
Ok(self.1.clone())
}
}
fn drop_files(h: &mut Harness<'static, PdfCraftApp>, files: Vec<Dropped>) {
for f in files {
h.input_mut().dropped_files.push(std::sync::Arc::new(f));
}
}
#[test]
fn files_dropped_between_pages_are_inserted_there() {
let mut h = organize(3);
// Over the gap between pages 1 and 2.
let gap = h.get_by_label("Insert a file before page 2").rect().center();
h.hover_at(gap + egui::vec2(0.0, 40.0));
h.run_steps(2);
drop_files(&mut h, vec![Dropped("extra.pdf", fixture(2)), Dropped("note.txt", b"a note".to_vec())]);
h.run_steps(4);
assert_eq!(h.state().views.len(), 1, "dropped files are not opened as documents");
assert_eq!(texts_of(h.state(), 0), ["Page 1", "Page 1", "Page 2", "a note", "Page 2", "Page 3"]);
assert_eq!(picked(&h), [1, 2, 3], "the inserted pages are selected");
h.key_press_modifiers(Modifiers::COMMAND, Key::Z);
h.run_steps(3);
h.key_press_modifiers(Modifiers::COMMAND, Key::Z);
h.run_steps(3);
assert_eq!(texts_of(h.state(), 0), ["Page 1", "Page 2", "Page 3"]);
// Right of the last page: the end.
let last = h.get_by_label("Page 3").rect();
h.hover_at(last.center() + egui::vec2(last.width() * 0.4, 0.0));
h.run_steps(2);
drop_files(&mut h, vec![Dropped("note.txt", b"a note".to_vec())]);
h.run_steps(4);
assert_eq!(texts_of(h.state(), 0), ["Page 1", "Page 2", "Page 3", "a note"]);
}
#[test]
fn files_dropped_before_the_pointer_is_known_wait_for_it() {
// Some platforms don't report the pointer while files are dragged over the window.
let mut h = organize(2);
drop_files(&mut h, vec![Dropped("note.txt", b"a note".to_vec())]);
h.run_steps(1);
assert_eq!(texts_of(h.state(), 0).len(), 2, "not placed yet");
let gap = h.get_by_label("Insert a file before page 2").rect().center();
h.hover_at(gap + egui::vec2(0.0, 40.0));
h.run_steps(3);
assert_eq!(texts_of(h.state(), 0), ["Page 1", "a note", "Page 2"]);
}
#[test]
fn files_dropped_outside_the_page_grid_still_open_as_documents() {
let mut h = harness(2, |_| {});
drop_files(&mut h, vec![Dropped("other.pdf", fixture(1))]);
h.run_steps(3);
assert_eq!(h.state().views.len(), 2);
assert_eq!(texts_of(h.state(), 0), ["Page 1", "Page 2"]);
}
fn grid_zoom(h: &Harness<'static, PdfCraftApp>) -> f32 {
h.state().views[0].grid_zoom()
}
#[test]
fn page_grid_zooms_with_its_buttons_keys_and_pinch() {
let mut h = organize(12);
let width = |h: &Harness<'static, PdfCraftApp>| h.get_by_label("Page 2").rect().left() - h.get_by_label("Page 1").rect().left();
let usual = width(&h);
h.get_by_label("Larger pages").click();
h.run_steps(3);
assert_eq!(grid_zoom(&h), 1.25);
assert!(width(&h) > usual + 20.0, "the pages are drawn larger");
h.get_by_label("Reset page size").click();
h.run_steps(3);
assert_eq!((grid_zoom(&h), width(&h)), (1.0, usual));
h.get_by_label("Smaller pages").click();
h.run_steps(3);
assert_eq!(grid_zoom(&h), 0.8);
assert!(width(&h) < usual - 20.0);
// Keys.
h.key_press_modifiers(Modifiers::COMMAND, Key::Num0);
h.run_steps(2);
assert_eq!(grid_zoom(&h), 1.0);
h.key_press_modifiers(Modifiers::COMMAND, Key::Equals);
h.run_steps(2);
assert_eq!(grid_zoom(&h), 1.25);
h.key_press_modifiers(Modifiers::COMMAND, Key::Minus);
h.run_steps(2);
assert_eq!(grid_zoom(&h), 1.0);
// Pinch (or Ctrl/⌘ + wheel) over the grid; elsewhere it leaves the grid alone.
let page = h.get_by_label("Page 1").rect().center();
h.hover_at(page);
h.run_steps(1);
h.event(egui::Event::Zoom(1.5));
h.run_steps(2);
assert_eq!(grid_zoom(&h), 1.5);
h.hover_at(h.get_by_label("Larger pages").rect().center());
h.run_steps(1);
h.event(egui::Event::Zoom(1.5));
h.run_steps(2);
assert_eq!(grid_zoom(&h), 1.5, "the pointer was on the toolbar");
// It stops at its limits, where the buttons switch off.
for _ in 0..12 {
h.key_press_modifiers(Modifiers::COMMAND, Key::Equals);
h.run_steps(1);
}
assert_eq!(grid_zoom(&h), 3.0);
h.get_by_label("Larger pages").click();
h.run_steps(2);
assert_eq!(grid_zoom(&h), 3.0);
for _ in 0..20 {
h.key_press_modifiers(Modifiers::COMMAND, Key::Minus);
h.run_steps(1);
}
assert_eq!(grid_zoom(&h), 0.5);
assert!(!dirty(&h), "zooming is not an edit");
}
#[test]
fn zoomed_pages_in_view_are_rendered_at_the_size_drawn() {
// A long document: sharpness must not depend on how many pages there are.
let mut h = harness(400, |app| {
app.set_option("organize", "on").unwrap();
app.set_option("grid-zoom", "300").unwrap();
});
// The 200 × 300 fixture fits by height; keep its aspect ratio instead of stretching
// it to the full cell width. Request enough pixels for that actual preview width.
let drawn = 194.0 * 3.0 * (200.0 / 300.0) * h.ctx.pixels_per_point();
let sharp = |h: &Harness<'static, PdfCraftApp>, page| h.state().views[0].grid_page_pixels(page);
for _ in 0..200 {
if sharp(&h, 0).is_some_and(|w| w as f32 >= drawn * 0.9) {
break;
}
std::thread::sleep(std::time::Duration::from_millis(20));
h.run_steps(1);
}
let width = sharp(&h, 0).expect("the first page has a sharp render");
assert!((drawn * 0.9..=drawn * 1.6).contains(&(width as f32)), "{width} for {drawn}");
assert!(sharp(&h, 399).is_none(), "pages out of view have none");
// Zoomed out again, thumbnails are enough and the sharp renders are dropped.
h.state_mut().set_option("grid-zoom", "50").unwrap();
h.run_steps(3);
assert!(sharp(&h, 0).is_none(), "page 0 still has a sharp render: {:?}", sharp(&h, 0));
}
#[test]
fn a_zoomed_page_grid_still_inserts_and_moves_at_the_right_gap() {
let dir = temp_path("zoomed-grid");
std::fs::create_dir_all(&dir).unwrap();
let note = dir.join("note.txt");
std::fs::write(&note, "a note").unwrap();
let mut h = harness(3, |app| {
app.set_option("organize", "on").unwrap();
app.set_option("grid-zoom", "200").unwrap();
assert!(
app.set_option("grid-zoom", "20").is_err() && app.set_option("grid-zoom", "NaN").is_err() && app.set_option("grid-zoom", "big").is_err()
);
});
assert_eq!(grid_zoom(&h), 2.0);
h.state_mut().pick_override = Some(vec![note.to_string_lossy().into_owned()]);
h.get_by_label("Insert a file before page 2").click();
h.run_steps(4);
assert_eq!(texts_of(h.state(), 0), ["Page 1", "a note", "Page 2", "Page 3"]);
// Files dropped over a gap go there too.
let gap = h.get_by_label("Insert a file before page 2").rect().center();
h.hover_at(gap + egui::vec2(0.0, 60.0));
h.run_steps(2);
drop_files(&mut h, vec![Dropped("more.txt", b"more".to_vec())]);
h.run_steps(4);
assert_eq!(texts_of(h.state(), 0), ["Page 1", "more", "a note", "Page 2", "Page 3"]);
}
#[test]
fn save_pages_writes_what_the_grid_shows() {
let path = temp_path("grid-save.pdf");
let mut h = organize(3);
h.get_by_label("Page 2").click();
h.run_steps(2);
h.key_press(Key::Delete);
h.run_steps(3);
h.state_mut().save_override = Some(path.to_string_lossy().into_owned());
h.get_by_label("Save pages").click();
h.run_steps(4);
assert!(!dirty(&h));
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("saved.pdf", None, std::fs::read(&path).unwrap()).unwrap();
assert_eq!(texts_of(&app, 0), ["Page 1", "Page 3"]);
}
#[test]
fn split_dialog_writes_one_file_per_part() {
let dir = temp_path("split-count");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let d = dir.to_string_lossy().into_owned();
let mut h = organize(3);
h.state_mut().export_dir_override = Some(d);
h.get_by_label("Split into files…").click();
h.run_steps(3);
h.get_by_label_contains("Creates 3 files from 3 pages");
h.get_by_label("Split").click();
h.run_steps(3);
let mut names: Vec<String> = std::fs::read_dir(&dir).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect();
names.sort();
assert_eq!(names, ["doc (page 1).pdf", "doc (page 2).pdf", "doc (page 3).pdf"]);
for name in names {
let info = pdfcraft_render::inspect(std::sync::Arc::new(std::fs::read(dir.join(name)).unwrap()), None).unwrap();
assert_eq!(info.pages.len(), 1);
}
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn split_at_bookmarks_with_equal_titles_keeps_every_part() {
let dir = temp_path("split-dup-titles");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let mut h = organize(3);
h.state_mut().export_dir_override = Some(dir.to_string_lossy().into_owned());
for (index, title) in [(0, "Same"), (1, "Same"), (2, "same")] {
h.state_mut().apply_edit(pdfcraft_engine::Edit::AddBookmark { parent: vec![], index, title: title.into(), page: index });
}
h.state_mut().split_draft.mode = pdfcraft_ui_egui::SplitMode::Bookmarks;
h.state_mut().run_command("page.split");
h.run_steps(3);
h.get_by_label_contains("Creates 3 files from 3 pages");
h.get_by_label("Split").click();
h.run_steps(3);
let mut names: Vec<String> = std::fs::read_dir(&dir).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect();
names.sort();
assert_eq!(names, ["doc - Same (2).pdf", "doc - Same.pdf", "doc - same (3).pdf"]);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn split_before_selected_pages() {
let dir = temp_path("split-sel");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let mut h = organize(4);
h.state_mut().export_dir_override = Some(dir.to_string_lossy().into_owned());
h.state_mut().views[0].select_pages(&[2]);
h.state_mut().split_draft.mode = pdfcraft_ui_egui::SplitMode::Selection;
h.state_mut().run_command("page.split");
h.run_steps(3);
h.get_by_label_contains("Creates 2 files from 4 pages");
h.get_by_label("Split").click();
h.run_steps(3);
let mut names: Vec<String> = std::fs::read_dir(&dir).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect();
names.sort();
assert_eq!(names, ["doc (pages 1-2).pdf", "doc (pages 3-4).pdf"]);
let _ = std::fs::remove_dir_all(dir);
}
// ── Encrypted documents ───────────────────────────────────────────────────────────────────────
fn protected(user: &str, owner: &str, permissions: i32) -> Vec<u8> {
protected_with(pdfcraft_cos::Algorithm::Aes256, user, owner, permissions)
}
fn protected_with(algorithm: pdfcraft_cos::Algorithm, user: &str, owner: &str, permissions: i32) -> Vec<u8> {
let mut doc = pdfcraft_cos::Document::open(std::sync::Arc::new(fixture(2))).unwrap();
doc.set_encryption(&pdfcraft_cos::NewEncryption {
algorithm,
user_password: user,
owner_password: owner,
permissions,
encrypt_metadata: true,
seed: [4; 32],
})
.unwrap();
pdfcraft_cos::write_full(&doc, &Default::default()).unwrap()
}
#[test]
fn password_prompt_opens_and_security_tab_reports_the_details() {
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("secret.pdf", None, protected("pw", "owner", -1)).unwrap();
app
});
h.run_steps(4);
h.get_by_label_contains("is protected");
let field = h.get_by_role(Role::PasswordInput);
field.focus();
field.type_text("pw");
h.run_steps(2);
h.key_press(Key::Enter);
h.run_steps(4);
assert_eq!(h.state().views.len(), 1);
h.state_mut().set_option("dialog", "properties").unwrap();
h.run_steps(2);
h.get_by_label("Security").click();
h.run_steps(3);
h.get_by_label("AES, 256-bit");
h.get_by_label("User password");
}
/// #785: the notice's message used to be laid out before its buttons, so in a narrow document
/// area (a side panel open) it ran under "Security settings" and on into the panel, and it sat
/// off the buttons' centre line.
#[test]
fn the_security_notice_wraps_beside_its_buttons_and_lines_up_with_them() {
// Wide: one line. Narrower: wrapped. Narrowest: cut short (the full text is on hover).
for (width, wraps) in [(1700.0, false), (1250.0, true), (900.0, false)] {
let mut h = Harness::builder().with_size(egui::vec2(width, 800.0)).build_eframe(|_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("locked.pdf", None, protected("", "owner", 0b0100)).unwrap();
app.set_option("panel", "bookmarks").unwrap();
app
});
h.run_steps(4);
let msg = h.get_by_label_contains("This document is secured").rect();
let button = h.get_by_label("Security settings").rect();
let dismiss = h.get_by_label("Dismiss").rect();
assert!(msg.right() <= button.left() + 0.5, "{width}: the message stops before the buttons: {msg:?} vs {button:?}");
assert!(button.right() <= dismiss.left() + 0.5, "{width}: {button:?} vs {dismiss:?}");
assert!((button.center().y - dismiss.center().y).abs() <= 0.5, "{width}: the buttons share a centre line");
assert!(msg.top() >= button.top(), "{width}: the message stays inside the bar: {msg:?} vs {button:?}");
let lines = (msg.height() / 14.0).floor().max(1.0);
let first_line = msg.top() + msg.height() / lines / 2.0;
assert!((first_line - button.center().y).abs() <= 1.5, "{width}: first line centred on the buttons: {msg:?} vs {button:?}");
assert_eq!(msg.height() > button.height(), wraps, "{width}: {msg:?}");
}
}
#[test]
fn restricted_documents_show_a_notice_and_block_page_changes() {
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("locked.pdf", None, protected("", "owner", 0b0100)).unwrap(); // opens without a password
app.set_option("organize", "on").unwrap();
app
});
h.run_steps(4);
h.get_by_label_contains("This document is secured");
h.get_by_label("Page 1").click();
h.run_steps(1);
h.get_by_label("Delete pages (Delete)").click();
h.key_press(Key::Delete);
h.run_steps(3);
assert_eq!(texts_of(h.state(), 0).len(), 2, "page changes are blocked");
assert_eq!(h.query_all_by_label_contains("Insert a file").count(), 0, "nothing to insert into");
// Looking closer is not a page change.
h.get_by_label("Larger pages").click();
h.run_steps(2);
assert_eq!(grid_zoom(&h), 1.25);
assert!(!dirty(&h));
h.get_by_label("Security settings").click();
h.run_steps(3);
assert!(h.query_all_by_label("Not allowed").count() >= 4);
}
/// Table 22, bit 5 (#568): a document whose security withholds copying keeps its text and images
/// in. ⌘C, snapshots and exports are refused with a notice; the owner password lifts that.
#[test]
fn copying_out_of_a_document_honours_its_security() {
fn open(password: &'static str) -> Harness<'static, PdfCraftApp> {
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.system_clipboard = false;
app.open_bytes("no-copy.pdf", None, protected("pw", "owner", -1 ^ 0b1_0000)).unwrap();
app.submit_password(Some(password.into()));
app
});
// Select the page's text once its text layer is loaded.
for _ in 0..40 {
h.run_steps(2);
if h.state_mut().views[0].select_all() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
assert_eq!(h.state().views[0].selected_text().as_deref(), Some("Page 1"));
h
}
fn copy(h: &mut Harness<'static, PdfCraftApp>) -> bool {
h.state_mut().toast = None;
h.event(egui::Event::Copy);
let mut copied = false;
for _ in 0..4 {
h.step();
copied |= h.output().platform_output.commands.iter().any(|c| matches!(c, egui::OutputCommand::CopyText(t) if t == "Page 1"));
}
copied
}
fn snapshot(h: &mut Harness<'static, PdfCraftApp>) {
// Around "Page 1", at (20, 150) on the 200 × 300 pt page.
let r = h.state().views[0].page_screen_rect(0).expect("on screen");
let at = |x: f32, y: f32| egui::pos2(r.left() + x / 200.0 * r.width(), r.top() + (300.0 - y) / 300.0 * r.height());
let (a, b) = (at(15.0, 175.0), at(120.0, 140.0));
h.hover_at(a);
h.run_steps(1);
h.drag_at(a);
h.run_steps(1);
h.hover_at(b);
h.run_steps(1);
h.drop_at(b);
h.run_steps(3);
}
let refused = Some("The document's security settings don't allow copying text and images");
let mut h = open("pw");
assert!(!copy(&mut h), "⌘C copies nothing");
assert_eq!(h.state().toast.as_ref().map(|t| t.0.as_str()), refused);
for command in ["edit.snapshot", "export.image", "export.text", "export.docx", "export.html", "export.rtf", "export.all_images"] {
h.state_mut().toast = None;
assert!(!h.state_mut().execute(command), "{command} is refused");
assert_eq!(h.state().toast.as_ref().map(|t| t.0.as_str()), refused, "{command} says why");
}
assert_eq!(h.state().dialog, None, "no export dialog");
// The snapshot tool, chosen on another tab, takes nothing here either.
h.state_mut().set_option("quick", "snapshot").unwrap();
snapshot(&mut h);
assert!(h.state().last_snapshot.is_none());
assert_eq!(h.state().toast.as_ref().map(|t| t.0.as_str()), refused);
let mut h = open("owner");
assert!(copy(&mut h), "the owner may copy");
assert!(h.state_mut().execute("edit.snapshot"));
snapshot(&mut h);
assert!(h.state().last_snapshot.is_some());
}
#[test]
fn replace_pages_dialog_swaps_page_content() {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("doc.pdf", None, fixture(3)).unwrap();
app.views[0].select_pages(&[1]);
app.start_replace("other.pdf".into(), fixture(5));
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| app);
h.run_steps(3);
egui_kittest::kittest::Queryable::get_by_label(&h, "Replace Pages");
h.state_mut().replace_draft.as_mut().unwrap().src_from = 5;
h.run_steps(1);
egui_kittest::kittest::Queryable::get_by_label(&h, "OK").click();
h.run_steps(3);
let s = h.state();
let doc = s.session.get(s.views[0].id).unwrap();
assert_eq!(doc.info.pages.len(), 3);
assert_eq!(doc.can_undo(), Some("Replace page"));
}
#[test]
fn extract_options_and_rotate_pages_dialog() {
let dir = temp_path("extract-sep");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let mut h = organize(4);
h.state_mut().export_dir_override = Some(dir.to_string_lossy().into_owned());
h.state_mut().views[0].select_pages(&[1, 2]);
h.state_mut().run_command("page.extract");
h.run_steps(2);
h.state_mut().extract_draft = pdfcraft_ui_egui::ExtractDraft { separate: true, delete: true, ..Default::default() };
h.get_all_by_label("Extract").last().unwrap().click();
h.run_steps(3);
let mut names: Vec<String> = std::fs::read_dir(&dir).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect();
names.sort();
assert_eq!(names, ["doc (page 2).pdf", "doc (page 3).pdf"]);
assert_eq!(texts_of(h.state(), 0), ["Page 1", "Page 4"], "deleted after extracting");
let _ = std::fs::remove_dir_all(dir);
// Rotate Pages: odd page numbers only.
h.state_mut().run_command("page.rotate_dialog");
h.run_steps(2);
h.state_mut().rotate_draft.which = 0;
h.state_mut().rotate_draft.parity = pdfcraft_engine::PageParity::Odd;
h.get_by_label("OK").click();
h.run_steps(3);
let s = h.state();
let d = s.session.get(s.views[0].id).unwrap();
assert_eq!(d.info.pages.iter().map(|p| p.rotation).collect::<Vec<_>>(), [90, 0]);
}
#[test]
fn dragging_thumbnails_reorders_pages() {
let mut h = organize(4);
let before = page_texts(h.state());
let grab = |h: &Harness<'static, PdfCraftApp>, label: &str| h.get_by_label(label).rect();
let (from, to) = (grab(&h, "Page 1").center(), grab(&h, "Page 3").right_center() - egui::vec2(10.0, 0.0));
h.hover_at(from);
h.run_steps(1);
h.drag_at(from);
h.run_steps(1);
for k in 1..=5 {
h.hover_at(from + (to - from) * (k as f32 / 5.0));
h.run_steps(1);
}
h.drop_at(to);
h.run_steps(4);
// Page 1 now sits after page 3.
let after = page_texts(h.state());
assert_eq!(after, vec![before[1].clone(), before[2].clone(), before[0].clone(), before[3].clone()], "{after:?}");
assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo(), Some("Move page"));
let sel: Vec<usize> = h.state().views[0].selected.iter().copied().collect();
assert_eq!(sel, vec![2], "the moved page stays selected");
}
#[test]
fn copying_cutting_and_pasting_pages() {
let mut h = organize(4);
let before = page_texts(h.state());
h.state_mut().views[0].select_pages(&[0]);
assert!(h.state_mut().execute("page.copy"));
h.state_mut().views[0].select_pages(&[2]);
assert!(h.state_mut().execute("page.paste"));
h.run_steps(2);
let after = page_texts(h.state());
assert_eq!(after, vec![before[0].clone(), before[1].clone(), before[2].clone(), before[0].clone(), before[3].clone()], "pasted after page 3");
let sel: Vec<usize> = h.state().views[0].selected.iter().copied().collect();
assert_eq!(sel, vec![3], "the pasted page is selected");
// Cut page 2 and paste it at the end.
h.state_mut().views[0].select_pages(&[1]);
assert!(h.state_mut().execute("page.cut"));
h.state_mut().views[0].select_pages(&[3]);
assert!(h.state_mut().execute("page.paste"));
let after = page_texts(h.state());
assert_eq!(after, vec![before[0].clone(), before[2].clone(), before[0].clone(), before[3].clone(), before[1].clone()]);
// Every page can be copied but not cut.
h.state_mut().views[0].select_pages(&[0, 1, 2, 3, 4]);
h.state_mut().execute("page.cut");
assert_eq!(page_texts(h.state()).len(), 5);
}
fn source_font_fixture() -> Vec<u8> {
let body = "BT /F1 18 Tf 20 220 Td (Serif) Tj ET BT /F2 18 Tf 20 120 Td (Mono) Tj ET";
let objs: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R >>".into(),
"<< /Type /Pages /Kids [4 0 R] /Count 1 /MediaBox [0 0 300 300] >>".into(),
"<< /Type /Font /Subtype /Type1 /BaseFont /Times-BoldItalic >>".into(),
"<< /Type /Page /Parent 2 0 R /Contents 7 0 R /Resources << /Font << /F1 3 0 R /F2 5 0 R >> >> >>".into(),
"<< /Type /Font /Subtype /Type1 /BaseFont /Courier-Oblique >>".into(),
"<< /Producer (PdfCraft) >>".into(),
format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()),
];
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
out
}
fn open_source_font_fixture() -> Harness<'static, PdfCraftApp> {
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("fonts.pdf", None, source_font_fixture()).expect("font fixture opens");
app
})
}
#[test]
fn clicking_existing_text_selects_its_source_font_style() {
let mut h = open_source_font_fixture();
h.run_steps(4);
assert!(h.state_mut().execute("edit.edit_text"));
h.run_steps(2);
let page = h.state().views[0].page_screen_rect(0).expect("on screen");
let click = |x: f32, y: f32| egui::pos2(page.left() + x / 300.0 * page.width(), page.top() + (300.0 - y) / 300.0 * page.height());
let serif = click(25.0, 228.0);
h.hover_at(serif);
h.run_steps(1);
h.drag_at(serif);
h.run_steps(1);
h.drop_at(serif);
h.run_steps(3);
let ed = h.state().views[0].line_editor.clone().expect("serif editor opens");
assert_eq!(ed.look.family, pdfcraft_engine::FontFamily::Times);
assert!(ed.look.bold && ed.look.italic, "source style: {:?}", ed.look);
h.key_press(egui::Key::Escape);
h.run_steps(2);
let mono = click(25.0, 128.0);
h.hover_at(mono);
h.run_steps(1);
h.drag_at(mono);
h.run_steps(1);
h.drop_at(mono);
h.run_steps(3);
let ed = h.state().views[0].line_editor.clone().expect("mono editor opens");
assert_eq!(ed.look.family, pdfcraft_engine::FontFamily::Courier);
assert!(!ed.look.bold && ed.look.italic, "source style: {:?}", ed.look);
}
#[test]
fn editing_existing_text_in_place() {
let mut h = harness(1, |_| {});
assert!(h.state_mut().execute("edit.edit_text"));
h.run_steps(2);
// The line "Page 1" sits at (20, 150) on a 200 × 300 page, 24 pt.
let r = h.state().views[0].page_screen_rect(0).expect("on screen");
let at = egui::pos2(r.left() + 40.0 / 200.0 * r.width(), r.top() + (300.0 - 158.0) / 300.0 * r.height());
h.hover_at(at);
h.run_steps(1);
h.drag_at(at);
h.run_steps(1);
h.drop_at(at);
h.run_steps(3);
let ed = h.state().views[0].line_editor.clone().expect("the line opens for editing");
assert_eq!((ed.page, ed.block, ed.text.as_str()), (0, 0, "Page 1"));
// A single-line paragraph's box may grow to the page's edge as the text does.
assert!(ed.growth().is_some(), "the editor box may grow");
h.state_mut().views[0].line_editor.as_mut().unwrap().text = "Chapter One".into();
h.run_steps(1);
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::Enter);
h.run_steps(4);
let s = h.state();
let doc = s.session.get(s.views[0].id).unwrap();
assert_eq!(doc.can_undo(), Some("Edit text"));
assert_eq!(doc.text_lines(0)[0].text, "Chapter One");
assert_eq!(texts_of(s, 0), ["Chapter One"], "the page shows it");
}
/// One page whose only line is drawn twice at the same spot (fake bold, as many generated
/// documents do).
fn double_drawn() -> Vec<u8> {
let objs: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R >>".into(),
"<< /Type /Pages /Kids [4 0 R] /Count 1 /MediaBox [0 0 200 300] >>".into(),
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(),
"<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources << /Font << /F1 3 0 R >> >> >>".into(),
format!(
"<< /Length {} >>\nstream\nBT /F1 24 Tf 20 150 Td (Page 1) Tj ET BT /F1 24 Tf 20 150 Td (Page 1) Tj ET\nendstream",
"BT /F1 24 Tf 20 150 Td (Page 1) Tj ET BT /F1 24 Tf 20 150 Td (Page 1) Tj ET".len()
),
];
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
out
}
#[test]
fn editing_a_double_drawn_line_replaces_every_copy() {
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("bold.pdf", None, double_drawn()).expect("opens");
app
});
h.run_steps(4);
assert!(h.state_mut().execute("edit.edit_text"));
h.run_steps(2);
let r = h.state().views[0].page_screen_rect(0).expect("on screen");
let at = egui::pos2(r.left() + 40.0 / 200.0 * r.width(), r.top() + (300.0 - 158.0) / 300.0 * r.height());
h.hover_at(at);
h.run_steps(1);
h.drag_at(at);
h.run_steps(1);
h.drop_at(at);
h.run_steps(3);
let ed = h.state().views[0].line_editor.clone().expect("the line opens for editing");
assert_eq!(ed.text, "Page 1");
h.state_mut().views[0].line_editor.as_mut().unwrap().text = "Replaced".into();
h.run_steps(1);
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::Enter);
h.run_steps(4);
let s = h.state();
assert_eq!(s.session.get(s.views[0].id).unwrap().text_lines(0).len(), 1, "one line");
assert_eq!(texts_of(s, 0), ["Replaced"], "no copy of the old text shows under it");
}
#[test]
fn editing_existing_images_on_the_page() {
// A page made from a 40 × 20 image (at 72 dpi: a 40 × 20 pt page filled by it).
let mut png = Vec::new();
image::RgbImage::from_pixel(80, 40, image::Rgb([200, 40, 40])).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
check_existing_artwork("picture.png", png);
}
#[test]
fn editing_existing_form_artwork_on_the_page() {
use pdfcraft_cos::{Dict, Document, Object, SaveOptions, Stream, write_incremental};
let mut doc = Document::open(std::sync::Arc::new(fixture(1))).unwrap();
let page = pdfcraft_model::pages(&doc)[0].clone();
let mut form = Dict::new();
form.set(b"Subtype".to_vec(), Object::name("Form"));
form.set(b"BBox".to_vec(), Object::Array([0, 0, 200, 300].map(Object::Int).to_vec()));
let artwork = doc.add(Object::Stream(Stream::flate(form, b"0.2 0.5 0.9 rg 20 20 160 260 re f")));
let contents = doc.add(Object::Stream(Stream::flate(Dict::new(), b"/Figure Do")));
doc.update_dict(page.obj, |d| {
d.set(b"Contents".to_vec(), Object::Ref(contents));
let mut xo = Dict::new();
xo.set(b"Figure".to_vec(), Object::Ref(artwork));
let mut resources = Dict::new();
resources.set(b"XObject".to_vec(), Object::Dict(xo));
d.set(b"Resources".to_vec(), Object::Dict(resources));
})
.unwrap();
check_existing_artwork("figure.pdf", write_incremental(&doc, &SaveOptions::default()).unwrap());
}
fn check_existing_artwork(name: &'static str, bytes: Vec<u8>) {
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(name, None, bytes.clone()).expect("opens");
app
});
h.run_steps(4);
let id = h.state().views[0].id;
let before = h.state().session.get(id).unwrap().page_images(0)[0].rect;
assert!(h.state_mut().execute("edit.edit_text"));
h.run_steps(2);
let r = h.state().views[0].page_screen_rect(0).expect("on screen");
// Select it, then drag it a quarter of the page to the right.
let c = r.center();
h.hover_at(c);
h.run_steps(1);
h.drag_at(c);
h.run_steps(1);
h.drop_at(c);
h.run_steps(2);
assert!(h.state().views[0].image_selection.is_some(), "selected");
let to = c + egui::vec2(r.width() / 4.0, 0.0);
h.hover_at(c);
h.run_steps(1);
h.drag_at(c);
h.run_steps(1);
for k in 1..=4 {
h.hover_at(c + (to - c) * (k as f32 / 4.0));
h.run_steps(1);
}
h.drop_at(to);
h.run_steps(4);
let doc = h.state().session.get(id).unwrap();
assert_eq!(doc.can_undo(), Some("Move image"));
let moved = doc.page_images(0)[0].rect;
let dx = moved[0] - before[0];
assert!((dx - (before[2] - before[0]) / 4.0).abs() < 1.5, "moved {dx} pt: {before:?} → {moved:?}");
// Delete with the Delete key.
h.key_press(egui::Key::Delete);
h.run_steps(4);
assert!(h.state().session.get(id).unwrap().page_images(0).is_empty());
}
#[test]
fn the_format_panel_restyles_the_paragraph_being_edited() {
let mut h = harness(1, |_| {});
assert!(h.state_mut().execute("edit.edit_text"));
h.run_steps(2);
let r = h.state().views[0].page_screen_rect(0).expect("on screen");
let at = egui::pos2(r.left() + 40.0 / 200.0 * r.width(), r.top() + (300.0 - 158.0) / 300.0 * r.height());
h.hover_at(at);
h.run_steps(1);
h.drag_at(at);
h.run_steps(1);
h.drop_at(at);
h.run_steps(3);
assert!(h.state().views[0].line_editor.is_some());
// The Format text panel shows the paragraph's look; making it bold applies at once.
h.get_by_label("FORMAT TEXT");
h.get_by_label("B").click();
h.run_steps(4);
let s = h.state();
let doc = s.session.get(s.views[0].id).unwrap();
assert_eq!(doc.can_undo(), Some("Edit text"));
assert_eq!(doc.text_blocks(0)[0].base_font, "Helvetica-Bold");
assert!(s.views[0].line_editor.is_some(), "still editing");
// Underline: another step, the text keeps its bold.
h.get_by_label("Underline").click();
h.run_steps(4);
let s = h.state();
let doc = s.session.get(s.views[0].id).unwrap();
assert_eq!(doc.text_blocks(0)[0].base_font, "Helvetica-Bold");
assert!(s.views[0].line_editor.as_ref().is_some_and(|e| e.extras.underline));
}
/// Drag with the pointer from `from` to `to` in a few steps.
fn drag(h: &mut Harness<'static, PdfCraftApp>, from: egui::Pos2, to: egui::Pos2) {
h.hover_at(from);
h.run_steps(1);
h.drag_at(from);
h.run_steps(1);
for k in 1..=5 {
h.hover_at(from + (to - from) * (k as f32 / 5.0));
h.run_steps(1);
}
h.drop_at(to);
h.run_steps(4);
}
#[test]
fn dragging_a_paragraph_moves_it_and_its_edge_rewraps_it() {
let mut h = harness(1, |_| {});
assert!(h.state_mut().execute("edit.edit_text"));
h.run_steps(2);
let block = |h: &Harness<'static, PdfCraftApp>| {
let s = h.state();
s.session.get(s.views[0].id).unwrap().text_blocks(0)[0].clone()
};
// User space (200 × 300 page) → screen.
let r = h.state().views[0].page_screen_rect(0).expect("on screen");
let k = r.width() / 200.0;
let screen = |x: f64, y: f64| egui::pos2(r.left() + x as f32 * k, r.top() + (300.0 - y as f32) * k);
// Move "Page 1" 30 pt right and 50 pt up.
let before = block(&h);
let mid = screen((before.rect[0] + before.rect[2]) / 2.0, (before.rect[1] + before.rect[3]) / 2.0);
drag(&mut h, mid, mid + egui::vec2(30.0 * k, -50.0 * k));
assert!(h.state().views[0].line_editor.is_none(), "dragging moves the box; it doesn't open it for typing");
let moved = block(&h);
assert_eq!(moved.text, "Page 1");
let near = |a: f64, b: f64| (a - b).abs() < 1.5;
assert!(near(moved.rect[0], before.rect[0] + 30.0) && near(moved.rect[1], before.rect[1] + 50.0), "{:?} → {:?}", before.rect, moved.rect);
assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo(), Some("Edit text"));
// Drag the handle on its right edge in to about 45 pt wide: "Page" and "1" rewrap onto two lines.
let edge = screen(moved.rect[2], (moved.rect[1] + moved.rect[3]) / 2.0) + egui::vec2(2.0, 0.0);
let narrower = (moved.rect[2] - moved.rect[0] - 45.0) as f32 * k;
drag(&mut h, edge, edge - egui::vec2(narrower, 0.0));
let s = h.state();
let doc = s.session.get(s.views[0].id).unwrap();
let lines: Vec<String> = doc.text_lines(0).iter().map(|l| l.text.clone()).collect();
assert_eq!(lines, ["Page", "1"], "rewrapped to the narrower box");
assert!(near(doc.text_lines(0)[0].rect[0], moved.rect[0]), "it keeps its place");
assert!(h.state().views[0].line_editor.is_none());
}
/// A quick flick: press at `from`, and the next frame already finds the pointer at `to`.
fn flick(h: &mut Harness<'static, PdfCraftApp>, from: egui::Pos2, to: egui::Pos2) {
h.hover_at(from);
h.run_steps(1);
h.drag_at(from);
h.run_steps(1);
h.hover_at(to);
h.run_steps(1);
h.drop_at(to);
h.run_steps(4);
}
#[test]
fn a_quick_flick_on_either_edge_rewraps_the_paragraph() {
let mut h = harness(1, |_| {});
assert!(h.state_mut().execute("edit.edit_text"));
h.run_steps(2);
let lines = |h: &Harness<'static, PdfCraftApp>| {
let s = h.state();
s.session.get(s.views[0].id).unwrap().text_lines(0)
};
let block = |h: &Harness<'static, PdfCraftApp>| {
let s = h.state();
s.session.get(s.views[0].id).unwrap().text_blocks(0)[0].clone()
};
let r = h.state().views[0].page_screen_rect(0).expect("on screen");
let k = r.width() / 200.0;
let screen = |x: f64, y: f64| egui::pos2(r.left() + x as f32 * k, r.top() + (300.0 - y as f32) * k);
let near = |a: f64, b: f64| (a - b).abs() < 1.5;
// Grab edges near the box's top: a rewrapped box reaches the window's bottom, where the
// tool's toast sits over the page and takes the press.
let grip_y = |b: &pdfcraft_engine::TextBlock| b.rect[3] - 6.0;
// Narrow "Page 1" from its right edge to about 45 pt: it rewraps onto two lines.
let b = block(&h);
let edge = screen(b.rect[2], grip_y(&b)) + egui::vec2(2.0, 0.0);
flick(&mut h, edge, edge - egui::vec2((b.rect[2] - b.rect[0] - 45.0) as f32 * k, 0.0));
let text: Vec<String> = lines(&h).iter().map(|l| l.text.clone()).collect();
assert_eq!(text, ["Page", "1"], "narrowed from the right edge");
// Flick the right edge outward, past the box and its handle: back onto one line.
let b = block(&h);
let edge = screen(b.rect[2], grip_y(&b)) + egui::vec2(2.0, 0.0);
flick(&mut h, edge, edge + egui::vec2(80.0 * k, 0.0));
let text: Vec<String> = lines(&h).iter().map(|l| l.text.clone()).collect();
assert_eq!(text, ["Page 1"], "widened by a fast drag that left the handle at once");
// Flick the left edge inward: it rewraps and its left side follows the pointer.
let b = block(&h);
let edge = screen(b.rect[0], grip_y(&b)) - egui::vec2(2.0, 0.0);
let inward = (b.rect[2] - b.rect[0] - 45.0) as f32;
flick(&mut h, edge, edge + egui::vec2(inward * k, 0.0));
let after = lines(&h);
let text: Vec<String> = after.iter().map(|l| l.text.clone()).collect();
assert_eq!(text, ["Page", "1"], "narrowed from the left edge");
assert!(near(after[0].rect[0], b.rect[0] + inward as f64), "{:?} → {:?}", b.rect, after[0].rect);
assert!(h.state().views[0].line_editor.is_none());
}
/// The Pages panel, in a window tall enough to show every thumbnail of a short fixture.
fn pages_panel(pages: usize) -> Harness<'static, PdfCraftApp> {
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 1900.0)).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("doc.pdf", None, fixture(pages)).expect("fixture opens");
app.set_option("panel", "pages").unwrap();
app
});
h.run_steps(4);
h
}
fn picked(h: &Harness<'static, PdfCraftApp>) -> Vec<usize> {
h.state().views[0].selected.iter().copied().collect()
}
#[test]
fn pages_panel_command_click_picks_pages_without_moving_the_document() {
let mut h = pages_panel(5);
// The first ⌘-click on another page keeps the current page (1) selected too.
h.get_by_label("Page 3").click_modifiers(Modifiers::COMMAND);
h.run_steps(2);
assert_eq!(picked(&h), [0, 2]);
assert_eq!(h.state().views[0].current, 0, "picking pages does not turn the page");
h.get_by_label_contains("2 pages selected");
h.get_by_label("Page 5").click_modifiers(Modifiers::COMMAND);
h.run_steps(2);
assert_eq!(picked(&h), [0, 2, 4]);
// ⌘-click again takes a page back out.
h.get_by_label("Page 3").click_modifiers(Modifiers::COMMAND);
h.run_steps(2);
assert_eq!(picked(&h), [0, 4]);
// Page commands act on what is picked, as in the organize grid.
assert_eq!(h.state().views[0].target_pages(), [0, 4]);
}
#[test]
fn pages_panel_shift_click_picks_a_range_and_a_plain_click_starts_over() {
let mut h = pages_panel(6);
// A plain click goes to the page and is the anchor of the next range.
h.get_by_label("Page 2").click_modifiers(Modifiers::NONE);
h.run_steps(2);
assert_eq!(h.state().views[0].current, 1);
assert!(picked(&h).is_empty());
h.get_by_label("Page 4").click_modifiers(Modifiers::SHIFT);
h.run_steps(2);
assert_eq!(picked(&h), [1, 2, 3]);
assert_eq!(h.state().views[0].current, 1);
// A second ⇧-click ranges from the same anchor, in either direction.
h.get_by_label("Page 1").click_modifiers(Modifiers::SHIFT);
h.run_steps(2);
assert_eq!(picked(&h), [0, 1]);
// ⇧ with no earlier click ranges from the current page.
h.get_by_label("Page 6").click_modifiers(Modifiers::NONE);
h.run_steps(2);
assert!(picked(&h).is_empty(), "a plain click drops the selection");
assert_eq!(h.state().views[0].current, 5);
h.get_by_label("Page 5").click_modifiers(Modifiers::SHIFT);
h.run_steps(2);
assert_eq!(picked(&h), [4, 5]);
}
#[test]
fn pages_panel_escape_clears_and_deleted_pages_leave_the_selection() {
let mut h = pages_panel(5);
h.get_by_label("Page 2").click_modifiers(Modifiers::COMMAND);
h.run_steps(2);
assert_eq!(picked(&h), [0, 1]);
// The pointer is still over the panel.
h.key_press(Key::Escape);
h.run_steps(2);
assert!(picked(&h).is_empty());
// Pages that no longer exist drop out of the selection.
h.get_by_label("Page 5").click_modifiers(Modifiers::COMMAND);
h.run_steps(2);
assert_eq!(picked(&h), [0, 4]);
h.state_mut().views[0].select_pages(&[3, 4]);
assert!(h.state_mut().execute("page.delete"));
h.run_steps(3);
assert_eq!(page_texts(h.state()).len(), 3);
assert!(picked(&h).iter().all(|p| *p < 3), "{:?}", picked(&h));
}
#[test]
fn print_shortcut_offers_the_pages_picked_in_the_pages_panel() {
let mut h = pages_panel(5);
h.get_by_label("Page 2").click_modifiers(Modifiers::NONE);
h.run_steps(2);
h.get_by_label("Page 4").click_modifiers(Modifiers::COMMAND);
h.run_steps(2);
assert_eq!(picked(&h), [1, 3]);
h.key_press_modifiers(Modifiers::COMMAND, Key::P);
h.run_steps(3);
assert_eq!(h.state().dialog, Some(pdfcraft_ui_egui::Dialog::Print));
assert_eq!(h.state().print_draft.which, pdfcraft_ui_egui::PrintWhich::Selected);
assert_eq!(h.state().print_draft.selected, [1, 3]);
h.get_by_label("Selected pages (2)");
h.get_by_label("Sheet 1 of 2");
}
#[test]
fn dragging_pages_panel_thumbnails_reorders_pages() {
let mut h = harness(4, |app| app.right = Some(pdfcraft_ui_egui::RightPanel::Pages));
let before = page_texts(h.state());
// The Pages panel is on the right; the document view may label its pages too.
let thumb = |h: &Harness<'static, PdfCraftApp>, label: &str| {
h.get_all_by_label(label).map(|n| n.rect()).max_by(|a, b| a.left().total_cmp(&b.left())).expect("the thumbnail")
};
// Grab page 3 (page 4 is below the window) and drop it above page 1.
let (from, to) = (thumb(&h, "Page 3").center(), thumb(&h, "Page 1").center_top() + egui::vec2(0.0, 4.0));
drag(&mut h, from, to);
h.run_steps(4);
let after = page_texts(h.state());
assert_eq!(after, vec![before[2].clone(), before[0].clone(), before[1].clone(), before[3].clone()], "{after:?}");
assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo(), Some("Move page"));
let view = &h.state().views[0];
assert_eq!(view.selected.iter().copied().collect::<Vec<_>>(), vec![0], "the moved page stays selected");
assert!(view.panel_drag.is_none());
}
#[test]
fn a_thumbnail_drag_that_ends_off_the_panel_or_outlives_the_pages_is_dropped() {
let mut h = harness(4, |app| app.right = Some(pdfcraft_ui_egui::RightPanel::Pages));
let before = page_texts(h.state());
// A drag left over (the panel closed mid-drag, the button released elsewhere) moves nothing.
h.state_mut().views[0].panel_drag = Some(vec![2]);
h.run_steps(2);
assert!(h.state().views[0].panel_drag.is_none());
assert_eq!(page_texts(h.state()), before);
// A change to the document drops page indexes taken before it.
h.state_mut().views[0].panel_drag = Some(vec![3]);
let id = h.state().views[0].id;
let info = h.state().session.get(id).unwrap().info.clone();
h.state_mut().views[0].document_changed(&info);
assert!(h.state().views[0].panel_drag.is_none());
}
/// A Pages panel thumbnail (the rightmost node with that label).
fn panel_thumb(h: &Harness<'static, PdfCraftApp>, label: &str) -> egui::Pos2 {
h.get_all_by_label(label).map(|n| n.rect()).max_by(|a, b| a.left().total_cmp(&b.left())).expect("the thumbnail").center()
}
fn right_click_at(h: &mut Harness<'static, PdfCraftApp>, at: egui::Pos2) {
h.hover_at(at);
h.run_steps(1);
h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Secondary, pressed: true, modifiers: Default::default() });
h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Secondary, pressed: false, modifiers: Default::default() });
h.run_steps(2);
}
#[test]
fn deleting_pages_from_the_pages_panel_menu() {
let mut h = pages_panel(4);
let at = panel_thumb(&h, "Page 2");
right_click_at(&mut h, at);
h.get_by_label("Delete pages").click();
h.run_steps(4);
assert_eq!(page_texts(h.state()), ["Page 1", "Page 3", "Page 4"]);
assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo(), Some("Delete page"));
assert!(h.state().views[0].selected.is_empty(), "the deleted pages leave the selection");
h.key_press_modifiers(Modifiers::COMMAND, Key::Z);
h.run_steps(3);
assert_eq!(page_texts(h.state()), ["Page 1", "Page 2", "Page 3", "Page 4"]);
}
#[test]
fn the_delete_key_deletes_pages_picked_in_the_pages_panel_but_never_every_page() {
let mut h = pages_panel(3);
// ⌘-click picks pages 2 and 3 (and the current page, 1: the first ⌘-click keeps it).
h.get_by_label("Page 2").click_modifiers(Modifiers::COMMAND);
h.run_steps(2);
h.get_by_label("Page 3").click_modifiers(Modifiers::COMMAND);
h.run_steps(2);
let (p1, p3) = (panel_thumb(&h, "Page 1"), panel_thumb(&h, "Page 3"));
h.hover_at(p3);
h.run_steps(1);
assert_eq!(h.state().views[0].selected.iter().copied().collect::<Vec<_>>(), vec![0, 1, 2]);
// Every page picked: Delete does nothing, and the menu item is off.
h.key_press(Key::Delete);
h.run_steps(3);
assert_eq!(page_texts(h.state()).len(), 3, "a document keeps at least one page");
right_click_at(&mut h, p3);
assert!(h.query_by(|n| n.label().as_deref() == Some("Delete pages") && n.is_disabled()).is_some());
h.key_press(Key::Escape);
h.run_steps(2);
// Pages 2 and 3 picked (page 2 current): Delete deletes them, and page 1 becomes current.
h.state_mut().views[0].select_pages(&[1, 2]);
h.hover_at(p1);
h.run_steps(1);
h.key_press(Key::Delete);
h.run_steps(4);
assert_eq!(page_texts(h.state()), ["Page 1"]);
assert_eq!(h.state().views[0].current, 0);
}
#[test]
fn host_dirty_hears_when_unsaved_work_appears_and_goes() {
let reports = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
let sink = reports.clone();
let mut h = harness(2, move |app| {
app.set_option("organize", "on").unwrap();
app.host_dirty = Some(Box::new(move |dirty| sink.lock().unwrap().push(dirty)));
});
assert_eq!(reports.lock().unwrap().last(), Some(&false), "a freshly opened document has nothing unsaved");
h.get_by_label("Rotate clockwise").click();
h.run_steps(3);
assert_eq!(reports.lock().unwrap().last(), Some(&true), "an edit is unsaved work");
let out = temp_path("host-dirty.pdf");
h.state_mut().save_override = Some(out.to_string_lossy().into_owned());
h.key_press_modifiers(Modifiers::COMMAND, Key::S);
h.run_steps(3);
assert_eq!(reports.lock().unwrap().last(), Some(&false), "saving clears it");
let _ = std::fs::remove_file(out);
}