mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-14 19:27:04 -04:00
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:
28 files changed
+553
-49
No files matched your search
+15
-15
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 }))
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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語文");
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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.")),
|
||||
|
||||
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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}"))?;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user