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

@ -151,7 +151,8 @@ fun EpubReaderTopBar(
onOpenTtsSettings: () -> Unit,
onOpenDeviceVoiceSettings: () -> Unit,
searchFocusRequester: androidx.compose.ui.focus.FocusRequester,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
onToggleReflow: (() -> Unit)? = null,
) {
AnimatedVisibility(
visible = isVisible,
@ -201,6 +202,24 @@ fun EpubReaderTopBar(
expanded = showMoreMenu,
onDismissRequest = { showMoreMenu = false }
) {
if (onToggleReflow != null) {
DropdownMenuItem(
text = { Text("View Original PDF") },
onClick = {
showMoreMenu = false
onToggleReflow()
},
leadingIcon = {
Icon(
painter = painterResource(id = R.drawable.picture_as_pdf),
contentDescription = null,
modifier = Modifier.size(20.dp)
)
}
)
HorizontalDivider()
}
DropdownMenuItem(
text = { Text("Reading Mode: Vertical") },
enabled = !isTtsActive,

View file

@ -128,6 +128,7 @@ import com.aryan.reader.BannerMessage
import com.aryan.reader.BuildConfig
import com.aryan.reader.CustomTopBanner
import com.aryan.reader.DeviceVoiceSettingsSheet
import com.aryan.reader.MainViewModel
import com.aryan.reader.RenderMode
import com.aryan.reader.SearchResult
import com.aryan.reader.SummarizationResult
@ -291,8 +292,25 @@ fun EpubReaderScreen(
coverImagePath: String?,
onRenderModeChange: (RenderMode) -> Unit,
customFonts: List<CustomFontEntity>,
onImportFont: (Uri) -> Unit
onImportFont: (Uri) -> Unit,
viewModel: MainViewModel
) {
val uiState by viewModel.uiState.collectAsState()
val isReflowFile = uiState.selectedBookId?.endsWith("_reflow") == true
val originalBookId = if (isReflowFile) uiState.selectedBookId!!.removeSuffix("_reflow") else null
val onOpenOriginal: (() -> Unit)? = if (originalBookId != null) {
{
val originalItem = uiState.recentFiles.find { it.bookId == originalBookId }
if (originalItem != null) {
viewModel.onRecentFileClicked(originalItem)
} else {
viewModel.showBanner("Original PDF not found.", true)
}
}
} else null
EpubReaderHost(
epubBook = epubBook,
renderMode = renderMode,
@ -307,7 +325,8 @@ fun EpubReaderScreen(
coverImagePath = coverImagePath,
onRenderModeChange = onRenderModeChange,
customFonts = customFonts,
onImportFont = onImportFont
onImportFont = onImportFont,
onToggleReflow = onOpenOriginal
)
}
@ -330,7 +349,8 @@ fun EpubReaderHost(
coverImagePath: String?,
onRenderModeChange: (RenderMode) -> Unit,
customFonts: List<CustomFontEntity>,
onImportFont: (Uri) -> Unit
onImportFont: (Uri) -> Unit,
onToggleReflow: (() -> Unit)? = null
) {
val view = LocalView.current
val context = LocalContext.current
@ -389,7 +409,9 @@ fun EpubReaderHost(
var isAutoScrollCollapsed by remember { mutableStateOf(false) }
val bookId = remember(epubBook.title) { getBookIdForPrefs(epubBook.title) }
val bookId = remember(epubBook.title, epubBook.fileName) {
if (epubBook.fileName.length > 20) epubBook.fileName else getBookIdForPrefs(epubBook.title)
}
var isAutoScrollLocal by remember { mutableStateOf(loadAutoScrollLocalMode(context, bookId)) }
val initialSettings = remember(isAutoScrollLocal) {
@ -1008,6 +1030,7 @@ fun EpubReaderHost(
chapterChunks = result.chunks
isChapterParsing = false
loadUpToChunkIndex = result.startChunkIndex
Timber.tag("ReflowPaginationDiag").d("EpubReaderScreen: loadChapterContent finished. chapterChunks.size=${chapterChunks.size}, isChapterParsing=$isChapterParsing")
if (chunkTargetOverride != null) {
chunkTargetOverride = null
@ -1030,6 +1053,7 @@ fun EpubReaderHost(
var isPagerInitialized by remember(initialLocator) { mutableStateOf(initialLocator == null) }
LaunchedEffect(paginator, currentRenderMode, isPagerInitialized) {
Timber.tag("ReflowPaginationDiag").d("EpubReaderScreen: Checking paginator init. currentRenderMode=$currentRenderMode, paginator=${paginator != null}, isPagerInitialized=$isPagerInitialized")
if (currentRenderMode == RenderMode.PAGINATED && paginator != null && !isPagerInitialized) {
scope.launch {
val bookPaginator = paginator as? BookPaginator
@ -2880,7 +2904,8 @@ fun EpubReaderHost(
searchFocusRequester = searchFocusRequester,
modifier = Modifier.align(Alignment.TopCenter),
onOpenTtsSettings = { showTtsSettingsSheet = true },
onOpenDeviceVoiceSettings = { showDeviceVoiceSettingsSheet = true }
onOpenDeviceVoiceSettings = { showDeviceVoiceSettingsSheet = true },
onToggleReflow = onToggleReflow,
)
val autoScrollPadding by androidx.compose.animation.core.animateDpAsState(