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

@ -29,6 +29,8 @@ import android.content.Context
import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.graphics.RectF
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarResult
import android.net.Uri
import android.os.Build
import android.os.ParcelFileDescriptor
@ -220,6 +222,7 @@ import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.collectAsLazyPagingItems
import androidx.paging.compose.itemContentType
import androidx.paging.compose.itemKey
import androidx.work.WorkInfo
import com.aryan.reader.AiDefinitionPopup
import com.aryan.reader.AiDefinitionResult
import com.aryan.reader.BuildConfig
@ -673,7 +676,16 @@ fun PdfViewerScreen(
var isBackgroundIndexing by remember { mutableStateOf(false) }
var backgroundIndexingProgress by remember { mutableFloatStateOf(0f) }
var currentBookId by remember { mutableStateOf<String?>(null) }
val bookId = currentBookId ?: pdfUri.toString().hashCode().toString()
val uiState by viewModel.uiState.collectAsState()
val reflowBookId = remember(bookId) { "${bookId}_reflow" }
val hasReflowFile by remember(uiState.recentFiles, reflowBookId) {
derivedStateOf {
uiState.recentFiles.any { it.bookId == reflowBookId && !it.isDeleted }
}
}
val originalFileName by remember(uiState.recentFiles, pdfUri) {
derivedStateOf {
uiState.recentFiles.find { it.uriString == pdfUri.toString() }?.displayName
@ -737,9 +749,6 @@ fun PdfViewerScreen(
derivedStateOf { isEditMode && !isDockMinimized }
}
var currentBookId by remember { mutableStateOf<String?>(null) }
val bookId = currentBookId ?: pdfUri.toString().hashCode().toString()
var isAutoScrollLocal by remember { mutableStateOf(loadPdfAutoScrollLocalMode(context, bookId)) }
LaunchedEffect(bookId) {
@ -1602,6 +1611,23 @@ fun PdfViewerScreen(
}
}
val reflowInfo by viewModel.reflowWorkInfo.collectAsState(initial = null)
val isReflowingThisBook by remember(reflowInfo, bookId) {
derivedStateOf {
reflowInfo?.tags?.contains("book_$bookId") == true &&
(reflowInfo?.state == WorkInfo.State.RUNNING || reflowInfo?.state == WorkInfo.State.ENQUEUED)
}
}
val reflowProgressValue by remember(reflowInfo, isReflowingThisBook) {
derivedStateOf {
if (isReflowingThisBook) {
reflowInfo?.progress?.getFloat(ReflowWorker.KEY_PROGRESS, 0f) ?: 0f
} else 0f
}
}
val onBookmarkClick: () -> Unit = {
val currentPage = if (displayMode == DisplayMode.PAGINATION) {
pagerState.currentPage
@ -1611,6 +1637,25 @@ fun PdfViewerScreen(
onToggleBookmark(currentPage)
}
LaunchedEffect(reflowInfo) {
if (reflowInfo?.state == WorkInfo.State.SUCCEEDED &&
reflowInfo?.tags?.contains("book_$bookId") == true) {
val result = snackbarHostState.showSnackbar(
message = "Text View generation complete!",
actionLabel = "OPEN",
duration = SnackbarDuration.Long
)
if (result == SnackbarResult.ActionPerformed) {
val item = uiState.recentFiles.find { it.bookId == reflowBookId }
if (item != null) {
viewModel.onRecentFileClicked(item)
}
}
}
}
LaunchedEffect(pdfUri) { debugPdfLinks(context, pdfUri, pdfiumCore, this) }
LaunchedEffect(currentBookId) {
@ -2837,6 +2882,12 @@ fun PdfViewerScreen(
}
}
val showStandardBars = showBars && !isEditMode
val snackbarPadding by animateDpAsState(
targetValue = if (showStandardBars && !searchState.isSearchActive) 56.dp else 0.dp,
label = "SnackbarPadding"
)
ModalNavigationDrawer(
drawerState = drawerState, gesturesEnabled = drawerState.isOpen, drawerContent = {
ModalDrawerSheet(modifier = Modifier.windowInsetsPadding(WindowInsets.statusBars)) {
@ -3100,7 +3151,14 @@ fun PdfViewerScreen(
}
}
}) {
Scaffold(snackbarHost = { SnackbarHost(snackbarHostState) }) { paddingValues ->
Scaffold(
snackbarHost = {
SnackbarHost(
hostState = snackbarHostState,
modifier = Modifier.padding(bottom = snackbarPadding)
)
}
) { paddingValues ->
BoxWithConstraints(modifier = Modifier
.fillMaxSize()
.padding(paddingValues)) {
@ -4083,8 +4141,6 @@ fun PdfViewerScreen(
}
}
val showStandardBars = showBars && !isEditMode
// Custom Top Bar
AnimatedVisibility(
visible = showStandardBars,
@ -4298,7 +4354,7 @@ fun PdfViewerScreen(
)
}
)
if (BuildConfig.DEBUG) {
DropdownMenuItem(
text = { Text("TTS Settings (Debug)") },
@ -4345,6 +4401,44 @@ fun PdfViewerScreen(
)
)
}
HorizontalDivider()
DropdownMenuItem(
text = {
Text(
when {
isReflowingThisBook -> "Generating... ${(reflowProgressValue * 100).toInt()}%"
hasReflowFile -> "Open Text View"
else -> "Generate Text View"
}
)
},
enabled = pdfDocument != null && !isReflowingThisBook,
onClick = {
showMoreMenu = false
if (hasReflowFile) {
val item = uiState.recentFiles.find { it.bookId == reflowBookId }
if (item != null) {
viewModel.onRecentFileClicked(item)
}
} else {
viewModel.generateAndImportReflowFile(
pdfBookId = bookId,
pdfUri = pdfUri,
originalTitle = originalFileName
)
}
},
leadingIcon = {
Icon(
painter = painterResource(id = R.drawable.format_size),
contentDescription = null,
modifier = Modifier.size(20.dp)
)
}
)
HorizontalDivider()
DropdownMenuItem(text = { Text("Share") }, onClick = {
@ -4373,6 +4467,50 @@ fun PdfViewerScreen(
}
}
AnimatedVisibility(
visible = showStandardBars && isReflowingThisBook,
enter = fadeIn() + slideInVertically(),
exit = fadeOut() + slideOutVertically(),
modifier = Modifier
.align(Alignment.TopCenter)
.padding(top = 56.dp)
.fillMaxWidth()
.padding(horizontal = 8.dp)
) {
Surface(
color = MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(bottomStart = 8.dp, bottomEnd = 8.dp),
shadowElevation = 4.dp
) {
Column(modifier = Modifier.padding(12.dp)) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = "Generating Text View...",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f)
)
Text(
text = "${(reflowProgressValue * 100).toInt()}%",
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
}
Spacer(Modifier.height(8.dp))
androidx.compose.material3.LinearProgressIndicator(
progress = { reflowProgressValue },
modifier = Modifier.fillMaxWidth().height(6.dp),
trackColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
)
}
}
}
// Search Results Panel
AnimatedVisibility(
visible = searchState.isSearchActive && searchState.showSearchResultsPanel,
@ -5772,6 +5910,7 @@ fun PdfViewerScreen(
}
}
}
val autoScrollPadding by animateDpAsState(
targetValue = if (showBars) (56.dp + 16.dp) else 16.dp,
label = "AutoScrollPadding"