mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-25 10:39:01 -06:00
* M7 #594: preserve ordinary paragraph drags (source-only GGEO candidate) [skip ci] Route body drags through source-preserving MoveObjects, bind gestures to the document generation, and keep added-content input ownership with the Edit panel open or closed. Add nine real-shell source regressions. Source-only transfer to the owner's fork, explicitly authorized before validation. Independently source-reviewed; formatting, builds, tests, rendering and all gates remain pending on ggeo-ai02. No upstream PR. AI-assisted with OpenAI. * M7 #594: record validated ordinary paragraph drags Format the source-reviewed candidate and record successful GGEO validation: focused UI/engine tests, exact-base source loss, native drag/history/save/reopen, independent Poppler checks, and all eight repository CI gates. Credit: @FredApps * M7: use generic validation wording * Tests: pin the clock where two saves or combines are compared byte for byte Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: FredApps <FredApps@users.noreply.github.com> Co-authored-by: Brandon Thomas <echelon@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
275 lines
11 KiB
Rust
275 lines
11 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)]);
|
|
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::new("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 });
|
|
}
|
|
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") {
|
|
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") {
|
|
h.render().unwrap().save(std::path::Path::new(&dir).join("bookmarks-from-structure.png")).unwrap();
|
|
}
|
|
}
|
|
|
|
#[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 });
|
|
}
|
|
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"));
|
|
}
|