Pdf text reflow (#36)
* Implemented PDF reflow mode by introducing a mechanism to convert PDF content to Markdown/HTML for viewing in the EPUB reader. Specific changes include: - Added `PdfReflowGenerator` and `PdfToMarkdownGenerator` to handle PDF text extraction and conversion to reflowable formats. - Updated `MainViewModel` with `toggleReflowMode` logic to switch between original PDF and reflowed views. - Modified `RecentFileEntity` and `RecentFileDao` to persist user reflow preferences, including a Room database migration (v12 to v13). - Updated `PdfViewerScreen` and `EpubReaderControls` to include UI options for toggling reflow mode. - Integrated reflow preference check into the book opening workflow to automatically load the preferred view. - Updated `AppNavigation` and `EpubReaderScreen` to support the new view switching state. * Implemented background processing and incremental loading for PDF reflow mode. - Added `reflowProgress` to `MainViewModel` to track and display PDF-to-Markdown conversion progress in the UI. - Refactored `PdfToMarkdownGenerator` to generate a skeleton EPUB structure immediately while processing page content (text and images) asynchronously. - Switched PDF text extraction to use `PDFBox` with optimized memory settings and JPEG compression for images. - Implemented priority page processing in reflow mode, starting with the user's current page. - Added "Clear Reflow Cache" debug option to the Home Screen. - Enhanced `BookPaginator` to support lazy loading of chapter content from disk and improved cache hit detection. * Refactored PDF Reflow Mode to generate standalone Markdown files instead of temporary EPUB books. * perf(reflow): optimize PDF-to-Markdown conversion and fix viewing lag - Re-architected PdfToMarkdownGenerator to use a single-pass stream (O(N) complexity), fixing performance bottlenecks and timeouts on large PDFs. - Implemented "Virtual Chaptering" in SingleFileImporter for Markdown files to split content into page-level HTML files, eliminating UI lag during reading. - Simplified ReflowWorker to delegate progress tracking and looping to the generator. - Enhanced PdfViewerScreen with a prominent top-bar progress indicator and a completion snackbar with an "OPEN" action. * Optimized EPUB parsing performance and fixed PDF viewer UI layout. - Optimized `EpubParser` by implementing parallel chapter parsing using coroutines and a semaphore to limit concurrency. - Reduced memory usage in `EpubParser` and `SingleFileImporter` by no longer storing full HTML content in memory for chapters. - Updated `EpubXMLFileParser` to support an existing `Document` object to avoid redundant Jsoup parsing. - Fixed an issue in `PdfViewerScreen` where the snackbar was appearing under the bottom app bar.
This commit is contained in:
parent
8f52549c19
commit
1e879eb604
21 changed files with 936 additions and 165 deletions
|
|
@ -68,6 +68,7 @@ import com.aryan.reader.paginatedreader.data.BookCacheDatabase
|
|||
import com.aryan.reader.paginatedreader.data.BookProcessingWorker
|
||||
import com.aryan.reader.pdf.PdfCoverGenerator
|
||||
import com.aryan.reader.pdf.PdfExporter
|
||||
import com.aryan.reader.pdf.ReflowWorker
|
||||
import com.aryan.reader.pdf.data.PageLayoutRepository
|
||||
import com.aryan.reader.pdf.data.PdfAnnotation
|
||||
import com.aryan.reader.pdf.data.PdfAnnotationRepository
|
||||
|
|
@ -80,10 +81,12 @@ import kotlinx.coroutines.Dispatchers
|
|||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
|
|
@ -188,6 +191,7 @@ data class ReaderScreenState(
|
|||
val searchQuery: String = "",
|
||||
val showFolderMigrationDialog: Boolean = false,
|
||||
val isRefreshing: Boolean = false,
|
||||
val reflowProgress: Float? = null
|
||||
)
|
||||
|
||||
open class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
|
@ -2252,90 +2256,141 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
}
|
||||
}
|
||||
|
||||
val reflowWorkInfo: Flow<WorkInfo?> = WorkManager.getInstance(appContext)
|
||||
.getWorkInfosByTagFlow(ReflowWorker.WORK_NAME)
|
||||
.map { list ->
|
||||
list.find { !it.state.isFinished } ?: list.firstOrNull()
|
||||
}
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null)
|
||||
|
||||
fun generateAndImportReflowFile(pdfBookId: String, pdfUri: Uri, originalTitle: String) {
|
||||
val reflowBookId = "${pdfBookId}_reflow"
|
||||
|
||||
viewModelScope.launch {
|
||||
val existing = recentFilesRepository.getFileByBookId(reflowBookId)
|
||||
if (existing != null) {
|
||||
showBanner("Opening existing text view...")
|
||||
onRecentFileClicked(existing)
|
||||
return@launch
|
||||
}
|
||||
|
||||
val workManager = WorkManager.getInstance(appContext)
|
||||
|
||||
val inputData = androidx.work.Data.Builder()
|
||||
.putString(ReflowWorker.KEY_BOOK_ID, pdfBookId)
|
||||
.putString(ReflowWorker.KEY_PDF_URI, pdfUri.toString())
|
||||
.putString(ReflowWorker.KEY_ORIGINAL_TITLE, originalTitle)
|
||||
.build()
|
||||
|
||||
val request = OneTimeWorkRequestBuilder<ReflowWorker>()
|
||||
.setInputData(inputData)
|
||||
.addTag(ReflowWorker.WORK_NAME)
|
||||
.addTag("book_$pdfBookId")
|
||||
.build()
|
||||
|
||||
workManager.enqueueUniqueWork(
|
||||
"reflow_$pdfBookId",
|
||||
ExistingWorkPolicy.KEEP,
|
||||
request
|
||||
)
|
||||
|
||||
showBanner("Text view generation started in background.")
|
||||
}
|
||||
}
|
||||
|
||||
private fun openBook(
|
||||
uri: Uri, bookId: String, type: FileType, originalDisplayName: String? = null
|
||||
) {
|
||||
Timber.d("Opening book with determined type: $type for bookId: $bookId")
|
||||
Timber.d("Opening book type: $type for bookId: $bookId")
|
||||
|
||||
_internalState.update {
|
||||
it.copy(
|
||||
selectedPdfUri = null,
|
||||
selectedEpubUri = null,
|
||||
selectedBookId = bookId,
|
||||
selectedEpubBook = null,
|
||||
selectedFileType = type,
|
||||
isLoading = true,
|
||||
errorMessage = null,
|
||||
initialLocator = null,
|
||||
initialPageInBook = null
|
||||
)
|
||||
}
|
||||
|
||||
if (type == FileType.PDF) {
|
||||
viewModelScope.launch {
|
||||
val recentItem = recentFilesRepository.getFileByBookId(bookId)
|
||||
|
||||
if (recentItem?.sourceFolderUri != null) {
|
||||
launch(Dispatchers.IO) {
|
||||
recentFilesRepository.syncLocalMetadataToFolder(bookId)
|
||||
}
|
||||
}
|
||||
|
||||
Timber.d("openBook: Loading PDF. bookId=$bookId ...")
|
||||
_internalState.update {
|
||||
it.copy(
|
||||
selectedPdfUri = uri,
|
||||
initialPageInBook = recentItem?.lastPage,
|
||||
initialBookmarksJson = recentItem?.bookmarksJson,
|
||||
isLoading = false
|
||||
)
|
||||
}
|
||||
addFileToRecent(
|
||||
uri,
|
||||
type,
|
||||
bookId,
|
||||
customDisplayName = originalDisplayName,
|
||||
isRecent = true,
|
||||
sourceFolderUri = null
|
||||
viewModelScope.launch {
|
||||
_internalState.update {
|
||||
it.copy(
|
||||
selectedPdfUri = null,
|
||||
selectedEpubUri = null,
|
||||
selectedBookId = bookId,
|
||||
selectedEpubBook = null,
|
||||
selectedFileType = type,
|
||||
isLoading = true,
|
||||
errorMessage = null,
|
||||
initialLocator = null,
|
||||
initialPageInBook = null
|
||||
)
|
||||
}
|
||||
} else if (type == FileType.EPUB || type == FileType.MOBI || type == FileType.MD || type == FileType.TXT || type == FileType.HTML) {
|
||||
viewModelScope.launch {
|
||||
val recentItem = recentFilesRepository.getFileByBookId(bookId)
|
||||
if (recentItem?.sourceFolderUri != null) {
|
||||
launch(Dispatchers.IO) {
|
||||
recentFilesRepository.syncLocalMetadataToFolder(bookId)
|
||||
}
|
||||
}
|
||||
val locator =
|
||||
if (recentItem?.lastChapterIndex != null && recentItem.locatorBlockIndex != null && recentItem.locatorCharOffset != null) {
|
||||
Locator(
|
||||
chapterIndex = recentItem.lastChapterIndex,
|
||||
blockIndex = recentItem.locatorBlockIndex,
|
||||
charOffset = recentItem.locatorCharOffset
|
||||
)
|
||||
} else {
|
||||
null
|
||||
|
||||
if (type == FileType.PDF) {
|
||||
viewModelScope.launch {
|
||||
val recentItem = recentFilesRepository.getFileByBookId(bookId)
|
||||
|
||||
if (recentItem?.sourceFolderUri != null) {
|
||||
launch(Dispatchers.IO) {
|
||||
recentFilesRepository.syncLocalMetadataToFolder(bookId)
|
||||
}
|
||||
}
|
||||
|
||||
_internalState.update {
|
||||
it.copy(
|
||||
selectedEpubUri = uri,
|
||||
initialLocator = locator,
|
||||
initialCfi = recentItem?.lastPositionCfi,
|
||||
initialBookmarksJson = recentItem?.bookmarksJson
|
||||
Timber.d("openBook: Loading PDF. bookId=$bookId ...")
|
||||
_internalState.update {
|
||||
it.copy(
|
||||
selectedPdfUri = uri,
|
||||
initialPageInBook = recentItem?.lastPage,
|
||||
initialBookmarksJson = recentItem?.bookmarksJson,
|
||||
isLoading = false
|
||||
)
|
||||
}
|
||||
addFileToRecent(
|
||||
uri,
|
||||
type,
|
||||
bookId,
|
||||
customDisplayName = originalDisplayName,
|
||||
isRecent = true,
|
||||
sourceFolderUri = null
|
||||
)
|
||||
}
|
||||
} else if (type == FileType.EPUB || type == FileType.MOBI || type == FileType.MD || type == FileType.TXT || type == FileType.HTML) {
|
||||
viewModelScope.launch {
|
||||
val recentItem = recentFilesRepository.getFileByBookId(bookId)
|
||||
if (recentItem?.sourceFolderUri != null) {
|
||||
launch(Dispatchers.IO) {
|
||||
recentFilesRepository.syncLocalMetadataToFolder(bookId)
|
||||
}
|
||||
}
|
||||
val locator =
|
||||
if (recentItem?.lastChapterIndex != null && recentItem.locatorBlockIndex != null && recentItem.locatorCharOffset != null) {
|
||||
Locator(
|
||||
chapterIndex = recentItem.lastChapterIndex,
|
||||
blockIndex = recentItem.locatorBlockIndex,
|
||||
charOffset = recentItem.locatorCharOffset
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
when (type) {
|
||||
FileType.EPUB -> {
|
||||
loadEpub(uri, bookId, customDisplayName = originalDisplayName)
|
||||
_internalState.update {
|
||||
it.copy(
|
||||
selectedEpubUri = uri,
|
||||
initialLocator = locator,
|
||||
initialCfi = recentItem?.lastPositionCfi,
|
||||
initialBookmarksJson = recentItem?.bookmarksJson
|
||||
)
|
||||
}
|
||||
FileType.MOBI -> {
|
||||
loadMobi(uri, bookId, customDisplayName = originalDisplayName)
|
||||
}
|
||||
else -> {
|
||||
loadSingleFile(uri, bookId, type, customDisplayName = originalDisplayName)
|
||||
|
||||
when (type) {
|
||||
FileType.EPUB -> {
|
||||
loadEpub(uri, bookId, customDisplayName = originalDisplayName)
|
||||
}
|
||||
|
||||
FileType.MOBI -> {
|
||||
loadMobi(uri, bookId, customDisplayName = originalDisplayName)
|
||||
}
|
||||
|
||||
else -> {
|
||||
loadSingleFile(
|
||||
uri,
|
||||
bookId,
|
||||
type,
|
||||
customDisplayName = originalDisplayName
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -3263,6 +3318,23 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
}
|
||||
}
|
||||
|
||||
fun clearReflowCache() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val reflowDir = File(appContext.cacheDir, "reflow_cache")
|
||||
if (reflowDir.exists()) {
|
||||
reflowDir.deleteRecursively()
|
||||
}
|
||||
val imagesDir = File(appContext.cacheDir, "reflow_images")
|
||||
if (imagesDir.exists()) {
|
||||
imagesDir.deleteRecursively()
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
showBanner("Reflow cache & images cleared.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val KEY_SORT_ORDER = "sort_order"
|
||||
internal const val KEY_SHELVES = "shelf_names"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue