* Centralize library management logic and introduce support for plain text and HTML formats

* Centralize library management logic and introduce support for plain text and HTML formats

* Expand unit test coverage for library state management, UI models, and MainViewModel features.

* Add comprehensive unit tests for PDF reader core logic, preferences, and data persistence

* Add unit tests for EPUB parsing, content loading, search functionality, and reader JavaScript bridges.

* Add unit tests for OPDS parsing and Smart Collection engine, and integrate Kover plugin

* Add comprehensive unit tests

* Centralize library snapshot serialization in the `shared` module and improve filtering and sorting logic.

* Implement text selection, highlighting, and reading state persistence for PDF and EPUB engines in desktop version

* Folder import support for desktop app

* Introduce Smart Shelves with rule-based filtering in desktop version

* Implement shared EPUB annotation serialization and highlight rendering

* Centralize file type capabilities and platform-specific support logic

* Refactor reader state management to use a central reducer

* Implement customizable reader toolbar and advanced formatting settings in shared

* Implement locator-based navigation and customizable highlight palette for desktop app

* Enhance reader customization and expand search functionality in desktop app

* Redesign reader settings and tools into a tabbed control panel in desktop app

* Enhance reader navigation and highlight precision in desktop app

* Implement bidirectional position synchronization and dynamic highlights in the desktop reader

* Implement shared state management and enhanced search for the PDF reader in desktop app

* Add vertical scroll support to the desktop PDF reader

* Implement ink, text, and eraser annotation support in desktop PDF viewer

* Implement PDF bookmarks, Table of Contents, and annotation editing in desktop app

* Implement link handling and navigation for PDF and EPUB readers in desktop app

* Implement PDF jump history for navigation in desktop app

* Enhance PDF ink rendering and annotation capabilities in desktop app

* Implement advanced PDF text annotations with inline editing and rich styling in desktop app

* Add move handle and movement logic for PDF text annotations in desktop app

* Implement local folder synchronization and metadata sidecar support in desktop app

* Implement book metadata extraction and drag-and-drop import for Desktop

* Implement dynamic and custom app theme management for desktop

* Introduce canonical PDF annotation codec and support for multi-segment highlights

* Implement rich text editing and pagination support for the PDF reader in desktop app

* Improve PDF rich text pagination, synchronization, and observability in desktop

* Hide trailing structural page breaks in rich text editor

* Implement a unified JVM book loader and expand supported formats on Desktop

* Add comic archive support for Desktop and enhance MOBI parsing

* Implement shared OPDS catalog support and UI for Android and Desktop

* Improve native WebView lifecycle and surface transition management on Desktop

* Enable Compose Swing interop blending and simplify Desktop WebView management

* Integrate BYOK AI features and Cloud TTS for desktop

* Enhance Desktop TTS with streaming audio and improved secure storage for AI key

* Implement scoped Cloud TTS with synchronized highlighting for EPUB and PDF in desktop app

* Implement custom font management and utility screens in desktop app

* Implement PDFium-based PDF annotation export

* Remove PdfBox dependency and standardize PDF export via Pdfium

* Implement local audio caching and playback controls for Gemini Cloud TTS in desktop app

* Implement reader themes and custom texture support in desktop app

* Redesign non-reader UI with responsive navigation and enhanced library management in desktop app

* Introduce ReaderWorkspaceShell to unify EPUB and PDF reader layouts in desktop app

* Exclude manual-only files from automated sync and import

* Implement customizable Text-to-Speech (TTS) word replacements

* Optimize reader performance with persistent layout caching and decoupled theme rendering

* Improve position restoration during reader reconfiguration in epub pagination

* Use independent thickness for eraser tool and stylus override
This commit is contained in:
Aryan 2026-05-10 10:07:37 +05:30 committed by GitHub
parent 88c7fa7b5c
commit 8366d76dcd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
214 changed files with 53372 additions and 4702 deletions

View file

@ -62,7 +62,8 @@ fun androidHtmlToSemanticBlocks(
fontFamilyMap: Map<String, FontFamily>,
constraints: androidx.compose.ui.unit.Constraints,
imageDimensionsCache: Map<String, Pair<Float, Float>> = emptyMap(),
mathSvgCache: Map<String, String> = emptyMap()
mathSvgCache: Map<String, String> = emptyMap(),
adaptThemeColors: Boolean = false
): List<SemanticBlock> {
return htmlToSemanticBlocks(
html = html,
@ -76,6 +77,7 @@ fun androidHtmlToSemanticBlocks(
imageDimensionsCache = imageDimensionsCache,
mathSvgCache = mathSvgCache,
resourceResolver = AndroidHtmlResourceResolver,
fontFamilyLoader = AndroidHtmlFontFamilyLoader
fontFamilyLoader = AndroidHtmlFontFamilyLoader,
adaptThemeColors = adaptThemeColors
)
}

View file

@ -42,7 +42,10 @@ import com.aryan.reader.paginatedreader.data.BookCacheDao
import com.aryan.reader.paginatedreader.data.BookProcessingInput
import com.aryan.reader.paginatedreader.data.BookProcessingWorker
import com.aryan.reader.paginatedreader.data.ConfigurationCache
import com.aryan.reader.paginatedreader.data.LATEST_PAGE_CACHE_VERSION
import com.aryan.reader.paginatedreader.data.LATEST_PROCESSING_VERSION
import com.aryan.reader.paginatedreader.data.PageCacheEntry
import com.aryan.reader.paginatedreader.data.PageIndexEntry
import com.aryan.reader.paginatedreader.data.ProcessedBook
import com.aryan.reader.paginatedreader.data.ProcessedChapter
import com.aryan.reader.paginatedreader.data.SerializableEpubChapter
@ -80,7 +83,8 @@ data class TtsChunk(
val text: String,
val sourceCfi: String,
val startOffsetInSource: Int,
val timedWords: List<TimedWord> = emptyList()
val timedWords: List<TimedWord> = emptyList(),
val spokenText: String = text
)
private data class PaginationRequest(val chapterIndex: Int, val priority: Int) : Comparable<PaginationRequest> {
@ -94,6 +98,26 @@ private data class PaginationRequest(val chapterIndex: Int, val priority: Int) :
}
}
private const val PAGE_INDEX_ANCHOR_SEPARATOR = "\u001F"
private data class TextRangeIndex(
val pageInChapter: Int,
val blockIndex: Int,
val startOffset: Int,
val endOffset: Int
)
private data class PageNavigationEntry(
val pageInChapter: Int,
val firstBlockIndex: Int,
val lastBlockIndex: Int,
val firstTextBlockIndex: Int?,
val firstTextCharOffset: Int,
val firstTextEndOffset: Int,
val firstCfi: String?,
val anchors: Set<String>
)
@OptIn(ExperimentalSerializationApi::class)
@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
@Stable
@ -138,7 +162,7 @@ class BookPaginator(
internal val chapterPageCounts = ConcurrentHashMap<Int, Int>()
val chapterStartPageIndices = ConcurrentHashMap<Int, Int>()
private val pageCache = object : LruCache<Int, List<Page>>(6) {
private val pageCache = object : LruCache<Int, List<Page>>(12) {
override fun entryRemoved(evicted: Boolean, key: Int, oldValue: List<Page>, newValue: List<Page>?) {
Timber.d("Chapter $key pages removed from cache. Evicted: $evicted")
}
@ -151,10 +175,15 @@ class BookPaginator(
}
private val chapterCharacterIndex = ConcurrentHashMap<Int, List<PageCharacterRange>>()
private val chapterCumulativeChars = ConcurrentHashMap<Int, List<Long>>()
private val chapterTextRangeIndex = ConcurrentHashMap<Int, List<TextRangeIndex>>()
private val chapterPageNavigationIndex = ConcurrentHashMap<Int, List<PageNavigationEntry>>()
private val chapterAnchorPageIndex = ConcurrentHashMap<Int, Map<String, Int>>()
private var pageCountsAreAccurate by mutableStateOf(false)
private val finalizedChapterCounts = ConcurrentHashMap.newKeySet<Int>()
private var currentConfigHash: Int = 0
@Volatile
private var chapterStartSnapshot: IntArray = IntArray(0)
private val paginationQueue = PriorityBlockingQueue<PaginationRequest>()
private val chaptersBeingProcessed = ConcurrentHashMap.newKeySet<Int>()
@ -198,6 +227,7 @@ class BookPaginator(
val bookRecord = bookCacheDao.getProcessedBook(bookId)
if (bookRecord == null || bookRecord.processingVersion < LATEST_PROCESSING_VERSION) {
Timber.i("Book cache is new or stale. Creating initial record.")
bookCacheDao.deleteEntireBookCache(bookId)
val initialBook = ProcessedBook(bookId, LATEST_PROCESSING_VERSION, 0) // Temp 0
bookCacheDao.insertProcessedBook(initialBook)
enqueueBookProcessingWork()
@ -229,8 +259,10 @@ class BookPaginator(
triggerPagination(startChapter, PRIORITY_HIGHEST)
// Queue neighbors with lower priority
if (startChapter + 1 < chapters.size) triggerPagination(startChapter + 1, PRIORITY_LOW)
if (startChapter - 1 >= 0) triggerPagination(startChapter - 1, PRIORITY_LOW)
for (offset in 1..2) {
if (startChapter + offset < chapters.size) triggerPagination(startChapter + offset, PRIORITY_LOW)
if (startChapter - offset >= 0) triggerPagination(startChapter - offset, PRIORITY_LOW)
}
isLoading = false
Timber.i("Paginator initialized. UI is ready.")
@ -238,10 +270,8 @@ class BookPaginator(
}
}
// [ADD this new function]
private fun runEstimator() {
var runningTotal = 0
val tempCounts = mutableMapOf<Int, Int>()
// This loop is extremely fast (math only)
chapters.forEachIndexed { index, chapter ->
@ -255,12 +285,12 @@ class BookPaginator(
chapterPageCounts[index] = estimatedCount
chapterStartPageIndices[index] = runningTotal
tempCounts[index] = estimatedCount
runningTotal += estimatedCount
}
totalPageCount = runningTotal
pageCountsAreAccurate = false
rebuildChapterStartSnapshot()
Timber.i("Estimator finished. Estimated total pages: $totalPageCount")
}
@ -287,6 +317,11 @@ class BookPaginator(
append("-pg:$paragraphGapMultiplier")
append("-img:$imageSizeMultiplier")
append("-vm:$verticalMarginMultiplier")
append("-proc:$LATEST_PROCESSING_VERSION")
append("-pageCache:$LATEST_PAGE_CACHE_VERSION")
append("-ua:${userAgentStylesheet.hashCode()}")
append("-css:${bookCss.hashCode()}")
append("-fonts:${allFontFaces.hashCode()}")
}
val hash = configString.hashCode()
return hash
@ -325,6 +360,7 @@ class BookPaginator(
}
totalPageCount = runningTotal
pageCountsAreAccurate = countsMap.size == chapters.size
rebuildChapterStartSnapshot()
}
private suspend fun updateAndSaveConfigurationCache() {
@ -340,12 +376,204 @@ class BookPaginator(
bookCacheDao.insertConfigurationCache(newCache)
bookCacheDao.cleanupOldConfigurations(bookId)
bookCacheDao.cleanupOldPageCaches(bookId)
if (finalizedChapterCounts.size >= chapters.size) {
pageCountsAreAccurate = true
}
}
private fun rebuildChapterStartSnapshot() {
chapterStartSnapshot = IntArray(chapters.size) { index ->
chapterStartPageIndices[index] ?: 0
}
}
private fun chapterContentVersion(chapter: EpubChapter): Int {
val backingFile = java.io.File(extractionBasePath, chapter.htmlFilePath)
return buildString {
append(chapter.absPath)
append('|')
append(chapter.htmlFilePath)
append('|')
append(chapter.htmlContent.length)
append('|')
append(chapter.htmlContent.hashCode())
append('|')
append(chapter.plainTextContent.length)
append('|')
append(chapter.plainTextContent.hashCode())
append('|')
if (backingFile.exists()) {
append(backingFile.length())
append('|')
append(backingFile.lastModified())
}
}.hashCode()
}
private suspend fun loadCachedPagesForChapter(chapter: EpubChapter, chapterIndex: Int): List<Page>? {
val cachedPages = bookCacheDao.getPageCache(bookId, currentConfigHash, chapterIndex) ?: return null
val expectedContentVersion = chapterContentVersion(chapter)
val isCompatible = cachedPages.processingVersion == LATEST_PROCESSING_VERSION &&
cachedPages.pageCacheVersion == LATEST_PAGE_CACHE_VERSION &&
cachedPages.contentVersion == expectedContentVersion
if (!isCompatible) {
Timber.d("Page cache stale for chapter $chapterIndex. Ignoring cached pages.")
return null
}
return try {
val pages = proto.decodeFromByteArray<List<Page>>(cachedPages.pagesProto)
if (pages.size != cachedPages.pageCount) {
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)
Timber.i("Page cache HIT for chapter $chapterIndex. Loaded ${pages.size} measured pages.")
pages
}
} catch (e: Exception) {
Timber.e(e, "Failed to deserialize page cache for chapter $chapterIndex")
null
}
}
private fun savePageCacheAsync(chapter: EpubChapter, chapterIndex: Int, pages: List<Page>) {
coroutineScope.launch(Dispatchers.IO) {
try {
val pageIndexEntries = buildPersistentPageIndexEntries(chapterIndex, pages)
val cacheEntry = PageCacheEntry(
bookId = bookId,
configHash = currentConfigHash,
chapterIndex = chapterIndex,
processingVersion = LATEST_PROCESSING_VERSION,
pageCacheVersion = LATEST_PAGE_CACHE_VERSION,
contentVersion = chapterContentVersion(chapter),
pageCount = pages.size,
pagesProto = proto.encodeToByteArray(pages)
)
bookCacheDao.insertPageCache(cacheEntry, pageIndexEntries)
Timber.d("Saved measured page cache for chapter $chapterIndex (${pages.size} pages).")
} catch (e: Exception) {
Timber.e(e, "Failed to persist page cache for chapter $chapterIndex")
}
}
}
private fun getAllBlocks(blocks: List<ContentBlock>): List<ContentBlock> {
return blocks.flatMap { block ->
when (block) {
is WrappingContentBlock -> listOf(block, block.floatedImage) + getAllBlocks(block.paragraphsToWrap)
is FlexContainerBlock -> listOf(block) + getAllBlocks(block.children)
is TableBlock -> listOf(block) + block.rows.flatten().flatMap { getAllBlocks(it.content) }
else -> listOf(block)
}
}
}
private fun applyPageRuntimeIndexes(chapterIndex: Int, pages: List<Page>) {
val characterIndex = mutableListOf<PageCharacterRange>()
val textRangeIndex = mutableListOf<TextRangeIndex>()
val navigationEntries = mutableListOf<PageNavigationEntry>()
val anchorPageMap = linkedMapOf<String, Int>()
val cumulativeCharsPerPage = mutableListOf<Long>()
var runningTotalChars = 0L
pages.forEachIndexed { pageInChapterIndex, page ->
val allBlocksOnPage = getAllBlocks(page.content)
val allTextBlocksOnPage = getAllTextBlocks(page.content)
val anchors = allBlocksOnPage.flatMap { findAllIds(it) }.toSet()
anchors.forEach { anchorPageMap.putIfAbsent(it, pageInChapterIndex) }
allTextBlocksOnPage.forEach { block ->
if (block.cfi != null && block.startCharOffsetInSource >= 0 && block.content.isNotEmpty()) {
val startOffset = block.startCharOffsetInSource
val endOffset = startOffset + block.content.text.length
characterIndex.add(
PageCharacterRange(
pageInChapter = pageInChapterIndex,
cfi = block.cfi!!,
startOffset = startOffset,
endOffset = endOffset
)
)
textRangeIndex.add(
TextRangeIndex(
pageInChapter = pageInChapterIndex,
blockIndex = block.blockIndex,
startOffset = startOffset,
endOffset = endOffset
)
)
}
}
val firstTextBlock = allTextBlocksOnPage.firstOrNull { it.content.text.isNotBlank() }
?: allTextBlocksOnPage.firstOrNull()
val firstBlock = allBlocksOnPage.firstOrNull()
val blockIndices = allBlocksOnPage.map { it.blockIndex }
navigationEntries.add(
PageNavigationEntry(
pageInChapter = pageInChapterIndex,
firstBlockIndex = blockIndices.minOrNull() ?: firstBlock?.blockIndex ?: -1,
lastBlockIndex = blockIndices.maxOrNull() ?: firstBlock?.blockIndex ?: -1,
firstTextBlockIndex = firstTextBlock?.blockIndex,
firstTextCharOffset = firstTextBlock?.startCharOffsetInSource ?: 0,
firstTextEndOffset = firstTextBlock?.let { it.startCharOffsetInSource + it.content.text.length } ?: 0,
firstCfi = firstTextBlock?.cfi ?: firstBlock?.cfi,
anchors = anchors
)
)
runningTotalChars += allTextBlocksOnPage.sumOf { it.content.text.length.toLong() }
cumulativeCharsPerPage.add(runningTotalChars)
}
chapterCharacterIndex[chapterIndex] = characterIndex
chapterTextRangeIndex[chapterIndex] = textRangeIndex
chapterPageNavigationIndex[chapterIndex] = navigationEntries
chapterAnchorPageIndex[chapterIndex] = anchorPageMap
chapterCumulativeChars[chapterIndex] = cumulativeCharsPerPage
}
private fun buildPersistentPageIndexEntries(chapterIndex: Int, pages: List<Page>): List<PageIndexEntry> {
val entries = chapterPageNavigationIndex[chapterIndex] ?: run {
applyPageRuntimeIndexes(chapterIndex, pages)
chapterPageNavigationIndex[chapterIndex].orEmpty()
}
return entries.map { entry ->
PageIndexEntry(
bookId = bookId,
configHash = currentConfigHash,
chapterIndex = chapterIndex,
pageInChapter = entry.pageInChapter,
firstBlockIndex = entry.firstBlockIndex,
lastBlockIndex = entry.lastBlockIndex,
firstTextBlockIndex = entry.firstTextBlockIndex,
firstTextCharOffset = entry.firstTextCharOffset,
firstTextEndOffset = entry.firstTextEndOffset,
firstCfi = entry.firstCfi,
anchors = entry.anchors.sorted().joinToString(PAGE_INDEX_ANCHOR_SEPARATOR)
)
}
}
private suspend fun updatePageCountsOnMain(chapterIndex: Int, actualPageCount: Int) {
withContext(Dispatchers.Main) {
if (chapterPageCounts[chapterIndex] != actualPageCount) {
updatePageCounts(chapterIndex, actualPageCount)
} else if (finalizedChapterCounts.add(chapterIndex)) {
coroutineScope.launch(Dispatchers.IO) { updateAndSaveConfigurationCache() }
}
generation++
}
}
suspend fun getTtsChunksForChapter(chapterIndex: Int, startingFromPageInChapter: Int = 0): List<TtsChunk>? {
val pages = pageCache[chapterIndex] ?: paginateChapter(chapterIndex)
if (pages.isNullOrEmpty()) {
@ -401,7 +629,12 @@ class BookPaginator(
private fun enqueueBookProcessingWork() {
val serializableChapters = chapters.map {
SerializableEpubChapter(it.htmlContent, it.title, it.absPath)
SerializableEpubChapter(
htmlContent = it.htmlContent,
title = it.title,
absPath = it.absPath,
htmlFilePath = it.htmlFilePath
)
}
val input = BookProcessingInput(
@ -437,13 +670,12 @@ class BookPaginator(
chapterAbsPath = chapter.absPath,
extractionBasePath = extractionBasePath,
userTextAlign = userTextAlign,
paragraphGapMultiplier = paragraphGapMultiplier
paragraphGapMultiplier = paragraphGapMultiplier,
adaptThemeColors = false
)
bookCacheDao.getProcessedChapter(bookId, chapterIndex)?.let { cachedChapter ->
if (cachedChapter.estimatedPageCount == 0) {
Timber.d("getBlocksForChapter: Found 'lite' cache for chapter $chapterIndex. Reprocessing for full fidelity.")
} else {
if (cachedChapter.contentBlocksProto.isNotEmpty()) {
try {
val semanticBlocks = proto.decodeFromByteArray<List<SemanticBlock>>(cachedChapter.contentBlocksProto)
@ -466,10 +698,12 @@ class BookPaginator(
} catch (e: Exception) {
Timber.e(e, "Failed to deserialize/style chapter $chapterIndex from DB. Reprocessing for this session.")
}
} else {
Timber.d("getBlocksForChapter: Cached chapter $chapterIndex had no semantic payload. Reprocessing.")
}
}
Timber.d("getBlocksForChapter: Cache MISS or 'lite' version found for chapter $chapterIndex. Parsing to Semantic IR.")
Timber.d("getBlocksForChapter: Cache MISS for chapter $chapterIndex. Parsing to Semantic IR.")
var htmlToParse = chapter.htmlContent
if (htmlToParse.isEmpty()) {
@ -506,10 +740,10 @@ class BookPaginator(
val processedHtml = document.outerHtml()
var parsingCssRules = OptimizedCssRules()
val uaResult = CssParser.parse(cssContent = userAgentStylesheet, cssPath = null, baseFontSizeSp = textStyle.fontSize.value, density = density.density, constraints = constraints, isDarkTheme = false, themeBackgroundColor = themeBackgroundColor, themeTextColor = themeTextColor)
val uaResult = CssParser.parse(cssContent = userAgentStylesheet, cssPath = null, baseFontSizeSp = textStyle.fontSize.value, density = density.density, constraints = constraints, isDarkTheme = false, adaptThemeColors = false)
parsingCssRules = parsingCssRules.merge(uaResult.rules)
bookCss.forEach { (path, content) ->
val bookCssResult = CssParser.parse(cssContent = content, cssPath = path, baseFontSizeSp = textStyle.fontSize.value, density = density.density, constraints = constraints, isDarkTheme = false, themeBackgroundColor = themeBackgroundColor, themeTextColor = themeTextColor)
val bookCssResult = CssParser.parse(cssContent = content, cssPath = path, baseFontSizeSp = textStyle.fontSize.value, density = density.density, constraints = constraints, isDarkTheme = false, adaptThemeColors = false)
parsingCssRules = parsingCssRules.merge(bookCssResult.rules)
}
@ -522,7 +756,8 @@ class BookPaginator(
density = density,
fontFamilyMap = fontFamilyMap,
constraints = constraints,
mathSvgCache = svgResults
mathSvgCache = svgResults,
adaptThemeColors = false
)
coroutineScope.launch(Dispatchers.IO) {
@ -617,6 +852,7 @@ class BookPaginator(
for (i in (chapterIndex + 1) until chapters.size) {
chapterStartPageIndices[i] = (chapterStartPageIndices[i] ?: 0) + difference
}
rebuildChapterStartSnapshot()
if (chapterIndex < currentUserChapterIndex.value) {
pageShiftRequest.tryEmit(difference)
@ -640,6 +876,14 @@ class BookPaginator(
val chapterStart = chapterStartPageIndices[chapterIndex] ?: 0
val currentPageInChapter = pageIndex - chapterStart
chapterAnchorPageIndex[chapterIndex]?.let { anchorPages ->
val anchorSet = tocAnchors.toSet()
return anchorPages
.filter { (anchor, anchorPage) -> anchor in anchorSet && anchorPage <= currentPageInChapter }
.maxByOrNull { it.value }
?.key
}
var lastFoundAnchor: String? = null
val anchorSet = tocAnchors.toSet()
@ -685,6 +929,19 @@ class BookPaginator(
)
return null
}
val starts = chapterStartSnapshot
if (starts.isNotEmpty()) {
val exactOrInsertionPoint = starts.binarySearch(pageIndex)
val index = if (exactOrInsertionPoint >= 0) {
exactOrInsertionPoint
} else {
-exactOrInsertionPoint - 2
}
if (index in chapters.indices) {
return index
}
}
val entry = chapterStartPageIndices.entries
.filter { it.value <= pageIndex }
.maxWithOrNull(compareBy({ it.value }, { it.key }))
@ -698,12 +955,24 @@ class BookPaginator(
override fun getCfiForPage(pageIndex: Int): String? {
val chapterIndex = findChapterIndexForPage(pageIndex) ?: return null
val chapterStart = chapterStartPageIndices[chapterIndex] ?: 0
val pageInChapterIndex = pageIndex - chapterStart
chapterPageNavigationIndex[chapterIndex]
?.getOrNull(pageInChapterIndex)
?.firstCfi
?.let { cfi ->
val offset = chapterPageNavigationIndex[chapterIndex]
?.getOrNull(pageInChapterIndex)
?.firstTextCharOffset
?: 0
return if (offset > 0 && !cfi.contains(':')) "$cfi:$offset" else cfi
}
val chapterPages = pageCache[chapterIndex]
if (chapterPages == null) {
Timber.w("getCfiForPage: Chapter $chapterIndex not in cache for page $pageIndex.")
return null
}
val pageInChapterIndex = pageIndex - (chapterStartPageIndices[chapterIndex] ?: 0)
val pageContent = chapterPages.getOrNull(pageInChapterIndex)?.content ?: return null
val firstTextBlock = pageContent.firstOrNull { it is TextContentBlock } as? TextContentBlock
@ -729,6 +998,11 @@ class BookPaginator(
return null
}
loadCachedPagesForChapter(chapter, chapterIndex)?.let {
Timber.d("paginateChapter: Persistent page cache HIT for chapter $chapterIndex.")
return it
}
val blocks = blockCache[chapterIndex] ?: run {
Timber.d("paginateChapter: L2 Cache MISS for chapter $chapterIndex. Loading from DB.")
val blocksFromDb = getBlocksForChapter(chapter, chapterIndex)
@ -757,47 +1031,10 @@ class BookPaginator(
pageCache.put(chapterIndex, pages)
Timber.d("paginateChapter: Chapter $chapterIndex pages stored in L1 pageCache.")
pageCache.put(chapterIndex, pages)
Timber.d("paginateChapter: Chapter $chapterIndex pages stored in L1 pageCache.")
applyPageRuntimeIndexes(chapterIndex, pages)
savePageCacheAsync(chapter, chapterIndex, pages)
val characterIndex = mutableListOf<PageCharacterRange>()
pages.forEachIndexed { pageInChapterIndex, page ->
var totalCharsOnPage = 0L
val allTextBlocksOnPage = getAllTextBlocks(page.content)
allTextBlocksOnPage.forEach { block ->
if (block.cfi != null && block.startCharOffsetInSource >= 0 && block.content.isNotEmpty()) {
val startOffset = block.startCharOffsetInSource
val endOffset = startOffset + block.content.text.length
totalCharsOnPage += block.content.text.length
characterIndex.add(
PageCharacterRange(
pageInChapter = pageInChapterIndex,
cfi = block.cfi!!,
startOffset = startOffset,
endOffset = endOffset
)
)
}
}
}
chapterCharacterIndex[chapterIndex] = characterIndex
val cumulativeCharsPerPage = mutableListOf<Long>()
var runningTotalChars = 0L
pages.forEachIndexed { _, page ->
val charsOnPage = getAllTextBlocks(page.content).sumOf { it.content.text.length.toLong() }
runningTotalChars += charsOnPage
cumulativeCharsPerPage.add(runningTotalChars)
}
chapterCumulativeChars[chapterIndex] = cumulativeCharsPerPage
withContext(Dispatchers.Main) {
if (chapterPageCounts[chapterIndex] != pages.size) {
updatePageCounts(chapterIndex, pages.size)
}
generation++
}
updatePageCountsOnMain(chapterIndex, pages.size)
return pages
}
@ -835,14 +1072,16 @@ class BookPaginator(
private fun prefetchChapters(currentChapterIndex: Int) {
Timber.v("Prefetching chapters around index $currentChapterIndex.")
val nextChapterIndex = currentChapterIndex + 1
if (nextChapterIndex < chapters.size) {
triggerPagination(nextChapterIndex, PRIORITY_MEDIUM)
}
for (offset in 1..2) {
val nextChapterIndex = currentChapterIndex + offset
if (nextChapterIndex < chapters.size) {
triggerPagination(nextChapterIndex, PRIORITY_MEDIUM)
}
val prevChapterIndex = currentChapterIndex - 1
if (prevChapterIndex >= 0) {
triggerPagination(prevChapterIndex, PRIORITY_MEDIUM)
val prevChapterIndex = currentChapterIndex - offset
if (prevChapterIndex >= 0) {
triggerPagination(prevChapterIndex, PRIORITY_MEDIUM)
}
}
}
@ -908,6 +1147,19 @@ class BookPaginator(
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
@ -1093,6 +1345,26 @@ class BookPaginator(
return null
}
chapterTextRangeIndex[targetChapterIndex]
?.firstOrNull { range ->
range.blockIndex == locator.blockIndex &&
(locator.charOffset in range.startOffset..<range.endOffset ||
(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
}
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
}
var fallbackPageInChapter = -1
for ((pageIndex, page) in chapterPages.withIndex()) {
@ -1150,6 +1422,23 @@ class BookPaginator(
val chStart = chapterStartPageIndices[chapterIndex] ?: 0
Timber.tag("POS_DIAG").d("getLocatorForPage: Request pageIndex=$pageIndex. Resolved chapterIndex=$chapterIndex (starts at $chStart). PageInChapter=${pageIndex - chStart}")
chapterPageNavigationIndex[chapterIndex]?.getOrNull(pageIndex - chStart)?.let { entry ->
entry.firstTextBlockIndex?.let { blockIndex ->
return Locator(
chapterIndex = chapterIndex,
blockIndex = blockIndex,
charOffset = entry.firstTextCharOffset
)
}
if (entry.firstBlockIndex >= 0) {
return Locator(
chapterIndex = chapterIndex,
blockIndex = entry.firstBlockIndex,
charOffset = 0
)
}
}
val pageContent = getPageContent(pageIndex) ?: return null
Timber.tag("POS_DIAG").d("getLocatorForPage: Inspecting page $pageIndex (chapter=$chapterIndex). Total top-level blocks=${pageContent.content.size}")

View file

@ -46,6 +46,8 @@ import org.jsoup.Jsoup
import java.io.File
import java.net.URLDecoder
private const val DEBUG_CONTENT_STYLING = false
@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
class ContentStyler(
private val baseTextStyle: TextStyle,
@ -57,7 +59,8 @@ class ContentStyler(
private val chapterAbsPath: String,
private val extractionBasePath: String,
private val userTextAlign: TextAlign?,
private val paragraphGapMultiplier: Float
private val paragraphGapMultiplier: Float,
private val adaptThemeColors: Boolean = true
) {
fun style(semanticBlocks: List<SemanticBlock>): List<ContentBlock> {
@ -167,6 +170,7 @@ class ContentStyler(
val nonBlankSvgContent = svgContent?.takeIf { it.isNotBlank() }
val finalSvgContent = when {
block.isFromMathJax || nonBlankSvgContent == null -> svgContent
!adaptThemeColors -> embedImagesInSvg(nonBlankSvgContent)
else -> {
val themedSvg = applyThemeToSvg(nonBlankSvgContent)
embedImagesInSvg(themedSvg)
@ -223,6 +227,10 @@ class ContentStyler(
}
private fun applyThemeToStyle(style: CssStyle): CssStyle {
if (!adaptThemeColors) {
return style
}
val newSpanStyle = style.spanStyle.let { original ->
val newColor = if (original.color.isSpecified) {
CssParser.adaptColorForTheme(original.color, isDarkTheme, isBackground = false, themeBackgroundColor, themeTextColor)
@ -346,7 +354,9 @@ class ContentStyler(
block: SemanticTextBlock,
blockStyle: CssStyle
): AnnotatedString {
Timber.d("ContentStyler: Building annotated string. UserAlign=$userTextAlign, CSSAlign=${blockStyle.paragraphStyle.textAlign}")
if (DEBUG_CONTENT_STYLING) {
Timber.d("ContentStyler: Building annotated string. UserAlign=$userTextAlign, CSSAlign=${blockStyle.paragraphStyle.textAlign}")
}
val builtString = buildAnnotatedString {
val rootFontFamily = findFirstAvailableFontFamily(blockStyle.fontFamilies, fontFamilyMap)
@ -394,7 +404,9 @@ class ContentStyler(
.merge(blockStyle.spanStyle)
.copy(fontFamily = effectiveBlockFontFamily)
Timber.d("ContentStyler: InitialSpanStyle. BaseFontSize=${baseTextStyle.fontSize}, BlockFontSize=${blockStyle.spanStyle.fontSize} -> Merged=${initialSpanStyle.fontSize}")
if (DEBUG_CONTENT_STYLING) {
Timber.d("ContentStyler: InitialSpanStyle. BaseFontSize=${baseTextStyle.fontSize}, BlockFontSize=${blockStyle.spanStyle.fontSize} -> Merged=${initialSpanStyle.fontSize}")
}
withStyle(finalParagraphStyle) {
withStyle(initialSpanStyle) {

View file

@ -48,10 +48,20 @@ data class Locator(
class LocatorConverter(
private val bookCacheDao: BookCacheDao,
private val proto: ProtoBuf,
private val context: Context
private val context: Context,
private val stableBookId: String? = null
) {
private suspend fun processAndCacheChapter(book: EpubBook, chapterIndex: Int): List<SemanticBlock>? = withContext(Dispatchers.IO) {
Timber.tag("POS_DIAG").d("processAndCacheChapter: Processing for bookId='${book.title}' index=$chapterIndex")
private fun cacheBookId(book: EpubBook, overrideBookId: String? = null): String {
return overrideBookId ?: stableBookId ?: book.title
}
private suspend fun processAndCacheChapter(
book: EpubBook,
chapterIndex: Int,
explicitBookId: String? = null
): List<SemanticBlock>? = withContext(Dispatchers.IO) {
val cacheBookId = cacheBookId(book, explicitBookId)
Timber.tag("POS_DIAG").d("processAndCacheChapter: Processing for bookId='$cacheBookId' index=$chapterIndex")
try {
val chapter = book.chapters.getOrNull(chapterIndex) ?: return@withContext null
@ -98,7 +108,8 @@ class LocatorConverter(
baseFontSizeSp = 16f,
density = density.density,
constraints = constraints,
isDarkTheme = false
isDarkTheme = false,
adaptThemeColors = false
)
val rules = bookCssResult.rules
@ -123,16 +134,17 @@ class LocatorConverter(
extractionBasePath = book.extractionBasePath,
density = density,
fontFamilyMap = emptyMap(),
constraints = constraints
constraints = constraints,
adaptThemeColors = false
)
val protoBytes = proto.encodeToByteArray(semanticBlocks)
val newCacheEntry = ProcessedChapter(
bookId = book.title,
bookId = cacheBookId,
chapterIndex = chapterIndex,
contentBlocksProto = protoBytes,
estimatedPageCount = 0
estimatedPageCount = estimateSemanticPageCount(semanticBlocks)
)
bookCacheDao.insertProcessedChapters(listOf(newCacheEntry))
semanticBlocks
@ -141,9 +153,9 @@ class LocatorConverter(
}
}
suspend fun getLocatorFromCfi(book: EpubBook, chapterIndex: Int, cfi: String): Locator? = withContext(Dispatchers.IO) {
suspend fun getLocatorFromCfi(book: EpubBook, chapterIndex: Int, cfi: String, bookId: String? = null): Locator? = withContext(Dispatchers.IO) {
Timber.tag("POS_DIAG").d("getLocatorFromCfi: Input CFI='$cfi' for chapterIndex=$chapterIndex")
val processedChapter = bookCacheDao.getProcessedChapter(bookId = book.title, chapterIndex = chapterIndex)
val processedChapter = bookCacheDao.getProcessedChapter(bookId = cacheBookId(book, bookId), chapterIndex = chapterIndex)
var allBlocks: List<SemanticBlock>? = null
@ -154,7 +166,7 @@ class LocatorConverter(
}
if (allBlocks.isNullOrEmpty()) {
allBlocks = processAndCacheChapter(book, chapterIndex)
allBlocks = processAndCacheChapter(book, chapterIndex, bookId)
}
if (allBlocks.isNullOrEmpty()) {
@ -224,8 +236,8 @@ class LocatorConverter(
return bestMatch
}
suspend fun getTtsChunksForChapter(book: EpubBook, chapterIndex: Int): List<TtsChunk>? = withContext(Dispatchers.IO) {
val processedChapter = bookCacheDao.getProcessedChapter(bookId = book.title, chapterIndex = chapterIndex)
suspend fun getTtsChunksForChapter(book: EpubBook, chapterIndex: Int, bookId: String? = null): List<TtsChunk>? = withContext(Dispatchers.IO) {
val processedChapter = bookCacheDao.getProcessedChapter(bookId = cacheBookId(book, bookId), chapterIndex = chapterIndex)
var allBlocks: List<SemanticBlock>? = null
if (processedChapter != null && processedChapter.contentBlocksProto.isNotEmpty()) {
@ -235,7 +247,7 @@ class LocatorConverter(
}
if (allBlocks.isNullOrEmpty()) {
allBlocks = processAndCacheChapter(book, chapterIndex)
allBlocks = processAndCacheChapter(book, chapterIndex, bookId)
}
if (allBlocks.isNullOrEmpty()) return@withContext null
@ -279,9 +291,9 @@ class LocatorConverter(
chunks
}
suspend fun getCfiFromLocator(book: EpubBook, locator: Locator): String? = withContext(Dispatchers.IO) {
suspend fun getCfiFromLocator(book: EpubBook, locator: Locator, bookId: String? = null): String? = withContext(Dispatchers.IO) {
Timber.tag("POS_DIAG").d("getCfiFromLocator: Input $locator")
val processedChapter = bookCacheDao.getProcessedChapter(bookId = book.title, chapterIndex = locator.chapterIndex)
val processedChapter = bookCacheDao.getProcessedChapter(bookId = cacheBookId(book, bookId), chapterIndex = locator.chapterIndex)
var blocks: List<SemanticBlock>? = null
if (processedChapter != null && processedChapter.contentBlocksProto.isNotEmpty()) {
@ -291,7 +303,7 @@ class LocatorConverter(
}
if (blocks.isNullOrEmpty()) {
blocks = processAndCacheChapter(book, locator.chapterIndex)
blocks = processAndCacheChapter(book, locator.chapterIndex, bookId)
}
if (blocks.isNullOrEmpty()) {
@ -331,8 +343,26 @@ class LocatorConverter(
return null
}
suspend fun getTextOffset(book: EpubBook, locator: Locator): Int? = withContext(Dispatchers.IO) {
val processedChapter = bookCacheDao.getProcessedChapter(bookId = book.title, chapterIndex = locator.chapterIndex)
private fun estimateSemanticPageCount(blocks: List<SemanticBlock>): Int {
var charCount = 0
fun walk(block: SemanticBlock) {
when (block) {
is SemanticTextBlock -> charCount += block.text.length
is SemanticFlexContainer -> block.children.forEach(::walk)
is SemanticTable -> block.rows.forEach { row -> row.forEach { cell -> cell.content.forEach(::walk) } }
is SemanticList -> block.items.forEach(::walk)
is SemanticWrappingBlock -> block.paragraphsToWrap.forEach(::walk)
else -> Unit
}
}
blocks.forEach(::walk)
return ((charCount + 2_499) / 2_500).coerceAtLeast(1)
}
suspend fun getTextOffset(book: EpubBook, locator: Locator, bookId: String? = null): Int? = withContext(Dispatchers.IO) {
val processedChapter = bookCacheDao.getProcessedChapter(bookId = cacheBookId(book, bookId), chapterIndex = locator.chapterIndex)
var allBlocks: List<SemanticBlock>? = null
if (processedChapter != null && processedChapter.contentBlocksProto.isNotEmpty()) {
@ -342,7 +372,7 @@ class LocatorConverter(
}
if (allBlocks.isNullOrEmpty()) {
allBlocks = processAndCacheChapter(book, locator.chapterIndex)
allBlocks = processAndCacheChapter(book, locator.chapterIndex, bookId)
}
if (allBlocks.isNullOrEmpty()) return@withContext null

View file

@ -723,6 +723,7 @@ private fun WrappingContentLayout(
fun PaginatedReaderScreen(
modifier: Modifier = Modifier,
book: EpubBook,
bookId: String? = null,
isDarkTheme: Boolean,
effectiveBg: Color,
effectiveText: Color,
@ -739,6 +740,9 @@ fun PaginatedReaderScreen(
textAlign: ReaderTextAlign,
ttsHighlightInfo: TtsHighlightInfo?,
initialChapterIndexInBook: Int?,
fallbackLocatorForReconfiguration: Locator? = null,
onReconfigurationAnchorCaptured: (Locator) -> Unit = {},
onReconfigurationRestoreActiveChanged: (Boolean) -> Unit = {},
onPaginatorReady: (IPaginator) -> Unit,
onTap: (Offset?) -> Unit,
isProUser: Boolean,
@ -796,37 +800,32 @@ fun PaginatedReaderScreen(
var anchorLocatorForReconfig by remember { mutableStateOf<Locator?>(null) }
val currentPaginatorRef = remember { mutableStateOf<IPaginator?>(null) }
val latestFallbackLocatorForReconfiguration by rememberUpdatedState(fallbackLocatorForReconfiguration)
val previousState = remember {
arrayOf<Any>(this.constraints, isDarkTheme, effectiveBg, effectiveText)
var previousConstraints by remember {
mutableStateOf(this.constraints)
}
if (previousState[0] != this.constraints ||
previousState[1] != isDarkTheme ||
previousState[2] != effectiveBg ||
previousState[3] != effectiveText
) {
if (previousConstraints != this.constraints) {
val activePaginator = currentPaginatorRef.value
if (activePaginator is BookPaginator) {
val currentPage = pagerState.currentPage
val locator = activePaginator.getLocatorForPage(currentPage)
anchorLocatorForReconfig = locator
val currentPage = pagerState.currentPage
val locator = resolvePaginatedReconfigurationAnchor(
currentPageLocator = (activePaginator as? BookPaginator)?.getLocatorForPage(currentPage),
fallbackLocator = fallbackLocatorForReconfiguration
)
anchorLocatorForReconfig = locator
Timber.tag("ThemeReconfig").d("""
Timber.tag("ThemeReconfig").d("""
RECONFIG DETECTED
- Reason: ${if (previousState[0] != this.constraints) "Constraints" else "Theme/Colors"}
- Reason: Constraints
- Current Page: $currentPage
- Saved Locator: $locator
""".trimIndent())
}
previousState[0] = this.constraints
previousState[1] = isDarkTheme
previousState[2] = effectiveBg
previousState[3] = effectiveText
previousConstraints = this.constraints
}
val textStyle = remember(
baseTextStyle, effectiveText,
val layoutTextStyle = remember(
baseTextStyle,
debouncedFontSizeMult,
debouncedLineHeightMult,
debouncedFontFamily
@ -835,7 +834,7 @@ fun PaginatedReaderScreen(
val adjustedLineHeight = adjustedFontSize * paginationLineHeightMultiplierForWebViewSetting(debouncedLineHeightMult)
baseTextStyle.copy(
color = effectiveText,
color = Color.Unspecified,
fontSize = adjustedFontSize,
lineHeight = adjustedLineHeight,
fontFamily = debouncedFontFamily,
@ -848,6 +847,9 @@ fun PaginatedReaderScreen(
)
)
}
val textStyle = remember(layoutTextStyle, effectiveText) {
layoutTextStyle.copy(color = effectiveText)
}
LaunchedEffect(pagerState) {
snapshotFlow { pagerState.currentPage }.collect { page ->
@ -875,12 +877,13 @@ fun PaginatedReaderScreen(
delay(400L)
val activePaginator = currentPaginatorRef.value
if (activePaginator is BookPaginator) {
val currentPage = pagerState.currentPage
val locator = activePaginator.getLocatorForPage(currentPage)
if (locator != null) {
anchorLocatorForReconfig = locator
}
val currentPage = pagerState.currentPage
val locator = resolvePaginatedReconfigurationAnchor(
currentPageLocator = (activePaginator as? BookPaginator)?.getLocatorForPage(currentPage),
fallbackLocator = fallbackLocatorForReconfiguration
)
if (locator != null) {
anchorLocatorForReconfig = locator
}
debouncedFontSizeMult = fontSizeMultiplier
@ -955,7 +958,15 @@ fun PaginatedReaderScreen(
remember(initialChapterIndexInBook, anchorLocatorForReconfig) {
anchorLocatorForReconfig?.chapterIndex ?: initialChapterIndexInBook ?: 0
}
val paginator = remember(book, textConstraints, isDarkTheme, textStyle, userTextAlign, effectiveBg, effectiveText, debouncedParagraphGapMult) {
LaunchedEffect(anchorLocatorForReconfig) {
anchorLocatorForReconfig?.let { locator ->
onReconfigurationAnchorCaptured(locator)
onReconfigurationRestoreActiveChanged(true)
}
}
val paginator = remember(book, bookId, textConstraints, layoutTextStyle, userTextAlign, debouncedParagraphGapMult, debouncedImageSizeMult, debouncedVerticalMarginMult) {
val userAgentStylesheet = UserAgentStylesheet.default
var allRules = OptimizedCssRules()
val allFontFaces = mutableListOf<FontFaceInfo>()
@ -963,12 +974,11 @@ fun PaginatedReaderScreen(
val uaResult = CssParser.parse(
cssContent = userAgentStylesheet,
cssPath = null,
baseFontSizeSp = textStyle.fontSize.value,
baseFontSizeSp = layoutTextStyle.fontSize.value,
density = density.density,
constraints = textConstraints,
isDarkTheme = isDarkTheme,
themeBackgroundColor = effectiveBg,
themeTextColor = effectiveText
isDarkTheme = false,
adaptThemeColors = false
)
allRules = allRules.merge(uaResult.rules)
allFontFaces.addAll(uaResult.fontFaces)
@ -977,12 +987,11 @@ fun PaginatedReaderScreen(
val bookCssResult = CssParser.parse(
cssContent = content,
cssPath = path,
baseFontSizeSp = textStyle.fontSize.value,
baseFontSizeSp = layoutTextStyle.fontSize.value,
density = density.density,
constraints = textConstraints,
isDarkTheme = isDarkTheme,
themeBackgroundColor = effectiveBg,
themeTextColor = effectiveText
isDarkTheme = false,
adaptThemeColors = false
)
allRules = allRules.merge(bookCssResult.rules)
allFontFaces.addAll(bookCssResult.fontFaces)
@ -990,12 +999,11 @@ fun PaginatedReaderScreen(
val fontFamilyMap = loadFontFamilies(
fontFaces = allFontFaces, extractionPath = book.extractionBasePath
)
book.title
val bookCacheDao =
BookCacheDatabase.getDatabase(context.applicationContext).bookCacheDao()
val proto = ProtoBuf { serializersModule = semanticBlockModule }
val uniqueBookId = if (book.fileName.length > 20) book.fileName else book.title
val uniqueBookId = bookId ?: if (book.fileName.length > 20) book.fileName else book.title
Timber.d("Recreating BookPaginator for ID: $uniqueBookId. TextAlign: $userTextAlign")
Timber.tag("ReflowPaginationDiag").d("PaginatedReaderScreen: Instantiating BookPaginator. book.chaptersForPagination.size=${book.chaptersForPagination.size}, initialChapter=$effectiveInitialChapter")
@ -1005,7 +1013,7 @@ fun PaginatedReaderScreen(
chapters = book.chaptersForPagination,
textMeasurer = textMeasurer,
constraints = textConstraints,
textStyle = textStyle,
textStyle = layoutTextStyle,
extractionBasePath = book.extractionBasePath,
density = density,
fontFamilyMap = fontFamilyMap,
@ -1037,25 +1045,32 @@ fun PaginatedReaderScreen(
if (anchorLocatorForReconfig != null) {
Timber.tag("POS_DIAG").d("Restoration Triggered. Anchor Locator: $anchorLocatorForReconfig")
snapshotFlow { paginator.isLoading }.filter { !it }.first()
try {
onReconfigurationRestoreActiveChanged(true)
snapshotFlow { paginator.isLoading }.filter { !it }.first()
val targetLocator = anchorLocatorForReconfig
if (targetLocator != null) {
val page = paginator.findPageForLocator(targetLocator)
val targetLocator = anchorLocatorForReconfig
if (targetLocator != null) {
val page = paginator.findPageForLocator(targetLocator)
Timber.tag("POS_DIAG").d("Restoration Result: Paginator resolved locator to page: $page")
Timber.tag("POS_DIAG").d("Restoration Result: Paginator resolved locator to page: $page")
if (page != null) {
pagerState.scrollToPage(page)
Timber.tag("POS_DIAG").i("Restoration: Pager scrolled to $page")
} else {
val startPage = paginator.chapterStartPageIndices[targetLocator.chapterIndex]
if (startPage != null) {
Timber.tag("POS_DIAG").w("Restoration: Precise page not found, falling back to chapter start: $startPage")
pagerState.scrollToPage(startPage)
if (page != null) {
pagerState.scrollToPage(page)
paginator.onUserScrolledTo(page)
Timber.tag("POS_DIAG").i("Restoration: Pager scrolled to $page")
} else {
val startPage = paginator.chapterStartPageIndices[targetLocator.chapterIndex]
if (startPage != null) {
Timber.tag("POS_DIAG").w("Restoration: Precise page not found, falling back to chapter start: $startPage")
pagerState.scrollToPage(startPage)
paginator.onUserScrolledTo(startPage)
}
}
anchorLocatorForReconfig = null
}
anchorLocatorForReconfig = null
} finally {
onReconfigurationRestoreActiveChanged(false)
}
}
}
@ -1083,13 +1098,31 @@ fun PaginatedReaderScreen(
LaunchedEffect(pagerState, paginator) {
snapshotFlow { pagerState.currentPage }.debounce(500)
.collectLatest { page -> paginator.onUserScrolledTo(page) }
.collectLatest { page ->
if (anchorLocatorForReconfig == null) {
paginator.onUserScrolledTo(page)
}
}
}
LaunchedEffect(paginator, pagerState) {
paginator.pageShiftRequest.collect { shiftAmount ->
val newPage = pagerState.currentPage + shiftAmount
pagerState.scrollToPage(newPage)
val anchor = resolvePaginatedReconfigurationAnchor(
currentPageLocator = anchorLocatorForReconfig,
fallbackLocator = latestFallbackLocatorForReconfiguration
)
val resolvedPage = anchor?.let { locator ->
(paginator as? BookPaginator)?.findPageForLocator(locator)
}
if (resolvedPage != null) {
pagerState.scrollToPage(resolvedPage)
paginator.onUserScrolledTo(resolvedPage)
} else {
val newPage = pagerState.currentPage + shiftAmount
pagerState.scrollToPage(newPage)
paginator.onUserScrolledTo(newPage)
}
}
}
@ -2218,6 +2251,13 @@ internal fun PaginatedReaderContent(
var pageContent by remember { mutableStateOf<Page?>(null) }
var currentChapterPath by remember { mutableStateOf<String?>(null) }
val themedPageContent = remember(pageContent, isDarkTheme, effectiveBg, effectiveText) {
pageContent?.applyReaderThemeForDisplay(
isDarkTheme = isDarkTheme,
themeBackgroundColor = effectiveBg,
themeTextColor = effectiveText
)
}
LaunchedEffect(pageIndex, uiState.generation) {
val fetchStartTime = System.currentTimeMillis()
@ -2236,7 +2276,7 @@ internal fun PaginatedReaderContent(
}
val textBlocksOnPage =
pageContent?.content?.extractTextBlocks()
themedPageContent?.content?.extractTextBlocks()
?.filter { it.cfi != null } ?: emptyList()
val lastTextBlock = textBlocksOnPage.lastOrNull()
val lastBlockAbs = lastTextBlock?.let {
@ -2379,7 +2419,8 @@ internal fun PaginatedReaderContent(
horizontal = horizontalPadding,
vertical = verticalPadding
), contentAlignment = Alignment.TopStart) {
if (pageContent != null) {
if (themedPageContent != null) {
val displayPage = themedPageContent
val onGeneralTapCallback: (Offset) -> Unit = { offset ->
activeSelection = null
onTap(offset)
@ -2405,7 +2446,7 @@ internal fun PaginatedReaderContent(
val ttsHighlightColor =
MaterialTheme.colorScheme.secondary.copy(alpha = 0.5f)
pageContent!!.content.forEach { block ->
displayPage.content.forEach { block ->
val marginModifier = Modifier.padding(
top = block.style.margin.top.coerceAtLeast(0.dp),
bottom = block.style.margin.bottom.coerceAtLeast(

View file

@ -24,6 +24,7 @@ import android.os.Build
import androidx.annotation.RequiresApi
import androidx.annotation.VisibleForTesting
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextMeasurer
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.Constraints
@ -77,7 +78,8 @@ class PaginatedReaderViewModel : ViewModel() {
context: Context,
initialChapterToPaginate: Int?,
mathMLRenderer: MathMLRenderer,
paragraphGapMultiplier: Float
paragraphGapMultiplier: Float,
bookId: String? = null
) {
if (paginator != null) return
@ -88,16 +90,16 @@ class PaginatedReaderViewModel : ViewModel() {
val userAgentStylesheet = UserAgentStylesheet.default
var allRules = OptimizedCssRules()
val allFontFaces = mutableListOf<FontFaceInfo>()
val layoutTextStyle = textStyle.copy(color = Color.Unspecified)
val uaResult = CssParser.parse(
cssContent = userAgentStylesheet,
cssPath = null,
baseFontSizeSp = textStyle.fontSize.value,
baseFontSizeSp = layoutTextStyle.fontSize.value,
density = density.density,
constraints = textConstraints,
isDarkTheme = isDarkTheme,
themeBackgroundColor = themeBackgroundColor,
themeTextColor = themeTextColor
isDarkTheme = false,
adaptThemeColors = false
)
allRules = allRules.merge(uaResult.rules)
allFontFaces.addAll(uaResult.fontFaces)
@ -106,12 +108,11 @@ class PaginatedReaderViewModel : ViewModel() {
val bookCssResult = CssParser.parse(
cssContent = content,
cssPath = path,
baseFontSizeSp = textStyle.fontSize.value,
baseFontSizeSp = layoutTextStyle.fontSize.value,
density = density.density,
constraints = textConstraints,
isDarkTheme = isDarkTheme,
themeBackgroundColor = themeBackgroundColor,
themeTextColor = themeTextColor
isDarkTheme = false,
adaptThemeColors = false
)
allRules = allRules.merge(bookCssResult.rules)
allFontFaces.addAll(bookCssResult.fontFaces)
@ -120,21 +121,21 @@ class PaginatedReaderViewModel : ViewModel() {
fontFaces = allFontFaces,
extractionPath = book.extractionBasePath
)
val bookId = book.title
val cacheBookId = bookId ?: if (book.fileName.length > 20) book.fileName else book.title
val bookCacheDao = BookCacheDatabase.getDatabase(context.applicationContext).bookCacheDao()
val newPaginator = BookPaginator(
coroutineScope = viewModelScope,
chapters = book.chaptersForPagination,
textMeasurer = textMeasurer,
constraints = textConstraints,
textStyle = textStyle,
textStyle = layoutTextStyle,
extractionBasePath = book.extractionBasePath,
density = density,
fontFamilyMap = fontFamilyMap,
isDarkTheme = isDarkTheme,
themeBackgroundColor = themeBackgroundColor,
themeTextColor = themeTextColor,
bookId = bookId,
bookId = cacheBookId,
bookCacheDao = bookCacheDao,
proto = proto,
initialChapterToPaginate = initialChapterToPaginate ?: 0,

View file

@ -0,0 +1,6 @@
package com.aryan.reader.paginatedreader
internal fun resolvePaginatedReconfigurationAnchor(
currentPageLocator: Locator?,
fallbackLocator: Locator?
): Locator? = currentPageLocator ?: fallbackLocator

View file

@ -35,8 +35,11 @@ import androidx.compose.ui.unit.isSpecified
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.util.concurrent.ConcurrentHashMap
import kotlin.math.roundToInt
private const val DEBUG_PAGINATION_LOGS = false
interface BlockMeasurementProvider {
suspend fun measure(block: ContentBlock): Int
suspend fun split(block: ParagraphBlock, availableHeight: Int): Pair<ParagraphBlock, ParagraphBlock>?
@ -53,9 +56,13 @@ class SuspendingAndroidBlockMeasurementProvider(
private val density: Density,
private val imageSizeMultiplier: Float
) : BlockMeasurementProvider {
private val measurementCache = ConcurrentHashMap<Int, Int>()
override suspend fun measure(block: ContentBlock): Int {
return measureBlockHeight(
val cacheKey = blockMeasurementCacheKey(block)
measurementCache[cacheKey]?.let { return it }
val measured = measureBlockHeight(
block = block,
textMeasurer = textMeasurer,
constraints = constraints,
@ -64,6 +71,17 @@ class SuspendingAndroidBlockMeasurementProvider(
density = density,
imageSizeMultiplier = imageSizeMultiplier
)
measurementCache[cacheKey] = measured
return measured
}
private fun blockMeasurementCacheKey(block: ContentBlock): Int {
var result = block.hashCode()
result = 31 * result + constraints.maxWidth
result = 31 * result + constraints.maxHeight
result = 31 * result + textStyle.hashCode()
result = 31 * result + imageSizeMultiplier.hashCode()
return result
}
override suspend fun split(block: ParagraphBlock, availableHeight: Int): Pair<ParagraphBlock, ParagraphBlock>? {
@ -280,7 +298,9 @@ class SuspendingAndroidBlockMeasurementProvider(
block.style.padding.bottom.toPx() + (block.style.borderBottom?.width?.toPx() ?: 0f)
}.roundToInt()
Timber.tag("PAGINATION_DEBUG").d("SplitTable: avail=$availableHeight, topDec=$decorationTop, botDec=$decorationBottom")
if (DEBUG_PAGINATION_LOGS) {
Timber.tag("PAGINATION_DEBUG").d("SplitTable: avail=$availableHeight, topDec=$decorationTop, botDec=$decorationBottom")
}
currentHeight += decorationTop
for (i in block.rows.indices) {
@ -305,7 +325,9 @@ class SuspendingAndroidBlockMeasurementProvider(
}
if (currentHeight + maxRowHeight + decorationBottom > availableHeight) {
Timber.tag("PAGINATION_DEBUG").d("SplitTable: Breaking at row $i. currentH=$currentHeight, rowH=$maxRowHeight")
if (DEBUG_PAGINATION_LOGS) {
Timber.tag("PAGINATION_DEBUG").d("SplitTable: Breaking at row $i. currentH=$currentHeight, rowH=$maxRowHeight")
}
splitRowIndex = i
break
}
@ -410,7 +432,9 @@ suspend fun paginate(
if (blocks.isEmpty()) {
return emptyList()
}
Timber.d("Starting pagination for ${blocks.size} blocks with page height $pageHeight.")
if (DEBUG_PAGINATION_LOGS) {
Timber.d("Starting pagination for ${blocks.size} blocks with page height $pageHeight.")
}
val pages = mutableListOf<Page>()
var currentPageContent = mutableListOf<ContentBlock>()
@ -437,8 +461,10 @@ suspend fun paginate(
val spaceRequired = blockHeightWithSafetyMargin + spaceBetweenBlocks
Timber.tag("PAGINATION_DEBUG")
.d("Processing ${block::class.simpleName}: req=$spaceRequired, remaining=$remainingHeight, margin=$spaceBetweenBlocks, heightOnly=$blockHeight")
if (DEBUG_PAGINATION_LOGS) {
Timber.tag("PAGINATION_DEBUG")
.d("Processing ${block::class.simpleName}: req=$spaceRequired, remaining=$remainingHeight, margin=$spaceBetweenBlocks, heightOnly=$blockHeight")
}
if (spaceRequired <= remainingHeight) {
var blockToAdd = block
@ -608,23 +634,31 @@ suspend fun paginate(
}
else -> {
Timber.d("Page ${pageIndex + 1}: Block type is not splittable.")
if (DEBUG_PAGINATION_LOGS) {
Timber.d("Page ${pageIndex + 1}: Block type is not splittable.")
}
}
}
} else {
Timber.d("Page ${pageIndex + 1}: Not enough height for splitting ($heightForSplitting <= 50).")
if (DEBUG_PAGINATION_LOGS) {
Timber.d("Page ${pageIndex + 1}: Not enough height for splitting ($heightForSplitting <= 50).")
}
}
if (!wasSplit) {
if (currentPageContent.isEmpty()) {
Timber.tag("PAGINATION_DEBUG")
.w("FORCING block ${block::class.simpleName} onto page because it is the first block, even though req($spaceRequired) > remaining($remainingHeight)")
if (DEBUG_PAGINATION_LOGS) {
Timber.tag("PAGINATION_DEBUG")
.w("FORCING block ${block::class.simpleName} onto page because it is the first block, even though req($spaceRequired) > remaining($remainingHeight)")
}
val forcedHeight = blockHeight + spaceBetweenBlocks
val blockToAdd = setBlockExpectedHeight(block, forcedHeight)
currentPageContent.add(blockToAdd)
} else {
Timber.tag("PAGINATION_DEBUG")
.d("Block ${block::class.simpleName} did not fit and was not split. Moving to next page.")
if (DEBUG_PAGINATION_LOGS) {
Timber.tag("PAGINATION_DEBUG")
.d("Block ${block::class.simpleName} did not fit and was not split. Moving to next page.")
}
remainingBlocks.add(0, block)
}
}
@ -643,7 +677,9 @@ suspend fun paginate(
pages.add(Page(content = currentPageContent.toList()))
}
Timber.i("Pagination complete. Produced ${pages.size} pages from ${blocks.size} initial blocks.")
if (DEBUG_PAGINATION_LOGS) {
Timber.i("Pagination complete. Produced ${pages.size} pages from ${blocks.size} initial blocks.")
}
return pages
}
@ -906,7 +942,9 @@ private suspend fun measureBlockHeight(
(contentHeight + verticalPaddingPx + verticalBorderPx).roundToInt()
}
Timber.tag("PAGINATION_DEBUG").v("Measure result for ${block::class.simpleName}: content=$contentHeight, paddingV=$verticalPaddingPx, borderV=$verticalBorderPx, total=$finalHeight")
if (DEBUG_PAGINATION_LOGS) {
Timber.tag("PAGINATION_DEBUG").v("Measure result for ${block::class.simpleName}: content=$contentHeight, paddingV=$verticalPaddingPx, borderV=$verticalBorderPx, total=$finalHeight")
}
return finalHeight
}
@ -935,10 +973,14 @@ private suspend fun splitParagraphBlock(
val availableTextHeight = availableHeight - decorationTop - decorationBottom - centeredSafetyPaddingPx
Timber.tag("PAGINATION_DEBUG").d("SplitPara: totalAvail=$availableHeight, topDec=$decorationTop, botDec=$decorationBottom, textAvail=$availableTextHeight")
if (DEBUG_PAGINATION_LOGS) {
Timber.tag("PAGINATION_DEBUG").d("SplitPara: totalAvail=$availableHeight, topDec=$decorationTop, botDec=$decorationBottom, textAvail=$availableTextHeight")
}
if (availableTextHeight <= 0) {
Timber.tag("PAGINATION_DEBUG").w("SplitPara aborted: availableTextHeight <= 0")
if (DEBUG_PAGINATION_LOGS) {
Timber.tag("PAGINATION_DEBUG").w("SplitPara aborted: availableTextHeight <= 0")
}
return null
}
@ -969,7 +1011,9 @@ private suspend fun splitParagraphBlock(
}
if (lastVisibleLine == 0) {
Timber.d("Orphan control: Preventing split that would leave one line at the bottom of the page.")
if (DEBUG_PAGINATION_LOGS) {
Timber.d("Orphan control: Preventing split that would leave one line at the bottom of the page.")
}
return null
}
@ -985,7 +1029,9 @@ private suspend fun splitParagraphBlock(
)
}
if (part2Layout.lineCount == 1) {
Timber.d("Widow control: Adjusting split to prevent a single line at the top of the next page.")
if (DEBUG_PAGINATION_LOGS) {
Timber.d("Widow control: Adjusting split to prevent a single line at the top of the next page.")
}
lastVisibleLine--
splitOffset = layoutResult.getLineEnd(lastVisibleLine, visibleEnd = true)
}
@ -1046,7 +1092,9 @@ private suspend fun splitParagraphBlock(
endCharOffsetInSource = block.endCharOffsetInSource
)
Timber.d("Split block at offset $splitOffset. Part 1 len: ${part1.content.length}, Part 2 len: ${part2.content.length}")
if (DEBUG_PAGINATION_LOGS) {
Timber.d("Split block at offset $splitOffset. Part 1 len: ${part1.content.length}, Part 2 len: ${part2.content.length}")
}
return part1 to part2
}
@ -1090,7 +1138,9 @@ private suspend fun calculateContentHeightWithMargins(
}
}.roundToInt()
totalHeight += (childHeight + margin)
Timber.tag("PAGINATION_DEBUG").v(" Internal Child ${child::class.simpleName}: h=$childHeight, margin=$margin, runningTotal=$totalHeight")
if (DEBUG_PAGINATION_LOGS) {
Timber.tag("PAGINATION_DEBUG").v(" Internal Child ${child::class.simpleName}: h=$childHeight, margin=$margin, runningTotal=$totalHeight")
}
}
if (children.isNotEmpty()) {
totalHeight += with(density) { children.last().style.margin.bottom.toPx().roundToInt() }

View file

@ -0,0 +1,265 @@
package com.aryan.reader.paginatedreader
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import org.jsoup.Jsoup
internal fun Page.applyReaderThemeForDisplay(
isDarkTheme: Boolean,
themeBackgroundColor: Color,
themeTextColor: Color
): Page {
return copy(
content = content.map {
it.applyReaderThemeForDisplay(isDarkTheme, themeBackgroundColor, themeTextColor)
}
)
}
private fun ContentBlock.applyReaderThemeForDisplay(
isDarkTheme: Boolean,
themeBackgroundColor: Color,
themeTextColor: Color
): ContentBlock {
val themedStyle = style.applyReaderThemeForDisplay(isDarkTheme, themeBackgroundColor, themeTextColor)
return when (this) {
is ParagraphBlock -> copy(
content = content.applyReaderThemeForDisplay(isDarkTheme, themeBackgroundColor, themeTextColor),
style = themedStyle
)
is HeaderBlock -> copy(
content = content.applyReaderThemeForDisplay(isDarkTheme, themeBackgroundColor, themeTextColor),
style = themedStyle
)
is QuoteBlock -> copy(
content = content.applyReaderThemeForDisplay(isDarkTheme, themeBackgroundColor, themeTextColor),
style = themedStyle
)
is ListItemBlock -> copy(
content = content.applyReaderThemeForDisplay(isDarkTheme, themeBackgroundColor, themeTextColor),
style = themedStyle
)
is ImageBlock -> copy(style = themedStyle)
is SpacerBlock -> copy(style = themedStyle)
is MathBlock -> copy(
svgContent = if (isFromMathJax) {
svgContent
} else {
svgContent?.applyReaderThemeToSvgText(themeTextColor)
},
style = themedStyle
)
is WrappingContentBlock -> copy(
floatedImage = floatedImage.applyReaderThemeForDisplay(
isDarkTheme,
themeBackgroundColor,
themeTextColor
) as ImageBlock,
paragraphsToWrap = paragraphsToWrap.map {
it.applyReaderThemeForDisplay(
isDarkTheme,
themeBackgroundColor,
themeTextColor
) as ParagraphBlock
},
style = themedStyle
)
is TableBlock -> copy(
rows = rows.map { row ->
row.map { cell ->
cell.copy(
content = cell.content.map {
it.applyReaderThemeForDisplay(isDarkTheme, themeBackgroundColor, themeTextColor)
},
style = cell.style.applyReaderThemeForDisplay(
isDarkTheme,
themeBackgroundColor,
themeTextColor
)
)
}
},
style = themedStyle
)
is FlexContainerBlock -> copy(
children = children.map {
it.applyReaderThemeForDisplay(isDarkTheme, themeBackgroundColor, themeTextColor)
},
style = themedStyle
)
}
}
private fun AnnotatedString.applyReaderThemeForDisplay(
isDarkTheme: Boolean,
themeBackgroundColor: Color,
themeTextColor: Color
): AnnotatedString {
return buildAnnotatedString {
append(this@applyReaderThemeForDisplay.text)
this@applyReaderThemeForDisplay.spanStyles.forEach { range ->
addStyle(
range.item.applyReaderThemeForDisplay(isDarkTheme, themeBackgroundColor, themeTextColor),
range.start,
range.end
)
}
this@applyReaderThemeForDisplay.paragraphStyles.forEach { range ->
addStyle(range.item, range.start, range.end)
}
this@applyReaderThemeForDisplay.getStringAnnotations(0, this@applyReaderThemeForDisplay.length).forEach { range ->
val item = if (range.tag == "CustomUnderline") {
range.item.applyReaderThemeToUnderlineAnnotation(isDarkTheme, themeBackgroundColor, themeTextColor)
} else {
range.item
}
addStringAnnotation(range.tag, item, range.start, range.end)
}
}
}
private fun CssStyle.applyReaderThemeForDisplay(
isDarkTheme: Boolean,
themeBackgroundColor: Color,
themeTextColor: Color
): CssStyle {
val emphasis = textEmphasis
return copy(
spanStyle = spanStyle.applyReaderThemeForDisplay(isDarkTheme, themeBackgroundColor, themeTextColor),
blockStyle = blockStyle.applyReaderThemeForDisplay(isDarkTheme, themeBackgroundColor, themeTextColor),
textDecorationColor = textDecorationColor.applyReaderThemeColor(
isDarkTheme = isDarkTheme,
isBackground = false,
themeBackgroundColor = themeBackgroundColor,
themeTextColor = themeTextColor
),
textEmphasis = emphasis?.copy(
color = emphasis.color.applyReaderThemeColor(
isDarkTheme = isDarkTheme,
isBackground = false,
themeBackgroundColor = themeBackgroundColor,
themeTextColor = themeTextColor
)
)
)
}
private fun SpanStyle.applyReaderThemeForDisplay(
isDarkTheme: Boolean,
themeBackgroundColor: Color,
themeTextColor: Color
): SpanStyle {
return copy(
color = color.applyReaderThemeColor(
isDarkTheme = isDarkTheme,
isBackground = false,
themeBackgroundColor = themeBackgroundColor,
themeTextColor = themeTextColor
),
background = background.applyReaderThemeColor(
isDarkTheme = isDarkTheme,
isBackground = true,
themeBackgroundColor = themeBackgroundColor,
themeTextColor = themeTextColor
)
)
}
private fun BlockStyle.applyReaderThemeForDisplay(
isDarkTheme: Boolean,
themeBackgroundColor: Color,
themeTextColor: Color
): BlockStyle {
return copy(
backgroundColor = backgroundColor.applyReaderThemeColor(
isDarkTheme = isDarkTheme,
isBackground = true,
themeBackgroundColor = themeBackgroundColor,
themeTextColor = themeTextColor
),
borderTop = borderTop?.applyReaderThemeForDisplay(isDarkTheme, themeBackgroundColor, themeTextColor),
borderRight = borderRight?.applyReaderThemeForDisplay(isDarkTheme, themeBackgroundColor, themeTextColor),
borderBottom = borderBottom?.applyReaderThemeForDisplay(isDarkTheme, themeBackgroundColor, themeTextColor),
borderLeft = borderLeft?.applyReaderThemeForDisplay(isDarkTheme, themeBackgroundColor, themeTextColor)
)
}
private fun BorderStyle.applyReaderThemeForDisplay(
isDarkTheme: Boolean,
themeBackgroundColor: Color,
themeTextColor: Color
): BorderStyle {
return copy(
color = color.applyReaderThemeColor(
isDarkTheme = isDarkTheme,
isBackground = false,
themeBackgroundColor = themeBackgroundColor,
themeTextColor = themeTextColor
)
)
}
private fun Color.applyReaderThemeColor(
isDarkTheme: Boolean,
isBackground: Boolean,
themeBackgroundColor: Color,
themeTextColor: Color
): Color {
if (!isSpecified) return this
return CssParser.adaptColorForTheme(
color = this,
isDarkTheme = isDarkTheme,
isBackground = isBackground,
themeBackground = themeBackgroundColor,
themeText = themeTextColor
)
}
private fun String.applyReaderThemeToUnderlineAnnotation(
isDarkTheme: Boolean,
themeBackgroundColor: Color,
themeTextColor: Color
): String {
val parts = split('|').toMutableList()
val colorPart = parts.getOrNull(1) ?: return this
if (colorPart == "Unspecified") return this
val color = colorPart.toULongOrNull()?.let { Color(it) } ?: return this
parts[1] = color.applyReaderThemeColor(
isDarkTheme = isDarkTheme,
isBackground = false,
themeBackgroundColor = themeBackgroundColor,
themeTextColor = themeTextColor
).value.toString()
return parts.joinToString("|")
}
private fun String.applyReaderThemeToSvgText(themeTextColor: Color): String {
if (!themeTextColor.isSpecified || isBlank()) return this
return try {
val textColorHex = themeTextColor.toCssHexString()
val svgDocument = Jsoup.parseBodyFragment(this)
val svgElement = svgDocument.body().children().firstOrNull() ?: return this
svgElement.select("text").forEach { textElement ->
val existingStyle = textElement.attr("style")
val styleWithoutFill = existingStyle.replace(Regex("""\bfill\s*:\s*[^;]+;?"""), "")
val newStyle = "fill:$textColorHex; $styleWithoutFill".trim()
textElement.attr("style", newStyle)
textElement.removeAttr("fill")
}
svgElement.outerHtml()
} catch (_: Exception) {
this
}
}
private fun Color.toCssHexString(): String {
val red = (this.red * 255).toInt()
val green = (this.green * 255).toInt()
val blue = (this.blue * 255).toInt()
return "#%02X%02X%02X".format(red, green, blue)
}

View file

@ -28,6 +28,8 @@ import androidx.room.Query
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.Transaction
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
@Dao
abstract class BookCacheDao {
@ -151,12 +153,19 @@ abstract class BookCacheDao {
@Query("DELETE FROM configuration_cache WHERE bookId = :bookId")
abstract suspend fun deleteConfigurationCacheForBook(bookId: String)
@Query("DELETE FROM page_cache_metadata WHERE book_id = :bookId")
protected abstract suspend fun deletePageCacheMetadataForBook(bookId: String)
@Query("DELETE FROM page_cache_metadata WHERE book_id = :bookId AND config_hash = :configHash AND chapter_index = :chapterIndex")
protected abstract suspend fun deletePageCacheMetadataForChapter(bookId: String, configHash: Int, chapterIndex: Int)
@Transaction
open suspend fun deleteEntireBookCache(bookId: String) {
deleteBook(bookId)
deleteChaptersForBook(bookId)
deleteAnchorsForBook(bookId)
deleteConfigurationCacheForBook(bookId)
deletePageCacheMetadataForBook(bookId)
}
@Query("DELETE FROM anchor_index")
@ -165,12 +174,16 @@ abstract class BookCacheDao {
@Query("DELETE FROM configuration_cache")
abstract suspend fun clearConfigurationCache()
@Query("DELETE FROM page_cache_metadata")
protected abstract suspend fun clearPageCacheMetadata()
@Transaction
open suspend fun clearAllCache() {
clearProcessedBooks()
clearProcessedChapters()
clearAnchors()
clearConfigurationCache()
clearPageCacheMetadata()
}
@Query("SELECT * FROM configuration_cache WHERE bookId = :bookId AND configHash = :configHash")
@ -188,6 +201,101 @@ abstract class BookCacheDao {
)
""")
abstract suspend fun cleanupOldConfigurations(bookId: String)
@Query("SELECT * FROM page_cache_metadata WHERE book_id = :bookId AND config_hash = :configHash AND chapter_index = :chapterIndex")
protected abstract suspend fun getPageCacheMetadata(bookId: String, configHash: Int, chapterIndex: Int): PageCacheMetadata?
@Query("SELECT chunk_data FROM page_cache_chunks WHERE book_id = :bookId AND config_hash = :configHash AND chapter_index = :chapterIndex ORDER BY chunk_index ASC")
protected abstract suspend fun getPageCacheChunks(bookId: String, configHash: Int, chapterIndex: Int): List<ByteArray>
@Insert(onConflict = OnConflictStrategy.REPLACE)
protected abstract suspend fun insertPageCacheMetadata(metadata: PageCacheMetadata)
@Insert(onConflict = OnConflictStrategy.REPLACE)
protected abstract suspend fun insertPageCacheChunks(chunks: List<PageCacheChunk>)
@Insert(onConflict = OnConflictStrategy.REPLACE)
abstract suspend fun insertPageIndexEntries(entries: List<PageIndexEntry>)
@Query("SELECT * FROM page_index_entries WHERE book_id = :bookId AND config_hash = :configHash AND chapter_index = :chapterIndex ORDER BY page_in_chapter ASC")
abstract suspend fun getPageIndexEntries(bookId: String, configHash: Int, chapterIndex: Int): List<PageIndexEntry>
@Transaction
open suspend fun getPageCache(bookId: String, configHash: Int, chapterIndex: Int): PageCacheEntry? {
val metadata = getPageCacheMetadata(bookId, configHash, chapterIndex) ?: return null
val chunks = getPageCacheChunks(bookId, configHash, chapterIndex)
if (chunks.isEmpty()) return null
val totalSize = chunks.sumOf { it.size }
val mergedData = ByteArray(totalSize)
var offset = 0
for (chunk in chunks) {
System.arraycopy(chunk, 0, mergedData, offset, chunk.size)
offset += chunk.size
}
return PageCacheEntry(
bookId = metadata.bookId,
configHash = metadata.configHash,
chapterIndex = metadata.chapterIndex,
processingVersion = metadata.processingVersion,
pageCacheVersion = metadata.pageCacheVersion,
contentVersion = metadata.contentVersion,
pageCount = metadata.pageCount,
pagesProto = mergedData
)
}
@Transaction
open suspend fun insertPageCache(entry: PageCacheEntry, pageIndexEntries: List<PageIndexEntry>) {
@Suppress("LocalVariableName") val CHUNK_SIZE = 900 * 1024
deletePageCacheMetadataForChapter(entry.bookId, entry.configHash, entry.chapterIndex)
insertPageCacheMetadata(
PageCacheMetadata(
bookId = entry.bookId,
configHash = entry.configHash,
chapterIndex = entry.chapterIndex,
processingVersion = entry.processingVersion,
pageCacheVersion = entry.pageCacheVersion,
contentVersion = entry.contentVersion,
pageCount = entry.pageCount
)
)
val chunks = ArrayList<PageCacheChunk>()
var offset = 0
var chunkIndex = 0
while (offset < entry.pagesProto.size) {
val end = (offset + CHUNK_SIZE).coerceAtMost(entry.pagesProto.size)
chunks.add(
PageCacheChunk(
bookId = entry.bookId,
configHash = entry.configHash,
chapterIndex = entry.chapterIndex,
chunkIndex = chunkIndex,
chunkData = entry.pagesProto.copyOfRange(offset, end)
)
)
offset = end
chunkIndex++
}
insertPageCacheChunks(chunks)
if (pageIndexEntries.isNotEmpty()) {
insertPageIndexEntries(pageIndexEntries)
}
}
@Query("""
DELETE FROM page_cache_metadata
WHERE book_id = :bookId AND config_hash NOT IN (
SELECT configHash FROM configuration_cache
WHERE bookId = :bookId
ORDER BY rowid DESC LIMIT 3
)
""")
abstract suspend fun cleanupOldPageCaches(bookId: String)
}
@Database(
@ -196,9 +304,12 @@ abstract class BookCacheDao {
ProcessedChapterMetadata::class,
ProcessedChapterChunk::class,
ConfigurationCache::class,
AnchorIndexEntry::class
AnchorIndexEntry::class,
PageCacheMetadata::class,
PageCacheChunk::class,
PageIndexEntry::class
],
version = 10,
version = 11,
exportSchema = false
)
abstract class BookCacheDatabase : RoomDatabase() {
@ -215,11 +326,73 @@ abstract class BookCacheDatabase : RoomDatabase() {
BookCacheDatabase::class.java,
"book_cache_database"
)
.addMigrations(MIGRATION_10_11)
.fallbackToDestructiveMigration(true)
.build()
INSTANCE = instance
instance
}
}
private val MIGRATION_10_11 = object : Migration(10, 11) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"""
CREATE TABLE IF NOT EXISTS `page_cache_metadata` (
`book_id` TEXT NOT NULL,
`config_hash` INTEGER NOT NULL,
`chapter_index` INTEGER NOT NULL,
`processing_version` INTEGER NOT NULL,
`page_cache_version` INTEGER NOT NULL,
`content_version` INTEGER NOT NULL,
`page_count` INTEGER NOT NULL,
PRIMARY KEY(`book_id`, `config_hash`, `chapter_index`)
)
""".trimIndent()
)
db.execSQL(
"""
CREATE TABLE IF NOT EXISTS `page_cache_chunks` (
`book_id` TEXT NOT NULL,
`config_hash` INTEGER NOT NULL,
`chapter_index` INTEGER NOT NULL,
`chunk_index` INTEGER NOT NULL,
`chunk_data` BLOB NOT NULL,
PRIMARY KEY(`book_id`, `config_hash`, `chapter_index`, `chunk_index`),
FOREIGN KEY(`book_id`, `config_hash`, `chapter_index`)
REFERENCES `page_cache_metadata`(`book_id`, `config_hash`, `chapter_index`)
ON UPDATE NO ACTION ON DELETE CASCADE
)
""".trimIndent()
)
db.execSQL(
"CREATE INDEX IF NOT EXISTS `index_page_cache_chunks_book_id_config_hash_chapter_index` ON `page_cache_chunks` (`book_id`, `config_hash`, `chapter_index`)"
)
db.execSQL(
"""
CREATE TABLE IF NOT EXISTS `page_index_entries` (
`book_id` TEXT NOT NULL,
`config_hash` INTEGER NOT NULL,
`chapter_index` INTEGER NOT NULL,
`page_in_chapter` INTEGER NOT NULL,
`first_block_index` INTEGER NOT NULL,
`last_block_index` INTEGER NOT NULL,
`first_text_block_index` INTEGER,
`first_text_char_offset` INTEGER NOT NULL,
`first_text_end_offset` INTEGER NOT NULL,
`first_cfi` TEXT,
`anchors` TEXT NOT NULL,
PRIMARY KEY(`book_id`, `config_hash`, `chapter_index`, `page_in_chapter`),
FOREIGN KEY(`book_id`, `config_hash`, `chapter_index`)
REFERENCES `page_cache_metadata`(`book_id`, `config_hash`, `chapter_index`)
ON UPDATE NO ACTION ON DELETE CASCADE
)
""".trimIndent()
)
db.execSQL(
"CREATE INDEX IF NOT EXISTS `index_page_index_entries_book_id_config_hash_chapter_index` ON `page_index_entries` (`book_id`, `config_hash`, `chapter_index`)"
)
}
}
}
}

View file

@ -25,7 +25,8 @@ import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
const val LATEST_PROCESSING_VERSION = 10
const val LATEST_PROCESSING_VERSION = 11
const val LATEST_PAGE_CACHE_VERSION = 3
@Entity(tableName = "processed_books")
data class ProcessedBook(
@ -131,3 +132,121 @@ data class ConfigurationCache(
val configHash: Int,
val chapterPageCounts: String
)
data class PageCacheEntry(
val bookId: String,
val configHash: Int,
val chapterIndex: Int,
val processingVersion: Int,
val pageCacheVersion: Int,
val contentVersion: Int,
val pageCount: Int,
val pagesProto: ByteArray
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as PageCacheEntry
if (bookId != other.bookId) return false
if (configHash != other.configHash) return false
if (chapterIndex != other.chapterIndex) return false
if (processingVersion != other.processingVersion) return false
if (pageCacheVersion != other.pageCacheVersion) return false
if (contentVersion != other.contentVersion) return false
if (pageCount != other.pageCount) return false
if (!pagesProto.contentEquals(other.pagesProto)) return false
return true
}
override fun hashCode(): Int {
var result = bookId.hashCode()
result = 31 * result + configHash
result = 31 * result + chapterIndex
result = 31 * result + processingVersion
result = 31 * result + pageCacheVersion
result = 31 * result + contentVersion
result = 31 * result + pageCount
result = 31 * result + pagesProto.contentHashCode()
return result
}
}
@Entity(tableName = "page_cache_metadata", primaryKeys = ["book_id", "config_hash", "chapter_index"])
data class PageCacheMetadata(
@ColumnInfo(name = "book_id") val bookId: String,
@ColumnInfo(name = "config_hash") val configHash: Int,
@ColumnInfo(name = "chapter_index") val chapterIndex: Int,
@ColumnInfo(name = "processing_version") val processingVersion: Int,
@ColumnInfo(name = "page_cache_version") val pageCacheVersion: Int,
@ColumnInfo(name = "content_version") val contentVersion: Int,
@ColumnInfo(name = "page_count") val pageCount: Int
)
@Entity(
tableName = "page_cache_chunks",
primaryKeys = ["book_id", "config_hash", "chapter_index", "chunk_index"],
foreignKeys = [
ForeignKey(
entity = PageCacheMetadata::class,
parentColumns = ["book_id", "config_hash", "chapter_index"],
childColumns = ["book_id", "config_hash", "chapter_index"],
onDelete = ForeignKey.CASCADE
)
],
indices = [Index(value = ["book_id", "config_hash", "chapter_index"])]
)
data class PageCacheChunk(
@ColumnInfo(name = "book_id") val bookId: String,
@ColumnInfo(name = "config_hash") val configHash: Int,
@ColumnInfo(name = "chapter_index") val chapterIndex: Int,
@ColumnInfo(name = "chunk_index") val chunkIndex: Int,
@ColumnInfo(name = "chunk_data", typeAffinity = ColumnInfo.BLOB) val chunkData: ByteArray
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as PageCacheChunk
if (bookId != other.bookId) return false
if (configHash != other.configHash) return false
if (chapterIndex != other.chapterIndex) return false
if (chunkIndex != other.chunkIndex) return false
if (!chunkData.contentEquals(other.chunkData)) return false
return true
}
override fun hashCode(): Int {
var result = bookId.hashCode()
result = 31 * result + configHash
result = 31 * result + chapterIndex
result = 31 * result + chunkIndex
result = 31 * result + chunkData.contentHashCode()
return result
}
}
@Entity(
tableName = "page_index_entries",
primaryKeys = ["book_id", "config_hash", "chapter_index", "page_in_chapter"],
foreignKeys = [
ForeignKey(
entity = PageCacheMetadata::class,
parentColumns = ["book_id", "config_hash", "chapter_index"],
childColumns = ["book_id", "config_hash", "chapter_index"],
onDelete = ForeignKey.CASCADE
)
],
indices = [Index(value = ["book_id", "config_hash", "chapter_index"])]
)
data class PageIndexEntry(
@ColumnInfo(name = "book_id") val bookId: String,
@ColumnInfo(name = "config_hash") val configHash: Int,
@ColumnInfo(name = "chapter_index") val chapterIndex: Int,
@ColumnInfo(name = "page_in_chapter") val pageInChapter: Int,
@ColumnInfo(name = "first_block_index") val firstBlockIndex: Int,
@ColumnInfo(name = "last_block_index") val lastBlockIndex: Int,
@ColumnInfo(name = "first_text_block_index") val firstTextBlockIndex: Int?,
@ColumnInfo(name = "first_text_char_offset") val firstTextCharOffset: Int,
@ColumnInfo(name = "first_text_end_offset") val firstTextEndOffset: Int,
@ColumnInfo(name = "first_cfi") val firstCfi: String?,
@ColumnInfo(name = "anchors") val anchors: String
)

View file

@ -63,7 +63,8 @@ import kotlin.math.abs
data class SerializableEpubChapter(
@ProtoNumber(1) val htmlContent: String,
@ProtoNumber(2) val title: String,
@ProtoNumber(3) val absPath: String
@ProtoNumber(3) val absPath: String,
@ProtoNumber(4) val htmlFilePath: String = absPath
)
@OptIn(ExperimentalSerializationApi::class)
@ -208,7 +209,8 @@ class BookProcessingWorker(
baseFontSizeSp = textStyle.fontSize.value,
density = density.density,
constraints = constraints,
isDarkTheme = false // GUARANTEED LIGHT THEME
isDarkTheme = false,
adaptThemeColors = false
)
lightThemeCssRules = lightThemeCssRules.merge(uaResult.rules)
@ -219,7 +221,8 @@ class BookProcessingWorker(
baseFontSizeSp = textStyle.fontSize.value,
density = density.density,
constraints = constraints,
isDarkTheme = false // GUARANTEED LIGHT THEME
isDarkTheme = false,
adaptThemeColors = false
)
lightThemeCssRules = lightThemeCssRules.merge(bookCssResult.rules)
}
@ -242,7 +245,20 @@ class BookProcessingWorker(
Timber.d("Async task started for chapter index $index.")
if (db.bookCacheDao().getProcessedChapter(bookId, index) == null) {
Timber.d("[BG_PROC] Caching chapter $index: ${chapter.title}")
val document = Jsoup.parse(chapter.htmlContent, chapter.absPath)
val htmlToParse = chapter.htmlContent.ifBlank {
val backingFile = File(extractionBasePath, chapter.htmlFilePath)
if (backingFile.exists()) {
backingFile.readText()
} else {
""
}
}
if (htmlToParse.isBlank()) {
Timber.w("[BG_PROC] Skipping chapter $index because no HTML content was available.")
return@async null
}
val document = Jsoup.parse(htmlToParse, chapter.absPath)
val mathElements = document.select("math")
val svgResults = mutableMapOf<String, String>()
@ -294,7 +310,7 @@ class BookProcessingWorker(
bookId = bookId,
chapterIndex = index,
contentBlocksProto = protoBytes,
estimatedPageCount = 0
estimatedPageCount = estimateSemanticPageCount(semanticBlocks)
)
} else {
Timber.d("Chapter $index was already in the database. Skipping.")
@ -371,4 +387,28 @@ class BookProcessingWorker(
blocks.forEach { walk(it) }
return anchors
}
private fun estimateSemanticPageCount(
blocks: List<com.aryan.reader.paginatedreader.SemanticBlock>
): Int {
var charCount = 0
fun walk(block: com.aryan.reader.paginatedreader.SemanticBlock) {
when (block) {
is com.aryan.reader.paginatedreader.SemanticTextBlock -> {
charCount += block.text.length
}
is com.aryan.reader.paginatedreader.SemanticFlexContainer -> block.children.forEach(::walk)
is com.aryan.reader.paginatedreader.SemanticTable -> {
block.rows.forEach { row -> row.forEach { cell -> cell.content.forEach(::walk) } }
}
is com.aryan.reader.paginatedreader.SemanticList -> block.items.forEach(::walk)
is com.aryan.reader.paginatedreader.SemanticWrappingBlock -> block.paragraphsToWrap.forEach(::walk)
else -> Unit
}
}
blocks.forEach(::walk)
return ((charCount + 2_499) / 2_500).coerceAtLeast(1)
}
}