diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index 919c6fe6..c3b12f87 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -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" diff --git a/ROADMAP.md b/ROADMAP.md index 48d9e8d0..5949645f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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. diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 9aba0c7d..6ed206a6 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -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 = 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 = pages.iter().zip(texts).map(|(p, t)| json!({ "page": p + 1, "text": text(&t) })).collect(); Ok(json!({ "pages": out })) } diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 88fd8b7a..0935582b 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -150,9 +150,16 @@ pub fn tools() -> Vec { 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") diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index fae13b60..1790dc70 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -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 { + 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 = 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}"); + } +} diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index f8f0ae9f..0d7329aa 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -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"), diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index 8a6c38fd..8c47af21 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -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) -> 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) -> 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 { + 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, [f32; 4])> = Vec::new(); + let mut line_ids: Vec = 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 = 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::>().join("\n"); + } + // Rows: a line joins the row whose first line its vertical centre falls within. + let mut order: Vec = (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)> = 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 = 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語文"); + } } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index e5eaec2d..016e07fb 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -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 { - 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 { + 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 { 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, } } diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index 865d72a1..52a57270 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -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"); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 4e80e52d..c237889f 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -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.")), diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index bcc1a58b..ed0f3945 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -74,6 +74,7 @@ Fit visible ملاءمة المرئي Marquee zoom تكبير بالتحديد Take a snapshot التقاط لقطة + Column select تحديد عمود Full screen mode وضع ملء الشاشة Read mode وضع القراءة Switch light / dark theme التبديل بين السمة الفاتحة والداكنة diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index d0a3cde9..d25d29e7 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -109,6 +109,7 @@ Rotate view clockwise Завърти изгледа по часовниковата стрелка Rotate view counterclockwise Завърти изгледа обратно на часовниковата стрелка Take a snapshot Направи моментна снимка + Column select Избор на колона Full screen mode Цял екран Read mode Режим на четене Switch light / dark theme Превключи светла / тъмна тема diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 2e8d5087..20623a74 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 07796fce..58720ac2 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 8e46cf81..13bcd06f 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index f857b911..4c293b5e 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 13f85c02..db059d58 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index ead6977a..852c0eb8 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 4845803e..4ab9e223 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -73,6 +73,7 @@ Fit visible 表示範囲に合わせる Marquee zoom 範囲指定ズーム Take a snapshot スナップショットを作成 + Column select 列の選択 Full screen mode 全画面表示 Read mode 閲覧モード Switch light / dark theme 明るい/暗いテーマを切り替え diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index ed26ca06..69997634 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index e86e9aaf..10d34a85 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -65,6 +65,7 @@ Fit visible По видимой области Marquee zoom Масштаб выделением Take a snapshot Сделать снимок + Column select Выделение столбца Full screen mode Полноэкранный режим Read mode Режим чтения Switch light / dark theme Переключить светлую / тёмную тему diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv index e7e114be..ab197fdc 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -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 లేత / ముదురు థీమ్‌ల మధ్య మారు diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index b294ba47..5fc970b9 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -72,6 +72,7 @@ Fit visible За видимою областю Marquee zoom Масштабувати виділену область Take a snapshot Зробити знімок + Column select Виділення стовпця Full screen mode Повноекранний режим Read mode Режим читання Switch light / dark theme Перемкнути світлу / темну тему diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index a7b8f839..9cdfc5b8 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -115,6 +115,7 @@ Rotate view clockwise 顺时针旋转视图 Rotate view counterclockwise 逆时针旋转视图 Take a snapshot 拍摄快照 + Column select 列选择 Full screen mode 全屏模式 Read mode 阅读模式 Switch light / dark theme 切换浅色/深色主题 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index f28058ec..520bd3ec 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -74,6 +74,7 @@ Rotate view clockwise 順時針旋轉檢視 Rotate view counterclockwise 逆時針旋轉檢視 Take a snapshot 擷取快照 + Column select 欄選取 Full screen mode 全螢幕模式 Read mode 閱讀模式 Switch light / dark theme 切換淺色/深色主題 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 64661d8f..d4182d3d 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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}"))?; diff --git a/crates/ui-egui/tests/column_select.rs b/crates/ui-egui/tests/column_select.rs new file mode 100644 index 00000000..30ef0a13 --- /dev/null +++ b/crates/ui-egui/tests/column_select.rs @@ -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 { + 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 { + 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"); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 4bec0867..91ac26f1 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -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"