diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 13d6da88df..7a0976644b 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -768,6 +768,7 @@ pub(crate) struct PageMenuPerms { pub can_annotate: bool, pub can_print: bool, pub can_copy: bool, + pub can_copy_items: bool, pub can_paste: bool, pub has_annotations: bool, } @@ -796,7 +797,7 @@ pub(crate) fn default_page_context_menu(ui: &mut egui::Ui, page: usize, at: [f64 pick( ui.add_enabled( - perms.can_copy, + perms.can_copy_items, egui::Button::new(tl!("Copy")).shortcut_text(crate::commands::shortcut_label(ui.ctx(), crate::commands::COPY)), ), ui, @@ -1692,6 +1693,15 @@ fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame< let image_used = crate::edit_text_ui::image_input(&image_cx, &images, view); !others_own_pointer && (text_used || image_used) }; + if tool == QuickTool::EditText { + if others_own_pointer { + if ptr.pressed().is_some() || resp.clicked() || resp.secondary_clicked() { + view.image_selection = None; + } + } else { + crate::content_ui::sync_image_selection(view, i, &images, added); + } + } let on_link = tool == QuickTool::Link && can_modify && crate::link_ui::page_input(cx, doc_links, view); if tool == QuickTool::Select && resp.secondary_clicked() @@ -2673,7 +2683,8 @@ fn draw_pages( can_assemble: doc.allows_assembly(), can_annotate: allowed, can_print: doc.allows_printing(), - can_copy: can_copy_items, + can_copy, + can_copy_items, can_paste, has_annotations: !info.annotations.is_empty(), }; @@ -3744,17 +3755,18 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { if preparing { crate::prepare::keys(ui.ctx(), view); } + if tool != QuickTool::EditText { + view.image_selection = None; + crate::content_ui::release_image_selection(view, &added); + } if editing_content { - crate::content_ui::keys(ui.ctx(), view); + crate::content_ui::keys(ui.ctx(), view, tool == QuickTool::EditText); } if tool == QuickTool::Link { crate::link_ui::keys(ui.ctx(), view); } else { view.links.selected = None; } - if tool != QuickTool::EditText { - view.image_selection = None; - } if stamp_placed { tool = QuickTool::Select; } diff --git a/crates/ui-egui/src/tools/content_ui.rs b/crates/ui-egui/src/tools/content_ui.rs index 4bc3068c86..f6d7574e25 100644 --- a/crates/ui-egui/src/tools/content_ui.rs +++ b/crates/ui-egui/src/tools/content_ui.rs @@ -542,9 +542,52 @@ pub(crate) fn paint_page( } } +/// The image controller owns image selection; the added-content panel follows the same XObject. +pub(crate) fn sync_image_selection(view: &mut DocView, page: usize, images: &[pdfcraft_engine::PageImage], added: &[Added]) { + let Some(selection) = view.image_selection.as_ref().filter(|selection| selection.page == page) else { return }; + if view.content.draft.is_some() { + return; + } + let object = images + .get(selection.index) + .and_then(|image| image.object) + .filter(|object| images.iter().filter(|image| image.object == Some(*object)).count() == 1); + view.content.selected = object.and_then(|object| { + let mut matches = added + .iter() + .filter(|item| item.page == page) + .enumerate() + .filter(|(_, item)| matches!(&item.content, AddedContent::Image(image) if image.image == object)); + let selected = matches.next().map(|(index, _)| (page, index)); + if matches.next().is_none() { selected } else { None } + }); + view.content.select_added = None; + view.content.grab = None; +} + +/// Leaving the image controller cannot leave a hidden added image owning keyboard actions. +pub(crate) fn release_image_selection(view: &mut DocView, added: &[Added]) { + if view.content.draft.is_some() { + return; + } + let image_selected = view.content.selected.is_some_and(|(page, index)| { + added.iter().filter(|item| item.page == page).nth(index).is_some_and(|item| matches!(item.content, AddedContent::Image(_))) + }); + if image_selected { + view.content.selected = None; + view.content.select_added = None; + view.content.grab = None; + } +} + /// Delete removes the selected item; Escape clears the selection. -pub(crate) fn keys(ctx: &egui::Context, view: &mut DocView) { - if view.content.selected.is_none() || view.content.draft.is_some() || ctx.egui_wants_keyboard_input() { +pub(crate) fn keys(ctx: &egui::Context, view: &mut DocView, image_controller_active: bool) { + if image_controller_active && view.image_selection.is_some() + || matches!(view.pending_edit.as_ref(), Some(Edit::EditPageImage { .. })) + || view.content.selected.is_none() + || view.content.draft.is_some() + || ctx.egui_wants_keyboard_input() + { return; } let (del, esc) = ctx.input(|i| (i.key_pressed(egui::Key::Delete) || i.key_pressed(egui::Key::Backspace), i.key_pressed(egui::Key::Escape))); @@ -1180,35 +1223,34 @@ impl crate::PdfCraftApp { self.session.get(id).and_then(|d| d.info.pages.get(page)).map(|p| (p.width as f64, p.height as f64)).unwrap_or((612.0, 792.0)); let k = ((pw * 0.8) / raw_w.max(1.0)).min((ph * 0.8) / raw_h.max(1.0)).min(1.0); let (w, h) = (raw_w * k, raw_h * k); - Some([at[0] - w * 0.5, at[1] - h * 0.5, at[0] + w * 0.5, at[1] + h * 0.5]) + let Some(info) = self.session.get(id).and_then(|doc| doc.info.pages.get(page)) else { return false }; + let view = info.user_to_view(at[0] as f32, at[1] as f32).map(f64::from); + // Added content uses the page's bottom-left display origin; rendered view space is y-down. + let center = [view[0], f64::from(info.height) - view[1]]; + Some([center[0] - w * 0.5, center[1] - h * 0.5, center[0] + w * 0.5, center[1] + h * 0.5]) } else { None }; + let added_before: std::collections::HashSet<_> = + self.session.get(id).into_iter().flat_map(|doc| doc.added()).filter(|item| item.page == page).map(|item| item.obj).collect(); if self.apply_edit(pdfcraft_engine::Edit::AddImage { page, rect: target_rect, name, bytes: std::sync::Arc::new(bytes) }) { - let added_count = self.session.get(id).map_or(0, |d| d.added().iter().filter(|a| a.page == page).count()); - let added_idx = added_count.saturating_sub(1); - let pasted_idx = self.session.get(id).map_or(0, |d| { - let images = d.page_images(page); - if let Some(target) = target_rect { - images - .iter() - .rposition(|im| { - (im.rect[0] - target[0]).abs() < 1e-2 - && (im.rect[1] - target[1]).abs() < 1e-2 - && (im.rect[2] - target[2]).abs() < 1e-2 - && (im.rect[3] - target[3]).abs() < 1e-2 - }) - .unwrap_or(images.len().saturating_sub(1)) - } else { - images.len().saturating_sub(1) + let selection = self.session.get(id).and_then(|doc| { + let mut inserted = + doc.added().iter().filter(|item| item.page == page).enumerate().filter(|(_, item)| !added_before.contains(&item.obj)); + let (added_idx, item) = inserted.next()?; + if inserted.next().is_some() { + return None; } + let AddedContent::Image(image) = &item.content else { return None }; + let pasted_idx = doc.page_images(page).iter().rposition(|placed| placed.object == Some(image.image))?; + Some((added_idx, pasted_idx)) }); if let Some(v) = self.views.get_mut(i) { v.current = page; - v.content.selected = Some((page, added_idx)); - v.content.select_added = Some((page, added_idx)); - v.image_selection = Some(crate::edit_text_ui::ImageSelection::at(page, pasted_idx)); + v.content.selected = selection.map(|(added, _)| (page, added)); + v.content.select_added = v.content.selected; + v.image_selection = selection.map(|(_, image)| crate::edit_text_ui::ImageSelection::at(page, image)); } self.quick_tool = crate::QuickTool::EditText; self.notify_tr("Image pasted"); diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 221f9b754f..5189548462 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -57,6 +57,7 @@ mod open_action; mod organize_drag; mod pageboxes; mod panel_snapshots; +mod paste_ownership; mod pdf_links; mod pickers; mod pinned_tools; diff --git a/crates/ui-egui/tests/it/paste_ownership.rs b/crates/ui-egui/tests/it/paste_ownership.rs new file mode 100644 index 0000000000..5dc91e051a --- /dev/null +++ b/crates/ui-egui/tests/it/paste_ownership.rs @@ -0,0 +1,304 @@ +//! Context paste coordinates, insertion identity and visible image ownership. +use egui::{Key, Modifiers, PointerButton, Pos2}; +use egui_kittest::{ + Harness, + kittest::{NodeT, Queryable}, +}; +use pdfcraft_engine::{AddedContent, AddedImage, Edit}; +use pdfcraft_ui_egui::{Clip, PdfCraftApp}; + +fn fixture(rotation: u16, cropped: bool, shared: bool) -> Vec { + fixture_at(rotation, cropped, shared, false) +} +fn fixture_at(rotation: u16, cropped: bool, shared: bool, collision: bool) -> Vec { + let stream = |body: &str, dict: &str| format!("<< {dict} /Length {} >>\nstream\n{body}\nendstream", body.len()); + let (media, crop, x, y) = if cropped { ("0 0 600 800", "150 250 450 550", 170, 270) } else { ("0 0 300 300", "0 0 300 300", 20, 20) }; + let (x, y, w, height) = if collision { (60, 80, 40, 20) } else { (x, y, 30, 30) }; + let extra = if shared { format!(" q 30 0 0 30 {} {y} cm /RedAgain Do Q", x + 50) } else { String::new() }; + let objects = [ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + format!( + "<< /Type /Page /Parent 2 0 R /MediaBox [{media}] /CropBox [{crop}] /Rotate {rotation} /Resources << /XObject << /Red 5 0 R /RedAgain 5 0 R >> >> /Contents 4 0 R >>" + ), + stream(&format!("q {w} 0 0 {height} {x} {y} cm /Red Do Q{extra}"), ""), + stream("FF0000>", "/Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /ASCIIHexDecode"), + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (index, object) in objects.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", index + 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(bytes: Vec) -> Harness<'static, PdfCraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 1000.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.system_clipboard = false; + app.open_bytes("ownership.pdf", None, bytes.clone()).unwrap(); + app.clipboard_override = Some(Clip::Image { width: 40, height: 20, rgba: [0, 0, 255, 255].repeat(800) }); + app + }); + h.run_steps(4); + h +} + +fn point(h: &Harness<'static, PdfCraftApp>, user: [f64; 2]) -> Pos2 { + let app = h.state(); + let info = &app.session.get(app.views[0].id).unwrap().info.pages[0]; + let v = info.user_to_view(user[0] as f32, user[1] as f32); + app.views[0].page_xform(0).unwrap().norm_to_screen(v[0] / info.width, v[1] / info.height) +} + +fn right_click(h: &mut Harness<'static, PdfCraftApp>, user: [f64; 2]) { + let p = point(h, user); + h.hover_at(p); + h.run_steps(1); + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos: p, button: PointerButton::Secondary, pressed, modifiers: Modifiers::NONE }); + } + h.run_steps(2); +} + +fn reopened(app: &mut PdfCraftApp) -> PdfCraftApp { + let id = app.views[0].id; + let bytes = app.session.save_bytes(id).unwrap(); + let mut saved = PdfCraftApp::new(); + saved.open_bytes("saved.pdf", None, bytes.as_ref().clone()).unwrap(); + saved +} + +fn check_placement(rotation: u16, cropped: bool, view_rotation: u16, shared: bool, existing_added: bool, collision: bool) { + let mut h = harness(fixture_at(rotation, cropped, shared, collision)); + if existing_added { + let mut png = Vec::new(); + image::RgbImage::from_pixel(40, 40, image::Rgb([0, 0, 255])).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + h.state_mut().add_image("existing.png".into(), png); + assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().added().len(), 1); + h.state_mut().quick_tool = pdfcraft_ui_egui::QuickTool::Select; + h.state_mut().views[0].image_selection = None; + h.state_mut().views[0].content.selected = None; + } + h.state_mut().views[0].rotation = view_rotation; + h.run_steps(3); + let id = h.state().views[0].id; + let originals: Vec<_> = h.state().session.get(id).unwrap().page_images(0).iter().map(|image| image.object.unwrap()).collect(); + let at = if cropped { [250.0, 400.0] } else { [210.0, 80.0] }; + right_click(&mut h, at); + h.get_by_label_contains("Paste").click(); + h.run_steps(3); + let doc = h.state().session.get(id).unwrap(); + let inserted = doc + .added() + .iter() + .find_map(|item| match &item.content { + AddedContent::Image(image) if !originals.contains(&image.image) => Some(image.image), + _ => None, + }) + .expect("a new image XObject was inserted"); + assert!(!originals.contains(&inserted), "embedding must allocate a fresh image identity"); + let selected = &doc.page_images(0)[h.state().views[0].image_selection.as_ref().unwrap().index]; + assert_eq!(selected.object, Some(inserted), "the visible selection must name the inserted XObject"); + let center = [(selected.rect[0] + selected.rect[2]) * 0.5, (selected.rect[1] + selected.rect[3]) * 0.5]; + assert!((center[0] - at[0]).abs() < 0.001 && (center[1] - at[1]).abs() < 0.001, "rotation={rotation}, crop={cropped}: {center:?} vs {at:?}"); + let saved = reopened(h.state_mut()); + let placed = saved.session.get(saved.views[0].id).unwrap().page_images(0).into_iter().find(|image| image.object == Some(inserted)).unwrap(); + assert!(((placed.rect[0] + placed.rect[2]) * 0.5 - at[0]).abs() < 0.001); + assert!(((placed.rect[1] + placed.rect[3]) * 0.5 - at[1]).abs() < 0.001); + h.key_press(Key::Delete); + h.run_steps(3); + let saved = reopened(h.state_mut()); + let doc = saved.session.get(saved.views[0].id).unwrap(); + assert_eq!(doc.added().len(), usize::from(existing_added)); + let remaining = doc.page_images(0); + assert_eq!(remaining.len(), originals.len()); + assert!(remaining.iter().any(|image| image.name == "Red")); + if shared { + assert!(remaining.iter().any(|image| image.name == "RedAgain")); + } +} + +#[test] +fn context_paste_ordinary_page_control() { + check_placement(0, false, 0, false, false, false); +} +#[test] +fn context_paste_rotate90_places_and_selects_inserted_image() { + check_placement(90, false, 0, false, false, false); +} +#[test] +fn context_paste_rotate180_places_and_selects_inserted_image() { + check_placement(180, false, 0, false, false, false); +} +#[test] +fn context_paste_rotate270_places_and_selects_inserted_image() { + check_placement(270, false, 0, false, false, false); +} +#[test] +fn context_paste_crop_offset_places_and_selects_inserted_image() { + check_placement(0, true, 0, false, false, false); +} +#[test] +fn context_paste_rotated_crop_places_and_selects_inserted_image() { + check_placement(90, true, 0, false, false, false); +} +#[test] +fn context_paste_view_rotation_control() { + check_placement(0, false, 90, false, false, false); +} +#[test] +fn context_paste_shared_original_xobject_control() { + check_placement(90, false, 0, true, false, false); +} + +#[test] +fn context_paste_with_existing_added_image_control() { + check_placement(90, false, 0, false, true, false); +} + +#[test] +fn context_paste_identity_ignores_original_at_display_rectangle() { + check_placement(90, false, 0, false, false, true); +} + +fn check_delete_owner(original: bool, shared_added: bool) { + let mut h = harness(fixture(0, false, false)); + let id = h.state().views[0].id; + if shared_added { + let object = h.state().session.get(id).unwrap().page_images(0)[0].object.unwrap(); + let (extension, bytes) = h.state().session.get(id).unwrap().page_image_copy_file(0, 0).unwrap(); + assert!(h.state_mut().apply_edit(Edit::PasteContent { + page: 0, + content: AddedContent::Image(AddedImage::new([130.0, 130.0, 170.0, 170.0], object)), + file: Some((format!("shared.{extension}"), std::sync::Arc::new(bytes))) + })); + assert_eq!(h.state().session.get(id).unwrap().page_images(0).len(), 2); + assert!(h.state().session.get(id).unwrap().page_images(0).iter().all(|image| image.object == Some(object))); + h.state_mut().execute("edit.edit_text"); + h.state_mut().views[0].content.selected = Some((0, 0)); + } else { + let mut png = Vec::new(); + image::RgbImage::from_pixel(40, 40, image::Rgb([0, 0, 255])).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + h.state_mut().add_image("blue.png".into(), png); + } + h.run_steps(3); + let p = point(&h, [150.0, 150.0]); + h.hover_at(p); + h.run_steps(1); + h.drag_at(p); + h.run_steps(1); + h.drop_at(p); + h.run_steps(2); + // The legacy editor can retain the added-image selection while its frame is hidden. + h.state_mut().views[0].content.selected = Some((0, 0)); + let target = if original { [35.0, 35.0] } else { [150.0, 150.0] }; + right_click(&mut h, target); + h.get_by_label("Copy Image").click(); + h.run_steps(2); + let selection = h.state().views[0].image_selection.as_ref().unwrap(); + let selected = &h.state().session.get(id).unwrap().page_images(0)[selection.index]; + assert_eq!(selected.name == "Red", original, "the frame belongs to the requested occurrence"); + h.key_press(Key::Delete); + h.run_steps(3); + let saved = reopened(h.state_mut()); + let doc = saved.session.get(saved.views[0].id).unwrap(); + assert_eq!(doc.page_images(0).len(), 1); + assert_eq!(doc.page_images(0).iter().any(|image| image.name == "Red"), !original, "Delete must remove the visibly selected occurrence"); + assert_eq!(doc.added().len(), usize::from(original), "the other item must survive save/reopen"); +} + +#[test] +fn original_image_selection_owns_delete_over_hidden_added_image() { + check_delete_owner(true, false); +} +#[test] +fn added_image_selection_delete_control() { + check_delete_owner(false, false); +} +#[test] +fn shared_xobject_original_occurrence_owns_delete() { + check_delete_owner(true, true); +} + +#[test] +fn whole_page_actions_do_not_require_a_selection() { + let mut h = harness(fixture(0, false, false)); + right_click(&mut h, [210.0, 80.0]); + assert!(h.get_by_label_contains("Copy").accesskit_node().is_disabled()); + assert!(!h.get_by_label("Take a snapshot").accesskit_node().is_disabled()); + assert!(!h.get_by_label("Export this PDF").accesskit_node().is_disabled()); +} + +#[test] +fn whole_page_actions_still_require_copy_permission() { + let mut app = PdfCraftApp::new(); + app.open_bytes("source.pdf", None, fixture(0, false, false)).unwrap(); + assert!(app.apply_edit(Edit::Protect(pdfcraft_engine::Protection { permissions_password: Some("owner".into()), ..Default::default() }))); + let bytes = app.session.save_bytes(app.views[0].id).unwrap(); + let mut h = harness(bytes.as_ref().clone()); + right_click(&mut h, [210.0, 80.0]); + assert!(h.get_by_label("Take a snapshot").accesskit_node().is_disabled()); + assert!(h.get_by_label("Export this PDF").accesskit_node().is_disabled()); +} + +#[test] +fn legacy_text_delete_survives_switching_out_of_the_image_tool() { + let mut h = harness(fixture(0, false, false)); + h.state_mut().execute("edit.edit_text"); + right_click(&mut h, [35.0, 35.0]); + h.get_by_label("Copy Image").click(); + h.run_steps(2); + assert!(h.state().views[0].image_selection.is_some()); + assert!(h.state_mut().apply_edit(Edit::AddText { + page: 0, + text: pdfcraft_engine::AddedText { text: "note".into(), rect: [100.0, 100.0, 180.0, 130.0], ..Default::default() } + })); + assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().added().len(), 1); + h.state_mut().views[0].content.selected = Some((0, 0)); + h.state_mut().quick_tool = pdfcraft_ui_egui::QuickTool::Select; + h.key_press(Key::Delete); + h.run_steps(3); + let saved = reopened(h.state_mut()); + let doc = saved.session.get(saved.views[0].id).unwrap(); + assert!(doc.added().is_empty()); + assert_eq!(doc.page_images(0).len(), 1); +} + +#[test] +fn leaving_image_controller_cannot_leave_hidden_image_owning_delete() { + let mut h = harness(fixture(0, false, false)); + let mut png = Vec::new(); + image::RgbImage::from_pixel(40, 40, image::Rgb([0, 0, 255])).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + h.state_mut().add_image("blue.png".into(), png); + h.run_steps(3); + // Start from no selection, then let the controller select the added image itself. + h.state_mut().views[0].image_selection = None; + h.state_mut().views[0].content.selected = None; + let p = point(&h, [150.0, 150.0]); + h.hover_at(p); + h.run_steps(1); + h.drag_at(p); + h.run_steps(1); + h.drop_at(p); + h.run_steps(2); + assert!(h.state().views[0].image_selection.is_some()); + h.key_press(Key::V); + h.run_steps(2); + assert_eq!(h.state().quick_tool, pdfcraft_ui_egui::QuickTool::Select); + assert!(h.state().views[0].image_selection.is_none()); + h.key_press(Key::Delete); + h.run_steps(3); + let saved = reopened(h.state_mut()); + let doc = saved.session.get(saved.views[0].id).unwrap(); + assert_eq!(doc.page_images(0).len(), 2, "there is no visible image selection to delete"); + assert_eq!(doc.added().len(), 1); +}