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 <jni.h>
#include <dlfcn.h> #include <dlfcn.h>
#include <android/log.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 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 double (*FPDFText_GetFontSize_t)(void* text_page, int index);
typedef int (*FPDFText_GetFontWeight_t)(void* text_page, int index); typedef int (*FPDFText_GetFontWeight_t)(void* text_page, int index);
typedef int (*FPDFText_GetFontInfo_t)(void* text_page, int index, void* buffer, unsigned long buflen, int* flags); typedef int (*FPDFText_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 void* pdfium_handle = nullptr;
static FPDFText_GetFontSize_t get_font_size_func = nullptr; static FPDFText_GetFontSize_t get_font_size_func = nullptr;
static FPDFText_GetFontWeight_t get_font_weight_func = nullptr; static FPDFText_GetFontWeight_t get_font_weight_func = nullptr;
static FPDFText_GetFontInfo_t get_font_info_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() { static bool init_pdfium() {
if (pdfium_handle) return true; 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_weight_func = (FPDFText_GetFontWeight_t) dlsym(pdfium_handle, "FPDFText_GetFontWeight");
get_font_info_func = (FPDFText_GetFontInfo_t) dlsym(pdfium_handle, "FPDFText_GetFontInfo"); 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 extern "C" JNIEXPORT jdouble JNICALL
@ -86,3 +126,70 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_getPageFontFlags(JNIEnv *env, jclas
delete[] fill; delete[] fill;
return result; 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 getPageFontSizes(textPagePtr: Long, count: Int): FloatArray?
@JvmStatic external fun getPageFontWeights(textPagePtr: Long, count: Int): IntArray? @JvmStatic external fun getPageFontWeights(textPagePtr: Long, count: Int): IntArray?
@JvmStatic external fun getPageFontFlags(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.Column
import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row 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.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width 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.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CopyAll import androidx.compose.material.icons.filled.CopyAll
import androidx.compose.material.icons.filled.Delete 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.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Popup import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupPositionProvider import androidx.compose.ui.window.PopupPositionProvider
@ -99,7 +107,10 @@ internal data class CustomPdfMenuState(
val anchorRect: Rect, val anchorRect: Rect,
val charRange: Pair<Int, Int>, val charRange: Pair<Int, Int>,
val isExistingHighlight: Boolean = false, 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 { 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 @Composable
internal fun PdfSelectionMenuPopup( internal fun PdfSelectionMenuPopup(
menuState: CustomPdfMenuState, menuState: CustomPdfMenuState,
popupPositionProvider: PopupPositionProvider, popupPositionProvider: PopupPositionProvider,
onDismiss: () -> Unit,
onCopy: (String) -> Unit, onCopy: (String) -> Unit,
onAiDefine: (String) -> Unit, onAiDefine: (String) -> Unit,
onSelectAll: () -> Unit, onSelectAll: () -> Unit,
@ -167,57 +194,79 @@ internal fun PdfSelectionMenuPopup(
) { ) {
Popup( Popup(
popupPositionProvider = popupPositionProvider, popupPositionProvider = popupPositionProvider,
onDismissRequest = null, onDismissRequest = onDismiss,
properties = PopupProperties( properties = PopupProperties(
focusable = false, focusable = true,
dismissOnClickOutside = false, dismissOnClickOutside = true,
dismissOnBackPress = false dismissOnBackPress = true
) )
) { ) {
Surface( Surface(
shape = RoundedCornerShape(12.dp), shape = RoundedCornerShape(12.dp),
shadowElevation = 6.dp, shadowElevation = 8.dp,
color = MaterialTheme.colorScheme.surface, 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( Column(
modifier = Modifier.width(IntrinsicSize.Max)
) {
// Color Row
Row(
modifier = Modifier 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(), .fillMaxWidth(),
horizontalArrangement = Arrangement.Center, horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
PdfHighlightColor.entries.forEach { colorEnum -> PdfHighlightColor.entries.forEach { colorEnum ->
Box( Box(
modifier = Modifier modifier = Modifier.padding(horizontal = 6.dp).size(32.dp)
.padding(horizontal = 6.dp) .background(colorEnum.color, CircleShape).clip(CircleShape)
.size(32.dp)
.background(colorEnum.color, CircleShape)
.clip(CircleShape)
.clickable { .clickable {
Timber.tag("PdfHighlightDebug").d("Color box clicked: $colorEnum") Timber.tag("PdfHighlightDebug")
.d("Color box clicked: $colorEnum")
onColorSelected(colorEnum) onColorSelected(colorEnum)
} })
)
} }
} }
HorizontalDivider() HorizontalDivider()
// Delete Option (Only for existing)
if (menuState.isExistingHighlight) { if (menuState.isExistingHighlight) {
Row( Row(modifier = Modifier.fillMaxWidth().clickable { onDelete() }
modifier = Modifier
.fillMaxWidth()
.clickable { onDelete() }
.padding(horizontal = 16.dp, vertical = 12.dp), .padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp) horizontalArrangement = Arrangement.spacedBy(12.dp)) {
) {
Icon( Icon(
imageVector = Icons.Default.Delete, imageVector = Icons.Default.Delete,
contentDescription = "Remove", contentDescription = "Remove",
@ -233,33 +282,28 @@ internal fun PdfSelectionMenuPopup(
HorizontalDivider() HorizontalDivider()
} }
// Standard Options
Row( Row(
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
// Copy
Box( Box(
modifier = Modifier modifier = Modifier.weight(1f)
.weight(1f) .clickable { onCopy(menuState.selectedText) }.padding(vertical = 12.dp),
.clickable { onCopy(menuState.selectedText) } contentAlignment = Alignment.Center) {
.padding(vertical = 12.dp),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) { 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) Text("Copy", style = MaterialTheme.typography.labelSmall)
} }
} }
// Dictionary
if (menuState.selectedText.length <= 2000) { if (menuState.selectedText.length <= 2000) {
Box( Box(
modifier = Modifier modifier = Modifier.weight(1f)
.weight(1f)
.clickable { onAiDefine(menuState.selectedText) } .clickable { onAiDefine(menuState.selectedText) }
.padding(vertical = 12.dp), .padding(vertical = 12.dp), contentAlignment = Alignment.Center) {
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) { Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon( Icon(
painter = painterResource(id = R.drawable.dictionary), painter = painterResource(id = R.drawable.dictionary),
@ -271,17 +315,15 @@ internal fun PdfSelectionMenuPopup(
} }
} }
// Select All (Only for new selection)
if (!menuState.isExistingHighlight) { if (!menuState.isExistingHighlight) {
Box( Box(modifier = Modifier.weight(1f).clickable { onSelectAll() }
modifier = Modifier .padding(vertical = 12.dp), contentAlignment = Alignment.Center) {
.weight(1f)
.clickable { onSelectAll() }
.padding(vertical = 12.dp),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) { 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) 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> { internal fun mergeRectsIntoLines(rects: List<Rect>): List<Rect> {
if (rects.isEmpty()) return emptyList() if (rects.isEmpty()) return emptyList()

View file

@ -158,6 +158,17 @@ enum class InkType {
PEN, HIGHLIGHTER, HIGHLIGHTER_ROUND, ERASER, FOUNTAIN_PEN, PENCIL, TEXT 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 PdfPoint(val x: Float, val y: Float, val timestamp: Long = 0L)
data class PdfTile(val bitmap: Bitmap, val renderRect: Rect, val tileId: Int) data class PdfTile(val bitmap: Bitmap, val renderRect: Rect, val tileId: Int)
@ -811,6 +822,189 @@ internal fun PdfPageComposable(
onHighlightLoading(false) 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) { LaunchedEffect(pageIndex, pdfDocumentItem, actualBitmapWidthPx, actualBitmapHeightPx, virtualPage) {
if (!isPdfPage) { if (!isPdfPage) {
if (pageLinks.isNotEmpty()) pageLinks = emptyList() if (pageLinks.isNotEmpty()) pageLinks = emptyList()
@ -1123,10 +1317,14 @@ internal fun PdfPageComposable(
) )
val newTile = PdfTile(tileBitmap, tileRect, tileId) val newTile = PdfTile(tileBitmap, tileRect, tileId)
var handedOver = false
try {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
if (isActive) {
tiles = tiles + newTile tiles = tiles + newTile
} else { handedOver = true
}
} finally {
if (!handedOver) {
PdfBitmapPool.recycle(tileBitmap) PdfBitmapPool.recycle(tileBitmap)
} }
} }
@ -1903,7 +2101,6 @@ internal fun PdfPageComposable(
if (selectionMethodUsed == PdfSelectionMethod.PDFIUM) { if (selectionMethodUsed == PdfSelectionMethod.PDFIUM) {
if (selectionCharRange.value != null && selectedWordScreenRects.isNotEmpty()) { if (selectionCharRange.value != null && selectedWordScreenRects.isNotEmpty()) {
val currentRange = selectionCharRange.value!! val currentRange = selectionCharRange.value!!
val firstRect = selectedWordScreenRects.first()
coroutineScope.launch { coroutineScope.launch {
var pageForMenu: PdfPageKt? = null var pageForMenu: PdfPageKt? = null
var textPageForMenu: PdfTextPageKt? = null var textPageForMenu: PdfTextPageKt? = null
@ -1917,9 +2114,12 @@ internal fun PdfPageComposable(
currentRange.second - currentRange.first currentRange.second - currentRange.first
) )
if (!text.isNullOrBlank()) { if (!text.isNullOrBlank()) {
val combinedRect = Rect(selectedWordScreenRects.first())
selectedWordScreenRects.forEach { combinedRect.union(it) }
customMenuState = CustomPdfMenuState( customMenuState = CustomPdfMenuState(
selectedText = text, selectedText = text,
anchorRect = firstRect, anchorRect = combinedRect,
charRange = currentRange charRange = currentRange
) )
Timber.d( Timber.d(
@ -1974,9 +2174,12 @@ internal fun PdfPageComposable(
} }
} }
val firstRect = selectedSymbolInfos.first().symbol.boundingBox!! val firstRect = selectedSymbolInfos.first().symbol.boundingBox!!
val combinedRect = Rect(firstRect)
selectedSymbolInfos.forEach { info -> info.symbol.boundingBox?.let { combinedRect.union(it) } }
customMenuState = CustomPdfMenuState( customMenuState = CustomPdfMenuState(
selectedText = selectedText, selectedText = selectedText,
anchorRect = firstRect, anchorRect = combinedRect,
charRange = Pair(-1, -1) charRange = Pair(-1, -1)
) )
Timber.d( Timber.d(
@ -2077,11 +2280,13 @@ internal fun PdfPageComposable(
currentRange.first, currentRange.first,
currentRange.second - currentRange.first currentRange.second - currentRange.first
) )
val firstRect = selectedWordScreenRects.first()
if (!text.isNullOrBlank()) { if (!text.isNullOrBlank()) {
val combinedRect = Rect(selectedWordScreenRects.first())
selectedWordScreenRects.forEach { combinedRect.union(it) }
customMenuState = CustomPdfMenuState( customMenuState = CustomPdfMenuState(
selectedText = text, selectedText = text,
anchorRect = firstRect, anchorRect = combinedRect,
charRange = currentRange charRange = currentRange
) )
pdfiumSelectionSuccessful = true pdfiumSelectionSuccessful = true
@ -2169,11 +2374,12 @@ internal fun PdfPageComposable(
ocrSelectionSymbolIndices ocrSelectionSymbolIndices
) )
val menuAnchorContentRect = val combinedRect = Rect(selectedWordScreenRects.first())
foundElement.boundingBox!! selectedWordScreenRects.forEach { combinedRect.union(it) }
customMenuState = CustomPdfMenuState( customMenuState = CustomPdfMenuState(
selectedText = foundElement.text, selectedText = foundElement.text,
anchorRect = menuAnchorContentRect, anchorRect = combinedRect,
charRange = Pair( charRange = Pair(
-1, -1 -1, -1
) )
@ -2293,10 +2499,11 @@ internal fun PdfPageComposable(
val tapXInBitmap = tapInContentCoords.x val tapXInBitmap = tapInContentCoords.x
val tapYInBitmap = tapInContentCoords.y val tapYInBitmap = tapInContentCoords.y
val annotHitTolerance = with(density) { 24.dp.toPx() } / inputScale
val hitTolerance = with(density) { 16.dp.toPx() } / inputScale val hitTolerance = with(density) { 16.dp.toPx() } / inputScale
Timber.d( 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 var tappedRect: Rect? = null
@ -2316,18 +2523,38 @@ internal fun PdfPageComposable(
} else false } 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) { if (hitHighlightPair != null && tappedRect != null) {
val hitHighlight = hitHighlightPair.first val hitHighlight = hitHighlightPair.first
val anchorRect = android.graphics.Rect(
tappedRect.left, val combinedRect = Rect(hitHighlightPair.second.first())
tappedRect.top, hitHighlightPair.second.forEach { combinedRect.union(it) }
tappedRect.right,
tappedRect.bottom
)
customMenuState = CustomPdfMenuState( customMenuState = CustomPdfMenuState(
selectedText = hitHighlight.text, selectedText = hitHighlight.text,
anchorRect = anchorRect, anchorRect = combinedRect,
charRange = hitHighlight.range, charRange = hitHighlight.range,
isExistingHighlight = true, isExistingHighlight = true,
highlightId = hitHighlight.id highlightId = hitHighlight.id
@ -3129,6 +3356,7 @@ internal fun PdfPageComposable(
} }
coroutineScope.launch { coroutineScope.launch {
var localBitmap: Bitmap? = null
try { try {
val renderResult = withContext(Dispatchers.IO) { val renderResult = withContext(Dispatchers.IO) {
val rawPageCount = pdfDocumentItem.getPageCount() val rawPageCount = pdfDocumentItem.getPageCount()
@ -3172,8 +3400,12 @@ internal fun PdfPageComposable(
"Rendering page $pageIndex at ${scaledWidth}x${scaledHeight}" "Rendering page $pageIndex at ${scaledWidth}x${scaledHeight}"
) )
val newBitmap = createBitmap(scaledWidth, scaledHeight) val newBitmap = createBitmap(scaledWidth, scaledHeight)
localBitmap = newBitmap
page.renderPageBitmap( page.renderPageBitmap(
newBitmap, 0, 0, scaledWidth, scaledHeight, false newBitmap,
0, 0,
scaledWidth, scaledHeight,
true
) )
page.close() page.close()
@ -3190,6 +3422,7 @@ internal fun PdfPageComposable(
val old = bitmapState val old = bitmapState
bitmapState = newBitmap bitmapState = newBitmap
localBitmap = null // Handed over successfully
currentRenderedPageId = targetPageId currentRenderedPageId = targetPageId
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
@ -3214,6 +3447,7 @@ internal fun PdfPageComposable(
pageErrorMessage = "Error processing page: ${e.localizedMessage}" pageErrorMessage = "Error processing page: ${e.localizedMessage}"
} finally { } finally {
isLoadingPage = false isLoadingPage = false
localBitmap?.recycle()
} }
} }
} }
@ -3467,9 +3701,11 @@ internal fun PdfPageComposable(
val fullText = val fullText =
textPage.textPageGetText(0, charCount) textPage.textPageGetText(0, charCount)
if (!fullText.isNullOrBlank()) { if (!fullText.isNullOrBlank()) {
val combinedRect = Rect(selectedWordScreenRects.first())
selectedWordScreenRects.forEach { combinedRect.union(it) }
customMenuState = CustomPdfMenuState( customMenuState = CustomPdfMenuState(
selectedText = fullText, selectedText = fullText,
anchorRect = selectedWordScreenRects.first(), anchorRect = combinedRect,
charRange = selectionCharRange.value!! charRange = selectionCharRange.value!!
) )
} }
@ -3507,9 +3743,11 @@ internal fun PdfPageComposable(
} }
} }
if (fullText.isNotBlank()) { if (fullText.isNotBlank()) {
val combinedRect = Rect(selectedWordScreenRects.first())
selectedWordScreenRects.forEach { combinedRect.union(it) }
customMenuState = CustomPdfMenuState( customMenuState = CustomPdfMenuState(
selectedText = fullText, selectedText = fullText,
anchorRect = selectedWordScreenRects.first(), anchorRect = combinedRect,
charRange = Pair(-1, -1) charRange = Pair(-1, -1)
) )
} }
@ -4612,8 +4850,7 @@ private fun PdfPageRenderer(
} }
menuState?.let { state -> menuState?.let { state ->
if (state.anchorRect.width() > 0 || state.anchorRect.height() > 0) { if (state.anchorRect.width() > 0 || state.anchorRect.height() > 0) {
val popupPositionProvider = val popupPositionProvider = remember(state.anchorRect, density, offset, scale, layoutCoordinates) {
remember(state.anchorRect, density, offset, scale, layoutCoordinates) {
object : PopupPositionProvider { object : PopupPositionProvider {
override fun calculatePosition( override fun calculatePosition(
anchorBounds: IntRect, anchorBounds: IntRect,
@ -4622,41 +4859,32 @@ private fun PdfPageRenderer(
popupContentSize: IntSize popupContentSize: IntSize
): IntOffset { ): IntOffset {
val coords = layoutCoordinates ?: return IntOffset.Zero val coords = layoutCoordinates ?: return IntOffset.Zero
val menuAnchorContentRect = state.anchorRect
val topLeftLocal = contentToScreenCoordinates( // Map the bitmap-space anchor (the icon) to window-space
Offset( val topLeftLocal = contentToScreenCoordinates(Offset(state.anchorRect.left.toFloat(), state.anchorRect.top.toFloat()))
menuAnchorContentRect.left.toFloat(), val bottomRightLocal = contentToScreenCoordinates(Offset(state.anchorRect.right.toFloat(), state.anchorRect.bottom.toFloat()))
menuAnchorContentRect.top.toFloat()
)
)
val bottomRightLocal = contentToScreenCoordinates(
Offset(
menuAnchorContentRect.right.toFloat(),
menuAnchorContentRect.bottom.toFloat()
)
)
val topLeftWindow = coords.localToWindow(topLeftLocal) val topLeftWindow = coords.localToWindow(topLeftLocal)
val bottomRightWindow = coords.localToWindow(bottomRightLocal) val bottomRightWindow = coords.localToWindow(bottomRightLocal)
val windowCenterX = (topLeftWindow.x + bottomRightWindow.x) / 2 val windowCenterX = (topLeftWindow.x + bottomRightWindow.x) / 2
val windowTopY = topLeftWindow.y val gapPx = with(density) { 16.dp.toPx() } // Increased gap
val windowBottomY = bottomRightWindow.y
val xInWindow = windowCenterX - popupContentSize.width / 2 // Try placing ABOVE the icon first
var yInWindow = var yInWindow = (topLeftWindow.y - popupContentSize.height - gapPx).toInt()
windowTopY - popupContentSize.height - with(density) { 8.dp.toPx() }
if (yInWindow < 0) { 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( val xInWindow = (windowCenterX - popupContentSize.width / 2).toInt()
0, windowSize.width - popupContentSize.width .coerceIn(0, windowSize.width - popupContentSize.width)
)
val finalY = yInWindow.toInt().coerceIn(
0, windowSize.height - popupContentSize.height
)
return IntOffset(finalX, finalY) return IntOffset(xInWindow, yInWindow)
} }
} }
} }
@ -4664,6 +4892,7 @@ private fun PdfPageRenderer(
PdfSelectionMenuPopup( PdfSelectionMenuPopup(
menuState = state, menuState = state,
popupPositionProvider = popupPositionProvider, popupPositionProvider = popupPositionProvider,
onDismiss = onMenuDismiss,
onCopy = onCopy, onCopy = onCopy,
onAiDefine = onAiDefine, onAiDefine = onAiDefine,
onSelectAll = onSelectAll, onSelectAll = onSelectAll,