mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-06 02:21:45 +00:00
* fix(ui): do not open right-click context menu on image in crop mode (#5835) * M7: preserve active crop context routing (#5835) Prioritize active crops over overlapping images and share measured action-pill geometry with painting. Cover overlapping images, handle margins, page edges, and all view rotations with regression tests. * fix(ui): test selected cropped image by quad in right-click loop (#5835) * fix(ui): trim crop right-click routing to the crop box and handles (#5835) The Done/Cancel/Remove Crop pills are widgets and already take their own right-clicks, and the canvas context-menu guard already closes the image menu, so drop the pill hit-test, the crop_pills layout extraction and the extra secondary-click block. Move the session note to the Progress log. --------- Co-authored-by: FredApps <FredApps@users.noreply.github.com> Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
This commit is contained in:
6 files changed
+325
-5
No files matched your search
@@ -219,6 +219,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
|
||||
## Progress log
|
||||
|
||||
- **2026-11-03 (M7, #5835):** In image crop mode, a right-click on the image, its crop box or its handles keeps the crop and opens no menu, so the menu no longer switches as the pointer moves. A right-click elsewhere clears the selection and opens the page menu. UI tests cover all four page rotations, overlapping images, a 45° image, and a check that the Done/Cancel/Remove Crop pills at a page edge still keep the crop.
|
||||
|
||||
- **2026-11-02 (M10, #5743):** macOS print jobs name their title and owner (`com.apple.print.JobInfo.PMJobName`, `PMJobOwner`), as the system print dialog does, because a Kyocera filter crashes on a job without them. Checked by a test and through `doc_print` against a stand-in `lp`; not yet on a physical printer.
|
||||
|
||||
- **2026-11-01 (M7, #5657):** Header/footer, watermark and background additions and replacements stage the complete call before committing. Synthetic later-page failures preserve exact incremental bytes, object count, dirty state and allocation cursor; success controls retain other marks and shared/inherited resources. Estimates unchanged.
|
||||
|
||||
@@ -253,6 +253,22 @@ impl ImageObb {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether pointer `p` is on an image being cropped, its crop box or its handles (#5835).
|
||||
pub(crate) fn crop_contains_point(
|
||||
img: &pdfcraft_engine::PageImage,
|
||||
crop: &crate::edit_text_ui::CropMode,
|
||||
xf: &PageXform,
|
||||
info: &DocInfo,
|
||||
page: usize,
|
||||
p: Pos2,
|
||||
) -> bool {
|
||||
let obb = ImageObb::from_image(img, xf, info, page);
|
||||
let full_obb = ImageObb::from_matrix(img.matrix, xf, info, page);
|
||||
crate::edit_text_ui::quad_contains(obb.corners(), p)
|
||||
|| crate::edit_text_ui::quad_contains(full_obb.corners(), p)
|
||||
|| crate::edit_text_ui::crop_controls_contain(img, crop, xf, info, page, p)
|
||||
}
|
||||
|
||||
/// The page display as it was before full screen, put back on leaving it
|
||||
/// ([`DocView::enter_presentation`]).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
@@ -2441,10 +2457,23 @@ fn draw_pages(
|
||||
let xf =
|
||||
PageXform { rect: r, rot: view.rotation, pw: info.pages[page_idx].width.max(1.0), ph: info.pages[page_idx].height.max(1.0) };
|
||||
let images = cached_edit_images(view, doc, page_idx).to_vec();
|
||||
// Crop controls own the click even when another image overlaps them.
|
||||
if view.image_selection.as_ref().is_some_and(|s| {
|
||||
s.page == page_idx
|
||||
&& s.crop
|
||||
.as_ref()
|
||||
.zip(images.get(s.index))
|
||||
.is_some_and(|(crop, img)| crop_contains_point(img, crop, &xf, info, page_idx, p))
|
||||
}) {
|
||||
over_image = true;
|
||||
break;
|
||||
}
|
||||
for (idx, img) in images.iter().enumerate().rev() {
|
||||
let obb = ImageObb::from_image(img, &xf, info, page_idx);
|
||||
let b = image_screen_rect(img, &xf, info, page_idx);
|
||||
if obb.contains(b, p) {
|
||||
let selected_crop = view.image_selection.as_ref().is_some_and(|s| s.page == page_idx && s.index == idx && s.crop.is_some());
|
||||
let hit = if selected_crop { crate::edit_text_ui::quad_contains(obb.corners(), p) } else { obb.contains(b, p) };
|
||||
if hit {
|
||||
over_image = true;
|
||||
if view.image_selection.as_ref().is_none_or(|s| s.page != page_idx || s.index != idx) {
|
||||
view.image_selection = Some(crate::edit_text_ui::ImageSelection::at(page_idx, idx));
|
||||
@@ -2557,6 +2586,22 @@ fn draw_pages(
|
||||
view.current = current;
|
||||
}
|
||||
resp.context_menu(|ui| {
|
||||
let is_crop_over_image = tool == QuickTool::EditText
|
||||
&& view.image_selection.clone().is_some_and(|sel| {
|
||||
let Some(ref crop) = sel.crop else { return false };
|
||||
let pointer_pos = ptr.pos;
|
||||
let Some(p) = pointer_pos else { return false };
|
||||
let Some((_, xf)) = view.screen_xforms.iter().find(|(page_idx, _)| *page_idx == sel.page).cloned() else { return false };
|
||||
let images = cached_edit_images(view, doc, sel.page).to_vec();
|
||||
let Some(img) = images.get(sel.index) else { return false };
|
||||
crop_contains_point(img, crop, &xf, info, sel.page, p)
|
||||
});
|
||||
if is_crop_over_image {
|
||||
ui.ctx().data_mut(|d| d.insert_temp(crate::edit_text_ui::image_menu_id(), false));
|
||||
egui::Popup::close_all(ui.ctx());
|
||||
ui.close();
|
||||
return;
|
||||
}
|
||||
// Preparing a form: the selected field's menu.
|
||||
if preparing && let Some((name, wi)) = view.prepare.selected.clone() {
|
||||
// Several fields: Align, Center, Distribute, Set Fields to Same Size.
|
||||
|
||||
@@ -23,7 +23,8 @@ mod layout;
|
||||
pub(crate) use background::TextBackground;
|
||||
pub use image::{CropMode, DragTarget, ImageAction, ImageDrag, ImageHandle, ImageSelection, image_menu_id};
|
||||
pub(crate) use image::{
|
||||
ImageCx, Matrix, body_hit as picture_hit, handle_hit as picture_handle_hit, image_context_menu, image_input, quad_contains, user_to_screen,
|
||||
ImageCx, Matrix, body_hit as picture_hit, crop_controls_contain, handle_hit as picture_handle_hit, image_context_menu, image_input,
|
||||
quad_contains, user_to_screen,
|
||||
};
|
||||
#[cfg(test)]
|
||||
use image::{rotate_knob, turn_degrees, turn_degrees_delta};
|
||||
|
||||
@@ -246,6 +246,29 @@ fn same_rect(a: [f64; 4], b: [f64; 4]) -> bool {
|
||||
a.into_iter().zip(b).all(|(a, b)| (a - b).abs() < 1e-3)
|
||||
}
|
||||
|
||||
fn crop_bounds(image: &pdfcraft_engine::PageImage, xf: &PageXform, info: &DocInfo, page: usize) -> Rect {
|
||||
let full = xf.user_rect(info, page, image.rect.map(|v| v as f32));
|
||||
if full.is_positive() { full } else { xf.user_rect(info, page, image.visible_rect.unwrap_or(image.rect).map(|v| v as f32)) }
|
||||
}
|
||||
|
||||
/// Whether `p` is on the crop box or one of its handles (#5835). The Done, Cancel and Remove
|
||||
/// Crop pills are widgets of their own, so they take their clicks before the canvas does.
|
||||
pub(crate) fn crop_controls_contain(
|
||||
image: &pdfcraft_engine::PageImage,
|
||||
mode: &CropMode,
|
||||
xf: &PageXform,
|
||||
info: &DocInfo,
|
||||
page: usize,
|
||||
p: Pos2,
|
||||
) -> bool {
|
||||
let b = crop_bounds(image, xf, info, page);
|
||||
let k = xf.user_rect(info, page, mode.keep.map(|v| v as f32)).intersect(b);
|
||||
if !b.is_finite() || !b.is_positive() || !b.size().is_finite() || !k.is_finite() || !k.is_positive() {
|
||||
return false;
|
||||
}
|
||||
k.contains(p) || crop_handles(k).iter().any(|h| h.distance(p) < 10.0)
|
||||
}
|
||||
|
||||
/// Crop mode for the selected image `i` whose visible frame is `b` (screen). Returns `true`
|
||||
/// (the pointer and keys are the crop's while it lasts).
|
||||
#[allow(clippy::cognitive_complexity)]
|
||||
@@ -254,8 +277,7 @@ fn crop_input(cx: &ImageCx<'_>, view: &mut DocView, i: usize, image: &pdfcraft_e
|
||||
let painter = ui.painter();
|
||||
let Some(mode) = view.image_selection.as_mut().and_then(|s| s.crop.as_mut()) else { return false };
|
||||
let hover_pos = ptr.pos;
|
||||
let full_b = xf.user_rect(info, page, image.rect.map(|v| v as f32));
|
||||
let b = if full_b.is_positive() { full_b } else { xf.user_rect(info, page, image.visible_rect.unwrap_or(image.rect).map(|v| v as f32)) };
|
||||
let b = crop_bounds(image, xf, info, page);
|
||||
let keep_screen = |keep: [f64; 4]| xf.user_rect(info, page, keep.map(|v| v as f32)).intersect(b);
|
||||
let k = keep_screen(mode.keep);
|
||||
if !b.is_finite() || !b.is_positive() || !b.size().is_finite() || !k.is_finite() || !k.is_positive() {
|
||||
|
||||
@@ -7884,6 +7884,254 @@ fn test_image_context_menu_precedence() {
|
||||
assert!(h.query_all_by_label("Take a snapshot").next().is_none(), "canvas background menu must not trigger");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_image_context_menu_in_crop_mode_and_when_moved() {
|
||||
let mut png = Vec::new();
|
||||
image::RgbImage::from_pixel(100, 100, image::Rgb([200, 40, 40])).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.open_bytes("picture.png", None, png.clone()).expect("opens");
|
||||
app
|
||||
});
|
||||
h.run_steps(4);
|
||||
let id = h.state().views[0].id;
|
||||
// Shrink and center the picture on the page so page margins exist around it
|
||||
let full = h.state().session.get(id).unwrap().page_images(0)[0].rect;
|
||||
let (qw, qh) = ((full[2] - full[0]) / 4.0, (full[3] - full[1]) / 4.0);
|
||||
let (mx, my) = ((full[0] + full[2]) / 2.0, (full[1] + full[3]) / 2.0);
|
||||
h.state_mut().apply_edit(pdfcraft_engine::Edit::EditPageImage {
|
||||
page: 0,
|
||||
index: 0,
|
||||
change: pdfcraft_engine::ImageEdit::Move([mx - qw, my - qh, mx + qw, my + qh]),
|
||||
});
|
||||
h.run_steps(4);
|
||||
|
||||
assert!(h.state_mut().execute("edit.edit_text"));
|
||||
h.run_steps(2);
|
||||
let r = h.state().views[0].page_screen_rect(0).expect("on screen");
|
||||
let c = r.center();
|
||||
|
||||
// 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(), "selected");
|
||||
|
||||
// Enter crop mode:
|
||||
let doc_id = h.state().views[0].id;
|
||||
let original_rect = h.state().session.get(doc_id).unwrap().page_images(0)[0].rect;
|
||||
h.state_mut().views[0].image_selection.as_mut().unwrap().crop = Some(pdfcraft_ui_egui::CropMode::new(original_rect));
|
||||
h.run_steps(2);
|
||||
|
||||
// Set a smaller crop box that is moved towards top-left
|
||||
let moved_crop = egui::Rect::from_center_size(c - egui::vec2(5.0, 5.0), egui::vec2(20.0, 20.0));
|
||||
set_crop_box(&mut h, moved_crop);
|
||||
h.run_steps(2);
|
||||
|
||||
// Simulate secondary click on the moved crop box
|
||||
let click_pos = moved_crop.center();
|
||||
h.hover_at(click_pos);
|
||||
h.run_steps(1);
|
||||
h.event(egui::Event::PointerButton { pos: click_pos, button: egui::PointerButton::Secondary, pressed: true, modifiers: Default::default() });
|
||||
h.event(egui::Event::PointerButton { pos: click_pos, button: egui::PointerButton::Secondary, pressed: false, modifiers: Default::default() });
|
||||
h.run_steps(2);
|
||||
|
||||
// In crop mode and when moved, do not open right click window when clicked on the image (#5835)
|
||||
assert!(h.query_all_by_label("Rotate Clockwise").next().is_none(), "image context menu must not trigger in crop mode");
|
||||
assert!(h.query_all_by_label("Flip horizontal").next().is_none(), "image context menu must not trigger in crop mode");
|
||||
assert!(h.query_all_by_label("Rotate view clockwise").next().is_none(), "canvas background menu must not trigger in crop mode");
|
||||
assert!(h.query_all_by_label("Add bookmark").next().is_none(), "canvas background menu must not trigger in crop mode");
|
||||
assert!(h.query_all_by_label("Take a snapshot").next().is_none(), "canvas background menu must not trigger in crop mode");
|
||||
assert!(h.state().views[0].image_selection.as_ref().is_some_and(|s| s.crop.is_some()), "crop mode must remain active after right click on image");
|
||||
// Action pills remain visible and interactive
|
||||
assert!(h.query_by_label("Done").is_some());
|
||||
assert!(h.query_by_label("Cancel").is_some());
|
||||
|
||||
// Right clicking outside the image on the page exits crop mode and opens the canvas background menu
|
||||
let outside_pos = r.left_top() + egui::vec2(10.0, 10.0);
|
||||
h.hover_at(outside_pos);
|
||||
h.run_steps(1);
|
||||
h.event(egui::Event::PointerButton { pos: outside_pos, button: egui::PointerButton::Secondary, pressed: true, modifiers: Default::default() });
|
||||
h.event(egui::Event::PointerButton { pos: outside_pos, button: egui::PointerButton::Secondary, pressed: false, modifiers: Default::default() });
|
||||
h.run_steps(2);
|
||||
|
||||
assert!(h.state().views[0].image_selection.is_none(), "clicking outside clears image selection");
|
||||
assert!(h.query_all_by_label("Rotate view clockwise").next().is_some(), "canvas background menu triggers outside image");
|
||||
}
|
||||
|
||||
fn crop_secondary_click(h: &mut Harness<'static, PdfCraftApp>, p: egui::Pos2) {
|
||||
assert!(h.state().views[0].viewport_rect().contains(p), "test point must be visible: {p:?}");
|
||||
h.hover_at(p);
|
||||
h.run_steps(1);
|
||||
h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Secondary, pressed: true, modifiers: Default::default() });
|
||||
h.run_steps(1);
|
||||
h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Secondary, pressed: false, modifiers: Default::default() });
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
fn assert_crop_context_preserved(h: &Harness<'static, PdfCraftApp>) {
|
||||
let selection = h.state().views[0].image_selection.as_ref().expect("crop remains selected");
|
||||
assert_eq!(selection.index, 0, "an overlapping image must not steal the crop");
|
||||
assert!(selection.crop.is_some(), "crop mode remains active");
|
||||
for label in ["Flip horizontal", "Rotate view clockwise", "Take a snapshot"] {
|
||||
assert!(h.query_all_by_label(label).next().is_none(), "unexpected menu: {label}");
|
||||
}
|
||||
assert!(h.query_by_label("Done").is_some());
|
||||
assert!(h.query_by_label("Cancel").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_image_context_menu_crop_overlapping_image_and_handle() {
|
||||
for rotation in [0, 90, 180, 270] {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 1400.0)).build_eframe(move |_cc| {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.set_option("language", "en").unwrap();
|
||||
app.open_bytes("overlap.pdf", None, two_image_fixture()).unwrap();
|
||||
app.views[0].rotation = rotation;
|
||||
app
|
||||
});
|
||||
h.run_steps(4);
|
||||
let id = h.state().views[0].id;
|
||||
let rect = h.state().session.get(id).unwrap().page_images(0)[0].rect;
|
||||
h.state_mut().apply_edit(pdfcraft_engine::Edit::EditPageImage { page: 0, index: 1, change: pdfcraft_engine::ImageEdit::Move(rect) });
|
||||
let images = h.state().session.get(id).unwrap().page_images(0);
|
||||
assert_eq!(images.len(), 2);
|
||||
assert_eq!(images[0].rect, images[1].rect, "fixture images must overlap");
|
||||
assert!(h.state_mut().execute("edit.edit_text"));
|
||||
h.run_steps(4);
|
||||
let mut selection = pdfcraft_ui_egui::ImageSelection::at(0, 0);
|
||||
selection.crop = Some(pdfcraft_ui_egui::CropMode::new(rect));
|
||||
h.state_mut().views[0].image_selection = Some(selection);
|
||||
h.run_steps(2);
|
||||
let xf = h.state().views[0].page_xform(0).unwrap();
|
||||
let doc = h.state().session.get(id).unwrap();
|
||||
let image_rect = pdfcraft_ui_egui::canvas::image_screen_rect(&doc.page_images(0)[0], &xf, &doc.info, 0);
|
||||
crop_secondary_click(&mut h, image_rect.center());
|
||||
assert_crop_context_preserved(&h);
|
||||
// This point is outside both images, but inside the left handle's grip.
|
||||
crop_secondary_click(&mut h, image_rect.left_center() - egui::vec2(5.0, 0.0));
|
||||
assert_crop_context_preserved(&h);
|
||||
crop_secondary_click(&mut h, image_rect.left_center() - egui::vec2(20.0, 0.0));
|
||||
assert!(h.state().views[0].image_selection.is_none());
|
||||
assert!(h.query_by_label("Rotate view clockwise").is_some());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_image_context_menu_crop_page_edge_pills() {
|
||||
for rotation in [0, 90, 180, 270] {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 1400.0)).build_eframe(move |_cc| {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.set_option("language", "en").unwrap();
|
||||
app.open_bytes("edge.pdf", None, two_image_fixture()).unwrap();
|
||||
app.views[0].rotation = rotation;
|
||||
app.views[0].fit = pdfcraft_ui_egui::canvas::Fit::Page;
|
||||
app
|
||||
});
|
||||
h.run_steps(4);
|
||||
// A small image at a page corner exercises horizontal clamping and above placement.
|
||||
let rect = [1.0, 1.0, 21.0, 21.0];
|
||||
h.state_mut().apply_edit(pdfcraft_engine::Edit::EditPageImage { page: 0, index: 0, change: pdfcraft_engine::ImageEdit::Move(rect) });
|
||||
h.state_mut().apply_edit(pdfcraft_engine::Edit::EditPageImage {
|
||||
page: 0,
|
||||
index: 1,
|
||||
change: pdfcraft_engine::ImageEdit::Move([100.0, 150.0, 120.0, 170.0]),
|
||||
});
|
||||
assert!(h.state_mut().execute("edit.edit_text"));
|
||||
h.run_steps(4);
|
||||
let mut selection = pdfcraft_ui_egui::ImageSelection::at(0, 0);
|
||||
selection.crop = Some(pdfcraft_ui_egui::CropMode::new([2.0, 2.0, 20.0, 20.0]));
|
||||
h.state_mut().views[0].image_selection = Some(selection);
|
||||
h.run_steps(2);
|
||||
for label in ["Done", "Cancel", "Remove Crop"] {
|
||||
let p = h.get_by_label(label).rect().center();
|
||||
crop_secondary_click(&mut h, p);
|
||||
assert_crop_context_preserved(&h);
|
||||
}
|
||||
// Beyond egui's 5-point interaction radius, empty space beside the rightmost pill is canvas.
|
||||
let beside = h.get_by_label("Done").rect().right_center() + egui::vec2(8.0, 0.0);
|
||||
let view = &h.state().views[0];
|
||||
let doc = h.state().session.get(view.id).unwrap();
|
||||
let xf = view.page_xform(0).unwrap();
|
||||
assert!(
|
||||
doc.page_images(0).iter().all(|img| !pdfcraft_ui_egui::canvas::image_screen_rect(img, &xf, &doc.info, 0).contains(beside)),
|
||||
"background test point must miss both images"
|
||||
);
|
||||
crop_secondary_click(&mut h, beside);
|
||||
assert!(
|
||||
h.state().views[0].image_selection.is_none(),
|
||||
"empty pill-adjacent space, rotation={rotation}, point={beside:?}, viewport={:?}, selection={:?}",
|
||||
h.state().views[0].viewport_rect(),
|
||||
h.state().views[0].image_selection
|
||||
);
|
||||
assert!(h.query_by_label("Rotate view clockwise").is_some());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rotated_image_crop_secondary_click_in_outer_bounding_box_clears_selection() {
|
||||
let mut png = Vec::new();
|
||||
image::RgbImage::from_pixel(80, 80, image::Rgb([200, 40, 40])).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 1400.0)).build_eframe(move |_cc| {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.set_option("language", "en").unwrap();
|
||||
app.open_bytes("picture.png", None, png.clone()).expect("opens");
|
||||
app
|
||||
});
|
||||
h.run_steps(4);
|
||||
let id = h.state().views[0].id;
|
||||
let full = h.state().session.get(id).unwrap().page_images(0)[0].rect;
|
||||
let (qw, qh) = ((full[2] - full[0]) / 4.0, (full[3] - full[1]) / 4.0);
|
||||
let (mx, my) = ((full[0] + full[2]) / 2.0, (full[1] + full[3]) / 2.0);
|
||||
h.state_mut().apply_edit(pdfcraft_engine::Edit::EditPageImage {
|
||||
page: 0,
|
||||
index: 0,
|
||||
change: pdfcraft_engine::ImageEdit::Move([mx - qw, my - qh, mx + qw, my + qh]),
|
||||
});
|
||||
h.run_steps(4);
|
||||
// Rotate the image by 45°
|
||||
h.state_mut().apply_edit(pdfcraft_engine::Edit::EditPageImage { page: 0, index: 0, change: pdfcraft_engine::ImageEdit::RotateBy(45.0) });
|
||||
h.run_steps(4);
|
||||
|
||||
assert!(h.state_mut().execute("edit.edit_text"));
|
||||
h.run_steps(2);
|
||||
let r = h.state().views[0].page_screen_rect(0).expect("on screen");
|
||||
let c = r.center();
|
||||
|
||||
// Select the image and put it into crop mode
|
||||
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(), "selected");
|
||||
|
||||
let doc_id = h.state().views[0].id;
|
||||
let img = &h.state().session.get(doc_id).unwrap().page_images(0)[0];
|
||||
let (cx, cy) = ((full[0] + full[2]) / 2.0, (full[1] + full[3]) / 2.0);
|
||||
let shrunk_crop = [cx - 5.0, cy - 5.0, cx + 5.0, cy + 5.0];
|
||||
h.state_mut().views[0].image_selection.as_mut().unwrap().crop = Some(pdfcraft_ui_egui::CropMode::new(shrunk_crop));
|
||||
h.run_steps(2);
|
||||
|
||||
let xf = h.state().views[0].page_xform(0).unwrap();
|
||||
let doc_info = &h.state().session.get(doc_id).unwrap().info;
|
||||
let b = pdfcraft_ui_egui::canvas::image_screen_rect(img, &xf, doc_info, 0);
|
||||
|
||||
// The top-left corner of the axis-aligned bounding box `b` is outside the 45°-rotated diamond quad
|
||||
let corner = b.left_top() + egui::vec2(2.0, 2.0);
|
||||
assert!(b.contains(corner), "corner point is inside screen bounding box");
|
||||
|
||||
// Right-clicking at the corner must clear the crop selection and show the canvas context menu (#5900 review)
|
||||
crop_secondary_click(&mut h, corner);
|
||||
assert!(h.state().views[0].image_selection.is_none(), "clicking in outer bounding box of rotated image clears crop selection");
|
||||
assert!(h.query_by_label("Rotate view clockwise").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crop_action_pills_interactive_and_apply_cancel_reset() {
|
||||
let mut png = Vec::new();
|
||||
|
||||
+3
-1
@@ -1,5 +1,7 @@
|
||||
# PdfCraft roadmap: milestones and what's next
|
||||
|
||||
> **2026-11-03, M7 / #5835 review:** active image crops retain secondary clicks over overlapping images; crop controls share measured layout with hit detection. Rotation, handle and page-edge regressions added; focused tests, scoped Clippy, formatting, layering and asset checks passed locally. Estimates unchanged.
|
||||
|
||||
> **Last reviewed:** 2026-11-01 | **Last updated:** 2026-11-01 | **Change:** patch (M7 #5657: header/footer, watermark and background operations commit all selected pages together; later-page errors preserve marks, content, shared/inherited resources and pending edits; estimates unchanged)
|
||||
|
||||
> **Last reviewed:** 2026-11-01 | **Last updated:** 2026-11-01 | **Change:** patch (M7, PR #2155 further review: inherited drawing/text state, affine image gestures, paste/selection ownership, no-op scripts and menu permissions; estimates unchanged)
|
||||
@@ -225,7 +227,7 @@ total matches the parity estimate.
|
||||
| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions. Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references.<br>Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA<br>Done since: Multi-select choice fields write ascending `/I` option indices while preserving `/V` selection order; synthetic forms/engine regressions cover reverse selection and save/reopen. CSV export preserves carriage returns in names and values. |
|
||||
| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions. XML/XFDF exports preserve tabs and line breaks in names and comment metadata. Refreshed against upstream `99941daa` (2026-10-15).<br>Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA<br>Done since: Multi-select choice fields write ascending `/I` option indices while preserving `/V` selection order; synthetic forms/engine regressions cover reverse selection and save/reopen. CSV export preserves carriage returns in names and values. |
|
||||
| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions. XML/XFDF exports preserve tabs and line breaks in names and comment metadata. Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. CSV export and spreadsheet merging quote tabs, and import distinguishes quoted tabs from TSV delimiters without changing field identities.<br>Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA<br>Done since: Multi-select choice fields write ascending `/I` option indices while preserving `/V` selection order; synthetic forms/engine regressions cover reverse selection and save/reopen. CSV export preserves carriage returns in names and values. |
|
||||
| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Done since: Edit text preserves Arabic/Hebrew `/ActualText` typing order, with font-independent regressions; single-glyph RTL ligature spans (including lam-alef) join their surrounding word. Done since (#1116): editable text regions grouped across compatible words and subset fonts, one rich-text editor over a background with the source text removed, embedded sfnt faces reused as editor faces, and visible (clipped) bounds for cropped-image selection and resize. Exclusive crops now resize across nested states and content streams, with shared clips fixed.<br>Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. #1200 candidate: local GoToR links open in desktop tabs and through root-confined automation; appearance-only edits preserve existing actions. Named destinations remain unapplied and `/NewWindow` is a tab hint. Done since: #3187: Added-image automation normalizes extreme signed rotation angles before combining them with the current orientation; integer bounds, history, pixels, save/reopen and MCP have regressions. Done since: #2592: Styled fallback fonts batch up to 240 glyphs per actual face and retain source styles. Budget refusal and distinct same-BaseFont descriptor weights have mutation-checked regressions; coverage and complex mixed runs remain limited. Done since: #5657 page-mark operations are atomic across selected pages, including shared/inherited resources and existing unsaved edits. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks PR #2155 candidate: editor-owned crops preserve original clips through group moves; Arrange preserves added-item stream ownership and refuses unsafe scope/state/tag extraction; image handles and flips follow page orientation; general transforms and Unicode script toggles have headless tools. Clipboard, mixed-size formatting, Home pins and reloadable installed-font discovery have review regressions. Latest follow-up uses explicit image-paste events, refreshes the mixed-size display, and documents script normalization with Undo. Further review covers inherited Form text and mask chronology, scoped transform composition, affine resize/rotation pivots, paste/selection ownership, no-op scripts and page-menu permissions. |
|
||||
| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Done since: Edit text preserves Arabic/Hebrew `/ActualText` typing order, with font-independent regressions; single-glyph RTL ligature spans (including lam-alef) join their surrounding word. Done since (#1116): editable text regions grouped across compatible words and subset fonts, one rich-text editor over a background with the source text removed, embedded sfnt faces reused as editor faces, and visible (clipped) bounds for cropped-image selection and resize. Exclusive crops now resize across nested states and content streams, with shared clips fixed.<br>Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. #1200 candidate: local GoToR links open in desktop tabs and through root-confined automation; appearance-only edits preserve existing actions. Named destinations remain unapplied and `/NewWindow` is a tab hint. Done since: #3187: Added-image automation normalizes extreme signed rotation angles before combining them with the current orientation; integer bounds, history, pixels, save/reopen and MCP have regressions. Done since: #2592: Styled fallback fonts batch up to 240 glyphs per actual face and retain source styles. Budget refusal and distinct same-BaseFont descriptor weights have mutation-checked regressions; coverage and complex mixed runs remain limited. Done since: #5657 page-mark operations are atomic across selected pages, including shared/inherited resources and existing unsaved edits. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks PR #2155 candidate: editor-owned crops preserve original clips through group moves; Arrange preserves added-item stream ownership and refuses unsafe scope/state/tag extraction; image handles and flips follow page orientation; general transforms and Unicode script toggles have headless tools. Clipboard, mixed-size formatting, Home pins and reloadable installed-font discovery have review regressions. Latest follow-up uses explicit image-paste events, refreshes the mixed-size display, and documents script normalization with Undo. Further review covers inherited Form text and mask chronology, scoped transform composition, affine resize/rotation pivots, paste/selection ownership, no-op scripts and page-menu permissions. Done since: #5835 a right-click on an image in crop mode keeps the crop and opens no menu, also over overlapping and rotated images. |
|
||||
| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Done since: Edit text preserves Arabic/Hebrew `/ActualText` typing order, with font-independent regressions. Done since (#1116): editable text regions grouped across compatible words and subset fonts, one rich-text editor over a background with the source text removed, embedded sfnt faces reused as editor faces, and visible (clipped) bounds for cropped-image selection and resize. Exclusive crops now resize across nested states and content streams, with shared clips fixed.<br>Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. #1200 candidate: local GoToR links open in desktop tabs and through root-confined automation; appearance-only edits preserve existing actions. Named destinations remain unapplied and `/NewWindow` is a tab hint. #1316: replacing a line or paragraph keeps a neighbouring line whose glyph box overlaps when the baselines differ; a second copy on the same baseline is still replaced. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks |
|
||||
| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). #3212: Headers, Bates numbers, watermarks and added text stay visible after unfinished marked content, without revealing hidden content. Tracked editable text distinguishes repeated glyph spacing from word gaps, with synthetic paragraph/object regressions. Missing: editing existing text and images in place (the longest pole), watermark print/screen visibility and repeat |
|
||||
| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions.<br>Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA<br>Done since: Existing simple field-font encodings and Differences are respected when generating appearances; an unencodable value uses a separate Helvetica appearance font without changing the source font. Synthetic reopen regressions cover punctuation, subset fallback and multiline text. Exact font metrics/layout and broader Unicode fallback remain incomplete. |
|
||||
|
||||
Reference in new issue
Block a user