Desktop app (#308)

* Implement build profiles and feature policy for offline desktop builds

* Introduce unified cross-platform Settings Hub

* Refactor main settings into a hierarchical page-based navigation model

* Refactor library projection to use shared multiplatform logic

* Refactor UI state consumption by removing intermediate screen models

* Introduce AndroidSharedStateBridge to centralize state mapping and reduction logic

* Refactor state management for tabs, selection, and pinning to use shared bridge logic

* Refactor file type management and validation into a centralized shared module

* Centralize file type resolution and improve handling of unknown types

* Centralize book import logic with SharedImportPlanner

* Refactor magnifier geometry logic and coordinate mapping

* Properly handle orientation changes in scroll-locked PDF reader

* Add screen orientation controls to EPUB and PDF readers

* Implement right-to-left (RTL) pagination support and refactor reader menus

* Separate right-to-left pagination settings for PDF and EPUB

* Ensure PDF page data is scoped by document key for multi tab support

* Implement theme-aware link styling for the epub reader

* Implement jump history for back and forward navigation in the epub reader

* Improve locator handling and navigation logic in paginated reader mode

* Implement stable pagination navigation and location tracking

* Centralize banner message management and auto-dismiss logic in MainViewModel

* Implement zoom and pan state preservation for PDF pan lock mode

* Enhance reader navigation UI and workspace layout management in desktop app

* Refactor reader navigation sidebar and relocate search controls in desktop app

* Enhance reader UI with redesigned selection menus and bottom sheet overlays

* Implement custom highlight palettes and reader theme customization in desktop app

* Implement cross-platform modal layer and refine reader UI styling

* Improve highlight accuracy and implement metadata enrichment on book open in desktop app

* Implement two-page spread layout for paginated reader on desktop

* Implement persistent caching for book loading and pagination in desktop app

* Implement persistent caching for book loading and pagination in desktop app

* Optimize reader settings updates by separating layout and appearance changes in desktop app

* Improve desktop window branding and native Windows styling

* Enhance reader selection interactions and UI across EPUB and PDF viewers in desktop app

* Refine selection handle positioning and interaction logic

* Implement EPUB selection debug logging and improve handle targeting

* Optimize desktop book loading performance and UI responsiveness

* Implement anchored zoom gestures and rendering optimizations for the Desktop PDF viewer.

* Implement smooth zoom preview for the PDF reader in desktop app

* Optimize PDF rendering performance and responsiveness in the desktop reader

* Implement conditional diagnostic logging and update desktop build configuration

* Implemented hierarchical TOC, custom scrollbars, and improved desktop modal handling

* Added management options for annotations and highlights in the sidebar in desktop app

* Implemented `SharedStableOutlinedTextField` and updated text input fields to use `TextFieldValue` for improved cursor and selection stability.

* Refined library filters and enhanced OPDS functionality in desktop app

* Improved EPUB pagination measurement and implemented layout diagnostic logging for desktop app

* Added PPTX support including document parsing, rendering, and indexing

* Improved PPTX rendering and layout accuracy

* Implemented text autofit support for PPTX rendering

* Enhanced PPTX rendering with support for custom geometry, automatic numbering, table styles, and image opacity

* Improved EPUB pagination accuracy and added layout telemetry in desktop app

* Improved folder synchronization with metadata-only mode and hashed sidecar management in desktop app

* Implemented rich text font scaling and migrated desktop ink tools to custom pointer input handling

* Implemented billing account obfuscation

* Implemented hierarchical folder navigation and improved library selection functionality in desktop app

* Implemented platform-aware directory resolution and multi-platform native library support for desktop

* Added full-screen mode for the reader workspace

* Added PDF zoom indicator and interactive vertical scrollbar with page tooltips

* Refactored speech bubble prefetching to use a limited radius and improved ML detector initialization and lifecycle management

* Updated PDF indexing to replace existing page text and removed search result item keys

* Implemented "preparing" foreground notification for TTS service

* Optimized PDF rendering performance by pre-calculating page-specific annotations

* Refactored desktop packaging tasks and improved distribution configuration

* Optimized EPUB parser memory usage and added path traversal protection

* Refactored WorkManager monitoring logic and added work pruning

* Implemented comprehensive resource cleanup and memory management for WebView-based components to prevent memory leaks

* Implemented bitmap size limits and scaling to prevent canvas rendering errors

* Split long text paragraphs into multiple semantic blocks during HTML parsing

* Implemented local ActionMode for text selection to prevent platform crashes

* Refactored PPTX text layout, optimized HtmlParser block detection, and improved banner dismissal logic

* Added desktop startup splash screen and deferred WebView initialization

* Reorganized settings hub and added separate PDF reader defaults

* Implemented embedded cover extraction and metadata support for MOBI and FB2 formats

* Implemented batching for MetadataExtractionWorker and optimized EPUB metadata extraction performance.

* Implemented procedurally generated book covers and replaced static placeholders

* Redesigned search UI with a top bar and results overlay in desktop app

* Added PDF page gap and overlay visibility options and implemented DesktopBookImporter

* Refactored PDF reader UI with tabbed inspector and improved theme background handling in desktop

* Implemented PDF viewport persistence for zoom and scroll positions in desktop app

* Improved desktop fullscreen implementation and state restoration

* Implemented desktop window state persistence

* Implemented flavor-based branding and ProGuard configuration for desktop builds

* Implemented precise reader positioning and improved highlight rendering logic in desktop app

* Added support for user-editable book metadata

* Enhanced book metadata support and integrated info/edit dialogs

* Implemented embedded EPUB metadata editing

* Improved highlight mapping and added custom scrollbar styling for the reader.

* Reduced desktop WebView bundle size by excluding unused locales and runtime files

* Added neutral pan mode as the default PDF interaction state.

* Refactored library empty states and updated primary navigation tabs in desktop app

* Implemented native paginated reader and unified content rendering architecture in desktop epub reader

* Implemented native EPUB image rendering for desktop and improved block layout spacing with margin collapsing.

* Improved pagination overflow detection in desktop

* Implemented multi-block text selection with interactive handles and CFI support in desktop epub pagination
This commit is contained in:
Aryan 2026-05-15 22:36:51 +05:30 committed by GitHub
parent c0d0e57e79
commit b20ade9946
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
247 changed files with 43321 additions and 7087 deletions

View file

@ -59,6 +59,8 @@ import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.coroutines.yield
import kotlinx.serialization.ExperimentalSerializationApi
@ -76,6 +78,7 @@ private const val PRIORITY_HIGHEST = 0
private const val PRIORITY_HIGH = 1
private const val PRIORITY_MEDIUM = 2
private const val PRIORITY_LOW = 3
private const val TAG_STABLE_PAGE_NAV = "StablePageNav"
data class TimedWord(val word: String, val startTime: Double, val startOffset: Int)
@ -187,6 +190,7 @@ class BookPaginator(
private val paginationQueue = PriorityBlockingQueue<PaginationRequest>()
private val chaptersBeingProcessed = ConcurrentHashMap.newKeySet<Int>()
private val chapterPaginationLocks = ConcurrentHashMap<Int, Mutex>()
private val navigationCallbacks = ConcurrentHashMap<Int, MutableList<(List<Page>) -> Unit>>()
private var paginationWorker: Job? = null
@ -430,9 +434,9 @@ class BookPaginator(
Timber.w("Page cache count mismatch for chapter $chapterIndex. Ignoring cached pages.")
null
} else {
pageCache.put(chapterIndex, pages)
applyPageRuntimeIndexes(chapterIndex, pages)
updatePageCountsOnMain(chapterIndex, pages.size)
pageCache.put(chapterIndex, pages)
Timber.i("Page cache HIT for chapter $chapterIndex. Loaded ${pages.size} measured pages.")
pages
}
@ -574,8 +578,86 @@ class BookPaginator(
}
}
private suspend fun ensureChapterPaginated(chapterIndex: Int): List<Page>? {
if (chapterIndex !in chapters.indices) {
Timber.w("ensureChapterPaginated: Ignoring invalid chapter index $chapterIndex.")
return null
}
pageCache[chapterIndex]?.let {
Timber.tag(TAG_STABLE_PAGE_NAV)
.d("ensure_chapter hit_memory chapter=$chapterIndex pages=${it.size}")
return it
}
val lock = chapterPaginationLocks.computeIfAbsent(chapterIndex) { Mutex() }
return lock.withLock {
pageCache[chapterIndex]?.also {
Timber.tag(TAG_STABLE_PAGE_NAV)
.d("ensure_chapter hit_after_wait chapter=$chapterIndex pages=${it.size}")
} ?: run {
Timber.tag(TAG_STABLE_PAGE_NAV)
.d("ensure_chapter paginate chapter=$chapterIndex finalized=${chapterIndex in finalizedChapterCounts}")
paginateChapter(chapterIndex)
}
}
}
private suspend fun ensureStableStartPageForChapter(chapterIndex: Int): Int? {
Timber.tag(TAG_STABLE_PAGE_NAV).d(
"stable_start request chapter=$chapterIndex countsAccurate=$pageCountsAreAccurate finalized=${chapterIndex in finalizedChapterCounts}"
)
return resolveStableChapterStartPage(
chapterIndex = chapterIndex,
chapterCount = chapters.size,
pageCountsAreAccurate = pageCountsAreAccurate,
chapterStartPage = { chapterStartPageIndices[it] },
isChapterFinalized = { it in finalizedChapterCounts },
ensureChapterPaginated = { ensureChapterPaginated(it) != null }
)
}
suspend fun findStableChapterStartPage(chapterIndex: Int): Int? = withContext(Dispatchers.IO) {
val stableStart = ensureStableStartPageForChapter(chapterIndex) ?: return@withContext null
val targetPages = ensureChapterPaginated(chapterIndex) ?: return@withContext null
Timber.tag(TAG_STABLE_PAGE_NAV).d(
"stable_chapter_start resolved chapter=$chapterIndex page=$stableStart targetPages=${targetPages.size}"
)
stableStart.takeIf { targetPages.isNotEmpty() }
}
suspend fun findStablePageForLocator(locator: Locator): Int? = withContext(Dispatchers.IO) {
val targetChapterIndex = locator.chapterIndex
Timber.tag("POS_DIAG").d("findStablePageForLocator: Searching for $locator")
Timber.tag(TAG_STABLE_PAGE_NAV).d("stable_locator request locator=$locator")
val chapterPages = ensureChapterPaginated(targetChapterIndex)
val chapterStartPage = ensureStableStartPageForChapter(targetChapterIndex)
Timber.tag("POS_DIAG").d(
"findStablePageForLocator: targetChapterIndex=$targetChapterIndex, stableStart=$chapterStartPage, chapterPages.size=${chapterPages?.size}"
)
if (chapterPages.isNullOrEmpty() || chapterStartPage == null) {
Timber.e("Stable locator navigation failed: Could not stabilize target chapter $targetChapterIndex.")
return@withContext null
}
val pageInChapter = findPageInChapterForLocator(locator, chapterPages) ?: run {
Timber.tag("POS_DIAG").e("findStablePageForLocator: FAILED to resolve locator in chapter $targetChapterIndex")
return@withContext null
}
val finalPageIndex = chapterStartPage + pageInChapter
Timber.tag("POS_DIAG").i("findStablePageForLocator: FOUND absolute page $finalPageIndex")
Timber.tag(TAG_STABLE_PAGE_NAV).d(
"stable_locator resolved locator=$locator page=$finalPageIndex chapterStart=$chapterStartPage pageInChapter=$pageInChapter"
)
finalPageIndex
}
suspend fun getTtsChunksForChapter(chapterIndex: Int, startingFromPageInChapter: Int = 0): List<TtsChunk>? {
val pages = pageCache[chapterIndex] ?: paginateChapter(chapterIndex)
val pages = ensureChapterPaginated(chapterIndex)
if (pages.isNullOrEmpty()) {
Timber.w("PAGINATOR: Chapter $chapterIndex has no pages or could not be paginated.")
return null
@ -794,7 +876,7 @@ class BookPaginator(
}
Timber.i("Worker: Starting pagination for chapter $chapterIndex.")
val pages = paginateChapter(chapterIndex)
val pages = ensureChapterPaginated(chapterIndex)
if (pages != null) {
Timber.i("Worker: Successfully finished pagination for chapter $chapterIndex.")
@ -836,6 +918,9 @@ class BookPaginator(
val difference = actualPageCount - estimatedPageCount
if (difference == 0) {
Timber.tag(TAG_STABLE_PAGE_NAV).d(
"page_count_noop chapter=$chapterIndex count=$actualPageCount currentUserChapter=${currentUserChapterIndex.value}"
)
if (!pageCountsAreAccurate && finalizedChapterCounts.add(chapterIndex)) {
coroutineScope.launch(Dispatchers.IO) { updateAndSaveConfigurationCache() }
}
@ -854,8 +939,16 @@ class BookPaginator(
}
rebuildChapterStartSnapshot()
if (chapterIndex < currentUserChapterIndex.value) {
pageShiftRequest.tryEmit(difference)
val currentUserChapter = currentUserChapterIndex.value
val shouldShiftCurrentPage = chapterIndex < currentUserChapter
Timber.tag(TAG_STABLE_PAGE_NAV).d(
"page_count_update chapter=$chapterIndex estimated=$estimatedPageCount actual=$actualPageCount diff=$difference currentUserChapter=$currentUserChapter shiftCurrent=$shouldShiftCurrentPage total=$totalPageCount"
)
if (shouldShiftCurrentPage) {
val emitted = pageShiftRequest.tryEmit(difference)
Timber.tag(TAG_STABLE_PAGE_NAV).d(
"page_shift_emit chapter=$chapterIndex diff=$difference currentUserChapter=$currentUserChapter emitted=$emitted"
)
}
}
@ -1028,13 +1121,12 @@ class BookPaginator(
)
Timber.d("paginateChapter: PaginatorLogic returned ${pages.size} pages for chapter $chapterIndex.")
pageCache.put(chapterIndex, pages)
Timber.d("paginateChapter: Chapter $chapterIndex pages stored in L1 pageCache.")
applyPageRuntimeIndexes(chapterIndex, pages)
savePageCacheAsync(chapter, chapterIndex, pages)
updatePageCountsOnMain(chapterIndex, pages.size)
pageCache.put(chapterIndex, pages)
Timber.d("paginateChapter: Chapter $chapterIndex pages stored in L1 pageCache.")
return pages
}
@ -1105,12 +1197,68 @@ class BookPaginator(
return Jsoup.parse(htmlToParse).body().text()
}
private fun calculateAccurateStartIndex(targetChapterIndex: Int): Int {
if (targetChapterIndex <= 0) {
return 0
suspend fun findStablePageForAnchor(chapterIndex: Int, anchor: String?): Int? = withContext(Dispatchers.IO) {
Timber.tag("TOC_NAV_DEBUG").d("Stable precision nav request for anchor: '$anchor'")
if (anchor.isNullOrBlank()) {
return@withContext findStableChapterStartPage(chapterIndex)
}
val startIndex = chapterStartPageIndices[targetChapterIndex] ?: 0
return startIndex
// 1. QUICK LOOKUP: Check the Anchor Index first
val indexEntry = bookCacheDao.getAnchorIndex(bookId, anchor)
val (targetChapter, targetBlock) = if (indexEntry != null) {
Timber.tag("TOC_NAV_DEBUG").i("Index HIT: Anchor '$anchor' is in Chapter ${indexEntry.chapterIndex}, Block ${indexEntry.blockIndex}")
indexEntry.chapterIndex to indexEntry.blockIndex
} else {
Timber.tag("TOC_NAV_DEBUG").w("Index MISS: Falling back to linear scan for '$anchor' in Chapter $chapterIndex")
chapterIndex to null
}
// 2. ENSURE PAGINATION: Get pages for the determined chapter
val chapterPages = ensureChapterPaginated(targetChapter)
val chapterStartPage = ensureStableStartPageForChapter(targetChapter)
if (chapterPages == null || chapterStartPage == null) {
Timber.e("Anchor navigation failed: Could not stabilize target chapter $targetChapter.")
return@withContext null
}
val indexedPageInChapter = targetBlock?.let { blockIndex ->
chapterPageNavigationIndex[targetChapter]
?.firstOrNull { blockIndex in it.firstBlockIndex..it.lastBlockIndex }
?.pageInChapter
} ?: chapterAnchorPageIndex[targetChapter]?.get(anchor)
if (indexedPageInChapter != null) {
val finalPage = chapterStartPage + indexedPageInChapter
Timber.tag("TOC_NAV_DEBUG").d("Navigation resolved from page index to Absolute Page: $finalPage")
return@withContext finalPage
}
// 3. FIND PAGE
var targetPageInChapter = 0
var found = false
for ((pageIndex, page) in chapterPages.withIndex()) {
val isMatch = if (targetBlock != null) {
// Fast path: We know exactly which block we are looking for
page.content.any { it.blockIndex == targetBlock }
} else {
// Slow path: Linear ID scan (fallback)
page.content.any { containsAnchor(it, anchor) }
}
if (isMatch) {
targetPageInChapter = pageIndex
found = true
break
}
}
val finalPage = chapterStartPage + targetPageInChapter
Timber.tag("TOC_NAV_DEBUG").d("Navigation resolved to Absolute Page: $finalPage (Found: $found)")
finalPage
}
override fun findPageForAnchor(
@ -1119,70 +1267,8 @@ class BookPaginator(
onResult: (pageIndex: Int) -> Unit
) {
coroutineScope.launch(Dispatchers.IO) {
Timber.tag("TOC_NAV_DEBUG").d("Precision nav request for anchor: '$anchor'")
if (anchor.isNullOrBlank()) {
val start = chapterStartPageIndices[chapterIndex] ?: 0
withContext(Dispatchers.Main) { onResult(start) }
return@launch
}
// 1. QUICK LOOKUP: Check the Anchor Index first
val indexEntry = bookCacheDao.getAnchorIndex(bookId, anchor)
val (targetChapter, targetBlock) = if (indexEntry != null) {
Timber.tag("TOC_NAV_DEBUG").i("Index HIT: Anchor '$anchor' is in Chapter ${indexEntry.chapterIndex}, Block ${indexEntry.blockIndex}")
indexEntry.chapterIndex to indexEntry.blockIndex
} else {
Timber.tag("TOC_NAV_DEBUG").w("Index MISS: Falling back to linear scan for '$anchor' in Chapter $chapterIndex")
chapterIndex to null
}
// 2. ENSURE PAGINATION: Get pages for the determined chapter
val chapterPages = pageCache[targetChapter] ?: paginateChapter(targetChapter)
val chapterStartPage = chapterStartPageIndices[targetChapter] ?: 0
if (chapterPages == null) {
withContext(Dispatchers.Main) { onResult(chapterStartPage) }
return@launch
}
val indexedPageInChapter = targetBlock?.let { blockIndex ->
chapterPageNavigationIndex[targetChapter]
?.firstOrNull { blockIndex in it.firstBlockIndex..it.lastBlockIndex }
?.pageInChapter
} ?: chapterAnchorPageIndex[targetChapter]?.get(anchor)
if (indexedPageInChapter != null) {
val finalPage = chapterStartPage + indexedPageInChapter
Timber.tag("TOC_NAV_DEBUG").d("Navigation resolved from page index to Absolute Page: $finalPage")
withContext(Dispatchers.Main) { onResult(finalPage) }
return@launch
}
// 3. FIND PAGE
var targetPageInChapter = 0
var found = false
for ((pageIndex, page) in chapterPages.withIndex()) {
val isMatch = if (targetBlock != null) {
// Fast path: We know exactly which block we are looking for
page.content.any { it.blockIndex == targetBlock }
} else {
// Slow path: Linear ID scan (fallback)
page.content.any { containsAnchor(it, anchor) }
}
if (isMatch) {
targetPageInChapter = pageIndex
found = true
break
}
}
val finalPage = chapterStartPage + targetPageInChapter
Timber.tag("TOC_NAV_DEBUG").d("Navigation resolved to Absolute Page: $finalPage (Found: $found)")
withContext(Dispatchers.Main) { onResult(finalPage) }
val page = findStablePageForAnchor(chapterIndex, anchor) ?: return@launch
withContext(Dispatchers.Main) { onResult(page) }
}
}
@ -1234,69 +1320,79 @@ class BookPaginator(
onNavigationComplete: (pageIndex: Int) -> Unit
) {
coroutineScope.launch(Dispatchers.IO) {
Timber.i("Navigating to href: '$href' from chapter: '$currentChapterAbsPath'")
val (targetChapterPath, anchor) = resolveHref(currentChapterAbsPath, href)
if (targetChapterPath == null) {
Timber.w("Could not resolve href '$href' to a valid chapter path.")
return@launch
}
val targetChapterIndex = chapters.indexOfFirst { it.absPath == targetChapterPath }
if (targetChapterIndex == -1) {
Timber.w("Could not find chapter for path: $targetChapterPath")
return@launch
}
findPageForAnchor(targetChapterIndex, anchor, onNavigationComplete)
val targetPage = findStablePageForHref(currentChapterAbsPath, href) ?: return@launch
withContext(Dispatchers.Main) { onNavigationComplete(targetPage) }
}
}
suspend fun findStablePageForHref(currentChapterAbsPath: String, href: String): Int? = withContext(Dispatchers.IO) {
Timber.i("Navigating to href: '$href' from chapter: '$currentChapterAbsPath'")
val (targetChapterPath, anchor) = resolveHref(currentChapterAbsPath, href)
if (targetChapterPath == null) {
Timber.w("Could not resolve href '$href' to a valid chapter path.")
return@withContext null
}
val targetChapterIndex = chapters.indexOfFirst { it.absPath == targetChapterPath }
if (targetChapterIndex == -1) {
Timber.w("Could not find chapter for path: $targetChapterPath")
return@withContext null
}
findStablePageForAnchor(targetChapterIndex, anchor)
}
suspend fun findStablePageForSearchResult(result: SearchResult): Int? = withContext(Dispatchers.IO) {
val targetChapterIndex = result.locationInSource
Timber.i("Finding page for search result: '${result.query}' in chapter $targetChapterIndex")
val chapterPages = ensureChapterPaginated(targetChapterIndex)
val chapterStartPage = ensureStableStartPageForChapter(targetChapterIndex)
if (chapterPages == null || chapterStartPage == null) {
Timber.e("Search result navigation failed: Could not stabilize target chapter $targetChapterIndex.")
return@withContext null
}
var targetPageInChapter = 0
var occurrenceCount = 0
pageLoop@ for ((pageIndex, page) in chapterPages.withIndex()) {
for (block in page.content) {
val textToSearch = when (block) {
is ParagraphBlock -> block.content.text
is HeaderBlock -> block.content.text
is QuoteBlock -> block.content.text
is ListItemBlock -> block.content.text
else -> null
}
if (textToSearch != null) {
var lastIndex = -1
while (true) {
lastIndex = textToSearch.indexOf(result.query, startIndex = lastIndex + 1, ignoreCase = true)
if (lastIndex == -1) break
if (occurrenceCount == result.occurrenceIndexInLocation) {
targetPageInChapter = pageIndex
Timber.i("Found search result '${result.query}' at occurrence ${result.occurrenceIndexInLocation} on page $pageIndex of chapter $targetChapterIndex")
break@pageLoop
}
occurrenceCount++
}
}
}
}
val finalPageIndex = chapterStartPage + targetPageInChapter
Timber.i("Search result found. Final page index: $finalPageIndex")
finalPageIndex
}
override fun findPageForSearchResult(result: SearchResult, onResult: (pageIndex: Int) -> Unit) {
coroutineScope.launch(Dispatchers.IO) {
val targetChapterIndex = result.locationInSource
Timber.i("Finding page for search result: '${result.query}' in chapter $targetChapterIndex")
val chapterPages = pageCache[targetChapterIndex] ?: paginateChapter(targetChapterIndex)
val chapterStartPage = calculateAccurateStartIndex(targetChapterIndex)
if (chapterPages == null) {
Timber.e("Search result navigation failed: Could not paginate target chapter $targetChapterIndex.")
return@launch
}
var targetPageInChapter = 0
var occurrenceCount = 0
pageLoop@ for ((pageIndex, page) in chapterPages.withIndex()) {
for (block in page.content) {
val textToSearch = when (block) {
is ParagraphBlock -> block.content.text
is HeaderBlock -> block.content.text
is QuoteBlock -> block.content.text
is ListItemBlock -> block.content.text
else -> null
}
if (textToSearch != null) {
var lastIndex = -1
while (true) {
lastIndex = textToSearch.indexOf(result.query, startIndex = lastIndex + 1, ignoreCase = true)
if (lastIndex == -1) break
if (occurrenceCount == result.occurrenceIndexInLocation) {
targetPageInChapter = pageIndex
Timber.i("Found search result '${result.query}' at occurrence ${result.occurrenceIndexInLocation} on page $pageIndex of chapter $targetChapterIndex")
break@pageLoop
}
occurrenceCount++
}
}
}
}
val finalPageIndex = chapterStartPage + targetPageInChapter
Timber.i("Search result found. Final page index: $finalPageIndex")
withContext(Dispatchers.Main) { onResult(finalPageIndex) }
val page = findStablePageForSearchResult(result) ?: return@launch
withContext(Dispatchers.Main) { onResult(page) }
}
}
@ -1331,20 +1427,8 @@ class BookPaginator(
}
}
suspend fun findPageForLocator(locator: Locator): Int? {
private fun findPageInChapterForLocator(locator: Locator, chapterPages: List<Page>): Int? {
val targetChapterIndex = locator.chapterIndex
Timber.tag("POS_DIAG").d("findPageForLocator: Searching for $locator")
val chapterPages = pageCache[targetChapterIndex] ?: paginateChapter(targetChapterIndex)
val chapterStartPage = chapterStartPageIndices[targetChapterIndex] ?: 0
Timber.tag("POS_DIAG").d("findPageForLocator: targetChapterIndex=$targetChapterIndex, chapterStartPage=$chapterStartPage, chapterPages.size=${chapterPages?.size}")
if (chapterPages.isNullOrEmpty()) {
Timber.e("Locator navigation failed: Could not paginate target chapter $targetChapterIndex.")
return null
}
chapterTextRangeIndex[targetChapterIndex]
?.firstOrNull { range ->
range.blockIndex == locator.blockIndex &&
@ -1352,17 +1436,15 @@ class BookPaginator(
(range.startOffset == range.endOffset && locator.charOffset == range.startOffset))
}
?.let { range ->
val finalPageIndex = chapterStartPage + range.pageInChapter
Timber.tag("POS_DIAG").i("findPageForLocator: FOUND via runtime index on absolute page $finalPageIndex")
return finalPageIndex
Timber.tag("POS_DIAG").i("findPageInChapterForLocator: FOUND via runtime index on pageInChapter ${range.pageInChapter}")
return range.pageInChapter
}
chapterPageNavigationIndex[targetChapterIndex]
?.firstOrNull { locator.blockIndex in it.firstBlockIndex..it.lastBlockIndex }
?.let { entry ->
val finalPageIndex = chapterStartPage + entry.pageInChapter
Timber.tag("POS_DIAG").w("findPageForLocator: Using block-range fallback page $finalPageIndex")
return finalPageIndex
Timber.tag("POS_DIAG").w("findPageInChapterForLocator: Using block-range fallback pageInChapter ${entry.pageInChapter}")
return entry.pageInChapter
}
var fallbackPageInChapter = -1
@ -1370,7 +1452,7 @@ class BookPaginator(
for ((pageIndex, page) in chapterPages.withIndex()) {
val allTextBlocks = getAllTextBlocks(page.content)
if (allTextBlocks.any { it.blockIndex == locator.blockIndex }) {
Timber.tag("POS_DIAG").d("findPageForLocator: Found target blockIndex ${locator.blockIndex} on PageInChapter $pageIndex (Abs ${chapterStartPage + pageIndex})")
Timber.tag("POS_DIAG").d("findPageInChapterForLocator: Found target blockIndex ${locator.blockIndex} on PageInChapter $pageIndex")
}
for (textBlock in allTextBlocks) {
if (textBlock.blockIndex == locator.blockIndex) {
@ -1384,15 +1466,13 @@ class BookPaginator(
val isInside = locator.charOffset in startOffsetOnPage..<endOffsetOnPage
if (isInside) {
val finalPageIndex = chapterStartPage + pageIndex
Timber.tag("POS_DIAG").i("findPageForLocator: FOUND match on absolute page $finalPageIndex")
return finalPageIndex
Timber.tag("POS_DIAG").i("findPageInChapterForLocator: FOUND match on pageInChapter $pageIndex")
return pageIndex
}
if (textBlock.content.isEmpty() && locator.charOffset == startOffsetOnPage) {
val finalPageIndex = chapterStartPage + pageIndex
Timber.tag("POS_DIAG").i("findPageForLocator: FOUND empty block match on absolute page $finalPageIndex")
return finalPageIndex
Timber.tag("POS_DIAG").i("findPageInChapterForLocator: FOUND empty block match on pageInChapter $pageIndex")
return pageIndex
}
}
}
@ -1408,15 +1488,37 @@ class BookPaginator(
}
if (fallbackPageInChapter != -1) {
val finalPageIndex = chapterStartPage + fallbackPageInChapter
Timber.tag("POS_DIAG").w("findPageForLocator: Exact offset not found, using block-start fallback page $finalPageIndex")
return finalPageIndex
Timber.tag("POS_DIAG").w("findPageInChapterForLocator: Exact offset not found, using block-start fallback pageInChapter $fallbackPageInChapter")
return fallbackPageInChapter
}
Timber.tag("POS_DIAG").e("findPageForLocator: FAILED to resolve locator in chapter $targetChapterIndex")
return null
}
suspend fun findPageForLocator(locator: Locator): Int? {
val targetChapterIndex = locator.chapterIndex
Timber.tag("POS_DIAG").d("findPageForLocator: Searching for $locator")
val chapterPages = ensureChapterPaginated(targetChapterIndex)
val chapterStartPage = chapterStartPageIndices[targetChapterIndex] ?: 0
Timber.tag("POS_DIAG").d("findPageForLocator: targetChapterIndex=$targetChapterIndex, chapterStartPage=$chapterStartPage, chapterPages.size=${chapterPages?.size}")
if (chapterPages.isNullOrEmpty()) {
Timber.e("Locator navigation failed: Could not paginate target chapter $targetChapterIndex.")
return null
}
val pageInChapter = findPageInChapterForLocator(locator, chapterPages) ?: run {
Timber.tag("POS_DIAG").e("findPageForLocator: FAILED to resolve locator in chapter $targetChapterIndex")
return null
}
val finalPageIndex = chapterStartPage + pageInChapter
Timber.tag("POS_DIAG").i("findPageForLocator: FOUND absolute page $finalPageIndex")
return finalPageIndex
}
fun getLocatorForPage(pageIndex: Int): Locator? {
val chapterIndex = findChapterIndexForPage(pageIndex) ?: return null
val chStart = chapterStartPageIndices[chapterIndex] ?: 0
@ -1472,13 +1574,12 @@ class BookPaginator(
coroutineScope.launch(Dispatchers.IO) {
Timber.i("findPageForCfi: Starting search for CFI: '$cfi' in chapter: '$chapterIndex'")
val chapterPages = pageCache[chapterIndex] ?: paginateChapter(chapterIndex)
val chapterStartPage = calculateAccurateStartIndex(chapterIndex)
val chapterPages = ensureChapterPaginated(chapterIndex)
val chapterStartPage = ensureStableStartPageForChapter(chapterIndex)
Timber.d("findPageForCfi: Chapter $chapterIndex starts at absolute page $chapterStartPage.")
if (chapterPages == null) {
Timber.e("CFI Navigation failed: Could not paginate target chapter $chapterIndex.")
withContext(Dispatchers.Main) { onResult(chapterStartPage) }
if (chapterPages == null || chapterStartPage == null) {
Timber.e("CFI Navigation failed: Could not stabilize target chapter $chapterIndex.")
return@launch
}
Timber.d("findPageForCfi: Chapter $chapterIndex has ${chapterPages.size} pages.")

View file

@ -433,6 +433,14 @@ class ContentStyler(
baselineShift = baselineShift
)
if (!span.linkHref.isNullOrBlank()) {
finalSpanStyle = finalSpanStyle.withReaderLinkStyle(
isDarkTheme = isDarkTheme,
themeBackgroundColor = themeBackgroundColor,
themeTextColor = themeTextColor
)
}
val hasCustomDeco = themedSpanStyle.textDecorationStyle != null ||
themedSpanStyle.textDecorationColor.isSpecified ||
themedSpanStyle.textUnderlineOffset.isSpecified

View file

@ -29,7 +29,6 @@ import android.webkit.JavascriptInterface
import android.webkit.WebChromeClient
import android.webkit.WebView
import android.webkit.WebViewClient
import com.aryan.reader.BuildConfig
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withTimeoutOrNull
@ -44,6 +43,8 @@ class MathMLRenderer(private val context: Context) {
private var webView: WebView? = null
private val handler = Handler(Looper.getMainLooper())
@Volatile
private var isDestroyed = false
private var isMathJaxReady = false
private val readySignal = CompletableDeferred<Boolean>()
@ -59,11 +60,14 @@ class MathMLRenderer(private val context: Context) {
init {
handler.post {
setupWebView()
if (!isDestroyed) {
setupWebView()
}
}
}
suspend fun awaitReady(): Boolean {
if (isDestroyed) return false
Timber.d("awaitReady: Waiting for WebView and MathJax initialization...")
return withTimeoutOrNull(10_000) {
readySignal.await()
@ -76,9 +80,7 @@ class MathMLRenderer(private val context: Context) {
private fun setupWebView() {
try {
if (BuildConfig.DEBUG) {
WebView.setWebContentsDebuggingEnabled(true)
}
if (isDestroyed) return
webView = WebView(context).apply {
@SuppressLint("SetJavaScriptEnabled")
@ -114,6 +116,9 @@ class MathMLRenderer(private val context: Context) {
}
suspend fun render(mathML: String, originalAltText: String): RenderResult {
if (isDestroyed) {
return RenderResult.Failure(originalAltText)
}
if (!awaitReady()) {
Timber.e("WebView is not available or failed to initialize. Failing render.")
return RenderResult.Failure(originalAltText)
@ -140,6 +145,10 @@ class MathMLRenderer(private val context: Context) {
}
private fun processNextJob() {
if (isDestroyed) {
isProcessing = false
return
}
synchronized(jobQueue) {
if (jobQueue.isEmpty()) {
isProcessing = false
@ -153,6 +162,10 @@ class MathMLRenderer(private val context: Context) {
}
private fun executeRender() {
if (isDestroyed) {
isProcessing = false
return
}
if (!isMathJaxReady) {
Timber.d("executeRender called but MathJax not ready yet. Retrying...")
handler.postDelayed({ executeRender() }, 100)
@ -208,14 +221,43 @@ class MathMLRenderer(private val context: Context) {
}
fun destroy() {
isDestroyed = true
if (!readySignal.isCompleted) {
readySignal.complete(false)
}
val pendingJobs = synchronized(jobQueue) {
val copy = jobQueue.toList()
jobQueue.clear()
isProcessing = false
copy
}
pendingJobs.forEach { job ->
job.continuation(RenderResult.Failure(extractAltText(job.mathML)))
}
handler.removeCallbacksAndMessages(null)
handler.post {
webView?.destroy()
webView?.releaseMathRendererResources()
webView = null
Timber.d("MathMLRenderer WebView destroyed.")
}
synchronized(jobQueue) {
jobQueue.clear()
isProcessing = false
}
private fun extractAltText(mathML: String): String =
mathML.substringAfter("alttext=\"", "").substringBefore("\"")
.ifBlank { "MathML rendering failed" }
private fun WebView.releaseMathRendererResources() {
try {
stopLoading()
removeJavascriptInterface("AndroidBridge")
webChromeClient = null
webViewClient = WebViewClient()
loadDataWithBaseURL(null, "", "text/html", "UTF-8", null)
clearHistory()
removeAllViews()
destroy()
} catch (e: Exception) {
Timber.w(e, "Failed to fully release MathML WebView resources")
}
}
@ -229,7 +271,7 @@ class MathMLRenderer(private val context: Context) {
} else {
Timber.e("onSvgReady FAILURE. Received empty SVG.")
val job = synchronized(jobQueue) { jobQueue.firstOrNull() }
val altText = job?.mathML?.substringAfter("alttext=\"", "")?.substringBefore("\"") ?: "MathML rendering failed"
val altText = job?.mathML?.let(::extractAltText) ?: "MathML rendering failed"
completeCurrentJob(RenderResult.Failure(altText))
}
}
@ -244,4 +286,4 @@ class MathMLRenderer(private val context: Context) {
}
}
}
}
}

View file

@ -56,6 +56,7 @@ import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@ -253,6 +254,8 @@ private fun headerFontScale(level: Int): Float = when (level) {
}
private const val WEB_VIEW_NORMAL_LINE_HEIGHT_MULTIPLIER = 1.2f
private const val TAG_STABLE_PAGE_NAV = "StablePageNav"
private const val EXPLICIT_NAVIGATION_SHIFT_ANCHOR_WINDOW_MS = 10_000L
private fun paginationLineHeightMultiplierForWebViewSetting(multiplier: Float): Float {
return if (abs(multiplier - 1.0f) < 0.001f) WEB_VIEW_NORMAL_LINE_HEIGHT_MULTIPLIER else multiplier
@ -410,6 +413,30 @@ internal object CfiUtils {
fun getPath(cfi: String): String = cfi.split(':').first()
fun getOffset(cfi: String): Int = cfi.substringAfter(':', "0").toIntOrNull() ?: 0
fun getOffsetOrNull(cfi: String): Int? = cfi.substringAfter(':', "").toIntOrNull()
fun isPathStrictlyBetween(candidate: String, start: String, end: String): Boolean {
val candidateParts = pathParts(candidate) ?: return false
val startParts = pathParts(start) ?: return false
val endParts = pathParts(end) ?: return false
return comparePathParts(candidateParts, startParts) > 0 &&
comparePathParts(candidateParts, endParts) < 0
}
private fun pathParts(cfi: String): List<Int>? {
val segments = getPath(cfi).split('/').filter { it.isNotEmpty() }
if (segments.isEmpty()) return null
return segments.map { it.toIntOrNull() ?: return null }
}
private fun comparePathParts(first: List<Int>, second: List<Int>): Int {
val length = minOf(first.size, second.size)
for (index in 0 until length) {
val cmp = first[index].compareTo(second[index])
if (cmp != 0) return cmp
}
return first.size.compareTo(second.size)
}
}
private fun highlightQueryInText(
@ -729,6 +756,7 @@ fun PaginatedReaderScreen(
effectiveText: Color,
pagerState: PagerState,
isPageTurnAnimationEnabled: Boolean,
isRightToLeftPagination: Boolean = false,
searchQuery: String,
fontSizeMultiplier: Float,
lineHeightMultiplier: Float,
@ -741,6 +769,9 @@ fun PaginatedReaderScreen(
ttsHighlightInfo: TtsHighlightInfo?,
initialChapterIndexInBook: Int?,
fallbackLocatorForReconfiguration: Locator? = null,
explicitNavigationAnchor: Locator? = null,
explicitNavigationEpoch: Long = 0L,
isExternalNavigationInProgress: Boolean = false,
onReconfigurationAnchorCaptured: (Locator) -> Unit = {},
onReconfigurationRestoreActiveChanged: (Boolean) -> Unit = {},
onPaginatorReady: (IPaginator) -> Unit,
@ -754,6 +785,7 @@ fun PaginatedReaderScreen(
onStartTtsFromSelection: (String, Int) -> Unit,
onNoteRequested: (String?) -> Unit,
onFootnoteRequested: (String) -> Unit,
onInternalLinkNavigated: (Int) -> Unit = {},
userHighlights: List<UserHighlight>,
onHighlightCreated: (String, String, String) -> Unit,
onHighlightDeleted: (String) -> Unit,
@ -784,6 +816,11 @@ fun PaginatedReaderScreen(
} else Modifier
var isNavigatingByLink by remember { mutableStateOf(false) }
var localExplicitNavigationAnchor by remember { mutableStateOf<Locator?>(null) }
var localExplicitNavigationEpoch by remember { mutableLongStateOf(0L) }
val latestExternalNavigationAnchor by rememberUpdatedState(explicitNavigationAnchor)
val latestExternalNavigationEpoch by rememberUpdatedState(explicitNavigationEpoch)
val latestIsExternalNavigationInProgress by rememberUpdatedState(isExternalNavigationInProgress)
BoxWithConstraints(modifier = modifier.fillMaxSize().background(effectiveBg)) {
val textMeasurer = rememberTextMeasurer()
@ -1107,19 +1144,96 @@ fun PaginatedReaderScreen(
LaunchedEffect(paginator, pagerState) {
paginator.pageShiftRequest.collect { shiftAmount ->
val anchor = resolvePaginatedReconfigurationAnchor(
currentPageLocator = anchorLocatorForReconfig,
fallbackLocator = latestFallbackLocatorForReconfiguration
if (pagerState.pageCount <= 0) {
Timber.tag(TAG_STABLE_PAGE_NAV)
.w("shift_drop reason=emptyPager shift=$shiftAmount")
return@collect
}
val bookPaginator = paginator as? BookPaginator
val currentPageBeforeShift = pagerState.currentPage
val now = System.currentTimeMillis()
val externalAgeMs = if (latestExternalNavigationEpoch > 0L) {
now - latestExternalNavigationEpoch
} else {
-1L
}
val localAgeMs = if (localExplicitNavigationEpoch > 0L) {
now - localExplicitNavigationEpoch
} else {
-1L
}
val recentExternalNavigation =
externalAgeMs in 0L..EXPLICIT_NAVIGATION_SHIFT_ANCHOR_WINDOW_MS
val recentLocalNavigation =
localAgeMs in 0L..EXPLICIT_NAVIGATION_SHIFT_ANCHOR_WINDOW_MS
val activeExplicitAnchor = when {
latestIsExternalNavigationInProgress -> latestExternalNavigationAnchor
isNavigatingByLink -> localExplicitNavigationAnchor
else -> null
}
val recentExplicitAnchor = when {
recentExternalNavigation -> latestExternalNavigationAnchor
recentLocalNavigation -> localExplicitNavigationAnchor
else -> null
}
val activeExplicitAnchorSource = when {
activeExplicitAnchor == null -> null
latestIsExternalNavigationInProgress -> "explicit_external_active"
else -> "explicit_link"
}
val recentExplicitAnchorSource = when {
recentExplicitAnchor == null -> null
recentExternalNavigation -> "explicit_external_recent"
else -> "explicit_link_recent"
}
val currentPageLocator = bookPaginator?.getLocatorForPage(currentPageBeforeShift)
val fallbackLocator = latestFallbackLocatorForReconfiguration
var anchorSource = "none"
val anchor = when {
anchorLocatorForReconfig != null -> {
anchorSource = "reconfiguration"
anchorLocatorForReconfig
}
activeExplicitAnchor != null -> {
anchorSource = activeExplicitAnchorSource ?: "explicit_active"
activeExplicitAnchor
}
fallbackLocator != null -> {
anchorSource = "last_known"
fallbackLocator
}
recentExplicitAnchor != null -> {
anchorSource = recentExplicitAnchorSource ?: "explicit_recent"
recentExplicitAnchor
}
currentPageLocator != null -> {
anchorSource = "current_page"
currentPageLocator
}
else -> null
}
Timber.tag(TAG_STABLE_PAGE_NAV).d(
"shift_received shift=$shiftAmount currentPage=$currentPageBeforeShift anchorSource=$anchorSource anchor=$anchor currentLocator=$currentPageLocator fallback=$fallbackLocator externalInProgress=$latestIsExternalNavigationInProgress linkInProgress=$isNavigatingByLink externalAgeMs=$externalAgeMs localAgeMs=$localAgeMs"
)
val resolvedPage = anchor?.let { locator ->
(paginator as? BookPaginator)?.findPageForLocator(locator)
bookPaginator?.findStablePageForLocator(locator)
}
if (resolvedPage != null) {
Timber.tag(TAG_STABLE_PAGE_NAV).d(
"shift_apply_stable shift=$shiftAmount from=$currentPageBeforeShift to=$resolvedPage anchorSource=$anchorSource anchor=$anchor"
)
pagerState.scrollToPage(resolvedPage)
paginator.onUserScrolledTo(resolvedPage)
} else {
val newPage = pagerState.currentPage + shiftAmount
val maxPage = (pagerState.pageCount - 1).coerceAtLeast(0)
val newPage = (currentPageBeforeShift + shiftAmount).coerceIn(0, maxPage)
Timber.tag(TAG_STABLE_PAGE_NAV).w(
"shift_apply_relative shift=$shiftAmount from=$currentPageBeforeShift to=$newPage anchorSource=$anchorSource anchor=$anchor"
)
pagerState.scrollToPage(newPage)
paginator.onUserScrolledTo(newPage)
}
@ -1134,6 +1248,7 @@ fun PaginatedReaderScreen(
uiState = uiState,
pagerState = pagerState,
isPageTurnAnimationEnabled = isPageTurnAnimationEnabled,
isRightToLeftPagination = isRightToLeftPagination,
effectiveBg = effectiveBg,
searchQuery = searchQuery,
ttsHighlightInfo = ttsHighlightInfo,
@ -1163,100 +1278,126 @@ fun PaginatedReaderScreen(
}
}
},
onInternalLinkNavigated = onInternalLinkNavigated,
onLinkClick = { currentChapterPath, href, onNavComplete ->
coroutineScope.launch(Dispatchers.IO) {
isNavigatingByLink = true
var isFootnote = false
var footnoteHtml: String? = null
withContext(Dispatchers.Main) { isNavigatingByLink = true }
try {
var isFootnote = false
var footnoteHtml: String? = null
val sourceChapter =
book.chaptersForPagination.find { it.absPath == currentChapterPath }
if (sourceChapter != null) {
val sourceHtml = sourceChapter.htmlContent.ifEmpty {
try {
File(book.extractionBasePath, sourceChapter.htmlFilePath)
.readText()
} catch (_: Exception) {
""
}
}
if (sourceHtml.isNotEmpty()) {
val doc = Jsoup.parse(sourceHtml)
val safeHref = href.replace("\"", "\\\"")
val aTag = doc.select("a[href=\"$safeHref\"]").first()
if (aTag?.attr("epub:type") == "noteref" || href.startsWith("#")) {
isFootnote = true
}
} else if (href.startsWith("#")) {
isFootnote = true
}
} else if (href.startsWith("#")) {
isFootnote = true
}
if (isFootnote) {
val decodedHref = try {
URLDecoder.decode(href, "UTF-8")
} catch (_: Exception) {
href
}
val parts = decodedHref.split('#', limit = 2)
val pathPart = parts[0]
val anchor = if (parts.size > 1) parts[1] else null
if (anchor != null) {
val targetPath = if (pathPart.isBlank()) currentChapterPath else {
val sourceChapter =
book.chaptersForPagination.find { it.absPath == currentChapterPath }
if (sourceChapter != null) {
val sourceHtml = sourceChapter.htmlContent.ifEmpty {
try {
URI(currentChapterPath).resolve(pathPart)
.normalize().path
File(book.extractionBasePath, sourceChapter.htmlFilePath)
.readText()
} catch (_: Exception) {
null
""
}
}
if (sourceHtml.isNotEmpty()) {
val doc = Jsoup.parse(sourceHtml)
val safeHref = href.replace("\"", "\\\"")
val aTag = doc.select("a[href=\"$safeHref\"]").first()
if (targetPath != null) {
val targetChapter = book.chaptersForPagination.find {
val linkType = aTag?.attr("epub:type").orEmpty()
val linkRole = aTag?.attr("role").orEmpty()
if (
linkType.contains("noteref", ignoreCase = true) ||
linkRole.contains("doc-noteref", ignoreCase = true)
) {
isFootnote = true
}
}
}
run {
val decodedHref = try {
URLDecoder.decode(href, "UTF-8")
} catch (_: Exception) {
href
}
val parts = decodedHref.split('#', limit = 2)
val pathPart = parts[0]
val anchor = if (parts.size > 1) parts[1] else null
if (anchor != null) {
val targetPath = if (pathPart.isBlank()) currentChapterPath else {
try {
URI(it.absPath).normalize().path == targetPath
URI(currentChapterPath).resolve(pathPart)
.normalize().path
} catch (_: Exception) {
false
null
}
}
if (targetChapter != null) {
val targetHtml = targetChapter.htmlContent.ifEmpty {
if (targetPath != null) {
val targetChapter = book.chaptersForPagination.find {
try {
File(
book.extractionBasePath,
targetChapter.htmlFilePath
).readText()
URI(it.absPath).normalize().path == targetPath
} catch (_: Exception) {
""
false
}
}
if (targetHtml.isNotEmpty()) {
val doc = Jsoup.parse(targetHtml)
val noteEl = doc.getElementById(anchor)
if (noteEl != null) {
footnoteHtml = noteEl.html()
if (targetChapter != null) {
val targetHtml = targetChapter.htmlContent.ifEmpty {
try {
File(
book.extractionBasePath,
targetChapter.htmlFilePath
).readText()
} catch (_: Exception) {
""
}
}
if (targetHtml.isNotEmpty()) {
val doc = Jsoup.parse(targetHtml)
val noteEl = doc.getElementById(anchor)
if (noteEl != null) {
val targetType = noteEl.attr("epub:type")
val targetRole = noteEl.attr("role")
val targetClass = noteEl.className()
val targetLooksLikeFootnote =
targetType.contains("footnote", ignoreCase = true) ||
targetRole.contains("doc-footnote", ignoreCase = true) ||
targetClass.contains("footnote", ignoreCase = true)
if (isFootnote || targetLooksLikeFootnote) {
footnoteHtml = noteEl.html()
}
}
}
}
}
}
}
}
withContext(Dispatchers.Main) {
if (!footnoteHtml.isNullOrBlank()) {
onFootnoteRequested(footnoteHtml)
isNavigatingByLink = false
withContext(Dispatchers.Main) { onFootnoteRequested(footnoteHtml) }
} else {
paginator.navigateToHref(currentChapterPath, href) {
onNavComplete(it)
isNavigatingByLink = false
val targetPage = (paginator as? BookPaginator)?.findStablePageForHref(currentChapterPath, href)
withContext(Dispatchers.Main) {
if (targetPage != null) {
val targetAnchor = (paginator as? BookPaginator)?.getLocatorForPage(targetPage)
val navigationEpoch = System.currentTimeMillis()
localExplicitNavigationAnchor = targetAnchor
localExplicitNavigationEpoch = navigationEpoch
Timber.tag(TAG_STABLE_PAGE_NAV).d(
"link_resolved href=$href targetPage=$targetPage anchor=$targetAnchor epoch=$navigationEpoch"
)
paginator.onUserScrolledTo(targetPage)
onNavComplete(targetPage)
} else {
Timber.tag(TAG_STABLE_PAGE_NAV).w(
"link_failed href=$href currentChapterPath=$currentChapterPath"
)
}
}
}
} finally {
withContext(Dispatchers.Main) { isNavigatingByLink = false }
}
}
},
@ -1359,7 +1500,7 @@ private fun findFuzzyMatch(source: String, target: String, ignoreCase: Boolean =
return null
}
private fun getHighlightOffsetsInBlock(
internal fun getHighlightOffsetsInBlock(
block: TextContentBlock, highlight: UserHighlight
): IntRange? {
if (block.cfi == null) return null
@ -1368,6 +1509,7 @@ private fun getHighlightOffsetsInBlock(
val parts = highlight.cfi.split('|')
val startCfi = parts.firstOrNull() ?: highlight.cfi
val endCfi = parts.lastOrNull()
val isMultipartHighlight = endCfi != null && endCfi != startCfi
@Suppress("REDUNDANT_ELSE_IN_WHEN") val blockStartAbs = when (block) {
is ParagraphBlock -> block.startCharOffsetInSource
@ -1376,6 +1518,9 @@ private fun getHighlightOffsetsInBlock(
is ListItemBlock -> block.startCharOffsetInSource
else -> 0
}
val blockEndAbs = block.endCharOffsetInSource
.takeIf { it > blockStartAbs }
?: (blockStartAbs + block.content.text.length)
Timber.d(
"getHighlightOffsetsInBlock: Checking Block=${block.cfi} (AbsStart=$blockStartAbs) against Highlight=${highlight.cfi}"
@ -1419,48 +1564,28 @@ private fun getHighlightOffsetsInBlock(
)
}
var isAfterStart = false
var isBeforeEnd = true
if (relevantPart == null) {
if (startCfi.isNotEmpty()) {
try {
if (CfiUtils.compare(block.cfi!!, startCfi) > 0) {
isAfterStart = true
}
} catch (_: Exception) {
}
}
if (endCfi != null && endCfi != startCfi) {
try {
val endPath = CfiUtils.getPath(endCfi)
val cmp = CfiUtils.compare(blockPath, endPath)
Timber.d(" -> Comparing BlockPath ($blockPath) vs EndPath ($endPath). Result: $cmp")
if (CfiUtils.compare(blockPath, endPath) > 0) {
isBeforeEnd = false
}
} catch (_: Exception) {
}
}
}
Timber.d(" -> relevantPart=$relevantPart, isAfterStart=$isAfterStart, isBeforeEnd=$isBeforeEnd")
if (relevantPart == null && (!isAfterStart || !isBeforeEnd)) {
return null
}
val blockText = block.content.text
val highlightText = highlight.text
if (blockText.isEmpty() || highlightText.isEmpty()) return null
if (highlightText.contains(blockText, ignoreCase = false)) return 0 until blockText.length
if (highlightText.contains(blockText, ignoreCase = true)) return 0 until blockText.length
var startIndex = blockText.indexOf(highlightText, ignoreCase = false)
if (startIndex == -1) {
startIndex = blockText.indexOf(highlightText, ignoreCase = true)
val isIntermediateBlock = relevantPart == null &&
isMultipartHighlight &&
CfiUtils.isPathStrictlyBetween(block.cfi!!, startCfi, endCfi!!)
Timber.d(" -> relevantPart=$relevantPart, isIntermediateBlock=$isIntermediateBlock")
if (relevantPart == null) {
if (!isIntermediateBlock) return null
if (highlightText.contains(blockText, ignoreCase = false)) return 0 until blockText.length
if (highlightText.contains(blockText, ignoreCase = true)) return 0 until blockText.length
val normBlock = blockText.filter { !it.isWhitespace() }
val normHighlight = highlightText.filter { !it.isWhitespace() }
return if (normBlock.isNotBlank() && normHighlight.contains(normBlock, ignoreCase = true)) {
0 until blockText.length
} else {
null
}
}
if (relevantPart != null) {
@ -1482,11 +1607,27 @@ private fun getHighlightOffsetsInBlock(
Timber.d(" -> Path Equivalence: StartMatches=$startMatches, EndMatches=$endMatches")
if (startMatches || endMatches) {
val startAbs = CfiUtils.getOffsetOrNull(startCfi)
val endAbs = endCfi?.let { CfiUtils.getOffsetOrNull(it) }
if (startMatches && endMatches && startAbs != null && endAbs != null) {
val rangeStartAbs = minOf(startAbs, endAbs)
val rangeEndAbs = maxOf(startAbs, endAbs)
if (rangeEndAbs <= blockStartAbs || rangeStartAbs >= blockEndAbs) {
Timber.d(
" -> Skipping same-path split block outside highlight offsets. " +
"highlight=$rangeStartAbs..$rangeEndAbs block=$blockStartAbs..$blockEndAbs"
)
return null
}
} else {
if (startMatches && startAbs != null && startAbs >= blockEndAbs) return null
if (endMatches && endAbs != null && endAbs <= blockStartAbs) return null
}
var s = 0
var e = blockText.length
if (startMatches) {
val absOffset = CfiUtils.getOffset(startCfi)
val absOffset = startAbs ?: CfiUtils.getOffset(startCfi)
val relOffset = absOffset - blockStartAbs
if (relOffset < 0) {
@ -1530,7 +1671,7 @@ private fun getHighlightOffsetsInBlock(
}
if (endMatches) {
val absOffset = CfiUtils.getOffset(endCfi!!)
val absOffset = endAbs ?: CfiUtils.getOffset(endCfi!!)
val relOffset = absOffset - blockStartAbs
Timber.d(
@ -1559,18 +1700,16 @@ private fun getHighlightOffsetsInBlock(
}
}
if (startIndex >= 0) {
return startIndex until (startIndex + highlightText.length)
if (highlightText.contains(blockText, ignoreCase = false)) return 0 until blockText.length
if (highlightText.contains(blockText, ignoreCase = true)) return 0 until blockText.length
var startIndex = blockText.indexOf(highlightText, ignoreCase = false)
if (startIndex == -1) {
startIndex = blockText.indexOf(highlightText, ignoreCase = true)
}
if (relevantPart == null) {
@Suppress("KotlinConstantConditions") if (isAfterStart) {
val normBlock = blockText.filter { !it.isWhitespace() }
val normHighlight = highlightText.filter { !it.isWhitespace() }
if (normHighlight.contains(normBlock, ignoreCase = true)) {
return 0 until blockText.length
}
}
if (startIndex >= 0) {
return startIndex until (startIndex + highlightText.length)
}
val match = findFuzzyMatch(blockText, highlightText)
@ -2073,6 +2212,7 @@ internal fun PaginatedReaderContent(
uiState: PaginatedReaderUiState,
pagerState: PagerState,
isPageTurnAnimationEnabled: Boolean,
isRightToLeftPagination: Boolean = false,
effectiveBg: Color,
effectiveText: Color,
searchQuery: String,
@ -2084,6 +2224,7 @@ internal fun PaginatedReaderContent(
onGetPage: (Int) -> Page?,
onGetChapterPath: (Int) -> String?,
onLinkClick: (currentChapterPath: String, href: String, onNavComplete: (Int) -> Unit) -> Unit,
onInternalLinkNavigated: (Int) -> Unit,
onTap: (Offset?) -> Unit,
isProUser: Boolean,
isOss: Boolean,
@ -2231,7 +2372,8 @@ internal fun PaginatedReaderContent(
}
}
},
beyondViewportPageCount = 1
beyondViewportPageCount = 1,
reverseLayout = isRightToLeftPagination
) { pageIndex ->
val pageOffset =
(pageIndex - pagerState.currentPage) - pagerState.currentPageOffsetFraction
@ -2432,7 +2574,11 @@ internal fun PaginatedReaderContent(
} else {
currentChapterPath?.let { path ->
onLinkClick(path, href) { targetPageIndex ->
onInternalLinkNavigated(targetPageIndex)
coroutineScope.launch {
Timber.tag(TAG_STABLE_PAGE_NAV).d(
"link_scroll targetPage=$targetPageIndex currentPage=${pagerState.currentPage}"
)
pagerState.scrollToPage(targetPageIndex)
}
}

View file

@ -0,0 +1,93 @@
package com.aryan.reader.paginatedreader
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.style.TextDecoration
import kotlin.math.abs
internal fun SpanStyle.withReaderLinkStyle(
isDarkTheme: Boolean,
themeBackgroundColor: Color,
themeTextColor: Color
): SpanStyle {
val linkStyle = readerLinkSpanStyle(
isDarkTheme = isDarkTheme,
themeBackgroundColor = themeBackgroundColor,
themeTextColor = themeTextColor,
existingDecoration = textDecoration
)
return copy(
color = linkStyle.color,
background = linkStyle.background,
textDecoration = linkStyle.textDecoration
)
}
internal fun readerLinkSpanStyle(
isDarkTheme: Boolean,
themeBackgroundColor: Color,
themeTextColor: Color,
existingDecoration: TextDecoration? = null
): SpanStyle {
val background = themeBackgroundColor.takeIf { it.isSpecified }
?: if (isDarkTheme) Color.Black else Color.White
val text = themeTextColor.takeIf { it.isSpecified }
?: if (isDarkTheme) Color.White else Color.Black
val linkColor = readerLinkColorForTheme(isDarkTheme, background, text)
val backgroundAlpha = if (background.safeLuminance() < 0.45f) 0.24f else 0.16f
return SpanStyle(
color = linkColor,
background = linkColor.copy(alpha = backgroundAlpha),
textDecoration = existingDecoration.withUnderline()
)
}
private fun readerLinkColorForTheme(
isDarkTheme: Boolean,
background: Color,
text: Color
): Color {
val backgroundLuminance = background.safeLuminance()
val textLuminance = text.safeLuminance()
val candidates = if (isDarkTheme || backgroundLuminance < 0.45f) {
listOf(
Color(0xFF7DD3FC),
Color(0xFF5EEAD4),
Color(0xFFA5B4FC),
Color(0xFFFDE68A),
Color.White
)
} else {
listOf(
Color(0xFF005FCC),
Color(0xFF006D75),
Color(0xFF7A1E52),
Color(0xFF4A148C),
Color(0xFF111827)
)
}
return candidates.firstOrNull {
it.contrastRatio(background) >= 4.5f && abs(it.safeLuminance() - textLuminance) >= 0.08f
} ?: candidates.maxByOrNull { it.contrastRatio(background) }
?: if (isDarkTheme) Color(0xFF7DD3FC) else Color(0xFF005FCC)
}
private fun TextDecoration?.withUnderline(): TextDecoration {
val current = this ?: TextDecoration.None
val decorations = mutableListOf<TextDecoration>()
if (current.contains(TextDecoration.LineThrough)) decorations += TextDecoration.LineThrough
decorations += TextDecoration.Underline
return TextDecoration.combine(decorations)
}
private fun Color.contrastRatio(other: Color): Float {
val lighter = maxOf(safeLuminance(), other.safeLuminance())
val darker = minOf(safeLuminance(), other.safeLuminance())
return (lighter + 0.05f) / (darker + 0.05f)
}
private fun Color.safeLuminance(): Float {
return if (isSpecified) luminance() else 0f
}

View file

@ -118,6 +118,17 @@ private fun AnnotatedString.applyReaderThemeForDisplay(
}
addStringAnnotation(range.tag, item, range.start, range.end)
}
this@applyReaderThemeForDisplay.getStringAnnotations("URL", 0, this@applyReaderThemeForDisplay.length).forEach { range ->
addStyle(
readerLinkSpanStyle(
isDarkTheme = isDarkTheme,
themeBackgroundColor = themeBackgroundColor,
themeTextColor = themeTextColor
),
range.start,
range.end
)
}
}
}

View file

@ -0,0 +1,23 @@
package com.aryan.reader.paginatedreader
internal suspend fun resolveStableChapterStartPage(
chapterIndex: Int,
chapterCount: Int,
pageCountsAreAccurate: Boolean,
chapterStartPage: (Int) -> Int?,
isChapterFinalized: (Int) -> Boolean,
ensureChapterPaginated: suspend (Int) -> Boolean
): Int? {
if (chapterIndex !in 0 until chapterCount) return null
if (!pageCountsAreAccurate) {
for (prefixChapter in 0 until chapterIndex) {
if (!isChapterFinalized(prefixChapter)) {
val ready = ensureChapterPaginated(prefixChapter)
if (!ready) return null
}
}
}
return chapterStartPage(chapterIndex) ?: if (chapterIndex == 0) 0 else null
}