General fixes (#76)

* Added print functionality to PDF viewer

* feat(pdf): add interactive button support and visibility reveal heuristic

* Moved search state management to MainViewModel and improved search UI interaction.

* Updated `pdfiumandroid` to version 2.0.0 and handled resulting API changes, including nullable page/text page returns and revised native pointer access.

increased compileSdk to 36.

* Fixed Table of Contents (TOC) truncation bug and improved the TOC UI in `PdfViewerScreen`.

- Implemented `getFixedTableOfContents` using reflection to bypass a library issue where sibling nodes were incorrectly truncated during traversal.
- Enhanced the TOC drawer with a nested, expandable tree structure using the new `PdfTocTreeItem` component.
- Added a custom `VerticalScrollbar` with draggable support for better navigation within long TOC lists.
- Integrated `animateColorAsState` and `animateFloatAsState` for smoother UI transitions in the TOC and scrollbar.
- Optimized TOC loading by flattening the tree structure and managing expansion states with `rememberSaveable`.

* Improved zoom pivot calculation and interaction handling in PdfVerticalReader

* Fixed high-res PDF tile bleeding by implementing clipRect in PdfBitmapLayer

* fix(pdf): resolve zoom stuttering, in pagination mode, by removing eager scale snapping
This commit is contained in:
Aryan 2026-03-16 00:37:16 +05:30 committed by GitHub
parent dece09fec0
commit 1884ace646
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 1207 additions and 653 deletions

View file

@ -19,7 +19,7 @@ if (localPropertiesFile.exists()) {
android { android {
namespace = "com.aryan.reader" namespace = "com.aryan.reader"
compileSdk = 35 compileSdk = 36
ndkVersion = "29.0.14206865" ndkVersion = "29.0.14206865"
defaultConfig { defaultConfig {
@ -211,7 +211,7 @@ dependencies {
implementation("androidx.documentfile:documentfile:1.0.1") implementation("androidx.documentfile:documentfile:1.0.1")
implementation("androidx.browser:browser:1.8.0") implementation("androidx.browser:browser:1.8.0")
implementation("io.legere:pdfiumandroid:1.0.35") implementation("io.legere:pdfiumandroid:2.0.0")
} }
spotless { spotless {

View file

@ -3,6 +3,8 @@
#include <android/log.h> #include <android/log.h>
#include <vector> #include <vector>
#include <string> #include <string>
#include <mutex>
#include <regex>
#define LOG_TAG "PdfiumAnnotation" #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__)
@ -29,7 +31,21 @@ typedef int (*FPDFBitmap_GetStride_t)(void* bitmap);
typedef void* (*FPDFBitmap_GetBuffer_t)(void* bitmap); typedef void* (*FPDFBitmap_GetBuffer_t)(void* bitmap);
typedef void (*FPDFBitmap_Destroy_t)(void* bitmap); typedef void (*FPDFBitmap_Destroy_t)(void* bitmap);
typedef int (*FPDFPageObj_GetBounds_t)(void* page_object, float* left, float* bottom, float* right, float* top); typedef int (*FPDFPageObj_GetBounds_t)(void* page_object, float* left, float* bottom, float* right, float* top);
typedef int (*FPDF_DoAnnotAction_t)(void* annot, int action_type);
typedef void* (*FPDFAnnot_GetWidgetAtPoint_t)(void* page, double page_x, double page_y);
typedef void* (*FPDFLink_GetAction_t)(void* link);
typedef unsigned long (*FPDFAction_GetType_t)(void* action);
typedef void* (*FPDFLink_GetAnnot_t)(void* link);
typedef int (*FPDFAnnot_GetFlags_t)(void* annot);
typedef int (*FPDFAnnot_SetFlags_t)(void* annot, int flags);
typedef unsigned long (*FPDFAnnot_GetFormFieldName_t)(void* hFPDFTextPage, void* annot, void* buffer, unsigned long buflen);
static std::mutex g_pdfium_mutex;
static FPDFLink_GetAnnot_t get_link_annot_func = nullptr;
static FPDFLink_GetAction_t get_link_action_func = nullptr;
static FPDFAction_GetType_t get_action_type_func = nullptr;
static FPDF_DoAnnotAction_t do_annot_action_func = nullptr;
static FPDFAnnot_GetWidgetAtPoint_t get_widget_at_point_func = nullptr;
static FPDFPage_CountObjects_t count_objects_func = nullptr; static FPDFPage_CountObjects_t count_objects_func = nullptr;
static FPDFPage_GetObject_t get_object_func = nullptr; static FPDFPage_GetObject_t get_object_func = nullptr;
static FPDFPageObj_GetType_t get_object_type_func = nullptr; static FPDFPageObj_GetType_t get_object_type_func = nullptr;
@ -51,6 +67,9 @@ 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;
static FPDFText_GetCharBox_t get_char_box_func = nullptr; static FPDFText_GetCharBox_t get_char_box_func = nullptr;
static FPDFAnnot_GetFlags_t get_annot_flags_func = nullptr;
static FPDFAnnot_SetFlags_t set_annot_flags_func = nullptr;
static FPDFAnnot_GetFormFieldName_t get_form_field_name_func = nullptr;
typedef void* (*FPDFAnnot_GetLinkedAnnot_t)(void* annot, const char* key); typedef void* (*FPDFAnnot_GetLinkedAnnot_t)(void* annot, const char* key);
typedef void (*FPDFPage_CloseAnnot_t)(void* annot); typedef void (*FPDFPage_CloseAnnot_t)(void* annot);
@ -67,11 +86,13 @@ static bool init_pdfium() {
return false; return false;
} }
// --- Text Functions ---
get_font_size_func = (FPDFText_GetFontSize_t) dlsym(pdfium_handle, "FPDFText_GetFontSize"); get_font_size_func = (FPDFText_GetFontSize_t) dlsym(pdfium_handle, "FPDFText_GetFontSize");
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");
get_char_box_func = (FPDFText_GetCharBox_t) dlsym(pdfium_handle, "FPDFText_GetCharBox"); get_char_box_func = (FPDFText_GetCharBox_t) dlsym(pdfium_handle, "FPDFText_GetCharBox");
// --- Annotation Functions ---
get_annot_count_func = (FPDFPage_GetAnnotCount_t) dlsym(pdfium_handle, "FPDFPage_GetAnnotCount"); 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_func = (FPDFPage_GetAnnot_t) dlsym(pdfium_handle, "FPDFPage_GetAnnot");
get_annot_subtype_func = (FPDFAnnot_GetSubtype_t) dlsym(pdfium_handle, "FPDFAnnot_GetSubtype"); get_annot_subtype_func = (FPDFAnnot_GetSubtype_t) dlsym(pdfium_handle, "FPDFAnnot_GetSubtype");
@ -80,28 +101,51 @@ static bool init_pdfium() {
get_annot_color_func = (FPDFAnnot_GetColor_t) dlsym(pdfium_handle, "FPDFAnnot_GetColor"); get_annot_color_func = (FPDFAnnot_GetColor_t) dlsym(pdfium_handle, "FPDFAnnot_GetColor");
get_linked_annot_func = (FPDFAnnot_GetLinkedAnnot_t) dlsym(pdfium_handle, "FPDFAnnot_GetLinkedAnnot"); get_linked_annot_func = (FPDFAnnot_GetLinkedAnnot_t) dlsym(pdfium_handle, "FPDFAnnot_GetLinkedAnnot");
close_annot_func = (FPDFPage_CloseAnnot_t) dlsym(pdfium_handle, "FPDFPage_CloseAnnot"); close_annot_func = (FPDFPage_CloseAnnot_t) dlsym(pdfium_handle, "FPDFPage_CloseAnnot");
get_annot_flags_func = (FPDFAnnot_GetFlags_t) dlsym(pdfium_handle, "FPDFAnnot_GetFlags");
set_annot_flags_func = (FPDFAnnot_SetFlags_t) dlsym(pdfium_handle, "FPDFAnnot_SetFlags");
// --- Object & Bitmap Functions ---
count_objects_func = (FPDFPage_CountObjects_t) dlsym(pdfium_handle, "FPDFPage_CountObjects"); count_objects_func = (FPDFPage_CountObjects_t) dlsym(pdfium_handle, "FPDFPage_CountObjects");
get_object_func = (FPDFPage_GetObject_t) dlsym(pdfium_handle, "FPDFPage_GetObject"); get_object_func = (FPDFPage_GetObject_t) dlsym(pdfium_handle, "FPDFPage_GetObject");
get_object_type_func = (FPDFPageObj_GetType_t) dlsym(pdfium_handle, "FPDFPageObj_GetType"); get_object_type_func = (FPDFPageObj_GetType_t) dlsym(pdfium_handle, "FPDFPageObj_GetType");
get_object_bounds_func = (FPDFPageObj_GetBounds_t) dlsym(pdfium_handle, "FPDFPageObj_GetBounds");
get_image_bitmap_func = (FPDFImageObj_GetBitmap_t) dlsym(pdfium_handle, "FPDFImageObj_GetBitmap"); get_image_bitmap_func = (FPDFImageObj_GetBitmap_t) dlsym(pdfium_handle, "FPDFImageObj_GetBitmap");
bitmap_get_width_func = (FPDFBitmap_GetWidth_t) dlsym(pdfium_handle, "FPDFBitmap_GetWidth"); bitmap_get_width_func = (FPDFBitmap_GetWidth_t) dlsym(pdfium_handle, "FPDFBitmap_GetWidth");
bitmap_get_height_func = (FPDFBitmap_GetHeight_t) dlsym(pdfium_handle, "FPDFBitmap_GetHeight"); bitmap_get_height_func = (FPDFBitmap_GetHeight_t) dlsym(pdfium_handle, "FPDFBitmap_GetHeight");
bitmap_get_stride_func = (FPDFBitmap_GetStride_t) dlsym(pdfium_handle, "FPDFBitmap_GetStride"); bitmap_get_stride_func = (FPDFBitmap_GetStride_t) dlsym(pdfium_handle, "FPDFBitmap_GetStride");
bitmap_get_buffer_func = (FPDFBitmap_GetBuffer_t) dlsym(pdfium_handle, "FPDFBitmap_GetBuffer"); bitmap_get_buffer_func = (FPDFBitmap_GetBuffer_t) dlsym(pdfium_handle, "FPDFBitmap_GetBuffer");
bitmap_destroy_func = (FPDFBitmap_Destroy_t) dlsym(pdfium_handle, "FPDFBitmap_Destroy"); bitmap_destroy_func = (FPDFBitmap_Destroy_t) dlsym(pdfium_handle, "FPDFBitmap_Destroy");
get_object_bounds_func = (FPDFPageObj_GetBounds_t) dlsym(pdfium_handle, "FPDFPageObj_GetBounds");
// --- Interaction, Links & Form Functions ---
do_annot_action_func = (FPDF_DoAnnotAction_t) dlsym(pdfium_handle, "FPDF_DoAnnotAction");
get_widget_at_point_func = (FPDFAnnot_GetWidgetAtPoint_t) dlsym(pdfium_handle, "FPDFAnnot_GetWidgetAtPoint");
get_link_action_func = (FPDFLink_GetAction_t) dlsym(pdfium_handle, "FPDFLink_GetAction");
get_action_type_func = (FPDFAction_GetType_t) dlsym(pdfium_handle, "FPDFAction_GetType");
get_link_annot_func = (FPDFLink_GetAnnot_t) dlsym(pdfium_handle, "FPDFLink_GetAnnot");
get_form_field_name_func = (FPDFAnnot_GetFormFieldName_t) dlsym(pdfium_handle, "FPDFAnnot_GetFormFieldName");
// --- Validation & Logging ---
bool success = get_annot_count_func && get_annot_func && get_annot_subtype_func && bool success = get_annot_count_func && get_annot_func && get_annot_subtype_func &&
get_annot_rect_func && get_annot_string_func; get_annot_rect_func && get_annot_string_func;
if (!success) { if (!success) {
LOGE("Failed to find one or more annotation functions in libpdfium.so"); LOGE("Failed to find one or more core annotation functions in libpdfium.so");
} else { } else {
LOGI("Pdfium Annotation Bridge initialized successfully."); LOGI("Pdfium Annotation Bridge initialized successfully.");
} }
return success; LOGD("PdfInteraction: Flags -> Get:%p Set:%p, FormField -> %p",
get_annot_flags_func, set_annot_flags_func, get_form_field_name_func);
if (!get_link_action_func || !do_annot_action_func || !get_widget_at_point_func) {
LOGE("PdfInteraction: Missing one or more action/widget functions. LinkAction=%p, DoAction=%p, GetWidget=%p",
get_link_action_func, do_annot_action_func, get_widget_at_point_func);
} else {
LOGI("PdfInteraction: Initialization complete. Summary: LinkAction=%p, DoAction=%p, GetWidget=%p",
get_link_action_func, do_annot_action_func, get_widget_at_point_func);
}
return get_annot_count_func != nullptr;
} }
extern "C" JNIEXPORT jdouble JNICALL extern "C" JNIEXPORT jdouble JNICALL
@ -182,42 +226,19 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_getPageCharBoxes(JNIEnv *env, jclas
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 extern "C" JNIEXPORT jstring JNICALL
Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotString(JNIEnv *env, jclass clazz, jlong pagePtr, jint index, jstring key) { 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; std::lock_guard<std::mutex> lock(g_pdfium_mutex);
void* annot = get_annot_func(reinterpret_cast<void*>(pagePtr), index); if (!init_pdfium() || !get_annot_func || !get_annot_string_func || pagePtr == 0) return nullptr;
void* page = reinterpret_cast<void*>(pagePtr);
void* annot = get_annot_func(page, index);
if (!annot) return nullptr; if (!annot) return nullptr;
const char* nativeKey = env->GetStringUTFChars(key, nullptr); const char* nativeKey = env->GetStringUTFChars(key, nullptr);
if (strcmp(nativeKey, "IRT") == 0 && get_linked_annot_func && close_annot_func) { if (strcmp(nativeKey, "IRT") == 0) {
if (get_linked_annot_func && close_annot_func) {
void* parentAnnot = get_linked_annot_func(annot, "IRT"); void* parentAnnot = get_linked_annot_func(annot, "IRT");
if (parentAnnot) { if (parentAnnot) {
unsigned long len = get_annot_string_func(parentAnnot, "NM", nullptr, 0); unsigned long len = get_annot_string_func(parentAnnot, "NM", nullptr, 0);
@ -232,6 +253,9 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotString(JNIEnv *env, jclass
return result; return result;
} }
} }
env->ReleaseStringUTFChars(key, nativeKey);
return nullptr;
}
unsigned long len = get_annot_string_func(annot, nativeKey, nullptr, 0); unsigned long len = get_annot_string_func(annot, nativeKey, nullptr, 0);
@ -327,3 +351,144 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_extractImagePixels(JNIEnv *env, jcl
return result; return result;
} }
extern "C" JNIEXPORT jboolean JNICALL
Java_com_aryan_reader_pdf_NativePdfiumBridge_checkActionSupport(JNIEnv *env, jclass clazz) {
init_pdfium();
// Return true if we have ANY way to handle actions
return (do_annot_action_func || get_link_action_func) ? JNI_TRUE : JNI_FALSE;
}
extern "C" JNIEXPORT jint JNICALL
Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotSubtypeAtPoint(JNIEnv *env, jclass clazz, jlong pagePtr, jdouble x, jdouble y) {
if (!init_pdfium() || !get_annot_count_func || pagePtr == 0) return -1;
void* page = reinterpret_cast<void*>(pagePtr);
int count = get_annot_count_func(page);
for (int i = 0; i < count; i++) {
void* annot = get_annot_func(page, i);
float r[4]; // L, B, R, T
if (get_annot_rect_func(annot, r)) {
// FIX: Use min/max to handle inverted PDF rectangles
float minX = fmin(r[0], r[2]);
float maxX = fmax(r[0], r[2]);
float minY = fmin(r[1], r[3]);
float maxY = fmax(r[1], r[3]);
if (x >= minX && x <= maxX && y >= minY && y <= maxY) {
LOGI("PdfInteraction: MATCH FOUND! Index=%d, Type=%d", i, get_annot_subtype_func(annot));
return get_annot_subtype_func(annot);
}
}
}
return -1;
}
extern "C" JNIEXPORT jfloatArray JNICALL
Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotRectAtPoint(JNIEnv *env, jclass clazz, jlong pagePtr, jdouble x, jdouble y) {
if (!init_pdfium() || !get_annot_count_func || !get_annot_func || !get_annot_rect_func || pagePtr == 0) return nullptr;
void* page = reinterpret_cast<void*>(pagePtr);
int count = get_annot_count_func(page);
for (int i = 0; i < count; i++) {
void* annot = get_annot_func(page, i);
float rect[4];
if (get_annot_rect_func(annot, rect)) {
if (x >= rect[0] && x <= rect[2] && y >= rect[1] && y <= rect[3]) {
jfloatArray result = env->NewFloatArray(4);
env->SetFloatArrayRegion(result, 0, 4, rect);
return result;
}
}
}
return nullptr;
}
extern "C" JNIEXPORT jint JNICALL
Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotCount(JNIEnv *env, jclass clazz, jlong pagePtr) {
std::lock_guard<std::mutex> lock(g_pdfium_mutex);
if (!init_pdfium() || !get_annot_count_func || pagePtr == 0) 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 || pagePtr == 0) 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 || pagePtr == 0) 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 jboolean JNICALL
Java_com_aryan_reader_pdf_NativePdfiumBridge_performClick(JNIEnv *env, jclass clazz, jlong pagePtr, jdouble x, jdouble y) {
std::lock_guard<std::mutex> lock(g_pdfium_mutex);
if (!init_pdfium() || pagePtr == 0) return JNI_FALSE;
void* page = reinterpret_cast<void*>(pagePtr);
int count = get_annot_count_func(page);
void* hitAnnot = nullptr;
// 1. Find which annotation was clicked
for (int i = 0; i < count; i++) {
void* annot = get_annot_func(page, i);
if (!annot) continue;
float r[4];
if (get_annot_rect_func(annot, r)) {
float minX = fminf(r[0], r[2]);
float maxX = fmaxf(r[0], r[2]);
float minY = fminf(r[1], r[3]);
float maxY = fmaxf(r[1], r[3]);
if (x >= minX && x <= maxX && y >= minY && y <= maxY) {
hitAnnot = annot;
break;
}
}
}
if (hitAnnot) {
int subtype = get_annot_subtype_func(hitAnnot);
if (subtype == 19 || subtype == 20) {
LOGI("PdfInteraction: Button clicked (Subtype %d). Performing Blanket Reveal.", subtype);
bool anyChanged = false;
for (int j = 0; j < count; j++) {
void* target = get_annot_func(page, j);
if (!target || !get_annot_flags_func || !set_annot_flags_func) continue;
int flags = get_annot_flags_func(target);
// We check for: Invisible (1), Hidden (2), or NoView (32)
if (flags & (1 | 2 | 32)) {
LOGD("PdfInteraction: Unhiding element at index %d (Flags were 0x%X)", j, flags);
// Clear bits 1, 2, and 6 (1 + 2 + 32 = 35)
set_annot_flags_func(target, flags & ~35);
anyChanged = true;
}
}
if (anyChanged) {
return JNI_TRUE;
}
}
}
return JNI_FALSE;
}

View file

@ -20,8 +20,6 @@
// LibraryScreen.kt // LibraryScreen.kt
package com.aryan.reader package com.aryan.reader
import android.content.Context
import android.provider.DocumentsContract
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
@ -64,7 +62,6 @@ import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
@ -99,8 +96,6 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.documentfile.provider.DocumentFile
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.compose.AsyncImage import coil.compose.AsyncImage
import coil.request.ImageRequest import coil.request.ImageRequest
@ -146,7 +141,7 @@ fun LibraryScreen(
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var isSearchActive by remember { mutableStateOf(false) } val isSearchActive = uiState.isSearchActive
val searchQuery = uiState.searchQuery val searchQuery = uiState.searchQuery
val pickFolderLauncher = rememberLauncherForActivityResult( val pickFolderLauncher = rememberLauncherForActivityResult(
@ -222,8 +217,7 @@ fun LibraryScreen(
} }
BackHandler(enabled = isSearchActive) { BackHandler(enabled = isSearchActive) {
isSearchActive = false viewModel.setSearchActive(false)
viewModel.onSearchQueryChange("")
} }
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
@ -238,10 +232,7 @@ fun LibraryScreen(
searchQuery = searchQuery, searchQuery = searchQuery,
isSearchActive = isSearchActive, isSearchActive = isSearchActive,
onSearchQueryChange = viewModel::onSearchQueryChange, onSearchQueryChange = viewModel::onSearchQueryChange,
onSearchActiveChange = { active -> onSearchActiveChange = viewModel::setSearchActive,
isSearchActive = active
if (!active) viewModel.onSearchQueryChange("")
},
onSortOrderChange = viewModel::setSortOrder, onSortOrderChange = viewModel::setSortOrder,
onClearSelection = { viewModel.clearContextualAction() }, onClearSelection = { viewModel.clearContextualAction() },
onItemClick = viewModel::onRecentFileClicked, onItemClick = viewModel::onRecentFileClicked,
@ -474,6 +465,19 @@ fun LibraryScreenContent(
val tabTitles = listOf("All Books", "Shelves", "Folders") val tabTitles = listOf("All Books", "Shelves", "Folders")
val searchFocusRequester = remember { FocusRequester() } val searchFocusRequester = remember { FocusRequester() }
var textFieldValue by remember(isSearchActive) {
mutableStateOf(TextFieldValue(searchQuery, TextRange(searchQuery.length)))
}
LaunchedEffect(searchQuery) {
if (textFieldValue.text != searchQuery) {
textFieldValue = textFieldValue.copy(
text = searchQuery,
selection = TextRange(searchQuery.length)
)
}
}
LaunchedEffect(isSearchActive) { LaunchedEffect(isSearchActive) {
if (isSearchActive) { if (isSearchActive) {
searchFocusRequester.requestFocus() searchFocusRequester.requestFocus()
@ -501,7 +505,7 @@ fun LibraryScreenContent(
} else if (isSearchActive) { } else if (isSearchActive) {
Surface( Surface(
shadowElevation = 4.dp, shadowElevation = 4.dp,
modifier = Modifier.fillMaxWidth().statusBarsPadding() modifier = Modifier.fillMaxWidth()
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier
@ -513,8 +517,11 @@ fun LibraryScreenContent(
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Close search") Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Close search")
} }
OutlinedTextField( OutlinedTextField(
value = searchQuery, value = textFieldValue,
onValueChange = onSearchQueryChange, onValueChange = {
textFieldValue = it
onSearchQueryChange(it.text)
},
placeholder = { Text("Search title or author...") }, placeholder = { Text("Search title or author...") },
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
@ -866,9 +873,11 @@ private fun ShelfDetailScreen(
}, },
floatingActionButton = { floatingActionButton = {
if (shelf.name != "Unshelved" && !isContextualModeActive) { if (shelf.name != "Unshelved" && !isContextualModeActive) {
FloatingActionButton(onClick = onAddBooksClick) { ExtendedFloatingActionButton(
Icon(Icons.Default.Add, contentDescription = "Add books") onClick = onAddBooksClick,
} icon = { Icon(Icons.Default.Add, contentDescription = null) },
text = { Text("Add books") }
)
} }
} }
) { paddingValues -> ) { paddingValues ->
@ -1362,25 +1371,6 @@ private fun DeleteShelvesConfirmationDialog(
) )
} }
private fun getDisplayPathFromUri(context: Context, uriString: String): String {
val uri = uriString.toUri()
val fallbackName = DocumentFile.fromTreeUri(context, uri)?.name ?: "Unknown Folder"
if (DocumentsContract.isTreeUri(uri) && DocumentsContract.getTreeDocumentId(uri).isNotEmpty()) {
val documentId = DocumentsContract.getTreeDocumentId(uri)
val split = documentId.split(":")
if (split.size > 1) {
val type = split[0]
val path = split[1]
return when (type) {
"primary" -> "Internal Storage ▸ $path"
else -> path
}
}
}
return fallbackName
}
@Composable @Composable
private fun FolderSyncScreen( private fun FolderSyncScreen(
syncedFolders: List<SyncedFolder>, syncedFolders: List<SyncedFolder>,

View file

@ -203,6 +203,7 @@ data class ReaderScreenState(
val lastFolderScanTime: Long? = null, val lastFolderScanTime: Long? = null,
val hasUnreadFeedback: Boolean = false, val hasUnreadFeedback: Boolean = false,
val searchQuery: String = "", val searchQuery: String = "",
val isSearchActive: Boolean = false,
val showFolderMigrationDialog: Boolean = false, val showFolderMigrationDialog: Boolean = false,
val isRefreshing: Boolean = false, val isRefreshing: Boolean = false,
val reflowProgress: Float? = null, val reflowProgress: Float? = null,
@ -369,7 +370,23 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
) )
fun onSearchQueryChange(newQuery: String) { fun onSearchQueryChange(newQuery: String) {
_internalState.update { it.copy(searchQuery = newQuery) } _internalState.update {
if (it.isSearchActive) {
it.copy(searchQuery = newQuery)
} else {
it
}
}
}
fun setSearchActive(active: Boolean) {
_internalState.update {
if (active) {
it.copy(isSearchActive = true)
} else {
it.copy(isSearchActive = false, searchQuery = "")
}
}
} }
private val _reviewRequestEvent = Channel<Unit>(Channel.BUFFERED) private val _reviewRequestEvent = Channel<Unit>(Channel.BUFFERED)

View file

@ -24,8 +24,15 @@ object NativePdfiumBridge {
@JvmStatic external fun getPageObjectBoundingBox(pagePtr: Long, index: Int, outRect: FloatArray): Boolean @JvmStatic external fun getPageObjectBoundingBox(pagePtr: Long, index: Int, outRect: FloatArray): Boolean
@JvmStatic external fun extractImagePixels(pagePtr: Long, index: Int, dimens: IntArray): IntArray? @JvmStatic external fun extractImagePixels(pagePtr: Long, index: Int, dimens: IntArray): IntArray?
@JvmStatic external fun performClick(pagePtr: Long, x: Double, y: Double): Boolean
@JvmStatic external fun getAnnotSubtypeAtPoint(pagePtr: Long, x: Double, y: Double): Int
@JvmStatic external fun getAnnotRectAtPoint(pagePtr: Long, x: Double, y: Double): FloatArray?
@JvmStatic external fun checkActionSupport(): Boolean
const val ANNOT_TEXT = 1 // Sticky Note const val ANNOT_TEXT = 1 // Sticky Note
const val ANNOT_LINK = 2 // Link const val ANNOT_LINK = 2 // Link
const val ANNOT_HIGHLIGHT = 8 // Highlight const val ANNOT_HIGHLIGHT = 8 // Highlight
const val ANNOT_INK = 12 // Freehand drawing const val ANNOT_INK = 12 // Freehand drawing
const val ANNOT_WIDGET = 19
} }

View file

@ -55,7 +55,7 @@ class PdfCoverGenerator(context: Context) {
Timber.w("PDF has no pages, cannot generate cover: $pdfUri") Timber.w("PDF has no pages, cannot generate cover: $pdfUri")
return@withContext null return@withContext null
} }
doc.openPage(0).use { page -> doc.openPage(0)?.use { page ->
val originalWidth = page.getPageWidthPoint() val originalWidth = page.getPageWidthPoint()
val originalHeight = page.getPageHeightPoint() val originalHeight = page.getPageHeightPoint()
if (originalWidth <= 0 || originalHeight <= 0) { if (originalWidth <= 0 || originalHeight <= 0) {

View file

@ -73,6 +73,7 @@ import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.StrokeJoin import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.clipRect
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.graphics.drawscope.rotate import androidx.compose.ui.graphics.drawscope.rotate
import androidx.compose.ui.graphics.drawscope.translate import androidx.compose.ui.graphics.drawscope.translate
@ -438,7 +439,6 @@ internal fun PdfPageComposable(
onHighlightDelete: (String) -> Unit = {}, onHighlightDelete: (String) -> Unit = {},
onTts: (Int, Int) -> Unit = { _, _ -> }, onTts: (Int, Int) -> Unit = { _, _ -> },
) { ) {
SideEffect { Timber.tag("PdfDrawPerf").v("PdfPageComposable Recompose: Page $pageIndex") }
val pdfDocumentItem = pdfDocument.item val pdfDocumentItem = pdfDocument.item
var bitmapState by remember { mutableStateOf(PdfThumbnailCache.get(pageIndex)) } var bitmapState by remember { mutableStateOf(PdfThumbnailCache.get(pageIndex)) }
var currentRenderedPageId by remember { mutableStateOf<String?>(null) } var currentRenderedPageId by remember { mutableStateOf<String?>(null) }
@ -677,7 +677,7 @@ internal fun PdfPageComposable(
} }
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
try { try {
pdfDocumentItem.openPage(pdfPageIndex).use { page -> pdfDocumentItem.openPage(pdfPageIndex)?.use { page ->
val mapped = userHighlights.map { highlight -> val mapped = userHighlights.map { highlight ->
val screenRects = highlight.bounds.mapNotNull { pdfRectF -> val screenRects = highlight.bounds.mapNotNull { pdfRectF ->
page.mapRectToDevice( page.mapRectToDevice(
@ -750,7 +750,7 @@ internal fun PdfPageComposable(
try { try {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
tempPage = pdfDocumentItem.openPage(pdfPageIndex) tempPage = pdfDocumentItem.openPage(pdfPageIndex)
tempPage.openTextPage().use { textPage -> tempPage?.openTextPage()?.use { textPage ->
val charCount = textPage.textPageCountChars() val charCount = textPage.textPageCountChars()
if (charCount > 0) { if (charCount > 0) {
val pdfRectsF = val pdfRectsF =
@ -760,14 +760,14 @@ internal fun PdfPageComposable(
if (pdfRectsF.isNotEmpty()) { if (pdfRectsF.isNotEmpty()) {
val mappedScreenRects = pdfRectsF.mapNotNull { pdfRectF -> val mappedScreenRects = pdfRectsF.mapNotNull { pdfRectF ->
tempPage.mapRectToDevice( tempPage?.mapRectToDevice(
startX = 0, startX = 0,
startY = 0, startY = 0,
sizeX = actualBitmapWidthPx, sizeX = actualBitmapWidthPx,
sizeY = actualBitmapHeightPx, sizeY = actualBitmapHeightPx,
rotate = currentPageRotation, rotate = currentPageRotation,
coords = pdfRectF coords = pdfRectF
).takeIf { it.width() > 0 && it.height() > 0 } )?.takeIf { it.width() > 0 && it.height() > 0 }
} }
if (mappedScreenRects.isNotEmpty()) { if (mappedScreenRects.isNotEmpty()) {
rects = mappedScreenRects rects = mappedScreenRects
@ -841,7 +841,7 @@ internal fun PdfPageComposable(
val annotLink = 2 val annotLink = 2
try { try {
pdfDocumentItem.openPage(pdfPageIndex).use { pageWrapper -> pdfDocumentItem.openPage(pdfPageIndex)?.use { pageWrapper ->
// 1. Extract Links (Method 1: Annotations) // 1. Extract Links (Method 1: Annotations)
try { try {
@ -876,7 +876,7 @@ internal fun PdfPageComposable(
// 2. Extract Links (Method 2: Text) // 2. Extract Links (Method 2: Text)
try { try {
pageWrapper.openTextPage().use { textPage -> pageWrapper.openTextPage().use { textPage ->
textPage.loadWebLink().use { webLinks -> textPage.loadWebLink()?.use { webLinks ->
val webLinkCount = webLinks.countWebLinks() val webLinkCount = webLinks.countWebLinks()
for (linkIndex in 0 until webLinkCount) { for (linkIndex in 0 until webLinkCount) {
val rawUrl = webLinks.getURL(linkIndex, 2048) val rawUrl = webLinks.getURL(linkIndex, 2048)
@ -907,17 +907,12 @@ internal fun PdfPageComposable(
// 3. Extract Embedded Annotations // 3. Extract Embedded Annotations
try { try {
val unlockedPage = pageWrapper.page val pagePtr = getNativePointer(pageWrapper)
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
}
if (pagePtr != 0L) {
val count = NativePdfiumBridge.getAnnotCount(pagePtr)
Timber.tag("PdfCommentDebug").d("Page $pageIndex: Total Annotations found = $count")
if (count > 0) {
val count = NativePdfiumBridge.getAnnotCount(pagePtr) val count = NativePdfiumBridge.getAnnotCount(pagePtr)
Timber.tag("PdfCommentDebug").d("Page $pageIndex: Total Annotations found = $count") Timber.tag("PdfCommentDebug").d("Page $pageIndex: Total Annotations found = $count")
if (count > 0) { if (count > 0) {
@ -989,6 +984,10 @@ internal fun PdfPageComposable(
annot to screenRect annot to screenRect
} }
} }
}
} else {
Timber.tag("PdfCommentDebug").w("Page $pageIndex: Failed to resolve native page pointer.")
}
} catch (e: Exception) { } catch (e: Exception) {
Timber.tag("PdfCommentDebug").e(e, "Error extracting annotations") Timber.tag("PdfCommentDebug").e(e, "Error extracting annotations")
} }
@ -1007,141 +1006,6 @@ internal fun PdfPageComposable(
} }
} }
LaunchedEffect(pageIndex, pdfDocumentItem, actualBitmapWidthPx, actualBitmapHeightPx, virtualPage) {
if (!isPdfPage) {
if (pageLinks.isNotEmpty()) pageLinks = emptyList()
return@LaunchedEffect
}
if (actualBitmapWidthPx == 0 || actualBitmapHeightPx == 0) {
if (pageLinks.isNotEmpty()) pageLinks = emptyList()
return@LaunchedEffect
}
Timber.d(
"LaunchedEffect: Starting link fetch for page $pageIndex. Bitmap size: ${actualBitmapWidthPx}x${actualBitmapHeightPx}"
)
withContext(Dispatchers.IO) {
val allLinks = mutableListOf<PageLink>()
try {
pdfDocumentItem.openPage(pdfPageIndex).use { page ->
try {
val annotationLinks = page.getPageLinks()
Timber.d("Method 1 (getPageLinks) returned ${annotationLinks.size} links.")
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 = page.mapRectToDevice(
startX = 0,
startY = 0,
sizeX = actualBitmapWidthPx,
sizeY = actualBitmapHeightPx,
rotate = currentPageRotation,
coords = bounds
)
Timber.d(
"[Method 1] Link '$uri' | PDF Bounds: $bounds | Mapped Device Rect: $deviceRect"
)
if (deviceRect.width() > 0 && deviceRect.height() > 0) {
val tapRect = Rect(
deviceRect.left,
deviceRect.top - linkVerticalPaddingPx,
deviceRect.right,
deviceRect.bottom + linkVerticalPaddingPx
)
Timber.d(
"[Method 1] Padded Tap Rect: $tapRect (Padding: $linkVerticalPaddingPx)"
)
PageLink(
highlightBounds = deviceRect,
tapBounds = tapRect,
url = uri,
destPageIdx = destPageIdx,
source = LinkSource.ANNOTATION
)
} else null
} else null
}
allLinks.addAll(mappedAnnotationLinks)
}
} catch (e: Exception) {
Timber.e(e, "Error fetching annotation links")
}
// --- METHOD 2: Get links detected within the text content
// ---
try {
page.openTextPage().use { textPage ->
textPage.loadWebLink().use { webLinks ->
val webLinkCount = webLinks.countWebLinks()
Timber.d("Method 2 (loadWebLink) returned $webLinkCount links.")
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 = page.mapRectToDevice(
startX = 0,
startY = 0,
sizeX = actualBitmapWidthPx,
sizeY = actualBitmapHeightPx,
rotate = currentPageRotation,
coords = pdfRect
)
Timber.d(
"[Method 2] Link '$url' | PDF Rect: $pdfRect | Mapped Device Rect: $deviceRect"
)
if (deviceRect.width() > 0 && deviceRect.height() > 0) {
val tapRect = Rect(
deviceRect.left,
deviceRect.top - linkVerticalPaddingPx,
deviceRect.right,
deviceRect.bottom + linkVerticalPaddingPx
)
Timber.d(
"[Method 2] Padded Tap Rect: $tapRect (Padding: $linkVerticalPaddingPx)"
)
allLinks.add(
PageLink(
highlightBounds = deviceRect,
tapBounds = tapRect,
url = url,
destPageIdx = null,
source = LinkSource.TEXT_CONTENT
)
)
}
}
}
}
}
} catch (e: Exception) {
Timber.e(e, "Error fetching web links from text page")
}
pageLinks = allLinks
Timber.d(
"Finished fetching. Stored a total of ${allLinks.size} links in state."
)
}
} catch (e: Exception) {
Timber.e(e, "Failed to open page $pdfPageIndex for link fetching")
pageLinks = emptyList()
}
}
}
LaunchedEffect(isVisible, pageIndex) { LaunchedEffect(isVisible, pageIndex) {
if (!isVisible && !isVerticalScroll) { if (!isVisible && !isVerticalScroll) {
if (bitmapState != null) { if (bitmapState != null) {
@ -1153,6 +1017,21 @@ internal fun PdfPageComposable(
} }
} }
SideEffect {
if (effectiveScale > 1f) {
Timber.tag("PdfZoomDiagnostics").v(
"""
Page $pageIndex Stats:
- Internal Scale: $scale | Effective Scale: $effectiveScale
- Offset: $offset
- Bitmap Dims: ${actualBitmapWidthPx}x${actualBitmapHeightPx}
- Canvas Dims: ${canvasWidthPx.floatValue}x${canvasHeightPx.floatValue}
- Centering: X=$centeringOffsetX, Y=$centeringOffsetY
""".trimIndent()
)
}
}
LaunchedEffect( LaunchedEffect(
effectiveScale, effectiveScale,
effectiveOffset, effectiveOffset,
@ -1309,7 +1188,7 @@ internal fun PdfPageComposable(
val tileRenderX = (col * tileSizePx * effectiveScale).toInt() val tileRenderX = (col * tileSizePx * effectiveScale).toInt()
val tileRenderY = (row * tileSizePx * effectiveScale).toInt() val tileRenderY = (row * tileSizePx * effectiveScale).toInt()
page.renderPageBitmap( page?.renderPageBitmap(
bitmap = tileBitmap, bitmap = tileBitmap,
startX = -tileRenderX, startX = -tileRenderX,
startY = -tileRenderY, startY = -tileRenderY,
@ -1384,7 +1263,7 @@ internal fun PdfPageComposable(
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
try { try {
pdfDocumentItem.openPage(pdfPageIndex).use { page -> pdfDocumentItem.openPage(pdfPageIndex)?.use { page ->
page.openTextPage().use { textPage -> page.openTextPage().use { textPage ->
val charCount = textPage.textPageCountChars() val charCount = textPage.textPageCountChars()
if (charCount > 0) { if (charCount > 0) {
@ -1559,7 +1438,15 @@ internal fun PdfPageComposable(
textPageToUse = providedTextPage textPageToUse = providedTextPage
} else { } else {
localPage = doc.openPage(pageIdx) localPage = doc.openPage(pageIdx)
localTextPage = localPage.openTextPage() localTextPage = localPage?.openTextPage()
if (localPage == null || localTextPage == null) {
withContext(Dispatchers.Main) {
selectedWordScreenRects = emptyList()
startHandleContentPosition.value = null
endHandleContentPosition.value = null
}
return@withContext
}
pageToUse = localPage pageToUse = localPage
textPageToUse = localTextPage textPageToUse = localTextPage
} }
@ -1788,7 +1675,7 @@ internal fun PdfPageComposable(
pageForDrag = pdfDocumentItem.openPage( pageForDrag = pdfDocumentItem.openPage(
pdfPageIndex pdfPageIndex
) )
textPageForDrag = pageForDrag.openTextPage() textPageForDrag = pageForDrag?.openTextPage()
} }
} }
@ -1886,13 +1773,15 @@ internal fun PdfPageComposable(
} }
} else { } else {
// PDFIUM Logic // PDFIUM Logic
if (pageForDrag != null && textPageForDrag != null) { val pDrag = pageForDrag
val tDrag = textPageForDrag
if (pDrag != null && tDrag != null) {
val touchInContentCoords = val touchInContentCoords =
screenToContentCoordinates( screenToContentCoordinates(
dragPosition dragPosition
) )
val pdfCoords = pageForDrag.mapDeviceCoordsToPage( val pdfCoords = pDrag.mapDeviceCoordsToPage(
startX = 0, startX = 0,
startY = 0, startY = 0,
sizeX = actualBitmapWidthPx, sizeX = actualBitmapWidthPx,
@ -1904,7 +1793,7 @@ internal fun PdfPageComposable(
val charTolerance = 10.0 val charTolerance = 10.0
var charIndexForUpdate = var charIndexForUpdate =
textPageForDrag.textPageGetCharIndexAtPos( tDrag.textPageGetCharIndexAtPos(
x = pdfCoords.x.toDouble(), x = pdfCoords.x.toDouble(),
y = pdfCoords.y.toDouble(), y = pdfCoords.y.toDouble(),
xTolerance = charTolerance, xTolerance = charTolerance,
@ -1913,7 +1802,7 @@ internal fun PdfPageComposable(
if (charIndexForUpdate == -1 && activeDraggingHandle != null) { if (charIndexForUpdate == -1 && activeDraggingHandle != null) {
val pageWidthPdfUnits = val pageWidthPdfUnits =
pageForDrag.getPageWidthPoint() pDrag.getPageWidthPoint()
val wideSearchXTolerance = val wideSearchXTolerance =
pageWidthPdfUnits.toDouble() pageWidthPdfUnits.toDouble()
var ySearchCoordinate = pdfCoords.y.toDouble() var ySearchCoordinate = pdfCoords.y.toDouble()
@ -1921,7 +1810,7 @@ internal fun PdfPageComposable(
val currentRange = selectionCharRange.value val currentRange = selectionCharRange.value
if (currentRange != null) { if (currentRange != null) {
val pageTotalChars = val pageTotalChars =
textPageForDrag.textPageCountChars() tDrag.textPageCountChars()
if (pageTotalChars > 0) { if (pageTotalChars > 0) {
val anchorCharIndex = val anchorCharIndex =
if (activeDraggingHandle == Handle.START) { if (activeDraggingHandle == Handle.START) {
@ -1933,7 +1822,7 @@ internal fun PdfPageComposable(
} }
if (anchorCharIndex in 0..<pageTotalChars) { if (anchorCharIndex in 0..<pageTotalChars) {
val anchorCharBox = val anchorCharBox =
textPageForDrag.textPageGetCharBox( tDrag.textPageGetCharBox(
anchorCharIndex anchorCharIndex
) )
if (anchorCharBox != null) { if (anchorCharBox != null) {
@ -1947,7 +1836,7 @@ internal fun PdfPageComposable(
val wideSearchYTolerance = charTolerance * 1.5 val wideSearchYTolerance = charTolerance * 1.5
if (wideSearchXTolerance > 0) { if (wideSearchXTolerance > 0) {
charIndexForUpdate = charIndexForUpdate =
textPageForDrag.textPageGetCharIndexAtPos( tDrag.textPageGetCharIndexAtPos(
x = pdfCoords.x.toDouble(), x = pdfCoords.x.toDouble(),
y = ySearchCoordinate, y = ySearchCoordinate,
xTolerance = wideSearchXTolerance, xTolerance = wideSearchXTolerance,
@ -1958,7 +1847,7 @@ internal fun PdfPageComposable(
if (charIndexForUpdate != -1) { if (charIndexForUpdate != -1) {
val pageCharCount = val pageCharCount =
textPageForDrag.textPageCountChars() tDrag.textPageCountChars()
val currentRange = selectionCharRange.value val currentRange = selectionCharRange.value
if (currentRange != null) { if (currentRange != null) {
@ -2120,8 +2009,8 @@ internal fun PdfPageComposable(
try { try {
val text = withContext(Dispatchers.IO) { val text = withContext(Dispatchers.IO) {
pageForMenu = pdfDocumentItem.openPage(pdfPageIndex) pageForMenu = pdfDocumentItem.openPage(pdfPageIndex)
textPageForMenu = pageForMenu.openTextPage() textPageForMenu = pageForMenu?.openTextPage()
textPageForMenu.textPageGetText( textPageForMenu?.textPageGetText(
currentRange.first, currentRange.first,
currentRange.second - currentRange.first currentRange.second - currentRange.first
) )
@ -2244,9 +2133,12 @@ internal fun PdfPageComposable(
var pdfiumSelectionSuccessful = false var pdfiumSelectionSuccessful = false
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
tempPage = pdfDocumentItem.openPage(pdfPageIndex) tempPage = pdfDocumentItem.openPage(pdfPageIndex)
tempTextPage = tempPage.openTextPage() tempTextPage = tempPage?.openTextPage()
val pdfCoords = tempPage.mapDeviceCoordsToPage( val tPage = tempPage
val tTextPage = tempTextPage
if (tPage != null && tTextPage != null) {
val pdfCoords = tPage.mapDeviceCoordsToPage(
startX = 0, startX = 0,
startY = 0, startY = 0,
sizeX = actualBitmapWidthPx, sizeX = actualBitmapWidthPx,
@ -2256,7 +2148,7 @@ internal fun PdfPageComposable(
deviceY = touchInContentCoords.y.toInt() deviceY = touchInContentCoords.y.toInt()
) )
val charTolerance = 5.0 val charTolerance = 5.0
val charIndex = tempTextPage.textPageGetCharIndexAtPos( val charIndex = tTextPage.textPageGetCharIndexAtPos(
x = pdfCoords.x.toDouble(), x = pdfCoords.x.toDouble(),
y = pdfCoords.y.toDouble(), y = pdfCoords.y.toDouble(),
xTolerance = charTolerance, xTolerance = charTolerance,
@ -2264,9 +2156,9 @@ internal fun PdfPageComposable(
) )
if (charIndex != -1) { if (charIndex != -1) {
val pageCharCount = tempTextPage.textPageCountChars() val pageCharCount = tTextPage.textPageCountChars()
val wordBoundaries = findWordBoundaries( val wordBoundaries = findWordBoundaries(
tempTextPage, charIndex, pageCharCount tTextPage, charIndex, pageCharCount
) )
if (wordBoundaries != null) { if (wordBoundaries != null) {
@ -2281,14 +2173,14 @@ internal fun PdfPageComposable(
actualBitmapWidthPx, actualBitmapWidthPx,
actualBitmapHeightPx, actualBitmapHeightPx,
currentPageRotation, currentPageRotation,
providedPage = tempPage, providedPage = tPage,
providedTextPage = tempTextPage providedTextPage = tTextPage
) )
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
if (selectionCharRange.value != null && selectedWordScreenRects.isNotEmpty()) { if (selectionCharRange.value != null && selectedWordScreenRects.isNotEmpty()) {
val currentRange = selectionCharRange.value!! val currentRange = selectionCharRange.value!!
val text = withContext(Dispatchers.IO) { val text = withContext(Dispatchers.IO) {
tempTextPage.textPageGetText( tTextPage.textPageGetText(
currentRange.first, currentRange.first,
currentRange.second - currentRange.first currentRange.second - currentRange.first
) )
@ -2312,6 +2204,7 @@ internal fun PdfPageComposable(
} }
} }
} }
}
if (!pdfiumSelectionSuccessful && bitmapState != null) { if (!pdfiumSelectionSuccessful && bitmapState != null) {
Timber.d( Timber.d(
@ -2511,6 +2404,40 @@ internal fun PdfPageComposable(
detectTapGestures(onTap = { tapOffset -> detectTapGestures(onTap = { tapOffset ->
val tapInContentCoords = screenToContentCoordinates(tapOffset) val tapInContentCoords = screenToContentCoordinates(tapOffset)
coroutineScope.launch {
val wasHandled = withContext(Dispatchers.IO) {
try {
pdfDocumentItem.openPage(pdfPageIndex)?.use { page ->
val pagePtr = getNativePointer(page)
if (pagePtr == 0L) {
Timber.tag("PdfInteraction").e("Could not find native pointer for page $pdfPageIndex")
return@withContext false
}
val pdfCoords = page.mapDeviceCoordsToPage(
0, 0, actualBitmapWidthPx, actualBitmapHeightPx,
currentPageRotation, tapInContentCoords.x.toInt(), tapInContentCoords.y.toInt()
)
NativePdfiumBridge.performClick(pagePtr, pdfCoords.x.toDouble(), pdfCoords.y.toDouble())
} ?: false
} catch (e: Exception) {
Timber.tag("PdfInteraction").e(e, "Interaction error")
false
}
}
if (wasHandled) {
Timber.tag("PdfInteraction").i("Action detected. Refreshing page.")
tiles = emptyList()
bitmapState = null
isLoadingPage = true
currentRenderedPageId = "ACTION_${System.currentTimeMillis()}"
}
}
val tapXInBitmap = tapInContentCoords.x val tapXInBitmap = tapInContentCoords.x
val tapYInBitmap = tapInContentCoords.y val tapYInBitmap = tapInContentCoords.y
@ -2636,6 +2563,7 @@ internal fun PdfPageComposable(
coroutineScope.launch { coroutineScope.launch {
val startScale = scale val startScale = scale
val targetScale = if (startScale > 1.1f) 1f else 2.5f val targetScale = if (startScale > 1.1f) 1f else 2.5f
Timber.tag("PdfZoomDebug").i("DoubleTap Triggered: CurrentScale=$startScale, Target=$targetScale")
val startOffset = offset val startOffset = offset
val targetOffsetUnbounded = if (targetScale <= 1.1f) { val targetOffsetUnbounded = if (targetScale <= 1.1f) {
Offset.Zero Offset.Zero
@ -2734,6 +2662,7 @@ internal fun PdfPageComposable(
accumulatedPan += panChange accumulatedPan += panChange
if (accumulatedPan.getDistance() > touchSlop) { if (accumulatedPan.getDistance() > touchSlop) {
mode = 1 mode = 1
Timber.tag("PdfZoomDebug").d("Mode Change: PAN (Single Pointer)")
} }
} else if (pointerCount > 1) { } else if (pointerCount > 1) {
accumulatedZoom *= zoomChange accumulatedZoom *= zoomChange
@ -2744,8 +2673,10 @@ internal fun PdfPageComposable(
if (zoomDiff > 0.05f) { if (zoomDiff > 0.05f) {
mode = 2 mode = 2
Timber.tag("PdfZoomDebug").d("Mode Change: ZOOM (Multi Pointer)")
} else if (panDist > touchSlop) { } else if (panDist > touchSlop) {
mode = 1 mode = 1
Timber.tag("PdfZoomDebug").d("Mode Change: PAN (Multi Pointer)")
} }
} }
} }
@ -2764,6 +2695,7 @@ internal fun PdfPageComposable(
val newY = (offset.y + panChange.y).coerceIn( val newY = (offset.y + panChange.y).coerceIn(
-maxOffsetY, maxOffsetY -maxOffsetY, maxOffsetY
) )
Timber.tag("PdfZoomDebug").v("Panning: Offset $offset -> $newX, $newY (Max: $maxOffsetX, $maxOffsetY)")
offset = Offset(newX, newY) offset = Offset(newX, newY)
event.changes.forEach { event.changes.forEach {
@ -2773,40 +2705,25 @@ internal fun PdfPageComposable(
val oldScale = scale val oldScale = scale
val newScale = (scale * zoomChange).coerceIn(1f, 4f) val newScale = (scale * zoomChange).coerceIn(1f, 4f)
val previousCentroid = event.calculateCentroid( val previousCentroid = event.calculateCentroid(useCurrent = false)
useCurrent = false
)
if (previousCentroid != Offset.Unspecified) { if (previousCentroid != Offset.Unspecified) {
val ratio = newScale / oldScale val ratio = newScale / oldScale
val screenCenter = Offset( val screenCenter = Offset(size.width / 2f, size.height / 2f)
size.width / 2f, size.height / 2f val newOffset = offset * ratio + (previousCentroid - screenCenter) * (1 - ratio) + panChange
)
val newOffset =
offset * ratio + (previousCentroid - screenCenter) * (1 - ratio) + panChange
val contentWidth = actualBitmapWidthPx * newScale val contentWidth = actualBitmapWidthPx * newScale
val contentHeight = actualBitmapHeightPx * newScale val contentHeight = actualBitmapHeightPx * newScale
val maxOffsetX = val maxOffsetX = (contentWidth - size.width).coerceAtLeast(0f) / 2f
(contentWidth - size.width).coerceAtLeast(0f) / 2f val maxOffsetY = (contentHeight - size.height).coerceAtLeast(0f) / 2f
val maxOffsetY =
(contentHeight - size.height).coerceAtLeast(0f) / 2f Timber.tag("PdfZoomDebug").v("Scaling: RawZoom=$zoomChange, Scale=$oldScale->$newScale, Offset=$offset->$newOffset (Clamped Max: $maxOffsetX, $maxOffsetY)")
offset = Offset( offset = Offset(
x = newOffset.x.coerceIn( x = newOffset.x.coerceIn(-maxOffsetX, maxOffsetX),
-maxOffsetX, maxOffsetX y = newOffset.y.coerceIn(-maxOffsetY, maxOffsetY)
), y = newOffset.y.coerceIn(
-maxOffsetY, maxOffsetY
)
) )
scale = newScale scale = newScale
if (scale < 1.05f) {
scale = 1f
offset = Offset.Zero
onScaleChanged(scale) onScaleChanged(scale)
} else {
onScaleChanged(scale)
}
} }
event.changes.forEach { event.changes.forEach {
if (it.positionChanged()) it.consume() if (it.positionChanged()) it.consume()
@ -2881,7 +2798,17 @@ internal fun PdfPageComposable(
} }
} while (!canceled && event.changes.any { it.pressed }) } while (!canceled && event.changes.any { it.pressed })
if (mode == 1 && scale > 1f) { if (scale > 1f && scale < 1.05f) {
coroutineScope.launch {
val startScale = scale
val startOffset = offset
Animatable(0f).animateTo(1f) {
scale = lerp(startScale, 1f, value)
offset = lerp(startOffset, Offset.Zero, value)
onScaleChanged(scale)
}
}
} else if (mode == 1 && scale > 1f) {
val velocity = velocityTracker.calculateVelocity() val velocity = velocityTracker.calculateVelocity()
val contentWidth = actualBitmapWidthPx * scale val contentWidth = actualBitmapWidthPx * scale
val contentHeight = actualBitmapHeightPx * scale val contentHeight = actualBitmapHeightPx * scale
@ -3052,6 +2979,13 @@ internal fun PdfPageComposable(
} }
}, contentAlignment = Alignment.Center }, contentAlignment = Alignment.Center
) { ) {
SideEffect {
if (effectiveScale > 1f) {
Timber.tag("PdfZoomDiagnostics").d(
"BoxWithConstraints Page $pageIndex: MaxW=$maxWidth, MaxH=$maxHeight"
)
}
}
val imeInsets = WindowInsets.ime val imeInsets = WindowInsets.ime
val screenHeight = constraints.maxHeight.toFloat() val screenHeight = constraints.maxHeight.toFloat()
@ -3189,7 +3123,7 @@ internal fun PdfPageComposable(
"Highlighting (Pdfium): page $pageIndex, index: ${ttsHighlightData.startIndex}, len: ${ttsHighlightData.length}" "Highlighting (Pdfium): page $pageIndex, index: ${ttsHighlightData.startIndex}, len: ${ttsHighlightData.length}"
) )
rects = withContext(Dispatchers.IO) { rects = withContext(Dispatchers.IO) {
pdfDocumentItem.openPage(pdfPageIndex).use { page -> pdfDocumentItem.openPage(pdfPageIndex)?.use { page ->
page.openTextPage().use { textPage -> page.openTextPage().use { textPage ->
val pdfRectsF = textPage.textPageGetRectsForRanges( val pdfRectsF = textPage.textPageGetRectsForRanges(
intArrayOf( intArrayOf(
@ -3214,7 +3148,7 @@ internal fun PdfPageComposable(
emptyList() emptyList()
} }
} }
} } ?: emptyList()
} }
} }
@ -3290,7 +3224,8 @@ internal fun PdfPageComposable(
currentContainerMaxHeight, currentContainerMaxHeight,
density, density,
virtualPage, virtualPage,
isVisible isVisible,
currentRenderedPageId
) { ) {
if (!isVisible && !isVerticalScroll) return@LaunchedEffect if (!isVisible && !isVerticalScroll) return@LaunchedEffect
@ -3381,7 +3316,7 @@ internal fun PdfPageComposable(
) )
return@withContext null return@withContext null
} }
val page = pdfDocumentItem.openPage(pdfPageIndex) val page = pdfDocumentItem.openPage(pdfPageIndex) ?: return@withContext null
val rotation = page.getPageRotation() val rotation = page.getPageRotation()
val screenDpi = (density.density * 160).roundToInt() val screenDpi = (density.density * 160).roundToInt()
val originalWidthPdfUnits = page.getPageWidth(screenDpi) val originalWidthPdfUnits = page.getPageWidth(screenDpi)
@ -3730,12 +3665,14 @@ internal fun PdfPageComposable(
try { try {
val charCount = withContext(Dispatchers.IO) { val charCount = withContext(Dispatchers.IO) {
page = pdfDocumentItem.openPage(pdfPageIndex) page = pdfDocumentItem.openPage(pdfPageIndex)
textPage = page.openTextPage() textPage = page?.openTextPage()
textPage.textPageCountChars() textPage?.textPageCountChars() ?: 0
} }
if (charCount > 0) { if (charCount > 0) {
selectionCharRange.value = Pair(0, charCount) selectionCharRange.value = Pair(0, charCount)
val tPage = page
val tTextPage = textPage
updateSelectionVisuals( updateSelectionVisuals(
pdfDocumentItem, pdfDocumentItem,
pdfPageIndex, pdfPageIndex,
@ -3743,12 +3680,12 @@ internal fun PdfPageComposable(
actualBitmapWidthPx, actualBitmapWidthPx,
actualBitmapHeightPx, actualBitmapHeightPx,
currentPageRotation, currentPageRotation,
providedPage = page, providedPage = tPage,
providedTextPage = textPage providedTextPage = tTextPage
) )
if (selectedWordScreenRects.isNotEmpty()) { if (selectedWordScreenRects.isNotEmpty()) {
val fullText = withContext(Dispatchers.IO) { val fullText = withContext(Dispatchers.IO) {
textPage!!.textPageGetText(0, charCount) tTextPage?.textPageGetText(0, charCount)
} }
if (!fullText.isNullOrBlank()) { if (!fullText.isNullOrBlank()) {
val combinedRect = Rect(selectedWordScreenRects.first()) val combinedRect = Rect(selectedWordScreenRects.first())
@ -3904,23 +3841,19 @@ private fun PdfBitmapLayer(
colorFilter: ColorFilter? = null, colorFilter: ColorFilter? = null,
isDarkMode: Boolean = false isDarkMode: Boolean = false
) { ) {
SideEffect {
Timber.tag("PdfDrawPerf")
.v("BITMAP LAYER: SideEffect (Scale: $effectiveScale, Tiles: ${tiles.size})")
}
Canvas(modifier = Modifier Canvas(modifier = Modifier
.fillMaxSize() .fillMaxSize()
.graphicsLayer()) { .graphicsLayer()) {
val drawStart = System.nanoTime()
Timber.tag("PdfDrawPerf").v("BITMAP LAYER: Canvas Draw Start (Tiles: ${tiles.size})")
translate(left = centeringOffsetX, top = centeringOffsetY) { translate(left = centeringOffsetX, top = centeringOffsetY) {
// THIS is the fix: Hard clip to the target bounds so edge tiles can't bleed out.
clipRect(left = 0f, top = 0f, right = targetWidth.toFloat(), bottom = targetHeight.toFloat()) {
if (bitmapState != null && !bitmapState.isRecycled) { if (bitmapState != null && !bitmapState.isRecycled) {
val dstW = if (targetWidth > 0) targetWidth else bitmapState.width val dstW = if (targetWidth > 0) targetWidth else bitmapState.width
val dstH = if (targetHeight > 0) targetHeight else bitmapState.height val dstH = if (targetHeight > 0) targetHeight else bitmapState.height
val srcSize = IntSize(bitmapState.width, bitmapState.height) val srcSize = IntSize(bitmapState.width, bitmapState.height)
val dstSize = IntSize(dstW, dstH) val dstSize = IntSize(dstW, dstH)
// 1. Draw Base Bitmap (with Dark Mode filter if active) // 1. Draw Base Bitmap
drawImage( drawImage(
image = bitmapState.asImageBitmap(), image = bitmapState.asImageBitmap(),
srcOffset = IntOffset.Zero, srcOffset = IntOffset.Zero,
@ -3930,7 +3863,7 @@ private fun PdfBitmapLayer(
colorFilter = colorFilter colorFilter = colorFilter
) )
// 3. Draw Tiles // 2. Draw High-Res Tiles
if (effectiveScale > 1f) { if (effectiveScale > 1f) {
tiles.forEach { tile -> tiles.forEach { tile ->
if (!tile.bitmap.isRecycled) { if (!tile.bitmap.isRecycled) {
@ -3949,8 +3882,7 @@ private fun PdfBitmapLayer(
} }
} }
} }
val drawTime = (System.nanoTime() - drawStart) / 1_000_000f }
Timber.tag("PdfDrawPerf").v("BITMAP LAYER: Canvas draw finished in ${drawTime}ms")
} }
} }
@ -4337,6 +4269,7 @@ internal object PdfAnnotationRenderHelper {
} }
} }
@Suppress("SameParameterValue")
@Composable @Composable
private fun PdfAnnotationLayer( private fun PdfAnnotationLayer(
actualBitmapWidthPx: Int, actualBitmapWidthPx: Int,
@ -4454,13 +4387,6 @@ private fun PdfAnnotationLayer(
@Composable @Composable
private fun PdfPageStaticLayer(data: PageStaticData) { private fun PdfPageStaticLayer(data: PageStaticData) {
SideEffect {
Timber.tag("PdfDrawPerf").v(
"STATIC LAYER: Composition (Should not happen during draw). DataHash: ${data.hashCode()}"
)
}
Timber.tag("PdfDrawPerf").v("STATIC LAYER: Recomposing")
PdfBitmapLayer( PdfBitmapLayer(
bitmapState = data.bitmap.item, bitmapState = data.bitmap.item,
tiles = data.tiles.item, tiles = data.tiles.item,
@ -4591,15 +4517,11 @@ private fun PdfPageRenderer(
onHighlightDelete: (String) -> Unit, onHighlightDelete: (String) -> Unit,
onTts: (Int, Int) -> Unit, onTts: (Int, Int) -> Unit,
) { ) {
SideEffect {
Timber.tag("PdfPerf").v("PAGE_RENDERER: Recomposing Page ${selectionData.pageIndex}. DraggingHandle=${activeDraggingHandle != null}")
}
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.graphicsLayer { .graphicsLayer {
Timber.tag("PdfPerf").v("GraphicsLayer Update: Scale=$scale, Offset=$offset")
scaleX = scale scaleX = scale
scaleY = scale scaleY = scale
translationX = offset.x translationX = offset.x
@ -4607,9 +4529,7 @@ private fun PdfPageRenderer(
}) { }) {
// Layer 1: The Heavy Bitmap // Layer 1: The Heavy Bitmap
Box(modifier = Modifier Box(modifier = Modifier.fillMaxSize().graphicsLayer()) {
.fillMaxSize()
.graphicsLayer()) {
PdfPageStaticLayer(data = staticData) PdfPageStaticLayer(data = staticData)
} }
@ -4636,11 +4556,9 @@ private fun PdfPageRenderer(
selectionHighlightColor = selectionData.selectionHighlightColor selectionHighlightColor = selectionData.selectionHighlightColor
) )
// Layer 3: Annotations (Drawing) // Layer 3: Annotations & Text
if (staticData.targetWidth > 0 && staticData.targetHeight > 0) { if (staticData.targetWidth > 0 && staticData.targetHeight > 0) {
Box(modifier = Modifier Box(modifier = Modifier.fillMaxSize().graphicsLayer()) {
.fillMaxSize()
.graphicsLayer()) {
PdfAnnotationLayer( PdfAnnotationLayer(
actualBitmapWidthPx = staticData.targetWidth, actualBitmapWidthPx = staticData.targetWidth,
actualBitmapHeightPx = staticData.targetHeight, actualBitmapHeightPx = staticData.targetHeight,
@ -4653,19 +4571,6 @@ private fun PdfPageRenderer(
} }
if (richTextController != null) { if (richTextController != null) {
val density = LocalDensity.current
val textMeasurer = androidx.compose.ui.text.rememberTextMeasurer()
val targetW = staticData.targetWidth.toFloat()
val targetH = staticData.targetHeight.toFloat()
LaunchedEffect(targetW, targetH, density) {
if (targetW > 0 && targetH > 0) {
richTextController.updateLayoutConfig(
targetW, targetH, density, textMeasurer
)
}
}
val isEditable = isEditMode && selectedTool == InkType.TEXT val isEditable = isEditMode && selectedTool == InkType.TEXT
val hasContent = richTextController.pageLayouts.any { val hasContent = richTextController.pageLayouts.any {
it.pageIndex == selectionData.pageIndex it.pageIndex == selectionData.pageIndex
@ -4686,7 +4591,6 @@ private fun PdfPageRenderer(
} }
} }
// Text Boxes
textBoxes.forEach { box -> textBoxes.forEach { box ->
val isDraggingThisBox = (box.id == draggingBoxId) val isDraggingThisBox = (box.id == draggingBoxId)
val boxAlpha = if (isDraggingThisBox) 0f else 1f val boxAlpha = if (isDraggingThisBox) 0f else 1f
@ -4753,6 +4657,7 @@ private fun PdfPageRenderer(
) )
} }
} }
}
// Layer 4: Page Number Indicator // Layer 4: Page Number Indicator
if (totalPages > 0) { if (totalPages > 0) {
@ -4794,7 +4699,6 @@ private fun PdfPageRenderer(
onCanvasSizeChanged(size.width, size.height) onCanvasSizeChanged(size.width, size.height)
} }
} }
}
val teardropPainter = painterResource(id = R.drawable.teardrop) val teardropPainter = painterResource(id = R.drawable.teardrop)
@ -4932,7 +4836,6 @@ private fun PdfPageRenderer(
): IntOffset { ): IntOffset {
val coords = layoutCoordinates ?: return IntOffset.Zero val coords = layoutCoordinates ?: return IntOffset.Zero
// Map the bitmap-space anchor (the icon) to window-space
val topLeftLocal = contentToScreenCoordinates(Offset( val topLeftLocal = contentToScreenCoordinates(Offset(
menuState.anchorRect.left.toFloat(), menuState.anchorRect.left.toFloat(),
menuState.anchorRect.top.toFloat())) menuState.anchorRect.top.toFloat()))
@ -4944,14 +4847,12 @@ private fun PdfPageRenderer(
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 gapPx = with(density) { 16.dp.toPx() } // Increased gap val gapPx = with(density) { 16.dp.toPx() }
// Try placing ABOVE the icon first
var yInWindow = (topLeftWindow.y - popupContentSize.height - gapPx).toInt() var yInWindow = (topLeftWindow.y - popupContentSize.height - gapPx).toInt()
if (yInWindow < 0) { if (yInWindow < 0) {
yInWindow = (bottomRightWindow.y + gapPx).toInt() yInWindow = (bottomRightWindow.y + gapPx).toInt()
// Ensure it doesn't get pushed out of the bottom boundary either
if (yInWindow + popupContentSize.height > windowSize.height) { if (yInWindow + popupContentSize.height > windowSize.height) {
yInWindow = windowSize.height - popupContentSize.height - gapPx.toInt() yInWindow = windowSize.height - popupContentSize.height - gapPx.toInt()
} }
@ -4975,11 +4876,9 @@ private fun PdfPageRenderer(
onSearch = onSearch, onSearch = onSearch,
onSelectAll = onSelectAll, onSelectAll = onSelectAll,
onColorSelected = { color -> onColorSelected = { color ->
Timber.tag("PdfHighlightDebug").d("PdfSelectionMenuPopup onColorSelected: $color, isExisting=${menuState.isExistingHighlight}")
if (menuState.isExistingHighlight && menuState.highlightId != null) { if (menuState.isExistingHighlight && menuState.highlightId != null) {
onHighlightUpdate(menuState.highlightId, color) onHighlightUpdate(menuState.highlightId, color)
} else { } else {
Timber.tag("PdfHighlightDebug").d("Calling onHighlightAdd for page ${selectionData.pageIndex}")
onHighlightAdd( onHighlightAdd(
selectionData.pageIndex, menuState.charRange, menuState.selectedText, selectionData.pageIndex, menuState.charRange, menuState.selectedText,
color color
@ -5190,3 +5089,32 @@ fun PdfRichTextLayer(
} }
} }
} }
private fun getNativePointer(obj: Any): Long {
val priorityFields = listOf("pagePtr", "mNativePage", "page")
for (name in priorityFields) {
try {
val field = obj.javaClass.getDeclaredField(name)
field.isAccessible = true
val value = field.get(obj)
if (value is Long && value != 0L) return value
if (value != null && value !is Long) {
val nestedPtr = getNativePointer(value)
if (nestedPtr != 0L) return nestedPtr
}
} catch (_: Exception) {}
}
try {
for (field in obj.javaClass.declaredFields) {
if (field.type == Long::class.java || field.type == Long::class.javaPrimitiveType) {
field.isAccessible = true
val value = field.get(obj) as Long
if (value > 0xFFFFFFFFL) return value
}
}
} catch (_: Exception) {}
return 0L
}

View file

@ -131,13 +131,12 @@ object PdfToHtmlGenerator {
headerFooterStrings: Set<String> headerFooterStrings: Set<String>
): String { ): String {
return try { return try {
doc.openPage(pageIdx).use { page -> doc.openPage(pageIdx)?.use { page ->
if (page == null) return buildEmptyPageSection(pageNumber)
page.openTextPage().use { textPage -> page.openTextPage().use { textPage ->
val charCount = textPage.textPageCountChars() val charCount = textPage.textPageCountChars()
val pagePtr = page.page.pagePtr val pagePtr = getNativePointer(page)
val textPagePtr = textPage.page.pagePtr val textPagePtr = getNativePointer(textPage)
val imageElements = mutableListOf<ImageElement>() val imageElements = mutableListOf<ImageElement>()
val objCount = NativePdfiumBridge.getPageObjectCount(pagePtr) val objCount = NativePdfiumBridge.getPageObjectCount(pagePtr)
@ -178,7 +177,7 @@ object PdfToHtmlGenerator {
val flags: IntArray? val flags: IntArray?
val charBoxes: FloatArray? val charBoxes: FloatArray?
synchronized(PdfiumCore.lock) { synchronized(NativePdfiumBridge::class.java) {
sizes = NativePdfiumBridge.getPageFontSizes(textPagePtr, actualCount) sizes = NativePdfiumBridge.getPageFontSizes(textPagePtr, actualCount)
weights = NativePdfiumBridge.getPageFontWeights(textPagePtr, actualCount) weights = NativePdfiumBridge.getPageFontWeights(textPagePtr, actualCount)
flags = NativePdfiumBridge.getPageFontFlags(textPagePtr, actualCount) flags = NativePdfiumBridge.getPageFontFlags(textPagePtr, actualCount)
@ -293,8 +292,8 @@ object PdfToHtmlGenerator {
} }
buildPageHtml(pageNumber, finalElements, headerFooterStrings) buildPageHtml(pageNumber, finalElements, headerFooterStrings)
} } ?: buildEmptyPageSection(pageNumber)
} } ?: buildEmptyPageSection(pageNumber)
} catch (e: Exception) { } catch (e: Exception) {
Timber.tag(TAG).w(e, "Error extracting page $pageIdx") Timber.tag(TAG).w(e, "Error extracting page $pageIdx")
buildEmptyPageSection(pageNumber) buildEmptyPageSection(pageNumber)
@ -483,7 +482,7 @@ object PdfToHtmlGenerator {
for (pageIdx in samplePages) { for (pageIdx in samplePages) {
try { try {
doc.openPage(pageIdx).use { page -> doc.openPage(pageIdx)?.use { page ->
page.openTextPage().use { textPage -> page.openTextPage().use { textPage ->
val charCount = textPage.textPageCountChars() val charCount = textPage.textPageCountChars()
if (charCount <= 0) return@use if (charCount <= 0) return@use
@ -511,4 +510,21 @@ object PdfToHtmlGenerator {
.replace(">", "&gt;") .replace(">", "&gt;")
.replace("\"", "&quot;") .replace("\"", "&quot;")
.replace("'", "&#39;") .replace("'", "&#39;")
private fun getNativePointer(obj: Any): Long {
val priorityFields = listOf("pagePtr", "mNativePage", "page")
for (name in priorityFields) {
try {
val field = obj.javaClass.getDeclaredField(name)
field.isAccessible = true
val value = field.get(obj)
if (value is Long && value != 0L) return value
if (value != null && value !is Long) {
val nestedPtr = getNativePointer(value)
if (nestedPtr != 0L) return nestedPtr
}
} catch (_: Exception) {}
}
return 0L
}
} }

View file

@ -358,8 +358,12 @@ internal fun PdfVerticalReader(
val constrainedX = targetPanX.coerceIn(minPanX, maxPanX) val constrainedX = targetPanX.coerceIn(minPanX, maxPanX)
val constrainedY = targetPanY.coerceIn(minPanY, headerHeightPx) val constrainedY = targetPanY.coerceIn(minPanY, headerHeightPx)
// DEDICATED LOG if (constrainedZoom > 1.01f) {
Timber.tag("PdfZoomDebug").v("Clamp Internal: Zoom=$constrainedZoom, PanBoundsX=[$minPanX, $maxPanX], PanBoundsY=[$minPanY, $headerHeightPx]") Timber.tag("PdfZoomIssue").v(
"Clamp: Zoom=$constrainedZoom, targetY=$targetPanY, finalY=$constrainedY, " +
"boundsY=[$minPanY, $headerHeightPx], zoomedHeight=$zoomedDocHeight"
)
}
return Triple(constrainedZoom, constrainedX, constrainedY) return Triple(constrainedZoom, constrainedX, constrainedY)
} }
@ -370,10 +374,14 @@ internal fun PdfVerticalReader(
return clampValues(targetZoom, targetPanX, targetPanY) return clampValues(targetZoom, targetPanX, targetPanY)
} }
var isFlinging by remember { mutableStateOf(false) }
var isFastFlinging by remember { mutableStateOf(false) }
var isInteracting by remember { mutableStateOf(false) }
LaunchedEffect( LaunchedEffect(
totalDocHeight, screenHeight, headerHeightPx, footerHeightPx, zoomAnimatable.value totalDocHeight, screenHeight, headerHeightPx, footerHeightPx, zoomAnimatable.value, isInteracting, isFlinging
) { ) {
if (zoomAnimatable.isRunning || panXAnimatable.isRunning || panYAnimatable.isRunning) { if (zoomAnimatable.isRunning || panXAnimatable.isRunning || panYAnimatable.isRunning || isInteracting || isFlinging) {
return@LaunchedEffect return@LaunchedEffect
} }
@ -446,10 +454,6 @@ internal fun PdfVerticalReader(
} }
var selectionClearTrigger by remember { mutableLongStateOf(0L) } var selectionClearTrigger by remember { mutableLongStateOf(0L) }
var isFlinging by remember { mutableStateOf(false) }
var isFastFlinging by remember { mutableStateOf(false) }
var draggingBoxId by remember { mutableStateOf<String?>(null) } var draggingBoxId by remember { mutableStateOf<String?>(null) }
var draggingBoxOffset by remember { mutableStateOf(Offset.Zero) } var draggingBoxOffset by remember { mutableStateOf(Offset.Zero) }
var draggingBoxSize by remember { mutableStateOf(Size.Zero) } var draggingBoxSize by remember { mutableStateOf(Size.Zero) }
@ -549,7 +553,6 @@ internal fun PdfVerticalReader(
} }
var highResScale by remember { mutableFloatStateOf(1f) } var highResScale by remember { mutableFloatStateOf(1f) }
var isInteracting by remember { mutableStateOf(false) }
LaunchedEffect(isInteracting) { LaunchedEffect(isInteracting) {
if (isInteracting && isAutoScrollPlaying) { if (isInteracting && isAutoScrollPlaying) {
@ -617,8 +620,12 @@ internal fun PdfVerticalReader(
imeBottom, imeBottom,
isEditMode, isEditMode,
selectedTool, selectedTool,
zoomAnimatable.value zoomAnimatable.value,
isInteracting,
isFlinging
) { ) {
if (isInteracting || isFlinging) return@LaunchedEffect
val currentZoom = zoomAnimatable.value val currentZoom = zoomAnimatable.value
val zoomedDocHeight = totalDocHeight * currentZoom val zoomedDocHeight = totalDocHeight * currentZoom
@ -897,6 +904,7 @@ internal fun PdfVerticalReader(
) )
val down = awaitFirstDown(requireUnconsumed = false) val down = awaitFirstDown(requireUnconsumed = false)
isInteracting = true
Timber.tag("PointerTypeDebug").d("VerticalReader: Input Type detected: ${down.type}") Timber.tag("PointerTypeDebug").d("VerticalReader: Input Type detected: ${down.type}")
@ -1015,31 +1023,35 @@ internal fun PdfVerticalReader(
(!isEditMode || selectedTool == InkType.TEXT || isMultiTouch || (isStylusOnlyMode && isTouchInput)) (!isEditMode || selectedTool == InkType.TEXT || isMultiTouch || (isStylusOnlyMode && isTouchInput))
if (shouldScroll) { if (shouldScroll) {
if (!isInteracting) {
Timber.tag("PdfTouchDebug").i(
"VerticalReader: Taking control."
)
isInteracting = true
}
panLocked = true panLocked = true
if (zoomChange != 1f || panChange != Offset.Zero) { if (zoomChange != 1f || panChange != Offset.Zero) {
var effectiveZoomChange = zoomChange var effectiveZoomChange = zoomChange
if (gestureDisambiguationMode == 1) effectiveZoomChange = 1f
if (gestureDisambiguationMode == 1) {
effectiveZoomChange = 1f
}
val oldZoom = accumulatedZoom val oldZoom = accumulatedZoom
val rawTargetZoom = oldZoom * effectiveZoomChange val rawTargetZoom = oldZoom * effectiveZoomChange
val constrainedZoom = rawTargetZoom.coerceIn(1f, 5f) val constrainedZoom = rawTargetZoom.coerceIn(1f, 5f)
val actualZoomFactor = if (oldZoom == 0f) 1f
else constrainedZoom / oldZoom val prevCentroid = centroid - panChange
val rawNewPanX = val contentPivotX = (prevCentroid.x - accumulatedPanX) / oldZoom
(accumulatedPanX + panChange.x) - (centroid.x - accumulatedPanX) * (actualZoomFactor - 1) val contentPivotY = (prevCentroid.y - accumulatedPanY) / oldZoom
val rawNewPanY =
(accumulatedPanY + panChange.y) - (centroid.y - accumulatedPanY) * (actualZoomFactor - 1) Timber.tag("PdfZoomIssue").v(
"PivotCalc: ScreenCentroidY=${centroid.y}, DocumentPanY=$accumulatedPanY, " +
"CalculatedContentPivotY=$contentPivotY"
)
val rawNewPanX = centroid.x - (contentPivotX * constrainedZoom)
val rawNewPanY = centroid.y - (contentPivotY * constrainedZoom)
if (effectiveZoomChange > 1.0f) {
Timber.tag("PdfZoomIssue").d(
"PinchIn FIXED: ZoomFactor=$effectiveZoomChange, CentroidY=${centroid.y}, " +
"OldPanY=$accumulatedPanY, ResultRawPanY=$rawNewPanY"
)
}
val (finalZoom, finalX, finalY) = clampCamera( val (finalZoom, finalX, finalY) = clampCamera(
constrainedZoom, rawNewPanX, rawNewPanY constrainedZoom, rawNewPanX, rawNewPanY
) )
@ -1064,11 +1076,8 @@ internal fun PdfVerticalReader(
if (event.changes.isNotEmpty()) { if (event.changes.isNotEmpty()) {
velocityTrackerAccumulator += panChange velocityTrackerAccumulator += panChange
val time = event.changes[0].uptimeMillis val time = event.changes[0].uptimeMillis
tracker.addPosition( tracker.addPosition(time, velocityTrackerAccumulator)
time, velocityTrackerAccumulator
)
} }
} }
} }

View file

@ -33,7 +33,14 @@ import android.graphics.Bitmap
import android.graphics.RectF import android.graphics.RectF
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Bundle
import android.os.CancellationSignal
import android.os.ParcelFileDescriptor import android.os.ParcelFileDescriptor
import android.print.PageRange
import android.print.PrintAttributes
import android.print.PrintDocumentAdapter
import android.print.PrintDocumentInfo
import android.print.PrintManager
import android.provider.OpenableColumns import android.provider.OpenableColumns
import android.util.Base64 import android.util.Base64
import android.widget.Toast import android.widget.Toast
@ -43,6 +50,7 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.OptIn import androidx.annotation.OptIn
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
@ -57,12 +65,16 @@ import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.rememberDraggableState
import androidx.compose.foundation.gestures.waitForUpOrCancellation import androidx.compose.foundation.gestures.waitForUpOrCancellation
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsDraggedAsState
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.BoxWithConstraints
@ -74,6 +86,7 @@ import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.ime import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@ -83,7 +96,10 @@ import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.selection.selectable
@ -94,6 +110,7 @@ import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.ArrowDownward import androidx.compose.material.icons.filled.ArrowDownward
import androidx.compose.material.icons.filled.ArrowUpward import androidx.compose.material.icons.filled.ArrowUpward
import androidx.compose.material.icons.filled.Brush import androidx.compose.material.icons.filled.Brush
@ -129,7 +146,6 @@ import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MenuDefaults import androidx.compose.material3.MenuDefaults
import androidx.compose.material3.ModalDrawerSheet import androidx.compose.material3.ModalDrawerSheet
@ -245,8 +261,8 @@ import com.aryan.reader.R
import com.aryan.reader.SearchResult import com.aryan.reader.SearchResult
import com.aryan.reader.SearchTopBar import com.aryan.reader.SearchTopBar
import com.aryan.reader.SummarizationPopup import com.aryan.reader.SummarizationPopup
import com.aryan.reader.TooltipIconButton
import com.aryan.reader.SummarizationResult import com.aryan.reader.SummarizationResult
import com.aryan.reader.TooltipIconButton
import com.aryan.reader.TtsSettingsSheet import com.aryan.reader.TtsSettingsSheet
import com.aryan.reader.countWords import com.aryan.reader.countWords
import com.aryan.reader.epubreader.AutoScrollControls import com.aryan.reader.epubreader.AutoScrollControls
@ -270,8 +286,7 @@ import com.aryan.reader.tts.TtsPlaybackManager
import com.aryan.reader.tts.loadTtsMode import com.aryan.reader.tts.loadTtsMode
import com.aryan.reader.tts.rememberTtsController import com.aryan.reader.tts.rememberTtsController
import com.aryan.reader.tts.splitTextIntoChunks import com.aryan.reader.tts.splitTextIntoChunks
import io.legere.pdfiumandroid.PdfDocument import io.legere.pdfiumandroid.api.Bookmark
import io.legere.pdfiumandroid.PdfPasswordException
import io.legere.pdfiumandroid.suspend.PdfDocumentKt import io.legere.pdfiumandroid.suspend.PdfDocumentKt
import io.legere.pdfiumandroid.suspend.PdfPageKt import io.legere.pdfiumandroid.suspend.PdfPageKt
import io.legere.pdfiumandroid.suspend.PdfTextPageKt import io.legere.pdfiumandroid.suspend.PdfTextPageKt
@ -292,6 +307,8 @@ import org.json.JSONObject
import timber.log.Timber import timber.log.Timber
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import java.io.File import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.net.HttpURLConnection import java.net.HttpURLConnection
import java.net.URL import java.net.URL
import kotlin.math.max import kotlin.math.max
@ -574,8 +591,153 @@ private fun loadDisplayMode(context: Context): DisplayMode {
} }
} }
private const val MAX_FIXED_RECURSION = 128
/**
* Patches the library bug where siblings are truncated due to depth-state leakage.
*/
suspend fun PdfDocumentKt.getFixedTableOfContents(): List<Bookmark> {
val tag = "PdfTocFix"
Timber.tag(tag).i("Starting Pure Reflection Traversal...")
return try {
// 1. Get the 'document' field (PdfDocumentU) from PdfDocumentKt
val documentField = PdfDocumentKt::class.java.getDeclaredField("document").apply { isAccessible = true }
val docUInstance = documentField.get(this) ?: return getTableOfContents()
// 2. Get the 'nativeDocument' field from PdfDocumentU
val nativeDocField = docUInstance.javaClass.getDeclaredField("nativeDocument").apply { isAccessible = true }
val nativeDocInstance = nativeDocField.get(docUInstance) ?: return getTableOfContents()
// 3. Get the native pointer (long) from PdfDocumentU
val ptrField = docUInstance.javaClass.getDeclaredField("mNativeDocPtr").apply { isAccessible = true }
val mNativeDocPtr = ptrField.get(docUInstance) as Long
// 4. Look up native methods using primitive 'long' types (mandatory for JNI)
val nClass = nativeDocInstance.javaClass
val lp = Long::class.javaPrimitiveType!! // Shorthand for 'long'
val getTitleM = nClass.getMethod("getBookmarkTitle", lp)
val getDestIdxM = nClass.getMethod("getBookmarkDestIndex", lp, lp)
val getFirstChildM = nClass.getMethod("getFirstChildBookmark", lp, lp)
val getSiblingM = nClass.getMethod("getSiblingBookmark", lp, lp)
val topLevel = mutableListOf<Bookmark>()
val visited = mutableSetOf<Long>()
/**
* Corrected traversal: Iterative for siblings, recursive for children.
*/
fun walk(parentList: MutableList<Bookmark>, startPtr: Long, level: Int) {
var currentPtr = startPtr
var itemIndex = 0
while (currentPtr != 0L) {
if (visited.contains(currentPtr)) break
visited.add(currentPtr)
val title = getTitleM.invoke(nativeDocInstance, currentPtr) as? String ?: "Untitled"
val pageIdx = getDestIdxM.invoke(nativeDocInstance, mNativeDocPtr, currentPtr) as Long
Timber.tag(tag).v("Lvl $level | Item $itemIndex | Ptr: 0x${java.lang.Long.toHexString(currentPtr)} | $title")
val bookmark = Bookmark().apply {
this.mNativePtr = currentPtr
this.title = title
this.pageIdx = pageIdx
}
parentList.add(bookmark)
// Recursive dive into children
val firstChild = getFirstChildM.invoke(nativeDocInstance, mNativeDocPtr, currentPtr) as Long
if (firstChild != 0L && level < MAX_FIXED_RECURSION) {
walk(bookmark.children, firstChild, level + 1)
}
// Iterative move to next sibling
currentPtr = getSiblingM.invoke(nativeDocInstance, mNativeDocPtr, currentPtr) as Long
itemIndex++
}
}
// 5. Start from the root (Pass 0L as primitive long)
val firstRoot = getFirstChildM.invoke(nativeDocInstance, mNativeDocPtr, 0L) as Long
if (firstRoot != 0L) {
walk(topLevel, firstRoot, 0)
}
if (topLevel.isEmpty()) {
Timber.tag(tag).w("No items found, falling back to library.")
getTableOfContents()
} else {
Timber.tag(tag).i("TOC Successfully Patched! Nodes: ${visited.size}")
topLevel
}
} catch (e: Exception) {
Timber.tag(tag).e(e, "Reflection traversal critical error.")
this.getTableOfContents()
}
}
internal data class PdfBookmark(val pageIndex: Int, val title: String, val totalPages: Int) internal data class PdfBookmark(val pageIndex: Int, val title: String, val totalPages: Int)
class PdfPrintDocumentAdapter(
private val context: Context,
private val pdfUri: Uri,
private val fileName: String
) : PrintDocumentAdapter() {
override fun onLayout(
oldAttributes: PrintAttributes?,
newAttributes: PrintAttributes?,
cancellationSignal: CancellationSignal?,
callback: LayoutResultCallback?,
extras: Bundle?
) {
if (cancellationSignal?.isCanceled == true) {
callback?.onLayoutCancelled()
return
}
val info = PrintDocumentInfo.Builder(fileName)
.setContentType(PrintDocumentInfo.CONTENT_TYPE_DOCUMENT)
.build()
callback?.onLayoutFinished(info, true)
}
override fun onWrite(
pages: Array<out PageRange>?,
destination: ParcelFileDescriptor?,
cancellationSignal: CancellationSignal?,
callback: WriteResultCallback?
) {
try {
context.contentResolver.openFileDescriptor(pdfUri, "r")?.use { pfd ->
FileInputStream(pfd.fileDescriptor).use { input ->
FileOutputStream(destination?.fileDescriptor).use { output ->
val buf = ByteArray(8192)
var bytesRead: Int
while (input.read(buf).also { bytesRead = it } > 0) {
if (cancellationSignal?.isCanceled == true) {
Timber.tag("PdfPrint").d("Print job cancelled during write")
callback?.onWriteCancelled()
return
}
output.write(buf, 0, bytesRead)
}
}
}
}
Timber.tag("PdfPrint").i("PDF successfully streamed to print spooler")
callback?.onWriteFinished(arrayOf(PageRange.ALL_PAGES))
} catch (e: Exception) {
Timber.tag("PdfPrint").e(e, "Error writing PDF to print spooler")
callback?.onWriteFailed(e.message)
}
}
}
private fun loadPdfBookmarksFromJson(bookmarksJson: String?): Set<PdfBookmark> { private fun loadPdfBookmarksFromJson(bookmarksJson: String?): Set<PdfBookmark> {
if (bookmarksJson.isNullOrBlank()) return emptySet() if (bookmarksJson.isNullOrBlank()) return emptySet()
return try { return try {
@ -615,23 +777,188 @@ private data class TtsPageData(
private data class TocEntry(val title: String, val pageIndex: Int, val nestLevel: Int) private data class TocEntry(val title: String, val pageIndex: Int, val nestLevel: Int)
private fun flattenToc(bookmarks: List<PdfDocument.Bookmark>, level: Int = 0): List<TocEntry> { private fun flattenToc(bookmarks: List<Bookmark>, level: Int = 0): List<TocEntry> {
Timber.tag("PdfTocDebug").d("Processing level $level with ${bookmarks.size} items")
val entries = mutableListOf<TocEntry>() val entries = mutableListOf<TocEntry>()
for (bookmark in bookmarks) { for ((index, bookmark) in bookmarks.withIndex()) {
val title = bookmark.title ?: "Untitled Chapter"
val childCount = bookmark.children.size
Timber.tag("PdfTocDebug").d(
"Lvl $level | Item $index: \"$title\" (Page: ${bookmark.pageIdx}) | Children: $childCount"
)
entries.add( entries.add(
TocEntry( TocEntry(
title = bookmark.title ?: "Untitled Chapter", title = title,
pageIndex = bookmark.pageIdx.toInt(), pageIndex = bookmark.pageIdx.toInt(),
nestLevel = level nestLevel = level
) )
) )
if (bookmark.children.isNotEmpty()) {
if (childCount > 0) {
Timber.tag("PdfTocDebug").v("Entering children of \"$title\"")
entries.addAll(flattenToc(bookmark.children, level + 1)) entries.addAll(flattenToc(bookmark.children, level + 1))
Timber.tag("PdfTocDebug").v("Returned to Lvl $level from \"$title\"")
} }
} }
return entries return entries
} }
private data class ScrollbarCalculations(
val thumbHeight: Float,
val thumbOffset: Float,
val contentHeight: Float,
val viewportHeight: Float
)
@Composable
fun VerticalScrollbar(
listState: LazyListState,
modifier: Modifier = Modifier
) {
val interactionSource = remember { MutableInteractionSource() }
val isDragged by interactionSource.collectIsDraggedAsState()
val scrollbarState by remember {
derivedStateOf {
val layoutInfo = listState.layoutInfo
val totalItems = layoutInfo.totalItemsCount
val visibleItemsInfo = layoutInfo.visibleItemsInfo
val viewportHeight = layoutInfo.viewportSize.height.toFloat()
if (totalItems == 0 || visibleItemsInfo.isEmpty() || viewportHeight <= 0f) {
return@derivedStateOf null
}
// Estimate total height
val averageItemHeight = visibleItemsInfo.sumOf { it.size } / visibleItemsInfo.size.toFloat()
val estimatedContentHeight = (averageItemHeight * totalItems).coerceAtLeast(viewportHeight)
val viewportRatio = viewportHeight / estimatedContentHeight
if (viewportRatio >= 1f) return@derivedStateOf null
val thumbHeight = (viewportHeight * viewportRatio).coerceIn(80f, viewportHeight / 2)
val firstItemIndex = listState.firstVisibleItemIndex
val firstItemOffset = listState.firstVisibleItemScrollOffset
val currentScrollPixels = (firstItemIndex * averageItemHeight) + firstItemOffset
val maxScrollPixels = estimatedContentHeight - viewportHeight
val scrollProgress = (currentScrollPixels / maxScrollPixels).coerceIn(0f, 1f)
val trackHeight = viewportHeight - thumbHeight
val thumbOffset = trackHeight * scrollProgress
ScrollbarCalculations(
thumbHeight = thumbHeight,
thumbOffset = thumbOffset,
contentHeight = estimatedContentHeight,
viewportHeight = viewportHeight
)
}
}
val targetAlpha = if (listState.isScrollInProgress || isDragged) 1f else 0f
val alpha by animateFloatAsState(
targetValue = targetAlpha,
animationSpec = tween(durationMillis = 200),
label = "ScrollbarAlpha"
)
if (scrollbarState != null) {
val state = scrollbarState!!
val draggableState = rememberDraggableState { delta ->
val trackHeight = state.viewportHeight - state.thumbHeight
if (trackHeight > 0) {
val scrollRatio = delta / trackHeight
val totalScrollableDistance = state.contentHeight - state.viewportHeight
val scrollDelta = scrollRatio * totalScrollableDistance
listState.dispatchRawDelta(scrollDelta)
}
}
Box(
modifier = modifier
.width(30.dp)
.fillMaxHeight()
.draggable(
state = draggableState,
orientation = Orientation.Vertical,
interactionSource = interactionSource
)
) {
Box(
modifier = Modifier
.align(Alignment.TopEnd)
.graphicsLayer { translationY = state.thumbOffset }
.padding(end = 4.dp)
.width(6.dp)
.height(with(LocalDensity.current) { state.thumbHeight.toDp() })
.alpha(alpha)
.background(
color = if (isDragged) MaterialTheme.colorScheme.primary.copy(alpha = 0.8f)
else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
shape = RoundedCornerShape(100)
)
)
}
}
}
@Composable
private fun PdfTocTreeItem(
label: String,
nestLevel: Int,
isExpanded: Boolean,
hasChildren: Boolean,
isCurrent: Boolean,
onToggleExpand: () -> Unit,
onClick: () -> Unit
) {
val backgroundColor by animateColorAsState(
targetValue = if (isCurrent) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.2f) else Color.Transparent,
label = "TocItemBackground"
)
val contentColor = if (isCurrent) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface
Row(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 48.dp)
.background(backgroundColor)
.clickable(onClick = onClick)
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Spacer(modifier = Modifier.width((16 * nestLevel).dp))
Box(
modifier = Modifier
.size(40.dp)
.clickable(enabled = hasChildren, onClick = onToggleExpand),
contentAlignment = Alignment.Center
) {
if (hasChildren) {
Icon(
imageVector = if (isExpanded) Icons.Default.KeyboardArrowDown else Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = if (isExpanded) "Collapse" else "Expand",
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Text(
text = label,
style = if (nestLevel == 0) MaterialTheme.typography.bodyLarge else MaterialTheme.typography.bodyMedium,
fontWeight = if (isCurrent) FontWeight.Bold else if (nestLevel == 0) FontWeight.SemiBold else FontWeight.Normal,
color = contentColor,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f).padding(end = 16.dp)
)
}
}
@OptIn(UnstableApi::class) @OptIn(UnstableApi::class)
@Suppress("unused") @Suppress("unused")
private fun saveTtsMode(context: Context, mode: TtsPlaybackManager.TtsMode) { private fun saveTtsMode(context: Context, mode: TtsPlaybackManager.TtsMode) {
@ -644,6 +971,7 @@ private suspend fun renderPageToBitmap(doc: PdfDocumentKt, pageIndex: Int): Bitm
var page: PdfPageKt? = null var page: PdfPageKt? = null
try { try {
page = doc.openPage(pageIndex) page = doc.openPage(pageIndex)
if (page == null) return@withContext null
val bitmapWidth = 1080 val bitmapWidth = 1080
val aspectRatio = val aspectRatio =
@ -853,6 +1181,25 @@ fun PdfViewerScreen(
isAutoScrollLocal = loadPdfAutoScrollLocalMode(context, bookId) isAutoScrollLocal = loadPdfAutoScrollLocalMode(context, bookId)
} }
val onPrintDocument = {
val printManager = context.getSystemService(Context.PRINT_SERVICE) as PrintManager
val jobName = "${context.getString(R.string.app_name)} - $originalFileName"
try {
Timber.tag("PdfPrint").d("Starting print job: $jobName")
printManager.print(
jobName,
PdfPrintDocumentAdapter(context, pdfUri, originalFileName),
null
)
} catch (e: Exception) {
Timber.tag("PdfPrint").e(e, "Failed to initialize print job")
coroutineScope.launch {
snackbarHostState.showSnackbar("Could not open print settings")
}
}
}
val initialSettings = remember(isAutoScrollLocal, bookId) { val initialSettings = remember(isAutoScrollLocal, bookId) {
if (isAutoScrollLocal) { if (isAutoScrollLocal) {
loadPdfAutoScrollLocalSettings(context, bookId) ?: Triple( loadPdfAutoScrollLocalSettings(context, bookId) ?: Triple(
@ -1357,7 +1704,7 @@ fun PdfViewerScreen(
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
try { try {
doc.openPage(pageIndex).use { page -> doc.openPage(pageIndex)?.use { page ->
page.openTextPage().use { textPage -> page.openTextPage().use { textPage ->
val fullText = textPage.textPageGetText(newStart, newEnd - newStart) ?: text val fullText = textPage.textPageGetText(newStart, newEnd - newStart) ?: text
val rects = textPage.textPageGetRectsForRanges(intArrayOf(newStart, newEnd - newStart)) val rects = textPage.textPageGetRectsForRanges(intArrayOf(newStart, newEnd - newStart))
@ -1895,7 +2242,7 @@ fun PdfViewerScreen(
if (pdfDocument != null) { if (pdfDocument != null) {
try { try {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
pdfDocument!!.openPage(pageIndex).use { page -> pdfDocument!!.openPage(pageIndex)?.use { page ->
page.openTextPage().use { textPage -> page.openTextPage().use { textPage ->
val count = textPage.textPageCountChars() val count = textPage.textPageCountChars()
@ -2414,10 +2761,10 @@ fun PdfViewerScreen(
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
Timber.d("TTS: Opening page $pageToRead for Pdfium text extraction.") Timber.d("TTS: Opening page $pageToRead for Pdfium text extraction.")
tempPage = pdfDocument!!.openPage(pageToRead) tempPage = pdfDocument!!.openPage(pageToRead)
tempTextPage = tempPage.openTextPage() tempTextPage = tempPage?.openTextPage()
val charCount = tempTextPage.textPageCountChars() val charCount = tempTextPage?.textPageCountChars() ?: 0
if (charCount > 0) { if (charCount > 0) {
rawPageText = tempTextPage.textPageGetText(0, charCount)?.trim() rawPageText = tempTextPage?.textPageGetText(0, charCount)?.trim()
if (rawPageText.isNullOrBlank()) { if (rawPageText.isNullOrBlank()) {
Timber.d( Timber.d(
"TTS: Pdfium extracted text but it's blank (charCount: $charCount)." "TTS: Pdfium extracted text but it's blank (charCount: $charCount)."
@ -2772,6 +3119,17 @@ fun PdfViewerScreen(
pdfDocument = doc pdfDocument = doc
pfdState = currentPfdOpened pfdState = currentPfdOpened
val pagesCount = doc.getPageCount() val pagesCount = doc.getPageCount()
if (pagesCount > 0) {
try {
val tableOfContents = doc.getFixedTableOfContents()
val flattened = flattenToc(tableOfContents)
withContext(Dispatchers.Main) { flatTableOfContents = flattened }
} catch (e: Exception) {
Timber.w(e, "Failed to load TOC")
}
}
totalPages = pagesCount totalPages = pagesCount
if (pagesCount > 0) { if (pagesCount > 0) {
@ -2782,7 +3140,7 @@ fun PdfViewerScreen(
cachedRatios cachedRatios
} else { } else {
val computedRatios = ArrayList<Float>(pagesCount) val computedRatios = ArrayList<Float>(pagesCount)
doc.openPage(0).use { page -> doc.openPage(0)?.use { page ->
val width = page.getPageWidthPoint() val width = page.getPageWidthPoint()
val height = page.getPageHeightPoint() val height = page.getPageHeightPoint()
val ratio = if (height > 0) width.toFloat() / height.toFloat() val ratio = if (height > 0) width.toFloat() / height.toFloat()
@ -2797,7 +3155,7 @@ fun PdfViewerScreen(
for (i in 0 until pagesCount) { for (i in 0 until pagesCount) {
if (!isActive) break if (!isActive) break
try { try {
doc.openPage(i).use { page -> doc.openPage(i)?.use { page ->
val width = page.getPageWidthPoint() val width = page.getPageWidthPoint()
val height = page.getPageHeightPoint() val height = page.getPageHeightPoint()
val ratio = val ratio =
@ -2843,7 +3201,7 @@ fun PdfViewerScreen(
for (i in 1 until pagesCount) { for (i in 1 until pagesCount) {
if (!isActive) break if (!isActive) break
try { try {
doc.openPage(i).use { page -> doc.openPage(i)?.use { page ->
val width = page.getPageWidthPoint() val width = page.getPageWidthPoint()
val height = page.getPageHeightPoint() val height = page.getPageHeightPoint()
val ratio = if (height > 0) width.toFloat() / height.toFloat() val ratio = if (height > 0) width.toFloat() / height.toFloat()
@ -2866,16 +3224,6 @@ fun PdfViewerScreen(
} }
} }
} }
launch(Dispatchers.IO) {
try {
val tableOfContents = doc.getTableOfContents()
val flattened = flattenToc(tableOfContents)
withContext(Dispatchers.Main) { flatTableOfContents = flattened }
} catch (e: Exception) {
Timber.w(e, "Failed to load TOC")
}
}
} else { } else {
isDocumentReady = true isDocumentReady = true
isLoadingDocument = false isLoadingDocument = false
@ -2884,7 +3232,7 @@ fun PdfViewerScreen(
Timber.i("PDF document loaded optimistically. Total Pages: $totalPages.") Timber.i("PDF document loaded optimistically. Total Pages: $totalPages.")
} }
} catch (e: Exception) { } catch (e: Exception) {
if (e is PdfPasswordException || e.cause is PdfPasswordException) { if (e.javaClass.name.contains("PasswordException") || e.cause?.javaClass?.name?.contains("PasswordException") == true) {
Timber.w("PDF is password protected or password incorrect.") Timber.w("PDF is password protected or password incorrect.")
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
if (documentPassword != null) { if (documentPassword != null) {
@ -3358,9 +3706,7 @@ fun PdfViewerScreen(
0 -> { // Chapters Page 0 -> { // Chapters Page
if (flatTableOfContents.isEmpty()) { if (flatTableOfContents.isEmpty()) {
Box( Box(
modifier = Modifier modifier = Modifier.fillMaxSize().padding(16.dp),
.fillMaxSize()
.padding(16.dp),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Text( Text(
@ -3370,54 +3716,99 @@ fun PdfViewerScreen(
) )
} }
} else { } else {
val currentTocEntry by remember( val listState = rememberLazyListState()
pagerState.currentPage, flatTableOfContents
) { val allParentIndices = remember(flatTableOfContents) {
derivedStateOf { flatTableOfContents.indices.filter { i ->
flatTableOfContents.lastOrNull { val next = flatTableOfContents.getOrNull(i + 1)
it.pageIndex <= pagerState.currentPage next != null && next.nestLevel > flatTableOfContents[i].nestLevel
}.toSet()
} }
var expandedEntryIndices by rememberSaveable(flatTableOfContents) {
mutableStateOf(allParentIndices)
} }
val visibleItemInfo = remember(flatTableOfContents, expandedEntryIndices) {
val result = mutableListOf<Pair<Int, TocEntry>>()
val visibilityStack = BooleanArray(20) { false }
visibilityStack[0] = true
for (i in flatTableOfContents.indices) {
val entry = flatTableOfContents[i]
val level = entry.nestLevel.coerceIn(0, 19)
if (visibilityStack[level]) {
result.add(i to entry)
val isExpanded = expandedEntryIndices.contains(i)
if (level + 1 < visibilityStack.size) {
visibilityStack[level + 1] = isExpanded
} }
LazyColumn(modifier = Modifier.fillMaxHeight()) {
itemsIndexed(
items = flatTableOfContents, key = { index, entry ->
"toc_${index}_${entry.pageIndex}_${entry.title.hashCode()}"
}) { _, entry ->
val isCurrentChapter = entry == currentTocEntry
ListItem(
headlineContent = {
Text(
entry.title,
fontWeight = if (isCurrentChapter) FontWeight.Bold
else FontWeight.Normal,
modifier = Modifier.padding(
start = (16 * entry.nestLevel).dp
)
)
}, colors = if (isCurrentChapter) {
ListItemDefaults.colors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
headlineColor = MaterialTheme.colorScheme.onPrimaryContainer
)
} else { } else {
ListItemDefaults.colors() if (level + 1 < visibilityStack.size) {
}, modifier = Modifier.clickable { visibilityStack[level + 1] = false
}
}
}
result
}
val currentTocEntry by remember(pagerState.currentPage, verticalReaderState.currentPage, displayMode, flatTableOfContents) {
derivedStateOf {
val activePage = if (displayMode == DisplayMode.PAGINATION) pagerState.currentPage else verticalReaderState.currentPage
flatTableOfContents.lastOrNull { it.pageIndex <= activePage }
}
}
Box(modifier = Modifier.fillMaxSize()) {
LazyColumn(
state = listState,
modifier = Modifier
.fillMaxHeight()
.padding(end = 12.dp)
) {
items(
items = visibleItemInfo,
key = { it.second.title + it.first }
) { item ->
val (originalIndex, entry) = item
val nextItem = flatTableOfContents.getOrNull(originalIndex + 1)
val hasChildren = nextItem != null && nextItem.nestLevel > entry.nestLevel
val isExpanded = expandedEntryIndices.contains(originalIndex)
val isCurrentChapter = entry == currentTocEntry
PdfTocTreeItem(
label = entry.title,
nestLevel = entry.nestLevel,
isExpanded = isExpanded,
hasChildren = hasChildren,
isCurrent = isCurrentChapter,
onToggleExpand = {
expandedEntryIndices = if (isExpanded) {
expandedEntryIndices - originalIndex
} else {
expandedEntryIndices + originalIndex
}
},
onClick = {
coroutineScope.launch { coroutineScope.launch {
drawerState.close() drawerState.close()
if (displayMode == DisplayMode.PAGINATION) { if (displayMode == DisplayMode.PAGINATION) {
pagerState.scrollToPage( pagerState.scrollToPage(entry.pageIndex)
entry.pageIndex
)
} else { } else {
verticalReaderState.scrollToPage( verticalReaderState.scrollToPage(entry.pageIndex)
entry.pageIndex }
}
}
) )
} }
} }
})
HorizontalDivider() VerticalScrollbar(
} listState = listState,
modifier = Modifier.align(Alignment.CenterEnd)
)
} }
} }
} }
@ -3767,7 +4158,13 @@ fun PdfViewerScreen(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
key = { it }, key = { it },
beyondViewportPageCount = dynamicBeyondViewportPageCount, beyondViewportPageCount = dynamicBeyondViewportPageCount,
userScrollEnabled= currentPageScale == 1f && !(ttsState.isPlaying || ttsState.isLoading || searchState.isSearchActive) && !isPageSliderVisible && paginationDraggingBoxId == null userScrollEnabled = run {
val enabled = currentPageScale == 1f && !(ttsState.isPlaying || ttsState.isLoading || searchState.isSearchActive) && !isPageSliderVisible && paginationDraggingBoxId == null
SideEffect {
Timber.tag("PdfZoomDebug").v("Pager Scroll Enabled: $enabled (Scale: $currentPageScale, Playing: ${ttsState.isPlaying}, Slider: $isPageSliderVisible, DraggingBox: $paginationDraggingBoxId)")
}
enabled
}
) { pageIndex -> ) { pageIndex ->
val isVisiblePage = remember(pagerState.currentPage, pageIndex) { val isVisiblePage = remember(pagerState.currentPage, pageIndex) {
kotlin.math.abs(pagerState.currentPage - pageIndex) <= 1 kotlin.math.abs(pagerState.currentPage - pageIndex) <= 1
@ -5221,6 +5618,20 @@ fun PdfViewerScreen(
Icons.Default.Save, contentDescription = null Icons.Default.Save, contentDescription = null
) )
}) })
DropdownMenuItem(
text = { Text("Print") },
onClick = {
showMoreMenu = false
onPrintDocument()
},
leadingIcon = {
Icon(
painter = painterResource(id = R.drawable.print),
contentDescription = null,
modifier = Modifier.size(20.dp)
)
}
)
} }
} }
} }
@ -6929,6 +7340,7 @@ private fun debugPdfLinks(
if (pageCount > 0) { if (pageCount > 0) {
val pageIndex = 0 // Testing the first page val pageIndex = 0 // Testing the first page
page = doc.openPage(pageIndex) page = doc.openPage(pageIndex)
if (page == null) return@launch
Timber.d("Opened page $pageIndex") Timber.d("Opened page $pageIndex")
Timber.d( Timber.d(
@ -6957,7 +7369,7 @@ private fun debugPdfLinks(
// Method 2: The one that is working // Method 2: The one that is working
page.openTextPage().use { textPage -> page.openTextPage().use { textPage ->
textPage.loadWebLink().use { webLinks -> textPage.loadWebLink()?.use { webLinks ->
val webLinkCount = webLinks.countWebLinks() val webLinkCount = webLinks.countWebLinks()
Timber.d("[METHOD 2] loadWebLink() found $webLinkCount links.") Timber.d("[METHOD 2] loadWebLink() found $webLinkCount links.")
if (webLinkCount > 0) { if (webLinkCount > 0) {

View file

@ -171,7 +171,7 @@ class PdfTextRepository(context: Context) {
var ocrUsed = false var ocrUsed = false
try { try {
document.openPage(pageIndex).use { page -> document.openPage(pageIndex)?.use { page ->
page.openTextPage().use { textPage -> page.openTextPage().use { textPage ->
val count = textPage.textPageCountChars() val count = textPage.textPageCountChars()
if (count > 0) { if (count > 0) {
@ -188,7 +188,7 @@ class PdfTextRepository(context: Context) {
if (text.isBlank()) { if (text.isBlank()) {
try { try {
document.openPage(pageIndex).use { page -> document.openPage(pageIndex)?.use { page ->
val targetWidth = 1080 val targetWidth = 1080
val ptrWidth = page.getPageWidthPoint() val ptrWidth = page.getPageWidthPoint()
val ptrHeight = page.getPageHeightPoint() val ptrHeight = page.getPageHeightPoint()
@ -270,11 +270,11 @@ class PdfTextRepository(context: Context) {
suspend fun hasNativeText(document: PdfDocumentKt, pageIndex: Int): Boolean { suspend fun hasNativeText(document: PdfDocumentKt, pageIndex: Int): Boolean {
return withContext(Dispatchers.IO) { return withContext(Dispatchers.IO) {
try { try {
document.openPage(pageIndex).use { page -> document.openPage(pageIndex)?.use { page ->
page.openTextPage().use { textPage -> page.openTextPage().use { textPage ->
textPage.textPageCountChars() > 0 textPage.textPageCountChars() > 0
} }
} } ?: false
} catch (_: Exception) { } catch (_: Exception) {
false false
} }
@ -290,7 +290,7 @@ class PdfTextRepository(context: Context) {
return withContext(Dispatchers.IO) { return withContext(Dispatchers.IO) {
val rects = mutableListOf<RectF>() val rects = mutableListOf<RectF>()
try { try {
document.openPage(pageIndex).use { page -> document.openPage(pageIndex)?.use { page ->
val targetWidth = 1080 val targetWidth = 1080
val ptrWidth = page.getPageWidthPoint() val ptrWidth = page.getPageWidthPoint()
val ptrHeight = page.getPageHeightPoint() val ptrHeight = page.getPageHeightPoint()

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M640,320L640,200L320,200L320,320L240,320L240,120L720,120L720,320L640,320ZM160,400L160,400Q160,400 171.5,400Q183,400 200,400L760,400Q777,400 788.5,400Q800,400 800,400L800,400L720,400L720,400L240,400L240,400L160,400ZM720,500Q737,500 748.5,488.5Q760,477 760,460Q760,443 748.5,431.5Q737,420 720,420Q703,420 691.5,431.5Q680,443 680,460Q680,477 691.5,488.5Q703,500 720,500ZM640,760L640,600L320,600L320,760L640,760ZM720,840L240,840L240,680L80,680L80,440Q80,389 115,354.5Q150,320 200,320L760,320Q811,320 845.5,354.5Q880,389 880,440L880,680L720,680L720,840ZM800,600L800,440Q800,423 788.5,411.5Q777,400 760,400L200,400Q183,400 171.5,411.5Q160,423 160,440L160,600L240,600L240,520L720,520L720,600L800,600Z"/>
</vector>