V1.0.43 oss (#202)

* Added support for AI and Cloud credits in the Pro flavor.

* Implemented credit-based authentication and authorization for AI features and Cloud TTS.

* Updated AI feature access and purchase handling to a credit-based system.

* Refactored and enhanced the Text-to-Speech (TTS) system with persistent caching and a redesigned UI.

* Improved TTS cache management by organizing audio files by book title and adding a detailed cache storage UI.

* Refactored the TTS service to use a WebSocket-based Gemini Live connection for cloud audio generation.

* Removed the TTS cache settings tab and simplified voice sample playback by removing local caching logic.

* Implemented a low-latency streaming mechanism for Cloud TTS using a custom `ConcurrentInputStream` and `ExoPlayer` data source.

* Improved cloud TTS stability and prefetching logic in `TtsService` and `TtsPlaybackManager`.

* Implemented AI summarization caching and cost tracking in the EPUB reader.

* Enhanced chapter summary caching and UI feedback.

* limit summaries for pro users to 10 per day

* Implemented local caching for Cloud TTS audio chunks.

* Removed the Free tier tab from `ProScreen` and simplified the subscription interface. Updated tab logic to focus on Pro and Credits, including a new cost breakdown section for AI and Cloud TTS features.

* Refactored HTML parsing to include all child nodes during content chunking and semantic block parsing.

* Improved image rendering consistency in epub pagination reader

* Improved HTML parsing in `HtmlParser.kt` to better handle complex nested structures

* Improved CSS styling support in the epub paginated reader for word spacing and text decorations.

* Implemented scroll throttling in `epub_reader.js` to improve performance during scroll events

* Improved CFI resolution and scrolling reliability in EPUB reader

* Optimized PaginatedReader performance by caching text decorations.

* Implemented batching for recent file database operations to handle large datasets and introduced `RecentFileSummary` to optimize data retrieval by excluding heavy JSON columns.

* Improved navigation stability by wrapping `navController.navigate` and `popBackStack` calls in a try-catch block to handle `IllegalStateException` during concurrent transitions. Additionally, refined the backstack check for the main route to prevent redundant pops.

* feat(tts): redesign TTS controls with overlay UI and cache management

* Expanded and improved the TTS (Text-to-Speech) capabilities, particularly for Cloud voices.

* Improved TTS playback control and cache management.

* Integrated the TTS cache manager into the settings sheet and improved the TTS configuration UI.

* Updated `DeviceVoicesTab` to respect the current TTS mode, disabling voice selection when not in `BASE` mode.

* Improved error handling and state management for Cloud TTS in `TtsService` and `TtsPlaybackManager`.

* Improved TTS voice selection UI and sample playback logic.

* Updated `TtsUtils` and `TtsService` to remove `chunkIndex` from TTS cache filenames. Refined the cache file naming convention to rely on text and speaker hashes, and updated the cache file filter logic to correctly identify speakers in both legacy and new filename formats.

* Optimized tile rendering and state propagation in PDF viewer

* Added "Expand All", "Collapse All", and "Locate" functionality to the Table of Contents in both EPUB and PDF readers.

* Added sign-in requirement for credit purchases and improved purchase migration logic.

* Updated `EpubReaderTts` to support authenticated TTS requests by passing an auth token provider. The `ttsController.start` method now includes an `authToken` retrieved via `getAuthToken` and explicitly sets the `playbackSource` to "READER".

* feat(ai): replace summarization popup with a comprehensive AI Hub Bottom Sheet

* Improved locator logic and block traversal in `BookPaginator`.

* Updated AI features and Cloud TTS logic.

* Added manual clear and auto-reset functionality for AI summaries and recaps

* Optimized file importing, EPUB parsing, and TTS playback concurrency.

* Restricted TTS mode to BASE in OSS flavor and fixed TTS mode persistence in PDF viewer

* Bump version to 1.0.43(44)
This commit is contained in:
Aryan 2026-04-18 16:46:58 +05:30 committed by GitHub
parent e8f6be2800
commit 46620fa71a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
41 changed files with 4412 additions and 2406 deletions

View file

@ -257,16 +257,11 @@ class BookPaginator(
private fun getAllTextBlocks(blocks: List<ContentBlock>): List<TextContentBlock> {
return blocks.flatMap { block ->
when (block) {
is WrappingContentBlock -> {
Timber.d("PAGINATOR: Found WrappingContentBlock with ${block.paragraphsToWrap.size} paragraphs.")
getAllTextBlocks(block.paragraphsToWrap)
}
is WrappingContentBlock -> getAllTextBlocks(block.paragraphsToWrap)
is FlexContainerBlock -> getAllTextBlocks(block.children)
is TableBlock -> block.rows.flatten().flatMap { getAllTextBlocks(it.content) }
is TextContentBlock -> listOf(block)
else -> {
Timber.d("PAGINATOR: Skipping non-text block of type ${block::class.simpleName}")
emptyList()
}
else -> emptyList()
}
}
}
@ -833,7 +828,17 @@ class BookPaginator(
override fun getPlainTextForChapter(chapterIndex: Int): String? {
val chapter = chapters.getOrNull(chapterIndex) ?: return null
return Jsoup.parse(chapter.htmlContent).body().text()
Timber.tag("POS_DIAG").d("getPlainTextForChapter: chapterIndex=$chapterIndex, chapterTitle='${chapter.title}', hasInMemoryContent=${chapter.htmlContent.isNotEmpty()}")
val htmlToParse = chapter.htmlContent.ifEmpty {
try {
val file = java.io.File(extractionBasePath, chapter.htmlFilePath)
if (file.exists()) file.readText() else ""
} catch (_: Exception) {
""
}
}
if (htmlToParse.isBlank()) return null
return Jsoup.parse(htmlToParse).body().text()
}
private fun calculateAccurateStartIndex(targetChapterIndex: Int): Int {
@ -1075,11 +1080,13 @@ class BookPaginator(
suspend fun findPageForLocator(locator: Locator): Int? {
val targetChapterIndex = locator.chapterIndex
Timber.i("Finding page for locator: Chapter $targetChapterIndex, Block ${locator.blockIndex}, Offset ${locator.charOffset}")
Timber.tag("POS_DIAG").d("findPageForLocator: Searching for $locator")
val chapterPages = pageCache[targetChapterIndex] ?: paginateChapter(targetChapterIndex)
val chapterStartPage = chapterStartPageIndices[targetChapterIndex] ?: 0
Timber.tag("POS_DIAG").d("findPageForLocator: targetChapterIndex=$targetChapterIndex, chapterStartPage=$chapterStartPage, chapterPages.size=${chapterPages?.size}")
if (chapterPages.isNullOrEmpty()) {
Timber.e("Locator navigation failed: Could not paginate target chapter $targetChapterIndex.")
return null
@ -1088,57 +1095,87 @@ class BookPaginator(
var fallbackPageInChapter = -1
for ((pageIndex, page) in chapterPages.withIndex()) {
for (block in page.content) {
if (block.blockIndex == locator.blockIndex) {
Timber.tag("ThemeReconfig").d("Block Index Match: Found block ${locator.blockIndex} on page $pageIndex of Chapter $targetChapterIndex")
val allTextBlocks = getAllTextBlocks(page.content)
if (allTextBlocks.any { it.blockIndex == locator.blockIndex }) {
Timber.tag("POS_DIAG").d("findPageForLocator: Found target blockIndex ${locator.blockIndex} on PageInChapter $pageIndex (Abs ${chapterStartPage + pageIndex})")
}
for (textBlock in allTextBlocks) {
if (textBlock.blockIndex == locator.blockIndex) {
if (fallbackPageInChapter == -1) {
fallbackPageInChapter = pageIndex
}
val startOffsetOnPage = textBlock.startCharOffsetInSource
val endOffsetOnPage = startOffsetOnPage + textBlock.content.length
val textBlock = block as? TextContentBlock
if (textBlock != null) {
val startOffsetOnPage = textBlock.startCharOffsetInSource
val endOffsetOnPage = startOffsetOnPage + textBlock.content.length
Timber.tag("POS_DIAG").d(" -> Block Match: page=$pageIndex, targetOffset=${locator.charOffset}, blockRange=[$startOffsetOnPage, $endOffsetOnPage]")
val isInside = locator.charOffset in startOffsetOnPage..<endOffsetOnPage
Timber.tag("ThemeReconfig").d("Offset Check: Target ${locator.charOffset} vs Range [$startOffsetOnPage, $endOffsetOnPage]. Inside: $isInside")
val isInside = locator.charOffset in startOffsetOnPage..<endOffsetOnPage
if (isInside) {
val finalPageIndex = chapterStartPage + pageIndex
Timber.tag("POS_DIAG").i("findPageForLocator: FOUND match on absolute page $finalPageIndex")
return finalPageIndex
}
if (isInside) {
val finalPageIndex = chapterStartPage + pageIndex
return finalPageIndex
}
} else {
return chapterStartPage + pageIndex
if (textBlock.content.isEmpty() && locator.charOffset == startOffsetOnPage) {
val finalPageIndex = chapterStartPage + pageIndex
Timber.tag("POS_DIAG").i("findPageForLocator: FOUND empty block match on absolute page $finalPageIndex")
return finalPageIndex
}
}
}
if (fallbackPageInChapter == -1) {
for (block in page.content) {
if (block.blockIndex == locator.blockIndex) {
fallbackPageInChapter = pageIndex
break
}
}
}
}
Timber.tag("ThemeReconfig").e("Block Index NOT FOUND: Could not find block ${locator.blockIndex} in any page of Chapter $targetChapterIndex")
if (fallbackPageInChapter != -1) {
val finalPageIndex = chapterStartPage + fallbackPageInChapter
Timber.w("Locator offset not found. Using FALLBACK page. Final page index: $finalPageIndex")
Timber.tag("POS_DIAG").w("findPageForLocator: Exact offset not found, using block-start fallback page $finalPageIndex")
return finalPageIndex
}
Timber.e("Locator navigation FAILED. Block ${locator.blockIndex} not found in chapter $targetChapterIndex.")
Timber.tag("POS_DIAG").e("findPageForLocator: FAILED to resolve locator in chapter $targetChapterIndex")
return null
}
fun getLocatorForPage(pageIndex: Int): Locator? {
val chapterIndex = findChapterIndexForPage(pageIndex) ?: return null
val chStart = chapterStartPageIndices[chapterIndex] ?: 0
Timber.tag("POS_DIAG").d("getLocatorForPage: Request pageIndex=$pageIndex. Resolved chapterIndex=$chapterIndex (starts at $chStart). PageInChapter=${pageIndex - chStart}")
val pageContent = getPageContent(pageIndex) ?: return null
val firstTextBlock = pageContent.content.firstOrNull { it is TextContentBlock } as? TextContentBlock
val targetBlock = firstTextBlock ?: pageContent.content.firstOrNull() ?: return null
val charOffset = (targetBlock as? TextContentBlock)?.startCharOffsetInSource ?: 0
Timber.tag("POS_DIAG").d("getLocatorForPage: Inspecting page $pageIndex (chapter=$chapterIndex). Total top-level blocks=${pageContent.content.size}")
return Locator(
chapterIndex = chapterIndex,
blockIndex = targetBlock.blockIndex,
charOffset = charOffset
)
val allTextBlocks = getAllTextBlocks(pageContent.content)
val firstTextBlock = allTextBlocks.firstOrNull { it.content.text.isNotBlank() } ?: allTextBlocks.firstOrNull()
Timber.tag("POS_DIAG").d("getLocatorForPage: allTextBlocks count=${allTextBlocks.size}. Selected blockIndex=${firstTextBlock?.blockIndex}, charOffset=${firstTextBlock?.startCharOffsetInSource}, text snippet='${firstTextBlock?.content?.text?.take(20)?.replace("\n", " ")}'")
if (firstTextBlock != null) {
val locator = Locator(
chapterIndex = chapterIndex,
blockIndex = firstTextBlock.blockIndex,
charOffset = firstTextBlock.startCharOffsetInSource
)
Timber.tag("POS_DIAG").d("getLocatorForPage: Generated $locator for absolute page $pageIndex")
return locator
} else {
val firstBlock = pageContent.content.firstOrNull() ?: return null
val locator = Locator(
chapterIndex = chapterIndex,
blockIndex = firstBlock.blockIndex,
charOffset = 0
)
Timber.tag("POS_DIAG").d("getLocatorForPage: Generated fallback $locator for absolute page $pageIndex")
return locator
}
}
override fun findPageForCfi(chapterIndex: Int, cfi: String, onResult: (pageIndex: Int) -> Unit) {

View file

@ -26,6 +26,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.ParagraphStyle
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontFamily
@ -33,6 +34,7 @@ import androidx.compose.ui.text.style.BaselineShift
import androidx.compose.ui.text.style.Hyphens
import androidx.compose.ui.text.style.LineBreak
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
@ -250,7 +252,17 @@ class ContentStyler(
)
}
return style.copy(spanStyle = newSpanStyle, blockStyle = newBlockStyle)
val newTextDecorationColor = if (style.textDecorationColor.isSpecified) {
CssParser.adaptColorForTheme(style.textDecorationColor, isDarkTheme, isBackground = false, themeBackgroundColor, themeTextColor)
} else {
style.textDecorationColor
}
return style.copy(
spanStyle = newSpanStyle,
blockStyle = newBlockStyle,
textDecorationColor = newTextDecorationColor
)
}
private fun embedImagesInSvg(svgContent: String): String {
@ -402,12 +414,44 @@ class ContentStyler(
else -> null
}
val finalSpanStyle = themedSpanStyle.spanStyle.copy(
var finalSpanStyle = themedSpanStyle.spanStyle.copy(
fontFamily = effectiveSpanFontFamily,
baselineShift = baselineShift
)
val hasCustomDeco = themedSpanStyle.textDecorationStyle != null ||
themedSpanStyle.textDecorationColor.isSpecified ||
themedSpanStyle.textUnderlineOffset.isSpecified
val combinedDeco = finalSpanStyle.textDecoration ?: TextDecoration.None
if (hasCustomDeco && combinedDeco.contains(TextDecoration.Underline)) {
val decos = mutableListOf<TextDecoration>()
if (combinedDeco.contains(TextDecoration.LineThrough)) decos.add(TextDecoration.LineThrough)
finalSpanStyle = finalSpanStyle.copy(
textDecoration = if (decos.isNotEmpty()) TextDecoration.combine(decos) else TextDecoration.None
)
val styleStr = themedSpanStyle.textDecorationStyle ?: "solid"
val colorStr = if (themedSpanStyle.textDecorationColor.isSpecified) themedSpanStyle.textDecorationColor.value.toString() else "Unspecified"
val offsetStr = if (themedSpanStyle.textUnderlineOffset.isSpecified) themedSpanStyle.textUnderlineOffset.value.toString() else "0"
val annotationData = "$styleStr|$colorStr|$offsetStr"
addStringAnnotation("CustomUnderline", annotationData, span.start, span.end)
}
addStyle(initialSpanStyle.merge(finalSpanStyle), span.start, span.end)
val ws = themedSpanStyle.wordSpacing
if (ws.isSpecified && ws.value != 0f) {
val textToStyle = block.text.substring(span.start, span.end)
for (i in textToStyle.indices) {
if (textToStyle[i] == ' ') {
addStyle(SpanStyle(letterSpacing = ws), span.start + i, span.start + i + 1)
}
}
}
if (span.linkHref != null) {
addStringAnnotation("URL", span.linkHref, span.start, span.end)
}

View file

@ -34,7 +34,7 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.em
import androidx.compose.ui.unit.sp
import androidx.compose.ui.unit.isSpecified
import timber.log.Timber
import java.io.File
import java.util.regex.Pattern
@ -427,6 +427,11 @@ object CssParser {
var marginBottomStr: String? = null
var marginLeftStr: String? = null
var wordSpacing: TextUnit = TextUnit.Unspecified
var textDecorationStyle: String? = null
var textDecorationColor: Color = Color.Unspecified
var textUnderlineOffset: Dp = Dp.Unspecified
var borderTopWidth: Dp? = null
var borderRightWidth: Dp? = null
var borderBottomWidth: Dp? = null
@ -566,14 +571,42 @@ object CssParser {
}
}
"text-decoration" -> {
spanStyle = spanStyle.copy(
textDecoration = when(value) {
"underline" -> TextDecoration.Underline
"line-through" -> TextDecoration.LineThrough
"none" -> TextDecoration.None
else -> spanStyle.textDecoration
}
)
val parts = value.split(" ")
val decos = mutableListOf<TextDecoration>()
if (parts.contains("underline")) decos.add(TextDecoration.Underline)
if (parts.contains("line-through")) decos.add(TextDecoration.LineThrough)
if (parts.contains("none")) {
spanStyle = spanStyle.copy(textDecoration = TextDecoration.None)
} else if (decos.isNotEmpty()) {
spanStyle = spanStyle.copy(textDecoration = TextDecoration.combine(decos))
}
val styles = listOf("solid", "double", "dotted", "dashed", "wavy")
parts.firstOrNull { it in styles }?.let { textDecorationStyle = it }
parts.firstNotNullOfOrNull { parseColor(it) }?.let { color ->
textDecorationColor = this@CssParser.adaptColorForTheme(color, isDarkTheme, isBackground = false, themeBackgroundColor, themeTextColor)
}
}
"word-spacing" -> {
val trimmedValue = value.trim()
wordSpacing = if (trimmedValue.lowercase() == "normal") {
TextUnit.Unspecified
} else {
parseCssDimensionToTextUnit(value, containerWidthPx, density)
}
}
"text-decoration-style" -> {
textDecorationStyle = value
}
"text-decoration-color" -> {
parseColor(value)?.let {
textDecorationColor = this@CssParser.adaptColorForTheme(it, isDarkTheme, isBackground = false, themeBackgroundColor, themeTextColor)
}
}
"text-underline-offset" -> {
textUnderlineOffset = parseCssSizeToDp(value, baseFontSizeSp, density, containerWidthPx)
}
"letter-spacing" -> {
val letterSpacing = parseCssDimensionToTextUnit(value, containerWidthPx, density)
@ -623,9 +656,9 @@ object CssParser {
"padding-left" -> padding = padding.copy(left = parseCssSizeToDp(value, baseFontSizeSp, density, containerWidthPx))
"padding-right" -> padding = padding.copy(right = parseCssSizeToDp(value, baseFontSizeSp, density, containerWidthPx))
"width" -> width = parseCssSizeToDp(value, baseFontSizeSp, density, containerWidthPx)
"max-width" -> maxWidth = parseCssSizeToDp(value, baseFontSizeSp, density, containerWidthPx)
"height" -> height = parseCssSizeToDp(value, baseFontSizeSp, density, containerWidthPx)
"width" -> width = parseCssDimension(value, baseFontSizeSp, density, containerWidthPx)
"max-width" -> maxWidth = parseCssDimension(value, baseFontSizeSp, density, containerWidthPx)
"height" -> height = parseCssDimension(value, baseFontSizeSp, density, containerWidthPx)
"background-color" -> {
val originalColor = parseColor(value) ?: Color.Unspecified
@ -872,7 +905,10 @@ object CssParser {
borderCollapse = borderCollapse,
borderSpacing = borderSpacing
)
return CssStyle(spanStyle, paragraphStyle, blockStyle, fontFamilies, display, fontSize, textTransform, boxSizing, content, hyphens, fontVariantNumeric, textEmphasis)
return CssStyle(
spanStyle, paragraphStyle, blockStyle, fontFamilies, display, fontSize, textTransform, boxSizing, content, hyphens, fontVariantNumeric, textEmphasis,
wordSpacing, textDecorationStyle, textDecorationColor, textUnderlineOffset
)
}
private fun parseShorthand4(value: String, baseFontSize: Float, density: Float, containerWidth: Int): List<Dp> {
@ -939,7 +975,46 @@ object CssParser {
return Triple(w, s, c)
}
// ADD the parseCssSizeToDp function here at the bottom of the object or file
internal fun parseCssDimension(
size: String,
baseFontSizeSp: Float,
density: Float,
containerWidthPx: Int
): Dp {
val trimmed = size.trim().lowercase()
if (trimmed in listOf("auto", "none", "max-content", "min-content", "fit-content", "inherit", "initial")) {
return Dp.Unspecified
}
if (trimmed == "0" || trimmed == "0px") return 0.dp
return when {
trimmed.endsWith("px") -> trimmed.removeSuffix("px").toFloatOrNull()?.let { (it / density).dp } ?: Dp.Unspecified
trimmed.endsWith("dp") -> trimmed.removeSuffix("dp").toFloatOrNull()?.dp ?: Dp.Unspecified
trimmed.endsWith("em") -> trimmed.removeSuffix("em").toFloatOrNull()?.let { (it * baseFontSizeSp).dp } ?: Dp.Unspecified
trimmed.endsWith("rem") -> trimmed.removeSuffix("rem").toFloatOrNull()?.let { (it * baseFontSizeSp).dp } ?: Dp.Unspecified
trimmed.endsWith("pt") -> trimmed.removeSuffix("pt").toFloatOrNull()?.let { (it * 1.33f).dp } ?: Dp.Unspecified
trimmed.endsWith("%") -> {
val percent = trimmed.removeSuffix("%").toFloatOrNull()
if (percent != null) {
((percent / 100f) * containerWidthPx / density).dp
} else {
Dp.Unspecified
}
}
trimmed.endsWith("vw") -> {
val percent = trimmed.removeSuffix("vw").toFloatOrNull()
if (percent != null) {
((percent / 100f) * containerWidthPx / density).dp
} else {
Dp.Unspecified
}
}
trimmed.endsWith("vh") -> Dp.Unspecified
trimmed.toFloatOrNull() != null -> (trimmed.toFloat() / density).dp
else -> Dp.Unspecified
}
}
internal fun parseCssSizeToDp(
size: String,
baseFontSizeSp: Float,
@ -947,45 +1022,9 @@ object CssParser {
containerWidthPx: Int
): Dp {
val trimmed = size.trim().lowercase()
// Handle keywords
BORDER_WIDTH_KEYWORDS[trimmed]?.let { return it }
if (trimmed == "0" || trimmed == "0px") return 0.dp
return when {
trimmed.endsWith("px") -> trimmed.removeSuffix("px").toFloatOrNull()?.let { (it / density).dp } ?: 0.dp
trimmed.endsWith("dp") -> trimmed.removeSuffix("dp").toFloatOrNull()?.dp ?: 0.dp
trimmed.endsWith("em") -> trimmed.removeSuffix("em").toFloatOrNull()?.let { (it * baseFontSizeSp).dp } ?: 0.dp
trimmed.endsWith("rem") -> trimmed.removeSuffix("rem").toFloatOrNull()?.let { (it * baseFontSizeSp).dp } ?: 0.dp
trimmed.endsWith("pt") -> trimmed.removeSuffix("pt").toFloatOrNull()?.let { (it * 1.33f).dp } ?: 0.dp // 1pt ≈ 1.33px
trimmed.endsWith("%") -> {
val percent = trimmed.removeSuffix("%").toFloatOrNull()
if (percent != null) {
((percent / 100f) * containerWidthPx / density).dp
} else {
0.dp
}
}
trimmed.toFloatOrNull() != null -> (trimmed.toFloat() / density).dp
else -> 0.dp
}
}
internal fun parseCssDimensionToTextUnit(
dimension: String?,
containerWidthPx: Int,
density: Float
): TextUnit {
if (dimension.isNullOrBlank()) return TextUnit.Unspecified
val trimmed = dimension.trim().lowercase()
return when {
trimmed.endsWith("px") -> trimmed.removeSuffix("px").toFloatOrNull()?.sp ?: TextUnit.Unspecified
trimmed.endsWith("em") -> trimmed.removeSuffix("em").toFloatOrNull()?.em ?: TextUnit.Unspecified
trimmed.endsWith("rem") -> trimmed.removeSuffix("rem").toFloatOrNull()?.em ?: TextUnit.Unspecified
trimmed.endsWith("%") -> trimmed.removeSuffix("%").toFloatOrNull()?.let { (it / 100f).em } ?: TextUnit.Unspecified
trimmed.endsWith("pt") -> trimmed.removeSuffix("pt").toFloatOrNull()?.let { (it * 1.33f).sp } ?: TextUnit.Unspecified
else -> TextUnit.Unspecified
}
val dim = parseCssDimension(size, baseFontSizeSp, density, containerWidthPx)
return if (dim.isSpecified) dim else 0.dp
}
internal fun parseColor(colorString: String): Color? {

View file

@ -23,15 +23,18 @@ import android.graphics.BitmapFactory
import android.os.Build
import timber.log.Timber
import androidx.annotation.RequiresApi
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.text.ParagraphStyle
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.isSpecified
import androidx.compose.ui.unit.sp
import org.jsoup.Jsoup
import org.jsoup.nodes.Element
import org.jsoup.nodes.Node
@ -131,7 +134,7 @@ private class SemanticHtmlParser(
baseFontSizeSp = textStyle.fontSize.value,
density = density.density,
constraints = constraints,
isDarkTheme = false // Semantic parsing is always theme-agnostic
isDarkTheme = false
)
if (inlineParseResult.fontFaces.isNotEmpty()) {
@ -144,9 +147,7 @@ private class SemanticHtmlParser(
}
val body = document.body()
return body.children().flatMap { childElement ->
parseNodeToSemanticBlocks(childElement, getElementStyle(body))
}
return parseContainer(body, getElementStyle(body))
}
private fun parseNodeToSemanticBlocks(
@ -267,9 +268,15 @@ private class SemanticHtmlParser(
elementStyle.blockStyle.borderBottomLeftRadius > 0.dp
if (hasBoxStyles) {
val children = element.children().flatMap { child ->
parseNodeToSemanticBlocks(child, elementStyle)
}
val childStyle = elementStyle.copy(
blockStyle = elementStyle.blockStyle.copy(
backgroundColor = Color.Unspecified,
borderTop = null, borderRight = null, borderBottom = null, borderLeft = null,
padding = BoxBorders(),
margin = BoxBorders()
)
)
val children = parseContainer(element, childStyle)
listOf(SemanticFlexContainer(children, elementStyle, elementId, cfi, blockIndex = nextBlockIndex++))
} else {
parseContainer(element, elementStyle)
@ -280,16 +287,57 @@ private class SemanticHtmlParser(
"math-placeholder" -> parseMathPlaceholderToSemantic(element, elementStyle)
"img" -> parseImageElementToSemantic(element, elementStyle)?.let { listOf(it) } ?: emptyList()
"h1", "h2", "h3", "h4", "h5", "h6" -> {
val (text, spans) = buildSemanticTextAndSpans(element, elementStyle)
if (text.isNotBlank()) {
val hasNonTextChildren = element.select("img, svg, math-placeholder, table, hr, div, p, h1, h2, h3, h4, h5, h6, ul, ol, li, blockquote, figure, article, aside, header, footer, nav, section, main").isNotEmpty()
if (hasNonTextChildren) {
val level = tagName.substring(1).toIntOrNull() ?: 1
listOf(SemanticHeader(level, text, spans, elementStyle, elementId, cfi, blockIndex = nextBlockIndex++))
} else emptyList()
val fontSizeMultiplier = when (level) {
1 -> 1.5f; 2 -> 1.4f; 3 -> 1.3f; 4 -> 1.2f; 5 -> 1.1f; else -> 1.0f
}
val headerStyle = elementStyle.copy(
spanStyle = elementStyle.spanStyle.copy(
fontWeight = FontWeight.Bold,
fontSize = (textStyle.fontSize.value * fontSizeMultiplier).sp
)
)
val hasBoxStyles = headerStyle.blockStyle.backgroundColor.isSpecified ||
headerStyle.blockStyle.borderTop != null ||
headerStyle.blockStyle.borderRight != null ||
headerStyle.blockStyle.borderBottom != null ||
headerStyle.blockStyle.borderLeft != null ||
headerStyle.blockStyle.padding != BoxBorders() ||
headerStyle.blockStyle.borderTopLeftRadius > 0.dp ||
headerStyle.blockStyle.borderTopRightRadius > 0.dp ||
headerStyle.blockStyle.borderBottomRightRadius > 0.dp ||
headerStyle.blockStyle.borderBottomLeftRadius > 0.dp
if (hasBoxStyles) {
val childStyle = headerStyle.copy(
blockStyle = headerStyle.blockStyle.copy(
backgroundColor = Color.Unspecified,
borderTop = null, borderRight = null, borderBottom = null, borderLeft = null,
padding = BoxBorders(),
margin = BoxBorders()
)
)
val children = parseContainer(element, childStyle)
listOf(SemanticFlexContainer(children, headerStyle, elementId, cfi, blockIndex = nextBlockIndex++))
} else {
parseContainer(element, headerStyle)
}
} else {
val (text, spans) = buildSemanticTextAndSpans(element, elementStyle)
if (text.isNotBlank()) {
val level = tagName.substring(1).toIntOrNull() ?: 1
listOf(SemanticHeader(level, text, spans, elementStyle, elementId, cfi, blockIndex = nextBlockIndex++))
} else emptyList()
}
}
"hr" -> listOf(SemanticSpacer(style = elementStyle, elementId = elementId, cfi = cfi, blockIndex = nextBlockIndex++))
"ul", "ol" -> parseListElementToSemantic(element, elementStyle)
else -> {
if (element.isBlock) {
val hasBlockDescendant = !element.isBlock && element.select("img, svg, math-placeholder, hr, table, div, p, h1, h2, h3, h4, h5, h6, ul, ol, li, blockquote, figure, article, aside, header, footer, nav, section, main").isNotEmpty()
if (element.isBlock || hasBlockDescendant) {
parseContainer(element, elementStyle)
} else {
val (text, spans) = buildSemanticTextAndSpans(element, elementStyle)
@ -316,13 +364,30 @@ private class SemanticHtmlParser(
if (textNodesBuffer.isEmpty()) return
val (text, spans) = buildSemanticTextAndSpansFromNodes(textNodesBuffer, style)
if (text.isNotBlank()) {
children.add(SemanticParagraph(text, spans, style, element.id().ifBlank { null }, element.getCfiPath(), blockIndex = nextBlockIndex++)) }
val finalSpans = spans.toMutableList()
if (element.tagName().lowercase() == "a") {
val href = element.attr("href").ifBlank { null }
if (href != null) {
finalSpans.add(SemanticSpan(
start = 0,
end = text.length,
style = style,
linkHref = href,
tag = "a",
elementId = element.id().ifBlank { null }
))
}
}
children.add(SemanticParagraph(text, finalSpans, style, element.id().ifBlank { null }, element.getCfiPath(), blockIndex = nextBlockIndex++))
}
textNodesBuffer.clear()
}
element.childNodes().forEach { node ->
if (node is Element) {
val isEffectivelyBlock = node.isBlock || node.tagName().lowercase() in listOf("img", "svg", "math-placeholder", "hr")
val tagName = node.tagName().lowercase()
val isEffectivelyBlock = node.isBlock || tagName in listOf("img", "svg", "math-placeholder", "hr") ||
(!node.isBlock && node.select("img, svg, math-placeholder, hr, table, div, p, h1, h2, h3, h4, h5, h6, ul, ol, li, blockquote, figure, article, aside, header, footer, nav, section, main").isNotEmpty())
if (isEffectivelyBlock) {
flushTextBuffer()
@ -462,8 +527,12 @@ private class SemanticHtmlParser(
try {
BitmapFactory.Options().apply { inJustDecodeBounds = true }
.also { BitmapFactory.decodeFile(imageFile.absolutePath, it) }
.let { Pair(it.outWidth.toFloat(), it.outHeight.toFloat()) }
} catch (_: Exception) {
.let {
Timber.tag("IMAGE_DIAG").d("Parsed file bounds: ${it.outWidth}x${it.outHeight} for ${imageFile.name}")
Pair(it.outWidth.toFloat(), it.outHeight.toFloat())
}
} catch (e: Exception) {
Timber.tag("IMAGE_DIAG").e(e, "Failed to parse image bounds for ${imageFile.name}")
Pair(null, null)
}
}

View file

@ -142,6 +142,7 @@ class LocatorConverter(
}
suspend fun getLocatorFromCfi(book: EpubBook, chapterIndex: Int, cfi: String): 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)
var allBlocks: List<SemanticBlock>? = null
@ -167,14 +168,15 @@ class LocatorConverter(
val bestMatch = findBestMatchingBlock(allBlocks, baseCfiPath)
if (bestMatch != null) {
Timber.tag("PosSaveDiag").d("Found best match for baseCfiPath $baseCfiPath -> blockIndex=${bestMatch.blockIndex}, actualBlockCfi=${bestMatch.cfi}")
Locator(
val locator = Locator(
chapterIndex = chapterIndex,
blockIndex = bestMatch.blockIndex,
charOffset = charOffset
)
Timber.tag("POS_DIAG").d("getLocatorFromCfi: Successfully resolved to $locator")
locator
} else {
Timber.tag("PosSaveDiag").e("No semantic block match found for baseCfiPath $baseCfiPath inside ${allBlocks.size} parsed blocks")
Timber.tag("POS_DIAG").e("getLocatorFromCfi: Failed to find semantic block match for CFI path $baseCfiPath")
null
}
}
@ -201,15 +203,20 @@ class LocatorConverter(
.filter { it.cfi != null }
.map { block ->
val blockCfi = block.cfi!!
val isPrefix = inputCfi == blockCfi || inputCfi.startsWith("$blockCfi/")
val prefixScore = if (isPrefix) blockCfi.length else 0
var i = inputCfi.length - 1
var j = blockCfi.length - 1
var length = 0
var suffixScore = 0
while (i >= 0 && j >= 0 && inputCfi[i] == blockCfi[j]) {
length++
suffixScore++
i--
j--
}
Pair(block, length)
Pair(block, maxOf(prefixScore, suffixScore))
}
.maxByOrNull { it.second }
?.first
@ -218,6 +225,7 @@ class LocatorConverter(
}
suspend fun getCfiFromLocator(book: EpubBook, locator: Locator): String? = withContext(Dispatchers.IO) {
Timber.tag("POS_DIAG").d("getCfiFromLocator: Input $locator")
val processedChapter = bookCacheDao.getProcessedChapter(bookId = book.title, chapterIndex = locator.chapterIndex)
var blocks: List<SemanticBlock>? = null
@ -236,13 +244,15 @@ class LocatorConverter(
}
val foundBlock = findBlockByBlockIndex(blocks, locator.blockIndex)
foundBlock?.cfi?.let { cfi ->
val resultCfi = foundBlock?.cfi?.let { cfi ->
if (locator.charOffset > 0) {
"$cfi:${locator.charOffset}"
} else {
cfi
}
}
Timber.tag("POS_DIAG").d("getCfiFromLocator: Resulting CFI='$resultCfi'")
resultCfi
}
private fun findBlockByBlockIndex(blocks: List<SemanticBlock>, targetBlockIndex: Int): SemanticBlock? {

View file

@ -1,4 +1,6 @@
// PaginatedReader.kt
@file:Suppress("VariableNeverRead")
package com.aryan.reader.paginatedreader
import android.annotation.SuppressLint
@ -9,6 +11,7 @@ import android.content.Context
import android.content.Intent
import android.os.Build
import android.widget.Toast
import androidx.compose.ui.unit.isSpecified
import androidx.annotation.RequiresApi
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
@ -24,7 +27,6 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
@ -49,6 +51,7 @@ import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
@ -73,6 +76,8 @@ import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.ColorMatrix
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.drawscope.Fill
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.clipPath
@ -614,6 +619,18 @@ fun PaginatedReaderScreen(
)
}
LaunchedEffect(pagerState) {
snapshotFlow { pagerState.currentPage }.collect { page ->
Timber.tag("PageTurnDiag").i("Pager Settled: Now on page $page at ${System.currentTimeMillis()}")
}
}
LaunchedEffect(pagerState) {
snapshotFlow { pagerState.isScrollInProgress }.collect { isScrolling ->
Timber.tag("PageTurnDiag").d("Pager Scroll State: isScrolling=$isScrolling")
}
}
LaunchedEffect(fontSizeMultiplier, lineHeightMultiplier, paragraphGapMultiplier, fontFamily, textAlign) {
if (fontSizeMultiplier != debouncedFontSizeMult || lineHeightMultiplier != debouncedLineHeightMult || paragraphGapMultiplier != debouncedParagraphGapMult || fontFamily != debouncedFontFamily || textAlign != debouncedTextAlign) {
Timber.d("Formatting changed. Waiting for debounce.")
@ -755,7 +772,7 @@ fun PaginatedReaderScreen(
LaunchedEffect(paginator) {
if (anchorLocatorForReconfig != null) {
Timber.tag("ThemeReconfig").d("Restoration Effect Triggered for Locator: $anchorLocatorForReconfig")
Timber.tag("POS_DIAG").d("Restoration Triggered. Anchor Locator: $anchorLocatorForReconfig")
snapshotFlow { paginator.isLoading }.filter { !it }.first()
@ -763,19 +780,15 @@ fun PaginatedReaderScreen(
if (targetLocator != null) {
val page = paginator.findPageForLocator(targetLocator)
Timber.tag("ThemeReconfig").d("""
Restoration Progress:
- Target Locator: $targetLocator
- Paginator found Page: $page
- Chapter Start Page: ${paginator.chapterStartPageIndices[targetLocator.chapterIndex]}
""".trimIndent())
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("ThemeReconfig").w("Precise page not found, falling back to chapter start: $startPage")
Timber.tag("POS_DIAG").w("Restoration: Precise page not found, falling back to chapter start: $startPage")
pagerState.scrollToPage(startPage)
}
}
@ -831,7 +844,15 @@ fun PaginatedReaderScreen(
textStyle = textStyle,
horizontalPadding = horizontalPadding,
verticalPadding = verticalPadding,
onGetPage = { pageIndex -> paginator.getPageContent(pageIndex) },
onGetPage = { pageIndex ->
val startTime = System.currentTimeMillis()
val result = paginator.getPageContent(pageIndex)
val duration = System.currentTimeMillis() - startTime
if (duration > 16) {
Timber.tag("PageTurnDiag").w("HEAVY TASK: paginator.getPageContent($pageIndex) took ${duration}ms on Thread ${Thread.currentThread().name}")
}
result
},
onGetChapterPath = { pageIndex -> paginator.getChapterPathForPage(pageIndex) },
onGetChapterInfo = { pageIndex ->
paginator.findChapterIndexForPage(pageIndex)?.let { chapterIndex ->
@ -1186,8 +1207,206 @@ private fun TextWithEmphasis(
var layoutCoordinates by remember { mutableStateOf<LayoutCoordinates?>(null) }
val scope = rememberCoroutineScope()
var pressedHighlightCfi by remember { mutableStateOf<String?>(null) }
val density = LocalDensity.current
data class EmphasisMarkInfo(val center: Offset, val radius: Float, val color: Color)
data class UnderlineDrawInfo(val path: Path?, val effect: PathEffect?, val minX: Float, val maxX: Float, val y: Float, val decoStyle: String, val decoColor: Color)
// --- CACHING DECORATIONS FOR PERFORMANCE ---
val cachedHighlights = remember(block, userHighlights, textLayoutResult, pressedHighlightCfi) {
val startTime = System.currentTimeMillis()
val paths = mutableListOf<Pair<Path, Color>>()
val layout = textLayoutResult
if (layout != null && block.cfi != null && userHighlights.isNotEmpty()) {
userHighlights.forEach { highlight ->
val range = getHighlightOffsetsInBlock(block, highlight)
if (range != null) {
try {
val path = layout.getPathForRange(range.first, range.last + 1)
paths.add(path to highlight.color.color.copy(alpha = 0.4f))
if (highlight.cfi == pressedHighlightCfi) {
paths.add(path to Color.Black.copy(alpha = 0.1f))
}
} catch (e: Exception) {
Timber.tag("DecorationsDiag").e(e, "Highlight path out of bounds")
}
}
}
}
val duration = System.currentTimeMillis() - startTime
if (duration > 5) {
Timber.tag("DecorationsDiag").w("Calculated highlight paths for block ${block.blockIndex} in ${duration}ms")
}
paths
}
val cachedEmphasisMarks = remember(textLayoutResult, text, style.color, density) {
val startTime = System.currentTimeMillis()
val marks = mutableListOf<EmphasisMarkInfo>()
val layout = textLayoutResult
if (layout != null) {
val emphasisAnnotations = text.getStringAnnotations("TextEmphasis", 0, text.length)
if (emphasisAnnotations.isNotEmpty()) {
with(density) { // Provides the scope for .toPx()
emphasisAnnotations.forEach { annotation ->
val emphasis = parseEmphasisAnnotation(annotation.item, style.color)
val markColor = if (emphasis.color.isSpecified) emphasis.color else style.color
val markSize = layout.layoutInput.style.fontSize.toPx() * 0.3f
for (offset in annotation.start until annotation.end) {
if (offset >= text.text.length || text.text[offset].isWhitespace()) continue
try {
val boundingBox = layout.getBoundingBox(offset)
val center = Offset(
boundingBox.center.x,
if (emphasis.position == "under") boundingBox.bottom + markSize * 0.1f
else boundingBox.top - markSize * 0.1f
)
marks.add(EmphasisMarkInfo(center, markSize / 2, markColor))
} catch (e: Exception) {
Timber.tag("DecorationsDiag").e(e, "Emphasis mark out of bounds")
}
}
}
}
}
}
val duration = System.currentTimeMillis() - startTime
if (duration > 5) {
Timber.tag("DecorationsDiag").w("Calculated emphasis marks for block ${block.blockIndex} in ${duration}ms")
}
marks
}
val cachedUnderlines = remember(textLayoutResult, text, style.color, density) {
val startTime = System.currentTimeMillis()
val lines = mutableListOf<UnderlineDrawInfo>()
val layout = textLayoutResult
if (layout != null) {
val customUnderlines = text.getStringAnnotations("CustomUnderline", 0, text.length)
if (customUnderlines.isNotEmpty()) {
val maxIdx = maxOf(0, text.length - 1)
val groupedUnderlines = customUnderlines.groupBy { it.item }
val mergedUnderlines = mutableListOf<AnnotatedString.Range<String>>()
groupedUnderlines.forEach { (item, annotations) ->
val sorted = annotations.sortedBy { it.start }
var currentStart = -1
var currentEnd = -1
for (ann in sorted) {
if (currentStart == -1) {
currentStart = ann.start
currentEnd = ann.end
} else if (ann.start <= currentEnd) {
currentEnd = maxOf(currentEnd, ann.end)
} else {
mergedUnderlines.add(AnnotatedString.Range(item, currentStart, currentEnd))
currentStart = ann.start
currentEnd = ann.end
}
}
if (currentStart != -1) {
mergedUnderlines.add(AnnotatedString.Range(item, currentStart, currentEnd))
}
}
with(density) {
mergedUnderlines.forEach { annotation ->
val parts = annotation.item.split('|')
val decoStyle = parts.getOrNull(0) ?: "solid"
val colorStr = parts.getOrNull(1) ?: "Unspecified"
val decoColor = if (colorStr != "Unspecified") Color(colorStr.toULong()) else style.color
val safeStart = annotation.start.coerceIn(0, text.length)
val safeEnd = annotation.end.coerceIn(0, text.length)
if (safeStart < safeEnd) {
val startLine = layout.getLineForOffset(safeStart.coerceIn(0, maxIdx))
val endLine = layout.getLineForOffset((safeEnd - 1).coerceIn(0, maxIdx))
for (line in startLine..endLine) {
val lineStart = layout.getLineStart(line)
val lineEnd = layout.getLineEnd(line, visibleEnd = true)
val intersectionStart = maxOf(safeStart, lineStart)
val intersectionEnd = minOf(safeEnd, lineEnd)
var actualStart = intersectionStart
while (actualStart < intersectionEnd && text[actualStart].isWhitespace()) {
actualStart++
}
var actualEnd = intersectionEnd
while (actualEnd > actualStart && text[actualEnd - 1].isWhitespace()) {
actualEnd--
}
if (actualStart < actualEnd) {
var minX = Float.POSITIVE_INFINITY
var maxX = Float.NEGATIVE_INFINITY
for (i in actualStart until actualEnd) {
try {
val box = layout.getBoundingBox(i)
minX = minOf(minX, box.left, box.right)
maxX = maxOf(maxX, box.left, box.right)
} catch (e: Exception) {
Timber.tag("DecorationsDiag").e(e, "Underline box out of bounds")
}
}
if (minX < maxX && !minX.isInfinite() && !maxX.isInfinite()) {
val baseline = layout.getLineBaseline(line)
val defaultOffset = layout.layoutInput.style.fontSize.toPx() * 0.1f
val requestedOffset = parts.getOrNull(2)?.toFloatOrNull()?.dp?.toPx()
val y = baseline + (requestedOffset ?: defaultOffset)
var underlinePath: Path? = null
var effect: PathEffect? = null
when (decoStyle) {
"wavy" -> {
underlinePath = Path()
underlinePath.moveTo(minX, y)
val waveLength = 4.dp.toPx()
val amplitude = 1.dp.toPx()
var currentX = minX
var isUp = true
while (currentX < maxX) {
val nextX = minOf(currentX + waveLength / 2f, maxX)
val midX = currentX + (nextX - currentX) / 2f
val cpY = if (isUp) y - amplitude else y + amplitude
underlinePath.quadraticTo(midX, cpY, nextX, y)
currentX = nextX
isUp = !isUp
}
}
"dashed" -> {
effect = PathEffect.dashPathEffect(floatArrayOf(4.dp.toPx(), 4.dp.toPx()))
}
"dotted" -> {
effect = PathEffect.dashPathEffect(floatArrayOf(1f, 4.dp.toPx()))
}
}
lines.add(UnderlineDrawInfo(underlinePath, effect, minX, maxX, y, decoStyle, decoColor))
}
}
}
}
}
}
}
}
val duration = System.currentTimeMillis() - startTime
if (duration > 5) {
Timber.tag("DecorationsDiag").w("Calculated custom underlines for block ${block.blockIndex} in ${duration}ms")
}
lines
}
val customDrawer = Modifier.drawBehind {
val drawStartTime = System.currentTimeMillis()
textLayoutResult?.let { layoutResult ->
if (activeSelection != null) {
// ADD absolute offset helper:
@ -1219,48 +1438,60 @@ private fun TextWithEmphasis(
val path = layoutResult.getPathForRange(sOffset, eOffset)
drawPath(path, Color(0xFF1976D2).copy(alpha = 0.3f))
} catch (e: Exception) {
Timber.e(e, "Highlight path out of bounds")
Timber.tag("DecorationsDiag").e(e, "Highlight path out of bounds")
}
}
}
}
if (block.cfi != null && userHighlights.isNotEmpty()) {
userHighlights.forEach { highlight ->
val range = getHighlightOffsetsInBlock(block, highlight)
if (range != null) {
try {
val path = layoutResult.getPathForRange(range.first, range.last + 1)
drawPath(path, highlight.color.color.copy(alpha = 0.4f), blendMode = BlendMode.SrcOver)
if (highlight.cfi == pressedHighlightCfi) {
drawPath(path, Color.Black.copy(alpha = 0.1f), blendMode = BlendMode.SrcOver)
}
} catch (_: Exception) { }
}
}
cachedHighlights.forEach { (path, color) ->
drawPath(path, color, blendMode = BlendMode.SrcOver)
}
val emphasisAnnotations = text.getStringAnnotations("TextEmphasis", 0, text.length)
if (emphasisAnnotations.isNotEmpty()) {
emphasisAnnotations.forEach { annotation ->
val emphasis = parseEmphasisAnnotation(annotation.item, style.color)
val markColor = if (emphasis.color.isSpecified) emphasis.color else style.color
val markSize = layoutResult.layoutInput.style.fontSize.toPx() * 0.3f
for (offset in annotation.start until annotation.end) {
if (offset >= text.text.length || text.text[offset].isWhitespace()) continue
try {
val boundingBox = layoutResult.getBoundingBox(offset)
val center = Offset(
boundingBox.center.x,
if (emphasis.position == "under") boundingBox.bottom + markSize * 0.1f
else boundingBox.top - markSize * 0.1f
cachedEmphasisMarks.forEach { mark ->
drawCircle(mark.color, mark.radius, mark.center, style = Stroke(1f))
}
cachedUnderlines.forEach { line ->
when (line.decoStyle) {
"wavy" -> {
line.path?.let { p ->
drawPath(p, color = line.decoColor, style = Stroke(width = 1.dp.toPx(), cap = StrokeCap.Round, join = StrokeJoin.Round))
}
}
"dashed", "dotted" -> {
drawLine(
color = line.decoColor,
start = Offset(line.minX, line.y),
end = Offset(line.maxX, line.y),
strokeWidth = if (line.decoStyle == "dotted") 2.dp.toPx() else 1.dp.toPx(),
cap = if (line.decoStyle == "dotted") StrokeCap.Round else StrokeCap.Butt,
pathEffect = line.effect
)
}
else -> { // Solid or Double
drawLine(
color = line.decoColor,
start = Offset(line.minX, line.y),
end = Offset(line.maxX, line.y),
strokeWidth = 1.dp.toPx()
)
if (line.decoStyle == "double") {
drawLine(
color = line.decoColor,
start = Offset(line.minX, line.y + 2.dp.toPx()),
end = Offset(line.maxX, line.y + 2.dp.toPx()),
strokeWidth = 1.dp.toPx()
)
drawCircle(markColor, markSize / 2, center, style = Stroke(1f))
} catch (_: Exception) { }
}
}
}
}
}
val drawDuration = System.currentTimeMillis() - drawStartTime
if (drawDuration > 5) {
Timber.tag("DecorationsDiag").w("Modifier.drawBehind took ${drawDuration}ms for block ${block.blockIndex}")
}
}
fun getHighlightAt(offset: Offset, layout: TextLayoutResult): Pair<UserHighlight, Rect>? {
@ -1568,6 +1799,7 @@ internal fun PaginatedReaderContent(
val down = event.changes.firstOrNull { it.pressed }
if (down != null) {
pageTurnTouchY = down.position.y
Timber.tag("PageTurnFixDiag").v("Touch Event: Y=${down.position.y} at OffsetFraction=${pagerState.currentPageOffsetFraction}")
}
}
}
@ -1592,10 +1824,21 @@ internal fun PaginatedReaderContent(
var currentChapterPath by remember { mutableStateOf<String?>(null) }
LaunchedEffect(pageIndex, uiState.generation) {
val fetchStartTime = System.currentTimeMillis()
Timber.tag("PageTurnDiag").d("Page $pageIndex: Starting content fetch")
pageContent = onGetPage(pageIndex)
val fetchDuration = System.currentTimeMillis() - fetchStartTime
Timber.tag("PageTurnDiag").d("Page $pageIndex: Content fetched in ${fetchDuration}ms")
onGetChapterPath(pageIndex)?.let { currentChapterPath = it }
}
SideEffect {
Timber.tag("PageTurnDiag").v("Page $pageIndex: Re-composing content area")
}
val textBlocksOnPage =
pageContent?.content?.extractTextBlocks()
?.filter { it.cfi != null } ?: emptyList()
@ -2263,10 +2506,6 @@ internal fun PaginatedReaderContent(
}
is FlexContainerBlock -> {
// Background, border, and padding are
// already applied by the outer Box wrapper.
// Only apply padding + width here.
val containerModifier = paddingModifier
if (block.style.flexDirection == "row") {
val horizontalArrangement =
@ -2284,7 +2523,7 @@ internal fun PaginatedReaderContent(
else -> Alignment.Top
}
Row(
modifier = containerModifier.fillMaxWidth(),
modifier = paddingModifier.fillMaxWidth(),
horizontalArrangement = horizontalArrangement,
verticalAlignment = verticalAlignment
) {
@ -2336,7 +2575,7 @@ internal fun PaginatedReaderContent(
else -> Alignment.Start
}
Column(
modifier = containerModifier.fillMaxWidth(),
modifier = paddingModifier.fillMaxWidth(),
verticalArrangement = verticalArrangement,
horizontalAlignment = horizontalAlignment
) {
@ -2505,27 +2744,21 @@ internal fun PaginatedReaderContent(
is ImageBlock -> {
val style = block.style
val finalImageModifier = Modifier.then(
if (style.width != Dp.Unspecified) Modifier.width(
style.width
)
if (style.width.isSpecified && style.width > 0.dp) Modifier.width(style.width)
else Modifier.fillMaxWidth()
).then(
if (style.maxWidth.isSpecified && style.maxWidth > 0.dp) Modifier.widthIn(max = style.maxWidth)
else Modifier
).then(
if (style.maxWidth != Dp.Unspecified) Modifier.widthIn(
max = style.maxWidth
)
else Modifier
).then(
if (block.intrinsicWidth != null && block.intrinsicHeight != null && block.intrinsicWidth > 0f && block.intrinsicHeight > 0f) {
Modifier.aspectRatio(
block.intrinsicWidth / block.intrinsicHeight,
matchHeightConstraintsFirst = false
)
} else if (style.height != Dp.Unspecified) {
Modifier.height(style.height)
if (block.expectedHeight > 0) {
Modifier.height(with(density) { block.expectedHeight.toDp() })
} else {
Modifier.height(250.dp)
}
).then(paddingModifier)
.onGloballyPositioned { coords ->
Timber.tag("IMAGE_DIAG").v("Actual Rendered Height for [#${block.blockIndex}]: ${coords.size.height}px")
}
val colorFilter =
if (block.style.filter == "invert(100%)") {
@ -2732,24 +2965,13 @@ internal fun PaginatedReaderContent(
}
is ImageBlock -> {
val imageModifier =
Modifier.fillMaxWidth()
.then(
if (blockInCell.intrinsicWidth != null && blockInCell.intrinsicHeight != null && blockInCell.intrinsicWidth > 0f && blockInCell.intrinsicHeight > 0f) {
Modifier.aspectRatio(
blockInCell.intrinsicWidth / blockInCell.intrinsicHeight,
matchHeightConstraintsFirst = false
)
} else if (blockInCell.style.height != Dp.Unspecified) {
Modifier.height(
blockInCell.style.height
)
} else {
Modifier.height(
250.dp
)
}
)
val imageModifier = Modifier.fillMaxWidth().then(
if (blockInCell.expectedHeight > 0) {
Modifier.height(with(density) { blockInCell.expectedHeight.toDp() })
} else {
Modifier.height(250.dp)
}
)
AsyncImage(
model = Builder(
LocalContext.current
@ -3457,18 +3679,16 @@ private fun RenderFlexChildBlock(
val style = childBlock.style
val imageModifier = Modifier
.then(
if (style.width != Dp.Unspecified) Modifier.width(style.width)
if (style.width != Dp.Unspecified && style.width > 0.dp) Modifier.width(style.width)
else Modifier
)
.then(
if (style.maxWidth != Dp.Unspecified) Modifier.widthIn(max = style.maxWidth)
if (style.maxWidth != Dp.Unspecified && style.maxWidth > 0.dp) Modifier.widthIn(max = style.maxWidth)
else Modifier
)
.then(
if (childBlock.intrinsicWidth != null && childBlock.intrinsicHeight != null && childBlock.intrinsicWidth > 0f && childBlock.intrinsicHeight > 0f) {
Modifier.aspectRatio(childBlock.intrinsicWidth / childBlock.intrinsicHeight, matchHeightConstraintsFirst = false)
} else if (style.height != Dp.Unspecified) {
Modifier.height(style.height)
if (childBlock.expectedHeight > 0) {
Modifier.height(with(density) { childBlock.expectedHeight.toDp() })
} else {
Modifier.height(250.dp)
}
@ -3582,10 +3802,8 @@ private fun RenderFlexChildBlock(
)
} else if (blockInCell is ImageBlock) {
val imageModifier = Modifier.fillMaxWidth().then(
if (blockInCell.intrinsicWidth != null && blockInCell.intrinsicHeight != null && blockInCell.intrinsicWidth > 0f && blockInCell.intrinsicHeight > 0f) {
Modifier.aspectRatio(blockInCell.intrinsicWidth / blockInCell.intrinsicHeight, matchHeightConstraintsFirst = false)
} else if (blockInCell.style.height != Dp.Unspecified) {
Modifier.height(blockInCell.style.height)
if (blockInCell.expectedHeight > 0) {
Modifier.height(with(density) { blockInCell.expectedHeight.toDp() })
} else {
Modifier.height(250.dp)
}
@ -3625,6 +3843,11 @@ private fun Modifier.realisticBookPage(
isDarkTheme: Boolean,
touchY: Float?
): Modifier = composed {
// Log composition frequency
SideEffect {
Timber.tag("PageTurnFixDiag").v("Page $pageIndex re-composed. Offset: ${pagerState.currentPageOffsetFraction}")
}
val frontPath = remember { Path() }
val backPath = remember { Path() }
val reflectedScreenPath = remember { Path() }
@ -3633,6 +3856,11 @@ private fun Modifier.realisticBookPage(
.graphicsLayer {
val pageOffset = (pageIndex - pagerState.currentPage) - pagerState.currentPageOffsetFraction
// Log layer property updates
if (abs(pageOffset) > 0.001f && abs(pageOffset) < 0.999f) {
Timber.tag("PageTurnFixDiag").d("graphicsLayer: Page $pageIndex, Offset: $pageOffset")
}
if (pageOffset <= 1f && pageOffset > -1f) {
translationX = -pageOffset * size.width
}
@ -3644,6 +3872,7 @@ private fun Modifier.realisticBookPage(
}
}
.drawWithContent {
val drawStart = System.nanoTime()
val pageOffset = (pageIndex - pagerState.currentPage) - pagerState.currentPageOffsetFraction
if (abs(pageOffset) < 0.001f) {
@ -3670,10 +3899,21 @@ private fun Modifier.realisticBookPage(
val dy = cornerY - dragY
val nLen = kotlin.math.sqrt(dx * dx + dy * dy)
// CRITICAL GEOMETRY LOG
if (progress > 0.8f) { // Focus logs on the "end" of the turn where the stall happens
Timber.tag("PageTurnFixDiag").i(
"Geometry Page $pageIndex: progress=$progress, nLen=$nLen, cornerY=$cornerY, dragX=$dragX, midX=$midX"
)
}
if (nLen > 0f) {
val nx = dx / nLen
val ny = dy / nLen
if (nx.isNaN() || ny.isNaN()) {
Timber.tag("PageTurnFixDiag").e("NAN DETECTED in Normal Vectors: nx=$nx, ny=$ny")
}
val huge = w * 3f
val vx = -ny
@ -3733,17 +3973,12 @@ private fun Modifier.realisticBookPage(
clipRect(0f, 0f, w, h) {
clipPath(frontPath) {
drawPath(reflectedScreenPath, color = paperColor)
val flapTint = if (isDarkTheme) Color.White.copy(alpha = 0.08f) else Color.Black.copy(alpha = 0.06f)
drawPath(reflectedScreenPath, color = flapTint)
val innerShadowWidth = shadowWidth * 0.7f
val innerShadowBrush = Brush.linearGradient(
colors = listOf(
Color.Black.copy(alpha = 0.25f),
Color.Black.copy(alpha = 0.05f),
Color.Transparent
),
colors = listOf(Color.Black.copy(alpha = 0.25f), Color.Black.copy(alpha = 0.05f), Color.Transparent),
start = Offset(midX, midY),
end = Offset(midX - nx * innerShadowWidth, midY - ny * innerShadowWidth)
)
@ -3769,16 +4004,15 @@ private fun Modifier.realisticBookPage(
drawContent()
}
}
else if (pageOffset > 0f && pageOffset <= 1f) {
drawRect(color = paperColor)
drawContent()
val dimAlpha = (0.25f * pageOffset).coerceIn(0f, 0.4f)
drawRect(color = Color.Black.copy(alpha = dimAlpha))
}
else {
drawRect(color = paperColor)
drawContent()
}
val drawDuration = (System.nanoTime() - drawStart) / 1_000_000.0
if (drawDuration > 12.0) { // Log slow frames (anything near the 16ms frame budget)
Timber.tag("PageTurnFixDiag").w("Slow Draw on Page $pageIndex: ${drawDuration}ms")
}
}
}

View file

@ -303,7 +303,11 @@ data class CssStyle(
@ProtoNumber(9) val content: String? = null,
@ProtoNumber(10) val hyphens: String? = null,
@ProtoNumber(11) val fontVariantNumeric: String? = null,
@ProtoNumber(12) val textEmphasis: TextEmphasis? = null
@ProtoNumber(12) val textEmphasis: TextEmphasis? = null,
@ProtoNumber(13) @Serializable(with = TextUnitSerializer::class) val wordSpacing: TextUnit = TextUnit.Unspecified,
@ProtoNumber(14) val textDecorationStyle: String? = null,
@ProtoNumber(15) @Serializable(with = ColorSerializer::class) val textDecorationColor: Color = Color.Unspecified,
@ProtoNumber(16) @Serializable(with = DpSerializer::class) val textUnderlineOffset: Dp = Dp.Unspecified
) {
fun merge(other: CssStyle): CssStyle {
return CssStyle(
@ -318,7 +322,11 @@ data class CssStyle(
content = other.content ?: this.content,
hyphens = other.hyphens ?: this.hyphens,
fontVariantNumeric = other.fontVariantNumeric ?: this.fontVariantNumeric,
textEmphasis = other.textEmphasis ?: this.textEmphasis
textEmphasis = other.textEmphasis ?: this.textEmphasis,
wordSpacing = if (other.wordSpacing.isSpecified) other.wordSpacing else this.wordSpacing,
textDecorationStyle = other.textDecorationStyle ?: this.textDecorationStyle,
textDecorationColor = if (other.textDecorationColor.isSpecified) other.textDecorationColor else this.textDecorationColor,
textUnderlineOffset = if (other.textUnderlineOffset.isSpecified) other.textUnderlineOffset else this.textUnderlineOffset
)
}
}

View file

@ -742,26 +742,25 @@ private suspend fun measureBlockHeight(
val imageIntrinsicWidth = block.intrinsicWidth
val imageIntrinsicHeight = block.intrinsicHeight
if (imageIntrinsicWidth != null && imageIntrinsicHeight != null && imageIntrinsicWidth > 0) {
val aspectRatio = imageIntrinsicHeight / imageIntrinsicWidth
val styledWidthDp = block.style.width
val styledHeightPx = if (block.style.height.isSpecified) with(density) { block.style.height.toPx() } else null
val styledWidthPx = if (block.style.width.isSpecified) with(density) { block.style.width.toPx() } else null
val imageRenderWidthPx = if (styledWidthDp != Dp.Unspecified) {
with(density) { styledWidthDp.toPx() }
} else {
contentMaxWidth
val measuredHeight = when {
styledHeightPx != null && styledHeightPx > 0f -> styledHeightPx
styledWidthPx != null && styledWidthPx > 0f && imageIntrinsicWidth != null && imageIntrinsicHeight != null && imageIntrinsicWidth > 0 -> {
val aspectRatio = imageIntrinsicHeight / imageIntrinsicWidth
styledWidthPx * aspectRatio
}
val height = (imageRenderWidthPx * aspectRatio).roundToInt()
height
} else {
Timber.w("Image at '${block.path}' has no valid intrinsic dimensions, falling back to fixed height.")
if (block.style.height != Dp.Unspecified) {
with(density) { block.style.height.toPx().roundToInt() }
} else {
with(density) { 250.dp.toPx().roundToInt() }
imageIntrinsicWidth != null && imageIntrinsicHeight != null && imageIntrinsicWidth > 0 -> {
val aspectRatio = imageIntrinsicHeight / imageIntrinsicWidth
contentMaxWidth * aspectRatio
}
else -> with(density) { 250.dp.toPx() }
}
val finalHeight = measuredHeight.coerceAtMost(constraints.maxHeight.toFloat()).roundToInt()
Timber.tag("IMAGE_DIAG").d("Measured Image [#${block.blockIndex}]: $finalHeight px (Capped at ${constraints.maxHeight})")
finalHeight
}
is SpacerBlock -> {
val height = with(density) { block.height.toPx().roundToInt() }

View file

@ -198,7 +198,7 @@ abstract class BookCacheDao {
ConfigurationCache::class,
AnchorIndexEntry::class
],
version = 6,
version = 7,
exportSchema = false
)
abstract class BookCacheDatabase : RoomDatabase() {

View file

@ -25,7 +25,7 @@ import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
const val LATEST_PROCESSING_VERSION = 6
const val LATEST_PROCESSING_VERSION = 7
@Entity(tableName = "processed_books")
data class ProcessedBook(