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:
parent
9a95b4afcd
commit
843a77d0ef
13 changed files with 1270 additions and 895 deletions
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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(
|
val visibleRecentFiles = sortedAllFiles.filterNot { it.bookId.endsWith("_reflow") }
|
||||||
compareBy(nullsLast()) {
|
|
||||||
it.author?.lowercase()
|
|
||||||
})
|
|
||||||
|
|
||||||
SortOrder.PERCENT_ASC -> filteredFiles.sortedBy { it.progressPercentage ?: 0f }
|
val validContextualItems = internalState.contextualActionItems.filter { contextItem ->
|
||||||
SortOrder.PERCENT_DESC -> filteredFiles.sortedByDescending {
|
visibleRecentFiles.any { dbItem -> dbItem.uriString == contextItem.uriString }
|
||||||
it.progressPercentage ?: 0f
|
}.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) {
|
||||||
|
|
|
||||||
|
|
@ -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(
|
val chapters = rawChapters.mapIndexed { index, rawText ->
|
||||||
title: String,
|
async(Dispatchers.Default) {
|
||||||
@Suppress("SameParameterValue") bodyContent: String?,
|
if (rawText.isBlank()) return@async null
|
||||||
@Suppress("SameParameterValue")cssStyle: String?,
|
|
||||||
fileName: String,
|
val pageNum = index + 1
|
||||||
bookId: String,
|
val chapterTitle = if (rawChapters.size > 1) "Page $pageNum" else title
|
||||||
extractionDir: File,
|
val fileName = "page_$pageNum.html"
|
||||||
metadataFile: File,
|
val file = File(extractionDir, fileName)
|
||||||
preGeneratedFullHtml: String? = null,
|
|
||||||
author: String? = null
|
val fullHtml = """
|
||||||
): EpubBook {
|
|
||||||
val fullHtml = preGeneratedFullHtml ?: """
|
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html xmlns="http://www.w3.org/1999/xhtml">
|
<html xmlns="http://www.w3.org/1999/xhtml">
|
||||||
<head>
|
<head>
|
||||||
<title>${title.replace("\"", """)}</title>
|
<title>${title.replace("\"", """)}</title>
|
||||||
<style>
|
<style>${cssStyle}</style>
|
||||||
${cssStyle ?: ""}
|
|
||||||
</style>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
$bodyContent
|
${rawText.trim()}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
|
|
||||||
val plainText = Jsoup.parse(fullHtml).text()
|
file.writeText(fullHtml)
|
||||||
|
|
||||||
try {
|
EpubChapter(
|
||||||
File(extractionDir, "content.html").writeText(fullHtml)
|
chapterId = "${bookId}_$pageNum",
|
||||||
} catch (e: Exception) {
|
absPath = fileName,
|
||||||
Timber.e(e, "Failed to save generated HTML to disk")
|
title = chapterTitle,
|
||||||
}
|
htmlFilePath = fileName,
|
||||||
|
plainTextContent = Jsoup.parse(fullHtml).text(),
|
||||||
val chapter = EpubChapter(
|
|
||||||
chapterId = bookId,
|
|
||||||
absPath = "content.html",
|
|
||||||
title = title,
|
|
||||||
htmlFilePath = "content.html",
|
|
||||||
plainTextContent = plainText,
|
|
||||||
htmlContent = "",
|
htmlContent = "",
|
||||||
depth = 0,
|
depth = 0,
|
||||||
isInToc = true
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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) }
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
|
|
|
||||||
|
|
@ -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,12 +1535,15 @@ internal fun PdfPageComposable(
|
||||||
providedTextPage: PdfTextPageKt? = null
|
providedTextPage: PdfTextPageKt? = null
|
||||||
) {
|
) {
|
||||||
if (charRange == null || currentBitmapWidth == 0 || currentBitmapHeight == 0) {
|
if (charRange == null || currentBitmapWidth == 0 || currentBitmapHeight == 0) {
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
selectedWordScreenRects = emptyList()
|
selectedWordScreenRects = emptyList()
|
||||||
startHandleContentPosition.value = null
|
startHandleContentPosition.value = null
|
||||||
endHandleContentPosition.value = null
|
endHandleContentPosition.value = null
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
var localPage: PdfPageKt? = null
|
var localPage: PdfPageKt? = null
|
||||||
var localTextPage: PdfTextPageKt? = null
|
var localTextPage: PdfTextPageKt? = null
|
||||||
|
|
||||||
|
|
@ -1561,10 +1563,12 @@ internal fun PdfPageComposable(
|
||||||
|
|
||||||
val (startIndex, endIndex) = charRange
|
val (startIndex, endIndex) = charRange
|
||||||
if (startIndex >= endIndex) {
|
if (startIndex >= endIndex) {
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
selectedWordScreenRects = emptyList()
|
selectedWordScreenRects = emptyList()
|
||||||
startHandleContentPosition.value = null
|
startHandleContentPosition.value = null
|
||||||
endHandleContentPosition.value = null
|
endHandleContentPosition.value = null
|
||||||
return
|
}
|
||||||
|
return@withContext
|
||||||
}
|
}
|
||||||
|
|
||||||
val length = endIndex - startIndex
|
val length = endIndex - startIndex
|
||||||
|
|
@ -1591,6 +1595,8 @@ internal fun PdfPageComposable(
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
selectedWordScreenRects = mappedScreenRects
|
selectedWordScreenRects = mappedScreenRects
|
||||||
|
|
||||||
if (mappedScreenRects.isNotEmpty()) {
|
if (mappedScreenRects.isNotEmpty()) {
|
||||||
|
|
@ -1606,20 +1612,24 @@ internal fun PdfPageComposable(
|
||||||
startHandleContentPosition.value = null
|
startHandleContentPosition.value = null
|
||||||
endHandleContentPosition.value = null
|
endHandleContentPosition.value = null
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
selectedWordScreenRects = emptyList()
|
selectedWordScreenRects = emptyList()
|
||||||
startHandleContentPosition.value = null
|
startHandleContentPosition.value = null
|
||||||
endHandleContentPosition.value = null
|
endHandleContentPosition.value = null
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Timber.e(e, "Error updating selection visuals for page $pageIdx, range $charRange: $e")
|
Timber.e(e, "Error updating selection visuals for page $pageIdx, range $charRange: $e")
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
selectedWordScreenRects = emptyList()
|
selectedWordScreenRects = emptyList()
|
||||||
startHandleContentPosition.value = null
|
startHandleContentPosition.value = null
|
||||||
endHandleContentPosition.value = null
|
endHandleContentPosition.value = null
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (providedPage == null && providedTextPage == null) {
|
if (providedPage == null && providedTextPage == null) {
|
||||||
withContext(NonCancellable) {
|
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()
|
||||||
val text = textPageForMenu.textPageGetText(
|
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,23 +2230,19 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var pdfiumSelectionSuccessful = false
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
tempPage = pdfDocumentItem.openPage(pdfPageIndex)
|
||||||
|
tempTextPage = tempPage.openTextPage()
|
||||||
|
|
||||||
val pdfCoords = tempPage.mapDeviceCoordsToPage(
|
val pdfCoords = tempPage.mapDeviceCoordsToPage(
|
||||||
startX = 0,
|
startX = 0,
|
||||||
startY = 0,
|
startY = 0,
|
||||||
|
|
@ -2254,7 +2260,6 @@ internal fun PdfPageComposable(
|
||||||
yTolerance = charTolerance
|
yTolerance = charTolerance
|
||||||
)
|
)
|
||||||
|
|
||||||
var pdfiumSelectionSuccessful = false
|
|
||||||
if (charIndex != -1) {
|
if (charIndex != -1) {
|
||||||
val pageCharCount = tempTextPage.textPageCountChars()
|
val pageCharCount = tempTextPage.textPageCountChars()
|
||||||
val wordBoundaries = findWordBoundaries(
|
val wordBoundaries = findWordBoundaries(
|
||||||
|
|
@ -2262,24 +2267,29 @@ internal fun PdfPageComposable(
|
||||||
)
|
)
|
||||||
|
|
||||||
if (wordBoundaries != null) {
|
if (wordBoundaries != null) {
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
selectionMethodUsed = PdfSelectionMethod.PDFIUM
|
selectionMethodUsed = PdfSelectionMethod.PDFIUM
|
||||||
selectionCharRange.value = wordBoundaries
|
selectionCharRange.value = wordBoundaries
|
||||||
|
}
|
||||||
updateSelectionVisuals(
|
updateSelectionVisuals(
|
||||||
pdfDocumentItem,
|
pdfDocumentItem,
|
||||||
pdfPageIndex,
|
pdfPageIndex,
|
||||||
selectionCharRange.value,
|
wordBoundaries,
|
||||||
actualBitmapWidthPx,
|
actualBitmapWidthPx,
|
||||||
actualBitmapHeightPx,
|
actualBitmapHeightPx,
|
||||||
currentPageRotation,
|
currentPageRotation,
|
||||||
providedPage = tempPage,
|
providedPage = tempPage,
|
||||||
providedTextPage = tempTextPage
|
providedTextPage = tempTextPage
|
||||||
)
|
)
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
if (selectionCharRange.value != null && selectedWordScreenRects.isNotEmpty()) {
|
if (selectionCharRange.value != null && selectedWordScreenRects.isNotEmpty()) {
|
||||||
val currentRange = selectionCharRange.value!!
|
val currentRange = selectionCharRange.value!!
|
||||||
val text = tempTextPage.textPageGetText(
|
val text = withContext(Dispatchers.IO) {
|
||||||
|
tempTextPage.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) }
|
||||||
|
|
@ -2297,6 +2307,8 @@ internal fun PdfPageComposable(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!pdfiumSelectionSuccessful && bitmapState != null) {
|
if (!pdfiumSelectionSuccessful && bitmapState != null) {
|
||||||
Timber.d(
|
Timber.d(
|
||||||
|
|
@ -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 {
|
||||||
|
val charCount = withContext(Dispatchers.IO) {
|
||||||
page = pdfDocumentItem.openPage(pdfPageIndex)
|
page = pdfDocumentItem.openPage(pdfPageIndex)
|
||||||
textPage = page.openTextPage()
|
textPage = page.openTextPage()
|
||||||
val charCount = textPage.textPageCountChars()
|
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,11 +3730,13 @@ 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(NonCancellable) {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
textPage?.close()
|
textPage?.close()
|
||||||
page?.close()
|
page?.close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
if (allOcrSymbolsForSelection.isNotEmpty()) {
|
if (allOcrSymbolsForSelection.isNotEmpty()) {
|
||||||
ocrSelectionSymbolIndices =
|
ocrSelectionSymbolIndices =
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
with(density) {
|
|
||||||
val magnifierWidthPx = magnifierWidth.toPx()
|
|
||||||
val magnifierHeightPx = magnifierHeight.toPx()
|
|
||||||
val magnifierOffsetAboveHandlePx = magnifierOffsetAboveHandle.toPx()
|
|
||||||
val effectiveScale = staticData.effectiveScale
|
val effectiveScale = staticData.effectiveScale
|
||||||
|
|
||||||
val modifier: Modifier
|
val effectiveZoomFactor = if (isVerticalScroll && effectiveScale > 1f) {
|
||||||
val effectiveZoomFactor: Float
|
effectiveScale * 1.25f
|
||||||
|
|
||||||
if (isVerticalScroll && effectiveScale > 1f) {
|
|
||||||
val yOffsetPixels =
|
|
||||||
pos.y - (magnifierHeightPx + magnifierOffsetAboveHandlePx) / effectiveScale
|
|
||||||
val xOffsetPixels = pos.x - (magnifierWidthPx / 2) / effectiveScale
|
|
||||||
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.offset(x = xOffsetPixels.toDp(), y = yOffsetPixels.toDp())
|
|
||||||
.graphicsLayer(
|
|
||||||
scaleX = 1f / effectiveScale,
|
|
||||||
scaleY = 1f / effectiveScale,
|
|
||||||
transformOrigin = TransformOrigin(0f, 0f)
|
|
||||||
)
|
|
||||||
|
|
||||||
effectiveZoomFactor = effectiveScale * 1.25f
|
|
||||||
} else {
|
} else {
|
||||||
val xOffsetVal = pos.x - magnifierWidthPx / 2
|
magnifierZoomFactor
|
||||||
val yOffsetVal = pos.y - magnifierHeightPx - magnifierOffsetAboveHandlePx
|
|
||||||
|
|
||||||
modifier = Modifier.offset(x = xOffsetVal.toDp(), y = yOffsetVal.toDp())
|
|
||||||
effectiveZoomFactor = magnifierZoomFactor
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val popupPositionProvider = remember(pos, layoutCoordinates, density) {
|
||||||
|
object : androidx.compose.ui.window.PopupPositionProvider {
|
||||||
|
override fun calculatePosition(
|
||||||
|
anchorBounds: androidx.compose.ui.unit.IntRect,
|
||||||
|
windowSize: androidx.compose.ui.unit.IntSize,
|
||||||
|
layoutDirection: androidx.compose.ui.unit.LayoutDirection,
|
||||||
|
popupContentSize: androidx.compose.ui.unit.IntSize
|
||||||
|
): androidx.compose.ui.unit.IntOffset {
|
||||||
|
val coords = layoutCoordinates ?: return androidx.compose.ui.unit.IntOffset.Zero
|
||||||
|
|
||||||
|
val windowPos = coords.localToWindow(pos)
|
||||||
|
val offsetPx = with(density) { magnifierOffsetAboveHandle.toPx() }
|
||||||
|
|
||||||
|
val x = (windowPos.x - popupContentSize.width / 2).toInt()
|
||||||
|
val y = (windowPos.y - popupContentSize.height - offsetPx).toInt()
|
||||||
|
|
||||||
|
return androidx.compose.ui.unit.IntOffset(x, y)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
androidx.compose.ui.window.Popup(
|
||||||
|
popupPositionProvider = popupPositionProvider,
|
||||||
|
properties = androidx.compose.ui.window.PopupProperties(
|
||||||
|
focusable = false,
|
||||||
|
dismissOnClickOutside = false,
|
||||||
|
dismissOnBackPress = false,
|
||||||
|
usePlatformDefaultWidth = false
|
||||||
|
)
|
||||||
|
) {
|
||||||
MagnifierComposable(
|
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()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
514
app/src/main/java/com/aryan/reader/pdf/PdfToHtmlGenerator.kt
Normal file
514
app/src/main/java/com/aryan/reader/pdf/PdfToHtmlGenerator.kt
Normal 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("&", "&")
|
||||||
|
.replace("<", "<")
|
||||||
|
.replace(">", ">")
|
||||||
|
.replace("\"", """)
|
||||||
|
.replace("'", "'")
|
||||||
|
}
|
||||||
|
|
@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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,28 +1037,93 @@ 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 drawingState = remember { PdfDrawingState() }
|
||||||
|
val pdfiumCore = remember(context) { PdfiumCoreKt(Dispatchers.Default) }
|
||||||
val allAnnotationsProvider = remember { { allAnnotations } }
|
val verticalReaderState = rememberVerticalPdfReaderState()
|
||||||
|
var virtualPages by remember { mutableStateOf<List<VirtualPage>>(emptyList()) }
|
||||||
LaunchedEffect(Unit) {
|
val totalDisplayPages by remember(virtualPages, totalPages) {
|
||||||
Timber.d("PdfViewerScreen init: initialBookmarksJson is '$initialBookmarksJson'")
|
derivedStateOf { if (virtualPages.isNotEmpty()) virtualPages.size else totalPages }
|
||||||
Timber.d("PdfViewerScreen init: Loaded ${bookmarks.size} bookmarks initially.")
|
}
|
||||||
|
val pagerState = rememberPagerState(initialPage = 0, pageCount = { totalDisplayPages })
|
||||||
|
val currentPage by remember {
|
||||||
|
derivedStateOf {
|
||||||
|
when (displayMode) {
|
||||||
|
DisplayMode.PAGINATION -> pagerState.currentPage
|
||||||
|
DisplayMode.VERTICAL_SCROLL -> verticalReaderState.currentPage
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(bookmarks) {
|
val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current
|
||||||
val objectList = bookmarks.map { bookmark ->
|
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 {
|
JSONObject().apply {
|
||||||
put("pageIndex", bookmark.pageIndex)
|
put("pageIndex", bookmark.pageIndex)
|
||||||
put("title", bookmark.title)
|
put("title", bookmark.title)
|
||||||
|
|
@ -1058,9 +1131,68 @@ fun PdfViewerScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val bookmarksJson = JSONArray(objectList).toString()
|
val bookmarksJson = JSONArray(objectList).toString()
|
||||||
Timber.d("Bookmarks changed. Firing onBookmarksChanged with JSON: $bookmarksJson")
|
withContext(Dispatchers.Main) {
|
||||||
onBookmarksChanged(bookmarksJson)
|
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 } }
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
Timber.d("PdfViewerScreen init: initialBookmarksJson is '$initialBookmarksJson'")
|
||||||
|
Timber.d("PdfViewerScreen init: Loaded ${bookmarks.size} bookmarks initially.")
|
||||||
|
}
|
||||||
|
|
||||||
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) }
|
||||||
|
|
@ -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,32 +2033,14 @@ 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 ->
|
saveAllData(true).join()
|
||||||
JSONObject().apply {
|
|
||||||
put("pageIndex", bookmark.pageIndex)
|
|
||||||
put("title", bookmark.title)
|
|
||||||
put("totalPages", bookmark.totalPages)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val bookmarksJson = JSONArray(objectList).toString()
|
|
||||||
onBookmarksChanged(bookmarksJson)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Timber.tag("AnnotationSync").d("Save complete. Navigating back.")
|
Timber.tag("AnnotationSync").d("Save complete. Navigating back.")
|
||||||
onNavigateBack()
|
onNavigateBack()
|
||||||
|
|
@ -4954,10 +4999,24 @@ fun PdfViewerScreen(
|
||||||
enabled = pdfDocument != null && !isReflowingThisBook,
|
enabled = pdfDocument != null && !isReflowingThisBook,
|
||||||
onClick = {
|
onClick = {
|
||||||
showMoreMenu = false
|
showMoreMenu = false
|
||||||
|
|
||||||
|
coroutineScope.launch {
|
||||||
|
if (richTextController != null) {
|
||||||
|
withContext(NonCancellable) { richTextController.saveImmediate() }
|
||||||
|
}
|
||||||
|
saveAllData(true).join()
|
||||||
|
|
||||||
if (hasReflowFile) {
|
if (hasReflowFile) {
|
||||||
val item = uiState.recentFiles.find { it.bookId == reflowBookId }
|
val item = uiState.allRecentFiles.find { it.bookId == reflowBookId }
|
||||||
if (item != null) {
|
if (item != null) {
|
||||||
viewModel.switchToFileSeamlessly(item, currentPage)
|
viewModel.switchToFileSeamlessly(item, currentPage)
|
||||||
|
} else {
|
||||||
|
viewModel.generateAndImportReflowFile(
|
||||||
|
pdfBookId = bookId,
|
||||||
|
pdfUri = pdfUri,
|
||||||
|
originalTitle = originalFileName,
|
||||||
|
autoOpenPage = currentPage
|
||||||
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
viewModel.generateAndImportReflowFile(
|
viewModel.generateAndImportReflowFile(
|
||||||
|
|
@ -4967,6 +5026,7 @@ fun PdfViewerScreen(
|
||||||
autoOpenPage = currentPage
|
autoOpenPage = currentPage
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
leadingIcon = {
|
leadingIcon = {
|
||||||
Icon(
|
Icon(
|
||||||
|
|
|
||||||
|
|
@ -21,55 +21,52 @@ 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,
|
||||||
|
|
@ -83,16 +80,16 @@ class ReflowWorker(
|
||||||
)
|
)
|
||||||
|
|
||||||
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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue