M3, #740: column select (Alt/Option-drag, or Edit ▸ Column select) (#765)

As in Acrobat, holding Alt (Option on macOS) when a drag starts selects only
the text inside the rectangle drawn, instead of everything between two
points in reading order. It works with the Select tool, the text markup
tools and Redact, so a column can be copied, highlighted or redacted.

- render: PageText::glyphs_in (glyph centre inside the rectangle),
  glyph_rects (one box per run, never covering unselected text) and
  column_text: rows top to bottom, side-by-side pieces separated by a tab,
  right to left in right-to-left rows, reading order for vertical text.
- ui-egui: a selection is a reading-order run or a column rectangle; the
  rectangle is outlined while dragging and the cursor is a crosshair.
  ⇧-click clears a column selection (it has no anchor glyph).
- Edit ▸ Column select (edit.column_select, quick tool "column-select")
  does it with a plain drag. It is the way in on Linux desktops whose
  window manager takes Alt-drag for moving windows (Xfce, Cinnamon, MATE,
  KDE Plasma 5 by default), where the app never sees the drag.
- automation: text_extract takes rect for the same extraction headless.
- "Column select" translated in all 15 catalogs (Arabic included); their ATTRIBUTION.toml
  hashes updated.
- parity: new view.column-select entry with platform notes and gaps
  (no selection across pages, no ⇧-click extension).

Windows: winit hands an Alt release to the system menu loop only when the
window has a native menu, which PdfCraft's does not, so Alt-drag cannot
leave the window in menu mode.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-10-10 16:41:27 -04:00
1 parent 2a02d5ddab
commit 65e89aefb2
28 files changed
+553 -49

No files matched your search

+15 -15
View File
@@ -19,7 +19,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "da2072af0bc5ac8f4b638cf2bd22ea81edddad32e534d3284ebeac205fb3d5a2"
sha256 = "90c0df152b440991ba6654812961dd885674e9b9e93c37d66a3d241df71bd28a"
[[asset]]
path = "crates/ui-egui/src/i18n/zh-hant.tsv"
@@ -30,7 +30,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "816a5e459259a8f2f4aa780157b63d4ab9098a34b2166957c13bbedda3cdbbb9"
sha256 = "2d4027cb7577efab49f523879873fefda78efb743e091a178fa1f92df37203cc"
[[asset]]
path = "crates/ui-egui/src/i18n/cs.tsv"
@@ -41,7 +41,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Czech UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "ca9722c4240eb202085ac26c0c0db534ccd3db225ee3331675cd180f28d3dc2b"
sha256 = "f973d0a66f4dae804bd308ac828ef5688c2336fd787724104253f72c1fab7446"
[[asset]]
path = "crates/ui-egui/src/i18n/pt-br.tsv"
@@ -52,7 +52,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "505a9def1e292d39e2973b2e1039a4d272b87ede3df844fb182c68de6a438894"
sha256 = "bd2d867653f3b5089941e52c1964028d91c164138e7142728b0d81e4b8dd295c"
[[asset]]
path = "crates/ui-egui/src/i18n/es.tsv"
@@ -63,7 +63,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "8dfca1bb531bef33ca1e0fcb001d548a064dfbf58d67301545b9b4e630a76695"
sha256 = "38f53f6e2413fddb747c362a1354d6627ded42f9bfc1e718cc61b90eeec5b980"
[[asset]]
path = "crates/ui-egui/src/i18n/fr.tsv"
@@ -74,7 +74,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "c6c0b185b418af93cd1a1fff6126483bc3a70147b2e97a4b14a76bcc712b6f01"
sha256 = "4f58ca9ef332e525277bdf6f9a8259d34b38f2c1607b1b5952fcc5e341c990ab"
[[asset]]
path = "crates/ui-egui/src/i18n/de.tsv"
@@ -85,7 +85,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "4287803114c8757aff013e559af65366ecf349e5ecc83bad90a2b5854a0aa971"
sha256 = "8f004e5abf2d5977771991b4f9e7296367b7e3911e5460e599550ce45c5a603e"
[[asset]]
path = "crates/ui-egui/src/i18n/te.tsv"
@@ -96,7 +96,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "abf2d9d5b3f1a216784585dd13d2e9052edd0b8f77fee2650e4ee2c0b5154fb7"
sha256 = "2454a4c6c6825b53ed259927bfc5ad914b0f7b88bb9418ccbb6c60053f4f58d0"
[[asset]]
path = "crates/ui-egui/src/i18n/it.tsv"
@@ -107,7 +107,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Italian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "194b4aadbafeeba02e8885dbce008c356b398d6bfff144e9ac30ad3796066775"
sha256 = "8c9e0815bb64a06f3cd3536649560183ba17a02928d6aed834ba895b8a51c0fc"
[[asset]]
path = "crates/ui-egui/src/i18n/ru.tsv"
@@ -118,7 +118,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "ce8a974c0e05aad532e6a91d604d132f278f1554966b8498e0850eb527918fe7"
sha256 = "0c794ec4c554485a4f306010550516ae1e4d05ddf9a4f707f01a023839e516f0"
[[asset]]
path = "crates/ui-egui/src/i18n/hu.tsv"
@@ -129,7 +129,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "09d3c394a5e5d1fb1674bc7356254055469daa5499324f595119b7fed73aba7c"
sha256 = "667849f4f8082ab6622c8d644fdc29f6db0cc05035c5b93faab2ee0e5b0b1bf6"
[[asset]]
path = "crates/ui-egui/src/i18n/bg.tsv"
@@ -140,7 +140,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "f0fe873d28d4ffdd613847199b2285f73350536f5e70fefccb357e226192fc93"
sha256 = "ad3ce6eb44c7b5b10180c574f6c91ef6e56eac833a1b385afae873ad794612cc"
[[asset]]
path = "crates/ui-egui/src/i18n/ja.tsv"
@@ -151,7 +151,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "ca8fef2dad11a3b5c5dcc759676601ee46929d6b7e99c8e62418b9bafe86b0f5"
sha256 = "895ec4f4814f74bcadc3c949735fa3cf4907255e9807367fe2c7a5e2875e5764"
[[asset]]
path = "crates/ui-egui/src/i18n/uk.tsv"
@@ -162,7 +162,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "bd314fdd94d13a07a9e6cd38ebc10d91a99a51fec49bdbcb0a3743b797104c9c"
sha256 = "89fd25329037709bac9dafbdf63e08ee22d4a5d3d99c70671614e5e5e0ac65a5"
[[asset]]
path = "crates/ui-egui/src/i18n/ar.tsv"
@@ -173,7 +173,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "0b608fdeaef7087158d64cab06cade594b8fb6d9f1fc6b87a261ccd8cec26681"
sha256 = "fad8447c713a75691b29b4395d55368a6154df568b5bb67a26eaee00bdfb6459"
[[asset]]
path = "assets/icons/accessibility.svg"
+1
View File
@@ -145,6 +145,7 @@ Newest first: the date, what moved, and the new overall estimate.
- **2026-10-10 (M10.2, JPEG scanning follow-up to #729):** Skip marker fill bytes and keep reading JFIF, APP14 and ICC metadata after the frame header, stopping before scan data or at EOI. Invalid segment lengths return errors; damaged or mismatched ICC profiles retain #729's device-space fallback. Synthetic regression cases added but not run at the contributor's request. Estimates unchanged.
- **2026-10-10 (M13, desktop action outputs):** Desktop Action Wizard keeps every result when input basenames repeat, or when the destination already has a result from an earlier run. It preserves the original filename and extension for a free name and adds numbered suffixes for occupied names; the bounded search returns an error rather than reusing an occupied path. A real-shell regression drives Start twice and reopens the three-page and one-page results, checking that the original inputs and earlier output remain intact. This covers the desktop writer separately from the CLI/MCP action_run repair in #877. Milestone and effort estimates unchanged.
- **2026-10-10 (M3, #740):** Column select, as in Acrobat: holding Alt (Option on macOS) when a drag starts selects only the text inside the rectangle, with the Select tool, the text markup tools and Redact; Edit ▸ Column select does it with a plain drag, for Linux desktops whose window manager takes Alt-drag (Xfce, Cinnamon, MATE, KDE Plasma 5 by default). Copy gives rows top to bottom with tab-separated cells (right to left in right-to-left rows, reading order for vertical text); `text_extract` takes the same rectangle as `rect`. The new label is in all 15 catalogs. Also repaired `parity/acrobat-features.toml`, which #650 had left unparseable (duplicate keys on `form.field-list`). Still missing: column selection across pages and ⇧-click extension. Estimates unchanged.
- **2026-10-10 (progress docs, hours per audience):** Each readiness number gets its own hours to ≈ 95%: full ≈ 49% / 780–1,400 h, mainstream ≈ 55% / 500–900 h, essentials ≈ 66% / 180–330 h. The full number is now the standard's additive weighted sum over the dimensions (written weights), 47% → 49%: method aligned with the standard, no new evidence. Beta ≈ 26 points, ≈ 400–700 h.
- **2026-10-10 (progress docs, gate consistency check):** Re-judged the convert-and-share row with the rule applied across all Crafting Apps (a workflow blocks only when it can't be completed at all; lossy exchange and missing sub-cases are beta items, as for FilmCraft's .prproj or VectorCraft's .ai). Unencrypted sharing works, encryption interop (#774) and Office→PDF are sub-cases, Word export is lossy rather than absent: partial, not blocking. **Stage pre-alpha → alpha**; numbers unchanged (47%, mainstream 55%; beta ≈ 400–700 h). Added an essentials-user score: ≈ 66% for someone who only opens, reads, comments, fills, signs, prints and rearranges pages.
- **2026-10-10 (progress docs, second look and alpha gate):** At the owner's request, after practitioners praised PdfCraft, re-examined the numbers. User sentiment: 7,595 stars, 177k + 39k downloads of 0.4.0/0.5.0, 23 user issues with explicit praise and switching reports (#179, #232, #273), issue themes concentrated in core paths (launch/GPU, rendering, editing, CJK, signing, printing). The checklist is not niche-dominated (497 mainstream vs 311 specialist rows; P0 95% / 91%). The first pass's 9-point cross-cutting deduction double-counted Windows printing, interop and glyphs: **ready for real work 40% → 47%**, and **≈ 55% for the mainstream practitioner** (new). The new core-workflow gate (craftrules progress-docs) fails on convert and share (#774, no Office import, #773): **stage alpha → pre-alpha**, ≈ 40–70 h from alpha; beta still ≈ 400–700 h.
+7 -1
View File
@@ -1453,8 +1453,14 @@ impl Automation {
Some(_) => self.pages(a, "pages")?,
None => (0..doc.info.pages.len()).collect(),
};
// Column select (#740): only the text inside the rectangle, row by row.
let rect = a.nums::<4>("rect")?.map(|r| r.map(|v| v as f32));
let texts = self.page_texts(id, &pages)?;
let out: Vec<Value> = pages.iter().zip(texts).map(|(p, t)| json!({ "page": p + 1, "text": t.plain_text() })).collect();
let text = |t: &PageText| match rect {
Some(r) => t.column_text(&t.glyphs_in(r)),
None => t.plain_text(),
};
let out: Vec<Value> = pages.iter().zip(texts).map(|(p, t)| json!({ "page": p + 1, "text": text(&t) })).collect();
Ok(json!({ "pages": out }))
}
+9 -2
View File
@@ -150,9 +150,16 @@ pub fn tools() -> Vec<ToolDef> {
t("page_render", "Render a page", "Render one page to a PNG image (default 96 dpi, at most 600).")
.ro()
.with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "dpi": { "type": "number", "minimum": 1, "maximum": 600 } }), &["doc", "page"])),
t("text_extract", "Extract text", "Extract the text of some or all pages, in reading order.")
t("text_extract", "Extract text", "Extract the text of some or all pages, in reading order. With rect, only the text inside that rectangle on each page, as Column select takes it: one row per visual line, side-by-side pieces (table cells) separated by a tab.")
.ro()
.with(schema(json!({ "doc": doc(), "pages": pages("to extract (default: all)") }), &["doc"])),
.cmd("edit.column_select")
.with(schema(
json!({
"doc": doc(), "pages": pages("to extract (default: all)"),
"rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4, "description": "Column select: [x0, y0, x1, y1] in points from the top-left of the displayed page; a glyph counts when its centre is inside." }
}),
&["doc"],
)),
t("text_find", "Find text", "Find a phrase (case-insensitive, whitespace-normalised) and return each match with its page and line rectangles in points (origin top-left).")
.ro()
.cmd("edit.find")
+65
View File
@@ -3723,3 +3723,68 @@ fn a_deleted_image_is_not_kept_by_reduce_or_optimize() {
let reopened = ok(&mut a, "doc_open", json!({ "path": "reduced.pdf" }));
assert_eq!(reopened["pages"], 1);
}
/// A two-page PDF with the same three-row table ("Name Qty / Apple 12 / Pear 7"); the second
/// page is turned with /Rotate 90.
fn table_pdf() -> Vec<u8> {
let body = "BT /F1 12 Tf 20 250 Td (Name) Tj 130 0 Td (Qty) Tj -130 -20 Td (Apple) Tj 130 0 Td (12) Tj -130 -20 Td (Pear) Tj 130 0 Td (7) Tj ET";
let objs = [
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
"<< /Type /Pages /Kids [4 0 R 5 0 R] /Count 2 /MediaBox [0 0 300 300] >>".to_string(),
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_string(),
"<< /Type /Page /Parent 2 0 R /Contents 6 0 R /Resources << /Font << /F1 3 0 R >> >> >>".to_string(),
"<< /Type /Page /Parent 2 0 R /Rotate 90 /Contents 6 0 R /Resources << /Font << /F1 3 0 R >> >> >>".to_string(),
format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()),
];
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
out
}
/// Issue #740: `text_extract` with `rect` takes what Column select takes, in the displayed-page
/// coordinates `text_find` reports, on upright and turned pages alike.
#[test]
fn text_extract_with_a_rect_takes_one_column() {
let dir = workdir("column");
std::fs::write(dir.join("table.pdf"), table_pdf()).unwrap();
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({ "path": "table.pdf" }))["doc"].as_u64().unwrap();
let reading = ok(&mut a, "text_extract", json!({ "doc": doc, "pages": [1] }));
assert_eq!(reading["pages"][0]["text"], "Name\nApple\nPear\nQty\n12\n7", "reading order is column by column");
for page in [1u64, 2] {
// The box around "Qty" and "7", as text_find reports them on this page.
let rect_of = |a: &mut Automation, q: &str| -> [f64; 4] {
let found = ok(a, "text_find", json!({ "doc": doc, "query": q }));
let m = found["matches"].as_array().unwrap().iter().find(|m| m["page"] == page).unwrap().clone();
let r: Vec<f64> = m["rects"][0].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect();
[r[0], r[1], r[2], r[3]]
};
let (qty, seven) = (rect_of(&mut a, "Qty"), rect_of(&mut a, "7"));
let rect = [qty[0].min(seven[0]) - 2.0, qty[1].min(seven[1]) - 2.0, qty[2].max(seven[2]) + 2.0, qty[3].max(seven[3]) + 2.0];
let column = ok(&mut a, "text_extract", json!({ "doc": doc, "pages": [page], "rect": rect }));
assert_eq!(column["pages"][0]["text"], "Qty\n12\n7", "page {page}, rect {rect:?}");
}
// The whole table, row by row with tab-separated cells (upright page).
let all = ok(&mut a, "text_extract", json!({ "doc": doc, "pages": [1], "rect": [0, 0, 300, 300] }));
assert_eq!(all["pages"][0]["text"], "Name\tQty\nApple\t12\nPear\t7");
// Nothing inside: empty text, not an error.
let none = ok(&mut a, "text_extract", json!({ "doc": doc, "pages": [1], "rect": [0, 0, 5, 5] }));
assert_eq!(none["pages"][0]["text"], "");
// A rect that is not four numbers is refused.
for bad in [json!([1, 2, 3]), json!("0 0 1 1"), json!([0, 0, "a", 1])] {
let e = a.call("text_extract", &json!({ "doc": doc, "rect": bad })).unwrap_err();
assert!(matches!(e, ToolError::InvalidArgs(_)), "{bad}: {e}");
}
}
+4
View File
@@ -201,6 +201,10 @@ pub const COMMANDS: &[CommandSpec] = &[
// View menu (where it would show twice). The palette and ⌘3 still run it.
c("view.fit_visible", "Fit visible", None, Some(Shortcut::cmd("3")), Document, "scan"),
c("view.marquee_zoom", "Marquee zoom", VIEW, None, Document, "zoom-in"),
// Acrobat's column select: drag a rectangle and select only the text inside it. Alt/Option-
// drag with the Select tool does the same; this is the way in where the desktop takes
// Alt-drag for moving windows (several Linux window managers).
c("edit.column_select", "Column select", EDIT, None, Document, "text-select"),
c("edit.snapshot", "Take a snapshot", EDIT, None, Document, "camera"),
c("view.full_screen", "Full screen mode", VIEW, Some(Shortcut::cmd("L")), Document, "maximize"),
c("view.read_mode", "Read mode", VIEW, Some(Shortcut { command: true, shift: false, mac_ctrl: true, key: "H" }), Document, "book-open"),
+186 -11
View File
@@ -213,21 +213,115 @@ impl PageText {
/// 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();
for i in range {
let Some(g) = self.glyphs.get(i) else { break };
let line = self.line_of[i];
self.merged_rects(range)
}
/// One rectangle per run of consecutive glyphs on one line, for glyph indices in ascending
/// order (a reading-order range, or [`Self::glyphs_in`]). A run stops at a skipped glyph, so
/// the rectangle never covers text that is not selected.
pub fn glyph_rects(&self, glyphs: &[usize]) -> Vec<[f32; 4]> {
self.merged_rects(glyphs.iter().copied())
}
fn merged_rects(&self, glyphs: impl IntoIterator<Item = usize>) -> Vec<[f32; 4]> {
let mut out: Vec<(u32, usize, [f32; 4])> = Vec::new();
for i in glyphs {
let (Some(g), Some(&line)) = (self.glyphs.get(i), self.line_of.get(i)) else { break };
match out.last_mut() {
Some((l, r)) if *l == line => {
r[0] = r[0].min(g.rect[0]);
r[1] = r[1].min(g.rect[1]);
r[2] = r[2].max(g.rect[2]);
r[3] = r[3].max(g.rect[3]);
Some((l, last, r)) if *l == line && last.checked_add(1) == Some(i) => {
*last = i;
union(r, &g.rect);
}
_ => out.push((line, g.rect)),
_ => out.push((line, i, g.rect)),
}
}
out.into_iter().map(|(_, r)| r).collect()
out.into_iter().map(|(_, _, r)| r).collect()
}
/// The glyphs whose centre lies inside `rect` (view space, corners in either order), in
/// reading order: what a column selection covers (Alt/Option-drag, Acrobat's column select).
/// A glyph only partly inside counts when its centre is, so a rectangle drawn a little into
/// the next column does not pick up the edge of its text.
pub fn glyphs_in(&self, rect: [f32; 4]) -> Vec<usize> {
let (x0, x1) = (rect[0].min(rect[2]), rect[0].max(rect[2]));
let (y0, y1) = (rect[1].min(rect[3]), rect[1].max(rect[3]));
let inside = |g: &TextGlyph| {
let (cx, cy) = ((g.rect[0] + g.rect[2]) / 2.0, (g.rect[1] + g.rect[3]) / 2.0);
(x0..=x1).contains(&cx) && (y0..=y1).contains(&cy)
};
self.glyphs.iter().enumerate().filter(|(_, g)| inside(g)).map(|(i, _)| i).collect()
}
/// The text of a column selection ([`Self::glyphs_in`]) as it looks on the page: one row per
/// visual line, top to bottom. Pieces of different lines that sit side by side (table cells,
/// newspaper columns) share a row and are separated by a tab, so a pasted table lands in
/// spreadsheet cells. Rows of right-to-left text put their pieces right to left. When any
/// selected glyph does not run horizontally (CJK vertical writing, turned text, a page shown
/// turned by `/Rotate`) rows mean nothing, and the lines come out one per row in reading order
/// instead.
pub fn column_text(&self, glyphs: &[usize]) -> String {
// The selected glyphs of each line, with their box: (glyphs, bbox).
let mut lines: Vec<(Vec<usize>, [f32; 4])> = Vec::new();
let mut line_ids: Vec<u32> = Vec::new();
for &i in glyphs {
let (Some(g), Some(&line)) = (self.glyphs.get(i), self.line_of.get(i)) else { continue };
match (lines.last_mut(), line_ids.last()) {
(Some((members, bb)), Some(&l)) if l == line => {
members.push(i);
union(bb, &g.rect);
}
_ => {
lines.push((vec![i], g.rect));
line_ids.push(line);
}
}
}
// Within the layout's own tolerance for reading a glyph as unturned.
let vertical = glyphs.iter().any(|&i| self.glyphs.get(i).is_some_and(|g| g.direction[1].abs() > WORD_GAP));
let piece = |members: &[usize]| {
let mut s = String::new();
let mut prev: Option<usize> = None;
for &i in members {
let Some(g) = self.glyphs.get(i) else { continue };
let gap = self.space_before.get(i).copied().unwrap_or(false) || prev.and_then(|p| p.checked_add(1)) != Some(i);
if prev.is_some() && gap {
s.push(' ');
}
s.push_str(&g.text);
prev = Some(i);
}
s
};
if vertical {
return lines.iter().map(|(members, _)| piece(members)).collect::<Vec<_>>().join("\n");
}
// Rows: a line joins the row whose first line its vertical centre falls within.
let mut order: Vec<usize> = (0..lines.len()).collect();
order.sort_by(|a, b| {
let cy = |k: usize| lines.get(k).map_or(0.0, |(_, bb)| (bb[1] + bb[3]) / 2.0);
cy(*a).total_cmp(&cy(*b))
});
let mut rows: Vec<(f32, f32, Vec<usize>)> = Vec::new();
for k in order {
let Some((_, bb)) = lines.get(k) else { continue };
let cy = (bb[1] + bb[3]) / 2.0;
match rows.last_mut() {
Some((top, bottom, members)) if cy >= *top && cy <= *bottom => members.push(k),
_ => rows.push((bb[1], bb[3], vec![k])),
}
}
let rtl = |members: &[usize]| members.iter().any(|&i| self.glyphs.get(i).is_some_and(|g| g.text.chars().any(is_rtl)));
let mut out = Vec::with_capacity(rows.len());
for (_, _, mut members) in rows {
let right_to_left = members.iter().all(|&k| lines.get(k).is_some_and(|(m, _)| rtl(m)));
members.sort_by(|a, b| {
let x = |k: usize| lines.get(k).map_or(0.0, |(_, bb)| bb[0]);
if right_to_left { x(*b).total_cmp(&x(*a)) } else { x(*a).total_cmp(&x(*b)) }
});
let cells: Vec<String> = members.iter().filter_map(|&k| lines.get(k)).map(|(m, _)| piece(m)).collect();
out.push(cells.join("\t"));
}
out.join("\n")
}
}
@@ -1203,4 +1297,85 @@ mod tests {
let slant = skewed.quad[3][0] - skewed.quad[0][0];
assert!((slant - 10.0).abs() < 1e-3, "the skewed glyph's upright edge leans by half its 20 pt height: {skewed:?}");
}
/// A three-row table, "Name Qty / Apple 12 / Pear 7": the columns sit far enough apart that
/// reading order takes one column, then the other.
fn table() -> PageText {
let mut v = Vec::new();
for (row, (name, qty)) in [("Name", "Qty"), ("Apple", "12"), ("Pear", "7")].into_iter().enumerate() {
let y = 10.0 + row as f32 * 14.0;
word(&mut v, name, 10.0, y, 6.0);
word(&mut v, qty, 100.0, y, 6.0);
}
layout(v)
}
/// Issue #740: a column selection takes only what is inside the rectangle.
#[test]
fn a_column_selection_takes_one_table_column() {
let t = table();
assert_eq!(t.plain_text(), "Name\nApple\nPear\nQty\n12\n7", "reading order is column by column");
let qty = t.glyphs_in([95.0, 0.0, 130.0, 60.0]);
assert_eq!(t.column_text(&qty), "Qty\n12\n7");
assert_eq!(t.glyph_rects(&qty).len(), 3, "one highlight per row");
// Corners in either order.
assert_eq!(t.glyphs_in([130.0, 60.0, 95.0, 0.0]), qty);
}
/// Across columns the text comes out row by row, cells separated by tabs.
#[test]
fn a_column_selection_across_columns_reads_row_by_row() {
let t = table();
let all = t.glyphs_in([0.0, 0.0, 200.0, 60.0]);
assert_eq!(t.column_text(&all), "Name\tQty\nApple\t12\nPear\t7");
// The middle row only, and only the start of "Apple": centres decide.
let part = t.glyphs_in([0.0, 25.0, 31.0, 33.0]);
assert_eq!(t.column_text(&part), "Appl");
assert_eq!(t.glyph_rects(&part), vec![[10.0, 24.0, 34.0, 34.0]]);
}
/// A rectangle that misses every glyph, or is not a number, selects nothing.
#[test]
fn an_empty_or_degenerate_column_selection_is_empty() {
let t = table();
for r in [[300.0, 300.0, 400.0, 400.0], [f32::NAN, 0.0, 200.0, 60.0], [50.0, 50.0, 50.0, 50.0]] {
let none = t.glyphs_in(r);
assert!(none.is_empty(), "{r:?}");
assert_eq!(t.column_text(&none), "");
assert!(t.glyph_rects(&none).is_empty());
}
assert_eq!(PageText::default().column_text(&[0, 7, usize::MAX]), "", "indices past the text are ignored");
}
/// Right-to-left rows put their cells right to left.
#[test]
fn right_to_left_rows_read_right_to_left() {
let mut v = Vec::new();
// "שלום" (right) and "עולם" (left), each drawn visually.
word(&mut v, "םולש", 100.0, 10.0, 6.0);
word(&mut v, "םלוע", 10.0, 10.0, 6.0);
let t = layout(v);
let all = t.glyphs_in([0.0, 0.0, 200.0, 30.0]);
assert_eq!(t.column_text(&all), "שלום\tעולם");
}
/// Vertical lines (CJK vertical writing) come out one per row, in reading order.
#[test]
fn vertical_lines_keep_reading_order() {
// Written downwards: the em box's edge towards the next line is its left one.
let cell = |t: &str, x: f32, y: f32| TextGlyph {
text: t.into(),
rect: [x, y, x + 10.0, y + 10.0],
quad: [[x, y], [x, y + 10.0], [x + 10.0, y + 10.0], [x + 10.0, y]],
direction: [0.0, 1.0],
};
let t = PageText {
// Right column 日本 first, then the left column 語文, each top to bottom.
glyphs: vec![cell("日", 40.0, 10.0), cell("本", 40.0, 20.0), cell("語", 20.0, 10.0), cell("文", 20.0, 20.0)],
line_of: vec![0, 0, 1, 1],
space_before: vec![false; 4],
};
let all = t.glyphs_in([0.0, 0.0, 60.0, 40.0]);
assert_eq!(t.column_text(&all), "日本\n語文");
}
}
+80 -20
View File
@@ -196,17 +196,45 @@ pub struct Find {
pub in_panel: bool,
}
/// A text selection on one page, in reading-order glyph indices.
/// A text selection on one page.
#[derive(Clone, Copy, Debug, PartialEq)]
struct Selection {
page: usize,
anchor: usize,
head: usize,
span: Span,
}
#[derive(Clone, Copy, Debug, PartialEq)]
enum Span {
/// Reading order, between two glyph indices.
Run { anchor: usize, head: 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] },
}
impl Selection {
fn range(&self) -> Range<usize> {
self.anchor.min(self.head)..self.anchor.max(self.head) + 1
fn run(page: usize, anchor: usize, head: usize) -> Self {
Self { page, span: Span::Run { anchor, head } }
}
fn range(anchor: usize, head: usize) -> Range<usize> {
anchor.min(head)..anchor.max(head).saturating_add(1)
}
/// One rectangle per selected piece of a line, in view space (highlighting, markup quads).
fn rects(&self, 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)),
}
}
/// The selected text, as Copy puts it on the clipboard.
fn text(&self, 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)),
}
}
}
@@ -686,7 +714,7 @@ impl DocView {
let s = self.selection?;
let text = self.texts.get(&s.page)?;
let page = info.pages.get(s.page)?;
let quads: Vec<[f64; 8]> = text.line_rects(s.range()).into_iter().map(|r| page.view_rect_to_quad(r)).collect();
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))
}
@@ -711,7 +739,7 @@ impl DocView {
/// Select text on `page` from glyph `from` to glyph `to` (tests and automation).
pub fn select_text(&mut self, page: usize, from: usize, to: usize) {
self.selection = Some(Selection { page, anchor: from, head: to });
self.selection = Some(Selection::run(page, from, to));
}
/// Open the find bar (or focus it if already open).
@@ -802,7 +830,7 @@ impl DocView {
pub fn selected_text(&self) -> Option<String> {
let s = self.selection?;
let t = self.texts.get(&s.page)?;
Some(t.text_of(s.range())).filter(|x| !x.is_empty())
Some(s.text(t)).filter(|x| !x.is_empty())
}
pub fn thumb(&self, page: usize) -> Option<&TextureHandle> {
@@ -952,7 +980,7 @@ impl DocView {
if t.glyphs.is_empty() {
return false;
}
self.selection = Some(Selection { page, anchor: 0, head: t.glyphs.len() - 1 });
self.selection = Some(Selection::run(page, 0, t.glyphs.len() - 1));
true
}
@@ -1890,7 +1918,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
let selects_text = match tool {
QuickTool::Comment(t) => t.markup().is_some() || t == comments::CommentTool::ReplaceText,
QuickTool::Select => !preparing,
QuickTool::Redact => true,
QuickTool::Redact | QuickTool::ColumnSelect => true,
QuickTool::Hand
| QuickTool::Measure(_)
| QuickTool::Crop
@@ -2295,9 +2323,16 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
}
}
if let Some(sel) = view.selection.filter(|s| s.page == i) {
for lr in text.line_rects(sel.range()) {
for lr in sel.rects(&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.
if let Span::Column { rect } = sel.span
&& resp.dragged()
{
let outline = Stroke::new(1.0, Color32::from_rgb(0x3A, 0x7B, 0xF0));
painter.rect_stroke(to_screen(rect), CornerRadius::ZERO, outline, egui::StrokeKind::Middle);
}
}
if selects_text
&& !consumed
@@ -2306,20 +2341,44 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
let (vx, vy) = xf.screen_to_view(p);
let over_text = text.glyphs.iter().any(|g| vx >= g.rect[0] && vx <= g.rect[2] && vy >= g.rect[1] && vy <= g.rect[3]);
let over_link = info.links.iter().any(|l| l.page == i && xf.user_rect(info, i, l.rect).contains(p));
if over_text && !over_link {
// Column select (#740): the Column select tool, or Alt (Option on macOS) held
// while a drag starts, as in Acrobat.
let column = tool == QuickTool::ColumnSelect || ui.input(|inp| inp.modifiers.alt);
if column && !over_link {
ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair);
} else if over_text && !over_link {
ui.ctx().set_cursor_icon(egui::CursorIcon::Text);
}
let press_here = ui.input(|inp| inp.pointer.press_origin()).is_some_and(|o| r.contains(o));
if resp.drag_started() && press_here && !over_link {
let origin = ui.input(|inp| inp.pointer.press_origin()).unwrap_or(p);
let (ox, oy) = xf.screen_to_view(origin);
view.selection = text.nearest(ox, oy).map(|a| Selection { page: i, anchor: a, head: a });
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))
};
}
if resp.dragged()
&& let Some(sel) = view.selection.as_mut().filter(|s| s.page == i)
&& let Some(h) = text.nearest(vx, vy)
{
sel.head = h;
match &mut sel.span {
Span::Run { head, .. } => {
if let Some(h) = text.nearest(vx, vy) {
*head = h;
}
}
Span::Column { rect } => {
rect[2] = vx;
rect[3] = vy;
}
}
}
// 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 = None;
}
// 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
@@ -2342,15 +2401,16 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
_ => 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 });
view.selection = Some(Selection::run(i, first, last));
}
} else if resp.clicked() && !over_link {
// ⇧-click extends a selection on this page to the click, keeping its
// anchor (#527). Otherwise (no shift, or no selection on this page; a
// selection cannot yet span pages) a click clears the selection.
// 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.
let shift = ui.input(|inp| inp.modifiers.shift);
match (view.selection.as_mut().filter(|s| shift && s.page == i), text.nearest(vx, vy)) {
(Some(sel), Some(h)) => sel.head = h,
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,
_ => view.selection = None,
}
}
+1
View File
@@ -470,6 +470,7 @@ impl PdfCraftApp {
}
}
"view.marquee_zoom" => self.quick_tool = crate::QuickTool::MarqueeZoom,
"edit.column_select" => self.quick_tool = crate::QuickTool::ColumnSelect,
"edit.snapshot" => {
self.quick_tool = crate::QuickTool::Snapshot;
self.notify_tr("Drag a rectangle around the area to copy");
+1
View File
@@ -579,6 +579,7 @@ impl Host for crate::PdfCraftApp {
crate::QuickTool::SignArea { certify: true } => "certify".to_string(),
crate::QuickTool::MarqueeZoom => "marquee-zoom".to_string(),
crate::QuickTool::Snapshot => "snapshot".to_string(),
crate::QuickTool::ColumnSelect => "column-select".to_string(),
crate::QuickTool::Stamp(k) => format!("stamp-{}", k.name().trim_start_matches("PC").to_ascii_lowercase()),
crate::QuickTool::CustomStamp(i) => format!("custom-stamp-{i}"),
crate::QuickTool::Fill(f) => format!("fill-{}", f.command().trim_start_matches("sign.fill.")),
+1
View File
@@ -74,6 +74,7 @@
Fit visible ملاءمة المرئي
Marquee zoom تكبير بالتحديد
Take a snapshot التقاط لقطة
Column select تحديد عمود
Full screen mode وضع ملء الشاشة
Read mode وضع القراءة
Switch light / dark theme التبديل بين السمة الفاتحة والداكنة
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -109,6 +109,7 @@
Rotate view clockwise Завърти изгледа по часовниковата стрелка
Rotate view counterclockwise Завърти изгледа обратно на часовниковата стрелка
Take a snapshot Направи моментна снимка
Column select Избор на колона
Full screen mode Цял екран
Read mode Режим на четене
Switch light / dark theme Превключи светла / тъмна тема
Can't render this file because it contains an unexpected character in line 8 and column 16.
+1
View File
@@ -67,6 +67,7 @@
Find… Najít…
Advanced search… Rozšířené hledání…
Take a snapshot Pořídit snímek
Column select Výběr sloupce
Clear form Vymazat formulář
Copy pages Kopírovat stránky
Cut pages Vyjmout stránky
Can't render this file because it contains an unexpected character in line 7 and column 81.
+1
View File
@@ -44,6 +44,7 @@
Fit visible Sichtbaren Inhalt einpassen
Marquee zoom Auswahlrahmen-Zoom
Take a snapshot Schnappschuss erstellen
Column select Spaltenauswahl
Full screen mode Vollbildmodus
Read mode Lesemodus
Switch light / dark theme Helles / dunkles Design umschalten
Can't render this file because it contains an unexpected character in line 6 and column 51.
+1
View File
@@ -67,6 +67,7 @@
Fit visible Ajustar al contenido visible
Marquee zoom Zoom por selección
Take a snapshot Tomar una instantánea
Column select Selección de columna
Full screen mode Modo de pantalla completa
Read mode Modo de lectura
Switch light / dark theme Cambiar entre tema claro y oscuro
Can't render this file because it contains an unexpected character in line 6 and column 60.
+1
View File
@@ -44,6 +44,7 @@
Fit visible Ajuster au contenu visible
Marquee zoom Zoom par sélection
Take a snapshot Prendre un instantané
Column select Sélection de colonne
Full screen mode Mode plein écran
Read mode Mode lecture
Switch light / dark theme Basculer entre les thèmes clair et sombre
Can't render this file because it contains an unexpected character in line 6 and column 39.
+1
View File
@@ -44,6 +44,7 @@
Fit visible Látható tartalomhoz igazítás
Marquee zoom Kijelölő nagyítás
Take a snapshot Pillanatkép
Column select Oszlopkijelölés
Full screen mode Teljes képernyő
Read mode Olvasási mód
Switch light / dark theme Világos / sötét téma váltása
Can't render this file because it contains an unexpected character in line 7 and column 44.
+1
View File
@@ -70,6 +70,7 @@
Fit visible Adatta al visibile
Marquee zoom Ingrandimento selettivo
Take a snapshot Acquisisci istantanea
Column select Selezione colonna
Full screen mode Modalità schermo intero
Read mode Modalità lettura
Switch light / dark theme Alterna tema chiaro/scuro
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -73,6 +73,7 @@
Fit visible 表示範囲に合わせる
Marquee zoom 範囲指定ズーム
Take a snapshot スナップショットを作成
Column select 列の選択
Full screen mode 全画面表示
Read mode 閲覧モード
Switch light / dark theme 明るい/暗いテーマを切り替え
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -51,6 +51,7 @@
Fit visible Ajustar ao visível
Marquee zoom Zoom por seleção
Take a snapshot Capturar instantâneo
Column select Seleção de coluna
Full screen mode Modo de tela cheia
Read mode Modo de leitura
Switch light / dark theme Alternar tema claro / escuro
Can't render this file because it contains an unexpected character in line 9 and column 16.
+1
View File
@@ -65,6 +65,7 @@
Fit visible По видимой области
Marquee zoom Масштаб выделением
Take a snapshot Сделать снимок
Column select Выделение столбца
Full screen mode Полноэкранный режим
Read mode Режим чтения
Switch light / dark theme Переключить светлую / тёмную тему
Can't render this file because it contains an unexpected character in line 9 and column 16.
+1
View File
@@ -50,6 +50,7 @@ comment menu Edit సవరించు
Fit visible కనిపించే భాగానికి సరిపోయేలా
Marquee zoom ఎంచుకున్న ప్రాంతానికి జూమ్
Take a snapshot స్నాప్‌షాట్ తీయి
Column select నిలువు వరుస ఎంపిక
Full screen mode పూర్తి స్క్రీన్ మోడ్
Read mode చదివే మోడ్
Switch light / dark theme లేత / ముదురు థీమ్‌ల మధ్య మారు
Can't render this file because it contains an unexpected character in line 13 and column 16.
+1
View File
@@ -72,6 +72,7 @@
Fit visible За видимою областю
Marquee zoom Масштабувати виділену область
Take a snapshot Зробити знімок
Column select Виділення стовпця
Full screen mode Повноекранний режим
Read mode Режим читання
Switch light / dark theme Перемкнути світлу / темну тему
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -115,6 +115,7 @@
Rotate view clockwise 顺时针旋转视图
Rotate view counterclockwise 逆时针旋转视图
Take a snapshot 拍摄快照
Column select 列选择
Full screen mode 全屏模式
Read mode 阅读模式
Switch light / dark theme 切换浅色/深色主题
Can't render this file because it contains an unexpected character in line 9 and column 16.
+1
View File
@@ -74,6 +74,7 @@
Rotate view clockwise 順時針旋轉檢視
Rotate view counterclockwise 逆時針旋轉檢視
Take a snapshot 擷取快照
Column select 欄選取
Full screen mode 全螢幕模式
Read mode 閱讀模式
Switch light / dark theme 切換淺色/深色主題
Can't render this file because it contains an unexpected character in line 9 and column 16.
+4
View File
@@ -205,6 +205,9 @@ pub enum QuickTool {
MarqueeZoom,
/// Edit ▸ Take a Snapshot.
Snapshot,
/// Edit ▸ Column Select: drag a rectangle to select only the text inside it (#740). The
/// Select tool does the same while Alt/Option is held at the start of a drag.
ColumnSelect,
}
/// Files dropped on a document's page grid.
@@ -1668,6 +1671,7 @@ impl PdfCraftApp {
"sign" => QuickTool::SignArea { certify: false },
"marquee-zoom" => QuickTool::MarqueeZoom,
"snapshot" => QuickTool::Snapshot,
"column-select" => QuickTool::ColumnSelect,
"certify" => QuickTool::SignArea { certify: true },
custom if custom.starts_with("custom-stamp-") => {
let i: usize = custom[13..].parse().map_err(|_| format!("bad stamp {custom}"))?;
+153
View File
@@ -0,0 +1,153 @@
//! Issue #740: column select, as in Acrobat. Alt-drag (Option-drag on macOS) with the Select tool,
//! or a plain drag with Edit ▸ Column select, selects only the text inside the rectangle drawn.
use egui::{Event, Modifiers, PointerButton, Pos2, vec2};
use egui_kittest::Harness;
use pdfcraft_ui_egui::PdfCraftApp;
/// One 300×200 page with a three-row table, "Name Qty / Apple 12 / Pear 7". Its columns sit far
/// enough apart that reading order takes one column, then the other: glyphs 0–3 "Name", 4–8
/// "Apple", 9–12 "Pear", 13–15 "Qty", 16–17 "12", 18 "7".
fn table() -> Vec<u8> {
let content =
"BT /F1 14 Tf 20 150 Td (Name) Tj 130 0 Td (Qty) Tj -130 -24 Td (Apple) Tj 130 0 Td (12) Tj -130 -24 Td (Pear) Tj 130 0 Td (7) 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()
}
fn harness(setup: impl FnOnce(&mut PdfCraftApp) + 'static) -> Harness<'static, PdfCraftApp> {
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.set_option("language", "en").unwrap();
app.open_bytes("table.pdf", None, table()).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));
}
assert!(h.state().views[0].glyph_screen_pos(0, 0).is_some(), "text layer loaded");
h
}
fn glyph(h: &Harness<'static, PdfCraftApp>, g: usize) -> Pos2 {
h.state().views[0].glyph_screen_pos(0, g).expect("glyph on screen")
}
fn text(h: &Harness<'static, PdfCraftApp>) -> Option<String> {
h.state().views[0].selected_text()
}
/// Press at `from`, move to `to` and release there, with `modifiers` held throughout.
fn drag(h: &mut Harness<'static, PdfCraftApp>, from: Pos2, to: Pos2, modifiers: Modifiers) {
h.event(Event::ModifiersChanged(modifiers));
h.hover_at(from);
h.run_steps(1);
h.event(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers });
h.run_steps(1);
for k in 1..=4 {
h.hover_at(from + (to - from) * (k as f32 / 4.0));
h.run_steps(1);
}
h.event(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers });
h.run_steps(1);
h.event(Event::ModifiersChanged(Modifiers::NONE));
h.run_steps(1);
}
/// Below-right of the top two rows: past the "y" of "Qty" (the wider cell), level with "12".
fn below_right_of_row_two(h: &Harness<'static, PdfCraftApp>) -> Pos2 {
egui::pos2(glyph(h, 15).x.max(glyph(h, 17).x) + 12.0, glyph(h, 17).y + 8.0)
}
/// From above-left of "Name" to below-right of "12": the top two rows of both columns.
fn top_two_rows(h: &Harness<'static, PdfCraftApp>) -> (Pos2, Pos2) {
(glyph(h, 0) - vec2(12.0, 12.0), below_right_of_row_two(h))
}
#[test]
fn alt_drag_selects_only_the_text_inside_the_rectangle() {
let mut h = harness(|_| {});
let (from, to) = top_two_rows(&h);
// A plain drag selects in reading order: everything from "Name" to "12".
drag(&mut h, from, to, Modifiers::NONE);
assert_eq!(text(&h).as_deref(), Some("Name\nApple\nPear\nQty\n12"));
h.run_steps(60);
// Alt (Option on macOS) at the start of the drag: only the rectangle, row by row.
drag(&mut h, from, to, Modifiers::ALT);
assert_eq!(text(&h).as_deref(), Some("Name\tQty\nApple\t12"));
// One column of numbers, which is what the issue asked for.
h.run_steps(60);
let (from, to) = (glyph(&h, 13) - vec2(8.0, 12.0), egui::pos2(glyph(&h, 15).x + 12.0, glyph(&h, 18).y + 12.0));
drag(&mut h, from, to, Modifiers::ALT);
assert_eq!(text(&h).as_deref(), Some("Qty\n12\n7"));
}
#[test]
fn the_column_select_tool_needs_no_modifier() {
let mut h = harness(|app| assert!(app.execute("edit.column_select")));
assert_eq!(h.state().quick_tool, pdfcraft_ui_egui::QuickTool::ColumnSelect);
let (from, to) = top_two_rows(&h);
drag(&mut h, from, to, Modifiers::NONE);
assert_eq!(text(&h).as_deref(), Some("Name\tQty\nApple\t12"));
// A rectangle around no text selects nothing.
h.run_steps(60);
let below = glyph(&h, 12) + vec2(0.0, 60.0);
drag(&mut h, below, below + vec2(60.0, 30.0), Modifiers::NONE);
assert_eq!(text(&h), None);
}
/// ⇧-click extends a reading-order selection; a column selection has no anchor glyph, so a
/// ⇧-click clears it like a plain click.
#[test]
fn shift_click_clears_a_column_selection() {
let mut h = harness(|_| {});
let (from, to) = top_two_rows(&h);
drag(&mut h, from, to, Modifiers::ALT);
assert!(text(&h).is_some());
h.run_steps(60);
let at = glyph(&h, 10);
h.event(Event::ModifiersChanged(Modifiers::SHIFT));
h.hover_at(at);
h.run_steps(1);
for pressed in [true, false] {
h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::SHIFT });
}
h.run_steps(1);
h.event(Event::ModifiersChanged(Modifiers::NONE));
h.run_steps(1);
assert_eq!(text(&h), None);
}
/// Alt-drag with the highlighter marks only the cells inside the rectangle: one quad per piece
/// of a line. The drag starts on text, since off text the highlighter draws an area highlight.
#[test]
fn alt_drag_with_the_highlighter_marks_only_the_column() {
let mut h = harness(|app| assert!(app.execute("comment.highlight")));
// Just above-left of the centre of "N", still on the glyph.
let (from, to) = (glyph(&h, 0) - vec2(2.0, 2.0), below_right_of_row_two(&h));
drag(&mut h, from, to, Modifiers::ALT);
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", 4)], "Name, Qty, Apple and 12, each its own quad");
}
+12
View File
@@ -1481,6 +1481,18 @@ 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", "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", "crates/ui-egui/tests/text_select.rs::shift_click_extends_the_selection_from_its_anchor"]
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). 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)."
[[feature]]
id = "view.column-select"
title = "Column select (Alt/Option-drag a rectangle of text)"
area = "B"
tier = "P2"
milestone = "M3"
status = "shipped"
commands = ["edit.column_select"]
tools = ["text_extract"]
evidence = ["crates/render/src/text.rs::a_column_selection_takes_one_table_column", "crates/render/src/text.rs::a_column_selection_across_columns_reads_row_by_row", "crates/render/src/text.rs::right_to_left_rows_read_right_to_left", "crates/render/src/text.rs::vertical_lines_keep_reading_order", "crates/ui-egui/tests/column_select.rs::alt_drag_selects_only_the_text_inside_the_rectangle", "crates/ui-egui/tests/column_select.rs::the_column_select_tool_needs_no_modifier", "crates/ui-egui/tests/column_select.rs::alt_drag_with_the_highlighter_marks_only_the_column", "crates/automation/tests/automation.rs::text_extract_with_a_rect_takes_one_column"]
notes = "Issue #740. Holding Alt (Option on macOS) when a drag starts selects only the text inside the rectangle drawn, with the Select tool, the text markup tools and Redact (with Highlight and Redact the drag must start on text, since off text they draw an area box). Edit ▸ Column select does the same with a plain drag. A glyph counts when its centre is inside. Copy gives one row per visual line, top to bottom; pieces side by side (table cells) are separated by a tab, right to left in right-to-left rows; vertical text keeps reading order. text_extract takes the same rectangle as rect. Platforms: on Linux desktops whose window manager moves windows with Alt-drag (Xfce, Cinnamon and MATE by default; KDE Plasma 5 and other setups with Alt as the window modifier; GNOME and Plasma 6 use Super) the drag never reaches PdfCraft, so use Edit ▸ Column select or change that setting. On Windows, releasing Alt does not open a system menu (the window has no native menu). The web build has the same Linux caveat in the browser. Checked by hand on Linux (Fedora) with the Column select tool; Alt/Option-drag, and macOS and Windows generally, rest on the kittest tests. Missing: selection across pages, ⇧-click extending a column, a default shortcut, and a check of Acrobat's own copied-text format (not observed)."
[[feature]]
id = "view.copy-text"
title = "Copy selected text"