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:
Aryan 2026-03-10 10:59:44 +05:30 committed by GitHub
parent acf282d4c7
commit c61a264a65
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 940 additions and 419 deletions

View file

@ -83,7 +83,7 @@ This project is licensed under the **GNU Affero General Public License v3.0 (AGP
## Support
If you find Episteme Reader useful and want to support its development, consider sponsoring.
If you find Episteme Reader useful and want to support its development, please consider sponsoring. Thank you!
<a href="https://github.com/sponsors/Aryan-Raj3112">
<img src="https://img.shields.io/badge/Sponsor-%E2%9D%A4-%23db61a2?logo=github" alt="Sponsor on GitHub"/>

View file

@ -65,12 +65,12 @@ set_target_properties(mobi PROPERTIES C_VISIBILITY_PRESET default)
# FINAL NATIVE LIBRARY FOR THE APP
# ===================================================================
# 6. Define our final JNI wrapper library.
# This single .so file will be loaded by the Android app.
add_library(
native-lib
SHARED
Woff2Converter.cpp
mobi_jni_bridge.c # The placeholder file you created
mobi_jni_bridge.c
pdfium_bridge.cpp # Add this new file
)
# 7. Tell our library where to find all necessary header files.
@ -88,8 +88,9 @@ find_library(z-lib z)
target_link_libraries(
native-lib
PRIVATE
woff2dec # From woff2
mobi # From libmobi
woff2dec
mobi
${log-lib}
${z-lib} # libmobi requires zlib
${z-lib}
dl
)

88
app/src/main/cpp/pdfium_bridge.cpp vendored Normal file
View file

@ -0,0 +1,88 @@
#include <jni.h>
#include <dlfcn.h>
#include <android/log.h>
#define LOG_TAG "PdfiumBridge"
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__)
typedef double (*FPDFText_GetFontSize_t)(void* text_page, int index);
typedef int (*FPDFText_GetFontWeight_t)(void* text_page, int index);
typedef int (*FPDFText_GetFontInfo_t)(void* text_page, int index, void* buffer, unsigned long buflen, int* flags);
static void* pdfium_handle = nullptr;
static FPDFText_GetFontSize_t get_font_size_func = nullptr;
static FPDFText_GetFontWeight_t get_font_weight_func = nullptr;
static FPDFText_GetFontInfo_t get_font_info_func = nullptr;
static bool init_pdfium() {
if (pdfium_handle) return true;
pdfium_handle = dlopen("libpdfium.so", RTLD_LAZY);
if (!pdfium_handle) {
LOGE("Failed to hook into libpdfium.so: %s", dlerror());
return false;
}
get_font_size_func = (FPDFText_GetFontSize_t) dlsym(pdfium_handle, "FPDFText_GetFontSize");
get_font_weight_func = (FPDFText_GetFontWeight_t) dlsym(pdfium_handle, "FPDFText_GetFontWeight");
get_font_info_func = (FPDFText_GetFontInfo_t) dlsym(pdfium_handle, "FPDFText_GetFontInfo");
return get_font_size_func != nullptr && get_font_weight_func != nullptr && get_font_info_func != nullptr;
}
extern "C" JNIEXPORT jdouble JNICALL
Java_com_aryan_reader_pdf_NativePdfiumBridge_getFontSize(JNIEnv *env, jclass clazz, jlong textPagePtr, jint index) {
if (!init_pdfium() || !get_font_size_func) return 0.0;
return get_font_size_func(reinterpret_cast<void*>(textPagePtr), index);
}
extern "C" JNIEXPORT jint JNICALL
Java_com_aryan_reader_pdf_NativePdfiumBridge_getFontWeight(JNIEnv *env, jclass clazz, jlong textPagePtr, jint index) {
if (!init_pdfium() || !get_font_weight_func) return 0;
return get_font_weight_func(reinterpret_cast<void*>(textPagePtr), index);
}
// Bulk extraction for blazing fast formatting processing
extern "C" JNIEXPORT jfloatArray JNICALL
Java_com_aryan_reader_pdf_NativePdfiumBridge_getPageFontSizes(JNIEnv *env, jclass clazz, jlong textPagePtr, jint count) {
if (!init_pdfium() || !get_font_size_func || count <= 0) return nullptr;
jfloatArray result = env->NewFloatArray(count);
jfloat *fill = new jfloat[count];
for(int i = 0; i < count; i++) {
fill[i] = (jfloat)get_font_size_func(reinterpret_cast<void*>(textPagePtr), i);
}
env->SetFloatArrayRegion(result, 0, count, fill);
delete[] fill;
return result;
}
extern "C" JNIEXPORT jintArray JNICALL
Java_com_aryan_reader_pdf_NativePdfiumBridge_getPageFontWeights(JNIEnv *env, jclass clazz, jlong textPagePtr, jint count) {
if (!init_pdfium() || !get_font_weight_func || count <= 0) return nullptr;
jintArray result = env->NewIntArray(count);
jint *fill = new jint[count];
for(int i = 0; i < count; i++) {
fill[i] = (jint)get_font_weight_func(reinterpret_cast<void*>(textPagePtr), i);
}
env->SetIntArrayRegion(result, 0, count, fill);
delete[] fill;
return result;
}
extern "C" JNIEXPORT jintArray JNICALL
Java_com_aryan_reader_pdf_NativePdfiumBridge_getPageFontFlags(JNIEnv *env, jclass clazz, jlong textPagePtr, jint count) {
if (!init_pdfium() || !get_font_info_func || count <= 0) return nullptr;
jintArray result = env->NewIntArray(count);
jint *fill = new jint[count];
for(int i = 0; i < count; i++) {
int flags = 0;
get_font_info_func(reinterpret_cast<void*>(textPagePtr), i, nullptr, 0, &flags);
fill[i] = (jint)flags;
}
env->SetIntArrayRegion(result, 0, count, fill);
delete[] fill;
return result;
}

View file

@ -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

View file

@ -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
)

View file

@ -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(

View 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?
}

View file

@ -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()
}
}

View file

@ -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
)
}
},

View file

@ -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()
}
}