// PdfPageComposable @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.ui.graphics.drawscope.withTransform 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.ImageBitmap import androidx.compose.ui.graphics.ImageShader import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.ShaderBrush import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeJoin import androidx.compose.ui.graphics.TileMode import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.clipRect 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.PointerType import androidx.compose.ui.input.pointer.changedToUp import androidx.compose.ui.input.pointer.isPrimaryPressed import androidx.compose.ui.input.pointer.isSecondaryPressed import androidx.compose.ui.input.pointer.isTertiaryPressed 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.res.stringResource 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.isCanvasSafeBitmap import com.aryan.reader.loadReaderTextureBitmap import com.aryan.reader.ml.SpeechBubble import com.aryan.reader.pdf.data.PdfAnnotation import com.aryan.reader.pdf.data.PdfTextBox import com.aryan.reader.pdf.data.VirtualPage import com.aryan.reader.pdf.ocr.OcrElement import com.aryan.reader.pdf.ocr.OcrResult import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.conflate 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.max 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 } internal fun shouldReportPdfPageCamera( isZoomEnabled: Boolean, isVerticalScroll: Boolean, isScrollLocked: Boolean, lockedState: Triple?, hasAppliedLockedState: Boolean ): Boolean { return !isZoomEnabled || isVerticalScroll || !isScrollLocked || lockedState == null || hasAppliedLockedState } internal fun initialPdfPageCamera( isZoomEnabled: Boolean, isVerticalScroll: Boolean, isScrollLocked: Boolean, lockedState: Triple? ): Pair { return if (isZoomEnabled && !isVerticalScroll && isScrollLocked && lockedState != null) { lockedState.first to Offset(lockedState.second, lockedState.third) } else { 1f to Offset.Zero } } internal fun shouldResetPdfZoomAfterBubbleZoomCleanup( isBubbleZoomModeActive: Boolean, scale: Float, isVerticalScroll: Boolean, isZoomEnabled: Boolean, isScrollLocked: Boolean ): Boolean { return !isBubbleZoomModeActive && scale > 1f && !isVerticalScroll && isZoomEnabled && !isScrollLocked } data class EmbeddedAnnotation( val index: Int, val subtype: Int, val rect: android.graphics.RectF, val contents: String?, val author: String?, val name: String?, val inReplyTo: String?, val replies: MutableList = mutableListOf() ) data class PdfPoint(val x: Float, val y: Float, val timestamp: Long = 0L) data class PdfTile(val bitmap: Bitmap, val renderRect: Rect, val tileId: Int, val renderScale: Float = 1f) internal fun pdfRenderPageId(documentKey: String, pageIndex: Int, virtualPage: VirtualPage?): String { val sourcePageId = when (virtualPage) { is VirtualPage.BlankPage -> "BLANK_${virtualPage.id}" is VirtualPage.PdfPage -> "PDF_${virtualPage.pdfIndex}" null -> "PDF_$pageIndex" } return "$documentKey:$sourcePageId" } private fun Throwable.readablePdfErrorDetail(): String { return localizedMessage?.takeIf { it.isNotBlank() } ?: javaClass.simpleName.takeIf { it.isNotBlank() } ?: "Unknown error" } private const val PDF_TILE_SIZE_DP = 256 private const val PDF_MAX_TILE_BITMAP_SIZE_PX = 3072 private const val PDF_MAX_DRAW_BITMAP_BYTES = 64L * 1024L * 1024L private const val PDF_MAX_DRAW_BITMAP_DIMENSION_PX = 4096 private const val PDF_TILE_SCALE_TOLERANCE = 0.06f private const val PDF_TILE_IDLE_RENDER_DELAY_MS = 90L private const val PDF_PAGINATION_PAN_FLING_MIN_VELOCITY = 600f private const val PDF_PAGINATION_PAN_FLING_MULTIPLIER = 0.72f enum class LinkSource { ANNOTATION, TEXT_CONTENT } data class PageLink( val highlightBounds: Rect, val tapBounds: Rect, val url: String?, val destPageIdx: Int?, val source: LinkSource ) private data class ExpandedBubbleRender( val bitmap: Bitmap, val zoomFactor: Float ) private fun computeDynamicBubbleZoomFactor( bubbleBounds: RectF, viewportWidth: Float, viewportHeight: Float ): Float { if (bubbleBounds.width() <= 0f || bubbleBounds.height() <= 0f) return 1.5f val targetWidth = viewportWidth * 0.6f val targetHeight = viewportHeight * 0.32f return min(targetWidth / bubbleBounds.width(), targetHeight / bubbleBounds.height()) .coerceIn(1.35f, 4.25f) } private fun isTapInsideBubble( bubble: SpeechBubble, tapX: Float, tapY: Float, hitSlopPx: Float ): Boolean { val expandedBounds = RectF(bubble.bounds) expandedBounds.inset(-hitSlopPx, -hitSlopPx) if (!expandedBounds.contains(tapX, tapY)) return false val mask = bubble.maskBitmap ?: return true if (!bubble.bounds.contains(tapX, tapY)) return true val normalizedX = ((tapX - bubble.bounds.left) / bubble.bounds.width()).coerceIn(0f, 0.999f) val normalizedY = ((tapY - bubble.bounds.top) / bubble.bounds.height()).coerceIn(0f, 0.999f) val maskX = (normalizedX * mask.width).toInt().coerceIn(0, mask.width - 1) val maskY = (normalizedY * mask.height).toInt().coerceIn(0, mask.height - 1) return AndroidColor.alpha(mask.getPixel(maskX, maskY)) > 24 } private suspend fun renderExpandedBubbleBitmap( document: ReaderDocument, pageIndex: Int, bubbleBounds: RectF, pageWidth: Int, pageHeight: Int, renderScale: Float ): Bitmap? = withContext(Dispatchers.IO) { if (pageWidth <= 0 || pageHeight <= 0 || bubbleBounds.width() <= 0f || bubbleBounds.height() <= 0f) { return@withContext null } document.openPage(pageIndex)?.use { page -> val safeRenderScale = safePdfBitmapRenderScale( contentWidth = bubbleBounds.width(), contentHeight = bubbleBounds.height(), requestedScale = renderScale ) val cropWidth = (bubbleBounds.width() * safeRenderScale).roundToInt().coerceAtLeast(1) val cropHeight = (bubbleBounds.height() * safeRenderScale).roundToInt().coerceAtLeast(1) val bitmap = createBitmap(cropWidth, cropHeight) try { page.renderPageBitmap( bitmap = bitmap, startX = (-bubbleBounds.left * safeRenderScale).roundToInt(), startY = (-bubbleBounds.top * safeRenderScale).roundToInt(), drawSizeX = (pageWidth * safeRenderScale).roundToInt().coerceAtLeast(cropWidth), drawSizeY = (pageHeight * safeRenderScale).roundToInt().coerceAtLeast(cropHeight), renderAnnot = true ) bitmap } catch (t: Throwable) { bitmap.recycle() Timber.tag("BubbleZoom").w(t, "Failed to render expanded bubble bitmap for page $pageIndex") null } } } private fun safePdfBitmapRenderScale( contentWidth: Float, contentHeight: Float, requestedScale: Float ): Float { if (contentWidth <= 0f || contentHeight <= 0f || requestedScale <= 0f) return 1f val requestedWidth = contentWidth * requestedScale val requestedHeight = contentHeight * requestedScale val requestedBytes = requestedWidth.toDouble() * requestedHeight.toDouble() * 4.0 val byteScale = sqrt(PDF_MAX_DRAW_BITMAP_BYTES.toDouble() / requestedBytes.coerceAtLeast(1.0)) val dimensionScale = PDF_MAX_DRAW_BITMAP_DIMENSION_PX.toDouble() / max(requestedWidth, requestedHeight).toDouble().coerceAtLeast(1.0) val limiter = min(1.0, min(byteScale, dimensionScale)).coerceAtLeast(0.01) return (requestedScale.toDouble() * limiter).coerceAtLeast(0.01).toFloat() } object PdfInkGeometry { fun calculateFountainPenPoints( points: List, baseWidth: Float, pageWidth: Float, pageHeight: Float ): Pair, List> { 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() val rightSide = mutableListOf() 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() private const val MAX_POOL_SIZE = 4 fun get(width: Int, height: Int): Bitmap { val iterator = pool.iterator() while (iterator.hasNext()) { val b = iterator.next() if (b.width == width && b.height == height && !b.isRecycled) { iterator.remove() b.eraseColor(AndroidColor.TRANSPARENT) return b } } return createBitmap(width, height) } fun get(size: Int): Bitmap = get(size, size) fun recycle(bitmap: Bitmap) { // Overflow bitmaps are left for GC; HWUI may still reference recently drawn bitmaps. if (!bitmap.isRecycled && pool.size < MAX_POOL_SIZE) { pool.offer(bitmap) } } fun clear() { while (!pool.isEmpty()) { pool.poll() } } } 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(cacheSize) { override fun sizeOf(key: String, entry: CacheEntry): Int { return entry.sizeKb } } fun get(pageId: String): Bitmap? { return memoryCache.get(pageId)?.bitmap?.takeUnless { it.isRecycled } } fun put(pageId: String, bitmap: Bitmap) { if (get(pageId) == null) { val sizeKb = (bitmap.allocationByteCount / 1024).coerceAtLeast(1) memoryCache.put(pageId, CacheEntry(bitmap, sizeKb)) } } fun clear() { memoryCache.evictAll() } } @Stable data class StableHolder(val item: T) @Stable data class PageStaticData( val bitmap: StableHolder, val tiles: StableHolder>, val effectiveScale: Float, val centeringOffsetX: Float, val centeringOffsetY: Float, val canvasWidth: Float, val canvasHeight: Float, val targetWidth: Int, val targetHeight: Int, val colorFilter: StableHolder, val isDarkMode: Boolean, val excludeImages: Boolean, val imageRects: StableHolder>, val textureBitmap: StableHolder, val textureAlpha: Float, val textureBlendMode: BlendMode ) @Stable data class PageSelectionData( val pageLinks: StableHolder>, val showAllTextHighlights: Boolean, val actualBitmapWidthPx: Int, val actualBitmapHeightPx: Int, val mergedAllTextPageHighlightRects: StableHolder>, val mergedTtsHighlightRects: StableHolder>, val mergedSearchFocusedRects: StableHolder>, val mergedSearchAllRects: StableHolder>, val searchHighlightMode: SearchHighlightMode, val ocrHoverHighlights: StableHolder>, val mergedSelectionRects: StableHolder>, val centeringOffsetX: Float, val centeringOffsetY: Float, val linkHighlightColor: Color, val scrimColorForTextHighlight: Color, val allTextPageHighlightColor: Color, val ttsHighlightColor: Color, val selectionHighlightColor: Color, val pageIndex: Int, val userHighlightScreenRects: StableHolder>>>, val customHighlightColors: StableHolder> ) @OptIn(FlowPreview::class) @Suppress("unused") @Composable internal fun PdfPageComposable( pdfDocument: StableHolder, documentKey: String, 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> = StableHolder(emptyList()), onPreSingleTap: ((Offset) -> Boolean)? = null, onSingleTap: (Offset?) -> Unit, isProUser: Boolean, onShowDictionaryUpsellDialog: () -> Unit, onWordSelectedForAiDefinition: (String) -> Unit, onTranslateText: (String) -> Unit, onSearchText: (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, showPageNumberOverlay: Boolean = true, visualScaleProvider: () -> Float = { 1f }, clearSelectionTrigger: Long = 0L, resetZoomTrigger: Long = 0L, onTtsHighlightCenterCalculated: ((Float) -> Unit)? = null, onSearchHighlightCenterCalculated: ((Float) -> Unit)? = null, activeTheme: com.aryan.reader.ReaderTheme = com.aryan.reader.ReaderTheme("no_theme", "No Theme", Color.Unspecified, Color.Unspecified, false), activeTextureAlpha: Float = 0.55f, excludeImages: Boolean = false, onDoubleTap: ((Offset) -> Unit)? = null, isEditMode: Boolean = false, drawingState: PdfDrawingState? = null, pageAnnotations: () -> List = { emptyList() }, onDrawStart: (PdfPoint, Boolean) -> Unit = { _, _ -> }, onDraw: (PdfPoint, Boolean) -> Unit = { _, _ -> }, onDrawEnd: () -> Unit = {}, visibleScreenRect: () -> IntRect? = { null }, selectedTool: InkType = InkType.PEN, richTextController: RichTextController? = null, textBoxes: List = 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, isScrollLocked: Boolean = false, isVisible: Boolean = true, isActivePage: Boolean = true, isBubbleZoomModeActive: Boolean = false, isStylusOnlyMode: Boolean = false, stylusButtonHovering: Boolean = false, isAutoScrollPlaying: Boolean = false, isHighlighterSnapEnabled: Boolean = false, userHighlights: List = emptyList(), onHighlightAdd: (Int, Pair, String, PdfHighlightColor) -> Unit = { _,_,_,_ -> }, onHighlightUpdate: (String, PdfHighlightColor) -> Unit = { _,_ -> }, onHighlightDelete: (String) -> Unit = {}, onNoteRequested: (String?) -> Unit = {}, onTts: (Int, Int) -> Unit = { _, _ -> }, activeToolThickness: Float = 0f, eraserToolThickness: Float = 0f, customHighlightColors: Map = emptyMap(), onPaletteClick: (() -> Unit)? = null, lockedState: Triple? = null, onZoomAndPanChanged: ((Float, Offset) -> Unit)? = null, onDetectBubbles: suspend (Int, Bitmap) -> List = { _, _ -> emptyList() }, onShowPanelPopup: (Bitmap) -> Unit = {} ) { val pdfDocumentItem = pdfDocument.item val targetPageId = remember(documentKey, virtualPage, pageIndex) { pdfRenderPageId(documentKey, pageIndex, virtualPage) } var bitmapState by remember(targetPageId) { mutableStateOf(PdfThumbnailCache.get(targetPageId)) } var currentRenderedPageId by remember(targetPageId) { mutableStateOf(null) } var isLoadingPage by remember(targetPageId) { mutableStateOf(true) } var pageErrorMessage by remember(targetPageId) { mutableStateOf(null) } val density = LocalDensity.current val context = LocalContext.current val viewConfiguration = LocalViewConfiguration.current val coroutineScope = rememberCoroutineScope() var isStylusEraserOverride by remember { mutableStateOf(false) } var layoutCoordinates by remember { mutableStateOf(null) } var ocrRipplePosition by remember { mutableStateOf(null) } var isTransforming by remember { mutableStateOf(false) } val initialCamera = initialPdfPageCamera( isZoomEnabled = isZoomEnabled, isVerticalScroll = isVerticalScroll, isScrollLocked = isScrollLocked, lockedState = lockedState ) var scale by remember(targetPageId) { mutableFloatStateOf(initialCamera.first) } var offset by remember(targetPageId) { mutableStateOf(initialCamera.second) } var paginationPanFlingJob by remember { mutableStateOf(null) } var hasAppliedLockedPaginationState by remember(targetPageId) { mutableStateOf(initialCamera.second != Offset.Zero || initialCamera.first != 1f) } val shouldReportCamera = shouldReportPdfPageCamera( isZoomEnabled = isZoomEnabled, isVerticalScroll = isVerticalScroll, isScrollLocked = isScrollLocked, lockedState = lockedState, hasAppliedLockedState = hasAppliedLockedPaginationState ) LaunchedEffect(scale, offset, shouldReportCamera) { if (shouldReportCamera) { onZoomAndPanChanged?.invoke(scale, offset) } } val currentOnSingleTap by rememberUpdatedState(onSingleTap) val currentOnPreSingleTap by rememberUpdatedState(onPreSingleTap) 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(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(targetPageId) { mutableStateOf>(emptyList()) } val tileSizeDp = PDF_TILE_SIZE_DP.dp val tileSizePx = with(LocalDensity.current) { tileSizeDp.toPx().toInt() } val latestEffectiveScale by rememberUpdatedState(effectiveScale) val latestEffectiveOffset by rememberUpdatedState(effectiveOffset) 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(targetPageId) { mutableStateOf?>(null) } var activeDraggingHandle by remember(targetPageId) { mutableStateOf(null) } var selectedWordScreenRects by remember(targetPageId) { mutableStateOf>(emptyList()) } val startHandleContentPosition = remember(targetPageId) { mutableStateOf(null) } val endHandleContentPosition = remember(targetPageId) { mutableStateOf(null) } var actualBitmapWidthPx by remember(targetPageId) { mutableIntStateOf(0) } var actualBitmapHeightPx by remember(targetPageId) { mutableIntStateOf(0) } var currentPageRotation by remember(targetPageId) { mutableIntStateOf(0) } val needsTilingNow = (effectiveScale > 1f || actualBitmapWidthPx > 3000 || actualBitmapHeightPx > 3000) && (isVerticalScroll || isActivePage) val canvasWidthPx = remember { mutableFloatStateOf(0f) } val canvasHeightPx = remember { mutableFloatStateOf(0f) } val isDarkMode = activeTheme.isDark || activeTheme.id == "reverse" val colorFilter = remember(activeTheme) { when (activeTheme.id) { "no_theme", "system" -> null "reverse" -> { 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 -> { val bgR = activeTheme.backgroundColor.red * 255f val bgG = activeTheme.backgroundColor.green * 255f val bgB = activeTheme.backgroundColor.blue * 255f val fgR = activeTheme.textColor.red * 255f val fgG = activeTheme.textColor.green * 255f val fgB = activeTheme.textColor.blue * 255f val dr = (bgR - fgR) / 255f val dg = (bgG - fgG) / 255f val db = (bgB - fgB) / 255f val lumR = 0.2126f val lumG = 0.7152f val lumB = 0.0722f val colorMatrix = floatArrayOf( dr * lumR, dr * lumG, dr * lumB, 0f, fgR, dg * lumR, dg * lumG, dg * lumB, 0f, fgG, db * lumR, db * lumG, db * lumB, 0f, fgB, 0f, 0f, 0f, 1f, 0f ) ColorFilter.colorMatrix(ColorMatrix(colorMatrix)) } } } val backgroundColor = remember(activeTheme, isVerticalScroll) { if (activeTheme.id == "no_theme" || activeTheme.id == "system") { if (isVerticalScroll) Color.White else Color.Black } else if (activeTheme.id == "reverse") { if (isVerticalScroll) Color.Black else Color.White } else { activeTheme.backgroundColor } } val textureBitmap = remember(activeTheme.textureId) { loadReaderTextureBitmap(context, activeTheme.textureId) } val effectiveTextureAlpha = remember(activeTheme.textureId, activeTextureAlpha) { if (activeTheme.textureId == null) 0f else activeTextureAlpha.coerceIn(0f, 1f) } val textureBlendMode = remember(activeTheme.textureId, activeTheme.isDark, activeTheme.id) { if (activeTheme.isDark || activeTheme.id == "reverse") BlendMode.Screen else BlendMode.Multiply } 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) { } var showMagnifier by remember { mutableStateOf(false) } var magnifierBitmapCenterTarget by remember { mutableStateOf(Offset.Zero) } val magnifierZoomFactor = 2.0f var customMenuState by remember(targetPageId) { mutableStateOf(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 } var detectedBubbles by remember(targetPageId) { mutableStateOf>(emptyList()) } var expandedBubbleIndex by remember(targetPageId) { mutableIntStateOf(-1) } var animatingBubbleIndex by remember(targetPageId) { mutableIntStateOf(-1) } val bubbleExpansionProgress = remember(targetPageId) { Animatable(0f) } var isDetectingBubbles by remember(targetPageId) { mutableStateOf(false) } var expandedBubbleRender by remember(targetPageId) { mutableStateOf(null) } val currentDetectedBubbles by rememberUpdatedState(detectedBubbles) val currentExpandedBubbleIndex by rememberUpdatedState(expandedBubbleIndex) val currentBubbleZoomModeActive by rememberUpdatedState(isBubbleZoomModeActive) val bubbleTapSlopPx = with(density) { 18.dp.toPx() } LaunchedEffect(expandedBubbleIndex) { if (expandedBubbleIndex != -1) { if (animatingBubbleIndex != -1 && animatingBubbleIndex != expandedBubbleIndex) { bubbleExpansionProgress.animateTo(0f, tween(150)) } animatingBubbleIndex = expandedBubbleIndex bubbleExpansionProgress.animateTo(1f, tween(250, easing = androidx.compose.animation.core.FastOutSlowInEasing)) } else { bubbleExpansionProgress.animateTo(0f, tween(200, easing = androidx.compose.animation.core.FastOutLinearInEasing)) animatingBubbleIndex = -1 } } LaunchedEffect( isBubbleZoomModeActive, isActivePage, isPdfPage, pdfPageIndex, bitmapState, actualBitmapWidthPx, actualBitmapHeightPx ) { Timber.tag("BubbleZoom").d("LaunchedEffect triggered. modeActive=$isBubbleZoomModeActive, activePage=$isActivePage, hasBitmap=${bitmapState != null}, dims=${actualBitmapWidthPx}x${actualBitmapHeightPx}") if (isBubbleZoomModeActive && isActivePage && isPdfPage && bitmapState != null && actualBitmapWidthPx > 0 && actualBitmapHeightPx > 0) { Timber.tag("BubbleZoom").d("Conditions met. Starting detection...") isDetectingBubbles = true try { val rawBubbles = onDetectBubbles(pdfPageIndex, bitmapState!!) Timber.tag("BubbleZoom").d("Detection complete. Found ${rawBubbles.size} raw bubbles.") // NEW: Scale bubbles down from render bitmap space to logical screen space val scaleX = actualBitmapWidthPx.toFloat() / bitmapState!!.width.toFloat() val scaleY = actualBitmapHeightPx.toFloat() / bitmapState!!.height.toFloat() val logicalBubbles = rawBubbles.map { b -> b.copy(bounds = android.graphics.RectF( b.bounds.left * scaleX, b.bounds.top * scaleY, b.bounds.right * scaleX, b.bounds.bottom * scaleY )) } val rowHeight = actualBitmapHeightPx * 0.1f detectedBubbles = logicalBubbles.sortedWith(compareBy { (it.bounds.centerY() / rowHeight).roundToInt() }.thenBy { it.bounds.centerX() }) expandedBubbleIndex = -1 Timber.tag("BubbleZoom").d("Sorted logical bubbles count: ${detectedBubbles.size}") } catch (e: Exception) { Timber.tag("BubbleZoom").e(e, "Bubble detection failed with exception") } finally { isDetectingBubbles = false } } else { Timber.tag("BubbleZoom").d("Conditions NOT met or mode disabled. Clearing bubbles.") detectedBubbles = emptyList() expandedBubbleIndex = -1 expandedBubbleRender?.bitmap?.takeUnless { it.isRecycled }?.recycle() expandedBubbleRender = null if ( shouldResetPdfZoomAfterBubbleZoomCleanup( isBubbleZoomModeActive = isBubbleZoomModeActive, scale = scale, isVerticalScroll = isVerticalScroll, isZoomEnabled = isZoomEnabled, isScrollLocked = isScrollLocked ) ) { coroutineScope.launch { Animatable(scale).animateTo(1f, tween(300)) { scale = this.value offset = Offset.Zero onScaleChanged(scale) } } } } } LaunchedEffect( animatingBubbleIndex, detectedBubbles, actualBitmapWidthPx, actualBitmapHeightPx, canvasWidthPx.floatValue, canvasHeightPx.floatValue, isBubbleZoomModeActive, isPdfPage, pdfPageIndex ) { val previousRender = expandedBubbleRender expandedBubbleRender = null previousRender?.bitmap?.takeUnless { it.isRecycled }?.recycle() if (!isBubbleZoomModeActive || !isPdfPage || animatingBubbleIndex !in detectedBubbles.indices) { return@LaunchedEffect } val bubble = detectedBubbles[animatingBubbleIndex] val zoomFactor = computeDynamicBubbleZoomFactor( bubbleBounds = bubble.bounds, viewportWidth = canvasWidthPx.floatValue.coerceAtLeast(actualBitmapWidthPx.toFloat()), viewportHeight = canvasHeightPx.floatValue.coerceAtLeast(actualBitmapHeightPx.toFloat()) ) val renderScale = (zoomFactor * 1.2f).coerceAtLeast(1.6f) val renderedBubble = renderExpandedBubbleBitmap( document = pdfDocumentItem, pageIndex = pdfPageIndex, bubbleBounds = bubble.bounds, pageWidth = actualBitmapWidthPx, pageHeight = actualBitmapHeightPx, renderScale = renderScale ) if (renderedBubble != null) { expandedBubbleRender = ExpandedBubbleRender( bitmap = renderedBubble, zoomFactor = zoomFactor ) } } DisposableEffect(targetPageId) { onDispose { val currentBitmap = bitmapState val cachedBitmap = PdfThumbnailCache.get(targetPageId) if (currentBitmap != null && !currentBitmap.isRecycled && currentBitmap !== cachedBitmap) { currentBitmap.recycle() } expandedBubbleRender?.bitmap?.takeUnless { it.isRecycled }?.recycle() } } @Suppress("DEPRECATION") val clipboardManager = LocalClipboardManager.current // OCR var ocrVisionTextForSelection by remember(targetPageId) { mutableStateOf(null) } var isPerformingOcrForSelection by remember { mutableStateOf(false) } var selectionMethodUsed by remember { mutableStateOf(PdfSelectionMethod.PDFIUM) } var ocrSelectionSymbolIndices by remember(targetPageId) { mutableStateOf?>(null) } var allOcrSymbolsForSelection by remember(targetPageId) { mutableStateOf>(emptyList()) } var highlightedTextScreenRects by remember(targetPageId) { mutableStateOf>(emptyList()) } val ttsHighlightColor = Color(0xFFFFECB3).copy(alpha = 0.4f) var allTextPageHighlightRects by remember(targetPageId) { mutableStateOf>(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>(emptyList()) } val searchHighlightColor = Color(0xFFFFAB00).copy(alpha = 0.5f) val mergedSearchHighlightRects = remember(searchHighlightRects) { mergeRectsIntoLines(searchHighlightRects) } var pageLinks by remember(targetPageId) { mutableStateOf>(emptyList()) } val linkHighlightColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f) val linkVerticalPaddingPx = remember(density) { with(density) { 10.dp.toPx().toInt() } } var userHighlightScreenRects by remember { mutableStateOf>>>(emptyList()) } LaunchedEffect(userHighlights, actualBitmapWidthPx, actualBitmapHeightPx, currentPageRotation, virtualPage) { if (!isPdfPage || actualBitmapWidthPx == 0 || actualBitmapHeightPx == 0 || userHighlights.isEmpty()) { userHighlightScreenRects = emptyList() return@LaunchedEffect } withContext(Dispatchers.IO) { try { pdfDocumentItem.openPage(pdfPageIndex)?.use { page -> val mapped = userHighlights.map { highlight -> val screenRects = highlight.bounds.mapNotNull { pdfRectF -> page.mapRectToDevice( startX = 0, startY = 0, sizeX = actualBitmapWidthPx, sizeY = actualBitmapHeightPx, rotate = currentPageRotation, coords = pdfRectF ).takeIf { it.width() > 0 && it.height() > 0 } } highlight to screenRects } withContext(Dispatchers.Main) { userHighlightScreenRects = mapped } } } catch (e: Exception) { Timber.e(e, "Failed to map user highlights to screen rects") } } } 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 = emptyList() var pdfiumSucceeded = false var tempPage: ReaderPage? = 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) } @Suppress("VariableNeverRead") var embeddedAnnotations by remember(targetPageId) { mutableStateOf>(emptyList()) } var standardAnnotScreenRects by remember(targetPageId) { mutableStateOf>>(emptyList()) } var imageScreenRects by remember(targetPageId) { mutableStateOf>(emptyList()) } LaunchedEffect(pageIndex, pdfDocumentItem, actualBitmapWidthPx, actualBitmapHeightPx, virtualPage) { if (!isPdfPage || actualBitmapWidthPx == 0 || actualBitmapHeightPx == 0) { if (pageLinks.isNotEmpty()) pageLinks = emptyList() if (standardAnnotScreenRects.isNotEmpty()) standardAnnotScreenRects = emptyList() if (imageScreenRects.isNotEmpty()) imageScreenRects = emptyList() return@LaunchedEffect } withContext(Dispatchers.IO) { val allLinks = mutableListOf() var finalDisplayList = emptyList() var mappedAnnots = emptyList>() var mappedImageRects = emptyList() val annotLink = 2 try { pdfDocumentItem.openPage(pdfPageIndex)?.use { pageWrapper -> // 1. Extract Links (Method 1: Annotations) try { val annotationLinks = pageWrapper.getLinks() 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 = pageWrapper.mapRectToDevice( startX = 0, startY = 0, sizeX = actualBitmapWidthPx, sizeY = actualBitmapHeightPx, rotate = currentPageRotation, coords = bounds ) if (deviceRect.width() > 0 && deviceRect.height() > 0) { val tapRect = Rect( deviceRect.left, deviceRect.top - linkVerticalPaddingPx, deviceRect.right, deviceRect.bottom + linkVerticalPaddingPx ) PageLink(deviceRect, tapRect, uri, destPageIdx, LinkSource.ANNOTATION) } else null } else null } allLinks.addAll(mappedAnnotationLinks) } } catch (e: Exception) { Timber.e(e, "Error fetching annotation links") } // 2. Extract Links (Method 2: Text) try { pageWrapper.openTextPage().use { textPage -> textPage.loadWebLink()?.use { webLinks -> val webLinkCount = webLinks.countWebLinks() 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 = pageWrapper.mapRectToDevice( 0, 0, actualBitmapWidthPx, actualBitmapHeightPx, currentPageRotation, pdfRect ) if (deviceRect.width() > 0 && deviceRect.height() > 0) { val tapRect = Rect( deviceRect.left, deviceRect.top - linkVerticalPaddingPx, deviceRect.right, deviceRect.bottom + linkVerticalPaddingPx ) allLinks.add(PageLink(deviceRect, tapRect, url, null, LinkSource.TEXT_CONTENT)) } } } } } } catch (e: Exception) { Timber.e(e, "Error fetching web links") } // --- Extract Image Bounds --- try { val pagePtr = pageWrapper.getNativePointer() if (pagePtr != 0L) { val objCount = PdfiumEngineProvider.bridge.getPageObjectCount(pagePtr) val imgRects = mutableListOf() val outRect = FloatArray(4) for (i in 0 until objCount) { if (PdfiumEngineProvider.bridge.getPageObjectType(pagePtr, i) == 3) { // 3 = FPDF_PAGEOBJ_IMAGE if (PdfiumEngineProvider.bridge.getPageObjectBoundingBox(pagePtr, i, outRect)) { val pdfRectF = android.graphics.RectF( min(outRect[0], outRect[2]), max(outRect[1], outRect[3]), max(outRect[0], outRect[2]), min(outRect[1], outRect[3]) ) val deviceRect = pageWrapper.mapRectToDevice( 0, 0, actualBitmapWidthPx, actualBitmapHeightPx, currentPageRotation, pdfRectF ) if (deviceRect.width() > 0 && deviceRect.height() > 0) { imgRects.add(android.graphics.Rect(deviceRect.left, deviceRect.top, deviceRect.right, deviceRect.bottom)) } } } } mappedImageRects = imgRects } } catch (e: Exception) { Timber.tag("PdfImageDebug").e(e, "Error extracting image rects") } // 3. Extract Embedded Annotations try { val pagePtr = pageWrapper.getNativePointer() if (pagePtr != 0L) { val count = PdfiumEngineProvider.bridge.getAnnotCount(pagePtr) Timber.tag("PdfCommentDebug").d("Page $pageIndex: Total Annotations found = $count") if (count > 0) { val allAnnots = (0 until count).mapNotNull { i -> val subtype = PdfiumEngineProvider.bridge.getAnnotSubtype(pagePtr, i) if (subtype == annotLink) return@mapNotNull null var contents = PdfiumEngineProvider.bridge.getAnnotString(pagePtr, i, "Contents") if (contents.isNullOrBlank()) { contents = PdfiumEngineProvider.bridge.getAnnotString(pagePtr, i, "RC") } val name = PdfiumEngineProvider.bridge.getAnnotString(pagePtr, i, "NM") val irt = PdfiumEngineProvider.bridge.getAnnotString(pagePtr, i, "IRT") val author = PdfiumEngineProvider.bridge.getAnnotString(pagePtr, i, "T") val pdfRectArray = PdfiumEngineProvider.bridge.getAnnotRect(pagePtr, i) val pdfRectF = if (pdfRectArray != null) { android.graphics.RectF( min(pdfRectArray[0], pdfRectArray[2]), max(pdfRectArray[1], pdfRectArray[3]), max(pdfRectArray[0], pdfRectArray[2]), min(pdfRectArray[1], pdfRectArray[3]) ) } else android.graphics.RectF() EmbeddedAnnotation(i, subtype, pdfRectF, contents, author, name, irt) } val annotMap = allAnnots.associateBy { it.name } val orphans = mutableListOf() allAnnots.forEach { annot -> if (!annot.inReplyTo.isNullOrBlank() && annotMap.containsKey(annot.inReplyTo)) { Timber.tag("PdfCommentDebug").i("Linking: ${annot.name} is a reply to ${annot.inReplyTo}") annotMap[annot.inReplyTo]?.replies?.add(annot) } else { orphans.add(annot) } } Timber.tag("PdfCommentDebug").d("After ID linking: Orphans count = ${orphans.size}") val groupedRoots = mutableListOf>() orphans.forEach { annot -> val match = groupedRoots.find { group -> val root = group.first() val inflatedRoot = android.graphics.RectF(root.rect).apply { inset(-10f, -10f) } android.graphics.RectF.intersects(inflatedRoot, annot.rect) } if (match != null) { Timber.tag("PdfCommentDebug").w("Geometric grouping triggered for ${annot.name} with ${match.first().name}. This might flatten nested replies!") match.add(annot) } else { groupedRoots.add(mutableListOf(annot)) } } val rootsWithReplies = groupedRoots.map { group -> val root = group.first() if (group.size > 1) { root.replies.addAll(group.drop(1)) } root } finalDisplayList = rootsWithReplies.filter { !it.contents.isNullOrBlank() || it.replies.any { r -> !r.contents.isNullOrBlank() } } mappedAnnots = finalDisplayList.map { annot -> val screenRect = pageWrapper.mapRectToDevice( 0, 0, actualBitmapWidthPx, actualBitmapHeightPx, currentPageRotation, annot.rect ) annot to screenRect } } } else { Timber.tag("PdfCommentDebug").w("Page $pageIndex: Failed to resolve native page pointer.") } } catch (e: Exception) { Timber.tag("PdfCommentDebug").e(e, "Error extracting annotations") } } } catch (e: Exception) { if (e !is kotlinx.coroutines.CancellationException) { Timber.e(e, "Failed to load links and annotations for page $pdfPageIndex") } } withContext(Dispatchers.Main) { pageLinks = allLinks embeddedAnnotations = finalDisplayList standardAnnotScreenRects = mappedAnnots imageScreenRects = mappedImageRects } } } 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(targetPageId)) { } } } } SideEffect { if (effectiveScale > 1f) { Timber.tag("PdfZoomDiagnostics").v( """ Page $pageIndex Stats: - Internal Scale: $scale | Effective Scale: $effectiveScale - Offset: $offset - Bitmap Dims: ${actualBitmapWidthPx}x${actualBitmapHeightPx} - Canvas Dims: ${canvasWidthPx.floatValue}x${canvasHeightPx.floatValue} - Centering: X=$centeringOffsetX, Y=$centeringOffsetY """.trimIndent() ) } } LaunchedEffect( needsTilingNow, actualBitmapWidthPx, actualBitmapHeightPx, canvasWidthPx.floatValue, canvasHeightPx.floatValue, isVerticalScroll, isScrolling, isAutoScrollPlaying, virtualPage, isActivePage ) { if (!needsTilingNow) { 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: ReaderPage? = 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 { val rect = visibleScreenRect() val observedScale = latestEffectiveScale if (isVerticalScroll && rect != null) { val qTop = rect.top / (tileSizePx / 2) val qLeft = rect.left / (tileSizePx / 2) val qBottom = rect.bottom / (tileSizePx / 2) val qRight = rect.right / (tileSizePx / 2) listOf(qTop, qLeft, qBottom, qRight, (observedScale * 10f).roundToInt()) } else if (!isVerticalScroll) { val observedOffset = latestEffectiveOffset val pivotX = screenWidth / 2f val pivotY = screenHeight / 2f val pxTl = (((0 - observedOffset.x) - pivotX) / observedScale + pivotX) - centeringOffsetX val pyTl = (((0 - observedOffset.y) - pivotY) / observedScale + pivotY) - centeringOffsetY val pxBr = (((screenWidth - observedOffset.x) - pivotX) / observedScale + pivotX) - centeringOffsetX val pyBr = (((screenHeight - observedOffset.y) - pivotY) / observedScale + pivotY) - centeringOffsetY val qTop = pyTl.toInt() / (tileSizePx / 2) val qLeft = pxTl.toInt() / (tileSizePx / 2) val qBottom = pyBr.toInt() / (tileSizePx / 2) val qRight = pxBr.toInt() / (tileSizePx / 2) listOf(qTop, qLeft, qBottom, qRight, (observedScale * 10f).roundToInt()) } else { null } }.conflate().collectLatest { _ -> val tileCalcStart = System.nanoTime() if (!isActive) return@collectLatest val renderScale = latestEffectiveScale val renderOffset = latestEffectiveOffset val currentVisibleRect = visibleScreenRect() 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 - renderOffset.x) - pivotX) / renderScale + pivotX) - centeringOffsetX pyTl = (((0 - renderOffset.y) - pivotY) / renderScale + pivotY) - centeringOffsetY pxBr = (((screenWidth - renderOffset.x) - pivotX) / renderScale + pivotX) - centeringOffsetX pyBr = (((screenHeight - renderOffset.y) - pivotY) / renderScale + pivotY) - centeringOffsetY } val visibleBitmapRect = Rect(pxTl.toInt(), pyTl.toInt(), pxBr.toInt(), pyBr.toInt()) val inset = if (renderScale > 2f) 0 else -tileSizePx visibleBitmapRect.inset(inset, inset) val requiredTileIds = mutableSetOf() 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 scaleTolerance = PDF_TILE_SCALE_TOLERANCE val validCurrentTileIds = tiles.filter { abs(it.renderScale - renderScale) <= scaleTolerance }.map { it.tileId }.toSet() val tilesToRenderIds = requiredTileIds - validCurrentTileIds val tilesToRecycleIds = currentTileIds - requiredTileIds 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 (tilesToRecycleIds.isNotEmpty()) { val (tilesToRecycle, tilesToKeep) = tiles.partition { it.tileId in tilesToRecycleIds } tiles = tilesToKeep withContext(Dispatchers.IO) { tilesToRecycle.forEach { PdfBitmapPool.recycle(it.bitmap) } } } if (isScrolling && renderScale > 1f) { return@collectLatest } if (requiredTileIds != validCurrentTileIds) { if (tilesToRenderIds.isNotEmpty()) { delay(PDF_TILE_IDLE_RENDER_DELAY_MS) if (!isActive) return@collectLatest if (isScrolling && latestEffectiveScale > 1f) return@collectLatest val renderedTiles = withContext(Dispatchers.IO) { val newTiles = mutableListOf() 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 tileRenderScale = min( renderScale, PDF_MAX_TILE_BITMAP_SIZE_PX.toFloat() / tileSizePx.toFloat() ) val tileRenderSize = (tileSizePx * tileRenderScale).toInt().coerceAtLeast(1) val tileBitmap = PdfBitmapPool.get(tileRenderSize) val fullPageRenderWidth = (actualBitmapWidthPx * tileRenderScale).toInt() val fullPageRenderHeight = (actualBitmapHeightPx * tileRenderScale).toInt() val tileRenderX = (col * tileSizePx * tileRenderScale).toInt() val tileRenderY = (row * tileSizePx * tileRenderScale).toInt() page?.renderPageBitmap( bitmap = tileBitmap, startX = -tileRenderX, startY = -tileRenderY, drawSizeX = fullPageRenderWidth, drawSizeY = fullPageRenderHeight, renderAnnot = true ) newTiles += PdfTile(tileBitmap, tileRect, tileId, renderScale) } newTiles } if (!isActive) { withContext(Dispatchers.IO) { renderedTiles.forEach { PdfBitmapPool.recycle(it.bitmap) } } return@collectLatest } if (renderedTiles.isNotEmpty()) { val renderedIds = renderedTiles.map { it.tileId }.toSet() val replacedTiles = tiles.filter { it.tileId in renderedIds } tiles = tiles.filterNot { it.tileId in renderedIds } + renderedTiles if (replacedTiles.isNotEmpty()) { coroutineScope.launch(Dispatchers.IO) { replacedTiles.forEach { PdfBitmapPool.recycle(it.bitmap) } } } } } } } } 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}") } } } } } var searchFocusedRects by remember(targetPageId) { mutableStateOf>(emptyList()) } var searchAllRects by remember(targetPageId) { mutableStateOf>(emptyList()) } var keyboardAdjustmentOriginalOffset by remember(targetPageId) { mutableStateOf(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 = emptyList() var foundFocused: List = 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() 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, 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?) { 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: ReaderDocument, pageIdx: Int, charRange: Pair?, currentBitmapWidth: Int, currentBitmapHeight: Int, rotation: Int, providedPage: ReaderPage? = null, providedTextPage: ReaderTextPage? = null ) { if (charRange == null || currentBitmapWidth == 0 || currentBitmapHeight == 0) { withContext(Dispatchers.Main) { selectedWordScreenRects = emptyList() startHandleContentPosition.value = null endHandleContentPosition.value = null } return } withContext(Dispatchers.IO) { var localPage: ReaderPage? = null var localTextPage: ReaderTextPage? = null try { val pageToUse: ReaderPage val textPageToUse: ReaderTextPage if (providedPage != null && providedTextPage != null) { pageToUse = providedPage textPageToUse = providedTextPage } else { localPage = doc.openPage(pageIdx) localTextPage = localPage?.openTextPage() if (localPage == null || localTextPage == null) { withContext(Dispatchers.Main) { selectedWordScreenRects = emptyList() startHandleContentPosition.value = null endHandleContentPosition.value = null } return@withContext } pageToUse = localPage textPageToUse = localTextPage } val (startIndex, endIndex) = charRange if (startIndex >= endIndex) { withContext(Dispatchers.Main) { selectedWordScreenRects = emptyList() startHandleContentPosition.value = null endHandleContentPosition.value = null } return@withContext } 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 } } withContext(Dispatchers.Main) { 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 { withContext(Dispatchers.Main) { selectedWordScreenRects = emptyList() startHandleContentPosition.value = null endHandleContentPosition.value = null } } } catch (e: Exception) { Timber.e(e, "Error updating selection visuals for page $pageIdx, range $charRange: $e") withContext(Dispatchers.Main) { selectedWordScreenRects = emptyList() startHandleContentPosition.value = null endHandleContentPosition.value = null } } finally { if (providedPage == null && providedTextPage == null) { withContext(NonCancellable) { 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 ) } } } LaunchedEffect(resetZoomTrigger) { if (resetZoomTrigger != 0L && scale > 1f && isZoomEnabled && !isVerticalScroll && !isScrollLocked) { coroutineScope.launch { val startScale = scale val startOffset = offset Animatable(0f).animateTo( 1f, animationSpec = tween(durationMillis = 300) ) { val progress = value scale = androidx.compose.ui.util.lerp( startScale, 1f, progress ) offset = androidx.compose.ui.geometry.lerp( startOffset, Offset.Zero, progress ) onScaleChanged(scale) } if (scale <= 1.05f) { scale = 1f offset = Offset.Zero onScaleChanged(scale) } } } } 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 (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(Channel.CONFLATED) coroutineScope.launch(Dispatchers.IO) { var pageForDrag: ReaderPage? = null var textPageForDrag: ReaderTextPage? = 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 val pDrag = pageForDrag val tDrag = textPageForDrag if (pDrag != null && tDrag != null) { val touchInContentCoords = screenToContentCoordinates( dragPosition ) val pdfCoords = pDrag.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 = tDrag.textPageGetCharIndexAtPos( x = pdfCoords.x.toDouble(), y = pdfCoords.y.toDouble(), xTolerance = charTolerance, yTolerance = charTolerance ) if (charIndexForUpdate == -1 && activeDraggingHandle != null) { val pageWidthPdfUnits = pDrag.getPageWidthPoint() val wideSearchXTolerance = pageWidthPdfUnits.toDouble() var ySearchCoordinate = pdfCoords.y.toDouble() val currentRange = selectionCharRange.value if (currentRange != null) { val pageTotalChars = tDrag.textPageCountChars() if (pageTotalChars > 0) { val anchorCharIndex = if (activeDraggingHandle == Handle.START) { currentRange.first } else { (currentRange.second - 1).coerceAtLeast( 0 ) } if (anchorCharIndex in 0.. 0) { charIndexForUpdate = tDrag.textPageGetCharIndexAtPos( x = pdfCoords.x.toDouble(), y = ySearchCoordinate, xTolerance = wideSearchXTolerance, yTolerance = wideSearchYTolerance ) } } if (charIndexForUpdate != -1) { val pageCharCount = tDrag.textPageCountChars() val currentRange = selectionCharRange.value if (currentRange != null) { val (currentStart, currentEnd) = currentRange var newRange: Pair? = 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!! coroutineScope.launch { var pageForMenu: ReaderPage? = null var textPageForMenu: ReaderTextPage? = null try { val text = withContext(Dispatchers.IO) { pageForMenu = pdfDocumentItem.openPage(pdfPageIndex) textPageForMenu = pageForMenu?.openTextPage() textPageForMenu?.textPageGetText( currentRange.first, currentRange.second - currentRange.first ) } if (!text.isNullOrBlank()) { val combinedRect = Rect(selectedWordScreenRects.first()) selectedWordScreenRects.forEach { combinedRect.union(it) } customMenuState = CustomPdfMenuState( selectedText = text, anchorRect = combinedRect, 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!! val combinedRect = Rect(firstRect) selectedSymbolInfos.forEach { info -> info.symbol.boundingBox?.let { combinedRect.union(it) } } customMenuState = CustomPdfMenuState( selectedText = selectedText, anchorRect = combinedRect, charRange = Pair(indices.first, indices.second) ) 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: ReaderPage? = null var tempTextPage: ReaderTextPage? = null var ocrAttemptedForThisPress = false try { if (!isPdfPage) return@launch 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 } var pdfiumSelectionSuccessful = false withContext(Dispatchers.IO) { tempPage = pdfDocumentItem.openPage(pdfPageIndex) tempTextPage = tempPage?.openTextPage() val tPage = tempPage val tTextPage = tempTextPage if (tPage != null && tTextPage != null) { val pdfCoords = tPage.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 = tTextPage.textPageGetCharIndexAtPos( x = pdfCoords.x.toDouble(), y = pdfCoords.y.toDouble(), xTolerance = charTolerance, yTolerance = charTolerance ) if (charIndex != -1) { val pageCharCount = tTextPage.textPageCountChars() val wordBoundaries = findWordBoundaries( tTextPage, charIndex, pageCharCount ) if (wordBoundaries != null) { withContext(Dispatchers.Main) { selectionMethodUsed = PdfSelectionMethod.PDFIUM selectionCharRange.value = wordBoundaries } updateSelectionVisuals( pdfDocumentItem, pdfPageIndex, wordBoundaries, actualBitmapWidthPx, actualBitmapHeightPx, currentPageRotation, providedPage = tPage, providedTextPage = tTextPage ) withContext(Dispatchers.Main) { if (selectionCharRange.value != null && selectedWordScreenRects.isNotEmpty()) { val currentRange = selectionCharRange.value!! val text = withContext(Dispatchers.IO) { tTextPage.textPageGetText( currentRange.first, currentRange.second - currentRange.first ) } if (!text.isNullOrBlank()) { val combinedRect = Rect(selectedWordScreenRects.first()) selectedWordScreenRects.forEach { combinedRect.union(it) } customMenuState = CustomPdfMenuState( selectedText = text, anchorRect = combinedRect, 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() val allElements = mutableListOf() 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 combinedRect = Rect(selectedWordScreenRects.first()) selectedWordScreenRects.forEach { combinedRect.union(it) } customMenuState = CustomPdfMenuState( selectedText = foundElement.text, anchorRect = combinedRect, charRange = Pair( symbolStartIndex, symbolEndIndex ) ) 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 = context.getString( R.string.error_ocr_selection, e.readablePdfErrorDetail() ) } 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 = context.getString( R.string.error_selection, e.readablePdfErrorDetail() ) 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, isStylusOnlyMode, userHighlightScreenRects, bubbleTapSlopPx, isScrollLocked ) { val isTapDetectionAllowed = !isEditMode || selectedTool == InkType.TEXT || isStylusOnlyMode if (!isTapDetectionAllowed) return@pointerInput detectTapGestures(onTap = { tapOffset -> if (currentOnPreSingleTap?.invoke(tapOffset) == true) { return@detectTapGestures } val tapInContentCoords = screenToContentCoordinates(tapOffset) val tapXInBitmap = tapInContentCoords.x val tapYInBitmap = tapInContentCoords.y val isWithinContentBounds = tapXInBitmap in 0f..actualBitmapWidthPx.toFloat() && tapYInBitmap in 0f..actualBitmapHeightPx.toFloat() if (!isWithinContentBounds) { currentOnSingleTap(tapOffset) return@detectTapGestures } Timber.tag("BubbleZoom").d("Tap inside bounds. modeActive=$currentBubbleZoomModeActive, detectedBubbles=${currentDetectedBubbles.size}, tapPos=($tapXInBitmap, $tapYInBitmap)") if (currentBubbleZoomModeActive && currentDetectedBubbles.isNotEmpty()) { val tappedBubbleIndex = currentDetectedBubbles.indexOfFirst { bubble -> isTapInsideBubble( bubble = bubble, tapX = tapXInBitmap, tapY = tapYInBitmap, hitSlopPx = bubbleTapSlopPx ) } Timber.tag("BubbleZoom").d("Tapped bubble index: $tappedBubbleIndex (expandedIndex=$currentExpandedBubbleIndex)") if (tappedBubbleIndex != -1) { expandedBubbleIndex = if (currentExpandedBubbleIndex == tappedBubbleIndex) { -1 } else { tappedBubbleIndex } return@detectTapGestures } else if (currentExpandedBubbleIndex != -1) { expandedBubbleIndex = -1 return@detectTapGestures } } coroutineScope.launch { val nativeResult = withContext(Dispatchers.IO) { try { pdfDocumentItem.openPage(pdfPageIndex)?.use { page -> val pagePtr = page.getNativePointer() if (pagePtr == 0L) { Timber.tag("PdfInteraction").e("Could not find native pointer for page $pdfPageIndex") return@withContext 0 } val pdfCoords = page.mapDeviceCoordsToPage( 0, 0, actualBitmapWidthPx, actualBitmapHeightPx, currentPageRotation, tapInContentCoords.x.toInt(), tapInContentCoords.y.toInt() ) val docPtr = try { val pdfDocKt = (pdfDocumentItem as? PdfDocumentWrapper)?.pdfDocument if (pdfDocKt != null) { val documentField = pdfDocKt.javaClass.getDeclaredField("document").apply { isAccessible = true } val docUInstance = documentField.get(pdfDocKt) if (docUInstance != null) { val ptrField = docUInstance.javaClass.getDeclaredField("mNativeDocPtr").apply { isAccessible = true } ptrField.get(docUInstance) as Long } else 0L } else 0L } catch (e: Exception) { 0L } Timber.tag("PdfLinkDiagnostic").i("Extracted docPtr: $docPtr | pagePtr: $pagePtr") val linkInfo = PdfiumEngineProvider.bridge.getLinkInfoAtPoint( docPtr, pagePtr, pdfCoords.x.toDouble(), pdfCoords.y.toDouble() ) if (linkInfo != null) { Timber.tag("PdfLinkDiagnostic").i(">>> Native Link Info Extracted: $linkInfo") if (linkInfo.startsWith("URI:")) { val url = linkInfo.substringAfter("URI:") withContext(Dispatchers.Main) { onLinkClicked(url) } return@withContext 1 } else if (linkInfo.startsWith("PAGE:")) { val targetPage = linkInfo.substringAfter("PAGE:").toIntOrNull() if (targetPage != null && targetPage >= 0) { withContext(Dispatchers.Main) { onInternalLinkClicked(targetPage) } return@withContext 1 } } } val clickHandled = PdfiumEngineProvider.bridge.performClick(pagePtr, pdfCoords.x.toDouble(), pdfCoords.y.toDouble()) if (clickHandled) { return@withContext 2 } return@withContext 0 } ?: 0 } catch (e: Exception) { Timber.tag("PdfInteraction").e(e, "Interaction error") 0 } } if (nativeResult == 2) { Timber.tag("PdfInteraction").i("Action detected. Refreshing page.") tiles = emptyList() bitmapState = null isLoadingPage = true currentRenderedPageId = "ACTION_${System.currentTimeMillis()}" return@launch } else if (nativeResult == 1) { return@launch } val annotHitTolerance = with(density) { 24.dp.toPx() } / inputScale val hitTolerance = with(density) { 16.dp.toPx() } / inputScale Timber.d("detectTapGestures: Tap at bitmap coords (${tapXInBitmap.toInt()}, ${tapYInBitmap.toInt()})") var tappedRect: Rect? = null val hitHighlightPair = userHighlightScreenRects.findLast { pair -> val hit = pair.second.find { r -> val hitLeft = r.left - hitTolerance val hitTop = r.top - hitTolerance val hitRight = r.right + hitTolerance val hitBottom = r.bottom + hitTolerance tapXInBitmap in hitLeft..hitRight && tapYInBitmap >= hitTop && tapYInBitmap <= hitBottom } if (hit != null) { tappedRect = hit true } else false } val standardHit = standardAnnotScreenRects.findLast { (annot, screenRect) -> if (annot.subtype == 2) return@findLast false val left = min(screenRect.left, screenRect.right) val right = max(screenRect.left, screenRect.right) val top = min(screenRect.top, screenRect.bottom) val bottom = max(screenRect.top, screenRect.bottom) val inflatedHitBox = Rect( (left - annotHitTolerance).toInt(), (top - annotHitTolerance).toInt(), (right + annotHitTolerance).toInt(), (bottom + annotHitTolerance).toInt() ) inflatedHitBox.contains(tapInContentCoords.x.toInt(), tapInContentCoords.y.toInt()) } if (standardHit != null) { val (annot, screenRect) = standardHit customMenuState = CustomPdfMenuState( selectedText = annot.contents ?: "No comment", anchorRect = screenRect, charRange = Pair(-1, -1), isComment = true, author = annot.author, annotation = annot ) return@launch } if (hitHighlightPair != null && tappedRect != null) { val hitHighlight = hitHighlightPair.first onNoteRequested(hitHighlight.id) return@launch } val clickedLink = pageLinks.firstOrNull { link -> link.tapBounds.contains(tapXInBitmap.toInt(), tapYInBitmap.toInt()) } if (clickedLink != null) { Timber.d("PdfPageComposable: Fallback pageLinks intercepted click.") if (clickedLink.destPageIdx != null && clickedLink.destPageIdx >= 0) { onInternalLinkClicked(clickedLink.destPageIdx) } else if (clickedLink.url != null) { onLinkClicked(clickedLink.url) } return@launch } val wasMenuVisible = customMenuState != null val wasSelectionVisible = selectionCharRange.value != null || ocrSelectionSymbolIndices != null if (wasMenuVisible || wasSelectionVisible) { customMenuState = null selectionCharRange.value = null ocrSelectionSymbolIndices = null updateSelectionVisuals( pdfDocumentItem, pdfPageIndex, null, actualBitmapWidthPx, actualBitmapHeightPx, currentPageRotation, ) } else { currentOnSingleTap(tapOffset) } } }, onDoubleTap = { tapOffset -> if (isZoomEnabled && !isVerticalScroll && !isScrollLocked) { 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 = androidx.compose.ui.util.lerp( startScale, targetScale, progress ) offset = androidx.compose.ui.geometry.lerp( startOffset, targetOffset, progress ) onScaleChanged(scale) } if (scale <= 1.05f) { scale = 1f offset = Offset.Zero onScaleChanged(scale) } } } else if (isVerticalScroll && !isScrollLocked && currentOnDoubleTap != null) { currentOnDoubleTap!!(tapOffset) } }) } .pointerInput( actualBitmapWidthPx, actualBitmapHeightPx, activeDraggingHandle, isZoomEnabled, isVerticalScroll, isEditMode, onTwoFingerSwipe, isScrollLocked ) { if (!isZoomEnabled || isVerticalScroll || actualBitmapWidthPx == 0 || activeDraggingHandle != null) return@pointerInput val decay = splineBasedDecay(this) val velocityTracker = VelocityTracker() val touchSlop = viewConfiguration.touchSlop awaitEachGesture { @Suppress("UnusedVariable", "Unused") val down = awaitFirstDown(requireUnconsumed = false) paginationPanFlingJob?.cancel() paginationPanFlingJob = null velocityTracker.resetTracking() var mode = 0 var accumulatedZoom = 1f var accumulatedPan = Offset.Zero var swipeAccumulatorX = 0f var velocityAccumulator = Offset.Zero do { val event = awaitPointerEvent() val canceled = event.changes.any { it.isConsumed } val pointerCount = event.changes.size if (!canceled) { val rawPanChange = event.calculatePan() val panChange = if (isScrollLocked && pointerCount == 1) { if (isVerticalScroll) Offset(0f, rawPanChange.y) else Offset.Zero } else { rawPanChange } val zoomChange = event.calculateZoom() if (scale > 1f) { if (mode == 0) { if (pointerCount == 1) { accumulatedPan += panChange if (accumulatedPan.getDistance() > touchSlop) { mode = 1 Timber.tag("PdfZoomDebug").d("Mode Change: PAN (Single Pointer)") } } else if (pointerCount > 1) { accumulatedZoom *= zoomChange accumulatedPan += panChange val zoomDiff = abs(accumulatedZoom - 1f) val panDist = accumulatedPan.getDistance() if (zoomDiff > 0.05f) { mode = 2 Timber.tag("PdfZoomDebug").d("Mode Change: ZOOM (Multi Pointer)") } else if (panDist > touchSlop) { mode = 1 Timber.tag("PdfZoomDebug").d("Mode Change: PAN (Multi Pointer)") } } } 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 ) Timber.tag("PdfZoomDebug").v("Panning: Offset $offset -> $newX, $newY (Max: $maxOffsetX, $maxOffsetY)") offset = Offset(newX, newY) if (event.changes.isNotEmpty() && panChange != Offset.Zero) { velocityAccumulator += panChange velocityTracker.addPosition( event.changes[0].uptimeMillis, velocityAccumulator ) } event.changes.forEach { if (it.positionChanged()) it.consume() } } else if (mode == 2 && pointerCount > 1) { val oldScale = scale val newScale = (scale * zoomChange).coerceIn(1f, 4f) Timber.tag("PdfZoomIssue").v("Gesture Scaling: old=$oldScale, new=$newScale, zoomChange=$zoomChange") 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 Timber.tag("PdfZoomDebug").v("Scaling: RawZoom=$zoomChange, Scale=$oldScale->$newScale, Offset=$offset->$newOffset (Clamped Max: $maxOffsetX, $maxOffsetY)") 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 (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 (scale > 1f && scale < 1.05f) { coroutineScope.launch { val startScale = scale val startOffset = offset Animatable(0f).animateTo(1f) { scale = lerp(startScale, 1f, value) offset = lerp(startOffset, Offset.Zero, value) onScaleChanged(scale) } } } else if (mode == 1 && scale > 1f) { 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 velocity = velocityTracker.calculateVelocity() val flingX = if (!isScrollLocked && abs(velocity.x) > PDF_PAGINATION_PAN_FLING_MIN_VELOCITY) { velocity.x * PDF_PAGINATION_PAN_FLING_MULTIPLIER } else { 0f } val flingY = if (abs(velocity.y) > PDF_PAGINATION_PAN_FLING_MIN_VELOCITY) { velocity.y * PDF_PAGINATION_PAN_FLING_MULTIPLIER } else { 0f } if (flingX == 0f && flingY == 0f) { offset = Offset( x = offset.x.coerceIn(-maxOffsetX, maxOffsetX), y = offset.y.coerceIn(-maxOffsetY, maxOffsetY) ) } else { val startOffset = offset paginationPanFlingJob = coroutineScope.launch { try { kotlinx.coroutines.coroutineScope { launch { if (flingX != 0f) { Animatable(startOffset.x).animateDecay(flingX, decay) { offset = offset.copy( x = value.coerceIn(-maxOffsetX, maxOffsetX) ) } } } launch { if (flingY != 0f) { Animatable(startOffset.y).animateDecay(flingY, decay) { offset = offset.copy( y = value.coerceIn(-maxOffsetY, maxOffsetY) ) } } } } } finally { paginationPanFlingJob = null } } } } } } .pointerInput( isEditMode, actualBitmapWidthPx, actualBitmapHeightPx, scale, offset, isScrolling, isVerticalScroll, selectedTool, isStylusOnlyMode, isHighlighterSnapEnabled ) { val canDraw = isEditMode && selectedTool != InkType.TEXT && !isScrolling && !isVerticalScroll && actualBitmapWidthPx > 0 && actualBitmapHeightPx > 0 if (!canDraw) { return@pointerInput } try { awaitEachGesture { val down = awaitFirstDown(requireUnconsumed = false) Timber.tag("PointerTypeDebug").d("Page $pageIndex: Input Type detected: ${down.type}") if (isStylusOnlyMode && down.type == PointerType.Touch) { return@awaitEachGesture } val buttons = currentEvent.buttons Timber.tag("StylusDebug").d( "Page $pageIndex | Type: ${down.type} | isPrimary: ${buttons.isPrimaryPressed} | isSecondary: ${buttons.isSecondaryPressed} | isTertiary: ${buttons.isTertiaryPressed} | buttonsString: $buttons" ) val isEraserOverride = down.type == PointerType.Eraser || (down.type == PointerType.Stylus && (currentEvent.buttons.isSecondaryPressed || currentEvent.buttons.isPrimaryPressed || stylusButtonHovering)) isStylusEraserOverride = isEraserOverride val dragPointerId = down.id val startPos = down.position var dragStarted = false val touchSlop = viewConfiguration.touchSlop if (selectedTool == InkType.ERASER || isEraserOverride) { eraserPosition = down.position } while (true) { val event = awaitPointerEvent() if (event.changes.size > 1) { if (dragStarted) { drawingState?.onDrawCancel() } eraserPosition = null isStylusEraserOverride = false 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), isEraserOverride) onDrawEnd() } else { onDrawEnd() } eraserPosition = null isStylusEraserOverride = false 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), isEraserOverride ) 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), isEraserOverride) if (selectedTool == InkType.ERASER || isEraserOverride) { 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), isEraserOverride) if (selectedTool == InkType.ERASER || isEraserOverride) { eraserPosition = change.position } change.consume() } } } } } finally { eraserPosition = null isStylusEraserOverride = false } }, contentAlignment = Alignment.Center ) { SideEffect { if (effectiveScale > 1f) { Timber.tag("PdfZoomDiagnostics").d( "BoxWithConstraints Page $pageIndex: MaxW=$maxWidth, MaxH=$maxHeight" ) } } 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) } } } var previousLockedViewportSize by remember { mutableStateOf?>(null) } LaunchedEffect( pageIndex, this@BoxWithConstraints.maxWidth, this@BoxWithConstraints.maxHeight, isScrollLocked, lockedState ) { val currentViewportSize = this@BoxWithConstraints.maxWidth to this@BoxWithConstraints.maxHeight val previousViewportSize = previousLockedViewportSize val orientationChanged = previousViewportSize != null && (previousViewportSize.first > previousViewportSize.second) != (currentViewportSize.first > currentViewportSize.second) previousLockedViewportSize = currentViewportSize if (isScrollLocked && !isVerticalScroll) { if (orientationChanged) { scale = 1f offset = Offset.Zero hasAppliedLockedPaginationState = true Timber.tag("PdfLockDiagnostic").i( "Orientation changed while locked; reset paginated zoom to fit on page $pageIndex" ) } else if (lockedState != null) { scale = lockedState.first offset = Offset(lockedState.second, lockedState.third) hasAppliedLockedPaginationState = true } else { hasAppliedLockedPaginationState = true } onScaleChanged(scale) } else if (!isScrollLocked && !isVerticalScroll) { hasAppliedLockedPaginationState = false scale = 1f offset = Offset.Zero onScaleChanged(1f) } } 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 = 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() } } } ?: 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, targetPageId, isVisible, currentRenderedPageId ) { if (!isVisible && !isVerticalScroll) return@LaunchedEffect pageErrorMessage = null val viewContainerWidthPx = with(density) { currentContainerMaxWidth.toPx().toInt() } val viewContainerHeightPx = with(density) { currentContainerMaxHeight.toPx().toInt() } if (viewContainerWidthPx <= 0 || viewContainerHeightPx <= 0) { if (bitmapState == null) isLoadingPage = true 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 } val (scaledWidth, scaledHeight) = if (isVerticalScroll) { viewContainerWidthPx to viewContainerHeightPx } else { var fittedWidth = viewContainerWidthPx var fittedHeight = (fittedWidth / pageAspect).toInt() if (fittedHeight > viewContainerHeightPx) { fittedHeight = viewContainerHeightPx fittedWidth = (fittedHeight * pageAspect).toInt() } fittedWidth to fittedHeight } if (scaledWidth == actualBitmapWidthPx && scaledHeight == actualBitmapHeightPx && bitmapState != null && currentRenderedPageId == targetPageId ) { isLoadingPage = false return@LaunchedEffect } actualBitmapWidthPx = scaledWidth actualBitmapHeightPx = scaledHeight currentPageRotation = 0 val MAX_BASE_DIMEN = 3000 val baseRenderScale = 1.5f var baseW = (scaledWidth * baseRenderScale).toInt() var baseH = (scaledHeight * baseRenderScale).toInt() if (baseW > MAX_BASE_DIMEN || baseH > MAX_BASE_DIMEN) { val downScale = MAX_BASE_DIMEN.toFloat() / maxOf(baseW, baseH) baseW = (baseW * downScale).toInt().coerceAtLeast(1) baseH = (baseH * downScale).toInt().coerceAtLeast(1) } val finalBitmap = PdfBitmapPool.get(baseW, baseH) finalBitmap.eraseColor(android.graphics.Color.WHITE) val old = bitmapState if (old != null && old !== finalBitmap) { if (old !== PdfThumbnailCache.get(targetPageId)) { old.recycle() } } bitmapState = finalBitmap currentRenderedPageId = targetPageId isLoadingPage = false return@LaunchedEffect } kotlinx.coroutines.coroutineScope { var localBitmap: Bitmap? = null 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) ?: return@withContext null try { val rotation = page.getPageRotation() val originalWidthPdfUnits = page.getPageWidthPoint() val originalHeightPdfUnits = page.getPageHeightPoint() if (originalWidthPdfUnits <= 0 || originalHeightPdfUnits <= 0) { throw Exception("Invalid page dimensions") } val aspectRatio = originalWidthPdfUnits.toFloat() / originalHeightPdfUnits.toFloat() val (scaledWidth, scaledHeight) = if (isVerticalScroll) { viewContainerWidthPx to viewContainerHeightPx } else { var fittedWidth = viewContainerWidthPx var fittedHeight = (fittedWidth / aspectRatio).toInt() if (fittedHeight > viewContainerHeightPx) { fittedHeight = viewContainerHeightPx fittedWidth = (fittedHeight * aspectRatio).toInt() } fittedWidth to fittedHeight } if (scaledWidth == actualBitmapWidthPx && scaledHeight == actualBitmapHeightPx && bitmapState != null && currentRenderedPageId == targetPageId ) { return@withContext null } val MAX_BASE_DIMEN = 3000 val baseRenderScale = 1.5f var baseW = (scaledWidth * baseRenderScale).toInt() var baseH = (scaledHeight * baseRenderScale).toInt() if (baseW > MAX_BASE_DIMEN || baseH > MAX_BASE_DIMEN) { val downScale = MAX_BASE_DIMEN.toFloat() / maxOf(baseW, baseH) baseW = (baseW * downScale).toInt().coerceAtLeast(1) baseH = (baseH * downScale).toInt().coerceAtLeast(1) } Timber.d( "Rendering page $pageIndex at ${baseW}x${baseH} (logical: ${scaledWidth}x${scaledHeight})" ) val newBitmap = createBitmap(baseW, baseH) localBitmap = newBitmap page.renderPageBitmap( newBitmap, 0, 0, baseW, baseH, true ) Triple(newBitmap, rotation, Pair(scaledWidth, scaledHeight)) } finally { page.close() } } if (renderResult != null) { val (newBitmap, rotation, dims) = renderResult actualBitmapWidthPx = dims.first actualBitmapHeightPx = dims.second currentPageRotation = rotation val old = bitmapState bitmapState = newBitmap localBitmap = null // Handed over successfully currentRenderedPageId = targetPageId withContext(Dispatchers.IO) { if (old != null && old !== newBitmap && !old.isRecycled) { val cached = PdfThumbnailCache.get(targetPageId) if (old !== cached) { old.recycle() } } val thumbWidth = newBitmap.width / 2 val thumbHeight = newBitmap.height / 2 if (thumbWidth > 0 && thumbHeight > 0) { PdfThumbnailCache.put( targetPageId, newBitmap.scale(thumbWidth, thumbHeight) ) } } } } catch (e: Exception) { if (e is CancellationException) throw e pageErrorMessage = context.getString( R.string.error_processing_page, e.readablePdfErrorDetail() ) } finally { isLoadingPage = false localBitmap?.recycle() } } } 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 stableColorFilter = remember(colorFilter) { StableHolder(colorFilter) } val stableImageRects = remember(imageScreenRects) { StableHolder(imageScreenRects) } val staticData = remember( stableBitmapState, stableTiles, effectiveScale, centeringOffsetX, centeringOffsetY, canvasWidthPx.floatValue, canvasHeightPx.floatValue, actualBitmapWidthPx, actualBitmapHeightPx, stableColorFilter, isDarkMode, excludeImages, stableImageRects, textureBitmap, effectiveTextureAlpha, textureBlendMode ) { 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, isDarkMode = isDarkMode, excludeImages = excludeImages, imageRects = stableImageRects, textureBitmap = StableHolder(textureBitmap), textureAlpha = effectiveTextureAlpha, textureBlendMode = textureBlendMode ) } 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, userHighlightScreenRects, customHighlightColors ) { 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, userHighlightScreenRects = StableHolder(userHighlightScreenRects), customHighlightColors = StableHolder(customHighlightColors) ) } 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, uiScale = effectiveScale, offset = offset, startHandlePos = startHandleContentPosition.value, endHandlePos = endHandleContentPosition.value, teardropWidthPx = teardropWidthPxState.value, onHighlightAdd = onHighlightAdd, onHighlightUpdate = onHighlightUpdate, onHighlightDelete = onHighlightDelete, onTts = onTts, onNote = onNoteRequested, 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 ) } }, onTranslate = { textToTranslate -> onTranslateText(textToTranslate.trim()) customMenuState = null selectionCharRange.value = null coroutineScope.launch { updateSelectionVisuals( pdfDocumentItem, pdfPageIndex, null, actualBitmapWidthPx, actualBitmapHeightPx, currentPageRotation ) } }, onSearch = { textToSearch -> onSearchText(textToSearch.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: ReaderPage? = null var textPage: ReaderTextPage? = null try { val charCount = withContext(Dispatchers.IO) { page = pdfDocumentItem.openPage(pdfPageIndex) textPage = page?.openTextPage() textPage?.textPageCountChars() ?: 0 } if (charCount > 0) { selectionCharRange.value = Pair(0, charCount) val tPage = page val tTextPage = textPage updateSelectionVisuals( pdfDocumentItem, pdfPageIndex, selectionCharRange.value, actualBitmapWidthPx, actualBitmapHeightPx, currentPageRotation, providedPage = tPage, providedTextPage = tTextPage ) if (selectedWordScreenRects.isNotEmpty()) { val fullText = withContext(Dispatchers.IO) { tTextPage?.textPageGetText(0, charCount) } if (!fullText.isNullOrBlank()) { val combinedRect = Rect(selectedWordScreenRects.first()) selectedWordScreenRects.forEach { combinedRect.union(it) } customMenuState = CustomPdfMenuState( selectedText = fullText, anchorRect = combinedRect, charRange = selectionCharRange.value!! ) } } } } catch (e: Exception) { Timber.e(e, "Failed to select all") } finally { withContext(NonCancellable) { 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()) { val combinedRect = Rect(selectedWordScreenRects.first()) selectedWordScreenRects.forEach { combinedRect.union(it) } customMenuState = CustomPdfMenuState( selectedText = fullText, anchorRect = combinedRect, 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, showPageNumberOverlay = showPageNumberOverlay, isScrolling = isScrolling, isEditMode = isEditMode, selectedTool = selectedTool, eraserPosition = eraserPosition, isStylusEraserOverride = isStylusEraserOverride, activeToolThickness = activeToolThickness, eraserToolThickness = eraserToolThickness, richTextController = richTextController, textBoxes = textBoxes, selectedTextBoxId = selectedTextBoxId, onTextBoxChange = onTextBoxChange, onTextBoxSelect = onTextBoxSelect, onTextBoxDragStart = onTextBoxDragStart, onTextBoxDrag = onTextBoxDrag, onTextBoxDragEnd = onTextBoxDragEnd, onDragPageTurn = onDragPageTurn, draggingBoxId = draggingBoxId, customHighlightColors = customHighlightColors, onPaletteClick = onPaletteClick, isBubbleZoomModeActive = isBubbleZoomModeActive, isActivePage = isActivePage, isDetectingBubbles = isDetectingBubbles, detectedBubbles = detectedBubbles, animatingBubbleIndex = animatingBubbleIndex, bubbleExpansionProgress = bubbleExpansionProgress.value, expandedBubbleRender = expandedBubbleRender ) } else -> { Text( text = stringResource(R.string.error_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, effectiveScale: Float, centeringOffsetX: Float, centeringOffsetY: Float, @Suppress("unused") canvasWidth: Float, @Suppress("unused") canvasHeight: Float, targetWidth: Int, targetHeight: Int, colorFilter: ColorFilter? = null, isDarkMode: Boolean = false, excludeImages: Boolean = false, imageRects: List = emptyList(), textureBitmap: ImageBitmap? = null, textureAlpha: Float = 0f, textureBlendMode: BlendMode = BlendMode.Multiply ) { Canvas(modifier = Modifier.fillMaxSize().graphicsLayer()) { translate(left = centeringOffsetX, top = centeringOffsetY) { clipRect(left = 0f, top = 0f, right = targetWidth.toFloat(), bottom = targetHeight.toFloat()) { if ( bitmapState != null && bitmapState.isCanvasSafeBitmap( maxBytes = PDF_MAX_DRAW_BITMAP_BYTES, maxDimension = PDF_MAX_DRAW_BITMAP_DIMENSION_PX ) ) { val dstW = if (targetWidth > 0) targetWidth else bitmapState.width val dstH = if (targetHeight > 0) targetHeight else bitmapState.height val srcSize = IntSize(bitmapState.width, bitmapState.height) val dstSize = IntSize(dstW, dstH) drawImage( image = bitmapState.asImageBitmap(), srcOffset = IntOffset.Zero, srcSize = srcSize, dstOffset = IntOffset.Zero, dstSize = dstSize, colorFilter = colorFilter, filterQuality = androidx.compose.ui.graphics.FilterQuality.High ) if (excludeImages && colorFilter != null && imageRects.isNotEmpty()) { imageRects.forEach { rect -> val scaleX = bitmapState.width.toFloat() / dstW.toFloat() val scaleY = bitmapState.height.toFloat() / dstH.toFloat() val srcRectLeft = (rect.left * scaleX).roundToInt().coerceAtLeast(0) val srcRectTop = (rect.top * scaleY).roundToInt().coerceAtLeast(0) val srcRectRight = (rect.right * scaleX).roundToInt().coerceAtMost(bitmapState.width) val srcRectBottom = (rect.bottom * scaleY).roundToInt().coerceAtMost(bitmapState.height) val w = srcRectRight - srcRectLeft val h = srcRectBottom - srcRectTop if (w > 0 && h > 0) { drawImage( image = bitmapState.asImageBitmap(), srcOffset = IntOffset(srcRectLeft, srcRectTop), srcSize = IntSize(w, h), dstOffset = IntOffset(rect.left, rect.top), dstSize = IntSize(rect.width(), rect.height()), colorFilter = null, filterQuality = androidx.compose.ui.graphics.FilterQuality.High ) } } } val needsTiling = effectiveScale > 1f || targetWidth > 3000 || targetHeight > 3000 if (needsTiling) { tiles.forEach { tile -> if ( tile.bitmap.isCanvasSafeBitmap( maxBytes = PDF_MAX_DRAW_BITMAP_BYTES, maxDimension = PDF_MAX_DRAW_BITMAP_DIMENSION_PX ) ) { drawImage( image = tile.bitmap.asImageBitmap(), srcOffset = IntOffset.Zero, 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, filterQuality = androidx.compose.ui.graphics.FilterQuality.High ) if (excludeImages && colorFilter != null && imageRects.isNotEmpty()) { imageRects.forEach { imgRect -> val intersectLeft = max(imgRect.left, tile.renderRect.left) val intersectTop = max(imgRect.top, tile.renderRect.top) val intersectRight = min(imgRect.right, tile.renderRect.right) val intersectBottom = min(imgRect.bottom, tile.renderRect.bottom) val iw = intersectRight - intersectLeft val ih = intersectBottom - intersectTop if (iw > 0 && ih > 0) { val scaleXBmp = tile.bitmap.width.toFloat() / tile.renderRect.width() val scaleYBmp = tile.bitmap.height.toFloat() / tile.renderRect.height() val srcLeft = ((intersectLeft - tile.renderRect.left) * scaleXBmp).roundToInt() val srcTop = ((intersectTop - tile.renderRect.top) * scaleYBmp).roundToInt() val srcRight = ((intersectRight - tile.renderRect.left) * scaleXBmp).roundToInt() val srcBottom = ((intersectBottom - tile.renderRect.top) * scaleYBmp).roundToInt() val srcW = srcRight - srcLeft val srcH = srcBottom - srcTop if (srcW > 0 && srcH > 0) { drawImage( image = tile.bitmap.asImageBitmap(), srcOffset = IntOffset(srcLeft, srcTop), srcSize = IntSize(srcW, srcH), dstOffset = IntOffset(intersectLeft, intersectTop), dstSize = IntSize(iw, ih), colorFilter = null, filterQuality = androidx.compose.ui.graphics.FilterQuality.High ) } } } } } } } if (textureBitmap != null && textureAlpha > 0f) { drawRect( brush = ShaderBrush(ImageShader(textureBitmap, TileMode.Repeated, TileMode.Repeated)), size = Size(dstW.toFloat(), dstH.toFloat()), blendMode = textureBlendMode, alpha = textureAlpha ) } } } } } } @Composable private fun PdfHighlightsLayer( pageLinks: List, showAllTextHighlights: Boolean, actualBitmapWidthPx: Int, actualBitmapHeightPx: Int, mergedAllTextPageHighlightRects: List, mergedTtsHighlightRects: List, mergedSearchFocusedRects: List, mergedSearchAllRects: List, searchHighlightMode: SearchHighlightMode, ocrHoverHighlights: List, mergedSelectionRects: List, userHighlightScreenRects: List>>, centeringOffsetX: Float, centeringOffsetY: Float, linkHighlightColor: Color, scrimColorForTextHighlight: Color, allTextPageHighlightColor: Color, ttsHighlightColor: Color, selectionHighlightColor: Color, customHighlightColors: Map = emptyMap() ) { 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 top = r.top + centeringOffsetY val bottom = r.bottom + centeringOffsetY return left < size.width && right > 0 && top < size.height && 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()) ) } } // 9. Persistent User Highlights userHighlightScreenRects.forEach { (highlight, screenRects) -> val displayColor = customHighlightColors[highlight.color] ?: highlight.color.color screenRects.forEach { r -> if (isVisible(r)) { drawRect( color = displayColor.copy(alpha = 0.4f), topLeft = Offset(r.left.toFloat(), r.top.toFloat()), size = Size(r.width().toFloat(), r.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? { val startTime = System.nanoTime() 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 ) } } val duration = (System.nanoTime() - startTime) / 1_000_000f if (duration > 1f) { Timber.tag("PdfPerf").v("Path Gen: Type=${annot.inkType}, Pts=${annot.points.size}, Time=${duration}ms") } return result } } @Suppress("SameParameterValue") @Composable private fun PdfAnnotationLayer( actualBitmapWidthPx: Int, actualBitmapHeightPx: Int, annotationsProvider: () -> List, drawingState: PdfDrawingState?, centeringOffsetX: Float, centeringOffsetY: Float, pageIndex: Int ) { SideEffect { Timber.tag("PdfPerf").v("ANNOT_LAYER: Recomposing Page $pageIndex") } val staticAnnotations = annotationsProvider() val staticRenderData = remember(staticAnnotations, actualBitmapWidthPx, actualBitmapHeightPx) { val startTime = System.nanoTime() val data = staticAnnotations.mapNotNull { annot -> PdfAnnotationRenderHelper.createRenderData( annot, actualBitmapWidthPx, actualBitmapHeightPx ) } val duration = (System.nanoTime() - startTime) / 1_000_000f Timber.tag("PdfPerf").d("ANNOT_LAYER: Processed ${staticAnnotations.size} static annots in ${duration}ms") data } 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 activeRenderData = remember( currentAnnotation, currentAnnotation?.points?.size, actualBitmapWidthPx, actualBitmapHeightPx ) { val startTime = System.nanoTime() val res = currentAnnotation?.let { annot -> PdfAnnotationRenderHelper.createRenderData(annot, actualBitmapWidthPx, actualBitmapHeightPx) } val duration = (System.nanoTime() - startTime) / 1_000_000f if (duration > 0.5f) { Timber.tag("PdfPerf").v("ANNOT_LAYER: Active path gen took ${duration}ms") } res } 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 if (drawDuration > 2f) { Timber.tag("PdfPerf").v("ANNOT_DRAW: Canvas draw took ${drawDuration}ms (Page $pageIndex)") } } } @Composable private fun PdfPageStaticLayer(data: PageStaticData) { 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, isDarkMode = data.isDarkMode, excludeImages = data.excludeImages, imageRects = data.imageRects.item, textureBitmap = data.textureBitmap.item, textureAlpha = data.textureAlpha, textureBlendMode = data.textureBlendMode ) } @Composable private fun PdfPageSelectionsLayer( pageLinks: List, showAllTextHighlights: Boolean, actualBitmapWidthPx: Int, actualBitmapHeightPx: Int, mergedAllTextPageHighlightRects: List, mergedTtsHighlightRects: List, mergedSearchFocusedRects: List, mergedSearchAllRects: List, searchHighlightMode: SearchHighlightMode, ocrHoverHighlights: List, mergedSelectionRects: List, userHighlightScreenRects: List>>, centeringOffsetX: Float, centeringOffsetY: Float, linkHighlightColor: Color, scrimColorForTextHighlight: Color, allTextPageHighlightColor: Color, ttsHighlightColor: Color, selectionHighlightColor: Color, customHighlightColors: Map = emptyMap() ) { SideEffect { Timber.tag("PdfDrawPerf").v("SELECTIONS LAYER: Recomposing") Timber.tag("PdfHighlightDebug").v("PdfPageSelectionsLayer Recomposing. userHighlights count: ${userHighlightScreenRects.size}") } 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, userHighlightScreenRects = userHighlightScreenRects, centeringOffsetX = centeringOffsetX, centeringOffsetY = centeringOffsetY, linkHighlightColor = linkHighlightColor, scrimColorForTextHighlight = scrimColorForTextHighlight, allTextPageHighlightColor = allTextPageHighlightColor, ttsHighlightColor = ttsHighlightColor, selectionHighlightColor = selectionHighlightColor, customHighlightColors = customHighlightColors ) 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, drawingState: PdfDrawingState?, onCanvasSizeChanged: (Float, Float) -> Unit, scale: Float, uiScale: 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, onTranslate: (String) -> Unit, onSearch: (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, showPageNumberOverlay: Boolean, isScrolling: Boolean, isEditMode: Boolean, selectedTool: InkType, eraserPosition: Offset?, isStylusEraserOverride: Boolean, richTextController: RichTextController?, textBoxes: List, selectedTextBoxId: String?, onTextBoxChange: (PdfTextBox) -> Unit, onTextBoxSelect: (String) -> Unit, onTextBoxDragStart: (PdfTextBox, Offset, Offset) -> Unit, onTextBoxDrag: (Offset) -> Unit, onTextBoxDragEnd: () -> Unit, onDragPageTurn: (Int) -> Unit, draggingBoxId: String? = null, customHighlightColors: Map = emptyMap(), onPaletteClick: (() -> Unit)? = null, onHighlightAdd: (Int, Pair, String, PdfHighlightColor) -> Unit, onHighlightUpdate: (String, PdfHighlightColor) -> Unit, onHighlightDelete: (String) -> Unit, onTts: (Int, Int) -> Unit, activeToolThickness: Float, eraserToolThickness: Float, onNote: (String?) -> Unit, isBubbleZoomModeActive: Boolean = false, isActivePage: Boolean = true, isDetectingBubbles: Boolean = false, detectedBubbles: List = emptyList(), animatingBubbleIndex: Int = -1, bubbleExpansionProgress: Float = 0f, expandedBubbleRender: ExpandedBubbleRender? = 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, userHighlightScreenRects = selectionData.userHighlightScreenRects.item, centeringOffsetX = selectionData.centeringOffsetX, centeringOffsetY = selectionData.centeringOffsetY, linkHighlightColor = selectionData.linkHighlightColor, scrimColorForTextHighlight = selectionData.scrimColorForTextHighlight, allTextPageHighlightColor = selectionData.allTextPageHighlightColor, ttsHighlightColor = selectionData.ttsHighlightColor, selectionHighlightColor = selectionData.selectionHighlightColor, customHighlightColors = selectionData.customHighlightColors.item ) // Layer 3: Annotations & Text 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 isEditable = isEditMode && selectedTool == InkType.TEXT val hasContent = richTextController.pageLayouts.any { it.pageIndex == selectionData.pageIndex } || richTextController.hasRenderableText 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 ) } } if (textBoxes.isNotEmpty()) { androidx.compose.runtime.SideEffect { Timber.tag("PdfTextBoxDebug").d("PdfPageRenderer parent graphicsLayer applied | scale=$scale | offset=$offset | Centering: X=${staticData.centeringOffsetX}, Y=${staticData.centeringOffsetY}") } } 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, scale = uiScale, pageWidthPx = staticData.targetWidth.toFloat(), pageHeightPx = staticData.targetHeight.toFloat(), handlePosition = HandlePosition.AUTO, onBoundsChanged = { newBounds -> Timber.tag("PdfTextBoxDebug").v("PdfPageRenderer onBoundsChanged [ID: ${box.id}] bounds=$newBounds draggingBoxId=$draggingBoxId") if (draggingBoxId != box.id) { onTextBoxChange(box.copy(relativeBounds = newBounds)) } else { Timber.tag("PdfTextBoxDebug").d("PdfPageRenderer onBoundsChanged IGNORED because box[ID: ${box.id}] is being dragged globally") } }, onTextChanged = { newText -> onTextBoxChange(box.copy(text = newText)) }, onSelect = { Timber.tag("PdfTextBoxDebug").d("PdfPageRenderer onSelect propagated[ID: ${box.id}]") onTextBoxSelect(box.id) }, onDragStart = { touchOffset -> Timber.tag("PdfTextBoxDebug").d("PdfPageRenderer onDragStart[ID: ${box.id}] isVerticalScroll=$isVerticalScroll | offset=$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 -> Timber.tag("PdfTextBoxDebug").v("PdfPageRenderer onDrag [ID: ${box.id}] delta=$delta currentBounds=$currentBounds scale=$scale") if (isVerticalScroll) { onTextBoxDrag(delta) } else { val scaledDelta = delta * scale onTextBoxDrag(scaledDelta) 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 = { Timber.tag("PdfTextBoxDebug").d("PdfPageRenderer onDragEnd[ID: ${box.id}]") onTextBoxDragEnd() }, onDragCancel = { Timber.tag("PdfTextBoxDebug").d("PdfPageRenderer onDragCancel [ID: ${box.id}]") onTextBoxDragEnd() }, modifier = Modifier .zIndex(10f) .offset { IntOffset( staticData.centeringOffsetX.roundToInt(), staticData.centeringOffsetY.roundToInt() ) } .alpha(boxAlpha) ) } } } // Layer 4: Page Number Indicator if (showPageNumberOverlay && totalPages > 0) { val pageNumColor = if (staticData.isDarkMode) { 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 || isStylusEraserOverride) && eraserPosition != null) { Canvas(modifier = Modifier.fillMaxSize()) { val eraserStrokeWidth = resolveEraserStrokeWidth( isStylusEraserOverride, activeToolThickness, eraserToolThickness ) val radiusPx = if (eraserStrokeWidth > 0f && staticData.targetWidth > 0) { eraserStrokeWidth * staticData.targetWidth * scale } else { 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 val effectiveScale = staticData.effectiveScale val effectiveZoomFactor = if (isVerticalScroll && effectiveScale > 1f) { effectiveScale * 1.25f } else { magnifierZoomFactor } val popupPositionProvider = remember(pos, layoutCoordinates, density) { object : androidx.compose.ui.window.PopupPositionProvider { override fun calculatePosition( anchorBounds: androidx.compose.ui.unit.IntRect, windowSize: androidx.compose.ui.unit.IntSize, layoutDirection: androidx.compose.ui.unit.LayoutDirection, popupContentSize: androidx.compose.ui.unit.IntSize ): androidx.compose.ui.unit.IntOffset { val coords = layoutCoordinates ?: return androidx.compose.ui.unit.IntOffset.Zero val windowPos = coords.localToWindow(pos) val offsetPx = with(density) { magnifierOffsetAboveHandle.toPx() } val x = (windowPos.x - popupContentSize.width / 2).toInt() val y = (windowPos.y - popupContentSize.height - offsetPx).toInt() return androidx.compose.ui.unit.IntOffset(x, y) } } } androidx.compose.ui.window.Popup( popupPositionProvider = popupPositionProvider, properties = androidx.compose.ui.window.PopupProperties( focusable = false, dismissOnClickOutside = false, dismissOnBackPress = false, usePlatformDefaultWidth = false ) ) { MagnifierComposable( sourceBitmap = staticData.bitmap.item.asImageBitmap(), tiles = if (effectiveScale > 1f) staticData.tiles.item else emptyList(), currentScale = effectiveScale, magnifierCenterOnBitmap = magnifierCenterTarget, contentWidthPx = staticData.targetWidth, contentHeightPx = staticData.targetHeight, magnifierWidth = magnifierWidth, magnifierHeight = magnifierHeight, zoomFactor = effectiveZoomFactor, selectionRectsInContentCoords = selectionData.mergedSelectionRects.item, highlightColor = Color(0x6633B5E5), colorFilter = staticData.colorFilter.item, modifier = Modifier ) } } } if (menuState != null) { BackHandler(enabled = true, onBack = onMenuDismiss) } if (menuState != null && !isScrolling && draggingBoxId == null && activeDraggingHandle == null) { if (menuState.anchorRect.width() > 0 || menuState.anchorRect.height() > 0) { val popupPositionProvider = remember(menuState.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 topLeftLocal = contentToScreenCoordinates(Offset( menuState.anchorRect.left.toFloat(), menuState.anchorRect.top.toFloat())) val bottomRightLocal = contentToScreenCoordinates(Offset( menuState.anchorRect.right.toFloat(), menuState.anchorRect.bottom.toFloat())) val topLeftWindow = coords.localToWindow(topLeftLocal) val bottomRightWindow = coords.localToWindow(bottomRightLocal) val windowCenterX = (topLeftWindow.x + bottomRightWindow.x) / 2 val gapPx = with(density) { 16.dp.toPx() } var yInWindow = (topLeftWindow.y - popupContentSize.height - gapPx).toInt() if (yInWindow < 0) { yInWindow = (bottomRightWindow.y + gapPx).toInt() if (yInWindow + popupContentSize.height > windowSize.height) { yInWindow = windowSize.height - popupContentSize.height - gapPx.toInt() } } val xInWindow = (windowCenterX - popupContentSize.width / 2).toInt() .coerceIn(0, windowSize.width - popupContentSize.width) return IntOffset(xInWindow, yInWindow) } } } PdfSelectionMenuPopup( menuState = menuState, popupPositionProvider = popupPositionProvider, onDismiss = onMenuDismiss, onCopy = onCopy, onAiDefine = onAiDefine, onTranslate = onTranslate, onSearch = onSearch, onSelectAll = onSelectAll, onColorSelected = { color -> if (menuState.isExistingHighlight && menuState.highlightId != null) { onHighlightUpdate(menuState.highlightId, color) } else { onHighlightAdd( selectionData.pageIndex, menuState.charRange, menuState.selectedText, color ) } onMenuDismiss() }, onDelete = { if (menuState.isExistingHighlight && menuState.highlightId != null) { onHighlightDelete(menuState.highlightId) } onMenuDismiss() }, onTts = { onTts(selectionData.pageIndex, menuState.charRange.first) onMenuDismiss() }, onNote = { if (menuState.isExistingHighlight && menuState.highlightId != null) { onNote(menuState.highlightId) } else { onNote(null) onHighlightAdd( selectionData.pageIndex, menuState.charRange, menuState.selectedText, PdfHighlightColor.YELLOW ) } onMenuDismiss() }, customHighlightColors = selectionData.customHighlightColors.item, onPaletteClick = onPaletteClick ) } } BookmarkButton( isBookmarked = isBookmarked, onClick = onBookmarkClick, modifier = Modifier .align(Alignment.TopEnd) .padding(top = centeringPaddingTop, end = centeringPaddingEnd) ) if (isPerformingOcr && ocrRipplePos != null) { OcrProcessingIndicator(position = ocrRipplePos) } if (isBubbleZoomModeActive && isActivePage) { if (isDetectingBubbles) { androidx.compose.material3.CircularProgressIndicator( modifier = Modifier.align(Alignment.Center) ) } else if (detectedBubbles.isNotEmpty()) { Canvas(modifier = Modifier.fillMaxSize().zIndex(20f)) { // Draw shadow-like hints for unexpanded bubbles detectedBubbles.forEachIndexed { index, bubble -> val hintAlpha = if (index == animatingBubbleIndex) 0.35f * (1f - bubbleExpansionProgress) else 0.35f if (hintAlpha > 0f) { val left = bubble.bounds.left + staticData.centeringOffsetX val top = bubble.bounds.top + staticData.centeringOffsetY val width = bubble.bounds.width() val height = bubble.bounds.height() if (bubble.maskBitmap != null) { drawImage( image = bubble.maskBitmap.asImageBitmap(), dstOffset = IntOffset(left.toInt(), top.toInt()), dstSize = IntSize(width.toInt(), height.toInt()), colorFilter = ColorFilter.tint(Color.Black.copy(alpha = hintAlpha)), filterQuality = androidx.compose.ui.graphics.FilterQuality.High ) } else { drawRoundRect( color = Color.Black.copy(alpha = hintAlpha), topLeft = Offset(left, top), size = Size(width, height), cornerRadius = androidx.compose.ui.geometry.CornerRadius(24f, 24f) ) } } } if (animatingBubbleIndex in detectedBubbles.indices && staticData.bitmap.item != null && bubbleExpansionProgress > 0f) { val baseBitmap = staticData.bitmap.item ?: return@Canvas if ( !baseBitmap.isCanvasSafeBitmap( maxBytes = PDF_MAX_DRAW_BITMAP_BYTES, maxDimension = PDF_MAX_DRAW_BITMAP_DIMENSION_PX ) ) { return@Canvas } val safeExpandedBubbleRender = expandedBubbleRender?.takeIf { it.bitmap.isCanvasSafeBitmap( maxBytes = PDF_MAX_DRAW_BITMAP_BYTES, maxDimension = PDF_MAX_DRAW_BITMAP_DIMENSION_PX ) } val bubble = detectedBubbles[animatingBubbleIndex] val left = bubble.bounds.left + staticData.centeringOffsetX val top = bubble.bounds.top + staticData.centeringOffsetY val logicalWidth = bubble.bounds.width() val logicalHeight = bubble.bounds.height() val pivotX = left + logicalWidth / 2f val pivotY = top + logicalHeight / 2f val targetZoomFactor = safeExpandedBubbleRender?.zoomFactor ?: computeDynamicBubbleZoomFactor( bubbleBounds = bubble.bounds, viewportWidth = staticData.canvasWidth, viewportHeight = staticData.canvasHeight ) val zoomFactor = androidx.compose.ui.util.lerp(1f, targetZoomFactor, bubbleExpansionProgress) withTransform({ scale(zoomFactor, zoomFactor, Offset(pivotX, pivotY)) }) { val dstOffset = IntOffset(left.toInt(), top.toInt()) val dstSize = IntSize(logicalWidth.toInt(), logicalHeight.toInt()) val renderScaleX = baseBitmap.width.toFloat() / staticData.targetWidth.toFloat() val renderScaleY = baseBitmap.height.toFloat() / staticData.targetHeight.toFloat() val srcOffset = IntOffset( (bubble.bounds.left * renderScaleX).toInt(), (bubble.bounds.top * renderScaleY).toInt() ) val srcSize = IntSize( (logicalWidth * renderScaleX).toInt(), (logicalHeight * renderScaleY).toInt() ) if (bubble.maskBitmap != null) { drawImage( image = bubble.maskBitmap.asImageBitmap(), dstOffset = IntOffset(left.toInt() + 12, top.toInt() + 12), dstSize = dstSize, colorFilter = ColorFilter.tint(Color.Black.copy(alpha = 0.5f * bubbleExpansionProgress)), filterQuality = androidx.compose.ui.graphics.FilterQuality.High ) } else { drawRoundRect( color = Color.Black.copy(alpha = 0.5f * bubbleExpansionProgress), topLeft = Offset(left + 12f, top + 12f), size = Size(logicalWidth, logicalHeight), cornerRadius = androidx.compose.ui.geometry.CornerRadius(24f, 24f) ) } if (bubble.maskBitmap != null) { val rect = androidx.compose.ui.geometry.Rect( dstOffset.x.toFloat(), dstOffset.y.toFloat(), dstOffset.x.toFloat() + dstSize.width, dstOffset.y.toFloat() + dstSize.height ) drawContext.canvas.saveLayer(rect, androidx.compose.ui.graphics.Paint()) drawImage( image = (safeExpandedBubbleRender?.bitmap ?: baseBitmap).asImageBitmap(), srcOffset = if (safeExpandedBubbleRender != null) IntOffset.Zero else srcOffset, srcSize = if (safeExpandedBubbleRender != null) { IntSize( safeExpandedBubbleRender.bitmap.width, safeExpandedBubbleRender.bitmap.height) } else { srcSize }, dstOffset = dstOffset, dstSize = dstSize, filterQuality = androidx.compose.ui.graphics.FilterQuality.High ) drawImage( image = bubble.maskBitmap.asImageBitmap(), dstOffset = dstOffset, dstSize = dstSize, blendMode = BlendMode.DstIn, filterQuality = androidx.compose.ui.graphics.FilterQuality.High ) drawContext.canvas.restore() } else { clipRect(left, top, left + logicalWidth, top + logicalHeight) { drawImage( image = (safeExpandedBubbleRender?.bitmap ?: baseBitmap).asImageBitmap(), srcOffset = if (safeExpandedBubbleRender != null) IntOffset.Zero else srcOffset, srcSize = if (safeExpandedBubbleRender != null) { IntSize( safeExpandedBubbleRender.bitmap.width, safeExpandedBubbleRender.bitmap.height) } else { srcSize }, dstOffset = dstOffset, dstSize = dstSize ) } drawRect( color = Color.White.copy(alpha = 0.5f * bubbleExpansionProgress), topLeft = Offset(left, top), size = Size(logicalWidth, logicalHeight), style = Stroke(width = 4f) ) } } } } } } } } @Stable class PdfDrawingState { var currentAnnotation by mutableStateOf(null) private set private val currentPoints = mutableListOf() 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 } fun updateDrag(point: PdfPoint) { if (currentPoints.isNotEmpty()) { val start = currentPoints.first() currentPoints.clear() currentPoints.add(start) currentPoints.add(point) currentAnnotation = currentAnnotation?.copy(points = currentPoints.toList()) } } } @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?.withoutTrailingPdfPageBreakForRender() } 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) { } } } } } private fun AnnotatedString.withoutTrailingPdfPageBreakForRender(): AnnotatedString { return if (text.lastOrNull() == PAGE_BREAK_CHAR) { subSequence(0, length - 1) } else { this } } private fun getNativePointer(obj: Any): Long { val priorityFields = listOf("pagePtr", "mNativePage", "page") for (name in priorityFields) { try { val field = obj.javaClass.getDeclaredField(name) field.isAccessible = true val value = field.get(obj) if (value is Long && value != 0L) return value if (value != null && value !is Long) { val nestedPtr = getNativePointer(value) if (nestedPtr != 0L) return nestedPtr } } catch (_: Exception) {} } try { for (field in obj.javaClass.declaredFields) { if (field.type == Long::class.java || field.type == Long::class.javaPrimitiveType) { field.isAccessible = true val value = field.get(obj) as Long if (value > 0xFFFFFFFFL) return value } } } catch (_: Exception) {} return 0L }