Skip to content

GH-513 text extraction and selection enhancements and keyboard support#514

Open
pcorless wants to merge 20 commits into
mainfrom
GH-513.text.selection
Open

GH-513 text extraction and selection enhancements and keyboard support#514
pcorless wants to merge 20 commits into
mainfrom
GH-513.text.selection

Conversation

@pcorless

Copy link
Copy Markdown
Owner

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 GlyphLocation if-thicket. Adds keyboard selection, a blinking caret, multi-page selection, and accent-insensitive search.

Core (org.icepdf.core) — additive, no breaking changes

  • New TextSequence: a flattened reading-order character sequence per PageText, with a canonical string and offset↔glyph/word/line mapping. This is the shared contract for selection, search, and extraction.
  • Supporting value types: Caret, OffsetRange, Bias, BreakType.
  • PageText gains a lazy getTextSequence() (invalidated on re-sort); existing per-glyph API untouched, so Redaction and annotations are unaffected.
  • SearchTerm gains per-term, opt-in foldDiacritics (default off, so redaction stays byte-exact).

Viewer (RI)

  • Selection model: document-level DocumentTextSelection (anchor→focus, survives page dispose); write-through bridge projects it onto legacy glyph flags so highlight/redaction/copy keep working.
  • Keyboard navigation: arrows / word (Ctrl) / line (Home-End) movement and Shift-extend variants, plus a blinking caret from a single click.
  • Search streamlining: WORD and PAGE modes unified onto one corpus matcher; whole-word (\b), regex, and Unicode-normalized (accent-insensitive) matching; find-and-select highlights hits in the new model. Legacy word-tokenization deprecated (not removed).

Tests

  • New 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).

pcorless and others added 15 commits July 15, 2026 20:32
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>
@pcorless pcorless changed the title GH-513 text selection GH-513 text extraction and selection enhancements and keyboard support Jul 17, 2026
pcorless and others added 5 commits July 16, 2026 21:44
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant