Pdf reflow upgrade (#63)

* Replaced PDF-to-Markdown reflow with an enhanced PDF-to-HTML generator.

Specific changes include:
- Replaced `PdfToMarkdownGenerator` and `PdfReflowGenerator` with `PdfToHtmlGenerator`.
- Added native bridge methods in `NativePdfiumBridge.kt` and `pdfium_bridge.cpp` to extract character bounding boxes, page objects, and image pixels.
- Implemented vertical merging of text and images in `PdfToHtmlGenerator` to maintain document layout.
- Added logic to detect and filter repeating headers and footers across PDF pages.
- Updated `ReflowWorker` to generate `.html` files instead of `.md` files and updated `FileType` handling.
- Simplified `SingleFileImporter` by removing dependencies on the legacy Markdown generator.

* Updated PDF to HTML generation to include page breaks and split HTML files into individual chapters based on page markers.

* Added junk character filtering and normalization to PDF text extraction

* - Added `allRecentFiles` to `ReaderScreenState` to track all files, including reflowed versions.
- Updated `recentFiles` in `MainViewModel` to filter out reflowed files (`_reflow`) from the main library view.
- Implemented `deleteBookPermanently` in `MainViewModel` to handle book deletion and cache cleanup.
- Added a "Delete Text View" option to the `EpubReader` controls for reflowed files.
- Improved reflow file detection in `PdfViewerScreen` by checking against `allRecentFiles`.

* Implemented a centralized data-saving mechanism in `PdfViewerScreen` using a debounced `saveAllData` function. This refactor consolidates the saving of annotations, text boxes, highlights, bookmarks, and scroll positions, adding lifecycle-aware triggers and a mutex to ensure data integrity during pauses or document navigation.

* Optimized PDF selection and annotation performance by offloading heavy operations to background threads and improving UI responsiveness.

- Moved PDF page/text opening, character range calculations, and text extraction to `Dispatchers.IO`.
- Wrapped UI updates in `updateSelectionVisuals` and selection logic with `withContext(Dispatchers.Main)`.
- Implemented a more efficient `Popup`-based magnifier to replace manual offsets and transformations.
- Updated `PdfSelectionMenuPopup` properties to prevent focus and click-outside dismissal conflicts.
This commit is contained in:
Aryan 2026-03-13 17:02:09 +05:30 committed by GitHub
parent 9a95b4afcd
commit 843a77d0ef
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 1270 additions and 895 deletions

View file

@ -12,13 +12,34 @@
typedef double (*FPDFText_GetFontSize_t)(void* text_page, int index); typedef double (*FPDFText_GetFontSize_t)(void* text_page, int index);
typedef int (*FPDFText_GetFontWeight_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_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 int (*FPDFPage_GetAnnotCount_t)(void* page);
typedef void* (*FPDFPage_GetAnnot_t)(void* page, int index); typedef void* (*FPDFPage_GetAnnot_t)(void* page, int index);
typedef int (*FPDFAnnot_GetSubtype_t)(void* annot); typedef int (*FPDFAnnot_GetSubtype_t)(void* annot);
typedef int (*FPDFAnnot_GetRect_t)(void* annot, void* rect); 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 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 (*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_GetAnnotCount_t get_annot_count_func = nullptr;
static FPDFPage_GetAnnot_t get_annot_func = nullptr; static FPDFPage_GetAnnot_t get_annot_func = nullptr;
static FPDFAnnot_GetSubtype_t get_annot_subtype_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_GetFontSize_t get_font_size_func = nullptr;
static FPDFText_GetFontWeight_t get_font_weight_func = nullptr; static FPDFText_GetFontWeight_t get_font_weight_func = nullptr;
static FPDFText_GetFontInfo_t get_font_info_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* (*FPDFAnnot_GetLinkedAnnot_t)(void* annot, const char* key);
typedef void (*FPDFPage_CloseAnnot_t)(void* annot); 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_size_func = (FPDFText_GetFontSize_t) dlsym(pdfium_handle, "FPDFText_GetFontSize");
get_font_weight_func = (FPDFText_GetFontWeight_t) dlsym(pdfium_handle, "FPDFText_GetFontWeight"); 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_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_count_func = (FPDFPage_GetAnnotCount_t) dlsym(pdfium_handle, "FPDFPage_GetAnnotCount");
get_annot_func = (FPDFPage_GetAnnot_t) dlsym(pdfium_handle, "FPDFPage_GetAnnot"); 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"); get_linked_annot_func = (FPDFAnnot_GetLinkedAnnot_t) dlsym(pdfium_handle, "FPDFAnnot_GetLinkedAnnot");
close_annot_func = (FPDFPage_CloseAnnot_t) dlsym(pdfium_handle, "FPDFPage_CloseAnnot"); 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 && bool success = get_annot_count_func && get_annot_func && get_annot_subtype_func &&
get_annot_rect_func && get_annot_string_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; 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<void*>(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 extern "C" JNIEXPORT jint JNICALL
Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotCount(JNIEnv *env, jclass clazz, jlong pagePtr) { Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotCount(JNIEnv *env, jclass clazz, jlong pagePtr) {
if (!init_pdfium() || !get_annot_count_func) return 0; if (!init_pdfium() || !get_annot_count_func) return 0;
@ -193,3 +248,82 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotString(JNIEnv *env, jclass
env->ReleaseStringUTFChars(key, nativeKey); env->ReleaseStringUTFChars(key, nativeKey);
return result; 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<void*>(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<void*>(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<void*>(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<void*>(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;
}

View file

@ -169,7 +169,6 @@ data class ReaderScreenState(
val selectedFileType: FileType? = null, val selectedFileType: FileType? = null,
val isLoading: Boolean = false, val isLoading: Boolean = false,
val errorMessage: String? = null, val errorMessage: String? = null,
val recentFiles: List<RecentFileItem> = emptyList(),
val contextualActionItems: Set<RecentFileItem> = emptySet(), val contextualActionItems: Set<RecentFileItem> = emptySet(),
val renderMode: RenderMode = RenderMode.VERTICAL_SCROLL, val renderMode: RenderMode = RenderMode.VERTICAL_SCROLL,
val sortOrder: SortOrder = SortOrder.RECENT, val sortOrder: SortOrder = SortOrder.RECENT,
@ -206,7 +205,9 @@ data class ReaderScreenState(
val searchQuery: String = "", val searchQuery: String = "",
val showFolderMigrationDialog: Boolean = false, val showFolderMigrationDialog: Boolean = false,
val isRefreshing: Boolean = false, val isRefreshing: Boolean = false,
val reflowProgress: Float? = null val reflowProgress: Float? = null,
val recentFiles: List<RecentFileItem> = emptyList(),
val allRecentFiles: List<RecentFileItem> = emptyList(),
) )
open class MainViewModel(application: Application) : AndroidViewModel(application) { open class MainViewModel(application: Application) : AndroidViewModel(application) {
@ -300,61 +301,42 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
open val uiState: StateFlow<ReaderScreenState> = combine( open val uiState: StateFlow<ReaderScreenState> = combine(
_internalState, recentFilesRepository.getRecentFilesFlow(), _prefsUpdateFlow _internalState, recentFilesRepository.getRecentFilesFlow(), _prefsUpdateFlow
) { internalState, recentFilesFromDb, _ -> ) { 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 query = internalState.searchQuery.trim()
val filteredFiles = if (query.isBlank()) { val rawFilteredByQuery = if (query.isBlank()) {
recentFilesFromDb recentFilesFromDb
} else { } else {
recentFilesFromDb.filter { item -> recentFilesFromDb.filter { item ->
item.displayName.contains(query, ignoreCase = true) || item.title?.contains( item.displayName.contains(query, ignoreCase = true) ||
query, ignoreCase = true item.title?.contains(query, ignoreCase = true) == true ||
) == true || item.author?.contains(query, ignoreCase = true) == true item.author?.contains(query, ignoreCase = true) == true
} }
} }
val sortedRecentFiles = when (internalState.sortOrder) { val sortedAllFiles = when (internalState.sortOrder) {
SortOrder.RECENT -> filteredFiles // Changed from recentFilesFromDb SortOrder.RECENT -> rawFilteredByQuery
SortOrder.TITLE_ASC -> filteredFiles.sortedBy { SortOrder.TITLE_ASC -> rawFilteredByQuery.sortedBy { it.title?.lowercase() ?: it.displayName.lowercase() }
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 }
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 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 shelfNames = prefs.getStringSet(KEY_SHELVES, emptySet()) ?: emptySet()
val shelvedBookIds = mutableSetOf<String>() val shelvedBookIds = mutableSetOf<String>()
val shelvesFromPrefs = shelfNames.map { shelfName -> val shelvesFromPrefs = shelfNames.map { shelfName ->
val bookIds = prefs.getStringSet( val bookIds = prefs.getStringSet("$KEY_SHELF_CONTENT_PREFIX$shelfName", emptySet()) ?: emptySet()
"$KEY_SHELF_CONTENT_PREFIX$shelfName", emptySet() val booksForShelf = visibleRecentFiles.filter { it.bookId in bookIds }
) ?: emptySet() shelvedBookIds.addAll(booksForShelf.map { it.bookId })
shelvedBookIds.addAll(bookIds)
val booksForShelf = sortedRecentFiles.filter {
it.bookId in bookIds
}
Shelf(shelfName, booksForShelf) Shelf(shelfName, booksForShelf)
}.sortedBy { it.name } }.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 allShelves = shelvesFromPrefs + Shelf("Unshelved", unshelvedBooks)
val booksAvailableForAdding = val booksAvailableForAdding =
@ -365,7 +347,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
when (internalState.addBooksSource) { when (internalState.addBooksSource) {
AddBooksSource.UNSHELVED -> unshelvedBooks AddBooksSource.UNSHELVED -> unshelvedBooks
AddBooksSource.ALL_BOOKS -> sortedRecentFiles.filter { AddBooksSource.ALL_BOOKS -> visibleRecentFiles.filter {
it.uriString !in currentShelfBooksUris it.uriString !in currentShelfBooksUris
} }
} }
@ -374,7 +356,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
} }
internalState.copy( internalState.copy(
recentFiles = sortedRecentFiles, recentFiles = visibleRecentFiles,
allRecentFiles = sortedAllFiles,
contextualActionItems = validContextualItems, contextualActionItems = validContextualItems,
shelves = allShelves, shelves = allShelves,
booksAvailableForAdding = booksAvailableForAdding booksAvailableForAdding = booksAvailableForAdding
@ -751,6 +734,29 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
viewModelScope.launch { fontsRepository.deleteFont(fontId) } 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 { private fun getInstallationId(): String {
var installationId = prefs.getString(KEY_INSTALLATION_ID, null) var installationId = prefs.getString(KEY_INSTALLATION_ID, null)
if (installationId == null) { if (installationId == null) {

View file

@ -21,7 +21,6 @@ package com.aryan.reader.epub
import android.content.Context import android.content.Context
import com.aryan.reader.FileType import com.aryan.reader.FileType
import com.aryan.reader.pdf.PdfToMarkdownGenerator
import com.vladsch.flexmark.ext.autolink.AutolinkExtension import com.vladsch.flexmark.ext.autolink.AutolinkExtension
import com.vladsch.flexmark.ext.gfm.strikethrough.StrikethroughExtension import com.vladsch.flexmark.ext.gfm.strikethrough.StrikethroughExtension
import com.vladsch.flexmark.ext.gfm.tasklist.TaskListExtension 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; } hr { border: 0; border-top: 1px solid #ccc; margin: 2em 0; }
""".trimIndent() """.trimIndent()
val delimiter = PdfToMarkdownGenerator.PAGE_DELIMITER.trim() val rawChapters = if (markdownContent.contains("\n\n---\n\n")) {
val rawChapters = if (markdownContent.contains(delimiter)) {
markdownContent.split(delimiter)
} else {
markdownContent.split("\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") 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() } val author = doc.select("meta[name=author]").attr("content").takeIf { it.isNotBlank() }
?: doc.select("meta[property=article: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") val rawChapters = if (bodyHtml.contains("<page-break></page-break>")) {
createBookFromHtmlBody(title, null, null, originalBookNameHint, bookId, extractionDir, metadataFile, preGeneratedFullHtml = finalHtml, author = author) bodyHtml.split("<page-break></page-break>")
} } else {
listOf(bodyHtml)
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 ?: """
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<title>${title.replace("\"", "&quot;")}</title>
<style>
${cssStyle ?: ""}
</style>
</head>
<body>
$bodyContent
</body>
</html>
""".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 chapter = EpubChapter( val chapters = rawChapters.mapIndexed { index, rawText ->
chapterId = bookId, async(Dispatchers.Default) {
absPath = "content.html", if (rawText.isBlank()) return@async null
title = title,
htmlFilePath = "content.html", val pageNum = index + 1
plainTextContent = plainText, val chapterTitle = if (rawChapters.size > 1) "Page $pageNum" else title
htmlContent = "", val fileName = "page_$pageNum.html"
depth = 0, val file = File(extractionDir, fileName)
isInToc = true
) val fullHtml = """
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<title>${title.replace("\"", "&quot;")}</title>
<style>${cssStyle}</style>
</head>
<body>
${rawText.trim()}
</body>
</html>
""".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( val book = EpubBook(
fileName = fileName, fileName = originalBookNameHint,
title = title, title = title,
author = author ?: "", author = author ?: "Unknown",
language = "en", language = "en",
coverImage = null, coverImage = null,
chapters = listOf(chapter), chapters = chapters,
chaptersForPagination = listOf(chapter), chaptersForPagination = chapters,
images = emptyList(), images = emptyList(),
pageList = emptyList(), pageList = emptyList(),
extractionBasePath = extractionDir.absolutePath, extractionBasePath = extractionDir.absolutePath,
@ -449,6 +444,6 @@ class SingleFileImporter(private val context: Context) {
Timber.e(e, "Failed to cache HTML metadata") Timber.e(e, "Failed to cache HTML metadata")
} }
return book return@withContext book
} }
} }

View file

@ -67,13 +67,13 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack 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.Add
import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.ChevronLeft import androidx.compose.material.icons.filled.ChevronLeft
import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.filled.Close 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.GraphicEq
import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.MoreVert
@ -153,6 +153,7 @@ fun EpubReaderTopBar(
searchFocusRequester: androidx.compose.ui.focus.FocusRequester, searchFocusRequester: androidx.compose.ui.focus.FocusRequester,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onToggleReflow: (() -> Unit)? = null, onToggleReflow: (() -> Unit)? = null,
onDeleteReflow: (() -> Unit)? = null,
) { ) {
AnimatedVisibility( AnimatedVisibility(
visible = isVisible, visible = isVisible,
@ -226,6 +227,27 @@ fun EpubReaderTopBar(
HorizontalDivider() 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( DropdownMenuItem(
text = { Text("Reading Mode: Vertical") }, text = { Text("Reading Mode: Vertical") },
enabled = !isTtsActive, enabled = !isTtsActive,
@ -386,7 +408,7 @@ fun EpubReaderBottomBar(
Icon(imageVector = Icons.Default.Search, contentDescription = "Search") Icon(imageVector = Icons.Default.Search, contentDescription = "Search")
} }
@Suppress("KotlinConstantConditions") @Suppress("KotlinConstantConditions", "SimplifyBooleanWithConstants")
if (BuildConfig.FLAVOR != "oss") { if (BuildConfig.FLAVOR != "oss") {
Box { Box {
var showAiFeaturesMenu by remember { mutableStateOf(false) } var showAiFeaturesMenu by remember { mutableStateOf(false) }

View file

@ -363,7 +363,16 @@ fun EpubReaderScreen(
onRenderModeChange = onRenderModeChange, onRenderModeChange = onRenderModeChange,
customFonts = customFonts, customFonts = customFonts,
onImportFont = onImportFont, 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, onRenderModeChange: (RenderMode) -> Unit,
customFonts: List<CustomFontEntity>, customFonts: List<CustomFontEntity>,
onImportFont: (Uri) -> Unit, onImportFont: (Uri) -> Unit,
onToggleReflow: ((Int) -> Unit)? = null onToggleReflow: ((Int) -> Unit)? = null,
onDeleteReflow: (() -> Unit)? = null
) { ) {
val view = LocalView.current val view = LocalView.current
val context = LocalContext.current val context = LocalContext.current
@ -3124,6 +3134,7 @@ fun EpubReaderHost(
onToggleReflow(activeChapter) onToggleReflow(activeChapter)
} }
} else null, } else null,
onDeleteReflow = onDeleteReflow
) )
val autoScrollPadding by androidx.compose.animation.core.animateDpAsState( val autoScrollPadding by androidx.compose.animation.core.animateDpAsState(

View file

@ -11,12 +11,19 @@ object NativePdfiumBridge {
@JvmStatic external fun getPageFontSizes(textPagePtr: Long, count: Int): FloatArray? @JvmStatic external fun getPageFontSizes(textPagePtr: Long, count: Int): FloatArray?
@JvmStatic external fun getPageFontWeights(textPagePtr: Long, count: Int): IntArray? @JvmStatic external fun getPageFontWeights(textPagePtr: Long, count: Int): IntArray?
@JvmStatic external fun getPageFontFlags(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 getAnnotCount(pagePtr: Long): Int
@JvmStatic external fun getAnnotSubtype(pagePtr: Long, index: Int): Int @JvmStatic external fun getAnnotSubtype(pagePtr: Long, index: Int): Int
@JvmStatic external fun getAnnotRect(pagePtr: Long, index: Int): FloatArray? @JvmStatic external fun getAnnotRect(pagePtr: Long, index: Int): FloatArray?
@JvmStatic external fun getAnnotString(pagePtr: Long, index: Int, key: String): String? @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_TEXT = 1 // Sticky Note
const val ANNOT_LINK = 2 // Link const val ANNOT_LINK = 2 // Link
const val ANNOT_HIGHLIGHT = 8 // Highlight const val ANNOT_HIGHLIGHT = 8 // Highlight

View file

@ -196,8 +196,8 @@ internal fun PdfSelectionMenuPopup(
popupPositionProvider = popupPositionProvider, popupPositionProvider = popupPositionProvider,
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
properties = PopupProperties( properties = PopupProperties(
focusable = true, focusable = false,
dismissOnClickOutside = true, dismissOnClickOutside = false,
dismissOnBackPress = true dismissOnBackPress = true
) )
) { ) {

View file

@ -71,7 +71,6 @@ import androidx.compose.ui.graphics.ColorMatrix
import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.StrokeJoin import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
@ -164,8 +163,8 @@ data class EmbeddedAnnotation(
val rect: android.graphics.RectF, val rect: android.graphics.RectF,
val contents: String?, val contents: String?,
val author: String?, val author: String?,
val name: String?, // Unique ID val name: String?,
val inReplyTo: String?, // ID of parent val inReplyTo: String?,
val replies: MutableList<EmbeddedAnnotation> = mutableListOf() val replies: MutableList<EmbeddedAnnotation> = mutableListOf()
) )
@ -1536,90 +1535,101 @@ internal fun PdfPageComposable(
providedTextPage: PdfTextPageKt? = null providedTextPage: PdfTextPageKt? = null
) { ) {
if (charRange == null || currentBitmapWidth == 0 || currentBitmapHeight == 0) { if (charRange == null || currentBitmapWidth == 0 || currentBitmapHeight == 0) {
selectedWordScreenRects = emptyList() withContext(Dispatchers.Main) {
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) {
selectedWordScreenRects = emptyList() selectedWordScreenRects = emptyList()
startHandleContentPosition.value = null startHandleContentPosition.value = null
endHandleContentPosition.value = null endHandleContentPosition.value = null
return
} }
return
}
val length = endIndex - startIndex withContext(Dispatchers.IO) {
val wordPdfRectsF = var localPage: PdfPageKt? = null
textPageToUse.textPageGetRectsForRanges(intArrayOf(startIndex, length))?.map { var localTextPage: PdfTextPageKt? = null
it.rect
} ?: emptyList()
if (wordPdfRectsF.isNotEmpty()) { try {
val mappedScreenRects = wordPdfRectsF.mapNotNull { pdfRectF -> val pageToUse: PdfPageKt
val screenRect = pageToUse.mapRectToDevice( val textPageToUse: PdfTextPageKt
startX = 0,
startY = 0, if (providedPage != null && providedTextPage != null) {
sizeX = currentBitmapWidth, pageToUse = providedPage
sizeY = currentBitmapHeight, textPageToUse = providedTextPage
rotate = rotation, } else {
coords = pdfRectF localPage = doc.openPage(pageIdx)
) localTextPage = localPage.openTextPage()
if (screenRect.width() > 0 && screenRect.height() > 0) screenRect pageToUse = localPage
else { textPageToUse = localTextPage
Timber.d( }
"updateSelectionVisuals: Filtering out invalid screen rect: $screenRect"
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 } catch (e: Exception) {
Timber.e(e, "Error updating selection visuals for page $pageIdx, range $charRange: $e")
if (mappedScreenRects.isNotEmpty()) { withContext(Dispatchers.Main) {
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() selectedWordScreenRects = emptyList()
startHandleContentPosition.value = null startHandleContentPosition.value = null
endHandleContentPosition.value = null endHandleContentPosition.value = null
} }
} else { } finally {
selectedWordScreenRects = emptyList() if (providedPage == null && providedTextPage == null) {
startHandleContentPosition.value = null withContext(NonCancellable) {
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) {
try { try {
localTextPage?.close() localTextPage?.close()
} catch (_: Exception) { } catch (_: Exception) {
@ -2105,14 +2115,14 @@ internal fun PdfPageComposable(
var pageForMenu: PdfPageKt? = null var pageForMenu: PdfPageKt? = null
var textPageForMenu: PdfTextPageKt? = null var textPageForMenu: PdfTextPageKt? = null
try { try {
pageForMenu = pdfDocumentItem.openPage( val text = withContext(Dispatchers.IO) {
pdfPageIndex pageForMenu = pdfDocumentItem.openPage(pdfPageIndex)
) textPageForMenu = pageForMenu.openTextPage()
textPageForMenu = pageForMenu.openTextPage() textPageForMenu.textPageGetText(
val text = textPageForMenu.textPageGetText( currentRange.first,
currentRange.first, currentRange.second - currentRange.first
currentRange.second - currentRange.first )
) }
if (!text.isNullOrBlank()) { if (!text.isNullOrBlank()) {
val combinedRect = Rect(selectedWordScreenRects.first()) val combinedRect = Rect(selectedWordScreenRects.first())
selectedWordScreenRects.forEach { combinedRect.union(it) } selectedWordScreenRects.forEach { combinedRect.union(it) }
@ -2220,79 +2230,81 @@ internal fun PdfPageComposable(
try { try {
if (!isPdfPage) return@launch if (!isPdfPage) return@launch
tempPage = pdfDocumentItem.openPage(pdfPageIndex) val touchInContentCoords = screenToContentCoordinates(down.position)
tempTextPage = tempPage.openTextPage() 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) { if (touchInContentCoords.x < 0 || touchInContentCoords.x > actualBitmapWidthPx || touchInContentCoords.y < 0 || touchInContentCoords.y > actualBitmapHeightPx) {
Timber.d( Timber.d("Long press: Touch point outside bitmap bounds.")
"Long press: Touch point outside bitmap bounds."
)
return@launch 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 var pdfiumSelectionSuccessful = false
if (charIndex != -1) { withContext(Dispatchers.IO) {
val pageCharCount = tempTextPage.textPageCountChars() tempPage = pdfDocumentItem.openPage(pdfPageIndex)
val wordBoundaries = findWordBoundaries( tempTextPage = tempPage.openTextPage()
tempTextPage, charIndex, pageCharCount
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) { if (charIndex != -1) {
selectionMethodUsed = PdfSelectionMethod.PDFIUM val pageCharCount = tempTextPage.textPageCountChars()
selectionCharRange.value = wordBoundaries val wordBoundaries = findWordBoundaries(
updateSelectionVisuals( tempTextPage, charIndex, pageCharCount
pdfDocumentItem,
pdfPageIndex,
selectionCharRange.value,
actualBitmapWidthPx,
actualBitmapHeightPx,
currentPageRotation,
providedPage = tempPage,
providedTextPage = tempTextPage
) )
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( if (wordBoundaries != null) {
selectedText = text, withContext(Dispatchers.Main) {
anchorRect = combinedRect, selectionMethodUsed = PdfSelectionMethod.PDFIUM
charRange = currentRange selectionCharRange.value = wordBoundaries
) }
pdfiumSelectionSuccessful = true updateSelectionVisuals(
Timber.d( pdfDocumentItem,
"Long press: PDFIUM selection successful. Menu: ${customMenuState?.anchorRect}" 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 page: PdfPageKt? = null
var textPage: PdfTextPageKt? = null var textPage: PdfTextPageKt? = null
try { try {
page = pdfDocumentItem.openPage(pdfPageIndex) val charCount = withContext(Dispatchers.IO) {
textPage = page.openTextPage() page = pdfDocumentItem.openPage(pdfPageIndex)
val charCount = textPage.textPageCountChars() textPage = page.openTextPage()
textPage.textPageCountChars()
}
if (charCount > 0) { if (charCount > 0) {
selectionCharRange.value = Pair(0, charCount) selectionCharRange.value = Pair(0, charCount)
updateSelectionVisuals( updateSelectionVisuals(
@ -3698,8 +3713,9 @@ internal fun PdfPageComposable(
providedTextPage = textPage providedTextPage = textPage
) )
if (selectedWordScreenRects.isNotEmpty()) { if (selectedWordScreenRects.isNotEmpty()) {
val fullText = val fullText = withContext(Dispatchers.IO) {
textPage.textPageGetText(0, charCount) textPage!!.textPageGetText(0, charCount)
}
if (!fullText.isNullOrBlank()) { if (!fullText.isNullOrBlank()) {
val combinedRect = Rect(selectedWordScreenRects.first()) val combinedRect = Rect(selectedWordScreenRects.first())
selectedWordScreenRects.forEach { combinedRect.union(it) } selectedWordScreenRects.forEach { combinedRect.union(it) }
@ -3714,9 +3730,11 @@ internal fun PdfPageComposable(
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e, "Failed to select all") Timber.e(e, "Failed to select all")
} finally { } finally {
withContext(Dispatchers.IO) { withContext(NonCancellable) {
textPage?.close() withContext(Dispatchers.IO) {
page?.close() textPage?.close()
page?.close()
}
} }
} }
} else { } else {
@ -4124,7 +4142,7 @@ internal sealed interface AnnotationRenderData {
internal object PdfAnnotationRenderHelper { internal object PdfAnnotationRenderHelper {
fun createRenderData(annot: PdfAnnotation, widthPx: Int, heightPx: Int): AnnotationRenderData? { fun createRenderData(annot: PdfAnnotation, widthPx: Int, heightPx: Int): AnnotationRenderData? {
val startTime = System.nanoTime()
if (annot.points.isEmpty()) return null if (annot.points.isEmpty()) return null
if (annot.points.size == 1) { 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 return result
} }
} }
@ -4291,8 +4313,21 @@ private fun PdfAnnotationLayer(
centeringOffsetY: Float, centeringOffsetY: Float,
pageIndex: Int 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 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) { val currentAnnotation = remember(drawingState, pageIndex) {
derivedStateOf { derivedStateOf {
val annot = drawingState?.currentAnnotation 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( val activeRenderData = remember(
currentAnnotation, currentAnnotation,
currentAnnotation?.points?.size, currentAnnotation?.points?.size,
actualBitmapWidthPx, actualBitmapWidthPx,
actualBitmapHeightPx actualBitmapHeightPx
) { ) {
if (currentAnnotation != null) { val startTime = System.nanoTime()
Timber.tag("PdfDrawPerf").v( val res = currentAnnotation?.let { annot ->
"ANNOT LAYER: Generating active path for ${currentAnnotation.points.size} points" PdfAnnotationRenderHelper.createRenderData(annot, actualBitmapWidthPx, actualBitmapHeightPx)
)
} }
currentAnnotation?.let { annot -> val duration = (System.nanoTime() - startTime) / 1_000_000f
PdfAnnotationRenderHelper.createRenderData( if (duration > 0.5f) {
annot, actualBitmapWidthPx, actualBitmapHeightPx Timber.tag("PdfPerf").v("ANNOT_LAYER: Active path gen took ${duration}ms")
)
} }
res
} }
Canvas(modifier = Modifier.fillMaxSize()) { Canvas(modifier = Modifier.fillMaxSize()) {
@ -4386,9 +4412,9 @@ private fun PdfAnnotationLayer(
activeRenderData?.let { drawData(it) } activeRenderData?.let { drawData(it) }
} }
val drawDuration = (System.nanoTime() - drawStart) / 1_000_000f val drawDuration = (System.nanoTime() - drawStart) / 1_000_000f
Timber.tag("PdfDrawPerf").v( if (drawDuration > 2f) {
"ANNOT DRAW: Canvas draw took ${drawDuration}ms. Points: ${currentAnnotation?.points?.size ?: 0}" Timber.tag("PdfPerf").v("ANNOT_DRAW: Canvas draw took ${drawDuration}ms (Page $pageIndex)")
) }
} }
} }
@ -4528,11 +4554,15 @@ private fun PdfPageRenderer(
onHighlightUpdate: (String, PdfHighlightColor) -> Unit, onHighlightUpdate: (String, PdfHighlightColor) -> Unit,
onHighlightDelete: (String) -> 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()) {
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.graphicsLayer { .graphicsLayer {
Timber.tag("PdfPerf").v("GraphicsLayer Update: Scale=$scale, Offset=$offset")
scaleX = scale scaleX = scale
scaleY = scale scaleY = scale
translationX = offset.x translationX = offset.x
@ -4783,7 +4813,6 @@ private fun PdfPageRenderer(
} }
if (showMagnifier && activeDraggingHandle != null && staticData.bitmap.item != null) { if (showMagnifier && activeDraggingHandle != null && staticData.bitmap.item != null) {
val handleContentPos = when (activeDraggingHandle) { val handleContentPos = when (activeDraggingHandle) {
Handle.START -> startHandlePos Handle.START -> startHandlePos
Handle.END -> endHandlePos Handle.END -> endHandlePos
@ -4795,39 +4824,44 @@ private fun PdfPageRenderer(
val magnifierWidth = 120.dp val magnifierWidth = 120.dp
val magnifierHeight = 60.dp val magnifierHeight = 60.dp
val magnifierOffsetAboveHandle = 24.dp val magnifierOffsetAboveHandle = 24.dp
val effectiveScale = staticData.effectiveScale
with(density) { val effectiveZoomFactor = if (isVerticalScroll && effectiveScale > 1f) {
val magnifierWidthPx = magnifierWidth.toPx() effectiveScale * 1.25f
val magnifierHeightPx = magnifierHeight.toPx() } else {
val magnifierOffsetAboveHandlePx = magnifierOffsetAboveHandle.toPx() magnifierZoomFactor
val effectiveScale = staticData.effectiveScale }
val modifier: Modifier val popupPositionProvider = remember(pos, layoutCoordinates, density) {
val effectiveZoomFactor: Float 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 windowPos = coords.localToWindow(pos)
val yOffsetPixels = val offsetPx = with(density) { magnifierOffsetAboveHandle.toPx() }
pos.y - (magnifierHeightPx + magnifierOffsetAboveHandlePx) / effectiveScale
val xOffsetPixels = pos.x - (magnifierWidthPx / 2) / effectiveScale
modifier = val x = (windowPos.x - popupContentSize.width / 2).toInt()
Modifier val y = (windowPos.y - popupContentSize.height - offsetPx).toInt()
.offset(x = xOffsetPixels.toDp(), y = yOffsetPixels.toDp())
.graphicsLayer(
scaleX = 1f / effectiveScale,
scaleY = 1f / effectiveScale,
transformOrigin = TransformOrigin(0f, 0f)
)
effectiveZoomFactor = effectiveScale * 1.25f return androidx.compose.ui.unit.IntOffset(x, y)
} else { }
val xOffsetVal = pos.x - magnifierWidthPx / 2
val yOffsetVal = pos.y - magnifierHeightPx - magnifierOffsetAboveHandlePx
modifier = Modifier.offset(x = xOffsetVal.toDp(), y = yOffsetVal.toDp())
effectiveZoomFactor = magnifierZoomFactor
} }
}
androidx.compose.ui.window.Popup(
popupPositionProvider = popupPositionProvider,
properties = androidx.compose.ui.window.PopupProperties(
focusable = false,
dismissOnClickOutside = false,
dismissOnBackPress = false,
usePlatformDefaultWidth = false
)
) {
MagnifierComposable( MagnifierComposable(
sourceBitmap = staticData.bitmap.item.asImageBitmap(), sourceBitmap = staticData.bitmap.item.asImageBitmap(),
tiles = if (effectiveScale > 1f) staticData.tiles.item else emptyList(), tiles = if (effectiveScale > 1f) staticData.tiles.item else emptyList(),
@ -4839,7 +4873,7 @@ private fun PdfPageRenderer(
selectionRectsInBitmapCoords = selectionData.mergedSelectionRects.item, selectionRectsInBitmapCoords = selectionData.mergedSelectionRects.item,
highlightColor = Color(0x6633B5E5), highlightColor = Color(0x6633B5E5),
colorFilter = staticData.colorFilter.item, colorFilter = staticData.colorFilter.item,
modifier = modifier modifier = Modifier
) )
} }
} }
@ -4848,9 +4882,10 @@ private fun PdfPageRenderer(
if (menuState != null) { if (menuState != null) {
BackHandler(enabled = true, onBack = onMenuDismiss) BackHandler(enabled = true, onBack = onMenuDismiss)
} }
menuState?.let { state ->
if (state.anchorRect.width() > 0 || state.anchorRect.height() > 0) { if (menuState != null && !isScrolling && draggingBoxId == null && activeDraggingHandle == null) {
val popupPositionProvider = remember(state.anchorRect, density, offset, scale, layoutCoordinates) { if (menuState.anchorRect.width() > 0 || menuState.anchorRect.height() > 0) {
val popupPositionProvider = remember(menuState.anchorRect, density, offset, scale, layoutCoordinates) {
object : PopupPositionProvider { object : PopupPositionProvider {
override fun calculatePosition( override fun calculatePosition(
anchorBounds: IntRect, anchorBounds: IntRect,
@ -4861,8 +4896,12 @@ private fun PdfPageRenderer(
val coords = layoutCoordinates ?: return IntOffset.Zero val coords = layoutCoordinates ?: return IntOffset.Zero
// Map the bitmap-space anchor (the icon) to window-space // Map the bitmap-space anchor (the icon) to window-space
val topLeftLocal = contentToScreenCoordinates(Offset(state.anchorRect.left.toFloat(), state.anchorRect.top.toFloat())) val topLeftLocal = contentToScreenCoordinates(Offset(
val bottomRightLocal = contentToScreenCoordinates(Offset(state.anchorRect.right.toFloat(), state.anchorRect.bottom.toFloat())) 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 topLeftWindow = coords.localToWindow(topLeftLocal)
val bottomRightWindow = coords.localToWindow(bottomRightLocal) val bottomRightWindow = coords.localToWindow(bottomRightLocal)
@ -4890,30 +4929,28 @@ private fun PdfPageRenderer(
} }
PdfSelectionMenuPopup( PdfSelectionMenuPopup(
menuState = state, menuState = menuState,
popupPositionProvider = popupPositionProvider, popupPositionProvider = popupPositionProvider,
onDismiss = onMenuDismiss, onDismiss = onMenuDismiss,
onCopy = onCopy, onCopy = onCopy,
onAiDefine = onAiDefine, onAiDefine = onAiDefine,
onSelectAll = onSelectAll, onSelectAll = onSelectAll,
onColorSelected = { color -> onColorSelected = { color ->
Timber.tag("PdfHighlightDebug").d("PdfSelectionMenuPopup onColorSelected: $color, isExisting=${state.isExistingHighlight}") Timber.tag("PdfHighlightDebug").d("PdfSelectionMenuPopup onColorSelected: $color, isExisting=${menuState.isExistingHighlight}")
if (state.isExistingHighlight && state.highlightId != null) { if (menuState.isExistingHighlight && menuState.highlightId != null) {
onHighlightUpdate(state.highlightId, color) onHighlightUpdate(menuState.highlightId, color)
} else { } else {
Timber.tag("PdfHighlightDebug").d("Calling onHighlightAdd for page ${selectionData.pageIndex}") Timber.tag("PdfHighlightDebug").d("Calling onHighlightAdd for page ${selectionData.pageIndex}")
onHighlightAdd( onHighlightAdd(
selectionData.pageIndex, selectionData.pageIndex, menuState.charRange, menuState.selectedText,
state.charRange,
state.selectedText,
color color
) )
} }
onMenuDismiss() onMenuDismiss()
}, },
onDelete = { onDelete = {
if (state.isExistingHighlight && state.highlightId != null) { if (menuState.isExistingHighlight && menuState.highlightId != null) {
onHighlightDelete(state.highlightId) onHighlightDelete(menuState.highlightId)
} }
onMenuDismiss() onMenuDismiss()
} }

View file

@ -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<EpubChapter>()
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 = """
<!DOCTYPE html>
<html>
<head>
<title>Page ${i + 1}</title>
<style>$css</style>
</head>
<body>
$cleanedHtml
</body>
</html>
""".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 "<p><i>(No text on this page)</i></p>"
val lines = rawText.split('\n')
val sb = StringBuilder()
sb.append("<div class='page-marker'>Page $pageNumber</div>")
var currentParagraph = StringBuilder()
for (line in lines) {
val trimmed = line.trim()
if (trimmed.isEmpty()) {
if (currentParagraph.isNotEmpty()) {
sb.append("<p>${currentParagraph.toString()}</p>")
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("<p>${currentParagraph.toString()}</p>")
currentParagraph.clear()
}
sb.append("<h2>$trimmed</h2>")
continue
}
if (currentParagraph.isNotEmpty()) {
currentParagraph.append(" ")
}
currentParagraph.append(trimmed)
if (trimmed.endsWith(".") || trimmed.endsWith("?") || trimmed.endsWith("!") || trimmed.endsWith(":")) {
}
}
if (currentParagraph.isNotEmpty()) {
sb.append("<p>${currentParagraph.toString()}</p>")
}
return sb.toString()
}
}

View file

@ -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<page-break></page-break>\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<TextSpan>,
val yPos: Float,
val charCount: Int
)
private fun buildGlobalHtmlHeader(): String = """
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body { font-family: sans-serif; line-height: 1.65; padding: 1em; max-width: 100%; margin: 0; }
h1 { font-size: 1.9em; font-weight: bold; margin: 1.2em 0 0.4em; }
h2 { font-size: 1.55em; font-weight: bold; margin: 1.1em 0 0.35em; }
h3 { font-size: 1.3em; font-weight: bold; margin: 1.0em 0 0.3em; }
h4 { font-size: 1.1em; font-weight: bold; margin: 0.9em 0 0.25em; }
p { margin: 0.5em 0; }
ul, ol { padding-left: 1.5em; margin: 0.5em 0; }
li { margin-bottom: 0.2em; }
hr { border: none; border-top: 1px solid currentColor; opacity: 0.25; margin: 1.4em 0; }
.page-section { margin-bottom: 0.5em; }
.page-marker { opacity: 0.4; font-size: 0.72em; margin-bottom: 1.2em; letter-spacing: 0.04em; }
.page-divider { border: none; border-top: 1px solid currentColor; opacity: 0.12; margin: 2em 0 1.5em; }
</style>
</head>
<body>
""".trimIndent() + "\n"
private fun buildGlobalHtmlFooter(): String = "\n</body>\n</html>\n"
private suspend fun extractPageHtml(
doc: PdfDocumentKt,
pageIdx: Int,
pageNumber: Int,
headerFooterStrings: Set<String>
): 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<ImageElement>()
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<TextLine>()
val currentSpans = mutableListOf<TextSpan>()
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<PageElement>()
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) =
"<section class=\"page-section\">\n" +
"<p class=\"page-marker\">— Page $pageNumber —</p>\n" +
"<p><em>(No text on this page)</em></p>\n</section>\n"
private fun buildFallbackPageSection(pageNumber: Int, rawText: String) =
"<section class=\"page-section\">\n" +
"<p class=\"page-marker\">— Page $pageNumber —</p>\n" +
"<p>${rawText.escapeHtml()}</p>\n</section>\n"
private fun buildPageHtml(
pageNumber: Int,
elements: List<PageElement>,
headerFooterStrings: Set<String>
): String {
val textElements = elements.filterIsInstance<TextElement>()
val sizeFreq = HashMap<Int, Int>()
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("<section class=\"page-section\">\n")
sb.append("<p class=\"page-marker\">— Page $pageNumber —</p>\n")
var inParagraph = false
var inUl = false
var inOl = false
fun closeParagraph() { if (inParagraph) { sb.append("</p>\n"); inParagraph = false } }
fun closeList() {
if (inUl) { sb.append("</ul>\n"); inUl = false }
if (inOl) { sb.append("</ol>\n"); inOl = false }
}
for ((index, element) in elements.withIndex()) {
when (element) {
is ImageElement -> {
closeParagraph()
closeList()
sb.append("<div style=\"text-align:center; margin: 1.5em 0;\">\n")
sb.append("<img src=\"data:image/jpeg;base64,${element.base64Data}\" style=\"max-width:100%; height:auto; border-radius: 6px;\"/>\n")
sb.append("</div>\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("<hr>\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("</ol>\n"); inOl = false }
if (!inUl) { sb.append("<ul>\n"); inUl = true }
val content = trimmed.removePrefix("").removePrefix("").removePrefix("").removePrefix("").removePrefix("- ").trim()
sb.append("<li>${content.escapeHtml()}</li>\n")
}
numberedMatch -> {
closeParagraph()
if (inUl) { sb.append("</ul>\n"); inUl = false }
if (!inOl) { sb.append("<ol>\n"); inOl = true }
val content = trimmed.substringAfter(" ").trim()
sb.append("<li>${content.escapeHtml()}</li>\n")
}
shouldBreakParagraph -> {
closeList()
if (!inParagraph) { sb.append("<p>"); inParagraph = true }
sb.append(renderSpans(line.spans))
closeParagraph()
}
else -> {
closeList()
if (!inParagraph) { sb.append("<p>"); inParagraph = true } else sb.append(" ")
sb.append(renderSpans(line.spans))
}
}
}
}
}
closeParagraph()
closeList()
sb.append("</section>\n")
return sb.toString()
}
private fun renderSpans(spans: List<TextSpan>, 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("<strong><em>")
else if (span.isBold) sb.append("<strong>")
else if (span.isItalic) sb.append("<em>")
}
sb.append(mid)
if (!insideHeading) {
if (span.isBold && span.isItalic) sb.append("</em></strong>")
else if (span.isBold) sb.append("</strong>")
else if (span.isItalic) sb.append("</em>")
}
sb.append(post)
}
return sb.toString()
}
private suspend fun detectRepeatingHeaderFooter(
doc: PdfDocumentKt,
totalPages: Int
): Set<String> = 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<String, Int>()
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("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
.replace("'", "&#39;")
}

View file

@ -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<TextSpan>
)
private fun fixKerning(text: String): String {
val pattern = Regex("\\b(?:[A-Za-z0-9] ){2,}[A-Za-z0-9]\\b")
return pattern.replace(text) { matchResult ->
matchResult.value.replace(" ", "")
}
}
private fun buildMarkdown(text: String, sizes: FloatArray, weights: IntArray, flags: IntArray, count: Int): String {
if (count == 0) return ""
val sizeFrequency = HashMap<Int, Int>()
for (i in 0 until count) {
val s = sizes[i].roundToInt()
sizeFrequency[s] = (sizeFrequency[s] ?: 0) + 1
}
val baseSize = sizeFrequency.maxByOrNull { it.value }?.key ?: 12
val lines = mutableListOf<TextLine>()
@Suppress("CanBeVal") var currentSpans = mutableListOf<TextSpan>()
val currentSpanText = StringBuilder()
var currentSize = -1f
var currentBold = false
var currentItalic = false
for (i in 0 until count) {
val c = text[i]
if (c == '\u0000') continue
if (c == '\n' || c == '\r') {
if (c == '\n' && i > 0 && text[i - 1] == '\r') continue
if (currentSpanText.isNotEmpty()) {
currentSpans.add(TextSpan(currentSpanText.toString(), currentSize, currentBold, currentItalic))
currentSpanText.clear()
}
lines.add(TextLine(currentSpans.toList()))
currentSpans.clear()
continue
}
val isSpace = c.isWhitespace()
val size = sizes[i]
val bold = weights[i] > 600
val italic = (flags[i] and 64) != 0
if (currentSpanText.isEmpty()) {
currentSize = size
currentBold = bold
currentItalic = italic
currentSpanText.append(c)
} else {
if (!isSpace && (currentSize != size || currentBold != bold || currentItalic != italic)) {
currentSpans.add(TextSpan(currentSpanText.toString(), currentSize, currentBold, currentItalic))
currentSpanText.clear()
currentSize = size
currentBold = bold
currentItalic = italic
}
currentSpanText.append(c)
}
}
if (currentSpanText.isNotEmpty()) {
currentSpans.add(TextSpan(currentSpanText.toString(), currentSize, currentBold, currentItalic))
}
if (currentSpans.isNotEmpty()) {
lines.add(TextLine(currentSpans))
}
val validLines = lines.filter { it.spans.isNotEmpty() }
val lineLengths = validLines.map { line -> line.spans.sumOf { it.text.length } }.filter { it > 10 }.sorted()
val typicalLineLen = if (lineLengths.isNotEmpty()) {
lineLengths[(lineLengths.size * 0.8).toInt().coerceAtMost(lineLengths.size - 1)]
} else {
80
}
val wrapThreshold = (typicalLineLen * 0.85).toInt()
val sb = StringBuilder()
for (i in lines.indices) {
val line = lines[i]
if (line.spans.isEmpty()) {
sb.append("\n")
continue
}
val maxFontSize = line.spans.filter { it.text.isNotBlank() }.maxOfOrNull { it.size } ?: baseSize.toFloat()
val charBigHeader = maxFontSize > baseSize * 1.5f
val charHeader = maxFontSize > baseSize * 1.2f
var prefix = ""
if (charBigHeader) prefix = "## "
else if (charHeader) prefix = "### "
val rawLineText = line.spans.joinToString("") { it.text }
val trimmedRaw = rawLineText.trim()
val lineLen = trimmedRaw.length
val isList = trimmedRaw.startsWith("") ||
trimmedRaw.startsWith("- ") ||
trimmedRaw.startsWith("") ||
trimmedRaw.matches(Regex("^[0-9]+\\.\\s.*")) ||
trimmedRaw.matches(Regex("^[a-zA-Z]\\)\\s.*"))
if (prefix.isNotEmpty() && !isList) {
sb.append(prefix)
}
for (span in line.spans) {
var spanText = span.text
spanText = fixKerning(spanText)
val leadingSpaces = spanText.takeWhile { it.isWhitespace() }
val trailingSpaces = spanText.takeLastWhile { it.isWhitespace() }
val trimmedText = spanText.trim()
if (trimmedText.isEmpty()) {
sb.append(spanText)
continue
}
sb.append(leadingSpaces)
var tag = ""
if (span.isBold && span.isItalic) tag = "***"
else if (span.isBold) tag = "**"
else if (span.isItalic) tag = "*"
sb.append(tag).append(trimmedText).append(tag)
sb.append(trailingSpaces)
}
var isParagraphBreak = false
if (prefix.isNotEmpty() || isList) {
isParagraphBreak = true
} else if (lineLen < wrapThreshold) {
isParagraphBreak = true
} else if (trimmedRaw.matches(Regex(".*[.!?\"'”’;:*]$"))) {
isParagraphBreak = true
} else {
val nextLine = lines.subList(i + 1, lines.size).firstOrNull { it.spans.isNotEmpty() }
if (nextLine != null) {
val nextRaw = nextLine.spans.joinToString("") { it.text }.trimStart()
if (nextRaw.startsWith("\"") || nextRaw.startsWith("") || nextRaw.startsWith("-")) {
isParagraphBreak = true
}
}
}
if (isParagraphBreak) {
sb.append("\n\n")
} else {
sb.append("\n")
}
}
return sb.toString().replace(Regex("\\n{3,}"), "\n\n").trim()
}
}

View file

@ -18,7 +18,9 @@
* mail: epistemereader@gmail.com * mail: epistemereader@gmail.com
*/ */
// PdfViewerScreen.kt // PdfViewerScreen.kt
@file:Suppress("COMPOSE_APPLIER_CALL_MISMATCH", "Unused", "UnusedVariable") @file:Suppress("COMPOSE_APPLIER_CALL_MISMATCH", "Unused", "UnusedVariable",
"SimplifyBooleanWithConstants"
)
package com.aryan.reader.pdf package com.aryan.reader.pdf
@ -27,6 +29,11 @@ import android.annotation.SuppressLint
import android.app.Activity import android.app.Activity
import android.content.Context import android.content.Context
import android.content.pm.PackageManager import android.content.pm.PackageManager
import androidx.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.Bitmap
import android.graphics.RectF import android.graphics.RectF
import android.net.Uri import android.net.Uri
@ -221,6 +228,7 @@ import androidx.core.graphics.createBitmap
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat import androidx.core.view.WindowInsetsControllerCompat
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import androidx.paging.LoadState import androidx.paging.LoadState
import androidx.paging.compose.LazyPagingItems import androidx.paging.compose.LazyPagingItems
@ -740,9 +748,9 @@ fun PdfViewerScreen(
val uiState by viewModel.uiState.collectAsState() val uiState by viewModel.uiState.collectAsState()
val reflowBookId = remember(bookId) { "${bookId}_reflow" } val reflowBookId = remember(bookId) { "${bookId}_reflow" }
val hasReflowFile by remember(uiState.recentFiles, reflowBookId) { val hasReflowFile by remember(uiState.allRecentFiles, reflowBookId) {
derivedStateOf { derivedStateOf {
uiState.recentFiles.any { it.bookId == reflowBookId && !it.isDeleted } uiState.allRecentFiles.any { it.bookId == reflowBookId && !it.isDeleted }
} }
} }
val originalFileName by remember(uiState.recentFiles, pdfUri) { val originalFileName by remember(uiState.recentFiles, pdfUri) {
@ -1029,18 +1037,155 @@ fun PdfViewerScreen(
var areAnnotationsLoaded by remember { mutableStateOf(false) } var areAnnotationsLoaded by remember { mutableStateOf(false) }
LaunchedEffect(allAnnotations) { val richTextRepository = remember(context) { PdfRichTextRepository(context) }
if (areAnnotationsLoaded && currentBookId != null) { val richTextController = remember(currentBookId) {
delay(1000) if (currentBookId != null) RichTextController(
richTextRepository,
withContext(Dispatchers.IO) { coroutineScope,
Timber.d("Auto-saving annotations locally for book $currentBookId") currentBookId!!
annotationRepository.saveAnnotations(currentBookId!!, allAnnotations) )
else null
}
var pdfDocument by remember { mutableStateOf<PdfDocumentKt?>(null) }
var pfdState by remember { mutableStateOf<ParcelFileDescriptor?>(null) }
var totalPages by remember { mutableIntStateOf(0) }
var currentPageScale by remember { mutableFloatStateOf(1f) }
val textBoxes = remember { mutableStateListOf<PdfTextBox>() }
var selectedTextBoxId by remember { mutableStateOf<String?>(null) }
val userHighlights = remember { mutableStateListOf<PdfUserHighlight>() }
val drawingState = remember { PdfDrawingState() }
val pdfiumCore = remember(context) { PdfiumCoreKt(Dispatchers.Default) }
val verticalReaderState = rememberVerticalPdfReaderState()
var virtualPages by remember { mutableStateOf<List<VirtualPage>>(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 } } val allAnnotationsProvider = remember { { allAnnotations } }
@ -1049,19 +1194,6 @@ fun PdfViewerScreen(
Timber.d("PdfViewerScreen init: Loaded ${bookmarks.size} bookmarks initially.") 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<List<TocEntry>>(emptyList()) } var flatTableOfContents by remember { mutableStateOf<List<TocEntry>>(emptyList()) }
var showDictionaryUpsellDialog by remember { mutableStateOf(false) } var showDictionaryUpsellDialog by remember { mutableStateOf(false) }
var showSummarizationUpsellDialog by remember { mutableStateOf(false) } var showSummarizationUpsellDialog by remember { mutableStateOf(false) }
@ -1080,17 +1212,8 @@ fun PdfViewerScreen(
var errorMessage by remember { mutableStateOf<String?>(null) } var errorMessage by remember { mutableStateOf<String?>(null) }
var isLoadingDocument by remember { mutableStateOf(true) } var isLoadingDocument by remember { mutableStateOf(true) }
var pdfDocument by remember { mutableStateOf<PdfDocumentKt?>(null) }
var pfdState by remember { mutableStateOf<ParcelFileDescriptor?>(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 selectionClearTrigger by remember { mutableLongStateOf(0L) }
var virtualPages by remember { mutableStateOf<List<VirtualPage>>(emptyList()) }
val displayPageRatios by remember(pageAspectRatios, virtualPages) { val displayPageRatios by remember(pageAspectRatios, virtualPages) {
derivedStateOf { derivedStateOf {
if (virtualPages.isEmpty()) { 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) { LaunchedEffect(richTextController, toolSettings.textStyle) {
richTextController?.let { controller -> richTextController?.let { controller ->
val config = toolSettings.textStyle 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) { LaunchedEffect(currentBookId) {
if (currentBookId != null) richTextRepository.load(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)") Timber.d("Derived currentPage recomposed. New value: $currentPage (Mode: $displayMode)")
val textBoxes = remember { mutableStateListOf<PdfTextBox>() }
var selectedTextBoxId by remember { mutableStateOf<String?>(null) }
val userHighlights = remember { mutableStateListOf<PdfUserHighlight>() }
val onHighlightAdd = remember(pdfDocument, currentBookId) { val onHighlightAdd = remember(pdfDocument, currentBookId) {
{ pageIndex: Int, range: Pair<Int, Int>, text: String, color: PdfHighlightColor -> { pageIndex: Int, range: Pair<Int, Int>, text: String, color: PdfHighlightColor ->
Timber.tag("PdfExportDebug").i("onHighlightAdd: Adding persistent highlight. Page: $pageIndex, Text: ${text.take(20)}...") 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}") 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) { LaunchedEffect(displayMode) {
coroutineScope.launch { coroutineScope.launch {
if (displayMode == DisplayMode.VERTICAL_SCROLL) { 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<SaveMode?>(null) } var pendingSaveMode by remember { mutableStateOf<SaveMode?>(null) }
val saveLauncher = rememberLauncherForActivityResult( val saveLauncher = rememberLauncherForActivityResult(
@ -1970,33 +2033,15 @@ fun PdfViewerScreen(
ttsController.stop() ttsController.stop()
coroutineScope.launch { coroutineScope.launch {
withContext(NonCancellable) { if (richTextController != null) {
if (richTextController != null) { withContext(NonCancellable) {
Timber.tag("RichTextFlow").d("Forcing RichTextController immediate sync and save...") Timber.tag("RichTextFlow").d("Forcing RichTextController immediate sync and save...")
richTextController.saveImmediate() 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.") Timber.tag("AnnotationSync").d("Save complete. Navigating back.")
onNavigateBack() onNavigateBack()
} }
@ -4954,18 +4999,33 @@ fun PdfViewerScreen(
enabled = pdfDocument != null && !isReflowingThisBook, enabled = pdfDocument != null && !isReflowingThisBook,
onClick = { onClick = {
showMoreMenu = false showMoreMenu = false
if (hasReflowFile) {
val item = uiState.recentFiles.find { it.bookId == reflowBookId } coroutineScope.launch {
if (item != null) { if (richTextController != null) {
viewModel.switchToFileSeamlessly(item, currentPage) 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 = { leadingIcon = {

View file

@ -21,86 +21,83 @@ class ReflowWorker(
override suspend fun doWork(): Result = withContext(Dispatchers.IO) { override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
val workStartTime = System.currentTimeMillis() val workStartTime = System.currentTimeMillis()
Timber.tag("PdfToMdPerf").d("=== ReflowWorker START ===") Timber.tag("PdfToHtmlPerf").d("=== ReflowWorker START ===")
val bookId = inputData.getString(KEY_BOOK_ID) ?: run { 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() return@withContext Result.failure()
} }
val pdfUriString = inputData.getString(KEY_PDF_URI) ?: run { 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() return@withContext Result.failure()
} }
val originalTitle = inputData.getString(KEY_ORIGINAL_TITLE) ?: "Document" val originalTitle = inputData.getString(KEY_ORIGINAL_TITLE) ?: "Document"
val reflowBookId = "${bookId}_reflow" val reflowBookId = "${bookId}_reflow"
Timber.tag("PdfToMdPerf").d("Input data | bookId=$bookId | reflowBookId=$reflowBookId | pdfUri=$pdfUriString | originalTitle=$originalTitle") 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() val pdfUri = pdfUriString.toUri()
Timber.tag("PdfToMdPerf").d("Dest file path: ${destFile.absolutePath} | exists=${destFile.exists()}") Timber.tag("PdfToHtmlPerf").d("Dest: ${destFile.absolutePath} | exists=${destFile.exists()}")
Timber.tag("PdfToMdPerf").d("Starting PdfToMarkdownGenerator.generateMarkdownFile...")
val genStartTime = System.currentTimeMillis()
val success = PdfToMarkdownGenerator.generateMarkdownFile( val genStartTime = System.currentTimeMillis()
applicationContext, val success = PdfToHtmlGenerator.generateHtmlFile(
pdfUri, context = applicationContext,
destFile, pdfUri = pdfUri,
destFile = destFile,
startPage = 1 startPage = 1
) { progress -> ) { progress ->
if ((progress * 10).toInt() % 1 == 0) {
Timber.tag("PdfToMdPerf").d("Progress: ${(progress * 100).toInt()}%")
}
setProgressAsync(workDataOf(KEY_PROGRESS to progress)) setProgressAsync(workDataOf(KEY_PROGRESS to progress))
} }
Timber.tag("PdfToMdPerf").d("generateMarkdownFile completed | success=$success | time=${System.currentTimeMillis() - genStartTime}ms") Timber.tag("PdfToHtmlPerf").d(
"generateHtmlFile done | success=$success | ${System.currentTimeMillis() - genStartTime}ms"
)
if (success && destFile.exists()) { if (success && destFile.exists()) {
val fileSizeKB = destFile.length() / 1024 val fileSizeKB = destFile.length() / 1024
Timber.tag("PdfToMdPerf").d("Reflow SUCCESS | outputFileSize=${fileSizeKB}KB") Timber.tag("PdfToHtmlPerf").d("Output size: ${fileSizeKB}KB")
Timber.tag("PdfToMdPerf").d("Starting database import...")
val dbStartTime = System.currentTimeMillis()
val repo = RecentFilesRepository(applicationContext) val repo = RecentFilesRepository(applicationContext)
val newItem = RecentFileItem( val newItem = RecentFileItem(
bookId = reflowBookId, bookId = reflowBookId,
uriString = destFile.toUri().toString(), uriString = destFile.toUri().toString(),
type = FileType.MD, type = FileType.HTML,
displayName = "$originalTitle (Text View)", displayName = "$originalTitle (Text View)",
timestamp = System.currentTimeMillis(), timestamp = System.currentTimeMillis(),
coverImagePath = null, coverImagePath = null,
title = "$originalTitle (Reflow)", title = "$originalTitle (Reflow)",
author = "Generated", author = "Generated",
isAvailable = true, isAvailable = true,
isRecent = true, isRecent = true,
lastModifiedTimestamp = System.currentTimeMillis(), lastModifiedTimestamp = System.currentTimeMillis(),
isDeleted = false, isDeleted = false,
sourceFolderUri = null sourceFolderUri = null
) )
repo.addRecentFile(newItem) repo.addRecentFile(newItem)
Timber.tag("PdfToMdPerf").d("Database import completed in ${System.currentTimeMillis() - dbStartTime}ms")
setProgressAsync(workDataOf(KEY_PROGRESS to 1.0f)) setProgressAsync(workDataOf(KEY_PROGRESS to 1.0f))
val totalTime = System.currentTimeMillis() - workStartTime 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() return@withContext Result.success()
} else { } else {
val totalTime = System.currentTimeMillis() - workStartTime 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() return@withContext Result.failure()
} }
} }
companion object { companion object {
const val WORK_NAME = "reflow_work" const val WORK_NAME = "reflow_work"
const val KEY_BOOK_ID = "book_id" const val KEY_BOOK_ID = "book_id"
const val KEY_PDF_URI = "pdf_uri" const val KEY_PDF_URI = "pdf_uri"
const val KEY_ORIGINAL_TITLE = "original_title" const val KEY_ORIGINAL_TITLE = "original_title"
const val KEY_PROGRESS = "progress" const val KEY_PROGRESS = "progress"
} }