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
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue