Add find in document (fixes #4) - #579
Open
JuliusBairaktaris wants to merge 1 commit into
Open
Conversation
There was a problem hiding this comment.
Pull request overview
This PR implements a comprehensive text search feature for the PDF viewer application and addresses a Toast deprecation warning by replacing the custom Toast view with a TextView overlay.
Key Changes:
- Added full-text search functionality with visual highlighting and navigation across PDF pages
- Replaced deprecated
Toast.setView()with a custom TextView overlay for page number display - Implemented bidirectional JavaScript-Android bridge communication for search operations
Reviewed changes
Copilot reviewed 9 out of 9 changed files in this pull request and generated 14 comments.
Show a summary per file
| File | Description |
|---|---|
| viewer/main.css | Added CSS classes for search highlight containers and selected/unselected highlight styling |
| viewer/js/search_controller.js | New search controller implementing asynchronous text indexing, substring matching, and DOM-based highlight rendering |
| viewer/js/index.js | Integrated search controller with PDF.js rendering pipeline and added message handler for Android bridge communication |
| viewer/css/text_layer.css | Code formatting improvements (spacing, line breaks) |
| app/src/main/res/values/strings.xml | Added search-related string resources (action_search, search_hint, no_matches, match_status) |
| app/src/main/res/menu/pdf_viewer.xml | Added search action menu item with SearchView configuration |
| app/src/main/res/layout/pdfviewer.xml | Added page_number_view TextView to replace deprecated Toast implementation |
| app/src/main/res/drawable/ic_search_24dp.xml | New search icon vector drawable |
| app/src/main/java/app/grapheneos/pdfviewer/PdfViewer.java | Implemented search UI logic, replaced Toast with TextView, added JSON-based WebView bridge methods, and integrated search navigation with existing prev/next buttons |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
JuliusBairaktaris
marked this pull request as draft
December 26, 2025 01:59
JuliusBairaktaris
force-pushed
the
main
branch
from
December 26, 2025 03:37
d8374d1 to
e1d86d3
Compare
JuliusBairaktaris
marked this pull request as ready for review
December 26, 2025 03:37
Open
mio-19
added a commit
to mio-19/repo
that referenced
this pull request
Apr 4, 2026
|
Github: This branch has conflicts that must be resolved |
Search is implemented natively: DocumentSearch owns the corpus, the match index and navigation, and matches with android.icu.text.StringSearch at Collator.PRIMARY. ICU reports offsets into the original string, so no normalized copy of the page text and no map back to it is needed. JavaScript is reduced to extracting per-item page text and turning the offsets Kotlin sends back into Ranges. Highlights use the CSS Custom Highlight API, which paints from live Ranges and mutates no DOM, so they follow zoom and rotation without any coordinate arithmetic and cannot corrupt the cached text layers index.js reuses. The bridge gains one method, setPageText, whose Boolean return is the whole cancellation protocol. The query string never crosses it in either direction; Kotlin sends only integer triples. The find bar replaces the top app bar while searching, following the find-in-page pattern of Chrome, Firefox and Acrobat. Fixes GrapheneOS#4
Author
|
@thestinger Ready for review. |
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.
Description
Adds find-in-document to the PDF viewer. Fixes #4.
This is a full rewrite of the original version of this PR. That one was written against the
pre-Kotlin
PdfViewer.javatree and no longer applies; more importantly it put the search enginein JavaScript (a
SearchControllerclass holding all the state) and hand-rolled the parts theplatform already provides. This version inverts that: Kotlin owns the search engine, JavaScript
is reduced to a text pump and a
Rangefactory, and both the matcher and the highlight rendererare platform built-ins rather than new code.
Architecture
search/DocumentSearch.kt)TreeMapof page → matchesandroid.icu.text.StringSearchatCollator.PRIMARYviewer/js/search.js,index.js)page.getTextContent()TextLayer.textDivs(pdf.js public API)SearchAppBar)TopAppBarWhy ICU.
StringSearchis the same collation search engine Chromium's own find-in-page uses.It folds case, diacritics, ligatures, full-width forms, ß/ss and CJK, and it reports match offsets
into the original string. That last property is what removes the need for a normalized copy of
the page text plus a map back to it — the roughly 900 lines of
normalize()+ diff-array machinerythat pdf.js's own
PDFFindControllerneeds. Nothing is vendored and no dependency is added.Why the Custom Highlight API. It paints from live
Rangeobjects, so highlights follow thetext layer through zoom, rotation and the canvas insets with no coordinate arithmetic anywhere —
no
getClientRects(), no dividing by--scale-factor, no overlay<div>s. It also mutates noDOM, which matters because
index.jskeeps an LRU cache of six rendered text layers and reusesthose exact nodes; the DOM-surgery approach pdf.js uses would corrupt them on every cache hit.
Bundle cost: +1.5 KB minified. For comparison, importing
PDFFindControllerfrompdfjs-dist/web/pdf_viewer.mjscosts +164 KB and needs aglobalThis.pdfjsLibshim, becausethat file destructures ~60 names off it at module scope. That option was measured and rejected.
Bridge
One new
@JavascriptInterfacemethod, and two calls the other way:setPageText(page: Int, itemsJson: String, generation: Int): Boolean— JS ships one page ofper-item strings. The Boolean return is the entire cancellation protocol:
falsemeans the indexis full, stop.
generationfences a sweep started for a previously opened document.extractText(startPage, generation)— walks outward from the page being viewed so the first result landsimmediately on a large document, then wraps.
setSearchHighlights(page, [[[itemIndex, offset, length], …], …], active)— digits, commas andbrackets only.
The query string never crosses the bridge in either direction, so there is nothing to escape.
UI
Follows the find-in-page pattern of Chrome, Firefox and Acrobat: a magnifier in the toolbar opens
a bar that replaces the top app bar — back arrow, text field, clear button, match counter, and
previous/next. Search-as-you-type with a 200 ms debounce, a progress bar while the document is
still being indexed, back button closes, IME "Search" jumps to the next match and dismisses the
keyboard.
Highlights are magenta, the active match orange. The zoomed shot is the point of the Custom
Highlight API: the highlights are still exactly on the glyphs after a re-render at a different
scale, with no coordinate code involved. The document shown is the freedesktop.org
shared-mime-info specification, a public document shipped by the
shared-mime-infopackage.Testing
A debug APK is attached to this release so the PR can be tried
without building it: PdfViewer-search-debug.apk. It is debug
signed and installs alongside a normal install as
PDF Viewer d.PdfViewerSearchMatcherTest(21 tests) pins the matcher's behaviour, which is the one part whosesemantics come from the platform: exact
(start, length)pairs for case, NFC and NFD diacritics,ligatures, full width, ß, CJK, non-overlapping matches, cross-line phrase matches, and termination
on a wholly collation-ignorable pattern.
PdfViewerSearchTest(5 tests) covers it end to end on a real WebView: counting and wrap-aroundnavigation across pages, that the Custom Highlight API actually paints ranges covering the matched
word on pdf.js's transformed
color: transparenttext layer, that highlights survive zoom androtation, the zero-match state, and that closing clears everything.
search.test.js(10 vitest cases) pins the end-of-line rule, including the invariant the wholeoffset scheme rests on: every item contributes exactly
str.length + (hasEOL ? 1 : 0)characters.Everything below was measured on an API 36 x86_64 emulator running WebView 133 (the minimum this
app accepts), not reasoned about:
color: transparentspans, thepainted range covers the matched word, and highlights re-establish across zoom and rotation.
This was the riskiest assumption in the design and is now a test, not a hope.
PRIMARYfolds case, NFC and NFD diacritics, ligatures, full width, ß and CJK, with exact(start, length)pairs pinned per case.hasEOLitems are not a corner case: on the 19-page spec used for the screenshots, 187 of2128 text items are
{"str": "", "hasEOL": true}, and each one produces atextDivsentry withno text node. The null guard in
applyHighlightsruns constantly.244k characters after). Matching is per page, off the UI thread, cancellable and incremental, so
it shows up as the progress bar taking longer rather than as a stall.
project's pre-existing ones, unchanged.
Notes for review
which the counter reads
n/m+. That is the cost of not re-sweeping the pdf.js worker on everykeystroke. If any retention is unwanted in a hardened viewer, dropping the corpus in
onStopisa small change that trades it for a re-scan.
collation) so
hyphen-\nationis found byhyphenation; every other line end becomes one space,because
U+000Ais not collation-equal to a space. This is why a phrase spanning a line breakis findable.
item.strinto visual orderbefore we ever see it, so a logically-typed Arabic or Hebrew query cannot match a pure-RTL line.
pdf.js's own find controller has exactly the same limitation. Fixing it means a per-item offset
transform; happy to add it if wanted.
the engine plus UI surface and a persisted preference), matches spanning a page boundary, and
reading-order reconstruction for multi-column layouts. None of these are things pdf.js or
Chromium do either.
.textLayer .highlightrules intext_layer.cssare now permanently unused.Left in place to avoid diverging from upstream pdf.js; happy to delete them for a smaller diff.
characters can produce the per-page cap of 100k matches for a one-letter query, which is roughly
a 1.4 MB argument to
evaluateJavascriptand 100k liveRanges. That is renderer-side jank fora fraction of a second, not an ANR, a crash or a wrong result — the Compose UI stays responsive
because the payload is built off the main thread and shipped asynchronously. Capping it would
make the counter disagree with what is painted, which seemed the worse trade. Real dense pages
are three orders of magnitude below this.
PDFPageProxyper page for the life ofthe document.
pdfDoc.cleanup()after the sweep would release it; not done here because itinteracts with the render cache and wanted a maintainer's opinion first.
Disclaimer: this PR was implemented with the assistance of Claude.