diff --git a/crates/edit/src/images.rs b/crates/edit/src/images.rs index 8bb6949f99..be963244e0 100644 --- a/crates/edit/src/images.rs +++ b/crates/edit/src/images.rs @@ -603,33 +603,72 @@ pub fn arrange_image(doc: &mut Document, page: usize, index: usize, order: Arran }; let Some(target_unit) = units.get(target_idx) else { return Ok(()) }; - let (scope_start, scope_end, placement) = image_transform_scope(&ops, target)?.unwrap_or((target, target, Matrix(img.draw_matrix))); + let has_other_paint = ops.iter().enumerate().any(|(i, op)| { + i >= target_unit.start + && i < target_unit.end + && i != target + && 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*" + ) + }); - let unit_ops = if scope_start != scope_end && target_unit.start == scope_start && target_unit.end == scope_end.saturating_add(1) { - if placement == Matrix::IDENTITY { - ops.get(scope_start..=scope_end).unwrap_or_default().to_vec() + let (target_span, unit_ops) = if has_other_paint { + if let Ok(Some((sub_start, sub_end, sub_placement))) = image_exclusive_scope(&ops, target) + && sub_start >= target_unit.start + && sub_end < target_unit.end + { + let u_ops = if sub_placement == Matrix::IDENTITY { + ops.get(sub_start..=sub_end).unwrap_or_default().to_vec() + } else { + let mut out = Vec::new(); + out.push(Op::new("q", vec![])); + out.push(Op::new("cm", sub_placement.0.map(pdfcraft_content::num).to_vec())); + if let Some(inner) = ops.get(sub_start.saturating_add(1)..=sub_end) { + out.extend_from_slice(inner); + } + out + }; + let start_op = ops.get(sub_start).unwrap_or(&ops[target]); + let end_op = ops.get(sub_end).unwrap_or(&ops[target]); + (start_op.span.start..end_op.span.end, u_ops) } else { - let mut out = Vec::new(); - out.push(Op::new("q", vec![])); - out.push(Op::new("cm", placement.0.map(pdfcraft_content::num).to_vec())); - if let Some(inner) = ops.get(scope_start.saturating_add(1)..=scope_end) { - out.extend_from_slice(inner); - } - out + let u_ops = vec![ + Op::new("q", vec![]), + Op::new("cm", img.draw_matrix.map(pdfcraft_content::num).to_vec()), + ops.get(target).cloned().unwrap_or_else(|| Op::new("Do", vec![Object::name(&img.name)])), + Op::new("Q", vec![]), + ]; + let target_op = &ops[target]; + (target_op.span.start..target_op.span.end, u_ops) } } else { - vec![ - Op::new("q", vec![]), - Op::new("cm", img.draw_matrix.map(pdfcraft_content::num).to_vec()), - ops.get(target).cloned().unwrap_or_else(|| Op::new("Do", vec![Object::name(&img.name)])), - Op::new("Q", vec![]), - ] + let (scope_start, scope_end, placement) = image_transform_scope(&ops, target)?.unwrap_or((target, target, Matrix(img.draw_matrix))); + let u_ops = if scope_start != scope_end && target_unit.start == scope_start && target_unit.end == scope_end.saturating_add(1) { + if placement == Matrix::IDENTITY { + ops.get(scope_start..=scope_end).unwrap_or_default().to_vec() + } else { + let mut out = Vec::new(); + out.push(Op::new("q", vec![])); + out.push(Op::new("cm", placement.0.map(pdfcraft_content::num).to_vec())); + if let Some(inner) = ops.get(scope_start.saturating_add(1)..=scope_end) { + out.extend_from_slice(inner); + } + out + } + } else { + vec![ + Op::new("q", vec![]), + Op::new("cm", img.draw_matrix.map(pdfcraft_content::num).to_vec()), + ops.get(target).cloned().unwrap_or_else(|| Op::new("Do", vec![Object::name(&img.name)])), + Op::new("Q", vec![]), + ] + }; + let Some(first_op) = ops.get(target_unit.start) else { return Ok(()) }; + let Some(last_op) = target_unit.end.checked_sub(1).and_then(|i| ops.get(i)) else { return Ok(()) }; + (first_op.span.start..last_op.span.end, u_ops) }; - let Some(first_op) = ops.get(target_unit.start) else { return Ok(()) }; - let Some(last_op) = target_unit.end.checked_sub(1).and_then(|i| ops.get(i)) else { return Ok(()) }; - let target_span = first_op.span.start..last_op.span.end; - let dest_pos = if insert_after { let Some(op) = dest_unit.end.checked_sub(1).and_then(|i| ops.get(i)) else { return Ok(()) }; op.span.end @@ -851,21 +890,15 @@ pub fn crop_image(doc: &mut Document, page: usize, index: usize, crop_rect: [f64 (start, end, drawing_ops, scope_matrix) } else { 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; + if i == target { break; } state.apply(o)?; } - let drawing_ops = ops.get(start..=target).unwrap_or_default().to_vec(); - (start, target, drawing_ops, scope_matrix) + let scope_matrix = state.placement.matrix; + let drawing_ops = vec![ops.get(target).cloned().unwrap_or_else(|| Op::new("Do", vec![Object::name(&img.name)]))]; + (target, target, drawing_ops, scope_matrix) }; let mut unit_ops = Vec::new(); diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index a9f61f8bbd..9d74024daa 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -1768,6 +1768,43 @@ fn image_arrange_stacking_order_and_remove_crop() { assert_eq!(img_restored_moved.visible_rect, Some(img_restored_moved.rect)); } +#[test] +fn image_arrange_preserves_shared_group_drawings() { + let content = "q 100 0 0 100 100 100 cm /Im0 Do /Im0 Do Q BT /F1 12 Tf 72 700 Td (End Text) Tj ET"; + let mut doc = image_page_with(content); + let initial = images::page_images(&doc, 0).unwrap(); + assert_eq!(initial.len(), 2, "both images initially present in shared group"); + + // Bring image 0 to front: it must not delete image 1 that shares the same q...Q group! + images::arrange_image(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); + let doc = reopen(&doc); + let after = images::page_images(&doc, 0).unwrap(); + assert_eq!(after.len(), 2, "both images must be preserved after BringToFront"); + let bytes = page_content_bytes(&doc, 0); + let str_content = String::from_utf8_lossy(&bytes); + assert!(str_content.contains("End Text"), "text in page must be preserved"); +} + +#[test] +fn image_crop_preserves_outer_cm_for_subsequent_drawings() { + let content = "100 0 0 100 50 50 cm /Im0 Do /Im0 Do"; + let mut doc = image_page_with(content); + let initial = images::page_images(&doc, 0).unwrap(); + assert_eq!(initial.len(), 2); + assert_eq!(initial[0].rect, [50.0, 50.0, 150.0, 150.0]); + assert_eq!(initial[1].rect, [50.0, 50.0, 150.0, 150.0]); + + // Crop image 0 + images::crop_image(&mut doc, 0, 0, [60.0, 60.0, 120.0, 120.0]).unwrap(); + let doc = reopen(&doc); + let after = images::page_images(&doc, 0).unwrap(); + assert_eq!(after.len(), 2, "both images must remain"); + // Image 0 is cropped: + assert!(close(after[0].visible_rect.unwrap(), [60.0, 60.0, 120.0, 120.0])); + // Crucial: Image 1 must still have its rect at [50, 50, 150, 150], proving outer cm was not destroyed! + assert_eq!(after[1].rect, [50.0, 50.0, 150.0, 150.0], "subsequent image must retain global cm"); +} + #[test] fn form_artwork_edits_only_the_selected_placement() { diff --git a/crates/ui-egui/src/content_ui.rs b/crates/ui-egui/src/content_ui.rs index 20a609ad3e..1b625e1290 100644 --- a/crates/ui-egui/src/content_ui.rs +++ b/crates/ui-egui/src/content_ui.rs @@ -560,25 +560,6 @@ pub(crate) fn format_panel(ui: &mut egui::Ui, t: &Tokens, style: &AddedText) -> for f in [FontFamily::Helvetica, FontFamily::Times, FontFamily::Courier] { ui.selectable_value(&mut s.family, f, f.label()); } - #[cfg(not(target_arch = "wasm32"))] - if let Some(installed) = crate::files::installed_font_files(ui.ctx()) - && !installed.is_empty() - { - ui.separator(); - ui.label(egui::RichText::new(tl!("Installed fonts")).weak().small()); - for font in &installed { - let fam = if font.family.eq_ignore_ascii_case("times") || font.face.to_ascii_lowercase().contains("times") { - FontFamily::Times - } else if font.family.eq_ignore_ascii_case("courier") || font.face.to_ascii_lowercase().contains("courier") { - FontFamily::Courier - } else { - FontFamily::Helvetica - }; - if ui.selectable_label(s.family == fam, &font.face).clicked() { - s.family = fam; - } - } - } }); ui.horizontal(|ui| { egui::ComboBox::from_id_salt("font-size") @@ -789,18 +770,16 @@ impl crate::PdfCraftApp { /// 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() + && cb.get_image().is_ok() { - return cb.get_image().is_ok(); + return true; } } - false + self.last_copied_image.is_some() || self.last_copied_file.is_some() } /// Paste an image from the clipboard onto `page`. @@ -813,27 +792,40 @@ impl crate::PdfCraftApp { return false; } - let image_data: Option<(String, Vec)> = if let Some((name, bytes)) = self.last_copied_file.clone() { + #[cfg(not(target_arch = "wasm32"))] + let os_png = if self.system_clipboard { + if let Ok(mut cb) = arboard::Clipboard::new() + && let Ok(img) = cb.get_image() + { + let matches_internal = self.last_copied_image.as_ref().is_some_and(|(w, h, rgba)| { + *w as usize == img.width && *h as usize == img.height && rgba.as_slice() == img.bytes.as_ref() + }); + if !matches_internal { + crate::create_ui::png_from_rgba(img.width, img.height, &img.bytes).ok().map(|bytes| { + (img.width as u32, img.height as u32, img.bytes.to_vec(), bytes) + }) + } else { + None + } + } else { + None + } + } else { + None + }; + #[cfg(target_arch = "wasm32")] + let os_png: Option<(u32, u32, Vec, Vec)> = None; + + let image_data: Option<(String, Vec)> = if let Some((w, h, rgba, png_bytes)) = os_png { + self.last_copied_image = Some((w, h, rgba)); + self.last_copied_file = Some(("clipboard_image.png".to_string(), png_bytes.clone())); + self.last_copied_image_rect = None; + Some(("clipboard_image.png".to_string(), png_bytes)) + } else 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 }; diff --git a/crates/ui-egui/src/edit_text_ui.rs b/crates/ui-egui/src/edit_text_ui.rs index 48b8a7d552..7af7b1091e 100644 --- a/crates/ui-egui/src/edit_text_ui.rs +++ b/crates/ui-egui/src/edit_text_ui.rs @@ -975,41 +975,73 @@ pub(crate) fn image_input( let initial = drag.initial_rect; if is_crop { let mut new_crop = initial; + let img_screen_w = b.width().max(0.0); + let img_screen_h = b.height().max(0.0); + let safe_min_w = 10.0_f32.min(img_screen_w).min(initial.width().max(0.0)).max(0.0); + let safe_min_h = 10.0_f32.min(img_screen_h).min(initial.height().max(0.0)).max(0.0); 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); + let min_x = b.left(); + let max_x = (initial.right() - safe_min_w).max(min_x); + let x = (initial.left() + delta.x).clamp(min_x, max_x); + + let min_y = b.top(); + let max_y = (initial.bottom() - safe_min_h).max(min_y); + let y = (initial.top() + delta.y).clamp(min_y, max_y); 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); + let min_x = (initial.left() + safe_min_w).min(b.right()); + let max_x = b.right().max(min_x); + let x = (initial.right() + delta.x).clamp(min_x, max_x); + + let min_y = b.top(); + let max_y = (initial.bottom() - safe_min_h).max(min_y); + let y = (initial.top() + delta.y).clamp(min_y, max_y); 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()); + let min_x = b.left(); + let max_x = (initial.right() - safe_min_w).max(min_x); + let x = (initial.left() + delta.x).clamp(min_x, max_x); + + let min_y = (initial.top() + safe_min_h).min(b.bottom()); + let max_y = b.bottom().max(min_y); + let y = (initial.bottom() + delta.y).clamp(min_y, max_y); 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()); + let min_x = (initial.left() + safe_min_w).min(b.right()); + let max_x = b.right().max(min_x); + let x = (initial.right() + delta.x).clamp(min_x, max_x); + + let min_y = (initial.top() + safe_min_h).min(b.bottom()); + let max_y = b.bottom().max(min_y); + let y = (initial.bottom() + delta.y).clamp(min_y, max_y); 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); + let min_y = b.top(); + let max_y = (initial.bottom() - safe_min_h).max(min_y); + let y = (initial.top() + delta.y).clamp(min_y, max_y); new_crop = Rect::from_min_max(Pos2::new(initial.left(), y), initial.max); } DragTarget::CropHandle(ImageHandle::BottomCenter) => { - let y = (initial.bottom() + delta.y).clamp(initial.top() + 10.0, b.bottom()); + let min_y = (initial.top() + safe_min_h).min(b.bottom()); + let max_y = b.bottom().max(min_y); + let y = (initial.bottom() + delta.y).clamp(min_y, max_y); new_crop = Rect::from_min_max(initial.min, Pos2::new(initial.right(), y)); } DragTarget::CropHandle(ImageHandle::LeftCenter) => { - let x = (initial.left() + delta.x).clamp(b.left(), initial.right() - 10.0); + let min_x = b.left(); + let max_x = (initial.right() - safe_min_w).max(min_x); + let x = (initial.left() + delta.x).clamp(min_x, max_x); new_crop = Rect::from_min_max(Pos2::new(x, initial.top()), initial.max); } DragTarget::CropHandle(ImageHandle::RightCenter) => { - let x = (initial.right() + delta.x).clamp(initial.left() + 10.0, b.right()); + let min_x = (initial.left() + safe_min_w).min(b.right()); + let max_x = b.right().max(min_x); + let x = (initial.right() + delta.x).clamp(min_x, max_x); new_crop = Rect::from_min_max(initial.min, Pos2::new(x, initial.bottom())); } DragTarget::Move => { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 1fddfa5363..49fb3b5fcb 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -73,7 +73,8 @@ mod autoscroll; mod bidi; pub mod bulk_fields; mod dialogs; -mod edit_text_ui; +pub mod edit_text_ui; +pub use edit_text_ui::{DragTarget, ImageDrag, ImageHandle, ImageSelection}; mod editing; #[cfg(not(target_arch = "wasm32"))] pub mod file_watch; diff --git a/crates/ui-egui/tests/editing.rs b/crates/ui-egui/tests/editing.rs index daa2c36744..f7630f955d 100644 --- a/crates/ui-egui/tests/editing.rs +++ b/crates/ui-egui/tests/editing.rs @@ -2717,11 +2717,63 @@ fn image_edge_handle_stretch_and_crop_mode() { 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] +fn tiny_image_crop_handle_drag_does_not_panic() { + let mut png = Vec::new(); + image::RgbImage::from_pixel(5, 5, image::Rgb([100, 150, 200])).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(800.0, 600.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("tiny.png", None, png.clone()).expect("opens"); + app + }); + h.run_steps(4); + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(2); + + // Enter crop mode on image 0 + let view = &mut h.state_mut().views[0]; + view.image_selection = Some(pdfcraft_ui_egui::ImageSelection { + page: 0, + index: 0, + drag: None, + crop_mode: true, + crop_rect: None, + }); + h.run_steps(1); + + // Simulate drag on TopLeft crop handle with an initial_rect of 5.0 x 5.0 + let initial = egui::Rect::from_min_size(egui::pos2(100.0, 100.0), egui::vec2(5.0, 5.0)); + let view = &mut h.state_mut().views[0]; + if let Some(s) = view.image_selection.as_mut() { + s.drag = Some(pdfcraft_ui_egui::ImageDrag { + start: egui::pos2(100.0, 100.0), + target: pdfcraft_ui_egui::DragTarget::CropHandle(pdfcraft_ui_egui::ImageHandle::TopLeft), + initial_rect: initial, + }); + } + + // Hover/drag to a new position, triggering the clamp calculation + h.hover_at(egui::pos2(105.0, 105.0)); + h.run_steps(2); + + // Also test BottomRight handle + let view = &mut h.state_mut().views[0]; + if let Some(s) = view.image_selection.as_mut() { + s.drag = Some(pdfcraft_ui_egui::ImageDrag { + start: egui::pos2(105.0, 105.0), + target: pdfcraft_ui_egui::DragTarget::CropHandle(pdfcraft_ui_egui::ImageHandle::BottomRight), + initial_rect: initial, + }); + } + h.hover_at(egui::pos2(95.0, 95.0)); + h.run_steps(2); +} + #[test] fn text_selection_action_icons() { let mut h = harness(1, |_| {});