Pdf reflow upgrade (#63)

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

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

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

* Added junk character filtering and normalization to PDF text extraction

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

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

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

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

View file

@ -12,13 +12,34 @@
typedef double (*FPDFText_GetFontSize_t)(void* text_page, int index);
typedef int (*FPDFText_GetFontWeight_t)(void* text_page, int index);
typedef int (*FPDFText_GetFontInfo_t)(void* text_page, int index, void* buffer, unsigned long buflen, int* flags);
typedef int (*FPDFText_GetCharBox_t)(void* text_page, int index, double* left, double* right, double* bottom, double* top);
typedef int (*FPDFPage_GetAnnotCount_t)(void* page);
typedef void* (*FPDFPage_GetAnnot_t)(void* page, int index);
typedef int (*FPDFAnnot_GetSubtype_t)(void* annot);
typedef int (*FPDFAnnot_GetRect_t)(void* annot, void* rect);
typedef unsigned long (*FPDFAnnot_GetStringValue_t)(void* annot, const char* key, void* buffer, unsigned long buflen);
typedef int (*FPDFAnnot_GetColor_t)(void* annot, int type, unsigned int* R, unsigned int* G, unsigned int* B, unsigned int* A);
typedef int (*FPDFPage_CountObjects_t)(void* page);
typedef void* (*FPDFPage_GetObject_t)(void* page, int index);
typedef int (*FPDFPageObj_GetType_t)(void* page_object);
typedef void* (*FPDFImageObj_GetBitmap_t)(void* image_object);
typedef int (*FPDFBitmap_GetWidth_t)(void* bitmap);
typedef int (*FPDFBitmap_GetHeight_t)(void* bitmap);
typedef int (*FPDFBitmap_GetStride_t)(void* bitmap);
typedef void* (*FPDFBitmap_GetBuffer_t)(void* bitmap);
typedef void (*FPDFBitmap_Destroy_t)(void* bitmap);
typedef int (*FPDFPageObj_GetBounds_t)(void* page_object, float* left, float* bottom, float* right, float* top);
static FPDFPage_CountObjects_t count_objects_func = nullptr;
static FPDFPage_GetObject_t get_object_func = nullptr;
static FPDFPageObj_GetType_t get_object_type_func = nullptr;
static FPDFImageObj_GetBitmap_t get_image_bitmap_func = nullptr;
static FPDFBitmap_GetWidth_t bitmap_get_width_func = nullptr;
static FPDFBitmap_GetHeight_t bitmap_get_height_func = nullptr;
static FPDFBitmap_GetStride_t bitmap_get_stride_func = nullptr;
static FPDFBitmap_GetBuffer_t bitmap_get_buffer_func = nullptr;
static FPDFBitmap_Destroy_t bitmap_destroy_func = nullptr;
static FPDFPageObj_GetBounds_t get_object_bounds_func = nullptr;
static FPDFPage_GetAnnotCount_t get_annot_count_func = nullptr;
static FPDFPage_GetAnnot_t get_annot_func = nullptr;
static FPDFAnnot_GetSubtype_t get_annot_subtype_func = nullptr;
@ -29,6 +50,7 @@ static void* pdfium_handle = nullptr;
static FPDFText_GetFontSize_t get_font_size_func = nullptr;
static FPDFText_GetFontWeight_t get_font_weight_func = nullptr;
static FPDFText_GetFontInfo_t get_font_info_func = nullptr;
static FPDFText_GetCharBox_t get_char_box_func = nullptr;
typedef void* (*FPDFAnnot_GetLinkedAnnot_t)(void* annot, const char* key);
typedef void (*FPDFPage_CloseAnnot_t)(void* annot);
@ -48,6 +70,7 @@ static bool init_pdfium() {
get_font_size_func = (FPDFText_GetFontSize_t) dlsym(pdfium_handle, "FPDFText_GetFontSize");
get_font_weight_func = (FPDFText_GetFontWeight_t) dlsym(pdfium_handle, "FPDFText_GetFontWeight");
get_font_info_func = (FPDFText_GetFontInfo_t) dlsym(pdfium_handle, "FPDFText_GetFontInfo");
get_char_box_func = (FPDFText_GetCharBox_t) dlsym(pdfium_handle, "FPDFText_GetCharBox");
get_annot_count_func = (FPDFPage_GetAnnotCount_t) dlsym(pdfium_handle, "FPDFPage_GetAnnotCount");
get_annot_func = (FPDFPage_GetAnnot_t) dlsym(pdfium_handle, "FPDFPage_GetAnnot");
@ -58,6 +81,17 @@ static bool init_pdfium() {
get_linked_annot_func = (FPDFAnnot_GetLinkedAnnot_t) dlsym(pdfium_handle, "FPDFAnnot_GetLinkedAnnot");
close_annot_func = (FPDFPage_CloseAnnot_t) dlsym(pdfium_handle, "FPDFPage_CloseAnnot");
count_objects_func = (FPDFPage_CountObjects_t) dlsym(pdfium_handle, "FPDFPage_CountObjects");
get_object_func = (FPDFPage_GetObject_t) dlsym(pdfium_handle, "FPDFPage_GetObject");
get_object_type_func = (FPDFPageObj_GetType_t) dlsym(pdfium_handle, "FPDFPageObj_GetType");
get_image_bitmap_func = (FPDFImageObj_GetBitmap_t) dlsym(pdfium_handle, "FPDFImageObj_GetBitmap");
bitmap_get_width_func = (FPDFBitmap_GetWidth_t) dlsym(pdfium_handle, "FPDFBitmap_GetWidth");
bitmap_get_height_func = (FPDFBitmap_GetHeight_t) dlsym(pdfium_handle, "FPDFBitmap_GetHeight");
bitmap_get_stride_func = (FPDFBitmap_GetStride_t) dlsym(pdfium_handle, "FPDFBitmap_GetStride");
bitmap_get_buffer_func = (FPDFBitmap_GetBuffer_t) dlsym(pdfium_handle, "FPDFBitmap_GetBuffer");
bitmap_destroy_func = (FPDFBitmap_Destroy_t) dlsym(pdfium_handle, "FPDFBitmap_Destroy");
get_object_bounds_func = (FPDFPageObj_GetBounds_t) dlsym(pdfium_handle, "FPDFPageObj_GetBounds");
bool success = get_annot_count_func && get_annot_func && get_annot_subtype_func &&
get_annot_rect_func && get_annot_string_func;
@ -127,6 +161,27 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_getPageFontFlags(JNIEnv *env, jclas
return result;
}
extern "C" JNIEXPORT jfloatArray JNICALL
Java_com_aryan_reader_pdf_NativePdfiumBridge_getPageCharBoxes(JNIEnv *env, jclass clazz, jlong textPagePtr, jint count) {
if (!init_pdfium() || !get_char_box_func || count <= 0) return nullptr;
const int stride = 4;
jfloatArray result = env->NewFloatArray(count * stride);
jfloat *fill = new jfloat[count * stride];
void* tp = reinterpret_cast<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
Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotCount(JNIEnv *env, jclass clazz, jlong pagePtr) {
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);
return result;
}
extern "C" JNIEXPORT jint JNICALL
Java_com_aryan_reader_pdf_NativePdfiumBridge_getPageObjectCount(JNIEnv *env, jclass clazz, jlong pagePtr) {
if (!init_pdfium() || !count_objects_func) return 0;
return count_objects_func(reinterpret_cast<void*>(pagePtr));
}
extern "C" JNIEXPORT jint JNICALL
Java_com_aryan_reader_pdf_NativePdfiumBridge_getPageObjectType(JNIEnv *env, jclass clazz, jlong pagePtr, jint index) {
if (!init_pdfium() || !get_object_func || !get_object_type_func) return 0;
void* obj = get_object_func(reinterpret_cast<void*>(pagePtr), index);
return obj ? get_object_type_func(obj) : 0;
}
extern "C" JNIEXPORT jboolean JNICALL
Java_com_aryan_reader_pdf_NativePdfiumBridge_getPageObjectBoundingBox(JNIEnv *env, jclass clazz, jlong pagePtr, jint index, jfloatArray outRect) {
if (!init_pdfium() || !get_object_func || !get_object_bounds_func) return JNI_FALSE;
void* obj = get_object_func(reinterpret_cast<void*>(pagePtr), index);
if (!obj) return JNI_FALSE;
float left = 0, bottom = 0, right = 0, top = 0;
if (get_object_bounds_func(obj, &left, &bottom, &right, &top)) {
jfloat rect[4] = {left, bottom, right, top};
env->SetFloatArrayRegion(outRect, 0, 4, rect);
return JNI_TRUE;
}
return JNI_FALSE;
}
extern "C" JNIEXPORT jintArray JNICALL
Java_com_aryan_reader_pdf_NativePdfiumBridge_extractImagePixels(JNIEnv *env, jclass clazz, jlong pagePtr, jint index, jintArray dimens) {
if (!init_pdfium() || !get_object_func || !get_image_bitmap_func || !bitmap_get_buffer_func) return nullptr;
void* obj = get_object_func(reinterpret_cast<void*>(pagePtr), index);
if (!obj || get_object_type_func(obj) != 3) return nullptr; // 3 = FPDF_PAGEOBJ_IMAGE
void* bmp = get_image_bitmap_func(obj);
if (!bmp) return nullptr;
int w = bitmap_get_width_func(bmp);
int h = bitmap_get_height_func(bmp);
int stride = bitmap_get_stride_func(bmp);
uint8_t* buffer = (uint8_t*)bitmap_get_buffer_func(bmp);
if (!buffer || w <= 0 || h <= 0) {
bitmap_destroy_func(bmp);
return nullptr;
}
jintArray result = env->NewIntArray(w * h);
jint* pixels = new jint[w * h];
int bpp = stride / w;
for (int y = 0; y < h; y++) {
uint8_t* row = buffer + y * stride;
for (int x = 0; x < w; x++) {
uint8_t r = 0, g = 0, b = 0, a = 255;
if (bpp >= 3) {
b = row[x * bpp + 0];
g = row[x * bpp + 1];
r = row[x * bpp + 2];
if (bpp >= 4) a = row[x * bpp + 3];
} else if (bpp == 1) {
r = g = b = row[x];
}
// Pack pixels for Android ARGB_8888
pixels[y * w + x] = (a << 24) | (r << 16) | (g << 8) | b;
}
}
env->SetIntArrayRegion(result, 0, w * h, pixels);
delete[] pixels;
bitmap_destroy_func(bmp);
jint dims[2] = {w, h};
env->SetIntArrayRegion(dimens, 0, 2, dims);
return result;
}

View file

@ -169,7 +169,6 @@ data class ReaderScreenState(
val selectedFileType: FileType? = null,
val isLoading: Boolean = false,
val errorMessage: String? = null,
val recentFiles: List<RecentFileItem> = emptyList(),
val contextualActionItems: Set<RecentFileItem> = emptySet(),
val renderMode: RenderMode = RenderMode.VERTICAL_SCROLL,
val sortOrder: SortOrder = SortOrder.RECENT,
@ -206,7 +205,9 @@ data class ReaderScreenState(
val searchQuery: String = "",
val showFolderMigrationDialog: Boolean = false,
val isRefreshing: Boolean = false,
val reflowProgress: Float? = null
val reflowProgress: Float? = null,
val recentFiles: List<RecentFileItem> = emptyList(),
val allRecentFiles: List<RecentFileItem> = emptyList(),
)
open class MainViewModel(application: Application) : AndroidViewModel(application) {
@ -300,61 +301,42 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
open val uiState: StateFlow<ReaderScreenState> = combine(
_internalState, recentFilesRepository.getRecentFilesFlow(), _prefsUpdateFlow
) { internalState, recentFilesFromDb, _ ->
val validContextualItems = internalState.contextualActionItems.filter { contextItem ->
recentFilesFromDb.any { dbItem ->
dbItem.uriString == contextItem.uriString
}
}.toSet()
if (validContextualItems.size != internalState.contextualActionItems.size) {
Timber.d(
"Contextual items updated due to recent files change. Before: ${internalState.contextualActionItems.size}, After: ${validContextualItems.size}"
)
}
val query = internalState.searchQuery.trim()
val filteredFiles = if (query.isBlank()) {
val rawFilteredByQuery = if (query.isBlank()) {
recentFilesFromDb
} else {
recentFilesFromDb.filter { item ->
item.displayName.contains(query, ignoreCase = true) || item.title?.contains(
query, ignoreCase = true
) == true || item.author?.contains(query, ignoreCase = true) == true
item.displayName.contains(query, ignoreCase = true) ||
item.title?.contains(query, ignoreCase = true) == true ||
item.author?.contains(query, ignoreCase = true) == true
}
}
val sortedRecentFiles = when (internalState.sortOrder) {
SortOrder.RECENT -> filteredFiles // Changed from recentFilesFromDb
SortOrder.TITLE_ASC -> filteredFiles.sortedBy {
it.title?.lowercase() ?: it.displayName.lowercase()
}
SortOrder.AUTHOR_ASC -> filteredFiles.sortedWith(
compareBy(nullsLast()) {
it.author?.lowercase()
})
SortOrder.PERCENT_ASC -> filteredFiles.sortedBy { it.progressPercentage ?: 0f }
SortOrder.PERCENT_DESC -> filteredFiles.sortedByDescending {
it.progressPercentage ?: 0f
}
val sortedAllFiles = when (internalState.sortOrder) {
SortOrder.RECENT -> rawFilteredByQuery
SortOrder.TITLE_ASC -> rawFilteredByQuery.sortedBy { it.title?.lowercase() ?: it.displayName.lowercase() }
SortOrder.AUTHOR_ASC -> rawFilteredByQuery.sortedWith(compareBy(nullsLast()) { it.author?.lowercase() })
SortOrder.PERCENT_ASC -> rawFilteredByQuery.sortedBy { it.progressPercentage ?: 0f }
SortOrder.PERCENT_DESC -> rawFilteredByQuery.sortedByDescending { it.progressPercentage ?: 0f }
}
val visibleRecentFiles = sortedAllFiles.filterNot { it.bookId.endsWith("_reflow") }
val validContextualItems = internalState.contextualActionItems.filter { contextItem ->
visibleRecentFiles.any { dbItem -> dbItem.uriString == contextItem.uriString }
}.toSet()
val shelfNames = prefs.getStringSet(KEY_SHELVES, emptySet()) ?: emptySet()
val shelvedBookIds = mutableSetOf<String>()
val shelvesFromPrefs = shelfNames.map { shelfName ->
val bookIds = prefs.getStringSet(
"$KEY_SHELF_CONTENT_PREFIX$shelfName", emptySet()
) ?: emptySet()
shelvedBookIds.addAll(bookIds)
val booksForShelf = sortedRecentFiles.filter {
it.bookId in bookIds
}
val bookIds = prefs.getStringSet("$KEY_SHELF_CONTENT_PREFIX$shelfName", emptySet()) ?: emptySet()
val booksForShelf = visibleRecentFiles.filter { it.bookId in bookIds }
shelvedBookIds.addAll(booksForShelf.map { it.bookId })
Shelf(shelfName, booksForShelf)
}.sortedBy { it.name }
val unshelvedBooks = sortedRecentFiles.filter { it.bookId !in shelvedBookIds }
val unshelvedBooks = visibleRecentFiles.filter { it.bookId !in shelvedBookIds }
val allShelves = shelvesFromPrefs + Shelf("Unshelved", unshelvedBooks)
val booksAvailableForAdding =
@ -365,7 +347,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
when (internalState.addBooksSource) {
AddBooksSource.UNSHELVED -> unshelvedBooks
AddBooksSource.ALL_BOOKS -> sortedRecentFiles.filter {
AddBooksSource.ALL_BOOKS -> visibleRecentFiles.filter {
it.uriString !in currentShelfBooksUris
}
}
@ -374,7 +356,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
internalState.copy(
recentFiles = sortedRecentFiles,
recentFiles = visibleRecentFiles,
allRecentFiles = sortedAllFiles,
contextualActionItems = validContextualItems,
shelves = allShelves,
booksAvailableForAdding = booksAvailableForAdding
@ -751,6 +734,29 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
viewModelScope.launch { fontsRepository.deleteFont(fontId) }
}
fun deleteBookPermanently(bookId: String, onDeleted: () -> Unit = {}) {
viewModelScope.launch {
val item = recentFilesRepository.getFileByBookId(bookId) ?: return@launch
Timber.d("Deleting book permanently from reader: $bookId")
pdfTextRepository.clearBookText(bookId)
try {
val cacheDir = File(appContext.cacheDir, "imported_file_$bookId")
if (cacheDir.exists()) cacheDir.deleteRecursively()
} catch (e: Exception) {
Timber.e(e, "Failed to clear cache for $bookId")
}
recentFilesRepository.deleteFilePermanently(listOf(bookId))
withContext(Dispatchers.Main) {
onDeleted()
showBanner("Text view deleted.")
}
}
}
private fun getInstallationId(): String {
var installationId = prefs.getString(KEY_INSTALLATION_ID, null)
if (installationId == null) {

View file

@ -21,7 +21,6 @@ package com.aryan.reader.epub
import android.content.Context
import com.aryan.reader.FileType
import com.aryan.reader.pdf.PdfToMarkdownGenerator
import com.vladsch.flexmark.ext.autolink.AutolinkExtension
import com.vladsch.flexmark.ext.gfm.strikethrough.StrikethroughExtension
import com.vladsch.flexmark.ext.gfm.tasklist.TaskListExtension
@ -114,11 +113,10 @@ class SingleFileImporter(private val context: Context) {
hr { border: 0; border-top: 1px solid #ccc; margin: 2em 0; }
""".trimIndent()
val delimiter = PdfToMarkdownGenerator.PAGE_DELIMITER.trim()
val rawChapters = if (markdownContent.contains(delimiter)) {
markdownContent.split(delimiter)
} else {
val rawChapters = if (markdownContent.contains("\n\n---\n\n")) {
markdownContent.split("\n\n---\n\n")
} else {
listOf(markdownContent)
}
Timber.tag("FileOpenPerf").d("[MD] parseMarkdown: Split into ${rawChapters.size} raw chapters | elapsed=${System.currentTimeMillis() - parseStart}ms")
@ -378,65 +376,62 @@ class SingleFileImporter(private val context: Context) {
val author = doc.select("meta[name=author]").attr("content").takeIf { it.isNotBlank() }
?: doc.select("meta[property=article:author]").attr("content").takeIf { it.isNotBlank() }
val finalHtml = doc.outerHtml()
val cssStyle = doc.select("style").html()
val bodyHtml = doc.body().html()
Timber.tag("FileOpenPerf").d("[HTML] parseHtml COMPLETE | elapsed=${System.currentTimeMillis() - parseStart}ms")
createBookFromHtmlBody(title, null, null, originalBookNameHint, bookId, extractionDir, metadataFile, preGeneratedFullHtml = finalHtml, author = author)
}
private fun createBookFromHtmlBody(
title: String,
@Suppress("SameParameterValue") bodyContent: String?,
@Suppress("SameParameterValue")cssStyle: String?,
fileName: String,
bookId: String,
extractionDir: File,
metadataFile: File,
preGeneratedFullHtml: String? = null,
author: String? = null
): EpubBook {
val fullHtml = preGeneratedFullHtml ?: """
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<title>${title.replace("\"", "&quot;")}</title>
<style>
${cssStyle ?: ""}
</style>
</head>
<body>
$bodyContent
</body>
</html>
""".trimIndent()
val plainText = Jsoup.parse(fullHtml).text()
try {
File(extractionDir, "content.html").writeText(fullHtml)
} catch (e: Exception) {
Timber.e(e, "Failed to save generated HTML to disk")
val rawChapters = if (bodyHtml.contains("<page-break></page-break>")) {
bodyHtml.split("<page-break></page-break>")
} else {
listOf(bodyHtml)
}
val chapter = EpubChapter(
chapterId = bookId,
absPath = "content.html",
title = title,
htmlFilePath = "content.html",
plainTextContent = plainText,
htmlContent = "",
depth = 0,
isInToc = true
)
val chapters = rawChapters.mapIndexed { index, rawText ->
async(Dispatchers.Default) {
if (rawText.isBlank()) return@async null
val pageNum = index + 1
val chapterTitle = if (rawChapters.size > 1) "Page $pageNum" else title
val fileName = "page_$pageNum.html"
val file = File(extractionDir, fileName)
val fullHtml = """
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<title>${title.replace("\"", "&quot;")}</title>
<style>${cssStyle}</style>
</head>
<body>
${rawText.trim()}
</body>
</html>
""".trimIndent()
file.writeText(fullHtml)
EpubChapter(
chapterId = "${bookId}_$pageNum",
absPath = fileName,
title = chapterTitle,
htmlFilePath = fileName,
plainTextContent = Jsoup.parse(fullHtml).text(),
htmlContent = "",
depth = 0,
isInToc = true
)
}
}.awaitAll().filterNotNull()
Timber.tag("FileOpenPerf").d("[HTML] parseHtml COMPLETE | elapsed=${System.currentTimeMillis() - parseStart}ms")
val book = EpubBook(
fileName = fileName,
fileName = originalBookNameHint,
title = title,
author = author ?: "",
author = author ?: "Unknown",
language = "en",
coverImage = null,
chapters = listOf(chapter),
chaptersForPagination = listOf(chapter),
chapters = chapters,
chaptersForPagination = chapters,
images = emptyList(),
pageList = emptyList(),
extractionBasePath = extractionDir.absolutePath,
@ -449,6 +444,6 @@ class SingleFileImporter(private val context: Context) {
Timber.e(e, "Failed to cache HTML metadata")
}
return book
return@withContext book
}
}

View file

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

View file

@ -363,7 +363,16 @@ fun EpubReaderScreen(
onRenderModeChange = onRenderModeChange,
customFonts = customFonts,
onImportFont = onImportFont,
onToggleReflow = onOpenOriginal
onToggleReflow = onOpenOriginal,
onDeleteReflow = if (isReflowFile) {
{
uiState.selectedBookId?.let { id ->
viewModel.deleteBookPermanently(id) {
onNavigateBack()
}
}
}
} else null
)
}
@ -388,7 +397,8 @@ fun EpubReaderHost(
onRenderModeChange: (RenderMode) -> Unit,
customFonts: List<CustomFontEntity>,
onImportFont: (Uri) -> Unit,
onToggleReflow: ((Int) -> Unit)? = null
onToggleReflow: ((Int) -> Unit)? = null,
onDeleteReflow: (() -> Unit)? = null
) {
val view = LocalView.current
val context = LocalContext.current
@ -3124,6 +3134,7 @@ fun EpubReaderHost(
onToggleReflow(activeChapter)
}
} else null,
onDeleteReflow = onDeleteReflow
)
val autoScrollPadding by androidx.compose.animation.core.animateDpAsState(

View file

@ -11,12 +11,19 @@ object NativePdfiumBridge {
@JvmStatic external fun getPageFontSizes(textPagePtr: Long, count: Int): FloatArray?
@JvmStatic external fun getPageFontWeights(textPagePtr: Long, count: Int): IntArray?
@JvmStatic external fun getPageFontFlags(textPagePtr: Long, count: Int): IntArray?
@JvmStatic external fun getPageCharBoxes(textPagePtr: Long, count: Int): FloatArray?
@JvmStatic external fun getAnnotCount(pagePtr: Long): Int
@JvmStatic external fun getAnnotSubtype(pagePtr: Long, index: Int): Int
@JvmStatic external fun getAnnotRect(pagePtr: Long, index: Int): FloatArray?
@JvmStatic external fun getAnnotString(pagePtr: Long, index: Int, key: String): String?
// Image/Object extraction
@JvmStatic external fun getPageObjectCount(pagePtr: Long): Int
@JvmStatic external fun getPageObjectType(pagePtr: Long, index: Int): Int
@JvmStatic external fun getPageObjectBoundingBox(pagePtr: Long, index: Int, outRect: FloatArray): Boolean
@JvmStatic external fun extractImagePixels(pagePtr: Long, index: Int, dimens: IntArray): IntArray?
const val ANNOT_TEXT = 1 // Sticky Note
const val ANNOT_LINK = 2 // Link
const val ANNOT_HIGHLIGHT = 8 // Highlight

View file

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

View file

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

View file

@ -1,135 +0,0 @@
package com.aryan.reader.pdf
import android.content.Context
import com.aryan.reader.epub.EpubBook
import com.aryan.reader.epub.EpubChapter
import com.aryan.reader.pdf.data.PdfTextRepository
import io.legere.pdfiumandroid.suspend.PdfDocumentKt
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import java.util.UUID
object PdfReflowGenerator {
suspend fun generateReflowBook(
context: Context,
bookId: String,
document: PdfDocumentKt,
repository: PdfTextRepository,
totalPages: Int
): EpubBook = withContext(Dispatchers.Default) {
val cacheDir = File(context.cacheDir, "reflow_cache/$bookId")
if (cacheDir.exists()) {
cacheDir.deleteRecursively()
}
cacheDir.mkdirs()
val chapters = mutableListOf<EpubChapter>()
val css = """
body { font-family: sans-serif; line-height: 1.6; padding: 1em; }
p { margin-bottom: 1em; }
h1, h2 { color: #333; margin-top: 1.5em; }
.page-marker { color: #888; font-size: 0.8em; margin-bottom: 2em; border-bottom: 1px solid #eee; }
""".trimIndent()
// We generate a chapter for every page to keep sync simple
for (i in 0 until totalPages) {
val rawText = repository.getOrExtractText(bookId, document, i)
val cleanedHtml = processTextToHtml(rawText, i + 1)
val fileName = "page_$i.html"
val file = File(cacheDir, fileName)
val fullHtml = """
<!DOCTYPE html>
<html>
<head>
<title>Page ${i + 1}</title>
<style>$css</style>
</head>
<body>
$cleanedHtml
</body>
</html>
""".trimIndent()
file.writeText(fullHtml)
chapters.add(
EpubChapter(
chapterId = "${bookId}_page_$i",
absPath = fileName,
title = "Page ${i + 1}",
htmlFilePath = fileName,
plainTextContent = rawText, // Raw text for search/TTS
htmlContent = fullHtml,
depth = 0,
isInToc = true
)
)
}
EpubBook(
fileName = "Reflow_Session",
title = document.getDocumentMeta().title ?: "Reflow View",
author = document.getDocumentMeta().author ?: "",
language = "en",
coverImage = null,
chapters = chapters,
chaptersForPagination = chapters,
images = emptyList(),
pageList = emptyList(),
extractionBasePath = cacheDir.absolutePath,
css = emptyMap()
)
}
private fun processTextToHtml(rawText: String, pageNumber: Int): String {
if (rawText.isBlank()) return "<p><i>(No text on this page)</i></p>"
val lines = rawText.split('\n')
val sb = StringBuilder()
sb.append("<div class='page-marker'>Page $pageNumber</div>")
var currentParagraph = StringBuilder()
for (line in lines) {
val trimmed = line.trim()
if (trimmed.isEmpty()) {
if (currentParagraph.isNotEmpty()) {
sb.append("<p>${currentParagraph.toString()}</p>")
currentParagraph.clear()
}
continue
}
// Heuristic: Header detection (All caps, short line, no punctuation at end)
val isHeader = trimmed.length < 50 && trimmed.all { it.isUpperCase() || !it.isLetter() } && !trimmed.endsWith(".")
if (isHeader) {
if (currentParagraph.isNotEmpty()) {
sb.append("<p>${currentParagraph.toString()}</p>")
currentParagraph.clear()
}
sb.append("<h2>$trimmed</h2>")
continue
}
if (currentParagraph.isNotEmpty()) {
currentParagraph.append(" ")
}
currentParagraph.append(trimmed)
if (trimmed.endsWith(".") || trimmed.endsWith("?") || trimmed.endsWith("!") || trimmed.endsWith(":")) {
}
}
if (currentParagraph.isNotEmpty()) {
sb.append("<p>${currentParagraph.toString()}</p>")
}
return sb.toString()
}
}

View file

@ -0,0 +1,514 @@
// PdfToHtmlGenerator.kt
package com.aryan.reader.pdf
import android.content.Context
import android.graphics.Bitmap
import android.net.Uri
import android.util.Base64
import io.legere.pdfiumandroid.PdfiumCore
import io.legere.pdfiumandroid.suspend.PdfDocumentKt
import io.legere.pdfiumandroid.suspend.PdfiumCoreKt
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.io.ByteArrayOutputStream
import java.io.File
import kotlin.math.roundToInt
private const val TAG = "PdfToHtml"
object PdfToHtmlGenerator {
suspend fun generateHtmlFile(
context: Context,
pdfUri: Uri,
destFile: File,
startPage: Int = 1,
onProgress: (Float) -> Unit
): Boolean = withContext(Dispatchers.IO) {
val t0 = System.currentTimeMillis()
Timber.tag(TAG).d("generateHtmlFile START | uri=$pdfUri | startPage=$startPage")
val pdfiumCore = PdfiumCoreKt(Dispatchers.Default)
val pfd = context.contentResolver.openFileDescriptor(pdfUri, "r") ?: run {
Timber.tag(TAG).e("Failed to open ParcelFileDescriptor")
return@withContext false
}
try {
val doc = pdfiumCore.newDocument(pfd)
val totalPages = doc.getPageCount()
Timber.tag(TAG).d("Document loaded. Total pages: $totalPages")
val headerFooterStrings = detectRepeatingHeaderFooter(doc, totalPages)
destFile.bufferedWriter().use { writer ->
writer.write(buildGlobalHtmlHeader())
for (pageIdx in (startPage - 1) until totalPages) {
if (pageIdx > startPage - 1) {
writer.write("\n<page-break></page-break>\n")
}
val pageHtml = extractPageHtml(doc, pageIdx, pageIdx + 1, headerFooterStrings)
writer.write(pageHtml)
if (pageIdx % 5 == 0 || pageIdx == totalPages - 1) {
onProgress((pageIdx + 1).toFloat() / totalPages.toFloat())
}
}
writer.write(buildGlobalHtmlFooter())
}
doc.close()
pfd.close()
Timber.tag(TAG).d("generateHtmlFile SUCCESS | ${System.currentTimeMillis() - t0}ms")
return@withContext true
} catch (e: Exception) {
Timber.tag(TAG).e(e, "Failed to generate HTML from PDF")
try { pfd.close() } catch (_: Exception) {}
return@withContext false
}
}
private data class TextSpan(
val text: String,
val size: Float,
val isBold: Boolean,
val isItalic: Boolean
)
private sealed interface PageElement {
val yPos: Float
}
private data class TextElement(
val line: TextLine,
override val yPos: Float
) : PageElement
private data class ImageElement(
val base64Data: String,
val width: Int,
val height: Int,
override val yPos: Float
) : PageElement
private data class TextLine(
val spans: List<TextSpan>,
val yPos: Float,
val charCount: Int
)
private fun buildGlobalHtmlHeader(): String = """
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body { font-family: sans-serif; line-height: 1.65; padding: 1em; max-width: 100%; margin: 0; }
h1 { font-size: 1.9em; font-weight: bold; margin: 1.2em 0 0.4em; }
h2 { font-size: 1.55em; font-weight: bold; margin: 1.1em 0 0.35em; }
h3 { font-size: 1.3em; font-weight: bold; margin: 1.0em 0 0.3em; }
h4 { font-size: 1.1em; font-weight: bold; margin: 0.9em 0 0.25em; }
p { margin: 0.5em 0; }
ul, ol { padding-left: 1.5em; margin: 0.5em 0; }
li { margin-bottom: 0.2em; }
hr { border: none; border-top: 1px solid currentColor; opacity: 0.25; margin: 1.4em 0; }
.page-section { margin-bottom: 0.5em; }
.page-marker { opacity: 0.4; font-size: 0.72em; margin-bottom: 1.2em; letter-spacing: 0.04em; }
.page-divider { border: none; border-top: 1px solid currentColor; opacity: 0.12; margin: 2em 0 1.5em; }
</style>
</head>
<body>
""".trimIndent() + "\n"
private fun buildGlobalHtmlFooter(): String = "\n</body>\n</html>\n"
private suspend fun extractPageHtml(
doc: PdfDocumentKt,
pageIdx: Int,
pageNumber: Int,
headerFooterStrings: Set<String>
): String {
return try {
doc.openPage(pageIdx).use { page ->
if (page == null) return buildEmptyPageSection(pageNumber)
page.openTextPage().use { textPage ->
val charCount = textPage.textPageCountChars()
val pagePtr = page.page.pagePtr
val textPagePtr = textPage.page.pagePtr
val imageElements = mutableListOf<ImageElement>()
val objCount = NativePdfiumBridge.getPageObjectCount(pagePtr)
for (i in 0 until objCount) {
if (NativePdfiumBridge.getPageObjectType(pagePtr, i) == 3) {
val bbox = FloatArray(4)
if (NativePdfiumBridge.getPageObjectBoundingBox(pagePtr, i, bbox)) {
val topY = bbox[3]
val dimens = IntArray(2)
val pixels = NativePdfiumBridge.extractImagePixels(pagePtr, i, dimens)
if (pixels != null && dimens[0] > 0 && dimens[1] > 0) {
try {
val bmp = Bitmap.createBitmap(pixels, dimens[0], dimens[1], Bitmap.Config.ARGB_8888)
val baos = ByteArrayOutputStream()
bmp.compress(Bitmap.CompressFormat.JPEG, 80, baos)
val b64 = Base64.encodeToString(baos.toByteArray(), Base64.NO_WRAP)
imageElements.add(ImageElement(b64, dimens[0], dimens[1], topY))
bmp.recycle()
} catch (_: Exception) {
Timber.tag(TAG).w("Failed to process image $i on page $pageIdx")
}
}
}
}
}
if (charCount <= 0) {
return@use if (imageElements.isNotEmpty()) {
buildPageHtml(pageNumber, imageElements.sortedByDescending { it.yPos }, headerFooterStrings)
} else buildEmptyPageSection(pageNumber)
}
val rawText = textPage.textPageGetText(0, charCount) ?: ""
val actualCount = minOf(charCount, rawText.length)
val sizes: FloatArray?
val weights: IntArray?
val flags: IntArray?
val charBoxes: FloatArray?
synchronized(PdfiumCore.lock) {
sizes = NativePdfiumBridge.getPageFontSizes(textPagePtr, actualCount)
weights = NativePdfiumBridge.getPageFontWeights(textPagePtr, actualCount)
flags = NativePdfiumBridge.getPageFontFlags(textPagePtr, actualCount)
charBoxes = NativePdfiumBridge.getPageCharBoxes(textPagePtr, actualCount)
}
if (sizes == null || weights == null || flags == null) {
return@use buildFallbackPageSection(pageNumber, rawText)
}
val textLines = mutableListOf<TextLine>()
val currentSpans = mutableListOf<TextSpan>()
val currentSpanBuf = StringBuilder()
var curSize = -1f
var curBold = false
var curItalic = false
var lineBaseline = 0f
fun commitSpan() {
if (currentSpanBuf.isNotEmpty()) {
currentSpans.add(TextSpan(currentSpanBuf.toString(), curSize, curBold, curItalic))
currentSpanBuf.clear()
}
}
fun commitLine() {
commitSpan()
if (currentSpans.isNotEmpty()) {
val text = currentSpans.joinToString("") { it.text }
if (text.isNotBlank()) {
textLines.add(TextLine(currentSpans.toList(), lineBaseline, text.length))
}
currentSpans.clear()
}
lineBaseline = 0f
}
for (i in 0 until actualCount) {
val c = rawText[i]
val code = c.code
if (code == 0 || code == 13) continue
if (c == '\n') {
commitLine()
continue
}
val charToProcess = when (c) {
'\u00A0' -> ' '
'\u00AD' -> '-'
'\u0009' -> ' '
else -> c
}
val type = Character.getType(c).toByte()
val isJunk = when {
code == 0xFFFE || code == 0xFFFF -> true
code == 0xFFFD -> true
type == Character.PRIVATE_USE -> true
type == Character.SURROGATE -> true
type == Character.UNASSIGNED -> true
(type == Character.CONTROL && code > 31) -> true
else -> false
}
if (isJunk) {
val prefix = rawText.substring(maxOf(0, i - 2), i).replace("\n", "\\n")
val suffix = rawText.substring(minOf(actualCount, i + 1), minOf(actualCount, i + 3)).replace("\n", "\\n")
Timber.tag("PdfToHtml").w("Filtered Junk: 0x${Integer.toHexString(code).uppercase()} at pg $pageIdx. Context: '$prefix[$c]$suffix'")
continue
}
val size = sizes[i].coerceAtLeast(0f)
val isBold = weights[i] > 600
val isItalic = (flags[i] and 64) != 0
if (currentSpanBuf.isEmpty() && currentSpans.isEmpty() && !charToProcess.isWhitespace()) {
lineBaseline = if (charBoxes != null && i * 4 + 1 < charBoxes.size) charBoxes[i * 4 + 1] else 0f
}
if (currentSpanBuf.isEmpty()) {
curSize = size; curBold = isBold; curItalic = isItalic
currentSpanBuf.append(charToProcess)
} else if (!charToProcess.isWhitespace() && (size != curSize || isBold != curBold || isItalic != curItalic)) {
commitSpan()
curSize = size; curBold = isBold; curItalic = isItalic
currentSpanBuf.append(charToProcess)
} else {
currentSpanBuf.append(charToProcess)
}
}
commitLine()
// 3. MERGE TEXT AND IMAGES VERTICALLY
val finalElements = mutableListOf<PageElement>()
var imgIdx = 0
val sortedImages = imageElements.sortedByDescending { it.yPos }
for (line in textLines) {
// Place images physically positioned above this text line
while (imgIdx < sortedImages.size && sortedImages[imgIdx].yPos >= line.yPos) {
finalElements.add(sortedImages[imgIdx])
imgIdx++
}
finalElements.add(TextElement(line, line.yPos))
}
// Place any remaining images at the bottom of the page
while (imgIdx < sortedImages.size) {
finalElements.add(sortedImages[imgIdx])
imgIdx++
}
buildPageHtml(pageNumber, finalElements, headerFooterStrings)
}
}
} catch (e: Exception) {
Timber.tag(TAG).w(e, "Error extracting page $pageIdx")
buildEmptyPageSection(pageNumber)
}
}
private fun buildEmptyPageSection(pageNumber: Int) =
"<section class=\"page-section\">\n" +
"<p class=\"page-marker\">— Page $pageNumber —</p>\n" +
"<p><em>(No text on this page)</em></p>\n</section>\n"
private fun buildFallbackPageSection(pageNumber: Int, rawText: String) =
"<section class=\"page-section\">\n" +
"<p class=\"page-marker\">— Page $pageNumber —</p>\n" +
"<p>${rawText.escapeHtml()}</p>\n</section>\n"
private fun buildPageHtml(
pageNumber: Int,
elements: List<PageElement>,
headerFooterStrings: Set<String>
): String {
val textElements = elements.filterIsInstance<TextElement>()
val sizeFreq = HashMap<Int, Int>()
textElements.forEach { te ->
te.line.spans.forEach { span ->
val s = span.size.roundToInt().coerceAtLeast(1)
sizeFreq[s] = (sizeFreq[s] ?: 0) + span.text.length
}
}
val baseSize = sizeFreq.maxByOrNull { it.value }?.key?.toFloat() ?: 12f
val lineLengths = textElements.filter { it.line.charCount > 10 }.map { it.line.charCount }.sorted()
val typicalLineLen = if (lineLengths.isNotEmpty())
lineLengths[(lineLengths.size * 0.80).toInt().coerceAtMost(lineLengths.size - 1)]
else 80
val wrapThreshold = (typicalLineLen * 0.80).toInt()
val sb = StringBuilder()
sb.append("<section class=\"page-section\">\n")
sb.append("<p class=\"page-marker\">— Page $pageNumber —</p>\n")
var inParagraph = false
var inUl = false
var inOl = false
fun closeParagraph() { if (inParagraph) { sb.append("</p>\n"); inParagraph = false } }
fun closeList() {
if (inUl) { sb.append("</ul>\n"); inUl = false }
if (inOl) { sb.append("</ol>\n"); inOl = false }
}
for ((index, element) in elements.withIndex()) {
when (element) {
is ImageElement -> {
closeParagraph()
closeList()
sb.append("<div style=\"text-align:center; margin: 1.5em 0;\">\n")
sb.append("<img src=\"data:image/jpeg;base64,${element.base64Data}\" style=\"max-width:100%; height:auto; border-radius: 6px;\"/>\n")
sb.append("</div>\n")
}
is TextElement -> {
val line = element.line
val lineText = line.spans.joinToString("") { it.text }
val trimmed = lineText.trim()
if (trimmed.isEmpty() || headerFooterStrings.any { hf -> trimmed.equals(hf, ignoreCase = true) }) {
closeParagraph()
continue
}
val maxSize = line.spans.filter { it.text.isNotBlank() }.maxOfOrNull { it.size } ?: baseSize
val headingLevel = when {
maxSize > baseSize * 1.6f -> 1
maxSize > baseSize * 1.28f -> 2
maxSize > baseSize * 1.10f -> 3
maxSize > baseSize * 1.04f -> 4
else -> 0
}
val lineLen = trimmed.length
val isShort = lineLen < 60
val isAllCaps = isShort && lineLen >= 3 && trimmed.any { it.isLetter() } && trimmed.all { it.isUpperCase() || !it.isLetter() } && !trimmed.endsWith(".")
val isBullet = trimmed.startsWith("") || trimmed.startsWith("") || trimmed.startsWith("") || trimmed.startsWith("") || (trimmed.startsWith("- ") && trimmed.length > 2 && !trimmed.startsWith("--"))
val numberedMatch = Regex("""^(\d{1,3}[.)]\s|\p{L}[.)]\s)""").containsMatchIn(trimmed)
val isHr = isShort && trimmed.length >= 3 && trimmed.all { it == '-' || it == '=' || it == '_' || it == '—' || it.isWhitespace() }
val effectiveHeading = when {
headingLevel > 0 -> headingLevel
isAllCaps && !isBullet && !numberedMatch -> 2
else -> 0
}
val nextTextElem = elements.drop(index + 1).firstOrNull { it is TextElement && it.line.spans.joinToString(""){ s->s.text}.isNotBlank() } as? TextElement
val shouldBreakParagraph = effectiveHeading > 0 || isBullet || numberedMatch || isHr ||
lineLen < wrapThreshold ||
trimmed.last().let { it == '.' || it == '!' || it == '?' || it == ':' || it == '"' || it == '\u201d' } ||
(nextTextElem != null && nextTextElem.line.spans.joinToString("") { it.text }.trimStart().let { it.startsWith("\u201c") || it.startsWith("\"") || it.startsWith("-") })
when {
isHr -> {
closeParagraph(); closeList()
sb.append("<hr>\n")
}
effectiveHeading > 0 -> {
closeParagraph(); closeList()
val tag = "h${effectiveHeading.coerceIn(1, 4)}"
sb.append("<$tag>${renderSpans(line.spans, insideHeading = true)}</$tag>\n")
}
isBullet -> {
closeParagraph()
if (inOl) { sb.append("</ol>\n"); inOl = false }
if (!inUl) { sb.append("<ul>\n"); inUl = true }
val content = trimmed.removePrefix("").removePrefix("").removePrefix("").removePrefix("").removePrefix("- ").trim()
sb.append("<li>${content.escapeHtml()}</li>\n")
}
numberedMatch -> {
closeParagraph()
if (inUl) { sb.append("</ul>\n"); inUl = false }
if (!inOl) { sb.append("<ol>\n"); inOl = true }
val content = trimmed.substringAfter(" ").trim()
sb.append("<li>${content.escapeHtml()}</li>\n")
}
shouldBreakParagraph -> {
closeList()
if (!inParagraph) { sb.append("<p>"); inParagraph = true }
sb.append(renderSpans(line.spans))
closeParagraph()
}
else -> {
closeList()
if (!inParagraph) { sb.append("<p>"); inParagraph = true } else sb.append(" ")
sb.append(renderSpans(line.spans))
}
}
}
}
}
closeParagraph()
closeList()
sb.append("</section>\n")
return sb.toString()
}
private fun renderSpans(spans: List<TextSpan>, insideHeading: Boolean = false): String {
val sb = StringBuilder()
for (span in spans) {
val s = span.text.escapeHtml()
if (s.isBlank()) { sb.append(s); continue }
val leadCount = s.length - s.trimStart().length
val trailCount = s.length - s.trimEnd().length
val pre = s.take(leadCount)
val post = if (trailCount > 0) s.takeLast(trailCount) else ""
val mid = s.substring(leadCount, s.length - trailCount)
if (mid.isEmpty()) { sb.append(s); continue }
sb.append(pre)
if (!insideHeading) {
if (span.isBold && span.isItalic) sb.append("<strong><em>")
else if (span.isBold) sb.append("<strong>")
else if (span.isItalic) sb.append("<em>")
}
sb.append(mid)
if (!insideHeading) {
if (span.isBold && span.isItalic) sb.append("</em></strong>")
else if (span.isBold) sb.append("</strong>")
else if (span.isItalic) sb.append("</em>")
}
sb.append(post)
}
return sb.toString()
}
private suspend fun detectRepeatingHeaderFooter(
doc: PdfDocumentKt,
totalPages: Int
): Set<String> = withContext(Dispatchers.Default) {
if (totalPages < 5) return@withContext emptySet()
val step = maxOf(1, totalPages / 8)
val samplePages = (0 until totalPages).filter { it % step == 0 }.take(8)
val frequency = HashMap<String, Int>()
for (pageIdx in samplePages) {
try {
doc.openPage(pageIdx).use { page ->
page.openTextPage().use { textPage ->
val charCount = textPage.textPageCountChars()
if (charCount <= 0) return@use
val rawText = textPage.textPageGetText(0, charCount) ?: return@use
val lines = rawText.split('\n').map { it.trim() }.filter { it.length > 2 }
if (lines.isNotEmpty()) {
val edgeLines = lines.take(2) + lines.takeLast(2)
for (line in edgeLines) {
frequency[line] = (frequency[line] ?: 0) + 1
}
}
}
}
} catch (e: Exception) {
Timber.tag(TAG).w(e, "Header/footer sampling failed for page $pageIdx")
}
}
frequency.filter { it.value >= 3 }.keys.toSet()
}
private fun String.escapeHtml(): String = this
.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
.replace("'", "&#39;")
}

View file

@ -1,273 +0,0 @@
// PdfToMarkdownGenerator.kt
package com.aryan.reader.pdf
import android.content.Context
import android.net.Uri
import io.legere.pdfiumandroid.PdfiumCore
import io.legere.pdfiumandroid.suspend.PdfiumCoreKt
import io.legere.pdfiumandroid.suspend.PdfDocumentKt
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.io.File
import kotlin.math.roundToInt
object PdfToMarkdownGenerator {
const val PAGE_DELIMITER = "\n\n[[PAGE_BREAK]]\n\n"
suspend fun generateMarkdownFile(
context: Context,
pdfUri: Uri,
destFile: File,
startPage: Int = 1,
onProgress: (Float) -> Unit
): Boolean = withContext(Dispatchers.IO) {
val methodStartTime = System.currentTimeMillis()
Timber.tag("PdfToMdPerf").d("generateMarkdownFile NATIVE START | uri=$pdfUri | startPage=$startPage")
val pdfiumCore = PdfiumCoreKt(Dispatchers.Default)
val pfd = context.contentResolver.openFileDescriptor(pdfUri, "r")
if (pfd == null) {
Timber.tag("PdfToMdPerf").e("Failed to open ParcelFileDescriptor")
return@withContext false
}
try {
val doc = pdfiumCore.newDocument(pfd)
val totalPages = doc.getPageCount()
Timber.tag("PdfToMdPerf").d("Document loaded natively. Total Pages: $totalPages")
destFile.bufferedWriter().use { writer ->
for (pageIdx in (startPage - 1) until totalPages) {
val pageMd = extractPageMarkdown(doc, pageIdx)
writer.write(pageMd)
writer.write(PAGE_DELIMITER)
if (pageIdx % 5 == 0 || pageIdx == totalPages - 1) {
onProgress((pageIdx + 1).toFloat() / totalPages.toFloat())
}
}
}
doc.close()
pfd.close()
Timber.tag("PdfToMdPerf").d("generateMarkdownFile NATIVE SUCCESS | totalTime=${System.currentTimeMillis() - methodStartTime}ms")
return@withContext true
} catch (e: Exception) {
Timber.e(e, "Failed to generate Markdown from PDF natively")
pfd.close()
return@withContext false
}
}
private suspend fun extractPageMarkdown(doc: PdfDocumentKt, pageIdx: Int): String {
return try {
doc.openPage(pageIdx).use { page ->
page.openTextPage().use { textPage ->
val charCount = textPage.textPageCountChars()
if (charCount <= 0) return@use ""
val text = textPage.textPageGetText(0, charCount) ?: ""
val actualCount = minOf(charCount, text.length)
val rawPtr = textPage.page.pagePtr
val sizes: FloatArray?
val weights: IntArray?
val flags: IntArray?
synchronized(PdfiumCore.lock) {
sizes = NativePdfiumBridge.getPageFontSizes(rawPtr, actualCount)
weights = NativePdfiumBridge.getPageFontWeights(rawPtr, actualCount)
flags = NativePdfiumBridge.getPageFontFlags(rawPtr, actualCount)
}
if (sizes == null || weights == null || flags == null) {
return@use text
}
buildMarkdown(text, sizes, weights, flags, actualCount)
}
}
} catch (e: Exception) {
Timber.w(e, "Error extracting page $pageIdx")
""
}
}
private data class TextSpan(
val text: String,
val size: Float,
val isBold: Boolean,
val isItalic: Boolean
)
private data class TextLine(
val spans: List<TextSpan>
)
private fun fixKerning(text: String): String {
val pattern = Regex("\\b(?:[A-Za-z0-9] ){2,}[A-Za-z0-9]\\b")
return pattern.replace(text) { matchResult ->
matchResult.value.replace(" ", "")
}
}
private fun buildMarkdown(text: String, sizes: FloatArray, weights: IntArray, flags: IntArray, count: Int): String {
if (count == 0) return ""
val sizeFrequency = HashMap<Int, Int>()
for (i in 0 until count) {
val s = sizes[i].roundToInt()
sizeFrequency[s] = (sizeFrequency[s] ?: 0) + 1
}
val baseSize = sizeFrequency.maxByOrNull { it.value }?.key ?: 12
val lines = mutableListOf<TextLine>()
@Suppress("CanBeVal") var currentSpans = mutableListOf<TextSpan>()
val currentSpanText = StringBuilder()
var currentSize = -1f
var currentBold = false
var currentItalic = false
for (i in 0 until count) {
val c = text[i]
if (c == '\u0000') continue
if (c == '\n' || c == '\r') {
if (c == '\n' && i > 0 && text[i - 1] == '\r') continue
if (currentSpanText.isNotEmpty()) {
currentSpans.add(TextSpan(currentSpanText.toString(), currentSize, currentBold, currentItalic))
currentSpanText.clear()
}
lines.add(TextLine(currentSpans.toList()))
currentSpans.clear()
continue
}
val isSpace = c.isWhitespace()
val size = sizes[i]
val bold = weights[i] > 600
val italic = (flags[i] and 64) != 0
if (currentSpanText.isEmpty()) {
currentSize = size
currentBold = bold
currentItalic = italic
currentSpanText.append(c)
} else {
if (!isSpace && (currentSize != size || currentBold != bold || currentItalic != italic)) {
currentSpans.add(TextSpan(currentSpanText.toString(), currentSize, currentBold, currentItalic))
currentSpanText.clear()
currentSize = size
currentBold = bold
currentItalic = italic
}
currentSpanText.append(c)
}
}
if (currentSpanText.isNotEmpty()) {
currentSpans.add(TextSpan(currentSpanText.toString(), currentSize, currentBold, currentItalic))
}
if (currentSpans.isNotEmpty()) {
lines.add(TextLine(currentSpans))
}
val validLines = lines.filter { it.spans.isNotEmpty() }
val lineLengths = validLines.map { line -> line.spans.sumOf { it.text.length } }.filter { it > 10 }.sorted()
val typicalLineLen = if (lineLengths.isNotEmpty()) {
lineLengths[(lineLengths.size * 0.8).toInt().coerceAtMost(lineLengths.size - 1)]
} else {
80
}
val wrapThreshold = (typicalLineLen * 0.85).toInt()
val sb = StringBuilder()
for (i in lines.indices) {
val line = lines[i]
if (line.spans.isEmpty()) {
sb.append("\n")
continue
}
val maxFontSize = line.spans.filter { it.text.isNotBlank() }.maxOfOrNull { it.size } ?: baseSize.toFloat()
val charBigHeader = maxFontSize > baseSize * 1.5f
val charHeader = maxFontSize > baseSize * 1.2f
var prefix = ""
if (charBigHeader) prefix = "## "
else if (charHeader) prefix = "### "
val rawLineText = line.spans.joinToString("") { it.text }
val trimmedRaw = rawLineText.trim()
val lineLen = trimmedRaw.length
val isList = trimmedRaw.startsWith("") ||
trimmedRaw.startsWith("- ") ||
trimmedRaw.startsWith("") ||
trimmedRaw.matches(Regex("^[0-9]+\\.\\s.*")) ||
trimmedRaw.matches(Regex("^[a-zA-Z]\\)\\s.*"))
if (prefix.isNotEmpty() && !isList) {
sb.append(prefix)
}
for (span in line.spans) {
var spanText = span.text
spanText = fixKerning(spanText)
val leadingSpaces = spanText.takeWhile { it.isWhitespace() }
val trailingSpaces = spanText.takeLastWhile { it.isWhitespace() }
val trimmedText = spanText.trim()
if (trimmedText.isEmpty()) {
sb.append(spanText)
continue
}
sb.append(leadingSpaces)
var tag = ""
if (span.isBold && span.isItalic) tag = "***"
else if (span.isBold) tag = "**"
else if (span.isItalic) tag = "*"
sb.append(tag).append(trimmedText).append(tag)
sb.append(trailingSpaces)
}
var isParagraphBreak = false
if (prefix.isNotEmpty() || isList) {
isParagraphBreak = true
} else if (lineLen < wrapThreshold) {
isParagraphBreak = true
} else if (trimmedRaw.matches(Regex(".*[.!?\"'”’;:*]$"))) {
isParagraphBreak = true
} else {
val nextLine = lines.subList(i + 1, lines.size).firstOrNull { it.spans.isNotEmpty() }
if (nextLine != null) {
val nextRaw = nextLine.spans.joinToString("") { it.text }.trimStart()
if (nextRaw.startsWith("\"") || nextRaw.startsWith("") || nextRaw.startsWith("-")) {
isParagraphBreak = true
}
}
}
if (isParagraphBreak) {
sb.append("\n\n")
} else {
sb.append("\n")
}
}
return sb.toString().replace(Regex("\\n{3,}"), "\n\n").trim()
}
}

View file

@ -18,7 +18,9 @@
* mail: epistemereader@gmail.com
*/
// PdfViewerScreen.kt
@file:Suppress("COMPOSE_APPLIER_CALL_MISMATCH", "Unused", "UnusedVariable")
@file:Suppress("COMPOSE_APPLIER_CALL_MISMATCH", "Unused", "UnusedVariable",
"SimplifyBooleanWithConstants"
)
package com.aryan.reader.pdf
@ -27,6 +29,11 @@ import android.annotation.SuppressLint
import android.app.Activity
import android.content.Context
import android.content.pm.PackageManager
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.compose.ui.platform.LocalLifecycleOwner
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import android.graphics.Bitmap
import android.graphics.RectF
import android.net.Uri
@ -221,6 +228,7 @@ import androidx.core.graphics.createBitmap
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.media3.common.util.UnstableApi
import androidx.paging.LoadState
import androidx.paging.compose.LazyPagingItems
@ -740,9 +748,9 @@ fun PdfViewerScreen(
val uiState by viewModel.uiState.collectAsState()
val reflowBookId = remember(bookId) { "${bookId}_reflow" }
val hasReflowFile by remember(uiState.recentFiles, reflowBookId) {
val hasReflowFile by remember(uiState.allRecentFiles, reflowBookId) {
derivedStateOf {
uiState.recentFiles.any { it.bookId == reflowBookId && !it.isDeleted }
uiState.allRecentFiles.any { it.bookId == reflowBookId && !it.isDeleted }
}
}
val originalFileName by remember(uiState.recentFiles, pdfUri) {
@ -1029,18 +1037,155 @@ fun PdfViewerScreen(
var areAnnotationsLoaded by remember { mutableStateOf(false) }
LaunchedEffect(allAnnotations) {
if (areAnnotationsLoaded && currentBookId != null) {
delay(1000)
withContext(Dispatchers.IO) {
Timber.d("Auto-saving annotations locally for book $currentBookId")
annotationRepository.saveAnnotations(currentBookId!!, allAnnotations)
val richTextRepository = remember(context) { PdfRichTextRepository(context) }
val richTextController = remember(currentBookId) {
if (currentBookId != null) RichTextController(
richTextRepository,
coroutineScope,
currentBookId!!
)
else null
}
var pdfDocument by remember { mutableStateOf<PdfDocumentKt?>(null) }
var pfdState by remember { mutableStateOf<ParcelFileDescriptor?>(null) }
var totalPages by remember { mutableIntStateOf(0) }
var currentPageScale by remember { mutableFloatStateOf(1f) }
val textBoxes = remember { mutableStateListOf<PdfTextBox>() }
var selectedTextBoxId by remember { mutableStateOf<String?>(null) }
val userHighlights = remember { mutableStateListOf<PdfUserHighlight>() }
val drawingState = remember { PdfDrawingState() }
val pdfiumCore = remember(context) { PdfiumCoreKt(Dispatchers.Default) }
val verticalReaderState = rememberVerticalPdfReaderState()
var virtualPages by remember { mutableStateOf<List<VirtualPage>>(emptyList()) }
val totalDisplayPages by remember(virtualPages, totalPages) {
derivedStateOf { if (virtualPages.isNotEmpty()) virtualPages.size else totalPages }
}
val pagerState = rememberPagerState(initialPage = 0, pageCount = { totalDisplayPages })
val currentPage by remember {
derivedStateOf {
when (displayMode) {
DisplayMode.PAGINATION -> pagerState.currentPage
DisplayMode.VERTICAL_SCROLL -> verticalReaderState.currentPage
}
}
}
val drawingState = remember { PdfDrawingState() }
val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current
val saveMutex = remember { Mutex() }
var initialScrollDone by remember { mutableStateOf(false) }
var isDocumentReady by remember { mutableStateOf(false) }
val lastSavedHashes = remember(currentBookId) { IntArray(5) { 0 } }
val currentAnnotations by rememberUpdatedState(allAnnotations)
val currentTextBoxes by rememberUpdatedState(textBoxes.toList())
val currentHighlights by rememberUpdatedState(userHighlights.toList())
val currentBookmarks by rememberUpdatedState(bookmarks)
val currentTotalPages by rememberUpdatedState(totalDisplayPages)
val currentPageState by rememberUpdatedState(currentPage)
val saveAllData = remember(currentBookId, annotationRepository, textBoxRepository, highlightRepository) {
{ force: Boolean ->
coroutineScope.launch {
val bookId = currentBookId ?: return@launch
val annots = currentAnnotations
val boxes = currentTextBoxes
val highlights = currentHighlights
val bms = currentBookmarks
val page = currentPageState
val totalPgs = currentTotalPages
val annotsHash = annots.hashCode()
val boxesHash = boxes.hashCode()
val highlightsHash = highlights.hashCode()
val bmsHash = bms.hashCode()
// Protect the lock and I/O execution with NonCancellable
withContext(NonCancellable) {
saveMutex.withLock {
withContext(Dispatchers.IO) {
var didSave = false
if (force || annotsHash != lastSavedHashes[0]) {
annotationRepository.saveAnnotations(bookId, annots)
lastSavedHashes[0] = annotsHash
didSave = true
}
if (force || boxesHash != lastSavedHashes[1]) {
textBoxRepository.saveTextBoxes(bookId, boxes)
lastSavedHashes[1] = boxesHash
didSave = true
}
if (force || highlightsHash != lastSavedHashes[2]) {
highlightRepository.saveHighlights(bookId, highlights)
lastSavedHashes[2] = highlightsHash
didSave = true
}
if (force || bmsHash != lastSavedHashes[3]) {
val objectList = bms.map { bookmark ->
JSONObject().apply {
put("pageIndex", bookmark.pageIndex)
put("title", bookmark.title)
put("totalPages", bookmark.totalPages)
}
}
val bookmarksJson = JSONArray(objectList).toString()
withContext(Dispatchers.Main) {
onBookmarksChanged(bookmarksJson)
}
lastSavedHashes[3] = bmsHash
didSave = true
}
if (force || page != lastSavedHashes[4]) {
if (totalPgs > 0) {
withContext(Dispatchers.Main) {
onSavePosition(page, totalPgs)
}
}
lastSavedHashes[4] = page
}
if (didSave) {
Timber.tag("PdfSavePerf").d("Saved data for book $bookId")
}
}
}
}
}
}
}
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_PAUSE || event == Lifecycle.Event.ON_STOP) {
Timber.tag("PdfSavePerf").i("Lifecycle $event triggered, forcing save.")
coroutineScope.launch {
if (richTextController != null) {
withContext(NonCancellable) { richTextController.saveImmediate() }
}
saveAllData(true).join()
}
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
LaunchedEffect(
allAnnotations,
textBoxes.toList(),
userHighlights.toList(),
bookmarks,
currentPage
) {
if (areAnnotationsLoaded && currentBookId != null && initialScrollDone) {
delay(2000) // Debounce period
saveAllData(false)
}
}
val allAnnotationsProvider = remember { { allAnnotations } }
@ -1049,19 +1194,6 @@ fun PdfViewerScreen(
Timber.d("PdfViewerScreen init: Loaded ${bookmarks.size} bookmarks initially.")
}
LaunchedEffect(bookmarks) {
val objectList = bookmarks.map { bookmark ->
JSONObject().apply {
put("pageIndex", bookmark.pageIndex)
put("title", bookmark.title)
put("totalPages", bookmark.totalPages)
}
}
val bookmarksJson = JSONArray(objectList).toString()
Timber.d("Bookmarks changed. Firing onBookmarksChanged with JSON: $bookmarksJson")
onBookmarksChanged(bookmarksJson)
}
var flatTableOfContents by remember { mutableStateOf<List<TocEntry>>(emptyList()) }
var showDictionaryUpsellDialog by remember { mutableStateOf(false) }
var showSummarizationUpsellDialog by remember { mutableStateOf(false) }
@ -1080,17 +1212,8 @@ fun PdfViewerScreen(
var errorMessage by remember { mutableStateOf<String?>(null) }
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 virtualPages by remember { mutableStateOf<List<VirtualPage>>(emptyList()) }
val displayPageRatios by remember(pageAspectRatios, virtualPages) {
derivedStateOf {
if (virtualPages.isEmpty()) {
@ -1108,16 +1231,6 @@ fun PdfViewerScreen(
}
}
val richTextRepository = remember(context) { PdfRichTextRepository(context) }
val richTextController = remember(currentBookId) {
if (currentBookId != null) RichTextController(
richTextRepository,
coroutineScope,
currentBookId!!
)
else null
}
LaunchedEffect(richTextController, toolSettings.textStyle) {
richTextController?.let { controller ->
val config = toolSettings.textStyle
@ -1142,13 +1255,6 @@ fun PdfViewerScreen(
}
}
val pdfiumCore = remember(context) { PdfiumCoreKt(Dispatchers.Default) }
val verticalReaderState = rememberVerticalPdfReaderState()
val totalDisplayPages by remember(virtualPages, totalPages) {
derivedStateOf { if (virtualPages.isNotEmpty()) virtualPages.size else totalPages }
}
val pagerState = rememberPagerState(initialPage = 0, pageCount = { totalDisplayPages })
LaunchedEffect(currentBookId) {
if (currentBookId != null) richTextRepository.load(currentBookId!!)
}
@ -1170,21 +1276,8 @@ fun PdfViewerScreen(
}
}
val currentPage by remember {
derivedStateOf {
when (displayMode) {
DisplayMode.PAGINATION -> pagerState.currentPage
DisplayMode.VERTICAL_SCROLL -> verticalReaderState.currentPage
}
}
}
Timber.d("Derived currentPage recomposed. New value: $currentPage (Mode: $displayMode)")
val textBoxes = remember { mutableStateListOf<PdfTextBox>() }
var selectedTextBoxId by remember { mutableStateOf<String?>(null) }
val userHighlights = remember { mutableStateListOf<PdfUserHighlight>() }
val onHighlightAdd = remember(pdfDocument, currentBookId) {
{ pageIndex: Int, range: Pair<Int, Int>, text: String, color: PdfHighlightColor ->
Timber.tag("PdfExportDebug").i("onHighlightAdd: Adding persistent highlight. Page: $pageIndex, Text: ${text.take(20)}...")
@ -1695,16 +1788,6 @@ fun PdfViewerScreen(
Timber.d("Pager state changed: pagerState.currentPage is now ${pagerState.currentPage}")
}
LaunchedEffect(currentPage, totalPages) {
if (totalPages > 0 && initialScrollDone) {
delay(500L)
Timber.d(
"Debounced save: Calling onSavePosition(page=$currentPage, totalPages=$totalPages)"
)
onSavePosition(currentPage, totalPages)
}
}
LaunchedEffect(displayMode) {
coroutineScope.launch {
if (displayMode == DisplayMode.VERTICAL_SCROLL) {
@ -1861,26 +1944,6 @@ fun PdfViewerScreen(
}
}
LaunchedEffect(textBoxes.toList()) {
if (currentBookId != null) {
delay(1000)
withContext(Dispatchers.IO) {
Timber.d("Auto-saving text boxes locally for book $currentBookId")
textBoxRepository.saveTextBoxes(currentBookId!!, textBoxes.toList())
}
}
}
LaunchedEffect(userHighlights.toList()) {
if (currentBookId != null) {
delay(1000)
withContext(Dispatchers.IO) {
Timber.d("Auto-saving highlights locally for book $currentBookId")
highlightRepository.saveHighlights(currentBookId!!, userHighlights.toList())
}
}
}
var pendingSaveMode by remember { mutableStateOf<SaveMode?>(null) }
val saveLauncher = rememberLauncherForActivityResult(
@ -1970,33 +2033,15 @@ fun PdfViewerScreen(
ttsController.stop()
coroutineScope.launch {
withContext(NonCancellable) {
if (richTextController != null) {
if (richTextController != null) {
withContext(NonCancellable) {
Timber.tag("RichTextFlow").d("Forcing RichTextController immediate sync and save...")
richTextController.saveImmediate()
}
if (totalDisplayPages > 0) {
onSavePosition(currentPage, totalDisplayPages)
if (currentBookId != null) {
annotationRepository.saveAnnotations(currentBookId!!, allAnnotations)
textBoxRepository.saveTextBoxes(currentBookId!!, textBoxes.toList())
highlightRepository.saveHighlights(currentBookId!!, userHighlights.toList())
}
val objectList = bookmarks.map { bookmark ->
JSONObject().apply {
put("pageIndex", bookmark.pageIndex)
put("title", bookmark.title)
put("totalPages", bookmark.totalPages)
}
}
val bookmarksJson = JSONArray(objectList).toString()
onBookmarksChanged(bookmarksJson)
}
}
saveAllData(true).join()
Timber.tag("AnnotationSync").d("Save complete. Navigating back.")
onNavigateBack()
}
@ -4954,18 +4999,33 @@ fun PdfViewerScreen(
enabled = pdfDocument != null && !isReflowingThisBook,
onClick = {
showMoreMenu = false
if (hasReflowFile) {
val item = uiState.recentFiles.find { it.bookId == reflowBookId }
if (item != null) {
viewModel.switchToFileSeamlessly(item, currentPage)
coroutineScope.launch {
if (richTextController != null) {
withContext(NonCancellable) { richTextController.saveImmediate() }
}
saveAllData(true).join()
if (hasReflowFile) {
val item = uiState.allRecentFiles.find { it.bookId == reflowBookId }
if (item != null) {
viewModel.switchToFileSeamlessly(item, currentPage)
} else {
viewModel.generateAndImportReflowFile(
pdfBookId = bookId,
pdfUri = pdfUri,
originalTitle = originalFileName,
autoOpenPage = currentPage
)
}
} else {
viewModel.generateAndImportReflowFile(
pdfBookId = bookId,
pdfUri = pdfUri,
originalTitle = originalFileName,
autoOpenPage = currentPage
)
}
} else {
viewModel.generateAndImportReflowFile(
pdfBookId = bookId,
pdfUri = pdfUri,
originalTitle = originalFileName,
autoOpenPage = currentPage
)
}
},
leadingIcon = {

View file

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