diff --git a/Cargo.lock b/Cargo.lock index 4e8882f61e..5f6b5922f6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4098,6 +4098,7 @@ dependencies = [ "pdfcraft-fonts", "pdfcraft-model", "pdfcraft-render", + "proptest", "thiserror 2.0.21", "unicode-bidi", ] @@ -4385,6 +4386,7 @@ dependencies = [ "pdfcraft-xfa", "png 0.18.1", "pollster 1.0.1", + "proptest", "rfd", "rustix 1.1.5", "serde", diff --git a/crates/edit/Cargo.toml b/crates/edit/Cargo.toml index f04f5ba17e..5284af0b0d 100644 --- a/crates/edit/Cargo.toml +++ b/crates/edit/Cargo.toml @@ -23,3 +23,4 @@ unicode-bidi = "0.3" [dev-dependencies] hayro-syntax = { workspace = true } pdfcraft-render = { workspace = true } +proptest = { workspace = true } diff --git a/crates/edit/src/images.rs b/crates/edit/src/images.rs index ab3be96a6a..5b7bbfd8d9 100644 --- a/crates/edit/src/images.rs +++ b/crates/edit/src/images.rs @@ -156,9 +156,20 @@ impl ImageState { Self { placement: Placement { matrix, clip, clips }, stack: Vec::new(), path: BezPath::new(), pending_clip: None } } + fn has_current_point(&self) -> bool { + matches!( + self.path.elements().last(), + Some(kurbo::PathEl::MoveTo(_) | kurbo::PathEl::LineTo(_) | kurbo::PathEl::QuadTo(_, _) | kurbo::PathEl::CurveTo(_, _, _)) + ) + } + fn point(&self, x: f64, y: f64) -> Point { let (x, y) = self.placement.matrix.apply(x, y); - Point::new(x, y) + if x.is_finite() && y.is_finite() { + Point::new(x, y) + } else { + Point::ZERO + } } fn apply(&mut self, op: &Op) -> Result<(), EditError> { @@ -179,7 +190,10 @@ impl ImageState { } b"cm" => { if let Some(m) = op.nums::<6>() { - self.placement.matrix = Matrix(m).then(&self.placement.matrix); + let next_mat = Matrix(m).then(&self.placement.matrix); + if next_mat.0.iter().all(|v| v.is_finite()) { + self.placement.matrix = next_mat; + } } } b"m" => { @@ -189,28 +203,52 @@ impl ImageState { } b"l" => { if let Some([x, y]) = op.nums::<2>() { - self.path.line_to(self.point(x, y)); + let pt = self.point(x, y); + if !self.has_current_point() { + self.path.move_to(pt); + } else { + self.path.line_to(pt); + } } } b"c" => { if let Some([x1, y1, x2, y2, x3, y3]) = op.nums::<6>() { - self.path.curve_to(self.point(x1, y1), self.point(x2, y2), self.point(x3, y3)); + let p1 = self.point(x1, y1); + let p2 = self.point(x2, y2); + let p3 = self.point(x3, y3); + if !self.has_current_point() { + self.path.move_to(p1); + } + self.path.curve_to(p1, p2, p3); } } b"v" => { - if let Some([x1, y1, x2, y2]) = op.nums::<4>() - && let Some(current) = self.path.elements().last().and_then(|e| e.end_point()) - { - self.path.curve_to(current, self.point(x1, y1), self.point(x2, y2)); + if let Some([x1, y1, x2, y2]) = op.nums::<4>() { + let p1 = self.point(x1, y1); + let p2 = self.point(x2, y2); + if let Some(current) = self.path.elements().last().and_then(|e| e.end_point()) { + self.path.curve_to(current, p1, p2); + } else { + self.path.move_to(p1); + self.path.curve_to(p1, p1, p2); + } } } b"y" => { if let Some([x1, y1, x2, y2]) = op.nums::<4>() { + let p1 = self.point(x1, y1); let end = self.point(x2, y2); - self.path.curve_to(self.point(x1, y1), end, end); + if !self.has_current_point() { + self.path.move_to(p1); + } + self.path.curve_to(p1, end, end); + } + } + b"h" => { + if self.has_current_point() { + self.path.close_path(); } } - b"h" => self.path.close_path(), b"re" => { if let Some([x, y, w, h]) = op.nums::<4>() { self.path.move_to(self.point(x, y)); @@ -494,7 +532,7 @@ fn clip_matrix(draw: Matrix, cm: Matrix, t: Matrix) -> Option { Some(outer.then(&t).then(&outer.invert()?)) } -fn is_entire_page_wrapped(ops: &[Op]) -> bool { +pub(crate) fn is_entire_page_wrapped(ops: &[Op]) -> bool { if ops.len() < 2 || !ops.first().is_some_and(|o| o.is("q")) || !ops.last().is_some_and(|o| o.is("Q")) { return false; } @@ -512,7 +550,7 @@ fn is_entire_page_wrapped(ops: &[Op]) -> bool { depth == 0 } -fn content_units(ops: &[Op], scan_start: usize, scan_end: usize) -> Vec> { +pub(crate) fn content_units(ops: &[Op], scan_start: usize, scan_end: usize) -> Vec> { let mut units = Vec::new(); let mut i = scan_start; while i < scan_end { diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 69fa8f4b4a..0814dbe45a 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -5930,3 +5930,85 @@ fn a_clipping_copy_on_the_same_baseline_is_refused() { assert!(matches!(&block, Err(EditError::Invalid(m)) if m.contains("clipping path")), "{block:?}"); assert_eq!(text::text_lines(&doc, 0).unwrap().len(), 2, "refused means untouched"); } + +#[cfg(test)] +mod proptests { + use super::*; + use proptest::prelude::*; + + proptest! { + #[test] + fn prop_crop_ctm_finite_for_valid_inputs( + a in -1000.0f64..1000.0, + b in -1000.0f64..1000.0, + c in -1000.0f64..1000.0, + d in -1000.0f64..1000.0, + e in -1000.0f64..1000.0, + f in -1000.0f64..1000.0, + x1 in -1000.0f64..1000.0, + y1 in -1000.0f64..1000.0, + w in 1.0f64..1000.0, + h in 1.0f64..1000.0, + ) { + let ctm = [a, b, c, d, e, f]; + let crop = [x1, y1, x1 + w, y1 + h]; + let res = images::crop_ctm(ctm, crop); + for v in res { + prop_assert!(v.is_finite()); + } + } + + #[test] + fn prop_is_entire_page_wrapped_arbitrary_bytes(bytes in proptest::collection::vec(any::(), 0..2048)) { + let pieces = pdfcraft_content::Pieces::join(&[&bytes]); + let ops = pieces.parse().ops; + let _ = images::is_entire_page_wrapped(&ops); + } + + #[test] + fn prop_content_units_arbitrary_bytes( + bytes in proptest::collection::vec(any::(), 0..2048), + start in 0usize..50, + end in 0usize..50, + ) { + let pieces = pdfcraft_content::Pieces::join(&[&bytes]); + let ops = pieces.parse().ops; + let scan_start = start.min(ops.len()); + let scan_end = end.min(ops.len()).max(scan_start); + let units = images::content_units(&ops, scan_start, scan_end); + for u in units { + prop_assert!(u.start <= u.end); + prop_assert!(u.end <= ops.len()); + } + } + + #[test] + fn prop_arrange_image_arbitrary_malformed( + index in 0usize..10, + order_idx in 0u8..4, + bytes in proptest::collection::vec(any::(), 0..1024), + ) { + let order = match order_idx { + 0 => images::ArrangeOrder::BringToFront, + 1 => images::ArrangeOrder::SendToBack, + 2 => images::ArrangeOrder::BringForward, + _ => images::ArrangeOrder::SendBackward, + }; + let mut doc = image_page_with(&String::from_utf8_lossy(&bytes)); + let _ = images::arrange_image(&mut doc, 0, index, order); + } + + #[test] + fn prop_crop_image_malformed_inputs( + x1 in -500.0f64..500.0, + y1 in -500.0f64..500.0, + x2 in -500.0f64..500.0, + y2 in -500.0f64..500.0, + bytes in proptest::collection::vec(any::(), 0..1024), + ) { + let mut doc = image_page_with(&String::from_utf8_lossy(&bytes)); + let _ = images::crop_image(&mut doc, 0, 0, [x1, y1, x2, y2]); + let _ = images::remove_image_crop(&mut doc, 0, 0); + } + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index a3132fb636..aea668936a 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -2580,6 +2580,37 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> ImageEdit::RemoveCrop => pdfcraft_edit::ImageChange::RemoveCrop, }; pdfcraft_edit::change_image(doc, *page, *index, &c)?; + if let Some(target_ref) = img.object { + let added_items: Vec<(usize, Added)> = + pdfcraft_edit::list_added(doc).into_iter().filter(|a| a.page == *page).enumerate().collect(); + if let Some((added_idx, added_item)) = + added_items.into_iter().find(|(_, a)| matches!(&a.content, AddedContent::Image(ai) if ai.image == target_ref)) + && let AddedContent::Image(mut ai) = added_item.content + { + match change { + ImageEdit::Rotate(q) => { + let k = q.rem_euclid(4) as u8; + ai.rotation = (ai.rotation + k) % 4; + let r = ai.rect; + let (cx, cy) = ((r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0); + if k % 2 == 1 { + let (w, h) = (r[2] - r[0], r[3] - r[1]); + ai.rect = [cx - h / 2.0, cy - w / 2.0, cx + h / 2.0, cy + w / 2.0]; + } + let _ = pdfcraft_edit::update_content(doc, *page, added_idx, &AddedContent::Image(ai)); + } + ImageEdit::Flip { horizontal: true } => { + ai.flip_h = !ai.flip_h; + let _ = pdfcraft_edit::update_content(doc, *page, added_idx, &AddedContent::Image(ai)); + } + ImageEdit::Flip { horizontal: false } => { + ai.flip_v = !ai.flip_v; + let _ = pdfcraft_edit::update_content(doc, *page, added_idx, &AddedContent::Image(ai)); + } + _ => {} + } + } + } } Edit::MoveObjects { page, objects, offset } => pdfcraft_edit::move_objects(doc, *page, objects, *offset)?, Edit::EditTextBlock { page, block, text, style } => { @@ -3995,7 +4026,12 @@ impl Session { let touches_added = edit.is_none_or(|e| { matches!( e, - Edit::AddText { .. } | Edit::AddImage { .. } | Edit::UpdateContent { .. } | Edit::DeleteContent { .. } | Edit::ReplaceImage { .. } + Edit::AddText { .. } + | Edit::AddImage { .. } + | Edit::UpdateContent { .. } + | Edit::DeleteContent { .. } + | Edit::ReplaceImage { .. } + | Edit::EditPageImage { .. } ) || matches!(e, Edit::MoveObjects { objects, .. } if objects.iter().any(|o| o.kind == ObjectKind::Added)) }); let touches_marks = edit.is_none_or(|e| { diff --git a/crates/engine/src/text_fonts.rs b/crates/engine/src/text_fonts.rs index 41a26aabdb..ac9664db94 100644 --- a/crates/engine/src/text_fonts.rs +++ b/crates/engine/src/text_fonts.rs @@ -65,19 +65,34 @@ pub fn discover(roots: &[PathBuf]) -> Vec { if read.is_err() { continue; } - if let Ok(bytes) = read.map(|_| bytes) { - if pdfcraft_fonts::embedding::validate_document_input(&bytes).is_err() { - continue; - } - if let Ok((family, face)) = pdfcraft_fonts::embedding::names(&bytes) { - files.push(FontFile { path, family, face }); - } + if pdfcraft_fonts::embedding::validate_document_input(&bytes).is_err() { + continue; + } + if let Ok((family, face)) = pdfcraft_fonts::embedding::names(&bytes) { + let key = font_content_key(&bytes); + files.push((FontFile { path, family, face }, key)); } } } - files.sort_by(|a, b| a.face.cmp(&b.face).then(a.path.cmp(&b.path))); - files.dedup_by(|a, b| a.path == b.path || (a.face == b.face && std::fs::read(&a.path).ok() == std::fs::read(&b.path).ok())); - files + files.sort_by(|(a, _), (b, _)| a.face.cmp(&b.face).then(a.path.cmp(&b.path))); + let mut seen_face_content = std::collections::HashSet::new(); + let mut deduped = Vec::new(); + for (file, key) in files { + if seen_face_content.insert((file.face.clone(), key)) { + deduped.push(file); + } + } + deduped +} + +fn font_content_key(bytes: &[u8]) -> (usize, u64, u64) { + use std::hash::{DefaultHasher, Hasher}; + let mut h1 = DefaultHasher::new(); + h1.write(bytes); + let mut h2 = DefaultHasher::new(); + h2.write_u8(0xff); + h2.write(bytes); + (bytes.len(), h1.finish(), h2.finish()) } fn font_extension(path: &Path) -> bool { path.extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("ttf") || e.eq_ignore_ascii_case("otf")) @@ -92,4 +107,69 @@ mod tests { let dirs = installed_directories(); assert!(!dirs.is_empty()); } + + #[test] + fn test_font_extension() { + assert!(font_extension(Path::new("Arial.ttf"))); + assert!(font_extension(Path::new("Noto.otf"))); + assert!(font_extension(Path::new("CUSTOM.TTF"))); + assert!(font_extension(Path::new("ROBOTO.OTF"))); + assert!(!font_extension(Path::new("document.pdf"))); + assert!(!font_extension(Path::new("font.woff"))); + assert!(!font_extension(Path::new("font.woff2"))); + assert!(!font_extension(Path::new("no_extension"))); + } + + #[test] + fn test_font_content_key() { + let empty = font_content_key(&[]); + assert_eq!(empty.0, 0); + + let a = font_content_key(b"TrueType font sample data bytes"); + let b = font_content_key(b"TrueType font sample data bytes"); + let c = font_content_key(b"Different font sample data bytes"); + + assert_eq!(a, b, "identical bytes must produce identical keys"); + assert_ne!(a, c, "differing bytes must produce distinct keys"); + assert_eq!(a.0, b"TrueType font sample data bytes".len()); + } + + #[test] + fn test_discover_empty_and_nonexistent_roots() { + let empty = discover(&[]); + assert!(empty.is_empty()); + + let nonexistent = discover(&[PathBuf::from("Z:\\nonexistent_font_path_xyz_12345")]); + assert!(nonexistent.is_empty()); + } + + #[test] + fn test_discover_deduplicates_non_adjacent_same_content() { + let root = std::env::temp_dir().join(format!("pdfcraft-font-dedup-test-{}", std::process::id())); + let dir_a = root.join("a"); + let dir_b = root.join("b"); + let dir_c = root.join("c"); + let _ = std::fs::create_dir_all(&dir_a); + let _ = std::fs::create_dir_all(&dir_b); + let _ = std::fs::create_dir_all(&dir_c); + + let inter_reg = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../assets/fonts/Inter-Regular.ttf"); + let inter_med = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../assets/fonts/Inter-Medium.ttf"); + + if inter_reg.exists() && inter_med.exists() { + let _ = std::fs::copy(&inter_reg, dir_a.join("FontA.ttf")); + let _ = std::fs::copy(&inter_med, dir_b.join("FontB.ttf")); + let _ = std::fs::copy(&inter_reg, dir_c.join("FontC.ttf")); + + let discovered = discover(std::slice::from_ref(&root)); + let _ = std::fs::remove_dir_all(&root); + + assert_eq!(discovered.len(), 2, "identical content in non-adjacent paths is deduplicated"); + let faces: Vec<&str> = discovered.iter().map(|f| f.face.as_str()).collect(); + assert!(faces.contains(&"Inter-Regular")); + assert!(faces.contains(&"Inter-Medium")); + } else { + let _ = std::fs::remove_dir_all(&root); + } + } } diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 42e9474f07..4178510adb 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -79,6 +79,7 @@ egui_kittest = { workspace = true, features = ["eframe", "wgpu"] } image = { version = "0.25", default-features = false, features = ["png"] } # Glyph-coverage test of the interface fonts; the same skrifa pdfcraft-fonts already uses. skrifa = "0.44" +proptest = { workspace = true } [build-dependencies] serde_json = { workspace = true } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 99ee327f90..50da90c7c5 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -3203,9 +3203,20 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { }); // Each document keeps its own scroll position when switching tabs (#189). scroll = scroll.id_salt(("page-view", view.id)); - // Every scroll here asks for no animation. An animated area would still queue a zero-length - // offset target for the next frame, which overwrites the jump a page turn just set. - scroll = scroll.animated(false); + // When a page jump or programmatic reposition occurs, or in non-continuous page display + // where single-page wheel turns jump to the page edge, disable animation so egui's + // queued target does not overwrite or ease the jump. For continuous page scrolling, + // animate if motion effects are enabled. + let has_jump = !view.shown + || edge_turned + || view.goto.is_some() + || view.goto_point.is_some() + || view.zoom_anchor.is_some() + || view.sync_scroll.is_some() + || (app.quick_tool == QuickTool::Hand + && !middle_gesture + && view.page_widget.is_some_and(|id| ui.ctx().is_being_dragged(id))); + scroll = scroll.animated(view.continuous && animates && !has_jump); // A newly opened document starts at the top. eframe saves egui's memory with the settings // and document ids start again at 1 every launch, so the offset stored under this id can be // where an earlier session left another document. @@ -3391,7 +3402,8 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { let wheel_delta = if selection_wheel { Vec2::ZERO } else { precise_scroll + boost_extra }; let delta = auto_delta + edge_delta - vec2(0.0, key_scroll) + wheel_delta + glide; if delta != Vec2::ZERO && delta.is_finite() { - ui.scroll_with_delta_animation(delta, egui::style::ScrollAnimation::none()); + let anim = if animates && key_scroll != 0.0 { ui.style().scroll_animation } else { egui::style::ScrollAnimation::none() }; + ui.scroll_with_delta_animation(delta, anim); } let (resp_rect, resp) = ui.allocate_exact_size(vec2(content_w, content_h), Sense::click_and_drag()); view.page_widget = Some(resp.id); diff --git a/crates/ui-egui/src/content_ui.rs b/crates/ui-egui/src/content_ui.rs index 02f6c6e2e5..128679e19b 100644 --- a/crates/ui-egui/src/content_ui.rs +++ b/crates/ui-egui/src/content_ui.rs @@ -7,7 +7,7 @@ use egui::{Color32, CornerRadius, Pos2, Rect, Stroke}; use pdfcraft_engine::{Added, AddedContent, AddedText, Edit, FontFamily, TextAlign}; use pdfcraft_render::DocInfo; -use crate::canvas::{DocView, ImageObb, PageXform, PainterExt}; +use crate::canvas::{DocView, PageXform}; use crate::theme::Tokens; use crate::widgets; @@ -253,7 +253,10 @@ fn on_page(added: &[Added], page: usize) -> Vec<(usize, &Added)> { } pub(crate) fn hits_added(xf: &PageXform, info: &DocInfo, page: usize, added: &[Added], point: Pos2) -> bool { - on_page(added, page).iter().any(|(_, a)| screen_rect(xf, info, page, a.content.rect()).expand(2.0).contains(point)) + on_page(added, page) + .iter() + .filter(|(_, a)| matches!(a.content, AddedContent::Text(_))) + .any(|(_, a)| screen_rect(xf, info, page, a.content.rect()).expand(2.0).contains(point)) } fn handles(r: Rect) -> [(Pos2, bool, bool); 4] { @@ -287,7 +290,7 @@ fn dragged(r: Rect, grab: Grab, d: egui::Vec2, keep_aspect: bool) -> Rect { } } -/// Added text and images keep pointer ownership over a form field while editing content. +/// Added text keeps pointer ownership over a form field while editing content. pub(crate) fn select_grabs(ui: &egui::Ui, xf: &PageXform, page: usize, info: &DocInfo, added: &[Added], view: &DocView) -> bool { let cv = &view.content; if cv.grab.is_some_and(|(grab_page, _, _)| grab_page == page) { @@ -296,7 +299,7 @@ pub(crate) fn select_grabs(ui: &egui::Ui, xf: &PageXform, page: usize, info: &Do let Some(p) = ui.input(|i| i.pointer.hover_pos().or(i.pointer.interact_pos())).filter(|p| xf.rect.contains(*p)) else { return false; }; - on_page(added, page).into_iter().any(|(index, item)| { + on_page(added, page).into_iter().filter(|(_, a)| matches!(a.content, AddedContent::Text(_))).any(|(index, item)| { let rect = screen_rect(xf, info, page, item.content.rect()); rect.expand(2.0).contains(p) || cv.selected == Some((page, index)) && handles(rect).into_iter().any(|(corner, _, _)| corner.distance(p) <= 6.0) @@ -328,13 +331,14 @@ pub(crate) fn page_input( if resp.drag_stopped() || !ui.input(|i| i.pointer.primary_down()) { cv.grab = None; let end = pointer.unwrap_or(start); + let items: Vec<(usize, &Added)> = + on_page(added, page).into_iter().filter(|(_, a)| matches!(a.content, AddedContent::Text(_))).collect(); if let Some((sp, si)) = cv.selected && sp == page - && let Some((_, a)) = on_page(added, page).into_iter().find(|(i, _)| *i == si) + && let Some((_, a)) = items.iter().find(|(i, _)| *i == si) && (end - start).length() >= 1.0 { - let keep = matches!(a.content, AddedContent::Image(_)); - let r = dragged(screen_rect(xf, info, page, a.content.rect()), grab, end - start, keep); + let r = dragged(screen_rect(xf, info, page, a.content.rect()), grab, end - start, false); let (p0, p1) = (to_display(xf, info, page, r.left_bottom()), to_display(xf, info, page, r.right_top())); let mut rect = [p0[0], p0[1], p1[0], p1[1]]; if let AddedContent::Text(t) = &a.content @@ -349,7 +353,8 @@ pub(crate) fn page_input( return consumed; } let Some(p) = pointer.filter(|p| xf.rect.contains(*p)) else { return consumed }; - let items = on_page(added, page); + let items: Vec<(usize, &Added)> = + on_page(added, page).into_iter().filter(|(_, a)| matches!(a.content, AddedContent::Text(_))).collect(); let hit = items.iter().rev().find(|(_, a)| screen_rect(xf, info, page, a.content.rect()).expand(2.0).contains(p)).map(|(i, a)| (*i, *a)); let selected_rect = cv .selected @@ -436,6 +441,9 @@ pub(crate) fn paint_page(ui: &egui::Ui, painter: &egui::Painter, xf: &PageXform, let cv = &view.content; let pointer = ui.input(|i| i.pointer.hover_pos()); for (i, a) in on_page(added, page) { + if !matches!(a.content, AddedContent::Text(_)) { + continue; + } if cv.draft.as_ref().is_some_and(|d| d.page == page && d.index == Some(i)) { continue; } @@ -445,42 +453,13 @@ pub(crate) fn paint_page(ui: &egui::Ui, painter: &egui::Painter, xf: &PageXform, && let (Some((gp, g, start)), Some(p)) = (cv.grab, pointer) && gp == page { - r = dragged(r, g, p - start, matches!(a.content, AddedContent::Image(_))); + r = dragged(r, g, p - start, false); } - let is_rotated_image = match &a.content { - AddedContent::Image(img) => img.rotation != 0, - _ => false, - }; - if is_rotated_image { - let theta = match &a.content { - AddedContent::Image(img) => (img.rotation as f32) * std::f32::consts::FRAC_PI_2, - _ => 0.0, - }; - let obb = ImageObb::from_center_size_angle(r.center(), r.width(), r.height(), theta); - if selected { - painter.polygon(obb.corners().to_vec(), Color32::TRANSPARENT, Stroke::new(1.5, SELECT_BLUE)); - for (_, c) in obb.handles() { - painter.circle(c, 3.5, Color32::WHITE, Stroke::new(1.5, SELECT_BLUE)); - } - } else if pointer.is_some_and(|p| r.expand(2.0).contains(p)) { - painter.polygon(obb.corners().to_vec(), Color32::TRANSPARENT, Stroke::new(1.0, SELECT_BLUE.gamma_multiply(0.7))); - } - } else if selected { + if selected { painter.rect_stroke(r.expand(1.0), CornerRadius::ZERO, Stroke::new(1.5, SELECT_BLUE), egui::StrokeKind::Outside); let er = r.expand(1.0); - if matches!(a.content, AddedContent::Image(_)) { - let (tl, tr, br, bl) = (er.left_top(), er.right_top(), er.right_bottom(), er.left_bottom()); - let tm = Pos2::new((tl.x + tr.x) * 0.5, tl.y); - let rm = Pos2::new(tr.x, (tr.y + br.y) * 0.5); - let bm = Pos2::new((bl.x + br.x) * 0.5, bl.y); - let lm = Pos2::new(tl.x, (tl.y + bl.y) * 0.5); - for c in [tl, tm, tr, rm, br, bm, bl, lm] { - painter.circle(c, 3.5, Color32::WHITE, Stroke::new(1.5, SELECT_BLUE)); - } - } else { - for (c, ..) in handles(er) { - painter.circle(c, 3.5, Color32::WHITE, Stroke::new(1.5, SELECT_BLUE)); - } + for (c, ..) in handles(er) { + painter.circle(c, 3.5, Color32::WHITE, Stroke::new(1.5, SELECT_BLUE)); } } else if pointer.is_some_and(|p| r.expand(2.0).contains(p)) { painter.rect_stroke(r.expand(1.0), CornerRadius::ZERO, Stroke::new(1.0, SELECT_BLUE.gamma_multiply(0.7)), egui::StrokeKind::Outside); @@ -504,12 +483,12 @@ pub(crate) fn keys(ctx: &egui::Context, view: &mut DocView) { /// After an add: select the new item. pub(crate) fn after_refresh(view: &mut DocView, added: &[Added]) { if let Some((page, before)) = view.content.select_added.take() - && on_page(added, page).len() > before + && on_page(added, page).get(before).is_some_and(|(_, a)| matches!(a.content, AddedContent::Text(_))) { view.content.selected = Some((page, before)); } if let Some((p, i)) = view.content.selected - && on_page(added, p).len() <= i + && on_page(added, p).get(i).is_none_or(|(_, a)| !matches!(a.content, AddedContent::Text(_))) { view.content.selected = None; } @@ -1034,9 +1013,16 @@ impl crate::PdfCraftApp { pub fn add_image(&mut self, name: String, bytes: Vec) { let Some((i, id)) = self.active_ids() else { return }; let page = self.views[i].current; - let before = self.session.get(id).map_or(0, |d| d.added().iter().filter(|a| a.page == page).count()); + let before = self.session.get(id).map_or(0, |d| d.page_images(page).len()); if self.apply_edit(Edit::AddImage { page, rect: None, name, bytes: std::sync::Arc::new(bytes) }) { - self.views[i].content.select_added = Some((page, before)); + if let Some(v) = self.views.get_mut(i) { + v.current = page; + v.content.selected = None; + v.content.select_added = None; + v.image_selection = + Some(crate::edit_text_ui::ImageSelection { page, index: before, crop_mode: false, crop_rect: None, drag: None, crop: None }); + } + self.quick_tool = crate::QuickTool::EditText; self.left = crate::LeftPanel::Tool("edit"); self.left_open = true; } @@ -1044,6 +1030,7 @@ impl crate::PdfCraftApp { } /// What the image tools ask for. +#[cfg(test)] pub(crate) enum ImageAction { Update(pdfcraft_engine::AddedContent), Replace, @@ -1051,11 +1038,14 @@ pub(crate) enum ImageAction { // Lucide names a flip icon after the axis it draws, Acrobat names the flip after the direction the // picture moves: Flip horizontal (left and right swap) shows Lucide's vertical axis. -const FLIP_HORIZONTAL_ICON: &str = "flip-vertical-2"; -const FLIP_VERTICAL_ICON: &str = "flip-horizontal-2"; +#[cfg(test)] +pub(crate) const FLIP_HORIZONTAL_ICON: &str = "flip-vertical-2"; +#[cfg(test)] +pub(crate) const FLIP_VERTICAL_ICON: &str = "flip-horizontal-2"; -/// Edit image: rotate, flip, crop, replace (shown while an added image is selected). -pub(crate) fn image_panel(ui: &mut egui::Ui, t: &Tokens, img: &pdfcraft_engine::AddedImage) -> Option { +/// Edit image: rotate, flip, replace (shown while an added image is selected). +#[cfg(test)] +pub(crate) fn image_panel(ui: &mut egui::Ui, _t: &Tokens, img: &pdfcraft_engine::AddedImage) -> Option { let mut out = None; widgets::section_title(ui, tl!("Edit image")); ui.horizontal(|ui| { @@ -1087,26 +1077,6 @@ pub(crate) fn image_panel(ui: &mut egui::Ui, t: &Tokens, img: &pdfcraft_engine:: out = Some(ImageAction::Replace); } }); - ui.label(egui::RichText::new(tl!("Crop (% trimmed from each side)")).small().color(t.text_muted)); - let mut crop = img.crop.map(|v| (v * 100.0).round()); - let mut changed = false; - ui.horizontal(|ui| { - for (k, label) in ["L", "B", "R", "T"].into_iter().enumerate() { - ui.label(label); - // Applied when the drag ends, so a drag is one undo step. - let r = ui.add(egui::DragValue::new(&mut crop[k]).range(0.0..=95.0).speed(0.5).suffix("%")); - changed |= r.drag_stopped() || (r.changed() && !r.dragged()); - } - }); - if changed { - let mut i = img.clone(); - let new_crop = crop.map(|v| (v / 100.0).clamp(0.0, 0.95)); - if new_crop != img.crop { - update_image_crop(&mut i, new_crop); - ui.ctx().request_repaint(); - out = Some(ImageAction::Update(AddedContent::Image(i))); - } - } out } diff --git a/crates/ui-egui/src/edit_text_ui.rs b/crates/ui-egui/src/edit_text_ui.rs index 263913e40e..a70f6d0bee 100644 --- a/crates/ui-egui/src/edit_text_ui.rs +++ b/crates/ui-egui/src/edit_text_ui.rs @@ -810,6 +810,9 @@ fn screen_to_user(xf: &PageXform, info: &DocInfo, page: usize, pt: Pos2) -> (f64 } pub(crate) fn quad_contains(quad: [Pos2; 4], p: Pos2) -> bool { + if !p.x.is_finite() || !p.y.is_finite() || quad.iter().any(|pt| !pt.x.is_finite() || !pt.y.is_finite()) { + return false; + } let mut sign = 0; for i in 0..4 { let p1 = quad[i]; diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index 4542700bd6..0ef6c07f22 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -45,7 +45,19 @@ impl PdfCraftApp { let label = edit.label(); let target_arrange_image = match &edit { Edit::EditPageImage { page, index, change: pdfcraft_engine::ImageEdit::Arrange(_) } => { - self.session.get(id).and_then(|doc| doc.page_images(*page).get(*index).cloned()) + let doc = self.session.get(id); + let target = doc.and_then(|d| d.page_images(*page).get(*index).cloned()); + let ambiguous = doc.is_some_and(|d| { + if let Some(t) = &target { + d.page_images(*page) + .iter() + .filter(|img| img.name == t.name && img.object == t.object && img.rect == t.rect) + .count() > 1 + } else { + false + } + }); + target.map(|t| (t, ambiguous)) } _ => None, }; @@ -171,13 +183,43 @@ impl PdfCraftApp { Edit::DeleteAnnotation { .. } => view.comments.selected = None, _ => {} } - if let (Some(target), Edit::EditPageImage { page, .. }) = (target_arrange_image, &edit) { - let new_idx = doc - .page_images(*page) + if let (Some((target, ambiguous_before)), Edit::EditPageImage { page, .. }) = (target_arrange_image, &edit) { + let page_images = doc.page_images(*page); + let exact_matches: Vec = page_images .iter() - .position(|img| img.name == target.name && img.object == target.object && img.rect == target.rect) - .or_else(|| doc.page_images(*page).iter().position(|img| img.name == target.name && img.object == target.object)) - .or_else(|| doc.page_images(*page).iter().position(|img| img.name == target.name)); + .enumerate() + .filter(|(_, img)| img.name == target.name && img.object == target.object && img.rect == target.rect) + .map(|(idx, _)| idx) + .collect(); + let new_idx = if !ambiguous_before && exact_matches.len() == 1 { + Some(exact_matches[0]) + } else if !ambiguous_before && exact_matches.is_empty() { + let name_obj_matches: Vec = page_images + .iter() + .enumerate() + .filter(|(_, img)| img.name == target.name && img.object == target.object) + .map(|(idx, _)| idx) + .collect(); + if name_obj_matches.len() == 1 { + Some(name_obj_matches[0]) + } else if name_obj_matches.is_empty() { + let name_matches: Vec = page_images + .iter() + .enumerate() + .filter(|(_, img)| img.name == target.name) + .map(|(idx, _)| idx) + .collect(); + if name_matches.len() == 1 { + Some(name_matches[0]) + } else { + None + } + } else { + None + } + } else { + None + }; if let Some(s) = view.image_selection.as_mut().filter(|s| s.page == *page) { if let Some(idx) = new_idx { s.index = idx; diff --git a/crates/ui-egui/src/image_engine.rs b/crates/ui-egui/src/image_engine.rs index 20e2d6e8ed..8418786cdc 100644 --- a/crates/ui-egui/src/image_engine.rs +++ b/crates/ui-egui/src/image_engine.rs @@ -139,7 +139,7 @@ pub const MIN_RECT_SIZE: f32 = 10.0; /// - Mid-edge handles: The opposite edge coordinate is fixed in 1D. The perpendicular axis is /// completely unaffected. pub fn apply_resize(image_rect: Rect, handle: HandleId, delta: Vec2) -> Rect { - if !delta.x.is_finite() || !delta.y.is_finite() { + if !image_rect.is_finite() || !delta.x.is_finite() || !delta.y.is_finite() { return image_rect; } let mut min = image_rect.min; @@ -235,7 +235,7 @@ pub fn apply_resize(image_rect: Rect, handle: HandleId, delta: Vec2) -> Rect { /// width and height uniformly from the locked anchor point, clamped to `>= MIN_RECT_SIZE`. /// For mid-edge handles, delegates to `apply_resize`. pub fn apply_resize_proportional(image_rect: Rect, handle: HandleId, delta: Vec2) -> Rect { - if !delta.x.is_finite() || !delta.y.is_finite() { + if !image_rect.is_finite() || !delta.x.is_finite() || !delta.y.is_finite() { return image_rect; } let w0 = image_rect.width().max(1.0); @@ -411,7 +411,7 @@ pub fn apply_crop(image_rect: Rect, uv_rect: Rect, handle: HandleId, delta: Vec2 /// Resets the crop boundary back to [0.0..1.0] and expands `image_rect` /// to reveal the full image at the current screen pixel scale without shifting existing pixels. pub fn reset_crop(image_rect: Rect, uv_rect: Rect) -> (Rect, Rect) { - if uv_rect.width() <= 0.0 || uv_rect.height() <= 0.0 || !uv_rect.width().is_finite() || !uv_rect.height().is_finite() { + if !image_rect.is_finite() || uv_rect.width() <= 0.0 || uv_rect.height() <= 0.0 || !uv_rect.width().is_finite() || !uv_rect.height().is_finite() { return (image_rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0))); } @@ -642,4 +642,139 @@ mod tests { assert_eq!(resized.width(), 120.0); assert_eq!(resized.height(), 240.0); } + + #[cfg(test)] + mod proptests { + use super::*; + use proptest::prelude::*; + + fn arb_handle() -> impl Strategy { + prop_oneof![ + Just(HandleId::TopLeft), + Just(HandleId::TopMid), + Just(HandleId::TopRight), + Just(HandleId::RightMid), + Just(HandleId::BottomRight), + Just(HandleId::BottomMid), + Just(HandleId::BottomLeft), + Just(HandleId::LeftMid), + ] + } + + proptest! { + #[test] + fn prop_apply_resize_never_inverts_and_respects_min_size( + min_x in -5000.0f32..5000.0, + min_y in -5000.0f32..5000.0, + w in 10.0f32..2000.0, + h in 10.0f32..2000.0, + handle in arb_handle(), + dx in -10000.0f32..10000.0, + dy in -10000.0f32..10000.0, + ) { + let rect = Rect::from_min_size(pos2(min_x, min_y), vec2(w, h)); + let res = apply_resize(rect, handle, vec2(dx, dy)); + prop_assert!(res.is_finite()); + prop_assert!(res.width() >= MIN_RECT_SIZE - 1e-3); + prop_assert!(res.height() >= MIN_RECT_SIZE - 1e-3); + prop_assert!(res.min.x <= res.max.x); + prop_assert!(res.min.y <= res.max.y); + } + + #[test] + fn prop_apply_resize_proportional_preserves_aspect_ratio( + min_x in -2000.0f32..2000.0, + min_y in -2000.0f32..2000.0, + w in 20.0f32..1000.0, + h in 20.0f32..1000.0, + handle in arb_handle(), + dx in -5000.0f32..5000.0, + dy in -5000.0f32..5000.0, + ) { + let rect = Rect::from_min_size(pos2(min_x, min_y), vec2(w, h)); + let res = apply_resize_proportional(rect, handle, vec2(dx, dy)); + prop_assert!(res.is_finite()); + prop_assert!(res.width() >= MIN_RECT_SIZE - 1e-3); + prop_assert!(res.height() >= MIN_RECT_SIZE - 1e-3); + + let is_corner = matches!(handle, HandleId::TopLeft | HandleId::TopRight | HandleId::BottomLeft | HandleId::BottomRight); + if is_corner { + let orig_ratio = w / h; + let new_ratio = res.width() / res.height(); + prop_assert!((orig_ratio - new_ratio).abs() < 1e-3, "orig: {orig_ratio}, new: {new_ratio}"); + } + } + + #[test] + fn prop_apply_crop_uv_clamped( + min_x in 0.0f32..1000.0, + min_y in 0.0f32..1000.0, + w in 50.0f32..500.0, + h in 50.0f32..500.0, + uv_u0 in 0.0f32..0.4, + uv_v0 in 0.0f32..0.4, + uv_w in 0.3f32..0.5, + uv_h in 0.3f32..0.5, + handle in arb_handle(), + dx in -2000.0f32..2000.0, + dy in -2000.0f32..2000.0, + ) { + let screen = Rect::from_min_size(pos2(min_x, min_y), vec2(w, h)); + let uv = Rect::from_min_size(pos2(uv_u0, uv_v0), vec2(uv_w, uv_h)); + let (new_screen, new_uv) = apply_crop(screen, uv, handle, vec2(dx, dy)); + + prop_assert!(new_screen.is_finite()); + prop_assert!(new_uv.is_finite()); + prop_assert!(new_uv.min.x >= 0.0 - 1e-5); + prop_assert!(new_uv.min.y >= 0.0 - 1e-5); + prop_assert!(new_uv.max.x <= 1.0 + 1e-5); + prop_assert!(new_uv.max.y <= 1.0 + 1e-5); + prop_assert!(new_uv.width() > 0.0); + prop_assert!(new_uv.height() > 0.0); + + let scale_x_orig = w / uv_w; + let scale_x_new = new_screen.width() / new_uv.width(); + prop_assert!((scale_x_orig - scale_x_new).abs() < 1e-2); + } + + #[test] + fn prop_reset_crop_invariants( + min_x in -1000.0f32..1000.0, + min_y in -1000.0f32..1000.0, + w in 50.0f32..500.0, + h in 50.0f32..500.0, + uv_u0 in 0.0f32..0.4, + uv_v0 in 0.0f32..0.4, + uv_w in 0.2f32..0.5, + uv_h in 0.2f32..0.5, + ) { + let screen = Rect::from_min_size(pos2(min_x, min_y), vec2(w, h)); + let uv = Rect::from_min_size(pos2(uv_u0, uv_v0), vec2(uv_w, uv_h)); + let (full_screen, full_uv) = reset_crop(screen, uv); + + prop_assert_eq!(full_uv, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0))); + prop_assert!(full_screen.is_finite()); + prop_assert!(full_screen.width() >= screen.width()); + prop_assert!(full_screen.height() >= screen.height()); + } + + #[test] + fn prop_quad_contains_never_panics( + q0_x in -1000.0f32..1000.0, + q0_y in -1000.0f32..1000.0, + q1_x in -1000.0f32..1000.0, + q1_y in -1000.0f32..1000.0, + q2_x in -1000.0f32..1000.0, + q2_y in -1000.0f32..1000.0, + q3_x in -1000.0f32..1000.0, + q3_y in -1000.0f32..1000.0, + px in -2000.0f32..2000.0, + py in -2000.0f32..2000.0, + ) { + let quad = [pos2(q0_x, q0_y), pos2(q1_x, q1_y), pos2(q2_x, q2_y), pos2(q3_x, q3_y)]; + let p = pos2(px, py); + let _ = crate::edit_text_ui::quad_contains(quad, p); + } + } + } } diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 4959219b9d..7343fe1908 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -572,26 +572,6 @@ fn format_section(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) { ui.separator(); return; } - let image = app.active_ids().and_then(|(i, id)| { - let (page, index) = app.views[i].content.selected?; - let doc = app.session.get(id)?; - match &doc.added().iter().filter(|a| a.page == page).nth(index)?.content { - pdfcraft_engine::AddedContent::Image(img) => Some((page, index, img.clone())), - _ => None, - } - }); - if let Some((page, index, img)) = image { - match crate::content_ui::image_panel(ui, t, &img) { - Some(crate::content_ui::ImageAction::Update(content)) => { - app.apply_edit(pdfcraft_engine::Edit::UpdateContent { page, index, content }); - } - Some(crate::content_ui::ImageAction::Replace) => app.replace_image_dialog(page, index), - None => {} - } - ui.add_space(6.0); - ui.separator(); - return; - } let selected = app.active_ids().and_then(|(i, id)| { let (page, index) = app.views[i].content.selected?; let doc = app.session.get(id)?; diff --git a/crates/ui-egui/tests/content.rs b/crates/ui-egui/tests/content.rs index dd2c032eff..cb4c1c618a 100644 --- a/crates/ui-egui/tests/content.rs +++ b/crates/ui-egui/tests/content.rs @@ -262,19 +262,66 @@ fn typing_moving_styling_and_deleting_added_content() { let a = added(&h); assert_eq!(a.len(), 1); assert_eq!(a[0].content.rect(), [135.0, 190.0, 165.0, 210.0]); - assert_eq!(h.state().views[0].content.selected, Some((0, 0))); - // Edit image: rotate clockwise turns the box around its centre. - h.get_by_label("Rotate clockwise").click(); + assert!(h.state().views[0].image_selection.is_some()); + assert_eq!(h.state().views[0].content.selected, None); + // Right-click image to rotate clockwise via refactored context menu + let c = at(&h, 150.0, 200.0); + 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("Rotate Clockwise").click(); h.run_steps(3); let a = added(&h); let pdfcraft_engine::AddedContent::Image(img) = &a[0].content else { panic!() }; - assert_eq!((img.rotation, img.rect), (3, [140.0, 185.0, 160.0, 215.0])); + assert_eq!((img.rotation, img.rect), (1, [140.0, 185.0, 160.0, 215.0])); + + 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("Flip horizontal").click(); h.run_steps(3); let pdfcraft_engine::AddedContent::Image(img) = &added(&h)[0].content else { panic!() }; assert!(img.flip_h); } +#[test] +fn add_image_activates_refactored_image_selection_and_crop() { + let mut h = harness(); + let mut png = Vec::new(); + { + let mut enc = png::Encoder::new(&mut png, 40, 40); + enc.set_color(png::ColorType::Rgb); + let mut w = enc.write_header().unwrap(); + w.write_image_data(&[128u8; 4800]).unwrap(); + } + h.state_mut().add_image("test_crop.png".into(), png); + h.run_steps(3); + + // Verify image_selection is activated and content.selected is None + let sel = h.state().views[0].image_selection.clone().expect("image_selection should be active"); + assert_eq!(h.state().views[0].content.selected, None); + assert!(!sel.crop_mode); + + // Right-click image to open context menu and click Crop + let c = at(&h, 150.0, 200.0); + 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("Crop").click(); + h.run_steps(3); + + // Verify refactored crop mode is now active + let sel_after = h.state().views[0].image_selection.clone().expect("image_selection active"); + assert!(sel_after.crop_mode, "crop_mode must be true"); + assert!(sel_after.crop.is_some(), "crop must be initialized"); +} + /// #125: text the standard fonts can't draw stays in the editor with a warning instead of being /// written to the page as `?` (or lost). #[test] diff --git a/crates/ui-egui/tests/editing.rs b/crates/ui-egui/tests/editing.rs index 870df2cf35..fdbea6fdf7 100644 --- a/crates/ui-egui/tests/editing.rs +++ b/crates/ui-egui/tests/editing.rs @@ -5581,6 +5581,8 @@ fn test_cropped_image_selection_and_bounding_box() { app }); h.run_steps(4); + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(2); let doc_id = h.state().views[0].id; let full_rect = h.state().session.get(doc_id).unwrap().page_images(0)[0].rect; @@ -5662,3 +5664,131 @@ fn read_mode_shortcut_applies_the_open_paragraph() { assert!(s.views[0].line_editor.is_none(), "no editor stays open in Read mode"); assert_eq!(s.session.get(s.views[0].id).unwrap().text_lines(0)[0].text, "Chapter One", "the edit is applied"); } + +#[test] +fn arrange_duplicate_image_clears_selection_when_ambiguous() { + use pdfcraft_cos::{Dict, Document, Object, SaveOptions, Stream, write_incremental}; + let mut doc = Document::open(std::sync::Arc::new(fixture(1))).unwrap(); + let page = pdfcraft_model::pages(&doc)[0].clone(); + let mut form = Dict::new(); + form.set(b"Subtype".to_vec(), Object::name("Form")); + form.set(b"BBox".to_vec(), Object::Array([0, 0, 200, 300].map(Object::Int).to_vec())); + let artwork = doc.add(Object::Stream(Stream::flate(form, b"0.2 0.5 0.9 rg 20 20 160 260 re f"))); + // Paint two occurrences of the same figure at the exact same position + let contents = doc.add(Object::Stream(Stream::flate(Dict::new(), b"q 1 0 0 1 0 0 cm /Figure Do Q q 1 0 0 1 0 0 cm /Figure Do Q"))); + doc.update_dict(page.obj, |d| { + d.set(b"Contents".to_vec(), Object::Ref(contents)); + let mut xo = Dict::new(); + xo.set(b"Figure".to_vec(), Object::Ref(artwork)); + let mut resources = Dict::new(); + resources.set(b"XObject".to_vec(), Object::Dict(xo)); + d.set(b"Resources".to_vec(), Object::Dict(resources)); + }) + .unwrap(); + let bytes = write_incremental(&doc, &SaveOptions::default()).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.open_bytes("dup.pdf", None, bytes.clone()).expect("opens"); + app + }); + h.run_steps(4); + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(2); + + let doc_id = h.state().views[0].id; + let images = h.state().session.get(doc_id).unwrap().page_images(0); + assert_eq!(images.len(), 2, "two duplicate image occurrences exist"); + assert_eq!(images[0].rect, images[1].rect); + assert_eq!(images[0].name, images[1].name); + + // Select the second occurrence (index 1) + h.state_mut().views[0].image_selection = Some(pdfcraft_ui_egui::edit_text_ui::ImageSelection { + page: 0, + index: 1, + drag: None, + crop: None, + crop_mode: false, + crop_rect: None, + }); + h.run_steps(2); + + // Arrange send to back + let applied = h.state_mut().apply_edit(pdfcraft_engine::Edit::EditPageImage { + page: 0, + index: 1, + change: pdfcraft_engine::ImageEdit::Arrange(pdfcraft_engine::ArrangeOrder::SendToBack), + }); + assert!(applied); + h.run_steps(2); + + // Because there are duplicate occurrences matching name, object, and rect, + // selection must be cleared rather than picking an arbitrary occurrence. + assert!( + h.state().views[0].image_selection.is_none(), + "selection is safely cleared when reordering ambiguous duplicate images" + ); +} + +#[test] +fn arrange_unique_image_preserves_selection() { + use pdfcraft_cos::{Dict, Document, Object, SaveOptions, Stream, write_incremental}; + let mut doc = Document::open(std::sync::Arc::new(fixture(1))).unwrap(); + let page = pdfcraft_model::pages(&doc)[0].clone(); + let mut form = Dict::new(); + form.set(b"Subtype".to_vec(), Object::name("Form")); + form.set(b"BBox".to_vec(), Object::Array([0, 0, 200, 300].map(Object::Int).to_vec())); + let artwork1 = doc.add(Object::Stream(Stream::flate(form.clone(), b"0.2 0.5 0.9 rg 20 20 160 260 re f"))); + let artwork2 = doc.add(Object::Stream(Stream::flate(form, b"0.9 0.2 0.5 rg 40 40 100 100 re f"))); + let contents = doc.add(Object::Stream(Stream::flate(Dict::new(), b"q 1 0 0 1 0 0 cm /FigA Do Q q 1 0 0 1 0 0 cm /FigB Do Q"))); + doc.update_dict(page.obj, |d| { + d.set(b"Contents".to_vec(), Object::Ref(contents)); + let mut xo = Dict::new(); + xo.set(b"FigA".to_vec(), Object::Ref(artwork1)); + xo.set(b"FigB".to_vec(), Object::Ref(artwork2)); + let mut resources = Dict::new(); + resources.set(b"XObject".to_vec(), Object::Dict(xo)); + d.set(b"Resources".to_vec(), Object::Dict(resources)); + }) + .unwrap(); + let bytes = write_incremental(&doc, &SaveOptions::default()).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.open_bytes("unique.pdf", None, bytes.clone()).expect("opens"); + app + }); + h.run_steps(4); + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(2); + + let doc_id = h.state().views[0].id; + let images = h.state().session.get(doc_id).unwrap().page_images(0); + assert_eq!(images.len(), 2); + assert_eq!(images[0].name, "FigA"); + assert_eq!(images[1].name, "FigB"); + + // Select FigA at index 0 and bring it to front (should move after FigB, becoming index 1) + h.state_mut().views[0].image_selection = Some(pdfcraft_ui_egui::edit_text_ui::ImageSelection { + page: 0, + index: 0, + drag: None, + crop: None, + crop_mode: false, + crop_rect: None, + }); + h.run_steps(2); + + let applied = h.state_mut().apply_edit(pdfcraft_engine::Edit::EditPageImage { + page: 0, + index: 0, + change: pdfcraft_engine::ImageEdit::Arrange(pdfcraft_engine::ArrangeOrder::BringToFront), + }); + assert!(applied); + h.run_steps(2); + + let sel = h.state().views[0].image_selection.as_ref().expect("selection is preserved for unique image"); + assert_eq!(sel.index, 1, "selection index tracks the unique image's new position"); +}