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

@ -413,10 +413,25 @@ class BookPaginator(
if (cachedChapter.estimatedPageCount == 0) {
Timber.d("getBlocksForChapter: Found 'lite' cache for chapter $chapterIndex. Reprocessing for full fidelity.")
} else {
Timber.d("getBlocksForChapter: Cache HIT for chapter $chapterIndex in DATABASE.")
try {
val semanticBlocks = proto.decodeFromByteArray<List<SemanticBlock>>(cachedChapter.contentBlocksProto)
return styler.style(semanticBlocks)
val isCacheEmpty = semanticBlocks.isEmpty()
val isLazyChapter = chapter.htmlContent.isEmpty()
var shouldIgnoreCache = false
if (isCacheEmpty && isLazyChapter) {
val file = java.io.File(extractionBasePath, chapter.htmlFilePath)
if (file.exists() && file.length() > 0) {
Timber.tag("ReflowPaginationDiag").w("getBlocksForChapter: Cache HIT but empty for lazy chapter $chapterIndex. Backing file exists (${file.length()} bytes). Ignoring cache.")
shouldIgnoreCache = true
}
}
if (!shouldIgnoreCache) {
Timber.d("getBlocksForChapter: Cache HIT for chapter $chapterIndex in DATABASE.")
return styler.style(semanticBlocks)
}
} catch (e: Exception) {
Timber.e(e, "Failed to deserialize/style chapter $chapterIndex from DB. Reprocessing for this session.")
}
@ -424,7 +439,23 @@ class BookPaginator(
}
Timber.d("getBlocksForChapter: Cache MISS or 'lite' version found for chapter $chapterIndex. Parsing to Semantic IR.")
val document = Jsoup.parse(chapter.htmlContent, chapter.absPath)
var htmlToParse = chapter.htmlContent
if (htmlToParse.isEmpty()) {
val file = java.io.File(extractionBasePath, chapter.htmlFilePath)
if (file.exists()) {
Timber.tag("ReflowPaginationDiag").d("getBlocksForChapter: Lazy loading content from disk for chapter $chapterIndex: ${file.name} (${file.length()} bytes)")
try {
htmlToParse = file.readText()
} catch (e: Exception) {
Timber.tag("ReflowPaginationDiag").e(e, "Failed to read lazy HTML file")
}
} else {
Timber.tag("ReflowPaginationDiag").w("getBlocksForChapter: htmlContent is empty and file not found: ${file.absolutePath}")
}
}
val document = Jsoup.parse(htmlToParse, chapter.absPath)
val mathElements = document.select("math")
val svgResults = mutableMapOf<String, String>()

View file

@ -655,7 +655,10 @@ fun PaginatedReaderScreen(
BookCacheDatabase.getDatabase(context.applicationContext).bookCacheDao()
val proto = ProtoBuf { serializersModule = semanticBlockModule }
Timber.d("Recreating BookPaginator. TextAlign: $userTextAlign")
val uniqueBookId = if (book.fileName.length > 20) book.fileName else book.title
Timber.d("Recreating BookPaginator for ID: $uniqueBookId. TextAlign: $userTextAlign")
Timber.tag("ReflowPaginationDiag").d("PaginatedReaderScreen: Instantiating BookPaginator. book.chaptersForPagination.size=${book.chaptersForPagination.size}, initialChapter=$effectiveInitialChapter")
BookPaginator(
coroutineScope = coroutineScope,
@ -667,7 +670,7 @@ fun PaginatedReaderScreen(
density = density,
fontFamilyMap = fontFamilyMap,
isDarkTheme = isDarkTheme,
bookId = book.title,
bookId = uniqueBookId,
bookCacheDao = bookCacheDao,
proto = proto,
initialChapterToPaginate = effectiveInitialChapter,
@ -719,23 +722,29 @@ fun PaginatedReaderScreen(
}
}
// FIX 2: Replace property delegates with local state and a LaunchedEffect observer.
var isLoading by remember { mutableStateOf(true) }
var totalPageCount by remember { mutableIntStateOf(0) }
var generation by remember { mutableIntStateOf(0) }
LaunchedEffect(paginator) {
launch { snapshotFlow { paginator.isLoading }.collect { isLoading = it } }
launch { snapshotFlow { paginator.isLoading }.collect {
Timber.tag("ReflowPaginationDiag").d("PaginatedReaderScreen: paginator.isLoading=$it")
isLoading = it
} }
launch {
snapshotFlow { paginator.totalPageCount }.collect { newTotalPageCount ->
Timber.tag("ReflowPaginationDiag").d("PaginatedReaderScreen: paginator.totalPageCount=$newTotalPageCount")
totalPageCount = newTotalPageCount
}
}
launch { snapshotFlow { paginator.generation }.collect { generation = it } }
launch { snapshotFlow { paginator.generation }.collect {
Timber.tag("ReflowPaginationDiag").d("PaginatedReaderScreen: paginator.generation=$it")
generation = it
} }
}
LaunchedEffect(pagerState, paginator) {
snapshotFlow { pagerState.currentPage }.debounce(500) // Wait for scrolling to settle
snapshotFlow { pagerState.currentPage }.debounce(500)
.collectLatest { page -> paginator.onUserScrolledTo(page) }
}
@ -1432,6 +1441,7 @@ internal fun PaginatedReaderContent(
CircularProgressIndicator()
}
} else {
Timber.tag("ReflowPaginationDiag").d("PaginatedReaderContent: isLoading=false, totalPageCount=${uiState.totalPageCount}")
if (uiState.totalPageCount > 0) {
uiState.generation
@ -1508,7 +1518,9 @@ internal fun PaginatedReaderContent(
var currentChapterPath by remember { mutableStateOf<String?>(null) }
LaunchedEffect(pageIndex, uiState.generation) {
Timber.tag("ReflowPaginationDiag").d("PaginatedReaderContent: Fetching page $pageIndex content. generation=${uiState.generation}")
pageContent = onGetPage(pageIndex)
Timber.tag("ReflowPaginationDiag").d("PaginatedReaderContent: Fetched page $pageIndex content. isNull=${pageContent == null}, blocks=${pageContent?.content?.size}")
onGetChapterPath(pageIndex)?.let { currentChapterPath = it }
}