From ef1bfdc57a91a2d9d391204d7a6967ed8489c727 Mon Sep 17 00:00:00 2001 From: Aryan Date: Mon, 9 Mar 2026 17:37:44 +0530 Subject: [PATCH] Pdf text highlight (#49) * feat(pdf): implement native text highlighting and interactive selection menu - Add `PdfUserHighlight` data model to track page index, character ranges, and normalized coordinates. - Redesign `PdfSelectionMenuPopup` to match the EPUB reader style, featuring a color palette row and a deletion option. - Integrate highlight rendering into the `PdfPageSelectionsLayer` using Canvas drawing. - Add hit-testing to `detectTapGestures` to allow users to tap existing highlights to change colors or remove them. * Implemented PDF highlight persistence, sync, and exporting. * Improved PDF highlight exporting and visibility logic - Fixed PDF highlight rendering by correctly calculating coordinates and merging adjacent rectangles into lines. - Updated `PdfExporter` to include highlights in the exported document and added debug logging. - Corrected visibility check in `PdfPageComposable` to account for vertical offsets. - Enhanced `PdfViewerScreen` highlight creation to use merged line rectangles * Refactored annotation synchronization logic and improved background sync reliability. - Refactored JSON bundle creation and extraction into reusable helper functions. - Improved stale file detection by checking all annotation types (ink, text, layout, highlights, text boxes). - Added background annotation downloading to `SyncWorker` - Implemented duplicate file cleanup in `GoogleDriveRepository` during uploads. - Added a `showFeedback` flag to `syncFolderMetadata` to control UI visibility during background operations. - Fixed a potential data loss edge case by aborting Firestore sync if the bundle upload fails. * Improved highlight interaction and management in PDF viewer - Implemented hit tolerance for highlight selection to improve touch detection accuracy. - Added logic to shift highlight page indices when adding or deleting pages. - Included highlights in the auto-pruning check for empty pages. - Added a "Highlights" tab to the navigation drawer to list, navigate to, and delete user highlights. --- LICENSE | 44 ++- README.md | 8 + .../java/com/aryan/reader/MainViewModel.kt | 188 ++++++------- .../reader/data/RecentFilesRepository.kt | 19 +- .../java/com/aryan/reader/pdf/PdfExporter.kt | 50 +++- .../java/com/aryan/reader/pdf/PdfHelper.kt | 227 +++++++++++++-- .../com/aryan/reader/pdf/PdfPageComposable.kt | 154 +++++++++- .../com/aryan/reader/pdf/PdfVerticalReader.kt | 10 +- .../com/aryan/reader/pdf/PdfViewerScreen.kt | 263 +++++++++++++++++- .../reader/pdf/data/PdfAnnotationData.kt | 66 +++++ .../reader/pdf/data/PdfHighlightRepository.kt | 55 ++++ 11 files changed, 921 insertions(+), 163 deletions(-) create mode 100644 app/src/main/java/com/aryan/reader/pdf/data/PdfHighlightRepository.kt diff --git a/LICENSE b/LICENSE index f4aa842..0ad25db 100644 --- a/LICENSE +++ b/LICENSE @@ -616,4 +616,46 @@ an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. - END OF TERMS AND CONDITIONS \ No newline at end of file + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/README.md b/README.md index ab121db..e115a01 100644 --- a/README.md +++ b/README.md @@ -80,3 +80,11 @@ This project is made possible by the Android open-source ecosystem: ## License This project is licensed under the **GNU Affero General Public License v3.0 (AGPL-3.0)**. See the [LICENSE](LICENSE) file for details. + +## Support + +If you find Episteme Reader useful and want to support its development, consider sponsoring. + + + Sponsor on GitHub + diff --git a/app/src/main/java/com/aryan/reader/MainViewModel.kt b/app/src/main/java/com/aryan/reader/MainViewModel.kt index 32c4762..0a39698 100644 --- a/app/src/main/java/com/aryan/reader/MainViewModel.kt +++ b/app/src/main/java/com/aryan/reader/MainViewModel.kt @@ -69,10 +69,12 @@ import com.aryan.reader.paginatedreader.data.BookCacheDatabase import com.aryan.reader.paginatedreader.data.BookProcessingWorker import com.aryan.reader.pdf.PdfCoverGenerator import com.aryan.reader.pdf.PdfExporter +import com.aryan.reader.pdf.PdfUserHighlight import com.aryan.reader.pdf.ReflowWorker import com.aryan.reader.pdf.data.PageLayoutRepository import com.aryan.reader.pdf.data.PdfAnnotation import com.aryan.reader.pdf.data.PdfAnnotationRepository +import com.aryan.reader.pdf.data.PdfHighlightRepository import com.aryan.reader.pdf.data.PdfTextBox import com.aryan.reader.pdf.data.PdfTextBoxRepository import com.aryan.reader.pdf.data.PdfTextRepository @@ -232,6 +234,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio private val pageLayoutRepository = PageLayoutRepository(appContext) private val pdfRichTextRepository = com.aryan.reader.pdf.PdfRichTextRepository(appContext) private val pdfTextBoxRepository = PdfTextBoxRepository(appContext) + private val pdfHighlightRepository = PdfHighlightRepository(appContext) data class PageModificationResult( val layout: List, @@ -918,6 +921,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio annotations: Map>, richTextPageLayouts: List? = null, textBoxes: List? = null, + highlights: List? = null, bookId: String ) { viewModelScope.launch { @@ -929,13 +933,14 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio val outputStream = appContext.contentResolver.openOutputStream(destUri) if (outputStream != null) { PdfExporter.exportAnnotatedPdf( - appContext, - sourceUri, - outputStream, - virtualPages, - annotations, - richTextPageLayouts, - textBoxes + context = appContext, + sourceUri = sourceUri, + destStream = outputStream, + virtualPages = virtualPages, + inkAnnotations = annotations, + richTextPageLayouts = richTextPageLayouts, + textBoxes = textBoxes, + highlights = highlights ) showBanner("PDF saved successfully.") } else { @@ -978,6 +983,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio annotations: Map>, richTextPageLayouts: List? = null, textBoxes: List? = null, + highlights: List? = null, includeAnnotations: Boolean, filename: String, bookId: String? = null @@ -1009,13 +1015,14 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio val virtualPages = pageLayoutRepository.getLayoutOrNull(resolvedBookId) PdfExporter.exportAnnotatedPdf( - appContext, - sourceUri, - outputStream, - virtualPages, - annotations, - richTextPageLayouts, - textBoxes + context = appContext, + sourceUri = sourceUri, + destStream = outputStream, + virtualPages = virtualPages, + inkAnnotations = annotations, + richTextPageLayouts = richTextPageLayouts, + textBoxes = textBoxes, + highlights = highlights ) } else { appContext.contentResolver.openInputStream(sourceUri)?.use { input -> @@ -1068,18 +1075,18 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio val deviceId = getInstallationId() Timber.tag("AnnotationSync").d("Preparing to sync book: ${book.bookId}") - // --- CHANGED BLOCK START --- - // Gather files from all three repositories val inkFile = pdfAnnotationRepository.getAnnotationFileForSync(book.bookId) val richTextFile = pdfRichTextRepository.getFileForSync(book.bookId) val layoutFile = pageLayoutRepository.getLayoutFile(book.bookId) val textBoxFile = pdfTextBoxRepository.getFileForSync(book.bookId) + val highlightFile = pdfHighlightRepository.getFileForSync(book.bookId) val hasInk = inkFile?.exists() == true val hasRichText = richTextFile.exists() val hasLayout = layoutFile.exists() val hasTextBoxes = textBoxFile.exists() - val hasAnyData = hasInk || hasRichText || hasLayout || hasTextBoxes + val hasHighlights = highlightFile.exists() + val hasAnyData = hasInk || hasRichText || hasLayout || hasTextBoxes || hasHighlights if (hasAnyData) { if (googleDriveRepository.hasDrivePermissions(appContext)) { @@ -1089,42 +1096,25 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio val bundleJson = JSONObject() bundleJson.put("version", 2) - if (hasInk) { + fun putJsonSafe(key: String, file: File?) { + if (file == null || !file.exists()) return try { - val inkContent = inkFile.readText() - val jsonArray = JSONArray(inkContent) - bundleJson.put("ink", jsonArray) + val content = file.readText().trim() + if (content.startsWith("[")) { + bundleJson.put(key, JSONArray(content)) + } else if (content.startsWith("{")) { + bundleJson.put(key, JSONObject(content)) + } } catch (e: Exception) { - Timber.e(e, "Failed to parse local ink file") + Timber.e(e, "Failed to parse local $key file") } } - if (hasRichText) { - try { - val textContent = richTextFile.readText() - bundleJson.put("text", JSONArray(textContent)) - } catch (e: Exception) { - Timber.e(e) - } - } - - if (hasLayout) { - try { - val layoutContent = layoutFile.readText() - bundleJson.put("layout", JSONArray(layoutContent)) - } catch (e: Exception) { - Timber.e(e) - } - } - - if (hasTextBoxes) { - try { - val tbContent = textBoxFile.readText() - bundleJson.put("textBoxes", JSONArray(tbContent)) - } catch (e: Exception) { - Timber.e(e, "Failed to parse local text box file") - } - } + if (hasInk) putJsonSafe("ink", inkFile) + if (hasRichText) putJsonSafe("text", richTextFile) + if (hasLayout) putJsonSafe("layout", layoutFile) + if (hasTextBoxes) putJsonSafe("textBoxes", textBoxFile) + if (hasHighlights) putJsonSafe("highlights", highlightFile) val bundleFile = File(appContext.cacheDir, "sync_bundle_${book.bookId}.json") @@ -1139,7 +1129,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio Timber.tag("AnnotationSync") .d("Bundle upload SUCCESS. ID: ${uploaded.id}") } else { - Timber.tag("AnnotationSync").e("Bundle upload FAILED.") + Timber.tag("AnnotationSync").e("Bundle upload FAILED. Skipping Firestore sync to prevent data loss.") + return@launch } } } @@ -1148,12 +1139,14 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio .d("No local data (ink/text/layout) to upload for ${book.bookId}") } + val newTimestamp = System.currentTimeMillis() val metadataToSync = book.toBookMetadata().copy( - lastModifiedTimestamp = System.currentTimeMillis(), + lastModifiedTimestamp = newTimestamp, hasAnnotations = hasAnyData ) firestoreRepository.syncBookMetadata(currentUser.uid, metadataToSync, deviceId) + recentFilesRepository.addRecentFile(book.copy(lastModifiedTimestamp = newTimestamp)) Timber.tag("AnnotationSync") .d("Firestore metadata updated for ${book.bookId} (hasData=$hasAnyData)") } catch (e: Exception) { @@ -1410,19 +1403,19 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } } - fun syncFolderMetadata() { - triggerFolderSyncWorker(metadataOnly = true) + fun syncFolderMetadata(showFeedback: Boolean = false) { + triggerFolderSyncWorker(metadataOnly = true, showFeedback = showFeedback) } fun scanSyncedFolder() { - triggerFolderSyncWorker(metadataOnly = false) + triggerFolderSyncWorker(metadataOnly = false, showFeedback = true) } - private fun triggerFolderSyncWorker(metadataOnly: Boolean) { + private fun triggerFolderSyncWorker(metadataOnly: Boolean, showFeedback: Boolean) { val folders = _internalState.value.syncedFolders if (folders.isEmpty()) return - Timber.tag("FolderSync").d("Requesting folder sync for ${folders.size} folders (metadataOnly=$metadataOnly)") + Timber.tag("FolderSync").d("Requesting folder sync for ${folders.size} folders (metadataOnly=$metadataOnly, feedback=$showFeedback)") val workManager = WorkManager.getInstance(appContext) val data = androidx.work.Data.Builder() @@ -1444,18 +1437,20 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio if (workInfo != null) { when (workInfo.state) { WorkInfo.State.RUNNING, WorkInfo.State.ENQUEUED -> { - val msg = if (metadataOnly) "Folder Sync: Updating metadata..." else "Scanning folder for new books..." - _internalState.update { it.copy( - isLoading = false, - isRefreshing = true, - bannerMessage = BannerMessage(msg) - ) } + if (showFeedback) { + val msg = if (metadataOnly) "Folder Sync: Updating metadata..." else "Scanning folder for new books..." + _internalState.update { it.copy( + isLoading = false, + isRefreshing = true, + bannerMessage = BannerMessage(msg) + ) } + } } WorkInfo.State.SUCCEEDED -> { _internalState.update { it.copy( isLoading = false, isRefreshing = false, - bannerMessage = BannerMessage("Folder Sync: Scan complete."), + bannerMessage = if (showFeedback) BannerMessage("Folder Sync: Scan complete.") else it.bannerMessage, lastFolderScanTime = System.currentTimeMillis() ) } } @@ -1463,7 +1458,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio _internalState.update { it.copy( isLoading = false, isRefreshing = false, - errorMessage = "Sync failed." + errorMessage = if (showFeedback) "Sync failed." else it.errorMessage ) } } else -> Unit @@ -1917,12 +1912,23 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio ) } - val annotationFile = - pdfAnnotationRepository.getAnnotationFileForSync(bookId) - val localFileMissing = annotationFile == null - val fileLastModified = annotationFile?.lastModified() ?: 0L - val isFileStale = - remote.hasAnnotations && (remote.lastModifiedTimestamp > fileLastModified) + val inkFile = pdfAnnotationRepository.getAnnotationFileForSync(bookId) + val richTextFile = pdfRichTextRepository.getFileForSync(bookId) + val layoutFile = pageLayoutRepository.getLayoutFile(bookId) + val textBoxFile = pdfTextBoxRepository.getFileForSync(bookId) + val highlightFile = pdfHighlightRepository.getFileForSync(bookId) + + val anyLocalFileExists = (inkFile?.exists() == true) || richTextFile.exists() || layoutFile.exists() || textBoxFile.exists() || highlightFile.exists() + val localFileMissing = !anyLocalFileExists + + val fileLastModified = maxOf( + inkFile?.lastModified() ?: 0L, + richTextFile.lastModified(), + layoutFile.lastModified(), + textBoxFile.lastModified(), + highlightFile.lastModified() + ) + val isFileStale = remote.hasAnnotations && (remote.lastModifiedTimestamp > fileLastModified) if (isMetadataNewer || localFileMissing && remote.hasAnnotations || isFileStale) { Timber.tag("AnnotationSync").d("Triggering download for $bookId.") @@ -2091,43 +2097,31 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio val richTextFile = pdfRichTextRepository.getFileForSync(bookId) val layoutFile = pageLayoutRepository.getLayoutFile(bookId) val textBoxFile = pdfTextBoxRepository.getFileForSync(bookId) + val highlightFile = pdfHighlightRepository.getFileForSync(bookId) - // Ensure directories exist inkFile.parentFile?.mkdirs() richTextFile.parentFile?.mkdirs() layoutFile.parentFile?.mkdirs() + textBoxFile.parentFile?.mkdirs() + highlightFile.parentFile?.mkdirs() if (isBundle) { val bundle = JSONObject(jsonString) - // 1. Ink - if (bundle.has("ink")) { - inkFile.writeText(bundle.getJSONArray("ink").toString()) - } else { - // If bundle exists but no ink key, implies ink was deleted or empty - if (inkFile.exists()) inkFile.delete() + fun writeSafe(key: String, file: File) { + if (bundle.has(key)) { + file.parentFile?.mkdirs() + file.writeText(bundle.get(key).toString()) + } else { + if (file.exists()) file.delete() + } } - // 2. Text - if (bundle.has("text")) { - richTextFile.writeText(bundle.getJSONArray("text").toString()) - } else { - if (richTextFile.exists()) richTextFile.delete() - } - - // 3. Layout - if (bundle.has("layout")) { - layoutFile.writeText(bundle.getJSONArray("layout").toString()) - } else { - if (layoutFile.exists()) layoutFile.delete() - } - - // 4. Text Boxes - if (bundle.has("textBoxes")) { - textBoxFile.writeText(bundle.getJSONArray("textBoxes").toString()) - } else { - if (textBoxFile.exists()) textBoxFile.delete() - } + writeSafe("ink", inkFile) + writeSafe("text", richTextFile) + writeSafe("layout", layoutFile) + writeSafe("textBoxes", textBoxFile) + writeSafe("highlights", highlightFile) Timber.tag("AnnotationSync").d("Unpacked unified bundle.") } else { @@ -2751,7 +2745,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } if (hasFolder) { - syncFolderMetadata() + syncFolderMetadata(showFeedback = true) } } catch (e: Exception) { Timber.e(e, "Refresh failed") diff --git a/app/src/main/java/com/aryan/reader/data/RecentFilesRepository.kt b/app/src/main/java/com/aryan/reader/data/RecentFilesRepository.kt index e4a4423..b7a24cc 100644 --- a/app/src/main/java/com/aryan/reader/data/RecentFilesRepository.kt +++ b/app/src/main/java/com/aryan/reader/data/RecentFilesRepository.kt @@ -52,6 +52,7 @@ class RecentFilesRepository(private val context: Context) { private val pdfRichTextRepository = PdfRichTextRepository(context) private val pageLayoutRepository = PageLayoutRepository(context) private val pdfTextBoxRepository = PdfTextBoxRepository(context) + private val pdfHighlightRepository = com.aryan.reader.pdf.data.PdfHighlightRepository(context) init { if (!coverCacheDir.exists()) { @@ -87,6 +88,7 @@ class RecentFilesRepository(private val context: Context) { coverCacheDir.deleteRecursively() } coverCacheDir.mkdirs() + pdfHighlightRepository.clearAll() Timber.d("Cleared all local book data and cover cache.") } @@ -182,15 +184,17 @@ class RecentFilesRepository(private val context: Context) { val richTextFile = pdfRichTextRepository.getFileForSync(bookId) val layoutFile = pageLayoutRepository.getLayoutFile(bookId) val textBoxFile = pdfTextBoxRepository.getFileForSync(bookId) + val highlightFile = pdfHighlightRepository.getFileForSync(bookId) val hasInk = inkFile?.exists() == true val hasRichText = richTextFile.exists() val hasLayout = layoutFile.exists() val hasTextBoxes = textBoxFile.exists() + val hasHighlights = highlightFile.exists() - Timber.tag("FolderAnnotationSync").d("File checks -> hasInk: $hasInk, hasRichText: $hasRichText, hasLayout: $hasLayout, hasTextBoxes: $hasTextBoxes") + Timber.tag("FolderAnnotationSync").d("File checks -> hasInk: $hasInk, hasRichText: $hasRichText, hasLayout: $hasLayout, hasTextBoxes: $hasTextBoxes, hasHighlights: $hasHighlights") - if (!hasInk && !hasRichText && !hasLayout && !hasTextBoxes) { + if (!hasInk && !hasRichText && !hasLayout && !hasTextBoxes && !hasHighlights) { Timber.tag("FolderAnnotationSync").d("No annotations found locally for bookId: $bookId. Aborting sync.") return@withContext } @@ -214,13 +218,15 @@ class RecentFilesRepository(private val context: Context) { if (hasRichText) putJsonSafe("text", richTextFile) if (hasLayout) putJsonSafe("layout", layoutFile) if (hasTextBoxes) putJsonSafe("textBoxes", textBoxFile) + if (hasHighlights) putJsonSafe("highlights", highlightFile) val tsInk = if(hasInk) inkFile.lastModified() else 0L val tsText = if(hasRichText) richTextFile.lastModified() else 0L val tsLayout = if(hasLayout) layoutFile.lastModified() else 0L val tsBox = if(hasTextBoxes) textBoxFile.lastModified() else 0L + val tsHighlight = if(hasHighlights) highlightFile.lastModified() else 0L - val maxFileTs = maxOf(tsInk, tsText, tsLayout, tsBox) + val maxFileTs = maxOf(tsInk, tsText, tsLayout, tsBox, tsHighlight) val finalTs = maxOf(maxFileTs, System.currentTimeMillis()) Timber.tag("FolderAnnotationSync").d("Pushing annotation bundle for $bookId to folder. finalTs=$finalTs") @@ -263,6 +269,9 @@ class RecentFilesRepository(private val context: Context) { // 4. Text Boxes writeSafe("textBoxes", pdfTextBoxRepository.getFileForSync(bookId)) + // 5. Highlights + writeSafe("highlights", pdfHighlightRepository.getFileForSync(bookId)) + Timber.tag("FolderAnnotationSync").i("Successfully imported annotation bundle for $bookId from folder.") } catch (e: Exception) { Timber.tag("FolderAnnotationSync").e(e, "Failed to import annotation bundle for $bookId") @@ -294,10 +303,6 @@ class RecentFilesRepository(private val context: Context) { return@withContext recentFileDao.getFolderBooksWithoutCovers().map { it.toRecentFileItem() } } - suspend fun updateReflowPreference(bookId: String, isPreferred: Boolean) = withContext(Dispatchers.IO) { - recentFileDao.updateReflowPreference(bookId, isPreferred) - } - suspend fun detachAllFolderBooks() = withContext(Dispatchers.IO) { recentFileDao.detachAllFolderBooks() Timber.d("Detached all folder books. They are now standard local files.") diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfExporter.kt b/app/src/main/java/com/aryan/reader/pdf/PdfExporter.kt index 8ceac7f..d974489 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfExporter.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfExporter.kt @@ -155,7 +155,8 @@ object PdfExporter { virtualPages: List?, inkAnnotations: Map>, richTextPageLayouts: List? = null, - textBoxes: List? = null + textBoxes: List? = null, + highlights: List? = null ) { withContext(Dispatchers.IO) { var sourceDocument: PDDocument? = null @@ -176,6 +177,8 @@ object PdfExporter { if (sourceDocument.numberOfPages > 0) sourceDocument.getPage(0) else null val fontCache = PdfBoxFontCache(destDocument, context) + Timber.tag("PdfExportDebug").i("Starting export. Total highlights received: ${highlights?.size ?: 0}") + pagesToProcess.forEachIndexed { virtualIndex, vPage -> val pageToDecorate: PDPage = when (vPage) { @@ -210,6 +213,15 @@ object PdfExporter { val pageHeight = cropBox.height val lowerLeftY = cropBox.lowerLeftY + val pageHighlights = highlights?.filter { it.pageIndex == virtualIndex } + Timber.tag("PdfExportDebug").d("Page $virtualIndex: Found ${pageHighlights?.size ?: 0} highlights to draw.") + + if (!pageHighlights.isNullOrEmpty()) { + PDPageContentStream(destDocument, pageToDecorate, PDPageContentStream.AppendMode.APPEND, true, true).use { cs -> + drawHighlights(cs, pageHighlights) + } + } + if (pageInkAnnos.isNotEmpty()) { val (pencilAnnos, vectorAnnos) = pageInkAnnos.partition { it.inkType == InkType.PENCIL } @@ -270,8 +282,9 @@ object PdfExporter { } } destDocument.save(destStream) + Timber.tag("PdfExportDebug").i("Export document saved successfully.") } catch (e: Exception) { - Timber.e(e, "Export failed") + Timber.tag("PdfExportDebug").e(e, "Export failed during processing") throw e } finally { sourceDocument?.close() @@ -461,6 +474,39 @@ object PdfExporter { } } + private fun drawHighlights( + cs: PDPageContentStream, + highlights: List + ) { + val gs = PDExtendedGraphicsState() + gs.blendMode = BlendMode.MULTIPLY + gs.nonStrokingAlphaConstant = 0.4f + cs.setGraphicsStateParameters(gs) + + for (highlight in highlights) { + val r = highlight.color.color.red + val g = highlight.color.color.green + val b = highlight.color.color.blue + cs.setNonStrokingColor(r, g, b) + + for (rect in highlight.bounds) { + val x = minOf(rect.left, rect.right) + val y = minOf(rect.top, rect.bottom) + val w = kotlin.math.abs(rect.right - rect.left) + val h = kotlin.math.abs(rect.top - rect.bottom) + + cs.addRect(x, y, w, h) + cs.fill() + } + } + + // Reset graphics state + val resetState = PDExtendedGraphicsState() + resetState.blendMode = BlendMode.NORMAL + resetState.nonStrokingAlphaConstant = 1.0f + cs.setGraphicsStateParameters(resetState) + } + private fun drawPencilOverlay( document: PDDocument, page: PDPage, diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfHelper.kt b/app/src/main/java/com/aryan/reader/pdf/PdfHelper.kt index 63039a4..b396c3f 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfHelper.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfHelper.kt @@ -21,34 +21,48 @@ package com.aryan.reader.pdf import android.graphics.Bitmap import android.graphics.Rect -import android.os.Handler -import android.os.Looper -import timber.log.Timber +import android.graphics.RectF import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CopyAll +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Popup import androidx.compose.ui.window.PopupPositionProvider import androidx.compose.ui.window.PopupProperties -import com.aryan.reader.countWords -import io.legere.pdfiumandroid.suspend.PdfTextPageKt -import kotlinx.coroutines.suspendCancellableCoroutine -import kotlin.coroutines.resume -import kotlin.coroutines.resumeWithException import com.aryan.reader.OcrEngine +import com.aryan.reader.R import com.aryan.reader.pdf.ocr.OcrElement import com.aryan.reader.pdf.ocr.OcrLine import com.aryan.reader.pdf.ocr.OcrResult import com.aryan.reader.pdf.ocr.OcrSymbol +import io.legere.pdfiumandroid.suspend.PdfTextPageKt +import timber.log.Timber +import java.util.UUID enum class OcrLanguage(val displayName: String) { LATIN("English, Spanish, French, etc."), @@ -64,10 +78,28 @@ internal data class OcrSymbolInfo( val parentLine: OcrLine ) +enum class PdfHighlightColor(val color: Color) { + YELLOW(Color(0xFFFBC02D)), + GREEN(Color(0xFF388E3C)), + BLUE(Color(0xFF1976D2)), + RED(Color(0xFFD32F2F)); +} + +data class PdfUserHighlight( + val id: String = UUID.randomUUID().toString(), + val pageIndex: Int, + val bounds: List, + val color: PdfHighlightColor, + val text: String, + val range: Pair +) + internal data class CustomPdfMenuState( val selectedText: String, val anchorRect: Rect, - val charRange: Pair + val charRange: Pair, + val isExistingHighlight: Boolean = false, + val highlightId: String? = null ) internal enum class PdfSelectionMethod { @@ -129,7 +161,9 @@ internal fun PdfSelectionMenuPopup( popupPositionProvider: PopupPositionProvider, onCopy: (String) -> Unit, onAiDefine: (String) -> Unit, - onSelectAll: () -> Unit + onSelectAll: () -> Unit, + onColorSelected: (PdfHighlightColor) -> Unit, + onDelete: () -> Unit ) { Popup( popupPositionProvider = popupPositionProvider, @@ -141,27 +175,117 @@ internal fun PdfSelectionMenuPopup( ) ) { Surface( - shape = RoundedCornerShape(8.dp), - shadowElevation = 4.dp, - color = MaterialTheme.colorScheme.surfaceVariant, - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.5f)) + shape = RoundedCornerShape(12.dp), + shadowElevation = 6.dp, + color = MaterialTheme.colorScheme.surface, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant) ) { - Row( - modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), - horizontalArrangement = Arrangement.spacedBy(4.dp) + Column( + modifier = Modifier.width(IntrinsicSize.Max) ) { - TextButton(onClick = { onCopy(menuState.selectedText) }) { - Text("Copy") - } - if (menuState.selectedText.length <= 2000) { - TextButton(onClick = { - onAiDefine(menuState.selectedText) - }) { - Text("Dictionary") + // Color Row + Row( + modifier = Modifier + .padding(vertical = 12.dp, horizontal = 12.dp) + .fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + PdfHighlightColor.entries.forEach { colorEnum -> + Box( + modifier = Modifier + .padding(horizontal = 6.dp) + .size(32.dp) + .background(colorEnum.color, CircleShape) + .clip(CircleShape) + .clickable { + Timber.tag("PdfHighlightDebug").d("Color box clicked: $colorEnum") + onColorSelected(colorEnum) + } + ) } } - TextButton(onClick = onSelectAll) { - Text("Select All") + + HorizontalDivider() + + // Delete Option (Only for existing) + if (menuState.isExistingHighlight) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onDelete() } + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = "Remove", + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(20.dp) + ) + Text( + text = "Remove", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error + ) + } + HorizontalDivider() + } + + // Standard Options + Row( + modifier = Modifier.fillMaxWidth() + ) { + // Copy + Box( + modifier = Modifier + .weight(1f) + .clickable { onCopy(menuState.selectedText) } + .padding(vertical = 12.dp), + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Icon(Icons.Default.CopyAll, contentDescription = null, modifier = Modifier.size(20.dp)) + Text("Copy", style = MaterialTheme.typography.labelSmall) + } + } + + // Dictionary + if (menuState.selectedText.length <= 2000) { + Box( + modifier = Modifier + .weight(1f) + .clickable { onAiDefine(menuState.selectedText) } + .padding(vertical = 12.dp), + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Icon( + painter = painterResource(id = R.drawable.dictionary), + contentDescription = null, + modifier = Modifier.size(20.dp) + ) + Text("Dictionary", style = MaterialTheme.typography.labelSmall) + } + } + } + + // Select All (Only for new selection) + if (!menuState.isExistingHighlight) { + Box( + modifier = Modifier + .weight(1f) + .clickable { onSelectAll() } + .padding(vertical = 12.dp), + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Icon(painter = painterResource(id = R.drawable.select_all), contentDescription = null, modifier = Modifier.size(20.dp)) + Text("Select All", style = MaterialTheme.typography.labelSmall) + } + } + } } } } @@ -286,4 +410,51 @@ internal fun preprocessTextForTts(rawText: String): ProcessedText { } } return ProcessedText(cleanTextBuilder.toString().trim(), indexMap) +} + +internal fun mergePdfRectsIntoLines(rects: List): List { + if (rects.isEmpty()) return emptyList() + + val normalized = rects.map { r -> + floatArrayOf( + minOf(r.left, r.right), + minOf(r.top, r.bottom), + maxOf(r.left, r.right), + maxOf(r.top, r.bottom) + ) + } + + val sorted = normalized.sortedWith(compareBy({ -it[3] }, { it[0] })) + + val merged = mutableListOf() + var current: FloatArray? = null + + for (r in sorted) { + if (current == null) { + current = r.clone() + } else { + val cMinY = current[1] + val cMaxY = current[3] + val rMinY = r[1] + val rMaxY = r[3] + + val overlapHeight = minOf(cMaxY, rMaxY) - maxOf(cMinY, rMinY) + val minHeight = minOf(cMaxY - cMinY, rMaxY - rMinY) + + if (overlapHeight > 0 && overlapHeight >= minHeight * 0.1f) { + current[0] = minOf(current[0], r[0]) + current[1] = minOf(current[1], r[1]) + current[2] = maxOf(current[2], r[2]) + current[3] = maxOf(current[3], r[3]) + } else { + merged.add(current) + current = r.clone() + } + } + } + current?.let { merged.add(it) } + + return merged.map { m -> + RectF(m[0], m[3], m[2], m[1]) + } } \ No newline at end of file diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfPageComposable.kt b/app/src/main/java/com/aryan/reader/pdf/PdfPageComposable.kt index 6143429..8897c2e 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfPageComposable.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfPageComposable.kt @@ -355,7 +355,8 @@ data class PageSelectionData( val allTextPageHighlightColor: Color, val ttsHighlightColor: Color, val selectionHighlightColor: Color, - val pageIndex: Int + val pageIndex: Int, + val userHighlightScreenRects: StableHolder>>>, ) @Suppress("unused") @@ -418,7 +419,11 @@ internal fun PdfPageComposable( isScrollLocked: Boolean = false, isVisible: Boolean = true, isStylusOnlyMode: Boolean = false, - isHighlighterSnapEnabled: Boolean = false + isHighlighterSnapEnabled: Boolean = false, + userHighlights: List = emptyList(), + onHighlightAdd: (Int, Pair, String, PdfHighlightColor) -> Unit = { _,_,_,_ -> }, + onHighlightUpdate: (String, PdfHighlightColor) -> Unit = { _,_ -> }, + onHighlightDelete: (String) -> Unit = {}, ) { SideEffect { Timber.tag("PdfDrawPerf").v("PdfPageComposable Recompose: Page $pageIndex") } val pdfDocumentItem = pdfDocument.item @@ -650,6 +655,39 @@ internal fun PdfPageComposable( val linkHighlightColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f) val linkVerticalPaddingPx = remember(density) { with(density) { 10.dp.toPx().toInt() } } + var userHighlightScreenRects by remember { mutableStateOf>>>(emptyList()) } + + LaunchedEffect(userHighlights, actualBitmapWidthPx, actualBitmapHeightPx, currentPageRotation, virtualPage) { + if (!isPdfPage || actualBitmapWidthPx == 0 || actualBitmapHeightPx == 0 || userHighlights.isEmpty()) { + userHighlightScreenRects = emptyList() + return@LaunchedEffect + } + withContext(Dispatchers.IO) { + try { + pdfDocumentItem.openPage(pdfPageIndex).use { page -> + val mapped = userHighlights.map { highlight -> + val screenRects = highlight.bounds.mapNotNull { pdfRectF -> + page.mapRectToDevice( + startX = 0, + startY = 0, + sizeX = actualBitmapWidthPx, + sizeY = actualBitmapHeightPx, + rotate = currentPageRotation, + coords = pdfRectF + ).takeIf { it.width() > 0 && it.height() > 0 } + } + highlight to screenRects + } + withContext(Dispatchers.Main) { + userHighlightScreenRects = mapped + } + } + } catch (e: Exception) { + Timber.e(e, "Failed to map user highlights to screen rects") + } + } + } + LaunchedEffect(pageIndex) { scale = 1f offset = Offset.Zero @@ -2241,7 +2279,8 @@ internal fun PdfPageComposable( isVerticalScroll, isEditMode, selectedTool, - isStylusOnlyMode + isStylusOnlyMode, + userHighlightScreenRects ) { val isTapDetectionAllowed = !isEditMode || selectedTool == InkType.TEXT || @@ -2250,13 +2289,52 @@ internal fun PdfPageComposable( if (!isTapDetectionAllowed) return@pointerInput detectTapGestures(onTap = { tapOffset -> - Timber.d( - "PdfPageComposable: onTap detected at $tapOffset. isVerticalScroll=$isVerticalScroll" - ) - val tapInContentCoords = screenToContentCoordinates(tapOffset) val tapXInBitmap = tapInContentCoords.x val tapYInBitmap = tapInContentCoords.y + + val hitTolerance = with(density) { 16.dp.toPx() } / inputScale + + Timber.d( + "detectTapGestures: Tap at bitmap coords (${tapXInBitmap.toInt()}, ${tapYInBitmap.toInt()}) with tolerance $hitTolerance" + ) + + var tappedRect: Rect? = null + val hitHighlightPair = userHighlightScreenRects.findLast { pair -> + val hit = pair.second.find { r -> + val hitLeft = r.left - hitTolerance + val hitTop = r.top - hitTolerance + val hitRight = r.right + hitTolerance + val hitBottom = r.bottom + hitTolerance + + tapXInBitmap in hitLeft..hitRight && + tapYInBitmap >= hitTop && tapYInBitmap <= hitBottom + } + if (hit != null) { + tappedRect = hit + true + } else false + } + + if (hitHighlightPair != null && tappedRect != null) { + val hitHighlight = hitHighlightPair.first + val anchorRect = android.graphics.Rect( + tappedRect.left, + tappedRect.top, + tappedRect.right, + tappedRect.bottom + ) + + customMenuState = CustomPdfMenuState( + selectedText = hitHighlight.text, + anchorRect = anchorRect, + charRange = hitHighlight.range, + isExistingHighlight = true, + highlightId = hitHighlight.id + ) + return@detectTapGestures + } + Timber.d( "detectTapGestures: Tap at bitmap coords (${tapXInBitmap.toInt()}, ${tapYInBitmap.toInt()})" ) @@ -3257,7 +3335,8 @@ internal fun PdfPageComposable( mergedSearchAllRects, searchHighlightMode, searchFocusedColor, - searchAllColor + searchAllColor, + userHighlightScreenRects ) { PageSelectionData( pageLinks = StableHolder(pageLinks), @@ -3281,6 +3360,7 @@ internal fun PdfPageComposable( mergedSearchFocusedRects = StableHolder(mergedSearchFocusedRects), mergedSearchAllRects = StableHolder(mergedSearchAllRects), searchHighlightMode = searchHighlightMode, + userHighlightScreenRects = StableHolder(userHighlightScreenRects), ) } @@ -3306,6 +3386,9 @@ internal fun PdfPageComposable( startHandlePos = startHandleContentPosition.value, endHandlePos = endHandleContentPosition.value, teardropWidthPx = teardropWidthPxState.value, + onHighlightAdd = onHighlightAdd, + onHighlightUpdate = onHighlightUpdate, + onHighlightDelete = onHighlightDelete, teardropHeightPx = teardropHeightPxState.value, activeDraggingHandle = activeDraggingHandle, showMagnifier = showMagnifier, @@ -3594,6 +3677,7 @@ private fun PdfHighlightsLayer( searchHighlightMode: SearchHighlightMode, ocrHoverHighlights: List, mergedSelectionRects: List, + userHighlightScreenRects: List>>, centeringOffsetX: Float, centeringOffsetY: Float, linkHighlightColor: Color, @@ -3610,8 +3694,9 @@ private fun PdfHighlightsLayer( fun isVisible(r: Rect): Boolean { val left = r.left + centeringOffsetX val right = r.right + centeringOffsetX + val top = r.top + centeringOffsetY val bottom = r.bottom + centeringOffsetY - return left < size.width && right < size.height && right > 0 && bottom > 0 + return left < size.width && right > 0 && top < size.height && bottom > 0 } // 1. Page Links @@ -3739,6 +3824,19 @@ private fun PdfHighlightsLayer( ) } } + + // 9. Persistent User Highlights + userHighlightScreenRects.forEach { (highlight, screenRects) -> + screenRects.forEach { r -> + if (isVisible(r)) { + drawRect( + color = highlight.color.color.copy(alpha = 0.4f), + topLeft = Offset(r.left.toFloat(), r.top.toFloat()), + size = Size(r.width().toFloat(), r.height().toFloat()) + ) + } + } + } } } } @@ -4093,6 +4191,7 @@ private fun PdfPageSelectionsLayer( searchHighlightMode: SearchHighlightMode, ocrHoverHighlights: List, mergedSelectionRects: List, + userHighlightScreenRects: List>>, centeringOffsetX: Float, centeringOffsetY: Float, linkHighlightColor: Color, @@ -4101,7 +4200,10 @@ private fun PdfPageSelectionsLayer( ttsHighlightColor: Color, selectionHighlightColor: Color ) { - SideEffect { Timber.tag("PdfDrawPerf").v("SELECTIONS LAYER: Recomposing") } + SideEffect { + Timber.tag("PdfDrawPerf").v("SELECTIONS LAYER: Recomposing") + Timber.tag("PdfHighlightDebug").v("PdfPageSelectionsLayer Recomposing. userHighlights count: ${userHighlightScreenRects.size}") + } val highlightStart = System.nanoTime() PdfHighlightsLayer( @@ -4116,6 +4218,7 @@ private fun PdfPageSelectionsLayer( searchHighlightMode = searchHighlightMode, ocrHoverHighlights = ocrHoverHighlights, mergedSelectionRects = mergedSelectionRects, + userHighlightScreenRects = userHighlightScreenRects, centeringOffsetX = centeringOffsetX, centeringOffsetY = centeringOffsetY, linkHighlightColor = linkHighlightColor, @@ -4182,7 +4285,10 @@ private fun PdfPageRenderer( onTextBoxDrag: (Offset) -> Unit, onTextBoxDragEnd: () -> Unit, onDragPageTurn: (Int) -> Unit, - draggingBoxId: String? = null + draggingBoxId: String? = null, + onHighlightAdd: (Int, Pair, String, PdfHighlightColor) -> Unit, + onHighlightUpdate: (String, PdfHighlightColor) -> Unit, + onHighlightDelete: (String) -> Unit, ) { Box(modifier = Modifier.fillMaxSize()) { Box( @@ -4215,6 +4321,7 @@ private fun PdfPageRenderer( searchHighlightMode = selectionData.searchHighlightMode, ocrHoverHighlights = selectionData.ocrHoverHighlights.item, mergedSelectionRects = selectionData.mergedSelectionRects.item, + userHighlightScreenRects = selectionData.userHighlightScreenRects.item, centeringOffsetX = selectionData.centeringOffsetX, centeringOffsetY = selectionData.centeringOffsetY, linkHighlightColor = selectionData.linkHighlightColor, @@ -4363,7 +4470,7 @@ private fun PdfPageRenderer( staticData.targetHeight.toDp() })) { Text( - text = "${selectionData.pageIndex + 1}\\$totalPages", + text = "${selectionData.pageIndex + 1}/$totalPages", color = pageNumColor.copy(alpha = 0.5f), style = MaterialTheme.typography.labelSmall.copy( fontSize = 12.sp, fontWeight = FontWeight.Bold @@ -4559,7 +4666,28 @@ private fun PdfPageRenderer( popupPositionProvider = popupPositionProvider, onCopy = onCopy, onAiDefine = onAiDefine, - onSelectAll = onSelectAll + onSelectAll = onSelectAll, + onColorSelected = { color -> + Timber.tag("PdfHighlightDebug").d("PdfSelectionMenuPopup onColorSelected: $color, isExisting=${state.isExistingHighlight}") + if (state.isExistingHighlight && state.highlightId != null) { + onHighlightUpdate(state.highlightId, color) + } else { + Timber.tag("PdfHighlightDebug").d("Calling onHighlightAdd for page ${selectionData.pageIndex}") + onHighlightAdd( + selectionData.pageIndex, + state.charRange, + state.selectedText, + color + ) + } + onMenuDismiss() + }, + onDelete = { + if (state.isExistingHighlight && state.highlightId != null) { + onHighlightDelete(state.highlightId) + } + onMenuDismiss() + } ) } } diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfVerticalReader.kt b/app/src/main/java/com/aryan/reader/pdf/PdfVerticalReader.kt index c7a84bf..e583556 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfVerticalReader.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfVerticalReader.kt @@ -223,7 +223,11 @@ internal fun PdfVerticalReader( autoScrollSpeed: Float = 1.0f, onInteractionListener: () -> Unit = {}, isStylusOnlyMode: Boolean = false, - isHighlighterSnapEnabled: Boolean = false + isHighlighterSnapEnabled: Boolean = false, + userHighlights: List = emptyList(), + onHighlightAdd: (Int, Pair, String, PdfHighlightColor) -> Unit = { _,_,_,_ -> }, + onHighlightUpdate: (String, PdfHighlightColor) -> Unit = { _,_ -> }, + onHighlightDelete: (String) -> Unit = {}, ) { SideEffect { Timber.tag("PdfDrawPerf").v("LIST: PdfVerticalReader Recomposing.") } var globalEraserPosition by remember { mutableStateOf(null) } @@ -1481,6 +1485,10 @@ internal fun PdfVerticalReader( selectedTextBoxId = selectedTextBoxId, onTextBoxChange = onTextBoxChange, onTextBoxSelect = onTextBoxSelect, + userHighlights = userHighlights.filter { it.pageIndex == page.index }, + onHighlightAdd = onHighlightAdd, + onHighlightUpdate = onHighlightUpdate, + onHighlightDelete = onHighlightDelete, onTextBoxDragStart = { box, localTopLeft, touchOffset -> val currentZoom = zoomAnimatable.value val panX = panXAnimatable.value diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt b/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt index c4bf0ce..9bba23a 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt @@ -28,6 +28,7 @@ import android.app.Activity import android.content.Context import android.content.pm.PackageManager import android.graphics.Bitmap +import kotlin.math.max import android.graphics.RectF import android.net.Uri import android.os.Build @@ -93,6 +94,7 @@ import androidx.compose.material.icons.filled.ArrowDownward import androidx.compose.material.icons.filled.ArrowUpward import androidx.compose.material.icons.filled.Brush import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Fullscreen import androidx.compose.material.icons.filled.FullscreenExit @@ -1013,6 +1015,7 @@ fun PdfViewerScreen( val pdfTextRepository = remember(context) { PdfTextRepository(context) } val annotationRepository = remember(context) { PdfAnnotationRepository(context) } val textBoxRepository = remember(context) { PdfTextBoxRepository(context) } + val highlightRepository = remember(context) { com.aryan.reader.pdf.data.PdfHighlightRepository(context) } var allAnnotations by remember { mutableStateOf>>(emptyMap()) } @@ -1179,6 +1182,90 @@ fun PdfViewerScreen( val textBoxes = remember { mutableStateListOf() } var selectedTextBoxId by remember { mutableStateOf(null) } + val userHighlights = remember { mutableStateListOf() } + + val onHighlightAdd = remember(pdfDocument, currentBookId) { + { pageIndex: Int, range: Pair, text: String, color: PdfHighlightColor -> + Timber.tag("PdfExportDebug").i("onHighlightAdd: Adding persistent highlight. Page: $pageIndex, Text: ${text.take(20)}...") + coroutineScope.launch { + val doc = pdfDocument + if (doc == null) { + Timber.tag("PdfHighlightDebug").e("onHighlightAdd failed: pdfDocument is null") + return@launch + } + + val existingOnPage = userHighlights.filter { + it.pageIndex == pageIndex && it.color == color + } + + var newStart = range.first + var newEnd = range.second + val highlightsToRemove = mutableListOf() + + existingOnPage.forEach { h -> + if (max(newStart, h.range.first) <= min(newEnd, h.range.second)) { + newStart = min(newStart, h.range.first) + newEnd = max(newEnd, h.range.second) + highlightsToRemove.add(h) + } + } + + userHighlights.removeAll(highlightsToRemove) + + withContext(Dispatchers.IO) { + try { + doc.openPage(pageIndex).use { page -> + page.openTextPage().use { textPage -> + val fullText = textPage.textPageGetText(newStart, newEnd - newStart) ?: text + val rects = textPage.textPageGetRectsForRanges(intArrayOf(newStart, newEnd - newStart)) + + val rawPdfRects = rects?.map { r -> r.rect } ?: emptyList() + val mergedPdfRects = mergePdfRectsIntoLines(rawPdfRects) + + val newHighlight = PdfUserHighlight( + pageIndex = pageIndex, + bounds = mergedPdfRects, + color = color, + text = fullText, + range = Pair(newStart, newEnd) + ) + + withContext(Dispatchers.Main) { + userHighlights.add(newHighlight) + Timber.tag("PdfExportDebug").d("userHighlights now contains ${userHighlights.size} items.") + } + } + } + } catch (e: Exception) { + Timber.tag("PdfHighlightDebug").e(e, "Failed to create highlight") + } + } + } + Unit + } + } + + val onHighlightUpdate = remember { + { id: String, newColor: PdfHighlightColor -> + Timber.tag("PdfHighlightDebug").d("onHighlightUpdate triggered: id=$id, newColor=$newColor") + val index = userHighlights.indexOfFirst { it.id == id } + if (index != -1) { + val old = userHighlights[index] + userHighlights[index] = old.copy(color = newColor) + Timber.tag("PdfHighlightDebug").d("Highlight successfully updated") + } else { + Timber.tag("PdfHighlightDebug").w("Highlight update failed: ID $id not found") + } + } + } + + val onHighlightDelete = remember { + { id: String -> + userHighlights.removeAll { it.id == id } + Unit + } + } + val onInsertPage: () -> Unit = { coroutineScope.launch { val targetIndex = currentPage + 1 @@ -1230,6 +1317,18 @@ fun PdfViewerScreen( textBoxes.addAll(shiftedBoxes) } + val shiftedHighlights = userHighlights.map { highlight -> + if (highlight.pageIndex >= targetIndex) { + highlight.copy(pageIndex = highlight.pageIndex + 1) + } else { + highlight + } + } + if (shiftedHighlights != userHighlights.toList()) { + userHighlights.clear() + userHighlights.addAll(shiftedHighlights) + } + val tempNewPage = VirtualPage.BlankPage(generateShortId(), refWidth, refHeight, wasManuallyAdded = true) val optimisticPages = virtualPages.toMutableList() optimisticPages.add(targetIndex, tempNewPage) @@ -1311,7 +1410,6 @@ fun PdfViewerScreen( if (currentBookId != null && currentPage in virtualPages.indices) { Timber.tag("RichTextMigration").i("DELETE: User requested deletion of page at index $currentPage") - // Update text boxes: remove those on current page, shift those after val boxesToKeep = textBoxes.filter { it.pageIndex != currentPage } val shiftedBoxes = boxesToKeep.map { box -> if (box.pageIndex > currentPage) { @@ -1323,6 +1421,17 @@ fun PdfViewerScreen( textBoxes.clear() textBoxes.addAll(shiftedBoxes) + val highlightsToKeep = userHighlights.filter { it.pageIndex != currentPage } + val shiftedHighlights = highlightsToKeep.map { highlight -> + if (highlight.pageIndex > currentPage) { + highlight.copy(pageIndex = highlight.pageIndex - 1) + } else { + highlight + } + } + userHighlights.clear() + userHighlights.addAll(shiftedHighlights) + val objectList = bookmarks.map { bookmark -> JSONObject().apply { put("pageIndex", bookmark.pageIndex) @@ -1498,7 +1607,8 @@ fun PdfViewerScreen( currentLastIndex > highestRequiredTextPageIndex && !hasTextOnPage(currentLastIndex) && allAnnotations[currentLastIndex].isNullOrEmpty() && - textBoxes.none { it.pageIndex == currentLastIndex } // Check for text boxes + textBoxes.none { it.pageIndex == currentLastIndex } && + userHighlights.none { it.pageIndex == currentLastIndex } ) { Timber.tag("RichTextFlow").i("Auto-pruning empty page at index $currentLastIndex.") pageRemoved = true @@ -1763,6 +1873,10 @@ fun PdfViewerScreen( val loadedBoxes = textBoxRepository.loadTextBoxes(currentBookId!!) textBoxes.clear() textBoxes.addAll(loadedBoxes) + + val loadedHighlights = highlightRepository.loadHighlights(currentBookId!!) + userHighlights.clear() + userHighlights.addAll(loadedHighlights) } } @@ -1776,6 +1890,16 @@ fun PdfViewerScreen( } } + LaunchedEffect(userHighlights.toList()) { + if (currentBookId != null) { + delay(1000) + withContext(Dispatchers.IO) { + Timber.d("Auto-saving highlights locally for book $currentBookId") + highlightRepository.saveHighlights(currentBookId!!, userHighlights.toList()) + } + } + } + var pendingSaveMode by remember { mutableStateOf(null) } val saveLauncher = rememberLauncherForActivityResult( @@ -1788,11 +1912,9 @@ fun PdfViewerScreen( coroutineScope.launch { val currentRichTextLayouts = richTextController?.pageLayouts - Timber.tag("PdfExportDebug").i("*** EXPORT TRIGGERED FROM VIEWER ***") - if (currentRichTextLayouts != null) { - Timber.tag("PdfExportDebug").i( - "Passing ${currentRichTextLayouts.size} rich text pages." - ) + Timber.tag("PdfExportDebug").i("SAVE TRIGGERED: userHighlights count: ${userHighlights.size}") + if (userHighlights.isEmpty()) { + Timber.tag("PdfExportDebug").w("Warning: userHighlights is EMPTY during save.") } viewModel.savePdfWithAnnotations( @@ -1801,11 +1923,10 @@ fun PdfViewerScreen( annotations = allAnnotations, richTextPageLayouts = currentRichTextLayouts, textBoxes = textBoxes.toList(), + highlights = userHighlights.toList(), bookId = currentBookId!! ) } - } else { - Timber.tag("PdfExportDebug").e("Cannot export: currentBookId is null") } } @@ -1880,6 +2001,7 @@ fun PdfViewerScreen( if (currentBookId != null) { annotationRepository.saveAnnotations(currentBookId!!, allAnnotations) textBoxRepository.saveTextBoxes(currentBookId!!, textBoxes.toList()) + highlightRepository.saveHighlights(currentBookId!!, userHighlights.toList()) } val objectList = bookmarks.map { bookmark -> @@ -3054,7 +3176,7 @@ fun PdfViewerScreen( ModalNavigationDrawer( drawerState = drawerState, gesturesEnabled = drawerState.isOpen, drawerContent = { ModalDrawerSheet(modifier = Modifier.windowInsetsPadding(WindowInsets.statusBars)) { - val drawerPagerState = rememberPagerState(pageCount = { 2 }) + val drawerPagerState = rememberPagerState(pageCount = { 3 }) val drawerScope = rememberCoroutineScope() Column(modifier = Modifier.fillMaxSize()) { @@ -3074,6 +3196,16 @@ fun PdfViewerScreen( text = { Text("Bookmarks") }, modifier = Modifier.testTag("BookmarksTab") ) + Tab( + selected = drawerPagerState.currentPage == 2, + onClick = { + drawerScope.launch { + drawerPagerState.animateScrollToPage(2) + } + }, + text = { Text("Highlights") }, + modifier = Modifier.testTag("HighlightsTab") + ) } HorizontalPager( @@ -3309,6 +3441,101 @@ fun PdfViewerScreen( } } } + 2 -> { // Highlights Page + if (userHighlights.isEmpty()) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + contentAlignment = Alignment.Center + ) { + Text( + "You haven't added any highlights yet.", + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center + ) + } + } else { + var showDeleteConfirmDialogFor by remember { + mutableStateOf(null) + } + val sortedHighlights = remember(userHighlights.toList()) { + userHighlights.sortedBy { it.pageIndex } + } + + LazyColumn(modifier = Modifier.fillMaxSize()) { + itemsIndexed( + items = sortedHighlights, + key = { _, highlight -> highlight.id } + ) { _, highlight -> + ListItem( + headlineContent = { + Text( + text = highlight.text.ifBlank { "Highlighted section" }, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .background( + color = highlight.color.color.copy(alpha = 0.3f), + shape = RoundedCornerShape(4.dp) + ) + .padding(horizontal = 4.dp, vertical = 2.dp) + ) + }, + supportingContent = { + Text( + "Page ${highlight.pageIndex + 1}", + style = MaterialTheme.typography.bodySmall + ) + }, + trailingContent = { + IconButton( + onClick = { showDeleteConfirmDialogFor = highlight } + ) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = "Delete highlight", + tint = MaterialTheme.colorScheme.error + ) + } + }, + modifier = Modifier.clickable { + coroutineScope.launch { + drawerState.close() + if (displayMode == DisplayMode.PAGINATION) { + pagerState.scrollToPage(highlight.pageIndex) + } else { + verticalReaderState.scrollToPage(highlight.pageIndex) + } + } + } + ) + HorizontalDivider() + } + } + + showDeleteConfirmDialogFor?.let { highlightToDelete -> + AlertDialog( + onDismissRequest = { showDeleteConfirmDialogFor = null }, + title = { Text("Delete Highlight?") }, + text = { Text("Are you sure you want to permanently delete this highlight?") }, + confirmButton = { + TextButton( + onClick = { + userHighlights.removeAll { it.id == highlightToDelete.id } + showDeleteConfirmDialogFor = null + } + ) { Text("Delete") } + }, + dismissButton = { + TextButton( + onClick = { showDeleteConfirmDialogFor = null } + ) { Text("Cancel") } + } + ) + } + } + } } } } @@ -3570,12 +3797,10 @@ fun PdfViewerScreen( currentPageScale = newScale } }, - ttsHighlightData = if (pagerState.currentPage == pageIndex) ttsHighlightData - else null, + ttsHighlightData = if (pagerState.currentPage == pageIndex) ttsHighlightData else null, searchQuery = searchState.searchQuery, searchHighlightMode = searchHighlightMode, - searchResultToHighlight = if (pagerState.currentPage == pageIndex) searchHighlightTarget - else null, + searchResultToHighlight = if (pagerState.currentPage == pageIndex) searchHighlightTarget else null, ocrHoverHighlights = stableOcrRects, modifier = Modifier.fillMaxSize(), showAllTextHighlights = showAllTextHighlights, @@ -3631,6 +3856,10 @@ fun PdfViewerScreen( onOcrModelDownloading = { isOcrModelDownloading = true }, + userHighlights = userHighlights.filter { it.pageIndex == pageIndex }, + onHighlightAdd = onHighlightAdd, + onHighlightUpdate = onHighlightUpdate, + onHighlightDelete = onHighlightDelete, onTwoFingerSwipe = { direction -> coroutineScope.launch { val targetPage = @@ -3956,6 +4185,10 @@ fun PdfViewerScreen( onWordSelectedForAiDefinition = onDictionaryLookupStable, ttsHighlightData = ttsHighlightData, ttsReadingPage = ttsPageData?.pageIndex, + userHighlights = userHighlights, + onHighlightAdd = onHighlightAdd, + onHighlightUpdate = onHighlightUpdate, + onHighlightDelete = onHighlightDelete, onLinkClicked = onLinkClickedStable, onInternalLinkClicked = onInternalLinkNavStable, bookmarks = bookmarksHolder, @@ -6103,6 +6336,7 @@ fun PdfViewerScreen( onClick = { showShareDialog = false isShareLoading = true + Timber.tag("PdfExportDebug").i("SHARE TRIGGERED: userHighlights count: ${userHighlights.size}") val filename = getSuggestedFilename( originalFileName, isAnnotated = true ) @@ -6115,6 +6349,7 @@ fun PdfViewerScreen( annotations = allAnnotations, richTextPageLayouts = currentRichTextLayouts, textBoxes = textBoxes.toList(), + highlights = userHighlights.toList(), includeAnnotations = true, filename = filename, bookId = currentBookId diff --git a/app/src/main/java/com/aryan/reader/pdf/data/PdfAnnotationData.kt b/app/src/main/java/com/aryan/reader/pdf/data/PdfAnnotationData.kt index e5be8a9..6a6e60b 100644 --- a/app/src/main/java/com/aryan/reader/pdf/data/PdfAnnotationData.kt +++ b/app/src/main/java/com/aryan/reader/pdf/data/PdfAnnotationData.kt @@ -19,12 +19,15 @@ */ package com.aryan.reader.pdf.data +import android.graphics.RectF import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb import com.aryan.reader.pdf.AnnotationType import com.aryan.reader.pdf.InkType +import com.aryan.reader.pdf.PdfHighlightColor import com.aryan.reader.pdf.PdfPoint +import com.aryan.reader.pdf.PdfUserHighlight import org.json.JSONArray import org.json.JSONObject import java.util.Locale @@ -207,4 +210,67 @@ object TextBoxSerializer { } return result } +} + +object HighlightSerializer { + fun toJson(highlights: List): String { + val rootArray = JSONArray() + highlights.forEach { h -> + val obj = JSONObject() + obj.put("id", h.id) + obj.put("pageIndex", h.pageIndex) + obj.put("color", h.color.name) + obj.put("text", h.text) + obj.put("rangeStart", h.range.first) + obj.put("rangeEnd", h.range.second) + + val boundsArray = JSONArray() + h.bounds.forEach { r -> + val rObj = JSONObject() + rObj.put("left", r.left.toDouble()) + rObj.put("top", r.top.toDouble()) + rObj.put("right", r.right.toDouble()) + rObj.put("bottom", r.bottom.toDouble()) + boundsArray.put(rObj) + } + obj.put("bounds", boundsArray) + rootArray.put(obj) + } + return rootArray.toString() + } + + fun fromJson(json: String): List { + val result = mutableListOf() + if (json.isBlank()) return result + try { + val rootArray = JSONArray(json) + for (i in 0 until rootArray.length()) { + val obj = rootArray.getJSONObject(i) + val boundsArray = obj.getJSONArray("bounds") + val bounds = mutableListOf() + for (j in 0 until boundsArray.length()) { + val rObj = boundsArray.getJSONObject(j) + bounds.add(RectF( + rObj.getDouble("left").toFloat(), + rObj.getDouble("top").toFloat(), + rObj.getDouble("right").toFloat(), + rObj.getDouble("bottom").toFloat() + )) + } + result.add( + PdfUserHighlight( + id = obj.optString("id", java.util.UUID.randomUUID().toString()), + pageIndex = obj.getInt("pageIndex"), + bounds = bounds, + color = try { PdfHighlightColor.valueOf(obj.getString("color")) } catch(_: Exception) { PdfHighlightColor.YELLOW }, + text = obj.optString("text", ""), + range = Pair(obj.optInt("rangeStart", 0), obj.optInt("rangeEnd", 0)) + ) + ) + } + } catch (e: Exception) { + e.printStackTrace() + } + return result + } } \ No newline at end of file diff --git a/app/src/main/java/com/aryan/reader/pdf/data/PdfHighlightRepository.kt b/app/src/main/java/com/aryan/reader/pdf/data/PdfHighlightRepository.kt new file mode 100644 index 0000000..07cfe77 --- /dev/null +++ b/app/src/main/java/com/aryan/reader/pdf/data/PdfHighlightRepository.kt @@ -0,0 +1,55 @@ +// PdfHighlightRepository.kt +package com.aryan.reader.pdf.data + +import android.content.Context +import com.aryan.reader.pdf.PdfUserHighlight +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import timber.log.Timber +import java.io.File + +class PdfHighlightRepository(private val context: Context) { + + fun getFileForSync(bookId: String): File { + val safeBookId = bookId.replace("/", "_") + val dir = File(context.filesDir, "pdf_highlights") + if (!dir.exists()) dir.mkdirs() + return File(dir, "highlights_$safeBookId.json") + } + + suspend fun saveHighlights(bookId: String, highlights: List) { + withContext(Dispatchers.IO) { + try { + val file = getFileForSync(bookId) + if (highlights.isEmpty()) { + if (file.exists()) file.delete() + return@withContext + } + file.writeText(HighlightSerializer.toJson(highlights)) + } catch (e: Exception) { + Timber.e(e, "Failed to save local highlights") + } + } + } + + suspend fun loadHighlights(bookId: String): List { + return withContext(Dispatchers.IO) { + try { + val file = getFileForSync(bookId) + if (file.exists()) { + HighlightSerializer.fromJson(file.readText()) + } else { + emptyList() + } + } catch (e: Exception) { + Timber.e(e, "Failed to load local highlights") + emptyList() + } + } + } + + suspend fun clearAll() = withContext(Dispatchers.IO) { + val dir = File(context.filesDir, "pdf_highlights") + if (dir.exists()) dir.deleteRecursively() + } +} \ No newline at end of file