mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-30 13:08:48 -06:00
M7: keep a moved or resized page image in place until its page is drawn again
A dragged page image is drawn from the page raster, blanked where it was and stretched into its new frame. On release the edit applied and the drag state cleared in the same frame, but the page raster on screen still had the picture at its old size and place until its replacement arrived. For those frames the old picture showed again around the new frame, then jumped. Keep the old and new frames on the selection after a release, and draw the picture the way the drag did while the page is out of date: its raster, or on a tiled page any tile, still waits for a replacement. A failed edit never makes the page stale, so nothing is kept. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
4164951453
commit
7a7d39f01f
@@ -1046,6 +1046,16 @@ impl DocView {
|
||||
self.pages.get(&page).map(|p| p.tex.id())
|
||||
}
|
||||
|
||||
/// Whether `page` is still drawn as it was before its last change: its raster, or on a tiled
|
||||
/// page any of its tiles, is waiting for a replacement.
|
||||
pub(crate) fn page_out_of_date(&self, page: usize) -> bool {
|
||||
if self.tiles.keys().any(|k| k.0 == page) {
|
||||
self.stale_tiles.iter().any(|k| k.0 == page)
|
||||
} else {
|
||||
self.pages.get(&page).is_some_and(|p| p.tag == STALE_TAG)
|
||||
}
|
||||
}
|
||||
|
||||
/// The print preview's picture of `page`: the sheet raster when it has arrived, otherwise the
|
||||
/// thumbnail.
|
||||
pub(crate) fn page_preview(&self, page: usize) -> Option<egui::TextureId> {
|
||||
|
||||
@@ -358,6 +358,9 @@ pub struct ImageSelection {
|
||||
drag: Option<(Pos2, Option<Pos2>)>,
|
||||
/// Crop mode (Acrobat's crop handles): the box kept so far.
|
||||
pub crop: Option<CropMode>,
|
||||
/// A move or resize applied on release: the frame it had and the one it got, in the page's
|
||||
/// 0..1 coordinates, while the page raster still has the picture where it was.
|
||||
settled: Option<(Rect, Rect)>,
|
||||
}
|
||||
|
||||
/// Cropping a page image: the handles trim instead of resizing, the part that will go is
|
||||
@@ -533,6 +536,25 @@ impl ImageSelection {
|
||||
}
|
||||
}
|
||||
|
||||
/// The page raster's picture of the box `from`, stretched into `to` over a blank where it was.
|
||||
fn stretched_picture(painter: &egui::Painter, xf: &PageXform, tex: egui::TextureId, from: Rect, to: Rect) {
|
||||
let clipped = painter.with_clip_rect(painter.clip_rect().intersect(xf.rect));
|
||||
clipped.rect_filled(from, CornerRadius::ZERO, Color32::WHITE);
|
||||
let mut mesh = egui::Mesh::with_texture(tex);
|
||||
for (a, b) in [
|
||||
(from.left_top(), to.left_top()),
|
||||
(from.right_top(), to.right_top()),
|
||||
(from.right_bottom(), to.right_bottom()),
|
||||
(from.left_bottom(), to.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.add_triangle(0, 1, 2);
|
||||
mesh.add_triangle(0, 2, 3);
|
||||
clipped.add(egui::Shape::mesh(mesh));
|
||||
}
|
||||
|
||||
/// Once a selected corner is grabbed, text under it cannot take over the gesture. The same
|
||||
/// applies after the pointer leaves the image/page while dragging.
|
||||
pub(crate) fn image_owns_pointer(ui: &egui::Ui, xf: &PageXform, page: usize, info: &DocInfo, view: &DocView) -> bool {
|
||||
@@ -697,6 +719,18 @@ pub(crate) fn image_input(
|
||||
);
|
||||
}
|
||||
};
|
||||
// Until the page is rendered again its raster has an applied move or resize's picture
|
||||
// where it was: it stays blanked there and drawn where it went.
|
||||
let (out_of_date, raster) = (view.page_out_of_date(page), view.page_raster(page));
|
||||
if let Some(s) = view.image_selection.as_mut().filter(|s| s.drag.is_none()) {
|
||||
match (s.settled, raster) {
|
||||
(Some((from, to)), Some(tex)) if out_of_date => {
|
||||
let screen = |r: Rect| Rect::from_two_pos(xf.norm_to_screen(r.min.x, r.min.y), xf.norm_to_screen(r.max.x, r.max.y));
|
||||
stretched_picture(painter, xf, tex, screen(from), screen(to));
|
||||
}
|
||||
_ => s.settled = None,
|
||||
}
|
||||
}
|
||||
if view.image_selection.as_ref().is_some_and(|s| s.drag.is_none()) {
|
||||
painter.rect_stroke(b, CornerRadius::ZERO, Stroke::new(1.5, ACCENT), egui::StrokeKind::Outside);
|
||||
handles(b);
|
||||
@@ -754,21 +788,7 @@ pub(crate) fn image_input(
|
||||
if preview != b
|
||||
&& let Some(tex) = view.page_raster(page)
|
||||
{
|
||||
let clipped = painter.with_clip_rect(painter.clip_rect().intersect(xf.rect));
|
||||
clipped.rect_filled(b, CornerRadius::ZERO, Color32::WHITE);
|
||||
let mut mesh = egui::Mesh::with_texture(tex);
|
||||
for (from, to) in [
|
||||
(b.left_top(), preview.left_top()),
|
||||
(b.right_top(), preview.right_top()),
|
||||
(b.right_bottom(), preview.right_bottom()),
|
||||
(b.left_bottom(), preview.left_bottom()),
|
||||
] {
|
||||
let (u, v) = xf.screen_to_norm(from);
|
||||
mesh.vertices.push(egui::epaint::Vertex { pos: to, uv: Pos2::new(u, v), color: Color32::WHITE });
|
||||
}
|
||||
mesh.add_triangle(0, 1, 2);
|
||||
mesh.add_triangle(0, 2, 3);
|
||||
clipped.add(egui::Shape::mesh(mesh));
|
||||
stretched_picture(painter, xf, tex, b, preview);
|
||||
}
|
||||
painter.rect_filled(visible, CornerRadius::ZERO, ACCENT.gamma_multiply(0.04));
|
||||
painter.rect_stroke(visible, CornerRadius::ZERO, Stroke::new(1.5, ACCENT), egui::StrokeKind::Outside);
|
||||
@@ -777,6 +797,11 @@ pub(crate) fn image_input(
|
||||
if resp.drag_stopped() || !ui.input(|i| i.pointer.primary_down()) {
|
||||
if let Some(s) = view.image_selection.as_mut() {
|
||||
s.drag = None;
|
||||
let norm = |r: Rect| {
|
||||
let ((u0, v0), (u1, v1)) = (xf.screen_to_norm(r.min), xf.screen_to_norm(r.max));
|
||||
Rect::from_two_pos(Pos2::new(u0, v0), Pos2::new(u1, v1))
|
||||
};
|
||||
s.settled = (preview != b).then(|| (norm(b), norm(preview)));
|
||||
}
|
||||
if preview != b {
|
||||
view.pending_edit =
|
||||
@@ -832,7 +857,7 @@ pub(crate) fn image_input(
|
||||
_ => egui::CursorIcon::PointingHand,
|
||||
});
|
||||
if resp.clicked() || resp.secondary_clicked() {
|
||||
view.image_selection = Some(ImageSelection { page, index: hit, drag: None, crop: None });
|
||||
view.image_selection = Some(ImageSelection { page, index: hit, drag: None, crop: None, settled: None });
|
||||
}
|
||||
if resp.secondary_clicked() {
|
||||
ui.ctx().data_mut(|d| d.insert_temp(menu_key, true));
|
||||
|
||||
@@ -4454,6 +4454,85 @@ fn image_pixels_follow_the_frame_while_a_corner_is_dragged() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Red pixels in `r`: the test picture.
|
||||
fn red(pixels: &image::RgbaImage, r: egui::Rect) -> usize {
|
||||
let (x0, y0, x1, y1) = (r.left().max(0.0) as u32, r.top().max(0.0) as u32, r.right() as u32, r.bottom() as u32);
|
||||
(y0..y1.min(pixels.height()))
|
||||
.flat_map(|y| (x0..x1.min(pixels.width())).map(move |x| (x, y)))
|
||||
.filter(|(x, y)| matches!(pixels.get_pixel(*x, *y).0, [r, g, b, _] if r > 150 && g < 90 && b < 90))
|
||||
.count()
|
||||
}
|
||||
|
||||
/// A resized picture is applied when the corner is let go, but the page raster still has it at
|
||||
/// its old size until the page is rendered again. Until then it stays drawn at its new size and
|
||||
/// blanked at its old one; the old one used to show again for those frames.
|
||||
#[test]
|
||||
fn a_resized_image_keeps_its_new_size_until_the_page_is_redrawn() {
|
||||
use pdfcraft_cos::{Dict, Document, Object, Stream, write_incremental};
|
||||
let mut doc = Document::open(std::sync::Arc::new(fixture(1))).unwrap();
|
||||
let mut image = Dict::new();
|
||||
image.set(b"Type".to_vec(), Object::name("XObject"));
|
||||
image.set(b"Subtype".to_vec(), Object::name("Image"));
|
||||
image.set(b"Width".to_vec(), Object::Int(1));
|
||||
image.set(b"Height".to_vec(), Object::Int(1));
|
||||
image.set(b"BitsPerComponent".to_vec(), Object::Int(8));
|
||||
image.set(b"ColorSpace".to_vec(), Object::name("DeviceRGB"));
|
||||
let image = doc.add(Object::Stream(Stream::flate(image, &[200, 30, 30])));
|
||||
let page = pdfcraft_model::pages(&doc).remove(0);
|
||||
let contents = doc.add(Object::Stream(Stream::flate(Dict::new(), b"q 80 0 0 80 60 40 cm /Im0 Do Q")));
|
||||
let mut resources = doc.resolve(page.dict.get(b"Resources").unwrap()).as_dict().unwrap().clone();
|
||||
let mut xo = Dict::new();
|
||||
xo.set(b"Im0".to_vec(), Object::Ref(image));
|
||||
resources.set(b"XObject".to_vec(), Object::Dict(xo));
|
||||
doc.update_dict(page.obj, |d| {
|
||||
d.set(b"Resources".to_vec(), Object::Dict(resources));
|
||||
d.set(b"Contents".to_vec(), Object::Ref(contents));
|
||||
})
|
||||
.unwrap();
|
||||
let bytes = write_incremental(&doc, &Default::default()).unwrap();
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 1100.0)).build_eframe(move |_cc| {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.set_option("language", "en").unwrap();
|
||||
app.open_bytes("picture.pdf", None, bytes).unwrap();
|
||||
app
|
||||
});
|
||||
h.run_steps(4);
|
||||
assert!(h.state_mut().execute("edit.edit_text"));
|
||||
h.state_mut().views[0].set_zoom(1.2);
|
||||
h.run_steps(4);
|
||||
support::wait_until(&mut h, "the page raster", |app| !app.render_pending());
|
||||
let page = h.state().views[0].page_screen_rect(0).unwrap();
|
||||
let at = |x: f32, y: f32| egui::pos2(page.left() + x / 200.0 * page.width(), page.top() + (300.0 - y) / 300.0 * page.height());
|
||||
click_at(&mut h, at(100.0, 80.0));
|
||||
assert!(h.state().views[0].image_selection.is_some(), "the picture is selected");
|
||||
support::wait_until(&mut h, "the page raster", |app| !app.render_pending());
|
||||
// From 60..140 × 40..120 to half its size, keeping its lower left corner.
|
||||
let (grip, to) = (at(140.0, 120.0), at(100.0, 80.0));
|
||||
let kept = egui::Rect::from_two_pos(at(62.0, 42.0), at(98.0, 78.0));
|
||||
let gone = egui::Rect::from_two_pos(at(104.0, 84.0), at(138.0, 118.0));
|
||||
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 + (to - grip) * (k as f32 / 4.0));
|
||||
h.run_steps(1);
|
||||
}
|
||||
h.state_mut().views[0].hold_rasters = true;
|
||||
h.drop_at(to);
|
||||
for frame in 0..4 {
|
||||
h.step();
|
||||
let pixels = h.render().unwrap();
|
||||
assert!(red(&pixels, kept) > 100, "frame {frame} after the release: the picture is not at its new size");
|
||||
assert_eq!(red(&pixels, gone), 0, "frame {frame} after the release: the picture shows at its old size");
|
||||
}
|
||||
h.state_mut().views[0].hold_rasters = false;
|
||||
support::wait_until(&mut h, "the page drawn with the resized picture", |app| !app.render_pending());
|
||||
h.run_steps(2);
|
||||
let pixels = h.render().unwrap();
|
||||
assert!(red(&pixels, kept) > 100 && red(&pixels, gone) == 0, "the page raster has the picture at its new size");
|
||||
}
|
||||
|
||||
/// Exercise actual pointer gestures on each cropped placement, without capturing private pixels.
|
||||
#[test]
|
||||
#[ignore = "requires an explicitly supplied local PDF with cropped artwork"]
|
||||
|
||||
Reference in New Issue
Block a user