diff --git a/app/src/main/cpp/pdfium_bridge.cpp b/app/src/main/cpp/pdfium_bridge.cpp index 103c071..401b4f7 100644 --- a/app/src/main/cpp/pdfium_bridge.cpp +++ b/app/src/main/cpp/pdfium_bridge.cpp @@ -1,19 +1,41 @@ #include #include #include +#include +#include -#define LOG_TAG "PdfiumBridge" +#define LOG_TAG "PdfiumAnnotation" #define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__) +#define LOGD(...) __android_log_print(ANDROID_LOG_DEBUG, LOG_TAG, __VA_ARGS__) +#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__) typedef double (*FPDFText_GetFontSize_t)(void* text_page, int index); typedef int (*FPDFText_GetFontWeight_t)(void* text_page, int index); typedef int (*FPDFText_GetFontInfo_t)(void* text_page, int index, void* buffer, unsigned long buflen, int* flags); +typedef int (*FPDFPage_GetAnnotCount_t)(void* page); +typedef void* (*FPDFPage_GetAnnot_t)(void* page, int index); +typedef int (*FPDFAnnot_GetSubtype_t)(void* annot); +typedef int (*FPDFAnnot_GetRect_t)(void* annot, void* rect); +typedef unsigned long (*FPDFAnnot_GetStringValue_t)(void* annot, const char* key, void* buffer, unsigned long buflen); +typedef int (*FPDFAnnot_GetColor_t)(void* annot, int type, unsigned int* R, unsigned int* G, unsigned int* B, unsigned int* A); +static FPDFPage_GetAnnotCount_t get_annot_count_func = nullptr; +static FPDFPage_GetAnnot_t get_annot_func = nullptr; +static FPDFAnnot_GetSubtype_t get_annot_subtype_func = nullptr; +static FPDFAnnot_GetRect_t get_annot_rect_func = nullptr; +static FPDFAnnot_GetStringValue_t get_annot_string_func = nullptr; +static FPDFAnnot_GetColor_t get_annot_color_func = nullptr; static void* pdfium_handle = nullptr; static FPDFText_GetFontSize_t get_font_size_func = nullptr; static FPDFText_GetFontWeight_t get_font_weight_func = nullptr; static FPDFText_GetFontInfo_t get_font_info_func = nullptr; +typedef void* (*FPDFAnnot_GetLinkedAnnot_t)(void* annot, const char* key); +typedef void (*FPDFPage_CloseAnnot_t)(void* annot); + +static FPDFAnnot_GetLinkedAnnot_t get_linked_annot_func = nullptr; +static FPDFPage_CloseAnnot_t close_annot_func = nullptr; + static bool init_pdfium() { if (pdfium_handle) return true; @@ -27,7 +49,25 @@ static bool init_pdfium() { get_font_weight_func = (FPDFText_GetFontWeight_t) dlsym(pdfium_handle, "FPDFText_GetFontWeight"); get_font_info_func = (FPDFText_GetFontInfo_t) dlsym(pdfium_handle, "FPDFText_GetFontInfo"); - return get_font_size_func != nullptr && get_font_weight_func != nullptr && get_font_info_func != nullptr; + get_annot_count_func = (FPDFPage_GetAnnotCount_t) dlsym(pdfium_handle, "FPDFPage_GetAnnotCount"); + get_annot_func = (FPDFPage_GetAnnot_t) dlsym(pdfium_handle, "FPDFPage_GetAnnot"); + get_annot_subtype_func = (FPDFAnnot_GetSubtype_t) dlsym(pdfium_handle, "FPDFAnnot_GetSubtype"); + get_annot_rect_func = (FPDFAnnot_GetRect_t) dlsym(pdfium_handle, "FPDFAnnot_GetRect"); + get_annot_string_func = (FPDFAnnot_GetStringValue_t) dlsym(pdfium_handle, "FPDFAnnot_GetStringValue"); + get_annot_color_func = (FPDFAnnot_GetColor_t) dlsym(pdfium_handle, "FPDFAnnot_GetColor"); + get_linked_annot_func = (FPDFAnnot_GetLinkedAnnot_t) dlsym(pdfium_handle, "FPDFAnnot_GetLinkedAnnot"); + close_annot_func = (FPDFPage_CloseAnnot_t) dlsym(pdfium_handle, "FPDFPage_CloseAnnot"); + + bool success = get_annot_count_func && get_annot_func && get_annot_subtype_func && + get_annot_rect_func && get_annot_string_func; + + if (!success) { + LOGE("Failed to find one or more annotation functions in libpdfium.so"); + } else { + LOGI("Pdfium Annotation Bridge initialized successfully."); + } + + return success; } extern "C" JNIEXPORT jdouble JNICALL @@ -85,4 +125,71 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_getPageFontFlags(JNIEnv *env, jclas env->SetIntArrayRegion(result, 0, count, fill); delete[] fill; return result; +} + +extern "C" JNIEXPORT jint JNICALL +Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotCount(JNIEnv *env, jclass clazz, jlong pagePtr) { + if (!init_pdfium() || !get_annot_count_func) return 0; + return get_annot_count_func(reinterpret_cast(pagePtr)); +} + +extern "C" JNIEXPORT jint JNICALL +Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotSubtype(JNIEnv *env, jclass clazz, jlong pagePtr, jint index) { + if (!init_pdfium() || !get_annot_func || !get_annot_subtype_func) return 0; + void* annot = get_annot_func(reinterpret_cast(pagePtr), index); + return annot ? get_annot_subtype_func(annot) : 0; +} + +extern "C" JNIEXPORT jfloatArray JNICALL +Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotRect(JNIEnv *env, jclass clazz, jlong pagePtr, jint index) { + if (!init_pdfium() || !get_annot_func || !get_annot_rect_func) return nullptr; + void* annot = get_annot_func(reinterpret_cast(pagePtr), index); + if (!annot) return nullptr; + + float rect[4]; + if (!get_annot_rect_func(annot, rect)) return nullptr; + + jfloatArray result = env->NewFloatArray(4); + env->SetFloatArrayRegion(result, 0, 4, rect); + return result; +} + +extern "C" JNIEXPORT jstring JNICALL +Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotString(JNIEnv *env, jclass clazz, jlong pagePtr, jint index, jstring key) { + if (!init_pdfium() || !get_annot_func || !get_annot_string_func) return nullptr; + void* annot = get_annot_func(reinterpret_cast(pagePtr), index); + if (!annot) return nullptr; + + const char* nativeKey = env->GetStringUTFChars(key, nullptr); + + if (strcmp(nativeKey, "IRT") == 0 && get_linked_annot_func && close_annot_func) { + void* parentAnnot = get_linked_annot_func(annot, "IRT"); + if (parentAnnot) { + unsigned long len = get_annot_string_func(parentAnnot, "NM", nullptr, 0); + jstring result = nullptr; + if (len > 2) { + std::vector buffer(len / 2); + get_annot_string_func(parentAnnot, "NM", buffer.data(), len); + result = env->NewString(reinterpret_cast(buffer.data()), (jsize)(buffer.size() - 1)); + } + close_annot_func(parentAnnot); + env->ReleaseStringUTFChars(key, nativeKey); + return result; + } + } + + unsigned long len = get_annot_string_func(annot, nativeKey, nullptr, 0); + + if (len <= 2) { + env->ReleaseStringUTFChars(key, nativeKey); + return nullptr; + } + + std::vector buffer(len / 2); + get_annot_string_func(annot, nativeKey, buffer.data(), len); + + jstring result = env->NewString(reinterpret_cast(buffer.data()), (jsize)(buffer.size() - 1)); + + env->ReleaseStringUTFChars(key, nativeKey); + return result; } \ No newline at end of file diff --git a/app/src/main/java/com/aryan/reader/pdf/NativePdfiumBridge.kt b/app/src/main/java/com/aryan/reader/pdf/NativePdfiumBridge.kt index 6aef742..b64ab5f 100644 --- a/app/src/main/java/com/aryan/reader/pdf/NativePdfiumBridge.kt +++ b/app/src/main/java/com/aryan/reader/pdf/NativePdfiumBridge.kt @@ -11,4 +11,14 @@ object NativePdfiumBridge { @JvmStatic external fun getPageFontSizes(textPagePtr: Long, count: Int): FloatArray? @JvmStatic external fun getPageFontWeights(textPagePtr: Long, count: Int): IntArray? @JvmStatic external fun getPageFontFlags(textPagePtr: Long, count: Int): IntArray? + + @JvmStatic external fun getAnnotCount(pagePtr: Long): Int + @JvmStatic external fun getAnnotSubtype(pagePtr: Long, index: Int): Int + @JvmStatic external fun getAnnotRect(pagePtr: Long, index: Int): FloatArray? + @JvmStatic external fun getAnnotString(pagePtr: Long, index: Int, key: String): String? + + const val ANNOT_TEXT = 1 // Sticky Note + const val ANNOT_LINK = 2 // Link + const val ANNOT_HIGHLIGHT = 8 // Highlight + const val ANNOT_INK = 12 // Freehand drawing } \ No newline at end of file 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 b396c3f..b1ca347 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfHelper.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfHelper.kt @@ -30,12 +30,18 @@ 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.Spacer +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.CopyAll import androidx.compose.material.icons.filled.Delete @@ -44,12 +50,14 @@ 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.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Popup import androidx.compose.ui.window.PopupPositionProvider @@ -99,7 +107,10 @@ internal data class CustomPdfMenuState( val anchorRect: Rect, val charRange: Pair, val isExistingHighlight: Boolean = false, - val highlightId: String? = null + val highlightId: String? = null, + val isComment: Boolean = false, + val author: String? = null, + val annotation: EmbeddedAnnotation? = null ) internal enum class PdfSelectionMethod { @@ -155,10 +166,26 @@ internal suspend fun findWordBoundaries( } } +@Composable +private fun CommentThread(replies: List, depth: Int) { + Timber.tag("PdfCommentDebug").v("Rendering CommentThread: Depth=$depth, ReplyCount=${replies.size}") + replies.forEach { reply -> + HorizontalDivider( + modifier = Modifier.padding(vertical = 8.dp), + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f) + ) + CommentItem(author = reply.author, text = reply.contents ?: "", depth = depth) + if (reply.replies.isNotEmpty()) { + CommentThread(reply.replies, depth + 1) + } + } +} + @Composable internal fun PdfSelectionMenuPopup( menuState: CustomPdfMenuState, popupPositionProvider: PopupPositionProvider, + onDismiss: () -> Unit, onCopy: (String) -> Unit, onAiDefine: (String) -> Unit, onSelectAll: () -> Unit, @@ -167,122 +194,138 @@ internal fun PdfSelectionMenuPopup( ) { Popup( popupPositionProvider = popupPositionProvider, - onDismissRequest = null, + onDismissRequest = onDismiss, properties = PopupProperties( - focusable = false, - dismissOnClickOutside = false, - dismissOnBackPress = false + focusable = true, + dismissOnClickOutside = true, + dismissOnBackPress = true ) ) { Surface( shape = RoundedCornerShape(12.dp), - shadowElevation = 6.dp, + shadowElevation = 8.dp, color = MaterialTheme.colorScheme.surface, - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant) + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + modifier = Modifier.widthIn(max = 300.dp) ) { - Column( - modifier = Modifier.width(IntrinsicSize.Max) - ) { - // 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) - } - ) - } - } - - HorizontalDivider() - - // Delete Option (Only for existing) - if (menuState.isExistingHighlight) { - Row( + Column(modifier = if (menuState.isComment) Modifier.fillMaxWidth() else Modifier.width(IntrinsicSize.Max)) { + if (menuState.isComment) { + Column( modifier = Modifier - .fillMaxWidth() - .clickable { onDelete() } - .padding(horizontal = 16.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) + .padding(16.dp) + .heightIn(max = 400.dp) + .verticalScroll(rememberScrollState()) ) { - 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 - ) + CommentItem(author = menuState.author, text = menuState.selectedText, depth = 0) + menuState.annotation?.replies?.let { CommentThread(it, 1) } } + HorizontalDivider() - } - - // Standard Options - Row( - modifier = Modifier.fillMaxWidth() - ) { - // Copy - Box( - modifier = Modifier - .weight(1f) - .clickable { onCopy(menuState.selectedText) } - .padding(vertical = 12.dp), - contentAlignment = Alignment.Center + TextButton( + onClick = { + val fullText = buildString { + append("${menuState.author ?: "Unknown"}: ${menuState.selectedText}\n") + fun appendReplies(replies: List, indent: String) { + for (r in replies) { + append("$indent${r.author ?: "Unknown"}: ${r.contents ?: ""}\n") + appendReplies(r.replies, "$indent ") + } + } + menuState.annotation?.replies?.let { appendReplies(it, " ") } + }.trimEnd() + onCopy(fullText) + }, + modifier = Modifier.fillMaxWidth() ) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Icon(Icons.Default.CopyAll, contentDescription = null, modifier = Modifier.size(20.dp)) - Text("Copy", style = MaterialTheme.typography.labelSmall) + Icon(Icons.Default.CopyAll, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text("Copy Thread") + } + } else { + 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) + }) } } - // Dictionary - if (menuState.selectedText.length <= 2000) { + HorizontalDivider() + + 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() + } + + Row( + modifier = Modifier.fillMaxWidth() + ) { Box( - modifier = Modifier - .weight(1f) - .clickable { onAiDefine(menuState.selectedText) } - .padding(vertical = 12.dp), - contentAlignment = Alignment.Center - ) { + modifier = Modifier.weight(1f) + .clickable { onCopy(menuState.selectedText) }.padding(vertical = 12.dp), + contentAlignment = Alignment.Center) { Column(horizontalAlignment = Alignment.CenterHorizontally) { Icon( - painter = painterResource(id = R.drawable.dictionary), + Icons.Default.CopyAll, contentDescription = null, modifier = Modifier.size(20.dp) ) - Text("Dictionary", style = MaterialTheme.typography.labelSmall) + Text("Copy", 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) + 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) + } + } + } + + 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) + } } } } @@ -292,6 +335,43 @@ internal fun PdfSelectionMenuPopup( } } +@Composable +private fun CommentItem(author: String?, text: String, depth: Int) { + val indentSize = (depth * 16).dp + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = indentSize, top = 4.dp, bottom = 4.dp) + ) { + if (depth > 0) { + Box( + modifier = Modifier + .width(2.dp) + .fillMaxHeight() + .background(MaterialTheme.colorScheme.outlineVariant) + ) + Spacer(modifier = Modifier.width(12.dp)) + } + + Column(modifier = Modifier.weight(1f)) { + if (!author.isNullOrBlank()) { + Text( + text = author, + style = MaterialTheme.typography.labelMedium, + color = if (depth > 0) MaterialTheme.colorScheme.secondary else MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold + ) + } + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface + ) + } + } +} + internal fun mergeRectsIntoLines(rects: List): List { if (rects.isEmpty()) return emptyList() 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 8897c2e..9badbc3 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfPageComposable.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfPageComposable.kt @@ -158,6 +158,17 @@ enum class InkType { PEN, HIGHLIGHTER, HIGHLIGHTER_ROUND, ERASER, FOUNTAIN_PEN, PENCIL, TEXT } +data class EmbeddedAnnotation( + val index: Int, + val subtype: Int, + val rect: android.graphics.RectF, + val contents: String?, + val author: String?, + val name: String?, // Unique ID + val inReplyTo: String?, // ID of parent + val replies: MutableList = mutableListOf() +) + data class PdfPoint(val x: Float, val y: Float, val timestamp: Long = 0L) data class PdfTile(val bitmap: Bitmap, val renderRect: Rect, val tileId: Int) @@ -811,6 +822,189 @@ internal fun PdfPageComposable( onHighlightLoading(false) } + @Suppress("VariableNeverRead") var embeddedAnnotations by remember { mutableStateOf>(emptyList()) } + var standardAnnotScreenRects by remember { mutableStateOf>>(emptyList()) } + + LaunchedEffect(pageIndex, pdfDocumentItem, actualBitmapWidthPx, actualBitmapHeightPx, virtualPage) { + if (!isPdfPage || actualBitmapWidthPx == 0 || actualBitmapHeightPx == 0) { + if (pageLinks.isNotEmpty()) pageLinks = emptyList() + if (standardAnnotScreenRects.isNotEmpty()) standardAnnotScreenRects = emptyList() + return@LaunchedEffect + } + + withContext(Dispatchers.IO) { + val allLinks = mutableListOf() + var finalDisplayList = emptyList() + var mappedAnnots = emptyList>() + val annotLink = 2 + + try { + pdfDocumentItem.openPage(pdfPageIndex).use { pageWrapper -> + + // 1. Extract Links (Method 1: Annotations) + try { + val annotationLinks = pageWrapper.getPageLinks() + if (annotationLinks.isNotEmpty()) { + val mappedAnnotationLinks = annotationLinks.mapNotNull { link -> + val uri = link.uri + val destPageIdx = link.destPageIdx + val bounds = link.bounds + + if (uri != null || (destPageIdx != null && destPageIdx >= 0)) { + val deviceRect = pageWrapper.mapRectToDevice( + startX = 0, startY = 0, + sizeX = actualBitmapWidthPx, sizeY = actualBitmapHeightPx, + rotate = currentPageRotation, coords = bounds + ) + if (deviceRect.width() > 0 && deviceRect.height() > 0) { + val tapRect = Rect( + deviceRect.left, deviceRect.top - linkVerticalPaddingPx, + deviceRect.right, deviceRect.bottom + linkVerticalPaddingPx + ) + PageLink(deviceRect, tapRect, uri, destPageIdx, LinkSource.ANNOTATION) + } else null + } else null + } + allLinks.addAll(mappedAnnotationLinks) + } + } catch (e: Exception) { + Timber.e(e, "Error fetching annotation links") + } + + // 2. Extract Links (Method 2: Text) + try { + pageWrapper.openTextPage().use { textPage -> + textPage.loadWebLink().use { webLinks -> + val webLinkCount = webLinks.countWebLinks() + for (linkIndex in 0 until webLinkCount) { + val rawUrl = webLinks.getURL(linkIndex, 2048) + val url = rawUrl?.substringBefore('\u0000') + if (url.isNullOrBlank()) continue + + val rectCount = webLinks.countRects(linkIndex) + for (rectIndex in 0 until rectCount) { + val pdfRect = webLinks.getRect(linkIndex, rectIndex) + val deviceRect = pageWrapper.mapRectToDevice( + 0, 0, actualBitmapWidthPx, actualBitmapHeightPx, + currentPageRotation, pdfRect + ) + if (deviceRect.width() > 0 && deviceRect.height() > 0) { + val tapRect = Rect( + deviceRect.left, deviceRect.top - linkVerticalPaddingPx, + deviceRect.right, deviceRect.bottom + linkVerticalPaddingPx + ) + allLinks.add(PageLink(deviceRect, tapRect, url, null, LinkSource.TEXT_CONTENT)) + } + } + } + } + } + } catch (e: Exception) { + Timber.e(e, "Error fetching web links") + } + + // 3. Extract Embedded Annotations + try { + val unlockedPage = pageWrapper.page + val pagePtr = try { + val field = unlockedPage.javaClass.getDeclaredField("pagePtr") + field.isAccessible = true + field.get(unlockedPage) as Long + } catch (e: Exception) { + val field = unlockedPage.javaClass.getDeclaredField("mNativePage") + field.isAccessible = true + field.get(unlockedPage) as Long + } + + val count = NativePdfiumBridge.getAnnotCount(pagePtr) + Timber.tag("PdfCommentDebug").d("Page $pageIndex: Total Annotations found = $count") + if (count > 0) { + val allAnnots = (0 until count).mapNotNull { i -> + val subtype = NativePdfiumBridge.getAnnotSubtype(pagePtr, i) + if (subtype == annotLink) return@mapNotNull null // skip links here + + val contents = NativePdfiumBridge.getAnnotString(pagePtr, i, "Contents") + val name = NativePdfiumBridge.getAnnotString(pagePtr, i, "NM") + val irt = NativePdfiumBridge.getAnnotString(pagePtr, i, "IRT") + val author = NativePdfiumBridge.getAnnotString(pagePtr, i, "T") + + val pdfRectArray = NativePdfiumBridge.getAnnotRect(pagePtr, i) + val pdfRectF = if (pdfRectArray != null) { + android.graphics.RectF(pdfRectArray[0], pdfRectArray[3], pdfRectArray[2], pdfRectArray[1]) + } else android.graphics.RectF() + + Timber.tag("PdfCommentDebug").v("Extracted Annot[$i]: Name=$name, IRT=$irt, Subtype=$subtype, Text=${contents?.take(10)}...") + + EmbeddedAnnotation(i, subtype, pdfRectF, contents, author, name, irt) + } + + val annotMap = allAnnots.associateBy { it.name } + val orphans = mutableListOf() + + allAnnots.forEach { annot -> + if (!annot.inReplyTo.isNullOrBlank() && annotMap.containsKey(annot.inReplyTo)) { + Timber.tag("PdfCommentDebug").i("Linking: ${annot.name} is a reply to ${annot.inReplyTo}") + annotMap[annot.inReplyTo]?.replies?.add(annot) + } else { + orphans.add(annot) + } + } + + Timber.tag("PdfCommentDebug").d("After ID linking: Orphans count = ${orphans.size}") + + val groupedRoots = mutableListOf>() + orphans.forEach { annot -> + val match = groupedRoots.find { group -> + val root = group.first() + val inflatedRoot = android.graphics.RectF(root.rect).apply { inset(-10f, -10f) } + android.graphics.RectF.intersects(inflatedRoot, annot.rect) + } + if (match != null) { + Timber.tag("PdfCommentDebug").w("Geometric grouping triggered for ${annot.name} with ${match.first().name}. This might flatten nested replies!") + match.add(annot) + } else { + groupedRoots.add(mutableListOf(annot)) + } + } + + val rootsWithReplies = groupedRoots.map { group -> + val root = group.first() + if (group.size > 1) { + root.replies.addAll(group.drop(1)) + } + root + } + + finalDisplayList = rootsWithReplies.filter { + !it.contents.isNullOrBlank() || it.replies.any { r -> !r.contents.isNullOrBlank() } + } + + mappedAnnots = finalDisplayList.map { annot -> + val screenRect = pageWrapper.mapRectToDevice( + 0, 0, actualBitmapWidthPx, actualBitmapHeightPx, + currentPageRotation, annot.rect + ) + annot to screenRect + } + } + } catch (e: Exception) { + Timber.tag("PdfCommentDebug").e(e, "Error extracting annotations") + } + } + } catch (e: Exception) { + if (e !is kotlinx.coroutines.CancellationException) { + Timber.e(e, "Failed to load links and annotations for page $pdfPageIndex") + } + } + + withContext(Dispatchers.Main) { + pageLinks = allLinks + embeddedAnnotations = finalDisplayList + standardAnnotScreenRects = mappedAnnots + } + } + } + LaunchedEffect(pageIndex, pdfDocumentItem, actualBitmapWidthPx, actualBitmapHeightPx, virtualPage) { if (!isPdfPage) { if (pageLinks.isNotEmpty()) pageLinks = emptyList() @@ -1123,10 +1317,14 @@ internal fun PdfPageComposable( ) val newTile = PdfTile(tileBitmap, tileRect, tileId) - withContext(Dispatchers.Main) { - if (isActive) { + var handedOver = false + try { + withContext(Dispatchers.Main) { tiles = tiles + newTile - } else { + handedOver = true + } + } finally { + if (!handedOver) { PdfBitmapPool.recycle(tileBitmap) } } @@ -1903,7 +2101,6 @@ internal fun PdfPageComposable( if (selectionMethodUsed == PdfSelectionMethod.PDFIUM) { if (selectionCharRange.value != null && selectedWordScreenRects.isNotEmpty()) { val currentRange = selectionCharRange.value!! - val firstRect = selectedWordScreenRects.first() coroutineScope.launch { var pageForMenu: PdfPageKt? = null var textPageForMenu: PdfTextPageKt? = null @@ -1917,9 +2114,12 @@ internal fun PdfPageComposable( currentRange.second - currentRange.first ) if (!text.isNullOrBlank()) { + val combinedRect = Rect(selectedWordScreenRects.first()) + selectedWordScreenRects.forEach { combinedRect.union(it) } + customMenuState = CustomPdfMenuState( selectedText = text, - anchorRect = firstRect, + anchorRect = combinedRect, charRange = currentRange ) Timber.d( @@ -1974,9 +2174,12 @@ internal fun PdfPageComposable( } } val firstRect = selectedSymbolInfos.first().symbol.boundingBox!! + val combinedRect = Rect(firstRect) + selectedSymbolInfos.forEach { info -> info.symbol.boundingBox?.let { combinedRect.union(it) } } + customMenuState = CustomPdfMenuState( selectedText = selectedText, - anchorRect = firstRect, + anchorRect = combinedRect, charRange = Pair(-1, -1) ) Timber.d( @@ -2077,11 +2280,13 @@ internal fun PdfPageComposable( currentRange.first, currentRange.second - currentRange.first ) - val firstRect = selectedWordScreenRects.first() if (!text.isNullOrBlank()) { + val combinedRect = Rect(selectedWordScreenRects.first()) + selectedWordScreenRects.forEach { combinedRect.union(it) } + customMenuState = CustomPdfMenuState( selectedText = text, - anchorRect = firstRect, + anchorRect = combinedRect, charRange = currentRange ) pdfiumSelectionSuccessful = true @@ -2169,11 +2374,12 @@ internal fun PdfPageComposable( ocrSelectionSymbolIndices ) - val menuAnchorContentRect = - foundElement.boundingBox!! + val combinedRect = Rect(selectedWordScreenRects.first()) + selectedWordScreenRects.forEach { combinedRect.union(it) } + customMenuState = CustomPdfMenuState( selectedText = foundElement.text, - anchorRect = menuAnchorContentRect, + anchorRect = combinedRect, charRange = Pair( -1, -1 ) @@ -2293,10 +2499,11 @@ internal fun PdfPageComposable( val tapXInBitmap = tapInContentCoords.x val tapYInBitmap = tapInContentCoords.y + val annotHitTolerance = with(density) { 24.dp.toPx() } / inputScale val hitTolerance = with(density) { 16.dp.toPx() } / inputScale Timber.d( - "detectTapGestures: Tap at bitmap coords (${tapXInBitmap.toInt()}, ${tapYInBitmap.toInt()}) with tolerance $hitTolerance" + "detectTapGestures: Tap at bitmap coords (${tapXInBitmap.toInt()}, ${tapYInBitmap.toInt()})" ) var tappedRect: Rect? = null @@ -2316,18 +2523,38 @@ internal fun PdfPageComposable( } else false } + val standardHit = standardAnnotScreenRects.findLast { (_, screenRect) -> + val inflatedHitBox = Rect( + (screenRect.left - annotHitTolerance).toInt(), + (screenRect.top - annotHitTolerance).toInt(), + (screenRect.right + annotHitTolerance).toInt(), + (screenRect.bottom + annotHitTolerance).toInt() + ) + inflatedHitBox.contains(tapInContentCoords.x.toInt(), tapInContentCoords.y.toInt()) + } + + if (standardHit != null) { + val (annot, screenRect) = standardHit + customMenuState = CustomPdfMenuState( + selectedText = annot.contents ?: "No comment", + anchorRect = screenRect, + charRange = Pair(-1, -1), + isComment = true, + author = annot.author, + annotation = annot + ) + return@detectTapGestures + } + if (hitHighlightPair != null && tappedRect != null) { val hitHighlight = hitHighlightPair.first - val anchorRect = android.graphics.Rect( - tappedRect.left, - tappedRect.top, - tappedRect.right, - tappedRect.bottom - ) + + val combinedRect = Rect(hitHighlightPair.second.first()) + hitHighlightPair.second.forEach { combinedRect.union(it) } customMenuState = CustomPdfMenuState( selectedText = hitHighlight.text, - anchorRect = anchorRect, + anchorRect = combinedRect, charRange = hitHighlight.range, isExistingHighlight = true, highlightId = hitHighlight.id @@ -3129,6 +3356,7 @@ internal fun PdfPageComposable( } coroutineScope.launch { + var localBitmap: Bitmap? = null try { val renderResult = withContext(Dispatchers.IO) { val rawPageCount = pdfDocumentItem.getPageCount() @@ -3172,8 +3400,12 @@ internal fun PdfPageComposable( "Rendering page $pageIndex at ${scaledWidth}x${scaledHeight}" ) val newBitmap = createBitmap(scaledWidth, scaledHeight) + localBitmap = newBitmap page.renderPageBitmap( - newBitmap, 0, 0, scaledWidth, scaledHeight, false + newBitmap, + 0, 0, + scaledWidth, scaledHeight, + true ) page.close() @@ -3190,6 +3422,7 @@ internal fun PdfPageComposable( val old = bitmapState bitmapState = newBitmap + localBitmap = null // Handed over successfully currentRenderedPageId = targetPageId withContext(Dispatchers.IO) { @@ -3214,6 +3447,7 @@ internal fun PdfPageComposable( pageErrorMessage = "Error processing page: ${e.localizedMessage}" } finally { isLoadingPage = false + localBitmap?.recycle() } } } @@ -3467,9 +3701,11 @@ internal fun PdfPageComposable( val fullText = textPage.textPageGetText(0, charCount) if (!fullText.isNullOrBlank()) { + val combinedRect = Rect(selectedWordScreenRects.first()) + selectedWordScreenRects.forEach { combinedRect.union(it) } customMenuState = CustomPdfMenuState( selectedText = fullText, - anchorRect = selectedWordScreenRects.first(), + anchorRect = combinedRect, charRange = selectionCharRange.value!! ) } @@ -3507,9 +3743,11 @@ internal fun PdfPageComposable( } } if (fullText.isNotBlank()) { + val combinedRect = Rect(selectedWordScreenRects.first()) + selectedWordScreenRects.forEach { combinedRect.union(it) } customMenuState = CustomPdfMenuState( selectedText = fullText, - anchorRect = selectedWordScreenRects.first(), + anchorRect = combinedRect, charRange = Pair(-1, -1) ) } @@ -4612,58 +4850,49 @@ private fun PdfPageRenderer( } menuState?.let { state -> if (state.anchorRect.width() > 0 || state.anchorRect.height() > 0) { - val popupPositionProvider = - remember(state.anchorRect, density, offset, scale, layoutCoordinates) { - object : PopupPositionProvider { - override fun calculatePosition( - anchorBounds: IntRect, - windowSize: IntSize, - layoutDirection: LayoutDirection, - popupContentSize: IntSize - ): IntOffset { - val coords = layoutCoordinates ?: return IntOffset.Zero - val menuAnchorContentRect = state.anchorRect - val topLeftLocal = contentToScreenCoordinates( - Offset( - menuAnchorContentRect.left.toFloat(), - menuAnchorContentRect.top.toFloat() - ) - ) - val bottomRightLocal = contentToScreenCoordinates( - Offset( - menuAnchorContentRect.right.toFloat(), - menuAnchorContentRect.bottom.toFloat() - ) - ) - val topLeftWindow = coords.localToWindow(topLeftLocal) - val bottomRightWindow = coords.localToWindow(bottomRightLocal) + val popupPositionProvider = remember(state.anchorRect, density, offset, scale, layoutCoordinates) { + object : PopupPositionProvider { + override fun calculatePosition( + anchorBounds: IntRect, + windowSize: IntSize, + layoutDirection: LayoutDirection, + popupContentSize: IntSize + ): IntOffset { + val coords = layoutCoordinates ?: return IntOffset.Zero - val windowCenterX = (topLeftWindow.x + bottomRightWindow.x) / 2 - val windowTopY = topLeftWindow.y - val windowBottomY = bottomRightWindow.y - val xInWindow = windowCenterX - popupContentSize.width / 2 - var yInWindow = - windowTopY - popupContentSize.height - with(density) { 8.dp.toPx() } + // Map the bitmap-space anchor (the icon) to window-space + val topLeftLocal = contentToScreenCoordinates(Offset(state.anchorRect.left.toFloat(), state.anchorRect.top.toFloat())) + val bottomRightLocal = contentToScreenCoordinates(Offset(state.anchorRect.right.toFloat(), state.anchorRect.bottom.toFloat())) - if (yInWindow < 0) { - yInWindow = windowBottomY + with(density) { 8.dp.toPx() } + val topLeftWindow = coords.localToWindow(topLeftLocal) + val bottomRightWindow = coords.localToWindow(bottomRightLocal) + + val windowCenterX = (topLeftWindow.x + bottomRightWindow.x) / 2 + val gapPx = with(density) { 16.dp.toPx() } // Increased gap + + // Try placing ABOVE the icon first + var yInWindow = (topLeftWindow.y - popupContentSize.height - gapPx).toInt() + + if (yInWindow < 0) { + yInWindow = (bottomRightWindow.y + gapPx).toInt() + // Ensure it doesn't get pushed out of the bottom boundary either + if (yInWindow + popupContentSize.height > windowSize.height) { + yInWindow = windowSize.height - popupContentSize.height - gapPx.toInt() } - - val finalX = xInWindow.toInt().coerceIn( - 0, windowSize.width - popupContentSize.width - ) - val finalY = yInWindow.toInt().coerceIn( - 0, windowSize.height - popupContentSize.height - ) - - return IntOffset(finalX, finalY) } + + val xInWindow = (windowCenterX - popupContentSize.width / 2).toInt() + .coerceIn(0, windowSize.width - popupContentSize.width) + + return IntOffset(xInWindow, yInWindow) } } + } PdfSelectionMenuPopup( menuState = state, popupPositionProvider = popupPositionProvider, + onDismiss = onMenuDismiss, onCopy = onCopy, onAiDefine = onAiDefine, onSelectAll = onSelectAll,