* Implement build profiles and feature policy for offline desktop builds * Introduce unified cross-platform Settings Hub * Refactor main settings into a hierarchical page-based navigation model * Refactor library projection to use shared multiplatform logic * Refactor UI state consumption by removing intermediate screen models * Introduce AndroidSharedStateBridge to centralize state mapping and reduction logic * Refactor state management for tabs, selection, and pinning to use shared bridge logic * Refactor file type management and validation into a centralized shared module * Centralize file type resolution and improve handling of unknown types * Centralize book import logic with SharedImportPlanner * Refactor magnifier geometry logic and coordinate mapping * Properly handle orientation changes in scroll-locked PDF reader * Add screen orientation controls to EPUB and PDF readers * Implement right-to-left (RTL) pagination support and refactor reader menus * Separate right-to-left pagination settings for PDF and EPUB * Ensure PDF page data is scoped by document key for multi tab support * Implement theme-aware link styling for the epub reader * Implement jump history for back and forward navigation in the epub reader * Improve locator handling and navigation logic in paginated reader mode * Implement stable pagination navigation and location tracking * Centralize banner message management and auto-dismiss logic in MainViewModel * Implement zoom and pan state preservation for PDF pan lock mode * Enhance reader navigation UI and workspace layout management in desktop app * Refactor reader navigation sidebar and relocate search controls in desktop app * Enhance reader UI with redesigned selection menus and bottom sheet overlays * Implement custom highlight palettes and reader theme customization in desktop app * Implement cross-platform modal layer and refine reader UI styling * Improve highlight accuracy and implement metadata enrichment on book open in desktop app * Implement two-page spread layout for paginated reader on desktop * Implement persistent caching for book loading and pagination in desktop app * Implement persistent caching for book loading and pagination in desktop app * Optimize reader settings updates by separating layout and appearance changes in desktop app * Improve desktop window branding and native Windows styling * Enhance reader selection interactions and UI across EPUB and PDF viewers in desktop app * Refine selection handle positioning and interaction logic * Implement EPUB selection debug logging and improve handle targeting * Optimize desktop book loading performance and UI responsiveness * Implement anchored zoom gestures and rendering optimizations for the Desktop PDF viewer. * Implement smooth zoom preview for the PDF reader in desktop app * Optimize PDF rendering performance and responsiveness in the desktop reader * Implement conditional diagnostic logging and update desktop build configuration * Implemented hierarchical TOC, custom scrollbars, and improved desktop modal handling * Added management options for annotations and highlights in the sidebar in desktop app * Implemented `SharedStableOutlinedTextField` and updated text input fields to use `TextFieldValue` for improved cursor and selection stability. * Refined library filters and enhanced OPDS functionality in desktop app * Improved EPUB pagination measurement and implemented layout diagnostic logging for desktop app * Added PPTX support including document parsing, rendering, and indexing * Improved PPTX rendering and layout accuracy * Implemented text autofit support for PPTX rendering * Enhanced PPTX rendering with support for custom geometry, automatic numbering, table styles, and image opacity * Improved EPUB pagination accuracy and added layout telemetry in desktop app * Improved folder synchronization with metadata-only mode and hashed sidecar management in desktop app * Implemented rich text font scaling and migrated desktop ink tools to custom pointer input handling * Implemented billing account obfuscation * Implemented hierarchical folder navigation and improved library selection functionality in desktop app * Implemented platform-aware directory resolution and multi-platform native library support for desktop * Added full-screen mode for the reader workspace * Added PDF zoom indicator and interactive vertical scrollbar with page tooltips * Refactored speech bubble prefetching to use a limited radius and improved ML detector initialization and lifecycle management * Updated PDF indexing to replace existing page text and removed search result item keys * Implemented "preparing" foreground notification for TTS service * Optimized PDF rendering performance by pre-calculating page-specific annotations * Refactored desktop packaging tasks and improved distribution configuration * Optimized EPUB parser memory usage and added path traversal protection * Refactored WorkManager monitoring logic and added work pruning * Implemented comprehensive resource cleanup and memory management for WebView-based components to prevent memory leaks * Implemented bitmap size limits and scaling to prevent canvas rendering errors * Split long text paragraphs into multiple semantic blocks during HTML parsing * Implemented local ActionMode for text selection to prevent platform crashes * Refactored PPTX text layout, optimized HtmlParser block detection, and improved banner dismissal logic * Added desktop startup splash screen and deferred WebView initialization * Reorganized settings hub and added separate PDF reader defaults * Implemented embedded cover extraction and metadata support for MOBI and FB2 formats * Implemented batching for MetadataExtractionWorker and optimized EPUB metadata extraction performance. * Implemented procedurally generated book covers and replaced static placeholders * Redesigned search UI with a top bar and results overlay in desktop app * Added PDF page gap and overlay visibility options and implemented DesktopBookImporter * Refactored PDF reader UI with tabbed inspector and improved theme background handling in desktop * Implemented PDF viewport persistence for zoom and scroll positions in desktop app * Improved desktop fullscreen implementation and state restoration * Implemented desktop window state persistence * Implemented flavor-based branding and ProGuard configuration for desktop builds * Implemented precise reader positioning and improved highlight rendering logic in desktop app * Added support for user-editable book metadata * Enhanced book metadata support and integrated info/edit dialogs * Implemented embedded EPUB metadata editing * Improved highlight mapping and added custom scrollbar styling for the reader. * Reduced desktop WebView bundle size by excluding unused locales and runtime files * Added neutral pan mode as the default PDF interaction state. * Refactored library empty states and updated primary navigation tabs in desktop app * Implemented native paginated reader and unified content rendering architecture in desktop epub reader * Implemented native EPUB image rendering for desktop and improved block layout spacing with margin collapsing. * Improved pagination overflow detection in desktop * Implemented multi-block text selection with interactive handles and CFI support in desktop epub pagination
6087 lines
295 KiB
Kotlin
6087 lines
295 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.isCanvasSafeBitmap
|
|
import com.aryan.reader.loadReaderTextureBitmap
|
|
import com.aryan.reader.ml.SpeechBubble
|
|
import com.aryan.reader.pdf.data.PdfAnnotation
|
|
import com.aryan.reader.pdf.data.PdfTextBox
|
|
import com.aryan.reader.pdf.data.VirtualPage
|
|
import com.aryan.reader.pdf.ocr.OcrElement
|
|
import com.aryan.reader.pdf.ocr.OcrResult
|
|
import kotlinx.coroutines.Dispatchers
|
|
import kotlinx.coroutines.FlowPreview
|
|
import kotlinx.coroutines.Job
|
|
import kotlinx.coroutines.NonCancellable
|
|
import kotlinx.coroutines.channels.Channel
|
|
import kotlinx.coroutines.delay
|
|
import kotlinx.coroutines.flow.collectLatest
|
|
import kotlinx.coroutines.flow.conflate
|
|
import kotlinx.coroutines.isActive
|
|
import kotlinx.coroutines.launch
|
|
import kotlinx.coroutines.withContext
|
|
import kotlinx.coroutines.yield
|
|
import timber.log.Timber
|
|
import java.util.concurrent.ConcurrentLinkedQueue
|
|
import kotlin.coroutines.cancellation.CancellationException
|
|
import kotlin.math.PI
|
|
import kotlin.math.abs
|
|
import kotlin.math.atan2
|
|
import kotlin.math.cos
|
|
import kotlin.math.max
|
|
import kotlin.math.min
|
|
import kotlin.math.pow
|
|
import kotlin.math.roundToInt
|
|
import kotlin.math.sin
|
|
import kotlin.math.sqrt
|
|
import android.graphics.Color as AndroidColor
|
|
import android.graphics.Paint as NativePaint
|
|
|
|
enum class Handle {
|
|
START, END
|
|
}
|
|
|
|
enum class AnnotationType {
|
|
INK, TEXT
|
|
}
|
|
|
|
enum class InkType {
|
|
PEN, HIGHLIGHTER, HIGHLIGHTER_ROUND, ERASER, FOUNTAIN_PEN, PENCIL, TEXT
|
|
}
|
|
|
|
internal fun shouldReportPdfPageCamera(
|
|
isZoomEnabled: Boolean,
|
|
isVerticalScroll: Boolean,
|
|
isScrollLocked: Boolean,
|
|
lockedState: Triple<Float, Float, Float>?,
|
|
hasAppliedLockedState: Boolean
|
|
): Boolean {
|
|
return !isZoomEnabled ||
|
|
isVerticalScroll ||
|
|
!isScrollLocked ||
|
|
lockedState == null ||
|
|
hasAppliedLockedState
|
|
}
|
|
|
|
internal fun initialPdfPageCamera(
|
|
isZoomEnabled: Boolean,
|
|
isVerticalScroll: Boolean,
|
|
isScrollLocked: Boolean,
|
|
lockedState: Triple<Float, Float, Float>?
|
|
): Pair<Float, Offset> {
|
|
return if (isZoomEnabled && !isVerticalScroll && isScrollLocked && lockedState != null) {
|
|
lockedState.first to Offset(lockedState.second, lockedState.third)
|
|
} else {
|
|
1f to Offset.Zero
|
|
}
|
|
}
|
|
|
|
internal fun shouldResetPdfZoomAfterBubbleZoomCleanup(
|
|
isBubbleZoomModeActive: Boolean,
|
|
scale: Float,
|
|
isVerticalScroll: Boolean,
|
|
isZoomEnabled: Boolean,
|
|
isScrollLocked: Boolean
|
|
): Boolean {
|
|
return !isBubbleZoomModeActive &&
|
|
scale > 1f &&
|
|
!isVerticalScroll &&
|
|
isZoomEnabled &&
|
|
!isScrollLocked
|
|
}
|
|
|
|
data class EmbeddedAnnotation(
|
|
val index: Int,
|
|
val subtype: Int,
|
|
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)
|
|
|
|
internal fun pdfRenderPageId(documentKey: String, pageIndex: Int, virtualPage: VirtualPage?): String {
|
|
val sourcePageId = when (virtualPage) {
|
|
is VirtualPage.BlankPage -> "BLANK_${virtualPage.id}"
|
|
is VirtualPage.PdfPage -> "PDF_${virtualPage.pdfIndex}"
|
|
null -> "PDF_$pageIndex"
|
|
}
|
|
return "$documentKey:$sourcePageId"
|
|
}
|
|
|
|
private fun Throwable.readablePdfErrorDetail(): String {
|
|
return localizedMessage?.takeIf { it.isNotBlank() }
|
|
?: javaClass.simpleName.takeIf { it.isNotBlank() }
|
|
?: "Unknown error"
|
|
}
|
|
|
|
private const val PDF_TILE_SIZE_DP = 256
|
|
private const val PDF_MAX_TILE_BITMAP_SIZE_PX = 3072
|
|
private const val PDF_MAX_DRAW_BITMAP_BYTES = 64L * 1024L * 1024L
|
|
private const val PDF_MAX_DRAW_BITMAP_DIMENSION_PX = 4096
|
|
private const val PDF_TILE_SCALE_TOLERANCE = 0.06f
|
|
private const val PDF_TILE_IDLE_RENDER_DELAY_MS = 90L
|
|
private const val PDF_PAGINATION_PAN_FLING_MIN_VELOCITY = 600f
|
|
private const val PDF_PAGINATION_PAN_FLING_MULTIPLIER = 0.72f
|
|
|
|
enum class LinkSource {
|
|
ANNOTATION, TEXT_CONTENT
|
|
}
|
|
|
|
data class PageLink(
|
|
val highlightBounds: Rect,
|
|
val tapBounds: Rect,
|
|
val url: String?,
|
|
val destPageIdx: Int?,
|
|
val source: LinkSource
|
|
)
|
|
|
|
private data class ExpandedBubbleRender(
|
|
val bitmap: Bitmap,
|
|
val zoomFactor: Float
|
|
)
|
|
|
|
private fun computeDynamicBubbleZoomFactor(
|
|
bubbleBounds: RectF,
|
|
viewportWidth: Float,
|
|
viewportHeight: Float
|
|
): Float {
|
|
if (bubbleBounds.width() <= 0f || bubbleBounds.height() <= 0f) return 1.5f
|
|
val targetWidth = viewportWidth * 0.6f
|
|
val targetHeight = viewportHeight * 0.32f
|
|
return min(targetWidth / bubbleBounds.width(), targetHeight / bubbleBounds.height())
|
|
.coerceIn(1.35f, 4.25f)
|
|
}
|
|
|
|
private fun isTapInsideBubble(
|
|
bubble: SpeechBubble,
|
|
tapX: Float,
|
|
tapY: Float,
|
|
hitSlopPx: Float
|
|
): Boolean {
|
|
val expandedBounds = RectF(bubble.bounds)
|
|
expandedBounds.inset(-hitSlopPx, -hitSlopPx)
|
|
if (!expandedBounds.contains(tapX, tapY)) return false
|
|
|
|
val mask = bubble.maskBitmap ?: return true
|
|
if (!bubble.bounds.contains(tapX, tapY)) return true
|
|
|
|
val normalizedX = ((tapX - bubble.bounds.left) / bubble.bounds.width()).coerceIn(0f, 0.999f)
|
|
val normalizedY = ((tapY - bubble.bounds.top) / bubble.bounds.height()).coerceIn(0f, 0.999f)
|
|
val maskX = (normalizedX * mask.width).toInt().coerceIn(0, mask.width - 1)
|
|
val maskY = (normalizedY * mask.height).toInt().coerceIn(0, mask.height - 1)
|
|
return AndroidColor.alpha(mask.getPixel(maskX, maskY)) > 24
|
|
}
|
|
|
|
private suspend fun renderExpandedBubbleBitmap(
|
|
document: ReaderDocument,
|
|
pageIndex: Int,
|
|
bubbleBounds: RectF,
|
|
pageWidth: Int,
|
|
pageHeight: Int,
|
|
renderScale: Float
|
|
): Bitmap? = withContext(Dispatchers.IO) {
|
|
if (pageWidth <= 0 || pageHeight <= 0 || bubbleBounds.width() <= 0f || bubbleBounds.height() <= 0f) {
|
|
return@withContext null
|
|
}
|
|
|
|
document.openPage(pageIndex)?.use { page ->
|
|
val safeRenderScale = safePdfBitmapRenderScale(
|
|
contentWidth = bubbleBounds.width(),
|
|
contentHeight = bubbleBounds.height(),
|
|
requestedScale = renderScale
|
|
)
|
|
val cropWidth = (bubbleBounds.width() * safeRenderScale).roundToInt().coerceAtLeast(1)
|
|
val cropHeight = (bubbleBounds.height() * safeRenderScale).roundToInt().coerceAtLeast(1)
|
|
val bitmap = createBitmap(cropWidth, cropHeight)
|
|
|
|
try {
|
|
page.renderPageBitmap(
|
|
bitmap = bitmap,
|
|
startX = (-bubbleBounds.left * safeRenderScale).roundToInt(),
|
|
startY = (-bubbleBounds.top * safeRenderScale).roundToInt(),
|
|
drawSizeX = (pageWidth * safeRenderScale).roundToInt().coerceAtLeast(cropWidth),
|
|
drawSizeY = (pageHeight * safeRenderScale).roundToInt().coerceAtLeast(cropHeight),
|
|
renderAnnot = true
|
|
)
|
|
bitmap
|
|
} catch (t: Throwable) {
|
|
bitmap.recycle()
|
|
Timber.tag("BubbleZoom").w(t, "Failed to render expanded bubble bitmap for page $pageIndex")
|
|
null
|
|
}
|
|
}
|
|
}
|
|
|
|
private fun safePdfBitmapRenderScale(
|
|
contentWidth: Float,
|
|
contentHeight: Float,
|
|
requestedScale: Float
|
|
): Float {
|
|
if (contentWidth <= 0f || contentHeight <= 0f || requestedScale <= 0f) return 1f
|
|
|
|
val requestedWidth = contentWidth * requestedScale
|
|
val requestedHeight = contentHeight * requestedScale
|
|
val requestedBytes = requestedWidth.toDouble() * requestedHeight.toDouble() * 4.0
|
|
val byteScale = sqrt(PDF_MAX_DRAW_BITMAP_BYTES.toDouble() / requestedBytes.coerceAtLeast(1.0))
|
|
val dimensionScale = PDF_MAX_DRAW_BITMAP_DIMENSION_PX.toDouble() /
|
|
max(requestedWidth, requestedHeight).toDouble().coerceAtLeast(1.0)
|
|
val limiter = min(1.0, min(byteScale, dimensionScale)).coerceAtLeast(0.01)
|
|
return (requestedScale.toDouble() * limiter).coerceAtLeast(0.01).toFloat()
|
|
}
|
|
|
|
object PdfInkGeometry {
|
|
fun calculateFountainPenPoints(
|
|
points: List<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) {
|
|
// Overflow bitmaps are left for GC; HWUI may still reference recently drawn bitmaps.
|
|
if (!bitmap.isRecycled && pool.size < MAX_POOL_SIZE) {
|
|
pool.offer(bitmap)
|
|
}
|
|
}
|
|
|
|
fun clear() {
|
|
while (!pool.isEmpty()) {
|
|
pool.poll()
|
|
}
|
|
}
|
|
}
|
|
|
|
internal object PdfThumbnailCache {
|
|
private val maxMemory = (Runtime.getRuntime().maxMemory() / 1024).toInt()
|
|
private val cacheSize = maxMemory / 8
|
|
|
|
private data class CacheEntry(val bitmap: Bitmap, val sizeKb: Int)
|
|
|
|
private val memoryCache = object : LruCache<String, CacheEntry>(cacheSize) {
|
|
override fun sizeOf(key: String, entry: CacheEntry): Int {
|
|
return entry.sizeKb
|
|
}
|
|
}
|
|
|
|
fun get(pageId: String): Bitmap? {
|
|
return memoryCache.get(pageId)?.bitmap?.takeUnless { it.isRecycled }
|
|
}
|
|
|
|
fun put(pageId: String, bitmap: Bitmap) {
|
|
if (get(pageId) == null) {
|
|
val sizeKb = (bitmap.allocationByteCount / 1024).coerceAtLeast(1)
|
|
memoryCache.put(pageId, CacheEntry(bitmap, sizeKb))
|
|
}
|
|
}
|
|
|
|
fun clear() {
|
|
memoryCache.evictAll()
|
|
}
|
|
}
|
|
|
|
@Stable
|
|
data class StableHolder<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>,
|
|
documentKey: String,
|
|
pageIndex: Int,
|
|
totalPages: Int,
|
|
modifier: Modifier = Modifier,
|
|
virtualPage: VirtualPage? = null,
|
|
onScaleChanged: (Float) -> Unit,
|
|
externalScale: Float = 1f,
|
|
showAllTextHighlights: Boolean,
|
|
onHighlightLoading: (Boolean) -> Unit,
|
|
searchQuery: String = "",
|
|
searchHighlightMode: SearchHighlightMode = SearchHighlightMode.ALL,
|
|
searchResultToHighlight: SearchResult?,
|
|
ocrHoverHighlights: StableHolder<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,
|
|
showPageNumberOverlay: Boolean = true,
|
|
visualScaleProvider: () -> Float = { 1f },
|
|
clearSelectionTrigger: Long = 0L,
|
|
resetZoomTrigger: Long = 0L,
|
|
onTtsHighlightCenterCalculated: ((Float) -> Unit)? = null,
|
|
onSearchHighlightCenterCalculated: ((Float) -> Unit)? = null,
|
|
activeTheme: com.aryan.reader.ReaderTheme = com.aryan.reader.ReaderTheme("no_theme", "No Theme", Color.Unspecified, Color.Unspecified, false),
|
|
activeTextureAlpha: Float = 0.55f,
|
|
excludeImages: Boolean = false,
|
|
onDoubleTap: ((Offset) -> Unit)? = null,
|
|
isEditMode: Boolean = false,
|
|
drawingState: PdfDrawingState? = null,
|
|
pageAnnotations: () -> List<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,
|
|
stylusButtonHovering: 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,
|
|
eraserToolThickness: 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
|
|
val targetPageId = remember(documentKey, virtualPage, pageIndex) {
|
|
pdfRenderPageId(documentKey, pageIndex, virtualPage)
|
|
}
|
|
var bitmapState by remember(targetPageId) { mutableStateOf(PdfThumbnailCache.get(targetPageId)) }
|
|
var currentRenderedPageId by remember(targetPageId) { mutableStateOf<String?>(null) }
|
|
var isLoadingPage by remember(targetPageId) { mutableStateOf(true) }
|
|
var pageErrorMessage by remember(targetPageId) { mutableStateOf<String?>(null) }
|
|
val density = LocalDensity.current
|
|
val context = LocalContext.current
|
|
val viewConfiguration = LocalViewConfiguration.current
|
|
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) }
|
|
val initialCamera = initialPdfPageCamera(
|
|
isZoomEnabled = isZoomEnabled,
|
|
isVerticalScroll = isVerticalScroll,
|
|
isScrollLocked = isScrollLocked,
|
|
lockedState = lockedState
|
|
)
|
|
var scale by remember(targetPageId) { mutableFloatStateOf(initialCamera.first) }
|
|
var offset by remember(targetPageId) { mutableStateOf(initialCamera.second) }
|
|
var paginationPanFlingJob by remember { mutableStateOf<Job?>(null) }
|
|
var hasAppliedLockedPaginationState by remember(targetPageId) {
|
|
mutableStateOf(initialCamera.second != Offset.Zero || initialCamera.first != 1f)
|
|
}
|
|
val shouldReportCamera = shouldReportPdfPageCamera(
|
|
isZoomEnabled = isZoomEnabled,
|
|
isVerticalScroll = isVerticalScroll,
|
|
isScrollLocked = isScrollLocked,
|
|
lockedState = lockedState,
|
|
hasAppliedLockedState = hasAppliedLockedPaginationState
|
|
)
|
|
|
|
LaunchedEffect(scale, offset, shouldReportCamera) {
|
|
if (shouldReportCamera) {
|
|
onZoomAndPanChanged?.invoke(scale, offset)
|
|
}
|
|
}
|
|
|
|
val currentOnSingleTap by rememberUpdatedState(onSingleTap)
|
|
val currentOnPreSingleTap by rememberUpdatedState(onPreSingleTap)
|
|
val currentOnDoubleTap by rememberUpdatedState(onDoubleTap)
|
|
|
|
val effectiveScale = if (isZoomEnabled && !isVerticalScroll) scale else externalScale
|
|
val effectiveOffset = if (isZoomEnabled && !isVerticalScroll) offset else Offset.Zero
|
|
|
|
var eraserPosition by remember { mutableStateOf<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(targetPageId) { mutableStateOf<List<PdfTile>>(emptyList()) }
|
|
val tileSizeDp = PDF_TILE_SIZE_DP.dp
|
|
val tileSizePx = with(LocalDensity.current) { tileSizeDp.toPx().toInt() }
|
|
val latestEffectiveScale by rememberUpdatedState(effectiveScale)
|
|
val latestEffectiveOffset by rememberUpdatedState(effectiveOffset)
|
|
|
|
SideEffect {
|
|
Timber.tag("PdfDrawPerf")
|
|
.v("PAGE RECOMPOSE: Page $pageIndex (EffectiveScale: $effectiveScale)")
|
|
}
|
|
|
|
val teardropWidthDp = 24.dp
|
|
val teardropHeightDp = 24.dp
|
|
val teardropWidthPxState = remember(density) {
|
|
derivedStateOf { with(density) { teardropWidthDp.toPx() / visualScaleProvider() } }
|
|
}
|
|
val teardropHeightPxState = remember(density) {
|
|
derivedStateOf { with(density) { teardropHeightDp.toPx() / visualScaleProvider() } }
|
|
}
|
|
|
|
val handleTouchExpansionDp = 8.dp
|
|
val handleTouchWidthPxState = remember(density) {
|
|
derivedStateOf {
|
|
with(density) {
|
|
(teardropWidthDp + handleTouchExpansionDp).toPx() / visualScaleProvider()
|
|
}
|
|
}
|
|
}
|
|
val handleTouchHeightPxState = remember(density) {
|
|
derivedStateOf {
|
|
with(density) {
|
|
(teardropHeightDp + handleTouchExpansionDp).toPx() / visualScaleProvider()
|
|
}
|
|
}
|
|
}
|
|
|
|
val selectionCharRange = remember(targetPageId) { mutableStateOf<Pair<Int, Int>?>(null) }
|
|
var activeDraggingHandle by remember(targetPageId) { mutableStateOf<Handle?>(null) }
|
|
var selectedWordScreenRects by remember(targetPageId) { mutableStateOf<List<Rect>>(emptyList()) }
|
|
val startHandleContentPosition = remember(targetPageId) { mutableStateOf<Offset?>(null) }
|
|
val endHandleContentPosition = remember(targetPageId) { mutableStateOf<Offset?>(null) }
|
|
|
|
var actualBitmapWidthPx by remember(targetPageId) { mutableIntStateOf(0) }
|
|
var actualBitmapHeightPx by remember(targetPageId) { mutableIntStateOf(0) }
|
|
var currentPageRotation by remember(targetPageId) { mutableIntStateOf(0) }
|
|
|
|
val needsTilingNow = (effectiveScale > 1f || actualBitmapWidthPx > 3000 || actualBitmapHeightPx > 3000) && (isVerticalScroll || isActivePage)
|
|
|
|
val canvasWidthPx = remember { mutableFloatStateOf(0f) }
|
|
val canvasHeightPx = remember { mutableFloatStateOf(0f) }
|
|
|
|
val isDarkMode = activeTheme.isDark || activeTheme.id == "reverse"
|
|
|
|
val colorFilter = remember(activeTheme) {
|
|
when (activeTheme.id) {
|
|
"no_theme", "system" -> null
|
|
"reverse" -> {
|
|
val colorMatrix = floatArrayOf(
|
|
-1f, 0f, 0f, 0f, 255f,
|
|
0f, -1f, 0f, 0f, 255f,
|
|
0f, 0f, -1f, 0f, 255f,
|
|
0f, 0f, 0f, 1f, 0f
|
|
)
|
|
ColorFilter.colorMatrix(ColorMatrix(colorMatrix))
|
|
}
|
|
else -> {
|
|
val bgR = activeTheme.backgroundColor.red * 255f
|
|
val bgG = activeTheme.backgroundColor.green * 255f
|
|
val bgB = activeTheme.backgroundColor.blue * 255f
|
|
|
|
val fgR = activeTheme.textColor.red * 255f
|
|
val fgG = activeTheme.textColor.green * 255f
|
|
val fgB = activeTheme.textColor.blue * 255f
|
|
|
|
val dr = (bgR - fgR) / 255f
|
|
val dg = (bgG - fgG) / 255f
|
|
val db = (bgB - fgB) / 255f
|
|
|
|
val lumR = 0.2126f
|
|
val lumG = 0.7152f
|
|
val lumB = 0.0722f
|
|
|
|
val colorMatrix = floatArrayOf(
|
|
dr * lumR, dr * lumG, dr * lumB, 0f, fgR,
|
|
dg * lumR, dg * lumG, dg * lumB, 0f, fgG,
|
|
db * lumR, db * lumG, db * lumB, 0f, fgB,
|
|
0f, 0f, 0f, 1f, 0f
|
|
)
|
|
ColorFilter.colorMatrix(ColorMatrix(colorMatrix))
|
|
}
|
|
}
|
|
}
|
|
|
|
val backgroundColor = remember(activeTheme, isVerticalScroll) {
|
|
if (activeTheme.id == "no_theme" || activeTheme.id == "system") {
|
|
if (isVerticalScroll) Color.White else Color.Black
|
|
} else if (activeTheme.id == "reverse") {
|
|
if (isVerticalScroll) Color.Black else Color.White
|
|
} else {
|
|
activeTheme.backgroundColor
|
|
}
|
|
}
|
|
val textureBitmap = remember(activeTheme.textureId) {
|
|
loadReaderTextureBitmap(context, activeTheme.textureId)
|
|
}
|
|
val effectiveTextureAlpha = remember(activeTheme.textureId, activeTextureAlpha) {
|
|
if (activeTheme.textureId == null) 0f else activeTextureAlpha.coerceIn(0f, 1f)
|
|
}
|
|
val textureBlendMode = remember(activeTheme.textureId, activeTheme.isDark, activeTheme.id) {
|
|
if (activeTheme.isDark || activeTheme.id == "reverse") BlendMode.Screen else BlendMode.Multiply
|
|
}
|
|
|
|
val centeringOffsetX by remember(canvasWidthPx.floatValue, actualBitmapWidthPx) {
|
|
derivedStateOf { (canvasWidthPx.floatValue - actualBitmapWidthPx) / 2f }
|
|
}
|
|
val centeringOffsetY by remember(canvasHeightPx.floatValue, actualBitmapHeightPx) {
|
|
derivedStateOf { (canvasHeightPx.floatValue - actualBitmapHeightPx) / 2f }
|
|
}
|
|
|
|
LaunchedEffect(centeringOffsetX, centeringOffsetY, pageIndex) {
|
|
|
|
}
|
|
|
|
var showMagnifier by remember { mutableStateOf(false) }
|
|
var magnifierBitmapCenterTarget by remember { mutableStateOf(Offset.Zero) }
|
|
val magnifierZoomFactor = 2.0f
|
|
|
|
var customMenuState by remember(targetPageId) { mutableStateOf<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 (
|
|
shouldResetPdfZoomAfterBubbleZoomCleanup(
|
|
isBubbleZoomModeActive = isBubbleZoomModeActive,
|
|
scale = scale,
|
|
isVerticalScroll = isVerticalScroll,
|
|
isZoomEnabled = isZoomEnabled,
|
|
isScrollLocked = isScrollLocked
|
|
)
|
|
) {
|
|
coroutineScope.launch {
|
|
Animatable(scale).animateTo(1f, tween(300)) {
|
|
scale = this.value
|
|
offset = Offset.Zero
|
|
onScaleChanged(scale)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
LaunchedEffect(
|
|
animatingBubbleIndex,
|
|
detectedBubbles,
|
|
actualBitmapWidthPx,
|
|
actualBitmapHeightPx,
|
|
canvasWidthPx.floatValue,
|
|
canvasHeightPx.floatValue,
|
|
isBubbleZoomModeActive,
|
|
isPdfPage,
|
|
pdfPageIndex
|
|
) {
|
|
val previousRender = expandedBubbleRender
|
|
expandedBubbleRender = null
|
|
previousRender?.bitmap?.takeUnless { it.isRecycled }?.recycle()
|
|
|
|
if (!isBubbleZoomModeActive || !isPdfPage || animatingBubbleIndex !in detectedBubbles.indices) {
|
|
return@LaunchedEffect
|
|
}
|
|
|
|
val bubble = detectedBubbles[animatingBubbleIndex]
|
|
val zoomFactor = computeDynamicBubbleZoomFactor(
|
|
bubbleBounds = bubble.bounds,
|
|
viewportWidth = canvasWidthPx.floatValue.coerceAtLeast(actualBitmapWidthPx.toFloat()),
|
|
viewportHeight = canvasHeightPx.floatValue.coerceAtLeast(actualBitmapHeightPx.toFloat())
|
|
)
|
|
val renderScale = (zoomFactor * 1.2f).coerceAtLeast(1.6f)
|
|
val renderedBubble = renderExpandedBubbleBitmap(
|
|
document = pdfDocumentItem,
|
|
pageIndex = pdfPageIndex,
|
|
bubbleBounds = bubble.bounds,
|
|
pageWidth = actualBitmapWidthPx,
|
|
pageHeight = actualBitmapHeightPx,
|
|
renderScale = renderScale
|
|
)
|
|
|
|
if (renderedBubble != null) {
|
|
expandedBubbleRender = ExpandedBubbleRender(
|
|
bitmap = renderedBubble,
|
|
zoomFactor = zoomFactor
|
|
)
|
|
}
|
|
}
|
|
|
|
DisposableEffect(targetPageId) {
|
|
onDispose {
|
|
val currentBitmap = bitmapState
|
|
val cachedBitmap = PdfThumbnailCache.get(targetPageId)
|
|
if (currentBitmap != null && !currentBitmap.isRecycled && currentBitmap !== cachedBitmap) {
|
|
currentBitmap.recycle()
|
|
}
|
|
expandedBubbleRender?.bitmap?.takeUnless { it.isRecycled }?.recycle()
|
|
}
|
|
}
|
|
|
|
@Suppress("DEPRECATION") val clipboardManager = LocalClipboardManager.current
|
|
|
|
// OCR
|
|
var ocrVisionTextForSelection by remember(targetPageId) { mutableStateOf<OcrResult?>(null) }
|
|
var isPerformingOcrForSelection by remember { mutableStateOf(false) }
|
|
var selectionMethodUsed by remember { mutableStateOf(PdfSelectionMethod.PDFIUM) }
|
|
var ocrSelectionSymbolIndices by remember(targetPageId) { mutableStateOf<Pair<Int, Int>?>(null) }
|
|
var allOcrSymbolsForSelection by remember(targetPageId) { mutableStateOf<List<OcrSymbolInfo>>(emptyList()) }
|
|
|
|
var highlightedTextScreenRects by remember(targetPageId) { mutableStateOf<List<Rect>>(emptyList()) }
|
|
val ttsHighlightColor = Color(0xFFFFECB3).copy(alpha = 0.4f)
|
|
|
|
var allTextPageHighlightRects by remember(targetPageId) { 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(targetPageId) { 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(targetPageId) { mutableStateOf<List<EmbeddedAnnotation>>(emptyList()) }
|
|
var standardAnnotScreenRects by remember(targetPageId) { mutableStateOf<List<Pair<EmbeddedAnnotation, Rect>>>(emptyList()) }
|
|
var imageScreenRects by remember(targetPageId) { mutableStateOf<List<android.graphics.Rect>>(emptyList()) }
|
|
|
|
LaunchedEffect(pageIndex, pdfDocumentItem, actualBitmapWidthPx, actualBitmapHeightPx, virtualPage) {
|
|
if (!isPdfPage || actualBitmapWidthPx == 0 || actualBitmapHeightPx == 0) {
|
|
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 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(targetPageId)) { }
|
|
}
|
|
}
|
|
}
|
|
|
|
SideEffect {
|
|
if (effectiveScale > 1f) {
|
|
Timber.tag("PdfZoomDiagnostics").v(
|
|
"""
|
|
Page $pageIndex Stats:
|
|
- Internal Scale: $scale | Effective Scale: $effectiveScale
|
|
- Offset: $offset
|
|
- Bitmap Dims: ${actualBitmapWidthPx}x${actualBitmapHeightPx}
|
|
- Canvas Dims: ${canvasWidthPx.floatValue}x${canvasHeightPx.floatValue}
|
|
- Centering: X=$centeringOffsetX, Y=$centeringOffsetY
|
|
""".trimIndent()
|
|
)
|
|
}
|
|
}
|
|
|
|
LaunchedEffect(
|
|
needsTilingNow,
|
|
actualBitmapWidthPx,
|
|
actualBitmapHeightPx,
|
|
canvasWidthPx.floatValue,
|
|
canvasHeightPx.floatValue,
|
|
isVerticalScroll,
|
|
isScrolling,
|
|
isAutoScrollPlaying,
|
|
virtualPage,
|
|
isActivePage
|
|
) {
|
|
if (!needsTilingNow) {
|
|
if (tiles.isNotEmpty()) {
|
|
val oldTiles = tiles
|
|
tiles = emptyList()
|
|
withContext(Dispatchers.IO) {
|
|
oldTiles.forEach { PdfBitmapPool.recycle(it.bitmap) }
|
|
}
|
|
}
|
|
return@LaunchedEffect
|
|
}
|
|
|
|
val screenWidth = canvasWidthPx.floatValue
|
|
val screenHeight = canvasHeightPx.floatValue
|
|
|
|
if (actualBitmapWidthPx == 0 || actualBitmapHeightPx == 0 || screenWidth == 0f || screenHeight == 0f) return@LaunchedEffect
|
|
|
|
var page: ReaderPage? = null
|
|
|
|
if (!isPdfPage) {
|
|
if (tiles.isNotEmpty()) {
|
|
val oldTiles = tiles
|
|
tiles = emptyList()
|
|
withContext(Dispatchers.IO) {
|
|
oldTiles.forEach { PdfBitmapPool.recycle(it.bitmap) }
|
|
}
|
|
}
|
|
return@LaunchedEffect
|
|
}
|
|
|
|
try {
|
|
page = withContext(Dispatchers.IO) { pdfDocumentItem.openPage(pdfPageIndex) }
|
|
|
|
snapshotFlow {
|
|
val rect = visibleScreenRect()
|
|
val observedScale = latestEffectiveScale
|
|
if (isVerticalScroll && rect != null) {
|
|
val qTop = rect.top / (tileSizePx / 2)
|
|
val qLeft = rect.left / (tileSizePx / 2)
|
|
val qBottom = rect.bottom / (tileSizePx / 2)
|
|
val qRight = rect.right / (tileSizePx / 2)
|
|
listOf(qTop, qLeft, qBottom, qRight, (observedScale * 10f).roundToInt())
|
|
} else if (!isVerticalScroll) {
|
|
val observedOffset = latestEffectiveOffset
|
|
val pivotX = screenWidth / 2f
|
|
val pivotY = screenHeight / 2f
|
|
val pxTl = (((0 - observedOffset.x) - pivotX) / observedScale + pivotX) - centeringOffsetX
|
|
val pyTl = (((0 - observedOffset.y) - pivotY) / observedScale + pivotY) - centeringOffsetY
|
|
val pxBr = (((screenWidth - observedOffset.x) - pivotX) / observedScale + pivotX) - centeringOffsetX
|
|
val pyBr = (((screenHeight - observedOffset.y) - pivotY) / observedScale + pivotY) - centeringOffsetY
|
|
|
|
val qTop = pyTl.toInt() / (tileSizePx / 2)
|
|
val qLeft = pxTl.toInt() / (tileSizePx / 2)
|
|
val qBottom = pyBr.toInt() / (tileSizePx / 2)
|
|
val qRight = pxBr.toInt() / (tileSizePx / 2)
|
|
listOf(qTop, qLeft, qBottom, qRight, (observedScale * 10f).roundToInt())
|
|
} else {
|
|
null
|
|
}
|
|
}.conflate().collectLatest { _ ->
|
|
|
|
val tileCalcStart = System.nanoTime()
|
|
if (!isActive) return@collectLatest
|
|
|
|
val renderScale = latestEffectiveScale
|
|
val renderOffset = latestEffectiveOffset
|
|
|
|
val currentVisibleRect = visibleScreenRect()
|
|
|
|
val pxTl: Float
|
|
val pxBr: Float
|
|
val pyTl: Float
|
|
val pyBr: Float
|
|
|
|
if (isVerticalScroll) {
|
|
if (currentVisibleRect != null) {
|
|
pxTl = currentVisibleRect.left.toFloat()
|
|
pyTl = currentVisibleRect.top.toFloat()
|
|
pxBr = currentVisibleRect.right.toFloat()
|
|
pyBr = currentVisibleRect.bottom.toFloat()
|
|
} else {
|
|
if (tiles.isNotEmpty()) {
|
|
val oldTiles = tiles
|
|
tiles = emptyList()
|
|
withContext(Dispatchers.IO) {
|
|
oldTiles.forEach { PdfBitmapPool.recycle(it.bitmap) }
|
|
}
|
|
}
|
|
return@collectLatest
|
|
}
|
|
} else {
|
|
val pivotX = screenWidth / 2f
|
|
val pivotY = screenHeight / 2f
|
|
|
|
pxTl = (((0 - renderOffset.x) - pivotX) / renderScale + pivotX) - centeringOffsetX
|
|
pyTl = (((0 - renderOffset.y) - pivotY) / renderScale + pivotY) - centeringOffsetY
|
|
pxBr = (((screenWidth - renderOffset.x) - pivotX) / renderScale + pivotX) - centeringOffsetX
|
|
pyBr = (((screenHeight - renderOffset.y) - pivotY) / renderScale + pivotY) - centeringOffsetY
|
|
}
|
|
|
|
val visibleBitmapRect = Rect(pxTl.toInt(), pyTl.toInt(), pxBr.toInt(), pyBr.toInt())
|
|
val inset = if (renderScale > 2f) 0 else -tileSizePx
|
|
visibleBitmapRect.inset(inset, inset)
|
|
|
|
val requiredTileIds = mutableSetOf<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(targetPageId) { mutableStateOf<List<Rect>>(emptyList()) }
|
|
var searchAllRects by remember(targetPageId) { mutableStateOf<List<Rect>>(emptyList()) }
|
|
|
|
var keyboardAdjustmentOriginalOffset by remember(targetPageId) { 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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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 = context.getString(
|
|
R.string.error_ocr_selection,
|
|
e.readablePdfErrorDetail()
|
|
)
|
|
} finally {
|
|
isPerformingOcrForSelection = false
|
|
ocrRipplePosition = null
|
|
}
|
|
} else if (!pdfiumSelectionSuccessful) {
|
|
Timber.d(
|
|
"Long press: PDFIUM selection failed and no bitmap for OCR."
|
|
)
|
|
}
|
|
|
|
if (!pdfiumSelectionSuccessful && !ocrAttemptedForThisPress) {
|
|
customMenuState = null
|
|
selectionCharRange.value = null
|
|
selectedWordScreenRects = emptyList()
|
|
}
|
|
} catch (e: Exception) {
|
|
Timber.e(
|
|
e,
|
|
"Error during long press text selection on page $pageIndex"
|
|
)
|
|
pageErrorMessage = context.getString(
|
|
R.string.error_selection,
|
|
e.readablePdfErrorDetail()
|
|
)
|
|
customMenuState = null
|
|
selectionCharRange.value = null
|
|
selectedWordScreenRects = emptyList()
|
|
} finally {
|
|
withContext(NonCancellable) {
|
|
withContext(Dispatchers.IO) {
|
|
try {
|
|
tempTextPage?.close()
|
|
} catch (_: Exception) {
|
|
}
|
|
try {
|
|
tempPage?.close()
|
|
} catch (_: Exception) {
|
|
}
|
|
}
|
|
}
|
|
if (isPerformingOcrForSelection && !ocrAttemptedForThisPress) isPerformingOcrForSelection =
|
|
false
|
|
}
|
|
}
|
|
val longPressInteractionEndEvent = waitForUpOrCancellation()
|
|
longPressInteractionEndEvent?.consume()
|
|
Timber.d(
|
|
"PointerInput: Long press interaction's 'up' event consumed."
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
.pointerInput(
|
|
actualBitmapWidthPx,
|
|
actualBitmapHeightPx,
|
|
scale,
|
|
offset,
|
|
customMenuState,
|
|
selectionCharRange.value,
|
|
pageLinks,
|
|
centeringOffsetX,
|
|
centeringOffsetY,
|
|
isZoomEnabled,
|
|
isVerticalScroll,
|
|
isEditMode,
|
|
selectedTool,
|
|
isStylusOnlyMode,
|
|
userHighlightScreenRects,
|
|
bubbleTapSlopPx,
|
|
isScrollLocked
|
|
) {
|
|
val isTapDetectionAllowed = !isEditMode ||
|
|
selectedTool == InkType.TEXT ||
|
|
isStylusOnlyMode
|
|
|
|
if (!isTapDetectionAllowed) return@pointerInput
|
|
|
|
detectTapGestures(onTap = { tapOffset ->
|
|
if (currentOnPreSingleTap?.invoke(tapOffset) == true) {
|
|
return@detectTapGestures
|
|
}
|
|
|
|
val tapInContentCoords = screenToContentCoordinates(tapOffset)
|
|
val tapXInBitmap = tapInContentCoords.x
|
|
val tapYInBitmap = tapInContentCoords.y
|
|
val isWithinContentBounds =
|
|
tapXInBitmap in 0f..actualBitmapWidthPx.toFloat() &&
|
|
tapYInBitmap in 0f..actualBitmapHeightPx.toFloat()
|
|
|
|
if (!isWithinContentBounds) {
|
|
currentOnSingleTap(tapOffset)
|
|
return@detectTapGestures
|
|
}
|
|
|
|
Timber.tag("BubbleZoom").d("Tap inside bounds. modeActive=$currentBubbleZoomModeActive, detectedBubbles=${currentDetectedBubbles.size}, tapPos=($tapXInBitmap, $tapYInBitmap)")
|
|
|
|
if (currentBubbleZoomModeActive && currentDetectedBubbles.isNotEmpty()) {
|
|
val tappedBubbleIndex = currentDetectedBubbles.indexOfFirst { bubble ->
|
|
isTapInsideBubble(
|
|
bubble = bubble,
|
|
tapX = tapXInBitmap,
|
|
tapY = tapYInBitmap,
|
|
hitSlopPx = bubbleTapSlopPx
|
|
)
|
|
}
|
|
|
|
Timber.tag("BubbleZoom").d("Tapped bubble index: $tappedBubbleIndex (expandedIndex=$currentExpandedBubbleIndex)")
|
|
|
|
if (tappedBubbleIndex != -1) {
|
|
expandedBubbleIndex = if (currentExpandedBubbleIndex == tappedBubbleIndex) {
|
|
-1
|
|
} else {
|
|
tappedBubbleIndex
|
|
}
|
|
return@detectTapGestures
|
|
} else if (currentExpandedBubbleIndex != -1) {
|
|
expandedBubbleIndex = -1
|
|
return@detectTapGestures
|
|
}
|
|
}
|
|
|
|
coroutineScope.launch {
|
|
val nativeResult = withContext(Dispatchers.IO) {
|
|
try {
|
|
pdfDocumentItem.openPage(pdfPageIndex)?.use { page ->
|
|
val pagePtr = page.getNativePointer()
|
|
|
|
if (pagePtr == 0L) {
|
|
Timber.tag("PdfInteraction").e("Could not find native pointer for page $pdfPageIndex")
|
|
return@withContext 0
|
|
}
|
|
|
|
val pdfCoords = page.mapDeviceCoordsToPage(
|
|
0, 0, actualBitmapWidthPx, actualBitmapHeightPx,
|
|
currentPageRotation, tapInContentCoords.x.toInt(), tapInContentCoords.y.toInt()
|
|
)
|
|
|
|
val docPtr = try {
|
|
val pdfDocKt = (pdfDocumentItem as? PdfDocumentWrapper)?.pdfDocument
|
|
if (pdfDocKt != null) {
|
|
val documentField = pdfDocKt.javaClass.getDeclaredField("document").apply { isAccessible = true }
|
|
val docUInstance = documentField.get(pdfDocKt)
|
|
if (docUInstance != null) {
|
|
val ptrField = docUInstance.javaClass.getDeclaredField("mNativeDocPtr").apply { isAccessible = true }
|
|
ptrField.get(docUInstance) as Long
|
|
} else 0L
|
|
} else 0L
|
|
} catch (e: Exception) { 0L }
|
|
|
|
Timber.tag("PdfLinkDiagnostic").i("Extracted docPtr: $docPtr | pagePtr: $pagePtr")
|
|
|
|
val linkInfo = PdfiumEngineProvider.bridge.getLinkInfoAtPoint(
|
|
docPtr, pagePtr, pdfCoords.x.toDouble(), pdfCoords.y.toDouble()
|
|
)
|
|
|
|
if (linkInfo != null) {
|
|
Timber.tag("PdfLinkDiagnostic").i(">>> Native Link Info Extracted: $linkInfo")
|
|
if (linkInfo.startsWith("URI:")) {
|
|
val url = linkInfo.substringAfter("URI:")
|
|
withContext(Dispatchers.Main) { onLinkClicked(url) }
|
|
return@withContext 1
|
|
} else if (linkInfo.startsWith("PAGE:")) {
|
|
val targetPage = linkInfo.substringAfter("PAGE:").toIntOrNull()
|
|
if (targetPage != null && targetPage >= 0) {
|
|
withContext(Dispatchers.Main) { onInternalLinkClicked(targetPage) }
|
|
return@withContext 1
|
|
}
|
|
}
|
|
}
|
|
|
|
val clickHandled = PdfiumEngineProvider.bridge.performClick(pagePtr, pdfCoords.x.toDouble(), pdfCoords.y.toDouble())
|
|
if (clickHandled) {
|
|
return@withContext 2
|
|
}
|
|
return@withContext 0
|
|
} ?: 0
|
|
} catch (e: Exception) {
|
|
Timber.tag("PdfInteraction").e(e, "Interaction error")
|
|
0
|
|
}
|
|
}
|
|
|
|
if (nativeResult == 2) {
|
|
Timber.tag("PdfInteraction").i("Action detected. Refreshing page.")
|
|
tiles = emptyList()
|
|
bitmapState = null
|
|
isLoadingPage = true
|
|
currentRenderedPageId = "ACTION_${System.currentTimeMillis()}"
|
|
return@launch
|
|
} else if (nativeResult == 1) {
|
|
return@launch
|
|
}
|
|
|
|
val annotHitTolerance = with(density) { 24.dp.toPx() } / inputScale
|
|
val hitTolerance = with(density) { 16.dp.toPx() } / inputScale
|
|
|
|
Timber.d("detectTapGestures: Tap at bitmap coords (${tapXInBitmap.toInt()}, ${tapYInBitmap.toInt()})")
|
|
|
|
var tappedRect: Rect? = null
|
|
val hitHighlightPair = userHighlightScreenRects.findLast { pair ->
|
|
val hit = pair.second.find { r ->
|
|
val hitLeft = r.left - hitTolerance
|
|
val hitTop = r.top - hitTolerance
|
|
val hitRight = r.right + hitTolerance
|
|
val hitBottom = r.bottom + hitTolerance
|
|
|
|
tapXInBitmap in hitLeft..hitRight && tapYInBitmap >= hitTop && tapYInBitmap <= hitBottom
|
|
}
|
|
if (hit != null) {
|
|
tappedRect = hit
|
|
true
|
|
} else false
|
|
}
|
|
|
|
val standardHit = standardAnnotScreenRects.findLast { (annot, screenRect) ->
|
|
if (annot.subtype == 2) return@findLast false
|
|
|
|
val left = min(screenRect.left, screenRect.right)
|
|
val right = max(screenRect.left, screenRect.right)
|
|
val top = min(screenRect.top, screenRect.bottom)
|
|
val bottom = max(screenRect.top, screenRect.bottom)
|
|
|
|
val inflatedHitBox = Rect(
|
|
(left - annotHitTolerance).toInt(),
|
|
(top - annotHitTolerance).toInt(),
|
|
(right + annotHitTolerance).toInt(),
|
|
(bottom + annotHitTolerance).toInt()
|
|
)
|
|
|
|
inflatedHitBox.contains(tapInContentCoords.x.toInt(), tapInContentCoords.y.toInt())
|
|
}
|
|
|
|
if (standardHit != null) {
|
|
val (annot, screenRect) = standardHit
|
|
customMenuState = CustomPdfMenuState(
|
|
selectedText = annot.contents ?: "No comment",
|
|
anchorRect = screenRect,
|
|
charRange = Pair(-1, -1),
|
|
isComment = true,
|
|
author = annot.author,
|
|
annotation = annot
|
|
)
|
|
return@launch
|
|
}
|
|
|
|
if (hitHighlightPair != null && tappedRect != null) {
|
|
val hitHighlight = hitHighlightPair.first
|
|
onNoteRequested(hitHighlight.id)
|
|
return@launch
|
|
}
|
|
|
|
val clickedLink = pageLinks.firstOrNull { link ->
|
|
link.tapBounds.contains(tapXInBitmap.toInt(), tapYInBitmap.toInt())
|
|
}
|
|
|
|
if (clickedLink != null) {
|
|
Timber.d("PdfPageComposable: Fallback pageLinks intercepted click.")
|
|
if (clickedLink.destPageIdx != null && clickedLink.destPageIdx >= 0) {
|
|
onInternalLinkClicked(clickedLink.destPageIdx)
|
|
} else if (clickedLink.url != null) {
|
|
onLinkClicked(clickedLink.url)
|
|
}
|
|
return@launch
|
|
}
|
|
|
|
val wasMenuVisible = customMenuState != null
|
|
val wasSelectionVisible = selectionCharRange.value != null || ocrSelectionSymbolIndices != null
|
|
|
|
if (wasMenuVisible || wasSelectionVisible) {
|
|
customMenuState = null
|
|
selectionCharRange.value = null
|
|
ocrSelectionSymbolIndices = null
|
|
updateSelectionVisuals(
|
|
pdfDocumentItem,
|
|
pdfPageIndex,
|
|
null,
|
|
actualBitmapWidthPx,
|
|
actualBitmapHeightPx,
|
|
currentPageRotation,
|
|
)
|
|
} else {
|
|
currentOnSingleTap(tapOffset)
|
|
}
|
|
}
|
|
}, onDoubleTap = { tapOffset ->
|
|
if (isZoomEnabled && !isVerticalScroll && !isScrollLocked) {
|
|
if (actualBitmapWidthPx == 0) return@detectTapGestures
|
|
coroutineScope.launch {
|
|
val startScale = scale
|
|
val targetScale = if (startScale > 1.1f) 1f else 2.5f
|
|
|
|
val startOffset = offset
|
|
val targetOffsetUnbounded = if (targetScale <= 1.1f) {
|
|
Offset.Zero
|
|
} else {
|
|
val ratio = targetScale / startScale
|
|
val screenCenter = Offset(
|
|
size.width / 2f, size.height / 2f
|
|
)
|
|
startOffset * ratio + (tapOffset - screenCenter) * (1 - ratio)
|
|
}
|
|
|
|
val contentWidth = actualBitmapWidthPx * targetScale
|
|
val contentHeight = actualBitmapHeightPx * targetScale
|
|
val maxOffsetX = (contentWidth - size.width).coerceAtLeast(0f) / 2f
|
|
val maxOffsetY = (contentHeight - size.height).coerceAtLeast(0f) / 2f
|
|
|
|
val targetOffset = Offset(
|
|
x = targetOffsetUnbounded.x.coerceIn(
|
|
-maxOffsetX, maxOffsetX
|
|
), y = targetOffsetUnbounded.y.coerceIn(
|
|
-maxOffsetY, maxOffsetY
|
|
)
|
|
)
|
|
|
|
Animatable(0f).animateTo(
|
|
1f, animationSpec = tween(
|
|
durationMillis = 300
|
|
)
|
|
) {
|
|
val progress = value
|
|
scale = androidx.compose.ui.util.lerp(
|
|
startScale, targetScale, progress
|
|
)
|
|
offset = androidx.compose.ui.geometry.lerp(
|
|
startOffset, targetOffset, progress
|
|
)
|
|
onScaleChanged(scale)
|
|
}
|
|
if (scale <= 1.05f) {
|
|
scale = 1f
|
|
offset = Offset.Zero
|
|
onScaleChanged(scale)
|
|
}
|
|
}
|
|
} else if (isVerticalScroll && !isScrollLocked && currentOnDoubleTap != null) {
|
|
currentOnDoubleTap!!(tapOffset)
|
|
}
|
|
})
|
|
}
|
|
.pointerInput(
|
|
actualBitmapWidthPx,
|
|
actualBitmapHeightPx,
|
|
activeDraggingHandle,
|
|
isZoomEnabled,
|
|
isVerticalScroll,
|
|
isEditMode,
|
|
onTwoFingerSwipe,
|
|
isScrollLocked
|
|
) {
|
|
if (!isZoomEnabled || isVerticalScroll || actualBitmapWidthPx == 0 || activeDraggingHandle != null) return@pointerInput
|
|
|
|
val decay = splineBasedDecay<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 {
|
|
kotlinx.coroutines.coroutineScope {
|
|
launch {
|
|
if (flingX != 0f) {
|
|
Animatable(startOffset.x).animateDecay(flingX, decay) {
|
|
offset = offset.copy(
|
|
x = value.coerceIn(-maxOffsetX, maxOffsetX)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
launch {
|
|
if (flingY != 0f) {
|
|
Animatable(startOffset.y).animateDecay(flingY, decay) {
|
|
offset = offset.copy(
|
|
y = value.coerceIn(-maxOffsetY, maxOffsetY)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} finally {
|
|
paginationPanFlingJob = null
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.pointerInput(
|
|
isEditMode,
|
|
actualBitmapWidthPx,
|
|
actualBitmapHeightPx,
|
|
scale,
|
|
offset,
|
|
isScrolling,
|
|
isVerticalScroll,
|
|
selectedTool,
|
|
isStylusOnlyMode,
|
|
isHighlighterSnapEnabled
|
|
) {
|
|
val canDraw = isEditMode && selectedTool != InkType.TEXT && !isScrolling && !isVerticalScroll && actualBitmapWidthPx > 0 && actualBitmapHeightPx > 0
|
|
|
|
if (!canDraw) {
|
|
return@pointerInput
|
|
}
|
|
|
|
try {
|
|
awaitEachGesture {
|
|
val down = awaitFirstDown(requireUnconsumed = false)
|
|
|
|
Timber.tag("PointerTypeDebug").d("Page $pageIndex: Input Type detected: ${down.type}")
|
|
|
|
if (isStylusOnlyMode && down.type == PointerType.Touch) {
|
|
return@awaitEachGesture
|
|
}
|
|
|
|
val buttons = currentEvent.buttons
|
|
Timber.tag("StylusDebug").d(
|
|
"Page $pageIndex | Type: ${down.type} | isPrimary: ${buttons.isPrimaryPressed} | isSecondary: ${buttons.isSecondaryPressed} | isTertiary: ${buttons.isTertiaryPressed} | buttonsString: $buttons"
|
|
)
|
|
|
|
val isEraserOverride = down.type == PointerType.Eraser || (down.type == PointerType.Stylus && (currentEvent.buttons.isSecondaryPressed || currentEvent.buttons.isPrimaryPressed || stylusButtonHovering))
|
|
isStylusEraserOverride = isEraserOverride
|
|
|
|
val dragPointerId = down.id
|
|
val startPos = down.position
|
|
var dragStarted = false
|
|
val touchSlop = viewConfiguration.touchSlop
|
|
|
|
if (selectedTool == InkType.ERASER || isEraserOverride) {
|
|
eraserPosition = down.position
|
|
}
|
|
|
|
while (true) {
|
|
val event = awaitPointerEvent()
|
|
|
|
if (event.changes.size > 1) {
|
|
if (dragStarted) {
|
|
drawingState?.onDrawCancel()
|
|
}
|
|
eraserPosition = null
|
|
isStylusEraserOverride = false
|
|
return@awaitEachGesture
|
|
}
|
|
|
|
val change = event.changes.firstOrNull {
|
|
it.id == dragPointerId
|
|
}
|
|
if (change == null) return@awaitEachGesture
|
|
|
|
if (change.changedToUp()) {
|
|
change.consume()
|
|
if (!dragStarted) {
|
|
val contentPos = screenToContentCoordinates(startPos)
|
|
val normX =
|
|
(contentPos.x / actualBitmapWidthPx).coerceIn(0f, 1f)
|
|
val normY =
|
|
(contentPos.y / actualBitmapHeightPx).coerceIn(0f, 1f)
|
|
|
|
onDrawStart(PdfPoint(normX, normY), isEraserOverride)
|
|
onDrawEnd()
|
|
} else {
|
|
onDrawEnd()
|
|
}
|
|
eraserPosition = null
|
|
isStylusEraserOverride = false
|
|
return@awaitEachGesture
|
|
}
|
|
|
|
if (change.positionChanged()) {
|
|
val dist = (change.position - startPos).getDistance()
|
|
|
|
if (!dragStarted) {
|
|
if (dist > touchSlop) {
|
|
dragStarted = true
|
|
|
|
val startContentPos = screenToContentCoordinates(startPos)
|
|
val startNormX =
|
|
(startContentPos.x / actualBitmapWidthPx).coerceIn(
|
|
0f, 1f
|
|
)
|
|
val startNormY =
|
|
(startContentPos.y / actualBitmapHeightPx).coerceIn(
|
|
0f, 1f
|
|
)
|
|
onDrawStart(
|
|
PdfPoint(startNormX, startNormY), isEraserOverride
|
|
)
|
|
|
|
val currContentPos = screenToContentCoordinates(
|
|
change.position
|
|
)
|
|
val currNormX =
|
|
(currContentPos.x / actualBitmapWidthPx).coerceIn(
|
|
0f, 1f
|
|
)
|
|
val currNormY =
|
|
(currContentPos.y / actualBitmapHeightPx).coerceIn(
|
|
0f, 1f
|
|
)
|
|
onDraw(PdfPoint(currNormX, currNormY), isEraserOverride)
|
|
|
|
if (selectedTool == InkType.ERASER || isEraserOverride) {
|
|
eraserPosition = change.position
|
|
}
|
|
change.consume()
|
|
}
|
|
} else {
|
|
val currContentPos = screenToContentCoordinates(
|
|
change.position
|
|
)
|
|
val currNormX =
|
|
(currContentPos.x / actualBitmapWidthPx).coerceIn(0f, 1f)
|
|
val currNormY =
|
|
(currContentPos.y / actualBitmapHeightPx).coerceIn(0f, 1f)
|
|
onDraw(PdfPoint(currNormX, currNormY), isEraserOverride)
|
|
|
|
if (selectedTool == InkType.ERASER || isEraserOverride) {
|
|
eraserPosition = change.position
|
|
}
|
|
change.consume()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} finally {
|
|
eraserPosition = null
|
|
isStylusEraserOverride = false
|
|
}
|
|
}, contentAlignment = Alignment.Center
|
|
) {
|
|
SideEffect {
|
|
if (effectiveScale > 1f) {
|
|
Timber.tag("PdfZoomDiagnostics").d(
|
|
"BoxWithConstraints Page $pageIndex: MaxW=$maxWidth, MaxH=$maxHeight"
|
|
)
|
|
}
|
|
}
|
|
val imeInsets = WindowInsets.ime
|
|
val screenHeight = constraints.maxHeight.toFloat()
|
|
|
|
val imeBottom = imeInsets.getBottom(density)
|
|
|
|
LaunchedEffect(
|
|
richTextController?.cursorPageIndex,
|
|
richTextController?.cursorRectInPage,
|
|
imeBottom,
|
|
pageIndex,
|
|
screenHeight,
|
|
isEditMode,
|
|
selectedTool,
|
|
density
|
|
) {
|
|
val controller = richTextController ?: return@LaunchedEffect
|
|
|
|
Timber.tag("KeyboardAdjust").v(
|
|
"Check: Page=$pageIndex, IME=$imeBottom, EditMode=$isEditMode, OffsetY=${offset.y}"
|
|
)
|
|
|
|
if (imeBottom == 0 || !isEditMode || selectedTool != InkType.TEXT) {
|
|
if (keyboardAdjustmentOriginalOffset != null) {
|
|
Timber.tag("KeyboardAdjust")
|
|
.i("Restoring original offset: $keyboardAdjustmentOriginalOffset")
|
|
if (isZoomEnabled && !isVerticalScroll) {
|
|
offset = offset.copy(y = keyboardAdjustmentOriginalOffset!!)
|
|
}
|
|
keyboardAdjustmentOriginalOffset = null
|
|
}
|
|
return@LaunchedEffect
|
|
}
|
|
|
|
val cursorPage = controller.cursorPageIndex
|
|
val cursorRect = controller.cursorRectInPage
|
|
|
|
if (cursorPage == pageIndex && cursorRect != null) {
|
|
if (keyboardAdjustmentOriginalOffset == null) {
|
|
keyboardAdjustmentOriginalOffset = offset.y
|
|
Timber.tag("KeyboardAdjust").i("Snapshot original offset: ${offset.y}")
|
|
}
|
|
|
|
val baseOffsetY = keyboardAdjustmentOriginalOffset!!
|
|
|
|
val currentScale = if (isZoomEnabled && !isVerticalScroll) scale else externalScale
|
|
val currentOffset =
|
|
if (isZoomEnabled && !isVerticalScroll) offset.copy(y = baseOffsetY)
|
|
else Offset.Zero
|
|
|
|
val contentHeight = actualBitmapHeightPx * currentScale
|
|
val centeringY = (screenHeight - contentHeight) / 2f
|
|
|
|
val cursorTopInPage = cursorRect.top * currentScale
|
|
val cursorBottomInPage = cursorRect.bottom * currentScale
|
|
|
|
val cursorScreenTop = centeringY + currentOffset.y + cursorTopInPage
|
|
val cursorScreenBottom = centeringY + currentOffset.y + cursorBottomInPage
|
|
|
|
val topSafeBuffer = with(density) { 80.dp.toPx() }
|
|
|
|
val visibleBottom = screenHeight - imeBottom
|
|
|
|
var targetAdjustment = 0f
|
|
|
|
if (cursorScreenBottom > visibleBottom) {
|
|
targetAdjustment = visibleBottom - cursorScreenBottom
|
|
} else if (cursorScreenTop < topSafeBuffer) {
|
|
targetAdjustment = topSafeBuffer - cursorScreenTop
|
|
}
|
|
|
|
Timber.tag("KeyboardAdjust").v(
|
|
"CursorBottom=$cursorScreenBottom, VisibleBottom=$visibleBottom, TargetAdj=$targetAdjustment"
|
|
)
|
|
|
|
if (kotlin.math.abs(targetAdjustment) > 1f && isZoomEnabled && !isVerticalScroll) {
|
|
val finalTargetY = baseOffsetY + targetAdjustment
|
|
|
|
if (offset.y != finalTargetY) {
|
|
Timber.tag("KeyboardAdjust").i("Applying adjustment. NewY=$finalTargetY")
|
|
offset = offset.copy(y = finalTargetY)
|
|
}
|
|
} else if (kotlin.math.abs(targetAdjustment) <= 1f && offset.y != baseOffsetY) {
|
|
Timber.tag("KeyboardAdjust")
|
|
.i("Cursor visible, restoring base. NewY=$baseOffsetY")
|
|
offset = offset.copy(y = baseOffsetY)
|
|
}
|
|
}
|
|
}
|
|
|
|
var previousLockedViewportSize by remember { mutableStateOf<Pair<Dp, Dp>?>(null) }
|
|
|
|
LaunchedEffect(
|
|
pageIndex, this@BoxWithConstraints.maxWidth, this@BoxWithConstraints.maxHeight,
|
|
isScrollLocked, lockedState
|
|
) {
|
|
val currentViewportSize = this@BoxWithConstraints.maxWidth to this@BoxWithConstraints.maxHeight
|
|
val previousViewportSize = previousLockedViewportSize
|
|
val orientationChanged = previousViewportSize != null &&
|
|
(previousViewportSize.first > previousViewportSize.second) !=
|
|
(currentViewportSize.first > currentViewportSize.second)
|
|
previousLockedViewportSize = currentViewportSize
|
|
|
|
if (isScrollLocked && !isVerticalScroll) {
|
|
if (orientationChanged) {
|
|
scale = 1f
|
|
offset = Offset.Zero
|
|
hasAppliedLockedPaginationState = true
|
|
Timber.tag("PdfLockDiagnostic").i(
|
|
"Orientation changed while locked; reset paginated zoom to fit on page $pageIndex"
|
|
)
|
|
} else if (lockedState != null) {
|
|
scale = lockedState.first
|
|
offset = Offset(lockedState.second, lockedState.third)
|
|
hasAppliedLockedPaginationState = true
|
|
} else {
|
|
hasAppliedLockedPaginationState = true
|
|
}
|
|
onScaleChanged(scale)
|
|
} else if (!isScrollLocked && !isVerticalScroll) {
|
|
hasAppliedLockedPaginationState = false
|
|
scale = 1f
|
|
offset = Offset.Zero
|
|
onScaleChanged(1f)
|
|
}
|
|
}
|
|
|
|
LaunchedEffect(
|
|
pdfDocumentItem,
|
|
pageIndex,
|
|
ttsHighlightData,
|
|
actualBitmapWidthPx,
|
|
actualBitmapHeightPx,
|
|
currentPageRotation,
|
|
lazyListState,
|
|
isVerticalScroll,
|
|
virtualPage
|
|
) {
|
|
if (scale > 1f) {
|
|
if (highlightedTextScreenRects.isNotEmpty()) highlightedTextScreenRects =
|
|
emptyList()
|
|
return@LaunchedEffect
|
|
}
|
|
|
|
if (ttsHighlightData == null || actualBitmapWidthPx == 0) {
|
|
if (highlightedTextScreenRects.isNotEmpty()) {
|
|
Timber.d("Highlighting: Clearing highlights for page $pageIndex.")
|
|
highlightedTextScreenRects = emptyList()
|
|
}
|
|
return@LaunchedEffect
|
|
}
|
|
|
|
if (!isPdfPage) {
|
|
if (highlightedTextScreenRects.isNotEmpty()) highlightedTextScreenRects =
|
|
emptyList()
|
|
return@LaunchedEffect
|
|
}
|
|
|
|
try {
|
|
var rects: List<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,
|
|
targetPageId,
|
|
isVisible,
|
|
currentRenderedPageId
|
|
) {
|
|
if (!isVisible && !isVerticalScroll) return@LaunchedEffect
|
|
pageErrorMessage = null
|
|
|
|
val viewContainerWidthPx = with(density) { currentContainerMaxWidth.toPx().toInt() }
|
|
val viewContainerHeightPx =
|
|
with(density) { currentContainerMaxHeight.toPx().toInt() }
|
|
|
|
if (viewContainerWidthPx <= 0 || viewContainerHeightPx <= 0) {
|
|
if (bitmapState == null) isLoadingPage = true
|
|
return@LaunchedEffect
|
|
}
|
|
|
|
if (!isPdfPage) {
|
|
val blankPage = virtualPage as? VirtualPage.BlankPage
|
|
val pageAspect = if (blankPage != null && blankPage.width > 0 && blankPage.height > 0) {
|
|
blankPage.width.toFloat() / blankPage.height.toFloat()
|
|
} else {
|
|
1f / 1.414f
|
|
}
|
|
|
|
val (scaledWidth, scaledHeight) = if (isVerticalScroll) {
|
|
viewContainerWidthPx to viewContainerHeightPx
|
|
} else {
|
|
var fittedWidth = viewContainerWidthPx
|
|
var fittedHeight = (fittedWidth / pageAspect).toInt()
|
|
|
|
if (fittedHeight > viewContainerHeightPx) {
|
|
fittedHeight = viewContainerHeightPx
|
|
fittedWidth = (fittedHeight * pageAspect).toInt()
|
|
}
|
|
|
|
fittedWidth to fittedHeight
|
|
}
|
|
|
|
if (scaledWidth == actualBitmapWidthPx &&
|
|
scaledHeight == actualBitmapHeightPx &&
|
|
bitmapState != null &&
|
|
currentRenderedPageId == targetPageId
|
|
) {
|
|
isLoadingPage = false
|
|
return@LaunchedEffect
|
|
}
|
|
|
|
actualBitmapWidthPx = scaledWidth
|
|
actualBitmapHeightPx = scaledHeight
|
|
currentPageRotation = 0
|
|
|
|
val MAX_BASE_DIMEN = 3000
|
|
|
|
val baseRenderScale = 1.5f
|
|
|
|
var baseW = (scaledWidth * baseRenderScale).toInt()
|
|
var baseH = (scaledHeight * baseRenderScale).toInt()
|
|
|
|
if (baseW > MAX_BASE_DIMEN || baseH > MAX_BASE_DIMEN) {
|
|
val downScale = MAX_BASE_DIMEN.toFloat() / maxOf(baseW, baseH)
|
|
baseW = (baseW * downScale).toInt().coerceAtLeast(1)
|
|
baseH = (baseH * downScale).toInt().coerceAtLeast(1)
|
|
}
|
|
|
|
val finalBitmap = PdfBitmapPool.get(baseW, baseH)
|
|
finalBitmap.eraseColor(android.graphics.Color.WHITE)
|
|
|
|
val old = bitmapState
|
|
if (old != null && old !== finalBitmap) {
|
|
if (old !== PdfThumbnailCache.get(targetPageId)) {
|
|
old.recycle()
|
|
}
|
|
}
|
|
bitmapState = finalBitmap
|
|
currentRenderedPageId = targetPageId
|
|
|
|
isLoadingPage = false
|
|
return@LaunchedEffect
|
|
}
|
|
|
|
kotlinx.coroutines.coroutineScope {
|
|
var localBitmap: Bitmap? = null
|
|
try {
|
|
val renderResult = withContext(Dispatchers.IO) {
|
|
val rawPageCount = pdfDocumentItem.getPageCount()
|
|
if (pdfPageIndex >= rawPageCount) {
|
|
Timber.w(
|
|
"PdfPageComposable: Index $pdfPageIndex out of bounds (count $rawPageCount). Waiting for layout update."
|
|
)
|
|
return@withContext null
|
|
}
|
|
val page = pdfDocumentItem.openPage(pdfPageIndex) ?: return@withContext null
|
|
try {
|
|
val rotation = page.getPageRotation()
|
|
val originalWidthPdfUnits = page.getPageWidthPoint()
|
|
val originalHeightPdfUnits = page.getPageHeightPoint()
|
|
|
|
if (originalWidthPdfUnits <= 0 || originalHeightPdfUnits <= 0) {
|
|
throw Exception("Invalid page dimensions")
|
|
}
|
|
|
|
val aspectRatio =
|
|
originalWidthPdfUnits.toFloat() / originalHeightPdfUnits.toFloat()
|
|
val (scaledWidth, scaledHeight) = if (isVerticalScroll) {
|
|
viewContainerWidthPx to viewContainerHeightPx
|
|
} else {
|
|
var fittedWidth = viewContainerWidthPx
|
|
var fittedHeight = (fittedWidth / aspectRatio).toInt()
|
|
|
|
if (fittedHeight > viewContainerHeightPx) {
|
|
fittedHeight = viewContainerHeightPx
|
|
fittedWidth = (fittedHeight * aspectRatio).toInt()
|
|
}
|
|
|
|
fittedWidth to fittedHeight
|
|
}
|
|
|
|
if (scaledWidth == actualBitmapWidthPx &&
|
|
scaledHeight == actualBitmapHeightPx &&
|
|
bitmapState != null &&
|
|
currentRenderedPageId == targetPageId
|
|
) {
|
|
return@withContext null
|
|
}
|
|
|
|
val MAX_BASE_DIMEN = 3000
|
|
|
|
val baseRenderScale = 1.5f
|
|
|
|
var baseW = (scaledWidth * baseRenderScale).toInt()
|
|
var baseH = (scaledHeight * baseRenderScale).toInt()
|
|
|
|
if (baseW > MAX_BASE_DIMEN || baseH > MAX_BASE_DIMEN) {
|
|
val downScale = MAX_BASE_DIMEN.toFloat() / maxOf(baseW, baseH)
|
|
baseW = (baseW * downScale).toInt().coerceAtLeast(1)
|
|
baseH = (baseH * downScale).toInt().coerceAtLeast(1)
|
|
}
|
|
|
|
Timber.d(
|
|
"Rendering page $pageIndex at ${baseW}x${baseH} (logical: ${scaledWidth}x${scaledHeight})"
|
|
)
|
|
val newBitmap = createBitmap(baseW, baseH)
|
|
localBitmap = newBitmap
|
|
page.renderPageBitmap(
|
|
newBitmap,
|
|
0, 0,
|
|
baseW, baseH,
|
|
true
|
|
)
|
|
|
|
Triple(newBitmap, rotation, Pair(scaledWidth, scaledHeight))
|
|
} finally {
|
|
page.close()
|
|
}
|
|
}
|
|
|
|
if (renderResult != null) {
|
|
val (newBitmap, rotation, dims) = renderResult
|
|
|
|
actualBitmapWidthPx = dims.first
|
|
actualBitmapHeightPx = dims.second
|
|
currentPageRotation = rotation
|
|
|
|
val old = bitmapState
|
|
|
|
bitmapState = newBitmap
|
|
localBitmap = null // Handed over successfully
|
|
currentRenderedPageId = targetPageId
|
|
|
|
withContext(Dispatchers.IO) {
|
|
if (old != null && old !== newBitmap && !old.isRecycled) {
|
|
val cached = PdfThumbnailCache.get(targetPageId)
|
|
if (old !== cached) {
|
|
old.recycle()
|
|
}
|
|
}
|
|
|
|
val thumbWidth = newBitmap.width / 2
|
|
val thumbHeight = newBitmap.height / 2
|
|
if (thumbWidth > 0 && thumbHeight > 0) {
|
|
PdfThumbnailCache.put(
|
|
targetPageId, newBitmap.scale(thumbWidth, thumbHeight)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
} catch (e: Exception) {
|
|
if (e is CancellationException) throw e
|
|
pageErrorMessage = context.getString(
|
|
R.string.error_processing_page,
|
|
e.readablePdfErrorDetail()
|
|
)
|
|
} finally {
|
|
isLoadingPage = false
|
|
localBitmap?.recycle()
|
|
}
|
|
}
|
|
}
|
|
|
|
when {
|
|
isLoadingPage -> {
|
|
if (placeholderBitmap != null && !placeholderBitmap.isRecycled) {
|
|
Canvas(modifier = Modifier.fillMaxSize()) {
|
|
if (!placeholderBitmap.isRecycled) {
|
|
val canvasWidth = size.width
|
|
val canvasHeight = size.height
|
|
val bitmapWidth = placeholderBitmap.width
|
|
val bitmapHeight = placeholderBitmap.height
|
|
|
|
if (bitmapWidth <= 0 || bitmapHeight <= 0) return@Canvas
|
|
|
|
val canvasAspectRatio = canvasWidth / canvasHeight
|
|
val bitmapAspectRatio =
|
|
bitmapWidth.toFloat() / bitmapHeight.toFloat()
|
|
|
|
val dstWidth: Float
|
|
val dstHeight: Float
|
|
|
|
if (bitmapAspectRatio > canvasAspectRatio) {
|
|
dstWidth = canvasWidth
|
|
dstHeight = dstWidth / bitmapAspectRatio
|
|
} else {
|
|
dstHeight = canvasHeight
|
|
dstWidth = dstHeight * bitmapAspectRatio
|
|
}
|
|
|
|
val dstOffset = Offset(
|
|
x = (canvasWidth - dstWidth) / 2f,
|
|
y = (canvasHeight - dstHeight) / 2f
|
|
)
|
|
|
|
drawImage(
|
|
image = placeholderBitmap.asImageBitmap(),
|
|
dstOffset = IntOffset(
|
|
dstOffset.x.roundToInt(), dstOffset.y.roundToInt()
|
|
),
|
|
dstSize = IntSize(
|
|
dstWidth.roundToInt(), dstHeight.roundToInt()
|
|
)
|
|
)
|
|
}
|
|
}
|
|
} else {
|
|
// Blank page
|
|
}
|
|
}
|
|
|
|
pageErrorMessage != null -> {
|
|
Text(
|
|
text = pageErrorMessage!!,
|
|
color = MaterialTheme.colorScheme.error,
|
|
modifier = Modifier
|
|
.padding(16.dp)
|
|
.align(Alignment.Center)
|
|
)
|
|
}
|
|
|
|
bitmapState != null && actualBitmapWidthPx > 0 && actualBitmapHeightPx > 0 -> {
|
|
val stableBitmapState = remember(bitmapState) { StableHolder(bitmapState) }
|
|
val stableTiles = remember(tiles) { StableHolder(tiles) }
|
|
val stableColorFilter = remember(colorFilter) { StableHolder(colorFilter) }
|
|
val stableImageRects = remember(imageScreenRects) { StableHolder(imageScreenRects) }
|
|
|
|
val staticData = remember(
|
|
stableBitmapState,
|
|
stableTiles,
|
|
effectiveScale,
|
|
centeringOffsetX,
|
|
centeringOffsetY,
|
|
canvasWidthPx.floatValue,
|
|
canvasHeightPx.floatValue,
|
|
actualBitmapWidthPx,
|
|
actualBitmapHeightPx,
|
|
stableColorFilter,
|
|
isDarkMode,
|
|
excludeImages,
|
|
stableImageRects,
|
|
textureBitmap,
|
|
effectiveTextureAlpha,
|
|
textureBlendMode
|
|
) {
|
|
Timber.tag("PdfDrawPerf").v(
|
|
"STATIC DATA GENERATED: Scale=$effectiveScale, Tiles=${stableTiles.item.size}"
|
|
)
|
|
PageStaticData(
|
|
bitmap = stableBitmapState,
|
|
tiles = stableTiles,
|
|
effectiveScale = effectiveScale,
|
|
centeringOffsetX = centeringOffsetX,
|
|
centeringOffsetY = centeringOffsetY,
|
|
canvasWidth = canvasWidthPx.floatValue,
|
|
canvasHeight = canvasHeightPx.floatValue,
|
|
targetWidth = actualBitmapWidthPx,
|
|
targetHeight = actualBitmapHeightPx,
|
|
colorFilter = stableColorFilter,
|
|
isDarkMode = isDarkMode,
|
|
excludeImages = excludeImages,
|
|
imageRects = stableImageRects,
|
|
textureBitmap = StableHolder(textureBitmap),
|
|
textureAlpha = effectiveTextureAlpha,
|
|
textureBlendMode = textureBlendMode
|
|
)
|
|
}
|
|
|
|
val selectionData = remember(
|
|
pageLinks,
|
|
showAllTextHighlights,
|
|
actualBitmapWidthPx,
|
|
actualBitmapHeightPx,
|
|
mergedAllTextPageHighlightRects,
|
|
mergedTtsHighlightRects,
|
|
mergedSearchHighlightRects,
|
|
ocrHoverHighlights,
|
|
mergedSelectionRects,
|
|
centeringOffsetX,
|
|
centeringOffsetY,
|
|
linkHighlightColor,
|
|
scrimColorForTextHighlight,
|
|
allTextPageHighlightColor,
|
|
ttsHighlightColor,
|
|
searchHighlightColor,
|
|
selectionHighlightColor,
|
|
pageIndex,
|
|
mergedSearchFocusedRects,
|
|
mergedSearchAllRects,
|
|
searchHighlightMode,
|
|
searchFocusedColor,
|
|
searchAllColor,
|
|
userHighlightScreenRects,
|
|
customHighlightColors
|
|
) {
|
|
PageSelectionData(
|
|
pageLinks = StableHolder(pageLinks),
|
|
showAllTextHighlights = showAllTextHighlights,
|
|
actualBitmapWidthPx = actualBitmapWidthPx,
|
|
actualBitmapHeightPx = actualBitmapHeightPx,
|
|
mergedAllTextPageHighlightRects = StableHolder(
|
|
mergedAllTextPageHighlightRects
|
|
),
|
|
mergedTtsHighlightRects = StableHolder(mergedTtsHighlightRects),
|
|
ocrHoverHighlights = ocrHoverHighlights,
|
|
mergedSelectionRects = StableHolder(mergedSelectionRects),
|
|
centeringOffsetX = centeringOffsetX,
|
|
centeringOffsetY = centeringOffsetY,
|
|
linkHighlightColor = linkHighlightColor,
|
|
scrimColorForTextHighlight = scrimColorForTextHighlight,
|
|
allTextPageHighlightColor = allTextPageHighlightColor,
|
|
ttsHighlightColor = ttsHighlightColor,
|
|
selectionHighlightColor = selectionHighlightColor,
|
|
pageIndex = pageIndex,
|
|
mergedSearchFocusedRects = StableHolder(mergedSearchFocusedRects),
|
|
mergedSearchAllRects = StableHolder(mergedSearchAllRects),
|
|
searchHighlightMode = searchHighlightMode,
|
|
userHighlightScreenRects = StableHolder(userHighlightScreenRects),
|
|
customHighlightColors = StableHolder(customHighlightColors)
|
|
)
|
|
}
|
|
|
|
val centeringPaddingEnd =
|
|
with(density) { centeringOffsetX.coerceAtLeast(0f).toDp() }
|
|
val centeringPaddingTop =
|
|
with(density) { centeringOffsetY.coerceAtLeast(0f).toDp() }
|
|
|
|
PdfPageRenderer(
|
|
staticData = staticData,
|
|
selectionData = selectionData,
|
|
totalPages = totalPages,
|
|
annotationsProvider = pageAnnotations,
|
|
drawingState = drawingState,
|
|
onCanvasSizeChanged = { w, h ->
|
|
if (canvasWidthPx.floatValue != w || canvasHeightPx.floatValue != h) {
|
|
canvasWidthPx.floatValue = w
|
|
canvasHeightPx.floatValue = h
|
|
}
|
|
},
|
|
scale = scale,
|
|
uiScale = effectiveScale,
|
|
offset = offset,
|
|
startHandlePos = startHandleContentPosition.value,
|
|
endHandlePos = endHandleContentPosition.value,
|
|
teardropWidthPx = teardropWidthPxState.value,
|
|
onHighlightAdd = onHighlightAdd,
|
|
onHighlightUpdate = onHighlightUpdate,
|
|
onHighlightDelete = onHighlightDelete,
|
|
onTts = onTts,
|
|
onNote = onNoteRequested,
|
|
teardropHeightPx = teardropHeightPxState.value,
|
|
activeDraggingHandle = activeDraggingHandle,
|
|
showMagnifier = showMagnifier,
|
|
magnifierCenterTarget = magnifierBitmapCenterTarget,
|
|
magnifierZoomFactor = magnifierZoomFactor,
|
|
menuState = customMenuState,
|
|
onMenuDismiss = {
|
|
customMenuState = null
|
|
selectionCharRange.value = null
|
|
coroutineScope.launch {
|
|
updateSelectionVisuals(
|
|
pdfDocumentItem,
|
|
pdfPageIndex,
|
|
null,
|
|
actualBitmapWidthPx,
|
|
actualBitmapHeightPx,
|
|
currentPageRotation,
|
|
)
|
|
}
|
|
},
|
|
onCopy = { textToCopy ->
|
|
clipboardManager.setText(AnnotatedString(textToCopy))
|
|
customMenuState = null
|
|
selectionCharRange.value = null
|
|
coroutineScope.launch {
|
|
updateSelectionVisuals(
|
|
pdfDocumentItem,
|
|
pdfPageIndex,
|
|
null,
|
|
actualBitmapWidthPx,
|
|
actualBitmapHeightPx,
|
|
currentPageRotation
|
|
)
|
|
}
|
|
},
|
|
onAiDefine = { textToDefine ->
|
|
onWordSelectedForAiDefinition(textToDefine.trim())
|
|
customMenuState = null
|
|
selectionCharRange.value = null
|
|
coroutineScope.launch {
|
|
updateSelectionVisuals(
|
|
pdfDocumentItem,
|
|
pdfPageIndex,
|
|
null,
|
|
actualBitmapWidthPx,
|
|
actualBitmapHeightPx,
|
|
currentPageRotation
|
|
)
|
|
}
|
|
},
|
|
onTranslate = { textToTranslate ->
|
|
onTranslateText(textToTranslate.trim())
|
|
customMenuState = null
|
|
selectionCharRange.value = null
|
|
coroutineScope.launch {
|
|
updateSelectionVisuals(
|
|
pdfDocumentItem,
|
|
pdfPageIndex,
|
|
null,
|
|
actualBitmapWidthPx,
|
|
actualBitmapHeightPx,
|
|
currentPageRotation
|
|
)
|
|
}
|
|
},
|
|
onSearch = { textToSearch ->
|
|
onSearchText(textToSearch.trim())
|
|
customMenuState = null
|
|
selectionCharRange.value = null
|
|
coroutineScope.launch {
|
|
updateSelectionVisuals(
|
|
pdfDocumentItem,
|
|
pdfPageIndex,
|
|
null,
|
|
actualBitmapWidthPx,
|
|
actualBitmapHeightPx,
|
|
currentPageRotation
|
|
)
|
|
}
|
|
},
|
|
onSelectAll = {
|
|
customMenuState = null
|
|
coroutineScope.launch {
|
|
if (!isPdfPage) return@launch
|
|
|
|
if (selectionMethodUsed == PdfSelectionMethod.PDFIUM) {
|
|
var page: ReaderPage? = null
|
|
var textPage: ReaderTextPage? = null
|
|
try {
|
|
val charCount = withContext(Dispatchers.IO) {
|
|
page = pdfDocumentItem.openPage(pdfPageIndex)
|
|
textPage = page?.openTextPage()
|
|
textPage?.textPageCountChars() ?: 0
|
|
}
|
|
|
|
if (charCount > 0) {
|
|
selectionCharRange.value = Pair(0, charCount)
|
|
val tPage = page
|
|
val tTextPage = textPage
|
|
updateSelectionVisuals(
|
|
pdfDocumentItem,
|
|
pdfPageIndex,
|
|
selectionCharRange.value,
|
|
actualBitmapWidthPx,
|
|
actualBitmapHeightPx,
|
|
currentPageRotation,
|
|
providedPage = tPage,
|
|
providedTextPage = tTextPage
|
|
)
|
|
if (selectedWordScreenRects.isNotEmpty()) {
|
|
val fullText = withContext(Dispatchers.IO) {
|
|
tTextPage?.textPageGetText(0, charCount)
|
|
}
|
|
if (!fullText.isNullOrBlank()) {
|
|
val combinedRect = Rect(selectedWordScreenRects.first())
|
|
selectedWordScreenRects.forEach { combinedRect.union(it) }
|
|
customMenuState = CustomPdfMenuState(
|
|
selectedText = fullText,
|
|
anchorRect = combinedRect,
|
|
charRange = selectionCharRange.value!!
|
|
)
|
|
}
|
|
}
|
|
}
|
|
} catch (e: Exception) {
|
|
Timber.e(e, "Failed to select all")
|
|
} finally {
|
|
withContext(NonCancellable) {
|
|
withContext(Dispatchers.IO) {
|
|
textPage?.close()
|
|
page?.close()
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
if (allOcrSymbolsForSelection.isNotEmpty()) {
|
|
ocrSelectionSymbolIndices =
|
|
Pair(0, allOcrSymbolsForSelection.size)
|
|
updateOcrSymbolSelectionRectsAndHandles(
|
|
ocrSelectionSymbolIndices
|
|
)
|
|
if (selectedWordScreenRects.isNotEmpty()) {
|
|
val fullText = buildString {
|
|
allOcrSymbolsForSelection.forEachIndexed { index, info ->
|
|
append(info.symbol.text)
|
|
if (index < allOcrSymbolsForSelection.size - 1) {
|
|
val nextInfo =
|
|
allOcrSymbolsForSelection[index + 1]
|
|
if (info.parentLine !== nextInfo.parentLine) append(
|
|
'\n'
|
|
)
|
|
else if (info.parentElement !== nextInfo.parentElement) append(
|
|
' '
|
|
)
|
|
}
|
|
}
|
|
}
|
|
if (fullText.isNotBlank()) {
|
|
val combinedRect = Rect(selectedWordScreenRects.first())
|
|
selectedWordScreenRects.forEach { combinedRect.union(it) }
|
|
customMenuState = CustomPdfMenuState(
|
|
selectedText = fullText,
|
|
anchorRect = combinedRect,
|
|
charRange = Pair(-1, -1)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
},
|
|
onShowUpsellDialog = {
|
|
onShowDictionaryUpsellDialog()
|
|
customMenuState = null
|
|
selectionCharRange.value = null
|
|
coroutineScope.launch {
|
|
updateSelectionVisuals(
|
|
pdfDocumentItem,
|
|
pdfPageIndex,
|
|
null,
|
|
actualBitmapWidthPx,
|
|
actualBitmapHeightPx,
|
|
currentPageRotation
|
|
)
|
|
}
|
|
},
|
|
isProUser = isProUser,
|
|
isBookmarked = isBookmarked,
|
|
onBookmarkClick = onBookmarkClick,
|
|
centeringPaddingTop = centeringPaddingTop,
|
|
centeringPaddingEnd = centeringPaddingEnd,
|
|
isPerformingOcr = isPerformingOcrForSelection,
|
|
ocrRipplePos = ocrRipplePosition,
|
|
layoutCoordinates = layoutCoordinates,
|
|
contentToScreenCoordinates = contentToScreenCoordinates,
|
|
density = density,
|
|
isVerticalScroll = isVerticalScroll,
|
|
showPageNumberOverlay = showPageNumberOverlay,
|
|
isScrolling = isScrolling,
|
|
isEditMode = isEditMode,
|
|
selectedTool = selectedTool,
|
|
eraserPosition = eraserPosition,
|
|
isStylusEraserOverride = isStylusEraserOverride,
|
|
activeToolThickness = activeToolThickness,
|
|
eraserToolThickness = eraserToolThickness,
|
|
richTextController = richTextController,
|
|
textBoxes = textBoxes,
|
|
selectedTextBoxId = selectedTextBoxId,
|
|
onTextBoxChange = onTextBoxChange,
|
|
onTextBoxSelect = onTextBoxSelect,
|
|
onTextBoxDragStart = onTextBoxDragStart,
|
|
onTextBoxDrag = onTextBoxDrag,
|
|
onTextBoxDragEnd = onTextBoxDragEnd,
|
|
onDragPageTurn = onDragPageTurn,
|
|
draggingBoxId = draggingBoxId,
|
|
customHighlightColors = customHighlightColors,
|
|
onPaletteClick = onPaletteClick,
|
|
isBubbleZoomModeActive = isBubbleZoomModeActive,
|
|
isActivePage = isActivePage,
|
|
isDetectingBubbles = isDetectingBubbles,
|
|
detectedBubbles = detectedBubbles,
|
|
animatingBubbleIndex = animatingBubbleIndex,
|
|
bubbleExpansionProgress = bubbleExpansionProgress.value,
|
|
expandedBubbleRender = expandedBubbleRender
|
|
)
|
|
}
|
|
|
|
else -> {
|
|
Text(
|
|
text = stringResource(R.string.error_unable_to_display_page, pageIndex + 1),
|
|
modifier = Modifier
|
|
.padding(16.dp)
|
|
.align(Alignment.Center)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
private fun OcrProcessingIndicator(position: Offset) {
|
|
val infiniteTransition = rememberInfiniteTransition(label = "ocr_indicator_transition")
|
|
val animatedRadius by infiniteTransition.animateFloat(
|
|
initialValue = 20f, targetValue = 120f, animationSpec = infiniteRepeatable(
|
|
animation = tween(1200), repeatMode = RepeatMode.Restart
|
|
), label = "ocr_radius"
|
|
)
|
|
val animatedAlpha by infiniteTransition.animateFloat(
|
|
initialValue = 0.8f, targetValue = 0f, animationSpec = infiniteRepeatable(
|
|
animation = tween(1200), repeatMode = RepeatMode.Restart
|
|
), label = "ocr_alpha"
|
|
)
|
|
|
|
val color = MaterialTheme.colorScheme.primary
|
|
|
|
Canvas(modifier = Modifier.fillMaxSize()) {
|
|
drawCircle(
|
|
color = color.copy(alpha = animatedAlpha),
|
|
radius = animatedRadius,
|
|
center = position,
|
|
style = Stroke(width = (4.dp * animatedAlpha).toPx())
|
|
)
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
private fun PdfBitmapLayer(
|
|
bitmapState: Bitmap?,
|
|
tiles: List<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.isCanvasSafeBitmap(
|
|
maxBytes = PDF_MAX_DRAW_BITMAP_BYTES,
|
|
maxDimension = PDF_MAX_DRAW_BITMAP_DIMENSION_PX
|
|
)
|
|
) {
|
|
val dstW = if (targetWidth > 0) targetWidth else bitmapState.width
|
|
val dstH = if (targetHeight > 0) targetHeight else bitmapState.height
|
|
val srcSize = IntSize(bitmapState.width, bitmapState.height)
|
|
val dstSize = IntSize(dstW, dstH)
|
|
|
|
drawImage(
|
|
image = bitmapState.asImageBitmap(),
|
|
srcOffset = IntOffset.Zero,
|
|
srcSize = srcSize,
|
|
dstOffset = IntOffset.Zero,
|
|
dstSize = dstSize,
|
|
colorFilter = colorFilter,
|
|
filterQuality = androidx.compose.ui.graphics.FilterQuality.High
|
|
)
|
|
|
|
if (excludeImages && colorFilter != null && imageRects.isNotEmpty()) {
|
|
imageRects.forEach { rect ->
|
|
val scaleX = bitmapState.width.toFloat() / dstW.toFloat()
|
|
val scaleY = bitmapState.height.toFloat() / dstH.toFloat()
|
|
|
|
val srcRectLeft = (rect.left * scaleX).roundToInt().coerceAtLeast(0)
|
|
val srcRectTop = (rect.top * scaleY).roundToInt().coerceAtLeast(0)
|
|
val srcRectRight = (rect.right * scaleX).roundToInt().coerceAtMost(bitmapState.width)
|
|
val srcRectBottom = (rect.bottom * scaleY).roundToInt().coerceAtMost(bitmapState.height)
|
|
|
|
val w = srcRectRight - srcRectLeft
|
|
val h = srcRectBottom - srcRectTop
|
|
if (w > 0 && h > 0) {
|
|
drawImage(
|
|
image = bitmapState.asImageBitmap(),
|
|
srcOffset = IntOffset(srcRectLeft, srcRectTop),
|
|
srcSize = IntSize(w, h),
|
|
dstOffset = IntOffset(rect.left, rect.top),
|
|
dstSize = IntSize(rect.width(), rect.height()),
|
|
colorFilter = null,
|
|
filterQuality = androidx.compose.ui.graphics.FilterQuality.High
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
val needsTiling = effectiveScale > 1f || targetWidth > 3000 || targetHeight > 3000
|
|
if (needsTiling) {
|
|
tiles.forEach { tile ->
|
|
if (
|
|
tile.bitmap.isCanvasSafeBitmap(
|
|
maxBytes = PDF_MAX_DRAW_BITMAP_BYTES,
|
|
maxDimension = PDF_MAX_DRAW_BITMAP_DIMENSION_PX
|
|
)
|
|
) {
|
|
drawImage(
|
|
image = tile.bitmap.asImageBitmap(),
|
|
srcOffset = IntOffset.Zero,
|
|
srcSize = IntSize(tile.bitmap.width, tile.bitmap.height),
|
|
dstOffset = IntOffset(tile.renderRect.left, tile.renderRect.top),
|
|
dstSize = IntSize(tile.renderRect.width(), tile.renderRect.height()),
|
|
colorFilter = colorFilter,
|
|
filterQuality = androidx.compose.ui.graphics.FilterQuality.High
|
|
)
|
|
|
|
if (excludeImages && colorFilter != null && imageRects.isNotEmpty()) {
|
|
imageRects.forEach { imgRect ->
|
|
val intersectLeft = max(imgRect.left, tile.renderRect.left)
|
|
val intersectTop = max(imgRect.top, tile.renderRect.top)
|
|
val intersectRight = min(imgRect.right, tile.renderRect.right)
|
|
val intersectBottom = min(imgRect.bottom, tile.renderRect.bottom)
|
|
|
|
val iw = intersectRight - intersectLeft
|
|
val ih = intersectBottom - intersectTop
|
|
|
|
if (iw > 0 && ih > 0) {
|
|
val scaleXBmp = tile.bitmap.width.toFloat() / tile.renderRect.width()
|
|
val scaleYBmp = tile.bitmap.height.toFloat() / tile.renderRect.height()
|
|
|
|
val srcLeft = ((intersectLeft - tile.renderRect.left) * scaleXBmp).roundToInt()
|
|
val srcTop = ((intersectTop - tile.renderRect.top) * scaleYBmp).roundToInt()
|
|
val srcRight = ((intersectRight - tile.renderRect.left) * scaleXBmp).roundToInt()
|
|
val srcBottom = ((intersectBottom - tile.renderRect.top) * scaleYBmp).roundToInt()
|
|
|
|
val srcW = srcRight - srcLeft
|
|
val srcH = srcBottom - srcTop
|
|
|
|
if (srcW > 0 && srcH > 0) {
|
|
drawImage(
|
|
image = tile.bitmap.asImageBitmap(),
|
|
srcOffset = IntOffset(srcLeft, srcTop),
|
|
srcSize = IntSize(srcW, srcH),
|
|
dstOffset = IntOffset(intersectLeft, intersectTop),
|
|
dstSize = IntSize(iw, ih),
|
|
colorFilter = null,
|
|
filterQuality = androidx.compose.ui.graphics.FilterQuality.High
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (textureBitmap != null && textureAlpha > 0f) {
|
|
drawRect(
|
|
brush = ShaderBrush(ImageShader(textureBitmap, TileMode.Repeated, TileMode.Repeated)),
|
|
size = Size(dstW.toFloat(), dstH.toFloat()),
|
|
blendMode = textureBlendMode,
|
|
alpha = textureAlpha
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
private fun PdfHighlightsLayer(
|
|
pageLinks: List<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,
|
|
showPageNumberOverlay: 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,
|
|
eraserToolThickness: 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
|
|
} || richTextController.hasRenderableText
|
|
|
|
if (isEditable || hasContent) {
|
|
PdfRichTextLayer(
|
|
pageIndex = selectionData.pageIndex,
|
|
controller = richTextController,
|
|
pageWidth = staticData.targetWidth.toFloat(),
|
|
pageHeight = staticData.targetHeight.toFloat(),
|
|
isTextEditingEnabled = isEditable && selectedTextBoxId == null,
|
|
centeringOffsetX = staticData.centeringOffsetX,
|
|
centeringOffsetY = staticData.centeringOffsetY,
|
|
isDarkMode = staticData.isDarkMode,
|
|
isScrolling = isScrolling
|
|
)
|
|
}
|
|
}
|
|
|
|
if (textBoxes.isNotEmpty()) {
|
|
androidx.compose.runtime.SideEffect {
|
|
Timber.tag("PdfTextBoxDebug").d("PdfPageRenderer parent graphicsLayer applied | scale=$scale | offset=$offset | Centering: X=${staticData.centeringOffsetX}, Y=${staticData.centeringOffsetY}")
|
|
}
|
|
}
|
|
|
|
textBoxes.forEach { box ->
|
|
val isDraggingThisBox = (box.id == draggingBoxId)
|
|
val boxAlpha = if (isDraggingThisBox) 0f else 1f
|
|
|
|
key(box.id) {
|
|
ResizableTextBox(
|
|
box = box,
|
|
isSelected = (box.id == selectedTextBoxId),
|
|
isEditMode = isEditMode,
|
|
isDarkMode = staticData.isDarkMode,
|
|
scale = uiScale,
|
|
pageWidthPx = staticData.targetWidth.toFloat(),
|
|
pageHeightPx = staticData.targetHeight.toFloat(),
|
|
handlePosition = HandlePosition.AUTO,
|
|
onBoundsChanged = { newBounds ->
|
|
Timber.tag("PdfTextBoxDebug").v("PdfPageRenderer onBoundsChanged [ID: ${box.id}] bounds=$newBounds draggingBoxId=$draggingBoxId")
|
|
if (draggingBoxId != box.id) {
|
|
onTextBoxChange(box.copy(relativeBounds = newBounds))
|
|
} else {
|
|
Timber.tag("PdfTextBoxDebug").d("PdfPageRenderer onBoundsChanged IGNORED because box[ID: ${box.id}] is being dragged globally")
|
|
}
|
|
},
|
|
onTextChanged = { newText ->
|
|
onTextBoxChange(box.copy(text = newText))
|
|
},
|
|
onSelect = {
|
|
Timber.tag("PdfTextBoxDebug").d("PdfPageRenderer onSelect propagated[ID: ${box.id}]")
|
|
onTextBoxSelect(box.id)
|
|
},
|
|
onDragStart = { touchOffset ->
|
|
Timber.tag("PdfTextBoxDebug").d("PdfPageRenderer onDragStart[ID: ${box.id}] isVerticalScroll=$isVerticalScroll | offset=$touchOffset")
|
|
if (isVerticalScroll) {
|
|
val topLeft = Offset(
|
|
box.relativeBounds.left * staticData.targetWidth,
|
|
box.relativeBounds.top * staticData.targetHeight
|
|
)
|
|
onTextBoxDragStart(box, topLeft, touchOffset)
|
|
} else {
|
|
onTextBoxDragStart(box, Offset.Zero, touchOffset)
|
|
}
|
|
},
|
|
onDrag = { delta, currentBounds ->
|
|
Timber.tag("PdfTextBoxDebug").v("PdfPageRenderer onDrag [ID: ${box.id}] delta=$delta currentBounds=$currentBounds scale=$scale")
|
|
if (isVerticalScroll) {
|
|
onTextBoxDrag(delta)
|
|
} else {
|
|
val scaledDelta = delta * scale
|
|
onTextBoxDrag(scaledDelta)
|
|
|
|
val width = staticData.targetWidth
|
|
val edgeThreshold = 60f
|
|
if (width > 0) {
|
|
if (currentBounds.left < edgeThreshold && delta.x < 0) {
|
|
onDragPageTurn(-1)
|
|
} else if (currentBounds.right > width - edgeThreshold && delta.x > 0) {
|
|
onDragPageTurn(1)
|
|
}
|
|
}
|
|
}
|
|
},
|
|
onDragEnd = {
|
|
Timber.tag("PdfTextBoxDebug").d("PdfPageRenderer onDragEnd[ID: ${box.id}]")
|
|
onTextBoxDragEnd()
|
|
},
|
|
onDragCancel = {
|
|
Timber.tag("PdfTextBoxDebug").d("PdfPageRenderer onDragCancel [ID: ${box.id}]")
|
|
onTextBoxDragEnd()
|
|
},
|
|
modifier = Modifier
|
|
.zIndex(10f)
|
|
.offset {
|
|
IntOffset(
|
|
staticData.centeringOffsetX.roundToInt(),
|
|
staticData.centeringOffsetY.roundToInt()
|
|
)
|
|
}
|
|
.alpha(boxAlpha)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Layer 4: Page Number Indicator
|
|
if (showPageNumberOverlay && totalPages > 0) {
|
|
val pageNumColor = if (staticData.isDarkMode) {
|
|
Color.White
|
|
} else {
|
|
Color.Black
|
|
}
|
|
|
|
Box(modifier = Modifier
|
|
.offset {
|
|
IntOffset(
|
|
x = staticData.centeringOffsetX.toInt(),
|
|
y = staticData.centeringOffsetY.toInt()
|
|
)
|
|
}
|
|
.size(width = with(density) {
|
|
staticData.targetWidth.toDp()
|
|
}, height = with(density) {
|
|
staticData.targetHeight.toDp()
|
|
})) {
|
|
Text(
|
|
text = "${selectionData.pageIndex + 1}/$totalPages",
|
|
color = pageNumColor.copy(alpha = 0.5f),
|
|
style = MaterialTheme.typography.labelSmall.copy(
|
|
fontSize = 12.sp, fontWeight = FontWeight.Bold
|
|
),
|
|
modifier = Modifier
|
|
.align(Alignment.BottomEnd)
|
|
.padding(end = 12.dp, bottom = 12.dp)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Capture size for coordinate conversions
|
|
Canvas(modifier = Modifier.fillMaxSize()) {
|
|
if (staticData.canvasWidth != size.width || staticData.canvasHeight != size.height) {
|
|
onCanvasSizeChanged(size.width, size.height)
|
|
}
|
|
}
|
|
|
|
val teardropPainter = painterResource(id = R.drawable.teardrop)
|
|
|
|
if (isEditMode && (selectedTool == InkType.ERASER || isStylusEraserOverride) && eraserPosition != null) {
|
|
Canvas(modifier = Modifier.fillMaxSize()) {
|
|
val eraserStrokeWidth = resolveEraserStrokeWidth(
|
|
isStylusEraserOverride,
|
|
activeToolThickness,
|
|
eraserToolThickness
|
|
)
|
|
val radiusPx = if (eraserStrokeWidth > 0f && staticData.targetWidth > 0) {
|
|
eraserStrokeWidth * staticData.targetWidth * scale
|
|
} else {
|
|
8.dp.toPx()
|
|
}
|
|
|
|
drawCircle(
|
|
color = Color.White.copy(alpha = 0.3f),
|
|
radius = radiusPx,
|
|
center = eraserPosition
|
|
)
|
|
|
|
drawCircle(
|
|
color = Color.Black,
|
|
radius = radiusPx,
|
|
center = eraserPosition,
|
|
style = Stroke(width = 1.dp.toPx())
|
|
)
|
|
}
|
|
}
|
|
|
|
Canvas(modifier = Modifier.fillMaxSize()) {
|
|
val handleColor = Color.Blue
|
|
val tiltAngleDegrees = 30f
|
|
|
|
startHandlePos?.let { contentPos ->
|
|
val position = contentToScreenCoordinates(contentPos)
|
|
translate(left = position.x - teardropWidthPx / 2, top = position.y) {
|
|
rotate(degrees = tiltAngleDegrees, pivot = Offset(teardropWidthPx / 2f, 0f)) {
|
|
with(teardropPainter) {
|
|
draw(
|
|
size = Size(teardropWidthPx, teardropHeightPx),
|
|
colorFilter = ColorFilter.tint(handleColor)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
endHandlePos?.let { contentPos ->
|
|
val position = contentToScreenCoordinates(contentPos)
|
|
translate(left = position.x - teardropWidthPx / 2, top = position.y) {
|
|
rotate(degrees = -tiltAngleDegrees, pivot = Offset(teardropWidthPx / 2f, 0f)) {
|
|
with(teardropPainter) {
|
|
draw(
|
|
size = Size(teardropWidthPx, teardropHeightPx),
|
|
colorFilter = ColorFilter.tint(handleColor)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (showMagnifier && activeDraggingHandle != null && staticData.bitmap.item != null) {
|
|
val handleContentPos = when (activeDraggingHandle) {
|
|
Handle.START -> startHandlePos
|
|
Handle.END -> endHandlePos
|
|
}
|
|
|
|
handleContentPos?.let { contentPos ->
|
|
val pos = contentToScreenCoordinates(contentPos)
|
|
|
|
val magnifierWidth = 120.dp
|
|
val magnifierHeight = 60.dp
|
|
val magnifierOffsetAboveHandle = 24.dp
|
|
val effectiveScale = staticData.effectiveScale
|
|
|
|
val effectiveZoomFactor = if (isVerticalScroll && effectiveScale > 1f) {
|
|
effectiveScale * 1.25f
|
|
} else {
|
|
magnifierZoomFactor
|
|
}
|
|
|
|
val popupPositionProvider = remember(pos, layoutCoordinates, density) {
|
|
object : androidx.compose.ui.window.PopupPositionProvider {
|
|
override fun calculatePosition(
|
|
anchorBounds: androidx.compose.ui.unit.IntRect,
|
|
windowSize: androidx.compose.ui.unit.IntSize,
|
|
layoutDirection: androidx.compose.ui.unit.LayoutDirection,
|
|
popupContentSize: androidx.compose.ui.unit.IntSize
|
|
): androidx.compose.ui.unit.IntOffset {
|
|
val coords = layoutCoordinates ?: return androidx.compose.ui.unit.IntOffset.Zero
|
|
|
|
val windowPos = coords.localToWindow(pos)
|
|
val offsetPx = with(density) { magnifierOffsetAboveHandle.toPx() }
|
|
|
|
val x = (windowPos.x - popupContentSize.width / 2).toInt()
|
|
val y = (windowPos.y - popupContentSize.height - offsetPx).toInt()
|
|
|
|
return androidx.compose.ui.unit.IntOffset(x, y)
|
|
}
|
|
}
|
|
}
|
|
|
|
androidx.compose.ui.window.Popup(
|
|
popupPositionProvider = popupPositionProvider,
|
|
properties = androidx.compose.ui.window.PopupProperties(
|
|
focusable = false,
|
|
dismissOnClickOutside = false,
|
|
dismissOnBackPress = false,
|
|
usePlatformDefaultWidth = false
|
|
)
|
|
) {
|
|
MagnifierComposable(
|
|
sourceBitmap = staticData.bitmap.item.asImageBitmap(),
|
|
tiles = if (effectiveScale > 1f) staticData.tiles.item else emptyList(),
|
|
currentScale = effectiveScale,
|
|
magnifierCenterOnBitmap = magnifierCenterTarget,
|
|
contentWidthPx = staticData.targetWidth,
|
|
contentHeightPx = staticData.targetHeight,
|
|
magnifierWidth = magnifierWidth,
|
|
magnifierHeight = magnifierHeight,
|
|
zoomFactor = effectiveZoomFactor,
|
|
selectionRectsInContentCoords = selectionData.mergedSelectionRects.item,
|
|
highlightColor = Color(0x6633B5E5),
|
|
colorFilter = staticData.colorFilter.item,
|
|
modifier = Modifier
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
if (menuState != null) {
|
|
BackHandler(enabled = true, onBack = onMenuDismiss)
|
|
}
|
|
|
|
if (menuState != null && !isScrolling && draggingBoxId == null && activeDraggingHandle == null) {
|
|
if (menuState.anchorRect.width() > 0 || menuState.anchorRect.height() > 0) {
|
|
val popupPositionProvider = remember(menuState.anchorRect, density, offset, scale, layoutCoordinates) {
|
|
object : PopupPositionProvider {
|
|
override fun calculatePosition(
|
|
anchorBounds: IntRect,
|
|
windowSize: IntSize,
|
|
layoutDirection: LayoutDirection,
|
|
popupContentSize: IntSize
|
|
): IntOffset {
|
|
val coords = layoutCoordinates ?: return IntOffset.Zero
|
|
|
|
val topLeftLocal = contentToScreenCoordinates(Offset(
|
|
menuState.anchorRect.left.toFloat(),
|
|
menuState.anchorRect.top.toFloat()))
|
|
val bottomRightLocal = contentToScreenCoordinates(Offset(
|
|
menuState.anchorRect.right.toFloat(),
|
|
menuState.anchorRect.bottom.toFloat()))
|
|
|
|
val topLeftWindow = coords.localToWindow(topLeftLocal)
|
|
val bottomRightWindow = coords.localToWindow(bottomRightLocal)
|
|
|
|
val windowCenterX = (topLeftWindow.x + bottomRightWindow.x) / 2
|
|
val gapPx = with(density) { 16.dp.toPx() }
|
|
|
|
var yInWindow = (topLeftWindow.y - popupContentSize.height - gapPx).toInt()
|
|
|
|
if (yInWindow < 0) {
|
|
yInWindow = (bottomRightWindow.y + gapPx).toInt()
|
|
if (yInWindow + popupContentSize.height > windowSize.height) {
|
|
yInWindow = windowSize.height - popupContentSize.height - gapPx.toInt()
|
|
}
|
|
}
|
|
|
|
val xInWindow = (windowCenterX - popupContentSize.width / 2).toInt()
|
|
.coerceIn(0, windowSize.width - popupContentSize.width)
|
|
|
|
return IntOffset(xInWindow, yInWindow)
|
|
}
|
|
}
|
|
}
|
|
|
|
PdfSelectionMenuPopup(
|
|
menuState = menuState,
|
|
popupPositionProvider = popupPositionProvider,
|
|
onDismiss = onMenuDismiss,
|
|
onCopy = onCopy,
|
|
onAiDefine = onAiDefine,
|
|
onTranslate = onTranslate,
|
|
onSearch = onSearch,
|
|
onSelectAll = onSelectAll,
|
|
onColorSelected = { color ->
|
|
if (menuState.isExistingHighlight && menuState.highlightId != null) {
|
|
onHighlightUpdate(menuState.highlightId, color)
|
|
} else {
|
|
onHighlightAdd(
|
|
selectionData.pageIndex, menuState.charRange, menuState.selectedText,
|
|
color
|
|
)
|
|
}
|
|
onMenuDismiss()
|
|
},
|
|
onDelete = {
|
|
if (menuState.isExistingHighlight && menuState.highlightId != null) {
|
|
onHighlightDelete(menuState.highlightId)
|
|
}
|
|
onMenuDismiss()
|
|
},
|
|
onTts = {
|
|
onTts(selectionData.pageIndex, menuState.charRange.first)
|
|
onMenuDismiss()
|
|
},
|
|
onNote = {
|
|
if (menuState.isExistingHighlight && menuState.highlightId != null) {
|
|
onNote(menuState.highlightId)
|
|
} else {
|
|
onNote(null)
|
|
onHighlightAdd(
|
|
selectionData.pageIndex, menuState.charRange, menuState.selectedText,
|
|
PdfHighlightColor.YELLOW
|
|
)
|
|
}
|
|
onMenuDismiss()
|
|
},
|
|
customHighlightColors = selectionData.customHighlightColors.item,
|
|
onPaletteClick = onPaletteClick
|
|
)
|
|
}
|
|
}
|
|
|
|
BookmarkButton(
|
|
isBookmarked = isBookmarked,
|
|
onClick = onBookmarkClick,
|
|
modifier = Modifier
|
|
.align(Alignment.TopEnd)
|
|
.padding(top = centeringPaddingTop, end = centeringPaddingEnd)
|
|
)
|
|
|
|
if (isPerformingOcr && ocrRipplePos != null) {
|
|
OcrProcessingIndicator(position = ocrRipplePos)
|
|
}
|
|
|
|
if (isBubbleZoomModeActive && isActivePage) {
|
|
if (isDetectingBubbles) {
|
|
androidx.compose.material3.CircularProgressIndicator(
|
|
modifier = Modifier.align(Alignment.Center)
|
|
)
|
|
} else if (detectedBubbles.isNotEmpty()) {
|
|
Canvas(modifier = Modifier.fillMaxSize().zIndex(20f)) {
|
|
// Draw shadow-like hints for unexpanded bubbles
|
|
detectedBubbles.forEachIndexed { index, bubble ->
|
|
val hintAlpha = if (index == animatingBubbleIndex) 0.35f * (1f - bubbleExpansionProgress) else 0.35f
|
|
if (hintAlpha > 0f) {
|
|
val left = bubble.bounds.left + staticData.centeringOffsetX
|
|
val top = bubble.bounds.top + staticData.centeringOffsetY
|
|
val width = bubble.bounds.width()
|
|
val height = bubble.bounds.height()
|
|
|
|
if (bubble.maskBitmap != null) {
|
|
drawImage(
|
|
image = bubble.maskBitmap.asImageBitmap(),
|
|
dstOffset = IntOffset(left.toInt(), top.toInt()),
|
|
dstSize = IntSize(width.toInt(), height.toInt()),
|
|
colorFilter = ColorFilter.tint(Color.Black.copy(alpha = hintAlpha)),
|
|
filterQuality = androidx.compose.ui.graphics.FilterQuality.High
|
|
)
|
|
} else {
|
|
drawRoundRect(
|
|
color = Color.Black.copy(alpha = hintAlpha),
|
|
topLeft = Offset(left, top),
|
|
size = Size(width, height),
|
|
cornerRadius = androidx.compose.ui.geometry.CornerRadius(24f, 24f)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
if (animatingBubbleIndex in detectedBubbles.indices && staticData.bitmap.item != null && bubbleExpansionProgress > 0f) {
|
|
val baseBitmap = staticData.bitmap.item ?: return@Canvas
|
|
if (
|
|
!baseBitmap.isCanvasSafeBitmap(
|
|
maxBytes = PDF_MAX_DRAW_BITMAP_BYTES,
|
|
maxDimension = PDF_MAX_DRAW_BITMAP_DIMENSION_PX
|
|
)
|
|
) {
|
|
return@Canvas
|
|
}
|
|
val safeExpandedBubbleRender = expandedBubbleRender?.takeIf {
|
|
it.bitmap.isCanvasSafeBitmap(
|
|
maxBytes = PDF_MAX_DRAW_BITMAP_BYTES,
|
|
maxDimension = PDF_MAX_DRAW_BITMAP_DIMENSION_PX
|
|
)
|
|
}
|
|
val bubble = detectedBubbles[animatingBubbleIndex]
|
|
val left = bubble.bounds.left + staticData.centeringOffsetX
|
|
val top = bubble.bounds.top + staticData.centeringOffsetY
|
|
val logicalWidth = bubble.bounds.width()
|
|
val logicalHeight = bubble.bounds.height()
|
|
val pivotX = left + logicalWidth / 2f
|
|
val pivotY = top + logicalHeight / 2f
|
|
val targetZoomFactor = safeExpandedBubbleRender?.zoomFactor ?: computeDynamicBubbleZoomFactor(
|
|
bubbleBounds = bubble.bounds,
|
|
viewportWidth = staticData.canvasWidth,
|
|
viewportHeight = staticData.canvasHeight
|
|
)
|
|
val zoomFactor = androidx.compose.ui.util.lerp(1f, targetZoomFactor, bubbleExpansionProgress)
|
|
|
|
withTransform({
|
|
scale(zoomFactor, zoomFactor, Offset(pivotX, pivotY))
|
|
}) {
|
|
val dstOffset = IntOffset(left.toInt(), top.toInt())
|
|
val dstSize = IntSize(logicalWidth.toInt(), logicalHeight.toInt())
|
|
|
|
val renderScaleX = baseBitmap.width.toFloat() / staticData.targetWidth.toFloat()
|
|
val renderScaleY = baseBitmap.height.toFloat() / staticData.targetHeight.toFloat()
|
|
|
|
val srcOffset = IntOffset(
|
|
(bubble.bounds.left * renderScaleX).toInt(),
|
|
(bubble.bounds.top * renderScaleY).toInt()
|
|
)
|
|
val srcSize = IntSize(
|
|
(logicalWidth * renderScaleX).toInt(),
|
|
(logicalHeight * renderScaleY).toInt()
|
|
)
|
|
|
|
if (bubble.maskBitmap != null) {
|
|
drawImage(
|
|
image = bubble.maskBitmap.asImageBitmap(),
|
|
dstOffset = IntOffset(left.toInt() + 12, top.toInt() + 12),
|
|
dstSize = dstSize,
|
|
colorFilter = ColorFilter.tint(Color.Black.copy(alpha = 0.5f * bubbleExpansionProgress)),
|
|
filterQuality = androidx.compose.ui.graphics.FilterQuality.High
|
|
)
|
|
} else {
|
|
drawRoundRect(
|
|
color = Color.Black.copy(alpha = 0.5f * bubbleExpansionProgress),
|
|
topLeft = Offset(left + 12f, top + 12f),
|
|
size = Size(logicalWidth, logicalHeight),
|
|
cornerRadius = androidx.compose.ui.geometry.CornerRadius(24f, 24f)
|
|
)
|
|
}
|
|
|
|
if (bubble.maskBitmap != null) {
|
|
val rect = androidx.compose.ui.geometry.Rect(
|
|
dstOffset.x.toFloat(),
|
|
dstOffset.y.toFloat(),
|
|
dstOffset.x.toFloat() + dstSize.width,
|
|
dstOffset.y.toFloat() + dstSize.height
|
|
)
|
|
drawContext.canvas.saveLayer(rect, androidx.compose.ui.graphics.Paint())
|
|
drawImage(
|
|
image = (safeExpandedBubbleRender?.bitmap ?: baseBitmap).asImageBitmap(),
|
|
srcOffset = if (safeExpandedBubbleRender != null) IntOffset.Zero else srcOffset,
|
|
srcSize = if (safeExpandedBubbleRender != null) {
|
|
IntSize(
|
|
safeExpandedBubbleRender.bitmap.width,
|
|
safeExpandedBubbleRender.bitmap.height)
|
|
} else {
|
|
srcSize
|
|
},
|
|
dstOffset = dstOffset,
|
|
dstSize = dstSize,
|
|
filterQuality = androidx.compose.ui.graphics.FilterQuality.High
|
|
)
|
|
drawImage(
|
|
image = bubble.maskBitmap.asImageBitmap(),
|
|
dstOffset = dstOffset,
|
|
dstSize = dstSize,
|
|
blendMode = BlendMode.DstIn,
|
|
filterQuality = androidx.compose.ui.graphics.FilterQuality.High
|
|
)
|
|
drawContext.canvas.restore()
|
|
} else {
|
|
clipRect(left, top, left + logicalWidth, top + logicalHeight) {
|
|
drawImage(
|
|
image = (safeExpandedBubbleRender?.bitmap ?: baseBitmap).asImageBitmap(),
|
|
srcOffset = if (safeExpandedBubbleRender != null) IntOffset.Zero else srcOffset,
|
|
srcSize = if (safeExpandedBubbleRender != null) {
|
|
IntSize(
|
|
safeExpandedBubbleRender.bitmap.width,
|
|
safeExpandedBubbleRender.bitmap.height)
|
|
} else {
|
|
srcSize
|
|
},
|
|
dstOffset = dstOffset,
|
|
dstSize = dstSize
|
|
)
|
|
}
|
|
drawRect(
|
|
color = Color.White.copy(alpha = 0.5f * bubbleExpansionProgress),
|
|
topLeft = Offset(left, top),
|
|
size = Size(logicalWidth, logicalHeight),
|
|
style = Stroke(width = 4f)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@Stable
|
|
class PdfDrawingState {
|
|
var currentAnnotation by mutableStateOf<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?.withoutTrailingPdfPageBreakForRender()
|
|
}
|
|
|
|
if (textToRender != null) {
|
|
val measureResult = remember(textToRender, editorWidth, density) {
|
|
textMeasurer.measure(
|
|
text = textToRender,
|
|
style = TextStyle(fontSize = 16.sp),
|
|
constraints = Constraints(maxWidth = editorWidth.toInt()),
|
|
density = density
|
|
)
|
|
}
|
|
|
|
Canvas(modifier = Modifier.fillMaxSize()) {
|
|
measureResult.multiParagraph.paint(drawContext.canvas)
|
|
}
|
|
|
|
if (isTextEditingEnabled) {
|
|
val tfv = controller.editingValue
|
|
val selection = tfv.selection
|
|
|
|
@Suppress("ControlFlowWithEmptyBody") if (controller.activePageIndex == pageIndex) {
|
|
val localStart = selection.start.coerceIn(0, textToRender.length)
|
|
val localEnd = selection.end.coerceIn(0, textToRender.length)
|
|
|
|
if (localStart != localEnd) {
|
|
val selectionPath = measureResult.getPathForRange(localStart, localEnd)
|
|
Canvas(modifier = Modifier.fillMaxSize()) {
|
|
drawPath(selectionPath, Color(0xFFB3D7FF).copy(alpha = 0.5f))
|
|
}
|
|
}
|
|
|
|
if (selection.collapsed && controller.isCursorVisible) {
|
|
val alpha = if (isScrolling) {
|
|
1f
|
|
} else {
|
|
val infiniteTransition = rememberInfiniteTransition(label = "cursor")
|
|
infiniteTransition.animateFloat(
|
|
initialValue = 1f,
|
|
targetValue = 0f,
|
|
animationSpec = infiniteRepeatable(tween(500), RepeatMode.Reverse),
|
|
label = "cursorAlpha"
|
|
).value
|
|
}
|
|
|
|
val cursorRect = measureResult.getCursorRect(localStart)
|
|
val cursorColor = if (isDarkMode) Color.White else Color.Black
|
|
val styleFontSize = controller.currentStyle.fontSize
|
|
val cursorHeight = if (styleFontSize.isSpecified) {
|
|
with(density) { styleFontSize.toPx() } * 1.2f
|
|
} else {
|
|
cursorRect.height
|
|
}
|
|
|
|
val centerY = cursorRect.center.y
|
|
val newTop = centerY - (cursorHeight / 2f)
|
|
val newBottom = centerY + (cursorHeight / 2f)
|
|
|
|
Canvas(modifier = Modifier.fillMaxSize()) {
|
|
drawLine(
|
|
color = cursorColor.copy(alpha = alpha),
|
|
start = Offset(cursorRect.left, newTop),
|
|
end = Offset(cursorRect.left, newBottom),
|
|
strokeWidth = 2.dp.toPx()
|
|
)
|
|
}
|
|
}
|
|
} else if (pageLayout != null) { }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private fun AnnotatedString.withoutTrailingPdfPageBreakForRender(): AnnotatedString {
|
|
return if (text.lastOrNull() == PAGE_BREAK_CHAR) {
|
|
subSequence(0, length - 1)
|
|
} else {
|
|
this
|
|
}
|
|
}
|
|
|
|
private fun getNativePointer(obj: Any): Long {
|
|
val priorityFields = listOf("pagePtr", "mNativePage", "page")
|
|
|
|
for (name in priorityFields) {
|
|
try {
|
|
val field = obj.javaClass.getDeclaredField(name)
|
|
field.isAccessible = true
|
|
val value = field.get(obj)
|
|
if (value is Long && value != 0L) return value
|
|
if (value != null && value !is Long) {
|
|
val nestedPtr = getNativePointer(value)
|
|
if (nestedPtr != 0L) return nestedPtr
|
|
}
|
|
} catch (_: Exception) {}
|
|
}
|
|
|
|
try {
|
|
for (field in obj.javaClass.declaredFields) {
|
|
if (field.type == Long::class.java || field.type == Long::class.javaPrimitiveType) {
|
|
field.isAccessible = true
|
|
val value = field.get(obj) as Long
|
|
if (value > 0xFFFFFFFFL) return value
|
|
}
|
|
}
|
|
} catch (_: Exception) {}
|
|
|
|
return 0L
|
|
}
|