mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-26 11:08:48 -06:00
* Text: find the word and the line around a glyph PageText gets word_at(i) and line_at(i). Each returns the first and last glyph of the word or the line around glyph i, or None when i is past the end of the page's text. A word stops at a word gap, a blank glyph or a line end. A line is the run of glyphs with the same line number. The canvas's double-click used its own loop for this, which indexed the glyph, line and gap arrays directly. It now calls word_at, which reads them with get(). Double-clicking selects the same words as before. Test: render words_and_lines_around_a_glyph, on two lines with a blank glyph, a word gap and a line break. * Issue #295: three clicks select the line, four clicks the page With the Select tool, quick clicks on text widen the selection like Acrobat: two clicks select a word (as before), three the line, four or more all the text on the page. A third click used to clear the selection. egui counts only up to triple clicks and reports a fourth quick click as another triple, so the view keeps its own count of the current run. A single click resets it and a pause ends it. With the markup tools (Highlight and the like) and Redact, a double-click already turns the word into a mark at once. They stop at the word, so more clicks don't stack line or page marks. Tests: ui-egui quick_clicks_select_a_word_then_the_line_then_the_page and quick_clicks_with_the_highlighter_mark_only_the_word. parity: view.text-select cites the tests. The notes say what is missing: paragraph selection, and Ctrl/⌘+A across the whole document (it selects the current page). * Clippy: as_chunks in the signature preview test (follow-up to #355) Clippy 1.99 rejects chunks_exact with a constant chunk size (chunks_exact_to_as_chunks), so `cargo clippy --workspace --all-targets -- -D warnings` fails on main since #355. Use as_chunks, as #360 did for the test above it. --------- Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
co-authored by
Brandon Thomas
parent
31f3631868
commit
85dcb9dece
@@ -146,6 +146,41 @@ impl PageText {
|
||||
.map(|(i, _)| i)
|
||||
}
|
||||
|
||||
/// The word around glyph `i` as its first and last glyph (double-click): it stops at word
|
||||
/// gaps, blank glyphs and line ends.
|
||||
pub fn word_at(&self, i: usize) -> Option<(usize, usize)> {
|
||||
self.glyphs.get(i)?;
|
||||
let blank = |k: usize| self.glyphs.get(k).is_none_or(|g| g.text.trim().is_empty());
|
||||
// Glyph `k` (≥ 1) follows glyph `k - 1` with no word gap or line break between them.
|
||||
let joined = |k: usize| !self.space_before.get(k).copied().unwrap_or(true) && self.line_of.get(k - 1) == self.line_of.get(k);
|
||||
let mut first = i;
|
||||
while first > 0 && joined(first) && !blank(first - 1) {
|
||||
first -= 1;
|
||||
}
|
||||
let mut last = i;
|
||||
while joined(last + 1) && !blank(last + 1) {
|
||||
last += 1;
|
||||
}
|
||||
Some((first, last))
|
||||
}
|
||||
|
||||
/// The line around glyph `i` as its first and last glyph (triple-click). A line's glyphs are
|
||||
/// consecutive.
|
||||
pub fn line_at(&self, i: usize) -> Option<(usize, usize)> {
|
||||
self.glyphs.get(i)?;
|
||||
let line = self.line_of.get(i)?;
|
||||
let same = |k: usize| self.line_of.get(k) == Some(line);
|
||||
let mut first = i;
|
||||
while first > 0 && same(first - 1) {
|
||||
first -= 1;
|
||||
}
|
||||
let mut last = i;
|
||||
while same(last + 1) {
|
||||
last += 1;
|
||||
}
|
||||
Some((first, last))
|
||||
}
|
||||
|
||||
/// Merge the boxes of `range` into one rectangle per line (for highlighting).
|
||||
pub fn line_rects(&self, range: std::ops::Range<usize>) -> Vec<[f32; 4]> {
|
||||
let mut out: Vec<(u32, [f32; 4])> = Vec::new();
|
||||
@@ -656,4 +691,24 @@ mod tests {
|
||||
word(&mut v, "每个字", 10.0, 10.0, 9.0);
|
||||
assert_eq!(layout(v).plain_text(), "每个字");
|
||||
}
|
||||
|
||||
/// Two lines, "ab cd" and "e fg": words stop at blank glyphs, word gaps and line ends.
|
||||
#[test]
|
||||
fn words_and_lines_around_a_glyph() {
|
||||
let t = PageText {
|
||||
glyphs: Vec::from(["a", "b", " ", "c", "d", "e", "f", "g"].map(|s| TextGlyph { text: s.into(), rect: [0.0; 4] })),
|
||||
line_of: vec![0, 0, 0, 0, 0, 1, 1, 1],
|
||||
space_before: vec![false, false, false, false, false, false, true, false],
|
||||
};
|
||||
assert_eq!(t.word_at(1), Some((0, 1)));
|
||||
assert_eq!(t.word_at(3), Some((3, 4)));
|
||||
assert_eq!(t.word_at(4), Some((3, 4)));
|
||||
assert_eq!(t.word_at(5), Some((5, 5)));
|
||||
assert_eq!(t.word_at(7), Some((6, 7)));
|
||||
assert_eq!(t.line_at(2), Some((0, 4)));
|
||||
assert_eq!(t.line_at(6), Some((5, 7)));
|
||||
assert_eq!(t.word_at(8), None);
|
||||
assert_eq!(t.line_at(8), None);
|
||||
assert_eq!(PageText::default().line_at(0), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,6 +222,8 @@ pub struct DocView {
|
||||
pub(crate) text_failed: HashSet<usize>,
|
||||
pub find: Option<Find>,
|
||||
selection: Option<Selection>,
|
||||
/// Clicks in the current run on the text layer: two select a word, three the line, four the page.
|
||||
clicks: u32,
|
||||
last_queue: Vec<RenderRequest>,
|
||||
viewport_w: f32,
|
||||
viewport_h: f32,
|
||||
@@ -363,6 +365,7 @@ impl DocView {
|
||||
text_failed: HashSet::new(),
|
||||
find: None,
|
||||
selection: None,
|
||||
clicks: 0,
|
||||
last_queue: Vec::new(),
|
||||
viewport_w: 800.0,
|
||||
viewport_h: 600.0,
|
||||
@@ -1701,20 +1704,28 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
{
|
||||
sel.head = h;
|
||||
}
|
||||
if resp.double_clicked() && over_text {
|
||||
if let Some(a) = text.nearest(vx, vy) {
|
||||
// Expand to the word: stop at inferred spaces, explicit spaces and line ends.
|
||||
let is_break = |k: usize| text.glyphs[k].text.trim().is_empty();
|
||||
let mut s0 = a;
|
||||
while s0 > 0 && !text.space_before[s0] && text.line_of[s0 - 1] == text.line_of[s0] && !is_break(s0 - 1) {
|
||||
s0 -= 1;
|
||||
}
|
||||
let mut e = a;
|
||||
while e + 1 < text.glyphs.len() && !text.space_before[e + 1] && text.line_of[e + 1] == text.line_of[e] && !is_break(e + 1)
|
||||
{
|
||||
e += 1;
|
||||
}
|
||||
view.selection = Some(Selection { page: i, anchor: s0, head: e });
|
||||
// Quick clicks widen the selection: two a word, three the line, four the page.
|
||||
// egui counts up to three, so four come from the run kept here. The markup and
|
||||
// Redact tools mark a double-clicked word at once, so they stop at the word.
|
||||
let clicks = if resp.triple_clicked() {
|
||||
view.clicks.max(2).saturating_add(1)
|
||||
} else if resp.double_clicked() {
|
||||
2
|
||||
} else {
|
||||
u32::from(resp.clicked())
|
||||
};
|
||||
if clicks > 0 {
|
||||
view.clicks = clicks;
|
||||
}
|
||||
let clicks = if clicks > 2 && tool != QuickTool::Select { 1 } else { clicks };
|
||||
if clicks >= 2 && over_text {
|
||||
let span = text.nearest(vx, vy).and_then(|a| match clicks {
|
||||
2 => text.word_at(a),
|
||||
3 => text.line_at(a),
|
||||
_ => text.glyphs.len().checked_sub(1).map(|last| (0, last)),
|
||||
});
|
||||
if let Some((first, last)) = span {
|
||||
view.selection = Some(Selection { page: i, anchor: first, head: last });
|
||||
}
|
||||
} else if resp.clicked() && !over_link {
|
||||
view.selection = None;
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
//! Issue #295: quick clicks on text widen the selection, as in Acrobat: two clicks select a
|
||||
//! word, three the line, four all the text on the page.
|
||||
|
||||
use egui::{Event, Modifiers, PointerButton, Pos2};
|
||||
use egui_kittest::Harness;
|
||||
use pdfcraft_ui_egui::PdfCraftApp;
|
||||
|
||||
/// One 300×200 page with two lines of Helvetica text.
|
||||
fn two_lines() -> Vec<u8> {
|
||||
let content = "BT /F1 14 Tf 20 150 Td (The quick brown fox) Tj 0 -24 Td (jumps over the dog) Tj ET";
|
||||
format!(
|
||||
"%PDF-1.7
|
||||
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
|
||||
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >> endobj
|
||||
4 0 obj << /Length {} >> stream
|
||||
{content}
|
||||
endstream endobj
|
||||
5 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> endobj
|
||||
trailer << /Root 1 0 R >>
|
||||
%%EOF",
|
||||
content.len()
|
||||
)
|
||||
.into_bytes()
|
||||
}
|
||||
|
||||
/// The page on screen and the pointer over the "u" of "quick". Frames are 1/60 s: a click takes
|
||||
/// two frames (press, release), and egui counts a double-click only within 0.3 s.
|
||||
fn harness(setup: impl FnOnce(&mut PdfCraftApp) + 'static) -> (Harness<'static, PdfCraftApp>, Pos2) {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_eframe(move |_cc| {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.open_bytes("lines.pdf", None, two_lines()).expect("opens");
|
||||
app.set_option("left", "closed").unwrap();
|
||||
app.set_option("author", "Tester").unwrap();
|
||||
setup(&mut app);
|
||||
app
|
||||
});
|
||||
for _ in 0..200 {
|
||||
h.run_steps(2);
|
||||
if !h.state().render_pending() {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
let at = h.state().views[0].glyph_screen_pos(0, 5).expect("text layer loaded");
|
||||
h.hover_at(at);
|
||||
h.run_steps(1);
|
||||
(h, at)
|
||||
}
|
||||
|
||||
/// Press and release without moving or leaving the page.
|
||||
fn click(h: &mut Harness<'static, PdfCraftApp>, pos: Pos2) {
|
||||
for pressed in [true, false] {
|
||||
h.event(Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
|
||||
}
|
||||
h.run_steps(1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quick_clicks_select_a_word_then_the_line_then_the_page() {
|
||||
let (mut h, at) = harness(|_| {});
|
||||
let page = "The quick brown fox\njumps over the dog";
|
||||
for (n, want) in [None, Some("quick"), Some("The quick brown fox"), Some(page), Some(page)].into_iter().enumerate() {
|
||||
click(&mut h, at);
|
||||
assert_eq!(h.state().views[0].selected_text().as_deref(), want, "after {} clicks", n + 1);
|
||||
}
|
||||
// A pause ends the run: the next double-click selects a word again.
|
||||
h.run_steps(60);
|
||||
click(&mut h, at);
|
||||
click(&mut h, at);
|
||||
assert_eq!(h.state().views[0].selected_text().as_deref(), Some("quick"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quick_clicks_with_the_highlighter_mark_only_the_word() {
|
||||
let (mut h, at) = harness(|app| assert!(app.execute("comment.highlight")));
|
||||
for _ in 0..4 {
|
||||
click(&mut h, at);
|
||||
}
|
||||
h.run_steps(3);
|
||||
let app = h.state();
|
||||
let doc = app.session.get(app.views[0].id).unwrap();
|
||||
let marks: Vec<(&str, usize)> = doc.info.annotations.iter().map(|a| (a.subtype.as_str(), a.quads.len())).collect();
|
||||
assert_eq!(marks, [("Highlight", 1)], "one highlight, on the double-clicked word");
|
||||
assert!(app.views[0].selected_text().is_none());
|
||||
}
|
||||
@@ -1466,8 +1466,8 @@ milestone = "M3"
|
||||
status = "shipped"
|
||||
commands = []
|
||||
tools = []
|
||||
evidence = ["crates/ui-egui/tests/ui.rs::drag_selects_text_and_copy_returns_it", "crates/ui-egui/tests/ui.rs::selection_works_in_every_view_rotation"]
|
||||
notes = ""
|
||||
evidence = ["crates/ui-egui/tests/ui.rs::drag_selects_text_and_copy_returns_it", "crates/ui-egui/tests/ui.rs::selection_works_in_every_view_rotation", "crates/ui-egui/tests/text_select.rs::quick_clicks_select_a_word_then_the_line_then_the_page", "crates/ui-egui/tests/text_select.rs::quick_clicks_with_the_highlighter_mark_only_the_word"]
|
||||
notes = "Drag to select; with the Select tool, quick clicks select a word (two), the line (three) or all the text on the page (four). The markup and Redact tools mark a double-clicked word and stop there. Missing: paragraph selection, and ⌘A across the whole document (it selects the current page)."
|
||||
|
||||
[[feature]]
|
||||
id = "view.copy-text"
|
||||
|
||||
Reference in New Issue
Block a user