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
|
|
@ -83,7 +83,7 @@ This project is licensed under the **GNU Affero General Public License v3.0 (AGP
|
||||||
|
|
||||||
## Support
|
## 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">
|
<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"/>
|
<img src="https://img.shields.io/badge/Sponsor-%E2%9D%A4-%23db61a2?logo=github" alt="Sponsor on GitHub"/>
|
||||||
|
|
|
||||||
11
app/src/main/cpp/CMakeLists.txt
vendored
11
app/src/main/cpp/CMakeLists.txt
vendored
|
|
@ -65,12 +65,12 @@ set_target_properties(mobi PROPERTIES C_VISIBILITY_PRESET default)
|
||||||
# FINAL NATIVE LIBRARY FOR THE APP
|
# FINAL NATIVE LIBRARY FOR THE APP
|
||||||
# ===================================================================
|
# ===================================================================
|
||||||
# 6. Define our final JNI wrapper library.
|
# 6. Define our final JNI wrapper library.
|
||||||
# This single .so file will be loaded by the Android app.
|
|
||||||
add_library(
|
add_library(
|
||||||
native-lib
|
native-lib
|
||||||
SHARED
|
SHARED
|
||||||
Woff2Converter.cpp
|
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.
|
# 7. Tell our library where to find all necessary header files.
|
||||||
|
|
@ -88,8 +88,9 @@ find_library(z-lib z)
|
||||||
target_link_libraries(
|
target_link_libraries(
|
||||||
native-lib
|
native-lib
|
||||||
PRIVATE
|
PRIVATE
|
||||||
woff2dec # From woff2
|
woff2dec
|
||||||
mobi # From libmobi
|
mobi
|
||||||
${log-lib}
|
${log-lib}
|
||||||
${z-lib} # libmobi requires zlib
|
${z-lib}
|
||||||
|
dl
|
||||||
)
|
)
|
||||||
88
app/src/main/cpp/pdfium_bridge.cpp
vendored
Normal file
88
app/src/main/cpp/pdfium_bridge.cpp
vendored
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -22,6 +22,7 @@ package com.aryan.reader
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import androidx.annotation.RequiresApi
|
import androidx.annotation.RequiresApi
|
||||||
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
|
@ -69,6 +70,36 @@ fun AppNavigation(
|
||||||
Timber.d("AppNavigation composable invoked.")
|
Timber.d("AppNavigation composable invoked.")
|
||||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
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) {
|
NavHost(navController = navController, startDestination = AppDestinations.MAIN_ROUTE) {
|
||||||
composable(AppDestinations.MAIN_ROUTE) {
|
composable(AppDestinations.MAIN_ROUTE) {
|
||||||
Timber.d("Navigating to Main Screen (${AppDestinations.MAIN_ROUTE}).")
|
Timber.d("Navigating to Main Screen (${AppDestinations.MAIN_ROUTE}).")
|
||||||
|
|
@ -77,39 +108,6 @@ fun AppNavigation(
|
||||||
windowSizeClass = windowSizeClass,
|
windowSizeClass = windowSizeClass,
|
||||||
navController = navController
|
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
|
// PDF Viewer Screen Composable
|
||||||
|
|
@ -119,37 +117,53 @@ fun AppNavigation(
|
||||||
val initialPage = uiState.initialPageInBook
|
val initialPage = uiState.initialPageInBook
|
||||||
val initialBookmarksJson = uiState.initialBookmarksJson
|
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) {
|
if (pdfUri != null) {
|
||||||
Timber.i("Displaying PDF Viewer for URI: $pdfUri, initialPage: $initialPage")
|
Timber.i("Displaying PDF Viewer for URI: $pdfUri, initialPage: $initialPage")
|
||||||
PdfViewerScreen(
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
pdfUri = pdfUri,
|
PdfViewerScreen(
|
||||||
initialPage = initialPage,
|
pdfUri = pdfUri,
|
||||||
initialBookmarksJson = initialBookmarksJson,
|
initialPage = initialPage,
|
||||||
isProUser = uiState.isProUser,
|
initialBookmarksJson = initialBookmarksJson,
|
||||||
onNavigateBack = {
|
isProUser = uiState.isProUser,
|
||||||
Timber.d("Back action triggered from PDF Viewer.")
|
onNavigateBack = {
|
||||||
viewModel.clearSelectedFile()
|
Timber.d("Back action triggered from PDF Viewer.")
|
||||||
},
|
viewModel.clearSelectedFile()
|
||||||
onSavePosition = viewModel::savePdfReadingPosition,
|
},
|
||||||
onBookmarksChanged = { bookmarksJson ->
|
onSavePosition = viewModel::savePdfReadingPosition,
|
||||||
if (bookId != null) {
|
onBookmarksChanged = { bookmarksJson ->
|
||||||
viewModel.saveBookmarks(bookId, bookmarksJson)
|
if (bookId != null) {
|
||||||
} else {
|
viewModel.saveBookmarks(bookId, bookmarksJson)
|
||||||
Timber.w("Could not find bookId to save PDF bookmarks for URI: ${uiState.selectedPdfUri}")
|
} 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)
|
} else if (uiState.isLoading) {
|
||||||
},
|
Timber.d("PDF URI is null but loading is in progress. Showing loading indicator.")
|
||||||
viewModel = viewModel
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
)
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
Timber.w("PDF URI is null in ViewModel state while on PDF screen. Navigating back to Main.")
|
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
|
uiState.recentFiles.find { it.uriString == uiState.selectedEpubUri.toString() }?.bookId
|
||||||
val customFonts by viewModel.customFonts.collectAsStateWithLifecycle()
|
val customFonts by viewModel.customFonts.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
EpubReaderScreen(
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
epubBook = epubBook,
|
EpubReaderScreen(
|
||||||
renderMode = renderMode,
|
epubBook = epubBook,
|
||||||
initialLocator = initialLocator,
|
renderMode = renderMode,
|
||||||
initialCfi = initialCfi,
|
initialLocator = initialLocator,
|
||||||
initialBookmarksJson = initialBookmarksJson,
|
initialCfi = initialCfi,
|
||||||
isProUser = uiState.isProUser,
|
initialBookmarksJson = initialBookmarksJson,
|
||||||
coverImagePath = coverPath,
|
isProUser = uiState.isProUser,
|
||||||
onNavigateBack = {
|
coverImagePath = coverPath,
|
||||||
Timber.d("Back action from EPUB Reader. Clearing selected file to navigate home.")
|
onNavigateBack = {
|
||||||
viewModel.clearSelectedFile()
|
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%")
|
onSavePosition = { locator, cfiForWebView, progress ->
|
||||||
epubUri?.let { uri ->
|
Timber.d("Auto-saving EPUB position: Locator $locator, Progress $progress%")
|
||||||
viewModel.saveEpubReadingPosition(uri, locator, cfiForWebView, 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 -> {
|
isLoading -> {
|
||||||
Timber.d("EPUB Reader: Showing loading indicator.")
|
Timber.d("EPUB Reader: Showing loading indicator.")
|
||||||
|
|
@ -231,9 +258,6 @@ fun AppNavigation(
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
Timber.w("EPUB Book is null and not loading/error state on EPUB screen. Navigating back.")
|
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
|
color = if (useOnlineDictionary) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = "Contextual definitions powered by AI.",
|
text = "Definitions powered by AI.",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = if (useOnlineDictionary) MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f) else MaterialTheme.colorScheme.onSurfaceVariant
|
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 isReflowFile = uiState.selectedBookId?.endsWith("_reflow") == true
|
||||||
val originalBookId = if (isReflowFile) uiState.selectedBookId!!.removeSuffix("_reflow") else null
|
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 }
|
val originalItem = uiState.recentFiles.find { it.bookId == originalBookId }
|
||||||
if (originalItem != null) {
|
if (originalItem != null) {
|
||||||
viewModel.onRecentFileClicked(originalItem)
|
viewModel.switchToFileSeamlessly(originalItem, currentChapter)
|
||||||
} else {
|
} else {
|
||||||
viewModel.showBanner("Original PDF not found.", true)
|
viewModel.showBanner("Original PDF not found.", true)
|
||||||
}
|
}
|
||||||
|
|
@ -388,7 +388,7 @@ fun EpubReaderHost(
|
||||||
onRenderModeChange: (RenderMode) -> Unit,
|
onRenderModeChange: (RenderMode) -> Unit,
|
||||||
customFonts: List<CustomFontEntity>,
|
customFonts: List<CustomFontEntity>,
|
||||||
onImportFont: (Uri) -> Unit,
|
onImportFont: (Uri) -> Unit,
|
||||||
onToggleReflow: (() -> Unit)? = null
|
onToggleReflow: ((Int) -> Unit)? = null
|
||||||
) {
|
) {
|
||||||
val view = LocalView.current
|
val view = LocalView.current
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|
@ -3114,7 +3114,16 @@ fun EpubReaderHost(
|
||||||
onOpenTtsSettings = { showTtsSettingsSheet = true },
|
onOpenTtsSettings = { showTtsSettingsSheet = true },
|
||||||
onOpenDictionarySettings = { showDictionarySettingsSheet = true },
|
onOpenDictionarySettings = { showDictionarySettingsSheet = true },
|
||||||
onOpenDeviceVoiceSettings = { showDeviceVoiceSettingsSheet = 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(
|
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.content.Context
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import com.tom_roush.pdfbox.io.MemoryUsageSetting
|
import io.legere.pdfiumandroid.PdfiumCore
|
||||||
import com.tom_roush.pdfbox.pdmodel.PDDocument
|
import io.legere.pdfiumandroid.suspend.PdfiumCoreKt
|
||||||
import com.tom_roush.pdfbox.pdmodel.PDPage
|
import io.legere.pdfiumandroid.suspend.PdfDocumentKt
|
||||||
import com.tom_roush.pdfbox.text.PDFTextStripper
|
|
||||||
import com.tom_roush.pdfbox.text.TextPosition
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
@ -15,8 +13,6 @@ import java.io.File
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
object PdfToMarkdownGenerator {
|
object PdfToMarkdownGenerator {
|
||||||
|
|
||||||
// Unique delimiter to split pages reliably
|
|
||||||
const val PAGE_DELIMITER = "\n\n[[PAGE_BREAK]]\n\n"
|
const val PAGE_DELIMITER = "\n\n[[PAGE_BREAK]]\n\n"
|
||||||
|
|
||||||
suspend fun generateMarkdownFile(
|
suspend fun generateMarkdownFile(
|
||||||
|
|
@ -26,109 +22,252 @@ object PdfToMarkdownGenerator {
|
||||||
startPage: Int = 1,
|
startPage: Int = 1,
|
||||||
onProgress: (Float) -> Unit
|
onProgress: (Float) -> Unit
|
||||||
): Boolean = withContext(Dispatchers.IO) {
|
): 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 {
|
try {
|
||||||
context.contentResolver.openInputStream(pdfUri)?.use { inputStream ->
|
val doc = pdfiumCore.newDocument(pfd)
|
||||||
// Setup mixed memory usage to handle larger files without OOM
|
val totalPages = doc.getPageCount()
|
||||||
PDDocument.load(inputStream, MemoryUsageSetting.setupMixed(50 * 1024 * 1024)).use { doc ->
|
Timber.tag("PdfToMdPerf").d("Document loaded natively. Total Pages: $totalPages")
|
||||||
val totalPages = doc.numberOfPages
|
|
||||||
|
|
||||||
// Configure stripper for linear processing
|
destFile.bufferedWriter().use { writer ->
|
||||||
val stripper = MarkdownStripper(totalPages, onProgress)
|
for (pageIdx in (startPage - 1) until totalPages) {
|
||||||
stripper.startPage = startPage
|
val pageMd = extractPageMarkdown(doc, pageIdx)
|
||||||
stripper.endPage = totalPages
|
writer.write(pageMd)
|
||||||
|
writer.write(PAGE_DELIMITER)
|
||||||
|
|
||||||
// Write directly to file stream (O(N) complexity)
|
if (pageIdx % 5 == 0 || pageIdx == totalPages - 1) {
|
||||||
destFile.bufferedWriter().use { writer ->
|
onProgress((pageIdx + 1).toFloat() / totalPages.toFloat())
|
||||||
stripper.writeText(doc, writer)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
doc.close()
|
||||||
|
pfd.close()
|
||||||
|
|
||||||
|
Timber.tag("PdfToMdPerf").d("generateMarkdownFile NATIVE SUCCESS | totalTime=${System.currentTimeMillis() - methodStartTime}ms")
|
||||||
return@withContext true
|
return@withContext true
|
||||||
} catch (e: Exception) {
|
} 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
|
return@withContext false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private class MarkdownStripper(
|
private suspend fun extractPageMarkdown(doc: PdfDocumentKt, pageIdx: Int): String {
|
||||||
private val totalPages: Int,
|
return try {
|
||||||
private val onProgress: (Float) -> Unit
|
doc.openPage(pageIdx).use { page ->
|
||||||
) : PDFTextStripper() {
|
page.openTextPage().use { textPage ->
|
||||||
private var currentPageBaseFontSize = 0f
|
val charCount = textPage.textPageCountChars()
|
||||||
|
if (charCount <= 0) return@use ""
|
||||||
|
|
||||||
init {
|
val text = textPage.textPageGetText(0, charCount) ?: ""
|
||||||
sortByPosition = true
|
val actualCount = minOf(charCount, text.length)
|
||||||
suppressDuplicateOverlappingText = true
|
|
||||||
paragraphStart = ""
|
|
||||||
paragraphEnd = "\n\n"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Override endPage to update progress and insert delimiter
|
val rawPtr = textPage.page.pagePtr
|
||||||
override fun endPage(page: PDPage?) {
|
|
||||||
super.endPage(page)
|
|
||||||
|
|
||||||
try {
|
val sizes: FloatArray?
|
||||||
// Insert our custom delimiter so importer can split chapters
|
val weights: IntArray?
|
||||||
output.write(PAGE_DELIMITER)
|
val flags: IntArray?
|
||||||
|
|
||||||
// Update progress
|
synchronized(PdfiumCore.lock) {
|
||||||
val current = currentPageNo // inherited from PDFTextStripper
|
sizes = NativePdfiumBridge.getPageFontSizes(rawPtr, actualCount)
|
||||||
if (totalPages > 0) {
|
weights = NativePdfiumBridge.getPageFontWeights(rawPtr, actualCount)
|
||||||
onProgress(current.toFloat() / totalPages.toFloat())
|
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")
|
|
||||||
}
|
}
|
||||||
}
|
} catch (e: Exception) {
|
||||||
|
Timber.w(e, "Error extracting page $pageIdx")
|
||||||
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())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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.app.Activity
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.pm.PackageManager
|
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 android.graphics.Bitmap
|
||||||
import kotlin.math.max
|
|
||||||
import android.graphics.RectF
|
import android.graphics.RectF
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
|
@ -59,8 +55,11 @@ import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
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.detectDragGestures
|
||||||
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
|
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
|
||||||
|
import androidx.compose.foundation.gestures.waitForUpOrCancellation
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
|
@ -137,10 +136,8 @@ import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.RadioButton
|
import androidx.compose.material3.RadioButton
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Slider
|
import androidx.compose.material3.Slider
|
||||||
import androidx.compose.material3.SnackbarDuration
|
|
||||||
import androidx.compose.material3.SnackbarHost
|
import androidx.compose.material3.SnackbarHost
|
||||||
import androidx.compose.material3.SnackbarHostState
|
import androidx.compose.material3.SnackbarHostState
|
||||||
import androidx.compose.material3.SnackbarResult
|
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Tab
|
import androidx.compose.material3.Tab
|
||||||
import androidx.compose.material3.TabRow
|
import androidx.compose.material3.TabRow
|
||||||
|
|
@ -286,6 +283,7 @@ import java.io.ByteArrayOutputStream
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.net.HttpURLConnection
|
import java.net.HttpURLConnection
|
||||||
import java.net.URL
|
import java.net.URL
|
||||||
|
import kotlin.math.max
|
||||||
import kotlin.math.min
|
import kotlin.math.min
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
import kotlin.random.Random
|
import kotlin.random.Random
|
||||||
|
|
@ -1845,26 +1843,6 @@ fun PdfViewerScreen(
|
||||||
onToggleBookmark(currentPage)
|
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(pdfUri) { debugPdfLinks(context, pdfUri, pdfiumCore, this) }
|
||||||
|
|
||||||
LaunchedEffect(currentBookId) {
|
LaunchedEffect(currentBookId) {
|
||||||
|
|
@ -4979,13 +4957,14 @@ fun PdfViewerScreen(
|
||||||
if (hasReflowFile) {
|
if (hasReflowFile) {
|
||||||
val item = uiState.recentFiles.find { it.bookId == reflowBookId }
|
val item = uiState.recentFiles.find { it.bookId == reflowBookId }
|
||||||
if (item != null) {
|
if (item != null) {
|
||||||
viewModel.onRecentFileClicked(item)
|
viewModel.switchToFileSeamlessly(item, currentPage)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
viewModel.generateAndImportReflowFile(
|
viewModel.generateAndImportReflowFile(
|
||||||
pdfBookId = bookId,
|
pdfBookId = bookId,
|
||||||
pdfUri = pdfUri,
|
pdfUri = pdfUri,
|
||||||
originalTitle = originalFileName
|
originalTitle = originalFileName,
|
||||||
|
autoOpenPage = currentPage
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -20,29 +20,49 @@ class ReflowWorker(
|
||||||
) : CoroutineWorker(context, params) {
|
) : CoroutineWorker(context, params) {
|
||||||
|
|
||||||
override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
|
override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
|
||||||
val bookId = inputData.getString(KEY_BOOK_ID) ?: return@withContext Result.failure()
|
val workStartTime = System.currentTimeMillis()
|
||||||
val pdfUriString = inputData.getString(KEY_PDF_URI) ?: return@withContext Result.failure()
|
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 originalTitle = inputData.getString(KEY_ORIGINAL_TITLE) ?: "Document"
|
||||||
val reflowBookId = "${bookId}_reflow"
|
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 destFile = File(applicationContext.filesDir, "${bookId}_reflow.md")
|
||||||
val pdfUri = pdfUriString.toUri()
|
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(
|
val success = PdfToMarkdownGenerator.generateMarkdownFile(
|
||||||
applicationContext,
|
applicationContext,
|
||||||
pdfUri,
|
pdfUri,
|
||||||
destFile,
|
destFile,
|
||||||
startPage = 1 // Always start from beginning for full regeneration
|
startPage = 1
|
||||||
) { progress ->
|
) { progress ->
|
||||||
// Report progress
|
if ((progress * 10).toInt() % 1 == 0) {
|
||||||
|
Timber.tag("PdfToMdPerf").d("Progress: ${(progress * 100).toInt()}%")
|
||||||
|
}
|
||||||
setProgressAsync(workDataOf(KEY_PROGRESS to progress))
|
setProgressAsync(workDataOf(KEY_PROGRESS to progress))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Timber.tag("PdfToMdPerf").d("generateMarkdownFile completed | success=$success | time=${System.currentTimeMillis() - genStartTime}ms")
|
||||||
|
|
||||||
if (success && destFile.exists()) {
|
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)
|
val repo = RecentFilesRepository(applicationContext)
|
||||||
|
|
||||||
|
|
@ -63,13 +83,16 @@ class ReflowWorker(
|
||||||
)
|
)
|
||||||
|
|
||||||
repo.addRecentFile(newItem)
|
repo.addRecentFile(newItem)
|
||||||
|
Timber.tag("PdfToMdPerf").d("Database import completed in ${System.currentTimeMillis() - dbStartTime}ms")
|
||||||
|
|
||||||
// 100% Progress
|
|
||||||
setProgressAsync(workDataOf(KEY_PROGRESS to 1.0f))
|
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()
|
return@withContext Result.success()
|
||||||
} else {
|
} 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()
|
return@withContext Result.failure()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue