Pdf reflow rework (#51)
* perf(pdf): native Pdfium-based reflow engine with auto-open Migrate PDF-to-Markdown reflow generation from PDFBox to a custom native implementation using Pdfium. This transition improves processing speed by ~10x and significantly reduces memory overhead. - Native JNI Bridge: Implemented `pdfium_bridge.cpp` using `dlopen` to hook into the existing `libpdfium.so` memory space. - Optimized Extraction: Replaced character-by-character JNI calls with bulk array retrieval (`getPageFontSizes`, `getPageFontWeights`), drastically reducing JNI boundary overhead. - Enhanced Accuracy: Improved Markdown formatting logic by using native font weight (bold) and relative font size variance (headers). - Thread Safety: Refactored generator to process pages sequentially while synchronized with the global `PdfiumCore.lock` to ensure stability across concurrent UI operations. - Seamless UX: Implemented a reactive auto-open system in the PDF viewer that tracks user intent and navigates to the reflow view immediately upon background task completion. * Improved PDF to Markdown conversion and added cache cleanup. - Implemented automated cleanup of imported file caches when deleting books. - Enhanced `PdfToMarkdownGenerator` with support for font flags (italics), improved kerning, and smarter paragraph wrapping. - Updated `NativePdfiumBridge` and C++ JNI code to extract font information flags from PDFium. * Implemented seamless file switching and enhanced reflow transition logic. Key changes include: - Added `switchToFileSeamlessly` to `MainViewModel` to handle state transitions and navigation when switching between PDF and reflowed text views. - Updated `generateAndImportReflowFile` to support automatic opening of the generated file at a specific page/chapter. - Integrated `NavigationEvent` and `CompletableDeferred` to manage asynchronous navigation and state updates during file switches. - Modified `EpubReaderScreen` and `PdfViewerScreen` to pass the current position when toggling between PDF and text modes. - Updated `AppNavigation` to move navigation logic out of the `NavHost` and added loading overlays to viewers to improve UI feedback during transitions.
This commit is contained in:
parent
acf282d4c7
commit
c61a264a65
11 changed files with 940 additions and 419 deletions
|
|
@ -22,6 +22,7 @@ package com.aryan.reader
|
|||
import android.os.Build
|
||||
import timber.log.Timber
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
|
|
@ -69,6 +70,36 @@ fun AppNavigation(
|
|||
Timber.d("AppNavigation composable invoked.")
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
|
||||
LaunchedEffect(uiState.selectedFileType, uiState.isLoading, uiState.selectedEpubBook, uiState.selectedPdfUri) {
|
||||
if (!uiState.isLoading) {
|
||||
when (uiState.selectedFileType) {
|
||||
FileType.PDF -> {
|
||||
if (uiState.selectedPdfUri != null) {
|
||||
if (navController.currentDestination?.route != AppDestinations.PDF_VIEWER_ROUTE) {
|
||||
navController.navigate(AppDestinations.PDF_VIEWER_ROUTE) {
|
||||
popUpTo(AppDestinations.MAIN_ROUTE)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
FileType.EPUB, FileType.MOBI, FileType.MD, FileType.TXT, FileType.HTML -> {
|
||||
if (uiState.selectedEpubBook != null) {
|
||||
if (navController.currentDestination?.route != AppDestinations.EPUB_READER_ROUTE) {
|
||||
navController.navigate(AppDestinations.EPUB_READER_ROUTE) {
|
||||
popUpTo(AppDestinations.MAIN_ROUTE)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
null -> {
|
||||
if (navController.currentDestination?.route != AppDestinations.MAIN_ROUTE) {
|
||||
navController.popBackStack(AppDestinations.MAIN_ROUTE, inclusive = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
NavHost(navController = navController, startDestination = AppDestinations.MAIN_ROUTE) {
|
||||
composable(AppDestinations.MAIN_ROUTE) {
|
||||
Timber.d("Navigating to Main Screen (${AppDestinations.MAIN_ROUTE}).")
|
||||
|
|
@ -77,39 +108,6 @@ fun AppNavigation(
|
|||
windowSizeClass = windowSizeClass,
|
||||
navController = navController
|
||||
)
|
||||
|
||||
LaunchedEffect(uiState.selectedFileType, uiState.isLoading, uiState.selectedEpubBook, uiState.selectedPdfUri) {
|
||||
if (!uiState.isLoading) {
|
||||
when (uiState.selectedFileType) {
|
||||
FileType.PDF -> {
|
||||
if (uiState.selectedPdfUri != null) {
|
||||
Timber.d("Navigating to PDF Viewer. Route: ${AppDestinations.PDF_VIEWER_ROUTE}")
|
||||
if (navController.currentDestination?.route != AppDestinations.PDF_VIEWER_ROUTE) {
|
||||
navController.navigate(AppDestinations.PDF_VIEWER_ROUTE)
|
||||
}
|
||||
}
|
||||
}
|
||||
FileType.EPUB, FileType.MOBI, FileType.MD, FileType.TXT, FileType.HTML -> {
|
||||
if (uiState.selectedEpubBook != null) {
|
||||
Timber.d("Navigating to EPUB Reader for ${uiState.selectedFileType}. Route: ${AppDestinations.EPUB_READER_ROUTE}")
|
||||
if (navController.currentDestination?.route != AppDestinations.EPUB_READER_ROUTE) {
|
||||
navController.navigate(AppDestinations.EPUB_READER_ROUTE)
|
||||
}
|
||||
} else if (uiState.selectedEpubUri != null && uiState.errorMessage == null) {
|
||||
Timber.d("${uiState.selectedFileType} selected, waiting for parsing/loading before navigation.")
|
||||
} else if (uiState.errorMessage != null) {
|
||||
Timber.w("${uiState.selectedFileType} loading failed, staying on Home. Error: ${uiState.errorMessage}")
|
||||
}
|
||||
}
|
||||
null -> {
|
||||
if (navController.currentDestination?.route != AppDestinations.MAIN_ROUTE) {
|
||||
Timber.d("File cleared, ensuring navigation back to Main Screen.")
|
||||
navController.popBackStack(AppDestinations.MAIN_ROUTE, inclusive = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// PDF Viewer Screen Composable
|
||||
|
|
@ -119,37 +117,53 @@ fun AppNavigation(
|
|||
val initialPage = uiState.initialPageInBook
|
||||
val initialBookmarksJson = uiState.initialBookmarksJson
|
||||
|
||||
val bookId = uiState.recentFiles.find { it.uriString == uiState.selectedPdfUri.toString() }?.bookId
|
||||
val bookId =
|
||||
uiState.recentFiles.find { it.uriString == uiState.selectedPdfUri.toString() }?.bookId
|
||||
|
||||
if (pdfUri != null) {
|
||||
Timber.i("Displaying PDF Viewer for URI: $pdfUri, initialPage: $initialPage")
|
||||
PdfViewerScreen(
|
||||
pdfUri = pdfUri,
|
||||
initialPage = initialPage,
|
||||
initialBookmarksJson = initialBookmarksJson,
|
||||
isProUser = uiState.isProUser,
|
||||
onNavigateBack = {
|
||||
Timber.d("Back action triggered from PDF Viewer.")
|
||||
viewModel.clearSelectedFile()
|
||||
},
|
||||
onSavePosition = viewModel::savePdfReadingPosition,
|
||||
onBookmarksChanged = { bookmarksJson ->
|
||||
if (bookId != null) {
|
||||
viewModel.saveBookmarks(bookId, bookmarksJson)
|
||||
} else {
|
||||
Timber.w("Could not find bookId to save PDF bookmarks for URI: ${uiState.selectedPdfUri}")
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
PdfViewerScreen(
|
||||
pdfUri = pdfUri,
|
||||
initialPage = initialPage,
|
||||
initialBookmarksJson = initialBookmarksJson,
|
||||
isProUser = uiState.isProUser,
|
||||
onNavigateBack = {
|
||||
Timber.d("Back action triggered from PDF Viewer.")
|
||||
viewModel.clearSelectedFile()
|
||||
},
|
||||
onSavePosition = viewModel::savePdfReadingPosition,
|
||||
onBookmarksChanged = { bookmarksJson ->
|
||||
if (bookId != null) {
|
||||
viewModel.saveBookmarks(bookId, bookmarksJson)
|
||||
} else {
|
||||
Timber.w("Could not find bookId to save PDF bookmarks for URI: ${uiState.selectedPdfUri}")
|
||||
}
|
||||
},
|
||||
onNavigateToPro = {
|
||||
navController.navigate(AppDestinations.PRO_SCREEN_ROUTE)
|
||||
},
|
||||
viewModel = viewModel
|
||||
)
|
||||
|
||||
if (uiState.isLoading) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background.copy(alpha = 0.5f)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
},
|
||||
onNavigateToPro = {
|
||||
navController.navigate(AppDestinations.PRO_SCREEN_ROUTE)
|
||||
},
|
||||
viewModel = viewModel
|
||||
)
|
||||
}
|
||||
}
|
||||
} else if (uiState.isLoading) {
|
||||
Timber.d("PDF URI is null but loading is in progress. Showing loading indicator.")
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
} else {
|
||||
Timber.w("PDF URI is null in ViewModel state while on PDF screen. Navigating back to Main.")
|
||||
LaunchedEffect(Unit) {
|
||||
navController.popBackStack(AppDestinations.MAIN_ROUTE, inclusive = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -172,40 +186,53 @@ fun AppNavigation(
|
|||
uiState.recentFiles.find { it.uriString == uiState.selectedEpubUri.toString() }?.bookId
|
||||
val customFonts by viewModel.customFonts.collectAsStateWithLifecycle()
|
||||
|
||||
EpubReaderScreen(
|
||||
epubBook = epubBook,
|
||||
renderMode = renderMode,
|
||||
initialLocator = initialLocator,
|
||||
initialCfi = initialCfi,
|
||||
initialBookmarksJson = initialBookmarksJson,
|
||||
isProUser = uiState.isProUser,
|
||||
coverImagePath = coverPath,
|
||||
onNavigateBack = {
|
||||
Timber.d("Back action from EPUB Reader. Clearing selected file to navigate home.")
|
||||
viewModel.clearSelectedFile()
|
||||
},
|
||||
onSavePosition = { locator, cfiForWebView, progress ->
|
||||
Timber.d("Auto-saving EPUB position: Locator $locator, Progress $progress%")
|
||||
epubUri?.let { uri ->
|
||||
viewModel.saveEpubReadingPosition(uri, locator, cfiForWebView, progress)
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
EpubReaderScreen(
|
||||
epubBook = epubBook,
|
||||
renderMode = renderMode,
|
||||
initialLocator = initialLocator,
|
||||
initialCfi = initialCfi,
|
||||
initialBookmarksJson = initialBookmarksJson,
|
||||
isProUser = uiState.isProUser,
|
||||
coverImagePath = coverPath,
|
||||
onNavigateBack = {
|
||||
Timber.d("Back action from EPUB Reader. Clearing selected file to navigate home.")
|
||||
viewModel.clearSelectedFile()
|
||||
},
|
||||
onSavePosition = { locator, cfiForWebView, progress ->
|
||||
Timber.d("Auto-saving EPUB position: Locator $locator, Progress $progress%")
|
||||
epubUri?.let { uri ->
|
||||
viewModel.saveEpubReadingPosition(uri, locator, cfiForWebView, progress)
|
||||
}
|
||||
},
|
||||
onBookmarksChanged = { bookmarksJson ->
|
||||
val bookId = uiState.recentFiles.find { it.uriString == uiState.selectedEpubUri.toString() }?.bookId
|
||||
if (bookId != null) {
|
||||
viewModel.saveBookmarks(bookId, bookmarksJson)
|
||||
} else {
|
||||
Timber.w("Could not find bookId to save bookmarks for URI: ${uiState.selectedEpubUri}")
|
||||
}
|
||||
},
|
||||
onNavigateToPro = {
|
||||
navController.navigate(AppDestinations.PRO_SCREEN_ROUTE)
|
||||
},
|
||||
onRenderModeChange = viewModel::setRenderMode,
|
||||
customFonts = customFonts,
|
||||
onImportFont = viewModel::importFont,
|
||||
viewModel = viewModel
|
||||
)
|
||||
|
||||
if (uiState.isLoading) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background.copy(alpha = 0.5f)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
},
|
||||
onBookmarksChanged = { bookmarksJson ->
|
||||
val bookId = uiState.recentFiles.find { it.uriString == uiState.selectedEpubUri.toString() }?.bookId
|
||||
if (bookId != null) {
|
||||
viewModel.saveBookmarks(bookId, bookmarksJson)
|
||||
} else {
|
||||
Timber.w("Could not find bookId to save bookmarks for URI: ${uiState.selectedEpubUri}")
|
||||
}
|
||||
},
|
||||
onNavigateToPro = {
|
||||
navController.navigate(AppDestinations.PRO_SCREEN_ROUTE)
|
||||
},
|
||||
onRenderModeChange = viewModel::setRenderMode,
|
||||
customFonts = customFonts,
|
||||
onImportFont = viewModel::importFont,
|
||||
viewModel = viewModel
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
isLoading -> {
|
||||
Timber.d("EPUB Reader: Showing loading indicator.")
|
||||
|
|
@ -231,9 +258,6 @@ fun AppNavigation(
|
|||
}
|
||||
else -> {
|
||||
Timber.w("EPUB Book is null and not loading/error state on EPUB screen. Navigating back.")
|
||||
LaunchedEffect(Unit) {
|
||||
navController.popBackStack(AppDestinations.MAIN_ROUTE, inclusive = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -118,7 +118,7 @@ fun DictionarySettingsDialog(
|
|||
color = if (useOnlineDictionary) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Text(
|
||||
text = "Contextual definitions powered by AI.",
|
||||
text = "Definitions powered by AI.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = if (useOnlineDictionary) MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f) else MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
|
|
|||
|
|
@ -337,11 +337,11 @@ fun EpubReaderScreen(
|
|||
val isReflowFile = uiState.selectedBookId?.endsWith("_reflow") == true
|
||||
val originalBookId = if (isReflowFile) uiState.selectedBookId!!.removeSuffix("_reflow") else null
|
||||
|
||||
val onOpenOriginal: (() -> Unit)? = if (originalBookId != null) {
|
||||
{
|
||||
val onOpenOriginal: ((Int) -> Unit)? = if (originalBookId != null) {
|
||||
{ currentChapter ->
|
||||
val originalItem = uiState.recentFiles.find { it.bookId == originalBookId }
|
||||
if (originalItem != null) {
|
||||
viewModel.onRecentFileClicked(originalItem)
|
||||
viewModel.switchToFileSeamlessly(originalItem, currentChapter)
|
||||
} else {
|
||||
viewModel.showBanner("Original PDF not found.", true)
|
||||
}
|
||||
|
|
@ -388,7 +388,7 @@ fun EpubReaderHost(
|
|||
onRenderModeChange: (RenderMode) -> Unit,
|
||||
customFonts: List<CustomFontEntity>,
|
||||
onImportFont: (Uri) -> Unit,
|
||||
onToggleReflow: (() -> Unit)? = null
|
||||
onToggleReflow: ((Int) -> Unit)? = null
|
||||
) {
|
||||
val view = LocalView.current
|
||||
val context = LocalContext.current
|
||||
|
|
@ -3114,7 +3114,16 @@ fun EpubReaderHost(
|
|||
onOpenTtsSettings = { showTtsSettingsSheet = true },
|
||||
onOpenDictionarySettings = { showDictionarySettingsSheet = true },
|
||||
onOpenDeviceVoiceSettings = { showDeviceVoiceSettingsSheet = true },
|
||||
onToggleReflow = onToggleReflow,
|
||||
onToggleReflow = if (onToggleReflow != null) {
|
||||
{
|
||||
val activeChapter = if (currentRenderMode == RenderMode.PAGINATED) {
|
||||
currentChapterInPaginatedMode ?: currentChapterIndex
|
||||
} else {
|
||||
currentChapterIndex
|
||||
}
|
||||
onToggleReflow(activeChapter)
|
||||
}
|
||||
} else null,
|
||||
)
|
||||
|
||||
val autoScrollPadding by androidx.compose.animation.core.animateDpAsState(
|
||||
|
|
|
|||
14
app/src/main/java/com/aryan/reader/pdf/NativePdfiumBridge.kt
Normal file
14
app/src/main/java/com/aryan/reader/pdf/NativePdfiumBridge.kt
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
package com.aryan.reader.pdf
|
||||
|
||||
object NativePdfiumBridge {
|
||||
init {
|
||||
System.loadLibrary("native-lib")
|
||||
}
|
||||
|
||||
@JvmStatic external fun getFontSize(textPagePtr: Long, index: Int): Double
|
||||
@JvmStatic external fun getFontWeight(textPagePtr: Long, index: Int): Int
|
||||
|
||||
@JvmStatic external fun getPageFontSizes(textPagePtr: Long, count: Int): FloatArray?
|
||||
@JvmStatic external fun getPageFontWeights(textPagePtr: Long, count: Int): IntArray?
|
||||
@JvmStatic external fun getPageFontFlags(textPagePtr: Long, count: Int): IntArray?
|
||||
}
|
||||
|
|
@ -3,11 +3,9 @@ package com.aryan.reader.pdf
|
|||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import com.tom_roush.pdfbox.io.MemoryUsageSetting
|
||||
import com.tom_roush.pdfbox.pdmodel.PDDocument
|
||||
import com.tom_roush.pdfbox.pdmodel.PDPage
|
||||
import com.tom_roush.pdfbox.text.PDFTextStripper
|
||||
import com.tom_roush.pdfbox.text.TextPosition
|
||||
import io.legere.pdfiumandroid.PdfiumCore
|
||||
import io.legere.pdfiumandroid.suspend.PdfiumCoreKt
|
||||
import io.legere.pdfiumandroid.suspend.PdfDocumentKt
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import timber.log.Timber
|
||||
|
|
@ -15,8 +13,6 @@ import java.io.File
|
|||
import kotlin.math.roundToInt
|
||||
|
||||
object PdfToMarkdownGenerator {
|
||||
|
||||
// Unique delimiter to split pages reliably
|
||||
const val PAGE_DELIMITER = "\n\n[[PAGE_BREAK]]\n\n"
|
||||
|
||||
suspend fun generateMarkdownFile(
|
||||
|
|
@ -26,109 +22,252 @@ object PdfToMarkdownGenerator {
|
|||
startPage: Int = 1,
|
||||
onProgress: (Float) -> Unit
|
||||
): Boolean = withContext(Dispatchers.IO) {
|
||||
val methodStartTime = System.currentTimeMillis()
|
||||
Timber.tag("PdfToMdPerf").d("generateMarkdownFile NATIVE START | uri=$pdfUri | startPage=$startPage")
|
||||
|
||||
val pdfiumCore = PdfiumCoreKt(Dispatchers.Default)
|
||||
val pfd = context.contentResolver.openFileDescriptor(pdfUri, "r")
|
||||
if (pfd == null) {
|
||||
Timber.tag("PdfToMdPerf").e("Failed to open ParcelFileDescriptor")
|
||||
return@withContext false
|
||||
}
|
||||
|
||||
try {
|
||||
context.contentResolver.openInputStream(pdfUri)?.use { inputStream ->
|
||||
// Setup mixed memory usage to handle larger files without OOM
|
||||
PDDocument.load(inputStream, MemoryUsageSetting.setupMixed(50 * 1024 * 1024)).use { doc ->
|
||||
val totalPages = doc.numberOfPages
|
||||
val doc = pdfiumCore.newDocument(pfd)
|
||||
val totalPages = doc.getPageCount()
|
||||
Timber.tag("PdfToMdPerf").d("Document loaded natively. Total Pages: $totalPages")
|
||||
|
||||
// Configure stripper for linear processing
|
||||
val stripper = MarkdownStripper(totalPages, onProgress)
|
||||
stripper.startPage = startPage
|
||||
stripper.endPage = totalPages
|
||||
destFile.bufferedWriter().use { writer ->
|
||||
for (pageIdx in (startPage - 1) until totalPages) {
|
||||
val pageMd = extractPageMarkdown(doc, pageIdx)
|
||||
writer.write(pageMd)
|
||||
writer.write(PAGE_DELIMITER)
|
||||
|
||||
// Write directly to file stream (O(N) complexity)
|
||||
destFile.bufferedWriter().use { writer ->
|
||||
stripper.writeText(doc, writer)
|
||||
if (pageIdx % 5 == 0 || pageIdx == totalPages - 1) {
|
||||
onProgress((pageIdx + 1).toFloat() / totalPages.toFloat())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
doc.close()
|
||||
pfd.close()
|
||||
|
||||
Timber.tag("PdfToMdPerf").d("generateMarkdownFile NATIVE SUCCESS | totalTime=${System.currentTimeMillis() - methodStartTime}ms")
|
||||
return@withContext true
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Failed to generate Markdown from PDF")
|
||||
Timber.e(e, "Failed to generate Markdown from PDF natively")
|
||||
pfd.close()
|
||||
return@withContext false
|
||||
}
|
||||
}
|
||||
|
||||
private class MarkdownStripper(
|
||||
private val totalPages: Int,
|
||||
private val onProgress: (Float) -> Unit
|
||||
) : PDFTextStripper() {
|
||||
private var currentPageBaseFontSize = 0f
|
||||
private suspend fun extractPageMarkdown(doc: PdfDocumentKt, pageIdx: Int): String {
|
||||
return try {
|
||||
doc.openPage(pageIdx).use { page ->
|
||||
page.openTextPage().use { textPage ->
|
||||
val charCount = textPage.textPageCountChars()
|
||||
if (charCount <= 0) return@use ""
|
||||
|
||||
init {
|
||||
sortByPosition = true
|
||||
suppressDuplicateOverlappingText = true
|
||||
paragraphStart = ""
|
||||
paragraphEnd = "\n\n"
|
||||
}
|
||||
val text = textPage.textPageGetText(0, charCount) ?: ""
|
||||
val actualCount = minOf(charCount, text.length)
|
||||
|
||||
// Override endPage to update progress and insert delimiter
|
||||
override fun endPage(page: PDPage?) {
|
||||
super.endPage(page)
|
||||
val rawPtr = textPage.page.pagePtr
|
||||
|
||||
try {
|
||||
// Insert our custom delimiter so importer can split chapters
|
||||
output.write(PAGE_DELIMITER)
|
||||
val sizes: FloatArray?
|
||||
val weights: IntArray?
|
||||
val flags: IntArray?
|
||||
|
||||
// Update progress
|
||||
val current = currentPageNo // inherited from PDFTextStripper
|
||||
if (totalPages > 0) {
|
||||
onProgress(current.toFloat() / totalPages.toFloat())
|
||||
synchronized(PdfiumCore.lock) {
|
||||
sizes = NativePdfiumBridge.getPageFontSizes(rawPtr, actualCount)
|
||||
weights = NativePdfiumBridge.getPageFontWeights(rawPtr, actualCount)
|
||||
flags = NativePdfiumBridge.getPageFontFlags(rawPtr, actualCount)
|
||||
}
|
||||
|
||||
if (sizes == null || weights == null || flags == null) {
|
||||
return@use text
|
||||
}
|
||||
|
||||
buildMarkdown(text, sizes, weights, flags, actualCount)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Error writing page delimiter")
|
||||
}
|
||||
}
|
||||
|
||||
override fun startPage(page: PDPage?) {
|
||||
currentPageBaseFontSize = 0f
|
||||
super.startPage(page)
|
||||
}
|
||||
|
||||
private fun calculateBaseFontSize(textPositions: List<TextPosition>) {
|
||||
val sizeCounts = mutableMapOf<Float, Int>()
|
||||
textPositions.forEach { pos ->
|
||||
val size = pos.fontSizeInPt.roundToInt().toFloat()
|
||||
sizeCounts[size] = (sizeCounts[size] ?: 0) + 1
|
||||
}
|
||||
currentPageBaseFontSize = sizeCounts.maxByOrNull { it.value }?.key ?: 12f
|
||||
}
|
||||
|
||||
override fun writeString(text: String?, textPositions: MutableList<TextPosition>?) {
|
||||
if (text.isNullOrBlank() || textPositions.isNullOrEmpty()) return
|
||||
|
||||
if (currentPageBaseFontSize == 0f) {
|
||||
calculateBaseFontSize(textPositions)
|
||||
}
|
||||
|
||||
val firstPos = textPositions[0]
|
||||
val fontSize = firstPos.fontSizeInPt
|
||||
val fontDescriptor = firstPos.font?.fontDescriptor
|
||||
|
||||
val isBold = fontDescriptor?.isForceBold == true ||
|
||||
(firstPos.font?.name?.contains("Bold", ignoreCase = true) == true)
|
||||
val isItalic = fontDescriptor?.isItalic == true ||
|
||||
(firstPos.font?.name?.contains("Italic", ignoreCase = true) == true)
|
||||
|
||||
// Header detection logic
|
||||
val isHeader = fontSize > currentPageBaseFontSize * 1.2
|
||||
val isBigHeader = fontSize > currentPageBaseFontSize * 1.5
|
||||
|
||||
val sb = StringBuilder()
|
||||
|
||||
if (isBigHeader) sb.append("## ")
|
||||
else if (isHeader) sb.append("### ")
|
||||
|
||||
if (isBold && !isHeader) sb.append("**")
|
||||
if (isItalic) sb.append("*")
|
||||
|
||||
text.forEach { char -> sb.append(char) }
|
||||
|
||||
if (isItalic) sb.append("*")
|
||||
if (isBold && !isHeader) sb.append("**")
|
||||
|
||||
writeString(sb.toString())
|
||||
} catch (e: Exception) {
|
||||
Timber.w(e, "Error extracting page $pageIdx")
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
private data class TextSpan(
|
||||
val text: String,
|
||||
val size: Float,
|
||||
val isBold: Boolean,
|
||||
val isItalic: Boolean
|
||||
)
|
||||
|
||||
private data class TextLine(
|
||||
val spans: List<TextSpan>
|
||||
)
|
||||
|
||||
private fun fixKerning(text: String): String {
|
||||
val pattern = Regex("\\b(?:[A-Za-z0-9] ){2,}[A-Za-z0-9]\\b")
|
||||
return pattern.replace(text) { matchResult ->
|
||||
matchResult.value.replace(" ", "")
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildMarkdown(text: String, sizes: FloatArray, weights: IntArray, flags: IntArray, count: Int): String {
|
||||
if (count == 0) return ""
|
||||
|
||||
val sizeFrequency = HashMap<Int, Int>()
|
||||
for (i in 0 until count) {
|
||||
val s = sizes[i].roundToInt()
|
||||
sizeFrequency[s] = (sizeFrequency[s] ?: 0) + 1
|
||||
}
|
||||
val baseSize = sizeFrequency.maxByOrNull { it.value }?.key ?: 12
|
||||
|
||||
val lines = mutableListOf<TextLine>()
|
||||
@Suppress("CanBeVal") var currentSpans = mutableListOf<TextSpan>()
|
||||
val currentSpanText = StringBuilder()
|
||||
|
||||
var currentSize = -1f
|
||||
var currentBold = false
|
||||
var currentItalic = false
|
||||
|
||||
for (i in 0 until count) {
|
||||
val c = text[i]
|
||||
if (c == '\u0000') continue
|
||||
|
||||
if (c == '\n' || c == '\r') {
|
||||
if (c == '\n' && i > 0 && text[i - 1] == '\r') continue
|
||||
|
||||
if (currentSpanText.isNotEmpty()) {
|
||||
currentSpans.add(TextSpan(currentSpanText.toString(), currentSize, currentBold, currentItalic))
|
||||
currentSpanText.clear()
|
||||
}
|
||||
lines.add(TextLine(currentSpans.toList()))
|
||||
currentSpans.clear()
|
||||
continue
|
||||
}
|
||||
|
||||
val isSpace = c.isWhitespace()
|
||||
val size = sizes[i]
|
||||
val bold = weights[i] > 600
|
||||
val italic = (flags[i] and 64) != 0
|
||||
|
||||
if (currentSpanText.isEmpty()) {
|
||||
currentSize = size
|
||||
currentBold = bold
|
||||
currentItalic = italic
|
||||
currentSpanText.append(c)
|
||||
} else {
|
||||
if (!isSpace && (currentSize != size || currentBold != bold || currentItalic != italic)) {
|
||||
currentSpans.add(TextSpan(currentSpanText.toString(), currentSize, currentBold, currentItalic))
|
||||
currentSpanText.clear()
|
||||
currentSize = size
|
||||
currentBold = bold
|
||||
currentItalic = italic
|
||||
}
|
||||
currentSpanText.append(c)
|
||||
}
|
||||
}
|
||||
|
||||
if (currentSpanText.isNotEmpty()) {
|
||||
currentSpans.add(TextSpan(currentSpanText.toString(), currentSize, currentBold, currentItalic))
|
||||
}
|
||||
if (currentSpans.isNotEmpty()) {
|
||||
lines.add(TextLine(currentSpans))
|
||||
}
|
||||
|
||||
val validLines = lines.filter { it.spans.isNotEmpty() }
|
||||
val lineLengths = validLines.map { line -> line.spans.sumOf { it.text.length } }.filter { it > 10 }.sorted()
|
||||
|
||||
val typicalLineLen = if (lineLengths.isNotEmpty()) {
|
||||
lineLengths[(lineLengths.size * 0.8).toInt().coerceAtMost(lineLengths.size - 1)]
|
||||
} else {
|
||||
80
|
||||
}
|
||||
|
||||
val wrapThreshold = (typicalLineLen * 0.85).toInt()
|
||||
|
||||
val sb = StringBuilder()
|
||||
|
||||
for (i in lines.indices) {
|
||||
val line = lines[i]
|
||||
if (line.spans.isEmpty()) {
|
||||
sb.append("\n")
|
||||
continue
|
||||
}
|
||||
|
||||
val maxFontSize = line.spans.filter { it.text.isNotBlank() }.maxOfOrNull { it.size } ?: baseSize.toFloat()
|
||||
val charBigHeader = maxFontSize > baseSize * 1.5f
|
||||
val charHeader = maxFontSize > baseSize * 1.2f
|
||||
|
||||
var prefix = ""
|
||||
if (charBigHeader) prefix = "## "
|
||||
else if (charHeader) prefix = "### "
|
||||
|
||||
val rawLineText = line.spans.joinToString("") { it.text }
|
||||
val trimmedRaw = rawLineText.trim()
|
||||
val lineLen = trimmedRaw.length
|
||||
|
||||
val isList = trimmedRaw.startsWith("•") ||
|
||||
trimmedRaw.startsWith("- ") ||
|
||||
trimmedRaw.startsWith("▪") ||
|
||||
trimmedRaw.matches(Regex("^[0-9]+\\.\\s.*")) ||
|
||||
trimmedRaw.matches(Regex("^[a-zA-Z]\\)\\s.*"))
|
||||
|
||||
if (prefix.isNotEmpty() && !isList) {
|
||||
sb.append(prefix)
|
||||
}
|
||||
|
||||
for (span in line.spans) {
|
||||
var spanText = span.text
|
||||
spanText = fixKerning(spanText)
|
||||
|
||||
val leadingSpaces = spanText.takeWhile { it.isWhitespace() }
|
||||
val trailingSpaces = spanText.takeLastWhile { it.isWhitespace() }
|
||||
val trimmedText = spanText.trim()
|
||||
|
||||
if (trimmedText.isEmpty()) {
|
||||
sb.append(spanText)
|
||||
continue
|
||||
}
|
||||
|
||||
sb.append(leadingSpaces)
|
||||
|
||||
var tag = ""
|
||||
if (span.isBold && span.isItalic) tag = "***"
|
||||
else if (span.isBold) tag = "**"
|
||||
else if (span.isItalic) tag = "*"
|
||||
|
||||
sb.append(tag).append(trimmedText).append(tag)
|
||||
sb.append(trailingSpaces)
|
||||
}
|
||||
|
||||
var isParagraphBreak = false
|
||||
|
||||
if (prefix.isNotEmpty() || isList) {
|
||||
isParagraphBreak = true
|
||||
} else if (lineLen < wrapThreshold) {
|
||||
isParagraphBreak = true
|
||||
} else if (trimmedRaw.matches(Regex(".*[.!?\"'”’;:*]$"))) {
|
||||
isParagraphBreak = true
|
||||
} else {
|
||||
val nextLine = lines.subList(i + 1, lines.size).firstOrNull { it.spans.isNotEmpty() }
|
||||
if (nextLine != null) {
|
||||
val nextRaw = nextLine.spans.joinToString("") { it.text }.trimStart()
|
||||
if (nextRaw.startsWith("\"") || nextRaw.startsWith("“") || nextRaw.startsWith("-")) {
|
||||
isParagraphBreak = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isParagraphBreak) {
|
||||
sb.append("\n\n")
|
||||
} else {
|
||||
sb.append("\n")
|
||||
}
|
||||
}
|
||||
|
||||
return sb.toString().replace(Regex("\\n{3,}"), "\n\n").trim()
|
||||
}
|
||||
}
|
||||
|
|
@ -27,11 +27,7 @@ import android.annotation.SuppressLint
|
|||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.gestures.waitForUpOrCancellation
|
||||
import android.graphics.Bitmap
|
||||
import kotlin.math.max
|
||||
import android.graphics.RectF
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
|
|
@ -59,8 +55,11 @@ import androidx.compose.foundation.Image
|
|||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.gestures.detectDragGestures
|
||||
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
|
||||
import androidx.compose.foundation.gestures.waitForUpOrCancellation
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
|
|
@ -137,10 +136,8 @@ import androidx.compose.material3.OutlinedTextField
|
|||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.SnackbarDuration
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.SnackbarResult
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Tab
|
||||
import androidx.compose.material3.TabRow
|
||||
|
|
@ -286,6 +283,7 @@ import java.io.ByteArrayOutputStream
|
|||
import java.io.File
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.random.Random
|
||||
|
|
@ -1845,26 +1843,6 @@ 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) {
|
||||
snackbarHostState.currentSnackbarData?.dismiss()
|
||||
val item = uiState.recentFiles.find { it.bookId == reflowBookId }
|
||||
if (item != null) {
|
||||
viewModel.onRecentFileClicked(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(pdfUri) { debugPdfLinks(context, pdfUri, pdfiumCore, this) }
|
||||
|
||||
LaunchedEffect(currentBookId) {
|
||||
|
|
@ -4979,13 +4957,14 @@ fun PdfViewerScreen(
|
|||
if (hasReflowFile) {
|
||||
val item = uiState.recentFiles.find { it.bookId == reflowBookId }
|
||||
if (item != null) {
|
||||
viewModel.onRecentFileClicked(item)
|
||||
viewModel.switchToFileSeamlessly(item, currentPage)
|
||||
}
|
||||
} else {
|
||||
viewModel.generateAndImportReflowFile(
|
||||
pdfBookId = bookId,
|
||||
pdfUri = pdfUri,
|
||||
originalTitle = originalFileName
|
||||
originalTitle = originalFileName,
|
||||
autoOpenPage = currentPage
|
||||
)
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -20,29 +20,49 @@ class ReflowWorker(
|
|||
) : CoroutineWorker(context, params) {
|
||||
|
||||
override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
|
||||
val bookId = inputData.getString(KEY_BOOK_ID) ?: return@withContext Result.failure()
|
||||
val pdfUriString = inputData.getString(KEY_PDF_URI) ?: return@withContext Result.failure()
|
||||
val workStartTime = System.currentTimeMillis()
|
||||
Timber.tag("PdfToMdPerf").d("=== ReflowWorker START ===")
|
||||
|
||||
val bookId = inputData.getString(KEY_BOOK_ID) ?: run {
|
||||
Timber.tag("PdfToMdPerf").e("FAILURE: KEY_BOOK_ID is null")
|
||||
return@withContext Result.failure()
|
||||
}
|
||||
|
||||
val pdfUriString = inputData.getString(KEY_PDF_URI) ?: run {
|
||||
Timber.tag("PdfToMdPerf").e("FAILURE: KEY_PDF_URI is null | bookId=$bookId")
|
||||
return@withContext Result.failure()
|
||||
}
|
||||
val originalTitle = inputData.getString(KEY_ORIGINAL_TITLE) ?: "Document"
|
||||
val reflowBookId = "${bookId}_reflow"
|
||||
|
||||
Timber.tag("PdfToMdPerf").d("Input data | bookId=$bookId | reflowBookId=$reflowBookId | pdfUri=$pdfUriString | originalTitle=$originalTitle")
|
||||
|
||||
val destFile = File(applicationContext.filesDir, "${bookId}_reflow.md")
|
||||
val pdfUri = pdfUriString.toUri()
|
||||
|
||||
Timber.tag("ReflowWorker").d("Starting background reflow for $originalTitle.")
|
||||
Timber.tag("PdfToMdPerf").d("Dest file path: ${destFile.absolutePath} | exists=${destFile.exists()}")
|
||||
Timber.tag("PdfToMdPerf").d("Starting PdfToMarkdownGenerator.generateMarkdownFile...")
|
||||
val genStartTime = System.currentTimeMillis()
|
||||
|
||||
// Delegate entire process to Generator (it now handles the loop and progress)
|
||||
val success = PdfToMarkdownGenerator.generateMarkdownFile(
|
||||
applicationContext,
|
||||
pdfUri,
|
||||
destFile,
|
||||
startPage = 1 // Always start from beginning for full regeneration
|
||||
startPage = 1
|
||||
) { progress ->
|
||||
// Report progress
|
||||
if ((progress * 10).toInt() % 1 == 0) {
|
||||
Timber.tag("PdfToMdPerf").d("Progress: ${(progress * 100).toInt()}%")
|
||||
}
|
||||
setProgressAsync(workDataOf(KEY_PROGRESS to progress))
|
||||
}
|
||||
|
||||
Timber.tag("PdfToMdPerf").d("generateMarkdownFile completed | success=$success | time=${System.currentTimeMillis() - genStartTime}ms")
|
||||
|
||||
if (success && destFile.exists()) {
|
||||
Timber.tag("ReflowWorker").d("Reflow complete. Importing to database.")
|
||||
val fileSizeKB = destFile.length() / 1024
|
||||
Timber.tag("PdfToMdPerf").d("Reflow SUCCESS | outputFileSize=${fileSizeKB}KB")
|
||||
Timber.tag("PdfToMdPerf").d("Starting database import...")
|
||||
val dbStartTime = System.currentTimeMillis()
|
||||
|
||||
val repo = RecentFilesRepository(applicationContext)
|
||||
|
||||
|
|
@ -63,13 +83,16 @@ class ReflowWorker(
|
|||
)
|
||||
|
||||
repo.addRecentFile(newItem)
|
||||
Timber.tag("PdfToMdPerf").d("Database import completed in ${System.currentTimeMillis() - dbStartTime}ms")
|
||||
|
||||
// 100% Progress
|
||||
setProgressAsync(workDataOf(KEY_PROGRESS to 1.0f))
|
||||
|
||||
val totalTime = System.currentTimeMillis() - workStartTime
|
||||
Timber.tag("PdfToMdPerf").d("=== ReflowWorker SUCCESS === | totalTime=${totalTime}ms | totalTimeSec=${totalTime / 1000}s")
|
||||
return@withContext Result.success()
|
||||
} else {
|
||||
Timber.e("Reflow failed or was incomplete.")
|
||||
val totalTime = System.currentTimeMillis() - workStartTime
|
||||
Timber.tag("PdfToMdPerf").e("=== ReflowWorker FAILURE === | success=$success | fileExists=${destFile.exists()} | totalTime=${totalTime}ms")
|
||||
return@withContext Result.failure()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue