/*
* Episteme Reader - A native Android document reader.
* Copyright (C) 2026 Episteme
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see .
*
* mail: epistemereader@gmail.com
*/
package com.aryan.reader.epubreader
import timber.log.Timber
import android.webkit.WebView
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.aryan.reader.RenderMode
import com.aryan.reader.SearchNavigationControls
import com.aryan.reader.SearchResult
import com.aryan.reader.SearchResultsPanel
import com.aryan.reader.SearchState
import com.aryan.reader.epub.EpubBook
import com.aryan.reader.epub.contentFilePath
import com.aryan.reader.paginatedreader.IPaginator
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jsoup.Jsoup
import org.jsoup.nodes.Element
import org.jsoup.nodes.Node
import org.jsoup.nodes.TextNode
import java.io.File
import kotlin.math.max
import kotlin.math.min
private const val EPUB_SEARCH_WINDOW_CHARS = 32_768
private const val EPUB_SEARCH_SNIPPET_RADIUS = 35
private const val EPUB_SEARCH_MAX_OVERLAP_CHARS = 4_096
private val epubSearchSkippedTags = setOf("script", "style", "noscript")
private val epubSearchBlockBoundaryTags = setOf(
"address",
"article",
"aside",
"blockquote",
"br",
"caption",
"dd",
"div",
"dl",
"dt",
"figcaption",
"figure",
"footer",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"header",
"hr",
"li",
"main",
"nav",
"ol",
"p",
"pre",
"section",
"table",
"td",
"th",
"tr",
"ul"
)
/**
* Creates the search implementation for EPUB chapters.
*/
fun createEpubSearcher(epubBook: EpubBook): suspend (String) -> List = { query ->
withContext(Dispatchers.Default) {
val searchQuery = query.trim()
if (searchQuery.isBlank()) {
return@withContext emptyList()
}
val results = mutableListOf()
epubBook.chapters.forEachIndexed { chapterIndex, chapter ->
try {
val htmlFile = File(epubBook.extractionBasePath, chapter.contentFilePath())
if (!htmlFile.exists()) return@forEachIndexed
val doc = Jsoup.parse(htmlFile, "UTF-8")
doc.select("script, style, noscript").remove()
val bodyNodes = doc.body().childNodes().toList()
val chunks = bodyNodes.chunked(20)
var occurrenceIndexInChapter = 0
chunks.forEachIndexed { chunkIndex, chunkNodes ->
occurrenceIndexInChapter = appendSearchResultsFromNodes(
nodes = chunkNodes,
query = searchQuery,
chapterIndex = chapterIndex,
chapterTitle = chapter.title,
chunkIndex = chunkIndex,
occurrenceIndexInChapter = occurrenceIndexInChapter,
results = results
)
}
} catch (e: Exception) {
Timber.e(e, "Failed to search in chapter $chapterIndex")
} catch (e: OutOfMemoryError) {
Timber.e(e, "Skipping search in chapter $chapterIndex after running out of memory")
}
}
results
}
}
private fun appendSearchResultsFromNodes(
nodes: List,
query: String,
chapterIndex: Int,
chapterTitle: String,
chunkIndex: Int,
occurrenceIndexInChapter: Int,
results: MutableList
): Int {
val searchWindow = EpubSearchWindow(
query = query,
chapterIndex = chapterIndex,
chapterTitle = chapterTitle,
chunkIndex = chunkIndex,
initialOccurrenceIndex = occurrenceIndexInChapter,
results = results
)
nodes.forEach { node ->
searchWindow.visit(node)
}
searchWindow.finish()
return searchWindow.occurrenceIndex
}
private class EpubSearchWindow(
private val query: String,
private val chapterIndex: Int,
private val chapterTitle: String,
private val chunkIndex: Int,
initialOccurrenceIndex: Int,
private val results: MutableList
) {
private val buffer = StringBuilder()
private val overlapChars = (query.length + EPUB_SEARCH_SNIPPET_RADIUS)
.coerceIn(EPUB_SEARCH_SNIPPET_RADIUS * 2, EPUB_SEARCH_MAX_OVERLAP_CHARS)
private var lastAppendedWasWhitespace = true
private var previousCharBeforeBuffer: Char? = null
var occurrenceIndex: Int = initialOccurrenceIndex
private set
fun visit(node: Node) {
when (node) {
is TextNode -> appendNormalizedText(node.wholeText)
is Element -> {
val tagName = node.tagName().lowercase()
if (tagName in epubSearchSkippedTags) return
if (tagName == "br") {
appendNormalizedWhitespace()
return
}
node.childNodes().forEach(::visit)
if (tagName in epubSearchBlockBoundaryTags) {
appendNormalizedWhitespace()
}
}
else -> node.childNodes().forEach(::visit)
}
}
fun finish() {
scanBuffer(buffer.length)
buffer.clear()
previousCharBeforeBuffer = null
}
private fun appendNormalizedText(text: String) {
text.forEach { char ->
if (char.isWhitespace()) {
appendNormalizedWhitespace()
} else {
buffer.append(char)
lastAppendedWasWhitespace = false
trimScannedPrefixIfNeeded()
}
}
}
private fun appendNormalizedWhitespace() {
if (buffer.isEmpty() || lastAppendedWasWhitespace) {
lastAppendedWasWhitespace = true
return
}
buffer.append(' ')
lastAppendedWasWhitespace = true
trimScannedPrefixIfNeeded()
}
private fun trimScannedPrefixIfNeeded() {
if (buffer.length < EPUB_SEARCH_WINDOW_CHARS) return
val scanEndExclusive = (buffer.length - overlapChars).coerceAtLeast(0)
if (scanEndExclusive <= 0) return
scanBuffer(scanEndExclusive)
previousCharBeforeBuffer = buffer[scanEndExclusive - 1]
buffer.delete(0, scanEndExclusive)
}
private fun scanBuffer(scanEndExclusive: Int) {
var searchFrom = 0
while (searchFrom < scanEndExclusive) {
val matchStart = buffer.indexOfIgnoreCase(query, searchFrom, scanEndExclusive)
if (matchStart == -1) break
if (isWordStart(matchStart)) {
addSearchResult(matchStart)
}
searchFrom = matchStart + 1
}
}
private fun isWordStart(matchStart: Int): Boolean {
val previousChar = if (matchStart > 0) {
buffer[matchStart - 1]
} else {
previousCharBeforeBuffer
}
return previousChar == null || !previousChar.isLetterOrDigit()
}
private fun addSearchResult(matchStart: Int) {
val snippetStart = max(0, matchStart - EPUB_SEARCH_SNIPPET_RADIUS)
val snippetEnd = min(buffer.length, matchStart + query.length + EPUB_SEARCH_SNIPPET_RADIUS)
val rawSnippet = buffer.substring(snippetStart, snippetEnd)
val highlightStart = matchStart - snippetStart
val highlightEnd = highlightStart + query.length
val annotatedSnippet = buildAnnotatedString {
append(rawSnippet)
addStyle(
style = SpanStyle(fontWeight = FontWeight.Bold),
start = highlightStart,
end = highlightEnd
)
}
results.add(
SearchResult(
locationInSource = chapterIndex,
locationTitle = chapterTitle,
snippet = annotatedSnippet,
query = query,
occurrenceIndexInLocation = occurrenceIndex,
chunkIndex = chunkIndex
)
)
occurrenceIndex++
}
}
private fun CharSequence.indexOfIgnoreCase(
query: String,
startIndex: Int,
matchStartLimitExclusive: Int
): Int {
if (query.isEmpty()) return -1
val lastStart = min(length - query.length, matchStartLimitExclusive - 1)
if (lastStart < startIndex) return -1
var index = startIndex.coerceAtLeast(0)
while (index <= lastStart) {
var queryIndex = 0
while (
queryIndex < query.length &&
this[index + queryIndex].equals(query[queryIndex], ignoreCase = true)
) {
queryIndex++
}
if (queryIndex == query.length) return index
index++
}
return -1
}
/**
* Handles the navigation to a specific search result.
*/
fun performSearchResultNavigation(
index: Int,
searchState: SearchState,
renderMode: RenderMode,
currentChapterIndex: Int,
loadedChunkCount: Int,
webView: WebView?,
paginator: IPaginator?,
coroutineScope: CoroutineScope,
onVerticalChapterChange: (chapterIndex: Int, chunkIndex: Int, result: SearchResult) -> Unit,
onVerticalScrollToResult: (result: SearchResult) -> Unit,
onPaginatedScrollToPage: suspend (pageIndex: Int) -> Unit
) {
if (index !in searchState.searchResults.indices) return
val result = searchState.searchResults[index]
searchState.currentSearchResultIndex = index
when (renderMode) {
RenderMode.VERTICAL_SCROLL -> {
if (currentChapterIndex != result.locationInSource) {
onVerticalChapterChange(result.locationInSource, result.chunkIndex, result)
} else {
if (result.chunkIndex >= loadedChunkCount) {
onVerticalChapterChange(result.locationInSource, result.chunkIndex, result)
} else {
webView?.let {
val js = "javascript:window.scrollToOccurrence(${result.occurrenceIndexInLocation});"
it.evaluateJavascript(js, null)
}
onVerticalScrollToResult(result)
}
}
}
RenderMode.PAGINATED -> {
paginator?.findPageForSearchResult(result) { pageIndex ->
coroutineScope.launch {
onPaginatedScrollToPage(pageIndex)
}
}
}
}
}
@Composable
fun EpubReaderSearchEffects(
searchState: SearchState,
webViewRef: WebView?,
currentChapterIndex: Int,
focusRequester: FocusRequester
) {
// 1. Auto-Highlight in WebView
LaunchedEffect(searchState.searchResults, currentChapterIndex) {
val query = searchState.searchQuery
if (query.isBlank()) {
webViewRef?.evaluateJavascript("javascript:window.clearSearchHighlights();", null)
return@LaunchedEffect
}
val resultsInCurrentChapter = searchState.searchResults.any { it.locationInSource == currentChapterIndex }
if (resultsInCurrentChapter) {
webViewRef?.let { webView ->
val escapedQuery = escapeJsString(query)
val js = "javascript:window.highlightAllOccurrences('${escapedQuery}');"
Timber.d("Highligting: $js")
webView.evaluateJavascript(js, null)
}
} else {
webViewRef?.evaluateJavascript("javascript:window.clearSearchHighlights();", null)
}
}
// 2. Focus Management
LaunchedEffect(searchState.isSearchActive) {
if (searchState.isSearchActive) {
delay(100)
focusRequester.requestFocus()
} else {
webViewRef?.evaluateJavascript("javascript:window.clearSearchHighlights();", null)
}
}
}
@Composable
fun EpubReaderSearchOverlay(
searchState: SearchState,
onNavigateResult: (Int) -> Unit,
bottomPadding: Dp
) {
val keyboardController = LocalSoftwareKeyboardController.current
androidx.compose.foundation.layout.Box(modifier = Modifier.fillMaxSize()) {
// Search Results Panel
AnimatedVisibility(
visible = searchState.isSearchActive && searchState.showSearchResultsPanel,
enter = slideInVertically { -it } + fadeIn(),
exit = slideOutVertically { -it } + fadeOut(),
) {
SearchResultsPanel(
results = searchState.searchResults,
isSearching = searchState.isSearchInProgress,
onResultClick = { result ->
val resultIndex = searchState.searchResults.indexOf(result)
if (resultIndex != -1) {
onNavigateResult(resultIndex)
}
searchState.showSearchResultsPanel = false
keyboardController?.hide()
},
modifier = Modifier.padding(top = 50.dp)
)
}
AnimatedVisibility(
visible = searchState.isSearchActive && !searchState.showSearchResultsPanel && searchState.hasResults,
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(bottom = bottomPadding + 45.dp + 16.dp, end = 16.dp)
) {
SearchNavigationControls(
searchState = searchState,
onNavigate = { index -> onNavigateResult(index) }
)
}
}
}