fix(canvas): find a page's pictures off the window thread (#6228)

This commit is contained in:
quincy-jc authored and GitHub committed 2026-11-09 08:47:47 +08:00
1 parent 0bfcc8024e
commit 0b9c7fb821
6 files changed
+335 -36

No files matched your search

+38 -1
View File
@@ -2,7 +2,7 @@
//! file in its original format. A picture drawn through grouped
//! Form artwork (as Word, InDesign and many scanners write them) is found inside the group.
use std::collections::HashSet;
use std::collections::{HashMap, HashSet};
use pdfcraft_cos::{Dict, Document as Cos, ObjRef, Object};
@@ -13,6 +13,31 @@ const MAX_COPY_PIXELS: u64 = 64_000_000;
/// How deep Form artwork is searched for its picture.
const MAX_FORM_DEPTH: usize = 12;
/// A picture on a page, with its index in [`Document::page_images`].
pub type PagePicture = (usize, pdfcraft_edit::PageImage);
/// The topmost of `pictures` under user-space point `at` (`[x, y]`). `pictures` is bottom first,
/// as [`Document::page_pictures_job`] lists them, so the last one that holds `at` wins.
pub fn picture_at(pictures: &[PagePicture], at: [f64; 2]) -> Option<&PagePicture> {
pictures.iter().rev().find(|(_, image)| image.contains(at))
}
/// The pictures on `page`, bottom first: raster images, and Form artwork that holds one.
fn pictures(cos: &Cos, page: usize) -> Vec<PagePicture> {
let images = pdfcraft_edit::page_images(cos, page).unwrap_or_default();
// Artwork drawn many times is searched once.
let mut holds_one: HashMap<ObjRef, bool> = HashMap::new();
images
.into_iter()
.enumerate()
.filter(|(_, i)| match (i.is_form, i.object) {
(false, Some(_)) => true,
(true, Some(form)) => *holds_one.entry(form).or_insert_with(|| picture_in_form(cos, form).is_some()),
_ => false,
})
.collect()
}
impl Document {
/// The topmost picture on `page` under user-space point `at`, as its index in
/// [`Document::page_images`]: a raster image, or Form artwork that holds one.
@@ -28,6 +53,18 @@ impl Document {
})
}
/// Work that lists the pictures on `page`, bottom first, for another thread: it reads every
/// drawing operator on the page, seconds on a dense one, so it must not run on the window
/// thread. Look a point up with [`picture_at`].
///
/// `None` when the document can't be read. The work reads the document as it is now: later
/// edits aren't seen (key its answer by [`Document::edit_generation`]). It returns no pictures
/// for a page that doesn't exist or can't be read.
pub fn page_pictures_job(&self, page: usize) -> Option<impl FnOnce() -> Vec<PagePicture> + Send + 'static> {
let cos = self.editor.as_ref()?.cos.clone();
Some(move || crate::guard(|| pictures(&cos, page)).unwrap_or_default())
}
/// Copy Image in Original Format: picture `index` on `page` as a file (extension, bytes):
/// a JPEG as stored in the PDF, otherwise a lossless PNG.
pub fn page_image_copy_file(&self, page: usize, index: usize) -> Result<(&'static str, Vec<u8>), String> {
+1
View File
@@ -24,6 +24,7 @@ pub mod commands;
mod comments;
pub mod compare;
mod copy_image;
pub use copy_image::{PagePicture, picture_at};
pub mod dates;
mod document;
mod edit;
+6
View File
@@ -684,8 +684,14 @@ impl eframe::App for PdfCraftApp {
self.motion.begin_pass(&ctx, self.animation_effects);
// One-time 0.36 -> 0.37 panel persistence migration, before any panel stores or reads state.
migrate_panel_state(ui);
// Another tool lets go of a click whose picture is still being found, whether or not its
// page is drawn to see the change.
let picks = self.quick_tool == QuickTool::Select;
for view in &mut self.views {
view.begin_render_frame();
if !picks {
view.pending_pick = None;
}
}
self.sync_titles(&ctx);
// The accessible window takes the same name, so a screen reader (AT-SPI on Linux, UIA on
+8 -11
View File
@@ -682,6 +682,10 @@ pub struct DocView {
pub image_selection: Option<crate::edit_text_ui::ImageSelection>,
/// Select tool: the picture clicked, to copy in its original format.
pub picked_image: Option<crate::image_pick::PickedImage>,
/// A click (page, user-space point, document generation) whose picture is still being looked for.
pub(crate) pending_pick: Option<(usize, [f64; 2], u64)>,
/// The pictures of the page last asked about.
pub(crate) pictures: crate::image_pick::PagePictures,
/// A paragraph box being dragged (moved, or resized from its right edge) in Edit text.
pub block_drag: Option<crate::edit_text_ui::BlockDrag>,
/// Commenting state: selected comment, gestures, composer.
@@ -1078,6 +1082,8 @@ impl DocView {
objects: Default::default(),
image_selection: None,
picked_image: None,
pending_pick: None,
pictures: Default::default(),
block_drag: None,
pending_action: None,
insert_at: None,
@@ -2023,17 +2029,8 @@ fn draw_pages(
// Over a picture (and no comment): Copy Image, as in Acrobat's page menu.
let mut copy_image = None;
if let Some((page, at)) = view.comments.context_at {
// The page's content is parsed once per right-click, not on every frame the menu is open.
let key = egui::Id::unique("copy-image-under-menu");
let cached = ui.data(|d| d.get_temp::<(usize, [f64; 2], Option<usize>)>(key)).filter(|c| c.0 == page && c.1 == at);
let hit = match cached {
Some(c) => c.2,
None => {
let hit = doc.page_image_at(page, at);
ui.data_mut(|d| d.insert_temp(key, (page, at, hit)));
hit
}
};
// On a page still being read, these items appear when the answer comes.
let hit = view.pictures.get(ui.ctx(), doc, page).and_then(|pictures| pdfcraft_engine::picture_at(pictures, at)).map(|(index, _)| *index);
if let Some(image) = hit {
if ui.button(tl!("Copy Image")).clicked() {
copy_image = Some(comments::CanvasAction::CopyImage(page, image));
+276 -23
View File
@@ -36,29 +36,278 @@ pub(crate) fn page_input(
pick: bool,
view: &mut DocView,
) {
let Some(p) = ptr.pos.filter(|p| xf.rect.contains(*p)) else { return };
if ptr.drag_started || resp.double_clicked() || resp.triple_clicked() {
view.picked_image = None;
return;
}
if !resp.clicked() || !pick {
return;
}
let Some(info_page) = info.pages.get(page) else { return };
let (vx, vy) = xf.screen_to_view(p);
let u = info_page.view_to_user(vx, vy);
let at = [f64::from(u[0]), f64::from(u[1])];
let generation = doc.edit_generation();
view.picked_image = doc.page_image_at(page, at).and_then(|index| {
doc.page_images(page).get(index).and_then(|i| {
let screen_rect = crate::canvas::image_screen_rect(i, xf, info, page);
if screen_rect.is_positive() && screen_rect.contains(p) {
Some(PickedImage { page, index, rect: crate::canvas::image_selectable_bounds(i), generation })
} else {
None
if let Some(p) = ptr.pos.filter(|p| xf.rect.contains(*p)) {
if ptr.drag_started || resp.double_clicked() || resp.triple_clicked() {
view.picked_image = None;
view.pending_pick = None;
return;
}
if resp.clicked() {
// A link's click picks nothing, and takes back an earlier click still being looked into.
view.pending_pick = None;
if pick {
let (vx, vy) = xf.screen_to_view(p);
let u = info_page.view_to_user(vx, vy);
view.picked_image = None;
view.pending_pick = Some((page, [f64::from(u[0]), f64::from(u[1])], generation));
}
})
});
}
}
// The click's picture, as soon as the page's pictures are known: the window stays live until then.
let Some((_, at, clicked_in)) = view.pending_pick.filter(|pending| pending.0 == page) else { return };
if clicked_in != generation {
view.pending_pick = None;
} else if let Some(pictures) = view.pictures.get(&resp.ctx, doc, page) {
// Where the click was on the page now: the view may have moved while the page was read.
let point = xf.user_rect_f64(info, page, [at[0], at[1], at[0], at[1]]).center();
view.picked_image = pdfcraft_engine::picture_at(pictures, at).and_then(|(index, i)| {
let screen_rect = crate::canvas::image_screen_rect(i, xf, info, page);
(screen_rect.is_positive() && screen_rect.contains(point)).then(|| PickedImage {
page,
index: *index,
rect: crate::canvas::image_selectable_bounds(i),
generation,
})
});
view.pending_pick = None;
}
}
/// Page and document generation.
type PictureKey = (usize, u64);
/// The pictures of the page last asked about, found on their own thread: the search reads every
/// drawing operator on the page, seconds on a dense one. A small file answers on the spot, so its
/// menu and selection don't flicker. The web build has no threads, so there every search runs on
/// the spot, as before.
///
/// One search at a time: a page left while it is read is read to the end before the next one
/// starts (the read can't be stopped part-way).
#[derive(Default)]
pub(crate) struct PagePictures {
/// The pictures of the key's page.
ready: Option<(PictureKey, Vec<pdfcraft_engine::PagePicture>)>,
/// The search under way. One at a time: each is a full read of a page.
job: Option<(PictureKey, std::sync::mpsc::Receiver<Vec<pdfcraft_engine::PagePicture>>)>,
}
impl PagePictures {
/// A file smaller than this has its pictures read on the spot.
const SMALL_FILE: usize = 8 * 1024 * 1024;
/// The pictures on `page`, or `None` while they are being found (a repaint follows the answer).
pub(crate) fn get(&mut self, ctx: &egui::Context, doc: &pdfcraft_engine::Document, page: usize) -> Option<&[pdfcraft_engine::PagePicture]> {
let key = (page, doc.edit_generation());
self.poll();
if self.job.is_none() && !self.ready.as_ref().is_some_and(|(k, _)| *k == key) {
match doc.page_pictures_job(page) {
Some(work) if doc.bytes.len() < Self::SMALL_FILE => self.ready = Some((key, work())),
Some(work) => self.start(ctx, key, work),
None => self.ready = Some((key, Vec::new())),
}
self.poll();
}
self.ready.as_ref().filter(|(k, _)| *k == key).map(|(_, pictures)| pictures.as_slice())
}
fn start(&mut self, ctx: &egui::Context, key: PictureKey, work: impl FnOnce() -> Vec<pdfcraft_engine::PagePicture> + Send + 'static) {
let (send, answer) = std::sync::mpsc::channel();
let ctx = ctx.clone();
let run = move || {
let _ = send.send(work());
ctx.request_repaint();
};
self.job = Some((key, answer));
#[cfg(target_arch = "wasm32")]
run();
#[cfg(not(target_arch = "wasm32"))]
if let Err(e) = std::thread::Builder::new().name("page-pictures".into()).spawn(run) {
log::warn!("page pictures thread: {e}");
self.job = None;
self.ready = Some((key, Vec::new()));
}
}
/// Take the answer if it has come.
fn poll(&mut self) {
use std::sync::mpsc::TryRecvError;
let Some((key, answer)) = &self.job else { return };
let got = match answer.try_recv() {
Ok(pictures) => Some(pictures),
Err(TryRecvError::Empty) => None,
// The search failed: this page offers no pictures.
Err(TryRecvError::Disconnected) => Some(Vec::new()),
};
if let Some(pictures) = got {
self.ready = Some((*key, pictures));
self.job = None;
}
}
}
#[cfg(test)]
mod picture_tests {
use super::*;
/// A search that takes its time doesn't hold the window: starting and polling it return before
/// the search is let go, and the answer is picked up when it comes.
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn a_slow_page_does_not_hold_up_the_window() {
let (release, wait) = std::sync::mpsc::channel::<()>();
// Lets the search go in the end, should it run on this thread or be waited for.
let late = release.clone();
let let_go = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let watchdog = let_go.clone();
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_secs(2));
watchdog.store(true, std::sync::atomic::Ordering::SeqCst);
let _ = late.send(());
});
let mut pictures = PagePictures::default();
let caller = std::thread::current().id();
let (ran_on, ran) = std::sync::mpsc::channel();
pictures.start(&egui::Context::default(), (3, 1), move || {
let _ = ran_on.send(std::thread::current().id());
let _ = wait.recv();
Vec::new()
});
pictures.poll();
assert!(!let_go.load(std::sync::atomic::Ordering::SeqCst), "starting and polling the search must not wait for it");
// A search that never ran (its thread couldn't start) fails the test rather than hanging it.
let ran_on = ran.recv_timeout(std::time::Duration::from_secs(10)).expect("the search ran");
assert_ne!(ran_on, caller, "the search must not run on the calling thread");
let _ = release.send(());
for _ in 0..500 {
std::thread::sleep(std::time::Duration::from_millis(10));
pictures.poll();
if pictures.ready.is_some() {
break;
}
}
assert_eq!(pictures.ready, Some(((3, 1), Vec::new())));
}
/// Escape takes back a click whose picture isn't known yet: it isn't selected when it is.
#[test]
fn escape_cancels_a_click_on_a_page_still_being_read() {
let mut app = PdfCraftApp::new();
app.open_bytes("notes.txt", None, b"hello".to_vec()).unwrap();
let (i, _) = app.active_ids().unwrap();
app.views[i].pending_pick = Some((0, [1.0, 1.0], 0));
let escape = egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() };
egui::Context::default()
.run_ui(egui::RawInput { events: vec![escape], ..Default::default() }, |ui| app.image_pick_keys(i, ui.ctx()))
.drop_without_applying_deltas();
assert_eq!(app.views[i].pending_pick, None);
}
/// A page (200 × 300) with a square comment over 20..80 × 20..80 and a link over 120..180 ×
/// 220..280.
fn page_with_a_comment_and_a_link() -> Vec<u8> {
let objs = [
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>",
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R] >>",
"<< /Type /Annot /Subtype /Square /Rect [20 20 80 80] /C [1 0 0] /Border [0 0 2] >>",
"<< /Type /Annot /Subtype /Link /Rect [120 220 180 280] /Border [0 0 0] /A << /S /URI /URI (https://example.org/) >> >>",
];
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
out
}
/// The page open with the Select tool, once it is on screen, and where a point on it is.
fn open_page() -> (egui_kittest::Harness<'static, PdfCraftApp>, impl Fn(f32, f32) -> egui::Pos2) {
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
let mut app = PdfCraftApp::new();
app.open_bytes("marks.pdf", None, page_with_a_comment_and_a_link()).unwrap();
app
});
let mut on_screen = None;
for _ in 0..600 {
h.run_steps(2);
on_screen = h.state().views[0].page_screen_rect(0);
if on_screen.is_some() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
let r = on_screen.expect("the page is on screen");
let k = r.width() / 200.0;
(h, move |x, y| r.min + egui::vec2(x * k, (300.0 - y) * k))
}
/// An earlier click on the page whose pictures are still being found, as on a dense drawing:
/// they don't come while the returned sender lives.
fn click_still_being_looked_into(
h: &mut egui_kittest::Harness<'static, PdfCraftApp>,
) -> std::sync::mpsc::Sender<Vec<pdfcraft_engine::PagePicture>> {
let id = h.state().views[0].id;
let generation = h.state().session.get(id).expect("the document").edit_generation();
let (send, answer) = std::sync::mpsc::channel();
let view = &mut h.state_mut().views[0];
view.pictures = PagePictures { ready: None, job: Some(((0, generation), answer)) };
view.pending_pick = Some((0, [150.0, 150.0], generation));
send
}
fn click(h: &mut egui_kittest::Harness<'static, PdfCraftApp>, p: egui::Pos2) {
h.hover_at(p);
h.run_steps(1);
h.drag_at(p);
h.run_steps(1);
h.drop_at(p);
h.run_steps(4);
}
/// Another tool lets go of the click, even while its page isn't drawn (Organize pages).
#[test]
fn another_tool_takes_back_a_click_on_a_page_not_drawn() {
let (mut h, _) = open_page();
let _keep = click_still_being_looked_into(&mut h);
assert!(h.state_mut().execute("page.organize"));
h.run_steps(2);
assert!(h.state().views[0].pending_pick.is_some(), "the click waits while the page isn't drawn");
h.state_mut().quick_tool = crate::QuickTool::Hand;
h.run_steps(1);
h.state_mut().quick_tool = crate::QuickTool::Select;
h.run_steps(1);
assert_eq!(h.state().views[0].pending_pick, None);
}
/// A click a comment takes lets go of the earlier click: its picture isn't picked after the
/// comment is selected.
#[test]
fn a_click_on_a_comment_takes_back_the_earlier_click() {
let (mut h, at) = open_page();
let _keep = click_still_being_looked_into(&mut h);
click(&mut h, at(50.0, 50.0));
assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "the comment is selected");
assert_eq!(h.state().views[0].pending_pick, None);
}
/// A click on a link follows it and lets go of the earlier click too.
#[test]
fn a_click_on_a_link_takes_back_the_earlier_click() {
let (mut h, at) = open_page();
let _keep = click_still_being_looked_into(&mut h);
click(&mut h, at(150.0, 250.0));
assert!(h.state().pending_link.is_some(), "the link is followed");
assert_eq!(h.state().views[0].pending_pick, None);
}
}
/// Forget the selected picture once the document has been edited since it was picked: Edit text &
@@ -167,16 +416,20 @@ fn write_clipboard_file(dir: &std::path::Path, name: &str, bytes: &[u8]) -> Resu
}
impl PdfCraftApp {
/// ⌘C / Ctrl+C copies the selected picture in its original format; Escape deselects it.
/// ⌘C / Ctrl+C copies the selected picture in its original format; Escape deselects it, or
/// takes back a click whose picture isn't known yet.
pub(crate) fn image_pick_keys(&mut self, i: usize, ctx: &egui::Context) {
let Some(picked) = self.views.get(i).and_then(|v| v.picked_image) else { return };
if ctx.egui_wants_keyboard_input() {
return;
}
if ctx.input(|inp| inp.key_pressed(egui::Key::Escape)) {
self.views[i].picked_image = None;
if let Some(view) = self.views.get_mut(i) {
view.picked_image = None;
view.pending_pick = None;
}
return;
}
let Some(picked) = self.views.get(i).and_then(|v| v.picked_image) else { return };
let copy = ctx.input(|inp| inp.events.iter().any(|e| matches!(e, egui::Event::Copy)));
if !copy || self.views[i].selected_text().is_some() {
return;
+6 -1
View File
@@ -834,8 +834,13 @@ pub(super) fn page_overlays(ui: &mut egui::Ui, view: &mut DocView, frame: &mut P
// it is over first sees, whichever page it began on.
let pick_sees =
matches!(input.press, PagePress::Free | PagePress::Mine(Owner::Link { .. })) || input.press == PagePress::Elsewhere && ptr.drag_started;
if tool == QuickTool::Select && pick_sees && !input.consumed {
if tool != QuickTool::Select {
view.pending_pick = None;
} else if pick_sees && !input.consumed {
crate::image_pick::page_input(resp, &xf, i, info, doc, ptr, input.press == PagePress::Free, view);
} else if resp.clicked() || ptr.drag_started {
// Another owner took the press: an earlier click's picture, still being found, isn't picked.
view.pending_pick = None;
}
crate::image_pick::drop_stale(doc, view);
crate::image_pick::paint(painter, &xf, i, info, view);