V1.0.46 oss (#253)

* add a UI trigger for demo annotations

* Implement internal link navigation

* Simplify bottom padding logic in `EpubReaderScreen` for vertical scroll mode

* Add support for Calibre metadata

* Implement advanced library management with Room-backed collections, tags, and smart rules.

* Display book series information in book details and remove tags from home screen

* Implement zoom reset functionality in PDF viewer

* Add a "Pages" thumbnail view to the PDF navigation drawer

* Implement jump-back navigation in PDF viewer

* MainViewModel: disable FolderSyncWorker, add log export, and enhance PDF position logging

* Replace Gson with Kotlin Serialization in SmartCollectionEngine

* Implement ONNX-based speech bubble detection

* Implement speech bubble detection in PDF viewer

* Implement "Smart Comic Zoom" feature for PDF manga/comic reading

* Implement reader session persistence and restoration in `MainViewModel`

* Add tap-to-turn page feature to PDF viewer

* Improve book cache management and recovery

* Refactor top overlay padding logic in `PdfViewerScreen`

* Implement stylus eraser support in PDF reader

* Refactor ReaderTextFormatPanel to use ModalBottomSheet and add new formatting controls

* Introduce a customizable horizontal margin setting for the EPUB reader

* Refine folder sync and metadata handling for better conflict resolution and stability.

* Introduce user-adjustable image scaling for the EPUB reader

* Use maxWidthPx as default width fallback for blocks

* Improve cross-page text selection and header styling in the paginated reader

* Refactor speech bubble detection and UI to support segmentation masks and interactive scaling

* Improve speech bubble detection masking and rendering quality

* Update ONNX speech bubble detector to use `.ort` model and optimize inference

* Improve pagination accuracy

* Implement hierarchical folder navigation for the library.

* Refactor library item layout for improved space efficiency

* Add support for browsing and downloading Google Fonts

* Persist library landing state and add shelf search functionality

* Fix base tts sample.

* Move SpeechBubbleDetector to main source set and update ONNX dependency

* Implement a "locate" feature and improve synchronization for TTS (Text-to-Speech) playback across EPUB and PDF readers.

* Refine TTS synchronization and voice management in the EPUB reader

* Adjust OCR checks for OSS flavor and optimize external dictionary intent flags

* Add "Locate" button to PDF drawer's pages tab and move the page number to bottom right of thumbnail

* fix various crashes

* Refactor SpeechBubbleDetector into product flavors

* Implement speech bubble detection caching and background prefetching

* Implement on-demand download for Bubble Zoom ML model

* Add smooth animation for PDF speech bubble expansion

* fixes #252

* hide Google Fonts option, in FontsScreen, for offline variant

* Bump version to 1.0.46(46)
This commit is contained in:
Aryan 2026-04-29 16:37:53 +05:30 committed by GitHub
parent 579b6f25bf
commit d16bdd70d4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
67 changed files with 9724 additions and 1890 deletions

View file

@ -13,6 +13,7 @@ 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
@ -83,6 +84,9 @@ 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
@ -115,6 +119,7 @@ import androidx.core.graphics.scale
import androidx.core.graphics.set
import com.aryan.reader.R
import com.aryan.reader.SearchResult
import com.aryan.reader.ml.SpeechBubble
import com.aryan.reader.pdf.data.PdfAnnotation
import com.aryan.reader.pdf.data.PdfTextBox
import com.aryan.reader.pdf.data.VirtualPage
@ -187,6 +192,78 @@ data class PageLink(
val source: LinkSource
)
private data class ExpandedBubbleRender(
val bitmap: Bitmap,
val zoomFactor: Float
)
private fun computeDynamicBubbleZoomFactor(
bubbleBounds: RectF,
viewportWidth: Float,
viewportHeight: Float
): Float {
if (bubbleBounds.width() <= 0f || bubbleBounds.height() <= 0f) return 1.5f
val targetWidth = viewportWidth * 0.6f
val targetHeight = viewportHeight * 0.32f
return min(targetWidth / bubbleBounds.width(), targetHeight / bubbleBounds.height())
.coerceIn(1.35f, 4.25f)
}
private fun isTapInsideBubble(
bubble: SpeechBubble,
tapX: Float,
tapY: Float,
hitSlopPx: Float
): Boolean {
val expandedBounds = RectF(bubble.bounds)
expandedBounds.inset(-hitSlopPx, -hitSlopPx)
if (!expandedBounds.contains(tapX, tapY)) return false
val mask = bubble.maskBitmap ?: return true
if (!bubble.bounds.contains(tapX, tapY)) return true
val normalizedX = ((tapX - bubble.bounds.left) / bubble.bounds.width()).coerceIn(0f, 0.999f)
val normalizedY = ((tapY - bubble.bounds.top) / bubble.bounds.height()).coerceIn(0f, 0.999f)
val maskX = (normalizedX * mask.width).toInt().coerceIn(0, mask.width - 1)
val maskY = (normalizedY * mask.height).toInt().coerceIn(0, mask.height - 1)
return AndroidColor.alpha(mask.getPixel(maskX, maskY)) > 24
}
private suspend fun renderExpandedBubbleBitmap(
document: ReaderDocument,
pageIndex: Int,
bubbleBounds: RectF,
pageWidth: Int,
pageHeight: Int,
renderScale: Float
): Bitmap? = withContext(Dispatchers.IO) {
if (pageWidth <= 0 || pageHeight <= 0 || bubbleBounds.width() <= 0f || bubbleBounds.height() <= 0f) {
return@withContext null
}
document.openPage(pageIndex)?.use { page ->
val cropWidth = (bubbleBounds.width() * renderScale).roundToInt().coerceAtLeast(1)
val cropHeight = (bubbleBounds.height() * renderScale).roundToInt().coerceAtLeast(1)
val bitmap = createBitmap(cropWidth, cropHeight)
try {
page.renderPageBitmap(
bitmap = bitmap,
startX = (-bubbleBounds.left * renderScale).roundToInt(),
startY = (-bubbleBounds.top * renderScale).roundToInt(),
drawSizeX = (pageWidth * renderScale).roundToInt().coerceAtLeast(cropWidth),
drawSizeY = (pageHeight * renderScale).roundToInt().coerceAtLeast(cropHeight),
renderAnnot = true
)
bitmap
} catch (t: Throwable) {
bitmap.recycle()
Timber.tag("BubbleZoom").w(t, "Failed to render expanded bubble bitmap for page $pageIndex")
null
}
}
}
object PdfInkGeometry {
fun calculateFountainPenPoints(
points: List<PdfPoint>, baseWidth: Float, pageWidth: Float, pageHeight: Float
@ -394,7 +471,8 @@ internal fun PdfPageComposable(
searchHighlightMode: SearchHighlightMode = SearchHighlightMode.ALL,
searchResultToHighlight: SearchResult?,
ocrHoverHighlights: StableHolder<List<RectF>> = StableHolder(emptyList()),
onSingleTap: () -> Unit,
onPreSingleTap: ((Offset) -> Boolean)? = null,
onSingleTap: (Offset?) -> Unit,
isProUser: Boolean,
onShowDictionaryUpsellDialog: () -> Unit,
onWordSelectedForAiDefinition: (String) -> Unit,
@ -415,6 +493,7 @@ internal fun PdfPageComposable(
isVerticalScroll: Boolean = false,
visualScaleProvider: () -> Float = { 1f },
clearSelectionTrigger: Long = 0L,
resetZoomTrigger: Long = 0L,
onTtsHighlightCenterCalculated: ((Float) -> Unit)? = null,
onSearchHighlightCenterCalculated: ((Float) -> Unit)? = null,
activeTheme: com.aryan.reader.ReaderTheme = com.aryan.reader.ReaderTheme("no_theme", "No Theme", Color.Unspecified, Color.Unspecified, false),
@ -423,8 +502,8 @@ internal fun PdfPageComposable(
isEditMode: Boolean = false,
drawingState: PdfDrawingState? = null,
pageAnnotations: () -> List<PdfAnnotation> = { emptyList() },
onDrawStart: (PdfPoint) -> Unit = {},
onDraw: (PdfPoint) -> Unit = {},
onDrawStart: (PdfPoint, Boolean) -> Unit = { _, _ -> },
onDraw: (PdfPoint, Boolean) -> Unit = { _, _ -> },
onDrawEnd: () -> Unit = {},
visibleScreenRect: () -> IntRect? = { null },
selectedTool: InkType = InkType.PEN,
@ -441,6 +520,7 @@ internal fun PdfPageComposable(
isScrollLocked: Boolean = false,
isVisible: Boolean = true,
isActivePage: Boolean = true,
isBubbleZoomModeActive: Boolean = false,
isStylusOnlyMode: Boolean = false,
isAutoScrollPlaying: Boolean = false,
isHighlighterSnapEnabled: Boolean = false,
@ -455,7 +535,7 @@ internal fun PdfPageComposable(
onPaletteClick: (() -> Unit)? = null,
lockedState: Triple<Float, Float, Float>? = null,
onZoomAndPanChanged: ((Float, Offset) -> Unit)? = null,
onDetectPanels: suspend (Bitmap) -> List<android.graphics.RectF> = { emptyList() },
onDetectBubbles: suspend (Int, Bitmap) -> List<SpeechBubble> = { _, _ -> emptyList() },
onShowPanelPopup: (Bitmap) -> Unit = {}
) {
val pdfDocumentItem = pdfDocument.item
@ -475,10 +555,7 @@ internal fun PdfPageComposable(
LocalContext.current
val viewConfiguration = LocalViewConfiguration.current
val coroutineScope = rememberCoroutineScope()
Timber.d(
"PdfPageComposable recompose: page=$pageIndex, isScrolling=$isScrolling, visualScale=$visualScaleProvider"
)
var isStylusEraserOverride by remember { mutableStateOf(false) }
var layoutCoordinates by remember { mutableStateOf<LayoutCoordinates?>(null) }
@ -493,6 +570,7 @@ internal fun PdfPageComposable(
}
val currentOnSingleTap by rememberUpdatedState(onSingleTap)
val currentOnPreSingleTap by rememberUpdatedState(onPreSingleTap)
val currentOnDoubleTap by rememberUpdatedState(onDoubleTap)
val effectiveScale = if (isZoomEnabled && !isVerticalScroll) scale else externalScale
@ -618,9 +696,7 @@ internal fun PdfPageComposable(
}
LaunchedEffect(centeringOffsetX, centeringOffsetY, pageIndex) {
Timber.d(
"PdfPageComposable Page $pageIndex | Centering Offset: x=$centeringOffsetX, y=$centeringOffsetY"
)
}
var showMagnifier by remember { mutableStateOf(false) }
@ -646,6 +722,132 @@ internal fun PdfPageComposable(
screenOffset
}
var detectedBubbles by remember(targetPageId) { mutableStateOf<List<SpeechBubble>>(emptyList()) }
var expandedBubbleIndex by remember(targetPageId) { mutableIntStateOf(-1) }
var animatingBubbleIndex by remember(targetPageId) { mutableIntStateOf(-1) }
val bubbleExpansionProgress = remember(targetPageId) { Animatable(0f) }
var isDetectingBubbles by remember(targetPageId) { mutableStateOf(false) }
var expandedBubbleRender by remember(targetPageId) { mutableStateOf<ExpandedBubbleRender?>(null) }
val currentDetectedBubbles by rememberUpdatedState(detectedBubbles)
val currentExpandedBubbleIndex by rememberUpdatedState(expandedBubbleIndex)
val currentBubbleZoomModeActive by rememberUpdatedState(isBubbleZoomModeActive)
val bubbleTapSlopPx = with(density) { 18.dp.toPx() }
LaunchedEffect(expandedBubbleIndex) {
if (expandedBubbleIndex != -1) {
if (animatingBubbleIndex != -1 && animatingBubbleIndex != expandedBubbleIndex) {
bubbleExpansionProgress.animateTo(0f, tween(150))
}
animatingBubbleIndex = expandedBubbleIndex
bubbleExpansionProgress.animateTo(1f, tween(250, easing = androidx.compose.animation.core.FastOutSlowInEasing))
} else {
bubbleExpansionProgress.animateTo(0f, tween(200, easing = androidx.compose.animation.core.FastOutLinearInEasing))
animatingBubbleIndex = -1
}
}
LaunchedEffect(
isBubbleZoomModeActive,
isActivePage,
isPdfPage,
pdfPageIndex,
bitmapState,
actualBitmapWidthPx,
actualBitmapHeightPx
) {
Timber.tag("BubbleZoom").d("LaunchedEffect triggered. modeActive=$isBubbleZoomModeActive, activePage=$isActivePage, hasBitmap=${bitmapState != null}, dims=${actualBitmapWidthPx}x${actualBitmapHeightPx}")
if (isBubbleZoomModeActive && isActivePage && isPdfPage && bitmapState != null && actualBitmapWidthPx > 0 && actualBitmapHeightPx > 0) {
Timber.tag("BubbleZoom").d("Conditions met. Starting detection...")
isDetectingBubbles = true
try {
val rawBubbles = onDetectBubbles(pdfPageIndex, bitmapState!!)
Timber.tag("BubbleZoom").d("Detection complete. Found ${rawBubbles.size} raw bubbles.")
// NEW: Scale bubbles down from render bitmap space to logical screen space
val scaleX = actualBitmapWidthPx.toFloat() / bitmapState!!.width.toFloat()
val scaleY = actualBitmapHeightPx.toFloat() / bitmapState!!.height.toFloat()
val logicalBubbles = rawBubbles.map { b ->
b.copy(bounds = android.graphics.RectF(
b.bounds.left * scaleX,
b.bounds.top * scaleY,
b.bounds.right * scaleX,
b.bounds.bottom * scaleY
))
}
val rowHeight = actualBitmapHeightPx * 0.1f
detectedBubbles = logicalBubbles.sortedWith(compareBy<SpeechBubble> { (it.bounds.centerY() / rowHeight).roundToInt() }.thenBy { it.bounds.centerX() })
expandedBubbleIndex = -1
Timber.tag("BubbleZoom").d("Sorted logical bubbles count: ${detectedBubbles.size}")
} catch (e: Exception) {
Timber.tag("BubbleZoom").e(e, "Bubble detection failed with exception")
} finally {
isDetectingBubbles = false
}
} else {
Timber.tag("BubbleZoom").d("Conditions NOT met or mode disabled. Clearing bubbles.")
detectedBubbles = emptyList()
expandedBubbleIndex = -1
expandedBubbleRender?.bitmap?.takeUnless { it.isRecycled }?.recycle()
expandedBubbleRender = null
if (!isBubbleZoomModeActive && scale > 1f && !isVerticalScroll && isZoomEnabled) {
coroutineScope.launch {
Animatable(scale).animateTo(1f, tween(300)) {
scale = this.value
offset = Offset.Zero
onScaleChanged(scale)
}
}
}
}
}
LaunchedEffect(
animatingBubbleIndex,
detectedBubbles,
actualBitmapWidthPx,
actualBitmapHeightPx,
canvasWidthPx.floatValue,
canvasHeightPx.floatValue,
isBubbleZoomModeActive,
isPdfPage,
pdfPageIndex
) {
val previousRender = expandedBubbleRender
expandedBubbleRender = null
previousRender?.bitmap?.takeUnless { it.isRecycled }?.recycle()
if (!isBubbleZoomModeActive || !isPdfPage || animatingBubbleIndex !in detectedBubbles.indices) {
return@LaunchedEffect
}
val bubble = detectedBubbles[animatingBubbleIndex]
val zoomFactor = computeDynamicBubbleZoomFactor(
bubbleBounds = bubble.bounds,
viewportWidth = canvasWidthPx.floatValue.coerceAtLeast(actualBitmapWidthPx.toFloat()),
viewportHeight = canvasHeightPx.floatValue.coerceAtLeast(actualBitmapHeightPx.toFloat())
)
val renderScale = (zoomFactor * 1.2f).coerceAtLeast(1.6f)
val renderedBubble = renderExpandedBubbleBitmap(
document = pdfDocumentItem,
pageIndex = pdfPageIndex,
bubbleBounds = bubble.bounds,
pageWidth = actualBitmapWidthPx,
pageHeight = actualBitmapHeightPx,
renderScale = renderScale
)
if (renderedBubble != null) {
expandedBubbleRender = ExpandedBubbleRender(
bitmap = renderedBubble,
zoomFactor = zoomFactor
)
}
}
DisposableEffect(Unit) {
onDispose {
val currentBitmap = bitmapState
@ -653,6 +855,7 @@ internal fun PdfPageComposable(
if (currentBitmap != null && !currentBitmap.isRecycled && currentBitmap !== cachedBitmap) {
currentBitmap.recycle()
}
expandedBubbleRender?.bitmap?.takeUnless { it.isRecycled }?.recycle()
}
}
@ -1646,6 +1849,32 @@ internal fun PdfPageComposable(
}
}
LaunchedEffect(resetZoomTrigger) {
if (resetZoomTrigger != 0L && scale > 1f && isZoomEnabled && !isVerticalScroll && !isScrollLocked) {
coroutineScope.launch {
val startScale = scale
val startOffset = offset
Animatable(0f).animateTo(
1f, animationSpec = tween(durationMillis = 300)
) {
val progress = value
scale = androidx.compose.ui.util.lerp(
startScale, 1f, progress
)
offset = androidx.compose.ui.geometry.lerp(
startOffset, Offset.Zero, progress
)
onScaleChanged(scale)
}
if (scale <= 1.05f) {
scale = 1f
offset = Offset.Zero
onScaleChanged(scale)
}
}
}
}
val errorSelection = stringResource(R.string.error_selection)
val errorOcrSelection = stringResource(R.string.error_ocr_selection)
val errorProcessingPage = stringResource(R.string.error_processing_page)
@ -2487,7 +2716,8 @@ internal fun PdfPageComposable(
isEditMode,
selectedTool,
isStylusOnlyMode,
userHighlightScreenRects
userHighlightScreenRects,
bubbleTapSlopPx
) {
val isTapDetectionAllowed = !isEditMode ||
selectedTool == InkType.TEXT ||
@ -2496,9 +2726,48 @@ internal fun PdfPageComposable(
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) {
@ -2660,7 +2929,7 @@ internal fun PdfPageComposable(
currentPageRotation,
)
} else {
currentOnSingleTap()
currentOnSingleTap(tapOffset)
}
}
}, onDoubleTap = { tapOffset ->
@ -2670,37 +2939,6 @@ internal fun PdfPageComposable(
val startScale = scale
val targetScale = if (startScale > 1.1f) 1f else 2.5f
if (com.aryan.reader.BuildConfig.DEBUG && startScale <= 1.1f && bitmapState != null) {
val tapInContentCoords = screenToContentCoordinates(tapOffset)
val ratioX = bitmapState!!.width.toFloat() / actualBitmapWidthPx.toFloat()
val ratioY = bitmapState!!.height.toFloat() / actualBitmapHeightPx.toFloat()
val tapXInBitmap = tapInContentCoords.x * ratioX
val tapYInBitmap = tapInContentCoords.y * ratioY
val panels = onDetectPanels(bitmapState!!)
val tappedPanel = panels.firstOrNull {
it.contains(tapXInBitmap, tapYInBitmap)
}
if (tappedPanel != null) {
Timber.d("Popup: Cropping panel $tappedPanel")
val left = tappedPanel.left.coerceAtLeast(0f).toInt()
val top = tappedPanel.top.coerceAtLeast(0f).toInt()
val right = tappedPanel.right.coerceAtMost(bitmapState!!.width.toFloat()).toInt()
val bottom = tappedPanel.bottom.coerceAtMost(bitmapState!!.height.toFloat()).toInt()
val width = right - left
val height = bottom - top
if (width > 0 && height > 0) {
val cropped = android.graphics.Bitmap.createBitmap(bitmapState!!, left, top, width, height)
onShowPanelPopup(cropped)
return@launch
}
}
}
val startOffset = offset
val targetOffsetUnbounded = if (targetScale <= 1.1f) {
Offset.Zero
@ -3017,12 +3255,20 @@ internal fun PdfPageComposable(
return@awaitEachGesture
}
val buttons = currentEvent.buttons
Timber.tag("StylusEraserDiagnostic").d(
"Page $pageIndex | Type: ${down.type} | isPrimary: ${buttons.isPrimaryPressed} | isSecondary: ${buttons.isSecondaryPressed} | isTertiary: ${buttons.isTertiaryPressed} | buttonsString: $buttons"
)
val isEraserOverride = down.type == PointerType.Eraser || (down.type == PointerType.Stylus && currentEvent.buttons.isSecondaryPressed)
isStylusEraserOverride = isEraserOverride
val dragPointerId = down.id
val startPos = down.position
var dragStarted = false
val touchSlop = viewConfiguration.touchSlop
if (selectedTool == InkType.ERASER) {
if (selectedTool == InkType.ERASER || isEraserOverride) {
eraserPosition = down.position
}
@ -3034,6 +3280,7 @@ internal fun PdfPageComposable(
drawingState?.onDrawCancel()
}
eraserPosition = null
isStylusEraserOverride = false
return@awaitEachGesture
}
@ -3051,12 +3298,13 @@ internal fun PdfPageComposable(
val normY =
(contentPos.y / actualBitmapHeightPx).coerceIn(0f, 1f)
onDrawStart(PdfPoint(normX, normY))
onDrawStart(PdfPoint(normX, normY), isEraserOverride)
onDrawEnd()
} else {
onDrawEnd()
}
eraserPosition = null
isStylusEraserOverride = false
return@awaitEachGesture
}
@ -3077,7 +3325,7 @@ internal fun PdfPageComposable(
0f, 1f
)
onDrawStart(
PdfPoint(startNormX, startNormY)
PdfPoint(startNormX, startNormY), isEraserOverride
)
val currContentPos = screenToContentCoordinates(
@ -3091,9 +3339,9 @@ internal fun PdfPageComposable(
(currContentPos.y / actualBitmapHeightPx).coerceIn(
0f, 1f
)
onDraw(PdfPoint(currNormX, currNormY))
onDraw(PdfPoint(currNormX, currNormY), isEraserOverride)
if (selectedTool == InkType.ERASER) {
if (selectedTool == InkType.ERASER || isEraserOverride) {
eraserPosition = change.position
}
change.consume()
@ -3106,9 +3354,9 @@ internal fun PdfPageComposable(
(currContentPos.x / actualBitmapWidthPx).coerceIn(0f, 1f)
val currNormY =
(currContentPos.y / actualBitmapHeightPx).coerceIn(0f, 1f)
onDraw(PdfPoint(currNormX, currNormY))
onDraw(PdfPoint(currNormX, currNormY), isEraserOverride)
if (selectedTool == InkType.ERASER) {
if (selectedTool == InkType.ERASER || isEraserOverride) {
eraserPosition = change.position
}
change.consume()
@ -3118,6 +3366,7 @@ internal fun PdfPageComposable(
}
} finally {
eraserPosition = null
isStylusEraserOverride = false
}
}, contentAlignment = Alignment.Center
) {
@ -3230,10 +3479,6 @@ internal fun PdfPageComposable(
offset = Offset.Zero
onScaleChanged(1f)
}
Timber.d(
"PdfPageComposable Page $pageIndex initialized/resized/locked. scale=$scale, offset=$offset"
)
}
LaunchedEffect(
@ -3386,15 +3631,8 @@ internal fun PdfPageComposable(
val viewContainerHeightPx =
with(density) { currentContainerMaxHeight.toPx().toInt() }
Timber.d(
"PdfPageComposable Page $pageIndex | viewContainerPx: ${viewContainerWidthPx}x${viewContainerHeightPx}"
)
if (viewContainerWidthPx <= 0 || viewContainerHeightPx <= 0) {
if (bitmapState == null) isLoadingPage = true
Timber.d(
"PdfPageComposable: viewContainer dimensions invalid ($viewContainerWidthPx x $viewContainerHeightPx), waiting."
)
return@LaunchedEffect
}
@ -3948,6 +4186,7 @@ internal fun PdfPageComposable(
isEditMode = isEditMode,
selectedTool = selectedTool,
eraserPosition = eraserPosition,
isStylusEraserOverride = isStylusEraserOverride,
activeToolThickness = activeToolThickness,
richTextController = richTextController,
textBoxes = textBoxes,
@ -3960,7 +4199,14 @@ internal fun PdfPageComposable(
onDragPageTurn = onDragPageTurn,
draggingBoxId = draggingBoxId,
customHighlightColors = customHighlightColors,
onPaletteClick = onPaletteClick
onPaletteClick = onPaletteClick,
isBubbleZoomModeActive = isBubbleZoomModeActive,
isActivePage = isActivePage,
isDetectingBubbles = isDetectingBubbles,
detectedBubbles = detectedBubbles,
animatingBubbleIndex = animatingBubbleIndex,
bubbleExpansionProgress = bubbleExpansionProgress.value,
expandedBubbleRender = expandedBubbleRender
)
}
@ -4080,15 +4326,10 @@ private fun PdfBitmapLayer(
if (excludeImages && colorFilter != null && imageRects.isNotEmpty()) {
imageRects.forEach { imgRect ->
val scaledImgRectLeft = (imgRect.left * effectiveScale).roundToInt()
val scaledImgRectTop = (imgRect.top * effectiveScale).roundToInt()
val scaledImgRectRight = (imgRect.right * effectiveScale).roundToInt()
val scaledImgRectBottom = (imgRect.bottom * effectiveScale).roundToInt()
val intersectLeft = max(scaledImgRectLeft, tile.renderRect.left)
val intersectTop = max(scaledImgRectTop, tile.renderRect.top)
val intersectRight = min(scaledImgRectRight, tile.renderRect.right)
val intersectBottom = min(scaledImgRectBottom, tile.renderRect.bottom)
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
@ -4151,7 +4392,6 @@ private fun PdfHighlightsLayer(
selectionHighlightColor: Color,
customHighlightColors: Map<PdfHighlightColor, Color> = emptyMap()
) {
Timber.d("PdfHighlightsLayer Recompose")
Canvas(modifier = Modifier
.fillMaxSize()
.graphicsLayer()) {
@ -4751,6 +4991,7 @@ private fun PdfPageRenderer(
isEditMode: Boolean,
selectedTool: InkType,
eraserPosition: Offset?,
isStylusEraserOverride: Boolean,
richTextController: RichTextController?,
textBoxes: List<PdfTextBox>,
selectedTextBoxId: String?,
@ -4769,6 +5010,13 @@ private fun PdfPageRenderer(
onTts: (Int, Int) -> Unit,
activeToolThickness: Float,
onNote: (String?) -> Unit,
isBubbleZoomModeActive: Boolean = false,
isActivePage: Boolean = true,
isDetectingBubbles: Boolean = false,
detectedBubbles: List<SpeechBubble> = emptyList(),
animatingBubbleIndex: Int = -1,
bubbleExpansionProgress: Float = 0f,
expandedBubbleRender: ExpandedBubbleRender? = null
) {
Box(modifier = Modifier.fillMaxSize()) {
Box(
@ -4976,7 +5224,7 @@ private fun PdfPageRenderer(
val teardropPainter = painterResource(id = R.drawable.teardrop)
if (isEditMode && selectedTool == InkType.ERASER && eraserPosition != null) {
if (isEditMode && (selectedTool == InkType.ERASER || isStylusEraserOverride) && eraserPosition != null) {
Canvas(modifier = Modifier.fillMaxSize()) {
val radiusPx = if (activeToolThickness > 0f && staticData.targetWidth > 0) {
activeToolThickness * staticData.targetWidth * scale // Calculate dynamic size based on tool settings scale
@ -5203,6 +5451,150 @@ private fun PdfPageRenderer(
if (isPerformingOcr && ocrRipplePos != null) {
OcrProcessingIndicator(position = ocrRipplePos)
}
if (isBubbleZoomModeActive && isActivePage) {
if (isDetectingBubbles) {
androidx.compose.material3.CircularProgressIndicator(
modifier = Modifier.align(Alignment.Center)
)
} else if (detectedBubbles.isNotEmpty()) {
Canvas(modifier = Modifier.fillMaxSize().zIndex(20f)) {
// Draw shadow-like hints for unexpanded bubbles
detectedBubbles.forEachIndexed { index, bubble ->
val hintAlpha = if (index == animatingBubbleIndex) 0.35f * (1f - bubbleExpansionProgress) else 0.35f
if (hintAlpha > 0f) {
val left = bubble.bounds.left + staticData.centeringOffsetX
val top = bubble.bounds.top + staticData.centeringOffsetY
val width = bubble.bounds.width()
val height = bubble.bounds.height()
if (bubble.maskBitmap != null) {
drawImage(
image = bubble.maskBitmap.asImageBitmap(),
dstOffset = IntOffset(left.toInt(), top.toInt()),
dstSize = IntSize(width.toInt(), height.toInt()),
colorFilter = ColorFilter.tint(Color.Black.copy(alpha = hintAlpha)),
filterQuality = androidx.compose.ui.graphics.FilterQuality.High
)
} else {
drawRoundRect(
color = Color.Black.copy(alpha = hintAlpha),
topLeft = Offset(left, top),
size = Size(width, height),
cornerRadius = androidx.compose.ui.geometry.CornerRadius(24f, 24f)
)
}
}
}
if (animatingBubbleIndex in detectedBubbles.indices && staticData.bitmap.item != null && bubbleExpansionProgress > 0f) {
val bubble = detectedBubbles[animatingBubbleIndex]
val left = bubble.bounds.left + staticData.centeringOffsetX
val top = bubble.bounds.top + staticData.centeringOffsetY
val logicalWidth = bubble.bounds.width()
val logicalHeight = bubble.bounds.height()
val pivotX = left + logicalWidth / 2f
val pivotY = top + logicalHeight / 2f
val targetZoomFactor = expandedBubbleRender?.zoomFactor ?: computeDynamicBubbleZoomFactor(
bubbleBounds = bubble.bounds,
viewportWidth = staticData.canvasWidth,
viewportHeight = staticData.canvasHeight
)
val zoomFactor = androidx.compose.ui.util.lerp(1f, targetZoomFactor, bubbleExpansionProgress)
withTransform({
scale(zoomFactor, zoomFactor, Offset(pivotX, pivotY))
}) {
val dstOffset = IntOffset(left.toInt(), top.toInt())
val dstSize = IntSize(logicalWidth.toInt(), logicalHeight.toInt())
val renderScaleX = staticData.bitmap.item.width.toFloat() / staticData.targetWidth.toFloat()
val renderScaleY = staticData.bitmap.item.height.toFloat() / staticData.targetHeight.toFloat()
val srcOffset = IntOffset(
(bubble.bounds.left * renderScaleX).toInt(),
(bubble.bounds.top * renderScaleY).toInt()
)
val srcSize = IntSize(
(logicalWidth * renderScaleX).toInt(),
(logicalHeight * renderScaleY).toInt()
)
if (bubble.maskBitmap != null) {
drawImage(
image = bubble.maskBitmap.asImageBitmap(),
dstOffset = IntOffset(left.toInt() + 12, top.toInt() + 12),
dstSize = dstSize,
colorFilter = ColorFilter.tint(Color.Black.copy(alpha = 0.5f * bubbleExpansionProgress)),
filterQuality = androidx.compose.ui.graphics.FilterQuality.High
)
} else {
drawRoundRect(
color = Color.Black.copy(alpha = 0.5f * bubbleExpansionProgress),
topLeft = Offset(left + 12f, top + 12f),
size = Size(logicalWidth, logicalHeight),
cornerRadius = androidx.compose.ui.geometry.CornerRadius(24f, 24f)
)
}
if (bubble.maskBitmap != null) {
val rect = androidx.compose.ui.geometry.Rect(
dstOffset.x.toFloat(),
dstOffset.y.toFloat(),
dstOffset.x.toFloat() + dstSize.width,
dstOffset.y.toFloat() + dstSize.height
)
drawContext.canvas.saveLayer(rect, androidx.compose.ui.graphics.Paint())
drawImage(
image = (expandedBubbleRender?.bitmap ?: staticData.bitmap.item).asImageBitmap(),
srcOffset = if (expandedBubbleRender != null) IntOffset.Zero else srcOffset,
srcSize = if (expandedBubbleRender != null) {
IntSize(
expandedBubbleRender.bitmap.width,
expandedBubbleRender.bitmap.height)
} else {
srcSize
},
dstOffset = dstOffset,
dstSize = dstSize,
filterQuality = androidx.compose.ui.graphics.FilterQuality.High
)
drawImage(
image = bubble.maskBitmap.asImageBitmap(),
dstOffset = dstOffset,
dstSize = dstSize,
blendMode = BlendMode.DstIn,
filterQuality = androidx.compose.ui.graphics.FilterQuality.High
)
drawContext.canvas.restore()
} else {
clipRect(left, top, left + logicalWidth, top + logicalHeight) {
drawImage(
image = (expandedBubbleRender?.bitmap ?: staticData.bitmap.item).asImageBitmap(),
srcOffset = if (expandedBubbleRender != null) IntOffset.Zero else srcOffset,
srcSize = if (expandedBubbleRender != null) {
IntSize(
expandedBubbleRender.bitmap.width,
expandedBubbleRender.bitmap.height)
} else {
srcSize
},
dstOffset = dstOffset,
dstSize = dstSize
)
}
drawRect(
color = Color.White.copy(alpha = 0.5f * bubbleExpansionProgress),
topLeft = Offset(left, top),
size = Size(logicalWidth, logicalHeight),
style = Stroke(width = 4f)
)
}
}
}
}
}
}
}
}
@ -5409,4 +5801,4 @@ private fun getNativePointer(obj: Any): Long {
} catch (_: Exception) {}
return 0L
}
}