Update v1.0.49 (#330)
* Added PDF top tab strip visibility toggle and fixed WebView hit test NPE * Refactored desktop reader screens and state management into specialized components * Added image gallery to reader sidebar and refactored desktop PDF UI components * Implemented EPUB image gallery * Refactored reader and library models to use shared common types, centralizing file type resolution and texture management while removing redundant mapping logic * Standardized UI styling and refactored app navigation layout * Implemented auto-hiding reader chrome and activity tracking in desktop * Refactored reader panels into distinct left and right modal layers with platform-specific sizing and keyboard navigation support. * Added PPTX support for desktop and refactored parsing into a shared module * Implement paid AI features and account management for the desktop application. * Implement AI Hub and enhanced Cloud TTS integration for Desktop * Implement streaming support for AI definition and summarization features * Implement support for password-protected PDFs and file actions in the desktop reader. * Implement cloud synchronization for desktop using Firestore and Google Drive * Implement PDF reflow and "Text View" for the desktop reader * Refactor OPDS logic to use SharedOpdsController * Optimize PDF tile rendering performance * Implement two-page spread support for PDF pagination * Implement two-page spread support for the PDF viewer * Improved shared spread zoom in PDF viewer * Improve PDF spread navigation with fling support and configurable page gaps * Add brightness control to PDF and EPUB readers * Refactor folder synchronization to use shared logic engine * Implement safe string formatting and validation for localized resources * Implement TTS chunk skip navigation * Implement deep-linking and playback controls for TTS media sessions * Implement start index for TTS playback * Improve TTS navigation, prefetching, and notification duration reporting * Implement TTS mini playback bar for background reading * Implement multi-window reader support for the desktop application * Improve desktop modal window management and visibility syncing * Implement localized string support for Desktop and shared UI * Implement language selection and persistence for Desktop * Implement plural string support for Desktop and migrate hardcoded counts to plurals.xml * Implement localized banner messages and UI strings using resource-backed SharedText * Implement compact badge styling for small book covers * Refactor PDF native interaction and improve HTML import memory safety * fix language persistence * Refactor reader overflow menus to use section-based logic * Refactor PDF layout remapping and improve text box interaction * Improve CFI resolution and TTS resume accuracy using dynamic chunk offsets * Centralize PDF annotation export mapping and improve metadata handling * Add support for threaded comments in PDF highlight annotations * Flatten highlight comments into a single thread for PDF export and allow author editing * Integrate page slider into reader chrome and persist toggle state * Handle fragments and queries in EPUB chapter paths * Implement dynamic, theme-aware coloring for the reader slider * Implement customizable app-wide font preference * Implement one-hand zoom gestures in the PDF viewer * Implement File Information dialog for PDF and EPUB readers * Bump version to 1.0.49 (53) * Refactor PDF reader logic into modular components * Add ProGuard rules to prevent R8 optimization issues in EPUB reader screens * Add option to use PDF filenames as display names * Fix preservation of PDF filename display preference in library projection
This commit is contained in:
parent
dc5196526f
commit
9510293ac3
245 changed files with 37538 additions and 12460 deletions
|
|
@ -42,6 +42,8 @@ object NativePdfiumBridge {
|
|||
inkPointOffsets: IntArray,
|
||||
inkPointCounts: IntArray,
|
||||
inkPoints: FloatArray,
|
||||
inkNames: Array<String>,
|
||||
inkContents: Array<String>,
|
||||
textPageIndices: IntArray,
|
||||
textBounds: FloatArray,
|
||||
textColors: IntArray,
|
||||
|
|
@ -62,7 +64,16 @@ object NativePdfiumBridge {
|
|||
highlightRectOffsets: IntArray,
|
||||
highlightRectCounts: IntArray,
|
||||
highlightRects: FloatArray,
|
||||
highlightContents: Array<String>
|
||||
highlightNames: Array<String>,
|
||||
highlightContents: Array<String>,
|
||||
highlightCommentOffsets: IntArray,
|
||||
highlightCommentCounts: IntArray,
|
||||
highlightCommentParentIndices: IntArray,
|
||||
highlightCommentNames: Array<String>,
|
||||
highlightCommentAuthors: Array<String>,
|
||||
highlightCommentContents: Array<String>,
|
||||
highlightCommentCreatedDates: Array<String>,
|
||||
highlightCommentModifiedDates: Array<String>
|
||||
): Boolean
|
||||
|
||||
const val ANNOT_TEXT = PdfiumAnnotationSubtype.TEXT
|
||||
|
|
|
|||
|
|
@ -0,0 +1,11 @@
|
|||
package com.aryan.reader.pdf
|
||||
|
||||
enum class AnnotationType {
|
||||
INK, TEXT
|
||||
}
|
||||
|
||||
enum class InkType {
|
||||
PEN, HIGHLIGHTER, HIGHLIGHTER_ROUND, ERASER, FOUNTAIN_PEN, PENCIL, TEXT
|
||||
}
|
||||
|
||||
data class PdfPoint(val x: Float, val y: Float, val timestamp: Long = 0L)
|
||||
111
app/src/main/java/com/aryan/reader/pdf/PdfBubbleZoom.kt
Normal file
111
app/src/main/java/com/aryan/reader/pdf/PdfBubbleZoom.kt
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
package com.aryan.reader.pdf
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.RectF
|
||||
import androidx.core.graphics.createBitmap
|
||||
import com.aryan.reader.ml.SpeechBubble
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import timber.log.Timber
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.math.sqrt
|
||||
import android.graphics.Color as AndroidColor
|
||||
|
||||
internal data class ExpandedBubbleRender(
|
||||
val bitmap: Bitmap,
|
||||
val zoomFactor: Float
|
||||
)
|
||||
|
||||
internal 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)
|
||||
}
|
||||
|
||||
internal 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
|
||||
}
|
||||
|
||||
internal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal 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()
|
||||
}
|
||||
|
||||
internal const val PDF_MAX_DRAW_BITMAP_BYTES = 64L * 1024L * 1024L
|
||||
internal const val PDF_MAX_DRAW_BITMAP_DIMENSION_PX = 4096
|
||||
|
|
@ -24,7 +24,6 @@ import android.graphics.Bitmap
|
|||
import android.net.Uri
|
||||
import timber.log.Timber
|
||||
import androidx.core.graphics.createBitmap
|
||||
import io.legere.pdfiumandroid.suspend.PdfiumCoreKt
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
|
|
@ -32,7 +31,6 @@ private const val TAG = "PdfCoverGenerator"
|
|||
|
||||
class PdfCoverGenerator(context: Context) {
|
||||
private val appContext = context.applicationContext
|
||||
private val pdfiumCore = PdfiumCoreKt(Dispatchers.IO)
|
||||
|
||||
/**
|
||||
* Generates a Bitmap cover for the first page of a PDF.
|
||||
|
|
@ -48,37 +46,40 @@ class PdfCoverGenerator(context: Context) {
|
|||
appContext.contentResolver.openFileDescriptor(pdfUri, "r").use { pfd ->
|
||||
if (pfd == null) {
|
||||
Timber.e("Failed to open ParcelFileDescriptor for URI: $pdfUri")
|
||||
return@withContext null
|
||||
}
|
||||
pdfiumCore.newDocument(pfd).use { doc ->
|
||||
if (doc.getPageCount() == 0) {
|
||||
Timber.w("PDF has no pages, cannot generate cover: $pdfUri")
|
||||
return@withContext null
|
||||
}
|
||||
doc.openPage(0)?.use { page ->
|
||||
val originalWidth = page.getPageWidthPoint()
|
||||
val originalHeight = page.getPageHeightPoint()
|
||||
if (originalWidth <= 0 || originalHeight <= 0) {
|
||||
Timber.e("Invalid page dimensions for cover: $pdfUri")
|
||||
return@withContext null
|
||||
null
|
||||
} else {
|
||||
PdfiumEngineProvider.withPdfium {
|
||||
PdfiumCoreProvider.core.newDocument(pfd).use { doc ->
|
||||
if (doc.getPageCount() == 0) {
|
||||
Timber.w("PDF has no pages, cannot generate cover: $pdfUri")
|
||||
return@withPdfium null
|
||||
}
|
||||
doc.openPage(0)?.use { page ->
|
||||
val originalWidth = page.getPageWidthPoint()
|
||||
val originalHeight = page.getPageHeightPoint()
|
||||
if (originalWidth <= 0 || originalHeight <= 0) {
|
||||
Timber.e("Invalid page dimensions for cover: $pdfUri")
|
||||
return@withPdfium null
|
||||
}
|
||||
|
||||
val aspectRatio = originalWidth.toFloat() / originalHeight.toFloat()
|
||||
val targetWidth = (targetHeight * aspectRatio).toInt()
|
||||
|
||||
if (targetWidth <= 0) {
|
||||
Timber.e("Calculated invalid bitmap width for cover: $targetWidth")
|
||||
return@withPdfium null
|
||||
}
|
||||
|
||||
val bitmap = createBitmap(targetWidth, targetHeight)
|
||||
page.renderPageBitmap(
|
||||
bitmap = bitmap,
|
||||
startX = 0, startY = 0,
|
||||
drawSizeX = targetWidth, drawSizeY = targetHeight,
|
||||
renderAnnot = false
|
||||
)
|
||||
bitmap
|
||||
}
|
||||
}
|
||||
|
||||
val aspectRatio = originalWidth.toFloat() / originalHeight.toFloat()
|
||||
val targetWidth = (targetHeight * aspectRatio).toInt()
|
||||
|
||||
if (targetWidth <= 0) {
|
||||
Timber.e("Calculated invalid bitmap width for cover: $targetWidth")
|
||||
return@withContext null
|
||||
}
|
||||
|
||||
val bitmap = createBitmap(targetWidth, targetHeight)
|
||||
page.renderPageBitmap(
|
||||
bitmap = bitmap,
|
||||
startX = 0, startY = 0,
|
||||
drawSizeX = targetWidth, drawSizeY = targetHeight,
|
||||
renderAnnot = false
|
||||
)
|
||||
bitmap
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -88,4 +89,4 @@ class PdfCoverGenerator(context: Context) {
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -158,21 +158,40 @@ internal fun shouldShowPdfAnnotationExportChoice(
|
|||
|
||||
internal fun getFastFileId(context: Context, uri: Uri): String {
|
||||
var result = uri.toString()
|
||||
Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i(
|
||||
"id.fast.start uri=$uri scheme=${uri.scheme}"
|
||||
)
|
||||
try {
|
||||
context.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE)
|
||||
val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
||||
if (uri.scheme == "file") {
|
||||
uri.path?.let {
|
||||
val file = java.io.File(it)
|
||||
result = "${file.name}_${file.length()}"
|
||||
Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i(
|
||||
"id.fast.file uri=$uri path=${file.absolutePath} exists=${file.exists()} " +
|
||||
"name=${file.name} size=${file.length()} mtime=${file.lastModified()} result=$result"
|
||||
)
|
||||
}
|
||||
} else {
|
||||
context.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE)
|
||||
val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
||||
|
||||
val size = if (sizeIndex != -1) cursor.getLong(sizeIndex) else 0L
|
||||
val name = if (nameIndex != -1) cursor.getString(nameIndex) else "unknown"
|
||||
val size = if (sizeIndex != -1) cursor.getLong(sizeIndex) else 0L
|
||||
val name = if (nameIndex != -1) cursor.getString(nameIndex) else "unknown"
|
||||
|
||||
result = "${name}_${size}"
|
||||
result = "${name}_${size}"
|
||||
Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i(
|
||||
"id.fast.content uri=$uri name=$name size=$size result=$result"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).e(e, "id.fast.failed uri=$uri fallback=$result")
|
||||
Timber.e(e, "Failed to generate fast file ID")
|
||||
}
|
||||
Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i("id.fast.done uri=$uri result=$result")
|
||||
return result
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ import androidx.compose.material3.MaterialTheme
|
|||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.ScrollableTabRow
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Tab
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
|
|
@ -81,6 +82,7 @@ import kotlinx.coroutines.withContext
|
|||
import org.json.JSONArray
|
||||
import timber.log.Timber
|
||||
import androidx.core.graphics.createBitmap
|
||||
import com.aryan.reader.cardTitle
|
||||
import com.aryan.reader.data.RecentFileItem
|
||||
import com.aryan.reader.pdf.data.VirtualPage
|
||||
|
||||
|
|
@ -316,9 +318,12 @@ private fun PdfTabsDrawerPage(
|
|||
activeTabBookId: String?,
|
||||
currentPage: Int,
|
||||
totalPages: Int,
|
||||
isTopTabStripVisible: Boolean,
|
||||
onTabSelected: (String) -> Unit,
|
||||
onTabClosed: (String) -> Unit,
|
||||
onNewTabClick: () -> Unit
|
||||
onNewTabClick: () -> Unit,
|
||||
onTopTabStripVisibilityChange: (Boolean) -> Unit,
|
||||
usePdfFileNameAsDisplayName: Boolean
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
Row(
|
||||
|
|
@ -357,6 +362,28 @@ private fun PdfTabsDrawerPage(
|
|||
|
||||
HorizontalDivider()
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onTopTabStripVisibilityChange(!isTopTabStripVisible) }
|
||||
.padding(start = 16.dp, end = 12.dp, top = 10.dp, bottom = 10.dp)
|
||||
.testTag("PdfTopTabStripVisibilityToggle"),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.pdf_tabs_show_top_app_bar_tabs),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Switch(
|
||||
checked = isTopTabStripVisible,
|
||||
onCheckedChange = null
|
||||
)
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
if (openTabs.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().padding(16.dp),
|
||||
|
|
@ -382,7 +409,8 @@ private fun PdfTabsDrawerPage(
|
|||
currentPage = currentPage,
|
||||
totalPages = totalPages,
|
||||
onTabSelected = onTabSelected,
|
||||
onTabClosed = onTabClosed
|
||||
onTabClosed = onTabClosed,
|
||||
usePdfFileNameAsDisplayName = usePdfFileNameAsDisplayName
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -397,7 +425,8 @@ private fun PdfDrawerTabItem(
|
|||
currentPage: Int,
|
||||
totalPages: Int,
|
||||
onTabSelected: (String) -> Unit,
|
||||
onTabClosed: (String) -> Unit
|
||||
onTabClosed: (String) -> Unit,
|
||||
usePdfFileNameAsDisplayName: Boolean
|
||||
) {
|
||||
val shape = RoundedCornerShape(8.dp)
|
||||
val containerColor by animateColorAsState(
|
||||
|
|
@ -485,7 +514,7 @@ private fun PdfDrawerTabItem(
|
|||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = tab.customName ?: tab.title ?: tab.displayName,
|
||||
text = tab.cardTitle(usePdfFileNameAsDisplayName),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Medium,
|
||||
color = contentColor,
|
||||
|
|
@ -542,11 +571,14 @@ internal fun PdfNavigationDrawerContent(
|
|||
isTabsEnabled: Boolean = false,
|
||||
openTabs: List<RecentFileItem> = emptyList(),
|
||||
activeTabBookId: String? = null,
|
||||
usePdfFileNameAsDisplayName: Boolean = false,
|
||||
isTopTabStripVisible: Boolean = true,
|
||||
customHighlightColors: Map<PdfHighlightColor, Color>,
|
||||
onPageSelected: (Int) -> Unit,
|
||||
onTabSelected: (String) -> Unit = {},
|
||||
onTabClosed: (String) -> Unit = {},
|
||||
onNewTabClick: () -> Unit = {},
|
||||
onTopTabStripVisibilityChange: (Boolean) -> Unit = {},
|
||||
onRenameBookmark: (PdfBookmark, String) -> Unit,
|
||||
onDeleteBookmark: (PdfBookmark) -> Unit,
|
||||
onDeleteHighlight: (PdfUserHighlight) -> Unit,
|
||||
|
|
@ -601,6 +633,7 @@ internal fun PdfNavigationDrawerContent(
|
|||
activeTabBookId = activeTabBookId,
|
||||
currentPage = currentPage,
|
||||
totalPages = totalPages,
|
||||
isTopTabStripVisible = isTopTabStripVisible,
|
||||
onTabSelected = { bookId ->
|
||||
if (bookId == activeTabBookId) {
|
||||
onCloseDrawer()
|
||||
|
|
@ -610,7 +643,9 @@ internal fun PdfNavigationDrawerContent(
|
|||
}
|
||||
},
|
||||
onTabClosed = onTabClosed,
|
||||
onNewTabClick = onNewTabClick
|
||||
onNewTabClick = onNewTabClick,
|
||||
onTopTabStripVisibilityChange = onTopTabStripVisibilityChange,
|
||||
usePdfFileNameAsDisplayName = usePdfFileNameAsDisplayName
|
||||
)
|
||||
|
||||
PdfDrawerSection.CHAPTERS -> { // Chapters Page
|
||||
|
|
|
|||
|
|
@ -0,0 +1,64 @@
|
|||
package com.aryan.reader.pdf
|
||||
|
||||
import android.graphics.RectF
|
||||
import timber.log.Timber
|
||||
|
||||
data class EmbeddedAnnotation(
|
||||
val index: Int,
|
||||
val subtype: Int,
|
||||
val rect: RectF,
|
||||
val contents: String?,
|
||||
val author: String?,
|
||||
val name: String?,
|
||||
val inReplyTo: String?,
|
||||
val replies: MutableList<EmbeddedAnnotation> = mutableListOf()
|
||||
)
|
||||
|
||||
internal fun groupEmbeddedAnnotationsForDisplay(
|
||||
annotations: List<EmbeddedAnnotation>
|
||||
): List<EmbeddedAnnotation> {
|
||||
if (annotations.isEmpty()) return emptyList()
|
||||
|
||||
val annotMap = annotations
|
||||
.filter { !it.name.isNullOrBlank() }
|
||||
.associateBy { it.name }
|
||||
val orphans = mutableListOf<EmbeddedAnnotation>()
|
||||
|
||||
annotations.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 = RectF(root.rect).apply { inset(-10f, -10f) }
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
return groupedRoots.map { group ->
|
||||
val root = group.first()
|
||||
if (group.size > 1) {
|
||||
root.replies.addAll(group.drop(1))
|
||||
}
|
||||
root
|
||||
}.filter {
|
||||
!it.contents.isNullOrBlank() || it.replies.any { reply -> !reply.contents.isNullOrBlank() }
|
||||
}
|
||||
}
|
||||
|
|
@ -96,7 +96,10 @@ import com.aryan.reader.pdf.ocr.OcrElement
|
|||
import com.aryan.reader.pdf.ocr.OcrLine
|
||||
import com.aryan.reader.pdf.ocr.OcrResult
|
||||
import com.aryan.reader.pdf.ocr.OcrSymbol
|
||||
import com.aryan.reader.shared.pdf.SharedPdfAnnotationComment
|
||||
import timber.log.Timber
|
||||
import java.text.DateFormat
|
||||
import java.util.Date
|
||||
import java.util.UUID
|
||||
|
||||
enum class OcrLanguage(@StringRes val displayNameRes: Int) {
|
||||
|
|
@ -135,7 +138,8 @@ data class PdfUserHighlight(
|
|||
val color: PdfHighlightColor,
|
||||
val text: String,
|
||||
val range: Pair<Int, Int>,
|
||||
val note: String? = null
|
||||
val note: String? = null,
|
||||
val comments: List<SharedPdfAnnotationComment> = emptyList()
|
||||
)
|
||||
|
||||
internal data class CustomPdfMenuState(
|
||||
|
|
@ -699,6 +703,13 @@ fun PdfHighlightColorRow(
|
|||
}
|
||||
}
|
||||
|
||||
private enum class PdfAnnotationSheetSection {
|
||||
NOTE,
|
||||
COMMENTS
|
||||
}
|
||||
|
||||
private const val DEFAULT_PDF_COMMENT_AUTHOR = "Reader"
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun PdfAnnotationBottomSheet(
|
||||
|
|
@ -709,7 +720,8 @@ fun PdfAnnotationBottomSheet(
|
|||
onPaletteClick: (() -> Unit)? = null,
|
||||
onColorChange: (PdfHighlightColor) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
onSave: (String) -> Unit,
|
||||
onSave: (String, List<SharedPdfAnnotationComment>) -> Unit,
|
||||
onUpdate: (String, List<SharedPdfAnnotationComment>) -> Unit = { _, _ -> },
|
||||
onDelete: () -> Unit,
|
||||
onCopy: () -> Unit,
|
||||
onDictionary: () -> Unit,
|
||||
|
|
@ -718,6 +730,24 @@ fun PdfAnnotationBottomSheet(
|
|||
) {
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
var noteText by remember { mutableStateOf(highlight.note ?: "") }
|
||||
var comments by remember(highlight.id) { mutableStateOf(highlight.comments) }
|
||||
var selectedSection by remember(highlight.id) { mutableStateOf(PdfAnnotationSheetSection.NOTE) }
|
||||
var commentText by remember(highlight.id) { mutableStateOf("") }
|
||||
var replyTargetId by remember(highlight.id) { mutableStateOf<String?>(null) }
|
||||
var editingCommentId by remember(highlight.id) { mutableStateOf<String?>(null) }
|
||||
var commentAuthor by remember(highlight.id) {
|
||||
mutableStateOf(
|
||||
highlight.comments
|
||||
.lastOrNull { it.author.isNotBlank() }
|
||||
?.author
|
||||
?: DEFAULT_PDF_COMMENT_AUTHOR
|
||||
)
|
||||
}
|
||||
|
||||
fun persistComments(nextComments: List<SharedPdfAnnotationComment>) {
|
||||
comments = nextComments
|
||||
onUpdate(noteText, nextComments)
|
||||
}
|
||||
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
|
|
@ -775,23 +805,98 @@ fun PdfAnnotationBottomSheet(
|
|||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = noteText,
|
||||
onValueChange = { noteText = it },
|
||||
placeholder = { Text(stringResource(R.string.placeholder_add_note), color = effectiveText.copy(alpha = 0.5f)) },
|
||||
modifier = Modifier.fillMaxWidth().heightIn(min = 100.dp),
|
||||
maxLines = 5,
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedContainerColor = Color.Transparent,
|
||||
unfocusedContainerColor = Color.Transparent,
|
||||
focusedBorderColor = MaterialTheme.colorScheme.primary,
|
||||
unfocusedBorderColor = effectiveText.copy(alpha = 0.3f),
|
||||
focusedTextColor = effectiveText,
|
||||
unfocusedTextColor = effectiveText
|
||||
),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
PdfAnnotationSheetTabs(
|
||||
selectedSection = selectedSection,
|
||||
commentCount = comments.count { it.contents.isNotBlank() },
|
||||
effectiveText = effectiveText,
|
||||
onSectionChange = { selectedSection = it }
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
if (selectedSection == PdfAnnotationSheetSection.NOTE) {
|
||||
OutlinedTextField(
|
||||
value = noteText,
|
||||
onValueChange = { noteText = it },
|
||||
placeholder = { Text(stringResource(R.string.placeholder_add_note), color = effectiveText.copy(alpha = 0.5f)) },
|
||||
modifier = Modifier.fillMaxWidth().heightIn(min = 100.dp),
|
||||
maxLines = 5,
|
||||
colors = pdfAnnotationTextFieldColors(effectiveText),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
)
|
||||
} else {
|
||||
PdfHighlightCommentsEditor(
|
||||
comments = comments,
|
||||
commentText = commentText,
|
||||
commentAuthor = commentAuthor,
|
||||
replyTargetId = replyTargetId,
|
||||
editingCommentId = editingCommentId,
|
||||
effectiveText = effectiveText,
|
||||
onCommentTextChange = { commentText = it },
|
||||
onCommentAuthorChange = { commentAuthor = it },
|
||||
onReply = {
|
||||
editingCommentId = null
|
||||
replyTargetId = it.id
|
||||
commentText = ""
|
||||
},
|
||||
onCancelReply = { replyTargetId = null },
|
||||
onEdit = { comment ->
|
||||
editingCommentId = comment.id
|
||||
replyTargetId = null
|
||||
commentText = comment.contents
|
||||
commentAuthor = comment.author.ifBlank { DEFAULT_PDF_COMMENT_AUTHOR }
|
||||
},
|
||||
onCancelEdit = {
|
||||
editingCommentId = null
|
||||
commentText = ""
|
||||
},
|
||||
onDelete = { comment ->
|
||||
val nextComments = comments.withoutCommentThread(comment.id)
|
||||
persistComments(nextComments)
|
||||
if (replyTargetId != null && (replyTargetId == comment.id || nextComments.none { it.id == replyTargetId })) {
|
||||
replyTargetId = null
|
||||
}
|
||||
if (editingCommentId != null && (editingCommentId == comment.id || nextComments.none { it.id == editingCommentId })) {
|
||||
editingCommentId = null
|
||||
commentText = ""
|
||||
}
|
||||
},
|
||||
onAddComment = {
|
||||
val contents = commentText.trim()
|
||||
if (contents.isNotBlank()) {
|
||||
val now = System.currentTimeMillis()
|
||||
val author = commentAuthor.trim().ifBlank { DEFAULT_PDF_COMMENT_AUTHOR }
|
||||
val nextComments = if (editingCommentId != null) {
|
||||
comments.map { comment ->
|
||||
if (comment.id == editingCommentId) {
|
||||
comment.copy(
|
||||
author = author,
|
||||
contents = contents,
|
||||
modifiedAt = now
|
||||
)
|
||||
} else {
|
||||
comment
|
||||
}
|
||||
}
|
||||
} else {
|
||||
comments + SharedPdfAnnotationComment(
|
||||
id = UUID.randomUUID().toString(),
|
||||
parentId = replyTargetId,
|
||||
author = author,
|
||||
contents = contents,
|
||||
createdAt = now,
|
||||
modifiedAt = now
|
||||
)
|
||||
}
|
||||
persistComments(nextComments)
|
||||
commentText = ""
|
||||
replyTargetId = null
|
||||
editingCommentId = null
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
|
||||
Row(
|
||||
|
|
@ -811,19 +916,326 @@ fun PdfAnnotationBottomSheet(
|
|||
Text(stringResource(R.string.action_delete))
|
||||
}
|
||||
Button(
|
||||
onClick = { onSave(noteText) },
|
||||
onClick = { onSave(noteText, comments) },
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimary
|
||||
)
|
||||
) {
|
||||
Text(stringResource(R.string.action_save_note))
|
||||
Text(stringResource(R.string.action_done))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PdfAnnotationSheetTabs(
|
||||
selectedSection: PdfAnnotationSheetSection,
|
||||
commentCount: Int,
|
||||
effectiveText: Color,
|
||||
onSectionChange: (PdfAnnotationSheetSection) -> Unit
|
||||
) {
|
||||
Surface(
|
||||
color = effectiveText.copy(alpha = 0.06f),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Row(modifier = Modifier.padding(4.dp)) {
|
||||
PdfAnnotationSheetTab(
|
||||
label = stringResource(R.string.label_note),
|
||||
selected = selectedSection == PdfAnnotationSheetSection.NOTE,
|
||||
effectiveText = effectiveText,
|
||||
modifier = Modifier.weight(1f),
|
||||
onClick = { onSectionChange(PdfAnnotationSheetSection.NOTE) }
|
||||
)
|
||||
PdfAnnotationSheetTab(
|
||||
label = "${stringResource(R.string.label_comments)} ($commentCount)",
|
||||
selected = selectedSection == PdfAnnotationSheetSection.COMMENTS,
|
||||
effectiveText = effectiveText,
|
||||
modifier = Modifier.weight(1f),
|
||||
onClick = { onSectionChange(PdfAnnotationSheetSection.COMMENTS) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PdfAnnotationSheetTab(
|
||||
label: String,
|
||||
selected: Boolean,
|
||||
effectiveText: Color,
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Surface(
|
||||
color = if (selected) MaterialTheme.colorScheme.primary else Color.Transparent,
|
||||
contentColor = if (selected) MaterialTheme.colorScheme.onPrimary else effectiveText,
|
||||
shape = RoundedCornerShape(6.dp),
|
||||
modifier = modifier
|
||||
.height(40.dp)
|
||||
.clip(RoundedCornerShape(6.dp))
|
||||
.clickable(onClick = onClick)
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxWidth().fillMaxHeight()) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PdfHighlightCommentsEditor(
|
||||
comments: List<SharedPdfAnnotationComment>,
|
||||
commentText: String,
|
||||
commentAuthor: String,
|
||||
replyTargetId: String?,
|
||||
editingCommentId: String?,
|
||||
effectiveText: Color,
|
||||
onCommentTextChange: (String) -> Unit,
|
||||
onCommentAuthorChange: (String) -> Unit,
|
||||
onReply: (SharedPdfAnnotationComment) -> Unit,
|
||||
onCancelReply: () -> Unit,
|
||||
onEdit: (SharedPdfAnnotationComment) -> Unit,
|
||||
onCancelEdit: () -> Unit,
|
||||
onDelete: (SharedPdfAnnotationComment) -> Unit,
|
||||
onAddComment: () -> Unit
|
||||
) {
|
||||
val commentIds = comments.filter { it.contents.isNotBlank() }.map { it.id }.toSet()
|
||||
val visibleComments = comments
|
||||
.filter { it.contents.isNotBlank() }
|
||||
.map { comment ->
|
||||
if (comment.parentId != null && comment.parentId !in commentIds) {
|
||||
comment.copy(parentId = null)
|
||||
} else {
|
||||
comment
|
||||
}
|
||||
}
|
||||
val replyTarget = visibleComments.firstOrNull { it.id == replyTargetId }
|
||||
val editingComment = visibleComments.firstOrNull { it.id == editingCommentId }
|
||||
|
||||
Column {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 220.dp)
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
PdfHighlightCommentThread(
|
||||
comments = visibleComments,
|
||||
parentId = null,
|
||||
depth = 0,
|
||||
visitedIds = emptySet(),
|
||||
effectiveText = effectiveText,
|
||||
onReply = onReply,
|
||||
onEdit = onEdit,
|
||||
onDelete = onDelete
|
||||
)
|
||||
}
|
||||
|
||||
if (editingComment != null || replyTarget != null) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = if (editingComment != null) {
|
||||
stringResource(R.string.label_editing_comment)
|
||||
} else {
|
||||
stringResource(
|
||||
R.string.label_replying_to,
|
||||
replyTarget?.author?.ifBlank { DEFAULT_PDF_COMMENT_AUTHOR }.orEmpty()
|
||||
)
|
||||
},
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = effectiveText.copy(alpha = 0.7f),
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
TextButton(onClick = if (editingComment != null) onCancelEdit else onCancelReply) {
|
||||
Text(stringResource(R.string.action_cancel))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = commentAuthor,
|
||||
onValueChange = onCommentAuthorChange,
|
||||
label = { Text(stringResource(R.string.author)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
colors = pdfAnnotationTextFieldColors(effectiveText),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = commentText,
|
||||
onValueChange = onCommentTextChange,
|
||||
placeholder = {
|
||||
Text(
|
||||
stringResource(R.string.placeholder_add_comment),
|
||||
color = effectiveText.copy(alpha = 0.5f)
|
||||
)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().heightIn(min = 88.dp),
|
||||
maxLines = 4,
|
||||
colors = pdfAnnotationTextFieldColors(effectiveText),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||
horizontalArrangement = Arrangement.End
|
||||
) {
|
||||
TextButton(onClick = onAddComment, enabled = commentText.isNotBlank()) {
|
||||
Text(
|
||||
stringResource(
|
||||
if (editingComment != null) R.string.action_save_comment else R.string.action_add_comment
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PdfHighlightCommentThread(
|
||||
comments: List<SharedPdfAnnotationComment>,
|
||||
parentId: String?,
|
||||
depth: Int,
|
||||
visitedIds: Set<String>,
|
||||
effectiveText: Color,
|
||||
onReply: (SharedPdfAnnotationComment) -> Unit,
|
||||
onEdit: (SharedPdfAnnotationComment) -> Unit,
|
||||
onDelete: (SharedPdfAnnotationComment) -> Unit
|
||||
) {
|
||||
comments
|
||||
.filter { it.parentId == parentId }
|
||||
.sortedWith(compareBy({ it.createdAt.takeIf { timestamp -> timestamp > 0L } ?: Long.MAX_VALUE }, { it.id }))
|
||||
.forEach { comment ->
|
||||
if (comment.id in visitedIds) return@forEach
|
||||
PdfHighlightCommentItem(
|
||||
comment = comment,
|
||||
depth = depth,
|
||||
effectiveText = effectiveText,
|
||||
onReply = { onReply(comment) },
|
||||
onEdit = { onEdit(comment) },
|
||||
onDelete = { onDelete(comment) }
|
||||
)
|
||||
PdfHighlightCommentThread(
|
||||
comments = comments,
|
||||
parentId = comment.id,
|
||||
depth = depth + 1,
|
||||
visitedIds = visitedIds + comment.id,
|
||||
effectiveText = effectiveText,
|
||||
onReply = onReply,
|
||||
onEdit = onEdit,
|
||||
onDelete = onDelete
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PdfHighlightCommentItem(
|
||||
comment: SharedPdfAnnotationComment,
|
||||
depth: Int,
|
||||
effectiveText: Color,
|
||||
onReply: () -> Unit,
|
||||
onEdit: () -> Unit,
|
||||
onDelete: () -> Unit
|
||||
) {
|
||||
val indentSize = (depth * 16).dp
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = indentSize, top = 6.dp, bottom = 6.dp)
|
||||
) {
|
||||
if (depth > 0) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(2.dp)
|
||||
.fillMaxHeight()
|
||||
.background(MaterialTheme.colorScheme.outlineVariant)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = comment.author.ifBlank { DEFAULT_PDF_COMMENT_AUTHOR },
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
val timestamp = comment.createdAt.formatPdfCommentTimestamp()
|
||||
if (timestamp.isNotBlank()) {
|
||||
Text(
|
||||
text = timestamp,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = effectiveText.copy(alpha = 0.55f)
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Text(
|
||||
text = comment.contents,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = effectiveText
|
||||
)
|
||||
Row {
|
||||
TextButton(onClick = onReply) {
|
||||
Text(stringResource(R.string.action_reply))
|
||||
}
|
||||
TextButton(onClick = onEdit) {
|
||||
Text(stringResource(R.string.label_edit))
|
||||
}
|
||||
TextButton(onClick = onDelete) {
|
||||
Text(stringResource(R.string.action_delete), color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun pdfAnnotationTextFieldColors(effectiveText: Color) =
|
||||
OutlinedTextFieldDefaults.colors(
|
||||
focusedContainerColor = Color.Transparent,
|
||||
unfocusedContainerColor = Color.Transparent,
|
||||
focusedBorderColor = MaterialTheme.colorScheme.primary,
|
||||
unfocusedBorderColor = effectiveText.copy(alpha = 0.3f),
|
||||
focusedTextColor = effectiveText,
|
||||
unfocusedTextColor = effectiveText
|
||||
)
|
||||
|
||||
private fun List<SharedPdfAnnotationComment>.withoutCommentThread(commentId: String): List<SharedPdfAnnotationComment> {
|
||||
val childrenByParentId = groupBy { it.parentId }
|
||||
val idsToRemove = mutableSetOf<String>()
|
||||
|
||||
fun collect(id: String) {
|
||||
if (!idsToRemove.add(id)) return
|
||||
childrenByParentId[id].orEmpty().forEach { child -> collect(child.id) }
|
||||
}
|
||||
|
||||
collect(commentId)
|
||||
return filterNot { it.id in idsToRemove }
|
||||
}
|
||||
|
||||
private fun Long.formatPdfCommentTimestamp(): String {
|
||||
if (this <= 0L) return ""
|
||||
return runCatching {
|
||||
DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT).format(Date(this))
|
||||
}.getOrDefault("")
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PdfBottomSheetToolButton(
|
||||
icon: Int,
|
||||
|
|
|
|||
|
|
@ -7,26 +7,18 @@ import androidx.media3.common.util.UnstableApi
|
|||
import com.aryan.reader.pdf.data.PdfAnnotation
|
||||
import com.aryan.reader.tts.TtsPlaybackManager
|
||||
|
||||
internal enum class SaveMode {
|
||||
ORIGINAL, ANNOTATED
|
||||
}
|
||||
internal typealias SaveMode = com.aryan.reader.shared.SaveMode
|
||||
|
||||
enum class SearchHighlightMode {
|
||||
FOCUSED, ALL
|
||||
}
|
||||
typealias SearchHighlightMode = com.aryan.reader.shared.SearchHighlightMode
|
||||
|
||||
internal sealed interface HistoryAction {
|
||||
data class Add(val pageIndex: Int, val annotation: PdfAnnotation) : HistoryAction
|
||||
data class Remove(val items: Map<Int, List<PdfAnnotation>>) : HistoryAction
|
||||
}
|
||||
|
||||
internal enum class DockLocation {
|
||||
TOP, BOTTOM, FLOATING
|
||||
}
|
||||
internal typealias DockLocation = com.aryan.reader.shared.DockLocation
|
||||
|
||||
internal enum class DisplayMode {
|
||||
PAGINATION, VERTICAL_SCROLL
|
||||
}
|
||||
internal typealias DisplayMode = com.aryan.reader.shared.PdfDisplayMode
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
@Suppress("unused")
|
||||
|
|
|
|||
|
|
@ -154,6 +154,15 @@ fun VerticalScrollbar(
|
|||
|
||||
@Composable
|
||||
internal fun PageScrubbingAnimation(currentPage: Int, totalPages: Int) {
|
||||
PageScrubbingAnimation(
|
||||
pageLabel = "Page $currentPage of $totalPages"
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun PageScrubbingAnimation(
|
||||
pageLabel: String
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
|
|
@ -178,7 +187,7 @@ internal fun PageScrubbingAnimation(currentPage: Int, totalPages: Int) {
|
|||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(
|
||||
text = "Page $currentPage of $totalPages",
|
||||
text = pageLabel,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
|
|
@ -188,9 +197,16 @@ internal fun PageScrubbingAnimation(currentPage: Int, totalPages: Int) {
|
|||
|
||||
@Composable
|
||||
internal fun ThumbnailWithIndicator(
|
||||
thumbnail: Bitmap, modifier: Modifier = Modifier, onClick: () -> Unit
|
||||
thumbnail: Bitmap,
|
||||
modifier: Modifier = Modifier,
|
||||
borderColor: Color = Color.Unspecified,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val borderColor = MaterialTheme.colorScheme.primary
|
||||
val effectiveBorderColor = if (borderColor == Color.Unspecified) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
borderColor
|
||||
}
|
||||
Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
|
|
@ -198,7 +214,7 @@ internal fun ThumbnailWithIndicator(
|
|||
.height(64.dp)
|
||||
.clickable(onClick = onClick),
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
border = BorderStroke(2.dp, borderColor)
|
||||
border = BorderStroke(2.dp, effectiveBorderColor)
|
||||
) {
|
||||
Image(
|
||||
bitmap = thumbnail.asImageBitmap(),
|
||||
|
|
@ -211,7 +227,7 @@ internal fun ThumbnailWithIndicator(
|
|||
.offset(y = (-4).dp)
|
||||
.size(8.dp)
|
||||
.rotate(45f)
|
||||
.background(borderColor))
|
||||
.background(effectiveBorderColor))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
357
app/src/main/java/com/aryan/reader/pdf/PdfOneHandZoom.kt
Normal file
357
app/src/main/java/com/aryan/reader/pdf/PdfOneHandZoom.kt
Normal file
|
|
@ -0,0 +1,357 @@
|
|||
package com.aryan.reader.pdf
|
||||
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.gestures.waitForUpOrCancellation
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.input.pointer.PointerEventTimeoutCancellationException
|
||||
import androidx.compose.ui.input.pointer.PointerId
|
||||
import androidx.compose.ui.input.pointer.PointerInputChange
|
||||
import androidx.compose.ui.input.pointer.PointerInputScope
|
||||
import androidx.compose.ui.input.pointer.changedToDown
|
||||
import androidx.compose.ui.input.pointer.changedToUp
|
||||
import androidx.compose.ui.input.pointer.positionChanged
|
||||
import androidx.compose.ui.platform.ViewConfiguration
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import timber.log.Timber
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.max
|
||||
import kotlin.math.pow
|
||||
|
||||
internal const val PDF_ONE_HAND_ZOOM_TRACE_TAG = "PdfOneHandZoomTrace"
|
||||
internal const val PDF_ONE_HAND_ZOOM_HOLD_TIMEOUT_MS = 90L
|
||||
internal const val PDF_ONE_HAND_ZOOM_DRAG_DISTANCE_FOR_DOUBLE_DP = 240f
|
||||
|
||||
internal enum class PdfSecondTapZoomAction {
|
||||
QUICK_DOUBLE_TAP,
|
||||
ONE_HAND_ZOOM,
|
||||
HELD_NO_MOVEMENT
|
||||
}
|
||||
|
||||
internal fun classifyPdfSecondTapZoomAction(
|
||||
pressDurationMillis: Long,
|
||||
totalDragY: Float,
|
||||
movementSlopPx: Float,
|
||||
holdTimeoutMillis: Long = PDF_ONE_HAND_ZOOM_HOLD_TIMEOUT_MS
|
||||
): PdfSecondTapZoomAction {
|
||||
return when {
|
||||
pressDurationMillis < holdTimeoutMillis -> PdfSecondTapZoomAction.QUICK_DOUBLE_TAP
|
||||
abs(totalDragY) >= movementSlopPx -> PdfSecondTapZoomAction.ONE_HAND_ZOOM
|
||||
else -> PdfSecondTapZoomAction.HELD_NO_MOVEMENT
|
||||
}
|
||||
}
|
||||
|
||||
internal fun pdfOneHandZoomScale(
|
||||
startScale: Float,
|
||||
totalDragY: Float,
|
||||
dragDistanceForDoublePx: Float,
|
||||
minScale: Float,
|
||||
maxScale: Float
|
||||
): Float {
|
||||
val safeStart = startScale.takeIf { it.isFinite() && it > 0f } ?: minScale
|
||||
val safeDistance = dragDistanceForDoublePx.takeIf { it.isFinite() && it > 0f } ?: 1f
|
||||
val scaleMultiplier = 2f.pow(totalDragY / safeDistance)
|
||||
return (safeStart * scaleMultiplier).coerceIn(minScale, maxScale)
|
||||
}
|
||||
|
||||
internal fun clampCenteredPdfCameraOffset(
|
||||
scale: Float,
|
||||
offset: Offset,
|
||||
viewportSize: Size,
|
||||
contentSize: Size
|
||||
): Offset {
|
||||
if (viewportSize.width <= 0f || viewportSize.height <= 0f || scale <= 1f) {
|
||||
return Offset.Zero
|
||||
}
|
||||
val maxOffsetX = ((contentSize.width * scale) - viewportSize.width).coerceAtLeast(0f) / 2f
|
||||
val maxOffsetY = ((contentSize.height * scale) - viewportSize.height).coerceAtLeast(0f) / 2f
|
||||
return Offset(
|
||||
x = offset.x.coerceIn(-maxOffsetX, maxOffsetX),
|
||||
y = offset.y.coerceIn(-maxOffsetY, maxOffsetY)
|
||||
)
|
||||
}
|
||||
|
||||
internal fun centeredPdfCameraOffsetForScaleChange(
|
||||
previousScale: Float,
|
||||
nextScale: Float,
|
||||
previousOffset: Offset,
|
||||
pivot: Offset,
|
||||
viewportSize: Size,
|
||||
contentSize: Size
|
||||
): Offset {
|
||||
val safePreviousScale = previousScale.takeIf { it.isFinite() && it > 0f } ?: 1f
|
||||
val ratio = nextScale / safePreviousScale
|
||||
val viewportCenter = Offset(viewportSize.width / 2f, viewportSize.height / 2f)
|
||||
val targetOffset = previousOffset * ratio + (pivot - viewportCenter) * (1f - ratio)
|
||||
return clampCenteredPdfCameraOffset(
|
||||
scale = nextScale,
|
||||
offset = targetOffset,
|
||||
viewportSize = viewportSize,
|
||||
contentSize = contentSize
|
||||
)
|
||||
}
|
||||
|
||||
internal fun topLeftPdfPanForScaleChange(
|
||||
previousScale: Float,
|
||||
nextScale: Float,
|
||||
previousPan: Offset,
|
||||
pivot: Offset
|
||||
): Offset {
|
||||
val safePreviousScale = previousScale.takeIf { it.isFinite() && it > 0f } ?: 1f
|
||||
val contentPivot = (pivot - previousPan) / safePreviousScale
|
||||
return pivot - (contentPivot * nextScale)
|
||||
}
|
||||
|
||||
private fun Offset.traceString(): String = "(${x.toInt()},${y.toInt()})"
|
||||
|
||||
private fun PointerInputChange.traceString(): String {
|
||||
return "id=$id pos=${position.traceString()} prev=${previousPosition.traceString()} pressed=$pressed consumed=$isConsumed"
|
||||
}
|
||||
|
||||
private fun traceOneHandZoom(message: String) {
|
||||
Timber.tag(PDF_ONE_HAND_ZOOM_TRACE_TAG).d(message)
|
||||
}
|
||||
|
||||
internal suspend fun PointerInputScope.detectPdfTapAndOneHandZoomGestures(
|
||||
viewConfiguration: ViewConfiguration,
|
||||
canStartOneHandZoom: () -> Boolean,
|
||||
canHandleQuickDoubleTap: () -> Boolean = { true },
|
||||
consumeSingleTap: Boolean = true,
|
||||
onTap: (Offset) -> Unit,
|
||||
onQuickDoubleTap: (Offset) -> Unit,
|
||||
onOneHandZoomHoldStart: (Offset) -> Unit,
|
||||
onOneHandZoom: (pivot: Offset, totalDragY: Float) -> Unit,
|
||||
onOneHandZoomEnd: (started: Boolean) -> Unit
|
||||
) {
|
||||
awaitEachGesture {
|
||||
val firstDown = awaitFirstDown(requireUnconsumed = false)
|
||||
traceOneHandZoom(
|
||||
"detector.firstDown ${firstDown.traceString()} consumeSingleTap=$consumeSingleTap " +
|
||||
"doubleTapTimeout=${viewConfiguration.doubleTapTimeoutMillis} holdTimeout=$PDF_ONE_HAND_ZOOM_HOLD_TIMEOUT_MS"
|
||||
)
|
||||
val firstUp = waitForUpOrCancellation()
|
||||
if (firstUp == null) {
|
||||
traceOneHandZoom("detector.firstUpCanceled first=${firstDown.traceString()}")
|
||||
return@awaitEachGesture
|
||||
}
|
||||
traceOneHandZoom("detector.firstUp ${firstUp.traceString()}")
|
||||
|
||||
val secondDown = awaitPdfSecondDown(
|
||||
firstPointerId = firstDown.id,
|
||||
timeoutMillis = viewConfiguration.doubleTapTimeoutMillis
|
||||
)
|
||||
|
||||
if (secondDown == null) {
|
||||
traceOneHandZoom(
|
||||
"detector.singleTap noSecondDown consume=$consumeSingleTap firstUpConsumedBefore=${firstUp.isConsumed} " +
|
||||
"tap=${firstDown.position.traceString()}"
|
||||
)
|
||||
if (consumeSingleTap) firstUp.consume()
|
||||
onTap(firstDown.position)
|
||||
return@awaitEachGesture
|
||||
}
|
||||
|
||||
val pivot = secondDown.position
|
||||
var latestPosition = pivot
|
||||
var quickDoubleTapUp: PointerInputChange? = null
|
||||
var canceled = false
|
||||
val oneHandAllowed = canStartOneHandZoom()
|
||||
val movementSlopPx = max(2f, viewConfiguration.touchSlop * 0.35f)
|
||||
var shouldStartOneHandZoom = false
|
||||
traceOneHandZoom(
|
||||
"detector.secondDown ${secondDown.traceString()} oneHandAllowed=$oneHandAllowed " +
|
||||
"quickAllowed=${canHandleQuickDoubleTap()} movementSlop=$movementSlopPx touchSlop=${viewConfiguration.touchSlop}"
|
||||
)
|
||||
|
||||
try {
|
||||
withTimeout(PDF_ONE_HAND_ZOOM_HOLD_TIMEOUT_MS) {
|
||||
while (true) {
|
||||
val event = awaitPointerEvent()
|
||||
val change = event.changes.firstOrNull { it.id == secondDown.id }
|
||||
if (change == null) {
|
||||
canceled = true
|
||||
traceOneHandZoom(
|
||||
"detector.preHoldCancel missingSecondPointer changes=${event.changes.joinToString { it.traceString() }}"
|
||||
)
|
||||
return@withTimeout
|
||||
}
|
||||
|
||||
latestPosition = change.position
|
||||
|
||||
if (change.changedToUp()) {
|
||||
quickDoubleTapUp = change
|
||||
traceOneHandZoom("detector.preHoldQuickUp ${change.traceString()}")
|
||||
return@withTimeout
|
||||
}
|
||||
|
||||
if (change.isConsumed) {
|
||||
canceled = true
|
||||
traceOneHandZoom(
|
||||
"detector.preHoldCancel consumedByOther ${change.traceString()} " +
|
||||
"allChanges=${event.changes.joinToString { it.traceString() }}"
|
||||
)
|
||||
return@withTimeout
|
||||
}
|
||||
|
||||
if (oneHandAllowed) {
|
||||
val delta = change.position - pivot
|
||||
val isVerticalZoomDrag = abs(delta.y) >= movementSlopPx &&
|
||||
abs(delta.y) >= abs(delta.x) * 1.1f
|
||||
if (isVerticalZoomDrag) {
|
||||
shouldStartOneHandZoom = true
|
||||
traceOneHandZoom(
|
||||
"detector.preHoldStart verticalDrag delta=${delta.traceString()} " +
|
||||
"slop=$movementSlopPx change=${change.traceString()}"
|
||||
)
|
||||
change.consume()
|
||||
return@withTimeout
|
||||
} else if (delta.getDistance() >= viewConfiguration.touchSlop) {
|
||||
canceled = true
|
||||
traceOneHandZoom(
|
||||
"detector.preHoldCancel nonZoomMove delta=${delta.traceString()} " +
|
||||
"distance=${delta.getDistance()} touchSlop=${viewConfiguration.touchSlop}"
|
||||
)
|
||||
return@withTimeout
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_: PointerEventTimeoutCancellationException) {
|
||||
// The second tap is being held, so the quick double-tap action is suppressed.
|
||||
shouldStartOneHandZoom = true
|
||||
traceOneHandZoom(
|
||||
"detector.holdTimeout pivot=${pivot.traceString()} latest=${latestPosition.traceString()} " +
|
||||
"oneHandAllowed=$oneHandAllowed"
|
||||
)
|
||||
}
|
||||
|
||||
if (canceled) {
|
||||
traceOneHandZoom("detector.end canceledBeforeAction latest=${latestPosition.traceString()}")
|
||||
return@awaitEachGesture
|
||||
}
|
||||
|
||||
if (quickDoubleTapUp != null) {
|
||||
val quickAllowed = canHandleQuickDoubleTap()
|
||||
traceOneHandZoom(
|
||||
"detector.quickDoubleTap fire quickAllowed=$quickAllowed upConsumedBefore=${quickDoubleTapUp?.isConsumed} " +
|
||||
"pivot=${pivot.traceString()}"
|
||||
)
|
||||
if (quickAllowed) {
|
||||
quickDoubleTapUp?.consume()
|
||||
}
|
||||
onQuickDoubleTap(pivot)
|
||||
return@awaitEachGesture
|
||||
}
|
||||
|
||||
if (!oneHandAllowed) {
|
||||
traceOneHandZoom("detector.oneHandBlocked waitingForUp pivot=${pivot.traceString()}")
|
||||
waitForUpOrCancellation()
|
||||
return@awaitEachGesture
|
||||
}
|
||||
|
||||
if (!shouldStartOneHandZoom) {
|
||||
traceOneHandZoom("detector.end noAction shouldStart=false latest=${latestPosition.traceString()}")
|
||||
return@awaitEachGesture
|
||||
}
|
||||
|
||||
var zoomStarted = false
|
||||
|
||||
fun updateZoom(position: Offset) {
|
||||
val totalDragY = position.y - pivot.y
|
||||
val action = classifyPdfSecondTapZoomAction(
|
||||
pressDurationMillis = PDF_ONE_HAND_ZOOM_HOLD_TIMEOUT_MS,
|
||||
totalDragY = totalDragY,
|
||||
movementSlopPx = movementSlopPx
|
||||
)
|
||||
if (action == PdfSecondTapZoomAction.ONE_HAND_ZOOM) {
|
||||
if (!zoomStarted) {
|
||||
traceOneHandZoom(
|
||||
"detector.oneHandZoomStart dragY=$totalDragY pivot=${pivot.traceString()} " +
|
||||
"position=${position.traceString()} slop=$movementSlopPx"
|
||||
)
|
||||
}
|
||||
zoomStarted = true
|
||||
}
|
||||
if (zoomStarted) {
|
||||
Timber.tag(PDF_ONE_HAND_ZOOM_TRACE_TAG).v(
|
||||
"detector.oneHandZoomUpdate dragY=$totalDragY pivot=${pivot.traceString()} position=${position.traceString()}"
|
||||
)
|
||||
onOneHandZoom(pivot, totalDragY)
|
||||
}
|
||||
}
|
||||
|
||||
traceOneHandZoom(
|
||||
"detector.oneHandHoldStart pivot=${pivot.traceString()} latest=${latestPosition.traceString()}"
|
||||
)
|
||||
onOneHandZoomHoldStart(pivot)
|
||||
try {
|
||||
updateZoom(latestPosition)
|
||||
|
||||
while (true) {
|
||||
val event = awaitPointerEvent()
|
||||
val change = event.changes.firstOrNull { it.id == secondDown.id }
|
||||
if (change == null) {
|
||||
traceOneHandZoom(
|
||||
"detector.postHoldEnd missingSecondPointer changes=${event.changes.joinToString { it.traceString() }}"
|
||||
)
|
||||
break
|
||||
}
|
||||
|
||||
latestPosition = change.position
|
||||
if (change.isConsumed) {
|
||||
traceOneHandZoom(
|
||||
"detector.postHoldEnd consumedByOther ${change.traceString()} zoomStarted=$zoomStarted"
|
||||
)
|
||||
break
|
||||
}
|
||||
|
||||
val isPositionChanged = change.positionChanged()
|
||||
val isUp = change.changedToUp()
|
||||
updateZoom(latestPosition)
|
||||
|
||||
if (isPositionChanged || zoomStarted || isUp) {
|
||||
Timber.tag(PDF_ONE_HAND_ZOOM_TRACE_TAG).v(
|
||||
"detector.consumePostHold changed=$isPositionChanged zoomStarted=$zoomStarted up=$isUp " +
|
||||
change.traceString()
|
||||
)
|
||||
change.consume()
|
||||
}
|
||||
|
||||
if (isUp) {
|
||||
traceOneHandZoom(
|
||||
"detector.oneHandPointerUp zoomStarted=$zoomStarted latest=${latestPosition.traceString()}"
|
||||
)
|
||||
break
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
traceOneHandZoom(
|
||||
"detector.oneHandEnd zoomStarted=$zoomStarted latest=${latestPosition.traceString()}"
|
||||
)
|
||||
onOneHandZoomEnd(zoomStarted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun androidx.compose.ui.input.pointer.AwaitPointerEventScope.awaitPdfSecondDown(
|
||||
firstPointerId: PointerId,
|
||||
timeoutMillis: Long
|
||||
): PointerInputChange? {
|
||||
return try {
|
||||
withTimeout(timeoutMillis) {
|
||||
while (true) {
|
||||
val event = awaitPointerEvent()
|
||||
val secondDown = event.changes.firstOrNull {
|
||||
it.id != firstPointerId && it.changedToDown()
|
||||
} ?: event.changes.firstOrNull {
|
||||
it.id == firstPointerId && it.changedToDown()
|
||||
}
|
||||
if (secondDown != null) return@withTimeout secondDown
|
||||
}
|
||||
null
|
||||
}
|
||||
} catch (_: PointerEventTimeoutCancellationException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,196 @@
|
|||
package com.aryan.reader.pdf
|
||||
|
||||
import com.aryan.reader.pdf.data.PdfAnnotation
|
||||
import com.aryan.reader.pdf.data.PdfTextBox
|
||||
import com.aryan.reader.pdf.data.VirtualPage
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
internal fun remapPdfAnnotationsForLayoutChange(
|
||||
currentLayout: List<VirtualPage>,
|
||||
updatedLayout: List<VirtualPage>,
|
||||
annotations: Map<Int, List<PdfAnnotation>>
|
||||
): Map<Int, List<PdfAnnotation>> {
|
||||
if (annotations.isEmpty()) return emptyMap()
|
||||
|
||||
val mapping = buildPdfPageIndexMapping(
|
||||
currentLayout = currentLayout,
|
||||
updatedLayout = updatedLayout,
|
||||
sourcePageIndices = annotations.keys
|
||||
)
|
||||
val remapped = linkedMapOf<Int, MutableList<PdfAnnotation>>()
|
||||
|
||||
annotations.toSortedMap().forEach { (sourcePageIndex, pageAnnotations) ->
|
||||
val targetPageIndex = mapping[sourcePageIndex] ?: return@forEach
|
||||
pageAnnotations.forEach { annotation ->
|
||||
remapped.getOrPut(targetPageIndex) { mutableListOf() }
|
||||
.add(annotation.copy(pageIndex = targetPageIndex))
|
||||
}
|
||||
}
|
||||
|
||||
return remapped.mapValues { (_, pageAnnotations) -> pageAnnotations.toList() }
|
||||
}
|
||||
|
||||
internal fun remapPdfTextBoxesForLayoutChange(
|
||||
currentLayout: List<VirtualPage>,
|
||||
updatedLayout: List<VirtualPage>,
|
||||
textBoxes: List<PdfTextBox>
|
||||
): List<PdfTextBox> {
|
||||
if (textBoxes.isEmpty()) return emptyList()
|
||||
|
||||
val mapping = buildPdfPageIndexMapping(
|
||||
currentLayout = currentLayout,
|
||||
updatedLayout = updatedLayout,
|
||||
sourcePageIndices = textBoxes.map { it.pageIndex }
|
||||
)
|
||||
|
||||
return textBoxes.mapNotNull { box ->
|
||||
mapping[box.pageIndex]?.let { targetPageIndex ->
|
||||
box.copy(pageIndex = targetPageIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun remapPdfUserHighlightsForLayoutChange(
|
||||
currentLayout: List<VirtualPage>,
|
||||
updatedLayout: List<VirtualPage>,
|
||||
highlights: List<PdfUserHighlight>
|
||||
): List<PdfUserHighlight> {
|
||||
if (highlights.isEmpty()) return emptyList()
|
||||
|
||||
val mapping = buildPdfPageIndexMapping(
|
||||
currentLayout = currentLayout,
|
||||
updatedLayout = updatedLayout,
|
||||
sourcePageIndices = highlights.map { it.pageIndex }
|
||||
)
|
||||
|
||||
return highlights.mapNotNull { highlight ->
|
||||
mapping[highlight.pageIndex]?.let { targetPageIndex ->
|
||||
highlight.copy(pageIndex = targetPageIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun remapPdfHistoryActionsForLayoutChange(
|
||||
currentLayout: List<VirtualPage>,
|
||||
updatedLayout: List<VirtualPage>,
|
||||
actions: List<HistoryAction>
|
||||
): List<HistoryAction> {
|
||||
if (actions.isEmpty()) return emptyList()
|
||||
|
||||
return actions.mapNotNull { action ->
|
||||
when (action) {
|
||||
is HistoryAction.Add -> {
|
||||
val mapping = buildPdfPageIndexMapping(
|
||||
currentLayout = currentLayout,
|
||||
updatedLayout = updatedLayout,
|
||||
sourcePageIndices = listOf(action.pageIndex)
|
||||
)
|
||||
val targetPageIndex = mapping[action.pageIndex] ?: return@mapNotNull null
|
||||
action.copy(
|
||||
pageIndex = targetPageIndex,
|
||||
annotation = action.annotation.copy(pageIndex = targetPageIndex)
|
||||
)
|
||||
}
|
||||
is HistoryAction.Remove -> {
|
||||
val remappedItems = remapPdfAnnotationsForLayoutChange(
|
||||
currentLayout = currentLayout,
|
||||
updatedLayout = updatedLayout,
|
||||
annotations = action.items
|
||||
)
|
||||
remappedItems.takeIf { it.isNotEmpty() }?.let(HistoryAction::Remove)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun remapPdfBookmarksJsonForLayoutChange(
|
||||
currentLayout: List<VirtualPage>,
|
||||
updatedLayout: List<VirtualPage>,
|
||||
currentBookmarksJson: String
|
||||
): String {
|
||||
if (currentBookmarksJson.isBlank()) return "[]"
|
||||
|
||||
val jsonArray = JSONArray(currentBookmarksJson)
|
||||
val sourcePageIndices = buildList {
|
||||
for (i in 0 until jsonArray.length()) {
|
||||
val pageIndex = jsonArray.optJSONObject(i)?.optInt("pageIndex", Int.MIN_VALUE)
|
||||
if (pageIndex != null && pageIndex != Int.MIN_VALUE) add(pageIndex)
|
||||
}
|
||||
}
|
||||
val mapping = buildPdfPageIndexMapping(
|
||||
currentLayout = currentLayout,
|
||||
updatedLayout = updatedLayout,
|
||||
sourcePageIndices = sourcePageIndices
|
||||
)
|
||||
val newArray = JSONArray()
|
||||
|
||||
for (i in 0 until jsonArray.length()) {
|
||||
val obj = jsonArray.getJSONObject(i)
|
||||
val sourcePageIndex = obj.optInt("pageIndex", Int.MIN_VALUE)
|
||||
val targetPageIndex = mapping[sourcePageIndex] ?: continue
|
||||
val newObj = JSONObject(obj.toString())
|
||||
newObj.put("pageIndex", targetPageIndex)
|
||||
newObj.put("totalPages", updatedLayout.size)
|
||||
newArray.put(newObj)
|
||||
}
|
||||
|
||||
return newArray.toString()
|
||||
}
|
||||
|
||||
internal fun buildPdfPageIndexMapping(
|
||||
currentLayout: List<VirtualPage>,
|
||||
updatedLayout: List<VirtualPage>,
|
||||
sourcePageIndices: Iterable<Int>
|
||||
): Map<Int, Int> {
|
||||
val distinctSourcePageIndices = sourcePageIndices.toSet()
|
||||
if (distinctSourcePageIndices.isEmpty()) return emptyMap()
|
||||
|
||||
val minimumCurrentPageCount = maxOf(
|
||||
currentLayout.size,
|
||||
distinctSourcePageIndices.maxOrNull()?.plus(1) ?: 0
|
||||
)
|
||||
val effectiveCurrentLayout = currentLayout.withDefaultPdfPagesUntil(minimumCurrentPageCount)
|
||||
val currentTokens = effectiveCurrentLayout.toOccurrenceTokens()
|
||||
val updatedTokenIndices = updatedLayout.toOccurrenceTokens()
|
||||
.mapIndexed { index, token -> token to index }
|
||||
.toMap()
|
||||
|
||||
return distinctSourcePageIndices.mapNotNull { sourcePageIndex ->
|
||||
val token = currentTokens.getOrNull(sourcePageIndex) ?: return@mapNotNull null
|
||||
val targetPageIndex = updatedTokenIndices[token] ?: return@mapNotNull null
|
||||
sourcePageIndex to targetPageIndex
|
||||
}.toMap()
|
||||
}
|
||||
|
||||
private fun List<VirtualPage>.withDefaultPdfPagesUntil(pageCount: Int): List<VirtualPage> {
|
||||
if (size >= pageCount) return this
|
||||
return this + (size until pageCount).map { VirtualPage.PdfPage(it) }
|
||||
}
|
||||
|
||||
private fun List<VirtualPage>.toOccurrenceTokens(): List<VirtualPageOccurrenceToken> {
|
||||
val seen = mutableMapOf<VirtualPageKey, Int>()
|
||||
return map { page ->
|
||||
val key = page.toVirtualPageKey()
|
||||
val occurrence = seen.getOrDefault(key, 0)
|
||||
seen[key] = occurrence + 1
|
||||
VirtualPageOccurrenceToken(key, occurrence)
|
||||
}
|
||||
}
|
||||
|
||||
private fun VirtualPage.toVirtualPageKey(): VirtualPageKey {
|
||||
return when (this) {
|
||||
is VirtualPage.PdfPage -> VirtualPageKey.Pdf(pdfIndex)
|
||||
is VirtualPage.BlankPage -> VirtualPageKey.Blank(id)
|
||||
}
|
||||
}
|
||||
|
||||
private data class VirtualPageOccurrenceToken(
|
||||
val key: VirtualPageKey,
|
||||
val occurrence: Int
|
||||
)
|
||||
|
||||
private sealed interface VirtualPageKey {
|
||||
data class Pdf(val pdfIndex: Int) : VirtualPageKey
|
||||
data class Blank(val id: String) : VirtualPageKey
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
22
app/src/main/java/com/aryan/reader/pdf/PdfPageLayoutDebug.kt
Normal file
22
app/src/main/java/com/aryan/reader/pdf/PdfPageLayoutDebug.kt
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
package com.aryan.reader.pdf
|
||||
|
||||
import com.aryan.reader.pdf.data.VirtualPage
|
||||
|
||||
internal const val PDF_BLANK_PAGE_PERSISTENCE_TAG = "PdfBlankPagePersist"
|
||||
|
||||
internal fun List<VirtualPage>.pdfLayoutDebugSummary(maxPages: Int = 16): String {
|
||||
val blankPages = filterIsInstance<VirtualPage.BlankPage>()
|
||||
val sample = take(maxPages).mapIndexed { displayIndex, page ->
|
||||
when (page) {
|
||||
is VirtualPage.PdfPage -> "$displayIndex:P${page.pdfIndex}"
|
||||
is VirtualPage.BlankPage ->
|
||||
"$displayIndex:B(${page.id.take(8)},${page.width}x${page.height},manual=${page.wasManuallyAdded})"
|
||||
}
|
||||
}.let { pages ->
|
||||
if (size > maxPages) pages + "...(+${size - maxPages})" else pages
|
||||
}
|
||||
|
||||
return "size=$size pdf=${count { it is VirtualPage.PdfPage }} " +
|
||||
"blank=${blankPages.size} manualBlank=${blankPages.count { it.wasManuallyAdded }} " +
|
||||
"pages=${sample.joinToString(prefix = "[", postfix = "]")}"
|
||||
}
|
||||
25
app/src/main/java/com/aryan/reader/pdf/PdfPageLinks.kt
Normal file
25
app/src/main/java/com/aryan/reader/pdf/PdfPageLinks.kt
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
package com.aryan.reader.pdf
|
||||
|
||||
import android.graphics.Rect
|
||||
import com.aryan.reader.pdf.data.VirtualPage
|
||||
|
||||
enum class LinkSource {
|
||||
ANNOTATION, TEXT_CONTENT
|
||||
}
|
||||
|
||||
data class PageLink(
|
||||
val highlightBounds: Rect,
|
||||
val tapBounds: Rect,
|
||||
val url: String?,
|
||||
val destPageIdx: Int?,
|
||||
val source: LinkSource
|
||||
)
|
||||
|
||||
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"
|
||||
}
|
||||
422
app/src/main/java/com/aryan/reader/pdf/PdfPageRenderResources.kt
Normal file
422
app/src/main/java/com/aryan/reader/pdf/PdfPageRenderResources.kt
Normal file
|
|
@ -0,0 +1,422 @@
|
|||
package com.aryan.reader.pdf
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Rect
|
||||
import android.util.LruCache
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.BlendMode
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.core.graphics.createBitmap
|
||||
import androidx.core.graphics.set
|
||||
import com.aryan.reader.pdf.data.PdfAnnotation
|
||||
import timber.log.Timber
|
||||
import java.util.concurrent.ConcurrentLinkedQueue
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.atan2
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.sin
|
||||
import kotlin.math.sqrt
|
||||
import android.graphics.Color as AndroidColor
|
||||
|
||||
data class PdfTile(val bitmap: Bitmap, val renderRect: Rect, val tileId: Int, val renderScale: Float = 1f)
|
||||
|
||||
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 bitmap = iterator.next()
|
||||
if (bitmap.width == width && bitmap.height == height && !bitmap.isRecycled) {
|
||||
iterator.remove()
|
||||
bitmap.eraseColor(AndroidColor.TRANSPARENT)
|
||||
return bitmap
|
||||
}
|
||||
}
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
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 point = annot.points[0]
|
||||
val x = point.x * widthPx
|
||||
val y = point.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
|
||||
}
|
||||
|
||||
return AnnotationRenderData.Standard(
|
||||
path = path,
|
||||
color = annot.color,
|
||||
strokeWidth = annot.strokeWidth * widthPx,
|
||||
cap = cap,
|
||||
blendMode = BlendMode.SrcOver
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -8,9 +8,9 @@ import androidx.compose.ui.graphics.toArgb
|
|||
import androidx.core.content.edit
|
||||
import com.aryan.reader.BuildConfig
|
||||
import com.aryan.reader.R
|
||||
import com.aryan.reader.ReaderTheme
|
||||
import com.aryan.reader.ReaderTexture
|
||||
import com.aryan.reader.epubreader.SystemUiMode
|
||||
import com.aryan.reader.shared.BuiltInPdfReaderThemes
|
||||
import com.aryan.reader.shared.reader.ReaderPageSpreadMode
|
||||
|
||||
internal const val VERTICAL_SCROLL_TAG = "PdfVerticalScroll"
|
||||
internal const val SETTINGS_PREFS_NAME = "epub_reader_settings"
|
||||
|
|
@ -33,7 +33,6 @@ private const val PDF_AUTO_SCROLL_LOCAL_SPEED_PREFIX = "pdf_as_local_speed_"
|
|||
private const val PDF_AUTO_SCROLL_LOCAL_MIN_PREFIX = "pdf_as_local_min_"
|
||||
private const val PDF_AUTO_SCROLL_LOCAL_MAX_PREFIX = "pdf_as_local_max_"
|
||||
private const val PDF_SCROLL_LOCKED_PREFIX = "pdf_sl_local_"
|
||||
internal const val PDF_FULL_SCREEN_PREFIX = "pdf_fs_local_"
|
||||
private const val PDF_MUSICIAN_MODE_KEY = "pdf_musician_mode_enabled"
|
||||
private const val PREF_USE_ONLINE_DICT = "use_online_dictionary"
|
||||
private const val PREF_EXTERNAL_DICT_PKG = "external_dictionary_package"
|
||||
|
|
@ -47,17 +46,21 @@ internal const val PDF_BOTTOM_TOOLS_KEY = "pdf_bottom_tools"
|
|||
internal const val PDF_SYSTEM_UI_MODE_KEY = "pdf_system_ui_mode"
|
||||
internal const val PDF_VERTICAL_PAGE_GAP_VISIBLE_KEY = "pdf_vertical_page_gap_visible"
|
||||
internal const val PDF_PAGE_NUMBER_OVERLAY_VISIBLE_KEY = "pdf_page_number_overlay_visible"
|
||||
internal const val PDF_TOP_TAB_STRIP_VISIBLE_KEY = "pdf_top_tab_strip_visible"
|
||||
internal const val PDF_PAGE_SPREAD_MODE_KEY = "pdf_page_spread_mode"
|
||||
internal const val PDF_FIRST_PAGE_STANDALONE_IN_SPREAD_KEY = "pdf_first_page_standalone_in_spread"
|
||||
internal const val PDF_LAYOUT_DEBUG_TAG = "PdfLayoutDebug"
|
||||
private const val PDF_HIDDEN_TOOLS_DEFAULTS_VERSION_KEY = "pdf_hidden_tools_defaults_version"
|
||||
private const val PDF_HIDDEN_TOOLS_DEFAULTS_VERSION = 2
|
||||
private const val PDF_HIDDEN_TOOLS_DEFAULTS_VERSION = 3
|
||||
|
||||
enum class PdfReaderTool(@StringRes val titleRes: Int, val category: String) {
|
||||
DICTIONARY(R.string.tool_external_apps, "Top Bar"),
|
||||
THEME(R.string.tooltip_theme_desc, "Top Bar"),
|
||||
BRIGHTNESS(R.string.tool_brightness, "Top Bar"),
|
||||
LOCK_PANNING(R.string.tooltip_lock_pan, "Top Bar"),
|
||||
FILE_INFO(R.string.file_information, "Overflow Menu"),
|
||||
VISUAL_OPTIONS(R.string.menu_visual_options, "Overflow Menu"),
|
||||
TAP_TO_TURN(R.string.menu_tap_to_turn_pages, "Overflow Menu"),
|
||||
FULL_SCREEN(R.string.tooltip_fullscreen, "Top Bar"),
|
||||
SLIDER(R.string.tool_navigation_slider, "Bottom Bar"),
|
||||
TOC(R.string.tool_sidebar, "Bottom Bar"),
|
||||
SEARCH(R.string.action_search, "Bottom Bar"),
|
||||
|
|
@ -83,38 +86,41 @@ enum class PdfReaderTool(@StringRes val titleRes: Int, val category: String) {
|
|||
internal fun defaultPdfHiddenTools(): Set<String> {
|
||||
return setOf(
|
||||
PdfReaderTool.SCREEN_ORIENTATION.name,
|
||||
PdfReaderTool.HIGHLIGHT_ALL.name
|
||||
PdfReaderTool.HIGHLIGHT_ALL.name,
|
||||
PdfReaderTool.BRIGHTNESS.name
|
||||
)
|
||||
}
|
||||
|
||||
internal fun defaultPdfToolOrder(): List<PdfReaderTool> = PdfReaderTool.entries.toList()
|
||||
|
||||
internal fun defaultPdfBottomTools(): Set<String> {
|
||||
return PdfReaderTool.entries.filter { it.category == "Bottom Bar" }.map { it.name }.toSet()
|
||||
internal fun isPdfReaderToolAvailable(tool: PdfReaderTool): Boolean {
|
||||
return BuildConfig.IS_PRO || tool != PdfReaderTool.OCR_LANGUAGE
|
||||
}
|
||||
|
||||
val PdfBuiltInThemes = listOf(
|
||||
ReaderTheme("no_theme", "No Theme", Color.Unspecified, Color.Unspecified, false),
|
||||
ReaderTheme("reverse", "Reverse", Color.Black, Color.White, true),
|
||||
ReaderTheme("light", "Light", Color(0xFFFFFFFF), Color(0xFF000000), false),
|
||||
ReaderTheme("dark", "Dark", Color(0xFF121212), Color(0xFFE0E0E0), true),
|
||||
ReaderTheme("sepia", "Sepia", Color(0xFFFBF0D9), Color(0xFF5F4B32), false),
|
||||
ReaderTheme("slate", "Slate", Color(0xFF2E3440), Color(0xFFECEFF4), true),
|
||||
ReaderTheme("oled", "OLED", Color(0xFF000000), Color(0xFFB0B0B0), true),
|
||||
ReaderTheme("pdf_natural_white_texture", "Natural White", Color(0xFFF7F1E5), Color(0xFF1D1B18), false, textureId = ReaderTexture.NATURAL_WHITE.id),
|
||||
ReaderTheme("pdf_retina_texture", "Retina", Color(0xFFF1E4CD), Color(0xFF2A2119), false, textureId = ReaderTexture.RETINA_WOOD.id),
|
||||
ReaderTheme("pdf_veneer_texture", "Veneer", Color(0xFFF4E7CF), Color(0xFF2A2119), false, textureId = ReaderTexture.LIGHT_VENEER.id),
|
||||
ReaderTheme("pdf_grey_wash_texture", "Grey Wash", Color(0xFF202124), Color(0xFFFFFFFF), true, textureId = ReaderTexture.GREY_WASH.id),
|
||||
ReaderTheme("pdf_fabric_texture", "Fabric", Color(0xFF262626), Color(0xFFE8E2D8), true, textureId = ReaderTexture.CLASSY_FABRIC.id),
|
||||
ReaderTheme("pdf_retro_texture", "Retro", Color(0xFFF6ECD8), Color(0xFF2F2118), false, textureId = ReaderTexture.RETRO_INTRO.id)
|
||||
)
|
||||
internal fun defaultPdfToolOrder(): List<PdfReaderTool> = PdfReaderTool.entries.filter(::isPdfReaderToolAvailable)
|
||||
|
||||
internal fun defaultPdfBottomTools(): Set<String> {
|
||||
return defaultPdfToolOrder().filter { it.category == "Bottom Bar" }.map { it.name }.toSet()
|
||||
}
|
||||
|
||||
val PdfBuiltInThemes = BuiltInPdfReaderThemes
|
||||
|
||||
private fun sanitizePdfToolNameSet(
|
||||
toolNames: Set<String>,
|
||||
includeTool: (PdfReaderTool) -> Boolean = { true }
|
||||
): Set<String> {
|
||||
return toolNames.mapNotNull { toolName ->
|
||||
PdfReaderTool.entries
|
||||
.firstOrNull { it.name == toolName }
|
||||
?.takeIf { isPdfReaderToolAvailable(it) && includeTool(it) }
|
||||
?.name
|
||||
}.toSet()
|
||||
}
|
||||
|
||||
internal fun loadPdfHiddenTools(context: Context): Set<String> {
|
||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||
val savedHiddenTools = prefs.getStringSet(PDF_HIDDEN_TOOLS_KEY, emptySet()).orEmpty()
|
||||
val savedHiddenTools = sanitizePdfToolNameSet(prefs.getStringSet(PDF_HIDDEN_TOOLS_KEY, emptySet()).orEmpty())
|
||||
val defaultsVersion = prefs.getInt(PDF_HIDDEN_TOOLS_DEFAULTS_VERSION_KEY, 0)
|
||||
if (defaultsVersion < PDF_HIDDEN_TOOLS_DEFAULTS_VERSION) {
|
||||
val migratedHiddenTools = savedHiddenTools + defaultPdfHiddenTools()
|
||||
val migratedHiddenTools = sanitizePdfToolNameSet(savedHiddenTools + pdfHiddenToolsIntroducedAfter(defaultsVersion))
|
||||
prefs.edit {
|
||||
putStringSet(PDF_HIDDEN_TOOLS_KEY, migratedHiddenTools)
|
||||
putInt(PDF_HIDDEN_TOOLS_DEFAULTS_VERSION_KEY, PDF_HIDDEN_TOOLS_DEFAULTS_VERSION)
|
||||
|
|
@ -124,10 +130,20 @@ internal fun loadPdfHiddenTools(context: Context): Set<String> {
|
|||
return savedHiddenTools
|
||||
}
|
||||
|
||||
private fun pdfHiddenToolsIntroducedAfter(defaultsVersion: Int): Set<String> {
|
||||
return buildSet {
|
||||
if (defaultsVersion < 2) {
|
||||
add(PdfReaderTool.SCREEN_ORIENTATION.name)
|
||||
add(PdfReaderTool.HIGHLIGHT_ALL.name)
|
||||
}
|
||||
if (defaultsVersion < 3) add(PdfReaderTool.BRIGHTNESS.name)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun savePdfHiddenTools(context: Context, hiddenTools: Set<String>) {
|
||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||
prefs.edit {
|
||||
putStringSet(PDF_HIDDEN_TOOLS_KEY, hiddenTools)
|
||||
putStringSet(PDF_HIDDEN_TOOLS_KEY, sanitizePdfToolNameSet(hiddenTools))
|
||||
putInt(PDF_HIDDEN_TOOLS_DEFAULTS_VERSION_KEY, PDF_HIDDEN_TOOLS_DEFAULTS_VERSION)
|
||||
}
|
||||
}
|
||||
|
|
@ -138,24 +154,41 @@ internal fun loadPdfToolOrder(context: Context): List<PdfReaderTool> {
|
|||
?.split(',')
|
||||
?.filter { it.isNotBlank() }
|
||||
?.mapNotNull { name -> PdfReaderTool.entries.firstOrNull { it.name == name } }
|
||||
?.filter(::isPdfReaderToolAvailable)
|
||||
.orEmpty()
|
||||
return (savedTools + defaultPdfToolOrder().filterNot { it in savedTools }).distinct()
|
||||
}
|
||||
|
||||
internal fun savePdfToolOrder(context: Context, toolOrder: List<PdfReaderTool>) {
|
||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||
prefs.edit { putString(PDF_TOOL_ORDER_KEY, toolOrder.joinToString(",") { it.name }) }
|
||||
prefs.edit {
|
||||
putString(
|
||||
PDF_TOOL_ORDER_KEY,
|
||||
toolOrder.filter(::isPdfReaderToolAvailable).joinToString(",") { it.name }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun loadPdfBottomTools(context: Context): Set<String> {
|
||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||
val defaultBottomTools = defaultPdfBottomTools()
|
||||
return prefs.getStringSet(PDF_BOTTOM_TOOLS_KEY, defaultBottomTools) ?: defaultBottomTools
|
||||
return sanitizePdfToolNameSet(
|
||||
toolNames = prefs.getStringSet(PDF_BOTTOM_TOOLS_KEY, defaultBottomTools) ?: defaultBottomTools,
|
||||
includeTool = { it.category == "Bottom Bar" }
|
||||
)
|
||||
}
|
||||
|
||||
internal fun savePdfBottomTools(context: Context, bottomTools: Set<String>) {
|
||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||
prefs.edit { putStringSet(PDF_BOTTOM_TOOLS_KEY, bottomTools) }
|
||||
prefs.edit {
|
||||
putStringSet(
|
||||
PDF_BOTTOM_TOOLS_KEY,
|
||||
sanitizePdfToolNameSet(
|
||||
toolNames = bottomTools,
|
||||
includeTool = { it.category == "Bottom Bar" }
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun loadCustomHighlightColors(context: Context): Map<PdfHighlightColor, Color> {
|
||||
|
|
@ -217,6 +250,38 @@ internal fun loadPdfPageNumberOverlayVisible(context: Context): Boolean {
|
|||
return prefs.getBoolean(PDF_PAGE_NUMBER_OVERLAY_VISIBLE_KEY, true)
|
||||
}
|
||||
|
||||
internal fun savePdfPageSpreadMode(context: Context, mode: ReaderPageSpreadMode) {
|
||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||
prefs.edit { putString(PDF_PAGE_SPREAD_MODE_KEY, mode.name) }
|
||||
}
|
||||
|
||||
internal fun loadPdfPageSpreadMode(context: Context): ReaderPageSpreadMode {
|
||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||
val modeName = prefs.getString(PDF_PAGE_SPREAD_MODE_KEY, ReaderPageSpreadMode.SINGLE.name)
|
||||
return runCatching { ReaderPageSpreadMode.valueOf(modeName ?: ReaderPageSpreadMode.SINGLE.name) }
|
||||
.getOrDefault(ReaderPageSpreadMode.SINGLE)
|
||||
}
|
||||
|
||||
internal fun savePdfFirstPageStandaloneInSpread(context: Context, isEnabled: Boolean) {
|
||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||
prefs.edit { putBoolean(PDF_FIRST_PAGE_STANDALONE_IN_SPREAD_KEY, isEnabled) }
|
||||
}
|
||||
|
||||
internal fun loadPdfFirstPageStandaloneInSpread(context: Context): Boolean {
|
||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||
return prefs.getBoolean(PDF_FIRST_PAGE_STANDALONE_IN_SPREAD_KEY, false)
|
||||
}
|
||||
|
||||
internal fun savePdfTopTabStripVisible(context: Context, isVisible: Boolean) {
|
||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||
prefs.edit { putBoolean(PDF_TOP_TAB_STRIP_VISIBLE_KEY, isVisible) }
|
||||
}
|
||||
|
||||
internal fun loadPdfTopTabStripVisible(context: Context): Boolean {
|
||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||
return prefs.getBoolean(PDF_TOP_TAB_STRIP_VISIBLE_KEY, true)
|
||||
}
|
||||
|
||||
internal fun savePdfThemeId(context: Context, themeId: String) {
|
||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||
prefs.edit { putString(PDF_THEME_KEY, themeId) }
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
|||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.LockOpen
|
||||
import androidx.compose.material.icons.filled.Menu
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
|
|
@ -72,6 +73,8 @@ import androidx.compose.ui.window.DialogProperties
|
|||
import com.aryan.reader.R
|
||||
import com.aryan.reader.epubreader.OptionSegmentedControl
|
||||
import com.aryan.reader.epubreader.SystemUiMode
|
||||
import com.aryan.reader.epubreader.titleRes
|
||||
import com.aryan.reader.shared.reader.ReaderPageSpreadMode
|
||||
|
||||
|
||||
enum class PdfFlatItemType { SECTION_HEADER, TOOL, EMPTY_PLACEHOLDER, MORE_HEADER, MORE_TOOL }
|
||||
|
|
@ -114,7 +117,7 @@ fun sanitizePdfPlaceholders(list: List<PdfFlatToolItem>): List<PdfFlatToolItem>
|
|||
}
|
||||
|
||||
private val pdfReorderableToolbarTools = setOf(
|
||||
PdfReaderTool.DICTIONARY, PdfReaderTool.THEME, PdfReaderTool.LOCK_PANNING,
|
||||
PdfReaderTool.DICTIONARY, PdfReaderTool.THEME, PdfReaderTool.BRIGHTNESS, PdfReaderTool.LOCK_PANNING,
|
||||
PdfReaderTool.SLIDER, PdfReaderTool.TOC, PdfReaderTool.SEARCH,
|
||||
PdfReaderTool.HIGHLIGHT_ALL, PdfReaderTool.AI_FEATURES,
|
||||
PdfReaderTool.EDIT_MODE, PdfReaderTool.TTS_CONTROLS,
|
||||
|
|
@ -126,11 +129,12 @@ internal fun buildPdfToolbarItems(
|
|||
toolOrder: List<PdfReaderTool>,
|
||||
bottomTools: Set<String>
|
||||
): List<PdfFlatToolItem> {
|
||||
val toolbarTools = toolOrder.filter { it in pdfReorderableToolbarTools }
|
||||
val availableToolOrder = toolOrder.filter(::isPdfReaderToolAvailable)
|
||||
val toolbarTools = availableToolOrder.filter { it in pdfReorderableToolbarTools }
|
||||
val topTools = toolbarTools.filter { !bottomTools.contains(it.name) && !hiddenTools.contains(it.name) }
|
||||
val bottomToolsList = toolbarTools.filter { bottomTools.contains(it.name) && !hiddenTools.contains(it.name) }
|
||||
val hiddenToolsList = toolbarTools.filter { hiddenTools.contains(it.name) }
|
||||
val moreTools = toolOrder.filter { it !in pdfReorderableToolbarTools }
|
||||
val moreTools = availableToolOrder.filter { it !in pdfReorderableToolbarTools }
|
||||
|
||||
val list = mutableListOf<PdfFlatToolItem>()
|
||||
|
||||
|
|
@ -541,7 +545,9 @@ private fun PdfToolPreviewIcon(tool: PdfReaderTool) {
|
|||
when (tool) {
|
||||
PdfReaderTool.DICTIONARY -> Icon(painterResource(id = R.drawable.dictionary), contentDescription = title, modifier = Modifier.size(20.dp))
|
||||
PdfReaderTool.THEME -> Icon(painterResource(id = R.drawable.palette), contentDescription = title, modifier = Modifier.size(20.dp))
|
||||
PdfReaderTool.BRIGHTNESS -> Icon(painterResource(id = R.drawable.contrast), contentDescription = title, modifier = Modifier.size(20.dp))
|
||||
PdfReaderTool.LOCK_PANNING -> Icon(Icons.Default.LockOpen, contentDescription = title, modifier = Modifier.size(20.dp))
|
||||
PdfReaderTool.FILE_INFO -> Icon(Icons.Default.Info, contentDescription = title, modifier = Modifier.size(20.dp))
|
||||
PdfReaderTool.SLIDER -> Icon(painterResource(id = R.drawable.slider), contentDescription = title, modifier = Modifier.size(20.dp))
|
||||
PdfReaderTool.TOC -> Icon(Icons.Default.Menu, contentDescription = title, modifier = Modifier.size(20.dp))
|
||||
PdfReaderTool.SEARCH -> Icon(Icons.Default.Search, contentDescription = title, modifier = Modifier.size(20.dp))
|
||||
|
|
@ -556,9 +562,14 @@ private fun PdfToolPreviewIcon(tool: PdfReaderTool) {
|
|||
|
||||
@Composable
|
||||
fun PdfVisualOptionsSheet(
|
||||
displayMode: DisplayMode,
|
||||
systemUiMode: SystemUiMode,
|
||||
pageSpreadMode: ReaderPageSpreadMode,
|
||||
firstPageStandaloneInSpread: Boolean,
|
||||
showVerticalPageGap: Boolean,
|
||||
showPageNumberOverlay: Boolean,
|
||||
onPageSpreadModeChange: (ReaderPageSpreadMode) -> Unit,
|
||||
onFirstPageStandaloneInSpreadChange: (Boolean) -> Unit,
|
||||
onSystemUiModeChange: (SystemUiMode) -> Unit,
|
||||
onShowVerticalPageGapChange: (Boolean) -> Unit,
|
||||
onShowPageNumberOverlayChange: (Boolean) -> Unit,
|
||||
|
|
@ -606,6 +617,35 @@ fun PdfVisualOptionsSheet(
|
|||
|
||||
Text(stringResource(R.string.visual_options_page_layout), style = MaterialTheme.typography.titleMedium)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
if (displayMode == DisplayMode.PAGINATION) {
|
||||
Text(
|
||||
stringResource(R.string.visual_options_pdf_page_spread),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
OptionSegmentedControl(
|
||||
options = ReaderPageSpreadMode.entries,
|
||||
selectedOption = pageSpreadMode,
|
||||
onOptionSelected = onPageSpreadModeChange,
|
||||
getLabel = {
|
||||
when (it) {
|
||||
ReaderPageSpreadMode.SINGLE -> stringResource(R.string.visual_options_pdf_spread_single)
|
||||
ReaderPageSpreadMode.TWO_PAGE -> stringResource(R.string.visual_options_pdf_spread_two)
|
||||
}
|
||||
}
|
||||
)
|
||||
if (pageSpreadMode == ReaderPageSpreadMode.TWO_PAGE) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
PdfVisualOptionSwitchRow(
|
||||
title = stringResource(R.string.visual_options_pdf_first_page_alone),
|
||||
description = stringResource(R.string.visual_options_pdf_first_page_alone_desc),
|
||||
checked = firstPageStandaloneInSpread,
|
||||
onCheckedChange = onFirstPageStandaloneInSpreadChange
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
}
|
||||
PdfVisualOptionSwitchRow(
|
||||
title = stringResource(R.string.visual_options_remove_page_gap),
|
||||
description = stringResource(R.string.visual_options_remove_page_gap_desc),
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ import androidx.compose.runtime.derivedStateOf
|
|||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
|
|
@ -55,6 +56,7 @@ import androidx.compose.ui.graphics.Color
|
|||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.pointer.PointerInputChange
|
||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.input.pointer.PointerInputScope
|
||||
import androidx.compose.ui.input.pointer.changedToUp
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
|
|
@ -85,6 +87,63 @@ enum class HandlePosition {
|
|||
TOP, BOTTOM, AUTO
|
||||
}
|
||||
|
||||
private const val TEXT_BOX_DRAG_PILL_VISUAL_WIDTH_DP = 48f
|
||||
private const val TEXT_BOX_DRAG_PILL_VISUAL_HEIGHT_DP = 24f
|
||||
private const val TEXT_BOX_DRAG_PILL_TOUCH_WIDTH_DP = 72f
|
||||
private const val TEXT_BOX_DRAG_PILL_TOUCH_HEIGHT_DP = 48f
|
||||
private const val TEXT_BOX_DRAG_PILL_GAP_DP = 8f
|
||||
|
||||
internal data class TextBoxChromeLayout(
|
||||
val containerWidthPx: Float,
|
||||
val containerHeightPx: Float,
|
||||
val contentWidthPx: Float,
|
||||
val contentHeightPx: Float,
|
||||
val contentOffsetX: Float,
|
||||
val contentOffsetY: Float,
|
||||
val outerTranslationX: Float,
|
||||
val outerTranslationY: Float,
|
||||
val dragPillLeftPx: Float,
|
||||
val dragPillTopPx: Float
|
||||
)
|
||||
|
||||
internal fun calculateTextBoxChromeLayout(
|
||||
textBoundsPx: Rect,
|
||||
isSelected: Boolean,
|
||||
isHandleAtTop: Boolean,
|
||||
handleSizePx: Float,
|
||||
dragPillWidthPx: Float,
|
||||
dragPillHeightPx: Float,
|
||||
dragPillGapPx: Float
|
||||
): TextBoxChromeLayout {
|
||||
val halfHandlePx = handleSizePx / 2f
|
||||
val contentWidthPx = textBoundsPx.width + handleSizePx
|
||||
val contentHeightPx = textBoundsPx.height + handleSizePx
|
||||
val dragPillTrackHeightPx = if (isSelected) dragPillHeightPx + dragPillGapPx else 0f
|
||||
val containerWidthPx = maxOf(contentWidthPx, if (isSelected) dragPillWidthPx else contentWidthPx)
|
||||
val containerHeightPx = contentHeightPx + dragPillTrackHeightPx
|
||||
val contentOffsetX = (containerWidthPx - contentWidthPx) / 2f
|
||||
val contentOffsetY = if (isSelected && isHandleAtTop) dragPillTrackHeightPx else 0f
|
||||
val dragPillLeftPx = (containerWidthPx - dragPillWidthPx) / 2f
|
||||
val dragPillTopPx = if (isSelected && isHandleAtTop) {
|
||||
0f
|
||||
} else {
|
||||
containerHeightPx - dragPillHeightPx
|
||||
}
|
||||
|
||||
return TextBoxChromeLayout(
|
||||
containerWidthPx = containerWidthPx,
|
||||
containerHeightPx = containerHeightPx,
|
||||
contentWidthPx = contentWidthPx,
|
||||
contentHeightPx = contentHeightPx,
|
||||
contentOffsetX = contentOffsetX,
|
||||
contentOffsetY = contentOffsetY,
|
||||
outerTranslationX = textBoundsPx.left - halfHandlePx - contentOffsetX,
|
||||
outerTranslationY = textBoundsPx.top - halfHandlePx - contentOffsetY,
|
||||
dragPillLeftPx = dragPillLeftPx,
|
||||
dragPillTopPx = dragPillTopPx
|
||||
)
|
||||
}
|
||||
|
||||
// Eagerly consumes pointer events so parent scaled pan/zoom gestures don't intercept it
|
||||
suspend fun PointerInputScope.detectEagerDragGestures(
|
||||
onDragStart: (Offset) -> Unit,
|
||||
|
|
@ -95,14 +154,14 @@ suspend fun PointerInputScope.detectEagerDragGestures(
|
|||
awaitEachGesture {
|
||||
var dragStarted = false
|
||||
try {
|
||||
val down = awaitFirstDown(requireUnconsumed = false)
|
||||
val down = awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial)
|
||||
down.consume() // Consume immediately
|
||||
onDragStart(down.position)
|
||||
dragStarted = true
|
||||
val pointerId = down.id
|
||||
var canceled = false
|
||||
while (true) {
|
||||
val event = awaitPointerEvent()
|
||||
val event = awaitPointerEvent(PointerEventPass.Initial)
|
||||
val change = event.changes.firstOrNull { it.id == pointerId }
|
||||
if (change == null) {
|
||||
canceled = true
|
||||
|
|
@ -151,6 +210,13 @@ fun ResizableTextBox(
|
|||
|
||||
val density = LocalDensity.current
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val currentOnBoundsChanged by rememberUpdatedState(onBoundsChanged)
|
||||
val currentOnTextChanged by rememberUpdatedState(onTextChanged)
|
||||
val currentOnSelect by rememberUpdatedState(onSelect)
|
||||
val currentOnDragStart by rememberUpdatedState(onDragStart)
|
||||
val currentOnDrag by rememberUpdatedState(onDrag)
|
||||
val currentOnDragEnd by rememberUpdatedState(onDragEnd)
|
||||
val currentOnDragCancel by rememberUpdatedState(onDragCancel)
|
||||
|
||||
// Counter-scale fixed sizes so they render proportionally regardless of the zoom level
|
||||
val handleSize = (10f / scale).dp
|
||||
|
|
@ -236,170 +302,207 @@ fun ResizableTextBox(
|
|||
}
|
||||
}
|
||||
|
||||
val dragPillTouchWidth = (TEXT_BOX_DRAG_PILL_TOUCH_WIDTH_DP / scale).dp
|
||||
val dragPillTouchHeight = (TEXT_BOX_DRAG_PILL_TOUCH_HEIGHT_DP / scale).dp
|
||||
val dragPillWidthPx = with(density) { dragPillTouchWidth.toPx() }
|
||||
val dragPillHeightPx = with(density) { dragPillTouchHeight.toPx() }
|
||||
val dragPillGapPx = with(density) { (TEXT_BOX_DRAG_PILL_GAP_DP / scale).dp.toPx() }
|
||||
val chromeLayout = calculateTextBoxChromeLayout(
|
||||
textBoundsPx = currentRectPx,
|
||||
isSelected = isSelected,
|
||||
isHandleAtTop = isHandleAtTop,
|
||||
handleSizePx = handleSizePx,
|
||||
dragPillWidthPx = dragPillWidthPx,
|
||||
dragPillHeightPx = dragPillHeightPx,
|
||||
dragPillGapPx = dragPillGapPx
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.zIndex(if (isSelected) 10f else 0f)
|
||||
.graphicsLayer {
|
||||
translationX = currentRectPx.left - halfHandlePx
|
||||
translationY = currentRectPx.top - halfHandlePx
|
||||
translationX = chromeLayout.outerTranslationX
|
||||
translationY = chromeLayout.outerTranslationY
|
||||
}
|
||||
.size(
|
||||
width = with(density) { (currentRectPx.width + handleSizePx).toDp() },
|
||||
height = with(density) { (currentRectPx.height + handleSizePx).toDp() }
|
||||
width = with(density) { chromeLayout.containerWidthPx.toDp() },
|
||||
height = with(density) { chromeLayout.containerHeightPx.toDp() }
|
||||
)
|
||||
) {
|
||||
// --- 1. Content Body ---
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(handleSize / 2)
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures {
|
||||
Timber.tag("PdfTextBoxDebug").d("TextBox Tapped[ID: ${box.id}]")
|
||||
onSelect()
|
||||
}
|
||||
.offset {
|
||||
IntOffset(
|
||||
chromeLayout.contentOffsetX.roundToInt(),
|
||||
chromeLayout.contentOffsetY.roundToInt()
|
||||
)
|
||||
}
|
||||
.then(
|
||||
if (isSelected) Modifier.border((1.5f / scale).dp, borderColor) else Modifier
|
||||
.size(
|
||||
width = with(density) { chromeLayout.contentWidthPx.toDp() },
|
||||
height = with(density) { chromeLayout.contentHeightPx.toDp() }
|
||||
)
|
||||
.zIndex(1f)
|
||||
) {
|
||||
BasicTextField(
|
||||
value = box.text,
|
||||
onValueChange = onTextChanged,
|
||||
// --- 1. Content Body ---
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(8.dp)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.focusRequester(focusRequester),
|
||||
textStyle = TextStyle(
|
||||
color = box.color,
|
||||
background = box.backgroundColor,
|
||||
fontFamily = fontFamily,
|
||||
fontSize = with(LocalDensity.current) {
|
||||
(box.fontSize * pageHeightPx).toSp()
|
||||
},
|
||||
fontWeight = if (box.isBold) FontWeight.Bold else FontWeight.Normal,
|
||||
fontStyle = if (box.isItalic) FontStyle.Italic else FontStyle.Normal,
|
||||
textDecoration = run {
|
||||
val decs = mutableListOf<TextDecoration>()
|
||||
if (box.isUnderline) decs.add(TextDecoration.Underline)
|
||||
if (box.isStrikeThrough) decs.add(TextDecoration.LineThrough)
|
||||
if (decs.isEmpty()) TextDecoration.None else TextDecoration.combine(decs)
|
||||
.padding(handleSize / 2)
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures {
|
||||
Timber.tag("PdfTextBoxDebug").d("TextBox Tapped[ID: ${box.id}]")
|
||||
currentOnSelect()
|
||||
}
|
||||
}
|
||||
),
|
||||
cursorBrush = SolidColor(if (isDarkMode) Color.White else MaterialTheme.colorScheme.primary),
|
||||
enabled = isEditMode && isSelected,
|
||||
readOnly = !isEditMode
|
||||
)
|
||||
.then(
|
||||
if (isSelected) Modifier.border((1.5f / scale).dp, borderColor) else Modifier
|
||||
)
|
||||
) {
|
||||
BasicTextField(
|
||||
value = box.text,
|
||||
onValueChange = currentOnTextChanged,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(8.dp)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.focusRequester(focusRequester),
|
||||
textStyle = TextStyle(
|
||||
color = box.color,
|
||||
background = box.backgroundColor,
|
||||
fontFamily = fontFamily,
|
||||
fontSize = with(LocalDensity.current) {
|
||||
(box.fontSize * pageHeightPx).toSp()
|
||||
},
|
||||
fontWeight = if (box.isBold) FontWeight.Bold else FontWeight.Normal,
|
||||
fontStyle = if (box.isItalic) FontStyle.Italic else FontStyle.Normal,
|
||||
textDecoration = run {
|
||||
val decs = mutableListOf<TextDecoration>()
|
||||
if (box.isUnderline) decs.add(TextDecoration.Underline)
|
||||
if (box.isStrikeThrough) decs.add(TextDecoration.LineThrough)
|
||||
if (decs.isEmpty()) TextDecoration.None else TextDecoration.combine(decs)
|
||||
}
|
||||
),
|
||||
cursorBrush = SolidColor(if (isDarkMode) Color.White else MaterialTheme.colorScheme.primary),
|
||||
enabled = isEditMode && isSelected,
|
||||
readOnly = !isEditMode
|
||||
)
|
||||
}
|
||||
|
||||
if (isSelected) {
|
||||
val handles = ResizeHandle.entries.filter { it != ResizeHandle.NONE }
|
||||
|
||||
fun getHandleCenter(handle: ResizeHandle, w: Float, h: Float): Offset {
|
||||
return when (handle) {
|
||||
ResizeHandle.TOP_LEFT -> Offset(halfHandlePx, halfHandlePx)
|
||||
ResizeHandle.TOP_CENTER -> Offset(halfHandlePx + w / 2, halfHandlePx)
|
||||
ResizeHandle.TOP_RIGHT -> Offset(halfHandlePx + w, halfHandlePx)
|
||||
ResizeHandle.RIGHT_CENTER -> Offset(halfHandlePx + w, halfHandlePx + h / 2)
|
||||
ResizeHandle.BOTTOM_RIGHT -> Offset(halfHandlePx + w, halfHandlePx + h)
|
||||
ResizeHandle.BOTTOM_CENTER -> Offset(halfHandlePx + w / 2, halfHandlePx + h)
|
||||
ResizeHandle.BOTTOM_LEFT -> Offset(halfHandlePx, halfHandlePx + h)
|
||||
ResizeHandle.LEFT_CENTER -> Offset(halfHandlePx, halfHandlePx + h / 2)
|
||||
else -> Offset.Zero
|
||||
}
|
||||
}
|
||||
|
||||
handles.forEach { handle ->
|
||||
val center = getHandleCenter(handle, currentRectPx.width, currentRectPx.height)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.offset {
|
||||
IntOffset(
|
||||
(center.x - handleTouchSizePx / 2).roundToInt(),
|
||||
(center.y - handleTouchSizePx / 2).roundToInt()
|
||||
)
|
||||
}
|
||||
.size(handleTouchSize)
|
||||
.pointerInput(box.id, handle, pageWidthPx, pageHeightPx) {
|
||||
detectEagerDragGestures(
|
||||
onDragStart = {
|
||||
Timber.tag("PdfTextBoxDebug").d("ResizeHandle DragStart[ID: ${box.id}] Handle=$handle")
|
||||
isDraggingOrResizing = true
|
||||
},
|
||||
onDragEnd = {
|
||||
isDraggingOrResizing = false
|
||||
val normalized = Rect(
|
||||
left = currentRectPx.left / pageWidthPx,
|
||||
top = currentRectPx.top / pageHeightPx,
|
||||
right = currentRectPx.right / pageWidthPx,
|
||||
bottom = currentRectPx.bottom / pageHeightPx
|
||||
)
|
||||
Timber.tag("PdfTextBoxDebug").d("ResizeHandle DragEnd [ID: ${box.id}] finalNormalized=$normalized")
|
||||
currentOnBoundsChanged(normalized)
|
||||
},
|
||||
onDragCancel = { isDraggingOrResizing = false }
|
||||
) { change, dragAmount ->
|
||||
Timber.tag("PdfTextBoxDebug").v("ResizeHandle Drag [ID: ${box.id}] Handle=$handle | dragAmount=$dragAmount")
|
||||
|
||||
var l = currentRectPx.left
|
||||
var t = currentRectPx.top
|
||||
var r = currentRectPx.right
|
||||
var b = currentRectPx.bottom
|
||||
val dx = dragAmount.x
|
||||
val dy = dragAmount.y
|
||||
val minSize = 50f / scale
|
||||
|
||||
when (handle) {
|
||||
ResizeHandle.TOP_LEFT -> {
|
||||
l = (l + dx).coerceIn(0f, maxOf(0f, r - minSize))
|
||||
t = (t + dy).coerceIn(0f, maxOf(0f, b - minSize))
|
||||
}
|
||||
ResizeHandle.TOP_CENTER -> t = (t + dy).coerceIn(0f, maxOf(0f, b - minSize))
|
||||
ResizeHandle.TOP_RIGHT -> {
|
||||
r = (r + dx).coerceIn(l + minSize, maxOf(l + minSize, pageWidthPx))
|
||||
t = (t + dy).coerceIn(0f, maxOf(0f, b - minSize))
|
||||
}
|
||||
ResizeHandle.RIGHT_CENTER -> r = (r + dx).coerceIn(l + minSize, maxOf(l + minSize, pageWidthPx))
|
||||
ResizeHandle.BOTTOM_RIGHT -> {
|
||||
r = (r + dx).coerceIn(l + minSize, maxOf(l + minSize, pageWidthPx))
|
||||
b = (b + dy).coerceIn(t + minSize, maxOf(t + minSize, pageHeightPx))
|
||||
}
|
||||
ResizeHandle.BOTTOM_CENTER -> b = (b + dy).coerceIn(t + minSize, maxOf(t + minSize, pageHeightPx))
|
||||
ResizeHandle.BOTTOM_LEFT -> {
|
||||
l = (l + dx).coerceIn(0f, maxOf(0f, r - minSize))
|
||||
b = (b + dy).coerceIn(t + minSize, maxOf(t + minSize, pageHeightPx))
|
||||
}
|
||||
ResizeHandle.LEFT_CENTER -> l = (l + dx).coerceIn(0f, maxOf(0f, r - minSize))
|
||||
else -> {}
|
||||
}
|
||||
currentRectPx = Rect(l, t, r, b)
|
||||
}
|
||||
}
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(handleSize)
|
||||
.background(handleColor, CircleShape)
|
||||
.align(Alignment.Center)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isSelected) {
|
||||
val handles = ResizeHandle.entries.filter { it != ResizeHandle.NONE }
|
||||
|
||||
fun getHandleCenter(handle: ResizeHandle, w: Float, h: Float): Offset {
|
||||
return when (handle) {
|
||||
ResizeHandle.TOP_LEFT -> Offset(halfHandlePx, halfHandlePx)
|
||||
ResizeHandle.TOP_CENTER -> Offset(halfHandlePx + w / 2, halfHandlePx)
|
||||
ResizeHandle.TOP_RIGHT -> Offset(halfHandlePx + w, halfHandlePx)
|
||||
ResizeHandle.RIGHT_CENTER -> Offset(halfHandlePx + w, halfHandlePx + h / 2)
|
||||
ResizeHandle.BOTTOM_RIGHT -> Offset(halfHandlePx + w, halfHandlePx + h)
|
||||
ResizeHandle.BOTTOM_CENTER -> Offset(halfHandlePx + w / 2, halfHandlePx + h)
|
||||
ResizeHandle.BOTTOM_LEFT -> Offset(halfHandlePx, halfHandlePx + h)
|
||||
ResizeHandle.LEFT_CENTER -> Offset(halfHandlePx, halfHandlePx + h / 2)
|
||||
else -> Offset.Zero
|
||||
}
|
||||
}
|
||||
|
||||
handles.forEach { handle ->
|
||||
val center = getHandleCenter(handle, currentRectPx.width, currentRectPx.height)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.offset {
|
||||
IntOffset(
|
||||
(center.x - handleTouchSizePx / 2).roundToInt(),
|
||||
(center.y - handleTouchSizePx / 2).roundToInt()
|
||||
)
|
||||
}
|
||||
.size(handleTouchSize)
|
||||
.pointerInput(onBoundsChanged) {
|
||||
detectEagerDragGestures(
|
||||
onDragStart = {
|
||||
Timber.tag("PdfTextBoxDebug").d("ResizeHandle DragStart[ID: ${box.id}] Handle=$handle")
|
||||
isDraggingOrResizing = true
|
||||
},
|
||||
onDragEnd = {
|
||||
isDraggingOrResizing = false
|
||||
val normalized = Rect(
|
||||
left = currentRectPx.left / pageWidthPx,
|
||||
top = currentRectPx.top / pageHeightPx,
|
||||
right = currentRectPx.right / pageWidthPx,
|
||||
bottom = currentRectPx.bottom / pageHeightPx
|
||||
)
|
||||
Timber.tag("PdfTextBoxDebug").d("ResizeHandle DragEnd [ID: ${box.id}] finalNormalized=$normalized")
|
||||
onBoundsChanged(normalized)
|
||||
},
|
||||
onDragCancel = { isDraggingOrResizing = false }
|
||||
) { change, dragAmount ->
|
||||
Timber.tag("PdfTextBoxDebug").v("ResizeHandle Drag [ID: ${box.id}] Handle=$handle | dragAmount=$dragAmount")
|
||||
|
||||
var l = currentRectPx.left
|
||||
var t = currentRectPx.top
|
||||
var r = currentRectPx.right
|
||||
var b = currentRectPx.bottom
|
||||
val dx = dragAmount.x
|
||||
val dy = dragAmount.y
|
||||
val minSize = 50f / scale
|
||||
|
||||
when (handle) {
|
||||
ResizeHandle.TOP_LEFT -> {
|
||||
l = (l + dx).coerceIn(0f, maxOf(0f, r - minSize))
|
||||
t = (t + dy).coerceIn(0f, maxOf(0f, b - minSize))
|
||||
}
|
||||
ResizeHandle.TOP_CENTER -> t = (t + dy).coerceIn(0f, maxOf(0f, b - minSize))
|
||||
ResizeHandle.TOP_RIGHT -> {
|
||||
r = (r + dx).coerceIn(l + minSize, maxOf(l + minSize, pageWidthPx))
|
||||
t = (t + dy).coerceIn(0f, maxOf(0f, b - minSize))
|
||||
}
|
||||
ResizeHandle.RIGHT_CENTER -> r = (r + dx).coerceIn(l + minSize, maxOf(l + minSize, pageWidthPx))
|
||||
ResizeHandle.BOTTOM_RIGHT -> {
|
||||
r = (r + dx).coerceIn(l + minSize, maxOf(l + minSize, pageWidthPx))
|
||||
b = (b + dy).coerceIn(t + minSize, maxOf(t + minSize, pageHeightPx))
|
||||
}
|
||||
ResizeHandle.BOTTOM_CENTER -> b = (b + dy).coerceIn(t + minSize, maxOf(t + minSize, pageHeightPx))
|
||||
ResizeHandle.BOTTOM_LEFT -> {
|
||||
l = (l + dx).coerceIn(0f, maxOf(0f, r - minSize))
|
||||
b = (b + dy).coerceIn(t + minSize, maxOf(t + minSize, pageHeightPx))
|
||||
}
|
||||
ResizeHandle.LEFT_CENTER -> l = (l + dx).coerceIn(0f, maxOf(0f, r - minSize))
|
||||
else -> {}
|
||||
}
|
||||
currentRectPx = Rect(l, t, r, b)
|
||||
}
|
||||
}
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(handleSize)
|
||||
.background(handleColor, CircleShape)
|
||||
.align(Alignment.Center)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
DragPill(
|
||||
isDarkMode = isDarkMode,
|
||||
scale = scale,
|
||||
modifier = Modifier
|
||||
.align(if (isHandleAtTop) Alignment.TopCenter else Alignment.BottomCenter)
|
||||
.offset(y = if (isHandleAtTop) (-32f / scale).dp else (32f / scale).dp)
|
||||
.offset {
|
||||
IntOffset(
|
||||
chromeLayout.dragPillLeftPx.roundToInt(),
|
||||
chromeLayout.dragPillTopPx.roundToInt()
|
||||
)
|
||||
}
|
||||
.size(width = dragPillTouchWidth, height = dragPillTouchHeight)
|
||||
.zIndex(20f)
|
||||
.pointerInput(pageWidthPx, pageHeightPx, onDragStart, onDragEnd, onDragCancel) {
|
||||
.pointerInput(box.id, pageWidthPx, pageHeightPx) {
|
||||
detectEagerDragGestures(
|
||||
onDragStart = { offset ->
|
||||
Timber.tag("PdfTextBoxDebug").d("DragPill DragStart [ID: ${box.id}] at offset=$offset")
|
||||
isDraggingOrResizing = true
|
||||
onDragStart(offset)
|
||||
currentOnDragStart(offset)
|
||||
},
|
||||
onDragEnd = {
|
||||
isDraggingOrResizing = false
|
||||
|
|
@ -410,12 +513,12 @@ fun ResizableTextBox(
|
|||
bottom = currentRectPx.bottom / pageHeightPx
|
||||
)
|
||||
Timber.tag("PdfTextBoxDebug").d("DragPill DragEnd[ID: ${box.id}] finalNormalized=$normalized")
|
||||
onBoundsChanged(normalized)
|
||||
onDragEnd()
|
||||
currentOnBoundsChanged(normalized)
|
||||
currentOnDragEnd()
|
||||
},
|
||||
onDragCancel = {
|
||||
isDraggingOrResizing = false
|
||||
onDragCancel()
|
||||
currentOnDragCancel()
|
||||
}
|
||||
) { change, dragAmount ->
|
||||
val w = currentRectPx.width
|
||||
|
|
@ -426,7 +529,7 @@ fun ResizableTextBox(
|
|||
val newTop = rawTop.coerceIn(0f, maxOf(0f, pageHeightPx - h))
|
||||
val newRect = Rect(newLeft, newTop, newLeft + w, newTop + h)
|
||||
currentRectPx = newRect
|
||||
onDrag(dragAmount, newRect)
|
||||
currentOnDrag(dragAmount, newRect)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
|
@ -440,20 +543,28 @@ private fun DragPill(
|
|||
isDarkMode: Boolean,
|
||||
scale: Float = 1f
|
||||
) {
|
||||
Surface(
|
||||
modifier = modifier
|
||||
.size(width = (48f / scale).dp, height = (24f / scale).dp),
|
||||
shape = CircleShape,
|
||||
color = if (isDarkMode) Color.White else Color.Black,
|
||||
contentColor = if (isDarkMode) Color.Black else Color.White,
|
||||
shadowElevation = (4f / scale).dp
|
||||
Box(
|
||||
modifier = modifier,
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.drag_handle),
|
||||
contentDescription = stringResource(R.string.content_desc_drag_text_box),
|
||||
modifier = Modifier.size((20f / scale).dp)
|
||||
)
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.size(
|
||||
width = (TEXT_BOX_DRAG_PILL_VISUAL_WIDTH_DP / scale).dp,
|
||||
height = (TEXT_BOX_DRAG_PILL_VISUAL_HEIGHT_DP / scale).dp
|
||||
),
|
||||
shape = CircleShape,
|
||||
color = if (isDarkMode) Color.White else Color.Black,
|
||||
contentColor = if (isDarkMode) Color.Black else Color.White,
|
||||
shadowElevation = (4f / scale).dp
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.drag_handle),
|
||||
contentDescription = stringResource(R.string.content_desc_drag_text_box),
|
||||
modifier = Modifier.size((20f / scale).dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -133,22 +133,23 @@ object PdfToHtmlGenerator {
|
|||
headerFooterStrings: Set<String>
|
||||
): String {
|
||||
return try {
|
||||
doc.openPage(pageIdx)?.use { page ->
|
||||
page.openTextPage().use { textPage ->
|
||||
val charCount = textPage.textPageCountChars()
|
||||
PdfiumEngineProvider.withPdfium {
|
||||
doc.openPage(pageIdx)?.use { page ->
|
||||
page.openTextPage().use { textPage ->
|
||||
val charCount = textPage.textPageCountChars()
|
||||
|
||||
val pagePtr = getNativePointer(page)
|
||||
val textPagePtr = getNativePointer(textPage)
|
||||
|
||||
val imageElements = mutableListOf<ImageElement>()
|
||||
val objCount = PdfiumEngineProvider.bridge.getPageObjectCount(pagePtr)
|
||||
val objCount = NativePdfiumBridge.getPageObjectCount(pagePtr)
|
||||
for (i in 0 until objCount) {
|
||||
if (PdfiumEngineProvider.bridge.getPageObjectType(pagePtr, i) == 3) {
|
||||
if (NativePdfiumBridge.getPageObjectType(pagePtr, i) == 3) {
|
||||
val bbox = FloatArray(4)
|
||||
if (PdfiumEngineProvider.bridge.getPageObjectBoundingBox(pagePtr, i, bbox)) {
|
||||
if (NativePdfiumBridge.getPageObjectBoundingBox(pagePtr, i, bbox)) {
|
||||
val topY = bbox[3]
|
||||
val dimens = IntArray(2)
|
||||
val pixels = PdfiumEngineProvider.bridge.extractImagePixels(pagePtr, i, dimens)
|
||||
val pixels = NativePdfiumBridge.extractImagePixels(pagePtr, i, dimens)
|
||||
if (pixels != null && dimens[0] > 0 && dimens[1] > 0) {
|
||||
try {
|
||||
val bmp = Bitmap.createBitmap(pixels, dimens[0], dimens[1], Bitmap.Config.ARGB_8888)
|
||||
|
|
@ -179,12 +180,10 @@ object PdfToHtmlGenerator {
|
|||
val flags: IntArray?
|
||||
val charBoxes: FloatArray?
|
||||
|
||||
synchronized(PdfiumEngineProvider.lock) {
|
||||
sizes = PdfiumEngineProvider.bridge.getPageFontSizes(textPagePtr, actualCount)
|
||||
weights = PdfiumEngineProvider.bridge.getPageFontWeights(textPagePtr, actualCount)
|
||||
flags = PdfiumEngineProvider.bridge.getPageFontFlags(textPagePtr, actualCount)
|
||||
charBoxes = PdfiumEngineProvider.bridge.getPageCharBoxes(textPagePtr, actualCount)
|
||||
}
|
||||
sizes = NativePdfiumBridge.getPageFontSizes(textPagePtr, actualCount)
|
||||
weights = NativePdfiumBridge.getPageFontWeights(textPagePtr, actualCount)
|
||||
flags = NativePdfiumBridge.getPageFontFlags(textPagePtr, actualCount)
|
||||
charBoxes = NativePdfiumBridge.getPageCharBoxes(textPagePtr, actualCount)
|
||||
|
||||
if (sizes == null || weights == null || flags == null) {
|
||||
return@use buildFallbackPageSection(pageNumber, rawText)
|
||||
|
|
@ -302,8 +301,9 @@ object PdfToHtmlGenerator {
|
|||
}
|
||||
|
||||
buildPageHtml(pageNumber, finalElements, headerFooterStrings)
|
||||
}
|
||||
} ?: buildEmptyPageSection(pageNumber)
|
||||
}
|
||||
} ?: buildEmptyPageSection(pageNumber)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.tag(TAG).w(e, "Error extracting page $pageIdx")
|
||||
buildEmptyPageSection(pageNumber)
|
||||
|
|
@ -506,17 +506,19 @@ object PdfToHtmlGenerator {
|
|||
|
||||
for (pageIdx in samplePages) {
|
||||
try {
|
||||
doc.openPage(pageIdx)?.use { page ->
|
||||
page.openTextPage().use { textPage ->
|
||||
val charCount = textPage.textPageCountChars()
|
||||
if (charCount <= 0) return@use
|
||||
val rawText = textPage.textPageGetText(0, charCount) ?: return@use
|
||||
PdfiumEngineProvider.withPdfium {
|
||||
doc.openPage(pageIdx)?.use { page ->
|
||||
page.openTextPage().use { textPage ->
|
||||
val charCount = textPage.textPageCountChars()
|
||||
if (charCount <= 0) return@use
|
||||
val rawText = textPage.textPageGetText(0, charCount) ?: return@use
|
||||
|
||||
val lines = rawText.split('\n').map { it.trim() }.filter { it.length > 2 }
|
||||
if (lines.isNotEmpty()) {
|
||||
val edgeLines = lines.take(2) + lines.takeLast(2)
|
||||
for (line in edgeLines) {
|
||||
frequency[line] = (frequency[line] ?: 0) + 1
|
||||
val lines = rawText.split('\n').map { it.trim() }.filter { it.length > 2 }
|
||||
if (lines.isNotEmpty()) {
|
||||
val edgeLines = lines.take(2) + lines.takeLast(2)
|
||||
for (line in edgeLines) {
|
||||
frequency[line] = (frequency[line] ?: 0) + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ import com.aryan.reader.SearchState
|
|||
import com.aryan.reader.SearchTopBar
|
||||
import com.aryan.reader.TooltipIconButton
|
||||
import com.aryan.reader.areReaderAiFeaturesEnabled
|
||||
import com.aryan.reader.cardTitle
|
||||
import com.aryan.reader.epubreader.SystemUiMode
|
||||
import kotlin.collections.isNotEmpty
|
||||
|
||||
|
|
@ -52,6 +53,7 @@ internal val PdfTabStripHeight = 44.dp
|
|||
private val pdfToolbarTools = setOf(
|
||||
PdfReaderTool.DICTIONARY,
|
||||
PdfReaderTool.THEME,
|
||||
PdfReaderTool.BRIGHTNESS,
|
||||
PdfReaderTool.LOCK_PANNING,
|
||||
PdfReaderTool.SLIDER,
|
||||
PdfReaderTool.TOC,
|
||||
|
|
@ -63,6 +65,61 @@ private val pdfToolbarTools = setOf(
|
|||
PdfReaderTool.SCREEN_ORIENTATION
|
||||
)
|
||||
|
||||
internal enum class PdfOverflowMenuSection {
|
||||
CUSTOMIZE_TOOLBAR,
|
||||
HIDDEN_TOOLS,
|
||||
OCR_LANGUAGE,
|
||||
VISUAL_OPTIONS,
|
||||
READING_MODE,
|
||||
TAP_TO_TURN,
|
||||
KEEP_SCREEN_ON,
|
||||
AUTO_SCROLL,
|
||||
TTS_SETTINGS,
|
||||
BOOKMARK,
|
||||
PAGE_MANAGEMENT,
|
||||
REFLOW,
|
||||
FILE_ACTIONS,
|
||||
FILE_INFO
|
||||
}
|
||||
|
||||
internal fun pdfOverflowMenuSections(
|
||||
hiddenTools: Set<String>,
|
||||
hasHiddenToolbarTools: Boolean,
|
||||
isPro: Boolean,
|
||||
effectiveFileType: FileType,
|
||||
hasFileInfo: Boolean = true
|
||||
): List<PdfOverflowMenuSection> = buildList {
|
||||
add(PdfOverflowMenuSection.CUSTOMIZE_TOOLBAR)
|
||||
if (hasHiddenToolbarTools) add(PdfOverflowMenuSection.HIDDEN_TOOLS)
|
||||
if (isPro && !hiddenTools.contains(PdfReaderTool.OCR_LANGUAGE.name)) {
|
||||
add(PdfOverflowMenuSection.OCR_LANGUAGE)
|
||||
}
|
||||
if (!hiddenTools.contains(PdfReaderTool.VISUAL_OPTIONS.name)) add(PdfOverflowMenuSection.VISUAL_OPTIONS)
|
||||
if (!hiddenTools.contains(PdfReaderTool.READING_MODE.name)) add(PdfOverflowMenuSection.READING_MODE)
|
||||
if (!hiddenTools.contains(PdfReaderTool.TAP_TO_TURN.name)) add(PdfOverflowMenuSection.TAP_TO_TURN)
|
||||
if (!hiddenTools.contains(PdfReaderTool.KEEP_SCREEN_ON.name)) add(PdfOverflowMenuSection.KEEP_SCREEN_ON)
|
||||
if (!hiddenTools.contains(PdfReaderTool.AUTO_SCROLL.name)) add(PdfOverflowMenuSection.AUTO_SCROLL)
|
||||
if (
|
||||
!hiddenTools.contains(PdfReaderTool.TTS_SETTINGS.name) ||
|
||||
!hiddenTools.contains(PdfReaderTool.TTS_REPLACEMENTS.name)
|
||||
) {
|
||||
add(PdfOverflowMenuSection.TTS_SETTINGS)
|
||||
}
|
||||
if (!hiddenTools.contains(PdfReaderTool.BOOKMARK.name)) add(PdfOverflowMenuSection.BOOKMARK)
|
||||
if (!hiddenTools.contains(PdfReaderTool.PAGE_MANAGEMENT.name)) add(PdfOverflowMenuSection.PAGE_MANAGEMENT)
|
||||
if (!hiddenTools.contains(PdfReaderTool.REFLOW.name)) add(PdfOverflowMenuSection.REFLOW)
|
||||
if (
|
||||
!hiddenTools.contains(PdfReaderTool.SHARE.name) ||
|
||||
(effectiveFileType == FileType.PDF && !hiddenTools.contains(PdfReaderTool.SAVE_COPY.name)) ||
|
||||
(effectiveFileType == FileType.PDF && !hiddenTools.contains(PdfReaderTool.PRINT.name))
|
||||
) {
|
||||
add(PdfOverflowMenuSection.FILE_ACTIONS)
|
||||
}
|
||||
if (hasFileInfo && !hiddenTools.contains(PdfReaderTool.FILE_INFO.name)) {
|
||||
add(PdfOverflowMenuSection.FILE_INFO)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
internal fun PdfTopBar(
|
||||
|
|
@ -76,6 +133,7 @@ internal fun PdfTopBar(
|
|||
isLoadingDocument: Boolean,
|
||||
errorMessage: String?,
|
||||
currentPageForDisplay: Int,
|
||||
currentPageLabel: String? = null,
|
||||
totalPages: Int,
|
||||
pagerStatePageCount: Int,
|
||||
hiddenTools: Set<String>,
|
||||
|
|
@ -87,6 +145,7 @@ internal fun PdfTopBar(
|
|||
isRightToLeftPagination: Boolean,
|
||||
isKeepScreenOn: Boolean,
|
||||
isTtsSessionActive: Boolean,
|
||||
isSliderActive: Boolean,
|
||||
isBookmarked: Boolean,
|
||||
canDeletePage: Boolean,
|
||||
isReflowingThisBook: Boolean,
|
||||
|
|
@ -95,9 +154,11 @@ internal fun PdfTopBar(
|
|||
isTabsEnabled: Boolean,
|
||||
openTabs: List<RecentFileItem>,
|
||||
activeTabBookId: String?,
|
||||
usePdfFileNameAsDisplayName: Boolean,
|
||||
effectiveFileType: FileType,
|
||||
onNavigateBack: () -> Unit,
|
||||
onShowThemePanel: () -> Unit,
|
||||
onShowBrightnessControl: () -> Unit,
|
||||
onToggleScrollLock: () -> Unit,
|
||||
onShowDictionarySettings: () -> Unit,
|
||||
onShowPenPlayground: () -> Unit,
|
||||
|
|
@ -125,6 +186,7 @@ internal fun PdfTopBar(
|
|||
onShowTtsSettings: () -> Unit,
|
||||
onShowTtsReplacements: () -> Unit,
|
||||
onToggleBookmark: () -> Unit,
|
||||
onShowFileInfo: () -> Unit,
|
||||
onInsertPage: () -> Unit,
|
||||
onDeletePage: () -> Unit,
|
||||
onReflowAction: () -> Unit,
|
||||
|
|
@ -176,7 +238,8 @@ internal fun PdfTopBar(
|
|||
val titleText = when {
|
||||
isLoadingDocument -> stringResource(R.string.loading_pdf)
|
||||
errorMessage != null -> stringResource(R.string.error_loading_pdf)
|
||||
totalPages > 0 && pagerStatePageCount > 0 -> stringResource(R.string.page_of_pages, currentPageForDisplay + 1, totalPages)
|
||||
totalPages > 0 && pagerStatePageCount > 0 -> currentPageLabel
|
||||
?: stringResource(R.string.page_of_pages, currentPageForDisplay + 1, totalPages)
|
||||
totalPages > 0 && pagerStatePageCount == 0 -> stringResource(R.string.loading_page)
|
||||
else -> stringResource(R.string.pdf_viewer)
|
||||
}
|
||||
|
|
@ -199,6 +262,13 @@ internal fun PdfTopBar(
|
|||
) {
|
||||
Icon(painterResource(id = R.drawable.palette), contentDescription = stringResource(R.string.tooltip_theme_desc), tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
PdfReaderTool.BRIGHTNESS -> TooltipIconButton(
|
||||
text = stringResource(R.string.reader_brightness_title),
|
||||
description = stringResource(R.string.reader_brightness_system_desc),
|
||||
onClick = onShowBrightnessControl
|
||||
) {
|
||||
Icon(painterResource(id = R.drawable.contrast), contentDescription = stringResource(R.string.reader_brightness_title), tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
PdfReaderTool.LOCK_PANNING -> TooltipIconButton(
|
||||
text = if (isScrollLocked) stringResource(R.string.tooltip_unlock_pan) else stringResource(R.string.tooltip_lock_pan),
|
||||
description = if (isScrollLocked) stringResource(R.string.tooltip_unlock_pan_desc) else stringResource(R.string.tooltip_lock_pan_desc),
|
||||
|
|
@ -219,7 +289,11 @@ internal fun PdfTopBar(
|
|||
onClick = onShowSlider,
|
||||
enabled = !isTtsPlayingOrLoading
|
||||
) {
|
||||
Icon(painterResource(id = R.drawable.slider), contentDescription = stringResource(R.string.content_desc_navigate_slider))
|
||||
Icon(
|
||||
painterResource(id = R.drawable.slider),
|
||||
contentDescription = stringResource(R.string.content_desc_navigate_slider),
|
||||
tint = if (isSliderActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
PdfReaderTool.TOC -> TooltipIconButton(
|
||||
text = stringResource(R.string.tooltip_toc),
|
||||
|
|
@ -321,267 +395,264 @@ internal fun PdfTopBar(
|
|||
}
|
||||
) {
|
||||
val hiddenToolbarTools = toolOrder.filter { it in pdfToolbarTools && hiddenTools.contains(it.name) }
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.title_customize_toolbar)) },
|
||||
onClick = { showMoreMenu = false; onShowCustomizeTools() },
|
||||
leadingIcon = { Icon(Icons.Default.Settings, contentDescription = stringResource(R.string.title_customize_toolbar), modifier = Modifier.size(20.dp)) }
|
||||
)
|
||||
HorizontalDivider()
|
||||
|
||||
if (hiddenToolbarTools.isNotEmpty()) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.toolbar_hidden_tools_menu)) },
|
||||
onClick = { showHiddenToolsExpanded = !showHiddenToolsExpanded },
|
||||
trailingIcon = {
|
||||
Icon(
|
||||
Icons.Default.ArrowDropDown,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.rotate(if (showHiddenToolsExpanded) 180f else 0f)
|
||||
)
|
||||
}
|
||||
)
|
||||
if (showHiddenToolsExpanded) {
|
||||
hiddenToolbarTools.forEach { tool ->
|
||||
HiddenPdfToolMenuItem(
|
||||
tool = tool,
|
||||
isTtsPlayingOrLoading = isTtsPlayingOrLoading,
|
||||
showAllTextHighlights = showAllTextHighlights,
|
||||
isHighlightingLoading = isHighlightingLoading,
|
||||
isEditMode = isEditMode,
|
||||
isTtsSessionActive = isTtsSessionActive,
|
||||
closeMenu = {
|
||||
showHiddenToolsExpanded = false
|
||||
showMoreMenu = false
|
||||
},
|
||||
onShowThemePanel = onShowThemePanel,
|
||||
onToggleScrollLock = onToggleScrollLock,
|
||||
onShowDictionarySettings = onShowDictionarySettings,
|
||||
onShowSlider = onShowSlider,
|
||||
onShowToc = onShowToc,
|
||||
onSearchClick = onSearchClick,
|
||||
onToggleHighlights = onToggleHighlights,
|
||||
onShowAiHub = onShowAiHub,
|
||||
onToggleEditMode = onToggleEditMode,
|
||||
onToggleTts = onToggleTts,
|
||||
onShowScreenOrientation = onShowScreenOrientation
|
||||
)
|
||||
}
|
||||
}
|
||||
HorizontalDivider()
|
||||
}
|
||||
|
||||
if (BuildConfig.IS_PRO && !hiddenTools.contains(PdfReaderTool.OCR_LANGUAGE.name)) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_ocr_language)) },
|
||||
onClick = { showMoreMenu = false; onShowOcrLanguage() }
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
|
||||
if (!hiddenTools.contains(PdfReaderTool.VISUAL_OPTIONS.name)) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_visual_options)) },
|
||||
onClick = { showMoreMenu = false; onShowVisualOptions() },
|
||||
leadingIcon = { Icon(Icons.Default.Visibility, contentDescription = null, modifier = Modifier.size(20.dp)) }
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
|
||||
if (!hiddenTools.contains(PdfReaderTool.READING_MODE.name)) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_change_reading_mode)) },
|
||||
onClick = { showReadingModeExpanded = !showReadingModeExpanded },
|
||||
trailingIcon = {
|
||||
Icon(
|
||||
Icons.Default.ArrowDropDown,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.rotate(if (showReadingModeExpanded) 180f else 0f)
|
||||
)
|
||||
}
|
||||
)
|
||||
if (showReadingModeExpanded) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_reading_mode_vertical)) },
|
||||
enabled = !isTtsSessionActive,
|
||||
onClick = { onChangeDisplayMode(DisplayMode.VERTICAL_SCROLL); showMoreMenu = false },
|
||||
trailingIcon = { if (displayMode == DisplayMode.VERTICAL_SCROLL) Icon(Icons.Filled.Check, contentDescription = stringResource(R.string.content_desc_selected)) }
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_reading_mode_paginated)) },
|
||||
enabled = !isTtsSessionActive,
|
||||
onClick = {
|
||||
onSetRightToLeftPagination(false)
|
||||
onChangeDisplayMode(DisplayMode.PAGINATION)
|
||||
showMoreMenu = false
|
||||
},
|
||||
trailingIcon = {
|
||||
if (displayMode == DisplayMode.PAGINATION && !isRightToLeftPagination) {
|
||||
Icon(Icons.Filled.Check, contentDescription = stringResource(R.string.content_desc_selected))
|
||||
}
|
||||
}
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_right_to_left_pagination)) },
|
||||
enabled = !isTtsSessionActive,
|
||||
onClick = {
|
||||
onSetRightToLeftPagination(true)
|
||||
onChangeDisplayMode(DisplayMode.PAGINATION)
|
||||
showMoreMenu = false
|
||||
},
|
||||
trailingIcon = {
|
||||
if (displayMode == DisplayMode.PAGINATION && isRightToLeftPagination) {
|
||||
Icon(Icons.Filled.Check, contentDescription = stringResource(R.string.content_desc_selected))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
HorizontalDivider()
|
||||
}
|
||||
|
||||
if (!hiddenTools.contains(PdfReaderTool.TAP_TO_TURN.name)) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_tap_to_turn_pages)) },
|
||||
enabled = displayMode == DisplayMode.PAGINATION,
|
||||
onClick = {
|
||||
onToggleTapToNavigate()
|
||||
showMoreMenu = false
|
||||
},
|
||||
trailingIcon = {
|
||||
if (tapToNavigateEnabled) {
|
||||
Icon(
|
||||
Icons.Filled.Check,
|
||||
contentDescription = stringResource(R.string.content_desc_enabled)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
|
||||
if (!hiddenTools.contains(PdfReaderTool.KEEP_SCREEN_ON.name)) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_keep_screen_on)) },
|
||||
onClick = { onToggleKeepScreenOn(); showMoreMenu = false },
|
||||
trailingIcon = { if (isKeepScreenOn) Icon(Icons.Filled.Check, contentDescription = stringResource(R.string.content_desc_selected)) }
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
|
||||
if (!hiddenTools.contains(PdfReaderTool.AUTO_SCROLL.name)) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_auto_scroll)) },
|
||||
enabled = !isTtsSessionActive && displayMode == DisplayMode.VERTICAL_SCROLL,
|
||||
onClick = { showMoreMenu = false; onStartAutoScroll() }
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
|
||||
val showTtsVoiceSettings = !hiddenTools.contains(PdfReaderTool.TTS_SETTINGS.name)
|
||||
val showTtsReplacements = !hiddenTools.contains(PdfReaderTool.TTS_REPLACEMENTS.name)
|
||||
if (showTtsVoiceSettings || showTtsReplacements) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_tts_settings)) },
|
||||
onClick = { showTtsSettingsExpanded = !showTtsSettingsExpanded },
|
||||
leadingIcon = { Icon(Icons.Default.GraphicEq, contentDescription = null, modifier = Modifier.size(20.dp)) },
|
||||
trailingIcon = {
|
||||
Icon(
|
||||
Icons.Default.ArrowDropDown,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.rotate(if (showTtsSettingsExpanded) 180f else 0f)
|
||||
)
|
||||
}
|
||||
)
|
||||
if (showTtsSettingsExpanded) {
|
||||
if (showTtsVoiceSettings) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_tts_voice_settings)) },
|
||||
enabled = !isTtsSessionActive,
|
||||
onClick = { showMoreMenu = false; onShowTtsSettings() },
|
||||
leadingIcon = { Icon(Icons.Default.GraphicEq, contentDescription = null, modifier = Modifier.size(20.dp)) }
|
||||
)
|
||||
}
|
||||
if (showTtsReplacements) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_tts_word_replacements)) },
|
||||
onClick = { showMoreMenu = false; onShowTtsReplacements() },
|
||||
leadingIcon = { Icon(Icons.Default.GraphicEq, contentDescription = null, modifier = Modifier.size(20.dp)) }
|
||||
)
|
||||
}
|
||||
}
|
||||
HorizontalDivider()
|
||||
}
|
||||
|
||||
if (!hiddenTools.contains(PdfReaderTool.BOOKMARK.name)) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(if (isBookmarked) stringResource(R.string.menu_remove_bookmark) else stringResource(R.string.menu_bookmark_this_page)) },
|
||||
onClick = { showMoreMenu = false; onToggleBookmark() }
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
|
||||
if (!hiddenTools.contains(PdfReaderTool.PAGE_MANAGEMENT.name)) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_insert_blank_page)) },
|
||||
onClick = { showMoreMenu = false; onInsertPage() }
|
||||
)
|
||||
if (canDeletePage) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_delete_page)) },
|
||||
onClick = { showMoreMenu = false; onDeletePage() },
|
||||
colors = MenuDefaults.itemColors(textColor = MaterialTheme.colorScheme.error)
|
||||
)
|
||||
}
|
||||
HorizontalDivider()
|
||||
}
|
||||
|
||||
if (!hiddenTools.contains(PdfReaderTool.REFLOW.name)) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(when { isReflowingThisBook -> stringResource(R.string.generating_text_view); hasReflowFile -> stringResource(R.string.action_open_text_view); else -> stringResource(R.string.action_generate_text_view) }) },
|
||||
enabled = isPdfDocumentLoaded && !isReflowingThisBook,
|
||||
onClick = { showMoreMenu = false; onReflowAction() },
|
||||
leadingIcon = { Icon(painterResource(id = R.drawable.format_size), contentDescription = null, modifier = Modifier.size(20.dp)) }
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
|
||||
val showShareAction = !hiddenTools.contains(PdfReaderTool.SHARE.name)
|
||||
val showSaveCopyAction = effectiveFileType == FileType.PDF && !hiddenTools.contains(PdfReaderTool.SAVE_COPY.name)
|
||||
val showPrintAction = effectiveFileType == FileType.PDF && !hiddenTools.contains(PdfReaderTool.PRINT.name)
|
||||
if (showShareAction || showSaveCopyAction || showPrintAction) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_share_save_print)) },
|
||||
onClick = { showFileActionsExpanded = !showFileActionsExpanded },
|
||||
leadingIcon = { Icon(Icons.Default.Share, contentDescription = null) },
|
||||
trailingIcon = {
|
||||
Icon(
|
||||
Icons.Default.ArrowDropDown,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.rotate(if (showFileActionsExpanded) 180f else 0f)
|
||||
pdfOverflowMenuSections(
|
||||
hiddenTools = hiddenTools,
|
||||
hasHiddenToolbarTools = hiddenToolbarTools.isNotEmpty(),
|
||||
isPro = BuildConfig.IS_PRO,
|
||||
effectiveFileType = effectiveFileType
|
||||
).forEachIndexed { index, section ->
|
||||
if (index > 0) HorizontalDivider()
|
||||
when (section) {
|
||||
PdfOverflowMenuSection.CUSTOMIZE_TOOLBAR -> {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.title_customize_toolbar)) },
|
||||
onClick = { showMoreMenu = false; onShowCustomizeTools() },
|
||||
leadingIcon = { Icon(Icons.Default.Settings, contentDescription = stringResource(R.string.title_customize_toolbar), modifier = Modifier.size(20.dp)) }
|
||||
)
|
||||
}
|
||||
)
|
||||
if (showFileActionsExpanded) {
|
||||
if (showShareAction) {
|
||||
PdfOverflowMenuSection.HIDDEN_TOOLS -> {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.action_share)) },
|
||||
onClick = { showMoreMenu = false; onShare() },
|
||||
leadingIcon = { Icon(Icons.Default.Share, contentDescription = null) }
|
||||
text = { Text(stringResource(R.string.toolbar_hidden_tools_menu)) },
|
||||
onClick = { showHiddenToolsExpanded = !showHiddenToolsExpanded },
|
||||
trailingIcon = {
|
||||
Icon(
|
||||
Icons.Default.ArrowDropDown,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.rotate(if (showHiddenToolsExpanded) 180f else 0f)
|
||||
)
|
||||
}
|
||||
)
|
||||
if (showHiddenToolsExpanded) {
|
||||
hiddenToolbarTools.forEach { tool ->
|
||||
HiddenPdfToolMenuItem(
|
||||
tool = tool,
|
||||
isTtsPlayingOrLoading = isTtsPlayingOrLoading,
|
||||
showAllTextHighlights = showAllTextHighlights,
|
||||
isHighlightingLoading = isHighlightingLoading,
|
||||
isEditMode = isEditMode,
|
||||
isTtsSessionActive = isTtsSessionActive,
|
||||
isSliderActive = isSliderActive,
|
||||
closeMenu = {
|
||||
showHiddenToolsExpanded = false
|
||||
showMoreMenu = false
|
||||
},
|
||||
onShowThemePanel = onShowThemePanel,
|
||||
onShowBrightnessControl = onShowBrightnessControl,
|
||||
onToggleScrollLock = onToggleScrollLock,
|
||||
onShowDictionarySettings = onShowDictionarySettings,
|
||||
onShowSlider = onShowSlider,
|
||||
onShowToc = onShowToc,
|
||||
onSearchClick = onSearchClick,
|
||||
onToggleHighlights = onToggleHighlights,
|
||||
onShowAiHub = onShowAiHub,
|
||||
onToggleEditMode = onToggleEditMode,
|
||||
onToggleTts = onToggleTts,
|
||||
onShowScreenOrientation = onShowScreenOrientation
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
PdfOverflowMenuSection.FILE_INFO -> {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.file_information)) },
|
||||
onClick = { showMoreMenu = false; onShowFileInfo() },
|
||||
leadingIcon = { Icon(Icons.Default.Info, contentDescription = null, modifier = Modifier.size(20.dp)) }
|
||||
)
|
||||
}
|
||||
if (showSaveCopyAction) {
|
||||
PdfOverflowMenuSection.OCR_LANGUAGE -> {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.action_save_copy_to_device)) },
|
||||
onClick = { showMoreMenu = false; onSaveCopy() },
|
||||
leadingIcon = { Icon(Icons.Default.Save, contentDescription = null) }
|
||||
text = { Text(stringResource(R.string.menu_ocr_language)) },
|
||||
onClick = { showMoreMenu = false; onShowOcrLanguage() }
|
||||
)
|
||||
}
|
||||
if (showPrintAction) {
|
||||
PdfOverflowMenuSection.VISUAL_OPTIONS -> {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.action_print)) },
|
||||
onClick = { showMoreMenu = false; onPrint() },
|
||||
leadingIcon = { Icon(painterResource(id = R.drawable.print), contentDescription = null, modifier = Modifier.size(20.dp)) }
|
||||
text = { Text(stringResource(R.string.menu_visual_options)) },
|
||||
onClick = { showMoreMenu = false; onShowVisualOptions() },
|
||||
leadingIcon = { Icon(Icons.Default.Visibility, contentDescription = null, modifier = Modifier.size(20.dp)) }
|
||||
)
|
||||
}
|
||||
PdfOverflowMenuSection.READING_MODE -> {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_change_reading_mode)) },
|
||||
onClick = { showReadingModeExpanded = !showReadingModeExpanded },
|
||||
trailingIcon = {
|
||||
Icon(
|
||||
Icons.Default.ArrowDropDown,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.rotate(if (showReadingModeExpanded) 180f else 0f)
|
||||
)
|
||||
}
|
||||
)
|
||||
if (showReadingModeExpanded) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_reading_mode_vertical)) },
|
||||
enabled = !isTtsSessionActive,
|
||||
onClick = { onChangeDisplayMode(DisplayMode.VERTICAL_SCROLL); showMoreMenu = false },
|
||||
trailingIcon = { if (displayMode == DisplayMode.VERTICAL_SCROLL) Icon(Icons.Filled.Check, contentDescription = stringResource(R.string.content_desc_selected)) }
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_reading_mode_paginated)) },
|
||||
enabled = !isTtsSessionActive,
|
||||
onClick = {
|
||||
onSetRightToLeftPagination(false)
|
||||
onChangeDisplayMode(DisplayMode.PAGINATION)
|
||||
showMoreMenu = false
|
||||
},
|
||||
trailingIcon = {
|
||||
if (displayMode == DisplayMode.PAGINATION && !isRightToLeftPagination) {
|
||||
Icon(Icons.Filled.Check, contentDescription = stringResource(R.string.content_desc_selected))
|
||||
}
|
||||
}
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_right_to_left_pagination)) },
|
||||
enabled = !isTtsSessionActive,
|
||||
onClick = {
|
||||
onSetRightToLeftPagination(true)
|
||||
onChangeDisplayMode(DisplayMode.PAGINATION)
|
||||
showMoreMenu = false
|
||||
},
|
||||
trailingIcon = {
|
||||
if (displayMode == DisplayMode.PAGINATION && isRightToLeftPagination) {
|
||||
Icon(Icons.Filled.Check, contentDescription = stringResource(R.string.content_desc_selected))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
PdfOverflowMenuSection.TAP_TO_TURN -> {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_tap_to_turn_pages)) },
|
||||
enabled = displayMode == DisplayMode.PAGINATION,
|
||||
onClick = {
|
||||
onToggleTapToNavigate()
|
||||
showMoreMenu = false
|
||||
},
|
||||
trailingIcon = {
|
||||
if (tapToNavigateEnabled) {
|
||||
Icon(
|
||||
Icons.Filled.Check,
|
||||
contentDescription = stringResource(R.string.content_desc_enabled)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
PdfOverflowMenuSection.KEEP_SCREEN_ON -> {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_keep_screen_on)) },
|
||||
onClick = { onToggleKeepScreenOn(); showMoreMenu = false },
|
||||
trailingIcon = { if (isKeepScreenOn) Icon(Icons.Filled.Check, contentDescription = stringResource(R.string.content_desc_selected)) }
|
||||
)
|
||||
}
|
||||
PdfOverflowMenuSection.AUTO_SCROLL -> {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_auto_scroll)) },
|
||||
enabled = !isTtsSessionActive && displayMode == DisplayMode.VERTICAL_SCROLL,
|
||||
onClick = { showMoreMenu = false; onStartAutoScroll() }
|
||||
)
|
||||
}
|
||||
PdfOverflowMenuSection.TTS_SETTINGS -> {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_tts_settings)) },
|
||||
onClick = { showTtsSettingsExpanded = !showTtsSettingsExpanded },
|
||||
leadingIcon = { Icon(Icons.Default.GraphicEq, contentDescription = null, modifier = Modifier.size(20.dp)) },
|
||||
trailingIcon = {
|
||||
Icon(
|
||||
Icons.Default.ArrowDropDown,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.rotate(if (showTtsSettingsExpanded) 180f else 0f)
|
||||
)
|
||||
}
|
||||
)
|
||||
if (showTtsSettingsExpanded) {
|
||||
if (showTtsVoiceSettings) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_tts_voice_settings)) },
|
||||
enabled = !isTtsSessionActive,
|
||||
onClick = { showMoreMenu = false; onShowTtsSettings() },
|
||||
leadingIcon = { Icon(Icons.Default.GraphicEq, contentDescription = null, modifier = Modifier.size(20.dp)) }
|
||||
)
|
||||
}
|
||||
if (showTtsReplacements) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_tts_word_replacements)) },
|
||||
onClick = { showMoreMenu = false; onShowTtsReplacements() },
|
||||
leadingIcon = { Icon(Icons.Default.GraphicEq, contentDescription = null, modifier = Modifier.size(20.dp)) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
PdfOverflowMenuSection.BOOKMARK -> {
|
||||
DropdownMenuItem(
|
||||
text = { Text(if (isBookmarked) stringResource(R.string.menu_remove_bookmark) else stringResource(R.string.menu_bookmark_this_page)) },
|
||||
onClick = { showMoreMenu = false; onToggleBookmark() }
|
||||
)
|
||||
}
|
||||
PdfOverflowMenuSection.PAGE_MANAGEMENT -> {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_insert_blank_page)) },
|
||||
onClick = { showMoreMenu = false; onInsertPage() }
|
||||
)
|
||||
if (canDeletePage) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_delete_page)) },
|
||||
onClick = { showMoreMenu = false; onDeletePage() },
|
||||
colors = MenuDefaults.itemColors(textColor = MaterialTheme.colorScheme.error)
|
||||
)
|
||||
}
|
||||
}
|
||||
PdfOverflowMenuSection.REFLOW -> {
|
||||
DropdownMenuItem(
|
||||
text = { Text(when { isReflowingThisBook -> stringResource(R.string.generating_text_view); hasReflowFile -> stringResource(R.string.action_open_text_view); else -> stringResource(R.string.action_generate_text_view) }) },
|
||||
enabled = isPdfDocumentLoaded && !isReflowingThisBook,
|
||||
onClick = { showMoreMenu = false; onReflowAction() },
|
||||
leadingIcon = { Icon(painterResource(id = R.drawable.format_size), contentDescription = null, modifier = Modifier.size(20.dp)) }
|
||||
)
|
||||
}
|
||||
PdfOverflowMenuSection.FILE_ACTIONS -> {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_share_save_print)) },
|
||||
onClick = { showFileActionsExpanded = !showFileActionsExpanded },
|
||||
leadingIcon = { Icon(Icons.Default.Share, contentDescription = null) },
|
||||
trailingIcon = {
|
||||
Icon(
|
||||
Icons.Default.ArrowDropDown,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.rotate(if (showFileActionsExpanded) 180f else 0f)
|
||||
)
|
||||
}
|
||||
)
|
||||
if (showFileActionsExpanded) {
|
||||
if (showShareAction) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.action_share)) },
|
||||
onClick = { showMoreMenu = false; onShare() },
|
||||
leadingIcon = { Icon(Icons.Default.Share, contentDescription = null) }
|
||||
)
|
||||
}
|
||||
if (showSaveCopyAction) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.action_save_copy_to_device)) },
|
||||
onClick = { showMoreMenu = false; onSaveCopy() },
|
||||
leadingIcon = { Icon(Icons.Default.Save, contentDescription = null) }
|
||||
)
|
||||
}
|
||||
if (showPrintAction) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.action_print)) },
|
||||
onClick = { showMoreMenu = false; onPrint() },
|
||||
leadingIcon = { Icon(painterResource(id = R.drawable.print), contentDescription = null, modifier = Modifier.size(20.dp)) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -608,7 +679,7 @@ internal fun PdfTopBar(
|
|||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = tab.customName ?: tab.title ?: tab.displayName,
|
||||
text = tab.cardTitle(usePdfFileNameAsDisplayName),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.widthIn(max = 140.dp),
|
||||
|
|
@ -645,8 +716,10 @@ private fun HiddenPdfToolMenuItem(
|
|||
isHighlightingLoading: Boolean,
|
||||
isEditMode: Boolean,
|
||||
isTtsSessionActive: Boolean,
|
||||
isSliderActive: Boolean,
|
||||
closeMenu: () -> Unit,
|
||||
onShowThemePanel: () -> Unit,
|
||||
onShowBrightnessControl: () -> Unit,
|
||||
onToggleScrollLock: () -> Unit,
|
||||
onShowDictionarySettings: () -> Unit,
|
||||
onShowSlider: () -> Unit,
|
||||
|
|
@ -671,6 +744,7 @@ private fun HiddenPdfToolMenuItem(
|
|||
closeMenu()
|
||||
when (tool) {
|
||||
PdfReaderTool.THEME -> onShowThemePanel()
|
||||
PdfReaderTool.BRIGHTNESS -> onShowBrightnessControl()
|
||||
PdfReaderTool.LOCK_PANNING -> onToggleScrollLock()
|
||||
PdfReaderTool.DICTIONARY -> onShowDictionarySettings()
|
||||
PdfReaderTool.SLIDER -> onShowSlider()
|
||||
|
|
@ -688,8 +762,14 @@ private fun HiddenPdfToolMenuItem(
|
|||
when (tool) {
|
||||
PdfReaderTool.DICTIONARY -> Icon(painterResource(id = R.drawable.dictionary), contentDescription = null, modifier = Modifier.size(20.dp))
|
||||
PdfReaderTool.THEME -> Icon(painterResource(id = R.drawable.palette), contentDescription = null, modifier = Modifier.size(20.dp))
|
||||
PdfReaderTool.BRIGHTNESS -> Icon(painterResource(id = R.drawable.contrast), contentDescription = null, modifier = Modifier.size(20.dp))
|
||||
PdfReaderTool.LOCK_PANNING -> Icon(Icons.Default.LockOpen, contentDescription = null, modifier = Modifier.size(20.dp))
|
||||
PdfReaderTool.SLIDER -> Icon(painterResource(id = R.drawable.slider), contentDescription = null, modifier = Modifier.size(20.dp))
|
||||
PdfReaderTool.SLIDER -> Icon(
|
||||
painterResource(id = R.drawable.slider),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = if (isSliderActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
PdfReaderTool.TOC -> Icon(Icons.Default.Menu, contentDescription = null, modifier = Modifier.size(20.dp))
|
||||
PdfReaderTool.SEARCH -> Icon(Icons.Default.Search, contentDescription = null, modifier = Modifier.size(20.dp))
|
||||
PdfReaderTool.HIGHLIGHT_ALL -> {
|
||||
|
|
@ -702,7 +782,12 @@ private fun HiddenPdfToolMenuItem(
|
|||
PdfReaderTool.SCREEN_ORIENTATION -> Icon(Icons.Default.ScreenRotation, contentDescription = null, modifier = Modifier.size(20.dp))
|
||||
else -> Icon(Icons.Default.MoreVert, contentDescription = null, modifier = Modifier.size(20.dp))
|
||||
}
|
||||
}
|
||||
},
|
||||
trailingIcon = if (tool == PdfReaderTool.SLIDER && isSliderActive) {
|
||||
{
|
||||
Icon(Icons.Default.Check, contentDescription = stringResource(R.string.content_desc_enabled))
|
||||
}
|
||||
} else null
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -849,8 +934,10 @@ fun PdfBottomBar(
|
|||
isHighlightingLoading: Boolean,
|
||||
isEditMode: Boolean,
|
||||
isTtsSessionActive: Boolean,
|
||||
isSliderActive: Boolean,
|
||||
ttsErrorMessage: String?,
|
||||
onShowThemePanel: () -> Unit,
|
||||
onShowBrightnessControl: () -> Unit,
|
||||
onToggleScrollLock: () -> Unit,
|
||||
onShowDictionarySettings: () -> Unit,
|
||||
onShowSlider: () -> Unit,
|
||||
|
|
@ -893,6 +980,13 @@ fun PdfBottomBar(
|
|||
) {
|
||||
Icon(painterResource(id = R.drawable.palette), contentDescription = stringResource(R.string.tooltip_theme_desc), tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
PdfReaderTool.BRIGHTNESS -> TooltipIconButton(
|
||||
text = stringResource(R.string.reader_brightness_title),
|
||||
description = stringResource(R.string.reader_brightness_system_desc),
|
||||
onClick = onShowBrightnessControl
|
||||
) {
|
||||
Icon(painterResource(id = R.drawable.contrast), contentDescription = stringResource(R.string.reader_brightness_title), tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
PdfReaderTool.LOCK_PANNING -> TooltipIconButton(
|
||||
text = stringResource(R.string.tooltip_lock_pan),
|
||||
description = stringResource(R.string.tooltip_lock_pan_desc),
|
||||
|
|
@ -913,7 +1007,11 @@ fun PdfBottomBar(
|
|||
onClick = onShowSlider,
|
||||
enabled = !isTtsPlayingOrLoading
|
||||
) {
|
||||
Icon(painterResource(id = R.drawable.slider), contentDescription = stringResource(R.string.content_desc_navigate_slider))
|
||||
Icon(
|
||||
painterResource(id = R.drawable.slider),
|
||||
contentDescription = stringResource(R.string.content_desc_navigate_slider),
|
||||
tint = if (isSliderActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
PdfReaderTool.TOC -> TooltipIconButton(
|
||||
text = stringResource(R.string.tooltip_toc),
|
||||
|
|
|
|||
35
app/src/main/java/com/aryan/reader/pdf/PdfVerticalPerfLog.kt
Normal file
35
app/src/main/java/com/aryan/reader/pdf/PdfVerticalPerfLog.kt
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
package com.aryan.reader.pdf
|
||||
|
||||
import timber.log.Timber
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
internal object PdfVerticalPerfLog {
|
||||
const val TAG = "PdfVerticalPerf"
|
||||
const val SAMPLE_INTERVAL_MS = 250L
|
||||
|
||||
fun nowNanos(): Long = System.nanoTime()
|
||||
|
||||
fun elapsedMs(startNanos: Long): Long = (System.nanoTime() - startNanos) / 1_000_000L
|
||||
|
||||
fun d(message: String) {
|
||||
Timber.tag(TAG).d(message)
|
||||
}
|
||||
|
||||
fun i(message: String) {
|
||||
Timber.tag(TAG).i(message)
|
||||
}
|
||||
|
||||
fun w(message: String) {
|
||||
Timber.tag(TAG).w(message)
|
||||
}
|
||||
|
||||
fun f(value: Float): String {
|
||||
if (value.isNaN() || value.isInfinite()) return value.toString()
|
||||
return (value * 10f).roundToInt().let { rounded ->
|
||||
if (rounded % 10 == 0) (rounded / 10).toString()
|
||||
else (rounded / 10f).toString()
|
||||
}
|
||||
}
|
||||
|
||||
fun xy(x: Float, y: Float): String = "(${f(x)},${f(y)})"
|
||||
}
|
||||
|
|
@ -42,7 +42,6 @@ import androidx.compose.foundation.gestures.calculateCentroid
|
|||
import androidx.compose.foundation.gestures.calculateCentroidSize
|
||||
import androidx.compose.foundation.gestures.calculatePan
|
||||
import androidx.compose.foundation.gestures.calculateZoom
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Row
|
||||
|
|
@ -88,6 +87,7 @@ import androidx.compose.ui.graphics.Color
|
|||
import androidx.compose.ui.graphics.TransformOrigin
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.isSpecified
|
||||
import androidx.compose.ui.input.pointer.PointerType
|
||||
import androidx.compose.ui.input.pointer.isPrimaryPressed
|
||||
import androidx.compose.ui.input.pointer.isSecondaryPressed
|
||||
|
|
@ -116,6 +116,7 @@ import com.aryan.reader.pdf.data.VirtualPage
|
|||
import com.aryan.reader.shared.pdf.calculatePdfVerticalPageLayoutPx
|
||||
import com.aryan.reader.shared.pdf.pdfVerticalPageGapDp
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
|
|
@ -128,6 +129,18 @@ import kotlin.math.min
|
|||
import kotlin.math.roundToInt
|
||||
|
||||
private const val SCROLL_BOUNDS_TAG = "PdfScrollBounds"
|
||||
private const val VERTICAL_TILE_RENDER_IDLE_COOLDOWN_MS = 220L
|
||||
|
||||
internal fun resolvePdfVerticalPageBackgroundColor(
|
||||
activeTheme: com.aryan.reader.ReaderTheme
|
||||
): Color {
|
||||
val resolved = when (activeTheme.id) {
|
||||
"no_theme", "system" -> Color.White
|
||||
"reverse" -> Color.Black
|
||||
else -> activeTheme.backgroundColor
|
||||
}
|
||||
return if (resolved.isSpecified) resolved else Color.White
|
||||
}
|
||||
|
||||
@Stable
|
||||
class VerticalPdfReaderState {
|
||||
|
|
@ -333,11 +346,7 @@ internal fun PdfVerticalReader(
|
|||
var isStylusEraserOverride by remember { mutableStateOf(false) }
|
||||
val isDarkMode = activeTheme.isDark || activeTheme.id == "reverse"
|
||||
val verticalPageBackgroundColor = remember(activeTheme) {
|
||||
when (activeTheme.id) {
|
||||
"no_theme", "system" -> Color.White
|
||||
"reverse" -> Color.Black
|
||||
else -> activeTheme.backgroundColor
|
||||
}
|
||||
resolvePdfVerticalPageBackgroundColor(activeTheme)
|
||||
}
|
||||
BoxWithConstraints(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.TopStart) {
|
||||
val imeInsets = WindowInsets.ime
|
||||
|
|
@ -374,6 +383,22 @@ internal fun PdfVerticalReader(
|
|||
var isFastFlinging by remember { mutableStateOf(false) }
|
||||
var isInteracting by remember { mutableStateOf(false) }
|
||||
var isDragging by remember { mutableStateOf(false) }
|
||||
var isTileRenderIdleCooldownActive by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(isInteracting, isFlinging) {
|
||||
if (isInteracting || isFlinging) {
|
||||
if (!isTileRenderIdleCooldownActive) {
|
||||
PdfVerticalPerfLog.d("tile-render-cooldown active=true reason=busy")
|
||||
}
|
||||
isTileRenderIdleCooldownActive = true
|
||||
} else if (isTileRenderIdleCooldownActive) {
|
||||
delay(VERTICAL_TILE_RENDER_IDLE_COOLDOWN_MS)
|
||||
PdfVerticalPerfLog.d(
|
||||
"tile-render-cooldown active=false idleFor=${VERTICAL_TILE_RENDER_IDLE_COOLDOWN_MS}ms"
|
||||
)
|
||||
isTileRenderIdleCooldownActive = false
|
||||
}
|
||||
}
|
||||
|
||||
val layoutState = remember(ratios, constraints.maxWidth, constraints.maxHeight, density, showPageGap, dividerHeightPxInt) {
|
||||
data class LayoutResult(val pages: List<PdfPageLayout>, val totalHeight: Float)
|
||||
|
|
@ -417,9 +442,48 @@ internal fun PdfVerticalReader(
|
|||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(layoutInfo, totalDocHeight, screenWidth, screenHeight, fitZoom, headerHeightPx, footerHeightPx) {
|
||||
PdfVerticalPerfLog.i(
|
||||
"layout-ready pages=${layoutInfo.size} totalH=${PdfVerticalPerfLog.f(totalDocHeight)} " +
|
||||
"screen=${PdfVerticalPerfLog.xy(screenWidth, screenHeight)} chrome=${PdfVerticalPerfLog.xy(headerHeightPx, footerHeightPx)} " +
|
||||
"fitZoom=${PdfVerticalPerfLog.f(fitZoom)} firstPageH=${PdfVerticalPerfLog.f(layoutInfo.firstOrNull()?.height ?: 0f)}"
|
||||
)
|
||||
}
|
||||
|
||||
val zoomAnimatable = remember { Animatable(fitZoom) }
|
||||
val panXAnimatable = remember { Animatable(if ((screenWidth * fitZoom) < screenWidth) (screenWidth - (screenWidth * fitZoom)) / 2f else 0f) }
|
||||
val panYAnimatable = remember { Animatable(0f) }
|
||||
val dragCameraUpdates = remember {
|
||||
Channel<Triple<Float, Float, Float>>(Channel.CONFLATED)
|
||||
}
|
||||
val oneHandZoomDistancePx = with(density) {
|
||||
PDF_ONE_HAND_ZOOM_DRAG_DISTANCE_FOR_DOUBLE_DP.dp.toPx()
|
||||
}
|
||||
var oneHandZoomStartZoom by remember { mutableFloatStateOf(fitZoom) }
|
||||
var oneHandZoomStartPan by remember { mutableStateOf(Offset.Zero) }
|
||||
var oneHandZoomPivotScreen by remember { mutableStateOf(Offset.Zero) }
|
||||
var isVerticalOneHandZooming by remember { mutableStateOf(false) }
|
||||
val latestIsVerticalOneHandZooming by rememberUpdatedState(isVerticalOneHandZooming)
|
||||
|
||||
DisposableEffect(dragCameraUpdates) {
|
||||
onDispose {
|
||||
dragCameraUpdates.close()
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(dragCameraUpdates) {
|
||||
for ((targetZoom, targetPanX, targetPanY) in dragCameraUpdates) {
|
||||
if (zoomAnimatable.value != targetZoom) {
|
||||
zoomAnimatable.snapTo(targetZoom)
|
||||
}
|
||||
if (panXAnimatable.value != targetPanX) {
|
||||
panXAnimatable.snapTo(targetPanX)
|
||||
}
|
||||
if (panYAnimatable.value != targetPanY) {
|
||||
panYAnimatable.snapTo(targetPanY)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(zoomAnimatable.value, panXAnimatable.value, panYAnimatable.value) {
|
||||
onZoomAndPanChanged?.invoke(zoomAnimatable.value, Offset(panXAnimatable.value, panYAnimatable.value))
|
||||
|
|
@ -601,6 +665,21 @@ internal fun PdfVerticalReader(
|
|||
return clampValues(targetZoom, targetPanX, targetPanY)
|
||||
}
|
||||
|
||||
fun updatePanBoundsForZoom(finalZoom: Float) {
|
||||
val zoomedDocWidth = screenWidth * finalZoom
|
||||
val (finalMinX, finalMaxX) = if (zoomedDocWidth < screenWidth) {
|
||||
val centeredX = (screenWidth - zoomedDocWidth) / 2f
|
||||
centeredX to centeredX
|
||||
} else {
|
||||
-(zoomedDocWidth - screenWidth) to 0f
|
||||
}
|
||||
panXAnimatable.updateBounds(lowerBound = finalMinX, upperBound = finalMaxX)
|
||||
|
||||
val zoomedDocHeight = totalDocHeight * finalZoom
|
||||
val minPanY = (screenHeight - footerHeightPx - zoomedDocHeight).coerceAtMost(headerHeightPx)
|
||||
panYAnimatable.updateBounds(lowerBound = minPanY, upperBound = headerHeightPx)
|
||||
}
|
||||
|
||||
LaunchedEffect(resetZoomTrigger) {
|
||||
if (resetZoomTrigger != 0L && zoomAnimatable.value > fitZoom && !isScrollLocked) {
|
||||
scope.launch {
|
||||
|
|
@ -864,23 +943,39 @@ internal fun PdfVerticalReader(
|
|||
|
||||
LaunchedEffect(isInteracting) {
|
||||
Timber.tag("PdfTouchDebug").i("VerticalReader: isInteracting changed to $isInteracting")
|
||||
PdfVerticalPerfLog.i(
|
||||
"interaction-state interacting=$isInteracting dragging=$isDragging flinging=$isFlinging " +
|
||||
"zoom=${PdfVerticalPerfLog.f(zoomAnimatable.value)} pan=${PdfVerticalPerfLog.xy(panXAnimatable.value, panYAnimatable.value)}"
|
||||
)
|
||||
}
|
||||
|
||||
LaunchedEffect(highResScale) {
|
||||
Timber.tag("PdfPerformance").i("VerticalReader HighResScale changed to: $highResScale")
|
||||
PdfVerticalPerfLog.i(
|
||||
"high-res-scale scale=${PdfVerticalPerfLog.f(highResScale)} zoom=${PdfVerticalPerfLog.f(zoomAnimatable.value)} " +
|
||||
"interacting=$isInteracting flinging=$isFlinging fastFlinging=$isFastFlinging"
|
||||
)
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
snapshotFlow { isInteracting || (isFlinging && isFastFlinging) }.collectLatest { isBusy ->
|
||||
snapshotFlow { isInteracting || isFlinging }.collectLatest { isBusy ->
|
||||
Timber.tag("PdfDrawPerf").d(
|
||||
"VerticalReader Interaction State: isBusy=$isBusy (Interacting=$isInteracting, Flinging=$isFlinging, Fast=$isFastFlinging)"
|
||||
)
|
||||
PdfVerticalPerfLog.d(
|
||||
"render-resolution-gate busy=$isBusy interacting=$isInteracting flinging=$isFlinging " +
|
||||
"fastFlinging=$isFastFlinging highRes=${PdfVerticalPerfLog.f(highResScale)} zoom=${PdfVerticalPerfLog.f(zoomAnimatable.value)}"
|
||||
)
|
||||
|
||||
if (!isBusy) {
|
||||
delay(50)
|
||||
val target = zoomAnimatable.value
|
||||
if (highResScale != target) {
|
||||
Timber.tag("PdfDrawPerf").v("VerticalReader: Updating highResScale to $target")
|
||||
PdfVerticalPerfLog.i(
|
||||
"high-res-scale-update from=${PdfVerticalPerfLog.f(highResScale)} to=${PdfVerticalPerfLog.f(target)} " +
|
||||
"pan=${PdfVerticalPerfLog.xy(panXAnimatable.value, panYAnimatable.value)}"
|
||||
)
|
||||
highResScale = target
|
||||
}
|
||||
}
|
||||
|
|
@ -893,7 +988,7 @@ internal fun PdfVerticalReader(
|
|||
}
|
||||
|
||||
LaunchedEffect(zoomAnimatable.value) {
|
||||
if (!isInteracting && !(isFlinging && isFastFlinging)) {
|
||||
if (!isInteracting && !isFlinging) {
|
||||
if (highResScale != zoomAnimatable.value) {
|
||||
highResScale = zoomAnimatable.value
|
||||
}
|
||||
|
|
@ -1071,6 +1166,91 @@ internal fun PdfVerticalReader(
|
|||
}
|
||||
}
|
||||
|
||||
val onDoubleTapDragZoomStart: (Offset) -> Unit = {
|
||||
if (!isScrollLocked) {
|
||||
isVerticalOneHandZooming = true
|
||||
Timber.tag(PDF_ONE_HAND_ZOOM_TRACE_TAG).d(
|
||||
"vertical.oneHandStart requestedPivot=$it center=(${(screenWidth / 2f).toInt()},${(screenHeight / 2f).toInt()}) " +
|
||||
"zoom=${zoomAnimatable.value} pan=(${panXAnimatable.value},${panYAnimatable.value})"
|
||||
)
|
||||
oneHandZoomPivotScreen = Offset(screenWidth / 2f, screenHeight / 2f)
|
||||
oneHandZoomStartZoom = zoomAnimatable.value
|
||||
oneHandZoomStartPan = Offset(panXAnimatable.value, panYAnimatable.value)
|
||||
isInteracting = true
|
||||
isDragging = true
|
||||
scope.launch {
|
||||
zoomAnimatable.stop()
|
||||
panXAnimatable.stop()
|
||||
panYAnimatable.stop()
|
||||
panXAnimatable.updateBounds(null, null)
|
||||
panYAnimatable.updateBounds(null, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val onDoubleTapDragZoom: (Offset, Float) -> Unit = { _, totalDragY ->
|
||||
if (!isScrollLocked) {
|
||||
val screenDragY = totalDragY * oneHandZoomStartZoom
|
||||
val targetZoom = pdfOneHandZoomScale(
|
||||
startScale = oneHandZoomStartZoom,
|
||||
totalDragY = screenDragY,
|
||||
dragDistanceForDoublePx = oneHandZoomDistancePx,
|
||||
minScale = fitZoom,
|
||||
maxScale = 5f
|
||||
)
|
||||
val rawPan = topLeftPdfPanForScaleChange(
|
||||
previousScale = oneHandZoomStartZoom,
|
||||
nextScale = targetZoom,
|
||||
previousPan = oneHandZoomStartPan,
|
||||
pivot = oneHandZoomPivotScreen
|
||||
)
|
||||
val (finalZoom, finalX, finalY) = clampCamera(targetZoom, rawPan.x, rawPan.y)
|
||||
Timber.tag(PDF_ONE_HAND_ZOOM_TRACE_TAG).v(
|
||||
"vertical.oneHandUpdate dragY=$totalDragY screenDragY=$screenDragY " +
|
||||
"targetZoom=$targetZoom finalZoom=$finalZoom pan=($finalX,$finalY)"
|
||||
)
|
||||
onZoomChange(finalZoom)
|
||||
dragCameraUpdates.trySend(Triple(finalZoom, finalX, finalY))
|
||||
}
|
||||
}
|
||||
|
||||
val onDoubleTapDragZoomEnd: () -> Unit = {
|
||||
val wasOneHandZooming = isVerticalOneHandZooming
|
||||
isVerticalOneHandZooming = false
|
||||
if (!isScrollLocked || wasOneHandZooming) {
|
||||
Timber.tag(PDF_ONE_HAND_ZOOM_TRACE_TAG).d(
|
||||
"vertical.oneHandEnd zoom=${zoomAnimatable.value} pan=(${panXAnimatable.value},${panYAnimatable.value})"
|
||||
)
|
||||
isInteracting = false
|
||||
isDragging = false
|
||||
val currentZoom = zoomAnimatable.value
|
||||
if (currentZoom > fitZoom && currentZoom < fitZoom * 1.05f) {
|
||||
scope.launch {
|
||||
val (finalZoom, finalX, finalY) = clampCamera(
|
||||
fitZoom,
|
||||
panXAnimatable.value,
|
||||
panYAnimatable.value
|
||||
)
|
||||
coroutineScope {
|
||||
launch { zoomAnimatable.animateTo(finalZoom, animationSpec = tween(180, easing = FastOutSlowInEasing)) }
|
||||
launch { panXAnimatable.animateTo(finalX, animationSpec = tween(180, easing = FastOutSlowInEasing)) }
|
||||
launch { panYAnimatable.animateTo(finalY, animationSpec = tween(180, easing = FastOutSlowInEasing)) }
|
||||
}
|
||||
onZoomChange(finalZoom)
|
||||
updatePanBoundsForZoom(finalZoom)
|
||||
}
|
||||
} else {
|
||||
updatePanBoundsForZoom(currentZoom)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val currentOnPageClick by rememberUpdatedState(onPageClick)
|
||||
val currentOnDoubleTapToZoom by rememberUpdatedState(onDoubleTapToZoom)
|
||||
val currentOnDoubleTapDragZoomStart by rememberUpdatedState(onDoubleTapDragZoomStart)
|
||||
val currentOnDoubleTapDragZoom by rememberUpdatedState(onDoubleTapDragZoom)
|
||||
val currentOnDoubleTapDragZoomEnd by rememberUpdatedState(onDoubleTapDragZoomEnd)
|
||||
|
||||
val globalDrawingModifier = Modifier.pointerInput(
|
||||
isEditMode,
|
||||
layoutInfo,
|
||||
|
|
@ -1180,31 +1360,86 @@ internal fun PdfVerticalReader(
|
|||
.fillMaxSize()
|
||||
.background(if (showPageGap) Color.Transparent else verticalPageBackgroundColor)
|
||||
.then(globalDrawingModifier)
|
||||
.pointerInput(isEditMode, selectedTool, isStylusOnlyMode, isScrollLocked) {
|
||||
Timber.tag("PdfTouchDebug").v(
|
||||
"VerticalReader: TapPointerInput init. isEditMode=$isEditMode"
|
||||
)
|
||||
|
||||
// Vertical zoom gestures live here so page tap handlers do not steal
|
||||
// alternating double-tap-hold attempts.
|
||||
.pointerInput(
|
||||
layoutInfo,
|
||||
isEditMode,
|
||||
selectedTool,
|
||||
isStylusOnlyMode,
|
||||
isScrollLocked
|
||||
) {
|
||||
val isTapDetectionAllowed = !isEditMode ||
|
||||
selectedTool == InkType.TEXT ||
|
||||
isStylusOnlyMode
|
||||
|
||||
if (!isTapDetectionAllowed) return@pointerInput
|
||||
if (!isTapDetectionAllowed) {
|
||||
Timber.tag(PDF_ONE_HAND_ZOOM_TRACE_TAG).d(
|
||||
"vertical.rootDetector.disabled edit=$isEditMode tool=$selectedTool stylusOnly=$isStylusOnlyMode"
|
||||
)
|
||||
return@pointerInput
|
||||
}
|
||||
|
||||
detectTapGestures(onTap = {
|
||||
if (!isEditMode) {
|
||||
Timber.tag("PdfTouchDebug").d("VerticalReader: Tap detected")
|
||||
selectionClearTrigger++
|
||||
onPageClick()
|
||||
} else if (selectedTool == InkType.TEXT) {
|
||||
onPageClick()
|
||||
Timber.tag(PDF_ONE_HAND_ZOOM_TRACE_TAG).d(
|
||||
"vertical.rootDetector.enabled scrollLocked=$isScrollLocked edit=$isEditMode " +
|
||||
"tool=$selectedTool pages=${layoutInfo.size} zoom=${zoomAnimatable.value}"
|
||||
)
|
||||
|
||||
fun isOverPage(screenOffset: Offset): Boolean {
|
||||
val zoom = zoomAnimatable.value.takeIf { it > 0f } ?: fitZoom
|
||||
val docX = (screenOffset.x - panXAnimatable.value) / zoom
|
||||
val docY = (screenOffset.y - panYAnimatable.value) / zoom
|
||||
return layoutInfo.any { page ->
|
||||
docX >= 0f &&
|
||||
docX <= page.width &&
|
||||
docY >= page.y &&
|
||||
docY <= page.y + page.height
|
||||
}
|
||||
}, onDoubleTap = { offset ->
|
||||
if (!isScrollLocked) {
|
||||
Timber.tag("PdfTouchDebug").d("VerticalReader: DoubleTap detected")
|
||||
onDoubleTapToZoom(offset)
|
||||
}
|
||||
|
||||
detectPdfTapAndOneHandZoomGestures(
|
||||
viewConfiguration = viewConfiguration,
|
||||
canStartOneHandZoom = { !isScrollLocked },
|
||||
canHandleQuickDoubleTap = { !isScrollLocked },
|
||||
consumeSingleTap = false,
|
||||
onTap = { offset ->
|
||||
val overPage = isOverPage(offset)
|
||||
Timber.tag(PDF_ONE_HAND_ZOOM_TRACE_TAG).d(
|
||||
"vertical.rootTap offset=$offset overPage=$overPage"
|
||||
)
|
||||
if (!overPage) {
|
||||
selectionClearTrigger++
|
||||
currentOnPageClick()
|
||||
}
|
||||
},
|
||||
onQuickDoubleTap = { offset ->
|
||||
if (!isScrollLocked) {
|
||||
Timber.tag(PDF_ONE_HAND_ZOOM_TRACE_TAG).d(
|
||||
"vertical.rootQuickDoubleTap offset=$offset zoom=${zoomAnimatable.value}"
|
||||
)
|
||||
currentOnDoubleTapToZoom(offset)
|
||||
}
|
||||
},
|
||||
onOneHandZoomHoldStart = { offset ->
|
||||
if (!isScrollLocked) {
|
||||
Timber.tag(PDF_ONE_HAND_ZOOM_TRACE_TAG).d(
|
||||
"vertical.rootOneHandHoldStart offset=$offset"
|
||||
)
|
||||
currentOnDoubleTapDragZoomStart(offset)
|
||||
}
|
||||
},
|
||||
onOneHandZoom = { offset, totalDragY ->
|
||||
if (!isScrollLocked) {
|
||||
currentOnDoubleTapDragZoom(offset, totalDragY)
|
||||
}
|
||||
},
|
||||
onOneHandZoomEnd = { _ ->
|
||||
if (!isScrollLocked || latestIsVerticalOneHandZooming) {
|
||||
Timber.tag(PDF_ONE_HAND_ZOOM_TRACE_TAG).d("vertical.rootOneHandEnd")
|
||||
currentOnDoubleTapDragZoomEnd()
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
.pointerInput(
|
||||
totalDocHeight,
|
||||
|
|
@ -1224,8 +1459,25 @@ internal fun PdfVerticalReader(
|
|||
)
|
||||
|
||||
val down = awaitFirstDown(requireUnconsumed = false)
|
||||
Timber.tag(PDF_ONE_HAND_ZOOM_TRACE_TAG).d(
|
||||
"vertical.scrollDetector.down consumed=${down.isConsumed} pos=${down.position} " +
|
||||
"zoom=${zoomAnimatable.value} pan=(${panXAnimatable.value},${panYAnimatable.value})"
|
||||
)
|
||||
isInteracting = true
|
||||
isDragging = false
|
||||
val gestureStartNanos = PdfVerticalPerfLog.nowNanos()
|
||||
var gestureLastSampleMs = System.currentTimeMillis()
|
||||
var gestureEventCount = 0
|
||||
var gestureConsumedEventCount = 0
|
||||
var gestureCanceledEventCount = 0
|
||||
var gestureZoomEventCount = 0
|
||||
var gestureMaxPanDelta = 0f
|
||||
PdfVerticalPerfLog.i(
|
||||
"gesture-start type=${down.type} scrollLocked=$isScrollLocked edit=$isEditMode tool=$selectedTool " +
|
||||
"zoom=${PdfVerticalPerfLog.f(zoomAnimatable.value)} highRes=${PdfVerticalPerfLog.f(highResScale)} " +
|
||||
"pan=${PdfVerticalPerfLog.xy(panXAnimatable.value, panYAnimatable.value)} currentPage=${state.currentPage} " +
|
||||
"visible=${state.firstVisiblePage}-${state.lastVisiblePage}"
|
||||
)
|
||||
|
||||
Timber.tag("PointerTypeDebug").d("VerticalReader: Input Type detected: ${down.type}")
|
||||
|
||||
|
|
@ -1282,10 +1534,28 @@ internal fun PdfVerticalReader(
|
|||
|
||||
do {
|
||||
val event = awaitPointerEvent()
|
||||
gestureEventCount++
|
||||
val isMultiTouch = event.changes.size > 1
|
||||
val canceled = event.changes.any { it.isConsumed } && !isMultiTouch
|
||||
|
||||
if (latestIsVerticalOneHandZooming && !isMultiTouch) {
|
||||
Timber.tag(PDF_ONE_HAND_ZOOM_TRACE_TAG).v(
|
||||
"vertical.scrollDetector.skipOneHandActive events=$gestureEventCount " +
|
||||
"changes=${event.changes.joinToString { change ->
|
||||
"pressed=${change.pressed},consumed=${change.isConsumed},moved=${change.positionChanged()}"
|
||||
}}"
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
if (canceled) {
|
||||
gestureCanceledEventCount++
|
||||
Timber.tag(PDF_ONE_HAND_ZOOM_TRACE_TAG).d(
|
||||
"vertical.scrollDetector.canceledByConsumed mode=$gestureDisambiguationMode " +
|
||||
"events=$gestureEventCount changes=${event.changes.joinToString { change ->
|
||||
"pressed=${change.pressed},consumed=${change.isConsumed},moved=${change.positionChanged()}"
|
||||
}}"
|
||||
)
|
||||
Timber.tag("PdfTouchDebug").v(
|
||||
"VerticalReader: Event Canceled (Child consumed?)."
|
||||
)
|
||||
|
|
@ -1311,7 +1581,11 @@ internal fun PdfVerticalReader(
|
|||
)
|
||||
|
||||
totalPanDistance += panMagnitude
|
||||
gestureMaxPanDelta = max(gestureMaxPanDelta, panMagnitude)
|
||||
gestureZoomAccumulator *= zoomChange
|
||||
if (abs(zoomChange - 1f) > 0.001f) {
|
||||
gestureZoomEventCount++
|
||||
}
|
||||
|
||||
val isZoomPastSlop = abs(gestureZoomAccumulator - 1f) > 0.05f
|
||||
val isPanPastSlop = totalPanDistance > touchSlop
|
||||
|
|
@ -1320,11 +1594,17 @@ internal fun PdfVerticalReader(
|
|||
if (isPanPastSlop || isZoomPastSlop) {
|
||||
if (spanMagnitude > panMagnitude * 1.5f) {
|
||||
gestureDisambiguationMode = 2
|
||||
Timber.tag(PDF_ONE_HAND_ZOOM_TRACE_TAG).d(
|
||||
"vertical.scrollDetector.modeZoom span=$spanMagnitude pan=$panMagnitude totalPan=$totalPanDistance"
|
||||
)
|
||||
Timber.tag("PdfTouchDebug").d(
|
||||
"Locked to ZOOM (Span > Pan * 1.5)"
|
||||
)
|
||||
} else {
|
||||
gestureDisambiguationMode = 1
|
||||
Timber.tag(PDF_ONE_HAND_ZOOM_TRACE_TAG).d(
|
||||
"vertical.scrollDetector.modePan span=$spanMagnitude pan=$panMagnitude totalPan=$totalPanDistance"
|
||||
)
|
||||
Timber.tag("PdfTouchDebug").d(
|
||||
"Locked to PAN (Pan Dominant)"
|
||||
)
|
||||
|
|
@ -1333,6 +1613,9 @@ internal fun PdfVerticalReader(
|
|||
} else if (gestureDisambiguationMode == 1) {
|
||||
if (spanMagnitude > (panMagnitude * 3f) && spanMagnitude > 4f) {
|
||||
gestureDisambiguationMode = 2
|
||||
Timber.tag(PDF_ONE_HAND_ZOOM_TRACE_TAG).d(
|
||||
"vertical.scrollDetector.modePanToZoom span=$spanMagnitude pan=$panMagnitude"
|
||||
)
|
||||
Timber.tag("PdfTouchDebug").d(
|
||||
"Breakout: Switching PAN -> ZOOM"
|
||||
)
|
||||
|
|
@ -1379,15 +1662,29 @@ internal fun PdfVerticalReader(
|
|||
onZoomChange(accumulatedZoom)
|
||||
}
|
||||
|
||||
scope.launch {
|
||||
zoomAnimatable.snapTo(accumulatedZoom)
|
||||
panXAnimatable.snapTo(accumulatedPanX)
|
||||
panYAnimatable.snapTo(accumulatedPanY)
|
||||
}
|
||||
dragCameraUpdates.trySend(
|
||||
Triple(accumulatedZoom, accumulatedPanX, accumulatedPanY)
|
||||
)
|
||||
|
||||
val consumedChanges = event.changes.count { it.positionChanged() }
|
||||
event.changes.forEach {
|
||||
if (it.positionChanged()) it.consume()
|
||||
}
|
||||
if (consumedChanges > 0) {
|
||||
gestureConsumedEventCount++
|
||||
}
|
||||
|
||||
val nowMs = System.currentTimeMillis()
|
||||
if (nowMs - gestureLastSampleMs >= PdfVerticalPerfLog.SAMPLE_INTERVAL_MS) {
|
||||
gestureLastSampleMs = nowMs
|
||||
PdfVerticalPerfLog.d(
|
||||
"gesture-drag-sample events=$gestureEventCount consumed=$gestureConsumedEventCount " +
|
||||
"mode=$gestureDisambiguationMode multi=$isMultiTouch panDelta=${PdfVerticalPerfLog.f(panMagnitude)} " +
|
||||
"totalPan=${PdfVerticalPerfLog.f(totalPanDistance)} zoomChange=${PdfVerticalPerfLog.f(zoomChange)} " +
|
||||
"zoom=${PdfVerticalPerfLog.f(accumulatedZoom)} pan=${PdfVerticalPerfLog.xy(accumulatedPanX, accumulatedPanY)} " +
|
||||
"highRes=${PdfVerticalPerfLog.f(highResScale)}"
|
||||
)
|
||||
}
|
||||
|
||||
if (event.changes.isNotEmpty()) {
|
||||
velocityTrackerAccumulator += panChange
|
||||
|
|
@ -1405,75 +1702,102 @@ internal fun PdfVerticalReader(
|
|||
}
|
||||
isDragging = false
|
||||
|
||||
val validFlingCondition = panLocked
|
||||
val gestureDurationMs = PdfVerticalPerfLog.elapsedMs(gestureStartNanos)
|
||||
|
||||
if (validFlingCondition) {
|
||||
if (panLocked) {
|
||||
val velocity = tracker.calculateVelocity()
|
||||
val flingSensitivity = 2.0f
|
||||
val minFlingVelocity = 250f
|
||||
val (finalZoom, finalX, finalY) = clampCamera(
|
||||
accumulatedZoom, accumulatedPanX, accumulatedPanY
|
||||
)
|
||||
val zoomedDocWidth = screenWidth * finalZoom
|
||||
val zoomedDocHeight = totalDocHeight * finalZoom
|
||||
|
||||
scope.launch {
|
||||
isFlinging = true
|
||||
try {
|
||||
if (accumulatedZoom !in fitZoom..5f) {
|
||||
zoomAnimatable.animateTo(
|
||||
finalZoom, animationSpec = tween(300)
|
||||
)
|
||||
}
|
||||
onZoomChange(zoomAnimatable.targetValue)
|
||||
val zoomedDocWidth = screenWidth * finalZoom
|
||||
val zoomedDocHeight = totalDocHeight * finalZoom
|
||||
|
||||
val flingMinX: Float
|
||||
val flingMaxX: Float
|
||||
if (zoomedDocWidth < screenWidth) {
|
||||
val centeredX = (screenWidth - zoomedDocWidth) / 2f
|
||||
flingMinX = centeredX
|
||||
flingMaxX = centeredX
|
||||
} else {
|
||||
flingMinX = -(zoomedDocWidth - screenWidth)
|
||||
flingMaxX = 0f
|
||||
}
|
||||
|
||||
val minPanY =
|
||||
(screenHeight - footerHeightPx - zoomedDocHeight).coerceAtMost(
|
||||
headerHeightPx
|
||||
)
|
||||
Timber.tag(SCROLL_BOUNDS_TAG).i("Fling Logic:")
|
||||
Timber.tag(SCROLL_BOUNDS_TAG)
|
||||
.d("- totalDocHeight: $totalDocHeight, zoom: $finalZoom -> zoomedDocHeight: $zoomedDocHeight")
|
||||
Timber.tag(SCROLL_BOUNDS_TAG)
|
||||
.d("- Fling bounds set to Y:[$minPanY, $headerHeightPx]")
|
||||
panXAnimatable.updateBounds(flingMinX, flingMaxX)
|
||||
panYAnimatable.updateBounds(minPanY, headerHeightPx)
|
||||
|
||||
coroutineScope {
|
||||
launch {
|
||||
val rawX = velocity.x * flingSensitivity
|
||||
val flingX = if (abs(rawX) > minFlingVelocity && !isScrollLocked) rawX
|
||||
else 0f
|
||||
|
||||
if (flingX != 0f) panXAnimatable.animateDecay(
|
||||
flingX, decay
|
||||
)
|
||||
}
|
||||
launch {
|
||||
val rawY = velocity.y * flingSensitivity
|
||||
val flingY = if (abs(rawY) > minFlingVelocity) rawY
|
||||
else 0f
|
||||
|
||||
if (flingY != 0f) panYAnimatable.animateDecay(
|
||||
flingY, decay
|
||||
)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
isFlinging = false
|
||||
}
|
||||
val flingMinX: Float
|
||||
val flingMaxX: Float
|
||||
if (zoomedDocWidth < screenWidth) {
|
||||
val centeredX = (screenWidth - zoomedDocWidth) / 2f
|
||||
flingMinX = centeredX
|
||||
flingMaxX = centeredX
|
||||
} else {
|
||||
flingMinX = -(zoomedDocWidth - screenWidth)
|
||||
flingMaxX = 0f
|
||||
}
|
||||
|
||||
val minPanY =
|
||||
(screenHeight - footerHeightPx - zoomedDocHeight).coerceAtMost(
|
||||
headerHeightPx
|
||||
)
|
||||
val rawX = velocity.x * flingSensitivity
|
||||
val rawY = velocity.y * flingSensitivity
|
||||
val flingX = if (abs(rawX) > minFlingVelocity && !isScrollLocked) rawX else 0f
|
||||
val flingY = if (abs(rawY) > minFlingVelocity) rawY else 0f
|
||||
val shouldRunFling = flingX != 0f || flingY != 0f || accumulatedZoom !in fitZoom..5f
|
||||
PdfVerticalPerfLog.i(
|
||||
"gesture-end duration=${gestureDurationMs}ms events=$gestureEventCount consumed=$gestureConsumedEventCount " +
|
||||
"canceled=$gestureCanceledEventCount zoomEvents=$gestureZoomEventCount maxPanDelta=${PdfVerticalPerfLog.f(gestureMaxPanDelta)} " +
|
||||
"totalPan=${PdfVerticalPerfLog.f(totalPanDistance)} mode=$gestureDisambiguationMode panLocked=$panLocked shouldRunFling=$shouldRunFling " +
|
||||
"velocity=${PdfVerticalPerfLog.xy(velocity.x, velocity.y)} fling=${PdfVerticalPerfLog.xy(flingX, flingY)} " +
|
||||
"zoom=${PdfVerticalPerfLog.f(finalZoom)} pan=${PdfVerticalPerfLog.xy(finalX, finalY)}"
|
||||
)
|
||||
|
||||
if (shouldRunFling) {
|
||||
scope.launch {
|
||||
isFlinging = true
|
||||
val flingStartNanos = PdfVerticalPerfLog.nowNanos()
|
||||
PdfVerticalPerfLog.i(
|
||||
"fling-start fling=${PdfVerticalPerfLog.xy(flingX, flingY)} " +
|
||||
"boundsX=${PdfVerticalPerfLog.xy(flingMinX, flingMaxX)} boundsY=${PdfVerticalPerfLog.xy(minPanY, headerHeightPx)} " +
|
||||
"zoomedDocH=${PdfVerticalPerfLog.f(zoomedDocHeight)} highRes=${PdfVerticalPerfLog.f(highResScale)}"
|
||||
)
|
||||
try {
|
||||
if (accumulatedZoom !in fitZoom..5f) {
|
||||
zoomAnimatable.animateTo(
|
||||
finalZoom, animationSpec = tween(300)
|
||||
)
|
||||
}
|
||||
onZoomChange(zoomAnimatable.targetValue)
|
||||
Timber.tag(SCROLL_BOUNDS_TAG).i("Fling Logic:")
|
||||
Timber.tag(SCROLL_BOUNDS_TAG)
|
||||
.d("- totalDocHeight: $totalDocHeight, zoom: $finalZoom -> zoomedDocHeight: $zoomedDocHeight")
|
||||
Timber.tag(SCROLL_BOUNDS_TAG)
|
||||
.d("- Fling bounds set to Y:[$minPanY, $headerHeightPx]")
|
||||
panXAnimatable.updateBounds(flingMinX, flingMaxX)
|
||||
panYAnimatable.updateBounds(minPanY, headerHeightPx)
|
||||
|
||||
coroutineScope {
|
||||
launch {
|
||||
if (flingX != 0f) panXAnimatable.animateDecay(
|
||||
flingX, decay
|
||||
)
|
||||
}
|
||||
launch {
|
||||
if (flingY != 0f) panYAnimatable.animateDecay(
|
||||
flingY, decay
|
||||
)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
PdfVerticalPerfLog.i(
|
||||
"fling-end duration=${PdfVerticalPerfLog.elapsedMs(flingStartNanos)}ms " +
|
||||
"zoom=${PdfVerticalPerfLog.f(zoomAnimatable.value)} pan=${PdfVerticalPerfLog.xy(panXAnimatable.value, panYAnimatable.value)} " +
|
||||
"velocity=${PdfVerticalPerfLog.xy(panXAnimatable.velocity, panYAnimatable.velocity)}"
|
||||
)
|
||||
isFlinging = false
|
||||
}
|
||||
}
|
||||
} else {
|
||||
panXAnimatable.updateBounds(flingMinX, flingMaxX)
|
||||
panYAnimatable.updateBounds(minPanY, headerHeightPx)
|
||||
}
|
||||
} else {
|
||||
PdfVerticalPerfLog.i(
|
||||
"gesture-end duration=${gestureDurationMs}ms events=$gestureEventCount consumed=$gestureConsumedEventCount " +
|
||||
"canceled=$gestureCanceledEventCount zoomEvents=$gestureZoomEventCount maxPanDelta=${PdfVerticalPerfLog.f(gestureMaxPanDelta)} " +
|
||||
"totalPan=${PdfVerticalPerfLog.f(totalPanDistance)} mode=$gestureDisambiguationMode panLocked=false no-fling " +
|
||||
"zoom=${PdfVerticalPerfLog.f(accumulatedZoom)} pan=${PdfVerticalPerfLog.xy(accumulatedPanX, accumulatedPanY)}"
|
||||
)
|
||||
}
|
||||
}
|
||||
}) {
|
||||
|
|
@ -1543,6 +1867,11 @@ internal fun PdfVerticalReader(
|
|||
Timber.tag("PdfDrawPerf").d(
|
||||
"Vertical Visible Pages Changed: ${finalPages.map { it.index }} (Dragging: ${draggedBox != null})"
|
||||
)
|
||||
PdfVerticalPerfLog.d(
|
||||
"visible-pages pages=${finalPages.map { it.index }} base=${baseVisiblePages.map { it.index }} " +
|
||||
"draggingBox=${draggedBox != null} zoom=${PdfVerticalPerfLog.f(zoom)} panY=${PdfVerticalPerfLog.f(panY)} " +
|
||||
"viewport=${PdfVerticalPerfLog.xy(viewportTop, viewportBottom)} buffered=${PdfVerticalPerfLog.xy(searchTop, searchBottom)}"
|
||||
)
|
||||
finalPages
|
||||
} else {
|
||||
cached
|
||||
|
|
@ -1569,6 +1898,10 @@ internal fun PdfVerticalReader(
|
|||
|
||||
if (mostVisible != null && mostVisible.index != state.currentPage) {
|
||||
Timber.tag("PdfPositionDebug").v("VerticalReader: Page changed to ${mostVisible.index} (PanY: $panY)")
|
||||
PdfVerticalPerfLog.d(
|
||||
"current-page-change from=${state.currentPage} to=${mostVisible.index} " +
|
||||
"viewport=${PdfVerticalPerfLog.xy(realViewportTop, realViewportBottom)} panY=${PdfVerticalPerfLog.f(panY)} zoom=${PdfVerticalPerfLog.f(zoom)}"
|
||||
)
|
||||
state.currentPage = mostVisible.index
|
||||
}
|
||||
}
|
||||
|
|
@ -1678,24 +2011,6 @@ internal fun PdfVerticalReader(
|
|||
{ text: String -> onSearchText(text) }
|
||||
}
|
||||
|
||||
val currentOnDoubleTapToZoom by rememberUpdatedState(onDoubleTapToZoom)
|
||||
val onDoubleTapLambda = remember(page, screenWidth, screenHeight) {
|
||||
{ localOffset: Offset ->
|
||||
Timber.tag("PdfZoomDebug").d(
|
||||
"Page ${page.index} Double Tap: Local=$localOffset, PageY=${page.y}"
|
||||
)
|
||||
val contentX = localOffset.x
|
||||
val contentY = localOffset.y + page.y
|
||||
val currentZ = zoomAnimatable.value
|
||||
val panX = panXAnimatable.value
|
||||
val panY = panYAnimatable.value
|
||||
val screenX = contentX * currentZ + panX
|
||||
val screenY = contentY * currentZ + panY
|
||||
Timber.tag("PdfZoomDebug").d("Mapped to Screen: ($screenX, $screenY)") // Added log
|
||||
currentOnDoubleTapToZoom(Offset(screenX, screenY))
|
||||
}
|
||||
}
|
||||
|
||||
val onTtsHighlightCenter: (Float) -> Unit =
|
||||
remember(page.index, ttsReadingPage) {
|
||||
{ highlightCenterY ->
|
||||
|
|
@ -1792,12 +2107,14 @@ internal fun PdfVerticalReader(
|
|||
onOcrStateChange = onOcrStateChange,
|
||||
onBookmarkClick = { onBookmarkClick(page.index) },
|
||||
isZoomEnabled = false,
|
||||
isScrolling = isDragging || (isFlinging && isFastFlinging),
|
||||
isScrolling = isInteracting ||
|
||||
isDragging ||
|
||||
isFlinging ||
|
||||
isTileRenderIdleCooldownActive,
|
||||
isVerticalScroll = true,
|
||||
showPageNumberOverlay = showPageNumberOverlay,
|
||||
isScrollLocked = isScrollLocked,
|
||||
visualScaleProvider = currentScaleProvider,
|
||||
onDoubleTap = onDoubleTapLambda,
|
||||
clearSelectionTrigger = selectionClearTrigger,
|
||||
onTtsHighlightCenterCalculated = onTtsHighlightCenter,
|
||||
onSearchHighlightCenterCalculated = onSearchHighlightCenter,
|
||||
|
|
@ -1993,6 +2310,11 @@ internal fun PdfVerticalReader(
|
|||
Timber.tag("PdfPerformance").d(
|
||||
"VerticalReader Layout Measure/Place took ${layoutTime}ms for ${measurables.size} items"
|
||||
)
|
||||
PdfVerticalPerfLog.d(
|
||||
"compose-layout-slow duration=${PdfVerticalPerfLog.f(layoutTime)}ms items=${measurables.size} " +
|
||||
"visible=${visiblePages.map { it.index }} zoom=${PdfVerticalPerfLog.f(zoomAnimatable.value)} " +
|
||||
"pan=${PdfVerticalPerfLog.xy(panXAnimatable.value, panYAnimatable.value)}"
|
||||
)
|
||||
}
|
||||
measureResult
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
141
app/src/main/java/com/aryan/reader/pdf/PdfViewerStateLogic.kt
Normal file
141
app/src/main/java/com/aryan/reader/pdf/PdfViewerStateLogic.kt
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
package com.aryan.reader.pdf
|
||||
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import com.aryan.reader.shared.pdf.PdfSpreadLayout
|
||||
import com.aryan.reader.shared.reader.ReaderSettings
|
||||
|
||||
internal fun resolveEraserStrokeWidth(
|
||||
isEraserOverride: Boolean,
|
||||
activeToolThickness: Float,
|
||||
eraserToolThickness: Float
|
||||
): Float = if (isEraserOverride) eraserToolThickness else activeToolThickness
|
||||
|
||||
internal fun canUsePdfSidecarsForBook(
|
||||
activeBookId: String?,
|
||||
loadedSidecarBookId: String?,
|
||||
areSidecarsLoaded: Boolean
|
||||
): Boolean = activeBookId != null && areSidecarsLoaded && loadedSidecarBookId == activeBookId
|
||||
|
||||
internal fun canManagePdfVirtualPages(
|
||||
isDocumentReady: Boolean,
|
||||
currentBookId: String?,
|
||||
loadedPageLayoutBookId: String?,
|
||||
virtualPageCount: Int
|
||||
): Boolean {
|
||||
return isDocumentReady &&
|
||||
currentBookId != null &&
|
||||
loadedPageLayoutBookId == currentBookId &&
|
||||
virtualPageCount > 0
|
||||
}
|
||||
|
||||
internal fun currentPageScaleAfterPdfPageChange(
|
||||
displayMode: DisplayMode,
|
||||
isScrollLocked: Boolean,
|
||||
lockedState: Triple<Float, Float, Float>?,
|
||||
currentActiveScale: Float
|
||||
): Float {
|
||||
return if (displayMode == DisplayMode.PAGINATION && isScrollLocked) {
|
||||
lockedState?.first ?: currentActiveScale
|
||||
} else {
|
||||
1f
|
||||
}
|
||||
}
|
||||
|
||||
internal fun pdfPageRangeText(
|
||||
pageIndex: Int,
|
||||
pageCount: Int,
|
||||
displayMode: DisplayMode,
|
||||
settings: ReaderSettings
|
||||
): String {
|
||||
val pageRange = if (displayMode == DisplayMode.PAGINATION) {
|
||||
PdfSpreadLayout.pageRangeLabel(pageIndex, pageCount, settings)
|
||||
} else {
|
||||
"${pageIndex.coerceIn(0, (pageCount - 1).coerceAtLeast(0)) + 1}"
|
||||
}
|
||||
return "$pageRange / $pageCount"
|
||||
}
|
||||
|
||||
internal fun pdfPageRangeLabel(
|
||||
pageIndex: Int,
|
||||
pageCount: Int,
|
||||
displayMode: DisplayMode,
|
||||
settings: ReaderSettings
|
||||
): String {
|
||||
val pageRange = if (displayMode == DisplayMode.PAGINATION) {
|
||||
PdfSpreadLayout.pageRangeLabel(pageIndex, pageCount, settings)
|
||||
} else {
|
||||
"${pageIndex.coerceIn(0, (pageCount - 1).coerceAtLeast(0)) + 1}"
|
||||
}
|
||||
return if ('-' in pageRange) {
|
||||
"Pages $pageRange of $pageCount"
|
||||
} else {
|
||||
"Page $pageRange of $pageCount"
|
||||
}
|
||||
}
|
||||
|
||||
internal fun clampPdfSpreadCameraOffset(
|
||||
scale: Float,
|
||||
offset: Offset,
|
||||
viewportWidth: Float,
|
||||
viewportHeight: Float
|
||||
): Offset {
|
||||
if (viewportWidth <= 0f || viewportHeight <= 0f || scale <= 1f) return Offset.Zero
|
||||
val maxOffsetX = ((viewportWidth * scale) - viewportWidth).coerceAtLeast(0f) / 2f
|
||||
val maxOffsetY = ((viewportHeight * scale) - viewportHeight).coerceAtLeast(0f) / 2f
|
||||
return Offset(
|
||||
x = offset.x.coerceIn(-maxOffsetX, maxOffsetX),
|
||||
y = offset.y.coerceIn(-maxOffsetY, maxOffsetY)
|
||||
)
|
||||
}
|
||||
|
||||
internal fun activePdfCameraAfterLockPreferenceLoad(
|
||||
isScrollLocked: Boolean,
|
||||
lockedState: Triple<Float, Float, Float>?
|
||||
): Pair<Float, Offset> {
|
||||
return if (isScrollLocked && lockedState != null) {
|
||||
lockedState.first to Offset(lockedState.second, lockedState.third)
|
||||
} else {
|
||||
1f to Offset.Zero
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import android.content.Context
|
|||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Paint
|
||||
import android.graphics.RectF
|
||||
import android.graphics.Typeface
|
||||
import android.graphics.pdf.PdfRenderer
|
||||
import android.net.Uri
|
||||
|
|
@ -33,12 +34,22 @@ import androidx.compose.ui.unit.isSpecified
|
|||
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.shared.pdf.PdfAnnotationKind
|
||||
import com.aryan.reader.shared.pdf.PdfInkTool
|
||||
import com.aryan.reader.shared.pdf.PdfPageBounds
|
||||
import com.aryan.reader.shared.pdf.PdfPagePoint
|
||||
import com.aryan.reader.shared.pdf.SharedPdfAnnotation
|
||||
import com.aryan.reader.shared.pdf.SharedPdfAnnotationExportMapper
|
||||
import com.aryan.reader.shared.pdf.pdfInkAppearancePoints
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
import java.io.OutputStream
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import timber.log.Timber
|
||||
|
|
@ -111,7 +122,8 @@ internal object PdfiumAnnotationExporter {
|
|||
textBoxes = emptyList(),
|
||||
highlights = highlights.orEmpty(),
|
||||
richTextPageLayouts = emptyList(),
|
||||
rasterOverlays = rasterOverlays
|
||||
rasterOverlays = rasterOverlays,
|
||||
pageSizes = pageSizes
|
||||
)
|
||||
|
||||
if (!payload.hasAnnotations()) {
|
||||
|
|
@ -119,38 +131,51 @@ internal object PdfiumAnnotationExporter {
|
|||
return@withContext
|
||||
}
|
||||
|
||||
val exported = NativePdfiumBridge.exportAnnotatedPdf(
|
||||
sourcePath = sourceFile.absolutePath,
|
||||
destPath = destFile.absolutePath,
|
||||
inkPageIndices = payload.inkPageIndices,
|
||||
inkTypes = payload.inkTypes,
|
||||
inkColors = payload.inkColors,
|
||||
inkStrokeWidths = payload.inkStrokeWidths,
|
||||
inkPointOffsets = payload.inkPointOffsets,
|
||||
inkPointCounts = payload.inkPointCounts,
|
||||
inkPoints = payload.inkPoints,
|
||||
textPageIndices = payload.textPageIndices,
|
||||
textBounds = payload.textBounds,
|
||||
textColors = payload.textColors,
|
||||
textBackgroundColors = payload.textBackgroundColors,
|
||||
textFontSizes = payload.textFontSizes,
|
||||
textFlags = payload.textFlags,
|
||||
textValues = payload.textValues,
|
||||
textFontPaths = payload.textFontPaths,
|
||||
textFontNames = payload.textFontNames,
|
||||
rasterPageIndices = payload.rasterPageIndices,
|
||||
rasterBounds = payload.rasterBounds,
|
||||
rasterWidths = payload.rasterWidths,
|
||||
rasterHeights = payload.rasterHeights,
|
||||
rasterPixelOffsets = payload.rasterPixelOffsets,
|
||||
rasterPixels = payload.rasterPixels,
|
||||
highlightPageIndices = payload.highlightPageIndices,
|
||||
highlightColors = payload.highlightColors,
|
||||
highlightRectOffsets = payload.highlightRectOffsets,
|
||||
highlightRectCounts = payload.highlightRectCounts,
|
||||
highlightRects = payload.highlightRects,
|
||||
highlightContents = payload.highlightContents
|
||||
)
|
||||
val exported = PdfiumEngineProvider.withPdfium {
|
||||
NativePdfiumBridge.exportAnnotatedPdf(
|
||||
sourcePath = sourceFile.absolutePath,
|
||||
destPath = destFile.absolutePath,
|
||||
inkPageIndices = payload.inkPageIndices,
|
||||
inkTypes = payload.inkTypes,
|
||||
inkColors = payload.inkColors,
|
||||
inkStrokeWidths = payload.inkStrokeWidths,
|
||||
inkPointOffsets = payload.inkPointOffsets,
|
||||
inkPointCounts = payload.inkPointCounts,
|
||||
inkPoints = payload.inkPoints,
|
||||
inkNames = payload.inkNames,
|
||||
inkContents = payload.inkContents,
|
||||
textPageIndices = payload.textPageIndices,
|
||||
textBounds = payload.textBounds,
|
||||
textColors = payload.textColors,
|
||||
textBackgroundColors = payload.textBackgroundColors,
|
||||
textFontSizes = payload.textFontSizes,
|
||||
textFlags = payload.textFlags,
|
||||
textValues = payload.textValues,
|
||||
textFontPaths = payload.textFontPaths,
|
||||
textFontNames = payload.textFontNames,
|
||||
rasterPageIndices = payload.rasterPageIndices,
|
||||
rasterBounds = payload.rasterBounds,
|
||||
rasterWidths = payload.rasterWidths,
|
||||
rasterHeights = payload.rasterHeights,
|
||||
rasterPixelOffsets = payload.rasterPixelOffsets,
|
||||
rasterPixels = payload.rasterPixels,
|
||||
highlightPageIndices = payload.highlightPageIndices,
|
||||
highlightColors = payload.highlightColors,
|
||||
highlightRectOffsets = payload.highlightRectOffsets,
|
||||
highlightRectCounts = payload.highlightRectCounts,
|
||||
highlightRects = payload.highlightRects,
|
||||
highlightNames = payload.highlightNames,
|
||||
highlightContents = payload.highlightContents,
|
||||
highlightCommentOffsets = payload.highlightCommentOffsets,
|
||||
highlightCommentCounts = payload.highlightCommentCounts,
|
||||
highlightCommentParentIndices = payload.highlightCommentParentIndices,
|
||||
highlightCommentNames = payload.highlightCommentNames,
|
||||
highlightCommentAuthors = payload.highlightCommentAuthors,
|
||||
highlightCommentContents = payload.highlightCommentContents,
|
||||
highlightCommentCreatedDates = payload.highlightCommentCreatedDates,
|
||||
highlightCommentModifiedDates = payload.highlightCommentModifiedDates
|
||||
)
|
||||
}
|
||||
|
||||
if (!exported) {
|
||||
throw IOException("PDFium failed to write annotated PDF.")
|
||||
|
|
@ -183,15 +208,21 @@ internal object PdfiumAnnotationExporter {
|
|||
highlights: List<PdfUserHighlight>,
|
||||
richTextPageLayouts: List<PageTextLayout> = emptyList(),
|
||||
fontPathResolver: (String?) -> String? = { it },
|
||||
rasterOverlays: List<PdfiumRasterOverlay> = emptyList()
|
||||
rasterOverlays: List<PdfiumRasterOverlay> = emptyList(),
|
||||
pageSizes: List<PdfiumPageSize> = emptyList()
|
||||
): PdfiumAnnotationExportPayload {
|
||||
val inkItems = inkAnnotations.entries
|
||||
.flatMap { (pageIndex, annotations) -> annotations.map { pageIndex to it } }
|
||||
.filter { (_, annotation) ->
|
||||
annotation.points.size >= 2 &&
|
||||
annotation.inkType != InkType.ERASER &&
|
||||
annotation.inkType != InkType.TEXT
|
||||
}
|
||||
val exportPayload = SharedPdfAnnotationExportMapper.build(
|
||||
sharedExportAnnotations(
|
||||
inkAnnotations = inkAnnotations,
|
||||
highlights = highlights,
|
||||
pageSizes = pageSizes
|
||||
)
|
||||
)
|
||||
val inkItems = exportPayload.inkAnnotations
|
||||
val inkPointsForExport = inkItems.map { annotation ->
|
||||
val pageSize = pageSizeFor(pageSizes, annotation.pageIndex)
|
||||
annotation.pdfInkAppearancePoints(pageSize.width.toFloat(), pageSize.height.toFloat())
|
||||
}
|
||||
|
||||
val inkPageIndices = IntArray(inkItems.size)
|
||||
val inkTypes = IntArray(inkItems.size)
|
||||
|
|
@ -199,17 +230,22 @@ internal object PdfiumAnnotationExporter {
|
|||
val inkStrokeWidths = FloatArray(inkItems.size)
|
||||
val inkPointOffsets = IntArray(inkItems.size)
|
||||
val inkPointCounts = IntArray(inkItems.size)
|
||||
val inkPoints = FloatArray(inkItems.sumOf { it.second.points.size } * 2)
|
||||
val inkPoints = FloatArray(inkPointsForExport.sumOf { it.size } * 2)
|
||||
val inkNames = Array(inkItems.size) { "" }
|
||||
val inkContents = Array(inkItems.size) { "" }
|
||||
|
||||
var inkPointCursor = 0
|
||||
inkItems.forEachIndexed { index, (pageIndex, annotation) ->
|
||||
inkPageIndices[index] = pageIndex
|
||||
inkTypes[index] = annotation.inkType.ordinal
|
||||
inkColors[index] = annotation.color.toArgb()
|
||||
inkItems.forEachIndexed { index, annotation ->
|
||||
val points = inkPointsForExport[index]
|
||||
inkPageIndices[index] = annotation.pageIndex
|
||||
inkTypes[index] = annotation.tool.toAndroidInkTypeOrdinal()
|
||||
inkColors[index] = annotation.colorArgb
|
||||
inkStrokeWidths[index] = annotation.strokeWidth
|
||||
inkPointOffsets[index] = inkPointCursor / 2
|
||||
inkPointCounts[index] = annotation.points.size
|
||||
annotation.points.forEach { point ->
|
||||
inkPointCounts[index] = points.size
|
||||
inkNames[index] = annotation.id
|
||||
inkContents[index] = annotation.contents
|
||||
points.forEach { point ->
|
||||
inkPoints[inkPointCursor++] = point.x
|
||||
inkPoints[inkPointCursor++] = point.y
|
||||
}
|
||||
|
|
@ -246,22 +282,49 @@ internal object PdfiumAnnotationExporter {
|
|||
rasterPixelCursor += overlay.pixels.size
|
||||
}
|
||||
|
||||
val boundedHighlights = highlights.filter { it.bounds.isNotEmpty() }
|
||||
val boundedHighlights = exportPayload.highlightAnnotations
|
||||
val highlightPageIndices = IntArray(boundedHighlights.size)
|
||||
val highlightColors = IntArray(boundedHighlights.size)
|
||||
val highlightRectOffsets = IntArray(boundedHighlights.size)
|
||||
val highlightRectCounts = IntArray(boundedHighlights.size)
|
||||
val highlightRects = FloatArray(boundedHighlights.sumOf { it.bounds.size } * 4)
|
||||
val highlightRects = FloatArray(boundedHighlights.sumOf { it.boundsList.size } * 4)
|
||||
val highlightNames = Array(boundedHighlights.size) { "" }
|
||||
val highlightContents = Array(boundedHighlights.size) { "" }
|
||||
val highlightCommentCount = boundedHighlights.sumOf { it.comments.size }
|
||||
val highlightCommentOffsets = IntArray(boundedHighlights.size)
|
||||
val highlightCommentCounts = IntArray(boundedHighlights.size)
|
||||
val highlightCommentParentIndices = IntArray(highlightCommentCount)
|
||||
val highlightCommentNames = Array(highlightCommentCount) { "" }
|
||||
val highlightCommentAuthors = Array(highlightCommentCount) { "" }
|
||||
val highlightCommentContents = Array(highlightCommentCount) { "" }
|
||||
val highlightCommentCreatedDates = Array(highlightCommentCount) { "" }
|
||||
val highlightCommentModifiedDates = Array(highlightCommentCount) { "" }
|
||||
|
||||
var highlightRectCursor = 0
|
||||
var highlightCommentCursor = 0
|
||||
boundedHighlights.forEachIndexed { index, highlight ->
|
||||
highlightPageIndices[index] = highlight.pageIndex
|
||||
highlightColors[index] = highlight.color.color.toArgb()
|
||||
highlightColors[index] = highlight.colorArgb
|
||||
highlightRectOffsets[index] = highlightRectCursor / 4
|
||||
highlightRectCounts[index] = highlight.bounds.size
|
||||
highlightContents[index] = highlight.note?.takeIf { it.isNotBlank() } ?: highlight.text
|
||||
highlight.bounds.forEach { rect ->
|
||||
highlightRectCounts[index] = highlight.boundsList.size
|
||||
highlightNames[index] = highlight.id
|
||||
highlightContents[index] = highlight.contents
|
||||
highlightCommentOffsets[index] = highlightCommentCursor
|
||||
highlightCommentCounts[index] = highlight.comments.size
|
||||
val localCommentIndices = mutableMapOf<String, Int>()
|
||||
highlight.comments.forEachIndexed { localIndex, comment ->
|
||||
val globalIndex = highlightCommentCursor + localIndex
|
||||
highlightCommentParentIndices[globalIndex] = comment.parentId?.let(localCommentIndices::get) ?: -1
|
||||
localCommentIndices[comment.id] = localIndex
|
||||
highlightCommentNames[globalIndex] = comment.id
|
||||
highlightCommentAuthors[globalIndex] = comment.author
|
||||
highlightCommentContents[globalIndex] = comment.contents
|
||||
highlightCommentCreatedDates[globalIndex] = comment.createdAt.toPdfDateString()
|
||||
highlightCommentModifiedDates[globalIndex] = comment.modifiedAt.toPdfDateString()
|
||||
.ifBlank { comment.createdAt.toPdfDateString() }
|
||||
}
|
||||
highlightCommentCursor += highlight.comments.size
|
||||
highlight.boundsList.forEach { rect ->
|
||||
highlightRects[highlightRectCursor++] = rect.left
|
||||
highlightRects[highlightRectCursor++] = rect.top
|
||||
highlightRects[highlightRectCursor++] = rect.right
|
||||
|
|
@ -277,6 +340,8 @@ internal object PdfiumAnnotationExporter {
|
|||
inkPointOffsets = inkPointOffsets,
|
||||
inkPointCounts = inkPointCounts,
|
||||
inkPoints = inkPoints,
|
||||
inkNames = inkNames,
|
||||
inkContents = inkContents,
|
||||
textPageIndices = textPageIndices,
|
||||
textBounds = textBounds,
|
||||
textColors = textColors,
|
||||
|
|
@ -297,7 +362,103 @@ internal object PdfiumAnnotationExporter {
|
|||
highlightRectOffsets = highlightRectOffsets,
|
||||
highlightRectCounts = highlightRectCounts,
|
||||
highlightRects = highlightRects,
|
||||
highlightContents = highlightContents
|
||||
highlightNames = highlightNames,
|
||||
highlightContents = highlightContents,
|
||||
highlightCommentOffsets = highlightCommentOffsets,
|
||||
highlightCommentCounts = highlightCommentCounts,
|
||||
highlightCommentParentIndices = highlightCommentParentIndices,
|
||||
highlightCommentNames = highlightCommentNames,
|
||||
highlightCommentAuthors = highlightCommentAuthors,
|
||||
highlightCommentContents = highlightCommentContents,
|
||||
highlightCommentCreatedDates = highlightCommentCreatedDates,
|
||||
highlightCommentModifiedDates = highlightCommentModifiedDates
|
||||
)
|
||||
}
|
||||
|
||||
private fun sharedExportAnnotations(
|
||||
inkAnnotations: Map<Int, List<PdfAnnotation>>,
|
||||
highlights: List<PdfUserHighlight>,
|
||||
pageSizes: List<PdfiumPageSize>
|
||||
): List<SharedPdfAnnotation> {
|
||||
val annotations = mutableListOf<SharedPdfAnnotation>()
|
||||
inkAnnotations.entries.forEach { (pageIndex, pageAnnotations) ->
|
||||
pageAnnotations.forEach { annotation ->
|
||||
if (annotation.type != AnnotationType.INK) return@forEach
|
||||
annotations += SharedPdfAnnotation(
|
||||
id = annotation.id,
|
||||
pageIndex = pageIndex,
|
||||
kind = PdfAnnotationKind.INK,
|
||||
tool = annotation.inkType.toSharedPdfInkTool(),
|
||||
points = annotation.points.map { point ->
|
||||
PdfPagePoint(point.x, point.y, point.timestamp)
|
||||
},
|
||||
note = annotation.note,
|
||||
colorArgb = annotation.color.toArgb(),
|
||||
strokeWidth = annotation.strokeWidth
|
||||
)
|
||||
}
|
||||
}
|
||||
highlights.forEach { highlight ->
|
||||
val boundsList = highlight.bounds.mapNotNull { rect ->
|
||||
rect.toNormalizedPdfPageBounds(pageSizeFor(pageSizes, highlight.pageIndex))
|
||||
}
|
||||
annotations += SharedPdfAnnotation(
|
||||
id = highlight.id,
|
||||
pageIndex = highlight.pageIndex,
|
||||
kind = PdfAnnotationKind.HIGHLIGHT,
|
||||
tool = PdfInkTool.HIGHLIGHTER,
|
||||
bounds = boundsList.firstOrNull(),
|
||||
boundsList = boundsList,
|
||||
text = highlight.text,
|
||||
note = highlight.note,
|
||||
comments = highlight.comments,
|
||||
colorArgb = highlight.color.color.toArgb(),
|
||||
rangeStartIndex = highlight.range.first,
|
||||
rangeEndIndex = (highlight.range.second - 1).coerceAtLeast(highlight.range.first)
|
||||
)
|
||||
}
|
||||
return annotations
|
||||
}
|
||||
|
||||
private fun InkType.toSharedPdfInkTool(): PdfInkTool {
|
||||
return when (this) {
|
||||
InkType.PEN -> PdfInkTool.PEN
|
||||
InkType.HIGHLIGHTER -> PdfInkTool.HIGHLIGHTER
|
||||
InkType.HIGHLIGHTER_ROUND -> PdfInkTool.HIGHLIGHTER_ROUND
|
||||
InkType.ERASER -> PdfInkTool.ERASER
|
||||
InkType.FOUNTAIN_PEN -> PdfInkTool.FOUNTAIN_PEN
|
||||
InkType.PENCIL -> PdfInkTool.PENCIL
|
||||
InkType.TEXT -> PdfInkTool.TEXT
|
||||
}
|
||||
}
|
||||
|
||||
private fun PdfInkTool.toAndroidInkTypeOrdinal(): Int {
|
||||
return when (this) {
|
||||
PdfInkTool.HIGHLIGHTER -> InkType.HIGHLIGHTER.ordinal
|
||||
PdfInkTool.HIGHLIGHTER_ROUND -> InkType.HIGHLIGHTER_ROUND.ordinal
|
||||
PdfInkTool.FOUNTAIN_PEN -> InkType.FOUNTAIN_PEN.ordinal
|
||||
PdfInkTool.PENCIL -> InkType.PENCIL.ordinal
|
||||
PdfInkTool.TEXT -> InkType.TEXT.ordinal
|
||||
PdfInkTool.ERASER -> InkType.ERASER.ordinal
|
||||
PdfInkTool.NONE,
|
||||
PdfInkTool.PEN -> InkType.PEN.ordinal
|
||||
}
|
||||
}
|
||||
|
||||
private fun RectF.toNormalizedPdfPageBounds(pageSize: PdfiumPageSize): PdfPageBounds? {
|
||||
val pageWidth = pageSize.width.takeIf { it > 0 }?.toFloat() ?: return null
|
||||
val pageHeight = pageSize.height.takeIf { it > 0 }?.toFloat() ?: return null
|
||||
val pdfLeft = minOf(left, right)
|
||||
val pdfRight = maxOf(left, right)
|
||||
val pdfTop = maxOf(top, bottom)
|
||||
val pdfBottom = minOf(top, bottom)
|
||||
if (pdfRight <= pdfLeft || pdfTop <= pdfBottom) return null
|
||||
|
||||
return PdfPageBounds(
|
||||
left = pdfLeft / pageWidth,
|
||||
top = (pageHeight - pdfTop) / pageHeight,
|
||||
right = pdfRight / pageWidth,
|
||||
bottom = (pageHeight - pdfBottom) / pageHeight
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -681,6 +842,13 @@ internal object PdfiumAnnotationExporter {
|
|||
private fun String.sanitizeRasterTextPreservingLength(): String =
|
||||
replace(PAGE_BREAK_CHAR, '\n')
|
||||
.replace('\r', ' ')
|
||||
|
||||
private fun Long.toPdfDateString(): String {
|
||||
if (this <= 0L) return ""
|
||||
return SimpleDateFormat("'D:'yyyyMMddHHmmss'Z'", Locale.US).apply {
|
||||
timeZone = TimeZone.getTimeZone("UTC")
|
||||
}.format(Date(this))
|
||||
}
|
||||
}
|
||||
|
||||
internal data class PdfiumRasterOverlay(
|
||||
|
|
@ -694,7 +862,7 @@ internal data class PdfiumRasterOverlay(
|
|||
val pixels: IntArray
|
||||
)
|
||||
|
||||
private data class PdfiumPageSize(
|
||||
internal data class PdfiumPageSize(
|
||||
val width: Int,
|
||||
val height: Int
|
||||
) {
|
||||
|
|
@ -738,6 +906,8 @@ internal data class PdfiumAnnotationExportPayload(
|
|||
val inkPointOffsets: IntArray,
|
||||
val inkPointCounts: IntArray,
|
||||
val inkPoints: FloatArray,
|
||||
val inkNames: Array<String>,
|
||||
val inkContents: Array<String>,
|
||||
val textPageIndices: IntArray,
|
||||
val textBounds: FloatArray,
|
||||
val textColors: IntArray,
|
||||
|
|
@ -758,7 +928,16 @@ internal data class PdfiumAnnotationExportPayload(
|
|||
val highlightRectOffsets: IntArray,
|
||||
val highlightRectCounts: IntArray,
|
||||
val highlightRects: FloatArray,
|
||||
val highlightContents: Array<String>
|
||||
val highlightNames: Array<String>,
|
||||
val highlightContents: Array<String>,
|
||||
val highlightCommentOffsets: IntArray,
|
||||
val highlightCommentCounts: IntArray,
|
||||
val highlightCommentParentIndices: IntArray,
|
||||
val highlightCommentNames: Array<String>,
|
||||
val highlightCommentAuthors: Array<String>,
|
||||
val highlightCommentContents: Array<String>,
|
||||
val highlightCommentCreatedDates: Array<String>,
|
||||
val highlightCommentModifiedDates: Array<String>
|
||||
) {
|
||||
fun hasAnnotations(): Boolean =
|
||||
inkPageIndices.isNotEmpty() ||
|
||||
|
|
|
|||
|
|
@ -44,9 +44,11 @@ import androidx.compose.ui.text.style.TextDecoration
|
|||
import androidx.compose.ui.unit.Constraints
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.aryan.reader.pdf.data.VirtualPage
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import androidx.compose.ui.text.font.Font
|
||||
|
|
@ -460,6 +462,65 @@ private fun AnnotatedString.withRestoredTrailingAndroidPageBreak(shouldRestore:
|
|||
return this + AnnotatedString(PAGE_BREAK_CHAR.toString())
|
||||
}
|
||||
|
||||
internal fun androidRichTextInsertionIndexForPage(
|
||||
insertPageIndex: Int,
|
||||
pageLayouts: List<PageTextLayout>,
|
||||
textLength: Int
|
||||
): Int {
|
||||
val rawIndex = if (insertPageIndex <= 0) {
|
||||
0
|
||||
} else {
|
||||
pageLayouts.find { it.pageIndex == insertPageIndex - 1 }?.globalEndIndex ?: textLength
|
||||
}
|
||||
return rawIndex.coerceIn(0, textLength)
|
||||
}
|
||||
|
||||
internal fun androidRichTextBlankInsertBreakCount(text: String, insertionCharIndex: Int): Int {
|
||||
val safeIndex = insertionCharIndex.coerceIn(0, text.length)
|
||||
if (safeIndex == 0 || safeIndex == text.length) return 1
|
||||
|
||||
val hasBoundaryBreakBefore = text.getOrNull(safeIndex - 1) == PAGE_BREAK_CHAR
|
||||
val hasBoundaryBreakAfter = text.getOrNull(safeIndex) == PAGE_BREAK_CHAR
|
||||
return if (hasBoundaryBreakBefore || hasBoundaryBreakAfter) 1 else 2
|
||||
}
|
||||
|
||||
internal fun remapAndroidRichTextForLayoutChange(
|
||||
currentLayout: List<VirtualPage>,
|
||||
updatedLayout: List<VirtualPage>,
|
||||
pageLayouts: List<PageTextLayout>
|
||||
): AnnotatedString {
|
||||
if (pageLayouts.isEmpty()) return AnnotatedString("")
|
||||
|
||||
val mapping = buildPdfPageIndexMapping(
|
||||
currentLayout = currentLayout,
|
||||
updatedLayout = updatedLayout,
|
||||
sourcePageIndices = pageLayouts.map { it.pageIndex }
|
||||
)
|
||||
if (mapping.isEmpty()) return AnnotatedString("")
|
||||
|
||||
val contentByTargetPage = linkedMapOf<Int, AnnotatedString>()
|
||||
pageLayouts.sortedBy { it.pageIndex }.forEach { layout ->
|
||||
val targetPageIndex = mapping[layout.pageIndex] ?: return@forEach
|
||||
val pageContent = layout.visibleText.withoutTrailingAndroidPageBreak()
|
||||
contentByTargetPage[targetPageIndex] = pageContent
|
||||
}
|
||||
|
||||
val lastPageWithContent = contentByTargetPage
|
||||
.filterValues { it.text.isNotEmpty() }
|
||||
.keys
|
||||
.maxOrNull()
|
||||
?: return AnnotatedString("")
|
||||
|
||||
val builder = AnnotatedString.Builder()
|
||||
for (pageIndex in 0..lastPageWithContent) {
|
||||
contentByTargetPage[pageIndex]?.let { builder.append(it) }
|
||||
if (pageIndex < lastPageWithContent) {
|
||||
builder.append(PAGE_BREAK_CHAR.toString())
|
||||
}
|
||||
}
|
||||
return builder.toAnnotatedString()
|
||||
}
|
||||
|
||||
class PdfRichTextRepository(private val context: Context) {
|
||||
private val _document = MutableStateFlow<GlobalRichDocument?>(null)
|
||||
val document = _document.asStateFlow()
|
||||
|
|
@ -1166,30 +1227,139 @@ class RichTextController(
|
|||
val original = globalTextFieldValue.annotatedString
|
||||
Timber.tag("RichTextMigration").d("insertPageBreakAt: Target Page Index: $insertPageIndex, Count: $count")
|
||||
|
||||
val insertionCharIndex = if (insertPageIndex == 0) 0 else {
|
||||
val prevLayout = pageLayouts.find { it.pageIndex == insertPageIndex - 1 }
|
||||
val idx = prevLayout?.globalEndIndex ?: original.length
|
||||
Timber.tag("RichTextMigration").v("insertPageBreakAt: Prev Page (${insertPageIndex - 1}) ends at global index $idx")
|
||||
idx
|
||||
}
|
||||
val safeIndex = insertionCharIndex.coerceIn(0, original.length)
|
||||
val safeIndex = androidRichTextInsertionIndexForPage(
|
||||
insertPageIndex = insertPageIndex,
|
||||
pageLayouts = pageLayouts,
|
||||
textLength = original.length
|
||||
)
|
||||
Timber.tag("RichTextMigration").v("insertPageBreakAt: insertion index $safeIndex")
|
||||
|
||||
Timber.tag("RichTextMigration").i("insertPageBreakAt: Inserting $count PAGE_BREAK_CHARs at global index $safeIndex")
|
||||
insertPageBreaksIntoGlobalText(
|
||||
original = original,
|
||||
safeIndex = safeIndex,
|
||||
count = count,
|
||||
caller = "InsertPageBreakAt"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val builder = AnnotatedString.Builder()
|
||||
builder.append(original.subSequence(0, safeIndex))
|
||||
fun insertBlankPageAt(insertPageIndex: Int) {
|
||||
scope.launch {
|
||||
forceSyncAndClear()
|
||||
|
||||
repeat(count) {
|
||||
builder.append(PAGE_BREAK_CHAR.toString())
|
||||
}
|
||||
val original = globalTextFieldValue.annotatedString
|
||||
val safeIndex = androidRichTextInsertionIndexForPage(
|
||||
insertPageIndex = insertPageIndex,
|
||||
pageLayouts = pageLayouts,
|
||||
textLength = original.length
|
||||
)
|
||||
val requiredBreaks = androidRichTextBlankInsertBreakCount(
|
||||
text = original.text,
|
||||
insertionCharIndex = safeIndex
|
||||
)
|
||||
|
||||
builder.append(original.subSequence(safeIndex, original.length))
|
||||
Timber.tag("RichTextMigration").i(
|
||||
"insertBlankPageAt: page=$insertPageIndex index=$safeIndex breaks=$requiredBreaks"
|
||||
)
|
||||
|
||||
val newCursorPos = safeIndex + count
|
||||
insertPageBreaksIntoGlobalText(
|
||||
original = original,
|
||||
safeIndex = safeIndex,
|
||||
count = requiredBreaks,
|
||||
caller = "InsertBlankPageAt"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
globalTextFieldValue = TextFieldValue(builder.toAnnotatedString(), TextRange(newCursorPos))
|
||||
debouncedSave(globalTextFieldValue)
|
||||
repaginate(dirtyStartIndex = safeIndex, caller = "InsertPageBreakAt")
|
||||
suspend fun remapPagesForLayoutChange(
|
||||
currentLayout: List<VirtualPage>,
|
||||
updatedLayout: List<VirtualPage>
|
||||
) = withContext(NonCancellable) {
|
||||
Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i(
|
||||
"rich.remap.start current=${currentLayout.pdfLayoutDebugSummary()} " +
|
||||
"updated=${updatedLayout.pdfLayoutDebugSummary()} pageLayouts=${pageLayouts.size} " +
|
||||
"textLen=${globalTextFieldValue.annotatedString.length}"
|
||||
)
|
||||
forceSyncAndClear()
|
||||
|
||||
val original = globalTextFieldValue.annotatedString
|
||||
if (pageLayouts.isEmpty() && original.text.isNotEmpty()) {
|
||||
Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).w(
|
||||
"rich.remap.skipNoLayouts current=${currentLayout.pdfLayoutDebugSummary()} " +
|
||||
"updated=${updatedLayout.pdfLayoutDebugSummary()} textLen=${original.length}"
|
||||
)
|
||||
Timber.tag("RichTextMigration").w(
|
||||
"remapPagesForLayoutChange skipped: no rich text page layouts for non-empty text"
|
||||
)
|
||||
return@withContext
|
||||
}
|
||||
val remapped = remapAndroidRichTextForLayoutChange(
|
||||
currentLayout = currentLayout,
|
||||
updatedLayout = updatedLayout,
|
||||
pageLayouts = pageLayouts
|
||||
)
|
||||
|
||||
if (remapped == original) {
|
||||
Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i(
|
||||
"rich.remap.noChange current=${currentLayout.pdfLayoutDebugSummary()} " +
|
||||
"updated=${updatedLayout.pdfLayoutDebugSummary()} textLen=${original.length}"
|
||||
)
|
||||
return@withContext
|
||||
}
|
||||
|
||||
Timber.tag("RichTextMigration").i(
|
||||
"remapPagesForLayoutChange: textLen ${original.length} -> ${remapped.length}, " +
|
||||
"pages ${currentLayout.size} -> ${updatedLayout.size}"
|
||||
)
|
||||
Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i(
|
||||
"rich.remap.apply textLen=${original.length}->${remapped.length} " +
|
||||
"current=${currentLayout.pdfLayoutDebugSummary()} updated=${updatedLayout.pdfLayoutDebugSummary()}"
|
||||
)
|
||||
|
||||
globalTextFieldValue = TextFieldValue(
|
||||
remapped,
|
||||
selection = TextRange(remapped.length)
|
||||
)
|
||||
repaginateSync(0)
|
||||
saveCurrentGlobalTextImmediately()
|
||||
Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i(
|
||||
"rich.remap.done textLen=${globalTextFieldValue.annotatedString.length} pageLayouts=${pageLayouts.size}"
|
||||
)
|
||||
}
|
||||
|
||||
private fun insertPageBreaksIntoGlobalText(
|
||||
original: AnnotatedString,
|
||||
safeIndex: Int,
|
||||
count: Int,
|
||||
caller: String
|
||||
) {
|
||||
val safeCount = count.coerceAtLeast(0)
|
||||
if (safeCount == 0) return
|
||||
|
||||
Timber.tag("RichTextMigration").i("$caller: Inserting $safeCount PAGE_BREAK_CHARs at global index $safeIndex")
|
||||
|
||||
val builder = AnnotatedString.Builder()
|
||||
builder.append(original.subSequence(0, safeIndex))
|
||||
|
||||
repeat(safeCount) {
|
||||
builder.append(PAGE_BREAK_CHAR.toString())
|
||||
}
|
||||
|
||||
builder.append(original.subSequence(safeIndex, original.length))
|
||||
|
||||
val newCursorPos = safeIndex + safeCount
|
||||
|
||||
globalTextFieldValue = TextFieldValue(builder.toAnnotatedString(), TextRange(newCursorPos))
|
||||
debouncedSave(globalTextFieldValue)
|
||||
repaginate(dirtyStartIndex = safeIndex, caller = caller)
|
||||
}
|
||||
|
||||
private suspend fun saveCurrentGlobalTextImmediately() {
|
||||
saveJob?.cancel()
|
||||
val finalAnnotated = globalTextFieldValue.annotatedString
|
||||
withContext(Dispatchers.Default) {
|
||||
val doc = RichTextMapper.fromAnnotatedString(finalAnnotated, lastPageHeight)
|
||||
repository.save(bookId, doc)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -64,6 +64,19 @@ interface ReaderTextPage : AutoCloseable {
|
|||
data class ReaderLink(val uri: String?, val destPageIdx: Int?, val bounds: RectF)
|
||||
data class ReaderTextRect(val rect: RectF)
|
||||
|
||||
internal data class PdfNativePageOverlayExtraction(
|
||||
val embeddedAnnotations: List<EmbeddedAnnotation> = emptyList(),
|
||||
val annotationScreenRects: List<Pair<EmbeddedAnnotation, Rect>> = emptyList(),
|
||||
val imageScreenRects: List<Rect> = emptyList(),
|
||||
val resolvedNativePointer: Boolean = true
|
||||
)
|
||||
|
||||
internal data class PdfNativeTapResult(
|
||||
val linkInfo: String? = null,
|
||||
val clickHandled: Boolean = false,
|
||||
val resolvedNativePointer: Boolean = true
|
||||
)
|
||||
|
||||
interface ReaderWebLinks : AutoCloseable {
|
||||
suspend fun countWebLinks(): Int
|
||||
suspend fun getURL(linkIndex: Int, maxLength: Int): String?
|
||||
|
|
@ -103,7 +116,16 @@ object DocumentFactory {
|
|||
ArchiveDocumentWrapper(cacheFile)
|
||||
} else {
|
||||
val pfd = context.contentResolver.openFileDescriptor(uri, "r") ?: throw Exception("Failed to open PDF")
|
||||
PdfDocumentWrapper(PdfiumEngineProvider.withPdfium { pdfiumCore.newDocument(pfd, password) })
|
||||
try {
|
||||
PdfDocumentWrapper(PdfiumEngineProvider.withPdfium { pdfiumCore.newDocument(pfd, password) })
|
||||
} catch (e: Throwable) {
|
||||
try {
|
||||
pfd.close()
|
||||
} catch (closeError: Exception) {
|
||||
e.addSuppressed(closeError)
|
||||
}
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -134,13 +156,25 @@ class PdfDocumentWrapper(val pdfDocument: PdfDocumentKt) : ReaderDocument {
|
|||
val page = PdfiumEngineProvider.withPdfium {
|
||||
if (isClosed.get()) null else pdfDocument.openPage(pageIndex)
|
||||
} ?: return null
|
||||
return PdfPageWrapper(page)
|
||||
return PdfPageWrapper(page, isClosed)
|
||||
}
|
||||
|
||||
override suspend fun getTableOfContents() = PdfiumEngineProvider.withPdfium {
|
||||
pdfDocument.getFixedTableOfContents()
|
||||
}
|
||||
|
||||
internal fun getNativeDocumentPointerForLockedAccess(): Long {
|
||||
if (isClosed.get()) return 0L
|
||||
return try {
|
||||
val documentField = pdfDocument.javaClass.getDeclaredField("document").apply { isAccessible = true }
|
||||
val docUInstance = documentField.get(pdfDocument) ?: return 0L
|
||||
val ptrField = docUInstance.javaClass.getDeclaredField("mNativeDocPtr").apply { isAccessible = true }
|
||||
ptrField.get(docUInstance) as? Long ?: 0L
|
||||
} catch (_: Exception) {
|
||||
0L
|
||||
}
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
if (!isClosed.compareAndSet(false, true)) return
|
||||
PdfiumEngineProvider.withPdfiumBlocking {
|
||||
|
|
@ -149,24 +183,28 @@ class PdfDocumentWrapper(val pdfDocument: PdfDocumentKt) : ReaderDocument {
|
|||
}
|
||||
}
|
||||
|
||||
class PdfPageWrapper(val pdfPage: PdfPageKt) : ReaderPage {
|
||||
class PdfPageWrapper(
|
||||
val pdfPage: PdfPageKt,
|
||||
private val ownerClosed: AtomicBoolean = AtomicBoolean(false)
|
||||
) : ReaderPage {
|
||||
private val isClosed = AtomicBoolean(false)
|
||||
private fun isUnavailable(): Boolean = isClosed.get() || ownerClosed.get()
|
||||
|
||||
override suspend fun getPageWidthPoint() = PdfiumEngineProvider.withPdfium {
|
||||
if (isClosed.get()) 0 else pdfPage.getPageWidthPoint()
|
||||
if (isUnavailable()) 0 else pdfPage.getPageWidthPoint()
|
||||
}
|
||||
|
||||
override suspend fun getPageHeightPoint() = PdfiumEngineProvider.withPdfium {
|
||||
if (isClosed.get()) 0 else pdfPage.getPageHeightPoint()
|
||||
if (isUnavailable()) 0 else pdfPage.getPageHeightPoint()
|
||||
}
|
||||
|
||||
override suspend fun getPageRotation() = PdfiumEngineProvider.withPdfium {
|
||||
if (isClosed.get()) 0 else pdfPage.getPageRotation()
|
||||
if (isUnavailable()) 0 else pdfPage.getPageRotation()
|
||||
}
|
||||
|
||||
override suspend fun renderPageBitmap(bitmap: Bitmap, startX: Int, startY: Int, drawSizeX: Int, drawSizeY: Int, renderAnnot: Boolean) {
|
||||
PdfiumEngineProvider.withPdfium {
|
||||
if (!isClosed.get()) {
|
||||
if (!isUnavailable()) {
|
||||
pdfPage.renderPageBitmap(bitmap, startX, startY, drawSizeX, drawSizeY, renderAnnot)
|
||||
}
|
||||
}
|
||||
|
|
@ -174,21 +212,21 @@ class PdfPageWrapper(val pdfPage: PdfPageKt) : ReaderPage {
|
|||
|
||||
override suspend fun mapRectToDevice(startX: Int, startY: Int, sizeX: Int, sizeY: Int, rotate: Int, coords: RectF) =
|
||||
PdfiumEngineProvider.withPdfium {
|
||||
if (isClosed.get()) Rect() else pdfPage.mapRectToDevice(startX, startY, sizeX, sizeY, rotate, coords)
|
||||
if (isUnavailable()) Rect() else pdfPage.mapRectToDevice(startX, startY, sizeX, sizeY, rotate, coords)
|
||||
}
|
||||
|
||||
override suspend fun mapDeviceCoordsToPage(startX: Int, startY: Int, sizeX: Int, sizeY: Int, rotate: Int, deviceX: Int, deviceY: Int) =
|
||||
PdfiumEngineProvider.withPdfium {
|
||||
if (isClosed.get()) PointF() else pdfPage.mapDeviceCoordsToPage(startX, startY, sizeX, sizeY, rotate, deviceX, deviceY)
|
||||
if (isUnavailable()) PointF() else pdfPage.mapDeviceCoordsToPage(startX, startY, sizeX, sizeY, rotate, deviceX, deviceY)
|
||||
}
|
||||
|
||||
override suspend fun openTextPage(): ReaderTextPage = PdfiumEngineProvider.withPdfium {
|
||||
if (isClosed.get()) DummyTextPage() else PdfTextPageWrapper(pdfPage.openTextPage())
|
||||
if (isUnavailable()) DummyTextPage() else PdfTextPageWrapper(pdfPage.openTextPage(), ownerClosed, isClosed)
|
||||
}
|
||||
|
||||
override suspend fun getLinks(): List<ReaderLink> {
|
||||
return PdfiumEngineProvider.withPdfium {
|
||||
if (isClosed.get()) {
|
||||
if (isUnavailable()) {
|
||||
emptyList()
|
||||
} else {
|
||||
pdfPage.getPageLinks().map { ReaderLink(it.uri, it.destPageIdx, it.bounds) }
|
||||
|
|
@ -197,9 +235,191 @@ class PdfPageWrapper(val pdfPage: PdfPageKt) : ReaderPage {
|
|||
}
|
||||
|
||||
override fun getNativePointer(): Long {
|
||||
if (isUnavailable()) return 0L
|
||||
return extractNativePointer(pdfPage)
|
||||
}
|
||||
|
||||
internal suspend fun extractNativePageOverlays(
|
||||
bitmapWidthPx: Int,
|
||||
bitmapHeightPx: Int,
|
||||
pageRotation: Int,
|
||||
pageIndex: Int,
|
||||
linkAnnotationSubtype: Int
|
||||
): PdfNativePageOverlayExtraction = PdfiumEngineProvider.withPdfium {
|
||||
if (isUnavailable() || bitmapWidthPx <= 0 || bitmapHeightPx <= 0) {
|
||||
return@withPdfium PdfNativePageOverlayExtraction()
|
||||
}
|
||||
|
||||
val pagePtr = extractNativePointer(pdfPage)
|
||||
if (pagePtr == 0L) {
|
||||
return@withPdfium PdfNativePageOverlayExtraction(resolvedNativePointer = false)
|
||||
}
|
||||
|
||||
val imageRects = extractImageScreenRectsLocked(pagePtr, bitmapWidthPx, bitmapHeightPx, pageRotation)
|
||||
val embeddedAnnotations = extractEmbeddedAnnotationsLocked(pagePtr, pageIndex, linkAnnotationSubtype)
|
||||
val mappedAnnots = embeddedAnnotations.mapNotNull { annotation ->
|
||||
val screenRect = pdfPage.mapRectToDevice(
|
||||
0,
|
||||
0,
|
||||
bitmapWidthPx,
|
||||
bitmapHeightPx,
|
||||
pageRotation,
|
||||
annotation.rect
|
||||
)
|
||||
if (screenRect.width() > 0 && screenRect.height() > 0) {
|
||||
annotation to screenRect
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
PdfNativePageOverlayExtraction(
|
||||
embeddedAnnotations = embeddedAnnotations,
|
||||
annotationScreenRects = mappedAnnots,
|
||||
imageScreenRects = imageRects
|
||||
)
|
||||
}
|
||||
|
||||
internal suspend fun resolveNativeTap(
|
||||
documentWrapper: PdfDocumentWrapper?,
|
||||
bitmapWidthPx: Int,
|
||||
bitmapHeightPx: Int,
|
||||
pageRotation: Int,
|
||||
deviceX: Int,
|
||||
deviceY: Int
|
||||
): PdfNativeTapResult = PdfiumEngineProvider.withPdfium {
|
||||
if (isUnavailable() || bitmapWidthPx <= 0 || bitmapHeightPx <= 0) {
|
||||
return@withPdfium PdfNativeTapResult()
|
||||
}
|
||||
|
||||
val pagePtr = extractNativePointer(pdfPage)
|
||||
if (pagePtr == 0L) {
|
||||
return@withPdfium PdfNativeTapResult(resolvedNativePointer = false)
|
||||
}
|
||||
|
||||
val pdfCoords = pdfPage.mapDeviceCoordsToPage(
|
||||
0,
|
||||
0,
|
||||
bitmapWidthPx,
|
||||
bitmapHeightPx,
|
||||
pageRotation,
|
||||
deviceX,
|
||||
deviceY
|
||||
)
|
||||
val docPtr = documentWrapper?.getNativeDocumentPointerForLockedAccess() ?: 0L
|
||||
|
||||
Timber.tag("PdfLinkDiagnostic").i("Extracted docPtr: $docPtr | pagePtr: $pagePtr")
|
||||
|
||||
val linkInfo = NativePdfiumBridge.getLinkInfoAtPoint(
|
||||
docPtr,
|
||||
pagePtr,
|
||||
pdfCoords.x.toDouble(),
|
||||
pdfCoords.y.toDouble()
|
||||
)
|
||||
if (linkInfo != null) {
|
||||
return@withPdfium PdfNativeTapResult(linkInfo = linkInfo)
|
||||
}
|
||||
|
||||
PdfNativeTapResult(
|
||||
clickHandled = NativePdfiumBridge.performClick(
|
||||
pagePtr,
|
||||
pdfCoords.x.toDouble(),
|
||||
pdfCoords.y.toDouble()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun extractImageScreenRectsLocked(
|
||||
pagePtr: Long,
|
||||
bitmapWidthPx: Int,
|
||||
bitmapHeightPx: Int,
|
||||
pageRotation: Int
|
||||
): List<Rect> {
|
||||
return try {
|
||||
val objectCount = NativePdfiumBridge.getPageObjectCount(pagePtr)
|
||||
if (objectCount <= 0) return emptyList()
|
||||
|
||||
val rects = mutableListOf<Rect>()
|
||||
val outRect = FloatArray(4)
|
||||
for (index in 0 until objectCount) {
|
||||
if (NativePdfiumBridge.getPageObjectType(pagePtr, index) != 3) continue
|
||||
if (!NativePdfiumBridge.getPageObjectBoundingBox(pagePtr, index, outRect)) continue
|
||||
|
||||
val pdfRect = RectF(
|
||||
minOf(outRect[0], outRect[2]),
|
||||
maxOf(outRect[1], outRect[3]),
|
||||
maxOf(outRect[0], outRect[2]),
|
||||
minOf(outRect[1], outRect[3])
|
||||
)
|
||||
val deviceRect = pdfPage.mapRectToDevice(
|
||||
0,
|
||||
0,
|
||||
bitmapWidthPx,
|
||||
bitmapHeightPx,
|
||||
pageRotation,
|
||||
pdfRect
|
||||
)
|
||||
if (deviceRect.width() > 0 && deviceRect.height() > 0) {
|
||||
rects += Rect(deviceRect.left, deviceRect.top, deviceRect.right, deviceRect.bottom)
|
||||
}
|
||||
}
|
||||
rects
|
||||
} catch (e: Exception) {
|
||||
Timber.tag("PdfImageDebug").e(e, "Error extracting image rects")
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
private fun extractEmbeddedAnnotationsLocked(
|
||||
pagePtr: Long,
|
||||
pageIndex: Int,
|
||||
linkAnnotationSubtype: Int
|
||||
): List<EmbeddedAnnotation> {
|
||||
return try {
|
||||
val count = NativePdfiumBridge.getAnnotCount(pagePtr)
|
||||
Timber.tag("PdfCommentDebug").d("Page $pageIndex: Total Annotations found = $count")
|
||||
if (count <= 0) return emptyList()
|
||||
|
||||
val annotations = mutableListOf<EmbeddedAnnotation>()
|
||||
for (index in 0 until count) {
|
||||
val subtype = NativePdfiumBridge.getAnnotSubtype(pagePtr, index)
|
||||
if (subtype == linkAnnotationSubtype) continue
|
||||
|
||||
var contents = NativePdfiumBridge.getAnnotString(pagePtr, index, "Contents")
|
||||
if (contents.isNullOrBlank()) {
|
||||
contents = NativePdfiumBridge.getAnnotString(pagePtr, index, "RC")
|
||||
}
|
||||
|
||||
val pdfRectArray = NativePdfiumBridge.getAnnotRect(pagePtr, index)
|
||||
val pdfRect = if (pdfRectArray != null) {
|
||||
RectF(
|
||||
minOf(pdfRectArray[0], pdfRectArray[2]),
|
||||
maxOf(pdfRectArray[1], pdfRectArray[3]),
|
||||
maxOf(pdfRectArray[0], pdfRectArray[2]),
|
||||
minOf(pdfRectArray[1], pdfRectArray[3])
|
||||
)
|
||||
} else {
|
||||
RectF()
|
||||
}
|
||||
|
||||
annotations += EmbeddedAnnotation(
|
||||
index = index,
|
||||
subtype = subtype,
|
||||
rect = pdfRect,
|
||||
contents = contents,
|
||||
author = NativePdfiumBridge.getAnnotString(pagePtr, index, "T"),
|
||||
name = NativePdfiumBridge.getAnnotString(pagePtr, index, "NM"),
|
||||
inReplyTo = NativePdfiumBridge.getAnnotString(pagePtr, index, "IRT")
|
||||
)
|
||||
}
|
||||
|
||||
groupEmbeddedAnnotationsForDisplay(annotations)
|
||||
} catch (e: Exception) {
|
||||
Timber.tag("PdfCommentDebug").e(e, "Error extracting annotations")
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
private fun extractNativePointer(obj: Any): Long {
|
||||
val priorityFields = listOf("page", "mNativePagePtr", "pagePtr", "mNativePage")
|
||||
|
||||
|
|
@ -231,66 +451,74 @@ class PdfPageWrapper(val pdfPage: PdfPageKt) : ReaderPage {
|
|||
|
||||
override fun close() {
|
||||
if (!isClosed.compareAndSet(false, true)) return
|
||||
if (ownerClosed.get()) return
|
||||
PdfiumEngineProvider.withPdfiumBlocking {
|
||||
closePdfiumResource("PdfPageWrapper") { pdfPage.close() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class PdfTextPageWrapper(private val textPage: PdfTextPageKt) : ReaderTextPage {
|
||||
class PdfTextPageWrapper(
|
||||
private val textPage: PdfTextPageKt,
|
||||
private val ownerClosed: AtomicBoolean = AtomicBoolean(false),
|
||||
private val pageClosed: AtomicBoolean = AtomicBoolean(false)
|
||||
) : ReaderTextPage {
|
||||
private val isClosed = AtomicBoolean(false)
|
||||
private fun isUnavailable(): Boolean = isClosed.get() || ownerClosed.get() || pageClosed.get()
|
||||
|
||||
override suspend fun textPageCountChars() = PdfiumEngineProvider.withPdfium {
|
||||
if (isClosed.get()) 0 else textPage.textPageCountChars()
|
||||
if (isUnavailable()) 0 else textPage.textPageCountChars()
|
||||
}
|
||||
|
||||
override suspend fun textPageGetText(startIndex: Int, count: Int) = PdfiumEngineProvider.withPdfium {
|
||||
if (isClosed.get()) null else textPage.textPageGetText(startIndex, count)
|
||||
if (isUnavailable()) null else textPage.textPageGetText(startIndex, count)
|
||||
}
|
||||
|
||||
override suspend fun textPageGetRectsForRanges(ranges: IntArray) = PdfiumEngineProvider.withPdfium {
|
||||
if (isClosed.get()) null else textPage.textPageGetRectsForRanges(ranges)?.map { ReaderTextRect(it.rect) }
|
||||
if (isUnavailable()) null else textPage.textPageGetRectsForRanges(ranges)?.map { ReaderTextRect(it.rect) }
|
||||
}
|
||||
|
||||
override suspend fun textPageGetCharIndexAtPos(x: Double, y: Double, xTolerance: Double, yTolerance: Double) = PdfiumEngineProvider.withPdfium {
|
||||
if (isClosed.get()) -1 else textPage.textPageGetCharIndexAtPos(x, y, xTolerance, yTolerance)
|
||||
if (isUnavailable()) -1 else textPage.textPageGetCharIndexAtPos(x, y, xTolerance, yTolerance)
|
||||
}
|
||||
|
||||
override suspend fun textPageGetCharBox(index: Int) = PdfiumEngineProvider.withPdfium {
|
||||
if (isClosed.get()) null else textPage.textPageGetCharBox(index)
|
||||
if (isUnavailable()) null else textPage.textPageGetCharBox(index)
|
||||
}
|
||||
|
||||
override suspend fun textPageGetUnicode(index: Int): Int {
|
||||
return PdfiumEngineProvider.withPdfium {
|
||||
if (isClosed.get()) 0 else textPage.textPageGetUnicode(index).code
|
||||
if (isUnavailable()) 0 else textPage.textPageGetUnicode(index).code
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun loadWebLink(): ReaderWebLinks? {
|
||||
val links = PdfiumEngineProvider.withPdfium {
|
||||
if (isClosed.get()) null else textPage.loadWebLink()
|
||||
if (isUnavailable()) null else textPage.loadWebLink()
|
||||
} ?: return null
|
||||
return object : ReaderWebLinks {
|
||||
private val isClosed = AtomicBoolean(false)
|
||||
private fun isUnavailable(): Boolean = isClosed.get() || ownerClosed.get() || pageClosed.get()
|
||||
|
||||
override suspend fun countWebLinks() = PdfiumEngineProvider.withPdfium {
|
||||
if (isClosed.get()) 0 else links.countWebLinks()
|
||||
if (isUnavailable()) 0 else links.countWebLinks()
|
||||
}
|
||||
|
||||
override suspend fun getURL(linkIndex: Int, maxLength: Int) = PdfiumEngineProvider.withPdfium {
|
||||
if (isClosed.get()) null else links.getURL(linkIndex, maxLength)
|
||||
if (isUnavailable()) null else links.getURL(linkIndex, maxLength)
|
||||
}
|
||||
|
||||
override suspend fun countRects(linkIndex: Int) = PdfiumEngineProvider.withPdfium {
|
||||
if (isClosed.get()) 0 else links.countRects(linkIndex)
|
||||
if (isUnavailable()) 0 else links.countRects(linkIndex)
|
||||
}
|
||||
|
||||
override suspend fun getRect(linkIndex: Int, rectIndex: Int) = PdfiumEngineProvider.withPdfium {
|
||||
if (isClosed.get()) RectF() else links.getRect(linkIndex, rectIndex)
|
||||
if (isUnavailable()) RectF() else links.getRect(linkIndex, rectIndex)
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
if (!isClosed.compareAndSet(false, true)) return
|
||||
if (ownerClosed.get() || pageClosed.get()) return
|
||||
PdfiumEngineProvider.withPdfiumBlocking {
|
||||
closePdfiumResource("PdfWebLinksWrapper") { links.close() }
|
||||
}
|
||||
|
|
@ -299,6 +527,7 @@ class PdfTextPageWrapper(private val textPage: PdfTextPageKt) : ReaderTextPage {
|
|||
}
|
||||
override fun close() {
|
||||
if (!isClosed.compareAndSet(false, true)) return
|
||||
if (ownerClosed.get() || pageClosed.get()) return
|
||||
PdfiumEngineProvider.withPdfiumBlocking {
|
||||
closePdfiumResource("PdfTextPageWrapper") { textPage.close() }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@
|
|||
package com.aryan.reader.pdf.data
|
||||
|
||||
import android.content.Context
|
||||
import com.aryan.reader.pdf.PDF_BLANK_PAGE_PERSISTENCE_TAG
|
||||
import com.aryan.reader.pdf.pdfLayoutDebugSummary
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONArray
|
||||
|
|
@ -41,6 +43,12 @@ class PageLayoutRepository(private val context: Context) {
|
|||
}
|
||||
|
||||
suspend fun saveLayout(bookId: String, pages: List<VirtualPage>) = withContext(Dispatchers.IO) {
|
||||
val file = getFile(bookId)
|
||||
Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i(
|
||||
"repo.saveLayout.start bookId=$bookId file=${file.absolutePath} " +
|
||||
"beforeExists=${file.exists()} beforeBytes=${if (file.exists()) file.length() else 0L} " +
|
||||
"beforeMtime=${if (file.exists()) file.lastModified() else 0L} layout=${pages.pdfLayoutDebugSummary()}"
|
||||
)
|
||||
val jsonArray = JSONArray()
|
||||
pages.forEach { page ->
|
||||
val obj = JSONObject()
|
||||
|
|
@ -59,13 +67,27 @@ class PageLayoutRepository(private val context: Context) {
|
|||
}
|
||||
jsonArray.put(obj)
|
||||
}
|
||||
getFile(bookId).writeText(jsonArray.toString())
|
||||
file.writeText(jsonArray.toString())
|
||||
Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i(
|
||||
"repo.saveLayout.done bookId=$bookId file=${file.absolutePath} " +
|
||||
"afterExists=${file.exists()} afterBytes=${file.length()} afterMtime=${file.lastModified()} " +
|
||||
"layout=${pages.pdfLayoutDebugSummary()}"
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun loadLayout(bookId: String, totalPdfPages: Int): List<VirtualPage> = withContext(Dispatchers.IO) {
|
||||
val file = getFile(bookId)
|
||||
Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i(
|
||||
"repo.loadLayout.start bookId=$bookId totalPdfPages=$totalPdfPages file=${file.absolutePath} " +
|
||||
"exists=${file.exists()} bytes=${if (file.exists()) file.length() else 0L} " +
|
||||
"mtime=${if (file.exists()) file.lastModified() else 0L}"
|
||||
)
|
||||
if (!file.exists()) {
|
||||
return@withContext (0 until totalPdfPages).map { VirtualPage.PdfPage(it) }
|
||||
val fallback = (0 until totalPdfPages).map { VirtualPage.PdfPage(it) }
|
||||
Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).w(
|
||||
"repo.loadLayout.missing bookId=$bookId returningDefault=${fallback.pdfLayoutDebugSummary()}"
|
||||
)
|
||||
return@withContext fallback
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
@ -84,18 +106,32 @@ class PageLayoutRepository(private val context: Context) {
|
|||
list.add(VirtualPage.BlankPage(obj.getString("id"), w, h, isManual))
|
||||
}
|
||||
}
|
||||
Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i(
|
||||
"repo.loadLayout.parsed bookId=$bookId layout=${list.pdfLayoutDebugSummary()}"
|
||||
)
|
||||
list
|
||||
} catch (_: Exception) {
|
||||
(0 until totalPdfPages).map { VirtualPage.PdfPage(it) }
|
||||
} catch (e: Exception) {
|
||||
val fallback = (0 until totalPdfPages).map { VirtualPage.PdfPage(it) }
|
||||
Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).e(
|
||||
e,
|
||||
"repo.loadLayout.failed bookId=$bookId returningDefault=${fallback.pdfLayoutDebugSummary()}"
|
||||
)
|
||||
fallback
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getLayoutOrNull(bookId: String): List<VirtualPage>? = withContext(Dispatchers.IO) {
|
||||
val file = getFile(bookId)
|
||||
Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i(
|
||||
"repo.getLayoutOrNull.start bookId=$bookId file=${file.absolutePath} " +
|
||||
"exists=${file.exists()} bytes=${if (file.exists()) file.length() else 0L} " +
|
||||
"mtime=${if (file.exists()) file.lastModified() else 0L}"
|
||||
)
|
||||
Timber.tag("PdfExportDebug").d("PageLayoutRepo: Looking for layout at ${file.absolutePath}")
|
||||
Timber.tag("PdfExportDebug").d("PageLayoutRepo: File exists: ${file.exists()}")
|
||||
|
||||
if (!file.exists()) {
|
||||
Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).w("repo.getLayoutOrNull.missing bookId=$bookId")
|
||||
Timber.tag("PdfExportDebug").w("PageLayoutRepo: No layout file for book $bookId")
|
||||
return@withContext null
|
||||
}
|
||||
|
|
@ -114,14 +150,19 @@ class PageLayoutRepository(private val context: Context) {
|
|||
} else {
|
||||
val w = obj.optInt("w", 595)
|
||||
val h = obj.optInt("h", 842)
|
||||
list.add(VirtualPage.BlankPage(obj.getString("id"), w, h))
|
||||
val isManual = obj.optBoolean("manual", false)
|
||||
list.add(VirtualPage.BlankPage(obj.getString("id"), w, h, isManual))
|
||||
}
|
||||
}
|
||||
Timber.tag("PdfExportDebug").i("PageLayoutRepo: Parsed ${list.size} virtual pages (${
|
||||
list.count { it is VirtualPage.PdfPage }
|
||||
} PDF, ${list.count { it is VirtualPage.BlankPage }} blank)")
|
||||
Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i(
|
||||
"repo.getLayoutOrNull.parsed bookId=$bookId layout=${list.pdfLayoutDebugSummary()}"
|
||||
)
|
||||
list
|
||||
} catch (e: Exception) {
|
||||
Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).e(e, "repo.getLayoutOrNull.failed bookId=$bookId")
|
||||
Timber.tag("PdfExportDebug").e(e, "PageLayoutRepo: Failed to parse layout")
|
||||
null
|
||||
}
|
||||
|
|
@ -135,4 +176,4 @@ class PageLayoutRepository(private val context: Context) {
|
|||
Timber.tag("PdfExportDebug").v("PageLayoutRepo: Layout file path: ${file.absolutePath}")
|
||||
return file
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import com.aryan.reader.pdf.InkType
|
|||
import com.aryan.reader.pdf.PdfHighlightColor
|
||||
import com.aryan.reader.pdf.PdfPoint
|
||||
import com.aryan.reader.pdf.PdfUserHighlight
|
||||
import com.aryan.reader.shared.pdf.SharedPdfAnnotationComment
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.util.Locale
|
||||
|
|
@ -237,6 +238,10 @@ object HighlightSerializer {
|
|||
if (!h.note.isNullOrBlank()) {
|
||||
obj.put("note", h.note)
|
||||
}
|
||||
val commentsArray = h.comments.toJsonArray()
|
||||
if (commentsArray.length() > 0) {
|
||||
obj.put("comments", commentsArray)
|
||||
}
|
||||
|
||||
val boundsArray = JSONArray()
|
||||
h.bounds.forEach { r ->
|
||||
|
|
@ -279,7 +284,8 @@ object HighlightSerializer {
|
|||
color = try { PdfHighlightColor.valueOf(obj.getString("color")) } catch(_: Exception) { PdfHighlightColor.YELLOW },
|
||||
text = obj.optString("text", ""),
|
||||
range = Pair(obj.optInt("rangeStart", 0), obj.optInt("rangeEnd", 0)),
|
||||
note = obj.optString("note").takeIf { !it.isNullOrBlank() }
|
||||
note = obj.optString("note").takeIf { !it.isNullOrBlank() },
|
||||
comments = obj.optJSONArray("comments").toSharedPdfAnnotationComments()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
@ -288,4 +294,50 @@ object HighlightSerializer {
|
|||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun List<SharedPdfAnnotationComment>.toJsonArray(): JSONArray {
|
||||
val array = JSONArray()
|
||||
forEach { comment ->
|
||||
val contents = comment.contents.trim()
|
||||
if (contents.isBlank()) return@forEach
|
||||
val obj = JSONObject()
|
||||
obj.put("id", comment.id)
|
||||
comment.parentId?.takeIf { it.isNotBlank() }?.let { obj.put("parentId", it) }
|
||||
comment.author.takeIf { it.isNotBlank() }?.let { obj.put("author", it) }
|
||||
obj.put("contents", contents)
|
||||
if (comment.createdAt > 0L) obj.put("createdAt", comment.createdAt)
|
||||
val modifiedAt = comment.modifiedAt.takeIf { it > 0L } ?: comment.createdAt
|
||||
if (modifiedAt > 0L) obj.put("modifiedAt", modifiedAt)
|
||||
array.put(obj)
|
||||
}
|
||||
return array
|
||||
}
|
||||
|
||||
private fun JSONArray?.toSharedPdfAnnotationComments(): List<SharedPdfAnnotationComment> {
|
||||
if (this == null) return emptyList()
|
||||
val comments = mutableListOf<SharedPdfAnnotationComment>()
|
||||
for (index in 0 until length()) {
|
||||
val obj = optJSONObject(index) ?: continue
|
||||
val contents = obj.optString("contents")
|
||||
.ifBlank { obj.optString("text") }
|
||||
.ifBlank { obj.optString("comment") }
|
||||
.trim()
|
||||
if (contents.isBlank()) continue
|
||||
val createdAt = obj.optLong("createdAt", obj.optLong("created", 0L))
|
||||
comments += SharedPdfAnnotationComment(
|
||||
id = obj.optString("id").takeIf { it.isNotBlank() } ?: UUID.randomUUID().toString(),
|
||||
parentId = obj.optString("parentId")
|
||||
.ifBlank { obj.optString("inReplyTo") }
|
||||
.takeIf { it.isNotBlank() },
|
||||
author = obj.optString("author").trim(),
|
||||
contents = contents,
|
||||
createdAt = createdAt,
|
||||
modifiedAt = obj.optLong(
|
||||
"modifiedAt",
|
||||
obj.optLong("modified", createdAt)
|
||||
)
|
||||
)
|
||||
}
|
||||
return comments
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -372,43 +372,50 @@ class PdfTextRepository(context: Context) {
|
|||
): List<RectF> {
|
||||
return withContext(Dispatchers.IO) {
|
||||
val rects = mutableListOf<RectF>()
|
||||
var bitmap: android.graphics.Bitmap? = null
|
||||
var targetWidth = 0
|
||||
var targetHeight = 0
|
||||
try {
|
||||
document.openPage(pageIndex)?.use { page ->
|
||||
val targetWidth = 1080
|
||||
val ptrWidth = page.getPageWidthPoint()
|
||||
val ptrHeight = page.getPageHeightPoint()
|
||||
PdfiumEngineProvider.withPdfium {
|
||||
document.openPage(pageIndex)?.use { page ->
|
||||
targetWidth = 1080
|
||||
val ptrWidth = page.getPageWidthPoint()
|
||||
val ptrHeight = page.getPageHeightPoint()
|
||||
|
||||
if (ptrWidth <= 0 || ptrHeight <= 0) return@use
|
||||
if (ptrWidth <= 0 || ptrHeight <= 0) return@use
|
||||
|
||||
val aspectRatio = ptrWidth.toFloat() / ptrHeight.toFloat()
|
||||
val targetHeight = (targetWidth / aspectRatio).toInt().coerceAtLeast(1)
|
||||
val aspectRatio = ptrWidth.toFloat() / ptrHeight.toFloat()
|
||||
targetHeight = (targetWidth / aspectRatio).toInt().coerceAtLeast(1)
|
||||
|
||||
val bitmap = createBitmap(targetWidth, targetHeight)
|
||||
page.renderPageBitmap(bitmap, 0, 0, targetWidth, targetHeight, false)
|
||||
bitmap = createBitmap(targetWidth, targetHeight)
|
||||
page.renderPageBitmap(bitmap!!, 0, 0, targetWidth, targetHeight, false)
|
||||
}
|
||||
}
|
||||
|
||||
val visionText = OcrHelper.extractTextFromBitmap(bitmap, onModelDownloading)
|
||||
val renderedBitmap = bitmap ?: return@withContext rects
|
||||
val visionText = OcrHelper.extractTextFromBitmap(renderedBitmap, onModelDownloading)
|
||||
|
||||
visionText?.textBlocks?.forEach { block ->
|
||||
block.lines.forEach { line ->
|
||||
line.elements.forEach { element ->
|
||||
if (element.text.contains(query, ignoreCase = true)) {
|
||||
element.boundingBox?.let { box ->
|
||||
val normalized = RectF(
|
||||
box.left.toFloat() / targetWidth,
|
||||
box.top.toFloat() / targetHeight,
|
||||
box.right.toFloat() / targetWidth,
|
||||
box.bottom.toFloat() / targetHeight
|
||||
)
|
||||
rects.add(normalized)
|
||||
}
|
||||
visionText?.textBlocks?.forEach { block ->
|
||||
block.lines.forEach { line ->
|
||||
line.elements.forEach { element ->
|
||||
if (element.text.contains(query, ignoreCase = true)) {
|
||||
element.boundingBox?.let { box ->
|
||||
val normalized = RectF(
|
||||
box.left.toFloat() / targetWidth,
|
||||
box.top.toFloat() / targetHeight,
|
||||
box.right.toFloat() / targetWidth,
|
||||
box.bottom.toFloat() / targetHeight
|
||||
)
|
||||
rects.add(normalized)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
bitmap.recycle()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.tag(TAG).e(e, "Failed to get OCR rects for page $pageIndex")
|
||||
} finally {
|
||||
bitmap?.recycle()
|
||||
}
|
||||
rects
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue