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 <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;
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue