Windows (#291)
* 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:
parent
88c7fa7b5c
commit
8366d76dcd
214 changed files with 53372 additions and 4702 deletions
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
package com.aryan.reader.paginatedreader
|
||||
|
||||
internal fun resolvePaginatedReconfigurationAnchor(
|
||||
currentPageLocator: Locator?,
|
||||
fallbackLocator: Locator?
|
||||
): Locator? = currentPageLocator ?: fallbackLocator
|
||||
|
|
@ -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() }
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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`)"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue