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.
This commit is contained in:
Aryan 2026-03-09 17:37:44 +05:30 committed by GitHub
parent 8c7c8cb48e
commit ef1bfdc57a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 921 additions and 163 deletions

42
LICENSE
View file

@ -617,3 +617,45 @@ Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
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.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
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 <https://www.gnu.org/licenses/>.
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
<https://www.gnu.org/licenses/>.

View file

@ -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.
<a href="https://github.com/sponsors/Aryan-Raj3112">
<img src="https://img.shields.io/badge/Sponsor-%E2%9D%A4-%23db61a2?logo=github" alt="Sponsor on GitHub"/>
</a>

View file

@ -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<VirtualPage>,
@ -918,6 +921,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
annotations: Map<Int, List<PdfAnnotation>>,
richTextPageLayouts: List<com.aryan.reader.pdf.PageTextLayout>? = null,
textBoxes: List<PdfTextBox>? = null,
highlights: List<PdfUserHighlight>? = 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<Int, List<PdfAnnotation>>,
richTextPageLayouts: List<com.aryan.reader.pdf.PageTextLayout>? = null,
textBoxes: List<PdfTextBox>? = null,
highlights: List<PdfUserHighlight>? = 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")

View file

@ -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.")

View file

@ -155,7 +155,8 @@ object PdfExporter {
virtualPages: List<VirtualPage>?,
inkAnnotations: Map<Int, List<PdfAnnotation>>,
richTextPageLayouts: List<PageTextLayout>? = null,
textBoxes: List<PdfTextBox>? = null
textBoxes: List<PdfTextBox>? = null,
highlights: List<PdfUserHighlight>? = 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<PdfUserHighlight>
) {
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,

View file

@ -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<RectF>,
val color: PdfHighlightColor,
val text: String,
val range: Pair<Int, Int>
)
internal data class CustomPdfMenuState(
val selectedText: String,
val anchorRect: Rect,
val charRange: Pair<Int, Int>
val charRange: Pair<Int, Int>,
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)
}
}
}
}
}
}
@ -287,3 +411,50 @@ internal fun preprocessTextForTts(rawText: String): ProcessedText {
}
return ProcessedText(cleanTextBuilder.toString().trim(), indexMap)
}
internal fun mergePdfRectsIntoLines(rects: List<RectF>): List<RectF> {
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<FloatArray>()
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])
}
}

View file

@ -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<List<Pair<PdfUserHighlight, List<Rect>>>>,
)
@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<PdfUserHighlight> = emptyList(),
onHighlightAdd: (Int, Pair<Int, Int>, 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<List<Pair<PdfUserHighlight, List<Rect>>>>(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<RectF>,
mergedSelectionRects: List<Rect>,
userHighlightScreenRects: List<Pair<PdfUserHighlight, List<Rect>>>,
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<RectF>,
mergedSelectionRects: List<Rect>,
userHighlightScreenRects: List<Pair<PdfUserHighlight, List<Rect>>>,
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<Int, Int>, 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()
}
)
}
}

View file

@ -223,7 +223,11 @@ internal fun PdfVerticalReader(
autoScrollSpeed: Float = 1.0f,
onInteractionListener: () -> Unit = {},
isStylusOnlyMode: Boolean = false,
isHighlighterSnapEnabled: Boolean = false
isHighlighterSnapEnabled: Boolean = false,
userHighlights: List<PdfUserHighlight> = emptyList(),
onHighlightAdd: (Int, Pair<Int, Int>, String, PdfHighlightColor) -> Unit = { _,_,_,_ -> },
onHighlightUpdate: (String, PdfHighlightColor) -> Unit = { _,_ -> },
onHighlightDelete: (String) -> Unit = {},
) {
SideEffect { Timber.tag("PdfDrawPerf").v("LIST: PdfVerticalReader Recomposing.") }
var globalEraserPosition by remember { mutableStateOf<Offset?>(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

View file

@ -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<Map<Int, List<PdfAnnotation>>>(emptyMap()) }
@ -1179,6 +1182,90 @@ fun PdfViewerScreen(
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)}...")
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<PdfUserHighlight>()
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<SaveMode?>(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<PdfUserHighlight?>(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

View file

@ -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
@ -208,3 +211,66 @@ object TextBoxSerializer {
return result
}
}
object HighlightSerializer {
fun toJson(highlights: List<PdfUserHighlight>): 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<PdfUserHighlight> {
val result = mutableListOf<PdfUserHighlight>()
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<RectF>()
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
}
}

View file

@ -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<PdfUserHighlight>) {
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<PdfUserHighlight> {
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()
}
}