Skip to content

Add find in document (fixes #4) - #579

Open
JuliusBairaktaris wants to merge 1 commit into
GrapheneOS:mainfrom
JuliusBairaktaris:main
Open

Add find in document (fixes #4)#579
JuliusBairaktaris wants to merge 1 commit into
GrapheneOS:mainfrom
JuliusBairaktaris:main

Conversation

@JuliusBairaktaris

@JuliusBairaktaris JuliusBairaktaris commented Dec 26, 2025

Copy link
Copy Markdown

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.java tree and no longer applies; more importantly it put the search engine
in JavaScript (a SearchController class holding all the state) and hand-rolled the parts the
platform already provides. This version inverts that: Kotlin owns the search engine, JavaScript
is reduced to a text pump and a Range factory
, and both the matcher and the highlight renderer
are platform built-ins rather than new code.

Architecture

Concern Where What does the work
Match index, counts, navigation, cancellation Kotlin (search/DocumentSearch.kt) TreeMap of page → matches
Text matching Kotlin android.icu.text.StringSearch at Collator.PRIMARY
Text extraction JS (viewer/js/search.js, index.js) page.getTextContent()
Offset → DOM mapping JS TextLayer.textDivs (pdf.js public API)
Highlight painting, scaling, rotation Browser CSS Custom Highlight API
UI Kotlin (SearchAppBar) Material 3 TopAppBar

Why ICU. StringSearch is 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 machinery
that pdf.js's own PDFFindController needs. Nothing is vendored and no dependency is added.

Why the Custom Highlight API. It paints from live Range objects, so highlights follow the
text 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 no
DOM, which matters because index.js keeps an LRU cache of six rendered text layers and reuses
those 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 PDFFindController from
pdfjs-dist/web/pdf_viewer.mjs costs +164 KB and needs a globalThis.pdfjsLib shim, because
that file destructures ~60 names off it at module scope. That option was measured and rejected.

Bridge

One new @JavascriptInterface method, and two calls the other way:

  • setPageText(page: Int, itemsJson: String, generation: Int): Boolean — JS ships one page of
    per-item strings. The Boolean return is the entire cancellation protocol: false means the index
    is full, stop. generation fences a sweep started for a previously opened document.
  • extractText(startPage, generation) — walks outward from the page being viewed so the first result lands
    immediately on a large document, then wraps.
  • setSearchHighlights(page, [[[itemIndex, offset, length], …], …], active) — digits, commas and
    brackets 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.

Find bar First match Stepping matches Zoomed to 180%

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-info package.

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 whose
semantics 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-around
navigation across pages, that the Custom Highlight API actually paints ranges covering the matched
word on pdf.js's transformed color: transparent text layer, that highlights survive zoom and
rotation, 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 whole
offset 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:

  • The Custom Highlight API does paint on pdf.js's transformed, color: transparent spans, the
    painted 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.
  • ICU PRIMARY folds case, NFC and NFD diacritics, ligatures, full width, ß and CJK, with exact
    (start, length) pairs pinned per case.
  • Empty hasEOL items are not a corner case: on the 19-page spec used for the screenshots, 187 of
    2128 text items are {"str": "", "hasEOL": true}, and each one produces a textDivs entry with
    no text node. The null guard in applyHighlights runs constantly.
  • ICU pays a large one-time cost on its first substantial scan (~12s on this emulator, ~1s per
    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.
  • The full instrumentation suite is green: 114 tests, of which 26 are new and the rest are the
    project's pre-existing ones, unchanged.

Notes for review

  • Text is retained for the document's lifetime, capped at 8M characters (~16 MB UTF-16), after
    which the counter reads n/m+. That is the cost of not re-sweeping the pdf.js worker on every
    keystroke. If any retention is unwanted in a hardened viewer, dropping the corpus in onStop is
    a small change that trades it for a re-scan.
  • Line breaks. A line-final hyphen becomes two soft hyphens (completely ignorable in root
    collation) so hyphen-\nation is found by hyphenation; every other line end becomes one space,
    because U+000A is not collation-equal to a space. This is why a phrase spanning a line break
    is findable.
  • RTL is a known ceiling, not a regression. pdf.js bidi-reorders item.str into visual order
    before 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.
  • Deliberately not included: match-case, whole-word and regex toggles (each is roughly one line in
    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.
  • The 45 vendored .textLayer .highlight rules in text_layer.css are now permanently unused.
    Left in place to avoid diverging from upstream pdf.js; happy to delete them for a smaller diff.
  • Known ceiling, deliberately not capped. A hostile PDF with a single page carrying ~1M
    characters can produce the per-page cap of 100k matches for a one-letter query, which is roughly
    a 1.4 MB argument to evaluateJavascript and 100k live Ranges. That is renderer-side jank for
    a 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.
  • Text extraction visits every page, so pdf.js retains a PDFPageProxy per page for the life of
    the document. pdfDoc.cleanup() after the sweep would release it; not done here because it
    interacts with the render cache and wanted a maintainer's opinion first.

Disclaimer: this PR was implemented with the assistance of Claude.

Copilot AI review requested due to automatic review settings December 26, 2025 00:37
@JuliusBairaktaris JuliusBairaktaris changed the title feat: Search (fixes: #4) feat: Search (Fixes #4) Dec 26, 2025

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread app/src/main/java/app/grapheneos/pdfviewer/PdfViewer.java Outdated
Comment thread app/src/main/res/menu/pdf_viewer.xml Outdated
Comment thread app/src/main/res/menu/pdf_viewer.xml Outdated
Comment thread app/src/main/java/app/grapheneos/pdfviewer/PdfViewer.java Outdated
Comment thread app/src/main/res/layout/pdfviewer.xml Outdated
Comment thread app/src/main/java/app/grapheneos/pdfviewer/PdfViewer.java Outdated
Comment thread app/src/main/java/app/grapheneos/pdfviewer/PdfViewer.java Outdated
Comment thread app/src/main/java/app/grapheneos/pdfviewer/PdfViewer.java Outdated
Comment thread viewer/js/index.js Outdated
Comment thread app/src/main/java/app/grapheneos/pdfviewer/PdfViewer.java Outdated
@JuliusBairaktaris
JuliusBairaktaris marked this pull request as draft December 26, 2025 01:59
@JuliusBairaktaris
JuliusBairaktaris marked this pull request as ready for review December 26, 2025 03:37
@JuliusBairaktaris JuliusBairaktaris mentioned this pull request Dec 26, 2025
mio-19 added a commit to mio-19/repo that referenced this pull request Apr 4, 2026
@mio-19

mio-19 commented Apr 15, 2026

Copy link
Copy Markdown

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
@JuliusBairaktaris JuliusBairaktaris changed the title feat: Search (Fixes #4) Add find in document (fixes #4) Aug 3, 2026
@JuliusBairaktaris

Copy link
Copy Markdown
Author

@thestinger Ready for review.

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.

search

3 participants