GH-513 text extraction and selection enhancements and keyboard support#514
Open
pcorless wants to merge 20 commits into
Open
GH-513 text extraction and selection enhancements and keyboard support#514pcorless wants to merge 20 commits into
pcorless wants to merge 20 commits into
Conversation
Rebuild text selection around a reading-order offset model, replacing the GlyphLocation directional if-thicket. Core (additive): Bias, OffsetRange, Caret, BreakType, and TextSequence (cached per PageText, invalidated on re-sort). TextSequence maps page-space points <-> character offsets <-> glyph/word/line and produces merged per-line highlight rectangles. Viewer: DocumentTextSelection (a document-level anchor->focus offset pair held by DocumentViewModel) is the authoritative selection. TextSelectionSupport derives per-page ranges, projects them onto the legacy per-glyph selection flags (write-through, so redaction/highlight/edit keep working), and extracts selected text. The TextSelection handlers now map mouse points to caret offsets (collapseTo/extendTo, word/line ranges for double/triple click); painting derives from rectsFor; multi-page drag uses nearest-page + extendTo. getSelectedText routes through the offset model. Also removes the disabled-by-default margin-exclusion (header/footer) feature and deletes GlyphLocation. Tests: TextSequenceTest, DocumentTextSelectionTest. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add keyboard navigation over the document-level focus caret: arrows (glyph), Ctrl+arrows (word), Home/End (line), Up/Down (caretAbove/caretBelow with a sticky page-space goal column), each with Shift to extend the selection, plus cross-page roll-over. Bindings are installed on PageViewComponentImpl via an InputMap/ActionMap only while the text-selection tool is active, so arrow keys still scroll under other tools. A caret bar is painted at the focus offset in TextSelectionPageHandler.paintTool via TextSequence.caretRect (TextSequence.caretAtLine added for cross-page landing). The mouse-side selection projection (syncSelection) is extracted to TextSelectionSupport.applyDocumentSelection and shared by the mouse and keyboard paths. Navigation moves the model, re-projects the flags, then focuses and scrolls the caret into view. Ctrl+A/Ctrl+C remain handled by the existing menu/accelerator paths. Tests: TextSequenceTest.navigation covers the nav primitives. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ts (Step 4a) Add TextSequence.searchText(), a whitespace-collapsed reading-order corpus for searching, plus searchToCanonicalRange() to map a match back to a canonical OffsetRange (and from there to glyphs/words/rects). Additive and unit-tested; nothing wires it into search yet. Add the first real search matching tests (DocumentSearchConvergenceTest): WORD/PAGE hit counts, regex, phrase, highlight content and result-fragment context padding, pinning current behavior as a safety net ahead of the searchHighlightWholePage corpus convergence. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Rewrite searchHighlightWholePage to search the page's reading-order TextSequence instead of a hand-rolled corpus. Literal terms match the whitespace-collapsed searchText(), regex terms match the canonical text(); matches map back via searchToCanonicalRange() -> wordsIn() and the matched words are highlighted in place. Result-fragment context is built from TextSequence.lineOf(word), backed by new per-word line/index maps. Deletes the duplicated textBuilder/wordOffsets/idxToWordText tables and the now-unused SearchHit class. Behavior is preserved against the Step 4a characterization tests (counts, regex, phrase, highlight content, context padding) and the full viewer suite; whitespace normalization is now uniform (all whitespace collapses, previously only exact single-space tokens), a mild robustness improvement for irregular spacing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Expand DocumentSearchConvergenceTest into broad search coverage across two PDFs (the poem and the redaction addendum): WORD vs PAGE, substring vs whole-word, phrases, across-line matches, regex, result-fragment context, and the accent-sensitive matching gap (caracter/ataudes/"si mismo" all currently miss their accented targets). This is the safety net for streamlining WORD/PAGE into one corpus engine and adding Unicode-normalized matching. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…4c.2)
Merge searchHighlightWordPage and searchHighlightWholePage into a single
corpus-based matcher: searchHighlightPage + compileSearchPattern + one
buildHitFragment. Literal terms match the whitespace-collapsed searchText(),
regex terms match the canonical text(), and whole-word wraps the term in
Unicode-aware \b boundaries. searchMode now only selects result-fragment
context (PAGE = full line, WORD = word padding); matching is identical for
both.
All poem search counts are preserved. The one delta is an improvement:
whole-word "PDF" goes 6 -> 8 on the addendum because \b now finds "PDF"
adjacent to punctuation ("(PDF 1.7)") that the old token-equality matcher
missed. Characterization baseline updated accordingly; full viewer suite
green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…4c.3) Add opt-in accent-insensitive matching for literal search terms. TextSequence gains a diacritic-folded search corpus (foldedSearchText / foldedToCanonicalRange) and a static foldDiacritics() helper (NFD decomposition with combining marks stripped, ASCII fast path). Matching folds both the corpus and the term so, e.g., "caracter" finds "carácter", "ataudes" finds "ataúdes" and "que" finds "Qué". Folding is a per-term flag (SearchTerm.setFoldDiacritics), default off, parallel to case-sensitive / whole-word. It is deliberately NOT a global default: interactive search and redaction share one search controller, and redaction must stay exact (folding on silently changed a redaction-by-search count). Interactive search opts in by default (SearchTextTask), disableable with -Dorg.icepdf.core.search.foldDiacritics=false; redaction and all other callers remain exact. Tests: DocumentSearchConvergenceTest.accentInsensitive covers default-exact vs folded; TextSequenceTest.foldedCorpus unit-tests the corpus and offset mapping. Full viewer suite green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
When navigating search hits (nextSearchHit / previousSearchHit), also set the document text selection to the current hit's word run, so the found text is copyable and stays consistent with mouse/keyboard selection. Implemented via TextSequence.rangeOf on the hit's first/last words and TextSelectionSupport.applyDocumentSelection. Additive: the existing highlight-cursor is unchanged, and selectSearchHit no-ops when running headless. The cursor's internal (page,line,word) tracking is left as-is. Test: DocumentSearchConvergenceTest.findAndSelectRange covers the hit-run -> offset-range -> selectable-text mapping. Full viewer suite green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a blinking text-selection caret. A single EDT-safe javax.swing.Timer (CaretBlink) toggles the caret's visible state and repaints the page holding the document focus caret; TextSelectionPageHandler.paintTool draws the caret only while CaretBlink.isVisible(). The blink is started/stopped by the text view handler as the tool is installed/removed and reset to solid whenever the caret moves (applyDocumentSelection) so it is immediately visible on interaction. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
After the corpus unification the matcher operates on the whole term against a reading-order text corpus and never consumes SearchTerm's per-word token list. Deprecate SearchTerm.getTerms() and DocumentSearchControllerImpl.searchPhraseParser() with javadoc pointing to the new path, and annotate addSearchTerm (which still populates the tokens for API compatibility). No behavior change; removal is left for a future major version pending an API review. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Update the search examples to capture the returned SearchTerm and enable accent-insensitive (Unicode-normalized) matching via setFoldDiacritics, showing the new opt-in capability (e.g. "cafe" finds "café"). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
caretAt picked the line purely by the click's y coordinate, returning the first line whose vertical band contained y. On pages with columns or rotated (vertical) text many lines share a y-band, so it grabbed the wrong line and placed the caret / selected the word in the wrong place (e.g. vertical "Introduction" text or the paragraph above on 2005CAT.pdf). caretAt now first does a direct glyph hit — the glyph whose bounds contain the point, closest-centre if several overlap — and only falls back to a nearest line computed by 2D distance (x and y), not y alone. On 2005CAT.pdf page 1 the share of glyph centres that resolved to the wrong caret drops from 28% to 0%. Test: TextSequenceTest.caretAtMultiColumn asserts every glyph centre resolves to a caret on that glyph. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
mouseClicked cleared the document selection on every click, which wiped the caret that mousePressed had just placed; for a single click wordLineSelection then did nothing, so the caret appeared on mouse-down and vanished on mouse-up. Return early from mouseClicked for click counts below two: mousePressed already places the caret and clears any previous selection, and only double/triple clicks need to clear and perform word/line selection. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Caret navigation keys only worked once text was selected; a bare caret (single click) left keyboard focus with the parent scroll pane, so the arrow keys scrolled instead of moving the caret. The mouse handlers used the unreliable requestFocus(), which does not settle on a quick click the way it does during a drag. Use requestFocusInWindow() consistently, and request focus on the page whenever selectionStart places a caret, so caret navigation works with just a cursor. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
AbstractDocumentView.mouseClicked unconditionally grabbed focus back to the view, so a single click (press+release) stole focus from the page component right after it was requested, and the parent scroll pane then consumed the caret-navigation keys. A drag never fires mouseClicked, which is why selecting text kept focus but a bare caret did not. Skip the focus grab while the text-selection tool is active so focus stays on the page component that owns the caret key bindings. Revert the invokeLater focus deferral in selectionStart now that the real cause is fixed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Vertical caret navigation positioned the caret with a free 2D nearest-line search (caretAt). When goalX sat at the right edge of a long line and the next line was shorter, the horizontal overshoot to the short line exceeded the vertical gap to the current line, so nearestLine snapped back to the long line and the caret could not advance onto the shorter line (typically the last line of a paragraph). Extract the within-line positioning into caretInLine(line, x) and route caretAbove/caretBelow/caretAtLine through it so they position within the explicitly chosen line, clamping to its end, instead of re-picking the line. Add caretBelowOntoShorterLine regression test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a golden-snapshot test that pins the current page reading order from PageText.getPageLines() across a small corpus spanning the hard cases: a single-column page drawn out of plot order (xr_650 p6), vertical+horizontal mixed text (2005CAT p1), a plain column, a dense reference layout, and two general docs. This is a safety net for a future reading-order rework (e.g. an XY-cut / projection-profile column detector to replace the preserveColumns plot-order heuristic that currently mis-orders xr_650 p6). getPageLines() feeds selection, search, and redaction, so any change must produce a reviewable line-by-line diff over real pages before it ships. Regenerate with -Dupdate.reading.order.golden=true (forwarded to the test JVM via build.gradle). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
windrivercasestudy1n3d2m8km0r.pdf page 2 is a clean two-column body with a full-width footer band - the multi-column proof case the corpus was missing. Under the current plot-order extractor the columns already read correctly (left col fully precedes right col), with only the footer hoisted to the front; a naive pure y-sort would interleave the columns, which is exactly what an XY-cut reading-order pass must avoid regressing. Add it as a characterization fixture and regenerate the golden snapshot. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a geometry-driven page reading order via recursive XY-cut
(XYCutReadingOrder): at each region prefer a vertical gutter (columns, read
left-to-right) then a horizontal band break (top-to-bottom), falling back to a
plain top-to-bottom leaf sort. Guards against false column splits: gutter width
and band gap are scale-relative (median line height), a column cut requires both
sides to overlap in y (rejects stacked halves) and a minimum line count, and a
skinny vertical glyph-stack (rotated marginal text) keeps its incoming order
rather than being reversed by a y-sort.
Wire it in behind a new reading-order strategy property that supersedes, and
defaults from, the existing preserveColumns boolean - the default is unchanged:
org.icepdf.core.views.page.text.readingOrder = plot | ysort | xycut
(unset -> plot when preserveColumns=true, else ysort)
Measured against the characterization corpus (algorithm run directly, so it
coexists with the default-mode golden in one JVM):
- xr_650 p6: single column drawn out of order now reads Environmental ->
Programs -> legal (was plot order: Programs first).
- windriver p2: clean two columns read left col -> right col -> footer, with
no interleaving (a pure y-sort would interleave them).
- 2005CAT p1: vertical INTRODUCTION label preserved (not reversed); horizontal
text unchanged.
- addendum / libre: identical to plot (no false column splits on indented
single-column content).
- test_print / potato: minor top-to-bottom corrections.
Add ReadingOrderXYCutTest: a second golden snapshot plus explicit invariant
assertions for the three anchor cases. Vertical-text internal order, tables,
and cross-page order remain non-goals (see READING-ORDER-XYCUT-PLAN.md).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
GH-513 — Caret/offset text selection & search convergence
Rewrites text selection around a caret/offset model and converges the search engine onto the same primitive, replacing the old per-glyph
GlyphLocationif-thicket. Adds keyboard selection, a blinking caret, multi-page selection, and accent-insensitive search.Core (
org.icepdf.core) — additive, no breaking changesTextSequence: a flattened reading-order character sequence perPageText, with a canonical string and offset↔glyph/word/line mapping. This is the shared contract for selection, search, and extraction.Caret,OffsetRange,Bias,BreakType.PageTextgains a lazygetTextSequence()(invalidated on re-sort); existing per-glyph API untouched, so Redaction and annotations are unaffected.SearchTermgains per-term, opt-infoldDiacritics(default off, so redaction stays byte-exact).Viewer (RI)
DocumentTextSelection(anchor→focus, survives page dispose); write-through bridge projects it onto legacy glyph flags so highlight/redaction/copy keep working.\b), regex, and Unicode-normalized (accent-insensitive) matching; find-and-select highlights hits in the new model. Legacy word-tokenization deprecated (not removed).Tests
TextSequenceTest,DocumentTextSelectionTest,DocumentSearchConvergenceTest(~13 tests across two PDFs) covering sequence invariants, caret round-trips, drag simulation, search-mode convergence, whole-word, and accent-folding.Compatibility
Core API is purely additive. Deprecated members retained pending an API review before any removal (would be a major-version bump).