mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-27 03:27:21 -06:00
* M7: the Edit tab turns on Edit text & images, as in Acrobat Entering Edit boxes every paragraph and image on the page at once, so text is editable with one click instead of first finding Edit text & images in the tool list. The floating toolbar gets an Edit text & images button while Edit is open or the tool is in use. Leaving Edit (or turning the tool off there) applies a paragraph still open in the editor, as clicking away from it does; left open, its page kept showing the paragraph's text removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * M7: parity notes for the Edit tab turning on Edit text & images Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * M7: the panel-reveal test looks for the Edit panel's own row With Edit text & images on, its name is on the floating toolbar as well as in the panel, so the label alone matched twice and no longer showed that the Tools tab was revealed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * M7: lay out the Edit panel as Acrobat's Under the Edit tab the panel is titled Edit, with Preferences and Close; Modify page is a row of icon buttons (rotate, insert, delete, extract) and Organize pages; Add content lists Text, Image, Header and footer, Watermark and Link, with the rest under More; Other options links Combine files, Redact a PDF and Prepare a form. The milestone banner and Ready chips are gone; a planned item is muted and says when it ships. Every row still comes from the catalogue. Edit text & images is on the floating toolbar. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * M7: Organize pages in the Edit panel opens the Organize workspace It ran page.organize, which showed the page grid behind the Edit panel; Acrobat replaces the panel with Organize pages' own. It now selects the Organize tool, so its panel and the grid open together. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * M7: translate the Edit panel's Less and Other options Every catalogue gets the two strings the Acrobat-style Edit panel adds. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore(i18n): refresh the catalogue hashes in ATTRIBUTION.toml Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * M7: no idle selection hint above Format text in the Edit panel "Shift-click to select several objects. Drag empty space to select an area." sat at the top of the Edit panel whenever Edit text & images was on, pushing Format text down; Acrobat's panel has no such line. The selection count and how to move it still show while objects are selected. Tests that read the hint as "nothing selected" check that no selection count shows instead. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * M7: let auto-deref find the Edit panel's buttons (clippy) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * M7: the Edit panel keeps main's Add / Update / Remove menu Main gave Header and footer, Watermark and Background a menu of Add, Update and Remove in the tool panel (#1857). This branch draws the Edit group in its own panel, so those rows ran only Add and Update / Remove were unreachable there. Both panels now share one helper for the caret and the menu. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Edit panel: Modify page buttons follow command enablement; Organize pages with no document asks for a file CodeRabbit on #2245: the four Modify page buttons were enabled from the catalogue (always Ready) while execute() refuses them on a document that forbids page assembly; they now use command_enabled. Organize pages with no document open selected a panel with no grid to show; it now runs page.organize, which asks for a file first. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> --------- Co-authored-by: abc2cb <271337822+abc2cb@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
393 lines
15 KiB
Rust
393 lines
15 KiB
Rust
//! Multi-object editing through the real shell. Synthetic artwork and standard-font text only.
|
|
use egui::{Key, Modifiers, Pos2};
|
|
use egui_kittest::Harness;
|
|
use egui_kittest::kittest::Queryable;
|
|
use pdfcraft_engine::{AddedText, Edit, EditableObject};
|
|
use pdfcraft_ui_egui::PdfCraftApp;
|
|
|
|
fn fixture(clipping: bool) -> Vec<u8> {
|
|
fixture_with_background(clipping, false)
|
|
}
|
|
|
|
fn fixture_with_background(clipping: bool, background: bool) -> Vec<u8> {
|
|
let stream = |body: &str, dict: &str| format!("<< {dict} /Length {} >>\nstream\n{body}\nendstream", body.len());
|
|
let mut objects = 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 400 500] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> /XObject << /Figure 6 0 R >> >> >>"
|
|
.into(),
|
|
stream(
|
|
&format!(
|
|
"{}q BT {} Tr /F1 18 Tf 40 430 Td (Group headline) Tj ET Q q 1 0 0 1 40 340 cm /Figure Do Q BT /F1 12 Tf 240 90 Td (Keep here) Tj ET",
|
|
if background { "q /Background Do Q " } else { "" },
|
|
if clipping { 4 } else { 0 }
|
|
),
|
|
"",
|
|
),
|
|
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(),
|
|
stream("1 0.2 0.1 rg 0 0 60 30 re f", "/Type /XObject /Subtype /Form /BBox [0 0 60 30]"),
|
|
];
|
|
if background {
|
|
objects[2] = objects[2].replace("/Figure 6 0 R", "/Figure 6 0 R /Background 7 0 R");
|
|
objects.push(stream("1 1 1 rg 0 0 400 500 re f", "/Type /XObject /Subtype /Form /BBox [0 0 400 500]"));
|
|
}
|
|
let mut out = b"%PDF-1.7\n".to_vec();
|
|
let mut offsets = Vec::new();
|
|
for (i, object) in objects.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", objects.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", objects.len() + 1).as_bytes());
|
|
out
|
|
}
|
|
|
|
fn harness() -> Harness<'static, PdfCraftApp> {
|
|
harness_with(false)
|
|
}
|
|
|
|
fn harness_with(clipping: bool) -> Harness<'static, PdfCraftApp> {
|
|
harness_document(clipping, false)
|
|
}
|
|
|
|
fn harness_document(clipping: bool, background: bool) -> 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.session = pdfcraft_engine::Session::new().with_clock(|| 1_700_000_000);
|
|
app.set_option("language", "en").unwrap();
|
|
app.open_bytes("group.pdf", None, fixture_with_background(clipping, background)).unwrap();
|
|
let id = app.views[0].id;
|
|
app.session
|
|
.apply(
|
|
id,
|
|
Edit::AddText { page: 0, text: AddedText { text: "Added note".into(), rect: [130.0, 340.0, 230.0, 370.0], ..Default::default() } },
|
|
)
|
|
.unwrap();
|
|
let saved = app.session.save_bytes(id).unwrap();
|
|
app.session.mark_saved(id, saved, None).unwrap();
|
|
app.set_option("zoom", "100").unwrap();
|
|
assert!(app.execute("edit.edit_text"));
|
|
app
|
|
});
|
|
for _ in 0..60 {
|
|
h.run_steps(2);
|
|
if !h.state().render_pending() {
|
|
break;
|
|
}
|
|
std::thread::sleep(std::time::Duration::from_millis(5));
|
|
}
|
|
h
|
|
}
|
|
|
|
fn at(h: &Harness<'static, PdfCraftApp>, x: f32, y: f32) -> Pos2 {
|
|
let r = h.state().views[0].page_screen_rect(0).unwrap();
|
|
r.min + egui::vec2(x, y) * (r.width() / 400.0)
|
|
}
|
|
fn objects(h: &Harness<'static, PdfCraftApp>) -> Vec<EditableObject> {
|
|
let app = h.state();
|
|
app.session.get(app.views[0].id).unwrap().editable_objects(0).unwrap()
|
|
}
|
|
fn click(h: &mut Harness<'static, PdfCraftApp>, p: Pos2, mods: Modifiers) {
|
|
h.hover_at(p);
|
|
h.run_steps(2);
|
|
for pressed in [true, false] {
|
|
h.event_modifiers(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: mods }, mods);
|
|
}
|
|
h.run_steps(4);
|
|
}
|
|
fn marquee(h: &mut Harness<'static, PdfCraftApp>) {
|
|
let (a, b) = (at(h, 25.0, 45.0), at(h, 235.0, 175.0));
|
|
h.hover_at(a);
|
|
h.run_steps(2);
|
|
h.drag_at(a);
|
|
h.run_steps(2);
|
|
h.hover_at(b);
|
|
h.run_steps(2);
|
|
h.drop_at(b);
|
|
h.run_steps(4);
|
|
h.get_by_label("3 objects selected");
|
|
}
|
|
fn drag(h: &mut Harness<'static, PdfCraftApp>, a: Pos2, b: Pos2) {
|
|
h.hover_at(a);
|
|
h.run_steps(2);
|
|
h.drag_at(a);
|
|
h.run_steps(2);
|
|
h.hover_at(b);
|
|
h.run_steps(2);
|
|
h.drop_at(b);
|
|
h.run_steps(4);
|
|
}
|
|
|
|
#[test]
|
|
fn marquee_and_mixed_group_drag_move_once_and_undo_together() {
|
|
let mut h = harness();
|
|
let before = objects(&h);
|
|
marquee(&mut h);
|
|
let (a, b) = (at(&h, 55.0, 70.0), at(&h, 80.0, 100.0));
|
|
drag(&mut h, a, b);
|
|
let after = objects(&h);
|
|
for old in &before {
|
|
let new = after.iter().find(|o| o.target == old.target).unwrap();
|
|
let shift = if old.text.as_deref() == Some("Keep here") { [0.0, 0.0] } else { [25.0, -30.0] };
|
|
for i in 0..4 {
|
|
assert!((new.rect[i] - old.rect[i] - shift[i % 2]).abs() < 0.01, "{old:?} -> {new:?}");
|
|
}
|
|
}
|
|
h.get_by_label("3 objects selected");
|
|
let app = h.state();
|
|
assert_eq!(app.session.get(app.views[0].id).unwrap().can_undo(), Some("Move objects"));
|
|
if let Ok(path) = std::env::var("PDFCRAFT_GROUP_SHOT") {
|
|
h.render().unwrap().save(path).unwrap();
|
|
}
|
|
h.key_press_modifiers(Modifiers::COMMAND, Key::Z);
|
|
h.run_steps(4);
|
|
assert_eq!(objects(&h), before);
|
|
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"
|
|
);
|
|
h.key_press_modifiers(Modifiers::COMMAND | Modifiers::SHIFT, Key::Z);
|
|
h.run_steps(4);
|
|
assert_eq!(objects(&h), after);
|
|
}
|
|
|
|
#[test]
|
|
fn modifier_clicks_extend_an_ordinary_selection_and_toggle_objects() {
|
|
let mut h = harness();
|
|
let p = at(&h, 55.0, 70.0);
|
|
click(&mut h, p, Modifiers::NONE);
|
|
assert!(h.state().views[0].line_editor.is_some());
|
|
let p = at(&h, 60.0, 145.0);
|
|
click(&mut h, p, Modifiers::SHIFT);
|
|
h.get_by_label("2 objects selected");
|
|
assert!(h.state().views[0].line_editor.is_none());
|
|
let p = at(&h, 155.0, 140.0);
|
|
click(&mut h, p, Modifiers::COMMAND);
|
|
h.get_by_label("3 objects selected");
|
|
let p = at(&h, 60.0, 145.0);
|
|
click(&mut h, p, Modifiers::CTRL);
|
|
h.get_by_label("2 objects selected");
|
|
assert!(!h.state().session.get(h.state().views[0].id).unwrap().dirty);
|
|
h.key_press(Key::Escape);
|
|
h.run_steps(3);
|
|
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"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn escaping_a_group_drag_and_switching_tools_do_not_edit() {
|
|
let mut h = harness();
|
|
let before = objects(&h);
|
|
marquee(&mut h);
|
|
let (a, b) = (at(&h, 55.0, 70.0), at(&h, 90.0, 110.0));
|
|
h.hover_at(a);
|
|
h.run_steps(2);
|
|
h.drag_at(a);
|
|
h.run_steps(2);
|
|
h.hover_at(b);
|
|
h.run_steps(2);
|
|
assert_eq!(objects(&h), before, "only a preview while held");
|
|
h.key_press(Key::Escape);
|
|
h.run_steps(2);
|
|
h.drop_at(b);
|
|
h.run_steps(4);
|
|
assert_eq!(objects(&h), before);
|
|
marquee(&mut h);
|
|
assert!(h.state_mut().execute("edit.text"));
|
|
h.run_steps(4);
|
|
assert_eq!(objects(&h), before);
|
|
}
|
|
|
|
#[test]
|
|
fn a_changed_text_editor_is_kept_when_group_selection_is_requested() {
|
|
let mut h = harness();
|
|
let before = objects(&h);
|
|
let p = at(&h, 55.0, 70.0);
|
|
click(&mut h, p, Modifiers::NONE);
|
|
h.state_mut().views[0].line_editor.as_mut().unwrap().text = "A draft still being edited".into();
|
|
let p = at(&h, 60.0, 145.0);
|
|
click(&mut h, p, Modifiers::SHIFT);
|
|
assert_eq!(h.state().views[0].line_editor.as_ref().unwrap().text, "A draft still being edited");
|
|
assert_eq!(objects(&h), before);
|
|
}
|
|
|
|
#[test]
|
|
fn a_document_change_invalidates_old_selection_before_the_next_drag() {
|
|
let mut h = harness();
|
|
marquee(&mut h);
|
|
let id = h.state().views[0].id;
|
|
h.state_mut()
|
|
.session
|
|
.apply(id, Edit::AddText { page: 0, text: AddedText { text: "A new item".into(), rect: [40.0, 210.0, 150.0, 240.0], ..Default::default() } })
|
|
.unwrap();
|
|
h.run_steps(4);
|
|
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_eq!(objects(&h).len(), 5);
|
|
}
|
|
|
|
#[test]
|
|
fn refusing_one_object_keeps_the_whole_group_and_its_selection() {
|
|
let mut h = harness_with(true);
|
|
let before = objects(&h);
|
|
marquee(&mut h);
|
|
let (a, b) = (at(&h, 55.0, 70.0), at(&h, 80.0, 100.0));
|
|
drag(&mut h, a, b);
|
|
assert_eq!(objects(&h), before);
|
|
h.get_by_label("3 objects selected");
|
|
assert!(h.state().toast.as_ref().unwrap().0.contains("Text used as a clipping path cannot be moved with this selection."));
|
|
assert!(!h.state().session.get(h.state().views[0].id).unwrap().dirty);
|
|
}
|
|
|
|
#[test]
|
|
fn a_group_drag_released_beyond_the_page_finishes_once() {
|
|
let mut h = harness();
|
|
let before = objects(&h);
|
|
marquee(&mut h);
|
|
let (a, b) = (at(&h, 55.0, 70.0), at(&h, 415.0, 100.0));
|
|
drag(&mut h, a, b);
|
|
let after = objects(&h);
|
|
for (old, new) in before.iter().zip(&after) {
|
|
let delta = if old.text.as_deref() == Some("Keep here") { 0.0 } else { 360.0 };
|
|
assert!((new.rect[0] - old.rect[0] - delta).abs() < 0.01);
|
|
}
|
|
h.key_press_modifiers(Modifiers::COMMAND, Key::Z);
|
|
h.run_steps(4);
|
|
assert_eq!(objects(&h), before);
|
|
}
|
|
|
|
#[test]
|
|
fn group_selection_can_be_extended_by_an_additive_marquee() {
|
|
let mut h = harness();
|
|
let p = at(&h, 55.0, 70.0);
|
|
click(&mut h, p, Modifiers::SHIFT);
|
|
h.get_by_label("1 object selected");
|
|
let (a, b) = (at(&h, 25.0, 120.0), at(&h, 235.0, 175.0));
|
|
h.hover_at(a);
|
|
h.run_steps(2);
|
|
h.event_modifiers(
|
|
egui::Event::PointerButton { pos: a, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::SHIFT },
|
|
Modifiers::SHIFT,
|
|
);
|
|
h.run_steps(2);
|
|
h.hover_at(b);
|
|
h.run_steps(2);
|
|
h.drop_at(b);
|
|
h.run_steps(4);
|
|
h.get_by_label("3 objects selected");
|
|
assert!(!h.state().session.get(h.state().views[0].id).unwrap().dirty);
|
|
}
|
|
|
|
#[test]
|
|
fn group_drag_respects_document_rotation_and_view_rotation() {
|
|
for document_rotation in [0, 90, 180, 270] {
|
|
let mut h = harness();
|
|
assert!(h.state_mut().apply_edit(Edit::RotatePages { pages: vec![0], degrees: document_rotation }));
|
|
h.state_mut().views[0].rotate_view(true);
|
|
h.run_steps(4);
|
|
let before = objects(&h);
|
|
let targets: Vec<_> = before.iter().filter(|o| o.text.as_deref() != Some("Keep here")).map(|o| o.target).collect();
|
|
for target in &targets {
|
|
let app = h.state();
|
|
let doc = app.session.get(app.views[0].id).unwrap();
|
|
let o = before.iter().find(|o| o.target == *target).unwrap();
|
|
let p = pdfcraft_ui_egui::canvas::PageXform {
|
|
rect: app.views[0].page_screen_rect(0).unwrap(),
|
|
rot: 90,
|
|
pw: doc.info.pages[0].width,
|
|
ph: doc.info.pages[0].height,
|
|
}
|
|
.user_rect(&doc.info, 0, o.rect.map(|v| v as f32))
|
|
.center();
|
|
click(&mut h, p, Modifiers::SHIFT);
|
|
}
|
|
h.get_by_label("3 objects selected");
|
|
let app = h.state();
|
|
let doc = app.session.get(app.views[0].id).unwrap();
|
|
let xf = pdfcraft_ui_egui::canvas::PageXform {
|
|
rect: app.views[0].page_screen_rect(0).unwrap(),
|
|
rot: 90,
|
|
pw: doc.info.pages[0].width,
|
|
ph: doc.info.pages[0].height,
|
|
};
|
|
let start =
|
|
xf.user_rect(&doc.info, 0, before.iter().find(|o| o.text.as_deref() == Some("Group headline")).unwrap().rect.map(|v| v as f32)).center();
|
|
let end = start + egui::vec2(21.0, 33.0);
|
|
let from = xf.screen_to_view(start);
|
|
let to = xf.screen_to_view(end);
|
|
let delta = doc.object_move_offset(0, [f64::from(to.0 - from.0), f64::from(to.1 - from.1)]).unwrap();
|
|
drag(&mut h, start, end);
|
|
for (old, new) in before.iter().zip(objects(&h)) {
|
|
let expected = if targets.contains(&old.target) { delta } else { [0.0, 0.0] };
|
|
for i in 0..4 {
|
|
assert!((new.rect[i] - old.rect[i] - expected[i % 2]).abs() < 0.01, "rotation {document_rotation}");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn group_drags_cancel_on_focus_loss_tab_switch_home_and_modal_dialogs() {
|
|
for case in 0..5 {
|
|
let mut h = harness();
|
|
let before = objects(&h);
|
|
marquee(&mut h);
|
|
let (a, b) = (at(&h, 55.0, 70.0), at(&h, 90.0, 110.0));
|
|
h.hover_at(a);
|
|
h.run_steps(2);
|
|
h.drag_at(a);
|
|
h.run_steps(2);
|
|
h.hover_at(b);
|
|
h.run_steps(2);
|
|
match case {
|
|
0 => h.input_mut().focused = false,
|
|
1 => {
|
|
h.state_mut().open_bytes("other.pdf", None, fixture(false)).unwrap();
|
|
}
|
|
2 => h.state_mut().active = None,
|
|
3 => h.state_mut().dialog = Some(pdfcraft_ui_egui::Dialog::About),
|
|
_ => h.state_mut().request_document_url("https://example.invalid/", pdfcraft_ui_egui::LinkOrigin::Link),
|
|
}
|
|
h.run_steps(2);
|
|
h.drop_at(b);
|
|
h.run_steps(2);
|
|
h.input_mut().focused = true;
|
|
h.state_mut().active = Some(0);
|
|
h.state_mut().dialog = None;
|
|
h.state_mut().pending_link = None;
|
|
h.run_steps(4);
|
|
assert_eq!(objects(&h), before, "cancelled gesture case {case}");
|
|
assert!(!h.state().session.get(h.state().views[0].id).unwrap().dirty, "case {case}");
|
|
h.get_by_label("3 objects selected");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn modifier_selection_picks_foreground_text_and_added_items_over_background_artwork() {
|
|
let mut h = harness_document(false, true);
|
|
let before = objects(&h);
|
|
assert_eq!(before.len(), 5);
|
|
for (x, y) in [(55.0, 70.0), (150.0, 135.0), (60.0, 145.0)] {
|
|
let p = at(&h, x, y);
|
|
click(&mut h, p, Modifiers::SHIFT);
|
|
}
|
|
h.get_by_label("3 objects selected");
|
|
let (a, b) = (at(&h, 55.0, 70.0), at(&h, 80.0, 100.0));
|
|
drag(&mut h, a, b);
|
|
let after = objects(&h);
|
|
for old in before {
|
|
let new = after.iter().find(|o| o.target == old.target).unwrap();
|
|
let stationary = old.text.as_deref() == Some("Keep here") || (old.target.kind == pdfcraft_engine::ObjectKind::Image && old.target.index == 0);
|
|
let shift = if stationary { [0.0, 0.0] } else { [25.0, -30.0] };
|
|
assert!(new.rect.iter().enumerate().all(|(i, value)| (value - old.rect[i] - shift[i % 2]).abs() < 0.01), "{old:?} -> {new:?}");
|
|
}
|
|
}
|