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:
Aryan 2026-05-20 22:14:01 +05:30 committed by GitHub
parent dc5196526f
commit 9510293ac3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
245 changed files with 37538 additions and 12460 deletions

View file

@ -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

View file

@ -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)

View 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

View file

@ -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) {
}
}
}
}
}

View file

@ -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
}

View file

@ -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

View file

@ -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() }
}
}

View file

@ -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,

View file

@ -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")

View file

@ -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))
}
}

View 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
}
}

View file

@ -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

View 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 = "]")}"
}

View 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"
}

View 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())
}
}
}

View file

@ -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) }

View file

@ -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),

View file

@ -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)
)
}
}
}
}

View file

@ -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
}
}
}
}

View file

@ -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),

View 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)})"
}

View file

@ -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

View 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
}

View file

@ -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() ||

View file

@ -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)
}
}

View file

@ -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() }
}

View file

@ -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
}
}
}

View 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
}
}

View file

@ -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
}