fix(ui): keep added image corner resize responsive and clip out-of-bounds drag preview (#4477, #3938) (#4625)

This commit is contained in:
jonathanwxh-cell authored and GitHub committed 2026-11-10 10:00:18 +07:00
1 parent 8e6db09459
commit c6d9c13002
8 files changed
+308 -52

No files matched your search

+9
View File
@@ -61,6 +61,15 @@ impl PageImage {
self.clips.iter().any(|clip| clip.app_crop)
}
/// The editing frame, respecting explicit PDF/editor crops but not the page boundary.
/// Selected handles remain reachable outside the page; body hits still use `contains`.
pub fn frame_rect(&self) -> Option<[f64; 4]> {
self.clips.iter().try_fold(self.rect, |rect, clip| {
let b = clip.path.bounding_box();
intersection(rect, [b.x0, b.y0, b.x1, b.y1])
})
}
/// Whether a user-space point belongs to the placed artwork and every active clip.
pub fn contains(&self, point: [f64; 2]) -> bool {
if !point.iter().all(|v| v.is_finite()) {
+19
View File
@@ -13610,6 +13610,25 @@ fn review_inside_scope_group_move_matches_direct_transform_under_outer_scale() {
}
}
#[test]
fn image_editing_frame_keeps_page_edge_outside_explicit_clips() {
for (prefix, expected) in [
("", [-30.0, -20.0, 70.0, 80.0]),
("-10 -5 60 65 re W n ", [-10.0, -5.0, 50.0, 60.0]),
("/PdfCraftImageCrop MP -10 -5 60 65 re W n ", [-10.0, -5.0, 50.0, 60.0]),
] {
let doc = image_page_with(&format!("q {prefix}100 0 0 100 -30 -20 cm /Im0 Do Q"));
for doc in [&doc, &reopen(&doc)] {
let im = images::page_images(doc, 0).unwrap().remove(0);
assert_eq!(im.frame_rect(), Some(expected));
assert_eq!(im.has_editor_crop(), prefix.starts_with("/PdfCraftImageCrop"));
assert_eq!(im.visible_rect.unwrap()[0..2], [0.0, 0.0]);
assert!(!im.contains([-5.0, 10.0]), "out-of-page pixels stay unselectable");
assert!(im.contains([10.0, 10.0]));
}
}
}
#[test]
fn explicit_builtin_thai_replaces_an_unavailable_source_without_installing_a_font() {
let mut doc = text_page("BT /F1 16 Tf 50 700 Td (A) Tj ET BT /F2 12 Tf 50 600 Td (ab) Tj ET");
+3 -6
View File
@@ -420,13 +420,10 @@ fn run_edit_page_image(doc: &mut pdfcraft_cos::Document, page: usize, index: usi
if let Some((added_idx, added_item)) = matched_added
&& let AddedContent::Image(ai) = added_item.content.clone()
{
// Page visibility alone is not a crop: dragging an added image over the page edge
// must not discard its editable added-item identity on the next resize.
let has_active_crop = ai.crop.iter().any(|v| v.abs() > 1e-6)
|| img.visible_rect.is_some_and(|v| {
(v[0] - img.rect[0]).abs() > 1e-4
|| (v[1] - img.rect[1]).abs() > 1e-4
|| (v[2] - img.rect[2]).abs() > 1e-4
|| (v[3] - img.rect[3]).abs() > 1e-4
});
|| img.frame_rect().is_none_or(|frame| frame.iter().zip(img.rect).any(|(a, b)| (a - b).abs() > 1e-4));
let non_axis_transform = matches!(change, ImageEdit::Transform([a, b, c, d, _, _])
if !((b.abs() < 1e-5 && c.abs() < 1e-5) || (a.abs() < 1e-5 && d.abs() < 1e-5)));
let should_promote = has_active_crop
+19
View File
@@ -13241,6 +13241,25 @@ fn redacting_an_optimized_jbig2_page_stores_the_rest_of_its_group_again() {
assert_eq!(named, 0, "no image names the dictionary, which left with them");
}
#[test]
fn resizing_an_added_image_back_inside_the_page_keeps_its_identity() {
let mut png = Vec::new();
{
let mut enc = png::Encoder::new(&mut png, 2, 2);
enc.set_color(png::ColorType::Rgb);
enc.write_header().unwrap().write_image_data(&[255u8; 12]).unwrap();
}
let (mut s, id) = session_with(1);
s.apply(id, Edit::AddImage { page: 0, rect: Some([-30.0, -20.0, 70.0, 80.0]), name: "edge.png".into(), bytes: Arc::new(png) }).unwrap();
let original = s.get(id).unwrap().added()[0].obj;
let target = [10.0, 10.0, 60.0, 60.0];
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Move(target) }).unwrap();
let added = s.get(id).unwrap().added();
assert_eq!(added.len(), 1);
assert_eq!(added[0].obj, original);
assert_eq!(added[0].content.rect(), target);
}
#[test]
fn fill_sign_multiline_text_survives_engine_save_reopen_and_render() {
let (mut session, id) = rotated_blank(0);
+11 -30
View File
@@ -102,39 +102,15 @@ impl PainterExt for egui::Painter {
}
}
/// User-space selectable bounding box for an image object.
/// If the image object has an active clipping path applied to it (the crop box),
/// this is the mathematical intersection of the full CTM-derived rect and the clipping path's rect.
/// User-space editing frame: explicit PDF/editor crops apply, but the page edge does not
/// truncate a selected image's handles. Body hit testing still respects page visibility.
pub fn image_selectable_bounds(im: &pdfcraft_engine::PageImage) -> [f64; 4] {
if let Some(clip) = im.clip_rect {
let r = [im.rect[0].max(clip[0]), im.rect[1].max(clip[1]), im.rect[2].min(clip[2]), im.rect[3].min(clip[3])];
if r.iter().all(|v| v.is_finite()) && r[2] > r[0] && r[3] > r[1] {
return r;
}
}
im.visible_rect.unwrap_or(im.rect)
im.frame_rect().unwrap_or(im.rect)
}
/// Screen-space selectable bounding box for an image object.
/// If the image object has an active clipping path applied to it (the crop box),
/// the final selectable `egui::Rect` is the mathematical intersection of the image's
/// full CTM-derived rect and the clipping path's rect.
/// Screen-space editing frame, including selected handles outside the page.
pub fn image_screen_rect(im: &pdfcraft_engine::PageImage, xf: &PageXform, info: &DocInfo, page: usize) -> Rect {
let full = xf.user_rect_f64(info, page, im.rect);
if let Some(clip) = im.clip_rect {
let clip_r = xf.user_rect_f64(info, page, clip);
let inter = full.intersect(clip_r);
if inter.is_positive() {
return inter;
}
}
if let Some(vr) = im.visible_rect {
let r = xf.user_rect_f64(info, page, vr);
if r.is_positive() {
return r;
}
}
full
xf.user_rect_f64(info, page, image_selectable_bounds(im))
}
/// An Oriented Bounding Box (OBB) representing a rotated rectangle in screen space,
@@ -1860,7 +1836,12 @@ fn draw_pages(
if delta != Vec2::ZERO && delta.is_finite() {
ui.scroll_with_delta_animation(delta, egui::style::ScrollAnimation::none());
}
let (resp_rect, resp) = ui.allocate_exact_size(vec2(content_w, content_h), Sense::click_and_drag());
// A resized handle can sit in the window beside a page that does not fill it. The
// response has to cover that visible area or the next press never starts: the pointer
// is reported only inside this rectangle once the previous drag has been released.
// Page positions stay on `content_w`/`content_h`; the extra area is not a new page.
let response_size = vec2(content_w.max(viewport.width()), content_h.max(viewport.height()));
let (resp_rect, resp) = ui.allocate_exact_size(response_size, Sense::click_and_drag());
view.page_widget = Some(resp.id);
// A drag lasts only while egui reports it, so the mark can't outlive one.
if !resp.dragged_by(egui::PointerButton::Primary) {
+30 -14
View File
@@ -275,29 +275,30 @@ impl MoveBackground {
return;
}
let r = screen_rect(xf, info, page, from);
let base = self.settling.map_or(r, |s| screen_rect(xf, info, page, s));
let to = if let Some((_, grab, start)) = cv.grab {
let Some(p) = pointer else { return };
dragged(r, grab, p - start, self.picture)
} else if let Some(to) = self.settling {
let to = screen_rect(xf, info, page, to);
dragged(base, grab, p - start, self.picture)
} else if self.settling.is_some() {
// A moved text box keeps its top edge (its height follows its lines).
if self.picture { to } else { r.translate(to.left_top() - r.left_top()) }
if self.picture { base } else { r.translate(base.left_top() - r.left_top()) }
} else {
return;
};
let painter = painter.with_clip_rect(painter.clip_rect().intersect(xf.rect));
xf.paint_image(&painter, background.id(), 0.0, 0.0, 1.0, 1.0);
// A pixel around the box, so its edges come along.
// A pixel around the box, so its edges come along. Clip the source to the page raster so
// an item extending past the page border never samples clamped edge texels (#3938, #4477).
let (r, to) = (r.expand(1.0), to.expand(1.0));
let clipped = r.intersect(xf.rect);
if !clipped.is_positive() || r.width() <= 0.0 || r.height() <= 0.0 {
return;
}
let map = |p: Pos2| Pos2::new(to.min.x + (p.x - r.min.x) / r.width() * to.width(), to.min.y + (p.y - r.min.y) / r.height() * to.height());
let mut mesh = egui::Mesh::with_texture(item.id());
for (a, b) in [
(r.left_top(), to.left_top()),
(r.right_top(), to.right_top()),
(r.right_bottom(), to.right_bottom()),
(r.left_bottom(), to.left_bottom()),
] {
for a in [clipped.left_top(), clipped.right_top(), clipped.right_bottom(), clipped.left_bottom()] {
let (u, v) = xf.screen_to_norm(a);
mesh.vertices.push(egui::epaint::Vertex { pos: b, uv: Pos2::new(u, v), color: Color32::WHITE });
mesh.vertices.push(egui::epaint::Vertex { pos: map(a), uv: Pos2::new(u, v), color: Color32::WHITE });
}
mesh.add_triangle(0, 1, 2);
mesh.add_triangle(0, 2, 3);
@@ -344,7 +345,11 @@ fn dragged(r: Rect, grab: Grab, d: egui::Vec2, keep_aspect: bool) -> Rect {
Grab::Corner(left, top) => {
let (mut dx, mut dy) = (if left { -d.x } else { d.x }, if top { -d.y } else { d.y });
if keep_aspect && r.width() > 0.0 && r.height() > 0.0 {
let k = ((r.width() + dx) / r.width()).max((r.height() + dy) / r.height()).max(0.05);
// Use the axis with the larger relative pointer change so shrinking along one axis
// does not freeze when the other axis has not moved inward (#4477).
let (rx, ry) = (dx / r.width(), dy / r.height());
let delta = if rx.abs() > ry.abs() { rx } else { ry };
let k = (1.0 + delta).max(0.05);
dx = r.width() * k - r.width();
dy = r.height() * k - r.height();
}
@@ -373,7 +378,8 @@ pub(crate) fn hit(xf: &PageXform, info: &DocInfo, page: usize, added: &[Added],
let items: Vec<(usize, &Added)> = on_page(added, page).into_iter().filter(|(_, a)| matches!(a.content, AddedContent::Text(_))).collect();
let selected = view.content.selected.filter(|s| s.0 == page).and_then(|(_, si)| items.iter().find(|(i, _)| *i == si));
if let Some((index, a)) = selected
&& let Some((_, left, top)) = handles(screen_rect(xf, info, page, a.content.rect())).into_iter().find(|(c, ..)| c.distance(p) <= 6.0)
&& let Some((_, left, top)) =
handles(screen_rect(xf, info, page, a.content.rect()).expand(1.0)).into_iter().find(|(c, ..)| c.distance(p) <= 6.0)
{
let icon = if left == top { egui::CursorIcon::ResizeNwSe } else { egui::CursorIcon::ResizeNeSw };
return Some(Hit::new(Owner::Added { index: *index, corner: Some((left, top)) }, Layer::Handle, icon));
@@ -1596,4 +1602,14 @@ mod tests {
assert_eq!(rows(width + 5.0), 2, "the saved text wraps here, so the editor must too");
assert_eq!(rows(width + 10.0), 1, "and keeps the line when the spacing fits");
}
/// #4477: dragging a corner handle predominantly inward on one axis must still shrink an image
/// proportionally instead of freezing because the other axis did not shrink.
#[test]
fn corner_resize_shrinks_image_when_dragged_inward_along_one_axis() {
let r = Rect::from_min_max(Pos2::new(100.0, 100.0), Pos2::new(200.0, 150.0));
let shrunk = dragged(r, Grab::Corner(false, false), egui::vec2(-40.0, 2.0), true);
assert!((shrunk.width() - 60.0).abs() < 1e-3, "width shrinks by 40%: {shrunk:?}");
assert!((shrunk.height() - 30.0).abs() < 1e-3, "height shrinks proportionally by 40%: {shrunk:?}");
}
}
@@ -528,12 +528,19 @@ fn stretched_picture(painter: &egui::Painter, xf: &PageXform, tex: egui::Texture
for r in blank {
clipped.rect_filled(*r, CornerRadius::ZERO, Color32::WHITE);
}
// The raster has no pixels beyond the page. Clip the source and its corresponding
// destination together, rather than stretching clamped edge texels into the preview.
let source = from.intersect(xf.rect);
if !source.is_positive() || !from.is_positive() {
return;
}
let target = egui::emath::RectTransform::from_to(from, to).transform_rect(source);
mapped_picture(
&clipped,
xf,
tex,
[from.left_top(), from.right_top(), from.right_bottom(), from.left_bottom()],
[to.left_top(), to.right_top(), to.right_bottom(), to.left_bottom()],
[source.left_top(), source.right_top(), source.right_bottom(), source.left_bottom()],
[target.left_top(), target.right_top(), target.right_bottom(), target.left_bottom()],
);
}
+208
View File
@@ -1039,6 +1039,177 @@ fn a_resized_added_picture_shows_only_at_its_new_size() {
assert!(red(&pixels, kept) > 100 && red(&pixels, gone) == 0, "the page raster has the picture at its new size");
}
/// #4477, #3938: in Edit a PDF, an added image can be resized repeatedly by its corner handles
/// (including the outer part of the handle circle and when a corner reaches past the page edge)
/// without losing selection to the object marquee, and dragging an out-of-bounds image back onto
/// the page does not smear edge pixels across the out-of-bounds half.
#[test]
fn added_picture_resizes_repeatedly_and_drags_back_from_out_of_bounds_without_smear() {
let _gpu = gpu();
let mut h = harness();
assert!(h.state_mut().execute("edit.edit_text"));
h.run_steps(2);
let mut png = Vec::new();
{
let mut enc = png::Encoder::new(&mut png, 60, 60);
enc.set_color(png::ColorType::Rgb);
let mut w = enc.write_header().unwrap();
w.write_image_data(&[200u8, 30, 30].repeat(3600)).unwrap();
}
h.state_mut().add_image("red.png".into(), png);
h.run_steps(3);
crate::support::settle(&mut h);
// 1. Grab the outer part of the lower-right corner handle (where the resize cursor is shown,
// outside r.expand(2.0)) and drag inward along one axis to shrink the image.
let rect0 = added(&h)[0].content.rect();
let r0 = item_on_screen(&h, rect0);
let grip1 = r0.expand(1.0).right_bottom() + egui::vec2(2.0, 2.0);
h.hover_at(grip1);
h.run_steps(2);
assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::ResizeNwSe);
let to1 = grip1 + egui::vec2(-30.0, 0.0);
h.drag_at(grip1);
h.run_steps(1);
for k in 1..=4 {
h.hover_at(grip1 + (to1 - grip1) * (k as f32 / 4.0));
h.run_steps(1);
}
h.drop_at(to1);
h.run_steps(3);
assert_eq!(h.state().views[0].content.selected, Some((0, 0)), "the added image stays selected after releasing a corner resize");
let rect1 = added(&h)[0].content.rect();
assert!(rect1[2] - rect1[0] < (rect0[2] - rect0[0]) - 10.0, "first corner resize shrinks the image: {rect0:?} -> {rect1:?}");
// 2. Immediately resize again right after letting go, stretching past the right page edge,
// then shrink back from the out-of-bounds corner handle.
let r1 = item_on_screen(&h, rect1);
let page_right = h.state().views[0].page_screen_rect(0).unwrap().right();
let grip2 = r1.expand(1.0).right_bottom();
let to2 = pos2(page_right + 12.0, grip2.y);
h.hover_at(grip2);
h.run_steps(1);
h.drag_at(grip2);
h.run_steps(1);
for k in 1..=4 {
h.hover_at(grip2 + (to2 - grip2) * (k as f32 / 4.0));
h.run_steps(1);
}
h.drop_at(to2);
h.run_steps(3);
let rect2 = added(&h)[0].content.rect();
assert!(rect2[2] > 300.0, "second resize reaches past the page edge: {rect2:?}");
// Grab the corner handle that now sits outside the page rect and shrink it back inside.
let r2 = item_on_screen(&h, rect2);
let grip3 = r2.expand(1.0).right_bottom();
assert!(!h.state().views[0].page_screen_rect(0).unwrap().contains(grip3), "corner handle lies outside the page rect");
let to3 = pos2(page_right - 40.0, grip3.y);
h.hover_at(grip3);
h.run_steps(2);
assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::ResizeNwSe);
h.drag_at(grip3);
h.run_steps(1);
for k in 1..=4 {
h.hover_at(grip3 + (to3 - grip3) * (k as f32 / 4.0));
h.run_steps(1);
}
h.drop_at(to3);
h.run_steps(3);
let rect3 = added(&h)[0].content.rect();
assert!(rect3[2] < 300.0, "out-of-bounds corner handle still resizes the image back inside: {rect2:?} -> {rect3:?}");
// 3. Place the image half below the bottom edge of the page (y = -30..30), wait for the page
// raster, then drag it up into the page: the out-of-bounds bottom half must not smear the
// page edge's red texels across the lower half during the drag preview.
let img = match &added(&h)[0].content {
pdfcraft_engine::AddedContent::Image(i) => pdfcraft_engine::AddedImage { rect: [120.0, -30.0, 180.0, 30.0], ..i.clone() },
_ => panic!(),
};
assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::UpdateContent { page: 0, index: 0, content: pdfcraft_engine::AddedContent::Image(img) }));
crate::support::settle(&mut h);
let from = item_on_screen(&h, [120.0, -30.0, 180.0, 30.0]);
let grab = pos2(from.center().x, from.top() + 10.0);
let shift = egui::vec2(0.0, -120.0);
h.hover_at(grab);
h.run_steps(1);
h.drag_at(grab);
h.run_steps(1);
for k in 1..=4 {
h.hover_at(grab + shift * (k as f32 / 4.0));
h.run_steps(1);
}
crate::support::settle(&mut h);
let moved = from.translate(shift);
let upper_half = egui::Rect::from_min_max(moved.min + egui::vec2(4.0, 4.0), pos2(moved.max.x - 4.0, moved.center().y - 4.0));
let lower_half = egui::Rect::from_min_max(pos2(moved.min.x + 4.0, moved.center().y + 4.0), moved.max - egui::vec2(4.0, 4.0));
let pixels = h.render().unwrap();
if let Ok(dir) = std::env::var("PDFCRAFT_SHOTS") {
pixels.save(format!("{dir}/added-image-outside-page-drag.png")).unwrap();
}
assert!(red(&pixels, upper_half) > 100, "the in-bounds half of the picture follows the drag");
assert_eq!(red(&pixels, lower_half), 0, "the out-of-bounds half must not smear edge texels across the lower half");
h.drop_at(grab + shift);
crate::support::settle(&mut h);
}
/// A short page leaves empty window under the scroll content. A handle dragged into that
/// margin has to start another resize after the pointer is released.
#[test]
fn a_handle_in_the_viewport_below_a_short_page_starts_another_resize() {
let _gpu = gpu();
let mut h = harness();
h.state_mut().set_option("zoom", "50").unwrap();
assert!(h.state_mut().execute("edit.edit_text"));
h.run_steps(2);
let mut png = Vec::new();
{
let mut enc = png::Encoder::new(&mut png, 60, 60);
enc.set_color(png::ColorType::Rgb);
let mut w = enc.write_header().unwrap();
w.write_image_data(&[200u8, 30, 30].repeat(3600)).unwrap();
}
h.state_mut().add_image("red.png".into(), png);
h.run_steps(3);
let img = match &added(&h)[0].content {
pdfcraft_engine::AddedContent::Image(i) => pdfcraft_engine::AddedImage { rect: [40.0, 0.0, 100.0, 40.0], ..i.clone() },
_ => panic!("added an image"),
};
assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::UpdateContent { page: 0, index: 0, content: pdfcraft_engine::AddedContent::Image(img) }));
h.run_steps(3);
let page = h.state().views[0].page_screen_rect(0).unwrap();
let grip = item_on_screen(&h, [40.0, 0.0, 100.0, 40.0]).expand(1.0).right_bottom();
let below = pos2(grip.x, page.bottom() + 80.0);
assert!(below.y < 880.0, "the margin point stays in the window: {below:?}");
h.hover_at(grip);
h.run_steps(1);
h.drag_at(grip);
h.run_steps(1);
for k in 1..=4 {
h.hover_at(grip + (below - grip) * (k as f32 / 4.0));
h.run_steps(1);
}
h.drop_at(below);
h.run_steps(3);
let pulled = added(&h)[0].content.rect();
assert!(pulled[1] < -5.0, "the first drag leaves the page: {pulled:?}");
let grip2 = item_on_screen(&h, pulled).expand(1.0).right_bottom();
assert!(grip2.y > page.bottom() + 40.0, "the handle is below the page margin: grip {grip2:?} page {page:?}");
let to = pos2(grip2.x, grip2.y - 36.0);
h.hover_at(grip2);
h.run_steps(1);
h.drag_at(grip2);
h.run_steps(1);
for k in 1..=4 {
h.hover_at(grip2 + (to - grip2) * (k as f32 / 4.0));
h.run_steps(1);
}
h.drop_at(to);
h.run_steps(3);
let again = added(&h)[0].content.rect();
assert!(again[1] > pulled[1] + 5.0, "the second press never started a resize: {pulled:?} -> {again:?}");
}
/// Bug A for added text: with one line of the box selected, an alignment applies to that line
/// only, as does a second one, the box stays open, and Undo puts it back and closes the box.
#[test]
@@ -1491,6 +1662,43 @@ fn typing_in_one_tab_never_reaches_another_tabs_added_text() {
assert_eq!(h.state().views[1].content.draft.as_ref().map(|d| d.text.as_str()), Some("Reviewed"), "the other tab's typing stays out");
}
/// Out-of-page editing grips do not turn the invisible image body into a context-menu target.
#[test]
fn image_context_menu_requires_visible_body_even_when_the_frame_crosses_the_page() {
for visible in [false, true] {
let mut h = harness();
assert!(h.state_mut().execute("edit.edit_text"));
h.run_steps(2);
let mut png = Vec::new();
image::RgbImage::from_pixel(20, 20, image::Rgb([200, 30, 30]))
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
.unwrap();
h.state_mut().add_image("edge.png".into(), png);
h.run_steps(3);
let image = match &added(&h)[0].content {
pdfcraft_engine::AddedContent::Image(i) => pdfcraft_engine::AddedImage { rect: [260.0, 150.0, 340.0, 230.0], ..i.clone() },
_ => panic!("added an image"),
};
assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::UpdateContent {
page: 0,
index: 0,
content: pdfcraft_engine::AddedContent::Image(image),
}));
h.run_steps(3);
h.state_mut().views[0].image_selection = Some(pdfcraft_ui_egui::ImageSelection::at(0, 0));
let x = if visible { 280.0 } else { 320.0 };
let point = item_on_screen(&h, [x, 190.0, x, 190.0]).center();
assert_eq!(h.state().views[0].page_screen_rect(0).unwrap().contains(point), visible);
h.hover_at(point);
h.run_steps(1);
h.event(egui::Event::PointerButton { pos: point, button: egui::PointerButton::Secondary, pressed: true, modifiers: Default::default() });
h.event(egui::Event::PointerButton { pos: point, button: egui::PointerButton::Secondary, pressed: false, modifiers: Default::default() });
h.run_steps(2);
// Flip horizontal also appears in the persistent sidebar; Arrange image is menu-only.
assert_eq!(h.query_all_by_label("Arrange image").next().is_some(), visible, "image menu requires visible body");
}
}
/// The rail's page and zoom fields are the same size, and each sits wholly inside the rail, so
/// neither box is cut off at the edge (the page box lost its right side once the rail's contents
/// changed width).