Skip to content

Commit 068780f

Browse files
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
1 parent a6618aa commit 068780f

13 files changed

Lines changed: 1430 additions & 4 deletions

File tree

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
package app.grapheneos.pdfviewer.test
2+
3+
import androidx.test.ext.junit.runners.AndroidJUnit4
4+
import app.grapheneos.pdfviewer.search.DocumentSearch
5+
import org.junit.Assert.assertEquals
6+
import org.junit.Assert.assertFalse
7+
import org.junit.Assert.assertNotEquals
8+
import org.junit.Assert.assertTrue
9+
import org.junit.Test
10+
import org.junit.runner.RunWith
11+
12+
/**
13+
* Pins the behaviour of the ICU collation matcher, which is the one part of search whose
14+
* semantics come from the platform rather than from this codebase.
15+
*/
16+
@RunWith(AndroidJUnit4::class)
17+
class PdfViewerSearchMatcherTest {
18+
19+
private val search = DocumentSearch()
20+
21+
/** Matches as (start, length) pairs. */
22+
private fun find(text: String, pattern: String): List<Pair<Int, Int>> =
23+
search.findMatches(text, pattern).toList().chunked(2).map { it[0] to it[1] }
24+
25+
private fun starts(text: String, pattern: String) = find(text, pattern).map { it.first }
26+
27+
@Test
28+
fun findsPlainSubstring() {
29+
assertEquals(listOf(4 to 4), find("the MIME database", "MIME"))
30+
}
31+
32+
@Test
33+
fun ignoresCase() {
34+
assertEquals(listOf(4 to 4), find("the mime database", "MIME"))
35+
assertEquals(listOf(4 to 4), find("the MIME database", "mime"))
36+
}
37+
38+
@Test
39+
fun findsEveryOccurrence() {
40+
assertEquals(listOf(0, 8, 16), starts("cat bat cat mat cat", "cat"))
41+
}
42+
43+
@Test
44+
fun matchesAreNotOverlapping() {
45+
assertEquals(listOf(0, 2), starts("aaaa", "aa"))
46+
}
47+
48+
@Test
49+
fun reportsNothingWhenAbsent() {
50+
assertEquals(emptyList<Pair<Int, Int>>(), find("hello world", "zzz"))
51+
assertEquals(emptyList<Pair<Int, Int>>(), find("ab", "abcdef"))
52+
}
53+
54+
@Test
55+
fun foldsDiacritics() {
56+
assertEquals(listOf(3 to 6), find("Le Résumé final", "resume"))
57+
assertEquals(listOf(4 to 6), find("the resume of", "Résumé"))
58+
}
59+
60+
@Test
61+
fun foldsDecomposedDiacritics() {
62+
// "Résumé" written NFD: e + U+0301 for each accent.
63+
val nfd = "Le Résumé final"
64+
val hits = find(nfd, "resume")
65+
assertEquals(1, hits.size)
66+
assertEquals(3, hits[0].first)
67+
// Length is in original-string units, so it covers the combining marks too.
68+
assertTrue("length ${hits[0].second} should cover the combining marks", hits[0].second >= 6)
69+
}
70+
71+
@Test
72+
fun foldsLigatures() {
73+
assertEquals(listOf(4 to 1), find("the file is here", "fi"))
74+
}
75+
76+
@Test
77+
fun foldsFullWidth() {
78+
assertEquals(listOf(5 to 3), find("code ABC here", "ABC"))
79+
}
80+
81+
@Test
82+
fun findsCjkSubstring() {
83+
assertEquals(listOf(0 to 2), find("日本語の本", "日本"))
84+
}
85+
86+
@Test
87+
fun softHyphenIsIgnorable() {
88+
// The end-of-line rule in search.js turns "hyphen-\nation" into "hyphen­­ation";
89+
// this is what makes a line-broken word findable as one word.
90+
val hits = find("hyphen­­ation", "hyphenation")
91+
assertEquals(1, hits.size)
92+
assertEquals(0, hits[0].first)
93+
assertEquals(13, hits[0].second)
94+
}
95+
96+
@Test
97+
fun spaceSeparatorJoinsLinesForPhraseSearch() {
98+
// "Chapter Three" + EOL + "Page Three Content" as search.js emits it: a phrase spanning
99+
// the line break is findable precisely because the separator is a space.
100+
assertEquals(listOf(10 to 10), find("3 Chapter Three Page Three Content", "three page"))
101+
}
102+
103+
@Test
104+
fun newlineIsNotEqualToSpace() {
105+
// Documents the reason search.js emits U+0020 and never U+000A at a line end.
106+
assertEquals(emptyList<Pair<Int, Int>>(), find("line\nnext", "line next"))
107+
}
108+
109+
@Test
110+
fun terminatesOnWhollyIgnorablePattern() {
111+
// A zero-length match would otherwise loop forever.
112+
assertEquals(emptyList<Pair<Int, Int>>(), find("abc", "­"))
113+
}
114+
115+
@Test
116+
fun toleratesEmptyInputs() {
117+
assertEquals(emptyList<Pair<Int, Int>>(), find("abc", ""))
118+
assertEquals(emptyList<Pair<Int, Int>>(), find("", "abc"))
119+
}
120+
121+
@Test
122+
fun tuplesMapOffsetsBackToTextItems() {
123+
// Item starts: "3"@0 " "@1 "Chapter Three"@2 " "@15 "Page Three Content"@16.
124+
search.setQuery("three page")
125+
assertTrue(search.addPage(1, """["3"," ","Chapter Three"," ","Page Three Content"]"""))
126+
// "Three Page" is offsets 10..20, so it spans three items: the tail of "Chapter Three",
127+
// the synthetic end-of-line space, and the head of "Page Three Content".
128+
assertEquals("[[[2,8,5],[3,0,1],[4,0,4]]]", search.tuplesFor(1))
129+
assertEquals(1, search.stats().total)
130+
}
131+
132+
@Test
133+
fun indexesMatchesAcrossPages() {
134+
search.setQuery("content")
135+
for (page in 1..4) {
136+
assertTrue(search.addPage(page, """["Page $page Content"]"""))
137+
}
138+
assertEquals(4, search.stats().total)
139+
assertEquals(1, search.countOn(3))
140+
assertEquals(2, search.ordinalBefore(3))
141+
assertEquals(1, search.firstPageFrom(1))
142+
assertEquals(3, search.firstPageFrom(3))
143+
// Wraps forward off the last page and backward off the first.
144+
assertEquals(2 to 0, search.step(1, 0, forward = true))
145+
assertEquals(1 to 0, search.step(4, 0, forward = true))
146+
assertEquals(4 to 0, search.step(1, 0, forward = false))
147+
}
148+
149+
@Test
150+
fun aNewQueryClearsMatchCapTruncation() {
151+
// A single very common character can cross the match cap. If that latched, extraction
152+
// would stay stopped and every later query would search only the pages scanned so far.
153+
search.setQuery("e")
154+
val dense = "e ".repeat(DocumentSearch.MAX_MATCHES + 1_000)
155+
// false is the signal to JS that the index is full and extraction should stop.
156+
assertFalse("the match cap should have tripped", search.addPage(1, """["$dense"]"""))
157+
assertTrue(search.stats().truncated)
158+
search.setQuery("content")
159+
assertFalse("a new query must resume extraction", search.stats().truncated)
160+
assertTrue(search.addPage(2, """["Page Two Content"]"""))
161+
assertEquals(1, search.stats().total)
162+
}
163+
164+
@Test
165+
fun theIndexVersionChangesWithTheQuery() {
166+
// The paint effect keys on this: two different queries can select the same (page, index),
167+
// and without a version change the highlights would keep the old query's offsets.
168+
search.setQuery("one")
169+
assertTrue(search.addPage(1, """["one two"]"""))
170+
val first = search.stats().version
171+
search.setQuery("two")
172+
assertNotEquals(first, search.stats().version)
173+
}
174+
175+
@Test
176+
fun aQueryStartingOnAnEndOfLineSeparatorStillMapsBack() {
177+
// "Chapter One" + synthetic EOL space + "Two": a query starting with the space produces a
178+
// piece whose offset sits at the end of the item's real text.
179+
search.setQuery(" two")
180+
assertTrue(search.addPage(1, """["Chapter One ","Two"]"""))
181+
assertEquals(1, search.stats().total)
182+
// Offset 11 is past "Chapter One".length is false - it is exactly the length, so the JS
183+
// side clamps it to a collapsed range and skips it, and item 1 carries the visible part.
184+
assertEquals("[[[0,11,1],[1,0,3]]]", search.tuplesFor(1))
185+
}
186+
187+
/**
188+
* Steady-state throughput. The first substantial ICU scan in a process pays a large one-time
189+
* cost (~12s on an x86 emulator) that a short scan does not absorb, so the first pass here is
190+
* untimed; what matters for search is the rate afterwards, which was ~1s per 244k characters
191+
* on the same emulator. The bound is loose enough not to measure whichever machine CI
192+
* allocates, and tight enough to catch an order-of-magnitude regression.
193+
*/
194+
@Test
195+
fun scansALargeCorpusQuickly() {
196+
val corpus = "The quick brown fox jumps over the lazy dog. MIME type test. ".repeat(4000)
197+
assertEquals(4000, search.findMatches(corpus, "lazy dog").size / 2)
198+
val start = System.nanoTime()
199+
val hits = search.findMatches(corpus, "lazy dog")
200+
val ms = (System.nanoTime() - start) / 1_000_000
201+
assertEquals(4000, hits.size / 2)
202+
assertTrue("scanning ${corpus.length} chars took ${ms}ms", ms < 10_000)
203+
}
204+
}

0 commit comments

Comments
 (0)