Files
pdfcraft/crates/ui-egui/tests/it/editing.rs
T
Livio OswaldandClaude eafe9e2c0b M8: typing the last page of a range keeps what is typed (#5574) (#5579)
* M8: typing the last page of a range keeps what is typed (#5574)

The page-range dialogs (Redact pages, Rotate pages, Duplicate field,
Number pages) raised the end to the start on every frame, so the
half-typed "1" of "12" became the start before the "2" arrived.
The end is now ordered only once its field is no longer being typed
into. Number pages no longer lowers the start to a half-typed end, and
its preview cannot underflow while the end is below the start.

* M8: a test types the last page of Rotate Pages' range (#5574)

Rotate Pages' From and to fields are labelled, and a test types "12" after a
first page of 5 (pages 5-12 are turned) and "3" left by clicking OK (page 5
alone), as croquete1 asked on #5579.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GbuSKAS2k5248LGFEapMUR

* M8: page ranges stay typed while the window is in the background, and OK orders them (#5574)

Review follow-up. The end field's typing check used Response::has_focus,
which is false while another window is in front, so switching apps
halfway through "12" still raised the typed "1" to the start. It now
asks egui's memory (widgets::typing_into). OK pressed while the end is
still focused (a screen reader's click) orders the range first in Rotate
pages, Duplicate field and Redact pages, and Number pages builds its edit
from the ordered end, so a reversed range never reaches the edit.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-11-01 15:05:01 -05:00

9649 lines
439 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());
}
/// Run `frames` frames and collect every attention request they sent to the window.
fn attention_over(h: &mut Harness<'static, PdfCraftApp>, frames: usize) -> Vec<egui::UserAttentionType> {
let mut sent = Vec::new();
for _ in 0..frames {
h.step();
let commands = h.output().viewport_output.values().flat_map(|v| v.commands.iter());
sent.extend(commands.filter_map(|c| if let egui::ViewportCommand::RequestUserAttention(a) = c { Some(*a) } else { None }));
}
sent
}
/// Close the window as the operating system does: Quit in the Dock menu, or logging out.
fn close_window(h: &mut Harness<'static, PdfCraftApp>) {
h.input_mut().viewports.entry(egui::ViewportId::ROOT).or_default().events.push(egui::ViewportEvent::Close);
}
/// Quit from the Dock while another app is in front: the prompt opens in a window the user can't
/// see, so the Dock icon bounces (the taskbar button flashes) until they come back. A prompt that
/// opens in front asks for nothing.
#[test]
fn a_save_prompt_opened_in_the_background_asks_for_attention_until_the_user_returns() {
use egui::UserAttentionType::{Critical, Reset};
let mut h = harness(2, |_| {});
h.state_mut().views[0].select_pages(&[0]);
h.state_mut().apply_edit(pdfcraft_engine::Edit::RotatePages { pages: vec![0], degrees: 90 });
h.input_mut().focused = false;
close_window(&mut h);
assert_eq!(attention_over(&mut h, 4), [Critical], "asked once, when the prompt opened");
assert_eq!(h.state().close_request, Some(CloseRequest::Quit));
h.input_mut().focused = true;
assert_eq!(attention_over(&mut h, 3), [Reset], "coming back withdraws the request");
h.get_by_label_contains("Save changes to “doc.pdf”");
h.get_by_label("Cancel").click();
assert!(attention_over(&mut h, 3).is_empty());
assert_eq!(h.state().views.len(), 1, "cancel keeps the document open");
// With PdfCraft in front, the prompt is in plain sight.
close_window(&mut h);
assert!(attention_over(&mut h, 4).is_empty());
h.get_by_label_contains("Save changes to “doc.pdf”");
// Leaving with the prompt open doesn't ask either: the user has seen it.
h.input_mut().focused = false;
assert!(attention_over(&mut h, 3).is_empty());
}
/// 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();
if support::skip_without_glyphs(&format!("save_prompt_fits_the_smallest_window_whatever_the_name ({start}…)"), "en", &name) {
continue;
}
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());
}
fn custom_properties(h: &Harness<'static, PdfCraftApp>) -> Vec<(String, String)> {
let app = h.state();
app.session.get(app.views[0].id).unwrap().custom_info()
}
fn type_into(h: &mut Harness<'static, PdfCraftApp>, label: &str, text: &str) {
let field = h.get_by_role_and_label(Role::TextInput, label);
field.focus();
field.type_text(text);
h.run_steps(2);
}
fn clear(h: &mut Harness<'static, PdfCraftApp>, label: &str) {
h.get_by_role_and_label(Role::TextInput, label).focus();
h.run_steps(1);
h.key_press_modifiers(Modifiers::COMMAND, Key::A);
h.key_press(Key::Backspace);
h.run_steps(2);
}
#[test]
fn custom_properties_are_added_in_the_custom_tab_as_one_undoable_step() {
let mut h = harness(1, |app| app.set_option("dialog", "custom").unwrap());
h.get_by_label("None");
type_into(&mut h, "Name:", "Project");
type_into(&mut h, "Value:", "Apollo");
h.get_by_role_and_label(Role::Button, "Add").click();
h.run_steps(3);
assert!(custom_properties(&h).is_empty(), "nothing is written before OK");
h.get_by_label("Project");
type_into(&mut h, "Name:", "Revision");
type_into(&mut h, "Value:", "4");
h.get_by_role_and_label(Role::Button, "Add").click();
h.run_steps(3);
h.get_by_label("OK").click();
h.run_steps(3);
assert_eq!(custom_properties(&h), [("Project".to_string(), "Apollo".to_string()), ("Revision".to_string(), "4".to_string())]);
let app = h.state();
assert_eq!(app.session.get(app.views[0].id).unwrap().can_undo(), Some("Change document properties"));
assert!(app.dialog.is_none() && app.custom_draft.is_none());
h.key_press_modifiers(Modifiers::COMMAND, Key::Z);
h.run_steps(3);
assert!(custom_properties(&h).is_empty());
}
#[test]
fn custom_properties_are_changed_and_deleted_in_the_custom_tab() {
let mut h = harness(1, |app| {
for (key, value) in [("Project", "Apollo"), ("Revision", "4")] {
assert!(app.apply_edit(pdfcraft_engine::Edit::SetInfo { key: key.into(), value: value.into() }));
}
app.set_option("dialog", "custom").unwrap();
});
h.get_by_label("Revision").click();
h.run_steps(3);
h.get_by_role_and_label(Role::Button, "Delete").click();
h.run_steps(3);
h.get_by_label("Project").click();
h.run_steps(3);
assert!(h.get_by_role_and_label(Role::Button, "Update").accesskit_node().is_disabled(), "the value is unchanged");
clear(&mut h, "Value:");
type_into(&mut h, "Value:", "Apollo 2");
h.get_by_role_and_label(Role::Button, "Update").click();
h.run_steps(3);
h.get_by_label("OK").click();
h.run_steps(3);
assert_eq!(custom_properties(&h), [("Project".to_string(), "Apollo 2".to_string())]);
let app = h.state();
let doc = app.session.get(app.views[0].id).unwrap();
assert_eq!(doc.info_value("Revision"), None);
assert_eq!(doc.can_undo(), Some("Change document properties"));
}
#[test]
fn the_custom_tab_refuses_names_a_custom_property_cannot_have() {
let mut h = harness(1, |app| {
assert!(app.apply_edit(pdfcraft_engine::Edit::SetInfo { key: "Project".into(), value: "Apollo".into() }));
app.set_option("dialog", "custom").unwrap();
});
type_into(&mut h, "Value:", "x");
let add_disabled = |h: &Harness<'static, PdfCraftApp>| h.get_by_role_and_label(Role::Button, "Add").accesskit_node().is_disabled();
assert!(add_disabled(&h), "no name");
// A standard name, a name with a space, a delimiter, and a name already listed.
for name in ["Title", "Client name", "R&D/2", "Project"] {
type_into(&mut h, "Name:", name);
assert!(add_disabled(&h), "{name} is refused");
clear(&mut h, "Name:");
}
type_into(&mut h, "Name:", "Client");
assert!(!add_disabled(&h));
assert!(h.get_by_role_and_label(Role::Button, "Delete").accesskit_node().is_disabled(), "Client is not listed");
}
#[test]
fn a_custom_property_whose_name_the_tools_refuse_is_listed_and_left_alone() {
// `/Client#20name` and `/Project#20` are valid PDF names with a space in them, which a new custom
// property can't have; `/Project#20` is not `/Project`. `/Dept#E9` is not UTF-8 and is listed as
// "Dept\u{FFFD}". `/Revision` is a number, so it isn't listed.
let pdf = String::from_utf8(fixture(1)).unwrap().replace(
"/Root 1 0 R >>\nstartxref",
"/Root 1 0 R /Info << /Client#20name (Acme) /Project#20 (Padded) /Dept#E9 (Sales) /Project (Apollo) /Revision 4 >> >>\nstartxref",
);
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("doc.pdf", None, pdf.into_bytes()).expect("fixture opens");
app.set_option("dialog", "custom").unwrap();
app
});
h.run_steps(4);
for name in ["Client name", "Project ", "Dept\u{FFFD}"] {
h.get_by_label(name).click();
h.run_steps(3);
for button in ["Update", "Delete"] {
assert!(h.get_by_role_and_label(Role::Button, button).accesskit_node().is_disabled(), "{button} is off for {name:?}");
}
}
clear(&mut h, "Name:");
type_into(&mut h, "Name:", "Revision");
assert!(h.get_by_role_and_label(Role::Button, "Add").accesskit_node().is_disabled(), "Add would overwrite the number");
assert_eq!(h.state().custom_draft.as_ref().map(|(_, d)| d.value.as_str()), Some("Sales"));
h.get_by_label("Project").click();
h.run_steps(3);
clear(&mut h, "Value:");
type_into(&mut h, "Value:", "Gemini");
h.get_by_role_and_label(Role::Button, "Update").click();
h.run_steps(3);
h.get_by_label("OK").click();
h.run_steps(3);
assert_eq!(
custom_properties(&h),
[
("Client name".to_string(), "Acme".to_string()),
("Project ".to_string(), "Padded".to_string()),
("Dept\u{FFFD}".to_string(), "Sales".to_string()),
("Project".to_string(), "Gemini".to_string())
]
);
let app = h.state();
let doc = app.session.get(app.views[0].id).unwrap();
assert!(doc.has_info("Revision") && doc.info_value("Revision").is_none(), "the number is kept");
assert!(app.dialog.is_none());
}
#[test]
fn a_custom_property_is_deleted_from_an_info_dictionary_in_the_trailer() {
let pdf = String::from_utf8(fixture(1))
.unwrap()
.replace("/Root 1 0 R >>\nstartxref", "/Root 1 0 R /Info << /Project (Apollo) /Revision (4) >> >>\nstartxref");
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("doc.pdf", None, pdf.into_bytes()).expect("fixture opens");
app.set_option("dialog", "custom").unwrap();
app
});
h.run_steps(4);
h.get_by_label("Project").click();
h.run_steps(3);
h.get_by_role_and_label(Role::Button, "Delete").click();
h.run_steps(3);
h.get_by_label("OK").click();
h.run_steps(3);
assert_eq!(custom_properties(&h), [("Revision".to_string(), "4".to_string())]);
let app = h.state();
assert_eq!(app.session.get(app.views[0].id).unwrap().can_undo(), Some("Change document properties"));
}
#[test]
fn add_stops_at_the_256_custom_properties_the_tab_lists() {
let with_props = |count: usize| {
let entries: String = (0..count).map(|n| format!("/P{n:03} (v) ")).collect();
let pdf =
String::from_utf8(fixture(1)).unwrap().replace("/Root 1 0 R >>\nstartxref", &format!("/Root 1 0 R /Info << {entries}>> >>\nstartxref"));
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("doc.pdf", None, pdf.into_bytes()).expect("fixture opens");
app.set_option("dialog", "custom").unwrap();
app
});
h.run_steps(4);
h
};
let add_disabled = |h: &Harness<'static, PdfCraftApp>| h.get_by_role_and_label(Role::Button, "Add").accesskit_node().is_disabled();
// 255 in the file: one more fits, a second does not.
let mut h = with_props(255);
type_into(&mut h, "Name:", "Client");
type_into(&mut h, "Value:", "Acme");
assert!(!add_disabled(&h));
h.get_by_role_and_label(Role::Button, "Add").click();
h.run_steps(3);
type_into(&mut h, "Name:", "Other");
type_into(&mut h, "Value:", "x");
assert!(add_disabled(&h), "the draft holds 256");
assert_eq!(h.state().custom_draft.as_ref().map(|(_, d)| d.props.len()), Some(256));
// 257 in the file: after a Delete a new name would still be past the 256 listed.
let mut h = with_props(257);
h.get_by_label("P000").click();
h.run_steps(3);
h.get_by_role_and_label(Role::Button, "Delete").click();
h.run_steps(3);
type_into(&mut h, "Name:", "Client");
type_into(&mut h, "Value:", "Acme");
assert!(add_disabled(&h), "the file lists 256");
}
#[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 });
});
support::open_menu(&mut h, "Edit");
support::menu_row(&h, "Undo Rotate page").click();
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);
}
#[test]
fn split_from_organize_grid_context_menu() {
let dir = temp_path("split-org-ctx");
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.get_by_label("Page 3").click_button(egui::PointerButton::Secondary);
h.run_steps(2);
h.get_all_by_label("Split document…").last().unwrap().click();
h.run_steps(3);
assert_eq!(h.state().dialog, Some(pdfcraft_ui_egui::Dialog::Split));
assert_eq!(h.state().split_draft.mode, pdfcraft_ui_egui::SplitMode::Selection);
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);
}
#[test]
fn split_from_pages_panel_context_menu() {
let mut h = harness(4, |app| app.right = Some(pdfcraft_ui_egui::RightPanel::Pages));
let thumb = h.get_all_by_label("Page 3").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("Split document…").last().unwrap().click();
h.run_steps(3);
assert_eq!(h.state().dialog, Some(pdfcraft_ui_egui::Dialog::Split));
assert_eq!(h.state().split_draft.mode, pdfcraft_ui_egui::SplitMode::Selection);
h.get_by_label_contains("Creates 2 files from 4 pages");
}
#[test]
fn split_from_pages_panel_context_menu_on_current_page_without_selection() {
let mut h = harness(4, |app| app.right = Some(pdfcraft_ui_egui::RightPanel::Pages));
// Navigate to Page 2 (0-indexed 1) so it is current, but keep selected empty
h.state_mut().views[0].go_to_page(1);
h.state_mut().views[0].selected.clear();
h.run_steps(2);
assert_eq!(h.state().views[0].current, 1);
assert!(h.state().views[0].selected.is_empty());
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("Split document…").last().unwrap().click();
h.run_steps(3);
assert_eq!(h.state().dialog, Some(pdfcraft_ui_egui::Dialog::Split));
assert_eq!(h.state().split_draft.mode, pdfcraft_ui_egui::SplitMode::Selection);
h.get_by_label_contains("Creates 2 files from 4 pages");
}
#[test]
fn split_from_pages_panel_context_menu_on_page_one_falls_back_to_pages() {
let mut h = harness(4, |app| app.right = Some(pdfcraft_ui_egui::RightPanel::Pages));
let thumb = h.get_all_by_label("Page 1").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("Split document…").last().unwrap().click();
h.run_steps(3);
assert_eq!(h.state().dialog, Some(pdfcraft_ui_egui::Dialog::Split));
assert_eq!(h.state().split_draft.mode, pdfcraft_ui_egui::SplitMode::Pages);
}
#[test]
fn split_from_pages_panel_context_menu_with_multi_selection() {
let mut h = harness(4, |app| app.right = Some(pdfcraft_ui_egui::RightPanel::Pages));
// Select pages 2 and 4 (indices 1 and 3)
h.state_mut().views[0].select_pages(&[1, 3]);
h.run_steps(2);
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("Split document…").last().unwrap().click();
h.run_steps(3);
assert_eq!(h.state().dialog, Some(pdfcraft_ui_egui::Dialog::Split));
assert_eq!(h.state().split_draft.mode, pdfcraft_ui_egui::SplitMode::Selection);
h.get_by_label_contains("Creates 3 files from 4 pages");
}
// ── 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 launch_display_override_survives_password_retry_without_changing_defaults() {
use pdfcraft_ui_egui::canvas::PageLayout;
use std::io::Write as _;
let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
let path = temp_path(&format!("startup-layout-{nonce}.pdf"));
let mut file = std::fs::OpenOptions::new().write(true).create_new(true).open(&path).unwrap();
file.write_all(&protected("reader", "owner", -4)).unwrap();
drop(file);
let mut app = PdfCraftApp::new();
app.set_option("default-layout", "continuous").unwrap();
app.open_path_with_display(path.to_str().unwrap(), PageLayout::TwoUp, false);
assert!(app.views.is_empty() && app.password_prompt.is_some());
app.submit_password(Some("wrong".into()));
assert!(app.views.is_empty() && app.password_prompt.is_some());
app.submit_password(Some("reader".into()));
assert_eq!(app.views.len(), 1);
assert_eq!(app.views[0].layout, PageLayout::TwoUp);
assert!(!app.views[0].continuous());
let persisted: serde_json::Value = serde_json::from_str(&app.persist()).unwrap();
assert_eq!(persisted["default_layout"], "continuous");
std::fs::remove_file(path).unwrap();
}
#[test]
fn asynchronous_launch_display_override_survives_retries_and_cancel() {
asynchronous_launch_display(false);
}
#[test]
fn asynchronous_launch_display_explicit_layout_survives_retries_and_cancel() {
asynchronous_launch_display(true);
}
fn asynchronous_launch_display(explicit: bool) {
use pdfcraft_ui_egui::canvas::PageLayout;
fn wait(h: &mut Harness<'static, PdfCraftApp>, stage: &str, ready: impl Fn(&PdfCraftApp) -> bool) {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
while !ready(h.state()) {
assert!(
std::time::Instant::now() < deadline,
"{stage}: views={}, prompt={}, error={}, notice={}",
h.state().views.len(),
h.state().password_prompt.is_some(),
h.state().password_prompt.as_ref().is_some_and(|p| p.error.is_some()),
h.state().toast.is_some()
);
h.run_steps(2);
std::thread::sleep(std::time::Duration::from_millis(5));
}
}
let path = temp_path(&format!("async-startup-layout-{explicit}.pdf"));
std::fs::write(&path, protected("reader", "owner", -4)).unwrap();
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.set_option("default-layout", "continuous").unwrap();
app.open_bytes("other.pdf", None, fixture(2)).unwrap();
app
});
h.run_steps(2);
assert!(!h.state().run_inline, "exercise upstream's actual background-opening path");
let other = h.state().views[0].id;
h.state_mut().open_path_with_display(path.to_str().unwrap(), PageLayout::TwoUp, false);
if explicit {
h.state_mut().set_launch_layout("single").unwrap();
}
wait(&mut h, "password prompt", |app| app.password_prompt.is_some());
// The existing document keeps its default view while this request is pending.
h.state_mut().submit_password(Some("wrong".into()));
wait(&mut h, "wrong-password result", |app| app.password_prompt.as_ref().is_some_and(|p| p.error.is_some()));
assert_eq!(h.state().views.len(), 1);
h.state_mut().submit_password(Some("reader".into()));
wait(&mut h, "first unlock", |app| app.views.len() == 2);
assert_eq!(h.state().views[0].id, other);
assert_eq!(h.state().views[0].layout, PageLayout::Single);
assert!(h.state().views[0].continuous());
assert_eq!(h.state().views[1].layout, if explicit { PageLayout::Single } else { PageLayout::TwoUp });
assert!(!h.state().views[1].continuous());
let second_path = path.with_extension("second.pdf");
std::fs::copy(&path, &second_path).unwrap();
h.state_mut().open_path_with_display(second_path.to_str().unwrap(), PageLayout::TwoUp, false);
wait(&mut h, "password prompt", |app| app.password_prompt.is_some());
h.state_mut().submit_password(None);
h.state_mut().open_path(second_path.to_str().unwrap());
wait(&mut h, "password prompt", |app| app.password_prompt.is_some());
h.state_mut().submit_password(Some("reader".into()));
wait(&mut h, "normal unlock after cancel", |app| app.views.len() == 3);
assert_eq!(h.state().views[2].layout, PageLayout::Single);
assert!(h.state().views[2].continuous());
let persisted: serde_json::Value = serde_json::from_str(&h.state().persist()).unwrap();
assert_eq!(persisted["default_layout"], "continuous");
std::fs::remove_file(path).unwrap();
std::fs::remove_file(second_path).unwrap();
}
#[test]
fn canceling_launch_display_override_does_not_affect_later_documents() {
use pdfcraft_ui_egui::canvas::PageLayout;
use std::io::Write as _;
let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
let path = temp_path(&format!("startup-cancel-layout-{nonce}.pdf"));
let mut file = std::fs::OpenOptions::new().write(true).create_new(true).open(&path).unwrap();
file.write_all(&protected("reader", "owner", -4)).unwrap();
drop(file);
let mut app = PdfCraftApp::new();
app.set_option("default-layout", "continuous").unwrap();
app.open_path_with_display(path.to_str().unwrap(), PageLayout::TwoUp, false);
assert!(app.views.is_empty() && app.password_prompt.is_some());
app.submit_password(None);
assert!(app.views.is_empty() && app.password_prompt.is_none());
app.open_bytes("next.pdf", None, fixture(2)).unwrap();
assert_eq!(app.views[0].layout, PageLayout::Single);
assert!(app.views[0].continuous());
// Reopening the same protected file normally must not resurrect the canceled override.
app.open_path(path.to_str().unwrap());
app.submit_password(Some("reader".into()));
assert_eq!(app.views.len(), 2);
assert_eq!(app.views[1].layout, PageLayout::Single);
assert!(app.views[1].continuous());
let persisted: serde_json::Value = serde_json::from_str(&app.persist()).unwrap();
assert_eq!(persisted["default_layout"], "continuous");
std::fs::remove_file(path).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");
}
/// #4035: the secured-document notice can be turned off for good. "Don't show again" hides it
/// in every document, the choice is kept in the settings and turned back on in Preferences (or
/// `--secured-notice`); the document stays restricted and Properties ▸ Security still opens.
#[test]
fn the_security_notice_can_be_turned_off_for_good() {
let open = |settings: Option<String>| {
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
if let Some(s) = &settings {
app.restore(s);
}
app.set_option("language", "en").unwrap();
app.open_bytes("locked.pdf", None, protected("", "owner", 0b0100)).unwrap();
app
});
h.run_steps(4);
h
};
let mut h = open(None);
assert!(h.state().show_secured_notice, "on by default");
h.get_by_label_contains("This document is secured");
h.get_by_label("Don't show again").click();
h.run_steps(2);
assert!(h.query_by_label_contains("This document is secured").is_none());
assert!(!h.state().show_secured_notice);
let id = h.state().active_ids().unwrap().1;
assert!(h.state().session.get(id).unwrap().security_summary().is_some_and(|s| !s.owner), "still restricted");
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("Password security");
h.get_by_label("User password");
// Kept in the settings: a later session opens the same file without the notice.
let saved = h.state().persist();
let mut h = open(Some(saved));
assert!(h.query_by_label_contains("This document is secured").is_none(), "remembered across a restart");
// Back on from Preferences.
h.state_mut().set_option("dialog", "preferences").unwrap();
h.run_steps(2);
h.get_by_role_and_label(Role::CheckBox, "Show a notice when a document is secured").scroll_to_me();
h.run_steps(4);
h.get_by_role_and_label(Role::CheckBox, "Show a notice when a document is secured").click();
h.run_steps(2);
assert!(h.state().show_secured_notice);
h.state_mut().dialog = None;
h.run_steps(2);
h.get_by_label_contains("This document is secured");
// Scriptable, with a clear refusal for anything else.
let app = h.state_mut();
app.set_option("secured-notice", "false").unwrap();
assert!(!app.show_secured_notice);
assert_eq!(app.set_option("secured-notice", "maybe"), Err("secured-notice must be true or false".into()));
}
/// 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()));
// One page at a time: Select All takes that page's text (scrolling continuously it would
// take both pages', #2639; what is tested here is the security of copying it).
if let Some(v) = app.views.first_mut() {
v.set_continuous(false);
}
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]);
}
/// #5579: typing the last page of Rotate Pages' range keeps what is typed (croquete1): "12" after
/// a first page of 5 rotates pages 5–12, not page 5 alone. An end typed below the start and left
/// by clicking OK is raised to the start as the field lets go: page 5, not nothing.
#[test]
fn rotate_pages_keeps_a_typed_last_page() {
use egui::accesskit::Role;
for (typed, rotated) in [("12", 5..13), ("3", 5..6)] {
let mut h = organize(20);
h.state_mut().run_command("page.rotate_dialog");
h.run_steps(2);
let d = &mut h.state_mut().rotate_draft;
(d.which, d.from, d.to) = (2, 5, 5);
h.run_steps(1);
h.get_by_role_and_label(Role::SpinButton, "to").click();
h.run_steps(2);
// As events, not `key_press`: its `run` checks the dialog's unnamed combo boxes.
h.event(egui::Event::Key { key: egui::Key::A, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::COMMAND });
for c in typed.chars() {
h.event(egui::Event::Text(c.to_string()));
h.run_steps(2);
}
// OK at once, the end still being typed.
h.get_by_label("OK").click();
h.run_steps(3);
let s = h.state();
let d = s.session.get(s.views[0].id).unwrap();
let turned: Vec<usize> = d.info.pages.iter().enumerate().filter(|(_, p)| p.rotation != 0).map(|(i, _)| i + 1).collect();
assert_eq!(turned, rotated.collect::<Vec<usize>>(), "typed {typed:?}");
}
}
#[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() {
if crate::support::skip_without_glyphs("a_paragraph_whose_edit_fails_stays_open_with_its_typing", "en", "Top ก") {
return;
}
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");
}
}
/// One US Letter page of Helvetica, for driving the live editor on a known baseline.
fn letter_text(body: &str) -> Vec<u8> {
let objs = [
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".to_string(),
format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()),
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_string(),
];
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, object) in objs.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{object}\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 offset in offsets {
out.extend_from_slice(format!("{offset: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_letter(name: &'static str, bytes: Vec<u8>) -> Harness<'static, PdfCraftApp> {
let mut h = Harness::builder().with_size(egui::vec2(1100.0, 1400.0)).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes(name, None, bytes).unwrap();
app
});
h.run_steps(4);
h
}
fn click_page(h: &mut Harness<'static, PdfCraftApp>, x: f32, y: f32) {
let page = h.state().views[0].page_screen_rect(0).expect("the page is on screen");
let at = egui::pos2(page.left() + x / 612.0 * page.width(), page.top() + (792.0 - y) / 792.0 * page.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);
}
fn save_live_shot(h: &mut Harness<'static, PdfCraftApp>, name: &str) {
for _ in 0..40 {
h.run_steps(2);
if !h.state().render_pending() {
break;
}
}
let dir = std::path::Path::new("/tmp/pdfcraft-visual");
let _ = std::fs::create_dir_all(dir);
if let Ok(image) = h.render() {
let _ = image.save(dir.join(name));
}
}
/// Live editor: a repeated step with no TL opens as one paragraph, and adding a row
/// beside a kept first row leaves the neighbour as its own paragraph on screen.
#[test]
fn the_live_editor_groups_a_repeated_step_and_keeps_the_neighbour() {
let step = 27.6_f32;
let repeated = format!(
"BT /F1 12 Tf 72 700 Td (One) Tj ET BT /F1 12 Tf 72 {} Td (Two) Tj ET BT /F1 12 Tf 72 {} Td (Three) Tj ET",
700.0 - step,
700.0 - 2.0 * step
);
let mut h = open_letter("repeated.pdf", letter_text(repeated.as_str()));
assert!(h.state_mut().execute("edit.edit_text"));
h.run_steps(2);
click_page(&mut h, 90.0, 700.0 - step + 4.0);
let editor = h.state().views[0].line_editor.clone().expect("the middle row opens");
assert!(editor.text.contains("One") && editor.text.contains("Two") && editor.text.contains("Three"), "{}", editor.text);
save_live_shot(&mut h, "live-repeated-step.png");
let partial = "BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (Alpha first row.) Tj T* (Alpha next row.) Tj ET \
BT /F1 12 Tf 14.4 TL 1 0 0 1 72 656.8 Tm (Neighbor stays.) Tj ET";
let mut h = open_letter("partial.pdf", letter_text(partial));
assert!(h.state_mut().execute("edit.edit_text"));
h.run_steps(2);
click_page(&mut h, 120.0, 704.0);
let editor = h.state().views[0].line_editor.clone().expect("the first paragraph opens");
assert!(editor.text.contains("Alpha first row.") && editor.text.contains("Alpha next row."), "{}", editor.text);
assert!(!editor.text.contains("Neighbor"), "{}", editor.text);
h.state_mut().views[0].line_editor.as_mut().unwrap().text = "Alpha first row.\nTwo\nThree".into();
h.run_steps(1);
h.key_press_modifiers(Modifiers::COMMAND, Key::Enter);
h.run_steps(6);
let blocks = {
let s = h.state();
let doc = s.session.get(s.views[0].id).unwrap();
doc.text_blocks(0).iter().map(|b| b.text.clone()).collect::<Vec<_>>()
};
assert_eq!(blocks, ["Alpha first row. Two Three", "Neighbor stays."]);
save_live_shot(&mut h, "live-after-adding-a-row.png");
click_page(&mut h, 120.0, 660.0);
let neighbour = h.state().views[0].line_editor.clone().expect("the neighbour opens on its own");
assert_eq!(neighbour.text, "Neighbor stays.");
save_live_shot(&mut h, "live-neighbour-open.png");
}
/// Widening example 1 must not pull the heading into that box or split the paragraph under example 2.
#[test]
fn widening_example_one_keeps_the_heading_and_the_next_paragraph() {
let body = "BT /F1 14 Tf 72 740 Td (1. Repeated step, no TL. Click Two.) Tj ET \
BT /F1 12 Tf 72 700 Td (One) Tj ET \
BT /F1 12 Tf 72 672.4 Td (Two) Tj ET \
BT /F1 12 Tf 72 644.8 Td (Three) Tj ET \
BT /F1 14 Tf 72 590 Td (2. Blank line. Edit the first paragraph.) Tj ET \
BT /F1 12 Tf 14.4 TL 1 0 0 1 72 550 Tm (First paragraph, first row.) Tj T* (First paragraph, second row.) Tj ET \
BT /F1 12 Tf 14.4 TL 1 0 0 1 72 506.8 Tm (Second paragraph, first row.) Tj T* (Second paragraph, second row.) Tj ET \
BT /F1 14 Tf 72 450 Td (3. Add a row under Alpha. Neighbor stays.) Tj ET \
BT /F1 12 Tf 14.4 TL 1 0 0 1 72 410 Tm (Alpha first row.) Tj T* (Alpha next row.) Tj ET \
BT /F1 12 Tf 14.4 TL 1 0 0 1 72 366.8 Tm (Neighbor stays.) Tj ET";
let mut h = open_letter("letter.pdf", letter_text(body));
assert!(h.state_mut().execute("edit.edit_text"));
h.run_steps(2);
click_page(&mut h, 90.0, 690.0);
h.state_mut().views[0].line_editor.as_mut().unwrap().text = "One\nTwo\nThree\nfour".into();
h.run_steps(1);
h.key_press_modifiers(Modifiers::COMMAND, Key::Enter);
h.run_steps(8);
let (edge, page_id) = {
let s = h.state();
let id = s.views[0].id;
let blocks = s.session.get(id).unwrap().text_blocks(0);
let texts: Vec<_> = blocks.iter().map(|b| b.text.clone()).collect();
assert!(texts.iter().any(|t| t == "1. Repeated step, no TL. Click Two."), "commit already joined the heading: {texts:?}");
assert!(
texts.iter().any(|t| t.contains("First paragraph, first row") && t.contains("second row")),
"commit already split the next paragraph: {texts:?}"
);
let block = blocks.iter().find(|b| b.text.contains("four")).expect("the edited paragraph");
let page = s.views[0].page_screen_rect(0).unwrap();
let edge = egui::pos2(
page.left() + block.rect[2] as f32 / 612.0 * page.width(),
page.top() + (792.0 - (block.rect[1] + block.rect[3]) as f32 / 2.0) / 792.0 * page.height(),
);
(edge, id)
};
let to = edge + egui::vec2(280.0, 0.0);
h.hover_at(edge);
h.run_steps(1);
h.drag_at(edge);
h.run_steps(1);
for k in 1..=6 {
h.hover_at(edge + (to - edge) * (k as f32 / 6.0));
h.run_steps(1);
}
h.drop_at(to);
h.run_steps(8);
let texts: Vec<_> = {
let s = h.state();
s.session.get(page_id).unwrap().text_blocks(0).iter().map(|b| b.text.clone()).collect()
};
let edited = {
let s = h.state();
s.session
.get(page_id)
.unwrap()
.text_blocks(0)
.iter()
.find(|b| b.editor_text.contains("four"))
.expect("the widened paragraph")
.editor_text
.clone()
};
assert_eq!(edited, "One\nTwo\nThree\nfour", "the width handle flattened the line breaks: {edited:?}");
assert!(texts.iter().any(|t| t == "1. Repeated step, no TL. Click Two."), "the width handle joined the heading: {texts:?}");
assert!(
texts.iter().any(|t| t.contains("First paragraph, first row") && t.contains("First paragraph, second row")),
"the width handle split the next paragraph: {texts:?}"
);
save_live_shot(&mut h, "live-example-one-widened.png");
}
/// The live width handle, after a row was added on top of the neighbour.
#[test]
fn resizing_the_live_box_keeps_the_neighbour() {
let partial = "BT /F1 12 Tf 14.4 TL 1 0 0 1 72 410 Tm (Alpha first row.) Tj T* (Alpha next row.) Tj ET \
BT /F1 12 Tf 14.4 TL 1 0 0 1 72 366.8 Tm (Neighbor stays.) Tj ET";
let mut h = open_letter("partial.pdf", letter_text(partial));
assert!(h.state_mut().execute("edit.edit_text"));
h.run_steps(2);
click_page(&mut h, 120.0, 414.0);
h.state_mut().views[0].line_editor.as_mut().unwrap().text = "Alpha first row.\nthis is another\nalpha test\nAlpha next row.".into();
h.run_steps(1);
h.key_press_modifiers(Modifiers::COMMAND, Key::Enter);
h.run_steps(6);
let before = {
let s = h.state();
s.session.get(s.views[0].id).unwrap().text_blocks(0).iter().map(|b| b.text.clone()).collect::<Vec<_>>()
};
assert!(before.iter().any(|t| t.contains("Neighbor stays.")), "the edit already dropped it: {before:?}");
let page = h.state().views[0].page_screen_rect(0).unwrap();
let rect = h.state().session.get(h.state().views[0].id).unwrap().text_blocks(0)[0].rect;
let edge = egui::pos2(
page.left() + rect[2] as f32 / 612.0 * page.width(),
page.top() + (792.0 - (rect[1] + rect[3]) as f32 / 2.0) / 792.0 * page.height(),
);
let to = edge + egui::vec2(80.0, 0.0);
h.hover_at(edge);
h.run_steps(1);
h.drag_at(edge);
h.run_steps(1);
for k in 1..=4 {
h.hover_at(edge + (to - edge) * (k as f32 / 4.0));
h.run_steps(1);
}
h.drop_at(to);
h.run_steps(6);
let after = {
let s = h.state();
s.session.get(s.views[0].id).unwrap().text_blocks(0).iter().map(|b| b.text.clone()).collect::<Vec<_>>()
};
assert!(after.iter().any(|t| t.contains("Neighbor stays.")), "resize removed the neighbour: {after:?}");
save_live_shot(&mut h, "live-after-resize.png");
}
#[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");
}
/// Under the Edit tab, opens the line "Page 1" (at (20, 150) on a 200 × 300 page, 24 pt) in the
/// paragraph editor and retypes it as "Chapter One", leaving the editor open.
fn edit_tab_with_an_open_paragraph(h: &mut Harness<'static, PdfCraftApp>) {
use pdfcraft_ui_egui::{Mode, QuickTool};
support::outside_menus(h, "Edit").click();
h.run_steps(3);
assert_eq!((h.state().mode, h.state().quick_tool), (Mode::Edit, QuickTool::EditText));
open_the_first_line(h);
}
/// With Edit text & images on, opens the line "Page 1" in the paragraph editor and retypes it as
/// "Chapter One", leaving the editor open.
fn open_the_first_line(h: &mut Harness<'static, PdfCraftApp>) {
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);
}
/// Edit was left with the paragraph [`edit_tab_with_an_open_paragraph`] opened: Edit text & images
/// is put down, and the paragraph's edit is applied instead of its editor staying open over the page.
fn assert_edit_text_put_down_and_the_paragraph_applied(s: &PdfCraftApp) {
assert_eq!(s.quick_tool, pdfcraft_ui_egui::QuickTool::Select, "Edit text & images is put down");
assert!(s.views[0].line_editor.is_none(), "no paragraph editor is left open");
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");
}
/// 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() {
let mut h = harness(1, |_| {});
edit_tab_with_an_open_paragraph(&mut h);
assert!(h.state_mut().execute("view.read_mode"));
h.run_steps(3);
assert_eq!(h.state().mode, pdfcraft_ui_egui::Mode::Read);
assert_edit_text_put_down_and_the_paragraph_applied(h.state());
}
/// The Edit panel's back arrow (it has one when opened from All tools; the Edit tab is its own way
/// back) leaves for All tools as the mode bar does: Edit text & images is put down and a paragraph
/// still open in the editor is applied, instead of every paragraph keeping its box, and the editor
/// staying open, under All tools.
#[test]
fn the_edit_panel_back_arrow_applies_an_open_paragraph_and_puts_edit_text_down() {
use pdfcraft_ui_egui::{LeftPanel, Mode, QuickTool};
let mut h = harness(1, |_| {});
// All tools ▸ Edit a PDF ▸ Edit text & images: the Edit panel, under All tools.
assert!(h.state_mut().execute("edit.edit_text"));
h.run_steps(3);
assert_eq!((h.state().mode, h.state().quick_tool), (Mode::AllTools, QuickTool::EditText));
open_the_first_line(&mut h);
h.get_by_label("Back to all tools").click();
h.run_steps(3);
assert_eq!((h.state().mode, h.state().left), (Mode::AllTools, LeftPanel::AllTools));
assert_edit_text_put_down_and_the_paragraph_applied(h.state());
}
/// Organize pages, opened under the Edit tab, leaves Edit the same way through its own back arrow.
#[test]
fn the_organize_back_arrow_applies_an_open_paragraph_and_puts_edit_text_down() {
use pdfcraft_ui_egui::{LeftPanel, Mode};
let mut h = harness(1, |_| {});
edit_tab_with_an_open_paragraph(&mut h);
// What the Edit panel's Organize pages row runs.
assert!(h.state_mut().execute("page.organize"));
h.run_steps(3);
assert!(h.state().views[0].organize, "the page grid opens");
assert!(h.state().views[0].line_editor.is_some(), "the paragraph is still open behind the grid");
h.get_by_label("Back to all tools").click();
h.run_steps(3);
let s = h.state();
assert_eq!((s.mode, s.left), (Mode::AllTools, LeftPanel::AllTools));
assert!(!s.views[0].organize, "the grid closes");
assert_edit_text_put_down_and_the_paragraph_applied(s);
}
#[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);
}
/// Add text places a box with a click anywhere off its own boxes: on a scanned page (one picture
/// filling it) too, and just beside a paragraph, where Edit text would rewrap it.
#[test]
fn add_text_places_a_box_on_a_picture_and_beside_a_paragraph() {
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);
h.state_mut().quick_tool = pdfcraft_ui_egui::QuickTool::AddText;
h.run_steps(2);
let c = h.state().views[0].page_screen_rect(0).expect("on screen").center();
h.hover_at(c);
h.run_steps(2);
assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::Text, "the I-beam over the picture");
click_at(&mut h, c);
assert!(h.state().views[0].content.draft.is_some(), "a click on the picture starts a text box");
let mut h = harness(2, |_| {});
h.state_mut().quick_tool = pdfcraft_ui_egui::QuickTool::AddText;
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;
// 6 px left of the text: 4 px past its box, within a closed paragraph's edge grip in Edit text.
let beside = egui::pos2(r.left() + block.rect[0] as f32 * k, r.top() + (300.0 - ((block.rect[1] + block.rect[3]) / 2.0) as f32) * k)
- egui::vec2(6.0, 0.0);
click_at(&mut h, beside);
assert!(h.state().views[0].content.draft.is_some(), "a click beside the paragraph starts a text box");
}
/// Add text opens a paragraph with a click anywhere in its box, just inside its edge too, where the
/// edge's grip shows (a drag there rewraps it, as with Edit text). Add text's hits left the grip
/// out there, so the click neither opened the paragraph nor started a box.
#[test]
fn add_text_opens_a_paragraph_from_just_inside_its_edge() {
let mut h = harness(2, |_| {});
h.state_mut().quick_tool = pdfcraft_ui_egui::QuickTool::AddText;
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;
// 2 px right of where the text starts: 4 px into its box, within its left edge's grip.
let inside = egui::pos2(r.left() + block.rect[0] as f32 * k, r.top() + (300.0 - ((block.rect[1] + block.rect[3]) / 2.0) as f32) * k)
+ egui::vec2(2.0, 0.0);
h.hover_at(inside);
h.run_steps(2);
assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::ResizeHorizontal, "the edge's grip");
click_at(&mut h, inside);
assert!(h.state().views[0].line_editor.is_some(), "the click opens the paragraph for typing");
assert!(h.state().views[0].content.draft.is_none(), "and starts no text box");
}
/// With Add text a paragraph dragged off its own box still moves to where the button comes up, as
/// with Edit text. Add text acted on the page's paragraphs only while the pointer was over one, so
/// the drag was left hanging, to land wherever the pointer next met a paragraph.
#[test]
fn add_text_finishes_a_paragraph_drag_that_leaves_its_box() {
let mut h = harness(2, |_| {});
h.state_mut().quick_tool = pdfcraft_ui_egui::QuickTool::AddText;
h.run_steps(2);
let id = h.state().views[0].id;
let before = h.state().session.get(id).unwrap().text_blocks(0)[0].rect;
let r = h.state().views[0].page_screen_rect(0).expect("on screen");
let k = r.width() / 200.0;
let from = egui::pos2(r.left() + ((before[0] + before[2]) / 2.0) as f32 * k, r.top() + (300.0 - ((before[1] + before[3]) / 2.0) as f32) * k);
// The drag starts in the box (8 px down), then goes 60 pt down, onto the blank page under it.
h.hover_at(from);
h.run_steps(1);
h.drag_at(from);
h.run_steps(1);
h.hover_at(from + egui::vec2(0.0, 8.0));
h.run_steps(1);
for i in 1..=4 {
h.hover_at(from + egui::vec2(0.0, 15.0 * k * i as f32));
h.run_steps(1);
}
h.drop_at(from + egui::vec2(0.0, 60.0 * k));
for _ in 0..10 {
h.run_steps(2);
}
assert!(h.state().views[0].block_drag.is_none(), "the drag is over");
let after = h.state().session.get(id).unwrap().text_blocks(0)[0].rect;
assert!(before[1] - after[1] > 50.0, "the paragraph moved down with it: {before:?} to {after:?}");
}
#[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);
assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::Grab, "the knob offers to turn the picture");
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);
}
assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::Grabbing, "and holds it while it turns");
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 dragging_rotate_knob_on_already_rotated_image_applies_delta_only() {
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;
// Center picture
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);
// Rotate the image by 45° clockwise initially
h.state_mut().apply_edit(pdfcraft_engine::Edit::EditPageImage { page: 0, index: 0, change: pdfcraft_engine::ImageEdit::RotateBy(45.0) });
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 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");
let initial_img = &h.state().session.get(id).unwrap().page_images(0)[0];
let initial_matrix = initial_img.matrix;
let xf = h.state().views[0].page_xform(0).expect("page 0 xform");
let doc_info = &h.state().session.get(id).unwrap().info;
let obb = pdfcraft_ui_egui::canvas::ImageObb::from_matrix(initial_matrix, &xf, doc_info, 0);
let knob = obb.s_rot;
let center = obb.screen_center;
let radius = (knob - center).length();
// Hover at the rotated knob position:
h.hover_at(knob);
h.run_steps(1);
assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::Grab, "knob offers Grab cursor on 45° rotated image");
// Rotate clockwise by a small delta of 15° (from 45° to 60°):
let to_angle = (45.0 + 15.0_f32).to_radians();
let to = center + egui::vec2(radius * to_angle.sin(), -radius * to_angle.cos());
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);
}
assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::Grabbing);
h.drop_at(to);
h.run_steps(4);
let doc = h.state().session.get(id).unwrap();
let final_img = &doc.page_images(0)[0];
let angle_deg = (-final_img.matrix[1]).atan2(final_img.matrix[0]).to_degrees();
let angle_deg = (angle_deg % 360.0 + 360.0) % 360.0;
assert!((angle_deg - 60.0).abs() < 2.0, "expected ~60° total rotation, got {angle_deg}°");
}
#[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 image_right_click_context_menu_items_and_actions() {
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);
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 image
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");
// Right-click image
h.hover_at(c);
h.run_steps(1);
h.event(egui::Event::PointerButton { pos: c, button: egui::PointerButton::Secondary, pressed: true, modifiers: Default::default() });
h.event(egui::Event::PointerButton { pos: c, button: egui::PointerButton::Secondary, pressed: false, modifiers: Default::default() });
h.run_steps(2);
assert!(h.state().views[0].image_selection.is_some());
for item in [
"Cut",
"Copy",
"Paste",
"Flip horizontal",
"Flip vertical",
"Arrange image",
"Replace image",
"Draw freehand",
"Redact Image",
"Create Link",
"Recognize Text",
] {
h.get_by_label_contains(item);
}
// Top horizontal icon buttons
h.get_by_label("Rotate Counterclockwise");
h.get_by_label("Rotate Clockwise");
h.get_by_label("Delete");
// Flip horizontal via context menu item
h.get_by_label("Flip horizontal").click();
h.run_steps(3);
let id = h.state().views[0].id;
let doc = h.state().session.get(id).unwrap();
assert_eq!(doc.can_undo(), Some("Flip image"));
// Undo flip
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::Z);
h.run_steps(3);
let doc = h.state().session.get(id).unwrap();
assert_eq!(doc.can_undo(), None);
}
#[test]
fn default_page_context_menu_when_not_in_edit_mode() {
let mut h = harness(1, |_| {});
h.run_steps(4);
assert_eq!(h.state().quick_tool, pdfcraft_ui_egui::QuickTool::Select);
let r = h.state().views[0].page_screen_rect(0).expect("on screen");
let c = r.center();
// Right click on page in Select mode
h.hover_at(c);
h.run_steps(1);
h.event(egui::Event::PointerButton { pos: c, button: egui::PointerButton::Secondary, pressed: true, modifiers: Default::default() });
h.event(egui::Event::PointerButton { pos: c, button: egui::PointerButton::Secondary, pressed: false, modifiers: Default::default() });
h.run_steps(2);
// Verify default page context menu items
h.get_by_label("Rotate view counterclockwise");
h.get_by_label("Print");
h.get_by_label("Select Tool");
h.get_by_label("Hand Tool");
h.get_by_label("Marquee Zoom");
for item in ["Add bookmark", "Take a snapshot", "Edit this PDF", "Delete page", "Export this PDF", "Find text or tools", "Document properties…"]
{
h.get_by_label_contains(item);
}
h.get_by_label("Rotate view clockwise");
assert!(h.query_all_by_label("Add comment").next().is_some());
assert!(h.query_all_by_label("Add text").next().is_some());
assert!(h.query_all_by_label("Add image").next().is_some());
assert!(h.query_all_by_label_contains("Copy").next().is_some());
assert!(h.query_all_by_label_contains("Paste").next().is_some());
// Clicking "Edit this PDF" enters edit mode
h.get_by_label("Edit this PDF").click();
h.run_steps(2);
assert_eq!(h.state().quick_tool, pdfcraft_ui_egui::QuickTool::EditText);
}
#[test]
fn page_context_menu_items_disabled_on_protected_pdf() {
let bytes = protected("", "owner", 0b0100);
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("protected.pdf", None, bytes.clone()).expect("opens");
app
});
h.run_steps(4);
assert_eq!(h.state().quick_tool, pdfcraft_ui_egui::QuickTool::Select);
let doc_id = h.state().views[0].id;
let doc = h.state().session.get(doc_id).unwrap();
assert!(!doc.allows_modification());
assert!(!doc.allows_assembly());
assert!(!doc.allows_annotation());
let r = h.state().views[0].page_screen_rect(0).expect("on screen");
let c = r.center();
// Right click on page in Select mode
h.hover_at(c);
h.run_steps(1);
h.event(egui::Event::PointerButton { pos: c, button: egui::PointerButton::Secondary, pressed: true, modifiers: Default::default() });
h.event(egui::Event::PointerButton { pos: c, button: egui::PointerButton::Secondary, pressed: false, modifiers: Default::default() });
h.run_steps(2);
let is_disabled =
|h: &mut Harness<'static, PdfCraftApp>, label: &str| h.query_by(|n| n.label().as_deref() == Some(label) && n.is_disabled()).is_some();
// Context menu is open; check restricted items are greyed out (disabled)
assert!(is_disabled(&mut h, "Edit this PDF"), "Edit this PDF should be disabled on protected document");
assert!(is_disabled(&mut h, "Add text"), "Add text should be disabled");
assert!(is_disabled(&mut h, "Add image"), "Add image should be disabled");
assert!(is_disabled(&mut h, "Delete page"), "Delete page should be disabled");
assert!(is_disabled(&mut h, "Add comment"), "Add comment should be disabled");
assert!(is_disabled(&mut h, "Add bookmark"), "Add bookmark should be disabled");
// View-only items should still be enabled
assert!(!is_disabled(&mut h, "Select Tool"));
assert!(!is_disabled(&mut h, "Hand Tool"));
assert!(!is_disabled(&mut h, "Find text or tools"));
}
#[test]
fn image_edge_handle_stretch_and_crop_mode() {
let mut png = Vec::new();
image::RgbImage::from_pixel(100, 100, image::Rgb([100, 150, 200]))
.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("square.png", None, png.clone()).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 c = r.center();
// Select image
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());
// Enter crop mode via context menu
h.event(egui::Event::PointerButton { pos: c, button: egui::PointerButton::Secondary, pressed: true, modifiers: Default::default() });
h.event(egui::Event::PointerButton { pos: c, button: egui::PointerButton::Secondary, pressed: false, modifiers: Default::default() });
h.run_steps(2);
h.get_by_label("Crop").click();
h.run_steps(2);
assert!(h.state().views[0].image_selection.as_ref().is_some_and(|s| s.crop.is_some()));
// Set smaller crop rect
let smaller = egui::Rect::from_center_size(c, egui::vec2(50.0, 50.0));
set_crop_box(&mut h, smaller);
// Confirm crop with Enter key
h.key_press(egui::Key::Enter);
h.run_steps(4);
assert!(!h.state().views[0].image_selection.as_ref().is_some_and(|s| s.crop.is_some()));
let doc_id = h.state().views[0].id;
let images = h.state().session.get(doc_id).unwrap().page_images(0);
assert_eq!(images.len(), 1, "image still exists: {images:?}");
assert!(images[0].visible_rect.is_some(), "cropped image is visible");
assert_ne!(images[0].visible_rect, Some(images[0].rect), "image is cropped");
// Remove crop via context menu
h.hover_at(c);
h.run_steps(1);
h.event(egui::Event::PointerButton { pos: c, button: egui::PointerButton::Secondary, pressed: true, modifiers: Default::default() });
h.event(egui::Event::PointerButton { pos: c, button: egui::PointerButton::Secondary, pressed: false, modifiers: Default::default() });
h.run_steps(2);
h.get_by_label("Remove crop").click();
h.run_steps(4);
let images = h.state().session.get(doc_id).unwrap().page_images(0);
assert_eq!(images[0].visible_rect, Some(images[0].rect), "crop is removed");
}
#[test]
fn tiny_image_crop_handle_drag_does_not_panic() {
let mut png = Vec::new();
image::RgbImage::from_pixel(5, 5, image::Rgb([100, 150, 200])).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
let mut h = Harness::builder().with_size(egui::vec2(800.0, 600.0)).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("tiny.png", None, png.clone()).expect("opens");
app
});
h.run_steps(4);
assert!(h.state_mut().execute("edit.edit_text"));
h.run_steps(2);
// Enter crop mode on image 0 with small dimensions (5.0 x 5.0)
h.state_mut().views[0].image_selection = Some(pdfcraft_ui_egui::ImageSelection {
page: 0,
index: 0,
drag: None,
crop: Some(pdfcraft_ui_egui::CropMode::new([100.0, 100.0, 105.0, 105.0])),
turning: false,
settled: None,
});
h.run_steps(1);
// Hover and drag a handle across a 5.0 x 5.0 image to ensure clamp bounds do not panic
h.hover_at(egui::pos2(100.0, 100.0));
h.run_steps(1);
h.drag_at(egui::pos2(100.0, 100.0));
h.run_steps(1);
h.hover_at(egui::pos2(105.0, 105.0));
h.run_steps(2);
h.drop_at(egui::pos2(105.0, 105.0));
h.run_steps(2);
}
#[test]
fn text_selection_action_icons() {
let mut h = harness(1, |_| {});
// Dragging selects only once the page's text layer is in, and it comes from the render pool
// in the background: under load a fixed number of frames was sometimes not enough.
support::wait_until(&mut h, "page 1's text layer", |app| app.views[0].has_page_text(0));
let r = h.state().views[0].page_screen_rect(0).expect("on screen");
let p0 = egui::pos2(r.left() + 40.0 / 200.0 * r.width(), r.top() + (300.0 - 158.0) / 300.0 * r.height());
let p1 = egui::pos2(r.left() + 100.0 / 200.0 * r.width(), r.top() + (300.0 - 158.0) / 300.0 * r.height());
// Drag to select text
h.hover_at(p0);
h.run_steps(1);
h.drag_at(p0);
h.run_steps(1);
h.hover_at(p1);
h.run_steps(1);
h.drop_at(p1);
h.run_steps(2);
assert!(h.state().views[0].selected_text().is_some());
// Verify all 6 minimalist action icons are present in the floating menu
assert!(h.query_all_by_label("Add comment").next().is_some());
assert!(h.query_all_by_label("Highlight").next().is_some());
assert!(h.query_all_by_label("Underline").next().is_some());
assert!(h.query_all_by_label("Strikethrough").next().is_some());
assert!(h.query_all_by_label("Replace text").next().is_some());
assert!(h.query_all_by_label("Copy").next().is_some());
}
#[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));
}
#[test]
fn the_format_panel_toggles_superscript_and_subscript_on_selection() {
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());
// In line editor, select "1" in "Page 1":
let ed = h.state_mut().views[0].line_editor.as_mut().unwrap();
ed.selection = Some(5..6);
ed.focus = true;
h.run_steps(1);
// Click Superscript: "1" becomes "¹"
h.get_by_label("FORMAT TEXT");
let btn = h.get_by_label("Superscript");
btn.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].text, "Page ¹");
// Select "¹" in "Page ¹" to toggle it back:
let ed = h.state_mut().views[0].line_editor.as_mut().unwrap();
ed.selection = Some(5..7);
ed.focus = true;
h.run_steps(1);
// Click Superscript again: "¹" toggles back to "1"
h.get_by_label("Superscript").click();
h.run_steps(4);
let s2 = h.state();
let doc2 = s2.session.get(s2.views[0].id).unwrap();
assert_eq!(doc2.text_blocks(0)[0].text, "Page 1");
// Subscript toggle in added text:
h.state_mut().views[0].line_editor = None;
h.state_mut().quick_tool = pdfcraft_ui_egui::QuickTool::AddText;
let draft = pdfcraft_ui_egui::TextDraft {
page: 0,
index: None,
rect: [20.0, 20.0, 120.0, 40.0],
text: "H2O".into(),
style: Default::default(),
focus: false,
selection: Some(1..2),
typing_from: None,
};
h.state_mut().views[0].content.draft = Some(draft);
h.run_steps(2);
h.get_by_label("Subscript").click();
h.run_steps(2);
assert_eq!(h.state().views[0].content.draft.as_ref().map(|d| d.text.as_str()), Some("H₂O"));
h.get_by_label("Subscript").click();
h.run_steps(2);
assert_eq!(h.state().views[0].content.draft.as_ref().map(|d| d.text.as_str()), Some("H2O"));
}
/// 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 typed_small_coloured_text_size_survives_save_and_reopen() {
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(3);
assert!(h.state().views[0].line_editor.is_some());
h.get_by_label("Font size:").click();
h.run_steps(1);
h.key_press_modifiers(Modifiers::COMMAND, Key::A);
h.event(egui::Event::Text("6.5".into()));
h.key_press(Key::Enter);
h.run_steps(4);
h.get_by_label("Colour").click();
h.run_steps(2);
h.get_by_label("Blue").click();
h.run_steps(4);
let id = h.state().views[0].id;
let lines = h.state().session.get(id).unwrap().text_lines(0);
assert!(!lines.is_empty());
for line in &lines {
assert!((line.size - 6.5).abs() < 0.01, "{line:?}");
assert!(line.color[2] > line.color[0], "the small text stays blue: {line:?}");
}
let saved = h.state().session.save_bytes(id).unwrap();
let mut reopened = pdfcraft_engine::Session::new();
let rid = reopened.open("small-blue.pdf", None, saved, None).unwrap();
assert_eq!(reopened.get(rid).unwrap().text_lines(0), lines);
}
#[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);
}
/// Sets the open crop's box on page 0 to the screen rectangle `r`, as dragging its handles would.
fn set_crop_box(h: &mut Harness<'static, PdfCraftApp>, r: egui::Rect) {
let view = &h.state().views[0];
let page = &h.state().session.get(view.id).unwrap().info.pages[0];
let xf = pdfcraft_ui_egui::canvas::PageXform { rect: view.page_screen_rect(0).unwrap(), rot: view.rotation, pw: page.width, ph: page.height };
let (a, b) = (xf.screen_to_view(r.min), xf.screen_to_view(r.max));
let (u, v) = (page.view_to_user(a.0, a.1), page.view_to_user(b.0, b.1));
let keep = [u[0].min(v[0]), u[1].min(v[1]), u[0].max(v[0]), u[1].max(v[1])].map(f64::from);
h.state_mut().views[0].image_selection.as_mut().unwrap().crop.as_mut().unwrap().keep = keep;
}
fn press(h: &mut Harness<'static, PdfCraftApp>, modifiers: Modifiers, key: Key) {
h.key_press_modifiers(modifiers, key);
h.run_steps(4);
}
/// A two-page document whose first page draws one picture (a Form XObject), open in Edit text
/// & images with the picture selected.
fn selected_figure() -> Harness<'static, PdfCraftApp> {
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 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");
h
}
#[test]
fn arrow_keys_nudge_a_selected_image_instead_of_turning_the_page() {
let mut h = selected_figure();
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);
// 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");
}
/// #3632: with a picture selected, Alt+← / Alt+→ still go back and forward through the view
/// history. They don't nudge it (the nudge patterns also match a press with Alt), so nothing moves
/// and the document isn't edited.
#[test]
fn alt_arrows_navigate_instead_of_nudging_a_selected_image() {
let mut h = selected_figure();
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);
// A followed link's jump to page 2 and back, as `go_to_dest` makes it.
h.state_mut().views[0].go_to_page(1);
h.run_steps(2);
h.state_mut().views[0].go_to_page(0);
h.run_steps(2);
assert!(h.state().views[0].image_selection.is_some(), "still selected, so a nudge could take the keys");
press(&mut h, Modifiers::ALT, Key::ArrowLeft);
assert_eq!(h.state().views[0].current, 1, "Alt+← went back");
press(&mut h, Modifiers::ALT, Key::ArrowRight);
assert_eq!(h.state().views[0].current, 0, "Alt+→ went forward");
// Each press is judged by its own modifiers, not the frame's: Alt let go in the same frame
// as Alt+← leaves the frame with none, and the press still goes back.
let arrow = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers };
h.event(arrow(Key::ArrowLeft, Modifiers::ALT));
h.run_steps(4);
assert_eq!(h.state().views[0].current, 1, "an Alt+← press went back");
assert_eq!(rect(&h), before, "the picture didn't move");
let doc = h.state().session.get(id).unwrap();
assert!(!doc.dirty && doc.can_undo().is_none(), "nothing was edited");
// And a plain → pressed before Alt goes down in the same frame still nudges.
h.state_mut().views[0].go_to_page(0);
h.run_steps(2);
h.event(arrow(Key::ArrowRight, Modifiers::NONE));
h.event(egui::Event::ModifiersChanged(Modifiers::ALT));
h.run_steps(4);
h.event(egui::Event::ModifiersChanged(Modifiers::NONE));
h.run_steps(2);
let moved = rect(&h);
assert!((moved[0] - before[0] - 1.0).abs() < 0.01, "a plain → nudged a point: {before:?} → {moved:?}");
}
#[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");
}
/// The open paragraph's frame shows the Move cursor as far out as a press there takes hold of the
/// box: the cursor reached 4 px past the frame, the press 6.
#[test]
fn an_open_paragraphs_frame_shows_move_wherever_a_press_takes_the_box() {
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 before = 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);
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");
// 7 px above the text: 5 px past the frame drawn 2 px out.
let far = screen((before.rect[0] + before.rect[2]) / 2.0, before.rect[3]) - egui::vec2(0.0, 7.0);
h.hover_at(far);
h.run_steps(3);
assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::Move, "the frame offers to move the box there");
click_at(&mut h, far);
assert!(h.state().views[0].line_editor.is_none(), "and a click there selects the box");
}
/// The open paragraph's frame takes hold of the box on its sides too: there the edges of a closed
/// paragraph would rewrap it, but around the open one lies its frame.
#[test]
fn an_open_paragraphs_frame_takes_the_box_beside_its_text() {
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 before = 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 middle = (before.rect[1] + before.rect[3]) / 2.0;
click_at(&mut h, screen((before.rect[0] + before.rect[2]) / 2.0, middle));
assert!(h.state().views[0].line_editor.is_some(), "a click on the text opens it for typing");
// 6 px left of the text: 4 px past the frame drawn 2 px out.
let beside = screen(before.rect[0], middle) - egui::vec2(6.0, 0.0);
h.hover_at(beside);
h.run_steps(3);
assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::Move, "the frame offers to move the box there");
click_at(&mut h, beside);
assert!(h.state().views[0].line_editor.is_none(), "and a click there selects the box");
}
/// 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."));
}
/// A delete the engine refuses (text used as a clipping path) leaves the document as it was and
/// keeps the selection, so the user can still move or nudge what they had selected.
#[test]
fn a_refused_delete_keeps_the_object_selection() {
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 id = h.state().views[0].id;
let (generation, bytes) = {
let doc = h.state().session.get(id).unwrap();
(doc.edit_generation(), doc.bytes.clone())
};
// An area selection from empty page space around the paragraph.
let (center, _) = paragraph_drag_points(&h, "Clipping text", [0.0, 0.0]);
drag(&mut h, center - egui::vec2(60.0, 25.0), center + egui::vec2(60.0, 25.0));
h.get_by_label("1 object selected");
h.key_press(Key::Delete);
h.run_steps(4);
let doc = h.state().session.get(id).unwrap();
assert_eq!((doc.edit_generation(), &doc.bytes), (generation, &bytes), "the refused delete changed nothing");
assert!(doc.can_undo().is_none());
assert!(h.state().toast.is_some(), "the refusal is reported");
h.get_by_label("1 object selected");
}
#[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");
}
/// Organize pages ▸ Page properties ▸ Tab Order sets the tab order of the selected pages only,
/// as one undo step, and opens on the order the pages have.
#[test]
fn page_properties_sets_the_tab_order_of_the_selected_pages() {
use pdfcraft_engine::TabOrder::{Annotations, Column, Row};
let tabs = |h: &Harness<'static, PdfCraftApp>| h.state().session.get(h.state().views[0].id).unwrap().tab_orders();
// The undo and redo labels: a new step replaces the redo step an undo left.
let undo = |h: &Harness<'static, PdfCraftApp>| {
let doc = h.state().session.get(h.state().views[0].id).unwrap();
(doc.can_undo().map(str::to_owned), doc.can_redo().map(str::to_owned))
};
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("Page properties").click();
h.run_steps(3);
assert_eq!(h.state().dialog, Some(Dialog::PageProps));
assert_eq!(h.state().page_props_draft.as_ref().unwrap().tab_order, Some(Annotations), "opens on Unspecified, as the pages have it");
h.get_by_label("Use Column Order").click();
h.run_steps(2);
h.get_by_label("OK").click();
h.run_steps(3);
assert_eq!(h.state().dialog, None);
assert_eq!(tabs(&h), [Column, Annotations, Column]);
assert_eq!(undo(&h).0.as_deref(), Some("Set tab order"));
h.key_press_modifiers(Modifiers::COMMAND, Key::Z);
h.run_steps(3);
assert_eq!(tabs(&h), [Annotations; 3], "one undo step");
// Pages with different orders open with no choice made, and OK is off until one is.
h.state_mut().apply_edit(pdfcraft_engine::Edit::SetTabOrder { pages: vec![0], order: Row });
h.run_steps(2);
h.state_mut().views[0].select_pages(&[0, 1]);
assert!(h.state_mut().execute("page.properties"));
h.run_steps(3);
assert_eq!(h.state().page_props_draft.as_ref().unwrap().tab_order, None);
assert!(h.get_by_label("OK").accesskit_node().is_disabled());
h.get_by_label("Use Row Order").click();
h.run_steps(2);
h.get_by_label("OK").click();
h.run_steps(3);
assert_eq!(tabs(&h), [Row, Row, Annotations]);
// OK on the order the pages already have adds no undo step, and Cancel changes nothing.
h.key_press_modifiers(Modifiers::COMMAND, Key::Z);
h.run_steps(3);
let before = (tabs(&h), undo(&h));
assert_eq!(before.1.1.as_deref(), Some("Set tab order"), "the undone step can be redone");
h.state_mut().views[0].select_pages(&[2]);
assert!(h.state_mut().execute("page.properties"));
h.run_steps(3);
h.get_by_label("OK").click();
h.run_steps(3);
assert_eq!((tabs(&h), undo(&h)), before);
assert!(h.state_mut().execute("page.properties"));
h.run_steps(3);
h.get_by_label("Use Document Structure").click();
h.run_steps(2);
h.get_by_label("Cancel").click();
h.run_steps(3);
assert_eq!(h.state().dialog, None);
assert_eq!((tabs(&h), undo(&h)), before);
}
/// Page properties sets the tab order of the document it was opened on: when another document
/// is active by OK, neither document changes.
#[test]
fn page_properties_does_not_apply_to_another_active_document() {
use pdfcraft_engine::TabOrder::Annotations;
let mut h = organize(3);
let first = h.state().views[0].id;
h.state_mut().views[0].select_pages(&[0]);
assert!(h.state_mut().execute("page.properties"));
h.run_steps(3);
h.get_by_label("Use Column Order").click();
h.run_steps(2);
h.state_mut().open_bytes("other.pdf", None, fixture(3)).unwrap();
h.run_steps(4);
let other = h.state().views[1].id;
assert_eq!(h.state().active_ids().map(|(_, id)| id), Some(other));
h.get_by_label("OK").click();
h.run_steps(3);
assert_eq!(h.state().dialog, None);
for id in [first, other] {
let doc = h.state().session.get(id).unwrap();
assert_eq!(doc.tab_orders(), [Annotations; 3]);
assert!(doc.can_undo().is_none());
}
}
/// Page properties sets the tab order of the pages it was opened on: when an edit changes the
/// document while the dialog is open, OK closes it without changing a page.
#[test]
fn page_properties_does_not_apply_after_the_document_changes() {
use pdfcraft_engine::TabOrder::Annotations;
let mut h = organize(3);
let id = h.state().views[0].id;
h.state_mut().views[0].select_pages(&[0]);
assert!(h.state_mut().execute("page.properties"));
h.run_steps(3);
h.get_by_label("Use Column Order").click();
h.run_steps(2);
// Page 1 becomes page 3, so index 0 is another page now.
assert!(h.state_mut().execute("page.reverse"));
h.run_steps(3);
h.get_by_label("OK").click();
h.run_steps(3);
assert_eq!(h.state().dialog, None);
let doc = h.state().session.get(id).unwrap();
assert_eq!(doc.tab_orders(), [Annotations; 3]);
assert_ne!(doc.can_undo(), Some("Set tab order"));
assert_eq!(
h.state().toast.as_ref().map(|t| t.0.as_str()),
Some("The document changed while the dialog was open, so nothing was changed. Open Page properties again to set the tab order.")
);
}
/// 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);
}
#[test]
fn image_copy_cut_paste_keyboard_and_context_menu() {
let mut png = Vec::new();
image::RgbImage::from_pixel(60, 40, image::Rgb([180, 50, 50])).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.system_clipboard = false;
app.open_bytes("picture.png", None, png.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 initial_count = h.state().session.get(id).unwrap().page_images(0).len();
assert_eq!(initial_count, 1, "starts with 1 image");
let r = h.state().views[0].page_screen_rect(0).expect("on screen");
let c = r.center();
// 1. Select the image
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(), "image selected");
// 2. Copy via keyboard shortcut (COMMAND+C / Event::Copy)
h.event(egui::Event::Copy);
h.run_steps(2);
assert!(h.state().last_copied_image.is_some(), "image copied to clipboard buffer");
// 3. The integration delivers the copied pixels for the keyboard paste.
let (width, height, rgba) = h.state().last_copied_image.clone().unwrap();
let image = egui::ColorImage::from_rgba_unmultiplied([width as usize, height as usize], &rgba);
h.event(egui::Event::PasteImage(std::sync::Arc::new(image)));
h.run_steps(3);
let after_paste = h.state().session.get(id).unwrap().page_images(0).len();
assert_eq!(after_paste, 2, "now has 2 images");
assert_eq!(h.state().views[0].image_selection.as_ref().map(|s| s.index), Some(1), "new image is selected");
// 4. Cut via keyboard shortcut (COMMAND+X / Event::Cut)
h.event(egui::Event::Cut);
h.run_steps(3);
let after_cut = h.state().session.get(id).unwrap().page_images(0).len();
assert_eq!(after_cut, 1, "cut deleted the pasted image");
assert!(h.state().views[0].image_selection.is_none(), "selection cleared after cut");
// 5. Paste again via context menu on the page
h.hover_at(c);
h.run_steps(1);
h.event(egui::Event::PointerButton { pos: c, button: egui::PointerButton::Secondary, pressed: true, modifiers: Default::default() });
h.event(egui::Event::PointerButton { pos: c, button: egui::PointerButton::Secondary, pressed: false, modifiers: Default::default() });
h.run_steps(2);
h.get_by_label_contains("Paste").click();
h.run_steps(3);
let after_paste2 = h.state().session.get(id).unwrap().page_images(0).len();
assert_eq!(after_paste2, 2, "pasted via context menu");
}
#[test]
fn added_and_pasted_image_uses_refactored_image_edits() {
let mut png = Vec::new();
image::RgbImage::from_pixel(80, 80, image::Rgb([120, 180, 240])).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
let mut h = harness(1, |_| {});
h.run_steps(4);
// 1. Add image via add_image
h.state_mut().add_image("test_add.png".into(), png);
h.run_steps(3);
// Verify refactored mode is active
assert_eq!(h.state().quick_tool, pdfcraft_ui_egui::QuickTool::EditText);
let id = h.state().views[0].id;
let images = h.state().session.get(id).unwrap().page_images(0);
assert_eq!(images.len(), 1, "image is added");
assert_eq!(h.state().views[0].image_selection.as_ref().map(|s| (s.page, s.index)), Some((0, 0)), "added image has refactored image_selection");
// 2. Perform refactored crop on the added image
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.event(egui::Event::PointerButton { pos: c, button: egui::PointerButton::Secondary, pressed: true, modifiers: Default::default() });
h.event(egui::Event::PointerButton { pos: c, button: egui::PointerButton::Secondary, pressed: false, modifiers: Default::default() });
h.run_steps(2);
h.get_by_label("Crop").click();
h.run_steps(2);
assert!(h.state().views[0].image_selection.as_ref().is_some_and(|s| s.crop.is_some()), "crop mode entered");
let smaller = egui::Rect::from_center_size(c, egui::vec2(40.0, 40.0));
set_crop_box(&mut h, smaller);
h.key_press(egui::Key::Enter);
h.run_steps(4);
assert!(!h.state().views[0].image_selection.as_ref().is_some_and(|s| s.crop.is_some()), "crop mode exited");
let images_after_crop = h.state().session.get(id).unwrap().page_images(0);
assert!(images_after_crop[0].visible_rect.is_some());
assert_ne!(images_after_crop[0].visible_rect, Some(images_after_crop[0].rect), "crop applied to added image");
// 3. Move the cropped added image
let orig_rect = images_after_crop[0].rect;
let orig_vis = images_after_crop[0].visible_rect.unwrap();
let dx = 20.0;
let dy = 20.0;
let new_rect = [orig_rect[0] + dx, orig_rect[1] + dy, orig_rect[2] + dx, orig_rect[3] + dy];
h.state_mut().apply_edit(pdfcraft_engine::Edit::EditPageImage { page: 0, index: 0, change: pdfcraft_engine::ImageEdit::Move(new_rect) });
h.run_steps(3);
let images_after_move = h.state().session.get(id).unwrap().page_images(0);
assert_eq!(images_after_move[0].rect, new_rect, "moved rect matches");
assert_eq!(
images_after_move[0].visible_rect,
Some([orig_vis[0] + dx, orig_vis[1] + dy, orig_vis[2] + dx, orig_vis[3] + dy]),
"crop preserved after move"
);
}
#[test]
fn stale_clipboard_plain_text_does_not_paste_image() {
let mut h = harness(1, |_| {});
h.run_steps(4);
// Put an image into internal cache
h.state_mut().last_copied_image = Some((10, 10, vec![0; 400]));
h.state_mut().last_copied_file = Some(("cached.png".to_string(), vec![1, 2, 3]));
// The text arrives in the paste event, as it does from the OS. The real system clipboard is
// shared by every test thread, so this test doesn't write it.
h.state_mut().system_clipboard = false;
// A paste event containing plain text clears internal image cache and does not paste image
h.event(egui::Event::Paste("just plain text".into()));
h.run_steps(2);
// can_paste_image should now be false because the clipboard has text, not an image!
assert!(!h.state().can_paste_image(), "cannot paste image when the clipboard has text");
// paste_image should fail and return false without pasting
assert!(!h.state_mut().paste_image(0, None), "must not paste stale cached image");
let doc = h.state().session.get(h.state().views[0].id).unwrap();
assert_eq!(doc.page_images(0).len(), 0, "no image pasted on the page");
}
/// Delivered image pixels paste once; accompanying key releases add no second image.
#[test]
fn delivered_image_with_key_releases_pastes_once() {
let pdf = include_bytes!("../data/form.pdf").to_vec();
let mut h = Harness::builder().with_step_dt(1.0 / 60.0).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("form.pdf", None, pdf.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 initial_count = h.state().session.get(id).unwrap().page_images(0).len();
h.state_mut().clipboard_override = Some(pdfcraft_ui_egui::Clip::Image { width: 40, height: 20, rgba: [100u8, 200, 100, 255].repeat(40 * 20) });
// The integration supplies the pixels and consumes native shortcut keys. Extra releases carry no paste authority.
h.event(egui::Event::PasteImage(std::sync::Arc::new(egui::ColorImage::filled([40, 20], egui::Color32::GREEN))));
h.event(egui::Event::Key { key: egui::Key::V, physical_key: None, pressed: false, repeat: false, modifiers: egui::Modifiers::COMMAND });
h.run_steps(3);
let count = h.state().session.get(id).unwrap().page_images(0).len();
assert_eq!(count, initial_count + 1, "exactly one image must be pasted for delivered pixels, got {count}");
h.event(egui::Event::Key { key: egui::Key::V, physical_key: None, pressed: false, repeat: false, modifiers: egui::Modifiers::NONE });
h.run_steps(2);
assert_eq!(h.state().session.get(id).unwrap().page_images(0).len(), initial_count + 1);
}
/// Win+V release without COMMAND must NOT paste an image.
#[test]
fn win_v_does_not_paste_on_open() {
let pdf = include_bytes!("../data/form.pdf").to_vec();
let mut h = Harness::builder().with_step_dt(1.0 / 60.0).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("form.pdf", None, pdf.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 initial_count = h.state().session.get(id).unwrap().page_images(0).len();
h.state_mut().clipboard_override = Some(pdfcraft_ui_egui::Clip::Image { width: 40, height: 20, rgba: [100u8, 200, 100, 255].repeat(40 * 20) });
// Complete an intentional paste immediately before opening clipboard history.
h.event(egui::Event::PasteImage(std::sync::Arc::new(egui::ColorImage::filled([40, 20], egui::Color32::GREEN))));
h.event(egui::Event::Key { key: egui::Key::V, physical_key: None, pressed: false, repeat: false, modifiers: egui::Modifiers::COMMAND });
h.run_steps(1);
assert_eq!(h.state().session.get(id).unwrap().page_images(0).len(), initial_count + 1);
// Simulate pressing Win+V (opening clipboard history flyout):
// The OS intercepts V press, so PdfCraft only sees V release without COMMAND (Modifiers::NONE).
h.event(egui::Event::Key { key: egui::Key::V, physical_key: None, pressed: false, repeat: false, modifiers: egui::Modifiers::NONE });
h.run_steps(2);
let count = h.state().session.get(id).unwrap().page_images(0).len();
assert_eq!(count, initial_count + 1, "opening Win+V after a completed paste must not paste again");
// Choosing an item delivers another image, possibly alongside synthetic shortcut events.
h.event(egui::Event::PasteImage(std::sync::Arc::new(egui::ColorImage::filled([40, 20], egui::Color32::GREEN))));
h.event(egui::Event::Key { key: egui::Key::V, physical_key: None, pressed: false, repeat: false, modifiers: egui::Modifiers::COMMAND });
h.run_steps(3);
let final_count = h.state().session.get(id).unwrap().page_images(0).len();
assert_eq!(final_count, initial_count + 2, "the two intentional deliveries must paste exactly two images");
}
/// Normal Ctrl+V press followed by release delivers exactly one image, not two.
#[test]
fn paste_press_and_release_delivers_single_image() {
let pdf = include_bytes!("../data/form.pdf").to_vec();
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.system_clipboard = false;
app.open_bytes("form.pdf", None, pdf.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 initial_count = h.state().session.get(id).unwrap().page_images(0).len();
h.state_mut().clipboard_override = Some(pdfcraft_ui_egui::Clip::Image { width: 40, height: 20, rgba: [100u8, 200, 100, 255].repeat(40 * 20) });
// Press V with COMMAND
h.event(egui::Event::Key { key: egui::Key::V, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::COMMAND });
h.run_steps(1);
// Release V with COMMAND
h.event(egui::Event::Key { key: egui::Key::V, physical_key: None, pressed: false, repeat: false, modifiers: egui::Modifiers::COMMAND });
h.run_steps(2);
let count = h.state().session.get(id).unwrap().page_images(0).len();
assert_eq!(count, initial_count + 1, "Ctrl+V press and release must deliver exactly one image, got {count}");
}
/// Issue P1: Paste via context menu at a point away from the centre centers the image at that point.
#[test]
fn paste_at_context_menu_point_centers_on_cursor() {
let pdf = include_bytes!("../data/form.pdf").to_vec();
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.system_clipboard = false;
app.open_bytes("form.pdf", None, pdf.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 initial_count = h.state().session.get(id).unwrap().page_images(0).len();
h.state_mut().clipboard_override = Some(pdfcraft_ui_egui::Clip::Image { width: 60, height: 40, rgba: [50u8, 150, 250, 255].repeat(60 * 40) });
let target_at = [120.0, 180.0];
assert!(h.state_mut().paste_image_at(0, target_at));
let images = h.state().session.get(id).unwrap().page_images(0);
assert_eq!(images.len(), initial_count + 1);
let r = images[initial_count].rect;
let center = ((r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0);
assert!((center.0 - target_at[0]).abs() < 1.0 && (center.1 - target_at[1]).abs() < 1.0, "expected center {target_at:?}, got {center:?}");
}
/// Issue P2: A newer clipboard override image beats stale in-app copy.
#[test]
fn newer_clipboard_override_beats_stale_in_app_copy() {
let pdf = include_bytes!("../data/form.pdf").to_vec();
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.system_clipboard = false;
app.open_bytes("form.pdf", None, pdf.clone()).expect("opens");
app
});
h.run_steps(4);
let id = h.state().views[0].id;
let initial_count = h.state().session.get(id).unwrap().page_images(0).len();
// Stale in-app copy: 10x10 red image
h.state_mut().last_copied_image = Some((10, 10, [255u8, 0, 0, 255].repeat(100)));
// Newer clipboard override: 50x50 green image
h.state_mut().clipboard_override = Some(pdfcraft_ui_egui::Clip::Image { width: 50, height: 50, rgba: [0u8, 255, 0, 255].repeat(2500) });
assert!(h.state_mut().paste_image(0, None));
let images = h.state().session.get(id).unwrap().page_images(0);
assert_eq!(images.len(), initial_count + 1);
let r = images[initial_count].rect;
let (w, h_dim) = (r[2] - r[0], r[3] - r[1]);
assert!((w - 50.0).abs() < 1.0 && (h_dim - 50.0).abs() < 1.0, "expected 50x50 override image, got {w}x{h_dim}");
}
#[test]
fn rotated_image_selection_and_crop() {
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);
assert!(h.state_mut().execute("edit.edit_text"));
h.run_steps(2);
let id = h.state().views[0].id;
let r = h.state().views[0].page_screen_rect(0).expect("on screen");
let c = r.center();
// Select the image
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");
// Right-click and rotate clockwise
h.event(egui::Event::PointerButton { pos: c, button: egui::PointerButton::Secondary, pressed: true, modifiers: Default::default() });
h.event(egui::Event::PointerButton { pos: c, button: egui::PointerButton::Secondary, pressed: false, modifiers: Default::default() });
h.run_steps(2);
h.get_by_label("Rotate Clockwise").click();
h.run_steps(3);
let doc = h.state().session.get(id).unwrap();
let im_rotated = &doc.page_images(0)[0];
assert!(im_rotated.matrix[1].abs() > 1.0 || im_rotated.matrix[2].abs() > 1.0, "matrix is rotated: {:?}", im_rotated.matrix);
// Enter crop mode with user-space bounds [0.2..0.8] of rotated image rect
let full = im_rotated.rect;
let crop_keep = [
full[0] + (full[2] - full[0]) * 0.2,
full[1] + (full[3] - full[1]) * 0.2,
full[0] + (full[2] - full[0]) * 0.8,
full[1] + (full[3] - full[1]) * 0.8,
];
h.state_mut().views[0].image_selection.as_mut().unwrap().crop = Some(pdfcraft_ui_egui::CropMode::new(crop_keep));
h.run_steps(2);
// Apply crop via Enter key
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_cropped = &doc.page_images(0)[0];
assert!(im_cropped.matrix[1].abs() > 1.0 || im_cropped.matrix[2].abs() > 1.0, "matrix remains rotated: {:?}", im_cropped.matrix);
assert!(h.state().views[0].image_selection.as_ref().is_some_and(|s| s.crop.is_none()), "crop mode ended");
}
#[test]
fn test_image_8_handles_render_and_drag() {
let mut png = Vec::new();
image::RgbImage::from_pixel(100, 100, image::Rgb([100, 150, 200]))
.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("square.png", None, png.clone()).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 c = r.center();
// Select the image
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 view = &h.state().views[0];
let sel = view.image_selection.as_ref().unwrap();
let doc = h.state().session.get(view.id).unwrap();
let img = &doc.page_images(sel.page)[sel.index];
let xf =
pdfcraft_ui_egui::canvas::PageXform { rect: r, rot: view.rotation, pw: doc.info.pages[sel.page].width, ph: doc.info.pages[sel.page].height };
let obb = pdfcraft_ui_egui::canvas::ImageObb::from_image(img, &xf, &doc.info, sel.page);
// Assert that exactly 8 unique resize handles (4 corners, 4 midpoints) are generated
let handles = obb.handles();
assert_eq!(handles.len(), 8, "exactly 8 resize handles generated");
let corners = [
pdfcraft_ui_egui::ImageHandle::TopLeft,
pdfcraft_ui_egui::ImageHandle::TopRight,
pdfcraft_ui_egui::ImageHandle::BottomRight,
pdfcraft_ui_egui::ImageHandle::BottomLeft,
];
let midpoints = [
pdfcraft_ui_egui::ImageHandle::TopCenter,
pdfcraft_ui_egui::ImageHandle::RightCenter,
pdfcraft_ui_egui::ImageHandle::BottomCenter,
pdfcraft_ui_egui::ImageHandle::LeftCenter,
];
for corner in &corners {
assert!(handles.iter().any(|(h, _)| h == corner), "missing corner handle: {:?}", corner);
}
for midpoint in &midpoints {
assert!(handles.iter().any(|(h, _)| h == midpoint), "missing midpoint handle: {:?}", midpoint);
}
for i in 0..8 {
for j in (i + 1)..8 {
assert!((handles[i].1 - handles[j].1).length() > 1.0, "handles {} and {} must be unique positions", i, j);
}
}
// Simulate a pointer drag on a corner that shrinks the image to 5x5 pixels and assert it does not panic
let tr_pos = obb.handle_pos(pdfcraft_ui_egui::ImageHandle::TopRight);
let anchor = obb.s00;
let shrink_target = anchor + egui::vec2(5.0, 5.0);
h.hover_at(tr_pos);
h.run_steps(1);
h.drag_at(tr_pos);
h.run_steps(1);
h.hover_at(shrink_target);
h.run_steps(2);
h.drop_at(shrink_target);
h.run_steps(4);
}
#[test]
fn test_image_context_menu_precedence() {
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);
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 image
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");
// Simulate a secondary click strictly inside an image's bounds
h.hover_at(c);
h.run_steps(1);
h.event(egui::Event::PointerButton { pos: c, button: egui::PointerButton::Secondary, pressed: true, modifiers: Default::default() });
h.event(egui::Event::PointerButton { pos: c, button: egui::PointerButton::Secondary, pressed: false, modifiers: Default::default() });
h.run_steps(2);
// Assert that the image widget consumes the click and the canvas background menu does not trigger
h.get_by_label("Rotate Clockwise");
h.get_by_label("Flip horizontal");
assert!(h.query_all_by_label("Rotate view clockwise").next().is_none(), "canvas background menu must not trigger");
assert!(h.query_all_by_label("Add bookmark").next().is_none(), "canvas background menu must not trigger");
assert!(h.query_all_by_label("Take a snapshot").next().is_none(), "canvas background menu must not trigger");
}
#[test]
fn crop_action_pills_interactive_and_apply_cancel_reset() {
let mut png = Vec::new();
image::RgbImage::from_pixel(100, 100, image::Rgb([100, 150, 200]))
.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("square.png", None, png.clone()).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 c = r.center();
// Select image
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());
// Enter crop mode
let doc_id = h.state().views[0].id;
let original_rect = h.state().session.get(doc_id).unwrap().page_images(0)[0].rect;
h.state_mut().views[0].image_selection.as_mut().unwrap().crop = Some(pdfcraft_ui_egui::CropMode::new(original_rect));
h.run_steps(2);
// Set smaller crop rect (50x50 in center)
let smaller = egui::Rect::from_center_size(c, egui::vec2(50.0, 50.0));
set_crop_box(&mut h, smaller);
h.run_steps(2);
let pill_y = smaller.max.y + 17.0;
let done_pos = egui::pos2(smaller.max.x - 20.0, pill_y);
h.hover_at(done_pos);
h.run_steps(1);
assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::PointingHand, "Done pill has PointingHand cursor");
let cancel_pos = egui::pos2(smaller.max.x - 85.0, pill_y);
h.hover_at(cancel_pos);
h.run_steps(1);
assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::PointingHand, "Cancel pill has PointingHand cursor");
// Click Cancel pill: exits crop mode without applying changes
click_at(&mut h, cancel_pos);
h.run_steps(3);
assert!(h.state().views[0].image_selection.as_ref().is_some_and(|s| s.crop.is_none()), "Cancel exits crop mode");
assert_eq!(h.state().session.get(doc_id).unwrap().page_images(0)[0].visible_rect, Some(original_rect), "image is uncropped");
// Re-enter crop mode and set crop rect
h.state_mut().views[0].image_selection.as_mut().unwrap().crop = Some(pdfcraft_ui_egui::CropMode::new(original_rect));
set_crop_box(&mut h, smaller);
h.run_steps(2);
// Click "Remove Crop" pill
let reset_pos = egui::pos2(smaller.max.x - 170.0, pill_y);
h.hover_at(reset_pos);
h.run_steps(1);
assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::PointingHand, "Remove Crop pill has PointingHand cursor");
click_at(&mut h, reset_pos);
h.run_steps(2);
let current_keep = h.state().views[0].image_selection.as_ref().unwrap().crop.as_ref().unwrap().keep;
assert_eq!(current_keep, original_rect, "Remove Crop restored crop to full bounds");
// Re-set smaller crop and click "Done" pill to apply
set_crop_box(&mut h, smaller);
h.run_steps(2);
let done_pos_after = egui::pos2(smaller.max.x - 20.0, pill_y);
click_at(&mut h, done_pos_after);
h.run_steps(4);
assert!(h.state().views[0].image_selection.as_ref().is_some_and(|s| s.crop.is_none()), "Done exits crop mode");
let after_images = h.state().session.get(doc_id).unwrap().page_images(0);
assert_ne!(after_images[0].visible_rect, Some(original_rect), "crop was applied");
}
#[test]
fn test_cropped_image_selection_and_bounding_box() {
let mut png = Vec::new();
image::RgbImage::from_pixel(100, 100, image::Rgb([100, 150, 200]))
.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("square.png", None, png.clone()).expect("opens");
app
});
h.run_steps(4);
assert!(h.state_mut().execute("edit.edit_text"));
h.run_steps(2);
let doc_id = h.state().views[0].id;
let full_rect = h.state().session.get(doc_id).unwrap().page_images(0)[0].rect;
// Apply crop to the center quarter: [0.25, 0.25, 0.75, 0.75]
let crop_keep = [
full_rect[0] + (full_rect[2] - full_rect[0]) * 0.25,
full_rect[1] + (full_rect[3] - full_rect[1]) * 0.25,
full_rect[0] + (full_rect[2] - full_rect[0]) * 0.75,
full_rect[1] + (full_rect[3] - full_rect[1]) * 0.75,
];
h.state_mut().apply_edit(pdfcraft_engine::Edit::EditPageImage { page: 0, index: 0, change: pdfcraft_engine::ImageEdit::Crop(crop_keep) });
h.run_steps(4);
let img = &h.state().session.get(doc_id).unwrap().page_images(0)[0];
assert_eq!(img.rect, full_rect, "underlying CTM rect is non-destructively preserved");
let vis = img.visible_rect.expect("has visible_rect");
assert!((vis[0] - crop_keep[0]).abs() < 1e-3);
assert!((vis[1] - crop_keep[1]).abs() < 1e-3);
assert!((vis[2] - crop_keep[2]).abs() < 1e-3);
assert!((vis[3] - crop_keep[3]).abs() < 1e-3);
// Verify selectable bounds helper gives the cropped intersection
assert_eq!(pdfcraft_ui_egui::canvas::image_selectable_bounds(img), vis);
// Select tool (mouse tool)
h.state_mut().quick_tool = pdfcraft_ui_egui::QuickTool::Select;
h.run_steps(2);
let page_rect = h.state().views[0].page_screen_rect(0).expect("on screen");
// Center point is inside the cropped area:
let center = page_rect.center();
click_at(&mut h, center);
h.run_steps(2);
let picked = h.state().views[0].picked_image.expect("image is picked when clicking inside crop");
assert_eq!(picked.page, 0);
assert_eq!(picked.index, 0);
// The selection bounding box wraps the visible cropped area, NOT the full CTM rect!
assert_eq!(picked.rect, vis, "picked rect wraps the cropped bounds");
// Deselect
h.key_press(egui::Key::Escape);
h.run_steps(2);
assert!(h.state().views[0].picked_image.is_none());
// Corner point (e.g. top-left corner of the page) is outside the crop box but inside full CTM rect
let corner = page_rect.min + egui::vec2(10.0, 10.0);
click_at(&mut h, corner);
h.run_steps(2);
assert!(h.state().views[0].picked_image.is_none(), "clicking cropped-away region must not hit-test or select the image");
}
/// 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");
}
#[test]
fn arrange_duplicate_image_clears_selection_when_ambiguous() {
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")));
// Paint two occurrences of the same figure at the exact same position
let contents = doc.add(Object::Stream(Stream::flate(Dict::new(), b"q 1 0 0 1 0 0 cm /Figure Do Q q 1 0 0 1 0 0 cm /Figure Do Q")));
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("dup.pdf", None, bytes.clone()).expect("opens");
app
});
h.run_steps(4);
assert!(h.state_mut().execute("edit.edit_text"));
h.run_steps(2);
let doc_id = h.state().views[0].id;
let images = h.state().session.get(doc_id).unwrap().page_images(0);
assert_eq!(images.len(), 2, "two duplicate image occurrences exist");
assert_eq!(images[0].rect, images[1].rect);
assert_eq!(images[0].name, images[1].name);
// Select the second occurrence (index 1)
h.state_mut().views[0].image_selection =
Some(pdfcraft_ui_egui::ImageSelection { page: 0, index: 1, drag: None, crop: None, turning: false, settled: None });
h.run_steps(2);
// Arrange send to back
let applied = h.state_mut().apply_edit(pdfcraft_engine::Edit::EditPageImage {
page: 0,
index: 1,
change: pdfcraft_engine::ImageEdit::Arrange(pdfcraft_engine::ArrangeOrder::SendToBack),
});
assert!(applied);
h.run_steps(2);
// Because there are duplicate occurrences matching name, object, and rect,
// selection must be cleared rather than picking an arbitrary occurrence.
assert!(h.state().views[0].image_selection.is_none(), "selection is safely cleared when reordering ambiguous duplicate images");
}
#[test]
fn arrange_unique_image_preserves_selection() {
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 artwork1 = doc.add(Object::Stream(Stream::flate(form.clone(), b"0.2 0.5 0.9 rg 20 20 160 260 re f")));
let artwork2 = doc.add(Object::Stream(Stream::flate(form, b"0.9 0.2 0.5 rg 40 40 100 100 re f")));
let contents = doc.add(Object::Stream(Stream::flate(Dict::new(), b"q 1 0 0 1 0 0 cm /FigA Do Q q 1 0 0 1 0 0 cm /FigB Do Q")));
doc.update_dict(page.obj, |d| {
d.set(b"Contents".to_vec(), Object::Ref(contents));
let mut xo = Dict::new();
xo.set(b"FigA".to_vec(), Object::Ref(artwork1));
xo.set(b"FigB".to_vec(), Object::Ref(artwork2));
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("unique.pdf", None, bytes.clone()).expect("opens");
app
});
h.run_steps(4);
assert!(h.state_mut().execute("edit.edit_text"));
h.run_steps(2);
let doc_id = h.state().views[0].id;
let images = h.state().session.get(doc_id).unwrap().page_images(0);
assert_eq!(images.len(), 2);
assert_eq!(images[0].name, "FigA");
assert_eq!(images[1].name, "FigB");
// Select FigA at index 0 and bring it to front (should move after FigB, becoming index 1)
h.state_mut().views[0].image_selection =
Some(pdfcraft_ui_egui::ImageSelection { page: 0, index: 0, drag: None, crop: None, turning: false, settled: None });
h.run_steps(2);
let applied = h.state_mut().apply_edit(pdfcraft_engine::Edit::EditPageImage {
page: 0,
index: 0,
change: pdfcraft_engine::ImageEdit::Arrange(pdfcraft_engine::ArrangeOrder::BringToFront),
});
assert!(applied);
h.run_steps(2);
let sel = h.state().views[0].image_selection.as_ref().expect("selection is preserved for unique image");
assert_eq!(sel.index, 1, "selection index tracks the unique image's new position");
}
#[test]
fn undo_arrange_re_resolves_image_selection_or_clears() {
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 artwork1 = doc.add(Object::Stream(Stream::flate(form.clone(), b"0.2 0.5 0.9 rg 20 20 160 260 re f")));
let artwork2 = doc.add(Object::Stream(Stream::flate(form, b"0.9 0.2 0.5 rg 40 40 100 100 re f")));
let contents = doc.add(Object::Stream(Stream::flate(Dict::new(), b"q 1 0 0 1 0 0 cm /FigA Do Q q 1 0 0 1 0 0 cm /FigB Do Q")));
doc.update_dict(page.obj, |d| {
d.set(b"Contents".to_vec(), Object::Ref(contents));
let mut xo = Dict::new();
xo.set(b"FigA".to_vec(), Object::Ref(artwork1));
xo.set(b"FigB".to_vec(), Object::Ref(artwork2));
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("undo_arrange.pdf", None, bytes.clone()).expect("opens");
app
});
h.run_steps(4);
assert!(h.state_mut().execute("edit.edit_text"));
h.run_steps(2);
let doc_id = h.state().views[0].id;
let images = h.state().session.get(doc_id).unwrap().page_images(0);
assert_eq!(images.len(), 2);
assert_eq!(images[0].name, "FigA");
assert_eq!(images[1].name, "FigB");
// Select FigA (back image, index 0)
h.state_mut().views[0].image_selection =
Some(pdfcraft_ui_egui::ImageSelection { page: 0, index: 0, drag: None, crop: None, turning: false, settled: None });
h.run_steps(2);
// Bring to front
let applied = h.state_mut().apply_edit(pdfcraft_engine::Edit::EditPageImage {
page: 0,
index: 0,
change: pdfcraft_engine::ImageEdit::Arrange(pdfcraft_engine::ArrangeOrder::BringToFront),
});
assert!(applied);
h.run_steps(2);
// Selection now tracks FigA at index 1
let sel = h.state().views[0].image_selection.as_ref().expect("selected after arrange");
assert_eq!(sel.index, 1);
// Undo
assert!(h.state_mut().execute("edit.undo"));
h.run_steps(2);
let doc_after = h.state().session.get(doc_id).unwrap();
let images_after = doc_after.page_images(0);
assert_eq!(images_after[0].name, "FigA");
assert_eq!(images_after[1].name, "FigB");
// After undoing Bring to front, selection must either be FigA (index 0) or None, never FigB (index 1)
let sel_after = h.state().views[0].image_selection.as_ref().expect("selection re-resolved to the same image");
assert_eq!(sel_after.index, 0, "selection should point to FigA at index 0, not FigB at index 1");
assert_eq!(images_after[sel_after.index].name, "FigA");
// Redo
assert!(h.state_mut().execute("edit.redo"));
h.run_steps(2);
let doc_redo = h.state().session.get(doc_id).unwrap();
let images_redo = doc_redo.page_images(0);
assert_eq!(images_redo[0].name, "FigB");
assert_eq!(images_redo[1].name, "FigA");
let sel_redo = h.state().views[0].image_selection.as_ref().expect("selection re-resolved after redo");
assert_eq!(sel_redo.index, 1, "selection should point to FigA at index 1");
assert_eq!(images_redo[sel_redo.index].name, "FigA");
// Bringing to front when already at front is refused, but selection must be preserved
let applied_again = h.state_mut().apply_edit(pdfcraft_engine::Edit::EditPageImage {
page: 0,
index: 1,
change: pdfcraft_engine::ImageEdit::Arrange(pdfcraft_engine::ArrangeOrder::BringToFront),
});
assert!(!applied_again);
h.run_steps(2);
let sel_refused = h.state().views[0].image_selection.as_ref().expect("selection preserved after refused arrange");
assert_eq!(sel_refused.index, 1);
}
/// #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,
source: egui::MouseWheelSource::Unknown,
});
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");
}
#[test]
fn copy_pasted_image_discards_coordinates_and_moves_without_offset() {
let mut png = Vec::new();
image::RgbImage::from_pixel(60, 60, image::Rgb([100, 150, 200])).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
let mut h = harness(1, |_| {});
h.state_mut().system_clipboard = false;
h.run_steps(4);
// 1. Add image to page 0
h.state_mut().add_image("test_img.png".into(), png);
h.run_steps(3);
let id = h.state().views[0].id;
let images_before = h.state().session.get(id).unwrap().page_images(0);
assert_eq!(images_before.len(), 1);
// 2. Copy the image from page 0
assert!(h.state_mut().copy_page_image(0, 0));
assert!(h.state().last_copied_image.is_some(), "image pixel data is kept");
// 3. Paste the image via Ctrl+V (paste_image with rect: None)
assert!(h.state_mut().paste_image(0, None));
h.run_steps(3);
let images_after_paste = h.state().session.get(id).unwrap().page_images(0);
assert_eq!(images_after_paste.len(), 2, "second image pasted");
// The pasted image should be centered on the page, not offset by (+20, -20) from the first image
let img1 = &images_after_paste[0];
let img2 = &images_after_paste[1];
assert_eq!(img1.rect, img2.rect, "pasted image placed using natural page centering");
// 4. Move the pasted image by an explicit Move change
let target = [50.0, 50.0, 110.0, 110.0];
h.state_mut().apply_edit(pdfcraft_engine::Edit::EditPageImage { page: 0, index: 1, change: pdfcraft_engine::ImageEdit::Move(target) });
h.run_steps(3);
let images_after_move = h.state().session.get(id).unwrap().page_images(0);
assert_eq!(images_after_move[1].rect, target, "moved image rect matches target exactly without offset");
}
#[test]
fn clicking_after_image_context_menu_action_resets_menu_state_and_updates_selection() {
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;
// Move the first image to left side: [10.0, 10.0, 30.0, 30.0]
h.state_mut().apply_edit(pdfcraft_engine::Edit::EditPageImage {
page: 0,
index: 0,
change: pdfcraft_engine::ImageEdit::Move([10.0, 10.0, 30.0, 30.0]),
});
// Add second image to right side: [50.0, 10.0, 70.0, 30.0]
let mut png2 = Vec::new();
image::RgbImage::from_pixel(20, 20, image::Rgb([40, 200, 40])).write_to(&mut std::io::Cursor::new(&mut png2), image::ImageFormat::Png).unwrap();
h.state_mut().apply_edit(pdfcraft_engine::Edit::AddImage {
page: 0,
rect: Some([50.0, 10.0, 70.0, 30.0]),
name: "img2.png".into(),
bytes: std::sync::Arc::new(png2),
});
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 info = h.state().session.get(id).unwrap().info.pages[0].clone();
let user_to_screen = move |u: [f64; 2]| {
let v = info.user_to_view(u[0] as f32, u[1] as f32);
let scale = r.width() / info.width;
egui::pos2(r.left() + v[0] * scale, r.top() + v[1] * scale)
};
let p0 = user_to_screen([20.0, 20.0]);
let p1 = user_to_screen([60.0, 20.0]);
let p_empty = user_to_screen([40.0, 35.0]);
// 1. Select image 0
h.hover_at(p0);
h.run_steps(1);
h.drag_at(p0);
h.run_steps(1);
h.drop_at(p0);
h.run_steps(2);
assert_eq!(h.state().views[0].image_selection.as_ref().map(|s| s.index), Some(0), "image 0 selected");
// 2. Select image 1 (switches selection)
h.hover_at(p1);
h.run_steps(1);
h.drag_at(p1);
h.run_steps(1);
h.drop_at(p1);
h.run_steps(2);
assert_eq!(h.state().views[0].image_selection.as_ref().map(|s| s.index), Some(1), "image 1 selected");
// 3. Click empty space (clears selection)
h.hover_at(p_empty);
h.run_steps(1);
h.drag_at(p_empty);
h.run_steps(1);
h.drop_at(p_empty);
h.run_steps(2);
assert!(h.state().views[0].image_selection.is_none(), "empty page clears selection");
// 4. Select image 0 again
h.hover_at(p0);
h.run_steps(1);
h.drag_at(p0);
h.run_steps(1);
h.drop_at(p0);
h.run_steps(2);
assert_eq!(h.state().views[0].image_selection.as_ref().map(|s| s.index), Some(0), "image 0 selected again");
// 5. Right-click image 0 to open context menu
h.hover_at(p0);
h.run_steps(1);
h.event(egui::Event::PointerButton { pos: p0, button: egui::PointerButton::Secondary, pressed: true, modifiers: Default::default() });
h.event(egui::Event::PointerButton { pos: p0, button: egui::PointerButton::Secondary, pressed: false, modifiers: Default::default() });
h.run_steps(2);
// 6. Click "Flip horizontal"
h.get_by_label("Flip horizontal").click();
h.run_steps(3);
assert!(h.query_all_by_label("Flip horizontal").next().is_none(), "the image menu closes after its action");
// 7. Click image 1: should switch selection to image 1
h.hover_at(p1);
h.run_steps(1);
h.drag_at(p1);
h.run_steps(1);
h.drop_at(p1);
h.run_steps(2);
assert_eq!(
h.state().views[0].image_selection.as_ref().map(|s| s.index),
Some(1),
"clicking other image switches selection after context menu action"
);
// 8. Click empty space: should clear selection
h.hover_at(p_empty);
h.run_steps(1);
h.drag_at(p_empty);
h.run_steps(1);
h.drop_at(p_empty);
h.run_steps(2);
assert!(h.state().views[0].image_selection.is_none(), "clicking empty page clears selection after context menu action");
}
#[test]
fn format_panel_shows_selection_size_and_updates_after_font_applied() {
let mut 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(),
];
let body = "BT /F1 24 Tf 20 200 Td (Heading) Tj /F1 12 Tf 0 -25 Td (Paragraph) Tj ET";
objs.push(format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()));
let mut pdf = Vec::from(b"%PDF-1.7\n");
let mut xref = Vec::new();
for (i, o) in objs.iter().enumerate() {
xref.push(pdf.len());
pdf.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let xref_pos = pdf.len();
pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in xref {
pdf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref_pos}\n%%EOF\n", objs.len() + 1).as_bytes());
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("test.pdf", None, pdf).expect("fixture 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 - 180.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(), "editor opened");
let text = h.state().views[0].line_editor.as_ref().unwrap().text.clone();
assert!(text.contains("Paragraph"), "text is {text}");
// Select the 12 pt paragraph text "Paragraph" via keyboard selection in the TextEdit
h.key_press_modifiers(Modifiers::COMMAND, Key::ArrowRight);
h.run_steps(1);
for _ in 0.."Paragraph".len() {
h.key_press_modifiers(Modifiers::SHIFT, Key::ArrowLeft);
h.run_steps(1);
}
use egui::accesskit::Role;
let size_node = h.get_by_role_and_label(Role::TextInput, "Font size:");
assert_eq!(size_node.value().as_deref(), Some("12"), "size text input value is 12");
let font_node = h.get_by_role_and_label(Role::ComboBox, "Font");
assert_eq!(font_node.value().as_deref(), Some("Helvetica"), "initial font is Helvetica");
// Apply Times to the selection
font_node.click();
h.run_steps(2);
h.get_by_label("Times").click();
h.run_steps(4);
// Font combo box for the whole block is now Mixed because Heading is Helvetica and Paragraph is Times
let font_node = h.get_by_role_and_label(Role::ComboBox, "Font");
assert_eq!(font_node.value().as_deref(), Some("Mixed"), "whole block has mixed fonts");
// Apply Times to the whole block
font_node.click();
h.run_steps(2);
h.get_by_label("Times").click();
h.run_steps(4);
// Now all text is Times, so font combo box updates to Times
let font_node = h.get_by_role_and_label(Role::ComboBox, "Font");
assert_eq!(font_node.value().as_deref(), Some("Times"), "font combo box updates to Times");
}
/// The paragraph opened for typing, as a person opens it: a click on its text.
fn open_paragraph(h: &mut Harness<'static, PdfCraftApp>) {
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());
click_at(h, at);
assert!(h.state().views[0].line_editor.is_some(), "the paragraph opens for typing");
}
/// Ctrl+Z after a formatting change, with the paragraph still open: the change is undone and
/// the editor closes. The box's own Undo used to take the key, putting back the text it had
/// before the change into a box laid out for the page after it, and the document kept the change.
#[test]
fn undo_with_the_paragraph_open_undoes_the_formatting() {
let mut h = harness(1, |_| {});
assert!(h.state_mut().execute("edit.edit_text"));
h.run_steps(2);
open_paragraph(&mut h);
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");
}
h.key_press_modifiers(Modifiers::COMMAND, Key::Z);
h.run_steps(4);
let s = h.state();
let doc = s.session.get(s.views[0].id).unwrap();
assert_eq!(doc.can_undo(), None, "the formatting is undone");
assert_eq!(doc.text_blocks(0)[0].base_font, "Helvetica");
assert!(s.views[0].line_editor.is_none(), "the editor closed with the step it showed");
}
/// Undo while typing takes back the typing in the box first; the document is left alone.
#[test]
fn undo_while_typing_takes_back_the_typing_first() {
let mut h = harness(1, |_| {});
assert!(h.state_mut().execute("edit.edit_text"));
h.run_steps(2);
open_paragraph(&mut h);
let original = h.state().views[0].line_editor.as_ref().unwrap().text.clone();
h.key_press_modifiers(Modifiers::COMMAND, Key::End);
h.event(egui::Event::Text(" more".into()));
h.run_steps(2);
assert_ne!(h.state().views[0].line_editor.as_ref().unwrap().text, original);
h.key_press_modifiers(Modifiers::COMMAND, Key::Z);
h.run_steps(3);
let s = h.state();
let ed = s.views[0].line_editor.as_ref().expect("still typing");
assert_eq!(ed.text, original, "the typing is taken back");
assert_eq!(s.session.get(s.views[0].id).unwrap().can_undo(), None, "the document is untouched");
}
/// A paragraph of a short, a wide and a short row, opened in the paragraph editor.
fn three_row_paragraph() -> Harness<'static, PdfCraftApp> {
let bytes = paragraph_drag_fixture("BT /F1 10 Tf 12 TL 60 400 Td (One two) Tj T* (three four five six seven eight) Tj T* (nine ten) Tj ET", 0, 1);
let mut h = paragraph_drag_harness(bytes, 0);
let (at, _) = paragraph_drag_points(&h, "One two three four five six seven eight nine ten", [0.0, 0.0]);
click_at(&mut h, at);
assert!(h.state().views[0].line_editor.is_some(), "the paragraph opens for typing");
h
}
/// Format text's alignment flyout, by pointer.
fn align_by_pointer(h: &mut Harness<'static, PdfCraftApp>, choice: &str) {
let button = h.get_by_label("Alignment").rect().center();
click_at(h, button);
let item = h.get_by_label(choice).rect().center();
click_at(h, item);
}
fn row_rect(h: &Harness<'static, PdfCraftApp>, starts: &str) -> [f64; 4] {
let s = h.state();
s.session.get(s.views[0].id).unwrap().text_lines(0).into_iter().find(|l| l.text.starts_with(starts)).expect("the row").rect
}
/// Existing-row alignment stays disabled until intent survives reopening; cancellation keeps all rows.
#[test]
fn selected_existing_rows_keep_alignment_disabled_and_the_complete_paragraph() {
let mut h = three_row_paragraph();
let before = h.state().views[0].line_editor.as_ref().unwrap().text.clone();
let first = row_rect(&h, "One");
let last = row_rect(&h, "nine");
h.key_press_modifiers(Modifiers::COMMAND, Key::End);
h.key_press_modifiers(Modifiers::SHIFT, Key::Home);
h.run_steps(2);
assert!(h.get_by_label("Alignment").accesskit_node().is_disabled());
let button = h.get_by_label("Alignment").rect().center();
click_at(&mut h, button);
assert_eq!(h.state().views[0].line_editor.as_ref().unwrap().text, before);
assert_eq!(row_rect(&h, "One"), first);
assert_eq!(row_rect(&h, "nine"), last);
assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo(), None);
h.key_press(Key::Escape);
h.run_steps(3);
assert_eq!(row_rect(&h, "One"), first);
assert_eq!(row_rect(&h, "nine"), last);
}
/// Clearing the selection restores ordinary whole-paragraph alignment and its undo step.
#[test]
fn whole_paragraph_alignment_stays_available_without_a_row_selection() {
let mut h = three_row_paragraph();
h.key_press_modifiers(Modifiers::COMMAND, Key::End);
h.key_press_modifiers(Modifiers::SHIFT, Key::Home);
h.run_steps(2);
assert!(h.get_by_label("Alignment").accesskit_node().is_disabled());
h.key_press(Key::End);
h.run_steps(2);
assert!(!h.get_by_label("Alignment").accesskit_node().is_disabled());
align_by_pointer(&mut h, "Align right");
assert!(h.state().views[0].line_editor.is_some());
h.key_press_modifiers(Modifiers::COMMAND, Key::Z);
h.run_steps(3);
assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo(), None);
}
/// Redo after Undo in the box puts the typing back, in the box: it doesn't reach the document,
/// whose own Redo (a formatting change waiting here) must stay waiting.
#[test]
fn redo_after_undoing_typing_puts_the_typing_back() {
let mut h = harness(1, |_| {});
assert!(h.state_mut().execute("edit.edit_text"));
h.run_steps(2);
open_paragraph(&mut h);
h.get_by_label("Bold").click();
h.run_steps(4);
// Undo the bold (closing the editor), so the document has it to redo.
h.key_press_modifiers(Modifiers::COMMAND, Key::Z);
h.run_steps(4);
assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_redo(), Some("Edit text"));
open_paragraph(&mut h);
let original = h.state().views[0].line_editor.as_ref().unwrap().text.clone();
h.key_press_modifiers(Modifiers::COMMAND, Key::End);
h.event(egui::Event::Text(" more".into()));
h.run_steps(2);
let typed = h.state().views[0].line_editor.as_ref().unwrap().text.clone();
h.key_press_modifiers(Modifiers::COMMAND, Key::Z);
h.run_steps(3);
assert_eq!(h.state().views[0].line_editor.as_ref().unwrap().text, original, "the typing is taken back");
h.key_press_modifiers(Modifiers::COMMAND | Modifiers::SHIFT, Key::Z);
h.run_steps(3);
let s = h.state();
let ed = s.views[0].line_editor.as_ref().expect("still typing");
assert_eq!(ed.text, typed, "Redo puts the typing back");
let doc = s.session.get(s.views[0].id).unwrap();
assert_eq!((doc.can_redo(), doc.text_blocks(0)[0].base_font.as_str()), (Some("Edit text"), "Helvetica"), "the document's Redo still waits");
}
/// Redo from the menu or toolbar with typing in the box the page doesn't have yet. Closing the
/// box first applied the typing, a new edit that dropped the document's waiting Redo, and the
/// Redo itself then failed: the step was gone. The typing now stays in the box and the Redo
/// waits; once Undo in the box takes the typing back, Redo from the menu puts it back there.
#[test]
fn redo_from_the_menu_with_typing_in_the_box_keeps_the_documents_redo() {
let mut h = harness(1, |_| {});
assert!(h.state_mut().execute("edit.edit_text"));
h.run_steps(2);
open_paragraph(&mut h);
h.get_by_label("Bold").click();
h.run_steps(4);
// Undo the bold (closing the editor), so the document has it to redo.
h.key_press_modifiers(Modifiers::COMMAND, Key::Z);
h.run_steps(4);
open_paragraph(&mut h);
let original = h.state().views[0].line_editor.as_ref().unwrap().text.clone();
h.key_press_modifiers(Modifiers::COMMAND, Key::End);
h.event(egui::Event::Text(" more".into()));
h.run_steps(2);
let typed = h.state().views[0].line_editor.as_ref().unwrap().text.clone();
assert!(h.state_mut().execute("edit.redo"));
h.run_steps(3);
{
let s = h.state();
let ed = s.views[0].line_editor.as_ref().expect("the box stays open");
assert_eq!(ed.text, typed, "the typing stays in the box, unapplied");
let doc = s.session.get(s.views[0].id).unwrap();
assert_eq!((doc.can_redo(), doc.text_blocks(0)[0].base_font.as_str()), (Some("Edit text"), "Helvetica"), "the document's Redo still waits");
}
h.key_press_modifiers(Modifiers::COMMAND, Key::Z);
h.run_steps(3);
assert_eq!(h.state().views[0].line_editor.as_ref().unwrap().text, original, "Undo takes the typing back in the box");
assert!(h.state_mut().execute("edit.redo"));
h.run_steps(3);
let s = h.state();
assert_eq!(s.views[0].line_editor.as_ref().expect("still open").text, typed, "Redo from the menu puts the typing back");
assert_eq!(s.session.get(s.views[0].id).unwrap().can_redo(), Some("Edit text"), "the document's Redo still waits");
}
/// Undo from the menu or toolbar with typing in the box the page doesn't have yet: the box takes
/// the typing back, as the key does, and stays open, and the document's waiting Redo stays. The
/// menu applied the typing first (a new edit, which dropped that Redo), closed the box and undid
/// the typing all at once.
#[test]
fn undo_from_the_menu_takes_back_typing_in_the_box() {
let mut h = harness(1, |_| {});
assert!(h.state_mut().execute("edit.edit_text"));
h.run_steps(2);
open_paragraph(&mut h);
h.get_by_label("Bold").click();
h.run_steps(4);
// Undo the bold (closing the editor), so the document has it to redo and nothing to undo.
h.key_press_modifiers(Modifiers::COMMAND, Key::Z);
h.run_steps(4);
open_paragraph(&mut h);
let original = h.state().views[0].line_editor.as_ref().unwrap().text.clone();
h.key_press_modifiers(Modifiers::COMMAND, Key::End);
h.event(egui::Event::Text(" more".into()));
h.run_steps(2);
h.key_press_modifiers(Modifiers::SHIFT, Key::Home);
h.run_steps(2);
assert!(h.state().views[0].line_editor.as_ref().unwrap().selection.is_some());
h.event(egui::Event::PointerMoved(egui::pos2(10.0, 10.0)));
h.ctx.memory_mut(|m| m.request_focus(egui::Id::unique("test-menu")));
assert!(h.state_mut().execute("edit.undo"), "Undo is enabled for the box's typing");
assert_eq!(h.state().views[0].line_editor.as_ref().unwrap().selection, None, "menu history clears the old byte range before the next frame");
h.run_steps(3);
let s = h.state();
let ed = s.views[0].line_editor.as_ref().expect("the box stays open");
assert_eq!(ed.text, original, "the typing is taken back in the box");
let doc = s.session.get(s.views[0].id).unwrap();
assert_eq!((doc.can_redo(), doc.can_undo()), (Some("Edit text"), None), "the document is untouched");
}
/// Redo from the menu after Undo in the box took the typing back, with nothing for the document
/// to redo: the menu item follows the box's own Redo too, and puts the typing back. It was
/// disabled, as it followed only the document's Redo, and only the key could do it.
#[test]
fn redo_from_the_menu_is_enabled_for_the_boxs_own_typing() {
let mut h = harness(1, |_| {});
assert!(h.state_mut().execute("edit.edit_text"));
h.run_steps(2);
open_paragraph(&mut h);
let original = h.state().views[0].line_editor.as_ref().unwrap().text.clone();
h.key_press_modifiers(Modifiers::COMMAND, Key::End);
h.event(egui::Event::Text(" more".into()));
h.run_steps(2);
let typed = h.state().views[0].line_editor.as_ref().unwrap().text.clone();
h.key_press_modifiers(Modifiers::COMMAND, Key::Z);
h.run_steps(3);
assert_eq!(h.state().views[0].line_editor.as_ref().unwrap().text, original, "the typing is taken back");
assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_redo(), None, "the document has nothing to redo");
// A range retained while the unfocused editor still showed its previous text.
h.state_mut().views[0].line_editor.as_mut().unwrap().selection = Some(0..1);
h.event(egui::Event::PointerMoved(egui::pos2(10.0, 10.0)));
h.ctx.memory_mut(|m| m.request_focus(egui::Id::unique("test-menu")));
assert!(h.state_mut().execute("edit.redo"), "Redo is enabled for the box's own step");
assert_eq!(h.state().views[0].line_editor.as_ref().unwrap().selection, None, "menu history clears the old byte range before the next frame");
h.run_steps(3);
assert_eq!(h.state().views[0].line_editor.as_ref().expect("still open").text, typed, "Redo puts the typing back");
}
/// Typing in tab A's paragraph, a paragraph opened in tab B, then back to A: Edit ▸ Undo takes
/// the typing back in A's box. The tabs share one text box, and its history was cleared each time
/// another tab's box took it, so Undo found no step and closed A's paragraph instead
/// (skylartaylor's and FredApps's review of 08ec1db7).
#[test]
fn undo_after_a_round_trip_through_another_tab_takes_back_the_typing() {
let mut h = harness(1, |app| {
app.open_bytes("copy.pdf", None, fixture(1)).expect("the copy opens");
});
let open_in = |h: &mut Harness<'static, PdfCraftApp>, tab: usize| {
h.state_mut().active = Some(tab);
h.run_steps(2);
if h.state().views[tab].line_editor.is_none() {
assert!(h.state_mut().execute("edit.edit_text"));
h.run_steps(2);
}
let r = h.state().views[tab].page_screen_rect(0).expect("on screen");
click_at(h, egui::pos2(r.left() + 40.0 / 200.0 * r.width(), r.top() + (300.0 - 158.0) / 300.0 * r.height()));
assert!(h.state().views[tab].line_editor.is_some(), "the paragraph opens for typing");
};
open_in(&mut h, 0);
h.get_by_label("Bold").click();
h.run_steps(4);
h.key_press_modifiers(Modifiers::COMMAND, Key::Z);
h.run_steps(4);
open_in(&mut h, 0);
let original = h.state().views[0].line_editor.as_ref().unwrap().text.clone();
h.key_press_modifiers(Modifiers::COMMAND, Key::End);
h.event(egui::Event::Text(" more".into()));
h.run_steps(2);
assert!(h.state().views[0].line_editor.as_ref().unwrap().text.ends_with(" more"));
open_in(&mut h, 1);
h.state_mut().active = Some(0);
h.run_steps(3);
assert!(h.state().views[0].line_editor.is_some(), "A's paragraph is still open after the round trip");
assert!(h.state_mut().execute("edit.undo"), "Undo is enabled");
h.run_steps(3);
let s = h.state();
let ed = s.views[0].line_editor.as_ref().expect("the box stays open: Undo takes back the typing");
assert_eq!(ed.text, original, "the typing is taken back in the box");
assert_eq!(s.session.get(s.views[0].id).unwrap().can_redo(), Some("Edit text"), "the document's Redo still waits");
}
/// Typing taken back with Ctrl+Z, then Bold applied with the paragraph still open: Ctrl+Shift+Z
/// must not bring the typing back on top of the bold, as the box's history is cleared once the
/// page has its text. The format left the text as it was, so the box kept its history and its
/// Redo put "Page 1 more" back (FredApps's review of abc378c0).
#[test]
fn redo_after_formatting_never_brings_back_typing_undone_before_it() {
let mut h = harness(1, |_| {});
assert!(h.state_mut().execute("edit.edit_text"));
h.run_steps(2);
open_paragraph(&mut h);
let original = h.state().views[0].line_editor.as_ref().unwrap().text.clone();
h.key_press_modifiers(Modifiers::COMMAND, Key::End);
h.event(egui::Event::Text(" more".into()));
h.run_steps(2);
h.key_press_modifiers(Modifiers::COMMAND, Key::Z);
h.run_steps(3);
assert_eq!(h.state().views[0].line_editor.as_ref().unwrap().text, original, "the typing is taken back");
h.get_by_label("Bold").click();
h.run_steps(4);
assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo(), Some("Edit text"), "the bold is applied");
h.key_press_modifiers(Modifiers::COMMAND | Modifiers::SHIFT, Key::Z);
h.run_steps(3);
let text = h.state().views[0].line_editor.as_ref().map(|ed| ed.text.clone());
assert_eq!(text.as_deref(), Some(original.as_str()), "the typing undone before the bold stays undone");
// Nor through what the menu's Redo runs.
h.state_mut().redo();
h.run_steps(3);
let s = h.state();
let text = s.views[0].line_editor.as_ref().map(|ed| ed.text.clone());
assert!(!text.as_deref().unwrap_or_default().contains("more"), "the menu's Redo brings it back: {text:?}");
assert_eq!(s.session.get(s.views[0].id).unwrap().text_blocks(0)[0].base_font, "Helvetica-Bold", "the bold stays");
}
/// Edit ▸ Undo chosen in the same frame as the switch back to tab A, before A's box is drawn
/// again: the shared text box still holds tab B's history then. Stepping through it put B's
/// typing into A's box; the box now claims its own history back first (CodeRabbit on abc378c0).
#[test]
fn undo_in_the_frame_of_the_switch_back_takes_back_the_typing() {
let mut h = harness(1, |app| {
app.open_bytes("copy.pdf", None, fixture(1)).expect("the copy opens");
});
let open_in = |h: &mut Harness<'static, PdfCraftApp>, tab: usize| {
h.state_mut().active = Some(tab);
h.run_steps(2);
if h.state().views[tab].line_editor.is_none() {
assert!(h.state_mut().execute("edit.edit_text"));
h.run_steps(2);
}
let r = h.state().views[tab].page_screen_rect(0).expect("on screen");
click_at(h, egui::pos2(r.left() + 40.0 / 200.0 * r.width(), r.top() + (300.0 - 158.0) / 300.0 * r.height()));
assert!(h.state().views[tab].line_editor.is_some(), "the paragraph opens for typing");
};
open_in(&mut h, 0);
let original = h.state().views[0].line_editor.as_ref().unwrap().text.clone();
h.key_press_modifiers(Modifiers::COMMAND, Key::End);
h.event(egui::Event::Text(" more".into()));
h.run_steps(2);
// In B, a paragraph whose text differs from A's ("Page 1 B", applied with Bold), so B's
// history can't pass for A's.
open_in(&mut h, 1);
h.key_press_modifiers(Modifiers::COMMAND, Key::End);
h.event(egui::Event::Text(" B".into()));
h.run_steps(2);
h.get_by_label("Bold").click();
h.run_steps(4);
assert_eq!(h.state().views[1].line_editor.as_ref().map(|ed| ed.text.as_str()), Some("Page 1 B"), "B's paragraph is open on its own text");
// Back to A and Undo at once: no frame draws A's box in between.
h.state_mut().active = Some(0);
assert!(h.state_mut().execute("edit.undo"), "Undo is enabled");
h.run_steps(3);
let ed = h.state().views[0].line_editor.clone().expect("the box stays open: Undo takes back the typing");
assert_eq!(ed.text, original, "the typing is taken back in A's box");
}
/// Redo from the menu chosen in the same frame as a switch to tab B, whose paragraph is open on
/// the same text as A's: the shared text box still holds A's history then, with A's undone
/// typing to redo, so B's box claims its own history back before stepping.
#[test]
fn redo_in_the_frame_of_a_tab_switch_never_brings_another_tabs_typing() {
let mut h = harness(1, |app| {
app.open_bytes("copy.pdf", None, fixture(1)).expect("the copy opens");
});
let open_in = |h: &mut Harness<'static, PdfCraftApp>, tab: usize| {
h.state_mut().active = Some(tab);
h.run_steps(2);
assert!(h.state_mut().execute("edit.edit_text"));
h.run_steps(2);
let r = h.state().views[tab].page_screen_rect(0).expect("on screen");
click_at(h, egui::pos2(r.left() + 40.0 / 200.0 * r.width(), r.top() + (300.0 - 158.0) / 300.0 * r.height()));
assert!(h.state().views[tab].line_editor.is_some(), "the paragraph opens for typing");
};
open_in(&mut h, 1);
open_in(&mut h, 0);
let original = h.state().views[0].line_editor.as_ref().unwrap().text.clone();
h.key_press_modifiers(Modifiers::COMMAND, Key::End);
h.event(egui::Event::Text(" secret".into()));
h.run_steps(2);
assert!(h.state_mut().execute("edit.undo"));
h.run_steps(3);
assert_eq!(h.state().views[0].line_editor.as_ref().unwrap().text, original, "taken back in A");
// To B and Redo at once: no frame draws B's box in between.
h.state_mut().active = Some(1);
h.state_mut().redo();
h.run_steps(3);
let s = h.state();
let b = s.views[1].line_editor.as_ref().map(|ed| ed.text.clone());
assert!(!b.as_deref().unwrap_or_default().contains("secret"), "A's typing came back in B: {b:?}");
assert!(!s.session.get(s.views[1].id).unwrap().text_blocks(0)[0].text.contains("secret"), "A's typing reached B's page");
}
/// Two copies of a document, the same paragraph open in each. The paragraph boxes share one text
/// box, and so one Undo history: a box opened on the same text as another tab's didn't clear it.
/// Typing in one tab, taken back with Undo, then came back with Redo (the key or the menu) in the
/// other tab.
#[test]
fn typing_in_one_tab_never_reaches_another_tabs_paragraph() {
let mut h = harness(1, |app| {
app.open_bytes("copy.pdf", None, fixture(1)).expect("the copy opens");
});
let open_in = |h: &mut Harness<'static, PdfCraftApp>, tab: usize| {
h.state_mut().active = Some(tab);
h.run_steps(2);
assert!(h.state_mut().execute("edit.edit_text"));
h.run_steps(2);
let r = h.state().views[tab].page_screen_rect(0).expect("on screen");
click_at(h, egui::pos2(r.left() + 40.0 / 200.0 * r.width(), r.top() + (300.0 - 158.0) / 300.0 * r.height()));
assert!(h.state().views[tab].line_editor.is_some(), "the paragraph opens for typing");
};
open_in(&mut h, 1);
let original = h.state().views[1].line_editor.as_ref().unwrap().text.clone();
open_in(&mut h, 0);
assert_eq!(h.state().views[0].line_editor.as_ref().unwrap().text, original, "the same paragraph");
h.key_press_modifiers(Modifiers::COMMAND, Key::End);
h.event(egui::Event::Text(" secret".into()));
h.run_steps(2);
h.key_press_modifiers(Modifiers::COMMAND, Key::Z);
h.run_steps(3);
assert_eq!(h.state().views[0].line_editor.as_ref().unwrap().text, original, "taken back");
// The copy, its paragraph still open: Redo there has nothing of its own to put back.
h.state_mut().active = Some(1);
h.run_steps(3);
assert!(h.state().views[1].line_editor.is_some(), "the copy's paragraph is still open");
h.key_press_modifiers(Modifiers::COMMAND | Modifiers::SHIFT, Key::Z);
h.run_steps(3);
let text = h.state().views[1].line_editor.as_ref().expect("the copy's paragraph stays open through Redo").text.clone();
assert!(!text.contains("secret"), "the other tab's typing came in: {text:?}");
// Nor through what the menu's Redo runs, which reads the box's history itself (the menu item
// is off here, as the copy's document has nothing to redo). With no Redo of its own, the box
// passes the step to the document and closes, so the page's text is checked too.
h.state_mut().redo();
h.run_steps(3);
let s = h.state();
let text = s.views[1].line_editor.as_ref().map(|ed| ed.text.clone());
assert!(!text.as_deref().unwrap_or_default().contains("secret"), "the other tab's typing came in from the menu: {text:?}");
assert_eq!(s.session.get(s.views[1].id).unwrap().text_blocks(0)[0].text, s.session.get(s.views[0].id).unwrap().text_blocks(0)[0].text);
}
/// A paragraph selected by its frame goes with one Delete (or Backspace), as in Acrobat, instead
/// of being emptied a typed character at a time, and undo brings it back.
#[test]
fn a_paragraph_selected_by_its_frame_deletes_with_one_key() {
for key in [Key::Delete, Key::Backspace] {
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 blocks = |h: &Harness<'static, PdfCraftApp>| h.state().session.get(id).unwrap().text_blocks(0);
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 b = blocks(&h)[0].clone();
click_at(&mut h, screen((b.rect[0] + b.rect[2]) / 2.0, (b.rect[1] + b.rect[3]) / 2.0));
assert!(h.state().views[0].line_editor.is_some(), "a click on the text opens it for typing");
click_at(&mut h, screen((b.rect[0] + b.rect[2]) / 2.0, b.rect[3]) - egui::vec2(0.0, 4.0));
assert!(h.state().views[0].line_editor.is_none(), "the frame selects the box");
press(&mut h, Modifiers::NONE, key);
assert!(blocks(&h).is_empty(), "{key:?} deletes the whole paragraph: {:?}", blocks(&h));
assert!(h.state().views[0].line_editor.is_none(), "no empty editor is left behind");
assert!(h.query_all_by_label_contains("object selected").next().is_none(), "the deleted paragraph is no longer selected");
assert_eq!(h.state().session.get(id).unwrap().text_blocks(1)[0].text, "Page 2", "the next page keeps its text");
assert_eq!(h.state().session.get(id).unwrap().info.pages.len(), 2, "Delete removed the paragraph, not a page");
assert!(h.state_mut().execute("edit.undo"));
h.run_steps(2);
assert_eq!(blocks(&h).iter().map(|b| b.text.as_str()).collect::<Vec<_>>(), ["Page 1"], "undo restores it");
}
}
fn two_image_fixture() -> Vec<u8> {
let content = "q 20 0 0 20 100 100 cm /Im0 Do Q q 20 0 0 20 20 20 cm /Im0 Do Q";
let objs: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 300] /Contents 4 0 R /Resources << /XObject << /Im0 5 0 R >> >> >>".into(),
format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()),
"<< /Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 4 >>\nstream\n\0\0\0\0\nendstream"
.into(),
];
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 duplicate_xobject_at_different_rects_arrange_and_delete_keeps_other_instance_through_menu() {
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 1400.0)).build_eframe(|_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("dup.pdf", None, two_image_fixture()).expect("opens fixture");
app
});
h.run_steps(4);
let id = h.state().views[0].id;
assert!(h.state_mut().execute("edit.edit_text"));
h.run_steps(2);
let xf = h.state().views[0].page_xform(0).expect("page 0 xform");
let imgs = h.state().session.get(id).unwrap().page_images(0);
// B is at index 0 [100, 100, 120, 120], in the back
let b0 = pdfcraft_ui_egui::canvas::image_screen_rect(&imgs[0], &xf, &h.state().session.get(id).unwrap().info, 0);
let p_b = b0.center();
// 1. Select B (image 0)
h.hover_at(p_b);
h.run_steps(1);
h.drag_at(p_b);
h.run_steps(1);
h.drop_at(p_b);
h.run_steps(2);
assert_eq!(h.state().views[0].image_selection.as_ref().map(|s| s.index), Some(0), "image 0 (B) selected");
// 2. Right-click B (image 0) to open context menu
h.hover_at(p_b);
h.run_steps(1);
h.event(egui::Event::PointerButton { pos: p_b, button: egui::PointerButton::Secondary, pressed: true, modifiers: Default::default() });
h.event(egui::Event::PointerButton { pos: p_b, button: egui::PointerButton::Secondary, pressed: false, modifiers: Default::default() });
h.run_steps(2);
// 3. Open "Arrange image" submenu and click "Bring to front"
h.get_by_label("Arrange image").click();
h.run_steps(2);
h.get_by_label("Bring to front").click();
h.run_steps(3);
// 4. Selection must be preserved after arrange: B is now at index 1 (in front)
let sel = h.state().views[0].image_selection.as_ref().map(|s| s.index);
assert_eq!(sel, Some(1), "selection must track B to index 1 after arrange");
// 5. Right-click on the selected image (B at index 1) again to open context menu
let sel_idx = sel.unwrap();
let xf_again = h.state().views[0].page_xform(0).expect("page 0 xform");
let cur_imgs = h.state().session.get(id).unwrap().page_images(0);
let b_rect = pdfcraft_ui_egui::canvas::image_screen_rect(&cur_imgs[sel_idx], &xf_again, &h.state().session.get(id).unwrap().info, 0);
let p_b2 = b_rect.center();
h.hover_at(p_b2);
h.run_steps(1);
h.event(egui::Event::PointerButton { pos: p_b2, button: egui::PointerButton::Secondary, pressed: true, modifiers: Default::default() });
h.event(egui::Event::PointerButton { pos: p_b2, button: egui::PointerButton::Secondary, pressed: false, modifiers: Default::default() });
h.run_steps(2);
// 6. Click "Delete" in context menu
h.get_by_label("Delete").click();
h.run_steps(3);
// 7. Save, reopen and verify: A remains at [20, 20, 40, 40], B is deleted
let saved = h.state().session.save_bytes(id).unwrap();
let mut reopened = pdfcraft_engine::Session::new();
let rid = reopened.open("again.pdf", None, saved, None).unwrap();
let imgs = reopened.get(rid).unwrap().page_images(0);
assert_eq!(imgs.len(), 1, "exactly one image must remain after deleting B");
assert!((imgs[0].rect[0] - 20.0).abs() < 1e-2, "remaining image must be A at [20, 20, 40, 40], was {:?}", imgs[0].rect);
}
/// Esc in an open paragraph leaves its text and selects its box, as in Acrobat, so Esc and
/// Delete remove it without aiming for the thin frame; the unapplied draft is discarded.
#[test]
fn escape_in_an_open_paragraph_selects_its_box_for_delete() {
for draft in [None, Some("Page one")] {
let mut h = harness(1, |_| {});
assert!(h.state_mut().execute("edit.edit_text"));
h.run_steps(2);
let id = h.state().views[0].id;
let blocks = |h: &Harness<'static, PdfCraftApp>| h.state().session.get(id).unwrap().text_blocks(0);
let r = h.state().views[0].page_screen_rect(0).expect("on screen");
let k = r.width() / 200.0;
let b = blocks(&h)[0].clone();
click_at(
&mut h,
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),
);
assert!(h.state().views[0].line_editor.is_some(), "a click inside the paragraph opens it for typing");
if let Some(text) = draft {
h.state_mut().views[0].line_editor.as_mut().unwrap().text = text.into();
h.run_steps(1);
}
press(&mut h, Modifiers::NONE, Key::Escape);
assert!(h.state().views[0].line_editor.is_none(), "Esc leaves the text");
h.get_by_label("1 object selected");
assert_eq!(blocks(&h)[0].text, "Page 1", "the draft {draft:?} is discarded");
press(&mut h, Modifiers::NONE, Key::Delete);
assert!(blocks(&h).is_empty(), "Esc then Delete removes the paragraph: {:?}", blocks(&h));
// One undo step brings it back.
assert!(h.state_mut().execute("edit.undo"));
h.run_steps(2);
assert_eq!(blocks(&h)[0].text, "Page 1");
}
}
#[test]
fn outward_image_grip_drags_grow_on_rotated_pages() {
for rotation in [0, 90, 180, 270] {
for image_turn in [0.0, 25.0] {
for grip in 0..8 {
let mut png = Vec::new();
image::RgbImage::from_pixel(100, 80, image::Rgb([100, 150, 200]))
.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("grips.png", None, png.clone()).unwrap();
app
});
h.run_steps(4);
h.state_mut().apply_edit(pdfcraft_engine::Edit::EditPageImage {
page: 0,
index: 0,
change: pdfcraft_engine::ImageEdit::Move([20.0, 20.0, 80.0, 60.0]),
});
h.state_mut().apply_edit(pdfcraft_engine::Edit::RotatePages { pages: vec![0], degrees: rotation });
if image_turn != 0.0 {
h.state_mut().apply_edit(pdfcraft_engine::Edit::EditPageImage {
page: 0,
index: 0,
change: pdfcraft_engine::ImageEdit::RotateBy(image_turn),
});
}
h.state_mut().views[0].fit = pdfcraft_ui_egui::canvas::Fit::Page;
assert!(h.state_mut().execute("edit.edit_text"));
h.state_mut().views[0].image_selection = Some(pdfcraft_ui_egui::ImageSelection::at(0, 0));
h.run_steps(3);
let view = &h.state().views[0];
let doc = h.state().session.get(view.id).unwrap();
let before = doc.page_images(0)[0].clone();
let obb = pdfcraft_ui_egui::canvas::ImageObb::from_image(&before, &view.page_xform(0).unwrap(), &doc.info, 0);
let (handle, start) = obb.handles()[grip];
let opposite = obb.handles()[(grip + 4) % 8].0;
let finish = start + (start - obb.screen_center) * 0.15;
assert!(view.viewport_rect().contains(start) && view.viewport_rect().contains(finish), "test grips must be visible");
h.hover_at(start);
h.run_steps(1);
h.drag_at(start);
h.run_steps(1);
let started = h.state().views[0].image_selection.clone();
h.hover_at(finish);
h.run_steps(2);
let moved = h.state().views[0].image_selection.clone();
h.drop_at(finish);
h.run_steps(3);
let view = &h.state().views[0];
let doc = h.state().session.get(view.id).unwrap();
let images = doc.page_images(0);
let after = &images[0];
let after_obb = pdfcraft_ui_egui::canvas::ImageObb::from_image(after, &view.page_xform(0).unwrap(), &doc.info, 0);
assert!(
after_obb.handle_pos(opposite).distance(obb.handle_pos(opposite)) < 1.0,
"fixed grip moved: rotation={rotation} image_turn={image_turn} grip={grip}"
);
assert!(
after_obb.handle_pos(handle).distance(finish) < 1.0,
"dragged grip missed pointer: rotation={rotation} image_turn={image_turn} grip={grip}"
);
let area = |m: [f64; 6]| (m[0] * m[3] - m[1] * m[2]).abs();
assert!(
area(after.matrix) > area(before.matrix) * 1.05,
"outward grip shrank or ignored: rotation={rotation} image_turn={image_turn} grip={grip}: {:?} => {:?}; started={started:?} moved={moved:?}",
before.matrix,
after.matrix
);
}
}
}
}
#[test]
fn author_image_clip_is_not_offered_as_a_removable_editor_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("author-clip.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].image_selection = Some(pdfcraft_ui_egui::ImageSelection::at(0, 0));
h.run_steps(3);
let view = &h.state().views[0];
let doc = h.state().session.get(view.id).unwrap();
let image = doc.page_images(0)[0].clone();
let visible = image.visible_rect.unwrap();
let xf = view.page_xform(0).unwrap();
let bounds = pdfcraft_ui_egui::canvas::image_screen_rect(&image, &xf, &doc.info, 0);
let p = bounds.center();
h.hover_at(p);
h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Secondary, pressed: true, modifiers: Default::default() });
h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Secondary, pressed: false, modifiers: Default::default() });
h.run_steps(2);
assert!(h.query_all_by_label("Remove crop").next().is_none(), "author clips are not editor crops");
h.get_by_label("Crop").click();
h.run_steps(2);
assert!(h.query_all_by_label("Remove Crop").next().is_none(), "the initial author clip has no removable draft crop");
let mut draft = visible;
draft[0] += (draft[2] - draft[0]) * 0.2;
h.state_mut().views[0].image_selection.as_mut().unwrap().crop.as_mut().unwrap().keep = draft;
h.run_steps(3);
h.get_by_label("Remove Crop").click();
h.run_steps(1);
assert_eq!(h.state().views[0].image_selection.as_ref().unwrap().crop.as_ref().unwrap().keep, visible, "draft reset preserves the author's clip");
h.get_by_label("Done").click();
h.run_steps(2);
assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().page_images(0)[0].visible_rect, Some(visible));
}
fn affine_review_image_fixture(matrix: [f64; 6], clip: Option<[f64; 4]>, rotation: 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).remove(0);
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, 80, 50])));
let mut xobjects = Dict::new();
xobjects.set(b"ImReview".to_vec(), Object::Ref(image));
let mut resources = Dict::new();
resources.set(b"XObject".to_vec(), Object::Dict(xobjects));
let mut content = String::from("q ");
if let Some([x0, y0, x1, y1]) = clip {
content.push_str(&format!("{x0} {y0} {} {} re W n ", x1 - x0, y1 - y0));
}
let [a, b, c, d, e, f] = matrix;
content.push_str(&format!("{a} {b} {c} {d} {e} {f} cm /ImReview Do Q"));
let stream = doc.add(Object::Stream(Stream::flate(Dict::new(), content.as_bytes())));
doc.update_dict(page.obj, |dict| {
dict.set(b"Contents".to_vec(), Object::Ref(stream));
dict.set(b"Resources".to_vec(), Object::Dict(resources));
dict.set(b"MediaBox".to_vec(), Object::Array(vec![Object::Int(0), Object::Int(0), Object::Int(360), Object::Int(360)]));
dict.set(b"Rotate".to_vec(), Object::Int(rotation));
})
.unwrap();
pdfcraft_cos::write_incremental(&doc, &Default::default()).unwrap()
}
fn affine_review_harness(bytes: Vec<u8>) -> Harness<'static, PdfCraftApp> {
let mut h = Harness::builder().with_size(egui::vec2(1200.0, 1000.0)).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("affine-review.pdf", None, bytes.clone()).unwrap();
app
});
h.run_steps(4);
h.state_mut().views[0].fit = pdfcraft_ui_egui::canvas::Fit::Page;
assert!(h.state_mut().execute("edit.edit_text"));
h.state_mut().views[0].image_selection = Some(pdfcraft_ui_egui::ImageSelection::at(0, 0));
h.run_steps(3);
h
}
#[test]
fn affine_review_resize_out_and_back_preserves_saved_placement() {
for (matrix, clip, rotation) in [
([100.0, 0.0, 0.0, 100.0, 100.0, 100.0], None, 0),
([100.0, 0.0, 50.0, 100.0, 100.0, 100.0], None, 0),
([86.60254, 50.0, -50.0, 86.60254, 150.0, 100.0], None, 0),
([100.0, 0.0, 0.0, 100.0, 100.0, 100.0], Some([100.0, 100.0, 150.0, 200.0]), 90),
] {
for offset in [egui::Vec2::ZERO, egui::vec2(3.0, 4.0)] {
let mut h = affine_review_harness(affine_review_image_fixture(matrix, clip, rotation));
let view = &h.state().views[0];
let id = view.id;
let doc = h.state().session.get(id).unwrap();
let original = doc.page_images(0)[0].matrix;
let before = h.state().session.save_bytes(id).unwrap();
let obb = pdfcraft_ui_egui::canvas::ImageObb::from_image(&doc.page_images(0)[0], &view.page_xform(0).unwrap(), &doc.info, 0);
let start = obb.handle_pos(pdfcraft_ui_egui::ImageHandle::TopRight) + offset;
h.hover_at(start);
h.run_steps(1);
h.drag_at(start);
h.run_steps(1);
h.hover_at(start + egui::vec2(30.0, -25.0));
h.run_steps(2);
h.hover_at(start);
h.run_steps(2);
h.drop_at(start);
h.run_steps(3);
let saved = h.state().session.save_bytes(id).unwrap();
assert!(saved == before, "out-and-back must not create an edit: matrix={matrix:?} clip={clip:?} rotation={rotation} offset={offset:?}");
assert_eq!(h.state().session.get(id).unwrap().can_undo(), None);
let mut reopened = pdfcraft_engine::Session::new();
let reopened_id = reopened.open("saved.pdf", None, saved, None).unwrap();
assert_eq!(reopened.get(reopened_id).unwrap().page_images(0)[0].matrix, original);
}
}
}
#[test]
fn affine_review_resize_uses_press_delta_and_affine_frame() {
for (matrix, clip, rotation) in [
([100.0, 0.0, 0.0, 100.0, 100.0, 100.0], None, 0),
([100.0, 0.0, 50.0, 100.0, 100.0, 100.0], None, 0),
([86.60254, 50.0, -50.0, 86.60254, 150.0, 100.0], None, 0),
([100.0, 0.0, 0.0, 100.0, 100.0, 100.0], Some([100.0, 100.0, 150.0, 200.0]), 90),
] {
let mut placements = Vec::new();
for offset in [egui::Vec2::ZERO, egui::vec2(3.0, 4.0)] {
let mut h = affine_review_harness(affine_review_image_fixture(matrix, clip, rotation));
let view = &h.state().views[0];
let id = view.id;
let doc = h.state().session.get(id).unwrap();
let xf = view.page_xform(0).unwrap();
let obb = pdfcraft_ui_egui::canvas::ImageObb::from_image(&doc.page_images(0)[0], &xf, &doc.info, 0);
let grip = obb.handle_pos(pdfcraft_ui_egui::ImageHandle::TopRight);
let start = grip + offset;
let finish = start + (grip - obb.screen_center) * 0.2;
h.hover_at(start);
h.run_steps(1);
h.drag_at(start);
h.run_steps(1);
h.hover_at(finish);
h.run_steps(2);
h.drop_at(finish);
h.run_steps(3);
let saved = h.state().session.save_bytes(id).unwrap();
let mut reopened = pdfcraft_engine::Session::new();
let reopened_id = reopened.open("saved.pdf", None, saved, None).unwrap();
let after = reopened.get(reopened_id).unwrap().page_images(0)[0].clone();
let after_obb = pdfcraft_ui_egui::canvas::ImageObb::from_image(&after, &xf, &reopened.get(reopened_id).unwrap().info, 0);
assert!(
after_obb.handle_pos(pdfcraft_ui_egui::ImageHandle::BottomLeft).distance(obb.handle_pos(pdfcraft_ui_egui::ImageHandle::BottomLeft))
< 0.1,
"opposite affine grip moved: {matrix:?} clip={clip:?} rotation={rotation}"
);
assert!(
after_obb.handle_pos(pdfcraft_ui_egui::ImageHandle::TopRight).distance(grip + (grip - obb.screen_center) * 0.2) < 0.2,
"resized affine frame missed the press-relative target: {matrix:?} clip={clip:?} rotation={rotation} offset={offset:?}"
);
placements.push(after.matrix);
}
assert!(placements[0].into_iter().zip(placements[1]).all(|(a, b)| (a - b).abs() < 1e-3), "press offset changed placement: {placements:?}");
}
}
#[test]
fn affine_review_cropped_rotation_preview_matches_saved_corners() {
let matrix = [70.710678, 70.710678, -70.710678, 70.710678, 170.710678, 100.0];
for (clip, rotation) in [(None, 0), (Some([100.0, 170.0, 241.421356, 241.421356]), 0), (Some([100.0, 170.0, 241.421356, 241.421356]), 90)] {
let mut h = affine_review_harness(affine_review_image_fixture(matrix, clip, rotation));
let view = &h.state().views[0];
let id = view.id;
let doc = h.state().session.get(id).unwrap();
let image = doc.page_images(0)[0].clone();
let xf = view.page_xform(0).unwrap();
let obb = pdfcraft_ui_egui::canvas::ImageObb::from_image(&image, &xf, &doc.info, 0);
let visible = image.visible_rect.unwrap_or(image.rect);
let p = doc.info.pages[0].user_to_view(((visible[0] + visible[2]) * 0.5) as f32, ((visible[1] + visible[3]) * 0.5) as f32);
let centre = xf.norm_to_screen(p[0] / xf.pw, p[1] / xf.ph);
let start = obb.s_rot;
let v = start - centre;
let finish = centre + egui::vec2(-v.y, v.x);
h.hover_at(start);
h.run_steps(1);
h.drag_at(start);
h.run_steps(1);
h.hover_at(finish);
h.run_steps(2);
assert!(h.state().views[0].image_selection.as_ref().unwrap().turning);
let preview = h
.output()
.shapes
.iter()
.find_map(|shape| match &shape.shape {
egui::Shape::Path(path) if path.closed && path.points.len() == 4 && path.stroke.width == 1.5 => Some(path.points.clone()),
_ => None,
})
.expect("rotation preview outline");
if clip.is_some()
&& rotation == 0
&& let Ok(directory) = std::env::var("PDFCRAFT_AFFINE_SHOTS")
{
h.render().unwrap().save(std::path::Path::new(&directory).join("affine-rotation-preview.png")).unwrap();
}
h.drop_at(finish);
h.run_steps(3);
if clip.is_some()
&& rotation == 0
&& let Ok(directory) = std::env::var("PDFCRAFT_AFFINE_SHOTS")
{
h.render().unwrap().save(std::path::Path::new(&directory).join("affine-rotation-committed.png")).unwrap();
}
let saved = h.state().session.save_bytes(id).unwrap();
let mut reopened = pdfcraft_engine::Session::new();
let reopened_id = reopened.open("saved.pdf", None, saved, None).unwrap();
let doc = reopened.get(reopened_id).unwrap();
let after = doc.page_images(0)[0].clone();
let committed = pdfcraft_ui_egui::canvas::ImageObb::from_matrix(after.matrix, &xf, &doc.info, 0).corners();
let (cx, cy) = ((visible[0] + visible[2]) * 0.5, (visible[1] + visible[3]) * 0.5);
let expected = pdfcraft_content::Matrix(image.matrix).then(&pdfcraft_content::Matrix([0.0, -1.0, 1.0, 0.0, cx - cy, cy + cx])).0;
assert!(
after.matrix.into_iter().zip(expected).all(|(a, b)| (a - b).abs() < 1e-3),
"knob must commit the requested 90-degree turn around visible bounds"
);
assert!(
preview.iter().zip(committed).all(|(a, b)| a.distance(b) < 1.0),
"preview jumped after commit/save: clip={clip:?} rotation={rotation}: {preview:?} != {committed:?}"
);
}
}