General fixes (#76)
* Added print functionality to PDF viewer * feat(pdf): add interactive button support and visibility reveal heuristic * Moved search state management to MainViewModel and improved search UI interaction. * Updated `pdfiumandroid` to version 2.0.0 and handled resulting API changes, including nullable page/text page returns and revised native pointer access. increased compileSdk to 36. * Fixed Table of Contents (TOC) truncation bug and improved the TOC UI in `PdfViewerScreen`. - Implemented `getFixedTableOfContents` using reflection to bypass a library issue where sibling nodes were incorrectly truncated during traversal. - Enhanced the TOC drawer with a nested, expandable tree structure using the new `PdfTocTreeItem` component. - Added a custom `VerticalScrollbar` with draggable support for better navigation within long TOC lists. - Integrated `animateColorAsState` and `animateFloatAsState` for smoother UI transitions in the TOC and scrollbar. - Optimized TOC loading by flattening the tree structure and managing expansion states with `rememberSaveable`. * Improved zoom pivot calculation and interaction handling in PdfVerticalReader * Fixed high-res PDF tile bleeding by implementing clipRect in PdfBitmapLayer * fix(pdf): resolve zoom stuttering, in pagination mode, by removing eager scale snapping
This commit is contained in:
parent
dece09fec0
commit
1884ace646
12 changed files with 1207 additions and 653 deletions
|
|
@ -33,7 +33,14 @@ import android.graphics.Bitmap
|
|||
import android.graphics.RectF
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.CancellationSignal
|
||||
import android.os.ParcelFileDescriptor
|
||||
import android.print.PageRange
|
||||
import android.print.PrintAttributes
|
||||
import android.print.PrintDocumentAdapter
|
||||
import android.print.PrintDocumentInfo
|
||||
import android.print.PrintManager
|
||||
import android.provider.OpenableColumns
|
||||
import android.util.Base64
|
||||
import android.widget.Toast
|
||||
|
|
@ -43,6 +50,7 @@ import androidx.activity.result.contract.ActivityResultContracts
|
|||
import androidx.annotation.OptIn
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
|
|
@ -57,12 +65,16 @@ import androidx.compose.foundation.Image
|
|||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.Orientation
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.gestures.detectDragGestures
|
||||
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
|
||||
import androidx.compose.foundation.gestures.draggable
|
||||
import androidx.compose.foundation.gestures.rememberDraggableState
|
||||
import androidx.compose.foundation.gestures.waitForUpOrCancellation
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsDraggedAsState
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
|
|
@ -74,6 +86,7 @@ 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.ime
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
|
|
@ -83,7 +96,10 @@ import androidx.compose.foundation.layout.systemBars
|
|||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
|
|
@ -94,6 +110,7 @@ import androidx.compose.foundation.text.BasicTextField
|
|||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.filled.ArrowDownward
|
||||
import androidx.compose.material.icons.filled.ArrowUpward
|
||||
import androidx.compose.material.icons.filled.Brush
|
||||
|
|
@ -129,7 +146,6 @@ import androidx.compose.material3.HorizontalDivider
|
|||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.MenuDefaults
|
||||
import androidx.compose.material3.ModalDrawerSheet
|
||||
|
|
@ -245,8 +261,8 @@ import com.aryan.reader.R
|
|||
import com.aryan.reader.SearchResult
|
||||
import com.aryan.reader.SearchTopBar
|
||||
import com.aryan.reader.SummarizationPopup
|
||||
import com.aryan.reader.TooltipIconButton
|
||||
import com.aryan.reader.SummarizationResult
|
||||
import com.aryan.reader.TooltipIconButton
|
||||
import com.aryan.reader.TtsSettingsSheet
|
||||
import com.aryan.reader.countWords
|
||||
import com.aryan.reader.epubreader.AutoScrollControls
|
||||
|
|
@ -270,8 +286,7 @@ import com.aryan.reader.tts.TtsPlaybackManager
|
|||
import com.aryan.reader.tts.loadTtsMode
|
||||
import com.aryan.reader.tts.rememberTtsController
|
||||
import com.aryan.reader.tts.splitTextIntoChunks
|
||||
import io.legere.pdfiumandroid.PdfDocument
|
||||
import io.legere.pdfiumandroid.PdfPasswordException
|
||||
import io.legere.pdfiumandroid.api.Bookmark
|
||||
import io.legere.pdfiumandroid.suspend.PdfDocumentKt
|
||||
import io.legere.pdfiumandroid.suspend.PdfPageKt
|
||||
import io.legere.pdfiumandroid.suspend.PdfTextPageKt
|
||||
|
|
@ -292,6 +307,8 @@ import org.json.JSONObject
|
|||
import timber.log.Timber
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.io.FileOutputStream
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import kotlin.math.max
|
||||
|
|
@ -574,8 +591,153 @@ private fun loadDisplayMode(context: Context): DisplayMode {
|
|||
}
|
||||
}
|
||||
|
||||
private const val MAX_FIXED_RECURSION = 128
|
||||
|
||||
/**
|
||||
* 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 data class PdfBookmark(val pageIndex: Int, val title: String, val totalPages: Int)
|
||||
|
||||
class PdfPrintDocumentAdapter(
|
||||
private val context: Context,
|
||||
private val pdfUri: Uri,
|
||||
private val fileName: String
|
||||
) : PrintDocumentAdapter() {
|
||||
|
||||
override fun onLayout(
|
||||
oldAttributes: PrintAttributes?,
|
||||
newAttributes: PrintAttributes?,
|
||||
cancellationSignal: CancellationSignal?,
|
||||
callback: LayoutResultCallback?,
|
||||
extras: Bundle?
|
||||
) {
|
||||
if (cancellationSignal?.isCanceled == true) {
|
||||
callback?.onLayoutCancelled()
|
||||
return
|
||||
}
|
||||
|
||||
val info = PrintDocumentInfo.Builder(fileName)
|
||||
.setContentType(PrintDocumentInfo.CONTENT_TYPE_DOCUMENT)
|
||||
.build()
|
||||
|
||||
callback?.onLayoutFinished(info, true)
|
||||
}
|
||||
|
||||
override fun onWrite(
|
||||
pages: Array<out PageRange>?,
|
||||
destination: ParcelFileDescriptor?,
|
||||
cancellationSignal: CancellationSignal?,
|
||||
callback: WriteResultCallback?
|
||||
) {
|
||||
try {
|
||||
context.contentResolver.openFileDescriptor(pdfUri, "r")?.use { pfd ->
|
||||
FileInputStream(pfd.fileDescriptor).use { input ->
|
||||
FileOutputStream(destination?.fileDescriptor).use { output ->
|
||||
val buf = ByteArray(8192)
|
||||
var bytesRead: Int
|
||||
while (input.read(buf).also { bytesRead = it } > 0) {
|
||||
if (cancellationSignal?.isCanceled == true) {
|
||||
Timber.tag("PdfPrint").d("Print job cancelled during write")
|
||||
callback?.onWriteCancelled()
|
||||
return
|
||||
}
|
||||
output.write(buf, 0, bytesRead)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Timber.tag("PdfPrint").i("PDF successfully streamed to print spooler")
|
||||
callback?.onWriteFinished(arrayOf(PageRange.ALL_PAGES))
|
||||
} catch (e: Exception) {
|
||||
Timber.tag("PdfPrint").e(e, "Error writing PDF to print spooler")
|
||||
callback?.onWriteFailed(e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadPdfBookmarksFromJson(bookmarksJson: String?): Set<PdfBookmark> {
|
||||
if (bookmarksJson.isNullOrBlank()) return emptySet()
|
||||
return try {
|
||||
|
|
@ -615,23 +777,188 @@ private data class TtsPageData(
|
|||
|
||||
private data class TocEntry(val title: String, val pageIndex: Int, val nestLevel: Int)
|
||||
|
||||
private fun flattenToc(bookmarks: List<PdfDocument.Bookmark>, level: Int = 0): List<TocEntry> {
|
||||
private 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 (bookmark in bookmarks) {
|
||||
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 = bookmark.title ?: "Untitled Chapter",
|
||||
title = title,
|
||||
pageIndex = bookmark.pageIdx.toInt(),
|
||||
nestLevel = level
|
||||
)
|
||||
)
|
||||
if (bookmark.children.isNotEmpty()) {
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
private data class ScrollbarCalculations(
|
||||
val thumbHeight: Float,
|
||||
val thumbOffset: Float,
|
||||
val contentHeight: Float,
|
||||
val viewportHeight: Float
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun VerticalScrollbar(
|
||||
listState: LazyListState,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
val isDragged by interactionSource.collectIsDraggedAsState()
|
||||
|
||||
val scrollbarState by remember {
|
||||
derivedStateOf {
|
||||
val layoutInfo = listState.layoutInfo
|
||||
val totalItems = layoutInfo.totalItemsCount
|
||||
val visibleItemsInfo = layoutInfo.visibleItemsInfo
|
||||
val viewportHeight = layoutInfo.viewportSize.height.toFloat()
|
||||
|
||||
if (totalItems == 0 || visibleItemsInfo.isEmpty() || viewportHeight <= 0f) {
|
||||
return@derivedStateOf null
|
||||
}
|
||||
|
||||
// Estimate total height
|
||||
val averageItemHeight = visibleItemsInfo.sumOf { it.size } / visibleItemsInfo.size.toFloat()
|
||||
val estimatedContentHeight = (averageItemHeight * totalItems).coerceAtLeast(viewportHeight)
|
||||
val viewportRatio = viewportHeight / estimatedContentHeight
|
||||
|
||||
if (viewportRatio >= 1f) return@derivedStateOf null
|
||||
|
||||
val thumbHeight = (viewportHeight * viewportRatio).coerceIn(80f, viewportHeight / 2)
|
||||
|
||||
val firstItemIndex = listState.firstVisibleItemIndex
|
||||
val firstItemOffset = listState.firstVisibleItemScrollOffset
|
||||
val currentScrollPixels = (firstItemIndex * averageItemHeight) + firstItemOffset
|
||||
val maxScrollPixels = estimatedContentHeight - viewportHeight
|
||||
val scrollProgress = (currentScrollPixels / maxScrollPixels).coerceIn(0f, 1f)
|
||||
val trackHeight = viewportHeight - thumbHeight
|
||||
val thumbOffset = trackHeight * scrollProgress
|
||||
|
||||
ScrollbarCalculations(
|
||||
thumbHeight = thumbHeight,
|
||||
thumbOffset = thumbOffset,
|
||||
contentHeight = estimatedContentHeight,
|
||||
viewportHeight = viewportHeight
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val targetAlpha = if (listState.isScrollInProgress || isDragged) 1f else 0f
|
||||
val alpha by animateFloatAsState(
|
||||
targetValue = targetAlpha,
|
||||
animationSpec = tween(durationMillis = 200),
|
||||
label = "ScrollbarAlpha"
|
||||
)
|
||||
|
||||
if (scrollbarState != null) {
|
||||
val state = scrollbarState!!
|
||||
val draggableState = rememberDraggableState { delta ->
|
||||
val trackHeight = state.viewportHeight - state.thumbHeight
|
||||
if (trackHeight > 0) {
|
||||
val scrollRatio = delta / trackHeight
|
||||
val totalScrollableDistance = state.contentHeight - state.viewportHeight
|
||||
val scrollDelta = scrollRatio * totalScrollableDistance
|
||||
listState.dispatchRawDelta(scrollDelta)
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.width(30.dp)
|
||||
.fillMaxHeight()
|
||||
.draggable(
|
||||
state = draggableState,
|
||||
orientation = Orientation.Vertical,
|
||||
interactionSource = interactionSource
|
||||
)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.graphicsLayer { translationY = state.thumbOffset }
|
||||
.padding(end = 4.dp)
|
||||
.width(6.dp)
|
||||
.height(with(LocalDensity.current) { state.thumbHeight.toDp() })
|
||||
.alpha(alpha)
|
||||
.background(
|
||||
color = if (isDragged) MaterialTheme.colorScheme.primary.copy(alpha = 0.8f)
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
|
||||
shape = RoundedCornerShape(100)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private 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(UnstableApi::class)
|
||||
@Suppress("unused")
|
||||
private fun saveTtsMode(context: Context, mode: TtsPlaybackManager.TtsMode) {
|
||||
|
|
@ -644,6 +971,7 @@ private suspend fun renderPageToBitmap(doc: PdfDocumentKt, pageIndex: Int): Bitm
|
|||
var page: PdfPageKt? = null
|
||||
try {
|
||||
page = doc.openPage(pageIndex)
|
||||
if (page == null) return@withContext null
|
||||
|
||||
val bitmapWidth = 1080
|
||||
val aspectRatio =
|
||||
|
|
@ -853,6 +1181,25 @@ fun PdfViewerScreen(
|
|||
isAutoScrollLocal = loadPdfAutoScrollLocalMode(context, bookId)
|
||||
}
|
||||
|
||||
val onPrintDocument = {
|
||||
val printManager = context.getSystemService(Context.PRINT_SERVICE) as PrintManager
|
||||
val jobName = "${context.getString(R.string.app_name)} - $originalFileName"
|
||||
|
||||
try {
|
||||
Timber.tag("PdfPrint").d("Starting print job: $jobName")
|
||||
printManager.print(
|
||||
jobName,
|
||||
PdfPrintDocumentAdapter(context, pdfUri, originalFileName),
|
||||
null
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Timber.tag("PdfPrint").e(e, "Failed to initialize print job")
|
||||
coroutineScope.launch {
|
||||
snackbarHostState.showSnackbar("Could not open print settings")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val initialSettings = remember(isAutoScrollLocal, bookId) {
|
||||
if (isAutoScrollLocal) {
|
||||
loadPdfAutoScrollLocalSettings(context, bookId) ?: Triple(
|
||||
|
|
@ -1357,7 +1704,7 @@ fun PdfViewerScreen(
|
|||
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
doc.openPage(pageIndex).use { page ->
|
||||
doc.openPage(pageIndex)?.use { page ->
|
||||
page.openTextPage().use { textPage ->
|
||||
val fullText = textPage.textPageGetText(newStart, newEnd - newStart) ?: text
|
||||
val rects = textPage.textPageGetRectsForRanges(intArrayOf(newStart, newEnd - newStart))
|
||||
|
|
@ -1895,7 +2242,7 @@ fun PdfViewerScreen(
|
|||
if (pdfDocument != null) {
|
||||
try {
|
||||
withContext(Dispatchers.IO) {
|
||||
pdfDocument!!.openPage(pageIndex).use { page ->
|
||||
pdfDocument!!.openPage(pageIndex)?.use { page ->
|
||||
page.openTextPage().use { textPage ->
|
||||
val count = textPage.textPageCountChars()
|
||||
|
||||
|
|
@ -2414,10 +2761,10 @@ fun PdfViewerScreen(
|
|||
withContext(Dispatchers.IO) {
|
||||
Timber.d("TTS: Opening page $pageToRead for Pdfium text extraction.")
|
||||
tempPage = pdfDocument!!.openPage(pageToRead)
|
||||
tempTextPage = tempPage.openTextPage()
|
||||
val charCount = tempTextPage.textPageCountChars()
|
||||
tempTextPage = tempPage?.openTextPage()
|
||||
val charCount = tempTextPage?.textPageCountChars() ?: 0
|
||||
if (charCount > 0) {
|
||||
rawPageText = tempTextPage.textPageGetText(0, charCount)?.trim()
|
||||
rawPageText = tempTextPage?.textPageGetText(0, charCount)?.trim()
|
||||
if (rawPageText.isNullOrBlank()) {
|
||||
Timber.d(
|
||||
"TTS: Pdfium extracted text but it's blank (charCount: $charCount)."
|
||||
|
|
@ -2772,6 +3119,17 @@ fun PdfViewerScreen(
|
|||
pdfDocument = doc
|
||||
pfdState = currentPfdOpened
|
||||
val pagesCount = doc.getPageCount()
|
||||
|
||||
if (pagesCount > 0) {
|
||||
try {
|
||||
val tableOfContents = doc.getFixedTableOfContents()
|
||||
val flattened = flattenToc(tableOfContents)
|
||||
withContext(Dispatchers.Main) { flatTableOfContents = flattened }
|
||||
} catch (e: Exception) {
|
||||
Timber.w(e, "Failed to load TOC")
|
||||
}
|
||||
}
|
||||
|
||||
totalPages = pagesCount
|
||||
|
||||
if (pagesCount > 0) {
|
||||
|
|
@ -2782,7 +3140,7 @@ fun PdfViewerScreen(
|
|||
cachedRatios
|
||||
} else {
|
||||
val computedRatios = ArrayList<Float>(pagesCount)
|
||||
doc.openPage(0).use { page ->
|
||||
doc.openPage(0)?.use { page ->
|
||||
val width = page.getPageWidthPoint()
|
||||
val height = page.getPageHeightPoint()
|
||||
val ratio = if (height > 0) width.toFloat() / height.toFloat()
|
||||
|
|
@ -2797,7 +3155,7 @@ fun PdfViewerScreen(
|
|||
for (i in 0 until pagesCount) {
|
||||
if (!isActive) break
|
||||
try {
|
||||
doc.openPage(i).use { page ->
|
||||
doc.openPage(i)?.use { page ->
|
||||
val width = page.getPageWidthPoint()
|
||||
val height = page.getPageHeightPoint()
|
||||
val ratio =
|
||||
|
|
@ -2843,7 +3201,7 @@ fun PdfViewerScreen(
|
|||
for (i in 1 until pagesCount) {
|
||||
if (!isActive) break
|
||||
try {
|
||||
doc.openPage(i).use { page ->
|
||||
doc.openPage(i)?.use { page ->
|
||||
val width = page.getPageWidthPoint()
|
||||
val height = page.getPageHeightPoint()
|
||||
val ratio = if (height > 0) width.toFloat() / height.toFloat()
|
||||
|
|
@ -2866,16 +3224,6 @@ fun PdfViewerScreen(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
launch(Dispatchers.IO) {
|
||||
try {
|
||||
val tableOfContents = doc.getTableOfContents()
|
||||
val flattened = flattenToc(tableOfContents)
|
||||
withContext(Dispatchers.Main) { flatTableOfContents = flattened }
|
||||
} catch (e: Exception) {
|
||||
Timber.w(e, "Failed to load TOC")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
isDocumentReady = true
|
||||
isLoadingDocument = false
|
||||
|
|
@ -2884,7 +3232,7 @@ fun PdfViewerScreen(
|
|||
Timber.i("PDF document loaded optimistically. Total Pages: $totalPages.")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is PdfPasswordException || e.cause is PdfPasswordException) {
|
||||
if (e.javaClass.name.contains("PasswordException") || e.cause?.javaClass?.name?.contains("PasswordException") == true) {
|
||||
Timber.w("PDF is password protected or password incorrect.")
|
||||
withContext(Dispatchers.Main) {
|
||||
if (documentPassword != null) {
|
||||
|
|
@ -3358,9 +3706,7 @@ fun PdfViewerScreen(
|
|||
0 -> { // Chapters Page
|
||||
if (flatTableOfContents.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp),
|
||||
modifier = Modifier.fillMaxSize().padding(16.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
|
|
@ -3370,54 +3716,99 @@ fun PdfViewerScreen(
|
|||
)
|
||||
}
|
||||
} else {
|
||||
val currentTocEntry by remember(
|
||||
pagerState.currentPage, flatTableOfContents
|
||||
) {
|
||||
derivedStateOf {
|
||||
flatTableOfContents.lastOrNull {
|
||||
it.pageIndex <= pagerState.currentPage
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
val allParentIndices = remember(flatTableOfContents) {
|
||||
flatTableOfContents.indices.filter { i ->
|
||||
val next = flatTableOfContents.getOrNull(i + 1)
|
||||
next != null && next.nestLevel > flatTableOfContents[i].nestLevel
|
||||
}.toSet()
|
||||
}
|
||||
|
||||
var expandedEntryIndices by rememberSaveable(flatTableOfContents) {
|
||||
mutableStateOf(allParentIndices)
|
||||
}
|
||||
|
||||
val visibleItemInfo = remember(flatTableOfContents, expandedEntryIndices) {
|
||||
val result = mutableListOf<Pair<Int, TocEntry>>()
|
||||
val visibilityStack = BooleanArray(20) { false }
|
||||
visibilityStack[0] = true
|
||||
|
||||
for (i in flatTableOfContents.indices) {
|
||||
val entry = flatTableOfContents[i]
|
||||
val level = entry.nestLevel.coerceIn(0, 19)
|
||||
|
||||
if (visibilityStack[level]) {
|
||||
result.add(i to entry)
|
||||
val isExpanded = expandedEntryIndices.contains(i)
|
||||
if (level + 1 < visibilityStack.size) {
|
||||
visibilityStack[level + 1] = isExpanded
|
||||
}
|
||||
} else {
|
||||
if (level + 1 < visibilityStack.size) {
|
||||
visibilityStack[level + 1] = false
|
||||
}
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
LazyColumn(modifier = Modifier.fillMaxHeight()) {
|
||||
itemsIndexed(
|
||||
items = flatTableOfContents, key = { index, entry ->
|
||||
"toc_${index}_${entry.pageIndex}_${entry.title.hashCode()}"
|
||||
}) { _, entry ->
|
||||
val isCurrentChapter = entry == currentTocEntry
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(
|
||||
entry.title,
|
||||
fontWeight = if (isCurrentChapter) FontWeight.Bold
|
||||
else FontWeight.Normal,
|
||||
modifier = Modifier.padding(
|
||||
start = (16 * entry.nestLevel).dp
|
||||
)
|
||||
)
|
||||
}, colors = if (isCurrentChapter) {
|
||||
ListItemDefaults.colors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
headlineColor = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
)
|
||||
} else {
|
||||
ListItemDefaults.colors()
|
||||
}, modifier = Modifier.clickable {
|
||||
coroutineScope.launch {
|
||||
drawerState.close()
|
||||
if (displayMode == DisplayMode.PAGINATION) {
|
||||
pagerState.scrollToPage(
|
||||
entry.pageIndex
|
||||
)
|
||||
|
||||
val currentTocEntry by remember(pagerState.currentPage, verticalReaderState.currentPage, displayMode, flatTableOfContents) {
|
||||
derivedStateOf {
|
||||
val activePage = if (displayMode == DisplayMode.PAGINATION) pagerState.currentPage else verticalReaderState.currentPage
|
||||
flatTableOfContents.lastOrNull { it.pageIndex <= activePage }
|
||||
}
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.padding(end = 12.dp)
|
||||
) {
|
||||
items(
|
||||
items = visibleItemInfo,
|
||||
key = { it.second.title + it.first }
|
||||
) { item ->
|
||||
val (originalIndex, entry) = item
|
||||
|
||||
val nextItem = flatTableOfContents.getOrNull(originalIndex + 1)
|
||||
val hasChildren = nextItem != null && nextItem.nestLevel > entry.nestLevel
|
||||
val isExpanded = expandedEntryIndices.contains(originalIndex)
|
||||
val isCurrentChapter = entry == currentTocEntry
|
||||
|
||||
PdfTocTreeItem(
|
||||
label = entry.title,
|
||||
nestLevel = entry.nestLevel,
|
||||
isExpanded = isExpanded,
|
||||
hasChildren = hasChildren,
|
||||
isCurrent = isCurrentChapter,
|
||||
onToggleExpand = {
|
||||
expandedEntryIndices = if (isExpanded) {
|
||||
expandedEntryIndices - originalIndex
|
||||
} else {
|
||||
verticalReaderState.scrollToPage(
|
||||
entry.pageIndex
|
||||
)
|
||||
expandedEntryIndices + originalIndex
|
||||
}
|
||||
},
|
||||
onClick = {
|
||||
coroutineScope.launch {
|
||||
drawerState.close()
|
||||
if (displayMode == DisplayMode.PAGINATION) {
|
||||
pagerState.scrollToPage(entry.pageIndex)
|
||||
} else {
|
||||
verticalReaderState.scrollToPage(entry.pageIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
HorizontalDivider()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
VerticalScrollbar(
|
||||
listState = listState,
|
||||
modifier = Modifier.align(Alignment.CenterEnd)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -3767,7 +4158,13 @@ fun PdfViewerScreen(
|
|||
modifier = Modifier.fillMaxSize(),
|
||||
key = { it },
|
||||
beyondViewportPageCount = dynamicBeyondViewportPageCount,
|
||||
userScrollEnabled= currentPageScale == 1f && !(ttsState.isPlaying || ttsState.isLoading || searchState.isSearchActive) && !isPageSliderVisible && paginationDraggingBoxId == null
|
||||
userScrollEnabled = run {
|
||||
val enabled = currentPageScale == 1f && !(ttsState.isPlaying || ttsState.isLoading || searchState.isSearchActive) && !isPageSliderVisible && paginationDraggingBoxId == null
|
||||
SideEffect {
|
||||
Timber.tag("PdfZoomDebug").v("Pager Scroll Enabled: $enabled (Scale: $currentPageScale, Playing: ${ttsState.isPlaying}, Slider: $isPageSliderVisible, DraggingBox: $paginationDraggingBoxId)")
|
||||
}
|
||||
enabled
|
||||
}
|
||||
) { pageIndex ->
|
||||
val isVisiblePage = remember(pagerState.currentPage, pageIndex) {
|
||||
kotlin.math.abs(pagerState.currentPage - pageIndex) <= 1
|
||||
|
|
@ -5221,6 +5618,20 @@ fun PdfViewerScreen(
|
|||
Icons.Default.Save, contentDescription = null
|
||||
)
|
||||
})
|
||||
DropdownMenuItem(
|
||||
text = { Text("Print") },
|
||||
onClick = {
|
||||
showMoreMenu = false
|
||||
onPrintDocument()
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.print),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -6929,6 +7340,7 @@ private fun debugPdfLinks(
|
|||
if (pageCount > 0) {
|
||||
val pageIndex = 0 // Testing the first page
|
||||
page = doc.openPage(pageIndex)
|
||||
if (page == null) return@launch
|
||||
Timber.d("Opened page $pageIndex")
|
||||
|
||||
Timber.d(
|
||||
|
|
@ -6957,7 +7369,7 @@ private fun debugPdfLinks(
|
|||
|
||||
// Method 2: The one that is working
|
||||
page.openTextPage().use { textPage ->
|
||||
textPage.loadWebLink().use { webLinks ->
|
||||
textPage.loadWebLink()?.use { webLinks ->
|
||||
val webLinkCount = webLinks.countWebLinks()
|
||||
Timber.d("[METHOD 2] loadWebLink() found $webLinkCount links.")
|
||||
if (webLinkCount > 0) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue