Desktop app (#308)
* Implement build profiles and feature policy for offline desktop builds * Introduce unified cross-platform Settings Hub * Refactor main settings into a hierarchical page-based navigation model * Refactor library projection to use shared multiplatform logic * Refactor UI state consumption by removing intermediate screen models * Introduce AndroidSharedStateBridge to centralize state mapping and reduction logic * Refactor state management for tabs, selection, and pinning to use shared bridge logic * Refactor file type management and validation into a centralized shared module * Centralize file type resolution and improve handling of unknown types * Centralize book import logic with SharedImportPlanner * Refactor magnifier geometry logic and coordinate mapping * Properly handle orientation changes in scroll-locked PDF reader * Add screen orientation controls to EPUB and PDF readers * Implement right-to-left (RTL) pagination support and refactor reader menus * Separate right-to-left pagination settings for PDF and EPUB * Ensure PDF page data is scoped by document key for multi tab support * Implement theme-aware link styling for the epub reader * Implement jump history for back and forward navigation in the epub reader * Improve locator handling and navigation logic in paginated reader mode * Implement stable pagination navigation and location tracking * Centralize banner message management and auto-dismiss logic in MainViewModel * Implement zoom and pan state preservation for PDF pan lock mode * Enhance reader navigation UI and workspace layout management in desktop app * Refactor reader navigation sidebar and relocate search controls in desktop app * Enhance reader UI with redesigned selection menus and bottom sheet overlays * Implement custom highlight palettes and reader theme customization in desktop app * Implement cross-platform modal layer and refine reader UI styling * Improve highlight accuracy and implement metadata enrichment on book open in desktop app * Implement two-page spread layout for paginated reader on desktop * Implement persistent caching for book loading and pagination in desktop app * Implement persistent caching for book loading and pagination in desktop app * Optimize reader settings updates by separating layout and appearance changes in desktop app * Improve desktop window branding and native Windows styling * Enhance reader selection interactions and UI across EPUB and PDF viewers in desktop app * Refine selection handle positioning and interaction logic * Implement EPUB selection debug logging and improve handle targeting * Optimize desktop book loading performance and UI responsiveness * Implement anchored zoom gestures and rendering optimizations for the Desktop PDF viewer. * Implement smooth zoom preview for the PDF reader in desktop app * Optimize PDF rendering performance and responsiveness in the desktop reader * Implement conditional diagnostic logging and update desktop build configuration * Implemented hierarchical TOC, custom scrollbars, and improved desktop modal handling * Added management options for annotations and highlights in the sidebar in desktop app * Implemented `SharedStableOutlinedTextField` and updated text input fields to use `TextFieldValue` for improved cursor and selection stability. * Refined library filters and enhanced OPDS functionality in desktop app * Improved EPUB pagination measurement and implemented layout diagnostic logging for desktop app * Added PPTX support including document parsing, rendering, and indexing * Improved PPTX rendering and layout accuracy * Implemented text autofit support for PPTX rendering * Enhanced PPTX rendering with support for custom geometry, automatic numbering, table styles, and image opacity * Improved EPUB pagination accuracy and added layout telemetry in desktop app * Improved folder synchronization with metadata-only mode and hashed sidecar management in desktop app * Implemented rich text font scaling and migrated desktop ink tools to custom pointer input handling * Implemented billing account obfuscation * Implemented hierarchical folder navigation and improved library selection functionality in desktop app * Implemented platform-aware directory resolution and multi-platform native library support for desktop * Added full-screen mode for the reader workspace * Added PDF zoom indicator and interactive vertical scrollbar with page tooltips * Refactored speech bubble prefetching to use a limited radius and improved ML detector initialization and lifecycle management * Updated PDF indexing to replace existing page text and removed search result item keys * Implemented "preparing" foreground notification for TTS service * Optimized PDF rendering performance by pre-calculating page-specific annotations * Refactored desktop packaging tasks and improved distribution configuration * Optimized EPUB parser memory usage and added path traversal protection * Refactored WorkManager monitoring logic and added work pruning * Implemented comprehensive resource cleanup and memory management for WebView-based components to prevent memory leaks * Implemented bitmap size limits and scaling to prevent canvas rendering errors * Split long text paragraphs into multiple semantic blocks during HTML parsing * Implemented local ActionMode for text selection to prevent platform crashes * Refactored PPTX text layout, optimized HtmlParser block detection, and improved banner dismissal logic * Added desktop startup splash screen and deferred WebView initialization * Reorganized settings hub and added separate PDF reader defaults * Implemented embedded cover extraction and metadata support for MOBI and FB2 formats * Implemented batching for MetadataExtractionWorker and optimized EPUB metadata extraction performance. * Implemented procedurally generated book covers and replaced static placeholders * Redesigned search UI with a top bar and results overlay in desktop app * Added PDF page gap and overlay visibility options and implemented DesktopBookImporter * Refactored PDF reader UI with tabbed inspector and improved theme background handling in desktop * Implemented PDF viewport persistence for zoom and scroll positions in desktop app * Improved desktop fullscreen implementation and state restoration * Implemented desktop window state persistence * Implemented flavor-based branding and ProGuard configuration for desktop builds * Implemented precise reader positioning and improved highlight rendering logic in desktop app * Added support for user-editable book metadata * Enhanced book metadata support and integrated info/edit dialogs * Implemented embedded EPUB metadata editing * Improved highlight mapping and added custom scrollbar styling for the reader. * Reduced desktop WebView bundle size by excluding unused locales and runtime files * Added neutral pan mode as the default PDF interaction state. * Refactored library empty states and updated primary navigation tabs in desktop app * Implemented native paginated reader and unified content rendering architecture in desktop epub reader * Implemented native EPUB image rendering for desktop and improved block layout spacing with margin collapsing. * Improved pagination overflow detection in desktop * Implemented multi-block text selection with interactive handles and CFI support in desktop epub pagination
This commit is contained in:
parent
c0d0e57e79
commit
b20ade9946
247 changed files with 43321 additions and 7087 deletions
|
|
@ -20,6 +20,7 @@
|
|||
package com.aryan.reader.pdf
|
||||
|
||||
import android.graphics.Rect
|
||||
import android.graphics.RectF
|
||||
import timber.log.Timber
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.layout.Box
|
||||
|
|
@ -32,6 +33,7 @@ import androidx.compose.ui.Modifier
|
|||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
|
|
@ -43,17 +45,114 @@ import androidx.compose.ui.unit.dp
|
|||
import kotlin.math.max
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
internal data class MagnifierContentSource(
|
||||
val sourceWidth: Int,
|
||||
val sourceHeight: Int,
|
||||
val contentLeft: Float,
|
||||
val contentTop: Float,
|
||||
val contentWidth: Float,
|
||||
val contentHeight: Float
|
||||
) {
|
||||
val scaleX: Float
|
||||
get() = if (contentWidth > 0f) sourceWidth.toFloat() / contentWidth else 1f
|
||||
|
||||
val scaleY: Float
|
||||
get() = if (contentHeight > 0f) sourceHeight.toFloat() / contentHeight else 1f
|
||||
|
||||
fun sourceX(contentX: Float): Float = (contentX - contentLeft) * scaleX
|
||||
|
||||
fun sourceY(contentY: Float): Float = (contentY - contentTop) * scaleY
|
||||
}
|
||||
|
||||
internal data class MagnifierSampleGeometry(
|
||||
val srcLeft: Int,
|
||||
val srcTop: Int,
|
||||
val srcWidth: Int,
|
||||
val srcHeight: Int,
|
||||
val outputScaleX: Float,
|
||||
val outputScaleY: Float
|
||||
)
|
||||
|
||||
internal fun calculateMagnifierSampleGeometry(
|
||||
centerContentX: Float,
|
||||
centerContentY: Float,
|
||||
contentSource: MagnifierContentSource,
|
||||
magnifierWidthPx: Float,
|
||||
magnifierHeightPx: Float,
|
||||
zoomFactor: Float
|
||||
): MagnifierSampleGeometry? {
|
||||
if (
|
||||
contentSource.sourceWidth <= 0 ||
|
||||
contentSource.sourceHeight <= 0 ||
|
||||
contentSource.contentWidth <= 0f ||
|
||||
contentSource.contentHeight <= 0f ||
|
||||
magnifierWidthPx <= 0f ||
|
||||
magnifierHeightPx <= 0f ||
|
||||
zoomFactor <= 0f
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
val sourceCenterX = contentSource.sourceX(centerContentX)
|
||||
val sourceCenterY = contentSource.sourceY(centerContentY)
|
||||
val sourceRectWidth = (magnifierWidthPx / zoomFactor * contentSource.scaleX).coerceAtLeast(1f)
|
||||
val sourceRectHeight = (magnifierHeightPx / zoomFactor * contentSource.scaleY).coerceAtLeast(1f)
|
||||
|
||||
val maxSrcLeft = max(0f, contentSource.sourceWidth.toFloat() - sourceRectWidth)
|
||||
val maxSrcTop = max(0f, contentSource.sourceHeight.toFloat() - sourceRectHeight)
|
||||
val srcLeft = (sourceCenterX - sourceRectWidth / 2f).coerceIn(0f, maxSrcLeft)
|
||||
val srcTop = (sourceCenterY - sourceRectHeight / 2f).coerceIn(0f, maxSrcTop)
|
||||
|
||||
val srcLeftInt = srcLeft.roundToInt().coerceIn(0, contentSource.sourceWidth - 1)
|
||||
val srcTopInt = srcTop.roundToInt().coerceIn(0, contentSource.sourceHeight - 1)
|
||||
val srcWidthInt = (contentSource.sourceWidth - srcLeftInt)
|
||||
.coerceAtMost(sourceRectWidth.roundToInt().coerceAtLeast(1))
|
||||
.coerceAtLeast(1)
|
||||
val srcHeightInt = (contentSource.sourceHeight - srcTopInt)
|
||||
.coerceAtMost(sourceRectHeight.roundToInt().coerceAtLeast(1))
|
||||
.coerceAtLeast(1)
|
||||
|
||||
return MagnifierSampleGeometry(
|
||||
srcLeft = srcLeftInt,
|
||||
srcTop = srcTopInt,
|
||||
srcWidth = srcWidthInt,
|
||||
srcHeight = srcHeightInt,
|
||||
outputScaleX = magnifierWidthPx / srcWidthInt,
|
||||
outputScaleY = magnifierHeightPx / srcHeightInt
|
||||
)
|
||||
}
|
||||
|
||||
internal fun mapContentRectToMagnifier(
|
||||
contentRect: Rect,
|
||||
contentSource: MagnifierContentSource,
|
||||
sample: MagnifierSampleGeometry
|
||||
): RectF {
|
||||
val sourceLeft = contentSource.sourceX(contentRect.left.toFloat())
|
||||
val sourceTop = contentSource.sourceY(contentRect.top.toFloat())
|
||||
val sourceRight = contentSource.sourceX(contentRect.right.toFloat())
|
||||
val sourceBottom = contentSource.sourceY(contentRect.bottom.toFloat())
|
||||
|
||||
return RectF(
|
||||
(sourceLeft - sample.srcLeft) * sample.outputScaleX,
|
||||
(sourceTop - sample.srcTop) * sample.outputScaleY,
|
||||
(sourceRight - sample.srcLeft) * sample.outputScaleX,
|
||||
(sourceBottom - sample.srcTop) * sample.outputScaleY
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MagnifierComposable(
|
||||
sourceBitmap: ImageBitmap,
|
||||
tiles: List<PdfTile>,
|
||||
currentScale: Float,
|
||||
magnifierCenterOnBitmap: Offset,
|
||||
contentWidthPx: Int = sourceBitmap.width,
|
||||
contentHeightPx: Int = sourceBitmap.height,
|
||||
modifier: Modifier = Modifier,
|
||||
magnifierWidth: Dp = 120.dp,
|
||||
magnifierHeight: Dp = 60.dp,
|
||||
zoomFactor: Float = 1.5f,
|
||||
selectionRectsInBitmapCoords: List<Rect>,
|
||||
selectionRectsInContentCoords: List<Rect>,
|
||||
highlightColor: Color,
|
||||
colorFilter: ColorFilter? = null
|
||||
) {
|
||||
|
|
@ -81,155 +180,71 @@ fun MagnifierComposable(
|
|||
}
|
||||
} else null
|
||||
|
||||
if (relevantTile != null) {
|
||||
// --- HIGH-RES TILE PATH ---
|
||||
val bitmapToUse: ImageBitmap
|
||||
val contentSource: MagnifierContentSource
|
||||
if (relevantTile != null && !relevantTile.bitmap.isRecycled) {
|
||||
Timber.d("Magnifier: Using HIGH-RES TILE path.")
|
||||
Timber.d("Magnifier: Tile.renderRect=${relevantTile.renderRect}, Tile.bitmap.size=${relevantTile.bitmap.width}x${relevantTile.bitmap.height}")
|
||||
val bitmapToUse = relevantTile.bitmap.asImageBitmap()
|
||||
|
||||
val tileBitmapWidth = relevantTile.bitmap.width.toFloat()
|
||||
val tileRenderRectWidth = relevantTile.renderRect.width().toFloat()
|
||||
|
||||
val tileScale = if (tileRenderRectWidth > 0) {
|
||||
tileBitmapWidth / tileRenderRectWidth
|
||||
} else {
|
||||
1f
|
||||
}
|
||||
Timber.d("Magnifier: Using derived tileScale=$tileScale instead of parent's currentScale=$currentScale")
|
||||
|
||||
|
||||
val centerInTileBitmap = Offset(
|
||||
x = (magnifierCenterOnBitmap.x - relevantTile.renderRect.left) * tileScale,
|
||||
y = (magnifierCenterOnBitmap.y - relevantTile.renderRect.top) * tileScale
|
||||
bitmapToUse = relevantTile.bitmap.asImageBitmap()
|
||||
contentSource = MagnifierContentSource(
|
||||
sourceWidth = bitmapToUse.width,
|
||||
sourceHeight = bitmapToUse.height,
|
||||
contentLeft = relevantTile.renderRect.left.toFloat(),
|
||||
contentTop = relevantTile.renderRect.top.toFloat(),
|
||||
contentWidth = relevantTile.renderRect.width().toFloat(),
|
||||
contentHeight = relevantTile.renderRect.height().toFloat()
|
||||
)
|
||||
|
||||
Timber.d("Magnifier: Calculated centerInTileBitmap=$centerInTileBitmap")
|
||||
|
||||
val sourceRectWidth = magnifierWidthPx / zoomFactor
|
||||
val sourceRectHeight = magnifierHeightPx / zoomFactor
|
||||
Timber.d("Magnifier: Desired sourceRect size=${sourceRectWidth}x$sourceRectHeight")
|
||||
|
||||
val srcLeft = (centerInTileBitmap.x - sourceRectWidth / 2f)
|
||||
val srcTop = (centerInTileBitmap.y - sourceRectHeight / 2f)
|
||||
Timber.d("Magnifier: Calculated source top-left=($srcLeft, $srcTop)")
|
||||
|
||||
val maxSrcLeft = max(0f, bitmapToUse.width.toFloat() - sourceRectWidth.coerceAtLeast(1f))
|
||||
val maxSrcTop = max(0f, bitmapToUse.height.toFloat() - sourceRectHeight.coerceAtLeast(1f))
|
||||
val clampedSrcLeft = srcLeft.coerceIn(0f, maxSrcLeft)
|
||||
val clampedSrcTop = srcTop.coerceIn(0f, maxSrcTop)
|
||||
Timber.d("Magnifier: Clamped source top-left=($clampedSrcLeft, $clampedSrcTop)")
|
||||
|
||||
val finalSrcLeftInt = clampedSrcLeft.roundToInt()
|
||||
val finalSrcTopInt = clampedSrcTop.roundToInt()
|
||||
|
||||
val finalSrcWidthInt = (bitmapToUse.width - finalSrcLeftInt)
|
||||
.coerceAtMost(sourceRectWidth.roundToInt()).coerceAtLeast(1)
|
||||
val finalSrcHeightInt = (bitmapToUse.height - finalSrcTopInt)
|
||||
.coerceAtMost(sourceRectHeight.roundToInt()).coerceAtLeast(1)
|
||||
Timber.d("Magnifier: Final source rect to draw from tile: offset=($finalSrcLeftInt, $finalSrcTopInt), size=${finalSrcWidthInt}x$finalSrcHeightInt")
|
||||
|
||||
if (finalSrcWidthInt <= 0 || finalSrcHeightInt <= 0 || finalSrcLeftInt >= bitmapToUse.width || finalSrcTopInt >= bitmapToUse.height) {
|
||||
Timber.w("Magnifier: Final source rect is invalid, returning.")
|
||||
return@Canvas
|
||||
}
|
||||
|
||||
drawImage(
|
||||
image = bitmapToUse,
|
||||
srcOffset = IntOffset(finalSrcLeftInt, finalSrcTopInt),
|
||||
srcSize = IntSize(finalSrcWidthInt, finalSrcHeightInt),
|
||||
dstSize = IntSize(magnifierWidthPx.roundToInt(), magnifierHeightPx.roundToInt()),
|
||||
colorFilter = colorFilter
|
||||
)
|
||||
|
||||
selectionRectsInBitmapCoords.forEach { rectInBitmap ->
|
||||
val translatedLeft = (rectInBitmap.left - relevantTile.renderRect.left) * tileScale
|
||||
val translatedTop = (rectInBitmap.top - relevantTile.renderRect.top) * tileScale
|
||||
val translatedRight = (rectInBitmap.right - relevantTile.renderRect.left) * tileScale
|
||||
val translatedBottom = (rectInBitmap.bottom - relevantTile.renderRect.top) * tileScale
|
||||
|
||||
val finalLeft = translatedLeft - clampedSrcLeft
|
||||
val finalTop = translatedTop - clampedSrcTop
|
||||
val finalRight = translatedRight - clampedSrcLeft
|
||||
val finalBottom = translatedBottom - clampedSrcTop
|
||||
|
||||
val magnifiedLeft = finalLeft * zoomFactor
|
||||
val magnifiedTop = finalTop * zoomFactor
|
||||
val magnifiedRight = finalRight * zoomFactor
|
||||
val magnifiedBottom = finalBottom * zoomFactor
|
||||
|
||||
if (magnifiedRight > 0 && magnifiedLeft < magnifierWidthPx && magnifiedBottom > 0 && magnifiedTop < magnifierHeightPx) {
|
||||
drawRect(
|
||||
color = highlightColor,
|
||||
topLeft = Offset(magnifiedLeft, magnifiedTop),
|
||||
size = androidx.compose.ui.geometry.Size(
|
||||
width = magnifiedRight - magnifiedLeft,
|
||||
height = magnifiedBottom - magnifiedTop
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
} else {
|
||||
// --- LOW-RES / NO-ZOOM PATH ---
|
||||
Timber.d("Magnifier: Using LOW-RES (base bitmap) path.")
|
||||
val sourceRectWidth = magnifierWidthPx / zoomFactor
|
||||
val sourceRectHeight = magnifierHeightPx / zoomFactor
|
||||
Timber.d("Magnifier: Desired sourceRect size=${sourceRectWidth}x$sourceRectHeight")
|
||||
|
||||
val srcLeft = (magnifierCenterOnBitmap.x - sourceRectWidth / 2f)
|
||||
val srcTop = (magnifierCenterOnBitmap.y - sourceRectHeight / 2f)
|
||||
Timber.d("Magnifier: Calculated source top-left=($srcLeft, $srcTop)")
|
||||
|
||||
val maxSrcLeft = max(0f, sourceBitmap.width.toFloat() - sourceRectWidth.coerceAtLeast(1f))
|
||||
val maxSrcTop = max(0f, sourceBitmap.height.toFloat() - sourceRectHeight.coerceAtLeast(1f))
|
||||
val clampedSrcLeft = srcLeft.coerceIn(0f, maxSrcLeft)
|
||||
val clampedSrcTop = srcTop.coerceIn(0f, maxSrcTop)
|
||||
Timber.d("Magnifier: Clamped source top-left=($clampedSrcLeft, $clampedSrcTop)")
|
||||
|
||||
val finalSrcLeftInt = clampedSrcLeft.roundToInt()
|
||||
val finalSrcTopInt = clampedSrcTop.roundToInt()
|
||||
|
||||
val finalSrcWidthInt = (sourceBitmap.width - finalSrcLeftInt)
|
||||
.coerceAtMost(sourceRectWidth.roundToInt()).coerceAtLeast(1)
|
||||
val finalSrcHeightInt = (sourceBitmap.height - finalSrcTopInt)
|
||||
.coerceAtMost(sourceRectHeight.roundToInt()).coerceAtLeast(1)
|
||||
Timber.d("Magnifier: Final source rect to draw from base: offset=($finalSrcLeftInt, $finalSrcTopInt), size=${finalSrcWidthInt}x$finalSrcHeightInt")
|
||||
|
||||
if (finalSrcWidthInt <= 0 || finalSrcHeightInt <= 0 || finalSrcLeftInt >= sourceBitmap.width || finalSrcTopInt >= sourceBitmap.height) {
|
||||
Timber.w("Magnifier: Final source rect is invalid, returning.")
|
||||
return@Canvas
|
||||
}
|
||||
|
||||
drawImage(
|
||||
image = sourceBitmap,
|
||||
srcOffset = IntOffset(finalSrcLeftInt, finalSrcTopInt),
|
||||
srcSize = IntSize(finalSrcWidthInt, finalSrcHeightInt),
|
||||
dstSize = IntSize(magnifierWidthPx.roundToInt(), magnifierHeightPx.roundToInt()),
|
||||
colorFilter = colorFilter
|
||||
bitmapToUse = sourceBitmap
|
||||
contentSource = MagnifierContentSource(
|
||||
sourceWidth = sourceBitmap.width,
|
||||
sourceHeight = sourceBitmap.height,
|
||||
contentLeft = 0f,
|
||||
contentTop = 0f,
|
||||
contentWidth = contentWidthPx.toFloat(),
|
||||
contentHeight = contentHeightPx.toFloat()
|
||||
)
|
||||
}
|
||||
|
||||
selectionRectsInBitmapCoords.forEach { rectInBitmap ->
|
||||
val translatedLeft = rectInBitmap.left - clampedSrcLeft
|
||||
val translatedTop = rectInBitmap.top - clampedSrcTop
|
||||
val rectWidthInBitmap = rectInBitmap.width().toFloat()
|
||||
val rectHeightInBitmap = rectInBitmap.height().toFloat()
|
||||
val sample = calculateMagnifierSampleGeometry(
|
||||
centerContentX = magnifierCenterOnBitmap.x,
|
||||
centerContentY = magnifierCenterOnBitmap.y,
|
||||
contentSource = contentSource,
|
||||
magnifierWidthPx = magnifierWidthPx,
|
||||
magnifierHeightPx = magnifierHeightPx,
|
||||
zoomFactor = zoomFactor
|
||||
) ?: run {
|
||||
Timber.w("Magnifier: Source geometry is invalid, returning.")
|
||||
return@Canvas
|
||||
}
|
||||
Timber.d("Magnifier: Final source rect offset=(${sample.srcLeft}, ${sample.srcTop}), size=${sample.srcWidth}x${sample.srcHeight}")
|
||||
|
||||
val magnifiedLeft = translatedLeft * zoomFactor
|
||||
val magnifiedTop = translatedTop * zoomFactor
|
||||
val magnifiedWidth = rectWidthInBitmap * zoomFactor
|
||||
val magnifiedHeight = rectHeightInBitmap * zoomFactor
|
||||
drawImage(
|
||||
image = bitmapToUse,
|
||||
srcOffset = IntOffset(sample.srcLeft, sample.srcTop),
|
||||
srcSize = IntSize(sample.srcWidth, sample.srcHeight),
|
||||
dstSize = IntSize(
|
||||
magnifierWidthPx.roundToInt().coerceAtLeast(1),
|
||||
magnifierHeightPx.roundToInt().coerceAtLeast(1)
|
||||
),
|
||||
colorFilter = colorFilter
|
||||
)
|
||||
|
||||
if (magnifiedLeft + magnifiedWidth > 0 && magnifiedLeft < magnifierWidthPx &&
|
||||
magnifiedTop + magnifiedHeight > 0 && magnifiedTop < magnifierHeightPx) {
|
||||
drawRect(
|
||||
color = highlightColor,
|
||||
topLeft = Offset(magnifiedLeft, magnifiedTop),
|
||||
size = androidx.compose.ui.geometry.Size(
|
||||
width = magnifiedWidth,
|
||||
height = magnifiedHeight
|
||||
)
|
||||
selectionRectsInContentCoords.forEach { contentRect ->
|
||||
val magnifierRect = mapContentRectToMagnifier(contentRect, contentSource, sample)
|
||||
if (magnifierRect.width() > 0f && magnifierRect.height() > 0f &&
|
||||
magnifierRect.right > 0f && magnifierRect.left < magnifierWidthPx &&
|
||||
magnifierRect.bottom > 0f && magnifierRect.top < magnifierHeightPx
|
||||
) {
|
||||
drawRect(
|
||||
color = highlightColor,
|
||||
topLeft = Offset(magnifierRect.left, magnifierRect.top),
|
||||
size = Size(
|
||||
width = magnifierRect.width(),
|
||||
height = magnifierRect.height()
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
24
app/src/main/java/com/aryan/reader/pdf/PdfBubblePrefetch.kt
Normal file
24
app/src/main/java/com/aryan/reader/pdf/PdfBubblePrefetch.kt
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
package com.aryan.reader.pdf
|
||||
|
||||
internal const val PDF_BUBBLE_PREFETCH_RADIUS = 1
|
||||
|
||||
internal fun buildPdfBubblePrefetchOrder(
|
||||
currentPage: Int,
|
||||
totalPages: Int,
|
||||
radius: Int = PDF_BUBBLE_PREFETCH_RADIUS
|
||||
): List<Int> {
|
||||
if (totalPages <= 0 || radius < 0) return emptyList()
|
||||
|
||||
val clampedCurrentPage = currentPage.coerceIn(0, totalPages - 1)
|
||||
val ordered = LinkedHashSet<Int>()
|
||||
ordered += clampedCurrentPage
|
||||
|
||||
for (distance in 1..radius) {
|
||||
val next = clampedCurrentPage + distance
|
||||
val previous = clampedCurrentPage - distance
|
||||
if (next in 0 until totalPages) ordered += next
|
||||
if (previous in 0 until totalPages) ordered += previous
|
||||
}
|
||||
|
||||
return ordered.toList()
|
||||
}
|
||||
|
|
@ -21,8 +21,14 @@ import kotlinx.coroutines.withContext
|
|||
import timber.log.Timber
|
||||
import java.io.FileInputStream
|
||||
import java.io.FileOutputStream
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.math.sqrt
|
||||
import kotlin.random.Random
|
||||
|
||||
private const val PDF_PREVIEW_MAX_WIDTH_PX = 1080
|
||||
private const val PDF_PREVIEW_MAX_HEIGHT_PX = 2048
|
||||
private const val PDF_PREVIEW_MAX_BYTES = 16L * 1024L * 1024L
|
||||
|
||||
object PdfiumCoreProvider {
|
||||
val core: PdfiumCoreKt by lazy {
|
||||
PdfiumCoreKt(Dispatchers.Default)
|
||||
|
|
@ -31,7 +37,7 @@ object PdfiumCoreProvider {
|
|||
|
||||
internal data class DocumentCacheItem(
|
||||
val doc: ReaderDocument,
|
||||
val pfd: ParcelFileDescriptor,
|
||||
val pfd: ParcelFileDescriptor?,
|
||||
val totalPages: Int,
|
||||
val pageAspectRatios: List<Float>,
|
||||
val flatTableOfContents: List<TocEntry>
|
||||
|
|
@ -48,7 +54,7 @@ internal class DocumentCache(val maxSize: Int = 3) {
|
|||
if (evicted) {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
try { oldValue.doc.close() } catch (e: Exception) { Timber.e(e) }
|
||||
try { oldValue.pfd.close() } catch (e: Exception) { Timber.e(e) }
|
||||
try { oldValue.pfd?.close() } catch (e: Exception) { Timber.e(e) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -156,14 +162,34 @@ internal suspend fun renderPageToBitmap(doc: ReaderDocument, pageIndex: Int): Bi
|
|||
page = doc.openPage(pageIndex)
|
||||
if (page == null) return@withContext null
|
||||
|
||||
val bitmapWidth = 1080
|
||||
val pageWidth = page.getPageWidthPoint()
|
||||
val pageHeight = page.getPageHeightPoint()
|
||||
if (pageWidth <= 0 || pageHeight <= 0) {
|
||||
Timber.e("Invalid page size for page $pageIndex: ${pageWidth}x${pageHeight}")
|
||||
return@withContext null
|
||||
}
|
||||
|
||||
val aspectRatio =
|
||||
page.getPageWidthPoint().toFloat() / page.getPageHeightPoint().toFloat()
|
||||
pageWidth.toFloat() / pageHeight.toFloat()
|
||||
if (aspectRatio.isNaN() || aspectRatio <= 0) {
|
||||
Timber.e("Invalid aspect ratio for page $pageIndex")
|
||||
return@withContext null
|
||||
}
|
||||
val bitmapHeight = (bitmapWidth / aspectRatio).toInt()
|
||||
|
||||
var bitmapWidth = PDF_PREVIEW_MAX_WIDTH_PX
|
||||
var bitmapHeight = (bitmapWidth / aspectRatio).roundToInt()
|
||||
|
||||
if (bitmapHeight > PDF_PREVIEW_MAX_HEIGHT_PX) {
|
||||
bitmapHeight = PDF_PREVIEW_MAX_HEIGHT_PX
|
||||
bitmapWidth = (bitmapHeight * aspectRatio).roundToInt().coerceAtLeast(1)
|
||||
}
|
||||
|
||||
val requestedBytes = bitmapWidth.toLong() * bitmapHeight.toLong() * 4L
|
||||
if (requestedBytes > PDF_PREVIEW_MAX_BYTES) {
|
||||
val scale = sqrt(PDF_PREVIEW_MAX_BYTES.toDouble() / requestedBytes.toDouble())
|
||||
bitmapWidth = (bitmapWidth * scale).roundToInt().coerceAtLeast(1)
|
||||
bitmapHeight = (bitmapHeight * scale).roundToInt().coerceAtLeast(1)
|
||||
}
|
||||
|
||||
if (bitmapHeight <= 0) {
|
||||
Timber.e("Invalid calculated bitmap height for page $pageIndex")
|
||||
|
|
@ -191,4 +217,4 @@ internal suspend fun renderPageToBitmap(doc: ReaderDocument, pageIndex: Int): Bi
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -76,6 +76,7 @@ import kotlinx.coroutines.withContext
|
|||
import org.json.JSONArray
|
||||
import timber.log.Timber
|
||||
import androidx.core.graphics.createBitmap
|
||||
import com.aryan.reader.pdf.data.VirtualPage
|
||||
|
||||
private const val MAX_FIXED_RECURSION = 128
|
||||
|
||||
|
|
@ -281,6 +282,7 @@ internal fun PdfTocTreeItem(
|
|||
@Composable
|
||||
internal fun PdfNavigationDrawerContent(
|
||||
pdfDocument: ReaderDocument?,
|
||||
documentKey: String,
|
||||
flatTableOfContents: List<TocEntry>,
|
||||
bookmarks: Set<PdfBookmark>,
|
||||
userHighlights: List<PdfUserHighlight>,
|
||||
|
|
@ -859,13 +861,16 @@ internal fun PdfNavigationDrawerContent(
|
|||
},
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
var thumb by remember { mutableStateOf(PdfThumbnailCache.get(pageIdx)) }
|
||||
val thumbPageId = remember(documentKey, pageIdx) {
|
||||
pdfRenderPageId(documentKey, pageIdx, VirtualPage.PdfPage(pageIdx))
|
||||
}
|
||||
var thumb by remember(thumbPageId) { mutableStateOf(PdfThumbnailCache.get(thumbPageId)) }
|
||||
|
||||
LaunchedEffect(pageIdx, pdfDocument) {
|
||||
LaunchedEffect(thumbPageId, pdfDocument) {
|
||||
if (thumb == null && pdfDocument != null) {
|
||||
withContext(kotlinx.coroutines.Dispatchers.IO) {
|
||||
try {
|
||||
val cached = PdfThumbnailCache.get(pageIdx)
|
||||
val cached = PdfThumbnailCache.get(thumbPageId)
|
||||
if (cached != null) {
|
||||
thumb = cached
|
||||
} else {
|
||||
|
|
@ -878,7 +883,7 @@ internal fun PdfNavigationDrawerContent(
|
|||
val bmp = createBitmap(thumbW, thumbH)
|
||||
bmp.eraseColor(android.graphics.Color.WHITE)
|
||||
p.renderPageBitmap(bmp, 0, 0, thumbW, thumbH, false)
|
||||
PdfThumbnailCache.put(pageIdx, bmp)
|
||||
PdfThumbnailCache.put(thumbPageId, bmp)
|
||||
thumb = bmp
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -123,6 +123,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.isCanvasSafeBitmap
|
||||
import com.aryan.reader.loadReaderTextureBitmap
|
||||
import com.aryan.reader.ml.SpeechBubble
|
||||
import com.aryan.reader.pdf.data.PdfAnnotation
|
||||
|
|
@ -135,7 +136,6 @@ import kotlinx.coroutines.FlowPreview
|
|||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.conflate
|
||||
|
|
@ -171,6 +171,47 @@ enum class InkType {
|
|||
PEN, HIGHLIGHTER, HIGHLIGHTER_ROUND, ERASER, FOUNTAIN_PEN, PENCIL, TEXT
|
||||
}
|
||||
|
||||
internal fun shouldReportPdfPageCamera(
|
||||
isZoomEnabled: Boolean,
|
||||
isVerticalScroll: Boolean,
|
||||
isScrollLocked: Boolean,
|
||||
lockedState: Triple<Float, Float, Float>?,
|
||||
hasAppliedLockedState: Boolean
|
||||
): Boolean {
|
||||
return !isZoomEnabled ||
|
||||
isVerticalScroll ||
|
||||
!isScrollLocked ||
|
||||
lockedState == null ||
|
||||
hasAppliedLockedState
|
||||
}
|
||||
|
||||
internal fun initialPdfPageCamera(
|
||||
isZoomEnabled: Boolean,
|
||||
isVerticalScroll: Boolean,
|
||||
isScrollLocked: Boolean,
|
||||
lockedState: Triple<Float, Float, Float>?
|
||||
): Pair<Float, Offset> {
|
||||
return if (isZoomEnabled && !isVerticalScroll && isScrollLocked && lockedState != null) {
|
||||
lockedState.first to Offset(lockedState.second, lockedState.third)
|
||||
} else {
|
||||
1f to Offset.Zero
|
||||
}
|
||||
}
|
||||
|
||||
internal fun shouldResetPdfZoomAfterBubbleZoomCleanup(
|
||||
isBubbleZoomModeActive: Boolean,
|
||||
scale: Float,
|
||||
isVerticalScroll: Boolean,
|
||||
isZoomEnabled: Boolean,
|
||||
isScrollLocked: Boolean
|
||||
): Boolean {
|
||||
return !isBubbleZoomModeActive &&
|
||||
scale > 1f &&
|
||||
!isVerticalScroll &&
|
||||
isZoomEnabled &&
|
||||
!isScrollLocked
|
||||
}
|
||||
|
||||
data class EmbeddedAnnotation(
|
||||
val index: Int,
|
||||
val subtype: Int,
|
||||
|
|
@ -186,8 +227,25 @@ data class PdfPoint(val x: Float, val y: Float, val timestamp: Long = 0L)
|
|||
|
||||
data class PdfTile(val bitmap: Bitmap, val renderRect: Rect, val tileId: Int, val renderScale: Float = 1f)
|
||||
|
||||
internal fun pdfRenderPageId(documentKey: String, pageIndex: Int, virtualPage: VirtualPage?): String {
|
||||
val sourcePageId = when (virtualPage) {
|
||||
is VirtualPage.BlankPage -> "BLANK_${virtualPage.id}"
|
||||
is VirtualPage.PdfPage -> "PDF_${virtualPage.pdfIndex}"
|
||||
null -> "PDF_$pageIndex"
|
||||
}
|
||||
return "$documentKey:$sourcePageId"
|
||||
}
|
||||
|
||||
private fun Throwable.readablePdfErrorDetail(): String {
|
||||
return localizedMessage?.takeIf { it.isNotBlank() }
|
||||
?: javaClass.simpleName.takeIf { it.isNotBlank() }
|
||||
?: "Unknown error"
|
||||
}
|
||||
|
||||
private const val PDF_TILE_SIZE_DP = 256
|
||||
private const val PDF_MAX_TILE_BITMAP_SIZE_PX = 3072
|
||||
private const val PDF_MAX_DRAW_BITMAP_BYTES = 64L * 1024L * 1024L
|
||||
private const val PDF_MAX_DRAW_BITMAP_DIMENSION_PX = 4096
|
||||
private const val PDF_TILE_SCALE_TOLERANCE = 0.06f
|
||||
private const val PDF_TILE_IDLE_RENDER_DELAY_MS = 90L
|
||||
private const val PDF_PAGINATION_PAN_FLING_MIN_VELOCITY = 600f
|
||||
|
|
@ -255,17 +313,22 @@ private suspend fun renderExpandedBubbleBitmap(
|
|||
}
|
||||
|
||||
document.openPage(pageIndex)?.use { page ->
|
||||
val cropWidth = (bubbleBounds.width() * renderScale).roundToInt().coerceAtLeast(1)
|
||||
val cropHeight = (bubbleBounds.height() * renderScale).roundToInt().coerceAtLeast(1)
|
||||
val safeRenderScale = safePdfBitmapRenderScale(
|
||||
contentWidth = bubbleBounds.width(),
|
||||
contentHeight = bubbleBounds.height(),
|
||||
requestedScale = renderScale
|
||||
)
|
||||
val cropWidth = (bubbleBounds.width() * safeRenderScale).roundToInt().coerceAtLeast(1)
|
||||
val cropHeight = (bubbleBounds.height() * safeRenderScale).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),
|
||||
startX = (-bubbleBounds.left * safeRenderScale).roundToInt(),
|
||||
startY = (-bubbleBounds.top * safeRenderScale).roundToInt(),
|
||||
drawSizeX = (pageWidth * safeRenderScale).roundToInt().coerceAtLeast(cropWidth),
|
||||
drawSizeY = (pageHeight * safeRenderScale).roundToInt().coerceAtLeast(cropHeight),
|
||||
renderAnnot = true
|
||||
)
|
||||
bitmap
|
||||
|
|
@ -277,6 +340,23 @@ private suspend fun renderExpandedBubbleBitmap(
|
|||
}
|
||||
}
|
||||
|
||||
private fun safePdfBitmapRenderScale(
|
||||
contentWidth: Float,
|
||||
contentHeight: Float,
|
||||
requestedScale: Float
|
||||
): Float {
|
||||
if (contentWidth <= 0f || contentHeight <= 0f || requestedScale <= 0f) return 1f
|
||||
|
||||
val requestedWidth = contentWidth * requestedScale
|
||||
val requestedHeight = contentHeight * requestedScale
|
||||
val requestedBytes = requestedWidth.toDouble() * requestedHeight.toDouble() * 4.0
|
||||
val byteScale = sqrt(PDF_MAX_DRAW_BITMAP_BYTES.toDouble() / requestedBytes.coerceAtLeast(1.0))
|
||||
val dimensionScale = PDF_MAX_DRAW_BITMAP_DIMENSION_PX.toDouble() /
|
||||
max(requestedWidth, requestedHeight).toDouble().coerceAtLeast(1.0)
|
||||
val limiter = min(1.0, min(byteScale, dimensionScale)).coerceAtLeast(0.01)
|
||||
return (requestedScale.toDouble() * limiter).coerceAtLeast(0.01).toFloat()
|
||||
}
|
||||
|
||||
object PdfInkGeometry {
|
||||
fun calculateFountainPenPoints(
|
||||
points: List<PdfPoint>, baseWidth: Float, pageWidth: Float, pageHeight: Float
|
||||
|
|
@ -380,16 +460,15 @@ internal object PdfBitmapPool {
|
|||
fun get(size: Int): Bitmap = get(size, size)
|
||||
|
||||
fun recycle(bitmap: Bitmap) {
|
||||
// Overflow bitmaps are left for GC; HWUI may still reference recently drawn bitmaps.
|
||||
if (!bitmap.isRecycled && pool.size < MAX_POOL_SIZE) {
|
||||
pool.offer(bitmap)
|
||||
} else {
|
||||
bitmap.recycle()
|
||||
}
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
while (!pool.isEmpty()) {
|
||||
pool.poll()?.recycle()
|
||||
pool.poll()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -400,20 +479,20 @@ internal object PdfThumbnailCache {
|
|||
|
||||
private data class CacheEntry(val bitmap: Bitmap, val sizeKb: Int)
|
||||
|
||||
private val memoryCache = object : LruCache<Int, CacheEntry>(cacheSize) {
|
||||
override fun sizeOf(key: Int, entry: CacheEntry): Int {
|
||||
private val memoryCache = object : LruCache<String, CacheEntry>(cacheSize) {
|
||||
override fun sizeOf(key: String, entry: CacheEntry): Int {
|
||||
return entry.sizeKb
|
||||
}
|
||||
}
|
||||
|
||||
fun get(pageIndex: Int): Bitmap? {
|
||||
return memoryCache.get(pageIndex)?.bitmap?.takeUnless { it.isRecycled }
|
||||
fun get(pageId: String): Bitmap? {
|
||||
return memoryCache.get(pageId)?.bitmap?.takeUnless { it.isRecycled }
|
||||
}
|
||||
|
||||
fun put(pageIndex: Int, bitmap: Bitmap) {
|
||||
if (get(pageIndex) == null) {
|
||||
fun put(pageId: String, bitmap: Bitmap) {
|
||||
if (get(pageId) == null) {
|
||||
val sizeKb = (bitmap.allocationByteCount / 1024).coerceAtLeast(1)
|
||||
memoryCache.put(pageIndex, CacheEntry(bitmap, sizeKb))
|
||||
memoryCache.put(pageId, CacheEntry(bitmap, sizeKb))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -475,6 +554,7 @@ data class PageSelectionData(
|
|||
@Composable
|
||||
internal fun PdfPageComposable(
|
||||
pdfDocument: StableHolder<ReaderDocument>,
|
||||
documentKey: String,
|
||||
pageIndex: Int,
|
||||
totalPages: Int,
|
||||
modifier: Modifier = Modifier,
|
||||
|
|
@ -507,6 +587,7 @@ internal fun PdfPageComposable(
|
|||
isScrolling: Boolean = false,
|
||||
lazyListState: LazyListState? = null,
|
||||
isVerticalScroll: Boolean = false,
|
||||
showPageNumberOverlay: Boolean = true,
|
||||
visualScaleProvider: () -> Float = { 1f },
|
||||
clearSelectionTrigger: Long = 0L,
|
||||
resetZoomTrigger: Long = 0L,
|
||||
|
|
@ -558,18 +639,13 @@ internal fun PdfPageComposable(
|
|||
onShowPanelPopup: (Bitmap) -> Unit = {}
|
||||
) {
|
||||
val pdfDocumentItem = pdfDocument.item
|
||||
var bitmapState by remember { mutableStateOf(PdfThumbnailCache.get(pageIndex)) }
|
||||
var currentRenderedPageId by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
val targetPageId = remember(virtualPage, pageIndex) {
|
||||
when (virtualPage) {
|
||||
is VirtualPage.BlankPage -> "BLANK_${virtualPage.id}"
|
||||
is VirtualPage.PdfPage -> "PDF_${virtualPage.pdfIndex}"
|
||||
null -> "PDF_$pageIndex"
|
||||
}
|
||||
val targetPageId = remember(documentKey, virtualPage, pageIndex) {
|
||||
pdfRenderPageId(documentKey, pageIndex, virtualPage)
|
||||
}
|
||||
var isLoadingPage by remember { mutableStateOf(true) }
|
||||
var pageErrorMessage by remember { mutableStateOf<String?>(null) }
|
||||
var bitmapState by remember(targetPageId) { mutableStateOf(PdfThumbnailCache.get(targetPageId)) }
|
||||
var currentRenderedPageId by remember(targetPageId) { mutableStateOf<String?>(null) }
|
||||
var isLoadingPage by remember(targetPageId) { mutableStateOf(true) }
|
||||
var pageErrorMessage by remember(targetPageId) { mutableStateOf<String?>(null) }
|
||||
val density = LocalDensity.current
|
||||
val context = LocalContext.current
|
||||
val viewConfiguration = LocalViewConfiguration.current
|
||||
|
|
@ -581,12 +657,30 @@ internal fun PdfPageComposable(
|
|||
var ocrRipplePosition by remember { mutableStateOf<Offset?>(null) }
|
||||
|
||||
var isTransforming by remember { mutableStateOf(false) }
|
||||
var scale by remember { mutableFloatStateOf(1f) }
|
||||
var offset by remember { mutableStateOf(Offset.Zero) }
|
||||
val initialCamera = initialPdfPageCamera(
|
||||
isZoomEnabled = isZoomEnabled,
|
||||
isVerticalScroll = isVerticalScroll,
|
||||
isScrollLocked = isScrollLocked,
|
||||
lockedState = lockedState
|
||||
)
|
||||
var scale by remember(targetPageId) { mutableFloatStateOf(initialCamera.first) }
|
||||
var offset by remember(targetPageId) { mutableStateOf(initialCamera.second) }
|
||||
var paginationPanFlingJob by remember { mutableStateOf<Job?>(null) }
|
||||
var hasAppliedLockedPaginationState by remember(targetPageId) {
|
||||
mutableStateOf(initialCamera.second != Offset.Zero || initialCamera.first != 1f)
|
||||
}
|
||||
val shouldReportCamera = shouldReportPdfPageCamera(
|
||||
isZoomEnabled = isZoomEnabled,
|
||||
isVerticalScroll = isVerticalScroll,
|
||||
isScrollLocked = isScrollLocked,
|
||||
lockedState = lockedState,
|
||||
hasAppliedLockedState = hasAppliedLockedPaginationState
|
||||
)
|
||||
|
||||
LaunchedEffect(scale, offset) {
|
||||
onZoomAndPanChanged?.invoke(scale, offset)
|
||||
LaunchedEffect(scale, offset, shouldReportCamera) {
|
||||
if (shouldReportCamera) {
|
||||
onZoomAndPanChanged?.invoke(scale, offset)
|
||||
}
|
||||
}
|
||||
|
||||
val currentOnSingleTap by rememberUpdatedState(onSingleTap)
|
||||
|
|
@ -609,7 +703,7 @@ internal fun PdfPageComposable(
|
|||
val isPdfPage = virtualPage == null || virtualPage is VirtualPage.PdfPage
|
||||
val pdfPageIndex = (virtualPage as? VirtualPage.PdfPage)?.pdfIndex ?: pageIndex
|
||||
|
||||
var tiles by remember { mutableStateOf<List<PdfTile>>(emptyList()) }
|
||||
var tiles by remember(targetPageId) { mutableStateOf<List<PdfTile>>(emptyList()) }
|
||||
val tileSizeDp = PDF_TILE_SIZE_DP.dp
|
||||
val tileSizePx = with(LocalDensity.current) { tileSizeDp.toPx().toInt() }
|
||||
val latestEffectiveScale by rememberUpdatedState(effectiveScale)
|
||||
|
|
@ -645,15 +739,15 @@ internal fun PdfPageComposable(
|
|||
}
|
||||
}
|
||||
|
||||
val selectionCharRange = remember { mutableStateOf<Pair<Int, Int>?>(null) }
|
||||
var activeDraggingHandle by remember { mutableStateOf<Handle?>(null) }
|
||||
var selectedWordScreenRects by remember { mutableStateOf<List<Rect>>(emptyList()) }
|
||||
val startHandleContentPosition = remember { mutableStateOf<Offset?>(null) }
|
||||
val endHandleContentPosition = remember { mutableStateOf<Offset?>(null) }
|
||||
val selectionCharRange = remember(targetPageId) { mutableStateOf<Pair<Int, Int>?>(null) }
|
||||
var activeDraggingHandle by remember(targetPageId) { mutableStateOf<Handle?>(null) }
|
||||
var selectedWordScreenRects by remember(targetPageId) { mutableStateOf<List<Rect>>(emptyList()) }
|
||||
val startHandleContentPosition = remember(targetPageId) { mutableStateOf<Offset?>(null) }
|
||||
val endHandleContentPosition = remember(targetPageId) { mutableStateOf<Offset?>(null) }
|
||||
|
||||
var actualBitmapWidthPx by remember { mutableIntStateOf(0) }
|
||||
var actualBitmapHeightPx by remember { mutableIntStateOf(0) }
|
||||
var currentPageRotation by remember { mutableIntStateOf(0) }
|
||||
var actualBitmapWidthPx by remember(targetPageId) { mutableIntStateOf(0) }
|
||||
var actualBitmapHeightPx by remember(targetPageId) { mutableIntStateOf(0) }
|
||||
var currentPageRotation by remember(targetPageId) { mutableIntStateOf(0) }
|
||||
|
||||
val needsTilingNow = (effectiveScale > 1f || actualBitmapWidthPx > 3000 || actualBitmapHeightPx > 3000) && (isVerticalScroll || isActivePage)
|
||||
|
||||
|
|
@ -736,7 +830,7 @@ internal fun PdfPageComposable(
|
|||
var magnifierBitmapCenterTarget by remember { mutableStateOf(Offset.Zero) }
|
||||
val magnifierZoomFactor = 2.0f
|
||||
|
||||
var customMenuState by remember { mutableStateOf<CustomPdfMenuState?>(null) }
|
||||
var customMenuState by remember(targetPageId) { mutableStateOf<CustomPdfMenuState?>(null) }
|
||||
|
||||
val inputScale = if (isZoomEnabled && !isVerticalScroll) scale else 1f
|
||||
val inputOffset = if (isZoomEnabled && !isVerticalScroll) offset else Offset.Zero
|
||||
|
|
@ -826,7 +920,15 @@ internal fun PdfPageComposable(
|
|||
expandedBubbleIndex = -1
|
||||
expandedBubbleRender?.bitmap?.takeUnless { it.isRecycled }?.recycle()
|
||||
expandedBubbleRender = null
|
||||
if (!isBubbleZoomModeActive && scale > 1f && !isVerticalScroll && isZoomEnabled) {
|
||||
if (
|
||||
shouldResetPdfZoomAfterBubbleZoomCleanup(
|
||||
isBubbleZoomModeActive = isBubbleZoomModeActive,
|
||||
scale = scale,
|
||||
isVerticalScroll = isVerticalScroll,
|
||||
isZoomEnabled = isZoomEnabled,
|
||||
isScrollLocked = isScrollLocked
|
||||
)
|
||||
) {
|
||||
coroutineScope.launch {
|
||||
Animatable(scale).animateTo(1f, tween(300)) {
|
||||
scale = this.value
|
||||
|
|
@ -881,10 +983,10 @@ internal fun PdfPageComposable(
|
|||
}
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
DisposableEffect(targetPageId) {
|
||||
onDispose {
|
||||
val currentBitmap = bitmapState
|
||||
val cachedBitmap = PdfThumbnailCache.get(pageIndex)
|
||||
val cachedBitmap = PdfThumbnailCache.get(targetPageId)
|
||||
if (currentBitmap != null && !currentBitmap.isRecycled && currentBitmap !== cachedBitmap) {
|
||||
currentBitmap.recycle()
|
||||
}
|
||||
|
|
@ -895,16 +997,16 @@ internal fun PdfPageComposable(
|
|||
@Suppress("DEPRECATION") val clipboardManager = LocalClipboardManager.current
|
||||
|
||||
// OCR
|
||||
var ocrVisionTextForSelection by remember { mutableStateOf<OcrResult?>(null) }
|
||||
var ocrVisionTextForSelection by remember(targetPageId) { mutableStateOf<OcrResult?>(null) }
|
||||
var isPerformingOcrForSelection by remember { mutableStateOf(false) }
|
||||
var selectionMethodUsed by remember { mutableStateOf(PdfSelectionMethod.PDFIUM) }
|
||||
var ocrSelectionSymbolIndices by remember { mutableStateOf<Pair<Int, Int>?>(null) }
|
||||
var allOcrSymbolsForSelection by remember { mutableStateOf<List<OcrSymbolInfo>>(emptyList()) }
|
||||
var ocrSelectionSymbolIndices by remember(targetPageId) { mutableStateOf<Pair<Int, Int>?>(null) }
|
||||
var allOcrSymbolsForSelection by remember(targetPageId) { mutableStateOf<List<OcrSymbolInfo>>(emptyList()) }
|
||||
|
||||
var highlightedTextScreenRects by remember { mutableStateOf<List<Rect>>(emptyList()) }
|
||||
var highlightedTextScreenRects by remember(targetPageId) { mutableStateOf<List<Rect>>(emptyList()) }
|
||||
val ttsHighlightColor = Color(0xFFFFECB3).copy(alpha = 0.4f)
|
||||
|
||||
var allTextPageHighlightRects by remember { mutableStateOf<List<Rect>>(emptyList()) }
|
||||
var allTextPageHighlightRects by remember(targetPageId) { mutableStateOf<List<Rect>>(emptyList()) }
|
||||
|
||||
var accumulatedKeyboardOffset by remember { mutableFloatStateOf(0f) }
|
||||
|
||||
|
|
@ -937,7 +1039,7 @@ internal fun PdfPageComposable(
|
|||
val mergedSearchHighlightRects =
|
||||
remember(searchHighlightRects) { mergeRectsIntoLines(searchHighlightRects) }
|
||||
|
||||
var pageLinks by remember { mutableStateOf<List<PageLink>>(emptyList()) }
|
||||
var pageLinks by remember(targetPageId) { mutableStateOf<List<PageLink>>(emptyList()) }
|
||||
val linkHighlightColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f)
|
||||
val linkVerticalPaddingPx = remember(density) { with(density) { 10.dp.toPx().toInt() } }
|
||||
|
||||
|
|
@ -1091,9 +1193,9 @@ internal fun PdfPageComposable(
|
|||
onHighlightLoading(false)
|
||||
}
|
||||
|
||||
@Suppress("VariableNeverRead") var embeddedAnnotations by remember { mutableStateOf<List<EmbeddedAnnotation>>(emptyList()) }
|
||||
var standardAnnotScreenRects by remember { mutableStateOf<List<Pair<EmbeddedAnnotation, Rect>>>(emptyList()) }
|
||||
var imageScreenRects by remember { mutableStateOf<List<android.graphics.Rect>>(emptyList()) }
|
||||
@Suppress("VariableNeverRead") var embeddedAnnotations by remember(targetPageId) { mutableStateOf<List<EmbeddedAnnotation>>(emptyList()) }
|
||||
var standardAnnotScreenRects by remember(targetPageId) { mutableStateOf<List<Pair<EmbeddedAnnotation, Rect>>>(emptyList()) }
|
||||
var imageScreenRects by remember(targetPageId) { mutableStateOf<List<android.graphics.Rect>>(emptyList()) }
|
||||
|
||||
LaunchedEffect(pageIndex, pdfDocumentItem, actualBitmapWidthPx, actualBitmapHeightPx, virtualPage) {
|
||||
if (!isPdfPage || actualBitmapWidthPx == 0 || actualBitmapHeightPx == 0) {
|
||||
|
|
@ -1216,84 +1318,80 @@ internal fun PdfPageComposable(
|
|||
val count = PdfiumEngineProvider.bridge.getAnnotCount(pagePtr)
|
||||
Timber.tag("PdfCommentDebug").d("Page $pageIndex: Total Annotations found = $count")
|
||||
if (count > 0) {
|
||||
val count = PdfiumEngineProvider.bridge.getAnnotCount(pagePtr)
|
||||
Timber.tag("PdfCommentDebug").d("Page $pageIndex: Total Annotations found = $count")
|
||||
if (count > 0) {
|
||||
val allAnnots = (0 until count).mapNotNull { i ->
|
||||
val subtype = PdfiumEngineProvider.bridge.getAnnotSubtype(pagePtr, i)
|
||||
if (subtype == annotLink) return@mapNotNull null
|
||||
val allAnnots = (0 until count).mapNotNull { i ->
|
||||
val subtype = PdfiumEngineProvider.bridge.getAnnotSubtype(pagePtr, i)
|
||||
if (subtype == annotLink) return@mapNotNull null
|
||||
|
||||
var contents = PdfiumEngineProvider.bridge.getAnnotString(pagePtr, i, "Contents")
|
||||
if (contents.isNullOrBlank()) {
|
||||
contents = PdfiumEngineProvider.bridge.getAnnotString(pagePtr, i, "RC")
|
||||
}
|
||||
|
||||
val name = PdfiumEngineProvider.bridge.getAnnotString(pagePtr, i, "NM")
|
||||
val irt = PdfiumEngineProvider.bridge.getAnnotString(pagePtr, i, "IRT")
|
||||
val author = PdfiumEngineProvider.bridge.getAnnotString(pagePtr, i, "T")
|
||||
|
||||
val pdfRectArray = PdfiumEngineProvider.bridge.getAnnotRect(pagePtr, i)
|
||||
val pdfRectF = if (pdfRectArray != null) {
|
||||
android.graphics.RectF(
|
||||
min(pdfRectArray[0], pdfRectArray[2]),
|
||||
max(pdfRectArray[1], pdfRectArray[3]),
|
||||
max(pdfRectArray[0], pdfRectArray[2]),
|
||||
min(pdfRectArray[1], pdfRectArray[3])
|
||||
)
|
||||
} else android.graphics.RectF()
|
||||
|
||||
EmbeddedAnnotation(i, subtype, pdfRectF, contents, author, name, irt)
|
||||
var contents = PdfiumEngineProvider.bridge.getAnnotString(pagePtr, i, "Contents")
|
||||
if (contents.isNullOrBlank()) {
|
||||
contents = PdfiumEngineProvider.bridge.getAnnotString(pagePtr, i, "RC")
|
||||
}
|
||||
|
||||
val annotMap = allAnnots.associateBy { it.name }
|
||||
val orphans = mutableListOf<EmbeddedAnnotation>()
|
||||
val name = PdfiumEngineProvider.bridge.getAnnotString(pagePtr, i, "NM")
|
||||
val irt = PdfiumEngineProvider.bridge.getAnnotString(pagePtr, i, "IRT")
|
||||
val author = PdfiumEngineProvider.bridge.getAnnotString(pagePtr, i, "T")
|
||||
|
||||
allAnnots.forEach { annot ->
|
||||
if (!annot.inReplyTo.isNullOrBlank() && annotMap.containsKey(annot.inReplyTo)) {
|
||||
Timber.tag("PdfCommentDebug").i("Linking: ${annot.name} is a reply to ${annot.inReplyTo}")
|
||||
annotMap[annot.inReplyTo]?.replies?.add(annot)
|
||||
} else {
|
||||
orphans.add(annot)
|
||||
}
|
||||
}
|
||||
|
||||
Timber.tag("PdfCommentDebug").d("After ID linking: Orphans count = ${orphans.size}")
|
||||
|
||||
val groupedRoots = mutableListOf<MutableList<EmbeddedAnnotation>>()
|
||||
orphans.forEach { annot ->
|
||||
val match = groupedRoots.find { group ->
|
||||
val root = group.first()
|
||||
val inflatedRoot = android.graphics.RectF(root.rect).apply { inset(-10f, -10f) }
|
||||
android.graphics.RectF.intersects(inflatedRoot, annot.rect)
|
||||
}
|
||||
if (match != null) {
|
||||
Timber.tag("PdfCommentDebug").w("Geometric grouping triggered for ${annot.name} with ${match.first().name}. This might flatten nested replies!")
|
||||
match.add(annot)
|
||||
} else {
|
||||
groupedRoots.add(mutableListOf(annot))
|
||||
}
|
||||
}
|
||||
|
||||
val rootsWithReplies = groupedRoots.map { group ->
|
||||
val root = group.first()
|
||||
if (group.size > 1) {
|
||||
root.replies.addAll(group.drop(1))
|
||||
}
|
||||
root
|
||||
}
|
||||
|
||||
finalDisplayList = rootsWithReplies.filter {
|
||||
!it.contents.isNullOrBlank() || it.replies.any { r -> !r.contents.isNullOrBlank() }
|
||||
}
|
||||
|
||||
mappedAnnots = finalDisplayList.map { annot ->
|
||||
val screenRect = pageWrapper.mapRectToDevice(
|
||||
0, 0, actualBitmapWidthPx, actualBitmapHeightPx,
|
||||
currentPageRotation, annot.rect
|
||||
val pdfRectArray = PdfiumEngineProvider.bridge.getAnnotRect(pagePtr, i)
|
||||
val pdfRectF = if (pdfRectArray != null) {
|
||||
android.graphics.RectF(
|
||||
min(pdfRectArray[0], pdfRectArray[2]),
|
||||
max(pdfRectArray[1], pdfRectArray[3]),
|
||||
max(pdfRectArray[0], pdfRectArray[2]),
|
||||
min(pdfRectArray[1], pdfRectArray[3])
|
||||
)
|
||||
annot to screenRect
|
||||
} else android.graphics.RectF()
|
||||
|
||||
EmbeddedAnnotation(i, subtype, pdfRectF, contents, author, name, irt)
|
||||
}
|
||||
|
||||
val annotMap = allAnnots.associateBy { it.name }
|
||||
val orphans = mutableListOf<EmbeddedAnnotation>()
|
||||
|
||||
allAnnots.forEach { annot ->
|
||||
if (!annot.inReplyTo.isNullOrBlank() && annotMap.containsKey(annot.inReplyTo)) {
|
||||
Timber.tag("PdfCommentDebug").i("Linking: ${annot.name} is a reply to ${annot.inReplyTo}")
|
||||
annotMap[annot.inReplyTo]?.replies?.add(annot)
|
||||
} else {
|
||||
orphans.add(annot)
|
||||
}
|
||||
}
|
||||
|
||||
Timber.tag("PdfCommentDebug").d("After ID linking: Orphans count = ${orphans.size}")
|
||||
|
||||
val groupedRoots = mutableListOf<MutableList<EmbeddedAnnotation>>()
|
||||
orphans.forEach { annot ->
|
||||
val match = groupedRoots.find { group ->
|
||||
val root = group.first()
|
||||
val inflatedRoot = android.graphics.RectF(root.rect).apply { inset(-10f, -10f) }
|
||||
android.graphics.RectF.intersects(inflatedRoot, annot.rect)
|
||||
}
|
||||
if (match != null) {
|
||||
Timber.tag("PdfCommentDebug").w("Geometric grouping triggered for ${annot.name} with ${match.first().name}. This might flatten nested replies!")
|
||||
match.add(annot)
|
||||
} else {
|
||||
groupedRoots.add(mutableListOf(annot))
|
||||
}
|
||||
}
|
||||
|
||||
val rootsWithReplies = groupedRoots.map { group ->
|
||||
val root = group.first()
|
||||
if (group.size > 1) {
|
||||
root.replies.addAll(group.drop(1))
|
||||
}
|
||||
root
|
||||
}
|
||||
|
||||
finalDisplayList = rootsWithReplies.filter {
|
||||
!it.contents.isNullOrBlank() || it.replies.any { r -> !r.contents.isNullOrBlank() }
|
||||
}
|
||||
|
||||
mappedAnnots = finalDisplayList.map { annot ->
|
||||
val screenRect = pageWrapper.mapRectToDevice(
|
||||
0, 0, actualBitmapWidthPx, actualBitmapHeightPx,
|
||||
currentPageRotation, annot.rect
|
||||
)
|
||||
annot to screenRect
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Timber.tag("PdfCommentDebug").w("Page $pageIndex: Failed to resolve native page pointer.")
|
||||
|
|
@ -1323,7 +1421,7 @@ internal fun PdfPageComposable(
|
|||
Timber.d("Page $pageIndex hidden. Releasing bitmap to save memory.")
|
||||
val old = bitmapState
|
||||
bitmapState = null
|
||||
@Suppress("ControlFlowWithEmptyBody") if (old != null && old !== PdfThumbnailCache.get(pageIndex)) { }
|
||||
@Suppress("ControlFlowWithEmptyBody") if (old != null && old !== PdfThumbnailCache.get(targetPageId)) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1584,10 +1682,10 @@ internal fun PdfPageComposable(
|
|||
}
|
||||
}
|
||||
|
||||
var searchFocusedRects by remember { mutableStateOf<List<Rect>>(emptyList()) }
|
||||
var searchAllRects by remember { mutableStateOf<List<Rect>>(emptyList()) }
|
||||
var searchFocusedRects by remember(targetPageId) { mutableStateOf<List<Rect>>(emptyList()) }
|
||||
var searchAllRects by remember(targetPageId) { mutableStateOf<List<Rect>>(emptyList()) }
|
||||
|
||||
var keyboardAdjustmentOriginalOffset by remember { mutableStateOf<Float?>(null) }
|
||||
var keyboardAdjustmentOriginalOffset by remember(targetPageId) { mutableStateOf<Float?>(null) }
|
||||
|
||||
val mergedSearchFocusedRects = remember(searchFocusedRects) { searchFocusedRects }
|
||||
val mergedSearchAllRects = remember(searchAllRects) { searchAllRects }
|
||||
|
|
@ -1932,10 +2030,6 @@ internal fun PdfPageComposable(
|
|||
}
|
||||
}
|
||||
|
||||
val errorSelection = stringResource(R.string.error_selection)
|
||||
val errorOcrSelection = stringResource(R.string.error_ocr_selection)
|
||||
val errorProcessingPage = stringResource(R.string.error_processing_page)
|
||||
|
||||
BoxWithConstraints(
|
||||
modifier = modifier
|
||||
.onGloballyPositioned { layoutCoordinates = it }
|
||||
|
|
@ -2706,7 +2800,10 @@ internal fun PdfPageComposable(
|
|||
Timber.e(
|
||||
e, "Long press: Error during OCR text selection"
|
||||
)
|
||||
pageErrorMessage = errorOcrSelection
|
||||
pageErrorMessage = context.getString(
|
||||
R.string.error_ocr_selection,
|
||||
e.readablePdfErrorDetail()
|
||||
)
|
||||
} finally {
|
||||
isPerformingOcrForSelection = false
|
||||
ocrRipplePosition = null
|
||||
|
|
@ -2727,7 +2824,10 @@ internal fun PdfPageComposable(
|
|||
e,
|
||||
"Error during long press text selection on page $pageIndex"
|
||||
)
|
||||
pageErrorMessage = errorSelection
|
||||
pageErrorMessage = context.getString(
|
||||
R.string.error_selection,
|
||||
e.readablePdfErrorDetail()
|
||||
)
|
||||
customMenuState = null
|
||||
selectionCharRange.value = null
|
||||
selectedWordScreenRects = emptyList()
|
||||
|
|
@ -2774,7 +2874,8 @@ internal fun PdfPageComposable(
|
|||
selectedTool,
|
||||
isStylusOnlyMode,
|
||||
userHighlightScreenRects,
|
||||
bubbleTapSlopPx
|
||||
bubbleTapSlopPx,
|
||||
isScrollLocked
|
||||
) {
|
||||
val isTapDetectionAllowed = !isEditMode ||
|
||||
selectedTool == InkType.TEXT ||
|
||||
|
|
@ -3040,7 +3141,7 @@ internal fun PdfPageComposable(
|
|||
onScaleChanged(scale)
|
||||
}
|
||||
}
|
||||
} else if (isVerticalScroll && currentOnDoubleTap != null) {
|
||||
} else if (isVerticalScroll && !isScrollLocked && currentOnDoubleTap != null) {
|
||||
currentOnDoubleTap!!(tapOffset)
|
||||
}
|
||||
})
|
||||
|
|
@ -3276,7 +3377,7 @@ internal fun PdfPageComposable(
|
|||
val startOffset = offset
|
||||
paginationPanFlingJob = coroutineScope.launch {
|
||||
try {
|
||||
coroutineScope {
|
||||
kotlinx.coroutines.coroutineScope {
|
||||
launch {
|
||||
if (flingX != 0f) {
|
||||
Animatable(startOffset.x).animateDecay(flingX, decay) {
|
||||
|
|
@ -3543,15 +3644,37 @@ internal fun PdfPageComposable(
|
|||
}
|
||||
}
|
||||
|
||||
var previousLockedViewportSize by remember { mutableStateOf<Pair<Dp, Dp>?>(null) }
|
||||
|
||||
LaunchedEffect(
|
||||
pageIndex, this@BoxWithConstraints.maxWidth, this@BoxWithConstraints.maxHeight,
|
||||
isScrollLocked, lockedState
|
||||
) {
|
||||
if (isScrollLocked && !isVerticalScroll && lockedState != null) {
|
||||
scale = lockedState.first
|
||||
offset = Offset(lockedState.second, lockedState.third)
|
||||
val currentViewportSize = this@BoxWithConstraints.maxWidth to this@BoxWithConstraints.maxHeight
|
||||
val previousViewportSize = previousLockedViewportSize
|
||||
val orientationChanged = previousViewportSize != null &&
|
||||
(previousViewportSize.first > previousViewportSize.second) !=
|
||||
(currentViewportSize.first > currentViewportSize.second)
|
||||
previousLockedViewportSize = currentViewportSize
|
||||
|
||||
if (isScrollLocked && !isVerticalScroll) {
|
||||
if (orientationChanged) {
|
||||
scale = 1f
|
||||
offset = Offset.Zero
|
||||
hasAppliedLockedPaginationState = true
|
||||
Timber.tag("PdfLockDiagnostic").i(
|
||||
"Orientation changed while locked; reset paginated zoom to fit on page $pageIndex"
|
||||
)
|
||||
} else if (lockedState != null) {
|
||||
scale = lockedState.first
|
||||
offset = Offset(lockedState.second, lockedState.third)
|
||||
hasAppliedLockedPaginationState = true
|
||||
} else {
|
||||
hasAppliedLockedPaginationState = true
|
||||
}
|
||||
onScaleChanged(scale)
|
||||
} else if (!isScrollLocked && !isVerticalScroll) {
|
||||
hasAppliedLockedPaginationState = false
|
||||
scale = 1f
|
||||
offset = Offset.Zero
|
||||
onScaleChanged(1f)
|
||||
|
|
@ -3699,10 +3822,12 @@ internal fun PdfPageComposable(
|
|||
currentContainerMaxHeight,
|
||||
density,
|
||||
virtualPage,
|
||||
targetPageId,
|
||||
isVisible,
|
||||
currentRenderedPageId
|
||||
) {
|
||||
if (!isVisible && !isVerticalScroll) return@LaunchedEffect
|
||||
pageErrorMessage = null
|
||||
|
||||
val viewContainerWidthPx = with(density) { currentContainerMaxWidth.toPx().toInt() }
|
||||
val viewContainerHeightPx =
|
||||
|
|
@ -3721,12 +3846,18 @@ internal fun PdfPageComposable(
|
|||
1f / 1.414f
|
||||
}
|
||||
|
||||
var scaledWidth = viewContainerWidthPx
|
||||
var scaledHeight = (scaledWidth / pageAspect).toInt()
|
||||
val (scaledWidth, scaledHeight) = if (isVerticalScroll) {
|
||||
viewContainerWidthPx to viewContainerHeightPx
|
||||
} else {
|
||||
var fittedWidth = viewContainerWidthPx
|
||||
var fittedHeight = (fittedWidth / pageAspect).toInt()
|
||||
|
||||
if (scaledHeight > viewContainerHeightPx) {
|
||||
scaledHeight = viewContainerHeightPx
|
||||
scaledWidth = (scaledHeight * pageAspect).toInt()
|
||||
if (fittedHeight > viewContainerHeightPx) {
|
||||
fittedHeight = viewContainerHeightPx
|
||||
fittedWidth = (fittedHeight * pageAspect).toInt()
|
||||
}
|
||||
|
||||
fittedWidth to fittedHeight
|
||||
}
|
||||
|
||||
if (scaledWidth == actualBitmapWidthPx &&
|
||||
|
|
@ -3760,7 +3891,7 @@ internal fun PdfPageComposable(
|
|||
|
||||
val old = bitmapState
|
||||
if (old != null && old !== finalBitmap) {
|
||||
if (old !== PdfThumbnailCache.get(pageIndex)) {
|
||||
if (old !== PdfThumbnailCache.get(targetPageId)) {
|
||||
old.recycle()
|
||||
}
|
||||
}
|
||||
|
|
@ -3771,7 +3902,7 @@ internal fun PdfPageComposable(
|
|||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
coroutineScope.launch {
|
||||
kotlinx.coroutines.coroutineScope {
|
||||
var localBitmap: Bitmap? = null
|
||||
try {
|
||||
val renderResult = withContext(Dispatchers.IO) {
|
||||
|
|
@ -3783,62 +3914,68 @@ internal fun PdfPageComposable(
|
|||
return@withContext null
|
||||
}
|
||||
val page = pdfDocumentItem.openPage(pdfPageIndex) ?: return@withContext null
|
||||
val rotation = page.getPageRotation()
|
||||
val screenDpi = (density.density * 160).roundToInt()
|
||||
val originalWidthPdfUnits = page.getPageWidthPoint()
|
||||
val originalHeightPdfUnits = page.getPageHeightPoint()
|
||||
try {
|
||||
val rotation = page.getPageRotation()
|
||||
val originalWidthPdfUnits = page.getPageWidthPoint()
|
||||
val originalHeightPdfUnits = page.getPageHeightPoint()
|
||||
|
||||
if (originalWidthPdfUnits <= 0 || originalHeightPdfUnits <= 0) {
|
||||
if (originalWidthPdfUnits <= 0 || originalHeightPdfUnits <= 0) {
|
||||
throw Exception("Invalid page dimensions")
|
||||
}
|
||||
|
||||
val aspectRatio =
|
||||
originalWidthPdfUnits.toFloat() / originalHeightPdfUnits.toFloat()
|
||||
val (scaledWidth, scaledHeight) = if (isVerticalScroll) {
|
||||
viewContainerWidthPx to viewContainerHeightPx
|
||||
} else {
|
||||
var fittedWidth = viewContainerWidthPx
|
||||
var fittedHeight = (fittedWidth / aspectRatio).toInt()
|
||||
|
||||
if (fittedHeight > viewContainerHeightPx) {
|
||||
fittedHeight = viewContainerHeightPx
|
||||
fittedWidth = (fittedHeight * aspectRatio).toInt()
|
||||
}
|
||||
|
||||
fittedWidth to fittedHeight
|
||||
}
|
||||
|
||||
if (scaledWidth == actualBitmapWidthPx &&
|
||||
scaledHeight == actualBitmapHeightPx &&
|
||||
bitmapState != null &&
|
||||
currentRenderedPageId == targetPageId
|
||||
) {
|
||||
return@withContext null
|
||||
}
|
||||
|
||||
val MAX_BASE_DIMEN = 3000
|
||||
|
||||
val baseRenderScale = 1.5f
|
||||
|
||||
var baseW = (scaledWidth * baseRenderScale).toInt()
|
||||
var baseH = (scaledHeight * baseRenderScale).toInt()
|
||||
|
||||
if (baseW > MAX_BASE_DIMEN || baseH > MAX_BASE_DIMEN) {
|
||||
val downScale = MAX_BASE_DIMEN.toFloat() / maxOf(baseW, baseH)
|
||||
baseW = (baseW * downScale).toInt().coerceAtLeast(1)
|
||||
baseH = (baseH * downScale).toInt().coerceAtLeast(1)
|
||||
}
|
||||
|
||||
Timber.d(
|
||||
"Rendering page $pageIndex at ${baseW}x${baseH} (logical: ${scaledWidth}x${scaledHeight})"
|
||||
)
|
||||
val newBitmap = createBitmap(baseW, baseH)
|
||||
localBitmap = newBitmap
|
||||
page.renderPageBitmap(
|
||||
newBitmap,
|
||||
0, 0,
|
||||
baseW, baseH,
|
||||
true
|
||||
)
|
||||
|
||||
Triple(newBitmap, rotation, Pair(scaledWidth, scaledHeight))
|
||||
} finally {
|
||||
page.close()
|
||||
throw Exception("Invalid page dimensions")
|
||||
}
|
||||
|
||||
val aspectRatio =
|
||||
originalWidthPdfUnits.toFloat() / originalHeightPdfUnits.toFloat()
|
||||
var scaledWidth = viewContainerWidthPx
|
||||
var scaledHeight = (scaledWidth / aspectRatio).toInt()
|
||||
|
||||
if (scaledHeight > viewContainerHeightPx) {
|
||||
scaledHeight = viewContainerHeightPx
|
||||
scaledWidth = (scaledHeight * aspectRatio).toInt()
|
||||
}
|
||||
|
||||
if (scaledWidth == actualBitmapWidthPx &&
|
||||
scaledHeight == actualBitmapHeightPx &&
|
||||
bitmapState != null &&
|
||||
currentRenderedPageId == targetPageId
|
||||
) {
|
||||
page.close()
|
||||
return@withContext null
|
||||
}
|
||||
|
||||
val MAX_BASE_DIMEN = 3000
|
||||
|
||||
val baseRenderScale = 1.5f
|
||||
|
||||
var baseW = (scaledWidth * baseRenderScale).toInt()
|
||||
var baseH = (scaledHeight * baseRenderScale).toInt()
|
||||
|
||||
if (baseW > MAX_BASE_DIMEN || baseH > MAX_BASE_DIMEN) {
|
||||
val downScale = MAX_BASE_DIMEN.toFloat() / maxOf(baseW, baseH)
|
||||
baseW = (baseW * downScale).toInt().coerceAtLeast(1)
|
||||
baseH = (baseH * downScale).toInt().coerceAtLeast(1)
|
||||
}
|
||||
|
||||
Timber.d(
|
||||
"Rendering page $pageIndex at ${baseW}x${baseH} (logical: ${scaledWidth}x${scaledHeight})"
|
||||
)
|
||||
val newBitmap = createBitmap(baseW, baseH)
|
||||
localBitmap = newBitmap
|
||||
page.renderPageBitmap(
|
||||
newBitmap,
|
||||
0, 0,
|
||||
baseW, baseH,
|
||||
true
|
||||
)
|
||||
page.close()
|
||||
|
||||
Triple(newBitmap, rotation, Pair(scaledWidth, scaledHeight))
|
||||
}
|
||||
|
||||
if (renderResult != null) {
|
||||
|
|
@ -3856,7 +3993,7 @@ internal fun PdfPageComposable(
|
|||
|
||||
withContext(Dispatchers.IO) {
|
||||
if (old != null && old !== newBitmap && !old.isRecycled) {
|
||||
val cached = PdfThumbnailCache.get(pageIndex)
|
||||
val cached = PdfThumbnailCache.get(targetPageId)
|
||||
if (old !== cached) {
|
||||
old.recycle()
|
||||
}
|
||||
|
|
@ -3866,14 +4003,17 @@ internal fun PdfPageComposable(
|
|||
val thumbHeight = newBitmap.height / 2
|
||||
if (thumbWidth > 0 && thumbHeight > 0) {
|
||||
PdfThumbnailCache.put(
|
||||
pageIndex, newBitmap.scale(thumbWidth, thumbHeight)
|
||||
targetPageId, newBitmap.scale(thumbWidth, thumbHeight)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
pageErrorMessage = errorProcessingPage
|
||||
pageErrorMessage = context.getString(
|
||||
R.string.error_processing_page,
|
||||
e.readablePdfErrorDetail()
|
||||
)
|
||||
} finally {
|
||||
isLoadingPage = false
|
||||
localBitmap?.recycle()
|
||||
|
|
@ -4265,6 +4405,7 @@ internal fun PdfPageComposable(
|
|||
contentToScreenCoordinates = contentToScreenCoordinates,
|
||||
density = density,
|
||||
isVerticalScroll = isVerticalScroll,
|
||||
showPageNumberOverlay = showPageNumberOverlay,
|
||||
isScrolling = isScrolling,
|
||||
isEditMode = isEditMode,
|
||||
selectedTool = selectedTool,
|
||||
|
|
@ -4296,7 +4437,7 @@ internal fun PdfPageComposable(
|
|||
|
||||
else -> {
|
||||
Text(
|
||||
text = stringResource(R.string.error_unable_to_display_page),
|
||||
text = stringResource(R.string.error_unable_to_display_page, pageIndex + 1),
|
||||
modifier = Modifier
|
||||
.padding(16.dp)
|
||||
.align(Alignment.Center)
|
||||
|
|
@ -4355,7 +4496,13 @@ private fun PdfBitmapLayer(
|
|||
Canvas(modifier = Modifier.fillMaxSize().graphicsLayer()) {
|
||||
translate(left = centeringOffsetX, top = centeringOffsetY) {
|
||||
clipRect(left = 0f, top = 0f, right = targetWidth.toFloat(), bottom = targetHeight.toFloat()) {
|
||||
if (bitmapState != null && !bitmapState.isRecycled) {
|
||||
if (
|
||||
bitmapState != null &&
|
||||
bitmapState.isCanvasSafeBitmap(
|
||||
maxBytes = PDF_MAX_DRAW_BITMAP_BYTES,
|
||||
maxDimension = PDF_MAX_DRAW_BITMAP_DIMENSION_PX
|
||||
)
|
||||
) {
|
||||
val dstW = if (targetWidth > 0) targetWidth else bitmapState.width
|
||||
val dstH = if (targetHeight > 0) targetHeight else bitmapState.height
|
||||
val srcSize = IntSize(bitmapState.width, bitmapState.height)
|
||||
|
|
@ -4400,7 +4547,12 @@ private fun PdfBitmapLayer(
|
|||
val needsTiling = effectiveScale > 1f || targetWidth > 3000 || targetHeight > 3000
|
||||
if (needsTiling) {
|
||||
tiles.forEach { tile ->
|
||||
if (!tile.bitmap.isRecycled) {
|
||||
if (
|
||||
tile.bitmap.isCanvasSafeBitmap(
|
||||
maxBytes = PDF_MAX_DRAW_BITMAP_BYTES,
|
||||
maxDimension = PDF_MAX_DRAW_BITMAP_DIMENSION_PX
|
||||
)
|
||||
) {
|
||||
drawImage(
|
||||
image = tile.bitmap.asImageBitmap(),
|
||||
srcOffset = IntOffset.Zero,
|
||||
|
|
@ -5086,6 +5238,7 @@ private fun PdfPageRenderer(
|
|||
contentToScreenCoordinates: (Offset) -> Offset,
|
||||
density: Density,
|
||||
isVerticalScroll: Boolean,
|
||||
showPageNumberOverlay: Boolean,
|
||||
isScrolling: Boolean,
|
||||
isEditMode: Boolean,
|
||||
selectedTool: InkType,
|
||||
|
|
@ -5282,7 +5435,7 @@ private fun PdfPageRenderer(
|
|||
}
|
||||
|
||||
// Layer 4: Page Number Indicator
|
||||
if (totalPages > 0) {
|
||||
if (showPageNumberOverlay && totalPages > 0) {
|
||||
val pageNumColor = if (staticData.isDarkMode) {
|
||||
Color.White
|
||||
} else {
|
||||
|
|
@ -5439,10 +5592,12 @@ private fun PdfPageRenderer(
|
|||
tiles = if (effectiveScale > 1f) staticData.tiles.item else emptyList(),
|
||||
currentScale = effectiveScale,
|
||||
magnifierCenterOnBitmap = magnifierCenterTarget,
|
||||
contentWidthPx = staticData.targetWidth,
|
||||
contentHeightPx = staticData.targetHeight,
|
||||
magnifierWidth = magnifierWidth,
|
||||
magnifierHeight = magnifierHeight,
|
||||
zoomFactor = effectiveZoomFactor,
|
||||
selectionRectsInBitmapCoords = selectionData.mergedSelectionRects.item,
|
||||
selectionRectsInContentCoords = selectionData.mergedSelectionRects.item,
|
||||
highlightColor = Color(0x6633B5E5),
|
||||
colorFilter = staticData.colorFilter.item,
|
||||
modifier = Modifier
|
||||
|
|
@ -5593,6 +5748,21 @@ private fun PdfPageRenderer(
|
|||
}
|
||||
|
||||
if (animatingBubbleIndex in detectedBubbles.indices && staticData.bitmap.item != null && bubbleExpansionProgress > 0f) {
|
||||
val baseBitmap = staticData.bitmap.item ?: return@Canvas
|
||||
if (
|
||||
!baseBitmap.isCanvasSafeBitmap(
|
||||
maxBytes = PDF_MAX_DRAW_BITMAP_BYTES,
|
||||
maxDimension = PDF_MAX_DRAW_BITMAP_DIMENSION_PX
|
||||
)
|
||||
) {
|
||||
return@Canvas
|
||||
}
|
||||
val safeExpandedBubbleRender = expandedBubbleRender?.takeIf {
|
||||
it.bitmap.isCanvasSafeBitmap(
|
||||
maxBytes = PDF_MAX_DRAW_BITMAP_BYTES,
|
||||
maxDimension = PDF_MAX_DRAW_BITMAP_DIMENSION_PX
|
||||
)
|
||||
}
|
||||
val bubble = detectedBubbles[animatingBubbleIndex]
|
||||
val left = bubble.bounds.left + staticData.centeringOffsetX
|
||||
val top = bubble.bounds.top + staticData.centeringOffsetY
|
||||
|
|
@ -5600,7 +5770,7 @@ private fun PdfPageRenderer(
|
|||
val logicalHeight = bubble.bounds.height()
|
||||
val pivotX = left + logicalWidth / 2f
|
||||
val pivotY = top + logicalHeight / 2f
|
||||
val targetZoomFactor = expandedBubbleRender?.zoomFactor ?: computeDynamicBubbleZoomFactor(
|
||||
val targetZoomFactor = safeExpandedBubbleRender?.zoomFactor ?: computeDynamicBubbleZoomFactor(
|
||||
bubbleBounds = bubble.bounds,
|
||||
viewportWidth = staticData.canvasWidth,
|
||||
viewportHeight = staticData.canvasHeight
|
||||
|
|
@ -5613,8 +5783,8 @@ private fun PdfPageRenderer(
|
|||
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 renderScaleX = baseBitmap.width.toFloat() / staticData.targetWidth.toFloat()
|
||||
val renderScaleY = baseBitmap.height.toFloat() / staticData.targetHeight.toFloat()
|
||||
|
||||
val srcOffset = IntOffset(
|
||||
(bubble.bounds.left * renderScaleX).toInt(),
|
||||
|
|
@ -5651,12 +5821,12 @@ private fun PdfPageRenderer(
|
|||
)
|
||||
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) {
|
||||
image = (safeExpandedBubbleRender?.bitmap ?: baseBitmap).asImageBitmap(),
|
||||
srcOffset = if (safeExpandedBubbleRender != null) IntOffset.Zero else srcOffset,
|
||||
srcSize = if (safeExpandedBubbleRender != null) {
|
||||
IntSize(
|
||||
expandedBubbleRender.bitmap.width,
|
||||
expandedBubbleRender.bitmap.height)
|
||||
safeExpandedBubbleRender.bitmap.width,
|
||||
safeExpandedBubbleRender.bitmap.height)
|
||||
} else {
|
||||
srcSize
|
||||
},
|
||||
|
|
@ -5675,12 +5845,12 @@ private fun PdfPageRenderer(
|
|||
} 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) {
|
||||
image = (safeExpandedBubbleRender?.bitmap ?: baseBitmap).asImageBitmap(),
|
||||
srcOffset = if (safeExpandedBubbleRender != null) IntOffset.Zero else srcOffset,
|
||||
srcSize = if (safeExpandedBubbleRender != null) {
|
||||
IntSize(
|
||||
expandedBubbleRender.bitmap.width,
|
||||
expandedBubbleRender.bitmap.height)
|
||||
safeExpandedBubbleRender.bitmap.width,
|
||||
safeExpandedBubbleRender.bitmap.height)
|
||||
} else {
|
||||
srcSize
|
||||
},
|
||||
|
|
|
|||
|
|
@ -43,7 +43,11 @@ internal const val PDF_HIDDEN_TOOLS_KEY = "pdf_hidden_tools"
|
|||
internal const val PDF_TOOL_ORDER_KEY = "pdf_tool_order"
|
||||
internal const val PDF_BOTTOM_TOOLS_KEY = "pdf_bottom_tools"
|
||||
internal const val PDF_SYSTEM_UI_MODE_KEY = "pdf_system_ui_mode"
|
||||
internal const val PDF_VERTICAL_PAGE_GAP_VISIBLE_KEY = "pdf_vertical_page_gap_visible"
|
||||
internal const val PDF_PAGE_NUMBER_OVERLAY_VISIBLE_KEY = "pdf_page_number_overlay_visible"
|
||||
internal const val PDF_LAYOUT_DEBUG_TAG = "PdfLayoutDebug"
|
||||
private const val PDF_HIDDEN_TOOLS_DEFAULTS_VERSION_KEY = "pdf_hidden_tools_defaults_version"
|
||||
private const val PDF_HIDDEN_TOOLS_DEFAULTS_VERSION = 2
|
||||
|
||||
enum class PdfReaderTool(val title: String, val category: String) {
|
||||
DICTIONARY("External Apps", "Top Bar"),
|
||||
|
|
@ -62,8 +66,9 @@ enum class PdfReaderTool(val title: String, val category: String) {
|
|||
OCR_LANGUAGE("OCR Language", "Overflow Menu"),
|
||||
READING_MODE("Reading Mode", "Overflow Menu"),
|
||||
KEEP_SCREEN_ON("Keep Screen On", "Overflow Menu"),
|
||||
SCREEN_ORIENTATION("Screen Orientation", "Top Bar"),
|
||||
AUTO_SCROLL("Auto Scroll", "Overflow Menu"),
|
||||
TTS_SETTINGS("TTS Voice Settings", "Overflow Menu"),
|
||||
TTS_SETTINGS("TTS Settings", "Overflow Menu"),
|
||||
TTS_REPLACEMENTS("TTS Word Replacements", "Overflow Menu"),
|
||||
BOOKMARK("Bookmark", "Overflow Menu"),
|
||||
PAGE_MANAGEMENT("Page Management", "Overflow Menu"),
|
||||
|
|
@ -91,12 +96,28 @@ val PdfBuiltInThemes = listOf(
|
|||
|
||||
internal fun loadPdfHiddenTools(context: Context): Set<String> {
|
||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||
return prefs.getStringSet(PDF_HIDDEN_TOOLS_KEY, emptySet()) ?: emptySet()
|
||||
val savedHiddenTools = prefs.getStringSet(PDF_HIDDEN_TOOLS_KEY, emptySet()).orEmpty()
|
||||
val defaultsVersion = prefs.getInt(PDF_HIDDEN_TOOLS_DEFAULTS_VERSION_KEY, 0)
|
||||
if (defaultsVersion < PDF_HIDDEN_TOOLS_DEFAULTS_VERSION) {
|
||||
val migratedHiddenTools = savedHiddenTools + setOf(
|
||||
PdfReaderTool.SCREEN_ORIENTATION.name,
|
||||
PdfReaderTool.HIGHLIGHT_ALL.name
|
||||
)
|
||||
prefs.edit {
|
||||
putStringSet(PDF_HIDDEN_TOOLS_KEY, migratedHiddenTools)
|
||||
putInt(PDF_HIDDEN_TOOLS_DEFAULTS_VERSION_KEY, PDF_HIDDEN_TOOLS_DEFAULTS_VERSION)
|
||||
}
|
||||
return migratedHiddenTools
|
||||
}
|
||||
return savedHiddenTools
|
||||
}
|
||||
|
||||
internal fun savePdfHiddenTools(context: Context, hiddenTools: Set<String>) {
|
||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||
prefs.edit { putStringSet(PDF_HIDDEN_TOOLS_KEY, hiddenTools) }
|
||||
prefs.edit {
|
||||
putStringSet(PDF_HIDDEN_TOOLS_KEY, hiddenTools)
|
||||
putInt(PDF_HIDDEN_TOOLS_DEFAULTS_VERSION_KEY, PDF_HIDDEN_TOOLS_DEFAULTS_VERSION)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun loadPdfToolOrder(context: Context): List<PdfReaderTool> {
|
||||
|
|
@ -164,6 +185,26 @@ internal fun loadPdfSystemUiMode(context: Context): SystemUiMode {
|
|||
return SystemUiMode.entries.find { it.id == id } ?: SystemUiMode.SYNC
|
||||
}
|
||||
|
||||
internal fun savePdfVerticalPageGapVisible(context: Context, isVisible: Boolean) {
|
||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||
prefs.edit { putBoolean(PDF_VERTICAL_PAGE_GAP_VISIBLE_KEY, isVisible) }
|
||||
}
|
||||
|
||||
internal fun loadPdfVerticalPageGapVisible(context: Context): Boolean {
|
||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||
return prefs.getBoolean(PDF_VERTICAL_PAGE_GAP_VISIBLE_KEY, true)
|
||||
}
|
||||
|
||||
internal fun savePdfPageNumberOverlayVisible(context: Context, isVisible: Boolean) {
|
||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||
prefs.edit { putBoolean(PDF_PAGE_NUMBER_OVERLAY_VISIBLE_KEY, isVisible) }
|
||||
}
|
||||
|
||||
internal fun loadPdfPageNumberOverlayVisible(context: Context): Boolean {
|
||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||
return prefs.getBoolean(PDF_PAGE_NUMBER_OVERLAY_VISIBLE_KEY, true)
|
||||
}
|
||||
|
||||
internal fun savePdfThemeId(context: Context, themeId: String) {
|
||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||
prefs.edit { putString(PDF_THEME_KEY, themeId) }
|
||||
|
|
|
|||
|
|
@ -41,7 +41,6 @@ import androidx.compose.ui.unit.dp
|
|||
import androidx.paging.LoadState
|
||||
import androidx.paging.compose.LazyPagingItems
|
||||
import androidx.paging.compose.itemContentType
|
||||
import androidx.paging.compose.itemKey
|
||||
import com.aryan.reader.R
|
||||
import com.aryan.reader.SearchResult
|
||||
|
||||
|
|
@ -154,9 +153,10 @@ fun PdfSearchResultsPanel(
|
|||
HorizontalDivider()
|
||||
|
||||
LazyColumn(modifier = Modifier.testTag("SearchResultsList")) {
|
||||
items(count = lazyResults.itemCount, key = lazyResults.itemKey {
|
||||
"${it.locationInSource}_${it.occurrenceIndexInLocation}"
|
||||
}, contentType = lazyResults.itemContentType { "SearchResult" }) { index ->
|
||||
items(
|
||||
count = lazyResults.itemCount,
|
||||
contentType = lazyResults.itemContentType { "SearchResult" }
|
||||
) { index ->
|
||||
val result = lazyResults[index]
|
||||
if (result != null) {
|
||||
ListItem(
|
||||
|
|
|
|||
|
|
@ -36,11 +36,14 @@ import androidx.compose.material.icons.filled.Menu
|
|||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.ScreenRotation
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
|
|
@ -143,7 +146,8 @@ fun PdfCustomizeToolsSheet(
|
|||
PdfReaderTool.DICTIONARY, PdfReaderTool.THEME, PdfReaderTool.LOCK_PANNING,
|
||||
PdfReaderTool.SLIDER, PdfReaderTool.TOC, PdfReaderTool.SEARCH,
|
||||
PdfReaderTool.HIGHLIGHT_ALL, PdfReaderTool.AI_FEATURES,
|
||||
PdfReaderTool.EDIT_MODE, PdfReaderTool.TTS_CONTROLS
|
||||
PdfReaderTool.EDIT_MODE, PdfReaderTool.TTS_CONTROLS,
|
||||
PdfReaderTool.SCREEN_ORIENTATION
|
||||
)
|
||||
|
||||
var localHiddenTools by remember { mutableStateOf(hiddenTools) }
|
||||
|
|
@ -504,6 +508,7 @@ private fun PdfToolPreviewIcon(tool: PdfReaderTool) {
|
|||
PdfReaderTool.AI_FEATURES -> Icon(painterResource(id = R.drawable.ai), contentDescription = tool.title, modifier = Modifier.size(20.dp))
|
||||
PdfReaderTool.EDIT_MODE -> Icon(Icons.Default.Edit, contentDescription = tool.title, modifier = Modifier.size(20.dp))
|
||||
PdfReaderTool.TTS_CONTROLS -> Icon(painterResource(id = R.drawable.text_to_speech), contentDescription = tool.title, modifier = Modifier.size(20.dp))
|
||||
PdfReaderTool.SCREEN_ORIENTATION -> Icon(Icons.Default.ScreenRotation, contentDescription = tool.title, modifier = Modifier.size(20.dp))
|
||||
else -> Icon(Icons.Default.MoreVert, contentDescription = tool.title, modifier = Modifier.size(20.dp))
|
||||
}
|
||||
}
|
||||
|
|
@ -511,7 +516,11 @@ private fun PdfToolPreviewIcon(tool: PdfReaderTool) {
|
|||
@Composable
|
||||
fun PdfVisualOptionsSheet(
|
||||
systemUiMode: SystemUiMode,
|
||||
showVerticalPageGap: Boolean,
|
||||
showPageNumberOverlay: Boolean,
|
||||
onSystemUiModeChange: (SystemUiMode) -> Unit,
|
||||
onShowVerticalPageGapChange: (Boolean) -> Unit,
|
||||
onShowPageNumberOverlayChange: (Boolean) -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
|
|
@ -549,6 +558,54 @@ fun PdfVisualOptionsSheet(
|
|||
onOptionSelected = onSystemUiModeChange,
|
||||
getLabel = { it.title }
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
HorizontalDivider()
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
Text(stringResource(R.string.visual_options_page_layout), style = MaterialTheme.typography.titleMedium)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
PdfVisualOptionSwitchRow(
|
||||
title = stringResource(R.string.visual_options_remove_page_gap),
|
||||
description = stringResource(R.string.visual_options_remove_page_gap_desc),
|
||||
checked = !showVerticalPageGap,
|
||||
onCheckedChange = { removeGap ->
|
||||
onShowVerticalPageGapChange(!removeGap)
|
||||
}
|
||||
)
|
||||
PdfVisualOptionSwitchRow(
|
||||
title = stringResource(R.string.visual_options_hide_page_number_overlay),
|
||||
description = stringResource(R.string.visual_options_hide_page_number_overlay_desc),
|
||||
checked = !showPageNumberOverlay,
|
||||
onCheckedChange = { hideOverlay ->
|
||||
onShowPageNumberOverlayChange(!hideOverlay)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PdfVisualOptionSwitchRow(
|
||||
title: String,
|
||||
description: String,
|
||||
checked: Boolean,
|
||||
onCheckedChange: (Boolean) -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(title, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurface)
|
||||
Text(
|
||||
description,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Switch(checked = checked, onCheckedChange = onCheckedChange)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,6 +47,8 @@ import com.aryan.reader.areReaderAiFeaturesEnabled
|
|||
import com.aryan.reader.epubreader.SystemUiMode
|
||||
import kotlin.collections.isNotEmpty
|
||||
|
||||
internal val PdfTabStripHeight = 44.dp
|
||||
|
||||
private val pdfToolbarTools = setOf(
|
||||
PdfReaderTool.DICTIONARY,
|
||||
PdfReaderTool.THEME,
|
||||
|
|
@ -57,7 +59,8 @@ private val pdfToolbarTools = setOf(
|
|||
PdfReaderTool.HIGHLIGHT_ALL,
|
||||
PdfReaderTool.AI_FEATURES,
|
||||
PdfReaderTool.EDIT_MODE,
|
||||
PdfReaderTool.TTS_CONTROLS
|
||||
PdfReaderTool.TTS_CONTROLS,
|
||||
PdfReaderTool.SCREEN_ORIENTATION
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
|
|
@ -81,6 +84,7 @@ internal fun PdfTopBar(
|
|||
isScrollLocked: Boolean,
|
||||
isEditMode: Boolean,
|
||||
displayMode: DisplayMode,
|
||||
isRightToLeftPagination: Boolean,
|
||||
isKeepScreenOn: Boolean,
|
||||
isTtsSessionActive: Boolean,
|
||||
isBookmarked: Boolean,
|
||||
|
|
@ -101,6 +105,7 @@ internal fun PdfTopBar(
|
|||
onShowCustomizeTools: () -> Unit,
|
||||
onShowOcrLanguage: () -> Unit,
|
||||
onShowVisualOptions: () -> Unit,
|
||||
onShowScreenOrientation: () -> Unit,
|
||||
onShowSlider: () -> Unit,
|
||||
onShowToc: () -> Unit,
|
||||
onSearchClick: () -> Unit,
|
||||
|
|
@ -114,6 +119,7 @@ internal fun PdfTopBar(
|
|||
tapToNavigateEnabled: Boolean,
|
||||
onToggleTapToNavigate: () -> Unit,
|
||||
onChangeDisplayMode: (DisplayMode) -> Unit,
|
||||
onSetRightToLeftPagination: (Boolean) -> Unit,
|
||||
onToggleKeepScreenOn: () -> Unit,
|
||||
onStartAutoScroll: () -> Unit,
|
||||
onShowTtsSettings: () -> Unit,
|
||||
|
|
@ -262,6 +268,13 @@ internal fun PdfTopBar(
|
|||
) {
|
||||
Icon(if (isTtsSessionActive) painterResource(id = R.drawable.close) else painterResource(id = R.drawable.text_to_speech), contentDescription = if (isTtsSessionActive) stringResource(R.string.content_desc_stop_tts) else stringResource(R.string.content_desc_start_tts), tint = if (isTtsSessionActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
PdfReaderTool.SCREEN_ORIENTATION -> TooltipIconButton(
|
||||
text = stringResource(R.string.menu_screen_orientation),
|
||||
description = stringResource(R.string.visual_options_screen_orientation_desc),
|
||||
onClick = onShowScreenOrientation
|
||||
) {
|
||||
Icon(Icons.Default.ScreenRotation, contentDescription = stringResource(R.string.menu_screen_orientation), tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
|
@ -281,11 +294,17 @@ internal fun PdfTopBar(
|
|||
Box {
|
||||
var showMoreMenu by remember { mutableStateOf(false) }
|
||||
var showHiddenToolsExpanded by remember { mutableStateOf(false) }
|
||||
var showReadingModeExpanded by remember { mutableStateOf(false) }
|
||||
var showTtsSettingsExpanded by remember { mutableStateOf(false) }
|
||||
var showFileActionsExpanded by remember { mutableStateOf(false) }
|
||||
TooltipIconButton(
|
||||
text = stringResource(R.string.tooltip_more_options),
|
||||
description = stringResource(R.string.tooltip_more_options_desc),
|
||||
onClick = {
|
||||
showHiddenToolsExpanded = false
|
||||
showReadingModeExpanded = false
|
||||
showTtsSettingsExpanded = false
|
||||
showFileActionsExpanded = false
|
||||
showMoreMenu = true
|
||||
}) {
|
||||
Icon(Icons.Default.MoreVert, contentDescription = stringResource(R.string.tooltip_more_options))
|
||||
|
|
@ -295,6 +314,9 @@ internal fun PdfTopBar(
|
|||
expanded = showMoreMenu,
|
||||
onDismissRequest = {
|
||||
showHiddenToolsExpanded = false
|
||||
showReadingModeExpanded = false
|
||||
showTtsSettingsExpanded = false
|
||||
showFileActionsExpanded = false
|
||||
showMoreMenu = false
|
||||
}
|
||||
) {
|
||||
|
|
@ -340,7 +362,8 @@ internal fun PdfTopBar(
|
|||
onToggleHighlights = onToggleHighlights,
|
||||
onShowAiHub = onShowAiHub,
|
||||
onToggleEditMode = onToggleEditMode,
|
||||
onToggleTts = onToggleTts
|
||||
onToggleTts = onToggleTts,
|
||||
onShowScreenOrientation = onShowScreenOrientation
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -366,18 +389,52 @@ internal fun PdfTopBar(
|
|||
|
||||
if (!hiddenTools.contains(PdfReaderTool.READING_MODE.name)) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_reading_mode_vertical)) },
|
||||
enabled = !isTtsSessionActive,
|
||||
onClick = { onChangeDisplayMode(DisplayMode.VERTICAL_SCROLL); showMoreMenu = false },
|
||||
trailingIcon = { if (displayMode == DisplayMode.VERTICAL_SCROLL) Icon(Icons.Filled.Check, contentDescription = stringResource(R.string.content_desc_selected)) }
|
||||
)
|
||||
HorizontalDivider()
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_reading_mode_paginated)) },
|
||||
enabled = !isTtsSessionActive,
|
||||
onClick = { onChangeDisplayMode(DisplayMode.PAGINATION); showMoreMenu = false },
|
||||
trailingIcon = { if (displayMode == DisplayMode.PAGINATION) Icon(Icons.Filled.Check, contentDescription = stringResource(R.string.content_desc_selected)) }
|
||||
text = { Text(stringResource(R.string.menu_change_reading_mode)) },
|
||||
onClick = { showReadingModeExpanded = !showReadingModeExpanded },
|
||||
trailingIcon = {
|
||||
Icon(
|
||||
Icons.Default.ArrowDropDown,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.rotate(if (showReadingModeExpanded) 180f else 0f)
|
||||
)
|
||||
}
|
||||
)
|
||||
if (showReadingModeExpanded) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_reading_mode_vertical)) },
|
||||
enabled = !isTtsSessionActive,
|
||||
onClick = { onChangeDisplayMode(DisplayMode.VERTICAL_SCROLL); showMoreMenu = false },
|
||||
trailingIcon = { if (displayMode == DisplayMode.VERTICAL_SCROLL) Icon(Icons.Filled.Check, contentDescription = stringResource(R.string.content_desc_selected)) }
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_reading_mode_paginated)) },
|
||||
enabled = !isTtsSessionActive,
|
||||
onClick = {
|
||||
onSetRightToLeftPagination(false)
|
||||
onChangeDisplayMode(DisplayMode.PAGINATION)
|
||||
showMoreMenu = false
|
||||
},
|
||||
trailingIcon = {
|
||||
if (displayMode == DisplayMode.PAGINATION && !isRightToLeftPagination) {
|
||||
Icon(Icons.Filled.Check, contentDescription = stringResource(R.string.content_desc_selected))
|
||||
}
|
||||
}
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_right_to_left_pagination)) },
|
||||
enabled = !isTtsSessionActive,
|
||||
onClick = {
|
||||
onSetRightToLeftPagination(true)
|
||||
onChangeDisplayMode(DisplayMode.PAGINATION)
|
||||
showMoreMenu = false
|
||||
},
|
||||
trailingIcon = {
|
||||
if (displayMode == DisplayMode.PAGINATION && isRightToLeftPagination) {
|
||||
Icon(Icons.Filled.Check, contentDescription = stringResource(R.string.content_desc_selected))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
HorizontalDivider()
|
||||
}
|
||||
|
||||
|
|
@ -419,24 +476,41 @@ internal fun PdfTopBar(
|
|||
HorizontalDivider()
|
||||
}
|
||||
|
||||
if (!hiddenTools.contains(PdfReaderTool.TTS_SETTINGS.name)) {
|
||||
val showTtsVoiceSettings = !hiddenTools.contains(PdfReaderTool.TTS_SETTINGS.name)
|
||||
val showTtsReplacements = !hiddenTools.contains(PdfReaderTool.TTS_REPLACEMENTS.name)
|
||||
if (showTtsVoiceSettings || showTtsReplacements) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_tts_voice_settings)) },
|
||||
enabled = !isTtsSessionActive,
|
||||
onClick = { showMoreMenu = false; onShowTtsSettings() },
|
||||
leadingIcon = { Icon(Icons.Default.GraphicEq, contentDescription = null, modifier = Modifier.size(20.dp)) }
|
||||
text = { Text(stringResource(R.string.menu_tts_settings)) },
|
||||
onClick = { showTtsSettingsExpanded = !showTtsSettingsExpanded },
|
||||
leadingIcon = { Icon(Icons.Default.GraphicEq, contentDescription = null, modifier = Modifier.size(20.dp)) },
|
||||
trailingIcon = {
|
||||
Icon(
|
||||
Icons.Default.ArrowDropDown,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.rotate(if (showTtsSettingsExpanded) 180f else 0f)
|
||||
)
|
||||
}
|
||||
)
|
||||
if (showTtsSettingsExpanded) {
|
||||
if (showTtsVoiceSettings) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_tts_voice_settings)) },
|
||||
enabled = !isTtsSessionActive,
|
||||
onClick = { showMoreMenu = false; onShowTtsSettings() },
|
||||
leadingIcon = { Icon(Icons.Default.GraphicEq, contentDescription = null, modifier = Modifier.size(20.dp)) }
|
||||
)
|
||||
}
|
||||
if (showTtsReplacements) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_tts_word_replacements)) },
|
||||
onClick = { showMoreMenu = false; onShowTtsReplacements() },
|
||||
leadingIcon = { Icon(Icons.Default.GraphicEq, contentDescription = null, modifier = Modifier.size(20.dp)) }
|
||||
)
|
||||
}
|
||||
}
|
||||
HorizontalDivider()
|
||||
}
|
||||
|
||||
if (!hiddenTools.contains(PdfReaderTool.TTS_REPLACEMENTS.name)) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_tts_word_replacements)) },
|
||||
onClick = { showMoreMenu = false; onShowTtsReplacements() },
|
||||
leadingIcon = { Icon(Icons.Default.GraphicEq, contentDescription = null, modifier = Modifier.size(20.dp)) }
|
||||
)
|
||||
}
|
||||
|
||||
if (!hiddenTools.contains(PdfReaderTool.BOOKMARK.name)) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(if (isBookmarked) stringResource(R.string.menu_remove_bookmark) else stringResource(R.string.menu_bookmark_this_page)) },
|
||||
|
|
@ -462,7 +536,7 @@ internal fun PdfTopBar(
|
|||
|
||||
if (!hiddenTools.contains(PdfReaderTool.REFLOW.name)) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(when { isReflowingThisBook -> stringResource(R.string.generating_reflow_progress); hasReflowFile -> stringResource(R.string.action_open_text_view); else -> stringResource(R.string.action_generate_text_view) }) },
|
||||
text = { Text(when { isReflowingThisBook -> stringResource(R.string.generating_text_view); hasReflowFile -> stringResource(R.string.action_open_text_view); else -> stringResource(R.string.action_generate_text_view) }) },
|
||||
enabled = isPdfDocumentLoaded && !isReflowingThisBook,
|
||||
onClick = { showMoreMenu = false; onReflowAction() },
|
||||
leadingIcon = { Icon(painterResource(id = R.drawable.format_size), contentDescription = null, modifier = Modifier.size(20.dp)) }
|
||||
|
|
@ -470,28 +544,45 @@ internal fun PdfTopBar(
|
|||
HorizontalDivider()
|
||||
}
|
||||
|
||||
if (!hiddenTools.contains(PdfReaderTool.SHARE.name)) {
|
||||
val showShareAction = !hiddenTools.contains(PdfReaderTool.SHARE.name)
|
||||
val showSaveCopyAction = effectiveFileType == FileType.PDF && !hiddenTools.contains(PdfReaderTool.SAVE_COPY.name)
|
||||
val showPrintAction = effectiveFileType == FileType.PDF && !hiddenTools.contains(PdfReaderTool.PRINT.name)
|
||||
if (showShareAction || showSaveCopyAction || showPrintAction) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.action_share)) },
|
||||
onClick = { showMoreMenu = false; onShare() },
|
||||
leadingIcon = { Icon(Icons.Default.Share, contentDescription = null) }
|
||||
)
|
||||
}
|
||||
|
||||
if (effectiveFileType == FileType.PDF && !hiddenTools.contains(PdfReaderTool.SAVE_COPY.name)) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.action_save_copy_to_device)) },
|
||||
onClick = { showMoreMenu = false; onSaveCopy() },
|
||||
leadingIcon = { Icon(Icons.Default.Save, contentDescription = null) }
|
||||
)
|
||||
}
|
||||
|
||||
if (effectiveFileType == FileType.PDF && !hiddenTools.contains(PdfReaderTool.PRINT.name)) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.action_print)) },
|
||||
onClick = { showMoreMenu = false; onPrint() },
|
||||
leadingIcon = { Icon(painterResource(id = R.drawable.print), contentDescription = null, modifier = Modifier.size(20.dp)) }
|
||||
text = { Text(stringResource(R.string.menu_share_save_print)) },
|
||||
onClick = { showFileActionsExpanded = !showFileActionsExpanded },
|
||||
leadingIcon = { Icon(Icons.Default.Share, contentDescription = null) },
|
||||
trailingIcon = {
|
||||
Icon(
|
||||
Icons.Default.ArrowDropDown,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.rotate(if (showFileActionsExpanded) 180f else 0f)
|
||||
)
|
||||
}
|
||||
)
|
||||
if (showFileActionsExpanded) {
|
||||
if (showShareAction) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.action_share)) },
|
||||
onClick = { showMoreMenu = false; onShare() },
|
||||
leadingIcon = { Icon(Icons.Default.Share, contentDescription = null) }
|
||||
)
|
||||
}
|
||||
if (showSaveCopyAction) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.action_save_copy_to_device)) },
|
||||
onClick = { showMoreMenu = false; onSaveCopy() },
|
||||
leadingIcon = { Icon(Icons.Default.Save, contentDescription = null) }
|
||||
)
|
||||
}
|
||||
if (showPrintAction) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.action_print)) },
|
||||
onClick = { showMoreMenu = false; onPrint() },
|
||||
leadingIcon = { Icon(painterResource(id = R.drawable.print), contentDescription = null, modifier = Modifier.size(20.dp)) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -499,7 +590,7 @@ internal fun PdfTopBar(
|
|||
}
|
||||
if (isTabsEnabled && openTabs.isNotEmpty() && effectiveFileType == FileType.PDF) {
|
||||
LazyRow(
|
||||
modifier = Modifier.fillMaxWidth().height(44.dp).background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f)),
|
||||
modifier = Modifier.fillMaxWidth().height(PdfTabStripHeight).background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f)),
|
||||
verticalAlignment = Alignment.Bottom
|
||||
) {
|
||||
items(openTabs, key = { it.bookId }) { tab ->
|
||||
|
|
@ -509,7 +600,7 @@ internal fun PdfTopBar(
|
|||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.height(if (isSelected) 44.dp else 36.dp)
|
||||
.height(if (isSelected) PdfTabStripHeight else 36.dp)
|
||||
.clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp))
|
||||
.background(bgColor)
|
||||
.clickable { onTabClick(tab.bookId) }
|
||||
|
|
@ -564,7 +655,8 @@ private fun HiddenPdfToolMenuItem(
|
|||
onToggleHighlights: () -> Unit,
|
||||
onShowAiHub: () -> Unit,
|
||||
onToggleEditMode: () -> Unit,
|
||||
onToggleTts: () -> Unit
|
||||
onToggleTts: () -> Unit,
|
||||
onShowScreenOrientation: () -> Unit
|
||||
) {
|
||||
val enabled = when (tool) {
|
||||
PdfReaderTool.SLIDER,
|
||||
|
|
@ -588,6 +680,7 @@ private fun HiddenPdfToolMenuItem(
|
|||
PdfReaderTool.AI_FEATURES -> onShowAiHub()
|
||||
PdfReaderTool.EDIT_MODE -> onToggleEditMode()
|
||||
PdfReaderTool.TTS_CONTROLS -> onToggleTts()
|
||||
PdfReaderTool.SCREEN_ORIENTATION -> onShowScreenOrientation()
|
||||
else -> Unit
|
||||
}
|
||||
},
|
||||
|
|
@ -606,6 +699,7 @@ private fun HiddenPdfToolMenuItem(
|
|||
PdfReaderTool.AI_FEATURES -> Icon(painterResource(id = R.drawable.ai), contentDescription = null, modifier = Modifier.size(20.dp))
|
||||
PdfReaderTool.EDIT_MODE -> Icon(Icons.Default.Edit, contentDescription = null, modifier = Modifier.size(20.dp), tint = if (isEditMode) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
PdfReaderTool.TTS_CONTROLS -> Icon(if (isTtsSessionActive) painterResource(id = R.drawable.close) else painterResource(id = R.drawable.text_to_speech), contentDescription = null, modifier = Modifier.size(20.dp), tint = if (isTtsSessionActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
PdfReaderTool.SCREEN_ORIENTATION -> Icon(Icons.Default.ScreenRotation, contentDescription = null, modifier = Modifier.size(20.dp))
|
||||
else -> Icon(Icons.Default.MoreVert, contentDescription = null, modifier = Modifier.size(20.dp))
|
||||
}
|
||||
}
|
||||
|
|
@ -766,6 +860,8 @@ fun PdfBottomBar(
|
|||
onShowAiHub: () -> Unit,
|
||||
onToggleEditMode: () -> Unit,
|
||||
onToggleTts: () -> Unit,
|
||||
onShowScreenOrientation: () -> Unit,
|
||||
showBubbleZoom: Boolean,
|
||||
isBubbleZoomModeActive: Boolean,
|
||||
onToggleBubbleZoom: () -> Unit
|
||||
) {
|
||||
|
|
@ -868,11 +964,18 @@ fun PdfBottomBar(
|
|||
) {
|
||||
Icon(if (isTtsSessionActive) painterResource(id = R.drawable.close) else painterResource(id = R.drawable.text_to_speech), contentDescription = if (isTtsSessionActive) stringResource(R.string.content_desc_stop_tts) else stringResource(R.string.content_desc_start_tts), tint = if (isTtsSessionActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
PdfReaderTool.SCREEN_ORIENTATION -> TooltipIconButton(
|
||||
text = stringResource(R.string.menu_screen_orientation),
|
||||
description = stringResource(R.string.visual_options_screen_orientation_desc),
|
||||
onClick = onShowScreenOrientation
|
||||
) {
|
||||
Icon(Icons.Default.ScreenRotation, contentDescription = stringResource(R.string.menu_screen_orientation), tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
if (BuildConfig.FLAVOR != "oss") {
|
||||
if (BuildConfig.FLAVOR != "oss" && showBubbleZoom) {
|
||||
TooltipIconButton(
|
||||
text = if (isBubbleZoomModeActive) stringResource(R.string.action_exit_smart_zoom) else stringResource(R.string.action_smart_comic_zoom),
|
||||
description = stringResource(R.string.desc_toggle_smart_comic_zoom),
|
||||
|
|
|
|||
|
|
@ -63,7 +63,6 @@ import androidx.compose.material3.Text
|
|||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
|
|
@ -81,7 +80,6 @@ import androidx.compose.runtime.withFrameNanos
|
|||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.geometry.Size
|
||||
|
|
@ -101,7 +99,6 @@ import androidx.compose.ui.layout.Layout
|
|||
import androidx.compose.ui.layout.LayoutCoordinates
|
||||
import androidx.compose.ui.layout.layoutId
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.positionInWindow
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalViewConfiguration
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
|
|
@ -116,6 +113,8 @@ 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
|
||||
import com.aryan.reader.shared.pdf.calculatePdfVerticalPageLayoutPx
|
||||
import com.aryan.reader.shared.pdf.pdfVerticalPageGapDp
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
|
|
@ -175,14 +174,70 @@ fun rememberVerticalPdfReaderState(): VerticalPdfReaderState {
|
|||
|
||||
private data class PdfPageLayout(
|
||||
val index: Int,
|
||||
val y: Float,
|
||||
val height: Float,
|
||||
val width: Float,
|
||||
val yPx: Int,
|
||||
val heightPx: Int,
|
||||
val widthPx: Int,
|
||||
val widthDp: Dp,
|
||||
val heightDp: Dp
|
||||
) {
|
||||
val y: Float
|
||||
get() = yPx.toFloat()
|
||||
|
||||
val height: Float
|
||||
get() = heightPx.toFloat()
|
||||
|
||||
val width: Float
|
||||
get() = widthPx.toFloat()
|
||||
}
|
||||
|
||||
private data class DividerLayout(val yPx: Int, val widthPx: Int, val heightPx: Int) {
|
||||
val y: Float
|
||||
get() = yPx.toFloat()
|
||||
|
||||
val width: Float
|
||||
get() = widthPx.toFloat()
|
||||
|
||||
val height: Float
|
||||
get() = heightPx.toFloat()
|
||||
}
|
||||
|
||||
internal data class PdfLockedOrientationResetCamera(
|
||||
val zoom: Float,
|
||||
val panX: Float,
|
||||
val panY: Float
|
||||
)
|
||||
|
||||
private data class DividerLayout(val y: Float, val width: Float, val height: Float)
|
||||
internal fun calculateLockedOrientationResetCamera(
|
||||
pageTopY: Float,
|
||||
totalDocHeight: Float,
|
||||
screenWidth: Float,
|
||||
screenHeight: Float,
|
||||
headerHeightPx: Float,
|
||||
footerHeightPx: Float,
|
||||
fitZoom: Float
|
||||
): PdfLockedOrientationResetCamera {
|
||||
val targetPanY = headerHeightPx - (pageTopY * fitZoom)
|
||||
val zoomedDocHeight = totalDocHeight * fitZoom
|
||||
val minPanY = if (zoomedDocHeight < (screenHeight - headerHeightPx - footerHeightPx)) {
|
||||
headerHeightPx
|
||||
} else {
|
||||
(screenHeight - footerHeightPx - zoomedDocHeight).coerceAtMost(headerHeightPx)
|
||||
}
|
||||
val finalPanY = targetPanY.coerceIn(minPanY, headerHeightPx)
|
||||
|
||||
val zoomedDocWidth = screenWidth * fitZoom
|
||||
val targetPanX = if (zoomedDocWidth < screenWidth) {
|
||||
(screenWidth - zoomedDocWidth) / 2f
|
||||
} else {
|
||||
0f
|
||||
}
|
||||
|
||||
return PdfLockedOrientationResetCamera(
|
||||
zoom = fitZoom,
|
||||
panX = targetPanX,
|
||||
panY = finalPanY
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("UnusedVariable")
|
||||
@SuppressLint("UnusedBoxWithConstraintsScope", "BinaryOperationInTimber")
|
||||
|
|
@ -192,6 +247,7 @@ internal fun PdfVerticalReader(
|
|||
modifier: Modifier = Modifier,
|
||||
state: VerticalPdfReaderState,
|
||||
pdfDocument: StableHolder<ReaderDocument>,
|
||||
documentKey: String,
|
||||
activeTheme: com.aryan.reader.ReaderTheme,
|
||||
activeTextureAlpha: Float = 0.55f,
|
||||
excludeImages: Boolean = false,
|
||||
|
|
@ -230,6 +286,7 @@ internal fun PdfVerticalReader(
|
|||
selectedTool: InkType,
|
||||
richTextController: RichTextController? = null,
|
||||
textBoxes: List<PdfTextBox> = emptyList(),
|
||||
textBoxesByPage: Map<Int, List<PdfTextBox>> = emptyMap(),
|
||||
selectedTextBoxId: String? = null,
|
||||
onTextBoxChange: (PdfTextBox) -> Unit = {},
|
||||
onTextBoxSelect: (String) -> Unit = {},
|
||||
|
|
@ -245,6 +302,7 @@ internal fun PdfVerticalReader(
|
|||
stylusButtonHovering: Boolean = false,
|
||||
isHighlighterSnapEnabled: Boolean = false,
|
||||
userHighlights: List<PdfUserHighlight> = emptyList(),
|
||||
userHighlightsByPage: Map<Int, List<PdfUserHighlight>> = emptyMap(),
|
||||
onHighlightAdd: (Int, Pair<Int, Int>, String, PdfHighlightColor) -> Unit = { _,_,_,_ -> },
|
||||
onHighlightUpdate: (String, PdfHighlightColor) -> Unit = { _,_ -> },
|
||||
onHighlightDelete: (String) -> Unit = {},
|
||||
|
|
@ -258,9 +316,10 @@ internal fun PdfVerticalReader(
|
|||
onZoomAndPanChanged: ((Float, Offset) -> Unit)? = null,
|
||||
resetZoomTrigger: Long = 0L,
|
||||
isBubbleZoomModeActive: Boolean = false,
|
||||
showPageGap: Boolean = true,
|
||||
showPageNumberOverlay: Boolean = true,
|
||||
onDetectBubbles: suspend (Int, Bitmap) -> List<SpeechBubble> = { _, _ -> emptyList() }
|
||||
) {
|
||||
SideEffect { Timber.tag("PdfDrawPerf").v("LIST: PdfVerticalReader Recomposing.") }
|
||||
DisposableEffect(state) {
|
||||
onDispose {
|
||||
state.scrollToPageHandler = null
|
||||
|
|
@ -273,6 +332,13 @@ internal fun PdfVerticalReader(
|
|||
var globalEraserPosition by remember { mutableStateOf<Offset?>(null) }
|
||||
var isStylusEraserOverride by remember { mutableStateOf(false) }
|
||||
val isDarkMode = activeTheme.isDark || activeTheme.id == "reverse"
|
||||
val verticalPageBackgroundColor = remember(activeTheme) {
|
||||
when (activeTheme.id) {
|
||||
"no_theme", "system" -> Color.White
|
||||
"reverse" -> Color.Black
|
||||
else -> activeTheme.backgroundColor
|
||||
}
|
||||
}
|
||||
BoxWithConstraints(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.TopStart) {
|
||||
val imeInsets = WindowInsets.ime
|
||||
val density = LocalDensity.current
|
||||
|
|
@ -282,6 +348,12 @@ internal fun PdfVerticalReader(
|
|||
|
||||
val ratios = pageAspectRatios.item
|
||||
val bookmarkSet = bookmarks.item
|
||||
val effectiveTextBoxesByPage = remember(textBoxes, textBoxesByPage) {
|
||||
textBoxesByPage.ifEmpty { textBoxes.groupBy { it.pageIndex } }
|
||||
}
|
||||
val effectiveUserHighlightsByPage = remember(userHighlights, userHighlightsByPage) {
|
||||
userHighlightsByPage.ifEmpty { userHighlights.groupBy { it.pageIndex } }
|
||||
}
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
|
|
@ -294,56 +366,37 @@ internal fun PdfVerticalReader(
|
|||
val headerHeightPx = with(density) { headerHeight.toPx() }
|
||||
val footerHeightPx = with(density) { footerHeight.toPx() }
|
||||
|
||||
val dividerHeightDp = 8.dp
|
||||
val dividerHeightDp = pdfVerticalPageGapDp(showPageGap, 8.dp)
|
||||
val dividerHeightPx = with(density) { dividerHeightDp.toPx() }
|
||||
val dividerHeightPxInt = dividerHeightPx.roundToInt().coerceAtLeast(0)
|
||||
|
||||
var isFlinging by remember { mutableStateOf(false) }
|
||||
var isFastFlinging by remember { mutableStateOf(false) }
|
||||
var isInteracting by remember { mutableStateOf(false) }
|
||||
var isDragging by remember { mutableStateOf(false) }
|
||||
|
||||
val layoutState = remember(ratios, screenWidth, screenHeight, density) {
|
||||
val layoutState = remember(ratios, constraints.maxWidth, constraints.maxHeight, density, showPageGap, dividerHeightPxInt) {
|
||||
data class LayoutResult(val pages: List<PdfPageLayout>, val totalHeight: Float)
|
||||
|
||||
var currentY = 0.0
|
||||
val verticalLayout = calculatePdfVerticalPageLayoutPx(
|
||||
pageAspectRatios = ratios,
|
||||
viewportWidthPx = constraints.maxWidth,
|
||||
viewportHeightPx = constraints.maxHeight,
|
||||
pageGapPx = dividerHeightPxInt
|
||||
)
|
||||
|
||||
if (ratios.size == 1) {
|
||||
val ratio = ratios[0]
|
||||
val safeRatio = if (ratio <= 0f) 1f else ratio
|
||||
val pageHeight = screenWidth / safeRatio
|
||||
if (pageHeight < screenHeight) {
|
||||
currentY = ((screenHeight - pageHeight) / 2f).toDouble()
|
||||
}
|
||||
val pages = verticalLayout.pages.map { page ->
|
||||
PdfPageLayout(
|
||||
index = page.pageIndex,
|
||||
yPx = page.topPx,
|
||||
heightPx = page.heightPx,
|
||||
widthPx = page.widthPx,
|
||||
widthDp = with(density) { page.widthPx.toDp() },
|
||||
heightDp = with(density) { page.heightPx.toDp() }
|
||||
)
|
||||
}
|
||||
|
||||
val pages = ratios.mapIndexed { index, ratio ->
|
||||
val safeRatio = if (ratio <= 0f) 1f else ratio
|
||||
val pageHeightDouble = screenWidth.toDouble() / safeRatio.toDouble()
|
||||
val pageHeight = pageHeightDouble.toFloat()
|
||||
|
||||
val info = PdfPageLayout(
|
||||
index = index,
|
||||
y = currentY.toFloat(),
|
||||
height = pageHeight,
|
||||
width = screenWidth,
|
||||
widthDp = with(density) { screenWidth.toDp() },
|
||||
heightDp = with(density) { pageHeight.toDp() })
|
||||
|
||||
currentY += pageHeightDouble
|
||||
if (index < ratios.lastIndex) {
|
||||
currentY += dividerHeightPx
|
||||
}
|
||||
info
|
||||
}
|
||||
|
||||
val totalH = if (pages.isNotEmpty()) {
|
||||
val last = pages.last()
|
||||
last.y + last.height
|
||||
} else {
|
||||
0f
|
||||
}
|
||||
|
||||
LayoutResult(pages, totalH)
|
||||
LayoutResult(pages, verticalLayout.totalHeightPx.toFloat())
|
||||
}
|
||||
|
||||
val layoutInfo = layoutState.pages
|
||||
|
|
@ -375,11 +428,17 @@ internal fun PdfVerticalReader(
|
|||
var isResizing by remember { mutableStateOf(false) }
|
||||
var previousScreenWidth by remember { mutableFloatStateOf(0f) }
|
||||
var previousScreenHeight by remember { mutableFloatStateOf(0f) }
|
||||
var lockedOrientationChangedDuringResize by remember { mutableStateOf(false) }
|
||||
val targetPageDuringResize = remember { mutableIntStateOf(-1) }
|
||||
|
||||
if (previousScreenWidth != screenWidth || previousScreenHeight != screenHeight) {
|
||||
if (previousScreenWidth > 0f) {
|
||||
val previousWasLandscape = previousScreenWidth > previousScreenHeight
|
||||
val currentIsLandscape = screenWidth > screenHeight
|
||||
isResizing = true
|
||||
if (isScrollLocked && previousWasLandscape != currentIsLandscape) {
|
||||
lockedOrientationChangedDuringResize = true
|
||||
}
|
||||
if (targetPageDuringResize.intValue == -1) {
|
||||
targetPageDuringResize.intValue = state.currentPage
|
||||
}
|
||||
|
|
@ -424,7 +483,45 @@ internal fun PdfVerticalReader(
|
|||
}
|
||||
|
||||
LaunchedEffect(layoutState.pages) {
|
||||
if (!isInitialLayout && !isScrollLocked) {
|
||||
if (!isInitialLayout && isScrollLocked && lockedOrientationChangedDuringResize) {
|
||||
val targetPageIdx = if (targetPageDuringResize.intValue != -1) {
|
||||
targetPageDuringResize.intValue
|
||||
} else {
|
||||
state.currentPage
|
||||
}
|
||||
|
||||
val newLayout = layoutState.pages
|
||||
val pageLayout = newLayout.getOrNull(targetPageIdx)
|
||||
|
||||
if (pageLayout != null) {
|
||||
val resetCamera = calculateLockedOrientationResetCamera(
|
||||
pageTopY = pageLayout.y,
|
||||
totalDocHeight = layoutState.totalHeight,
|
||||
screenWidth = screenWidth,
|
||||
screenHeight = screenHeight,
|
||||
headerHeightPx = headerHeightPx,
|
||||
footerHeightPx = footerHeightPx,
|
||||
fitZoom = fitZoom
|
||||
)
|
||||
|
||||
panXAnimatable.updateBounds(null, null)
|
||||
panYAnimatable.updateBounds(null, null)
|
||||
|
||||
coroutineScope {
|
||||
launch { zoomAnimatable.snapTo(resetCamera.zoom) }
|
||||
launch { panXAnimatable.snapTo(resetCamera.panX) }
|
||||
launch { panYAnimatable.snapTo(resetCamera.panY) }
|
||||
}
|
||||
|
||||
state.currentPage = targetPageIdx
|
||||
hasRestoredLockedState = true
|
||||
onZoomChange(resetCamera.zoom)
|
||||
onZoomAndPanChanged?.invoke(resetCamera.zoom, Offset(resetCamera.panX, resetCamera.panY))
|
||||
Timber.tag("PdfLockDiagnostic").i(
|
||||
"Orientation changed while locked; reset zoom to fit and kept page $targetPageIdx"
|
||||
)
|
||||
}
|
||||
} else if (!isInitialLayout && !isScrollLocked) {
|
||||
val targetPageIdx = if (targetPageDuringResize.intValue != -1) {
|
||||
targetPageDuringResize.intValue
|
||||
} else {
|
||||
|
|
@ -466,6 +563,7 @@ internal fun PdfVerticalReader(
|
|||
if (!isInitialLayout) {
|
||||
delay(50)
|
||||
isResizing = false
|
||||
lockedOrientationChangedDuringResize = false
|
||||
targetPageDuringResize.intValue = -1
|
||||
}
|
||||
isInitialLayout = false
|
||||
|
|
@ -1080,8 +1178,9 @@ internal fun PdfVerticalReader(
|
|||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(if (showPageGap) Color.Transparent else verticalPageBackgroundColor)
|
||||
.then(globalDrawingModifier)
|
||||
.pointerInput(isEditMode, selectedTool, isStylusOnlyMode) {
|
||||
.pointerInput(isEditMode, selectedTool, isStylusOnlyMode, isScrollLocked) {
|
||||
Timber.tag("PdfTouchDebug").v(
|
||||
"VerticalReader: TapPointerInput init. isEditMode=$isEditMode"
|
||||
)
|
||||
|
|
@ -1101,8 +1200,10 @@ internal fun PdfVerticalReader(
|
|||
onPageClick()
|
||||
}
|
||||
}, onDoubleTap = { offset ->
|
||||
Timber.tag("PdfTouchDebug").d("VerticalReader: DoubleTap detected")
|
||||
onDoubleTapToZoom(offset)
|
||||
if (!isScrollLocked) {
|
||||
Timber.tag("PdfTouchDebug").d("VerticalReader: DoubleTap detected")
|
||||
onDoubleTapToZoom(offset)
|
||||
}
|
||||
})
|
||||
}
|
||||
.pointerInput(
|
||||
|
|
@ -1428,11 +1529,16 @@ internal fun PdfVerticalReader(
|
|||
}
|
||||
|
||||
val cached = cachedVisiblePages.value
|
||||
val indicesMatch = cached.size == finalPages.size && cached.indices.all {
|
||||
cached[it].index == finalPages[it].index
|
||||
val layoutMatches = cached.size == finalPages.size && cached.indices.all {
|
||||
val cachedPage = cached[it]
|
||||
val newPage = finalPages[it]
|
||||
cachedPage.index == newPage.index &&
|
||||
cachedPage.yPx == newPage.yPx &&
|
||||
cachedPage.heightPx == newPage.heightPx &&
|
||||
cachedPage.widthPx == newPage.widthPx
|
||||
}
|
||||
|
||||
if (!indicesMatch) {
|
||||
if (!layoutMatches) {
|
||||
cachedVisiblePages.value = finalPages
|
||||
Timber.tag("PdfDrawPerf").d(
|
||||
"Vertical Visible Pages Changed: ${finalPages.map { it.index }} (Dragging: ${draggedBox != null})"
|
||||
|
|
@ -1472,20 +1578,13 @@ internal fun PdfVerticalReader(
|
|||
Layout(
|
||||
content = {
|
||||
visiblePages.forEach { page ->
|
||||
key(page.index) {
|
||||
key(documentKey, page.index) {
|
||||
val isBookmarked by remember(bookmarkSet, page.index) {
|
||||
derivedStateOf {
|
||||
bookmarkSet.any { it.pageIndex == page.index }
|
||||
}
|
||||
}
|
||||
|
||||
SideEffect {
|
||||
if (page.index == state.currentPage) {
|
||||
Timber.tag("PdfDrawPerf")
|
||||
.v("VERTICAL READER: Emitting Page ${page.index}")
|
||||
}
|
||||
}
|
||||
|
||||
val visibleScreenRectLambda = remember(page, screenWidth, screenHeight) {
|
||||
{
|
||||
val zoom = zoomAnimatable.value
|
||||
|
|
@ -1579,6 +1678,7 @@ internal fun PdfVerticalReader(
|
|||
{ text: String -> onSearchText(text) }
|
||||
}
|
||||
|
||||
val currentOnDoubleTapToZoom by rememberUpdatedState(onDoubleTapToZoom)
|
||||
val onDoubleTapLambda = remember(page, screenWidth, screenHeight) {
|
||||
{ localOffset: Offset ->
|
||||
Timber.tag("PdfZoomDebug").d(
|
||||
|
|
@ -1592,7 +1692,7 @@ internal fun PdfVerticalReader(
|
|||
val screenX = contentX * currentZ + panX
|
||||
val screenY = contentY * currentZ + panY
|
||||
Timber.tag("PdfZoomDebug").d("Mapped to Screen: ($screenX, $screenY)") // Added log
|
||||
onDoubleTapToZoom(Offset(screenX, screenY))
|
||||
currentOnDoubleTapToZoom(Offset(screenX, screenY))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1661,32 +1761,10 @@ internal fun PdfVerticalReader(
|
|||
|
||||
Box(modifier = Modifier
|
||||
.layoutId(page)
|
||||
.graphicsLayer {
|
||||
val z = zoomAnimatable.value
|
||||
val px = panXAnimatable.value
|
||||
val py = panYAnimatable.value
|
||||
|
||||
scaleX = z
|
||||
scaleY = z
|
||||
translationX = px
|
||||
translationY = page.y * (z - 1f) + py
|
||||
transformOrigin = TransformOrigin(0f, 0f)
|
||||
|
||||
if (page.index < 2 && z > 1.1f) {
|
||||
Timber.tag("PdfZoomDebug").v("Page ${page.index} Render: TransY=$translationY (PageY=${page.y}, GlobalY=${page.y + translationY})")
|
||||
}
|
||||
}
|
||||
.clipToBounds()
|
||||
.onGloballyPositioned { coordinates ->
|
||||
if (page.index == 0) {
|
||||
val pos = coordinates.positionInWindow()
|
||||
Timber.d(
|
||||
"Page 0 Box | GlobalPos: $pos | Size: ${coordinates.size} | PageY: ${page.y}"
|
||||
)
|
||||
}
|
||||
}) {
|
||||
) {
|
||||
PdfPageComposable(
|
||||
pdfDocument = pdfDocument,
|
||||
documentKey = documentKey,
|
||||
pageIndex = page.index,
|
||||
virtualPage = virtualPage,
|
||||
totalPages = totalPages,
|
||||
|
|
@ -1716,6 +1794,8 @@ internal fun PdfVerticalReader(
|
|||
isZoomEnabled = false,
|
||||
isScrolling = isDragging || (isFlinging && isFastFlinging),
|
||||
isVerticalScroll = true,
|
||||
showPageNumberOverlay = showPageNumberOverlay,
|
||||
isScrollLocked = isScrollLocked,
|
||||
visualScaleProvider = currentScaleProvider,
|
||||
onDoubleTap = onDoubleTapLambda,
|
||||
clearSelectionTrigger = selectionClearTrigger,
|
||||
|
|
@ -1734,11 +1814,11 @@ internal fun PdfVerticalReader(
|
|||
isStylusOnlyMode = isStylusOnlyMode,
|
||||
stylusButtonHovering = stylusButtonHovering,
|
||||
isAutoScrollPlaying = isAutoScrollPlaying,
|
||||
textBoxes = textBoxes.filter { it.pageIndex == page.index },
|
||||
textBoxes = effectiveTextBoxesByPage[page.index].orEmpty(),
|
||||
selectedTextBoxId = selectedTextBoxId,
|
||||
onTextBoxChange = onTextBoxChange,
|
||||
onTextBoxSelect = onTextBoxSelect,
|
||||
userHighlights = userHighlights.filter { it.pageIndex == page.index },
|
||||
userHighlights = effectiveUserHighlightsByPage[page.index].orEmpty(),
|
||||
onHighlightAdd = onHighlightAdd,
|
||||
onHighlightUpdate = onHighlightUpdate,
|
||||
onHighlightDelete = onHighlightDelete,
|
||||
|
|
@ -1852,26 +1932,17 @@ internal fun PdfVerticalReader(
|
|||
)
|
||||
}
|
||||
|
||||
if (page.index < totalPages - 1) {
|
||||
val dividerY = page.y + page.height
|
||||
if (page.index < totalPages - 1 && dividerHeightPxInt > 0) {
|
||||
val dividerYPx = page.yPx + page.heightPx
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.layoutId(
|
||||
DividerLayout(
|
||||
dividerY, page.width, dividerHeightPx
|
||||
yPx = dividerYPx,
|
||||
widthPx = page.widthPx,
|
||||
heightPx = dividerHeightPxInt
|
||||
)
|
||||
)
|
||||
.graphicsLayer {
|
||||
val z = zoomAnimatable.value
|
||||
val px = panXAnimatable.value
|
||||
val py = panYAnimatable.value
|
||||
|
||||
scaleX = z
|
||||
scaleY = z
|
||||
translationX = px
|
||||
translationY = dividerY * (z - 1f) + py
|
||||
transformOrigin = TransformOrigin(0f, 0f)
|
||||
}
|
||||
.background(
|
||||
MaterialTheme.colorScheme.surfaceVariant
|
||||
))
|
||||
|
|
@ -1881,6 +1952,15 @@ internal fun PdfVerticalReader(
|
|||
},
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.graphicsLayer {
|
||||
val z = zoomAnimatable.value
|
||||
|
||||
scaleX = z
|
||||
scaleY = z
|
||||
translationX = panXAnimatable.value
|
||||
translationY = panYAnimatable.value
|
||||
transformOrigin = TransformOrigin(0f, 0f)
|
||||
}
|
||||
.onGloballyPositioned { _ -> }) { measurables, constraints ->
|
||||
val layoutStart = System.nanoTime()
|
||||
Timber.tag("PdfDrawPerf")
|
||||
|
|
@ -1891,19 +1971,19 @@ internal fun PdfVerticalReader(
|
|||
is PdfPageLayout -> {
|
||||
val placeable = measurable.measure(
|
||||
Constraints.fixed(
|
||||
id.width.roundToInt(), id.height.roundToInt()
|
||||
id.widthPx, id.heightPx
|
||||
)
|
||||
)
|
||||
placeable.place(0, id.y.roundToInt())
|
||||
placeable.place(0, id.yPx)
|
||||
}
|
||||
|
||||
is DividerLayout -> {
|
||||
val placeable = measurable.measure(
|
||||
Constraints.fixed(
|
||||
id.width.roundToInt(), id.height.roundToInt()
|
||||
id.widthPx, id.heightPx
|
||||
)
|
||||
)
|
||||
placeable.place(0, id.y.roundToInt())
|
||||
placeable.place(0, id.yPx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -120,7 +120,6 @@ import androidx.compose.material3.rememberModalBottomSheetState
|
|||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
|
|
@ -198,13 +197,13 @@ import com.aryan.reader.AiDefinitionPopup
|
|||
import com.aryan.reader.AiFeature
|
||||
import com.aryan.reader.AiDefinitionResult
|
||||
import com.aryan.reader.AiHubBottomSheet
|
||||
import com.aryan.reader.BannerMessage
|
||||
import com.aryan.reader.BuildConfig
|
||||
import com.aryan.reader.CustomTopBanner
|
||||
import com.aryan.reader.FileType
|
||||
import com.aryan.reader.HighlightColorPickerDialog
|
||||
import com.aryan.reader.MainViewModel
|
||||
import com.aryan.reader.R
|
||||
import com.aryan.reader.ReaderScreenOrientationEffect
|
||||
import com.aryan.reader.ReaderScreenOrientationSheet
|
||||
import com.aryan.reader.ReaderThemePanel
|
||||
import com.aryan.reader.SearchResult
|
||||
import com.aryan.reader.SummarizationResult
|
||||
|
|
@ -225,6 +224,8 @@ import com.aryan.reader.callByokGeminiInlineAi
|
|||
import com.aryan.reader.isByokCloudTtsAvailable
|
||||
import com.aryan.reader.loadCustomThemes
|
||||
import com.aryan.reader.loadGlobalTextureTransparency
|
||||
import com.aryan.reader.loadReaderScreenOrientationMode
|
||||
import com.aryan.reader.loadPdfRightToLeftPagination
|
||||
import com.aryan.reader.loadTtsReplacementPreferences
|
||||
import com.aryan.reader.paginatedreader.TtsChunk
|
||||
import com.aryan.reader.pdf.data.AnnotationSettingsRepository
|
||||
|
|
@ -240,7 +241,10 @@ import com.aryan.reader.pdf.data.VirtualPage
|
|||
import com.aryan.reader.rememberSearchState
|
||||
import com.aryan.reader.saveCustomThemes
|
||||
import com.aryan.reader.saveGlobalTextureTransparency
|
||||
import com.aryan.reader.saveReaderScreenOrientationMode
|
||||
import com.aryan.reader.savePdfRightToLeftPagination
|
||||
import com.aryan.reader.saveTtsReplacementPreferences
|
||||
import com.aryan.reader.scaledToCanvasLimit
|
||||
import com.aryan.reader.shared.ReaderTtsReplacementPreferences
|
||||
import com.aryan.reader.summarizationUrl
|
||||
import com.aryan.reader.tts.SpeakerSamplePlayer
|
||||
|
|
@ -288,6 +292,36 @@ internal fun resolveEraserStrokeWidth(
|
|||
eraserToolThickness: Float
|
||||
): Float = if (isEraserOverride) eraserToolThickness else activeToolThickness
|
||||
|
||||
internal fun canUsePdfSidecarsForBook(
|
||||
activeBookId: String?,
|
||||
loadedSidecarBookId: String?,
|
||||
areSidecarsLoaded: Boolean
|
||||
): Boolean = activeBookId != null && areSidecarsLoaded && loadedSidecarBookId == activeBookId
|
||||
|
||||
internal fun currentPageScaleAfterPdfPageChange(
|
||||
displayMode: DisplayMode,
|
||||
isScrollLocked: Boolean,
|
||||
lockedState: Triple<Float, Float, Float>?,
|
||||
currentActiveScale: Float
|
||||
): Float {
|
||||
return if (displayMode == DisplayMode.PAGINATION && isScrollLocked) {
|
||||
lockedState?.first ?: currentActiveScale
|
||||
} else {
|
||||
1f
|
||||
}
|
||||
}
|
||||
|
||||
internal fun activePdfCameraAfterLockPreferenceLoad(
|
||||
isScrollLocked: Boolean,
|
||||
lockedState: Triple<Float, Float, Float>?
|
||||
): Pair<Float, Offset> {
|
||||
return if (isScrollLocked && lockedState != null) {
|
||||
lockedState.first to Offset(lockedState.second, lockedState.third)
|
||||
} else {
|
||||
1f to Offset.Zero
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("KotlinConstantConditions")
|
||||
@SuppressLint("UnusedBoxWithConstraintsScope", "ObsoleteSdkInt", "LocalContextGetResourceValueCall")
|
||||
@ExperimentalMaterial3Api
|
||||
|
|
@ -305,7 +339,6 @@ fun PdfViewerScreen(
|
|||
onNavigateToPro: () -> Unit,
|
||||
viewModel: MainViewModel
|
||||
) {
|
||||
SideEffect { Timber.tag("PdfDrawPerf").v("ROOT: PdfViewerScreen Recomposing") }
|
||||
val context = LocalContext.current
|
||||
LaunchedEffect(Unit) {
|
||||
PdfFontCache.init(context.assets)
|
||||
|
|
@ -338,7 +371,12 @@ fun PdfViewerScreen(
|
|||
var pageAspectRatios by remember { mutableStateOf<List<Float>>(emptyList()) }
|
||||
var showBars by rememberSaveable { mutableStateOf(true) }
|
||||
var systemUiMode by remember { mutableStateOf(loadPdfSystemUiMode(context)) }
|
||||
var showVerticalPageGap by remember { mutableStateOf(loadPdfVerticalPageGapVisible(context)) }
|
||||
var showPageNumberOverlay by remember { mutableStateOf(loadPdfPageNumberOverlayVisible(context)) }
|
||||
var showVisualOptionsSheet by remember { mutableStateOf(false) }
|
||||
var screenOrientationMode by remember { mutableStateOf(loadReaderScreenOrientationMode(context)) }
|
||||
var rightToLeftPagination by remember { mutableStateOf(loadPdfRightToLeftPagination(context)) }
|
||||
var showScreenOrientationSheet by remember { mutableStateOf(false) }
|
||||
var isFullScreen by remember { mutableStateOf(false) }
|
||||
var documentPassword by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
var pendingRestorePage by rememberSaveable { mutableStateOf(initialPage) }
|
||||
|
|
@ -349,6 +387,7 @@ fun PdfViewerScreen(
|
|||
var showPasswordDialog by remember { mutableStateOf(false) }
|
||||
var isPasswordError by remember { mutableStateOf(false) }
|
||||
LocalView.current
|
||||
ReaderScreenOrientationEffect(screenOrientationMode)
|
||||
|
||||
var ocrLanguage by remember { mutableStateOf(loadOcrLanguage(context)) }
|
||||
var hasSelectedOcrLanguage by remember { mutableStateOf(hasUserSelectedOcrLanguage(context)) }
|
||||
|
|
@ -397,6 +436,7 @@ fun PdfViewerScreen(
|
|||
val uiState by viewModel.uiState.collectAsState()
|
||||
val effectivePdfUri = uiState.selectedPdfUri ?: pdfUri
|
||||
val effectiveFileType = uiState.selectedFileType ?: FileType.PDF
|
||||
val isComicFile = effectiveFileType == FileType.CBZ || effectiveFileType == FileType.CBR || effectiveFileType == FileType.CB7
|
||||
|
||||
var showNewTabSheet by remember { mutableStateOf(false) }
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false)
|
||||
|
|
@ -404,6 +444,7 @@ fun PdfViewerScreen(
|
|||
val isTabsEnabled = uiState.isTabsEnabled
|
||||
val openTabs = uiState.openTabs
|
||||
val activeTabBookId = uiState.activeTabBookId
|
||||
val isPdfTabStripVisible = isTabsEnabled && openTabs.isNotEmpty() && effectiveFileType == FileType.PDF
|
||||
val originalFileName by remember(uiState.recentFiles, effectivePdfUri) {
|
||||
derivedStateOf {
|
||||
uiState.recentFiles.find { it.uriString == effectivePdfUri.toString() }?.displayName
|
||||
|
|
@ -412,6 +453,7 @@ fun PdfViewerScreen(
|
|||
}
|
||||
var currentBookId by remember { mutableStateOf<String?>(null) }
|
||||
val bookId = currentBookId ?: effectivePdfUri.toString().hashCode().toString()
|
||||
val activeDocumentRenderKey = currentBookId ?: effectivePdfUri.toString()
|
||||
var documentMetadataTitle by remember { mutableStateOf<String?>(null) }
|
||||
val view = LocalView.current
|
||||
var isDockDragging by remember { mutableStateOf(false) }
|
||||
|
|
@ -425,8 +467,16 @@ fun PdfViewerScreen(
|
|||
}
|
||||
|
||||
LaunchedEffect(bookId) {
|
||||
isScrollLocked = loadPdfScrollLocked(context, bookId)
|
||||
lockedState = loadPdfLockedState(context, bookId)
|
||||
val savedIsScrollLocked = loadPdfScrollLocked(context, bookId)
|
||||
val savedLockedState = loadPdfLockedState(context, bookId)
|
||||
val activeCamera = activePdfCameraAfterLockPreferenceLoad(
|
||||
isScrollLocked = savedIsScrollLocked,
|
||||
lockedState = savedLockedState
|
||||
)
|
||||
isScrollLocked = savedIsScrollLocked
|
||||
lockedState = savedLockedState
|
||||
currentActiveScale = activeCamera.first
|
||||
currentActiveOffset = activeCamera.second
|
||||
}
|
||||
|
||||
var isAutoScrollModeActive by remember { mutableStateOf(false) }
|
||||
|
|
@ -496,9 +546,8 @@ fun PdfViewerScreen(
|
|||
|
||||
val customFonts by viewModel.customFonts.collectAsState()
|
||||
|
||||
var bannerMessage by remember { mutableStateOf<BannerMessage?>(null) }
|
||||
fun showBanner(message: String, isError: Boolean = false) {
|
||||
bannerMessage = BannerMessage(message, isError = isError)
|
||||
fun showBanner(message: String, isError: Boolean = false, isPersistent: Boolean = false) {
|
||||
viewModel.showBanner(message, isError, isPersistent)
|
||||
}
|
||||
val onOcrStateChange: (Boolean) -> Unit = {}
|
||||
|
||||
|
|
@ -638,6 +687,13 @@ fun PdfViewerScreen(
|
|||
var showBubbleZoomDownloadDialog by remember { mutableStateOf(false) }
|
||||
val bubbleZoomDownloadProgress by viewModel.speechBubbleModelDownloadProgress.collectAsState()
|
||||
|
||||
LaunchedEffect(isComicFile) {
|
||||
if (!isComicFile) {
|
||||
isBubbleZoomModeActive = false
|
||||
showBubbleZoomDownloadDialog = false
|
||||
}
|
||||
}
|
||||
|
||||
var dockLocation by remember { mutableStateOf(initialDockLocation) }
|
||||
var dockOffset by remember { mutableStateOf(initialDockOffset) }
|
||||
var snapPreviewLocation by remember { mutableStateOf<DockLocation?>(null) }
|
||||
|
|
@ -725,7 +781,8 @@ fun PdfViewerScreen(
|
|||
val targetTopOverlayInset = remember(
|
||||
showStandardBars,
|
||||
systemUiMode,
|
||||
statusBarHeightDp
|
||||
statusBarHeightDp,
|
||||
isPdfTabStripVisible
|
||||
) {
|
||||
if (!showStandardBars) {
|
||||
0.dp
|
||||
|
|
@ -737,6 +794,9 @@ fun PdfViewerScreen(
|
|||
if (isStatusBarVisible) {
|
||||
inset += statusBarHeightDp
|
||||
}
|
||||
if (isPdfTabStripVisible) {
|
||||
inset += PdfTabStripHeight
|
||||
}
|
||||
inset
|
||||
}
|
||||
}
|
||||
|
|
@ -775,13 +835,14 @@ fun PdfViewerScreen(
|
|||
|
||||
LaunchedEffect(displayMode) { saveDisplayMode(context, displayMode) }
|
||||
|
||||
LaunchedEffect(currentActiveScale, currentActiveOffset, isScrollLocked) {
|
||||
LaunchedEffect(bookId, currentActiveScale, currentActiveOffset, isScrollLocked) {
|
||||
if (isScrollLocked) {
|
||||
val requestedCamera = currentActiveScale to currentActiveOffset
|
||||
delay(500)
|
||||
Timber.tag("PdfLockDiagnostic").d("SAVING: BookId=$bookId | Scale=$currentActiveScale | X=${currentActiveOffset.x} | Y=${currentActiveOffset.y}")
|
||||
Timber.tag("PdfLockDiagnostic").d("SAVING: BookId=$bookId | Scale=${requestedCamera.first} | X=${requestedCamera.second.x} | Y=${requestedCamera.second.y}")
|
||||
|
||||
lockedState = Triple(currentActiveScale, currentActiveOffset.x, currentActiveOffset.y)
|
||||
savePdfLockedState(context, bookId, currentActiveScale, currentActiveOffset.x, currentActiveOffset.y)
|
||||
lockedState = Triple(requestedCamera.first, requestedCamera.second.x, requestedCamera.second.y)
|
||||
savePdfLockedState(context, bookId, requestedCamera.first, requestedCamera.second.x, requestedCamera.second.y)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -800,14 +861,6 @@ fun PdfViewerScreen(
|
|||
errorMessage?.let { showBanner(it, isError = true) }
|
||||
}
|
||||
|
||||
LaunchedEffect(bannerMessage) {
|
||||
val message = bannerMessage ?: return@LaunchedEffect
|
||||
if (!message.isPersistent) {
|
||||
delay(2500L)
|
||||
bannerMessage = null
|
||||
}
|
||||
}
|
||||
|
||||
val annotationSettingsRepo = remember(context) { AnnotationSettingsRepository(context) }
|
||||
val toolSettings by annotationSettingsRepo.settings.collectAsState()
|
||||
var showToolSettings by rememberSaveable { mutableStateOf(false) }
|
||||
|
|
@ -861,6 +914,7 @@ fun PdfViewerScreen(
|
|||
var lastEraserPoint by remember { mutableStateOf<PdfPoint?>(null) }
|
||||
|
||||
var areAnnotationsLoaded by remember { mutableStateOf(false) }
|
||||
var loadedSidecarBookId by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
val richTextRepository = remember(context) { PdfRichTextRepository(context) }
|
||||
val richTextController = remember(currentBookId) {
|
||||
|
|
@ -936,16 +990,10 @@ fun PdfViewerScreen(
|
|||
}
|
||||
|
||||
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()
|
||||
return buildPdfBubblePrefetchOrder(
|
||||
currentPage = currentPage,
|
||||
totalPages = totalDisplayPages
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun detectSpeechBubblesForPage(
|
||||
|
|
@ -1125,39 +1173,80 @@ fun PdfViewerScreen(
|
|||
|
||||
val lastSavedHashes = remember(currentBookId) { IntArray(5) { -1 } }
|
||||
|
||||
val sidecarsReadyForCurrentBook =
|
||||
canUsePdfSidecarsForBook(currentBookId, loadedSidecarBookId, areAnnotationsLoaded)
|
||||
val textBoxesSnapshot by remember { derivedStateOf { textBoxes.toList() } }
|
||||
val userHighlightsSnapshot by remember { derivedStateOf { userHighlights.toList() } }
|
||||
val visibleAllAnnotations = if (sidecarsReadyForCurrentBook) allAnnotations else emptyMap()
|
||||
val visibleTextBoxes = if (sidecarsReadyForCurrentBook) textBoxesSnapshot else emptyList()
|
||||
val visibleUserHighlights = if (sidecarsReadyForCurrentBook) userHighlightsSnapshot else emptyList()
|
||||
val visibleTextBoxesByPage = remember(sidecarsReadyForCurrentBook, textBoxesSnapshot) {
|
||||
if (sidecarsReadyForCurrentBook) {
|
||||
textBoxesSnapshot.groupBy { it.pageIndex }
|
||||
} else {
|
||||
emptyMap()
|
||||
}
|
||||
}
|
||||
val visibleUserHighlightsByPage = remember(sidecarsReadyForCurrentBook, userHighlightsSnapshot) {
|
||||
if (sidecarsReadyForCurrentBook) {
|
||||
userHighlightsSnapshot.groupBy { it.pageIndex }
|
||||
} else {
|
||||
emptyMap()
|
||||
}
|
||||
}
|
||||
|
||||
val currentAnnotations by rememberUpdatedState(allAnnotations)
|
||||
val currentTextBoxes by rememberUpdatedState(textBoxes.toList())
|
||||
val currentHighlights by rememberUpdatedState(userHighlights.toList())
|
||||
val currentTextBoxes by rememberUpdatedState(textBoxesSnapshot)
|
||||
val currentHighlights by rememberUpdatedState(userHighlightsSnapshot)
|
||||
val currentLoadedSidecarBookId by rememberUpdatedState(loadedSidecarBookId)
|
||||
val currentAreAnnotationsLoaded by rememberUpdatedState(areAnnotationsLoaded)
|
||||
val currentBookmarks by rememberUpdatedState(bookmarks)
|
||||
val currentTotalPages by rememberUpdatedState(totalDisplayPages)
|
||||
val currentPageState by rememberUpdatedState(currentPage)
|
||||
val currentPendingPage by rememberUpdatedState(pendingRestorePage)
|
||||
val currentVisibleAllAnnotations by rememberUpdatedState(visibleAllAnnotations)
|
||||
|
||||
val saveAllData = remember(currentBookId, annotationRepository, textBoxRepository, highlightRepository) {
|
||||
{ force: Boolean ->
|
||||
val bookIdSnapshot = currentBookId
|
||||
val loadedSidecarBookIdSnapshot = currentLoadedSidecarBookId
|
||||
val canSaveSidecarsSnapshot = canUsePdfSidecarsForBook(
|
||||
bookIdSnapshot,
|
||||
loadedSidecarBookIdSnapshot,
|
||||
currentAreAnnotationsLoaded
|
||||
)
|
||||
val isDocumentReadySnapshot = isDocumentReady
|
||||
val initialScrollDoneSnapshot = initialScrollDone
|
||||
val annotsSnapshot = currentAnnotations
|
||||
val boxesSnapshot = currentTextBoxes
|
||||
val highlightsSnapshot = currentHighlights
|
||||
val bookmarksSnapshot = currentBookmarks
|
||||
val totalPagesSnapshot = currentTotalPages
|
||||
val currentPageSnapshot = currentPageState
|
||||
val pendingPageSnapshot = currentPendingPage
|
||||
viewModel.viewModelScope.launch {
|
||||
val bookId = currentBookId ?: return@launch
|
||||
val bookId = bookIdSnapshot ?: return@launch
|
||||
|
||||
if (!isDocumentReady && !force) {
|
||||
if (!isDocumentReadySnapshot && !force) {
|
||||
Timber.tag("PdfPositionDebug").w("UI: Save ignored. Document not ready.")
|
||||
return@launch
|
||||
}
|
||||
|
||||
val annots = currentAnnotations
|
||||
val boxes = currentTextBoxes
|
||||
val highlights = currentHighlights
|
||||
val bms = currentBookmarks
|
||||
val totalPgs = currentTotalPages
|
||||
val annots = annotsSnapshot
|
||||
val boxes = boxesSnapshot
|
||||
val highlights = highlightsSnapshot
|
||||
val bms = bookmarksSnapshot
|
||||
val totalPgs = totalPagesSnapshot
|
||||
|
||||
val restoreTarget = currentPendingPage ?: 0
|
||||
val page = if (!initialScrollDone) {
|
||||
Timber.tag("PdfPositionDebug").i("UI: Save during restoration | Using restoreTarget: $restoreTarget (CurrentUI: $currentPageState)")
|
||||
val restoreTarget = pendingPageSnapshot ?: 0
|
||||
val page = if (!initialScrollDoneSnapshot) {
|
||||
Timber.tag("PdfPositionDebug").i("UI: Save during restoration | Using restoreTarget: $restoreTarget (CurrentUI: $currentPageSnapshot)")
|
||||
restoreTarget
|
||||
} else {
|
||||
currentPageState
|
||||
currentPageSnapshot
|
||||
}
|
||||
|
||||
Timber.tag("PdfPositionDebug").v("UI: Save logic | Choosing: $page (UI: $currentPageState, Target: $restoreTarget, Done: $initialScrollDone)")
|
||||
Timber.tag("PdfPositionDebug").v("UI: Save logic | Choosing: $page (UI: $currentPageSnapshot, Target: $restoreTarget, Done: $initialScrollDoneSnapshot)")
|
||||
|
||||
val annotsHash = annots.hashCode()
|
||||
val boxesHash = boxes.hashCode()
|
||||
|
|
@ -1169,20 +1258,26 @@ fun PdfViewerScreen(
|
|||
withContext(Dispatchers.IO) {
|
||||
@Suppress("VariableNeverRead") var didSave = false
|
||||
|
||||
if (force || annotsHash != lastSavedHashes[0]) {
|
||||
annotationRepository.saveAnnotations(bookId, annots)
|
||||
lastSavedHashes[0] = annotsHash
|
||||
didSave = true
|
||||
}
|
||||
if (force || boxesHash != lastSavedHashes[1]) {
|
||||
textBoxRepository.saveTextBoxes(bookId, boxes)
|
||||
lastSavedHashes[1] = boxesHash
|
||||
didSave = true
|
||||
}
|
||||
if (force || highlightsHash != lastSavedHashes[2]) {
|
||||
highlightRepository.saveHighlights(bookId, highlights)
|
||||
lastSavedHashes[2] = highlightsHash
|
||||
didSave = true
|
||||
if (canSaveSidecarsSnapshot) {
|
||||
if (force || annotsHash != lastSavedHashes[0]) {
|
||||
annotationRepository.saveAnnotations(bookId, annots)
|
||||
lastSavedHashes[0] = annotsHash
|
||||
didSave = true
|
||||
}
|
||||
if (force || boxesHash != lastSavedHashes[1]) {
|
||||
textBoxRepository.saveTextBoxes(bookId, boxes)
|
||||
lastSavedHashes[1] = boxesHash
|
||||
didSave = true
|
||||
}
|
||||
if (force || highlightsHash != lastSavedHashes[2]) {
|
||||
highlightRepository.saveHighlights(bookId, highlights)
|
||||
lastSavedHashes[2] = highlightsHash
|
||||
didSave = true
|
||||
}
|
||||
} else {
|
||||
Timber.tag("PdfTabSync").d(
|
||||
"Skipping PDF sidecar save for $bookId; loaded sidecars belong to $loadedSidecarBookIdSnapshot"
|
||||
)
|
||||
}
|
||||
if (force || bmsHash != lastSavedHashes[3]) {
|
||||
val objectList = bms.map { bookmark ->
|
||||
|
|
@ -1239,18 +1334,19 @@ fun PdfViewerScreen(
|
|||
|
||||
LaunchedEffect(
|
||||
allAnnotations,
|
||||
textBoxes.toList(),
|
||||
userHighlights.toList(),
|
||||
textBoxesSnapshot,
|
||||
userHighlightsSnapshot,
|
||||
bookmarks,
|
||||
currentPage
|
||||
currentPage,
|
||||
sidecarsReadyForCurrentBook
|
||||
) {
|
||||
if (areAnnotationsLoaded && currentBookId != null && initialScrollDone) {
|
||||
if (sidecarsReadyForCurrentBook && initialScrollDone) {
|
||||
delay(2000) // Debounce period
|
||||
saveAllData(false)
|
||||
}
|
||||
}
|
||||
|
||||
val allAnnotationsProvider = remember { { allAnnotations } }
|
||||
val allAnnotationsProvider = remember { { currentVisibleAllAnnotations } }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
Timber.d("PdfViewerScreen init: initialBookmarksJson is '$initialBookmarksJson'")
|
||||
|
|
@ -2070,26 +2166,39 @@ fun PdfViewerScreen(
|
|||
}
|
||||
|
||||
LaunchedEffect(currentBookId) {
|
||||
if (currentBookId != null) {
|
||||
val loaded = annotationRepository.loadAnnotations(currentBookId!!)
|
||||
allAnnotations = loaded
|
||||
areAnnotationsLoaded = true
|
||||
val loadingBookId = currentBookId
|
||||
|
||||
val loadedBoxes = textBoxRepository.loadTextBoxes(currentBookId!!)
|
||||
textBoxes.clear()
|
||||
textBoxes.addAll(loadedBoxes)
|
||||
areAnnotationsLoaded = false
|
||||
loadedSidecarBookId = null
|
||||
allAnnotations = emptyMap()
|
||||
textBoxes.clear()
|
||||
userHighlights.clear()
|
||||
selectedTextBoxId = null
|
||||
undoStack.clear()
|
||||
redoStack.clear()
|
||||
erasedAnnotationsFromStroke.clear()
|
||||
drawingState.onDrawCancel()
|
||||
|
||||
val loadedHighlights = highlightRepository.loadHighlights(currentBookId!!)
|
||||
userHighlights.clear()
|
||||
userHighlights.addAll(loadedHighlights)
|
||||
}
|
||||
if (loadingBookId == null) return@LaunchedEffect
|
||||
|
||||
val loaded = annotationRepository.loadAnnotations(loadingBookId)
|
||||
val loadedBoxes = textBoxRepository.loadTextBoxes(loadingBookId)
|
||||
val loadedHighlights = highlightRepository.loadHighlights(loadingBookId)
|
||||
|
||||
if (currentBookId != loadingBookId) return@LaunchedEffect
|
||||
|
||||
allAnnotations = loaded
|
||||
textBoxes.addAll(loadedBoxes)
|
||||
userHighlights.addAll(loadedHighlights)
|
||||
loadedSidecarBookId = loadingBookId
|
||||
areAnnotationsLoaded = true
|
||||
}
|
||||
|
||||
var isRebuildingSyncedHighlightBounds by remember(currentBookId) { mutableStateOf(false) }
|
||||
LaunchedEffect(pdfDocument, currentBookId, userHighlights.toList()) {
|
||||
LaunchedEffect(pdfDocument, currentBookId, userHighlightsSnapshot, sidecarsReadyForCurrentBook) {
|
||||
val document = pdfDocument ?: return@LaunchedEffect
|
||||
if (currentBookId == null || isRebuildingSyncedHighlightBounds) return@LaunchedEffect
|
||||
val snapshot = userHighlights.toList()
|
||||
if (!sidecarsReadyForCurrentBook || isRebuildingSyncedHighlightBounds) return@LaunchedEffect
|
||||
val snapshot = userHighlightsSnapshot
|
||||
if (snapshot.none { it.bounds.isEmpty() && it.range.second > it.range.first }) return@LaunchedEffect
|
||||
|
||||
isRebuildingSyncedHighlightBounds = true
|
||||
|
|
@ -2116,18 +2225,18 @@ fun PdfViewerScreen(
|
|||
coroutineScope.launch {
|
||||
val currentRichTextLayouts = richTextController?.pageLayouts
|
||||
|
||||
Timber.tag("PdfExportDebug").i("SAVE TRIGGERED: userHighlights count: ${userHighlights.size}")
|
||||
if (userHighlights.isEmpty()) {
|
||||
Timber.tag("PdfExportDebug").i("SAVE TRIGGERED: userHighlights count: ${visibleUserHighlights.size}")
|
||||
if (visibleUserHighlights.isEmpty()) {
|
||||
Timber.tag("PdfExportDebug").w("Warning: userHighlights is EMPTY during save.")
|
||||
}
|
||||
|
||||
viewModel.savePdfWithAnnotations(
|
||||
sourceUri = effectivePdfUri,
|
||||
destUri = uri,
|
||||
annotations = allAnnotations,
|
||||
annotations = visibleAllAnnotations,
|
||||
richTextPageLayouts = currentRichTextLayouts,
|
||||
textBoxes = textBoxes.toList(),
|
||||
highlights = userHighlights.toList(),
|
||||
textBoxes = visibleTextBoxes,
|
||||
highlights = visibleUserHighlights,
|
||||
bookId = currentBookId!!
|
||||
)
|
||||
}
|
||||
|
|
@ -2879,6 +2988,17 @@ fun PdfViewerScreen(
|
|||
isDocumentReady = false
|
||||
errorMessage = null
|
||||
documentMetadataTitle = null
|
||||
currentBookId = null
|
||||
areAnnotationsLoaded = false
|
||||
loadedSidecarBookId = null
|
||||
allAnnotations = emptyMap()
|
||||
textBoxes.clear()
|
||||
userHighlights.clear()
|
||||
selectedTextBoxId = null
|
||||
undoStack.clear()
|
||||
redoStack.clear()
|
||||
erasedAnnotationsFromStroke.clear()
|
||||
drawingState.onDrawCancel()
|
||||
|
||||
if (showPasswordDialog) isPasswordError = false
|
||||
|
||||
|
|
@ -2932,12 +3052,13 @@ fun PdfViewerScreen(
|
|||
withContext(Dispatchers.IO) {
|
||||
Timber.tag("PdfTabSync").v("UI: Opening PFD for $effectivePdfUri")
|
||||
|
||||
if (pdfUri.scheme != "opds-pse") {
|
||||
val selectedDocumentType = uiState.selectedFileType ?: FileType.PDF
|
||||
if (pdfUri.scheme != "opds-pse" && selectedDocumentType == FileType.PDF) {
|
||||
currentPfdOpened = context.contentResolver.openFileDescriptor(effectivePdfUri, "r")
|
||||
if (currentPfdOpened == null) throw Exception("Failed to open ParcelFileDescriptor")
|
||||
}
|
||||
|
||||
val doc = DocumentFactory.loadDocument(context, effectivePdfUri, uiState.selectedFileType ?: FileType.PDF, documentPassword, pdfiumCore)
|
||||
val doc = DocumentFactory.loadDocument(context, effectivePdfUri, selectedDocumentType, documentPassword, pdfiumCore)
|
||||
|
||||
if (!isActive) {
|
||||
doc.close()
|
||||
|
|
@ -3027,7 +3148,7 @@ fun PdfViewerScreen(
|
|||
currentBookId!!,
|
||||
DocumentCacheItem(
|
||||
doc = doc,
|
||||
pfd = currentPfdOpened!!,
|
||||
pfd = currentPfdOpened,
|
||||
totalPages = pagesCount,
|
||||
pageAspectRatios = ratios,
|
||||
flatTableOfContents = flatTableOfContents
|
||||
|
|
@ -3094,8 +3215,8 @@ fun PdfViewerScreen(
|
|||
isLoadingDocument = false
|
||||
}
|
||||
} else {
|
||||
Timber.e(e, "Error loading PDF document")
|
||||
errorMessage = "Error loading PDF: ${e.localizedMessage}"
|
||||
Timber.e(e, "Error loading fixed-layout document")
|
||||
errorMessage = "Error loading document: ${e.localizedMessage}"
|
||||
isLoadingDocument = false
|
||||
}
|
||||
if (pdfDocument == null) {
|
||||
|
|
@ -3132,8 +3253,14 @@ fun PdfViewerScreen(
|
|||
summarizationResult = null
|
||||
}
|
||||
|
||||
LaunchedEffect(pagerState.currentPage) {
|
||||
currentPageScale = 1f
|
||||
LaunchedEffect(pagerState.currentPage, displayMode, isScrollLocked, lockedState) {
|
||||
val nextPageScale = currentPageScaleAfterPdfPageChange(
|
||||
displayMode = displayMode,
|
||||
isScrollLocked = isScrollLocked,
|
||||
lockedState = lockedState,
|
||||
currentActiveScale = currentActiveScale
|
||||
)
|
||||
currentPageScale = nextPageScale
|
||||
ocrUsedForCurrentPageTts = false
|
||||
}
|
||||
|
||||
|
|
@ -3172,7 +3299,8 @@ fun PdfViewerScreen(
|
|||
LaunchedEffect(effectivePdfUri, currentBookId, totalPages) {
|
||||
if (currentBookId == null || totalPages == 0) return@LaunchedEffect
|
||||
if (isBackgroundIndexing && backgroundIndexingProgress > 0f) return@LaunchedEffect
|
||||
if (uiState.selectedFileType != FileType.PDF) return@LaunchedEffect
|
||||
val selectedDocumentType = uiState.selectedFileType ?: return@LaunchedEffect
|
||||
if (selectedDocumentType != FileType.PDF && selectedDocumentType != FileType.PPTX) return@LaunchedEffect
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
val storedLang = pdfTextRepository.getBookLanguage(currentBookId!!)
|
||||
|
|
@ -3183,6 +3311,7 @@ fun PdfViewerScreen(
|
|||
isBackgroundIndexing = true
|
||||
var bgPfd: ParcelFileDescriptor? = null
|
||||
var bgDoc: PdfDocumentKt? = null
|
||||
var genericDoc: ReaderDocument? = null
|
||||
|
||||
try {
|
||||
val existingPages = pdfTextRepository.getIndexedPages(currentBookId!!)
|
||||
|
|
@ -3199,17 +3328,18 @@ fun PdfViewerScreen(
|
|||
"Indexer: Starting background indexing for ${totalPages - existingPages.size} pages."
|
||||
)
|
||||
|
||||
bgPfd = context.contentResolver.openFileDescriptor(effectivePdfUri, "r")
|
||||
val openedBgPfd = bgPfd
|
||||
if (openedBgPfd != null) {
|
||||
val pagesToIndex = (0 until totalPages).filter { !existingPages.contains(it) }
|
||||
val totalToDo = pagesToIndex.size
|
||||
var completed = 0
|
||||
|
||||
if (selectedDocumentType == FileType.PDF) {
|
||||
bgPfd = context.contentResolver.openFileDescriptor(effectivePdfUri, "r")
|
||||
val openedBgPfd = bgPfd
|
||||
if (openedBgPfd == null) return@withContext
|
||||
bgDoc = PdfiumEngineProvider.withPdfium {
|
||||
pdfiumCore.newDocument(openedBgPfd, documentPassword)
|
||||
}
|
||||
|
||||
val pagesToIndex = (0 until totalPages).filter { !existingPages.contains(it) }
|
||||
val totalToDo = pagesToIndex.size
|
||||
var completed = 0
|
||||
|
||||
for (pageIndex in pagesToIndex) {
|
||||
if (!isActive) break
|
||||
|
||||
|
|
@ -3223,6 +3353,37 @@ fun PdfViewerScreen(
|
|||
Timber.e(e, "Indexer: Failed on page $pageIndex")
|
||||
}
|
||||
|
||||
completed++
|
||||
if (completed % 5 == 0 || completed == totalToDo) {
|
||||
val totalIndexedSoFar = initialIndexedCount + completed
|
||||
backgroundIndexingProgress =
|
||||
totalIndexedSoFar.toFloat() / totalPages.toFloat()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val openedGenericDoc = DocumentFactory.loadDocument(
|
||||
context = context,
|
||||
uri = effectivePdfUri,
|
||||
type = selectedDocumentType,
|
||||
password = null,
|
||||
pdfiumCore = pdfiumCore
|
||||
)
|
||||
genericDoc = openedGenericDoc
|
||||
|
||||
for (pageIndex in pagesToIndex) {
|
||||
if (!isActive) break
|
||||
|
||||
try {
|
||||
pdfTextRepository.indexReaderPage(
|
||||
bookId = currentBookId!!,
|
||||
document = openedGenericDoc,
|
||||
pageIndex = pageIndex,
|
||||
onOcrModelDownloading = { isOcrModelDownloading = true }
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Indexer: Failed on page $pageIndex")
|
||||
}
|
||||
|
||||
completed++
|
||||
if (completed % 5 == 0 || completed == totalToDo) {
|
||||
val totalIndexedSoFar = initialIndexedCount + completed
|
||||
|
|
@ -3238,6 +3399,7 @@ fun PdfViewerScreen(
|
|||
PdfiumEngineProvider.withPdfium {
|
||||
bgDoc?.close()
|
||||
}
|
||||
genericDoc?.close()
|
||||
bgPfd?.close()
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Indexer: Cleanup failed")
|
||||
|
|
@ -3510,9 +3672,10 @@ fun PdfViewerScreen(
|
|||
ModalDrawerSheet(modifier = Modifier.windowInsetsPadding(WindowInsets.statusBars)) {
|
||||
PdfNavigationDrawerContent(
|
||||
pdfDocument = pdfDocument,
|
||||
documentKey = activeDocumentRenderKey,
|
||||
flatTableOfContents = flatTableOfContents,
|
||||
bookmarks = bookmarks,
|
||||
userHighlights = userHighlights,
|
||||
userHighlights = visibleUserHighlights,
|
||||
currentPage = currentPage,
|
||||
totalPages = totalDisplayPages,
|
||||
customHighlightColors = customHighlightColors,
|
||||
|
|
@ -3649,7 +3812,7 @@ fun PdfViewerScreen(
|
|||
}
|
||||
|
||||
pdfDocument != null && totalPages > 0 -> {
|
||||
val stablePdfDocument = remember(pdfDocument) { StableHolder(pdfDocument!!) }
|
||||
val stablePdfDocument = remember(activeDocumentRenderKey, pdfDocument) { StableHolder(pdfDocument!!) }
|
||||
when (displayMode) {
|
||||
DisplayMode.PAGINATION -> {
|
||||
val onPaginationPreSingleTap: (Offset) -> Boolean = { tapOffset ->
|
||||
|
|
@ -3664,7 +3827,11 @@ fun PdfViewerScreen(
|
|||
tapOffset.x < oneQuarterWidthPx -> {
|
||||
coroutineScope.launch {
|
||||
val targetPage =
|
||||
(pagerState.currentPage - 1).coerceAtLeast(0)
|
||||
if (rightToLeftPagination) {
|
||||
(pagerState.currentPage + 1).coerceAtMost(pagerState.pageCount - 1)
|
||||
} else {
|
||||
(pagerState.currentPage - 1).coerceAtLeast(0)
|
||||
}
|
||||
if (targetPage != pagerState.currentPage) {
|
||||
pagerState.scrollToPage(targetPage)
|
||||
}
|
||||
|
|
@ -3675,9 +3842,13 @@ fun PdfViewerScreen(
|
|||
tapOffset.x > (boxMaxWidthFloat - oneQuarterWidthPx) -> {
|
||||
coroutineScope.launch {
|
||||
val targetPage =
|
||||
(pagerState.currentPage + 1).coerceAtMost(
|
||||
pagerState.pageCount - 1
|
||||
)
|
||||
if (rightToLeftPagination) {
|
||||
(pagerState.currentPage - 1).coerceAtLeast(0)
|
||||
} else {
|
||||
(pagerState.currentPage + 1).coerceAtMost(
|
||||
pagerState.pageCount - 1
|
||||
)
|
||||
}
|
||||
if (targetPage != pagerState.currentPage) {
|
||||
pagerState.scrollToPage(targetPage)
|
||||
}
|
||||
|
|
@ -3694,14 +3865,14 @@ fun PdfViewerScreen(
|
|||
HorizontalPager(
|
||||
state = pagerState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
key = { it },
|
||||
key = { page -> "$activeDocumentRenderKey:$page" },
|
||||
beyondViewportPageCount = dynamicBeyondViewportPageCount,
|
||||
reverseLayout = rightToLeftPagination,
|
||||
userScrollEnabled = run {
|
||||
val enabled = (currentPageScale == 1f || (isScrollLocked && displayMode == DisplayMode.PAGINATION)) && !(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
|
||||
(currentPageScale == 1f || (isScrollLocked && displayMode == DisplayMode.PAGINATION)) &&
|
||||
!(ttsState.isPlaying || ttsState.isLoading || searchState.isSearchActive) &&
|
||||
!isPageSliderVisible &&
|
||||
paginationDraggingBoxId == null
|
||||
}
|
||||
) { pageIndex ->
|
||||
val isVisiblePage = remember(pagerState.currentPage, pageIndex) {
|
||||
|
|
@ -3881,6 +4052,7 @@ fun PdfViewerScreen(
|
|||
|
||||
PdfPageComposable(
|
||||
pdfDocument = stablePdfDocument,
|
||||
documentKey = activeDocumentRenderKey,
|
||||
pageIndex = pageIndex,
|
||||
virtualPage = virtualPage,
|
||||
totalPages = totalDisplayPages,
|
||||
|
|
@ -3923,6 +4095,7 @@ fun PdfViewerScreen(
|
|||
isBookmarked = isPageBookmarked,
|
||||
onBookmarkClick = { onToggleBookmark(pageIndex) },
|
||||
isZoomEnabled = true,
|
||||
showPageNumberOverlay = showPageNumberOverlay,
|
||||
clearSelectionTrigger = selectionClearTrigger,
|
||||
resetZoomTrigger = resetZoomTrigger,
|
||||
pageAnnotations = pageAnnotationsProvider,
|
||||
|
|
@ -3961,7 +4134,7 @@ fun PdfViewerScreen(
|
|||
onOcrModelDownloading = {
|
||||
isOcrModelDownloading = true
|
||||
},
|
||||
userHighlights = userHighlights.filter { it.pageIndex == pageIndex },
|
||||
userHighlights = visibleUserHighlightsByPage[pageIndex].orEmpty(),
|
||||
onHighlightAdd = onHighlightAdd,
|
||||
onHighlightUpdate = onHighlightUpdate,
|
||||
onHighlightDelete = onHighlightDelete,
|
||||
|
|
@ -3980,7 +4153,12 @@ fun PdfViewerScreen(
|
|||
detectSpeechBubblesForPage(sourcePageIndex, bitmap)
|
||||
},
|
||||
onShowPanelPopup = { bitmapWithRects ->
|
||||
poppedUpPanelBitmap = bitmapWithRects
|
||||
val safeBitmap = bitmapWithRects.scaledToCanvasLimit()
|
||||
if (safeBitmap !== bitmapWithRects && !bitmapWithRects.isRecycled) {
|
||||
bitmapWithRects.recycle()
|
||||
}
|
||||
poppedUpPanelBitmap?.takeUnless { it.isRecycled }?.recycle()
|
||||
poppedUpPanelBitmap = safeBitmap
|
||||
},
|
||||
onTwoFingerSwipe = { direction ->
|
||||
coroutineScope.launch {
|
||||
|
|
@ -3999,7 +4177,7 @@ fun PdfViewerScreen(
|
|||
isAutoScrollPlaying = isAutoScrollPlaying,
|
||||
isHighlighterSnapEnabled = isHighlighterSnapEnabled,
|
||||
isEditMode = isDrawingActive,
|
||||
textBoxes = textBoxes.filter { it.pageIndex == pageIndex },
|
||||
textBoxes = visibleTextBoxesByPage[pageIndex].orEmpty(),
|
||||
selectedTextBoxId = selectedTextBoxId,
|
||||
onTextBoxChange = { updatedBox ->
|
||||
val idx = textBoxes.indexOfFirst { it.id == updatedBox.id }
|
||||
|
|
@ -4326,7 +4504,7 @@ fun PdfViewerScreen(
|
|||
Box(modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.clip(RectangleShape)) {
|
||||
val docHolder = remember(pdfDocument) {
|
||||
val docHolder = remember(activeDocumentRenderKey, pdfDocument) {
|
||||
StableHolder(pdfDocument!!)
|
||||
}
|
||||
val bookmarksHolder =
|
||||
|
|
@ -4338,6 +4516,7 @@ fun PdfViewerScreen(
|
|||
PdfVerticalReader(
|
||||
state = verticalReaderState,
|
||||
pdfDocument = docHolder,
|
||||
documentKey = activeDocumentRenderKey,
|
||||
activeTheme = activeTheme,
|
||||
activeTextureAlpha = 1f - globalTextureTransparency,
|
||||
excludeImages = excludeImages,
|
||||
|
|
@ -4364,7 +4543,8 @@ fun PdfViewerScreen(
|
|||
onSearchText = onSearchTextStable,
|
||||
ttsHighlightData = ttsHighlightData,
|
||||
ttsReadingPage = ttsDisplayPageIndex,
|
||||
userHighlights = userHighlights,
|
||||
userHighlights = visibleUserHighlights,
|
||||
userHighlightsByPage = visibleUserHighlightsByPage,
|
||||
onHighlightAdd = onHighlightAdd,
|
||||
onHighlightUpdate = onHighlightUpdate,
|
||||
onHighlightDelete = onHighlightDelete,
|
||||
|
|
@ -4419,7 +4599,8 @@ fun PdfViewerScreen(
|
|||
isStylusOnlyMode = isStylusOnlyMode,
|
||||
stylusButtonHovering = stylusButtonHovering,
|
||||
isEditMode = isDrawingActive,
|
||||
textBoxes = textBoxes,
|
||||
textBoxes = visibleTextBoxes,
|
||||
textBoxesByPage = visibleTextBoxesByPage,
|
||||
selectedTextBoxId = selectedTextBoxId,
|
||||
onTextBoxChange = { updatedBox ->
|
||||
val idx = textBoxes.indexOfFirst { it.id == updatedBox.id }
|
||||
|
|
@ -4444,6 +4625,8 @@ fun PdfViewerScreen(
|
|||
autoScrollSpeed = autoScrollSpeed * 0.5f,
|
||||
onInteractionListener = onAutoScrollInteraction,
|
||||
lockedState = lockedState,
|
||||
showPageGap = showVerticalPageGap,
|
||||
showPageNumberOverlay = showPageNumberOverlay,
|
||||
onZoomAndPanChanged = { newScale, newOffset ->
|
||||
currentActiveScale = newScale
|
||||
currentActiveOffset = newOffset
|
||||
|
|
@ -5030,6 +5213,7 @@ fun PdfViewerScreen(
|
|||
isScrollLocked = isScrollLocked,
|
||||
isEditMode = isEditMode,
|
||||
displayMode = displayMode,
|
||||
isRightToLeftPagination = rightToLeftPagination,
|
||||
isKeepScreenOn = isKeepScreenOn,
|
||||
isTtsSessionActive = isTtsSessionActive,
|
||||
isBookmarked = isBookmarked,
|
||||
|
|
@ -5084,6 +5268,7 @@ fun PdfViewerScreen(
|
|||
}
|
||||
},
|
||||
onShowVisualOptions = { showVisualOptionsSheet = true },
|
||||
onShowScreenOrientation = { showScreenOrientationSheet = true },
|
||||
isTtsPlayingOrLoading = isPdfTtsPlayingOrLoading,
|
||||
showAllTextHighlights = showAllTextHighlights,
|
||||
isHighlightingLoading = isHighlightingLoading,
|
||||
|
|
@ -5100,6 +5285,10 @@ fun PdfViewerScreen(
|
|||
saveTapToNavigateSetting(context, tapToNavigateEnabled)
|
||||
},
|
||||
onChangeDisplayMode = { displayMode = it },
|
||||
onSetRightToLeftPagination = { enabled ->
|
||||
rightToLeftPagination = enabled
|
||||
savePdfRightToLeftPagination(context, enabled)
|
||||
},
|
||||
onToggleKeepScreenOn = {
|
||||
isKeepScreenOn = !isKeepScreenOn
|
||||
saveKeepScreenOn(context, isKeepScreenOn)
|
||||
|
|
@ -5215,7 +5404,11 @@ fun PdfViewerScreen(
|
|||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.msg_indexing_pages_progress),
|
||||
text = stringResource(
|
||||
R.string.msg_indexing_pages_progress,
|
||||
(backgroundIndexingProgress * 100f).roundToInt()
|
||||
.coerceIn(0, 100)
|
||||
),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer
|
||||
)
|
||||
|
|
@ -5427,6 +5620,8 @@ fun PdfViewerScreen(
|
|||
onShowAiHub = showPdfAiHub,
|
||||
onToggleEditMode = togglePdfEditMode,
|
||||
onToggleTts = togglePdfTts,
|
||||
onShowScreenOrientation = { showScreenOrientationSheet = true },
|
||||
showBubbleZoom = isComicFile,
|
||||
isBubbleZoomModeActive = isBubbleZoomModeActive,
|
||||
onToggleBubbleZoom = {
|
||||
if (isOss) {
|
||||
|
|
@ -6166,8 +6361,6 @@ fun PdfViewerScreen(
|
|||
}
|
||||
)
|
||||
}
|
||||
|
||||
CustomTopBanner(bannerMessage = bannerMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -6314,6 +6507,17 @@ fun PdfViewerScreen(
|
|||
|
||||
// --- PANEL POPUP ---
|
||||
if (poppedUpPanelBitmap != null) {
|
||||
val sourcePanelBitmap = poppedUpPanelBitmap
|
||||
val displayPanelBitmap = remember(sourcePanelBitmap) {
|
||||
sourcePanelBitmap?.scaledToCanvasLimit()
|
||||
}
|
||||
DisposableEffect(sourcePanelBitmap, displayPanelBitmap) {
|
||||
onDispose {
|
||||
if (displayPanelBitmap != null && displayPanelBitmap !== sourcePanelBitmap && !displayPanelBitmap.isRecycled) {
|
||||
displayPanelBitmap.recycle()
|
||||
}
|
||||
}
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
|
|
@ -6327,15 +6531,17 @@ fun PdfViewerScreen(
|
|||
},
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Image(
|
||||
bitmap = poppedUpPanelBitmap!!.asImageBitmap(),
|
||||
contentDescription = stringResource(R.string.content_desc_annotated_page),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
.clip(RoundedCornerShape(12.dp)),
|
||||
contentScale = ContentScale.Fit
|
||||
)
|
||||
displayPanelBitmap?.takeUnless { it.isRecycled }?.let { panelBitmap ->
|
||||
Image(
|
||||
bitmap = panelBitmap.asImageBitmap(),
|
||||
contentDescription = stringResource(R.string.content_desc_annotated_page),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
.clip(RoundedCornerShape(12.dp)),
|
||||
contentScale = ContentScale.Fit
|
||||
)
|
||||
}
|
||||
|
||||
IconButton(
|
||||
onClick = {
|
||||
|
|
@ -6832,7 +7038,7 @@ fun PdfViewerScreen(
|
|||
onClick = {
|
||||
showShareDialog = false
|
||||
isShareLoading = true
|
||||
Timber.tag("PdfExportDebug").i("SHARE TRIGGERED: userHighlights count: ${userHighlights.size}")
|
||||
Timber.tag("PdfExportDebug").i("SHARE TRIGGERED: userHighlights count: ${visibleUserHighlights.size}")
|
||||
val filename = getSuggestedFilename(
|
||||
originalFileName, isAnnotated = true
|
||||
)
|
||||
|
|
@ -6842,10 +7048,10 @@ fun PdfViewerScreen(
|
|||
viewModel.sharePdf(
|
||||
activityContext = context,
|
||||
sourceUri = effectivePdfUri,
|
||||
annotations = allAnnotations,
|
||||
annotations = visibleAllAnnotations,
|
||||
richTextPageLayouts = currentRichTextLayouts,
|
||||
textBoxes = textBoxes.toList(),
|
||||
highlights = userHighlights.toList(),
|
||||
textBoxes = visibleTextBoxes,
|
||||
highlights = visibleUserHighlights,
|
||||
includeAnnotations = true,
|
||||
filename = filename,
|
||||
bookId = currentBookId
|
||||
|
|
@ -6869,7 +7075,7 @@ fun PdfViewerScreen(
|
|||
viewModel.sharePdf(
|
||||
activityContext = context,
|
||||
sourceUri = pdfUri,
|
||||
annotations = allAnnotations,
|
||||
annotations = emptyMap(),
|
||||
includeAnnotations = false,
|
||||
filename = filename
|
||||
)
|
||||
|
|
@ -6917,13 +7123,33 @@ fun PdfViewerScreen(
|
|||
if (showVisualOptionsSheet) {
|
||||
PdfVisualOptionsSheet(
|
||||
systemUiMode = systemUiMode,
|
||||
showVerticalPageGap = showVerticalPageGap,
|
||||
showPageNumberOverlay = showPageNumberOverlay,
|
||||
onSystemUiModeChange = { mode ->
|
||||
systemUiMode = mode
|
||||
savePdfSystemUiMode(context, mode)
|
||||
},
|
||||
onShowVerticalPageGapChange = { isVisible ->
|
||||
showVerticalPageGap = isVisible
|
||||
savePdfVerticalPageGapVisible(context, isVisible)
|
||||
},
|
||||
onShowPageNumberOverlayChange = { isVisible ->
|
||||
showPageNumberOverlay = isVisible
|
||||
savePdfPageNumberOverlayVisible(context, isVisible)
|
||||
},
|
||||
onDismiss = { showVisualOptionsSheet = false }
|
||||
)
|
||||
}
|
||||
if (showScreenOrientationSheet) {
|
||||
ReaderScreenOrientationSheet(
|
||||
selectedMode = screenOrientationMode,
|
||||
onModeSelected = { mode ->
|
||||
screenOrientationMode = mode
|
||||
saveReaderScreenOrientationMode(context, mode)
|
||||
},
|
||||
onDismiss = { showScreenOrientationSheet = false }
|
||||
)
|
||||
}
|
||||
if (showCustomizeToolsSheet) {
|
||||
PdfCustomizeToolsSheet(
|
||||
hiddenTools = hiddenTools,
|
||||
|
|
|
|||
|
|
@ -57,7 +57,6 @@ import kotlinx.coroutines.launch
|
|||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import com.aryan.reader.shared.pdf.SHARED_PDF_RICH_TEXT_LOG_TAG
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
|
||||
|
|
@ -267,18 +266,18 @@ class TextPaginationEngine {
|
|||
dirtyGlobalIndex: Int = 0
|
||||
): List<PageTextLayout> {
|
||||
val totalLen = globalText.length
|
||||
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d(
|
||||
Timber.d(
|
||||
"android.paginate start textLen=$totalLen page=${pageWidthPx.richAndroidLogFloat()}x${pageHeightPx.richAndroidLogFloat()} " +
|
||||
"margin=${marginX.richAndroidLogFloat()},${marginY.richAndroidLogFloat()} prev=${previousLayouts.size} dirty=$dirtyGlobalIndex"
|
||||
)
|
||||
if (totalLen == 0) {
|
||||
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d("android.paginate empty -> p0:0-0")
|
||||
Timber.d("android.paginate empty -> p0:0-0")
|
||||
return listOf(
|
||||
PageTextLayout(0, AnnotatedString(""), 0, 0, pageHeightPx)
|
||||
)
|
||||
}
|
||||
if (pageWidthPx <= 0 || pageHeightPx <= 0) {
|
||||
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d("android.paginate aborted invalid page size")
|
||||
Timber.d("android.paginate aborted invalid page size")
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
|
|
@ -320,7 +319,7 @@ class TextPaginationEngine {
|
|||
" Page ${it.pageIndex}: Global[${it.globalStartIndex}..${it.globalEndIndex}]"
|
||||
}
|
||||
Timber.tag("RichTextMigration").i("Pagination Map Generated:\n$mapLog")
|
||||
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d("android.paginate done -> ${resultLayouts.richAndroidLayoutSummary()}")
|
||||
Timber.d("android.paginate done -> ${resultLayouts.richAndroidLayoutSummary()}")
|
||||
|
||||
return resultLayouts
|
||||
}
|
||||
|
|
@ -350,7 +349,7 @@ private fun MutableList<PageTextLayout>.appendMeasuredAndroidRichTextSegment(
|
|||
pageHeightPx = pageHeightPx
|
||||
)
|
||||
)
|
||||
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d(
|
||||
Timber.d(
|
||||
"android.paginate pageBreakOnly page=$nextPageIndex global=$segmentStart..$breakEnd"
|
||||
)
|
||||
return nextPageIndex + 1
|
||||
|
|
@ -398,11 +397,11 @@ private fun MutableList<PageTextLayout>.appendMeasuredAndroidRichTextSegment(
|
|||
)
|
||||
)
|
||||
if (isLastContentPage && explicitBreakEnd != null) {
|
||||
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d(
|
||||
Timber.d(
|
||||
"android.paginate pageBreak page=$nextPageIndex global=$globalStart..$globalEnd"
|
||||
)
|
||||
} else if (!fitsOnPage) {
|
||||
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d(
|
||||
Timber.d(
|
||||
"android.paginate overflow page=$nextPageIndex global=$globalStart..$globalEnd line=$overflowLineIndex"
|
||||
)
|
||||
}
|
||||
|
|
@ -475,12 +474,12 @@ class PdfRichTextRepository(private val context: Context) {
|
|||
suspend fun load(bookId: String) {
|
||||
withContext(Dispatchers.IO) {
|
||||
val file = getFile(bookId)
|
||||
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d(
|
||||
Timber.d(
|
||||
"android.repository.load start book=$bookId exists=${file.exists()} path=${file.absolutePath}"
|
||||
)
|
||||
if (!file.exists()) {
|
||||
_document.value = GlobalRichDocument("", emptyList())
|
||||
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d("android.repository.load missing -> empty book=$bookId")
|
||||
Timber.d("android.repository.load missing -> empty book=$bookId")
|
||||
return@withContext
|
||||
}
|
||||
try {
|
||||
|
|
@ -508,11 +507,11 @@ class PdfRichTextRepository(private val context: Context) {
|
|||
)
|
||||
}
|
||||
_document.value = GlobalRichDocument(text, spans)
|
||||
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d(
|
||||
Timber.d(
|
||||
"android.repository.load decoded book=$bookId rawLen=${jsonString.length} textLen=${text.length} spans=${spans.size}"
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).e(e, "android.repository.load failed book=$bookId")
|
||||
Timber.e(e, "android.repository.load failed book=$bookId")
|
||||
Timber.e(e, "Failed to load rich text doc")
|
||||
_document.value = GlobalRichDocument("", emptyList())
|
||||
}
|
||||
|
|
@ -523,7 +522,7 @@ class PdfRichTextRepository(private val context: Context) {
|
|||
_document.value = document
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d(
|
||||
Timber.d(
|
||||
"android.repository.save start book=$bookId textLen=${document.text.length} spans=${document.spans.size}"
|
||||
)
|
||||
val obj = JSONObject().apply {
|
||||
|
|
@ -548,11 +547,11 @@ class PdfRichTextRepository(private val context: Context) {
|
|||
}
|
||||
val file = getFile(bookId)
|
||||
file.writeText(obj.toString())
|
||||
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d(
|
||||
Timber.d(
|
||||
"android.repository.save done book=$bookId bytes=${file.length()} path=${file.absolutePath}"
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).e(e, "android.repository.save failed book=$bookId")
|
||||
Timber.e(e, "android.repository.save failed book=$bookId")
|
||||
Timber.e(e, "Failed to save rich text doc")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import android.net.Uri
|
|||
import android.os.Build
|
||||
import com.aryan.reader.FileType
|
||||
import com.aryan.reader.R
|
||||
import com.aryan.reader.pptx.PptxDocumentWrapper
|
||||
import io.legere.pdfiumandroid.api.Bookmark
|
||||
import io.legere.pdfiumandroid.suspend.PdfDocumentKt
|
||||
import io.legere.pdfiumandroid.suspend.PdfPageKt
|
||||
|
|
@ -79,7 +80,20 @@ object DocumentFactory {
|
|||
val catalogId = uri.getQueryParameter("catalogId")
|
||||
return OpdsStreamDocumentWrapper(context, bookId, urlTemplate, count, catalogId)
|
||||
}
|
||||
return if (type == FileType.CBZ || type == FileType.CBR || type == FileType.CB7) {
|
||||
return if (type == FileType.PPTX) {
|
||||
val cacheFile = File(context.cacheDir, "temp_pptx_${System.currentTimeMillis()}.pptx")
|
||||
try {
|
||||
withContext(Dispatchers.IO) {
|
||||
context.contentResolver.openInputStream(uri)?.use { input ->
|
||||
cacheFile.outputStream().use { output -> input.copyTo(output) }
|
||||
} ?: throw Exception("Failed to open PPTX")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
runCatching { cacheFile.delete() }
|
||||
throw e
|
||||
}
|
||||
PptxDocumentWrapper(cacheFile, deleteOnClose = true)
|
||||
} else if (type == FileType.CBZ || type == FileType.CBR || type == FileType.CB7) {
|
||||
val cacheFile = File(context.cacheDir, "temp_comic_${System.currentTimeMillis()}.${type.name.lowercase()}")
|
||||
withContext(Dispatchers.IO) {
|
||||
context.contentResolver.openInputStream(uri)?.use { input ->
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ import com.aryan.reader.pdf.PdfUserHighlight
|
|||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.util.Locale
|
||||
import java.util.UUID
|
||||
|
||||
data class PdfTextBox(
|
||||
val id: String,
|
||||
|
|
@ -54,7 +55,9 @@ data class PdfAnnotation(
|
|||
val pageIndex: Int,
|
||||
val points: List<PdfPoint>,
|
||||
val color: Color,
|
||||
val strokeWidth: Float
|
||||
val strokeWidth: Float,
|
||||
val id: String = UUID.randomUUID().toString(),
|
||||
val note: String? = null
|
||||
)
|
||||
|
||||
object AnnotationSerializer {
|
||||
|
|
@ -63,11 +66,15 @@ object AnnotationSerializer {
|
|||
annotations.forEach { (_, list) ->
|
||||
list.forEach { annotation ->
|
||||
val obj = JSONObject()
|
||||
obj.put("id", annotation.id)
|
||||
obj.put("pageIndex", annotation.pageIndex)
|
||||
obj.put("annotationType", annotation.type.name)
|
||||
obj.put("inkType", annotation.inkType.name)
|
||||
obj.put("color", annotation.color.toArgb())
|
||||
obj.put("strokeWidth", annotation.strokeWidth.toDouble())
|
||||
if (!annotation.note.isNullOrBlank()) {
|
||||
obj.put("note", annotation.note)
|
||||
}
|
||||
|
||||
val pointsArray = JSONArray()
|
||||
annotation.points.forEach { p ->
|
||||
|
|
@ -123,7 +130,10 @@ object AnnotationSerializer {
|
|||
pageIndex = pageIndex,
|
||||
points = points,
|
||||
color = Color(colorInt),
|
||||
strokeWidth = strokeWidth
|
||||
strokeWidth = strokeWidth,
|
||||
id = obj.optString("id").takeIf { it.isNotBlank() }
|
||||
?: UUID.randomUUID().toString(),
|
||||
note = obj.optString("note").takeIf { it.isNotBlank() }
|
||||
)
|
||||
|
||||
if (!resultMap.containsKey(pageIndex)) {
|
||||
|
|
@ -278,4 +288,4 @@ object HighlightSerializer {
|
|||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,6 +40,8 @@ class PdfAnnotationRepository(private val context: Context) {
|
|||
Timber.tag("AnnotationSync").d("Start saving local JSON for $bookId. Count: ${annotations.size}")
|
||||
|
||||
if (annotations.isEmpty()) {
|
||||
val file = getFile(bookId)
|
||||
if (file.exists()) file.delete()
|
||||
return@withContext
|
||||
}
|
||||
|
||||
|
|
@ -81,4 +83,4 @@ class PdfAnnotationRepository(private val context: Context) {
|
|||
|
||||
return if (valid) file else null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -114,6 +114,9 @@ interface PdfTextDao {
|
|||
@Query("SELECT pageIndex FROM pdf_search_index WHERE bookId = :bookId")
|
||||
suspend fun getIndexedPageIndices(bookId: String): List<Int>
|
||||
|
||||
@Query("DELETE FROM pdf_search_index WHERE bookId = :bookId AND pageIndex = :pageIndex")
|
||||
suspend fun deletePageText(bookId: String, pageIndex: Int)
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertPageText(entity: PdfSearchIndex)
|
||||
|
||||
|
|
@ -158,4 +161,4 @@ abstract class PdfTextDatabase : RoomDatabase() {
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ import androidx.compose.ui.text.font.FontWeight
|
|||
import androidx.compose.ui.graphics.Color
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import com.aryan.reader.pdf.ReaderDocument
|
||||
|
||||
private const val TAG = "PdfSearchDiag"
|
||||
|
||||
|
|
@ -55,6 +56,11 @@ class PdfTextRepository(context: Context) {
|
|||
private val dao = db.pdfTextDao()
|
||||
private val metaDao = db.pdfMetaDao()
|
||||
|
||||
private suspend fun replacePageText(bookId: String, pageIndex: Int, content: String) {
|
||||
dao.deletePageText(bookId, pageIndex)
|
||||
dao.insertPageText(PdfSearchIndex(bookId = bookId, pageIndex = pageIndex, content = content))
|
||||
}
|
||||
|
||||
suspend fun getPageRatios(bookId: String): List<Float>? {
|
||||
return withContext(Dispatchers.IO) {
|
||||
val meta = metaDao.getMetadata(bookId)
|
||||
|
|
@ -250,7 +256,7 @@ class PdfTextRepository(context: Context) {
|
|||
Timber.tag(TAG).e("Page $pageIndex: Cleaning might have failed. Text still looks like path: $snippetClean")
|
||||
} else if (text.isNotBlank()) {
|
||||
Timber.tag(TAG).v("Page $pageIndex: Inserting valid text ($cleanedLength chars).")
|
||||
dao.insertPageText(PdfSearchIndex(bookId = bookId, pageIndex = pageIndex, content = text))
|
||||
replacePageText(bookId = bookId, pageIndex = pageIndex, content = text)
|
||||
} else {
|
||||
Timber.tag(TAG).i("Page $pageIndex: Text became empty after cleaning. Skipping insertion.")
|
||||
}
|
||||
|
|
@ -279,6 +285,69 @@ class PdfTextRepository(context: Context) {
|
|||
}
|
||||
}
|
||||
|
||||
suspend fun indexReaderPage(
|
||||
bookId: String,
|
||||
document: ReaderDocument,
|
||||
pageIndex: Int,
|
||||
onOcrModelDownloading: () -> Unit = {}
|
||||
): Boolean {
|
||||
return withContext(Dispatchers.IO) {
|
||||
var text = ""
|
||||
var ocrUsed = false
|
||||
|
||||
try {
|
||||
document.openPage(pageIndex)?.use { page ->
|
||||
page.openTextPage().use { textPage ->
|
||||
val count = textPage.textPageCountChars()
|
||||
if (count > 0) {
|
||||
text = textPage.textPageGetText(0, count).orEmpty()
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.tag(TAG).e(e, "ReaderDocument extraction failed for page $pageIndex")
|
||||
}
|
||||
|
||||
if (text.isBlank()) {
|
||||
var bitmap: android.graphics.Bitmap? = null
|
||||
try {
|
||||
document.openPage(pageIndex)?.use { page ->
|
||||
val targetWidth = 1080
|
||||
val pageWidth = page.getPageWidthPoint()
|
||||
val pageHeight = page.getPageHeightPoint()
|
||||
if (pageWidth > 0 && pageHeight > 0) {
|
||||
val aspectRatio = pageWidth.toFloat() / pageHeight.toFloat()
|
||||
val targetHeight = (targetWidth / aspectRatio).toInt().coerceAtLeast(1)
|
||||
bitmap = createBitmap(targetWidth, targetHeight)
|
||||
page.renderPageBitmap(bitmap!!, 0, 0, targetWidth, targetHeight, false)
|
||||
}
|
||||
}
|
||||
|
||||
bitmap?.let {
|
||||
try {
|
||||
val visionText = OcrHelper.extractTextFromBitmap(it, onOcrModelDownloading)
|
||||
text = visionText?.text.orEmpty()
|
||||
ocrUsed = true
|
||||
} finally {
|
||||
it.recycle()
|
||||
bitmap = null
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
bitmap?.recycle()
|
||||
Timber.tag(TAG).e(e, "ReaderDocument OCR failed for page $pageIndex")
|
||||
}
|
||||
}
|
||||
|
||||
val cleaned = cleanIndexedText(text)
|
||||
if (cleaned.isNotBlank()) {
|
||||
replacePageText(bookId = bookId, pageIndex = pageIndex, content = cleaned)
|
||||
}
|
||||
|
||||
ocrUsed && cleaned.isNotEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun hasNativeText(document: PdfDocumentKt, pageIndex: Int): Boolean {
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
|
|
@ -599,4 +668,17 @@ class PdfTextRepository(context: Context) {
|
|||
|
||||
return null
|
||||
}
|
||||
|
||||
private fun cleanIndexedText(raw: String): String {
|
||||
if (raw.isBlank()) return ""
|
||||
var text = raw
|
||||
val patterns = listOf(
|
||||
Regex("(?i)file:/?/?/?\\S+"),
|
||||
Regex("(?i)/data/user/\\d+/\\S+"),
|
||||
Regex("(?i)/storage/emulated/\\d+/\\S+"),
|
||||
Regex("(?i)\\S*com\\.aryan\\.reader\\S*")
|
||||
)
|
||||
patterns.forEach { pattern -> text = text.replace(pattern, " ") }
|
||||
return text.replace(Regex("\\s+"), " ").trim()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue