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:
Aryan 2026-05-15 22:36:51 +05:30 committed by GitHub
parent c0d0e57e79
commit b20ade9946
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
247 changed files with 43321 additions and 7087 deletions

View file

@ -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()
)
}
)
}
}
}

View 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()
}

View file

@ -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
}
}
}
}
}

View file

@ -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
}
}

View file

@ -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
},

View file

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

View file

@ -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(

View file

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

View file

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

View file

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

View file

@ -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,

View file

@ -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")
}
}

View file

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

View file

@ -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
}
}
}

View file

@ -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
}
}
}

View file

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

View file

@ -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()
}
}