V1.0.46 oss (#253)

* add a UI trigger for demo annotations

* Implement internal link navigation

* Simplify bottom padding logic in `EpubReaderScreen` for vertical scroll mode

* Add support for Calibre metadata

* Implement advanced library management with Room-backed collections, tags, and smart rules.

* Display book series information in book details and remove tags from home screen

* Implement zoom reset functionality in PDF viewer

* Add a "Pages" thumbnail view to the PDF navigation drawer

* Implement jump-back navigation in PDF viewer

* MainViewModel: disable FolderSyncWorker, add log export, and enhance PDF position logging

* Replace Gson with Kotlin Serialization in SmartCollectionEngine

* Implement ONNX-based speech bubble detection

* Implement speech bubble detection in PDF viewer

* Implement "Smart Comic Zoom" feature for PDF manga/comic reading

* Implement reader session persistence and restoration in `MainViewModel`

* Add tap-to-turn page feature to PDF viewer

* Improve book cache management and recovery

* Refactor top overlay padding logic in `PdfViewerScreen`

* Implement stylus eraser support in PDF reader

* Refactor ReaderTextFormatPanel to use ModalBottomSheet and add new formatting controls

* Introduce a customizable horizontal margin setting for the EPUB reader

* Refine folder sync and metadata handling for better conflict resolution and stability.

* Introduce user-adjustable image scaling for the EPUB reader

* Use maxWidthPx as default width fallback for blocks

* Improve cross-page text selection and header styling in the paginated reader

* Refactor speech bubble detection and UI to support segmentation masks and interactive scaling

* Improve speech bubble detection masking and rendering quality

* Update ONNX speech bubble detector to use `.ort` model and optimize inference

* Improve pagination accuracy

* Implement hierarchical folder navigation for the library.

* Refactor library item layout for improved space efficiency

* Add support for browsing and downloading Google Fonts

* Persist library landing state and add shelf search functionality

* Fix base tts sample.

* Move SpeechBubbleDetector to main source set and update ONNX dependency

* Implement a "locate" feature and improve synchronization for TTS (Text-to-Speech) playback across EPUB and PDF readers.

* Refine TTS synchronization and voice management in the EPUB reader

* Adjust OCR checks for OSS flavor and optimize external dictionary intent flags

* Add "Locate" button to PDF drawer's pages tab and move the page number to bottom right of thumbnail

* fix various crashes

* Refactor SpeechBubbleDetector into product flavors

* Implement speech bubble detection caching and background prefetching

* Implement on-demand download for Bubble Zoom ML model

* Add smooth animation for PDF speech bubble expansion

* fixes #252

* hide Google Fonts option, in FontsScreen, for offline variant

* Bump version to 1.0.46(46)
This commit is contained in:
Aryan 2026-04-29 16:37:53 +05:30 committed by GitHub
parent 579b6f25bf
commit d16bdd70d4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
67 changed files with 9724 additions and 1890 deletions

View file

@ -26,10 +26,8 @@ import com.aryan.reader.pdf.data.PdfAnnotation
object DemoAnnotationGenerator {
// --- SVG Configuration ---
private const val SVG_WIDTH = 800f
// Extracted from your Figma SVG
private val DECORATIVE_DOTS = listOf(
DotData(120f, 90f, 5f, Color(0xFFF59E0B), 0.7f),
DotData(680f, 210f, 6f, Color(0xFFEC4899), 0.7f),
@ -72,47 +70,36 @@ object DemoAnnotationGenerator {
fun generateDemoAnnotations(pageIndex: Int): List<PdfAnnotation> {
val annotations = mutableListOf<PdfAnnotation>()
// --- Layout Calculation ---
// We want the SVG to occupy 80% of the page width, centered.
// PDF coordinates are 0..1.
val targetWidthPercent = 0.8f
// SVG aspect ratio 300 / 800 = 0.375
// Calculate scale factor relative to normalized page coordinates
val scaleX = targetWidthPercent / SVG_WIDTH
val scaleY = scaleX // Keep uniform scale in abstract space
// Center offsets (0.5 is middle of page)
val startX = (1f - targetWidthPercent) / 2f
val startY = 0.4f // Position slightly above center vertically
val startY = 0.2f
var currentTime = System.currentTimeMillis()
// Helper to transform SVG points to PDF Page Points
fun transformPoint(x: Float, y: Float): PdfPoint {
val pdfX = startX + (x * scaleX)
val pdfY = startY + (y * scaleY)
val pdfY = startY + (y * scaleX)
return PdfPoint(pdfX, pdfY, currentTime)
}
// 1. Render Decorative Dots
DECORATIVE_DOTS.forEach { dot ->
val pdfPoint = transformPoint(dot.cx, dot.cy)
// To make a "Dot" with the pen, we need at least 2 points very close together
// or a single point might not render depending on the implementation.
val points = listOf(
pdfPoint,
pdfPoint.copy(x = pdfPoint.x + 0.0001f, timestamp = currentTime + 10)
)
// Convert SVG radius to stroke width
val relativeThickness = (dot.r / SVG_WIDTH) * 2.5f
annotations.add(
PdfAnnotation(
type = AnnotationType.INK,
inkType = InkType.PEN, // Standard pen for dots
inkType = InkType.PEN,
pageIndex = pageIndex,
points = points,
color = dot.color.copy(alpha = dot.alpha),
@ -122,7 +109,6 @@ object DemoAnnotationGenerator {
currentTime += 50
}
// 2. Render Text ("Try Episteme!")
val textPaths = splitSvgPaths(TEXT_STROKES_DATA)
textPaths.forEach { pathString ->
val path = PathParser.createPathFromPathData(pathString)
@ -130,7 +116,6 @@ object DemoAnnotationGenerator {
if (flattenedPoints.isNotEmpty()) {
val pdfPoints = flattenedPoints.mapIndexed { _, p ->
// Increment time to simulate drawing speed for Fountain Pen physics
currentTime += 8
transformPoint(p.x, p.y).copy(timestamp = currentTime)
}
@ -138,18 +123,17 @@ object DemoAnnotationGenerator {
annotations.add(
PdfAnnotation(
type = AnnotationType.INK,
inkType = InkType.FOUNTAIN_PEN, // Handwriting looks best with this
inkType = InkType.FOUNTAIN_PEN,
pageIndex = pageIndex,
points = pdfPoints,
color = Color(0xFF418377), // Updated Green
strokeWidth = 0.004f // Fine tip
color = Color(0xFF418377),
strokeWidth = 0.004f
)
)
currentTime += 150 // Pen lift delay
currentTime += 150
}
}
// 3. Render Underline
val underlinePath = PathParser.createPathFromPathData(UNDERLINE_DATA)
val underlinePointsRaw = flattenPath(underlinePath)
val underlinePdfPoints = underlinePointsRaw.map { p ->
@ -160,10 +144,10 @@ object DemoAnnotationGenerator {
annotations.add(
PdfAnnotation(
type = AnnotationType.INK,
inkType = InkType.PEN, // Consistent width for underline
inkType = InkType.PEN,
pageIndex = pageIndex,
points = underlinePdfPoints,
color = Color(0xFFEC4899).copy(alpha = 0.6f), // Pink
color = Color(0xFFEC4899).copy(alpha = 0.6f),
strokeWidth = 0.005f
)
)
@ -171,8 +155,6 @@ object DemoAnnotationGenerator {
return annotations
}
// --- Helpers ---
private data class DotData(val cx: Float, val cy: Float, val r: Float, val color: Color, val alpha: Float)
private data class PointF(val x: Float, val y: Float)
@ -180,11 +162,9 @@ object DemoAnnotationGenerator {
* Android's Path doesn't give us points directly. We use approximate().
*/
private fun flattenPath(path: Path): List<PointF> {
// Approximate the path with error tolerance 0.5 (pixels in SVG space)
val approximation = path.approximate(0.5f)
val points = mutableListOf<PointF>()
// approximation array format: [t0, x0, y0, t1, x1, y1, ...]
var i = 0
while (i < approximation.size) {
val x = approximation[i + 1]
@ -204,15 +184,12 @@ object DemoAnnotationGenerator {
val result = mutableListOf<String>()
rawPaths.forEach { fullPathString ->
// Clean up and standardize
val cleanStr = fullPathString.trim()
// Split by "M" (Move command).
val parts = cleanStr.split("M")
parts.forEach { part ->
if (part.isNotBlank()) {
// Re-prepend M because split removed it
result.add("M ${part.trim()}")
}
}

View file

@ -1,9 +1,24 @@
// PdfNavigationDrawerContent.kt
package com.aryan.reader.pdf
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
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
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
@ -13,29 +28,260 @@ import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.ScrollableTabRow
import androidx.compose.material3.Surface
import androidx.compose.material3.Tab
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import io.legere.pdfiumandroid.api.Bookmark
import io.legere.pdfiumandroid.suspend.PdfDocumentKt
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.json.JSONArray
import timber.log.Timber
import androidx.core.graphics.createBitmap
private const val MAX_FIXED_RECURSION = 128
internal data class PdfBookmark(val pageIndex: Int, val title: String, val totalPages: Int)
internal data class TocEntry(val title: String, val pageIndex: Int, val nestLevel: Int)
/**
* Patches the library bug where siblings are truncated due to depth-state leakage.
*/
suspend fun PdfDocumentKt.getFixedTableOfContents(): List<Bookmark> {
val tag = "PdfTocFix"
Timber.tag(tag).i("Starting Pure Reflection Traversal...")
return try {
// 1. Get the 'document' field (PdfDocumentU) from PdfDocumentKt
val documentField = PdfDocumentKt::class.java.getDeclaredField("document").apply { isAccessible = true }
val docUInstance = documentField.get(this) ?: return getTableOfContents()
// 2. Get the 'nativeDocument' field from PdfDocumentU
val nativeDocField = docUInstance.javaClass.getDeclaredField("nativeDocument").apply { isAccessible = true }
val nativeDocInstance = nativeDocField.get(docUInstance) ?: return getTableOfContents()
// 3. Get the native pointer (long) from PdfDocumentU
val ptrField = docUInstance.javaClass.getDeclaredField("mNativeDocPtr").apply { isAccessible = true }
val mNativeDocPtr = ptrField.get(docUInstance) as Long
// 4. Look up native methods using primitive 'long' types (mandatory for JNI)
val nClass = nativeDocInstance.javaClass
val lp = Long::class.javaPrimitiveType!! // Shorthand for 'long'
val getTitleM = nClass.getMethod("getBookmarkTitle", lp)
val getDestIdxM = nClass.getMethod("getBookmarkDestIndex", lp, lp)
val getFirstChildM = nClass.getMethod("getFirstChildBookmark", lp, lp)
val getSiblingM = nClass.getMethod("getSiblingBookmark", lp, lp)
val topLevel = mutableListOf<Bookmark>()
val visited = mutableSetOf<Long>()
/**
* Corrected traversal: Iterative for siblings, recursive for children.
*/
fun walk(parentList: MutableList<Bookmark>, startPtr: Long, level: Int) {
var currentPtr = startPtr
var itemIndex = 0
while (currentPtr != 0L) {
if (visited.contains(currentPtr)) break
visited.add(currentPtr)
val title = getTitleM.invoke(nativeDocInstance, currentPtr) as? String ?: "Untitled"
val pageIdx = getDestIdxM.invoke(nativeDocInstance, mNativeDocPtr, currentPtr) as Long
Timber.tag(tag).v("Lvl $level | Item $itemIndex | Ptr: 0x${java.lang.Long.toHexString(currentPtr)} | $title")
val bookmark = Bookmark().apply {
this.mNativePtr = currentPtr
this.title = title
this.pageIdx = pageIdx
}
parentList.add(bookmark)
// Recursive dive into children
val firstChild = getFirstChildM.invoke(nativeDocInstance, mNativeDocPtr, currentPtr) as Long
if (firstChild != 0L && level < MAX_FIXED_RECURSION) {
walk(bookmark.children, firstChild, level + 1)
}
// Iterative move to next sibling
currentPtr = getSiblingM.invoke(nativeDocInstance, mNativeDocPtr, currentPtr) as Long
itemIndex++
}
}
// 5. Start from the root (Pass 0L as primitive long)
val firstRoot = getFirstChildM.invoke(nativeDocInstance, mNativeDocPtr, 0L) as Long
if (firstRoot != 0L) {
walk(topLevel, firstRoot, 0)
}
if (topLevel.isEmpty()) {
Timber.tag(tag).w("No items found, falling back to library.")
getTableOfContents()
} else {
Timber.tag(tag).i("TOC Successfully Patched! Nodes: ${visited.size}")
topLevel
}
} catch (e: Exception) {
Timber.tag(tag).e(e, "Reflection traversal critical error.")
this.getTableOfContents()
}
}
internal fun flattenToc(bookmarks: List<Bookmark>, level: Int = 0): List<TocEntry> {
Timber.tag("PdfTocDebug").d("Processing level $level with ${bookmarks.size} items")
val entries = mutableListOf<TocEntry>()
for ((index, bookmark) in bookmarks.withIndex()) {
val title = bookmark.title ?: "Untitled Chapter"
val childCount = bookmark.children.size
Timber.tag("PdfTocDebug").d(
"Lvl $level | Item $index: \"$title\" (Page: ${bookmark.pageIdx}) | Children: $childCount"
)
entries.add(
TocEntry(
title = title,
pageIndex = bookmark.pageIdx.toInt(),
nestLevel = level
)
)
if (childCount > 0) {
Timber.tag("PdfTocDebug").v("Entering children of \"$title\"")
entries.addAll(flattenToc(bookmark.children, level + 1))
Timber.tag("PdfTocDebug").v("Returned to Lvl $level from \"$title\"")
}
}
return entries
}
internal fun loadPdfBookmarksFromJson(bookmarksJson: String?): Set<PdfBookmark> {
if (bookmarksJson.isNullOrBlank()) return emptySet()
return try {
val jsonArray = JSONArray(bookmarksJson)
(0 until jsonArray.length()).mapNotNull { i ->
try {
val json = jsonArray.getJSONObject(i)
PdfBookmark(
pageIndex = json.getInt("pageIndex"),
title = json.getString("title"),
totalPages = json.getInt("totalPages")
)
} catch (e: Exception) {
Timber.e(e, "Failed to parse bookmark from JSON object")
null
}
}.toSet()
} catch (e: Exception) {
Timber.e(e, "Failed to parse bookmarks from JSON string: $bookmarksJson")
emptySet()
}
}
@Composable
internal fun PdfTocTreeItem(
label: String,
nestLevel: Int,
isExpanded: Boolean,
hasChildren: Boolean,
isCurrent: Boolean,
onToggleExpand: () -> Unit,
onClick: () -> Unit
) {
val backgroundColor by animateColorAsState(
targetValue = if (isCurrent) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.2f) else Color.Transparent,
label = "TocItemBackground"
)
val contentColor = if (isCurrent) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface
Row(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 48.dp)
.background(backgroundColor)
.clickable(onClick = onClick)
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Spacer(modifier = Modifier.width((16 * nestLevel).dp))
Box(
modifier = Modifier
.size(40.dp)
.clickable(enabled = hasChildren, onClick = onToggleExpand),
contentAlignment = Alignment.Center
) {
if (hasChildren) {
Icon(
imageVector = if (isExpanded) Icons.Default.KeyboardArrowDown else Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = if (isExpanded) "Collapse" else "Expand",
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Text(
text = label,
style = if (nestLevel == 0) MaterialTheme.typography.bodyLarge else MaterialTheme.typography.bodyMedium,
fontWeight = if (isCurrent) FontWeight.Bold else if (nestLevel == 0) FontWeight.SemiBold else FontWeight.Normal,
color = contentColor,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f).padding(end = 16.dp)
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun PdfNavigationDrawerContent(
pdfDocument: ReaderDocument?,
flatTableOfContents: List<TocEntry>,
bookmarks: Set<PdfBookmark>,
userHighlights: List<PdfUserHighlight>,
currentPage: Int,
totalPages: Int,
customHighlightColors: Map<PdfHighlightColor, Color>,
onPageSelected: (Int) -> Unit,
onRenameBookmark: (PdfBookmark, String) -> Unit,
@ -44,11 +290,15 @@ internal fun PdfNavigationDrawerContent(
onNoteRequested: (String?) -> Unit,
onCloseDrawer: () -> Unit
) {
val drawerPagerState = rememberPagerState(pageCount = { 3 })
val drawerPagerState = rememberPagerState(pageCount = { 4 })
val drawerScope = rememberCoroutineScope()
Column(modifier = Modifier.fillMaxSize()) {
TabRow(selectedTabIndex = drawerPagerState.currentPage) {
ScrollableTabRow(
selectedTabIndex = drawerPagerState.currentPage,
edgePadding = 8.dp,
modifier = Modifier.fillMaxWidth()
) {
Tab(selected = drawerPagerState.currentPage == 0, onClick = {
drawerScope.launch { drawerPagerState.animateScrollToPage(0) }
}, text = { Text("Chapters") })
@ -68,6 +318,14 @@ internal fun PdfNavigationDrawerContent(
text = { Text("Highlights") },
modifier = Modifier.testTag("HighlightsTab")
)
Tab(
selected = drawerPagerState.currentPage == 3,
onClick = {
drawerScope.launch { drawerPagerState.animateScrollToPage(3) }
},
text = { Text("Pages") },
modifier = Modifier.testTag("PagesTab")
)
}
HorizontalPager(
@ -531,6 +789,130 @@ internal fun PdfNavigationDrawerContent(
}
}
}
3 -> { // Pages Page
val listState = rememberLazyListState()
val pageRows = remember(totalPages) { (0 until totalPages).chunked(3) }
val currentRowIndex = currentPage / 3
Column(modifier = Modifier.fillMaxSize()) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp),
horizontalArrangement = Arrangement.SpaceEvenly
) {
TextButton(
onClick = {
drawerScope.launch {
if (currentRowIndex in pageRows.indices) {
listState.animateScrollToItem(currentRowIndex)
}
}
}
) {
Text("Locate")
}
}
HorizontalDivider()
Box(modifier = Modifier.fillMaxWidth().weight(1f)) {
LazyColumn(
state = listState,
modifier = Modifier
.fillMaxHeight()
.padding(end = 12.dp)
) {
items(pageRows, key = { it.firstOrNull() ?: 0 }) { row ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp, horizontal = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
row.forEach { pageIdx ->
Box(
modifier = Modifier
.weight(1f)
.aspectRatio(0.707f)
.background(
MaterialTheme.colorScheme.surfaceVariant,
RoundedCornerShape(4.dp)
)
.border(
width = if (currentPage == pageIdx) 2.dp else 1.dp,
color = if (currentPage == pageIdx) MaterialTheme.colorScheme.primary else Color.Black.copy(alpha = 0.1f),
shape = RoundedCornerShape(4.dp)
)
.clickable {
onCloseDrawer()
onPageSelected(pageIdx)
},
contentAlignment = Alignment.Center
) {
var thumb by remember { mutableStateOf(PdfThumbnailCache.get(pageIdx)) }
LaunchedEffect(pageIdx, pdfDocument) {
if (thumb == null && pdfDocument != null) {
withContext(kotlinx.coroutines.Dispatchers.IO) {
try {
val cached = PdfThumbnailCache.get(pageIdx)
if (cached != null) {
thumb = cached
} else {
pdfDocument.openPage(pageIdx)?.use { p ->
val w = p.getPageWidthPoint()
val h = p.getPageHeightPoint()
val ratio = if (h > 0) w.toFloat() / h.toFloat() else 1f
val thumbW = 200
val thumbH = (thumbW / ratio).toInt().coerceAtLeast(1)
val bmp = createBitmap(thumbW, thumbH)
bmp.eraseColor(android.graphics.Color.WHITE)
p.renderPageBitmap(bmp, 0, 0, thumbW, thumbH, false)
PdfThumbnailCache.put(pageIdx, bmp)
thumb = bmp
}
}
} catch (_: Exception) { }
}
}
}
if (thumb != null) {
Image(
bitmap = thumb!!.asImageBitmap(),
contentDescription = "Page ${pageIdx + 1}",
modifier = Modifier.fillMaxSize()
)
}
Text(
text = "${pageIdx + 1}",
style = MaterialTheme.typography.labelMedium.copy(
fontWeight = FontWeight.Bold
),
color = Color.White,
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(4.dp)
.background(
Color.Black.copy(alpha = 0.5f),
RoundedCornerShape(6.dp)
)
.padding(horizontal = 6.dp, vertical = 2.dp)
)
}
}
repeat(3 - row.size) { Spacer(modifier = Modifier.weight(1f)) }
}
}
}
VerticalScrollbar(
listState = listState,
modifier = Modifier.align(Alignment.CenterEnd)
)
}
}
}
}
}
}

View file

@ -239,15 +239,45 @@ internal fun BookmarkButton(
}
@Composable
internal fun ZoomPercentageIndicator(percentage: Int) {
internal fun ZoomPercentageIndicator(
percentage: Int,
onResetZoomClick: () -> Unit
) {
Surface(
shape = RoundedCornerShape(8.dp), color = MaterialTheme.colorScheme.scrim.copy(alpha = 0.8f)
) {
Text(
text = "$percentage%",
color = Color.White,
style = MaterialTheme.typography.bodyLarge,
androidx.compose.foundation.layout.Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp)
)
) {
Text(
text = "$percentage%",
color = Color.White,
style = MaterialTheme.typography.bodyLarge
)
Spacer(modifier = Modifier.width(8.dp))
// Divider
Box(
modifier = Modifier
.width(1.dp)
.height(16.dp)
.background(Color.White.copy(alpha = 0.5f))
)
Spacer(modifier = Modifier.width(8.dp))
// Reset Zoom Button
Icon(
painter = painterResource(id = R.drawable.zoom_out),
contentDescription = "Reset Zoom",
tint = Color.White,
modifier = Modifier
.size(20.dp)
.clip(RoundedCornerShape(4.dp))
.clickable(onClick = onResetZoomClick)
)
}
}
}

View file

@ -13,6 +13,7 @@ import android.graphics.RectF
import android.graphics.Shader
import android.util.LruCache
import androidx.activity.compose.BackHandler
import androidx.compose.ui.graphics.drawscope.withTransform
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
@ -83,6 +84,9 @@ import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.input.pointer.PointerEventTimeoutCancellationException
import androidx.compose.ui.input.pointer.PointerType
import androidx.compose.ui.input.pointer.changedToUp
import androidx.compose.ui.input.pointer.isPrimaryPressed
import androidx.compose.ui.input.pointer.isSecondaryPressed
import androidx.compose.ui.input.pointer.isTertiaryPressed
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.positionChanged
import androidx.compose.ui.input.pointer.util.VelocityTracker
@ -115,6 +119,7 @@ import androidx.core.graphics.scale
import androidx.core.graphics.set
import com.aryan.reader.R
import com.aryan.reader.SearchResult
import com.aryan.reader.ml.SpeechBubble
import com.aryan.reader.pdf.data.PdfAnnotation
import com.aryan.reader.pdf.data.PdfTextBox
import com.aryan.reader.pdf.data.VirtualPage
@ -187,6 +192,78 @@ data class PageLink(
val source: LinkSource
)
private data class ExpandedBubbleRender(
val bitmap: Bitmap,
val zoomFactor: Float
)
private fun computeDynamicBubbleZoomFactor(
bubbleBounds: RectF,
viewportWidth: Float,
viewportHeight: Float
): Float {
if (bubbleBounds.width() <= 0f || bubbleBounds.height() <= 0f) return 1.5f
val targetWidth = viewportWidth * 0.6f
val targetHeight = viewportHeight * 0.32f
return min(targetWidth / bubbleBounds.width(), targetHeight / bubbleBounds.height())
.coerceIn(1.35f, 4.25f)
}
private fun isTapInsideBubble(
bubble: SpeechBubble,
tapX: Float,
tapY: Float,
hitSlopPx: Float
): Boolean {
val expandedBounds = RectF(bubble.bounds)
expandedBounds.inset(-hitSlopPx, -hitSlopPx)
if (!expandedBounds.contains(tapX, tapY)) return false
val mask = bubble.maskBitmap ?: return true
if (!bubble.bounds.contains(tapX, tapY)) return true
val normalizedX = ((tapX - bubble.bounds.left) / bubble.bounds.width()).coerceIn(0f, 0.999f)
val normalizedY = ((tapY - bubble.bounds.top) / bubble.bounds.height()).coerceIn(0f, 0.999f)
val maskX = (normalizedX * mask.width).toInt().coerceIn(0, mask.width - 1)
val maskY = (normalizedY * mask.height).toInt().coerceIn(0, mask.height - 1)
return AndroidColor.alpha(mask.getPixel(maskX, maskY)) > 24
}
private suspend fun renderExpandedBubbleBitmap(
document: ReaderDocument,
pageIndex: Int,
bubbleBounds: RectF,
pageWidth: Int,
pageHeight: Int,
renderScale: Float
): Bitmap? = withContext(Dispatchers.IO) {
if (pageWidth <= 0 || pageHeight <= 0 || bubbleBounds.width() <= 0f || bubbleBounds.height() <= 0f) {
return@withContext null
}
document.openPage(pageIndex)?.use { page ->
val cropWidth = (bubbleBounds.width() * renderScale).roundToInt().coerceAtLeast(1)
val cropHeight = (bubbleBounds.height() * renderScale).roundToInt().coerceAtLeast(1)
val bitmap = createBitmap(cropWidth, cropHeight)
try {
page.renderPageBitmap(
bitmap = bitmap,
startX = (-bubbleBounds.left * renderScale).roundToInt(),
startY = (-bubbleBounds.top * renderScale).roundToInt(),
drawSizeX = (pageWidth * renderScale).roundToInt().coerceAtLeast(cropWidth),
drawSizeY = (pageHeight * renderScale).roundToInt().coerceAtLeast(cropHeight),
renderAnnot = true
)
bitmap
} catch (t: Throwable) {
bitmap.recycle()
Timber.tag("BubbleZoom").w(t, "Failed to render expanded bubble bitmap for page $pageIndex")
null
}
}
}
object PdfInkGeometry {
fun calculateFountainPenPoints(
points: List<PdfPoint>, baseWidth: Float, pageWidth: Float, pageHeight: Float
@ -394,7 +471,8 @@ internal fun PdfPageComposable(
searchHighlightMode: SearchHighlightMode = SearchHighlightMode.ALL,
searchResultToHighlight: SearchResult?,
ocrHoverHighlights: StableHolder<List<RectF>> = StableHolder(emptyList()),
onSingleTap: () -> Unit,
onPreSingleTap: ((Offset) -> Boolean)? = null,
onSingleTap: (Offset?) -> Unit,
isProUser: Boolean,
onShowDictionaryUpsellDialog: () -> Unit,
onWordSelectedForAiDefinition: (String) -> Unit,
@ -415,6 +493,7 @@ internal fun PdfPageComposable(
isVerticalScroll: Boolean = false,
visualScaleProvider: () -> Float = { 1f },
clearSelectionTrigger: Long = 0L,
resetZoomTrigger: Long = 0L,
onTtsHighlightCenterCalculated: ((Float) -> Unit)? = null,
onSearchHighlightCenterCalculated: ((Float) -> Unit)? = null,
activeTheme: com.aryan.reader.ReaderTheme = com.aryan.reader.ReaderTheme("no_theme", "No Theme", Color.Unspecified, Color.Unspecified, false),
@ -423,8 +502,8 @@ internal fun PdfPageComposable(
isEditMode: Boolean = false,
drawingState: PdfDrawingState? = null,
pageAnnotations: () -> List<PdfAnnotation> = { emptyList() },
onDrawStart: (PdfPoint) -> Unit = {},
onDraw: (PdfPoint) -> Unit = {},
onDrawStart: (PdfPoint, Boolean) -> Unit = { _, _ -> },
onDraw: (PdfPoint, Boolean) -> Unit = { _, _ -> },
onDrawEnd: () -> Unit = {},
visibleScreenRect: () -> IntRect? = { null },
selectedTool: InkType = InkType.PEN,
@ -441,6 +520,7 @@ internal fun PdfPageComposable(
isScrollLocked: Boolean = false,
isVisible: Boolean = true,
isActivePage: Boolean = true,
isBubbleZoomModeActive: Boolean = false,
isStylusOnlyMode: Boolean = false,
isAutoScrollPlaying: Boolean = false,
isHighlighterSnapEnabled: Boolean = false,
@ -455,7 +535,7 @@ internal fun PdfPageComposable(
onPaletteClick: (() -> Unit)? = null,
lockedState: Triple<Float, Float, Float>? = null,
onZoomAndPanChanged: ((Float, Offset) -> Unit)? = null,
onDetectPanels: suspend (Bitmap) -> List<android.graphics.RectF> = { emptyList() },
onDetectBubbles: suspend (Int, Bitmap) -> List<SpeechBubble> = { _, _ -> emptyList() },
onShowPanelPopup: (Bitmap) -> Unit = {}
) {
val pdfDocumentItem = pdfDocument.item
@ -475,10 +555,7 @@ internal fun PdfPageComposable(
LocalContext.current
val viewConfiguration = LocalViewConfiguration.current
val coroutineScope = rememberCoroutineScope()
Timber.d(
"PdfPageComposable recompose: page=$pageIndex, isScrolling=$isScrolling, visualScale=$visualScaleProvider"
)
var isStylusEraserOverride by remember { mutableStateOf(false) }
var layoutCoordinates by remember { mutableStateOf<LayoutCoordinates?>(null) }
@ -493,6 +570,7 @@ internal fun PdfPageComposable(
}
val currentOnSingleTap by rememberUpdatedState(onSingleTap)
val currentOnPreSingleTap by rememberUpdatedState(onPreSingleTap)
val currentOnDoubleTap by rememberUpdatedState(onDoubleTap)
val effectiveScale = if (isZoomEnabled && !isVerticalScroll) scale else externalScale
@ -618,9 +696,7 @@ internal fun PdfPageComposable(
}
LaunchedEffect(centeringOffsetX, centeringOffsetY, pageIndex) {
Timber.d(
"PdfPageComposable Page $pageIndex | Centering Offset: x=$centeringOffsetX, y=$centeringOffsetY"
)
}
var showMagnifier by remember { mutableStateOf(false) }
@ -646,6 +722,132 @@ internal fun PdfPageComposable(
screenOffset
}
var detectedBubbles by remember(targetPageId) { mutableStateOf<List<SpeechBubble>>(emptyList()) }
var expandedBubbleIndex by remember(targetPageId) { mutableIntStateOf(-1) }
var animatingBubbleIndex by remember(targetPageId) { mutableIntStateOf(-1) }
val bubbleExpansionProgress = remember(targetPageId) { Animatable(0f) }
var isDetectingBubbles by remember(targetPageId) { mutableStateOf(false) }
var expandedBubbleRender by remember(targetPageId) { mutableStateOf<ExpandedBubbleRender?>(null) }
val currentDetectedBubbles by rememberUpdatedState(detectedBubbles)
val currentExpandedBubbleIndex by rememberUpdatedState(expandedBubbleIndex)
val currentBubbleZoomModeActive by rememberUpdatedState(isBubbleZoomModeActive)
val bubbleTapSlopPx = with(density) { 18.dp.toPx() }
LaunchedEffect(expandedBubbleIndex) {
if (expandedBubbleIndex != -1) {
if (animatingBubbleIndex != -1 && animatingBubbleIndex != expandedBubbleIndex) {
bubbleExpansionProgress.animateTo(0f, tween(150))
}
animatingBubbleIndex = expandedBubbleIndex
bubbleExpansionProgress.animateTo(1f, tween(250, easing = androidx.compose.animation.core.FastOutSlowInEasing))
} else {
bubbleExpansionProgress.animateTo(0f, tween(200, easing = androidx.compose.animation.core.FastOutLinearInEasing))
animatingBubbleIndex = -1
}
}
LaunchedEffect(
isBubbleZoomModeActive,
isActivePage,
isPdfPage,
pdfPageIndex,
bitmapState,
actualBitmapWidthPx,
actualBitmapHeightPx
) {
Timber.tag("BubbleZoom").d("LaunchedEffect triggered. modeActive=$isBubbleZoomModeActive, activePage=$isActivePage, hasBitmap=${bitmapState != null}, dims=${actualBitmapWidthPx}x${actualBitmapHeightPx}")
if (isBubbleZoomModeActive && isActivePage && isPdfPage && bitmapState != null && actualBitmapWidthPx > 0 && actualBitmapHeightPx > 0) {
Timber.tag("BubbleZoom").d("Conditions met. Starting detection...")
isDetectingBubbles = true
try {
val rawBubbles = onDetectBubbles(pdfPageIndex, bitmapState!!)
Timber.tag("BubbleZoom").d("Detection complete. Found ${rawBubbles.size} raw bubbles.")
// NEW: Scale bubbles down from render bitmap space to logical screen space
val scaleX = actualBitmapWidthPx.toFloat() / bitmapState!!.width.toFloat()
val scaleY = actualBitmapHeightPx.toFloat() / bitmapState!!.height.toFloat()
val logicalBubbles = rawBubbles.map { b ->
b.copy(bounds = android.graphics.RectF(
b.bounds.left * scaleX,
b.bounds.top * scaleY,
b.bounds.right * scaleX,
b.bounds.bottom * scaleY
))
}
val rowHeight = actualBitmapHeightPx * 0.1f
detectedBubbles = logicalBubbles.sortedWith(compareBy<SpeechBubble> { (it.bounds.centerY() / rowHeight).roundToInt() }.thenBy { it.bounds.centerX() })
expandedBubbleIndex = -1
Timber.tag("BubbleZoom").d("Sorted logical bubbles count: ${detectedBubbles.size}")
} catch (e: Exception) {
Timber.tag("BubbleZoom").e(e, "Bubble detection failed with exception")
} finally {
isDetectingBubbles = false
}
} else {
Timber.tag("BubbleZoom").d("Conditions NOT met or mode disabled. Clearing bubbles.")
detectedBubbles = emptyList()
expandedBubbleIndex = -1
expandedBubbleRender?.bitmap?.takeUnless { it.isRecycled }?.recycle()
expandedBubbleRender = null
if (!isBubbleZoomModeActive && scale > 1f && !isVerticalScroll && isZoomEnabled) {
coroutineScope.launch {
Animatable(scale).animateTo(1f, tween(300)) {
scale = this.value
offset = Offset.Zero
onScaleChanged(scale)
}
}
}
}
}
LaunchedEffect(
animatingBubbleIndex,
detectedBubbles,
actualBitmapWidthPx,
actualBitmapHeightPx,
canvasWidthPx.floatValue,
canvasHeightPx.floatValue,
isBubbleZoomModeActive,
isPdfPage,
pdfPageIndex
) {
val previousRender = expandedBubbleRender
expandedBubbleRender = null
previousRender?.bitmap?.takeUnless { it.isRecycled }?.recycle()
if (!isBubbleZoomModeActive || !isPdfPage || animatingBubbleIndex !in detectedBubbles.indices) {
return@LaunchedEffect
}
val bubble = detectedBubbles[animatingBubbleIndex]
val zoomFactor = computeDynamicBubbleZoomFactor(
bubbleBounds = bubble.bounds,
viewportWidth = canvasWidthPx.floatValue.coerceAtLeast(actualBitmapWidthPx.toFloat()),
viewportHeight = canvasHeightPx.floatValue.coerceAtLeast(actualBitmapHeightPx.toFloat())
)
val renderScale = (zoomFactor * 1.2f).coerceAtLeast(1.6f)
val renderedBubble = renderExpandedBubbleBitmap(
document = pdfDocumentItem,
pageIndex = pdfPageIndex,
bubbleBounds = bubble.bounds,
pageWidth = actualBitmapWidthPx,
pageHeight = actualBitmapHeightPx,
renderScale = renderScale
)
if (renderedBubble != null) {
expandedBubbleRender = ExpandedBubbleRender(
bitmap = renderedBubble,
zoomFactor = zoomFactor
)
}
}
DisposableEffect(Unit) {
onDispose {
val currentBitmap = bitmapState
@ -653,6 +855,7 @@ internal fun PdfPageComposable(
if (currentBitmap != null && !currentBitmap.isRecycled && currentBitmap !== cachedBitmap) {
currentBitmap.recycle()
}
expandedBubbleRender?.bitmap?.takeUnless { it.isRecycled }?.recycle()
}
}
@ -1646,6 +1849,32 @@ internal fun PdfPageComposable(
}
}
LaunchedEffect(resetZoomTrigger) {
if (resetZoomTrigger != 0L && scale > 1f && isZoomEnabled && !isVerticalScroll && !isScrollLocked) {
coroutineScope.launch {
val startScale = scale
val startOffset = offset
Animatable(0f).animateTo(
1f, animationSpec = tween(durationMillis = 300)
) {
val progress = value
scale = androidx.compose.ui.util.lerp(
startScale, 1f, progress
)
offset = androidx.compose.ui.geometry.lerp(
startOffset, Offset.Zero, progress
)
onScaleChanged(scale)
}
if (scale <= 1.05f) {
scale = 1f
offset = Offset.Zero
onScaleChanged(scale)
}
}
}
}
val errorSelection = stringResource(R.string.error_selection)
val errorOcrSelection = stringResource(R.string.error_ocr_selection)
val errorProcessingPage = stringResource(R.string.error_processing_page)
@ -2487,7 +2716,8 @@ internal fun PdfPageComposable(
isEditMode,
selectedTool,
isStylusOnlyMode,
userHighlightScreenRects
userHighlightScreenRects,
bubbleTapSlopPx
) {
val isTapDetectionAllowed = !isEditMode ||
selectedTool == InkType.TEXT ||
@ -2496,9 +2726,48 @@ internal fun PdfPageComposable(
if (!isTapDetectionAllowed) return@pointerInput
detectTapGestures(onTap = { tapOffset ->
if (currentOnPreSingleTap?.invoke(tapOffset) == true) {
return@detectTapGestures
}
val tapInContentCoords = screenToContentCoordinates(tapOffset)
val tapXInBitmap = tapInContentCoords.x
val tapYInBitmap = tapInContentCoords.y
val isWithinContentBounds =
tapXInBitmap in 0f..actualBitmapWidthPx.toFloat() &&
tapYInBitmap in 0f..actualBitmapHeightPx.toFloat()
if (!isWithinContentBounds) {
currentOnSingleTap(tapOffset)
return@detectTapGestures
}
Timber.tag("BubbleZoom").d("Tap inside bounds. modeActive=$currentBubbleZoomModeActive, detectedBubbles=${currentDetectedBubbles.size}, tapPos=($tapXInBitmap, $tapYInBitmap)")
if (currentBubbleZoomModeActive && currentDetectedBubbles.isNotEmpty()) {
val tappedBubbleIndex = currentDetectedBubbles.indexOfFirst { bubble ->
isTapInsideBubble(
bubble = bubble,
tapX = tapXInBitmap,
tapY = tapYInBitmap,
hitSlopPx = bubbleTapSlopPx
)
}
Timber.tag("BubbleZoom").d("Tapped bubble index: $tappedBubbleIndex (expandedIndex=$currentExpandedBubbleIndex)")
if (tappedBubbleIndex != -1) {
expandedBubbleIndex = if (currentExpandedBubbleIndex == tappedBubbleIndex) {
-1
} else {
tappedBubbleIndex
}
return@detectTapGestures
} else if (currentExpandedBubbleIndex != -1) {
expandedBubbleIndex = -1
return@detectTapGestures
}
}
coroutineScope.launch {
val nativeResult = withContext(Dispatchers.IO) {
@ -2660,7 +2929,7 @@ internal fun PdfPageComposable(
currentPageRotation,
)
} else {
currentOnSingleTap()
currentOnSingleTap(tapOffset)
}
}
}, onDoubleTap = { tapOffset ->
@ -2670,37 +2939,6 @@ internal fun PdfPageComposable(
val startScale = scale
val targetScale = if (startScale > 1.1f) 1f else 2.5f
if (com.aryan.reader.BuildConfig.DEBUG && startScale <= 1.1f && bitmapState != null) {
val tapInContentCoords = screenToContentCoordinates(tapOffset)
val ratioX = bitmapState!!.width.toFloat() / actualBitmapWidthPx.toFloat()
val ratioY = bitmapState!!.height.toFloat() / actualBitmapHeightPx.toFloat()
val tapXInBitmap = tapInContentCoords.x * ratioX
val tapYInBitmap = tapInContentCoords.y * ratioY
val panels = onDetectPanels(bitmapState!!)
val tappedPanel = panels.firstOrNull {
it.contains(tapXInBitmap, tapYInBitmap)
}
if (tappedPanel != null) {
Timber.d("Popup: Cropping panel $tappedPanel")
val left = tappedPanel.left.coerceAtLeast(0f).toInt()
val top = tappedPanel.top.coerceAtLeast(0f).toInt()
val right = tappedPanel.right.coerceAtMost(bitmapState!!.width.toFloat()).toInt()
val bottom = tappedPanel.bottom.coerceAtMost(bitmapState!!.height.toFloat()).toInt()
val width = right - left
val height = bottom - top
if (width > 0 && height > 0) {
val cropped = android.graphics.Bitmap.createBitmap(bitmapState!!, left, top, width, height)
onShowPanelPopup(cropped)
return@launch
}
}
}
val startOffset = offset
val targetOffsetUnbounded = if (targetScale <= 1.1f) {
Offset.Zero
@ -3017,12 +3255,20 @@ internal fun PdfPageComposable(
return@awaitEachGesture
}
val buttons = currentEvent.buttons
Timber.tag("StylusEraserDiagnostic").d(
"Page $pageIndex | Type: ${down.type} | isPrimary: ${buttons.isPrimaryPressed} | isSecondary: ${buttons.isSecondaryPressed} | isTertiary: ${buttons.isTertiaryPressed} | buttonsString: $buttons"
)
val isEraserOverride = down.type == PointerType.Eraser || (down.type == PointerType.Stylus && currentEvent.buttons.isSecondaryPressed)
isStylusEraserOverride = isEraserOverride
val dragPointerId = down.id
val startPos = down.position
var dragStarted = false
val touchSlop = viewConfiguration.touchSlop
if (selectedTool == InkType.ERASER) {
if (selectedTool == InkType.ERASER || isEraserOverride) {
eraserPosition = down.position
}
@ -3034,6 +3280,7 @@ internal fun PdfPageComposable(
drawingState?.onDrawCancel()
}
eraserPosition = null
isStylusEraserOverride = false
return@awaitEachGesture
}
@ -3051,12 +3298,13 @@ internal fun PdfPageComposable(
val normY =
(contentPos.y / actualBitmapHeightPx).coerceIn(0f, 1f)
onDrawStart(PdfPoint(normX, normY))
onDrawStart(PdfPoint(normX, normY), isEraserOverride)
onDrawEnd()
} else {
onDrawEnd()
}
eraserPosition = null
isStylusEraserOverride = false
return@awaitEachGesture
}
@ -3077,7 +3325,7 @@ internal fun PdfPageComposable(
0f, 1f
)
onDrawStart(
PdfPoint(startNormX, startNormY)
PdfPoint(startNormX, startNormY), isEraserOverride
)
val currContentPos = screenToContentCoordinates(
@ -3091,9 +3339,9 @@ internal fun PdfPageComposable(
(currContentPos.y / actualBitmapHeightPx).coerceIn(
0f, 1f
)
onDraw(PdfPoint(currNormX, currNormY))
onDraw(PdfPoint(currNormX, currNormY), isEraserOverride)
if (selectedTool == InkType.ERASER) {
if (selectedTool == InkType.ERASER || isEraserOverride) {
eraserPosition = change.position
}
change.consume()
@ -3106,9 +3354,9 @@ internal fun PdfPageComposable(
(currContentPos.x / actualBitmapWidthPx).coerceIn(0f, 1f)
val currNormY =
(currContentPos.y / actualBitmapHeightPx).coerceIn(0f, 1f)
onDraw(PdfPoint(currNormX, currNormY))
onDraw(PdfPoint(currNormX, currNormY), isEraserOverride)
if (selectedTool == InkType.ERASER) {
if (selectedTool == InkType.ERASER || isEraserOverride) {
eraserPosition = change.position
}
change.consume()
@ -3118,6 +3366,7 @@ internal fun PdfPageComposable(
}
} finally {
eraserPosition = null
isStylusEraserOverride = false
}
}, contentAlignment = Alignment.Center
) {
@ -3230,10 +3479,6 @@ internal fun PdfPageComposable(
offset = Offset.Zero
onScaleChanged(1f)
}
Timber.d(
"PdfPageComposable Page $pageIndex initialized/resized/locked. scale=$scale, offset=$offset"
)
}
LaunchedEffect(
@ -3386,15 +3631,8 @@ internal fun PdfPageComposable(
val viewContainerHeightPx =
with(density) { currentContainerMaxHeight.toPx().toInt() }
Timber.d(
"PdfPageComposable Page $pageIndex | viewContainerPx: ${viewContainerWidthPx}x${viewContainerHeightPx}"
)
if (viewContainerWidthPx <= 0 || viewContainerHeightPx <= 0) {
if (bitmapState == null) isLoadingPage = true
Timber.d(
"PdfPageComposable: viewContainer dimensions invalid ($viewContainerWidthPx x $viewContainerHeightPx), waiting."
)
return@LaunchedEffect
}
@ -3948,6 +4186,7 @@ internal fun PdfPageComposable(
isEditMode = isEditMode,
selectedTool = selectedTool,
eraserPosition = eraserPosition,
isStylusEraserOverride = isStylusEraserOverride,
activeToolThickness = activeToolThickness,
richTextController = richTextController,
textBoxes = textBoxes,
@ -3960,7 +4199,14 @@ internal fun PdfPageComposable(
onDragPageTurn = onDragPageTurn,
draggingBoxId = draggingBoxId,
customHighlightColors = customHighlightColors,
onPaletteClick = onPaletteClick
onPaletteClick = onPaletteClick,
isBubbleZoomModeActive = isBubbleZoomModeActive,
isActivePage = isActivePage,
isDetectingBubbles = isDetectingBubbles,
detectedBubbles = detectedBubbles,
animatingBubbleIndex = animatingBubbleIndex,
bubbleExpansionProgress = bubbleExpansionProgress.value,
expandedBubbleRender = expandedBubbleRender
)
}
@ -4080,15 +4326,10 @@ private fun PdfBitmapLayer(
if (excludeImages && colorFilter != null && imageRects.isNotEmpty()) {
imageRects.forEach { imgRect ->
val scaledImgRectLeft = (imgRect.left * effectiveScale).roundToInt()
val scaledImgRectTop = (imgRect.top * effectiveScale).roundToInt()
val scaledImgRectRight = (imgRect.right * effectiveScale).roundToInt()
val scaledImgRectBottom = (imgRect.bottom * effectiveScale).roundToInt()
val intersectLeft = max(scaledImgRectLeft, tile.renderRect.left)
val intersectTop = max(scaledImgRectTop, tile.renderRect.top)
val intersectRight = min(scaledImgRectRight, tile.renderRect.right)
val intersectBottom = min(scaledImgRectBottom, tile.renderRect.bottom)
val intersectLeft = max(imgRect.left, tile.renderRect.left)
val intersectTop = max(imgRect.top, tile.renderRect.top)
val intersectRight = min(imgRect.right, tile.renderRect.right)
val intersectBottom = min(imgRect.bottom, tile.renderRect.bottom)
val iw = intersectRight - intersectLeft
val ih = intersectBottom - intersectTop
@ -4151,7 +4392,6 @@ private fun PdfHighlightsLayer(
selectionHighlightColor: Color,
customHighlightColors: Map<PdfHighlightColor, Color> = emptyMap()
) {
Timber.d("PdfHighlightsLayer Recompose")
Canvas(modifier = Modifier
.fillMaxSize()
.graphicsLayer()) {
@ -4751,6 +4991,7 @@ private fun PdfPageRenderer(
isEditMode: Boolean,
selectedTool: InkType,
eraserPosition: Offset?,
isStylusEraserOverride: Boolean,
richTextController: RichTextController?,
textBoxes: List<PdfTextBox>,
selectedTextBoxId: String?,
@ -4769,6 +5010,13 @@ private fun PdfPageRenderer(
onTts: (Int, Int) -> Unit,
activeToolThickness: Float,
onNote: (String?) -> Unit,
isBubbleZoomModeActive: Boolean = false,
isActivePage: Boolean = true,
isDetectingBubbles: Boolean = false,
detectedBubbles: List<SpeechBubble> = emptyList(),
animatingBubbleIndex: Int = -1,
bubbleExpansionProgress: Float = 0f,
expandedBubbleRender: ExpandedBubbleRender? = null
) {
Box(modifier = Modifier.fillMaxSize()) {
Box(
@ -4976,7 +5224,7 @@ private fun PdfPageRenderer(
val teardropPainter = painterResource(id = R.drawable.teardrop)
if (isEditMode && selectedTool == InkType.ERASER && eraserPosition != null) {
if (isEditMode && (selectedTool == InkType.ERASER || isStylusEraserOverride) && eraserPosition != null) {
Canvas(modifier = Modifier.fillMaxSize()) {
val radiusPx = if (activeToolThickness > 0f && staticData.targetWidth > 0) {
activeToolThickness * staticData.targetWidth * scale // Calculate dynamic size based on tool settings scale
@ -5203,6 +5451,150 @@ private fun PdfPageRenderer(
if (isPerformingOcr && ocrRipplePos != null) {
OcrProcessingIndicator(position = ocrRipplePos)
}
if (isBubbleZoomModeActive && isActivePage) {
if (isDetectingBubbles) {
androidx.compose.material3.CircularProgressIndicator(
modifier = Modifier.align(Alignment.Center)
)
} else if (detectedBubbles.isNotEmpty()) {
Canvas(modifier = Modifier.fillMaxSize().zIndex(20f)) {
// Draw shadow-like hints for unexpanded bubbles
detectedBubbles.forEachIndexed { index, bubble ->
val hintAlpha = if (index == animatingBubbleIndex) 0.35f * (1f - bubbleExpansionProgress) else 0.35f
if (hintAlpha > 0f) {
val left = bubble.bounds.left + staticData.centeringOffsetX
val top = bubble.bounds.top + staticData.centeringOffsetY
val width = bubble.bounds.width()
val height = bubble.bounds.height()
if (bubble.maskBitmap != null) {
drawImage(
image = bubble.maskBitmap.asImageBitmap(),
dstOffset = IntOffset(left.toInt(), top.toInt()),
dstSize = IntSize(width.toInt(), height.toInt()),
colorFilter = ColorFilter.tint(Color.Black.copy(alpha = hintAlpha)),
filterQuality = androidx.compose.ui.graphics.FilterQuality.High
)
} else {
drawRoundRect(
color = Color.Black.copy(alpha = hintAlpha),
topLeft = Offset(left, top),
size = Size(width, height),
cornerRadius = androidx.compose.ui.geometry.CornerRadius(24f, 24f)
)
}
}
}
if (animatingBubbleIndex in detectedBubbles.indices && staticData.bitmap.item != null && bubbleExpansionProgress > 0f) {
val bubble = detectedBubbles[animatingBubbleIndex]
val left = bubble.bounds.left + staticData.centeringOffsetX
val top = bubble.bounds.top + staticData.centeringOffsetY
val logicalWidth = bubble.bounds.width()
val logicalHeight = bubble.bounds.height()
val pivotX = left + logicalWidth / 2f
val pivotY = top + logicalHeight / 2f
val targetZoomFactor = expandedBubbleRender?.zoomFactor ?: computeDynamicBubbleZoomFactor(
bubbleBounds = bubble.bounds,
viewportWidth = staticData.canvasWidth,
viewportHeight = staticData.canvasHeight
)
val zoomFactor = androidx.compose.ui.util.lerp(1f, targetZoomFactor, bubbleExpansionProgress)
withTransform({
scale(zoomFactor, zoomFactor, Offset(pivotX, pivotY))
}) {
val dstOffset = IntOffset(left.toInt(), top.toInt())
val dstSize = IntSize(logicalWidth.toInt(), logicalHeight.toInt())
val renderScaleX = staticData.bitmap.item.width.toFloat() / staticData.targetWidth.toFloat()
val renderScaleY = staticData.bitmap.item.height.toFloat() / staticData.targetHeight.toFloat()
val srcOffset = IntOffset(
(bubble.bounds.left * renderScaleX).toInt(),
(bubble.bounds.top * renderScaleY).toInt()
)
val srcSize = IntSize(
(logicalWidth * renderScaleX).toInt(),
(logicalHeight * renderScaleY).toInt()
)
if (bubble.maskBitmap != null) {
drawImage(
image = bubble.maskBitmap.asImageBitmap(),
dstOffset = IntOffset(left.toInt() + 12, top.toInt() + 12),
dstSize = dstSize,
colorFilter = ColorFilter.tint(Color.Black.copy(alpha = 0.5f * bubbleExpansionProgress)),
filterQuality = androidx.compose.ui.graphics.FilterQuality.High
)
} else {
drawRoundRect(
color = Color.Black.copy(alpha = 0.5f * bubbleExpansionProgress),
topLeft = Offset(left + 12f, top + 12f),
size = Size(logicalWidth, logicalHeight),
cornerRadius = androidx.compose.ui.geometry.CornerRadius(24f, 24f)
)
}
if (bubble.maskBitmap != null) {
val rect = androidx.compose.ui.geometry.Rect(
dstOffset.x.toFloat(),
dstOffset.y.toFloat(),
dstOffset.x.toFloat() + dstSize.width,
dstOffset.y.toFloat() + dstSize.height
)
drawContext.canvas.saveLayer(rect, androidx.compose.ui.graphics.Paint())
drawImage(
image = (expandedBubbleRender?.bitmap ?: staticData.bitmap.item).asImageBitmap(),
srcOffset = if (expandedBubbleRender != null) IntOffset.Zero else srcOffset,
srcSize = if (expandedBubbleRender != null) {
IntSize(
expandedBubbleRender.bitmap.width,
expandedBubbleRender.bitmap.height)
} else {
srcSize
},
dstOffset = dstOffset,
dstSize = dstSize,
filterQuality = androidx.compose.ui.graphics.FilterQuality.High
)
drawImage(
image = bubble.maskBitmap.asImageBitmap(),
dstOffset = dstOffset,
dstSize = dstSize,
blendMode = BlendMode.DstIn,
filterQuality = androidx.compose.ui.graphics.FilterQuality.High
)
drawContext.canvas.restore()
} else {
clipRect(left, top, left + logicalWidth, top + logicalHeight) {
drawImage(
image = (expandedBubbleRender?.bitmap ?: staticData.bitmap.item).asImageBitmap(),
srcOffset = if (expandedBubbleRender != null) IntOffset.Zero else srcOffset,
srcSize = if (expandedBubbleRender != null) {
IntSize(
expandedBubbleRender.bitmap.width,
expandedBubbleRender.bitmap.height)
} else {
srcSize
},
dstOffset = dstOffset,
dstSize = dstSize
)
}
drawRect(
color = Color.White.copy(alpha = 0.5f * bubbleExpansionProgress),
topLeft = Offset(left, top),
size = Size(logicalWidth, logicalHeight),
style = Stroke(width = 4f)
)
}
}
}
}
}
}
}
}
@ -5409,4 +5801,4 @@ private fun getNativePointer(obj: Any): Long {
} catch (_: Exception) {}
return 0L
}
}

View file

@ -47,6 +47,7 @@ enum class PdfReaderTool(val title: String, val category: String) {
THEME("Theme Settings", "Top Bar"),
LOCK_PANNING("Lock Panning", "Top Bar"),
VISUAL_OPTIONS("Visual Options", "Overflow Menu"),
TAP_TO_TURN("Tap to Turn Pages", "Overflow Menu"),
FULL_SCREEN("Full Screen", "Top Bar"),
SLIDER("Navigation Slider", "Bottom Bar"),
TOC("Sidebar", "Bottom Bar"),
@ -370,4 +371,4 @@ internal fun savePdfDarkMode(context: Context, isDark: Boolean) {
internal fun loadPdfDarkMode(context: Context): Boolean {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
return prefs.getBoolean(PDF_DARK_MODE_KEY, false)
}
}

View file

@ -1,229 +0,0 @@
package com.aryan.reader.pdf
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import io.legere.pdfiumandroid.api.Bookmark
import io.legere.pdfiumandroid.suspend.PdfDocumentKt
import org.json.JSONArray
import timber.log.Timber
private const val MAX_FIXED_RECURSION = 128
internal data class PdfBookmark(val pageIndex: Int, val title: String, val totalPages: Int)
internal data class TocEntry(val title: String, val pageIndex: Int, val nestLevel: Int)
/**
* Patches the library bug where siblings are truncated due to depth-state leakage.
*/
suspend fun PdfDocumentKt.getFixedTableOfContents(): List<Bookmark> {
val tag = "PdfTocFix"
Timber.tag(tag).i("Starting Pure Reflection Traversal...")
return try {
// 1. Get the 'document' field (PdfDocumentU) from PdfDocumentKt
val documentField = PdfDocumentKt::class.java.getDeclaredField("document").apply { isAccessible = true }
val docUInstance = documentField.get(this) ?: return getTableOfContents()
// 2. Get the 'nativeDocument' field from PdfDocumentU
val nativeDocField = docUInstance.javaClass.getDeclaredField("nativeDocument").apply { isAccessible = true }
val nativeDocInstance = nativeDocField.get(docUInstance) ?: return getTableOfContents()
// 3. Get the native pointer (long) from PdfDocumentU
val ptrField = docUInstance.javaClass.getDeclaredField("mNativeDocPtr").apply { isAccessible = true }
val mNativeDocPtr = ptrField.get(docUInstance) as Long
// 4. Look up native methods using primitive 'long' types (mandatory for JNI)
val nClass = nativeDocInstance.javaClass
val lp = Long::class.javaPrimitiveType!! // Shorthand for 'long'
val getTitleM = nClass.getMethod("getBookmarkTitle", lp)
val getDestIdxM = nClass.getMethod("getBookmarkDestIndex", lp, lp)
val getFirstChildM = nClass.getMethod("getFirstChildBookmark", lp, lp)
val getSiblingM = nClass.getMethod("getSiblingBookmark", lp, lp)
val topLevel = mutableListOf<Bookmark>()
val visited = mutableSetOf<Long>()
/**
* Corrected traversal: Iterative for siblings, recursive for children.
*/
fun walk(parentList: MutableList<Bookmark>, startPtr: Long, level: Int) {
var currentPtr = startPtr
var itemIndex = 0
while (currentPtr != 0L) {
if (visited.contains(currentPtr)) break
visited.add(currentPtr)
val title = getTitleM.invoke(nativeDocInstance, currentPtr) as? String ?: "Untitled"
val pageIdx = getDestIdxM.invoke(nativeDocInstance, mNativeDocPtr, currentPtr) as Long
Timber.tag(tag).v("Lvl $level | Item $itemIndex | Ptr: 0x${java.lang.Long.toHexString(currentPtr)} | $title")
val bookmark = Bookmark().apply {
this.mNativePtr = currentPtr
this.title = title
this.pageIdx = pageIdx
}
parentList.add(bookmark)
// Recursive dive into children
val firstChild = getFirstChildM.invoke(nativeDocInstance, mNativeDocPtr, currentPtr) as Long
if (firstChild != 0L && level < MAX_FIXED_RECURSION) {
walk(bookmark.children, firstChild, level + 1)
}
// Iterative move to next sibling
currentPtr = getSiblingM.invoke(nativeDocInstance, mNativeDocPtr, currentPtr) as Long
itemIndex++
}
}
// 5. Start from the root (Pass 0L as primitive long)
val firstRoot = getFirstChildM.invoke(nativeDocInstance, mNativeDocPtr, 0L) as Long
if (firstRoot != 0L) {
walk(topLevel, firstRoot, 0)
}
if (topLevel.isEmpty()) {
Timber.tag(tag).w("No items found, falling back to library.")
getTableOfContents()
} else {
Timber.tag(tag).i("TOC Successfully Patched! Nodes: ${visited.size}")
topLevel
}
} catch (e: Exception) {
Timber.tag(tag).e(e, "Reflection traversal critical error.")
this.getTableOfContents()
}
}
internal fun flattenToc(bookmarks: List<Bookmark>, level: Int = 0): List<TocEntry> {
Timber.tag("PdfTocDebug").d("Processing level $level with ${bookmarks.size} items")
val entries = mutableListOf<TocEntry>()
for ((index, bookmark) in bookmarks.withIndex()) {
val title = bookmark.title ?: "Untitled Chapter"
val childCount = bookmark.children.size
Timber.tag("PdfTocDebug").d(
"Lvl $level | Item $index: \"$title\" (Page: ${bookmark.pageIdx}) | Children: $childCount"
)
entries.add(
TocEntry(
title = title,
pageIndex = bookmark.pageIdx.toInt(),
nestLevel = level
)
)
if (childCount > 0) {
Timber.tag("PdfTocDebug").v("Entering children of \"$title\"")
entries.addAll(flattenToc(bookmark.children, level + 1))
Timber.tag("PdfTocDebug").v("Returned to Lvl $level from \"$title\"")
}
}
return entries
}
internal fun loadPdfBookmarksFromJson(bookmarksJson: String?): Set<PdfBookmark> {
if (bookmarksJson.isNullOrBlank()) return emptySet()
return try {
val jsonArray = JSONArray(bookmarksJson)
(0 until jsonArray.length()).mapNotNull { i ->
try {
val json = jsonArray.getJSONObject(i)
PdfBookmark(
pageIndex = json.getInt("pageIndex"),
title = json.getString("title"),
totalPages = json.getInt("totalPages")
)
} catch (e: Exception) {
Timber.e(e, "Failed to parse bookmark from JSON object")
null
}
}.toSet()
} catch (e: Exception) {
Timber.e(e, "Failed to parse bookmarks from JSON string: $bookmarksJson")
emptySet()
}
}
@Composable
internal fun PdfTocTreeItem(
label: String,
nestLevel: Int,
isExpanded: Boolean,
hasChildren: Boolean,
isCurrent: Boolean,
onToggleExpand: () -> Unit,
onClick: () -> Unit
) {
val backgroundColor by animateColorAsState(
targetValue = if (isCurrent) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.2f) else Color.Transparent,
label = "TocItemBackground"
)
val contentColor = if (isCurrent) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface
Row(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 48.dp)
.background(backgroundColor)
.clickable(onClick = onClick)
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Spacer(modifier = Modifier.width((16 * nestLevel).dp))
Box(
modifier = Modifier
.size(40.dp)
.clickable(enabled = hasChildren, onClick = onToggleExpand),
contentAlignment = Alignment.Center
) {
if (hasChildren) {
Icon(
imageVector = if (isExpanded) Icons.Default.KeyboardArrowDown else Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = if (isExpanded) "Collapse" else "Expand",
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Text(
text = label,
style = if (nestLevel == 0) MaterialTheme.typography.bodyLarge else MaterialTheme.typography.bodyMedium,
fontWeight = if (isCurrent) FontWeight.Bold else if (nestLevel == 0) FontWeight.SemiBold else FontWeight.Normal,
color = contentColor,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f).padding(end = 16.dp)
)
}
}

View file

@ -18,6 +18,7 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.Undo
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
@ -33,6 +34,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.sp
import com.aryan.reader.BuildConfig
import com.aryan.reader.FileType
import com.aryan.reader.R
@ -81,6 +83,8 @@ internal fun PdfTopBar(
onShowCustomizeTools: () -> Unit,
onShowOcrLanguage: () -> Unit,
onShowVisualOptions: () -> Unit,
tapToNavigateEnabled: Boolean,
onToggleTapToNavigate: () -> Unit,
onChangeDisplayMode: (DisplayMode) -> Unit,
onToggleKeepScreenOn: () -> Unit,
onStartAutoScroll: () -> Unit,
@ -94,7 +98,8 @@ internal fun PdfTopBar(
onPrint: () -> Unit,
onTabClick: (String) -> Unit,
onTabClose: (String) -> Unit,
onNewTabClick: () -> Unit
onNewTabClick: () -> Unit,
onGenerateDemoAnnotations: () -> Unit
) {
AnimatedVisibility(
visible = showStandardBars,
@ -179,6 +184,9 @@ internal fun PdfTopBar(
}
if (BuildConfig.DEBUG) {
TooltipIconButton(text = "Demo Annotations", onClick = onGenerateDemoAnnotations) {
Icon(Icons.Default.BugReport, contentDescription = "Generate Demo Annotations", tint = MaterialTheme.colorScheme.secondary)
}
TooltipIconButton(text = stringResource(R.string.pen_playground), onClick = onShowPenPlayground) {
Icon(Icons.Default.Star, contentDescription = "Open Pen Playground", tint = MaterialTheme.colorScheme.primary)
}
@ -238,6 +246,26 @@ internal fun PdfTopBar(
HorizontalDivider()
}
if (!hiddenTools.contains(PdfReaderTool.TAP_TO_TURN.name)) {
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_tap_to_turn_pages)) },
enabled = displayMode == DisplayMode.PAGINATION,
onClick = {
onToggleTapToNavigate()
showMoreMenu = false
},
trailingIcon = {
if (tapToNavigateEnabled) {
Icon(
Icons.Filled.Check,
contentDescription = stringResource(R.string.content_desc_enabled)
)
}
}
)
HorizontalDivider()
}
if (!hiddenTools.contains(PdfReaderTool.KEEP_SCREEN_ON.name)) {
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_keep_screen_on)) },
@ -433,13 +461,17 @@ fun PdfBottomBar(
isEditMode: Boolean,
isTtsSessionActive: Boolean,
ttsErrorMessage: String?,
jumpBackPage: Int?,
onJumpBack: () -> Unit,
onShowSlider: () -> Unit,
onShowToc: () -> Unit,
onSearchClick: () -> Unit,
onToggleHighlights: () -> Unit,
onShowAiHub: () -> Unit,
onToggleEditMode: () -> Unit,
onToggleTts: () -> Unit
onToggleTts: () -> Unit,
isBubbleZoomModeActive: Boolean,
onToggleBubbleZoom: () -> Unit
) {
AnimatedVisibility(
visible = showStandardBars && !searchStateActive,
@ -456,8 +488,35 @@ fun PdfBottomBar(
Row(
modifier = Modifier.fillMaxWidth().padding(bottom = bottomBarPadding).height(56.dp).padding(horizontal = 8.dp).horizontalScroll(bottomBarScrollState),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
horizontalArrangement = Arrangement.SpaceEvenly
) {
if (jumpBackPage != null) {
TooltipIconButton(
text = "Jump Back to Page ${jumpBackPage + 1}",
description = "Return to previous page",
onClick = onJumpBack
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(
Icons.AutoMirrored.Filled.Undo,
contentDescription = "Jump Back",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(16.dp)
)
Text(
text = "${jumpBackPage + 1}",
fontSize = 10.sp,
lineHeight = 10.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
}
}
}
if (!hiddenTools.contains(PdfReaderTool.SLIDER.name)) {
TooltipIconButton(
text = stringResource(R.string.tooltip_slider),
@ -532,10 +591,24 @@ fun PdfBottomBar(
}
}
if (BuildConfig.FLAVOR != "oss") {
TooltipIconButton(
text = if (isBubbleZoomModeActive) "Exit Smart Zoom" else "Smart Comic Zoom",
description = "Toggle Smart Comic Zoom",
onClick = onToggleBubbleZoom
) {
Icon(
painterResource(R.drawable.comic_bubble),
contentDescription = "Smart Comic Zoom",
tint = if (isBubbleZoomModeActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
ttsErrorMessage?.let {
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall, modifier = Modifier.weight(1f).padding(start = 8.dp), maxLines = 2, overflow = TextOverflow.Ellipsis)
}
}
}
}
}
}

View file

@ -23,6 +23,7 @@
package com.aryan.reader.pdf
import android.annotation.SuppressLint
import android.graphics.Bitmap
import android.graphics.RectF
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState
@ -90,6 +91,9 @@ import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.PointerType
import androidx.compose.ui.input.pointer.isPrimaryPressed
import androidx.compose.ui.input.pointer.isSecondaryPressed
import androidx.compose.ui.input.pointer.isTertiaryPressed
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.positionChanged
import androidx.compose.ui.input.pointer.util.VelocityTracker
@ -108,6 +112,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex
import com.aryan.reader.SearchResult
import com.aryan.reader.ml.SpeechBubble
import com.aryan.reader.pdf.data.PdfAnnotation
import com.aryan.reader.pdf.data.PdfTextBox
import com.aryan.reader.pdf.data.VirtualPage
@ -217,8 +222,8 @@ internal fun PdfVerticalReader(
isEditMode: Boolean = false,
allAnnotations: () -> Map<Int, List<PdfAnnotation>> = { emptyMap() },
drawingState: PdfDrawingState,
onDrawStart: (Int, PdfPoint) -> Unit,
onDraw: (Int, PdfPoint) -> Unit,
onDrawStart: (Int, PdfPoint, Boolean) -> Unit,
onDraw: (Int, PdfPoint, Boolean) -> Unit,
onDrawEnd: () -> Unit,
onOcrModelDownloading: () -> Unit = {},
selectedTool: InkType,
@ -247,7 +252,10 @@ internal fun PdfVerticalReader(
customHighlightColors: Map<PdfHighlightColor, Color> = emptyMap(),
onPaletteClick: () -> Unit = {},
lockedState: Triple<Float, Float, Float>? = null,
onZoomAndPanChanged: ((Float, Offset) -> Unit)? = null
onZoomAndPanChanged: ((Float, Offset) -> Unit)? = null,
resetZoomTrigger: Long = 0L,
isBubbleZoomModeActive: Boolean = false,
onDetectBubbles: suspend (Int, Bitmap) -> List<SpeechBubble> = { _, _ -> emptyList() }
) {
SideEffect { Timber.tag("PdfDrawPerf").v("LIST: PdfVerticalReader Recomposing.") }
DisposableEffect(state) {
@ -260,6 +268,7 @@ internal fun PdfVerticalReader(
}
}
var globalEraserPosition by remember { mutableStateOf<Offset?>(null) }
var isStylusEraserOverride by remember { mutableStateOf(false) }
val isDarkMode = activeTheme.isDark || activeTheme.id == "reverse"
BoxWithConstraints(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.TopStart) {
val imeInsets = WindowInsets.ime
@ -393,18 +402,17 @@ internal fun PdfVerticalReader(
val zoomedDocHeight = totalDocHeight * savedScale
val minPanY = (screenHeight - footerHeightPx - zoomedDocHeight).coerceAtMost(headerHeightPx)
val maxPanY = headerHeightPx
zoomAnimatable.stop()
panXAnimatable.stop()
panYAnimatable.stop()
panXAnimatable.updateBounds(minPanX, maxPanX)
panYAnimatable.updateBounds(minPanY, maxPanY)
panYAnimatable.updateBounds(minPanY, headerHeightPx)
zoomAnimatable.snapTo(savedScale)
panXAnimatable.snapTo(savedPanX)
panYAnimatable.snapTo(savedPanY.coerceIn(minPanY, maxPanY))
panYAnimatable.snapTo(savedPanY.coerceIn(minPanY, headerHeightPx))
Timber.tag("PdfLockDiagnostic").d("RESTORE SNAP COMPLETE: Scale=${zoomAnimatable.value}, X=${panXAnimatable.value}, Y=${panYAnimatable.value}")
@ -492,6 +500,65 @@ internal fun PdfVerticalReader(
return clampValues(targetZoom, targetPanX, targetPanY)
}
LaunchedEffect(resetZoomTrigger) {
if (resetZoomTrigger != 0L && zoomAnimatable.value > fitZoom && !isScrollLocked) {
scope.launch {
zoomAnimatable.stop()
panXAnimatable.stop()
panYAnimatable.stop()
val startZoom = zoomAnimatable.value
val startPanX = panXAnimatable.value
val startPanY = panYAnimatable.value
val pivotScreenX = screenWidth / 2f
val pivotScreenY = screenHeight / 2f
val pivotContentX = (pivotScreenX - startPanX) / startZoom
val pivotContentY = (pivotScreenY - startPanY) / startZoom
val rawNextPanX = pivotScreenX - (pivotContentX * fitZoom)
val rawNextPanY = pivotScreenY - (pivotContentY * fitZoom)
val (finalZoom, finalX, finalY) = clampCamera(fitZoom, rawNextPanX, rawNextPanY)
panXAnimatable.updateBounds(
lowerBound = minOf(panXAnimatable.lowerBound ?: finalX, finalX, startPanX),
upperBound = maxOf(panXAnimatable.upperBound ?: finalX, finalX, startPanX)
)
panYAnimatable.updateBounds(
lowerBound = minOf(panYAnimatable.lowerBound ?: finalY, finalY, startPanY),
upperBound = maxOf(panYAnimatable.upperBound ?: finalY, finalY, startPanY)
)
coroutineScope {
launch { zoomAnimatable.animateTo(finalZoom, animationSpec = tween(400, easing = FastOutSlowInEasing)) }
launch { panXAnimatable.animateTo(finalX, animationSpec = tween(400, easing = FastOutSlowInEasing)) }
launch { panYAnimatable.animateTo(finalY, animationSpec = tween(400, easing = FastOutSlowInEasing)) }
}
onZoomChange(zoomAnimatable.value)
val zoomedDocWidth = screenWidth * finalZoom
val finalMinX: Float
val finalMaxX: Float
if (zoomedDocWidth < screenWidth) {
val centeredX = (screenWidth - zoomedDocWidth) / 2f
finalMinX = centeredX
finalMaxX = centeredX
} else {
finalMinX = -(zoomedDocWidth - screenWidth)
finalMaxX = 0f
}
panXAnimatable.updateBounds(lowerBound = finalMinX, upperBound = finalMaxX)
val zDocH = totalDocHeight * finalZoom
val minScrollY = (screenHeight - footerHeightPx - zDocH).coerceAtMost(headerHeightPx)
panYAnimatable.updateBounds(lowerBound = minScrollY, upperBound = headerHeightPx)
}
}
}
LaunchedEffect(
totalDocHeight, screenHeight, headerHeightPx, footerHeightPx, zoomAnimatable.value, isInteracting, isFlinging, isResizing
) {
@ -920,6 +987,15 @@ internal fun PdfVerticalReader(
return@awaitEachGesture
}
val buttons = currentEvent.buttons
Timber.tag("StylusEraserDiagnostic").d(
"VerticalReader | Type: ${down.type} | isPrimary: ${buttons.isPrimaryPressed} | isSecondary: ${buttons.isSecondaryPressed} | isTertiary: ${buttons.isTertiaryPressed} | buttonsString: $buttons"
)
val isEraserOverride = down.type == PointerType.Eraser ||
(down.type == PointerType.Stylus && currentEvent.buttons.isSecondaryPressed)
isStylusEraserOverride = isEraserOverride
fun getPageAndPoint(screenOffset: Offset): Pair<Int, PdfPoint>? {
val zoom = zoomAnimatable.value
val panX = panXAnimatable.value
@ -943,14 +1019,14 @@ internal fun PdfVerticalReader(
var isCanceled = false
try {
if (selectedTool == InkType.ERASER) {
if (selectedTool == InkType.ERASER || isEraserOverride) {
globalEraserPosition = down.position
}
val startData = getPageAndPoint(down.position)
if (startData != null) {
val (pageIndex, point) = startData
onDrawStart(pageIndex, point)
onDrawStart(pageIndex, point, isEraserOverride)
down.consume()
}
@ -969,7 +1045,7 @@ internal fun PdfVerticalReader(
if (change == null || !change.pressed) break
if (change.positionChanged()) {
if (selectedTool == InkType.ERASER) {
if (selectedTool == InkType.ERASER || isEraserOverride) {
globalEraserPosition = change.position
}
@ -977,11 +1053,11 @@ internal fun PdfVerticalReader(
if (dragData != null) {
val (pageIndex, point) = dragData
if (pageIndex != lastPageIndex && selectedTool != InkType.ERASER) {
if (pageIndex != lastPageIndex && selectedTool != InkType.ERASER && !isEraserOverride) {
onDrawEnd()
onDrawStart(pageIndex, point)
onDrawStart(pageIndex, point, isEraserOverride)
} else {
onDraw(pageIndex, point)
onDraw(pageIndex, point, isEraserOverride)
}
lastPageIndex = pageIndex
}
@ -993,6 +1069,7 @@ internal fun PdfVerticalReader(
onDrawEnd()
}
globalEraserPosition = null
isStylusEraserOverride = false
}
}
}
@ -1472,16 +1549,20 @@ internal fun PdfVerticalReader(
}
val onDrawStartLambda = remember(page.index, onDrawStart) {
{ point: PdfPoint -> onDrawStart(page.index, point) }
{ point: PdfPoint, isEraserOverride: Boolean ->
onDrawStart(page.index, point, isEraserOverride)
}
}
val currentOnDraw by rememberUpdatedState(onDraw)
val onDrawLambda = remember(page.index) {
{ point: PdfPoint -> currentOnDraw(page.index, point) }
{ point: PdfPoint, isEraserOverride: Boolean ->
currentOnDraw(page.index, point, isEraserOverride)
}
}
val onSingleTapLambda = remember(onPageClick) {
{
{ _: Offset? ->
selectionClearTrigger++
onPageClick()
}
@ -1759,7 +1840,9 @@ internal fun PdfVerticalReader(
draggingBoxId = null
}
},
draggingBoxId = draggingBoxId
draggingBoxId = draggingBoxId,
isBubbleZoomModeActive = isBubbleZoomModeActive,
onDetectBubbles = onDetectBubbles
)
}
@ -1875,6 +1958,7 @@ internal fun PdfVerticalReader(
animationSpec = tween(durationMillis = 300),
label = "scrollbarAlpha"
)
val safeCurrentPage = if (totalPages > 0) state.currentPage.coerceIn(0, totalPages - 1) else 0
val samsungBlue = Color(0xFF4285F4)
val samsungBlueDark = Color(0xFF1976D2)
@ -1934,7 +2018,7 @@ internal fun PdfVerticalReader(
.alpha(scrollbarAlpha)) {
Row(verticalAlignment = Alignment.CenterVertically) {
AnimatedVisibility(
visible = isDraggingScrollbar,
visible = isDraggingScrollbar && totalPages > 0,
enter = fadeIn() + androidx.compose.animation.slideInHorizontally {
it / 2
},
@ -1948,7 +2032,7 @@ internal fun PdfVerticalReader(
modifier = Modifier.padding(end = 12.dp)
) {
Text(
text = "${state.currentPage + 1}/${totalPages}",
text = "${safeCurrentPage + 1}/$totalPages",
style = MaterialTheme.typography.titleMedium.copy(
fontSize = 16.sp, fontWeight = FontWeight.Bold
),
@ -2060,7 +2144,7 @@ internal fun PdfVerticalReader(
}
}
if (isEditMode && selectedTool == InkType.ERASER && globalEraserPosition != null) {
if (isEditMode && (selectedTool == InkType.ERASER || isStylusEraserOverride) && globalEraserPosition != null) {
Canvas(modifier = Modifier.fillMaxSize()) {
val pos = globalEraserPosition!!
val radiusPx = if (activeToolThickness > 0f) {
@ -2127,4 +2211,4 @@ internal fun PdfVerticalReader(
}
}
}
}
}

View file

@ -92,6 +92,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.Undo
import androidx.compose.material.icons.filled.ArrowDownward
import androidx.compose.material.icons.filled.ArrowUpward
import androidx.compose.material.icons.filled.Close
@ -209,11 +210,14 @@ import com.aryan.reader.SearchResult
import com.aryan.reader.SummarizationResult
import com.aryan.reader.SummaryCacheManager
import com.aryan.reader.TtsSettingsSheet
import com.aryan.reader.ml.SpeechBubble
import com.aryan.reader.epubreader.AutoScrollControls
import com.aryan.reader.epubreader.DictionarySettingsDialog
import com.aryan.reader.epubreader.ExternalDictionaryHelper
import com.aryan.reader.epubreader.SystemUiMode
import com.aryan.reader.epubreader.TtsOverlayControls
import com.aryan.reader.epubreader.loadTapToNavigateSetting
import com.aryan.reader.epubreader.saveTapToNavigateSetting
import com.aryan.reader.fetchAiDefinition
import com.aryan.reader.loadCustomThemes
import com.aryan.reader.paginatedreader.TtsChunk
@ -252,6 +256,7 @@ import org.json.JSONObject
import timber.log.Timber
import java.io.ByteArrayOutputStream
import java.io.File
import java.util.LinkedHashSet
import java.net.HttpURLConnection
import java.net.URL
import kotlin.math.PI
@ -290,6 +295,7 @@ fun PdfViewerScreen(
val focusManager = LocalFocusManager.current
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
var displayMode by remember { mutableStateOf(loadDisplayMode(context)) }
var tapToNavigateEnabled by remember { mutableStateOf(loadTapToNavigateSetting(context)) }
var showThemePanel by remember { mutableStateOf(false) }
var currentThemeId by remember { mutableStateOf(loadPdfThemeId(context)) }
var excludeImages by remember { mutableStateOf(com.aryan.reader.loadExcludeImages(context)) }
@ -335,9 +341,11 @@ fun PdfViewerScreen(
savePdfHiddenTools(context, newSet)
}
val isOss = BuildConfig.FLAVOR == "oss"
val executeWithOcrCheck = remember(hasSelectedOcrLanguage) {
{ action: () -> Unit ->
if (hasSelectedOcrLanguage) {
if (isOss || hasSelectedOcrLanguage) {
action()
} else {
pendingActionAfterOcrSelection = action
@ -584,6 +592,9 @@ fun PdfViewerScreen(
var customHighlightColors by remember { mutableStateOf(loadCustomHighlightColors(context)) }
var showHighlightColorPicker by remember { mutableStateOf(false) }
var highlightColorPickerInitialSlot by remember { mutableStateOf(PdfHighlightColor.YELLOW) }
var isBubbleZoomModeActive by remember { mutableStateOf(false) }
var showBubbleZoomDownloadDialog by remember { mutableStateOf(false) }
val bubbleZoomDownloadProgress by viewModel.speechBubbleModelDownloadProgress.collectAsState()
var dockLocation by remember { mutableStateOf(initialDockLocation) }
var dockOffset by remember { mutableStateOf(initialDockOffset) }
@ -655,22 +666,11 @@ fun PdfViewerScreen(
snapPreviewLocation,
isEditMode,
isDockDragging,
showStandardBars,
systemUiMode,
statusBarHeightDp
) {
if (!isEditMode) {
var h = 0.dp
if (showStandardBars) {
h += 56.dp
}
val isStatusBarVisible = systemUiMode == SystemUiMode.DEFAULT || (systemUiMode == SystemUiMode.SYNC && showStandardBars)
if (isStatusBarVisible) {
h += statusBarHeightDp
}
h
0.dp
} else {
val isStickyTop = dockLocation == DockLocation.TOP && !isDockDragging
val isPreviewingTop = snapPreviewLocation == DockLocation.TOP
@ -686,6 +686,31 @@ fun PdfViewerScreen(
label = "verticalHeaderHeight"
)
val targetTopOverlayInset = remember(
showStandardBars,
systemUiMode,
statusBarHeightDp
) {
if (!showStandardBars) {
0.dp
} else {
var inset = 56.dp
val isStatusBarVisible =
systemUiMode == SystemUiMode.DEFAULT || (systemUiMode == SystemUiMode.SYNC && showStandardBars)
if (isStatusBarVisible) {
inset += statusBarHeightDp
}
inset
}
}
val topOverlayInset by animateDpAsState(
targetValue = targetTopOverlayInset,
animationSpec = tween(durationMillis = 200),
label = "topOverlayInset"
)
val verticalFooterHeight by remember(
dockLocation,
snapPreviewLocation,
@ -820,9 +845,155 @@ fun PdfViewerScreen(
}
}
}
var isDocumentReady by remember { mutableStateOf(false) }
suspend fun renderSpeechBubblePrefetchBitmap(
document: ReaderDocument,
sourcePageIndex: Int
): Bitmap? = withContext(Dispatchers.IO) {
document.openPage(sourcePageIndex)?.use { page ->
val pageWidth = page.getPageWidthPoint()
val pageHeight = page.getPageHeightPoint()
if (pageWidth <= 0 || pageHeight <= 0) {
return@withContext null
}
val longEdge = max(pageWidth, pageHeight).toFloat()
val targetLongEdge = when (document) {
is PdfDocumentWrapper -> 1600f.coerceAtLeast(longEdge)
else -> min(longEdge, 1600f)
}
val renderScale = (targetLongEdge / longEdge).coerceAtLeast(1f)
val renderWidth = (pageWidth * renderScale).roundToInt().coerceAtLeast(1)
val renderHeight = (pageHeight * renderScale).roundToInt().coerceAtLeast(1)
val renderBitmap = Bitmap.createBitmap(renderWidth, renderHeight, Bitmap.Config.ARGB_8888)
try {
page.renderPageBitmap(
bitmap = renderBitmap,
startX = 0,
startY = 0,
drawSizeX = renderWidth,
drawSizeY = renderHeight,
renderAnnot = true
)
renderBitmap
} catch (t: Throwable) {
renderBitmap.recycle()
Timber.tag("BubbleZoom").w(t, "Failed to render bubble prefetch bitmap for page $sourcePageIndex")
null
}
}
}
fun buildSpeechBubblePrefetchOrder(): List<Int> {
if (totalDisplayPages <= 0) return emptyList()
val ordered = LinkedHashSet<Int>()
ordered += currentPage.coerceIn(0, totalDisplayPages - 1)
for (distance in 1 until totalDisplayPages) {
val next = currentPage + distance
val previous = currentPage - distance
if (next in 0 until totalDisplayPages) ordered += next
if (previous in 0 until totalDisplayPages) ordered += previous
}
return ordered.toList()
}
suspend fun detectSpeechBubblesForPage(
sourcePageIndex: Int,
fallbackBitmap: Bitmap,
allowHighQualityFallback: Boolean = true
): List<SpeechBubble> {
val document = pdfDocument
val shouldUsePrefetchBitmap =
allowHighQualityFallback &&
document != null &&
!viewModel.hasCachedSpeechBubbles(bookId, sourcePageIndex)
val detectionBitmap = if (shouldUsePrefetchBitmap) {
renderSpeechBubblePrefetchBitmap(document!!, sourcePageIndex) ?: fallbackBitmap
} else {
fallbackBitmap
}
val ownsBitmap = detectionBitmap !== fallbackBitmap
return try {
val detected = viewModel.detectSpeechBubblesCached(
documentId = bookId,
pageIndex = sourcePageIndex,
bitmap = detectionBitmap,
context = context
)
if (ownsBitmap) {
viewModel.detectSpeechBubblesCached(
documentId = bookId,
pageIndex = sourcePageIndex,
bitmap = fallbackBitmap,
context = context
)
} else {
detected
}
} finally {
if (ownsBitmap && !detectionBitmap.isRecycled) {
detectionBitmap.recycle()
}
}
}
LaunchedEffect(
isBubbleZoomModeActive,
isDocumentReady,
pdfDocument,
bookId,
currentPage,
totalDisplayPages,
virtualPages
) {
val document = pdfDocument ?: return@LaunchedEffect
if (!isBubbleZoomModeActive || !isDocumentReady || totalDisplayPages <= 0) {
return@LaunchedEffect
}
for (displayPageIndex in buildSpeechBubblePrefetchOrder()) {
if (!isActive) break
val sourcePageIndex = when (val virtualPage = virtualPages.getOrNull(displayPageIndex)) {
is VirtualPage.PdfPage -> virtualPage.pdfIndex
null -> displayPageIndex
else -> continue
}
if (viewModel.hasCachedSpeechBubbles(bookId, sourcePageIndex)) {
continue
}
val prefetchBitmap = renderSpeechBubblePrefetchBitmap(document, sourcePageIndex) ?: continue
try {
detectSpeechBubblesForPage(
sourcePageIndex = sourcePageIndex,
fallbackBitmap = prefetchBitmap,
allowHighQualityFallback = false
)
} finally {
if (!prefetchBitmap.isRecycled) {
prefetchBitmap.recycle()
}
}
kotlinx.coroutines.yield()
}
}
val jumpHistory = remember { mutableStateListOf<Int>() }
var showJumpPill by remember { mutableStateOf(false) }
LaunchedEffect(showJumpPill, jumpHistory.size) {
if (showJumpPill && jumpHistory.isNotEmpty()) {
delay(4000)
showJumpPill = false
}
}
LaunchedEffect(currentPage, isDocumentReady, totalPages, initialScrollDone) {
if (isDocumentReady && totalPages > 0) {
if (initialScrollDone) {
@ -989,6 +1160,7 @@ fun PdfViewerScreen(
var isLoadingDocument by remember { mutableStateOf(true) }
var selectionClearTrigger by remember { mutableLongStateOf(0L) }
var resetZoomTrigger by remember { mutableLongStateOf(0L) }
val displayPageRatios by remember(pageAspectRatios, virtualPages) {
derivedStateOf {
@ -1879,7 +2051,6 @@ fun PdfViewerScreen(
val onDictionaryLookupStable = remember(executeWithOcrCheck, useOnlineDictionary, selectedDictPackage, uiState.credits, isProUser) {
{ text: String ->
executeWithOcrCheck {
val isOss = BuildConfig.FLAVOR == "oss"
val effectiveUseOnline = !isOss && useOnlineDictionary
if (effectiveUseOnline) {
@ -1954,6 +2125,14 @@ fun PdfViewerScreen(
{ targetPage: Int ->
coroutineScope.launch {
if (targetPage in 0 until totalPages) {
val current = if (displayMode == DisplayMode.PAGINATION) pagerState.currentPage else verticalReaderState.currentPage
if (current != targetPage) {
if (jumpHistory.size > 20) jumpHistory.removeAt(0)
jumpHistory.add(current)
showJumpPill = true
}
if (displayMode == DisplayMode.PAGINATION) {
pagerState.animateScrollToPage(targetPage)
} else {
@ -2700,19 +2879,8 @@ fun PdfViewerScreen(
}
}
LaunchedEffect(pagerState.isScrollInProgress) {
if (pagerState.isScrollInProgress && showBars) {
showBars = false
Timber.d("Pager scroll detected, hiding bars.")
}
}
LaunchedEffect(pagerState.isScrollInProgress) {
if (pagerState.isScrollInProgress) {
if (showBars) {
showBars = false
Timber.d("Pager scroll detected, hiding bars.")
}
if (displayMode == DisplayMode.PAGINATION && !isAutoPagingForTts && (ttsState.isPlaying || ttsState.isLoading)) {
ttsController.stop()
}
@ -2986,6 +3154,14 @@ fun PdfViewerScreen(
val onInternalLinkNav: (Int) -> Unit = { targetPage ->
coroutineScope.launch {
if (targetPage in 0 until totalPages) {
val current = if (displayMode == DisplayMode.PAGINATION) pagerState.currentPage else verticalReaderState.currentPage
if (current != targetPage) {
if (jumpHistory.size > 20) jumpHistory.removeAt(0)
jumpHistory.add(current)
showJumpPill = true
}
if (displayMode == DisplayMode.PAGINATION) {
pagerState.animateScrollToPage(targetPage)
} else {
@ -3014,16 +3190,24 @@ fun PdfViewerScreen(
fun navigateToPdfSearchResult(result: SearchResult) {
currentPdfSearchResult = result
searchHighlightTarget = result
coroutineScope.launch {
val targetPage = result.locationInSource
val current = if (displayMode == DisplayMode.PAGINATION) pagerState.currentPage else verticalReaderState.currentPage
if (current != targetPage) {
if (jumpHistory.size > 20) jumpHistory.removeAt(0)
jumpHistory.add(current)
showJumpPill = true
}
if (displayMode == DisplayMode.PAGINATION) {
if (pagerState.currentPage != result.locationInSource) {
pagerState.scrollToPage(result.locationInSource)
if (pagerState.currentPage != targetPage) {
pagerState.scrollToPage(targetPage)
}
} else {
verticalReaderState.scrollToPage(result.locationInSource)
verticalReaderState.scrollToPage(targetPage)
}
}
}
@ -3092,13 +3276,23 @@ fun PdfViewerScreen(
drawerState = drawerState, gesturesEnabled = drawerState.isOpen, drawerContent = {
ModalDrawerSheet(modifier = Modifier.windowInsetsPadding(WindowInsets.statusBars)) {
PdfNavigationDrawerContent(
pdfDocument = pdfDocument,
flatTableOfContents = flatTableOfContents,
bookmarks = bookmarks,
userHighlights = userHighlights,
currentPage = currentPage,
totalPages = totalDisplayPages,
customHighlightColors = customHighlightColors,
onPageSelected = { targetPage ->
coroutineScope.launch {
val current = if (displayMode == DisplayMode.PAGINATION) pagerState.currentPage else verticalReaderState.currentPage
if (current != targetPage) {
if (jumpHistory.size > 20) jumpHistory.removeAt(0)
jumpHistory.add(current)
showJumpPill = true
}
if (displayMode == DisplayMode.PAGINATION) {
pagerState.scrollToPage(targetPage)
} else {
@ -3203,6 +3397,44 @@ fun PdfViewerScreen(
val stablePdfDocument = remember(pdfDocument) { StableHolder(pdfDocument!!) }
when (displayMode) {
DisplayMode.PAGINATION -> {
val onPaginationPreSingleTap: (Offset) -> Boolean = { tapOffset ->
val canTurnPagesByTap = tapToNavigateEnabled &&
(currentPageScale <= 1.02f || isScrollLocked)
if (!canTurnPagesByTap) {
false
} else {
val oneQuarterWidthPx = boxMaxWidthFloat / 4f
when {
tapOffset.x < oneQuarterWidthPx -> {
coroutineScope.launch {
val targetPage =
(pagerState.currentPage - 1).coerceAtLeast(0)
if (targetPage != pagerState.currentPage) {
pagerState.scrollToPage(targetPage)
}
}
true
}
tapOffset.x > (boxMaxWidthFloat - oneQuarterWidthPx) -> {
coroutineScope.launch {
val targetPage =
(pagerState.currentPage + 1).coerceAtMost(
pagerState.pageCount - 1
)
if (targetPage != pagerState.currentPage) {
pagerState.scrollToPage(targetPage)
}
}
true
}
else -> false
}
}
}
Box(modifier = Modifier.fillMaxSize()) {
HorizontalPager(
state = pagerState,
@ -3291,9 +3523,10 @@ fun PdfViewerScreen(
@Suppress("ControlFlowWithEmptyBody") val onDrawPagination =
remember(pageIndex) {
{ point: PdfPoint ->
if (currentSelectedTool == InkType.TEXT) {
} else if (currentSelectedTool == InkType.ERASER) {
{ point: PdfPoint, isEraserOverride: Boolean ->
val effectiveTool = if (isEraserOverride) InkType.ERASER else currentSelectedTool
if (effectiveTool == InkType.TEXT) {
} else if (effectiveTool == InkType.ERASER) {
val aspectRatio = pageAspectRatios.getOrElse(pageIndex) { 1f }
val existing = allAnnotations[pageIndex] ?: emptyList()
val toRemove = existing.filter {
@ -3328,12 +3561,13 @@ fun PdfViewerScreen(
@Suppress("ControlFlowWithEmptyBody") val onDrawStartPagination =
remember(pageIndex) {
{ point: PdfPoint ->
{ point: PdfPoint, isEraserOverride: Boolean ->
if (showToolSettings) {
showToolSettings = false
} else {
if (currentSelectedTool == InkType.TEXT) {
} else if (currentSelectedTool == InkType.ERASER) {
val effectiveTool = if (isEraserOverride) InkType.ERASER else currentSelectedTool
if (effectiveTool == InkType.TEXT) {
} else if (effectiveTool == InkType.ERASER) {
lastEraserPoint = point
erasedAnnotationsFromStroke.clear()
val aspectRatio = pageAspectRatios.getOrElse(pageIndex) { 1f }
@ -3362,7 +3596,7 @@ fun PdfViewerScreen(
drawingState.onDrawStart(
pageIndex,
pointWithTime,
currentSelectedTool,
effectiveTool,
currentStrokeColorState,
currentStrokeWidthState
)
@ -3403,7 +3637,8 @@ fun PdfViewerScreen(
modifier = Modifier.fillMaxSize(),
showAllTextHighlights = showAllTextHighlights,
onHighlightLoading = { /* no-op for paginated mode */ },
onSingleTap = onSingleTapStable,
onPreSingleTap = onPaginationPreSingleTap,
onSingleTap = { _ -> onSingleTapStable() },
isProUser = isProUser,
onShowDictionaryUpsellDialog = {
if (useOnlineDictionary) {
@ -3420,6 +3655,7 @@ fun PdfViewerScreen(
onBookmarkClick = { onToggleBookmark(pageIndex) },
isZoomEnabled = true,
clearSelectionTrigger = selectionClearTrigger,
resetZoomTrigger = resetZoomTrigger,
pageAnnotations = pageAnnotationsProvider,
drawingState = drawingState,
onDrawStart = onDrawStartPagination,
@ -3470,12 +3706,11 @@ fun PdfViewerScreen(
currentActiveOffset = newOffset
}
},
onDetectPanels = { bitmap ->
Toast.makeText(context, "Scanning for panels...", Toast.LENGTH_SHORT).show()
viewModel.detectComicPanels(bitmap, context)
onDetectBubbles = { sourcePageIndex, bitmap ->
detectSpeechBubblesForPage(sourcePageIndex, bitmap)
},
onShowPanelPopup = { croppedBitmap ->
poppedUpPanelBitmap = croppedBitmap
onShowPanelPopup = { bitmapWithRects ->
poppedUpPanelBitmap = bitmapWithRects
},
onTwoFingerSwipe = { direction ->
coroutineScope.launch {
@ -3647,7 +3882,15 @@ fun PdfViewerScreen(
paginationDraggingBoxId = null
}
},
onDragPageTurn = { /* Handled in onTextBoxDrag */ },
onDragPageTurn = { direction ->
coroutineScope.launch {
val targetPage = pagerState.currentPage + direction
if (targetPage in 0 until totalDisplayPages) {
pagerState.animateScrollToPage(targetPage)
}
}
},
isBubbleZoomModeActive = isBubbleZoomModeActive,
isVisible = isVisiblePage,
isActivePage = pagerState.currentPage == pageIndex,
isScrolling = pagerState.isScrollInProgress
@ -3718,12 +3961,13 @@ fun PdfViewerScreen(
@Suppress("ControlFlowWithEmptyBody") val onDrawStartStable =
remember {
{ pageIndex: Int, point: PdfPoint ->
{ pageIndex: Int, point: PdfPoint, isEraserOverride: Boolean ->
if (showToolSettings) {
showToolSettings = false
} else {
if (currentSelectedTool == InkType.TEXT) {
} else if (currentSelectedTool == InkType.ERASER) {
val effectiveTool = if (isEraserOverride) InkType.ERASER else currentSelectedTool
if (effectiveTool == InkType.TEXT) {
} else if (effectiveTool == InkType.ERASER) {
lastEraserPoint = point
erasedAnnotationsFromStroke.clear()
@ -3753,7 +3997,7 @@ fun PdfViewerScreen(
drawingState.onDrawStart(
pageIndex,
pointWithTime,
currentSelectedTool,
effectiveTool,
currentStrokeColorState,
currentStrokeWidthState
)
@ -3763,8 +4007,9 @@ fun PdfViewerScreen(
}
val onDrawStable = remember(isHighlighterSnapEnabled, isCurrentToolHighlighter, calculateSnappedPoint) {
{ pageIndex: Int, point: PdfPoint ->
if (currentSelectedTool == InkType.ERASER) {
{ pageIndex: Int, point: PdfPoint, isEraserOverride: Boolean ->
val effectiveTool = if (isEraserOverride) InkType.ERASER else currentSelectedTool
if (effectiveTool == InkType.ERASER) {
val aspectRatio = pageAspectRatios.getOrElse(pageIndex) { 1f }
val existing = allAnnotations[pageIndex] ?: emptyList()
val toRemove = existing.filter {
@ -3915,6 +4160,11 @@ fun PdfViewerScreen(
onZoomAndPanChanged = { newScale, newOffset ->
currentActiveScale = newScale
currentActiveOffset = newOffset
},
resetZoomTrigger = resetZoomTrigger,
isBubbleZoomModeActive = isBubbleZoomModeActive,
onDetectBubbles = { sourcePageIndex, bitmap ->
detectSpeechBubblesForPage(sourcePageIndex, bitmap)
}
)
}
@ -4108,7 +4358,7 @@ fun PdfViewerScreen(
modifier = Modifier
.align(Alignment.TopCenter)
.fillMaxWidth()
.padding(top = if (showBars) verticalHeaderHeight else 0.dp)
.padding(top = topOverlayInset)
.padding(8.dp)
) {
Surface(
@ -4138,6 +4388,55 @@ fun PdfViewerScreen(
}
}
AnimatedVisibility(
visible = bubbleZoomDownloadProgress != null,
enter = slideInVertically() + fadeIn(),
exit = slideOutVertically() + fadeOut(),
modifier = Modifier
.align(Alignment.TopCenter)
.fillMaxWidth()
// shift down slightly if the OCR indicator is also showing
.padding(top = topOverlayInset + if (isOcrModelDownloading) 64.dp else 0.dp)
.padding(8.dp)
) {
Surface(
color = MaterialTheme.colorScheme.tertiaryContainer,
shape = RoundedCornerShape(8.dp),
shadowElevation = 4.dp
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
val progress = bubbleZoomDownloadProgress ?: 0f
if (progress > 0f) {
CircularProgressIndicator(
progress = { progress },
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onTertiaryContainer,
trackColor = MaterialTheme.colorScheme.onTertiaryContainer.copy(alpha = 0.2f)
)
} else {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onTertiaryContainer
)
}
Spacer(modifier = Modifier.width(12.dp))
Text(
text = "Downloading Bubble Zoom model... ${(progress * 100).toInt()}%",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onTertiaryContainer
)
}
}
}
// --- Slider UI Overlay ---
AnimatedVisibility(
visible = isPageSliderVisible,
@ -4209,6 +4508,15 @@ fun PdfViewerScreen(
scrubDebounceJob.value = coroutineScope.launch {
delay(200)
if (isActive) {
val targetPage = newValue.roundToInt()
if (targetPage != sliderStartPage) {
if (jumpHistory.lastOrNull() != sliderStartPage) {
if (jumpHistory.size > 20) jumpHistory.removeAt(0)
jumpHistory.add(sliderStartPage)
}
showJumpPill = true
}
if (displayMode == DisplayMode.PAGINATION) {
pagerState.scrollToPage(
newValue.roundToInt()
@ -4418,10 +4726,17 @@ fun PdfViewerScreen(
},
onShowCustomizeTools = { showCustomizeToolsSheet = true },
onShowOcrLanguage = {
hasSelectedOcrLanguage = true
showOcrLanguageDialog = true
if (!isOss) {
hasSelectedOcrLanguage = true
showOcrLanguageDialog = true
}
},
onShowVisualOptions = { showVisualOptionsSheet = true },
tapToNavigateEnabled = tapToNavigateEnabled,
onToggleTapToNavigate = {
tapToNavigateEnabled = !tapToNavigateEnabled
saveTapToNavigateSetting(context, tapToNavigateEnabled)
},
onChangeDisplayMode = { displayMode = it },
onToggleKeepScreenOn = {
isKeepScreenOn = !isKeepScreenOn
@ -4481,13 +4796,28 @@ fun PdfViewerScreen(
}
}
},
onNewTabClick = { showNewTabSheet = true }
onNewTabClick = { showNewTabSheet = true },
onGenerateDemoAnnotations = {
val page = if (displayMode == DisplayMode.PAGINATION) pagerState.currentPage else verticalReaderState.currentPage
val demoAnnots = DemoAnnotationGenerator.generateDemoAnnotations(page)
if (demoAnnots.isNotEmpty()) {
Timber.d("Debug: Generating ${demoAnnots.size} demo annotations for page $page")
val existing = allAnnotations[page] ?: emptyList()
allAnnotations = allAnnotations + (page to (existing + demoAnnots))
demoAnnots.forEach { annot ->
undoStack.add(HistoryAction.Add(page, annot))
}
redoStack.clear()
}
}
)
ReflowProgressOverlay(
modifier = Modifier
.align(Alignment.TopCenter)
.padding(top = verticalHeaderHeight)
.padding(top = topOverlayInset)
.fillMaxWidth()
.padding(horizontal = 8.dp),
showStandardBars = showStandardBars,
@ -4502,7 +4832,7 @@ fun PdfViewerScreen(
Column(
modifier = Modifier
.fillMaxSize()
.padding(top = verticalHeaderHeight)
.padding(top = topOverlayInset)
.background(MaterialTheme.colorScheme.surface)
) {
if (isBackgroundIndexing) {
@ -4673,6 +5003,56 @@ fun PdfViewerScreen(
)
}
val effectiveNavBarForPill = if (systemUiMode == SystemUiMode.DEFAULT || (systemUiMode == SystemUiMode.SYNC && showStandardBars)) with(density) { navBarHeight.toDp() } else 0.dp
val isBottomBarVisibleForPill = showStandardBars && !searchState.isSearchActive
val targetPillBottomPadding = if (isBottomBarVisibleForPill) 56.dp + 16.dp + effectiveNavBarForPill else 16.dp + effectiveNavBarForPill
val pillBottomPadding by animateDpAsState(
targetValue = targetPillBottomPadding,
label = "PillBottomPadding"
)
AnimatedVisibility(
visible = showJumpPill && jumpHistory.isNotEmpty(),
enter = fadeIn() + slideInVertically { it },
exit = fadeOut() + slideOutVertically { it },
modifier = Modifier
.align(Alignment.BottomStart)
.padding(bottom = pillBottomPadding)
.padding(start = 16.dp)
) {
val lastPage = jumpHistory.lastOrNull() ?: 0
Surface(
shape = CircleShape,
color = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
shadowElevation = 6.dp,
onClick = {
val target = jumpHistory.removeLastOrNull()
if (target != null) {
showJumpPill = false
coroutineScope.launch {
if (displayMode == DisplayMode.PAGINATION) {
pagerState.animateScrollToPage(target)
} else {
verticalReaderState.scrollToPage(target)
}
}
}
}
) {
Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(Icons.AutoMirrored.Filled.Undo, contentDescription = "Jump Back", modifier = Modifier.size(18.dp))
Spacer(modifier = Modifier.width(8.dp))
Text("Back to Pg ${lastPage + 1}", style = MaterialTheme.typography.labelLarge)
}
}
}
// Bottom Bar
PdfBottomBar(
modifier = Modifier.align(Alignment.BottomCenter),
@ -4687,6 +5067,20 @@ fun PdfViewerScreen(
isEditMode = isEditMode,
isTtsSessionActive = isTtsSessionActive,
ttsErrorMessage = ttsState.errorMessage,
jumpBackPage = jumpHistory.lastOrNull(),
onJumpBack = {
val target = jumpHistory.removeLastOrNull()
if (target != null) {
showJumpPill = false
coroutineScope.launch {
if (displayMode == DisplayMode.PAGINATION) {
pagerState.animateScrollToPage(target)
} else {
verticalReaderState.scrollToPage(target)
}
}
}
},
onShowSlider = {
val currentPageForSlider = if (displayMode == DisplayMode.PAGINATION) pagerState.currentPage else verticalReaderState.currentPage
sliderStartPage = currentPageForSlider
@ -4736,6 +5130,16 @@ fun PdfViewerScreen(
} else {
startTtsWithPermissionCheck(null, null)
}
},
isBubbleZoomModeActive = isBubbleZoomModeActive,
onToggleBubbleZoom = {
if (isOss) {
coroutineScope.launch { snackbarHostState.showSnackbar("Bubble Zoom is only available in Playstore version of Episteme") }
} else if (!isBubbleZoomModeActive && !viewModel.isSpeechBubbleModelAvailable(context)) {
showBubbleZoomDownloadDialog = true
} else {
isBubbleZoomModeActive = !isBubbleZoomModeActive
}
}
)
@ -5145,7 +5549,12 @@ fun PdfViewerScreen(
exit = fadeOut()
) {
val percentage = (currentPageScale * 100).roundToInt()
ZoomPercentageIndicator(percentage = percentage)
ZoomPercentageIndicator(
percentage = percentage,
onResetZoomClick = {
resetZoomTrigger = System.currentTimeMillis()
}
)
}
val isImeVisible = WindowInsets.ime.getBottom(LocalDensity.current) > 0
@ -5465,7 +5874,7 @@ fun PdfViewerScreen(
) {
Image(
bitmap = poppedUpPanelBitmap!!.asImageBitmap(),
contentDescription = "Zoomed Panel",
contentDescription = "Annotated Page",
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
@ -5485,7 +5894,7 @@ fun PdfViewerScreen(
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = "Close Panel",
contentDescription = "Close Image",
tint = Color.White
)
}
@ -5500,6 +5909,30 @@ fun PdfViewerScreen(
onConfirm = { password -> documentPassword = password })
}
if (showBubbleZoomDownloadDialog) {
AlertDialog(
onDismissRequest = { showBubbleZoomDownloadDialog = false },
icon = { Icon(Icons.Default.Info, contentDescription = null) },
title = { Text("Download Bubble Zoom Model") },
text = {
Text("To use the Bubble Zoom feature, an AI model needs to be downloaded (~134 MB). Do you want to download it now?")
},
confirmButton = {
TextButton(onClick = {
showBubbleZoomDownloadDialog = false
viewModel.downloadSpeechBubbleModel(context)
}) {
Text("Download")
}
},
dismissButton = {
TextButton(onClick = { showBubbleZoomDownloadDialog = false }) {
Text(stringResource(R.string.action_cancel))
}
}
)
}
if (showNewTabSheet) {
ModalBottomSheet(
onDismissRequest = { showNewTabSheet = false },
@ -5656,7 +6089,7 @@ fun PdfViewerScreen(
}
}
if (showOcrLanguageDialog) {
if (showOcrLanguageDialog && !isOss) {
OcrLanguageSelectionDialog(
currentLanguage = ocrLanguage,
isFirstRun = !hasSelectedOcrLanguage,
@ -6037,6 +6470,17 @@ fun PdfViewerScreen(
currentTtsMode = currentTtsMode,
isCollapsed = isTtsCollapsed,
onCollapseChange = { isTtsCollapsed = it },
onLocateCurrentChunk = {
ttsPageData?.pageIndex?.let { targetPage ->
coroutineScope.launch {
if (displayMode == DisplayMode.PAGINATION) {
pagerState.scrollToPage(targetPage)
} else {
verticalReaderState.scrollToPage(targetPage)
}
}
}
},
onOpenTtsSettings = { showTtsSettingsSheet = true },
onClose = {
ttsController.stop()
@ -6165,4 +6609,4 @@ fun PdfViewerScreen(
}
}
}
}
}