Render embedded annotations in PDF (#59)

* Added support for reading and displaying embedded PDF annotations.

Changes include:
- Added JNI bindings in `NativePdfiumBridge` and `pdfium_bridge.cpp` to extract annotation count, subtypes, rectangles, and string values (Contents, NM, IRT, T) using PDFium.
- Introduced `EmbeddedAnnotation` data class to represent PDF annotations and their reply threads.
- Implemented annotation extraction logic in `PdfPageComposable` to fetch, group, and map embedded annotations (Sticky Notes, Highlights, etc.) to screen coordinates.
- Updated `PdfSelectionMenuPopup` to support displaying and copying comment threads.
- Improved selection menu positioning and hit detection for annotations.
- Added a safety check to recycle bitmaps in `PdfTile` rendering if handover to the main thread fails.

* Improved comment thread UI
This commit is contained in:
Aryan 2026-03-12 10:11:42 +05:30 committed by GitHub
parent 06ec45504f
commit 8582ed9679
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 585 additions and 159 deletions

View file

@ -1,19 +1,41 @@
#include <jni.h>
#include <dlfcn.h>
#include <android/log.h>
#include <vector>
#include <string>
#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
@ -86,3 +126,70 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_getPageFontFlags(JNIEnv *env, jclas
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<void*>(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<void*>(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<void*>(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<void*>(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<unsigned short> buffer(len / 2);
get_annot_string_func(parentAnnot, "NM", buffer.data(), len);
result = env->NewString(reinterpret_cast<const jchar*>(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<unsigned short> buffer(len / 2);
get_annot_string_func(annot, nativeKey, buffer.data(), len);
jstring result = env->NewString(reinterpret_cast<const jchar*>(buffer.data()), (jsize)(buffer.size() - 1));
env->ReleaseStringUTFChars(key, nativeKey);
return result;
}

View file

@ -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
}

View file

@ -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<Int, Int>,
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<EmbeddedAnnotation>, 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,57 +194,79 @@ 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 = if (menuState.isComment) Modifier.fillMaxWidth() else Modifier.width(IntrinsicSize.Max)) {
if (menuState.isComment) {
Column(
modifier = Modifier.width(IntrinsicSize.Max)
) {
// Color Row
Row(
modifier = Modifier
.padding(vertical = 12.dp, horizontal = 12.dp)
.padding(16.dp)
.heightIn(max = 400.dp)
.verticalScroll(rememberScrollState())
) {
CommentItem(author = menuState.author, text = menuState.selectedText, depth = 0)
menuState.annotation?.replies?.let { CommentThread(it, 1) }
}
HorizontalDivider()
TextButton(
onClick = {
val fullText = buildString {
append("${menuState.author ?: "Unknown"}: ${menuState.selectedText}\n")
fun appendReplies(replies: List<EmbeddedAnnotation>, 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()
) {
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)
modifier = Modifier.padding(horizontal = 6.dp).size(32.dp)
.background(colorEnum.color, CircleShape).clip(CircleShape)
.clickable {
Timber.tag("PdfHighlightDebug").d("Color box clicked: $colorEnum")
Timber.tag("PdfHighlightDebug")
.d("Color box clicked: $colorEnum")
onColorSelected(colorEnum)
}
)
})
}
}
HorizontalDivider()
// Delete Option (Only for existing)
if (menuState.isExistingHighlight) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onDelete() }
Row(modifier = Modifier.fillMaxWidth().clickable { onDelete() }
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = "Remove",
@ -233,33 +282,28 @@ internal fun PdfSelectionMenuPopup(
HorizontalDivider()
}
// Standard Options
Row(
modifier = Modifier.fillMaxWidth()
) {
// Copy
Box(
modifier = Modifier
.weight(1f)
.clickable { onCopy(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(Icons.Default.CopyAll, contentDescription = null, modifier = Modifier.size(20.dp))
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)
modifier = Modifier.weight(1f)
.clickable { onAiDefine(menuState.selectedText) }
.padding(vertical = 12.dp),
contentAlignment = Alignment.Center
) {
.padding(vertical = 12.dp), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
painter = painterResource(id = R.drawable.dictionary),
@ -271,17 +315,15 @@ internal fun PdfSelectionMenuPopup(
}
}
// Select All (Only for new selection)
if (!menuState.isExistingHighlight) {
Box(
modifier = Modifier
.weight(1f)
.clickable { onSelectAll() }
.padding(vertical = 12.dp),
contentAlignment = Alignment.Center
) {
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))
Icon(
painter = painterResource(id = R.drawable.select_all),
contentDescription = null,
modifier = Modifier.size(20.dp)
)
Text("Select All", style = MaterialTheme.typography.labelSmall)
}
}
@ -291,6 +333,44 @@ 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<Rect>): List<Rect> {
if (rects.isEmpty()) return emptyList()

View file

@ -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<EmbeddedAnnotation> = 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<List<EmbeddedAnnotation>>(emptyList()) }
var standardAnnotScreenRects by remember { mutableStateOf<List<Pair<EmbeddedAnnotation, Rect>>>(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<PageLink>()
var finalDisplayList = emptyList<EmbeddedAnnotation>()
var mappedAnnots = emptyList<Pair<EmbeddedAnnotation, Rect>>()
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<EmbeddedAnnotation>()
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<MutableList<EmbeddedAnnotation>>()
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)
var handedOver = false
try {
withContext(Dispatchers.Main) {
if (isActive) {
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,8 +4850,7 @@ private fun PdfPageRenderer(
}
menuState?.let { state ->
if (state.anchorRect.width() > 0 || state.anchorRect.height() > 0) {
val popupPositionProvider =
remember(state.anchorRect, density, offset, scale, layoutCoordinates) {
val popupPositionProvider = remember(state.anchorRect, density, offset, scale, layoutCoordinates) {
object : PopupPositionProvider {
override fun calculatePosition(
anchorBounds: IntRect,
@ -4622,41 +4859,32 @@ private fun PdfPageRenderer(
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()
)
)
// Map the bitmap-space anchor (the icon) to window-space
val topLeftLocal = contentToScreenCoordinates(Offset(state.anchorRect.left.toFloat(), state.anchorRect.top.toFloat()))
val bottomRightLocal = contentToScreenCoordinates(Offset(state.anchorRect.right.toFloat(), state.anchorRect.bottom.toFloat()))
val topLeftWindow = coords.localToWindow(topLeftLocal)
val bottomRightWindow = coords.localToWindow(bottomRightLocal)
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() }
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 = windowBottomY + with(density) { 8.dp.toPx() }
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
)
val xInWindow = (windowCenterX - popupContentSize.width / 2).toInt()
.coerceIn(0, windowSize.width - popupContentSize.width)
return IntOffset(finalX, finalY)
return IntOffset(xInWindow, yInWindow)
}
}
}
@ -4664,6 +4892,7 @@ private fun PdfPageRenderer(
PdfSelectionMenuPopup(
menuState = state,
popupPositionProvider = popupPositionProvider,
onDismiss = onMenuDismiss,
onCopy = onCopy,
onAiDefine = onAiDefine,
onSelectAll = onSelectAll,