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:
parent
06ec45504f
commit
8582ed9679
4 changed files with 585 additions and 159 deletions
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
@ -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,122 +194,138 @@ 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(
|
Column(modifier = if (menuState.isComment) Modifier.fillMaxWidth() else Modifier.width(IntrinsicSize.Max)) {
|
||||||
modifier = Modifier.width(IntrinsicSize.Max)
|
if (menuState.isComment) {
|
||||||
) {
|
Column(
|
||||||
// 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(
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.padding(16.dp)
|
||||||
.clickable { onDelete() }
|
.heightIn(max = 400.dp)
|
||||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
.verticalScroll(rememberScrollState())
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
|
||||||
) {
|
) {
|
||||||
Icon(
|
CommentItem(author = menuState.author, text = menuState.selectedText, depth = 0)
|
||||||
imageVector = Icons.Default.Delete,
|
menuState.annotation?.replies?.let { CommentThread(it, 1) }
|
||||||
contentDescription = "Remove",
|
|
||||||
tint = MaterialTheme.colorScheme.error,
|
|
||||||
modifier = Modifier.size(20.dp)
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = "Remove",
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.error
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
}
|
TextButton(
|
||||||
|
onClick = {
|
||||||
// Standard Options
|
val fullText = buildString {
|
||||||
Row(
|
append("${menuState.author ?: "Unknown"}: ${menuState.selectedText}\n")
|
||||||
modifier = Modifier.fillMaxWidth()
|
fun appendReplies(replies: List<EmbeddedAnnotation>, indent: String) {
|
||||||
) {
|
for (r in replies) {
|
||||||
// Copy
|
append("$indent${r.author ?: "Unknown"}: ${r.contents ?: ""}\n")
|
||||||
Box(
|
appendReplies(r.replies, "$indent ")
|
||||||
modifier = Modifier
|
}
|
||||||
.weight(1f)
|
}
|
||||||
.clickable { onCopy(menuState.selectedText) }
|
menuState.annotation?.replies?.let { appendReplies(it, " ") }
|
||||||
.padding(vertical = 12.dp),
|
}.trimEnd()
|
||||||
contentAlignment = Alignment.Center
|
onCopy(fullText)
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
) {
|
) {
|
||||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
Icon(Icons.Default.CopyAll, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||||
Icon(Icons.Default.CopyAll, contentDescription = null, modifier = Modifier.size(20.dp))
|
Spacer(Modifier.width(8.dp))
|
||||||
Text("Copy", style = MaterialTheme.typography.labelSmall)
|
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
|
HorizontalDivider()
|
||||||
if (menuState.selectedText.length <= 2000) {
|
|
||||||
|
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(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier.weight(1f)
|
||||||
.weight(1f)
|
.clickable { onCopy(menuState.selectedText) }.padding(vertical = 12.dp),
|
||||||
.clickable { onAiDefine(menuState.selectedText) }
|
contentAlignment = Alignment.Center) {
|
||||||
.padding(vertical = 12.dp),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
Icon(
|
Icon(
|
||||||
painter = painterResource(id = R.drawable.dictionary),
|
Icons.Default.CopyAll,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
modifier = Modifier.size(20.dp)
|
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.selectedText.length <= 2000) {
|
||||||
if (!menuState.isExistingHighlight) {
|
Box(
|
||||||
Box(
|
modifier = Modifier.weight(1f)
|
||||||
modifier = Modifier
|
.clickable { onAiDefine(menuState.selectedText) }
|
||||||
.weight(1f)
|
.padding(vertical = 12.dp), contentAlignment = Alignment.Center) {
|
||||||
.clickable { onSelectAll() }
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
.padding(vertical = 12.dp),
|
Icon(
|
||||||
contentAlignment = Alignment.Center
|
painter = painterResource(id = R.drawable.dictionary),
|
||||||
) {
|
contentDescription = null,
|
||||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
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("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<Rect>): List<Rect> {
|
internal fun mergeRectsIntoLines(rects: List<Rect>): List<Rect> {
|
||||||
if (rects.isEmpty()) return emptyList()
|
if (rects.isEmpty()) return emptyList()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
withContext(Dispatchers.Main) {
|
var handedOver = false
|
||||||
if (isActive) {
|
try {
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
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,58 +4850,49 @@ 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,
|
windowSize: IntSize,
|
||||||
windowSize: IntSize,
|
layoutDirection: LayoutDirection,
|
||||||
layoutDirection: LayoutDirection,
|
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(
|
|
||||||
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 windowCenterX = (topLeftWindow.x + bottomRightWindow.x) / 2
|
// Map the bitmap-space anchor (the icon) to window-space
|
||||||
val windowTopY = topLeftWindow.y
|
val topLeftLocal = contentToScreenCoordinates(Offset(state.anchorRect.left.toFloat(), state.anchorRect.top.toFloat()))
|
||||||
val windowBottomY = bottomRightWindow.y
|
val bottomRightLocal = contentToScreenCoordinates(Offset(state.anchorRect.right.toFloat(), state.anchorRect.bottom.toFloat()))
|
||||||
val xInWindow = windowCenterX - popupContentSize.width / 2
|
|
||||||
var yInWindow =
|
|
||||||
windowTopY - popupContentSize.height - with(density) { 8.dp.toPx() }
|
|
||||||
|
|
||||||
if (yInWindow < 0) {
|
val topLeftWindow = coords.localToWindow(topLeftLocal)
|
||||||
yInWindow = windowBottomY + with(density) { 8.dp.toPx() }
|
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(
|
PdfSelectionMenuPopup(
|
||||||
menuState = state,
|
menuState = state,
|
||||||
popupPositionProvider = popupPositionProvider,
|
popupPositionProvider = popupPositionProvider,
|
||||||
|
onDismiss = onMenuDismiss,
|
||||||
onCopy = onCopy,
|
onCopy = onCopy,
|
||||||
onAiDefine = onAiDefine,
|
onAiDefine = onAiDefine,
|
||||||
onSelectAll = onSelectAll,
|
onSelectAll = onSelectAll,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue