collective

Browse / github.com/storytold/pdfcraft / patch

M3: select text across pages (view.text-select)

M3: select text across pages (view.text-select) ## What this changes Text selection can now run across pages. Until now a selection could only belong to the page it started on: the drag extended it only while the pointer was over that page, and `Selection` held a single page (the #721 test even pinned "the selection stays on its own page (a selection can't span pages yet)"). A reading-order selection now goes from its anchor glyph to a head glyph on any page (`Span::Run` gains `head_page`). It covers the rest of the first page, the pages in between whole, and the last page up to the pointer. - **Drag** onto another page extends the selec

Status
open
Score
0.00
Adopters
0 other operators
Author
TheSuperStew (imported from pull request #5017, open)
Base
1dd2737b47a9c1c9562f0af563053d1bf510e4bf
Size
+829 −143 in 14 files
Review
pending
Shared
2026-10-28
Id
8b4d86e6c6e91dcb252deea7

How it went

No other operator has reported using it yet.

Diff

raw · ROADMAP.md crates/ui-egui/src/app/clipboard.rs crates/ui-egui/src/canvas.rs crates/ui-egui/src/canvas/cache.rs crates/ui-egui/src/canvas/navigation.rs crates/ui-egui/src/canvas/selection.rs crates/ui-egui/src/canvas/tests.rs crates/ui-egui/src/commands.rs crates/ui-egui/src/editing.rs crates/ui-egui/src/state.rs crates/ui-egui/src/tools/comments/input.rs crates/ui-egui/src/tools/redact_ui.rs crates/ui-egui/tests/it/text_select.rs parity/acrobat-features.toml

diff --git a/ROADMAP.md b/ROADMAP.md--- a/ROADMAP.md+++ b/ROADMAP.md@@ -199,6 +199,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md  ## Progress log +- **2026-10-26 (M3, view.text-select):** Text selection runs across pages: dragging onto another page (also a quick flick off the page, and the edge and wheel scrolling of #721/#1670) or ⇧-clicking there selects the rest of the first page, the pages between and the last page up to the pointer. Copy has each page's part on a line of its own, reading pages not yet read off the UI thread; highlight, underline, strikethrough and Redact put one mark on each page in one undo step (a tool that finishes first marks once the pages are read; Redact refuses a selection over a page whose text could not be read); Add comment and Replace text stay one-page. A drag from one page's text that is already over the next when it starts no longer begins a Redact or area-highlight box there. Estimates unchanged. - **2026-10-24 (M10, #4158 partial):** Word, HTML, RTF and PowerPoint export keep a block that continues another on its line (an italic case name, a link, a superscript) as a run in its own style inside that paragraph, with the source's word space, instead of a paragraph of its own. Style changes that wrap onto the next line still split paragraphs (the reading blocks keep style boundaries). Estimates unchanged. - **2026-10-24 (M3, window move on Wayland, #3970 follow-up):** After a move or resize by PdfCraft's own title bar or edges, the button release given back to egui goes before the pointer leaving the window, so hover no longer sticks at the old place until the pointer moves. Found by CodeRabbit in review of #3970; a unit test covers it. Estimates unchanged. - **2026-10-24 (M14, #4253):** The annotation quick bar remembers Keep tool selected across restarts, including unpinning. Old and malformed settings keep safe defaults; the opt-in UI control channel reads and changes the same preference. Estimates unchanged.diff --git a/crates/ui-egui/src/app/clipboard.rs b/crates/ui-egui/src/app/clipboard.rs--- a/crates/ui-egui/src/app/clipboard.rs+++ b/crates/ui-egui/src/app/clipboard.rs@@ -40,12 +40,19 @@ impl PdfCraftApp {         self.document_copy = None;         let Some(v) = self.views.get(i) else { return false };         let id = v.id;-        if v.all_text && !v.pages_without_text().is_empty() {+        // So are a selection's pages not read yet (a selection across pages copied before every+        // page it covers has its text), rather than copying nothing.+        let waiting = v.selection_waits_for_text();+        if (v.all_text && !v.pages_without_text().is_empty()) || waiting.is_some() {             // 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 };+            let job = |v: &crate::DocView, doc: &pdfcraft_engine::Document| match waiting {+                Some(span) if !v.all_text => DocumentCopyJob::of_selection(v, doc, span),+                _ => DocumentCopyJob::of(v, doc),+            };+            let Some(job) = self.views.get(i).and_then(|v| Some(job(v, self.session.get(id)?))) else { return false };             self.document_copy = Some(DocumentCopy::start(id, job, ctx));             return true;         }diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs--- a/crates/ui-egui/src/canvas.rs+++ b/crates/ui-egui/src/canvas.rs@@ -5,7 +5,7 @@ //! stale rasters are shown stretched until the sharp one arrives (tiling comes in M3.3).  use std::collections::{BTreeSet, HashMap, HashSet};-use std::ops::Range;+use std::ops::{Range, RangeInclusive}; use std::sync::Arc;  use egui::{Align2, Color32, CornerRadius, Pos2, Rect, Sense, Stroke, TextureHandle, TextureOptions, Vec2, pos2, vec2};@@ -61,7 +61,8 @@ pub(crate) use organize::grid_page_rect; use organize::organize_grid; #[cfg(test)] use organize::sharp_enough;-use selection::{Selection, Span, click_unit, selection_actions};+use selection::{Selection, Span, click_unit, selection_actions, start_selection_drag};+pub(crate) use selection::{TextSpan, ToolMarks, one_undo_step}; pub use xform::PageXform; use xform::snap_to_pixels; use zoom::anchored_scroll;@@ -446,9 +447,16 @@ pub struct DocView {     pub content: crate::content_ui::ContentView,     /// Edit a PDF ▸ Link tool state.     pub links: crate::link_ui::LinkView,-    /// Redact tool: the box being drawn, and a mark to add (page, quads) for the app to style.+    /// Redact tool: the box being drawn, and the marks to add (page, quads) for the app to style,+    /// one per page (a text selection can cover several), added in one undo step.     pub redact_drag: crate::redact_ui::AreaDrag,-    pub pending_redaction: Option<(usize, Vec<[f64; 8]>)>,+    pub pending_redaction: Vec<(usize, Vec<[f64; 8]>)>,+    /// A text selection Redact did not mark: it covers a page whose text could not be read+    /// ([`ToolMarks::Unreadable`]). The app says so.+    pub(crate) redaction_refused: bool,+    /// A marking tool finished this text selection while a page it covers was still to be read+    /// (or none was drawn): it is marked once they are ([`DocView::tool_marks`]).+    marks_waiting: Option<(QuickTool, Selection)>,     /// A crop rectangle being dragged (Crop tool).     pub crop_drag: crate::crop::CropDrag,     /// Enhance ▸ Camera image: the quadrilateral being placed.@@ -710,7 +718,9 @@ impl DocView {             content: Default::default(),             links: Default::default(),             redact_drag: None,-            pending_redaction: None,+            pending_redaction: Vec::new(),+            redaction_refused: false,+            marks_waiting: None,             crop_drag: None,             camera: None,             camera_done: None,@@ -1791,8 +1801,8 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {                         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) {+                if let Some(sel) = view.selection.filter(|s| s.pages().contains(&i)) {+                    for lr in sel.rects_on(i, &text) {                         painter.rect_filled(to_screen(lr), CornerRadius::same(1), Color32::from_rgba_unmultiplied(0x3A, 0x7B, 0xF0, 70));                     }                     // The rectangle being drawn, as Acrobat shows it while column-selecting.@@ -1847,39 +1857,41 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {                         }                     }                     if resp.drag_started() && press_here && !over_link {-                        // A drag replaces the selection without passing through none, so a-                        // re-drag over text whose bar Copy closed would keep the bar hidden.-                        view.actions_dismissed = None;-                        view.selection_drag = true;-                        let origin = ptr.origin.unwrap_or(p);-                        let (ox, oy) = xf.screen_to_view(origin);-                        if view.clicks < 2 {-                            view.selection = if column {-                                Some(Selection { page: i, span: Span::Column { rect: [ox, oy, vx, vy] } })-                            } else {-                                text.nearest(ox, oy).map(|a| Selection::run(i, a, a))-                            };-                        }+                        let origin = xf.screen_to_view(ptr.origin.unwrap_or(p));+                        start_selection_drag(view, i, &text, column, origin, (vx, vy));                     }                     let clicks = if view.clicks > 2 && tool != QuickTool::Select { 1 } else { view.clicks };+                    // The drag extends the selection on its own page and, for a reading-order+                    // selection the drag started, on any page the pointer moves on to.                     if resp.dragged()-                        && let Some(sel) = view.selection.as_mut().filter(|s| s.page == i)+                        && let Some(mut sel) = view.selection.filter(|s| s.page == i || (view.selection_drag && matches!(s.span, Span::Run { .. })))                     {+                        let start_page = sel.page;+                        let anchor_text = if start_page == i { Some(Arc::clone(&text)) } else { view.texts.get(&start_page).cloned() };                         match &mut sel.span {                             // After a multi-click the drag extends by its unit (#1685), as in                             // Acrobat and browsers: whole words or lines, always keeping the one                             // clicked, which holds the anchor whichever way the drag went.-                            Span::Run { anchor, head } if clicks >= 2 => {+                            Span::Run { anchor, head, head_page } if clicks >= 2 => {                                 if let Some(h) = text.nearest(vx, vy)-                                    && let Some((first, last)) = click_unit(&text, clicks, *anchor)+                                    && let Some(anchor_text) = &anchor_text+                                    && let Some((first, last)) = click_unit(anchor_text, clicks, *anchor)                                 {                                     let (to_first, to_last) = click_unit(&text, clicks, h).unwrap_or((h, h));-                                    (*anchor, *head) = if to_first < first { (last, to_first) } else { (first, to_last.max(last)) };+                                    // (page, glyph) pairs compare in reading order.+                                    (*anchor, *head) = if (i, to_first) < (start_page, first) {+                                        (last, to_first)+                                    } else if i == start_page {+                                        (first, to_last.max(last))+                                    } else {+                                        (first, to_last)+                                    };+                                    *head_page = i;                                 }                             }-                            Span::Run { head, .. } => {+                            Span::Run { head, head_page, .. } => {                                 if let Some(h) = text.nearest(vx, vy) {-                                    *head = h;+                                    (*head, *head_page) = (h, i);                                 }                             }                             Span::Column { rect } => {@@ -1887,10 +1899,11 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {                                 rect[3] = vy;                             }                         }+                        view.selection = Some(sel);                     }                     // A rectangle around no text selects nothing.                     if resp.drag_stopped()-                        && view.selection.is_some_and(|s| s.page == i && matches!(s.span, Span::Column { .. }) && s.text(&text).is_empty())+                        && view.selection.is_some_and(|s| s.page == i && matches!(s.span, Span::Column { .. }) && s.text_on(i, &text).is_empty())                     {                         view.selection = None;                     }@@ -1918,16 +1931,28 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {                             }                         }                     } else if resp.clicked() && view.clicks < 2 && !over_link {-                        // ⇧-click extends a selection on this page to the click, keeping its-                        // anchor (#527). Otherwise (no shift, no selection on this page, or a-                        // column selection, which has no anchor glyph; a selection cannot yet-                        // span pages) a click clears the selection.+                        // ⇧-click extends a reading-order selection to the click, on its page or+                        // another, keeping its anchor (#527). Otherwise (no shift, no selection, or+                        // a column selection, which has no anchor glyph) a click clears the+                        // selection.                         let shift = ui.input(|inp| inp.modifiers.shift);-                        match (view.selection.as_mut().filter(|s| shift && s.page == i).map(|s| &mut s.span), text.nearest(vx, vy)) {-                            (Some(Span::Run { head, .. }), Some(h)) => *head = h,+                        match (view.selection.as_mut().filter(|s| shift && matches!(s.span, Span::Run { .. })), text.nearest(vx, vy)) {+                            (Some(sel), Some(h)) => sel.extend_to(i, h),                             _ => view.selection = None,                         }                     }+                } else if selects_text+                    && !consumed+                    && resp.drag_started()+                    && let Some(origin) = ptr.origin.filter(|o| r.contains(*o))+                    && let Some(p) = pointer+                    && !info.links.iter().any(|l| l.page == i && xf.user_rect(info, i, l.rect).contains(origin))+                {+                    // A quick drag can leave the page it was pressed on before egui calls it a+                    // drag (a flick off the bottom of a page, onto the gap or the next page): it+                    // still starts the selection there, for the next page to extend.+                    let column = tool == QuickTool::ColumnSelect || ui.input(|inp| inp.modifiers.alt);+                    start_selection_drag(view, i, &text, column, xf.screen_to_view(origin), xf.screen_to_view(p));                 }             } @@ -2117,7 +2142,8 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {             }         }         // Held at the top or bottom edge, or dragged past it, a text selection scrolls the view-        // so it can reach text off screen (#1670); the selection follows the pointer on its page.+        // so it can reach text off screen (#1670); the selection follows the pointer, onto the+        // pages it scrolls to.         if view.selection_drag             && navigation_enabled             && unobstructed@@ -2216,7 +2242,8 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {                 return;             }             if view.comments.selected.is_none() {-                let selected = view.selected_text().is_some_and(|t| !t.is_empty());+                // A selection whose pages are still being read copies once they are.+                let selected = view.selection_waits_for_text().is_some() || view.selected_text().is_some_and(|t| !t.is_empty());                 let key = crate::commands::shortcut_label(ui.ctx(), crate::commands::COPY);                 if ui.add_enabled(can_copy && selected, egui::Button::new(tl!("Copy")).shortcut_text(key)).clicked() {                     copy_selected = true;@@ -2404,6 +2431,11 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {     // 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<usize> = if hand { Vec::new() } else { visible_now.iter().copied().filter(need_text).collect() };+    // A selection across pages copies and marks every page it covers, scrolled past or not.+    if let Some(s) = view.selection {+        let rest: Vec<usize> = s.pages().filter(|p| *p < info.pages.len()).filter(need_text).filter(|p| !text_pages.contains(p)).collect();+        text_pages.extend(rest);+    }     if view.find.as_ref().is_some_and(|f| !f.case_query.trim().is_empty()) {         let n = info.pages.len();         let rest: Vec<usize> = (0..n).map(|k| (cur + k) % n).filter(need_text).filter(|p| !text_pages.contains(p)).collect();@@ -2579,21 +2611,31 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {     if let Some((page, i)) = app.views[index].links.open_existing.take() {         app.open_link_props(page, None, Some(i));     }-    if let Some((page, quads)) = app.views[index].pending_redaction.take() {+    if std::mem::take(&mut app.views[index].redaction_refused) {+        app.notify_tr("Part of the selected text is on a page whose text could not be read, so nothing was marked for redaction. Mark that page with a box instead.");+    }+    let marks = std::mem::take(&mut app.views[index].pending_redaction);+    if !marks.is_empty() {         let author = app.comment_prefs.author.clone();-        app.views[index].pending_edit = Some(if app.quick_tool == QuickTool::Comment(comments::CommentTool::Highlight) {-            // An area highlight: a highlight over the box.-            let style = app.comment_prefs.style(comments::CommentTool::Highlight);-            pdfcraft_engine::Edit::AddAnnotation(pdfcraft_engine::NewAnnotation {-                page,-                shape: pdfcraft_engine::Shape::TextMarkup { kind: pdfcraft_engine::Markup::Highlight, quads },-                style,-                contents: String::new(),-                author,+        let edits: Vec<pdfcraft_engine::Edit> = marks+            .into_iter()+            .map(|(page, quads)| {+                if app.quick_tool == QuickTool::Comment(comments::CommentTool::Highlight) {+                    // An area highlight: a highlight over the box.+                    let style = app.comment_prefs.style(comments::CommentTool::Highlight);+                    pdfcraft_engine::Edit::AddAnnotation(pdfcraft_engine::NewAnnotation {+                        page,+                        shape: pdfcraft_engine::Shape::TextMarkup { kind: pdfcraft_engine::Markup::Highlight, quads },+                        style,+                        contents: String::new(),+                        author: author.clone(),+                    })+                } else {+                    app.redact_prefs.mark(page, quads, &author)+                }             })-        } else {-            app.redact_prefs.mark(page, quads, &author)-        });+            .collect();+        app.views[index].pending_edit = one_undo_step(edits);     }     match image_action {         Some(crate::edit_text_ui::ImageAction::Replace(page, index)) => app.replace_page_image_dialog(page, index),diff --git a/crates/ui-egui/src/canvas/cache.rs b/crates/ui-egui/src/canvas/cache.rs--- a/crates/ui-egui/src/canvas/cache.rs+++ b/crates/ui-egui/src/canvas/cache.rs@@ -199,7 +199,7 @@ impl DocView {         self.text_failed.remove(&page);         self.errors.remove(&page);         self.last_queue.clear();-        if self.selection.is_some_and(|s| s.page == page) {+        if self.selection.is_some_and(|s| s.pages().contains(&page)) {             self.selection = None;         }     }@@ -451,7 +451,7 @@ impl DocView {         }         let mut keep: HashSet<usize> = self.frame_visible.clone();         if let Some(s) = self.selection {-            keep.insert(s.page);+            keep.extend(s.pages());         }         if let Some(f) = self.find.as_ref() {             keep.extend(f.matches.iter().map(|(p, _)| *p));diff --git a/crates/ui-egui/src/canvas/navigation.rs b/crates/ui-egui/src/canvas/navigation.rs--- a/crates/ui-egui/src/canvas/navigation.rs+++ b/crates/ui-egui/src/canvas/navigation.rs@@ -9,15 +9,19 @@ impl DocView {     /// zoom are kept). `None` before the first frame has laid pages out, when the bookmark just     /// goes to the current page.     pub(crate) fn bookmark_position(&self, info: &DocInfo) -> Option<(usize, [Option<f32>; 2])> {+        // Across pages, the corner is on the selection's first page.+        let first = self.selection.map(|s| *s.pages().start());         if let Some(sel) = self.selection-            && let Some(text) = self.texts.get(&sel.page)-            && let Some(p) = info.pages.get(sel.page)+            && let Some(first) = first+            && let Some(text) = self.texts.get(&first)+            && let Some(p) = info.pages.get(first)         {             // The selection's bounds in view space, which View ▸ Rotate View leaves unturned: the             // corner at the screen's top-left is a different one for each quarter turn.-            let [x0, y0, x1, y1] = sel.rects(text).iter().fold([f32::INFINITY, f32::INFINITY, f32::NEG_INFINITY, f32::NEG_INFINITY], |b, r| {-                [b[0].min(r[0]), b[1].min(r[1]), b[2].max(r[2]), b[3].max(r[3])]-            });+            let [x0, y0, x1, y1] =+                sel.rects_on(first, text).iter().fold([f32::INFINITY, f32::INFINITY, f32::NEG_INFINITY, f32::NEG_INFINITY], |b, r| {+                    [b[0].min(r[0]), b[1].min(r[1]), b[2].max(r[2]), b[3].max(r[3])]+                });             let (x, y) = match self.rotation {                 90 => (x0, y1),                 180 => (x1, y1),@@ -25,7 +29,7 @@ impl DocView {                 _ => (x0, y0),             };             if x.is_finite() && y.is_finite() {-                return Some((sel.page, p.xyz_for_view(Some(x), Some(y))));+                return Some((first, p.xyz_for_view(Some(x), Some(y))));             }         }         // The page showing at the window's top edge: one that crosses it (the first, in a spreaddiff --git a/crates/ui-egui/src/canvas/selection.rs b/crates/ui-egui/src/canvas/selection.rs--- a/crates/ui-egui/src/canvas/selection.rs+++ b/crates/ui-egui/src/canvas/selection.rs@@ -1,6 +1,7 @@ use super::*; -/// A text selection on one page.+/// A text selection. `page` is the page it started on: a column selection stays there, a+/// reading-order one may run on to other pages. #[derive(Clone, Copy, Debug, PartialEq)] pub(super) struct Selection {     pub(super) page: usize,@@ -9,8 +10,10 @@ pub(super) struct Selection {  #[derive(Clone, Copy, Debug, PartialEq)] pub(super) enum Span {-    /// Reading order, between two glyph indices.-    Run { anchor: usize, head: usize },+    /// Reading order, from glyph `anchor` on the selection's page to glyph `head` on `head_page`+    /// (either may come first). Across pages it takes the rest of the first page, every page+    /// between whole, and the last page up to its end, as Acrobat does.+    Run { anchor: usize, head: usize, head_page: usize },     /// Column select (#740): the glyphs inside a rectangle in page view space, drawn from the     /// corner the drag started at (`rect[0..2]`) to the pointer (`rect[2..4]`).     Column { rect: [f32; 4] },@@ -18,30 +21,119 @@ pub(super) enum Span {  impl Selection {     pub(super) fn run(page: usize, anchor: usize, head: usize) -> Self {-        Self { page, span: Span::Run { anchor, head } }+        Self { page, span: Span::Run { anchor, head, head_page: page } }     } -    fn range(anchor: usize, head: usize) -> Range<usize> {-        anchor.min(head)..anchor.max(head).saturating_add(1)+    /// The pages the selection covers, first to last.+    pub(super) fn pages(&self) -> RangeInclusive<usize> {+        match self.span {+            Span::Run { head_page, .. } => self.page.min(head_page)..=self.page.max(head_page),+            Span::Column { .. } => self.page..=self.page,+        }+    }++    /// Whether the selection runs over more than one page.+    pub(super) fn spans_pages(&self) -> bool {+        self.pages().start() != self.pages().end()+    }++    /// The reading-order glyphs selected on `page`, which has `len` glyphs; `None` off the+    /// selection's pages and for a column selection.+    pub(super) fn run_on(&self, page: usize, len: usize) -> Option<Range<usize>> {+        self.text_span()?.on(page, len)     } -    /// One rectangle per selected piece of a line, in view space (highlighting, markup quads).-    pub(super) fn rects(&self, text: &PageText) -> Vec<[f32; 4]> {+    /// A reading-order selection's ends in reading order; `None` for a column selection.+    pub(super) fn text_span(&self) -> Option<TextSpan> {+        let Span::Run { anchor, head, head_page } = self.span else { return None };+        // (page, glyph) pairs compare in reading order.+        let (start, end) = if (self.page, anchor) <= (head_page, head) {+            ((self.page, anchor), (head_page, head))+        } else {+            ((head_page, head), (self.page, anchor))+        };+        Some(TextSpan { start, end })+    }++    /// One rectangle per selected piece of a line on `page`, in view space (highlighting,+    /// markup quads).+    pub(super) fn rects_on(&self, page: usize, text: &PageText) -> Vec<[f32; 4]> {         match self.span {-            Span::Run { anchor, head } => text.line_rects(Self::range(anchor, head)),-            Span::Column { rect } => text.glyph_rects(&text.glyphs_in(rect)),+            Span::Run { .. } => self.run_on(page, text.glyphs.len()).map(|r| text.line_rects(r)).unwrap_or_default(),+            Span::Column { rect } if page == self.page => text.glyph_rects(&text.glyphs_in(rect)),+            Span::Column { .. } => Vec::new(),         }     } -    /// The selected text, as Copy puts it on the clipboard.-    pub(super) fn text(&self, text: &PageText) -> String {+    /// The text selected on `page`, as Copy puts it on the clipboard.+    pub(super) fn text_on(&self, page: usize, text: &PageText) -> String {         match self.span {-            Span::Run { anchor, head } => text.text_of(Self::range(anchor, head)),-            Span::Column { rect } => text.column_text(&text.glyphs_in(rect)),+            Span::Run { .. } => self.run_on(page, text.glyphs.len()).map(|r| text.text_of(r)).unwrap_or_default(),+            Span::Column { rect } if page == self.page => text.column_text(&text.glyphs_in(rect)),+            Span::Column { .. } => String::new(),+        }+    }++    /// Extend a reading-order selection to glyph `head` on `page`; a column selection, which has+    /// no anchor glyph, stays as it is.+    pub(super) fn extend_to(&mut self, page: usize, glyph: usize) {+        if let Span::Run { head, head_page, .. } = &mut self.span {+            (*head, *head_page) = (glyph, page);         }     } } +/// What [`DocView::tool_marks`] gives a marking tool.+#[derive(Debug, PartialEq)]+pub(crate) enum ToolMarks {+    /// Nothing to mark now (perhaps later, once the selection's pages are read).+    None,+    /// (page, quads) for each page the selection covers: mark them all, in one undo step.+    Marks(Vec<(usize, Vec<[f64; 8]>)>),+    /// The selection covers a page whose text could not be read: not marked, say why.+    Unreadable,+}++/// Reading-order text from glyph `start.1` on page `start.0` to glyph `end.1` on page `end.0`,+/// both included, `start` first: what a reading-order selection covers, which a copy reads off+/// the UI thread too ([`crate::DocumentCopyJob::of_selection`]).+#[derive(Clone, Copy, Debug, PartialEq, Eq)]+pub(crate) struct TextSpan {+    pub(crate) start: (usize, usize),+    pub(crate) end: (usize, usize),+}++impl TextSpan {+    /// The glyphs covered on `page`, which has `len` glyphs: the rest of the first page, every+    /// page between whole, the last page up to its end glyph. `None` off its pages.+    pub(crate) fn on(&self, page: usize, len: usize) -> Option<Range<usize>> {+        if page < self.start.0 || page > self.end.0 {+            return None;+        }+        let from = if page == self.start.0 { self.start.1 } else { 0 };+        let to = if page == self.end.0 { self.end.1.saturating_add(1) } else { len };+        Some(from..to.max(from))+    }+}++/// A drag pressed on `page` starts a text selection there: from the glyph nearest `origin`, or+/// a column rectangle from `origin` to `to` (page view space). After a multi-click the word or+/// line the clicks selected stays, for the drag to extend by that unit.+pub(super) fn start_selection_drag(view: &mut DocView, page: usize, text: &PageText, column: bool, origin: (f32, f32), to: (f32, f32)) {+    // A drag replaces the selection without passing through none, so a re-drag over text whose+    // bar Copy closed would keep the bar hidden.+    view.actions_dismissed = None;+    view.selection_drag = true;+    if view.clicks < 2 {+        let (ox, oy) = origin;+        view.selection = if column {+            Some(Selection { page, span: Span::Column { rect: [ox, oy, to.0, to.1] } })+        } else {+            text.nearest(ox, oy).map(|a| Selection::run(page, a, a))+        };+    }+}+ /// The glyphs `clicks` quick clicks select around glyph `a`: two the word, three the line, more /// the page. pub(super) fn click_unit(text: &PageText, clicks: u32, a: usize) -> Option<(usize, usize)> {@@ -53,18 +145,91 @@ pub(super) fn click_unit(text: &PageText, clicks: u32, a: usize) -> Option<(usiz }  impl DocView {-    /// The text selection as markup quadrilaterals in user space: (page, quads).-    pub(crate) fn selection_quads(&self, info: &DocInfo) -> Option<(usize, Vec<[f64; 8]>)> {-        let s = self.selection?;-        let text = self.texts.get(&s.page)?;-        let page = info.pages.get(s.page)?;-        let quads: Vec<[f64; 8]> = s.rects(text).into_iter().map(|r| page.view_rect_to_quad(r)).collect();-        (!quads.is_empty()).then_some((s.page, quads))+    /// The text layers of the selection's pages, first to last: `None` while one of them is still+    /// to be read, so a selection is never copied or marked short. A page whose text could not be+    /// read has nothing selected and is left out.+    fn selection_texts(&self) -> Option<Vec<(usize, &Arc<PageText>)>> {+        let mut texts = Vec::new();+        for p in self.selection?.pages() {+            match self.texts.get(&p) {+                Some(t) => texts.push((p, t)),+                None if self.text_failed.contains(&p) => {}+                None => return None,+            }+        }+        Some(texts)+    }++    /// The text selection as markup quadrilaterals in user space, one entry per page with+    /// selected text: (page, quads). Empty when there is nothing to mark yet.+    pub(crate) fn selection_quads(&self, info: &DocInfo) -> Vec<(usize, Vec<[f64; 8]>)> {+        let (Some(s), Some(texts)) = (self.selection, self.selection_texts()) else { return Vec::new() };+        texts+            .into_iter()+            .filter_map(|(p, text)| {+                let page = info.pages.get(p)?;+                let quads: Vec<[f64; 8]> = s.rects_on(p, text).into_iter().map(|r| page.view_rect_to_quad(r)).collect();+                (!quads.is_empty()).then_some((p, quads))+            })+            .collect()+    }++    /// A reading-order selection whose pages are not all read yet: Copy reads them off the UI+    /// thread ([`crate::DocumentCopyJob::of_selection`]) rather than copying short or nothing.+    pub(crate) fn selection_waits_for_text(&self) -> Option<TextSpan> {+        self.selection.filter(|_| self.selection_texts().is_none())?.text_span()+    }++    /// Whether the selection covers a page whose text could not be read. Nothing there can be+    /// selected, so a redaction of the rest would leave that page's content in place: Redact+    /// refuses such a selection rather than mark it short.+    pub(crate) fn selection_has_unreadable_page(&self) -> bool {+        self.selection.is_some_and(|s| s.pages().any(|p| self.text_failed.contains(&p)))+    }++    /// What a marking tool (a markup tool, Redact) marks for the text selection a drag, a+    /// multi-click or picking the tool just finished (`finished`): (page, quads) for every page+    /// the selection covers, as [`Self::selection_quads`], returned once: from one of its pages+    /// (`Some(page)`, called for each page drawn) or from anywhere (`None`). While a page it+    /// covers is still to be read, or none of its pages is drawn, nothing is marked yet: the+    /// selection is remembered with the tool and marked on a later frame, as long as neither has+    /// changed meanwhile. With `whole`, a selection over a page whose text could not be read is+    /// refused instead ([`Self::selection_has_unreadable_page`]).+    pub(crate) fn tool_marks(&mut self, tool: QuickTool, finished: bool, page: Option<usize>, whole: bool, info: &DocInfo) -> ToolMarks {+        let Some(sel) = self.selection else {+            self.marks_waiting = None;+            return ToolMarks::None;+        };+        if finished {+            self.marks_waiting = Some((tool, sel));+        }+        if self.marks_waiting != Some((tool, sel)) {+            return ToolMarks::None;+        }+        if whole && self.selection_has_unreadable_page() {+            self.marks_waiting = None;+            return ToolMarks::Unreadable;+        }+        if self.selection_texts().is_none() {+            return ToolMarks::None;+        }+        let marks = self.selection_quads(info);+        if marks.is_empty() {+            // Every page is read and nothing is selected: nothing to wait for.+            self.marks_waiting = None;+            return ToolMarks::None;+        }+        if page.is_some_and(|page| !marks.iter().any(|(p, _)| *p == page)) {+            return ToolMarks::None;+        }+        self.marks_waiting = None;+        ToolMarks::Marks(marks)     }      pub(crate) fn clear_selection(&mut self) {         self.selection = None;         self.actions_dismissed = None;+        self.marks_waiting = None;     }      /// Select text on `page` from glyph `from` to glyph `to` (tests and automation).@@ -83,14 +248,15 @@ 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`]).+    /// the others, [`DocView::pages_without_text`]). A selection across pages has each page's+    /// part on a line of its own, as Select All of the document does.     pub fn selected_text(&self) -> Option<String> {         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())+        let (s, texts) = (self.selection?, self.selection_texts()?);+        let parts: Vec<String> = texts.into_iter().map(|(p, t)| s.text_on(p, t)).filter(|x| !x.is_empty()).collect();+        Some(parts.join("\n")).filter(|x| !x.is_empty())     }      /// Select all: every page in Organize, or every word on the current page.@@ -152,6 +318,17 @@ enum SelectionAction {     Copy, } +/// Edits that each add a page's part of something marked on a text selection, as one undo step:+/// the edit itself for one page, a batch named like it for several. For edits whose label has no+/// hole (adding a comment), so the batch's name is a wording the catalogues translate.+pub(crate) fn one_undo_step(mut edits: Vec<Edit>) -> Option<Edit> {+    if edits.len() <= 1 {+        return edits.pop();+    }+    let label = edits.first()?.label().wording.to_owned();+    Some(Edit::Batch { label, edits })+}+ /// Actions on an existing text selection, next to the selected words. pub(super) fn selection_actions(     ui: &mut egui::Ui,@@ -172,13 +349,15 @@ pub(super) fn selection_actions(     if view.actions_dismissed == Some(selection) {         return false;     }-    let Some(text) = view.texts.get(&selection.page) else { return false };-    let Some(xf) = view.page_xform(selection.page) else { return false };-    let Some(last) = selection.rects(text).last().copied() else { return false };-    let selected = selection.text(text);-    if selected.is_empty() {+    if view.selected_text().is_none() {         return false;     }+    // Next to where the selection ends: the last rectangle on its last page with text selected.+    let Some(texts) = view.selection_texts() else { return false };+    let Some((page, last)) = texts.iter().rev().find_map(|(p, t)| selection.rects_on(*p, t).last().map(|r| (*p, *r))) else { return false };+    let Some(xf) = view.page_xform(page) else { return false };+    // A comment or a replacement sits on one page: across pages, only markup and Copy.+    let across_pages = selection.spans_pages();     let rect = xf.view_rect(last);     if !viewport.intersects(rect) {         return false;@@ -198,7 +377,8 @@ pub(super) fn selection_actions(                         (tl!("Strikethrough"), SelectionAction::Markup(comments::CommentTool::StrikeOut)),                         (tl!("Replace text"), SelectionAction::Replace),                     ] {-                        if ui.add_enabled(can_comment, egui::Button::new(label)).clicked() {+                        let one_page_only = matches!(action, SelectionAction::Note | SelectionAction::Replace);+                        if ui.add_enabled(can_comment && !(across_pages && one_page_only), egui::Button::new(label)).clicked() {                             chosen = Some(action);                         }                     }@@ -215,12 +395,17 @@ pub(super) fn selection_actions(         view.actions_dismissed = Some(selection);         return true;     }-    let Some((page, quads)) = view.selection_quads(info) else { return false };-    let last = quads.last().copied().unwrap_or_default();-    let at = [last[2].max(last[6]), last[1].max(last[3])];-    view.clear_selection();+    let marks = view.selection_quads(info);+    if marks.is_empty() {+        return false;+    }     match action {         SelectionAction::Note | SelectionAction::Replace => {+            // Disabled across pages (above).+            let Ok([(page, quads)]) = <[_; 1]>::try_from(marks) else { return false };+            let last = quads.last().copied().unwrap_or_default();+            let at = [last[2].max(last[6]), last[1].max(last[3])];+            view.clear_selection();             if matches!(action, SelectionAction::Replace) {                 view.comments.replace = Some(quads);             }@@ -235,13 +420,17 @@ pub(super) fn selection_actions(         SelectionAction::Markup(tool) => {             // Every markup button carries a markup tool; anything else adds nothing.             let Some(kind) = tool.markup() else { return false };-            view.pending_edit = Some(Edit::AddAnnotation(pdfcraft_engine::NewAnnotation {-                page,-                shape: pdfcraft_engine::Shape::TextMarkup { kind, quads },-                style: prefs.style(tool),-                contents: String::new(),-                author: prefs.author.clone(),-            }));+            view.clear_selection();+            let edits = marks.into_iter().map(|(page, quads)| {+                Edit::AddAnnotation(pdfcraft_engine::NewAnnotation {+                    page,+                    shape: pdfcraft_engine::Shape::TextMarkup { kind, quads },+                    style: prefs.style(tool),+                    contents: String::new(),+                    author: prefs.author.clone(),+                })+            });+            view.pending_edit = one_undo_step(edits.collect());         }         // Copy is dispatched through the app after the view borrow ends.         SelectionAction::Copy => {}diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs--- a/crates/ui-egui/src/canvas/tests.rs+++ b/crates/ui-egui/src/canvas/tests.rs@@ -892,12 +892,19 @@ fn a_later_copy_cancels_a_document_copy_still_reading() {     assert!(app.document_copy.is_none(), "cancelled");     let copies: Vec<_> = out.platform_output.commands.iter().filter(|c| matches!(c, egui::OutputCommand::CopyText(_))).collect();     assert_eq!(copies, vec![&egui::OutputCommand::CopyText("a word".to_owned())], "the later copy stays");-    // So does copying a page selection.+    // So does copying a page selection whose page is read.     app.views[0].select_all();     assert!(app.copy_selected_text(0, &ctx) && app.document_copy.is_some());+    app.views[0].texts.insert(0, Arc::new(line_text("abcdef")));     app.views[0].select_text(0, 0, 5);     app.copy_selected_text(0, &ctx);     assert!(app.document_copy.is_none(), "a page selection's copy cancels it");+    // One whose pages are still to be read replaces it with its own (#5017 review).+    app.views[0].select_all();+    assert!(app.copy_selected_text(0, &ctx) && app.document_copy.is_some());+    app.views[0].select_text(0, 0, 5);+    app.views[0].selection = Some(Selection { page: 0, span: Span::Run { anchor: 0, head: 0, head_page: 1 } });+    assert!(app.copy_selected_text(0, &ctx) && app.document_copy.is_some(), "the selection's own copy"); }  /// A press anywhere on a page ends Select All of the document, whatever the tool (here the@@ -1010,6 +1017,107 @@ fn text_layers_keep_the_selection_page() {     v.frame_visible = HashSet::from([90, 91]);     let _ = v.prepare_render_queue(&info, 1.0);     assert_eq!(v.selected_text().as_deref(), Some("x"), "stale selections still copy");+    // Every page of a selection across pages stays, however far from the pages shown.+    for p in 0..100 {+        v.texts.insert(p, Arc::new(text.clone()));+    }+    v.selection = Some(Selection { page: 0, span: Span::Run { anchor: 0, head: 0, head_page: 40 } });+    let _ = v.prepare_render_queue(&info, 1.0);+    assert!((0..=40).all(|p| v.texts.contains_key(&p)), "the selection's pages are kept");+    assert_eq!(v.selected_text().map(|t| t.lines().count()), Some(41));+}++/// One line of 10-wide glyphs, one per char.+fn line_text(s: &str) -> PageText {+    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(),+    }+}++#[test]+fn a_selection_across_pages_takes_the_rest_of_the_first_and_the_start_of_the_last() {+    let s = Selection { page: 1, span: Span::Run { anchor: 2, head: 1, head_page: 3 } };+    assert_eq!(s.pages(), 1..=3);+    assert!(s.spans_pages());+    let on = |s: &Selection| (0..5).map(|p| s.run_on(p, 5)).collect::<Vec<_>>();+    assert_eq!(on(&s), [None, Some(2..5), Some(0..5), Some(0..2), None]);+    // Anchored on the last page and dragged back: the same glyphs.+    let back = Selection { page: 3, span: Span::Run { anchor: 1, head: 2, head_page: 1 } };+    assert_eq!(on(&back), on(&s));+    assert_eq!(back.pages(), 1..=3);+    // On one page, as before: either way round, both ends included.+    let one = Selection::run(2, 4, 1);+    assert!(!one.spans_pages());+    assert_eq!(on(&one), [None, None, Some(1..5), None, None]);+    // A column selection stays on its page and has no reading-order run.+    let column = Selection { page: 2, span: Span::Column { rect: [0.0, 0.0, 100.0, 100.0] } };+    assert_eq!(column.pages(), 2..=2);+    assert_eq!(column.run_on(2, 5), None);+    let t = line_text("abcde");+    assert!(column.rects_on(1, &t).is_empty() && column.text_on(1, &t).is_empty(), "nothing off its page");+    // Extending moves the head, onto another page too; a column selection has none to move.+    let mut ext = Selection::run(0, 3, 3);+    ext.extend_to(2, 1);+    assert_eq!(ext, Selection { page: 0, span: Span::Run { anchor: 3, head: 1, head_page: 2 } });+    let mut col = column;+    col.extend_to(4, 0);+    assert_eq!(col, column);+    // Hostile indices: no overflow, no inverted range.+    let edge = Selection { page: usize::MAX, span: Span::Run { anchor: usize::MAX, head: usize::MAX, head_page: usize::MAX } };+    assert_eq!(edge.run_on(usize::MAX, 3), Some(usize::MAX..usize::MAX));+    assert_eq!(edge.text_on(usize::MAX, &t), "");+    // Glyph indices past a page's end (a stale selection) select only what is there.+    let stale = Selection { page: 0, span: Span::Run { anchor: 3, head: 99, head_page: 1 } };+    assert_eq!(stale.text_on(0, &t), "de");+    assert_eq!(stale.text_on(1, &t), "abcde");+}++#[test]+fn a_selection_across_pages_copies_each_page_on_its_own_line_once_all_are_read() {+    let info = thumbnail_info(4);+    let mut v = DocView::new(DocId(1), &info, ViewDefaults::default());+    v.texts.insert(0, Arc::new(line_text("abc")));+    v.texts.insert(2, Arc::new(line_text("ghi")));+    v.texts.insert(3, Arc::new(line_text("jkl")));+    v.selection = Some(Selection { page: 2, span: Span::Run { anchor: 0, head: 1, head_page: 0 } });+    assert_eq!(v.selected_text(), None, "page 2 is not read yet: no short copy");+    assert!(v.selection_quads(&info).is_empty(), "nor a short markup");+    v.texts.insert(1, Arc::new(line_text("def")));+    assert_eq!(v.selected_text().as_deref(), Some("bc\ndef\ng"));+    // A page whose text could not be read has nothing selected and is passed over.+    v.texts.remove(&1);+    v.text_failed.insert(1);+    assert_eq!(v.selected_text().as_deref(), Some("bc\ng"));+    let pages: Vec<usize> = v.selection_quads(&info).into_iter().map(|(p, _)| p).collect();+    assert_eq!(pages, [0, 2], "one set of quads per page with text selected");+    // A change to one of its pages ends it, as on one page; to another page it stays.+    v.page_changed(3);+    assert!(v.selection.is_some());+    v.page_changed(1);+    assert!(v.selection.is_none());+}++#[test]+fn markup_on_several_pages_is_one_undo_step_named_like_one() {+    let mark = |page| {+        Edit::AddAnnotation(pdfcraft_engine::NewAnnotation {+            page,+            shape: pdfcraft_engine::Shape::TextMarkup { kind: pdfcraft_engine::Markup::Highlight, quads: vec![[0.0; 8]] },+            style: Default::default(),+            contents: String::new(),+            author: String::new(),+        })+    };+    assert!(one_undo_step(Vec::new()).is_none());+    assert!(matches!(one_undo_step(vec![mark(0)]), Some(Edit::AddAnnotation(_))));+    let Some(Edit::Batch { label, edits }) = one_undo_step(vec![mark(0), mark(1)]) else { panic!("a batch") };+    assert_eq!((label.as_str(), edits.len()), (mark(0).label().wording, 2));+    assert!(pdfcraft_engine::EDIT_LABELS.contains(&label.as_str()), "a wording the catalogues translate"); }  #[test]@@ -2032,3 +2140,112 @@ fn a_moved_picture_is_drawn_from_the_fresh_raster_while_a_tile_is_still_stale()     h.state_mut().views[0].stale_tiles.clear();     h.state_mut().views[0].tiles.clear(); }++/// Review of #5017: a selection across pages copied before every page it covers is read is not+/// dropped. Copy stays on and reads those pages off the UI thread, taking only the part each+/// page has selected.+#[test]+fn a_selection_across_pages_still_to_be_read_is_copied_once_read() {+    let mut app = PdfCraftApp::new();+    app.open_bytes("three.pdf", None, text_pages_pdf(&["Alpha one", "Bravo two", "Charlie three"])).unwrap();+    // From the second glyph of page 1 to the first of page 3, no page read yet.+    app.views[0].selection = Some(Selection { page: 0, span: Span::Run { anchor: 1, head: 0, head_page: 2 } });+    let span = app.views[0].selection_waits_for_text().expect("waits for its pages");+    assert_eq!(span, TextSpan { start: (0, 1), end: (2, 0) });+    assert!(app.views[0].selected_text().is_none(), "nothing to copy on this thread yet");+    assert!(pdfcraft_engine::commands::command("edit.copy").is_some_and(|c| app.command_enabled(c)), "Copy stays on");+    // The job reads only the selection's part of each page, and no page outside it.+    let doc = app.session.get(app.views[0].id).unwrap();+    let text = crate::DocumentCopyJob::of_selection(&app.views[0], doc, span).text(&std::sync::atomic::AtomicBool::new(false));+    assert_eq!(text.as_deref(), Some("lpha one\nBravo two\nC"));+    let later = crate::DocumentCopyJob::of_selection(&app.views[0], doc, TextSpan { start: (1, 0), end: (1, 4) });+    assert!(matches!(later.pages[0], crate::CopyPage::Skip) && matches!(later.pages[2], crate::CopyPage::Skip));+    assert_eq!(later.text(&std::sync::atomic::AtomicBool::new(false)).as_deref(), Some("Bravo"));+    // Copy starts that job, and the clipboard gets the text when it is done.+    let ctx = egui::Context::default();+    assert!(app.copy_selected_text(0, &ctx), "the copy starts");+    let mut copied = 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();+        copied = out.platform_output.commands.iter().find_map(|c| match c {+            egui::OutputCommand::CopyText(t) => Some(t.clone()),+            _ => None,+        });+        if copied.is_some() {+            break;+        }+        std::thread::sleep(std::time::Duration::from_millis(10));+    }+    assert_eq!(copied.as_deref(), Some("lpha one\nBravo two\nC"));+}++/// Review of #5017: Redact refuses a selection across a page whose text could not be read,+/// rather than leave that page unmarked, and says why; markup tools mark the readable pages.+#[test]+fn redaction_refuses_a_selection_over_an_unreadable_page() {+    let mut app = PdfCraftApp::new();+    app.open_bytes("three.pdf", None, text_pages_pdf(&["Alpha one", "Bravo two", "Charlie three"])).unwrap();+    let info = app.session.get(app.views[0].id).unwrap().info.clone();+    let v = &mut app.views[0];+    v.texts.insert(0, Arc::new(line_text("abc")));+    v.texts.insert(2, Arc::new(line_text("ghi")));+    v.text_failed.insert(1);+    let sel = Selection { page: 0, span: Span::Run { anchor: 1, head: 1, head_page: 2 } };+    v.selection = Some(sel);+    assert!(v.selection_has_unreadable_page());+    assert_eq!(v.tool_marks(QuickTool::Redact, true, None, true, &info), ToolMarks::Unreadable);+    let pages = |m: ToolMarks| match m {+        ToolMarks::Marks(m) => m.into_iter().map(|(p, _)| p).collect::<Vec<_>>(),+        other => panic!("marks expected, got {other:?}"),+    };+    let highlight = QuickTool::Comment(comments::CommentTool::Highlight);+    assert_eq!(pages(v.tool_marks(highlight, true, None, false, &info)), [0, 2], "a highlight has nothing to hide");+    // Redact ▸ Mark on it: nothing marked, the selection kept, and the reason given.+    app.views[0].selection = Some(sel);+    assert!(app.execute("redact.mark"));+    let doc = app.session.get(app.views[0].id).unwrap();+    assert_eq!(doc.redaction_marks(), 0, "no partial redaction");+    assert_eq!(app.views[0].selection, Some(sel), "still selected");+    assert!(app.toast.as_ref().is_some_and(|(t, _)| t.contains("could not be read")), "{:?}", app.toast);+}++/// Review of #5017: a marking tool that finishes a selection while a page it covers is still+/// to be read marks it once that page is read, from one of its pages, unless the selection or+/// the tool changed meanwhile.+#[test]+fn a_marking_tool_waits_for_the_selections_pages() {+    let info = thumbnail_info(3);+    let mut v = DocView::new(DocId(1), &info, ViewDefaults::default());+    v.texts.insert(0, Arc::new(line_text("abc")));+    v.texts.insert(2, Arc::new(line_text("ghi")));+    let sel = Selection { page: 0, span: Span::Run { anchor: 1, head: 1, head_page: 2 } };+    v.selection = Some(sel);+    let tool = QuickTool::Comment(comments::CommentTool::Underline);+    assert_eq!(v.tool_marks(tool, true, Some(0), false, &info), ToolMarks::None, "page 2 is not read yet");+    assert_eq!(v.tool_marks(tool, false, Some(0), false, &info), ToolMarks::None, "still waiting");+    v.texts.insert(1, Arc::new(line_text("def")));+    // Asked from a page it doesn't cover, it keeps waiting; from one it covers, all of it, once.+    let mut w = DocView::new(DocId(2), &thumbnail_info(4), ViewDefaults::default());+    w.texts = v.texts.clone();+    w.selection = Some(sel);+    assert_eq!(w.tool_marks(tool, true, Some(3), false, &info), ToolMarks::None);+    assert!(matches!(w.tool_marks(tool, false, Some(2), false, &info), ToolMarks::Marks(ref m) if m.len() == 3));+    assert_eq!(w.tool_marks(tool, false, Some(2), false, &info), ToolMarks::None, "marked once");+    assert!(matches!(v.tool_marks(tool, false, Some(1), false, &info), ToolMarks::Marks(ref m) if m.len() == 3));+    // Another tool, or another selection, does not inherit the wait.+    v.texts.remove(&1);+    assert_eq!(v.tool_marks(tool, true, Some(0), false, &info), ToolMarks::None);+    v.texts.insert(1, Arc::new(line_text("def")));+    assert_eq!(v.tool_marks(QuickTool::Redact, false, Some(0), true, &info), ToolMarks::None, "another tool");+    v.selection = Some(Selection::run(0, 0, 2));+    assert_eq!(v.tool_marks(tool, false, Some(0), false, &info), ToolMarks::None, "another selection");+    // Clearing the selection ends the wait.+    v.selection = Some(sel);+    v.texts.remove(&1);+    assert_eq!(v.tool_marks(tool, true, Some(0), false, &info), ToolMarks::None);+    v.clear_selection();+    v.selection = Some(sel);+    v.texts.insert(1, Arc::new(line_text("def")));+    assert_eq!(v.tool_marks(tool, false, Some(0), false, &info), ToolMarks::None, "cleared");+}diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs--- a/crates/ui-egui/src/commands.rs+++ b/crates/ui-egui/src/commands.rs@@ -85,10 +85,9 @@ 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.all_text || 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.selection_waits_for_text().is_some() || 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()),@@ -309,17 +308,27 @@ impl PdfCraftApp {                 if self.right.is_none() && !self.comments_panel_closed {                     self.choose_right_panel(Some(RightPanel::Comments));                 }-                // A text selection made before picking a markup tool is marked right away.+                // A text selection made before picking a markup tool is marked right away, on+                // every page it covers, in one undo step.                 if let (Some(kind), Some(i)) = (tool.markup(), active)                     && let Some(doc) = self.session.get(self.views[i].id)                 {-                    let info = &doc.info;-                    if let Some((page, quads)) = self.views[i].selection_quads(info) {-                        self.views[i].clear_selection();-                        let style = self.comment_prefs.style(tool);-                        let author = self.comment_prefs.author.clone();-                        let shape = pdfcraft_engine::Shape::TextMarkup { kind, quads };-                        self.apply_edit(Edit::AddAnnotation(pdfcraft_engine::NewAnnotation { page, shape, style, contents: String::new(), author }));+                    // Pages still to be read are marked when they are, by the tool now picked.+                    let tool_now = self.quick_tool;+                    if let crate::canvas::ToolMarks::Marks(marks) = self.views[i].tool_marks(tool_now, true, None, false, &doc.info) {+                        let edits: Vec<Edit> = marks+                            .into_iter()+                            .map(|(page, quads)| {+                                let style = self.comment_prefs.style(tool);+                                let author = self.comment_prefs.author.clone();+                                let shape = pdfcraft_engine::Shape::TextMarkup { kind, quads };+                                Edit::AddAnnotation(pdfcraft_engine::NewAnnotation { page, shape, style, contents: String::new(), author })+                            })+                            .collect();+                        if let Some(edit) = crate::canvas::one_undo_step(edits) {+                            self.views[i].clear_selection();+                            self.apply_edit(edit);+                        }                     }                 }             }@@ -467,15 +476,26 @@ impl PdfCraftApp {                 self.quick_tool = crate::QuickTool::Redact;                 self.left = crate::LeftPanel::Tool("redact");                 self.reveal_tools();-                // A text selection made first is marked right away.+                // A text selection made first is marked right away, on every page it covers, in+                // one undo step.                 if let Some(i) = active                     && let Some(doc) = self.session.get(self.views[i].id)                 {-                    let info = &doc.info;-                    if let Some((page, quads)) = self.views[i].selection_quads(info) {-                        self.views[i].clear_selection();-                        let author = self.comment_prefs.author.clone();-                        self.apply_edit(self.redact_prefs.mark(page, quads, &author));+                    let author = self.comment_prefs.author.clone();+                    // Pages still to be read are marked when they are, by the Redact tool now+                    // picked; a page that could not be read refuses the whole selection.+                    match self.views[i].tool_marks(crate::QuickTool::Redact, true, None, true, &doc.info) {+                        crate::canvas::ToolMarks::Marks(marks) => {+                            let edits: Vec<Edit> = marks.into_iter().map(|(page, quads)| self.redact_prefs.mark(page, quads, &author)).collect();+                            if let Some(edit) = crate::canvas::one_undo_step(edits) {+                                self.views[i].clear_selection();+                                self.apply_edit(edit);+                            }+                        }+                        crate::canvas::ToolMarks::Unreadable => {+                            self.notify_tr("Part of the selected text is on a page whose text could not be read, so nothing was marked for redaction. Mark that page with a box instead.");+                        }+                        crate::canvas::ToolMarks::None => {}                     }                 }             }diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs--- a/crates/ui-egui/src/editing.rs+++ b/crates/ui-egui/src/editing.rs@@ -62,6 +62,9 @@ impl PdfCraftApp {                 if let Some(page) = comment_page(&edit).or_else(|| content_page(&edit)) {                     // Comment and content edits change one page: keep every other raster.                     view.page_changed(page);+                } else if let Some(pages) = annotated_pages(&edit) {+                    // Markup on a selection across pages: one comment on each of them.+                    pages.into_iter().for_each(|page| view.page_changed(page));                 } else if let Some(form_before) = form_before                     && doc.info.pages.len() == page_count_before                     && doc.xfa.is_some() == had_xfa@@ -665,6 +668,20 @@ fn comment_page(edit: &Edit) -> Option<usize> {     } } +/// The pages of a batch that only adds annotations (markup on a text selection across pages),+/// each of which changes only its own page; `None` for anything else.+fn annotated_pages(edit: &Edit) -> Option<Vec<usize>> {+    let Edit::Batch { edits, .. } = edit else { return None };+    let pages: Vec<usize> = edits+        .iter()+        .map(|e| match e {+            Edit::AddAnnotation(a) => Some(a.page),+            _ => None,+        })+        .collect::<Option<_>>()?;+    (!pages.is_empty()).then_some(pages)+}+ /// Whether `edit` is a form-related modification. fn is_form_edit(edit: &Edit) -> bool {     matches!(diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs--- a/crates/ui-egui/src/state.rs+++ b/crates/ui-egui/src/state.rs@@ -232,9 +232,11 @@ pub(crate) enum CopyPage { }  /// 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.+/// layer as far as it was read, and the document to read the others from. A copy of a text+/// selection across pages whose pages are not all read yet is one too, limited to its `span`. pub(crate) struct DocumentCopyJob {     pub(crate) pages: Vec<CopyPage>,+    span: Option<crate::canvas::TextSpan>,     bytes: std::sync::Arc<Vec<u8>>,     password: Option<std::sync::Arc<str>>, }@@ -248,7 +250,20 @@ impl DocumentCopyJob {                 None => CopyPage::ToRead,             })             .collect();-        DocumentCopyJob { pages, bytes: doc.bytes.clone(), password: doc.password.as_deref().map(std::sync::Arc::from) }+        DocumentCopyJob { pages, span: None, bytes: doc.bytes.clone(), password: doc.password.as_deref().map(std::sync::Arc::from) }+    }++    /// A copy of the reading-order text `span` covers: its pages as [`Self::of`] takes them, every+    /// other page skipped.+    pub(crate) fn of_selection(v: &DocView, doc: &pdfcraft_engine::Document, span: crate::canvas::TextSpan) -> DocumentCopyJob {+        let mut job = DocumentCopyJob::of(v, doc);+        for (page, p) in job.pages.iter_mut().enumerate() {+            if page < span.start.0 || page > span.end.0 {+                *p = CopyPage::Skip;+            }+        }+        job.span = Some(span);+        job     }      /// The document's text in page order, a page per line run. Each page read here is let go@@ -265,12 +280,18 @@ impl DocumentCopyJob {                 out.push_str(&text);             }         };+        // The glyphs taken from `page`: all of them, or the part a selection covers.+        let span = self.span;+        let part = |page: usize, t: &pdfcraft_render::PageText| match span {+            Some(span) => span.on(page, t.glyphs.len()).map(|r| t.text_of(r)).unwrap_or_default(),+            None => t.text_of(0..t.glyphs.len()),+        };         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::Read(t) => push(part(page, &t)),                 CopyPage::ToRead => {                     let r = renderer.get_or_insert_with(|| {                         let config = pdfcraft_render::RenderConfig { password: self.password.clone(), ..Default::default() };@@ -285,7 +306,7 @@ impl DocumentCopyJob {                         preview: false,                     };                     if let Some(t) = r.render(req).text {-                        push(t.text_of(0..t.glyphs.len()));+                        push(part(page, &t));                     }                 }                 CopyPage::Skip => {}diff --git a/crates/ui-egui/src/tools/comments/input.rs b/crates/ui-egui/src/tools/comments/input.rs--- a/crates/ui-egui/src/tools/comments/input.rs+++ b/crates/ui-egui/src/tools/comments/input.rs@@ -491,7 +491,11 @@ fn select_input( pub(crate) fn page_after_text(resp: &egui::Response, cx: &PageCx<'_>, view: &mut DocView) {     let QuickTool::Comment(tool) = cx.tool else { return };     if tool == CommentTool::ReplaceText && cx.allowed && (resp.drag_stopped() || resp.double_clicked()) {-        if let Some((page, quads)) = view.selection_quads(cx.info).filter(|(p, _)| *p == cx.page) {+        // A replacement sits on one page: a selection across pages stays selected for the bar's+        // markup or Copy.+        if let Ok([(page, quads)]) = <[_; 1]>::try_from(view.selection_quads(cx.info))+            && page == cx.page+        {             view.clear_selection();             let last = quads.last().copied().unwrap_or_default();             let at = [last[2].max(last[6]), last[1].max(last[3])];@@ -500,15 +504,23 @@ pub(crate) fn page_after_text(resp: &egui::Response, cx: &PageCx<'_>, view: &mut         }         return;     }-    if tool.markup().is_none() || !cx.allowed || !(resp.drag_stopped() || resp.double_clicked()) {-        return;-    }-    if let Some(kind) = tool.markup()-        && let Some((page, quads)) = view.selection_quads(cx.info).filter(|(p, _)| *p == cx.page)-    {+    let Some(kind) = tool.markup().filter(|_| cx.allowed) else { return };+    // Once, from whichever of the selection's pages is drawn: after an edge scroll its first page+    // may be out of view. Every page it covers gets its own markup, in one undo step, once all+    // of them are read.+    let finished = resp.drag_stopped() || resp.double_clicked();+    if let crate::canvas::ToolMarks::Marks(marks) = view.tool_marks(cx.tool, finished, Some(cx.page), false, cx.info) {         view.clear_selection();-        view.pending_edit = Some(new_comment(cx, tool, Shape::TextMarkup { kind, quads }, String::new()));-        let _ = page;+        let edits = marks.into_iter().map(|(page, quads)| {+            Edit::AddAnnotation(NewAnnotation {+                page,+                shape: Shape::TextMarkup { kind, quads },+                style: cx.prefs.style(tool),+                contents: String::new(),+                author: cx.prefs.author.clone(),+            })+        });+        view.pending_edit = crate::canvas::one_undo_step(edits.collect());     } } diff --git a/crates/ui-egui/src/tools/redact_ui.rs b/crates/ui-egui/src/tools/redact_ui.rs--- a/crates/ui-egui/src/tools/redact_ui.rs+++ b/crates/ui-egui/src/tools/redact_ui.rs@@ -256,7 +256,7 @@ pub(crate) fn page_input(             let r = Rect::from_two_pos(start, end).intersect(xf.rect);             if r.width() >= 3.0 && r.height() >= 3.0 {                 let (a, b) = (to_user(xf, info, page, r.min), to_user(xf, info, page, r.max));-                view.pending_redaction = Some((page, vec![rect_quad([a[0], a[1], b[0], b[1]])]));+                view.pending_redaction = vec![(page, vec![rect_quad([a[0], a[1], b[0], b[1]])])];             }         }         return true;@@ -265,9 +265,11 @@ pub(crate) fn page_input(     if !over_text(p) {         ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair);     }+    // A box starts on the page the button went down on: a quick drag from another page's text+    // can already be over this one when egui calls it a drag, and is a text selection.     if resp.drag_started() {         let origin = ptr.origin.unwrap_or(p);-        if !over_text(origin) {+        if xf.rect.contains(origin) && !over_text(origin) {             view.redact_drag = Some((page, origin));             return true;         }@@ -276,13 +278,20 @@ pub(crate) fn page_input( }  /// A finished text selection with the Redact tool becomes a mark.+/// Across pages: a mark on each page it covers, once all are read; refused when one of them could+/// not be read, rather than leave it unmarked. pub(crate) fn after_text(resp: &egui::Response, page: usize, info: &DocInfo, view: &mut DocView) {-    if !(resp.drag_stopped() || resp.double_clicked()) || view.redact_drag.is_some() {+    if view.redact_drag.is_some() {         return;     }-    if let Some((p, quads)) = view.selection_quads(info).filter(|(p, _)| *p == page) {-        view.clear_selection();-        view.pending_redaction = Some((p, quads));+    let finished = resp.drag_stopped() || resp.double_clicked();+    match view.tool_marks(crate::QuickTool::Redact, finished, Some(page), true, info) {+        crate::canvas::ToolMarks::Marks(marks) => {+            view.clear_selection();+            view.pending_redaction = marks;+        }+        crate::canvas::ToolMarks::Unreadable => view.redaction_refused = true,+        crate::canvas::ToolMarks::None => {}     } } diff --git a/crates/ui-egui/tests/it/text_select.rs b/crates/ui-egui/tests/it/text_select.rs--- a/crates/ui-egui/tests/it/text_select.rs+++ b/crates/ui-egui/tests/it/text_select.rs@@ -407,8 +407,8 @@ fn page_top(h: &Harness<'static, PdfCraftApp>, page: usize) -> f32 { }  /// Issue #721: selecting near the bottom of a page with the button held, the wheel keeps-/// scrolling while the pointer crosses the gap onto the next page. The selection stays on its-/// own page (a selection can't span pages yet).+/// scrolling while the pointer crosses the gap onto the next page, and the selection follows the+/// pointer onto that page. #[test] fn a_selection_drag_keeps_scrolling_over_the_gap_and_the_next_page() {     let (mut h, _) = harness_with(bottom_lines(3), |app| {@@ -434,13 +434,106 @@ fn a_selection_drag_keeps_scrolling_over_the_gap_and_the_next_page() {         }     }     assert!(over_gap && over_next, "the pointer crossed the gap ({over_gap}) onto the next page ({over_next})");+    // The next page's text layer may still be on its way: the selection reaches it once read.+    crate::support::wait_until(&mut h, "page 2's text layer", |app| app.views[0].glyph_screen_pos(1, 0).is_some());+    h.hover_at(to);+    h.run_steps(2);     let held = h.state().views[0].selected_text().expect("still selected");-    assert!(held.starts_with("ends") && "Page 1 ends with this line".contains(held.as_str()), "the selection stays on page 1: {held:?}");+    assert!(held.starts_with("ends with this line\nPage 2"), "the selection runs on onto page 2: {held:?}");     h.event(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });     h.run_steps(2);     assert_eq!(h.state().views[0].selected_text(), Some(held), "releasing keeps the selection"); } +/// `bottom_lines(3)` at 75 %, every page on screen with its text layer read.+fn three_pages() -> Harness<'static, PdfCraftApp> {+    let (mut h, _) = harness_with(bottom_lines(3), |app| {+        app.set_option("layout", "continuous").unwrap();+        app.set_option("zoom", "75").unwrap();+    });+    crate::support::wait_until(&mut h, "every page's text layer", |app| {+        (0..3).all(|p| app.views[0].glyph_screen_pos(p, 10).is_some()) && !app.render_pending()+    });+    let viewport = h.state().views[0].viewport_rect();+    assert!((0..3).all(|p| h.state().views[0].page_screen_rect(p).is_some_and(|r| viewport.contains_rect(r))), "every page in view");+    h+}++fn glyph_on(h: &Harness<'static, PdfCraftApp>, page: usize, g: usize) -> Pos2 {+    h.state().views[0].glyph_screen_pos(page, g).expect("glyph on screen")+}++/// A drag from one page onto the next selects to the end of the first page and from the start+/// of the next, as in Acrobat, and Copy has both parts in reading order. Dragging the other way+/// selects the same text.+#[test]+fn a_drag_selects_text_across_pages() {+    let mut h = three_pages();+    // From "ends" on page 1 (glyph 7) to the "s" of "ends" on page 2 (glyph 10).+    let (from, to) = (glyph_on(&h, 0, 7), glyph_on(&h, 1, 10));+    drag(&mut h, from, to);+    let want = "ends with this line\nPage 2 ends";+    assert_eq!(h.state().views[0].selected_text().as_deref(), Some(want), "dragging down");+    h.run_steps(60);+    drag(&mut h, to, from);+    assert_eq!(h.state().views[0].selected_text().as_deref(), Some(want), "dragging up");+    // Over three pages: the middle one is selected whole.+    h.run_steps(60);+    let page3 = glyph_on(&h, 2, 3);+    drag(&mut h, from, page3);+    assert_eq!(h.state().views[0].selected_text().as_deref(), Some("ends with this line\nPage 2 ends with this line\nPage"));+    // ⌘C copies all of it.+    h.event(Event::Copy);+    let mut did_copy = false;+    for _ in 0..4 {+        h.step();+        did_copy |= copied(&h, "ends with this line\nPage 2 ends with this line\nPage");+    }+    assert!(did_copy, "Copy has the text of every page");+}++/// ⇧-click on another page extends the selection there from its anchor, either way.+#[test]+fn shift_click_extends_the_selection_onto_another_page() {+    let mut h = three_pages();+    // "ends" on page 2.+    let (from, to) = (glyph_on(&h, 1, 7), glyph_on(&h, 1, 10));+    drag(&mut h, from, to);+    assert_eq!(h.state().views[0].selected_text().as_deref(), Some("ends"));+    h.run_steps(60);+    let page3 = glyph_on(&h, 2, 3);+    shift_click(&mut h, page3);+    assert_eq!(h.state().views[0].selected_text().as_deref(), Some("ends with this line\nPage"));+    h.run_steps(60);+    // Before the anchor, on page 1: from the click to the anchor.+    // Glyph 22 is the "l" of "line" (spaces are glyphs too).+    let line = glyph_on(&h, 0, 22);+    shift_click(&mut h, line);+    assert_eq!(h.state().views[0].selected_text().as_deref(), Some("line\nPage 2 e"));+}++/// Marking a selection that runs across pages puts one markup on each page, in one undo step.+#[test]+fn a_selection_across_pages_is_marked_on_every_page_in_one_step() {+    use egui_kittest::kittest::Queryable;+    let mut h = three_pages();+    let (from, to) = (glyph_on(&h, 0, 7), glyph_on(&h, 1, 10));+    drag(&mut h, from, to);+    h.run_steps(3);+    h.get_by_label("Underline").click();+    h.run_steps(3);+    let marks = |h: &Harness<'static, PdfCraftApp>| {+        let app = h.state();+        let doc = app.session.get(app.views[0].id).unwrap();+        doc.info.annotations.iter().map(|a| (a.page, a.subtype.clone())).collect::<Vec<_>>()+    };+    assert_eq!(marks(&h), [(0, "Underline".to_string()), (1, "Underline".to_string())]);+    assert!(h.state().views[0].selected_text().is_none(), "the selection is consumed");+    h.state_mut().undo();+    h.run_steps(3);+    assert_eq!(marks(&h), [], "one undo removes both");+}+ /// One 300×600 page with a line of Helvetica text at its top and another at its bottom. fn tall_page() -> Vec<u8> {     let content = "BT /F1 14 Tf 20 570 Td (Top line) Tj 0 -550 Td (Bottom line) Tj ET";@@ -649,3 +742,57 @@ fn full_screen_double_click_selects_a_word_and_stays_on_the_page() {     h.run_steps(30);     assert_eq!(h.state().views[0].current, 0, "no page turn after leaving"); }++/// A quick drag can be off its page by the frame egui calls it a drag, in the gap or on the next+/// page: the selection still starts where the button went down and runs on to the pointer.+#[test]+fn a_flick_off_the_page_still_selects_from_the_press() {+    let mut h = three_pages();+    let (from, to) = (glyph_on(&h, 0, 7), glyph_on(&h, 1, 10));+    h.event(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });+    h.run_steps(1);+    let gap =+        egui::pos2(from.x, (h.state().views[0].page_screen_rect(0).unwrap().bottom() + h.state().views[0].page_screen_rect(1).unwrap().top()) / 2.0);+    h.hover_at(gap);+    h.run_steps(1);+    assert_eq!(h.state().views[0].selected_text().as_deref(), Some("e"), "started at the press, on page 1; the gap moves nothing");+    h.hover_at(to);+    h.run_steps(1);+    h.event(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });+    h.run_steps(1);+    assert_eq!(h.state().views[0].selected_text().as_deref(), Some("ends with this line\nPage 2 ends"));+}++/// The markup and Redact tools mark a drag across pages on every page it covers, in one step,+/// and so does picking a markup tool with such a selection already made.+#[test]+fn markup_tools_mark_a_selection_across_pages_on_every_page() {+    let marks = |h: &Harness<'static, PdfCraftApp>| {+        let app = h.state();+        let doc = app.session.get(app.views[0].id).unwrap();+        doc.info.annotations.iter().map(|a| (a.page, a.subtype.clone())).collect::<Vec<_>>()+    };+    let both = |kind: &str| vec![(0, kind.to_string()), (1, kind.to_string())];+    for (command, kind) in [("comment.strikeout", "StrikeOut"), ("redact.mark", "Redact")] {+        // Dragging with the tool.+        let mut h = three_pages();+        assert!(h.state_mut().execute(command));+        h.run_steps(2);+        let (from, to) = (glyph_on(&h, 0, 7), glyph_on(&h, 1, 10));+        drag(&mut h, from, to);+        h.run_steps(3);+        assert_eq!(marks(&h), both(kind), "{command}: dragged with the tool");+        h.state_mut().undo();+        h.run_steps(3);+        assert_eq!(marks(&h), [], "{command}: one undo step");+        // Selecting first, then picking the tool.+        // (The tool opened the Comments panel above, which moved the pages.)+        let mut h = three_pages();+        let (from, to) = (glyph_on(&h, 0, 7), glyph_on(&h, 1, 10));+        drag(&mut h, from, to);+        assert!(h.state().views[0].selected_text().is_some());+        assert!(h.state_mut().execute(command));+        h.run_steps(3);+        assert_eq!(marks(&h), both(kind), "{command}: picked after selecting");+    }+}diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml--- a/parity/acrobat-features.toml+++ b/parity/acrobat-features.toml@@ -1526,8 +1526,8 @@ milestone = "M3" status = "shipped" commands = [] tools = []-evidence = ["crates/ui-egui/tests/it/ui.rs::drag_selects_text_and_copy_returns_it", "crates/ui-egui/tests/it/ui.rs::selection_works_in_every_view_rotation", "crates/ui-egui/tests/it/text_select.rs::quick_clicks_select_a_word_then_the_line_then_the_page", "crates/ui-egui/tests/it/text_select.rs::quick_clicks_with_the_highlighter_mark_only_the_word", "crates/ui-egui/tests/it/text_select.rs::shift_click_extends_the_selection_from_its_anchor", "crates/ui-egui/tests/it/comments.rs::selected_text_shows_actions_and_underlines_without_changing_tools", "crates/ui-egui/tests/it/comments.rs::selected_text_replace_action_opens_replacement_composer", "crates/ui-egui/tests/it/comments.rs::selected_text_comment_action_opens_note_composer"]-notes = "Drag to select; ⇧-click extends the selection to the click, keeping its anchor (same page only); with the Select tool, quick clicks select a word (two), the line (three) or all the text on the page (four). A floating action bar offers comment, highlight, underline, strikethrough, replace text and copy after selection. The markup and Redact tools mark a double-clicked word and stop there. Missing: selection across pages (dragging or ⇧-clicking onto another page), paragraph selection, and ⌘A across the whole document (it selects the current page). Acrobat's other selection context actions such as bookmark, link and export selection remain outside this bar."+evidence = ["crates/ui-egui/tests/it/ui.rs::drag_selects_text_and_copy_returns_it", "crates/ui-egui/tests/it/ui.rs::selection_works_in_every_view_rotation", "crates/ui-egui/tests/it/text_select.rs::quick_clicks_select_a_word_then_the_line_then_the_page", "crates/ui-egui/tests/it/text_select.rs::quick_clicks_with_the_highlighter_mark_only_the_word", "crates/ui-egui/tests/it/text_select.rs::shift_click_extends_the_selection_from_its_anchor", "crates/ui-egui/tests/it/text_select.rs::a_drag_selects_text_across_pages", "crates/ui-egui/tests/it/text_select.rs::shift_click_extends_the_selection_onto_another_page", "crates/ui-egui/tests/it/text_select.rs::a_selection_across_pages_is_marked_on_every_page_in_one_step", "crates/ui-egui/tests/it/text_select.rs::a_selection_drag_keeps_scrolling_over_the_gap_and_the_next_page", "crates/ui-egui/src/canvas/tests.rs::a_selection_across_pages_copies_each_page_on_its_own_line_once_all_are_read", "crates/ui-egui/src/canvas/tests.rs::a_selection_across_pages_still_to_be_read_is_copied_once_read", "crates/ui-egui/src/canvas/tests.rs::redaction_refuses_a_selection_over_an_unreadable_page", "crates/ui-egui/src/canvas/tests.rs::a_marking_tool_waits_for_the_selections_pages", "crates/ui-egui/tests/it/comments.rs::selected_text_shows_actions_and_underlines_without_changing_tools", "crates/ui-egui/tests/it/comments.rs::selected_text_replace_action_opens_replacement_composer", "crates/ui-egui/tests/it/comments.rs::selected_text_comment_action_opens_note_composer"]+notes = "Drag to select; ⇧-click extends the selection to the click, keeping its anchor. A selection runs across pages (dragging onto another page, with the edge and wheel scrolling of #721/#1670, or ⇧-clicking there): the rest of the first page, the pages between whole and the last page up to the pointer, copied with each page on a line of its own; pages not read yet are read off the UI thread for Copy, and a markup or Redact tool that finishes the selection first marks it once they are. With the Select tool, quick clicks select a word (two), the line (three) or all the text on the page (four). A floating action bar offers comment, highlight, underline, strikethrough, replace text and copy after selection; across pages, highlight, underline, strikethrough and Redact put one mark on each page in one undo step (Redact refuses a selection over a page whose text could not be read, and says so, rather than leave that page unmarked), while Add comment and Replace text (one page each) are greyed out. The markup and Redact tools mark a double-clicked word and stop there. Missing: paragraph selection, column selection across pages, an explanation on the greyed-out cross-page actions, and a check against Acrobat of its cross-page copied-text format (not observed). Acrobat's other selection context actions such as bookmark, link and export selection remain outside this bar."  [[feature]] id = "view.column-select"