Files
pdfcraft/crates/ui-egui/tests/it/editing.rs
T
Jayesh Prajapati 86730ed144 Tests: UI tests wait for background work by time, not frame count (#4186) (#4242)
* Tests: UI tests wait for background work by time, not frame count (#4186)

A frame count is not a time: on a busy runner the worker had not finished
the open, or the page text had not arrived, within 30 frames or 50 hover
rounds. support::step_until (#[must_use]) steps frames until a condition
holds or 30 s pass, sleeping briefly between frames, and says whether it
held; each test asserts on that with its own message.

- control_open_replies_pending_while_the_worker_opens_and_focuses_once_it_has
  and mode_tabs_keep_the_hand_over_page_text_scrolled_under_them wait with it,
  and so does control.rs call() for its reply.
- support::settle waits for every pending render and fails if the pages are
  not rendered within 30 s. It replaces all the render-pending settle loops
  (40 to 3000 frames) and the local settle/wait_for_pages wrappers, except
  text_select's double-click wait, which is held to 8 frames on purpose to
  stay inside egui's multi-click window, and view.rs's loops that already
  wait by a deadline.
- wait_until is built on step_until, keeping its diagnostic message.
- tabbing_back_to_the_same_field_then_saving_keeps_the_committed_value already
  waited 30 s for its file. It failed because its "same-frame" case shared
  its scratch directory with typing_and_saving_in_the_same_frame_saves_the_last_keystrokes,
  which runs in the same test process (since ee1ee704, #1045) and deleted
  the directory mid-save. forms' and pickers' scratch() now give every call
  a directory of its own.

Fixes #4186

* M14: update ctutils to 0.4.5 for RUSTSEC-2026-0385

The dependency-advisory gate rejects ctutils 0.4.2 (non-constant-time
mask generation and u128 comparisons, fixed in 0.4.4). It comes in
through crypto-bigint for the signing crates. Only its version and
checksum change in Cargo.lock.

(cherry picked from commit 1f8811761a)
2026-10-24 02:05:33 -04:00

5994 lines
271 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 crate::support;
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, OpenOutcome, PdfCraftApp};
use crate::gpu;
/// 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 builder = Harness::builder().with_size(egui::vec2(1400.0, 900.0));
if std::env::var_os("PDFCRAFT_ORGANIZE_SNAPSHOT").is_some() {
builder = builder.wgpu();
}
let mut h = builder.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())
}
/// Organize pages ▸ Insert ▸ `item`.
fn insert_menu(h: &mut Harness<'static, PdfCraftApp>, item: &str) {
h.get_by_label("Insert").click();
h.run_steps(2);
h.get_by_label(item).click();
}
fn type_selection(h: &mut Harness<'static, PdfCraftApp>, text: &str) {
use egui::accesskit::Role;
h.get_by_role_and_label(Role::TextInput, "Selected pages").click();
h.run_steps(1);
h.key_press_modifiers(Modifiers::COMMAND, Key::A);
h.get_by_role_and_label(Role::TextInput, "Selected pages").type_text(text);
h.run_steps(2);
}
#[test]
fn organize_page_range_tracks_thumbnails_and_applies_typed_selection() {
use egui::accesskit::Role;
let _gpu = gpu();
let mut h = organize(100);
h.get_by_label("Select page 2").click();
h.run_steps(2);
assert_eq!(h.get_by_role_and_label(Role::TextInput, "Selected pages").value().as_deref(), Some("2"));
h.get_by_label("Select page 3").click();
h.run_steps(2);
assert_eq!(h.get_by_role_and_label(Role::TextInput, "Selected pages").value().as_deref(), Some("2-3"));
let old = h.state().views[0].selected.clone();
type_selection(&mut h, "1,12,16-18,89,92,93,97,99");
assert_eq!(h.state().views[0].selected, old, "typing is not committed until Enter or focus loss");
h.key_press(Key::Enter);
h.run_steps(2);
assert_eq!(h.state().views[0].target_pages(), [0, 11, 15, 16, 17, 88, 91, 92, 96, 98]);
assert_eq!(h.get_by_role_and_label(Role::TextInput, "Selected pages").value().as_deref(), Some("1, 12, 16-18, 89, 92-93, 97, 99"));
if let Some(path) = std::env::var_os("PDFCRAFT_ORGANIZE_SNAPSHOT") {
h.render().unwrap().save(std::path::Path::new(&path)).unwrap();
}
h.get_by_label("Rotate clockwise").click();
h.run_steps(3);
let doc = h.state().session.get(h.state().views[0].id).unwrap();
for (i, page) in doc.info.pages.iter().enumerate() {
assert_eq!(page.rotation, if h.state().views[0].selected.contains(&i) { 90 } else { 0 });
}
h.key_press_modifiers(Modifiers::COMMAND, Key::Z);
h.run_steps(3);
assert!(h.state().session.get(h.state().views[0].id).unwrap().info.pages.iter().all(|p| p.rotation == 0));
}
#[test]
fn organize_page_range_rejects_invalid_input_and_escape_cancels_without_clearing() {
use egui::accesskit::Role;
let mut h = organize(5);
h.state_mut().set_option("select", "2-3").unwrap();
h.run_steps(2);
for text in ["0", "6", "1,", "no"] {
type_selection(&mut h, text);
h.key_press(Key::Enter);
h.run_steps(2);
assert_eq!(h.state().views[0].target_pages(), [1, 2]);
}
type_selection(&mut h, "1-5");
h.key_press(Key::Escape);
h.run_steps(2);
assert_eq!(h.state().views[0].target_pages(), [1, 2]);
assert_eq!(h.get_by_role_and_label(Role::TextInput, "Selected pages").value().as_deref(), Some("2-3"));
type_selection(&mut h, "5, 4, 4");
h.key_press(Key::Enter);
h.run_steps(2);
assert_eq!(h.state().views[0].target_pages(), [3, 4]);
type_selection(&mut h, " ");
h.key_press(Key::Enter);
h.run_steps(2);
assert!(h.state().views[0].selected.is_empty());
assert!(h.state_mut().set_option("select", "0").is_err());
assert!(h.state_mut().set_option("select", "6").is_err());
}
#[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");
}
/// Issue #1910: the page grid opens on the page the user was reading: it is selected and
/// scrolled into view, whether the document was on a middle page, its last page or a page
/// other than an earlier grid selection.
#[test]
fn organize_opens_on_the_current_page() {
let mut h = harness(60, |_| {});
let in_view = |h: &Harness<'static, PdfCraftApp>, page: usize| {
let viewport = h.state().views[0].viewport_rect();
let label = format!("Page {}", page + 1);
h.query_by_label(&label).is_some_and(|n| viewport.contains_rect(n.rect()))
};
// From a middle page.
h.state_mut().views[0].go_to_page(30);
h.run_steps(2);
assert!(h.state_mut().execute("page.organize"));
h.run_steps(4);
assert!(h.state().views[0].organize);
assert_eq!(h.state().views[0].target_pages(), [30]);
assert!(h.state().views[0].selected.is_empty(), "an empty selection is the current page, so checkboxes still toggle");
assert!(in_view(&h, 30), "the current page is scrolled into view");
assert!(!in_view(&h, 0), "the grid does not open on page 1");
// An earlier grid selection on another page does not win over the page the user went to.
h.get_by_label("Select page 32").click();
h.run_steps(2);
assert_eq!(h.state().views[0].target_pages(), [31]);
h.get_by_label("Close").click();
h.run_steps(2);
assert!(!h.state().views[0].organize);
h.state_mut().views[0].go_to_page(45);
h.run_steps(2);
h.state_mut().set_option("tool", "organize").unwrap();
h.run_steps(4);
assert_eq!(h.state().views[0].target_pages(), [45]);
assert!(in_view(&h, 45));
assert!(!in_view(&h, 1));
// From the last page.
h.get_by_label("Close").click();
h.run_steps(2);
h.state_mut().views[0].go_to_page(59);
h.run_steps(2);
h.state_mut().set_option("organize", "on").unwrap();
h.run_steps(4);
assert_eq!(h.state().views[0].target_pages(), [59]);
assert!(in_view(&h, 59));
assert!(!in_view(&h, 0));
}
#[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 reverse_pages_reverses_the_selection_or_else_the_whole_document() {
let mut h = organize(4);
assert!(h.state_mut().execute("page.reverse"));
h.run_steps(3);
assert_eq!(page_texts(h.state()), ["Page 4", "Page 3", "Page 2", "Page 1"]);
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);
assert!(h.state_mut().execute("page.reverse"));
h.run_steps(3);
assert_eq!(page_texts(h.state()), ["Page 2", "Page 3", "Page 4", "Page 1"]);
}
#[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);
insert_menu(&mut h, "Insert a blank page after the selection");
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 save_as_to_a_new_file_drops_earlier_revisions() {
// #2165: Save As appended a revision, so a deleted page stayed in the new file's first one.
let out = temp_path("save-as-clean.pdf");
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);
h.state_mut().save_override = Some(out.to_string_lossy().into_owned());
assert!(h.state_mut().save_active(pdfcraft_ui_egui::SaveTarget::As));
let saved = std::fs::read(&out).expect("file written");
let count = |needle: &[u8]| saved.windows(needle.len()).filter(|w| *w == needle).count();
assert_eq!(count(b"%%EOF"), 1, "one revision only");
assert_eq!((count(b"(Page 1)"), count(b"(Page 2)")), (1, 0), "the deleted page's content is gone");
assert!(!dirty(&h));
assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().bytes.as_slice(), saved.as_slice());
assert_eq!(page_texts(h.state()), ["Page 1", "Page 3"]);
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);
}
/// An organize harness with a dirty document and the "Save changes?" prompt open.
fn dirty_prompt() -> Harness<'static, PdfCraftApp> {
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
}
/// The prompt has Save chosen on arrival; `keys` then move the choice and Enter or Space presses it.
fn press_after(keys: &[(Modifiers, Key)], activate: Key) -> Harness<'static, PdfCraftApp> {
let mut h = dirty_prompt();
for (m, k) in keys {
h.key_press_modifiers(*m, *k);
h.run_steps(2);
}
h.key_press(activate);
h.run_steps(3);
h
}
#[test]
fn the_save_prompt_moves_between_buttons_with_the_keyboard() {
let none = Modifiers::NONE;
// Left from Save is Cancel: Enter or Space presses it, so nothing closes and nothing saves.
for activate in [Key::Enter, Key::Space] {
let h = press_after(&[(none, Key::ArrowLeft)], activate);
assert_eq!(h.state().views.len(), 1, "{activate:?} on Cancel keeps the document");
assert!(h.state().close_request.is_none());
assert!(dirty(&h), "{activate:?} on Cancel doesn't save");
}
// Two left is Don't save, which closes without saving; Shift+Tab walks the same way.
for keys in [
vec![(none, Key::ArrowLeft), (none, Key::ArrowLeft)],
vec![(Modifiers::SHIFT, Key::Tab), (Modifiers::SHIFT, Key::Tab)],
vec![(none, Key::ArrowLeft), (none, Key::ArrowLeft), (none, Key::ArrowLeft)],
vec![(none, Key::Tab)],
] {
let h = press_after(&keys, Key::Enter);
assert!(h.state().views.is_empty(), "{keys:?} reaches Don't save, which closes the tab");
}
// Tab from Save wraps to Don't save; arrows stop at the right end, so Save stays chosen.
let out = temp_path("arrows-stop.pdf");
let mut h = dirty_prompt();
h.state_mut().save_override = Some(out.to_string_lossy().into_owned());
h.key_press(Key::ArrowRight);
h.run_steps(2);
h.key_press(Key::Enter);
h.run_steps(3);
assert!(h.state().views.is_empty(), "Right from Save stays on Save, which saves");
assert!(out.exists());
let _ = std::fs::remove_file(out);
}
#[test]
fn the_save_prompt_escape_still_cancels_whatever_is_chosen() {
let h = press_after(&[(Modifiers::NONE, Key::ArrowLeft), (Modifiers::NONE, Key::ArrowLeft)], Key::Escape);
assert_eq!(h.state().views.len(), 1, "Escape cancels even with Don't save chosen");
assert!(dirty(&h));
}
#[test]
fn the_save_prompt_starts_on_save_every_time_it_opens() {
// Move to Don't save, cancel with Escape, and ask again: the choice is back on Save.
let out = temp_path("reopened.pdf");
let mut h = dirty_prompt();
h.state_mut().save_override = Some(out.to_string_lossy().into_owned());
h.key_press(Key::ArrowLeft);
h.key_press(Key::ArrowLeft);
h.run_steps(2);
h.key_press(Key::Escape);
h.run_steps(3);
assert_eq!(h.state().views.len(), 1);
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 saved and closed");
assert!(out.exists(), "the second prompt opened on Save");
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 opening_the_same_path_again_focuses_its_tab() {
let pdfs = TempPdfs::new("dedup-open");
let a = pdfs.file("a.pdf", 1);
let b = pdfs.file("b.pdf", 1);
// Inline opens: this pins the synchronous open contract (open vs focus). A live app
// schedules the background worker instead and answers `Pending` until it completes.
let mut h = session_harness(|app| app.run_inline = true);
assert_eq!(h.state_mut().open_path(&a), OpenOutcome::Opened);
assert_eq!(h.state_mut().open_path(&b), OpenOutcome::Opened);
h.run_steps(2);
assert_eq!(h.state().views.len(), 2, "two different files open two tabs");
assert_eq!(h.state_mut().open_path(&a), OpenOutcome::Focused, "opening an open file focuses, it doesn't reopen");
h.run_steps(2);
assert_eq!(h.state().views.len(), 2, "opening an open file again adds no tab");
assert_eq!(h.state().active, Some(0), "the existing tab comes to the front");
assert_eq!(open_paths(h.state()), vec![a, b], "the tab order is untouched");
}
#[test]
fn opening_a_missing_file_fails_and_tells_the_user() {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
let missing = std::env::temp_dir().join(format!("pdfcraft-no-such-file-{}.pdf", std::process::id()));
assert_eq!(app.open_path(&missing.to_string_lossy()), OpenOutcome::Failed);
assert!(app.views.is_empty());
assert!(app.toast.is_some(), "the failure is still reported to the user");
}
#[test]
fn opening_the_same_file_with_a_dot_component_focuses_its_tab() {
let pdfs = TempPdfs::new("dedup-dot");
let a = pdfs.file("a.pdf", 1);
let dotted = format!("{}/./a.pdf", pdfs.0.display());
let mut h = session_harness(|app| app.run_inline = true);
h.state_mut().open_path(&a);
h.state_mut().open_path(&dotted);
h.run_steps(2);
assert_eq!(h.state().views.len(), 1, "another spelling of the same file matches");
assert_eq!(h.state().active, Some(0));
}
#[test]
fn opening_a_file_whose_copy_vanished_from_disk_focuses_its_tab() {
let pdfs = TempPdfs::new("dedup-vanished");
let a = pdfs.file("a.pdf", 1);
let mut h = session_harness(|_| {});
h.state_mut().open_path(&a);
// A live window opens through the background worker; the tab has to exist before the file
// is deleted, or the worker itself fails to read what this test is about to remove.
support::wait_until(&mut h, "the vanished file's tab", |app| app.views.len() == 1);
std::fs::remove_file(&a).unwrap();
h.state_mut().open_path(&a);
h.run_steps(2);
assert_eq!(h.state().views.len(), 1, "no second tab");
assert_eq!(h.state().active, Some(0), "the open tab is focused");
assert!(h.state().toast.is_none(), "no read error: the file is never read again");
}
#[test]
fn opening_an_encrypted_document_twice_keeps_one_prompt_and_one_tab() {
let pdfs = TempPdfs::new("dedup-encrypted");
let secret = pdfs.0.join("secret.pdf");
std::fs::write(&secret, protected("pw", "owner", -1)).unwrap();
let s = secret.to_string_lossy().into_owned();
let mut h = session_harness(|app| app.run_inline = true);
h.state_mut().open_path(&s);
h.state_mut().open_path(&s);
h.run_steps(2);
assert!(h.state().views.is_empty(), "still locked");
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, "unlocking opens one tab, not two");
}
#[test]
fn opening_documents_without_a_path_still_open_twice() {
let bytes = fixture(1);
let mut h = session_harness(|_| {});
h.state_mut().open_bytes("copy.pdf", None, bytes.clone()).unwrap();
h.state_mut().open_bytes("copy.pdf", None, bytes).unwrap();
h.run_steps(2);
assert_eq!(h.state().views.len(), 2, "bytes have no path to compare (attachments, conversions)");
}
#[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 ──────────────────────────────────────────────
/// A one-page PDF whose form holds one text field named `name`.
fn form_pdf(name: &str) -> Vec<u8> {
form_pdf_pages(name, 1)
}
/// A PDF of `pages` pages whose form holds one text field named `name` (on the first page).
fn form_pdf_pages(name: &str, pages: usize) -> Vec<u8> {
let kids: Vec<String> = (0..pages).map(|i| format!("{} 0 R", 3 + i)).collect();
let mut objs: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R] >> >>".into(),
format!("<< /Type /Pages /Kids [{}] /Count {pages} /MediaBox [0 0 300 400] >>", kids.join(" ")),
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>".into(),
format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T ({name}) /V (x) /Rect [60 60 200 80] /P 3 0 R >>"),
];
for _ in 1..pages {
objs.push("<< /Type /Page /Parent 2 0 R >>".to_string()); // objects after the field's number
}
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
}
/// organize.field-name-conflicts: the Combine tab warns, before anything is combined, that a
/// file's form fields collide with earlier files' and will be renamed, naming the new names.
#[test]
fn combine_list_warns_that_colliding_field_names_will_be_renamed() {
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(), form_pdf("Text1")), ("two.pdf".into(), form_pdf("Text1"))]);
});
h.run_steps(3);
h.get_by_label_contains("will be renamed");
h.get_by_label_contains("Text1 → Text1-1");
}
/// A file split into parts (its pages moved among other files') is combined as one file, once —
/// so the preview must not warn that its field names collide with their own.
#[test]
fn combine_list_does_not_warn_that_a_split_file_collides_with_itself() {
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(), form_pdf_pages("Text1", 2))]);
});
h.run_steps(3);
// one.pdf in two parts — pages 1 and 2, sharing a group, as moving its pages apart leaves it.
let mut part = h.state().combine_draft[0].clone();
part.id += 1000;
h.state_mut().combine_draft[0].range = "1".into();
part.range = "2".into();
h.state_mut().combine_draft.push(part);
h.run_steps(3);
assert!(h.query_by_label_contains("will be renamed").is_none(), "one file, however many parts, cannot collide with itself");
}
/// Copying a page with a field and pasting it back into the same document is a duplicate: the
/// copy keeps the field's name rather than becoming Text1-1.
#[test]
fn pasting_pages_into_their_own_document_keeps_field_names() {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("form.pdf", None, form_pdf("Text1")).unwrap();
app.views[0].select_pages(&[0]);
assert!(app.execute("page.copy"));
assert!(app.execute("page.paste"));
let doc = app.session.get(app.views[0].id).unwrap();
assert_eq!(doc.info.pages.len(), 2, "pasted");
assert!(doc.form.iter().all(|f| f.name == "Text1"), "{:?}", doc.form.iter().map(|f| &f.name).collect::<Vec<_>>());
}
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 replace_button_replaces_selected_pages_in_organize() {
let dir = temp_path("replace-btn");
std::fs::create_dir_all(&dir).unwrap();
let other = dir.join("other.pdf");
std::fs::write(&other, fixture(5)).unwrap();
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.state_mut().pick_override = Some(vec![other.to_string_lossy().into_owned()]);
h.get_by_label("Replace").click();
h.run_steps(3);
egui_kittest::kittest::Queryable::get_by_label(&h, "Replace Pages");
assert_eq!(h.state().replace_draft.as_ref().unwrap().from, 2);
assert_eq!(h.state().replace_draft.as_ref().unwrap().to, 3);
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 pages"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn replace_pages_from_thumbnail_context_menu() {
let dir = temp_path("replace-ctx");
std::fs::create_dir_all(&dir).unwrap();
let other = dir.join("other.pdf");
std::fs::write(&other, fixture(5)).unwrap();
let mut h = organize(3);
h.state_mut().pick_override = Some(vec![other.to_string_lossy().into_owned()]);
h.get_by_label("Page 2").click_button(egui::PointerButton::Secondary);
h.run_steps(2);
h.get_all_by_label("Replace pages…").last().unwrap().click();
h.run_steps(3);
egui_kittest::kittest::Queryable::get_by_label(&h, "Replace Pages");
assert_eq!(h.state().replace_draft.as_ref().unwrap().from, 2);
assert_eq!(h.state().replace_draft.as_ref().unwrap().to, 2);
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"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn replace_pages_from_pages_panel_context_menu() {
let dir = temp_path("replace-panel-ctx");
std::fs::create_dir_all(&dir).unwrap();
let other = dir.join("other.pdf");
std::fs::write(&other, fixture(5)).unwrap();
let mut h = harness(4, |app| app.right = Some(pdfcraft_ui_egui::RightPanel::Pages));
h.state_mut().pick_override = Some(vec![other.to_string_lossy().into_owned()]);
let thumb = h.get_all_by_label("Page 2").max_by(|a, b| a.rect().left().total_cmp(&b.rect().left())).unwrap();
thumb.click_button(egui::PointerButton::Secondary);
h.run_steps(2);
h.get_all_by_label("Replace pages…").last().unwrap().click();
h.run_steps(3);
egui_kittest::kittest::Queryable::get_by_label(&h, "Replace Pages");
assert_eq!(h.state().replace_draft.as_ref().unwrap().from, 2);
assert_eq!(h.state().replace_draft.as_ref().unwrap().to, 2);
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(), 4);
assert_eq!(doc.can_undo(), Some("Replace page"));
let _ = std::fs::remove_dir_all(&dir);
}
#[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);
insert_menu(&mut h, "Insert pages from a file…");
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, |_| {});
let original = h.state().views[0].id;
drop_files(&mut h, vec![Dropped("other.pdf", fixture(1))]);
support::wait_until(&mut h, "dropped document", |app| app.views.len() == 2 && app.password_prompt.is_none());
assert_eq!(h.state().views.len(), 2);
assert_eq!(texts_of(h.state(), 0), ["Page 1", "Page 2"]);
assert!(h.state().views.iter().any(|view| view.id == original));
assert_eq!(h.state().session.get(original).unwrap().bytes.as_slice(), fixture(2).as_slice());
let (_, id) = h.state().active_ids().unwrap();
let doc = h.state().session.get(id).unwrap();
assert_eq!(doc.name, "other.pdf");
assert_eq!(doc.info.pages.len(), 1);
assert_eq!(doc.bytes.as_slice(), fixture(1).as_slice());
}
/// The Pages panel's thumbnail of page `label` (the panel is right of the page view).
fn panel_thumb_rect(h: &Harness<'static, PdfCraftApp>, label: &str) -> egui::Rect {
h.get_all_by_label(label).map(|n| n.rect()).max_by(|a, b| a.left().total_cmp(&b.left())).unwrap()
}
#[test]
fn files_dropped_on_the_pages_panel_are_inserted_there() {
let mut h = harness(3, |app| app.right = Some(pdfcraft_ui_egui::RightPanel::Pages));
// The top half of page 2's thumbnail: between pages 1 and 2.
let thumb = panel_thumb_rect(&h, "Page 2");
h.hover_at(thumb.center() - egui::vec2(0.0, thumb.height() * 0.25));
h.run_steps(2);
assert_eq!(h.state().views[0].panel_gap, Some(1));
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");
// The bottom half of the last thumbnail: the end.
let mut h = harness(2, |app| app.right = Some(pdfcraft_ui_egui::RightPanel::Pages));
let last = panel_thumb_rect(&h, "Page 2");
h.hover_at(last.center() + egui::vec2(0.0, last.height() * 0.25));
h.run_steps(2);
drop_files(&mut h, vec![Dropped("note.txt", b"a note".to_vec())]);
h.run_steps(4);
assert_eq!(h.state().views.len(), 1);
assert_eq!(texts_of(h.state(), 0), ["Page 1", "Page 2", "a note"]);
}
#[test]
fn files_dropped_beside_the_pages_panel_still_open_as_documents() {
let mut h = harness(2, |app| app.right = Some(pdfcraft_ui_egui::RightPanel::Pages));
// Over the page view, not the panel.
let thumb = panel_thumb_rect(&h, "Page 1");
h.hover_at(egui::pos2(thumb.left() - 300.0, thumb.center().y));
h.run_steps(2);
drop_files(&mut h, vec![Dropped("other.pdf", fixture(1))]);
// Native opening prepares the document on a worker; three UI frames need not give it
// any CPU time. Wait for the result, as the other dropped-document regression does.
support::wait_until(&mut h, "document dropped beside the Pages panel", |app| app.views.len() == 2 && app.password_prompt.is_none());
assert_eq!(h.state().views.len(), 2);
assert_eq!(texts_of(h.state(), 0), ["Page 1", "Page 2"]);
assert_eq!(texts_of(h.state(), 1), ["Page 1"], "the dropped PDF opens as its own document");
}
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");
}
/// As in Acrobat, Organize pages keeps the size last chosen: every document's grid opens at it,
/// and so does the next start of the app.
#[test]
fn page_grid_size_is_remembered_for_every_document_and_restart() {
let mut h = organize(3);
h.get_by_label("Larger pages").click();
h.run_steps(3);
assert_eq!(grid_zoom(&h), 1.25);
h.state_mut().open_bytes("other.pdf", None, fixture(2)).expect("second fixture opens");
h.state_mut().set_option("organize", "on").unwrap();
h.run_steps(4);
let other = h.state().active.expect("the new document is active");
assert_ne!(other, 0);
assert_eq!(h.state().views[other].grid_zoom(), 1.25, "the other document");
let settings = h.state().persist();
let h = harness(3, move |app| {
app.restore(&settings);
app.set_option("organize", "on").unwrap();
});
assert_eq!(grid_zoom(&h), 1.25, "after a restart");
}
#[test]
fn a_damaged_remembered_page_grid_size_is_ignored_or_clamped() {
let mut app = PdfCraftApp::new();
app.restore(r#"{"organize_zoom": "huge"}"#);
assert_eq!(app.organize_zoom, 1.0);
app.restore(r#"{"organize_zoom": 40.0}"#);
assert_eq!(app.organize_zoom, 3.0);
app.restore(r#"{"organize_zoom": -2}"#);
assert_eq!(app.organize_zoom, 0.5);
}
#[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_all_by_label("Split").last().unwrap().click();
h.run_steps(3);
h.get_by_label_contains("Creates 3 files from 3 pages");
h.get_all_by_label("Split").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 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,
left: None,
top: None,
});
}
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_all_by_label("Split").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 - 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_all_by_label("Split").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 (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> {
encrypted(fixture(2), algorithm, user, owner, permissions)
}
/// `bytes` rewritten encrypted.
fn encrypted(bytes: Vec<u8>, algorithm: pdfcraft_cos::Algorithm, user: &str, owner: &str, permissions: i32) -> Vec<u8> {
let mut doc = pdfcraft_cos::Document::open(std::sync::Arc::new(bytes)).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);
support::wait_until(&mut h, "unlocked document", |app| app.views.len() == 1);
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:?}");
}
}
/// #995, #1077: a file with only a permissions password opens without asking, restricted. Its
/// notice offers Unlock…, which takes the permissions password and lifts the restrictions.
#[test]
fn the_security_notice_unlocks_with_the_permissions_password() {
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();
app
});
h.run_steps(4);
h.get_by_label_contains("This document is secured");
h.get_by_label("Unlock…").click();
h.run_steps(2);
h.get_by_label_contains("Enter its permissions password");
type_password(&mut h, "wrong");
h.get_by_label("Incorrect password. Try again.");
type_password(&mut h, "owner");
assert!(h.state().unlock_prompt.is_none());
assert!(h.query_by_label_contains("This document is secured").is_none(), "the restrictions are lifted");
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("Owner password (no restrictions apply)");
assert!(!h.state().session.get(h.state().active_ids().unwrap().1).unwrap().dirty, "unlocking is not an edit");
}
/// Changing a restricted document's security asks for its permissions password first (Acrobat
/// does too), then carries on.
#[test]
fn remove_security_asks_for_the_permissions_password_first() {
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();
app
});
h.run_steps(4);
assert!(h.state_mut().execute("protect.remove"));
h.run_steps(2);
assert!(h.state().unlock_prompt.is_some(), "asks for the password");
// Cancelling changes nothing.
h.key_press(Key::Escape);
h.run_steps(2);
assert!(h.state().unlock_prompt.is_none());
let id = h.state().active_ids().unwrap().1;
assert!(!h.state().session.get(id).unwrap().dirty);
assert!(h.state_mut().execute("protect.remove"));
h.run_steps(2);
type_password(&mut h, "owner");
let doc = h.state().session.get(id).unwrap();
assert!(doc.dirty && doc.security_summary().is_none(), "security is removed (written by the next save)");
}
/// A secured form still says it is one and offers Highlight fields: the security notice joins
/// the form's instead of replacing it.
#[test]
fn a_secured_form_shows_its_form_notice_and_its_security() {
// Changing and assembling withheld, filling allowed (as government forms are often set).
let bytes = encrypted(include_bytes!("../data/form.pdf").to_vec(), pdfcraft_cos::Algorithm::Aes256, "", "owner", -1036);
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 900.0)).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("form.pdf", None, bytes).unwrap();
app
});
h.run_steps(4);
h.get_by_label_contains("interactive form fields. This document is secured. Some changes are restricted by its security settings.");
h.get_by_label("Hide field highlights");
h.get_by_label("Security settings");
h.get_by_label("Unlock…");
}
#[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 (up to ~4 s: a loaded test machine
// can take far longer than the 0.2 s this used to allow).
for _ in 0..200 {
h.run_steps(2);
if h.state_mut().views[0].select_all() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(20));
}
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.pptx", "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"));
}
/// Pages 1 and 3 are selected. OK replaces those two and leaves the unchecked page between them.
#[test]
fn replace_split_selection_keeps_the_unchecked_page() {
fn sized(n: usize, width: u32, height: u32, label: &str) -> 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 {width} {height}] >>", 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 ({label} {}) 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
}
let open = |selected: &[usize], edited: bool| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("original.pdf", None, sized(3, 200, 300, "Original")).unwrap();
app.views[0].select_pages(selected);
app.start_replace("other.pdf".into(), sized(5, 400, 500, "Replacement"));
if edited {
app.replace_draft.as_mut().unwrap().range_edited = true;
}
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| app);
h.run_steps(2);
h
};
let sizes = |app: &PdfCraftApp| {
let doc = app.session.get(app.views[0].id).unwrap();
doc.info.pages.iter().map(|p| (p.width.round() as i32, p.height.round() as i32)).collect::<Vec<_>>()
};
let mut split = open(&[0, 2], false);
let draft = split.state().replace_draft.as_ref().unwrap();
assert_eq!((draft.from, draft.to, draft.pages.as_slice(), draft.range_edited), (1, 3, &[0usize, 2][..], false));
egui_kittest::kittest::Queryable::get_by_label(&split, "OK").click();
split.run_steps(3);
assert_eq!(texts_of(split.state(), 0), ["Replacement 1", "Original 2", "Replacement 2"]);
assert_eq!(sizes(split.state()), [(400, 500), (200, 300), (400, 500)]);
assert_eq!(split.state().session.get(split.state().views[0].id).unwrap().can_undo(), Some("Replace pages"));
split.state_mut().undo();
split.run_steps(2);
assert_eq!(texts_of(split.state(), 0), ["Original 1", "Original 2", "Original 3"]);
assert_eq!(sizes(split.state()), [(200, 300), (200, 300), (200, 300)]);
let mut span = open(&[0, 2], true);
egui_kittest::kittest::Queryable::get_by_label(&span, "OK").click();
span.run_steps(3);
assert_eq!(texts_of(span.state(), 0), ["Replacement 1", "Replacement 2", "Replacement 3"]);
let mut adjacent = open(&[1, 2], false);
egui_kittest::kittest::Queryable::get_by_label(&adjacent, "OK").click();
adjacent.run_steps(3);
assert_eq!(texts_of(adjacent.state(), 0), ["Original 1", "Replacement 1", "Replacement 2"]);
assert_eq!(sizes(adjacent.state()), [(200, 300), (400, 500), (400, 500)]);
}
#[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");
}
/// A page drag with the copy modifier held — Ctrl (Option on macOS) — instead of none.
/// Modifiers are input state: `ModifiersChanged` once, as the column-select drag does.
fn copy_drag(h: &mut Harness<'static, PdfCraftApp>, from: egui::Pos2, to: egui::Pos2) {
page_drag(h, from, to, copy_modifiers(h));
}
/// The copy modifier of the OS the app reports: Option on macOS, Ctrl elsewhere. That is the OS
/// the gesture itself reads, so a Mac browser — a wasm build, where `cfg!(target_os = "macos")` is
/// false — is covered by asking the context rather than the target.
fn copy_modifiers(h: &Harness<'static, PdfCraftApp>) -> Modifiers {
if h.ctx.os() == egui::os::OperatingSystem::Mac { Modifiers::ALT } else { Modifiers::CTRL }
}
/// Drag the page under `from` to `to` with `modifiers` held for the whole gesture.
fn page_drag(h: &mut Harness<'static, PdfCraftApp>, from: egui::Pos2, to: egui::Pos2, modifiers: Modifiers) {
h.event(egui::Event::ModifiersChanged(modifiers));
h.run_steps(1);
h.hover_at(from);
h.run_steps(1);
h.event(egui::Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers });
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.event(egui::Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers });
h.run_steps(4);
h.event(egui::Event::ModifiersChanged(Modifiers::NONE));
h.run_steps(1);
}
#[test]
fn dragging_with_the_copy_modifier_copies_the_pages_into_the_gap() {
let mut h = organize(3);
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));
copy_drag(&mut h, from, to);
// The original stays first; a copy of page 1 lands after page 3 (#2997).
let after = page_texts(h.state());
assert_eq!(after, vec![before[0].clone(), before[1].clone(), before[2].clone(), before[0].clone()], "{after:?}");
assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo(), Some("Duplicate page"));
let sel: Vec<usize> = h.state().views[0].selected.iter().copied().collect();
assert_eq!(sel, vec![3], "the copy is selected");
}
/// macOS is not only `cfg!(target_os = "macos")`: the wasm build runs in a Mac browser, where the
/// cfg is false but the context says Mac. The gesture must follow the context, or Option-drag
/// there moves the pages instead of copying them (#3092 review).
#[test]
fn option_copies_and_ctrl_moves_when_the_context_reports_macos() {
let grab = |h: &Harness<'static, PdfCraftApp>, label: &str| h.get_by_label(label).rect();
let points = |h: &Harness<'static, PdfCraftApp>| (grab(h, "Page 1").center(), grab(h, "Page 3").right_center() - egui::vec2(10.0, 0.0));
// Ctrl is not the copy modifier on macOS, so that drag moves the page.
let mut h = organize(3);
h.ctx.set_os(egui::os::OperatingSystem::Mac);
let before = page_texts(h.state());
let (from, to) = points(&h);
page_drag(&mut h, from, to, Modifiers::CTRL);
assert_eq!(page_texts(h.state()), vec![before[1].clone(), before[2].clone(), before[0].clone()], "Ctrl moves on macOS");
assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo(), Some("Move page"));
// Option is, so the same drag drops a copy at the gap.
let mut h = organize(3);
h.ctx.set_os(egui::os::OperatingSystem::Mac);
let before = page_texts(h.state());
let (from, to) = points(&h);
page_drag(&mut h, from, to, Modifiers::ALT);
assert_eq!(page_texts(h.state()), vec![before[0].clone(), before[1].clone(), before[2].clone(), before[0].clone()], "Option copies on macOS");
assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo(), Some("Duplicate page"));
let sel: Vec<usize> = h.state().views[0].selected.iter().copied().collect();
assert_eq!(sel, vec![3], "the copy is 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> {
source_font_fixture_with_text("BT /F1 18 Tf 20 220 Td (Serif) Tj ET BT /F2 18 Tf 20 120 Td (Mono) Tj ET")
}
fn source_font_fixture_with_text(body: &str) -> 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 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 deleting_a_draft_does_not_show_the_original_text_underneath() {
let _gpu = gpu();
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).unwrap();
let at = egui::pos2(r.left() + 40.0 / 200.0 * r.width(), r.top() + 142.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);
h.state_mut().views[0].line_editor.as_mut().unwrap().text.clear();
crate::support::settle(&mut h);
let shot = h.render().unwrap();
if let Ok(path) = std::env::var("PDFCRAFT_TEXT_PREVIEW_SHOT") {
shot.save(path).unwrap();
}
// The right half of the original glyphs, away from the caret and editor border.
let k = r.width() / 200.0;
for y in 152..166 {
for x in 65..82 {
let px = (r.left() + x as f32 * k) as u32;
let py = (r.top() + (300 - y) as f32 * k) as u32;
let [red, green, blue, alpha] = shot.get_pixel(px, py).0;
assert!(
!(red < 100 && green < 100 && blue < 100) && alpha == 255,
"the deleted source is still visible at {x},{y}: {:?}",
[red, green, blue, alpha]
);
}
}
assert!(!dirty(&h), "the document is unchanged until the draft is applied");
assert_eq!(page_texts(h.state()), ["Page 1"]);
h.key_press(egui::Key::Escape);
h.run_steps(3);
assert_eq!(page_texts(h.state()), ["Page 1"]);
}
#[test]
fn switching_from_a_narrow_editor_keeps_one_frame_and_a_wide_heading_on_one_line() {
let _gpu = gpu();
let bytes = source_font_fixture_with_text(
"BT /F1 18 Tf 20 220 Td (Editable PDF heading) Tj ET BT /F2 18 Tf 20 120 Td (Mono) Tj ET BT /F2 18 Tf 20 98 Td (Text) Tj ET",
);
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("editor.pdf", None, bytes).unwrap();
app
});
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).unwrap();
for y in [128.0, 228.0] {
let point = egui::pos2(page.left() + 25.0 / 300.0 * page.width(), page.top() + (300.0 - y) / 300.0 * page.height());
h.hover_at(point);
h.run_steps(1);
h.drag_at(point);
h.run_steps(1);
h.drop_at(point);
h.run_steps(4);
if y == 128.0 {
assert_eq!(h.state().views[0].line_editor.as_ref().unwrap().text, "Mono\nText", "opening a paragraph keeps its source line breaks");
h.key_press(Key::Escape);
h.run_steps(2);
}
}
let heading = h
.output()
.shapes
.iter()
.find_map(|shape| match &shape.shape {
egui::Shape::Text(text) if text.galley.text() == "Editable PDF heading" => Some(&text.galley),
_ => None,
})
.expect("the heading editor is painted");
assert_eq!(heading.rows.len(), 1, "opening a wider block must not reuse the previous editor's narrow width");
let frames = h
.output()
.shapes
.iter()
.filter(|shape| {
matches!(&shape.shape,
egui::Shape::Rect(rect) if rect.stroke.color == egui::Color32::from_rgb(0x14, 0x73, 0xE6)
&& rect.rect.width() > 100.0 && page.contains(rect.rect.center())
)
})
.count();
assert_eq!(frames, 1, "the active block has only the editor frame");
if let Ok(path) = std::env::var("PDFCRAFT_TEXT_FRAME_SHOT") {
h.render().unwrap().save(path).unwrap();
}
assert!(!dirty(&h));
}
/// Clicking a paragraph changes nothing on the page but the editor's frame and caret: the page's
/// own text stays until something is typed. The editor used to cover the paragraph (white, or
/// dark for pale text) until its textless background was ready, and drew it again in its own fonts.
#[test]
fn opening_a_paragraph_leaves_the_page_as_it_is() {
let _gpu = gpu();
let bytes = source_font_fixture_with_text("0 0 0.45 rg 10 180 280 70 re f 1 1 1 rg BT /F2 20 Tf 20 205 Td (White on a banner) Tj ET");
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("banner.pdf", None, bytes).expect("opens");
app
});
h.run_steps(4);
assert!(h.state_mut().execute("edit.edit_text"));
crate::support::settle(&mut h);
let page = h.state().views[0].page_screen_rect(0).expect("on screen");
let k = page.width() / 300.0;
let block = h.state().session.get(h.state().views[0].id).unwrap().text_blocks(0)[0].rect;
let r = egui::Rect::from_min_max(
egui::pos2(page.left() + block[0] as f32 * k, page.top() + (300.0 - block[3] as f32) * k),
egui::pos2(page.left() + block[2] as f32 * k, page.top() + (300.0 - block[1] as f32) * k),
);
let at = egui::pos2(r.left() + 2.0, r.center().y);
h.hover_at(at);
h.run_steps(2);
let before = h.render().unwrap();
// Inside the paragraph, away from the caret (at the click) and the frame around it.
let inside = r.shrink(3.0);
let changed = |img: &image::RgbaImage| {
let mut n = 0;
for y in inside.top() as u32..inside.bottom() as u32 {
for x in inside.left() as u32..inside.right() as u32 {
if (x as f32 - at.x).abs() < 14.0 {
continue;
}
let (a, b) = (img.get_pixel(x, y).0, before.get_pixel(x, y).0);
if a.iter().zip(b.iter()).take(3).any(|(p, q)| (i32::from(*p) - i32::from(*q)).abs() > 40) {
n += 1;
}
}
}
n
};
h.drag_at(at);
h.run_steps(1);
h.drop_at(at);
for frame in 0..8 {
h.step();
let n = changed(&h.render().unwrap());
assert!(n < 20, "frame {frame} after the click changed {n} pixels of the paragraph");
}
assert_eq!(h.state().views[0].line_editor.as_ref().map(|e| e.text.as_str()), Some("White on a banner"));
}
/// A quick click (press and release in one frame) from an open, unchanged paragraph straight into
/// another one: in no frame does the first paragraph lose its text, and the new editor's frame
/// never spans the first one (it took the first paragraph's measured extent for a frame).
#[test]
fn a_quick_click_into_another_paragraph_keeps_both_in_place() {
let _gpu = gpu();
let bytes = source_font_fixture_with_text("BT /F1 24 Tf 20 220 Td (Top) Tj ET BT /F2 24 Tf 20 120 Td (Bottom) Tj ET");
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("boxes.pdf", None, bytes).expect("opens");
app
});
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 k = page.width() / 300.0;
let mut blocks = {
let s = h.state();
s.session.get(s.views[0].id).unwrap().text_blocks(0)
};
blocks.sort_by(|a, b| b.rect[1].total_cmp(&a.rect[1]));
let screen = |b: &pdfcraft_engine::TextBlock| {
egui::Rect::from_min_max(
egui::pos2(page.left() + b.rect[0] as f32 * k, page.top() + (300.0 - b.rect[3] as f32) * k),
egui::pos2(page.left() + b.rect[2] as f32 * k, page.top() + (300.0 - b.rect[1] as f32) * k),
)
};
let (top, bottom) = (screen(&blocks[0]), screen(&blocks[1]));
click_at(&mut h, top.center());
crate::support::settle(&mut h);
assert_eq!(h.state().views[0].line_editor.as_ref().map(|e| e.text.as_str()), Some("Top"));
let ink = |pixels: &image::RgbaImage, r: egui::Rect| {
(r.top() as u32..r.bottom() as u32)
.flat_map(|y| (r.left() as u32..r.right() as u32).map(move |x| (x, y)))
.filter(|(x, y)| pixels.get_pixel(*x, *y).0[..3].iter().all(|v| *v < 90))
.count()
};
h.hover_at(bottom.center());
h.run_steps(1);
let before = ink(&h.render().unwrap(), top.shrink(2.0));
assert!(before > 50, "the top paragraph shows");
for pressed in [true, false] {
h.event(egui::Event::PointerButton { pos: bottom.center(), button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE });
}
for frame in 0..4 {
h.step();
let now = ink(&h.render().unwrap(), top.shrink(2.0));
assert!(now * 2 > before, "frame {frame}: the top paragraph lost its text ({now} of {before} dark pixels)");
for shape in &h.output().shapes {
if let egui::Shape::Rect(r) = &shape.shape
&& r.stroke.width == 1.5
&& r.rect.contains(bottom.center())
{
assert!(!r.rect.intersects(top), "frame {frame}: the bottom editor's frame {:?} spans the top paragraph {top:?}", r.rect);
}
}
}
assert_eq!(h.state().views[0].line_editor.as_ref().map(|e| e.text.as_str()), Some("Bottom"));
}
/// #2022: clicking straight into another text box while one is open for typing must apply the
/// open paragraph first; the typed text used to be dropped with the editor that switched away.
#[test]
fn switching_straight_to_another_box_applies_the_open_paragraph() {
let bytes = source_font_fixture_with_text("BT /F1 24 Tf 20 220 Td (Top) Tj ET BT /F2 24 Tf 20 120 Td (Bottom) Tj ET");
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("boxes.pdf", None, bytes).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 k = r.width() / 300.0;
let mut blocks = {
let s = h.state();
s.session.get(s.views[0].id).unwrap().text_blocks(0)
};
blocks.sort_by(|a, b| b.rect[1].partial_cmp(&a.rect[1]).unwrap_or(std::cmp::Ordering::Equal));
assert_eq!(blocks.iter().map(|b| b.text.as_str()).collect::<Vec<_>>(), ["Top", "Bottom"], "two editable boxes");
let center = |b: &pdfcraft_engine::TextBlock| {
egui::pos2(r.left() + ((b.rect[0] + b.rect[2]) / 2.0) as f32 * k, r.top() + (300.0 - (b.rect[1] + b.rect[3]) / 2.0) as f32 * k)
};
// Open the top box and type into it.
click_at(&mut h, center(&blocks[0]));
let ed = h.state().views[0].line_editor.clone().expect("the top box opens for typing");
assert_eq!(ed.text, "Top");
h.state_mut().views[0].line_editor.as_mut().unwrap().text = "Top edited".into();
h.run_steps(1);
// Click straight into the bottom box: the open paragraph is applied, and the bottom box opens.
click_at(&mut h, center(&blocks[1]));
let s = h.state();
let doc = s.session.get(s.views[0].id).unwrap();
assert_eq!(
doc.text_blocks(0).iter().map(|b| b.text.clone()).collect::<Vec<_>>(),
["Top edited", "Bottom"],
"the typed text is applied, not dropped"
);
assert_eq!(doc.can_undo(), Some("Edit text"));
let ed = s.views[0].line_editor.clone().expect("the bottom box is now open");
assert_eq!(ed.text, "Bottom");
}
// Regression test for #811: text typed into an open paragraph was not counted as unsaved work, so
// closing the tab or quitting discarded it without the save prompt.
#[test]
fn text_typed_into_an_open_paragraph_counts_as_unsaved_work() {
let bytes = source_font_fixture_with_text("BT /F1 24 Tf 20 220 Td (Top) Tj ET");
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("box.pdf", None, bytes).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 k = r.width() / 300.0;
let block = {
let s = h.state();
s.session.get(s.views[0].id).unwrap().text_blocks(0).into_iter().next().expect("one editable box")
};
let center = egui::pos2(
r.left() + ((block.rect[0] + block.rect[2]) / 2.0) as f32 * k,
r.top() + (300.0 - (block.rect[1] + block.rect[3]) / 2.0) as f32 * k,
);
click_at(&mut h, center);
assert_eq!(h.state().views[0].line_editor.as_ref().map(|e| e.text.as_str()), Some("Top"), "the paragraph is open for typing");
assert_eq!(h.state().first_dirty(), None, "opening a paragraph changes nothing");
h.state_mut().views[0].line_editor.as_mut().unwrap().text = "Top, and a draft not applied yet".into();
h.run_steps(1);
assert!(!h.state().session.get(h.state().views[0].id).unwrap().dirty, "the document itself is untouched until the draft is applied");
assert_eq!(h.state().first_dirty(), Some(0), "the draft in the open paragraph is unsaved work");
// Typing the original text back leaves nothing to lose.
h.state_mut().views[0].line_editor.as_mut().unwrap().text = "Top".into();
h.run_steps(1);
assert_eq!(h.state().first_dirty(), None, "a draft equal to the page text is not unsaved work");
}
/// Dark pixels in `r`: glyph ink on a white page.
fn ink(pixels: &image::RgbaImage, r: egui::Rect) -> usize {
let (x0, y0, x1, y1) = (r.left().max(0.0) as u32, r.top().max(0.0) as u32, r.right() as u32, r.bottom() as u32);
(y0..y1.min(pixels.height()))
.flat_map(|y| (x0..x1.min(pixels.width())).map(move |x| (x, y)))
.filter(|(x, y)| pixels.get_pixel(*x, *y).0[..3].iter().all(|v| *v < 90))
.count()
}
/// Applying a paragraph changes the page, whose raster still has the old text until it is
/// rendered again. Until then the paragraph keeps the look it had in the editor; the old text
/// used to come back under the new outline for those frames and then jump to the new one.
#[test]
fn an_applied_paragraph_keeps_its_edited_look_until_the_page_is_redrawn() {
let _gpu = gpu();
let bytes = source_font_fixture_with_text("BT /F1 24 Tf 20 220 Td (Topmost) Tj ET BT /F2 24 Tf 20 120 Td (Bottom) Tj ET");
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("boxes.pdf", None, bytes).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 k = r.width() / 300.0;
let top = {
let s = h.state();
let blocks = s.session.get(s.views[0].id).unwrap().text_blocks(0);
let b = blocks.iter().find(|b| b.text == "Topmost").expect("the top box").rect;
egui::Rect::from_min_max(
egui::pos2(r.left() + b[0] as f32 * k, r.top() + (300.0 - b[3] as f32) * k),
egui::pos2(r.left() + b[2] as f32 * k, r.top() + (300.0 - b[1] as f32) * k),
)
};
// "T" stays; the rest of the word goes.
let head = egui::Rect::from_min_max(top.min, egui::pos2(top.left() + top.width() * 0.12, top.bottom()));
let tail = egui::Rect::from_min_max(egui::pos2(top.left() + top.width() * 0.3, top.top()), top.max).shrink(2.0);
click_at(&mut h, top.center());
support::wait_until(&mut h, "the editor's textless page", |app| !app.render_pending());
h.state_mut().views[0].line_editor.as_mut().expect("the top box is open").text = "T".into();
h.run_steps(2);
h.state_mut().views[0].hold_rasters = true;
h.key_press_modifiers(Modifiers::COMMAND, Key::Enter);
for frame in 0..4 {
h.step();
assert!(h.state().views[0].line_editor.is_none(), "applied");
let pixels = h.render().unwrap();
assert!(ink(&pixels, head) > 20, "frame {frame} after applying shows no text");
assert_eq!(ink(&pixels, tail), 0, "frame {frame} after applying shows the old text again");
}
h.state_mut().views[0].hold_rasters = false;
support::wait_until(&mut h, "the page drawn with the edit", |app| !app.render_pending());
h.run_steps(2);
let pixels = h.render().unwrap();
assert!(ink(&pixels, head) > 20, "the page draws the applied text");
assert_eq!(ink(&pixels, tail), 0, "and not the old one");
let s = h.state();
assert_eq!(s.session.get(s.views[0].id).unwrap().text_blocks(0).iter().map(|b| b.text.clone()).collect::<Vec<_>>(), ["T", "Bottom"]);
}
/// A paragraph whose edit fails (here a letter no font can draw) stays open with what was typed,
/// as a form field does; the editor used to close with the error and the typing was lost.
#[test]
fn a_paragraph_whose_edit_fails_stays_open_with_its_typing() {
let bytes = source_font_fixture_with_text("BT /F1 24 Tf 20 220 Td (Top) Tj ET BT /F2 24 Tf 20 120 Td (Bottom) Tj ET");
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("boxes.pdf", None, bytes).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 k = r.width() / 300.0;
let top = egui::pos2(r.left() + 30.0 * k, r.top() + (300.0 - 228.0) * k);
click_at(&mut h, top);
assert_eq!(h.state().views[0].line_editor.as_ref().map(|e| e.text.as_str()), Some("Top"));
// U+0E01 (Thai): neither the page's font nor a fallback face draws it.
h.state_mut().views[0].line_editor.as_mut().unwrap().text = "Top ก".into();
h.run_steps(1);
h.key_press_modifiers(Modifiers::COMMAND, Key::Enter);
h.run_steps(3);
let s = h.state();
assert!(s.toast.as_ref().is_some_and(|(m, _)| m.starts_with("Edit text failed")), "the failure is said: {:?}", s.toast);
assert_eq!(s.session.get(s.views[0].id).unwrap().can_undo(), None, "nothing was applied");
let ed = s.views[0].line_editor.as_ref().expect("the paragraph is still open");
assert_eq!((ed.block, ed.text.as_str()), (0, "Top ก"), "with what was typed");
// Taking the letter out applies it.
h.state_mut().views[0].line_editor.as_mut().unwrap().text = "Top edited".into();
h.run_steps(1);
h.key_press_modifiers(Modifiers::COMMAND, Key::Enter);
h.run_steps(3);
let s = h.state();
assert!(s.views[0].line_editor.is_none());
let doc = s.session.get(s.views[0].id).unwrap();
assert_eq!(doc.text_blocks(0).iter().map(|b| b.text.clone()).collect::<Vec<_>>(), ["Top edited", "Bottom"]);
}
/// Two boxes, "Top" and "Bottom", in Edit text, with egui's animations on: the window animates
/// unless the system asks for less motion, and the test harness turns them off.
fn two_boxes_animated() -> (Harness<'static, PdfCraftApp>, [egui::Rect; 2]) {
let bytes = source_font_fixture_with_text("BT /F1 24 Tf 20 220 Td (Top) Tj ET BT /F2 24 Tf 20 120 Td (Bottom) Tj ET");
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("boxes.pdf", None, bytes).expect("opens");
app
});
h.ctx.all_styles_mut(|s| s.animation_time = 0.2);
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 k = r.width() / 300.0;
let mut blocks = {
let s = h.state();
s.session.get(s.views[0].id).unwrap().text_blocks(0)
};
blocks.sort_by(|a, b| b.rect[1].partial_cmp(&a.rect[1]).unwrap_or(std::cmp::Ordering::Equal));
let on_screen = |b: &pdfcraft_engine::TextBlock| {
egui::Rect::from_min_max(
egui::pos2(r.left() + b.rect[0] as f32 * k, r.top() + (300.0 - b.rect[3] as f32) * k),
egui::pos2(r.left() + b.rect[2] as f32 * k, r.top() + (300.0 - b.rect[1] as f32) * k),
)
};
(h, [on_screen(&blocks[0]), on_screen(&blocks[1])])
}
/// The colour of the editor's frame (the accent stroke around `r`) this frame, if it is painted.
fn editor_frame_color(h: &Harness<'static, PdfCraftApp>, r: egui::Rect) -> Option<egui::Color32> {
h.output().shapes.iter().find_map(|shape| match &shape.shape {
egui::Shape::Rect(rect) if rect.stroke.width == 1.5 && rect.rect.contains(r.center()) => Some(rect.stroke.color),
_ => None,
})
}
/// A paragraph opens at once: it used to fade in over a fifth of a second, its frame dimmed and
/// the page's own copy of the text showing through the editor's cover.
#[test]
fn a_paragraph_editor_opens_at_full_opacity() {
let (mut h, [top, _]) = two_boxes_animated();
h.hover_at(top.center());
h.run_steps(1);
h.drag_at(top.center());
h.run_steps(1);
h.drop_at(top.center());
let accent = egui::Color32::from_rgb(0x14, 0x73, 0xE6);
for frame in 0..8 {
h.step();
// The click's own frame opens it: the editor's frame is there from then on, at full colour.
assert_eq!(editor_frame_color(&h, top), Some(accent), "frame {frame} after the click");
}
}
#[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"));
// The source container stays visible while added text can overflow it.
assert!(ed.growth().is_none(), "opening the editor preserves the source container");
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");
}
/// The Read mode command (View ▸ Read mode, its shortcut, the palette) leaves Edit as the Edit tab
/// does when another tab is chosen (#2241): Edit text & images is put down, and a paragraph still
/// open in the editor is applied instead of staying open, editable, over the page in Read mode.
#[test]
fn the_read_mode_command_applies_an_open_paragraph_and_puts_edit_text_down() {
use pdfcraft_ui_egui::{Mode, QuickTool};
let mut h = harness(1, |_| {});
h.get_by_label("Edit").click();
h.run_steps(3);
assert_eq!((h.state().mode, h.state().quick_tool), (Mode::Edit, QuickTool::EditText));
// Open the line "Page 1" (at (20, 150) on a 200 × 300 page, 24 pt) and retype it.
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_eq!(h.state().views[0].line_editor.as_ref().expect("the line opens for editing").text, "Page 1");
h.state_mut().views[0].line_editor.as_mut().unwrap().text = "Chapter One".into();
h.run_steps(1);
assert!(h.state_mut().execute("view.read_mode"));
h.run_steps(3);
let s = h.state();
assert_eq!(s.mode, Mode::Read);
assert_eq!(s.quick_tool, QuickTool::Select, "Edit text & images is put down");
assert!(s.views[0].line_editor.is_none(), "no paragraph editor is left open in Read mode");
let doc = s.session.get(s.views[0].id).unwrap();
assert_eq!(doc.can_undo(), Some("Edit text"), "the open paragraph's edit is applied");
assert_eq!(doc.text_lines(0)[0].text, "Chapter One");
assert_eq!(texts_of(s, 0), ["Chapter One"], "the page shows it");
}
#[test]
fn text_tool_clicking_source_text_edits_instead_of_adding_a_copy() {
let mut h = harness(1, |_| {});
assert!(h.state_mut().execute("edit.text"));
h.run_steps(2);
let page = h.state().views[0].page_screen_rect(0).unwrap();
let blank = egui::pos2(page.left() + 0.2 * page.width(), page.top() + 0.1 * page.height());
h.hover_at(blank);
h.run_steps(1);
h.drag_at(blank);
h.run_steps(1);
h.drop_at(blank);
h.run_steps(3);
h.event(egui::Event::Text("Keep this note".into()));
h.run_steps(3);
let point = egui::pos2(page.left() + 40.0 / 200.0 * page.width(), page.top() + 142.0 / 300.0 * page.height());
h.hover_at(point);
h.run_steps(1);
h.drag_at(point);
h.run_steps(1);
h.drop_at(point);
h.run_steps(4);
assert!(h.state().views[0].content.draft.is_none(), "clicking source text must not start an added layer");
assert_eq!(h.state().views[0].line_editor.as_ref().unwrap().text, "Page 1");
assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().added().len(), 1, "the previous draft is kept");
h.key_press_modifiers(Modifiers::COMMAND, Key::A);
h.event(egui::Event::Text("Replacement".into()));
h.run_steps(3);
h.key_press_modifiers(Modifiers::COMMAND, Key::Enter);
h.run_steps(3);
assert_eq!(page_texts(h.state()), ["Keep this note\nReplacement"]);
assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().added().len(), 1, "editing the source adds no second layer");
}
#[test]
fn text_tool_mixed_style_row_grows_in_the_editor_and_saved_pdf() {
let _gpu = gpu();
let bytes = source_font_fixture_with_text("BT /F1 12 Tf 20 220 Td (Small ) Tj /F2 12 Tf (row) Tj ET");
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("mixed-row.pdf", None, bytes).unwrap();
app
});
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).unwrap();
let point = egui::pos2(page.left() + 25.0 / 300.0 * page.width(), page.top() + 75.0 / 300.0 * page.height());
h.hover_at(point);
h.run_steps(1);
h.drag_at(point);
h.run_steps(1);
h.drop_at(point);
h.run_steps(4);
let replacement = "Small row with more words";
h.key_press_modifiers(Modifiers::COMMAND, Key::End);
h.event(egui::Event::Text(" with more words".into()));
h.run_steps(4);
let galley = h
.output()
.shapes
.iter()
.find_map(|s| match &s.shape {
egui::Shape::Text(t) if t.galley.text() == replacement => Some(&t.galley),
_ => None,
})
.expect("the draft is painted");
assert_eq!(galley.rows.len(), 1, "mixed source runs grow as one visual row");
if let Ok(path) = std::env::var("PDFCRAFT_MIXED_ROW_SHOT") {
h.render().unwrap().save(path).unwrap();
}
h.key_press_modifiers(Modifiers::COMMAND, Key::Enter);
h.run_steps(3);
let state = h.state();
let doc = state.session.get(state.views[0].id).unwrap();
assert_eq!(doc.text_blocks(0)[0].editor_text, replacement);
assert_eq!(doc.text_blocks(0)[0].rows.len(), 1);
let bytes = state.session.save_bytes(state.views[0].id).unwrap();
let mut reopened = pdfcraft_engine::Session::new();
let id = reopened.open("again.pdf", None, bytes, None).unwrap();
assert_eq!(reopened.get(id).unwrap().text_blocks(0)[0].rows.len(), 1);
}
/// 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 dragging_the_rotate_knob_turns_a_page_image() {
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();
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("picture.png", None, png.clone()).expect("opens");
app
});
h.run_steps(4);
let id = h.state().views[0].id;
// A smaller picture in the middle of the page: the knob is over the page, not beside it.
let full = h.state().session.get(id).unwrap().page_images(0)[0].rect;
let (qw, qh) = ((full[2] - full[0]) / 4.0, (full[3] - full[1]) / 4.0);
let (mx, my) = ((full[0] + full[2]) / 2.0, (full[1] + full[3]) / 2.0);
h.state_mut().apply_edit(pdfcraft_engine::Edit::EditPageImage {
page: 0,
index: 0,
change: pdfcraft_engine::ImageEdit::Move([mx - qw, my - qh, mx + qw, my + qh]),
});
h.run_steps(4);
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");
let c = r.center();
// Select the picture.
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");
// Grab the knob above the top edge and swing it a quarter turn clockwise (to the right).
let knob = egui::pos2(c.x, c.y - r.height() / 4.0 - 22.0);
let to = egui::pos2(c.x + 100.0, c.y);
h.hover_at(knob);
h.run_steps(1);
h.drag_at(knob);
h.run_steps(1);
for k in 1..=4 {
h.hover_at(knob + (to - knob) * (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("Rotate image"));
let turned = doc.page_images(0)[0].rect;
let (w, hh) = (before[2] - before[0], before[3] - before[1]);
assert!((turned[2] - turned[0] - hh).abs() < 1.5 && (turned[3] - turned[1] - w).abs() < 1.5, "{before:?} -> {turned:?}");
}
#[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("Bold").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));
}
/// Horizontal scale and Character spacing take any value in their ranges, not only the listed
/// ones: values between the listed ones and beyond them, typed in Format text, reach the saved
/// file as the engine writes them.
#[test]
fn the_format_panel_takes_any_scale_and_character_spacing() {
let mut h = harness(1, |_| {});
let id = h.state().views[0].id;
let width = |h: &Harness<'static, PdfCraftApp>, view: usize| {
let s = h.state();
let r = s.session.get(s.views[view].id).unwrap().text_blocks(0)[0].rect;
r[2] - r[0]
};
let plain = width(&h, 0);
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());
// Typed the way a person does: click the field, type, Enter.
let type_in = |h: &mut Harness<'static, PdfCraftApp>, label: &str, value: &str| {
h.get_by_role_and_label(Role::SpinButton, label).click();
h.run_steps(2);
h.event(egui::Event::Text(value.into()));
h.run_steps(1);
h.key_press(Key::Enter);
h.run_steps(4);
};
let extras = |h: &Harness<'static, PdfCraftApp>| h.state().views[0].line_editor.as_ref().map(|e| (e.extras.scale, e.extras.char_spacing));
// Between the listed values (50…200 %, −1…5 pt) and beyond them, within what was always allowed.
type_in(&mut h, "Horizontal scale", "80");
assert_eq!(extras(&h), Some((80.0, 0.0)));
type_in(&mut h, "Horizontal scale", "250");
assert_eq!(extras(&h), Some((250.0, 0.0)));
type_in(&mut h, "Character spacing", "0.25");
assert_eq!(extras(&h), Some((250.0, 0.25)));
type_in(&mut h, "Character spacing", "-2");
assert_eq!(extras(&h), Some((250.0, -2.0)));
assert_eq!(h.state().session.get(id).unwrap().can_undo(), Some("Edit text"));
// Saved and reopened, the paragraph is as wide as the engine draws it with those values.
let bytes = h.state().session.save_bytes(id).unwrap();
h.state_mut().open_bytes("reference.pdf", None, fixture(1)).expect("opens");
let style = pdfcraft_engine::BlockStyle { scale: Some(250.0), char_spacing: Some(-2.0), ..Default::default() };
assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::EditTextBlock { page: 0, block: 0, text: "Page 1".into(), style }));
h.state_mut().open_bytes("saved.pdf", None, bytes.as_ref().clone()).expect("the saved file opens");
h.run_steps(3);
let (reference, saved) = (width(&h, 1), width(&h, 2));
assert!(saved > plain * 1.5, "250 % widens it: {plain} → {saved}");
assert!((saved - reference).abs() < 0.5, "saved {saved}, the engine's own {reference}");
}
#[test]
fn selected_text_formatting_keeps_the_rest_of_the_paragraph_and_reopens() {
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).unwrap();
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(4);
h.key_press_modifiers(Modifiers::COMMAND, Key::ArrowRight);
h.run_steps(1);
h.key_press_modifiers(Modifiers::SHIFT, Key::ArrowLeft);
h.run_steps(2);
assert_eq!(h.state().views[0].line_editor.as_ref().unwrap().selection, Some(5..6));
h.get_by_label("Bold").click();
h.run_steps(5);
let id = h.state().views[0].id;
let doc = h.state().session.get(id).unwrap();
let lines = doc.text_lines(0);
assert!(lines.iter().any(|l| l.text == "1" && l.base_font == "Helvetica-Bold"), "{lines:?}");
assert!(lines.iter().any(|l| l.text.trim_end() == "Page" && l.base_font == "Helvetica"));
let saved = h.state_mut().session.save_bytes(id).unwrap();
let mut reopen = pdfcraft_engine::Session::new();
let rid = reopen.open("saved.pdf", None, saved, None).unwrap();
assert_eq!(reopen.get(rid).unwrap().text_lines(0), lines);
}
/// 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("Move object"));
// 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_eq!(doc.can_undo(), Some("Edit text"), "edge drags still rewrap text");
assert!(h.state().views[0].line_editor.is_none());
}
/// Press and release at `at` without moving.
fn click_at(h: &mut Harness<'static, PdfCraftApp>, at: egui::Pos2) {
h.hover_at(at);
h.run_steps(1);
h.drag_at(at);
h.run_steps(1);
h.drop_at(at);
h.run_steps(4);
}
fn press(h: &mut Harness<'static, PdfCraftApp>, modifiers: Modifiers, key: Key) {
h.key_press_modifiers(modifiers, key);
h.run_steps(4);
}
#[test]
fn arrow_keys_nudge_a_selected_image_instead_of_turning_the_page() {
use pdfcraft_cos::{Dict, Document, Object, SaveOptions, Stream, write_incremental};
let mut doc = Document::open(std::sync::Arc::new(fixture(2))).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 40 200 120 80 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();
let bytes = write_incremental(&doc, &SaveOptions::default()).unwrap();
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("figure.pdf", None, bytes.clone()).expect("opens");
app
});
h.run_steps(4);
assert!(h.state_mut().execute("edit.edit_text"));
h.run_steps(2);
let id = h.state().views[0].id;
let rect = |h: &Harness<'static, PdfCraftApp>| h.state().session.get(id).unwrap().page_images(0)[0].rect;
let before = rect(&h);
let r = h.state().views[0].page_screen_rect(0).expect("on screen");
click_at(&mut h, egui::pos2(r.left() + 100.0 / 200.0 * r.width(), r.top() + (300.0 - 240.0) / 300.0 * r.height()));
assert!(h.state().views[0].image_selection.is_some(), "selected");
// As in Acrobat: an arrow moves it a point, Shift and an arrow ten.
press(&mut h, Modifiers::NONE, Key::ArrowRight);
press(&mut h, Modifiers::SHIFT, Key::ArrowDown);
let moved = rect(&h);
let near = |a: f64, b: f64| (a - b).abs() < 0.01;
assert!(near(moved[0], before[0] + 1.0) && near(moved[1], before[1] - 10.0), "{before:?} → {moved:?}");
assert!(near(moved[2] - moved[0], before[2] - before[0]), "moved, not resized");
assert_eq!(h.state().session.get(id).unwrap().can_undo(), Some("Move image"));
assert_eq!(h.state().views[0].current, 0, "the arrows moved the image, not the page");
assert!(h.state().views[0].image_selection.is_some(), "it stays selected for the next nudge");
}
#[test]
fn an_open_paragraph_moves_by_its_frame_and_by_the_arrow_keys() {
let mut h = harness(2, |_| {});
assert!(h.state_mut().execute("edit.edit_text"));
h.run_steps(2);
let id = h.state().views[0].id;
let block = |h: &Harness<'static, PdfCraftApp>| h.state().session.get(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);
// Just above the paragraph's frame: on the frame, off its text.
let frame = |b: &pdfcraft_engine::TextBlock| screen((b.rect[0] + b.rect[2]) / 2.0, b.rect[3]) - egui::vec2(0.0, 4.0);
let near = |a: f64, b: f64| (a - b).abs() < 0.01;
let before = block(&h);
click_at(&mut h, screen((before.rect[0] + before.rect[2]) / 2.0, (before.rect[1] + before.rect[3]) / 2.0));
assert!(h.state().views[0].line_editor.is_some(), "a click on the text opens it for typing");
// A click on its frame selects the box instead, and the arrows move it.
click_at(&mut h, frame(&before));
assert!(h.state().views[0].line_editor.is_none(), "the frame selects the box, it doesn't type");
press(&mut h, Modifiers::NONE, Key::ArrowRight);
press(&mut h, Modifiers::SHIFT, Key::ArrowUp);
let nudged = block(&h);
assert_eq!(nudged.text, "Page 1");
assert!(near(nudged.rect[0], before.rect[0] + 1.0) && near(nudged.rect[1], before.rect[1] + 10.0), "{:?} → {:?}", before.rect, nudged.rect);
assert_eq!(h.state().session.get(id).unwrap().can_undo(), Some("Move object"));
assert_eq!(h.state().views[0].current, 0, "the arrows moved the paragraph, not the page");
// Open it again and drag it by its frame: it follows the pointer.
press(&mut h, Modifiers::NONE, Key::Escape);
click_at(&mut h, screen((nudged.rect[0] + nudged.rect[2]) / 2.0, (nudged.rect[1] + nudged.rect[3]) / 2.0));
assert!(h.state().views[0].line_editor.is_some());
let from = frame(&nudged);
drag(&mut h, from, from + egui::vec2(30.0 * k, -50.0 * k));
assert!(h.state().views[0].line_editor.is_none());
let dragged = block(&h);
assert!(
(dragged.rect[0] - nudged.rect[0] - 30.0).abs() < 1.5 && (dragged.rect[1] - nudged.rect[1] - 50.0).abs() < 1.5,
"{:?} → {:?}",
nudged.rect,
dragged.rect
);
assert_eq!(h.state().session.get(id).unwrap().text_lines(0).len(), 1, "moved, not retyped");
}
/// Contributor-original text, with an off-origin CropBox. Keep the moving paragraph last in
/// source order so an ordinary hit picks it again when two independent boxes fully overlap.
fn paragraph_drag_fixture(content: &str, rotation: i64, unit: i64) -> Vec<u8> {
use pdfcraft_cos::{Dict, Document, Object, Stream};
let mut doc = Document::open(std::sync::Arc::new(fixture(1))).unwrap();
let page = pdfcraft_model::pages(&doc)[0].obj;
let contents = doc.add(Object::Stream(Stream::flate(Dict::new(), content.as_bytes())));
doc.update_dict(page, |d| {
d.set(b"Contents".to_vec(), Object::Ref(contents));
d.set(b"MediaBox".to_vec(), Object::Array([0, 0, 400, 500].map(Object::Int).to_vec()));
d.set(b"CropBox".to_vec(), Object::Array([20, 30, 380, 470].map(Object::Int).to_vec()));
d.set(b"Rotate".to_vec(), Object::Int(rotation));
d.set(b"UserUnit".to_vec(), Object::Int(unit));
})
.unwrap();
pdfcraft_cos::write_incremental(&doc, &Default::default()).unwrap()
}
fn paragraph_drag_harness(bytes: Vec<u8>, view_rotation: u16) -> Harness<'static, PdfCraftApp> {
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 1100.0)).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
app.session = pdfcraft_engine::Session::new().with_clock(|| 1_700_000_000);
app.set_option("language", "en").unwrap();
app.open_bytes("paragraphs.pdf", None, bytes).unwrap();
app.set_option("zoom", "75").unwrap();
app.views[0].rotation = view_rotation;
assert!(app.execute("edit.edit_text"));
app
});
h.run_steps(4);
h
}
fn dragged_paragraphs(h: &Harness<'static, PdfCraftApp>) -> Vec<pdfcraft_engine::TextBlock> {
let app = h.state();
app.session.get(app.views[0].id).unwrap().text_blocks(0)
}
/// Resolve the current inventory and transform for every gesture, never an old block index
/// or cached screen position. With identical labels, choose the last (topmost) source box.
fn paragraph_drag_points(h: &Harness<'static, PdfCraftApp>, text: &str, offset: [f64; 2]) -> (egui::Pos2, egui::Pos2) {
let app = h.state();
let view = &app.views[0];
let doc = app.session.get(view.id).unwrap();
let b = doc.text_blocks(0).into_iter().rfind(|b| b.text == text).unwrap();
let xf = pdfcraft_ui_egui::canvas::PageXform {
rect: view.page_screen_rect(0).unwrap(),
rot: view.rotation,
pw: doc.info.pages[0].width,
ph: doc.info.pages[0].height,
};
let moved = std::array::from_fn(|i| (b.rect[i] + offset[i % 2]) as f32);
(xf.user_rect(&doc.info, 0, b.rect.map(|v| v as f32)).center(), xf.user_rect(&doc.info, 0, moved).center())
}
fn drag_paragraph(h: &mut Harness<'static, PdfCraftApp>, text: &str, offset: [f64; 2]) {
let (from, to) = paragraph_drag_points(h, text, offset);
assert!(
h.query_all_by_label_contains("object selected").next().is_none() && h.query_all_by_label_contains("objects selected").next().is_none(),
"no objects selected"
);
assert!(h.state().views[0].line_editor.is_none());
drag(h, from, to);
assert!(h.state().views[0].line_editor.is_none(), "a body drag must not open the typing editor");
assert!(
h.query_all_by_label_contains("object selected").next().is_none() && h.query_all_by_label_contains("objects selected").next().is_none(),
"no objects selected"
);
}
fn assert_dragged_paragraphs(actual: &[pdfcraft_engine::TextBlock], expected: &[pdfcraft_engine::TextBlock]) {
assert_eq!(actual.len(), expected.len(), "independent paragraphs must survive");
// Source order is itself an invariant, including when text and geometry are identical.
for (a, e) in actual.iter().zip(expected) {
assert_eq!((&a.text, &a.base_font, a.size, a.bold, a.italic, a.color), (&e.text, &e.base_font, e.size, e.bold, e.italic, e.color));
assert_eq!(a.lines.len(), e.lines.len(), "moving must not rewrap");
assert!(a.rect.iter().zip(e.rect).all(|(a, e)| (a - e).abs() < 0.01), "{a:?} != {e:?}");
}
}
/// Read only the current page contents, not stale revisions or the renderer's deduplicated
/// text layer. These synthetic fixtures contain each literal exactly once per source draw.
fn paragraph_drag_content(h: &Harness<'static, PdfCraftApp>) -> String {
let app = h.state();
let doc = pdfcraft_cos::Document::open(app.session.get(app.views[0].id).unwrap().bytes.clone()).unwrap();
let page = pdfcraft_model::pages(&doc)[0].clone();
let contents = doc.resolve(page.dict.get(b"Contents").unwrap());
let pieces = match contents.as_array() {
Some(a) => a.clone(),
None => vec![contents.as_ref().clone()],
};
let mut bytes = Vec::new();
for piece in pieces {
let object = doc.resolve(&piece);
let pdfcraft_cos::Object::Stream(stream) = object.as_ref() else { panic!("fixture content must be a stream") };
bytes.extend(stream.decoded().unwrap());
}
String::from_utf8(bytes).unwrap()
}
fn save_paragraph_drag(h: &mut Harness<'static, PdfCraftApp>, name: &str, original: &[u8]) -> Vec<u8> {
let out = temp_path(name);
let id = h.state().views[0].id;
h.state_mut().save_override = Some(out.to_string_lossy().into_owned());
h.key_press_modifiers(Modifiers::COMMAND, Key::S);
h.run_steps(4);
let saved = std::fs::read(&out).unwrap();
assert!(saved.starts_with(original), "Save keeps the opened/saved revisions");
assert!(!h.state().session.get(id).unwrap().dirty);
assert_eq!(h.state().session.get(id).unwrap().bytes.as_slice(), saved.as_slice());
std::fs::remove_file(out).unwrap();
saved
}
fn paragraph_drag_shot(h: &mut Harness<'static, PdfCraftApp>, name: &str) {
if let Ok(directory) = std::env::var("PDFCRAFT_PARAGRAPH_DRAG_SHOTS") {
assert!(
crate::support::step_until(h, |h| {
h.run_steps(1);
!h.state().render_pending()
}),
"the screenshot must show the current document generation"
);
let directory = std::path::Path::new(&directory);
std::fs::create_dir_all(directory).unwrap();
h.render().unwrap().save(directory.join(name)).unwrap();
}
}
fn check_paragraph_overlap_drag(identical: bool, reopen_between: bool) {
let _gpu = gpu();
let (stationary, moving) = if identical { ("Repeated", "Repeated") } else { ("Stationary beta", "Moving alpha") };
let content =
format!("BT /F1 12 Tf 60 220 Td ({stationary}) Tj ET BT /F1 12 Tf 250 100 Td (Keep here) Tj ET BT /F1 12 Tf 60 320 Td ({moving}) Tj ET");
let mut saved_base = paragraph_drag_fixture(&content, 0, 1);
let mut h = paragraph_drag_harness(saved_base.clone(), 0);
let before = dragged_paragraphs(&h);
assert_eq!(before.len(), 3);
paragraph_drag_shot(&mut h, &format!("before-{identical}-{reopen_between}.png"));
let moving_index = before.iter().rposition(|b| b.text == moving).unwrap();
let mut overlap = before.clone();
for i in [1, 3] {
overlap[moving_index].rect[i] -= 100.0;
}
drag_paragraph(&mut h, moving, [0.0, -100.0]);
assert_dragged_paragraphs(&dragged_paragraphs(&h), &overlap);
paragraph_drag_shot(&mut h, &format!("overlap-{identical}-{reopen_between}.png"));
h.key_press_modifiers(Modifiers::COMMAND, Key::Z);
h.run_steps(4);
assert_dragged_paragraphs(&dragged_paragraphs(&h), &before);
assert!(h.state().session.get(h.state().views[0].id).unwrap().can_undo().is_none(), "the first release was exactly one step");
h.key_press_modifiers(Modifiers::COMMAND | Modifiers::SHIFT, Key::Z);
h.run_steps(4);
assert_dragged_paragraphs(&dragged_paragraphs(&h), &overlap);
if reopen_between {
let bytes = save_paragraph_drag(&mut h, &format!("paragraph-overlap-{identical}.pdf"), &saved_base);
saved_base = bytes.clone();
h = paragraph_drag_harness(bytes, 0);
assert_dragged_paragraphs(&dragged_paragraphs(&h), &overlap);
}
// A rewrite here used to remove the stationary box, even if its text was different.
drag_paragraph(&mut h, moving, [150.0, 0.0]);
let mut after = overlap.clone();
for i in [0, 2] {
after[moving_index].rect[i] += 150.0;
}
assert_dragged_paragraphs(&dragged_paragraphs(&h), &after);
paragraph_drag_shot(&mut h, &format!("after-{identical}-{reopen_between}.png"));
let current = paragraph_drag_content(&h);
assert_eq!(current.matches(&format!("({stationary})")).count(), if identical { 2 } else { 1 });
assert_eq!(current.matches(&format!("({moving})")).count(), if identical { 2 } else { 1 });
assert_eq!(current.matches("(Keep here)").count(), 1);
assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo(), Some("Move object"));
h.key_press_modifiers(Modifiers::COMMAND, Key::Z);
h.run_steps(4);
assert_dragged_paragraphs(&dragged_paragraphs(&h), &overlap);
if !reopen_between {
h.key_press_modifiers(Modifiers::COMMAND, Key::Z);
h.run_steps(4);
assert_dragged_paragraphs(&dragged_paragraphs(&h), &before);
}
assert!(h.state().session.get(h.state().views[0].id).unwrap().can_undo().is_none(), "each release was exactly one step");
if !reopen_between {
h.key_press_modifiers(Modifiers::COMMAND | Modifiers::SHIFT, Key::Z);
h.run_steps(4);
assert_dragged_paragraphs(&dragged_paragraphs(&h), &overlap);
}
h.key_press_modifiers(Modifiers::COMMAND | Modifiers::SHIFT, Key::Z);
h.run_steps(4);
assert_dragged_paragraphs(&dragged_paragraphs(&h), &after);
assert!(h.state().session.get(h.state().views[0].id).unwrap().can_redo().is_none());
let bytes = save_paragraph_drag(&mut h, &format!("paragraph-separated-{identical}-{reopen_between}.pdf"), &saved_base);
let reopened = paragraph_drag_harness(bytes, 0);
assert_dragged_paragraphs(&dragged_paragraphs(&reopened), &after);
assert_eq!(paragraph_drag_content(&reopened), current);
}
#[test]
fn ordinary_paragraph_drags_preserve_overlapping_text_and_history() {
check_paragraph_overlap_drag(false, false);
}
#[test]
fn ordinary_paragraph_drags_preserve_identical_overlapping_labels() {
check_paragraph_overlap_drag(true, false);
}
#[test]
fn ordinary_paragraph_drags_preserve_overlap_after_save_and_reopen() {
check_paragraph_overlap_drag(false, true);
check_paragraph_overlap_drag(true, true);
}
#[test]
fn ordinary_paragraph_drags_preserve_partial_overlap_and_touching_labels() {
let content = "BT /F1 12 Tf 60 220 Td (Repeated) Tj ET BT /F1 12 Tf 60 320 Td (Repeated) Tj ET";
for fraction in [0.25, 0.75, 1.0] {
let mut h = paragraph_drag_harness(paragraph_drag_fixture(content, 0, 1), 0);
let mut expected = dragged_paragraphs(&h);
assert_eq!(expected.len(), 2);
let width = expected[1].rect[2] - expected[1].rect[0];
// 75% overlap, 25% overlap, then exact touching of the original source rectangles.
for offset in [[width * fraction, -100.0], [150.0, -40.0]] {
drag_paragraph(&mut h, "Repeated", offset);
for (i, coordinate) in expected[1].rect.iter_mut().enumerate() {
*coordinate += offset[i % 2];
}
assert_dragged_paragraphs(&dragged_paragraphs(&h), &expected);
assert_eq!(paragraph_drag_content(&h).matches("(Repeated)").count(), 2);
}
}
}
#[test]
fn ordinary_paragraph_drags_preserve_multiline_kerning_strings() {
let content =
concat!("BT /F1 12 Tf 60 220 Td (Keep here) Tj ET ", "BT /F1 12 Tf 14 TL 60 320 Td [(First) -120 ( line)] TJ T* (Second line) Tj ET");
let mut h = paragraph_drag_harness(paragraph_drag_fixture(content, 0, 1), 0);
let mut expected = dragged_paragraphs(&h);
assert_eq!(expected.len(), 2);
assert_eq!(expected[1].lines.len(), 2);
let text = expected[1].text.clone();
for offset in [[0.0, -100.0], [150.0, 0.0]] {
drag_paragraph(&mut h, &text, offset);
for (i, coordinate) in expected[1].rect.iter_mut().enumerate() {
*coordinate += offset[i % 2];
}
assert_dragged_paragraphs(&dragged_paragraphs(&h), &expected);
let current = paragraph_drag_content(&h);
assert!(current.contains("[(First) -120 ( line)] TJ"), "original encoded strings and kerning stay byte-identical");
for literal in ["(Keep here)", "(Second line)"] {
assert_eq!(current.matches(literal).count(), 1);
}
}
}
#[test]
fn ordinary_paragraph_drag_respects_rotations_crop_and_user_unit() {
let content = "BT /F1 12 Tf 60 220 Td (Keep here) Tj ET BT /F1 12 Tf 60 320 Td (Moving alpha) Tj ET";
for rotation in [0, 90, 180, 270] {
for view_rotation in [0, 90, 180, 270] {
let mut h = paragraph_drag_harness(paragraph_drag_fixture(content, rotation, 2), view_rotation);
let mut expected = dragged_paragraphs(&h);
let moving = expected.iter_mut().find(|b| b.text == "Moving alpha").unwrap();
for (i, coordinate) in moving.rect.iter_mut().enumerate() {
*coordinate += [35.0, -27.0][i % 2];
}
drag_paragraph(&mut h, "Moving alpha", [35.0, -27.0]);
assert_dragged_paragraphs(&dragged_paragraphs(&h), &expected);
}
}
}
#[test]
fn an_ordinary_added_text_drag_keeps_its_content_edit() {
for panel_open in [true, false] {
check_added_text_drag(panel_open);
}
}
fn check_added_text_drag(panel_open: bool) {
let content = "BT /F1 12 Tf 60 220 Td (Keep here) Tj ET";
let mut h = paragraph_drag_harness(paragraph_drag_fixture(content, 0, 1), 0);
let id = h.state().views[0].id;
h.state_mut()
.session
.apply(
id,
pdfcraft_engine::Edit::AddText {
page: 0,
text: pdfcraft_engine::AddedText { text: "Added note".into(), rect: [100.0, 330.0, 230.0, 354.0], ..Default::default() },
},
)
.unwrap();
let original = h.state().session.save_bytes(id).unwrap();
h = paragraph_drag_harness(original.as_ref().clone(), 0);
h.state_mut().left_open = panel_open;
h.run_steps(4);
let added = |h: &Harness<'static, PdfCraftApp>| {
let app = h.state();
app.session.get(app.views[0].id).unwrap().added().to_vec()
};
let before_added = added(&h);
assert_eq!(before_added.len(), 1);
let before = dragged_paragraphs(&h);
assert_eq!(before.len(), 2, "added text is also visible in the raw paragraph list");
let mut expected = before.clone();
let moved = expected.iter_mut().find(|b| b.text == "Added note").unwrap();
for (i, coordinate) in moved.rect.iter_mut().enumerate() {
*coordinate += [30.0, -20.0][i % 2];
}
// The same pointer hits added content and its source glyphs. Only content_ui may own it:
// a Text target would be excluded from MoveObjects and overwrite the valid UpdateContent.
let (from, to) = paragraph_drag_points(&h, "Added note", [30.0, -20.0]);
drag(&mut h, from, to);
assert!(h.state().views[0].line_editor.is_none());
assert!(h.state().views[0].block_drag.is_none());
assert_dragged_paragraphs(&dragged_paragraphs(&h), &expected);
let after_added = added(&h);
assert_eq!(after_added.len(), 1);
let (old_rect, new_rect) = (before_added[0].content.rect(), after_added[0].content.rect());
for i in 0..4 {
assert!((new_rect[i] - old_rect[i] - [30.0, -20.0][i % 2]).abs() < 0.01);
}
assert_eq!(after_added[0].content, before_added[0].content.with_rect(new_rect));
assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo(), Some("Edit content"));
h.key_press_modifiers(Modifiers::COMMAND, Key::Z);
h.run_steps(4);
assert_eq!(added(&h), before_added);
assert_dragged_paragraphs(&dragged_paragraphs(&h), &before);
assert!(h.state().session.get(h.state().views[0].id).unwrap().can_undo().is_none(), "one content edit, no paragraph rewrite");
h.key_press_modifiers(Modifiers::COMMAND | Modifiers::SHIFT, Key::Z);
h.run_steps(4);
assert_eq!(added(&h), after_added);
assert_dragged_paragraphs(&dragged_paragraphs(&h), &expected);
let bytes = save_paragraph_drag(&mut h, &format!("paragraph-added-content-{panel_open}.pdf"), original.as_slice());
let reopened = paragraph_drag_harness(bytes, 0);
assert_eq!(added(&reopened), after_added);
assert_dragged_paragraphs(&dragged_paragraphs(&reopened), &expected);
}
#[test]
fn an_ordinary_paragraph_drag_refusal_never_rewrites_the_text() {
let content = "q BT 4 Tr /F1 12 Tf 60 320 Td (Clipping text) Tj ET Q";
let mut h = paragraph_drag_harness(paragraph_drag_fixture(content, 0, 1), 0);
let before = dragged_paragraphs(&h);
let id = h.state().views[0].id;
let bytes = h.state().session.get(id).unwrap().bytes.clone();
let generation = h.state().session.get(id).unwrap().edit_generation();
drag_paragraph(&mut h, "Clipping text", [30.0, -20.0]);
let doc = h.state().session.get(id).unwrap();
assert_eq!(doc.bytes, bytes);
assert_eq!(doc.edit_generation(), generation);
assert!(doc.can_undo().is_none());
assert!(!doc.dirty);
assert_dragged_paragraphs(&dragged_paragraphs(&h), &before);
assert!(h.state().toast.as_ref().unwrap().0.contains("Text used as a clipping path cannot be moved with this selection."));
}
#[test]
fn an_ordinary_paragraph_drag_cannot_reuse_an_index_after_an_edit() {
let content = "BT /F1 12 Tf 60 120 Td (Discard) Tj ET BT /F1 12 Tf 60 320 Td (Moving alpha) Tj ET BT /F1 12 Tf 60 220 Td (Keep here) Tj ET";
let mut h = paragraph_drag_harness(paragraph_drag_fixture(content, 0, 1), 0);
let (from, to) = paragraph_drag_points(&h, "Moving alpha", [30.0, -20.0]);
h.hover_at(from);
h.run_steps(1);
h.drag_at(from);
h.run_steps(1);
h.hover_at(to);
h.run_steps(2);
assert!(h.state().views[0].block_drag.is_some());
let id = h.state().views[0].id;
h.state_mut()
.session
.apply(id, pdfcraft_engine::Edit::EditTextBlock { page: 0, block: 0, text: String::new(), style: Default::default() })
.unwrap();
let after_edit = dragged_paragraphs(&h);
assert_eq!(after_edit.len(), 2, "the surviving paragraphs get different indexes");
assert_eq!(after_edit[1].text, "Keep here", "the old target index now names an unrelated paragraph");
let generation = h.state().session.get(id).unwrap().edit_generation();
h.run_steps(2);
h.drop_at(to);
h.run_steps(4);
assert!(h.state().views[0].block_drag.is_none());
assert_eq!(h.state().session.get(id).unwrap().edit_generation(), generation, "the stale release adds no edit");
assert_dragged_paragraphs(&dragged_paragraphs(&h), &after_edit);
}
/// 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());
}
/// #2276: a paragraph's rewrap handle is drawn half outside its box, and the resize pointer
/// shows a few pixels further out still. A press there started an area selection instead: its
/// rectangle followed the pointer, and on release the box went back to its old width.
#[test]
fn a_paragraph_rewraps_from_the_outer_half_of_its_edge_handles() {
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).iter().map(|l| l.text.clone()).collect::<Vec<_>>()
};
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; 4], b: [f64; 4]| a.iter().zip(b).all(|(a, b)| (a - b).abs() < 1.5);
// The box is 2 px outside the text and its handle 7 px wide, centred on the box's edge: press
// 3 px outside the box, on the handle. Near the box's top, clear of the tool's toast.
let outside = 2.0 + 3.0;
let before = block(&h);
let grip = screen(before.rect[2], before.rect[3] - 6.0) + egui::vec2(outside, 0.0);
drag(&mut h, grip, grip - egui::vec2((before.rect[2] - before.rect[0] - 45.0) as f32 * k, 0.0));
assert_eq!(lines(&h), ["Page", "1"], "narrowed from the right handle");
let narrowed = block(&h);
assert!(narrowed.rect[2] < before.rect[2] - 20.0 && (narrowed.rect[0] - before.rect[0]).abs() < 1.5, "{:?} → {:?}", before.rect, narrowed.rect);
let id = h.state().views[0].id;
assert_eq!(h.state().session.get(id).unwrap().can_undo(), Some("Edit text"));
assert!(h.state().views[0].line_editor.is_none());
// Saved and reopened, the paragraph keeps its new width.
let saved = h.state_mut().session.save_bytes(id).unwrap();
let mut reopen = pdfcraft_engine::Session::new();
let rid = reopen.open("saved.pdf", None, saved, None).unwrap();
assert!(near(reopen.get(rid).unwrap().text_blocks(0)[0].rect, narrowed.rect));
// Undo gives the old width back, redo the new one.
h.state_mut().undo();
h.run_steps(2);
assert_eq!(lines(&h), ["Page 1"]);
assert!(near(block(&h).rect, before.rect), "{:?} → {:?}", before.rect, block(&h).rect);
h.state_mut().redo();
h.run_steps(2);
assert!(near(block(&h).rect, narrowed.rect), "{:?} → {:?}", narrowed.rect, block(&h).rect);
// The left handle's outer half too.
h.state_mut().undo();
h.run_steps(2);
let grip = screen(before.rect[0], before.rect[3] - 6.0) - egui::vec2(outside, 0.0);
let inward = (before.rect[2] - before.rect[0] - 45.0) as f32;
drag(&mut h, grip, grip + egui::vec2(inward * k, 0.0));
assert_eq!(lines(&h), ["Page", "1"], "narrowed from the left handle");
assert!((block(&h).rect[0] - (before.rect[0] + f64::from(inward))).abs() < 1.5, "{:?} → {:?}", before.rect, block(&h).rect);
}
/// 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 resized_pages_panel_thumbnails_reorder_multiple_pages_and_undo() {
let mut h = harness(4, |app| {
app.right = Some(pdfcraft_ui_egui::RightPanel::Pages);
app.set_option("thumbnail-zoom", "50").unwrap();
});
let before = page_texts(h.state());
h.state_mut().views[0].select_pages(&[1, 2]);
h.run_steps(2);
let from = panel_thumb_rect(&h, "Page 2").center();
let to = panel_thumb_rect(&h, "Page 1").center_top() + egui::vec2(0.0, 4.0);
drag(&mut h, from, to);
h.run_steps(4);
assert_eq!(page_texts(h.state()), vec![before[1].clone(), before[2].clone(), before[0].clone(), before[3].clone()]);
assert_eq!(h.state().views[0].target_pages(), vec![0, 1]);
assert_eq!(h.state().views[0].thumbnail_zoom(), 0.5);
assert!(h.state_mut().execute("edit.undo"));
h.run_steps(4);
assert_eq!(page_texts(h.state()), before);
assert_eq!(h.state().views[0].thumbnail_zoom(), 0.5);
}
#[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);
}
#[test]
fn one_editor_contains_mixed_styles_and_visual_rows() {
let _gpu = gpu();
let bytes = source_font_fixture_with_text(
"BT 0 0.2 0.5 rg /F1 1 Tf 18 0 0 18 20 240 Tm (Heading) Tj ET \
BT 0 g /F2 12 Tf 20 215 Td (Plain ) Tj /F1 12 Tf 0 0.2 0.5 rg (bold) Tj /F2 12 Tf 0 g ( words) Tj ET \
BT /F2 10 Tf 20 195 Td (Last row) Tj ET",
);
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("mixed.pdf", None, bytes).unwrap();
app
});
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).unwrap();
let point = egui::pos2(page.left() + 30.0 / 300.0 * page.width(), page.top() + 55.0 / 300.0 * page.height());
h.hover_at(point);
h.run_steps(1);
h.drag_at(point);
h.run_steps(1);
h.drop_at(point);
h.run_steps(6);
assert_eq!(h.state().views[0].line_editor.as_ref().unwrap().text, "Heading\nPlain bold words\nLast row");
let galley = h
.output()
.shapes
.iter()
.find_map(|shape| match &shape.shape {
egui::Shape::Text(t) if t.galley.text().starts_with("Heading\n") => Some(&t.galley),
_ => None,
})
.expect("one galley contains the whole region");
assert_eq!(galley.rows.len(), 3);
assert!(galley.job.sections.iter().any(|s| s.format.color == egui::Color32::BLACK));
assert!(galley.job.sections.iter().any(|s| s.format.color == egui::Color32::from_rgb(0, 51, 128)));
assert!(galley.job.sections.iter().any(|s| s.format.font_id.size > galley.job.sections.last().unwrap().format.font_id.size));
let original_text = h.state().views[0].line_editor.as_ref().unwrap().text.clone();
h.state_mut().views[0].line_editor.as_mut().unwrap().text.push_str("\nAdditional row\nAnother row");
h.run_steps(3);
let visible = h
.output()
.shapes
.iter()
.find_map(|s| match &s.shape {
egui::Shape::Text(t) if t.galley.text().ends_with("Another row") => Some(t.galley.rect.translate(t.pos.to_vec2())),
_ => None,
})
.unwrap();
let frame = h
.output()
.shapes
.iter()
.find_map(|s| match &s.shape {
egui::Shape::Rect(r) if r.stroke.color == egui::Color32::from_rgb(0x14, 0x73, 0xE6) && r.rect.width() > 50.0 => Some(r.rect),
_ => None,
})
.unwrap();
assert!(frame.contains_rect(visible), "fallback font rows remain inside the growing frame");
h.state_mut().views[0].line_editor.as_mut().unwrap().text = original_text;
h.run_steps(3);
if let Ok(path) = std::env::var("PDFCRAFT_RICH_FRAME_SHOT") {
h.render().unwrap().save(path).unwrap();
}
h.state_mut().views[0].line_editor.as_mut().unwrap().text.clear();
h.run_steps(3);
h.key_press_modifiers(Modifiers::COMMAND, Key::Enter);
h.run_steps(4);
assert!(h.state().session.get(h.state().views[0].id).unwrap().text_blocks(0).is_empty());
assert!(dirty(&h));
h.key_press_modifiers(Modifiers::COMMAND, Key::Z);
h.run_steps(3);
assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().text_blocks(0)[0].rows.len(), 3);
}
#[test]
fn the_caret_on_a_small_row_is_as_tall_as_that_row() {
// It was one row of the region's first (18 pt) size tall, reaching into the row below.
let bytes = source_font_fixture_with_text(
"BT 0 0.2 0.5 rg /F1 1 Tf 18 0 0 18 20 240 Tm (Heading) Tj ET \
BT 0 g /F2 12 Tf 20 215 Td (Plain ) Tj /F1 12 Tf 0 0.2 0.5 rg (bold) Tj /F2 12 Tf 0 g ( words) Tj ET \
BT /F2 10 Tf 20 195 Td (Last row) Tj ET",
);
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("mixed.pdf", None, bytes).unwrap();
app
});
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).unwrap();
let k = page.width() / 300.0;
let screen = |x: f32, y: f32| egui::pos2(page.left() + x * k, page.top() + (300.0 - y) * k);
let click = |h: &mut Harness<'static, PdfCraftApp>, at: egui::Pos2| {
h.hover_at(at);
h.run_steps(1);
h.drag_at(at);
h.run_steps(1);
h.drop_at(at);
h.run_steps(4);
};
click(&mut h, screen(30.0, 245.0));
assert!(h.state().views[0].line_editor.is_some(), "the region opens");
let row = h.state().session.get(h.state().views[0].id).unwrap().text_lines(0).into_iter().find(|l| l.text == "Last row").unwrap().rect;
click(&mut h, screen(40.0, ((row[1] + row[3]) / 2.0) as f32));
let row_height = (row[3] - row[1]) as f32 * k;
let (left, right) = (screen(row[0] as f32, 0.0).x - 2.0, screen(row[2] as f32, 0.0).x + 2.0);
let middle = screen(0.0, ((row[1] + row[3]) / 2.0) as f32).y;
// The caret: a vertical segment within the row's span, through its middle.
let caret = h
.output()
.shapes
.iter()
.find_map(|s| match &s.shape {
egui::Shape::LineSegment { points: [a, b], .. }
if (a.x - b.x).abs() < 0.01 && (left..right).contains(&a.x) && a.y.min(b.y) < middle && a.y.max(b.y) > middle =>
{
Some((b.y - a.y).abs())
}
_ => None,
})
.expect("a caret on the last row");
assert!(caret > row_height * 0.8 && caret < row_height + 4.0, "caret {caret} px on a {row_height} px row");
}
/// Opt-in private-document regression. This never captures images or saves the source file.
/// Run with PDFCRAFT_TEXT_REGRESSION_PDF=/path/to/document.pdf and --ignored.
#[test]
#[ignore = "requires an explicitly supplied local PDF"]
fn private_document_regions_open_as_one_editor() {
let path = std::env::var("PDFCRAFT_TEXT_REGRESSION_PDF").expect("supply a local PDF path");
let bytes = std::fs::read(&path).unwrap();
let original = bytes.clone();
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 1100.0)).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("private.pdf", None, bytes).unwrap();
app
});
h.run_steps(4);
assert!(h.state_mut().execute("edit.edit_text"));
h.run_steps(2);
h.state_mut().views[0].fit = pdfcraft_ui_egui::canvas::Fit::Page;
assert!(h.state_mut().execute("view.layout.single"));
h.run_steps(3);
let page_count = h.state().session.get(h.state().views[0].id).unwrap().info.pages.len();
let mut checked = 0;
for page_index in 0..page_count {
h.state_mut().views[0].go_to_page(page_index);
h.run_steps(4);
let blocks = h.state().session.get(h.state().views[0].id).unwrap().text_blocks(page_index);
let page = h.state().views[0].page_screen_rect(page_index).unwrap();
let info = h.state().session.get(h.state().views[0].id).unwrap().info.pages[page_index].clone();
let source_lines = h.state().session.get(h.state().views[0].id).unwrap().text_lines(page_index);
let programs = h.state().session.get(h.state().views[0].id).unwrap().text_editor_fonts(page_index);
assert!(!programs.is_empty(), "source sfnt fonts must be usable in the editor");
let scale = page.width() / info.width;
for (index, block) in blocks.iter().enumerate().filter(|(_, b)| b.rows.len() >= 3) {
let line = &source_lines[block.lines[0]];
let v = info.user_to_view((line.rect[0] + 3.0) as f32, (line.rect[1] + line.rect[3]) as f32 / 2.0);
let point = egui::pos2(page.left() + v[0] * scale, page.top() + v[1] * scale);
h.hover_at(point);
h.run_steps(1);
h.drag_at(point);
h.run_steps(1);
h.drop_at(point);
h.run_steps(8);
let editor = h.state().views[0].line_editor.as_ref().unwrap();
assert_eq!(editor.block, index);
assert_eq!(editor.text, block.editor_text);
assert!(!editor.overflow, "unchanged source region must fit (page {}, region {})", page_index + 1, index + 1);
let text_shape = h
.output()
.shapes
.iter()
.find_map(|s| match &s.shape {
egui::Shape::Text(t) if t.galley.text() == block.editor_text => Some(t),
_ => None,
})
.expect("the whole source region is editable");
for (section, (_, source)) in text_shape.galley.job.sections.iter().zip(block.edited_spans(&block.editor_text)) {
if programs.iter().any(|font| font.resource == source_lines[source].font) {
assert!(
matches!(&section.format.font_id.family,
egui::FontFamily::Name(name) if name.starts_with("pdf-source-")),
"each available embedded source face is used in the editor"
);
}
}
assert_eq!(text_shape.galley.rows.len(), block.rows.len(), "source rows stay in place");
for (row, source_row) in text_shape.galley.rows.iter().zip(&block.rows) {
let source = &source_lines[source_row[0]];
let baseline = text_shape.pos.y + row.pos.y + row.glyphs[0].pos.y;
let expected = page.top() + info.user_to_view(0.0, source.baseline()[1] as f32)[1] * scale;
assert!((baseline - expected).abs() < 1.5, "editor baseline stays on the source baseline");
}
let frames = h
.output()
.shapes
.iter()
.filter(|s| {
matches!(&s.shape,
egui::Shape::Rect(r) if r.stroke.color==egui::Color32::from_rgb(0x14,0x73,0xE6)
&& r.rect.width()>50.0 && page.contains(r.rect.center()))
})
.count();
assert_eq!(frames, 1, "one stable editor frame");
h.state_mut().views[0].line_editor.as_mut().unwrap().text.push_str("\nAdditional text\nAdditional text");
h.run_steps(3);
let editor = h.state().views[0].line_editor.as_ref().unwrap();
assert!(editor.overflow, "extra rows extend the block");
let draft =
h.state().session.get(h.state().views[0].id).unwrap().text_edit_layout(page_index, index, &editor.text, &editor.style()).unwrap();
let bounds = draft.draft_rect.unwrap();
assert!((bounds[3] - block.rect[3]).abs() < 0.5, "draft bounds exclude untouched neighboring regions");
h.key_press(Key::Escape);
h.run_steps(2);
checked += 1;
}
}
assert!(checked > 0, "the supplied PDF must contain a multiline region");
assert!(!dirty(&h));
assert_eq!(std::fs::read(path).unwrap(), original);
}
#[test]
fn inline_editor_preserves_source_font_and_previews_live_bounds() {
use pdfcraft_cos::{Dict, Document, Object, Stream, write_incremental};
use skrifa::MetadataProvider as _;
let _gpu = gpu();
let mut cos = Document::open(std::sync::Arc::new(source_font_fixture_with_text(
"BT /F1 18 Tf 20 220 Td (Stable source text) Tj ET BT /F1 12 Tf 20 194 Td (The second row stays here) Tj ET",
)))
.unwrap();
let bytes = include_bytes!("../../../../assets/fonts/Inter-Regular.ttf");
let face = skrifa::FontRef::new(bytes).unwrap();
let metrics = face.metrics(skrifa::instance::Size::unscaled(), skrifa::instance::LocationRef::default());
let glyphs = face.glyph_metrics(skrifa::instance::Size::unscaled(), skrifa::instance::LocationRef::default());
let units = 1000.0 / f64::from(metrics.units_per_em);
let program = cos.add(Object::Stream(Stream::flate(Dict::new(), bytes)));
let mut descriptor = Dict::new();
descriptor.set(b"FontFile2".to_vec(), Object::Ref(program));
descriptor.set(b"Ascent".to_vec(), Object::Real(f64::from(metrics.ascent) * units));
descriptor.set(b"Descent".to_vec(), Object::Real(f64::from(metrics.descent) * units));
let mut font = Dict::new();
font.set(b"Type".to_vec(), Object::name("Font"));
font.set(b"Subtype".to_vec(), Object::name("TrueType"));
font.set(b"BaseFont".to_vec(), Object::name("Inter-Regular"));
font.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding"));
font.set(b"FirstChar".to_vec(), Object::Int(32));
font.set(b"LastChar".to_vec(), Object::Int(126));
font.set(
b"Widths".to_vec(),
Object::Array(
(32..=126u8)
.map(|c| {
let id = face.charmap().map(char::from(c)).unwrap();
Object::Real(f64::from(glyphs.advance_width(id).unwrap()) * units)
})
.collect(),
),
);
font.set(b"FontDescriptor".to_vec(), Object::Dict(descriptor));
cos.update_dict(pdfcraft_cos::ObjRef::new(3, 0), |d| *d = font).unwrap();
let bytes = write_incremental(&cos, &Default::default()).unwrap();
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("stable.pdf", None, bytes).unwrap();
app
});
h.run_steps(4);
assert!(h.state_mut().execute("edit.edit_text"));
h.run_steps(2);
let source = h.state().session.get(h.state().views[0].id).unwrap().text_blocks(0)[0].clone();
let lines = h.state().session.get(h.state().views[0].id).unwrap().text_lines(0);
let page = h.state().views[0].page_screen_rect(0).unwrap();
let scale = page.width() / 300.0;
let point = egui::pos2(page.left() + 30.0 * scale, page.top() + 75.0 * scale);
h.hover_at(point);
h.run_steps(1);
h.drag_at(point);
h.run_steps(1);
h.drop_at(point);
h.run_steps(8);
assert!(!h.state().views[0].line_editor.as_ref().unwrap().overflow);
let shape = h
.output()
.shapes
.iter()
.find_map(|s| match &s.shape {
egui::Shape::Text(t) if t.galley.text() == source.editor_text => Some(t),
_ => None,
})
.unwrap();
assert_eq!(shape.galley.rows.len(), 2);
for (row, source_row) in shape.galley.rows.iter().zip(&source.rows) {
let line = &lines[source_row[0]];
let y = shape.pos.y + row.pos.y + row.glyphs[0].pos.y;
assert!((y - (page.top() + (300.0 - line.baseline()[1] as f32) * scale)).abs() < 1.0);
}
for section in &shape.galley.job.sections {
assert!(matches!(&section.format.font_id.family,egui::FontFamily::Name(name) if name.starts_with("pdf-source-")));
}
let frame = |h: &Harness<'_, PdfCraftApp>| {
h.output()
.shapes
.iter()
.find_map(|s| match &s.shape {
egui::Shape::Rect(r) if r.stroke.color == egui::Color32::from_rgb(0x14, 0x73, 0xE6) && r.rect.width() > 100.0 => Some(r.rect),
_ => None,
})
.unwrap()
};
let original_frame = frame(&h);
h.state_mut().views[0].line_editor.as_mut().unwrap().text.push_str("\nExtra row that does not fit\nAnother row");
h.run_steps(3);
assert!(h.state().views[0].line_editor.as_ref().unwrap().overflow);
let expanded = frame(&h);
assert!(expanded.bottom() > original_frame.bottom() + 20.0, "the frame visibly expands around the extra rows");
assert_eq!(expanded.left(), original_frame.left());
assert_eq!(expanded.top(), original_frame.top());
h.state_mut().views[0].line_editor.as_mut().unwrap().text = "Stable source text".into();
h.run_steps(3);
let reduced = frame(&h);
assert!(reduced.bottom() < original_frame.bottom() - 20.0, "deleting a row visibly shrinks the block");
h.state_mut().views[0].line_editor.as_mut().unwrap().text.push_str("\nExtra row that does not fit\nAnother row");
h.run_steps(3);
if let Ok(path) = std::env::var("PDFCRAFT_OVERFLOW_FRAME_SHOT") {
h.render().unwrap().save(path).unwrap();
}
let id = h.state().views[0].id;
let editor = h.state().views[0].line_editor.as_ref().unwrap();
let draft_text = editor.text.clone();
let measured = h.state().session.get(id).unwrap().text_edit_layout(0, 0, &draft_text, &editor.style()).unwrap().draft_rect.unwrap();
let expected_frame = egui::Rect::from_min_max(
egui::pos2(page.left() + measured[0] as f32 * scale, page.top() + (300.0 - measured[3] as f32) * scale),
egui::pos2(page.left() + measured[2] as f32 * scale, page.top() + (300.0 - measured[1] as f32) * scale),
)
.expand(2.0);
let live = frame(&h);
assert!(
(live.min - expected_frame.min).length() < 0.1 && (live.max - expected_frame.max).length() < 0.1,
"the live frame shows the same PDF geometry as Apply/Save"
);
assert!(!dirty(&h), "draft measurement does not modify the document");
h.key_press_modifiers(Modifiers::COMMAND, Key::Enter);
h.run_steps(3);
assert!(dirty(&h));
assert_eq!(h.state().session.get(id).unwrap().text_blocks(0)[0].rect, measured);
let bytes = h.state().session.save_bytes(id).unwrap();
let mut reopened = pdfcraft_engine::Session::new();
let reopened_id = reopened.open("again.pdf", None, bytes, None).unwrap();
assert_eq!(reopened.get(reopened_id).unwrap().text_blocks(0)[0].rect, measured);
h.key_press_modifiers(Modifiers::COMMAND, Key::Z);
h.run_steps(3);
assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().text_blocks(0)[0], source);
}
fn clipped_artwork_with_labels() -> Vec<u8> {
clipped_artwork_with_labels_drawn(false)
}
/// `own_clip` draws the image alone in its clip (`q re W n cm Do Q`), which moves with it (#1049).
fn clipped_artwork_with_labels_drawn(own_clip: bool) -> Vec<u8> {
use pdfcraft_cos::{Dict, Document, Object, Stream, write_incremental};
let mut doc = Document::open(std::sync::Arc::new(fixture(2))).unwrap();
let mut image = Dict::new();
image.set(b"Type".to_vec(), Object::name("XObject"));
image.set(b"Subtype".to_vec(), Object::name("Image"));
image.set(b"Width".to_vec(), Object::Int(1));
image.set(b"Height".to_vec(), Object::Int(1));
image.set(b"BitsPerComponent".to_vec(), Object::Int(8));
image.set(b"ColorSpace".to_vec(), Object::name("DeviceRGB"));
let image = doc.add(Object::Stream(Stream::flate(image, &[220, 235, 245])));
for page in pdfcraft_model::pages(&doc) {
let contents = doc.add(Object::Stream(Stream::flate(Dict::new(),
if own_clip {
&b"q 30 170 130 80 re W* n 800 0 0 900 -300 -400 cm /Im0 Do Q BT /F1 12 Tf 30 130 Td (Editable caption) Tj ET BT /F1 12 Tf 40 220 Td (Overlay) Tj ET BT /F1 9 Tf 154 249 Td (Corner label) Tj ET"[..]
} else {
&b"q 30 170 130 80 re W* n q 800 0 0 900 -300 -400 cm /Im0 Do Q Q BT /F1 12 Tf 30 130 Td (Editable caption) Tj ET BT /F1 12 Tf 40 220 Td (Overlay) Tj ET BT /F1 9 Tf 154 249 Td (Corner label) Tj ET"[..]
})));
let mut resources = doc.resolve(page.dict.get(b"Resources").unwrap()).as_dict().unwrap().clone();
let mut xo = Dict::new();
xo.set(b"Im0".to_vec(), Object::Ref(image));
resources.set(b"XObject".to_vec(), Object::Dict(xo));
doc.update_dict(page.obj, |d| {
d.set(b"Resources".to_vec(), Object::Dict(resources));
d.set(b"Contents".to_vec(), Object::Ref(contents));
})
.unwrap();
}
write_incremental(&doc, &Default::default()).unwrap()
}
#[test]
fn cropped_images_do_not_intercept_text_and_stay_inside_each_page() {
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 1100.0)).build_eframe(|_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("clipped.pdf", None, clipped_artwork_with_labels()).unwrap();
app
});
h.run_steps(4);
assert!(h.state_mut().execute("edit.edit_text"));
h.state_mut().views[0].fit = pdfcraft_ui_egui::canvas::Fit::Page;
assert!(h.state_mut().execute("view.layout.single"));
h.run_steps(3);
for page in 0..2 {
h.state_mut().views[0].go_to_page(page);
h.run_steps(3);
let r = h.state().views[0].page_screen_rect(page).unwrap();
let at = |x: f32, y: f32| egui::pos2(r.left() + x / 200.0 * r.width(), r.top() + (300.0 - y) / 300.0 * r.height());
for (x, y, label) in [(65.0, 134.0, "Editable caption"), (65.0, 224.0, "Overlay")] {
let p = at(x, y);
h.hover_at(p);
h.run_steps(1);
h.drag_at(p);
h.run_steps(1);
h.drop_at(p);
h.run_steps(3);
assert_eq!(h.state().views[0].line_editor.as_ref().unwrap().text, label);
assert!(h.state().views[0].image_selection.is_none(), "text wins even inside the visible artwork");
h.key_press(Key::Escape);
h.run_steps(2);
}
let p = at(95.0, 195.0);
h.hover_at(p);
h.run_steps(1);
h.drag_at(p);
h.run_steps(1);
h.drop_at(p);
h.run_steps(3);
assert!(h.state().views[0].image_selection.as_ref().is_some_and(|s| s.page == page));
for shape in &h.output().shapes {
if let egui::Shape::Rect(rect) = &shape.shape
&& rect.stroke.color == egui::Color32::from_rgb(0x14, 0x73, 0xE6)
&& rect.rect.width() > 50.0
{
assert!(r.expand(3.0).contains_rect(rect.rect), "no image frame escapes its own page");
}
}
}
assert!(!dirty(&h));
}
#[test]
fn cropped_image_corner_resize_keeps_opposite_corner_and_scales_crop() {
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 1100.0)).build_eframe(|_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("clipped.pdf", None, clipped_artwork_with_labels_drawn(true)).unwrap();
app
});
h.run_steps(4);
assert!(h.state_mut().execute("edit.edit_text"));
h.state_mut().views[0].fit = pdfcraft_ui_egui::canvas::Fit::Page;
assert!(h.state_mut().execute("view.layout.single"));
h.run_steps(3);
let page = h.state().views[0].page_screen_rect(0).unwrap();
let at = |x: f32, y: f32| egui::pos2(page.left() + x / 200.0 * page.width(), page.top() + (300.0 - y) / 300.0 * page.height());
let p = at(95.0, 195.0);
h.hover_at(p);
h.run_steps(1);
h.drag_at(p);
h.run_steps(1);
h.drop_at(p);
h.run_steps(3);
let id = h.state().views[0].id;
assert!(h.state().views[0].image_selection.is_some());
let text = |doc: &pdfcraft_engine::Document| {
doc.text_lines(0).into_iter().map(|l| (l.text.clone(), l.rect, l.font.clone(), l.size, l.color, l.baseline())).collect::<Vec<_>>()
};
let original_text = text(h.state().session.get(id).unwrap());
// Grow then shrink using the visible top-right grip, never the off-page raw placement.
for (factor, right, top, horizontal_only, off_centre) in [
(1.15, 179.5, 262.0, false, false),
(0.7, 134.65, 234.4, false, false),
(0.75, 108.4875, 218.3, true, false),
(1.1, 116.33625, 223.13, false, true),
] {
let before = h.state().session.get(id).unwrap().page_images(0)[0].visible_rect.unwrap();
let grip = at(before[2] as f32, before[3] as f32);
let offset = if off_centre { egui::vec2(3.0, -3.0) } else { egui::Vec2::ZERO };
let from = grip + offset;
let fixed = at(before[0] as f32, before[1] as f32);
let desired = fixed + (grip - fixed) * factor + offset;
let to = if horizontal_only { egui::pos2(desired.x, from.y) } else { desired };
let generation = h.state().session.get(id).unwrap().edit_generation();
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);
}
assert_eq!(h.state().session.get(id).unwrap().edit_generation(), generation, "dragging previews without committing every frame");
h.drop_at(to);
h.run_steps(4);
assert!(h.state().views[0].line_editor.is_none(), "a label under a selected image grip cannot take its drag");
let after = h.state().session.get(id).unwrap().page_images(0)[0].visible_rect.unwrap();
for (actual, expected) in after.into_iter().zip([30.0, 170.0, right, top]) {
assert!((actual - expected).abs() < 0.05, "cropped image follows the visible grip: {actual} vs {expected}");
}
assert_eq!(text(h.state().session.get(id).unwrap()), original_text, "image resizing leaves captions in place");
}
let saved = h.state().session.save_bytes(id).unwrap();
let mut reopened = pdfcraft_engine::Session::new();
let re = reopened.open("again.pdf", None, saved, None).unwrap();
assert_eq!(reopened.get(re).unwrap().page_images(0), h.state().session.get(id).unwrap().page_images(0));
}
/// While a corner is dragged, the image's pixels are drawn in the blue frame, not left at their
/// old size until release (#1671).
#[test]
fn image_pixels_follow_the_frame_while_a_corner_is_dragged() {
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 1100.0)).build_eframe(|_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("clipped.pdf", None, clipped_artwork_with_labels_drawn(true)).unwrap();
app
});
h.run_steps(4);
assert!(h.state_mut().execute("edit.edit_text"));
h.state_mut().views[0].set_zoom(1.2);
h.run_steps(4);
// The dragged pixels are the page raster's: wait for it, as a slow runner may not have it yet
// (the drag then drew no picture at all).
assert!(
crate::support::step_until(&mut h, |h| {
h.run_steps(1);
!h.state().render_pending()
}),
"the page raster arrives"
);
let page = h.state().views[0].page_screen_rect(0).unwrap();
let at = |x: f32, y: f32| egui::pos2(page.left() + x / 200.0 * page.width(), page.top() + (300.0 - y) / 300.0 * page.height());
let p = at(95.0, 195.0);
h.hover_at(p);
h.run_steps(1);
h.drag_at(p);
h.run_steps(1);
h.drop_at(p);
h.run_steps(3);
assert!(h.state().views[0].image_selection.is_some());
let frame = |h: &Harness<'static, PdfCraftApp>| {
h.output().shapes.iter().find_map(|s| match &s.shape {
egui::Shape::Rect(r) if r.stroke.color == egui::Color32::from_rgb(0x14, 0x73, 0xE6) && r.rect.width() > 20.0 => Some(r.rect),
_ => None,
})
};
// Textured meshes smaller than the page: the page raster itself always covers the page.
let pictures = |h: &Harness<'static, PdfCraftApp>| -> Vec<egui::Rect> {
h.output()
.shapes
.iter()
.filter_map(|s| match &s.shape {
egui::Shape::Mesh(m) if m.texture_id != egui::TextureId::default() => Some(m.calc_bounds()),
_ => None,
})
.filter(|b| b.width() < page.width() - 1.0)
.collect()
};
// The dragged picture is cut from the page raster, which a render thread delivers after
// however many frames it takes (more on a slow runner): wait for it on screen, by state.
let raster_shown = |h: &Harness<'static, PdfCraftApp>| {
h.output().shapes.iter().any(|s| {
matches!(&s.shape, egui::Shape::Mesh(m) if m.texture_id != egui::TextureId::default() && m.calc_bounds().width() >= page.width() - 1.0)
})
};
let wait_for_raster = |h: &mut Harness<'static, PdfCraftApp>| {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
while h.state().render_pending() || !raster_shown(h) {
assert!(std::time::Instant::now() < deadline, "timed out waiting for the page raster");
h.step();
std::thread::sleep(std::time::Duration::from_millis(5));
}
};
let start = frame(&h).expect("the selected image has a frame");
let (grip, fixed) = (start.right_top(), start.left_bottom());
for factor in [0.6, 1.3] {
wait_for_raster(&mut h);
let to = fixed + (grip - fixed) * factor;
h.hover_at(grip);
h.run_steps(1);
h.drag_at(grip);
h.run_steps(1);
for k in 1..=4 {
h.hover_at(grip + (to - grip) * (k as f32 / 4.0));
h.run_steps(1);
}
let live = frame(&h).expect("the drag shows its frame");
assert!((live.width() - start.width() * factor).abs() < 1.0, "the frame follows the grip: {live:?} from {start:?}");
let drawn = pictures(&h);
assert!(
drawn.iter().any(|b| (b.min - live.min).length() < 0.5 && (b.max - live.max).length() < 0.5),
"the picture is drawn in the frame {live:?}, not only at {drawn:?}"
);
h.drop_at(to);
h.run_steps(4);
h.key_press_modifiers(Modifiers::COMMAND, Key::Z);
h.run_steps(4);
}
}
/// Red pixels in `r`: the test picture.
fn red(pixels: &image::RgbaImage, r: egui::Rect) -> usize {
let (x0, y0, x1, y1) = (r.left().max(0.0) as u32, r.top().max(0.0) as u32, r.right() as u32, r.bottom() as u32);
(y0..y1.min(pixels.height()))
.flat_map(|y| (x0..x1.min(pixels.width())).map(move |x| (x, y)))
.filter(|(x, y)| matches!(pixels.get_pixel(*x, *y).0, [r, g, b, _] if r > 150 && g < 90 && b < 90))
.count()
}
/// A resized picture is applied when the corner is let go, but the page raster still has it at
/// its old size until the page is rendered again. Until then it stays drawn at its new size and
/// blanked at its old one; the old one used to show again for those frames.
#[test]
fn a_resized_image_keeps_its_new_size_until_the_page_is_redrawn() {
use pdfcraft_cos::{Dict, Document, Object, Stream, write_incremental};
let _gpu = gpu();
let mut doc = Document::open(std::sync::Arc::new(fixture(1))).unwrap();
let mut image = Dict::new();
image.set(b"Type".to_vec(), Object::name("XObject"));
image.set(b"Subtype".to_vec(), Object::name("Image"));
image.set(b"Width".to_vec(), Object::Int(1));
image.set(b"Height".to_vec(), Object::Int(1));
image.set(b"BitsPerComponent".to_vec(), Object::Int(8));
image.set(b"ColorSpace".to_vec(), Object::name("DeviceRGB"));
let image = doc.add(Object::Stream(Stream::flate(image, &[200, 30, 30])));
let page = pdfcraft_model::pages(&doc).remove(0);
let contents = doc.add(Object::Stream(Stream::flate(Dict::new(), b"q 80 0 0 80 60 40 cm /Im0 Do Q")));
let mut resources = doc.resolve(page.dict.get(b"Resources").unwrap()).as_dict().unwrap().clone();
let mut xo = Dict::new();
xo.set(b"Im0".to_vec(), Object::Ref(image));
resources.set(b"XObject".to_vec(), Object::Dict(xo));
doc.update_dict(page.obj, |d| {
d.set(b"Resources".to_vec(), Object::Dict(resources));
d.set(b"Contents".to_vec(), Object::Ref(contents));
})
.unwrap();
let bytes = write_incremental(&doc, &Default::default()).unwrap();
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 1100.0)).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("picture.pdf", None, bytes).unwrap();
app
});
h.run_steps(4);
assert!(h.state_mut().execute("edit.edit_text"));
h.state_mut().views[0].set_zoom(1.2);
h.run_steps(4);
support::wait_until(&mut h, "the page raster", |app| !app.render_pending());
let page = h.state().views[0].page_screen_rect(0).unwrap();
let at = |x: f32, y: f32| egui::pos2(page.left() + x / 200.0 * page.width(), page.top() + (300.0 - y) / 300.0 * page.height());
click_at(&mut h, at(100.0, 80.0));
assert!(h.state().views[0].image_selection.is_some(), "the picture is selected");
support::wait_until(&mut h, "the page raster", |app| !app.render_pending());
// From 60..140 × 40..120 to half its size, keeping its lower left corner.
let (grip, to) = (at(140.0, 120.0), at(100.0, 80.0));
let kept = egui::Rect::from_two_pos(at(62.0, 42.0), at(98.0, 78.0));
let gone = egui::Rect::from_two_pos(at(104.0, 84.0), at(138.0, 118.0));
h.hover_at(grip);
h.run_steps(1);
h.drag_at(grip);
h.run_steps(1);
for k in 1..=4 {
h.hover_at(grip + (to - grip) * (k as f32 / 4.0));
h.run_steps(1);
}
// While dragged, no outline stays where the picture was.
let old = egui::Rect::from_two_pos(at(60.0, 40.0), at(140.0, 120.0));
let trailing = h.output().shapes.iter().any(|shape| {
matches!(&shape.shape, egui::Shape::Rect(r) if r.stroke.width == 0.75 && (r.rect.min - old.min).length() < 2.0 && (r.rect.max - old.max).length() < 2.0)
});
assert!(!trailing, "the dragged picture's outline trails behind it");
h.state_mut().views[0].hold_rasters = true;
h.drop_at(to);
for frame in 0..4 {
h.step();
let pixels = h.render().unwrap();
assert!(red(&pixels, kept) > 100, "frame {frame} after the release: the picture is not at its new size");
assert_eq!(red(&pixels, gone), 0, "frame {frame} after the release: the picture shows at its old size");
}
// Resized again before the page is drawn: the raster still has the first size, so that is
// what stays blanked while the corner moves.
let (grip, to) = (to, at(80.0, 60.0));
let again = egui::Rect::from_two_pos(at(62.0, 42.0), at(78.0, 58.0));
h.hover_at(grip);
h.run_steps(1);
h.drag_at(grip);
h.run_steps(1);
for k in 1..=4 {
h.hover_at(grip + (to - grip) * (k as f32 / 4.0));
h.run_steps(1);
}
let pixels = h.render().unwrap();
assert!(red(&pixels, again) > 20, "the second drag draws the picture at its new size");
assert_eq!(red(&pixels, gone), 0, "the second drag shows the picture at its first size");
h.drop_at(to);
h.run_steps(2);
let pixels = h.render().unwrap();
assert_eq!(red(&pixels, gone), 0, "after the second release");
h.state_mut().views[0].hold_rasters = false;
support::wait_until(&mut h, "the page drawn with the resized picture", |app| !app.render_pending());
h.run_steps(2);
let pixels = h.render().unwrap();
assert!(red(&pixels, again) > 20 && red(&pixels, gone) == 0, "the page raster has the picture at its new size");
}
/// Exercise actual pointer gestures on each cropped placement, without capturing private pixels.
#[test]
#[ignore = "requires an explicitly supplied local PDF with cropped artwork"]
fn private_cropped_image_grips_resize_without_moving_the_opposite_corner() {
let path = std::env::var("PDFCRAFT_CLIPPED_ARTWORK_PDF").expect("supply a local PDF path");
let original = std::fs::read(&path).unwrap();
let bytes = original.clone();
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 1100.0)).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("private.pdf", None, bytes).unwrap();
app
});
h.run_steps(4);
assert!(h.state_mut().execute("edit.edit_text"));
h.state_mut().views[0].fit = pdfcraft_ui_egui::canvas::Fit::Page;
assert!(h.state_mut().execute("view.layout.single"));
let id = h.state().views[0].id;
let pages = h.state().session.get(id).unwrap().info.pages.len();
let mut checked = 0;
for page_index in 0..pages {
h.state_mut().views[0].go_to_page(page_index);
h.run_steps(4);
let doc = h.state().session.get(id).unwrap();
let info = doc.info.pages[page_index].clone();
let images = doc.page_images(page_index);
let blocks = doc.text_blocks(page_index);
let page = h.state().views[0].page_screen_rect(page_index).unwrap();
let at = |x: f64, y: f64| {
let v = info.user_to_view(x as f32, y as f32);
page.min + egui::vec2(v[0], v[1]) * (page.width() / info.width)
};
for (index, image) in images.iter().enumerate() {
let Some(b) = image.visible_rect else { continue };
let blank = (1..10)
.flat_map(|x| (1..10).map(move |y| (x, y)))
.map(|(x, y)| (b[0] + (b[2] - b[0]) * x as f64 / 10.0, b[1] + (b[3] - b[1]) * y as f64 / 10.0))
.find(|(x, y)| !blocks.iter().any(|l| *x >= l.rect[0] && *x <= l.rect[2] && *y >= l.rect[1] && *y <= l.rect[3]))
.expect("image has an unlabelled selection point");
let corners = [at(b[0], b[3]), at(b[2], b[3]), at(b[0], b[1]), at(b[2], b[1])];
for corner in 0..4 {
for (factor, axis) in [(0.8, 0), (1.04, 0), (0.8, 1), (0.8, 2)] {
let p = at(blank.0, blank.1);
h.hover_at(p);
h.run_steps(1);
h.drag_at(p);
h.run_steps(1);
h.drop_at(p);
h.run_steps(3);
assert_eq!(h.state().views[0].image_selection.as_ref().map(|s| s.index), Some(index));
let generation = h.state().session.get(id).unwrap().edit_generation();
let from = corners[corner];
let fixed = corners[3 - corner];
let desired = fixed + (from - fixed) * factor;
let to = match axis {
1 => egui::pos2(desired.x, from.y),
2 => egui::pos2(from.x, desired.y),
_ => desired,
};
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);
assert_eq!(h.state().session.get(id).unwrap().edit_generation(), generation, "no PDF rewrite on pointer movement");
}
h.drop_at(to);
h.run_steps(4);
let after = h.state().session.get(id).unwrap().page_images(page_index)[index].visible_rect.unwrap();
let actual = egui::Rect::from_two_pos(at(after[0], after[1]), at(after[2], after[3]));
let expected = egui::Rect::from_two_pos(fixed, desired).intersect(page);
assert!(
(actual.min - expected.min).length() < 0.2 && (actual.max - expected.max).length() < 0.2,
"page {}, image {}, corner {corner}: {actual:?} vs {expected:?}",
page_index + 1,
index + 1
);
assert!(h.state().views[0].line_editor.is_none());
h.key_press_modifiers(Modifiers::COMMAND, Key::Z);
h.run_steps(4);
checked += 1;
}
}
}
}
assert_eq!(h.state().session.get(id).unwrap().bytes.as_slice(), original.as_slice(), "undo restored the source document in memory");
assert_eq!(std::fs::read(path).unwrap(), original);
println!("verified {checked} real-file corner drag gestures on {pages} pages");
}
/// Local acceptance check for PDFs containing cropped artwork. No screenshots or source saves.
#[test]
#[ignore = "requires an explicitly supplied local PDF with cropped artwork"]
fn private_document_cropped_artwork_does_not_block_text() {
let path = std::env::var("PDFCRAFT_CLIPPED_ARTWORK_PDF").expect("supply a local PDF path");
let original = std::fs::read(&path).unwrap();
let bytes = original.clone();
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 1100.0)).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("private.pdf", None, bytes).unwrap();
app
});
h.run_steps(4);
assert!(h.state_mut().execute("edit.edit_text"));
h.state_mut().views[0].fit = pdfcraft_ui_egui::canvas::Fit::Page;
assert!(h.state_mut().execute("view.layout.single"));
h.run_steps(3);
let pages = h.state().session.get(h.state().views[0].id).unwrap().info.pages.len();
let mut checked = 0;
let mut cropped = 0;
for page in 0..pages {
h.state_mut().views[0].go_to_page(page);
h.run_steps(4);
let doc = h.state().session.get(h.state().views[0].id).unwrap();
let info = doc.info.pages[page].clone();
let blocks = doc.text_blocks(page);
let images = doc.page_images(page);
let r = h.state().views[0].page_screen_rect(page).unwrap();
for image in &images {
let visible = image.visible_rect.unwrap();
assert!(visible[0] >= f64::from(info.crop[0]) - 0.01 && visible[1] >= f64::from(info.crop[1]) - 0.01);
assert!(visible[2] <= f64::from(info.crop[2]) + 0.01 && visible[3] <= f64::from(info.crop[3]) + 0.01);
if image.rect != visible {
cropped += 1;
}
}
for (index, block) in blocks.iter().enumerate() {
let [x0, y0, x1, y1] = block.rect;
let v = info.user_to_view(((x0 + x1) / 2.0) as f32, ((y0 + y1) / 2.0) as f32);
let scale = r.width() / info.width;
let p = egui::pos2(r.left() + v[0] * scale, r.top() + v[1] * scale);
h.hover_at(p);
h.run_steps(1);
h.drag_at(p);
h.run_steps(1);
h.drop_at(p);
h.run_steps(3);
assert_eq!(h.state().views[0].line_editor.as_ref().map(|e| e.block), Some(index), "every source block can open directly");
assert!(h.state().views[0].image_selection.is_none());
h.key_press(Key::Escape);
h.run_steps(2);
checked += 1;
}
}
assert!(checked > 0 && cropped > 0);
assert!(!dirty(&h));
assert_eq!(std::fs::read(path).unwrap(), original);
println!("checked {pages} pages, {checked} text regions, {cropped} clipped image placements");
}
/// Actual keyboard input into every page of a private Chinese PDF; no private captures.
#[test]
#[ignore = "requires a private Chinese PDF and an approved full source font file"]
fn private_new_chinese_keyboard_input_preserves_font_layout_and_saved_pixels() {
use pdfcraft_engine::{Edit, Session};
use std::sync::Arc;
let path = std::env::var("PDFCRAFT_CHINESE_TEXT_PDF").expect("supply a local PDF path");
let font_path = std::env::var("PDFCRAFT_CHINESE_FULL_FONT").expect("supply the full source face");
let original = std::fs::read(&path).unwrap();
let bytes = original.clone();
let font = Arc::new(std::fs::read(font_path).unwrap());
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 1100.0)).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("private.pdf", None, bytes).unwrap();
app
});
h.run_steps(4);
assert!(h.state_mut().execute("edit.edit_text"));
h.state_mut().views[0].fit = pdfcraft_ui_egui::canvas::Fit::Page;
assert!(h.state_mut().execute("view.layout.single"));
let id = h.state().views[0].id;
let pages = h.state().session.get(id).unwrap().info.pages.len();
let all: String = (0..pages).flat_map(|p| h.state().session.get(id).unwrap().text_lines(p)).map(|l| l.text).collect();
let new: String = "测试编辑删除龘".chars().filter(|c| !all.contains(*c)).take(4).collect();
assert!(new.chars().count() >= 2);
let pixels = |bytes: Arc<Vec<u8>>, page| {
let mut r = PageRenderer::new(bytes, Default::default());
let p = r.render(RenderRequest { page, scale: 1.5, ..Default::default() });
assert!(p.error.is_none() && p.warnings.is_empty());
p.rgba.to_vec()
};
for page_index in 0..pages {
h.state_mut().views[0].go_to_page(page_index);
h.state_mut().session.apply(id, Edit::RegisterTextFont { page: page_index, bytes: font.clone() }).unwrap();
h.run_steps(4);
let doc = h.state().session.get(id).unwrap();
let (index, block) = doc
.text_blocks(page_index)
.into_iter()
.enumerate()
.find(|(_, b)| b.rows.len() == 1 && b.editor_text.chars().any(|c| ('\u{4e00}'..='\u{9fff}').contains(&c)))
.unwrap();
let source = doc.text_lines(page_index)[block.lines[0]].clone();
let info = doc.info.pages[page_index].clone();
let page = h.state().views[0].page_screen_rect(page_index).unwrap();
let scale = page.width() / info.width;
let v = info.user_to_view((source.rect[0] + 3.0) as f32, ((source.rect[1] + source.rect[3]) / 2.0) as f32);
let point = page.min + egui::vec2(v[0], v[1]) * scale;
h.hover_at(point);
h.run_steps(1);
h.drag_at(point);
h.run_steps(1);
h.drop_at(point);
h.run_steps(8);
assert_eq!(h.state().views[0].line_editor.as_ref().unwrap().block, index);
let draft = format!("{}{new}", block.editor_text);
h.key_press_modifiers(Modifiers::COMMAND, Key::A);
h.event(egui::Event::Text(draft.clone()));
h.run_steps(4);
assert_eq!(h.state().views[0].line_editor.as_ref().unwrap().text, draft);
let galley = h
.output()
.shapes
.iter()
.find_map(|s| match &s.shape {
egui::Shape::Text(t) if t.galley.text() == draft => Some(t.galley.clone()),
_ => None,
})
.expect("typed Chinese is in the actual inline editor");
assert!(
galley
.job
.sections
.iter()
.filter(|s| draft
.get(usize::from(s.byte_range.start)..usize::from(s.byte_range.end))
.is_some_and(|text| text.chars().any(|c| ('\u{4e00}'..='\u{9fff}').contains(&c))))
.all(|s| matches!(&s.format.font_id.family, egui::FontFamily::Name(n) if n.starts_with("pdf-source-")))
);
h.key_press_modifiers(Modifiers::COMMAND, Key::Enter);
h.run_steps(5);
assert!(h.state().views[0].line_editor.is_none());
let edited = h.state().session.get(id).unwrap();
assert!(edited.text_lines(page_index).iter().any(|l| l.text.contains(&new) && l.base_font == "DroidSansFallback"));
let first = edited.text_lines(page_index).into_iter().find(|l| l.text.starts_with(source.text.trim())).unwrap();
assert!(first.baseline().iter().zip(source.baseline()).all(|(a, b)| (a - b).abs() < 0.01));
let current = pixels(edited.display.clone(), page_index);
let saved = h.state_mut().session.save_bytes(id).unwrap();
let mut reopen = Session::new();
let rid = reopen.open("saved.pdf", None, saved, None).unwrap();
assert_eq!(pixels(reopen.get(rid).unwrap().display.clone(), page_index), current);
// Repeat keyboard input and deletion without recreating the editor via state mutation.
h.hover_at(point);
h.run_steps(1);
h.drag_at(point);
h.run_steps(1);
h.drop_at(point);
h.run_steps(6);
assert!(h.state().views[0].line_editor.as_ref().unwrap().text.contains(&new));
h.key_press_modifiers(Modifiers::COMMAND, Key::A);
let shortened = draft.replace(&new, &new.chars().skip(1).collect::<String>());
h.event(egui::Event::Text(shortened.clone()));
h.run_steps(3);
h.key_press_modifiers(Modifiers::COMMAND, Key::Enter);
h.run_steps(4);
let current = pixels(h.state().session.get(id).unwrap().display.clone(), page_index);
let saved = h.state_mut().session.save_bytes(id).unwrap();
let rid = reopen.open("deleted.pdf", None, saved, None).unwrap();
assert_eq!(pixels(reopen.get(rid).unwrap().display.clone(), page_index), current);
let content = |s: &str| s.chars().filter(|c| !c.is_whitespace()).collect::<String>();
assert_eq!(content(&reopen.get(rid).unwrap().text_blocks(page_index)[index].editor_text), content(&shortened));
h.state_mut().session.undo(id).unwrap();
h.state_mut().session.undo(id).unwrap();
h.state_mut().session.undo(id).unwrap();
h.run_steps(4);
}
assert_eq!(std::fs::read(path).unwrap(), original);
println!("verified Chinese keyboard insertion/deletion and save/reopen rendering on {pages} pages");
}
/// Moving text shows the four-way arrow wherever a drag would move it (#1520 follow-up): over a
/// closed paragraph, on the frame of an open one, and while dragging. Inside the open one the
/// pointer reads as text; on a closed one's edge, as the rewrap handle.
#[test]
fn moving_text_offers_the_four_way_arrow() {
let mut h = harness(2, |_| {});
assert!(h.state_mut().execute("edit.edit_text"));
h.run_steps(2);
let id = h.state().views[0].id;
let block = h.state().session.get(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 centre = screen((block.rect[0] + block.rect[2]) / 2.0, (block.rect[1] + block.rect[3]) / 2.0);
let cursor_at = |h: &mut Harness<'static, PdfCraftApp>, at: egui::Pos2| {
h.hover_at(at);
h.run_steps(2);
h.output().platform_output.cursor_icon
};
// A closed paragraph: a drag moves it; its left edge rewraps it.
assert_eq!(cursor_at(&mut h, centre), egui::CursorIcon::Move);
assert_eq!(cursor_at(&mut h, screen(block.rect[0], (block.rect[1] + block.rect[3]) / 2.0)), egui::CursorIcon::ResizeHorizontal);
// While it is being dragged.
h.hover_at(centre);
h.run_steps(1);
h.drag_at(centre);
h.run_steps(1);
let mid = centre + egui::vec2(20.0 * k, 10.0 * k);
h.hover_at(mid);
h.run_steps(2);
assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::Move);
h.drop_at(mid);
h.run_steps(4);
let moved = h.state().session.get(id).unwrap().text_blocks(0)[0].clone();
assert!((moved.rect[0] - block.rect[0] - 20.0).abs() < 1.5, "{:?} → {:?}", block.rect, moved.rect);
// Open: text inside, the frame moves it.
let centre = screen((moved.rect[0] + moved.rect[2]) / 2.0, (moved.rect[1] + moved.rect[3]) / 2.0);
click_at(&mut h, centre);
assert!(h.state().views[0].line_editor.is_some());
assert_eq!(cursor_at(&mut h, centre), egui::CursorIcon::Text);
let frame = screen((moved.rect[0] + moved.rect[2]) / 2.0, moved.rect[3]) - egui::vec2(0.0, 4.0);
assert_eq!(cursor_at(&mut h, frame), egui::CursorIcon::Move);
}
/// Cropping a picture on the page (Acrobat's crop handles): in crop mode the
/// handles trim, Enter applies what is kept, Esc leaves the picture as it was.
#[test]
fn cropping_a_picture_with_its_handles() {
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();
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("picture.png", None, png.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].visible_rect.unwrap();
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 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");
// Crop mode (as the context menu's Crop Image starts it): drag the right-bottom handle a
// quarter of the picture inward, then Enter.
h.state_mut().views[0].image_selection.as_mut().unwrap().crop = Some(pdfcraft_ui_egui::CropMode::new(before));
h.run_steps(2);
let corner = r.right_bottom();
let to = corner - egui::vec2(r.width() / 4.0, r.height() / 4.0);
h.hover_at(corner);
h.run_steps(1);
h.drag_at(corner);
h.run_steps(1);
for k in 1..=4 {
h.hover_at(corner + (to - corner) * (k as f32 / 4.0));
h.run_steps(1);
}
h.drop_at(to);
h.run_steps(2);
let keep = h.state().views[0].image_selection.as_ref().unwrap().crop.as_ref().unwrap().keep;
assert!(keep[2] < before[2] - 1.0 && keep[1] > before[1] + 1.0, "trimmed on the right and the bottom: {keep:?} of {before:?}");
h.key_press(egui::Key::Enter);
h.run_steps(4);
let doc = h.state().session.get(id).unwrap();
assert_eq!(doc.can_undo(), Some("Crop image"));
let im = &doc.page_images(0)[0];
assert_eq!(im.rect, [0.0, 0.0, 80.0, 40.0], "the placement is not scaled");
let v = im.visible_rect.unwrap();
assert!((v[2] - 60.0).abs() < 1.5 && (v[1] - 10.0).abs() < 1.5, "kept {v:?}");
assert!(h.state().views[0].image_selection.as_ref().is_some_and(|s| s.crop.is_none()), "crop mode ends");
// Esc leaves it.
h.state_mut().views[0].image_selection.as_mut().unwrap().crop = Some(pdfcraft_ui_egui::CropMode::new([10.0, 10.0, 20.0, 20.0]));
h.run_steps(2);
h.key_press(egui::Key::Escape);
h.run_steps(3);
let doc = h.state().session.get(id).unwrap();
assert_eq!(doc.page_images(0)[0].visible_rect.unwrap(), v);
assert!(h.state().views[0].image_selection.as_ref().is_none_or(|s| s.crop.is_none()));
}
/// Organize pages has Acrobat's panel whenever the grid shows, however it was opened, and the
/// panel's Close ends Organize: the grid closes and All tools comes back.
#[test]
fn organize_panel_comes_with_the_grid_and_closes_it() {
let mut h = organize(3);
h.get_by_label("Insert");
h.get_by_label("Bates numbering");
h.get_by_label("Page properties");
h.get_by_label("Close panel").click();
h.run_steps(3);
assert!(!h.state().views[0].organize, "Close ends Organize");
assert_eq!(h.state().left, pdfcraft_ui_egui::LeftPanel::AllTools);
assert!(h.query_by_label("Page properties").is_none(), "the panel is gone");
}
/// Leaving Organize (a page opened from the grid, or the grid's Close) goes back to the
/// workspace's panel: under the Edit tab, the Edit panel, not All tools.
#[test]
fn leaving_organize_goes_back_to_the_workspace_panel() {
use pdfcraft_ui_egui::{LeftPanel, Mode};
let mut h = harness(3, |_| {});
h.state_mut().mode = Mode::Edit;
h.state_mut().left = LeftPanel::Tool("edit");
h.run_steps(2);
h.state_mut().left = LeftPanel::Tool("organize");
h.state_mut().views[0].organize = true;
h.run_steps(3);
h.get_by_label("Pages selected".to_uppercase().as_str());
// What opening a page from the grid does.
h.state_mut().views[0].organize = false;
h.run_steps(3);
assert_eq!(h.state().left, LeftPanel::Tool("edit"), "back to the Edit panel");
// The same from All tools goes back to All tools.
h.state_mut().mode = Mode::AllTools;
h.state_mut().left = LeftPanel::Tool("organize");
h.state_mut().views[0].organize = true;
h.run_steps(3);
h.state_mut().views[0].organize = false;
h.run_steps(3);
assert_eq!(h.state().left, LeftPanel::AllTools);
}
/// The Read mode command (not the mode bar) applies a paragraph still open in the editor, as
/// putting the Edit text tool down does; left open, it would stay drawn in Read mode with no tool.
#[test]
fn read_mode_shortcut_applies_the_open_paragraph() {
use pdfcraft_ui_egui::{Mode, QuickTool};
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);
assert!(h.state().views[0].line_editor.is_some(), "the line opens for editing");
h.state_mut().views[0].line_editor.as_mut().unwrap().text = "Chapter One".into();
h.run_steps(1);
h.state_mut().run_command("view.read_mode");
h.run_steps(3);
let s = h.state();
assert_eq!(s.mode, Mode::Read);
assert_eq!(s.quick_tool, QuickTool::Select);
assert!(s.views[0].line_editor.is_none(), "no editor stays open in Read mode");
assert_eq!(s.session.get(s.views[0].id).unwrap().text_lines(0)[0].text, "Chapter One", "the edit is applied");
}
/// #3061: a paragraph running past its page's bottom edge (an HTML converter relying on the
/// page clip) opens in an editor that draws on its page only. It covered the next page.
#[test]
fn a_paragraph_running_off_its_page_edits_without_covering_the_next_page() {
let _gpu = gpu();
let text = |first: &str, count: usize, leading: usize| {
let mut s = String::from("BT /F1 14 Tf\n");
for i in 0..count {
s.push_str(&format!("1 0 0 1 72 {} Tm ({first} line {}: the client may cancel this) Tj\n", 720 - (leading * i) as i64, i + 1));
}
s.push_str("ET");
s.into_bytes()
};
// Page 1's single paragraph goes down to y = -270; page 2 is ordinary.
let (one, two) = (text("Page one", 56, 18), text("PAGE TWO", 3, 40));
let stream = |d: &[u8]| [format!("<< /Length {} >>\nstream\n", d.len()).into_bytes(), d.to_vec(), b"\nendstream".to_vec()].concat();
let page = |contents: usize| {
format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents {contents} 0 R /Resources << /Font << /F1 7 0 R >> >> >>")
.into_bytes()
};
let objs: Vec<Vec<u8>> = vec![
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
b"<< /Type /Pages /Kids [3 0 R 5 0 R] /Count 2 >>".to_vec(),
page(4),
stream(&one),
page(6),
stream(&two),
b"<< /Type /Font /Subtype /Type1 /BaseFont /Times-Roman >>".to_vec(),
];
let mut bytes = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(bytes.len());
bytes.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
bytes.extend_from_slice(o);
bytes.extend_from_slice(b"\nendobj\n");
}
let xref = bytes.len();
bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
bytes.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
let mut h = Harness::builder().with_size(egui::vec2(1200.0, 1400.0)).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("off-page.pdf", None, bytes).unwrap();
app
});
h.run_steps(4);
h.state_mut().views[0].fit = pdfcraft_ui_egui::canvas::Fit::Page;
h.run_steps(4);
let rects =
|h: &Harness<'static, PdfCraftApp>| (h.state().views[0].page_screen_rect(0).unwrap(), h.state().views[0].page_screen_rect(1).unwrap());
// The same pages without Edit text, so without the paragraph's outline: what the gap looks
// like there, pixel by pixel (review on #3070: it isn't one colour on every platform).
let (first, second) = rects(&h);
let plain = h.render().unwrap();
assert!(h.state_mut().execute("edit.edit_text"));
h.run_steps(4);
assert_eq!(rects(&h), (first, second), "Edit text keeps the pages where they were");
let id = h.state().views[0].id;
let info = h.state().session.get(id).unwrap().info.pages[0].clone();
shown_on_its_page_only(&mut h, &plain, first, second, "the paragraph's outline");
let v = info.user_to_view(200.0, 378.0);
let p = first.min + egui::vec2(v[0], v[1]) * (first.width() / info.width);
h.hover_at(p);
h.run_steps(1);
h.drag_at(p);
h.run_steps(1);
h.drop_at(p);
crate::support::settle(&mut h);
assert!(
h.state().views[0].line_editor.as_ref().is_some_and(|e| e.page == 0 && e.text.contains("line 50")),
"the paragraph's off-page lines are in it"
);
shown_on_its_page_only(&mut h, &plain, first, second, "the open editor");
// Scrolled up past the tool bar, the editor stays inside the document area.
let viewport = h.state().views[0].viewport_rect();
let above = viewport.top() - 3.0;
let before = h.render().unwrap();
h.hover_at(egui::pos2(first.right() - 20.0, first.center().y));
h.event(egui::Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
delta: egui::vec2(0.0, -300.0),
phase: egui::TouchPhase::Move,
modifiers: egui::Modifiers::NONE,
});
h.run_steps(4);
let scrolled = h.state().views[0].page_screen_rect(0).unwrap();
assert!(scrolled.top() < viewport.top() - 100.0, "scrolled: {scrolled:?} in {viewport:?}");
assert!(h.state().views[0].line_editor.is_some(), "still editing");
let after = h.render().unwrap();
for x in (first.left() as u32 + 70)..(first.left() as u32 + 420) {
assert_eq!(after.get_pixel(x, above as u32), before.get_pixel(x, above as u32), "above the document area at x = {x}");
}
}
/// Between pages `first` and `second`, and on `second` above its own text, across where page
/// 1's paragraph is: what is there without it (the gap as `plain` shows it without Edit text,
/// white paper).
fn shown_on_its_page_only(h: &mut Harness<'static, PdfCraftApp>, plain: &image::RgbaImage, first: egui::Rect, second: egui::Rect, what: &str) {
let shot = h.render().unwrap();
let pixel = |x: f32, y: f32| shot.get_pixel(x as u32, y as u32).0;
let gap_y = (first.bottom() + second.top()) / 2.0;
for x in (first.left() as u32 + 70)..(first.left() as u32 + 420) {
let x = x as f32;
assert_eq!(pixel(x, gap_y), plain.get_pixel(x as u32, gap_y as u32).0, "{what}: between the pages at x = {x}");
for dy in [4.0, 20.0, 40.0] {
let [r, g, b, _] = pixel(x, second.top() + dy);
assert!(r > 240 && g > 240 && b > 240, "{what}: page 2 at {x}, +{dy}: {r},{g},{b}");
}
}
}
/// Where a mode bar tab is, by its label.
fn mode_tab(h: &Harness<'static, PdfCraftApp>, label: &str) -> egui::Rect {
h.get_all_by_role_and_label(Role::Button, label).map(|n| n.rect()).find(|r| r.bottom() < 120.0).expect("a mode tab")
}
/// The cursor over each mode bar tab that page 1's text lies under, once that text is scrolled
/// up under the bar. With the All tools panel closed it lies under Edit, Convert and E-Sign; it
/// starts right of All tools.
fn mode_tab_cursors(h: &mut Harness<'static, PdfCraftApp>, text: egui::Rect) -> [(&'static str, egui::CursorIcon); 3] {
h.state_mut().views[0].goto = Some((0, 0.5));
h.run_steps(4);
let offset = h.state().views[0].page_screen_rect(0).expect("on screen").top();
["Edit", "Convert", "E-Sign"].map(|label| {
let at = mode_tab(h, label).center();
assert!(text.translate(egui::vec2(0.0, offset)).contains(at), "the text lies under {label}");
h.hover_at(at);
h.run_steps(2);
(label, h.output().platform_output.cursor_icon)
})
}
const HAND_ON_EVERY_TAB: [(&str, egui::CursorIcon); 3] =
[("Edit", egui::CursorIcon::PointingHand), ("Convert", egui::CursorIcon::PointingHand), ("E-Sign", egui::CursorIcon::PointingHand)];
/// Page 1's text block on screen, relative to the page's top edge, in the window as it is now.
fn page_text(h: &Harness<'static, PdfCraftApp>) -> egui::Rect {
let id = h.state().views[0].id;
let block = h.state().session.get(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 at = |x: f64, y: f64| egui::pos2(r.left() + x as f32 * k, (300.0 - y as f32) * k);
egui::Rect::from_two_pos(at(block.rect[0], block.rect[1]), at(block.rect[2], block.rect[3]))
}
/// Text scrolled up under the mode bar leaves its tabs the hand (#2755). The Select tool's
/// text layer showed its I-beam there, over chrome that hides the page.
#[test]
fn mode_tabs_keep_the_hand_over_page_text_scrolled_under_them() {
let mut h = harness(2, |app| app.set_option("left", "closed").unwrap());
let text = page_text(&h);
// Where the page shows, its text takes the I-beam, once the page's text has arrived.
h.state_mut().views[0].goto = Some((0, 0.3));
h.run_steps(4);
let top = h.state().views[0].page_screen_rect(0).expect("on screen").top();
let mut cursor = egui::CursorIcon::Default;
let text_cursor = crate::support::step_until(&mut h, |h| {
h.hover_at(text.center() + egui::vec2(0.0, top));
h.run_steps(2);
cursor = h.output().platform_output.cursor_icon;
cursor == egui::CursorIcon::Text
});
assert!(text_cursor, "the I-beam over page text, not {cursor:?}");
assert_eq!(mode_tab_cursors(&mut h, text), HAND_ON_EVERY_TAB);
}
/// Edit text, then the All tools panel closed, as reported (#2755): a paragraph scrolled up
/// under the mode bar gave its tabs the four-way arrow, or the I-beam once a paragraph was open.
/// The open paragraph's editor also covered the tabs and took their clicks.
#[test]
fn mode_tabs_keep_the_hand_over_a_paragraph_scrolled_under_them() {
let mut h = harness(2, |_| {});
assert!(h.state_mut().execute("edit.edit_text"));
h.state_mut().set_option("left", "closed").unwrap();
h.run_steps(4);
let text = page_text(&h);
assert_eq!(mode_tab_cursors(&mut h, text), HAND_ON_EVERY_TAB, "a closed paragraph under the tabs");
// Open it, where it shows.
h.state_mut().views[0].goto = Some((0, 0.3));
h.run_steps(4);
let top = h.state().views[0].page_screen_rect(0).expect("on screen").top();
click_at(&mut h, text.center() + egui::vec2(0.0, top));
assert!(h.state().views[0].line_editor.is_some());
assert_eq!(mode_tab_cursors(&mut h, text), HAND_ON_EVERY_TAB, "the open paragraph under the tabs");
// Nor does the editor's frame, with the paragraph's top edge just under a tab (the frame
// reaches 2 to 6 points outside it).
let convert = mode_tab(&h, "Convert").center();
h.state_mut().views[0].goto = Some((0, 0.0));
h.run_steps(4);
let page = h.state().views[0].page_screen_rect(0).expect("on screen");
h.state_mut().views[0].goto = Some((0, (page.top() - (convert.y + 4.0 - text.top())) / page.height()));
h.run_steps(4);
let page = h.state().views[0].page_screen_rect(0).expect("on screen");
assert!((page.top() + text.top() - convert.y - 4.0).abs() < 1.0, "Convert lies on the paragraph's frame");
h.hover_at(convert);
h.run_steps(2);
assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::PointingHand, "the open paragraph's frame under a tab");
h.state_mut().views[0].goto = Some((0, 0.5));
h.run_steps(4);
click_at(&mut h, convert);
assert_eq!(h.state().mode, pdfcraft_ui_egui::Mode::Convert, "the tab takes the click, not the paragraph's editor");
}