mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-15 15:22:54 +00:00
Select a table by rows when a drag crosses its columns (#3669)
## What this changes Fixes #3850. Partly addresses #3682: drags within one column of its two-column page now select that column's text (the tester's harness measured 3/9 → 7/9 on `cols.pdf`). Drags from one column into the next still follow reading order, as on `main`; they need column-correct reading order (#3676). Selecting text in a table selected the wrong things. A table reads column by column, so a plain drag along one row, or down and across a few rows, took the rest of the first column and the top of the next ones between its ends. On some rows the highlight jumped to the column headers and half the table. Copy gave the cells out of order. Preview selects these by rows, and this makes PdfCraft do the same. Rebased on `main` after the module split (#4889): the row selection is in `crates/render/src/text.rs`, the padded-cell join and barcode rows in `text/order.rs` and `text/layout.rs`, the drag's pointer end in `crates/ui-egui/src/canvas/selection.rs` and `canvas.rs`. - **Visual rows** (`PageText::visual_between`, used by `Selection::rects` and `Selection::text`). The drag selects by rows when reading order between its ends would not follow them: the rest of the first row, the rows between, and the start of the last. Copy gives the cells tab-separated, as Column select does. Rows are used when either of these holds: - Reading order strays above or below the rows dragged over. - The drag ends in another column. That means across a gutter that no text of those rows crosses (checked a few lines above and below as well, so a justified line's wide word gap does not count), or with the pointer past the end of both lines. - **Limits on the rows.** Rows never take lines left of everywhere the drag has been, and they stay in the column when the drag does. A drag in the right column of side-by-side text that ends on a hanging list marker does not pull in the left column. Running text keeps reading order whenever it covers the same glyphs, and so does a column of running text beside a sidebar. A drag down one article or table column keeps reading order too. A line's extent for these limits is its ink: spaces padding it out don't count. - **Pointer position.** `Span::Run` records where the drag's pointer ended (`head_x`). It can lie well past the nearest glyph, as in the empty end of a table row. Select all, a clicked word, line or page, and four-click page selection keep reading order. - **Hit testing.** `PageText::nearest` still takes the glyph whose em box holds the point first (#3693). Failing that, it prefers the row level with the pointer, so past the end of a short row a longer row just below no longer captures the drag. Between rows, the old distance rule still applies. - **Padded cells.** Report generators pad each cell of a row with trailing spaces. When a cell's padding stops short of the next cell (more than the 3-height continuation gap), the next cell on its baseline now joins the row, within 8 line heights (`PADDED_FIELD`, `PADDED_REACH`). Without this, the row's last cells were read after the whole table, in selection, copy and `text_extract` alike. - **Barcodes drawn with a font** (new since the last review). Mexican CFDI invoices and receipts print their QR or Aztec code with a barcode font: rows of glyphs stacked flush, 5 pt from the seal's text and on nearly the same baselines. Each code row joined the seal line beside it, so the seal read interleaved with the code, some seal lines came after the whole code, and a drag down the seal jumped through it. Rows of at least 8 glyphs, 4 or more stacked flush with one glyph count and the same edges (`barcode_rows`), now join no other text and read after the page's text. A point beside the code is not taken by the code row level with it. ## How you verified it - [x] `cargo fmt --all --check` - [x] `cargo clippy --workspace --all-targets --locked -- -D warnings` - [x] tests pass on the crates I changed, and on the crates that use text extraction: | Crate or suite | Passed | |---|---| | `pdfcraft-render` (lib + integration) | 476 + 64 | | `pdfcraft-ui-egui --lib` | 696 | | `pdfcraft-ui-egui --test it` (`column_select`, `text_select`, `drag_selects`, `selection_works`) | 33 | | `pdfcraft-automation` | 359 | | `pdfcraft-engine` | 442 | | `pdfcraft-export` | 43 | - [x] `cargo xtask layers`, `cargo xtask assets`, `cargo check -p pdfcraft-render --target wasm32-unknown-unknown`, `cargo xtask parity` **Tests.** I reverted each fix and watched these fail: - `column_select.rs::a_plain_drag_along_one_table_row_selects_only_that_row` (UI, select tool and highlighter). Before the fix it got `"Apple\nPear\nQty\n12"` instead of `"Apple\t12"`. - `column_select.rs::a_drag_ending_between_the_columns_selects_the_rows` (UI). Without `head_x` it got `"Name\nApple\nPear"`. - `column_select.rs::a_drag_down_text_beside_a_barcode_selects_only_the_text` (UI). Without the barcode rows it got `"ABCDEFGHIJKL one two three\nFGHIJKLMNOPQ\n…"`. - `text/tests.rs::a_barcode_drawn_with_a_font_reads_apart_from_the_text_beside_it` - `text/tests.rs::a_point_beside_a_barcode_takes_the_text_not_the_code_row` - `text/tests.rs::a_point_level_with_a_row_takes_that_rows_nearest_glyph` - `text/tests.rs::a_drag_in_the_right_column_takes_nothing_from_the_left` Also new in `text/tests.rs`: `a_drag_along_one_table_row_selects_that_row`, `a_drag_over_table_rows_selects_those_rows`, `a_drag_that_reading_order_already_covers_keeps_it`, `a_pointer_ending_beside_the_column_takes_the_rows`, `a_cell_padded_with_spaces_joins_the_next_cell_of_its_row`, `a_justified_gap_is_not_a_gutter`, and the running-text, sidebar, heading and vertical-text cases from earlier review rounds. In `column_select.rs`: double-click drags, select all and four-click page selection on uneven columns. All fixtures are synthetic. **Corpus.** Before the rebase, text extraction of the pinned pdf.js corpus was byte-identical with and without this PR. The barcode change leaves it identical too: the plain text of the first 60 pages of each of its 989 PDFs (1,525 pages) is the same with and without it. **Real-world files** (private, not attached): - **Text extraction:** on a personal folder of PDFs (bank and hotel reports, payslips, policies, contracts, slide decks), the only files that differed from `main` before the rebase were bank statements whose padded rows now keep their amounts. The barcode change alters one page across 105 files: a statement's CFDI page, whose seal now reads in order. - **Simulated drags:** about 53,700 random drags across those files. Selection takes at most about 145 µs per frame. - **Two-column contract:** on a bilingual two-column contract, 2,649 drags kept inside one column leak into the other column less often than before (1,224 vs 1,591). In no file does the new code leak a two-column drag where the old one did not, except across table rows, where rows running across the page is intended. ## Never-crash - [x] No new `unwrap`/`expect`/`panic!`/`unreachable!`/`todo!` outside tests - [x] No indexing with input-derived positions in the new code (`get`). Allocations are bounded by the page's glyphs, and `side_by_side` compares at most `SIDE_BY_SIDE_LINES` lines. Barcode rows are found with one hash map over the page's rows. NaN boxes rank last in `nearest` (`total_cmp`) and are never barcode rows. No recursion. - [x] No `unsafe` - [ ] Any crash this fixes has a small synthetic regression test (n/a, no crash) ## Provenance - [x] No asset from an Adobe product, and no Adobe icon recreated, traced or imitated - [x] No font files committed here - [x] No GPL/AGPL code copied in - [x] Behaviour comes from black-box observation (Preview's selection on the same files), not from reading another implementation's source - [x] No new assets ## Security - [x] `--root` confinement untouched - [x] MCP and the UI control channel untouched - [x] No new outbound network access ## Parity and reach - [x] `parity/acrobat-features.toml`: `view.text-select` gains the new evidence and a note. Selection across pages is still missing, as before. - [x] Reachable headlessly: the padded-cell join and barcode rows change `text_extract` too. Visual-row selection is a pointer interaction, like the rest of plain drag selection. - [x] `ROADMAP.md` progress log line ## Anything a reviewer should look at first 1. **A deliberate behaviour change.** `alt_drag_selects_only_the_text_inside_the_rectangle` asserted that a plain drag from "Name" to "12" selects in reading order: `"Name\nApple\nPear\nQty\n12"`. It now selects the two rows dragged over, `"Name\tQty\nApple\t12"`, and I changed the assertion. Reading order itself is unchanged (`a_column_selection_takes_one_table_column` still asserts it), and so is a drag down one column. 2. **The gutter test is a heuristic.** "Another column" means a vertical strip at least one line height wide that no text in the dragged rows, or 4 line heights around them, crosses. That catches table columns and side-by-side text, and ignores justified word gaps in practice. Thin columns with ragged edges may still fool it. 3. **The padded-cell join changes reading order.** It applies only to a segment whose box runs on at least 2 line heights of trailing spaces past its last visible glyph, and only towards a cell on the same baseline within 8 line heights. 4. **Barcode rows change reading order.** A stack of 4+ flush rows of 8+ glyphs, each with the same glyph count, height and edges, reads after the page's text. Text set solid still has lines of different lengths and counts; a hex dump set solid in a monospaced font would also match, and would then read last. 5. **Overlap with #2431** (narrow article gutters, #2387). The two PRs touch different code paths and should rebase cleanly. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Text drags across table-like layouts now select cells by visual row, with copied cells separated by tabs. Multi-click and Shift-click selections can also follow visual rows where applicable; select-all and other non-row selections retain reading order. * Text selection and pointer placement work more accurately around columns, gutters, and barcode-like text, reducing unintended selection of nearby content. Barcode text is kept separate from adjacent text. * **Documentation** * Updated feature coverage and progress notes to reflect text-selection behavior and regression testing. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
1 parent
27287c297d
commit
aef22858a9
9 files changed
+1152
-58
No files matched your search
@@ -261,6 +261,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
| 8 | 1.0 polish: performance, right-to-left, missing languages, keyboard-only (M14) | 55–95 |
|
||||
|
||||
## Progress log
|
||||
- **2026-11-03 (M3, #3669):** A drag across a table selects its rows, as Preview does, instead of reading order's detour down each column; a cell padded with spaces reads with the next cell of its row. Synthetic render and UI drag regressions; estimates unchanged.
|
||||
|
||||
- **2026-11-09 (M10, #5130):** Windows Printer default sends native two-sided bindings only when automatic duplex support is confirmed, preserving manual driver defaults (#5130/#5772); physical output remains unqualified. Both Windows submission paths preserve DLL guards and current spooler fixes. Review follow-up validates both duplex scopes through craft-print 9fe50c94 and corrects CI formatting. Estimates unchanged.
|
||||
|
||||
- **2026-10-27 (M10, #5005):** Vector printing retains each annotation's optional-content membership when drawing its appearance.
|
||||
@@ -290,6 +292,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
- **2026-11-05 (M2, #5889):** A non-embedded Arial set as a TrueType CID font (`CIDFontType2`, `Identity-H`, as Word and PDFMaker write it) draws the glyphs its codes name. Those codes are glyph indexes in the author's Arial, whose first 258 glyphs follow the standard Macintosh order, so the standard substitute now draws each glyph by name (vendored hayro-interpret patch 76). Before, it took each code as one of its own glyph indexes, and "Low Power" drew as "@ck 'Dck Yf". Arial Narrow, Black and Unicode MS, and `CIDFontType0` fonts, are not read this way. Copying or searching those runs still needs a `/ToUnicode`.
|
||||
|
||||
- **2026-11-07 (M7, #2964 / #3185):** Supported Form text can be prepared for direct editing; groups left grouped (unsafe compositing, text nested past eight levels) are reported. Legacy Thai keeps the original font programs, Regular/Bold and text settings, also across same-face subsets; the narrow THSarabunPSK map repair is checked glyph by glyph against the font program; cross-family replacement is explicit. Builds on #3067 and #2811. Unsafe groups and mixed contextual styles remain partial; estimates unchanged.
|
||||
|
||||
- **2026-10-28 (M10, #4119 partial):** Scan & OCR ▸ Enhance scanned file cleans scanned pages in place: background removal (level 0–100, as the camera clean-up), edge shadow removal (book gutters, copier borders) and despeckle. Each scan image is decoded, cleaned and written back into its own object at the same size and colour space (JPEG stays JPEG, others lossless, 1-bit stays 1-bit), so the OCR text layer, comments and links stay; one undo step, shared images once, unsupported images (CMYK, indexed, JPEG 2000, JBIG2, CCITT, broken data) reported. `page_enhance_scan` drives it headlessly. Not yet an option of Recognize text. Estimates unchanged.
|
||||
- **2026-11-07 (M10, `ocr.ocr-web`, port onto #977):** Web OCR rebuilt on main's engines and preprocessing (#977). `OcrJob::run` is now `OcrJob::steps` a page at a time: the page renders, crops to the region and cleans each page, a Web Worker runs ocrs on the result, and `OcrShot::place` maps the words back through the region and preprocessing. Every strategy reads like this when only one engine is present, and the web build has only ocrs. The downloaded models are installed into the engine cache (`ocr::install`), so the Verify screen and the engine-status checks see them too. The batch keeps main's skipped-files bucket, low-confidence notes and cancel. The translations moved to craft-i18n (storytold/craft-i18n#141). The Verify screen starts the model download itself and runs once the models land; before, on a fresh page it failed with the desktop's "set PDFCRAFT_MODELS" message. Known limits: the cleaning steps, and the Verify screen's Run OCR and re-reads, run on the page. Estimates unchanged.
|
||||
|
||||
|
||||
+391
-26
@@ -15,7 +15,7 @@ use hayro::hayro_interpret::{
|
||||
SoftMask, interpret_page,
|
||||
};
|
||||
use kurbo::{Affine, BezPath, Rect, Shape};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
|
||||
/// Semantic exclusions for statistics. Untagged text is retained; no layout guess is made.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
@@ -203,8 +203,14 @@ impl PageText {
|
||||
///
|
||||
/// A glyph whose drawn em box (`quad`) holds the point wins. Skewed or turned text has bounding
|
||||
/// boxes wider than the glyphs, so the box of the letter before overlaps this one and would
|
||||
/// otherwise be found first (#3693). Failing that, the nearest bounding box wins, and a tie
|
||||
/// goes to the glyph whose centre is closer.
|
||||
/// otherwise be found first (#3693). Failing that, the nearest bounding box wins, vertical
|
||||
/// distance weighing more, and a tie goes to the glyph whose centre is closer. A point level
|
||||
/// with some text prefers the nearest glyph of that row, so past the end of a short table cell
|
||||
/// a longer cell in the next row does not win; but a point in the leading right beside another
|
||||
/// line, parted by a gutter (see [`Self::gutter_between`]) from the row level with it, takes
|
||||
/// that line: between two lines of one column, it is not taken by another column's line that
|
||||
/// different baselines put level. Nor is a point beside a barcode drawn with a font taken by
|
||||
/// the code's row level with it.
|
||||
pub fn nearest(&self, x: f32, y: f32) -> Option<usize> {
|
||||
let centre_distance = |g: &TextGlyph| {
|
||||
let (cx, cy) = (g.quad.iter().map(|q| q[0]).sum::<f32>() / 4.0, g.quad.iter().map(|q| q[1]).sum::<f32>() / 4.0);
|
||||
@@ -220,29 +226,55 @@ impl PageText {
|
||||
if inside.is_some() {
|
||||
return inside;
|
||||
}
|
||||
self.glyphs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, g)| {
|
||||
let dx = if x < g.rect[0] {
|
||||
g.rect[0] - x
|
||||
} else if x > g.rect[2] {
|
||||
x - g.rect[2]
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let dy = if y < g.rect[1] {
|
||||
g.rect[1] - y
|
||||
} else if y > g.rect[3] {
|
||||
y - g.rect[3]
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
// Prefer the same line: vertical distance weighs more.
|
||||
(i, dx + dy * 3.0, centre_distance(g))
|
||||
})
|
||||
.min_by(|a, b| a.1.total_cmp(&b.1).then(a.2.total_cmp(&b.2)))
|
||||
.map(|(i, _, _)| i)
|
||||
// (glyph, distance, centre distance). `total_cmp` ranks NaN last, so a glyph with a broken
|
||||
// box never wins over a real one.
|
||||
let mut on_row: Option<(usize, f32, f32)> = None;
|
||||
let mut any: Option<(usize, f32, f32)> = None;
|
||||
let closer = |best: Option<(usize, f32, f32)>, d: f32, c: f32| best.is_none_or(|(_, b, bc)| d.total_cmp(&b).then(c.total_cmp(&bc)).is_lt());
|
||||
for (i, g) in self.glyphs.iter().enumerate() {
|
||||
let dx = if x < g.rect[0] {
|
||||
g.rect[0] - x
|
||||
} else if x > g.rect[2] {
|
||||
x - g.rect[2]
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let dy = if y < g.rect[1] {
|
||||
g.rect[1] - y
|
||||
} else if y > g.rect[3] {
|
||||
y - g.rect[3]
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let c = centre_distance(g);
|
||||
if dy == 0.0 && closer(on_row, dx, c) {
|
||||
on_row = Some((i, dx, c));
|
||||
}
|
||||
// Prefer the same line: vertical distance weighs more.
|
||||
let d = dx + dy * 3.0;
|
||||
if closer(any, d, c) {
|
||||
any = Some((i, d, c));
|
||||
}
|
||||
}
|
||||
match (on_row, any) {
|
||||
// The point sits in the leading right beside another line (within half its height), and
|
||||
// a gutter parts it from the row level with it: that row is another column's.
|
||||
(Some((r, _, _)), Some((i, _, _))) if r != i => {
|
||||
let (Some(g), Some(n)) = (self.glyphs.get(r), self.glyphs.get(i)) else { return Some(r) };
|
||||
let beside = (n.rect[1] - y).max(y - n.rect[3]) <= (n.rect[3] - n.rect[1]) * 0.5;
|
||||
let h = g.rect[3] - g.rect[1];
|
||||
// From the far end of the row's line, so its column's shorter lines count too.
|
||||
let toward_right = x > g.rect[2];
|
||||
let from = self.line_span(r).map_or(if toward_right { g.rect[2] } else { g.rect[0] }, |(l0, l1)| if toward_right { l0 } else { l1 });
|
||||
let reach = h * GUTTER_ROWS;
|
||||
// A barcode drawn with a font has a row at every height beside it; the point is only
|
||||
// on one of them when it is over the code.
|
||||
let off_barcode = self.line_span(r).is_some_and(|(r0, r1)| !(r0..=r1).contains(&x))
|
||||
&& self.line_of.get(r).is_some_and(|l| self.barcode_lines().contains(l));
|
||||
Some(if off_barcode || (beside && self.gutter_between(from, x, y - reach, y + reach, h)) { i } else { r })
|
||||
}
|
||||
(on_row, any) => on_row.or(any).map(|(i, _, _)| i),
|
||||
}
|
||||
}
|
||||
|
||||
/// The word around glyph `i` as its first and last glyph (double-click): it stops at word
|
||||
@@ -307,6 +339,323 @@ impl PageText {
|
||||
out.into_iter().map(|(_, _, r)| r).collect()
|
||||
}
|
||||
|
||||
/// The anchor and head for [`Self::visual_between`] that take two units (words or lines, each
|
||||
/// as its first and last glyph in reading order) whole, from `unit` to `to`: the earlier
|
||||
/// unit's left end and the later one's right end, earlier meaning on a higher row, or on the
|
||||
/// same row and to the left. Reading order may have them the other way round (a drag from a
|
||||
/// right-hand cell down into a left-hand one), and a right-to-left word reads from its right
|
||||
/// end, so the ends are taken by position.
|
||||
pub fn row_ends(&self, unit: (usize, usize), to: (usize, usize)) -> (usize, usize) {
|
||||
// A unit's outer glyphs: its leftmost and rightmost, whatever its reading order.
|
||||
let outer = |(i, j): (usize, usize)| match (self.glyphs.get(i), self.glyphs.get(j)) {
|
||||
(Some(g), Some(h)) if h.rect[0] < g.rect[0] => (j, i),
|
||||
_ => (i, j),
|
||||
};
|
||||
let ((unit_left, unit_right), (to_left, to_right)) = (outer(unit), outer(to));
|
||||
let later = match (self.glyphs.get(unit_left), self.glyphs.get(to_left)) {
|
||||
(Some(a), Some(b)) => {
|
||||
let dy = (b.rect[1] + b.rect[3] - a.rect[1] - a.rect[3]) / 2.0;
|
||||
let h = (a.rect[3] - a.rect[1]).min(b.rect[3] - b.rect[1]);
|
||||
if dy.abs() > h / 2.0 { dy > 0.0 } else { b.rect[0] > a.rect[0] }
|
||||
}
|
||||
_ => to.0 > unit.0,
|
||||
};
|
||||
if later { (unit_left, to_right) } else { (unit_right, to_left) }
|
||||
}
|
||||
|
||||
/// What a drag from glyph `anchor` to glyph `head` selects by visual rows, as Preview selects
|
||||
/// it, where reading order between them would not follow the rows: on one row, the row's
|
||||
/// glyphs from one end to the other, as [`Self::glyphs_in`] takes them; over several, the rest
|
||||
/// of the top row from the upper end, the rows between and the bottom row up to the lower end.
|
||||
///
|
||||
/// A table reads column by column, so a drag across its columns would take the first column
|
||||
/// down to the drag's end and the next ones from the top, and a row whose cells came apart
|
||||
/// reads its last cells after the table. Rows are used when reading order strays above or
|
||||
/// below the rows the drag covers, or when the drag ends in another column than it started
|
||||
/// in: the two glyphs' lines don't overlap side by side, or the pointer, at `head_x` (view
|
||||
/// space) when known, ended past the end of both of them. `None` otherwise
|
||||
/// (running text, a drag down one article or table column): the reading-order range is the
|
||||
/// answer.
|
||||
pub fn visual_between(&self, anchor: usize, head: usize, head_x: Option<f32>) -> Option<Vec<usize>> {
|
||||
let (a, h) = (self.glyphs.get(anchor)?, self.glyphs.get(head)?);
|
||||
if self.line_of.get(anchor)? == self.line_of.get(head)? {
|
||||
return None;
|
||||
}
|
||||
// Rows mean nothing in vertical writing or turned text: two vertical columns at one
|
||||
// height are not a row. Right-to-left rows are still rows.
|
||||
let horizontal = |g: &TextGlyph| g.direction[1].abs() <= WORD_GAP;
|
||||
if !horizontal(a) || !horizontal(h) {
|
||||
return None;
|
||||
}
|
||||
let centre = |g: &TextGlyph| (g.rect[1] + g.rect[3]) / 2.0;
|
||||
let (upper, lower) = if centre(a) <= centre(h) { (a, h) } else { (h, a) };
|
||||
let (top, bottom) = (upper.rect[1].min(lower.rect[1]), upper.rect[3].max(lower.rect[3]));
|
||||
let range = self.glyphs.get(anchor.min(head)..=anchor.max(head))?;
|
||||
let strays = !range.iter().all(|g| (top..=bottom).contains(¢re(g)));
|
||||
// One row: each one's centre lies within the other's height.
|
||||
let within = |y: f32, g: &TextGlyph| (g.rect[1]..=g.rect[3]).contains(&y);
|
||||
let one_row = within(centre(upper), lower) && within(centre(lower), upper);
|
||||
let spans = self.line_span(anchor).zip(self.line_span(head));
|
||||
// From the anchor to where the pointer ended (or the head glyph), across a gutter no text
|
||||
// of these rows crosses: another column. Word gaps in a column, however wide a justified
|
||||
// line spreads them, are crossed by the other rows' text.
|
||||
// From the far end of the anchor's line, so its column's other lines count wherever along
|
||||
// the line the drag started.
|
||||
let to_x = head_x.unwrap_or((h.rect[0] + h.rect[2]) / 2.0);
|
||||
let a_x = (a.rect[0] + a.rect[2]) / 2.0;
|
||||
let from = spans.map_or(a_x, |((a0, a1), _)| if to_x >= a_x { a0 } else { a1 });
|
||||
let strip = self.gutter_strip(from, to_x, top, bottom, a.rect[3] - a.rect[1]);
|
||||
let other_column = strip.is_some();
|
||||
if !strays && (one_row || !other_column) {
|
||||
return None;
|
||||
}
|
||||
// From one column of running text into the next, reading order carries the text on (the
|
||||
// rest of one column, then the start of the next); rows would interleave the columns. One
|
||||
// row shows too little of the columns, so a drag along one looks at the rows around it.
|
||||
let lh = a.rect[3] - a.rect[1];
|
||||
let (near_top, near_bottom) = if one_row { (top - lh * GUTTER_ROWS, bottom + lh * GUTTER_ROWS) } else { (top, bottom) };
|
||||
let strip = if one_row { self.gutter_strip(from, to_x, near_top, near_bottom, lh) } else { strip };
|
||||
if let Some(strip) = strip
|
||||
&& self.running_text_beside(strip, from.min(to_x), from.max(to_x), near_top, near_bottom)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let picked = if one_row {
|
||||
self.glyphs_in([a.rect[0].min(h.rect[0]), top, a.rect[2].max(h.rect[2]), bottom])
|
||||
} else {
|
||||
// Rows run left to right from the upper end; other writing keeps reading order.
|
||||
let ltr = |g: &TextGlyph| g.direction[0] > 0.0 && g.direction[1].abs() <= WORD_GAP && !g.text.chars().any(is_rtl);
|
||||
if !ltr(upper) || !ltr(lower) {
|
||||
return None;
|
||||
}
|
||||
// The rows take only lines reaching into the columns the drag went through: none left
|
||||
// of everywhere it has been (the column beside a right-hand one), and, when it stayed
|
||||
// in one column (reading order only strayed, as when a justified line's last word is
|
||||
// read late), none right of it either. Across columns, rows run on to their end.
|
||||
let ((a0, a1), (b0, b1)) = spans?;
|
||||
let pointer = head_x.filter(|x| x.is_finite());
|
||||
let left = a0.min(b0).min(pointer.unwrap_or(f32::INFINITY));
|
||||
let right = if other_column { f32::INFINITY } else { a1.max(b1).max(pointer.unwrap_or(f32::NEG_INFINITY)) };
|
||||
let mut lines: BTreeMap<u32, (f32, f32)> = BTreeMap::new();
|
||||
for (g, &l) in self.glyphs.iter().zip(&self.line_of) {
|
||||
if (top..=bottom).contains(¢re(g)) && !g.text.trim().is_empty() {
|
||||
let span = lines.entry(l).or_insert((f32::INFINITY, f32::NEG_INFINITY));
|
||||
*span = (span.0.min(g.rect[0]), span.1.max(g.rect[2]));
|
||||
}
|
||||
}
|
||||
let in_column = |l: u32| lines.get(&l).is_some_and(|&(l0, l1)| l0.max(left) < l1.min(right));
|
||||
// Across columns, nearly every row of a table has cells side by side; a column of
|
||||
// running text beside a sidebar or a figure caption has many rows with nothing beside
|
||||
// them. Rows would interleave the two, so reading order is kept. The whole rows of the
|
||||
// band within the columns, not just the glyphs picked: the bottom row is cut off at the
|
||||
// pointer and may show a single cell, and text left of the drag is no part of the rows.
|
||||
if other_column {
|
||||
let band: Vec<usize> = (0..self.glyphs.len())
|
||||
.filter(|&i| self.glyphs.get(i).is_some_and(|g| (top..=bottom).contains(¢re(g))))
|
||||
.filter(|&i| self.line_of.get(i).is_some_and(|&l| in_column(l)))
|
||||
.collect();
|
||||
let (multi, rows) = self.cell_rows(&band);
|
||||
if (multi as f32) < rows as f32 * ROW_CELLS {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
let wanted = |(g, &l): (&TextGlyph, &u32)| {
|
||||
let (cx, cy) = ((g.rect[0] + g.rect[2]) / 2.0, centre(g));
|
||||
let on_top_row = cy <= upper.rect[3];
|
||||
let on_bottom_row = cy >= lower.rect[1];
|
||||
(top..=bottom).contains(&cy) && (!on_top_row || cx >= upper.rect[0]) && (!on_bottom_row || cx <= lower.rect[2]) && in_column(l)
|
||||
};
|
||||
self.glyphs.iter().zip(&self.line_of).enumerate().filter(|(_, gl)| wanted(*gl)).map(|(i, _)| i).collect()
|
||||
};
|
||||
// The same glyphs as reading order, in running text: its text is the better copy. In a
|
||||
// table (pieces side by side on a row) the rows give the copy its row-by-row order.
|
||||
let (lo, hi) = (anchor.min(head), anchor.max(head));
|
||||
let same = picked.first() == Some(&lo) && picked.last() == Some(&hi) && picked.len() == hi - lo + 1;
|
||||
if same && !self.side_by_side(&picked) {
|
||||
return None;
|
||||
}
|
||||
(!picked.is_empty()).then_some(picked)
|
||||
}
|
||||
|
||||
/// Whether two of the lines `glyphs` fall on sit side by side on one row, as table cells do.
|
||||
/// Only the first [`SIDE_BY_SIDE_LINES`] lines are compared.
|
||||
fn side_by_side(&self, glyphs: &[usize]) -> bool {
|
||||
let mut lines: Vec<(u32, [f32; 4])> = Vec::new();
|
||||
for &i in glyphs {
|
||||
let (Some(g), Some(&l)) = (self.glyphs.get(i), self.line_of.get(i)) else { continue };
|
||||
if let Some((last, bb)) = lines.last_mut()
|
||||
&& *last == l
|
||||
{
|
||||
union(bb, &g.rect);
|
||||
} else if lines.len() >= SIDE_BY_SIDE_LINES {
|
||||
break;
|
||||
} else {
|
||||
lines.push((l, g.rect));
|
||||
}
|
||||
}
|
||||
lines.iter().enumerate().any(|(k, (_, a))| {
|
||||
lines.iter().skip(k + 1).any(|(_, b)| {
|
||||
let (ca, cb) = ((a[1] + a[3]) / 2.0, (b[1] + b[3]) / 2.0);
|
||||
(b[1]..=b[3]).contains(&ca) && (a[1]..=a[3]).contains(&cb)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// The visual rows of the visible glyphs `glyphs` (by vertical position, whichever lines they
|
||||
/// belong to), as how many have two or more cells (a gap of [`ROW_CELL_GAP`] line heights between
|
||||
/// glyphs) and how many there are. A statement row written as one long line with wide gaps
|
||||
/// counts as cells, as a table row of separate lines does.
|
||||
fn cell_rows(&self, glyphs: &[usize]) -> (usize, usize) {
|
||||
let mut ink: Vec<(f32, f32, f32, f32)> = glyphs
|
||||
.iter()
|
||||
.filter_map(|&i| self.glyphs.get(i))
|
||||
.filter(|g| !g.text.trim().is_empty())
|
||||
.map(|g| ((g.rect[1] + g.rect[3]) / 2.0, g.rect[0], g.rect[2], g.rect[3] - g.rect[1]))
|
||||
.collect();
|
||||
ink.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
let (mut rows, mut multi) = (0usize, 0usize);
|
||||
let mut start = 0;
|
||||
while start < ink.len() {
|
||||
// A row runs while the centres stay within 0.6 line heights of the row's first glyph.
|
||||
let (cy0, h0) = (ink[start].0, ink[start].3.max(0.1));
|
||||
let end = start + ink[start..].iter().take_while(|g| (g.0 - cy0).abs() <= h0 * 0.6).count().max(1);
|
||||
let mut row: Vec<(f32, f32)> = ink[start..end].iter().map(|g| (g.1, g.2)).collect();
|
||||
row.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
let mut reach = f32::NEG_INFINITY;
|
||||
let gap = row.iter().any(|&(x0, x1)| {
|
||||
let wide = reach.is_finite() && x0 - reach > h0 * ROW_CELL_GAP;
|
||||
reach = reach.max(x1);
|
||||
wide
|
||||
});
|
||||
rows += 1;
|
||||
multi += usize::from(gap);
|
||||
start = end;
|
||||
}
|
||||
(multi, rows)
|
||||
}
|
||||
|
||||
/// Whether a column gutter lies between `x0` and `x1` (either order) in the rows whose
|
||||
/// centres lie between `top` and `bottom`: a vertical strip at least `width` wide that no
|
||||
/// glyph of those rows crosses, with at least two lines of text on each side of it between
|
||||
/// `x0` and `x1`, or beyond either when the strip reaches it (columns' baselines need not
|
||||
/// line up). The gap a justified line leaves between two words has only that line beyond it.
|
||||
fn gutter_between(&self, x0: f32, x1: f32, top: f32, bottom: f32, width: f32) -> bool {
|
||||
self.gutter_strip(x0, x1, top, bottom, width).is_some()
|
||||
}
|
||||
|
||||
/// [`Self::gutter_between`]'s gutter, as its left and right edges.
|
||||
fn gutter_strip(&self, x0: f32, x1: f32, top: f32, bottom: f32, width: f32) -> Option<(f32, f32)> {
|
||||
let (lo, hi) = (x0.min(x1), x0.max(x1));
|
||||
if !(hi - lo >= width && width > 0.0) {
|
||||
return None;
|
||||
}
|
||||
let centre = |g: &TextGlyph| (g.rect[1] + g.rect[3]) / 2.0;
|
||||
// The rows' glyphs as (centre, left, right), top to bottom.
|
||||
let mut rows: Vec<(f32, f32, f32)> =
|
||||
self.glyphs.iter().filter(|g| (top..=bottom).contains(¢re(g))).map(|g| (centre(g), g.rect[0], g.rect[2])).collect();
|
||||
rows.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
// Two lines of text on each side of the strip g0..g1: glyphs whose centres lie within half
|
||||
// a width of the previous one's (rows is sorted by centre) are on its line.
|
||||
let lines_beside = |side: &dyn Fn(f32, f32) -> bool| {
|
||||
let (mut lines, mut last) = (0usize, f32::NEG_INFINITY);
|
||||
for &(cy, _, _) in rows.iter().filter(|&&(_, a, b)| side(a, b)) {
|
||||
if cy - last > width * 0.5 {
|
||||
lines += 1;
|
||||
}
|
||||
last = cy;
|
||||
}
|
||||
lines
|
||||
};
|
||||
// Text within lo..hi counts, so a column beyond the span parts nothing inside it; but a
|
||||
// strip running on to either end, where the point is out in a gap, is a gutter if a
|
||||
// column lies beyond it.
|
||||
let parted = |g0: f32, g1: f32| {
|
||||
let left = if g0 <= lo { f32::NEG_INFINITY } else { lo };
|
||||
let right = if g1 >= hi { f32::INFINITY } else { hi };
|
||||
lines_beside(&|_, b| (left..=g0).contains(&b)) >= 2 && lines_beside(&|a, _| (g1..=right).contains(&a)) >= 2
|
||||
};
|
||||
let mut spans: Vec<(f32, f32)> = rows.iter().filter(|&&(_, a, b)| b > lo && a < hi).map(|&(_, a, b)| (a, b)).collect();
|
||||
spans.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
let mut covered = lo;
|
||||
for (s0, s1) in spans {
|
||||
if s0 - covered >= width && parted(covered, s0) {
|
||||
return Some((covered, s0));
|
||||
}
|
||||
covered = covered.max(s1);
|
||||
}
|
||||
(hi - covered >= width && parted(covered, hi)).then_some((covered, hi))
|
||||
}
|
||||
|
||||
/// Whether the lines between `top` and `bottom` on each side of the gutter `g0..g1` are
|
||||
/// running text: one column a side (no two lines side by side), most of its lines (at least
|
||||
/// half: paragraphs' first and last lines fall short) starting and ending at about the same
|
||||
/// place, at least half as wide as the gutter and [`RUNNING_TEXT_EMS`] line heights wide.
|
||||
/// Table cells, terms and form labels are short or ragged beside a wide gap, or sit several
|
||||
/// to a row. Only the lines ending after `lo` on the left, and starting before `hi`
|
||||
/// (or right after the gutter) on the right, count.
|
||||
fn running_text_beside(&self, (g0, g1): (f32, f32), lo: f32, hi: f32, top: f32, bottom: f32) -> bool {
|
||||
let mut lines: BTreeMap<u32, [f32; 4]> = BTreeMap::new();
|
||||
for (g, &l) in self.glyphs.iter().zip(&self.line_of) {
|
||||
if (top..=bottom).contains(&((g.rect[1] + g.rect[3]) / 2.0)) && !g.text.trim().is_empty() {
|
||||
lines.entry(l).and_modify(|r| union(r, &g.rect)).or_insert(g.rect);
|
||||
}
|
||||
}
|
||||
let gutter = g1 - g0;
|
||||
let column = |side: Vec<[f32; 4]>| {
|
||||
let n = side.len();
|
||||
if n < 2 {
|
||||
return false;
|
||||
}
|
||||
let (x0, x1) = side.iter().fold((f32::INFINITY, f32::NEG_INFINITY), |(a, b), r| (a.min(r[0]), b.max(r[2])));
|
||||
let w = x1 - x0;
|
||||
let one_a_row = side.iter().enumerate().all(|(k, a)| {
|
||||
side.iter().skip(k + 1).all(|b| {
|
||||
let (ca, cb) = ((a[1] + a[3]) / 2.0, (b[1] + b[3]) / 2.0);
|
||||
!((b[1]..=b[3]).contains(&ca) || (a[1]..=a[3]).contains(&cb))
|
||||
})
|
||||
});
|
||||
let starts = side.iter().filter(|r| r[0] <= x0 + w * RUNNING_TEXT_RAG).count();
|
||||
let ends = side.iter().filter(|r| r[2] >= x1 - w * RUNNING_TEXT_RAG).count();
|
||||
let tallest = side.iter().map(|r| r[3] - r[1]).fold(0.0f32, f32::max);
|
||||
one_a_row && w >= gutter * 0.5 && w >= tallest * RUNNING_TEXT_EMS && starts * 2 >= n && ends * 2 >= n
|
||||
};
|
||||
let reach = hi.max(g1 + gutter);
|
||||
let left: Vec<[f32; 4]> = lines.values().filter(|r| r[2] <= g0 && r[2] >= lo).copied().collect();
|
||||
let right: Vec<[f32; 4]> = lines.values().filter(|r| r[0] >= g1 && r[0] <= reach).copied().collect();
|
||||
left.len() <= SIDE_BY_SIDE_LINES && right.len() <= SIDE_BY_SIDE_LINES && column(left) && column(right)
|
||||
}
|
||||
|
||||
/// The lines that are rows of a barcode drawn with a font (a QR or Aztec code), as the layout
|
||||
/// finds them: such rows read after all other text.
|
||||
fn barcode_lines(&self) -> HashSet<u32> {
|
||||
let mut lines: Vec<(u32, usize, [f32; 4])> = Vec::new();
|
||||
for (g, &l) in self.glyphs.iter().zip(&self.line_of) {
|
||||
match lines.last_mut() {
|
||||
Some((id, n, bb)) if *id == l => {
|
||||
*n += 1;
|
||||
union(bb, &g.rect);
|
||||
}
|
||||
_ => lines.push((l, 1, g.rect)),
|
||||
}
|
||||
}
|
||||
let rows: Vec<(usize, [f32; 4])> = lines.iter().map(|&(_, n, bb)| (n, bb)).collect();
|
||||
lines.iter().zip(barcode_rows(&rows)).filter(|(_, barcode)| *barcode).map(|((l, _, _), _)| *l).collect()
|
||||
}
|
||||
|
||||
/// The left and right edges of the ink of the line glyph `i` is on: spaces padding it out (a
|
||||
/// heading indented with spaces past a barcode beside it) are not where its text is. A line of
|
||||
/// spaces only spans its spaces.
|
||||
fn line_span(&self, i: usize) -> Option<(f32, f32)> {
|
||||
let (first, last) = self.line_at(i)?;
|
||||
let line = self.glyphs.get(first..=last)?;
|
||||
let span = |inked: bool| {
|
||||
line.iter()
|
||||
.filter(|g| !inked || !g.text.trim().is_empty())
|
||||
.fold((f32::INFINITY, f32::NEG_INFINITY), |(x0, x1), g| (x0.min(g.rect[0]), x1.max(g.rect[2])))
|
||||
};
|
||||
Some(Some(span(true)).filter(|(x0, x1)| x0 <= x1).unwrap_or_else(|| span(false)))
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -402,6 +751,21 @@ impl PageText {
|
||||
}
|
||||
}
|
||||
|
||||
/// Line heights above and below a point (or a one-row drag) looked over for a gutter.
|
||||
const GUTTER_ROWS: f32 = 4.0;
|
||||
/// The share of a row selection's visual rows that must have cells side by side for the rows to be
|
||||
/// a table's: a column of running text beside a sidebar has such cells on only some of its rows.
|
||||
const ROW_CELLS: f32 = 0.75;
|
||||
/// A gap of this many line heights between two glyphs of a row parts two cells (a word gap is
|
||||
/// about a third of a line height).
|
||||
const ROW_CELL_GAP: f32 = 2.0;
|
||||
/// Running text is at least this many line heights wide; terms, labels and narrow cells are not.
|
||||
const RUNNING_TEXT_EMS: f32 = 8.0;
|
||||
/// The share of a column's width its running text's lines may fall short of its edges by.
|
||||
const RUNNING_TEXT_RAG: f32 = 0.2;
|
||||
/// Lines a selection compares pairwise to tell a table from running text.
|
||||
const SIDE_BY_SIDE_LINES: usize = 256;
|
||||
|
||||
use hayro::hayro_interpret::TransformExt;
|
||||
|
||||
mod device;
|
||||
@@ -416,6 +780,7 @@ pub use layout::layout;
|
||||
#[cfg(test)]
|
||||
use layout::{from_upright, to_upright};
|
||||
use layout::{is_rtl, union};
|
||||
use order::barcode_rows;
|
||||
#[cfg(test)]
|
||||
use order::{Run, Scratch, Seg, collide, restarts_run};
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::device::{DIRECTION_TOLERANCE, Drawn, WORD_GAP};
|
||||
use super::order::{
|
||||
NONE, REDRAW_CANDIDATES, Run, SAME_LINE_GAP, Scratch, Seg, fingerprint, is_script_on, measure_cluster_gaps, place_glyphs, redraws, restarts_run,
|
||||
reverse_rtl, same_line,
|
||||
NONE, REDRAW_CANDIDATES, Run, SAME_LINE_GAP, Scratch, Seg, fingerprint, ink_of, is_script_on, mark_barcode_rows, measure_cluster_gaps,
|
||||
place_glyphs, redraws, restarts_run, reverse_rtl, same_line,
|
||||
};
|
||||
use super::{HashMap, PageText, TextGlyph};
|
||||
|
||||
@@ -171,7 +171,8 @@ pub(super) fn union(a: &mut [f32; 4], b: &[f32; 4]) {
|
||||
/// Heuristics (documented so they can be tuned and tested; plan/architecture.md §8):
|
||||
/// 1. *Segments:* consecutive glyphs (content order) on one baseline with no column-sized gap.
|
||||
/// A smaller glyph right next to the previous one (a sub- or superscript) stays in the segment.
|
||||
/// Segments on the same baseline that nearly touch are merged (text drawn out of order).
|
||||
/// Segments on the same baseline that nearly touch are merged (text drawn out of order), and so
|
||||
/// is a table cell padded with spaces with the next cell of its row (`PADDED_FIELD`).
|
||||
/// 2. *Blocks:* segments stacked with line-sized gaps and overlapping horizontally.
|
||||
/// 3. *Block order:* repeatedly take the top-most remaining block, preferring a block to its left
|
||||
/// that overlaps it vertically (so columns read left, then right). A column goes on first (#3658):
|
||||
@@ -183,6 +184,8 @@ pub(super) fn union(a: &mut [f32; 4], b: &[f32; 4]) {
|
||||
/// Known limit: when a paragraph in the next column starts on the same line as the continuation
|
||||
/// (text on a baseline grid), it counts as a row and the top-most rule applies, so the column
|
||||
/// order of #3658 is lost there. Telling prose from a row in that case needs more evidence.
|
||||
/// The rows of a barcode drawn with a font (a QR or Aztec code) join no other text and are read
|
||||
/// after everything else, top to bottom.
|
||||
/// 4. Within a segment glyphs run left to right; runs of right-to-left script are reversed so the
|
||||
/// text comes out in logical order. A block whose first line sits on the baseline of the line read
|
||||
/// just before it, to its right and less than [`SAME_LINE_GAP`] heights away, continues that line
|
||||
@@ -263,9 +266,10 @@ fn arrange(glyphs: &[TextGlyph]) -> (Vec<usize>, Vec<u32>, Vec<bool>) {
|
||||
attach_mark(&glyphs, &mut anchor, i);
|
||||
s.first.end = i + 1;
|
||||
s.extend_box(&g, hi);
|
||||
s.ink = s.ink.max(ink_of(glyphs[i]));
|
||||
furthest_progress = furthest_progress.max((g[0] - run_start_x).abs()).max((g[2] - run_start_x).abs());
|
||||
} else {
|
||||
segs.push(Seg::new(Run { start: i, end: i + 1 }, g, hi));
|
||||
segs.push(Seg::new(Run { start: i, end: i + 1 }, g, hi, ink_of(glyphs[i])));
|
||||
run_start_x = g[0];
|
||||
furthest_progress = 0.0;
|
||||
}
|
||||
@@ -314,7 +318,8 @@ fn arrange(glyphs: &[TextGlyph]) -> (Vec<usize>, Vec<u32>, Vec<bool>) {
|
||||
kept_runs
|
||||
};
|
||||
segs.truncate(retained);
|
||||
// Merge same-baseline segments that nearly touch, then attach any remaining superscript or
|
||||
mark_barcode_rows(&mut segs);
|
||||
// Merge same-baseline segments that nearly touch, and a padded cell with the next of its row, then attach any remaining superscript or
|
||||
// subscript segments to the closest host line segment they sit on.
|
||||
let mut merged = true;
|
||||
while merged {
|
||||
@@ -327,7 +332,8 @@ fn arrange(glyphs: &[TextGlyph]) -> (Vec<usize>, Vec<u32>, Vec<bool>) {
|
||||
let (sa, sb) = (&segs[a], &segs[b]);
|
||||
let h = sa.h.min(sb.h);
|
||||
let gap = (sb.bbox[0] - sa.bbox[2]).max(sa.bbox[0] - sb.bbox[2]);
|
||||
if (sa.cy() - sb.cy()).abs() < h * 0.3 && (sa.h / sb.h - 1.0).abs() < 0.35 && gap < h * 1.2 {
|
||||
let joined = (gap < h * 1.2 || sa.padded_towards(sb, h)) && sa.barcode == sb.barcode;
|
||||
if (sa.cy() - sb.cy()).abs() < h * 0.3 && (sa.h / sb.h - 1.0).abs() < 0.35 && joined {
|
||||
let sb = segs.remove(b);
|
||||
let a = if b < a { a - 1 } else { a };
|
||||
segs[a].merge(sb);
|
||||
@@ -347,7 +353,10 @@ fn arrange(glyphs: &[TextGlyph]) -> (Vec<usize>, Vec<u32>, Vec<bool>) {
|
||||
}
|
||||
let sa = &segs[a];
|
||||
let gap = (sb.bbox[0] - sa.bbox[2]).max(sa.bbox[0] - sb.bbox[2]);
|
||||
sa.bbox[2] - sa.bbox[0] > sb.bbox[2] - sb.bbox[0] && gap < sa.h * 1.2 && is_script_on(&sa.bbox, sa.h, &sb.bbox, sb.h)
|
||||
sa.bbox[2] - sa.bbox[0] > sb.bbox[2] - sb.bbox[0]
|
||||
&& gap < sa.h * 1.2
|
||||
&& sa.barcode == sb.barcode
|
||||
&& is_script_on(&sa.bbox, sa.h, &sb.bbox, sb.h)
|
||||
})
|
||||
.min_by(|&a1, &a2| (segs[a1].cy() - sb.cy()).abs().total_cmp(&(segs[a2].cy() - sb.cy()).abs()));
|
||||
if let Some(a) = best_host {
|
||||
@@ -374,7 +383,11 @@ fn arrange(glyphs: &[TextGlyph]) -> (Vec<usize>, Vec<u32>, Vec<bool>) {
|
||||
let vgap = s.bbox[1] - last.bbox[3];
|
||||
let overlap = s.bbox[2].min(bb[2]) - s.bbox[0].max(bb[0]);
|
||||
let minw = (s.bbox[2] - s.bbox[0]).min(bb[2] - bb[0]).max(1.0);
|
||||
vgap > -last.h * 0.5 && vgap < last.h.max(s.h) * 1.1 && overlap > minw * 0.3 && (last.h / s.h - 1.0).abs() < 0.6
|
||||
vgap > -last.h * 0.5
|
||||
&& vgap < last.h.max(s.h) * 1.1
|
||||
&& overlap > minw * 0.3
|
||||
&& (last.h / s.h - 1.0).abs() < 0.6
|
||||
&& last.barcode == s.barcode
|
||||
});
|
||||
match target {
|
||||
Some(b) => {
|
||||
@@ -387,7 +400,10 @@ fn arrange(glyphs: &[TextGlyph]) -> (Vec<usize>, Vec<u32>, Vec<bool>) {
|
||||
}
|
||||
|
||||
// 3. Block order.
|
||||
let mut remaining: Vec<usize> = (0..blocks.len()).collect();
|
||||
// A barcode drawn with a font is a picture, not text to read: it comes after the page's text
|
||||
// and does not hold up the column beside it.
|
||||
let (mut remaining, barcodes): (Vec<usize>, Vec<usize>) =
|
||||
(0..blocks.len()).partition(|&b| blocks[b].0.first().and_then(|&s| segs.get(s)).is_none_or(|s| !s.barcode));
|
||||
let mut block_order: Vec<usize> = Vec::with_capacity(blocks.len());
|
||||
// A block to the left of `cb` that shares at least half the height of the shorter one: it reads first.
|
||||
let beside_left = |cb: &[f32; 4], lb: &[f32; 4]| {
|
||||
@@ -450,6 +466,7 @@ fn arrange(glyphs: &[TextGlyph]) -> (Vec<usize>, Vec<u32>, Vec<bool>) {
|
||||
remaining.retain(|r| *r != pick);
|
||||
block_order.push(pick);
|
||||
}
|
||||
block_order.extend(barcodes);
|
||||
|
||||
// 4. Emit each segment in block order. Its glyphs are placed here, into scratch that every segment reuses, and
|
||||
// appended straight to the output. Blocks and their order use only segment boxes, so placement can wait until now.
|
||||
@@ -563,7 +580,8 @@ fn put_on_line(line_of: &mut Vec<u32>, space_before: &mut [bool], glyphs: std::o
|
||||
/// takes it: the short last line of a column of several lines is no label (review on #4468). One-line
|
||||
/// paragraphs of two columns that share a baseline cannot be told from a table row, and are further
|
||||
/// apart than [`same_line`] allows unless the left one fills its column. Right-to-left lines are
|
||||
/// left alone: their blocks still read left first.
|
||||
/// left alone: their blocks still read left first. A row of a barcode drawn with a font joins no
|
||||
/// line of text, nor text a code row.
|
||||
fn joins_line(segs: &[Seg], last: Option<(usize, bool, bool)>, si: usize, first: bool, paragraph: bool, has_rtl: bool) -> Option<&Seg> {
|
||||
let (p, p_rtl, p_alone) = last?;
|
||||
if !first || p_rtl || has_rtl {
|
||||
@@ -571,5 +589,5 @@ fn joins_line(segs: &[Seg], last: Option<(usize, bool, bool)>, si: usize, first:
|
||||
}
|
||||
let (p, s) = (segs.get(p)?, segs.get(si)?);
|
||||
let label = p_alone && p.bbox[2] - p.bbox[0] < SAME_LINE_GAP * p.h;
|
||||
(same_line(p, s) && (!paragraph || label)).then_some(p)
|
||||
(same_line(p, s) && (!paragraph || label) && p.barcode == s.barcode).then_some(p)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::TextGlyph;
|
||||
use super::device::WORD_GAP;
|
||||
use super::layout::is_rtl;
|
||||
use super::{HashMap, TextGlyph};
|
||||
|
||||
/// Glyphs `start..end` of the glyph list [`arrange`] lays out, drawn one after another.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -31,6 +31,11 @@ pub(super) struct Seg {
|
||||
extra: Option<Box<SegExtra>>,
|
||||
pub(super) bbox: [f32; 4],
|
||||
pub(super) h: f32,
|
||||
/// The right edge of its visible glyphs; the box may reach further with trailing spaces.
|
||||
/// Negative infinity when every glyph is blank.
|
||||
pub(super) ink: f32,
|
||||
/// A row of a barcode drawn with a font (see [`mark_barcode_rows`]): it joins no other text.
|
||||
pub(super) barcode: bool,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -38,6 +43,11 @@ struct SegExtra {
|
||||
more: Vec<Run>,
|
||||
}
|
||||
|
||||
/// The right edge of `g`'s ink, for [`Seg::ink`]: negative infinity for a blank glyph.
|
||||
pub(super) fn ink_of(g: &TextGlyph) -> f32 {
|
||||
if g.text.trim().is_empty() { f32::NEG_INFINITY } else { g.rect[2] }
|
||||
}
|
||||
|
||||
/// Whether `(script_bbox, script_h)` is a superscript or subscript on `(host_bbox, host_h)`.
|
||||
pub(super) fn is_script_on(host_bbox: &[f32; 4], host_h: f32, script_bbox: &[f32; 4], script_h: f32) -> bool {
|
||||
let host_cy = (host_bbox[1] + host_bbox[3]) / 2.0;
|
||||
@@ -65,8 +75,16 @@ pub(super) fn is_script_on(host_bbox: &[f32; 4], host_h: f32, script_bbox: &[f32
|
||||
}
|
||||
|
||||
impl Seg {
|
||||
pub(super) fn new(first: Run, bbox: [f32; 4], h: f32) -> Self {
|
||||
Seg { first, extra: None, bbox, h }
|
||||
pub(super) fn new(first: Run, bbox: [f32; 4], h: f32, ink: f32) -> Self {
|
||||
Seg { first, extra: None, bbox, h, ink, barcode: false }
|
||||
}
|
||||
|
||||
/// Whether this segment is a table cell padded with spaces towards `next`, the next cell on
|
||||
/// its baseline: a report generator lays rows out in fixed-width fields this way (see
|
||||
/// [`PADDED_FIELD`]).
|
||||
pub(super) fn padded_towards(&self, next: &Seg, h: f32) -> bool {
|
||||
let gap = next.bbox[0] - self.bbox[2];
|
||||
self.ink.is_finite() && self.bbox[2] - self.ink >= h * PADDED_FIELD && (0.0..h * PADDED_REACH).contains(&gap)
|
||||
}
|
||||
|
||||
pub(super) fn cy(&self) -> f32 {
|
||||
@@ -96,6 +114,7 @@ impl Seg {
|
||||
|
||||
pub(super) fn merge(&mut self, other: Seg) {
|
||||
self.extend_box(&other.bbox, other.h);
|
||||
self.ink = self.ink.max(other.ink);
|
||||
let extra = self.extra.get_or_insert_with(Default::default);
|
||||
extra.more.push(other.first);
|
||||
if let Some(o) = other.extra {
|
||||
@@ -104,6 +123,66 @@ impl Seg {
|
||||
}
|
||||
}
|
||||
|
||||
/// A barcode font (a QR or Aztec code on an invoice, as on every Mexican CFDI) draws its symbol as
|
||||
/// rows of glyphs stacked flush with no leading: at least this many rows ...
|
||||
const BARCODE_ROWS: usize = 4;
|
||||
/// ... of at least this many glyphs each.
|
||||
const BARCODE_ROW_GLYPHS: usize = 8;
|
||||
|
||||
/// Mark the segments that are rows of a barcode drawn with a font (see [`barcode_rows`]). Text
|
||||
/// beside the code sits on the same baselines and would otherwise join its rows, so the code's
|
||||
/// rows and the text would interleave in reading order.
|
||||
pub(super) fn mark_barcode_rows(segs: &mut [Seg]) {
|
||||
let rows: Vec<(usize, [f32; 4])> = segs.iter().map(|s| (if s.extra.is_none() { s.first.len() } else { 0 }, s.bbox)).collect();
|
||||
for (s, barcode) in segs.iter_mut().zip(barcode_rows(&rows)) {
|
||||
s.barcode = barcode;
|
||||
}
|
||||
}
|
||||
|
||||
/// Which of the rows of text `rows`, each as its glyph count and box, are rows of a barcode drawn
|
||||
/// with a font: runs of at least [`BARCODE_ROWS`] rows of one glyph count, height, left and right
|
||||
/// edge, each starting where the one above ends. Text set solid still has lines of different
|
||||
/// lengths and glyph counts.
|
||||
pub(super) fn barcode_rows(rows: &[(usize, [f32; 4])]) -> Vec<bool> {
|
||||
// Positions to an eighth of a point: a barcode's rows are placed exactly.
|
||||
let q = |v: f32| (v * 8.0).round() as i64;
|
||||
let height = |bb: &[f32; 4]| bb[3] - bb[1];
|
||||
let mut stacks: HashMap<(usize, i64, i64, i64), Vec<usize>> = HashMap::new();
|
||||
for (k, (n, bb)) in rows.iter().enumerate() {
|
||||
if *n >= BARCODE_ROW_GLYPHS && bb.iter().all(|v| v.is_finite()) {
|
||||
stacks.entry((*n, q(bb[0]), q(bb[2]), q(height(bb)))).or_default().push(k);
|
||||
}
|
||||
}
|
||||
let mut barcode = vec![false; rows.len()];
|
||||
for stack in stacks.values_mut().filter(|stack| stack.len() >= BARCODE_ROWS) {
|
||||
stack.sort_by(|&a, &b| rows[a].1[1].total_cmp(&rows[b].1[1]));
|
||||
let mut start = 0;
|
||||
for k in 1..=stack.len() {
|
||||
let flush = stack.get(k).is_some_and(|&r| {
|
||||
let (above, below) = (&rows[stack[k - 1]].1, &rows[r].1);
|
||||
(below[1] - above[3]).abs() <= height(above) * 0.05
|
||||
});
|
||||
if !flush {
|
||||
if k - start >= BARCODE_ROWS {
|
||||
for &r in &stack[start..k] {
|
||||
barcode[r] = true;
|
||||
}
|
||||
}
|
||||
start = k;
|
||||
}
|
||||
}
|
||||
}
|
||||
barcode
|
||||
}
|
||||
|
||||
/// A segment whose box runs on past its last visible glyph by this many line heights of spaces is
|
||||
/// a padded table cell, and the next cell on its baseline belongs to the same row if it starts
|
||||
/// within [`PADDED_REACH`] line heights of the padding. Without this, a row whose padding stops
|
||||
/// short of the next cell reads that cell after the whole table, and a drag along the row selects
|
||||
/// everything between.
|
||||
const PADDED_FIELD: f32 = 2.0;
|
||||
const PADDED_REACH: f32 = 8.0;
|
||||
|
||||
/// Two glyphs of different runs that overlap by more than this fraction of the line height collide.
|
||||
const RUN_COLLISION: f32 = 0.25;
|
||||
|
||||
|
||||
@@ -2687,6 +2687,414 @@ fn a_column_selection_across_columns_reads_row_by_row() {
|
||||
assert_eq!(t.glyph_rects(&part), vec![[10.0, 24.0, 34.0, 34.0]]);
|
||||
}
|
||||
|
||||
/// A drag along one row of a table that reads column by column selects that row, not the
|
||||
/// rest of the first column and the top of the second.
|
||||
#[test]
|
||||
fn a_drag_along_one_table_row_selects_that_row() {
|
||||
let t = table();
|
||||
let (apple, two) = (4, 17);
|
||||
assert_eq!(t.text_of(apple..two + 1), "Apple\nPear\nQty\n12", "reading order detours through the columns");
|
||||
for (anchor, head) in [(apple, two), (two, apple)] {
|
||||
let row = t.visual_between(anchor, head, None).expect("one row");
|
||||
assert_eq!(t.column_text(&row), "Apple\t12");
|
||||
assert_eq!(t.glyph_rects(&row).len(), 2, "one highlight per cell");
|
||||
}
|
||||
// Part of a row: from inside "Apple" to the "1" of "12".
|
||||
let part = t.visual_between(6, 16, None).expect("one row");
|
||||
assert_eq!(t.column_text(&part), "ple\t1");
|
||||
}
|
||||
|
||||
/// A drag over several rows of that table selects by rows too: the rest of the top row from
|
||||
/// where it started, then the bottom row up to where it ends.
|
||||
#[test]
|
||||
fn a_drag_over_table_rows_selects_those_rows() {
|
||||
let t = table();
|
||||
let (name, two) = (0, 17);
|
||||
assert_eq!(t.text_of(name..two + 1), "Name\nApple\nPear\nQty\n12", "reading order takes \"Pear\" from the row below");
|
||||
for (anchor, head) in [(name, two), (two, name)] {
|
||||
let rows = t.visual_between(anchor, head, None).expect("rows");
|
||||
assert_eq!(t.column_text(&rows), "Name\tQty\nApple\t12");
|
||||
}
|
||||
// From inside "Name" ("me") to the "1" of "12": the start of the top row and the end of
|
||||
// the bottom one are left out.
|
||||
let rows = t.visual_between(2, 16, None).expect("rows");
|
||||
assert_eq!(t.column_text(&rows), "me\tQty\nApple\t1");
|
||||
// Over three rows, ending in the other column: the middle row comes whole, though reading
|
||||
// order (down the first column, then the second) would cover the same glyphs.
|
||||
let rows = t.visual_between(1, 18, None).expect("rows");
|
||||
assert_eq!(t.column_text(&rows), "ame\tQty\nApple\t12\nPear\t7");
|
||||
// Down the second column only, reading order stays.
|
||||
assert_eq!(t.visual_between(13, 18, None), None, "from \"Qty\" down to \"7\"");
|
||||
}
|
||||
|
||||
/// A drag down the first column whose pointer ends out to the right, past the text of its
|
||||
/// last row, crosses the columns: the rows. With the pointer over the column, reading order.
|
||||
#[test]
|
||||
fn a_pointer_ending_beside_the_column_takes_the_rows() {
|
||||
let t = table();
|
||||
let pear = 9;
|
||||
assert_eq!(t.visual_between(0, pear, Some(20.0)), None, "over the first column");
|
||||
let rows = t.visual_between(0, pear, Some(150.0)).expect("rows");
|
||||
assert_eq!(t.column_text(&rows), "Name\tQty\nApple\t12\nP");
|
||||
// Running text: the rows are reading order's own glyphs, so reading order stays.
|
||||
let mut v = Vec::new();
|
||||
word(&mut v, "Heading", 10.0, 10.0, 6.0);
|
||||
word(&mut v, "A longer line of text", 10.0, 22.0, 6.0);
|
||||
let t = layout(v);
|
||||
assert_eq!(t.visual_between(0, 20, Some(200.0)), None);
|
||||
}
|
||||
|
||||
/// Side-by-side columns (a bilingual contract): a drag in the right column that ends on a list
|
||||
/// marker hanging left of its text, past a gap like a gutter, still takes nothing from the left
|
||||
/// column, where it never went.
|
||||
#[test]
|
||||
fn a_drag_in_the_right_column_takes_nothing_from_the_left() {
|
||||
let mut v = Vec::new();
|
||||
for (row, (left, right)) in [("Uno dos", "One two"), ("Tres cuatro", "Three four"), ("Cinco seis", "Five six")].into_iter().enumerate() {
|
||||
let y = 10.0 + row as f32 * 12.0;
|
||||
word(&mut v, left, 10.0, y, 6.0);
|
||||
word(&mut v, right, 230.0, y, 6.0);
|
||||
}
|
||||
word(&mut v, "II.", 200.0, 46.0, 6.0);
|
||||
word(&mut v, "Seven", 230.0, 46.0, 6.0);
|
||||
let t = layout(v);
|
||||
let one = (0..t.glyphs.len()).find(|&i| t.glyphs[i].rect[0] == 230.0 && t.glyphs[i].rect[1] == 10.0).expect("\"One\"");
|
||||
let marker = (0..t.glyphs.len()).find(|&i| t.glyphs[i].rect[0] == 200.0).expect("\"II.\"");
|
||||
let selected = t.visual_between(one, marker, Some(203.0)).unwrap_or_else(|| (one.min(marker)..=one.max(marker)).collect());
|
||||
assert!(selected.iter().all(|&i| t.glyphs[i].rect[0] >= 200.0), "{:?}", t.column_text(&selected));
|
||||
}
|
||||
|
||||
/// A justified line leaves a gap between two words wider than a column gutter, which no other
|
||||
/// row of a short drag may cross; but it parts only that line, where a gutter parts the rows.
|
||||
#[test]
|
||||
fn a_justified_gap_is_not_a_gutter() {
|
||||
let mut v = Vec::new();
|
||||
for row in 0..8 {
|
||||
word(&mut v, "Some words of a line", 10.0, 10.0 + row as f32 * 12.0, 6.0);
|
||||
}
|
||||
// Row 8: "Wide" then, 60 units on, "gap".
|
||||
word(&mut v, "Wide", 10.0, 106.0, 6.0);
|
||||
word(&mut v, "gap", 94.0, 106.0, 6.0);
|
||||
let t = layout(v);
|
||||
assert!(!t.gutter_between(20.0, 100.0, 106.0 - 40.0, 116.0 + 40.0, 10.0), "the rows above cross it");
|
||||
assert!(!t.gutter_between(20.0, 100.0, 106.0, 116.0, 10.0), "on its own row it parts one line only");
|
||||
assert!(!t.gutter_between(20.0, 200.0, 0.0, 200.0, 10.0), "past the text's right edge nothing is parted");
|
||||
// A table's columns part every row.
|
||||
assert!(table().gutter_between(20.0, 100.0, 0.0, 60.0, 10.0));
|
||||
}
|
||||
|
||||
/// A heading across the page just above a table crosses the table's gutter, but a drag over
|
||||
/// the table from its first cell to its last still takes the rows.
|
||||
#[test]
|
||||
fn a_heading_above_a_table_does_not_hide_its_gutter() {
|
||||
let mut v = Vec::new();
|
||||
word(&mut v, "A heading wider than the whole table", 10.0, -6.0, 6.0);
|
||||
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);
|
||||
}
|
||||
let t = layout(v);
|
||||
let first = (0..t.glyphs.len()).find(|&i| t.glyphs[i].text == "N").expect("\"Name\"");
|
||||
let last = (0..t.glyphs.len()).find(|&i| t.glyphs[i].text == "7").expect("\"7\"");
|
||||
let x = (t.glyphs[last].rect[0] + t.glyphs[last].rect[2]) / 2.0;
|
||||
let rows = t.visual_between(first, last, Some(x)).expect("rows");
|
||||
assert_eq!(t.column_text(&rows), "Name\tQty\nApple\t12\nPear\t7");
|
||||
}
|
||||
|
||||
/// Two columns of running text: one paragraph fills the left column and runs on at the top of
|
||||
/// the right. A drag from its start to its end, or along the first row from one column into
|
||||
/// the other, keeps reading order (the left column, then the right) instead of interleaving
|
||||
/// the columns by rows.
|
||||
#[test]
|
||||
fn a_drag_across_columns_of_running_text_keeps_reading_order() {
|
||||
let mut v = Vec::new();
|
||||
let left =
|
||||
["One paragraph starts at the top", "of the left column and runs down", "for a few ordinary lines of text", "until the column ends and then"];
|
||||
let right = ["it carries on at the top of the", "right column where the reader", "expects it to continue."];
|
||||
for (row, s) in left.into_iter().enumerate() {
|
||||
word(&mut v, s, 10.0, 10.0 + row as f32 * 14.0, 6.0);
|
||||
}
|
||||
for (row, s) in right.into_iter().enumerate() {
|
||||
word(&mut v, s, 240.0, 10.0 + row as f32 * 14.0, 6.0);
|
||||
}
|
||||
let t = layout(v);
|
||||
let last = t.glyphs.len() - 1;
|
||||
let at = |i: usize| Some((t.glyphs[i].rect[0] + t.glyphs[i].rect[2]) / 2.0);
|
||||
assert_eq!(t.visual_between(0, last, at(last)), None, "to the end of the paragraph");
|
||||
let right_start = (0..t.glyphs.len()).find(|&i| t.glyphs[i].rect[0] == 240.0 && t.glyphs[i].rect[1] == 10.0).expect("\"it\"");
|
||||
assert_eq!(t.visual_between(0, right_start + 1, at(right_start + 1)), None, "along the first row");
|
||||
}
|
||||
|
||||
/// A column of running text beside a short sidebar (not a table: most of its rows have nothing
|
||||
/// beside them): a drag from the bottom of the text up to the top of the sidebar keeps reading
|
||||
/// order, the text to its end and then the sidebar's first line, not every row between.
|
||||
#[test]
|
||||
fn a_sidebar_beside_running_text_is_not_a_table() {
|
||||
let mut v = Vec::new();
|
||||
for row in 0..10 {
|
||||
word(&mut v, "Some running text of a longer line", 10.0, 10.0 + row as f32 * 12.0, 6.0);
|
||||
}
|
||||
for (row, s) in ["Dates", "3 May: fair", "4 May: open day"].into_iter().enumerate() {
|
||||
word(&mut v, s, 260.0, 10.0 + row as f32 * 20.0, 6.0);
|
||||
}
|
||||
let t = layout(v);
|
||||
let last_line_start =
|
||||
(0..t.glyphs.len()).find(|&i| t.glyphs[i].rect[0] == 10.0 && t.glyphs[i].rect[1] == 10.0 + 9.0 * 12.0).expect("last line start");
|
||||
let dates = (0..t.glyphs.len()).find(|&i| t.glyphs[i].rect[0] == 260.0 && t.glyphs[i].rect[1] == 10.0).expect("\"Dates\"");
|
||||
let x = Some((t.glyphs[dates].rect[0] + t.glyphs[dates].rect[2]) / 2.0);
|
||||
assert_eq!(t.visual_between(last_line_start, dates + 3, x), None, "from the last prose line up to the sidebar");
|
||||
assert_eq!(t.visual_between(dates + 3, last_line_start, x), None, "and back");
|
||||
}
|
||||
|
||||
/// Short terms beside their definitions (a description list) are not running text: a drag
|
||||
/// across them takes the rows.
|
||||
#[test]
|
||||
fn terms_beside_definitions_still_take_the_rows() {
|
||||
let mut v = Vec::new();
|
||||
for (row, (term, def)) in
|
||||
[("PDF/UA", "PDF for accessibility"), ("PDF/A", "PDF for long-term preservation"), ("PDF/E", "PDF for engineering workflows")]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let y = 10.0 + row as f32 * 14.0;
|
||||
word(&mut v, term, 10.0, y, 6.0);
|
||||
word(&mut v, def, 100.0, y, 6.0);
|
||||
}
|
||||
let t = layout(v);
|
||||
let first = 0;
|
||||
let eng = t.find("engineering").into_iter().next().expect("\"engineering\"").start;
|
||||
let x = Some((t.glyphs[eng].rect[0] + t.glyphs[eng].rect[2]) / 2.0);
|
||||
let rows = t.visual_between(first, eng, x).expect("rows");
|
||||
assert_eq!(t.column_text(&rows), "PDF/UA\tPDF for accessibility\nPDF/A\tPDF for long-term preservation\nPDF/E\tPDF for e");
|
||||
}
|
||||
|
||||
/// Reading order stays the answer on one line, down one column, and where it doesn't leave
|
||||
/// the rows the drag covers.
|
||||
#[test]
|
||||
fn a_drag_that_reading_order_already_covers_keeps_it() {
|
||||
let t = table();
|
||||
assert_eq!(t.visual_between(4, 8, None), None, "within \"Apple\"");
|
||||
assert_eq!(t.visual_between(0, 10, None), None, "down the first column, from \"Name\" into \"Pear\"");
|
||||
assert_eq!(t.visual_between(4, usize::MAX, None), None, "past the text");
|
||||
// One row of two cells reads as one line, so reading order stays on the row.
|
||||
let mut v = Vec::new();
|
||||
word(&mut v, "Left", 10.0, 10.0, 6.0);
|
||||
word(&mut v, "Right", 100.0, 10.0, 6.0);
|
||||
let t = layout(v);
|
||||
assert_eq!(t.plain_text(), "Left Right");
|
||||
assert_eq!(t.visual_between(0, 8, None), None);
|
||||
}
|
||||
|
||||
/// A report generator pads each cell of a row with spaces. A cell whose padding stops short of
|
||||
/// the next cell (here 62 units, its last letter further from the next cell than a line reaches)
|
||||
/// still reads as one row with it, instead of the next cell coming after the whole table.
|
||||
#[test]
|
||||
fn a_cell_padded_with_spaces_joins_the_next_cell_of_its_row() {
|
||||
let line_of_cells = |cell: &str, next_x: f32| {
|
||||
let mut v = Vec::new();
|
||||
word(&mut v, cell, 10.0, 10.0, 6.0);
|
||||
word(&mut v, "9.00", next_x, 10.0, 6.0);
|
||||
let t = layout(v);
|
||||
let last = t.glyphs.len() - 1;
|
||||
t.line_of[0] == t.line_of[last]
|
||||
};
|
||||
assert!(line_of_cells("Item ", 120.0), "padded to x 58, the amount 62 further on");
|
||||
assert!(!line_of_cells("Item", 120.0), "unpadded, the gap is a column gap");
|
||||
assert!(!line_of_cells("Item ", 160.0), "padded, but the next cell is too far");
|
||||
}
|
||||
|
||||
/// Past the end of a short row, the pointer stays on that row: a longer row just below,
|
||||
/// nearer by the old distance, used to take the drag's end into it.
|
||||
#[test]
|
||||
fn a_point_level_with_a_row_takes_that_rows_nearest_glyph() {
|
||||
let mut v = Vec::new();
|
||||
word(&mut v, "Short", 10.0, 10.0, 6.0);
|
||||
word(&mut v, "A much longer row", 10.0, 22.0, 6.0);
|
||||
let t = layout(v);
|
||||
// Level with "Short" (y 10..20, ending at x 40), 60 past it, above the long row.
|
||||
assert_eq!(t.nearest(100.0, 15.0), Some(4), "the \"t\" of \"Short\"");
|
||||
// In the gap between the rows the nearer glyph still wins.
|
||||
let below = t.nearest(100.0, 21.5).and_then(|i| t.line_of.get(i).copied());
|
||||
assert_eq!(below, Some(1), "just above the long row, nearer to it than to the short one");
|
||||
assert_eq!(PageText::default().nearest(0.0, 0.0), None);
|
||||
assert!(t.nearest(f32::NAN, 15.0).is_some(), "a NaN point still answers, never panics");
|
||||
}
|
||||
|
||||
/// Two columns whose baselines don't line up: a point in the leading between two right-hand
|
||||
/// lines, level with a left-hand line far away, takes the right-hand line next to it.
|
||||
#[test]
|
||||
fn a_point_between_lines_is_not_taken_by_a_far_line_level_with_it() {
|
||||
let mut v = Vec::new();
|
||||
for row in 0..3 {
|
||||
word(&mut v, "Left text", 10.0, 10.0 + row as f32 * 12.0, 6.0);
|
||||
}
|
||||
for row in 0..2 {
|
||||
word(&mut v, "Right text", 200.0, 16.0 + row as f32 * 12.0, 6.0);
|
||||
}
|
||||
let t = layout(v);
|
||||
// y 27: between the right lines (16..26 and 28..38), within the left line at 22..32.
|
||||
let i = t.nearest(205.0, 27.0).expect("a glyph");
|
||||
assert!(t.glyphs[i].rect[0] >= 200.0, "took {:?} at {:?}", t.glyphs[i].text, t.glyphs[i].rect);
|
||||
}
|
||||
|
||||
/// The mirror image: a point in the gutter just right of the left column, between two of its
|
||||
/// lines and level with a right-hand line, takes the left-hand line beside it.
|
||||
#[test]
|
||||
fn a_point_in_the_gutter_is_not_taken_by_the_far_column_level_with_it() {
|
||||
let mut v = Vec::new();
|
||||
for row in 0..3 {
|
||||
word(&mut v, "Left text", 10.0, 10.0 + row as f32 * 12.0, 6.0);
|
||||
}
|
||||
for row in 0..2 {
|
||||
word(&mut v, "Right text", 200.0, 16.0 + row as f32 * 12.0, 6.0);
|
||||
}
|
||||
let t = layout(v);
|
||||
// y 21: between the left lines (10..20 and 22..32), within the right line at 16..26.
|
||||
let i = t.nearest(70.0, 21.0).expect("a glyph");
|
||||
assert!(t.glyphs[i].rect[2] <= 64.0, "took {:?} at {:?}", t.glyphs[i].text, t.glyphs[i].rect);
|
||||
}
|
||||
|
||||
/// The same, with the left line level with the point the longest of its column (ragged right):
|
||||
/// the column's shorter lines still mark the gutter.
|
||||
#[test]
|
||||
fn a_long_level_line_does_not_hide_the_gutter_from_its_column() {
|
||||
let mut v = Vec::new();
|
||||
for (row, s) in ["Left text", "Left text longer", "Left text"].into_iter().enumerate() {
|
||||
word(&mut v, s, 10.0, 10.0 + row as f32 * 12.0, 6.0);
|
||||
}
|
||||
for row in 0..2 {
|
||||
word(&mut v, "Right text", 200.0, 16.0 + row as f32 * 12.0, 6.0);
|
||||
}
|
||||
let t = layout(v);
|
||||
let i = t.nearest(205.0, 27.0).expect("a glyph");
|
||||
assert!(t.glyphs[i].rect[0] >= 200.0, "took {:?} at {:?}", t.glyphs[i].text, t.glyphs[i].rect);
|
||||
}
|
||||
|
||||
/// An invoice's seal beside its QR code, the code drawn with a barcode font: eight flush rows of
|
||||
/// twelve 4 × 6 pt glyphs, and five 8 pt lines of text 5 pt to their right, every other one on a
|
||||
/// code row's baseline.
|
||||
fn barcode_beside_text() -> PageText {
|
||||
const SEAL: [&str; 5] = ["one two three", "four five six", "seven eight", "nine ten", "eleven twelve"];
|
||||
let mut v = Vec::new();
|
||||
for row in 0..8 {
|
||||
let y = 100.0 + row as f32 * 6.0;
|
||||
for k in 0..12 {
|
||||
let c = char::from(b'A' + ((row * 5 + k) % 26) as u8);
|
||||
let x = 10.0 + k as f32 * 4.0;
|
||||
v.push(em_box(&c.to_string(), x, y, x + 4.0, y + 6.0));
|
||||
}
|
||||
}
|
||||
for (line, text) in SEAL.into_iter().enumerate() {
|
||||
let y = 100.0 + line as f32 * 9.0;
|
||||
for (k, c) in text.chars().enumerate() {
|
||||
let x = 63.0 + k as f32 * 4.8;
|
||||
v.push(em_box(&c.to_string(), x, y, x + 4.8, y + 8.0));
|
||||
}
|
||||
}
|
||||
layout(v)
|
||||
}
|
||||
|
||||
/// The seal's lines read on together, not each joined to the code row beside it, and the code
|
||||
/// reads after all the text.
|
||||
#[test]
|
||||
fn a_barcode_drawn_with_a_font_reads_apart_from_the_text_beside_it() {
|
||||
let t = barcode_beside_text();
|
||||
let text = t.plain_text();
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
assert_eq!(lines.get(..5), Some(&["one two three", "four five six", "seven eight", "nine ten", "eleven twelve"][..]), "{text}");
|
||||
assert_eq!(lines.len(), 13, "{text}");
|
||||
assert!(lines[5..].iter().all(|l| l.len() == 12 && l.chars().all(|c| c.is_ascii_uppercase())), "{text}");
|
||||
}
|
||||
|
||||
/// A point in the leading right of the seal's text, level with a row of the code, takes the
|
||||
/// seal's line, and a drag down the seal selects none of the code.
|
||||
#[test]
|
||||
fn a_point_beside_a_barcode_takes_the_text_not_the_code_row() {
|
||||
let t = barcode_beside_text();
|
||||
let is_code = |i: usize| t.glyphs[i].rect[2] <= 58.0;
|
||||
// Right of "four five six" (ends at 125.4), in the leading below it and level with code row 3.
|
||||
let i = t.nearest(120.0, 117.5).expect("a glyph");
|
||||
assert!(!is_code(i), "took the code's {:?} at {:?}", t.glyphs[i].text, t.glyphs[i].rect);
|
||||
// Over the code, its row still wins.
|
||||
let i = t.nearest(30.0, 117.5).expect("a glyph");
|
||||
assert!(is_code(i));
|
||||
// From "one" to the end of "nine ten", the pointer past it.
|
||||
let (one, ten) = (t.nearest(65.0, 104.0).expect("one"), t.nearest(100.0, 131.0).expect("ten"));
|
||||
let picked = t.visual_between(one, ten, Some(110.0)).unwrap_or_else(|| (one.min(ten)..=one.max(ten)).collect());
|
||||
assert!(!picked.iter().any(|&i| is_code(i)), "{:?}", t.column_text(&picked));
|
||||
}
|
||||
|
||||
/// A short word on the baseline of a barcode's first row, a label's width to its left, stays a
|
||||
/// line of its own: the code row does not go on the word's line, and the code keeps all its rows.
|
||||
#[test]
|
||||
fn a_word_on_a_barcode_rows_baseline_does_not_take_the_row() {
|
||||
let mut v = Vec::new();
|
||||
for (k, c) in "Seal".chars().enumerate() {
|
||||
let x = 10.0 + k as f32 * 4.0;
|
||||
v.push(em_box(&c.to_string(), x, 100.0, x + 4.0, 106.0));
|
||||
}
|
||||
for row in 0..4 {
|
||||
let y = 100.0 + row as f32 * 6.0;
|
||||
for k in 0..12 {
|
||||
let c = char::from(b'A' + ((row * 5 + k) % 26) as u8);
|
||||
let x = 50.0 + k as f32 * 4.0;
|
||||
v.push(em_box(&c.to_string(), x, y, x + 4.0, y + 6.0));
|
||||
}
|
||||
}
|
||||
assert_eq!(layout(v).plain_text(), "Seal\nABCDEFGHIJKL\nFGHIJKLMNOPQ\nKLMNOPQRSTUV\nPQRSTUVWXYZA");
|
||||
}
|
||||
|
||||
/// A table beside a line of prose on one of its rows: the prose, left of everywhere the drag
|
||||
/// went, does not count as a row with one cell, and a drag down the table still takes its rows.
|
||||
#[test]
|
||||
fn prose_left_of_a_table_does_not_hide_its_rows() {
|
||||
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, 200.0, y, 6.0);
|
||||
word(&mut v, qty, 290.0, y, 6.0);
|
||||
}
|
||||
word(&mut v, "Some unrelated prose", 10.0, 17.0, 6.0);
|
||||
let t = layout(v);
|
||||
let at = |c: &str, x: f32| t.glyphs.iter().position(|g| g.text == c && (g.rect[0] - x).abs() < 0.5).expect("a glyph");
|
||||
let (name, two) = (at("N", 200.0), at("2", 296.0));
|
||||
let picked = t.visual_between(name, two, Some(t.glyphs[two].rect[2] + 4.0)).expect("rows");
|
||||
assert_eq!(t.column_text(&picked), "Name\tQty\nApple\t12");
|
||||
}
|
||||
|
||||
/// A double-click drag between two words of one table row, or of two rows, takes both whole
|
||||
/// in either direction. A right-to-left word reads from its right end, so its first glyph in
|
||||
/// reading order is not its left end.
|
||||
#[test]
|
||||
fn row_ends_take_both_words_whole() {
|
||||
let t = table();
|
||||
let rows = |t: &PageText, from: usize, to: usize| {
|
||||
let (unit, other) = (t.word_at(from).expect("a word"), t.word_at(to).expect("a word"));
|
||||
let (anchor, head) = t.row_ends(unit, other);
|
||||
t.visual_between(anchor, head, Some(t.glyphs[to].rect[2])).map(|p| t.column_text(&p))
|
||||
};
|
||||
// "Name" 0..=3, "Apple" 4..=8, "Qty" 13..=15.
|
||||
for (from, to, copied) in [(1, 14, "Name\tQty"), (14, 1, "Name\tQty"), (14, 6, "Qty\nApple"), (6, 14, "Qty\nApple")] {
|
||||
assert_eq!(rows(&t, from, to).as_deref(), Some(copied), "{from} to {to}");
|
||||
}
|
||||
|
||||
// Two columns of Hebrew words, drawn visually (each word's letters left to right reversed).
|
||||
let mut v = Vec::new();
|
||||
for (row, (a, b)) in [("םש", "תומכ"), ("חופת", "רשע"), ("סגא", "עבש")].into_iter().enumerate() {
|
||||
let y = 10.0 + row as f32 * 14.0;
|
||||
word(&mut v, a, 10.0, y, 6.0);
|
||||
word(&mut v, b, 100.0, y, 6.0);
|
||||
}
|
||||
let t = layout(v);
|
||||
let at = |x: f32| t.glyphs.iter().position(|g| (g.rect[0] - x).abs() < 0.5 && (g.rect[1] - 10.0).abs() < 0.5).expect("a glyph");
|
||||
let (left, right) = (at(10.0), at(100.0));
|
||||
for (from, to) in [(left, right), (right, left)] {
|
||||
let picked = rows(&t, from, to).expect("rows");
|
||||
assert!(picked.contains("שם") && picked.contains("כמות"), "{picked:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A rectangle that misses every glyph, or is not a number, selects nothing.
|
||||
#[test]
|
||||
fn an_empty_or_degenerate_column_selection_is_empty() {
|
||||
@@ -2731,6 +3139,10 @@ fn vertical_lines_keep_reading_order() {
|
||||
};
|
||||
let all = t.glyphs_in([0.0, 0.0, 60.0, 40.0]);
|
||||
assert_eq!(t.column_text(&all), "日本\n語文");
|
||||
// A drag from one vertical column into the next at the same height is not along a row:
|
||||
// reading order stays (本 then 語文, down each column).
|
||||
assert_eq!(t.visual_between(1, 3, Some(25.0)), None, "from 本 across to 文");
|
||||
assert_eq!(t.visual_between(0, 2, Some(25.0)), None, "from 日 across to 語");
|
||||
}
|
||||
|
||||
/// An embedded TrueType font with no `/Encoding`, flagged `flags`, its `/ToUnicode` (if
|
||||
@@ -2892,7 +3304,7 @@ fn paragraph_selection_includes_lines_with_footnote_references() {
|
||||
|
||||
// Pin that internal band too: replacing the first extend_box condition with `false`
|
||||
// must fail here even when plain text and highlight rectangles remain unchanged.
|
||||
let mut segment = Seg::new(Run { start: 0, end: 1 }, marker, marker[3] - marker[1]);
|
||||
let mut segment = Seg::new(Run { start: 0, end: 1 }, marker, marker[3] - marker[1], marker[2]);
|
||||
segment.extend_box(&body, body[3] - body[1]);
|
||||
for (actual, expected) in segment.bbox.into_iter().zip([marker[0], body[1], body[2], body[3]]) {
|
||||
close(actual, expected);
|
||||
|
||||
@@ -820,17 +820,25 @@ fn update_dragged_selection(view: &mut DocView, text: &PageText, gesture: &TextG
|
||||
&& let Some(selection) = view.selection.as_mut().filter(|selection| selection.page == gesture.page)
|
||||
{
|
||||
match &mut selection.span {
|
||||
Span::Run { anchor, head } if clicks >= 2 => {
|
||||
Span::Run { anchor, head, head_x } if clicks >= 2 => {
|
||||
if let Some(hit) = text.nearest(gesture.vx, gesture.vy)
|
||||
&& let Some((first, last)) = click_unit(text, clicks, *anchor)
|
||||
{
|
||||
let (to_first, to_last) = click_unit(text, clicks, hit).unwrap_or((hit, hit));
|
||||
(*anchor, *head) = if to_first < first { (last, to_first) } else { (first, to_last.max(last)) };
|
||||
// A drag, so it selects table rows as a plain drag does, by whole units.
|
||||
// The page unit (four clicks) is the page's text in reading order.
|
||||
*head_x = Some(gesture.vx).filter(|x| x.is_finite() && clicks < 4);
|
||||
let in_order = if to_first < first { (last, to_first) } else { (first, to_last.max(last)) };
|
||||
// Rows take both units whole, from the earlier one's start to the later one's end.
|
||||
let by_rows = text.row_ends((first, last), (to_first, to_last));
|
||||
let rows = head_x.is_some() && text.visual_between(by_rows.0, by_rows.1, *head_x).is_some();
|
||||
(*anchor, *head) = if rows { by_rows } else { in_order };
|
||||
}
|
||||
}
|
||||
Span::Run { head, .. } => {
|
||||
Span::Run { head, head_x, .. } => {
|
||||
if let Some(hit) = text.nearest(gesture.vx, gesture.vy) {
|
||||
*head = hit;
|
||||
*head_x = Some(gesture.vx).filter(|x| x.is_finite());
|
||||
}
|
||||
}
|
||||
Span::Column { rect } => {
|
||||
@@ -873,7 +881,10 @@ fn finish_text_pointer_gesture(ui: &egui::Ui, view: &mut DocView, text: &PageTex
|
||||
view.selection.as_mut().filter(|selection| shift && selection.page == gesture.page).map(|selection| &mut selection.span),
|
||||
text.nearest(gesture.vx, gesture.vy),
|
||||
) {
|
||||
(Some(Span::Run { head, .. }), Some(hit)) => *head = hit,
|
||||
(Some(Span::Run { head, head_x, .. }), Some(hit)) => {
|
||||
*head = hit;
|
||||
*head_x = Some(gesture.vx).filter(|x| x.is_finite());
|
||||
}
|
||||
_ => view.selection = None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,8 +9,10 @@ pub(super) struct Selection {
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub(super) enum Span {
|
||||
/// Reading order, between two glyph indices.
|
||||
Run { anchor: usize, head: usize },
|
||||
/// Reading order, between two glyph indices. `head_x` is where a drag's pointer ended (page
|
||||
/// view space), which may lie well past the head glyph, as in the empty end of a table row;
|
||||
/// `None` when the head came from a click or a command.
|
||||
Run { anchor: usize, head: usize, head_x: Option<f32> },
|
||||
/// Column select (#740): the glyphs inside a rectangle in page view space, drawn from the
|
||||
/// corner the drag started at (`rect[0..2]`) to the pointer (`rect[2..4]`).
|
||||
Column { rect: [f32; 4] },
|
||||
@@ -18,17 +20,28 @@ pub(super) enum Span {
|
||||
|
||||
impl Selection {
|
||||
pub(super) fn run(page: usize, anchor: usize, head: usize) -> Self {
|
||||
Self { page, span: Span::Run { anchor, head } }
|
||||
Self { page, span: Span::Run { anchor, head, head_x: None } }
|
||||
}
|
||||
|
||||
fn range(anchor: usize, head: usize) -> Range<usize> {
|
||||
anchor.min(head)..anchor.max(head).saturating_add(1)
|
||||
}
|
||||
|
||||
/// What a drag over table rows selects by rows, not reading order's detour down each column.
|
||||
/// Only a drag (also one after a double- or triple-click) or a ⇧-click has a pointer end; a
|
||||
/// clicked word, line or page, Select all and a command's selection keep reading order, which
|
||||
/// is the text they stand for.
|
||||
fn by_rows(text: &PageText, anchor: usize, head: usize, head_x: Option<f32>) -> Option<Vec<usize>> {
|
||||
head_x.and_then(|x| text.visual_between(anchor, head, Some(x)))
|
||||
}
|
||||
|
||||
/// One rectangle per selected piece of a line, in view space (highlighting, markup quads).
|
||||
pub(super) fn rects(&self, text: &PageText) -> Vec<[f32; 4]> {
|
||||
match self.span {
|
||||
Span::Run { anchor, head } => text.line_rects(Self::range(anchor, head)),
|
||||
Span::Run { anchor, head, head_x } => match Self::by_rows(text, anchor, head, head_x) {
|
||||
Some(row) => text.glyph_rects(&row),
|
||||
None => text.line_rects(Self::range(anchor, head)),
|
||||
},
|
||||
Span::Column { rect } => text.glyph_rects(&text.glyphs_in(rect)),
|
||||
}
|
||||
}
|
||||
@@ -36,7 +49,10 @@ impl Selection {
|
||||
/// The selected text, as Copy puts it on the clipboard.
|
||||
pub(super) fn text(&self, text: &PageText) -> String {
|
||||
match self.span {
|
||||
Span::Run { anchor, head } => text.text_of(Self::range(anchor, head)),
|
||||
Span::Run { anchor, head, head_x } => match Self::by_rows(text, anchor, head, head_x) {
|
||||
Some(row) => text.column_text(&row),
|
||||
None => text.text_of(Self::range(anchor, head)),
|
||||
},
|
||||
Span::Column { rect } => text.column_text(&text.glyphs_in(rect)),
|
||||
}
|
||||
}
|
||||
@@ -99,8 +115,8 @@ impl DocView {
|
||||
}
|
||||
|
||||
/// The selection's page, its text layer and the selected glyphs in copy order, for Copy with
|
||||
/// formatting. `None` after Select All of the document and for a column selection, which
|
||||
/// copy plain text (a column's rows and tab-separated cells are not paragraphs).
|
||||
/// formatting. `None` after Select All of the document and for a column selection or a table's
|
||||
/// rows, which copy plain text (rows and tab-separated cells are not paragraphs).
|
||||
pub(crate) fn selected_glyphs(&self) -> Option<(usize, Arc<PageText>, Vec<usize>)> {
|
||||
if self.all_text {
|
||||
return None;
|
||||
@@ -108,8 +124,11 @@ impl DocView {
|
||||
let s = self.selection?;
|
||||
let t = self.texts.get(&s.page)?;
|
||||
let glyphs = match s.span {
|
||||
Span::Run { anchor, head } => Selection::range(anchor, head).take(t.glyphs.len()).collect(),
|
||||
Span::Column { .. } => return None,
|
||||
Span::Run { anchor, head, head_x } if Selection::by_rows(t, anchor, head, head_x).is_none() => {
|
||||
Selection::range(anchor, head).take(t.glyphs.len()).collect()
|
||||
}
|
||||
// A table's rows, like a column, are tab-separated cells.
|
||||
Span::Run { .. } | Span::Column { .. } => return None,
|
||||
};
|
||||
Some((s.page, t.clone(), glyphs))
|
||||
}
|
||||
|
||||
@@ -82,9 +82,10 @@ fn top_two_rows(h: &Harness<'static, PdfCraftApp>) -> (Pos2, Pos2) {
|
||||
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".
|
||||
// A plain drag selects the rows it covers. Reading order, column by column, would take "Pear"
|
||||
// from the row below on its way from "Name" to "12".
|
||||
drag(&mut h, from, to, Modifiers::NONE);
|
||||
assert_eq!(text(&h).as_deref(), Some("Name\nApple\nPear\nQty\n12"));
|
||||
assert_eq!(text(&h).as_deref(), Some("Name\tQty\nApple\t12"));
|
||||
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);
|
||||
@@ -168,3 +169,189 @@ fn a_column_selection_copies_plain_text_only() {
|
||||
let copied = h.output().platform_output.commands.iter().any(|c| matches!(c, egui::OutputCommand::CopyText(t) if t == "Name\tQty\nApple\t12"));
|
||||
assert!(copied, "the plain text goes out the usual way");
|
||||
}
|
||||
|
||||
/// A plain drag along one row of a table that reads column by column selects that row, as
|
||||
/// Preview does, not the rest of the first column and the top of the second between its ends.
|
||||
/// The highlighter marks the same cells.
|
||||
#[test]
|
||||
fn a_plain_drag_along_one_table_row_selects_only_that_row() {
|
||||
let mut h = harness(|_| {});
|
||||
// From just left of "Apple" to past "12", level with both.
|
||||
let (from, to) = (glyph(&h, 4) - vec2(4.0, 0.0), glyph(&h, 17) + vec2(12.0, 0.0));
|
||||
for (a, b) in [(from, to), (to, from)] {
|
||||
h.key_press(egui::Key::Escape);
|
||||
h.run_steps(60);
|
||||
drag(&mut h, a, b, Modifiers::NONE);
|
||||
assert_eq!(text(&h).as_deref(), Some("Apple\t12"));
|
||||
}
|
||||
|
||||
let mut h = harness(|app| assert!(app.execute("comment.highlight")));
|
||||
// The highlighter starts on text: the centre of "A".
|
||||
let (from, to) = (glyph(&h, 4), glyph(&h, 17) + vec2(12.0, 0.0));
|
||||
drag(&mut h, from, to, Modifiers::NONE);
|
||||
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", 2)], "Apple and 12, each its own quad");
|
||||
}
|
||||
|
||||
/// A drag down from "Name" that ends in the gap between the columns, level with "Pear", crosses
|
||||
/// into the rows: the nearest glyph is still in the first column, but the pointer is not.
|
||||
#[test]
|
||||
fn a_drag_ending_between_the_columns_selects_the_rows() {
|
||||
let mut h = harness(|_| {});
|
||||
let (from, pear) = (glyph(&h, 0) - vec2(4.0, 0.0), glyph(&h, 12));
|
||||
// A little right of "Pear", well short of "7".
|
||||
let gap = pear + vec2((glyph(&h, 18).x - pear.x) / 4.0, 0.0);
|
||||
drag(&mut h, from, gap, Modifiers::NONE);
|
||||
assert_eq!(text(&h).as_deref(), Some("Name\tQty\nApple\t12\nPear"));
|
||||
// Over the first column, the drag stays in it.
|
||||
h.key_press(egui::Key::Escape);
|
||||
h.run_steps(60);
|
||||
drag(&mut h, from, pear, Modifiers::NONE);
|
||||
assert_eq!(text(&h).as_deref(), Some("Name\nApple\nPear"));
|
||||
}
|
||||
|
||||
/// Two article columns, the right one a line shorter: its last glyph, the end of reading order,
|
||||
/// sits above the left column's last line.
|
||||
fn uneven_columns() -> Vec<u8> {
|
||||
let content = "BT /F1 14 Tf \
|
||||
1 0 0 1 20 250 Tm (Left first line.) Tj 1 0 0 1 220 250 Tm (Right first line.) Tj \
|
||||
1 0 0 1 20 225 Tm (Left other line.) Tj 1 0 0 1 220 225 Tm (Right final line.) Tj \
|
||||
1 0 0 1 20 200 Tm (Left final line.) Tj ET";
|
||||
format!(
|
||||
"%PDF-1.7\n\
|
||||
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\
|
||||
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 400 300] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >> endobj\n\
|
||||
4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\
|
||||
5 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Courier >> endobj\n\
|
||||
trailer << /Root 1 0 R >>\n%%EOF",
|
||||
content.len()
|
||||
)
|
||||
.into_bytes()
|
||||
}
|
||||
|
||||
/// Select all takes the page's text in reading order, whole: it has no pointer, so the rows a
|
||||
/// drag would take don't apply, though its last glyph is not the lowest.
|
||||
#[test]
|
||||
fn select_all_keeps_every_line_of_uneven_columns() {
|
||||
let mut h = uneven_harness();
|
||||
assert!(h.state_mut().views[0].select_all());
|
||||
h.run_steps(1);
|
||||
assert_eq!(text(&h).as_deref(), Some(UNEVEN_PAGE_TEXT));
|
||||
}
|
||||
|
||||
/// Four clicks select the page, and a pointer that moves on a little after the fourth press
|
||||
/// keeps it whole: the page unit has no rows to take.
|
||||
#[test]
|
||||
fn four_clicks_then_a_short_drag_keep_the_whole_page() {
|
||||
let mut h = uneven_harness();
|
||||
let at = glyph(&h, 2);
|
||||
for _ in 0..3 {
|
||||
for pressed in [true, false] {
|
||||
h.hover_at(at);
|
||||
h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
|
||||
h.run_steps(1);
|
||||
}
|
||||
}
|
||||
drag(&mut h, at, at + vec2(30.0, 8.0), Modifiers::NONE);
|
||||
assert_eq!(text(&h).as_deref(), Some(UNEVEN_PAGE_TEXT));
|
||||
}
|
||||
|
||||
fn uneven_harness() -> Harness<'static, PdfCraftApp> {
|
||||
opened("uneven.pdf", uneven_columns())
|
||||
}
|
||||
|
||||
/// A harness with the one-page PDF `bytes` open, its text layer ready.
|
||||
fn opened(name: &'static str, bytes: Vec<u8>) -> 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(name, None, bytes).expect("opens");
|
||||
app
|
||||
});
|
||||
crate::support::wait_until(&mut h, "the page's text layer", |app| app.views[0].glyph_screen_pos(0, 0).is_some() && !app.render_pending());
|
||||
h
|
||||
}
|
||||
|
||||
const UNEVEN_PAGE_TEXT: &str = "Left first line.\nLeft other line.\nLeft final line.\nRight first line.\nRight final line.";
|
||||
|
||||
/// A drag after a double-click extends by whole words, and over a table it takes the rows as a
|
||||
/// plain drag does: from "Name" down to "12", not reading order's detour through "Pear".
|
||||
#[test]
|
||||
fn a_double_click_drag_over_table_rows_selects_the_rows() {
|
||||
let mut h = harness(|_| {});
|
||||
let (name, two) = (glyph(&h, 1), glyph(&h, 17));
|
||||
for pressed in [true, false] {
|
||||
h.hover_at(name);
|
||||
h.event(Event::PointerButton { pos: name, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
|
||||
h.run_steps(1);
|
||||
}
|
||||
drag(&mut h, name, two, Modifiers::NONE);
|
||||
assert_eq!(text(&h).as_deref(), Some("Name\tQty\nApple\t12"));
|
||||
}
|
||||
|
||||
/// An invoice's seal beside its QR code, the code drawn with a font as on a Mexican CFDI: eight
|
||||
/// rows of twelve 6 pt Courier glyphs stacked flush (6 pt apart), and five 8 pt lines 5 pt to their
|
||||
/// right, every other one on a code row's baseline. The seal reads first: glyphs 0–12 "one two
|
||||
/// three", 13–25 "four five six", 26–36 "seven eight", 37–44 "nine ten", 45–57 "eleven twelve".
|
||||
fn barcode_beside_seal() -> Vec<u8> {
|
||||
let rows: String = BARCODE_ROWS.iter().map(|row| format!("({row}) Tj T* ")).collect();
|
||||
let seal: String = SEAL.iter().map(|line| format!("({line}) Tj T* ")).collect();
|
||||
let content = format!("BT /F1 6 Tf 6 TL 20 250 Td {rows}ET BT /F1 8 Tf 9 TL 68.2 250 Td {seal}ET");
|
||||
format!(
|
||||
"%PDF-1.7\n\
|
||||
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\
|
||||
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >> endobj\n\
|
||||
4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\
|
||||
5 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Courier >> endobj\n\
|
||||
trailer << /Root 1 0 R >>\n%%EOF",
|
||||
content.len()
|
||||
)
|
||||
.into_bytes()
|
||||
}
|
||||
|
||||
const BARCODE_ROWS: [&str; 8] =
|
||||
["ABCDEFGHIJKL", "FGHIJKLMNOPQ", "KLMNOPQRSTUV", "PQRSTUVWXYZA", "UVWXYZABCDEF", "ZABCDEFGHIJK", "EFGHIJKLMNOP", "JKLMNOPQRSTU"];
|
||||
const SEAL: [&str; 5] = ["one two three", "four five six", "seven eight", "nine ten", "eleven twelve"];
|
||||
|
||||
/// A drag down the seal beside a QR code drawn with a font selects the seal's lines, in order,
|
||||
/// and none of the code, whichever way it goes; the code's rows would otherwise join the seal's
|
||||
/// lines level with them.
|
||||
#[test]
|
||||
fn a_drag_down_text_beside_a_barcode_selects_only_the_text() {
|
||||
let mut h = opened("cfdi.pdf", barcode_beside_seal());
|
||||
// From just left of "one" to past the end of "nine ten".
|
||||
let (from, to) = (glyph(&h, 0) - vec2(4.0, 0.0), glyph(&h, 44) + vec2(12.0, 0.0));
|
||||
for (a, b) in [(from, to), (to, from)] {
|
||||
h.key_press(egui::Key::Escape);
|
||||
h.run_steps(60);
|
||||
drag(&mut h, a, b, Modifiers::NONE);
|
||||
assert_eq!(text(&h).as_deref(), Some("one two three\nfour five six\nseven eight\nnine ten"));
|
||||
}
|
||||
// Select all: the seal, then the code.
|
||||
assert!(h.state_mut().views[0].select_all());
|
||||
h.run_steps(1);
|
||||
assert_eq!(text(&h), Some([SEAL.join("\n"), BARCODE_ROWS.join("\n")].join("\n")));
|
||||
}
|
||||
|
||||
/// A drag after a double-click on "Qty", left and down into "Apple", takes both words whole, as
|
||||
/// rows: reading order has "Apple" first, but the rows start at the upper word. So does the drag
|
||||
/// the other way, from "Apple" up into "Qty", and along one row, from "Name" into "Qty" and back.
|
||||
#[test]
|
||||
fn a_double_click_drag_back_across_the_columns_keeps_whole_words() {
|
||||
for (from, to, copied) in [(14, 6, "Qty\nApple"), (6, 14, "Qty\nApple"), (1, 14, "Name\tQty"), (14, 1, "Name\tQty")] {
|
||||
let mut h = harness(|_| {});
|
||||
let (from, to) = (glyph(&h, from), glyph(&h, to));
|
||||
for pressed in [true, false] {
|
||||
h.hover_at(from);
|
||||
h.event(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
|
||||
h.run_steps(1);
|
||||
}
|
||||
drag(&mut h, from, to, Modifiers::NONE);
|
||||
assert_eq!(text(&h).as_deref(), Some(copied));
|
||||
}
|
||||
}
|
||||
@@ -1538,8 +1538,8 @@ milestone = "M3"
|
||||
status = "shipped"
|
||||
commands = []
|
||||
tools = []
|
||||
evidence = ["crates/ui-egui/tests/it/ui.rs::drag_selects_text_and_copy_returns_it", "crates/ui-egui/tests/it/ui.rs::selection_works_in_every_view_rotation", "crates/ui-egui/tests/it/text_select.rs::quick_clicks_select_a_word_then_the_line_then_the_page", "crates/ui-egui/tests/it/text_select.rs::quick_clicks_with_the_highlighter_mark_only_the_word", "crates/ui-egui/tests/it/text_select.rs::shift_click_extends_the_selection_from_its_anchor", "crates/ui-egui/tests/it/comments.rs::selected_text_shows_actions_and_underlines_without_changing_tools", "crates/ui-egui/tests/it/comments.rs::selected_text_replace_action_opens_replacement_composer", "crates/ui-egui/tests/it/comments.rs::selected_text_comment_action_opens_note_composer"]
|
||||
notes = "Drag to select; ⇧-click extends the selection to the click, keeping its anchor (same page only); with the Select tool, quick clicks select a word (two), the line (three) or all the text on the page (four). A floating action bar offers comment, highlight, underline, strikethrough, replace text and copy after selection. The markup and Redact tools mark a double-clicked word and stop there. Missing: selection across pages (dragging or ⇧-clicking onto another page), paragraph selection, and ⌘A across the whole document (it selects the current page). Acrobat's other selection context actions such as bookmark, link and export selection remain outside this bar."
|
||||
evidence = ["crates/ui-egui/tests/it/ui.rs::drag_selects_text_and_copy_returns_it", "crates/ui-egui/tests/it/ui.rs::selection_works_in_every_view_rotation", "crates/ui-egui/tests/it/text_select.rs::quick_clicks_select_a_word_then_the_line_then_the_page", "crates/ui-egui/tests/it/text_select.rs::quick_clicks_with_the_highlighter_mark_only_the_word", "crates/ui-egui/tests/it/text_select.rs::shift_click_extends_the_selection_from_its_anchor", "crates/ui-egui/tests/it/comments.rs::selected_text_shows_actions_and_underlines_without_changing_tools", "crates/ui-egui/tests/it/comments.rs::selected_text_replace_action_opens_replacement_composer", "crates/ui-egui/tests/it/comments.rs::selected_text_comment_action_opens_note_composer", "crates/render/src/text/tests.rs::a_drag_along_one_table_row_selects_that_row", "crates/render/src/text/tests.rs::a_drag_over_table_rows_selects_those_rows", "crates/render/src/text/tests.rs::a_pointer_ending_beside_the_column_takes_the_rows", "crates/render/src/text/tests.rs::a_drag_in_the_right_column_takes_nothing_from_the_left", "crates/render/src/text/tests.rs::a_cell_padded_with_spaces_joins_the_next_cell_of_its_row", "crates/ui-egui/tests/it/column_select.rs::a_plain_drag_along_one_table_row_selects_only_that_row", "crates/ui-egui/tests/it/column_select.rs::a_drag_ending_between_the_columns_selects_the_rows", "crates/render/src/text/tests.rs::a_barcode_drawn_with_a_font_reads_apart_from_the_text_beside_it", "crates/render/src/text/tests.rs::a_point_beside_a_barcode_takes_the_text_not_the_code_row", "crates/ui-egui/tests/it/column_select.rs::a_drag_down_text_beside_a_barcode_selects_only_the_text"]
|
||||
notes = "Drag to select; across a table, which reads column by column, a drag selects by visual rows (the rest of its first row, the rows between, the start of its last), and copy gives the cells tab-separated; a drag down one column keeps reading order, and a QR or Aztec code drawn with a barcode font (as on Mexican CFDI invoices) reads apart from the text beside it. ⇧-click extends the selection to the click, keeping its anchor (same page only); with the Select tool, quick clicks select a word (two), the line (three) or all the text on the page (four). A floating action bar offers comment, highlight, underline, strikethrough, replace text and copy after selection. The markup and Redact tools mark a double-clicked word and stop there. Missing: selection across pages (dragging or ⇧-clicking onto another page), paragraph selection, and ⌘A across the whole document (it selects the current page). Acrobat's other selection context actions such as bookmark, link and export selection remain outside this bar."
|
||||
|
||||
[[feature]]
|
||||
id = "view.column-select"
|
||||
|
||||
Reference in new issue
Block a user