book-reader/app/src/main/java/com/aryan/reader/pdf/PdfPageComposable.kt
Aryan d7a9cae9e1
v1.0.47 (#279)
* Add performance and stylus debugging logs

* Refactor and decouple UI models from `MainViewModel`

* Refactor library state management and projection logic

* Implement desktop shell using Compose Multiplatform

* Implement desktop shell using Compose Multiplatform

* Implement desktop shell using Compose Multiplatform

* Introduce ReaderEngine and enhance EPUB reader features in windows app

* Move core paginated reader logic to a Kotlin Multiplatform `shared` module and introduce experimental desktop support.

* Implement PDF rendering and text extraction for desktop using Pdfium

* Add `NonReaderScreens.kt` and UI dependencies

* Refactor and centralize library state management and models to improve cross-platform consistency

* Implement JSON persistence for desktop library and enhance library management features including shelf CRUD, tagging, and metadata editing

* Implement PDF annotation system and enhanced zoom controls for the desktop viewer

* Implement WebView-based EPUB rendering for desktop using CEF and embedded resources

* Optimize UI state projection, navigation state handling, and main screen pager performance

* Implement Bring Your Own Key (BYOK) support for AI features in OSS version

* Support Gemini-based Cloud TTS with BYOK support for OSS builds

* Refactor table cell image sizing in `PaginatedReader` and improve `MobiParser` native library loading and error handling.

* crash fixes

* Enhance navigation stability with lifecycle-aware safety checks and update `navigation-compose` to 2.9.6

* Implement dynamic bottom padding for the page info bar to account for device rounded corners

* Implement bidirectional jump history navigation and replace the jump-back pill with a dedicated `PdfJumpHistoryBar`

* Optimize PDF tiling performance and refine pan-and-fling gesture handling

* Implement customizable toolbars with drag-and-drop reordering and placement for PDF and EPUB readers

* Updated UI for customize toolbar

* Refine drag-and-drop reordering and section assignment for PDF and EPUB reader controls

* restructure PDF viewer UI component hierarchy to fix verifier crash

* Implement separate text dimming factors for light and dark themes

* Synchronize Pdfium access and improve resource lifecycle safety across Kotlin and native layers

* Enhance image alignment in paginated and EPUB readers through anchor detection and style-based positioning

* Centralize file type resolution logic and implement HTML sanitization during import

* Introduce vertical margin customization and configurable progress bar positioning

* texture support in epub reader

* Enhance TTS session management, progress tracking, and diagnostic logging

* Optimize library state projection and folder synchronization performance by refactoring collection lookups and refining metadata extraction logic.

* Refine TTS page mapping for PDF and overhaul TTS control UI

* Implement natural session completion logic in `TtsPlaybackManager` for cloud tts

* Replace Snackbar with `CustomTopBanner` for notifications in `PdfViewerScreen`

* Refine TTS playback continuity across PDF pages and improve state management for session transitions

* Implement global texture transparency and enhance textured theme support across PDF and EPUB readers.

* Update reader themes and improve texture rendering in page animations, EPUB UI, and immersive mode

* Add Support Project screen

* Optimize library performance via projection caching, batch database updates, and scoped folder synchronization.

* Enhance folder synchronization with fallback query mechanisms and refactor annotation sidecar importing logic

* Bump version to 1.0.47 (51)
2026-05-04 21:55:38 +05:30

5900 lines
286 KiB
Kotlin

// 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.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.coroutineScope
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
}
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<EmbeddedAnnotation> = 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)
private const val PDF_TILE_SIZE_DP = 256
private const val PDF_MAX_TILE_BITMAP_SIZE_PX = 3072
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 cropWidth = (bubbleBounds.width() * renderScale).roundToInt().coerceAtLeast(1)
val cropHeight = (bubbleBounds.height() * renderScale).roundToInt().coerceAtLeast(1)
val bitmap = createBitmap(cropWidth, cropHeight)
try {
page.renderPageBitmap(
bitmap = bitmap,
startX = (-bubbleBounds.left * renderScale).roundToInt(),
startY = (-bubbleBounds.top * renderScale).roundToInt(),
drawSizeX = (pageWidth * renderScale).roundToInt().coerceAtLeast(cropWidth),
drawSizeY = (pageHeight * renderScale).roundToInt().coerceAtLeast(cropHeight),
renderAnnot = true
)
bitmap
} catch (t: Throwable) {
bitmap.recycle()
Timber.tag("BubbleZoom").w(t, "Failed to render expanded bubble bitmap for page $pageIndex")
null
}
}
}
object PdfInkGeometry {
fun calculateFountainPenPoints(
points: List<PdfPoint>, baseWidth: Float, pageWidth: Float, pageHeight: Float
): Pair<List<Offset>, List<Offset>> {
if (points.size < 2) return Pair(emptyList(), emptyList())
if (points.size % 50 == 0) {
Timber.tag("FountainPenDebug").d(
"Calculate Points: PWidth=$pageWidth, PHeight=$pageHeight, BaseW=$baseWidth, Pts=${points.size}"
)
}
val leftSide = mutableListOf<Offset>()
val rightSide = mutableListOf<Offset>()
val computedWidths = FloatArray(points.size)
computedWidths[0] = baseWidth
val velocityFactor = 300f
for (i in 1 until points.size) {
val p1 = points[i - 1]
val p2 = points[i]
val dxNorm = p2.x - p1.x
val dyNorm = p2.y - p1.y
val aspect = if (pageWidth > 0 && pageHeight > 0) pageHeight / pageWidth else 1f
val distNorm = sqrt(dxNorm * dxNorm + (dyNorm * aspect) * (dyNorm * aspect))
val timeDelta = (p2.timestamp - p1.timestamp).coerceAtLeast(1)
val velocityNorm = distNorm / timeDelta
val targetWidth = (baseWidth * (1f / (1f + velocityNorm * velocityFactor))).coerceIn(
baseWidth * 0.2f, baseWidth * 1.4f
)
computedWidths[i] = computedWidths[i - 1] * 0.6f + targetWidth * 0.4f
if (i < 5) {
Timber.tag("FountainPenDebug").v(
"Pt[$i]: dt=$timeDelta, velNorm=$velocityNorm, width=${computedWidths[i]} (base=$baseWidth)"
)
}
}
for (i in 0 until points.size - 1) {
val pCurrent = points[i]
val pNext = points[i + 1]
val curX = pCurrent.x * pageWidth
val curY = pCurrent.y * pageHeight
val nextX = pNext.x * pageWidth
val nextY = pNext.y * pageHeight
val angle = atan2(nextY - curY, nextX - curX)
val normalAngle = angle - (PI / 2f).toFloat()
val w = computedWidths[i] / 2f
leftSide.add(Offset((curX + cos(normalAngle) * w), (curY + sin(normalAngle) * w)))
rightSide.add(Offset((curX - cos(normalAngle) * w), (curY - sin(normalAngle) * w)))
}
val lastIdx = points.lastIndex
val lastP = points[lastIdx]
val prevP = points[lastIdx - 1]
val lastX = lastP.x * pageWidth
val lastY = lastP.y * pageHeight
val prevX = prevP.x * pageWidth
val prevY = prevP.y * pageHeight
val lastAngle = atan2(lastY - prevY, lastX - prevX)
val lastNormal = lastAngle - (PI / 2f).toFloat()
val lastW = computedWidths[lastIdx] / 2f
leftSide.add(Offset((lastX + cos(lastNormal) * lastW), (lastY + sin(lastNormal) * lastW)))
rightSide.add(Offset((lastX - cos(lastNormal) * lastW), (lastY - sin(lastNormal) * lastW)))
return Pair(leftSide, rightSide)
}
}
internal object PdfBitmapPool {
private val pool = ConcurrentLinkedQueue<Bitmap>()
private const val MAX_POOL_SIZE = 4
fun get(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) {
if (!bitmap.isRecycled && pool.size < MAX_POOL_SIZE) {
pool.offer(bitmap)
} else {
bitmap.recycle()
}
}
fun clear() {
while (!pool.isEmpty()) {
pool.poll()?.recycle()
}
}
}
internal object PdfThumbnailCache {
private val maxMemory = (Runtime.getRuntime().maxMemory() / 1024).toInt()
private val cacheSize = maxMemory / 8
private data class CacheEntry(val bitmap: Bitmap, val sizeKb: Int)
private val memoryCache = object : LruCache<Int, CacheEntry>(cacheSize) {
override fun sizeOf(key: Int, entry: CacheEntry): Int {
return entry.sizeKb
}
}
fun get(pageIndex: Int): Bitmap? {
return memoryCache.get(pageIndex)?.bitmap?.takeUnless { it.isRecycled }
}
fun put(pageIndex: Int, bitmap: Bitmap) {
if (get(pageIndex) == null) {
val sizeKb = (bitmap.allocationByteCount / 1024).coerceAtLeast(1)
memoryCache.put(pageIndex, CacheEntry(bitmap, sizeKb))
}
}
fun clear() {
memoryCache.evictAll()
}
}
@Stable
data class StableHolder<T>(val item: T)
@Stable
data class PageStaticData(
val bitmap: StableHolder<Bitmap?>,
val tiles: StableHolder<List<PdfTile>>,
val effectiveScale: Float,
val centeringOffsetX: Float,
val centeringOffsetY: Float,
val canvasWidth: Float,
val canvasHeight: Float,
val targetWidth: Int,
val targetHeight: Int,
val colorFilter: StableHolder<ColorFilter?>,
val isDarkMode: Boolean,
val excludeImages: Boolean,
val imageRects: StableHolder<List<android.graphics.Rect>>,
val textureBitmap: StableHolder<ImageBitmap?>,
val textureAlpha: Float,
val textureBlendMode: BlendMode
)
@Stable
data class PageSelectionData(
val pageLinks: StableHolder<List<PageLink>>,
val showAllTextHighlights: Boolean,
val actualBitmapWidthPx: Int,
val actualBitmapHeightPx: Int,
val mergedAllTextPageHighlightRects: StableHolder<List<Rect>>,
val mergedTtsHighlightRects: StableHolder<List<Rect>>,
val mergedSearchFocusedRects: StableHolder<List<Rect>>,
val mergedSearchAllRects: StableHolder<List<Rect>>,
val searchHighlightMode: SearchHighlightMode,
val ocrHoverHighlights: StableHolder<List<RectF>>,
val mergedSelectionRects: StableHolder<List<Rect>>,
val centeringOffsetX: Float,
val centeringOffsetY: Float,
val linkHighlightColor: Color,
val scrimColorForTextHighlight: Color,
val allTextPageHighlightColor: Color,
val ttsHighlightColor: Color,
val selectionHighlightColor: Color,
val pageIndex: Int,
val userHighlightScreenRects: StableHolder<List<Pair<PdfUserHighlight, List<Rect>>>>,
val customHighlightColors: StableHolder<Map<PdfHighlightColor, Color>>
)
@OptIn(FlowPreview::class)
@Suppress("unused")
@Composable
internal fun PdfPageComposable(
pdfDocument: StableHolder<ReaderDocument>,
pageIndex: Int,
totalPages: Int,
modifier: Modifier = Modifier,
virtualPage: VirtualPage? = null,
onScaleChanged: (Float) -> Unit,
externalScale: Float = 1f,
showAllTextHighlights: Boolean,
onHighlightLoading: (Boolean) -> Unit,
searchQuery: String = "",
searchHighlightMode: SearchHighlightMode = SearchHighlightMode.ALL,
searchResultToHighlight: SearchResult?,
ocrHoverHighlights: StableHolder<List<RectF>> = StableHolder(emptyList()),
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,
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<PdfAnnotation> = { emptyList() },
onDrawStart: (PdfPoint, Boolean) -> Unit = { _, _ -> },
onDraw: (PdfPoint, Boolean) -> Unit = { _, _ -> },
onDrawEnd: () -> Unit = {},
visibleScreenRect: () -> IntRect? = { null },
selectedTool: InkType = InkType.PEN,
richTextController: RichTextController? = null,
textBoxes: List<PdfTextBox> = emptyList(),
selectedTextBoxId: String? = null,
onTextBoxChange: (PdfTextBox) -> Unit = {},
onTextBoxSelect: (String) -> Unit = {},
onTextBoxDragStart: (PdfTextBox, Offset, Offset) -> Unit = { _, _, _ -> },
onTextBoxDrag: (Offset) -> Unit = {},
onTextBoxDragEnd: () -> Unit = {},
onDragPageTurn: (Int) -> Unit = {},
draggingBoxId: String? = null,
isScrollLocked: Boolean = false,
isVisible: Boolean = true,
isActivePage: Boolean = true,
isBubbleZoomModeActive: Boolean = false,
isStylusOnlyMode: Boolean = false,
isAutoScrollPlaying: Boolean = false,
isHighlighterSnapEnabled: Boolean = false,
userHighlights: List<PdfUserHighlight> = emptyList(),
onHighlightAdd: (Int, Pair<Int, Int>, String, PdfHighlightColor) -> Unit = { _,_,_,_ -> },
onHighlightUpdate: (String, PdfHighlightColor) -> Unit = { _,_ -> },
onHighlightDelete: (String) -> Unit = {},
onNoteRequested: (String?) -> Unit = {},
onTts: (Int, Int) -> Unit = { _, _ -> },
activeToolThickness: Float = 0f,
customHighlightColors: Map<PdfHighlightColor, Color> = emptyMap(),
onPaletteClick: (() -> Unit)? = null,
lockedState: Triple<Float, Float, Float>? = null,
onZoomAndPanChanged: ((Float, Offset) -> Unit)? = null,
onDetectBubbles: suspend (Int, Bitmap) -> List<SpeechBubble> = { _, _ -> emptyList() },
onShowPanelPopup: (Bitmap) -> Unit = {}
) {
val pdfDocumentItem = pdfDocument.item
var bitmapState by remember { mutableStateOf(PdfThumbnailCache.get(pageIndex)) }
var currentRenderedPageId by remember { mutableStateOf<String?>(null) }
val targetPageId = remember(virtualPage, pageIndex) {
when (virtualPage) {
is VirtualPage.BlankPage -> "BLANK_${virtualPage.id}"
is VirtualPage.PdfPage -> "PDF_${virtualPage.pdfIndex}"
null -> "PDF_$pageIndex"
}
}
var isLoadingPage by remember { mutableStateOf(true) }
var pageErrorMessage by remember { mutableStateOf<String?>(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<LayoutCoordinates?>(null) }
var ocrRipplePosition by remember { mutableStateOf<Offset?>(null) }
var isTransforming by remember { mutableStateOf(false) }
var scale by remember { mutableFloatStateOf(1f) }
var offset by remember { mutableStateOf(Offset.Zero) }
var paginationPanFlingJob by remember { mutableStateOf<Job?>(null) }
LaunchedEffect(scale, offset) {
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<Offset?>(null) }
SideEffect {
if (drawingState?.currentAnnotation?.pageIndex == pageIndex) {
Timber.tag("PdfDrawPerf").v(
"PAGE EFFECTIVE SCALE: Page $pageIndex = $effectiveScale (ZoomEnabled=$isZoomEnabled)"
)
}
}
val isPdfPage = virtualPage == null || virtualPage is VirtualPage.PdfPage
val pdfPageIndex = (virtualPage as? VirtualPage.PdfPage)?.pdfIndex ?: pageIndex
var tiles by remember { mutableStateOf<List<PdfTile>>(emptyList()) }
val tileSizeDp = 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 { mutableStateOf<Pair<Int, Int>?>(null) }
var activeDraggingHandle by remember { mutableStateOf<Handle?>(null) }
var selectedWordScreenRects by remember { mutableStateOf<List<Rect>>(emptyList()) }
val startHandleContentPosition = remember { mutableStateOf<Offset?>(null) }
val endHandleContentPosition = remember { mutableStateOf<Offset?>(null) }
var actualBitmapWidthPx by remember { mutableIntStateOf(0) }
var actualBitmapHeightPx by remember { mutableIntStateOf(0) }
var currentPageRotation by remember { mutableIntStateOf(0) }
val 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 { mutableStateOf<CustomPdfMenuState?>(null) }
val inputScale = if (isZoomEnabled && !isVerticalScroll) scale else 1f
val inputOffset = if (isZoomEnabled && !isVerticalScroll) offset else Offset.Zero
val screenToContentCoordinates: (Offset) -> Offset = { screenOffset ->
val screenCenter = Offset(canvasWidthPx.floatValue / 2f, canvasHeightPx.floatValue / 2f)
val pCanvas = ((screenOffset - inputOffset - screenCenter) / inputScale) + screenCenter
val contentOffset = pCanvas - Offset(centeringOffsetX, centeringOffsetY)
contentOffset
}
val contentToScreenCoordinates: (Offset) -> Offset = { contentOffset ->
val pCanvas = contentOffset + Offset(centeringOffsetX, centeringOffsetY)
val screenCenter = Offset(canvasWidthPx.floatValue / 2f, canvasHeightPx.floatValue / 2f)
val screenOffset = (pCanvas - screenCenter) * inputScale + screenCenter + inputOffset
screenOffset
}
var detectedBubbles by remember(targetPageId) { mutableStateOf<List<SpeechBubble>>(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<ExpandedBubbleRender?>(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<SpeechBubble> { (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 (!isBubbleZoomModeActive && scale > 1f && !isVerticalScroll && isZoomEnabled) {
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(Unit) {
onDispose {
val currentBitmap = bitmapState
val cachedBitmap = PdfThumbnailCache.get(pageIndex)
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 { mutableStateOf<OcrResult?>(null) }
var isPerformingOcrForSelection by remember { mutableStateOf(false) }
var selectionMethodUsed by remember { mutableStateOf(PdfSelectionMethod.PDFIUM) }
var ocrSelectionSymbolIndices by remember { mutableStateOf<Pair<Int, Int>?>(null) }
var allOcrSymbolsForSelection by remember { mutableStateOf<List<OcrSymbolInfo>>(emptyList()) }
var highlightedTextScreenRects by remember { mutableStateOf<List<Rect>>(emptyList()) }
val ttsHighlightColor = Color(0xFFFFECB3).copy(alpha = 0.4f)
var allTextPageHighlightRects by remember { mutableStateOf<List<Rect>>(emptyList()) }
var accumulatedKeyboardOffset by remember { mutableFloatStateOf(0f) }
val allTextPageHighlightColor = if (isDarkMode) {
Color(0xFFFFEB3B).copy(alpha = 0.4f)
} else {
MaterialTheme.colorScheme.primary.copy(alpha = 0f)
}
val scrimColorForTextHighlight = if (isDarkMode) {
Color.Transparent
} else {
Color.Black.copy(alpha = 0.4f)
}
val selectionHighlightColor = Color(0x6633B5E5)
val mergedSelectionRects =
remember(selectedWordScreenRects) { mergeRectsIntoLines(selectedWordScreenRects) }
val mergedTtsHighlightRects =
remember(highlightedTextScreenRects, centeringOffsetX, centeringOffsetY) {
mergeRectsIntoLines(highlightedTextScreenRects)
}
val mergedAllTextPageHighlightRects =
remember(allTextPageHighlightRects, centeringOffsetX, centeringOffsetY) {
mergeRectsIntoLines(allTextPageHighlightRects)
}
var searchHighlightRects by remember { mutableStateOf<List<Rect>>(emptyList()) }
val searchHighlightColor = Color(0xFFFFAB00).copy(alpha = 0.5f)
val mergedSearchHighlightRects =
remember(searchHighlightRects) { mergeRectsIntoLines(searchHighlightRects) }
var pageLinks by remember { mutableStateOf<List<PageLink>>(emptyList()) }
val linkHighlightColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f)
val linkVerticalPaddingPx = remember(density) { with(density) { 10.dp.toPx().toInt() } }
var userHighlightScreenRects by remember { mutableStateOf<List<Pair<PdfUserHighlight, List<Rect>>>>(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<Rect> = 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 { mutableStateOf<List<EmbeddedAnnotation>>(emptyList()) }
var standardAnnotScreenRects by remember { mutableStateOf<List<Pair<EmbeddedAnnotation, Rect>>>(emptyList()) }
var imageScreenRects by remember { mutableStateOf<List<android.graphics.Rect>>(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<PageLink>()
var finalDisplayList = emptyList<EmbeddedAnnotation>()
var mappedAnnots = emptyList<Pair<EmbeddedAnnotation, Rect>>()
var mappedImageRects = emptyList<android.graphics.Rect>()
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<android.graphics.Rect>()
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 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<EmbeddedAnnotation>()
allAnnots.forEach { annot ->
if (!annot.inReplyTo.isNullOrBlank() && annotMap.containsKey(annot.inReplyTo)) {
Timber.tag("PdfCommentDebug").i("Linking: ${annot.name} is a reply to ${annot.inReplyTo}")
annotMap[annot.inReplyTo]?.replies?.add(annot)
} else {
orphans.add(annot)
}
}
Timber.tag("PdfCommentDebug").d("After ID linking: Orphans count = ${orphans.size}")
val groupedRoots = mutableListOf<MutableList<EmbeddedAnnotation>>()
orphans.forEach { annot ->
val match = groupedRoots.find { group ->
val root = group.first()
val inflatedRoot = android.graphics.RectF(root.rect).apply { inset(-10f, -10f) }
android.graphics.RectF.intersects(inflatedRoot, annot.rect)
}
if (match != null) {
Timber.tag("PdfCommentDebug").w("Geometric grouping triggered for ${annot.name} with ${match.first().name}. This might flatten nested replies!")
match.add(annot)
} else {
groupedRoots.add(mutableListOf(annot))
}
}
val rootsWithReplies = groupedRoots.map { group ->
val root = group.first()
if (group.size > 1) {
root.replies.addAll(group.drop(1))
}
root
}
finalDisplayList = rootsWithReplies.filter {
!it.contents.isNullOrBlank() || it.replies.any { r -> !r.contents.isNullOrBlank() }
}
mappedAnnots = finalDisplayList.map { annot ->
val screenRect = pageWrapper.mapRectToDevice(
0, 0, actualBitmapWidthPx, actualBitmapHeightPx,
currentPageRotation, annot.rect
)
annot to screenRect
}
}
}
} else {
Timber.tag("PdfCommentDebug").w("Page $pageIndex: Failed to resolve native page pointer.")
}
} 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(pageIndex)) { }
}
}
}
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<Int>()
val cols = (actualBitmapWidthPx + tileSizePx - 1) / tileSizePx
val startCol = (visibleBitmapRect.left / tileSizePx).coerceAtLeast(0)
val endCol = ((visibleBitmapRect.right + tileSizePx - 1) / tileSizePx).coerceAtMost(cols)
val startRow = (visibleBitmapRect.top / tileSizePx).coerceAtLeast(0)
val endRow = ((visibleBitmapRect.bottom + tileSizePx - 1) / tileSizePx).coerceAtMost(
(actualBitmapHeightPx + tileSizePx - 1) / tileSizePx
)
for (row in startRow until endRow) {
for (col in startCol until endCol) {
requiredTileIds.add(row * cols + col)
}
}
val currentTileIds = tiles.map { it.tileId }.toSet()
val 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<PdfTile>()
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 { mutableStateOf<List<Rect>>(emptyList()) }
var searchAllRects by remember { mutableStateOf<List<Rect>>(emptyList()) }
var keyboardAdjustmentOriginalOffset by remember { mutableStateOf<Float?>(null) }
val mergedSearchFocusedRects = remember(searchFocusedRects) { searchFocusedRects }
val mergedSearchAllRects = remember(searchAllRects) { searchAllRects }
// Colors
val searchFocusedColor = Color(0xFFFF6D00).copy(alpha = 0.5f) // Bold Orange
val searchAllColor = Color(0xFFFFEB3B).copy(alpha = 0.4f) // Standard Yellow
LaunchedEffect(
searchQuery, searchResultToHighlight, actualBitmapWidthPx, actualBitmapHeightPx, virtualPage
) {
if (!isPdfPage) {
searchFocusedRects = emptyList()
searchAllRects = emptyList()
return@LaunchedEffect
}
if (actualBitmapWidthPx == 0 || searchQuery.isBlank()) {
searchFocusedRects = emptyList()
searchAllRects = emptyList()
return@LaunchedEffect
}
val isTargetOnPage = searchResultToHighlight?.locationInSource == pageIndex
var foundAll: List<Rect> = emptyList()
var foundFocused: List<Rect> = emptyList()
withContext(Dispatchers.IO) {
try {
pdfDocumentItem.openPage(pdfPageIndex)?.use { page ->
page.openTextPage().use { textPage ->
val charCount = textPage.textPageCountChars()
if (charCount > 0) {
val fullText = textPage.textPageGetText(0, charCount)
if (!fullText.isNullOrBlank()) {
val occurrences = mutableListOf<Int>()
try {
val regex = Regex("(?i)\\b${Regex.escape(searchQuery)}")
val matches = regex.findAll(fullText)
matches.forEach { matchResult ->
occurrences.add(matchResult.range.first)
}
} catch (_: Exception) {
var lastIndex = -1
while (true) {
lastIndex = fullText.indexOf(
searchQuery, lastIndex + 1, ignoreCase = true
)
if (lastIndex == -1) break
occurrences.add(lastIndex)
}
}
if (occurrences.isNotEmpty()) {
val queryLen = searchQuery.length
val allRectsRaw = occurrences.flatMap { startIndex ->
textPage.textPageGetRectsForRanges(
intArrayOf(startIndex, queryLen)
)?.map { it.rect } ?: emptyList()
}
foundAll = allRectsRaw.mapNotNull { pdfRectF ->
page.mapRectToDevice(
startX = 0,
startY = 0,
sizeX = actualBitmapWidthPx,
sizeY = actualBitmapHeightPx,
rotate = currentPageRotation,
coords = pdfRectF
).takeIf {
it.width() > 0 && it.height() > 0
}
}
if (isTargetOnPage) {
val targetIdx =
searchResultToHighlight.occurrenceIndexInLocation
if (targetIdx >= 0 && targetIdx < occurrences.size) {
val startIndex = occurrences[targetIdx]
val focusedRectsRaw =
textPage.textPageGetRectsForRanges(
intArrayOf(startIndex, queryLen)
)?.map { it.rect } ?: emptyList()
foundFocused = focusedRectsRaw.mapNotNull { pdfRectF ->
page.mapRectToDevice(
startX = 0,
startY = 0,
sizeX = actualBitmapWidthPx,
sizeY = actualBitmapHeightPx,
rotate = currentPageRotation,
coords = pdfRectF
).takeIf {
it.width() > 0 && it.height() > 0
}
}
}
}
}
}
}
}
}
} catch (e: Exception) {
Timber.e(e, "Search Highlight: Failed to get rects for page $pageIndex")
}
}
searchAllRects = foundAll
searchFocusedRects = foundFocused
if (foundFocused.isNotEmpty() && onSearchHighlightCenterCalculated != null) {
val unionRect = RectF()
if (foundFocused.isNotEmpty()) {
unionRect.set(foundFocused[0])
for (i in 1 until foundFocused.size) {
unionRect.union(RectF(foundFocused[i]))
}
onSearchHighlightCenterCalculated(unionRect.centerY())
}
}
}
fun findClosestOcrSymbolIndex(symbols: List<OcrSymbolInfo>, x: Float, y: Float): Int {
if (symbols.isEmpty()) return -1
val containingSymbolIndex = symbols.indexOfFirst {
it.symbol.boundingBox?.contains(x.toInt(), y.toInt()) == true
}
if (containingSymbolIndex != -1) return containingSymbolIndex
var minDistanceSq = Float.MAX_VALUE
var closestSymbolIndex = -1
symbols.forEachIndexed { index, info ->
info.symbol.boundingBox?.let { box ->
val distSq = (x - box.exactCenterX()).pow(2) + (y - box.exactCenterY()).pow(2)
if (distSq < minDistanceSq) {
minDistanceSq = distSq
closestSymbolIndex = index
}
}
}
return closestSymbolIndex
}
fun updateOcrSymbolSelectionRectsAndHandles(indices: Pair<Int, Int>?) {
if (indices == null || allOcrSymbolsForSelection.isEmpty() || indices.first >= indices.second) {
selectedWordScreenRects = emptyList()
startHandleContentPosition.value = null
endHandleContentPosition.value = null
return
}
val selectedSymbols = allOcrSymbolsForSelection.subList(indices.first, indices.second)
if (selectedSymbols.isEmpty()) {
selectedWordScreenRects = emptyList()
startHandleContentPosition.value = null
endHandleContentPosition.value = null
return
}
selectedWordScreenRects = selectedSymbols.mapNotNull { it.symbol.boundingBox }
val firstRect = selectedSymbols.first().symbol.boundingBox!!
val lastRect = selectedSymbols.last().symbol.boundingBox!!
startHandleContentPosition.value =
Offset(firstRect.left.toFloat(), firstRect.bottom.toFloat())
endHandleContentPosition.value = Offset(lastRect.right.toFloat(), lastRect.bottom.toFloat())
}
suspend fun updateSelectionVisuals(
doc: ReaderDocument,
pageIdx: Int,
charRange: Pair<Int, Int>?,
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)
}
}
}
}
val errorSelection = stringResource(R.string.error_selection)
val errorOcrSelection = stringResource(R.string.error_ocr_selection)
val errorProcessingPage = stringResource(R.string.error_processing_page)
BoxWithConstraints(
modifier = modifier
.onGloballyPositioned { layoutCoordinates = it }
.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<Offset>(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..<pageTotalChars) {
val anchorCharBox =
tDrag.textPageGetCharBox(
anchorCharIndex
)
if (anchorCharBox != null) {
ySearchCoordinate =
((anchorCharBox.top + anchorCharBox.bottom) / 2.0)
}
}
}
}
val wideSearchYTolerance = charTolerance * 1.5
if (wideSearchXTolerance > 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<Int, Int>? = null
var newHandle = activeDraggingHandle
when (activeDraggingHandle) {
Handle.START -> {
val newStart =
charIndexForUpdate.coerceIn(
0, pageCharCount - 1
)
if (newStart >= currentEnd - 1 && pageCharCount > 0) {
val tempOldEndCharIndex =
(currentEnd - 1).coerceAtLeast(
0
)
newHandle = Handle.END
newRange = Pair(
tempOldEndCharIndex,
(newStart + 1).coerceAtMost(
pageCharCount
)
)
} else {
newRange = Pair(
newStart, currentEnd
)
}
}
Handle.END -> {
val newEnd =
(charIndexForUpdate + 1).coerceIn(
1, pageCharCount
)
if (newEnd <= currentStart + 1 && pageCharCount > 0) {
newHandle = Handle.START
newRange = Pair(
(newEnd - 1).coerceAtLeast(
0
),
(currentStart + 1).coerceAtMost(
pageCharCount
)
)
} else {
newRange = Pair(
currentStart, newEnd
)
}
}
else -> {}
}
if (newRange != null) {
if (newRange.first >= newRange.second) {
if (pageCharCount > 0) {
val fixStart = min(
newRange.first,
newRange.second - 1
).coerceIn(
0, pageCharCount - 1
)
val fixEnd =
(fixStart + 1).coerceAtMost(
pageCharCount
)
newRange =
if (fixStart < fixEnd) Pair(
fixStart, fixEnd
)
else null
} else {
newRange = null
}
}
}
withContext(
Dispatchers.Main
) {
if (newHandle != null) activeDraggingHandle =
newHandle
if (newRange != null) selectionCharRange.value =
newRange
updateSelectionVisuals(
pdfDocumentItem,
pageIndex,
selectionCharRange.value,
actualBitmapWidthPx,
actualBitmapHeightPx,
currentPageRotation,
providedPage = pageForDrag,
providedTextPage = textPageForDrag
)
// Magnifier update
val currentActiveHandleForMagnifier =
activeDraggingHandle
val rects = selectedWordScreenRects
if (rects.isNotEmpty()) {
val relevantRect =
when (currentActiveHandleForMagnifier) {
Handle.START -> rects.first()
Handle.END -> rects.last()
null -> null
}
val handleContentPosToFollow =
when (currentActiveHandleForMagnifier) {
Handle.START -> startHandleContentPosition.value
Handle.END -> endHandleContentPosition.value
null -> null
}
if (relevantRect != null && handleContentPosToFollow != null) {
magnifierBitmapCenterTarget =
Offset(
x = handleContentPosToFollow.x,
y = relevantRect.exactCenterY()
)
}
}
}
}
}
}
}
}
}
} catch (e: Exception) {
Timber.e(e, "Error during handle drag worker")
} finally {
withContext(Dispatchers.IO) {
textPageForDrag?.close()
pageForDrag?.close()
}
}
}
try {
drag(down.id) { change ->
dragEventChannel.trySend(change.position)
change.consume()
}
} finally {
dragEventChannel.close()
}
if (selectionMethodUsed == PdfSelectionMethod.PDFIUM) {
if (selectionCharRange.value != null && selectedWordScreenRects.isNotEmpty()) {
val currentRange = selectionCharRange.value!!
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<OcrSymbolInfo>()
val allElements = mutableListOf<OcrElement>()
visionText.textBlocks.forEach { block ->
block.lines.forEach { line ->
line.elements.forEach { element ->
allElements.add(element)
element.symbols.forEach { symbol ->
symbolInfoList.add(
OcrSymbolInfo(
symbol, element, line
)
)
}
}
}
}
allOcrSymbolsForSelection = symbolInfoList
var foundElement: OcrElement? = null
val touchInContentCoords =
screenToContentCoordinates(
down.position
)
val touchXInBitmap = touchInContentCoords.x
val touchYInBitmap = touchInContentCoords.y
for (element in allElements) {
val box = element.boundingBox
if (box != null) {
if (touchXInBitmap >= box.left && touchXInBitmap <= box.right && touchYInBitmap >= box.top && touchYInBitmap <= box.bottom) {
foundElement = element
break
}
}
}
if (foundElement != null) {
var symbolStartIndex = 0
var elementFoundInList = false
for (element in allElements) {
if (element === foundElement) {
elementFoundInList = true
break
}
symbolStartIndex += element.symbols.size
}
if (elementFoundInList && foundElement.symbols.isNotEmpty()) {
val symbolEndIndex =
symbolStartIndex + foundElement.symbols.size
selectionMethodUsed = PdfSelectionMethod.OCR
ocrSelectionSymbolIndices = Pair(
symbolStartIndex, symbolEndIndex
)
updateOcrSymbolSelectionRectsAndHandles(
ocrSelectionSymbolIndices
)
val 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 = errorOcrSelection
} 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 = errorSelection
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
) {
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 && 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<Float>(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 {
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)
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)
}
}
}
LaunchedEffect(
pageIndex, this@BoxWithConstraints.maxWidth, this@BoxWithConstraints.maxHeight,
isScrollLocked, lockedState
) {
if (isScrollLocked && !isVerticalScroll && lockedState != null) {
scale = lockedState.first
offset = Offset(lockedState.second, lockedState.third)
onScaleChanged(scale)
} else if (!isScrollLocked && !isVerticalScroll) {
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<Rect> = emptyList()
when (ttsHighlightData) {
is TtsHighlightData.Pdfium -> {
Timber.d(
"Highlighting (Pdfium): page $pageIndex, index: ${ttsHighlightData.startIndex}, len: ${ttsHighlightData.length}"
)
rects = withContext(Dispatchers.IO) {
pdfDocumentItem.openPage(pdfPageIndex)?.use { page ->
page.openTextPage().use { textPage ->
val pdfRectsF = textPage.textPageGetRectsForRanges(
intArrayOf(
ttsHighlightData.startIndex, ttsHighlightData.length
)
)?.map { it.rect } ?: emptyList()
if (pdfRectsF.isNotEmpty()) {
pdfRectsF.mapNotNull { pdfRectF ->
page.mapRectToDevice(
startX = 0,
startY = 0,
sizeX = actualBitmapWidthPx,
sizeY = actualBitmapHeightPx,
rotate = currentPageRotation,
coords = pdfRectF
).takeIf {
it.width() > 0 && it.height() > 0
}
}
} else {
emptyList()
}
}
} ?: emptyList()
}
}
is TtsHighlightData.Ocr -> {
val textToHighlight = ttsHighlightData.text
Timber.d(
"Highlighting (OCR): page $pageIndex, text: \"${textToHighlight.take(50)}...\""
)
val ocrTextToUse = ocrVisionTextForSelection ?: try {
bitmapState?.let {
OcrHelper.extractTextFromBitmap(
it, onOcrModelDownloading
)
}
} catch (e: Exception) {
Timber.e(
e, "Highlighting: On-demand OCR for TTS failed."
)
null
}
rects = if (ocrTextToUse != null) {
findRectsForTextChunkInOcrVisual(ocrTextToUse, textToHighlight)
} else {
emptyList()
}
}
}
highlightedTextScreenRects = rects
if (rects.isNotEmpty() && onTtsHighlightCenterCalculated != null) {
val unionRect = RectF()
unionRect.set(
rects[0].left.toFloat(),
rects[0].top.toFloat(),
rects[0].right.toFloat(),
rects[0].bottom.toFloat()
)
for (i in 1 until rects.size) {
val r = rects[i]
unionRect.union(
r.left.toFloat(), r.top.toFloat(), r.right.toFloat(), r.bottom.toFloat()
)
}
onTtsHighlightCenterCalculated(unionRect.centerY())
}
} catch (e: Exception) {
Timber.e(e, "Highlighting: Error calculating highlights for page $pageIndex: $e")
highlightedTextScreenRects = emptyList()
}
}
Box(modifier = Modifier
.fillMaxSize()
.clipToBounds()
.background(backgroundColor)) {
val currentContainerMaxWidth = this@BoxWithConstraints.maxWidth
val currentContainerMaxHeight = this@BoxWithConstraints.maxHeight
val containerWidthPx = with(density) { this@BoxWithConstraints.maxWidth.toPx() }
val containerHeightPx = with(density) { this@BoxWithConstraints.maxHeight.toPx() }
val centeringOffsetX = (containerWidthPx - actualBitmapWidthPx) / 2f
val centeringOffsetY = (containerHeightPx - actualBitmapHeightPx) / 2f
LaunchedEffect(
pdfDocumentItem,
pageIndex,
isPdfPage,
pdfPageIndex,
currentContainerMaxWidth,
currentContainerMaxHeight,
density,
virtualPage,
isVisible,
currentRenderedPageId
) {
if (!isVisible && !isVerticalScroll) return@LaunchedEffect
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
}
var scaledWidth = viewContainerWidthPx
var scaledHeight = (scaledWidth / pageAspect).toInt()
if (scaledHeight > viewContainerHeightPx) {
scaledHeight = viewContainerHeightPx
scaledWidth = (scaledHeight * pageAspect).toInt()
}
if (scaledWidth == actualBitmapWidthPx &&
scaledHeight == actualBitmapHeightPx &&
bitmapState != null &&
currentRenderedPageId == targetPageId
) {
isLoadingPage = false
return@LaunchedEffect
}
actualBitmapWidthPx = scaledWidth
actualBitmapHeightPx = scaledHeight
currentPageRotation = 0
val 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(pageIndex)) {
old.recycle()
}
}
bitmapState = finalBitmap
currentRenderedPageId = targetPageId
isLoadingPage = false
return@LaunchedEffect
}
coroutineScope.launch {
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
val rotation = page.getPageRotation()
val screenDpi = (density.density * 160).roundToInt()
val originalWidthPdfUnits = page.getPageWidthPoint()
val originalHeightPdfUnits = page.getPageHeightPoint()
if (originalWidthPdfUnits <= 0 || originalHeightPdfUnits <= 0) {
page.close()
throw Exception("Invalid page dimensions")
}
val aspectRatio =
originalWidthPdfUnits.toFloat() / originalHeightPdfUnits.toFloat()
var scaledWidth = viewContainerWidthPx
var scaledHeight = (scaledWidth / aspectRatio).toInt()
if (scaledHeight > viewContainerHeightPx) {
scaledHeight = viewContainerHeightPx
scaledWidth = (scaledHeight * aspectRatio).toInt()
}
if (scaledWidth == actualBitmapWidthPx &&
scaledHeight == actualBitmapHeightPx &&
bitmapState != null &&
currentRenderedPageId == targetPageId
) {
page.close()
return@withContext null
}
val MAX_BASE_DIMEN = 3000
val baseRenderScale = 1.5f
var baseW = (scaledWidth * baseRenderScale).toInt()
var baseH = (scaledHeight * baseRenderScale).toInt()
if (baseW > MAX_BASE_DIMEN || baseH > MAX_BASE_DIMEN) {
val downScale = MAX_BASE_DIMEN.toFloat() / maxOf(baseW, baseH)
baseW = (baseW * downScale).toInt().coerceAtLeast(1)
baseH = (baseH * downScale).toInt().coerceAtLeast(1)
}
Timber.d(
"Rendering page $pageIndex at ${baseW}x${baseH} (logical: ${scaledWidth}x${scaledHeight})"
)
val newBitmap = createBitmap(baseW, baseH)
localBitmap = newBitmap
page.renderPageBitmap(
newBitmap,
0, 0,
baseW, baseH,
true
)
page.close()
Triple(newBitmap, rotation, Pair(scaledWidth, scaledHeight))
}
if (renderResult != null) {
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(pageIndex)
if (old !== cached) {
old.recycle()
}
}
val thumbWidth = newBitmap.width / 2
val thumbHeight = newBitmap.height / 2
if (thumbWidth > 0 && thumbHeight > 0) {
PdfThumbnailCache.put(
pageIndex, newBitmap.scale(thumbWidth, thumbHeight)
)
}
}
}
} catch (e: Exception) {
if (e is CancellationException) throw e
pageErrorMessage = errorProcessingPage
} 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,
isScrolling = isScrolling,
isEditMode = isEditMode,
selectedTool = selectedTool,
eraserPosition = eraserPosition,
isStylusEraserOverride = isStylusEraserOverride,
activeToolThickness = activeToolThickness,
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),
modifier = Modifier
.padding(16.dp)
.align(Alignment.Center)
)
}
}
}
}
}
@Composable
private fun OcrProcessingIndicator(position: Offset) {
val infiniteTransition = rememberInfiniteTransition(label = "ocr_indicator_transition")
val animatedRadius by infiniteTransition.animateFloat(
initialValue = 20f, targetValue = 120f, animationSpec = infiniteRepeatable(
animation = tween(1200), repeatMode = RepeatMode.Restart
), label = "ocr_radius"
)
val animatedAlpha by infiniteTransition.animateFloat(
initialValue = 0.8f, targetValue = 0f, animationSpec = infiniteRepeatable(
animation = tween(1200), repeatMode = RepeatMode.Restart
), label = "ocr_alpha"
)
val color = MaterialTheme.colorScheme.primary
Canvas(modifier = Modifier.fillMaxSize()) {
drawCircle(
color = color.copy(alpha = animatedAlpha),
radius = animatedRadius,
center = position,
style = Stroke(width = (4.dp * animatedAlpha).toPx())
)
}
}
@Composable
private fun PdfBitmapLayer(
bitmapState: Bitmap?,
tiles: List<PdfTile>,
effectiveScale: Float,
centeringOffsetX: Float,
centeringOffsetY: Float,
@Suppress("unused") canvasWidth: Float,
@Suppress("unused") canvasHeight: Float,
targetWidth: Int,
targetHeight: Int,
colorFilter: ColorFilter? = null,
isDarkMode: Boolean = false,
excludeImages: Boolean = false,
imageRects: List<android.graphics.Rect> = 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.isRecycled) {
val dstW = if (targetWidth > 0) targetWidth else bitmapState.width
val dstH = if (targetHeight > 0) targetHeight else bitmapState.height
val srcSize = IntSize(bitmapState.width, bitmapState.height)
val dstSize = IntSize(dstW, dstH)
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.isRecycled) {
drawImage(
image = tile.bitmap.asImageBitmap(),
srcOffset = IntOffset.Zero,
srcSize = IntSize(tile.bitmap.width, tile.bitmap.height),
dstOffset = IntOffset(tile.renderRect.left, tile.renderRect.top),
dstSize = IntSize(tile.renderRect.width(), tile.renderRect.height()),
colorFilter = colorFilter,
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<PageLink>,
showAllTextHighlights: Boolean,
actualBitmapWidthPx: Int,
actualBitmapHeightPx: Int,
mergedAllTextPageHighlightRects: List<Rect>,
mergedTtsHighlightRects: List<Rect>,
mergedSearchFocusedRects: List<Rect>,
mergedSearchAllRects: List<Rect>,
searchHighlightMode: SearchHighlightMode,
ocrHoverHighlights: List<RectF>,
mergedSelectionRects: List<Rect>,
userHighlightScreenRects: List<Pair<PdfUserHighlight, List<Rect>>>,
centeringOffsetX: Float,
centeringOffsetY: Float,
linkHighlightColor: Color,
scrimColorForTextHighlight: Color,
allTextPageHighlightColor: Color,
ttsHighlightColor: Color,
selectionHighlightColor: Color,
customHighlightColors: Map<PdfHighlightColor, Color> = 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<PdfAnnotation>,
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<PageLink>,
showAllTextHighlights: Boolean,
actualBitmapWidthPx: Int,
actualBitmapHeightPx: Int,
mergedAllTextPageHighlightRects: List<Rect>,
mergedTtsHighlightRects: List<Rect>,
mergedSearchFocusedRects: List<Rect>,
mergedSearchAllRects: List<Rect>,
searchHighlightMode: SearchHighlightMode,
ocrHoverHighlights: List<RectF>,
mergedSelectionRects: List<Rect>,
userHighlightScreenRects: List<Pair<PdfUserHighlight, List<Rect>>>,
centeringOffsetX: Float,
centeringOffsetY: Float,
linkHighlightColor: Color,
scrimColorForTextHighlight: Color,
allTextPageHighlightColor: Color,
ttsHighlightColor: Color,
selectionHighlightColor: Color,
customHighlightColors: Map<PdfHighlightColor, Color> = 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<PdfAnnotation>,
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,
isScrolling: Boolean,
isEditMode: Boolean,
selectedTool: InkType,
eraserPosition: Offset?,
isStylusEraserOverride: Boolean,
richTextController: RichTextController?,
textBoxes: List<PdfTextBox>,
selectedTextBoxId: String?,
onTextBoxChange: (PdfTextBox) -> Unit,
onTextBoxSelect: (String) -> Unit,
onTextBoxDragStart: (PdfTextBox, Offset, Offset) -> Unit,
onTextBoxDrag: (Offset) -> Unit,
onTextBoxDragEnd: () -> Unit,
onDragPageTurn: (Int) -> Unit,
draggingBoxId: String? = null,
customHighlightColors: Map<PdfHighlightColor, Color> = emptyMap(),
onPaletteClick: (() -> Unit)? = null,
onHighlightAdd: (Int, Pair<Int, Int>, String, PdfHighlightColor) -> Unit,
onHighlightUpdate: (String, PdfHighlightColor) -> Unit,
onHighlightDelete: (String) -> Unit,
onTts: (Int, Int) -> Unit,
activeToolThickness: Float,
onNote: (String?) -> Unit,
isBubbleZoomModeActive: Boolean = false,
isActivePage: Boolean = true,
isDetectingBubbles: Boolean = false,
detectedBubbles: List<SpeechBubble> = 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
}
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 (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 radiusPx = if (activeToolThickness > 0f && staticData.targetWidth > 0) {
activeToolThickness * staticData.targetWidth * scale // Calculate dynamic size based on tool settings 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,
magnifierWidth = magnifierWidth,
magnifierHeight = magnifierHeight,
zoomFactor = effectiveZoomFactor,
selectionRectsInBitmapCoords = selectionData.mergedSelectionRects.item,
highlightColor = Color(0x6633B5E5),
colorFilter = staticData.colorFilter.item,
modifier = Modifier
)
}
}
}
if (menuState != null) {
BackHandler(enabled = true, onBack = onMenuDismiss)
}
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 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 = expandedBubbleRender?.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 = staticData.bitmap.item.width.toFloat() / staticData.targetWidth.toFloat()
val renderScaleY = staticData.bitmap.item.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 = (expandedBubbleRender?.bitmap ?: staticData.bitmap.item).asImageBitmap(),
srcOffset = if (expandedBubbleRender != null) IntOffset.Zero else srcOffset,
srcSize = if (expandedBubbleRender != null) {
IntSize(
expandedBubbleRender.bitmap.width,
expandedBubbleRender.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 = (expandedBubbleRender?.bitmap ?: staticData.bitmap.item).asImageBitmap(),
srcOffset = if (expandedBubbleRender != null) IntOffset.Zero else srcOffset,
srcSize = if (expandedBubbleRender != null) {
IntSize(
expandedBubbleRender.bitmap.width,
expandedBubbleRender.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<PdfAnnotation?>(null)
private set
private val currentPoints = mutableListOf<PdfPoint>()
fun onDrawStart(pageIndex: Int, point: PdfPoint, type: InkType, color: Color, width: Float) {
currentPoints.clear()
currentPoints.add(point)
currentAnnotation = PdfAnnotation(
type = AnnotationType.INK,
inkType = type,
pageIndex = pageIndex,
points = currentPoints.toList(),
color = color,
strokeWidth = width
)
}
fun onDraw(point: PdfPoint) {
currentPoints.add(point)
currentAnnotation = currentAnnotation?.copy(points = currentPoints.toList())
}
fun onDrawCancel() {
currentAnnotation = null
currentPoints.clear()
}
fun onDrawEnd(): PdfAnnotation? {
val finalAnnot = currentAnnotation
currentAnnotation = null
currentPoints.clear()
return finalAnnot
}
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
}
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 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
}