Select All of the document: copy off the UI thread, end it on any page press

- Copy checks the document's copying permission first, then reads the
  pages whose text wasn't read for display on a worker thread
  (DocumentCopy; in place on the web), keeping only each page's text as
  it goes; the clipboard gets the text on a later frame. A new copy or
  the document closing cancels it.
- Any press on a page ends Select All of the document, whatever the tool
  and whether its text is read; so do switching to one page at a time, a
  page selection, and an edit of the document.
This commit is contained in:
SBaksa
2026-10-21 04:24:30 +11:00
parent daa76197dc
commit 1f182ff0bf
2 changed files with 241 additions and 30 deletions
+88 -3
View File
@@ -981,6 +981,7 @@ impl DocView {
self.waiting_since.clear();
self.last_queue.clear();
self.selection = None;
self.all_text = false;
if let Some(f) = self.find.as_mut() {
f.matches.clear();
f.current = None;
@@ -1067,6 +1068,7 @@ impl DocView {
/// Select text on `page` from glyph `from` to glyph `to` (tests and automation).
pub fn select_text(&mut self, page: usize, from: usize, to: usize) {
self.all_text = false;
self.selection = Some(Selection::run(page, from, to));
}
@@ -1367,6 +1369,7 @@ impl DocView {
self.all_text = true;
return self.selected_text().is_some();
}
self.all_text = false;
let page = self.current;
let Some(t) = self.texts.get(&page) else { return false };
if t.glyphs.is_empty() {
@@ -1435,6 +1438,8 @@ impl DocView {
pub fn set_continuous(&mut self, continuous: bool) {
if self.continuous != continuous {
self.continuous = continuous;
// Select All of the document belongs to continuous scrolling.
self.all_text = false;
self.goto = Some((self.current, 0.0));
self.reset_coarse_state();
}
@@ -3301,6 +3306,11 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
view.signature_drag.prepare(ui.ctx(), doc, preview_target, scale);
view.signature_drag.paint(painter, &pcx, &view.comments, view.pending_edit.as_ref());
// Any press on the page ends Select All of the document: text read or not, any tool,
// on a link too.
if view.all_text && ui.input(|inp| inp.pointer.primary_pressed() && inp.pointer.press_origin().is_some_and(|o| r.contains(o))) {
view.all_text = false;
}
// Text layer: find matches, selection, I-beam and drag-to-select.
let to_screen = |g: [f32; 4]| xf.view_rect(g);
if let Some(text) = view.texts.get(&i).cloned() {
@@ -3373,8 +3383,6 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
.is_some_and(|o| r.contains(o));
let pressed = ui.input(|inp| inp.pointer.primary_pressed());
if pressed && press_here && !over_link {
// A press on a page starts over: Select All of the document is dropped.
view.all_text = false;
let now = ui.input(|inp| inp.time);
// The same limits egui uses to count clicks on release.
let (max_delay, max_dist) = ui.ctx().options(|o| (o.input_options.max_double_click_delay, o.input_options.max_click_dist));
@@ -5754,10 +5762,87 @@ trailer << /Root 1 0 R >>
assert!(!v.select_all());
assert!(v.all_text && v.selection.is_none());
assert_eq!(v.pages_without_text(), vec![0, 1, 2]);
let text = app.selection_text(&app.views[0]).expect("text to copy");
// Copy reads the pages off the UI thread; the clipboard gets the text once they're read.
let ctx = egui::Context::default();
assert!(app.copy_selected_text(0, &ctx), "the copy starts");
assert!(app.document_copy.is_some(), "reading the pages, not on this thread");
let mut text = None;
for _ in 0..500 {
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| app.finish_document_copy(ui.ctx()));
out.textures_delta.clear();
text = out.platform_output.commands.iter().find_map(|c| match c {
egui::OutputCommand::CopyText(t) => Some(t.clone()),
_ => None,
});
if text.is_some() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(10));
}
let text = text.expect("the copied text arrives");
assert!(app.document_copy.is_none(), "done");
let at = |w: &str| text.find(w).unwrap_or_else(|| panic!("{w:?} missing from {text:?}"));
assert!(at("Alpha") < at("Bravo") && at("Bravo") < at("Charlie"), "page order: {text:?}");
assert_eq!(text.lines().count(), 3, "a line per page: {text:?}");
// A new scope ends it: one page at a time, Select All is the page's again.
app.views[0].set_continuous(false);
assert!(!app.views[0].all_text, "not after switching to one page at a time");
// So do a page selection and a change of the document's content (an edit, undo).
app.views[0].set_continuous(true);
app.views[0].select_all();
app.views[0].select_text(0, 0, 0);
assert!(!app.views[0].all_text, "a page selection replaces it");
app.views[0].select_all();
app.views[0].invalidate_content();
assert!(!app.views[0].all_text, "not past an edit of the document");
}
/// A press anywhere on a page ends Select All of the document, whatever the tool (here the
/// Hand, which selects no text) and whether the page's text is read yet.
#[test]
fn a_press_on_a_page_ends_select_all_of_the_document() {
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("three.pdf", None, text_pages_pdf(&["Alpha one", "Bravo two", "Charlie three"])).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 first page is on screen");
h.state_mut().quick_tool = crate::QuickTool::Hand;
h.state_mut().views[0].select_all();
assert!(h.state().views[0].all_text);
let at = r.center();
h.event(egui::Event::PointerMoved(at));
h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE });
h.run_steps(1);
h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE });
h.run_steps(2);
assert!(!h.state().views[0].all_text, "a press on the page ends it");
}
/// The pages of a whole-document copy come out in order whatever was read before, and a
/// cancelled copy gives nothing (a new copy or a closed document cancels the old one).
#[test]
fn a_document_copy_reads_in_page_order_and_stops_when_cancelled() {
let mut app = PdfCraftApp::new();
app.open_bytes("three.pdf", None, text_pages_pdf(&["Alpha one", "Bravo two", "Charlie three"])).unwrap();
// The middle page's text read for display already; the others read by the copy.
let doc = app.session.get(app.views[0].id).unwrap();
let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), Default::default());
let middle = r.render(RenderRequest { page: 1, kind: RequestKind::Text, ..Default::default() }).text.unwrap();
app.views[0].texts.insert(1, middle);
let job = || crate::DocumentCopyJob::of(&app.views[0], app.session.get(app.views[0].id).unwrap());
let text = job().text(&std::sync::atomic::AtomicBool::new(false)).unwrap();
assert!(text.find("Alpha").unwrap() < text.find("Bravo").unwrap() && text.find("Bravo").unwrap() < text.find("Charlie").unwrap(), "{text:?}");
assert_eq!(job().text(&std::sync::atomic::AtomicBool::new(true)), None, "cancelled");
}
/// Select All keeps to the page shown one page at a time, and in a document of one page; of
+153 -27
View File
@@ -384,6 +384,121 @@ pub struct PageClip {
pub pages: Vec<usize>,
}
/// One page of a whole-document copy: its text as read for display, to read, or not to try
/// (reading it failed before).
enum CopyPage {
Read(std::sync::Arc<pdfcraft_render::PageText>),
ToRead,
Skip,
}
/// What a whole-document copy needs, taken from the view on the UI thread: each page's text
/// layer as far as it was read, and the document to read the others from.
struct DocumentCopyJob {
pages: Vec<CopyPage>,
bytes: std::sync::Arc<Vec<u8>>,
password: Option<std::sync::Arc<str>>,
}
impl DocumentCopyJob {
fn of(v: &DocView, doc: &pdfcraft_engine::Document) -> DocumentCopyJob {
let missing = v.pages_without_text();
let pages = (0..v.page_count())
.map(|p| match v.texts.get(&p) {
Some(t) => CopyPage::Read(t.clone()),
None if missing.contains(&p) => CopyPage::ToRead,
None => CopyPage::Skip,
})
.collect();
DocumentCopyJob { pages, bytes: doc.bytes.clone(), password: doc.password.as_deref().map(std::sync::Arc::from) }
}
/// The document's text in page order, a page per line run. Each page read here is let go
/// once its text is taken, so a large document never holds every page's text layer at once.
/// `None` when `cancel` was set.
fn text(self, cancel: &std::sync::atomic::AtomicBool) -> Option<String> {
let mut renderer: Option<pdfcraft_render::PageRenderer> = None;
let mut out = String::new();
let mut push = |text: String| {
if !text.is_empty() {
if !out.is_empty() {
out.push('\n');
}
out.push_str(&text);
}
};
for (page, p) in self.pages.into_iter().enumerate() {
if cancel.load(std::sync::atomic::Ordering::Relaxed) {
return None;
}
match p {
CopyPage::Read(t) => push(t.text_of(0..t.glyphs.len())),
CopyPage::ToRead => {
let r = renderer.get_or_insert_with(|| {
let config = pdfcraft_render::RenderConfig { password: self.password.clone(), ..Default::default() };
pdfcraft_render::PageRenderer::new(self.bytes.clone(), config)
});
let req = pdfcraft_render::RenderRequest {
page,
kind: pdfcraft_render::RequestKind::Text,
tile: None,
scale: 1.0,
tag: 0,
preview: false,
};
if let Some(t) = r.render(req).text {
push(t.text_of(0..t.glyphs.len()));
}
}
CopyPage::Skip => {}
}
}
Some(out)
}
}
/// A whole-document copy reading its pages off the UI thread (in place on the web, which has no
/// threads). Dropping it (a new copy, the document closed) stops the reading.
struct DocumentCopy {
doc: DocId,
rx: std::sync::mpsc::Receiver<String>,
cancel: std::sync::Arc<std::sync::atomic::AtomicBool>,
}
impl DocumentCopy {
fn start(doc: DocId, job: DocumentCopyJob, ctx: &egui::Context) -> DocumentCopy {
let (tx, rx) = std::sync::mpsc::channel();
let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
#[cfg(not(target_arch = "wasm32"))]
{
let (stop, ctx) = (cancel.clone(), ctx.clone());
let spawned = std::thread::Builder::new().name("copy-document-text".into()).spawn(move || {
if let Some(text) = job.text(&stop) {
let _ = tx.send(text);
ctx.request_repaint();
}
});
if let Err(e) = spawned {
log::warn!("copying the document's text: {e}");
}
}
#[cfg(target_arch = "wasm32")]
{
let _ = ctx;
if let Some(text) = job.text(&cancel) {
let _ = tx.send(text);
}
}
DocumentCopy { doc, rx, cancel }
}
}
impl Drop for DocumentCopy {
fn drop(&mut self) {
self.cancel.store(true, std::sync::atomic::Ordering::Relaxed);
}
}
/// Files delivered asynchronously: (name, bytes).
pub type Inbox = std::sync::Arc<std::sync::Mutex<Vec<(String, Vec<u8>)>>>;
@@ -602,6 +717,8 @@ pub struct PdfCraftApp {
/// Whether the window currently has the app's header bar (`None` until the first frame).
app_frame_shown: Option<bool>,
pub password_prompt: Option<PasswordPrompt>,
/// Copy of a whole document's text (#2639) whose pages are being read off the UI thread.
document_copy: Option<DocumentCopy>,
pub unlock_prompt: Option<UnlockPrompt>,
pub full_screen: bool,
/// Files delivered asynchronously (web drag-and-drop, web file picker).
@@ -957,6 +1074,7 @@ impl PdfCraftApp {
system_title_bar: false,
app_frame_shown: None,
password_prompt: None,
document_copy: None,
unlock_prompt: None,
full_screen: false,
inbox: Default::default(),
@@ -2852,6 +2970,7 @@ impl PdfCraftApp {
object_ui::nudge_keys(ctx, &mut self.views[i], doc);
}
canvas::shortcuts(&mut self.views[i], ctx, self.scroll_speed);
self.finish_document_copy(ctx);
self.copy_key(i, ctx);
self.image_pick_keys(i, ctx);
}
@@ -2886,35 +3005,22 @@ impl PdfCraftApp {
self.copy_selected_text(i, ctx);
}
/// The text Copy takes from view `v`: its selection, or after Select All of the document every
/// page's text, the pages not read for display yet read here (#2639).
fn selection_text(&self, v: &DocView) -> Option<String> {
if !v.all_text {
return v.selected_text();
}
let missing = v.pages_without_text();
let mut more = std::collections::HashMap::new();
if !missing.is_empty()
&& let Some(doc) = self.session.get(v.id)
{
let config = pdfcraft_render::RenderConfig { password: doc.password.as_deref().map(std::sync::Arc::from), ..Default::default() };
let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), config);
for page in missing {
let req =
pdfcraft_render::RenderRequest { page, kind: pdfcraft_render::RequestKind::Text, tile: None, scale: 1.0, tag: 0, preview: false };
if let Some(t) = r.render(req).text {
more.insert(page, t);
}
}
}
Some(v.whole_text(&more))
}
/// Shared permission gate for copying selected page text from menus and keyboard events.
/// After Select All of the document (#2639), pages whose text wasn't read for display are read
/// off the UI thread ([`DocumentCopy`]), and the clipboard gets the text when they're done.
pub(crate) fn copy_selected_text(&mut self, i: usize, ctx: &egui::Context) -> bool {
let Some((id, text)) = self.views.get(i).and_then(|v| Some((v.id, self.selection_text(v)?))).filter(|(_, text)| !text.is_empty()) else {
return false;
};
let Some(v) = self.views.get(i) else { return false };
let id = v.id;
if v.all_text && !v.pages_without_text().is_empty() {
// Checked before any page is read: a document that refuses copying reads nothing.
if !self.may_copy(id) {
return false;
}
let Some(job) = self.views.get(i).and_then(|v| Some(DocumentCopyJob::of(v, self.session.get(id)?))) else { return false };
self.document_copy = Some(DocumentCopy::start(id, job, ctx));
return true;
}
let Some(text) = v.selected_text().filter(|text| !text.is_empty()) else { return false };
if !self.may_copy(id) {
return false;
}
@@ -2922,6 +3028,26 @@ impl PdfCraftApp {
true
}
/// The clipboard text of a whole-document copy, once its pages are read; dropped with the
/// document it was copied from.
fn finish_document_copy(&mut self, ctx: &egui::Context) {
let Some(c) = &self.document_copy else { return };
if !self.views.iter().any(|v| v.id == c.doc) {
self.document_copy = None;
return;
}
match c.rx.try_recv() {
Ok(text) => {
self.document_copy = None;
if !text.is_empty() {
ctx.copy_text(text);
}
}
Err(std::sync::mpsc::TryRecvError::Empty) => ctx.request_repaint_after(std::time::Duration::from_millis(50)),
Err(std::sync::mpsc::TryRecvError::Disconnected) => self.document_copy = None,
}
}
/// The quick tools' keys, as in Acrobat: V selects, H pans, and holding Space pans until it
/// is released. Plain letters, so not while a text field, a form field, a dialog or the
/// palette has the keyboard.