From daa76197dc85898565adbb1084eaa3665cede3ee Mon Sep 17 00:00:00 2001 From: SBaksa Date: Tue, 20 Oct 2026 16:44:29 +1100 Subject: [PATCH] Select All takes the whole document's text while scrolling continuously (#2639) Scrolling continuously, Edit > Select All (Cmd+A) selects the text of every page of the document; one page at a time it still selects the page shown, as does a document of one page. Shown pages highlight as their text arrives, and Copy gets all of it in page order, reading the text of pages not read for display (text far away is let go to bound memory, so it isn't kept for this). A press on a page or Escape ends it. Select All still returns whether text is selected. The Select All parity entry covers both modes; the viewer and copy security tests run one page at a time, which is what they check. --- crates/ui-egui/src/canvas.rs | 141 +++++++++++++++++++++++++++++++- crates/ui-egui/src/commands.rs | 5 +- crates/ui-egui/src/lib.rs | 26 +++++- crates/ui-egui/tests/editing.rs | 5 ++ crates/ui-egui/tests/viewer.rs | 4 +- parity/acrobat-features.toml | 6 +- 6 files changed, 179 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index eb9244a28..b7abd2bb7 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -446,6 +446,9 @@ pub struct DocView { pub(crate) text_failed: HashSet, pub find: Option, selection: Option, + /// Select All while scrolling continuously (#2639): the text of every page of the document, + /// highlighted on each page as its text arrives and copied whole ([`DocView::whole_text`]). + pub(crate) all_text: bool, /// The selection whose action bar was closed by its Copy button: the bar stays hidden /// until the selection changes, as it does once a markup button consumes the selection. actions_dismissed: Option, @@ -741,6 +744,7 @@ impl DocView { text_failed: HashSet::new(), find: None, selection: None, + all_text: false, actions_dismissed: None, selection_drag: false, clicks: 0, @@ -1171,7 +1175,12 @@ impl DocView { } /// Selected text, if any (⌘C). + /// The selected text: of the pages read so far, after Select All of the document (Copy reads + /// the others, [`DocView::pages_without_text`]). pub fn selected_text(&self) -> Option { + if self.all_text { + return Some(self.whole_text(&HashMap::new())).filter(|x| !x.is_empty()); + } let s = self.selection?; let t = self.texts.get(&s.page)?; Some(s.text(t)).filter(|x| !x.is_empty()) @@ -1349,6 +1358,15 @@ impl DocView { self.select_anchor = (self.page_count > 0).then_some(self.current); return !self.selected.is_empty(); } + // Scrolling continuously, Select All takes the whole document's text (#2639); one page at + // a time, the page shown. + // Like the page's, true once some text is selected: the pages whose text isn't read yet are + // read when the selection is copied. + if self.continuous && self.page_count > 1 { + self.selection = None; + self.all_text = true; + return self.selected_text().is_some(); + } let page = self.current; let Some(t) = self.texts.get(&page) else { return false }; if t.glyphs.is_empty() { @@ -1358,6 +1376,29 @@ impl DocView { true } + /// Pages whose text [`DocView::whole_text`] still needs: not read yet, nor failed. + pub(crate) fn pages_without_text(&self) -> Vec { + (0..self.page_count).filter(|p| !self.texts.contains_key(p) && !self.text_failed.contains(p)).collect() + } + + /// The whole document's text in page order, one page after another on new lines, from the + /// pages' text read so far and `more` (pages read for this, keyed by page). + pub(crate) fn whole_text(&self, more: &HashMap>) -> String { + let mut out = String::new(); + for p in 0..self.page_count { + let Some(t) = self.texts.get(&p).or_else(|| more.get(&p)) else { continue }; + let text = t.text_of(0..t.glyphs.len()); + if text.is_empty() { + continue; + } + if !out.is_empty() { + out.push('\n'); + } + out.push_str(&text); + } + out + } + /// Rotate the view 90° clockwise or counter-clockwise (View ▸ Rotate View, ⇧⌘+ / ⇧⌘−). pub fn rotate_view(&mut self, clockwise: bool) { self.rotation = (self.rotation + if clockwise { 90 } else { 270 }) % 360; @@ -2288,8 +2329,9 @@ pub fn shortcuts(view: &mut DocView, ctx: &egui::Context, speed: crate::scroll_s if key(Key::Escape) { if view.camera.is_some() || view.camera_done.is_some() { view.pending_action = Some(ViewAction::CancelCamera); - } else if view.selection.is_some() { + } else if view.selection.is_some() || view.all_text { view.selection = None; + view.all_text = false; } else { view.find = None; } @@ -3278,6 +3320,11 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { } } } + if view.all_text { + for lr in text.line_rects(0..text.glyphs.len()) { + painter.rect_filled(to_screen(lr), CornerRadius::same(1), Color32::from_rgba_unmultiplied(0x3A, 0x7B, 0xF0, 70)); + } + } if let Some(sel) = view.selection.filter(|s| s.page == i) { for lr in sel.rects(&text) { painter.rect_filled(to_screen(lr), CornerRadius::same(1), Color32::from_rgba_unmultiplied(0x3A, 0x7B, 0xF0, 70)); @@ -3326,6 +3373,8 @@ 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)); @@ -3859,7 +3908,9 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { } }); } - // Text layers: visible pages for selection, every page while a search is active. + // Text layers: visible pages for selection, every page while a search is active. (Select All + // of the document needs no more: shown pages highlight as their text comes, and Copy reads + // the rest, as text far away is let go to bound memory.) let need_text = |p: &usize| !view.texts.contains_key(p) && !view.text_failed.contains(p); let mut text_pages: Vec = if hand { Vec::new() } else { visible_now.iter().copied().filter(need_text).collect() }; if view.find.as_ref().is_some_and(|f| !f.case_query.trim().is_empty()) { @@ -5657,6 +5708,92 @@ trailer << /Root 1 0 R >> assert!(v.text_failed.contains(&0) && v.text_failed.contains(&99), "failures are bytes-free: never evict"); } + /// A PDF of one line of text on each page, `lines[i]` on page `i`. + fn text_pages_pdf(lines: &[&str]) -> Vec { + let n = lines.len(); + // 1 catalog, 2 pages, 3 font, then a page and its content stream for each line. + let mut objects = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + format!("<< /Type /Pages /Count {n} /Kids [{}] >>", (0..n).map(|i| format!("{} 0 R", 4 + 2 * i)).collect::>().join(" ")), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_string(), + ]; + for (i, line) in lines.iter().enumerate() { + let content = format!("BT /F1 18 Tf 72 700 Td ({line}) Tj ET"); + objects.push(format!( + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 3 0 R >> >> /Contents {} 0 R >>", + 5 + 2 * i + )); + objects.push(format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len())); + } + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objects.iter().enumerate() { + offsets.push(out.len()); + out.extend(format!("{} 0 obj\n{o}\nendobj\n", i + 1).bytes()); + } + let xref = out.len(); + out.extend(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).bytes()); + for o in offsets { + out.extend(format!("{o:010} 00000 n \n").bytes()); + } + out.extend(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).bytes()); + out + } + + /// #2639: scrolling continuously, Select All takes every page's text, and Copy gets all of it + /// in page order, reading the pages whose text wasn't read for display. + #[test] + fn select_all_copies_the_whole_document_while_scrolling_continuously() { + let mut app = PdfCraftApp::new(); + app.open_bytes("three.pdf", None, text_pages_pdf(&["Alpha one", "Bravo two", "Charlie three"])).unwrap(); + let v = &mut app.views[0]; + assert!(v.continuous, "continuous scrolling is the default"); + assert!(v.texts.is_empty(), "no page's text read yet"); + // Nothing shown is selected yet (false, as for a page whose text isn't read), but the whole + // document is: Copy reads every page. + 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"); + 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:?}"); + } + + /// Select All keeps to the page shown one page at a time, and in a document of one page; of + /// the document, it reads the text already read and doesn't ask again for failed pages. + #[test] + fn select_all_of_the_document_only_while_scrolling_through_several_pages() { + let info = thumbnail_info(3); + let one_line = |s: &str| PageText { + glyphs: s + .chars() + .map(|c| pdfcraft_render::TextGlyph { text: c.into(), rect: [0.0, 0.0, 10.0, 10.0], quad: [[0.0, 0.0]; 4], direction: [1.0, 0.0] }) + .collect(), + line_of: s.chars().map(|_| 0).collect(), + space_before: s.chars().map(|_| false).collect(), + }; + let mut v = DocView::new(DocId(1), &info, ViewDefaults::default()); + for p in 0..3 { + v.texts.insert(p, Arc::new(one_line("abc"))); + } + v.continuous = false; + v.current = 1; + assert!(v.select_all()); + assert!(!v.all_text && v.selection.is_some_and(|s| s.page == 1), "one page at a time: the page shown"); + let single = thumbnail_info(1); + let mut w = DocView::new(DocId(2), &single, ViewDefaults::default()); + w.texts.insert(0, Arc::new(one_line("abc"))); + assert!(w.select_all()); + assert!(!w.all_text && w.selection.is_some(), "a one-page document: that page"); + // Text read for display is used; pages that failed aren't asked for again. + let mut d = DocView::new(DocId(3), &info, ViewDefaults::default()); + d.texts.insert(1, Arc::new(one_line("abc"))); + d.text_failed.insert(2); + assert!(d.select_all() && d.all_text); + assert_eq!(d.pages_without_text(), vec![0]); + } + #[test] fn text_layers_keep_the_selection_page() { let info = thumbnail_info(100); diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index b769c0c00..52766620b 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -86,7 +86,10 @@ impl PdfCraftApp { } commands::is_enabled(spec, &self.session, self.active_ids().map(|(_, id)| id)) && (spec.id != "edit.copy" - || self.active.and_then(|i| self.views.get(i)).is_some_and(|v| !v.organize && v.selected_text().is_some_and(|t| !t.is_empty()))) + || self + .active + .and_then(|i| self.views.get(i)) + .is_some_and(|v| !v.organize && (v.all_text || v.selected_text().is_some_and(|t| !t.is_empty())))) && (spec.needs != commands::Needs::TwoPageView || self.active.and_then(|i| self.views.get(i)).is_some_and(crate::DocView::cover_applies)) && match spec.id { "view.previous_view" => self.active.and_then(|i| self.views.get(i)).is_some_and(|v| !v.back.is_empty()), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 9db924759..2923788de 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -2886,9 +2886,33 @@ 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 { + 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. 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, v.selected_text()?))).filter(|(_, text)| !text.is_empty()) else { + 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; }; if !self.may_copy(id) { diff --git a/crates/ui-egui/tests/editing.rs b/crates/ui-egui/tests/editing.rs index d41041e52..23451ee2a 100644 --- a/crates/ui-egui/tests/editing.rs +++ b/crates/ui-egui/tests/editing.rs @@ -2209,6 +2209,11 @@ fn copying_out_of_a_document_honours_its_security() { app.system_clipboard = false; app.open_bytes("no-copy.pdf", None, protected("pw", "owner", -1 ^ 0b1_0000)).unwrap(); app.submit_password(Some(password.into())); + // One page at a time: Select All takes that page's text (scrolling continuously it would + // take both pages', #2639; what is tested here is the security of copying it). + if let Some(v) = app.views.first_mut() { + v.set_continuous(false); + } app }); // Select the page's text once its text layer is loaded (up to ~4 s: a loaded test machine diff --git a/crates/ui-egui/tests/viewer.rs b/crates/ui-egui/tests/viewer.rs index b15469485..224e2ff8a 100644 --- a/crates/ui-egui/tests/viewer.rs +++ b/crates/ui-egui/tests/viewer.rs @@ -91,7 +91,9 @@ fn view_history_select_all_and_find_options() { assert_eq!(h.state().views[0].current, 0); assert!(h.state_mut().views[0].view_history(true)); assert_eq!(h.state().views[0].current, 3, "next view"); - // Select all on the current page (once its text is known). + // Select all on the current page (once its text is known), one page at a time: scrolling + // continuously it takes the whole document's text (#2639). + h.state_mut().views[0].set_continuous(false); // Background rendering and text extraction can take seconds on a loaded CI runner (FreeBSD VM). for _ in 0..3000 { h.run_steps(2); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 7aca7d5c8..9ddac693c 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -1543,15 +1543,15 @@ notes = "Keyboard, Edit-menu, page-context-menu and floating-selection Copy hono [[feature]] id = "view.select-all" -title = "Select all text on a page" +title = "Select all text" area = "B" tier = "P1" milestone = "M3" status = "shipped" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/viewer.rs::view_history_select_all_and_find_options"] -notes = "⌘A on the current page" +evidence = ["crates/ui-egui/tests/viewer.rs::view_history_select_all_and_find_options", "crates/ui-egui/src/canvas.rs::select_all_copies_the_whole_document_while_scrolling_continuously", "crates/ui-egui/src/canvas.rs::select_all_of_the_document_only_while_scrolling_through_several_pages"] +notes = "⌘A: the current page one page at a time; the whole document scrolling continuously (#2639), copied whole" [[feature]] id = "view.copy-with-layout"