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)}$tag>\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("- ${content.escapeHtml()}
\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"
}