Files
pdfcraft/crates/ui-egui/tests/it/bookmarks.rs
T
Claude 65d8385616 Merge remote-tracking branch 'upstream/main' into feat/bookmarks-from-text-styles
# Conflicts:
#	crates/automation/src/lib.rs
#	crates/engine/src/lib.rs
#	crates/ui-egui/src/lib.rs
#	crates/ui-egui/src/panels.rs

Main's module layout (#4889) moved what this branch changed: the tools to
automation's dispatch.rs (the levels parser to json.rs), the edit to
engine's edit.rs, edit/labels.rs and edit/run.rs, Document::text_styles
to document.rs, the dialog state to state.rs and app.rs, and the menu
item to panels/right.rs and panels/bookmarks.rs. The dialog module joins
the others in dialogs/. Main's craft-menu bar (#4752) lists its commands
by hand, so bookmark.from_text_styles is added after
bookmark.from_structure.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GbuSKAS2k5248LGFEapMUR
2026-10-27 20:46:00 +00:00

462 lines
20 KiB
Rust

//! Editing bookmarks in the Bookmarks panel (M4.6), driven like a user would.
use egui::{Key, Modifiers};
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use pdfcraft_ui_egui::PdfCraftApp;
const PAGES: &[u8] = b"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R 5 0 R] /Count 3 /MediaBox [0 0 300 400] >> endobj
3 0 obj << /Type /Page /Parent 2 0 R >> endobj
4 0 obj << /Type /Page /Parent 2 0 R >> endobj
5 0 obj << /Type /Page /Parent 2 0 R >> endobj
trailer << /Root 1 0 R >>
%%EOF";
fn harness() -> Harness<'static, PdfCraftApp> {
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("pages.pdf", None, PAGES.to_vec()).expect("opens");
app.set_option("left", "closed").unwrap();
app.set_option("panel", "bookmarks").unwrap();
app
});
h.run_steps(6);
h
}
fn outline(h: &Harness<'static, PdfCraftApp>) -> Vec<(String, Option<usize>, usize)> {
let id = h.state().views[0].id;
fn flat(items: &[pdfcraft_render::OutlineItem], depth: usize, out: &mut Vec<(String, Option<usize>, usize)>) {
for i in items {
out.push((i.title.clone(), i.page, depth));
flat(&i.children, depth + 1, out);
}
}
let mut out = Vec::new();
flat(&h.state().session.get(id).unwrap().info.outline, 0, &mut out);
out
}
/// The inline rename editor (the text field, not its inner text run).
fn field<'a>(h: &'a Harness<'static, PdfCraftApp>, value: &'a str) -> egui_kittest::Node<'a> {
h.query_all_by_value(value).next().unwrap_or_else(|| panic!("no editor showing {value:?}"))
}
/// Add a bookmark with ⌘B at the current page and name it by typing.
fn add(h: &mut Harness<'static, PdfCraftApp>, title: &str) {
h.key_press_modifiers(Modifiers::COMMAND, Key::B);
h.run_steps(3);
field(h, "Untitled"); // the inline editor is open and focused
h.key_press_modifiers(Modifiers::COMMAND, Key::A);
h.run_steps(1);
field(h, "Untitled").type_text(title);
h.run_steps(1);
h.key_press(Key::Enter);
h.run_steps(3);
}
fn menu(h: &mut Harness<'static, PdfCraftApp>, bookmark: &str, item: &str) {
h.get_by_label(bookmark).click_secondary();
h.run_steps(2);
h.get_by_label(item).click();
h.run_steps(3);
}
#[test]
fn new_rename_reorder_indent_delete_and_undo() {
let mut h = harness();
h.get_by_label("This document has no bookmarks.");
add(&mut h, "Intro");
h.state_mut().set_option("page", "3").unwrap();
h.run_steps(3);
add(&mut h, "Results");
assert_eq!(outline(&h), [("Intro".into(), Some(0), 0), ("Results".into(), Some(2), 0)]);
menu(&mut h, "Results", "Indent");
assert_eq!(outline(&h), [("Intro".into(), Some(0), 0), ("Results".into(), Some(2), 1)]);
h.get_by_label("Results"); // its new parent opened, so it still shows
menu(&mut h, "Results", "Outdent");
menu(&mut h, "Results", "Move up");
assert_eq!(outline(&h)[0].0, "Results");
menu(&mut h, "Intro", "Rename");
field(&h, "Intro");
h.key_press_modifiers(Modifiers::COMMAND, Key::A);
h.run_steps(1);
field(&h, "Intro").type_text("Introduction");
h.key_press(Key::Enter);
h.run_steps(3);
assert_eq!(outline(&h)[1].0, "Introduction");
menu(&mut h, "Results", "Delete");
assert_eq!(outline(&h), [("Introduction".into(), Some(0), 0)]);
h.key_press_modifiers(Modifiers::COMMAND, Key::Z);
h.run_steps(3);
assert_eq!(outline(&h).len(), 2, "delete is undoable");
assert!(h.state().session.get(h.state().views[0].id).unwrap().dirty);
}
/// The side panel's width as egui keeps it from one frame to the next.
fn panel_width(h: &Harness<'static, PdfCraftApp>) -> f32 {
h.ctx.data(|data| data.get_temp::<egui::Rect>(egui::Id::unique("pdfcraft-inspector-body"))).expect("the inspector body is shown").width()
}
#[test]
fn the_panel_keeps_its_width_while_the_pointer_moves() {
// The search row was 2 px wider than the panel, and a resizable panel keeps the width its
// content used, so every repaint (any pointer move) widened it until it reached its maximum.
let mut h = harness();
add(&mut h, "Intro");
h.get_by_label("Search"); // the search row shows once there are bookmarks
let before = panel_width(&h);
for i in 0..30 {
h.hover_at(egui::pos2(300.0 + 10.0 * i as f32, 400.0));
h.run_steps(1);
}
assert_eq!(panel_width(&h), before, "the Bookmarks panel drifted wider");
}
#[test]
fn escape_cancels_a_rename() {
let mut h = harness();
add(&mut h, "Keep");
menu(&mut h, "Keep", "Rename");
field(&h, "Keep").type_text("zzz");
h.key_press(Key::Escape);
h.run_steps(3);
assert_eq!(outline(&h)[0].0, "Keep");
}
#[test]
fn number_pages_dialog_labels_the_selected_pages() {
let mut h = harness();
h.state_mut().views[0].select_pages(&[0, 1]);
assert!(h.state_mut().execute("page.number"));
h.run_steps(3);
h.get_by_label("Number pages");
h.get_by_value("1, 2, 3").click(); // the style menu
h.run_steps(2);
h.get_by_label("i, ii, iii").click();
h.run_steps(2);
h.get_by_label_contains("Labels: i, ii");
h.get_by_label("OK").click();
h.run_steps(3);
let id = h.state().views[0].id;
let labels: Vec<String> = h.state().session.get(id).unwrap().info.pages.iter().map(|p| p.label.clone()).collect();
assert_eq!(labels, ["i", "ii", "3"]);
assert_eq!(h.state().session.get(id).unwrap().can_undo(), Some("Number pages"));
}
#[test]
fn search_finds_collapsed_bookmarks_and_keeps_original_paths() {
use pdfcraft_engine::Edit;
let mut h = harness();
for (parent, index, title, page) in
[(vec![], 0, "Unrelated", 0), (vec![], 1, "Reports", 0), (vec![1], 0, "Other chapter", 1), (vec![1], 1, "Résumé results", 2)]
{
h.state_mut().apply_edit(Edit::AddBookmark { parent, index, title: title.into(), page, left: None, top: None });
}
h.run_steps(3);
h.get_by_label("Bookmark options").click();
h.run_steps(2);
h.get_by_label("Collapse all bookmarks").click();
h.run_steps(3);
assert!(h.query_by_label("Résumé results").is_none());
let id = h.state().views[0].id;
// A full save stamps /ModDate from the clock: pin it, or two saves a second apart differ.
let session = std::mem::take(&mut h.state_mut().session);
h.state_mut().session = session.with_clock(|| 1_700_000_000);
let before = h.state().session.save_full_bytes(id).unwrap();
h.get_by_label("Search").click();
h.run_steps(1);
h.get_by_label("Search").type_text("RÉSUMÉ");
h.run_steps(3);
h.get_by_label("Reports");
h.get_by_label("Résumé results").click();
h.run_steps(3);
assert_eq!(h.state().views[0].current, 2);
assert!(h.query_by_label("Unrelated").is_none());
assert!(h.query_by_label("Other chapter").is_none());
assert_eq!(h.state().session.save_full_bytes(id).unwrap(), before, "search doesn't edit the PDF");
if let Ok(dir) = std::env::var("PDFCRAFT_BOOKMARK_SHOTS") {
let _gpu = crate::gpu();
h.render().unwrap().save(std::path::Path::new(&dir).join("filtered-bookmarks.png")).unwrap();
}
menu(&mut h, "Résumé results", "Delete");
assert_eq!(outline(&h).len(), 3);
assert!(outline(&h).iter().any(|b| b.0 == "Other chapter"));
h.get_by_label("No matches.");
add(&mut h, "New section");
assert!(outline(&h).iter().any(|b| b.0 == "New section"), "new bookmarks remain nameable while searching");
h.get_by_label("No matches.");
h.get_by_label("Clear").click();
h.run_steps(3);
h.get_by_label("Unrelated");
assert!(h.query_by_label("Other chapter").is_none(), "search did not change saved expansion state");
}
#[test]
fn bookmark_search_is_per_document() {
let mut h = harness();
add(&mut h, "First document");
h.get_by_label("Search").click();
h.run_steps(1);
h.get_by_label("Search").type_text("not found");
h.run_steps(3);
h.get_by_label("No matches.");
h.state_mut().open_bytes("second.pdf", None, PAGES.to_vec()).unwrap();
h.run_steps(3);
add(&mut h, "Second document");
h.get_by_label("Second document");
h.state_mut().active = Some(0);
h.run_steps(3);
h.get_by_label("No matches.");
h.get_by_label("Clear").click();
h.run_steps(3);
h.get_by_label("First document");
}
#[test]
fn new_bookmarks_from_structure_nest_the_tagged_headings() {
const TAGGED: &[u8] = b"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R /StructTreeRoot 5 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 300 400] >> endobj
3 0 obj << /Type /Page /Parent 2 0 R >> endobj
4 0 obj << /Type /Page /Parent 2 0 R >> endobj
5 0 obj << /Type /StructTreeRoot /K [6 0 R 7 0 R] >> endobj
6 0 obj << /S /H1 /Pg 3 0 R /ActualText (Overview) >> endobj
7 0 obj << /S /H2 /Pg 4 0 R /ActualText (Method) >> endobj
trailer << /Root 1 0 R >>
%%EOF";
let mut h = harness();
h.state_mut().open_bytes("tagged.pdf", None, TAGGED.to_vec()).unwrap();
h.run_steps(3);
h.get_by_label("Bookmark options").click();
h.run_steps(2);
h.get_by_label("New bookmarks from structure").click();
h.run_steps(3);
let id = h.state().active.and_then(|i| h.state().views.get(i)).unwrap().id;
let titles: Vec<String> = h.state().session.get(id).unwrap().info.outline.iter().map(|o| o.title.clone()).collect();
assert_eq!(titles, ["Untitled"]);
h.get_by_label("Overview");
h.get_by_label("Method");
assert_eq!(h.state().session.get(id).unwrap().can_undo(), Some("New bookmarks from structure"));
if let Ok(dir) = std::env::var("PDFCRAFT_BOOKMARK_SHOTS") {
let _gpu = crate::gpu();
h.render().unwrap().save(std::path::Path::new(&dir).join("bookmarks-from-structure.png")).unwrap();
}
}
/// Two pages whose chapter and section headings are set apart only by style (#1693).
fn styled_pdf() -> Vec<u8> {
let page1 = "BT /FB 18 Tf 72 700 Td (Introduction) Tj ET BT /FR 11 Tf 72 670 Td (Some text) Tj ET BT /FB 14 Tf 72 640 Td (Background) Tj ET";
let page2 = "BT /FB 18 Tf 72 700 Td (Results) Tj ET BT /FR 11 Tf 72 670 Td (More text) Tj ET";
format!(
"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 612 792] /Resources << /Font << /FB 7 0 R /FR 8 0 R >> >> >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /Contents 5 0 R >> endobj
4 0 obj << /Type /Page /Parent 2 0 R /Contents 6 0 R >> endobj
5 0 obj << /Length {} >>
stream
{page1}
endstream
endobj
6 0 obj << /Length {} >>
stream
{page2}
endstream
endobj
7 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >> endobj
8 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> endobj
trailer << /Root 1 0 R >>
%%EOF",
page1.len(),
page2.len()
)
.into_bytes()
}
/// #1693: Bookmark options ▸ New bookmarks from text styles… lists the document's styles with a
/// sample, offers the largest for level 1, and Create makes the bookmarks (sections under
/// their chapter) as one undo step. Cancel changes nothing; a document without text says so.
#[test]
fn new_bookmarks_from_text_styles_from_the_bookmark_options() {
let mut h = harness();
h.state_mut().open_bytes("styled.pdf", None, styled_pdf()).unwrap();
h.run_steps(3);
let id = h.state().active.and_then(|i| h.state().views.get(i)).unwrap().id;
let open = |h: &mut Harness<'static, PdfCraftApp>| {
h.get_by_label("Bookmark options").click();
h.run_steps(2);
h.get_by_label("New bookmarks from text styles…").click();
h.run_steps(3);
};
open(&mut h);
assert_eq!(h.state().dialog, Some(pdfcraft_ui_egui::Dialog::BookmarksFromTextStyles));
assert_eq!(h.state().text_style_draft.levels, [Some(0), None, None, None], "the largest style is offered for level 1");
// Cancel: nothing made.
h.get_by_label("Cancel").click();
h.run_steps(2);
assert_eq!(h.state().dialog, None);
assert!(h.state().session.get(id).unwrap().info.outline.is_empty());
// Level 2 from its list: the sections' style.
open(&mut h);
// Each level's list is named after it, for screen readers too.
h.get_by_role_and_label(egui::accesskit::Role::ComboBox, "Level 2").click();
h.run_steps(2);
// It lists every style, by font, size, weight and a sample.
h.get_by_label("Helvetica-Bold, 18 pt, bold · “Introduction” (2 lines)");
h.get_by_label("Helvetica, 11 pt · “Some text” (2 lines)");
h.run_steps(2);
h.get_by_label("Helvetica-Bold, 14 pt, bold · “Background” (1 line)").click();
h.run_steps(2);
assert_eq!(h.state().text_style_draft.levels, [Some(0), Some(1), None, None]);
h.get_by_label("Create").click();
h.run_steps(3);
assert_eq!(h.state().dialog, None);
let outline = h.state().session.get(id).unwrap().info.outline.clone();
assert_eq!(outline.len(), 1);
assert_eq!(outline[0].title, "Untitled");
let titles: Vec<(&str, Option<usize>)> = outline[0].children.iter().map(|o| (o.title.as_str(), o.page)).collect();
assert_eq!(titles, [("Introduction", Some(0)), ("Results", Some(1))]);
assert_eq!(outline[0].children[0].children[0].title, "Background");
assert_eq!(h.state().session.get(id).unwrap().can_undo(), Some("New bookmarks from text styles"));
assert_eq!(h.state().right, Some(pdfcraft_ui_egui::RightPanel::Bookmarks));
// A document with no text: nothing to choose from.
h.state_mut().open_bytes("pages.pdf", None, PAGES.to_vec()).unwrap();
h.run_steps(3);
open(&mut h);
let message = h.get_by_label("This document has no text to make bookmarks from.").rect();
// The dialog's Close, below the message (the panel has a Close of its own).
h.get_all_by_label("Close").find(|n| n.rect().top() > message.bottom()).expect("the dialog's Close").click();
h.run_steps(2);
assert_eq!(h.state().dialog, None);
// The command (Pages menu, command palette) opens it too.
assert!(h.state_mut().execute("bookmark.from_text_styles"));
h.run_steps(2);
assert_eq!(h.state().dialog, Some(pdfcraft_ui_egui::Dialog::BookmarksFromTextStyles));
}
#[test]
fn expanding_and_collapsing_all_bookmarks() {
use pdfcraft_engine::Edit;
let mut h = harness();
for (parent, title) in [(vec![], "Part"), (vec![0], "Chapter"), (vec![0, 0], "Section")] {
h.state_mut().apply_edit(Edit::AddBookmark { parent, index: 0, title: title.into(), page: 0, left: None, top: None });
}
h.run_steps(3);
let shown = |h: &Harness<'static, PdfCraftApp>, t: &str| h.query_by_label(t).is_some();
h.get_by_label("Bookmark options").click();
h.run_steps(2);
h.get_by_label("Expand all bookmarks").click();
h.run_steps(3);
assert!(shown(&h, "Chapter") && shown(&h, "Section"));
h.get_by_label("Bookmark options").click();
h.run_steps(2);
h.get_by_label("Collapse all bookmarks").click();
h.run_steps(3);
assert!(shown(&h, "Part") && !shown(&h, "Chapter"));
h.get_by_label("Bookmark options").click();
h.run_steps(2);
h.get_by_label("Expand top-level bookmarks").click();
h.run_steps(3);
assert!(shown(&h, "Chapter") && !shown(&h, "Section"));
}
/// #3193: a bookmark's Style and Colour menus make its title bold, italic and coloured
/// (ISO 32000-2 §12.3.3, Tables 151 and 152), each an undoable edit.
#[test]
fn style_and_colour_from_the_bookmark_menu() {
let mut h = harness();
add(&mut h, "Intro");
let look = |h: &Harness<'static, PdfCraftApp>| {
let id = h.state().views[0].id;
let b = &h.state().session.get(id).unwrap().info.outline[0];
(b.bold, b.italic, b.color)
};
let submenu = |h: &mut Harness<'static, PdfCraftApp>, menu: &str, item: &str| {
h.get_by_label("Intro").click_secondary();
h.run_steps(2);
// The submenu arrow is painted as an atom; the accessible name is the menu title.
h.get_by_label(menu).click();
h.run_steps(2);
h.get_by_label(item).click();
h.run_steps(3);
};
submenu(&mut h, "Style", "Bold");
assert_eq!(look(&h), (true, false, None));
submenu(&mut h, "Style", "Italic");
assert_eq!(look(&h), (true, true, None));
submenu(&mut h, "Colour", "Red");
let (bold, italic, color) = look(&h);
assert!(bold && italic, "the colour keeps the style");
let [r, g, b] = color.expect("coloured");
assert!((r - 0.89).abs() < 0.01 && (g - 0.13).abs() < 0.01 && (b - 0.13).abs() < 0.01, "{color:?}");
submenu(&mut h, "Style", "Bold");
assert!(!look(&h).0, "bold again turns it off");
h.key_press_modifiers(Modifiers::COMMAND, Key::Z);
h.run_steps(3);
assert!(look(&h).0, "undoable");
}
/// Top-level bookmarks open as the file says, like every other level: a positive /Count shows
/// the children, a negative one hides them until the reader opens it (ISO 32000-2 §12.3.3,
/// Table 151). They used to open regardless.
#[test]
fn a_closed_top_level_bookmark_opens_closed() {
const OUTLINED: &[u8] = b"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R /Outlines 10 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >> endobj
3 0 obj << /Type /Page /Parent 2 0 R >> endobj
10 0 obj << /Type /Outlines /First 11 0 R /Last 13 0 R /Count 5 >> endobj
11 0 obj << /Title (Closed parent) /Parent 10 0 R /Next 12 0 R /First 21 0 R /Last 22 0 R /Count -2 /Dest [3 0 R /Fit] >> endobj
12 0 obj << /Title (Open parent) /Parent 10 0 R /Prev 11 0 R /Next 13 0 R /First 23 0 R /Last 24 0 R /Count 2 /Dest [3 0 R /Fit] >> endobj
13 0 obj << /Title (Uncounted parent) /Parent 10 0 R /Prev 12 0 R /First 25 0 R /Last 25 0 R /Dest [3 0 R /Fit] >> endobj
21 0 obj << /Title (Closed child A) /Parent 11 0 R /Next 22 0 R /Dest [3 0 R /Fit] >> endobj
22 0 obj << /Title (Closed child B) /Parent 11 0 R /Prev 21 0 R /Dest [3 0 R /Fit] >> endobj
23 0 obj << /Title (Open child A) /Parent 12 0 R /Next 24 0 R /Dest [3 0 R /Fit] >> endobj
24 0 obj << /Title (Open child B) /Parent 12 0 R /Prev 23 0 R /Dest [3 0 R /Fit] >> endobj
25 0 obj << /Title (Uncounted child) /Parent 13 0 R /Dest [3 0 R /Fit] >> endobj
trailer << /Root 1 0 R >>
%%EOF";
let mut h = harness();
h.state_mut().open_bytes("outlined.pdf", None, OUTLINED.to_vec()).unwrap();
h.run_steps(4);
let shown = |h: &Harness<'static, PdfCraftApp>, t: &str| h.query_by_label(t).is_some();
assert!(shown(&h, "Closed parent") && shown(&h, "Open parent") && shown(&h, "Uncounted parent"));
assert!(!shown(&h, "Closed child A") && !shown(&h, "Closed child B"), "a negative /Count opens closed");
assert!(shown(&h, "Open child A") && shown(&h, "Open child B"), "a positive /Count opens open");
assert!(!shown(&h, "Uncounted child"), "only a positive /Count opens a bookmark");
// The disclosure triangle at the row's left still opens and closes it.
let toggle = |h: &mut Harness<'static, PdfCraftApp>, title: &str| {
let row = h.get_by_label(title).rect();
let at = egui::pos2(row.left() + 8.0, row.top() + 14.0);
h.hover_at(at);
h.run_steps(1);
h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(3);
};
toggle(&mut h, "Closed parent");
assert!(shown(&h, "Closed child A") && shown(&h, "Closed child B"), "opened by the reader");
toggle(&mut h, "Closed parent");
assert!(!shown(&h, "Closed child A"), "closed again");
toggle(&mut h, "Open parent");
assert!(!shown(&h, "Open child A"), "an open bookmark closes");
// A search still shows the matches inside a closed bookmark, and clearing it restores it.
h.get_by_label("Search").click();
h.run_steps(1);
h.get_by_label("Search").type_text("child B");
h.run_steps(3);
assert!(shown(&h, "Closed child B") && shown(&h, "Open child B"));
h.get_by_label("Clear").click();
h.run_steps(3);
assert!(!shown(&h, "Closed child B") && !shown(&h, "Open child B"), "search leaves the open state alone");
}