From 068780fa19c92203a9533b8d1401ba92ce2fca11 Mon Sep 17 00:00:00 2001 From: Julius Bairaktaris Date: Mon, 3 Aug 2026 04:32:54 +0200 Subject: [PATCH] Add find in document 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 #4 --- .../test/PdfViewerSearchMatcherTest.kt | 204 ++++++++++++ .../pdfviewer/test/PdfViewerSearchTest.kt | 205 ++++++++++++ .../app/grapheneos/pdfviewer/PdfJsChannel.kt | 11 + .../grapheneos/pdfviewer/PdfViewerScreen.kt | 226 +++++++++++++- .../java/app/grapheneos/pdfviewer/TestTags.kt | 2 + .../pdfviewer/search/DocumentSearch.kt | 291 ++++++++++++++++++ .../java/app/grapheneos/pdfviewer/ui/Theme.kt | 35 ++- .../pdfviewer/viewModel/PdfViewModel.kt | 188 ++++++++++- app/src/main/res/values/strings.xml | 6 + viewer/css/text_layer.css | 23 ++ viewer/js/index.js | 151 +++++++++ viewer/js/search.js | 34 ++ viewer/js/search.test.js | 58 ++++ 13 files changed, 1430 insertions(+), 4 deletions(-) create mode 100644 app/src/androidTest/kotlin/app/grapheneos/pdfviewer/test/PdfViewerSearchMatcherTest.kt create mode 100644 app/src/androidTest/kotlin/app/grapheneos/pdfviewer/test/PdfViewerSearchTest.kt create mode 100644 app/src/main/java/app/grapheneos/pdfviewer/search/DocumentSearch.kt create mode 100644 viewer/js/search.js create mode 100644 viewer/js/search.test.js diff --git a/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/test/PdfViewerSearchMatcherTest.kt b/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/test/PdfViewerSearchMatcherTest.kt new file mode 100644 index 000000000..209ddd7ba --- /dev/null +++ b/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/test/PdfViewerSearchMatcherTest.kt @@ -0,0 +1,204 @@ +package app.grapheneos.pdfviewer.test + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import app.grapheneos.pdfviewer.search.DocumentSearch +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Pins the behaviour of the ICU collation matcher, which is the one part of search whose + * semantics come from the platform rather than from this codebase. + */ +@RunWith(AndroidJUnit4::class) +class PdfViewerSearchMatcherTest { + + private val search = DocumentSearch() + + /** Matches as (start, length) pairs. */ + private fun find(text: String, pattern: String): List> = + search.findMatches(text, pattern).toList().chunked(2).map { it[0] to it[1] } + + private fun starts(text: String, pattern: String) = find(text, pattern).map { it.first } + + @Test + fun findsPlainSubstring() { + assertEquals(listOf(4 to 4), find("the MIME database", "MIME")) + } + + @Test + fun ignoresCase() { + assertEquals(listOf(4 to 4), find("the mime database", "MIME")) + assertEquals(listOf(4 to 4), find("the MIME database", "mime")) + } + + @Test + fun findsEveryOccurrence() { + assertEquals(listOf(0, 8, 16), starts("cat bat cat mat cat", "cat")) + } + + @Test + fun matchesAreNotOverlapping() { + assertEquals(listOf(0, 2), starts("aaaa", "aa")) + } + + @Test + fun reportsNothingWhenAbsent() { + assertEquals(emptyList>(), find("hello world", "zzz")) + assertEquals(emptyList>(), find("ab", "abcdef")) + } + + @Test + fun foldsDiacritics() { + assertEquals(listOf(3 to 6), find("Le Résumé final", "resume")) + assertEquals(listOf(4 to 6), find("the resume of", "Résumé")) + } + + @Test + fun foldsDecomposedDiacritics() { + // "Résumé" written NFD: e + U+0301 for each accent. + val nfd = "Le Résumé final" + val hits = find(nfd, "resume") + assertEquals(1, hits.size) + assertEquals(3, hits[0].first) + // Length is in original-string units, so it covers the combining marks too. + assertTrue("length ${hits[0].second} should cover the combining marks", hits[0].second >= 6) + } + + @Test + fun foldsLigatures() { + assertEquals(listOf(4 to 1), find("the file is here", "fi")) + } + + @Test + fun foldsFullWidth() { + assertEquals(listOf(5 to 3), find("code ABC here", "ABC")) + } + + @Test + fun findsCjkSubstring() { + assertEquals(listOf(0 to 2), find("日本語の本", "日本")) + } + + @Test + fun softHyphenIsIgnorable() { + // The end-of-line rule in search.js turns "hyphen-\nation" into "hyphen­­ation"; + // this is what makes a line-broken word findable as one word. + val hits = find("hyphen­­ation", "hyphenation") + assertEquals(1, hits.size) + assertEquals(0, hits[0].first) + assertEquals(13, hits[0].second) + } + + @Test + fun spaceSeparatorJoinsLinesForPhraseSearch() { + // "Chapter Three" + EOL + "Page Three Content" as search.js emits it: a phrase spanning + // the line break is findable precisely because the separator is a space. + assertEquals(listOf(10 to 10), find("3 Chapter Three Page Three Content", "three page")) + } + + @Test + fun newlineIsNotEqualToSpace() { + // Documents the reason search.js emits U+0020 and never U+000A at a line end. + assertEquals(emptyList>(), find("line\nnext", "line next")) + } + + @Test + fun terminatesOnWhollyIgnorablePattern() { + // A zero-length match would otherwise loop forever. + assertEquals(emptyList>(), find("abc", "­")) + } + + @Test + fun toleratesEmptyInputs() { + assertEquals(emptyList>(), find("abc", "")) + assertEquals(emptyList>(), find("", "abc")) + } + + @Test + fun tuplesMapOffsetsBackToTextItems() { + // Item starts: "3"@0 " "@1 "Chapter Three"@2 " "@15 "Page Three Content"@16. + search.setQuery("three page") + assertTrue(search.addPage(1, """["3"," ","Chapter Three"," ","Page Three Content"]""")) + // "Three Page" is offsets 10..20, so it spans three items: the tail of "Chapter Three", + // the synthetic end-of-line space, and the head of "Page Three Content". + assertEquals("[[[2,8,5],[3,0,1],[4,0,4]]]", search.tuplesFor(1)) + assertEquals(1, search.stats().total) + } + + @Test + fun indexesMatchesAcrossPages() { + search.setQuery("content") + for (page in 1..4) { + assertTrue(search.addPage(page, """["Page $page Content"]""")) + } + assertEquals(4, search.stats().total) + assertEquals(1, search.countOn(3)) + assertEquals(2, search.ordinalBefore(3)) + assertEquals(1, search.firstPageFrom(1)) + assertEquals(3, search.firstPageFrom(3)) + // Wraps forward off the last page and backward off the first. + assertEquals(2 to 0, search.step(1, 0, forward = true)) + assertEquals(1 to 0, search.step(4, 0, forward = true)) + assertEquals(4 to 0, search.step(1, 0, forward = false)) + } + + @Test + fun aNewQueryClearsMatchCapTruncation() { + // A single very common character can cross the match cap. If that latched, extraction + // would stay stopped and every later query would search only the pages scanned so far. + search.setQuery("e") + val dense = "e ".repeat(DocumentSearch.MAX_MATCHES + 1_000) + // false is the signal to JS that the index is full and extraction should stop. + assertFalse("the match cap should have tripped", search.addPage(1, """["$dense"]""")) + assertTrue(search.stats().truncated) + search.setQuery("content") + assertFalse("a new query must resume extraction", search.stats().truncated) + assertTrue(search.addPage(2, """["Page Two Content"]""")) + assertEquals(1, search.stats().total) + } + + @Test + fun theIndexVersionChangesWithTheQuery() { + // The paint effect keys on this: two different queries can select the same (page, index), + // and without a version change the highlights would keep the old query's offsets. + search.setQuery("one") + assertTrue(search.addPage(1, """["one two"]""")) + val first = search.stats().version + search.setQuery("two") + assertNotEquals(first, search.stats().version) + } + + @Test + fun aQueryStartingOnAnEndOfLineSeparatorStillMapsBack() { + // "Chapter One" + synthetic EOL space + "Two": a query starting with the space produces a + // piece whose offset sits at the end of the item's real text. + search.setQuery(" two") + assertTrue(search.addPage(1, """["Chapter One ","Two"]""")) + assertEquals(1, search.stats().total) + // Offset 11 is past "Chapter One".length is false - it is exactly the length, so the JS + // side clamps it to a collapsed range and skips it, and item 1 carries the visible part. + assertEquals("[[[0,11,1],[1,0,3]]]", search.tuplesFor(1)) + } + + /** + * Steady-state throughput. The first substantial ICU scan in a process pays a large one-time + * cost (~12s on an x86 emulator) that a short scan does not absorb, so the first pass here is + * untimed; what matters for search is the rate afterwards, which was ~1s per 244k characters + * on the same emulator. The bound is loose enough not to measure whichever machine CI + * allocates, and tight enough to catch an order-of-magnitude regression. + */ + @Test + fun scansALargeCorpusQuickly() { + val corpus = "The quick brown fox jumps over the lazy dog. MIME type test. ".repeat(4000) + assertEquals(4000, search.findMatches(corpus, "lazy dog").size / 2) + val start = System.nanoTime() + val hits = search.findMatches(corpus, "lazy dog") + val ms = (System.nanoTime() - start) / 1_000_000 + assertEquals(4000, hits.size / 2) + assertTrue("scanning ${corpus.length} chars took ${ms}ms", ms < 10_000) + } +} diff --git a/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/test/PdfViewerSearchTest.kt b/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/test/PdfViewerSearchTest.kt new file mode 100644 index 000000000..aa8ab486b --- /dev/null +++ b/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/test/PdfViewerSearchTest.kt @@ -0,0 +1,205 @@ +package app.grapheneos.pdfviewer.test + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import app.grapheneos.pdfviewer.R +import app.grapheneos.pdfviewer.RetryableComposeRule +import app.grapheneos.pdfviewer.TestTags +import app.grapheneos.pdfviewer.currentPage +import app.grapheneos.pdfviewer.testrules.RetryRules +import app.grapheneos.pdfviewer.util.PdfViewerLauncher +import app.grapheneos.pdfviewer.util.PdfViewerTestUtils +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.rules.RuleChain + +/** + * Find in document, end to end: the JS text pump, the ICU index in the ViewModel and the + * Custom Highlight API paint path. + * + * test-multipage.pdf is four pages of "N Chapter " / "Page Content", so "Content" + * matches exactly once per page. + */ +@RunWith(AndroidJUnit4::class) +class PdfViewerSearchTest { + + private val composeRule = RetryableComposeRule() + + @get:Rule + val rules: RuleChain = RuleChain.outerRule(RetryRules()).around(composeRule) + + private fun string(id: Int) = + InstrumentationRegistry.getInstrumentation().targetContext.getString(id) + + @Before + fun setup() { + PdfViewerTestUtils.init(composeRule) + } + + private fun openSearch() = + composeRule.onNodeWithContentDescription(string(R.string.action_search)).performClick() + + private fun typeQuery(text: String) { + composeRule.onNodeWithTag(TestTags.SEARCH_FIELD).performTextInput(text) + composeRule.waitForIdle() + } + + private fun countIs(expected: String) { + PdfViewerTestUtils.pollUntil(description = { "match count should be $expected" }) { + composeRule.onNodeWithTag(TestTags.SEARCH_COUNT).assertTextEquals(expected) + true + } + } + + private fun clickNext() = + composeRule.onNodeWithContentDescription(string(R.string.search_next)).performClick() + + private fun clickPrevious() = + composeRule.onNodeWithContentDescription(string(R.string.search_previous)).performClick() + + @Test + fun findsEveryMatchAndNavigatesAcrossPages() { + PdfViewerLauncher.launchWithTestAsset("test-multipage.pdf").use { scenario -> + PdfViewerTestUtils.waitForDocumentFullyLoaded(scenario) + PdfViewerTestUtils.waitForCanvasRendered(scenario) + + openSearch() + composeRule.onNodeWithTag(TestTags.SEARCH_FIELD).assertIsDisplayed() + + typeQuery("Content") + countIs("1/4") + scenario.onActivity { assertEquals(1, it.currentPage) } + + clickNext() + countIs("2/4") + PdfViewerTestUtils.pollUntil(description = { "should jump to page 2" }) { + var page = 0 + scenario.onActivity { page = it.currentPage } + page == 2 + } + + clickNext() + countIs("3/4") + clickNext() + countIs("4/4") + + // Wraps off the end of the document, and back off the front. + clickNext() + countIs("1/4") + clickPrevious() + countIs("4/4") + PdfViewerTestUtils.pollUntil(description = { "should wrap back to page 4" }) { + var page = 0 + scenario.onActivity { page = it.currentPage } + page == 4 + } + } + } + + @Test + fun paintsHighlightsOnTheTextLayer() { + PdfViewerLauncher.launchWithTestAsset("test-multipage.pdf").use { scenario -> + PdfViewerTestUtils.waitForDocumentFullyLoaded(scenario) + PdfViewerTestUtils.waitForCanvasRendered(scenario) + + openSearch() + typeQuery("Chapter") + countIs("1/4") + + // The riskiest assumption in the design: that the Custom Highlight API accepts + // Ranges over pdf.js's transformed, transparent text layer spans. + PdfViewerTestUtils.pollUntil(description = { "highlights should be registered" }) { + PdfViewerTestUtils.evaluateJs(scenario, "CSS.highlights.get('pdf-find').size") != "0" + } + assertEquals( + "active match should be highlighted separately", + "1", + PdfViewerTestUtils.evaluateJs(scenario, "CSS.highlights.get('pdf-find-active').size") + ) + + // The range must actually cover the matched word, not an arbitrary span. + val text = PdfViewerTestUtils.evaluateJs( + scenario, + "Array.from(CSS.highlights.get('pdf-find'))[0].toString()" + ) + assertTrue("highlighted text was $text", text.contains("Chapter", ignoreCase = true)) + } + } + + @Test + fun survivesZoomAndRotation() { + PdfViewerLauncher.launchWithTestAsset("test-multipage.pdf").use { scenario -> + PdfViewerTestUtils.waitForDocumentFullyLoaded(scenario) + PdfViewerTestUtils.waitForCanvasRendered(scenario) + + openSearch() + typeQuery("Chapter") + countIs("1/4") + + // Re-rendering rebuilds the text layer; highlights have to be re-established from + // the stored offsets rather than preserved. + scenario.onActivity { it.viewModel.setDocumentOrientationDegrees(90) } + PdfViewerTestUtils.evaluateJs(scenario, "onRenderPage(0)") + PdfViewerTestUtils.waitForCanvasRendered(scenario) + PdfViewerTestUtils.pollUntil(description = { "highlights should survive rotation" }) { + PdfViewerTestUtils.evaluateJs(scenario, "CSS.highlights.get('pdf-find').size") != "0" + } + + scenario.onActivity { it.viewModel.setZoomRatio(2f) } + PdfViewerTestUtils.evaluateJs(scenario, "onRenderPage(1)") + PdfViewerTestUtils.waitForCanvasRendered(scenario) + PdfViewerTestUtils.pollUntil(description = { "highlights should survive zoom" }) { + PdfViewerTestUtils.evaluateJs(scenario, "CSS.highlights.get('pdf-find').size") != "0" + } + } + } + + @Test + fun reportsNoMatchesAndDisablesNavigation() { + PdfViewerLauncher.launchWithTestAsset("test-multipage.pdf").use { scenario -> + PdfViewerTestUtils.waitForDocumentFullyLoaded(scenario) + PdfViewerTestUtils.waitForCanvasRendered(scenario) + + openSearch() + typeQuery("nothingmatchesthis") + countIs("0/0") + composeRule.onNodeWithContentDescription(string(R.string.search_next)) + .assertIsNotEnabled() + PdfViewerTestUtils.pollUntil(description = { "no highlights for a miss" }) { + PdfViewerTestUtils.evaluateJs(scenario, "CSS.highlights.get('pdf-find').size") == "0" + } + } + } + + @Test + fun closingSearchClearsHighlights() { + PdfViewerLauncher.launchWithTestAsset("test-multipage.pdf").use { scenario -> + PdfViewerTestUtils.waitForDocumentFullyLoaded(scenario) + PdfViewerTestUtils.waitForCanvasRendered(scenario) + + openSearch() + typeQuery("Chapter") + countIs("1/4") + + composeRule.onNodeWithContentDescription(string(R.string.action_close)).performClick() + composeRule.waitForIdle() + + composeRule.onNodeWithTag(TestTags.SEARCH_FIELD).assertDoesNotExist() + PdfViewerTestUtils.pollUntil(description = { "highlights should be cleared" }) { + PdfViewerTestUtils.evaluateJs(scenario, "CSS.highlights.get('pdf-find').size") == "0" + } + scenario.onActivity { assertEquals("", it.viewModel.searchQuery.value) } + } + } +} diff --git a/app/src/main/java/app/grapheneos/pdfviewer/PdfJsChannel.kt b/app/src/main/java/app/grapheneos/pdfviewer/PdfJsChannel.kt index 7537e4198..841c07276 100644 --- a/app/src/main/java/app/grapheneos/pdfviewer/PdfJsChannel.kt +++ b/app/src/main/java/app/grapheneos/pdfviewer/PdfJsChannel.kt @@ -59,6 +59,9 @@ class PdfJsChannel(private val viewModel: PdfViewModel) { @JavascriptInterface fun getInsetBottom(): Float = viewModel.insetBottom + @JavascriptInterface + fun getInsetIme(): Float = viewModel.insetIme + @JavascriptInterface fun getDocumentOrientationDegrees(): Int = viewModel.documentOrientationDegrees.value @@ -99,4 +102,12 @@ class PdfJsChannel(private val viewModel: PdfViewModel) { @JavascriptInterface fun getPassword(): String = viewModel.encryptedDocumentPassword + + /** + * One page of extracted text, as a JSON array of per-item strings. Called on a WebView + * binder thread. Returns false once the index is full, which stops the extraction loop. + */ + @JavascriptInterface + fun setPageText(page: Int, itemsJson: String, generation: Int): Boolean = + viewModel.setPageText(page, itemsJson, generation) } diff --git a/app/src/main/java/app/grapheneos/pdfviewer/PdfViewerScreen.kt b/app/src/main/java/app/grapheneos/pdfviewer/PdfViewerScreen.kt index 689c78b97..8da3cb11c 100644 --- a/app/src/main/java/app/grapheneos/pdfviewer/PdfViewerScreen.kt +++ b/app/src/main/java/app/grapheneos/pdfviewer/PdfViewerScreen.kt @@ -17,6 +17,7 @@ import android.webkit.WebResourceResponse import android.webkit.WebSettings import android.webkit.WebView import android.webkit.WebViewClient +import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.AnimatedVisibility @@ -34,10 +35,12 @@ import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.displayCutout import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.ime import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.union +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape @@ -45,7 +48,12 @@ import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowUp import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.material.icons.outlined.Info @@ -56,6 +64,7 @@ import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.SnackbarDuration @@ -64,6 +73,7 @@ import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton +import androidx.compose.material3.TextField import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect @@ -86,6 +96,7 @@ import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.testTag @@ -103,6 +114,7 @@ import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView @@ -119,13 +131,16 @@ import app.grapheneos.pdfviewer.PdfJsChannel.Companion.MIN_ZOOM_RATIO import app.grapheneos.pdfviewer.PdfViewer.Companion.PDF_MIME import app.grapheneos.pdfviewer.outline.OutlineScreen import app.grapheneos.pdfviewer.properties.DocumentProperty +import app.grapheneos.pdfviewer.ui.darkSearchFieldColors import app.grapheneos.pdfviewer.ui.darkTopAppBarColors import app.grapheneos.pdfviewer.viewModel.PdfViewModel +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import java.io.FileNotFoundException import java.io.IOException import java.io.InputStream @@ -249,6 +264,9 @@ fun PdfViewerScreen( var showPageIndicator by remember { mutableStateOf(false) } val isToolbarVisible by viewModel.toolbarVisible.collectAsStateWithLifecycle() + val searchActive by viewModel.searchActive.collectAsStateWithLifecycle() + val searchQuery by viewModel.searchQuery.collectAsStateWithLifecycle() + val searchResult by viewModel.searchResult.collectAsStateWithLifecycle() var showOutline by rememberSaveable { mutableStateOf(false) } var showJumpToPage by rememberSaveable { mutableStateOf(false) } var showDocProperties by rememberSaveable { mutableStateOf(false) } @@ -309,10 +327,12 @@ fun PdfViewerScreen( val insetLeftPx = systemInsets.getLeft(density, layoutDirection).toFloat() val insetRightPx = systemInsets.getRight(density, layoutDirection).toFloat() val insetBottomPx = systemInsets.getBottom(density).toFloat() + val insetImePx = WindowInsets.ime.getBottom(density).toFloat() SideEffect { viewModel.insetLeft = insetLeftPx viewModel.insetRight = insetRightPx viewModel.insetBottom = insetBottomPx + viewModel.insetIme = insetImePx if (isToolbarVisible) { viewModel.insetTop = toolbarHeightPx } @@ -325,6 +345,47 @@ fun PdfViewerScreen( } } + // Extraction runs once per document. webView is a key so a crash and Activity recreate + // re-issues it; the corpus lives in the ViewModel, so a finished sweep is never repeated. + LaunchedEffect(searchActive, documentLoaded, webView, numPages) { + if (!searchActive || !documentLoaded || numPages == 0) return@LaunchedEffect + if (viewModel.extractionComplete()) return@LaunchedEffect + webView?.evaluateJavascript( + "extractText(${page.coerceIn(1, numPages)},${viewModel.currentSearchGeneration})", null + ) + } + + LaunchedEffect(searchQuery, numPages) { + if (numPages > 0) viewModel.runSearch(searchQuery, page.coerceIn(1, numPages)) + } + + // Keyed on the index too: stepping between two matches on the same page must still bring the + // viewer back to that page if the user has since swiped away from it. + LaunchedEffect(searchResult.activePage, searchResult.activeIndex) { + if (searchResult.activePage > 0) jumpToPage(viewModel, webView, searchResult.activePage) + } + + // Deliberately not keyed on the whole searchResult: that would fire one evaluateJavascript + // per page during a scan. The active match plus the displayed page covers every visible + // transition, and the scan starts at the current page so its highlights land immediately. + // searchResult.version is in the key list because two different queries can select the same + // (page, index); without it the highlights would keep the previous query's offsets. + LaunchedEffect( + page, searchResult.activePage, searchResult.activeIndex, searchResult.version, + searchActive, webView + ) { + val wv = webView ?: return@LaunchedEffect + if (!searchActive || searchResult.total == 0) { + wv.evaluateJavascript("setSearchHighlights(0,[],-1)", null) + return@LaunchedEffect + } + val active = if (searchResult.activePage == page) searchResult.activeIndex else -1 + // Off the main thread: tuplesFor takes the index lock, which a binder thread can be + // holding for the length of one page's match pass. + val tuples = withContext(Dispatchers.Default) { viewModel.tuplesFor(page) } + wv.evaluateJavascript("setSearchHighlights($page,$tuples,$active)", null) + } + LaunchedEffect(pageIndicator) { if (pageIndicator > 0) { showPageIndicator = true @@ -342,6 +403,9 @@ fun PdfViewerScreen( GestureHelper.attach(context, wv, object : GestureHelper.GestureListener { override fun onTapUp(): Boolean { if (viewModel.uri.value == null) return false + // Hiding the toolbar while searching would take the find bar with it, and also + // hide the status bar and freeze insetTop, so guard the whole gesture. + if (viewModel.searchActive.value) return false wv.evaluateJavascript("isTextSelected()") { selection -> if (!selection.toBoolean()) { viewModel.setToolbarVisible(!viewModel.toolbarVisible.value) @@ -496,7 +560,21 @@ fun PdfViewerScreen( } } - if (isToolbarVisible) { + if (isToolbarVisible && searchActive && !webViewCrashed && webViewOk) { + SearchAppBar( + query = searchQuery, + result = searchResult, + numPages = numPages, + onQueryChange = viewModel::setSearchQuery, + onStep = viewModel::stepMatch, + onClose = viewModel::closeSearch, + modifier = Modifier + .fillMaxWidth() + .onGloballyPositioned { coordinates -> + toolbarHeightPx = coordinates.size.height.toFloat() + } + ) + } else if (isToolbarVisible) { PdfTopAppBar( title = displayName, documentLoaded = documentLoaded, @@ -547,6 +625,7 @@ fun PdfViewerScreen( } saveAsLauncher.launch(intent) }, + onSearch = { viewModel.openSearch() }, onDocumentProperties = { showDocProperties = true }, onToggleTextLayer = { webView?.evaluateJavascript("toggleTextLayerVisibility()", null) @@ -830,6 +909,7 @@ private fun PdfTopAppBar( onZoomOut: () -> Unit, onCustomZoom: () -> Unit, onOutline: () -> Unit, + onSearch: () -> Unit, onShare: () -> Unit, onSaveAs: () -> Unit, onDocumentProperties: () -> Unit, @@ -855,6 +935,12 @@ private fun PdfTopAppBar( contentDescription = stringResource(R.string.action_next) ) } + IconButton(onClick = onSearch, enabled = enabled) { + Icon( + Icons.Default.Search, + contentDescription = stringResource(R.string.action_search) + ) + } } IconButton(onClick = onOpen, enabled = !webViewCrashed && webViewOk) { @@ -1002,6 +1088,144 @@ private fun PdfTopAppBar( ) } +/** + * Find-in-document bar. Replaces the top app bar while a search is active, the way Chrome, + * Firefox and Acrobat all present find on Android. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SearchAppBar( + query: String, + result: PdfViewModel.SearchResult, + numPages: Int, + onQueryChange: (String) -> Unit, + onStep: (Boolean) -> Unit, + onClose: () -> Unit, + modifier: Modifier = Modifier +) { + val focusRequester = remember { FocusRequester() } + val focusManager = LocalFocusManager.current + + // Held as TextFieldValue, and saved, so the caret does not jump to the start on a + // configuration change — the same pattern the JumpToPage and CustomZoom dialogs use. + var field by rememberSaveable(stateSaver = TextFieldValue.Saver) { + mutableStateOf(TextFieldValue(query, TextRange(query.length))) + } + // Re-synced from an effect rather than during composition: typing updates `field` first and + // `query` only after the ViewModel round trip, so a composition-time write could put the + // pre-keystroke text back. + LaunchedEffect(query) { + if (field.text != query) { + field = TextFieldValue(query, TextRange(query.length)) + } + } + var focusedOnce by rememberSaveable { mutableStateOf(false) } + + BackHandler(onBack = onClose) + LaunchedEffect(Unit) { + if (!focusedOnce) { + focusedOnce = true + focusRequester.requestFocus() + } + } + + val hasMatches = result.total > 0 + val countText = when { + query.isEmpty() -> "" + result.scanning && result.total == 0 -> "" + // While the document is still being indexed the total is a lower bound, so it is shown + // with a "+". Without that, "1/3" two seconds in reads as final when it will become + // "1/247" once the scan finishes. + result.truncated || result.scanning -> stringResource( + R.string.search_match_count_partial, result.ordinal, result.total + ) + else -> stringResource(R.string.search_match_count, result.ordinal, result.total) + } + + Column { + TopAppBar( + // The modifier carries onGloballyPositioned, so it must sit on the bar itself and + // not the Column: insetTop has to stay put when the progress bar comes and goes. + modifier = modifier, + colors = darkTopAppBarColors(), + navigationIcon = { + IconButton(onClick = onClose) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.action_close) + ) + } + }, + title = { + TextField( + value = field, + onValueChange = { + field = it + onQueryChange(it.text) + }, + singleLine = true, + placeholder = { Text(stringResource(R.string.action_search)) }, + trailingIcon = if (query.isEmpty()) null else ({ + IconButton(onClick = { onQueryChange("") }) { + Icon( + Icons.Default.Close, + contentDescription = stringResource(R.string.search_clear) + ) + } + }), + colors = darkSearchFieldColors(), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + keyboardActions = KeyboardActions( + onSearch = { + // Dismissing the IME is the only way to stop a match being scrolled + // behind the keyboard: edge to edge means the WebView is not resized. + focusManager.clearFocus() + if (hasMatches) onStep(true) + } + ), + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester) + .testTag(TestTags.SEARCH_FIELD) + ) + }, + actions = { + Text( + text = countText, + style = MaterialTheme.typography.labelLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + // Bounded so the text field keeps a usable width at large font scales and + // in narrow split-screen windows, where "128/1024+" would otherwise eat + // most of the bar. + .widthIn(max = 88.dp) + .testTag(TestTags.SEARCH_COUNT) + .semantics { liveRegion = LiveRegionMode.Polite } + ) + IconButton(onClick = { onStep(false) }, enabled = hasMatches) { + Icon( + Icons.Default.KeyboardArrowUp, + contentDescription = stringResource(R.string.search_previous) + ) + } + IconButton(onClick = { onStep(true) }, enabled = hasMatches) { + Icon( + Icons.Default.KeyboardArrowDown, + contentDescription = stringResource(R.string.search_next) + ) + } + } + ) + if (result.scanning && numPages > 0) { + LinearProgressIndicator( + progress = { result.scannedPages / numPages.toFloat() }, + modifier = Modifier.fillMaxWidth() + ) + } + } +} + @Composable private fun WebViewAlertScreen( title: String, diff --git a/app/src/main/java/app/grapheneos/pdfviewer/TestTags.kt b/app/src/main/java/app/grapheneos/pdfviewer/TestTags.kt index 19d1ea396..8a3abe8bf 100644 --- a/app/src/main/java/app/grapheneos/pdfviewer/TestTags.kt +++ b/app/src/main/java/app/grapheneos/pdfviewer/TestTags.kt @@ -10,4 +10,6 @@ object TestTags { const val JUMP_TO_PAGE_FIELD = "jump_to_page_field" const val CUSTOM_ZOOM_FIELD = "custom_zoom_field" const val ZOOM_PERCENTAGE = "zoom_percentage" + const val SEARCH_FIELD = "search_field" + const val SEARCH_COUNT = "search_count" } diff --git a/app/src/main/java/app/grapheneos/pdfviewer/search/DocumentSearch.kt b/app/src/main/java/app/grapheneos/pdfviewer/search/DocumentSearch.kt new file mode 100644 index 000000000..d14065ae0 --- /dev/null +++ b/app/src/main/java/app/grapheneos/pdfviewer/search/DocumentSearch.kt @@ -0,0 +1,291 @@ +package app.grapheneos.pdfviewer.search + +import android.icu.text.Collator +import android.icu.text.RuleBasedCollator +import android.icu.text.SearchIterator +import android.icu.text.StringSearch +import android.icu.util.ULocale +import androidx.annotation.VisibleForTesting +import org.json.JSONArray +import java.text.StringCharacterIterator +import java.util.TreeMap + +/** One page of extracted text plus the start offset of each text item within it. */ +private class PageText(val text: String, val starts: IntArray) + +/** + * The whole search engine: corpus, matcher and match index. + * + * Matching is [StringSearch] at [Collator.PRIMARY], which folds case, diacritics, ligatures, + * width and kana the way Chromium's find-in-page does, and reports offsets into the *original* + * string. That is what removes any need for a normalized copy of the text and a map back to it. + * + * Holds no Android UI or coroutine types so it can be tested directly. + */ +class DocumentSearch { + + companion object { + // ponytail: two caps, one behaviour. 8M chars (~16 MB UTF-16) covers a 2000 page dense + // book; past either cap extraction stops and the count renders as "n/m+". Upgrade path + // if any retention is objectionable: drop the corpus in onStop and re-extract, at the + // cost of a full worker sweep on the next keystroke. + const val MAX_CORPUS_CHARS = 8_000_000 + const val MAX_MATCHES = 100_000 + + // pdf.js takes the page count from the catalog's /Count, which it trusts after probing + // only the last page, so a kilobyte of hostile page tree can claim a hundred million + // pages. Text-free pages cost no characters, so the character cap alone would never stop + // the sweep; this bounds it by entries as well. + const val MAX_PAGES = 50_000 + } + + // ULocale.ROOT, not the user locale: a Turkish collator makes "i" != "I", which is correct + // for sorting Turkish and wrong for searching a document. + // + // Collation already compares canonically equivalent text through its internal FCD check, so + // precomposed and combining-mark forms match with or without CANONICAL_DECOMPOSITION. + // Measured on API 36: byte-identical match offsets for NFC/NFD Latin, Hangul, Vietnamese, + // Arabic, ligatures, eszett, full width and CJK. NO_DECOMPOSITION is the root default and + // skips a normalization pass PDF text never needs, so it is set explicitly rather than + // inherited. + // + // ponytail: matching runs inline on the WebView binder thread, which blocks the JS + // extraction loop until it returns. That is deliberate backpressure and costs microseconds + // for a typical 2 KB page. ICU does pay a large one-time cost on its first substantial + // scan (~12s on an x86 emulator, then ~1s per 244k chars), which lands on the first page of + // the first query; if that is ever measurable on real hardware, warm the collator on a + // background thread when the search bar opens. + private val collator = (Collator.getInstance(ULocale.ROOT) as RuleBasedCollator).apply { + strength = Collator.PRIMARY + decomposition = Collator.NO_DECOMPOSITION + } + + private val lock = Any() + private val pages = HashMap() + /** page -> flat [start, length, start, length, ...] in that page's text. */ + private val matches = TreeMap() + private var query = "" + private var chars = 0 + private var total = 0 + private var truncated = false + private var version = 0 + + /** + * Everything the UI needs about the index, read under a single lock acquisition. [version] + * changes whenever the query does, so consumers can tell "same active match, different query" + * apart from "nothing happened". + */ + class Stats( + val total: Int, + val scannedPages: Int, + val truncated: Boolean, + val version: Int + ) + + fun stats(): Stats = synchronized(lock) { Stats(total, pages.size, truncated, version) } + + fun clear() = synchronized(lock) { + pages.clear() + matches.clear() + total = 0 + chars = 0 + truncated = false + query = "" + version++ + } + + fun setQuery(value: String) = synchronized(lock) { + query = value + matches.clear() + total = 0 + version++ + // Only the corpus cap is sticky. A single very common character can cross MAX_MATCHES on + // a big book; if that latched, extraction would stay stopped for the rest of the session + // and every later query would silently search only the pages scanned before it. + truncated = chars >= MAX_CORPUS_CHARS || pages.size >= MAX_PAGES + } + + /** True when [value] is already the indexed query, so a re-scan would be wasted work. */ + fun isIndexed(value: String): Boolean = + synchronized(lock) { value.isNotEmpty() && value == query && pages.isNotEmpty() } + + /** + * Adds one page of extracted text. Returns false once a cap is reached, which is the + * signal for the extraction loop in JS to stop. + */ + fun addPage(page: Int, itemsJson: String): Boolean { + val pending: String + synchronized(lock) { + // Checked before parsing, so a page that arrives after the cap cannot make the + // binder thread materialise it. + if (truncated) return false + if (pages.containsKey(page)) return true + pending = query + } + // Parsed outside the lock: this runs on a WebView binder thread and the parse is by far + // the slowest part of the call. + val array = JSONArray(itemsJson) + val builder = StringBuilder() + val starts = IntArray(array.length()) + for (i in 0 until array.length()) { + starts[i] = builder.length + builder.append(array.getString(i)) + } + val text = PageText(builder.toString(), starts) + // Matched outside the lock too: see matchPage. Retried rather than abandoned if the query + // changed meanwhile, because the scan loop that runSearch starts skips pages that were not + // yet in the corpus when it passed them — dropping the result here would leave this page + // unmatched for the rest of the query's life. + var attempt = pending + // Bounded: this runs on a binder thread, and the retry only exists to cover a query that + // changed during one page's match pass. Four consecutive changes inside a few milliseconds + // does not happen; if it somehow did, the page lands unmatched and the next keystroke + // re-matches it. + repeat(4) { + val found = if (attempt.isEmpty()) IntArray(0) else findMatches(text.text, attempt) + synchronized(lock) { + if (truncated || pages.containsKey(page)) return !truncated + if (attempt != query) { + attempt = query + return@synchronized + } + pages[page] = text + chars += text.text.length + store(page, found) + if (chars >= MAX_CORPUS_CHARS || total >= MAX_MATCHES || pages.size >= MAX_PAGES) { + truncated = true + return false + } + return true + } + } + // Retries exhausted: still record the text, so the page is searchable from the next pass. + synchronized(lock) { + if (truncated || pages.containsKey(page)) return !truncated + pages[page] = text + chars += text.text.length + if (chars >= MAX_CORPUS_CHARS || pages.size >= MAX_PAGES) { + truncated = true + return false + } + return true + } + } + + /** + * No-op for a page that has not been extracted yet; it is matched on arrival instead. + * + * The ICU pass runs outside the lock. Holding it across a scan would make every main-thread + * caller ([stats], [step], [setQuery]) wait for that scan, which is an ANR on the first + * search of a process or on a page carrying a book's worth of text. [PageText] is immutable, + * so matching a snapshot is safe; the result is dropped if the query moved on meanwhile. + */ + fun matchPage(page: Int) { + val text: PageText + val pending: String + synchronized(lock) { + text = pages[page] ?: return + pending = query + if (pending.isEmpty()) return + } + val found = findMatches(text.text, pending) + synchronized(lock) { + if (pending == query) store(page, found) + } + } + + private fun store(page: Int, found: IntArray) { + if (found.isEmpty()) matches.remove(page) else matches[page] = found + // Recomputed rather than accumulated, so matching the same page twice cannot double count. + total = matches.values.sumOf { it.size / 2 } + } + + @VisibleForTesting + fun findMatches(text: String, pattern: String): IntArray { + if (pattern.isEmpty() || text.isEmpty()) return IntArray(0) + val out = ArrayList() + try { + val search = StringSearch(pattern, StringCharacterIterator(text), collator, null) + var index = search.first() + while (index != SearchIterator.DONE) { + val length = search.matchLength + // A wholly collation-ignorable pattern (a lone soft hyphen, say) matches + // everywhere with zero length and would otherwise spin forever. + if (length <= 0) break + out.add(index) + out.add(length) + if (out.size / 2 >= MAX_MATCHES) break + index = search.next() + } + } catch (_: IllegalArgumentException) { + // ICU rejects some degenerate patterns outright. + return IntArray(0) + } + return out.toIntArray() + } + + /** Snapshot of the page numbers actually held, so callers never loop over a claimed count. */ + fun pageNumbers(): List = synchronized(lock) { pages.keys.sorted() } + + fun countOn(page: Int): Int = synchronized(lock) { (matches[page]?.size ?: 0) / 2 } + + fun ordinalBefore(page: Int): Int = + synchronized(lock) { matches.headMap(page).values.sumOf { it.size / 2 } } + + fun firstPageFrom(page: Int): Int? = + synchronized(lock) { matches.ceilingKey(page) ?: matches.firstEntry()?.key } + + /** Next/previous match as (page, indexOnPage), wrapping around the document. */ + fun step(page: Int, index: Int, forward: Boolean): Pair? = synchronized(lock) { + if (matches.isEmpty()) return null + val here = matches[page] + if (here != null) { + val next = index + if (forward) 1 else -1 + if (next >= 0 && next < here.size / 2) return page to next + } + val target = if (forward) { + matches.higherKey(page) ?: matches.firstKey() + } else { + matches.lowerKey(page) ?: matches.lastKey() + } + return target to if (forward) 0 else matches[target]!!.size / 2 - 1 + } + + /** + * The current page's matches as `[[[itemIndex,offsetInItem,length],...],...]` — one inner + * array per match, one triple per text item that match spans. Digits, commas and brackets + * only, so it is safe to inline into an evaluateJavascript call. + */ + fun tuplesFor(page: Int): String = synchronized(lock) { + val flat = matches[page] ?: return "[]" + val text = pages[page] ?: return "[]" + val starts = text.starts + val out = StringBuilder("[") + var m = 0 + while (m < flat.size) { + if (m > 0) out.append(',') + out.append('[') + val from = flat[m] + val to = from + flat[m + 1] + var item = starts.binarySearch(from).let { if (it >= 0) it else -it - 2 } + .coerceAtLeast(0) + var first = true + while (item < starts.size && starts[item] < to) { + val itemEnd = if (item + 1 < starts.size) starts[item + 1] else text.text.length + val begin = maxOf(from, starts[item]) + val end = minOf(to, itemEnd) + if (end > begin) { + if (!first) out.append(',') + out.append('[').append(item).append(',') + .append(begin - starts[item]).append(',') + .append(end - begin).append(']') + first = false + } + item++ + } + out.append(']') + m += 2 + } + return out.append(']').toString() + } +} diff --git a/app/src/main/java/app/grapheneos/pdfviewer/ui/Theme.kt b/app/src/main/java/app/grapheneos/pdfviewer/ui/Theme.kt index ed8b2457a..14a75c2b2 100644 --- a/app/src/main/java/app/grapheneos/pdfviewer/ui/Theme.kt +++ b/app/src/main/java/app/grapheneos/pdfviewer/ui/Theme.kt @@ -2,8 +2,11 @@ package app.grapheneos.pdfviewer.ui import android.os.Build import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.ColorScheme import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.TextFieldColors +import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TopAppBarColors import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.darkColorScheme @@ -11,6 +14,7 @@ import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext @Composable @@ -27,15 +31,42 @@ fun PdfViewerTheme(content: @Composable () -> Unit) { } @Composable -fun darkTopAppBarColors(): TopAppBarColors { - val darkScheme = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { +private fun darkScheme(): ColorScheme = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { dynamicDarkColorScheme(LocalContext.current) } else { darkColorScheme() } + +@Composable +fun darkTopAppBarColors(): TopAppBarColors { + val darkScheme = darkScheme() return TopAppBarDefaults.topAppBarColors( containerColor = darkScheme.surface, titleContentColor = darkScheme.onSurface, + navigationIconContentColor = darkScheme.onSurfaceVariant, actionIconContentColor = darkScheme.onSurfaceVariant ) } + +/** + * Text field colours for a field sitting inside a [darkTopAppBarColors] bar. Without these the + * field inherits the ambient (possibly light) scheme and renders dark text on a dark bar. + */ +@Composable +fun darkSearchFieldColors(): TextFieldColors { + val darkScheme = darkScheme() + return TextFieldDefaults.colors( + focusedTextColor = darkScheme.onSurface, + unfocusedTextColor = darkScheme.onSurface, + cursorColor = darkScheme.primary, + focusedPlaceholderColor = darkScheme.onSurfaceVariant, + unfocusedPlaceholderColor = darkScheme.onSurfaceVariant, + focusedTrailingIconColor = darkScheme.onSurfaceVariant, + unfocusedTrailingIconColor = darkScheme.onSurfaceVariant, + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent + ) +} diff --git a/app/src/main/java/app/grapheneos/pdfviewer/viewModel/PdfViewModel.kt b/app/src/main/java/app/grapheneos/pdfviewer/viewModel/PdfViewModel.kt index 39baf6758..875948812 100644 --- a/app/src/main/java/app/grapheneos/pdfviewer/viewModel/PdfViewModel.kt +++ b/app/src/main/java/app/grapheneos/pdfviewer/viewModel/PdfViewModel.kt @@ -13,12 +13,14 @@ import app.grapheneos.pdfviewer.outline.OutlineNode import app.grapheneos.pdfviewer.properties.DEFAULT_VALUE import app.grapheneos.pdfviewer.properties.DocumentPropertiesRetriever import app.grapheneos.pdfviewer.properties.DocumentProperty +import app.grapheneos.pdfviewer.search.DocumentSearch import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.cancel import kotlinx.coroutines.cancelChildren import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -27,6 +29,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import org.json.JSONException import java.io.FileNotFoundException import java.io.IOException import java.io.InputStream @@ -43,6 +46,9 @@ class PdfViewModel( private const val STATE_DOCUMENT_ORIENTATION_DEGREES: String = "documentOrientationDegrees" private const val STATE_DOCUMENT_PROPERTIES = "documentProperties" private const val STATE_DOCUMENT_NAME = "documentName" + private const val STATE_SEARCH_ACTIVE = "searchActive" + private const val STATE_SEARCH_QUERY = "searchQuery" + private const val SEARCH_DEBOUNCE_MS = 200L } val uri: StateFlow = savedStateHandle.getStateFlow(STATE_URI, null) @@ -73,7 +79,12 @@ class PdfViewModel( private val _webViewCrashed = MutableStateFlow(false) val webViewCrashed: StateFlow = _webViewCrashed.asStateFlow() - fun setWebViewCrashed(value: Boolean) { _webViewCrashed.value = value } + fun setWebViewCrashed(value: Boolean) { + _webViewCrashed.value = value + // Nothing can be highlighted or scrolled without a renderer, so do not leave an + // auto-focusing find bar with a keyboard on top of the crash screen. + if (value) closeSearch() + } private val _toolbarVisible = MutableStateFlow(true) val toolbarVisible: StateFlow = _toolbarVisible.asStateFlow() @@ -189,6 +200,175 @@ class PdfViewModel( } } + /** + * Everything the find bar renders. [ordinal] is 1-based across the whole document and 0 when + * nothing is selected; [scanning] means the corpus is still being extracted, so [total] is a + * lower bound. + */ + data class SearchResult( + val total: Int = 0, + val ordinal: Int = 0, + val activePage: Int = 0, + val activeIndex: Int = -1, + val scannedPages: Int = 0, + val scanning: Boolean = false, + val truncated: Boolean = false, + /** Changes with the query, so a repaint is triggered even when the active match does not. */ + val version: Int = 0 + ) + + private val search = DocumentSearch() + // The active match is read and written from the main thread (stepMatch), a background + // dispatcher (runSearch) and a WebView binder thread (setPageText), so every read-modify-write + // of the pair goes through this lock. + private val searchLock = Any() + private var activePage = 0 + private var activeIndex = -1 + private var searchJob: Job? = null + + /** Bumped per document so a sweep started for a previous one cannot write into the corpus. */ + @Volatile + private var searchGeneration = 0 + val currentSearchGeneration: Int get() = searchGeneration + + val searchActive: StateFlow = + savedStateHandle.getStateFlow(STATE_SEARCH_ACTIVE, false) + + val searchQuery: StateFlow = savedStateHandle.getStateFlow(STATE_SEARCH_QUERY, "") + fun setSearchQuery(value: String) { savedStateHandle[STATE_SEARCH_QUERY] = value } + + private val _searchResult = MutableStateFlow(SearchResult()) + val searchResult: StateFlow = _searchResult.asStateFlow() + + fun openSearch() { + setToolbarVisible(true) + savedStateHandle[STATE_SEARCH_ACTIVE] = true + } + + fun closeSearch() { + savedStateHandle[STATE_SEARCH_ACTIVE] = false + savedStateHandle[STATE_SEARCH_QUERY] = "" + searchJob?.cancel() + search.setQuery("") + synchronized(searchLock) { + activePage = 0 + activeIndex = -1 + } + _searchResult.value = SearchResult() + } + + /** True once every page has been extracted, or the index hit its cap. */ + fun extractionComplete(): Boolean { + val stats = search.stats() + return _numPages.value > 0 && (stats.scannedPages >= _numPages.value || stats.truncated) + } + + fun tuplesFor(page: Int): String = search.tuplesFor(page) + + /** + * Called on a WebView binder thread. Returns false to stop extraction. + * + * [generation] identifies the document the sweep was started for. `loadUrl` does not tear the + * JS context down synchronously, so a sweep for the previous document can still land calls + * here after [resetDocumentState] has cleared the corpus; without the token those pages would + * be re-inserted and then never overwritten, and document A's text would be searched and + * highlighted as if it were document B's. + */ + fun setPageText(page: Int, itemsJson: String, generation: Int): Boolean { + if (generation != searchGeneration) return false + // Bounds-checked because this is reachable from the renderer, where untrusted PDF content + // is parsed. Empty pages add no characters, so without this a loop over made-up page + // numbers would grow the map without ever tripping the character cap. + if (page < 1 || page > _numPages.value) return false + val more = try { + search.addPage(page, itemsJson) + } catch (_: JSONException) { + true + } + publishSearch() + return more + } + + fun runSearch(query: String, fromPage: Int) { + // A configuration change re-runs the effect that calls this. Re-scanning would clear the + // index and show 0/0 for the length of a full sweep, for no gain. + if (search.isIndexed(query)) { + publishSearch() + return + } + searchJob?.cancel() + if (query.isEmpty()) { + search.setQuery("") + synchronized(searchLock) { + activePage = 0 + activeIndex = -1 + } + publishSearch() + return + } + searchJob = viewModelScope.launch(Dispatchers.Default) { + // The delay is the debounce: the next keystroke cancels this job before it elapses. + delay(SEARCH_DEBOUNCE_MS) + search.setQuery(query) + synchronized(searchLock) { + activePage = 0 + activeIndex = -1 + } + publishSearch() + // Driven by the pages actually held, not by the reported page count: pdf.js takes + // that from the PDF's own /Count, so a hostile document can claim a hundred million + // pages and turn this into an unbounded loop. Pages that arrive later are matched on + // arrival by addPage. + val numbers = search.pageNumbers() + val start = numbers.indexOfFirst { it >= fromPage }.coerceAtLeast(0) + for (i in numbers.indices) { + ensureActive() + search.matchPage(numbers[(start + i) % numbers.size]) + publishSearch() + } + } + } + + fun stepMatch(forward: Boolean) { + synchronized(searchLock) { + val next = search.step(activePage, activeIndex, forward) ?: return + activePage = next.first + activeIndex = next.second + } + publishSearch() + } + + /** + * Publishes are serialised on [searchLock] as a whole, including the assignment. Without that, + * a thread that read an early snapshot can be descheduled and then overwrite a later, correct + * one, leaving the find bar showing a stale count with nothing to trigger another publish. + */ + private fun publishSearch() = synchronized(searchLock) { + if (activeIndex < 0) { + search.firstPageFrom(page.value.coerceAtLeast(1))?.let { + activePage = it + activeIndex = 0 + } + } else if (search.countOn(activePage) <= activeIndex) { + // The page the selection was on no longer matches, e.g. the query changed. + activePage = 0 + activeIndex = -1 + } + val stats = search.stats() + _searchResult.value = SearchResult( + total = stats.total, + ordinal = if (activeIndex < 0) 0 else { + search.ordinalBefore(activePage) + activeIndex + 1 + }, + activePage = activePage, + activeIndex = activeIndex, + scannedPages = stats.scannedPages, + scanning = searchQuery.value.isNotEmpty() && !extractionComplete(), + truncated = stats.truncated, + version = stats.version + ) + } + private val _zoomRatio = MutableStateFlow(0f) val zoomRatio: StateFlow = _zoomRatio.asStateFlow() fun setZoomRatio(value: Float) { _zoomRatio.value = value } @@ -200,6 +380,9 @@ class PdfViewModel( @Volatile var insetTop = 0f @Volatile var insetRight = 0f @Volatile var insetBottom = 0f + /** Keyboard height. Under edge-to-edge the WebView is not resized, so scroll-to-match has to + * subtract this itself or the active match lands behind the IME. */ + @Volatile var insetIme = 0f val streamLock = Any() @Volatile var inputStream: InputStream? = null @@ -292,6 +475,9 @@ class PdfViewModel( clearOutline() clearDocumentProperties() dismissPasswordPrompt() + searchGeneration++ + search.clear() + closeSearch() } fun prepareForLoad() { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a4827dabc..a86087220 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -15,6 +15,12 @@ Outline Properties Close + Find in document + Previous match + Next match + Clear search + %1$d/%2$d + %1$d/%2$d+ View nested outline entries No outline available diff --git a/viewer/css/text_layer.css b/viewer/css/text_layer.css index 9d957deba..960cce50a 100644 --- a/viewer/css/text_layer.css +++ b/viewer/css/text_layer.css @@ -107,6 +107,29 @@ background: transparent; } +/* + * Search highlights. The Custom Highlight API paints from live Ranges, so highlights follow the + * text layer's transforms through zoom and rotation with no coordinate maths, and it mutates no + * DOM, which matters because rendered text layers are cached and reused. + */ +::highlight(pdf-find) { + background-color: rgb(180 0 170 / 0.4); +} + +::highlight(pdf-find-active) { + background-color: rgb(255 148 61 / 0.7); +} + +@media screen and (forced-colors: active) { + ::highlight(pdf-find), + ::highlight(pdf-find-active) { + background-color: Highlight; + /* The text layer is transparent over the canvas; in high contrast mode the matched + glyphs have to become legible, which .textLayer's forced-color-adjust: none allows. */ + color: HighlightText; + } +} + .textLayer .endOfContent { display: block; position: absolute; diff --git a/viewer/js/index.js b/viewer/js/index.js index 2e7ba55ce..2a15f6a3a 100644 --- a/viewer/js/index.js +++ b/viewer/js/index.js @@ -6,6 +6,7 @@ import { getDocument, } from "pdfjs-dist"; import { getSimplifiedOutline } from "./outline.js"; +import { pageTextItems } from "./search.js"; GlobalWorkerOptions.workerSrc = "/viewer/js/worker.js"; @@ -31,6 +32,93 @@ const maxCached = 6; let isTextLayerVisible = false; let userZoomed = false; +// The page whose text layer is actually in the DOM, and its text item divs. Not the same as +// channel.getPage(), which is the page that has been *requested*: jumpToPage updates the model +// before asking for a render, so painting against it would put one page's offsets on another +// page's divs. +let displayedPage = 0; +let displayedDivs = []; +// { page, groups, active } as handed over by setSearchHighlights, or null. +let searchState = null; +let pendingScroll = false; +let extractEpoch = 0; +// An extraction asked for before the document finished loading, replayed once it has. A +// configuration change or a WebView crash recreates the WebView while the native side still +// believes the document is loaded, so the request arrives before pdfDoc exists. +let pendingExtract = null; + +const findHighlight = new Highlight(); +const activeHighlight = new Highlight(); +activeHighlight.priority = 1; +CSS.highlights.set("pdf-find", findHighlight); +CSS.highlights.set("pdf-find-active", activeHighlight); + +function scrollToRange(range) { + const rect = range.getBoundingClientRect(); + // A collapsed range sits at the page origin and is not worth scrolling to. + if (rect.width === 0 && rect.height === 0) { + return; + } + const ratio = globalThis.devicePixelRatio; + const top = channel.getInsetTop() / ratio; + const width = globalThis.innerWidth; + // Edge to edge means the WebView keeps its full height when the keyboard opens, so the + // usable band has to be narrowed by hand or matches scroll behind the IME. + const height = globalThis.innerHeight - channel.getInsetIme() / ratio; + // Already fully visible in that band: leave the viewport alone, otherwise stepping between + // two matches on the same line re-centres the page on every tap. + if (rect.top >= top && rect.bottom <= height && rect.left >= 0 && rect.right <= width) { + return; + } + scrollBy({ + left: rect.left - width / 2, + top: rect.top - (top + height) / 2, + behavior: "instant" + }); +} + +// The only place highlights are painted. Called whenever either half of the pair +// (search results, displayed page) changes. +function applyHighlights() { + findHighlight.clear(); + activeHighlight.clear(); + // Always clear before bailing out: the page cache re-attaches the *same* nodes, so ranges + // left in a Highlight would light up again when a cached text layer is swapped back in. + if (searchState === null || searchState.page !== displayedPage) { + return; + } + let activeRange = null; + for (let match = 0; match < searchState.groups.length; match++) { + for (const piece of searchState.groups[match]) { + // Items with an empty string get a div that is never appended, so it has no text + // node; likewise anything past pdf.js's MAX_TEXT_DIVS_TO_RENDER cutoff. + const node = displayedDivs[piece[0]]?.firstChild; + if (!node) { + continue; + } + const start = Math.min(piece[1], node.length); + const end = Math.min(piece[1] + piece[2], node.length); + // A match that begins on a synthetic end-of-line separator clamps to an empty range, + // which paints nothing and whose rect is at the page origin. + if (start >= end) { + continue; + } + const range = document.createRange(); + range.setStart(node, start); + range.setEnd(node, end); + findHighlight.add(range); + if (match === searchState.active) { + activeHighlight.add(range); + activeRange ??= range; + } + } + } + if (activeRange !== null && pendingScroll) { + pendingScroll = false; + scrollToRange(activeRange); + } +} + function maybeRenderNextPage() { if (renderPending) { pageRendering = false; @@ -128,6 +216,10 @@ function renderPage(pageNumber, zoom, prerender, prerenderTrigger = 0) { setLayerTransform(cached.pageWidth, cached.pageHeight, textLayerDiv); container.style.setProperty("--scale-factor", newZoomRatio.toString()); textLayerDiv.hidden = false; + + displayedPage = pageNumber; + displayedDivs = cached.textDivs; + applyHighlights(); } pageRendering = false; @@ -256,6 +348,10 @@ function renderPage(pageNumber, zoom, prerender, prerenderTrigger = 0) { textLayerDiv = newTextLayerDiv; container.style.setProperty("--scale-factor", newZoomRatio.toString()); textLayerDiv.hidden = false; + + displayedPage = pageNumber; + displayedDivs = textLayer.textDivs; + applyHighlights(); } if (cache.length === maxCached) { @@ -267,6 +363,7 @@ function renderPage(pageNumber, zoom, prerender, prerenderTrigger = 0) { orientationDegrees: orientationDegrees, canvas: newCanvas, textLayerDiv: newTextLayerDiv, + textDivs: textLayer.textDivs, pageWidth: viewport.width, pageHeight: viewport.height }); @@ -335,7 +432,55 @@ globalThis.toggleTextLayerVisibility = function () { isTextLayerVisible = !isTextLayerVisible; }; +// page 0 clears. May arrive before, during or after the target page's render: if the page is +// not displayed yet applyHighlights() clears and returns, and the render that follows repaints +// and performs the deferred scroll. +globalThis.setSearchHighlights = function (page, groups, active) { + searchState = page === 0 ? null : { page: page, groups: groups, active: active }; + pendingScroll = active >= 0; + applyHighlights(); +}; + +// Streams every page's text to the native side, starting at the page being viewed so the first +// results arrive immediately, then wrapping. Runs once per document; the native side caches. +globalThis.extractText = async function (startPage, generation) { + if (pdfDoc === null) { + pendingExtract = { startPage: startPage, generation: generation }; + return; + } + const epoch = ++extractEpoch; + const total = pdfDoc.numPages; + for (let i = 0; i < total; i++) { + const pageNumber = ((startPage - 1 + i) % total) + 1; + // Page turns beat the scan: the pdf.js worker is single threaded. Bounded so a stuck + // flag cannot wedge extraction. + for (let wait = 0; pageRendering && wait < 64; wait++) { + await new Promise((resolve) => setTimeout(resolve, 16)); + } + if (epoch !== extractEpoch) { + return; + } + let items = []; + try { + const page = await pdfDoc.getPage(pageNumber); + ({ items } = await page.getTextContent()); + if (pageNumber !== displayedPage) { + page.cleanup(); + } + } catch (error) { + console.log("getTextContent error: " + error); + } + // A false return means the native index is full and there is nothing left to send. + if (epoch !== extractEpoch || + !channel.setPageText( + pageNumber, JSON.stringify(pageTextItems(items)), generation)) { + return; + } + } +}; + globalThis.loadDocument = function () { + extractEpoch++; userZoomed = false; const pdfPassword = channel.getPassword(); const loadingTask = getDocument({ @@ -372,6 +517,12 @@ globalThis.loadDocument = function () { channel.onLoaded(); pdfDoc = newDoc; channel.setNumPages(pdfDoc.numPages); + if (pendingExtract !== null) { + const resume = pendingExtract; + pendingExtract = null; + // A stale generation is rejected by the first setPageText, which ends the loop. + globalThis.extractText(resume.startPage, resume.generation); + } pdfDoc.getMetadata().then(function (data) { channel.setDocumentProperties(JSON.stringify(data.info)); }).catch(function (error) { diff --git a/viewer/js/search.js b/viewer/js/search.js new file mode 100644 index 000000000..d2597e2b3 --- /dev/null +++ b/viewer/js/search.js @@ -0,0 +1,34 @@ +// U+002D hyphen-minus, U+2010 hyphen, U+2011 non-breaking hyphen. +const EOL_HYPHENS = "-‐‑"; + +// U+00AD soft hyphen is completely ignorable in root collation, so a line-broken word joins +// back together for the matcher without changing any offset. +const SOFT_HYPHEN = "­"; + +/** + * Maps the text items of one page to the strings the matcher concatenates. + * + * Item i contributes exactly `items[i].str.length + (hasEOL ? 1 : 0)` characters, so every + * offset below `items[i].str.length` is also a valid offset into the text node of + * `TextLayer.textDivs[i]`. The whole offset scheme rests on that invariant. + * + * Items that do not end a line are concatenated with no separator, because pdf.js splits runs + * mid-word on font and kerning changes. A line-final hyphen is replaced by two soft hyphens so + * "hyphen-\nation" matches "hyphenation"; every other line end becomes a single space, because + * U+000A is not collation-equal to a space. + * + * @param {Array} items text content items from page.getTextContent() + * @return {string[]} one string per item, index-aligned with TextLayer.textDivs + */ +export function pageTextItems(items) { + return items.map((item) => { + const str = item.str; + if (!item.hasEOL) { + return str; + } + if (str.length > 0 && EOL_HYPHENS.includes(str[str.length - 1])) { + return str.slice(0, -1) + SOFT_HYPHEN + SOFT_HYPHEN; + } + return str + " "; + }); +} diff --git a/viewer/js/search.test.js b/viewer/js/search.test.js new file mode 100644 index 000000000..0c8f930f8 --- /dev/null +++ b/viewer/js/search.test.js @@ -0,0 +1,58 @@ +import { describe, expect, test } from "vitest"; +import { pageTextItems } from "./search.js"; + +const SHY = "­"; + +function items(...specs) { + return specs.map(([str, hasEOL]) => ({ str, hasEOL })); +} + +describe("pageTextItems", () => { + test("items that do not end a line pass through unchanged", () => { + expect(pageTextItems(items(["Chap", false], ["ter", false]))).toEqual(["Chap", "ter"]); + }); + + test("a line end becomes a single space", () => { + expect(pageTextItems(items(["Chapter One", true]))).toEqual(["Chapter One "]); + }); + + test("an empty item that ends a line still yields a separator", () => { + expect(pageTextItems(items(["", true]))).toEqual([" "]); + }); + + test.each(["-", "‐", "‑"])("a line-final %j joins the word back up", (hyphen) => { + expect(pageTextItems(items([`hyphen${hyphen}`, true]))).toEqual([`hyphen${SHY}${SHY}`]); + }); + + test("a hyphen that does not end a line is left alone", () => { + expect(pageTextItems(items(["e-mail", false]))).toEqual(["e-mail"]); + }); + + test("a lone hyphen ending a line does not underflow", () => { + expect(pageTextItems(items(["-", true]))).toEqual([`${SHY}${SHY}`]); + }); + + // The load-bearing invariant: offsets below str.length stay valid in the matching corpus, + // which is what lets Kotlin return per-item offsets that index the text layer directly. + test("every item contributes str.length + (hasEOL ? 1 : 0) characters", () => { + const input = items( + ["Chap", false], ["ter One", true], ["", true], ["hyphen-", true], + ["‐", true], ["Page One Content", false], ["file", true] + ); + const out = pageTextItems(input); + for (const [i, item] of input.entries()) { + expect(out[i].length).toBe(item.str.length + (item.hasEOL ? 1 : 0)); + } + }); + + // Real item list of page 3 of app/src/androidTest/assets/test-multipage.pdf. The empty + // hasEOL item is the one whose textDivs entry is never appended to the DOM, so it is also + // the fixture that pins the null-firstChild guard in index.js. + test("joins a real page into searchable text", () => { + const page3 = items( + ["3", false], [" ", false], ["Chapter Three", false], + ["", true], ["Page Three Content", false] + ); + expect(pageTextItems(page3).join("")).toBe("3 Chapter Three Page Three Content"); + }); +});