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:
Aryan 2026-03-16 00:37:16 +05:30 committed by GitHub
parent dece09fec0
commit 1884ace646
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 1207 additions and 653 deletions

View file

@ -20,8 +20,6 @@
// LibraryScreen.kt
package com.aryan.reader
import android.content.Context
import android.provider.DocumentsContract
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
@ -64,7 +62,6 @@ import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@ -99,8 +96,6 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.documentfile.provider.DocumentFile
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.compose.AsyncImage
import coil.request.ImageRequest
@ -146,7 +141,7 @@ fun LibraryScreen(
val scope = rememberCoroutineScope()
var isSearchActive by remember { mutableStateOf(false) }
val isSearchActive = uiState.isSearchActive
val searchQuery = uiState.searchQuery
val pickFolderLauncher = rememberLauncherForActivityResult(
@ -222,8 +217,7 @@ fun LibraryScreen(
}
BackHandler(enabled = isSearchActive) {
isSearchActive = false
viewModel.onSearchQueryChange("")
viewModel.setSearchActive(false)
}
Box(modifier = Modifier.fillMaxSize()) {
@ -238,10 +232,7 @@ fun LibraryScreen(
searchQuery = searchQuery,
isSearchActive = isSearchActive,
onSearchQueryChange = viewModel::onSearchQueryChange,
onSearchActiveChange = { active ->
isSearchActive = active
if (!active) viewModel.onSearchQueryChange("")
},
onSearchActiveChange = viewModel::setSearchActive,
onSortOrderChange = viewModel::setSortOrder,
onClearSelection = { viewModel.clearContextualAction() },
onItemClick = viewModel::onRecentFileClicked,
@ -474,6 +465,19 @@ fun LibraryScreenContent(
val tabTitles = listOf("All Books", "Shelves", "Folders")
val searchFocusRequester = remember { FocusRequester() }
var textFieldValue by remember(isSearchActive) {
mutableStateOf(TextFieldValue(searchQuery, TextRange(searchQuery.length)))
}
LaunchedEffect(searchQuery) {
if (textFieldValue.text != searchQuery) {
textFieldValue = textFieldValue.copy(
text = searchQuery,
selection = TextRange(searchQuery.length)
)
}
}
LaunchedEffect(isSearchActive) {
if (isSearchActive) {
searchFocusRequester.requestFocus()
@ -501,7 +505,7 @@ fun LibraryScreenContent(
} else if (isSearchActive) {
Surface(
shadowElevation = 4.dp,
modifier = Modifier.fillMaxWidth().statusBarsPadding()
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier
@ -513,8 +517,11 @@ fun LibraryScreenContent(
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Close search")
}
OutlinedTextField(
value = searchQuery,
onValueChange = onSearchQueryChange,
value = textFieldValue,
onValueChange = {
textFieldValue = it
onSearchQueryChange(it.text)
},
placeholder = { Text("Search title or author...") },
modifier = Modifier
.weight(1f)
@ -866,9 +873,11 @@ private fun ShelfDetailScreen(
},
floatingActionButton = {
if (shelf.name != "Unshelved" && !isContextualModeActive) {
FloatingActionButton(onClick = onAddBooksClick) {
Icon(Icons.Default.Add, contentDescription = "Add books")
}
ExtendedFloatingActionButton(
onClick = onAddBooksClick,
icon = { Icon(Icons.Default.Add, contentDescription = null) },
text = { Text("Add books") }
)
}
}
) { paddingValues ->
@ -1362,25 +1371,6 @@ private fun DeleteShelvesConfirmationDialog(
)
}
private fun getDisplayPathFromUri(context: Context, uriString: String): String {
val uri = uriString.toUri()
val fallbackName = DocumentFile.fromTreeUri(context, uri)?.name ?: "Unknown Folder"
if (DocumentsContract.isTreeUri(uri) && DocumentsContract.getTreeDocumentId(uri).isNotEmpty()) {
val documentId = DocumentsContract.getTreeDocumentId(uri)
val split = documentId.split(":")
if (split.size > 1) {
val type = split[0]
val path = split[1]
return when (type) {
"primary" -> "Internal Storage ▸ $path"
else -> path
}
}
}
return fallbackName
}
@Composable
private fun FolderSyncScreen(
syncedFolders: List<SyncedFolder>,

View file

@ -203,6 +203,7 @@ data class ReaderScreenState(
val lastFolderScanTime: Long? = null,
val hasUnreadFeedback: Boolean = false,
val searchQuery: String = "",
val isSearchActive: Boolean = false,
val showFolderMigrationDialog: Boolean = false,
val isRefreshing: Boolean = false,
val reflowProgress: Float? = null,
@ -369,7 +370,23 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
)
fun onSearchQueryChange(newQuery: String) {
_internalState.update { it.copy(searchQuery = newQuery) }
_internalState.update {
if (it.isSearchActive) {
it.copy(searchQuery = newQuery)
} else {
it
}
}
}
fun setSearchActive(active: Boolean) {
_internalState.update {
if (active) {
it.copy(isSearchActive = true)
} else {
it.copy(isSearchActive = false, searchQuery = "")
}
}
}
private val _reviewRequestEvent = Channel<Unit>(Channel.BUFFERED)

View file

@ -24,8 +24,15 @@ object NativePdfiumBridge {
@JvmStatic external fun getPageObjectBoundingBox(pagePtr: Long, index: Int, outRect: FloatArray): Boolean
@JvmStatic external fun extractImagePixels(pagePtr: Long, index: Int, dimens: IntArray): IntArray?
@JvmStatic external fun performClick(pagePtr: Long, x: Double, y: Double): Boolean
@JvmStatic external fun getAnnotSubtypeAtPoint(pagePtr: Long, x: Double, y: Double): Int
@JvmStatic external fun getAnnotRectAtPoint(pagePtr: Long, x: Double, y: Double): FloatArray?
@JvmStatic external fun checkActionSupport(): Boolean
const val ANNOT_TEXT = 1 // Sticky Note
const val ANNOT_LINK = 2 // Link
const val ANNOT_HIGHLIGHT = 8 // Highlight
const val ANNOT_INK = 12 // Freehand drawing
const val ANNOT_WIDGET = 19
}

View file

@ -55,7 +55,7 @@ class PdfCoverGenerator(context: Context) {
Timber.w("PDF has no pages, cannot generate cover: $pdfUri")
return@withContext null
}
doc.openPage(0).use { page ->
doc.openPage(0)?.use { page ->
val originalWidth = page.getPageWidthPoint()
val originalHeight = page.getPageHeightPoint()
if (originalWidth <= 0 || originalHeight <= 0) {

File diff suppressed because it is too large Load diff

View file

@ -131,13 +131,12 @@ object PdfToHtmlGenerator {
headerFooterStrings: Set<String>
): String {
return try {
doc.openPage(pageIdx).use { page ->
if (page == null) return buildEmptyPageSection(pageNumber)
doc.openPage(pageIdx)?.use { page ->
page.openTextPage().use { textPage ->
val charCount = textPage.textPageCountChars()
val pagePtr = page.page.pagePtr
val textPagePtr = textPage.page.pagePtr
val pagePtr = getNativePointer(page)
val textPagePtr = getNativePointer(textPage)
val imageElements = mutableListOf<ImageElement>()
val objCount = NativePdfiumBridge.getPageObjectCount(pagePtr)
@ -178,7 +177,7 @@ object PdfToHtmlGenerator {
val flags: IntArray?
val charBoxes: FloatArray?
synchronized(PdfiumCore.lock) {
synchronized(NativePdfiumBridge::class.java) {
sizes = NativePdfiumBridge.getPageFontSizes(textPagePtr, actualCount)
weights = NativePdfiumBridge.getPageFontWeights(textPagePtr, actualCount)
flags = NativePdfiumBridge.getPageFontFlags(textPagePtr, actualCount)
@ -293,8 +292,8 @@ object PdfToHtmlGenerator {
}
buildPageHtml(pageNumber, finalElements, headerFooterStrings)
}
}
} ?: buildEmptyPageSection(pageNumber)
} ?: buildEmptyPageSection(pageNumber)
} catch (e: Exception) {
Timber.tag(TAG).w(e, "Error extracting page $pageIdx")
buildEmptyPageSection(pageNumber)
@ -483,7 +482,7 @@ object PdfToHtmlGenerator {
for (pageIdx in samplePages) {
try {
doc.openPage(pageIdx).use { page ->
doc.openPage(pageIdx)?.use { page ->
page.openTextPage().use { textPage ->
val charCount = textPage.textPageCountChars()
if (charCount <= 0) return@use
@ -511,4 +510,21 @@ object PdfToHtmlGenerator {
.replace(">", "&gt;")
.replace("\"", "&quot;")
.replace("'", "&#39;")
private fun getNativePointer(obj: Any): Long {
val priorityFields = listOf("pagePtr", "mNativePage", "page")
for (name in priorityFields) {
try {
val field = obj.javaClass.getDeclaredField(name)
field.isAccessible = true
val value = field.get(obj)
if (value is Long && value != 0L) return value
if (value != null && value !is Long) {
val nestedPtr = getNativePointer(value)
if (nestedPtr != 0L) return nestedPtr
}
} catch (_: Exception) {}
}
return 0L
}
}

View file

@ -358,8 +358,12 @@ internal fun PdfVerticalReader(
val constrainedX = targetPanX.coerceIn(minPanX, maxPanX)
val constrainedY = targetPanY.coerceIn(minPanY, headerHeightPx)
// DEDICATED LOG
Timber.tag("PdfZoomDebug").v("Clamp Internal: Zoom=$constrainedZoom, PanBoundsX=[$minPanX, $maxPanX], PanBoundsY=[$minPanY, $headerHeightPx]")
if (constrainedZoom > 1.01f) {
Timber.tag("PdfZoomIssue").v(
"Clamp: Zoom=$constrainedZoom, targetY=$targetPanY, finalY=$constrainedY, " +
"boundsY=[$minPanY, $headerHeightPx], zoomedHeight=$zoomedDocHeight"
)
}
return Triple(constrainedZoom, constrainedX, constrainedY)
}
@ -370,10 +374,14 @@ internal fun PdfVerticalReader(
return clampValues(targetZoom, targetPanX, targetPanY)
}
var isFlinging by remember { mutableStateOf(false) }
var isFastFlinging by remember { mutableStateOf(false) }
var isInteracting by remember { mutableStateOf(false) }
LaunchedEffect(
totalDocHeight, screenHeight, headerHeightPx, footerHeightPx, zoomAnimatable.value
totalDocHeight, screenHeight, headerHeightPx, footerHeightPx, zoomAnimatable.value, isInteracting, isFlinging
) {
if (zoomAnimatable.isRunning || panXAnimatable.isRunning || panYAnimatable.isRunning) {
if (zoomAnimatable.isRunning || panXAnimatable.isRunning || panYAnimatable.isRunning || isInteracting || isFlinging) {
return@LaunchedEffect
}
@ -446,10 +454,6 @@ internal fun PdfVerticalReader(
}
var selectionClearTrigger by remember { mutableLongStateOf(0L) }
var isFlinging by remember { mutableStateOf(false) }
var isFastFlinging by remember { mutableStateOf(false) }
var draggingBoxId by remember { mutableStateOf<String?>(null) }
var draggingBoxOffset by remember { mutableStateOf(Offset.Zero) }
var draggingBoxSize by remember { mutableStateOf(Size.Zero) }
@ -549,7 +553,6 @@ internal fun PdfVerticalReader(
}
var highResScale by remember { mutableFloatStateOf(1f) }
var isInteracting by remember { mutableStateOf(false) }
LaunchedEffect(isInteracting) {
if (isInteracting && isAutoScrollPlaying) {
@ -617,8 +620,12 @@ internal fun PdfVerticalReader(
imeBottom,
isEditMode,
selectedTool,
zoomAnimatable.value
zoomAnimatable.value,
isInteracting,
isFlinging
) {
if (isInteracting || isFlinging) return@LaunchedEffect
val currentZoom = zoomAnimatable.value
val zoomedDocHeight = totalDocHeight * currentZoom
@ -897,6 +904,7 @@ internal fun PdfVerticalReader(
)
val down = awaitFirstDown(requireUnconsumed = false)
isInteracting = true
Timber.tag("PointerTypeDebug").d("VerticalReader: Input Type detected: ${down.type}")
@ -1015,31 +1023,35 @@ internal fun PdfVerticalReader(
(!isEditMode || selectedTool == InkType.TEXT || isMultiTouch || (isStylusOnlyMode && isTouchInput))
if (shouldScroll) {
if (!isInteracting) {
Timber.tag("PdfTouchDebug").i(
"VerticalReader: Taking control."
)
isInteracting = true
}
panLocked = true
if (zoomChange != 1f || panChange != Offset.Zero) {
var effectiveZoomChange = zoomChange
if (gestureDisambiguationMode == 1) {
effectiveZoomChange = 1f
}
if (gestureDisambiguationMode == 1) effectiveZoomChange = 1f
val oldZoom = accumulatedZoom
val rawTargetZoom = oldZoom * effectiveZoomChange
val constrainedZoom = rawTargetZoom.coerceIn(1f, 5f)
val actualZoomFactor = if (oldZoom == 0f) 1f
else constrainedZoom / oldZoom
val rawNewPanX =
(accumulatedPanX + panChange.x) - (centroid.x - accumulatedPanX) * (actualZoomFactor - 1)
val rawNewPanY =
(accumulatedPanY + panChange.y) - (centroid.y - accumulatedPanY) * (actualZoomFactor - 1)
val prevCentroid = centroid - panChange
val contentPivotX = (prevCentroid.x - accumulatedPanX) / oldZoom
val contentPivotY = (prevCentroid.y - accumulatedPanY) / oldZoom
Timber.tag("PdfZoomIssue").v(
"PivotCalc: ScreenCentroidY=${centroid.y}, DocumentPanY=$accumulatedPanY, " +
"CalculatedContentPivotY=$contentPivotY"
)
val rawNewPanX = centroid.x - (contentPivotX * constrainedZoom)
val rawNewPanY = centroid.y - (contentPivotY * constrainedZoom)
if (effectiveZoomChange > 1.0f) {
Timber.tag("PdfZoomIssue").d(
"PinchIn FIXED: ZoomFactor=$effectiveZoomChange, CentroidY=${centroid.y}, " +
"OldPanY=$accumulatedPanY, ResultRawPanY=$rawNewPanY"
)
}
val (finalZoom, finalX, finalY) = clampCamera(
constrainedZoom, rawNewPanX, rawNewPanY
)
@ -1064,11 +1076,8 @@ internal fun PdfVerticalReader(
if (event.changes.isNotEmpty()) {
velocityTrackerAccumulator += panChange
val time = event.changes[0].uptimeMillis
tracker.addPosition(
time, velocityTrackerAccumulator
)
tracker.addPosition(time, velocityTrackerAccumulator)
}
}
}

View file

@ -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) {

View file

@ -171,7 +171,7 @@ class PdfTextRepository(context: Context) {
var ocrUsed = false
try {
document.openPage(pageIndex).use { page ->
document.openPage(pageIndex)?.use { page ->
page.openTextPage().use { textPage ->
val count = textPage.textPageCountChars()
if (count > 0) {
@ -188,7 +188,7 @@ class PdfTextRepository(context: Context) {
if (text.isBlank()) {
try {
document.openPage(pageIndex).use { page ->
document.openPage(pageIndex)?.use { page ->
val targetWidth = 1080
val ptrWidth = page.getPageWidthPoint()
val ptrHeight = page.getPageHeightPoint()
@ -270,11 +270,11 @@ class PdfTextRepository(context: Context) {
suspend fun hasNativeText(document: PdfDocumentKt, pageIndex: Int): Boolean {
return withContext(Dispatchers.IO) {
try {
document.openPage(pageIndex).use { page ->
document.openPage(pageIndex)?.use { page ->
page.openTextPage().use { textPage ->
textPage.textPageCountChars() > 0
}
}
} ?: false
} catch (_: Exception) {
false
}
@ -290,7 +290,7 @@ class PdfTextRepository(context: Context) {
return withContext(Dispatchers.IO) {
val rects = mutableListOf<RectF>()
try {
document.openPage(pageIndex).use { page ->
document.openPage(pageIndex)?.use { page ->
val targetWidth = 1080
val ptrWidth = page.getPageWidthPoint()
val ptrHeight = page.getPageHeightPoint()