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"); + }); +});