Pdf reflow upgrade (#63)
* Replaced PDF-to-Markdown reflow with an enhanced PDF-to-HTML generator. Specific changes include: - Replaced `PdfToMarkdownGenerator` and `PdfReflowGenerator` with `PdfToHtmlGenerator`. - Added native bridge methods in `NativePdfiumBridge.kt` and `pdfium_bridge.cpp` to extract character bounding boxes, page objects, and image pixels. - Implemented vertical merging of text and images in `PdfToHtmlGenerator` to maintain document layout. - Added logic to detect and filter repeating headers and footers across PDF pages. - Updated `ReflowWorker` to generate `.html` files instead of `.md` files and updated `FileType` handling. - Simplified `SingleFileImporter` by removing dependencies on the legacy Markdown generator. * Updated PDF to HTML generation to include page breaks and split HTML files into individual chapters based on page markers. * Added junk character filtering and normalization to PDF text extraction * - Added `allRecentFiles` to `ReaderScreenState` to track all files, including reflowed versions. - Updated `recentFiles` in `MainViewModel` to filter out reflowed files (`_reflow`) from the main library view. - Implemented `deleteBookPermanently` in `MainViewModel` to handle book deletion and cache cleanup. - Added a "Delete Text View" option to the `EpubReader` controls for reflowed files. - Improved reflow file detection in `PdfViewerScreen` by checking against `allRecentFiles`. * Implemented a centralized data-saving mechanism in `PdfViewerScreen` using a debounced `saveAllData` function. This refactor consolidates the saving of annotations, text boxes, highlights, bookmarks, and scroll positions, adding lifecycle-aware triggers and a mutex to ensure data integrity during pauses or document navigation. * Optimized PDF selection and annotation performance by offloading heavy operations to background threads and improving UI responsiveness. - Moved PDF page/text opening, character range calculations, and text extraction to `Dispatchers.IO`. - Wrapped UI updates in `updateSelectionVisuals` and selection logic with `withContext(Dispatchers.Main)`. - Implemented a more efficient `Popup`-based magnifier to replace manual offsets and transformations. - Updated `PdfSelectionMenuPopup` properties to prevent focus and click-outside dismissal conflicts.
This commit is contained in:
parent
9a95b4afcd
commit
843a77d0ef
13 changed files with 1270 additions and 895 deletions
|
|
@ -18,7 +18,9 @@
|
|||
* mail: epistemereader@gmail.com
|
||||
*/
|
||||
// PdfViewerScreen.kt
|
||||
@file:Suppress("COMPOSE_APPLIER_CALL_MISMATCH", "Unused", "UnusedVariable")
|
||||
@file:Suppress("COMPOSE_APPLIER_CALL_MISMATCH", "Unused", "UnusedVariable",
|
||||
"SimplifyBooleanWithConstants"
|
||||
)
|
||||
|
||||
package com.aryan.reader.pdf
|
||||
|
||||
|
|
@ -27,6 +29,11 @@ import android.annotation.SuppressLint
|
|||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.RectF
|
||||
import android.net.Uri
|
||||
|
|
@ -221,6 +228,7 @@ import androidx.core.graphics.createBitmap
|
|||
import androidx.core.view.WindowCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.WindowInsetsControllerCompat
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.paging.LoadState
|
||||
import androidx.paging.compose.LazyPagingItems
|
||||
|
|
@ -740,9 +748,9 @@ fun PdfViewerScreen(
|
|||
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
val reflowBookId = remember(bookId) { "${bookId}_reflow" }
|
||||
val hasReflowFile by remember(uiState.recentFiles, reflowBookId) {
|
||||
val hasReflowFile by remember(uiState.allRecentFiles, reflowBookId) {
|
||||
derivedStateOf {
|
||||
uiState.recentFiles.any { it.bookId == reflowBookId && !it.isDeleted }
|
||||
uiState.allRecentFiles.any { it.bookId == reflowBookId && !it.isDeleted }
|
||||
}
|
||||
}
|
||||
val originalFileName by remember(uiState.recentFiles, pdfUri) {
|
||||
|
|
@ -1029,18 +1037,155 @@ fun PdfViewerScreen(
|
|||
|
||||
var areAnnotationsLoaded by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(allAnnotations) {
|
||||
if (areAnnotationsLoaded && currentBookId != null) {
|
||||
delay(1000)
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
Timber.d("Auto-saving annotations locally for book $currentBookId")
|
||||
annotationRepository.saveAnnotations(currentBookId!!, allAnnotations)
|
||||
val richTextRepository = remember(context) { PdfRichTextRepository(context) }
|
||||
val richTextController = remember(currentBookId) {
|
||||
if (currentBookId != null) RichTextController(
|
||||
richTextRepository,
|
||||
coroutineScope,
|
||||
currentBookId!!
|
||||
)
|
||||
else null
|
||||
}
|
||||
var pdfDocument by remember { mutableStateOf<PdfDocumentKt?>(null) }
|
||||
var pfdState by remember { mutableStateOf<ParcelFileDescriptor?>(null) }
|
||||
var totalPages by remember { mutableIntStateOf(0) }
|
||||
var currentPageScale by remember { mutableFloatStateOf(1f) }
|
||||
val textBoxes = remember { mutableStateListOf<PdfTextBox>() }
|
||||
var selectedTextBoxId by remember { mutableStateOf<String?>(null) }
|
||||
val userHighlights = remember { mutableStateListOf<PdfUserHighlight>() }
|
||||
val drawingState = remember { PdfDrawingState() }
|
||||
val pdfiumCore = remember(context) { PdfiumCoreKt(Dispatchers.Default) }
|
||||
val verticalReaderState = rememberVerticalPdfReaderState()
|
||||
var virtualPages by remember { mutableStateOf<List<VirtualPage>>(emptyList()) }
|
||||
val totalDisplayPages by remember(virtualPages, totalPages) {
|
||||
derivedStateOf { if (virtualPages.isNotEmpty()) virtualPages.size else totalPages }
|
||||
}
|
||||
val pagerState = rememberPagerState(initialPage = 0, pageCount = { totalDisplayPages })
|
||||
val currentPage by remember {
|
||||
derivedStateOf {
|
||||
when (displayMode) {
|
||||
DisplayMode.PAGINATION -> pagerState.currentPage
|
||||
DisplayMode.VERTICAL_SCROLL -> verticalReaderState.currentPage
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val drawingState = remember { PdfDrawingState() }
|
||||
val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current
|
||||
val saveMutex = remember { Mutex() }
|
||||
|
||||
var initialScrollDone by remember { mutableStateOf(false) }
|
||||
var isDocumentReady by remember { mutableStateOf(false) }
|
||||
|
||||
val lastSavedHashes = remember(currentBookId) { IntArray(5) { 0 } }
|
||||
|
||||
val currentAnnotations by rememberUpdatedState(allAnnotations)
|
||||
val currentTextBoxes by rememberUpdatedState(textBoxes.toList())
|
||||
val currentHighlights by rememberUpdatedState(userHighlights.toList())
|
||||
val currentBookmarks by rememberUpdatedState(bookmarks)
|
||||
val currentTotalPages by rememberUpdatedState(totalDisplayPages)
|
||||
val currentPageState by rememberUpdatedState(currentPage)
|
||||
|
||||
val saveAllData = remember(currentBookId, annotationRepository, textBoxRepository, highlightRepository) {
|
||||
{ force: Boolean ->
|
||||
coroutineScope.launch {
|
||||
val bookId = currentBookId ?: return@launch
|
||||
val annots = currentAnnotations
|
||||
val boxes = currentTextBoxes
|
||||
val highlights = currentHighlights
|
||||
val bms = currentBookmarks
|
||||
val page = currentPageState
|
||||
val totalPgs = currentTotalPages
|
||||
|
||||
val annotsHash = annots.hashCode()
|
||||
val boxesHash = boxes.hashCode()
|
||||
val highlightsHash = highlights.hashCode()
|
||||
val bmsHash = bms.hashCode()
|
||||
|
||||
// Protect the lock and I/O execution with NonCancellable
|
||||
withContext(NonCancellable) {
|
||||
saveMutex.withLock {
|
||||
withContext(Dispatchers.IO) {
|
||||
var didSave = false
|
||||
|
||||
if (force || annotsHash != lastSavedHashes[0]) {
|
||||
annotationRepository.saveAnnotations(bookId, annots)
|
||||
lastSavedHashes[0] = annotsHash
|
||||
didSave = true
|
||||
}
|
||||
if (force || boxesHash != lastSavedHashes[1]) {
|
||||
textBoxRepository.saveTextBoxes(bookId, boxes)
|
||||
lastSavedHashes[1] = boxesHash
|
||||
didSave = true
|
||||
}
|
||||
if (force || highlightsHash != lastSavedHashes[2]) {
|
||||
highlightRepository.saveHighlights(bookId, highlights)
|
||||
lastSavedHashes[2] = highlightsHash
|
||||
didSave = true
|
||||
}
|
||||
if (force || bmsHash != lastSavedHashes[3]) {
|
||||
val objectList = bms.map { bookmark ->
|
||||
JSONObject().apply {
|
||||
put("pageIndex", bookmark.pageIndex)
|
||||
put("title", bookmark.title)
|
||||
put("totalPages", bookmark.totalPages)
|
||||
}
|
||||
}
|
||||
val bookmarksJson = JSONArray(objectList).toString()
|
||||
withContext(Dispatchers.Main) {
|
||||
onBookmarksChanged(bookmarksJson)
|
||||
}
|
||||
lastSavedHashes[3] = bmsHash
|
||||
didSave = true
|
||||
}
|
||||
if (force || page != lastSavedHashes[4]) {
|
||||
if (totalPgs > 0) {
|
||||
withContext(Dispatchers.Main) {
|
||||
onSavePosition(page, totalPgs)
|
||||
}
|
||||
}
|
||||
lastSavedHashes[4] = page
|
||||
}
|
||||
|
||||
if (didSave) {
|
||||
Timber.tag("PdfSavePerf").d("Saved data for book $bookId")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(lifecycleOwner) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_PAUSE || event == Lifecycle.Event.ON_STOP) {
|
||||
Timber.tag("PdfSavePerf").i("Lifecycle $event triggered, forcing save.")
|
||||
coroutineScope.launch {
|
||||
if (richTextController != null) {
|
||||
withContext(NonCancellable) { richTextController.saveImmediate() }
|
||||
}
|
||||
saveAllData(true).join()
|
||||
}
|
||||
}
|
||||
}
|
||||
lifecycleOwner.lifecycle.addObserver(observer)
|
||||
onDispose {
|
||||
lifecycleOwner.lifecycle.removeObserver(observer)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(
|
||||
allAnnotations,
|
||||
textBoxes.toList(),
|
||||
userHighlights.toList(),
|
||||
bookmarks,
|
||||
currentPage
|
||||
) {
|
||||
if (areAnnotationsLoaded && currentBookId != null && initialScrollDone) {
|
||||
delay(2000) // Debounce period
|
||||
saveAllData(false)
|
||||
}
|
||||
}
|
||||
|
||||
val allAnnotationsProvider = remember { { allAnnotations } }
|
||||
|
||||
|
|
@ -1049,19 +1194,6 @@ fun PdfViewerScreen(
|
|||
Timber.d("PdfViewerScreen init: Loaded ${bookmarks.size} bookmarks initially.")
|
||||
}
|
||||
|
||||
LaunchedEffect(bookmarks) {
|
||||
val objectList = bookmarks.map { bookmark ->
|
||||
JSONObject().apply {
|
||||
put("pageIndex", bookmark.pageIndex)
|
||||
put("title", bookmark.title)
|
||||
put("totalPages", bookmark.totalPages)
|
||||
}
|
||||
}
|
||||
val bookmarksJson = JSONArray(objectList).toString()
|
||||
Timber.d("Bookmarks changed. Firing onBookmarksChanged with JSON: $bookmarksJson")
|
||||
onBookmarksChanged(bookmarksJson)
|
||||
}
|
||||
|
||||
var flatTableOfContents by remember { mutableStateOf<List<TocEntry>>(emptyList()) }
|
||||
var showDictionaryUpsellDialog by remember { mutableStateOf(false) }
|
||||
var showSummarizationUpsellDialog by remember { mutableStateOf(false) }
|
||||
|
|
@ -1080,17 +1212,8 @@ fun PdfViewerScreen(
|
|||
var errorMessage by remember { mutableStateOf<String?>(null) }
|
||||
var isLoadingDocument by remember { mutableStateOf(true) }
|
||||
|
||||
var pdfDocument by remember { mutableStateOf<PdfDocumentKt?>(null) }
|
||||
var pfdState by remember { mutableStateOf<ParcelFileDescriptor?>(null) }
|
||||
var totalPages by remember { mutableIntStateOf(0) }
|
||||
var currentPageScale by remember { mutableFloatStateOf(1f) }
|
||||
|
||||
var initialScrollDone by remember { mutableStateOf(false) }
|
||||
var isDocumentReady by remember { mutableStateOf(false) }
|
||||
|
||||
var selectionClearTrigger by remember { mutableLongStateOf(0L) }
|
||||
|
||||
var virtualPages by remember { mutableStateOf<List<VirtualPage>>(emptyList()) }
|
||||
val displayPageRatios by remember(pageAspectRatios, virtualPages) {
|
||||
derivedStateOf {
|
||||
if (virtualPages.isEmpty()) {
|
||||
|
|
@ -1108,16 +1231,6 @@ fun PdfViewerScreen(
|
|||
}
|
||||
}
|
||||
|
||||
val richTextRepository = remember(context) { PdfRichTextRepository(context) }
|
||||
val richTextController = remember(currentBookId) {
|
||||
if (currentBookId != null) RichTextController(
|
||||
richTextRepository,
|
||||
coroutineScope,
|
||||
currentBookId!!
|
||||
)
|
||||
else null
|
||||
}
|
||||
|
||||
LaunchedEffect(richTextController, toolSettings.textStyle) {
|
||||
richTextController?.let { controller ->
|
||||
val config = toolSettings.textStyle
|
||||
|
|
@ -1142,13 +1255,6 @@ fun PdfViewerScreen(
|
|||
}
|
||||
}
|
||||
|
||||
val pdfiumCore = remember(context) { PdfiumCoreKt(Dispatchers.Default) }
|
||||
val verticalReaderState = rememberVerticalPdfReaderState()
|
||||
val totalDisplayPages by remember(virtualPages, totalPages) {
|
||||
derivedStateOf { if (virtualPages.isNotEmpty()) virtualPages.size else totalPages }
|
||||
}
|
||||
val pagerState = rememberPagerState(initialPage = 0, pageCount = { totalDisplayPages })
|
||||
|
||||
LaunchedEffect(currentBookId) {
|
||||
if (currentBookId != null) richTextRepository.load(currentBookId!!)
|
||||
}
|
||||
|
|
@ -1170,21 +1276,8 @@ fun PdfViewerScreen(
|
|||
}
|
||||
}
|
||||
|
||||
val currentPage by remember {
|
||||
derivedStateOf {
|
||||
when (displayMode) {
|
||||
DisplayMode.PAGINATION -> pagerState.currentPage
|
||||
DisplayMode.VERTICAL_SCROLL -> verticalReaderState.currentPage
|
||||
}
|
||||
}
|
||||
}
|
||||
Timber.d("Derived currentPage recomposed. New value: $currentPage (Mode: $displayMode)")
|
||||
|
||||
val textBoxes = remember { mutableStateListOf<PdfTextBox>() }
|
||||
var selectedTextBoxId by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
val userHighlights = remember { mutableStateListOf<PdfUserHighlight>() }
|
||||
|
||||
val onHighlightAdd = remember(pdfDocument, currentBookId) {
|
||||
{ pageIndex: Int, range: Pair<Int, Int>, text: String, color: PdfHighlightColor ->
|
||||
Timber.tag("PdfExportDebug").i("onHighlightAdd: Adding persistent highlight. Page: $pageIndex, Text: ${text.take(20)}...")
|
||||
|
|
@ -1695,16 +1788,6 @@ fun PdfViewerScreen(
|
|||
Timber.d("Pager state changed: pagerState.currentPage is now ${pagerState.currentPage}")
|
||||
}
|
||||
|
||||
LaunchedEffect(currentPage, totalPages) {
|
||||
if (totalPages > 0 && initialScrollDone) {
|
||||
delay(500L)
|
||||
Timber.d(
|
||||
"Debounced save: Calling onSavePosition(page=$currentPage, totalPages=$totalPages)"
|
||||
)
|
||||
onSavePosition(currentPage, totalPages)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(displayMode) {
|
||||
coroutineScope.launch {
|
||||
if (displayMode == DisplayMode.VERTICAL_SCROLL) {
|
||||
|
|
@ -1861,26 +1944,6 @@ fun PdfViewerScreen(
|
|||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(textBoxes.toList()) {
|
||||
if (currentBookId != null) {
|
||||
delay(1000)
|
||||
withContext(Dispatchers.IO) {
|
||||
Timber.d("Auto-saving text boxes locally for book $currentBookId")
|
||||
textBoxRepository.saveTextBoxes(currentBookId!!, textBoxes.toList())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(userHighlights.toList()) {
|
||||
if (currentBookId != null) {
|
||||
delay(1000)
|
||||
withContext(Dispatchers.IO) {
|
||||
Timber.d("Auto-saving highlights locally for book $currentBookId")
|
||||
highlightRepository.saveHighlights(currentBookId!!, userHighlights.toList())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var pendingSaveMode by remember { mutableStateOf<SaveMode?>(null) }
|
||||
|
||||
val saveLauncher = rememberLauncherForActivityResult(
|
||||
|
|
@ -1970,33 +2033,15 @@ fun PdfViewerScreen(
|
|||
ttsController.stop()
|
||||
|
||||
coroutineScope.launch {
|
||||
withContext(NonCancellable) {
|
||||
if (richTextController != null) {
|
||||
if (richTextController != null) {
|
||||
withContext(NonCancellable) {
|
||||
Timber.tag("RichTextFlow").d("Forcing RichTextController immediate sync and save...")
|
||||
richTextController.saveImmediate()
|
||||
}
|
||||
|
||||
if (totalDisplayPages > 0) {
|
||||
onSavePosition(currentPage, totalDisplayPages)
|
||||
|
||||
if (currentBookId != null) {
|
||||
annotationRepository.saveAnnotations(currentBookId!!, allAnnotations)
|
||||
textBoxRepository.saveTextBoxes(currentBookId!!, textBoxes.toList())
|
||||
highlightRepository.saveHighlights(currentBookId!!, userHighlights.toList())
|
||||
}
|
||||
|
||||
val objectList = bookmarks.map { bookmark ->
|
||||
JSONObject().apply {
|
||||
put("pageIndex", bookmark.pageIndex)
|
||||
put("title", bookmark.title)
|
||||
put("totalPages", bookmark.totalPages)
|
||||
}
|
||||
}
|
||||
val bookmarksJson = JSONArray(objectList).toString()
|
||||
onBookmarksChanged(bookmarksJson)
|
||||
}
|
||||
}
|
||||
|
||||
saveAllData(true).join()
|
||||
|
||||
Timber.tag("AnnotationSync").d("Save complete. Navigating back.")
|
||||
onNavigateBack()
|
||||
}
|
||||
|
|
@ -4954,18 +4999,33 @@ fun PdfViewerScreen(
|
|||
enabled = pdfDocument != null && !isReflowingThisBook,
|
||||
onClick = {
|
||||
showMoreMenu = false
|
||||
if (hasReflowFile) {
|
||||
val item = uiState.recentFiles.find { it.bookId == reflowBookId }
|
||||
if (item != null) {
|
||||
viewModel.switchToFileSeamlessly(item, currentPage)
|
||||
|
||||
coroutineScope.launch {
|
||||
if (richTextController != null) {
|
||||
withContext(NonCancellable) { richTextController.saveImmediate() }
|
||||
}
|
||||
saveAllData(true).join()
|
||||
|
||||
if (hasReflowFile) {
|
||||
val item = uiState.allRecentFiles.find { it.bookId == reflowBookId }
|
||||
if (item != null) {
|
||||
viewModel.switchToFileSeamlessly(item, currentPage)
|
||||
} else {
|
||||
viewModel.generateAndImportReflowFile(
|
||||
pdfBookId = bookId,
|
||||
pdfUri = pdfUri,
|
||||
originalTitle = originalFileName,
|
||||
autoOpenPage = currentPage
|
||||
)
|
||||
}
|
||||
} else {
|
||||
viewModel.generateAndImportReflowFile(
|
||||
pdfBookId = bookId,
|
||||
pdfUri = pdfUri,
|
||||
originalTitle = originalFileName,
|
||||
autoOpenPage = currentPage
|
||||
)
|
||||
}
|
||||
} else {
|
||||
viewModel.generateAndImportReflowFile(
|
||||
pdfBookId = bookId,
|
||||
pdfUri = pdfUri,
|
||||
originalTitle = originalFileName,
|
||||
autoOpenPage = currentPage
|
||||
)
|
||||
}
|
||||
},
|
||||
leadingIcon = {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue