diff --git a/crates/edit/src/images.rs b/crates/edit/src/images.rs index 14556c7644..8bb6949f99 100644 --- a/crates/edit/src/images.rs +++ b/crates/edit/src/images.rs @@ -660,6 +660,122 @@ pub fn arrange_image(doc: &mut Document, page: usize, index: usize, order: Arran Ok(()) } +/// Locate the innermost graphics state scope enclosing only `target` (with no other painting operations). +/// Returns `Some((start, end, scope_matrix))` where `start` is the index of `q`, `end` is the index of `Q`, +/// and `scope_matrix` is the CTM in effect immediately before entering that `q`. +fn image_exclusive_scope(ops: &[Op], target: usize) -> Result, EditError> { + struct Scope { + start: usize, + matrix: Matrix, + inherited_clip: bool, + inherited_path: bool, + selected: bool, + other_paint: bool, + } + let mut stack: Vec = Vec::new(); + let mut matrix = Matrix::IDENTITY; + let mut clipped = false; + let mut path = false; + let mut pending_clip = false; + let mut text = false; + let mut at_image = None; + let mut isolated = None; + for (index, op) in ops.iter().enumerate() { + match op.op.as_slice() { + b"q" => { + if stack.len() >= 256 { + return Err(EditError::Invalid("the image graphics state is too deeply nested".into())); + } + stack.push(Scope { start: index, matrix, inherited_clip: clipped, inherited_path: path, selected: false, other_paint: false }); + } + b"Q" => { + if let Some(scope) = stack.pop() { + if scope.selected && !scope.other_paint && !scope.inherited_clip && !scope.inherited_path { + isolated.get_or_insert((scope.start, index, scope.matrix)); + } + matrix = scope.matrix; + clipped = scope.inherited_clip; + } + } + b"cm" => { + if let Some(m) = op.nums::<6>() { + matrix = Matrix(m).then(&matrix); + } + } + b"BT" => text = true, + b"ET" => text = false, + b"m" | b"l" | b"c" | b"v" | b"y" | b"re" => path = true, + b"W" | b"W*" => pending_clip = true, + _ => {} + } + if index == target { + if text { + return Err(EditError::Invalid("an image inside a text object cannot be cropped".into())); + } + at_image = Some(matrix); + for scope in &mut stack { + scope.selected = true; + } + } else if matches!( + op.op.as_slice(), + b"Do" | b"Tj" | b"TJ" | b"'" | b"\"" | b"sh" | b"BI" | b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" + ) { + for scope in &mut stack { + scope.other_paint = true; + } + } + if matches!(op.op.as_slice(), b"n" | b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*") { + clipped |= pending_clip; + pending_clip = false; + path = false; + } + } + if at_image.is_none() { + return Err(EditError::Invalid("the image operation is missing".into())); + } + Ok(isolated) +} + +fn extract_image_drawing_ops(ops: &[Op], start: usize, end: usize, scope_matrix: Matrix) -> Vec { + let mut drawing = Vec::new(); + let mut i = start.saturating_add(1); + while i < end { + let op = match ops.get(i) { + Some(o) => o, + None => break, + }; + if op.is("cm") + && scope_matrix != Matrix::IDENTITY + && let (Some(m), Some(inv)) = (op.nums::<6>(), scope_matrix.invert()) + && Matrix(m) == inv + && ops.get(i + 1).is_some_and(|next| next.is("re")) + { + i = i.saturating_add(1); + continue; + } + if op.is("re") { + i = i.saturating_add(1); + if ops.get(i).is_some_and(|next| next.is("W") || next.is("W*")) { + i = i.saturating_add(1); + } + if ops.get(i).is_some_and(|next| next.is("n")) { + i = i.saturating_add(1); + } + if scope_matrix != Matrix::IDENTITY + && ops.get(i).is_some_and(|next| next.is("cm")) + && let Some(m) = ops.get(i).and_then(|next| next.nums::<6>()) + && Matrix(m) == scope_matrix + { + i = i.saturating_add(1); + } + continue; + } + drawing.push(op.clone()); + i = i.saturating_add(1); + } + drawing +} + /// Remove any private clipping path / crop on image `index` on `page`. pub fn remove_image_crop(doc: &mut Document, page: usize, index: usize) -> Result<(), EditError> { let images = page_images(doc, page)?; @@ -682,14 +798,19 @@ pub fn remove_image_crop(doc: &mut Document, page: usize, index: usize) -> Resul .iter() .position(|op| op.span.start == span && op.is("Do")) .ok_or_else(|| EditError::Invalid("the image operation could not be located".into()))?; - if let Some((at, _)) = exclusive_clip(&ops, target) { - let end_idx = at.saturating_add(2); - if let (Some(start_op), Some(end_op)) = (ops.get(at), ops.get(end_idx)) { - let range = start_op.span.start..end_op.span.end; - let changed = pieces.splice([(range, Vec::new())]); - let contents = crate::text::rewritten_contents(doc, &all, changed); - doc.update_dict(p.obj, |d| d.set(b"Contents".to_vec(), contents))?; - } + if let Some((start, end, scope_matrix)) = image_exclusive_scope(&ops, target)? { + let drawing_ops = extract_image_drawing_ops(&ops, start, end, scope_matrix); + let start_op = ops.get(start).ok_or_else(|| EditError::Invalid("the image operation is missing".into()))?; + let end_op = ops.get(end).ok_or_else(|| EditError::Invalid("the image operation is missing".into()))?; + let range = start_op.span.start..end_op.span.end; + let mut unit_ops = Vec::new(); + unit_ops.push(Op::new("q", vec![])); + unit_ops.extend(drawing_ops); + unit_ops.push(Op::new("Q", vec![])); + let serialized = serialize_ops(&unit_ops); + let changed = pieces.splice([(range, serialized)]); + let contents = crate::text::rewritten_contents(doc, &all, changed); + doc.update_dict(p.obj, |d| d.set(b"Contents".to_vec(), contents))?; } Ok(()) } @@ -725,62 +846,52 @@ pub fn crop_image(doc: &mut Document, page: usize, index: usize, crop_rect: [f64 .position(|op| op.span.start == span && op.is("Do")) .ok_or_else(|| EditError::Invalid("the image operation could not be located".into()))?; - let (range, unit_ops) = if let Some((at, _)) = exclusive_clip(&ops, target) { - let op_re = ops.get(at).ok_or_else(|| EditError::Invalid("the crop operation is missing".into()))?; - ( - op_re.span.start..op_re.span.end, - vec![Op::new("re", vec![pdfcraft_content::num(x0), pdfcraft_content::num(y0), pdfcraft_content::num(w), pdfcraft_content::num(h)])], - ) - } else if target >= 2 - && ops.get(target - 2).is_some_and(|o| o.is("q")) - && ops.get(target - 1).is_some_and(|o| o.is("cm")) - && ops.get(target + 1).is_some_and(|o| o.is("Q")) - { - let q_op = &ops[target - 2]; - let q_end = &ops[target + 1]; - ( - q_op.span.start..q_end.span.end, - vec![ - Op::new("q", vec![]), - Op::new("re", vec![pdfcraft_content::num(x0), pdfcraft_content::num(y0), pdfcraft_content::num(w), pdfcraft_content::num(h)]), - Op::new("W", vec![]), - Op::new("n", vec![]), - ops[target - 1].clone(), - ops[target].clone(), - Op::new("Q", vec![]), - ], - ) - } else if target >= 1 && ops.get(target - 1).is_some_and(|o| o.is("cm")) { - let cm_op = &ops[target - 1]; - let do_op = &ops[target]; - ( - cm_op.span.start..do_op.span.end, - vec![ - Op::new("q", vec![]), - Op::new("re", vec![pdfcraft_content::num(x0), pdfcraft_content::num(y0), pdfcraft_content::num(w), pdfcraft_content::num(h)]), - Op::new("W", vec![]), - Op::new("n", vec![]), - ops[target - 1].clone(), - ops[target].clone(), - Op::new("Q", vec![]), - ], - ) + let (start, end, drawing_ops, scope_matrix) = if let Some((start, end, scope_matrix)) = image_exclusive_scope(&ops, target)? { + let drawing_ops = extract_image_drawing_ops(&ops, start, end, scope_matrix); + (start, end, drawing_ops, scope_matrix) } else { - let do_op = &ops[target]; - ( - do_op.span.start..do_op.span.end, - vec![ - Op::new("q", vec![]), - Op::new("re", vec![pdfcraft_content::num(x0), pdfcraft_content::num(y0), pdfcraft_content::num(w), pdfcraft_content::num(h)]), - Op::new("W", vec![]), - Op::new("n", vec![]), - Op::new("cm", img.draw_matrix.map(pdfcraft_content::num).to_vec()), - ops[target].clone(), - Op::new("Q", vec![]), - ], - ) + let mut state = ImageState::new(Matrix::IDENTITY, p.crop(doc), Vec::new()); + let mut scope_matrix = Matrix::IDENTITY; + let start = if target >= 1 && ops.get(target - 1).is_some_and(|o| o.is("cm")) { + target - 1 + } else { + target + }; + for (i, o) in ops.iter().enumerate() { + if i == start { + scope_matrix = state.placement.matrix; + break; + } + state.apply(o)?; + } + let drawing_ops = ops.get(start..=target).unwrap_or_default().to_vec(); + (start, target, drawing_ops, scope_matrix) }; + let mut unit_ops = Vec::new(); + unit_ops.push(Op::new("q", vec![])); + if scope_matrix != Matrix::IDENTITY + && let Some(inv) = scope_matrix.invert() + { + unit_ops.push(Op::new("cm", inv.0.map(pdfcraft_content::num).to_vec())); + } + unit_ops.push(Op::new("re", vec![ + pdfcraft_content::num(x0), + pdfcraft_content::num(y0), + pdfcraft_content::num(w), + pdfcraft_content::num(h), + ])); + unit_ops.push(Op::new("W", vec![])); + unit_ops.push(Op::new("n", vec![])); + if scope_matrix != Matrix::IDENTITY { + unit_ops.push(Op::new("cm", scope_matrix.0.map(pdfcraft_content::num).to_vec())); + } + unit_ops.extend(drawing_ops); + unit_ops.push(Op::new("Q", vec![])); + + let start_op = ops.get(start).ok_or_else(|| EditError::Invalid("the image operation is missing".into()))?; + let end_op = ops.get(end).ok_or_else(|| EditError::Invalid("the image operation is missing".into()))?; + let range = start_op.span.start..end_op.span.end; let serialized = serialize_ops(&unit_ops); let changed = pieces.splice([(range, serialized)]); let contents = crate::text::rewritten_contents(doc, &all, changed); diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index c3aa428f84..a9f61f8bbd 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -1740,6 +1740,32 @@ fn image_arrange_stacking_order_and_remove_crop() { let doc = reopen(&doc); let img_restored = &images::page_images(&doc, 0).unwrap()[0]; assert_eq!(img_restored.visible_rect, Some(img_restored.rect)); + + // Test cropping an image that was moved (has an outer transform) + let mut doc = image_page_with("q 200 0 0 100 100 100 cm /Im0 Do Q"); + let move_transform = [1.0, 0.0, 0.0, 1.0, 50.0, 40.0]; + images::change_image(&mut doc, 0, 0, &images::ImageChange::Transform(move_transform)).unwrap(); + let doc = reopen(&doc); + let img_moved = &images::page_images(&doc, 0).unwrap()[0]; + assert_eq!(img_moved.rect, [150.0, 140.0, 350.0, 240.0]); + let mut doc = doc; + images::crop_image(&mut doc, 0, 0, [170.0, 150.0, 300.0, 220.0]).unwrap(); + let doc = reopen(&doc); + let img_crop_moved = &images::page_images(&doc, 0).unwrap()[0]; + assert!(img_crop_moved.visible_rect.is_some(), "cropped image must not be deleted"); + assert!(close(img_crop_moved.visible_rect.unwrap(), [170.0, 150.0, 300.0, 220.0])); + // Adjust crop (re-crop) + let mut doc = doc; + images::crop_image(&mut doc, 0, 0, [180.0, 160.0, 280.0, 210.0]).unwrap(); + let doc = reopen(&doc); + let img_recropped = &images::page_images(&doc, 0).unwrap()[0]; + assert!(close(img_recropped.visible_rect.unwrap(), [180.0, 160.0, 280.0, 210.0])); + // Remove crop from moved image + let mut doc = doc; + images::remove_image_crop(&mut doc, 0, 0).unwrap(); + let doc = reopen(&doc); + let img_restored_moved = &images::page_images(&doc, 0).unwrap()[0]; + assert_eq!(img_restored_moved.visible_rect, Some(img_restored_moved.rect)); } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 8f21dcdfe2..aacc091bff 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -542,6 +542,8 @@ pub(crate) enum PageMenuAction { SelectTool, HandTool, MarqueeZoom, + Copy, + Paste(usize, [f64; 2]), AddComment(usize, [f64; 2]), AddBookmark(usize), TakeSnapshot, @@ -558,6 +560,8 @@ pub(crate) fn default_page_context_menu( ui: &mut egui::Ui, page: usize, at: [f64; 2], + can_copy: bool, + can_paste: bool, action: &mut Option, ) { // Top horizontal icon bar: Rotate CCW, Print, Select tool, Hand tool, Marquee Zoom @@ -587,6 +591,17 @@ pub(crate) fn default_page_context_menu( ui.separator(); + if ui.add_enabled(can_copy, egui::Button::new(tl!("Copy")).shortcut_text("Ctrl+C")).clicked() { + *action = Some(PageMenuAction::Copy); + ui.close(); + } + if ui.add_enabled(can_paste, egui::Button::new(tl!("Paste")).shortcut_text("Ctrl+V")).clicked() { + *action = Some(PageMenuAction::Paste(page, at)); + ui.close(); + } + + ui.separator(); + if ui.button(tl!("Add comment")).clicked() { *action = Some(PageMenuAction::AddComment(page, at)); ui.close(); @@ -2191,6 +2206,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { let editing_content = (app.left_open && app.left == crate::LeftPanel::Tool("edit")) || matches!(app.quick_tool, QuickTool::AddText | QuickTool::EditText); let text_style = app.text_style.clone(); + let can_paste_image = app.can_paste_image(); let view = &mut app.views[index]; // Opened without the owner password and something is restricted. let secured = doc.security_summary().is_some_and(|s| !(s.owner || (s.permissions.modify() && s.permissions.assemble()))); @@ -3227,7 +3243,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { return; } if tool == QuickTool::EditText && let Some(sel) = view.image_selection.clone() { - crate::edit_text_ui::image_context_menu(ui, view, doc, sel.page, sel.index, &mut image_action); + crate::edit_text_ui::image_context_menu(ui, view, doc, sel.page, sel.index, can_paste_image, &mut image_action); return; } if view.comments.selected.is_some() { @@ -3260,11 +3276,14 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { ui.separator(); } } + let has_copy_image = copy_image.is_some(); if copy_image.is_some() { canvas_action = copy_image; } let (page, at) = view.comments.context_at.unwrap_or((view.current, [0.0, 0.0])); - default_page_context_menu(ui, page, at, &mut page_action); + let can_copy = view.image_selection.is_some() || view.selected_text().is_some() || has_copy_image; + let can_paste = can_paste_image; + default_page_context_menu(ui, page, at, can_copy, can_paste, &mut page_action); }); (wanted, visible_now) }); @@ -3531,13 +3550,11 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { Some(crate::edit_text_ui::ImageAction::Copy(page, i)) => app.copy_page_image(page, i), Some(crate::edit_text_ui::ImageAction::CopyFile(page, i)) => app.copy_page_image_file(page, i), Some(crate::edit_text_ui::ImageAction::Cut(page, i)) => { - app.copy_page_image(page, i); - app.views[index].pending_edit = Some(pdfcraft_engine::Edit::EditPageImage { - page, - index: i, - change: pdfcraft_engine::ImageEdit::Delete, - }); - app.views[index].image_selection = None; + app.cut_page_image(index, page, i); + } + Some(crate::edit_text_ui::ImageAction::Paste(page, rect)) => { + let offset_rect = [rect[0] + 20.0, rect[1] - 20.0, rect[2] + 20.0, rect[3] - 20.0]; + app.paste_image(page, Some(offset_rect)); } Some(crate::edit_text_ui::ImageAction::DrawFreehand) => { app.quick_tool = crate::QuickTool::Comment(crate::comments::CommentTool::Ink); @@ -3591,6 +3608,23 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { None => {} } match page_action { + Some(PageMenuAction::Copy) => { + if let Some(sel) = app.views[index].image_selection.clone() { + app.copy_page_image(sel.page, sel.index); + } else if let Some(text) = app.views[index].selected_text() { + if let Some(id) = app.views.get(index).map(|v| v.id) && app.may_copy(id) { + ui.ctx().copy_text(text); + } + } else if let Some((page, at)) = app.views[index].comments.context_at + && let Some(doc) = app.session.get(app.views[index].id) + && let Some(image) = doc.page_image_at(page, at) + { + app.copy_page_image(page, image); + } + } + Some(PageMenuAction::Paste(page, at)) => { + app.paste_image_at(page, at); + } Some(PageMenuAction::RotateCCW(page)) => { app.views[index].pending_edit = Some(pdfcraft_engine::Edit::RotatePages { pages: vec![page], degrees: -90 }); } diff --git a/crates/ui-egui/src/content_ui.rs b/crates/ui-egui/src/content_ui.rs index 148830e010..20a609ad3e 100644 --- a/crates/ui-egui/src/content_ui.rs +++ b/crates/ui-egui/src/content_ui.rs @@ -745,6 +745,15 @@ impl crate::PdfCraftApp { if !self.may_copy(id) { return; } + let img_rect = self.session.get(id).and_then(|d| d.page_images(page).into_iter().nth(index).map(|im| im.rect)); + self.last_copied_image_rect = img_rect; + if let Some(doc) = self.session.get(id) + && let Ok((ext, bytes)) = doc.page_image_copy_file(page, index) + { + let stem = crate::image_pick::safe_name(doc.name.rsplit_once('.').map_or(doc.name.as_str(), |(s, _)| s)); + let name = format!("{stem} p{} img{}.{ext}", page.saturating_add(1), index.saturating_add(1)); + self.last_copied_file = Some((name, bytes)); + } let Some(pixels) = self.session.get(id).map(|d| d.page_image_rgba(page, index)) else { return }; let copied = pixels.and_then(|(w, h, rgba)| { #[cfg(not(target_arch = "wasm32"))] @@ -765,6 +774,128 @@ impl crate::PdfCraftApp { } } + /// Cut Image: copy image `index` on `page` to the clipboard, then delete it. + pub(crate) fn cut_page_image(&mut self, view_idx: usize, page: usize, index: usize) { + self.copy_page_image(page, index); + if let Some(view) = self.views.get_mut(view_idx) { + view.pending_edit = Some(pdfcraft_engine::Edit::EditPageImage { + page, + index, + change: pdfcraft_engine::ImageEdit::Delete, + }); + view.image_selection = None; + } + } + + /// Whether there is an image in the in-memory clipboard or system clipboard ready to paste. + pub fn can_paste_image(&self) -> bool { + if self.last_copied_image.is_some() || self.last_copied_file.is_some() { + return true; + } + #[cfg(not(target_arch = "wasm32"))] + { + if self.system_clipboard + && let Ok(mut cb) = arboard::Clipboard::new() + { + return cb.get_image().is_ok(); + } + } + false + } + + /// Paste an image from the clipboard onto `page`. + pub fn paste_image(&mut self, page: usize, rect: Option<[f64; 4]>) -> bool { + let Some((i, id)) = self.active_ids() else { return false; }; + if let Some(doc) = self.session.get(id) + && !doc.allows_modification() + { + self.notify_error(tl!("The document's security settings don't allow changes")); + return false; + } + + let image_data: Option<(String, Vec)> = if let Some((name, bytes)) = self.last_copied_file.clone() { + Some((name, bytes)) + } else if let Some((w, h, rgba)) = self.last_copied_image.clone() { + crate::create_ui::png_from_rgba(w as usize, h as usize, &rgba).ok().map(|b| ("pasted_image.png".to_string(), b)) + } else { + #[cfg(not(target_arch = "wasm32"))] + { + if self.system_clipboard { + arboard::Clipboard::new() + .ok() + .and_then(|mut cb| cb.get_image().ok()) + .and_then(|img| { + crate::create_ui::png_from_rgba(img.width, img.height, &img.bytes) + .ok() + .map(|b| ("clipboard_image.png".to_string(), b)) + }) + } else { + None + } + } + #[cfg(target_arch = "wasm32")] + None + }; + + let Some((name, bytes)) = image_data else { + self.notify_tr("No image on clipboard to paste"); + return false; + }; + + let target_rect = match rect { + Some(r) => Some(r), + None => { + if let Some(src_rect) = self.last_copied_image_rect { + let w = src_rect[2] - src_rect[0]; + let h = src_rect[3] - src_rect[1]; + let offset_x = 20.0; + let offset_y = -20.0; + let new_r = [src_rect[0] + offset_x, src_rect[1] + offset_y, src_rect[0] + offset_x + w, src_rect[1] + offset_y + h]; + self.last_copied_image_rect = Some(new_r); + Some(new_r) + } else { + None + } + } + }; + + let before = self.session.get(id).map_or(0, |d| d.page_images(page).len()); + if self.apply_edit(pdfcraft_engine::Edit::AddImage { + page, + rect: target_rect, + name, + bytes: std::sync::Arc::new(bytes), + }) { + if let Some(v) = self.views.get_mut(i) { + v.current = page; + v.image_selection = Some(crate::edit_text_ui::ImageSelection { + page, + index: before, + crop_mode: false, + crop_rect: None, + drag: None, + }); + } + self.quick_tool = crate::QuickTool::EditText; + self.notify_tr("Image pasted"); + true + } else { + false + } + } + + /// Paste an image centered at context menu point `at: [f64; 2]` on `page`. + pub fn paste_image_at(&mut self, page: usize, at: [f64; 2]) -> bool { + let rect = if let Some(src_r) = self.last_copied_image_rect { + let w = src_r[2] - src_r[0]; + let h = src_r[3] - src_r[1]; + Some([at[0] - w * 0.5, at[1] - h * 0.5, at[0] + w * 0.5, at[1] + h * 0.5]) + } else { + None + }; + self.paste_image(page, rect) + } + /// Click an image field: pick a picture for it. pub fn choose_field_image(&mut self, name: &str) { #[cfg(not(target_arch = "wasm32"))] diff --git a/crates/ui-egui/src/create_ui.rs b/crates/ui-egui/src/create_ui.rs index 63f8035251..fb8f1b2e27 100644 --- a/crates/ui-egui/src/create_ui.rs +++ b/crates/ui-egui/src/create_ui.rs @@ -66,7 +66,7 @@ fn read_clipboard() -> Option { cb.get_text().ok().filter(|t| !t.trim().is_empty()).map(Clip::Text) } -fn png_from_rgba(width: usize, height: usize, rgba: &[u8]) -> Result, String> { +pub(crate) fn png_from_rgba(width: usize, height: usize, rgba: &[u8]) -> Result, String> { let (w, h) = (u32::try_from(width).map_err(|e| e.to_string())?, u32::try_from(height).map_err(|e| e.to_string())?); if w == 0 || h == 0 || rgba.len() != width * height * 4 { return Err("the clipboard image is empty or malformed".into()); diff --git a/crates/ui-egui/src/edit_text_ui.rs b/crates/ui-egui/src/edit_text_ui.rs index 282ab3eac9..48b8a7d552 100644 --- a/crates/ui-egui/src/edit_text_ui.rs +++ b/crates/ui-egui/src/edit_text_ui.rs @@ -616,7 +616,7 @@ impl ImageSelection { /// applies after the pointer leaves the image/page while dragging. pub(crate) fn image_owns_pointer(ui: &egui::Ui, xf: &PageXform, page: usize, info: &DocInfo, view: &DocView) -> bool { let Some(selection) = view.image_selection.as_ref().filter(|s| s.page == page) else { return false }; - if selection.drag.is_some() { + if selection.drag.is_some() || selection.crop_mode { return true; } let Some(image) = view.edit_images.get(&page).and_then(|(_, images)| images.get(selection.index)) else { return false }; @@ -698,6 +698,7 @@ pub(crate) enum ImageAction { Copy(usize, usize), CopyFile(usize, usize), Cut(usize, usize), + Paste(usize, [f64; 4]), DrawFreehand, Redact(usize, usize), CreateLink(usize, usize), @@ -858,14 +859,22 @@ pub(crate) fn image_input( painter.line_segment([Pos2::new(active_crop.left(), y2), Pos2::new(active_crop.right(), y2)], Stroke::new(0.75, ACCENT.gamma_multiply(0.5))); let crop_handles = [ + ImageHandle::TopLeft, ImageHandle::TopCenter, + ImageHandle::TopRight, ImageHandle::RightCenter, + ImageHandle::BottomRight, ImageHandle::BottomCenter, + ImageHandle::BottomLeft, ImageHandle::LeftCenter, ]; for &h in &crop_handles { let c = handle_pos(active_crop, h); - let sz = if matches!(h, ImageHandle::TopCenter | ImageHandle::BottomCenter) { egui::vec2(16.0, 7.0) } else { egui::vec2(7.0, 16.0) }; + let sz = match h { + ImageHandle::TopCenter | ImageHandle::BottomCenter => egui::vec2(16.0, 6.0), + ImageHandle::LeftCenter | ImageHandle::RightCenter => egui::vec2(6.0, 16.0), + _ => egui::vec2(10.0, 10.0), + }; painter.rect(Rect::from_center_size(c, sz), CornerRadius::same(1), Color32::WHITE, Stroke::new(1.5, ACCENT), egui::StrokeKind::Middle); } @@ -878,13 +887,21 @@ pub(crate) fn image_input( } if allow_interaction { - if ui.input(|inp| inp.key_pressed(egui::Key::Enter)) { + let enter_pressed = ui.input_mut(|inp| inp.consume_key(egui::Modifiers::NONE, egui::Key::Enter)); + let dbl_click = resp.double_clicked() && hover_pos.is_some_and(|p| active_crop.contains(p)); + if enter_pressed || dbl_click { if active_crop != b { view.pending_edit = Some(Edit::EditPageImage { page, index: i, change: pdfcraft_engine::ImageEdit::Crop(user_box(xf, info, page, active_crop)), }); + } else if images.get(i).is_some_and(|im| im.visible_rect.is_some() && im.visible_rect != Some(im.rect)) { + view.pending_edit = Some(Edit::EditPageImage { + page, + index: i, + change: pdfcraft_engine::ImageEdit::RemoveCrop, + }); } if let Some(s) = view.image_selection.as_mut() { s.crop_mode = false; @@ -892,7 +909,7 @@ pub(crate) fn image_input( } return true; } - if ui.input(|inp| inp.key_pressed(egui::Key::Escape)) { + if ui.input_mut(|inp| inp.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) { if let Some(s) = view.image_selection.as_mut() { s.crop_mode = false; s.crop_rect = None; @@ -918,9 +935,13 @@ pub(crate) fn image_input( if is_crop { let active_crop = view.image_selection.as_ref().and_then(|s| s.crop_rect).unwrap_or(b); let crop_handles = [ + ImageHandle::TopLeft, ImageHandle::TopCenter, + ImageHandle::TopRight, ImageHandle::RightCenter, + ImageHandle::BottomRight, ImageHandle::BottomCenter, + ImageHandle::BottomLeft, ImageHandle::LeftCenter, ]; let hit_edge = crop_handles.iter().copied().find(|&h| handle_pos(active_crop, h).distance(o) < 8.0); @@ -955,6 +976,26 @@ pub(crate) fn image_input( if is_crop { let mut new_crop = initial; match drag.target { + DragTarget::CropHandle(ImageHandle::TopLeft) => { + let x = (initial.left() + delta.x).clamp(b.left(), initial.right() - 10.0); + let y = (initial.top() + delta.y).clamp(b.top(), initial.bottom() - 10.0); + new_crop = Rect::from_min_max(Pos2::new(x, y), initial.max); + } + DragTarget::CropHandle(ImageHandle::TopRight) => { + let x = (initial.right() + delta.x).clamp(initial.left() + 10.0, b.right()); + let y = (initial.top() + delta.y).clamp(b.top(), initial.bottom() - 10.0); + new_crop = Rect::from_min_max(Pos2::new(initial.left(), y), Pos2::new(x, initial.bottom())); + } + DragTarget::CropHandle(ImageHandle::BottomLeft) => { + let x = (initial.left() + delta.x).clamp(b.left(), initial.right() - 10.0); + let y = (initial.bottom() + delta.y).clamp(initial.top() + 10.0, b.bottom()); + new_crop = Rect::from_min_max(Pos2::new(x, initial.top()), Pos2::new(initial.right(), y)); + } + DragTarget::CropHandle(ImageHandle::BottomRight) => { + let x = (initial.right() + delta.x).clamp(initial.left() + 10.0, b.right()); + let y = (initial.bottom() + delta.y).clamp(initial.top() + 10.0, b.bottom()); + new_crop = Rect::from_min_max(initial.min, Pos2::new(x, y)); + } DragTarget::CropHandle(ImageHandle::TopCenter) => { let y = (initial.top() + delta.y).clamp(b.top(), initial.bottom() - 10.0); new_crop = Rect::from_min_max(Pos2::new(initial.left(), y), initial.max); @@ -1072,8 +1113,15 @@ pub(crate) fn image_input( && ui.input(|inp| inp.key_pressed(egui::Key::Delete) || inp.key_pressed(egui::Key::Backspace)) && !ui.ctx().egui_wants_keyboard_input() { - view.image_selection = None; - view.pending_edit = Some(Edit::EditPageImage { page, index: i, change: pdfcraft_engine::ImageEdit::Delete }); + if is_crop { + if let Some(s) = view.image_selection.as_mut() { + s.crop_mode = false; + s.crop_rect = None; + } + } else { + view.image_selection = None; + view.pending_edit = Some(Edit::EditPageImage { page, index: i, change: pdfcraft_engine::ImageEdit::Delete }); + } return true; } } @@ -1083,6 +1131,7 @@ pub(crate) fn image_input( let Some(p) = hover_pos.filter(|p| xf.rect.contains(*p)) else { return false }; let Some(hit) = boxes.iter().enumerate().rposition(|(i, b)| b.contains(p) && image_contains(i, p)) else { let over_popup = egui::Popup::is_any_open(ui.ctx()) + || resp.context_menu_opened() || ui.ctx().is_pointer_over_egui() || ui.ctx().egui_wants_pointer_input(); if (resp.secondary_clicked() || resp.clicked()) && !over_popup { @@ -1124,6 +1173,7 @@ pub(crate) fn image_context_menu( doc: &pdfcraft_engine::Document, page: usize, hit: usize, + can_paste: bool, action: &mut Option, ) { let images = doc.page_images(page); @@ -1171,8 +1221,10 @@ pub(crate) fn image_context_menu( *action = Some(ImageAction::Copy(page, hit)); ui.close(); } - // Paste item: disabled when no item can be pasted into the image layer - ui.add_enabled(false, egui::Button::new(tl!("Paste")).shortcut_text("Ctrl+V")); + if ui.add_enabled(can_paste, egui::Button::new(tl!("Paste")).shortcut_text("Ctrl+V")).clicked() { + *action = Some(ImageAction::Paste(page, img.rect)); + ui.close(); + } ui.separator(); diff --git a/crates/ui-egui/src/image_pick.rs b/crates/ui-egui/src/image_pick.rs index a05aefe2d2..3030bfb293 100644 --- a/crates/ui-egui/src/image_pick.rs +++ b/crates/ui-egui/src/image_pick.rs @@ -59,7 +59,7 @@ pub(crate) fn paint(painter: &egui::Painter, xf: &PageXform, page: usize, info: } /// A file name without characters file systems refuse. -fn safe_name(s: &str) -> String { +pub(crate) fn safe_name(s: &str) -> String { let cleaned: String = s.chars().map(|c| if c.is_control() || r#"/\:*?"<>|"#.contains(c) { '_' } else { c }).take(80).collect(); let cleaned = cleaned.trim().trim_matches('.').to_string(); if cleaned.is_empty() { "PdfCraft".into() } else { cleaned } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index de858de5cd..1fddfa5363 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -704,6 +704,7 @@ pub struct PdfCraftApp { /// The last image copied in its original format (file name, bytes), also put on the /// system clipboard as a file when `system_clipboard` is on. pub last_copied_file: Option<(String, Vec)>, + pub last_copied_image_rect: Option<[f64; 4]>, pub system_clipboard: bool, /// Attach file: the file to attach instead of asking (tests, automation). pub attach_override: Option<(String, Vec)>, @@ -992,6 +993,7 @@ impl PdfCraftApp { last_snapshot: None, last_copied_image: None, last_copied_file: None, + last_copied_image_rect: None, system_clipboard: true, attach_override: None, duplicate_draft: None, @@ -2682,7 +2684,7 @@ impl PdfCraftApp { object_ui::nudge_keys(ctx, &mut self.views[i], doc); } canvas::shortcuts(&mut self.views[i], ctx); - self.copy_key(i, ctx); + self.clipboard_keys(i, ctx); self.image_pick_keys(i, ctx); } } @@ -2706,16 +2708,42 @@ impl PdfCraftApp { false } - /// ⌘C copies the selected text, unless a text field has the keyboard (it copies its own). - fn copy_key(&mut self, i: usize, ctx: &egui::Context) { - // ⌘C arrives as a Copy event on most platforms. - let copy = ctx.input(|input| input.events.iter().any(|e| matches!(e, egui::Event::Copy))); - if !copy || ctx.egui_wants_keyboard_input() { + /// ⌘C / ⌘X / ⌘V copy, cut and paste text or images on canvas (unless a text field has focus). + fn clipboard_keys(&mut self, i: usize, ctx: &egui::Context) { + use egui::{Key, KeyboardShortcut, Modifiers}; + if ctx.egui_wants_keyboard_input() || self.views.get(i).is_some_and(|v| v.forms.focus.is_some() || v.organize) { return; } - let Some((id, text)) = self.views.get(i).and_then(|v| Some((v.id, v.selected_text()?))) else { return }; - if self.may_copy(id) { - ctx.copy_text(text); + let copy = ctx.input(|input| input.events.iter().any(|e| matches!(e, egui::Event::Copy))) + || ctx.input_mut(|input| input.consume_shortcut(&KeyboardShortcut::new(Modifiers::COMMAND, Key::C))); + let cut = ctx.input(|input| input.events.iter().any(|e| matches!(e, egui::Event::Cut))) + || ctx.input_mut(|input| input.consume_shortcut(&KeyboardShortcut::new(Modifiers::COMMAND, Key::X))); + let paste = ctx.input(|input| input.events.iter().any(|e| matches!(e, egui::Event::Paste(_)))) + || ctx.input_mut(|input| input.consume_shortcut(&KeyboardShortcut::new(Modifiers::COMMAND, Key::V))); + + if copy { + if let Some(text) = self.views.get(i).and_then(|v| v.selected_text()) + && let Some(id) = self.views.get(i).map(|v| v.id) + && self.may_copy(id) + { + ctx.copy_text(text); + } else if let Some(sel) = self.views.get(i).and_then(|v| v.image_selection.clone()) + && let Some(id) = self.views.get(i).map(|v| v.id) + && self.may_copy(id) + { + self.copy_page_image(sel.page, sel.index); + } + } + if cut + && let Some(sel) = self.views.get(i).and_then(|v| v.image_selection.clone()) + && let Some(id) = self.views.get(i).map(|v| v.id) + && self.may_copy(id) + { + self.cut_page_image(i, sel.page, sel.index); + } + if paste { + let page = self.views.get(i).map_or(0, |v| v.current); + self.paste_image(page, None); } } diff --git a/crates/ui-egui/src/object_ui.rs b/crates/ui-egui/src/object_ui.rs index 118946bd0c..3b410be261 100644 --- a/crates/ui-egui/src/object_ui.rs +++ b/crates/ui-egui/src/object_ui.rs @@ -160,6 +160,9 @@ pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, p if view.objects.input_blocked { return true; } + if resp.context_menu_opened() || egui::Popup::is_any_open(ui.ctx()) { + return false; + } if view.content.draft.is_some() || view.line_editor.as_ref().is_some_and(|e| e.has_changes()) { let blocked = view.objects.blocked_page == Some(page); let modifier_press = ui.input(|i| i.pointer.primary_pressed()) diff --git a/crates/ui-egui/tests/comments.rs b/crates/ui-egui/tests/comments.rs index a86ee0d76c..b570a050e1 100644 --- a/crates/ui-egui/tests/comments.rs +++ b/crates/ui-egui/tests/comments.rs @@ -1039,7 +1039,7 @@ fn hovering_highlight_without_comment_respects_display_author_option() { assert!(json.contains("\"display_author_hover_no_comment\":false")); let mut app2 = PdfCraftApp::new(); app2.restore(&json); - assert_eq!(app2.comment_prefs.display_author_hover_no_comment, false); + assert!(!app2.comment_prefs.display_author_hover_no_comment); } #[test] diff --git a/crates/ui-egui/tests/editing.rs b/crates/ui-egui/tests/editing.rs index 971c8bf9c8..daa2c36744 100644 --- a/crates/ui-egui/tests/editing.rs +++ b/crates/ui-egui/tests/editing.rs @@ -2654,6 +2654,8 @@ fn default_page_context_menu_when_not_in_edit_mode() { assert!(h.query_all_by_label("Add comment").next().is_some()); assert!(h.query_all_by_label("Add text").next().is_some()); assert!(h.query_all_by_label("Add image").next().is_some()); + assert!(h.query_all_by_label_contains("Copy").next().is_some()); + assert!(h.query_all_by_label_contains("Paste").next().is_some()); // Clicking "Edit this PDF" enters edit mode h.get_by_label("Edit this PDF").click(); @@ -2694,10 +2696,30 @@ fn image_edge_handle_stretch_and_crop_mode() { h.run_steps(2); assert!(h.state().views[0].image_selection.as_ref().is_some_and(|s| s.crop_mode)); + // Set smaller crop rect + let smaller = egui::Rect::from_center_size(c, egui::vec2(50.0, 50.0)); + h.state_mut().views[0].image_selection.as_mut().unwrap().crop_rect = Some(smaller); + // Confirm crop with Enter key h.key_press(egui::Key::Enter); - h.run_steps(2); + h.run_steps(4); assert!(!h.state().views[0].image_selection.as_ref().is_some_and(|s| s.crop_mode)); + let doc_id = h.state().views[0].id; + let images = h.state().session.get(doc_id).unwrap().page_images(0); + assert_eq!(images.len(), 1, "image still exists: {images:?}"); + assert!(images[0].visible_rect.is_some(), "cropped image is visible"); + assert_ne!(images[0].visible_rect, Some(images[0].rect), "image is cropped"); + + // Remove crop via context menu + h.hover_at(c); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: c, button: egui::PointerButton::Secondary, pressed: true, modifiers: Default::default() }); + h.event(egui::Event::PointerButton { pos: c, button: egui::PointerButton::Secondary, pressed: false, modifiers: Default::default() }); + h.run_steps(2); + h.get_by_label("Remove crop").click(); + h.run_steps(4); + let images = h.state().session.get(doc_id).unwrap().page_images(0); + assert_eq!(images[0].visible_rect, Some(images[0].rect), "crop is removed"); } #[test] @@ -4341,3 +4363,67 @@ fn private_new_chinese_keyboard_input_preserves_font_layout_and_saved_pixels() { assert_eq!(std::fs::read(path).unwrap(), original); println!("verified Chinese keyboard insertion/deletion and save/reopen rendering on {pages} pages"); } + +#[test] +fn image_copy_cut_paste_keyboard_and_context_menu() { + let mut png = Vec::new(); + image::RgbImage::from_pixel(60, 40, image::Rgb([180, 50, 50])) + .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 = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.system_clipboard = false; + app.open_bytes("picture.png", None, png.clone()).expect("opens"); + app + }); + h.run_steps(4); + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(2); + + let id = h.state().views[0].id; + let initial_count = h.state().session.get(id).unwrap().page_images(0).len(); + assert_eq!(initial_count, 1, "starts with 1 image"); + + let r = h.state().views[0].page_screen_rect(0).expect("on screen"); + let c = r.center(); + + // 1. Select the image + 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(), "image selected"); + + // 2. Copy via keyboard shortcut (COMMAND+C / Event::Copy) + h.event(egui::Event::Copy); + h.run_steps(2); + assert!(h.state().last_copied_image.is_some(), "image copied to clipboard buffer"); + + // 3. Paste via keyboard shortcut (COMMAND+V / Event::Paste) + h.event(egui::Event::Paste("".into())); + h.run_steps(3); + let after_paste = h.state().session.get(id).unwrap().page_images(0).len(); + assert_eq!(after_paste, 2, "now has 2 images"); + assert_eq!(h.state().views[0].image_selection.as_ref().map(|s| s.index), Some(1), "new image is selected"); + + // 4. Cut via keyboard shortcut (COMMAND+X / Event::Cut) + h.event(egui::Event::Cut); + h.run_steps(3); + let after_cut = h.state().session.get(id).unwrap().page_images(0).len(); + assert_eq!(after_cut, 1, "cut deleted the pasted image"); + assert!(h.state().views[0].image_selection.is_none(), "selection cleared after cut"); + + // 5. Paste again via context menu on the page + h.hover_at(c); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: c, button: egui::PointerButton::Secondary, pressed: true, modifiers: Default::default() }); + h.event(egui::Event::PointerButton { pos: c, button: egui::PointerButton::Secondary, pressed: false, modifiers: Default::default() }); + h.run_steps(2); + h.get_by_label_contains("Paste").click(); + h.run_steps(3); + let after_paste2 = h.state().session.get(id).unwrap().page_images(0).len(); + assert_eq!(after_paste2, 2, "pasted via context menu"); +}