diff --git a/app/src/main/cpp/pdfium_bridge.cpp b/app/src/main/cpp/pdfium_bridge.cpp index 401b4f7..e278bd0 100644 --- a/app/src/main/cpp/pdfium_bridge.cpp +++ b/app/src/main/cpp/pdfium_bridge.cpp @@ -12,13 +12,34 @@ 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); +typedef int (*FPDFText_GetCharBox_t)(void* text_page, int index, double* left, double* right, double* bottom, double* top); typedef int (*FPDFPage_GetAnnotCount_t)(void* page); typedef void* (*FPDFPage_GetAnnot_t)(void* page, int index); typedef int (*FPDFAnnot_GetSubtype_t)(void* annot); typedef int (*FPDFAnnot_GetRect_t)(void* annot, void* rect); typedef unsigned long (*FPDFAnnot_GetStringValue_t)(void* annot, const char* key, void* buffer, unsigned long buflen); typedef int (*FPDFAnnot_GetColor_t)(void* annot, int type, unsigned int* R, unsigned int* G, unsigned int* B, unsigned int* A); +typedef int (*FPDFPage_CountObjects_t)(void* page); +typedef void* (*FPDFPage_GetObject_t)(void* page, int index); +typedef int (*FPDFPageObj_GetType_t)(void* page_object); +typedef void* (*FPDFImageObj_GetBitmap_t)(void* image_object); +typedef int (*FPDFBitmap_GetWidth_t)(void* bitmap); +typedef int (*FPDFBitmap_GetHeight_t)(void* bitmap); +typedef int (*FPDFBitmap_GetStride_t)(void* bitmap); +typedef void* (*FPDFBitmap_GetBuffer_t)(void* bitmap); +typedef void (*FPDFBitmap_Destroy_t)(void* bitmap); +typedef int (*FPDFPageObj_GetBounds_t)(void* page_object, float* left, float* bottom, float* right, float* top); +static FPDFPage_CountObjects_t count_objects_func = nullptr; +static FPDFPage_GetObject_t get_object_func = nullptr; +static FPDFPageObj_GetType_t get_object_type_func = nullptr; +static FPDFImageObj_GetBitmap_t get_image_bitmap_func = nullptr; +static FPDFBitmap_GetWidth_t bitmap_get_width_func = nullptr; +static FPDFBitmap_GetHeight_t bitmap_get_height_func = nullptr; +static FPDFBitmap_GetStride_t bitmap_get_stride_func = nullptr; +static FPDFBitmap_GetBuffer_t bitmap_get_buffer_func = nullptr; +static FPDFBitmap_Destroy_t bitmap_destroy_func = nullptr; +static FPDFPageObj_GetBounds_t get_object_bounds_func = nullptr; static FPDFPage_GetAnnotCount_t get_annot_count_func = nullptr; static FPDFPage_GetAnnot_t get_annot_func = nullptr; static FPDFAnnot_GetSubtype_t get_annot_subtype_func = nullptr; @@ -29,6 +50,7 @@ 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 FPDFText_GetCharBox_t get_char_box_func = nullptr; typedef void* (*FPDFAnnot_GetLinkedAnnot_t)(void* annot, const char* key); typedef void (*FPDFPage_CloseAnnot_t)(void* annot); @@ -48,6 +70,7 @@ static bool init_pdfium() { 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"); + get_char_box_func = (FPDFText_GetCharBox_t) dlsym(pdfium_handle, "FPDFText_GetCharBox"); get_annot_count_func = (FPDFPage_GetAnnotCount_t) dlsym(pdfium_handle, "FPDFPage_GetAnnotCount"); get_annot_func = (FPDFPage_GetAnnot_t) dlsym(pdfium_handle, "FPDFPage_GetAnnot"); @@ -58,6 +81,17 @@ static bool init_pdfium() { get_linked_annot_func = (FPDFAnnot_GetLinkedAnnot_t) dlsym(pdfium_handle, "FPDFAnnot_GetLinkedAnnot"); close_annot_func = (FPDFPage_CloseAnnot_t) dlsym(pdfium_handle, "FPDFPage_CloseAnnot"); + count_objects_func = (FPDFPage_CountObjects_t) dlsym(pdfium_handle, "FPDFPage_CountObjects"); + get_object_func = (FPDFPage_GetObject_t) dlsym(pdfium_handle, "FPDFPage_GetObject"); + get_object_type_func = (FPDFPageObj_GetType_t) dlsym(pdfium_handle, "FPDFPageObj_GetType"); + get_image_bitmap_func = (FPDFImageObj_GetBitmap_t) dlsym(pdfium_handle, "FPDFImageObj_GetBitmap"); + bitmap_get_width_func = (FPDFBitmap_GetWidth_t) dlsym(pdfium_handle, "FPDFBitmap_GetWidth"); + bitmap_get_height_func = (FPDFBitmap_GetHeight_t) dlsym(pdfium_handle, "FPDFBitmap_GetHeight"); + bitmap_get_stride_func = (FPDFBitmap_GetStride_t) dlsym(pdfium_handle, "FPDFBitmap_GetStride"); + bitmap_get_buffer_func = (FPDFBitmap_GetBuffer_t) dlsym(pdfium_handle, "FPDFBitmap_GetBuffer"); + bitmap_destroy_func = (FPDFBitmap_Destroy_t) dlsym(pdfium_handle, "FPDFBitmap_Destroy"); + get_object_bounds_func = (FPDFPageObj_GetBounds_t) dlsym(pdfium_handle, "FPDFPageObj_GetBounds"); + bool success = get_annot_count_func && get_annot_func && get_annot_subtype_func && get_annot_rect_func && get_annot_string_func; @@ -127,6 +161,27 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_getPageFontFlags(JNIEnv *env, jclas return result; } +extern "C" JNIEXPORT jfloatArray JNICALL +Java_com_aryan_reader_pdf_NativePdfiumBridge_getPageCharBoxes(JNIEnv *env, jclass clazz, jlong textPagePtr, jint count) { + if (!init_pdfium() || !get_char_box_func || count <= 0) return nullptr; + + const int stride = 4; + jfloatArray result = env->NewFloatArray(count * stride); + jfloat *fill = new jfloat[count * stride]; + void* tp = reinterpret_cast(textPagePtr); + for (int i = 0; i < count; i++) { + double left = 0, right = 0, bottom = 0, top = 0; + get_char_box_func(tp, i, &left, &right, &bottom, &top); + fill[i * stride + 0] = (jfloat)left; + fill[i * stride + 1] = (jfloat)bottom; + fill[i * stride + 2] = (jfloat)right; + fill[i * stride + 3] = (jfloat)top; + } + env->SetFloatArrayRegion(result, 0, count * stride, fill); + delete[] fill; + return result; +} + extern "C" JNIEXPORT jint JNICALL Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotCount(JNIEnv *env, jclass clazz, jlong pagePtr) { if (!init_pdfium() || !get_annot_count_func) return 0; @@ -191,5 +246,84 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotString(JNIEnv *env, jclass jstring result = env->NewString(reinterpret_cast(buffer.data()), (jsize)(buffer.size() - 1)); env->ReleaseStringUTFChars(key, nativeKey); + return result; +} + +extern "C" JNIEXPORT jint JNICALL +Java_com_aryan_reader_pdf_NativePdfiumBridge_getPageObjectCount(JNIEnv *env, jclass clazz, jlong pagePtr) { + if (!init_pdfium() || !count_objects_func) return 0; + return count_objects_func(reinterpret_cast(pagePtr)); +} + +extern "C" JNIEXPORT jint JNICALL +Java_com_aryan_reader_pdf_NativePdfiumBridge_getPageObjectType(JNIEnv *env, jclass clazz, jlong pagePtr, jint index) { + if (!init_pdfium() || !get_object_func || !get_object_type_func) return 0; + void* obj = get_object_func(reinterpret_cast(pagePtr), index); + return obj ? get_object_type_func(obj) : 0; +} + +extern "C" JNIEXPORT jboolean JNICALL +Java_com_aryan_reader_pdf_NativePdfiumBridge_getPageObjectBoundingBox(JNIEnv *env, jclass clazz, jlong pagePtr, jint index, jfloatArray outRect) { + if (!init_pdfium() || !get_object_func || !get_object_bounds_func) return JNI_FALSE; + void* obj = get_object_func(reinterpret_cast(pagePtr), index); + if (!obj) return JNI_FALSE; + + float left = 0, bottom = 0, right = 0, top = 0; + if (get_object_bounds_func(obj, &left, &bottom, &right, &top)) { + jfloat rect[4] = {left, bottom, right, top}; + env->SetFloatArrayRegion(outRect, 0, 4, rect); + return JNI_TRUE; + } + return JNI_FALSE; +} + +extern "C" JNIEXPORT jintArray JNICALL +Java_com_aryan_reader_pdf_NativePdfiumBridge_extractImagePixels(JNIEnv *env, jclass clazz, jlong pagePtr, jint index, jintArray dimens) { + if (!init_pdfium() || !get_object_func || !get_image_bitmap_func || !bitmap_get_buffer_func) return nullptr; + + void* obj = get_object_func(reinterpret_cast(pagePtr), index); + if (!obj || get_object_type_func(obj) != 3) return nullptr; // 3 = FPDF_PAGEOBJ_IMAGE + + void* bmp = get_image_bitmap_func(obj); + if (!bmp) return nullptr; + + int w = bitmap_get_width_func(bmp); + int h = bitmap_get_height_func(bmp); + int stride = bitmap_get_stride_func(bmp); + uint8_t* buffer = (uint8_t*)bitmap_get_buffer_func(bmp); + + if (!buffer || w <= 0 || h <= 0) { + bitmap_destroy_func(bmp); + return nullptr; + } + + jintArray result = env->NewIntArray(w * h); + jint* pixels = new jint[w * h]; + int bpp = stride / w; + + for (int y = 0; y < h; y++) { + uint8_t* row = buffer + y * stride; + for (int x = 0; x < w; x++) { + uint8_t r = 0, g = 0, b = 0, a = 255; + if (bpp >= 3) { + b = row[x * bpp + 0]; + g = row[x * bpp + 1]; + r = row[x * bpp + 2]; + if (bpp >= 4) a = row[x * bpp + 3]; + } else if (bpp == 1) { + r = g = b = row[x]; + } + // Pack pixels for Android ARGB_8888 + pixels[y * w + x] = (a << 24) | (r << 16) | (g << 8) | b; + } + } + + env->SetIntArrayRegion(result, 0, w * h, pixels); + delete[] pixels; + bitmap_destroy_func(bmp); + + jint dims[2] = {w, h}; + env->SetIntArrayRegion(dimens, 0, 2, dims); + return result; } \ No newline at end of file diff --git a/app/src/main/java/com/aryan/reader/MainViewModel.kt b/app/src/main/java/com/aryan/reader/MainViewModel.kt index efba026..9dfd813 100644 --- a/app/src/main/java/com/aryan/reader/MainViewModel.kt +++ b/app/src/main/java/com/aryan/reader/MainViewModel.kt @@ -169,7 +169,6 @@ data class ReaderScreenState( val selectedFileType: FileType? = null, val isLoading: Boolean = false, val errorMessage: String? = null, - val recentFiles: List = emptyList(), val contextualActionItems: Set = emptySet(), val renderMode: RenderMode = RenderMode.VERTICAL_SCROLL, val sortOrder: SortOrder = SortOrder.RECENT, @@ -206,7 +205,9 @@ data class ReaderScreenState( val searchQuery: String = "", val showFolderMigrationDialog: Boolean = false, val isRefreshing: Boolean = false, - val reflowProgress: Float? = null + val reflowProgress: Float? = null, + val recentFiles: List = emptyList(), + val allRecentFiles: List = emptyList(), ) open class MainViewModel(application: Application) : AndroidViewModel(application) { @@ -300,61 +301,42 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio open val uiState: StateFlow = combine( _internalState, recentFilesRepository.getRecentFilesFlow(), _prefsUpdateFlow ) { internalState, recentFilesFromDb, _ -> - val validContextualItems = internalState.contextualActionItems.filter { contextItem -> - recentFilesFromDb.any { dbItem -> - dbItem.uriString == contextItem.uriString - } - }.toSet() - - if (validContextualItems.size != internalState.contextualActionItems.size) { - Timber.d( - "Contextual items updated due to recent files change. Before: ${internalState.contextualActionItems.size}, After: ${validContextualItems.size}" - ) - } - val query = internalState.searchQuery.trim() - val filteredFiles = if (query.isBlank()) { + val rawFilteredByQuery = if (query.isBlank()) { recentFilesFromDb } else { recentFilesFromDb.filter { item -> - item.displayName.contains(query, ignoreCase = true) || item.title?.contains( - query, ignoreCase = true - ) == true || item.author?.contains(query, ignoreCase = true) == true + item.displayName.contains(query, ignoreCase = true) || + item.title?.contains(query, ignoreCase = true) == true || + item.author?.contains(query, ignoreCase = true) == true } } - val sortedRecentFiles = when (internalState.sortOrder) { - SortOrder.RECENT -> filteredFiles // Changed from recentFilesFromDb - SortOrder.TITLE_ASC -> filteredFiles.sortedBy { - it.title?.lowercase() ?: it.displayName.lowercase() - } - - SortOrder.AUTHOR_ASC -> filteredFiles.sortedWith( - compareBy(nullsLast()) { - it.author?.lowercase() - }) - - SortOrder.PERCENT_ASC -> filteredFiles.sortedBy { it.progressPercentage ?: 0f } - SortOrder.PERCENT_DESC -> filteredFiles.sortedByDescending { - it.progressPercentage ?: 0f - } + val sortedAllFiles = when (internalState.sortOrder) { + SortOrder.RECENT -> rawFilteredByQuery + SortOrder.TITLE_ASC -> rawFilteredByQuery.sortedBy { it.title?.lowercase() ?: it.displayName.lowercase() } + SortOrder.AUTHOR_ASC -> rawFilteredByQuery.sortedWith(compareBy(nullsLast()) { it.author?.lowercase() }) + SortOrder.PERCENT_ASC -> rawFilteredByQuery.sortedBy { it.progressPercentage ?: 0f } + SortOrder.PERCENT_DESC -> rawFilteredByQuery.sortedByDescending { it.progressPercentage ?: 0f } } + val visibleRecentFiles = sortedAllFiles.filterNot { it.bookId.endsWith("_reflow") } + + val validContextualItems = internalState.contextualActionItems.filter { contextItem -> + visibleRecentFiles.any { dbItem -> dbItem.uriString == contextItem.uriString } + }.toSet() + val shelfNames = prefs.getStringSet(KEY_SHELVES, emptySet()) ?: emptySet() val shelvedBookIds = mutableSetOf() val shelvesFromPrefs = shelfNames.map { shelfName -> - val bookIds = prefs.getStringSet( - "$KEY_SHELF_CONTENT_PREFIX$shelfName", emptySet() - ) ?: emptySet() - shelvedBookIds.addAll(bookIds) - val booksForShelf = sortedRecentFiles.filter { - it.bookId in bookIds - } + val bookIds = prefs.getStringSet("$KEY_SHELF_CONTENT_PREFIX$shelfName", emptySet()) ?: emptySet() + val booksForShelf = visibleRecentFiles.filter { it.bookId in bookIds } + shelvedBookIds.addAll(booksForShelf.map { it.bookId }) Shelf(shelfName, booksForShelf) }.sortedBy { it.name } - val unshelvedBooks = sortedRecentFiles.filter { it.bookId !in shelvedBookIds } + val unshelvedBooks = visibleRecentFiles.filter { it.bookId !in shelvedBookIds } val allShelves = shelvesFromPrefs + Shelf("Unshelved", unshelvedBooks) val booksAvailableForAdding = @@ -365,7 +347,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio when (internalState.addBooksSource) { AddBooksSource.UNSHELVED -> unshelvedBooks - AddBooksSource.ALL_BOOKS -> sortedRecentFiles.filter { + AddBooksSource.ALL_BOOKS -> visibleRecentFiles.filter { it.uriString !in currentShelfBooksUris } } @@ -374,7 +356,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } internalState.copy( - recentFiles = sortedRecentFiles, + recentFiles = visibleRecentFiles, + allRecentFiles = sortedAllFiles, contextualActionItems = validContextualItems, shelves = allShelves, booksAvailableForAdding = booksAvailableForAdding @@ -751,6 +734,29 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio viewModelScope.launch { fontsRepository.deleteFont(fontId) } } + fun deleteBookPermanently(bookId: String, onDeleted: () -> Unit = {}) { + viewModelScope.launch { + val item = recentFilesRepository.getFileByBookId(bookId) ?: return@launch + + Timber.d("Deleting book permanently from reader: $bookId") + + pdfTextRepository.clearBookText(bookId) + try { + val cacheDir = File(appContext.cacheDir, "imported_file_$bookId") + if (cacheDir.exists()) cacheDir.deleteRecursively() + } catch (e: Exception) { + Timber.e(e, "Failed to clear cache for $bookId") + } + + recentFilesRepository.deleteFilePermanently(listOf(bookId)) + + withContext(Dispatchers.Main) { + onDeleted() + showBanner("Text view deleted.") + } + } + } + private fun getInstallationId(): String { var installationId = prefs.getString(KEY_INSTALLATION_ID, null) if (installationId == null) { diff --git a/app/src/main/java/com/aryan/reader/epub/SingleFileImporter.kt b/app/src/main/java/com/aryan/reader/epub/SingleFileImporter.kt index 2617344..ff29632 100644 --- a/app/src/main/java/com/aryan/reader/epub/SingleFileImporter.kt +++ b/app/src/main/java/com/aryan/reader/epub/SingleFileImporter.kt @@ -21,7 +21,6 @@ package com.aryan.reader.epub import android.content.Context import com.aryan.reader.FileType -import com.aryan.reader.pdf.PdfToMarkdownGenerator import com.vladsch.flexmark.ext.autolink.AutolinkExtension import com.vladsch.flexmark.ext.gfm.strikethrough.StrikethroughExtension import com.vladsch.flexmark.ext.gfm.tasklist.TaskListExtension @@ -114,11 +113,10 @@ class SingleFileImporter(private val context: Context) { hr { border: 0; border-top: 1px solid #ccc; margin: 2em 0; } """.trimIndent() - val delimiter = PdfToMarkdownGenerator.PAGE_DELIMITER.trim() - val rawChapters = if (markdownContent.contains(delimiter)) { - markdownContent.split(delimiter) - } else { + val rawChapters = if (markdownContent.contains("\n\n---\n\n")) { markdownContent.split("\n\n---\n\n") + } else { + listOf(markdownContent) } Timber.tag("FileOpenPerf").d("[MD] parseMarkdown: Split into ${rawChapters.size} raw chapters | elapsed=${System.currentTimeMillis() - parseStart}ms") @@ -378,65 +376,62 @@ class SingleFileImporter(private val context: Context) { val author = doc.select("meta[name=author]").attr("content").takeIf { it.isNotBlank() } ?: doc.select("meta[property=article:author]").attr("content").takeIf { it.isNotBlank() } - val finalHtml = doc.outerHtml() + val cssStyle = doc.select("style").html() + val bodyHtml = doc.body().html() - Timber.tag("FileOpenPerf").d("[HTML] parseHtml COMPLETE | elapsed=${System.currentTimeMillis() - parseStart}ms") - createBookFromHtmlBody(title, null, null, originalBookNameHint, bookId, extractionDir, metadataFile, preGeneratedFullHtml = finalHtml, author = author) - } - - private fun createBookFromHtmlBody( - title: String, - @Suppress("SameParameterValue") bodyContent: String?, - @Suppress("SameParameterValue")cssStyle: String?, - fileName: String, - bookId: String, - extractionDir: File, - metadataFile: File, - preGeneratedFullHtml: String? = null, - author: String? = null - ): EpubBook { - val fullHtml = preGeneratedFullHtml ?: """ - - - - ${title.replace("\"", """)} - - - - $bodyContent - - - """.trimIndent() - - val plainText = Jsoup.parse(fullHtml).text() - - try { - File(extractionDir, "content.html").writeText(fullHtml) - } catch (e: Exception) { - Timber.e(e, "Failed to save generated HTML to disk") + val rawChapters = if (bodyHtml.contains("")) { + bodyHtml.split("") + } else { + listOf(bodyHtml) } - val chapter = EpubChapter( - chapterId = bookId, - absPath = "content.html", - title = title, - htmlFilePath = "content.html", - plainTextContent = plainText, - htmlContent = "", - depth = 0, - isInToc = true - ) + val chapters = rawChapters.mapIndexed { index, rawText -> + async(Dispatchers.Default) { + if (rawText.isBlank()) return@async null + + val pageNum = index + 1 + val chapterTitle = if (rawChapters.size > 1) "Page $pageNum" else title + val fileName = "page_$pageNum.html" + val file = File(extractionDir, fileName) + + val fullHtml = """ + + + + ${title.replace("\"", """)} + + + + ${rawText.trim()} + + + """.trimIndent() + + file.writeText(fullHtml) + + EpubChapter( + chapterId = "${bookId}_$pageNum", + absPath = fileName, + title = chapterTitle, + htmlFilePath = fileName, + plainTextContent = Jsoup.parse(fullHtml).text(), + htmlContent = "", + depth = 0, + isInToc = true + ) + } + }.awaitAll().filterNotNull() + + Timber.tag("FileOpenPerf").d("[HTML] parseHtml COMPLETE | elapsed=${System.currentTimeMillis() - parseStart}ms") val book = EpubBook( - fileName = fileName, + fileName = originalBookNameHint, title = title, - author = author ?: "", + author = author ?: "Unknown", language = "en", coverImage = null, - chapters = listOf(chapter), - chaptersForPagination = listOf(chapter), + chapters = chapters, + chaptersForPagination = chapters, images = emptyList(), pageList = emptyList(), extractionBasePath = extractionDir.absolutePath, @@ -449,6 +444,6 @@ class SingleFileImporter(private val context: Context) { Timber.e(e, "Failed to cache HTML metadata") } - return book + return@withContext book } } \ No newline at end of file diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderControls.kt b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderControls.kt index bff4365..6cdc712 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderControls.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderControls.kt @@ -67,13 +67,13 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.automirrored.filled.MenuBook import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.ChevronLeft import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.GraphicEq import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.MoreVert @@ -153,6 +153,7 @@ fun EpubReaderTopBar( searchFocusRequester: androidx.compose.ui.focus.FocusRequester, modifier: Modifier = Modifier, onToggleReflow: (() -> Unit)? = null, + onDeleteReflow: (() -> Unit)? = null, ) { AnimatedVisibility( visible = isVisible, @@ -226,6 +227,27 @@ fun EpubReaderTopBar( HorizontalDivider() } + onDeleteReflow?.let { + HorizontalDivider() + DropdownMenuItem( + text = { Text("Delete Text View") }, + onClick = { + showMoreMenu = false + it() + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = null, + tint = MaterialTheme.colorScheme.error + ) + }, + colors = androidx.compose.material3.MenuDefaults.itemColors( + textColor = MaterialTheme.colorScheme.error + ) + ) + } + DropdownMenuItem( text = { Text("Reading Mode: Vertical") }, enabled = !isTtsActive, @@ -386,7 +408,7 @@ fun EpubReaderBottomBar( Icon(imageVector = Icons.Default.Search, contentDescription = "Search") } - @Suppress("KotlinConstantConditions") + @Suppress("KotlinConstantConditions", "SimplifyBooleanWithConstants") if (BuildConfig.FLAVOR != "oss") { Box { var showAiFeaturesMenu by remember { mutableStateOf(false) } diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt index fbc9163..599723f 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt @@ -363,7 +363,16 @@ fun EpubReaderScreen( onRenderModeChange = onRenderModeChange, customFonts = customFonts, onImportFont = onImportFont, - onToggleReflow = onOpenOriginal + onToggleReflow = onOpenOriginal, + onDeleteReflow = if (isReflowFile) { + { + uiState.selectedBookId?.let { id -> + viewModel.deleteBookPermanently(id) { + onNavigateBack() + } + } + } + } else null ) } @@ -388,7 +397,8 @@ fun EpubReaderHost( onRenderModeChange: (RenderMode) -> Unit, customFonts: List, onImportFont: (Uri) -> Unit, - onToggleReflow: ((Int) -> Unit)? = null + onToggleReflow: ((Int) -> Unit)? = null, + onDeleteReflow: (() -> Unit)? = null ) { val view = LocalView.current val context = LocalContext.current @@ -3124,6 +3134,7 @@ fun EpubReaderHost( onToggleReflow(activeChapter) } } else null, + onDeleteReflow = onDeleteReflow ) val autoScrollPadding by androidx.compose.animation.core.animateDpAsState( diff --git a/app/src/main/java/com/aryan/reader/pdf/NativePdfiumBridge.kt b/app/src/main/java/com/aryan/reader/pdf/NativePdfiumBridge.kt index b64ab5f..1cf6c30 100644 --- a/app/src/main/java/com/aryan/reader/pdf/NativePdfiumBridge.kt +++ b/app/src/main/java/com/aryan/reader/pdf/NativePdfiumBridge.kt @@ -11,12 +11,19 @@ object NativePdfiumBridge { @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? + @JvmStatic external fun getPageCharBoxes(textPagePtr: Long, count: Int): FloatArray? @JvmStatic external fun getAnnotCount(pagePtr: Long): Int @JvmStatic external fun getAnnotSubtype(pagePtr: Long, index: Int): Int @JvmStatic external fun getAnnotRect(pagePtr: Long, index: Int): FloatArray? @JvmStatic external fun getAnnotString(pagePtr: Long, index: Int, key: String): String? + // Image/Object extraction + @JvmStatic external fun getPageObjectCount(pagePtr: Long): Int + @JvmStatic external fun getPageObjectType(pagePtr: Long, index: Int): Int + @JvmStatic external fun getPageObjectBoundingBox(pagePtr: Long, index: Int, outRect: FloatArray): Boolean + @JvmStatic external fun extractImagePixels(pagePtr: Long, index: Int, dimens: IntArray): IntArray? + const val ANNOT_TEXT = 1 // Sticky Note const val ANNOT_LINK = 2 // Link const val ANNOT_HIGHLIGHT = 8 // Highlight diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfHelper.kt b/app/src/main/java/com/aryan/reader/pdf/PdfHelper.kt index b1ca347..bd69d26 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfHelper.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfHelper.kt @@ -196,8 +196,8 @@ internal fun PdfSelectionMenuPopup( popupPositionProvider = popupPositionProvider, onDismissRequest = onDismiss, properties = PopupProperties( - focusable = true, - dismissOnClickOutside = true, + focusable = false, + dismissOnClickOutside = false, dismissOnBackPress = true ) ) { diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfPageComposable.kt b/app/src/main/java/com/aryan/reader/pdf/PdfPageComposable.kt index 9badbc3..5f3c17c 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfPageComposable.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfPageComposable.kt @@ -71,7 +71,6 @@ import androidx.compose.ui.graphics.ColorMatrix import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeJoin -import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.drawIntoCanvas @@ -164,8 +163,8 @@ data class EmbeddedAnnotation( val rect: android.graphics.RectF, val contents: String?, val author: String?, - val name: String?, // Unique ID - val inReplyTo: String?, // ID of parent + val name: String?, + val inReplyTo: String?, val replies: MutableList = mutableListOf() ) @@ -1536,90 +1535,101 @@ internal fun PdfPageComposable( providedTextPage: PdfTextPageKt? = null ) { if (charRange == null || currentBitmapWidth == 0 || currentBitmapHeight == 0) { - selectedWordScreenRects = emptyList() - startHandleContentPosition.value = null - endHandleContentPosition.value = null - return - } - - var localPage: PdfPageKt? = null - var localTextPage: PdfTextPageKt? = null - - try { - val pageToUse: PdfPageKt - val textPageToUse: PdfTextPageKt - - if (providedPage != null && providedTextPage != null) { - pageToUse = providedPage - textPageToUse = providedTextPage - } else { - localPage = doc.openPage(pageIdx) - localTextPage = localPage.openTextPage() - pageToUse = localPage - textPageToUse = localTextPage - } - - val (startIndex, endIndex) = charRange - if (startIndex >= endIndex) { + withContext(Dispatchers.Main) { selectedWordScreenRects = emptyList() startHandleContentPosition.value = null endHandleContentPosition.value = null - return } + return + } - val length = endIndex - startIndex - val wordPdfRectsF = - textPageToUse.textPageGetRectsForRanges(intArrayOf(startIndex, length))?.map { - it.rect - } ?: emptyList() + withContext(Dispatchers.IO) { + var localPage: PdfPageKt? = null + var localTextPage: PdfTextPageKt? = null - if (wordPdfRectsF.isNotEmpty()) { - val mappedScreenRects = wordPdfRectsF.mapNotNull { pdfRectF -> - val screenRect = pageToUse.mapRectToDevice( - startX = 0, - startY = 0, - sizeX = currentBitmapWidth, - sizeY = currentBitmapHeight, - rotate = rotation, - coords = pdfRectF - ) - if (screenRect.width() > 0 && screenRect.height() > 0) screenRect - else { - Timber.d( - "updateSelectionVisuals: Filtering out invalid screen rect: $screenRect" + try { + val pageToUse: PdfPageKt + val textPageToUse: PdfTextPageKt + + if (providedPage != null && providedTextPage != null) { + pageToUse = providedPage + textPageToUse = providedTextPage + } else { + localPage = doc.openPage(pageIdx) + localTextPage = localPage.openTextPage() + pageToUse = localPage + textPageToUse = localTextPage + } + + val (startIndex, endIndex) = charRange + if (startIndex >= endIndex) { + withContext(Dispatchers.Main) { + selectedWordScreenRects = emptyList() + startHandleContentPosition.value = null + endHandleContentPosition.value = null + } + return@withContext + } + + val length = endIndex - startIndex + val wordPdfRectsF = + textPageToUse.textPageGetRectsForRanges(intArrayOf(startIndex, length))?.map { + it.rect + } ?: emptyList() + + if (wordPdfRectsF.isNotEmpty()) { + val mappedScreenRects = wordPdfRectsF.mapNotNull { pdfRectF -> + val screenRect = pageToUse.mapRectToDevice( + startX = 0, + startY = 0, + sizeX = currentBitmapWidth, + sizeY = currentBitmapHeight, + rotate = rotation, + coords = pdfRectF ) - null + if (screenRect.width() > 0 && screenRect.height() > 0) screenRect + else { + Timber.d( + "updateSelectionVisuals: Filtering out invalid screen rect: $screenRect" + ) + null + } + } + + withContext(Dispatchers.Main) { + selectedWordScreenRects = mappedScreenRects + + if (mappedScreenRects.isNotEmpty()) { + val firstRect = mappedScreenRects.first() + val lastRect = mappedScreenRects.last() + + startHandleContentPosition.value = + Offset(firstRect.left.toFloat(), firstRect.bottom.toFloat()) + endHandleContentPosition.value = + Offset(lastRect.right.toFloat(), lastRect.bottom.toFloat()) + } else { + selectedWordScreenRects = emptyList() + startHandleContentPosition.value = null + endHandleContentPosition.value = null + } + } + } else { + withContext(Dispatchers.Main) { + selectedWordScreenRects = emptyList() + startHandleContentPosition.value = null + endHandleContentPosition.value = null } } - selectedWordScreenRects = mappedScreenRects - - if (mappedScreenRects.isNotEmpty()) { - val firstRect = mappedScreenRects.first() - val lastRect = mappedScreenRects.last() - - startHandleContentPosition.value = - Offset(firstRect.left.toFloat(), firstRect.bottom.toFloat()) - endHandleContentPosition.value = - Offset(lastRect.right.toFloat(), lastRect.bottom.toFloat()) - } else { + } catch (e: Exception) { + Timber.e(e, "Error updating selection visuals for page $pageIdx, range $charRange: $e") + withContext(Dispatchers.Main) { selectedWordScreenRects = emptyList() startHandleContentPosition.value = null endHandleContentPosition.value = null } - } else { - selectedWordScreenRects = emptyList() - startHandleContentPosition.value = null - endHandleContentPosition.value = null - } - } catch (e: Exception) { - Timber.e(e, "Error updating selection visuals for page $pageIdx, range $charRange: $e") - selectedWordScreenRects = emptyList() - startHandleContentPosition.value = null - endHandleContentPosition.value = null - } finally { - if (providedPage == null && providedTextPage == null) { - withContext(NonCancellable) { - withContext(Dispatchers.IO) { + } finally { + if (providedPage == null && providedTextPage == null) { + withContext(NonCancellable) { try { localTextPage?.close() } catch (_: Exception) { @@ -2105,14 +2115,14 @@ internal fun PdfPageComposable( var pageForMenu: PdfPageKt? = null var textPageForMenu: PdfTextPageKt? = null try { - pageForMenu = pdfDocumentItem.openPage( - pdfPageIndex - ) - textPageForMenu = pageForMenu.openTextPage() - val text = textPageForMenu.textPageGetText( - currentRange.first, - currentRange.second - currentRange.first - ) + val text = withContext(Dispatchers.IO) { + pageForMenu = pdfDocumentItem.openPage(pdfPageIndex) + textPageForMenu = pageForMenu.openTextPage() + textPageForMenu.textPageGetText( + currentRange.first, + currentRange.second - currentRange.first + ) + } if (!text.isNullOrBlank()) { val combinedRect = Rect(selectedWordScreenRects.first()) selectedWordScreenRects.forEach { combinedRect.union(it) } @@ -2220,79 +2230,81 @@ internal fun PdfPageComposable( try { if (!isPdfPage) return@launch - tempPage = pdfDocumentItem.openPage(pdfPageIndex) - tempTextPage = tempPage.openTextPage() - - val touchInContentCoords = screenToContentCoordinates( - down.position - ) - Timber.d( - "Long press: initial touch in content coords: $touchInContentCoords" - ) + val touchInContentCoords = screenToContentCoordinates(down.position) + Timber.d("Long press: initial touch in content coords: $touchInContentCoords") if (touchInContentCoords.x < 0 || touchInContentCoords.x > actualBitmapWidthPx || touchInContentCoords.y < 0 || touchInContentCoords.y > actualBitmapHeightPx) { - Timber.d( - "Long press: Touch point outside bitmap bounds." - ) + Timber.d("Long press: Touch point outside bitmap bounds.") return@launch } - val pdfCoords = tempPage.mapDeviceCoordsToPage( - startX = 0, - startY = 0, - sizeX = actualBitmapWidthPx, - sizeY = actualBitmapHeightPx, - rotate = currentPageRotation, - deviceX = touchInContentCoords.x.toInt(), - deviceY = touchInContentCoords.y.toInt() - ) - val charTolerance = 5.0 - val charIndex = tempTextPage.textPageGetCharIndexAtPos( - x = pdfCoords.x.toDouble(), - y = pdfCoords.y.toDouble(), - xTolerance = charTolerance, - yTolerance = charTolerance - ) - var pdfiumSelectionSuccessful = false - if (charIndex != -1) { - val pageCharCount = tempTextPage.textPageCountChars() - val wordBoundaries = findWordBoundaries( - tempTextPage, charIndex, pageCharCount + withContext(Dispatchers.IO) { + tempPage = pdfDocumentItem.openPage(pdfPageIndex) + tempTextPage = tempPage.openTextPage() + + val pdfCoords = tempPage.mapDeviceCoordsToPage( + startX = 0, + startY = 0, + sizeX = actualBitmapWidthPx, + sizeY = actualBitmapHeightPx, + rotate = currentPageRotation, + deviceX = touchInContentCoords.x.toInt(), + deviceY = touchInContentCoords.y.toInt() + ) + val charTolerance = 5.0 + val charIndex = tempTextPage.textPageGetCharIndexAtPos( + x = pdfCoords.x.toDouble(), + y = pdfCoords.y.toDouble(), + xTolerance = charTolerance, + yTolerance = charTolerance ) - if (wordBoundaries != null) { - selectionMethodUsed = PdfSelectionMethod.PDFIUM - selectionCharRange.value = wordBoundaries - updateSelectionVisuals( - pdfDocumentItem, - pdfPageIndex, - selectionCharRange.value, - actualBitmapWidthPx, - actualBitmapHeightPx, - currentPageRotation, - providedPage = tempPage, - providedTextPage = tempTextPage + if (charIndex != -1) { + val pageCharCount = tempTextPage.textPageCountChars() + val wordBoundaries = findWordBoundaries( + tempTextPage, charIndex, pageCharCount ) - if (selectionCharRange.value != null && selectedWordScreenRects.isNotEmpty()) { - val currentRange = selectionCharRange.value!! - val text = tempTextPage.textPageGetText( - currentRange.first, - currentRange.second - currentRange.first - ) - if (!text.isNullOrBlank()) { - val combinedRect = Rect(selectedWordScreenRects.first()) - selectedWordScreenRects.forEach { combinedRect.union(it) } - customMenuState = CustomPdfMenuState( - selectedText = text, - anchorRect = combinedRect, - charRange = currentRange - ) - pdfiumSelectionSuccessful = true - Timber.d( - "Long press: PDFIUM selection successful. Menu: ${customMenuState?.anchorRect}" - ) + if (wordBoundaries != null) { + withContext(Dispatchers.Main) { + selectionMethodUsed = PdfSelectionMethod.PDFIUM + selectionCharRange.value = wordBoundaries + } + updateSelectionVisuals( + pdfDocumentItem, + pdfPageIndex, + wordBoundaries, + actualBitmapWidthPx, + actualBitmapHeightPx, + currentPageRotation, + providedPage = tempPage, + providedTextPage = tempTextPage + ) + withContext(Dispatchers.Main) { + if (selectionCharRange.value != null && selectedWordScreenRects.isNotEmpty()) { + val currentRange = selectionCharRange.value!! + val text = withContext(Dispatchers.IO) { + tempTextPage.textPageGetText( + currentRange.first, + currentRange.second - currentRange.first + ) + } + if (!text.isNullOrBlank()) { + val combinedRect = Rect(selectedWordScreenRects.first()) + selectedWordScreenRects.forEach { combinedRect.union(it) } + + customMenuState = CustomPdfMenuState( + selectedText = text, + anchorRect = combinedRect, + charRange = currentRange + ) + pdfiumSelectionSuccessful = true + Timber.d( + "Long press: PDFIUM selection successful. Menu: ${customMenuState?.anchorRect}" + ) + } + } } } } @@ -3682,9 +3694,12 @@ internal fun PdfPageComposable( var page: PdfPageKt? = null var textPage: PdfTextPageKt? = null try { - page = pdfDocumentItem.openPage(pdfPageIndex) - textPage = page.openTextPage() - val charCount = textPage.textPageCountChars() + val charCount = withContext(Dispatchers.IO) { + page = pdfDocumentItem.openPage(pdfPageIndex) + textPage = page.openTextPage() + textPage.textPageCountChars() + } + if (charCount > 0) { selectionCharRange.value = Pair(0, charCount) updateSelectionVisuals( @@ -3698,8 +3713,9 @@ internal fun PdfPageComposable( providedTextPage = textPage ) if (selectedWordScreenRects.isNotEmpty()) { - val fullText = - textPage.textPageGetText(0, charCount) + val fullText = withContext(Dispatchers.IO) { + textPage!!.textPageGetText(0, charCount) + } if (!fullText.isNullOrBlank()) { val combinedRect = Rect(selectedWordScreenRects.first()) selectedWordScreenRects.forEach { combinedRect.union(it) } @@ -3714,9 +3730,11 @@ internal fun PdfPageComposable( } catch (e: Exception) { Timber.e(e, "Failed to select all") } finally { - withContext(Dispatchers.IO) { - textPage?.close() - page?.close() + withContext(NonCancellable) { + withContext(Dispatchers.IO) { + textPage?.close() + page?.close() + } } } } else { @@ -4124,7 +4142,7 @@ internal sealed interface AnnotationRenderData { internal object PdfAnnotationRenderHelper { fun createRenderData(annot: PdfAnnotation, widthPx: Int, heightPx: Int): AnnotationRenderData? { - + val startTime = System.nanoTime() if (annot.points.isEmpty()) return null if (annot.points.size == 1) { @@ -4277,6 +4295,10 @@ internal object PdfAnnotationRenderHelper { ) } } + val duration = (System.nanoTime() - startTime) / 1_000_000f + if (duration > 1f) { + Timber.tag("PdfPerf").v("Path Gen: Type=${annot.inkType}, Pts=${annot.points.size}, Time=${duration}ms") + } return result } } @@ -4291,8 +4313,21 @@ private fun PdfAnnotationLayer( centeringOffsetY: Float, pageIndex: Int ) { - SideEffect { Timber.tag("PdfDrawPerf").v("ANNOT LAYER: Recomposing (Page $pageIndex)") } + SideEffect { Timber.tag("PdfPerf").v("ANNOT_LAYER: Recomposing Page $pageIndex") } + val staticAnnotations = annotationsProvider() + + val staticRenderData = remember(staticAnnotations, actualBitmapWidthPx, actualBitmapHeightPx) { + val startTime = System.nanoTime() + val data = staticAnnotations.mapNotNull { annot -> + PdfAnnotationRenderHelper.createRenderData( + annot, actualBitmapWidthPx, actualBitmapHeightPx + ) + } + val duration = (System.nanoTime() - startTime) / 1_000_000f + Timber.tag("PdfPerf").d("ANNOT_LAYER: Processed ${staticAnnotations.size} static annots in ${duration}ms") + data + } val currentAnnotation = remember(drawingState, pageIndex) { derivedStateOf { val annot = drawingState?.currentAnnotation @@ -4312,30 +4347,21 @@ private fun PdfAnnotationLayer( ) } - val staticRenderData = remember(staticAnnotations, actualBitmapWidthPx, actualBitmapHeightPx) { - staticAnnotations.mapNotNull { annot -> - PdfAnnotationRenderHelper.createRenderData( - annot, actualBitmapWidthPx, actualBitmapHeightPx - ) - } - } - val activeRenderData = remember( currentAnnotation, currentAnnotation?.points?.size, actualBitmapWidthPx, actualBitmapHeightPx ) { - if (currentAnnotation != null) { - Timber.tag("PdfDrawPerf").v( - "ANNOT LAYER: Generating active path for ${currentAnnotation.points.size} points" - ) + val startTime = System.nanoTime() + val res = currentAnnotation?.let { annot -> + PdfAnnotationRenderHelper.createRenderData(annot, actualBitmapWidthPx, actualBitmapHeightPx) } - currentAnnotation?.let { annot -> - PdfAnnotationRenderHelper.createRenderData( - annot, actualBitmapWidthPx, actualBitmapHeightPx - ) + val duration = (System.nanoTime() - startTime) / 1_000_000f + if (duration > 0.5f) { + Timber.tag("PdfPerf").v("ANNOT_LAYER: Active path gen took ${duration}ms") } + res } Canvas(modifier = Modifier.fillMaxSize()) { @@ -4386,9 +4412,9 @@ private fun PdfAnnotationLayer( activeRenderData?.let { drawData(it) } } val drawDuration = (System.nanoTime() - drawStart) / 1_000_000f - Timber.tag("PdfDrawPerf").v( - "ANNOT DRAW: Canvas draw took ${drawDuration}ms. Points: ${currentAnnotation?.points?.size ?: 0}" - ) + if (drawDuration > 2f) { + Timber.tag("PdfPerf").v("ANNOT_DRAW: Canvas draw took ${drawDuration}ms (Page $pageIndex)") + } } } @@ -4528,11 +4554,15 @@ private fun PdfPageRenderer( onHighlightUpdate: (String, PdfHighlightColor) -> Unit, onHighlightDelete: (String) -> Unit, ) { + SideEffect { + Timber.tag("PdfPerf").v("PAGE_RENDERER: Recomposing Page ${selectionData.pageIndex}. DraggingHandle=${activeDraggingHandle != null}") + } Box(modifier = Modifier.fillMaxSize()) { Box( modifier = Modifier .fillMaxSize() .graphicsLayer { + Timber.tag("PdfPerf").v("GraphicsLayer Update: Scale=$scale, Offset=$offset") scaleX = scale scaleY = scale translationX = offset.x @@ -4783,7 +4813,6 @@ private fun PdfPageRenderer( } if (showMagnifier && activeDraggingHandle != null && staticData.bitmap.item != null) { - val handleContentPos = when (activeDraggingHandle) { Handle.START -> startHandlePos Handle.END -> endHandlePos @@ -4795,39 +4824,44 @@ private fun PdfPageRenderer( val magnifierWidth = 120.dp val magnifierHeight = 60.dp val magnifierOffsetAboveHandle = 24.dp + val effectiveScale = staticData.effectiveScale - with(density) { - val magnifierWidthPx = magnifierWidth.toPx() - val magnifierHeightPx = magnifierHeight.toPx() - val magnifierOffsetAboveHandlePx = magnifierOffsetAboveHandle.toPx() - val effectiveScale = staticData.effectiveScale + val effectiveZoomFactor = if (isVerticalScroll && effectiveScale > 1f) { + effectiveScale * 1.25f + } else { + magnifierZoomFactor + } - val modifier: Modifier - val effectiveZoomFactor: Float + val popupPositionProvider = remember(pos, layoutCoordinates, density) { + object : androidx.compose.ui.window.PopupPositionProvider { + override fun calculatePosition( + anchorBounds: androidx.compose.ui.unit.IntRect, + windowSize: androidx.compose.ui.unit.IntSize, + layoutDirection: androidx.compose.ui.unit.LayoutDirection, + popupContentSize: androidx.compose.ui.unit.IntSize + ): androidx.compose.ui.unit.IntOffset { + val coords = layoutCoordinates ?: return androidx.compose.ui.unit.IntOffset.Zero - if (isVerticalScroll && effectiveScale > 1f) { - val yOffsetPixels = - pos.y - (magnifierHeightPx + magnifierOffsetAboveHandlePx) / effectiveScale - val xOffsetPixels = pos.x - (magnifierWidthPx / 2) / effectiveScale + val windowPos = coords.localToWindow(pos) + val offsetPx = with(density) { magnifierOffsetAboveHandle.toPx() } - modifier = - Modifier - .offset(x = xOffsetPixels.toDp(), y = yOffsetPixels.toDp()) - .graphicsLayer( - scaleX = 1f / effectiveScale, - scaleY = 1f / effectiveScale, - transformOrigin = TransformOrigin(0f, 0f) - ) + val x = (windowPos.x - popupContentSize.width / 2).toInt() + val y = (windowPos.y - popupContentSize.height - offsetPx).toInt() - effectiveZoomFactor = effectiveScale * 1.25f - } else { - val xOffsetVal = pos.x - magnifierWidthPx / 2 - val yOffsetVal = pos.y - magnifierHeightPx - magnifierOffsetAboveHandlePx - - modifier = Modifier.offset(x = xOffsetVal.toDp(), y = yOffsetVal.toDp()) - effectiveZoomFactor = magnifierZoomFactor + return androidx.compose.ui.unit.IntOffset(x, y) + } } + } + androidx.compose.ui.window.Popup( + popupPositionProvider = popupPositionProvider, + properties = androidx.compose.ui.window.PopupProperties( + focusable = false, + dismissOnClickOutside = false, + dismissOnBackPress = false, + usePlatformDefaultWidth = false + ) + ) { MagnifierComposable( sourceBitmap = staticData.bitmap.item.asImageBitmap(), tiles = if (effectiveScale > 1f) staticData.tiles.item else emptyList(), @@ -4839,7 +4873,7 @@ private fun PdfPageRenderer( selectionRectsInBitmapCoords = selectionData.mergedSelectionRects.item, highlightColor = Color(0x6633B5E5), colorFilter = staticData.colorFilter.item, - modifier = modifier + modifier = Modifier ) } } @@ -4848,9 +4882,10 @@ private fun PdfPageRenderer( if (menuState != null) { BackHandler(enabled = true, onBack = onMenuDismiss) } - menuState?.let { state -> - if (state.anchorRect.width() > 0 || state.anchorRect.height() > 0) { - val popupPositionProvider = remember(state.anchorRect, density, offset, scale, layoutCoordinates) { + + if (menuState != null && !isScrolling && draggingBoxId == null && activeDraggingHandle == null) { + if (menuState.anchorRect.width() > 0 || menuState.anchorRect.height() > 0) { + val popupPositionProvider = remember(menuState.anchorRect, density, offset, scale, layoutCoordinates) { object : PopupPositionProvider { override fun calculatePosition( anchorBounds: IntRect, @@ -4861,8 +4896,12 @@ private fun PdfPageRenderer( val coords = layoutCoordinates ?: return IntOffset.Zero // Map the bitmap-space anchor (the icon) to window-space - val topLeftLocal = contentToScreenCoordinates(Offset(state.anchorRect.left.toFloat(), state.anchorRect.top.toFloat())) - val bottomRightLocal = contentToScreenCoordinates(Offset(state.anchorRect.right.toFloat(), state.anchorRect.bottom.toFloat())) + val topLeftLocal = contentToScreenCoordinates(Offset( + menuState.anchorRect.left.toFloat(), + menuState.anchorRect.top.toFloat())) + val bottomRightLocal = contentToScreenCoordinates(Offset( + menuState.anchorRect.right.toFloat(), + menuState.anchorRect.bottom.toFloat())) val topLeftWindow = coords.localToWindow(topLeftLocal) val bottomRightWindow = coords.localToWindow(bottomRightLocal) @@ -4890,30 +4929,28 @@ private fun PdfPageRenderer( } PdfSelectionMenuPopup( - menuState = state, + menuState = menuState, popupPositionProvider = popupPositionProvider, onDismiss = onMenuDismiss, onCopy = onCopy, onAiDefine = onAiDefine, onSelectAll = onSelectAll, onColorSelected = { color -> - Timber.tag("PdfHighlightDebug").d("PdfSelectionMenuPopup onColorSelected: $color, isExisting=${state.isExistingHighlight}") - if (state.isExistingHighlight && state.highlightId != null) { - onHighlightUpdate(state.highlightId, color) + Timber.tag("PdfHighlightDebug").d("PdfSelectionMenuPopup onColorSelected: $color, isExisting=${menuState.isExistingHighlight}") + if (menuState.isExistingHighlight && menuState.highlightId != null) { + onHighlightUpdate(menuState.highlightId, color) } else { Timber.tag("PdfHighlightDebug").d("Calling onHighlightAdd for page ${selectionData.pageIndex}") onHighlightAdd( - selectionData.pageIndex, - state.charRange, - state.selectedText, + selectionData.pageIndex, menuState.charRange, menuState.selectedText, color ) } onMenuDismiss() }, onDelete = { - if (state.isExistingHighlight && state.highlightId != null) { - onHighlightDelete(state.highlightId) + if (menuState.isExistingHighlight && menuState.highlightId != null) { + onHighlightDelete(menuState.highlightId) } onMenuDismiss() } diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfReflowGenerator.kt b/app/src/main/java/com/aryan/reader/pdf/PdfReflowGenerator.kt deleted file mode 100644 index 337b18d..0000000 --- a/app/src/main/java/com/aryan/reader/pdf/PdfReflowGenerator.kt +++ /dev/null @@ -1,135 +0,0 @@ -package com.aryan.reader.pdf - -import android.content.Context -import com.aryan.reader.epub.EpubBook -import com.aryan.reader.epub.EpubChapter -import com.aryan.reader.pdf.data.PdfTextRepository -import io.legere.pdfiumandroid.suspend.PdfDocumentKt -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import java.io.File -import java.util.UUID - -object PdfReflowGenerator { - - suspend fun generateReflowBook( - context: Context, - bookId: String, - document: PdfDocumentKt, - repository: PdfTextRepository, - totalPages: Int - ): EpubBook = withContext(Dispatchers.Default) { - val cacheDir = File(context.cacheDir, "reflow_cache/$bookId") - if (cacheDir.exists()) { - cacheDir.deleteRecursively() - } - cacheDir.mkdirs() - - val chapters = mutableListOf() - val css = """ - body { font-family: sans-serif; line-height: 1.6; padding: 1em; } - p { margin-bottom: 1em; } - h1, h2 { color: #333; margin-top: 1.5em; } - .page-marker { color: #888; font-size: 0.8em; margin-bottom: 2em; border-bottom: 1px solid #eee; } - """.trimIndent() - - // We generate a chapter for every page to keep sync simple - for (i in 0 until totalPages) { - val rawText = repository.getOrExtractText(bookId, document, i) - val cleanedHtml = processTextToHtml(rawText, i + 1) - - val fileName = "page_$i.html" - val file = File(cacheDir, fileName) - - val fullHtml = """ - - - - Page ${i + 1} - - - - $cleanedHtml - - - """.trimIndent() - - file.writeText(fullHtml) - - chapters.add( - EpubChapter( - chapterId = "${bookId}_page_$i", - absPath = fileName, - title = "Page ${i + 1}", - htmlFilePath = fileName, - plainTextContent = rawText, // Raw text for search/TTS - htmlContent = fullHtml, - depth = 0, - isInToc = true - ) - ) - } - - EpubBook( - fileName = "Reflow_Session", - title = document.getDocumentMeta().title ?: "Reflow View", - author = document.getDocumentMeta().author ?: "", - language = "en", - coverImage = null, - chapters = chapters, - chaptersForPagination = chapters, - images = emptyList(), - pageList = emptyList(), - extractionBasePath = cacheDir.absolutePath, - css = emptyMap() - ) - } - - private fun processTextToHtml(rawText: String, pageNumber: Int): String { - if (rawText.isBlank()) return "

(No text on this page)

" - - val lines = rawText.split('\n') - val sb = StringBuilder() - - sb.append("
Page $pageNumber
") - - var currentParagraph = StringBuilder() - - for (line in lines) { - val trimmed = line.trim() - if (trimmed.isEmpty()) { - if (currentParagraph.isNotEmpty()) { - sb.append("

${currentParagraph.toString()}

") - currentParagraph.clear() - } - continue - } - - // Heuristic: Header detection (All caps, short line, no punctuation at end) - val isHeader = trimmed.length < 50 && trimmed.all { it.isUpperCase() || !it.isLetter() } && !trimmed.endsWith(".") - - if (isHeader) { - if (currentParagraph.isNotEmpty()) { - sb.append("

${currentParagraph.toString()}

") - currentParagraph.clear() - } - sb.append("

$trimmed

") - continue - } - - if (currentParagraph.isNotEmpty()) { - currentParagraph.append(" ") - } - currentParagraph.append(trimmed) - - if (trimmed.endsWith(".") || trimmed.endsWith("?") || trimmed.endsWith("!") || trimmed.endsWith(":")) { - } - } - - if (currentParagraph.isNotEmpty()) { - sb.append("

${currentParagraph.toString()}

") - } - - return sb.toString() - } -} \ No newline at end of file diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfToHtmlGenerator.kt b/app/src/main/java/com/aryan/reader/pdf/PdfToHtmlGenerator.kt new file mode 100644 index 0000000..2bcb46d --- /dev/null +++ b/app/src/main/java/com/aryan/reader/pdf/PdfToHtmlGenerator.kt @@ -0,0 +1,514 @@ +// PdfToHtmlGenerator.kt +package com.aryan.reader.pdf + +import android.content.Context +import android.graphics.Bitmap +import android.net.Uri +import android.util.Base64 +import io.legere.pdfiumandroid.PdfiumCore +import io.legere.pdfiumandroid.suspend.PdfDocumentKt +import io.legere.pdfiumandroid.suspend.PdfiumCoreKt +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import timber.log.Timber +import java.io.ByteArrayOutputStream +import java.io.File +import kotlin.math.roundToInt + +private const val TAG = "PdfToHtml" + +object PdfToHtmlGenerator { + + suspend fun generateHtmlFile( + context: Context, + pdfUri: Uri, + destFile: File, + startPage: Int = 1, + onProgress: (Float) -> Unit + ): Boolean = withContext(Dispatchers.IO) { + val t0 = System.currentTimeMillis() + Timber.tag(TAG).d("generateHtmlFile START | uri=$pdfUri | startPage=$startPage") + + val pdfiumCore = PdfiumCoreKt(Dispatchers.Default) + val pfd = context.contentResolver.openFileDescriptor(pdfUri, "r") ?: run { + Timber.tag(TAG).e("Failed to open ParcelFileDescriptor") + return@withContext false + } + + try { + val doc = pdfiumCore.newDocument(pfd) + val totalPages = doc.getPageCount() + Timber.tag(TAG).d("Document loaded. Total pages: $totalPages") + + val headerFooterStrings = detectRepeatingHeaderFooter(doc, totalPages) + + destFile.bufferedWriter().use { writer -> + writer.write(buildGlobalHtmlHeader()) + for (pageIdx in (startPage - 1) until totalPages) { + if (pageIdx > startPage - 1) { + writer.write("\n\n") + } + + val pageHtml = extractPageHtml(doc, pageIdx, pageIdx + 1, headerFooterStrings) + writer.write(pageHtml) + if (pageIdx % 5 == 0 || pageIdx == totalPages - 1) { + onProgress((pageIdx + 1).toFloat() / totalPages.toFloat()) + } + } + writer.write(buildGlobalHtmlFooter()) + } + + doc.close() + pfd.close() + Timber.tag(TAG).d("generateHtmlFile SUCCESS | ${System.currentTimeMillis() - t0}ms") + return@withContext true + } catch (e: Exception) { + Timber.tag(TAG).e(e, "Failed to generate HTML from PDF") + try { pfd.close() } catch (_: Exception) {} + return@withContext false + } + } + + private data class TextSpan( + val text: String, + val size: Float, + val isBold: Boolean, + val isItalic: Boolean + ) + + private sealed interface PageElement { + val yPos: Float + } + + private data class TextElement( + val line: TextLine, + override val yPos: Float + ) : PageElement + + private data class ImageElement( + val base64Data: String, + val width: Int, + val height: Int, + override val yPos: Float + ) : PageElement + + private data class TextLine( + val spans: List, + val yPos: Float, + val charCount: Int + ) + + private fun buildGlobalHtmlHeader(): String = """ + + + + + + + + + """.trimIndent() + "\n" + + private fun buildGlobalHtmlFooter(): String = "\n\n\n" + + private suspend fun extractPageHtml( + doc: PdfDocumentKt, + pageIdx: Int, + pageNumber: Int, + headerFooterStrings: Set + ): String { + return try { + doc.openPage(pageIdx).use { page -> + if (page == null) return buildEmptyPageSection(pageNumber) + page.openTextPage().use { textPage -> + val charCount = textPage.textPageCountChars() + + val pagePtr = page.page.pagePtr + val textPagePtr = textPage.page.pagePtr + + val imageElements = mutableListOf() + val objCount = NativePdfiumBridge.getPageObjectCount(pagePtr) + for (i in 0 until objCount) { + if (NativePdfiumBridge.getPageObjectType(pagePtr, i) == 3) { + val bbox = FloatArray(4) + if (NativePdfiumBridge.getPageObjectBoundingBox(pagePtr, i, bbox)) { + val topY = bbox[3] + val dimens = IntArray(2) + val pixels = NativePdfiumBridge.extractImagePixels(pagePtr, i, dimens) + if (pixels != null && dimens[0] > 0 && dimens[1] > 0) { + try { + val bmp = Bitmap.createBitmap(pixels, dimens[0], dimens[1], Bitmap.Config.ARGB_8888) + val baos = ByteArrayOutputStream() + bmp.compress(Bitmap.CompressFormat.JPEG, 80, baos) + val b64 = Base64.encodeToString(baos.toByteArray(), Base64.NO_WRAP) + imageElements.add(ImageElement(b64, dimens[0], dimens[1], topY)) + bmp.recycle() + } catch (_: Exception) { + Timber.tag(TAG).w("Failed to process image $i on page $pageIdx") + } + } + } + } + } + + if (charCount <= 0) { + return@use if (imageElements.isNotEmpty()) { + buildPageHtml(pageNumber, imageElements.sortedByDescending { it.yPos }, headerFooterStrings) + } else buildEmptyPageSection(pageNumber) + } + + val rawText = textPage.textPageGetText(0, charCount) ?: "" + val actualCount = minOf(charCount, rawText.length) + + val sizes: FloatArray? + val weights: IntArray? + val flags: IntArray? + val charBoxes: FloatArray? + + synchronized(PdfiumCore.lock) { + sizes = NativePdfiumBridge.getPageFontSizes(textPagePtr, actualCount) + weights = NativePdfiumBridge.getPageFontWeights(textPagePtr, actualCount) + flags = NativePdfiumBridge.getPageFontFlags(textPagePtr, actualCount) + charBoxes = NativePdfiumBridge.getPageCharBoxes(textPagePtr, actualCount) + } + + if (sizes == null || weights == null || flags == null) { + return@use buildFallbackPageSection(pageNumber, rawText) + } + + val textLines = mutableListOf() + val currentSpans = mutableListOf() + val currentSpanBuf = StringBuilder() + var curSize = -1f + var curBold = false + var curItalic = false + var lineBaseline = 0f + + fun commitSpan() { + if (currentSpanBuf.isNotEmpty()) { + currentSpans.add(TextSpan(currentSpanBuf.toString(), curSize, curBold, curItalic)) + currentSpanBuf.clear() + } + } + + fun commitLine() { + commitSpan() + if (currentSpans.isNotEmpty()) { + val text = currentSpans.joinToString("") { it.text } + if (text.isNotBlank()) { + textLines.add(TextLine(currentSpans.toList(), lineBaseline, text.length)) + } + currentSpans.clear() + } + lineBaseline = 0f + } + + for (i in 0 until actualCount) { + val c = rawText[i] + val code = c.code + + if (code == 0 || code == 13) continue + + if (c == '\n') { + commitLine() + continue + } + + val charToProcess = when (c) { + '\u00A0' -> ' ' + '\u00AD' -> '-' + '\u0009' -> ' ' + else -> c + } + + val type = Character.getType(c).toByte() + val isJunk = when { + code == 0xFFFE || code == 0xFFFF -> true + code == 0xFFFD -> true + type == Character.PRIVATE_USE -> true + type == Character.SURROGATE -> true + type == Character.UNASSIGNED -> true + (type == Character.CONTROL && code > 31) -> true + else -> false + } + + if (isJunk) { + val prefix = rawText.substring(maxOf(0, i - 2), i).replace("\n", "\\n") + val suffix = rawText.substring(minOf(actualCount, i + 1), minOf(actualCount, i + 3)).replace("\n", "\\n") + Timber.tag("PdfToHtml").w("Filtered Junk: 0x${Integer.toHexString(code).uppercase()} at pg $pageIdx. Context: '$prefix[$c]$suffix'") + continue + } + + val size = sizes[i].coerceAtLeast(0f) + val isBold = weights[i] > 600 + val isItalic = (flags[i] and 64) != 0 + + if (currentSpanBuf.isEmpty() && currentSpans.isEmpty() && !charToProcess.isWhitespace()) { + lineBaseline = if (charBoxes != null && i * 4 + 1 < charBoxes.size) charBoxes[i * 4 + 1] else 0f + } + + if (currentSpanBuf.isEmpty()) { + curSize = size; curBold = isBold; curItalic = isItalic + currentSpanBuf.append(charToProcess) + } else if (!charToProcess.isWhitespace() && (size != curSize || isBold != curBold || isItalic != curItalic)) { + commitSpan() + curSize = size; curBold = isBold; curItalic = isItalic + currentSpanBuf.append(charToProcess) + } else { + currentSpanBuf.append(charToProcess) + } + } + commitLine() + + // 3. MERGE TEXT AND IMAGES VERTICALLY + val finalElements = mutableListOf() + var imgIdx = 0 + val sortedImages = imageElements.sortedByDescending { it.yPos } + + for (line in textLines) { + // Place images physically positioned above this text line + while (imgIdx < sortedImages.size && sortedImages[imgIdx].yPos >= line.yPos) { + finalElements.add(sortedImages[imgIdx]) + imgIdx++ + } + finalElements.add(TextElement(line, line.yPos)) + } + // Place any remaining images at the bottom of the page + while (imgIdx < sortedImages.size) { + finalElements.add(sortedImages[imgIdx]) + imgIdx++ + } + + buildPageHtml(pageNumber, finalElements, headerFooterStrings) + } + } + } catch (e: Exception) { + Timber.tag(TAG).w(e, "Error extracting page $pageIdx") + buildEmptyPageSection(pageNumber) + } + } + + private fun buildEmptyPageSection(pageNumber: Int) = + "
\n" + + "

— Page $pageNumber —

\n" + + "

(No text on this page)

\n
\n" + + private fun buildFallbackPageSection(pageNumber: Int, rawText: String) = + "
\n" + + "

— Page $pageNumber —

\n" + + "

${rawText.escapeHtml()}

\n
\n" + + private fun buildPageHtml( + pageNumber: Int, + elements: List, + headerFooterStrings: Set + ): String { + val textElements = elements.filterIsInstance() + + val sizeFreq = HashMap() + textElements.forEach { te -> + te.line.spans.forEach { span -> + val s = span.size.roundToInt().coerceAtLeast(1) + sizeFreq[s] = (sizeFreq[s] ?: 0) + span.text.length + } + } + val baseSize = sizeFreq.maxByOrNull { it.value }?.key?.toFloat() ?: 12f + + val lineLengths = textElements.filter { it.line.charCount > 10 }.map { it.line.charCount }.sorted() + val typicalLineLen = if (lineLengths.isNotEmpty()) + lineLengths[(lineLengths.size * 0.80).toInt().coerceAtMost(lineLengths.size - 1)] + else 80 + val wrapThreshold = (typicalLineLen * 0.80).toInt() + + val sb = StringBuilder() + sb.append("
\n") + sb.append("

— Page $pageNumber —

\n") + + var inParagraph = false + var inUl = false + var inOl = false + + fun closeParagraph() { if (inParagraph) { sb.append("

\n"); inParagraph = false } } + fun closeList() { + if (inUl) { sb.append("\n"); inUl = false } + if (inOl) { sb.append("\n"); inOl = false } + } + + for ((index, element) in elements.withIndex()) { + when (element) { + is ImageElement -> { + closeParagraph() + closeList() + sb.append("
\n") + sb.append("\n") + sb.append("
\n") + } + is TextElement -> { + val line = element.line + val lineText = line.spans.joinToString("") { it.text } + val trimmed = lineText.trim() + + if (trimmed.isEmpty() || headerFooterStrings.any { hf -> trimmed.equals(hf, ignoreCase = true) }) { + closeParagraph() + continue + } + + val maxSize = line.spans.filter { it.text.isNotBlank() }.maxOfOrNull { it.size } ?: baseSize + val headingLevel = when { + maxSize > baseSize * 1.6f -> 1 + maxSize > baseSize * 1.28f -> 2 + maxSize > baseSize * 1.10f -> 3 + maxSize > baseSize * 1.04f -> 4 + else -> 0 + } + + val lineLen = trimmed.length + val isShort = lineLen < 60 + val isAllCaps = isShort && lineLen >= 3 && trimmed.any { it.isLetter() } && trimmed.all { it.isUpperCase() || !it.isLetter() } && !trimmed.endsWith(".") + val isBullet = trimmed.startsWith("•") || trimmed.startsWith("▪") || trimmed.startsWith("◦") || trimmed.startsWith("–") || (trimmed.startsWith("- ") && trimmed.length > 2 && !trimmed.startsWith("--")) + val numberedMatch = Regex("""^(\d{1,3}[.)]\s|\p{L}[.)]\s)""").containsMatchIn(trimmed) + val isHr = isShort && trimmed.length >= 3 && trimmed.all { it == '-' || it == '=' || it == '_' || it == '—' || it.isWhitespace() } + + val effectiveHeading = when { + headingLevel > 0 -> headingLevel + isAllCaps && !isBullet && !numberedMatch -> 2 + else -> 0 + } + + val nextTextElem = elements.drop(index + 1).firstOrNull { it is TextElement && it.line.spans.joinToString(""){ s->s.text}.isNotBlank() } as? TextElement + val shouldBreakParagraph = effectiveHeading > 0 || isBullet || numberedMatch || isHr || + lineLen < wrapThreshold || + trimmed.last().let { it == '.' || it == '!' || it == '?' || it == ':' || it == '"' || it == '\u201d' } || + (nextTextElem != null && nextTextElem.line.spans.joinToString("") { it.text }.trimStart().let { it.startsWith("\u201c") || it.startsWith("\"") || it.startsWith("-") }) + + when { + isHr -> { + closeParagraph(); closeList() + sb.append("
\n") + } + effectiveHeading > 0 -> { + closeParagraph(); closeList() + val tag = "h${effectiveHeading.coerceIn(1, 4)}" + sb.append("<$tag>${renderSpans(line.spans, insideHeading = true)}\n") + } + isBullet -> { + closeParagraph() + if (inOl) { sb.append("\n"); inOl = false } + if (!inUl) { sb.append("
    \n"); inUl = true } + val content = trimmed.removePrefix("•").removePrefix("▪").removePrefix("◦").removePrefix("–").removePrefix("- ").trim() + sb.append("
  • ${content.escapeHtml()}
  • \n") + } + numberedMatch -> { + closeParagraph() + if (inUl) { sb.append("
\n"); inUl = false } + if (!inOl) { sb.append("
    \n"); inOl = true } + val content = trimmed.substringAfter(" ").trim() + sb.append("
  1. ${content.escapeHtml()}
  2. \n") + } + shouldBreakParagraph -> { + closeList() + if (!inParagraph) { sb.append("

    "); inParagraph = true } + sb.append(renderSpans(line.spans)) + closeParagraph() + } + else -> { + closeList() + if (!inParagraph) { sb.append("

    "); inParagraph = true } else sb.append(" ") + sb.append(renderSpans(line.spans)) + } + } + } + } + } + + closeParagraph() + closeList() + sb.append("

\n") + return sb.toString() + } + + private fun renderSpans(spans: List, insideHeading: Boolean = false): String { + val sb = StringBuilder() + for (span in spans) { + val s = span.text.escapeHtml() + if (s.isBlank()) { sb.append(s); continue } + + val leadCount = s.length - s.trimStart().length + val trailCount = s.length - s.trimEnd().length + val pre = s.take(leadCount) + val post = if (trailCount > 0) s.takeLast(trailCount) else "" + val mid = s.substring(leadCount, s.length - trailCount) + + if (mid.isEmpty()) { sb.append(s); continue } + + sb.append(pre) + if (!insideHeading) { + if (span.isBold && span.isItalic) sb.append("") + else if (span.isBold) sb.append("") + else if (span.isItalic) sb.append("") + } + sb.append(mid) + if (!insideHeading) { + if (span.isBold && span.isItalic) sb.append("") + else if (span.isBold) sb.append("") + else if (span.isItalic) sb.append("") + } + sb.append(post) + } + return sb.toString() + } + + private suspend fun detectRepeatingHeaderFooter( + doc: PdfDocumentKt, + totalPages: Int + ): Set = withContext(Dispatchers.Default) { + if (totalPages < 5) return@withContext emptySet() + + val step = maxOf(1, totalPages / 8) + val samplePages = (0 until totalPages).filter { it % step == 0 }.take(8) + val frequency = HashMap() + + for (pageIdx in samplePages) { + try { + doc.openPage(pageIdx).use { page -> + page.openTextPage().use { textPage -> + val charCount = textPage.textPageCountChars() + if (charCount <= 0) return@use + val rawText = textPage.textPageGetText(0, charCount) ?: return@use + + val lines = rawText.split('\n').map { it.trim() }.filter { it.length > 2 } + if (lines.isNotEmpty()) { + val edgeLines = lines.take(2) + lines.takeLast(2) + for (line in edgeLines) { + frequency[line] = (frequency[line] ?: 0) + 1 + } + } + } + } + } catch (e: Exception) { + Timber.tag(TAG).w(e, "Header/footer sampling failed for page $pageIdx") + } + } + frequency.filter { it.value >= 3 }.keys.toSet() + } + + private fun String.escapeHtml(): String = this + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """) + .replace("'", "'") +} \ No newline at end of file diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfToMarkdownGenerator.kt b/app/src/main/java/com/aryan/reader/pdf/PdfToMarkdownGenerator.kt deleted file mode 100644 index 3fdd119..0000000 --- a/app/src/main/java/com/aryan/reader/pdf/PdfToMarkdownGenerator.kt +++ /dev/null @@ -1,273 +0,0 @@ -// PdfToMarkdownGenerator.kt -package com.aryan.reader.pdf - -import android.content.Context -import android.net.Uri -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 -import java.io.File -import kotlin.math.roundToInt - -object PdfToMarkdownGenerator { - const val PAGE_DELIMITER = "\n\n[[PAGE_BREAK]]\n\n" - - suspend fun generateMarkdownFile( - context: Context, - pdfUri: Uri, - destFile: File, - 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 { - val doc = pdfiumCore.newDocument(pfd) - val totalPages = doc.getPageCount() - Timber.tag("PdfToMdPerf").d("Document loaded natively. Total Pages: $totalPages") - - destFile.bufferedWriter().use { writer -> - for (pageIdx in (startPage - 1) until totalPages) { - val pageMd = extractPageMarkdown(doc, pageIdx) - writer.write(pageMd) - writer.write(PAGE_DELIMITER) - - 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 natively") - pfd.close() - return@withContext false - } - } - - 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 "" - - val text = textPage.textPageGetText(0, charCount) ?: "" - val actualCount = minOf(charCount, text.length) - - val rawPtr = textPage.page.pagePtr - - val sizes: FloatArray? - val weights: IntArray? - val flags: IntArray? - - 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.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 - ) - - 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() - 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() - @Suppress("CanBeVal") var currentSpans = mutableListOf() - 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() - } -} \ No newline at end of file diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt b/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt index 8788715..c085929 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt @@ -18,7 +18,9 @@ * mail: epistemereader@gmail.com */ // PdfViewerScreen.kt -@file:Suppress("COMPOSE_APPLIER_CALL_MISMATCH", "Unused", "UnusedVariable") +@file:Suppress("COMPOSE_APPLIER_CALL_MISMATCH", "Unused", "UnusedVariable", + "SimplifyBooleanWithConstants" +) package com.aryan.reader.pdf @@ -27,6 +29,11 @@ import android.annotation.SuppressLint import android.app.Activity import android.content.Context import android.content.pm.PackageManager +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.compose.ui.platform.LocalLifecycleOwner +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import android.graphics.Bitmap import android.graphics.RectF import android.net.Uri @@ -221,6 +228,7 @@ import androidx.core.graphics.createBitmap import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsControllerCompat +import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.media3.common.util.UnstableApi import androidx.paging.LoadState import androidx.paging.compose.LazyPagingItems @@ -740,9 +748,9 @@ fun PdfViewerScreen( val uiState by viewModel.uiState.collectAsState() val reflowBookId = remember(bookId) { "${bookId}_reflow" } - val hasReflowFile by remember(uiState.recentFiles, reflowBookId) { + val hasReflowFile by remember(uiState.allRecentFiles, reflowBookId) { derivedStateOf { - uiState.recentFiles.any { it.bookId == reflowBookId && !it.isDeleted } + uiState.allRecentFiles.any { it.bookId == reflowBookId && !it.isDeleted } } } val originalFileName by remember(uiState.recentFiles, pdfUri) { @@ -1029,18 +1037,155 @@ fun PdfViewerScreen( var areAnnotationsLoaded by remember { mutableStateOf(false) } - LaunchedEffect(allAnnotations) { - if (areAnnotationsLoaded && currentBookId != null) { - delay(1000) - - withContext(Dispatchers.IO) { - Timber.d("Auto-saving annotations locally for book $currentBookId") - annotationRepository.saveAnnotations(currentBookId!!, allAnnotations) + val richTextRepository = remember(context) { PdfRichTextRepository(context) } + val richTextController = remember(currentBookId) { + if (currentBookId != null) RichTextController( + richTextRepository, + coroutineScope, + currentBookId!! + ) + else null + } + var pdfDocument by remember { mutableStateOf(null) } + var pfdState by remember { mutableStateOf(null) } + var totalPages by remember { mutableIntStateOf(0) } + var currentPageScale by remember { mutableFloatStateOf(1f) } + val textBoxes = remember { mutableStateListOf() } + var selectedTextBoxId by remember { mutableStateOf(null) } + val userHighlights = remember { mutableStateListOf() } + val drawingState = remember { PdfDrawingState() } + val pdfiumCore = remember(context) { PdfiumCoreKt(Dispatchers.Default) } + val verticalReaderState = rememberVerticalPdfReaderState() + var virtualPages by remember { mutableStateOf>(emptyList()) } + val totalDisplayPages by remember(virtualPages, totalPages) { + derivedStateOf { if (virtualPages.isNotEmpty()) virtualPages.size else totalPages } + } + val pagerState = rememberPagerState(initialPage = 0, pageCount = { totalDisplayPages }) + val currentPage by remember { + derivedStateOf { + when (displayMode) { + DisplayMode.PAGINATION -> pagerState.currentPage + DisplayMode.VERTICAL_SCROLL -> verticalReaderState.currentPage } } } - val drawingState = remember { PdfDrawingState() } + val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current + val saveMutex = remember { Mutex() } + + var initialScrollDone by remember { mutableStateOf(false) } + var isDocumentReady by remember { mutableStateOf(false) } + + val lastSavedHashes = remember(currentBookId) { IntArray(5) { 0 } } + + val currentAnnotations by rememberUpdatedState(allAnnotations) + val currentTextBoxes by rememberUpdatedState(textBoxes.toList()) + val currentHighlights by rememberUpdatedState(userHighlights.toList()) + val currentBookmarks by rememberUpdatedState(bookmarks) + val currentTotalPages by rememberUpdatedState(totalDisplayPages) + val currentPageState by rememberUpdatedState(currentPage) + + val saveAllData = remember(currentBookId, annotationRepository, textBoxRepository, highlightRepository) { + { force: Boolean -> + coroutineScope.launch { + val bookId = currentBookId ?: return@launch + val annots = currentAnnotations + val boxes = currentTextBoxes + val highlights = currentHighlights + val bms = currentBookmarks + val page = currentPageState + val totalPgs = currentTotalPages + + val annotsHash = annots.hashCode() + val boxesHash = boxes.hashCode() + val highlightsHash = highlights.hashCode() + val bmsHash = bms.hashCode() + + // Protect the lock and I/O execution with NonCancellable + withContext(NonCancellable) { + saveMutex.withLock { + withContext(Dispatchers.IO) { + var didSave = false + + if (force || annotsHash != lastSavedHashes[0]) { + annotationRepository.saveAnnotations(bookId, annots) + lastSavedHashes[0] = annotsHash + didSave = true + } + if (force || boxesHash != lastSavedHashes[1]) { + textBoxRepository.saveTextBoxes(bookId, boxes) + lastSavedHashes[1] = boxesHash + didSave = true + } + if (force || highlightsHash != lastSavedHashes[2]) { + highlightRepository.saveHighlights(bookId, highlights) + lastSavedHashes[2] = highlightsHash + didSave = true + } + if (force || bmsHash != lastSavedHashes[3]) { + val objectList = bms.map { bookmark -> + JSONObject().apply { + put("pageIndex", bookmark.pageIndex) + put("title", bookmark.title) + put("totalPages", bookmark.totalPages) + } + } + val bookmarksJson = JSONArray(objectList).toString() + withContext(Dispatchers.Main) { + onBookmarksChanged(bookmarksJson) + } + lastSavedHashes[3] = bmsHash + didSave = true + } + if (force || page != lastSavedHashes[4]) { + if (totalPgs > 0) { + withContext(Dispatchers.Main) { + onSavePosition(page, totalPgs) + } + } + lastSavedHashes[4] = page + } + + if (didSave) { + Timber.tag("PdfSavePerf").d("Saved data for book $bookId") + } + } + } + } + } + } + } + + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_PAUSE || event == Lifecycle.Event.ON_STOP) { + Timber.tag("PdfSavePerf").i("Lifecycle $event triggered, forcing save.") + coroutineScope.launch { + if (richTextController != null) { + withContext(NonCancellable) { richTextController.saveImmediate() } + } + saveAllData(true).join() + } + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + } + } + + LaunchedEffect( + allAnnotations, + textBoxes.toList(), + userHighlights.toList(), + bookmarks, + currentPage + ) { + if (areAnnotationsLoaded && currentBookId != null && initialScrollDone) { + delay(2000) // Debounce period + saveAllData(false) + } + } val allAnnotationsProvider = remember { { allAnnotations } } @@ -1049,19 +1194,6 @@ fun PdfViewerScreen( Timber.d("PdfViewerScreen init: Loaded ${bookmarks.size} bookmarks initially.") } - LaunchedEffect(bookmarks) { - val objectList = bookmarks.map { bookmark -> - JSONObject().apply { - put("pageIndex", bookmark.pageIndex) - put("title", bookmark.title) - put("totalPages", bookmark.totalPages) - } - } - val bookmarksJson = JSONArray(objectList).toString() - Timber.d("Bookmarks changed. Firing onBookmarksChanged with JSON: $bookmarksJson") - onBookmarksChanged(bookmarksJson) - } - var flatTableOfContents by remember { mutableStateOf>(emptyList()) } var showDictionaryUpsellDialog by remember { mutableStateOf(false) } var showSummarizationUpsellDialog by remember { mutableStateOf(false) } @@ -1080,17 +1212,8 @@ fun PdfViewerScreen( var errorMessage by remember { mutableStateOf(null) } var isLoadingDocument by remember { mutableStateOf(true) } - var pdfDocument by remember { mutableStateOf(null) } - var pfdState by remember { mutableStateOf(null) } - var totalPages by remember { mutableIntStateOf(0) } - var currentPageScale by remember { mutableFloatStateOf(1f) } - - var initialScrollDone by remember { mutableStateOf(false) } - var isDocumentReady by remember { mutableStateOf(false) } - var selectionClearTrigger by remember { mutableLongStateOf(0L) } - var virtualPages by remember { mutableStateOf>(emptyList()) } val displayPageRatios by remember(pageAspectRatios, virtualPages) { derivedStateOf { if (virtualPages.isEmpty()) { @@ -1108,16 +1231,6 @@ fun PdfViewerScreen( } } - val richTextRepository = remember(context) { PdfRichTextRepository(context) } - val richTextController = remember(currentBookId) { - if (currentBookId != null) RichTextController( - richTextRepository, - coroutineScope, - currentBookId!! - ) - else null - } - LaunchedEffect(richTextController, toolSettings.textStyle) { richTextController?.let { controller -> val config = toolSettings.textStyle @@ -1142,13 +1255,6 @@ fun PdfViewerScreen( } } - val pdfiumCore = remember(context) { PdfiumCoreKt(Dispatchers.Default) } - val verticalReaderState = rememberVerticalPdfReaderState() - val totalDisplayPages by remember(virtualPages, totalPages) { - derivedStateOf { if (virtualPages.isNotEmpty()) virtualPages.size else totalPages } - } - val pagerState = rememberPagerState(initialPage = 0, pageCount = { totalDisplayPages }) - LaunchedEffect(currentBookId) { if (currentBookId != null) richTextRepository.load(currentBookId!!) } @@ -1170,21 +1276,8 @@ fun PdfViewerScreen( } } - val currentPage by remember { - derivedStateOf { - when (displayMode) { - DisplayMode.PAGINATION -> pagerState.currentPage - DisplayMode.VERTICAL_SCROLL -> verticalReaderState.currentPage - } - } - } Timber.d("Derived currentPage recomposed. New value: $currentPage (Mode: $displayMode)") - val textBoxes = remember { mutableStateListOf() } - var selectedTextBoxId by remember { mutableStateOf(null) } - - val userHighlights = remember { mutableStateListOf() } - val onHighlightAdd = remember(pdfDocument, currentBookId) { { pageIndex: Int, range: Pair, text: String, color: PdfHighlightColor -> Timber.tag("PdfExportDebug").i("onHighlightAdd: Adding persistent highlight. Page: $pageIndex, Text: ${text.take(20)}...") @@ -1695,16 +1788,6 @@ fun PdfViewerScreen( Timber.d("Pager state changed: pagerState.currentPage is now ${pagerState.currentPage}") } - LaunchedEffect(currentPage, totalPages) { - if (totalPages > 0 && initialScrollDone) { - delay(500L) - Timber.d( - "Debounced save: Calling onSavePosition(page=$currentPage, totalPages=$totalPages)" - ) - onSavePosition(currentPage, totalPages) - } - } - LaunchedEffect(displayMode) { coroutineScope.launch { if (displayMode == DisplayMode.VERTICAL_SCROLL) { @@ -1861,26 +1944,6 @@ fun PdfViewerScreen( } } - LaunchedEffect(textBoxes.toList()) { - if (currentBookId != null) { - delay(1000) - withContext(Dispatchers.IO) { - Timber.d("Auto-saving text boxes locally for book $currentBookId") - textBoxRepository.saveTextBoxes(currentBookId!!, textBoxes.toList()) - } - } - } - - LaunchedEffect(userHighlights.toList()) { - if (currentBookId != null) { - delay(1000) - withContext(Dispatchers.IO) { - Timber.d("Auto-saving highlights locally for book $currentBookId") - highlightRepository.saveHighlights(currentBookId!!, userHighlights.toList()) - } - } - } - var pendingSaveMode by remember { mutableStateOf(null) } val saveLauncher = rememberLauncherForActivityResult( @@ -1970,33 +2033,15 @@ fun PdfViewerScreen( ttsController.stop() coroutineScope.launch { - withContext(NonCancellable) { - if (richTextController != null) { + if (richTextController != null) { + withContext(NonCancellable) { Timber.tag("RichTextFlow").d("Forcing RichTextController immediate sync and save...") richTextController.saveImmediate() } - - if (totalDisplayPages > 0) { - onSavePosition(currentPage, totalDisplayPages) - - if (currentBookId != null) { - annotationRepository.saveAnnotations(currentBookId!!, allAnnotations) - textBoxRepository.saveTextBoxes(currentBookId!!, textBoxes.toList()) - highlightRepository.saveHighlights(currentBookId!!, userHighlights.toList()) - } - - val objectList = bookmarks.map { bookmark -> - JSONObject().apply { - put("pageIndex", bookmark.pageIndex) - put("title", bookmark.title) - put("totalPages", bookmark.totalPages) - } - } - val bookmarksJson = JSONArray(objectList).toString() - onBookmarksChanged(bookmarksJson) - } } + saveAllData(true).join() + Timber.tag("AnnotationSync").d("Save complete. Navigating back.") onNavigateBack() } @@ -4954,18 +4999,33 @@ fun PdfViewerScreen( enabled = pdfDocument != null && !isReflowingThisBook, onClick = { showMoreMenu = false - if (hasReflowFile) { - val item = uiState.recentFiles.find { it.bookId == reflowBookId } - if (item != null) { - viewModel.switchToFileSeamlessly(item, currentPage) + + coroutineScope.launch { + if (richTextController != null) { + withContext(NonCancellable) { richTextController.saveImmediate() } + } + saveAllData(true).join() + + if (hasReflowFile) { + val item = uiState.allRecentFiles.find { it.bookId == reflowBookId } + if (item != null) { + viewModel.switchToFileSeamlessly(item, currentPage) + } else { + viewModel.generateAndImportReflowFile( + pdfBookId = bookId, + pdfUri = pdfUri, + originalTitle = originalFileName, + autoOpenPage = currentPage + ) + } + } else { + viewModel.generateAndImportReflowFile( + pdfBookId = bookId, + pdfUri = pdfUri, + originalTitle = originalFileName, + autoOpenPage = currentPage + ) } - } else { - viewModel.generateAndImportReflowFile( - pdfBookId = bookId, - pdfUri = pdfUri, - originalTitle = originalFileName, - autoOpenPage = currentPage - ) } }, leadingIcon = { diff --git a/app/src/main/java/com/aryan/reader/pdf/ReflowWorker.kt b/app/src/main/java/com/aryan/reader/pdf/ReflowWorker.kt index d2b1820..b539b4c 100644 --- a/app/src/main/java/com/aryan/reader/pdf/ReflowWorker.kt +++ b/app/src/main/java/com/aryan/reader/pdf/ReflowWorker.kt @@ -21,86 +21,83 @@ class ReflowWorker( override suspend fun doWork(): Result = withContext(Dispatchers.IO) { val workStartTime = System.currentTimeMillis() - Timber.tag("PdfToMdPerf").d("=== ReflowWorker START ===") + Timber.tag("PdfToHtmlPerf").d("=== ReflowWorker START ===") val bookId = inputData.getString(KEY_BOOK_ID) ?: run { - Timber.tag("PdfToMdPerf").e("FAILURE: KEY_BOOK_ID is null") + Timber.tag("PdfToHtmlPerf").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") + Timber.tag("PdfToHtmlPerf").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") + Timber.tag("PdfToHtmlPerf").d( + "Input | bookId=$bookId | reflowBookId=$reflowBookId | pdfUri=$pdfUriString | title=$originalTitle" + ) - val destFile = File(applicationContext.filesDir, "${bookId}_reflow.md") + val destFile = File(applicationContext.filesDir, "${bookId}_reflow.html") val pdfUri = pdfUriString.toUri() - Timber.tag("PdfToMdPerf").d("Dest file path: ${destFile.absolutePath} | exists=${destFile.exists()}") - Timber.tag("PdfToMdPerf").d("Starting PdfToMarkdownGenerator.generateMarkdownFile...") - val genStartTime = System.currentTimeMillis() + Timber.tag("PdfToHtmlPerf").d("Dest: ${destFile.absolutePath} | exists=${destFile.exists()}") - val success = PdfToMarkdownGenerator.generateMarkdownFile( - applicationContext, - pdfUri, - destFile, + val genStartTime = System.currentTimeMillis() + val success = PdfToHtmlGenerator.generateHtmlFile( + context = applicationContext, + pdfUri = pdfUri, + destFile = destFile, startPage = 1 ) { 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") + Timber.tag("PdfToHtmlPerf").d( + "generateHtmlFile done | success=$success | ${System.currentTimeMillis() - genStartTime}ms" + ) if (success && destFile.exists()) { 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() + Timber.tag("PdfToHtmlPerf").d("Output size: ${fileSizeKB}KB") val repo = RecentFilesRepository(applicationContext) - val newItem = RecentFileItem( - bookId = reflowBookId, - uriString = destFile.toUri().toString(), - type = FileType.MD, - displayName = "$originalTitle (Text View)", - timestamp = System.currentTimeMillis(), - coverImagePath = null, - title = "$originalTitle (Reflow)", - author = "Generated", - isAvailable = true, - isRecent = true, + bookId = reflowBookId, + uriString = destFile.toUri().toString(), + type = FileType.HTML, + displayName = "$originalTitle (Text View)", + timestamp = System.currentTimeMillis(), + coverImagePath = null, + title = "$originalTitle (Reflow)", + author = "Generated", + isAvailable = true, + isRecent = true, lastModifiedTimestamp = System.currentTimeMillis(), - isDeleted = false, - sourceFolderUri = null + isDeleted = false, + sourceFolderUri = null ) repo.addRecentFile(newItem) - Timber.tag("PdfToMdPerf").d("Database import completed in ${System.currentTimeMillis() - dbStartTime}ms") - 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") + Timber.tag("PdfToHtmlPerf").d("=== ReflowWorker SUCCESS === | ${totalTime}ms") return@withContext Result.success() } else { val totalTime = System.currentTimeMillis() - workStartTime - Timber.tag("PdfToMdPerf").e("=== ReflowWorker FAILURE === | success=$success | fileExists=${destFile.exists()} | totalTime=${totalTime}ms") + Timber.tag("PdfToHtmlPerf").e( + "=== ReflowWorker FAILURE === | success=$success | fileExists=${destFile.exists()} | ${totalTime}ms" + ) return@withContext Result.failure() } } companion object { - const val WORK_NAME = "reflow_work" - const val KEY_BOOK_ID = "book_id" - const val KEY_PDF_URI = "pdf_uri" + const val WORK_NAME = "reflow_work" + const val KEY_BOOK_ID = "book_id" + const val KEY_PDF_URI = "pdf_uri" const val KEY_ORIGINAL_TITLE = "original_title" const val KEY_PROGRESS = "progress" }