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:
Aryan 2026-03-07 16:10:34 +05:30 committed by GitHub
parent 8f52549c19
commit 1e879eb604
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 936 additions and 165 deletions

View file

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