Files
pdfcraft/crates/ui-egui/tests/editing.rs
T
e59c9a793e Fix text editing layout and preserve source PDF font styles (#36)
* Comments: a text box's rectangle follows its edited text

Editing a text box (or typewriter or callout) rewrote /Contents but
kept the old /Rect: the appearance clipped the new text to the old box
and silently dropped lines that no longer fit, while shorter text left
an oversized box. set_contents now re-measures the text with the same
Helvetica widths as at creation and re-fits the box: the wrap width and
top edge stay, the height fits the wrapped lines. A callout's leader
line keeps its place and re-attaches to the moved box (that geometry
moved from set_rect into a shared apply_text_box). A locked box keeps
its text editable, so it re-fits too.

Covered by annot tests (grow and shrink with nothing clipped, callout
leader re-attach, locked box) and a comment_edit round trip through the
automation tools; parity entries cite them.

Co-Authored-By: GLM <noreply@z.ai>

* Polish edit text and images tool

* feat(edit-text): select source font style

* fix(edit-text): preserve detected PDF font styles

* feat(edit-text): support Japanese replacements

* fix(forms): escape non-ASCII format script strings

* Forms: unwrap_or instead of map_or with identity (clippy map_or_identity)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: GLM <noreply@z.ai>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 07:48:23 -04:00

865 lines
35 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Headless UI tests for editing and saving: the organize toolbar, selection, undo/redo keys,
//! save/save-as, the unsaved-changes prompt and editable document properties.
use egui::accesskit::Role;
use egui::{Key, Modifiers};
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use printcraft_render::{PageRenderer, RenderRequest, RequestKind};
use printcraft_ui_egui::{CloseRequest, PrintCraftApp};
/// 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 PrintCraftApp) + 'static) -> Harness<'static, PrintCraftApp> {
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
let mut app = PrintCraftApp::new();
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, PrintCraftApp> {
harness(pages, |app| app.set_option("organize", "on").unwrap())
}
/// Page labels of the active document, read back from its current bytes.
fn page_texts(app: &PrintCraftApp) -> 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, PrintCraftApp>) -> 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!("printcraft-ui-tests-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
dir.join(name)
}
#[test]
fn organize_delete_then_undo_and_redo_with_keys() {
let mut h = organize(3);
h.get_by_label("Page 2").click();
h.run_steps(2);
h.get_by_label_contains("1 page selected");
h.get_by_label("Delete pages (Delete)").click();
h.run_steps(3);
assert_eq!(page_texts(h.state()), ["Page 1", "Page 3"]);
assert!(dirty(&h));
h.get_by_label("doc.pdf (edited)"); // the tab shows unsaved changes
h.key_press_modifiers(Modifiers::COMMAND, Key::Z);
h.run_steps(3);
assert_eq!(page_texts(h.state()), ["Page 1", "Page 2", "Page 3"]);
h.key_press_modifiers(Modifiers::COMMAND | Modifiers::SHIFT, Key::Z);
h.run_steps(3);
assert_eq!(page_texts(h.state()), ["Page 1", "Page 3"]);
}
#[test]
fn shift_click_selects_a_range_and_moves_it() {
let mut h = organize(4);
h.get_by_label("Page 1").click();
h.run_steps(1);
h.get_by_label("Page 2").click_modifiers(Modifiers::SHIFT);
h.run_steps(2);
h.get_by_label_contains("2 pages selected");
h.get_by_label("Move later").click();
h.run_steps(3);
assert_eq!(page_texts(h.state()), ["Page 3", "Page 1", "Page 2", "Page 4"]);
// The moved pages stay selected at their new position, so repeated clicks keep moving them.
assert_eq!(h.state().views[0].selected.iter().copied().collect::<Vec<_>>(), [1, 2]);
h.get_by_label("Move later").click();
h.run_steps(3);
assert_eq!(page_texts(h.state()), ["Page 3", "Page 4", "Page 1", "Page 2"]);
}
#[test]
fn command_click_toggles_and_rotation_applies_to_selection() {
let mut h = organize(3);
h.get_by_label("Page 1").click();
h.run_steps(1);
h.get_by_label("Page 3").click_modifiers(Modifiers::COMMAND);
h.run_steps(2);
h.get_by_label("Rotate clockwise").click();
h.run_steps(3);
let rot: Vec<u16> = {
let app = h.state();
app.session.get(app.views[0].id).unwrap().info.pages.iter().map(|p| p.rotation).collect()
};
assert_eq!(rot, [90, 0, 90]);
}
#[test]
fn delete_is_refused_when_it_would_remove_every_page() {
let mut h = organize(2);
h.state_mut().views[0].select_pages(&[0, 1]);
h.run_steps(2);
h.key_press(Key::Delete);
h.run_steps(3);
assert_eq!(page_texts(h.state()).len(), 2, "a document keeps at least one page");
assert!(!dirty(&h));
}
#[test]
fn insert_blank_page_after_selection() {
let mut h = organize(2);
h.get_by_label("Page 1").click();
h.run_steps(1);
h.get_by_label("Insert a blank page after the selection").click();
h.run_steps(3);
assert_eq!(page_texts(h.state()), ["Page 1", "", "Page 2"]);
let app = h.state();
let info = &app.session.get(app.views[0].id).unwrap().info;
assert_eq!((info.pages[1].width, info.pages[1].height), (200.0, 300.0), "matches the neighbouring page");
}
#[test]
fn save_writes_an_incremental_update_and_clears_dirty() {
let out = temp_path("saved.pdf");
let original = fixture(3);
let target = out.to_string_lossy().into_owned();
let mut h = organize(3);
h.state_mut().save_override = Some(target.clone());
h.get_by_label("Page 3").click();
h.run_steps(1);
h.get_by_label("Rotate counterclockwise").click();
h.run_steps(3);
h.key_press_modifiers(Modifiers::COMMAND, Key::S);
h.run_steps(3);
let saved = std::fs::read(&out).expect("file written");
assert_eq!(&saved[..original.len()], &original[..], "original bytes untouched");
assert!(!dirty(&h));
h.get_by_label("saved.pdf"); // the tab takes the new name, no edited marker
// What was written is what the app now shows.
assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().bytes.as_slice(), saved.as_slice());
let reopened = printcraft_render::inspect(std::sync::Arc::new(saved), None).unwrap();
assert_eq!(reopened.pages[2].rotation, 270);
let _ = std::fs::remove_file(out);
}
#[test]
fn closing_a_dirty_tab_asks_and_cancel_keeps_it() {
let mut h = organize(2);
h.get_by_label("Rotate clockwise").click();
h.run_steps(3);
h.key_press_modifiers(Modifiers::COMMAND, Key::W);
h.run_steps(3);
h.get_by_label_contains("Save changes to “doc.pdf”");
h.get_by_label("Cancel").click();
h.run_steps(3);
assert_eq!(h.state().views.len(), 1, "cancel keeps the document open");
assert!(dirty(&h));
assert!(h.state().close_request.is_none());
h.key_press_modifiers(Modifiers::COMMAND, Key::W);
h.run_steps(3);
h.get_by_label("Don't save").click();
h.run_steps(3);
assert!(h.state().views.is_empty(), "discarding closes the tab");
}
#[test]
fn closing_a_dirty_tab_can_save_first() {
let out = temp_path("closed.pdf");
let mut h = organize(2);
h.state_mut().save_override = Some(out.to_string_lossy().into_owned());
h.get_by_label("Rotate clockwise").click();
h.run_steps(3);
h.state_mut().request_close_tab(0);
h.run_steps(2);
h.get_by_label("Save").click();
h.run_steps(3);
assert!(h.state().views.is_empty());
let saved = printcraft_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 = printcraft_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 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(printcraft_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());
}
#[test]
fn document_properties_edit_is_one_undoable_step() {
let mut h = harness(1, |app| app.set_option("dialog", "properties").unwrap());
let title = h.get_by_role_and_label(Role::TextInput, "Title");
title.focus();
title.type_text("Annual report");
h.run_steps(2);
let author = h.get_by_role_and_label(Role::TextInput, "Author");
author.focus();
author.type_text("Finance team");
h.run_steps(2);
h.get_by_label("OK").click();
h.run_steps(3);
let app = h.state();
let doc = app.session.get(app.views[0].id).unwrap();
assert_eq!(doc.info.title.as_deref(), Some("Annual report"), "inspection sees the new title");
assert_eq!(doc.info_value("Author").as_deref(), Some("Finance team"));
assert_eq!(doc.can_undo(), Some("Change document properties"));
assert!(app.dialog.is_none());
h.key_press_modifiers(Modifiers::COMMAND, Key::Z);
h.run_steps(3);
let app = h.state();
let doc = app.session.get(app.views[0].id).unwrap();
assert_eq!(doc.info.title, None);
assert_eq!(doc.info_value("Author"), None);
}
#[test]
fn cancelling_properties_discards_the_draft() {
let mut h = harness(1, |app| app.set_option("dialog", "properties").unwrap());
let title = h.get_by_role_and_label(Role::TextInput, "Title");
title.focus();
title.type_text("Draft");
h.run_steps(2);
h.get_by_label("Cancel").click();
h.run_steps(3);
assert!(!dirty(&h));
assert!(h.state().props_draft.is_none());
}
#[test]
fn edit_menu_names_the_step_to_undo() {
let mut h = harness(2, |app| {
app.apply_edit(printcraft_engine::Edit::RotatePages { pages: vec![0], degrees: 90 });
});
h.get_by_label("Menu").click();
h.run_steps(2);
h.get_by_label("Edit ⏵").hover(); // submenus open on hover; their labels carry the arrow
h.run_steps(3);
h.get_by_label_contains("Undo Rotate page").click(); // the label includes the shortcut
h.run_steps(3);
let app = h.state();
let doc = app.session.get(app.views[0].id).unwrap();
assert_eq!(doc.can_redo(), Some("Rotate page"));
assert_eq!(doc.info.pages[0].rotation, 0);
}
// ── Combine / insert from file / extract / split ──────────────────────────────────────────────
fn texts_of(app: &PrintCraftApp, 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(printcraft_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.use_files(printcraft_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 in this order");
h.get_by_label("3 pages");
// two.pdf first; one.pdf's pages 3 and 1.
h.get_all_by_label("Move up").last().unwrap().click();
h.run_steps(2);
h.state_mut().combine_draft[1].range = "3, 1".into();
h.run_steps(1);
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());
}
#[test]
fn extract_button_copies_selected_pages_to_a_new_tab() {
let mut h = organize(3);
h.get_by_label("Page 2").click();
h.run_steps(1);
h.get_by_label("Page 3").click_modifiers(Modifiers::COMMAND);
h.run_steps(2);
h.get_by_label("Extract pages to a new document").click();
h.run_steps(3);
h.get_by_label("2 pages selected.");
h.get_all_by_label("Extract").last().unwrap().click();
h.run_steps(3);
let app = h.state();
assert_eq!(app.views.len(), 2);
assert_eq!(texts_of(app, 1), ["Page 2", "Page 3"]);
assert_eq!(texts_of(app, 0).len(), 3, "the original is unchanged");
assert!(!app.session.get(app.views[0].id).unwrap().dirty);
}
#[test]
fn inserting_a_file_goes_after_the_selection_and_undoes() {
let mut h = organize(2);
h.get_by_label("Page 1").click();
h.run_steps(2);
h.state_mut().use_files(printcraft_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 split_dialog_writes_one_file_per_part() {
let dir = temp_path("split-count");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let d = dir.to_string_lossy().into_owned();
let mut h = organize(3);
h.state_mut().export_dir_override = Some(d);
h.get_by_label("Split into files…").click();
h.run_steps(3);
h.get_by_label_contains("Creates 3 files from 3 pages");
h.get_by_label("Split").click();
h.run_steps(3);
let mut names: Vec<String> = std::fs::read_dir(&dir).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect();
names.sort();
assert_eq!(names, ["doc (page 1).pdf", "doc (page 2).pdf", "doc (page 3).pdf"]);
for name in names {
let info = printcraft_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_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 = printcraft_ui_egui::SplitMode::Selection;
h.state_mut().run_command("page.split");
h.run_steps(3);
h.get_by_label_contains("Creates 2 files from 4 pages");
h.get_by_label("Split").click();
h.run_steps(3);
let mut names: Vec<String> = std::fs::read_dir(&dir).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect();
names.sort();
assert_eq!(names, ["doc (pages 1-2).pdf", "doc (pages 3-4).pdf"]);
let _ = std::fs::remove_dir_all(dir);
}
// ── Encrypted documents ───────────────────────────────────────────────────────────────────────
fn protected(user: &str, owner: &str, permissions: i32) -> Vec<u8> {
let mut doc = printcraft_cos::Document::open(std::sync::Arc::new(fixture(2))).unwrap();
doc.set_encryption(&printcraft_cos::NewEncryption {
algorithm: printcraft_cos::Algorithm::Aes256,
user_password: user,
owner_password: owner,
permissions,
encrypt_metadata: true,
seed: [4; 32],
})
.unwrap();
printcraft_cos::write_full(&doc, &Default::default()).unwrap()
}
#[test]
fn password_prompt_opens_and_security_tab_reports_the_details() {
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
let mut app = PrintCraftApp::new();
app.open_bytes("secret.pdf", None, protected("pw", "owner", -1)).unwrap();
app
});
h.run_steps(4);
h.get_by_label_contains("is protected");
let field = h.get_by_role(Role::PasswordInput);
field.focus();
field.type_text("pw");
h.run_steps(2);
h.key_press(Key::Enter);
h.run_steps(4);
assert_eq!(h.state().views.len(), 1);
h.state_mut().set_option("dialog", "properties").unwrap();
h.run_steps(2);
h.get_by_label("Security").click();
h.run_steps(3);
h.get_by_label("AES, 256-bit");
h.get_by_label("User password");
}
#[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 = PrintCraftApp::new();
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!(!dirty(&h));
h.get_by_label("Security settings").click();
h.run_steps(3);
assert!(h.query_all_by_label("Not allowed").count() >= 4);
}
#[test]
fn replace_pages_dialog_swaps_page_content() {
let mut app = PrintCraftApp::new();
app.open_bytes("doc.pdf", None, fixture(3)).unwrap();
app.views[0].select_pages(&[1]);
app.start_replace("other.pdf".into(), fixture(5));
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| app);
h.run_steps(3);
egui_kittest::kittest::Queryable::get_by_label(&h, "Replace Pages");
h.state_mut().replace_draft.as_mut().unwrap().src_from = 5;
h.run_steps(1);
egui_kittest::kittest::Queryable::get_by_label(&h, "OK").click();
h.run_steps(3);
let s = h.state();
let doc = s.session.get(s.views[0].id).unwrap();
assert_eq!(doc.info.pages.len(), 3);
assert_eq!(doc.can_undo(), Some("Replace page"));
}
#[test]
fn extract_options_and_rotate_pages_dialog() {
let dir = temp_path("extract-sep");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let mut h = organize(4);
h.state_mut().export_dir_override = Some(dir.to_string_lossy().into_owned());
h.state_mut().views[0].select_pages(&[1, 2]);
h.state_mut().run_command("page.extract");
h.run_steps(2);
h.state_mut().extract_draft = printcraft_ui_egui::ExtractDraft { separate: true, delete: true };
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 = printcraft_engine::PageParity::Odd;
h.get_by_label("OK").click();
h.run_steps(3);
let s = h.state();
let d = s.session.get(s.views[0].id).unwrap();
assert_eq!(d.info.pages.iter().map(|p| p.rotation).collect::<Vec<_>>(), [90, 0]);
}
#[test]
fn dragging_thumbnails_reorders_pages() {
let mut h = organize(4);
let before = page_texts(h.state());
let grab = |h: &Harness<'static, PrintCraftApp>, label: &str| h.get_by_label(label).rect();
let (from, to) = (grab(&h, "Page 1").center(), grab(&h, "Page 3").right_center() - egui::vec2(10.0, 0.0));
h.hover_at(from);
h.run_steps(1);
h.drag_at(from);
h.run_steps(1);
for k in 1..=5 {
h.hover_at(from + (to - from) * (k as f32 / 5.0));
h.run_steps(1);
}
h.drop_at(to);
h.run_steps(4);
// Page 1 now sits after page 3.
let after = page_texts(h.state());
assert_eq!(after, vec![before[1].clone(), before[2].clone(), before[0].clone(), before[3].clone()], "{after:?}");
assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo(), Some("Move page"));
let sel: Vec<usize> = h.state().views[0].selected.iter().copied().collect();
assert_eq!(sel, vec![2], "the moved page stays selected");
}
#[test]
fn copying_cutting_and_pasting_pages() {
let mut h = organize(4);
let before = page_texts(h.state());
h.state_mut().views[0].select_pages(&[0]);
assert!(h.state_mut().execute("page.copy"));
h.state_mut().views[0].select_pages(&[2]);
assert!(h.state_mut().execute("page.paste"));
h.run_steps(2);
let after = page_texts(h.state());
assert_eq!(after, vec![before[0].clone(), before[1].clone(), before[2].clone(), before[0].clone(), before[3].clone()], "pasted after page 3");
let sel: Vec<usize> = h.state().views[0].selected.iter().copied().collect();
assert_eq!(sel, vec![3], "the pasted page is selected");
// Cut page 2 and paste it at the end.
h.state_mut().views[0].select_pages(&[1]);
assert!(h.state_mut().execute("page.cut"));
h.state_mut().views[0].select_pages(&[3]);
assert!(h.state_mut().execute("page.paste"));
let after = page_texts(h.state());
assert_eq!(after, vec![before[0].clone(), before[2].clone(), before[0].clone(), before[3].clone(), before[1].clone()]);
// Every page can be copied but not cut.
h.state_mut().views[0].select_pages(&[0, 1, 2, 3, 4]);
h.state_mut().execute("page.cut");
assert_eq!(page_texts(h.state()).len(), 5);
}
fn source_font_fixture() -> Vec<u8> {
let body = "BT /F1 18 Tf 20 220 Td (Serif) Tj ET BT /F2 18 Tf 20 120 Td (Mono) Tj ET";
let objs: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R >>".into(),
"<< /Type /Pages /Kids [4 0 R] /Count 1 /MediaBox [0 0 300 300] >>".into(),
"<< /Type /Font /Subtype /Type1 /BaseFont /Times-BoldItalic >>".into(),
"<< /Type /Page /Parent 2 0 R /Contents 7 0 R /Resources << /Font << /F1 3 0 R /F2 5 0 R >> >> >>".into(),
"<< /Type /Font /Subtype /Type1 /BaseFont /Courier-Oblique >>".into(),
"<< /Producer (PrintCraft) >>".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, PrintCraftApp> {
Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
let mut app = PrintCraftApp::new();
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, printcraft_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, printcraft_engine::FontFamily::Courier);
assert!(!ed.look.bold && ed.look.italic, "source style: {:?}", ed.look);
}
#[test]
fn editing_existing_text_in_place() {
let mut h = harness(1, |_| {});
assert!(h.state_mut().execute("edit.edit_text"));
h.run_steps(2);
// The line "Page 1" sits at (20, 150) on a 200 × 300 page, 24 pt.
let r = h.state().views[0].page_screen_rect(0).expect("on screen");
let at = egui::pos2(r.left() + 40.0 / 200.0 * r.width(), r.top() + (300.0 - 158.0) / 300.0 * r.height());
h.hover_at(at);
h.run_steps(1);
h.drag_at(at);
h.run_steps(1);
h.drop_at(at);
h.run_steps(3);
let ed = h.state().views[0].line_editor.clone().expect("the line opens for editing");
assert_eq!((ed.page, ed.block, ed.text.as_str()), (0, 0, "Page 1"));
// A single-line paragraph's box may grow to the page's edge as the text does.
assert!(ed.growth().is_some(), "the editor box may grow");
h.state_mut().views[0].line_editor.as_mut().unwrap().text = "Chapter One".into();
h.run_steps(1);
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::Enter);
h.run_steps(4);
let s = h.state();
let doc = s.session.get(s.views[0].id).unwrap();
assert_eq!(doc.can_undo(), Some("Edit text"));
assert_eq!(doc.text_lines(0)[0].text, "Chapter One");
assert_eq!(texts_of(s, 0), ["Chapter One"], "the page shows it");
}
/// One page whose only line is drawn twice at the same spot (fake bold, as many generated
/// documents do).
fn double_drawn() -> Vec<u8> {
let objs: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R >>".into(),
"<< /Type /Pages /Kids [4 0 R] /Count 1 /MediaBox [0 0 200 300] >>".into(),
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(),
"<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources << /Font << /F1 3 0 R >> >> >>".into(),
format!(
"<< /Length {} >>\nstream\nBT /F1 24 Tf 20 150 Td (Page 1) Tj ET BT /F1 24 Tf 20 150 Td (Page 1) Tj ET\nendstream",
"BT /F1 24 Tf 20 150 Td (Page 1) Tj ET BT /F1 24 Tf 20 150 Td (Page 1) Tj ET".len()
),
];
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
out
}
#[test]
fn editing_a_double_drawn_line_replaces_every_copy() {
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
let mut app = PrintCraftApp::new();
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();
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
let mut app = PrintCraftApp::new();
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].rect;
assert!(h.state_mut().execute("edit.edit_text"));
h.run_steps(2);
let r = h.state().views[0].page_screen_rect(0).expect("on screen");
// Select it, then drag it a quarter of the page to the right.
let c = r.center();
h.hover_at(c);
h.run_steps(1);
h.drag_at(c);
h.run_steps(1);
h.drop_at(c);
h.run_steps(2);
assert!(h.state().views[0].image_selection.is_some(), "selected");
let to = c + egui::vec2(r.width() / 4.0, 0.0);
h.hover_at(c);
h.run_steps(1);
h.drag_at(c);
h.run_steps(1);
for k in 1..=4 {
h.hover_at(c + (to - c) * (k as f32 / 4.0));
h.run_steps(1);
}
h.drop_at(to);
h.run_steps(4);
let doc = h.state().session.get(id).unwrap();
assert_eq!(doc.can_undo(), Some("Move image"));
let moved = doc.page_images(0)[0].rect;
let dx = moved[0] - before[0];
assert!((dx - (before[2] - before[0]) / 4.0).abs() < 1.5, "moved {dx} pt: {before:?} → {moved:?}");
// Delete with the Delete key.
h.key_press(egui::Key::Delete);
h.run_steps(4);
assert!(h.state().session.get(id).unwrap().page_images(0).is_empty());
}
#[test]
fn the_format_panel_restyles_the_paragraph_being_edited() {
let mut h = harness(1, |_| {});
assert!(h.state_mut().execute("edit.edit_text"));
h.run_steps(2);
let r = h.state().views[0].page_screen_rect(0).expect("on screen");
let at = egui::pos2(r.left() + 40.0 / 200.0 * r.width(), r.top() + (300.0 - 158.0) / 300.0 * r.height());
h.hover_at(at);
h.run_steps(1);
h.drag_at(at);
h.run_steps(1);
h.drop_at(at);
h.run_steps(3);
assert!(h.state().views[0].line_editor.is_some());
// The Format text panel shows the paragraph's look; making it bold applies at once.
h.get_by_label("FORMAT TEXT");
h.get_by_label("B").click();
h.run_steps(4);
let s = h.state();
let doc = s.session.get(s.views[0].id).unwrap();
assert_eq!(doc.can_undo(), Some("Edit text"));
assert_eq!(doc.text_blocks(0)[0].base_font, "Helvetica-Bold");
assert!(s.views[0].line_editor.is_some(), "still editing");
// Underline: another step, the text keeps its bold.
h.get_by_label("Underline").click();
h.run_steps(4);
let s = h.state();
let doc = s.session.get(s.views[0].id).unwrap();
assert_eq!(doc.text_blocks(0)[0].base_font, "Helvetica-Bold");
assert!(s.views[0].line_editor.as_ref().is_some_and(|e| e.extras.underline));
}