v1.0.45-oss (#221)

* Implemented ML-based comic panel detection and a panel popup viewer.

* Optimized `ComicPanelDetector` for performance and memory efficiency.

* Optimized comic panel detection performance by introducing a dedicated single-thread dispatcher for ML tasks, replacing mutex-based synchronization.

* Refactored system UI handling and layout padding in `PdfViewerScreen`.

* Refactor `PdfViewerScreen.kt` by extracting components and logic into specialized files.

* replace hardcoded padding with dynamic header height and adjust IME layout logic

* Improve `PdfTextBox` interaction and visual consistency during zoom and pan.

* improve PDF text box dragging and scaling behavior across zoom levels

* optimize color scheme calculation using remember and expand text dimming coverage

* Implement in-app language selection and per-app language preferences.

* Improve PDF lock stability in `PdfVerticalReader`

* Implement "Preserve Image Colors" option for PDF themes

* Optimize TOC locate in ReaderDrawer

* Update library and home screen UI components

* smoother page turn animation in epub pagination mode

* Implement automatic page skipping for TTS when no text is found in PDF viewer

* Refine status bar handling and window insets across main screens

* Optimize ViewModel initialization and integrate AndroidX SplashScreen

* Move ComicPanelDetector to debug source set and introduce IPanelDetector interface

* Optimize TOC scrolling in PdfNavigationDrawerContent

* Bump version to 1.0.45(45)
This commit is contained in:
Aryan 2026-04-22 14:11:52 +05:30 committed by GitHub
parent 71e3614ad6
commit cb7de86224
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 4795 additions and 3447 deletions

View file

@ -347,7 +347,9 @@ data class PageStaticData(
val targetWidth: Int,
val targetHeight: Int,
val colorFilter: StableHolder<ColorFilter?>,
val isDarkMode: Boolean
val isDarkMode: Boolean,
val excludeImages: Boolean,
val imageRects: StableHolder<List<android.graphics.Rect>>
)
@Stable
@ -416,6 +418,7 @@ internal fun PdfPageComposable(
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),
excludeImages: Boolean = false,
onDoubleTap: ((Offset) -> Unit)? = null,
isEditMode: Boolean = false,
drawingState: PdfDrawingState? = null,
@ -451,7 +454,9 @@ internal fun PdfPageComposable(
customHighlightColors: Map<PdfHighlightColor, Color> = emptyMap(),
onPaletteClick: (() -> Unit)? = null,
lockedState: Triple<Float, Float, Float>? = null,
onZoomAndPanChanged: ((Float, Offset) -> Unit)? = null
onZoomAndPanChanged: ((Float, Offset) -> Unit)? = null,
onDetectPanels: suspend (Bitmap) -> List<android.graphics.RectF> = { emptyList() },
onShowPanelPopup: (Bitmap) -> Unit = {}
) {
val pdfDocumentItem = pdfDocument.item
var bitmapState by remember { mutableStateOf(PdfThumbnailCache.get(pageIndex)) }
@ -852,11 +857,13 @@ internal fun PdfPageComposable(
@Suppress("VariableNeverRead") var embeddedAnnotations by remember { mutableStateOf<List<EmbeddedAnnotation>>(emptyList()) }
var standardAnnotScreenRects by remember { mutableStateOf<List<Pair<EmbeddedAnnotation, Rect>>>(emptyList()) }
var imageScreenRects by remember { mutableStateOf<List<android.graphics.Rect>>(emptyList()) }
LaunchedEffect(pageIndex, pdfDocumentItem, actualBitmapWidthPx, actualBitmapHeightPx, virtualPage) {
if (!isPdfPage || actualBitmapWidthPx == 0 || actualBitmapHeightPx == 0) {
if (pageLinks.isNotEmpty()) pageLinks = emptyList()
if (standardAnnotScreenRects.isNotEmpty()) standardAnnotScreenRects = emptyList()
if (imageScreenRects.isNotEmpty()) imageScreenRects = emptyList()
return@LaunchedEffect
}
@ -864,6 +871,7 @@ internal fun PdfPageComposable(
val allLinks = mutableListOf<PageLink>()
var finalDisplayList = emptyList<EmbeddedAnnotation>()
var mappedAnnots = emptyList<Pair<EmbeddedAnnotation, Rect>>()
var mappedImageRects = emptyList<android.graphics.Rect>()
val annotLink = 2
try {
@ -931,6 +939,39 @@ internal fun PdfPageComposable(
Timber.e(e, "Error fetching web links")
}
// --- Extract Image Bounds ---
try {
val pagePtr = pageWrapper.getNativePointer()
if (pagePtr != 0L) {
val objCount = NativePdfiumBridge.getPageObjectCount(pagePtr)
val imgRects = mutableListOf<android.graphics.Rect>()
val outRect = FloatArray(4)
for (i in 0 until objCount) {
if (NativePdfiumBridge.getPageObjectType(pagePtr, i) == 3) { // 3 = FPDF_PAGEOBJ_IMAGE
if (NativePdfiumBridge.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()
@ -1035,6 +1076,7 @@ internal fun PdfPageComposable(
pageLinks = allLinks
embeddedAnnotations = finalDisplayList
standardAnnotScreenRects = mappedAnnots
imageScreenRects = mappedImageRects
}
}
}
@ -2627,7 +2669,38 @@ internal fun PdfPageComposable(
coroutineScope.launch {
val startScale = scale
val targetScale = if (startScale > 1.1f) 1f else 2.5f
Timber.tag("PdfZoomDebug").i("DoubleTap Triggered: CurrentScale=$startScale, Target=$targetScale")
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
@ -2658,10 +2731,10 @@ internal fun PdfPageComposable(
)
) {
val progress = value
scale = lerp(
scale = androidx.compose.ui.util.lerp(
startScale, targetScale, progress
)
offset = lerp(
offset = androidx.compose.ui.geometry.lerp(
startOffset, targetOffset, progress
)
onScaleChanged(scale)
@ -3555,6 +3628,7 @@ internal fun PdfPageComposable(
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,
@ -3567,7 +3641,9 @@ internal fun PdfPageComposable(
actualBitmapWidthPx,
actualBitmapHeightPx,
stableColorFilter,
isDarkMode
isDarkMode,
excludeImages,
stableImageRects
) {
Timber.tag("PdfDrawPerf").v(
"STATIC DATA GENERATED: Scale=$effectiveScale, Tiles=${stableTiles.item.size}"
@ -3583,7 +3659,9 @@ internal fun PdfPageComposable(
targetWidth = actualBitmapWidthPx,
targetHeight = actualBitmapHeightPx,
colorFilter = stableColorFilter,
isDarkMode = isDarkMode
isDarkMode = isDarkMode,
excludeImages = excludeImages,
imageRects = stableImageRects
)
}
@ -3659,6 +3737,7 @@ internal fun PdfPageComposable(
}
},
scale = scale,
uiScale = effectiveScale,
offset = offset,
startHandlePos = startHandleContentPosition.value,
endHandlePos = endHandleContentPosition.value,
@ -3936,11 +4015,11 @@ private fun PdfBitmapLayer(
targetWidth: Int,
targetHeight: Int,
colorFilter: ColorFilter? = null,
isDarkMode: Boolean = false
isDarkMode: Boolean = false,
excludeImages: Boolean = false,
imageRects: List<android.graphics.Rect> = emptyList()
) {
Canvas(modifier = Modifier
.fillMaxSize()
.graphicsLayer()) {
Canvas(modifier = Modifier.fillMaxSize().graphicsLayer()) {
translate(left = centeringOffsetX, top = centeringOffsetY) {
clipRect(left = 0f, top = 0f, right = targetWidth.toFloat(), bottom = targetHeight.toFloat()) {
if (bitmapState != null && !bitmapState.isRecycled) {
@ -3959,6 +4038,32 @@ private fun PdfBitmapLayer(
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 ->
@ -3968,12 +4073,52 @@ private fun PdfBitmapLayer(
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()
),
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 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 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
)
}
}
}
}
}
}
}
@ -4497,7 +4642,9 @@ private fun PdfPageStaticLayer(data: PageStaticData) {
targetWidth = data.targetWidth,
targetHeight = data.targetHeight,
colorFilter = data.colorFilter.item,
isDarkMode = data.isDarkMode
isDarkMode = data.isDarkMode,
excludeImages = data.excludeImages,
imageRects = data.imageRects.item
)
}
@ -4571,6 +4718,7 @@ private fun PdfPageRenderer(
drawingState: PdfDrawingState?,
onCanvasSizeChanged: (Float, Float) -> Unit,
scale: Float,
uiScale: Float,
offset: Offset,
startHandlePos: Offset?,
endHandlePos: Offset?,
@ -4697,6 +4845,12 @@ private fun PdfPageRenderer(
}
}
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
@ -4707,17 +4861,27 @@ private fun PdfPageRenderer(
isSelected = (box.id == selectedTextBoxId),
isEditMode = isEditMode,
isDarkMode = staticData.isDarkMode,
scale = uiScale,
pageWidthPx = staticData.targetWidth.toFloat(),
pageHeightPx = staticData.targetHeight.toFloat(),
handlePosition = HandlePosition.AUTO,
onBoundsChanged = { newBounds ->
onTextBoxChange(box.copy(relativeBounds = 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 = { onTextBoxSelect(box.id) },
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,
@ -4729,10 +4893,12 @@ private fun PdfPageRenderer(
}
},
onDrag = { delta, currentBounds ->
Timber.tag("PdfTextBoxDebug").v("PdfPageRenderer onDrag [ID: ${box.id}] delta=$delta currentBounds=$currentBounds scale=$scale")
if (isVerticalScroll) {
onTextBoxDrag(delta)
} else {
onTextBoxDrag(delta)
val scaledDelta = delta * scale
onTextBoxDrag(scaledDelta)
val width = staticData.targetWidth
val edgeThreshold = 60f
@ -4746,9 +4912,11 @@ private fun PdfPageRenderer(
}
},
onDragEnd = {
Timber.tag("PdfTextBoxDebug").d("PdfPageRenderer onDragEnd[ID: ${box.id}]")
onTextBoxDragEnd()
},
onDragCancel = {
Timber.tag("PdfTextBoxDebug").d("PdfPageRenderer onDragCancel [ID: ${box.id}]")
onTextBoxDragEnd()
},
modifier = Modifier