book-reader/app/src/main/java/com/aryan/reader/pdf/PdfPageComposable.kt

4885 lines
No EOL
229 KiB
Kotlin

/*
* Episteme Reader - A native Android document reader.
* Copyright (C) 2026 Episteme Authors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* Electronic mail: epistemereader@gmail.com
*/
@file:Suppress(
"RemoveRedundantQualifierName", "COMPOSE_APPLIER_CALL_MISMATCH", "UnusedVariable", "unused"
)
package com.aryan.reader.pdf
import android.graphics.Bitmap
import android.graphics.BitmapShader
import android.graphics.PorterDuff
import android.graphics.PorterDuffColorFilter
import android.graphics.Rect
import android.graphics.RectF
import android.graphics.Shader
import android.util.LruCache
import androidx.activity.compose.BackHandler
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.animation.splineBasedDecay
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.calculateCentroid
import androidx.compose.foundation.gestures.calculatePan
import androidx.compose.foundation.gestures.calculateZoom
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.drag
import androidx.compose.foundation.gestures.waitForUpOrCancellation
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.material3.MaterialTheme
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
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
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.Size
import androidx.compose.ui.geometry.lerp
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.ColorMatrix
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.graphics.drawscope.rotate
import androidx.compose.ui.graphics.drawscope.translate
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.input.pointer.PointerEventTimeoutCancellationException
import androidx.compose.ui.input.pointer.changedToUp
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.positionChanged
import androidx.compose.ui.input.pointer.util.VelocityTracker
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalViewConfiguration
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntRect
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.isSpecified
import androidx.compose.ui.unit.sp
import androidx.compose.ui.util.lerp
import androidx.compose.ui.window.PopupPositionProvider
import androidx.compose.ui.zIndex
import androidx.core.graphics.createBitmap
import androidx.core.graphics.scale
import androidx.core.graphics.set
import com.aryan.reader.R
import com.aryan.reader.SearchResult
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.pdf.ocr.OcrElement
import com.aryan.reader.pdf.ocr.OcrResult
import io.legere.pdfiumandroid.PdfPageObjectType
import io.legere.pdfiumandroid.suspend.PdfDocumentKt
import io.legere.pdfiumandroid.suspend.PdfPageKt
import io.legere.pdfiumandroid.suspend.PdfTextPageKt
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.yield
import timber.log.Timber
import java.util.concurrent.ConcurrentLinkedQueue
import kotlin.coroutines.cancellation.CancellationException
import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.min
import kotlin.math.pow
import kotlin.math.roundToInt
import kotlin.math.sin
import kotlin.math.sqrt
import android.graphics.Color as AndroidColor
import android.graphics.Paint as NativePaint
enum class Handle {
START, END
}
enum class AnnotationType {
INK, TEXT
}
enum class InkType {
PEN, HIGHLIGHTER, HIGHLIGHTER_ROUND, ERASER, FOUNTAIN_PEN, PENCIL, TEXT
}
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)
enum class LinkSource {
ANNOTATION, TEXT_CONTENT
}
data class PageLink(
val highlightBounds: Rect,
val tapBounds: Rect,
val url: String?,
val destPageIdx: Int?,
val source: LinkSource
)
object PdfInkGeometry {
fun calculateFountainPenPoints(
points: List<PdfPoint>, baseWidth: Float, pageWidth: Float, pageHeight: Float
): Pair<List<Offset>, List<Offset>> {
if (points.size < 2) return Pair(emptyList(), emptyList())
if (points.size % 50 == 0) {
Timber.tag("FountainPenDebug").d(
"Calculate Points: PWidth=$pageWidth, PHeight=$pageHeight, BaseW=$baseWidth, Pts=${points.size}"
)
}
val leftSide = mutableListOf<Offset>()
val rightSide = mutableListOf<Offset>()
val computedWidths = FloatArray(points.size)
computedWidths[0] = baseWidth
val velocityFactor = 300f
for (i in 1 until points.size) {
val p1 = points[i - 1]
val p2 = points[i]
val dxNorm = p2.x - p1.x
val dyNorm = p2.y - p1.y
val aspect = if (pageWidth > 0 && pageHeight > 0) pageHeight / pageWidth else 1f
val distNorm = sqrt(dxNorm * dxNorm + (dyNorm * aspect) * (dyNorm * aspect))
val timeDelta = (p2.timestamp - p1.timestamp).coerceAtLeast(1)
val velocityNorm = distNorm / timeDelta
val targetWidth = (baseWidth * (1f / (1f + velocityNorm * velocityFactor))).coerceIn(
baseWidth * 0.2f, baseWidth * 1.4f
)
computedWidths[i] = computedWidths[i - 1] * 0.6f + targetWidth * 0.4f
if (i < 5) {
Timber.tag("FountainPenDebug").v(
"Pt[$i]: dt=$timeDelta, velNorm=$velocityNorm, width=${computedWidths[i]} (base=$baseWidth)"
)
}
}
for (i in 0 until points.size - 1) {
val pCurrent = points[i]
val pNext = points[i + 1]
val curX = pCurrent.x * pageWidth
val curY = pCurrent.y * pageHeight
val nextX = pNext.x * pageWidth
val nextY = pNext.y * pageHeight
val angle = atan2(nextY - curY, nextX - curX)
val normalAngle = angle - (PI / 2f).toFloat()
val w = computedWidths[i] / 2f
leftSide.add(Offset((curX + cos(normalAngle) * w), (curY + sin(normalAngle) * w)))
rightSide.add(Offset((curX - cos(normalAngle) * w), (curY - sin(normalAngle) * w)))
}
val lastIdx = points.lastIndex
val lastP = points[lastIdx]
val prevP = points[lastIdx - 1]
val lastX = lastP.x * pageWidth
val lastY = lastP.y * pageHeight
val prevX = prevP.x * pageWidth
val prevY = prevP.y * pageHeight
val lastAngle = atan2(lastY - prevY, lastX - prevX)
val lastNormal = lastAngle - (PI / 2f).toFloat()
val lastW = computedWidths[lastIdx] / 2f
leftSide.add(Offset((lastX + cos(lastNormal) * lastW), (lastY + sin(lastNormal) * lastW)))
rightSide.add(Offset((lastX - cos(lastNormal) * lastW), (lastY - sin(lastNormal) * lastW)))
return Pair(leftSide, rightSide)
}
}
internal object PdfBitmapPool {
private val pool = ConcurrentLinkedQueue<Bitmap>()
private const val MAX_POOL_SIZE = 4
fun get(size: Int): Bitmap {
val iterator = pool.iterator()
while (iterator.hasNext()) {
val b = iterator.next()
if (b.width == size && b.height == size && !b.isRecycled) {
iterator.remove()
b.eraseColor(AndroidColor.TRANSPARENT)
return b
}
}
return createBitmap(size, size)
}
fun recycle(bitmap: Bitmap) {
if (!bitmap.isRecycled && pool.size < MAX_POOL_SIZE) {
pool.offer(bitmap)
} else {
bitmap.recycle()
}
}
fun clear() {
while (!pool.isEmpty()) {
pool.poll()?.recycle()
}
}
}
internal object PdfThumbnailCache {
private val maxMemory = (Runtime.getRuntime().maxMemory() / 1024).toInt()
private val cacheSize = maxMemory / 8
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 {
return entry.sizeKb
}
}
fun get(pageIndex: Int): Bitmap? {
return memoryCache.get(pageIndex)?.bitmap?.takeUnless { it.isRecycled }
}
fun put(pageIndex: Int, bitmap: Bitmap) {
if (get(pageIndex) == null) {
val sizeKb = (bitmap.allocationByteCount / 1024).coerceAtLeast(1)
memoryCache.put(pageIndex, CacheEntry(bitmap, sizeKb))
}
}
fun clear() {
memoryCache.evictAll()
}
}
@Stable
data class StableHolder<T>(val item: T)
@Stable
data class PageStaticData(
val bitmap: StableHolder<Bitmap?>,
val tiles: StableHolder<List<PdfTile>>,
val effectiveScale: Float,
val centeringOffsetX: Float,
val centeringOffsetY: Float,
val canvasWidth: Float,
val canvasHeight: Float,
val targetWidth: Int,
val targetHeight: Int,
val colorFilter: StableHolder<ColorFilter?>,
val imageScreenRects: StableHolder<List<Rect>>,
val isDarkMode: Boolean
)
@Stable
data class PageSelectionData(
val pageLinks: StableHolder<List<PageLink>>,
val showAllTextHighlights: Boolean,
val actualBitmapWidthPx: Int,
val actualBitmapHeightPx: Int,
val mergedAllTextPageHighlightRects: StableHolder<List<Rect>>,
val mergedTtsHighlightRects: StableHolder<List<Rect>>,
val mergedSearchFocusedRects: StableHolder<List<Rect>>,
val mergedSearchAllRects: StableHolder<List<Rect>>,
val searchHighlightMode: SearchHighlightMode,
val ocrHoverHighlights: StableHolder<List<RectF>>,
val mergedSelectionRects: StableHolder<List<Rect>>,
val centeringOffsetX: Float,
val centeringOffsetY: Float,
val linkHighlightColor: Color,
val scrimColorForTextHighlight: Color,
val allTextPageHighlightColor: Color,
val ttsHighlightColor: Color,
val selectionHighlightColor: Color,
val pageIndex: Int
)
@Suppress("unused")
@Composable
internal fun PdfPageComposable(
pdfDocument: StableHolder<PdfDocumentKt>,
pageIndex: Int,
totalPages: Int,
modifier: Modifier = Modifier,
virtualPage: VirtualPage? = null,
onScaleChanged: (Float) -> Unit,
externalScale: Float = 1f,
showAllTextHighlights: Boolean,
onHighlightLoading: (Boolean) -> Unit,
searchQuery: String = "",
searchHighlightMode: SearchHighlightMode = SearchHighlightMode.ALL,
searchResultToHighlight: SearchResult?,
ocrHoverHighlights: StableHolder<List<RectF>> = StableHolder(emptyList()),
onSingleTap: () -> Unit,
isProUser: Boolean,
onShowDictionaryUpsellDialog: () -> Unit,
onWordSelectedForAiDefinition: (String) -> Unit,
ttsHighlightData: TtsHighlightData?,
onLinkClicked: (String) -> Unit,
onInternalLinkClicked: (Int) -> Unit,
isBookmarked: Boolean,
onOcrStateChange: (Boolean) -> Unit,
onBookmarkClick: () -> Unit,
onOcrModelDownloading: () -> Unit = {},
onTwoFingerSwipe: (direction: Int) -> Unit = {},
placeholderBitmap: Bitmap? = null,
isZoomEnabled: Boolean = true,
isScrolling: Boolean = false,
lazyListState: LazyListState? = null,
isVerticalScroll: Boolean = false,
visualScaleProvider: () -> Float = { 1f },
clearSelectionTrigger: Long = 0L,
onTtsHighlightCenterCalculated: ((Float) -> Unit)? = null,
onSearchHighlightCenterCalculated: ((Float) -> Unit)? = null,
isDarkMode: Boolean = false,
onDoubleTap: ((Offset) -> Unit)? = null,
isEditMode: Boolean = false,
drawingState: PdfDrawingState? = null,
pageAnnotations: () -> List<PdfAnnotation> = { emptyList() },
onDrawStart: (PdfPoint) -> Unit = {},
onDraw: (PdfPoint) -> Unit = {},
onDrawEnd: () -> Unit = {},
visibleScreenRect: () -> IntRect? = { null },
selectedTool: InkType = InkType.PEN,
richTextController: RichTextController? = null,
textBoxes: List<PdfTextBox> = emptyList(),
selectedTextBoxId: String? = null,
onTextBoxChange: (PdfTextBox) -> Unit = {},
onTextBoxSelect: (String) -> Unit = {},
onTextBoxDragStart: (PdfTextBox, Offset, Offset) -> Unit = { _, _, _ -> },
onTextBoxDrag: (Offset) -> Unit = {},
onTextBoxDragEnd: () -> Unit = {},
onDragPageTurn: (Int) -> Unit = {},
draggingBoxId: String? = null,
isVisible: Boolean = true,
) {
SideEffect { Timber.tag("PdfDrawPerf").v("PdfPageComposable Recompose: Page $pageIndex") }
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"
}
}
var isLoadingPage by remember { mutableStateOf(true) }
var pageErrorMessage by remember { mutableStateOf<String?>(null) }
val density = LocalDensity.current
LocalContext.current
val viewConfiguration = LocalViewConfiguration.current
val coroutineScope = rememberCoroutineScope()
Timber.d(
"PdfPageComposable recompose: page=$pageIndex, isScrolling=$isScrolling, visualScale=$visualScaleProvider"
)
var layoutCoordinates by remember { mutableStateOf<LayoutCoordinates?>(null) }
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 currentOnSingleTap by rememberUpdatedState(onSingleTap)
val currentOnDoubleTap by rememberUpdatedState(onDoubleTap)
val effectiveScale = if (isZoomEnabled && !isVerticalScroll) scale else externalScale
val effectiveOffset = if (isZoomEnabled && !isVerticalScroll) offset else Offset.Zero
var eraserPosition by remember { mutableStateOf<Offset?>(null) }
SideEffect {
if (drawingState?.currentAnnotation?.pageIndex == pageIndex) {
Timber.tag("PdfDrawPerf").v(
"PAGE EFFECTIVE SCALE: Page $pageIndex = $effectiveScale (ZoomEnabled=$isZoomEnabled)"
)
}
}
val isPdfPage = virtualPage == null || virtualPage is VirtualPage.PdfPage
val pdfPageIndex = (virtualPage as? VirtualPage.PdfPage)?.pdfIndex ?: pageIndex
var tiles by remember { mutableStateOf<List<PdfTile>>(emptyList()) }
val tileSizeDp = 256.dp
val tileSizePx = with(LocalDensity.current) { tileSizeDp.toPx().toInt() }
SideEffect {
Timber.tag("PdfDrawPerf")
.v("PAGE RECOMPOSE: Page $pageIndex (EffectiveScale: $effectiveScale)")
}
val teardropWidthDp = 24.dp
val teardropHeightDp = 24.dp
val teardropWidthPxState = remember(density) {
derivedStateOf { with(density) { teardropWidthDp.toPx() / visualScaleProvider() } }
}
val teardropHeightPxState = remember(density) {
derivedStateOf { with(density) { teardropHeightDp.toPx() / visualScaleProvider() } }
}
val handleTouchExpansionDp = 8.dp
val handleTouchWidthPxState = remember(density) {
derivedStateOf {
with(density) {
(teardropWidthDp + handleTouchExpansionDp).toPx() / visualScaleProvider()
}
}
}
val handleTouchHeightPxState = remember(density) {
derivedStateOf {
with(density) {
(teardropHeightDp + handleTouchExpansionDp).toPx() / visualScaleProvider()
}
}
}
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) }
var actualBitmapWidthPx by remember { mutableIntStateOf(0) }
var actualBitmapHeightPx by remember { mutableIntStateOf(0) }
var currentPageRotation by remember { mutableIntStateOf(0) }
val canvasWidthPx = remember { mutableFloatStateOf(0f) }
val canvasHeightPx = remember { mutableFloatStateOf(0f) }
val colorFilter = remember(isDarkMode) {
if (isDarkMode) {
val colorMatrix = floatArrayOf(
-1f,
0f,
0f,
0f,
255f,
0f,
-1f,
0f,
0f,
255f,
0f,
0f,
-1f,
0f,
255f,
0f,
0f,
0f,
1f,
0f
)
ColorFilter.colorMatrix(ColorMatrix(colorMatrix))
} else {
null
}
}
val backgroundColor = remember(isDarkMode, isVerticalScroll) {
if (isDarkMode) {
Color(0xFF2A2A2A)
} else {
if (isVerticalScroll) Color.White else Color.Black
}
}
val centeringOffsetX by remember(canvasWidthPx.floatValue, actualBitmapWidthPx) {
derivedStateOf { (canvasWidthPx.floatValue - actualBitmapWidthPx) / 2f }
}
val centeringOffsetY by remember(canvasHeightPx.floatValue, actualBitmapHeightPx) {
derivedStateOf { (canvasHeightPx.floatValue - actualBitmapHeightPx) / 2f }
}
LaunchedEffect(centeringOffsetX, centeringOffsetY, pageIndex) {
Timber.d(
"PdfPageComposable Page $pageIndex | Centering Offset: x=$centeringOffsetX, y=$centeringOffsetY"
)
}
var showMagnifier by remember { mutableStateOf(false) }
var magnifierBitmapCenterTarget by remember { mutableStateOf(Offset.Zero) }
val magnifierZoomFactor = 2.0f
var customMenuState by remember { mutableStateOf<CustomPdfMenuState?>(null) }
val inputScale = if (isZoomEnabled && !isVerticalScroll) scale else 1f
val inputOffset = if (isZoomEnabled && !isVerticalScroll) offset else Offset.Zero
val screenToContentCoordinates: (Offset) -> Offset = { screenOffset ->
val screenCenter = Offset(canvasWidthPx.floatValue / 2f, canvasHeightPx.floatValue / 2f)
val pCanvas = ((screenOffset - inputOffset - screenCenter) / inputScale) + screenCenter
val contentOffset = pCanvas - Offset(centeringOffsetX, centeringOffsetY)
contentOffset
}
val contentToScreenCoordinates: (Offset) -> Offset = { contentOffset ->
val pCanvas = contentOffset + Offset(centeringOffsetX, centeringOffsetY)
val screenCenter = Offset(canvasWidthPx.floatValue / 2f, canvasHeightPx.floatValue / 2f)
val screenOffset = (pCanvas - screenCenter) * inputScale + screenCenter + inputOffset
screenOffset
}
DisposableEffect(Unit) {
onDispose {
val currentBitmap = bitmapState
val cachedBitmap = PdfThumbnailCache.get(pageIndex)
if (currentBitmap != null && !currentBitmap.isRecycled && currentBitmap !== cachedBitmap) {
currentBitmap.recycle()
}
}
}
@Suppress("DEPRECATION") val clipboardManager = LocalClipboardManager.current
// OCR
var ocrVisionTextForSelection by remember { 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 highlightedTextScreenRects by remember { mutableStateOf<List<Rect>>(emptyList()) }
val ttsHighlightColor = Color(0xFFFFECB3).copy(alpha = 0.4f)
var imageScreenRects by remember { mutableStateOf<List<Rect>>(emptyList()) }
var allTextPageHighlightRects by remember { mutableStateOf<List<Rect>>(emptyList()) }
var accumulatedKeyboardOffset by remember { mutableFloatStateOf(0f) }
val allTextPageHighlightColor = if (isDarkMode) {
Color(0xFFFFEB3B).copy(alpha = 0.4f)
} else {
MaterialTheme.colorScheme.primary.copy(alpha = 0f)
}
val scrimColorForTextHighlight = if (isDarkMode) {
Color.Transparent
} else {
Color.Black.copy(alpha = 0.4f)
}
val selectionHighlightColor = Color(0x6633B5E5)
val mergedSelectionRects =
remember(selectedWordScreenRects) { mergeRectsIntoLines(selectedWordScreenRects) }
val mergedTtsHighlightRects =
remember(highlightedTextScreenRects, centeringOffsetX, centeringOffsetY) {
mergeRectsIntoLines(highlightedTextScreenRects)
}
val mergedAllTextPageHighlightRects =
remember(allTextPageHighlightRects, centeringOffsetX, centeringOffsetY) {
mergeRectsIntoLines(allTextPageHighlightRects)
}
var searchHighlightRects by remember { mutableStateOf<List<Rect>>(emptyList()) }
val searchHighlightColor = Color(0xFFFFAB00).copy(alpha = 0.5f)
val mergedSearchHighlightRects =
remember(searchHighlightRects) { mergeRectsIntoLines(searchHighlightRects) }
var pageLinks by remember { mutableStateOf<List<PageLink>>(emptyList()) }
val linkHighlightColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f)
val linkVerticalPaddingPx = remember(density) { with(density) { 10.dp.toPx().toInt() } }
LaunchedEffect(pageIndex) {
scale = 1f
offset = Offset.Zero
onScaleChanged(1f)
}
LaunchedEffect(isPerformingOcrForSelection) { onOcrStateChange(isPerformingOcrForSelection) }
LaunchedEffect(
showAllTextHighlights, pageIndex, pdfDocumentItem, actualBitmapWidthPx, scale, isScrolling, virtualPage
) {
if (!isPdfPage) {
if (allTextPageHighlightRects.isNotEmpty()) {
allTextPageHighlightRects = emptyList()
}
return@LaunchedEffect
}
if (isScrolling) {
return@LaunchedEffect
}
if (effectiveScale > 1f) {
if (allTextPageHighlightRects.isNotEmpty()) {
allTextPageHighlightRects = emptyList()
}
return@LaunchedEffect
}
if (!showAllTextHighlights) {
if (allTextPageHighlightRects.isNotEmpty()) {
allTextPageHighlightRects = emptyList()
}
return@LaunchedEffect
}
if (actualBitmapWidthPx == 0 || actualBitmapHeightPx == 0) {
return@LaunchedEffect
}
onHighlightLoading(true)
var rects: List<Rect> = emptyList()
var pdfiumSucceeded = false
var tempPage: PdfPageKt? = null
try {
withContext(Dispatchers.IO) {
tempPage = pdfDocumentItem.openPage(pdfPageIndex)
tempPage.openTextPage().use { textPage ->
val charCount = textPage.textPageCountChars()
if (charCount > 0) {
val pdfRectsF =
textPage.textPageGetRectsForRanges(intArrayOf(0, charCount))?.map {
it.rect
} ?: emptyList()
if (pdfRectsF.isNotEmpty()) {
val mappedScreenRects = pdfRectsF.mapNotNull { pdfRectF ->
tempPage.mapRectToDevice(
startX = 0,
startY = 0,
sizeX = actualBitmapWidthPx,
sizeY = actualBitmapHeightPx,
rotate = currentPageRotation,
coords = pdfRectF
).takeIf { it.width() > 0 && it.height() > 0 }
}
if (mappedScreenRects.isNotEmpty()) {
rects = mappedScreenRects
pdfiumSucceeded = true
Timber.d(
"Found ${rects.size} rects for all-text highlight via PDFium."
)
}
} else {
Timber.d(
"PDFium's textPageGetRectsForRanges returned no rects for page $pageIndex."
)
}
} else {
Timber.d("PDFium found 0 characters on page $pageIndex.")
}
}
}
} catch (e: Exception) {
Timber.e(e, "Error getting all-text highlights via PDFium")
} finally {
withContext(NonCancellable) {
withContext(Dispatchers.IO) {
try {
tempPage?.close()
} catch (e: Exception) {
Timber.w("Error closing tempPage in highlights: ${e.message}")
}
}
}
}
if (!pdfiumSucceeded && bitmapState != null) {
Timber.d("PDFium failed for all-text highlight, trying OCR.")
try {
val visionText =
OcrHelper.extractTextFromBitmap(bitmapState!!, onOcrModelDownloading)
if (visionText != null) {
val ocrRects = visionText.textBlocks.flatMap { block ->
block.lines.asSequence().mapNotNull { it.boundingBox }
}.toList()
if (ocrRects.isNotEmpty()) {
rects = ocrRects
Timber.d("Found ${rects.size} rects for all-text highlight via OCR.")
} else {
Timber.d("OCR ran but found no text lines for page $pageIndex.")
}
}
} catch (e: Exception) {
Timber.e(e, "Error getting all-text highlights via OCR")
}
}
allTextPageHighlightRects = rects
onHighlightLoading(false)
}
LaunchedEffect(pageIndex, pdfDocumentItem, actualBitmapWidthPx, actualBitmapHeightPx, virtualPage) {
if (!isPdfPage) {
if (pageLinks.isNotEmpty()) pageLinks = emptyList()
return@LaunchedEffect
}
if (actualBitmapWidthPx == 0 || actualBitmapHeightPx == 0) {
if (pageLinks.isNotEmpty()) pageLinks = emptyList()
return@LaunchedEffect
}
Timber.d(
"LaunchedEffect: Starting link fetch for page $pageIndex. Bitmap size: ${actualBitmapWidthPx}x${actualBitmapHeightPx}"
)
withContext(Dispatchers.IO) {
val allLinks = mutableListOf<PageLink>()
try {
pdfDocumentItem.openPage(pdfPageIndex).use { page ->
try {
val annotationLinks = page.getPageLinks()
Timber.d("Method 1 (getPageLinks) returned ${annotationLinks.size} links.")
if (annotationLinks.isNotEmpty()) {
val mappedAnnotationLinks = annotationLinks.mapNotNull { link ->
val uri = link.uri
val destPageIdx = link.destPageIdx
val bounds = link.bounds
if (uri != null || (destPageIdx != null && destPageIdx >= 0)) {
val deviceRect = page.mapRectToDevice(
startX = 0,
startY = 0,
sizeX = actualBitmapWidthPx,
sizeY = actualBitmapHeightPx,
rotate = currentPageRotation,
coords = bounds
)
Timber.d(
"[Method 1] Link '$uri' | PDF Bounds: $bounds | Mapped Device Rect: $deviceRect"
)
if (deviceRect.width() > 0 && deviceRect.height() > 0) {
val tapRect = Rect(
deviceRect.left,
deviceRect.top - linkVerticalPaddingPx,
deviceRect.right,
deviceRect.bottom + linkVerticalPaddingPx
)
Timber.d(
"[Method 1] Padded Tap Rect: $tapRect (Padding: $linkVerticalPaddingPx)"
)
PageLink(
highlightBounds = deviceRect,
tapBounds = tapRect,
url = uri,
destPageIdx = destPageIdx,
source = LinkSource.ANNOTATION
)
} else null
} else null
}
allLinks.addAll(mappedAnnotationLinks)
}
} catch (e: Exception) {
Timber.e(e, "Error fetching annotation links")
}
// --- METHOD 2: Get links detected within the text content
// ---
try {
page.openTextPage().use { textPage ->
textPage.loadWebLink().use { webLinks ->
val webLinkCount = webLinks.countWebLinks()
Timber.d("Method 2 (loadWebLink) returned $webLinkCount links.")
for (linkIndex in 0 until webLinkCount) {
val rawUrl = webLinks.getURL(linkIndex, 2048)
val url = rawUrl?.substringBefore('\u0000')
if (url.isNullOrBlank()) continue
val rectCount = webLinks.countRects(linkIndex)
for (rectIndex in 0 until rectCount) {
val pdfRect = webLinks.getRect(linkIndex, rectIndex)
val deviceRect = page.mapRectToDevice(
startX = 0,
startY = 0,
sizeX = actualBitmapWidthPx,
sizeY = actualBitmapHeightPx,
rotate = currentPageRotation,
coords = pdfRect
)
Timber.d(
"[Method 2] Link '$url' | PDF Rect: $pdfRect | Mapped Device Rect: $deviceRect"
)
if (deviceRect.width() > 0 && deviceRect.height() > 0) {
val tapRect = Rect(
deviceRect.left,
deviceRect.top - linkVerticalPaddingPx,
deviceRect.right,
deviceRect.bottom + linkVerticalPaddingPx
)
Timber.d(
"[Method 2] Padded Tap Rect: $tapRect (Padding: $linkVerticalPaddingPx)"
)
allLinks.add(
PageLink(
highlightBounds = deviceRect,
tapBounds = tapRect,
url = url,
destPageIdx = null,
source = LinkSource.TEXT_CONTENT
)
)
}
}
}
}
}
} catch (e: Exception) {
Timber.e(e, "Error fetching web links from text page")
}
pageLinks = allLinks
Timber.d(
"Finished fetching. Stored a total of ${allLinks.size} links in state."
)
}
} catch (e: Exception) {
Timber.e(e, "Failed to open page $pdfPageIndex for link fetching")
pageLinks = emptyList()
}
}
}
LaunchedEffect(isVisible, pageIndex) {
if (!isVisible && !isVerticalScroll) {
if (bitmapState != null) {
Timber.d("Page $pageIndex hidden. Releasing bitmap to save memory.")
val old = bitmapState
bitmapState = null
@Suppress("ControlFlowWithEmptyBody") if (old != null && old !== PdfThumbnailCache.get(pageIndex)) { }
}
}
}
LaunchedEffect(
effectiveScale,
effectiveOffset,
actualBitmapWidthPx,
actualBitmapHeightPx,
canvasWidthPx.floatValue,
canvasHeightPx.floatValue,
isVerticalScroll,
isScrolling,
virtualPage
) {
if (effectiveScale <= 1f) {
if (tiles.isNotEmpty()) {
val oldTiles = tiles
tiles = emptyList()
withContext(Dispatchers.IO) {
oldTiles.forEach { PdfBitmapPool.recycle(it.bitmap) }
}
}
return@LaunchedEffect
}
val screenWidth = canvasWidthPx.floatValue
val screenHeight = canvasHeightPx.floatValue
if (actualBitmapWidthPx == 0 || actualBitmapHeightPx == 0 || screenWidth == 0f || screenHeight == 0f) return@LaunchedEffect
var page: PdfPageKt? = null
if (!isPdfPage) {
if (tiles.isNotEmpty()) {
val oldTiles = tiles
tiles = emptyList()
withContext(Dispatchers.IO) {
oldTiles.forEach { PdfBitmapPool.recycle(it.bitmap) }
}
}
return@LaunchedEffect
}
try {
page = withContext(Dispatchers.IO) { pdfDocumentItem.openPage(pdfPageIndex) }
snapshotFlow { visibleScreenRect() }.collectLatest { currentVisibleRect ->
val tileCalcStart = System.nanoTime()
if (!isActive) return@collectLatest
if (isScrolling && effectiveScale > 1f) {
return@collectLatest
}
val pxTl: Float
val pxBr: Float
val pyTl: Float
val pyBr: Float
if (isVerticalScroll) {
if (currentVisibleRect != null) {
pxTl = currentVisibleRect.left.toFloat()
pyTl = currentVisibleRect.top.toFloat()
pxBr = currentVisibleRect.right.toFloat()
pyBr = currentVisibleRect.bottom.toFloat()
} else {
if (tiles.isNotEmpty()) {
val oldTiles = tiles
tiles = emptyList()
withContext(Dispatchers.IO) {
oldTiles.forEach { PdfBitmapPool.recycle(it.bitmap) }
}
}
return@collectLatest
}
} else {
val pivotX = screenWidth / 2f
val pivotY = screenHeight / 2f
pxTl =
(((0 - effectiveOffset.x) - pivotX) / effectiveScale + pivotX) - centeringOffsetX
pyTl =
(((0 - effectiveOffset.y) - pivotY) / effectiveScale + pivotY) - centeringOffsetY
pxBr =
(((screenWidth - effectiveOffset.x) - pivotX) / effectiveScale + pivotX) - centeringOffsetX
pyBr =
(((screenHeight - effectiveOffset.y) - pivotY) / effectiveScale + pivotY) - centeringOffsetY
}
val visibleBitmapRect = Rect(pxTl.toInt(), pyTl.toInt(), pxBr.toInt(), pyBr.toInt())
val inset = if (effectiveScale > 2f) 0 else -tileSizePx
visibleBitmapRect.inset(inset, inset)
val requiredTileIds = mutableSetOf<Int>()
val cols = (actualBitmapWidthPx + tileSizePx - 1) / tileSizePx
val startCol = (visibleBitmapRect.left / tileSizePx).coerceAtLeast(0)
val endCol =
((visibleBitmapRect.right + tileSizePx - 1) / tileSizePx).coerceAtMost(cols)
val startRow = (visibleBitmapRect.top / tileSizePx).coerceAtLeast(0)
val endRow =
((visibleBitmapRect.bottom + tileSizePx - 1) / tileSizePx).coerceAtMost(
(actualBitmapHeightPx + tileSizePx - 1) / tileSizePx
)
for (row in startRow until endRow) {
for (col in startCol until endCol) {
requiredTileIds.add(row * cols + col)
}
}
val currentTileIds = tiles.map { it.tileId }.toSet()
val duration = (System.nanoTime() - tileCalcStart) / 1_000_000f
if (duration > 2f) {
Timber.tag("PdfPerformance").d(
"Page $pageIndex | Tile Calc took ${duration}ms | Tiles Needed: ${requiredTileIds.size}"
)
}
if (requiredTileIds != currentTileIds) {
val tilesToRenderIds = requiredTileIds - currentTileIds
val tilesToRecycleIds = currentTileIds - requiredTileIds
if (tilesToRecycleIds.isNotEmpty()) {
val (tilesToRecycle, tilesToKeep) = tiles.partition { it.tileId in tilesToRecycleIds }
tiles = tilesToKeep
withContext(Dispatchers.IO) {
tilesToRecycle.forEach { PdfBitmapPool.recycle(it.bitmap) }
}
}
if (tilesToRenderIds.isNotEmpty()) {
withContext(Dispatchers.IO) {
tilesToRenderIds.forEach { tileId ->
if (!isActive) return@forEach
yield()
val row = tileId / cols
val col = tileId % cols
val tileRect = Rect(
col * tileSizePx,
row * tileSizePx,
(col + 1) * tileSizePx,
(row + 1) * tileSizePx
)
val tileRenderSize =
(tileSizePx * effectiveScale).toInt().coerceAtLeast(1)
val tileBitmap = PdfBitmapPool.get(tileRenderSize)
val fullPageRenderWidth =
(actualBitmapWidthPx * effectiveScale).toInt()
val fullPageRenderHeight =
(actualBitmapHeightPx * effectiveScale).toInt()
val tileRenderX = (col * tileSizePx * effectiveScale).toInt()
val tileRenderY = (row * tileSizePx * effectiveScale).toInt()
page.renderPageBitmap(
bitmap = tileBitmap,
startX = -tileRenderX,
startY = -tileRenderY,
drawSizeX = fullPageRenderWidth,
drawSizeY = fullPageRenderHeight,
renderAnnot = false
)
val newTile = PdfTile(tileBitmap, tileRect, tileId)
withContext(Dispatchers.Main) {
if (isActive) {
tiles = tiles + newTile
} else {
PdfBitmapPool.recycle(tileBitmap)
}
}
}
}
}
}
}
} catch (e: Exception) {
if (e is CancellationException) throw e
Timber.e(e, "Error during tiling process for page $pageIndex")
} finally {
withContext(NonCancellable) {
withContext(Dispatchers.IO) {
try {
page?.close()
} catch (e: Exception) {
Timber.w("Error closing tile page: ${e.message}")
}
}
}
}
}
LaunchedEffect(pageIndex, pdfDocumentItem, actualBitmapWidthPx, actualBitmapHeightPx, virtualPage) {
if (!isPdfPage) {
imageScreenRects = emptyList()
return@LaunchedEffect
}
if (actualBitmapWidthPx == 0 || actualBitmapHeightPx == 0) {
imageScreenRects = emptyList()
return@LaunchedEffect
}
withContext(Dispatchers.IO) {
try {
pdfDocumentItem.openPage(pdfPageIndex).use { page ->
val objects = page.getPageObjects()
val foundImageRects =
objects.filter { it.type == PdfPageObjectType.IMAGE }.mapNotNull { obj ->
val pdfRect = obj.bounds
val screenRect = page.mapRectToDevice(
startX = 0,
startY = 0,
sizeX = actualBitmapWidthPx,
sizeY = actualBitmapHeightPx,
rotate = currentPageRotation,
coords = pdfRect
)
if (screenRect.width() > 0 && screenRect.height() > 0) screenRect
else null
}
imageScreenRects = foundImageRects
}
} catch (e: Exception) {
Timber.e(e, "Error fetching image objects for page $pageIndex")
imageScreenRects = emptyList()
}
}
}
var searchFocusedRects by remember { mutableStateOf<List<Rect>>(emptyList()) }
var searchAllRects by remember { mutableStateOf<List<Rect>>(emptyList()) }
var keyboardAdjustmentOriginalOffset by remember { mutableStateOf<Float?>(null) }
val mergedSearchFocusedRects = remember(searchFocusedRects) { searchFocusedRects }
val mergedSearchAllRects = remember(searchAllRects) { searchAllRects }
// Colors
val searchFocusedColor = Color(0xFFFF6D00).copy(alpha = 0.5f) // Bold Orange
val searchAllColor = Color(0xFFFFEB3B).copy(alpha = 0.4f) // Standard Yellow
LaunchedEffect(
searchQuery, searchResultToHighlight, actualBitmapWidthPx, actualBitmapHeightPx, virtualPage
) {
if (!isPdfPage) {
searchFocusedRects = emptyList()
searchAllRects = emptyList()
return@LaunchedEffect
}
if (actualBitmapWidthPx == 0 || searchQuery.isBlank()) {
searchFocusedRects = emptyList()
searchAllRects = emptyList()
return@LaunchedEffect
}
val isTargetOnPage = searchResultToHighlight?.locationInSource == pageIndex
var foundAll: List<Rect> = emptyList()
var foundFocused: List<Rect> = emptyList()
withContext(Dispatchers.IO) {
try {
pdfDocumentItem.openPage(pdfPageIndex).use { page ->
page.openTextPage().use { textPage ->
val charCount = textPage.textPageCountChars()
if (charCount > 0) {
val fullText = textPage.textPageGetText(0, charCount)
if (!fullText.isNullOrBlank()) {
val occurrences = mutableListOf<Int>()
try {
val regex = Regex("(?i)\\b${Regex.escape(searchQuery)}")
val matches = regex.findAll(fullText)
matches.forEach { matchResult ->
occurrences.add(matchResult.range.first)
}
} catch (_: Exception) {
var lastIndex = -1
while (true) {
lastIndex = fullText.indexOf(
searchQuery, lastIndex + 1, ignoreCase = true
)
if (lastIndex == -1) break
occurrences.add(lastIndex)
}
}
if (occurrences.isNotEmpty()) {
val queryLen = searchQuery.length
val allRectsRaw = occurrences.flatMap { startIndex ->
textPage.textPageGetRectsForRanges(
intArrayOf(startIndex, queryLen)
)?.map { it.rect } ?: emptyList()
}
foundAll = allRectsRaw.mapNotNull { pdfRectF ->
page.mapRectToDevice(
startX = 0,
startY = 0,
sizeX = actualBitmapWidthPx,
sizeY = actualBitmapHeightPx,
rotate = currentPageRotation,
coords = pdfRectF
).takeIf {
it.width() > 0 && it.height() > 0
}
}
if (isTargetOnPage) {
val targetIdx =
searchResultToHighlight.occurrenceIndexInLocation
if (targetIdx >= 0 && targetIdx < occurrences.size) {
val startIndex = occurrences[targetIdx]
val focusedRectsRaw =
textPage.textPageGetRectsForRanges(
intArrayOf(startIndex, queryLen)
)?.map { it.rect } ?: emptyList()
foundFocused = focusedRectsRaw.mapNotNull { pdfRectF ->
page.mapRectToDevice(
startX = 0,
startY = 0,
sizeX = actualBitmapWidthPx,
sizeY = actualBitmapHeightPx,
rotate = currentPageRotation,
coords = pdfRectF
).takeIf {
it.width() > 0 && it.height() > 0
}
}
}
}
}
}
}
}
}
} catch (e: Exception) {
Timber.e(e, "Search Highlight: Failed to get rects for page $pageIndex")
}
}
searchAllRects = foundAll
searchFocusedRects = foundFocused
if (foundFocused.isNotEmpty() && onSearchHighlightCenterCalculated != null) {
val unionRect = RectF()
if (foundFocused.isNotEmpty()) {
unionRect.set(foundFocused[0])
for (i in 1 until foundFocused.size) {
unionRect.union(RectF(foundFocused[i]))
}
onSearchHighlightCenterCalculated(unionRect.centerY())
}
}
}
fun findClosestOcrSymbolIndex(symbols: List<OcrSymbolInfo>, x: Float, y: Float): Int {
if (symbols.isEmpty()) return -1
val containingSymbolIndex = symbols.indexOfFirst {
it.symbol.boundingBox?.contains(x.toInt(), y.toInt()) == true
}
if (containingSymbolIndex != -1) return containingSymbolIndex
var minDistanceSq = Float.MAX_VALUE
var closestSymbolIndex = -1
symbols.forEachIndexed { index, info ->
info.symbol.boundingBox?.let { box ->
val distSq = (x - box.exactCenterX()).pow(2) + (y - box.exactCenterY()).pow(2)
if (distSq < minDistanceSq) {
minDistanceSq = distSq
closestSymbolIndex = index
}
}
}
return closestSymbolIndex
}
fun updateOcrSymbolSelectionRectsAndHandles(indices: Pair<Int, Int>?) {
if (indices == null || allOcrSymbolsForSelection.isEmpty() || indices.first >= indices.second) {
selectedWordScreenRects = emptyList()
startHandleContentPosition.value = null
endHandleContentPosition.value = null
return
}
val selectedSymbols = allOcrSymbolsForSelection.subList(indices.first, indices.second)
if (selectedSymbols.isEmpty()) {
selectedWordScreenRects = emptyList()
startHandleContentPosition.value = null
endHandleContentPosition.value = null
return
}
selectedWordScreenRects = selectedSymbols.mapNotNull { it.symbol.boundingBox }
val firstRect = selectedSymbols.first().symbol.boundingBox!!
val lastRect = selectedSymbols.last().symbol.boundingBox!!
startHandleContentPosition.value =
Offset(firstRect.left.toFloat(), firstRect.bottom.toFloat())
endHandleContentPosition.value = Offset(lastRect.right.toFloat(), lastRect.bottom.toFloat())
}
suspend fun updateSelectionVisuals(
doc: PdfDocumentKt,
pageIdx: Int,
charRange: Pair<Int, Int>?,
currentBitmapWidth: Int,
currentBitmapHeight: Int,
rotation: Int,
providedPage: PdfPageKt? = null,
providedTextPage: PdfTextPageKt? = null
) {
if (charRange == null || currentBitmapWidth == 0 || currentBitmapHeight == 0) {
selectedWordScreenRects = emptyList()
startHandleContentPosition.value = null
endHandleContentPosition.value = null
return
}
var localPage: PdfPageKt? = null
var localTextPage: PdfTextPageKt? = null
try {
val pageToUse: PdfPageKt
val textPageToUse: PdfTextPageKt
if (providedPage != null && providedTextPage != null) {
pageToUse = providedPage
textPageToUse = providedTextPage
} else {
localPage = doc.openPage(pageIdx)
localTextPage = localPage.openTextPage()
pageToUse = localPage
textPageToUse = localTextPage
}
val (startIndex, endIndex) = charRange
if (startIndex >= endIndex) {
selectedWordScreenRects = emptyList()
startHandleContentPosition.value = null
endHandleContentPosition.value = null
return
}
val length = endIndex - startIndex
val wordPdfRectsF =
textPageToUse.textPageGetRectsForRanges(intArrayOf(startIndex, length))?.map {
it.rect
} ?: emptyList()
if (wordPdfRectsF.isNotEmpty()) {
val mappedScreenRects = wordPdfRectsF.mapNotNull { pdfRectF ->
val screenRect = pageToUse.mapRectToDevice(
startX = 0,
startY = 0,
sizeX = currentBitmapWidth,
sizeY = currentBitmapHeight,
rotate = rotation,
coords = pdfRectF
)
if (screenRect.width() > 0 && screenRect.height() > 0) screenRect
else {
Timber.d(
"updateSelectionVisuals: Filtering out invalid screen rect: $screenRect"
)
null
}
}
selectedWordScreenRects = mappedScreenRects
if (mappedScreenRects.isNotEmpty()) {
val firstRect = mappedScreenRects.first()
val lastRect = mappedScreenRects.last()
startHandleContentPosition.value =
Offset(firstRect.left.toFloat(), firstRect.bottom.toFloat())
endHandleContentPosition.value =
Offset(lastRect.right.toFloat(), lastRect.bottom.toFloat())
} else {
selectedWordScreenRects = emptyList()
startHandleContentPosition.value = null
endHandleContentPosition.value = null
}
} else {
selectedWordScreenRects = emptyList()
startHandleContentPosition.value = null
endHandleContentPosition.value = null
}
} catch (e: Exception) {
Timber.e(e, "Error updating selection visuals for page $pageIdx, range $charRange: $e")
selectedWordScreenRects = emptyList()
startHandleContentPosition.value = null
endHandleContentPosition.value = null
} finally {
if (providedPage == null && providedTextPage == null) {
withContext(NonCancellable) {
withContext(Dispatchers.IO) {
try {
localTextPage?.close()
} catch (_: Exception) {
}
try {
localPage?.close()
} catch (_: Exception) {
}
}
}
}
}
}
LaunchedEffect(clearSelectionTrigger) {
if (clearSelectionTrigger != 0L) {
if (customMenuState != null || selectionCharRange.value != null || ocrSelectionSymbolIndices != null) {
customMenuState = null
selectionCharRange.value = null
ocrSelectionSymbolIndices = null
updateSelectionVisuals(
pdfDocumentItem,
pdfPageIndex,
null,
actualBitmapWidthPx,
actualBitmapHeightPx,
currentPageRotation
)
}
}
}
BoxWithConstraints(
modifier = modifier
.onGloballyPositioned { layoutCoordinates = it }
.pointerInput(
pdfDocumentItem,
pageIndex,
actualBitmapWidthPx,
actualBitmapHeightPx,
currentPageRotation,
centeringOffsetX,
centeringOffsetY,
scale,
offset,
isTransforming,
isVerticalScroll,
inputScale,
inputOffset,
isEditMode
) {
if (isTransforming) return@pointerInput
if (isEditMode) return@pointerInput
awaitEachGesture {
val down = awaitFirstDown(requireUnconsumed = false)
var dragStartedOnHandle = false
val shp = startHandleContentPosition.value
val ehp = endHandleContentPosition.value
val handleTouchWidthPx = handleTouchWidthPxState.value
val handleTouchHeightPx = handleTouchHeightPxState.value
if (shp != null) {
val handleScreenPos = contentToScreenCoordinates(shp)
val handleRect = Rect(
(handleScreenPos.x - handleTouchWidthPx / 2).toInt(),
handleScreenPos.y.toInt(),
(handleScreenPos.x + handleTouchWidthPx / 2).toInt(),
(handleScreenPos.y + handleTouchHeightPx).toInt()
)
if (handleRect.contains(
down.position.x.toInt(), down.position.y.toInt()
)
) {
activeDraggingHandle = Handle.START
dragStartedOnHandle = true
down.consume()
customMenuState = null
Timber.d("PointerInput: Press on START teardrop handle")
}
}
if (!dragStartedOnHandle && ehp != null) {
val handleScreenPos = contentToScreenCoordinates(ehp)
val handleRect = Rect(
(handleScreenPos.x - handleTouchWidthPx / 2).toInt(),
handleScreenPos.y.toInt(),
(handleScreenPos.x + handleTouchWidthPx / 2).toInt(),
(handleScreenPos.y + handleTouchHeightPx).toInt()
)
if (handleRect.contains(
down.position.x.toInt(), down.position.y.toInt()
)
) {
activeDraggingHandle = Handle.END
dragStartedOnHandle = true
down.consume()
customMenuState = null
Timber.d("PointerInput: Press on END teardrop handle")
}
}
if (selectedTool == InkType.ERASER) {
eraserPosition = down.position
}
if (dragStartedOnHandle) {
Timber.d(
"PointerInput: Drag started on handle $activeDraggingHandle"
)
showMagnifier = true
customMenuState = null
val rects = selectedWordScreenRects
// (keep the magnifier target logic here as
// is)
if (rects.isNotEmpty()) {
val relevantRect = when (activeDraggingHandle) {
Handle.START -> rects.first()
Handle.END -> rects.last()
else -> null
}
val handlePos = when (activeDraggingHandle) {
Handle.START -> startHandleContentPosition.value
Handle.END -> endHandleContentPosition.value
else -> null
}
if (relevantRect != null && handlePos != null) {
magnifierBitmapCenterTarget = Offset(
x = handlePos.x, y = relevantRect.exactCenterY()
)
}
} else {
val initialHandlePos = when (activeDraggingHandle) {
Handle.START -> startHandleContentPosition.value
Handle.END -> endHandleContentPosition.value
else -> null
}
initialHandlePos?.let { contentPos ->
magnifierBitmapCenterTarget = contentPos
}
}
val dragEventChannel = Channel<Offset>(Channel.CONFLATED)
coroutineScope.launch(Dispatchers.IO) {
var pageForDrag: PdfPageKt? = null
var textPageForDrag: PdfTextPageKt? = null
try {
if (selectionMethodUsed == PdfSelectionMethod.PDFIUM) {
if (isPdfPage) {
pageForDrag = pdfDocumentItem.openPage(
pdfPageIndex
)
textPageForDrag = pageForDrag.openTextPage()
}
}
for (dragPosition in dragEventChannel) {
if (activeDraggingHandle != null) {
if (selectionMethodUsed == PdfSelectionMethod.OCR) {
if (allOcrSymbolsForSelection.isNotEmpty()) {
val touchInContentCoords =
screenToContentCoordinates(
dragPosition
)
val touchXInBitmap = touchInContentCoords.x
val touchYInBitmap = touchInContentCoords.y
val targetSymbolIndex = findClosestOcrSymbolIndex(
allOcrSymbolsForSelection,
touchXInBitmap,
touchYInBitmap
)
if (targetSymbolIndex != -1) {
ocrSelectionSymbolIndices?.let { currentRange ->
val (start, end) = currentRange
when (activeDraggingHandle) {
Handle.START -> {
if (targetSymbolIndex >= end - 1) {
activeDraggingHandle =
Handle.END
ocrSelectionSymbolIndices =
Pair(
end - 1,
targetSymbolIndex + 1
)
} else {
ocrSelectionSymbolIndices =
Pair(
targetSymbolIndex, end
)
}
}
Handle.END -> {
if (targetSymbolIndex + 1 <= start + 1) {
activeDraggingHandle =
Handle.START
ocrSelectionSymbolIndices =
Pair(
targetSymbolIndex,
start + 1
)
} else {
ocrSelectionSymbolIndices =
Pair(
start,
targetSymbolIndex + 1
)
}
}
else -> {}
}
withContext(
Dispatchers.Main
) {
updateOcrSymbolSelectionRectsAndHandles(
ocrSelectionSymbolIndices
)
}
}
}
withContext(Dispatchers.Main) {
val currentActiveHandle = activeDraggingHandle
val currentRects = selectedWordScreenRects
if (currentRects.isNotEmpty()) {
val relevantRect =
when (currentActiveHandle) {
Handle.START -> currentRects.first()
Handle.END -> currentRects.last()
null -> null
}
val handleContentPos =
when (currentActiveHandle) {
Handle.START -> startHandleContentPosition.value
Handle.END -> endHandleContentPosition.value
null -> null
}
if (relevantRect != null && handleContentPos != null) {
magnifierBitmapCenterTarget = Offset(
x = handleContentPos.x,
y = relevantRect.exactCenterY()
)
}
}
}
}
} else {
// PDFIUM Logic
if (pageForDrag != null && textPageForDrag != null) {
val touchInContentCoords =
screenToContentCoordinates(
dragPosition
)
val pdfCoords = pageForDrag.mapDeviceCoordsToPage(
startX = 0,
startY = 0,
sizeX = actualBitmapWidthPx,
sizeY = actualBitmapHeightPx,
rotate = currentPageRotation,
deviceX = touchInContentCoords.x.toInt(),
deviceY = touchInContentCoords.y.toInt()
)
val charTolerance = 10.0
var charIndexForUpdate =
textPageForDrag.textPageGetCharIndexAtPos(
x = pdfCoords.x.toDouble(),
y = pdfCoords.y.toDouble(),
xTolerance = charTolerance,
yTolerance = charTolerance
)
if (charIndexForUpdate == -1 && activeDraggingHandle != null) {
val pageWidthPdfUnits =
pageForDrag.getPageWidthPoint()
val wideSearchXTolerance =
pageWidthPdfUnits.toDouble()
var ySearchCoordinate = pdfCoords.y.toDouble()
val currentRange = selectionCharRange.value
if (currentRange != null) {
val pageTotalChars =
textPageForDrag.textPageCountChars()
if (pageTotalChars > 0) {
val anchorCharIndex =
if (activeDraggingHandle == Handle.START) {
currentRange.first
} else {
(currentRange.second - 1).coerceAtLeast(
0
)
}
if (anchorCharIndex in 0..<pageTotalChars) {
val anchorCharBox =
textPageForDrag.textPageGetCharBox(
anchorCharIndex
)
if (anchorCharBox != null) {
ySearchCoordinate =
((anchorCharBox.top + anchorCharBox.bottom) / 2.0)
}
}
}
}
val wideSearchYTolerance = charTolerance * 1.5
if (wideSearchXTolerance > 0) {
charIndexForUpdate =
textPageForDrag.textPageGetCharIndexAtPos(
x = pdfCoords.x.toDouble(),
y = ySearchCoordinate,
xTolerance = wideSearchXTolerance,
yTolerance = wideSearchYTolerance
)
}
}
if (charIndexForUpdate != -1) {
val pageCharCount =
textPageForDrag.textPageCountChars()
val currentRange = selectionCharRange.value
if (currentRange != null) {
val (currentStart, currentEnd) = currentRange
var newRange: Pair<Int, Int>? = null
var newHandle = activeDraggingHandle
when (activeDraggingHandle) {
Handle.START -> {
val newStart =
charIndexForUpdate.coerceIn(
0, pageCharCount - 1
)
if (newStart >= currentEnd - 1 && pageCharCount > 0) {
val tempOldEndCharIndex =
(currentEnd - 1).coerceAtLeast(
0
)
newHandle = Handle.END
newRange = Pair(
tempOldEndCharIndex,
(newStart + 1).coerceAtMost(
pageCharCount
)
)
} else {
newRange = Pair(
newStart, currentEnd
)
}
}
Handle.END -> {
val newEnd =
(charIndexForUpdate + 1).coerceIn(
1, pageCharCount
)
if (newEnd <= currentStart + 1 && pageCharCount > 0) {
newHandle = Handle.START
newRange = Pair(
(newEnd - 1).coerceAtLeast(
0
),
(currentStart + 1).coerceAtMost(
pageCharCount
)
)
} else {
newRange = Pair(
currentStart, newEnd
)
}
}
else -> {}
}
if (newRange != null) {
if (newRange.first >= newRange.second) {
if (pageCharCount > 0) {
val fixStart = min(
newRange.first,
newRange.second - 1
).coerceIn(
0, pageCharCount - 1
)
val fixEnd =
(fixStart + 1).coerceAtMost(
pageCharCount
)
newRange =
if (fixStart < fixEnd) Pair(
fixStart, fixEnd
)
else null
} else {
newRange = null
}
}
}
withContext(
Dispatchers.Main
) {
if (newHandle != null) activeDraggingHandle =
newHandle
if (newRange != null) selectionCharRange.value =
newRange
updateSelectionVisuals(
pdfDocumentItem,
pageIndex,
selectionCharRange.value,
actualBitmapWidthPx,
actualBitmapHeightPx,
currentPageRotation,
providedPage = pageForDrag,
providedTextPage = textPageForDrag
)
// Magnifier update
val currentActiveHandleForMagnifier =
activeDraggingHandle
val rects = selectedWordScreenRects
if (rects.isNotEmpty()) {
val relevantRect =
when (currentActiveHandleForMagnifier) {
Handle.START -> rects.first()
Handle.END -> rects.last()
null -> null
}
val handleContentPosToFollow =
when (currentActiveHandleForMagnifier) {
Handle.START -> startHandleContentPosition.value
Handle.END -> endHandleContentPosition.value
null -> null
}
if (relevantRect != null && handleContentPosToFollow != null) {
magnifierBitmapCenterTarget =
Offset(
x = handleContentPosToFollow.x,
y = relevantRect.exactCenterY()
)
}
}
}
}
}
}
}
}
}
} catch (e: Exception) {
Timber.e(e, "Error during handle drag worker")
} finally {
withContext(Dispatchers.IO) {
textPageForDrag?.close()
pageForDrag?.close()
}
}
}
try {
drag(down.id) { change ->
dragEventChannel.trySend(change.position)
change.consume()
}
} finally {
dragEventChannel.close()
}
if (selectionMethodUsed == PdfSelectionMethod.PDFIUM) {
if (selectionCharRange.value != null && selectedWordScreenRects.isNotEmpty()) {
val currentRange = selectionCharRange.value!!
val firstRect = selectedWordScreenRects.first()
coroutineScope.launch {
var pageForMenu: PdfPageKt? = null
var textPageForMenu: PdfTextPageKt? = null
try {
pageForMenu = pdfDocumentItem.openPage(
pdfPageIndex
)
textPageForMenu = pageForMenu.openTextPage()
val text = textPageForMenu.textPageGetText(
currentRange.first,
currentRange.second - currentRange.first
)
if (!text.isNullOrBlank()) {
customMenuState = CustomPdfMenuState(
selectedText = text,
anchorRect = firstRect,
charRange = currentRange
)
Timber.d(
"Menu shown after drag. Anchor: ${customMenuState?.anchorRect}"
)
} else {
customMenuState = null
}
} catch (e: Exception) {
Timber.e(
e, "Error fetching text for menu after drag"
)
customMenuState = null
} finally {
withContext(NonCancellable) {
withContext(Dispatchers.IO) {
try {
textPageForMenu?.close()
} catch (_: Exception) {
}
try {
pageForMenu?.close()
} catch (_: Exception) {
}
}
}
}
}
} else {
customMenuState = null
}
} else {
if (ocrSelectionSymbolIndices != null && selectedWordScreenRects.isNotEmpty()) {
val indices = ocrSelectionSymbolIndices!!
val selectedSymbolInfos = allOcrSymbolsForSelection.subList(
indices.first, indices.second
)
if (selectedSymbolInfos.isNotEmpty()) {
val selectedText = buildString {
selectedSymbolInfos.forEachIndexed { index, info ->
append(info.symbol.text)
if (index < selectedSymbolInfos.size - 1) {
val nextInfo = selectedSymbolInfos[index + 1]
if (info.parentLine !== nextInfo.parentLine) {
append('\n')
} else if (info.parentElement !== nextInfo.parentElement) {
append(' ')
}
}
}
}
val firstRect = selectedSymbolInfos.first().symbol.boundingBox!!
customMenuState = CustomPdfMenuState(
selectedText = selectedText,
anchorRect = firstRect,
charRange = Pair(-1, -1)
)
Timber.d(
"Menu shown after OCR drag. Anchor: ${customMenuState?.anchorRect}"
)
} else {
customMenuState = null
}
} else {
customMenuState = null
}
}
activeDraggingHandle = null
showMagnifier = false
Timber.d(
"PointerInput: Drag on handle completed/cancelled. Menu state: $customMenuState"
)
} else {
val longPressTimeout = viewConfiguration.longPressTimeoutMillis
try {
withTimeout(longPressTimeout) {
waitForUpOrCancellation()
}
} catch (_: PointerEventTimeoutCancellationException) {
down.consume()
Timber.d(
"PointerInput: Long press detected at screen position ${down.position}"
)
selectionCharRange.value = null
ocrSelectionSymbolIndices = null
customMenuState = null
showMagnifier = false
coroutineScope.launch {
var tempPage: PdfPageKt? = null
var tempTextPage: PdfTextPageKt? = null
var ocrAttemptedForThisPress = false
try {
if (!isPdfPage) return@launch
tempPage = pdfDocumentItem.openPage(pdfPageIndex)
tempTextPage = tempPage.openTextPage()
val touchInContentCoords = screenToContentCoordinates(
down.position
)
Timber.d(
"Long press: initial touch in content coords: $touchInContentCoords"
)
if (touchInContentCoords.x < 0 || touchInContentCoords.x > actualBitmapWidthPx || touchInContentCoords.y < 0 || touchInContentCoords.y > actualBitmapHeightPx) {
Timber.d(
"Long press: Touch point outside bitmap bounds."
)
return@launch
}
val pdfCoords = tempPage.mapDeviceCoordsToPage(
startX = 0,
startY = 0,
sizeX = actualBitmapWidthPx,
sizeY = actualBitmapHeightPx,
rotate = currentPageRotation,
deviceX = touchInContentCoords.x.toInt(),
deviceY = touchInContentCoords.y.toInt()
)
val charTolerance = 5.0
val charIndex = tempTextPage.textPageGetCharIndexAtPos(
x = pdfCoords.x.toDouble(),
y = pdfCoords.y.toDouble(),
xTolerance = charTolerance,
yTolerance = charTolerance
)
var pdfiumSelectionSuccessful = false
if (charIndex != -1) {
val pageCharCount = tempTextPage.textPageCountChars()
val wordBoundaries = findWordBoundaries(
tempTextPage, charIndex, pageCharCount
)
if (wordBoundaries != null) {
selectionMethodUsed = PdfSelectionMethod.PDFIUM
selectionCharRange.value = wordBoundaries
updateSelectionVisuals(
pdfDocumentItem,
pdfPageIndex,
selectionCharRange.value,
actualBitmapWidthPx,
actualBitmapHeightPx,
currentPageRotation,
providedPage = tempPage,
providedTextPage = tempTextPage
)
if (selectionCharRange.value != null && selectedWordScreenRects.isNotEmpty()) {
val currentRange = selectionCharRange.value!!
val text = tempTextPage.textPageGetText(
currentRange.first,
currentRange.second - currentRange.first
)
val firstRect = selectedWordScreenRects.first()
if (!text.isNullOrBlank()) {
customMenuState = CustomPdfMenuState(
selectedText = text,
anchorRect = firstRect,
charRange = currentRange
)
pdfiumSelectionSuccessful = true
Timber.d(
"Long press: PDFIUM selection successful. Menu: ${customMenuState?.anchorRect}"
)
}
}
}
}
if (!pdfiumSelectionSuccessful && bitmapState != null) {
Timber.d(
"Long press: PDFIUM selection failed or incomplete. Attempting OCR."
)
ocrRipplePosition = down.position
ocrAttemptedForThisPress = true
isPerformingOcrForSelection = true
customMenuState = null
selectionCharRange.value = null
selectedWordScreenRects = emptyList()
try {
val visionText = OcrHelper.extractTextFromBitmap(
bitmapState!!, onOcrModelDownloading
)
ocrVisionTextForSelection = visionText
if (visionText != null && visionText.text.isNotBlank()) {
val symbolInfoList = mutableListOf<OcrSymbolInfo>()
val allElements = mutableListOf<OcrElement>()
visionText.textBlocks.forEach { block ->
block.lines.forEach { line ->
line.elements.forEach { element ->
allElements.add(element)
element.symbols.forEach { symbol ->
symbolInfoList.add(
OcrSymbolInfo(
symbol, element, line
)
)
}
}
}
}
allOcrSymbolsForSelection = symbolInfoList
var foundElement: OcrElement? = null
val touchInContentCoords =
screenToContentCoordinates(
down.position
)
val touchXInBitmap = touchInContentCoords.x
val touchYInBitmap = touchInContentCoords.y
for (element in allElements) {
val box = element.boundingBox
if (box != null) {
if (touchXInBitmap >= box.left && touchXInBitmap <= box.right && touchYInBitmap >= box.top && touchYInBitmap <= box.bottom) {
foundElement = element
break
}
}
}
if (foundElement != null) {
var symbolStartIndex = 0
var elementFoundInList = false
for (element in allElements) {
if (element === foundElement) {
elementFoundInList = true
break
}
symbolStartIndex += element.symbols.size
}
if (elementFoundInList && foundElement.symbols.isNotEmpty()) {
val symbolEndIndex =
symbolStartIndex + foundElement.symbols.size
selectionMethodUsed = PdfSelectionMethod.OCR
ocrSelectionSymbolIndices = Pair(
symbolStartIndex, symbolEndIndex
)
updateOcrSymbolSelectionRectsAndHandles(
ocrSelectionSymbolIndices
)
val menuAnchorContentRect =
foundElement.boundingBox!!
customMenuState = CustomPdfMenuState(
selectedText = foundElement.text,
anchorRect = menuAnchorContentRect,
charRange = Pair(
-1, -1
)
)
Timber.d(
"Long press: OCR selection successful. Menu: ${customMenuState?.anchorRect}"
)
} else {
Timber.d(
"Long press: OCR word found, but couldn't calculate its symbol indices. Found in list: $elementFoundInList"
)
customMenuState = null
ocrSelectionSymbolIndices = null
allOcrSymbolsForSelection = emptyList()
selectedWordScreenRects = emptyList()
startHandleContentPosition.value = null
endHandleContentPosition.value = null
}
} else {
Timber.d(
"Long press: OCR successful but no text element at touch point."
)
customMenuState = null
ocrSelectionSymbolIndices = null
allOcrSymbolsForSelection = emptyList()
selectedWordScreenRects = emptyList()
startHandleContentPosition.value = null
endHandleContentPosition.value = null
}
} else {
Timber.d(
"Long press: OCR returned no text."
)
}
} catch (e: Exception) {
Timber.e(
e, "Long press: Error during OCR text selection"
)
pageErrorMessage =
"OCR selection error: ${e.localizedMessage}"
} finally {
isPerformingOcrForSelection = false
ocrRipplePosition = null
}
} else if (!pdfiumSelectionSuccessful) {
Timber.d(
"Long press: PDFIUM selection failed and no bitmap for OCR."
)
}
if (!pdfiumSelectionSuccessful && !ocrAttemptedForThisPress) {
customMenuState = null
selectionCharRange.value = null
selectedWordScreenRects = emptyList()
}
} catch (e: Exception) {
Timber.e(
e,
"Error during long press text selection on page $pageIndex"
)
pageErrorMessage = "Selection error: ${e.localizedMessage}"
customMenuState = null
selectionCharRange.value = null
selectedWordScreenRects = emptyList()
} finally {
withContext(NonCancellable) {
withContext(Dispatchers.IO) {
try {
tempTextPage?.close()
} catch (_: Exception) {
}
try {
tempPage?.close()
} catch (_: Exception) {
}
}
}
if (isPerformingOcrForSelection && !ocrAttemptedForThisPress) isPerformingOcrForSelection =
false
}
}
val longPressInteractionEndEvent = waitForUpOrCancellation()
longPressInteractionEndEvent?.consume()
Timber.d(
"PointerInput: Long press interaction's 'up' event consumed."
)
}
}
}
}
.pointerInput(
actualBitmapWidthPx,
actualBitmapHeightPx,
scale,
offset,
customMenuState,
selectionCharRange.value,
pageLinks,
centeringOffsetX,
centeringOffsetY,
isZoomEnabled,
isVerticalScroll,
isEditMode,
selectedTool
) {
if (isEditMode && selectedTool != InkType.TEXT) return@pointerInput
detectTapGestures(onTap = { tapOffset ->
Timber.d(
"PdfPageComposable: onTap detected at $tapOffset. isVerticalScroll=$isVerticalScroll"
)
val tapInContentCoords = screenToContentCoordinates(tapOffset)
val tapXInBitmap = tapInContentCoords.x
val tapYInBitmap = tapInContentCoords.y
Timber.d(
"detectTapGestures: Tap at bitmap coords (${tapXInBitmap.toInt()}, ${tapYInBitmap.toInt()})"
)
val clickedLink = pageLinks.firstOrNull { link ->
link.tapBounds.contains(
tapXInBitmap.toInt(), tapYInBitmap.toInt()
)
}
if (clickedLink != null) {
Timber.d(
"PdfPageComposable: Link clicked. Ignoring selection logic."
)
if (clickedLink.destPageIdx != null && clickedLink.destPageIdx >= 0) {
onInternalLinkClicked(clickedLink.destPageIdx)
} else if (clickedLink.url != null) {
onLinkClicked(clickedLink.url)
}
return@detectTapGestures
}
val wasMenuVisible = customMenuState != null
val wasSelectionVisible =
selectionCharRange.value != null || ocrSelectionSymbolIndices != null
Timber.d(
"PdfPageComposable: State check - MenuVisible=$wasMenuVisible, SelectionVisible=$wasSelectionVisible"
)
if (wasMenuVisible || wasSelectionVisible) {
Timber.d(
"PdfPageComposable: Clearing selection/menu."
)
customMenuState = null
selectionCharRange.value = null
ocrSelectionSymbolIndices = null
coroutineScope.launch {
updateSelectionVisuals(
pdfDocumentItem,
pdfPageIndex,
null,
actualBitmapWidthPx,
actualBitmapHeightPx,
currentPageRotation,
)
}
} else {
Timber.d(
"PdfPageComposable: No selection active. Calling onSingleTap()."
)
currentOnSingleTap()
}
}, onDoubleTap = { tapOffset ->
if (isZoomEnabled && !isVerticalScroll) {
if (actualBitmapWidthPx == 0) return@detectTapGestures
coroutineScope.launch {
val startScale = scale
val targetScale = if (startScale > 1.1f) 1f else 2.5f
val startOffset = offset
val targetOffsetUnbounded = if (targetScale <= 1.1f) {
Offset.Zero
} else {
val ratio = targetScale / startScale
val screenCenter = Offset(
size.width / 2f, size.height / 2f
)
startOffset * ratio + (tapOffset - screenCenter) * (1 - ratio)
}
val contentWidth = actualBitmapWidthPx * targetScale
val contentHeight = actualBitmapHeightPx * targetScale
val maxOffsetX = (contentWidth - size.width).coerceAtLeast(0f) / 2f
val maxOffsetY = (contentHeight - size.height).coerceAtLeast(0f) / 2f
val targetOffset = Offset(
x = targetOffsetUnbounded.x.coerceIn(
-maxOffsetX, maxOffsetX
), y = targetOffsetUnbounded.y.coerceIn(
-maxOffsetY, maxOffsetY
)
)
Animatable(0f).animateTo(
1f, animationSpec = tween(
durationMillis = 300
)
) {
val progress = value
scale = lerp(
startScale, targetScale, progress
)
offset = lerp(
startOffset, targetOffset, progress
)
onScaleChanged(scale)
}
if (scale <= 1.05f) {
scale = 1f
offset = Offset.Zero
onScaleChanged(scale)
}
}
} else if (isVerticalScroll && currentOnDoubleTap != null) {
currentOnDoubleTap!!(tapOffset)
}
})
}
.pointerInput(
actualBitmapWidthPx,
actualBitmapHeightPx,
activeDraggingHandle,
isZoomEnabled,
isVerticalScroll,
isEditMode,
onTwoFingerSwipe
) {
if (!isZoomEnabled || isVerticalScroll || actualBitmapWidthPx == 0 || activeDraggingHandle != null) return@pointerInput
val decay = splineBasedDecay<Float>(this)
val velocityTracker = VelocityTracker()
val touchSlop = viewConfiguration.touchSlop
awaitEachGesture {
@Suppress("UnusedVariable", "Unused") val down =
awaitFirstDown(requireUnconsumed = false)
velocityTracker.resetTracking()
var mode = 0
var accumulatedZoom = 1f
var accumulatedPan = Offset.Zero
var swipeAccumulatorX = 0f
do {
val event = awaitPointerEvent()
val canceled = event.changes.any { it.isConsumed }
val pointerCount = event.changes.size
val currentCentroid = event.calculateCentroid(useCurrent = true)
if (pointerCount > 0 && currentCentroid != Offset.Unspecified) {
velocityTracker.addPosition(
event.changes[0].uptimeMillis, currentCentroid
)
}
if (!canceled) {
val panChange = event.calculatePan()
val zoomChange = event.calculateZoom()
if (scale > 1f) {
if (mode == 0) {
if (pointerCount == 1) {
accumulatedPan += panChange
if (accumulatedPan.getDistance() > touchSlop) {
mode = 1
}
} else if (pointerCount > 1) {
accumulatedZoom *= zoomChange
accumulatedPan += panChange
val zoomDiff = abs(accumulatedZoom - 1f)
val panDist = accumulatedPan.getDistance()
if (zoomDiff > 0.05f) {
mode = 2
} else if (panDist > touchSlop) {
mode = 1
}
}
}
if (mode == 1) {
val contentWidth = actualBitmapWidthPx * scale
val contentHeight = actualBitmapHeightPx * scale
val maxOffsetX =
(contentWidth - size.width).coerceAtLeast(0f) / 2f
val maxOffsetY =
(contentHeight - size.height).coerceAtLeast(0f) / 2f
val newX = (offset.x + panChange.x).coerceIn(
-maxOffsetX, maxOffsetX
)
val newY = (offset.y + panChange.y).coerceIn(
-maxOffsetY, maxOffsetY
)
offset = Offset(newX, newY)
event.changes.forEach {
if (it.positionChanged()) it.consume()
}
} else if (mode == 2 && pointerCount > 1) {
val oldScale = scale
val newScale = (scale * zoomChange).coerceIn(1f, 4f)
val previousCentroid = event.calculateCentroid(
useCurrent = false
)
if (previousCentroid != Offset.Unspecified) {
val ratio = newScale / oldScale
val screenCenter = Offset(
size.width / 2f, size.height / 2f
)
val newOffset =
offset * ratio + (previousCentroid - screenCenter) * (1 - ratio) + panChange
val contentWidth = actualBitmapWidthPx * newScale
val contentHeight = actualBitmapHeightPx * newScale
val maxOffsetX =
(contentWidth - size.width).coerceAtLeast(0f) / 2f
val maxOffsetY =
(contentHeight - size.height).coerceAtLeast(0f) / 2f
offset = Offset(
x = newOffset.x.coerceIn(
-maxOffsetX, maxOffsetX
), y = newOffset.y.coerceIn(
-maxOffsetY, maxOffsetY
)
)
scale = newScale
if (scale < 1.05f) {
scale = 1f
offset = Offset.Zero
onScaleChanged(scale)
} else {
onScaleChanged(scale)
}
}
event.changes.forEach {
if (it.positionChanged()) it.consume()
}
}
} else {
if (pointerCount > 1) {
if (mode == 0) {
accumulatedZoom *= zoomChange
accumulatedPan += panChange
if (abs(accumulatedZoom - 1f) > 0.05f) {
mode = 2
} else if (accumulatedPan.getDistance() > touchSlop) {
if (abs(accumulatedPan.x) > abs(accumulatedPan.y) * 1.5f) {
mode = 3
}
}
}
if (mode == 2) {
val oldScale = scale
val newScale = (scale * zoomChange).coerceIn(
1f, 4f
)
val previousCentroid = event.calculateCentroid(
useCurrent = false
)
if (previousCentroid != Offset.Unspecified) {
val ratio = newScale / oldScale
val screenCenter = Offset(
size.width / 2f, size.height / 2f
)
val newOffset =
offset * ratio + (previousCentroid - screenCenter) * (1 - ratio) + panChange
val contentWidth = actualBitmapWidthPx * newScale
val contentHeight = actualBitmapHeightPx * newScale
val maxOffsetX =
(contentWidth - size.width).coerceAtLeast(0f) / 2f
val maxOffsetY =
(contentHeight - size.height).coerceAtLeast(0f) / 2f
offset = Offset(
x = newOffset.x.coerceIn(
-maxOffsetX, maxOffsetX
), y = newOffset.y.coerceIn(
-maxOffsetY, maxOffsetY
)
)
scale = newScale
onScaleChanged(scale)
}
event.changes.forEach {
if (it.positionChanged()) it.consume()
}
} else if (mode == 3) {
swipeAccumulatorX += panChange.x
if (abs(swipeAccumulatorX) > 70f) {
val direction = if (swipeAccumulatorX > 0) -1
else 1
onTwoFingerSwipe(direction)
mode = 4
}
event.changes.forEach {
if (it.positionChanged()) it.consume()
}
}
}
}
}
} while (!canceled && event.changes.any { it.pressed })
if (mode == 1 && scale > 1f) {
val velocity = velocityTracker.calculateVelocity()
val contentWidth = actualBitmapWidthPx * scale
val contentHeight = actualBitmapHeightPx * scale
val maxOffsetX = (contentWidth - size.width).coerceAtLeast(0f) / 2f
val maxOffsetY = (contentHeight - size.height).coerceAtLeast(0f) / 2f
val startX = offset.x
val startY = offset.y
coroutineScope.launch {
coroutineScope {
launch {
Animatable(startX).animateDecay(
velocity.x, decay
) {
val newX = value.coerceIn(
-maxOffsetX, maxOffsetX
)
offset = offset.copy(x = newX)
}
}
launch {
Animatable(startY).animateDecay(
velocity.y, decay
) {
val newY = value.coerceIn(
-maxOffsetY, maxOffsetY
)
offset = offset.copy(y = newY)
}
}
}
}
}
}
}
.pointerInput(
isEditMode,
actualBitmapWidthPx,
actualBitmapHeightPx,
scale,
offset,
isScrolling,
isVerticalScroll,
selectedTool
) {
val canDraw = isEditMode && selectedTool != InkType.TEXT && !isScrolling && !isVerticalScroll && actualBitmapWidthPx > 0 && actualBitmapHeightPx > 0
if (!canDraw) {
return@pointerInput
}
try {
awaitEachGesture {
val down = awaitFirstDown(requireUnconsumed = false)
val dragPointerId = down.id
val startPos = down.position
var dragStarted = false
val touchSlop = viewConfiguration.touchSlop
if (selectedTool == InkType.ERASER) {
eraserPosition = down.position
}
while (true) {
val event = awaitPointerEvent()
if (event.changes.size > 1) {
if (dragStarted) {
drawingState?.onDrawCancel()
}
eraserPosition = null
return@awaitEachGesture
}
val change = event.changes.firstOrNull {
it.id == dragPointerId
}
if (change == null) return@awaitEachGesture
if (change.changedToUp()) {
change.consume()
if (!dragStarted) {
val contentPos = screenToContentCoordinates(startPos)
val normX =
(contentPos.x / actualBitmapWidthPx).coerceIn(0f, 1f)
val normY =
(contentPos.y / actualBitmapHeightPx).coerceIn(0f, 1f)
onDrawStart(PdfPoint(normX, normY))
onDrawEnd()
} else {
onDrawEnd()
}
eraserPosition = null
return@awaitEachGesture
}
if (change.positionChanged()) {
val dist = (change.position - startPos).getDistance()
if (!dragStarted) {
if (dist > touchSlop) {
dragStarted = true
val startContentPos = screenToContentCoordinates(startPos)
val startNormX =
(startContentPos.x / actualBitmapWidthPx).coerceIn(
0f, 1f
)
val startNormY =
(startContentPos.y / actualBitmapHeightPx).coerceIn(
0f, 1f
)
onDrawStart(
PdfPoint(startNormX, startNormY)
)
val currContentPos = screenToContentCoordinates(
change.position
)
val currNormX =
(currContentPos.x / actualBitmapWidthPx).coerceIn(
0f, 1f
)
val currNormY =
(currContentPos.y / actualBitmapHeightPx).coerceIn(
0f, 1f
)
onDraw(PdfPoint(currNormX, currNormY))
if (selectedTool == InkType.ERASER) {
eraserPosition = change.position
}
change.consume()
}
} else {
val currContentPos = screenToContentCoordinates(
change.position
)
val currNormX =
(currContentPos.x / actualBitmapWidthPx).coerceIn(0f, 1f)
val currNormY =
(currContentPos.y / actualBitmapHeightPx).coerceIn(0f, 1f)
onDraw(PdfPoint(currNormX, currNormY))
if (selectedTool == InkType.ERASER) {
eraserPosition = change.position
}
change.consume()
}
}
}
}
} finally {
eraserPosition = null
}
}, contentAlignment = Alignment.Center
) {
val imeInsets = WindowInsets.ime
val screenHeight = constraints.maxHeight.toFloat()
val imeBottom = imeInsets.getBottom(density)
LaunchedEffect(
richTextController?.cursorPageIndex,
richTextController?.cursorRectInPage,
imeBottom,
pageIndex,
screenHeight,
isEditMode,
selectedTool,
density
) {
val controller = richTextController ?: return@LaunchedEffect
Timber.tag("KeyboardAdjust").v(
"Check: Page=$pageIndex, IME=$imeBottom, EditMode=$isEditMode, OffsetY=${offset.y}"
)
if (imeBottom == 0 || !isEditMode || selectedTool != InkType.TEXT) {
if (keyboardAdjustmentOriginalOffset != null) {
Timber.tag("KeyboardAdjust")
.i("Restoring original offset: $keyboardAdjustmentOriginalOffset")
if (isZoomEnabled && !isVerticalScroll) {
offset = offset.copy(y = keyboardAdjustmentOriginalOffset!!)
}
keyboardAdjustmentOriginalOffset = null
}
return@LaunchedEffect
}
val cursorPage = controller.cursorPageIndex
val cursorRect = controller.cursorRectInPage
if (cursorPage == pageIndex && cursorRect != null) {
if (keyboardAdjustmentOriginalOffset == null) {
keyboardAdjustmentOriginalOffset = offset.y
Timber.tag("KeyboardAdjust").i("Snapshot original offset: ${offset.y}")
}
val baseOffsetY = keyboardAdjustmentOriginalOffset!!
val currentScale = if (isZoomEnabled && !isVerticalScroll) scale else externalScale
val currentOffset =
if (isZoomEnabled && !isVerticalScroll) offset.copy(y = baseOffsetY)
else Offset.Zero
val contentHeight = actualBitmapHeightPx * currentScale
val centeringY = (screenHeight - contentHeight) / 2f
val cursorTopInPage = cursorRect.top * currentScale
val cursorBottomInPage = cursorRect.bottom * currentScale
val cursorScreenTop = centeringY + currentOffset.y + cursorTopInPage
val cursorScreenBottom = centeringY + currentOffset.y + cursorBottomInPage
val topSafeBuffer = with(density) { 80.dp.toPx() }
val visibleBottom = screenHeight - imeBottom
var targetAdjustment = 0f
if (cursorScreenBottom > visibleBottom) {
targetAdjustment = visibleBottom - cursorScreenBottom
} else if (cursorScreenTop < topSafeBuffer) {
targetAdjustment = topSafeBuffer - cursorScreenTop
}
Timber.tag("KeyboardAdjust").v(
"CursorBottom=$cursorScreenBottom, VisibleBottom=$visibleBottom, TargetAdj=$targetAdjustment"
)
if (kotlin.math.abs(targetAdjustment) > 1f && isZoomEnabled && !isVerticalScroll) {
val finalTargetY = baseOffsetY + targetAdjustment
if (offset.y != finalTargetY) {
Timber.tag("KeyboardAdjust").i("Applying adjustment. NewY=$finalTargetY")
offset = offset.copy(y = finalTargetY)
}
} else if (kotlin.math.abs(targetAdjustment) <= 1f && offset.y != baseOffsetY) {
Timber.tag("KeyboardAdjust")
.i("Cursor visible, restoring base. NewY=$baseOffsetY")
offset = offset.copy(y = baseOffsetY)
}
}
}
LaunchedEffect(
this@BoxWithConstraints.maxWidth, this@BoxWithConstraints.maxHeight, pageIndex
) {
Timber.d(
"PdfPageComposable Page $pageIndex | Constraints: maxWidth=${this@BoxWithConstraints.maxWidth}, maxHeight=${this@BoxWithConstraints.maxHeight}"
)
}
LaunchedEffect(
pdfDocumentItem,
pageIndex,
ttsHighlightData,
actualBitmapWidthPx,
actualBitmapHeightPx,
currentPageRotation,
lazyListState,
isVerticalScroll,
virtualPage
) {
if (scale > 1f) {
if (highlightedTextScreenRects.isNotEmpty()) highlightedTextScreenRects =
emptyList()
return@LaunchedEffect
}
if (ttsHighlightData == null || actualBitmapWidthPx == 0) {
if (highlightedTextScreenRects.isNotEmpty()) {
Timber.d("Highlighting: Clearing highlights for page $pageIndex.")
highlightedTextScreenRects = emptyList()
}
return@LaunchedEffect
}
if (!isPdfPage) {
if (highlightedTextScreenRects.isNotEmpty()) highlightedTextScreenRects =
emptyList()
return@LaunchedEffect
}
try {
var rects: List<Rect> = emptyList()
when (ttsHighlightData) {
is TtsHighlightData.Pdfium -> {
Timber.d(
"Highlighting (Pdfium): page $pageIndex, index: ${ttsHighlightData.startIndex}, len: ${ttsHighlightData.length}"
)
rects = withContext(Dispatchers.IO) {
pdfDocumentItem.openPage(pdfPageIndex).use { page ->
page.openTextPage().use { textPage ->
val pdfRectsF = textPage.textPageGetRectsForRanges(
intArrayOf(
ttsHighlightData.startIndex, ttsHighlightData.length
)
)?.map { it.rect } ?: emptyList()
if (pdfRectsF.isNotEmpty()) {
pdfRectsF.mapNotNull { pdfRectF ->
page.mapRectToDevice(
startX = 0,
startY = 0,
sizeX = actualBitmapWidthPx,
sizeY = actualBitmapHeightPx,
rotate = currentPageRotation,
coords = pdfRectF
).takeIf {
it.width() > 0 && it.height() > 0
}
}
} else {
emptyList()
}
}
}
}
}
is TtsHighlightData.Ocr -> {
val textToHighlight = ttsHighlightData.text
Timber.d(
"Highlighting (OCR): page $pageIndex, text: \"${textToHighlight.take(50)}...\""
)
val ocrTextToUse = ocrVisionTextForSelection ?: try {
bitmapState?.let {
OcrHelper.extractTextFromBitmap(
it, onOcrModelDownloading
)
}
} catch (e: Exception) {
Timber.e(
e, "Highlighting: On-demand OCR for TTS failed."
)
null
}
rects = if (ocrTextToUse != null) {
findRectsForTextChunkInOcrVisual(ocrTextToUse, textToHighlight)
} else {
emptyList()
}
}
}
highlightedTextScreenRects = rects
if (rects.isNotEmpty() && onTtsHighlightCenterCalculated != null) {
val unionRect = RectF()
unionRect.set(
rects[0].left.toFloat(),
rects[0].top.toFloat(),
rects[0].right.toFloat(),
rects[0].bottom.toFloat()
)
for (i in 1 until rects.size) {
val r = rects[i]
unionRect.union(
r.left.toFloat(), r.top.toFloat(), r.right.toFloat(), r.bottom.toFloat()
)
}
onTtsHighlightCenterCalculated(unionRect.centerY())
}
} catch (e: Exception) {
Timber.e(e, "Highlighting: Error calculating highlights for page $pageIndex: $e")
highlightedTextScreenRects = emptyList()
}
}
Box(modifier = Modifier
.fillMaxSize()
.clipToBounds()
.background(backgroundColor)) {
val currentContainerMaxWidth = this@BoxWithConstraints.maxWidth
val currentContainerMaxHeight = this@BoxWithConstraints.maxHeight
val containerWidthPx = with(density) { this@BoxWithConstraints.maxWidth.toPx() }
val containerHeightPx = with(density) { this@BoxWithConstraints.maxHeight.toPx() }
val centeringOffsetX = (containerWidthPx - actualBitmapWidthPx) / 2f
val centeringOffsetY = (containerHeightPx - actualBitmapHeightPx) / 2f
LaunchedEffect(
pdfDocumentItem,
pageIndex,
isPdfPage,
pdfPageIndex,
currentContainerMaxWidth,
currentContainerMaxHeight,
density,
virtualPage,
isVisible
) {
if (!isVisible && !isVerticalScroll) return@LaunchedEffect
val viewContainerWidthPx = with(density) { currentContainerMaxWidth.toPx().toInt() }
val viewContainerHeightPx =
with(density) { currentContainerMaxHeight.toPx().toInt() }
Timber.d(
"PdfPageComposable Page $pageIndex | viewContainerPx: ${viewContainerWidthPx}x${viewContainerHeightPx}"
)
if (viewContainerWidthPx <= 0 || viewContainerHeightPx <= 0) {
if (bitmapState == null) isLoadingPage = true
Timber.d(
"PdfPageComposable: viewContainer dimensions invalid ($viewContainerWidthPx x $viewContainerHeightPx), waiting."
)
return@LaunchedEffect
}
if (!isPdfPage) {
val blankPage = virtualPage as? VirtualPage.BlankPage
val pageAspect = if (blankPage != null && blankPage.width > 0 && blankPage.height > 0) {
blankPage.width.toFloat() / blankPage.height.toFloat()
} else {
1f / 1.414f
}
var scaledWidth = viewContainerWidthPx
var scaledHeight = (scaledWidth / pageAspect).toInt()
if (scaledHeight > viewContainerHeightPx) {
scaledHeight = viewContainerHeightPx
scaledWidth = (scaledHeight * pageAspect).toInt()
}
if (scaledWidth == actualBitmapWidthPx &&
scaledHeight == actualBitmapHeightPx &&
bitmapState != null &&
currentRenderedPageId == targetPageId
) {
isLoadingPage = false
return@LaunchedEffect
}
actualBitmapWidthPx = scaledWidth
actualBitmapHeightPx = scaledHeight
currentPageRotation = 0
val bitmap = PdfBitmapPool.get(maxOf(scaledWidth, scaledHeight))
bitmap.eraseColor(android.graphics.Color.WHITE)
val finalBitmap =
if (bitmap.width != scaledWidth || bitmap.height != scaledHeight) {
val scaled = bitmap.scale(scaledWidth, scaledHeight, false)
if (scaled !== bitmap) PdfBitmapPool.recycle(bitmap)
scaled.eraseColor(android.graphics.Color.WHITE)
scaled
} else {
bitmap
}
val old = bitmapState
if (old != null && old !== finalBitmap) {
if (old !== PdfThumbnailCache.get(pageIndex)) {
old.recycle()
}
}
bitmapState = finalBitmap
if (old != null && old !== finalBitmap) {
if (old !== PdfThumbnailCache.get(pageIndex)) old.recycle()
}
currentRenderedPageId = targetPageId
isLoadingPage = false
return@LaunchedEffect
}
coroutineScope.launch {
try {
val renderResult = withContext(Dispatchers.IO) {
val rawPageCount = pdfDocumentItem.getPageCount()
if (pdfPageIndex >= rawPageCount) {
Timber.w(
"PdfPageComposable: Index $pdfPageIndex out of bounds (count $rawPageCount). Waiting for layout update."
)
return@withContext null
}
val page = pdfDocumentItem.openPage(pdfPageIndex)
val rotation = page.getPageRotation()
val screenDpi = (density.density * 160).roundToInt()
val originalWidthPdfUnits = page.getPageWidth(screenDpi)
val originalHeightPdfUnits = page.getPageHeight(screenDpi)
if (originalWidthPdfUnits <= 0 || originalHeightPdfUnits <= 0) {
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
}
Timber.d(
"Rendering page $pageIndex at ${scaledWidth}x${scaledHeight}"
)
val newBitmap = createBitmap(scaledWidth, scaledHeight)
page.renderPageBitmap(
newBitmap, 0, 0, scaledWidth, scaledHeight, false
)
page.close()
Triple(newBitmap, rotation, Pair(scaledWidth, scaledHeight))
}
if (renderResult != null) {
val (newBitmap, rotation, dims) = renderResult
actualBitmapWidthPx = dims.first
actualBitmapHeightPx = dims.second
currentPageRotation = rotation
val old = bitmapState
bitmapState = newBitmap
currentRenderedPageId = targetPageId
withContext(Dispatchers.IO) {
if (old != null && old !== newBitmap && !old.isRecycled) {
val cached = PdfThumbnailCache.get(pageIndex)
if (old !== cached) {
old.recycle()
}
}
val thumbWidth = newBitmap.width / 2
val thumbHeight = newBitmap.height / 2
if (thumbWidth > 0 && thumbHeight > 0) {
PdfThumbnailCache.put(
pageIndex, newBitmap.scale(thumbWidth, thumbHeight)
)
}
}
}
} catch (e: Exception) {
if (e is CancellationException) throw e
pageErrorMessage = "Error processing page: ${e.localizedMessage}"
} finally {
isLoadingPage = false
}
}
}
when {
isLoadingPage -> {
if (placeholderBitmap != null && !placeholderBitmap.isRecycled) {
Canvas(modifier = Modifier.fillMaxSize()) {
if (!placeholderBitmap.isRecycled) {
val canvasWidth = size.width
val canvasHeight = size.height
val bitmapWidth = placeholderBitmap.width
val bitmapHeight = placeholderBitmap.height
if (bitmapWidth <= 0 || bitmapHeight <= 0) return@Canvas
val canvasAspectRatio = canvasWidth / canvasHeight
val bitmapAspectRatio =
bitmapWidth.toFloat() / bitmapHeight.toFloat()
val dstWidth: Float
val dstHeight: Float
if (bitmapAspectRatio > canvasAspectRatio) {
dstWidth = canvasWidth
dstHeight = dstWidth / bitmapAspectRatio
} else {
dstHeight = canvasHeight
dstWidth = dstHeight * bitmapAspectRatio
}
val dstOffset = Offset(
x = (canvasWidth - dstWidth) / 2f,
y = (canvasHeight - dstHeight) / 2f
)
drawImage(
image = placeholderBitmap.asImageBitmap(),
dstOffset = IntOffset(
dstOffset.x.roundToInt(), dstOffset.y.roundToInt()
),
dstSize = IntSize(
dstWidth.roundToInt(), dstHeight.roundToInt()
)
)
}
}
} else {
// Blank page
}
}
pageErrorMessage != null -> {
Text(
text = pageErrorMessage!!,
color = MaterialTheme.colorScheme.error,
modifier = Modifier
.padding(16.dp)
.align(Alignment.Center)
)
}
bitmapState != null && actualBitmapWidthPx > 0 && actualBitmapHeightPx > 0 -> {
val stableBitmapState = remember(bitmapState) { StableHolder(bitmapState) }
val stableTiles = remember(tiles) { StableHolder(tiles) }
val stableImageScreenRects =
remember(imageScreenRects) { StableHolder(imageScreenRects) }
val stableColorFilter = remember(colorFilter) { StableHolder(colorFilter) }
val staticData = remember(
stableBitmapState,
stableTiles,
effectiveScale,
centeringOffsetX,
centeringOffsetY,
canvasWidthPx.floatValue,
canvasHeightPx.floatValue,
actualBitmapWidthPx,
actualBitmapHeightPx,
stableColorFilter,
stableImageScreenRects,
isDarkMode
) {
Timber.tag("PdfDrawPerf").v(
"STATIC DATA GENERATED: Scale=$effectiveScale, Tiles=${stableTiles.item.size}"
)
PageStaticData(
bitmap = stableBitmapState,
tiles = stableTiles,
effectiveScale = effectiveScale,
centeringOffsetX = centeringOffsetX,
centeringOffsetY = centeringOffsetY,
canvasWidth = canvasWidthPx.floatValue,
canvasHeight = canvasHeightPx.floatValue,
targetWidth = actualBitmapWidthPx,
targetHeight = actualBitmapHeightPx,
colorFilter = stableColorFilter,
imageScreenRects = stableImageScreenRects,
isDarkMode = isDarkMode
)
}
val selectionData = remember(
pageLinks,
showAllTextHighlights,
actualBitmapWidthPx,
actualBitmapHeightPx,
mergedAllTextPageHighlightRects,
mergedTtsHighlightRects,
mergedSearchHighlightRects,
ocrHoverHighlights,
mergedSelectionRects,
centeringOffsetX,
centeringOffsetY,
linkHighlightColor,
scrimColorForTextHighlight,
allTextPageHighlightColor,
ttsHighlightColor,
searchHighlightColor,
selectionHighlightColor,
pageIndex,
mergedSearchFocusedRects,
mergedSearchAllRects,
searchHighlightMode,
searchFocusedColor,
searchAllColor
) {
PageSelectionData(
pageLinks = StableHolder(pageLinks),
showAllTextHighlights = showAllTextHighlights,
actualBitmapWidthPx = actualBitmapWidthPx,
actualBitmapHeightPx = actualBitmapHeightPx,
mergedAllTextPageHighlightRects = StableHolder(
mergedAllTextPageHighlightRects
),
mergedTtsHighlightRects = StableHolder(mergedTtsHighlightRects),
ocrHoverHighlights = ocrHoverHighlights,
mergedSelectionRects = StableHolder(mergedSelectionRects),
centeringOffsetX = centeringOffsetX,
centeringOffsetY = centeringOffsetY,
linkHighlightColor = linkHighlightColor,
scrimColorForTextHighlight = scrimColorForTextHighlight,
allTextPageHighlightColor = allTextPageHighlightColor,
ttsHighlightColor = ttsHighlightColor,
selectionHighlightColor = selectionHighlightColor,
pageIndex = pageIndex,
mergedSearchFocusedRects = StableHolder(mergedSearchFocusedRects),
mergedSearchAllRects = StableHolder(mergedSearchAllRects),
searchHighlightMode = searchHighlightMode,
)
}
val centeringPaddingEnd =
with(density) { centeringOffsetX.coerceAtLeast(0f).toDp() }
val centeringPaddingTop =
with(density) { centeringOffsetY.coerceAtLeast(0f).toDp() }
PdfPageRenderer(
staticData = staticData,
selectionData = selectionData,
totalPages = totalPages,
annotationsProvider = pageAnnotations,
drawingState = drawingState,
onCanvasSizeChanged = { w, h ->
if (canvasWidthPx.floatValue != w || canvasHeightPx.floatValue != h) {
canvasWidthPx.floatValue = w
canvasHeightPx.floatValue = h
}
},
scale = scale,
offset = offset,
startHandlePos = startHandleContentPosition.value,
endHandlePos = endHandleContentPosition.value,
teardropWidthPx = teardropWidthPxState.value,
teardropHeightPx = teardropHeightPxState.value,
activeDraggingHandle = activeDraggingHandle,
showMagnifier = showMagnifier,
magnifierCenterTarget = magnifierBitmapCenterTarget,
magnifierZoomFactor = magnifierZoomFactor,
menuState = customMenuState,
onMenuDismiss = {
customMenuState = null
selectionCharRange.value = null
coroutineScope.launch {
updateSelectionVisuals(
pdfDocumentItem,
pdfPageIndex,
null,
actualBitmapWidthPx,
actualBitmapHeightPx,
currentPageRotation,
)
}
},
onCopy = { textToCopy ->
clipboardManager.setText(AnnotatedString(textToCopy))
customMenuState = null
selectionCharRange.value = null
coroutineScope.launch {
updateSelectionVisuals(
pdfDocumentItem,
pdfPageIndex,
null,
actualBitmapWidthPx,
actualBitmapHeightPx,
currentPageRotation
)
}
},
onAiDefine = { textToDefine ->
onWordSelectedForAiDefinition(textToDefine.trim())
customMenuState = null
selectionCharRange.value = null
coroutineScope.launch {
updateSelectionVisuals(
pdfDocumentItem,
pdfPageIndex,
null,
actualBitmapWidthPx,
actualBitmapHeightPx,
currentPageRotation
)
}
},
onSelectAll = {
customMenuState = null
coroutineScope.launch {
if (!isPdfPage) return@launch
if (selectionMethodUsed == PdfSelectionMethod.PDFIUM) {
var page: PdfPageKt? = null
var textPage: PdfTextPageKt? = null
try {
page = pdfDocumentItem.openPage(pdfPageIndex)
textPage = page.openTextPage()
val charCount = textPage.textPageCountChars()
if (charCount > 0) {
selectionCharRange.value = Pair(0, charCount)
updateSelectionVisuals(
pdfDocumentItem,
pdfPageIndex,
selectionCharRange.value,
actualBitmapWidthPx,
actualBitmapHeightPx,
currentPageRotation,
providedPage = page,
providedTextPage = textPage
)
if (selectedWordScreenRects.isNotEmpty()) {
val fullText =
textPage.textPageGetText(0, charCount)
if (!fullText.isNullOrBlank()) {
customMenuState = CustomPdfMenuState(
selectedText = fullText,
anchorRect = selectedWordScreenRects.first(),
charRange = selectionCharRange.value!!
)
}
}
}
} catch (e: Exception) {
Timber.e(e, "Failed to select all")
} finally {
withContext(Dispatchers.IO) {
textPage?.close()
page?.close()
}
}
} else {
if (allOcrSymbolsForSelection.isNotEmpty()) {
ocrSelectionSymbolIndices =
Pair(0, allOcrSymbolsForSelection.size)
updateOcrSymbolSelectionRectsAndHandles(
ocrSelectionSymbolIndices
)
if (selectedWordScreenRects.isNotEmpty()) {
val fullText = buildString {
allOcrSymbolsForSelection.forEachIndexed { index, info ->
append(info.symbol.text)
if (index < allOcrSymbolsForSelection.size - 1) {
val nextInfo =
allOcrSymbolsForSelection[index + 1]
if (info.parentLine !== nextInfo.parentLine) append(
'\n'
)
else if (info.parentElement !== nextInfo.parentElement) append(
' '
)
}
}
}
if (fullText.isNotBlank()) {
customMenuState = CustomPdfMenuState(
selectedText = fullText,
anchorRect = selectedWordScreenRects.first(),
charRange = Pair(-1, -1)
)
}
}
}
}
}
},
onShowUpsellDialog = {
onShowDictionaryUpsellDialog()
customMenuState = null
selectionCharRange.value = null
coroutineScope.launch {
updateSelectionVisuals(
pdfDocumentItem,
pdfPageIndex,
null,
actualBitmapWidthPx,
actualBitmapHeightPx,
currentPageRotation
)
}
},
isProUser = isProUser,
isBookmarked = isBookmarked,
onBookmarkClick = onBookmarkClick,
centeringPaddingTop = centeringPaddingTop,
centeringPaddingEnd = centeringPaddingEnd,
isPerformingOcr = isPerformingOcrForSelection,
ocrRipplePos = ocrRipplePosition,
layoutCoordinates = layoutCoordinates,
contentToScreenCoordinates = contentToScreenCoordinates,
density = density,
isVerticalScroll = isVerticalScroll,
isScrolling = isScrolling,
isEditMode = isEditMode,
selectedTool = selectedTool,
eraserPosition = eraserPosition,
richTextController = richTextController,
textBoxes = textBoxes,
selectedTextBoxId = selectedTextBoxId,
onTextBoxChange = onTextBoxChange,
onTextBoxSelect = onTextBoxSelect,
onTextBoxDragStart = onTextBoxDragStart,
onTextBoxDrag = onTextBoxDrag,
onTextBoxDragEnd = onTextBoxDragEnd,
onDragPageTurn = onDragPageTurn,
draggingBoxId = draggingBoxId
)
}
else -> {
Text(
text = "Unable to display page ${pageIndex + 1}.",
modifier = Modifier
.padding(16.dp)
.align(Alignment.Center)
)
}
}
}
}
}
@Composable
private fun OcrProcessingIndicator(position: Offset) {
val infiniteTransition = rememberInfiniteTransition(label = "ocr_indicator_transition")
val animatedRadius by infiniteTransition.animateFloat(
initialValue = 20f, targetValue = 120f, animationSpec = infiniteRepeatable(
animation = tween(1200), repeatMode = RepeatMode.Restart
), label = "ocr_radius"
)
val animatedAlpha by infiniteTransition.animateFloat(
initialValue = 0.8f, targetValue = 0f, animationSpec = infiniteRepeatable(
animation = tween(1200), repeatMode = RepeatMode.Restart
), label = "ocr_alpha"
)
val color = MaterialTheme.colorScheme.primary
Canvas(modifier = Modifier.fillMaxSize()) {
drawCircle(
color = color.copy(alpha = animatedAlpha),
radius = animatedRadius,
center = position,
style = Stroke(width = (4.dp * animatedAlpha).toPx())
)
}
}
@Composable
private fun PdfBitmapLayer(
bitmapState: Bitmap?,
tiles: List<PdfTile>,
effectiveScale: Float,
centeringOffsetX: Float,
centeringOffsetY: Float,
@Suppress("unused") canvasWidth: Float,
@Suppress("unused") canvasHeight: Float,
targetWidth: Int,
targetHeight: Int,
colorFilter: ColorFilter? = null,
imageScreenRects: List<Rect> = emptyList(),
isDarkMode: Boolean = false
) {
SideEffect {
Timber.tag("PdfDrawPerf")
.v("BITMAP LAYER: SideEffect (Scale: $effectiveScale, Tiles: ${tiles.size})")
}
Canvas(modifier = Modifier
.fillMaxSize()
.graphicsLayer()) {
val drawStart = System.nanoTime()
Timber.tag("PdfDrawPerf").v("BITMAP LAYER: Canvas Draw Start (Tiles: ${tiles.size})")
translate(left = centeringOffsetX, top = centeringOffsetY) {
if (bitmapState != null && !bitmapState.isRecycled) {
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)
val dstSize = IntSize(dstW, dstH)
// 1. Draw Base Bitmap (with Dark Mode filter if active)
drawImage(
image = bitmapState.asImageBitmap(),
srcOffset = IntOffset.Zero,
srcSize = srcSize,
dstOffset = IntOffset.Zero,
dstSize = dstSize,
colorFilter = colorFilter
)
// 2. If Dark Mode, draw Images ON TOP without filter
if (isDarkMode && imageScreenRects.isNotEmpty()) {
imageScreenRects.forEach { rect ->
val scaleX = bitmapState.width.toFloat() / dstW.toFloat()
val scaleY = bitmapState.height.toFloat() / dstH.toFloat()
val srcLeft = (rect.left * scaleX).toInt().coerceAtLeast(0)
val srcTop = (rect.top * scaleY).toInt().coerceAtLeast(0)
val srcRight = (rect.right * scaleX).toInt().coerceAtMost(bitmapState.width)
val srcBottom =
(rect.bottom * scaleY).toInt().coerceAtMost(bitmapState.height)
val w = srcRight - srcLeft
val h = srcBottom - srcTop
if (w > 0 && h > 0) {
drawImage(
image = bitmapState.asImageBitmap(),
srcOffset = IntOffset(srcLeft, srcTop),
srcSize = IntSize(w, h),
dstOffset = IntOffset(rect.left, rect.top),
dstSize = IntSize(rect.width(), rect.height()),
colorFilter = null
)
}
}
}
// 3. Draw Tiles
if (effectiveScale > 1f) {
tiles.forEach { tile ->
if (!tile.bitmap.isRecycled) {
drawImage(
image = tile.bitmap.asImageBitmap(),
srcOffset = IntOffset.Zero,
srcSize = IntSize(tile.bitmap.width, tile.bitmap.height),
dstOffset = IntOffset(tile.renderRect.left, tile.renderRect.top),
dstSize = IntSize(
tile.renderRect.width(), tile.renderRect.height()
),
colorFilter = colorFilter
)
// Draw Tile Images (Original Colors)
if (isDarkMode && imageScreenRects.isNotEmpty()) {
imageScreenRects.forEach { imgRect ->
if (Rect.intersects(imgRect, tile.renderRect)) {
val intersectLeft =
maxOf(imgRect.left, tile.renderRect.left)
val intersectTop = maxOf(imgRect.top, tile.renderRect.top)
val intersectRight =
minOf(imgRect.right, tile.renderRect.right)
val intersectBottom =
minOf(imgRect.bottom, tile.renderRect.bottom)
val width = intersectRight - intersectLeft
val height = intersectBottom - intersectTop
if (width > 0 && height > 0) {
val tileRelX = intersectLeft - tile.renderRect.left
val tileRelY = intersectTop - tile.renderRect.top
val tileScaleX =
tile.bitmap.width.toFloat() / tile.renderRect.width()
.toFloat()
val tileScaleY =
tile.bitmap.height.toFloat() / tile.renderRect.height()
.toFloat()
val srcX = (tileRelX * tileScaleX).toInt()
val srcY = (tileRelY * tileScaleY).toInt()
val srcW = (width * tileScaleX).toInt()
val srcH = (height * tileScaleY).toInt()
if (srcW > 0 && srcH > 0) {
drawImage(
image = tile.bitmap.asImageBitmap(),
srcOffset = IntOffset(srcX, srcY),
srcSize = IntSize(srcW, srcH),
dstOffset = IntOffset(
intersectLeft, intersectTop
),
dstSize = IntSize(width, height),
colorFilter = null // Original Colors
)
}
}
}
}
}
}
}
}
}
}
val drawTime = (System.nanoTime() - drawStart) / 1_000_000f
Timber.tag("PdfDrawPerf").v("BITMAP LAYER: Canvas draw finished in ${drawTime}ms")
}
}
@Composable
private fun PdfHighlightsLayer(
pageLinks: List<PageLink>,
showAllTextHighlights: Boolean,
actualBitmapWidthPx: Int,
actualBitmapHeightPx: Int,
mergedAllTextPageHighlightRects: List<Rect>,
mergedTtsHighlightRects: List<Rect>,
mergedSearchFocusedRects: List<Rect>,
mergedSearchAllRects: List<Rect>,
searchHighlightMode: SearchHighlightMode,
ocrHoverHighlights: List<RectF>,
mergedSelectionRects: List<Rect>,
centeringOffsetX: Float,
centeringOffsetY: Float,
linkHighlightColor: Color,
scrimColorForTextHighlight: Color,
allTextPageHighlightColor: Color,
ttsHighlightColor: Color,
selectionHighlightColor: Color
) {
Timber.d("PdfHighlightsLayer Recompose")
Canvas(modifier = Modifier
.fillMaxSize()
.graphicsLayer()) {
translate(left = centeringOffsetX, top = centeringOffsetY) {
fun isVisible(r: Rect): Boolean {
val left = r.left + centeringOffsetX
val right = r.right + centeringOffsetX
val bottom = r.bottom + centeringOffsetY
return left < size.width && right < size.height && right > 0 && bottom > 0
}
// 1. Page Links
pageLinks.forEach { link ->
if (isVisible(link.highlightBounds)) {
drawRect(
color = linkHighlightColor, topLeft = Offset(
link.highlightBounds.left.toFloat(), link.highlightBounds.top.toFloat()
), size = Size(
link.highlightBounds.width().toFloat(),
link.highlightBounds.height().toFloat()
)
)
}
}
// 2. Search Results - BACKGROUND (Yellow)
if (searchHighlightMode == SearchHighlightMode.ALL) {
val yellowColor = Color(0xFFFFEB3B).copy(alpha = 0.4f)
mergedSearchAllRects.forEach { rect ->
if (rect.width() > 0 && rect.height() > 0 && isVisible(rect)) {
val inflated = RectF(rect)
inflated.inset(-3f, -3f) // padding
drawRect(
color = yellowColor,
topLeft = Offset(inflated.left, inflated.top),
size = Size(inflated.width(), inflated.height())
)
}
}
}
// 3. Search Results - FOCUSED (Orange + Border)
val focusedColor = Color(0xFFFF6D00).copy(alpha = 0.4f)
val focusedStroke = Color(0xFFFF6D00).copy(alpha = 0.9f)
mergedSearchFocusedRects.forEach { rect ->
if (rect.width() > 0 && rect.height() > 0 && isVisible(rect)) {
val inflated = RectF(rect)
inflated.inset(-5f, -5f) // Extra padding for focus
// Fill
drawRect(
color = focusedColor,
topLeft = Offset(inflated.left, inflated.top),
size = Size(inflated.width(), inflated.height())
)
// Border
drawRect(
color = focusedStroke,
topLeft = Offset(inflated.left, inflated.top),
size = Size(inflated.width(), inflated.height()),
style = Stroke(width = 3.dp.toPx())
)
}
}
// 4. Scrim for Text Highlights
if (showAllTextHighlights && actualBitmapWidthPx > 0 && actualBitmapHeightPx > 0 && scrimColorForTextHighlight.alpha > 0f) {
with(drawContext.canvas.nativeCanvas) {
val checkPoint = saveLayer(null, null)
drawRect(
color = scrimColorForTextHighlight, topLeft = Offset.Zero, size = Size(
actualBitmapWidthPx.toFloat(), actualBitmapHeightPx.toFloat()
)
)
mergedAllTextPageHighlightRects.forEach { rect ->
if (rect.width() > 0 && rect.height() > 0) {
drawRect(
color = Color.Transparent,
topLeft = Offset(rect.left.toFloat(), rect.top.toFloat()),
size = Size(rect.width().toFloat(), rect.height().toFloat()),
blendMode = BlendMode.Clear
)
}
}
restoreToCount(checkPoint)
}
}
// 5. All Text Highlights (Overlay)
mergedAllTextPageHighlightRects.forEach { rect ->
if (rect.width() > 0 && rect.height() > 0 && isVisible(rect)) {
drawRect(
color = allTextPageHighlightColor,
topLeft = Offset(rect.left.toFloat(), rect.top.toFloat()),
size = Size(rect.width().toFloat(), rect.height().toFloat())
)
}
}
// 6. TTS Highlights
mergedTtsHighlightRects.forEach { rect ->
if (rect.width() > 0 && rect.height() > 0 && isVisible(rect)) {
drawRect(
color = ttsHighlightColor,
topLeft = Offset(rect.left.toFloat(), rect.top.toFloat()),
size = Size(rect.width().toFloat(), rect.height().toFloat())
)
}
}
// 7. OCR Hover
ocrHoverHighlights.forEach { rectF ->
val left = rectF.left * actualBitmapWidthPx
val top = rectF.top * actualBitmapHeightPx
val width = rectF.width() * actualBitmapWidthPx
val height = rectF.height() * actualBitmapHeightPx
val absLeft = left + centeringOffsetX
val absTop = top + centeringOffsetY
if (absLeft < size.width && absTop < size.height && (absLeft + width) > 0 && (absTop + height) > 0) {
drawRect(
color = Color(0xFFFFAB00).copy(alpha = 0.5f), // Generic highlight color
topLeft = Offset(left, top), size = Size(width, height)
)
}
}
// 8. User Selection
mergedSelectionRects.forEach { lineRect ->
if (lineRect.width() > 0 && lineRect.height() > 0 && isVisible(lineRect)) {
drawRect(
color = selectionHighlightColor,
topLeft = Offset(lineRect.left.toFloat(), lineRect.top.toFloat()),
size = Size(lineRect.width().toFloat(), lineRect.height().toFloat())
)
}
}
}
}
}
internal object PdfTextureGenerator {
private var noiseBitmap: Bitmap? = null
fun getNoiseTexture(): Bitmap {
if (noiseBitmap == null) {
val size = 256
val bitmap = createBitmap(size, size, Bitmap.Config.ARGB_8888)
for (x in 0 until size) {
for (y in 0 until size) {
val isGrain = Math.random() > 0.4
if (isGrain) {
val alpha = (Math.random() * 100 + 100).toInt()
bitmap[x, y] = AndroidColor.argb(alpha, 0, 0, 0)
} else {
bitmap[x, y] = AndroidColor.TRANSPARENT
}
}
}
noiseBitmap = bitmap
}
return noiseBitmap!!
}
}
internal sealed interface AnnotationRenderData {
data class Standard(
val path: Path,
val color: Color,
val strokeWidth: Float,
val cap: StrokeCap,
val blendMode: BlendMode
) : AnnotationRenderData
data class Fountain(val path: Path, val color: Color) : AnnotationRenderData
data class Pencil(
val path: android.graphics.Path,
val color: Color,
val strokeWidth: Float,
val velocityAlpha: Float
) : AnnotationRenderData
}
internal object PdfAnnotationRenderHelper {
fun createRenderData(annot: PdfAnnotation, widthPx: Int, heightPx: Int): AnnotationRenderData? {
if (annot.points.isEmpty()) return null
if (annot.points.size == 1) {
val p = annot.points[0]
val x = p.x * widthPx
val y = p.y * heightPx
val path = if (annot.inkType == InkType.PENCIL) android.graphics.Path() else Path()
if (path is android.graphics.Path) {
path.moveTo(x, y)
path.lineTo(x, y)
return AnnotationRenderData.Pencil(
path = path,
color = annot.color,
strokeWidth = annot.strokeWidth * widthPx,
velocityAlpha = 1.0f
)
} else if (path is Path) {
if (annot.inkType == InkType.FOUNTAIN_PEN) {
val radius = (annot.strokeWidth * widthPx) / 2f
path.addOval(
androidx.compose.ui.geometry.Rect(
center = Offset(x, y), radius = radius
)
)
return AnnotationRenderData.Fountain(path = path, color = annot.color)
}
path.moveTo(x, y)
path.lineTo(x, y)
val cap = when (annot.inkType) {
InkType.HIGHLIGHTER -> StrokeCap.Butt
InkType.HIGHLIGHTER_ROUND -> StrokeCap.Round
else -> StrokeCap.Round
}
val blendMode = BlendMode.SrcOver
return AnnotationRenderData.Standard(
path = path,
color = annot.color,
strokeWidth = annot.strokeWidth * widthPx,
cap = cap,
blendMode = blendMode
)
}
}
val result = when (annot.inkType) {
InkType.PENCIL -> {
val path = android.graphics.Path()
val first = annot.points[0]
path.moveTo(first.x * widthPx, first.y * heightPx)
var totalDist = 0f
for (i in 1 until annot.points.size) {
val p0 = annot.points[i - 1]
val p1 = annot.points[i]
val p0x = p0.x * widthPx
val p0y = p0.y * heightPx
val p1x = p1.x * widthPx
val p1y = p1.y * heightPx
val midX = (p0x + p1x) / 2f
val midY = (p0y + p1y) / 2f
val dx = p1x - p0x
val dy = p1y - p0y
totalDist += sqrt(dx * dx + dy * dy)
if (i == 1) path.lineTo(midX, midY)
else path.quadTo(p0x, p0y, midX, midY)
}
val last = annot.points.last()
path.lineTo(last.x * widthPx, last.y * heightPx)
val duration =
(annot.points.last().timestamp - annot.points.first().timestamp).coerceAtLeast(1)
val velocity = totalDist / duration
val velocityAlphaFactor = (1f - (velocity - 0.2f) / 1.8f).coerceIn(0.4f, 1.0f)
AnnotationRenderData.Pencil(
path = path,
color = annot.color,
strokeWidth = annot.strokeWidth * widthPx,
velocityAlpha = velocityAlphaFactor
)
}
InkType.FOUNTAIN_PEN -> {
val baseStrokeWidth = annot.strokeWidth * widthPx
val path = Path()
val (leftSide, rightSide) = PdfInkGeometry.calculateFountainPenPoints(
annot.points, baseStrokeWidth, widthPx.toFloat(), heightPx.toFloat()
)
if (leftSide.isNotEmpty()) {
path.moveTo(leftSide[0].x, leftSide[0].y)
for (i in 1 until leftSide.size) {
path.lineTo(leftSide[i].x, leftSide[i].y)
}
for (i in rightSide.size - 1 downTo 0) {
path.lineTo(rightSide[i].x, rightSide[i].y)
}
path.close()
}
AnnotationRenderData.Fountain(path = path, color = annot.color)
}
else -> {
val path = Path()
val first = annot.points[0]
path.moveTo(first.x * widthPx, first.y * heightPx)
for (i in 1 until annot.points.size) {
val p0 = annot.points[i - 1]
val p1 = annot.points[i]
val p0x = p0.x * widthPx
val p0y = p0.y * heightPx
val p1x = p1.x * widthPx
val p1y = p1.y * heightPx
val midX = (p0x + p1x) / 2f
val midY = (p0y + p1y) / 2f
if (i == 1) path.lineTo(midX, midY)
else path.quadraticTo(p0x, p0y, midX, midY)
}
val last = annot.points.last()
path.lineTo(last.x * widthPx, last.y * heightPx)
val blendMode = when (annot.inkType) {
InkType.HIGHLIGHTER, InkType.HIGHLIGHTER_ROUND -> BlendMode.Multiply
else -> BlendMode.SrcOver
}
val cap = when (annot.inkType) {
InkType.HIGHLIGHTER -> StrokeCap.Butt
InkType.HIGHLIGHTER_ROUND -> StrokeCap.Round
else -> StrokeCap.Round
}
AnnotationRenderData.Standard(
path = path,
color = annot.color,
strokeWidth = annot.strokeWidth * widthPx,
cap = cap,
blendMode = blendMode
)
}
}
return result
}
}
@Composable
private fun PdfAnnotationLayer(
actualBitmapWidthPx: Int,
actualBitmapHeightPx: Int,
annotationsProvider: () -> List<PdfAnnotation>,
drawingState: PdfDrawingState?,
centeringOffsetX: Float,
centeringOffsetY: Float,
pageIndex: Int
) {
SideEffect { Timber.tag("PdfDrawPerf").v("ANNOT LAYER: Recomposing (Page $pageIndex)") }
val staticAnnotations = annotationsProvider()
val currentAnnotation = remember(drawingState, pageIndex) {
derivedStateOf {
val annot = drawingState?.currentAnnotation
val result = if (annot?.pageIndex == pageIndex) annot else null
if (drawingState != null) {
Timber.tag("PdfDrawPerf").v(
"DerivedState Calc Page $pageIndex: Global=${annot?.pageIndex} -> Result=${result != null}"
)
}
result
}
}.value
SideEffect {
Timber.tag("PdfDrawPerf").v(
"ANNOT LAYER: State Check Page $pageIndex | AnnotHash: ${currentAnnotation?.hashCode()} | AnnotPoints: ${currentAnnotation?.points?.size}"
)
}
val staticRenderData = remember(staticAnnotations, actualBitmapWidthPx, actualBitmapHeightPx) {
staticAnnotations.mapNotNull { annot ->
PdfAnnotationRenderHelper.createRenderData(
annot, actualBitmapWidthPx, actualBitmapHeightPx
)
}
}
val activeRenderData = remember(
currentAnnotation,
currentAnnotation?.points?.size,
actualBitmapWidthPx,
actualBitmapHeightPx
) {
if (currentAnnotation != null) {
Timber.tag("PdfDrawPerf").v(
"ANNOT LAYER: Generating active path for ${currentAnnotation.points.size} points"
)
}
currentAnnotation?.let { annot ->
PdfAnnotationRenderHelper.createRenderData(
annot, actualBitmapWidthPx, actualBitmapHeightPx
)
}
}
Canvas(modifier = Modifier.fillMaxSize()) {
val drawStart = System.nanoTime()
translate(left = centeringOffsetX, top = centeringOffsetY) {
fun drawData(data: AnnotationRenderData) {
when (data) {
is AnnotationRenderData.Standard -> {
drawPath(
path = data.path, color = data.color, style = Stroke(
width = data.strokeWidth, cap = data.cap, join = StrokeJoin.Round
), blendMode = data.blendMode
)
}
is AnnotationRenderData.Fountain -> {
drawPath(
path = data.path,
color = data.color,
style = androidx.compose.ui.graphics.drawscope.Fill
)
}
is AnnotationRenderData.Pencil -> {
val texture = PdfTextureGenerator.getNoiseTexture()
drawIntoCanvas { canvas ->
val paint = NativePaint().apply {
isAntiAlias = true
style = NativePaint.Style.STROKE
strokeCap = NativePaint.Cap.ROUND
strokeJoin = NativePaint.Join.ROUND
strokeWidth = data.strokeWidth
shader = BitmapShader(
texture, Shader.TileMode.REPEAT, Shader.TileMode.REPEAT
)
colorFilter = PorterDuffColorFilter(
data.color.toArgb(), PorterDuff.Mode.SRC_IN
)
alpha = (data.color.alpha * data.velocityAlpha * 255).toInt()
}
canvas.nativeCanvas.drawPath(data.path, paint)
}
}
}
}
staticRenderData.forEach { drawData(it) }
activeRenderData?.let { drawData(it) }
}
val drawDuration = (System.nanoTime() - drawStart) / 1_000_000f
Timber.tag("PdfDrawPerf").v(
"ANNOT DRAW: Canvas draw took ${drawDuration}ms. Points: ${currentAnnotation?.points?.size ?: 0}"
)
}
}
@Composable
private fun PdfPageStaticLayer(data: PageStaticData) {
SideEffect {
Timber.tag("PdfDrawPerf").v(
"STATIC LAYER: Composition (Should not happen during draw). DataHash: ${data.hashCode()}"
)
}
Timber.tag("PdfDrawPerf").v("STATIC LAYER: Recomposing")
PdfBitmapLayer(
bitmapState = data.bitmap.item,
tiles = data.tiles.item,
effectiveScale = data.effectiveScale,
centeringOffsetX = data.centeringOffsetX,
centeringOffsetY = data.centeringOffsetY,
canvasWidth = data.canvasWidth,
canvasHeight = data.canvasHeight,
targetWidth = data.targetWidth,
targetHeight = data.targetHeight,
colorFilter = data.colorFilter.item,
imageScreenRects = data.imageScreenRects.item,
isDarkMode = data.isDarkMode
)
}
@Composable
private fun PdfPageSelectionsLayer(
pageLinks: List<PageLink>,
showAllTextHighlights: Boolean,
actualBitmapWidthPx: Int,
actualBitmapHeightPx: Int,
mergedAllTextPageHighlightRects: List<Rect>,
mergedTtsHighlightRects: List<Rect>,
mergedSearchFocusedRects: List<Rect>,
mergedSearchAllRects: List<Rect>,
searchHighlightMode: SearchHighlightMode,
ocrHoverHighlights: List<RectF>,
mergedSelectionRects: List<Rect>,
centeringOffsetX: Float,
centeringOffsetY: Float,
linkHighlightColor: Color,
scrimColorForTextHighlight: Color,
allTextPageHighlightColor: Color,
ttsHighlightColor: Color,
selectionHighlightColor: Color
) {
SideEffect { Timber.tag("PdfDrawPerf").v("SELECTIONS LAYER: Recomposing") }
val highlightStart = System.nanoTime()
PdfHighlightsLayer(
pageLinks = pageLinks,
showAllTextHighlights = showAllTextHighlights,
actualBitmapWidthPx = actualBitmapWidthPx,
actualBitmapHeightPx = actualBitmapHeightPx,
mergedAllTextPageHighlightRects = mergedAllTextPageHighlightRects,
mergedTtsHighlightRects = mergedTtsHighlightRects,
mergedSearchFocusedRects = mergedSearchFocusedRects,
mergedSearchAllRects = mergedSearchAllRects,
searchHighlightMode = searchHighlightMode,
ocrHoverHighlights = ocrHoverHighlights,
mergedSelectionRects = mergedSelectionRects,
centeringOffsetX = centeringOffsetX,
centeringOffsetY = centeringOffsetY,
linkHighlightColor = linkHighlightColor,
scrimColorForTextHighlight = scrimColorForTextHighlight,
allTextPageHighlightColor = allTextPageHighlightColor,
ttsHighlightColor = ttsHighlightColor,
selectionHighlightColor = selectionHighlightColor
)
val highlightTime = (System.nanoTime() - highlightStart) / 1_000_000f
if (highlightTime > 1f) {
SideEffect {
Timber.tag("PdfPerformance")
.v("PdfHighlightsLayer composition/draw took ${highlightTime}ms")
}
}
}
@Composable
private fun PdfPageRenderer(
staticData: PageStaticData,
selectionData: PageSelectionData,
totalPages: Int,
annotationsProvider: () -> List<PdfAnnotation>,
drawingState: PdfDrawingState?,
onCanvasSizeChanged: (Float, Float) -> Unit,
scale: Float,
offset: Offset,
startHandlePos: Offset?,
endHandlePos: Offset?,
teardropWidthPx: Float,
teardropHeightPx: Float,
activeDraggingHandle: Handle?,
showMagnifier: Boolean,
magnifierCenterTarget: Offset,
magnifierZoomFactor: Float,
menuState: CustomPdfMenuState?,
onMenuDismiss: () -> Unit,
onCopy: (String) -> Unit,
onAiDefine: (String) -> Unit,
onSelectAll: () -> Unit,
onShowUpsellDialog: () -> Unit,
isProUser: Boolean,
isBookmarked: Boolean,
onBookmarkClick: () -> Unit,
centeringPaddingTop: Dp,
centeringPaddingEnd: Dp,
isPerformingOcr: Boolean,
ocrRipplePos: Offset?,
layoutCoordinates: LayoutCoordinates?,
contentToScreenCoordinates: (Offset) -> Offset,
density: Density,
isVerticalScroll: Boolean,
isScrolling: Boolean,
isEditMode: Boolean,
selectedTool: InkType,
eraserPosition: Offset?,
richTextController: RichTextController?,
textBoxes: List<PdfTextBox>,
selectedTextBoxId: String?,
onTextBoxChange: (PdfTextBox) -> Unit,
onTextBoxSelect: (String) -> Unit,
onTextBoxDragStart: (PdfTextBox, Offset, Offset) -> Unit,
onTextBoxDrag: (Offset) -> Unit,
onTextBoxDragEnd: () -> Unit,
onDragPageTurn: (Int) -> Unit,
draggingBoxId: String? = null
) {
Box(modifier = Modifier.fillMaxSize()) {
Box(
modifier = Modifier
.fillMaxSize()
.graphicsLayer {
scaleX = scale
scaleY = scale
translationX = offset.x
translationY = offset.y
}) {
// Layer 1: The Heavy Bitmap
Box(modifier = Modifier
.fillMaxSize()
.graphicsLayer()) {
PdfPageStaticLayer(data = staticData)
}
// Layer 2: The Lightweight Highlights
PdfPageSelectionsLayer(
pageLinks = selectionData.pageLinks.item,
showAllTextHighlights = selectionData.showAllTextHighlights,
actualBitmapWidthPx = selectionData.actualBitmapWidthPx,
actualBitmapHeightPx = selectionData.actualBitmapHeightPx,
mergedAllTextPageHighlightRects = selectionData.mergedAllTextPageHighlightRects.item,
mergedTtsHighlightRects = selectionData.mergedTtsHighlightRects.item,
mergedSearchFocusedRects = selectionData.mergedSearchFocusedRects.item,
mergedSearchAllRects = selectionData.mergedSearchAllRects.item,
searchHighlightMode = selectionData.searchHighlightMode,
ocrHoverHighlights = selectionData.ocrHoverHighlights.item,
mergedSelectionRects = selectionData.mergedSelectionRects.item,
centeringOffsetX = selectionData.centeringOffsetX,
centeringOffsetY = selectionData.centeringOffsetY,
linkHighlightColor = selectionData.linkHighlightColor,
scrimColorForTextHighlight = selectionData.scrimColorForTextHighlight,
allTextPageHighlightColor = selectionData.allTextPageHighlightColor,
ttsHighlightColor = selectionData.ttsHighlightColor,
selectionHighlightColor = selectionData.selectionHighlightColor
)
// Layer 3: Annotations (Drawing)
if (staticData.targetWidth > 0 && staticData.targetHeight > 0) {
Box(modifier = Modifier
.fillMaxSize()
.graphicsLayer()) {
PdfAnnotationLayer(
actualBitmapWidthPx = staticData.targetWidth,
actualBitmapHeightPx = staticData.targetHeight,
annotationsProvider = annotationsProvider,
drawingState = drawingState,
centeringOffsetX = staticData.centeringOffsetX,
centeringOffsetY = staticData.centeringOffsetY,
pageIndex = selectionData.pageIndex
)
}
if (richTextController != null) {
val density = LocalDensity.current
val textMeasurer = androidx.compose.ui.text.rememberTextMeasurer()
val targetW = staticData.targetWidth.toFloat()
val targetH = staticData.targetHeight.toFloat()
LaunchedEffect(targetW, targetH, density) {
if (targetW > 0 && targetH > 0) {
richTextController.updateLayoutConfig(
targetW, targetH, density, textMeasurer
)
}
}
val isEditable = isEditMode && selectedTool == InkType.TEXT
val hasContent = richTextController.pageLayouts.any {
it.pageIndex == selectionData.pageIndex
}
if (isEditable || hasContent) {
PdfRichTextLayer(
pageIndex = selectionData.pageIndex,
controller = richTextController,
pageWidth = staticData.targetWidth.toFloat(),
pageHeight = staticData.targetHeight.toFloat(),
isTextEditingEnabled = isEditable && selectedTextBoxId == null,
centeringOffsetX = staticData.centeringOffsetX,
centeringOffsetY = staticData.centeringOffsetY,
isDarkMode = staticData.isDarkMode,
isScrolling = isScrolling
)
}
}
// Text Boxes
textBoxes.forEach { box ->
val isDraggingThisBox = (box.id == draggingBoxId)
val boxAlpha = if (isDraggingThisBox) 0f else 1f
key(box.id) {
ResizableTextBox(
box = box,
isSelected = (box.id == selectedTextBoxId),
isEditMode = isEditMode,
isDarkMode = staticData.isDarkMode,
pageWidthPx = staticData.targetWidth.toFloat(),
pageHeightPx = staticData.targetHeight.toFloat(),
handlePosition = HandlePosition.AUTO,
onBoundsChanged = { newBounds ->
onTextBoxChange(box.copy(relativeBounds = newBounds))
},
onTextChanged = { newText ->
onTextBoxChange(box.copy(text = newText))
},
onSelect = { onTextBoxSelect(box.id) },
onDragStart = { touchOffset ->
if (isVerticalScroll) {
val topLeft = Offset(
box.relativeBounds.left * staticData.targetWidth,
box.relativeBounds.top * staticData.targetHeight
)
onTextBoxDragStart(box, topLeft, touchOffset)
} else {
onTextBoxDragStart(box, Offset.Zero, touchOffset)
}
},
onDrag = { delta, currentBounds ->
if (isVerticalScroll) {
onTextBoxDrag(delta)
} else {
onTextBoxDrag(delta)
val width = staticData.targetWidth
val edgeThreshold = 60f
if (width > 0) {
if (currentBounds.left < edgeThreshold && delta.x < 0) {
onDragPageTurn(-1)
} else if (currentBounds.right > width - edgeThreshold && delta.x > 0) {
onDragPageTurn(1)
}
}
}
},
onDragEnd = {
onTextBoxDragEnd()
},
onDragCancel = {
onTextBoxDragEnd()
},
modifier = Modifier
.zIndex(10f)
.offset {
IntOffset(
staticData.centeringOffsetX.roundToInt(),
staticData.centeringOffsetY.roundToInt()
)
}
.alpha(boxAlpha)
)
}
}
// Layer 4: Page Number Indicator
if (totalPages > 0) {
@Suppress("KotlinConstantConditions") val isOverImage = remember(
staticData.imageScreenRects,
staticData.targetWidth,
staticData.targetHeight,
density
) {
val w = staticData.targetWidth
val h = staticData.targetHeight
if (w > 0 && h > 0) {
val checkOffsetPx = with(density) { 20.dp.toPx().toInt() }
val checkX = w - checkOffsetPx
val checkY = h - checkOffsetPx
staticData.imageScreenRects.item.any { rect ->
rect.contains(checkX, checkY)
}
} else {
false
}
}
val pageNumColor = if (staticData.isDarkMode && !isOverImage) {
Color.White
} else {
Color.Black
}
Box(modifier = Modifier
.offset {
IntOffset(
x = staticData.centeringOffsetX.toInt(),
y = staticData.centeringOffsetY.toInt()
)
}
.size(width = with(density) {
staticData.targetWidth.toDp()
}, height = with(density) {
staticData.targetHeight.toDp()
})) {
Text(
text = "${selectionData.pageIndex + 1}\\$totalPages",
color = pageNumColor.copy(alpha = 0.5f),
style = MaterialTheme.typography.labelSmall.copy(
fontSize = 12.sp, fontWeight = FontWeight.Bold
),
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(end = 12.dp, bottom = 12.dp)
)
}
}
}
// Capture size for coordinate conversions
Canvas(modifier = Modifier.fillMaxSize()) {
if (staticData.canvasWidth != size.width || staticData.canvasHeight != size.height) {
onCanvasSizeChanged(size.width, size.height)
}
}
}
val teardropPainter = painterResource(id = R.drawable.teardrop)
if (isEditMode && selectedTool == InkType.ERASER && eraserPosition != null) {
Canvas(modifier = Modifier.fillMaxSize()) {
val radiusPx = 8.dp.toPx()
drawCircle(
color = Color.White.copy(alpha = 0.3f),
radius = radiusPx,
center = eraserPosition
)
drawCircle(
color = Color.Black,
radius = radiusPx,
center = eraserPosition,
style = Stroke(width = 1.dp.toPx())
)
}
}
Canvas(modifier = Modifier.fillMaxSize()) {
val handleColor = Color.Blue
val tiltAngleDegrees = 30f
startHandlePos?.let { contentPos ->
val position = contentToScreenCoordinates(contentPos)
translate(left = position.x - teardropWidthPx / 2, top = position.y) {
rotate(degrees = tiltAngleDegrees, pivot = Offset(teardropWidthPx / 2f, 0f)) {
with(teardropPainter) {
draw(
size = Size(teardropWidthPx, teardropHeightPx),
colorFilter = ColorFilter.tint(handleColor)
)
}
}
}
}
endHandlePos?.let { contentPos ->
val position = contentToScreenCoordinates(contentPos)
translate(left = position.x - teardropWidthPx / 2, top = position.y) {
rotate(degrees = -tiltAngleDegrees, pivot = Offset(teardropWidthPx / 2f, 0f)) {
with(teardropPainter) {
draw(
size = Size(teardropWidthPx, teardropHeightPx),
colorFilter = ColorFilter.tint(handleColor)
)
}
}
}
}
}
if (showMagnifier && activeDraggingHandle != null && staticData.bitmap.item != null) {
val handleContentPos = when (activeDraggingHandle) {
Handle.START -> startHandlePos
Handle.END -> endHandlePos
}
handleContentPos?.let { contentPos ->
val pos = contentToScreenCoordinates(contentPos)
val magnifierWidth = 120.dp
val magnifierHeight = 60.dp
val magnifierOffsetAboveHandle = 24.dp
with(density) {
val magnifierWidthPx = magnifierWidth.toPx()
val magnifierHeightPx = magnifierHeight.toPx()
val magnifierOffsetAboveHandlePx = magnifierOffsetAboveHandle.toPx()
val effectiveScale = staticData.effectiveScale
val modifier: Modifier
val effectiveZoomFactor: Float
if (isVerticalScroll && effectiveScale > 1f) {
val yOffsetPixels =
pos.y - (magnifierHeightPx + magnifierOffsetAboveHandlePx) / effectiveScale
val xOffsetPixels = pos.x - (magnifierWidthPx / 2) / effectiveScale
modifier =
Modifier
.offset(x = xOffsetPixels.toDp(), y = yOffsetPixels.toDp())
.graphicsLayer(
scaleX = 1f / effectiveScale,
scaleY = 1f / effectiveScale,
transformOrigin = TransformOrigin(0f, 0f)
)
effectiveZoomFactor = effectiveScale * 1.25f
} else {
val xOffsetVal = pos.x - magnifierWidthPx / 2
val yOffsetVal = pos.y - magnifierHeightPx - magnifierOffsetAboveHandlePx
modifier = Modifier.offset(x = xOffsetVal.toDp(), y = yOffsetVal.toDp())
effectiveZoomFactor = magnifierZoomFactor
}
MagnifierComposable(
sourceBitmap = staticData.bitmap.item.asImageBitmap(),
tiles = if (effectiveScale > 1f) staticData.tiles.item else emptyList(),
currentScale = effectiveScale,
magnifierCenterOnBitmap = magnifierCenterTarget,
magnifierWidth = magnifierWidth,
magnifierHeight = magnifierHeight,
zoomFactor = effectiveZoomFactor,
selectionRectsInBitmapCoords = selectionData.mergedSelectionRects.item,
highlightColor = Color(0x6633B5E5),
colorFilter = staticData.colorFilter.item,
modifier = modifier
)
}
}
}
if (menuState != null) {
BackHandler(enabled = true, onBack = onMenuDismiss)
}
menuState?.let { state ->
if (state.anchorRect.width() > 0 || state.anchorRect.height() > 0) {
val popupPositionProvider =
remember(state.anchorRect, density, offset, scale, layoutCoordinates) {
object : PopupPositionProvider {
override fun calculatePosition(
anchorBounds: IntRect,
windowSize: IntSize,
layoutDirection: LayoutDirection,
popupContentSize: IntSize
): IntOffset {
val coords = layoutCoordinates ?: return IntOffset.Zero
val menuAnchorContentRect = state.anchorRect
val topLeftLocal = contentToScreenCoordinates(
Offset(
menuAnchorContentRect.left.toFloat(),
menuAnchorContentRect.top.toFloat()
)
)
val bottomRightLocal = contentToScreenCoordinates(
Offset(
menuAnchorContentRect.right.toFloat(),
menuAnchorContentRect.bottom.toFloat()
)
)
val topLeftWindow = coords.localToWindow(topLeftLocal)
val bottomRightWindow = coords.localToWindow(bottomRightLocal)
val windowCenterX = (topLeftWindow.x + bottomRightWindow.x) / 2
val windowTopY = topLeftWindow.y
val windowBottomY = bottomRightWindow.y
val xInWindow = windowCenterX - popupContentSize.width / 2
var yInWindow =
windowTopY - popupContentSize.height - with(density) { 8.dp.toPx() }
if (yInWindow < 0) {
yInWindow = windowBottomY + with(density) { 8.dp.toPx() }
}
val finalX = xInWindow.toInt().coerceIn(
0, windowSize.width - popupContentSize.width
)
val finalY = yInWindow.toInt().coerceIn(
0, windowSize.height - popupContentSize.height
)
return IntOffset(finalX, finalY)
}
}
}
PdfSelectionMenuPopup(
menuState = state,
popupPositionProvider = popupPositionProvider,
onCopy = onCopy,
onAiDefine = onAiDefine,
onSelectAll = onSelectAll,
isProUser = isProUser,
onShowUpsellDialog = onShowUpsellDialog
)
}
}
BookmarkButton(
isBookmarked = isBookmarked,
onClick = onBookmarkClick,
modifier = Modifier
.align(Alignment.TopEnd)
.padding(top = centeringPaddingTop, end = centeringPaddingEnd)
)
if (isPerformingOcr && ocrRipplePos != null) {
OcrProcessingIndicator(position = ocrRipplePos)
}
}
}
@Stable
class PdfDrawingState {
var currentAnnotation by mutableStateOf<PdfAnnotation?>(null)
private set
private val currentPoints = mutableListOf<PdfPoint>()
fun onDrawStart(pageIndex: Int, point: PdfPoint, type: InkType, color: Color, width: Float) {
currentPoints.clear()
currentPoints.add(point)
currentAnnotation = PdfAnnotation(
type = AnnotationType.INK,
inkType = type,
pageIndex = pageIndex,
points = currentPoints.toList(),
color = color,
strokeWidth = width
)
}
fun onDraw(point: PdfPoint) {
currentPoints.add(point)
currentAnnotation = currentAnnotation?.copy(points = currentPoints.toList())
}
fun onDrawCancel() {
currentAnnotation = null
currentPoints.clear()
}
fun onDrawEnd(): PdfAnnotation? {
val finalAnnot = currentAnnotation
currentAnnotation = null
currentPoints.clear()
return finalAnnot
}
}
@Composable
fun PdfRichTextLayer(
pageIndex: Int,
controller: RichTextController,
pageWidth: Float,
pageHeight: Float,
isTextEditingEnabled: Boolean,
centeringOffsetX: Float,
centeringOffsetY: Float,
isDarkMode: Boolean,
isScrolling: Boolean
) {
val density = LocalDensity.current
val textMeasurer = androidx.compose.ui.text.rememberTextMeasurer()
LaunchedEffect(pageWidth, pageHeight, density) {
if (pageWidth > 0 && pageHeight > 0) {
controller.updateLayoutConfig(pageWidth, pageHeight, density, textMeasurer)
}
}
val pageLayout = remember(controller.pageLayouts, pageIndex) {
controller.pageLayouts.find { it.pageIndex == pageIndex }
}
val marginX = pageWidth * 0.1f
val marginY = pageHeight * 0.08f
val editorWidth = pageWidth - (marginX * 2)
val editorHeight = pageHeight - (marginY * 2)
val editorWidthDp = with(density) { editorWidth.toDp() }
val editorHeightDp = with(density) { editorHeight.toDp() }
Box(modifier = Modifier
.offset {
IntOffset(
(centeringOffsetX + marginX).roundToInt(), (centeringOffsetY + marginY).roundToInt()
)
}
.size(editorWidthDp, editorHeightDp)
.graphicsLayer()
.then(
if (isTextEditingEnabled) {
Modifier.pointerInput(pageIndex) {
detectTapGestures { tapOffset ->
controller.handleTapOnPage(pageIndex, tapOffset)
}
}
} else {
Modifier
}
)
) {
val textToRender = if (controller.activePageIndex == pageIndex) {
controller.localTextFieldValue.annotatedString
} else {
pageLayout?.visibleText
}
if (textToRender != null) {
val measureResult = remember(textToRender, editorWidth, density) {
textMeasurer.measure(
text = textToRender,
style = TextStyle(fontSize = 16.sp),
constraints = Constraints(maxWidth = editorWidth.toInt()),
density = density
)
}
Canvas(modifier = Modifier.fillMaxSize()) {
measureResult.multiParagraph.paint(drawContext.canvas)
}
if (isTextEditingEnabled) {
val tfv = controller.editingValue
val selection = tfv.selection
@Suppress("ControlFlowWithEmptyBody") if (controller.activePageIndex == pageIndex) {
val localStart = selection.start.coerceIn(0, textToRender.length)
val localEnd = selection.end.coerceIn(0, textToRender.length)
if (localStart != localEnd) {
val selectionPath = measureResult.getPathForRange(localStart, localEnd)
Canvas(modifier = Modifier.fillMaxSize()) {
drawPath(selectionPath, Color(0xFFB3D7FF).copy(alpha = 0.5f))
}
}
if (selection.collapsed && controller.isCursorVisible) {
val alpha = if (isScrolling) {
1f
} else {
val infiniteTransition = rememberInfiniteTransition(label = "cursor")
infiniteTransition.animateFloat(
initialValue = 1f,
targetValue = 0f,
animationSpec = infiniteRepeatable(tween(500), RepeatMode.Reverse),
label = "cursorAlpha"
).value
}
val cursorRect = measureResult.getCursorRect(localStart)
val cursorColor = if (isDarkMode) Color.White else Color.Black
val styleFontSize = controller.currentStyle.fontSize
val cursorHeight = if (styleFontSize.isSpecified) {
with(density) { styleFontSize.toPx() } * 1.2f
} else {
cursorRect.height
}
val centerY = cursorRect.center.y
val newTop = centerY - (cursorHeight / 2f)
val newBottom = centerY + (cursorHeight / 2f)
Canvas(modifier = Modifier.fillMaxSize()) {
drawLine(
color = cursorColor.copy(alpha = alpha),
start = Offset(cursorRect.left, newTop),
end = Offset(cursorRect.left, newBottom),
strokeWidth = 2.dp.toPx()
)
}
}
} else if (pageLayout != null) { }
}
}
}
}